From 9af61b9fbe4466f174f68c066ebec15d7b7b0a19 Mon Sep 17 00:00:00 2001 From: Odai Ahmad Date: Wed, 23 Sep 2026 01:24:08 +0300 Subject: [PATCH 1/7] feat(pieces): add AI-agent atomics to wordpress, woocommerce, imap and google-my-business (#15722) --- bun.lock | 8 +- .../community/google-my-business/package.json | 2 +- .../src/i18n/translation.json | 49 +- .../community/google-my-business/src/index.ts | 23 + .../src/lib/actions/create-reply.ts | 2 +- .../src/lib/actions/delete-review-reply.ts | 33 ++ .../get-location-verification-state.ts | 31 ++ .../src/lib/actions/get-location.ts | 32 ++ .../src/lib/actions/get-media.ts | 39 ++ .../src/lib/actions/get-review.ts | 32 ++ .../src/lib/actions/list-accounts.ts | 41 ++ .../src/lib/actions/list-locations.ts | 44 ++ .../src/lib/actions/list-media.ts | 46 ++ .../src/lib/actions/list-reviews.ts | 60 +++ .../src/lib/actions/reply-to-review.ts | 38 ++ .../src/lib/actions/update-location.ts | 72 +++ .../src/lib/common/client.ts | 192 ++++++++ .../src/lib/output-schemas.ts | 200 ++++++++ .../src/lib/triggers/new-review.ts | 14 +- packages/pieces/community/imap/package.json | 2 +- .../community/imap/src/i18n/translation.json | 107 ++++- packages/pieces/community/imap/src/index.ts | 32 +- .../imap/src/lib/actions/archive-emails.ts | 65 +++ .../imap/src/lib/actions/copy-email.ts | 2 +- .../imap/src/lib/actions/copy-emails.ts | 55 +++ .../imap/src/lib/actions/create-draft.ts | 120 +++++ .../imap/src/lib/actions/create-folder.ts | 69 +++ .../imap/src/lib/actions/delete-email.ts | 4 +- .../imap/src/lib/actions/delete-emails.ts | 77 +++ .../imap/src/lib/actions/delete-folder.ts | 61 +++ .../imap/src/lib/actions/get-email.ts | 112 +++++ .../imap/src/lib/actions/get-folder-status.ts | 56 +++ .../imap/src/lib/actions/get-quota.ts | 70 +++ .../imap/src/lib/actions/list-folders.ts | 52 ++ .../imap/src/lib/actions/mark-email-read.ts | 4 +- .../imap/src/lib/actions/move-email.ts | 2 +- .../imap/src/lib/actions/move-emails.ts | 55 +++ .../imap/src/lib/actions/rename-folder.ts | 69 +++ .../imap/src/lib/actions/search-emails.ts | 169 +++++++ .../imap/src/lib/actions/trash-emails.ts | 67 +++ .../src/lib/actions/update-email-flags.ts | 207 ++++++++ .../community/imap/src/lib/common/imap.ts | 84 +++- .../community/imap/src/lib/common/index.ts | 3 + .../community/imap/src/lib/common/message.ts | 451 ++++++++++++++++++ .../community/imap/src/lib/common/mime.ts | 198 ++++++++ .../imap/src/lib/common/special-use.ts | 56 +++ .../community/imap/src/lib/output-schemas.ts | 269 ++++++++++- .../pieces/community/woocommerce/package.json | 2 +- .../woocommerce/src/i18n/translation.json | 303 +++++++++++- .../pieces/community/woocommerce/src/index.ts | 80 +++- .../src/lib/actions/add-order-note.ts | 2 +- .../src/lib/actions/ai/add-order-note.ts | 21 + .../lib/actions/ai/batch-update-products.ts | 95 ++++ .../src/lib/actions/ai/create-coupon.ts | 132 +++++ .../src/lib/actions/ai/create-customer.ts | 110 +++++ .../src/lib/actions/ai/create-order-refund.ts | 62 +++ .../src/lib/actions/ai/create-order.ts | 21 + .../lib/actions/ai/create-product-category.ts | 57 +++ .../src/lib/actions/ai/create-product-tag.ts | 51 ++ .../src/lib/actions/ai/create-product.ts | 117 +++++ .../src/lib/actions/ai/delete-coupon.ts | 38 ++ .../src/lib/actions/ai/delete-customer.ts | 71 +++ .../src/lib/actions/ai/delete-order-note.ts | 41 ++ .../src/lib/actions/ai/delete-order.ts | 38 ++ .../src/lib/actions/ai/delete-product.ts | 38 ++ .../src/lib/actions/ai/get-coupon.ts | 21 + .../src/lib/actions/ai/get-customer.ts | 21 + .../lib/actions/ai/get-order-totals-report.ts | 28 ++ .../src/lib/actions/ai/get-order.ts | 21 + .../lib/actions/ai/get-product-variation.ts | 40 ++ .../src/lib/actions/ai/get-product.ts | 21 + .../src/lib/actions/ai/get-sales-report.ts | 31 ++ .../lib/actions/ai/get-top-sellers-report.ts | 31 ++ .../src/lib/actions/ai/list-coupons.ts | 61 +++ .../src/lib/actions/ai/list-customers.ts | 90 ++++ .../src/lib/actions/ai/list-order-notes.ts | 48 ++ .../src/lib/actions/ai/list-order-refunds.ts | 40 ++ .../src/lib/actions/ai/list-orders.ts | 90 ++++ .../lib/actions/ai/list-payment-gateways.ts | 43 ++ .../lib/actions/ai/list-product-categories.ts | 62 +++ .../lib/actions/ai/list-product-reviews.ts | 71 +++ .../src/lib/actions/ai/list-product-tags.ts | 56 +++ .../lib/actions/ai/list-product-variations.ts | 52 ++ .../src/lib/actions/ai/list-products.ts | 116 +++++ .../src/lib/actions/ai/update-coupon.ts | 21 + .../src/lib/actions/ai/update-customer.ts | 21 + .../src/lib/actions/ai/update-order.ts | 21 + .../lib/actions/ai/update-product-review.ts | 51 ++ .../actions/ai/update-product-variation.ts | 96 ++++ .../src/lib/actions/ai/update-product.ts | 136 ++++++ .../src/lib/actions/create-coupon.ts | 2 +- .../src/lib/actions/create-customer.ts | 2 +- .../src/lib/actions/create-order.ts | 23 +- .../src/lib/actions/create-product.ts | 2 +- .../src/lib/actions/find-coupon.ts | 2 +- .../src/lib/actions/find-customer.ts | 2 +- .../src/lib/actions/find-orders.ts | 2 +- .../src/lib/actions/find-product.ts | 2 +- .../src/lib/actions/get-customer.ts | 2 +- .../woocommerce/src/lib/actions/get-order.ts | 2 +- .../src/lib/actions/update-coupon.ts | 2 +- .../src/lib/actions/update-customer.ts | 2 +- .../src/lib/actions/update-order.ts | 2 +- .../src/lib/actions/update-product.ts | 2 +- .../woocommerce/src/lib/common/client.ts | 139 ++++++ .../woocommerce/src/lib/common/props.ts | 253 ++++++++++ .../woocommerce/src/lib/output-schemas.ts | 195 ++++++++ .../pieces/community/wordpress/package.json | 2 +- .../wordpress/src/i18n/translation.json | 261 +++++++++- .../pieces/community/wordpress/src/index.ts | 60 +++ .../lib/actions/create-blog-post.action.ts | 36 ++ .../src/lib/actions/create-category.action.ts | 58 +++ .../src/lib/actions/create-comment.action.ts | 57 +++ .../src/lib/actions/create-page.action.ts | 2 +- .../src/lib/actions/create-post.action.ts | 2 +- .../lib/actions/create-site-page.action.ts | 36 ++ .../src/lib/actions/create-tag.action.ts | 47 ++ .../src/lib/actions/delete-category.action.ts | 31 ++ .../src/lib/actions/delete-comment.action.ts | 31 ++ .../src/lib/actions/delete-media.action.ts | 31 ++ .../actions/delete-post-permanently.action.ts | 31 ++ .../src/lib/actions/delete-tag.action.ts | 31 ++ .../lib/actions/get-current-user.action.ts | 30 ++ .../src/lib/actions/get-media.action.ts | 37 ++ .../src/lib/actions/get-page.action.ts | 37 ++ .../src/lib/actions/get-post-by-id.action.ts | 37 ++ .../src/lib/actions/get-post.action.ts | 2 +- .../lib/actions/get-site-settings.action.ts | 29 ++ .../src/lib/actions/list-categories.action.ts | 82 ++++ .../src/lib/actions/list-comments.action.ts | 101 ++++ .../src/lib/actions/list-media.action.ts | 81 ++++ .../src/lib/actions/list-pages.action.ts | 107 +++++ .../src/lib/actions/list-posts.action.ts | 140 ++++++ .../src/lib/actions/list-tags.action.ts | 72 +++ .../src/lib/actions/list-users.action.ts | 54 +++ .../lib/actions/moderate-comment.action.ts | 90 ++++ .../lib/actions/search-site-content.action.ts | 87 ++++ .../src/lib/actions/trash-page.action.ts | 31 ++ .../src/lib/actions/trash-post.action.ts | 31 ++ .../lib/actions/update-blog-post.action.ts | 42 ++ .../actions/update-media-details.action.ts | 65 +++ .../src/lib/actions/update-page.action.ts | 42 ++ .../src/lib/actions/update-post.action.ts | 2 +- .../src/lib/actions/upload-media.action.ts | 69 +++ .../wordpress/src/lib/common/client.ts | 308 ++++++++++++ .../wordpress/src/lib/common/content-body.ts | 437 +++++++++++++++++ .../wordpress/src/lib/output-schemas.ts | 415 ++++++++++++++-- .../src/lib/trigger/new-post.trigger.ts | 26 +- 148 files changed, 9714 insertions(+), 136 deletions(-) create mode 100644 packages/pieces/community/google-my-business/src/lib/actions/delete-review-reply.ts create mode 100644 packages/pieces/community/google-my-business/src/lib/actions/get-location-verification-state.ts create mode 100644 packages/pieces/community/google-my-business/src/lib/actions/get-location.ts create mode 100644 packages/pieces/community/google-my-business/src/lib/actions/get-media.ts create mode 100644 packages/pieces/community/google-my-business/src/lib/actions/get-review.ts create mode 100644 packages/pieces/community/google-my-business/src/lib/actions/list-accounts.ts create mode 100644 packages/pieces/community/google-my-business/src/lib/actions/list-locations.ts create mode 100644 packages/pieces/community/google-my-business/src/lib/actions/list-media.ts create mode 100644 packages/pieces/community/google-my-business/src/lib/actions/list-reviews.ts create mode 100644 packages/pieces/community/google-my-business/src/lib/actions/reply-to-review.ts create mode 100644 packages/pieces/community/google-my-business/src/lib/actions/update-location.ts create mode 100644 packages/pieces/community/google-my-business/src/lib/common/client.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/archive-emails.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/copy-emails.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/create-draft.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/create-folder.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/delete-emails.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/delete-folder.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/get-email.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/get-folder-status.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/get-quota.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/list-folders.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/move-emails.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/rename-folder.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/search-emails.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/trash-emails.ts create mode 100644 packages/pieces/community/imap/src/lib/actions/update-email-flags.ts create mode 100644 packages/pieces/community/imap/src/lib/common/message.ts create mode 100644 packages/pieces/community/imap/src/lib/common/mime.ts create mode 100644 packages/pieces/community/imap/src/lib/common/special-use.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/add-order-note.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/batch-update-products.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/create-coupon.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/create-customer.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/create-order-refund.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/create-order.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/create-product-category.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/create-product-tag.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/create-product.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/delete-coupon.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/delete-customer.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/delete-order-note.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/delete-order.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/delete-product.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/get-coupon.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/get-customer.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/get-order-totals-report.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/get-order.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/get-product-variation.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/get-product.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/get-sales-report.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/get-top-sellers-report.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/list-coupons.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/list-customers.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/list-order-notes.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/list-order-refunds.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/list-orders.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/list-payment-gateways.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-categories.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-reviews.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-tags.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-variations.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/list-products.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/update-coupon.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/update-customer.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/update-order.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/update-product-review.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/update-product-variation.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/actions/ai/update-product.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/common/client.ts create mode 100644 packages/pieces/community/woocommerce/src/lib/common/props.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/create-blog-post.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/create-category.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/create-comment.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/create-site-page.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/create-tag.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/delete-category.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/delete-comment.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/delete-media.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/delete-post-permanently.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/delete-tag.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/get-current-user.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/get-media.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/get-page.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/get-post-by-id.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/get-site-settings.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/list-categories.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/list-comments.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/list-media.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/list-pages.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/list-posts.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/list-tags.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/list-users.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/moderate-comment.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/search-site-content.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/trash-page.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/trash-post.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/update-blog-post.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/update-media-details.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/update-page.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/actions/upload-media.action.ts create mode 100644 packages/pieces/community/wordpress/src/lib/common/client.ts create mode 100644 packages/pieces/community/wordpress/src/lib/common/content-body.ts diff --git a/bun.lock b/bun.lock index 21147384caa7..7a7ceaab3ac8 100644 --- a/bun.lock +++ b/bun.lock @@ -3886,7 +3886,7 @@ }, "packages/pieces/community/google-my-business": { "name": "@activepieces/piece-google-my-business", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -4457,7 +4457,7 @@ }, "packages/pieces/community/imap": { "name": "@activepieces/piece-imap", - "version": "0.4.9", + "version": "0.5.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9859,7 +9859,7 @@ }, "packages/pieces/community/woocommerce": { "name": "@activepieces/piece-woocommerce", - "version": "0.3.0", + "version": "0.4.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -9899,7 +9899,7 @@ }, "packages/pieces/community/wordpress": { "name": "@activepieces/piece-wordpress", - "version": "0.5.0", + "version": "0.7.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/community/google-my-business/package.json b/packages/pieces/community/google-my-business/package.json index da8e0f2927ae..61647d729d72 100644 --- a/packages/pieces/community/google-my-business/package.json +++ b/packages/pieces/community/google-my-business/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-google-my-business", - "version": "0.3.0", + "version": "0.4.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/google-my-business/src/i18n/translation.json b/packages/pieces/community/google-my-business/src/i18n/translation.json index 3864bed725d4..dd7937609e80 100644 --- a/packages/pieces/community/google-my-business/src/i18n/translation.json +++ b/packages/pieces/community/google-my-business/src/i18n/translation.json @@ -32,5 +32,50 @@ "New Review": "New Review", "Triggers when there is new review": "Triggers when there is new review", "Account": "Account", - "Location": "Location" -} \ No newline at end of file + "Location": "Location", + "List Accounts": "List Accounts", + "Lists the Business Profile accounts the connected user can access.": "Lists the Business Profile accounts the connected user can access.", + "List Locations": "List Locations", + "Lists the business locations of an account.": "Lists the business locations of an account.", + "Get Location": "Get Location", + "Gets the details of a business location.": "Gets the details of a business location.", + "Update Location": "Update Location", + "Updates the website, labels or store code of a business location.": "Updates the website, labels or store code of a business location.", + "Website": "Website", + "The public website URL of the business.": "The public website URL of the business.", + "Labels": "Labels", + "Store Code": "Store Code", + "Your internal identifier for the location.": "Your internal identifier for the location.", + "List Reviews": "List Reviews", + "Lists the customer reviews of a location.": "Lists the customer reviews of a location.", + "Order By": "Order By", + "Sort order. Google defaults to newest first when unset.": "Sort order. Google defaults to newest first when unset.", + "Newest first": "Newest first", + "Lowest rating first": "Lowest rating first", + "Highest rating first": "Highest rating first", + "Get Location Verification State": "Get Location Verification State", + "Gets whether a location is verified and can publish changes.": "Gets whether a location is verified and can publish changes.", + "List Media": "List Media", + "Lists the photos and videos of a location.": "Lists the photos and videos of a location.", + "Get Media": "Get Media", + "Gets one photo or video of a location.": "Gets one photo or video of a location.", + "Media ID": "Media ID", + "The media key: the last segment of the name field returned by List Media (list-media).": "The media key: the last segment of the name field returned by List Media (list-media).", + "Get Review": "Get Review", + "Gets one customer review of a location.": "Gets one customer review of a location.", + "Reply to Review": "Reply to Review", + "Creates or replaces the owner reply to a review.": "Creates or replaces the owner reply to a review.", + "The reply text.": "The reply text.", + "Delete Review Reply": "Delete Review Reply", + "Deletes the owner reply to a review.": "Deletes the owner reply to a review.", + "Account ID": "Account ID", + "The account id, e.g. 123 or accounts/123. Get it from the name field returned by List Accounts (list-accounts).": "The account id, e.g. 123 or accounts/123. Get it from the name field returned by List Accounts (list-accounts).", + "Location ID": "Location ID", + "The location id, e.g. 456 or locations/456. Get it from the name field returned by List Locations (list-locations).": "The location id, e.g. 456 or locations/456. Get it from the name field returned by List Locations (list-locations).", + "Review ID": "Review ID", + "The reviewId field returned by List Reviews (list-reviews), or the full review name.": "The reviewId field returned by List Reviews (list-reviews), or the full review name.", + "Maximum Results": "Maximum Results", + "Stop after this many items. Larger values are fetched over several requests.": "Stop after this many items. Larger values are fetched over several requests.", + "Internal labels for the location. Replaces the existing labels. Leave empty to keep the current labels; this action cannot clear them.": "Internal labels for the location. Replaces the existing labels. Leave empty to keep the current labels; this action cannot clear them.", + "Updates selected fields of a Google Business Profile location; only the fields you supply are changed and everything else is left untouched. Website is public and appears on Google Maps and Search, so change it only when the user explicitly asks. Labels replaces the whole label set, so include every label you want to keep; an empty list is ignored and cannot clear them. Store Code is internal. Empty text fields are ignored. Description, phone, hours, address and categories cannot be edited here. Safe to repeat with the same values.": "Updates selected fields of a Google Business Profile location; only the fields you supply are changed and everything else is left untouched. Website is public and appears on Google Maps and Search, so change it only when the user explicitly asks. Labels replaces the whole label set, so include every label you want to keep; an empty list is ignored and cannot clear them. Store Code is internal. Empty text fields are ignored. Description, phone, hours, address and categories cannot be edited here. Safe to repeat with the same values." +} diff --git a/packages/pieces/community/google-my-business/src/index.ts b/packages/pieces/community/google-my-business/src/index.ts index f27174a65472..b4e5f18d5e78 100644 --- a/packages/pieces/community/google-my-business/src/index.ts +++ b/packages/pieces/community/google-my-business/src/index.ts @@ -11,6 +11,17 @@ import { deletePost } from './lib/actions/delete-post'; import { getPost } from './lib/actions/get-post'; import { listPosts } from './lib/actions/list-posts'; import { updatePost } from './lib/actions/update-post'; +import { deleteReviewReply } from './lib/actions/delete-review-reply'; +import { getLocation } from './lib/actions/get-location'; +import { getLocationVerificationState } from './lib/actions/get-location-verification-state'; +import { getMedia } from './lib/actions/get-media'; +import { getReview } from './lib/actions/get-review'; +import { listAccounts } from './lib/actions/list-accounts'; +import { listLocations } from './lib/actions/list-locations'; +import { listMedia } from './lib/actions/list-media'; +import { listReviews } from './lib/actions/list-reviews'; +import { replyToReview } from './lib/actions/reply-to-review'; +import { updateLocation } from './lib/actions/update-location'; import { newReview } from './lib/triggers/new-review'; export const googleAuth = PieceAuth.OAuth2({ @@ -28,6 +39,7 @@ export const googleBusiness = createPiece({ logoUrl: 'https://cdn.activepieces.com/pieces/google-business.png', authors: ["kishanprmr","MoShizzle","abuaboud"], categories: [PieceCategory.MARKETING], + minimumSupportedRelease: '0.88.2', actions: [ createPost, listPosts, @@ -35,6 +47,17 @@ export const googleBusiness = createPiece({ updatePost, deletePost, createReply, + listAccounts, + listLocations, + getLocation, + updateLocation, + listReviews, + getLocationVerificationState, + listMedia, + getMedia, + getReview, + replyToReview, + deleteReviewReply, createCustomApiCallAction({ baseUrl: () => { return 'https://mybusiness.googleapis.com/v4'; diff --git a/packages/pieces/community/google-my-business/src/lib/actions/create-reply.ts b/packages/pieces/community/google-my-business/src/lib/actions/create-reply.ts index 6b20c219a54a..2ef5c1cdafd9 100644 --- a/packages/pieces/community/google-my-business/src/lib/actions/create-reply.ts +++ b/packages/pieces/community/google-my-business/src/lib/actions/create-reply.ts @@ -11,7 +11,7 @@ export const createReply = createAction({ classification: 'WRITE', displayName: 'Create or Update Reply', description: 'Create or update a reply to a review if it already exists', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Posts the business owner\'s reply to a specific Google Business Profile review, or overwrites the existing reply if one is already present. Use to respond to customer reviews; safe to repeat since it upserts on the review name. Requires the full review resource name in the form accounts/{account}/locations/{location}/reviews/{review}.', idempotent: true }, props: { reviewName: Property.ShortText({ diff --git a/packages/pieces/community/google-my-business/src/lib/actions/delete-review-reply.ts b/packages/pieces/community/google-my-business/src/lib/actions/delete-review-reply.ts new file mode 100644 index 000000000000..49aa6a9104cd --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/actions/delete-review-reply.ts @@ -0,0 +1,33 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { googleAuth } from '../..'; +import { gmbApi } from '../common/client'; +import { deleteReviewReplyActionOutputSchema } from '../output-schemas'; + +export const deleteReviewReply = createAction({ + name: 'delete-review-reply', + outputSchema: deleteReviewReplyActionOutputSchema, + classification: 'DESTRUCTIVE', + displayName: 'Delete Review Reply', + description: 'Deletes the owner reply to a review.', + audience: 'ai', + aiMetadata: { + description: + 'Removes the business owner reply from a customer review of a Google Business Profile location. The review itself is not deleted. Use only when the user asks to take a reply down.', + idempotent: false, + }, + auth: googleAuth, + props: { + account_id: gmbApi.props.accountId(), + location_id: gmbApi.props.locationId(), + review_id: gmbApi.props.reviewId(), + }, + async run(ctx) { + await gmbApi.request({ + accessToken: ctx.auth.access_token, + method: HttpMethod.DELETE, + url: `${gmbApi.resourceNames.reviewUrl(ctx.propsValue)}/reply`, + }); + return { success: true }; + }, +}); diff --git a/packages/pieces/community/google-my-business/src/lib/actions/get-location-verification-state.ts b/packages/pieces/community/google-my-business/src/lib/actions/get-location-verification-state.ts new file mode 100644 index 000000000000..3302d9ba17a6 --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/actions/get-location-verification-state.ts @@ -0,0 +1,31 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { googleAuth } from '../..'; +import { gmbApi } from '../common/client'; +import { getLocationVerificationStateActionOutputSchema } from '../output-schemas'; + +export const getLocationVerificationState = createAction({ + name: 'get-location-verification-state', + outputSchema: getLocationVerificationStateActionOutputSchema, + classification: 'READ', + displayName: 'Get Location Verification State', + description: 'Gets whether a location is verified and can publish changes.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the Voice of Merchant state of a Google Business Profile location: whether the owner has control (hasVoiceOfMerchant), whether a verification is pending, and what to do otherwise. Use to explain why edits, posts or metrics fail on an unverified location. Read-only and safe to repeat.', + idempotent: true, + }, + auth: googleAuth, + props: { + location_id: gmbApi.props.locationId(), + }, + async run(ctx) { + const location = gmbApi.resourceNames.v1Location(ctx.propsValue.location_id); + return gmbApi.request>({ + accessToken: ctx.auth.access_token, + method: HttpMethod.GET, + url: `${gmbApi.hosts.verifications}/${location}/VoiceOfMerchantState`, + }); + }, +}); diff --git a/packages/pieces/community/google-my-business/src/lib/actions/get-location.ts b/packages/pieces/community/google-my-business/src/lib/actions/get-location.ts new file mode 100644 index 000000000000..a05c7c0df861 --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/actions/get-location.ts @@ -0,0 +1,32 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { googleAuth } from '../..'; +import { gmbApi } from '../common/client'; +import { getLocationActionOutputSchema } from '../output-schemas'; + +export const getLocation = createAction({ + name: 'get-location', + outputSchema: getLocationActionOutputSchema, + classification: 'READ', + displayName: 'Get Location', + description: 'Gets the details of a business location.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one Google Business Profile location: title, store code, address, phone numbers, website, regular and special hours, categories, labels, description and metadata (placeId, Maps URL). Use to read the current listing before editing it. Read-only and safe to repeat.', + idempotent: true, + }, + auth: googleAuth, + props: { + location_id: gmbApi.props.locationId(), + }, + async run(ctx) { + const location = gmbApi.resourceNames.v1Location(ctx.propsValue.location_id); + return gmbApi.request>({ + accessToken: ctx.auth.access_token, + method: HttpMethod.GET, + url: `${gmbApi.hosts.businessInformation}/${location}`, + query: new URLSearchParams({ readMask: gmbApi.locationReadMask }), + }); + }, +}); diff --git a/packages/pieces/community/google-my-business/src/lib/actions/get-media.ts b/packages/pieces/community/google-my-business/src/lib/actions/get-media.ts new file mode 100644 index 000000000000..39e74290d0bb --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/actions/get-media.ts @@ -0,0 +1,39 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleAuth } from '../..'; +import { gmbApi } from '../common/client'; +import { getMediaActionOutputSchema } from '../output-schemas'; + +export const getMedia = createAction({ + name: 'get-media', + outputSchema: getMediaActionOutputSchema, + classification: 'READ', + displayName: 'Get Media', + description: 'Gets one photo or video of a location.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one photo or video of a Google Business Profile location with its format, Google URL, thumbnail, category and view count. Read-only and safe to repeat.', + idempotent: true, + }, + auth: googleAuth, + props: { + account_id: gmbApi.props.accountId(), + location_id: gmbApi.props.locationId(), + media_id: Property.ShortText({ + displayName: 'Media ID', + description: 'The media key: the last segment of the name field returned by List Media (list-media).', + required: true, + }), + }, + async run(ctx) { + const { account_id, location_id, media_id } = ctx.propsValue; + const parent = gmbApi.resourceNames.v4Location({ account: account_id, location: location_id }); + const media = gmbApi.resourceNames.childId({ value: media_id, marker: 'media' }); + return gmbApi.request>({ + accessToken: ctx.auth.access_token, + method: HttpMethod.GET, + url: `${gmbApi.hosts.v4}/${parent}/media/${encodeURIComponent(media)}`, + }); + }, +}); diff --git a/packages/pieces/community/google-my-business/src/lib/actions/get-review.ts b/packages/pieces/community/google-my-business/src/lib/actions/get-review.ts new file mode 100644 index 000000000000..3f348033daaf --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/actions/get-review.ts @@ -0,0 +1,32 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { googleAuth } from '../..'; +import { gmbApi } from '../common/client'; +import { getReviewActionOutputSchema } from '../output-schemas'; + +export const getReview = createAction({ + name: 'get-review', + outputSchema: getReviewActionOutputSchema, + classification: 'READ', + displayName: 'Get Review', + description: 'Gets one customer review of a location.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one customer review of a Google Business Profile location with reviewer, star rating, comment, timestamps and the current owner reply if any. Use to check a review before or after replying. Read-only and safe to repeat.', + idempotent: true, + }, + auth: googleAuth, + props: { + account_id: gmbApi.props.accountId(), + location_id: gmbApi.props.locationId(), + review_id: gmbApi.props.reviewId(), + }, + async run(ctx) { + return gmbApi.request>({ + accessToken: ctx.auth.access_token, + method: HttpMethod.GET, + url: gmbApi.resourceNames.reviewUrl(ctx.propsValue), + }); + }, +}); diff --git a/packages/pieces/community/google-my-business/src/lib/actions/list-accounts.ts b/packages/pieces/community/google-my-business/src/lib/actions/list-accounts.ts new file mode 100644 index 000000000000..144589cae8f8 --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/actions/list-accounts.ts @@ -0,0 +1,41 @@ +import { propsValidation } from '@activepieces/pieces-common'; +import { createAction, isNil } from '@activepieces/pieces-framework'; +import * as z from 'zod/mini'; +import { googleAuth } from '../..'; +import { gmbApi } from '../common/client'; +import { listAccountsActionOutputSchema } from '../output-schemas'; + +export const listAccounts = createAction({ + name: 'list-accounts', + outputSchema: listAccountsActionOutputSchema, + classification: 'SEARCH', + displayName: 'List Accounts', + description: 'Lists the Business Profile accounts the connected user can access.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the Google Business Profile accounts the connected Google user can manage. Each account name has the form accounts/{id}; pass it (or the bare id) as Account ID to List Locations and the review or media actions. Call this first when no account id is known. Read-only and safe to repeat.', + idempotent: true, + }, + auth: googleAuth, + props: { + max_results: gmbApi.props.maxResults(), + }, + async run(ctx) { + await propsValidation.validateZod(ctx.propsValue, { + max_results: z.optional(z.number().check(z.gte(1))), + }); + const { items, nextPageToken } = await gmbApi.paginate({ + accessToken: ctx.auth.access_token, + url: `${gmbApi.hosts.accountManagement}/accounts`, + itemsKey: 'accounts', + pageSize: 20, + maxResults: ctx.propsValue.max_results ?? 100, + }); + return { + accounts: items, + count: items.length, + ...(isNil(nextPageToken) ? {} : { next_page_token: nextPageToken }), + }; + }, +}); diff --git a/packages/pieces/community/google-my-business/src/lib/actions/list-locations.ts b/packages/pieces/community/google-my-business/src/lib/actions/list-locations.ts new file mode 100644 index 000000000000..8e0ac6cc1599 --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/actions/list-locations.ts @@ -0,0 +1,44 @@ +import { propsValidation } from '@activepieces/pieces-common'; +import { createAction, isNil } from '@activepieces/pieces-framework'; +import * as z from 'zod/mini'; +import { googleAuth } from '../..'; +import { gmbApi } from '../common/client'; +import { listLocationsActionOutputSchema } from '../output-schemas'; + +export const listLocations = createAction({ + name: 'list-locations', + outputSchema: listLocationsActionOutputSchema, + classification: 'SEARCH', + displayName: 'List Locations', + description: 'Lists the business locations of an account.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the business locations under a Google Business Profile account, with title, address, phone, website, hours, categories, description and placeId. Each location name has the form locations/{id}; pass it (or the bare id) as Location ID to the location, review, media and post actions. Read-only and safe to repeat.', + idempotent: true, + }, + auth: googleAuth, + props: { + account_id: gmbApi.props.accountId(), + max_results: gmbApi.props.maxResults(), + }, + async run(ctx) { + await propsValidation.validateZod(ctx.propsValue, { + max_results: z.optional(z.number().check(z.gte(1))), + }); + const account = gmbApi.resourceNames.accountName(ctx.propsValue.account_id); + const { items, nextPageToken } = await gmbApi.paginate({ + accessToken: ctx.auth.access_token, + url: `${gmbApi.hosts.businessInformation}/${account}/locations`, + query: new URLSearchParams({ readMask: gmbApi.locationReadMask }), + itemsKey: 'locations', + pageSize: 100, + maxResults: ctx.propsValue.max_results ?? 100, + }); + return { + locations: items, + count: items.length, + ...(isNil(nextPageToken) ? {} : { next_page_token: nextPageToken }), + }; + }, +}); diff --git a/packages/pieces/community/google-my-business/src/lib/actions/list-media.ts b/packages/pieces/community/google-my-business/src/lib/actions/list-media.ts new file mode 100644 index 000000000000..45478f88e6b5 --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/actions/list-media.ts @@ -0,0 +1,46 @@ +import { propsValidation } from '@activepieces/pieces-common'; +import { createAction, isNil } from '@activepieces/pieces-framework'; +import * as z from 'zod/mini'; +import { googleAuth } from '../..'; +import { gmbApi } from '../common/client'; +import { listMediaActionOutputSchema } from '../output-schemas'; + +export const listMedia = createAction({ + name: 'list-media', + outputSchema: listMediaActionOutputSchema, + classification: 'SEARCH', + displayName: 'List Media', + description: 'Lists the photos and videos of a location.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the photos and videos attached to a Google Business Profile location with format, Google URL, category and view count, plus the total media count. The last segment of each media name is the Media ID for Get Media. Read-only and safe to repeat.', + idempotent: true, + }, + auth: googleAuth, + props: { + account_id: gmbApi.props.accountId(), + location_id: gmbApi.props.locationId(), + max_results: gmbApi.props.maxResults(), + }, + async run(ctx) { + await propsValidation.validateZod(ctx.propsValue, { + max_results: z.optional(z.number().check(z.gte(1))), + }); + const { account_id, location_id, max_results } = ctx.propsValue; + const parent = gmbApi.resourceNames.v4Location({ account: account_id, location: location_id }); + const { items, nextPageToken, lastPage } = await gmbApi.paginate({ + accessToken: ctx.auth.access_token, + url: `${gmbApi.hosts.v4}/${parent}/media`, + itemsKey: 'mediaItems', + pageSize: 100, + maxResults: max_results ?? 100, + }); + return { + media_items: items, + count: items.length, + total_media_item_count: lastPage['totalMediaItemCount'] ?? 0, + ...(isNil(nextPageToken) ? {} : { next_page_token: nextPageToken }), + }; + }, +}); diff --git a/packages/pieces/community/google-my-business/src/lib/actions/list-reviews.ts b/packages/pieces/community/google-my-business/src/lib/actions/list-reviews.ts new file mode 100644 index 000000000000..b76812d86cf5 --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/actions/list-reviews.ts @@ -0,0 +1,60 @@ +import { propsValidation } from '@activepieces/pieces-common'; +import { createAction, isNil, Property } from '@activepieces/pieces-framework'; +import * as z from 'zod/mini'; +import { googleAuth } from '../..'; +import { gmbApi } from '../common/client'; +import { listReviewsActionOutputSchema } from '../output-schemas'; + +export const listReviews = createAction({ + name: 'list-reviews', + outputSchema: listReviewsActionOutputSchema, + classification: 'SEARCH', + displayName: 'List Reviews', + description: 'Lists the customer reviews of a location.', + audience: 'ai', + aiMetadata: { + description: + 'Lists customer reviews of a Google Business Profile location with reviewer, star rating (ONE to FIVE), comment, timestamps and any existing owner reply, plus the location average rating and total review count. Each review has a reviewId to pass to Get Review, Reply to Review or Delete Review Reply. Read-only and safe to repeat.', + idempotent: true, + }, + auth: googleAuth, + props: { + account_id: gmbApi.props.accountId(), + location_id: gmbApi.props.locationId(), + order_by: Property.StaticDropdown({ + displayName: 'Order By', + description: 'Sort order. Google defaults to newest first when unset.', + required: false, + options: { + options: [ + { label: 'Newest first', value: 'updateTime desc' }, + { label: 'Lowest rating first', value: 'rating' }, + { label: 'Highest rating first', value: 'rating desc' }, + ], + }, + }), + max_results: gmbApi.props.maxResults(), + }, + async run(ctx) { + await propsValidation.validateZod(ctx.propsValue, { + max_results: z.optional(z.number().check(z.gte(1))), + }); + const { account_id, location_id, order_by, max_results } = ctx.propsValue; + const parent = gmbApi.resourceNames.v4Location({ account: account_id, location: location_id }); + const { items, nextPageToken, lastPage } = await gmbApi.paginate({ + accessToken: ctx.auth.access_token, + url: `${gmbApi.hosts.v4}/${parent}/reviews`, + query: new URLSearchParams(isNil(order_by) ? {} : { orderBy: order_by }), + itemsKey: 'reviews', + pageSize: 50, + maxResults: max_results ?? 100, + }); + return { + reviews: items, + count: items.length, + average_rating: lastPage['averageRating'] ?? null, + total_review_count: lastPage['totalReviewCount'] ?? 0, + ...(isNil(nextPageToken) ? {} : { next_page_token: nextPageToken }), + }; + }, +}); diff --git a/packages/pieces/community/google-my-business/src/lib/actions/reply-to-review.ts b/packages/pieces/community/google-my-business/src/lib/actions/reply-to-review.ts new file mode 100644 index 000000000000..20414bfbfc99 --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/actions/reply-to-review.ts @@ -0,0 +1,38 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { googleAuth } from '../..'; +import { gmbApi } from '../common/client'; +import { replyToReviewActionOutputSchema } from '../output-schemas'; + +export const replyToReview = createAction({ + name: 'reply-to-review', + outputSchema: replyToReviewActionOutputSchema, + classification: 'WRITE', + displayName: 'Reply to Review', + description: 'Creates or replaces the owner reply to a review.', + audience: 'ai', + aiMetadata: { + description: + 'Posts the business owner reply to a customer review of a Google Business Profile location, replacing any existing reply. The reply is public on Google Maps and Search. Safe to repeat with the same comment since it upserts.', + idempotent: true, + }, + auth: googleAuth, + props: { + account_id: gmbApi.props.accountId(), + location_id: gmbApi.props.locationId(), + review_id: gmbApi.props.reviewId(), + comment: Property.LongText({ + displayName: 'Comment', + description: 'The reply text.', + required: true, + }), + }, + async run(ctx) { + return gmbApi.request>({ + accessToken: ctx.auth.access_token, + method: HttpMethod.PUT, + url: `${gmbApi.resourceNames.reviewUrl(ctx.propsValue)}/reply`, + body: { comment: ctx.propsValue.comment }, + }); + }, +}); diff --git a/packages/pieces/community/google-my-business/src/lib/actions/update-location.ts b/packages/pieces/community/google-my-business/src/lib/actions/update-location.ts new file mode 100644 index 000000000000..265f3a487e50 --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/actions/update-location.ts @@ -0,0 +1,72 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, isNil, Property } from '@activepieces/pieces-framework'; +import { googleAuth } from '../..'; +import { gmbApi } from '../common/client'; +import { updateLocationActionOutputSchema } from '../output-schemas'; + +export const updateLocation = createAction({ + name: 'update-location', + outputSchema: updateLocationActionOutputSchema, + classification: 'WRITE', + displayName: 'Update Location', + description: 'Updates the website, labels or store code of a business location.', + audience: 'ai', + aiMetadata: { + description: + 'Updates selected fields of a Google Business Profile location; only the fields you supply are changed and everything else is left untouched. Website is public and appears on Google Maps and Search, so change it only when the user explicitly asks. Labels replaces the whole label set, so include every label you want to keep; an empty list is ignored and cannot clear them. Store Code is internal. Empty text fields are ignored. Description, phone, hours, address and categories cannot be edited here. Safe to repeat with the same values.', + idempotent: true, + }, + auth: googleAuth, + props: { + location_id: gmbApi.props.locationId(), + website_uri: Property.ShortText({ + displayName: 'Website', + description: 'The public website URL of the business.', + required: false, + }), + labels: Property.Array({ + displayName: 'Labels', + description: 'Internal labels for the location. Replaces the existing labels. Leave empty to keep the current labels; this action cannot clear them.', + required: false, + }), + store_code: Property.ShortText({ + displayName: 'Store Code', + description: 'Your internal identifier for the location.', + required: false, + }), + }, + async run(ctx) { + const { location_id, website_uri, labels, store_code } = ctx.propsValue; + const websiteValue = nonBlank(website_uri); + const storeCodeValue = nonBlank(store_code); + const suppliedLabels = isNil(labels) + ? [] + : labels.filter((label): label is string => typeof label === 'string' && label.trim().length > 0); + const labelValues = suppliedLabels.length === 0 ? undefined : suppliedLabels; + const patches: Patch[] = [ + ...(isNil(websiteValue) ? [] : [{ mask: 'websiteUri', body: { websiteUri: websiteValue } }]), + ...(isNil(labelValues) ? [] : [{ mask: 'labels', body: { labels: labelValues } }]), + ...(isNil(storeCodeValue) ? [] : [{ mask: 'storeCode', body: { storeCode: storeCodeValue } }]), + ]; + if (patches.length === 0) { + throw new Error('Provide at least one field to update: website_uri, labels or store_code.'); + } + const location = gmbApi.resourceNames.v1Location(location_id); + return gmbApi.request>({ + accessToken: ctx.auth.access_token, + method: HttpMethod.PATCH, + url: `${gmbApi.hosts.businessInformation}/${location}`, + query: new URLSearchParams({ updateMask: patches.map((patch) => patch.mask).join(',') }), + body: patches.reduce>((acc, patch) => ({ ...acc, ...patch.body }), {}), + }); + }, +}); + +function nonBlank(value: string | undefined): string | undefined { + return isNil(value) || value.trim().length === 0 ? undefined : value; +} + +type Patch = { + mask: string; + body: Record; +}; diff --git a/packages/pieces/community/google-my-business/src/lib/common/client.ts b/packages/pieces/community/google-my-business/src/lib/common/client.ts new file mode 100644 index 000000000000..829d4e9e88b2 --- /dev/null +++ b/packages/pieces/community/google-my-business/src/lib/common/client.ts @@ -0,0 +1,192 @@ +import { HttpError, HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { isNil, Property } from '@activepieces/pieces-framework'; + +export const gmbApi = { + request, + paginate, + locationReadMask: + 'name,title,storeCode,languageCode,phoneNumbers,categories,storefrontAddress,websiteUri,regularHours,specialHours,serviceArea,labels,latlng,openInfo,metadata,profile', + resourceNames: { accountName, v1Location, v4Location, childId, reviewUrl }, + props: { + accountId: () => + Property.ShortText({ + displayName: 'Account ID', + description: 'The account id, e.g. 123 or accounts/123. Get it from the name field returned by List Accounts (list-accounts).', + required: true, + }), + locationId: () => + Property.ShortText({ + displayName: 'Location ID', + description: 'The location id, e.g. 456 or locations/456. Get it from the name field returned by List Locations (list-locations).', + required: true, + }), + reviewId: () => + Property.ShortText({ + displayName: 'Review ID', + description: 'The reviewId field returned by List Reviews (list-reviews), or the full review name.', + required: true, + }), + maxResults: () => + Property.Number({ + displayName: 'Maximum Results', + description: 'Stop after this many items. Larger values are fetched over several requests.', + required: false, + defaultValue: 100, + }), + }, + hosts: { + accountManagement: 'https://mybusinessaccountmanagement.googleapis.com/v1', + businessInformation: 'https://mybusinessbusinessinformation.googleapis.com/v1', + v4: 'https://mybusiness.googleapis.com/v4', + verifications: 'https://mybusinessverifications.googleapis.com/v1', + }, +}; + + +async function request({ accessToken, method, url, query, body }: RequestParams): Promise { + const queryString = isNil(query) ? '' : query.toString(); + try { + const response = await httpClient.sendRequest({ + method, + url: queryString.length > 0 ? `${url}?${queryString}` : url, + headers: { Authorization: `Bearer ${accessToken}` }, + ...(isNil(body) ? {} : { body }), + }); + return response.body; + } catch (error) { + if (error instanceof HttpError) { + throw new Error(buildErrorMessage({ status: error.response.status, body: error.response.body })); + } + throw error; + } +} + +async function paginate({ accessToken, url, query, itemsKey, pageSize, maxResults }: PaginateParams): Promise { + const items: unknown[] = []; + let pageToken: string | undefined; + let lastPage: Record = {}; + do { + const params = new URLSearchParams(query); + params.set('pageSize', String(Math.min(maxResults - items.length, pageSize))); + if (!isNil(pageToken)) { + params.set('pageToken', pageToken); + } + lastPage = await request>({ accessToken, method: HttpMethod.GET, url, query: params }); + const pageItems = lastPage[itemsKey]; + if (Array.isArray(pageItems)) { + items.push(...pageItems); + } + const next = lastPage['nextPageToken']; + pageToken = typeof next === 'string' && next.length > 0 ? next : undefined; + } while (!isNil(pageToken) && items.length < maxResults); + return { items: items.slice(0, maxResults), nextPageToken: pageToken, lastPage }; +} + +function buildErrorMessage({ status, body }: { status: number; body: unknown }): string { + const vendor = readVendorError(body); + const vendorMessage = vendor.message ?? 'Unknown error'; + const suffix = `(status ${status}${isNil(vendor.status) ? '' : ` ${vendor.status}`}): ${vendorMessage}`; + if (status === 403) { + const disabled = vendor.reasons.some((reason) => reason === 'SERVICE_DISABLED' || reason === 'accessNotConfigured'); + if (disabled) { + return `This Business Profile API is not enabled for the connection's Google project ${suffix}`; + } + return `The connected Google account does not manage this account or location ${suffix}`; + } + if (status === 404) { + return `Not found; check the account, location or resource id format ${suffix}`; + } + if (status === 429) { + return `Google Business Profile API quota exceeded; retry later ${suffix}`; + } + if (status === 400 && vendor.status === 'FAILED_PRECONDITION') { + return `Precondition failed; the location may be unverified ${suffix}`; + } + return `Google Business Profile request failed ${suffix}`; +} + +function readVendorError(body: unknown): VendorError { + const empty: VendorError = { message: undefined, status: undefined, reasons: [] }; + if (!isRecord(body) || !isRecord(body['error'])) { + return empty; + } + const error = body['error']; + const details = Array.isArray(error['details']) ? error['details'] : []; + const errors = Array.isArray(error['errors']) ? error['errors'] : []; + const reasons = [...details, ...errors] + .filter(isRecord) + .map((item) => item['reason']) + .filter((reason): reason is string => typeof reason === 'string'); + return { + message: typeof error['message'] === 'string' ? error['message'] : undefined, + status: typeof error['status'] === 'string' ? error['status'] : undefined, + reasons, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function lastSegment({ value, marker }: { value: string; marker: string }): string { + const trimmed = value.trim().replace(/^\/+|\/+$/g, ''); + const parts = trimmed.split('/'); + const index = parts.lastIndexOf(marker); + if (index >= 0 && index + 1 < parts.length) { + return parts[index + 1]; + } + return parts[parts.length - 1]; +} + +function accountName(account: string): string { + return `accounts/${lastSegment({ value: account, marker: 'accounts' })}`; +} + +function v1Location(location: string): string { + return `locations/${lastSegment({ value: location, marker: 'locations' })}`; +} + +function v4Location({ account, location }: { account: string; location: string }): string { + return `${accountName(account)}/${v1Location(location)}`; +} + +function childId({ value, marker }: { value: string; marker: string }): string { + return lastSegment({ value, marker }); +} + +function reviewUrl({ account_id, location_id, review_id }: { account_id: string; location_id: string; review_id: string }): string { + const parent = v4Location({ account: account_id, location: location_id }); + const review = childId({ value: review_id, marker: 'reviews' }); + return `https://mybusiness.googleapis.com/v4/${parent}/reviews/${encodeURIComponent(review)}`; +} + + +type RequestParams = { + accessToken: string; + method: HttpMethod; + url: string; + query?: URLSearchParams; + body?: Record; +}; + +type PaginateParams = { + accessToken: string; + url: string; + query?: URLSearchParams; + itemsKey: string; + pageSize: number; + maxResults: number; +}; + +type PaginateResult = { + items: unknown[]; + nextPageToken: string | undefined; + lastPage: Record; +}; + +type VendorError = { + message: string | undefined; + status: string | undefined; + reasons: string[]; +}; + diff --git a/packages/pieces/community/google-my-business/src/lib/output-schemas.ts b/packages/pieces/community/google-my-business/src/lib/output-schemas.ts index bb18579d60f0..7bdbe7cbca29 100644 --- a/packages/pieces/community/google-my-business/src/lib/output-schemas.ts +++ b/packages/pieces/community/google-my-business/src/lib/output-schemas.ts @@ -58,6 +58,146 @@ const localPostFields: OutputSchema['fields'] = [ }, ]; +const accountFields: OutputSchema['fields'] = [ + { key: 'name', label: 'Account Name' }, + { key: 'accountName', label: 'Display Name' }, + { key: 'type', label: 'Account Type' }, + { key: 'role', label: 'Role' }, + { key: 'verificationState', label: 'Verification State' }, + { key: 'vettedState', label: 'Vetted State' }, +]; + +const locationFields: OutputSchema['fields'] = [ + { key: 'name', label: 'Location Name' }, + { key: 'title', label: 'Title' }, + { key: 'storeCode', label: 'Store Code' }, + { key: 'languageCode', label: 'Language Code' }, + { key: 'websiteUri', label: 'Website', format: 'url' }, + { key: 'labels', label: 'Labels' }, + { + key: 'regularHours', + label: 'Regular Hours', + children: [ + { + key: 'periods', + label: 'Periods', + listItems: [ + { key: 'openDay', label: 'Open Day' }, + { key: 'openTime', label: 'Open Time', children: [{ key: 'hours', label: 'Hours', format: 'number' }] }, + { key: 'closeDay', label: 'Close Day' }, + { key: 'closeTime', label: 'Close Time', children: [{ key: 'hours', label: 'Hours', format: 'number' }] }, + ], + }, + ], + }, + { key: 'phoneNumbers', label: 'Phone Numbers', children: [{ key: 'primaryPhone', label: 'Primary Phone' }] }, + { + key: 'categories', + label: 'Categories', + children: [ + { + key: 'primaryCategory', + label: 'Primary Category', + children: [ + { key: 'name', label: 'Category Id' }, + { key: 'displayName', label: 'Category' }, + { + key: 'serviceTypes', + label: 'Service Types', + labelKey: 'displayName', + listItems: [ + { key: 'serviceTypeId', label: 'Service Type Id' }, + { key: 'displayName', label: 'Service Type' }, + ], + }, + ], + }, + ], + }, + { + key: 'storefrontAddress', + label: 'Address', + children: [ + { key: 'regionCode', label: 'Region Code' }, + { key: 'languageCode', label: 'Language Code' }, + { key: 'addressLines', label: 'Address Lines' }, + { key: 'postalCode', label: 'Postal Code' }, + { key: 'administrativeArea', label: 'Administrative Area' }, + { key: 'locality', label: 'Locality' }, + ], + }, + { + key: 'latlng', + label: 'Coordinates', + children: [ + { key: 'latitude', label: 'Latitude', format: 'number' }, + { key: 'longitude', label: 'Longitude', format: 'number' }, + ], + }, + { key: 'openInfo', label: 'Open Info', children: [ + { key: 'status', label: 'Status' }, + { key: 'canReopen', label: 'Can Reopen', format: 'boolean' }, + ] }, + { + key: 'metadata', + label: 'Metadata', + children: [ + { key: 'placeId', label: 'Place Id' }, + { key: 'mapsUri', label: 'Maps URL', format: 'url' }, + { key: 'newReviewUri', label: 'New Review URL', format: 'url' }, + { key: 'hasVoiceOfMerchant', label: 'Has Voice Of Merchant', format: 'boolean' }, + { key: 'canDelete', label: 'Can Delete', format: 'boolean' }, + { key: 'canModifyServiceList', label: 'Can Modify Service List', format: 'boolean' }, + ], + }, + { key: 'profile', label: 'Profile', children: [{ key: 'description', label: 'Description' }] }, +]; + +const reviewFields: OutputSchema['fields'] = [ + { key: 'name', label: 'Review Name' }, + { key: 'reviewId', label: 'Review Id' }, + { key: 'reviewReplyUrl', label: 'Review Reply URL', format: 'url' }, + { + key: 'reviewer', + label: 'Reviewer', + children: [ + { key: 'displayName', label: 'Display Name' }, + { key: 'profilePhotoUrl', label: 'Profile Photo', format: 'url' }, + ], + }, + { key: 'starRating', label: 'Star Rating' }, + { key: 'comment', label: 'Comment' }, + { key: 'createTime', label: 'Created At', format: 'datetime' }, + { key: 'updateTime', label: 'Updated At', format: 'datetime' }, + { + key: 'reviewReply', + label: 'Reply', + children: [ + { key: 'comment', label: 'Reply Comment' }, + { key: 'updateTime', label: 'Reply Updated At', format: 'datetime' }, + ], + }, +]; + +const mediaFields: OutputSchema['fields'] = [ + { key: 'name', label: 'Media Name' }, + { key: 'mediaFormat', label: 'Media Format' }, + { key: 'googleUrl', label: 'Google URL', format: 'url' }, + { key: 'thumbnailUrl', label: 'Thumbnail URL', format: 'url' }, + { + key: 'dimensions', + label: 'Dimensions', + children: [ + { key: 'widthPixels', label: 'Width Pixels', format: 'number' }, + { key: 'heightPixels', label: 'Height Pixels', format: 'number' }, + ], + }, + { key: 'createTime', label: 'Created At', format: 'datetime' }, + { key: 'description', label: 'Description' }, + { key: 'locationAssociation', label: 'Location Association', children: [{ key: 'category', label: 'Category' }] }, + { key: 'insights', label: 'Insights', children: [{ key: 'viewCount', label: 'View Count' }] }, +]; + export const createPostActionOutputSchema: OutputSchema = { fields: localPostFields }; export const getPostActionOutputSchema: OutputSchema = { fields: localPostFields }; export const updatePostActionOutputSchema: OutputSchema = { fields: localPostFields }; @@ -67,3 +207,63 @@ export const listPostsActionOutputSchema: OutputSchema = { { key: 'nextPageToken', label: 'Next Page Token' }, ], }; +export const listAccountsActionOutputSchema: OutputSchema = { + fields: [ + { key: 'accounts', label: 'Accounts', labelKey: 'accountName', listItems: accountFields }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'next_page_token', label: 'Next Page Token' }, + ], +}; +export const listLocationsActionOutputSchema: OutputSchema = { + fields: [ + { key: 'locations', label: 'Locations', labelKey: 'title', listItems: locationFields }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'next_page_token', label: 'Next Page Token' }, + ], +}; +export const getLocationActionOutputSchema: OutputSchema = { fields: locationFields }; +export const updateLocationActionOutputSchema: OutputSchema = { fields: locationFields }; +export const listReviewsActionOutputSchema: OutputSchema = { + fields: [ + { key: 'reviews', label: 'Reviews', labelKey: 'comment', listItems: reviewFields }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'average_rating', label: 'Average Rating', format: 'number' }, + { key: 'total_review_count', label: 'Total Review Count', format: 'number' }, + { key: 'next_page_token', label: 'Next Page Token' }, + ], +}; +export const getReviewActionOutputSchema: OutputSchema = { fields: reviewFields }; +export const replyToReviewActionOutputSchema: OutputSchema = { + fields: [ + { key: 'comment', label: 'Reply Comment' }, + { key: 'updateTime', label: 'Updated At', format: 'datetime' }, + ], +}; +export const deleteReviewReplyActionOutputSchema: OutputSchema = { + fields: [{ key: 'success', label: 'Success', format: 'boolean' }], +}; +export const getLocationVerificationStateActionOutputSchema: OutputSchema = { + fields: [ + { key: 'hasVoiceOfMerchant', label: 'Has Voice Of Merchant', format: 'boolean' }, + { key: 'hasBusinessAuthority', label: 'Has Business Authority', format: 'boolean' }, + { + key: 'verify', + label: 'Verify', + children: [{ key: 'hasPendingVerification', label: 'Has Pending Verification', format: 'boolean' }], + }, + { + key: 'complyWithGuidelines', + label: 'Comply With Guidelines', + children: [{ key: 'recommendationReason', label: 'Recommendation Reason' }], + }, + ], +}; +export const listMediaActionOutputSchema: OutputSchema = { + fields: [ + { key: 'media_items', label: 'Media Items', labelKey: 'name', listItems: mediaFields }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'total_media_item_count', label: 'Total Media Item Count', format: 'number' }, + { key: 'next_page_token', label: 'Next Page Token' }, + ], +}; +export const getMediaActionOutputSchema: OutputSchema = { fields: mediaFields }; diff --git a/packages/pieces/community/google-my-business/src/lib/triggers/new-review.ts b/packages/pieces/community/google-my-business/src/lib/triggers/new-review.ts index fe027c12791a..9130f579bb5a 100644 --- a/packages/pieces/community/google-my-business/src/lib/triggers/new-review.ts +++ b/packages/pieces/community/google-my-business/src/lib/triggers/new-review.ts @@ -34,18 +34,10 @@ export const newReview = createTrigger({ return await pollingHelper.test(polling, ctx); }, async onEnable(ctx) { - await pollingHelper.onEnable(polling, { - auth: ctx.auth, - store: ctx.store, - propsValue: ctx.propsValue, - }); + await pollingHelper.onEnable(polling, ctx); }, async onDisable(ctx) { - await pollingHelper.onDisable(polling, { - auth: ctx.auth, - store: ctx.store, - propsValue: ctx.propsValue, - }); + await pollingHelper.onDisable(polling, ctx); }, async run(ctx) { return await pollingHelper.poll(polling, ctx); @@ -84,7 +76,7 @@ const getResponse = async ( Authorization: `Bearer ${authentication.access_token}`, }, queryParams: { - pageSize: '100', + pageSize: '50', orderBy: 'updateTime desc', }, }); diff --git a/packages/pieces/community/imap/package.json b/packages/pieces/community/imap/package.json index c139c20b9d98..e8afe53f1252 100644 --- a/packages/pieces/community/imap/package.json +++ b/packages/pieces/community/imap/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-imap", - "version": "0.4.9", + "version": "0.5.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/imap/src/i18n/translation.json b/packages/pieces/community/imap/src/i18n/translation.json index 2b4fee9bad4c..1cbbf78ed95d 100644 --- a/packages/pieces/community/imap/src/i18n/translation.json +++ b/packages/pieces/community/imap/src/i18n/translation.json @@ -38,5 +38,108 @@ "Trigger when a new email is received": "Trigger when a new email is received", "Mailbox": "Mailbox", "Select the mailbox to search.": "Select the mailbox to search.", - "\n**Emails Filtering:**\n\nAdd a Router Piece to filter emails based on the subject, to, from, cc or other fields.\n": "\n**Emails Filtering:**\n\nAdd a Router Piece to filter emails based on the subject, to, from, cc or other fields.\n" -} \ No newline at end of file + "\n**Emails Filtering:**\n\nAdd a Router Piece to filter emails based on the subject, to, from, cc or other fields.\n": "\n**Emails Filtering:**\n\nAdd a Router Piece to filter emails based on the subject, to, from, cc or other fields.\n", + "List Folders": "List Folders", + "Lists every folder in the mailbox.": "Lists every folder in the mailbox.", + "Include Message Counts": "Include Message Counts", + "Also return the total and unread message count of each folder. Slower on accounts with many folders.": "Also return the total and unread message count of each folder. Slower on accounts with many folders.", + "Get Folder Status": "Get Folder Status", + "Gets message counts and UID information for a folder.": "Gets message counts and UID information for a folder.", + "Folder": "Folder", + "Folder path from list_folders.": "Folder path from list_folders.", + "Create Folder": "Create Folder", + "Creates a folder, optionally inside a parent folder.": "Creates a folder, optionally inside a parent folder.", + "Folder Name": "Folder Name", + "Name of the new folder. Must not contain the server folder separator; use Parent Folder for nesting.": "Name of the new folder. Must not contain the server folder separator; use Parent Folder for nesting.", + "Optional existing folder path from list_folders to create the folder inside.": "Optional existing folder path from list_folders to create the folder inside.", + "Rename Folder": "Rename Folder", + "Renames or moves a folder to a new path.": "Renames or moves a folder to a new path.", + "Current folder path from list_folders.": "Current folder path from list_folders.", + "New Path": "New Path", + "Full new path, using the folder separator from list_folders for nesting (for example \"Projects/2026\").": "Full new path, using the folder separator from list_folders for nesting (for example \"Projects/2026\").", + "Delete Folder": "Delete Folder", + "Permanently deletes a folder and the emails in it.": "Permanently deletes a folder and the emails in it.", + "Get Quota": "Get Quota", + "Gets the storage and message quota for a folder.": "Gets the storage and message quota for a folder.", + "Search Emails": "Search Emails", + "Searches a folder and returns matching email summaries, newest first.": "Searches a folder and returns matching email summaries, newest first.", + "From": "From", + "Text contained in the From header.": "Text contained in the From header.", + "To": "To", + "Text contained in the To header.": "Text contained in the To header.", + "Subject": "Subject", + "Text contained in the subject.": "Text contained in the subject.", + "Body": "Body", + "Text contained in the message body.": "Text contained in the message body.", + "Text": "Text", + "Text contained anywhere in the headers or body.": "Text contained anywhere in the headers or body.", + "Message-ID": "Message-ID", + "Exact Message-ID header value, for example .": "Exact Message-ID header value, for example .", + "Received Since": "Received Since", + "Only messages received on or after this date. Day granularity on most servers (time of day is ignored).": "Only messages received on or after this date. Day granularity on most servers (time of day is ignored).", + "Received Before": "Received Before", + "Only messages received before this date. Day granularity on most servers (time of day is ignored).": "Only messages received before this date. Day granularity on most servers (time of day is ignored).", + "Read Status": "Read Status", + "Filter by read state. Leave empty for both.": "Filter by read state. Leave empty for both.", + "Read": "Read", + "Unread": "Unread", + "Flagged": "Flagged", + "Filter by the flagged (starred) state. Leave empty for both.": "Filter by the flagged (starred) state. Leave empty for both.", + "Not Flagged": "Not Flagged", + "Keyword": "Keyword", + "Only messages carrying this custom keyword (IMAP flag).": "Only messages carrying this custom keyword (IMAP flag).", + "Limit": "Limit", + "Maximum number of emails to return (1-100).": "Maximum number of emails to return (1-100).", + "Get Email": "Get Email", + "Gets the full content of one email, including attachments.": "Gets the full content of one email, including attachments.", + "Folder path from list_folders that contains the email.": "Folder path from list_folders that contains the email.", + "UID from search_emails in the same folder.": "UID from search_emails in the same folder.", + "UID Validity": "UID Validity", + "Optional uid_validity returned by search_emails. When set and the folder has changed, the call fails instead of acting on stale UIDs.": "Optional uid_validity returned by search_emails. When set and the folder has changed, the call fails instead of acting on stale UIDs.", + "Update Email Flags": "Update Email Flags", + "Marks emails read or unread, flagged or unflagged, answered, and adds or removes keywords.": "Marks emails read or unread, flagged or unflagged, answered, and adds or removes keywords.", + "Folder path from list_folders that contains the emails.": "Folder path from list_folders that contains the emails.", + "Message UIDs": "Message UIDs", + "UIDs from search_emails in the same folder (up to 500). UIDs that no longer exist are reported in not_found_uids.": "UIDs from search_emails in the same folder (up to 500). UIDs that no longer exist are reported in not_found_uids.", + "Leave empty to keep the current read status.": "Leave empty to keep the current read status.", + "Mark as Unread": "Mark as Unread", + "Leave empty to keep the current flagged status.": "Leave empty to keep the current flagged status.", + "Flag": "Flag", + "Unflag": "Unflag", + "Answered": "Answered", + "Leave empty to keep the current answered status.": "Leave empty to keep the current answered status.", + "Mark as Answered": "Mark as Answered", + "Mark as Not Answered": "Mark as Not Answered", + "Add Keywords": "Add Keywords", + "Custom keywords to add, for example \"invoice\".": "Custom keywords to add, for example \"invoice\".", + "Remove Keywords": "Remove Keywords", + "Custom keywords to remove.": "Custom keywords to remove.", + "Move Emails": "Move Emails", + "Moves emails from one folder to another.": "Moves emails from one folder to another.", + "Existing folder path from list_folders to move the emails into.": "Existing folder path from list_folders to move the emails into.", + "Copy Emails": "Copy Emails", + "Copies emails into another folder, keeping the originals.": "Copies emails into another folder, keeping the originals.", + "Existing folder path from list_folders to copy the emails into.": "Existing folder path from list_folders to copy the emails into.", + "Move Emails to Trash": "Move Emails to Trash", + "Moves emails to the Trash folder.": "Moves emails to the Trash folder.", + "Archive Emails": "Archive Emails", + "Moves emails to the Archive folder.": "Moves emails to the Archive folder.", + "Delete Emails Permanently": "Delete Emails Permanently", + "Permanently deletes emails. This cannot be undone.": "Permanently deletes emails. This cannot be undone.", + "Create Draft": "Create Draft", + "Saves a new draft email in the Drafts folder.": "Saves a new draft email in the Drafts folder.", + "Recipients, each \"name@example.com\" or \"Name \".": "Recipients, each \"name@example.com\" or \"Name \".", + "Cc": "Cc", + "Cc recipients.": "Cc recipients.", + "Bcc": "Bcc", + "Bcc recipients.": "Bcc recipients.", + "Text Body": "Text Body", + "Plain-text body. Provide this, an HTML body, or both.": "Plain-text body. Provide this, an HTML body, or both.", + "HTML Body": "HTML Body", + "HTML body. Provide this, a text body, or both.": "HTML body. Provide this, a text body, or both.", + "Sender address. Defaults to the connection username when it is an email address.": "Sender address. Defaults to the connection username when it is an email address.", + "In Reply To": "In Reply To", + "Message-ID of the email being answered, for example .": "Message-ID of the email being answered, for example .", + "Drafts Folder": "Drafts Folder", + "Folder path to save the draft in. Leave empty to use the Drafts folder.": "Folder path to save the draft in. Leave empty to use the Drafts folder." +} diff --git a/packages/pieces/community/imap/src/index.ts b/packages/pieces/community/imap/src/index.ts index ce37e06509f6..4fcdcac967f5 100644 --- a/packages/pieces/community/imap/src/index.ts +++ b/packages/pieces/community/imap/src/index.ts @@ -6,11 +6,26 @@ import { markEmailAsRead } from './lib/actions/mark-email-read'; import { copyEmail } from './lib/actions/copy-email'; import { deleteEmail } from './lib/actions/delete-email'; import { moveEmail } from './lib/actions/move-email'; +import { listFolders } from './lib/actions/list-folders'; +import { getFolderStatus } from './lib/actions/get-folder-status'; +import { createFolder } from './lib/actions/create-folder'; +import { renameFolder } from './lib/actions/rename-folder'; +import { deleteFolder } from './lib/actions/delete-folder'; +import { getQuota } from './lib/actions/get-quota'; +import { searchEmails } from './lib/actions/search-emails'; +import { getEmail } from './lib/actions/get-email'; +import { updateEmailFlags } from './lib/actions/update-email-flags'; +import { moveEmails } from './lib/actions/move-emails'; +import { copyEmails } from './lib/actions/copy-emails'; +import { trashEmails } from './lib/actions/trash-emails'; +import { archiveEmails } from './lib/actions/archive-emails'; +import { deleteEmails } from './lib/actions/delete-emails'; +import { createDraft } from './lib/actions/create-draft'; export const imapPiece = createPiece({ displayName: 'IMAP', description: 'Receive new email trigger', - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/imap.png', categories: [PieceCategory.BUSINESS_INTELLIGENCE], authors: ['kishanprmr', 'MoShizzle', 'khaledmashaly', 'abuaboud', 'simonc'], @@ -20,6 +35,21 @@ export const imapPiece = createPiece({ copyEmail, moveEmail, deleteEmail, + listFolders, + getFolderStatus, + createFolder, + renameFolder, + deleteFolder, + getQuota, + searchEmails, + getEmail, + updateEmailFlags, + moveEmails, + copyEmails, + trashEmails, + archiveEmails, + deleteEmails, + createDraft, ], triggers: [newEmail], }); diff --git a/packages/pieces/community/imap/src/lib/actions/archive-emails.ts b/packages/pieces/community/imap/src/lib/actions/archive-emails.ts new file mode 100644 index 000000000000..e9b6a381f5a2 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/archive-emails.ts @@ -0,0 +1,65 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { + imapAuth, + performImapOperation, + transferInFolder, + resolveSpecialFolder, + parseUids, + samePath, + folderProp, + uidsProp, + uidValidityProp, + ImapError, +} from '../common'; +import { archiveEmailsOutputSchema } from '../output-schemas'; + +export const archiveEmails = createAction({ + auth: imapAuth, + name: 'archive_emails', + classification: 'WRITE', + displayName: 'Archive Emails', + description: 'Moves emails to the Archive folder.', + audience: 'ai', + aiMetadata: { + description: + 'Moves one or more emails by UID out of a folder into the mailbox Archive folder, found automatically (on Gmail, All Mail). When special_use_source is "name", the Archive folder was guessed from its name. Fails, naming move_emails, if no Archive folder exists. uid_validity is required and must come from the search_emails call that produced these UIDs; a mismatch fails the call so stale UIDs cannot archive the wrong emails. Not idempotent: archived UIDs are gone from the source.', + idempotent: false, + }, + props: { + folder: folderProp({ + displayName: 'Folder', + description: 'Folder path from list_folders that contains the emails.', + }), + uids: uidsProp(), + uid_validity: uidValidityProp({ required: true }), + }, + outputSchema: archiveEmailsOutputSchema, + async run({ auth, propsValue }) { + const uids = parseUids({ uids: propsValue.uids }); + const sourceFolder = propsValue.folder.trim(); + return performImapOperation(auth, async (client) => { + const archive = await resolveSpecialFolder({ client, role: 'archive' }); + if (!archive) { + throw new ImapError( + 'No Archive folder was found on this server. Use move_emails with an explicit target folder.' + ); + } + if (samePath({ a: sourceFolder, b: archive.path })) { + throw new ImapError('These emails are already in the Archive folder.'); + } + const result = await transferInFolder({ + client, + sourceFolder, + targetFolder: archive.path, + uids, + uidValidity: propsValue.uid_validity, + mode: 'move', + }); + return { + ...result, + destination_folder: archive.path, + special_use_source: archive.special_use_source, + }; + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/copy-email.ts b/packages/pieces/community/imap/src/lib/actions/copy-email.ts index bea77f862749..946162f3259d 100644 --- a/packages/pieces/community/imap/src/lib/actions/copy-email.ts +++ b/packages/pieces/community/imap/src/lib/actions/copy-email.ts @@ -31,7 +31,7 @@ export const copyEmail = createAction({ displayName: 'Copy Email', description: 'Copy an email to another mailbox', outputSchema: copyEmailActionOutputSchema, - audience: 'both', + audience: 'human', aiMetadata: { description: 'Copies an email (by message UID) from a source IMAP folder into a target folder, leaving the original in place. Use to duplicate a message into another mailbox without removing it from the source. Requires the source folder, target folder, and the UID; not idempotent since each call appends another copy with a new UID.', idempotent: false }, props, async run({ auth, propsValue }) { diff --git a/packages/pieces/community/imap/src/lib/actions/copy-emails.ts b/packages/pieces/community/imap/src/lib/actions/copy-emails.ts new file mode 100644 index 000000000000..f96ca490b87f --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/copy-emails.ts @@ -0,0 +1,55 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { + imapAuth, + performImapOperation, + transferInFolder, + parseUids, + folderProp, + uidsProp, + uidValidityProp, +} from '../common'; +import { copyEmailsOutputSchema } from '../output-schemas'; + +export const copyEmails = createAction({ + auth: imapAuth, + name: 'copy_emails', + classification: 'WRITE', + displayName: 'Copy Emails', + description: 'Copies emails into another folder, keeping the originals.', + audience: 'ai', + aiMetadata: { + description: + 'Copies one or more emails by UID from a source folder into an existing target folder, leaving the originals in place. Use move_emails to relocate instead of duplicate. uid_validity is required and must come from the search_emails call that produced these UIDs; a mismatch fails the call so stale UIDs cannot copy the wrong emails. UIDs that no longer exist are reported, not failed. Not idempotent: every call adds another copy.', + idempotent: false, + }, + props: { + source_folder: folderProp({ + displayName: 'Source Folder', + description: 'Folder path from list_folders that contains the emails.', + }), + uids: uidsProp(), + target_folder: Property.ShortText({ + displayName: 'Target Folder', + description: 'Existing folder path from list_folders to copy the emails into.', + required: true, + }), + uid_validity: uidValidityProp({ required: true }), + }, + outputSchema: copyEmailsOutputSchema, + async run({ auth, propsValue }) { + const uids = parseUids({ uids: propsValue.uids }); + const sourceFolder = propsValue.source_folder.trim(); + const targetFolder = propsValue.target_folder.trim(); + return performImapOperation(auth, async (client) => { + const result = await transferInFolder({ + client, + sourceFolder, + targetFolder, + uids, + uidValidity: propsValue.uid_validity, + mode: 'copy', + }); + return { ...result, target_folder: targetFolder }; + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/create-draft.ts b/packages/pieces/community/imap/src/lib/actions/create-draft.ts new file mode 100644 index 000000000000..a02d42795c42 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/create-draft.ts @@ -0,0 +1,120 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { + imapAuth, + performImapOperation, + withMailboxLock, + selectedMailbox, + resolveSpecialFolder, + buildDraftMessage, + assertHeaderSafe, + ImapError, +} from '../common'; +import { createDraftOutputSchema } from '../output-schemas'; + +function toStringList(values: unknown[] | undefined): string[] { + return (values ?? []).map((value) => String(value)).filter((value) => value.trim().length > 0); +} + +export const createDraft = createAction({ + auth: imapAuth, + name: 'create_draft', + classification: 'WRITE', + displayName: 'Create Draft', + description: 'Saves a new draft email in the Drafts folder.', + audience: 'ai', + aiMetadata: { + description: + 'Saves a new draft (not sent) to the mailbox Drafts folder, found automatically, or to a folder you name. IMAP cannot send mail; use an SMTP or Gmail tool to send. Set in_reply_to to a Message-ID from get_email to thread the draft as a reply (only that one Message-ID is referenced). Not idempotent: every call saves another draft.', + idempotent: false, + }, + props: { + to: Property.Array({ + displayName: 'To', + description: 'Recipients, each "name@example.com" or "Name ".', + required: true, + }), + cc: Property.Array({ displayName: 'Cc', description: 'Cc recipients.', required: false }), + bcc: Property.Array({ displayName: 'Bcc', description: 'Bcc recipients.', required: false }), + subject: Property.ShortText({ displayName: 'Subject', required: false }), + text: Property.LongText({ + displayName: 'Text Body', + description: 'Plain-text body. Provide this, an HTML body, or both.', + required: false, + }), + html: Property.LongText({ + displayName: 'HTML Body', + description: 'HTML body. Provide this, a text body, or both.', + required: false, + }), + from: Property.ShortText({ + displayName: 'From', + description: + 'Sender address. Defaults to the connection username when it is an email address.', + required: false, + }), + in_reply_to: Property.ShortText({ + displayName: 'In Reply To', + description: 'Message-ID of the email being answered, for example .', + required: false, + }), + folder: Property.ShortText({ + displayName: 'Drafts Folder', + description: 'Folder path to save the draft in. Leave empty to use the Drafts folder.', + required: false, + }), + }, + outputSchema: createDraftOutputSchema, + async run({ auth, propsValue }) { + const fromInput = propsValue.from?.trim(); + const username = auth.props.username; + const from = fromInput ? fromInput : username.includes('@') ? username : ''; + if (!from) { + throw new ImapError('Provide a From address; the connection username is not an email address.'); + } + const folderInput = propsValue.folder?.trim(); + if (folderInput) { + assertHeaderSafe({ field: 'Drafts folder', value: folderInput }); + } + const date = new Date(); + const { raw, messageId } = buildDraftMessage({ + from, + to: toStringList(propsValue.to), + cc: toStringList(propsValue.cc), + bcc: toStringList(propsValue.bcc), + subject: propsValue.subject ?? '', + text: propsValue.text ?? undefined, + html: propsValue.html ?? undefined, + inReplyTo: propsValue.in_reply_to ?? undefined, + date, + }); + + return performImapOperation(auth, async (client) => { + const drafts = folderInput + ? folderInput + : (await resolveSpecialFolder({ client, role: 'drafts' }))?.path; + if (!drafts) { + throw new ImapError( + 'No Drafts folder was found on this server. Set Drafts Folder, or create one with create_folder.' + ); + } + return withMailboxLock({ + client, + folder: drafts, + readOnly: false, + run: async () => { + const result = await client.append(drafts, raw, ['\\Draft', '\\Seen'], date); + if (!result) { + throw new ImapError(`The server refused to save the draft in "${drafts}".`); + } + const mailbox = selectedMailbox({ client }); + return { + folder: mailbox.path, + ...(result.uid === undefined ? {} : { uid: result.uid }), + uid_validity: (result.uidValidity ?? mailbox.uidValidity).toString(), + message_id: messageId, + }; + }, + }); + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/create-folder.ts b/packages/pieces/community/imap/src/lib/actions/create-folder.ts new file mode 100644 index 000000000000..27984ee5dc03 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/create-folder.ts @@ -0,0 +1,69 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { imapAuth, performImapOperation, ImapError, assertHeaderSafe } from '../common'; +import { createFolderOutputSchema } from '../output-schemas'; + +export const createFolder = createAction({ + auth: imapAuth, + name: 'create_folder', + classification: 'WRITE', + displayName: 'Create Folder', + description: 'Creates a folder, optionally inside a parent folder.', + audience: 'ai', + aiMetadata: { + description: + 'Creates an IMAP folder, optionally nested under an existing parent path from list_folders. Use before move_emails or copy_emails when the target folder does not exist yet. If the folder already exists it returns created=false instead of failing, so it is safe to retry.', + idempotent: true, + }, + props: { + name: Property.ShortText({ + displayName: 'Folder Name', + description: + 'Name of the new folder. Must not contain the server folder separator; use Parent Folder for nesting.', + required: true, + }), + parent_folder: Property.ShortText({ + displayName: 'Parent Folder', + description: 'Optional existing folder path from list_folders to create the folder inside.', + required: false, + }), + }, + outputSchema: createFolderOutputSchema, + async run({ auth, propsValue }) { + const name = propsValue.name.trim(); + const parent = propsValue.parent_folder?.trim() ?? ''; + if (!name) { + throw new ImapError('Folder name must not be empty.'); + } + assertHeaderSafe({ field: 'Folder name', value: name }); + return performImapOperation(auth, async (client) => { + const folders = await client.list(); + const delimiter = folders.find((entry) => entry.delimiter)?.delimiter ?? '/'; + if (name.includes(delimiter)) { + throw new ImapError( + `Folder name must not contain the separator "${delimiter}"; use Parent Folder for nesting.` + ); + } + if (parent && !folders.some((entry) => entry.path === parent)) { + throw new ImapError( + `Parent folder "${parent}" does not exist; pick a path from list_folders.` + ); + } + const expectedPath = parent ? `${parent}${delimiter}${name}` : name; + const existing = folders.find((entry) => entry.path === expectedPath); + if (existing) { + return { path: existing.path, created: false }; + } + try { + const result = await client.mailboxCreate(parent ? [parent, name] : name); + return { path: result.path, created: result.created }; + } catch (error) { + const after = await client.list(); + const match = after.find((entry) => entry.path === expectedPath); + if (match) { + return { path: match.path, created: false }; + } + throw error; + } + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/delete-email.ts b/packages/pieces/community/imap/src/lib/actions/delete-email.ts index c44aa3b8693c..001701a13630 100644 --- a/packages/pieces/community/imap/src/lib/actions/delete-email.ts +++ b/packages/pieces/community/imap/src/lib/actions/delete-email.ts @@ -1,5 +1,6 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { imapAuth, deleteEmail as deleteImapEmail, mailboxDropdown } from '../common'; +import { deleteEmailActionOutputSchema } from '../output-schemas'; const permanentDeletionNotice = ` **Permanent Deletion:** @@ -29,7 +30,8 @@ export const deleteEmail = createAction({ classification: 'DESTRUCTIVE', displayName: 'Delete Email', description: 'Permanently delete an email', - audience: 'both', + outputSchema: deleteEmailActionOutputSchema, + audience: 'human', aiMetadata: { description: 'Permanently deletes an email (by message UID) from an IMAP folder; this cannot be undone and does not move the message to Trash (use Move Email for that). Use only when irreversible removal is intended. Requires the folder and the UID; destructive and not idempotent since a repeat call cannot find the already-deleted message.', idempotent: false }, props, async run({ auth, propsValue }) { diff --git a/packages/pieces/community/imap/src/lib/actions/delete-emails.ts b/packages/pieces/community/imap/src/lib/actions/delete-emails.ts new file mode 100644 index 000000000000..dd6d9c76f3ed --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/delete-emails.ts @@ -0,0 +1,77 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { + imapAuth, + performImapOperation, + withMailboxLock, + selectedMailbox, + assertUidValidity, + assertUidPlusForDelete, + partitionExistingUids, + parseUids, + folderProp, + uidsProp, + uidValidityProp, + ImapError, +} from '../common'; +import { deleteEmailsOutputSchema } from '../output-schemas'; + +export const deleteEmails = createAction({ + auth: imapAuth, + name: 'delete_emails', + classification: 'DESTRUCTIVE', + displayName: 'Delete Emails Permanently', + description: 'Permanently deletes emails. This cannot be undone.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes (expunges) one or more emails by UID; this cannot be undone. Prefer trash_emails unless permanent removal is explicitly wanted, for example emptying mail that is already in Trash. On Gmail, deleting from a label folder only removes that label. Refuses on servers without UIDPLUS, where it would expunge other messages too. uid_validity is required and must come from the search_emails call that produced these UIDs; a mismatch fails the call so stale UIDs cannot delete the wrong emails. Not idempotent: a repeat call reports the UIDs as not found.', + idempotent: false, + }, + props: { + folder: folderProp({ + displayName: 'Folder', + description: 'Folder path from list_folders that contains the emails.', + }), + uids: uidsProp(), + uid_validity: uidValidityProp({ required: true }), + }, + outputSchema: deleteEmailsOutputSchema, + async run({ auth, propsValue }) { + const uids = parseUids({ uids: propsValue.uids }); + const folder = propsValue.folder.trim(); + return performImapOperation(auth, async (client) => { + assertUidPlusForDelete({ client }); + return withMailboxLock({ + client, + folder, + readOnly: false, + run: async () => { + assertUidValidity({ client, expected: propsValue.uid_validity }); + const mailbox = selectedMailbox({ client }); + const { found, notFound } = await partitionExistingUids({ client, uids }); + const range = found.join(','); + const ok = await client.messageDelete(range, { uid: true }); + if (!ok) { + throw new ImapError('The server refused to delete the emails.'); + } + const remaining = await client.search({ uid: range }, { uid: true }); + if (remaining === false) { + throw new ImapError('The delete ran, but the server rejected the follow-up check.'); + } + const stillPresent = new Set(remaining); + const deleted = found.filter((uid) => !stillPresent.has(uid)); + const notDeleted = found.filter((uid) => stillPresent.has(uid)); + if (deleted.length === 0) { + throw new ImapError('The server did not delete any of the emails.'); + } + return { + folder: mailbox.path, + deleted_uids: deleted, + not_deleted_uids: notDeleted, + not_found_uids: notFound, + }; + }, + }); + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/delete-folder.ts b/packages/pieces/community/imap/src/lib/actions/delete-folder.ts new file mode 100644 index 000000000000..9c0af13e2b09 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/delete-folder.ts @@ -0,0 +1,61 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { + imapAuth, + performImapOperation, + ImapError, + ImapMailboxNotFoundError, + isInbox, +} from '../common'; +import { deleteFolderOutputSchema } from '../output-schemas'; + +export const deleteFolder = createAction({ + auth: imapAuth, + name: 'delete_folder', + classification: 'DESTRUCTIVE', + displayName: 'Delete Folder', + description: 'Permanently deletes a folder and the emails in it.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes an IMAP folder. On most servers every message inside is destroyed; on Gmail only the label is removed. Refuses INBOX, folders that have child folders, and any folder with a special-use role (Trash, Sent, Drafts, Junk, Archive, All), including roles guessed from the folder name. Not idempotent: a repeat call fails because the folder is gone.', + idempotent: false, + }, + props: { + folder: Property.ShortText({ + displayName: 'Folder', + description: 'Folder path from list_folders.', + required: true, + }), + }, + outputSchema: deleteFolderOutputSchema, + async run({ auth, propsValue }) { + const folder = propsValue.folder.trim(); + if (!folder || isInbox({ path: folder })) { + throw new ImapError('INBOX cannot be deleted.'); + } + return performImapOperation(auth, async (client) => { + const folders = await client.list(); + const current = folders.find((entry) => entry.path === folder); + if (!current) { + throw new ImapMailboxNotFoundError(); + } + if (current.specialUse) { + const guessed = + current.specialUseSource === 'name' + ? ' (the role was guessed from the folder name)' + : ''; + throw new ImapError( + `"${folder}" is a ${current.specialUse} folder${guessed} and will not be deleted.` + ); + } + const children = folders.filter((entry) => entry.parentPath === folder); + if (children.length > 0) { + throw new ImapError( + `"${folder}" has ${children.length} child folder(s); delete or move them first.` + ); + } + const result = await client.mailboxDelete(folder); + return { path: result.path, deleted: true }; + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/get-email.ts b/packages/pieces/community/imap/src/lib/actions/get-email.ts new file mode 100644 index 000000000000..56a663cef7c3 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/get-email.ts @@ -0,0 +1,112 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { simpleParser } from 'mailparser'; +import { + imapAuth, + performImapOperation, + withMailboxLock, + selectedMailbox, + assertOptionalUidValidity, + parseSingleUid, + flagsToArray, + mapParsedAddresses, + writeAttachments, + toIsoDate, + folderProp, + uidValidityProp, + ImapEmailNotFoundError, + ImapError, +} from '../common'; +import { getEmailOutputSchema } from '../output-schemas'; + +const MAX_MESSAGE_BYTES = 25 * 1024 * 1024; + +export const getEmail = createAction({ + auth: imapAuth, + name: 'get_email', + classification: 'READ', + displayName: 'Get Email', + description: 'Gets the full content of one email, including attachments.', + audience: 'ai', + aiMetadata: { + description: + 'Fetches and parses one email by UID: headers, text and HTML bodies, flags, and attachments saved as files. Use after search_emails to read a specific message. The message is not marked as read. Pass the uid_validity from the same search_emails call so a stale UID is reported instead of returning an unrelated email. Messages over 25 MB are refused. Read-only, safe to retry.', + idempotent: true, + }, + props: { + folder: folderProp({ + displayName: 'Folder', + description: 'Folder path from list_folders that contains the email.', + }), + uid: Property.Number({ + displayName: 'Message UID', + description: 'UID from search_emails in the same folder.', + required: true, + }), + uid_validity: uidValidityProp({ required: false }), + }, + outputSchema: getEmailOutputSchema, + async run({ auth, propsValue, files }) { + const folder = propsValue.folder.trim(); + const uid = parseSingleUid({ uid: propsValue.uid }); + return performImapOperation(auth, async (client) => + withMailboxLock({ + client, + folder, + readOnly: true, + run: async () => { + assertOptionalUidValidity({ client, expected: propsValue.uid_validity }); + const mailbox = selectedMailbox({ client }); + const meta = await client.fetchOne( + String(uid), + { uid: true, size: true }, + { uid: true } + ); + if (!meta) { + throw new ImapEmailNotFoundError(); + } + if ((meta.size ?? 0) > MAX_MESSAGE_BYTES) { + throw new ImapError( + `The email is ${meta.size} bytes, above the 25 MB limit for get_email.` + ); + } + const message = await client.fetchOne( + String(uid), + { uid: true, source: true, flags: true, size: true, internalDate: true }, + { uid: true } + ); + if (!message || !message.source) { + throw new ImapEmailNotFoundError(); + } + const parsed = await simpleParser(message.source); + const from = mapParsedAddresses(parsed.from); + const flags = flagsToArray(message.flags); + const references = parsed.references; + return { + folder: mailbox.path, + uid: message.uid, + uid_validity: mailbox.uidValidity.toString(), + message_id: parsed.messageId ?? null, + in_reply_to: parsed.inReplyTo ?? null, + references: references === undefined ? [] : Array.isArray(references) ? references : [references], + subject: parsed.subject ?? '', + date: toIsoDate(parsed.date), + internal_date: toIsoDate(message.internalDate), + from_name: from[0]?.name ?? '', + from_address: from[0]?.address ?? '', + to: mapParsedAddresses(parsed.to), + cc: mapParsedAddresses(parsed.cc), + bcc: mapParsedAddresses(parsed.bcc), + reply_to: mapParsedAddresses(parsed.replyTo), + text: parsed.text ?? null, + html: parsed.html === false ? null : parsed.html, + flags, + seen: flags.includes('\\Seen'), + flagged: flags.includes('\\Flagged'), + size: message.size ?? null, + attachments: await writeAttachments({ attachments: parsed.attachments, files }), + }; + }, + }) + ); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/get-folder-status.ts b/packages/pieces/community/imap/src/lib/actions/get-folder-status.ts new file mode 100644 index 000000000000..f179ca2bd022 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/get-folder-status.ts @@ -0,0 +1,56 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { + imapAuth, + performImapOperation, + bigintToString, + folderProp, + ImapError, +} from '../common'; +import { getFolderStatusOutputSchema } from '../output-schemas'; + +export const getFolderStatus = createAction({ + auth: imapAuth, + name: 'get_folder_status', + classification: 'READ', + displayName: 'Get Folder Status', + description: 'Gets message counts and UID information for a folder.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the total, unread and recent message counts plus uid_next and uid_validity of one IMAP folder without opening it. Use for a quick unread count or to check whether UIDs from an earlier search are still valid; use search_emails to list the messages themselves. Read-only, safe to retry.', + idempotent: true, + }, + props: { + folder: folderProp({ + displayName: 'Folder', + description: 'Folder path from list_folders.', + }), + }, + outputSchema: getFolderStatusOutputSchema, + async run({ auth, propsValue }) { + const folder = propsValue.folder.trim(); + return performImapOperation(auth, async (client) => { + const status = await client.status(folder, { + messages: true, + unseen: true, + recent: true, + uidNext: true, + uidValidity: true, + highestModseq: true, + }); + if (!status) { + throw new ImapError(`Could not read the status of "${folder}".`); + } + const highestModseq = bigintToString(status.highestModseq); + return { + path: status.path, + messages: status.messages ?? null, + unseen: status.unseen ?? null, + recent: status.recent ?? null, + uid_next: status.uidNext ?? null, + uid_validity: bigintToString(status.uidValidity) ?? null, + ...(highestModseq ? { highest_modseq: highestModseq } : {}), + }; + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/get-quota.ts b/packages/pieces/community/imap/src/lib/actions/get-quota.ts new file mode 100644 index 000000000000..eab40d15ab7b --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/get-quota.ts @@ -0,0 +1,70 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { imapAuth, performImapOperation, folderProp, ImapError } from '../common'; +import { getQuotaOutputSchema } from '../output-schemas'; + +function readResource({ + source, + resource, +}: { + source: unknown; + resource: string; +}): { used: number | null; limit: number | null } | null { + if (typeof source !== 'object' || source === null || !(resource in source)) { + return null; + } + const entry: unknown = Reflect.get(source, resource); + if (typeof entry !== 'object' || entry === null) { + return null; + } + const used: unknown = Reflect.get(entry, 'usage') ?? Reflect.get(entry, 'used'); + const limit: unknown = Reflect.get(entry, 'limit'); + return { + used: typeof used === 'number' ? used : null, + limit: typeof limit === 'number' ? limit : null, + }; +} + +export const getQuota = createAction({ + auth: imapAuth, + name: 'get_quota', + classification: 'READ', + displayName: 'Get Quota', + description: 'Gets the storage and message quota for a folder.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the storage (in bytes) and message-count usage and limits of the quota root that applies to a folder, via the IMAP QUOTA extension. Use to check whether the mailbox is near full. has_quota=false means the server reports no quota for that folder. Fails with a clear message when the server lacks QUOTA. Read-only, safe to retry.', + idempotent: true, + }, + props: { + folder: folderProp({ + displayName: 'Folder', + description: 'Folder path from list_folders.', + }), + }, + outputSchema: getQuotaOutputSchema, + async run({ auth, propsValue }) { + const folder = propsValue.folder.trim(); + return performImapOperation(auth, async (client) => { + if (!client.capabilities.has('QUOTA')) { + throw new ImapError('The server does not support the QUOTA extension.'); + } + const result = await client.getQuota(folder); + if (!result) { + throw new ImapError(`The server returned no quota information for "${folder}".`); + } + const storage = readResource({ source: result, resource: 'storage' }); + const messages = + readResource({ source: result, resource: 'message' }) ?? + readResource({ source: result, resource: 'messages' }); + return { + path: result.path, + has_quota: storage !== null || messages !== null, + storage_used_bytes: storage?.used ?? null, + storage_limit_bytes: storage?.limit ?? null, + messages_used: messages?.used ?? null, + messages_limit: messages?.limit ?? null, + }; + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/list-folders.ts b/packages/pieces/community/imap/src/lib/actions/list-folders.ts new file mode 100644 index 000000000000..e24503690358 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/list-folders.ts @@ -0,0 +1,52 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { imapAuth, performImapOperation, flagsToArray } from '../common'; +import { listFoldersOutputSchema } from '../output-schemas'; + +export const listFolders = createAction({ + auth: imapAuth, + name: 'list_folders', + classification: 'SEARCH', + displayName: 'List Folders', + description: 'Lists every folder in the mailbox.', + audience: 'ai', + aiMetadata: { + description: + 'Lists all IMAP folders with their full path, delimiter, parent and detected special-use role (Trash, Drafts, Sent, Junk, Archive, All). Call first to learn exact folder paths for every other IMAP tool. special_use_source "name" means the role was guessed from the folder name. Set include_counts only when counts are needed: it costs one STATUS per folder. Read-only, safe to retry.', + idempotent: true, + }, + props: { + include_counts: Property.Checkbox({ + displayName: 'Include Message Counts', + description: + 'Also return the total and unread message count of each folder. Slower on accounts with many folders.', + required: false, + defaultValue: false, + }), + }, + outputSchema: listFoldersOutputSchema, + async run({ auth, propsValue }) { + const includeCounts = propsValue.include_counts === true; + return performImapOperation(auth, async (client) => { + const entries = await client.list( + includeCounts ? { statusQuery: { messages: true, unseen: true } } : {} + ); + const folders = entries.map((entry) => { + const status = entry.status; + const hasCounts = + includeCounts && status !== undefined && typeof status.messages === 'number'; + return { + path: entry.path, + name: entry.name, + delimiter: entry.delimiter, + parent_path: entry.parentPath, + special_use: entry.specialUse ?? null, + special_use_source: entry.specialUseSource ?? null, + flags: flagsToArray(entry.flags), + subscribed: entry.subscribed, + ...(hasCounts ? { messages: status.messages, unseen: status.unseen ?? null } : {}), + }; + }); + return { folders, count: folders.length }; + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/mark-email-read.ts b/packages/pieces/community/imap/src/lib/actions/mark-email-read.ts index d080e8082ab5..d7afa8f37d4a 100644 --- a/packages/pieces/community/imap/src/lib/actions/mark-email-read.ts +++ b/packages/pieces/community/imap/src/lib/actions/mark-email-read.ts @@ -1,5 +1,6 @@ import { createAction, Property } from '@activepieces/pieces-framework'; import { imapAuth, setEmailReadStatus, mailboxDropdown } from '../common'; +import { markEmailReadActionOutputSchema } from '../output-schemas'; const props = { mailbox: mailboxDropdown({ @@ -26,7 +27,8 @@ export const markEmailAsRead = createAction({ classification: 'WRITE', displayName: 'Mark Email as Read/Unread', description: 'Sets the read status of an email', - audience: 'both', + outputSchema: markEmailReadActionOutputSchema, + audience: 'human', aiMetadata: { description: 'Sets the read/unread (\\Seen) flag on a specific email in an IMAP mailbox, identified by its message UID. Use to mark a message as read after processing it or to flag one as unread. Requires the exact mailbox folder and UID; this is idempotent since it sets the flag to the chosen target state.', idempotent: true }, props, async run({ auth, propsValue }) { diff --git a/packages/pieces/community/imap/src/lib/actions/move-email.ts b/packages/pieces/community/imap/src/lib/actions/move-email.ts index 19f84ddbd15d..e0f5077fc0ee 100644 --- a/packages/pieces/community/imap/src/lib/actions/move-email.ts +++ b/packages/pieces/community/imap/src/lib/actions/move-email.ts @@ -27,7 +27,7 @@ export const moveEmail = createAction({ displayName: 'Move Email', description: 'Move an email to another mailbox', outputSchema: moveEmailActionOutputSchema, - audience: 'both', + audience: 'human', aiMetadata: { description: 'Moves an email (by message UID) from a source IMAP folder to a target folder, removing it from the source. Use to file, archive, or send a message to a Trash folder. Requires the source folder, target folder, and the UID; not idempotent since after the move the message no longer exists at the source UID and a repeat call cannot find it.', idempotent: false }, props, async run({ auth, propsValue }) { diff --git a/packages/pieces/community/imap/src/lib/actions/move-emails.ts b/packages/pieces/community/imap/src/lib/actions/move-emails.ts new file mode 100644 index 000000000000..8de097c22061 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/move-emails.ts @@ -0,0 +1,55 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { + imapAuth, + performImapOperation, + transferInFolder, + parseUids, + folderProp, + uidsProp, + uidValidityProp, +} from '../common'; +import { moveEmailsOutputSchema } from '../output-schemas'; + +export const moveEmails = createAction({ + auth: imapAuth, + name: 'move_emails', + classification: 'WRITE', + displayName: 'Move Emails', + description: 'Moves emails from one folder to another.', + audience: 'ai', + aiMetadata: { + description: + 'Moves one or more emails by UID from a source folder to an existing target folder. Use trash_emails or archive_emails when the destination is the Trash or Archive folder, and create_folder first if the target does not exist. UIDs that no longer exist are reported, not failed. uid_validity is required and must come from the search_emails call that produced these UIDs; a mismatch fails the call so stale UIDs cannot move the wrong emails. Refuses on servers that support neither MOVE nor UIDPLUS. Not idempotent: moved UIDs are gone from the source.', + idempotent: false, + }, + props: { + source_folder: folderProp({ + displayName: 'Source Folder', + description: 'Folder path from list_folders that contains the emails.', + }), + uids: uidsProp(), + target_folder: Property.ShortText({ + displayName: 'Target Folder', + description: 'Existing folder path from list_folders to move the emails into.', + required: true, + }), + uid_validity: uidValidityProp({ required: true }), + }, + outputSchema: moveEmailsOutputSchema, + async run({ auth, propsValue }) { + const uids = parseUids({ uids: propsValue.uids }); + const sourceFolder = propsValue.source_folder.trim(); + const targetFolder = propsValue.target_folder.trim(); + return performImapOperation(auth, async (client) => { + const result = await transferInFolder({ + client, + sourceFolder, + targetFolder, + uids, + uidValidity: propsValue.uid_validity, + mode: 'move', + }); + return { ...result, target_folder: targetFolder }; + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/rename-folder.ts b/packages/pieces/community/imap/src/lib/actions/rename-folder.ts new file mode 100644 index 000000000000..be090aeaaabd --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/rename-folder.ts @@ -0,0 +1,69 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { + imapAuth, + performImapOperation, + ImapError, + ImapMailboxNotFoundError, + isInbox, + assertHeaderSafe, +} from '../common'; +import { renameFolderOutputSchema } from '../output-schemas'; + +export const renameFolder = createAction({ + auth: imapAuth, + name: 'rename_folder', + classification: 'WRITE', + displayName: 'Rename Folder', + description: 'Renames or moves a folder to a new path.', + audience: 'ai', + aiMetadata: { + description: + 'Renames an IMAP folder, or moves it under another parent, by giving its full new path. INBOX and folders the server flags as special-use (Trash, Sent, Drafts and so on) cannot be renamed. Not idempotent: a repeat call fails because the old path no longer exists.', + idempotent: false, + }, + props: { + folder: Property.ShortText({ + displayName: 'Folder', + description: 'Current folder path from list_folders.', + required: true, + }), + new_path: Property.ShortText({ + displayName: 'New Path', + description: + 'Full new path, using the folder separator from list_folders for nesting (for example "Projects/2026").', + required: true, + }), + }, + outputSchema: renameFolderOutputSchema, + async run({ auth, propsValue }) { + const folder = propsValue.folder.trim(); + const newPath = propsValue.new_path.trim(); + assertHeaderSafe({ field: 'New path', value: newPath }); + if (!folder || !newPath) { + throw new ImapError('Both the folder and the new path are required.'); + } + if (isInbox({ path: folder }) || isInbox({ path: newPath })) { + throw new ImapError('INBOX cannot be renamed, and no folder can be renamed to INBOX.'); + } + if (folder === newPath) { + throw new ImapError('The new path is the same as the current path.'); + } + return performImapOperation(auth, async (client) => { + const folders = await client.list(); + const current = folders.find((entry) => entry.path === folder); + if (!current) { + throw new ImapMailboxNotFoundError(); + } + if (current.specialUse && current.specialUseSource === 'extension') { + throw new ImapError( + `"${folder}" is the server's ${current.specialUse} folder and cannot be renamed.` + ); + } + if (folders.some((entry) => entry.path === newPath)) { + throw new ImapError(`A folder named "${newPath}" already exists.`); + } + const result = await client.mailboxRename(folder, newPath); + return { path: result.path, new_path: result.newPath }; + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/search-emails.ts b/packages/pieces/community/imap/src/lib/actions/search-emails.ts new file mode 100644 index 000000000000..d2af5d4f0e41 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/search-emails.ts @@ -0,0 +1,169 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { type SearchObject } from 'imapflow'; +import { + imapAuth, + performImapOperation, + withMailboxLock, + selectedMailbox, + summarizeMessage, + folderProp, + ImapError, + type EmailSummary, +} from '../common'; +import { searchEmailsOutputSchema } from '../output-schemas'; + +function optionalText(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export const searchEmails = createAction({ + auth: imapAuth, + name: 'search_emails', + classification: 'SEARCH', + displayName: 'Search Emails', + description: 'Searches a folder and returns matching email summaries, newest first.', + audience: 'ai', + aiMetadata: { + description: + 'Searches one IMAP folder and returns envelope summaries (uid, subject, from, to, date, flags, size) newest first, without bodies. This is the source of message UIDs for every other IMAP email tool; pass the returned uid_validity along to detect stale UIDs. All filters are combined with AND; with no filters it lists the newest messages. Use get_email to read a full message. Read-only, safe to retry.', + idempotent: true, + }, + props: { + folder: folderProp({ + displayName: 'Folder', + description: 'Folder path from list_folders.', + }), + from: Property.ShortText({ displayName: 'From', description: 'Text contained in the From header.', required: false }), + to: Property.ShortText({ displayName: 'To', description: 'Text contained in the To header.', required: false }), + subject: Property.ShortText({ displayName: 'Subject', description: 'Text contained in the subject.', required: false }), + body: Property.ShortText({ displayName: 'Body', description: 'Text contained in the message body.', required: false }), + text: Property.ShortText({ + displayName: 'Text', + description: 'Text contained anywhere in the headers or body.', + required: false, + }), + message_id: Property.ShortText({ + displayName: 'Message-ID', + description: 'Exact Message-ID header value, for example .', + required: false, + }), + since: Property.DateTime({ + displayName: 'Received Since', + description: + 'Only messages received on or after this date. Day granularity on most servers (time of day is ignored).', + required: false, + }), + before: Property.DateTime({ + displayName: 'Received Before', + description: + 'Only messages received before this date. Day granularity on most servers (time of day is ignored).', + required: false, + }), + read_status: Property.StaticDropdown({ + displayName: 'Read Status', + description: 'Filter by read state. Leave empty for both.', + required: false, + options: { + options: [ + { label: 'Read', value: 'read' }, + { label: 'Unread', value: 'unread' }, + ], + }, + }), + flagged: Property.StaticDropdown({ + displayName: 'Flagged', + description: 'Filter by the flagged (starred) state. Leave empty for both.', + required: false, + options: { + options: [ + { label: 'Flagged', value: 'flagged' }, + { label: 'Not Flagged', value: 'unflagged' }, + ], + }, + }), + keyword: Property.ShortText({ + displayName: 'Keyword', + description: 'Only messages carrying this custom keyword (IMAP flag).', + required: false, + }), + limit: Property.Number({ + displayName: 'Limit', + description: 'Maximum number of emails to return (1-100).', + required: false, + defaultValue: 25, + }), + }, + outputSchema: searchEmailsOutputSchema, + async run({ auth, propsValue }) { + const folder = propsValue.folder.trim(); + const limit = propsValue.limit ?? 25; + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + throw new ImapError('Limit must be a whole number between 1 and 100.'); + } + const criteria: SearchObject = {}; + const from = optionalText(propsValue.from); + const to = optionalText(propsValue.to); + const subject = optionalText(propsValue.subject); + const body = optionalText(propsValue.body); + const text = optionalText(propsValue.text); + const messageId = optionalText(propsValue.message_id); + const keyword = optionalText(propsValue.keyword); + if (from) criteria.from = from; + if (to) criteria.to = to; + if (subject) criteria.subject = subject; + if (body) criteria.body = body; + if (text) criteria.text = text; + if (messageId) criteria.header = { 'message-id': messageId }; + if (propsValue.since) criteria.since = new Date(propsValue.since); + if (propsValue.before) criteria.before = new Date(propsValue.before); + if (propsValue.read_status === 'read') criteria.seen = true; + if (propsValue.read_status === 'unread') criteria.seen = false; + if (propsValue.flagged === 'flagged') criteria.flagged = true; + if (propsValue.flagged === 'unflagged') criteria.flagged = false; + if (keyword) criteria.keyword = keyword; + if (Object.keys(criteria).length === 0) criteria.all = true; + + return performImapOperation(auth, async (client) => + withMailboxLock({ + client, + folder, + readOnly: true, + run: async () => { + const mailbox = selectedMailbox({ client }); + const base = { + folder: mailbox.path, + uid_validity: mailbox.uidValidity.toString(), + }; + const permanent = mailbox.permanentFlags; + const keywordDropped = + keyword !== undefined && + !mailbox.flags.has(keyword) && + permanent !== undefined && + !permanent.has('\\*') && + !permanent.has(keyword); + if (keywordDropped) { + return { ...base, total_matches: 0, count: 0, emails: [] }; + } + const matches = await client.search(criteria, { uid: true }); + if (matches === false) { + throw new ImapError('The server rejected the search.'); + } + const top = [...matches].sort((a, b) => b - a).slice(0, limit); + const emails: EmailSummary[] = []; + if (top.length > 0) { + for await (const message of client.fetch( + top.join(','), + { uid: true, envelope: true, flags: true, size: true, internalDate: true }, + { uid: true } + )) { + emails.push(summarizeMessage({ message })); + } + } + emails.sort((a, b) => b.uid - a.uid); + return { ...base, total_matches: matches.length, count: emails.length, emails }; + }, + }) + ); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/trash-emails.ts b/packages/pieces/community/imap/src/lib/actions/trash-emails.ts new file mode 100644 index 000000000000..de88fd2fe3fe --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/trash-emails.ts @@ -0,0 +1,67 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { + imapAuth, + performImapOperation, + transferInFolder, + resolveSpecialFolder, + parseUids, + samePath, + folderProp, + uidsProp, + uidValidityProp, + ImapError, +} from '../common'; +import { trashEmailsOutputSchema } from '../output-schemas'; + +export const trashEmails = createAction({ + auth: imapAuth, + name: 'trash_emails', + classification: 'WRITE', + displayName: 'Move Emails to Trash', + description: 'Moves emails to the Trash folder.', + audience: 'ai', + aiMetadata: { + description: + 'Moves one or more emails by UID to the mailbox Trash folder, found automatically. This is the recoverable way to remove mail; prefer it over delete_emails. When special_use_source is "name", the Trash folder was guessed from its name. Fails, naming move_emails, if no Trash folder exists; use delete_emails for mail already in Trash. uid_validity is required and must come from the search_emails call that produced these UIDs; a mismatch fails the call so stale UIDs cannot trash the wrong emails. Not idempotent: trashed UIDs are gone from the source.', + idempotent: false, + }, + props: { + folder: folderProp({ + displayName: 'Folder', + description: 'Folder path from list_folders that contains the emails.', + }), + uids: uidsProp(), + uid_validity: uidValidityProp({ required: true }), + }, + outputSchema: trashEmailsOutputSchema, + async run({ auth, propsValue }) { + const uids = parseUids({ uids: propsValue.uids }); + const sourceFolder = propsValue.folder.trim(); + return performImapOperation(auth, async (client) => { + const trash = await resolveSpecialFolder({ client, role: 'trash' }); + if (!trash) { + throw new ImapError( + 'No Trash folder was found on this server. Use move_emails with an explicit target folder.' + ); + } + if (samePath({ a: sourceFolder, b: trash.path })) { + throw new ImapError( + 'These emails are already in the Trash folder. Use delete_emails to remove them permanently.' + ); + } + const result = await transferInFolder({ + client, + sourceFolder, + targetFolder: trash.path, + uids, + uidValidity: propsValue.uid_validity, + mode: 'move', + }); + return { + ...result, + destination_folder: trash.path, + special_use_source: trash.special_use_source, + }; + }); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/actions/update-email-flags.ts b/packages/pieces/community/imap/src/lib/actions/update-email-flags.ts new file mode 100644 index 000000000000..64af53b3ced2 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/actions/update-email-flags.ts @@ -0,0 +1,207 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { + imapAuth, + performImapOperation, + withMailboxLock, + selectedMailbox, + assertUidValidity, + partitionExistingUids, + parseUids, + flagsToArray, + folderProp, + uidsProp, + uidValidityProp, + ImapError, +} from '../common'; +import { updateEmailFlagsOutputSchema } from '../output-schemas'; + +const KEYWORD_ATOM = /^[\x21-\x7e]+$/; +const KEYWORD_FORBIDDEN = /[(){%*"\\\]]/; + +function parseKeywords({ values }: { values: unknown[] | undefined }): string[] { + const keywords = (values ?? []) + .map((value) => String(value).trim()) + .filter((value) => value.length > 0); + for (const keyword of keywords) { + if (!KEYWORD_ATOM.test(keyword) || KEYWORD_FORBIDDEN.test(keyword)) { + throw new ImapError( + `"${keyword}" is not a valid keyword: use printable ASCII without spaces or any of ( ) { % * " \\ ].` + ); + } + if (keyword.startsWith('\\')) { + throw new ImapError(`"${keyword}" is a system flag, not a keyword.`); + } + } + return [...new Set(keywords)]; +} + +function toggleOption({ + on, + off, +}: { + on: { label: string; value: string }; + off: { label: string; value: string }; +}) { + return { options: [on, off] }; +} + +export const updateEmailFlags = createAction({ + auth: imapAuth, + name: 'update_email_flags', + classification: 'WRITE', + displayName: 'Update Email Flags', + description: 'Marks emails read or unread, flagged or unflagged, answered, and adds or removes keywords.', + audience: 'ai', + aiMetadata: { + description: + 'Changes flags on one or more emails in a folder: read/unread, flagged/unflagged, answered, and custom keywords (labels). Only the options you set are changed; everything else is left untouched. Use trash_emails or delete_emails to remove messages instead. uid_validity is required and must come from the search_emails call that produced these UIDs; a mismatch fails the call so stale UIDs cannot re-flag the wrong emails. Sets a target state, so it is safe to retry.', + idempotent: true, + }, + props: { + folder: folderProp({ + displayName: 'Folder', + description: 'Folder path from list_folders that contains the emails.', + }), + uids: uidsProp(), + uid_validity: uidValidityProp({ required: true }), + read: Property.StaticDropdown({ + displayName: 'Read Status', + description: 'Leave empty to keep the current read status.', + required: false, + options: toggleOption({ + on: { label: 'Mark as Read', value: 'mark_read' }, + off: { label: 'Mark as Unread', value: 'mark_unread' }, + }), + }), + flagged: Property.StaticDropdown({ + displayName: 'Flagged', + description: 'Leave empty to keep the current flagged status.', + required: false, + options: toggleOption({ + on: { label: 'Flag', value: 'flag' }, + off: { label: 'Unflag', value: 'unflag' }, + }), + }), + answered: Property.StaticDropdown({ + displayName: 'Answered', + description: 'Leave empty to keep the current answered status.', + required: false, + options: toggleOption({ + on: { label: 'Mark as Answered', value: 'mark_answered' }, + off: { label: 'Mark as Not Answered', value: 'unmark_answered' }, + }), + }), + add_keywords: Property.Array({ + displayName: 'Add Keywords', + description: 'Custom keywords to add, for example "invoice".', + required: false, + }), + remove_keywords: Property.Array({ + displayName: 'Remove Keywords', + description: 'Custom keywords to remove.', + required: false, + }), + }, + outputSchema: updateEmailFlagsOutputSchema, + async run({ auth, propsValue }) { + const folder = propsValue.folder.trim(); + const uids = parseUids({ uids: propsValue.uids }); + const add: string[] = []; + const remove: string[] = []; + const toggles = [ + { choice: propsValue.read, on: 'mark_read', off: 'mark_unread', flag: '\\Seen' }, + { choice: propsValue.flagged, on: 'flag', off: 'unflag', flag: '\\Flagged' }, + { + choice: propsValue.answered, + on: 'mark_answered', + off: 'unmark_answered', + flag: '\\Answered', + }, + ]; + for (const { choice, on, off, flag } of toggles) { + if (choice === on) { + add.push(flag); + } else if (choice === off) { + remove.push(flag); + } + } + const addKeywords = parseKeywords({ values: propsValue.add_keywords }); + const removeKeywords = parseKeywords({ values: propsValue.remove_keywords }); + const overlap = addKeywords.filter((keyword) => removeKeywords.includes(keyword)); + if (overlap.length > 0) { + throw new ImapError(`Keyword "${overlap[0]}" cannot be both added and removed.`); + } + add.push(...addKeywords); + remove.push(...removeKeywords); + if (add.length === 0 && remove.length === 0) { + throw new ImapError('Choose at least one flag or keyword to change.'); + } + + return performImapOperation(auth, async (client) => + withMailboxLock({ + client, + folder, + readOnly: false, + run: async () => { + assertUidValidity({ client, expected: propsValue.uid_validity }); + const mailbox = selectedMailbox({ client }); + const permanent = mailbox.permanentFlags; + const keywords = [...addKeywords, ...removeKeywords]; + if ( + keywords.length > 0 && + permanent && + !permanent.has('\\*') && + !keywords.every((keyword) => permanent.has(keyword)) + ) { + throw new ImapError('This folder does not accept custom keywords.'); + } + const { found, notFound } = await partitionExistingUids({ client, uids }); + const range = found.join(','); + if (add.length > 0) { + const ok = await client.messageFlagsAdd(range, add, { uid: true }); + if (!ok) { + throw new ImapError(`The server refused to add ${add.join(', ')}.`); + } + } + if (remove.length > 0) { + const ok = await client.messageFlagsRemove(range, remove, { uid: true }); + if (!ok) { + throw new ImapError(`The server refused to remove ${remove.join(', ')}.`); + } + } + const emails: Array<{ + uid: number; + flags: string[]; + seen: boolean; + flagged: boolean; + answered: boolean; + }> = []; + for await (const message of client.fetch(range, { uid: true, flags: true }, { uid: true })) { + const flags = flagsToArray(message.flags); + const missing = add.find((flag) => !flags.includes(flag)); + const lingering = remove.find((flag) => flags.includes(flag)); + if (missing || lingering) { + throw new ImapError( + `The server did not apply ${missing ?? lingering} to UID ${message.uid}; the folder may not allow it.` + ); + } + emails.push({ + uid: message.uid, + flags, + seen: flags.includes('\\Seen'), + flagged: flags.includes('\\Flagged'), + answered: flags.includes('\\Answered'), + }); + } + emails.sort((a, b) => a.uid - b.uid); + return { + folder: mailbox.path, + uid_validity: mailbox.uidValidity.toString(), + emails, + not_found_uids: notFound, + }; + }, + }) + ); + }, +}); diff --git a/packages/pieces/community/imap/src/lib/common/imap.ts b/packages/pieces/community/imap/src/lib/common/imap.ts index 1d25ab471d14..504f71fe79ab 100644 --- a/packages/pieces/community/imap/src/lib/common/imap.ts +++ b/packages/pieces/community/imap/src/lib/common/imap.ts @@ -23,6 +23,11 @@ import { ImapConnectionLostError, ImapEmailNotFoundError, } from './errors'; +import { + assertFolderExists, + assertUidPlusForDelete, + transferMessages, +} from './message'; type Message = { data: ParsedMail & { uid: number }; @@ -50,7 +55,11 @@ async function confirmEmailExists( { uid: true } ); - if (!searchResult || searchResult.length === 0) { + if (searchResult === false) { + throw new ImapError('The server rejected the search for the email.'); + } + + if (searchResult.length === 0) { throw new ImapEmailNotFoundError(); } } @@ -81,6 +90,7 @@ async function copyEmail({ auth, sourceMailbox, async (imapClient) => { + await assertFolderExists({ client: imapClient, path: targetMailbox }); await confirmEmailExists(imapClient, uid); const result: false | CopyResponseObject = await imapClient.messageCopy( @@ -108,12 +118,31 @@ async function deleteEmail({ mailbox: string; uid: number; }): Promise { - return (await performMailboxOperation(auth, mailbox, async (imapClient) => { - await confirmEmailExists(imapClient, uid); - await imapClient.messageDelete({ uid: uid.toString() }, { uid: true }); + return (await performMailboxOperation( + auth, + mailbox, + async (imapClient) => { + assertUidPlusForDelete({ client: imapClient }); + await confirmEmailExists(imapClient, uid); + const deleted = await imapClient.messageDelete( + { uid: uid.toString() }, + { uid: true } + ); + if (!deleted) { + throw new ImapError('The server refused to delete the email.'); + } + const remaining = await imapClient.search( + { uid: uid.toString() }, + { uid: true } + ); + if (remaining === false || remaining.length > 0) { + throw new ImapError('The email is still present after the delete.'); + } - return { success: true }; - })) as T; + return { success: true }; + }, + { readOnly: false } + )) as T; } async function fetchEmails({ @@ -169,21 +198,20 @@ async function moveEmail({ auth, sourceMailbox, async (imapClient) => { + await assertFolderExists({ client: imapClient, path: targetMailbox }); await confirmEmailExists(imapClient, uid); - const result: false | CopyResponseObject = await imapClient.messageMove( - { uid: uid.toString() }, - targetMailbox, - { uid: true } - ); - - if (result) { - const newUid = result.uidMap?.get(uid); - return { success: true, newUid }; - } + const result = await transferMessages({ + client: imapClient, + uids: [uid], + target: targetMailbox, + mode: 'move', + }); - return { success: false }; - } + const newUid = result.uidMap?.get(uid); + return { success: true, newUid }; + }, + { readOnly: false } )) as T; } @@ -199,10 +227,10 @@ async function parseStream(stream: Readable) { }); } -async function performImapOperation( +async function performImapOperation( auth: ImapAuth, - callback: (imapClient: ImapFlow) => Promise -) { + callback: (imapClient: ImapFlow) => Promise +): Promise { let imapClient: ImapFlow | null = null; try { @@ -220,7 +248,10 @@ async function performImapOperation( throw new ImapConnectionTimeoutError(); } else if (imapError.code === 'ERR_SSL_PACKET_LENGTH_TOO_LONG') { throw new ImapSslPacketLengthTooLongError(); - } else if (imapError.responseText?.includes('AUTH')) { + } else if ( + imapError.responseText?.includes('AUTH') || + ('authenticationFailed' in imapError && imapError.authenticationFailed === true) + ) { throw new ImapAuthenticationError(); } else if (imapError.message?.includes('IMAP connection')) { throw new ImapConnectionLostError(); @@ -228,10 +259,17 @@ async function performImapOperation( throw new ImapCertificateError(); } else if (imapError instanceof ImapError) { throw imapError; + } else if ( + imapError.code === 'NotFound' || + ('mailboxMissing' in imapError && imapError.mailboxMissing === true) + ) { + throw new ImapMailboxNotFoundError(); } throw new ImapError( - imapError.message || 'Failed to perform IMAP operation' + imapError.responseText || + imapError.message || + 'Failed to perform IMAP operation' ); } finally { try { diff --git a/packages/pieces/community/imap/src/lib/common/index.ts b/packages/pieces/community/imap/src/lib/common/index.ts index d19d36919668..e1cad987be5a 100644 --- a/packages/pieces/community/imap/src/lib/common/index.ts +++ b/packages/pieces/community/imap/src/lib/common/index.ts @@ -3,3 +3,6 @@ export * from './constants'; export * from './errors'; export * from './imap'; export * from './props'; +export * from './message'; +export * from './special-use'; +export * from './mime'; diff --git a/packages/pieces/community/imap/src/lib/common/message.ts b/packages/pieces/community/imap/src/lib/common/message.ts new file mode 100644 index 000000000000..544b4454b3ae --- /dev/null +++ b/packages/pieces/community/imap/src/lib/common/message.ts @@ -0,0 +1,451 @@ +import { FilesService, Property } from '@activepieces/pieces-framework'; +import { + type CopyResponseObject, + type FetchMessageObject, + type ImapFlow, + type MailboxObject, + type MessageAddressObject, +} from 'imapflow'; +import { type AddressObject, type Attachment } from 'mailparser'; +import { ImapEmailNotFoundError, ImapError } from './errors'; + +const MAX_UIDS = 500; + +function flagsToArray(flags: Set | undefined): string[] { + return flags ? [...flags].sort() : []; +} + +function bigintToString(value: bigint | undefined): string | undefined { + return value === undefined ? undefined : value.toString(); +} + +function toIsoDate(value: Date | string | undefined): string | null { + if (value === undefined) { + return null; + } + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +function mapEnvelopeAddresses( + addresses: MessageAddressObject[] | undefined +): EmailAddress[] { + return (addresses ?? []).map((entry) => ({ + name: entry.name ?? '', + address: entry.address ?? '', + })); +} + +function mapParsedAddresses( + value: AddressObject | AddressObject[] | undefined +): EmailAddress[] { + if (!value) { + return []; + } + const objects = Array.isArray(value) ? value : [value]; + const result: EmailAddress[] = []; + for (const object of objects) { + for (const entry of object.value) { + if (entry.group && entry.group.length > 0) { + for (const member of entry.group) { + result.push({ name: member.name ?? '', address: member.address ?? '' }); + } + } else { + result.push({ name: entry.name ?? '', address: entry.address ?? '' }); + } + } + } + return result; +} + +function summarizeMessage({ + message, +}: { + message: FetchMessageObject; +}): EmailSummary { + const envelope = message.envelope; + const from = mapEnvelopeAddresses(envelope?.from); + const flags = flagsToArray(message.flags); + return { + uid: message.uid, + message_id: envelope?.messageId ?? null, + in_reply_to: envelope?.inReplyTo ?? null, + subject: envelope?.subject ?? '', + date: toIsoDate(envelope?.date), + internal_date: toIsoDate(message.internalDate), + from_name: from[0]?.name ?? '', + from_address: from[0]?.address ?? '', + to: mapEnvelopeAddresses(envelope?.to), + cc: mapEnvelopeAddresses(envelope?.cc), + flags, + seen: flags.includes('\\Seen'), + flagged: flags.includes('\\Flagged'), + answered: flags.includes('\\Answered'), + size: message.size ?? null, + }; +} + +function parseUids({ uids }: { uids: unknown[] | undefined }): number[] { + if (!uids || uids.length === 0) { + throw new ImapError('Provide at least one message UID.'); + } + const parsed = new Set(); + for (const raw of uids) { + const text = String(raw).trim(); + if (!/^[1-9]\d*$/.test(text)) { + throw new ImapError( + `"${text}" is not a valid message UID; UIDs are positive integers from search_emails.` + ); + } + parsed.add(Number(text)); + } + if (parsed.size > MAX_UIDS) { + throw new ImapError(`At most ${MAX_UIDS} UIDs can be processed per call.`); + } + return [...parsed].sort((a, b) => a - b); +} + +function parseSingleUid({ uid }: { uid: unknown }): number { + const [value] = parseUids({ uids: [uid] }); + return value; +} + +function selectedMailbox({ client }: { client: ImapFlow }): MailboxObject { + const mailbox = client.mailbox; + if (!mailbox) { + throw new ImapError('No folder is open on the IMAP connection.'); + } + return mailbox; +} + +function assertUidValidity({ + client, + expected, +}: { + client: ImapFlow; + expected: string; +}): void { + const trimmed = expected.trim(); + if (!trimmed) { + throw new ImapError( + 'uid_validity is required so stale UIDs cannot target the wrong emails. Re-run search_emails on this folder and pass the uid_validity it returns.' + ); + } + const actual = selectedMailbox({ client }).uidValidity.toString(); + if (actual !== trimmed) { + throw new ImapError( + `The folder UIDVALIDITY changed from ${trimmed} to ${actual}, so the supplied UIDs are stale and may now identify different emails. Nothing was changed. Re-run search_emails on this folder to get fresh UIDs and uid_validity, then retry.` + ); + } +} + +function assertOptionalUidValidity({ + client, + expected, +}: { + client: ImapFlow; + expected: string | undefined; +}): void { + if (!expected?.trim()) { + return; + } + assertUidValidity({ client, expected }); +} + +async function partitionExistingUids({ + client, + uids, +}: { + client: ImapFlow; + uids: number[]; +}): Promise<{ found: number[]; notFound: number[] }> { + const result = await client.search({ uid: uids.join(',') }, { uid: true }); + if (result === false) { + throw new ImapError('The server rejected the UID search.'); + } + const existing = new Set(result); + const found = uids.filter((uid) => existing.has(uid)); + const notFound = uids.filter((uid) => !existing.has(uid)); + if (found.length === 0) { + throw new ImapEmailNotFoundError(); + } + return { found, notFound }; +} + +async function assertFolderExists({ + client, + path, +}: { + client: ImapFlow; + path: string; +}): Promise { + try { + const status = await client.status(path, { messages: true }); + if (!status) { + throw new ImapError(`Could not read the target folder "${path}".`); + } + } catch (error) { + if (error instanceof ImapError) { + throw error; + } + if (hasCode({ error, code: 'NotFound' })) { + throw new ImapError( + `Target folder "${path}" does not exist; create it with create_folder or pick a path from list_folders.` + ); + } + throw error; + } +} + +function assertUidPlusForDelete({ client }: { client: ImapFlow }): void { + if (!client.capabilities.has('UIDPLUS')) { + throw new ImapError( + 'The server does not support UIDPLUS, so deleting would expunge every message marked \\Deleted in the folder. Use trash_emails instead.' + ); + } +} + +async function transferMessages({ + client, + uids, + target, + mode, +}: { + client: ImapFlow; + uids: number[]; + target: string; + mode: 'move' | 'copy'; +}): Promise { + const range = uids.join(','); + if (mode === 'copy') { + const copied = await client.messageCopy(range, target, { uid: true }); + if (!copied) { + throw new ImapError(`The server refused to copy the emails to "${target}".`); + } + return copied; + } + if (client.capabilities.has('MOVE')) { + const moved = await client.messageMove(range, target, { uid: true }); + if (!moved) { + throw new ImapError(`The server refused to move the emails to "${target}".`); + } + return moved; + } + if (client.capabilities.has('UIDPLUS')) { + const copied = await client.messageCopy(range, target, { uid: true }); + if (!copied) { + throw new ImapError(`The server refused to copy the emails to "${target}"; nothing was removed.`); + } + const deleted = await client.messageDelete(range, { uid: true }); + if (!deleted) { + throw new ImapError( + `The emails were copied to "${target}" but could not be removed from the source folder.` + ); + } + return copied; + } + throw new ImapError( + 'The server supports neither MOVE nor UIDPLUS, so a safe move is not possible. Use copy_emails instead.' + ); +} + +async function transferInFolder({ + client, + sourceFolder, + targetFolder, + uids, + uidValidity, + mode, +}: { + client: ImapFlow; + sourceFolder: string; + targetFolder: string; + uids: number[]; + uidValidity: string; + mode: 'move' | 'copy'; +}): Promise { + if (samePath({ a: sourceFolder, b: targetFolder })) { + throw new ImapError('The source and target folders must be different.'); + } + await assertFolderExists({ client, path: targetFolder }); + return withMailboxLock({ + client, + folder: sourceFolder, + readOnly: false, + run: async () => { + assertUidValidity({ client, expected: uidValidity }); + const mailbox = selectedMailbox({ client }); + const { found, notFound } = await partitionExistingUids({ client, uids }); + const response = await transferMessages({ client, uids: found, target: targetFolder, mode }); + const results = found.map((uid) => { + const newUid = response.uidMap?.get(uid); + return newUid === undefined ? { uid } : { uid, new_uid: newUid }; + }); + const targetUidValidity = bigintToString(response.uidValidity); + return { + source_folder: mailbox.path, + source_uid_validity: mailbox.uidValidity.toString(), + ...(targetUidValidity ? { target_uid_validity: targetUidValidity } : {}), + results, + count: results.length, + not_found_uids: notFound, + }; + }, + }); +} + +async function withMailboxLock({ + client, + folder, + readOnly, + run, +}: { + client: ImapFlow; + folder: string; + readOnly: boolean; + run: () => Promise; +}): Promise { + const lock = await client.getMailboxLock(folder, { readOnly }); + try { + return await run(); + } finally { + lock.release(); + } +} + +async function writeAttachments({ + attachments, + files, +}: { + attachments: Attachment[]; + files: FilesService; +}): Promise { + return Promise.all( + attachments.map(async (attachment, index) => { + const filename = attachment.filename ?? `attachment-${index + 1}`; + const file = await files.write({ fileName: filename, data: attachment.content }); + return { + filename, + content_type: attachment.contentType, + size: attachment.size, + file, + }; + }) + ); +} + +function isInbox({ path }: { path: string }): boolean { + return path.trim().toUpperCase() === 'INBOX'; +} + +function samePath({ a, b }: { a: string; b: string }): boolean { + return isInbox({ path: a }) && isInbox({ path: b }) ? true : a === b; +} + +function hasCode({ error, code }: { error: unknown; code: string }): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === code + ); +} + +const folderProp = ({ + displayName, + description, +}: { + displayName: string; + description: string; +}) => + Property.ShortText({ + displayName, + description, + required: true, + defaultValue: 'INBOX', + }); + +const uidsProp = () => + Property.Array({ + displayName: 'Message UIDs', + description: + 'UIDs from search_emails in the same folder (up to 500). UIDs that no longer exist are reported in not_found_uids.', + required: true, + }); + +const uidValidityProp = ({ required }: { required: T }) => + Property.ShortText({ + displayName: 'UID Validity', + description: required + ? 'The uid_validity returned by the search_emails call that produced these UIDs. If the folder UIDVALIDITY has changed the UIDs are stale and the call fails instead of acting on unrelated emails.' + : 'The uid_validity returned by search_emails. When set and the folder has changed, the call fails instead of reading a stale UID.', + required, + }); + +type EmailAddress = { name: string; address: string }; + +type EmailSummary = { + uid: number; + message_id: string | null; + in_reply_to: string | null; + subject: string; + date: string | null; + internal_date: string | null; + from_name: string; + from_address: string; + to: EmailAddress[]; + cc: EmailAddress[]; + flags: string[]; + seen: boolean; + flagged: boolean; + answered: boolean; + size: number | null; +}; + +type TransferOutput = { + source_folder: string; + source_uid_validity: string; + target_uid_validity?: string; + results: Array<{ uid: number; new_uid?: number }>; + count: number; + not_found_uids: number[]; +}; + +type AttachmentOutput = { + filename: string; + content_type: string; + size: number; + file: string; +}; + +export { + type EmailAddress, + type EmailSummary, + type AttachmentOutput, + type TransferOutput, + MAX_UIDS, + transferInFolder, + flagsToArray, + bigintToString, + toIsoDate, + mapEnvelopeAddresses, + mapParsedAddresses, + summarizeMessage, + parseUids, + parseSingleUid, + selectedMailbox, + assertUidValidity, + assertOptionalUidValidity, + partitionExistingUids, + assertFolderExists, + assertUidPlusForDelete, + transferMessages, + withMailboxLock, + writeAttachments, + isInbox, + samePath, + hasCode, + folderProp, + uidsProp, + uidValidityProp, +}; diff --git a/packages/pieces/community/imap/src/lib/common/mime.ts b/packages/pieces/community/imap/src/lib/common/mime.ts new file mode 100644 index 000000000000..21a56a1fd739 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/common/mime.ts @@ -0,0 +1,198 @@ +import { randomBytes } from 'crypto'; +import { ImapError } from './errors'; + +const CRLF = '\r\n'; +const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +const ADDR_SPEC = /^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/; +const MESSAGE_ID = /^<[\x21-\x3b\x3d\x3f-\x7e]+@[\x21-\x3b\x3d\x3f-\x7e]+>$/; +const ASCII_PRINTABLE = /^[\x20-\x7e]*$/; +const NEEDS_QUOTING = /[()<>[\]:;@\\,."]/; +const MAX_ENCODED_BYTES = 45; + +function assertHeaderSafe({ field, value }: { field: string; value: string }): void { + if (/[\r\n\0]/.test(value)) { + throw new ImapError(`${field} must not contain line breaks or NUL characters.`); + } +} + +function encodeWords({ value }: { value: string }): string[] { + const words: string[] = []; + let chunk = ''; + for (const char of value) { + if (Buffer.byteLength(chunk + char, 'utf8') > MAX_ENCODED_BYTES) { + words.push(chunk); + chunk = ''; + } + chunk += char; + } + if (chunk.length > 0) { + words.push(chunk); + } + return words.map((word) => `=?UTF-8?B?${Buffer.from(word, 'utf8').toString('base64')}?=`); +} + +function encodeText({ value }: { value: string }): string { + if (ASCII_PRINTABLE.test(value) && value.length <= 900) { + return value; + } + return encodeWords({ value }).join(`${CRLF} `); +} + +function formatDisplayName({ name }: { name: string }): string { + if (!ASCII_PRINTABLE.test(name)) { + return encodeWords({ value: name }).join(`${CRLF} `); + } + if (NEEDS_QUOTING.test(name)) { + return `"${name.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return name; +} + +function parseAddress({ field, value }: { field: string; value: string }): ParsedAddress { + assertHeaderSafe({ field, value }); + const trimmed = value.trim(); + const angle = /^(.*)<([^<>]+)>$/.exec(trimmed); + const rawName = angle ? angle[1].trim().replace(/^"(.*)"$/, '$1') : ''; + const address = angle ? angle[2].trim() : trimmed; + if (!ADDR_SPEC.test(address)) { + throw new ImapError( + `${field} "${trimmed}" is not a valid email address; use "name@example.com" or "Name " with an ASCII address.` + ); + } + return { name: rawName, address }; +} + +function formatAddress({ address }: { address: ParsedAddress }): string { + if (!address.name) { + return address.address; + } + return `${formatDisplayName({ name: address.name })} <${address.address}>`; +} + +function formatAddressList({ + field, + values, +}: { + field: string; + values: string[]; +}): string | null { + const cleaned = values.map((value) => value.trim()).filter((value) => value.length > 0); + if (cleaned.length === 0) { + return null; + } + return cleaned + .map((value) => formatAddress({ address: parseAddress({ field, value }) })) + .join(`,${CRLF} `); +} + +function formatDate({ date }: { date: Date }): string { + const pad = (n: number) => String(n).padStart(2, '0'); + const offsetMinutes = -date.getTimezoneOffset(); + const sign = offsetMinutes >= 0 ? '+' : '-'; + const abs = Math.abs(offsetMinutes); + return `${DAYS[date.getDay()]}, ${pad(date.getDate())} ${MONTHS[date.getMonth()]} ${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())} ${sign}${pad(Math.floor(abs / 60))}${pad(abs % 60)}`; +} + +function base64Body({ content }: { content: string }): string { + const encoded = Buffer.from(content, 'utf8').toString('base64'); + const lines = encoded.match(/.{1,76}/g) ?? []; + return lines.join(CRLF); +} + +function textPart({ subtype, content }: { subtype: 'plain' | 'html'; content: string }): string { + return [ + `Content-Type: text/${subtype}; charset=UTF-8`, + 'Content-Transfer-Encoding: base64', + '', + base64Body({ content }), + ].join(CRLF); +} + +function buildDraftMessage({ + from, + to, + cc, + bcc, + subject, + text, + html, + inReplyTo, + date, +}: { + from: string; + to: string[]; + cc: string[]; + bcc: string[]; + subject: string; + text: string | undefined; + html: string | undefined; + inReplyTo: string | undefined; + date: Date; +}): { raw: string; messageId: string } { + const fromAddress = parseAddress({ field: 'From', value: from }); + const toHeader = formatAddressList({ field: 'To', values: to }); + if (!toHeader) { + throw new ImapError('Provide at least one recipient in To.'); + } + const ccHeader = formatAddressList({ field: 'Cc', values: cc }); + const bccHeader = formatAddressList({ field: 'Bcc', values: bcc }); + assertHeaderSafe({ field: 'Subject', value: subject }); + const replyTo = inReplyTo?.trim(); + if (replyTo) { + assertHeaderSafe({ field: 'In-Reply-To', value: replyTo }); + if (!MESSAGE_ID.test(replyTo)) { + throw new ImapError('In-Reply-To must be a Message-ID in the form .'); + } + } + if (!text && !html) { + throw new ImapError('Provide a text body, an HTML body, or both.'); + } + const domain = fromAddress.address.split('@')[1]; + const messageId = `<${randomBytes(12).toString('hex')}.${date.getTime()}@${domain}>`; + const headers = [ + `From: ${formatAddress({ address: fromAddress })}`, + `To: ${toHeader}`, + ]; + if (ccHeader) { + headers.push(`Cc: ${ccHeader}`); + } + if (bccHeader) { + headers.push(`Bcc: ${bccHeader}`); + } + headers.push(`Subject: ${encodeText({ value: subject })}`); + headers.push(`Date: ${formatDate({ date })}`); + headers.push(`Message-ID: ${messageId}`); + if (replyTo) { + headers.push(`In-Reply-To: ${replyTo}`); + headers.push(`References: ${replyTo}`); + } + headers.push('MIME-Version: 1.0'); + + let body: string; + if (text && html) { + const boundary = `----=_ap_${randomBytes(12).toString('hex')}`; + headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`); + body = [ + `--${boundary}`, + textPart({ subtype: 'plain', content: text }), + `--${boundary}`, + textPart({ subtype: 'html', content: html }), + `--${boundary}--`, + '', + ].join(CRLF); + } else { + const subtype = text ? 'plain' : 'html'; + headers.push( + `Content-Type: text/${subtype}; charset=UTF-8`, + 'Content-Transfer-Encoding: base64' + ); + body = `${base64Body({ content: text ? text : html ?? '' })}${CRLF}`; + } + + return { raw: `${headers.join(CRLF)}${CRLF}${CRLF}${body}`, messageId }; +} + +type ParsedAddress = { name: string; address: string }; + +export { assertHeaderSafe, parseAddress, buildDraftMessage }; diff --git a/packages/pieces/community/imap/src/lib/common/special-use.ts b/packages/pieces/community/imap/src/lib/common/special-use.ts new file mode 100644 index 000000000000..f77fbae8a346 --- /dev/null +++ b/packages/pieces/community/imap/src/lib/common/special-use.ts @@ -0,0 +1,56 @@ +import { type ImapFlow, type ListResponse } from 'imapflow'; + +async function resolveSpecialFolder({ + client, + role, +}: { + client: ImapFlow; + role: SpecialFolderRole; +}): Promise { + const folders = await client.list(); + const match = findSpecialFolder({ folders, role }); + if (!match) { + return null; + } + return { + path: match.path, + special_use: match.specialUse ?? '', + special_use_source: match.specialUseSource ?? null, + }; +} + +function findSpecialFolder({ + folders, + role, +}: { + folders: ListResponse[]; + role: SpecialFolderRole; +}): ListResponse | undefined { + const selectable = folders.filter((folder) => !folder.flags.has('\\Noselect')); + if (role === 'archive') { + return ( + selectable.find((folder) => folder.specialUse === '\\Archive') ?? + selectable.find( + (folder) => + folder.specialUse === '\\All' && folder.specialUseSource === 'extension' + ) + ); + } + const flag = role === 'trash' ? '\\Trash' : '\\Drafts'; + return selectable.find((folder) => folder.specialUse === flag); +} + +type SpecialFolderRole = 'trash' | 'drafts' | 'archive'; + +type ResolvedSpecialFolder = { + path: string; + special_use: string; + special_use_source: 'user' | 'extension' | 'name' | null; +}; + +export { + type SpecialFolderRole, + type ResolvedSpecialFolder, + resolveSpecialFolder, + findSpecialFolder, +}; diff --git a/packages/pieces/community/imap/src/lib/output-schemas.ts b/packages/pieces/community/imap/src/lib/output-schemas.ts index 6e1e2802a54a..a3ad2d59ecd8 100644 --- a/packages/pieces/community/imap/src/lib/output-schemas.ts +++ b/packages/pieces/community/imap/src/lib/output-schemas.ts @@ -13,6 +13,65 @@ const addressFields: OutputSchema['fields'] = [ }, ]; +const recipientListItems: OutputSchema['fields'] = [ + { key: 'address', label: 'Email', format: 'email' }, + { key: 'name', label: 'Name' }, +]; + +const emailSummaryFields: OutputSchema['fields'] = [ + { key: 'uid', label: 'Message UID', format: 'number' }, + { key: 'message_id', label: 'Message-ID' }, + { key: 'in_reply_to', label: 'In Reply To' }, + { key: 'subject', label: 'Subject' }, + { key: 'date', label: 'Date', format: 'datetime' }, + { key: 'internal_date', label: 'Received At', format: 'datetime' }, + { key: 'from_name', label: 'From Name' }, + { key: 'from_address', label: 'From Email', format: 'email' }, + { key: 'to', label: 'To', labelKey: 'address', listItems: recipientListItems }, + { key: 'cc', label: 'Cc', labelKey: 'address', listItems: recipientListItems }, + { key: 'flags', label: 'Flags' }, + { key: 'seen', label: 'Read', format: 'boolean' }, + { key: 'flagged', label: 'Flagged', format: 'boolean' }, + { key: 'answered', label: 'Answered', format: 'boolean' }, + { key: 'size', label: 'Size', format: 'filesize' }, +]; + +const transferFields: OutputSchema['fields'] = [ + { key: 'source_folder', label: 'Source Folder' }, + { key: 'source_uid_validity', label: 'Source UID Validity' }, + { + key: 'target_uid_validity', + label: 'Target UID Validity', + description: 'Present only when the server supports UIDPLUS.', + }, + { + key: 'results', + label: 'Results', + labelKey: 'uid', + listItems: [ + { key: 'uid', label: 'Original UID', format: 'number' }, + { + key: 'new_uid', + label: 'New UID', + format: 'number', + description: 'UID in the target folder. Present only when the server supports UIDPLUS.', + }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'not_found_uids', label: 'UIDs Not Found' }, +]; + +const specialDestinationFields: OutputSchema['fields'] = [ + { key: 'destination_folder', label: 'Destination Folder' }, + { + key: 'special_use_source', + label: 'Special-Use Source', + description: + 'extension when the server flags the folder, name when the role was guessed from the folder name.', + }, +]; + export const newEmailTriggerOutputSchema: OutputSchema = { fields: [ { key: 'subject', label: 'Subject' }, @@ -63,7 +122,215 @@ export const moveEmailActionOutputSchema: OutputSchema = { label: 'New Message UID', format: 'number', description: - 'UID of the message in the target folder. Absent when the server reports no new UID, in which case Success is false.', + 'UID of the message in the target folder. Absent when the server does not report a new UID.', + }, + ], +}; + +export const markEmailReadActionOutputSchema: OutputSchema = { + fields: [{ key: 'success', label: 'Success', format: 'boolean' }], +}; + +export const deleteEmailActionOutputSchema: OutputSchema = { + fields: [{ key: 'success', label: 'Success', format: 'boolean' }], +}; + +export const listFoldersOutputSchema: OutputSchema = { + fields: [ + { + key: 'folders', + label: 'Folders', + labelKey: 'path', + listItems: [ + { key: 'path', label: 'Path' }, + { key: 'name', label: 'Name' }, + { key: 'delimiter', label: 'Delimiter' }, + { key: 'parent_path', label: 'Parent Path' }, + { key: 'special_use', label: 'Special Use' }, + { key: 'special_use_source', label: 'Special-Use Source' }, + { key: 'flags', label: 'Flags' }, + { key: 'subscribed', label: 'Subscribed', format: 'boolean' }, + { + key: 'messages', + label: 'Messages', + format: 'number', + description: 'Present only when Include Message Counts is on.', + }, + { + key: 'unseen', + label: 'Unread', + format: 'number', + description: 'Present only when Include Message Counts is on.', + }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const getFolderStatusOutputSchema: OutputSchema = { + fields: [ + { key: 'path', label: 'Path' }, + { key: 'messages', label: 'Messages', format: 'number' }, + { key: 'unseen', label: 'Unread', format: 'number' }, + { key: 'recent', label: 'Recent', format: 'number' }, + { key: 'uid_next', label: 'Next UID', format: 'number' }, + { key: 'uid_validity', label: 'UID Validity' }, + { + key: 'highest_modseq', + label: 'Highest Mod-Sequence', + description: 'Present only when the server supports CONDSTORE.', + }, + ], +}; + +export const createFolderOutputSchema: OutputSchema = { + fields: [ + { key: 'path', label: 'Path' }, + { + key: 'created', + label: 'Created', + format: 'boolean', + description: 'False when the folder already existed.', + }, + ], +}; + +export const renameFolderOutputSchema: OutputSchema = { + fields: [ + { key: 'path', label: 'Old Path' }, + { key: 'new_path', label: 'New Path' }, + ], +}; + +export const deleteFolderOutputSchema: OutputSchema = { + fields: [ + { key: 'path', label: 'Path' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + ], +}; + +export const getQuotaOutputSchema: OutputSchema = { + fields: [ + { key: 'path', label: 'Path' }, + { key: 'has_quota', label: 'Has Quota', format: 'boolean' }, + { key: 'storage_used_bytes', label: 'Storage Used', format: 'filesize' }, + { key: 'storage_limit_bytes', label: 'Storage Limit', format: 'filesize' }, + { key: 'messages_used', label: 'Messages Used', format: 'number' }, + { key: 'messages_limit', label: 'Messages Limit', format: 'number' }, + ], +}; + +export const searchEmailsOutputSchema: OutputSchema = { + fields: [ + { key: 'folder', label: 'Folder' }, + { key: 'uid_validity', label: 'UID Validity' }, + { key: 'total_matches', label: 'Total Matches', format: 'number' }, + { key: 'count', label: 'Returned', format: 'number' }, + { key: 'emails', label: 'Emails', labelKey: 'subject', listItems: emailSummaryFields }, + ], +}; + +export const getEmailOutputSchema: OutputSchema = { + fields: [ + { key: 'folder', label: 'Folder' }, + { key: 'uid', label: 'Message UID', format: 'number' }, + { key: 'uid_validity', label: 'UID Validity' }, + { key: 'message_id', label: 'Message-ID' }, + { key: 'in_reply_to', label: 'In Reply To' }, + { key: 'references', label: 'References' }, + { key: 'subject', label: 'Subject' }, + { key: 'date', label: 'Date', format: 'datetime' }, + { key: 'internal_date', label: 'Received At', format: 'datetime' }, + { key: 'from_name', label: 'From Name' }, + { key: 'from_address', label: 'From Email', format: 'email' }, + { key: 'to', label: 'To', labelKey: 'address', listItems: recipientListItems }, + { key: 'cc', label: 'Cc', labelKey: 'address', listItems: recipientListItems }, + { key: 'bcc', label: 'Bcc', labelKey: 'address', listItems: recipientListItems }, + { key: 'reply_to', label: 'Reply To', labelKey: 'address', listItems: recipientListItems }, + { key: 'text', label: 'Text Body' }, + { key: 'html', label: 'HTML Body', format: 'html' }, + { key: 'flags', label: 'Flags' }, + { key: 'seen', label: 'Read', format: 'boolean' }, + { key: 'flagged', label: 'Flagged', format: 'boolean' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { + key: 'attachments', + label: 'Attachments', + labelKey: 'filename', + listItems: [ + { key: 'filename', label: 'File Name' }, + { key: 'content_type', label: 'Content Type' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { + key: 'file', + label: 'File', + description: 'Stored file reference; pass it to any action that accepts a file.', + }, + ], + }, + ], +}; + +export const updateEmailFlagsOutputSchema: OutputSchema = { + fields: [ + { key: 'folder', label: 'Folder' }, + { key: 'uid_validity', label: 'UID Validity' }, + { + key: 'emails', + label: 'Emails', + labelKey: 'uid', + listItems: [ + { key: 'uid', label: 'Message UID', format: 'number' }, + { key: 'flags', label: 'Flags' }, + { key: 'seen', label: 'Read', format: 'boolean' }, + { key: 'flagged', label: 'Flagged', format: 'boolean' }, + { key: 'answered', label: 'Answered', format: 'boolean' }, + ], + }, + { key: 'not_found_uids', label: 'UIDs Not Found' }, + ], +}; + +export const moveEmailsOutputSchema: OutputSchema = { + fields: [...transferFields, { key: 'target_folder', label: 'Target Folder' }], +}; + +export const copyEmailsOutputSchema: OutputSchema = { + fields: [...transferFields, { key: 'target_folder', label: 'Target Folder' }], +}; + +export const trashEmailsOutputSchema: OutputSchema = { + fields: [...transferFields, ...specialDestinationFields], +}; + +export const archiveEmailsOutputSchema: OutputSchema = { + fields: [...transferFields, ...specialDestinationFields], +}; + +export const deleteEmailsOutputSchema: OutputSchema = { + fields: [ + { key: 'folder', label: 'Folder' }, + { key: 'deleted_uids', label: 'Deleted UIDs' }, + { + key: 'not_deleted_uids', + label: 'UIDs Not Deleted', + description: 'UIDs that still exist after the delete.', + }, + { key: 'not_found_uids', label: 'UIDs Not Found' }, + ], +}; + +export const createDraftOutputSchema: OutputSchema = { + fields: [ + { key: 'folder', label: 'Folder' }, + { + key: 'uid', + label: 'Draft UID', + format: 'number', + description: 'UID of the saved draft, when the server reports it.', }, + { key: 'uid_validity', label: 'UID Validity' }, + { key: 'message_id', label: 'Message-ID' }, ], }; diff --git a/packages/pieces/community/woocommerce/package.json b/packages/pieces/community/woocommerce/package.json index 463f2ada63c0..21f797e9d209 100644 --- a/packages/pieces/community/woocommerce/package.json +++ b/packages/pieces/community/woocommerce/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-woocommerce", - "version": "0.3.0", + "version": "0.4.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/woocommerce/src/i18n/translation.json b/packages/pieces/community/woocommerce/src/i18n/translation.json index 8c9f91cc59f1..1f2de1f40ec1 100644 --- a/packages/pieces/community/woocommerce/src/i18n/translation.json +++ b/packages/pieces/community/woocommerce/src/i18n/translation.json @@ -198,5 +198,304 @@ "Triggers when an existing coupon is deleted.": "Triggers when an existing coupon is deleted.", "Triggers when new customer is created.": "Triggers when new customer is created.", "Triggers when an existing customer is updated.": "Triggers when an existing customer is updated.", - "Triggers when an existing customer is deleted.": "Triggers when an existing customer is deleted." -} \ No newline at end of file + "Triggers when an existing customer is deleted.": "Triggers when an existing customer is deleted.", + "Add a private or customer-facing note to an order.": "Add a private or customer-facing note to an order.", + "Batch Update Products": "Batch Update Products", + "Update the price, stock or status of up to 100 products in one call.": "Update the price, stock or status of up to 100 products in one call.", + "Updates": "Updates", + "One entry per product, 1 to 100 entries. Each needs the product id and at least one field to change.": "One entry per product, 1 to 100 entries. Each needs the product id and at least one field to change.", + "Decimal string, e.g. 19.99.": "Decimal string, e.g. 19.99.", + "Decimal string, e.g. 14.99.": "Decimal string, e.g. 14.99.", + "Units in stock. Turns on stock management for that product.": "Units in stock. Turns on stock management for that product.", + "Pending review": "Pending review", + "Create a discount coupon.": "Create a discount coupon.", + "Code": "Code", + "Coupon code customers enter at checkout. Must be unique.": "Coupon code customers enter at checkout. Must be unique.", + "Percentage off the cart, a fixed amount off the cart, or a fixed amount off each product.": "Percentage off the cart, a fixed amount off the cart, or a fixed amount off each product.", + "Discount amount as a decimal string: a percentage for percentage coupons, otherwise money, e.g. 10.": "Discount amount as a decimal string: a percentage for percentage coupons, otherwise money, e.g. 10.", + "Internal description of the coupon.": "Internal description of the coupon.", + "Expiry Date": "Expiry Date", + "The coupon stops working after this date.": "The coupon stops working after this date.", + "Minimum cart subtotal needed to use the coupon, e.g. 50.00.": "Minimum cart subtotal needed to use the coupon, e.g. 50.00.", + "Maximum Spend": "Maximum Spend", + "Maximum cart subtotal allowed when using the coupon.": "Maximum cart subtotal allowed when using the coupon.", + "Individual Use Only": "Individual Use Only", + "Yes stops this coupon from being combined with other coupons. Leave empty for the store default (No).": "Yes stops this coupon from being combined with other coupons. Leave empty for the store default (No).", + "Yes": "Yes", + "No": "No", + "Exclude Sale Items": "Exclude Sale Items", + "Yes stops the coupon from applying to items already on sale. Leave empty for the store default (No).": "Yes stops the coupon from applying to items already on sale. Leave empty for the store default (No).", + "Free Shipping": "Free Shipping", + "Yes lets the coupon unlock free shipping, if a free-shipping method is set up. Leave empty for the store default (No).": "Yes lets the coupon unlock free shipping, if a free-shipping method is set up. Leave empty for the store default (No).", + "How many times the coupon can be used in total.": "How many times the coupon can be used in total.", + "Usage Limit Per Customer": "Usage Limit Per Customer", + "How many times each customer can use the coupon.": "How many times each customer can use the coupon.", + "Product IDs": "Product IDs", + "Ids of the only products the coupon applies to.": "Ids of the only products the coupon applies to.", + "Excluded Product IDs": "Excluded Product IDs", + "Ids of products the coupon never applies to.": "Ids of products the coupon never applies to.", + "Allowed Emails": "Allowed Emails", + "Only customers with these billing emails can use the coupon.": "Only customers with these billing emails can use the coupon.", + "Create a customer account. Only the email is required.": "Create a customer account. Only the email is required.", + "Email address of the customer. Must not already be registered.": "Email address of the customer. Must not already be registered.", + "Login name. Generated from the email when empty and the store allows it.": "Login name. Generated from the email when empty and the store allows it.", + "Account password. Generated when empty and the store allows it.": "Account password. Generated when empty and the store allows it.", + "Billing Company": "Billing Company", + "Billing Address Line 1": "Billing Address Line 1", + "Billing Address Line 2": "Billing Address Line 2", + "Billing State": "Billing State", + "State or county code, e.g. CA.": "State or county code, e.g. CA.", + "Two-letter ISO country code, e.g. US.": "Two-letter ISO country code, e.g. US.", + "Shipping Company": "Shipping Company", + "Shipping Address Line 1": "Shipping Address Line 1", + "Shipping Address Line 2": "Shipping Address Line 2", + "Shipping City": "Shipping City", + "Shipping State": "Shipping State", + "Shipping Postcode": "Shipping Postcode", + "Shipping Country": "Shipping Country", + "Create an order with one or more line items.": "Create an order with one or more line items.", + "Quantity": "Quantity", + "Create Order Refund": "Create Order Refund", + "Refund an amount on an order. By default the refund is only recorded in WooCommerce.": "Refund an amount on an order. By default the refund is only recorded in WooCommerce.", + "Id of the order to refund.": "Id of the order to refund.", + "Amount to refund as a decimal string, e.g. 10.00. Cannot exceed what is left to refund on the order.": "Amount to refund as a decimal string, e.g. 10.00. Cannot exceed what is left to refund on the order.", + "Reason": "Reason", + "Reason for the refund, stored with the refund.": "Reason for the refund, stored with the refund.", + "Refund Via Gateway": "Refund Via Gateway", + "When on, the money is actually sent back to the customer through the payment gateway. When off (the default) the refund is only recorded in WooCommerce.": "When on, the money is actually sent back to the customer through the payment gateway. When off (the default) the refund is only recorded in WooCommerce.", + "Create a product. Only the name is required.": "Create a product. Only the name is required.", + "Product name.": "Product name.", + "Product Type": "Product Type", + "Defaults to simple.": "Defaults to simple.", + "External / affiliate": "External / affiliate", + "Defaults to published, which makes the product visible in the shop.": "Defaults to published, which makes the product visible in the shop.", + "Price as a decimal string, e.g. 19.99.": "Price as a decimal string, e.g. 19.99.", + "Discounted price as a decimal string. Must be lower than the regular price.": "Discounted price as a decimal string. Must be lower than the regular price.", + "SKU": "SKU", + "Stock keeping unit. Must be unique in the store.": "Stock keeping unit. Must be unique in the store.", + "Full product description. HTML is allowed.": "Full product description. HTML is allowed.", + "Short Description": "Short Description", + "Short summary shown near the price. HTML is allowed.": "Short summary shown near the price. HTML is allowed.", + "Category IDs": "Category IDs", + "Ids of the categories to put the product in. Find them with list_product_categories.": "Ids of the categories to put the product in. Find them with list_product_categories.", + "Tag IDs": "Tag IDs", + "Ids of the tags to attach. Find them with list_product_tags.": "Ids of the tags to attach. Find them with list_product_tags.", + "Image URLs": "Image URLs", + "Public image URLs. The first one becomes the main image. The store downloads each one, so an unreachable URL fails the call.": "Public image URLs. The first one becomes the main image. The store downloads each one, so an unreachable URL fails the call.", + "Units in stock. Setting it turns on stock management for the product.": "Units in stock. Setting it turns on stock management for the product.", + "Stock Status": "Stock Status", + "Stock status. Ignored when a stock quantity is set, because the quantity decides it.": "Stock status. Ignored when a stock quantity is set, because the quantity decides it.", + "In stock": "In stock", + "Out of stock": "Out of stock", + "On backorder": "On backorder", + "Create Product Category": "Create Product Category", + "Create a product category.": "Create a product category.", + "Category name.": "Category name.", + "Parent Category ID": "Parent Category ID", + "Id of the parent category. Leave empty for a top-level category.": "Id of the parent category. Leave empty for a top-level category.", + "Slug": "Slug", + "URL-friendly name. Generated from the name when empty.": "URL-friendly name. Generated from the name when empty.", + "Category description. HTML is allowed.": "Category description. HTML is allowed.", + "Create Product Tag": "Create Product Tag", + "Create a product tag.": "Create a product tag.", + "Tag name.": "Tag name.", + "Tag description.": "Tag description.", + "Delete Coupon": "Delete Coupon", + "Move a coupon to the trash, or delete it permanently.": "Move a coupon to the trash, or delete it permanently.", + "Id of the coupon to delete.": "Id of the coupon to delete.", + "Delete Permanently": "Delete Permanently", + "When off (the default) the coupon is moved to the trash and can be restored from the WordPress admin. When on it is deleted for good.": "When off (the default) the coupon is moved to the trash and can be restored from the WordPress admin. When on it is deleted for good.", + "Delete Customer": "Delete Customer", + "Permanently delete a customer account.": "Permanently delete a customer account.", + "Id of the customer to delete. Find it with list_customers.": "Id of the customer to delete. Find it with list_customers.", + "Reassign To User ID": "Reassign To User ID", + "Optional id of another user who takes over the posts owned by the deleted customer. Must differ from the customer id.": "Optional id of another user who takes over the posts owned by the deleted customer. Must differ from the customer id.", + "Delete Order": "Delete Order", + "Move an order to the trash, or delete it permanently.": "Move an order to the trash, or delete it permanently.", + "Id of the order to delete. Find it with list_orders.": "Id of the order to delete. Find it with list_orders.", + "When off (the default) the order is moved to the trash and can be restored from the WordPress admin. When on it is deleted for good.": "When off (the default) the order is moved to the trash and can be restored from the WordPress admin. When on it is deleted for good.", + "Delete Order Note": "Delete Order Note", + "Permanently delete a note from an order.": "Permanently delete a note from an order.", + "Id of the order the note belongs to.": "Id of the order the note belongs to.", + "Note ID": "Note ID", + "Id of the note to delete.": "Id of the note to delete.", + "Delete Product": "Delete Product", + "Move a product to the trash, or delete it permanently.": "Move a product to the trash, or delete it permanently.", + "Id of the product to delete. Find it with list_products.": "Id of the product to delete. Find it with list_products.", + "When off (the default) the product is moved to the trash and can be restored from the WordPress admin. When on it is deleted for good.": "When off (the default) the product is moved to the trash and can be restored from the WordPress admin. When on it is deleted for good.", + "Get Coupon": "Get Coupon", + "Get a coupon by its ID.": "Get a coupon by its ID.", + "Get a customer by their ID.": "Get a customer by their ID.", + "Get an order by its ID.": "Get an order by its ID.", + "Get Order Totals Report": "Get Order Totals Report", + "Get the number of orders in each status.": "Get the number of orders in each status.", + "Get Product": "Get Product", + "Get a product by its ID.": "Get a product by its ID.", + "Get Product Variation": "Get Product Variation", + "Get one variation of a variable product.": "Get one variation of a variable product.", + "Id of the parent variable product.": "Id of the parent variable product.", + "Variation ID": "Variation ID", + "Id of the variation.": "Id of the variation.", + "Get Sales Report": "Get Sales Report", + "Get sales totals for a period or date range.": "Get sales totals for a period or date range.", + "Period": "Period", + "Preset period to report on. Leave empty when using a date range; if both are empty the current week is used.": "Preset period to report on. Leave empty when using a date range; if both are empty the current week is used.", + "This week": "This week", + "This month": "This month", + "Last month": "Last month", + "This year": "This year", + "Start Date": "Start Date", + "First day of a custom range, as YYYY-MM-DD. Use together with End Date instead of Period.": "First day of a custom range, as YYYY-MM-DD. Use together with End Date instead of Period.", + "End Date": "End Date", + "Last day of a custom range, as YYYY-MM-DD. Use together with Start Date instead of Period.": "Last day of a custom range, as YYYY-MM-DD. Use together with Start Date instead of Period.", + "Get Top Sellers Report": "Get Top Sellers Report", + "Get the best-selling products for a period or date range.": "Get the best-selling products for a period or date range.", + "List Coupons": "List Coupons", + "List coupons, or find one by its exact code.": "List coupons, or find one by its exact code.", + "Exact coupon code to find.": "Exact coupon code to find.", + "Search": "Search", + "Text to match against coupon codes and descriptions.": "Text to match against coupon codes and descriptions.", + "Only return coupons created after this date and time (ISO 8601).": "Only return coupons created after this date and time (ISO 8601).", + "Created Before": "Created Before", + "Only return coupons created before this date and time (ISO 8601).": "Only return coupons created before this date and time (ISO 8601).", + "Page": "Page", + "Page number, starting at 1. If a page comes back with exactly per_page items, request the next page to see more.": "Page number, starting at 1. If a page comes back with exactly per_page items, request the next page to see more.", + "Per Page": "Per Page", + "Number of items per page, from 1 to 100. Defaults to 10.": "Number of items per page, from 1 to 100. Defaults to 10.", + "List Customers": "List Customers", + "List customers, filtered by email, text or role.": "List customers, filtered by email, text or role.", + "Exact email address to match.": "Exact email address to match.", + "Text to match against names, usernames and emails.": "Text to match against names, usernames and emails.", + "Role": "Role", + "WordPress role to return. Defaults to all roles.": "WordPress role to return. Defaults to all roles.", + "All roles": "All roles", + "Customer": "Customer", + "Subscriber": "Subscriber", + "Shop manager": "Shop manager", + "Administrator": "Administrator", + "Sort By": "Sort By", + "Field to sort by. Defaults to name.": "Field to sort by. Defaults to name.", + "ID": "ID", + "Registration date": "Registration date", + "Sort Direction": "Sort Direction", + "Ascending or descending. Defaults to ascending.": "Ascending or descending. Defaults to ascending.", + "Ascending": "Ascending", + "Descending": "Descending", + "List Order Notes": "List Order Notes", + "List the notes on an order.": "List the notes on an order.", + "Id of the order.": "Id of the order.", + "Note Type": "Note Type", + "Which notes to return. Defaults to all.": "Which notes to return. Defaults to all.", + "All notes": "All notes", + "Notes sent to the customer": "Notes sent to the customer", + "Private notes": "Private notes", + "List Order Refunds": "List Order Refunds", + "List the refunds recorded on an order.": "List the refunds recorded on an order.", + "List Orders": "List Orders", + "List orders, filtered by status, customer, product, text or date range.": "List orders, filtered by status, customer, product, text or date range.", + "Only return orders with this status. Leave empty for any status except trash.": "Only return orders with this status. Leave empty for any status except trash.", + "Pending payment": "Pending payment", + "Only return orders placed by this customer. Find the id with list_customers.": "Only return orders placed by this customer. Find the id with list_customers.", + "Only return orders that contain this product.": "Only return orders that contain this product.", + "Text to match against order fields such as the billing name or email.": "Text to match against order fields such as the billing name or email.", + "Only return orders created after this date and time (ISO 8601).": "Only return orders created after this date and time (ISO 8601).", + "Only return orders created before this date and time (ISO 8601).": "Only return orders created before this date and time (ISO 8601).", + "Field to sort by. Defaults to date.": "Field to sort by. Defaults to date.", + "Date": "Date", + "Title": "Title", + "Ascending or descending. Defaults to descending.": "Ascending or descending. Defaults to descending.", + "List Payment Gateways": "List Payment Gateways", + "List the payment methods installed in the store and whether they are enabled.": "List the payment methods installed in the store and whether they are enabled.", + "List Product Categories": "List Product Categories", + "List product categories, optionally filtered by name, slug or parent.": "List product categories, optionally filtered by name, slug or parent.", + "Text to match against category names.": "Text to match against category names.", + "Exact category slug.": "Exact category slug.", + "Only return direct children of this category. Use 0 for top-level categories.": "Only return direct children of this category. Use 0 for top-level categories.", + "Hide Empty": "Hide Empty", + "When on, categories without products are left out.": "When on, categories without products are left out.", + "List Product Reviews": "List Product Reviews", + "List product reviews, including ones waiting for moderation.": "List product reviews, including ones waiting for moderation.", + "Only return reviews of this product.": "Only return reviews of this product.", + "Moderation status to return. Defaults to all.": "Moderation status to return. Defaults to all.", + "All": "All", + "Approved": "Approved", + "On hold (awaiting moderation)": "On hold (awaiting moderation)", + "Spam": "Spam", + "Reviewer Email": "Reviewer Email", + "Only return reviews written with this email address.": "Only return reviews written with this email address.", + "Text to match against the review content.": "Text to match against the review content.", + "List Product Tags": "List Product Tags", + "List product tags, optionally filtered by name or slug.": "List product tags, optionally filtered by name or slug.", + "Text to match against tag names.": "Text to match against tag names.", + "Exact tag slug.": "Exact tag slug.", + "When on, tags without products are left out.": "When on, tags without products are left out.", + "List Product Variations": "List Product Variations", + "List the variations of a variable product.": "List the variations of a variable product.", + "Id of the variable product. Find it with list_products.": "Id of the variable product. Find it with list_products.", + "Only return the variation with this SKU.": "Only return the variation with this SKU.", + "Only return variations with this stock status.": "Only return variations with this stock status.", + "List Products": "List Products", + "List products, filtered by text, SKU, status, category, tag, stock or price.": "List products, filtered by text, SKU, status, category, tag, stock or price.", + "Text to match against product names and content.": "Text to match against product names and content.", + "Exact SKU to match. Separate several SKUs with commas.": "Exact SKU to match. Separate several SKUs with commas.", + "Only return products with this status. Leave empty for any status.": "Only return products with this status. Leave empty for any status.", + "Only return products of this type.": "Only return products of this type.", + "Category ID": "Category ID", + "Only return products in this category. Find ids with list_product_categories.": "Only return products in this category. Find ids with list_product_categories.", + "Tag ID": "Tag ID", + "Only return products with this tag. Find ids with list_product_tags.": "Only return products with this tag. Find ids with list_product_tags.", + "Only return products with this stock status.": "Only return products with this stock status.", + "On Sale": "On Sale", + "Yes returns only products on sale, No only products not on sale. Leave empty for both.": "Yes returns only products on sale, No only products not on sale. Leave empty for both.", + "Minimum Price": "Minimum Price", + "Only return products priced at or above this amount, e.g. 10.00.": "Only return products priced at or above this amount, e.g. 10.00.", + "Maximum Price": "Maximum Price", + "Only return products priced at or below this amount, e.g. 99.99.": "Only return products priced at or below this amount, e.g. 99.99.", + "Price": "Price", + "Popularity": "Popularity", + "Rating": "Rating", + "Change the amount, type, expiry or limits of a coupon.": "Change the amount, type, expiry or limits of a coupon.", + "Change the name, email or billing details of a customer.": "Change the name, email or billing details of a customer.", + "Change the status, customer note or transaction ID of an order.": "Change the status, customer note or transaction ID of an order.", + "Change selected fields of a product. Fields left empty are not changed.": "Change selected fields of a product. Fields left empty are not changed.", + "Id of the product to update. Find it with list_products.": "Id of the product to update. Find it with list_products.", + "New product name.": "New product name.", + "New SKU. Must be unique in the store.": "New SKU. Must be unique in the store.", + "New status.": "New status.", + "New regular price as a decimal string, e.g. 19.99.": "New regular price as a decimal string, e.g. 19.99.", + "New sale price as a decimal string. To remove a sale use End Sale instead.": "New sale price as a decimal string. To remove a sale use End Sale instead.", + "End Sale": "End Sale", + "When on, clears the sale price so the product sells at its regular price again.": "When on, clears the sale price so the product sells at its regular price again.", + "New number of units in stock. Setting it turns on stock management.": "New number of units in stock. Setting it turns on stock management.", + "New stock status. Ignored when a stock quantity is set.": "New stock status. Ignored when a stock quantity is set.", + "Featured": "Featured", + "Yes marks the product as featured, No removes the mark. Leave empty to keep it.": "Yes marks the product as featured, No removes the mark. Leave empty to keep it.", + "Catalog Visibility": "Catalog Visibility", + "Where the product is shown.": "Where the product is shown.", + "Shop and search results": "Shop and search results", + "Shop only": "Shop only", + "Search results only": "Search results only", + "Hidden": "Hidden", + "New full description. HTML is allowed.": "New full description. HTML is allowed.", + "New short description. HTML is allowed.": "New short description. HTML is allowed.", + "Replaces all of the product categories with these ids. Include the ones to keep.": "Replaces all of the product categories with these ids. Include the ones to keep.", + "Replaces all of the product tags with these ids. Include the ones to keep.": "Replaces all of the product tags with these ids. Include the ones to keep.", + "Moderate Product Review": "Moderate Product Review", + "Approve, hold, mark as spam or trash a product review.": "Approve, hold, mark as spam or trash a product review.", + "Review ID": "Review ID", + "Id of the review.": "Id of the review.", + "New moderation status.": "New moderation status.", + "Approve": "Approve", + "Hold (unapprove)": "Hold (unapprove)", + "Mark as spam": "Mark as spam", + "Not spam": "Not spam", + "Move to trash": "Move to trash", + "Restore from trash": "Restore from trash", + "Update Product Variation": "Update Product Variation", + "Change selected fields of one variation. Fields left empty are not changed.": "Change selected fields of one variation. Fields left empty are not changed.", + "Id of the variation to update.": "Id of the variation to update.", + "When on, clears the sale price so the variation sells at its regular price again.": "When on, clears the sale price so the variation sells at its regular price again.", + "New status. Private hides the variation from customers.": "New status. Private hides the variation from customers.", + "New number of units in stock. Setting it turns on stock management for this variation.": "New number of units in stock. Setting it turns on stock management for this variation.", + "New variation description.": "New variation description." +} diff --git a/packages/pieces/community/woocommerce/src/index.ts b/packages/pieces/community/woocommerce/src/index.ts index 5115ce1ea070..3a2d6f728751 100644 --- a/packages/pieces/community/woocommerce/src/index.ts +++ b/packages/pieces/community/woocommerce/src/index.ts @@ -21,6 +21,45 @@ import { wooCreateCustomer } from './lib/actions/create-customer'; import { wooCreateProduct } from './lib/actions/create-product'; import { wooFindCustomer } from './lib/actions/find-customer'; import { wooFindProduct } from './lib/actions/find-product'; +import { wooAiAddOrderNote } from './lib/actions/ai/add-order-note'; +import { wooAiBatchUpdateProducts } from './lib/actions/ai/batch-update-products'; +import { wooAiCreateCoupon } from './lib/actions/ai/create-coupon'; +import { wooAiCreateCustomer } from './lib/actions/ai/create-customer'; +import { wooAiCreateOrder } from './lib/actions/ai/create-order'; +import { wooAiCreateOrderRefund } from './lib/actions/ai/create-order-refund'; +import { wooAiCreateProduct } from './lib/actions/ai/create-product'; +import { wooAiCreateProductCategory } from './lib/actions/ai/create-product-category'; +import { wooAiCreateProductTag } from './lib/actions/ai/create-product-tag'; +import { wooAiDeleteCoupon } from './lib/actions/ai/delete-coupon'; +import { wooAiDeleteCustomer } from './lib/actions/ai/delete-customer'; +import { wooAiDeleteOrder } from './lib/actions/ai/delete-order'; +import { wooAiDeleteOrderNote } from './lib/actions/ai/delete-order-note'; +import { wooAiDeleteProduct } from './lib/actions/ai/delete-product'; +import { wooAiGetCoupon } from './lib/actions/ai/get-coupon'; +import { wooAiGetCustomer } from './lib/actions/ai/get-customer'; +import { wooAiGetOrder } from './lib/actions/ai/get-order'; +import { wooAiGetOrderTotalsReport } from './lib/actions/ai/get-order-totals-report'; +import { wooAiGetProduct } from './lib/actions/ai/get-product'; +import { wooAiGetProductVariation } from './lib/actions/ai/get-product-variation'; +import { wooAiGetSalesReport } from './lib/actions/ai/get-sales-report'; +import { wooAiGetTopSellersReport } from './lib/actions/ai/get-top-sellers-report'; +import { wooAiListCoupons } from './lib/actions/ai/list-coupons'; +import { wooAiListCustomers } from './lib/actions/ai/list-customers'; +import { wooAiListOrderNotes } from './lib/actions/ai/list-order-notes'; +import { wooAiListOrderRefunds } from './lib/actions/ai/list-order-refunds'; +import { wooAiListOrders } from './lib/actions/ai/list-orders'; +import { wooAiListPaymentGateways } from './lib/actions/ai/list-payment-gateways'; +import { wooAiListProductCategories } from './lib/actions/ai/list-product-categories'; +import { wooAiListProductReviews } from './lib/actions/ai/list-product-reviews'; +import { wooAiListProductTags } from './lib/actions/ai/list-product-tags'; +import { wooAiListProductVariations } from './lib/actions/ai/list-product-variations'; +import { wooAiListProducts } from './lib/actions/ai/list-products'; +import { wooAiUpdateCoupon } from './lib/actions/ai/update-coupon'; +import { wooAiUpdateCustomer } from './lib/actions/ai/update-customer'; +import { wooAiUpdateOrder } from './lib/actions/ai/update-order'; +import { wooAiUpdateProduct } from './lib/actions/ai/update-product'; +import { wooAiUpdateProductReview } from './lib/actions/ai/update-product-review'; +import { wooAiUpdateProductVariation } from './lib/actions/ai/update-product-variation'; import { triggers } from './lib/triggers'; import { wooAuth } from './lib/auth'; @@ -42,7 +81,7 @@ export const woocommerce = createPiece({ logoUrl: 'https://cdn.activepieces.com/pieces/woocommerce.png', categories: [PieceCategory.COMMERCE], auth: wooAuth, - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', authors: ["TaskMagicKyle","kishanprmr","MoShizzle","khaledmashaly","abuaboud"], actions: [ wooCreateCustomer, @@ -60,6 +99,45 @@ export const woocommerce = createPiece({ wooCreateOrder, wooUpdateOrder, wooAddOrderNote, + wooAiAddOrderNote, + wooAiBatchUpdateProducts, + wooAiCreateCoupon, + wooAiCreateCustomer, + wooAiCreateOrder, + wooAiCreateOrderRefund, + wooAiCreateProduct, + wooAiCreateProductCategory, + wooAiCreateProductTag, + wooAiDeleteCoupon, + wooAiDeleteCustomer, + wooAiDeleteOrder, + wooAiDeleteOrderNote, + wooAiDeleteProduct, + wooAiGetCoupon, + wooAiGetCustomer, + wooAiGetOrder, + wooAiGetOrderTotalsReport, + wooAiGetProduct, + wooAiGetProductVariation, + wooAiGetSalesReport, + wooAiGetTopSellersReport, + wooAiListCoupons, + wooAiListCustomers, + wooAiListOrderNotes, + wooAiListOrderRefunds, + wooAiListOrders, + wooAiListPaymentGateways, + wooAiListProductCategories, + wooAiListProductReviews, + wooAiListProductTags, + wooAiListProductVariations, + wooAiListProducts, + wooAiUpdateCoupon, + wooAiUpdateCustomer, + wooAiUpdateOrder, + wooAiUpdateProduct, + wooAiUpdateProductReview, + wooAiUpdateProductVariation, createCustomApiCallAction({ baseUrl: (auth) => (auth?.props.baseUrl ?? ''), auth: wooAuth, diff --git a/packages/pieces/community/woocommerce/src/lib/actions/add-order-note.ts b/packages/pieces/community/woocommerce/src/lib/actions/add-order-note.ts index f4d1fd5c8940..0571ab9e6b04 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/add-order-note.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/add-order-note.ts @@ -14,7 +14,7 @@ export const wooAddOrderNote = createAction({ classification: 'WRITE', displayName: 'Add Order Note', description: 'Add a note to an order, optionally visible to the customer', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Adds a note to an existing WooCommerce order. A private note is visible only to store staff; a customer note is emailed to the customer and shown on the order page. Use when an agent needs to record why something happened on an order or communicate an update. Not idempotent: each call appends another note.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/add-order-note.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/add-order-note.ts new file mode 100644 index 000000000000..7e48f3100db1 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/add-order-note.ts @@ -0,0 +1,21 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { addOrderNoteOutputSchema } from '../../output-schemas'; +import { wooAddOrderNote } from '../add-order-note'; + +export const wooAiAddOrderNote = createAction({ + name: 'add_order_note', + classification: 'WRITE', + displayName: 'Add Order Note', + description: 'Add a private or customer-facing note to an order.', + audience: 'ai', + aiMetadata: { + description: + 'Adds a note to one order: a private note for staff, or, with customer note on, a note that is emailed to the customer straight away. Read existing notes with list_order_notes. Each call adds another note, so a retry duplicates it (and resends the email).', + idempotent: false, + }, + auth: wooAuth, + outputSchema: addOrderNoteOutputSchema, + props: wooAddOrderNote.props, + run: wooAddOrderNote.run, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/batch-update-products.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/batch-update-products.ts new file mode 100644 index 000000000000..00738d11805f --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/batch-update-products.ts @@ -0,0 +1,95 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { batchUpdateProductsOutputSchema } from '../../output-schemas'; + +export const wooAiBatchUpdateProducts = createAction({ + name: 'batch_update_products', + classification: 'WRITE', + displayName: 'Batch Update Products', + description: 'Update the price, stock or status of up to 100 products in one call.', + audience: 'ai', + aiMetadata: { + description: + 'Updates prices, stock quantity or status of 1 to 100 products in a single request; only the fields given for each product change. The call succeeds as a whole even when some products fail, so check each returned entry for an error. For other fields, or a single product, use update_product.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: batchUpdateProductsOutputSchema, + props: { + updates: Property.Array({ + displayName: 'Updates', + description: 'One entry per product, 1 to 100 entries. Each needs the product id and at least one field to change.', + required: true, + properties: { + id: Property.Number({ + displayName: 'Product ID', + required: true, + }), + regular_price: Property.ShortText({ + displayName: 'Regular Price', + description: 'Decimal string, e.g. 19.99.', + required: false, + }), + sale_price: Property.ShortText({ + displayName: 'Sale Price', + description: 'Decimal string, e.g. 14.99.', + required: false, + }), + stock_quantity: Property.Number({ + displayName: 'Stock Quantity', + description: 'Units in stock. Turns on stock management for that product.', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + required: false, + options: { options: wooProps.productStatusOptions }, + }), + }, + }), + }, + async run(context) { + const updates = context.propsValue.updates ?? []; + if (updates.length === 0 || updates.length > 100) { + throw new Error(`Updates must contain between 1 and 100 entries; got ${updates.length}.`); + } + const items = updates.map((item, index) => toBatchItem({ item, index })); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/products/batch', + body: { update: items }, + }); + }, +}); + +function toBatchItem({ item, index }: { item: unknown; index: number }): Record { + const entry: Record = Object(item); + const id = Number(entry['id']); + if (!Number.isInteger(id) || id <= 0) { + throw new Error(`Entry ${index + 1} has no valid product id.`); + } + const stock = entry['stock_quantity']; + const hasStock = stock !== undefined && stock !== null && stock !== ''; + const fields = wooValues.pruneUndefined({ + regular_price: readText(entry['regular_price']), + sale_price: readText(entry['sale_price']), + manage_stock: hasStock ? true : undefined, + stock_quantity: hasStock ? Number(stock) : undefined, + status: readText(entry['status']), + }); + if (Object.keys(fields).length === 0) { + throw new Error(`Entry ${index + 1} (product ${id}) has no field to change.`); + } + return { id, ...fields }; +} + +function readText(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + return wooValues.nonEmpty(String(value)); +} diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/create-coupon.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-coupon.ts new file mode 100644 index 000000000000..c8b9fce0129d --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-coupon.ts @@ -0,0 +1,132 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { createCouponOutputSchema } from '../../output-schemas'; + +export const wooAiCreateCoupon = createAction({ + name: 'create_coupon', + classification: 'WRITE', + displayName: 'Create Coupon', + description: 'Create a discount coupon.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a discount coupon with a code, discount type and amount; optional limits and restrictions are only applied when given. Coupon codes must be unique, and a code that already exists is rejected, so check list_coupons with the code first. Each call creates a new coupon.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: createCouponOutputSchema, + props: { + code: Property.ShortText({ + displayName: 'Code', + description: 'Coupon code customers enter at checkout. Must be unique.', + required: true, + }), + discount_type: Property.StaticDropdown({ + displayName: 'Discount Type', + description: 'Percentage off the cart, a fixed amount off the cart, or a fixed amount off each product.', + required: true, + options: { + options: [ + { label: 'Percentage discount', value: 'percent' }, + { label: 'Fixed cart discount', value: 'fixed_cart' }, + { label: 'Fixed product discount', value: 'fixed_product' }, + ], + }, + }), + amount: Property.ShortText({ + displayName: 'Amount', + description: 'Discount amount as a decimal string: a percentage for percentage coupons, otherwise money, e.g. 10.', + required: true, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'Internal description of the coupon.', + required: false, + }), + date_expires: Property.DateTime({ + displayName: 'Expiry Date', + description: 'The coupon stops working after this date.', + required: false, + }), + minimum_amount: Property.ShortText({ + displayName: 'Minimum Spend', + description: 'Minimum cart subtotal needed to use the coupon, e.g. 50.00.', + required: false, + }), + maximum_amount: Property.ShortText({ + displayName: 'Maximum Spend', + description: 'Maximum cart subtotal allowed when using the coupon.', + required: false, + }), + individual_use: wooProps.triStateProp({ + displayName: 'Individual Use Only', + description: 'Yes stops this coupon from being combined with other coupons. Leave empty for the store default (No).', + }), + exclude_sale_items: wooProps.triStateProp({ + displayName: 'Exclude Sale Items', + description: 'Yes stops the coupon from applying to items already on sale. Leave empty for the store default (No).', + }), + free_shipping: wooProps.triStateProp({ + displayName: 'Free Shipping', + description: 'Yes lets the coupon unlock free shipping, if a free-shipping method is set up. Leave empty for the store default (No).', + }), + usage_limit: Property.Number({ + displayName: 'Usage Limit', + description: 'How many times the coupon can be used in total.', + required: false, + }), + usage_limit_per_user: Property.Number({ + displayName: 'Usage Limit Per Customer', + description: 'How many times each customer can use the coupon.', + required: false, + }), + product_ids: Property.Array({ + displayName: 'Product IDs', + description: 'Ids of the only products the coupon applies to.', + required: false, + }), + excluded_product_ids: Property.Array({ + displayName: 'Excluded Product IDs', + description: 'Ids of products the coupon never applies to.', + required: false, + }), + email_restrictions: Property.Array({ + displayName: 'Allowed Emails', + description: 'Only customers with these billing emails can use the coupon.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const amount = props.amount.trim(); + if (amount.length === 0 || !Number.isFinite(Number(amount)) || Number(amount) < 0) { + throw new Error(`Amount must be a non-negative decimal number such as 10; got "${props.amount}".`); + } + const body = wooValues.pruneUndefined({ + code: props.code.trim(), + discount_type: props.discount_type, + amount, + description: wooValues.nonEmpty(props.description), + date_expires: wooValues.nonEmpty(props.date_expires), + minimum_amount: wooValues.nonEmpty(props.minimum_amount), + maximum_amount: wooValues.nonEmpty(props.maximum_amount), + individual_use: wooValues.resolveTriState(props.individual_use), + exclude_sale_items: wooValues.resolveTriState(props.exclude_sale_items), + free_shipping: wooValues.resolveTriState(props.free_shipping), + usage_limit: props.usage_limit ?? undefined, + usage_limit_per_user: props.usage_limit_per_user ?? undefined, + product_ids: wooValues.toIdList(props.product_ids), + excluded_product_ids: wooValues.toIdList(props.excluded_product_ids), + email_restrictions: wooValues.toStringList(props.email_restrictions), + }); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/coupons', + body, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/create-customer.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-customer.ts new file mode 100644 index 000000000000..db093d0a0388 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-customer.ts @@ -0,0 +1,110 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooValues } from '../../common/props'; +import { createCustomerOutputSchema } from '../../output-schemas'; + +export const wooAiCreateCustomer = createAction({ + name: 'create_customer', + classification: 'WRITE', + displayName: 'Create Customer', + description: 'Create a customer account. Only the email is required.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a customer account (a WordPress user with the customer role); only the email is required. Check list_customers by email first, because an email that is already registered is rejected. Username and password are generated when the store settings allow it, otherwise a password must be given; the store may email the new customer their account details.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: createCustomerOutputSchema, + props: { + email: Property.ShortText({ + displayName: 'Email', + description: 'Email address of the customer. Must not already be registered.', + required: true, + }), + first_name: Property.ShortText({ displayName: 'First Name', required: false }), + last_name: Property.ShortText({ displayName: 'Last Name', required: false }), + username: Property.ShortText({ + displayName: 'Username', + description: 'Login name. Generated from the email when empty and the store allows it.', + required: false, + }), + password: Property.ShortText({ + displayName: 'Password', + description: 'Account password. Generated when empty and the store allows it.', + required: false, + }), + billing_company: Property.ShortText({ displayName: 'Billing Company', required: false }), + billing_address_1: Property.ShortText({ displayName: 'Billing Address Line 1', required: false }), + billing_address_2: Property.ShortText({ displayName: 'Billing Address Line 2', required: false }), + billing_city: Property.ShortText({ displayName: 'Billing City', required: false }), + billing_state: Property.ShortText({ + displayName: 'Billing State', + description: 'State or county code, e.g. CA.', + required: false, + }), + billing_postcode: Property.ShortText({ displayName: 'Billing Postcode', required: false }), + billing_country: Property.ShortText({ + displayName: 'Billing Country', + description: 'Two-letter ISO country code, e.g. US.', + required: false, + }), + billing_phone: Property.ShortText({ displayName: 'Billing Phone', required: false }), + shipping_company: Property.ShortText({ displayName: 'Shipping Company', required: false }), + shipping_address_1: Property.ShortText({ displayName: 'Shipping Address Line 1', required: false }), + shipping_address_2: Property.ShortText({ displayName: 'Shipping Address Line 2', required: false }), + shipping_city: Property.ShortText({ displayName: 'Shipping City', required: false }), + shipping_state: Property.ShortText({ + displayName: 'Shipping State', + description: 'State or county code, e.g. CA.', + required: false, + }), + shipping_postcode: Property.ShortText({ displayName: 'Shipping Postcode', required: false }), + shipping_country: Property.ShortText({ + displayName: 'Shipping Country', + description: 'Two-letter ISO country code, e.g. US.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const firstName = wooValues.nonEmpty(props.first_name); + const lastName = wooValues.nonEmpty(props.last_name); + const billing = wooValues.pruneUndefined({ + company: wooValues.nonEmpty(props.billing_company), + address_1: wooValues.nonEmpty(props.billing_address_1), + address_2: wooValues.nonEmpty(props.billing_address_2), + city: wooValues.nonEmpty(props.billing_city), + state: wooValues.nonEmpty(props.billing_state), + postcode: wooValues.nonEmpty(props.billing_postcode), + country: wooValues.nonEmpty(props.billing_country), + phone: wooValues.nonEmpty(props.billing_phone), + }); + const shipping = wooValues.pruneUndefined({ + company: wooValues.nonEmpty(props.shipping_company), + address_1: wooValues.nonEmpty(props.shipping_address_1), + address_2: wooValues.nonEmpty(props.shipping_address_2), + city: wooValues.nonEmpty(props.shipping_city), + state: wooValues.nonEmpty(props.shipping_state), + postcode: wooValues.nonEmpty(props.shipping_postcode), + country: wooValues.nonEmpty(props.shipping_country), + }); + const body = wooValues.pruneUndefined({ + email: props.email.trim(), + first_name: firstName, + last_name: lastName, + username: wooValues.nonEmpty(props.username), + password: wooValues.nonEmpty(props.password), + billing: Object.keys(billing).length > 0 ? billing : undefined, + shipping: Object.keys(shipping).length > 0 ? shipping : undefined, + }); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/customers', + body, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/create-order-refund.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-order-refund.ts new file mode 100644 index 000000000000..06c1a0068ecf --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-order-refund.ts @@ -0,0 +1,62 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooValues } from '../../common/props'; +import { orderRefundOutputSchema } from '../../output-schemas'; + +export const wooAiCreateOrderRefund = createAction({ + name: 'create_order_refund', + classification: 'DESTRUCTIVE', + displayName: 'Create Order Refund', + description: 'Refund an amount on an order. By default the refund is only recorded in WooCommerce.', + audience: 'ai', + aiMetadata: { + description: + 'Records an irreversible refund of an amount on one order and emails the customer about it; by default no money moves, but the order total drops and a full refund marks the order refunded. With Refund Via Gateway on, real money is sent back through the payment gateway, which fails on gateways without refund support (bank transfer, cheque, cash on delivery). Check list_order_refunds first, because each call adds another refund.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: orderRefundOutputSchema, + props: { + order_id: Property.Number({ + displayName: 'Order ID', + description: 'Id of the order to refund.', + required: true, + }), + amount: Property.ShortText({ + displayName: 'Amount', + description: 'Amount to refund as a decimal string, e.g. 10.00. Cannot exceed what is left to refund on the order.', + required: true, + }), + reason: Property.ShortText({ + displayName: 'Reason', + description: 'Reason for the refund, stored with the refund.', + required: false, + }), + refund_via_gateway: Property.Checkbox({ + displayName: 'Refund Via Gateway', + description: 'When on, the money is actually sent back to the customer through the payment gateway. When off (the default) the refund is only recorded in WooCommerce.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const { order_id, amount, reason, refund_via_gateway } = context.propsValue; + const trimmedAmount = amount.trim(); + const numericAmount = Number(trimmedAmount); + if (trimmedAmount.length === 0 || !Number.isFinite(numericAmount) || numericAmount <= 0) { + throw new Error(`Amount must be a positive decimal number such as 10.00; got "${amount}".`); + } + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: `/orders/${wooClient.encodeId(order_id)}/refunds`, + body: wooValues.pruneUndefined({ + amount: trimmedAmount, + reason: wooValues.nonEmpty(reason), + api_refund: refund_via_gateway === true, + }), + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/create-order.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-order.ts new file mode 100644 index 000000000000..41136c4204fa --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-order.ts @@ -0,0 +1,21 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { createOrderOutputSchema } from '../../output-schemas'; +import { wooCreateOrder } from '../create-order'; + +export const wooAiCreateOrder = createAction({ + name: 'create_order', + classification: 'WRITE', + displayName: 'Create Order', + description: 'Create an order with one or more line items.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new order from product ids and quantities, optionally for a customer, with a status, and with a payment gateway; get product ids from list_products (a variation id can be used as the product id), customer ids from list_customers, and payment gateways from list_payment_gateways, passing the gateway id as Payment Method ID and its title as Payment Method Title. Status processing or completed reduces stock and emails the customer, while pending (the default) does neither. Each call creates a separate order.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: createOrderOutputSchema, + props: wooCreateOrder.props, + run: wooCreateOrder.run, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/create-product-category.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-product-category.ts new file mode 100644 index 000000000000..c2f5269bf00b --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-product-category.ts @@ -0,0 +1,57 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooValues } from '../../common/props'; +import { productCategoryOutputSchema } from '../../output-schemas'; + +export const wooAiCreateProductCategory = createAction({ + name: 'create_product_category', + classification: 'WRITE', + displayName: 'Create Product Category', + description: 'Create a product category.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new product category, optionally under a parent category. Look it up with list_product_categories first: a category with the same name under the same parent is rejected, and the error message then gives the existing category id to use instead.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: productCategoryOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Name', + description: 'Category name.', + required: true, + }), + parent_id: Property.Number({ + displayName: 'Parent Category ID', + description: 'Id of the parent category. Leave empty for a top-level category.', + required: false, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: 'URL-friendly name. Generated from the name when empty.', + required: false, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'Category description. HTML is allowed.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/products/categories', + body: wooValues.pruneUndefined({ + name: props.name, + parent: props.parent_id ?? undefined, + slug: wooValues.nonEmpty(props.slug), + description: wooValues.nonEmpty(props.description), + }), + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/create-product-tag.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-product-tag.ts new file mode 100644 index 000000000000..c90abc37eeb3 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-product-tag.ts @@ -0,0 +1,51 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooValues } from '../../common/props'; +import { productTagOutputSchema } from '../../output-schemas'; + +export const wooAiCreateProductTag = createAction({ + name: 'create_product_tag', + classification: 'WRITE', + displayName: 'Create Product Tag', + description: 'Create a product tag.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new product tag. Look it up with list_product_tags first: a tag with the same name is rejected, and the error message then gives the existing tag id to use instead.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: productTagOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Name', + description: 'Tag name.', + required: true, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: 'URL-friendly name. Generated from the name when empty.', + required: false, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'Tag description.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/products/tags', + body: wooValues.pruneUndefined({ + name: props.name, + slug: wooValues.nonEmpty(props.slug), + description: wooValues.nonEmpty(props.description), + }), + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/create-product.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-product.ts new file mode 100644 index 000000000000..95b32df72ce7 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/create-product.ts @@ -0,0 +1,117 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { createProductOutputSchema } from '../../output-schemas'; + +export const wooAiCreateProduct = createAction({ + name: 'create_product', + classification: 'WRITE', + displayName: 'Create Product', + description: 'Create a product. Only the name is required.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a new product in the store catalog; only the name is required and it is published as a simple product unless told otherwise. Check list_products by SKU or name first, because each call creates a separate product. A variable product is created without variations.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: createProductOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Name', + description: 'Product name.', + required: true, + }), + type: Property.StaticDropdown({ + displayName: 'Product Type', + description: 'Defaults to simple.', + required: false, + options: { options: wooProps.productTypeOptions }, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'Defaults to published, which makes the product visible in the shop.', + required: false, + options: { options: wooProps.productStatusOptions }, + }), + regular_price: Property.ShortText({ + displayName: 'Regular Price', + description: 'Price as a decimal string, e.g. 19.99.', + required: false, + }), + sale_price: Property.ShortText({ + displayName: 'Sale Price', + description: 'Discounted price as a decimal string. Must be lower than the regular price.', + required: false, + }), + sku: Property.ShortText({ + displayName: 'SKU', + description: 'Stock keeping unit. Must be unique in the store.', + required: false, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'Full product description. HTML is allowed.', + required: false, + }), + short_description: Property.LongText({ + displayName: 'Short Description', + description: 'Short summary shown near the price. HTML is allowed.', + required: false, + }), + category_ids: Property.Array({ + displayName: 'Category IDs', + description: 'Ids of the categories to put the product in. Find them with list_product_categories.', + required: false, + }), + tag_ids: Property.Array({ + displayName: 'Tag IDs', + description: 'Ids of the tags to attach. Find them with list_product_tags.', + required: false, + }), + image_urls: Property.Array({ + displayName: 'Image URLs', + description: 'Public image URLs. The first one becomes the main image. The store downloads each one, so an unreachable URL fails the call.', + required: false, + }), + stock_quantity: Property.Number({ + displayName: 'Stock Quantity', + description: 'Units in stock. Setting it turns on stock management for the product.', + required: false, + }), + stock_status: wooProps.stockStatusProp({ + description: 'Stock status. Ignored when a stock quantity is set, because the quantity decides it.', + }), + }, + async run(context) { + const props = context.propsValue; + const categoryIds = wooValues.toIdList(props.category_ids); + const tagIds = wooValues.toIdList(props.tag_ids); + const imageUrls = wooValues.toStringList(props.image_urls); + const hasStock = props.stock_quantity !== undefined && props.stock_quantity !== null; + const body = wooValues.pruneUndefined({ + name: props.name, + type: props.type ?? 'simple', + status: props.status ?? 'publish', + regular_price: wooValues.nonEmpty(props.regular_price), + sale_price: wooValues.nonEmpty(props.sale_price), + sku: wooValues.nonEmpty(props.sku), + description: wooValues.nonEmpty(props.description), + short_description: wooValues.nonEmpty(props.short_description), + categories: categoryIds?.map((id) => ({ id })), + tags: tagIds?.map((id) => ({ id })), + images: imageUrls?.map((src) => ({ src })), + manage_stock: hasStock ? true : undefined, + stock_quantity: hasStock ? props.stock_quantity : undefined, + stock_status: hasStock ? undefined : props.stock_status, + }); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.POST, + path: '/products', + body, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-coupon.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-coupon.ts new file mode 100644 index 000000000000..1a5f3129a0cd --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-coupon.ts @@ -0,0 +1,38 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps } from '../../common/props'; +import { deleteCouponOutputSchema } from '../../output-schemas'; + +export const wooAiDeleteCoupon = createAction({ + name: 'delete_coupon', + classification: 'DESTRUCTIVE', + displayName: 'Delete Coupon', + description: 'Move a coupon to the trash, or delete it permanently.', + audience: 'ai', + aiMetadata: { + description: + 'Removes one coupon by id (find it with list_coupons by code) so it can no longer be used. By default it goes to the trash, which a person can restore from the WordPress admin; with permanent it is deleted for good. Trashing an already-trashed coupon fails, so a retry is not safe.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: deleteCouponOutputSchema, + props: { + coupon_id: Property.Number({ + displayName: 'Coupon ID', + description: 'Id of the coupon to delete.', + required: true, + }), + permanent: wooProps.permanentProp({ resource: 'coupon' }), + }, + async run(context) { + const { coupon_id, permanent } = context.propsValue; + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.DELETE, + path: `/coupons/${wooClient.encodeId(coupon_id)}`, + queryParams: { force: permanent === true }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-customer.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-customer.ts new file mode 100644 index 000000000000..8b7c1c3dddf0 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-customer.ts @@ -0,0 +1,71 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { deleteCustomerOutputSchema } from '../../output-schemas'; + +export const wooAiDeleteCustomer = createAction({ + name: 'delete_customer', + classification: 'DESTRUCTIVE', + displayName: 'Delete Customer', + description: 'Permanently delete a customer account.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes one customer account (its WordPress user); customers have no trash, so it cannot be restored. Only accounts with the customer or subscriber role can be deleted here, so staff and administrators are refused. Their orders stay in the store; optionally reassign their posts to another user id.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: deleteCustomerOutputSchema, + props: { + customer_id: Property.Number({ + displayName: 'Customer ID', + description: 'Id of the customer to delete. Find it with list_customers.', + required: true, + }), + reassign_to_user_id: Property.Number({ + displayName: 'Reassign To User ID', + description: 'Optional id of another user who takes over the posts owned by the deleted customer. Must differ from the customer id.', + required: false, + }), + }, + async run(context) { + const { customer_id, reassign_to_user_id } = context.propsValue; + const auth = context.auth.props; + const hasReassign = reassign_to_user_id !== undefined && reassign_to_user_id !== null; + if (hasReassign && String(reassign_to_user_id) === String(customer_id)) { + throw new Error('Reassign To User ID must be a different user than the customer being deleted.'); + } + const customer = await wooClient.request({ + auth, + method: HttpMethod.GET, + path: `/customers/${wooClient.encodeId(customer_id)}`, + }); + const role = readRole(customer); + if (role === undefined || !deletableRoles.includes(role)) { + throw new Error( + `Refusing to delete user ${customer_id}: their role is "${role ?? 'unknown'}". Only accounts with the customer or subscriber role can be deleted with this action.` + ); + } + return wooClient.request({ + auth, + method: HttpMethod.DELETE, + path: `/customers/${wooClient.encodeId(customer_id)}`, + queryParams: { + force: true, + reassign: hasReassign ? reassign_to_user_id : undefined, + }, + }); + }, +}); + +function readRole(customer: unknown): string | undefined { + if (typeof customer !== 'object' || customer === null) { + return undefined; + } + const record: Record = Object(customer); + const role = record['role']; + return typeof role === 'string' ? role : undefined; +} + +const deletableRoles = ['customer', 'subscriber']; diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-order-note.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-order-note.ts new file mode 100644 index 000000000000..d3f39cb7a15a --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-order-note.ts @@ -0,0 +1,41 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { deleteOrderNoteOutputSchema } from '../../output-schemas'; + +export const wooAiDeleteOrderNote = createAction({ + name: 'delete_order_note', + classification: 'DESTRUCTIVE', + displayName: 'Delete Order Note', + description: 'Permanently delete a note from an order.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes one note from an order (find note ids with list_order_notes); order notes have no trash, so it cannot be restored. Deleting a note sent to the customer does not unsend the email. A second call for the same note fails.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: deleteOrderNoteOutputSchema, + props: { + order_id: Property.Number({ + displayName: 'Order ID', + description: 'Id of the order the note belongs to.', + required: true, + }), + note_id: Property.Number({ + displayName: 'Note ID', + description: 'Id of the note to delete.', + required: true, + }), + }, + async run(context) { + const { order_id, note_id } = context.propsValue; + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.DELETE, + path: `/orders/${wooClient.encodeId(order_id)}/notes/${wooClient.encodeId(note_id)}`, + queryParams: { force: true }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-order.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-order.ts new file mode 100644 index 000000000000..cfa104d61691 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-order.ts @@ -0,0 +1,38 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps } from '../../common/props'; +import { deleteOrderOutputSchema } from '../../output-schemas'; + +export const wooAiDeleteOrder = createAction({ + name: 'delete_order', + classification: 'DESTRUCTIVE', + displayName: 'Delete Order', + description: 'Move an order to the trash, or delete it permanently.', + audience: 'ai', + aiMetadata: { + description: + 'Removes one order by id; to stop an order without removing it, set its status to cancelled with update_order instead. By default it goes to the trash, which a person can restore from the WordPress admin; with permanent it is deleted for good. Trashing an already-trashed order fails, so a retry is not safe.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: deleteOrderOutputSchema, + props: { + order_id: Property.Number({ + displayName: 'Order ID', + description: 'Id of the order to delete. Find it with list_orders.', + required: true, + }), + permanent: wooProps.permanentProp({ resource: 'order' }), + }, + async run(context) { + const { order_id, permanent } = context.propsValue; + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.DELETE, + path: `/orders/${wooClient.encodeId(order_id)}`, + queryParams: { force: permanent === true }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-product.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-product.ts new file mode 100644 index 000000000000..b93933b2809b --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/delete-product.ts @@ -0,0 +1,38 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps } from '../../common/props'; +import { deleteProductOutputSchema } from '../../output-schemas'; + +export const wooAiDeleteProduct = createAction({ + name: 'delete_product', + classification: 'DESTRUCTIVE', + displayName: 'Delete Product', + description: 'Move a product to the trash, or delete it permanently.', + audience: 'ai', + aiMetadata: { + description: + 'Removes one product by id. By default it goes to the trash, which a person can restore from the WordPress admin; with permanent it is deleted for good, together with all variations of a variable product. Trashing an already-trashed product fails, so a retry is not safe.', + idempotent: false, + }, + auth: wooAuth, + outputSchema: deleteProductOutputSchema, + props: { + product_id: Property.Number({ + displayName: 'Product ID', + description: 'Id of the product to delete. Find it with list_products.', + required: true, + }), + permanent: wooProps.permanentProp({ resource: 'product' }), + }, + async run(context) { + const { product_id, permanent } = context.propsValue; + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.DELETE, + path: `/products/${wooClient.encodeId(product_id)}`, + queryParams: { force: permanent === true }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/get-coupon.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-coupon.ts new file mode 100644 index 000000000000..19db331d0937 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-coupon.ts @@ -0,0 +1,21 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { findCouponOutputSchema } from '../../output-schemas'; +import { wooFindCoupon } from '../find-coupon'; + +export const wooAiGetCoupon = createAction({ + name: 'get_coupon', + classification: 'READ', + displayName: 'Get Coupon', + description: 'Get a coupon by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one coupon by its numeric id with its code, discount, limits, restrictions and usage count. To find a coupon by its code use list_coupons. Read-only and safe to retry.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: findCouponOutputSchema, + props: wooFindCoupon.props, + run: wooFindCoupon.run, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/get-customer.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-customer.ts new file mode 100644 index 000000000000..81d580083669 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-customer.ts @@ -0,0 +1,21 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { getCustomerOutputSchema } from '../../output-schemas'; +import { wooGetCustomer } from '../get-customer'; + +export const wooAiGetCustomer = createAction({ + name: 'get_customer', + classification: 'READ', + displayName: 'Get Customer', + description: 'Get a customer by their ID.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one customer by numeric id, with role, billing and shipping addresses and whether they have paid for an order. To find a customer by email or name use list_customers first. Read-only and safe to retry.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: getCustomerOutputSchema, + props: wooGetCustomer.props, + run: wooGetCustomer.run, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/get-order-totals-report.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-order-totals-report.ts new file mode 100644 index 000000000000..4e6c95e2e3e8 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-order-totals-report.ts @@ -0,0 +1,28 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { orderTotalsReportOutputSchema } from '../../output-schemas'; + +export const wooAiGetOrderTotalsReport = createAction({ + name: 'get_order_totals_report', + classification: 'READ', + displayName: 'Get Order Totals Report', + description: 'Get the number of orders in each status.', + audience: 'ai', + aiMetadata: { + description: + 'Returns how many orders the store currently holds in each status (pending payment, processing, on hold, completed, cancelled, refunded, failed and so on), across all time. Use it for a quick overview of the order pipeline; to see the orders themselves use list_orders with a status.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: orderTotalsReportOutputSchema, + props: {}, + async run(context) { + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/reports/orders/totals', + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/get-order.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-order.ts new file mode 100644 index 000000000000..91a698978f8c --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-order.ts @@ -0,0 +1,21 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { getOrderOutputSchema } from '../../output-schemas'; +import { wooGetOrder } from '../get-order'; + +export const wooAiGetOrder = createAction({ + name: 'get_order', + classification: 'READ', + displayName: 'Get Order', + description: 'Get an order by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one order by its numeric id with status, totals, line items, addresses, payment method and refunds. To find orders by status, customer or date use list_orders. Read-only and safe to retry.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: getOrderOutputSchema, + props: wooGetOrder.props, + run: wooGetOrder.run, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/get-product-variation.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-product-variation.ts new file mode 100644 index 000000000000..c42dd5479a33 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-product-variation.ts @@ -0,0 +1,40 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { productVariationOutputSchema } from '../../output-schemas'; + +export const wooAiGetProductVariation = createAction({ + name: 'get_product_variation', + classification: 'READ', + displayName: 'Get Product Variation', + description: 'Get one variation of a variable product.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one variation of a variable product by product id and variation id, including its attributes, price and stock. Variation ids come from list_product_variations or from the variations list of get_product. Read-only and safe to retry.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: productVariationOutputSchema, + props: { + product_id: Property.Number({ + displayName: 'Product ID', + description: 'Id of the parent variable product.', + required: true, + }), + variation_id: Property.Number({ + displayName: 'Variation ID', + description: 'Id of the variation.', + required: true, + }), + }, + async run(context) { + const { product_id, variation_id } = context.propsValue; + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/products/${wooClient.encodeId(product_id)}/variations/${wooClient.encodeId(variation_id)}`, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/get-product.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-product.ts new file mode 100644 index 000000000000..a94ecbb204fb --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-product.ts @@ -0,0 +1,21 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { findProductOutputSchema } from '../../output-schemas'; +import { wooFindProduct } from '../find-product'; + +export const wooAiGetProduct = createAction({ + name: 'get_product', + classification: 'READ', + displayName: 'Get Product', + description: 'Get a product by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one product by its numeric id with prices, stock, categories, tags, images and, for a variable product, its variation ids. To find a product by name or SKU use list_products first; for a single variation use get_product_variation. Read-only and safe to retry.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: findProductOutputSchema, + props: wooFindProduct.props, + run: wooFindProduct.run, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/get-sales-report.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-sales-report.ts new file mode 100644 index 000000000000..e94991651ddb --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-sales-report.ts @@ -0,0 +1,31 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { salesReportOutputSchema } from '../../output-schemas'; + +export const wooAiGetSalesReport = createAction({ + name: 'get_sales_report', + classification: 'READ', + displayName: 'Get Sales Report', + description: 'Get sales totals for a period or date range.', + audience: 'ai', + aiMetadata: { + description: + 'Returns store sales totals (gross and net sales, orders, items, tax, shipping, refunds, discounts) for a preset period or a custom date range, plus a per-day or per-month breakdown. Give either a period or both dates, not both; with neither, the current week is used. For best-selling products use get_top_sellers_report; for order counts by status use get_order_totals_report.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: salesReportOutputSchema, + props: wooProps.reportRangeProps(), + async run(context) { + const { period, date_min, date_max } = context.propsValue; + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/reports/sales', + queryParams: wooValues.resolveReportRange({ period, dateMin: date_min, dateMax: date_max }), + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/get-top-sellers-report.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-top-sellers-report.ts new file mode 100644 index 000000000000..6027b76af3f7 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/get-top-sellers-report.ts @@ -0,0 +1,31 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { topSellersReportOutputSchema } from '../../output-schemas'; + +export const wooAiGetTopSellersReport = createAction({ + name: 'get_top_sellers_report', + classification: 'READ', + displayName: 'Get Top Sellers Report', + description: 'Get the best-selling products for a period or date range.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the best-selling products with the quantity sold for a preset period or a custom date range, highest first. Give either a period or both dates, not both; with neither, the current week is used. For money totals use get_sales_report.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: topSellersReportOutputSchema, + props: wooProps.reportRangeProps(), + async run(context) { + const { period, date_min, date_max } = context.propsValue; + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/reports/top_sellers', + queryParams: wooValues.resolveReportRange({ period, dateMin: date_min, dateMax: date_max }), + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/list-coupons.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-coupons.ts new file mode 100644 index 000000000000..4d9ca2cf9480 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-coupons.ts @@ -0,0 +1,61 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { listCouponsOutputSchema } from '../../output-schemas'; + +export const wooAiListCoupons = createAction({ + name: 'list_coupons', + classification: 'SEARCH', + displayName: 'List Coupons', + description: 'List coupons, or find one by its exact code.', + audience: 'ai', + aiMetadata: { + description: + 'Lists discount coupons with their codes, amounts, limits and usage counts. Pass code to look up one coupon by its exact code (the way to get a coupon id for get_coupon, update_coupon or delete_coupon, and to check that a code is free before create_coupon). Paged: if a page returns exactly per_page items, request the next page.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: listCouponsOutputSchema, + props: { + code: Property.ShortText({ + displayName: 'Code', + description: 'Exact coupon code to find.', + required: false, + }), + search: Property.ShortText({ + displayName: 'Search', + description: 'Text to match against coupon codes and descriptions.', + required: false, + }), + after: Property.DateTime({ + displayName: 'Created After', + description: 'Only return coupons created after this date and time (ISO 8601).', + required: false, + }), + before: Property.DateTime({ + displayName: 'Created Before', + description: 'Only return coupons created before this date and time (ISO 8601).', + required: false, + }), + page: wooProps.pageProp(), + per_page: wooProps.perPageProp(), + }, + async run(context) { + const props = context.propsValue; + const paging = await wooValues.paging({ page: props.page, perPage: props.per_page }); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/coupons', + queryParams: { + code: wooValues.nonEmpty(props.code), + search: wooValues.nonEmpty(props.search), + after: wooValues.nonEmpty(props.after), + before: wooValues.nonEmpty(props.before), + ...paging, + }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/list-customers.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-customers.ts new file mode 100644 index 000000000000..2977e91f0390 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-customers.ts @@ -0,0 +1,90 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { listCustomersOutputSchema } from '../../output-schemas'; + +export const wooAiListCustomers = createAction({ + name: 'list_customers', + classification: 'SEARCH', + displayName: 'List Customers', + description: 'List customers, filtered by email, text or role.', + audience: 'ai', + aiMetadata: { + description: + 'Searches store customers and returns them with their ids, emails and addresses. Use it to find a customer id by exact email or by name before get_customer, update_customer, create_order or list_orders; role defaults to all, so shop staff and administrators are included. Paged: if a page returns exactly per_page items, request the next page.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: listCustomersOutputSchema, + props: { + email: Property.ShortText({ + displayName: 'Email', + description: 'Exact email address to match.', + required: false, + }), + search: Property.ShortText({ + displayName: 'Search', + description: 'Text to match against names, usernames and emails.', + required: false, + }), + role: Property.StaticDropdown({ + displayName: 'Role', + description: 'WordPress role to return. Defaults to all roles.', + required: false, + defaultValue: 'all', + options: { + options: [ + { label: 'All roles', value: 'all' }, + { label: 'Customer', value: 'customer' }, + { label: 'Subscriber', value: 'subscriber' }, + { label: 'Shop manager', value: 'shop_manager' }, + { label: 'Administrator', value: 'administrator' }, + ], + }, + }), + orderby: Property.StaticDropdown({ + displayName: 'Sort By', + description: 'Field to sort by. Defaults to name.', + required: false, + options: { + options: [ + { label: 'Name', value: 'name' }, + { label: 'ID', value: 'id' }, + { label: 'Registration date', value: 'registered_date' }, + ], + }, + }), + order: Property.StaticDropdown({ + displayName: 'Sort Direction', + description: 'Ascending or descending. Defaults to ascending.', + required: false, + options: { + options: [ + { label: 'Ascending', value: 'asc' }, + { label: 'Descending', value: 'desc' }, + ], + }, + }), + page: wooProps.pageProp(), + per_page: wooProps.perPageProp(), + }, + async run(context) { + const props = context.propsValue; + const paging = await wooValues.paging({ page: props.page, perPage: props.per_page }); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/customers', + queryParams: { + email: wooValues.nonEmpty(props.email), + search: wooValues.nonEmpty(props.search), + role: props.role ?? 'all', + orderby: props.orderby, + order: props.order, + ...paging, + }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/list-order-notes.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-order-notes.ts new file mode 100644 index 000000000000..c8f6b70dc6c0 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-order-notes.ts @@ -0,0 +1,48 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { listOrderNotesOutputSchema } from '../../output-schemas'; + +export const wooAiListOrderNotes = createAction({ + name: 'list_order_notes', + classification: 'SEARCH', + displayName: 'List Order Notes', + description: 'List the notes on an order.', + audience: 'ai', + aiMetadata: { + description: + 'Lists all notes on one order, both private staff notes and notes sent to the customer, including the ones WooCommerce writes itself for status changes and emails. Use it to read an order history or to find a note id for delete_order_note; add a note with add_order_note.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: listOrderNotesOutputSchema, + props: { + order_id: Property.Number({ + displayName: 'Order ID', + description: 'Id of the order.', + required: true, + }), + type: Property.StaticDropdown({ + displayName: 'Note Type', + description: 'Which notes to return. Defaults to all.', + required: false, + options: { + options: [ + { label: 'All notes', value: 'any' }, + { label: 'Notes sent to the customer', value: 'customer' }, + { label: 'Private notes', value: 'internal' }, + ], + }, + }), + }, + async run(context) { + const { order_id, type } = context.propsValue; + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/orders/${wooClient.encodeId(order_id)}/notes`, + queryParams: { type: type ?? 'any' }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/list-order-refunds.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-order-refunds.ts new file mode 100644 index 000000000000..262fdfe5ce04 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-order-refunds.ts @@ -0,0 +1,40 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { listOrderRefundsOutputSchema } from '../../output-schemas'; + +export const wooAiListOrderRefunds = createAction({ + name: 'list_order_refunds', + classification: 'SEARCH', + displayName: 'List Order Refunds', + description: 'List the refunds recorded on an order.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the refunds already recorded on one order, with amount, reason and whether money was sent back through the gateway. Check it before create_order_refund to avoid refunding twice. Paged: if a page returns exactly per_page items, request the next page.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: listOrderRefundsOutputSchema, + props: { + order_id: Property.Number({ + displayName: 'Order ID', + description: 'Id of the order.', + required: true, + }), + page: wooProps.pageProp(), + per_page: wooProps.perPageProp(), + }, + async run(context) { + const props = context.propsValue; + const paging = await wooValues.paging({ page: props.page, perPage: props.per_page }); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/orders/${wooClient.encodeId(props.order_id)}/refunds`, + queryParams: { ...paging }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/list-orders.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-orders.ts new file mode 100644 index 000000000000..2af609cf186d --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-orders.ts @@ -0,0 +1,90 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { listOrdersOutputSchema } from '../../output-schemas'; + +export const wooAiListOrders = createAction({ + name: 'list_orders', + classification: 'SEARCH', + displayName: 'List Orders', + description: 'List orders, filtered by status, customer, product, text or date range.', + audience: 'ai', + aiMetadata: { + description: + 'Searches orders and returns them with totals, line items and addresses. Use it to find orders by status, customer id, product id, free text (such as an email or name) or creation date range; use get_order when you already have the order id. Paged: if a page returns exactly per_page items, request the next page.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: listOrdersOutputSchema, + props: { + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'Only return orders with this status. Leave empty for any status except trash.', + required: false, + options: { options: wooProps.orderStatusOptions }, + }), + customer_id: Property.Number({ + displayName: 'Customer ID', + description: 'Only return orders placed by this customer. Find the id with list_customers.', + required: false, + }), + product_id: Property.Number({ + displayName: 'Product ID', + description: 'Only return orders that contain this product.', + required: false, + }), + search: Property.ShortText({ + displayName: 'Search', + description: 'Text to match against order fields such as the billing name or email.', + required: false, + }), + after: Property.DateTime({ + displayName: 'Created After', + description: 'Only return orders created after this date and time (ISO 8601).', + required: false, + }), + before: Property.DateTime({ + displayName: 'Created Before', + description: 'Only return orders created before this date and time (ISO 8601).', + required: false, + }), + orderby: Property.StaticDropdown({ + displayName: 'Sort By', + description: 'Field to sort by. Defaults to date.', + required: false, + options: { + options: [ + { label: 'Date', value: 'date' }, + { label: 'ID', value: 'id' }, + { label: 'Title', value: 'title' }, + { label: 'Slug', value: 'slug' }, + ], + }, + }), + order: wooProps.sortOrderProp(), + page: wooProps.pageProp(), + per_page: wooProps.perPageProp(), + }, + async run(context) { + const props = context.propsValue; + const paging = await wooValues.paging({ page: props.page, perPage: props.per_page }); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/orders', + queryParams: { + status: props.status, + customer: props.customer_id, + product: props.product_id, + search: wooValues.nonEmpty(props.search), + after: wooValues.nonEmpty(props.after), + before: wooValues.nonEmpty(props.before), + orderby: props.orderby, + order: props.order, + ...paging, + }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/list-payment-gateways.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-payment-gateways.ts new file mode 100644 index 000000000000..6ba284f86d5c --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-payment-gateways.ts @@ -0,0 +1,43 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { listPaymentGatewaysOutputSchema } from '../../output-schemas'; + +export const wooAiListPaymentGateways = createAction({ + name: 'list_payment_gateways', + classification: 'SEARCH', + displayName: 'List Payment Gateways', + description: 'List the payment methods installed in the store and whether they are enabled.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the payment gateways installed in the store with their ids, titles, whether they are enabled, and what they support (for example refunds). Use it to pick a payment method for create_order, or to check whether a gateway supports refunds before create_order_refund with Refund Via Gateway. Gateway settings and credentials are never returned.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: listPaymentGatewaysOutputSchema, + props: {}, + async run(context) { + const gateways = await wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/payment_gateways', + }); + return gateways.map((gateway) => toPublicGateway(gateway)); + }, +}); + +function toPublicGateway(gateway: unknown): Record { + const record: Record = Object(gateway); + return { + id: record['id'], + title: record['title'], + description: record['description'], + order: record['order'], + enabled: record['enabled'], + method_title: record['method_title'], + method_description: record['method_description'], + method_supports: record['method_supports'], + }; +} diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-categories.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-categories.ts new file mode 100644 index 000000000000..7925a5a0b417 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-categories.ts @@ -0,0 +1,62 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { listProductCategoriesOutputSchema } from '../../output-schemas'; + +export const wooAiListProductCategories = createAction({ + name: 'list_product_categories', + classification: 'SEARCH', + displayName: 'List Product Categories', + description: 'List product categories, optionally filtered by name, slug or parent.', + audience: 'ai', + aiMetadata: { + description: + 'Lists product categories with their ids, slugs, parents and product counts. Use it to find a category id for list_products, create_product or update_product, and before create_product_category to avoid a duplicate. Paged: if a page returns exactly per_page items, request the next page.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: listProductCategoriesOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Text to match against category names.', + required: false, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: 'Exact category slug.', + required: false, + }), + parent_id: Property.Number({ + displayName: 'Parent Category ID', + description: 'Only return direct children of this category. Use 0 for top-level categories.', + required: false, + }), + hide_empty: Property.Checkbox({ + displayName: 'Hide Empty', + description: 'When on, categories without products are left out.', + required: false, + defaultValue: false, + }), + page: wooProps.pageProp(), + per_page: wooProps.perPageProp(), + }, + async run(context) { + const props = context.propsValue; + const paging = await wooValues.paging({ page: props.page, perPage: props.per_page }); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/products/categories', + queryParams: { + search: wooValues.nonEmpty(props.search), + slug: wooValues.nonEmpty(props.slug), + parent: props.parent_id, + hide_empty: props.hide_empty === true ? true : undefined, + ...paging, + }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-reviews.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-reviews.ts new file mode 100644 index 000000000000..51b35300d3f9 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-reviews.ts @@ -0,0 +1,71 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { listProductReviewsOutputSchema } from '../../output-schemas'; + +export const wooAiListProductReviews = createAction({ + name: 'list_product_reviews', + classification: 'SEARCH', + displayName: 'List Product Reviews', + description: 'List product reviews, including ones waiting for moderation.', + audience: 'ai', + aiMetadata: { + description: + 'Lists customer product reviews with their rating, text, reviewer and moderation status. Status defaults to all, so reviews on hold or marked as spam are included; use it to find a review id for update_product_review. Paged: if a page returns exactly per_page items, request the next page.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: listProductReviewsOutputSchema, + props: { + product_id: Property.Number({ + displayName: 'Product ID', + description: 'Only return reviews of this product.', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'Moderation status to return. Defaults to all.', + required: false, + defaultValue: 'all', + options: { + options: [ + { label: 'All', value: 'all' }, + { label: 'Approved', value: 'approved' }, + { label: 'On hold (awaiting moderation)', value: 'hold' }, + { label: 'Spam', value: 'spam' }, + { label: 'Trash', value: 'trash' }, + ], + }, + }), + reviewer_email: Property.ShortText({ + displayName: 'Reviewer Email', + description: 'Only return reviews written with this email address.', + required: false, + }), + search: Property.ShortText({ + displayName: 'Search', + description: 'Text to match against the review content.', + required: false, + }), + page: wooProps.pageProp(), + per_page: wooProps.perPageProp(), + }, + async run(context) { + const props = context.propsValue; + const paging = await wooValues.paging({ page: props.page, perPage: props.per_page }); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/products/reviews', + queryParams: { + product: props.product_id, + status: props.status ?? 'all', + reviewer_email: wooValues.nonEmpty(props.reviewer_email), + search: wooValues.nonEmpty(props.search), + ...paging, + }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-tags.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-tags.ts new file mode 100644 index 000000000000..01ff34a408ec --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-tags.ts @@ -0,0 +1,56 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { listProductTagsOutputSchema } from '../../output-schemas'; + +export const wooAiListProductTags = createAction({ + name: 'list_product_tags', + classification: 'SEARCH', + displayName: 'List Product Tags', + description: 'List product tags, optionally filtered by name or slug.', + audience: 'ai', + aiMetadata: { + description: + 'Lists product tags with their ids, slugs and product counts. Use it to find a tag id for list_products, create_product or update_product, and before create_product_tag to avoid a duplicate. Paged: if a page returns exactly per_page items, request the next page.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: listProductTagsOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Text to match against tag names.', + required: false, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: 'Exact tag slug.', + required: false, + }), + hide_empty: Property.Checkbox({ + displayName: 'Hide Empty', + description: 'When on, tags without products are left out.', + required: false, + defaultValue: false, + }), + page: wooProps.pageProp(), + per_page: wooProps.perPageProp(), + }, + async run(context) { + const props = context.propsValue; + const paging = await wooValues.paging({ page: props.page, perPage: props.per_page }); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/products/tags', + queryParams: { + search: wooValues.nonEmpty(props.search), + slug: wooValues.nonEmpty(props.slug), + hide_empty: props.hide_empty === true ? true : undefined, + ...paging, + }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-variations.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-variations.ts new file mode 100644 index 000000000000..b49771d63b6a --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-product-variations.ts @@ -0,0 +1,52 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { listProductVariationsOutputSchema } from '../../output-schemas'; + +export const wooAiListProductVariations = createAction({ + name: 'list_product_variations', + classification: 'SEARCH', + displayName: 'List Product Variations', + description: 'List the variations of a variable product.', + audience: 'ai', + aiMetadata: { + description: + 'Lists the variations (such as sizes or colours) of one variable product, with their ids, attributes, prices and stock. Use it to find a variation id for get_product_variation or update_product_variation; simple products have no variations. Paged: if a page returns exactly per_page items, request the next page.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: listProductVariationsOutputSchema, + props: { + product_id: Property.Number({ + displayName: 'Product ID', + description: 'Id of the variable product. Find it with list_products.', + required: true, + }), + sku: Property.ShortText({ + displayName: 'SKU', + description: 'Only return the variation with this SKU.', + required: false, + }), + stock_status: wooProps.stockStatusProp({ + description: 'Only return variations with this stock status.', + }), + page: wooProps.pageProp(), + per_page: wooProps.perPageProp(), + }, + async run(context) { + const props = context.propsValue; + const paging = await wooValues.paging({ page: props.page, perPage: props.per_page }); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: `/products/${wooClient.encodeId(props.product_id)}/variations`, + queryParams: { + sku: wooValues.nonEmpty(props.sku), + stock_status: props.stock_status, + ...paging, + }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/list-products.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-products.ts new file mode 100644 index 000000000000..1ddb2e84497c --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/list-products.ts @@ -0,0 +1,116 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { listProductsOutputSchema } from '../../output-schemas'; + +export const wooAiListProducts = createAction({ + name: 'list_products', + classification: 'SEARCH', + displayName: 'List Products', + description: 'List products, filtered by text, SKU, status, category, tag, stock or price.', + audience: 'ai', + aiMetadata: { + description: + 'Searches the store catalog and returns matching products, including their ids, prices and stock. Use it to find a product id by name or SKU before get_product, update_product or create_order; variations of a variable product are listed with list_product_variations. Paged: if a page returns exactly per_page items, request the next page.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: listProductsOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Text to match against product names and content.', + required: false, + }), + sku: Property.ShortText({ + displayName: 'SKU', + description: 'Exact SKU to match. Separate several SKUs with commas.', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'Only return products with this status. Leave empty for any status.', + required: false, + options: { options: wooProps.productStatusOptions }, + }), + type: Property.StaticDropdown({ + displayName: 'Product Type', + description: 'Only return products of this type.', + required: false, + options: { options: wooProps.productTypeOptions }, + }), + category_id: Property.Number({ + displayName: 'Category ID', + description: 'Only return products in this category. Find ids with list_product_categories.', + required: false, + }), + tag_id: Property.Number({ + displayName: 'Tag ID', + description: 'Only return products with this tag. Find ids with list_product_tags.', + required: false, + }), + stock_status: wooProps.stockStatusProp({ + description: 'Only return products with this stock status.', + }), + on_sale: wooProps.triStateProp({ + displayName: 'On Sale', + description: 'Yes returns only products on sale, No only products not on sale. Leave empty for both.', + }), + min_price: Property.ShortText({ + displayName: 'Minimum Price', + description: 'Only return products priced at or above this amount, e.g. 10.00.', + required: false, + }), + max_price: Property.ShortText({ + displayName: 'Maximum Price', + description: 'Only return products priced at or below this amount, e.g. 99.99.', + required: false, + }), + orderby: Property.StaticDropdown({ + displayName: 'Sort By', + description: 'Field to sort by. Defaults to date.', + required: false, + options: { + options: [ + { label: 'Date', value: 'date' }, + { label: 'ID', value: 'id' }, + { label: 'Title', value: 'title' }, + { label: 'Slug', value: 'slug' }, + { label: 'Price', value: 'price' }, + { label: 'Popularity', value: 'popularity' }, + { label: 'Rating', value: 'rating' }, + ], + }, + }), + order: wooProps.sortOrderProp(), + page: wooProps.pageProp(), + per_page: wooProps.perPageProp(), + }, + async run(context) { + const props = context.propsValue; + const paging = await wooValues.paging({ page: props.page, perPage: props.per_page }); + const onSale = wooValues.resolveTriState(props.on_sale); + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.GET, + path: '/products', + queryParams: { + search: wooValues.nonEmpty(props.search), + sku: wooValues.nonEmpty(props.sku), + status: props.status, + type: props.type, + category: props.category_id, + tag: props.tag_id, + stock_status: props.stock_status, + on_sale: onSale, + min_price: wooValues.nonEmpty(props.min_price), + max_price: wooValues.nonEmpty(props.max_price), + orderby: props.orderby, + order: props.order, + ...paging, + }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/update-coupon.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-coupon.ts new file mode 100644 index 000000000000..3b568343c7dc --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-coupon.ts @@ -0,0 +1,21 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { updateCouponOutputSchema } from '../../output-schemas'; +import { wooUpdateCoupon } from '../update-coupon'; + +export const wooAiUpdateCoupon = createAction({ + name: 'update_coupon', + classification: 'WRITE', + displayName: 'Update Coupon', + description: 'Change the amount, type, expiry or limits of a coupon.', + audience: 'ai', + aiMetadata: { + description: + 'Changes the amount, discount type, description, expiry date, usage limit or minimum spend of one coupon by id; fields left empty are kept. Find the id with list_coupons by code.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: updateCouponOutputSchema, + props: wooUpdateCoupon.props, + run: wooUpdateCoupon.run, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/update-customer.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-customer.ts new file mode 100644 index 000000000000..52bde320781a --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-customer.ts @@ -0,0 +1,21 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { updateCustomerOutputSchema } from '../../output-schemas'; +import { wooUpdateCustomer } from '../update-customer'; + +export const wooAiUpdateCustomer = createAction({ + name: 'update_customer', + classification: 'WRITE', + displayName: 'Update Customer', + description: 'Change the name, email or billing details of a customer.', + audience: 'ai', + aiMetadata: { + description: + 'Changes the email, first or last name, or billing phone, city, postcode and country of one customer by id; fields left empty are kept. Find the id with list_customers.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: updateCustomerOutputSchema, + props: wooUpdateCustomer.props, + run: wooUpdateCustomer.run, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/update-order.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-order.ts new file mode 100644 index 000000000000..34e1d3ae56c2 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-order.ts @@ -0,0 +1,21 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { updateOrderOutputSchema } from '../../output-schemas'; +import { wooUpdateOrder } from '../update-order'; + +export const wooAiUpdateOrder = createAction({ + name: 'update_order', + classification: 'WRITE', + displayName: 'Update Order', + description: 'Change the status, customer note or transaction ID of an order.', + audience: 'ai', + aiMetadata: { + description: + 'Changes the status, customer-provided note field or transaction id of one order; fields left empty are kept. Status changes have side effects: processing and completed email the customer, and refunded records a refund for the remaining amount and emails the customer, but moves no money and does not restock (use create_order_refund for partial refunds). The customer note field sends nothing; to message the customer use add_order_note.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: updateOrderOutputSchema, + props: wooUpdateOrder.props, + run: wooUpdateOrder.run, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/update-product-review.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-product-review.ts new file mode 100644 index 000000000000..713db11f7c74 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-product-review.ts @@ -0,0 +1,51 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { productReviewOutputSchema } from '../../output-schemas'; + +export const wooAiUpdateProductReview = createAction({ + name: 'update_product_review', + classification: 'WRITE', + displayName: 'Moderate Product Review', + description: 'Approve, hold, mark as spam or trash a product review.', + audience: 'ai', + aiMetadata: { + description: + 'Changes the moderation status of one product review: approve it, put it on hold, mark or unmark it as spam, or move it to or from the trash. The review text and rating cannot be changed here. Find review ids with list_product_reviews.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: productReviewOutputSchema, + props: { + review_id: Property.Number({ + displayName: 'Review ID', + description: 'Id of the review.', + required: true, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'New moderation status.', + required: true, + options: { + options: [ + { label: 'Approve', value: 'approved' }, + { label: 'Hold (unapprove)', value: 'hold' }, + { label: 'Mark as spam', value: 'spam' }, + { label: 'Not spam', value: 'unspam' }, + { label: 'Move to trash', value: 'trash' }, + { label: 'Restore from trash', value: 'untrash' }, + ], + }, + }), + }, + async run(context) { + const { review_id, status } = context.propsValue; + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.PUT, + path: `/products/reviews/${wooClient.encodeId(review_id)}`, + body: { status }, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/update-product-variation.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-product-variation.ts new file mode 100644 index 000000000000..7781f871fce3 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-product-variation.ts @@ -0,0 +1,96 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { productVariationOutputSchema } from '../../output-schemas'; + +export const wooAiUpdateProductVariation = createAction({ + name: 'update_product_variation', + classification: 'WRITE', + displayName: 'Update Product Variation', + description: 'Change selected fields of one variation. Fields left empty are not changed.', + audience: 'ai', + aiMetadata: { + description: + 'Changes only the supplied fields (price, SKU, status, stock, description) of one variation of a variable product; everything else is left as it is. Find the ids with list_product_variations. For the parent product itself use update_product.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: productVariationOutputSchema, + props: { + product_id: Property.Number({ + displayName: 'Product ID', + description: 'Id of the parent variable product.', + required: true, + }), + variation_id: Property.Number({ + displayName: 'Variation ID', + description: 'Id of the variation to update.', + required: true, + }), + regular_price: Property.ShortText({ + displayName: 'Regular Price', + description: 'New regular price as a decimal string, e.g. 19.99.', + required: false, + }), + sale_price: Property.ShortText({ + displayName: 'Sale Price', + description: 'New sale price as a decimal string. To remove a sale use End Sale instead.', + required: false, + }), + end_sale: Property.Checkbox({ + displayName: 'End Sale', + description: 'When on, clears the sale price so the variation sells at its regular price again.', + required: false, + defaultValue: false, + }), + sku: Property.ShortText({ + displayName: 'SKU', + description: 'New SKU. Must be unique in the store.', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'New status. Private hides the variation from customers.', + required: false, + options: { options: wooProps.productStatusOptions }, + }), + stock_quantity: Property.Number({ + displayName: 'Stock Quantity', + description: 'New number of units in stock. Setting it turns on stock management for this variation.', + required: false, + }), + stock_status: wooProps.stockStatusProp({ + description: 'New stock status. Ignored when a stock quantity is set.', + }), + description: Property.LongText({ + displayName: 'Description', + description: 'New variation description.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const hasStock = props.stock_quantity !== undefined && props.stock_quantity !== null; + const body = wooValues.pruneUndefined({ + regular_price: wooValues.nonEmpty(props.regular_price), + sale_price: props.end_sale ? '' : wooValues.nonEmpty(props.sale_price), + sku: wooValues.nonEmpty(props.sku), + status: props.status, + manage_stock: hasStock ? true : undefined, + stock_quantity: hasStock ? props.stock_quantity : undefined, + stock_status: hasStock ? undefined : props.stock_status, + description: wooValues.nonEmpty(props.description), + }); + if (Object.keys(body).length === 0) { + throw new Error('No fields to update were supplied. Provide at least one field to change.'); + } + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.PUT, + path: `/products/${wooClient.encodeId(props.product_id)}/variations/${wooClient.encodeId(props.variation_id)}`, + body, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/ai/update-product.ts b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-product.ts new file mode 100644 index 000000000000..c8f3ba25ca86 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/actions/ai/update-product.ts @@ -0,0 +1,136 @@ +import { HttpMethod } from '@activepieces/pieces-common'; +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wooAuth } from '../../auth'; +import { wooClient } from '../../common/client'; +import { wooProps, wooValues } from '../../common/props'; +import { updateProductOutputSchema } from '../../output-schemas'; + +export const wooAiUpdateProduct = createAction({ + name: 'update_product', + classification: 'WRITE', + displayName: 'Update Product', + description: 'Change selected fields of a product. Fields left empty are not changed.', + audience: 'ai', + aiMetadata: { + description: + 'Changes only the supplied fields of one product by id; everything else is left as it is. Category IDs and Tag IDs replace the whole list, so include the existing ids you want to keep (read them with get_product). For several products at once use batch_update_products; for a variation use update_product_variation.', + idempotent: true, + }, + auth: wooAuth, + outputSchema: updateProductOutputSchema, + props: { + product_id: Property.Number({ + displayName: 'Product ID', + description: 'Id of the product to update. Find it with list_products.', + required: true, + }), + name: Property.ShortText({ + displayName: 'Name', + description: 'New product name.', + required: false, + }), + sku: Property.ShortText({ + displayName: 'SKU', + description: 'New SKU. Must be unique in the store.', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'New status.', + required: false, + options: { options: wooProps.productStatusOptions }, + }), + regular_price: Property.ShortText({ + displayName: 'Regular Price', + description: 'New regular price as a decimal string, e.g. 19.99.', + required: false, + }), + sale_price: Property.ShortText({ + displayName: 'Sale Price', + description: 'New sale price as a decimal string. To remove a sale use End Sale instead.', + required: false, + }), + end_sale: Property.Checkbox({ + displayName: 'End Sale', + description: 'When on, clears the sale price so the product sells at its regular price again.', + required: false, + defaultValue: false, + }), + stock_quantity: Property.Number({ + displayName: 'Stock Quantity', + description: 'New number of units in stock. Setting it turns on stock management.', + required: false, + }), + stock_status: wooProps.stockStatusProp({ + description: 'New stock status. Ignored when a stock quantity is set.', + }), + featured: wooProps.triStateProp({ + displayName: 'Featured', + description: 'Yes marks the product as featured, No removes the mark. Leave empty to keep it.', + }), + catalog_visibility: Property.StaticDropdown({ + displayName: 'Catalog Visibility', + description: 'Where the product is shown.', + required: false, + options: { + options: [ + { label: 'Shop and search results', value: 'visible' }, + { label: 'Shop only', value: 'catalog' }, + { label: 'Search results only', value: 'search' }, + { label: 'Hidden', value: 'hidden' }, + ], + }, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'New full description. HTML is allowed.', + required: false, + }), + short_description: Property.LongText({ + displayName: 'Short Description', + description: 'New short description. HTML is allowed.', + required: false, + }), + category_ids: Property.Array({ + displayName: 'Category IDs', + description: 'Replaces all of the product categories with these ids. Include the ones to keep.', + required: false, + }), + tag_ids: Property.Array({ + displayName: 'Tag IDs', + description: 'Replaces all of the product tags with these ids. Include the ones to keep.', + required: false, + }), + }, + async run(context) { + const props = context.propsValue; + const categoryIds = wooValues.toIdList(props.category_ids); + const tagIds = wooValues.toIdList(props.tag_ids); + const hasStock = props.stock_quantity !== undefined && props.stock_quantity !== null; + const body = wooValues.pruneUndefined({ + name: wooValues.nonEmpty(props.name), + sku: wooValues.nonEmpty(props.sku), + status: props.status, + regular_price: wooValues.nonEmpty(props.regular_price), + sale_price: props.end_sale ? '' : wooValues.nonEmpty(props.sale_price), + manage_stock: hasStock ? true : undefined, + stock_quantity: hasStock ? props.stock_quantity : undefined, + stock_status: hasStock ? undefined : props.stock_status, + featured: wooValues.resolveTriState(props.featured), + catalog_visibility: props.catalog_visibility, + description: wooValues.nonEmpty(props.description), + short_description: wooValues.nonEmpty(props.short_description), + categories: categoryIds?.map((id) => ({ id })), + tags: tagIds?.map((id) => ({ id })), + }); + if (Object.keys(body).length === 0) { + throw new Error('No fields to update were supplied. Provide at least one field to change.'); + } + return wooClient.request({ + auth: context.auth.props, + method: HttpMethod.PUT, + path: `/products/${wooClient.encodeId(props.product_id)}`, + body, + }); + }, +}); diff --git a/packages/pieces/community/woocommerce/src/lib/actions/create-coupon.ts b/packages/pieces/community/woocommerce/src/lib/actions/create-coupon.ts index cf9a7068bf86..126d7a26b3f8 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/create-coupon.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/create-coupon.ts @@ -19,7 +19,7 @@ export const wooCreateCoupon = createAction({ classification: 'WRITE', displayName: 'Create Coupon', description: 'Create a coupon', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Creates a new discount coupon in a WooCommerce store with a code, discount type (fixed_cart, fixed_product, percent, or percent_product), amount, and minimum order amount. Use when an agent needs to issue a promotional or discount code. Not idempotent: each call posts a new coupon, and the code must be unique in the store.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/create-customer.ts b/packages/pieces/community/woocommerce/src/lib/actions/create-customer.ts index e3e51f260c78..28db2eb026d9 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/create-customer.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/create-customer.ts @@ -14,7 +14,7 @@ export const wooCreateCustomer = createAction({ classification: 'WRITE', displayName: 'Create Customer', description: 'Create a Customer', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Creates a new customer account in a WooCommerce store with email, name, username, password, and a billing/shipping address (the same address is applied to both). Use when an agent needs to register a shopper. Not idempotent: each call creates a new customer, and email and username must be unique in the store.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/create-order.ts b/packages/pieces/community/woocommerce/src/lib/actions/create-order.ts index 0f33edd344a9..706772de51d1 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/create-order.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/create-order.ts @@ -14,10 +14,10 @@ export const wooCreateOrder = createAction({ classification: 'WRITE', displayName: 'Create Order', description: 'Create an order with one or more line items', - audience: 'both', + audience: 'human', aiMetadata: { description: - 'Creates a new order in a WooCommerce store from a list of product IDs and quantities, with an optional customer, status, and billing email. Use when an agent needs to place an order on a customer behalf. Not idempotent: each call creates a separate order.', + 'Creates a new order in a WooCommerce store from a list of product IDs and quantities, with an optional customer, status, billing email, and payment gateway (payment method id, with an optional display title). Use when an agent needs to place an order on a customer behalf. Not idempotent: each call creates a separate order.', idempotent: false, }, auth: wooAuth, @@ -62,6 +62,12 @@ export const wooCreateOrder = createAction({ description: 'Email address for the order', required: false, }), + payment_method: Property.ShortText({ + displayName: 'Payment Method ID', + description: + 'Payment gateway id the order is associated with, e.g. bacs, cheque or cod', + required: false, + }), payment_method_title: Property.ShortText({ displayName: 'Payment Method Title', description: 'Human-readable payment method, e.g. Direct bank transfer', @@ -70,8 +76,14 @@ export const wooCreateOrder = createAction({ }, async run(configValue) { const trimmedBaseUrl = configValue.auth.props.baseUrl.replace(/\/$/, ''); - const { line_items, customer_id, status, billing_email, payment_method_title } = - configValue.propsValue; + const { + line_items, + customer_id, + status, + billing_email, + payment_method, + payment_method_title, + } = configValue.propsValue; const body: Record = { line_items: line_items.map((item) => { @@ -91,6 +103,9 @@ export const wooCreateOrder = createAction({ if (billing_email) { body['billing'] = { email: billing_email }; } + if (payment_method) { + body['payment_method'] = payment_method; + } if (payment_method_title) { body['payment_method_title'] = payment_method_title; } diff --git a/packages/pieces/community/woocommerce/src/lib/actions/create-product.ts b/packages/pieces/community/woocommerce/src/lib/actions/create-product.ts index 3f0b9bb224a5..faafdc7a38b8 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/create-product.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/create-product.ts @@ -14,7 +14,7 @@ export const wooCreateProduct = createAction({ classification: 'WRITE', displayName: 'Create Product', description: 'Create a Product', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Creates a new product in a WooCommerce store with a name, type (simple, grouped, external, or variable), regular price, descriptions, category IDs, and image URLs (categories and images are comma-separated lists). Use when an agent needs to add a catalog item. Not idempotent: each call creates a new product.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/find-coupon.ts b/packages/pieces/community/woocommerce/src/lib/actions/find-coupon.ts index de17bea6c743..5244406d2070 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/find-coupon.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/find-coupon.ts @@ -14,7 +14,7 @@ export const wooFindCoupon = createAction({ classification: 'READ', displayName: 'Find Coupon', description: 'Find a Coupon', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Retrieves a single coupon from a WooCommerce store by its numeric coupon ID, including the discount type, amount, usage limits and restrictions. Use when an agent already has a coupon ID and needs the full coupon record. Read-only and idempotent.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/find-customer.ts b/packages/pieces/community/woocommerce/src/lib/actions/find-customer.ts index 1b9f9b527431..eb80cb27d05d 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/find-customer.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/find-customer.ts @@ -14,7 +14,7 @@ export const wooFindCustomer = createAction({ classification: 'READ', displayName: 'Find Customer', description: 'Find a Customer', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Looks up customers in a WooCommerce store by exact email address. Use when an agent needs to resolve a shopper to their customer record or ID before referencing them. Read-only and idempotent. Requires the email; returns matching customers (empty if none match).', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/find-orders.ts b/packages/pieces/community/woocommerce/src/lib/actions/find-orders.ts index 387d449e4c1b..fe2110b96ef7 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/find-orders.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/find-orders.ts @@ -14,7 +14,7 @@ export const wooFindOrders = createAction({ classification: 'READ', displayName: 'Find Orders', description: 'Search orders by status, customer or date', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Searches orders in a WooCommerce store, optionally narrowed by status, customer ID, or a created-after date, and returns a list of full order records. Use when an agent needs to process a batch of orders rather than one known order. Read-only and idempotent. Returns an array, newest first.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/find-product.ts b/packages/pieces/community/woocommerce/src/lib/actions/find-product.ts index 2696f705fbc0..3f1ef6a8ba61 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/find-product.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/find-product.ts @@ -14,7 +14,7 @@ export const wooFindProduct = createAction({ classification: 'READ', displayName: 'Find Product', description: 'Find a Product', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Retrieves a single product from a WooCommerce store by its numeric product ID. Use when an agent already has a product ID and needs the full product record (price, stock, status, etc.). Read-only and idempotent. Requires the exact product ID.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/get-customer.ts b/packages/pieces/community/woocommerce/src/lib/actions/get-customer.ts index 8b90b213e220..8088e50cbffe 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/get-customer.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/get-customer.ts @@ -14,7 +14,7 @@ export const wooGetCustomer = createAction({ classification: 'READ', displayName: 'Get Customer', description: 'Retrieve a single customer by their ID', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Retrieves one customer from a WooCommerce store by their numeric customer ID, including billing and shipping addresses. Use when an agent has a customer ID rather than an email address; Find Customer searches by email instead. Read-only and idempotent.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/get-order.ts b/packages/pieces/community/woocommerce/src/lib/actions/get-order.ts index f95fcbfd1143..0fcf59ab72ca 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/get-order.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/get-order.ts @@ -14,7 +14,7 @@ export const wooGetOrder = createAction({ classification: 'READ', displayName: 'Get Order', description: 'Retrieve a single order by its ID', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Retrieves one order from a WooCommerce store by its numeric order ID, including line items, billing and shipping addresses, totals, and status. Use when an agent already has an order ID and needs the full order record. Read-only and idempotent.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/update-coupon.ts b/packages/pieces/community/woocommerce/src/lib/actions/update-coupon.ts index 6162eaeca476..2ae8eb900034 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/update-coupon.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/update-coupon.ts @@ -14,7 +14,7 @@ export const wooUpdateCoupon = createAction({ classification: 'WRITE', displayName: 'Update Coupon', description: 'Update a coupon amount, description, expiry or usage limit', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Updates an existing WooCommerce coupon by ID. Can change the discount amount and type, description, expiry date, usage limit and minimum spend. Only the fields provided are changed. Idempotent: sending the same values twice leaves the coupon in the same state.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/update-customer.ts b/packages/pieces/community/woocommerce/src/lib/actions/update-customer.ts index f7933cd6e14a..3ae04936a00a 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/update-customer.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/update-customer.ts @@ -14,7 +14,7 @@ export const wooUpdateCustomer = createAction({ classification: 'WRITE', displayName: 'Update Customer', description: 'Update a customer name, email or billing details', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Updates an existing WooCommerce customer by ID. Can change the email, first and last name, and billing phone, city, postcode and country. Only the fields provided are changed. Idempotent: sending the same values twice leaves the customer in the same state.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/update-order.ts b/packages/pieces/community/woocommerce/src/lib/actions/update-order.ts index 7ce5c414cfb0..9859f5ad8fc9 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/update-order.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/update-order.ts @@ -14,7 +14,7 @@ export const wooUpdateOrder = createAction({ classification: 'WRITE', displayName: 'Update Order', description: 'Update an order, most commonly its status', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Updates an existing WooCommerce order by ID. Most often used to move an order through its lifecycle by setting status (pending, processing, on-hold, completed, cancelled, refunded, failed), and can also set the customer note or transaction ID. Only the fields provided are changed. Idempotent: applying the same status twice leaves the order in the same state.', diff --git a/packages/pieces/community/woocommerce/src/lib/actions/update-product.ts b/packages/pieces/community/woocommerce/src/lib/actions/update-product.ts index 9198ca8ce20f..3d57543eb096 100644 --- a/packages/pieces/community/woocommerce/src/lib/actions/update-product.ts +++ b/packages/pieces/community/woocommerce/src/lib/actions/update-product.ts @@ -14,7 +14,7 @@ export const wooUpdateProduct = createAction({ classification: 'WRITE', displayName: 'Update Product', description: 'Update a product, for example its price or stock level', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Updates an existing WooCommerce product by ID. Commonly used to change price, sale price, stock quantity, or publish status. Only the fields provided are changed; everything else is left untouched. Idempotent: sending the same values twice leaves the product in the same state.', diff --git a/packages/pieces/community/woocommerce/src/lib/common/client.ts b/packages/pieces/community/woocommerce/src/lib/common/client.ts new file mode 100644 index 000000000000..2cb487fe0044 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/common/client.ts @@ -0,0 +1,139 @@ +import { + AuthenticationType, + HttpError, + HttpMethod, + httpClient, + QueryParams, +} from '@activepieces/pieces-common'; +import { tryCatch } from '@activepieces/pieces-framework'; + +async function request({ + auth, + method, + path, + queryParams, + body, +}: WooRequestParams): Promise { + const baseUrl = auth.baseUrl.replace(/\/$/, ''); + const { data, error } = await tryCatch(() => + httpClient.sendRequest({ + method, + url: `${baseUrl}/wp-json/wc/v3${path}`, + authentication: { + type: AuthenticationType.BASIC, + username: auth.consumerKey, + password: auth.consumerSecret, + }, + queryParams: dropEmpty(queryParams), + body, + }) + ); + if (error) { + throw toWooError(error); + } + return data.body; +} + +function dropEmpty(queryParams: WooQueryParams | undefined): QueryParams { + const result: QueryParams = {}; + if (!queryParams) { + return result; + } + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined && value !== null && value !== '') { + result[key] = String(value); + } + } + return result; +} + +function toWooError(error: Error): Error { + if (!(error instanceof HttpError)) { + return error; + } + const status = error.response.status; + const body = readWooErrorBody(error.response.body); + const detail = `WooCommerce returned ${status}${body.code ? ` (${body.code})` : ''}`; + const message = body.message ?? ''; + if (status === 401) { + return new Error( + `${detail}: authentication failed. Check that the consumer key and secret are valid, that the key has Read/Write permission, and that the store is reached over HTTPS. ${message}`.trim() + ); + } + if (status === 403) { + return new Error( + `${detail}: the API key's user is not allowed to perform this operation. ${message}`.trim() + ); + } + if (status === 404) { + return new Error( + `${detail}: the requested resource was not found; check the id. ${message}`.trim() + ); + } + if (status === 410) { + return new Error( + `${detail}: the resource is already in the trash. Pass permanent=true to delete it for good. ${message}`.trim() + ); + } + if (body.resourceId !== undefined) { + return new Error( + `${detail}: ${message} Existing resource id: ${body.resourceId}.` + ); + } + return new Error(message ? `${detail}: ${message}` : detail); +} + +function readWooErrorBody(body: unknown): WooErrorBody { + if (typeof body !== 'object' || body === null) { + return {}; + } + const record: Record = Object(body); + const data: Record = + typeof record['data'] === 'object' && record['data'] !== null + ? Object(record['data']) + : {}; + const resourceId = data['resource_id']; + return { + code: typeof record['code'] === 'string' ? record['code'] : undefined, + message: + typeof record['message'] === 'string' ? record['message'] : undefined, + resourceId: + typeof resourceId === 'number' || typeof resourceId === 'string' + ? resourceId + : undefined, + }; +} + +function encodeId(id: string | number): string { + return encodeURIComponent(String(id).trim()); +} + +export const wooClient = { + request, + encodeId, +}; + +export type WooAuthProps = { + baseUrl: string; + consumerKey: string; + consumerSecret: string; +}; + +export type WooQueryParams = Record< + string, + string | number | boolean | undefined | null +>; + +type WooRequestParams = { + auth: WooAuthProps; + method: HttpMethod; + path: string; + queryParams?: WooQueryParams; + body?: unknown; +}; + +type WooErrorBody = { + code?: string; + message?: string; + resourceId?: number | string; +}; diff --git a/packages/pieces/community/woocommerce/src/lib/common/props.ts b/packages/pieces/community/woocommerce/src/lib/common/props.ts new file mode 100644 index 000000000000..3107ed94e5d4 --- /dev/null +++ b/packages/pieces/community/woocommerce/src/lib/common/props.ts @@ -0,0 +1,253 @@ +import { propsValidation } from '@activepieces/pieces-common'; +import { Property } from '@activepieces/pieces-framework'; +import * as z from 'zod/mini'; + +function pageProp() { + return Property.Number({ + displayName: 'Page', + description: + 'Page number, starting at 1. If a page comes back with exactly per_page items, request the next page to see more.', + required: false, + defaultValue: 1, + }); +} + +function perPageProp() { + return Property.Number({ + displayName: 'Per Page', + description: 'Number of items per page, from 1 to 100. Defaults to 10.', + required: false, + defaultValue: 10, + }); +} + +function sortOrderProp() { + return Property.StaticDropdown({ + displayName: 'Sort Direction', + description: 'Ascending or descending. Defaults to descending.', + required: false, + options: { + options: [ + { label: 'Descending', value: 'desc' }, + { label: 'Ascending', value: 'asc' }, + ], + }, + }); +} + +function triStateProp({ + displayName, + description, +}: { + displayName: string; + description: string; +}) { + return Property.StaticDropdown({ + displayName, + description, + required: false, + options: { + options: [ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + ], + }, + }); +} + +function stockStatusProp({ description }: { description: string }) { + return Property.StaticDropdown({ + displayName: 'Stock Status', + description, + required: false, + options: { options: stockStatusOptions }, + }); +} + +function permanentProp({ resource }: { resource: string }) { + return Property.Checkbox({ + displayName: 'Delete Permanently', + description: `When off (the default) the ${resource} is moved to the trash and can be restored from the WordPress admin. When on it is deleted for good.`, + required: false, + defaultValue: false, + }); +} + +async function paging({ + page, + perPage, +}: { + page: number | undefined; + perPage: number | undefined; +}): Promise<{ page: number; per_page: number }> { + await propsValidation.validateZod( + { page, per_page: perPage }, + { + page: z.optional(z.number().check(z.minimum(1))), + per_page: z.optional(z.number().check(z.minimum(1), z.maximum(100))), + } + ); + return { page: page ?? 1, per_page: perPage ?? 10 }; +} + +function resolveTriState(value: string | undefined | null): boolean | undefined { + if (value === 'yes') { + return true; + } + if (value === 'no') { + return false; + } + return undefined; +} + +function toIdList(values: unknown[] | undefined | null): number[] | undefined { + if (!values || values.length === 0) { + return undefined; + } + return values.map((value) => { + const parsed = Number(String(value).trim()); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error( + `Expected a list of numeric ids, but got "${String(value)}". Read the ids with the matching list action first.` + ); + } + return parsed; + }); +} + +function toStringList( + values: unknown[] | undefined | null +): string[] | undefined { + if (!values || values.length === 0) { + return undefined; + } + return values + .map((value) => String(value).trim()) + .filter((value) => value.length > 0); +} + +function pruneUndefined( + record: Record +): Record { + return Object.fromEntries( + Object.entries(record).filter(([, value]) => value !== undefined) + ); +} + +function nonEmpty(value: string | undefined | null): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function reportRangeProps() { + return { + period: Property.StaticDropdown({ + displayName: 'Period', + description: 'Preset period to report on. Leave empty when using a date range; if both are empty the current week is used.', + required: false, + options: { + options: [ + { label: 'This week', value: 'week' }, + { label: 'This month', value: 'month' }, + { label: 'Last month', value: 'last_month' }, + { label: 'This year', value: 'year' }, + ], + }, + }), + date_min: Property.ShortText({ + displayName: 'Start Date', + description: 'First day of a custom range, as YYYY-MM-DD. Use together with End Date instead of Period.', + required: false, + }), + date_max: Property.ShortText({ + displayName: 'End Date', + description: 'Last day of a custom range, as YYYY-MM-DD. Use together with Start Date instead of Period.', + required: false, + }), + }; +} + +function resolveReportRange({ + period, + dateMin, + dateMax, +}: { + period: string | undefined | null; + dateMin: string | undefined | null; + dateMax: string | undefined | null; +}): Record { + const min = nonEmpty(dateMin); + const max = nonEmpty(dateMax); + if ((min && !max) || (!min && max)) { + throw new Error('Provide both Start Date and End Date for a custom range, or neither.'); + } + if (period && min) { + throw new Error('Provide either Period or a Start Date and End Date range, not both.'); + } + if (min && max) { + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + if (!datePattern.test(min) || !datePattern.test(max)) { + throw new Error('Start Date and End Date must be in YYYY-MM-DD format.'); + } + return { date_min: min, date_max: max }; + } + return { period: period ?? undefined }; +} + +const stockStatusOptions = [ + { label: 'In stock', value: 'instock' }, + { label: 'Out of stock', value: 'outofstock' }, + { label: 'On backorder', value: 'onbackorder' }, +]; + +const orderStatusOptions = [ + { label: 'Pending payment', value: 'pending' }, + { label: 'Processing', value: 'processing' }, + { label: 'On hold', value: 'on-hold' }, + { label: 'Completed', value: 'completed' }, + { label: 'Cancelled', value: 'cancelled' }, + { label: 'Refunded', value: 'refunded' }, + { label: 'Failed', value: 'failed' }, + { label: 'Trash', value: 'trash' }, +]; + +const productStatusOptions = [ + { label: 'Published', value: 'publish' }, + { label: 'Draft', value: 'draft' }, + { label: 'Pending review', value: 'pending' }, + { label: 'Private', value: 'private' }, +]; + +const productTypeOptions = [ + { label: 'Simple', value: 'simple' }, + { label: 'Variable', value: 'variable' }, + { label: 'Grouped', value: 'grouped' }, + { label: 'External / affiliate', value: 'external' }, +]; + +export const wooProps = { + pageProp, + perPageProp, + sortOrderProp, + triStateProp, + stockStatusProp, + permanentProp, + reportRangeProps, + stockStatusOptions, + orderStatusOptions, + productStatusOptions, + productTypeOptions, +}; + +export const wooValues = { + paging, + resolveTriState, + toIdList, + toStringList, + pruneUndefined, + nonEmpty, + resolveReportRange, +}; diff --git a/packages/pieces/community/woocommerce/src/lib/output-schemas.ts b/packages/pieces/community/woocommerce/src/lib/output-schemas.ts index 3f8823e8a55f..df739a931df1 100644 --- a/packages/pieces/community/woocommerce/src/lib/output-schemas.ts +++ b/packages/pieces/community/woocommerce/src/lib/output-schemas.ts @@ -341,6 +341,168 @@ const orderNoteFields: Fields = [ { key: 'date_created_gmt', label: 'Created At (GMT)', format: 'datetime' }, ]; +const variationAttributeFields: Fields = [ + { key: 'id', label: 'Attribute ID' }, + { key: 'name', label: 'Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'option', label: 'Option' }, +]; + +const variationFields: Fields = [ + { key: 'id', label: 'Variation ID' }, + { key: 'parent_id', label: 'Parent Product ID' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'status', label: 'Status' }, + { key: 'description', label: 'Description', format: 'html' }, + { key: 'permalink', label: 'Permalink', format: 'url' }, + { key: 'sku', label: 'SKU' }, + { key: 'global_unique_id', label: 'Global Unique ID' }, + { key: 'price', label: 'Price' }, + { key: 'regular_price', label: 'Regular Price' }, + { key: 'sale_price', label: 'Sale Price' }, + { key: 'date_on_sale_from', label: 'On Sale From', format: 'datetime' }, + { key: 'date_on_sale_from_gmt', label: 'On Sale From (GMT)', format: 'datetime' }, + { key: 'date_on_sale_to', label: 'On Sale To', format: 'datetime' }, + { key: 'date_on_sale_to_gmt', label: 'On Sale To (GMT)', format: 'datetime' }, + { key: 'on_sale', label: 'On Sale', format: 'boolean' }, + { key: 'purchasable', label: 'Purchasable', format: 'boolean' }, + { key: 'virtual', label: 'Virtual', format: 'boolean' }, + { key: 'downloadable', label: 'Downloadable', format: 'boolean' }, + { key: 'downloads', label: 'Downloads', listItems: downloadFields, labelKey: 'name' }, + { key: 'download_limit', label: 'Download Limit', format: 'number' }, + { key: 'download_expiry', label: 'Download Expiry (Days)', format: 'number' }, + { key: 'tax_status', label: 'Tax Status' }, + { key: 'tax_class', label: 'Tax Class' }, + { key: 'manage_stock', label: 'Manage Stock' }, + { key: 'stock_quantity', label: 'Stock Quantity', format: 'number' }, + { key: 'stock_status', label: 'Stock Status' }, + { key: 'backorders', label: 'Backorders' }, + { key: 'backorders_allowed', label: 'Backorders Allowed', format: 'boolean' }, + { key: 'backordered', label: 'Backordered', format: 'boolean' }, + { key: 'low_stock_amount', label: 'Low Stock Amount', format: 'number' }, + { key: 'weight', label: 'Weight' }, + { key: 'dimensions', label: 'Dimensions', children: dimensionFields }, + { key: 'shipping_class', label: 'Shipping Class' }, + { key: 'shipping_class_id', label: 'Shipping Class ID' }, + { key: 'image', label: 'Image', children: imageFields }, + { key: 'gallery_image_ids', label: 'Gallery Image IDs' }, + { key: 'attributes', label: 'Attributes', listItems: variationAttributeFields, labelKey: 'name' }, + { key: 'menu_order', label: 'Menu Order', format: 'number' }, + { key: 'date_created', label: 'Created At', format: 'datetime' }, + { key: 'date_created_gmt', label: 'Created At (GMT)', format: 'datetime' }, + { key: 'date_modified', label: 'Modified At', format: 'datetime' }, + { key: 'date_modified_gmt', label: 'Modified At (GMT)', format: 'datetime' }, + { key: 'meta_data', label: 'Meta Data', listItems: metaDataFields, labelKey: 'key' }, +]; + +const categoryFields: Fields = [ + { key: 'id', label: 'Category ID' }, + { key: 'name', label: 'Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'parent', label: 'Parent Category ID' }, + { key: 'description', label: 'Description', format: 'html' }, + { key: 'display', label: 'Display Type' }, + { key: 'image', label: 'Image' }, + { key: 'menu_order', label: 'Menu Order', format: 'number' }, + { key: 'count', label: 'Product Count', format: 'number' }, +]; + +const tagFields: Fields = [ + { key: 'id', label: 'Tag ID' }, + { key: 'name', label: 'Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'description', label: 'Description' }, + { key: 'count', label: 'Product Count', format: 'number' }, +]; + +const reviewFields: Fields = [ + { key: 'id', label: 'Review ID' }, + { key: 'product_id', label: 'Product ID' }, + { key: 'product_name', label: 'Product Name' }, + { key: 'product_permalink', label: 'Product URL', format: 'url' }, + { key: 'status', label: 'Status' }, + { key: 'reviewer', label: 'Reviewer' }, + { key: 'reviewer_email', label: 'Reviewer Email', format: 'email' }, + { key: 'review', label: 'Review', format: 'html' }, + { key: 'rating', label: 'Rating', format: 'number' }, + { key: 'verified', label: 'Verified Owner', format: 'boolean' }, + { key: 'reviewer_avatar_urls', label: 'Reviewer Avatar URLs', children: [ + { key: '24', label: 'Avatar 24px', format: 'url' }, + { key: '48', label: 'Avatar 48px', format: 'url' }, + { key: '96', label: 'Avatar 96px', format: 'url' }, + ] }, + { key: 'date_created', label: 'Created At', format: 'datetime' }, + { key: 'date_created_gmt', label: 'Created At (GMT)', format: 'datetime' }, +]; + +const refundFields: Fields = [ + { key: 'id', label: 'Refund ID' }, + { key: 'amount', label: 'Amount' }, + { key: 'reason', label: 'Reason' }, + { key: 'refunded_by', label: 'Refunded By User ID' }, + { key: 'refunded_payment', label: 'Refunded Through Gateway', format: 'boolean' }, + { key: 'date_created', label: 'Created At', format: 'datetime' }, + { key: 'date_created_gmt', label: 'Created At (GMT)', format: 'datetime' }, + { key: 'line_items', label: 'Refunded Line Items', listItems: lineItemFields, labelKey: 'name' }, + { key: 'shipping_lines', label: 'Refunded Shipping Lines', listItems: shippingLineFields, labelKey: 'method_title' }, + { key: 'tax_lines', label: 'Refunded Tax Lines', listItems: taxLineFields, labelKey: 'label' }, + { key: 'fee_lines', label: 'Refunded Fee Lines', listItems: feeLineFields, labelKey: 'name' }, + { key: 'meta_data', label: 'Meta Data', listItems: metaDataFields, labelKey: 'key' }, +]; + +const salesReportFields: Fields = [ + { key: 'total_sales', label: 'Total Sales' }, + { key: 'net_sales', label: 'Net Sales' }, + { key: 'average_sales', label: 'Average Sales Per Day' }, + { key: 'total_orders', label: 'Total Orders', format: 'number' }, + { key: 'total_items', label: 'Total Items', format: 'number' }, + { key: 'total_tax', label: 'Total Tax' }, + { key: 'total_shipping', label: 'Total Shipping' }, + { key: 'total_refunds', label: 'Total Refunds' }, + { key: 'total_discount', label: 'Total Discount' }, + { key: 'total_customers', label: 'New Customers', format: 'number' }, + { key: 'totals_grouped_by', label: 'Grouped By' }, + { key: 'totals', label: 'Totals By Period', dynamicKey: true, children: [ + { key: 'sales', label: 'Sales' }, + { key: 'orders', label: 'Orders', format: 'number' }, + { key: 'items', label: 'Items', format: 'number' }, + { key: 'tax', label: 'Tax' }, + { key: 'shipping', label: 'Shipping' }, + { key: 'discount', label: 'Discount' }, + { key: 'customers', label: 'New Customers', format: 'number' }, + { key: 'refunds', label: 'Refunds' }, + ] }, +]; + +const topSellerFields: Fields = [ + { key: 'name', label: 'Product Name' }, + { key: 'product_id', label: 'Product ID' }, + { key: 'quantity', label: 'Quantity Sold', format: 'number' }, +]; + +const orderTotalFields: Fields = [ + { key: 'slug', label: 'Status' }, + { key: 'name', label: 'Status Name' }, + { key: 'total', label: 'Orders', format: 'number' }, +]; + +const paymentGatewayFields: Fields = [ + { key: 'id', label: 'Gateway ID' }, + { key: 'title', label: 'Title' }, + { key: 'description', label: 'Description' }, + { key: 'order', label: 'Sort Order' }, + { key: 'enabled', label: 'Enabled', format: 'boolean' }, + { key: 'method_title', label: 'Method Title' }, + { key: 'method_description', label: 'Method Description', format: 'html' }, + { key: 'method_supports', label: 'Supported Features' }, +]; + +const batchErrorFields: Fields = [ + { key: 'code', label: 'Error Code' }, + { key: 'message', label: 'Error Message' }, +]; + const listOf = (key: string, label: string, fields: Fields, itemLabel: string): OutputSchema => ({ itemLabel, fields: [{ key, label, value: '', listItems: fields }], @@ -366,6 +528,39 @@ export const findOrdersOutputSchema: OutputSchema = listOf('orders', 'Orders', o export const addOrderNoteOutputSchema: OutputSchema = { fields: orderNoteFields }; +export const listProductsOutputSchema: OutputSchema = listOf('products', 'Products', productFields, '{name}'); +export const deleteProductOutputSchema: OutputSchema = { fields: productFields }; +export const batchUpdateProductsOutputSchema: OutputSchema = { + fields: [ + { key: 'update', label: 'Updated Products', labelKey: 'name', listItems: [ + ...productFields, + { key: 'error', label: 'Error', children: batchErrorFields }, + ] }, + ], +}; +export const listProductVariationsOutputSchema: OutputSchema = listOf('variations', 'Variations', variationFields, '{sku}'); +export const productVariationOutputSchema: OutputSchema = { fields: variationFields }; +export const listProductCategoriesOutputSchema: OutputSchema = listOf('categories', 'Categories', categoryFields, '{name}'); +export const productCategoryOutputSchema: OutputSchema = { fields: categoryFields }; +export const listProductTagsOutputSchema: OutputSchema = listOf('tags', 'Tags', tagFields, '{name}'); +export const productTagOutputSchema: OutputSchema = { fields: tagFields }; +export const listProductReviewsOutputSchema: OutputSchema = listOf('reviews', 'Reviews', reviewFields, '{reviewer}'); +export const productReviewOutputSchema: OutputSchema = { fields: reviewFields }; +export const listOrdersOutputSchema: OutputSchema = listOf('orders', 'Orders', orderFields, '#{number} — {status}'); +export const deleteOrderOutputSchema: OutputSchema = { fields: orderFields }; +export const listOrderNotesOutputSchema: OutputSchema = listOf('notes', 'Notes', orderNoteFields, '{note}'); +export const deleteOrderNoteOutputSchema: OutputSchema = { fields: orderNoteFields }; +export const orderRefundOutputSchema: OutputSchema = { fields: refundFields }; +export const listOrderRefundsOutputSchema: OutputSchema = listOf('refunds', 'Refunds', refundFields, '{amount} — {reason}'); +export const listCustomersOutputSchema: OutputSchema = listOf('customers', 'Customers', customerFields, '{email}'); +export const deleteCustomerOutputSchema: OutputSchema = { fields: customerFields }; +export const listCouponsOutputSchema: OutputSchema = listOf('coupons', 'Coupons', couponFields, '{code}'); +export const deleteCouponOutputSchema: OutputSchema = { fields: couponFields }; +export const salesReportOutputSchema: OutputSchema = listOf('report', 'Sales Report', salesReportFields, '{total_sales}'); +export const topSellersReportOutputSchema: OutputSchema = listOf('top_sellers', 'Top Sellers', topSellerFields, '{name}'); +export const orderTotalsReportOutputSchema: OutputSchema = listOf('totals', 'Order Totals', orderTotalFields, '{name}'); +export const listPaymentGatewaysOutputSchema: OutputSchema = listOf('gateways', 'Payment Gateways', paymentGatewayFields, '{title}'); + export const productTriggerOutputSchema: OutputSchema = { fields: productFields }; export const orderTriggerOutputSchema: OutputSchema = { fields: orderFields }; export const couponTriggerOutputSchema: OutputSchema = { fields: couponFields }; diff --git a/packages/pieces/community/wordpress/package.json b/packages/pieces/community/wordpress/package.json index d5eeb06c8a6e..f68535d0e821 100644 --- a/packages/pieces/community/wordpress/package.json +++ b/packages/pieces/community/wordpress/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-wordpress", - "version": "0.6.0", + "version": "0.7.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/wordpress/src/i18n/translation.json b/packages/pieces/community/wordpress/src/i18n/translation.json index 363a0db92808..81cf5c5168f7 100644 --- a/packages/pieces/community/wordpress/src/i18n/translation.json +++ b/packages/pieces/community/wordpress/src/i18n/translation.json @@ -72,5 +72,262 @@ "Raw": "Raw", "New Post": "New Post", "Triggers when a new post is published": "Triggers when a new post is published", - "Authors": "Authors" -} \ No newline at end of file + "Authors": "Authors", + "List Posts": "List Posts", + "Lists blog posts, optionally filtered by keyword, status, category, tag, author or date.": "Lists blog posts, optionally filtered by keyword, status, category, tag, author or date.", + "Search": "Search", + "Only posts whose title, content or excerpt contain this text.": "Only posts whose title, content or excerpt contain this text.", + "Post statuses to include. Defaults to published only. \"Any\" includes every status except trash.": "Post statuses to include. Defaults to published only. \"Any\" includes every status except trash.", + "Pending Review": "Pending Review", + "Any": "Any", + "Category IDs": "Category IDs", + "Only posts in any of these category IDs (from list_categories).": "Only posts in any of these category IDs (from list_categories).", + "Tag IDs": "Tag IDs", + "Only posts with any of these tag IDs (from list_tags).": "Only posts with any of these tag IDs (from list_tags).", + "Author ID": "Author ID", + "Only posts by this user ID (from list_users).": "Only posts by this user ID (from list_users).", + "Published After": "Published After", + "Only posts published after this ISO 8601 date, e.g. 2026-01-31T00:00:00.": "Only posts published after this ISO 8601 date, e.g. 2026-01-31T00:00:00.", + "Published Before": "Published Before", + "Only posts published before this ISO 8601 date.": "Only posts published before this ISO 8601 date.", + "Order By": "Order By", + "Sort field. Defaults to date. \"Relevance\" needs Search.": "Sort field. Defaults to date. \"Relevance\" needs Search.", + "Modified Date": "Modified Date", + "ID": "ID", + "Relevance": "Relevance", + "Order": "Order", + "Sort direction. Defaults to descending.": "Sort direction. Defaults to descending.", + "Descending": "Descending", + "Ascending": "Ascending", + "Results Per Page": "Results Per Page", + "How many results to return, from 1 to 100. Defaults to 10.": "How many results to return, from 1 to 100. Defaults to 10.", + "Page": "Page", + "Page of results to return, starting at 1. The output reports total_pages.": "Page of results to return, starting at 1. The output reports total_pages.", + "Get Post by ID": "Get Post by ID", + "Gets one blog post by its ID.": "Gets one blog post by its ID.", + "ID of the post, from list_posts or search_site_content.": "ID of the post, from list_posts or search_site_content.", + "Create Blog Post": "Create Blog Post", + "Creates a blog post. Saved as a draft unless a status is given.": "Creates a blog post. Saved as a draft unless a status is given.", + "The post title.": "The post title.", + "The post body as HTML.": "The post body as HTML.", + "Leave empty to save the post as a draft. \"Scheduled (future)\" needs a Publish Date.": "Leave empty to save the post as a draft. \"Scheduled (future)\" needs a Publish Date.", + "Publish": "Publish", + "Scheduled (future)": "Scheduled (future)", + "Publish Date": "Publish Date", + "Publish date in the site's timezone, ISO 8601 (e.g. 2030-01-31T09:00:00). A past date with status \"future\" publishes immediately.": "Publish date in the site's timezone, ISO 8601 (e.g. 2030-01-31T09:00:00). A past date with status \"future\" publishes immediately.", + "URL slug.": "URL slug.", + "Featured Media ID": "Featured Media ID", + "ID of an uploaded image to use as the featured image. Get it from upload_media or list_media.": "ID of an uploaded image to use as the featured image. Get it from upload_media or list_media.", + "User ID of the author. Get it from list_users or get_current_user. Leave empty for the connected user.": "User ID of the author. Get it from list_users or get_current_user. Leave empty for the connected user.", + "Comments": "Comments", + "Whether visitors can comment.": "Whether visitors can comment.", + "Open": "Open", + "Closed": "Closed", + "Pingbacks": "Pingbacks", + "Whether the post accepts pingbacks and trackbacks.": "Whether the post accepts pingbacks and trackbacks.", + "Short summary of the post.": "Short summary of the post.", + "Category IDs from list_categories or create_category.": "Category IDs from list_categories or create_category.", + "Tag IDs from list_tags or create_tag.": "Tag IDs from list_tags or create_tag.", + "Sticky": "Sticky", + "Whether the post is pinned to the top of the blog.": "Whether the post is pinned to the top of the blog.", + "Yes": "Yes", + "No": "No", + "Format": "Format", + "Custom ACF Fields": "Custom ACF Fields", + "Post format. Only formats the active theme supports are accepted.": "Post format. Only formats the active theme supports are accepted.", + "Advanced Custom Fields values as field name and value pairs; field names come from the ACF plugin menu. Only the fields you supply are written. Requires the site to expose ACF over the REST API.": "Advanced Custom Fields values as field name and value pairs; field names come from the ACF plugin menu. Only the fields you supply are written. Requires the site to expose ACF over the REST API.", + "Standard": "Standard", + "Aside": "Aside", + "Chat": "Chat", + "Gallery": "Gallery", + "Link": "Link", + "Image": "Image", + "Quote": "Quote", + "Video": "Video", + "Audio": "Audio", + "Update Blog Post": "Update Blog Post", + "Changes only the fields you supply on an existing blog post.": "Changes only the fields you supply on an existing blog post.", + "ID of the post to update, from list_posts.": "ID of the post to update, from list_posts.", + "The post title. Leave empty to keep the current value.": "The post title. Leave empty to keep the current value.", + "The post body as HTML. Leave empty to keep the current value.": "The post body as HTML. Leave empty to keep the current value.", + "New status. Leave empty to keep the current value. To trash the post, use the trash action instead.": "New status. Leave empty to keep the current value. To trash the post, use the trash action instead.", + "Publish date in the site's timezone, ISO 8601 (e.g. 2030-01-31T09:00:00). A past date with status \"future\" publishes immediately. Leave empty to keep the current value.": "Publish date in the site's timezone, ISO 8601 (e.g. 2030-01-31T09:00:00). A past date with status \"future\" publishes immediately. Leave empty to keep the current value.", + "URL slug. Leave empty to keep the current value.": "URL slug. Leave empty to keep the current value.", + "ID of an uploaded image to use as the featured image. Get it from upload_media or list_media. Leave empty to keep the current value.": "ID of an uploaded image to use as the featured image. Get it from upload_media or list_media. Leave empty to keep the current value.", + "Whether visitors can comment. Leave empty to keep the current value.": "Whether visitors can comment. Leave empty to keep the current value.", + "Whether the post accepts pingbacks and trackbacks. Leave empty to keep the current value.": "Whether the post accepts pingbacks and trackbacks. Leave empty to keep the current value.", + "Short summary of the post. Leave empty to keep the current value.": "Short summary of the post. Leave empty to keep the current value.", + "Category IDs from list_categories or create_category. The list replaces the post's current set. Leave empty to keep the current value.": "Category IDs from list_categories or create_category. The list replaces the post's current set. Leave empty to keep the current value.", + "Tag IDs from list_tags or create_tag. The list replaces the post's current set. Leave empty to keep the current value.": "Tag IDs from list_tags or create_tag. The list replaces the post's current set. Leave empty to keep the current value.", + "Whether the post is pinned to the top of the blog. Leave empty to keep the current value.": "Whether the post is pinned to the top of the blog. Leave empty to keep the current value.", + "Post format. Only formats the active theme supports are accepted. Leave empty to keep the current value.": "Post format. Only formats the active theme supports are accepted. Leave empty to keep the current value.", + "Advanced Custom Fields values as field name and value pairs; field names come from the ACF plugin menu. Only the fields you supply are written. Requires the site to expose ACF over the REST API. Leave empty to keep the current value.": "Advanced Custom Fields values as field name and value pairs; field names come from the ACF plugin menu. Only the fields you supply are written. Requires the site to expose ACF over the REST API. Leave empty to keep the current value.", + "Move Post to Trash": "Move Post to Trash", + "Moves a blog post to the trash, where it can still be restored.": "Moves a blog post to the trash, where it can still be restored.", + "ID of the post to trash, from list_posts.": "ID of the post to trash, from list_posts.", + "Delete Post Permanently": "Delete Post Permanently", + "Permanently deletes a blog post, skipping the trash. This cannot be undone.": "Permanently deletes a blog post, skipping the trash. This cannot be undone.", + "ID of the post to delete, from list_posts.": "ID of the post to delete, from list_posts.", + "List Pages": "List Pages", + "Lists site pages, optionally filtered by keyword, status or parent page.": "Lists site pages, optionally filtered by keyword, status or parent page.", + "Only pages whose title or content contain this text.": "Only pages whose title or content contain this text.", + "Page statuses to include. Defaults to published only. \"Any\" includes every status except trash.": "Page statuses to include. Defaults to published only. \"Any\" includes every status except trash.", + "Parent Page ID": "Parent Page ID", + "Only direct children of this page ID. Use 0 for top-level pages.": "Only direct children of this page ID. Use 0 for top-level pages.", + "Menu Order": "Menu Order", + "Get Page": "Get Page", + "Gets one site page by its ID.": "Gets one site page by its ID.", + "Page ID": "Page ID", + "ID of the page, from list_pages or search_site_content.": "ID of the page, from list_pages or search_site_content.", + "Create Site Page": "Create Site Page", + "Creates a static page. Saved as a draft unless a status is given.": "Creates a static page. Saved as a draft unless a status is given.", + "The page title.": "The page title.", + "The page body as HTML.": "The page body as HTML.", + "Leave empty to save the page as a draft. \"Scheduled (future)\" needs a Publish Date.": "Leave empty to save the page as a draft. \"Scheduled (future)\" needs a Publish Date.", + "Whether the page accepts pingbacks and trackbacks.": "Whether the page accepts pingbacks and trackbacks.", + "ID of the parent page, from list_pages.": "ID of the parent page, from list_pages.", + "Position of the page among its siblings.": "Position of the page among its siblings.", + "Update Page": "Update Page", + "Changes only the fields you supply on an existing page.": "Changes only the fields you supply on an existing page.", + "ID of the page to update, from list_pages.": "ID of the page to update, from list_pages.", + "The page title. Leave empty to keep the current value.": "The page title. Leave empty to keep the current value.", + "The page body as HTML. Leave empty to keep the current value.": "The page body as HTML. Leave empty to keep the current value.", + "New status. Leave empty to keep the current value. To trash the page, use the trash action instead.": "New status. Leave empty to keep the current value. To trash the page, use the trash action instead.", + "Whether the page accepts pingbacks and trackbacks. Leave empty to keep the current value.": "Whether the page accepts pingbacks and trackbacks. Leave empty to keep the current value.", + "ID of the parent page, from list_pages. Leave empty to keep the current value.": "ID of the parent page, from list_pages. Leave empty to keep the current value.", + "Position of the page among its siblings. Leave empty to keep the current value.": "Position of the page among its siblings. Leave empty to keep the current value.", + "Move Page to Trash": "Move Page to Trash", + "Moves a page to the trash, where it can still be restored.": "Moves a page to the trash, where it can still be restored.", + "ID of the page to trash, from list_pages.": "ID of the page to trash, from list_pages.", + "List Categories": "List Categories", + "Lists post categories, optionally filtered by name, parent or post.": "Lists post categories, optionally filtered by name, parent or post.", + "Only categories whose name or slug contain this text.": "Only categories whose name or slug contain this text.", + "Only the category with this exact slug.": "Only the category with this exact slug.", + "Parent Category ID": "Parent Category ID", + "Only direct children of this category ID. Use 0 for top-level categories.": "Only direct children of this category ID. Use 0 for top-level categories.", + "Only categories assigned to this post ID (from list_posts).": "Only categories assigned to this post ID (from list_posts).", + "Hide Empty": "Hide Empty", + "Whether to leave out categories with no published posts. Defaults to No.": "Whether to leave out categories with no published posts. Defaults to No.", + "Create Category": "Create Category", + "Creates a post category, or returns the existing one with the same name.": "Creates a post category, or returns the existing one with the same name.", + "Name": "Name", + "Category name.": "Category name.", + "Description": "Description", + "Category description, used only when a new category is created.": "Category description, used only when a new category is created.", + "URL slug. Leave empty to generate it from the name. Fails if another category already uses it.": "URL slug. Leave empty to generate it from the name. Fails if another category already uses it.", + "ID of the parent category, from list_categories. Leave empty for a top-level category.": "ID of the parent category, from list_categories. Leave empty for a top-level category.", + "Delete Category": "Delete Category", + "Permanently deletes a post category. This cannot be undone.": "Permanently deletes a post category. This cannot be undone.", + "Category ID": "Category ID", + "ID of the category to delete, from list_categories.": "ID of the category to delete, from list_categories.", + "List Tags": "List Tags", + "Lists post tags, optionally filtered by name or post.": "Lists post tags, optionally filtered by name or post.", + "Only tags whose name or slug contain this text.": "Only tags whose name or slug contain this text.", + "Only the tag with this exact slug.": "Only the tag with this exact slug.", + "Only tags assigned to this post ID (from list_posts).": "Only tags assigned to this post ID (from list_posts).", + "Whether to leave out tags with no published posts. Defaults to No.": "Whether to leave out tags with no published posts. Defaults to No.", + "Create Tag": "Create Tag", + "Creates a post tag, or returns the existing one with the same name.": "Creates a post tag, or returns the existing one with the same name.", + "Tag name.": "Tag name.", + "Tag description, used only when a new tag is created.": "Tag description, used only when a new tag is created.", + "URL slug. Leave empty to generate it from the name. Fails if another tag already uses it.": "URL slug. Leave empty to generate it from the name. Fails if another tag already uses it.", + "Delete Tag": "Delete Tag", + "Permanently deletes a post tag. This cannot be undone.": "Permanently deletes a post tag. This cannot be undone.", + "Tag ID": "Tag ID", + "ID of the tag to delete, from list_tags.": "ID of the tag to delete, from list_tags.", + "List Comments": "List Comments", + "Lists comments, optionally filtered by post, status, author or keyword.": "Lists comments, optionally filtered by post, status, author or keyword.", + "Only comments on this post or page ID (from list_posts or list_pages).": "Only comments on this post or page ID (from list_posts or list_pages).", + "Moderation status to list. Defaults to approved. Other statuses need a moderator (Editor or Administrator) connection.": "Moderation status to list. Defaults to approved. Other statuses need a moderator (Editor or Administrator) connection.", + "Approved": "Approved", + "Pending (held for moderation)": "Pending (held for moderation)", + "Spam": "Spam", + "Only comments containing this text.": "Only comments containing this text.", + "Author User ID": "Author User ID", + "Only comments written by this registered user ID (from list_users).": "Only comments written by this registered user ID (from list_users).", + "Parent Comment ID": "Parent Comment ID", + "Only replies to this comment ID. Use 0 for top-level comments.": "Only replies to this comment ID. Use 0 for top-level comments.", + "Sort direction by date. Defaults to newest first.": "Sort direction by date. Defaults to newest first.", + "Create Comment": "Create Comment", + "Adds a comment, or a reply to a comment, on a published post or page.": "Adds a comment, or a reply to a comment, on a published post or page.", + "ID of a published post or page with comments open, from list_posts or list_pages.": "ID of a published post or page with comments open, from list_posts or list_pages.", + "Comment": "Comment", + "Comment text. Basic HTML is allowed.": "Comment text. Basic HTML is allowed.", + "Reply To Comment ID": "Reply To Comment ID", + "ID of the comment to reply to, from list_comments. Leave empty for a top-level comment.": "ID of the comment to reply to, from list_comments. Leave empty for a top-level comment.", + "Moderate Comment": "Moderate Comment", + "Approves, holds, marks as spam, or trashes a comment, or reverses spam or trash.": "Approves, holds, marks as spam, or trashes a comment, or reverses spam or trash.", + "Comment ID": "Comment ID", + "ID of the comment, from list_comments.": "ID of the comment, from list_comments.", + "Action": "Action", + "The moderation action to apply.": "The moderation action to apply.", + "Approve": "Approve", + "Hold for moderation": "Hold for moderation", + "Mark as spam": "Mark as spam", + "Not spam": "Not spam", + "Move to trash": "Move to trash", + "Restore from trash": "Restore from trash", + "Delete Comment Permanently": "Delete Comment Permanently", + "Permanently deletes a comment, skipping the trash. This cannot be undone.": "Permanently deletes a comment, skipping the trash. This cannot be undone.", + "ID of the comment to delete, from list_comments.": "ID of the comment to delete, from list_comments.", + "List Media": "List Media", + "Lists files in the media library, optionally filtered by keyword, type or attached post.": "Lists files in the media library, optionally filtered by keyword, type or attached post.", + "Only files whose title, caption or file name contain this text.": "Only files whose title, caption or file name contain this text.", + "Media Type": "Media Type", + "Only files of this general type.": "Only files of this general type.", + "Text": "Text", + "Application (PDF, documents, archives)": "Application (PDF, documents, archives)", + "MIME Type": "MIME Type", + "Only files with this exact MIME type, e.g. image/png.": "Only files with this exact MIME type, e.g. image/png.", + "Attached Post ID": "Attached Post ID", + "Only files attached to this post or page ID. Use 0 for unattached files.": "Only files attached to this post or page ID. Use 0 for unattached files.", + "Get Media": "Get Media", + "Gets one media library file by its ID.": "Gets one media library file by its ID.", + "Media ID": "Media ID", + "ID of the media item, from list_media or upload_media.": "ID of the media item, from list_media or upload_media.", + "Upload Media": "Upload Media", + "Uploads a file to the media library, with optional title, alt text, caption and description.": "Uploads a file to the media library, with optional title, alt text, caption and description.", + "File": "File", + "The file to upload, as a URL or base64 data. The file name and extension decide its type.": "The file to upload, as a URL or base64 data. The file name and extension decide its type.", + "Media title. Defaults to the file name.": "Media title. Defaults to the file name.", + "Alt Text": "Alt Text", + "Alternative text describing the image for screen readers.": "Alternative text describing the image for screen readers.", + "Caption": "Caption", + "Caption shown with the file.": "Caption shown with the file.", + "Longer description of the file.": "Longer description of the file.", + "Attach To Post ID": "Attach To Post ID", + "ID of the post or page to attach the file to, from list_posts or list_pages.": "ID of the post or page to attach the file to, from list_posts or list_pages.", + "Update Media Details": "Update Media Details", + "Changes the title, alt text, caption, description or attached post of a media file.": "Changes the title, alt text, caption, description or attached post of a media file.", + "New title. Leave empty to keep the current value.": "New title. Leave empty to keep the current value.", + "New alternative text. Leave empty to keep the current value.": "New alternative text. Leave empty to keep the current value.", + "New caption. Leave empty to keep the current value.": "New caption. Leave empty to keep the current value.", + "New description. Leave empty to keep the current value.": "New description. Leave empty to keep the current value.", + "ID of the post or page to attach the file to. Leave empty to keep the current value.": "ID of the post or page to attach the file to. Leave empty to keep the current value.", + "Delete Media": "Delete Media", + "Permanently deletes a media file and its generated sizes. This cannot be undone.": "Permanently deletes a media file and its generated sizes. This cannot be undone.", + "ID of the media item to delete, from list_media.": "ID of the media item to delete, from list_media.", + "Get Current User": "Get Current User", + "Gets the WordPress user this connection signs in as, with roles and capabilities.": "Gets the WordPress user this connection signs in as, with roles and capabilities.", + "List Users": "List Users", + "Lists site users, optionally filtered by name or role.": "Lists site users, optionally filtered by name or role.", + "Only users whose name, slug, email or URL contain this text.": "Only users whose name, slug, email or URL contain this text.", + "Roles": "Roles", + "Only users with any of these role slugs, e.g. administrator, editor, author, contributor, subscriber.": "Only users with any of these role slugs, e.g. administrator, editor, author, contributor, subscriber.", + "Search Site Content": "Search Site Content", + "Searches published posts and pages, or categories and tags, by keyword.": "Searches published posts and pages, or categories and tags, by keyword.", + "Text to search for.": "Text to search for.", + "Type": "Type", + "What to search. Defaults to posts and pages.": "What to search. Defaults to posts and pages.", + "Posts and pages": "Posts and pages", + "Categories and tags": "Categories and tags", + "Post formats": "Post formats", + "Subtype": "Subtype", + "Narrow the search within the chosen Type. \"Post\" and \"Page\" work only with Type \"Posts and pages\"; \"Category\" and \"Tag\" only with Type \"Categories and tags\". Leave empty for all.": "Narrow the search within the chosen Type. \"Post\" and \"Page\" work only with Type \"Posts and pages\"; \"Category\" and \"Tag\" only with Type \"Categories and tags\". Leave empty for all.", + "Category": "Category", + "Tag": "Tag", + "Get Site Settings": "Get Site Settings", + "Gets the site title, tagline, URL, timezone, date format and reading settings.": "Gets the site title, tagline, URL, timezone, date format and reading settings.", + "Short summary of the page. Takes effect only when the theme or site supports page excerpts; WordPress silently ignores it otherwise.": "Short summary of the page. Takes effect only when the theme or site supports page excerpts; WordPress silently ignores it otherwise.", + "Short summary of the page. Takes effect only when the theme or site supports page excerpts; WordPress silently ignores it otherwise. Leave empty to keep the current value.": "Short summary of the page. Takes effect only when the theme or site supports page excerpts; WordPress silently ignores it otherwise. Leave empty to keep the current value." +} diff --git a/packages/pieces/community/wordpress/src/index.ts b/packages/pieces/community/wordpress/src/index.ts index 512bd6881c36..a0afab49a46d 100644 --- a/packages/pieces/community/wordpress/src/index.ts +++ b/packages/pieces/community/wordpress/src/index.ts @@ -17,6 +17,36 @@ import { getWordPressPost } from './lib/actions/get-post.action'; import { wordpressCommon } from './lib/common'; import { wordpressNewPost } from './lib/trigger/new-post.trigger'; import { updateWordPressPost } from './lib/actions/update-post.action'; +import { listPostsAction } from './lib/actions/list-posts.action'; +import { getPostByIdAction } from './lib/actions/get-post-by-id.action'; +import { createBlogPostAction } from './lib/actions/create-blog-post.action'; +import { updateBlogPostAction } from './lib/actions/update-blog-post.action'; +import { trashPostAction } from './lib/actions/trash-post.action'; +import { deletePostPermanentlyAction } from './lib/actions/delete-post-permanently.action'; +import { listPagesAction } from './lib/actions/list-pages.action'; +import { getPageAction } from './lib/actions/get-page.action'; +import { createSitePageAction } from './lib/actions/create-site-page.action'; +import { updatePageAction } from './lib/actions/update-page.action'; +import { trashPageAction } from './lib/actions/trash-page.action'; +import { listCategoriesAction } from './lib/actions/list-categories.action'; +import { createCategoryAction } from './lib/actions/create-category.action'; +import { deleteCategoryAction } from './lib/actions/delete-category.action'; +import { listTagsAction } from './lib/actions/list-tags.action'; +import { createTagAction } from './lib/actions/create-tag.action'; +import { deleteTagAction } from './lib/actions/delete-tag.action'; +import { listCommentsAction } from './lib/actions/list-comments.action'; +import { createCommentAction } from './lib/actions/create-comment.action'; +import { moderateCommentAction } from './lib/actions/moderate-comment.action'; +import { deleteCommentAction } from './lib/actions/delete-comment.action'; +import { listMediaAction } from './lib/actions/list-media.action'; +import { getMediaAction } from './lib/actions/get-media.action'; +import { uploadMediaAction } from './lib/actions/upload-media.action'; +import { updateMediaDetailsAction } from './lib/actions/update-media-details.action'; +import { deleteMediaAction } from './lib/actions/delete-media.action'; +import { getCurrentUserAction } from './lib/actions/get-current-user.action'; +import { listUsersAction } from './lib/actions/list-users.action'; +import { searchSiteContentAction } from './lib/actions/search-site-content.action'; +import { getSiteSettingsAction } from './lib/actions/get-site-settings.action'; const markdownPropertyDescription = ` Connect with a WordPress **application password**: @@ -120,6 +150,36 @@ export const wordpress = createPiece({ createWordPressPage, updateWordPressPost, getWordPressPost, + listPostsAction, + getPostByIdAction, + createBlogPostAction, + updateBlogPostAction, + trashPostAction, + deletePostPermanentlyAction, + listPagesAction, + getPageAction, + createSitePageAction, + updatePageAction, + trashPageAction, + listCategoriesAction, + createCategoryAction, + deleteCategoryAction, + listTagsAction, + createTagAction, + deleteTagAction, + listCommentsAction, + createCommentAction, + moderateCommentAction, + deleteCommentAction, + listMediaAction, + getMediaAction, + uploadMediaAction, + updateMediaDetailsAction, + deleteMediaAction, + getCurrentUserAction, + listUsersAction, + searchSiteContentAction, + getSiteSettingsAction, createCustomApiCallAction({ baseUrl: (auth) =>auth ? (auth.props.website_url).trim() + '/wp-json/wp/v2' : '', diff --git a/packages/pieces/community/wordpress/src/lib/actions/create-blog-post.action.ts b/packages/pieces/community/wordpress/src/lib/actions/create-blog-post.action.ts new file mode 100644 index 000000000000..d803e7fd11cf --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/create-blog-post.action.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { postEditOutputSchema } from '../output-schemas'; + +export const createBlogPostAction = createAction({ + auth: wordpressAuth, + name: 'create_blog_post', + classification: 'WRITE', + displayName: 'Create Blog Post', + description: 'Creates a blog post. Saved as a draft unless a status is given.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a WordPress blog post from HTML content, optionally with categories, tags and a featured image. With no status the post is saved as a draft; set status "publish" to publish now, or "future" with a Publish Date to schedule (a past date publishes immediately). Custom ACF fields can be set by field name when the site exposes ACF over the REST API. Use create_site_page for static pages. Each call creates a new post, so retries duplicate.', + idempotent: false, + }, + outputSchema: postEditOutputSchema, + props: { + ...wordpressContent.contentProps({ kind: 'post', mode: 'create' }), + ...wordpressContent.postOnlyProps({ mode: 'create' }), + }, + async run({ auth, propsValue }) { + wordpressContent.requireDateForFuture({ values: propsValue }); + const body = wordpressContent.buildContentBody({ kind: 'post', values: propsValue }); + const response = await wordpressApi.request({ + auth, + method: HttpMethod.POST, + path: '/posts', + body, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/create-category.action.ts b/packages/pieces/community/wordpress/src/lib/actions/create-category.action.ts new file mode 100644 index 000000000000..c13f12b15099 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/create-category.action.ts @@ -0,0 +1,58 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wordpressAuth } from '../..'; +import { wordpressApi } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { createCategoryOutputSchema } from '../output-schemas'; + +export const createCategoryAction = createAction({ + auth: wordpressAuth, + name: 'create_category', + classification: 'WRITE', + displayName: 'Create Category', + description: 'Creates a post category, or returns the existing one with the same name.', + audience: 'ai', + aiMetadata: { + description: + 'Gets or creates a WordPress post category by name. If a category with that name already exists under the same parent it is returned unchanged with created=false (its description is not updated); otherwise a new one is created with created=true. Use the returned ID in create_blog_post. Safe to retry.', + idempotent: true, + }, + outputSchema: createCategoryOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Name', + description: 'Category name.', + required: true, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'Category description, used only when a new category is created.', + required: false, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: 'URL slug. Leave empty to generate it from the name. Fails if another category already uses it.', + required: false, + }), + parent: Property.Number({ + displayName: 'Parent Category ID', + description: 'ID of the parent category, from list_categories. Leave empty for a top-level category.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const body: Record = { name: propsValue.name }; + if (wordpressContent.isFilledText(propsValue.description)) { + body['description'] = propsValue.description; + } + if (wordpressContent.isFilledText(propsValue.slug)) { + body['slug'] = propsValue.slug; + } + if (wordpressContent.isSetNumber(propsValue.parent)) { + body['parent'] = wordpressContent.requireWholeNumber({ + value: propsValue.parent, + propName: 'Parent Category ID', + }); + } + return wordpressApi.getOrCreateTerm({ auth, taxonomy: 'categories', body }); + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/create-comment.action.ts b/packages/pieces/community/wordpress/src/lib/actions/create-comment.action.ts new file mode 100644 index 000000000000..4e9780894dc7 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/create-comment.action.ts @@ -0,0 +1,57 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { commentEditOutputSchema } from '../output-schemas'; + +export const createCommentAction = createAction({ + auth: wordpressAuth, + name: 'create_comment', + classification: 'WRITE', + displayName: 'Create Comment', + description: 'Adds a comment, or a reply to a comment, on a published post or page.', + audience: 'ai', + aiMetadata: { + description: + 'Posts a comment as the connected WordPress user on a published post or page, or replies to an existing comment. Fails if the post is not published or its comments are closed. Each call adds a new comment, so retries duplicate (WordPress rejects an exact duplicate).', + idempotent: false, + }, + outputSchema: commentEditOutputSchema, + props: { + post: Property.Number({ + displayName: 'Post ID', + description: 'ID of a published post or page with comments open, from list_posts or list_pages.', + required: true, + }), + content: Property.LongText({ + displayName: 'Comment', + description: 'Comment text. Basic HTML is allowed.', + required: true, + }), + parent: Property.Number({ + displayName: 'Reply To Comment ID', + description: 'ID of the comment to reply to, from list_comments. Leave empty for a top-level comment.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const body: Record = { + post: wordpressContent.requireWholeNumber({ value: propsValue.post, propName: 'Post ID' }), + content: propsValue.content, + }; + if (wordpressContent.isSetNumber(propsValue.parent)) { + body['parent'] = wordpressContent.requireWholeNumber({ + value: propsValue.parent, + propName: 'Reply To Comment ID', + }); + } + const response = await wordpressApi.request({ + auth, + method: HttpMethod.POST, + path: '/comments', + body, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/create-page.action.ts b/packages/pieces/community/wordpress/src/lib/actions/create-page.action.ts index c8a69adb6bef..2c1074439d67 100644 --- a/packages/pieces/community/wordpress/src/lib/actions/create-page.action.ts +++ b/packages/pieces/community/wordpress/src/lib/actions/create-page.action.ts @@ -14,7 +14,7 @@ export const createWordPressPage = createAction({ name: 'create_page', classification: 'WRITE', description: 'Add a new page to your WordPress site', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Publishes a new static page (not a blog post) on a WordPress site via the REST API, with optional status, slug, excerpt, and comment settings. Choose this for standalone pages like About or Contact rather than dated posts. Requires a title and HTML content; not idempotent — each call creates a separate page.', idempotent: false }, displayName: 'Create Page', outputSchema: createPageActionOutputSchema, diff --git a/packages/pieces/community/wordpress/src/lib/actions/create-post.action.ts b/packages/pieces/community/wordpress/src/lib/actions/create-post.action.ts index 862fab239f14..e15ff1a59c66 100644 --- a/packages/pieces/community/wordpress/src/lib/actions/create-post.action.ts +++ b/packages/pieces/community/wordpress/src/lib/actions/create-post.action.ts @@ -18,7 +18,7 @@ export const createWordPressPost = createAction({ name: 'create_post', classification: 'WRITE', description: 'Add a new post to your WordPress site', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Publishes a new blog post on a WordPress site via the REST API, with optional status (draft/publish/etc.), categories, tags, excerpt, featured image, and custom ACF fields. Choose this to add fresh content; for editing an existing post use Update Post. Requires a title and HTML content; not idempotent — each call creates a separate post.', idempotent: false }, displayName: 'Create Post', outputSchema: createPostActionOutputSchema, diff --git a/packages/pieces/community/wordpress/src/lib/actions/create-site-page.action.ts b/packages/pieces/community/wordpress/src/lib/actions/create-site-page.action.ts new file mode 100644 index 000000000000..208fee493a5a --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/create-site-page.action.ts @@ -0,0 +1,36 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { pageEditOutputSchema } from '../output-schemas'; + +export const createSitePageAction = createAction({ + auth: wordpressAuth, + name: 'create_site_page', + classification: 'WRITE', + displayName: 'Create Site Page', + description: 'Creates a static page. Saved as a draft unless a status is given.', + audience: 'ai', + aiMetadata: { + description: + 'Creates a WordPress static page (About, Contact and similar) from HTML content, optionally under a parent page. With no status the page is saved as a draft; set status "publish" to publish now. Use create_blog_post for dated blog posts. Each call creates a new page, so retries duplicate.', + idempotent: false, + }, + outputSchema: pageEditOutputSchema, + props: { + ...wordpressContent.contentProps({ kind: 'page', mode: 'create' }), + ...wordpressContent.pageOnlyProps({ mode: 'create' }), + }, + async run({ auth, propsValue }) { + wordpressContent.requireDateForFuture({ values: propsValue }); + const body = wordpressContent.buildContentBody({ kind: 'page', values: propsValue }); + const response = await wordpressApi.request({ + auth, + method: HttpMethod.POST, + path: '/pages', + body, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/create-tag.action.ts b/packages/pieces/community/wordpress/src/lib/actions/create-tag.action.ts new file mode 100644 index 000000000000..836e2ba02346 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/create-tag.action.ts @@ -0,0 +1,47 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wordpressAuth } from '../..'; +import { wordpressApi } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { createTagOutputSchema } from '../output-schemas'; + +export const createTagAction = createAction({ + auth: wordpressAuth, + name: 'create_tag', + classification: 'WRITE', + displayName: 'Create Tag', + description: 'Creates a post tag, or returns the existing one with the same name.', + audience: 'ai', + aiMetadata: { + description: + 'Gets or creates a WordPress post tag by name. If a tag with that name already exists it is returned unchanged with created=false; otherwise a new one is created with created=true. Use the returned ID in create_blog_post. Safe to retry.', + idempotent: true, + }, + outputSchema: createTagOutputSchema, + props: { + name: Property.ShortText({ + displayName: 'Name', + description: 'Tag name.', + required: true, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'Tag description, used only when a new tag is created.', + required: false, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: 'URL slug. Leave empty to generate it from the name. Fails if another tag already uses it.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const body: Record = { name: propsValue.name }; + if (wordpressContent.isFilledText(propsValue.description)) { + body['description'] = propsValue.description; + } + if (wordpressContent.isFilledText(propsValue.slug)) { + body['slug'] = propsValue.slug; + } + return wordpressApi.getOrCreateTerm({ auth, taxonomy: 'tags', body }); + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/delete-category.action.ts b/packages/pieces/community/wordpress/src/lib/actions/delete-category.action.ts new file mode 100644 index 000000000000..46a690afcf88 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/delete-category.action.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wordpressAuth } from '../..'; +import { wordpressApi } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { deleteCategoryOutputSchema } from '../output-schemas'; + +export const deleteCategoryAction = createAction({ + auth: wordpressAuth, + name: 'delete_category', + classification: 'DESTRUCTIVE', + displayName: 'Delete Category', + description: 'Permanently deletes a post category. This cannot be undone.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a WordPress category; categories have no trash. Posts in it keep their other categories, and posts left with none move to the default category. The site\'s default category (usually "Uncategorized") cannot be deleted and returns a 403. A repeat call fails because the category no longer exists.', + idempotent: false, + }, + outputSchema: deleteCategoryOutputSchema, + props: { + category_id: Property.Number({ + displayName: 'Category ID', + description: 'ID of the category to delete, from list_categories.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const id = wordpressContent.requireWholeNumber({ value: propsValue.category_id, propName: 'Category ID' }); + return wordpressApi.forceDelete({ auth, path: `/categories/${id}` }); + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/delete-comment.action.ts b/packages/pieces/community/wordpress/src/lib/actions/delete-comment.action.ts new file mode 100644 index 000000000000..5b6ce7658ebb --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/delete-comment.action.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wordpressAuth } from '../..'; +import { wordpressApi } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { deleteCommentOutputSchema } from '../output-schemas'; + +export const deleteCommentAction = createAction({ + auth: wordpressAuth, + name: 'delete_comment', + classification: 'DESTRUCTIVE', + displayName: 'Delete Comment Permanently', + description: 'Permanently deletes a comment, skipping the trash. This cannot be undone.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a WordPress comment, bypassing the trash; it cannot be restored. Use moderate_comment with "trash" or "spam" when the comment may need to come back. A repeat call fails because the comment no longer exists.', + idempotent: false, + }, + outputSchema: deleteCommentOutputSchema, + props: { + comment_id: Property.Number({ + displayName: 'Comment ID', + description: 'ID of the comment to delete, from list_comments.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const id = wordpressContent.requireWholeNumber({ value: propsValue.comment_id, propName: 'Comment ID' }); + return wordpressApi.forceDelete({ auth, path: `/comments/${id}` }); + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/delete-media.action.ts b/packages/pieces/community/wordpress/src/lib/actions/delete-media.action.ts new file mode 100644 index 000000000000..bb4913bd184e --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/delete-media.action.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wordpressAuth } from '../..'; +import { wordpressApi } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { deleteMediaOutputSchema } from '../output-schemas'; + +export const deleteMediaAction = createAction({ + auth: wordpressAuth, + name: 'delete_media', + classification: 'DESTRUCTIVE', + displayName: 'Delete Media', + description: 'Permanently deletes a media file and its generated sizes. This cannot be undone.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a WordPress media item and removes its files from the server; media has no trash by default, so there is no recoverable alternative. Posts that used it as a featured image or embedded it will show a missing image. A repeat call fails because the item no longer exists.', + idempotent: false, + }, + outputSchema: deleteMediaOutputSchema, + props: { + media_id: Property.Number({ + displayName: 'Media ID', + description: 'ID of the media item to delete, from list_media.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const id = wordpressContent.requireWholeNumber({ value: propsValue.media_id, propName: 'Media ID' }); + return wordpressApi.forceDelete({ auth, path: `/media/${id}` }); + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/delete-post-permanently.action.ts b/packages/pieces/community/wordpress/src/lib/actions/delete-post-permanently.action.ts new file mode 100644 index 000000000000..45681d4c01af --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/delete-post-permanently.action.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wordpressAuth } from '../..'; +import { wordpressApi } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { deletePostOutputSchema } from '../output-schemas'; + +export const deletePostPermanentlyAction = createAction({ + auth: wordpressAuth, + name: 'delete_post_permanently', + classification: 'DESTRUCTIVE', + displayName: 'Delete Post Permanently', + description: 'Permanently deletes a blog post, skipping the trash. This cannot be undone.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a WordPress blog post in any status, bypassing the trash; it cannot be restored. Use trash_post instead when the post may need to come back. Returns the deleted post; a repeat call fails because the post no longer exists.', + idempotent: false, + }, + outputSchema: deletePostOutputSchema, + props: { + post_id: Property.Number({ + displayName: 'Post ID', + description: 'ID of the post to delete, from list_posts.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const id = wordpressContent.requireWholeNumber({ value: propsValue.post_id, propName: 'Post ID' }); + return wordpressApi.forceDelete({ auth, path: `/posts/${id}` }); + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/delete-tag.action.ts b/packages/pieces/community/wordpress/src/lib/actions/delete-tag.action.ts new file mode 100644 index 000000000000..a2d03e3d81c9 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/delete-tag.action.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wordpressAuth } from '../..'; +import { wordpressApi } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { deleteTagOutputSchema } from '../output-schemas'; + +export const deleteTagAction = createAction({ + auth: wordpressAuth, + name: 'delete_tag', + classification: 'DESTRUCTIVE', + displayName: 'Delete Tag', + description: 'Permanently deletes a post tag. This cannot be undone.', + audience: 'ai', + aiMetadata: { + description: + 'Permanently deletes a WordPress tag and removes it from every post; tags have no trash. To take a tag off one post only, use update_blog_post with the new tag list. A repeat call fails because the tag no longer exists.', + idempotent: false, + }, + outputSchema: deleteTagOutputSchema, + props: { + tag_id: Property.Number({ + displayName: 'Tag ID', + description: 'ID of the tag to delete, from list_tags.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const id = wordpressContent.requireWholeNumber({ value: propsValue.tag_id, propName: 'Tag ID' }); + return wordpressApi.forceDelete({ auth, path: `/tags/${id}` }); + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/get-current-user.action.ts b/packages/pieces/community/wordpress/src/lib/actions/get-current-user.action.ts new file mode 100644 index 000000000000..732dd59f750b --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/get-current-user.action.ts @@ -0,0 +1,30 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { getCurrentUserOutputSchema } from '../output-schemas'; + +export const getCurrentUserAction = createAction({ + auth: wordpressAuth, + name: 'get_current_user', + classification: 'READ', + displayName: 'Get Current User', + description: 'Gets the WordPress user this connection signs in as, with roles and capabilities.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the WordPress user the connection authenticates as: ID, name, email, roles and capabilities. Use it to check that the connection works and whether the role allows a write (for example edit_pages, moderate_comments, manage_categories) before attempting it. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: getCurrentUserOutputSchema, + props: {}, + async run({ auth }) { + const response = await wordpressApi.request({ + auth, + method: HttpMethod.GET, + path: '/users/me', + queryParams: { context: 'edit' }, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/get-media.action.ts b/packages/pieces/community/wordpress/src/lib/actions/get-media.action.ts new file mode 100644 index 000000000000..68715486eed6 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/get-media.action.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { getMediaOutputSchema } from '../output-schemas'; + +export const getMediaAction = createAction({ + auth: wordpressAuth, + name: 'get_media', + classification: 'READ', + displayName: 'Get Media', + description: 'Gets one media library file by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one WordPress media library item by ID, with its file URL, MIME type, alt text, caption and image sizes. Get the ID from list_media or upload_media. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: getMediaOutputSchema, + props: { + media_id: Property.Number({ + displayName: 'Media ID', + description: 'ID of the media item, from list_media or upload_media.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const id = wordpressContent.requireWholeNumber({ value: propsValue.media_id, propName: 'Media ID' }); + const response = await wordpressApi.request({ + auth, + method: HttpMethod.GET, + path: `/media/${id}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/get-page.action.ts b/packages/pieces/community/wordpress/src/lib/actions/get-page.action.ts new file mode 100644 index 000000000000..210c6c84a1dd --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/get-page.action.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { getPageOutputSchema } from '../output-schemas'; + +export const getPageAction = createAction({ + auth: wordpressAuth, + name: 'get_page', + classification: 'READ', + displayName: 'Get Page', + description: 'Gets one site page by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one WordPress static page by ID, including drafts the connected user can edit. Get the ID from list_pages or search_site_content; use get_post_by_id for blog posts. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: getPageOutputSchema, + props: { + page_id: Property.Number({ + displayName: 'Page ID', + description: 'ID of the page, from list_pages or search_site_content.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const pageId = wordpressContent.requireWholeNumber({ value: propsValue.page_id, propName: 'Page ID' }); + const response = await wordpressApi.request({ + auth, + method: HttpMethod.GET, + path: `/pages/${pageId}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/get-post-by-id.action.ts b/packages/pieces/community/wordpress/src/lib/actions/get-post-by-id.action.ts new file mode 100644 index 000000000000..06794921a2ee --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/get-post-by-id.action.ts @@ -0,0 +1,37 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { getPostByIdOutputSchema } from '../output-schemas'; + +export const getPostByIdAction = createAction({ + auth: wordpressAuth, + name: 'get_post_by_id', + classification: 'READ', + displayName: 'Get Post by ID', + description: 'Gets one blog post by its ID.', + audience: 'ai', + aiMetadata: { + description: + 'Returns one WordPress blog post by ID, including drafts and private posts the connected user can edit. Get the ID from list_posts or search_site_content; use get_page for pages. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: getPostByIdOutputSchema, + props: { + post_id: Property.Number({ + displayName: 'Post ID', + description: 'ID of the post, from list_posts or search_site_content.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const postId = wordpressContent.requireWholeNumber({ value: propsValue.post_id, propName: 'Post ID' }); + const response = await wordpressApi.request({ + auth, + method: HttpMethod.GET, + path: `/posts/${postId}`, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/get-post.action.ts b/packages/pieces/community/wordpress/src/lib/actions/get-post.action.ts index 6afe953a9345..dfffef9510f8 100644 --- a/packages/pieces/community/wordpress/src/lib/actions/get-post.action.ts +++ b/packages/pieces/community/wordpress/src/lib/actions/get-post.action.ts @@ -18,7 +18,7 @@ export const getWordPressPost = createAction({ name: 'get_post', classification: 'READ', description: 'Fetch one post by its ID', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Fetches a single WordPress post by its numeric post ID via the REST API. Use it to read the current content, status, or metadata of a known post before acting on it. Requires the post ID; read-only and idempotent.', idempotent: true }, displayName: 'Get Post Details', outputSchema: getPostActionOutputSchema, diff --git a/packages/pieces/community/wordpress/src/lib/actions/get-site-settings.action.ts b/packages/pieces/community/wordpress/src/lib/actions/get-site-settings.action.ts new file mode 100644 index 000000000000..9600ac0ad697 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/get-site-settings.action.ts @@ -0,0 +1,29 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { getSiteSettingsOutputSchema } from '../output-schemas'; + +export const getSiteSettingsAction = createAction({ + auth: wordpressAuth, + name: 'get_site_settings', + classification: 'READ', + displayName: 'Get Site Settings', + description: 'Gets the site title, tagline, URL, timezone, date format and reading settings.', + audience: 'ai', + aiMetadata: { + description: + 'Returns the WordPress site settings: title, tagline, URL, admin email, timezone, date and time formats, default category and post format, and front-page setup. Use it to learn the site timezone before scheduling a post, or the default category ID. Needs an Administrator connection. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: getSiteSettingsOutputSchema, + props: {}, + async run({ auth }) { + const response = await wordpressApi.request({ + auth, + method: HttpMethod.GET, + path: '/settings', + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/list-categories.action.ts b/packages/pieces/community/wordpress/src/lib/actions/list-categories.action.ts new file mode 100644 index 000000000000..d012a7bbdbd2 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/list-categories.action.ts @@ -0,0 +1,82 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { QueryParams } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { listCategoriesOutputSchema } from '../output-schemas'; + +export const listCategoriesAction = createAction({ + auth: wordpressAuth, + name: 'list_categories', + classification: 'SEARCH', + displayName: 'List Categories', + description: 'Lists post categories, optionally filtered by name, parent or post.', + audience: 'ai', + aiMetadata: { + description: + 'Lists WordPress post categories with their IDs, names, slugs, parents and post counts. Use it to turn a category name into the ID that create_blog_post, update_blog_post and list_posts need; use create_category to add a missing one. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listCategoriesOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Only categories whose name or slug contain this text.', + required: false, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: 'Only the category with this exact slug.', + required: false, + }), + parent: Property.Number({ + displayName: 'Parent Category ID', + description: 'Only direct children of this category ID. Use 0 for top-level categories.', + required: false, + }), + post: Property.Number({ + displayName: 'Post ID', + description: 'Only categories assigned to this post ID (from list_posts).', + required: false, + }), + hide_empty: Property.StaticDropdown({ + displayName: 'Hide Empty', + description: 'Whether to leave out categories with no published posts. Defaults to No.', + required: false, + options: wordpressContent.trueFalseOptions, + }), + ...wordpressContent.pagingProps(), + }, + async run({ auth, propsValue }) { + const queryParams: QueryParams = wordpressContent.buildPaging({ + perPage: propsValue.per_page, + page: propsValue.page, + }); + if (wordpressContent.isFilledText(propsValue.search)) { + queryParams['search'] = propsValue.search; + } + if (wordpressContent.isFilledText(propsValue.slug)) { + queryParams['slug'] = propsValue.slug; + } + if (wordpressContent.isSetNumber(propsValue.parent)) { + queryParams['parent'] = String( + wordpressContent.requireWholeNumber({ value: propsValue.parent, propName: 'Parent Category ID' }) + ); + } + if (wordpressContent.isSetNumber(propsValue.post)) { + queryParams['post'] = String( + wordpressContent.requireWholeNumber({ value: propsValue.post, propName: 'Post ID' }) + ); + } + if (wordpressContent.isFilledText(propsValue.hide_empty)) { + queryParams['hide_empty'] = propsValue.hide_empty; + } + const result = await wordpressApi.list({ auth, path: '/categories', queryParams }); + return { + categories: result.items, + count: result.items.length, + total: result.total, + total_pages: result.total_pages, + }; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/list-comments.action.ts b/packages/pieces/community/wordpress/src/lib/actions/list-comments.action.ts new file mode 100644 index 000000000000..e958d2dfdbfd --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/list-comments.action.ts @@ -0,0 +1,101 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { QueryParams } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { listCommentsOutputSchema } from '../output-schemas'; + +export const listCommentsAction = createAction({ + auth: wordpressAuth, + name: 'list_comments', + classification: 'SEARCH', + displayName: 'List Comments', + description: 'Lists comments, optionally filtered by post, status, author or keyword.', + audience: 'ai', + aiMetadata: { + description: + 'Lists WordPress comments filtered by post, moderation status, author or keyword. Defaults to approved comments; pick status "hold" to find comments awaiting moderation for moderate_comment. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listCommentsOutputSchema, + props: { + post: Property.Number({ + displayName: 'Post ID', + description: 'Only comments on this post or page ID (from list_posts or list_pages).', + required: false, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: 'Moderation status to list. Defaults to approved. Other statuses need a moderator (Editor or Administrator) connection.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Approved', value: 'approve' }, + { label: 'Pending (held for moderation)', value: 'hold' }, + { label: 'Spam', value: 'spam' }, + { label: 'Trash', value: 'trash' }, + ], + }, + }), + search: Property.ShortText({ + displayName: 'Search', + description: 'Only comments containing this text.', + required: false, + }), + author: Property.Number({ + displayName: 'Author User ID', + description: 'Only comments written by this registered user ID (from list_users).', + required: false, + }), + parent: Property.Number({ + displayName: 'Parent Comment ID', + description: 'Only replies to this comment ID. Use 0 for top-level comments.', + required: false, + }), + order: Property.StaticDropdown({ + displayName: 'Order', + description: 'Sort direction by date. Defaults to newest first.', + required: false, + options: wordpressContent.orderOptions, + }), + ...wordpressContent.pagingProps(), + }, + async run({ auth, propsValue }) { + const queryParams: QueryParams = wordpressContent.buildPaging({ + perPage: propsValue.per_page, + page: propsValue.page, + }); + if (wordpressContent.isSetNumber(propsValue.post)) { + queryParams['post'] = String( + wordpressContent.requireWholeNumber({ value: propsValue.post, propName: 'Post ID' }) + ); + } + if (wordpressContent.isFilledText(propsValue.status)) { + queryParams['status'] = propsValue.status; + } + if (wordpressContent.isFilledText(propsValue.search)) { + queryParams['search'] = propsValue.search; + } + if (wordpressContent.isSetNumber(propsValue.author)) { + queryParams['author'] = String( + wordpressContent.requireWholeNumber({ value: propsValue.author, propName: 'Author User ID' }) + ); + } + if (wordpressContent.isSetNumber(propsValue.parent)) { + queryParams['parent'] = String( + wordpressContent.requireWholeNumber({ value: propsValue.parent, propName: 'Parent Comment ID' }) + ); + } + if (wordpressContent.isFilledText(propsValue.order)) { + queryParams['order'] = propsValue.order; + } + const result = await wordpressApi.list({ auth, path: '/comments', queryParams }); + return { + comments: result.items, + count: result.items.length, + total: result.total, + total_pages: result.total_pages, + }; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/list-media.action.ts b/packages/pieces/community/wordpress/src/lib/actions/list-media.action.ts new file mode 100644 index 000000000000..54b71457321e --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/list-media.action.ts @@ -0,0 +1,81 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { QueryParams } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { listMediaOutputSchema } from '../output-schemas'; + +export const listMediaAction = createAction({ + auth: wordpressAuth, + name: 'list_media', + classification: 'SEARCH', + displayName: 'List Media', + description: 'Lists files in the media library, optionally filtered by keyword, type or attached post.', + audience: 'ai', + aiMetadata: { + description: + 'Lists files in the WordPress media library with their IDs, URLs, MIME types and sizes, filtered by keyword, media type, MIME type or the post they are attached to. Use it to find a media ID for featured_media in create_blog_post, or for get_media and update_media_details. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listMediaOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Only files whose title, caption or file name contain this text.', + required: false, + }), + media_type: Property.StaticDropdown({ + displayName: 'Media Type', + description: 'Only files of this general type.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Image', value: 'image' }, + { label: 'Video', value: 'video' }, + { label: 'Audio', value: 'audio' }, + { label: 'Text', value: 'text' }, + { label: 'Application (PDF, documents, archives)', value: 'application' }, + ], + }, + }), + mime_type: Property.ShortText({ + displayName: 'MIME Type', + description: 'Only files with this exact MIME type, e.g. image/png.', + required: false, + }), + parent: Property.Number({ + displayName: 'Attached Post ID', + description: 'Only files attached to this post or page ID. Use 0 for unattached files.', + required: false, + }), + ...wordpressContent.pagingProps(), + }, + async run({ auth, propsValue }) { + const queryParams: QueryParams = wordpressContent.buildPaging({ + perPage: propsValue.per_page, + page: propsValue.page, + }); + if (wordpressContent.isFilledText(propsValue.search)) { + queryParams['search'] = propsValue.search; + } + if (wordpressContent.isFilledText(propsValue.media_type)) { + queryParams['media_type'] = propsValue.media_type; + } + if (wordpressContent.isFilledText(propsValue.mime_type)) { + queryParams['mime_type'] = propsValue.mime_type; + } + if (wordpressContent.isSetNumber(propsValue.parent)) { + queryParams['parent'] = String( + wordpressContent.requireWholeNumber({ value: propsValue.parent, propName: 'Attached Post ID' }) + ); + } + const result = await wordpressApi.list({ auth, path: '/media', queryParams }); + return { + media: result.items, + count: result.items.length, + total: result.total, + total_pages: result.total_pages, + }; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/list-pages.action.ts b/packages/pieces/community/wordpress/src/lib/actions/list-pages.action.ts new file mode 100644 index 000000000000..5a48e124d71c --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/list-pages.action.ts @@ -0,0 +1,107 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { QueryParams } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { listPagesOutputSchema } from '../output-schemas'; + +export const listPagesAction = createAction({ + auth: wordpressAuth, + name: 'list_pages', + classification: 'SEARCH', + displayName: 'List Pages', + description: 'Lists site pages, optionally filtered by keyword, status or parent page.', + audience: 'ai', + aiMetadata: { + description: + 'Lists WordPress static pages with full page fields, filtered by keyword, status or parent page. Use it to find page IDs for get_page, update_page or trash_page; use list_posts for blog posts. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listPagesOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Only pages whose title or content contain this text.', + required: false, + }), + status: Property.StaticMultiSelectDropdown({ + displayName: 'Status', + description: 'Page statuses to include. Defaults to published only. "Any" includes every status except trash.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Published', value: 'publish' }, + { label: 'Draft', value: 'draft' }, + { label: 'Pending Review', value: 'pending' }, + { label: 'Private', value: 'private' }, + { label: 'Scheduled', value: 'future' }, + { label: 'Trash', value: 'trash' }, + { label: 'Any', value: 'any' }, + ], + }, + }), + parent: Property.Number({ + displayName: 'Parent Page ID', + description: 'Only direct children of this page ID. Use 0 for top-level pages.', + required: false, + }), + orderby: Property.StaticDropdown({ + displayName: 'Order By', + description: 'Sort field. Defaults to date. "Relevance" needs Search.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Date', value: 'date' }, + { label: 'Modified Date', value: 'modified' }, + { label: 'Title', value: 'title' }, + { label: 'ID', value: 'id' }, + { label: 'Slug', value: 'slug' }, + { label: 'Menu Order', value: 'menu_order' }, + { label: 'Relevance', value: 'relevance' }, + ], + }, + }), + order: Property.StaticDropdown({ + displayName: 'Order', + description: 'Sort direction. Defaults to descending.', + required: false, + options: wordpressContent.orderOptions, + }), + ...wordpressContent.pagingProps(), + }, + async run({ auth, propsValue }) { + const queryParams: QueryParams = wordpressContent.buildPaging({ + perPage: propsValue.per_page, + page: propsValue.page, + }); + if (wordpressContent.isFilledText(propsValue.search)) { + queryParams['search'] = propsValue.search; + } + if (propsValue.orderby === 'relevance' && queryParams['search'] === undefined) { + throw new Error('Order By "Relevance" needs a Search value.'); + } + if (propsValue.status && propsValue.status.length > 0) { + queryParams['status'] = propsValue.status.join(','); + } + if (wordpressContent.isSetNumber(propsValue.parent)) { + queryParams['parent'] = String( + wordpressContent.requireWholeNumber({ value: propsValue.parent, propName: 'Parent Page ID' }) + ); + } + if (wordpressContent.isFilledText(propsValue.orderby)) { + queryParams['orderby'] = propsValue.orderby; + } + if (wordpressContent.isFilledText(propsValue.order)) { + queryParams['order'] = propsValue.order; + } + const result = await wordpressApi.list({ auth, path: '/pages', queryParams }); + return { + pages: result.items, + count: result.items.length, + total: result.total, + total_pages: result.total_pages, + }; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/list-posts.action.ts b/packages/pieces/community/wordpress/src/lib/actions/list-posts.action.ts new file mode 100644 index 000000000000..f2fa8a26737d --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/list-posts.action.ts @@ -0,0 +1,140 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { QueryParams } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { listPostsOutputSchema } from '../output-schemas'; + +export const listPostsAction = createAction({ + auth: wordpressAuth, + name: 'list_posts', + classification: 'SEARCH', + displayName: 'List Posts', + description: 'Lists blog posts, optionally filtered by keyword, status, category, tag, author or date.', + audience: 'ai', + aiMetadata: { + description: + 'Lists WordPress blog posts with full post fields, filtered by keyword, status, category IDs, tag IDs, author or date range. Use it to find post IDs for get_post_by_id, update_blog_post or trash_post; use search_site_content for a quick keyword lookup across posts and pages together. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listPostsOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Only posts whose title, content or excerpt contain this text.', + required: false, + }), + status: Property.StaticMultiSelectDropdown({ + displayName: 'Status', + description: 'Post statuses to include. Defaults to published only. "Any" includes every status except trash.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Published', value: 'publish' }, + { label: 'Draft', value: 'draft' }, + { label: 'Pending Review', value: 'pending' }, + { label: 'Private', value: 'private' }, + { label: 'Scheduled', value: 'future' }, + { label: 'Trash', value: 'trash' }, + { label: 'Any', value: 'any' }, + ], + }, + }), + categories: Property.Array({ + displayName: 'Category IDs', + description: 'Only posts in any of these category IDs (from list_categories).', + required: false, + }), + tags: Property.Array({ + displayName: 'Tag IDs', + description: 'Only posts with any of these tag IDs (from list_tags).', + required: false, + }), + author: Property.Number({ + displayName: 'Author ID', + description: 'Only posts by this user ID (from list_users).', + required: false, + }), + after: Property.ShortText({ + displayName: 'Published After', + description: 'Only posts published after this ISO 8601 date, e.g. 2026-01-31T00:00:00.', + required: false, + }), + before: Property.ShortText({ + displayName: 'Published Before', + description: 'Only posts published before this ISO 8601 date.', + required: false, + }), + orderby: Property.StaticDropdown({ + displayName: 'Order By', + description: 'Sort field. Defaults to date. "Relevance" needs Search.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Date', value: 'date' }, + { label: 'Modified Date', value: 'modified' }, + { label: 'Title', value: 'title' }, + { label: 'ID', value: 'id' }, + { label: 'Slug', value: 'slug' }, + { label: 'Relevance', value: 'relevance' }, + ], + }, + }), + order: Property.StaticDropdown({ + displayName: 'Order', + description: 'Sort direction. Defaults to descending.', + required: false, + options: wordpressContent.orderOptions, + }), + ...wordpressContent.pagingProps(), + }, + async run({ auth, propsValue }) { + const queryParams: QueryParams = wordpressContent.buildPaging({ + perPage: propsValue.per_page, + page: propsValue.page, + }); + if (wordpressContent.isFilledText(propsValue.search)) { + queryParams['search'] = propsValue.search; + } + if (propsValue.orderby === 'relevance' && queryParams['search'] === undefined) { + throw new Error('Order By "Relevance" needs a Search value.'); + } + if (propsValue.status && propsValue.status.length > 0) { + queryParams['status'] = propsValue.status.join(','); + } + const categories = wordpressContent.parseIdList({ values: propsValue.categories, propName: 'Category IDs' }); + if (categories) { + queryParams['categories'] = categories.join(','); + } + const tags = wordpressContent.parseIdList({ values: propsValue.tags, propName: 'Tag IDs' }); + if (tags) { + queryParams['tags'] = tags.join(','); + } + if (wordpressContent.isSetNumber(propsValue.author)) { + queryParams['author'] = String( + wordpressContent.requireWholeNumber({ value: propsValue.author, propName: 'Author ID' }) + ); + } + if (wordpressContent.isFilledText(propsValue.after)) { + queryParams['after'] = propsValue.after; + } + if (wordpressContent.isFilledText(propsValue.before)) { + queryParams['before'] = propsValue.before; + } + if (wordpressContent.isFilledText(propsValue.orderby)) { + queryParams['orderby'] = propsValue.orderby; + } + if (wordpressContent.isFilledText(propsValue.order)) { + queryParams['order'] = propsValue.order; + } + const result = await wordpressApi.list({ auth, path: '/posts', queryParams }); + return { + posts: result.items, + count: result.items.length, + total: result.total, + total_pages: result.total_pages, + }; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/list-tags.action.ts b/packages/pieces/community/wordpress/src/lib/actions/list-tags.action.ts new file mode 100644 index 000000000000..6fd7b4664345 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/list-tags.action.ts @@ -0,0 +1,72 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { QueryParams } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { listTagsOutputSchema } from '../output-schemas'; + +export const listTagsAction = createAction({ + auth: wordpressAuth, + name: 'list_tags', + classification: 'SEARCH', + displayName: 'List Tags', + description: 'Lists post tags, optionally filtered by name or post.', + audience: 'ai', + aiMetadata: { + description: + 'Lists WordPress post tags with their IDs, names, slugs and post counts. Use it to turn a tag name into the ID that create_blog_post, update_blog_post and list_posts need; use create_tag to add a missing one. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listTagsOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Only tags whose name or slug contain this text.', + required: false, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: 'Only the tag with this exact slug.', + required: false, + }), + post: Property.Number({ + displayName: 'Post ID', + description: 'Only tags assigned to this post ID (from list_posts).', + required: false, + }), + hide_empty: Property.StaticDropdown({ + displayName: 'Hide Empty', + description: 'Whether to leave out tags with no published posts. Defaults to No.', + required: false, + options: wordpressContent.trueFalseOptions, + }), + ...wordpressContent.pagingProps(), + }, + async run({ auth, propsValue }) { + const queryParams: QueryParams = wordpressContent.buildPaging({ + perPage: propsValue.per_page, + page: propsValue.page, + }); + if (wordpressContent.isFilledText(propsValue.search)) { + queryParams['search'] = propsValue.search; + } + if (wordpressContent.isFilledText(propsValue.slug)) { + queryParams['slug'] = propsValue.slug; + } + if (wordpressContent.isSetNumber(propsValue.post)) { + queryParams['post'] = String( + wordpressContent.requireWholeNumber({ value: propsValue.post, propName: 'Post ID' }) + ); + } + if (wordpressContent.isFilledText(propsValue.hide_empty)) { + queryParams['hide_empty'] = propsValue.hide_empty; + } + const result = await wordpressApi.list({ auth, path: '/tags', queryParams }); + return { + tags: result.items, + count: result.items.length, + total: result.total, + total_pages: result.total_pages, + }; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/list-users.action.ts b/packages/pieces/community/wordpress/src/lib/actions/list-users.action.ts new file mode 100644 index 000000000000..5abfeab65604 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/list-users.action.ts @@ -0,0 +1,54 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { QueryParams } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { listUsersOutputSchema } from '../output-schemas'; + +export const listUsersAction = createAction({ + auth: wordpressAuth, + name: 'list_users', + classification: 'SEARCH', + displayName: 'List Users', + description: 'Lists site users, optionally filtered by name or role.', + audience: 'ai', + aiMetadata: { + description: + 'Lists WordPress users with their IDs, display names and slugs, filtered by keyword or role. Use it to find an author ID for create_blog_post or list_posts; use get_current_user for the connected account. Without the list_users capability (Administrator) only users with published posts are returned and role filtering fails. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: listUsersOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Only users whose name, slug, email or URL contain this text.', + required: false, + }), + roles: Property.Array({ + displayName: 'Roles', + description: 'Only users with any of these role slugs, e.g. administrator, editor, author, contributor, subscriber.', + required: false, + }), + ...wordpressContent.pagingProps(), + }, + async run({ auth, propsValue }) { + const queryParams: QueryParams = wordpressContent.buildPaging({ + perPage: propsValue.per_page, + page: propsValue.page, + }); + if (wordpressContent.isFilledText(propsValue.search)) { + queryParams['search'] = propsValue.search; + } + const roles = wordpressContent.parseStringList({ values: propsValue.roles }); + if (roles) { + queryParams['roles'] = roles.join(','); + } + const result = await wordpressApi.list({ auth, path: '/users', queryParams }); + return { + users: result.items, + count: result.items.length, + total: result.total, + total_pages: result.total_pages, + }; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/moderate-comment.action.ts b/packages/pieces/community/wordpress/src/lib/actions/moderate-comment.action.ts new file mode 100644 index 000000000000..83b9bce15309 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/moderate-comment.action.ts @@ -0,0 +1,90 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressApiError, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { commentEditOutputSchema } from '../output-schemas'; + +function isInTargetState({ + target, + current, +}: { + target: string; + current: unknown; +}): boolean { + switch (target) { + case 'approve': + return current === 'approved'; + case 'unspam': + return current !== 'spam'; + case 'untrash': + return current !== 'trash'; + default: + return current === target; + } +} + +export const moderateCommentAction = createAction({ + auth: wordpressAuth, + name: 'moderate_comment', + classification: 'WRITE', + displayName: 'Moderate Comment', + description: 'Approves, holds, marks as spam, or trashes a comment, or reverses spam or trash.', + audience: 'ai', + aiMetadata: { + description: + 'Sets the moderation status of a WordPress comment: approve, hold for review, spam, trash, or reverse spam/trash. Trash is recoverable; use delete_comment only to remove a comment for good. Setting the status a comment already has returns the comment unchanged, so it is safe to retry. Needs a moderator (Editor or Administrator) connection.', + idempotent: true, + }, + outputSchema: commentEditOutputSchema, + props: { + comment_id: Property.Number({ + displayName: 'Comment ID', + description: 'ID of the comment, from list_comments.', + required: true, + }), + status: Property.StaticDropdown({ + displayName: 'Action', + description: 'The moderation action to apply.', + required: true, + options: { + disabled: false, + options: [ + { label: 'Approve', value: 'approve' }, + { label: 'Hold for moderation', value: 'hold' }, + { label: 'Mark as spam', value: 'spam' }, + { label: 'Not spam', value: 'unspam' }, + { label: 'Move to trash', value: 'trash' }, + { label: 'Restore from trash', value: 'untrash' }, + ], + }, + }), + }, + async run({ auth, propsValue }) { + const id = wordpressContent.requireWholeNumber({ value: propsValue.comment_id, propName: 'Comment ID' }); + const target = propsValue.status; + try { + const response = await wordpressApi.request({ + auth, + method: HttpMethod.POST, + path: `/comments/${id}`, + body: { status: target }, + }); + return response.body; + } catch (error) { + if (!(error instanceof WordpressApiError) || error.code !== 'rest_comment_failed_edit') { + throw error; + } + const current = await wordpressApi.request({ + auth, + method: HttpMethod.GET, + path: `/comments/${id}`, + queryParams: { context: 'edit' }, + }); + if (isInTargetState({ target, current: current.body['status'] })) { + return current.body; + } + throw error; + } + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/search-site-content.action.ts b/packages/pieces/community/wordpress/src/lib/actions/search-site-content.action.ts new file mode 100644 index 000000000000..362bc73dc969 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/search-site-content.action.ts @@ -0,0 +1,87 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { QueryParams } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { searchSiteContentOutputSchema } from '../output-schemas'; + +const subtypesByType: Record = { + post: ['post', 'page'], + term: ['category', 'post_tag'], + 'post-format': [], +}; + +export const searchSiteContentAction = createAction({ + auth: wordpressAuth, + name: 'search_site_content', + classification: 'SEARCH', + displayName: 'Search Site Content', + description: 'Searches published posts and pages, or categories and tags, by keyword.', + audience: 'ai', + aiMetadata: { + description: + 'Keyword search across the WordPress site, returning only ID, title, URL and type for each match. By default it searches published posts and pages together; set Type to "term" to search categories and tags. Use list_posts or list_pages instead when you need drafts, full fields or filters. Read-only and safe to retry.', + idempotent: true, + }, + outputSchema: searchSiteContentOutputSchema, + props: { + search: Property.ShortText({ + displayName: 'Search', + description: 'Text to search for.', + required: true, + }), + type: Property.StaticDropdown({ + displayName: 'Type', + description: 'What to search. Defaults to posts and pages.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Posts and pages', value: 'post' }, + { label: 'Categories and tags', value: 'term' }, + { label: 'Post formats', value: 'post-format' }, + ], + }, + }), + subtype: Property.StaticDropdown({ + displayName: 'Subtype', + description: + 'Narrow the search within the chosen Type. "Post" and "Page" work only with Type "Posts and pages"; "Category" and "Tag" only with Type "Categories and tags". Leave empty for all.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Post', value: 'post' }, + { label: 'Page', value: 'page' }, + { label: 'Category', value: 'category' }, + { label: 'Tag', value: 'post_tag' }, + ], + }, + }), + ...wordpressContent.pagingProps(), + }, + async run({ auth, propsValue }) { + const type = wordpressContent.isFilledText(propsValue.type) ? propsValue.type : 'post'; + const queryParams: QueryParams = { + ...wordpressContent.buildPaging({ perPage: propsValue.per_page, page: propsValue.page }), + search: propsValue.search, + type, + }; + if (wordpressContent.isFilledText(propsValue.subtype)) { + const allowed = subtypesByType[type] ?? []; + if (!allowed.includes(propsValue.subtype)) { + throw new Error( + `Subtype "${propsValue.subtype}" does not belong to Type "${type}". Allowed: ${allowed.length > 0 ? allowed.join(', ') : 'none (leave Subtype empty)'}.` + ); + } + queryParams['subtype'] = propsValue.subtype; + } + const result = await wordpressApi.list({ auth, path: '/search', queryParams }); + return { + results: result.items, + count: result.items.length, + total: result.total, + total_pages: result.total_pages, + }; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/trash-page.action.ts b/packages/pieces/community/wordpress/src/lib/actions/trash-page.action.ts new file mode 100644 index 000000000000..a41766548568 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/trash-page.action.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wordpressAuth } from '../..'; +import { wordpressApi } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { pageEditOutputSchema } from '../output-schemas'; + +export const trashPageAction = createAction({ + auth: wordpressAuth, + name: 'trash_page', + classification: 'DESTRUCTIVE', + displayName: 'Move Page to Trash', + description: 'Moves a page to the trash, where it can still be restored.', + audience: 'ai', + aiMetadata: { + description: + 'Moves a WordPress static page to the trash; it can be restored from the WordPress admin until the trash is emptied. Child pages are not trashed. Calling it on a page that is already trashed returns the page unchanged, so it is safe to retry.', + idempotent: true, + }, + outputSchema: pageEditOutputSchema, + props: { + page_id: Property.Number({ + displayName: 'Page ID', + description: 'ID of the page to trash, from list_pages.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const id = wordpressContent.requireWholeNumber({ value: propsValue.page_id, propName: 'Page ID' }); + return wordpressApi.trashItem({ auth, collection: 'pages', id }); + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/trash-post.action.ts b/packages/pieces/community/wordpress/src/lib/actions/trash-post.action.ts new file mode 100644 index 000000000000..72551d17d8c2 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/trash-post.action.ts @@ -0,0 +1,31 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { wordpressAuth } from '../..'; +import { wordpressApi } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { postEditOutputSchema } from '../output-schemas'; + +export const trashPostAction = createAction({ + auth: wordpressAuth, + name: 'trash_post', + classification: 'DESTRUCTIVE', + displayName: 'Move Post to Trash', + description: 'Moves a blog post to the trash, where it can still be restored.', + audience: 'ai', + aiMetadata: { + description: + 'Moves a WordPress blog post to the trash; it can be restored from the WordPress admin until the trash is emptied. Prefer this over delete_post_permanently unless the post must be gone for good. Calling it on a post that is already trashed returns the post unchanged, so it is safe to retry.', + idempotent: true, + }, + outputSchema: postEditOutputSchema, + props: { + post_id: Property.Number({ + displayName: 'Post ID', + description: 'ID of the post to trash, from list_posts.', + required: true, + }), + }, + async run({ auth, propsValue }) { + const id = wordpressContent.requireWholeNumber({ value: propsValue.post_id, propName: 'Post ID' }); + return wordpressApi.trashItem({ auth, collection: 'posts', id }); + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/update-blog-post.action.ts b/packages/pieces/community/wordpress/src/lib/actions/update-blog-post.action.ts new file mode 100644 index 000000000000..ae22a69b8481 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/update-blog-post.action.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { postEditOutputSchema } from '../output-schemas'; + +export const updateBlogPostAction = createAction({ + auth: wordpressAuth, + name: 'update_blog_post', + classification: 'WRITE', + displayName: 'Update Blog Post', + description: 'Changes only the fields you supply on an existing blog post.', + audience: 'ai', + aiMetadata: { + description: + 'Updates an existing WordPress blog post by ID, sending only the fields you supply; everything else stays as it is. Category IDs and Tag IDs replace the current set. Also sets comments or pingbacks open or closed, and writes only the custom ACF fields you supply. To trash a post use trash_post. Safe to retry with the same input.', + idempotent: true, + }, + outputSchema: postEditOutputSchema, + props: { + post_id: Property.Number({ + displayName: 'Post ID', + description: 'ID of the post to update, from list_posts.', + required: true, + }), + ...wordpressContent.contentProps({ kind: 'post', mode: 'update' }), + ...wordpressContent.postOnlyProps({ mode: 'update' }), + }, + async run({ auth, propsValue }) { + const postId = wordpressContent.requireWholeNumber({ value: propsValue.post_id, propName: 'Post ID' }); + const body = wordpressContent.buildContentBody({ kind: 'post', values: propsValue }); + wordpressContent.requirePatch({ body }); + const response = await wordpressApi.request({ + auth, + method: HttpMethod.POST, + path: `/posts/${postId}`, + body, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/update-media-details.action.ts b/packages/pieces/community/wordpress/src/lib/actions/update-media-details.action.ts new file mode 100644 index 000000000000..6b6e92925704 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/update-media-details.action.ts @@ -0,0 +1,65 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { mediaEditOutputSchema } from '../output-schemas'; + +export const updateMediaDetailsAction = createAction({ + auth: wordpressAuth, + name: 'update_media_details', + classification: 'WRITE', + displayName: 'Update Media Details', + description: 'Changes the title, alt text, caption, description or attached post of a media file.', + audience: 'ai', + aiMetadata: { + description: + 'Updates the details of an existing WordPress media item by ID, sending only the fields you supply; the file itself is not replaced. Use it to add alt text or a caption after upload_media. Safe to retry with the same input.', + idempotent: true, + }, + outputSchema: mediaEditOutputSchema, + props: { + media_id: Property.Number({ + displayName: 'Media ID', + description: 'ID of the media item, from list_media or upload_media.', + required: true, + }), + title: Property.ShortText({ + displayName: 'Title', + description: 'New title. Leave empty to keep the current value.', + required: false, + }), + alt_text: Property.ShortText({ + displayName: 'Alt Text', + description: 'New alternative text. Leave empty to keep the current value.', + required: false, + }), + caption: Property.LongText({ + displayName: 'Caption', + description: 'New caption. Leave empty to keep the current value.', + required: false, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'New description. Leave empty to keep the current value.', + required: false, + }), + post: Property.Number({ + displayName: 'Attach To Post ID', + description: 'ID of the post or page to attach the file to. Leave empty to keep the current value.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const id = wordpressContent.requireWholeNumber({ value: propsValue.media_id, propName: 'Media ID' }); + const body = wordpressContent.buildMediaDetailsBody({ values: propsValue }); + wordpressContent.requirePatch({ body }); + const response = await wordpressApi.request({ + auth, + method: HttpMethod.POST, + path: `/media/${id}`, + body, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/update-page.action.ts b/packages/pieces/community/wordpress/src/lib/actions/update-page.action.ts new file mode 100644 index 000000000000..c767a9b1f869 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/update-page.action.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { pageEditOutputSchema } from '../output-schemas'; + +export const updatePageAction = createAction({ + auth: wordpressAuth, + name: 'update_page', + classification: 'WRITE', + displayName: 'Update Page', + description: 'Changes only the fields you supply on an existing page.', + audience: 'ai', + aiMetadata: { + description: + 'Updates an existing WordPress static page by ID, sending only the fields you supply; everything else stays as it is. To trash a page use trash_page; use update_blog_post for blog posts. Safe to retry with the same input.', + idempotent: true, + }, + outputSchema: pageEditOutputSchema, + props: { + page_id: Property.Number({ + displayName: 'Page ID', + description: 'ID of the page to update, from list_pages.', + required: true, + }), + ...wordpressContent.contentProps({ kind: 'page', mode: 'update' }), + ...wordpressContent.pageOnlyProps({ mode: 'update' }), + }, + async run({ auth, propsValue }) { + const pageId = wordpressContent.requireWholeNumber({ value: propsValue.page_id, propName: 'Page ID' }); + const body = wordpressContent.buildContentBody({ kind: 'page', values: propsValue }); + wordpressContent.requirePatch({ body }); + const response = await wordpressApi.request({ + auth, + method: HttpMethod.POST, + path: `/pages/${pageId}`, + body, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/actions/update-post.action.ts b/packages/pieces/community/wordpress/src/lib/actions/update-post.action.ts index b5203a7c2319..d4b4e9fcc3ba 100644 --- a/packages/pieces/community/wordpress/src/lib/actions/update-post.action.ts +++ b/packages/pieces/community/wordpress/src/lib/actions/update-post.action.ts @@ -14,7 +14,7 @@ export const updateWordPressPost = createAction({ name: 'update_post', classification: 'WRITE', description: 'Change the title, content or settings of an existing post', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Updates an existing WordPress post identified by its post ID, changing only the fields you supply (title, content, status, categories, tags, excerpt, featured image, or ACF fields). Choose this to edit or republish a known post rather than create a new one. Requires the target post ID; idempotent — repeating with the same input leaves the post in the same final state.', idempotent: true }, displayName: 'Update Post', outputSchema: updatePostActionOutputSchema, diff --git a/packages/pieces/community/wordpress/src/lib/actions/upload-media.action.ts b/packages/pieces/community/wordpress/src/lib/actions/upload-media.action.ts new file mode 100644 index 000000000000..9acb29a52ccf --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/actions/upload-media.action.ts @@ -0,0 +1,69 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import FormData from 'form-data'; +import { wordpressAuth } from '../..'; +import { wordpressApi, WordpressRecord } from '../common/client'; +import { wordpressContent } from '../common/content-body'; +import { mediaEditOutputSchema } from '../output-schemas'; + +export const uploadMediaAction = createAction({ + auth: wordpressAuth, + name: 'upload_media', + classification: 'WRITE', + displayName: 'Upload Media', + description: 'Uploads a file to the media library, with optional title, alt text, caption and description.', + audience: 'ai', + aiMetadata: { + description: + 'Uploads a file (image, PDF, video and so on) to the WordPress media library in one request, optionally setting its title, alt text, caption, description and the post it is attached to. Returns the media ID and public URL; pass the ID as featured_media to create_blog_post. Each call uploads a new copy, so retries duplicate. The site limits allowed file types and size.', + idempotent: false, + }, + outputSchema: mediaEditOutputSchema, + props: { + file: Property.File({ + displayName: 'File', + description: 'The file to upload, as a URL or base64 data. The file name and extension decide its type.', + required: true, + }), + title: Property.ShortText({ + displayName: 'Title', + description: 'Media title. Defaults to the file name.', + required: false, + }), + alt_text: Property.ShortText({ + displayName: 'Alt Text', + description: 'Alternative text describing the image for screen readers.', + required: false, + }), + caption: Property.LongText({ + displayName: 'Caption', + description: 'Caption shown with the file.', + required: false, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'Longer description of the file.', + required: false, + }), + post: Property.Number({ + displayName: 'Attach To Post ID', + description: 'ID of the post or page to attach the file to, from list_posts or list_pages.', + required: false, + }), + }, + async run({ auth, propsValue }) { + const form = new FormData(); + form.append('file', propsValue.file.data, { filename: propsValue.file.filename }); + const fields = wordpressContent.buildMediaDetailsBody({ values: propsValue }); + for (const [key, value] of Object.entries(fields)) { + form.append(key, String(value)); + } + const response = await wordpressApi.request({ + auth, + method: HttpMethod.POST, + path: '/media', + body: form, + }); + return response.body; + }, +}); diff --git a/packages/pieces/community/wordpress/src/lib/common/client.ts b/packages/pieces/community/wordpress/src/lib/common/client.ts new file mode 100644 index 000000000000..8de976a74331 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/common/client.ts @@ -0,0 +1,308 @@ +import { + AuthenticationType, + httpClient, + HttpError, + HttpMethod, + HttpResponse, + QueryParams, +} from '@activepieces/pieces-common'; +import { AppConnectionValueForAuthProperty } from '@activepieces/pieces-framework'; +import { wordpressAuth } from '../..'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readErrorBody(body: unknown): { + code: string; + message: string; + data: Record; +} { + if (isRecord(body)) { + return { + code: typeof body['code'] === 'string' ? body['code'] : 'unknown_error', + message: + typeof body['message'] === 'string' ? body['message'] : JSON.stringify(body), + data: isRecord(body['data']) ? body['data'] : {}, + }; + } + return { + code: 'non_json_response', + message: typeof body === 'string' ? body.slice(0, 200) : String(body), + data: {}, + }; +} + +function unreachableMessage({ url }: { url: string }): string { + return `WordPress did not return REST API JSON from ${url}. The REST API is not reachable at /wp-json: set Settings > Permalinks to any option other than "Plain", check the Website URL, and check that no security plugin blocks the REST API.`; +} + +function describeError({ + status, + code, + message, + data, + path, +}: { + status: number; + code: string; + message: string; + data: Record; + path: string; +}): string { + const suffix = ` [HTTP ${status}, code ${code}]`; + switch (code) { + case 'rest_no_route': + return `The WordPress REST route ${path} was not found. Set Settings > Permalinks to any option other than "Plain" and check that no security plugin blocks /wp-json.${suffix}`; + case 'rest_post_invalid_page_number': + return `The requested page is past the last page of results. Use a lower page number; list outputs report total_pages.${suffix}`; + case 'rest_already_trashed': + return `The item is already in the trash.${suffix}`; + case 'rest_trash_not_supported': + case 'rest_trash_not_implemented': + return `This item cannot be moved to the trash on this site. Use the permanent delete action instead.${suffix}`; + case 'rest_comment_closed': + return `Comments are closed on this post.${suffix}`; + case 'rest_comment_draft_post': + return `Comments can only be added to a published post.${suffix}`; + case 'rest_comment_failed_edit': + return `WordPress could not change the comment status.${suffix}`; + case 'duplicate_term_slug': + return `Another term already uses this slug. Use a different slug or omit it.${suffix}`; + case 'term_exists': + return `A term with this name already exists under the same parent (term ID ${String(data['term_id'])}).${suffix}`; + } + if (status === 401) { + return `WordPress rejected the credentials: ${message} Check the username and use an Application Password (Users > Profile > Application Passwords).${suffix}`; + } + if (status === 403) { + return `The connected WordPress user's role is not allowed to do this: ${message}${suffix}`; + } + if (status === 404) { + return `WordPress could not find the requested item: ${message} Check the ID.${suffix}`; + } + if (status === 429) { + return `The WordPress host is rate limiting requests. Wait and retry.${suffix}`; + } + const params = isRecord(data['params']) ? ` ${JSON.stringify(data['params'])}` : ''; + return `WordPress returned an error: ${message}${params}${suffix}`; +} + +function siteBaseUrl({ auth }: { auth: WordpressAuthValue }): string { + return `${auth.props.website_url.trim().replace(/\/+$/, '')}/wp-json/wp/v2`; +} + +async function request({ + auth, + method, + path, + queryParams, + body, +}: WordpressRequestParams): Promise> { + const url = `${siteBaseUrl({ auth })}${path}`; + try { + const response = await httpClient.sendRequest({ + method, + url, + queryParams, + body, + authentication: { + type: AuthenticationType.BASIC, + username: auth.props.username, + password: auth.props.password, + }, + }); + if (typeof response.body === 'string' || response.body === undefined) { + throw new WordpressApiError({ + status: response.status, + code: 'non_json_response', + data: {}, + message: `${unreachableMessage({ url })} [HTTP ${response.status}, code non_json_response]`, + }); + } + return response; + } catch (error) { + if (error instanceof HttpError) { + const status = error.response.status; + const { code, message, data } = readErrorBody(error.response.body); + throw new WordpressApiError({ + status, + code, + data, + message: + code === 'non_json_response' + ? `${unreachableMessage({ url })} [HTTP ${status}, code ${code}]` + : describeError({ status, code, message, data, path }), + }); + } + throw error; + } +} + +function readCountHeader({ + headers, + name, +}: { + headers: HttpResponse['headers']; + name: string; +}): number | undefined { + const raw = headers?.[name]; + const value = Array.isArray(raw) ? raw[0] : raw; + if (value === undefined || value === '') { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +async function list({ + auth, + path, + queryParams, +}: { + auth: WordpressAuthValue; + path: string; + queryParams: QueryParams; +}): Promise> { + const response = await request({ + auth, + method: HttpMethod.GET, + path, + queryParams, + }); + const items = Array.isArray(response.body) ? response.body : []; + return { + items, + total: readCountHeader({ headers: response.headers, name: 'x-wp-total' }), + total_pages: readCountHeader({ + headers: response.headers, + name: 'x-wp-totalpages', + }), + }; +} + +async function trashItem({ + auth, + collection, + id, +}: { + auth: WordpressAuthValue; + collection: 'posts' | 'pages'; + id: number; +}): Promise { + try { + const response = await request({ + auth, + method: HttpMethod.DELETE, + path: `/${collection}/${id}`, + }); + return response.body; + } catch (error) { + if (error instanceof WordpressApiError && error.code === 'rest_already_trashed') { + const current = await request({ + auth, + method: HttpMethod.GET, + path: `/${collection}/${id}`, + queryParams: { context: 'edit' }, + }); + return current.body; + } + throw error; + } +} + +async function getOrCreateTerm({ + auth, + taxonomy, + body, +}: { + auth: WordpressAuthValue; + taxonomy: 'categories' | 'tags'; + body: Record; +}): Promise { + try { + const response = await request({ + auth, + method: HttpMethod.POST, + path: `/${taxonomy}`, + body, + }); + return { ...response.body, created: true }; + } catch (error) { + if (error instanceof WordpressApiError && error.code === 'term_exists') { + const termId = error.data['term_id']; + if (typeof termId !== 'number' && typeof termId !== 'string') { + throw error; + } + const existing = await request({ + auth, + method: HttpMethod.GET, + path: `/${taxonomy}/${termId}`, + }); + return { ...existing.body, created: false }; + } + throw error; + } +} + +async function forceDelete({ + auth, + path, +}: { + auth: WordpressAuthValue; + path: string; +}): Promise { + const response = await request({ + auth, + method: HttpMethod.DELETE, + path, + queryParams: { force: 'true' }, + }); + return response.body; +} + +export class WordpressApiError extends Error { + public readonly status: number; + public readonly code: string; + public readonly data: Record; + + constructor({ + status, + code, + data, + message, + }: { + status: number; + code: string; + data: Record; + message: string; + }) { + super(message); + this.name = 'WordpressApiError'; + this.status = status; + this.code = code; + this.data = data; + } +} + +export const wordpressApi = { request, list, trashItem, getOrCreateTerm, forceDelete }; + +export type WordpressAuthValue = AppConnectionValueForAuthProperty< + typeof wordpressAuth +>; + +export type WordpressRequestParams = { + auth: WordpressAuthValue; + method: HttpMethod; + path: string; + queryParams?: QueryParams; + body?: unknown; +}; + +export type WordpressListResult = { + items: T[]; + total: number | undefined; + total_pages: number | undefined; +}; + +export type WordpressRecord = Record; diff --git a/packages/pieces/community/wordpress/src/lib/common/content-body.ts b/packages/pieces/community/wordpress/src/lib/common/content-body.ts new file mode 100644 index 000000000000..f856909730a6 --- /dev/null +++ b/packages/pieces/community/wordpress/src/lib/common/content-body.ts @@ -0,0 +1,437 @@ +import { QueryParams } from '@activepieces/pieces-common'; +import { Property } from '@activepieces/pieces-framework'; + +function isFilledText(value: string | null | undefined): value is string { + return typeof value === 'string' && value.trim() !== ''; +} + +function isSetNumber(value: number | null | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +function requireWholeNumber({ + value, + propName, +}: { + value: number; + propName: string; +}): number { + if (!Number.isInteger(value) || value < 0) { + throw new Error(`${propName} must be a whole-number ID; got "${value}".`); + } + return value; +} + +function parseIdList({ + values, + propName, +}: { + values: unknown[] | null | undefined; + propName: string; +}): number[] | undefined { + if (!Array.isArray(values) || values.length === 0) { + return undefined; + } + return values.map((item) => { + const parsed = typeof item === 'string' ? Number(item.trim()) : item; + if (typeof parsed !== 'number' || !Number.isInteger(parsed) || parsed <= 0) { + throw new Error( + `${propName} must contain whole-number IDs only; got "${String(item)}".` + ); + } + return parsed; + }); +} + +function parseStringList({ + values, +}: { + values: unknown[] | null | undefined; +}): string[] | undefined { + if (!Array.isArray(values)) { + return undefined; + } + const items = values + .map((item) => String(item).trim()) + .filter((item) => item !== ''); + return items.length > 0 ? items : undefined; +} + +function buildPaging({ + perPage, + page, +}: { + perPage: number | null | undefined; + page: number | null | undefined; +}): QueryParams { + const resolvedPerPage = isSetNumber(perPage) ? perPage : 10; + const resolvedPage = isSetNumber(page) ? page : 1; + if ( + !Number.isInteger(resolvedPerPage) || + resolvedPerPage < 1 || + resolvedPerPage > 100 + ) { + throw new Error( + `Results Per Page must be a whole number from 1 to 100; got "${resolvedPerPage}".` + ); + } + if (!Number.isInteger(resolvedPage) || resolvedPage < 1) { + throw new Error( + `Page must be a whole number of 1 or more; got "${resolvedPage}".` + ); + } + return { per_page: String(resolvedPerPage), page: String(resolvedPage) }; +} + +function addText({ + target, + key, + value, +}: { + target: Record; + key: string; + value: string | null | undefined; +}): void { + if (isFilledText(value)) { + target[key] = value; + } +} + +function addNumber({ + target, + key, + value, + propName, +}: { + target: Record; + key: string; + value: number | null | undefined; + propName: string; +}): void { + if (isSetNumber(value)) { + target[key] = requireWholeNumber({ value, propName }); + } +} + +function buildContentBody({ + kind, + values, +}: { + kind: 'post' | 'page'; + values: ContentValues; +}): Record { + const body: Record = {}; + addText({ target: body, key: 'title', value: values.title }); + addText({ target: body, key: 'content', value: values.content }); + addText({ target: body, key: 'status', value: values.status }); + addText({ target: body, key: 'date', value: values.date }); + addText({ target: body, key: 'slug', value: values.slug }); + addText({ target: body, key: 'comment_status', value: values.comment_status }); + addText({ target: body, key: 'ping_status', value: values.ping_status }); + addNumber({ + target: body, + key: 'featured_media', + value: values.featured_media, + propName: 'Featured Media ID', + }); + addNumber({ target: body, key: 'author', value: values.author, propName: 'Author ID' }); + addText({ target: body, key: 'excerpt', value: values.excerpt }); + if (kind === 'post') { + addText({ target: body, key: 'format', value: values.format }); + if (values.sticky === 'true' || values.sticky === 'false') { + body['sticky'] = values.sticky === 'true'; + } + const categories = parseIdList({ values: values.categories, propName: 'Category IDs' }); + if (categories) { + body['categories'] = categories; + } + const tags = parseIdList({ values: values.tags, propName: 'Tag IDs' }); + if (tags) { + body['tags'] = tags; + } + if (values.acf && Object.keys(values.acf).length > 0) { + body['acf'] = values.acf; + } + } + if (kind === 'page') { + addNumber({ target: body, key: 'parent', value: values.parent, propName: 'Parent Page ID' }); + if (isSetNumber(values.menu_order)) { + if (!Number.isInteger(values.menu_order)) { + throw new Error(`Menu Order must be a whole number; got "${values.menu_order}".`); + } + body['menu_order'] = values.menu_order; + } + } + return body; +} + +function requireDateForFuture({ values }: { values: ContentValues }): void { + if (values.status === 'future' && !isFilledText(values.date)) { + throw new Error( + 'Status "future" needs a Publish Date in the future (ISO 8601, e.g. 2030-01-31T09:00:00).' + ); + } +} + +function buildMediaDetailsBody({ + values, +}: { + values: MediaDetailsValues; +}): Record { + const body: Record = {}; + addText({ target: body, key: 'title', value: values.title }); + addText({ target: body, key: 'alt_text', value: values.alt_text }); + addText({ target: body, key: 'caption', value: values.caption }); + addText({ target: body, key: 'description', value: values.description }); + addNumber({ target: body, key: 'post', value: values.post, propName: 'Attached Post ID' }); + return body; +} + +function requirePatch({ body }: { body: Record }): void { + if (Object.keys(body).length === 0) { + throw new Error( + 'Nothing to update: supply at least one field to change. Empty text fields are treated as "leave unchanged".' + ); + } +} + +const openClosedOptions = { + disabled: false, + options: [ + { label: 'Open', value: 'open' }, + { label: 'Closed', value: 'closed' }, + ], +}; + +const trueFalseOptions = { + disabled: false, + options: [ + { label: 'Yes', value: 'true' }, + { label: 'No', value: 'false' }, + ], +}; + +const contentStatusOptions = { + disabled: false, + options: [ + { label: 'Publish', value: 'publish' }, + { label: 'Draft', value: 'draft' }, + { label: 'Pending Review', value: 'pending' }, + { label: 'Private', value: 'private' }, + { label: 'Scheduled (future)', value: 'future' }, + ], +}; + +const postFormatOptions = { + disabled: false, + options: [ + { label: 'Standard', value: 'standard' }, + { label: 'Aside', value: 'aside' }, + { label: 'Chat', value: 'chat' }, + { label: 'Gallery', value: 'gallery' }, + { label: 'Link', value: 'link' }, + { label: 'Image', value: 'image' }, + { label: 'Quote', value: 'quote' }, + { label: 'Status', value: 'status' }, + { label: 'Video', value: 'video' }, + { label: 'Audio', value: 'audio' }, + ], +}; + +const orderOptions = { + disabled: false, + options: [ + { label: 'Descending', value: 'desc' }, + { label: 'Ascending', value: 'asc' }, + ], +}; + +function contentProps({ + kind, + mode, +}: { + kind: 'post' | 'page'; + mode: 'create' | 'update'; +}) { + const required = mode === 'create'; + const noun = kind === 'post' ? 'post' : 'page'; + const unchanged = mode === 'update' ? ' Leave empty to keep the current value.' : ''; + return { + title: Property.ShortText({ + displayName: 'Title', + description: `The ${noun} title.${unchanged}`, + required, + }), + content: Property.LongText({ + displayName: 'Content', + description: `The ${noun} body as HTML.${unchanged}`, + required, + }), + status: Property.StaticDropdown({ + displayName: 'Status', + description: + mode === 'create' + ? `Leave empty to save the ${noun} as a draft. "Scheduled (future)" needs a Publish Date.` + : `New status.${unchanged} To trash the ${noun}, use the trash action instead.`, + required: false, + options: contentStatusOptions, + }), + date: Property.ShortText({ + displayName: 'Publish Date', + description: `Publish date in the site's timezone, ISO 8601 (e.g. 2030-01-31T09:00:00). A past date with status "future" publishes immediately.${unchanged}`, + required: false, + }), + slug: Property.ShortText({ + displayName: 'Slug', + description: `URL slug.${unchanged}`, + required: false, + }), + featured_media: Property.Number({ + displayName: 'Featured Media ID', + description: `ID of an uploaded image to use as the featured image. Get it from upload_media or list_media.${unchanged}`, + required: false, + }), + author: Property.Number({ + displayName: 'Author ID', + description: `User ID of the author. Get it from list_users or get_current_user. Leave empty for the connected user.`, + required: false, + }), + comment_status: Property.StaticDropdown({ + displayName: 'Comments', + description: `Whether visitors can comment.${unchanged}`, + required: false, + options: openClosedOptions, + }), + ping_status: Property.StaticDropdown({ + displayName: 'Pingbacks', + description: `Whether the ${noun} accepts pingbacks and trackbacks.${unchanged}`, + required: false, + options: openClosedOptions, + }), + }; +} + +function postOnlyProps({ mode }: { mode: 'create' | 'update' }) { + const unchanged = mode === 'update' ? ' Leave empty to keep the current value.' : ''; + const replaces = mode === 'update' ? ' The list replaces the post\'s current set.' : ''; + return { + excerpt: Property.LongText({ + displayName: 'Excerpt', + description: `Short summary of the post.${unchanged}`, + required: false, + }), + categories: Property.Array({ + displayName: 'Category IDs', + description: `Category IDs from list_categories or create_category.${replaces}${unchanged}`, + required: false, + }), + tags: Property.Array({ + displayName: 'Tag IDs', + description: `Tag IDs from list_tags or create_tag.${replaces}${unchanged}`, + required: false, + }), + sticky: Property.StaticDropdown({ + displayName: 'Sticky', + description: `Whether the post is pinned to the top of the blog.${unchanged}`, + required: false, + options: trueFalseOptions, + }), + format: Property.StaticDropdown({ + displayName: 'Format', + description: `Post format. Only formats the active theme supports are accepted.${unchanged}`, + required: false, + options: postFormatOptions, + }), + acf: Property.Object({ + displayName: 'Custom ACF Fields', + description: `Advanced Custom Fields values as field name and value pairs; field names come from the ACF plugin menu. Only the fields you supply are written. Requires the site to expose ACF over the REST API.${unchanged}`, + required: false, + }), + }; +} + +function pageOnlyProps({ mode }: { mode: 'create' | 'update' }) { + const unchanged = mode === 'update' ? ' Leave empty to keep the current value.' : ''; + return { + excerpt: Property.LongText({ + displayName: 'Excerpt', + description: `Short summary of the page. Takes effect only when the theme or site supports page excerpts; WordPress silently ignores it otherwise.${unchanged}`, + required: false, + }), + parent: Property.Number({ + displayName: 'Parent Page ID', + description: `ID of the parent page, from list_pages.${unchanged}`, + required: false, + }), + menu_order: Property.Number({ + displayName: 'Menu Order', + description: `Position of the page among its siblings.${unchanged}`, + required: false, + }), + }; +} + +function pagingProps() { + return { + per_page: Property.Number({ + displayName: 'Results Per Page', + description: 'How many results to return, from 1 to 100. Defaults to 10.', + required: false, + defaultValue: 10, + }), + page: Property.Number({ + displayName: 'Page', + description: 'Page of results to return, starting at 1. The output reports total_pages.', + required: false, + defaultValue: 1, + }), + }; +} + +export const wordpressContent = { + isFilledText, + isSetNumber, + requireWholeNumber, + parseIdList, + parseStringList, + buildPaging, + buildContentBody, + requireDateForFuture, + buildMediaDetailsBody, + requirePatch, + contentProps, + postOnlyProps, + pageOnlyProps, + pagingProps, + openClosedOptions, + trueFalseOptions, + orderOptions, +}; + +export type ContentValues = { + title?: string | null; + content?: string | null; + status?: string | null; + date?: string | null; + slug?: string | null; + featured_media?: number | null; + author?: number | null; + comment_status?: string | null; + ping_status?: string | null; + excerpt?: string | null; + categories?: unknown[] | null; + tags?: unknown[] | null; + sticky?: string | null; + format?: string | null; + acf?: Record | null; + parent?: number | null; + menu_order?: number | null; +}; + +export type MediaDetailsValues = { + title?: string | null; + alt_text?: string | null; + caption?: string | null; + description?: string | null; + post?: number | null; +}; diff --git a/packages/pieces/community/wordpress/src/lib/output-schemas.ts b/packages/pieces/community/wordpress/src/lib/output-schemas.ts index 70899cae39e4..db267492fab2 100644 --- a/packages/pieces/community/wordpress/src/lib/output-schemas.ts +++ b/packages/pieces/community/wordpress/src/lib/output-schemas.ts @@ -13,6 +13,329 @@ const renderedBodyFields: OutputSchema['fields'] = [ { key: 'protected', label: 'Password Protected', format: 'boolean' }, ]; +const postFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Post ID', format: 'number' }, + { key: 'title', label: 'Title', children: renderedTextFields }, + { key: 'link', label: 'Link', format: 'url' }, + { key: 'slug', label: 'Slug' }, + { key: 'status', label: 'Status' }, + { key: 'type', label: 'Type' }, + { key: 'date', label: 'Published Date', format: 'datetime' }, + { key: 'date_gmt', label: 'Published Date (GMT)', format: 'datetime' }, + { key: 'modified', label: 'Modified Date', format: 'datetime' }, + { key: 'modified_gmt', label: 'Modified Date (GMT)', format: 'datetime' }, + { key: 'content', label: 'Content', children: renderedBodyFields }, + { key: 'excerpt', label: 'Excerpt', children: renderedBodyFields }, + { key: 'guid', label: 'GUID', children: renderedUrlFields }, + { key: 'author', label: 'Author ID', format: 'number' }, + { key: 'featured_media', label: 'Featured Media ID', format: 'number' }, + { key: 'sticky', label: 'Sticky', format: 'boolean' }, + { key: 'format', label: 'Format' }, + { + key: 'categories', + label: 'Category IDs', + description: 'Category term IDs assigned to the post, as a list of numbers.', + }, + { + key: 'tags', + label: 'Tag IDs', + description: 'Tag term IDs assigned to the post, as a list of numbers.', + }, + { key: 'comment_status', label: 'Comment Status' }, + { key: 'ping_status', label: 'Ping Status' }, +]; + +const renderedRawTextFields: OutputSchema['fields'] = [ + { key: 'rendered', label: 'Rendered', format: 'html' }, + { key: 'raw', label: 'Raw' }, +]; + +const renderedRawUrlFields: OutputSchema['fields'] = [ + { key: 'rendered', label: 'Rendered', format: 'url' }, + { key: 'raw', label: 'Raw', format: 'url' }, +]; + +const renderedRawBodyFields: OutputSchema['fields'] = [ + { key: 'rendered', label: 'Rendered', format: 'html' }, + { key: 'raw', label: 'Raw' }, + { key: 'protected', label: 'Password Protected', format: 'boolean' }, +]; + +const renderedRawContentFields: OutputSchema['fields'] = [ + ...renderedRawBodyFields, + { key: 'block_version', label: 'Block Version', format: 'number' }, +]; + +const itemDateFields: OutputSchema['fields'] = [ + { key: 'date', label: 'Published Date', format: 'datetime' }, + { key: 'date_gmt', label: 'Published Date (GMT)', format: 'datetime' }, + { key: 'modified', label: 'Modified Date', format: 'datetime' }, + { key: 'modified_gmt', label: 'Modified Date (GMT)', format: 'datetime' }, +]; + +const itemCommonFields: OutputSchema['fields'] = [ + ...itemDateFields, + { key: 'link', label: 'Link', format: 'url' }, + { key: 'slug', label: 'Slug' }, + { key: 'status', label: 'Status' }, + { key: 'type', label: 'Type' }, + { key: 'author', label: 'Author ID', format: 'number' }, + { key: 'featured_media', label: 'Featured Media ID', format: 'number' }, + { key: 'comment_status', label: 'Comment Status' }, + { key: 'ping_status', label: 'Ping Status' }, + { key: 'template', label: 'Template' }, + { key: 'meta', label: 'Meta', description: 'Registered meta fields; the keys depend on the site.' }, + { key: 'class_list', label: 'CSS Classes', description: 'CSS classes WordPress assigns to the item, as a list of strings.' }, +]; + +const editOnlyFields: OutputSchema['fields'] = [ + { key: 'permalink_template', label: 'Permalink Template' }, + { key: 'generated_slug', label: 'Generated Slug' }, +]; + +const postTermFields: OutputSchema['fields'] = [ + { key: 'sticky', label: 'Sticky', format: 'boolean' }, + { key: 'format', label: 'Format' }, + { + key: 'categories', + label: 'Category IDs', + description: 'Category term IDs assigned to the post, as a list of numbers.', + }, + { + key: 'tags', + label: 'Tag IDs', + description: 'Tag term IDs assigned to the post, as a list of numbers.', + }, +]; + +const postViewFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Post ID', format: 'number' }, + { key: 'title', label: 'Title', children: renderedTextFields }, + { key: 'content', label: 'Content', children: renderedBodyFields }, + { key: 'excerpt', label: 'Excerpt', children: renderedBodyFields }, + { key: 'guid', label: 'GUID', children: renderedUrlFields }, + ...itemCommonFields, + ...postTermFields, +]; + +const postEditFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Post ID', format: 'number' }, + { key: 'title', label: 'Title', children: renderedRawTextFields }, + { key: 'content', label: 'Content', children: renderedRawContentFields }, + { key: 'excerpt', label: 'Excerpt', children: renderedRawBodyFields }, + { key: 'guid', label: 'GUID', children: renderedRawUrlFields }, + { key: 'password', label: 'Post Password' }, + ...itemCommonFields, + ...postTermFields, + ...editOnlyFields, +]; + +const pageOnlyFields: OutputSchema['fields'] = [ + { key: 'parent', label: 'Parent Page ID', format: 'number' }, + { key: 'menu_order', label: 'Menu Order', format: 'number' }, +]; + +const pageViewFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Page ID', format: 'number' }, + { key: 'title', label: 'Title', children: renderedTextFields }, + { key: 'content', label: 'Content', children: renderedBodyFields }, + { key: 'excerpt', label: 'Excerpt', description: 'Present only when the theme or site supports page excerpts.', children: renderedBodyFields }, + { key: 'guid', label: 'GUID', children: renderedUrlFields }, + ...itemCommonFields, + ...pageOnlyFields, +]; + +const pageEditFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Page ID', format: 'number' }, + { key: 'title', label: 'Title', children: renderedRawTextFields }, + { key: 'content', label: 'Content', children: renderedRawContentFields }, + { key: 'excerpt', label: 'Excerpt', description: 'Present only when the theme or site supports page excerpts.', children: renderedRawBodyFields }, + { key: 'guid', label: 'GUID', children: renderedRawUrlFields }, + { key: 'password', label: 'Page Password' }, + ...itemCommonFields, + ...pageOnlyFields, + ...editOnlyFields, +]; + +const termFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Term ID', format: 'number' }, + { key: 'name', label: 'Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'description', label: 'Description' }, + { key: 'count', label: 'Published Post Count', format: 'number' }, + { key: 'link', label: 'Archive Link', format: 'url' }, + { key: 'taxonomy', label: 'Taxonomy' }, + { key: 'meta', label: 'Meta', description: 'Registered term meta; usually an empty list.' }, +]; + +const categoryFields: OutputSchema['fields'] = [ + ...termFields, + { key: 'parent', label: 'Parent Category ID', format: 'number', description: '0 when the category has no parent.' }, +]; + +const avatarFields: OutputSchema['fields'] = [ + { key: '24', label: 'Avatar 24px', format: 'image' }, + { key: '48', label: 'Avatar 48px', format: 'image' }, + { key: '96', label: 'Avatar 96px', format: 'image' }, +]; + +const commentViewFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Comment ID', format: 'number' }, + { key: 'post', label: 'Post ID', format: 'number' }, + { key: 'parent', label: 'Parent Comment ID', format: 'number' }, + { key: 'author', label: 'Author User ID', format: 'number', description: '0 for a guest comment.' }, + { key: 'author_name', label: 'Author Name' }, + { key: 'author_url', label: 'Author URL', format: 'url' }, + { key: 'date', label: 'Date', format: 'datetime' }, + { key: 'date_gmt', label: 'Date (GMT)', format: 'datetime' }, + { key: 'content', label: 'Content', children: renderedTextFields }, + { key: 'link', label: 'Link', format: 'url' }, + { key: 'status', label: 'Status', description: 'approved, hold, spam or trash.' }, + { key: 'type', label: 'Type' }, + { key: 'author_avatar_urls', label: 'Author Avatars', children: avatarFields }, + { key: 'meta', label: 'Meta', description: 'Registered comment meta; the keys depend on the site.' }, +]; + +const commentEditFields: OutputSchema['fields'] = [ + ...commentViewFields.filter((field) => field.key !== 'content'), + { key: 'content', label: 'Content', children: renderedRawTextFields }, + { key: 'author_email', label: 'Author Email', format: 'email' }, + { key: 'author_ip', label: 'Author IP' }, + { key: 'author_user_agent', label: 'Author User Agent' }, +]; + +const mediaViewFields: OutputSchema['fields'] = [ + { key: 'id', label: 'Media ID', format: 'number' }, + { key: 'title', label: 'Title', children: renderedTextFields }, + { key: 'guid', label: 'GUID', children: renderedUrlFields }, + { key: 'description', label: 'Description', children: renderedTextFields }, + { key: 'caption', label: 'Caption', children: renderedTextFields }, + ...itemCommonFields, + { key: 'alt_text', label: 'Alt Text' }, + { key: 'media_type', label: 'Media Type' }, + { key: 'mime_type', label: 'MIME Type' }, + { key: 'source_url', label: 'File URL', format: 'url' }, + { key: 'filename', label: 'File Name' }, + { key: 'filesize', label: 'File Size', format: 'filesize' }, + { key: 'post', label: 'Attached Post ID', format: 'number', description: 'null when the file is not attached to a post.' }, + { + key: 'media_details', + label: 'Media Details', + description: 'File details; images also carry sizes and image_meta.', + children: [ + { key: 'width', label: 'Width', format: 'number' }, + { key: 'height', label: 'Height', format: 'number' }, + { key: 'file', label: 'Relative File Path' }, + { key: 'filesize', label: 'File Size', format: 'filesize' }, + { key: 'sizes', label: 'Sizes', dynamicKey: true, description: 'Generated image sizes keyed by size name.' }, + { key: 'image_meta', label: 'Image Metadata', description: 'EXIF-style metadata extracted from the image.' }, + ], + }, +]; + +const mediaEditFields: OutputSchema['fields'] = [ + ...mediaViewFields.filter( + (field) => !['title', 'guid', 'description', 'caption'].includes(field.key) + ), + { key: 'title', label: 'Title', children: renderedRawTextFields }, + { key: 'guid', label: 'GUID', children: renderedRawUrlFields }, + { key: 'description', label: 'Description', children: renderedRawTextFields }, + { key: 'caption', label: 'Caption', children: renderedRawTextFields }, + ...editOnlyFields, + { key: 'missing_image_sizes', label: 'Missing Image Sizes', description: 'Image sizes WordPress has not generated yet.' }, + { key: 'exif_orientation', label: 'EXIF Orientation', format: 'number' }, + { key: 'image_output_format', label: 'Image Output Format' }, + { key: 'image_save_progressive', label: 'Save Progressive', format: 'boolean' }, + { key: 'image_quality', label: 'Image Quality', description: 'Default quality and per-size quality.' }, +]; + +const userPublicFields: OutputSchema['fields'] = [ + { key: 'id', label: 'User ID', format: 'number' }, + { key: 'name', label: 'Display Name' }, + { key: 'slug', label: 'Slug' }, + { key: 'url', label: 'Website', format: 'url' }, + { key: 'description', label: 'Biography' }, + { key: 'link', label: 'Author Archive Link', format: 'url' }, + { key: 'avatar_urls', label: 'Avatars', children: avatarFields }, + { key: 'meta', label: 'Meta', description: 'Registered user meta; the keys depend on the site.' }, + { key: 'is_super_admin', label: 'Is Super Admin', format: 'boolean' }, +]; + +const currentUserFields: OutputSchema['fields'] = [ + ...userPublicFields, + { key: 'username', label: 'Username' }, + { key: 'first_name', label: 'First Name' }, + { key: 'last_name', label: 'Last Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'nickname', label: 'Nickname' }, + { key: 'locale', label: 'Locale' }, + { key: 'registered_date', label: 'Registered Date', format: 'datetime' }, + { key: 'roles', label: 'Roles', description: 'Role slugs, e.g. administrator, editor, author.' }, + { key: 'capabilities', label: 'Capabilities', dynamicKey: true, description: 'Capability name to true/false for this user.' }, + { key: 'extra_capabilities', label: 'Extra Capabilities', dynamicKey: true }, +]; + +const searchResultFields: OutputSchema['fields'] = [ + { key: 'id', label: 'ID', format: 'number' }, + { key: 'title', label: 'Title' }, + { key: 'url', label: 'URL', format: 'url' }, + { key: 'type', label: 'Type', description: 'post for posts and pages; the taxonomy (category, post_tag) for terms.' }, + { key: 'subtype', label: 'Subtype', description: 'post or page; present for post results only.' }, +]; + +const settingsFields: OutputSchema['fields'] = [ + { key: 'title', label: 'Site Title' }, + { key: 'description', label: 'Tagline' }, + { key: 'url', label: 'Site URL', format: 'url' }, + { key: 'email', label: 'Admin Email', format: 'email' }, + { key: 'timezone', label: 'Timezone', description: 'Empty when the site uses a UTC offset instead of a named zone.' }, + { key: 'date_format', label: 'Date Format' }, + { key: 'time_format', label: 'Time Format' }, + { key: 'start_of_week', label: 'Start of Week', format: 'number' }, + { key: 'language', label: 'Language' }, + { key: 'use_smilies', label: 'Convert Emoticons', format: 'boolean' }, + { key: 'default_category', label: 'Default Category ID', format: 'number' }, + { key: 'default_post_format', label: 'Default Post Format' }, + { key: 'posts_per_page', label: 'Posts Per Page', format: 'number' }, + { key: 'show_on_front', label: 'Front Page Shows' }, + { key: 'page_on_front', label: 'Front Page ID', format: 'number' }, + { key: 'page_for_posts', label: 'Posts Page ID', format: 'number' }, + { key: 'default_ping_status', label: 'Default Ping Status' }, + { key: 'default_comment_status', label: 'Default Comment Status' }, + { key: 'site_logo', label: 'Site Logo Media ID', format: 'number', description: 'null when no logo is set.' }, + { key: 'site_icon', label: 'Site Icon Media ID', format: 'number' }, +]; + +function listSchema({ + key, + label, + itemFields, + itemLabel, +}: { + key: string; + label: string; + itemFields: OutputSchema['fields']; + itemLabel: string; +}): OutputSchema { + return { + fields: [ + { key, label, labelKey: itemLabel, listItems: itemFields }, + { key: 'count', label: 'Returned Count', format: 'number' }, + { key: 'total', label: 'Total Matches', format: 'number', description: 'From the X-WP-Total header; absent when the host strips it.' }, + { key: 'total_pages', label: 'Total Pages', format: 'number', description: 'From the X-WP-TotalPages header; absent when the host strips it.' }, + ], + }; +} + +function deleteSchema({ itemFields }: { itemFields: OutputSchema['fields'] }): OutputSchema { + return { + fields: [ + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + { key: 'previous', label: 'Deleted Item', children: itemFields }, + ], + }; +} + export const createPostActionOutputSchema: OutputSchema = { fields: [ { key: 'id', label: 'Post ID', value: 'body.id', format: 'number' }, @@ -109,40 +432,64 @@ export const getPostActionOutputSchema: OutputSchema = { ], }; -const postFields: OutputSchema['fields'] = [ - { key: 'id', label: 'Post ID', format: 'number' }, - { key: 'title', label: 'Title', children: renderedTextFields }, - { key: 'link', label: 'Link', format: 'url' }, - { key: 'slug', label: 'Slug' }, - { key: 'status', label: 'Status' }, - { key: 'type', label: 'Type' }, - { key: 'date', label: 'Published Date', format: 'datetime' }, - { key: 'date_gmt', label: 'Published Date (GMT)', format: 'datetime' }, - { key: 'modified', label: 'Modified Date', format: 'datetime' }, - { key: 'modified_gmt', label: 'Modified Date (GMT)', format: 'datetime' }, - { key: 'content', label: 'Content', children: renderedBodyFields }, - { key: 'excerpt', label: 'Excerpt', children: renderedBodyFields }, - { key: 'guid', label: 'GUID', children: renderedUrlFields }, - { key: 'author', label: 'Author ID', format: 'number' }, - { key: 'featured_media', label: 'Featured Media ID', format: 'number' }, - { key: 'sticky', label: 'Sticky', format: 'boolean' }, - { key: 'format', label: 'Format' }, - { - key: 'categories', - label: 'Category IDs', - description: 'Category term IDs assigned to the post, as a list of numbers.', - }, - { - key: 'tags', - label: 'Tag IDs', - description: 'Tag term IDs assigned to the post, as a list of numbers.', - }, - { key: 'comment_status', label: 'Comment Status' }, - { key: 'ping_status', label: 'Ping Status' }, -]; - -// update_post returns response.body, so its paths are bare — unlike the create/get -// actions above, which return the full HttpResponse and address the post under body. export const updatePostActionOutputSchema: OutputSchema = { fields: postFields }; export const newPostTriggerOutputSchema: OutputSchema = { fields: postFields }; + +export const listPostsOutputSchema: OutputSchema = listSchema({ key: 'posts', label: 'Posts', itemFields: postViewFields, itemLabel: 'slug' }); + +export const getPostByIdOutputSchema: OutputSchema = { fields: postViewFields }; + +export const postEditOutputSchema: OutputSchema = { fields: postEditFields }; + +export const deletePostOutputSchema: OutputSchema = deleteSchema({ itemFields: postEditFields }); + +export const listPagesOutputSchema: OutputSchema = listSchema({ key: 'pages', label: 'Pages', itemFields: pageViewFields, itemLabel: 'slug' }); + +export const getPageOutputSchema: OutputSchema = { fields: pageViewFields }; + +export const pageEditOutputSchema: OutputSchema = { fields: pageEditFields }; + +export const listCategoriesOutputSchema: OutputSchema = listSchema({ key: 'categories', label: 'Categories', itemFields: categoryFields, itemLabel: 'name' }); + +export const createCategoryOutputSchema: OutputSchema = { + fields: [ + ...categoryFields, + { key: 'created', label: 'Created', format: 'boolean', description: 'false when a category with this name already existed under the same parent and was returned instead.' }, + ], +}; + +export const deleteCategoryOutputSchema: OutputSchema = deleteSchema({ itemFields: categoryFields }); + +export const listTagsOutputSchema: OutputSchema = listSchema({ key: 'tags', label: 'Tags', itemFields: termFields, itemLabel: 'name' }); + +export const createTagOutputSchema: OutputSchema = { + fields: [ + ...termFields, + { key: 'created', label: 'Created', format: 'boolean', description: 'false when a tag with this name already existed and was returned instead.' }, + ], +}; + +export const deleteTagOutputSchema: OutputSchema = deleteSchema({ itemFields: termFields }); + +export const listCommentsOutputSchema: OutputSchema = listSchema({ key: 'comments', label: 'Comments', itemFields: commentViewFields, itemLabel: 'author_name' }); + +export const commentEditOutputSchema: OutputSchema = { fields: commentEditFields }; + +export const deleteCommentOutputSchema: OutputSchema = deleteSchema({ itemFields: commentEditFields }); + +export const listMediaOutputSchema: OutputSchema = listSchema({ key: 'media', label: 'Media', itemFields: mediaViewFields, itemLabel: 'filename' }); + +export const getMediaOutputSchema: OutputSchema = { fields: mediaViewFields }; + +export const mediaEditOutputSchema: OutputSchema = { fields: mediaEditFields }; + +export const deleteMediaOutputSchema: OutputSchema = deleteSchema({ itemFields: mediaEditFields }); + +export const getCurrentUserOutputSchema: OutputSchema = { fields: currentUserFields }; + +export const listUsersOutputSchema: OutputSchema = listSchema({ key: 'users', label: 'Users', itemFields: userPublicFields, itemLabel: 'name' }); + +export const searchSiteContentOutputSchema: OutputSchema = listSchema({ key: 'results', label: 'Results', itemFields: searchResultFields, itemLabel: 'title' }); + +export const getSiteSettingsOutputSchema: OutputSchema = { fields: settingsFields }; diff --git a/packages/pieces/community/wordpress/src/lib/trigger/new-post.trigger.ts b/packages/pieces/community/wordpress/src/lib/trigger/new-post.trigger.ts index 0a07f6d818c7..fb242ce4c4f9 100644 --- a/packages/pieces/community/wordpress/src/lib/trigger/new-post.trigger.ts +++ b/packages/pieces/community/wordpress/src/lib/trigger/new-post.trigger.ts @@ -129,34 +129,16 @@ export const wordpressNewPost = createTrigger({ }, type: TriggerStrategy.POLLING, async test(ctx) { - return await pollingHelper.test(polling, { - auth: ctx.auth, - store: ctx.store, - propsValue: ctx.propsValue, - files: ctx.files, - }); + return await pollingHelper.test(polling, ctx); }, async onEnable(ctx) { - await pollingHelper.onEnable(polling, { - auth: ctx.auth, - store: ctx.store, - propsValue: ctx.propsValue, - }); + await pollingHelper.onEnable(polling, ctx); }, async onDisable(ctx) { - await pollingHelper.onDisable(polling, { - auth: ctx.auth, - store: ctx.store, - propsValue: ctx.propsValue, - }); + await pollingHelper.onDisable(polling, ctx); }, async run(ctx) { - return await pollingHelper.poll(polling, { - auth: ctx.auth, - store: ctx.store, - propsValue: ctx.propsValue, - files: ctx.files, - }); + return await pollingHelper.poll(polling, ctx); }, }); From c8ebc4d3067daa8ac3937d4106628689bb0e5612 Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Wed, 23 Sep 2026 01:33:43 +0300 Subject: [PATCH 2/7] feat(discord): step form (#15531) --- bun.lock | 2 +- .../pieces/community/discord/package.json | 2 +- .../pieces/community/discord/src/index.ts | 10 +- .../src/lib/actions/add-role-to-member.ts | 10 +- .../src/lib/actions/ban-a-guild-member.ts | 23 ++-- .../discord/src/lib/actions/create-channel.ts | 10 +- .../src/lib/actions/create-guild-role.ts | 63 +++++++-- .../discord/src/lib/actions/delete-channel.ts | 27 +++- .../src/lib/actions/delete-guild-role.ts | 36 +++-- .../discord/src/lib/actions/find-channel.ts | 9 +- .../src/lib/actions/find-guild-member.ts | 111 +++++++++++---- .../src/lib/actions/remove-ban-from-user.ts | 23 ++-- .../lib/actions/remove-member-from-guild.ts | 10 +- .../lib/actions/remove-role-from-member.ts | 10 +- .../discord/src/lib/actions/rename-channel.ts | 11 +- .../src/lib/actions/send-approval-message.ts | 6 +- .../src/lib/actions/send-message-webhook.ts | 31 +++-- .../src/lib/actions/send-message-with-bot.ts | 11 +- .../pieces/community/discord/src/lib/auth.ts | 9 +- .../community/discord/src/lib/common/index.ts | 128 ++++++++---------- .../discord/src/lib/common/models.ts | 5 + .../discord/src/lib/triggers/new-member.ts | 22 +-- .../discord/src/lib/triggers/new-message.ts | 13 +- 23 files changed, 359 insertions(+), 223 deletions(-) diff --git a/bun.lock b/bun.lock index 7a7ceaab3ac8..b9b3210018cf 100644 --- a/bun.lock +++ b/bun.lock @@ -2622,7 +2622,7 @@ }, "packages/pieces/community/discord": { "name": "@activepieces/piece-discord", - "version": "0.6.0", + "version": "0.7.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/community/discord/package.json b/packages/pieces/community/discord/package.json index f28f8ebef69b..60e2e9a4d63a 100644 --- a/packages/pieces/community/discord/package.json +++ b/packages/pieces/community/discord/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-discord", - "version": "0.6.0", + "version": "0.7.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { diff --git a/packages/pieces/community/discord/src/index.ts b/packages/pieces/community/discord/src/index.ts index 63670e18526d..fb286d9e6eb8 100644 --- a/packages/pieces/community/discord/src/index.ts +++ b/packages/pieces/community/discord/src/index.ts @@ -66,18 +66,10 @@ import { discordRevokeInvite } from './lib/actions/ai-revoke-invite'; import { discordCreateDm } from './lib/actions/ai-create-dm'; import { discordGetUser } from './lib/actions/ai-get-user'; -const markdown = ` -To obtain a token, follow these steps: -1. Go to https://discord.com/developers/applications -2. Click on Application (or create one if you don't have one) -3. Click on Bot -4. Copy the token -`; - export const discord = createPiece({ displayName: 'Discord', description: 'Instant messaging and VoIP social platform', - minimumSupportedRelease: '0.87.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/discord.png', categories: [PieceCategory.COMMUNICATION], auth: discordAuth, diff --git a/packages/pieces/community/discord/src/lib/actions/add-role-to-member.ts b/packages/pieces/community/discord/src/lib/actions/add-role-to-member.ts index 5bfd11a2484d..286403ab5a78 100644 --- a/packages/pieces/community/discord/src/lib/actions/add-role-to-member.ts +++ b/packages/pieces/community/discord/src/lib/actions/add-role-to-member.ts @@ -12,23 +12,25 @@ export const discordAddRoleToMember = createAction({ auth: discordAuth, name: 'add_role_to_member', classification: 'WRITE', - description: 'Add Guild Member Role', + description: 'Give a member of a server a role.', audience: 'human', aiMetadata: { description: 'Assigns a role to a guild member, identified by guild ID, user ID, and role ID. Use to grant permissions or tag a user. Requires the bot to have Manage Roles and a higher role than the target; idempotent, since re-adding an already-assigned role leaves the member unchanged.', idempotent: true }, - displayName: 'Add role to member', + displayName: 'Add Role to Member', outputSchema: discordSuccessActionOutputSchema, props: { guild_id: discordCommon.guilds, user_id: Property.ShortText({ displayName: 'User ID', - description: 'The user id of the member', + description: + 'Right-click the member and choose Copy User ID in Developer Mode.', + placeholder: '123456789012345678', required: true, }), role_id: discordCommon.roles, }, async run(configValue) { - const request: HttpRequest = { + const request: HttpRequest = { method: HttpMethod.PUT, url: `https://discord.com/api/v9/guilds/${configValue.propsValue.guild_id}/members/${configValue.propsValue.user_id}/roles/${configValue.propsValue.role_id}`, headers: { diff --git a/packages/pieces/community/discord/src/lib/actions/ban-a-guild-member.ts b/packages/pieces/community/discord/src/lib/actions/ban-a-guild-member.ts index db89d497c88a..5f08ddc23bf5 100644 --- a/packages/pieces/community/discord/src/lib/actions/ban-a-guild-member.ts +++ b/packages/pieces/community/discord/src/lib/actions/ban-a-guild-member.ts @@ -12,37 +12,40 @@ export const discordBanGuildMember = createAction({ auth: discordAuth, name: 'ban_guild_member', classification: 'DESTRUCTIVE', - description: 'Bans a guild member', + description: 'Ban a user from a server so they cannot rejoin.', audience: 'both', aiMetadata: { description: 'Bans a user from a guild, identified by guild ID and user ID, with an optional audit-log reason; this removes them and blocks rejoining until unbanned. Use to permanently remove a disruptive user. Requires the bot to have Ban Members permission; idempotent, since re-banning an already-banned user yields the same end state.', idempotent: true }, outputSchema: discordSuccessActionOutputSchema, - displayName: 'Ban guild member', + displayName: 'Ban Member', props: { guild_id: discordCommon.guilds, user_id: Property.ShortText({ displayName: 'User ID', - description: 'The user id of the member', + description: + 'Right-click the member and choose Copy User ID in Developer Mode.', + placeholder: '123456789012345678', required: true, }), ban_reason: Property.ShortText({ - displayName: 'Ban Reason', - description: 'The reason for banning the member', + displayName: 'Reason', + description: 'Recorded in the server audit log.', required: false, + advanced: true, }), }, async run(configValue) { - const request: HttpRequest = { + const reason = configValue.propsValue.ban_reason; + + const request: HttpRequest = { method: HttpMethod.PUT, url: `https://discord.com/api/v9/guilds/${configValue.propsValue.guild_id}/bans/${configValue.propsValue.user_id}`, headers: { authorization: `Bot ${configValue.auth.secret_text}`, 'Content-Type': 'application/json', - 'X-Audit-Log-Reason': `${configValue.propsValue.ban_reason}`, - }, - body: { - reason: `${configValue.propsValue.ban_reason}`, + ...(reason ? { 'X-Audit-Log-Reason': reason } : {}), }, + body: reason ? { reason } : undefined, }; const res = await httpClient.sendRequest(request); diff --git a/packages/pieces/community/discord/src/lib/actions/create-channel.ts b/packages/pieces/community/discord/src/lib/actions/create-channel.ts index a6a6bab507fe..b796457618a9 100644 --- a/packages/pieces/community/discord/src/lib/actions/create-channel.ts +++ b/packages/pieces/community/discord/src/lib/actions/create-channel.ts @@ -12,22 +12,24 @@ export const discordCreateChannel = createAction({ auth: discordAuth, name: 'create_channel', classification: 'WRITE', - description: 'create a channel', + description: 'Create a text channel in a server.', audience: 'human', aiMetadata: { description: 'Creates a new channel in a guild with the given name and optional topic, identified by guild ID. Use to provision a channel before posting to it. Requires the bot to have Manage Channels permission; not idempotent, since each call creates a separate channel even with the same name.', idempotent: false }, - displayName: 'Create channel', + displayName: 'Create Channel', outputSchema: discordCreateChannelActionOutputSchema, props: { guild_id: discordCommon.guilds, name: Property.ShortText({ displayName: 'Name', - description: 'The name of the new channel', + description: 'Discord lowercases the name and turns spaces into dashes.', + placeholder: 'project-updates', required: true, }), topic: Property.LongText({ displayName: 'Topic', - description: 'The topic of the new channel', + description: 'Shown at the top of the channel.', required: false, + advanced: true, }), }, diff --git a/packages/pieces/community/discord/src/lib/actions/create-guild-role.ts b/packages/pieces/community/discord/src/lib/actions/create-guild-role.ts index 0eef423a3f15..910a5e1d150d 100644 --- a/packages/pieces/community/discord/src/lib/actions/create-guild-role.ts +++ b/packages/pieces/community/discord/src/lib/actions/create-guild-role.ts @@ -12,52 +12,59 @@ export const discordCreateGuildRole = createAction({ auth: discordAuth, name: 'createGuildRole', classification: 'WRITE', - displayName: 'Create guild role', - description: 'Creates a new role on the specified guild', + displayName: 'Create Role', + description: 'Create a role in a server.', audience: 'human', aiMetadata: { description: 'Creates a new role in a guild with the given name and optional color, hoist, and mentionable settings, identified by guild ID. Use to provision a role before assigning it to members. Requires the bot to have Manage Roles permission; not idempotent, since each call creates a separate role even with the same name.', idempotent: false }, outputSchema: discordRoleActionOutputSchema, props: { guild_id: discordCommon.guilds, role_name: Property.ShortText({ - displayName: 'Role Name', - description: 'The name of the role', + displayName: 'Name', + placeholder: 'Moderators', required: true, }), role_color: Property.ShortText({ - displayName: 'Role Color', - description: `The RGB color of the role (may be better to set manually on the server)`, + displayName: 'Color', + description: 'Hex like #5865F2 or a decimal RGB number.', + placeholder: '#5865F2', required: false, + advanced: true, }), display_separated: Property.Checkbox({ - displayName: 'Display Separated', - description: - 'Whether the role should be displayed separately in the sidebar', + displayName: 'Show Separately', + description: 'Lists members with this role in their own sidebar group.', required: false, + advanced: true, }), role_mentionable: Property.Checkbox({ displayName: 'Mentionable', - description: 'Whether the role can be mentioned by other users', + description: 'Lets anyone @mention this role.', required: false, + advanced: true, }), creation_reason: Property.ShortText({ - displayName: 'Creation Reason', - description: 'The reason for creating the role', + displayName: 'Reason', + description: 'Recorded in the server audit log.', required: false, + advanced: true, }), }, async run(configValue) { + const reason = configValue.propsValue.creation_reason; + const color = toColorInteger({ color: configValue.propsValue.role_color }); + const request: HttpRequest = { url: `https://discord.com/api/v9/guilds/${configValue.propsValue.guild_id}/roles`, method: HttpMethod.POST, headers: { authorization: `Bot ${configValue.auth.secret_text}`, 'Content-Type': 'application/json', - 'X-Audit-Log-Reason': `${configValue.propsValue.creation_reason}`, + ...(reason ? { 'X-Audit-Log-Reason': reason } : {}), }, body: { name: configValue.propsValue.role_name, - color: configValue.propsValue.role_color, + ...(color === undefined ? {} : { color }), hoist: configValue.propsValue.display_separated, mentionable: configValue.propsValue.role_mentionable, }, @@ -74,3 +81,31 @@ export const discordCreateGuildRole = createAction({ }; }, }); + +function toColorInteger({ + color, +}: { + color: string | undefined; +}): number | undefined { + const trimmed = color?.trim(); + if (!trimmed) { + return undefined; + } + + if (/^\d+$/.test(trimmed)) { + const decimal = Number(trimmed); + if (decimal > MAX_COLOR_VALUE) { + throw new Error(`Color must be between 0 and ${MAX_COLOR_VALUE}, received "${trimmed}".`); + } + return decimal; + } + + const hex = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed; + if (/^[0-9a-fA-F]{6}$/.test(hex)) { + return parseInt(hex, 16); + } + + throw new Error(`Color must be a hex value like #5865F2 or a decimal RGB number, received "${trimmed}".`); +} + +const MAX_COLOR_VALUE = 0xffffff; diff --git a/packages/pieces/community/discord/src/lib/actions/delete-channel.ts b/packages/pieces/community/discord/src/lib/actions/delete-channel.ts index b412fe0fa61c..29d9b593c032 100644 --- a/packages/pieces/community/discord/src/lib/actions/delete-channel.ts +++ b/packages/pieces/community/discord/src/lib/actions/delete-channel.ts @@ -12,11 +12,11 @@ export const discordDeleteChannel = createAction({ auth: discordAuth, name: 'delete_channel', classification: 'DESTRUCTIVE', - description: 'delete a channel', + description: 'Permanently delete a channel and all of its messages.', audience: 'human', aiMetadata: { description: 'Permanently deletes a channel, identified by channel ID. Use to remove a channel and its messages; this is destructive and cannot be undone. Requires the bot to have Manage Channels permission; idempotent in end state, since deleting an already-removed channel leaves it gone.', idempotent: true }, outputSchema: discordGetChannelActionOutputSchema, - displayName: 'Delete channel', + displayName: 'Delete Channel', props: { channel_id: discordCommon.channel, }, @@ -34,13 +34,28 @@ export const discordDeleteChannel = createAction({ try { const res = await httpClient.sendRequest(request); return res.body; - } catch (error: any) { - // Discord returns 404 (Unknown Channel, 10003) when the channel is - // already gone. Treat that as success so the action is idempotent. - if (error?.response?.status === 404) { + } catch (error) { + if (isResponseWithStatus(error) && error.response.status === 404) { return { success: true, alreadyAbsent: true }; } throw error; } }, }); + +function isResponseWithStatus(error: unknown): error is ResponseWithStatus { + return ( + typeof error === 'object' && + error !== null && + 'response' in error && + typeof error.response === 'object' && + error.response !== null && + 'status' in error.response + ); +} + +interface ResponseWithStatus { + response: { + status: number; + }; +} diff --git a/packages/pieces/community/discord/src/lib/actions/delete-guild-role.ts b/packages/pieces/community/discord/src/lib/actions/delete-guild-role.ts index 98dcb05840a7..0a6c248f8171 100644 --- a/packages/pieces/community/discord/src/lib/actions/delete-guild-role.ts +++ b/packages/pieces/community/discord/src/lib/actions/delete-guild-role.ts @@ -12,8 +12,8 @@ export const discordDeleteGuildRole = createAction({ auth: discordAuth, name: 'deleteGuildRole', classification: 'DESTRUCTIVE', - displayName: 'Delete guild role', - description: 'Deletes the specified role from the specified guild', + displayName: 'Delete Role', + description: 'Delete a role from a server.', audience: 'human', aiMetadata: { description: 'Permanently deletes a role from a guild, identified by guild ID and role ID, with an optional audit-log reason; the role is removed from all members. Use to remove an unwanted role. Requires the bot to have Manage Roles permission; idempotent in end state, since deleting an already-removed role leaves it gone.', idempotent: true }, outputSchema: discordSuccessWithAlreadyAbsentActionOutputSchema, @@ -21,19 +21,22 @@ export const discordDeleteGuildRole = createAction({ guild_id: discordCommon.guilds, role_id: discordCommon.roles, deletion_reason: Property.ShortText({ - displayName: 'Deletion reason', - description: 'The reason for deleting the role', + displayName: 'Reason', + description: 'Recorded in the server audit log.', required: false, + advanced: true, }), }, async run(configValue) { + const reason = configValue.propsValue.deletion_reason; + const request: HttpRequest = { url: `https://discord.com/api/v9/guilds/${configValue.propsValue.guild_id}/roles/${configValue.propsValue.role_id}`, method: HttpMethod.DELETE, headers: { Authorization: `Bot ${configValue.auth.secret_text}`, 'Content-Type': 'application/json', - 'X-Audit-Log-Reason': `${configValue.propsValue.deletion_reason}`, + ...(reason ? { 'X-Audit-Log-Reason': reason } : {}), }, }; @@ -42,13 +45,28 @@ export const discordDeleteGuildRole = createAction({ return { success: res.status === 204, }; - } catch (error: any) { - // Discord returns 404 (Unknown Role, 10011) when the role is already - // gone. Treat that as success so the action is idempotent. - if (error?.response?.status === 404) { + } catch (error) { + if (isResponseWithStatus(error) && error.response.status === 404) { return { success: true, alreadyAbsent: true }; } throw error; } }, }); + +function isResponseWithStatus(error: unknown): error is ResponseWithStatus { + return ( + typeof error === 'object' && + error !== null && + 'response' in error && + typeof error.response === 'object' && + error.response !== null && + 'status' in error.response + ); +} + +interface ResponseWithStatus { + response: { + status: number; + }; +} diff --git a/packages/pieces/community/discord/src/lib/actions/find-channel.ts b/packages/pieces/community/discord/src/lib/actions/find-channel.ts index 2994bcd4066e..f179b305b17f 100644 --- a/packages/pieces/community/discord/src/lib/actions/find-channel.ts +++ b/packages/pieces/community/discord/src/lib/actions/find-channel.ts @@ -13,22 +13,23 @@ export const discordFindChannel = createAction({ auth: discordAuth, name: 'find_channel', classification: 'SEARCH', - description: 'find a channel by name', + description: 'Look up a channel in a server by its exact name.', audience: 'human', aiMetadata: { description: 'Looks up a channel in a guild by its exact name and returns its channel ID, given the guild ID. Use to resolve a channel name into the ID required by message, rename, or delete actions. Read-only and idempotent; matching is exact and returns the first match.', idempotent: true }, - displayName: 'Find channel', + displayName: 'Find Channel', outputSchema: discordSuccessWithChannelIdActionOutputSchema, props: { guild_id: discordCommon.guilds, name: Property.ShortText({ displayName: 'Name', - description: 'The name of the channel', + description: 'Exact name without the leading #, case-sensitive.', + placeholder: 'general', required: true, }), }, async run(configValue) { - const request: HttpRequest = { + const request: HttpRequest = { method: HttpMethod.GET, url: `https://discord.com/api/v9/guilds/${configValue.propsValue.guild_id}/channels`, headers: { diff --git a/packages/pieces/community/discord/src/lib/actions/find-guild-member.ts b/packages/pieces/community/discord/src/lib/actions/find-guild-member.ts index 8f96a6f1714d..9753b18f3091 100644 --- a/packages/pieces/community/discord/src/lib/actions/find-guild-member.ts +++ b/packages/pieces/community/discord/src/lib/actions/find-guild-member.ts @@ -6,59 +6,110 @@ import { } from '@activepieces/pieces-common'; import { discordAuth } from '../auth'; import { discordCommon } from '../common'; -import { Member } from '../common/models'; import { discordListGuildMembersActionOutputSchema } from '../output-schemas'; export const discordFindGuildMemberByUsername = createAction({ auth: discordAuth, name: 'list_guild_members', classification: 'SEARCH', - description: 'List Guild Members', + description: 'List server members, optionally filtered by name.', audience: 'human', aiMetadata: { description: 'Lists members of a guild, returning their user IDs and usernames for the given guild ID. Use to look up a member ID before role, kick, or ban actions, or to enumerate who is in a server. Read-only and idempotent; requires the Server Members privileged intent to be enabled for the bot.', idempotent: true }, - displayName: 'List guild members', + displayName: 'List Members', outputSchema: discordListGuildMembersActionOutputSchema, props: { guild_id: discordCommon.guilds, shortText: Property.ShortText({ displayName: 'Search', - description: 'Search for a member', - required: true, + description: + 'Only members whose username or nickname contains this text.', + placeholder: 'jane', + required: false, }), }, async run(configValue) { - const request: HttpRequest = { - method: HttpMethod.GET, - url: `https://discord.com/api/v9/guilds/${configValue.propsValue.guild_id}/members`, - headers: { - authorization: `Bot ${configValue.auth.secret_text}`, - 'Content-Type': 'application/json', - }, - }; + const members = await fetchAllMembers({ + guildId: configValue.propsValue.guild_id, + token: configValue.auth.secret_text, + }); - const res = await httpClient.sendRequest(request); + const search = configValue.propsValue.shortText?.trim().toLowerCase() ?? ''; + const matching = + search.length === 0 + ? members + : members.filter((member) => matchesSearch({ member, search })); - const options: { options: { value: string; label: string }[] } = { - options: [], - }; - - if (res.body.length === 0) + if (matching.length === 0) return { disabled: true, options: [], - placeholder: 'No members found, please add the bot to a guild first', + placeholder: 'No members found', }; - await Promise.all( - res.body.map(async (member) => { - options.options.push({ - value: member.user.id, - label: member.user.username, - }); - }) - ); - - return options; + return { + options: matching.map((member) => ({ + value: member.user.id, + label: member.user.username, + })), + }; }, }); + +async function fetchAllMembers({ + guildId, + token, +}: { + guildId: string; + token: string; +}): Promise { + const pages: GuildMember[][] = []; + let after: string | undefined = undefined; + + do { + const request: HttpRequest = { + method: HttpMethod.GET, + url: `https://discord.com/api/v9/guilds/${guildId}/members`, + queryParams: { + limit: `${MEMBERS_PAGE_SIZE}`, + ...(after ? { after } : {}), + }, + headers: { + authorization: `Bot ${token}`, + 'Content-Type': 'application/json', + }, + }; + const res = await httpClient.sendRequest(request); + pages.push(res.body); + after = + res.body.length === MEMBERS_PAGE_SIZE + ? res.body[res.body.length - 1].user.id + : undefined; + } while (after !== undefined); + + return pages.flat(); +} + +function matchesSearch({ + member, + search, +}: { + member: GuildMember; + search: string; +}): boolean { + const haystack = [member.user.username, member.user.global_name, member.nick]; + return haystack.some( + (value) => typeof value === 'string' && value.toLowerCase().includes(search) + ); +} + +interface GuildMember { + user: { + id: string; + username: string; + global_name?: string | null; + }; + nick?: string | null; +} + +const MEMBERS_PAGE_SIZE = 1000; diff --git a/packages/pieces/community/discord/src/lib/actions/remove-ban-from-user.ts b/packages/pieces/community/discord/src/lib/actions/remove-ban-from-user.ts index aee4c088da3a..fcd2c8ae6e68 100644 --- a/packages/pieces/community/discord/src/lib/actions/remove-ban-from-user.ts +++ b/packages/pieces/community/discord/src/lib/actions/remove-ban-from-user.ts @@ -12,8 +12,8 @@ export const discordRemoveBanFromUser = createAction({ auth: discordAuth, name: 'remove_ban_from_user', classification: 'WRITE', - displayName: 'Remove ban from user', - description: 'Removes the guild ban from a user', + displayName: 'Remove Ban from User', + description: 'Lift a ban so the user can rejoin the server.', audience: 'both', aiMetadata: { description: 'Lifts a guild ban for a user, identified by guild ID and user ID, with an optional audit-log reason; the user may rejoin afterward. Use to reverse a previous ban. Requires the bot to have Ban Members permission; idempotent, since unbanning a user who is not banned yields the same end state.', idempotent: true }, outputSchema: discordSuccessActionOutputSchema, @@ -21,27 +21,30 @@ export const discordRemoveBanFromUser = createAction({ guild_id: discordCommon.guilds, user_id: Property.ShortText({ displayName: 'User ID', - description: 'The ID of the user', + description: + 'Right-click the member and choose Copy User ID in Developer Mode.', + placeholder: '123456789012345678', required: true, }), unban_reason: Property.ShortText({ - displayName: 'Unban Reason', - description: 'The reason for unbanning the user', + displayName: 'Reason', + description: 'Recorded in the server audit log.', required: false, + advanced: true, }), }, async run(configValue) { - const request: HttpRequest = { + const reason = configValue.propsValue.unban_reason; + + const request: HttpRequest = { method: HttpMethod.DELETE, url: `https://discord.com/api/v9/guilds/${configValue.propsValue.guild_id}/bans/${configValue.propsValue.user_id}`, headers: { authorization: `Bot ${configValue.auth.secret_text}`, 'Content-Type': 'application/json', - 'X-Audit-Log-Reason': `${configValue.propsValue.unban_reason}`, - }, - body: { - reason: `${configValue.propsValue.unban_reason}`, + ...(reason ? { 'X-Audit-Log-Reason': reason } : {}), }, + body: reason ? { reason } : undefined, }; const res = await httpClient.sendRequest(request); diff --git a/packages/pieces/community/discord/src/lib/actions/remove-member-from-guild.ts b/packages/pieces/community/discord/src/lib/actions/remove-member-from-guild.ts index bc87c54adf89..585619ec6076 100644 --- a/packages/pieces/community/discord/src/lib/actions/remove-member-from-guild.ts +++ b/packages/pieces/community/discord/src/lib/actions/remove-member-from-guild.ts @@ -12,22 +12,24 @@ export const discordRemoveMemberFromGuild = createAction({ auth: discordAuth, name: 'remove_member_from_guild', classification: 'DESTRUCTIVE', - description: 'Remove Guild Member', + description: 'Kick a member; they can rejoin with an invite.', audience: 'both', aiMetadata: { description: 'Kicks a member from a guild, identified by guild ID and user ID; the user may rejoin later via an invite. Use to remove someone without a permanent ban. Requires the bot to have Kick Members permission; idempotent, since removing an absent member yields the same end state.', idempotent: true }, outputSchema: discordSuccessActionOutputSchema, - displayName: 'Remove member from guild', + displayName: 'Remove Member from Server', props: { guild_id: discordCommon.guilds, user_id: Property.ShortText({ displayName: 'User ID', - description: 'The user id of the member', + description: + 'Right-click the member and choose Copy User ID in Developer Mode.', + placeholder: '123456789012345678', required: true, }), }, async run(configValue) { - const request: HttpRequest = { + const request: HttpRequest = { method: HttpMethod.DELETE, url: `https://discord.com/api/v9/guilds/${configValue.propsValue.guild_id}/members/${configValue.propsValue.user_id}`, headers: { diff --git a/packages/pieces/community/discord/src/lib/actions/remove-role-from-member.ts b/packages/pieces/community/discord/src/lib/actions/remove-role-from-member.ts index 73c89f2131f9..b339f9f69457 100644 --- a/packages/pieces/community/discord/src/lib/actions/remove-role-from-member.ts +++ b/packages/pieces/community/discord/src/lib/actions/remove-role-from-member.ts @@ -12,23 +12,25 @@ export const discordRemoveRoleFromMember = createAction({ auth: discordAuth, name: 'remove_role_from_member', classification: 'WRITE', - description: 'Remove Guild Member Role', + description: 'Take a role away from a member of a server.', audience: 'human', aiMetadata: { description: 'Removes a role from a guild member, identified by guild ID, user ID, and role ID. Use to revoke permissions or untag a user. Requires the bot to have Manage Roles and a higher role than the target; idempotent, since removing a role the member does not have leaves them unchanged.', idempotent: true }, - displayName: 'Remove role from member', + displayName: 'Remove Role from Member', outputSchema: discordSuccessActionOutputSchema, props: { guild_id: discordCommon.guilds, user_id: Property.ShortText({ displayName: 'User ID', - description: 'The user id of the member', + description: + 'Right-click the member and choose Copy User ID in Developer Mode.', + placeholder: '123456789012345678', required: true, }), role_id: discordCommon.roles, }, async run(configValue) { - const request: HttpRequest = { + const request: HttpRequest = { method: HttpMethod.DELETE, url: `https://discord.com/api/v9/guilds/${configValue.propsValue.guild_id}/members/${configValue.propsValue.user_id}/roles/${configValue.propsValue.role_id}`, headers: { diff --git a/packages/pieces/community/discord/src/lib/actions/rename-channel.ts b/packages/pieces/community/discord/src/lib/actions/rename-channel.ts index 0d28dde70097..6f9da63536d4 100644 --- a/packages/pieces/community/discord/src/lib/actions/rename-channel.ts +++ b/packages/pieces/community/discord/src/lib/actions/rename-channel.ts @@ -12,22 +12,23 @@ export const discordRenameChannel = createAction({ auth: discordAuth, name: 'rename_channel', classification: 'WRITE', - description: 'rename a channel', + description: 'Give a channel a new name.', audience: 'human', aiMetadata: { description: 'Renames an existing channel, identified by channel ID, to a new name. Use to update a channel title. Requires the bot to have Manage Channels permission; idempotent, since setting the same name repeatedly yields the same result.', idempotent: true }, - displayName: 'Rename channel', + displayName: 'Rename Channel', outputSchema: discordSuccessActionOutputSchema, props: { channel_id: discordCommon.channel, name: Property.ShortText({ displayName: 'Name', - description: 'The new name of the channel', + description: 'Discord lowercases the name and turns spaces into dashes.', + placeholder: 'project-updates', required: true, }), }, async run(configValue) { - const request: HttpRequest = { + const request: HttpRequest = { method: HttpMethod.PATCH, url: `https://discord.com/api/v9/channels/${configValue.propsValue.channel_id}`, body: { @@ -42,7 +43,7 @@ export const discordRenameChannel = createAction({ const res = await httpClient.sendRequest(request); return { - success: res.status === 204, + success: res.status >= 200 && res.status < 300, }; }, }); diff --git a/packages/pieces/community/discord/src/lib/actions/send-approval-message.ts b/packages/pieces/community/discord/src/lib/actions/send-approval-message.ts index 749d5a00358c..780bf949b558 100644 --- a/packages/pieces/community/discord/src/lib/actions/send-approval-message.ts +++ b/packages/pieces/community/discord/src/lib/actions/send-approval-message.ts @@ -14,18 +14,18 @@ export const discordSendApprovalMessage = createAction({ name: 'request_approval_message', classification: 'WRITE', description: - 'send a message to a channel asking for approval and wait for a response', + 'Post a message with a review button and pause until someone responds.', audience: 'both', aiMetadata: { description: 'Posts a message with a single button linking to a confirmation page where a human chooses Approve or Disapprove to a Discord channel, then pauses the flow until they respond and resumes with the decision. Use as a human-in-the-loop approval gate before an agent proceeds with a consequential step. Blocks until a response arrives; each call posts a new approval message, so it is not idempotent.', idempotent: false }, displayName: 'Request Approval in a Channel', outputSchema: discordSendApprovalMessageActionOutputSchema, props: { + channel: discordCommon.channel, content: Property.LongText({ displayName: 'Message', - description: 'The message you want to send', + description: 'Say what needs approval; a Review & Respond button is added below.', required: true, }), - channel: discordCommon.channel, }, async run(ctx) { if (ctx.executionType === ExecutionType.BEGIN) { diff --git a/packages/pieces/community/discord/src/lib/actions/send-message-webhook.ts b/packages/pieces/community/discord/src/lib/actions/send-message-webhook.ts index a9b4d285c3f3..029e2cac7a91 100644 --- a/packages/pieces/community/discord/src/lib/actions/send-message-webhook.ts +++ b/packages/pieces/community/discord/src/lib/actions/send-message-webhook.ts @@ -8,39 +8,48 @@ import { export const discordSendMessageWebhook = createAction({ name: 'send_message_webhook', classification: 'WRITE', - description: 'Send a discord message via webhook', + description: 'Post a message to a channel through a Discord webhook URL.', audience: 'both', aiMetadata: { description: 'Posts a message to a Discord channel through an incoming webhook URL, with optional custom username, avatar, and rich embeds. Use when you have a webhook URL rather than a bot connection; no Discord auth is needed. Each call posts a new message, so it is not idempotent.', idempotent: false }, - displayName: 'Send Message Webhook', + displayName: 'Send Message via Webhook', requireAuth: false, props: { webhook_url: Property.ShortText({ displayName: 'Webhook URL', + description: 'Server Settings, Integrations, Webhooks, then Copy Webhook URL.', + placeholder: 'https://discord.com/api/webhooks/…', required: true, }), - username: Property.ShortText({ - displayName: 'Name', - required: false, - }), content: Property.LongText({ displayName: 'Message', required: true, }), + username: Property.ShortText({ + displayName: 'Display Name', + description: 'Shown instead of the webhook name for this message.', + placeholder: 'Deploy Bot', + required: false, + advanced: true, + }), avatar_url: Property.ShortText({ displayName: 'Avatar URL', - description: 'The avatar url for webhook', + description: 'Shown instead of the webhook avatar for this message.', + placeholder: 'https://example.com/avatar.png', required: false, + advanced: true, }), embeds: Property.Json({ - displayName: 'embeds', - description: 'Embeds to send along with the message', + displayName: 'Embeds', + description: 'JSON array of Discord embed objects.', required: false, + advanced: true, defaultValue: [], }), tts: Property.Checkbox({ - displayName: 'Text to speech', - description: 'Robot reads the message', + displayName: 'Text to Speech', + description: 'Discord reads the message aloud in the channel.', required: false, + advanced: true, }), }, async run(configValue) { diff --git a/packages/pieces/community/discord/src/lib/actions/send-message-with-bot.ts b/packages/pieces/community/discord/src/lib/actions/send-message-with-bot.ts index db62120b8dcc..768718527cfb 100644 --- a/packages/pieces/community/discord/src/lib/actions/send-message-with-bot.ts +++ b/packages/pieces/community/discord/src/lib/actions/send-message-with-bot.ts @@ -19,7 +19,7 @@ export const sendMessageWithBot = createAction({ auth:discordAuth, displayName: 'Send Message with Bot', description: - 'Send messages via bot to any channel or thread you want, with an optional file attachment.', + 'Post a message and optional files to a channel or thread as the bot.', audience: 'human', aiMetadata: { description: 'Posts a new message (optionally with file attachments) to a Discord channel or thread using a bot token. Use when an agent needs to send bot-authored content to a known channel ID. Requires the bot to have access to that channel; each call posts a separate message, so it is not idempotent.', idempotent: false }, outputSchema: discordMessageOutputSchema, @@ -27,15 +27,15 @@ export const sendMessageWithBot = createAction({ channel_id: discordCommon.channel, message: Property.LongText({ displayName: 'Message', - description: 'Message content to send.', + description: 'Text of the message. Leave empty to send only files.', required: false, }), files: Property.Array({ displayName: 'Attachments', + description: 'Files uploaded with the message.', properties: { file: Property.File({ displayName: 'File', - description: 'Optional file to send with the message.', required: false, }), }, @@ -49,7 +49,9 @@ export const sendMessageWithBot = createAction({ const files = configValue.propsValue.files as FileObject[] ?? []; const formData = new FormData(); - formData.append('content', message); + if (typeof message === 'string' && message.length > 0) { + formData.append('content', message); + } if (files && files.length > 0) { files.forEach((fileObj, index) => { @@ -63,7 +65,6 @@ export const sendMessageWithBot = createAction({ url: `https://discord.com/api/v10/channels/${channelId}/messages`, headers: { authorization: `Bot ${configValue.auth.secret_text}`, - 'Content-Type': 'multipart/form-data', }, body: formData, }; diff --git a/packages/pieces/community/discord/src/lib/auth.ts b/packages/pieces/community/discord/src/lib/auth.ts index ee175ab1c9f3..fdc342e6817f 100644 --- a/packages/pieces/community/discord/src/lib/auth.ts +++ b/packages/pieces/community/discord/src/lib/auth.ts @@ -1,11 +1,10 @@ import { PieceAuth } from '@activepieces/pieces-framework'; const markdown = ` -To obtain a token, follow these steps: -1. Go to https://discord.com/developers/applications -2. Click on Application (or create one if you don't have one) -3. Click on Bot -4. Copy the token +1. Open https://discord.com/developers/applications and select or create an application. +2. In **Bot**, click **Reset Token** and copy the token. +3. Still in **Bot**, enable **Server Members Intent** and **Message Content Intent**. +4. In **OAuth2 → URL Generator**, pick the **bot** scope and the permissions you need, open the URL and add the bot to your server. `; export const discordAuth = PieceAuth.SecretText({ diff --git a/packages/pieces/community/discord/src/lib/common/index.ts b/packages/pieces/community/discord/src/lib/common/index.ts index e8dc5cc36cf3..3375116c2c36 100644 --- a/packages/pieces/community/discord/src/lib/common/index.ts +++ b/packages/pieces/community/discord/src/lib/common/index.ts @@ -1,20 +1,13 @@ import { HttpMethod, httpClient } from '@activepieces/pieces-common'; -import { Channel, Guild } from '../common/models'; +import { Channel, Guild, Role } from '../common/models'; import { Property } from '@activepieces/pieces-framework'; import { discordAuth } from '../auth'; -export interface Member { - user: { - id: string; - username: string; - }; -} - export const discordCommon = { channel: Property.Dropdown({ auth: discordAuth, displayName: 'Channel', - description: 'List of channels', + description: 'Channels from every server the bot has joined.', required: true, refreshers: [], options: async ({ auth }) => { @@ -22,7 +15,7 @@ export const discordCommon = { return { disabled: true, options: [], - placeholder: 'Please connect your bot first', + placeholder: 'Connect your bot first', }; } @@ -35,46 +28,52 @@ export const discordCommon = { }; const res = await httpClient.sendRequest(request); - const options: { options: { value: string; label: string }[] } = { - options: [], - }; if (res.body.length === 0) return { disabled: true, options: [], - placeholder: 'No guilds found, please add the bot to a guild first', + placeholder: 'No servers found, add the bot to a server first', }; - await Promise.all( - res.body.map(async (guild) => { - const requestChannels = { - method: HttpMethod.GET, - url: 'https://discord.com/api/v9/guilds/' + guild.id + '/channels', - headers: { - Authorization: 'Bot ' + auth.secret_text, - }, - }; - - const resChannels = await httpClient.sendRequest( - requestChannels - ); - resChannels.body.forEach((channel) => { - options.options.push({ + const options = ( + await Promise.all( + res.body.map(async (guild) => { + const requestChannels = { + method: HttpMethod.GET, + url: + 'https://discord.com/api/v9/guilds/' + guild.id + '/channels', + headers: { + Authorization: 'Bot ' + auth.secret_text, + }, + }; + + const resChannels = await httpClient.sendRequest( + requestChannels + ); + + return resChannels.body.map((channel) => ({ value: channel.id, - label: channel.name, - }); - }); - }) - ); + label: `${channel.name} (${guild.name})`, + })); + }) + ) + ).flat(); + + if (options.length === 0) + return { + disabled: true, + options: [], + placeholder: 'No channels found in the servers the bot has joined', + }; - return options; + return { options }; }, }), roles: Property.Dropdown({ auth: discordAuth, - displayName: 'Roles', - description: 'List of roles', + displayName: 'Role', + description: 'Roles of the selected server.', required: true, refreshers: ['guild_id'], options: async ({ auth, guild_id }) => { @@ -82,7 +81,7 @@ export const discordCommon = { return { disabled: true, options: [], - placeholder: 'Please connect your bot first', + placeholder: 'Connect your bot first', }; } @@ -90,7 +89,7 @@ export const discordCommon = { return { disabled: true, options: [], - placeholder: 'Please select a guild first', + placeholder: 'Select a server first', }; } @@ -102,35 +101,27 @@ export const discordCommon = { }, }; - const res = await httpClient.sendRequest(request); - - const options: { options: { value: string; label: string }[] } = { - options: [], - }; + const res = await httpClient.sendRequest(request); if (res.body.length === 0) return { disabled: true, options: [], - placeholder: 'No roles found, please add the bot to a guild first', + placeholder: 'No roles found in this server', }; - await Promise.all( - res.body.map(async (role) => { - options.options.push({ - value: role.id, - label: role.name, - }); - }) - ); - - return options; + return { + options: res.body.map((role) => ({ + value: role.id, + label: role.name, + })), + }; }, }), guilds: Property.Dropdown({ auth: discordAuth, - displayName: 'Guilds', - description: 'List of guilds', + displayName: 'Server', + description: 'Only servers the bot has been added to are listed.', required: true, refreshers: [], options: async ({ auth }) => { @@ -138,7 +129,7 @@ export const discordCommon = { return { disabled: true, options: [], - placeholder: 'Please connect your bot first', + placeholder: 'Connect your bot first', }; } @@ -151,27 +142,20 @@ export const discordCommon = { }; const res = await httpClient.sendRequest(request); - const options: { options: { value: string; label: string }[] } = { - options: [], - }; if (res.body.length === 0) return { disabled: true, options: [], - placeholder: 'No guilds found, please add the bot to a guild first', + placeholder: 'No servers found, add the bot to a server first', }; - await Promise.all( - res.body.map(async (guild) => { - options.options.push({ - value: guild.id, - label: guild.name, - }); - }) - ); - - return options; + return { + options: res.body.map((guild) => ({ + value: guild.id, + label: guild.name, + })), + }; }, }), }; diff --git a/packages/pieces/community/discord/src/lib/common/models.ts b/packages/pieces/community/discord/src/lib/common/models.ts index f9714ba03abb..baadeb2c72e1 100644 --- a/packages/pieces/community/discord/src/lib/common/models.ts +++ b/packages/pieces/community/discord/src/lib/common/models.ts @@ -19,6 +19,11 @@ export interface Member { }; } +export interface Role { + id: string; + name: string; +} + export interface Message { id: string; type: number; diff --git a/packages/pieces/community/discord/src/lib/triggers/new-member.ts b/packages/pieces/community/discord/src/lib/triggers/new-member.ts index 03f0b6d330c6..c1f7ea001eee 100644 --- a/packages/pieces/community/discord/src/lib/triggers/new-member.ts +++ b/packages/pieces/community/discord/src/lib/triggers/new-member.ts @@ -14,7 +14,6 @@ import { } from '@activepieces/pieces-framework'; import dayjs from 'dayjs'; import { discordAuth } from '../auth'; -import { discordCommon } from '../common'; import { discordNewMemberTriggerOutputSchema } from '../output-schemas'; interface Member { @@ -56,23 +55,30 @@ export const newMember = createTrigger({ name: 'new_member', classification: 'READ', displayName: 'New Member', - description: 'Triggers when a new member joins a guild', + description: 'Fires when a member joins a server.', aiMetadata: { description: 'Fires when a new member joins the specified Discord guild (server), emitting one event per joining member with their user details. Polls the guild member list periodically, so detection is near-real-time rather than instant.', }, type: TriggerStrategy.POLLING, outputSchema: discordNewMemberTriggerOutputSchema, props: { + guildId: Property.ShortText({ + displayName: 'Server ID', + description: + 'Right-click the server and choose Copy Server ID in Developer Mode.', + placeholder: '123456789012345678', + required: true, + }), limit: Property.Number({ displayName: 'Limit', - description: 'The number of members to fetch (max 1000)', + description: 'Most recent members checked each poll, up to 1000.', required: false, defaultValue: 50, - }), - guildId: Property.ShortText({ - displayName: 'Guild ID', - description: 'The ID of the Discord guild (server)', - required: true, + display: 'stepper', + min: 1, + max: 1000, + step: 1, + advanced: true, }), }, sampleData: {}, diff --git a/packages/pieces/community/discord/src/lib/triggers/new-message.ts b/packages/pieces/community/discord/src/lib/triggers/new-message.ts index 846e01101bef..7137452344ae 100644 --- a/packages/pieces/community/discord/src/lib/triggers/new-message.ts +++ b/packages/pieces/community/discord/src/lib/triggers/new-message.ts @@ -51,21 +51,26 @@ export const newMessage = createTrigger({ auth: discordAuth, name: 'new_message', classification: 'READ', - displayName: 'New message', - description: 'Triggers when a message is sent in a channel', + displayName: 'New Message', + description: 'Fires when a message is posted in a channel.', aiMetadata: { description: 'Fires when a new message is posted in the selected Discord channel, emitting one event per message. Polls the channel periodically, so detection is near-real-time rather than instant.', }, type: TriggerStrategy.POLLING, outputSchema: discordMessageOutputSchema, props: { + channel: discordCommon.channel, limit: Property.Number({ displayName: 'Limit', - description: 'The number of messages to fetch', + description: 'Most recent messages checked each poll, up to 100.', required: false, defaultValue: 50, + display: 'stepper', + min: 1, + max: 100, + step: 1, + advanced: true, }), - channel: discordCommon.channel, }, sampleData: {}, onEnable: async (context) => { From ba6b0288a23fdc378108379bb5ad49c3fed5a506 Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Wed, 23 Sep 2026 01:41:45 +0300 Subject: [PATCH 3/7] feat(pdf): step form (#15532) --- bun.lock | 2 +- packages/pieces/core/pdf/package.json | 2 +- packages/pieces/core/pdf/src/index.ts | 2 +- .../pdf/src/lib/actions/add-image-to-pdf.ts | 29 ++--- .../pdf/src/lib/actions/add-text-to-pdf.ts | 26 ++--- .../pdf/src/lib/actions/convert-to-image.ts | 6 +- .../pdf/src/lib/actions/extract-pdf-pages.ts | 23 ++-- .../core/pdf/src/lib/actions/extract-text.ts | 3 +- .../core/pdf/src/lib/actions/image-to-pdf.ts | 10 +- .../core/pdf/src/lib/actions/merge-pdfs.ts | 7 +- .../pdf/src/lib/actions/pdf-page-count.ts | 3 +- .../core/pdf/src/lib/actions/text-to-pdf.ts | 6 +- packages/pieces/core/pdf/src/lib/common.ts | 2 +- .../core/pdf/test/convert-to-image.test.ts | 101 ++++++++++++++++++ .../core/pdf/test/extract-pdf-pages.test.ts | 22 ++++ 15 files changed, 187 insertions(+), 57 deletions(-) create mode 100644 packages/pieces/core/pdf/test/convert-to-image.test.ts diff --git a/bun.lock b/bun.lock index b9b3210018cf..77f33076c0e1 100644 --- a/bun.lock +++ b/bun.lock @@ -10508,7 +10508,7 @@ }, "packages/pieces/core/pdf": { "name": "@activepieces/piece-pdf", - "version": "0.5.12", + "version": "0.6.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/core/pdf/package.json b/packages/pieces/core/pdf/package.json index 05d7e27999bb..bb6d2482b3e8 100644 --- a/packages/pieces/core/pdf/package.json +++ b/packages/pieces/core/pdf/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-pdf", - "version": "0.5.12", + "version": "0.6.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/core/pdf/src/index.ts b/packages/pieces/core/pdf/src/index.ts index 08979a2b2826..2ee368665dac 100644 --- a/packages/pieces/core/pdf/src/index.ts +++ b/packages/pieces/core/pdf/src/index.ts @@ -13,7 +13,7 @@ import { addImageToPdf } from './lib/actions/add-image-to-pdf'; export const PDF = createPiece({ displayName: 'PDF', auth: PieceAuth.None(), - minimumSupportedRelease: '0.34.2', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/pdf.svg', authors: [ 'nyamkamunhjin', diff --git a/packages/pieces/core/pdf/src/lib/actions/add-image-to-pdf.ts b/packages/pieces/core/pdf/src/lib/actions/add-image-to-pdf.ts index 1af08e274258..e2a92de42b96 100644 --- a/packages/pieces/core/pdf/src/lib/actions/add-image-to-pdf.ts +++ b/packages/pieces/core/pdf/src/lib/actions/add-image-to-pdf.ts @@ -9,49 +9,50 @@ export const addImageToPdf = createAction({ name: 'addImageToPdf', classification: 'READ', displayName: 'Add Image to PDF', - description: 'Stamps one or more images (PNG or JPG) at exact pixel distances from the top-left corner.', + description: 'Stamp one or more images onto pages of an existing PDF.', aiMetadata: { description: 'Stamps one or more PNG or JPEG images onto an existing PDF at exact point offsets from the top-left corner, each item targeting either a single page or every page. Use it for signatures, logos or watermarks — prefer Add Text to PDF for text, and Image to PDF to turn an image into a standalone document. Each image must have a positive scale and fit entirely within the page bounds, otherwise the call fails; the input file is never modified and repeating the call produces the same stamped content, so idempotent.', idempotent: true }, outputSchema: addImageToPdfActionOutputSchema, props: { file: Property.File({ displayName: 'PDF File or URL', + placeholder: 'https://example.com/document.pdf', required: true, }), imageItems: Property.Array({ - displayName: 'Image Items to Insert', - description: 'Add each image you want to stamp.', + displayName: 'Images to Stamp', + description: 'One item per image. Each can target one page or all pages.', required: true, properties: { imageFile: Property.File({ - displayName: 'Image File (PNG/JPG)', - description: 'The signature or image file to insert.', + displayName: 'Image File or URL', + description: 'PNG or JPEG.', required: true, }), applyToAllPages: Property.Checkbox({ - displayName: 'Apply to all pages?', - description: 'If checked, this image is stamped on every page.', + displayName: 'Apply to All Pages', + description: 'Stamp this item on every page and ignore Page Number.', required: false, defaultValue: false, }), pageNumber: Property.Number({ displayName: 'Page Number', - description: 'Which page to stamp? (Leave blank or ignore if applying to all pages)', + description: 'Pages start at 1. Ignored when Apply to All Pages is on.', required: false, defaultValue: 1, }), distanceFromLeft: Property.Number({ - displayName: 'Distance from Left Edge (in pixels)', - description: '0 is the far left edge of the page. Standard A4 width is about 595 pts.', + displayName: 'Distance from Left', + description: 'In points from the left edge. An A4 page is 595 points wide.', required: true, }), distanceFromTop: Property.Number({ - displayName: 'Distance from Top Edge (in pixels)', - description: '0 is the very top edge of the page. Standard A4 height is about 842 pts.', + displayName: 'Distance from Top', + description: 'In points from the top edge. An A4 page is 842 points tall.', required: true, }), scale: Property.Number({ - displayName: 'Image Scale', - description: 'Scale multiplier for the image (e.g., 0.5 for half size, 1.0 for original size).', + displayName: 'Scale', + description: '1 keeps the original size, 0.5 halves it.', required: true, defaultValue: 1.0, }), diff --git a/packages/pieces/core/pdf/src/lib/actions/add-text-to-pdf.ts b/packages/pieces/core/pdf/src/lib/actions/add-text-to-pdf.ts index 16fd8fbc69da..8913eda0b649 100644 --- a/packages/pieces/core/pdf/src/lib/actions/add-text-to-pdf.ts +++ b/packages/pieces/core/pdf/src/lib/actions/add-text-to-pdf.ts @@ -13,17 +13,18 @@ export const addTextToPdf = createAction({ name: 'addTextToPdf', classification: 'READ', displayName: 'Add Text to PDF', - description: 'Stamps one or more text strings at exact pixel distances from the top-left corner.', + description: 'Stamp one or more text strings onto pages of an existing PDF.', aiMetadata: { description: 'Stamps one or more text strings onto an existing PDF at exact point offsets from the top-left corner, each item targeting either a single page or every page. Use it for headers, dates or reference numbers — prefer Add Image to PDF for signatures and logos, and Text to PDF to build a document from scratch. Only the 14 standard PDF fonts are available and the call fails if any text would run off a page edge; the input file is never modified and repeating the call produces the same stamped content, so idempotent.', idempotent: true }, outputSchema: addTextToPdfActionOutputSchema, props: { file: Property.File({ displayName: 'PDF File or URL', + placeholder: 'https://example.com/document.pdf', required: true, }), textItems: Property.Array({ - displayName: 'Text Items to Insert', - description: 'Add each piece of text you want to stamp.', + displayName: 'Text to Stamp', + description: 'One item per text block. Each can target one page or all pages.', required: true, properties: { text: Property.LongText({ @@ -31,30 +32,30 @@ export const addTextToPdf = createAction({ required: true, }), applyToAllPages: Property.Checkbox({ - displayName: 'Apply to all pages?', - description: 'If checked, this text is stamped on every page.', + displayName: 'Apply to All Pages', + description: 'Stamp this item on every page and ignore Page Number.', required: false, defaultValue: false, }), pageNumber: Property.Number({ displayName: 'Page Number', - description: 'Which page to stamp? (Leave blank or ignore if applying to all pages)', + description: 'Pages start at 1. Ignored when Apply to All Pages is on.', required: false, defaultValue: 1, }), distanceFromLeft: Property.Number({ - displayName: 'Distance from Left Edge (in pixels)', - description: '0 is the far left edge of the page. Standard A4 width is about 595 pts.', + displayName: 'Distance from Left', + description: 'In points from the left edge. An A4 page is 595 points wide.', required: true, }), distanceFromTop: Property.Number({ - displayName: 'Distance from Top Edge (in pixels)', - description: '0 is the very top edge of the page. Standard A4 height is about 842 pts.', + displayName: 'Distance from Top', + description: 'In points from the top edge. An A4 page is 842 points tall.', required: true, }), font: Property.StaticDropdown({ displayName: 'Font', - description: 'Select the exact font variant for this text item.', + description: 'One of the 14 standard PDF fonts.', required: true, defaultValue: StandardFonts.Helvetica, options: { @@ -63,12 +64,13 @@ export const addTextToPdf = createAction({ }), fontSize: Property.Number({ displayName: 'Font Size', + description: 'In points.', required: true, defaultValue: 11, }), lineSpacing: Property.Number({ displayName: 'Line Spacing', - description: 'The vertical spacing multiplier between lines. (Examples: 1.0 = Single, 1.15 = Standard, 2.0 = Double)', + description: 'Multiplier between lines: 1 single, 1.15 standard, 2 double.', required: false, defaultValue: 1.15, }), diff --git a/packages/pieces/core/pdf/src/lib/actions/convert-to-image.ts b/packages/pieces/core/pdf/src/lib/actions/convert-to-image.ts index c0b6ec937a8f..016323dcb6e8 100644 --- a/packages/pieces/core/pdf/src/lib/actions/convert-to-image.ts +++ b/packages/pieces/core/pdf/src/lib/actions/convert-to-image.ts @@ -37,7 +37,7 @@ async function convertPdfToImages(dataBuffer: Buffer): Promise { const files = await fs.readdir(outputDir); const imageBuffers = []; - for (const file of files) { + for (const file of [...files].sort()) { const filePath = join(outputDir, file); const imageBuffer = await fs.readFile(filePath); await fs.unlink(filePath); @@ -72,16 +72,18 @@ export const convertToImage = createAction({ name: 'convertToImage', classification: 'READ', displayName: 'Convert to Image', - description: 'Convert a PDF file or URL to an image', + description: 'Render a PDF file or URL as PNG images.', aiMetadata: { description: 'Rasterizes a PDF to PNG images, either as one tall combined image or as a separate image per page (selected via Output Image Type). Pick this when a downstream vision or OCR step needs pixels rather than a text layer; prefer Extract Text for text-based PDFs and Image to PDF for the reverse direction. Requires the pdftoppm binary on the worker and a PDF no larger than 16 MB; the render is deterministic, so idempotent.', idempotent: true }, outputSchema: convertToImageActionOutputSchema, props: { file: Property.File({ displayName: 'PDF File or URL', required: true, + placeholder: 'https://example.com/document.pdf', }), imageOutputType: Property.StaticDropdown({ displayName: 'Output Image Type', + description: 'One tall image of all pages, or one image per page.', required: true, options: { options: [ diff --git a/packages/pieces/core/pdf/src/lib/actions/extract-pdf-pages.ts b/packages/pieces/core/pdf/src/lib/actions/extract-pdf-pages.ts index c34bf6b3f951..0ad645e7c43e 100644 --- a/packages/pieces/core/pdf/src/lib/actions/extract-pdf-pages.ts +++ b/packages/pieces/core/pdf/src/lib/actions/extract-pdf-pages.ts @@ -1,6 +1,5 @@ -import { createAction, Property } from '@activepieces/pieces-framework'; +import { createAction, MarkdownVariant, Property } from '@activepieces/pieces-framework'; import { PDFDocument } from 'pdf-lib'; -import { MarkdownVariant } from '@activepieces/pieces-framework'; import { extractPdfPagesActionOutputSchema } from '../output-schemas'; export function pageRangeToIndexes( @@ -18,7 +17,7 @@ export function pageRangeToIndexes( throw Error('Range start/end has to be a non-zero number'); } - if (startPage > totalPages || endPage > totalPages) { + if (Math.abs(startPage) > totalPages || Math.abs(endPage) > totalPages) { throw Error( 'Range start/end has to be less or equal to the total number of pages' ); @@ -49,15 +48,11 @@ export function pageRangeToIndexes( } const markdownValue = ` -This action can extract or rearrange the pages in a PDF. +Ranges are copied in the order listed, so pages can be reordered. -- The order of array determines the sequence of pages. -- Each array element is one inclusive continuous range with a start page and end page. -- Pages start from 1, and 0 is not valid. -- Start page has to be less than end page. -- You can select one page by setting the same start and end page. -- To select pages from the start, specify negative pages eg. -1 is the last page, -5 is the 5th last page. start: -5, end: -1 are the last 5 pages. -- Range cannot span across 0 eg. start: -3, end: 5. +- Pages start at 1. Start and end are inclusive; use the same number for one page. +- Negative numbers count from the end: -1 is the last page, -5 to -1 the last five. +- A range cannot cross 0. `; export const extractPdfPages = createAction({ @@ -65,7 +60,7 @@ export const extractPdfPages = createAction({ name: 'extractPdfPages', classification: 'READ', displayName: 'Extract PDF Pages', - description: 'Extract or rearrange page(s)from PDF File.', + description: 'Copy page ranges from a PDF into a new file, in the order given.', aiMetadata: { description: 'Builds a new PDF from page ranges taken from a source PDF; because ranges are copied in the order given, it both extracts/trims and reorders pages. Use it to split or resequence one document — use Merge PDFs to join separate files, and PDF Page Count first if bounds are unknown. Ranges are 1-indexed and inclusive, negatives count from the end (start -5, end -1 is the last five pages), and no range may span across 0; the source file is untouched and repeating the call produces the same page content, so idempotent.', idempotent: true }, outputSchema: extractPdfPagesActionOutputSchema, props: { @@ -76,16 +71,20 @@ export const extractPdfPages = createAction({ file: Property.File({ displayName: 'PDF File or URL', required: true, + placeholder: 'https://example.com/document.pdf', }), pageRanges: Property.Array({ displayName: 'Page Ranges', + description: 'One item per range. Order here is the page order in the output.', properties: { startPage: Property.Number({ displayName: 'Start Page', + description: 'First page of the range. Pages start at 1.', required: true, }), endPage: Property.Number({ displayName: 'End Page', + description: 'Last page of the range, inclusive.', required: true, }), }, diff --git a/packages/pieces/core/pdf/src/lib/actions/extract-text.ts b/packages/pieces/core/pdf/src/lib/actions/extract-text.ts index 0b8f8cfb5874..3bb4e8763de7 100644 --- a/packages/pieces/core/pdf/src/lib/actions/extract-text.ts +++ b/packages/pieces/core/pdf/src/lib/actions/extract-text.ts @@ -7,12 +7,13 @@ export const extractText = createAction({ name: 'extractText', classification: 'READ', displayName: 'Extract Text', - description: 'Extract text from PDF file or url', + description: 'Extract the text layer from a PDF file or URL.', aiMetadata: { description: 'Extracts the text layer of a PDF supplied as an uploaded file or a URL, merging every page into a single string. Use this to read a text-based PDF before parsing, classifying or summarizing it; for a scanned/rasterized PDF use Convert to Image plus an OCR or vision step instead, and use PDF Page Count when only the document length matters. Requires a loadable PDF and returns nothing useful when the pages are just embedded scan images; read-only and idempotent.', idempotent: true }, outputSchema: extractTextActionOutputSchema, props: { file: Property.File({ displayName: 'PDF File or URL', + placeholder: 'https://example.com/document.pdf', required: true, }), }, diff --git a/packages/pieces/core/pdf/src/lib/actions/image-to-pdf.ts b/packages/pieces/core/pdf/src/lib/actions/image-to-pdf.ts index 38d8663cfda0..470455f7e5f8 100644 --- a/packages/pieces/core/pdf/src/lib/actions/image-to-pdf.ts +++ b/packages/pieces/core/pdf/src/lib/actions/image-to-pdf.ts @@ -7,14 +7,14 @@ export const imageToPdf = createAction({ name: 'imageToPdf', classification: 'READ', displayName: 'Image to PDF', - description: 'Convert image to PDF', + description: 'Wrap a PNG or JPEG image in a one-page A4 PDF.', aiMetadata: { description: 'Wraps a single PNG or JPEG image into a new one-page A4 PDF, scaling it to fit inside the margins and correcting the EXIF orientation. Use it to make an image attachable or printable as a document; use Add Image to PDF to stamp an image onto an existing PDF, and Merge PDFs to combine several single-image results. Accepts one image per call and only the png/jpg/jpeg extensions; layout and page size are fixed and repeating the call produces the same document content, so idempotent.', idempotent: true }, outputSchema: imageToPdfActionOutputSchema, props: { image: Property.File({ - displayName: 'image', - description: - 'Image has to be png, jpeg or jpg and it will be scaled down to fit the page when image is larger than an A4 page', + displayName: 'Image File or URL', + description: 'PNG or JPEG. Large images are scaled down to fit the page.', + placeholder: 'https://example.com/photo.png', required: true, }), }, @@ -118,7 +118,7 @@ export const imageToPdf = createAction({ fileName: `${image.filename}.pdf`, }); } catch (error) { - throw new Error(`Failed to convert text to PDF: ${(error as Error).message}`); + throw new Error(`Failed to convert image to PDF: ${(error as Error).message}`); } }, }); diff --git a/packages/pieces/core/pdf/src/lib/actions/merge-pdfs.ts b/packages/pieces/core/pdf/src/lib/actions/merge-pdfs.ts index 0e9cd2cf9582..e56251ac252e 100644 --- a/packages/pieces/core/pdf/src/lib/actions/merge-pdfs.ts +++ b/packages/pieces/core/pdf/src/lib/actions/merge-pdfs.ts @@ -7,13 +7,13 @@ export const mergePdfs = createAction({ name: 'mergePdfs', classification: 'READ', displayName: 'Merge PDFs', - description: 'Merges multiple PDF files into a single PDF document.', + description: 'Merge two or more PDF files into one document.', aiMetadata: { description: 'Concatenates two or more PDF files into one new PDF, keeping the order in which the files are listed. Use it to join separate documents; use Extract PDF Pages to pull out or resequence pages inside a single document, and Image to PDF first if a source is an image rather than a PDF. Requires at least 2 files and each filename must end in .pdf; the source files are never modified and repeating the call produces the same merged content, so idempotent.', idempotent: true }, outputSchema: mergePdfsActionOutputSchema, props: { pdfFiles: Property.Array({ displayName: 'PDF Files', - description: 'Array of PDF files to merge', + description: 'Add the files in the order they should appear.', required: true, properties: { file: Property.File({ @@ -24,9 +24,10 @@ export const mergePdfs = createAction({ }), outputFileName: Property.ShortText({ displayName: 'Output File Name', - description: 'Name for the merged PDF file (without extension)', + description: 'File name for the merged PDF, without the extension.', required: false, defaultValue: 'merged-document', + advanced: true, }), }, async run(context) { diff --git a/packages/pieces/core/pdf/src/lib/actions/pdf-page-count.ts b/packages/pieces/core/pdf/src/lib/actions/pdf-page-count.ts index d2810b0dc831..7df20e008662 100644 --- a/packages/pieces/core/pdf/src/lib/actions/pdf-page-count.ts +++ b/packages/pieces/core/pdf/src/lib/actions/pdf-page-count.ts @@ -7,12 +7,13 @@ export const pdfPageCount = createAction({ name: 'pdfPageCount', classification: 'READ', displayName: 'PDF Page Count', - description: 'Get page count of PDF file.', + description: 'Count the pages in a PDF file or URL.', aiMetadata: { description: 'Returns the number of pages in a PDF given as an uploaded file or a URL. Use it as a cheap pre-check before any paging, splitting or looping logic — Extract PDF Pages performs the actual splitting and Extract Text pulls the content. Requires a loadable, non-encrypted PDF; read-only and idempotent.', idempotent: true }, outputSchema: pdfPageCountActionOutputSchema, props: { file: Property.File({ displayName: 'PDF File or URL', + placeholder: 'https://example.com/document.pdf', required: true, }), }, diff --git a/packages/pieces/core/pdf/src/lib/actions/text-to-pdf.ts b/packages/pieces/core/pdf/src/lib/actions/text-to-pdf.ts index 4174435be66a..492350d373cb 100644 --- a/packages/pieces/core/pdf/src/lib/actions/text-to-pdf.ts +++ b/packages/pieces/core/pdf/src/lib/actions/text-to-pdf.ts @@ -7,13 +7,13 @@ export const textToPdf = createAction({ name: 'textToPdf', classification: 'READ', displayName: 'Text to PDF', - description: 'Convert text to PDF', + description: 'Create a new A4 PDF from plain text.', aiMetadata: { description: 'Renders a plain-text string into a brand-new A4 PDF, word-wrapping and paginating automatically at a fixed Helvetica 12pt layout. Use it to turn text into an attachable document; use Add Text to PDF to stamp text onto an existing PDF instead, and Image to PDF when the source is an image. Text is the only input and newlines are treated as paragraph breaks; the same text always produces the same document content, so idempotent.', idempotent: true }, outputSchema: textToPdfActionOutputSchema, props: { text: Property.LongText({ - displayName: 'text', - description: 'Enter text to convert', + displayName: 'Text', + description: 'Line breaks start a new paragraph. Helvetica 12 pt on A4.', required: true, }), }, diff --git a/packages/pieces/core/pdf/src/lib/common.ts b/packages/pieces/core/pdf/src/lib/common.ts index 16425d7b8a96..d7539780d7ae 100644 --- a/packages/pieces/core/pdf/src/lib/common.ts +++ b/packages/pieces/core/pdf/src/lib/common.ts @@ -16,7 +16,7 @@ export function getTargetPages( } if (pageNumber === undefined) { - throw new Error(`Page Number is required when "Apply to all pages?" is not checked for ${itemName}.`); + throw new Error(`Page Number is required when "Apply to All Pages" is not checked for ${itemName}.`); } const pageIndex = Number(pageNumber) - 1; diff --git a/packages/pieces/core/pdf/test/convert-to-image.test.ts b/packages/pieces/core/pdf/test/convert-to-image.test.ts new file mode 100644 index 000000000000..9a599cc2d1e4 --- /dev/null +++ b/packages/pieces/core/pdf/test/convert-to-image.test.ts @@ -0,0 +1,101 @@ +import { basename } from 'path'; +import Jimp from 'jimp'; + +const renderedFiles = vi.hoisted(() => new Map()); + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { + ...actual, + promises: { + ...actual.promises, + mkdir: async () => undefined, + writeFile: async () => undefined, + readdir: async () => [...renderedFiles.keys()], + readFile: async (filePath: string) => renderedFiles.get(basename(filePath)), + unlink: async () => undefined, + rm: async () => undefined, + }, + }; +}); + +vi.mock('child_process', () => ({ + exec: ( + command: string, + callback: (error: Error | null, result: { stdout: string; stderr: string }) => void + ) => { + const stdout = command.startsWith('command -v') ? '/usr/bin/pdftoppm\n' : ''; + callback(null, { stdout, stderr: '' }); + }, +})); + +const PAGE_SIZE = 4; +const PAGE_COLORS = [0xff0000ff, 0x00ff00ff, 0x0000ffff]; + +async function makePagePng(color: number): Promise { + const image = new Jimp(PAGE_SIZE, PAGE_SIZE, color); + return image.getBufferAsync(Jimp.MIME_PNG); +} + +async function seedRenderedPagesOutOfOrder(): Promise { + const pages = await Promise.all(PAGE_COLORS.map((color) => makePagePng(color))); + renderedFiles.clear(); + renderedFiles.set('output-3.png', pages[2]); + renderedFiles.set('output-1.png', pages[0]); + renderedFiles.set('output-2.png', pages[1]); + return pages; +} + +function createMockContext(propsValue: Record) { + const writtenFiles: Array<{ data: Buffer; fileName: string }> = []; + return { + propsValue, + files: { + write: async (params: { data: Buffer; fileName: string }) => { + writtenFiles.push(params); + return `file:///${params.fileName}`; + }, + }, + writtenFiles, + }; +} + +describe('convertToImage page order', () => { + test('writes one image per page in page order when the directory listing is unsorted', async () => { + const { convertToImage } = await import('../src/lib/actions/convert-to-image'); + const pages = await seedRenderedPagesOutOfOrder(); + const ctx = createMockContext({ + file: { filename: 'test.pdf', extension: 'pdf', data: Buffer.from('pdf') }, + imageOutputType: 'multiple', + }); + + const result = await convertToImage.run(ctx as any); + + expect(result).toStrictEqual({ + images: [ + 'file:///converted_image_page_1.png', + 'file:///converted_image_page_2.png', + 'file:///converted_image_page_3.png', + ], + }); + expect(ctx.writtenFiles.map((file) => file.data)).toStrictEqual(pages); + }); + + test('stacks pages top to bottom in page order in the combined image', async () => { + const { convertToImage } = await import('../src/lib/actions/convert-to-image'); + await seedRenderedPagesOutOfOrder(); + const ctx = createMockContext({ + file: { filename: 'test.pdf', extension: 'pdf', data: Buffer.from('pdf') }, + imageOutputType: 'single', + }); + + const result = await convertToImage.run(ctx as any); + + expect(result).toStrictEqual({ image: 'file:///converted_image.png' }); + const combined = await Jimp.read(ctx.writtenFiles[0].data); + expect(combined.getHeight()).toBe(PAGE_SIZE * PAGE_COLORS.length); + PAGE_COLORS.forEach((color, pageIndex) => { + expect(combined.getPixelColor(0, pageIndex * PAGE_SIZE)).toBe(color); + }); + }); +}); diff --git a/packages/pieces/core/pdf/test/extract-pdf-pages.test.ts b/packages/pieces/core/pdf/test/extract-pdf-pages.test.ts index b679ce52aab9..79c214f86616 100644 --- a/packages/pieces/core/pdf/test/extract-pdf-pages.test.ts +++ b/packages/pieces/core/pdf/test/extract-pdf-pages.test.ts @@ -56,4 +56,26 @@ describe('pageRangeToIndexes', () => { const indexes = pageRangeToIndexes(-5, -5, 10); expect(indexes).toStrictEqual([5]); }); + + test('should accept negative start equal to the total page count', () => { + const indexes = pageRangeToIndexes(-10, -10, 10); + expect(indexes).toStrictEqual([0]); + }); + + test('should map the full negative range to every page', () => { + const indexes = pageRangeToIndexes(-3, -1, 3); + expect(indexes).toStrictEqual([0, 1, 2]); + }); + + test('should throw error if negative start is beyond the total pages', () => { + expect(() => { + pageRangeToIndexes(-15, -1, 10); + }).toThrow(); + }); + + test('should throw error if negative end is beyond the total pages', () => { + expect(() => { + pageRangeToIndexes(-15, -12, 10); + }).toThrow(); + }); }); From 02a6ab9dc6e1737bbd7daf499f27b38f43af0d63 Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Wed, 23 Sep 2026 01:44:21 +0300 Subject: [PATCH 4/7] feat(math-helper): clarify the inputs and validate numeric ranges (#15679) --- bun.lock | 2 +- packages/pieces/core/math-helper/package.json | 2 +- packages/pieces/core/math-helper/src/index.ts | 6 +-- .../math-helper/src/lib/actions/addition.ts | 4 +- .../math-helper/src/lib/actions/division.ts | 6 +-- .../src/lib/actions/generateRandom.ts | 27 ++++++---- .../math-helper/src/lib/actions/modulo.ts | 15 ++++-- .../src/lib/actions/multiplication.ts | 4 +- .../src/lib/actions/subtraction.ts | 12 ++--- .../math-helper/src/lib/output-schemas.ts | 4 +- .../core/math-helper/test/math-helper.test.ts | 49 +++++++++++++++++++ 11 files changed, 93 insertions(+), 38 deletions(-) diff --git a/bun.lock b/bun.lock index 77f33076c0e1..78b6598a934c 100644 --- a/bun.lock +++ b/bun.lock @@ -10493,7 +10493,7 @@ }, "packages/pieces/core/math-helper": { "name": "@activepieces/piece-math-helper", - "version": "0.0.31", + "version": "0.1.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/core/math-helper/package.json b/packages/pieces/core/math-helper/package.json index 794fded106dd..796208c6b442 100644 --- a/packages/pieces/core/math-helper/package.json +++ b/packages/pieces/core/math-helper/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-math-helper", - "version": "0.0.31", + "version": "0.1.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "bundleDeps": true, diff --git a/packages/pieces/core/math-helper/src/index.ts b/packages/pieces/core/math-helper/src/index.ts index b6c5aebdaf73..69f65c822657 100644 --- a/packages/pieces/core/math-helper/src/index.ts +++ b/packages/pieces/core/math-helper/src/index.ts @@ -6,13 +6,9 @@ import { modulo } from './lib/actions/modulo'; import { multiplication } from './lib/actions/multiplication'; import { subtraction } from './lib/actions/subtraction'; -const markdownDescription = ` -Perform mathematical operations. -`; - export const math = createPiece({ displayName: 'Math Helper', - description: markdownDescription, + description: 'Add, subtract, multiply, divide, or pick a random number.', auth: PieceAuth.None(), minimumSupportedRelease: '0.36.1', logoUrl: 'https://cdn.activepieces.com/pieces/new-core/math-helper.svg', diff --git a/packages/pieces/core/math-helper/src/lib/actions/addition.ts b/packages/pieces/core/math-helper/src/lib/actions/addition.ts index 20ea3214eb48..13ed397b3115 100644 --- a/packages/pieces/core/math-helper/src/lib/actions/addition.ts +++ b/packages/pieces/core/math-helper/src/lib/actions/addition.ts @@ -12,17 +12,15 @@ export const addition = createAction({ outputSchema: additionActionOutputSchema, auth: PieceAuth.None(), displayName: 'Addition', - description: 'Add the first number and the second number', + description: 'Add two numbers together.', aiMetadata: { description: 'Compute the sum of exactly two numbers, returning first_number + second_number. Pick this only for a two-operand addition; use the sibling Subtraction, Multiplication, Division, or Modulo actions for other operations, and the Code piece for summing a list or evaluating any multi-term formula. Both operands are required and must be numeric; read-only and idempotent, with no external side effects.', idempotent: true }, props: { first_number: Property.Number({ displayName: 'First Number', - description: undefined, required: true, }), second_number: Property.Number({ displayName: 'Second Number', - description: undefined, required: true, }), }, diff --git a/packages/pieces/core/math-helper/src/lib/actions/division.ts b/packages/pieces/core/math-helper/src/lib/actions/division.ts index 558ced742760..faec13a6cdb5 100644 --- a/packages/pieces/core/math-helper/src/lib/actions/division.ts +++ b/packages/pieces/core/math-helper/src/lib/actions/division.ts @@ -14,17 +14,17 @@ export const division = createAction({ outputSchema: divisionActionOutputSchema, auth: PieceAuth.None(), displayName: 'Division', - description: 'Divide first number by the second number', + description: 'Divide the first number by the second number.', aiMetadata: { description: 'Compute the quotient of two numbers, returning first_number / second_number as a floating-point value rather than integer division. Pick this for a two-operand division such as an average, ratio, or unit price; use the sibling Modulo action when you need the remainder instead, or the other Math Helper siblings for the remaining operations. Critical constraint: second_number is validated and must not be zero, or the step fails before computing; read-only and idempotent.', idempotent: true }, props: { first_number: Property.Number({ displayName: 'First Number', - description: undefined, + description: 'The number to divide.', required: true, }), second_number: Property.Number({ displayName: 'Second Number', - description: undefined, + description: 'The number to divide by. Cannot be zero.', required: true, }), }, diff --git a/packages/pieces/core/math-helper/src/lib/actions/generateRandom.ts b/packages/pieces/core/math-helper/src/lib/actions/generateRandom.ts index 5fae2f48cd49..8bcb569ba72a 100644 --- a/packages/pieces/core/math-helper/src/lib/actions/generateRandom.ts +++ b/packages/pieces/core/math-helper/src/lib/actions/generateRandom.ts @@ -11,24 +11,31 @@ export const generateRandom = createAction({ classification: 'READ', outputSchema: generateRandomActionOutputSchema, auth: PieceAuth.None(), - displayName: 'Generate Random Number', - description: 'Generate random number between two numbers (inclusive)', - aiMetadata: { description: 'Draw a pseudo-random integer uniformly from the inclusive range between first_number (the minimum) and second_number (the maximum). Pick this for sampling, jitter, or picking an arbitrary index; it is not cryptographically secure, so use the Crypto piece for tokens, secrets, or IDs, and the arithmetic siblings for deterministic math. It returns whole numbers only, and the range is honoured only when second_number is at least first_number; not idempotent, since each call returns a different value for identical inputs.', idempotent: false }, + displayName: 'Random Number', + description: 'Pick a random whole number from a range, ends included.', + aiMetadata: { description: 'Draw a pseudo-random integer uniformly from the inclusive range between first_number (the minimum) and second_number (the maximum). Pick this for sampling, jitter, or picking an arbitrary index; it is not cryptographically secure, so use the Crypto piece for tokens, secrets, or IDs, and the arithmetic siblings for deterministic math. It returns whole numbers only; decimal bounds are rounded inward and reversed bounds are swapped, so the result always lies inside the range, and the step fails when the range holds no whole number at all (1.2 to 1.8); not idempotent, since each call returns a different value for identical inputs.', idempotent: false }, props: { first_number: Property.Number({ - displayName: 'First Number', - description: undefined, + displayName: 'Minimum', + description: 'Lowest whole number that can be returned.', required: true, }), second_number: Property.Number({ - displayName: 'Second Number', - description: undefined, + displayName: 'Maximum', + description: 'Highest whole number that can be returned.', required: true, }), }, async run(context) { - const min = context.propsValue['first_number']; - const max = context.propsValue['second_number']; - return Math.floor(Math.random() * (max - min + 1) + min); + const first = context.propsValue['first_number']; + const second = context.propsValue['second_number']; + const low = Math.ceil(Math.min(first, second)); + const high = Math.floor(Math.max(first, second)); + if (high < low) { + throw new Error( + 'There is no whole number between the minimum and the maximum.', + ); + } + return Math.floor(Math.random() * (high - low + 1) + low); }, }); diff --git a/packages/pieces/core/math-helper/src/lib/actions/modulo.ts b/packages/pieces/core/math-helper/src/lib/actions/modulo.ts index c6b6d215f699..ae9823e88c94 100644 --- a/packages/pieces/core/math-helper/src/lib/actions/modulo.ts +++ b/packages/pieces/core/math-helper/src/lib/actions/modulo.ts @@ -3,6 +3,8 @@ import { PieceAuth, Property, } from '@activepieces/pieces-framework'; +import { propsValidation } from '@activepieces/pieces-common'; +import * as z from 'zod/mini'; import { moduloActionOutputSchema } from '../output-schemas'; export const modulo = createAction({ @@ -12,21 +14,26 @@ export const modulo = createAction({ outputSchema: moduloActionOutputSchema, auth: PieceAuth.None(), displayName: 'Modulo', - description: 'Get the remainder of the first number divided by second number', - aiMetadata: { description: 'Compute the remainder of first_number divided by second_number using JavaScript % semantics, so the sign of the result follows the dividend (-7 with divisor 3 yields -1, not 2). Pick this for divisibility or every-Nth checks and for cycling an index over a fixed range; use the sibling Division action when you want the quotient instead. Unlike Division, second_number is not validated here, so a zero divisor returns NaN rather than failing the step; read-only and idempotent.', idempotent: true }, + description: 'Get the remainder after dividing the first number by the second.', + aiMetadata: { description: 'Compute the remainder of first_number divided by second_number using JavaScript % semantics, so the sign of the result follows the dividend (-7 with divisor 3 yields -1, not 2). Pick this for divisibility or every-Nth checks and for cycling an index over a fixed range; use the sibling Division action when you want the quotient instead. Like Division, second_number is validated and must not be zero, or the step fails before computing; read-only and idempotent.', idempotent: true }, props: { first_number: Property.Number({ displayName: 'First Number', - description: undefined, + description: 'The number to divide.', required: true, }), second_number: Property.Number({ displayName: 'Second Number', - description: undefined, + description: 'The number to divide by. Cannot be zero.', required: true, }), }, async run(context) { + await propsValidation.validateZod(context.propsValue, { + second_number: z.number().check( + z.refine((val) => val !== 0, 'Second number cannot be zero'), + ), + }); return ( context.propsValue['first_number'] % context.propsValue['second_number'] ); diff --git a/packages/pieces/core/math-helper/src/lib/actions/multiplication.ts b/packages/pieces/core/math-helper/src/lib/actions/multiplication.ts index 3b6e912024ff..59dad7480a64 100644 --- a/packages/pieces/core/math-helper/src/lib/actions/multiplication.ts +++ b/packages/pieces/core/math-helper/src/lib/actions/multiplication.ts @@ -12,17 +12,15 @@ export const multiplication = createAction({ outputSchema: multiplicationActionOutputSchema, auth: PieceAuth.None(), displayName: 'Multiplication', - description: 'Multiply first number by the second number', + description: 'Multiply two numbers together.', aiMetadata: { description: 'Compute the product of exactly two numbers, returning first_number * second_number. Pick this for a two-operand multiplication, including percentage or unit-rate scaling (e.g. an amount times 0.15); use the sibling Addition, Subtraction, Division, or Modulo actions for other operations, and the Code piece for exponents or multi-term formulas. Both operands are required and must be numeric; read-only and idempotent, with no external side effects.', idempotent: true }, props: { first_number: Property.Number({ displayName: 'First Number', - description: undefined, required: true, }), second_number: Property.Number({ displayName: 'Second Number', - description: undefined, required: true, }), }, diff --git a/packages/pieces/core/math-helper/src/lib/actions/subtraction.ts b/packages/pieces/core/math-helper/src/lib/actions/subtraction.ts index 39d09216693c..2608a95d15a5 100644 --- a/packages/pieces/core/math-helper/src/lib/actions/subtraction.ts +++ b/packages/pieces/core/math-helper/src/lib/actions/subtraction.ts @@ -12,17 +12,17 @@ export const subtraction = createAction({ outputSchema: subtractionActionOutputSchema, auth: PieceAuth.None(), displayName: 'Subtraction', - description: 'Subtract the first number from the second number', - aiMetadata: { description: 'Compute the difference of two numbers. Critical: the operand order is the reverse of what the prop names suggest, because it returns second_number - first_number; put the amount being subtracted in first_number and the value it is subtracted from in second_number. Pick this for a two-operand subtraction only, using the sibling Addition, Multiplication, Division, or Modulo actions for other operations and the Code piece for multi-term formulas; read-only and idempotent.', idempotent: true }, + description: 'Subtract one number from another.', + aiMetadata: { description: 'Compute the difference of two numbers. It returns second_number - first_number: first_number is the amount being subtracted (labelled "Number to Subtract") and second_number is the value it is subtracted from (labelled "Starting Number"). Pick this for a two-operand subtraction only, using the sibling Addition, Multiplication, Division, or Modulo actions for other operations and the Code piece for multi-term formulas; read-only and idempotent.', idempotent: true }, props: { first_number: Property.Number({ - displayName: 'First Number', - description: undefined, + displayName: 'Number to Subtract', + description: 'This amount is taken away from the starting number.', required: true, }), second_number: Property.Number({ - displayName: 'Second Number', - description: undefined, + displayName: 'Starting Number', + description: 'The result is this number minus the number above.', required: true, }), }, diff --git a/packages/pieces/core/math-helper/src/lib/output-schemas.ts b/packages/pieces/core/math-helper/src/lib/output-schemas.ts index 00838582f620..ca895fd30e23 100644 --- a/packages/pieces/core/math-helper/src/lib/output-schemas.ts +++ b/packages/pieces/core/math-helper/src/lib/output-schemas.ts @@ -19,7 +19,7 @@ export const additionActionOutputSchema = wholeNumberOutput( export const subtractionActionOutputSchema = wholeNumberOutput( 'difference', 'Difference', - 'The second number minus the first number.', + 'The starting number minus the number to subtract.', ); export const multiplicationActionOutputSchema = wholeNumberOutput( @@ -43,5 +43,5 @@ export const moduloActionOutputSchema = wholeNumberOutput( export const generateRandomActionOutputSchema = wholeNumberOutput( 'randomNumber', 'Random Number', - 'A pseudo-random integer between the two numbers, inclusive.', + 'A random whole number between the minimum and maximum, both included.', ); diff --git a/packages/pieces/core/math-helper/test/math-helper.test.ts b/packages/pieces/core/math-helper/test/math-helper.test.ts index 610291d81fda..3403e2e3742a 100644 --- a/packages/pieces/core/math-helper/test/math-helper.test.ts +++ b/packages/pieces/core/math-helper/test/math-helper.test.ts @@ -108,6 +108,13 @@ describe('modulo', () => { }); expect(await modulo.run(ctx)).toBe(-1); }); + + test('throws on zero divisor', async () => { + const ctx = createMockActionContext({ + propsValue: { first_number: 10, second_number: 0 }, + }); + await expect(modulo.run(ctx)).rejects.toThrow(); + }); }); describe('generateRandom', () => { @@ -120,4 +127,46 @@ describe('generateRandom', () => { expect(result).toBeLessThanOrEqual(10); }); + test('stays inside decimal bounds', async () => { + const ctx = createMockActionContext({ + propsValue: { first_number: 1.5, second_number: 3.5 }, + }); + for (let i = 0; i < 50; i++) { + const result = await generateRandom.run(ctx); + expect(result).toBeGreaterThanOrEqual(2); + expect(result).toBeLessThanOrEqual(3); + } + }); + + test('accepts reversed bounds', async () => { + const ctx = createMockActionContext({ + propsValue: { first_number: 10, second_number: 1 }, + }); + for (let i = 0; i < 50; i++) { + const result = await generateRandom.run(ctx); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(10); + } + }); + + test('returns the bound when both are equal', async () => { + const ctx = createMockActionContext({ + propsValue: { first_number: 7, second_number: 7 }, + }); + expect(await generateRandom.run(ctx)).toBe(7); + }); + + test('throws when the range holds no whole number', async () => { + const ctx = createMockActionContext({ + propsValue: { first_number: 1.2, second_number: 1.8 }, + }); + await expect(generateRandom.run(ctx)).rejects.toThrow(); + }); + + test('throws when both bounds are the same decimal', async () => { + const ctx = createMockActionContext({ + propsValue: { first_number: 2.5, second_number: 2.5 }, + }); + await expect(generateRandom.run(ctx)).rejects.toThrow(); + }); }); From 625e0233496a4d1e5c6e59016b527e38e400c494 Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Wed, 23 Sep 2026 01:44:58 +0300 Subject: [PATCH 5/7] feat(file-helper): improve the file action step forms (#15684) --- bun.lock | 2 +- packages/pieces/core/file-helper/package.json | 2 +- .../pieces/core/file-helper/src/i18n/ca.json | 23 +------ .../pieces/core/file-helper/src/i18n/de.json | 41 ++++-------- .../pieces/core/file-helper/src/i18n/es.json | 41 ++++-------- .../pieces/core/file-helper/src/i18n/fr.json | 41 ++++-------- .../pieces/core/file-helper/src/i18n/hi.json | 23 +------ .../pieces/core/file-helper/src/i18n/id.json | 23 +------ .../pieces/core/file-helper/src/i18n/ja.json | 41 ++++-------- .../pieces/core/file-helper/src/i18n/nl.json | 41 ++++-------- .../pieces/core/file-helper/src/i18n/pt.json | 41 ++++-------- .../pieces/core/file-helper/src/i18n/ru.json | 31 +++------ .../file-helper/src/i18n/translation.json | 66 ++++++++++-------- .../pieces/core/file-helper/src/i18n/vi.json | 23 +------ .../pieces/core/file-helper/src/i18n/zh.json | 31 +-------- packages/pieces/core/file-helper/src/index.ts | 4 +- .../src/lib/actions/change-file-encoding.ts | 17 +++-- .../src/lib/actions/check-file-type.ts | 24 +++---- .../src/lib/actions/create-file.ts | 20 ++++-- .../src/lib/actions/get-file-name.ts | 3 +- .../file-helper/src/lib/actions/read-file.ts | 7 +- .../file-helper/src/lib/actions/unzip-file.ts | 67 +++++++++---------- .../file-helper/src/lib/actions/zip-files.ts | 37 ++++------ .../file-helper/test/check-file-type.test.ts | 30 +++++++++ 24 files changed, 252 insertions(+), 427 deletions(-) diff --git a/bun.lock b/bun.lock index 78b6598a934c..100b56c42231 100644 --- a/bun.lock +++ b/bun.lock @@ -10394,7 +10394,7 @@ }, "packages/pieces/core/file-helper": { "name": "@activepieces/piece-file-helper", - "version": "0.1.31", + "version": "0.2.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/core/file-helper/package.json b/packages/pieces/core/file-helper/package.json index be4af781f879..12acc90c0c26 100644 --- a/packages/pieces/core/file-helper/package.json +++ b/packages/pieces/core/file-helper/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-file-helper", - "version": "0.1.31", + "version": "0.2.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/core/file-helper/src/i18n/ca.json b/packages/pieces/core/file-helper/src/i18n/ca.json index 05aa20f5195f..aa9b9a79db49 100644 --- a/packages/pieces/core/file-helper/src/i18n/ca.json +++ b/packages/pieces/core/file-helper/src/i18n/ca.json @@ -1,27 +1,10 @@ { - "Files Helper": "Files Helper", - "Read file content and return it in different formats.": "Read file content and return it in different formats.", "Read File": "Read File", - "Create file": "Create file", "Change File Encoding": "Change File Encoding", - "Check file type": "Check file type", - "Read a file from the file system": "Read a file from the file system", - "Create file from content": "Create file from content", - "Changes the encoding of a file": "Changes the encoding of a file", - "Check MIME type of a file and filter based on selected types": "Check MIME type of a file and filter based on selected types", + "Get File Name": "Get File Name", "File": "File", - "Output format": "Output format", "Content": "Content", - "File name": "File name", "Encoding": "Encoding", - "Source file": "Source file", - "Source encoding": "Source encoding", - "Output file name": "Output file name", - "Output encoding": "Output encoding", - "File to Check": "File to Check", - "Select MIME Types": "Select MIME Types", - "The output format": "The output format", - "Choose one or more MIME types to check against the file.": "Choose one or more MIME types to check against the file.", "Text": "Text", "Base64": "Base64", "ASCII": "ASCII", @@ -105,7 +88,5 @@ "FreeARC Archive": "FreeARC Archive", "CD Audio": "CD Audio", "MPEG Transport Stream": "MPEG Transport Stream", - "Opus Audio in Ogg Container": "Opus Audio in Ogg Container", - "Get File Name": "Get File Name", - "Get the name of a file": "Get the name of a file" + "Opus Audio in Ogg Container": "Opus Audio in Ogg Container" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/de.json b/packages/pieces/core/file-helper/src/i18n/de.json index f3e17c9d763e..cf3fcb37503b 100644 --- a/packages/pieces/core/file-helper/src/i18n/de.json +++ b/packages/pieces/core/file-helper/src/i18n/de.json @@ -1,39 +1,24 @@ { - "Read file content and return it in different formats.": "Lesen Sie den Dateiinhalt und geben Sie ihn in verschiedenen Formaten zurück.", "Read File": "Datei lesen", - "Create file": "Datei erstellen", + "Create File": "Datei erstellen", "Change File Encoding": "Dateicodierung ändern", - "Check file type": "Dateityp überprüfen", + "Check File Type": "Dateityp überprüfen", "Zip Files": "Zip-Dateien", "Unzip File": "Datei entpacken", - "Read a file from the file system": "Eine Datei aus dem Dateisystem lesen", - "Create file from content": "Datei aus Inhalt erstellen", - "Changes the encoding of a file": "Ändert die Kodierung einer Datei", - "Check MIME type of a file and filter based on selected types": "Überprüfen Sie den MIME-Typ einer Datei und Filter basierend auf ausgewählten Typen", - "Create compressed zip file from one or many files": "Komprimierte Zip-Datei aus einer oder mehreren Dateien erstellen", - "Unzip compressed zip file": "Komprimierte Zip-Datei entpacken", + "Get File Name": "Dateiname abrufen", "File": "Datei", - "Output format": "Ausgabeformat", + "Output Format": "Ausgabeformat", "Content": "Inhalt", - "File name": "Dateiname", + "File Name": "Dateiname", "Encoding": "Encodierung", - "Source file": "Quelldatei", - "Source encoding": "Quellcodierung", - "Output file name": "Dateiname ausgeben", - "Output encoding": "Ausgabekodierung", - "File to Check": "Zu überprüfende Datei", - "Select MIME Types": "MIME-Typen auswählen", + "Source File": "Quelldatei", + "Source Encoding": "Quellcodierung", + "Output File Name": "Dateiname ausgeben", + "Output Encoding": "Ausgabekodierung", "Files": "Dateien", - "Name of zipped file": "Name der Zip-Datei", - "Use password": "Passwort verwenden", - "Password options": "Passwort-Optionen", + "Use Password": "Passwort verwenden", + "Password Options": "Passwort-Optionen", "Zip File": "Zip-Datei", - "Max Results": "Maximale Ergebnisse", - "The output format": "Das Ausgabeformat", - "Choose one or more MIME types to check against the file.": "Wählen Sie einen oder mehrere MIME-Typen, um die Datei zu überprüfen.", - "Enable password protection for the zip file": "Passwortschutz für die Zip-Datei aktivieren", - "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n": "\nSendet einen Fehler, wenn die Zip-Datei mehr als erwartet enthält.\n- Dies ist ein sicherer Test, wenn Sie mit nicht vertrauenswürdigen Zip-Dateien arbeiten.\n- Dieses Feld ausschließen oder auf 0 setzen, um diese Überprüfung zu überspringen.\n", - "Enable if the zip file is password protected": "Aktivieren, wenn die Zip-Datei passwortgeschützt ist", "Text": "Text", "Base64": "Base64", "ASCII": "ASCII", @@ -117,7 +102,5 @@ "FreeARC Archive": "FreeARC-Archiv", "CD Audio": "CD Audio", "MPEG Transport Stream": "MPEG-Transport-Stream", - "Opus Audio in Ogg Container": "Opus Audio im Ogg Container", - "Get File Name": "Dateiname abrufen", - "Get the name of a file": "Den Dateinamen abrufen" + "Opus Audio in Ogg Container": "Opus Audio im Ogg Container" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/es.json b/packages/pieces/core/file-helper/src/i18n/es.json index 9971da670894..3f053849fbc5 100644 --- a/packages/pieces/core/file-helper/src/i18n/es.json +++ b/packages/pieces/core/file-helper/src/i18n/es.json @@ -1,39 +1,24 @@ { - "Read file content and return it in different formats.": "Leer contenido de archivo y devolverlo en diferentes formatos.", "Read File": "Leer archivo", - "Create file": "Crear archivo", + "Create File": "Crear archivo", "Change File Encoding": "Cambiar codificación de archivo", - "Check file type": "Comprobar tipo de archivo", + "Check File Type": "Comprobar tipo de archivo", "Zip Files": "Archivos Zip", "Unzip File": "Descomprimir archivo", - "Read a file from the file system": "Leer un archivo del sistema de archivos", - "Create file from content": "Crear archivo a partir de contenido", - "Changes the encoding of a file": "Cambia la codificación de un archivo", - "Check MIME type of a file and filter based on selected types": "Compruebe el tipo MIME de un archivo y filtro basado en los tipos seleccionados", - "Create compressed zip file from one or many files": "Crear archivo comprimido a partir de uno o muchos archivos", - "Unzip compressed zip file": "Archivo comprimido descomprimido", + "Get File Name": "Obtener nombre de archivo", "File": "Archivo", - "Output format": "Formato de salida", + "Output Format": "Formato de salida", "Content": "Contenido", - "File name": "Nombre del archivo", + "File Name": "Nombre del archivo", "Encoding": "Codificación", - "Source file": "Archivo fuente", - "Source encoding": "Codificación de origen", - "Output file name": "Nombre del archivo de salida", - "Output encoding": "Codificación de salida", - "File to Check": "Archivo a revisar", - "Select MIME Types": "Seleccionar tipos MIME", + "Source File": "Archivo fuente", + "Source Encoding": "Codificación de origen", + "Output File Name": "Nombre del archivo de salida", + "Output Encoding": "Codificación de salida", "Files": "Archivos", - "Name of zipped file": "Nombre del archivo comprimido", - "Use password": "Usar contraseña", - "Password options": "Opciones de contraseña", + "Use Password": "Usar contraseña", + "Password Options": "Opciones de contraseña", "Zip File": "Archivo Zip", - "Max Results": "Resultados máximos", - "The output format": "El formato de salida", - "Choose one or more MIME types to check against the file.": "Elija uno o más tipos MIME para comprobar contra el archivo.", - "Enable password protection for the zip file": "Activar la protección de contraseña para el archivo zip", - "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n": "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n", - "Enable if the zip file is password protected": "Activar si el archivo zip está protegido por contraseña", "Text": "Texto", "Base64": "Base64", "ASCII": "ASCII", @@ -117,7 +102,5 @@ "FreeARC Archive": "Archivo FreeARC", "CD Audio": "Audio de CD", "MPEG Transport Stream": "Flujo de transporte MPEG", - "Opus Audio in Ogg Container": "Audio Opus en contenedor Ogg", - "Get File Name": "Obtener nombre de archivo", - "Get the name of a file": "Obtener el nombre de un archivo" + "Opus Audio in Ogg Container": "Audio Opus en contenedor Ogg" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/fr.json b/packages/pieces/core/file-helper/src/i18n/fr.json index 6fde983ef77e..2ed7726ba7b5 100644 --- a/packages/pieces/core/file-helper/src/i18n/fr.json +++ b/packages/pieces/core/file-helper/src/i18n/fr.json @@ -1,39 +1,24 @@ { - "Read file content and return it in different formats.": "Lire le contenu du fichier et le renvoyer dans différents formats.", "Read File": "Lire le fichier", - "Create file": "Créer un fichier", + "Create File": "Créer un fichier", "Change File Encoding": "Modifier l'encodage du fichier", - "Check file type": "Vérifier le type de fichier", + "Check File Type": "Vérifier le type de fichier", "Zip Files": "Fichiers Zip", "Unzip File": "Décompresser le fichier", - "Read a file from the file system": "Lire un fichier depuis le système de fichiers", - "Create file from content": "Créer un fichier à partir du contenu", - "Changes the encoding of a file": "Modifie l'encodage d'un fichier", - "Check MIME type of a file and filter based on selected types": "Vérifier le type MIME d'un fichier et le filtre basé sur les types sélectionnés", - "Create compressed zip file from one or many files": "Créer un fichier zip compressé à partir d'un ou plusieurs fichiers", - "Unzip compressed zip file": "Décompresser le fichier zip compressé", + "Get File Name": "Obtenir le nom du fichier", "File": "Ficher", - "Output format": "Format de sortie", + "Output Format": "Format de sortie", "Content": "Contenus", - "File name": "Nom du fichier", + "File Name": "Nom du fichier", "Encoding": "Encodage", - "Source file": "Fichier source", - "Source encoding": "Encodage source", - "Output file name": "Nom du fichier de sortie", - "Output encoding": "Encodage de sortie", - "File to Check": "Fichier à vérifier", - "Select MIME Types": "Sélectionnez les types MIME", + "Source File": "Fichier source", + "Source Encoding": "Encodage source", + "Output File Name": "Nom du fichier de sortie", + "Output Encoding": "Encodage de sortie", "Files": "Fichiers", - "Name of zipped file": "Nom du fichier compressé", - "Use password": "Utiliser le mot de passe", - "Password options": "Options de mot de passe", + "Use Password": "Utiliser le mot de passe", + "Password Options": "Options de mot de passe", "Zip File": "Fichier Zip", - "Max Results": "Nombre maximum de résultats", - "The output format": "Le format de sortie", - "Choose one or more MIME types to check against the file.": "Choisissez un ou plusieurs types MIME à vérifier par rapport au fichier.", - "Enable password protection for the zip file": "Activer la protection par mot de passe pour le fichier zip", - "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n": "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n", - "Enable if the zip file is password protected": "Activer si le fichier zip est protégé par mot de passe", "Text": "Texte du texte", "Base64": "Base64", "ASCII": "ASCII", @@ -117,7 +102,5 @@ "FreeARC Archive": "Archive FreeARC", "CD Audio": "CD audio", "MPEG Transport Stream": "Flux de Transport MPEG", - "Opus Audio in Ogg Container": "Opus Audio dans Ogg Container", - "Get File Name": "Obtenir le nom du fichier", - "Get the name of a file": "Récupère le nom d'un fichier" + "Opus Audio in Ogg Container": "Opus Audio dans Ogg Container" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/hi.json b/packages/pieces/core/file-helper/src/i18n/hi.json index 05aa20f5195f..aa9b9a79db49 100644 --- a/packages/pieces/core/file-helper/src/i18n/hi.json +++ b/packages/pieces/core/file-helper/src/i18n/hi.json @@ -1,27 +1,10 @@ { - "Files Helper": "Files Helper", - "Read file content and return it in different formats.": "Read file content and return it in different formats.", "Read File": "Read File", - "Create file": "Create file", "Change File Encoding": "Change File Encoding", - "Check file type": "Check file type", - "Read a file from the file system": "Read a file from the file system", - "Create file from content": "Create file from content", - "Changes the encoding of a file": "Changes the encoding of a file", - "Check MIME type of a file and filter based on selected types": "Check MIME type of a file and filter based on selected types", + "Get File Name": "Get File Name", "File": "File", - "Output format": "Output format", "Content": "Content", - "File name": "File name", "Encoding": "Encoding", - "Source file": "Source file", - "Source encoding": "Source encoding", - "Output file name": "Output file name", - "Output encoding": "Output encoding", - "File to Check": "File to Check", - "Select MIME Types": "Select MIME Types", - "The output format": "The output format", - "Choose one or more MIME types to check against the file.": "Choose one or more MIME types to check against the file.", "Text": "Text", "Base64": "Base64", "ASCII": "ASCII", @@ -105,7 +88,5 @@ "FreeARC Archive": "FreeARC Archive", "CD Audio": "CD Audio", "MPEG Transport Stream": "MPEG Transport Stream", - "Opus Audio in Ogg Container": "Opus Audio in Ogg Container", - "Get File Name": "Get File Name", - "Get the name of a file": "Get the name of a file" + "Opus Audio in Ogg Container": "Opus Audio in Ogg Container" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/id.json b/packages/pieces/core/file-helper/src/i18n/id.json index 05aa20f5195f..aa9b9a79db49 100644 --- a/packages/pieces/core/file-helper/src/i18n/id.json +++ b/packages/pieces/core/file-helper/src/i18n/id.json @@ -1,27 +1,10 @@ { - "Files Helper": "Files Helper", - "Read file content and return it in different formats.": "Read file content and return it in different formats.", "Read File": "Read File", - "Create file": "Create file", "Change File Encoding": "Change File Encoding", - "Check file type": "Check file type", - "Read a file from the file system": "Read a file from the file system", - "Create file from content": "Create file from content", - "Changes the encoding of a file": "Changes the encoding of a file", - "Check MIME type of a file and filter based on selected types": "Check MIME type of a file and filter based on selected types", + "Get File Name": "Get File Name", "File": "File", - "Output format": "Output format", "Content": "Content", - "File name": "File name", "Encoding": "Encoding", - "Source file": "Source file", - "Source encoding": "Source encoding", - "Output file name": "Output file name", - "Output encoding": "Output encoding", - "File to Check": "File to Check", - "Select MIME Types": "Select MIME Types", - "The output format": "The output format", - "Choose one or more MIME types to check against the file.": "Choose one or more MIME types to check against the file.", "Text": "Text", "Base64": "Base64", "ASCII": "ASCII", @@ -105,7 +88,5 @@ "FreeARC Archive": "FreeARC Archive", "CD Audio": "CD Audio", "MPEG Transport Stream": "MPEG Transport Stream", - "Opus Audio in Ogg Container": "Opus Audio in Ogg Container", - "Get File Name": "Get File Name", - "Get the name of a file": "Get the name of a file" + "Opus Audio in Ogg Container": "Opus Audio in Ogg Container" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/ja.json b/packages/pieces/core/file-helper/src/i18n/ja.json index 3801ab90124c..88a956385641 100644 --- a/packages/pieces/core/file-helper/src/i18n/ja.json +++ b/packages/pieces/core/file-helper/src/i18n/ja.json @@ -1,39 +1,24 @@ { - "Read file content and return it in different formats.": "ファイルの内容を読み込み、さまざまな形式で返します。", "Read File": "ファイルの読み取り", - "Create file": "ファイルを作成", + "Create File": "ファイルを作成", "Change File Encoding": "ファイルエンコーディングを変更", - "Check file type": "ファイルの種類を確認", + "Check File Type": "ファイルの種類を確認", "Zip Files": "Zipファイル", "Unzip File": "ファイルを展開する", - "Read a file from the file system": "ファイルシステムからファイルを読み込みます", - "Create file from content": "コンテンツからファイルを作成", - "Changes the encoding of a file": "ファイルのエンコーディングを変更します", - "Check MIME type of a file and filter based on selected types": "選択したタイプに基づいてファイルのMIMEタイプとフィルタをチェックします", - "Create compressed zip file from one or many files": "1つまたは複数のファイルから圧縮されたzipファイルを作成します", - "Unzip compressed zip file": "圧縮されたzipファイルを解凍する", + "Get File Name": "ファイル名を取得", "File": "ファイル", - "Output format": "出力フォーマット", + "Output Format": "出力フォーマット", "Content": "コンテンツ", - "File name": "ファイル名", + "File Name": "ファイル名", "Encoding": "エンコード", - "Source file": "ソースファイル", - "Source encoding": "ソースのエンコーディング", - "Output file name": "出力ファイル名", - "Output encoding": "出力エンコーディング", - "File to Check": "チェックするファイル", - "Select MIME Types": "MIMEタイプの選択", + "Source File": "ソースファイル", + "Source Encoding": "ソースのエンコーディング", + "Output File Name": "出力ファイル名", + "Output Encoding": "出力エンコーディング", "Files": "ファイル", - "Name of zipped file": "zip形式のファイル名", - "Use password": "パスワードを使用", - "Password options": "パスワードオプション", + "Use Password": "パスワードを使用", + "Password Options": "パスワードオプション", "Zip File": "Zipファイル", - "Max Results": "最大結果", - "The output format": "出力フォーマット", - "Choose one or more MIME types to check against the file.": "ファイルをチェックするために 1 つ以上の MIME タイプを選択してください。", - "Enable password protection for the zip file": "ZIP ファイルのパスワード保護を有効にする", - "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n": "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n", - "Enable if the zip file is password protected": "ZIP ファイルがパスワードで保護されている場合は有効にする", "Text": "テキスト", "Base64": "Base64", "ASCII": "ASCII", @@ -117,7 +102,5 @@ "FreeARC Archive": "FreeARCアーカイブ", "CD Audio": "CD オーディオ", "MPEG Transport Stream": "MPEG転送ストリーム", - "Opus Audio in Ogg Container": "Opus Audio in Ogg Container", - "Get File Name": "ファイル名を取得", - "Get the name of a file": "ファイル名を取得する" + "Opus Audio in Ogg Container": "Opus Audio in Ogg Container" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/nl.json b/packages/pieces/core/file-helper/src/i18n/nl.json index 6f91e2abd474..bdb4adc3b87e 100644 --- a/packages/pieces/core/file-helper/src/i18n/nl.json +++ b/packages/pieces/core/file-helper/src/i18n/nl.json @@ -1,39 +1,24 @@ { - "Read file content and return it in different formats.": "Lees de bestandsinhoud en geef deze in verschillende formaten terug.", "Read File": "Bestand lezen", - "Create file": "Bestand aanmaken", + "Create File": "Bestand aanmaken", "Change File Encoding": "Bestandscodering wijzigen", - "Check file type": "Controleer bestandstype", + "Check File Type": "Controleer bestandstype", "Zip Files": "Zip bestanden", "Unzip File": "Uitpakken bestand", - "Read a file from the file system": "Lees een bestand van het bestandssysteem", - "Create file from content": "Maak bestand van inhoud", - "Changes the encoding of a file": "Verandert de codering van een bestand", - "Check MIME type of a file and filter based on selected types": "Controleer MIME type van een bestand en filter op basis van geselecteerde types", - "Create compressed zip file from one or many files": "Maak gecomprimeerd zip-bestand van één of meerdere bestanden", - "Unzip compressed zip file": "Unzip gecomprimeerd zip-bestand", + "Get File Name": "Bestandsnaam ophalen", "File": "Bestand", - "Output format": "Uitvoer formaat", + "Output Format": "Uitvoer formaat", "Content": "Inhoud", - "File name": "Bestandsnaam is vereist", + "File Name": "Bestandsnaam is vereist", "Encoding": "Codering", - "Source file": "Bron bestand", - "Source encoding": "Broncode codering", - "Output file name": "Output bestandsnaam", - "Output encoding": "Uitvoer codering", - "File to Check": "Te controleren bestand", - "Select MIME Types": "MIME-typen selecteren", + "Source File": "Bron bestand", + "Source Encoding": "Broncode codering", + "Output File Name": "Output bestandsnaam", + "Output Encoding": "Uitvoer codering", "Files": "Bestanden", - "Name of zipped file": "Naam van gezipt bestand", - "Use password": "Gebruik wachtwoord", - "Password options": "Wachtwoord opties", + "Use Password": "Gebruik wachtwoord", + "Password Options": "Wachtwoord opties", "Zip File": "Zip bestand", - "Max Results": "Max. aantal resultaten", - "The output format": "Het uitgangsformaat", - "Choose one or more MIME types to check against the file.": "Kies een of meer MIME-types om het bestand te checken.", - "Enable password protection for the zip file": "Wachtwoordbeveiliging voor zip-bestand inschakelen", - "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n": "\nGooi een fout als zip-bestand meer dan verwacht invoert.\n- Dit is een veilige meting bij het werken met niet-vertrouwde zip-bestanden.\n- Dit veld uitsluiten of op 0 zetten om deze controle over te slaan.\n", - "Enable if the zip file is password protected": "Inschakelen als het zip-bestand is beveiligd met een wachtwoord", "Text": "Tekstveld", "Base64": "Base64", "ASCII": "ASCII", @@ -117,7 +102,5 @@ "FreeARC Archive": "FreeARC archief", "CD Audio": "CD audio", "MPEG Transport Stream": "MPEG Transport Stream", - "Opus Audio in Ogg Container": "Opus Audio in Ogg Container", - "Get File Name": "Bestandsnaam ophalen", - "Get the name of a file": "De naam van een bestand ophalen" + "Opus Audio in Ogg Container": "Opus Audio in Ogg Container" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/pt.json b/packages/pieces/core/file-helper/src/i18n/pt.json index f1fc0c471358..6b902889959f 100644 --- a/packages/pieces/core/file-helper/src/i18n/pt.json +++ b/packages/pieces/core/file-helper/src/i18n/pt.json @@ -1,39 +1,24 @@ { - "Read file content and return it in different formats.": "Leia o conteúdo do arquivo e retorna-o em formatos diferentes.", "Read File": "Arquivo de leitura", - "Create file": "Criar arquivo", + "Create File": "Criar arquivo", "Change File Encoding": "Alterar Codificação do Arquivo", - "Check file type": "Verificar tipo de arquivo", + "Check File Type": "Verificar tipo de arquivo", "Zip Files": "Arquivos Zip", "Unzip File": "Descompactar Arquivo", - "Read a file from the file system": "Ler um arquivo do sistema de arquivos", - "Create file from content": "Criar arquivo a partir do conteúdo", - "Changes the encoding of a file": "Altera a codificação de um arquivo", - "Check MIME type of a file and filter based on selected types": "Verificar o tipo MIME de um arquivo e filtro baseado nos tipos selecionados", - "Create compressed zip file from one or many files": "Criar arquivo zip compactado a partir de um ou vários arquivos", - "Unzip compressed zip file": "Descompactar arquivo zip compactado", + "Get File Name": "Obter Nome do Arquivo", "File": "Arquivo", - "Output format": "Formato de saída", + "Output Format": "Formato de saída", "Content": "Conteúdo", - "File name": "Nome do arquivo", + "File Name": "Nome do arquivo", "Encoding": "Codificação", - "Source file": "Arquivo de origem", - "Source encoding": "Codificação fonte", - "Output file name": "Nome do arquivo de saída", - "Output encoding": "Codificação de saída", - "File to Check": "Arquivo para verificar", - "Select MIME Types": "Selecionar tipos MIME", + "Source File": "Arquivo de origem", + "Source Encoding": "Codificação fonte", + "Output File Name": "Nome do arquivo de saída", + "Output Encoding": "Codificação de saída", "Files": "arquivos", - "Name of zipped file": "Nome do arquivo compactado", - "Use password": "Usar senha", - "Password options": "Opções de senha", + "Use Password": "Usar senha", + "Password Options": "Opções de senha", "Zip File": "Arquivo compactado", - "Max Results": "Resultados no Máx.", - "The output format": "O formato de saída", - "Choose one or more MIME types to check against the file.": "Escolha um ou mais tipos de MIME para checar contra o arquivo.", - "Enable password protection for the zip file": "Ativar proteção por senha para o arquivo zip", - "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n": "U\nLança um erro se o arquivo zip tiver mais do que o esperado.\n- Esta é uma medida segura ao funcionar com arquivos zip não confiáveis.\n- Excluir este campo ou definir como 0 para ignorar esta verificação.\n", - "Enable if the zip file is password protected": "Ativar se o arquivo zip estiver protegido por senha", "Text": "texto", "Base64": "Base64", "ASCII": "ASCII", @@ -117,7 +102,5 @@ "FreeARC Archive": "Arquivo FreeARC", "CD Audio": "Áudio do CD", "MPEG Transport Stream": "Transporte MPEG - Transporte", - "Opus Audio in Ogg Container": "Opus Audio no Recipiente Ogg", - "Get File Name": "Obter Nome do Arquivo", - "Get the name of a file": "Obter o nome de um arquivo" + "Opus Audio in Ogg Container": "Opus Audio no Recipiente Ogg" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/ru.json b/packages/pieces/core/file-helper/src/i18n/ru.json index 74f0ac9984f1..d0a1d75dd2a0 100644 --- a/packages/pieces/core/file-helper/src/i18n/ru.json +++ b/packages/pieces/core/file-helper/src/i18n/ru.json @@ -1,27 +1,18 @@ { - "Files Helper": "Помощник по файлам", - "Read file content and return it in different formats.": "Читайте содержимое файла и возвращайте его в разных форматах.", "Read File": "Читать файл", - "Create file": "Создать файл", + "Create File": "Создать файл", "Change File Encoding": "Изменить кодировку файла", - "Check file type": "Проверить тип файла", - "Read a file from the file system": "Читать файл из файловой системы", - "Create file from content": "Создать файл из содержимого", - "Changes the encoding of a file": "Изменяет кодировку файла", - "Check MIME type of a file and filter based on selected types": "Проверьте MIME-тип файла и фильтра на основе выбранных типов", + "Check File Type": "Проверить тип файла", + "Get File Name": "Получить имя файла", "File": "Файл", - "Output format": "Формат вывода", + "Output Format": "Формат вывода", "Content": "Содержание", - "File name": "Имя файла", + "File Name": "Имя файла", "Encoding": "Кодировка", - "Source file": "Исходный файл", - "Source encoding": "Исходная кодировка", - "Output file name": "Имя файла вывода", - "Output encoding": "Кодировка вывода", - "File to Check": "Файл для проверки", - "Select MIME Types": "Выберите типы MIME", - "The output format": "Выходной формат", - "Choose one or more MIME types to check against the file.": "Выберите один или несколько типов MIME для проверки с файлом.", + "Source File": "Исходный файл", + "Source Encoding": "Исходная кодировка", + "Output File Name": "Имя файла вывода", + "Output Encoding": "Кодировка вывода", "Text": "Текст", "Base64": "Base64", "ASCII": "ASCII", @@ -105,7 +96,5 @@ "FreeARC Archive": "Архив FreeARC", "CD Audio": "CD аудио", "MPEG Transport Stream": "MPEG транспортный поток", - "Opus Audio in Ogg Container": "Opus аудио в Ogg Container", - "Get File Name": "Получить имя файла", - "Get the name of a file": "Получить имя файла" + "Opus Audio in Ogg Container": "Opus аудио в Ogg Container" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/translation.json b/packages/pieces/core/file-helper/src/i18n/translation.json index d2e80157dd9f..8b33d1dd52a9 100644 --- a/packages/pieces/core/file-helper/src/i18n/translation.json +++ b/packages/pieces/core/file-helper/src/i18n/translation.json @@ -1,39 +1,49 @@ { - "Read file content and return it in different formats.": "Read file content and return it in different formats.", + "Read, create, convert, check, zip and unzip files in your flow.": "Read, create, convert, check, zip and unzip files in your flow.", "Read File": "Read File", - "Create file": "Create file", + "Create File": "Create File", "Change File Encoding": "Change File Encoding", - "Check file type": "Check file type", + "Check File Type": "Check File Type", "Zip Files": "Zip Files", "Unzip File": "Unzip File", - "Read a file from the file system": "Read a file from the file system", - "Create file from content": "Create file from content", - "Changes the encoding of a file": "Changes the encoding of a file", - "Check MIME type of a file and filter based on selected types": "Check MIME type of a file and filter based on selected types", - "Create compressed zip file from one or many files": "Create compressed zip file from one or many files", - "Unzip compressed zip file": "Unzip compressed zip file", + "Get File Name": "Get File Name", + "Return a file's contents as text or Base64.": "Return a file's contents as text or Base64.", + "Save text as a new file.": "Save text as a new file.", + "Convert a file from one text encoding to another.": "Convert a file from one text encoding to another.", + "Detect a file's type and check it against the one you pick.": "Detect a file's type and check it against the one you pick.", + "Bundle one or more files into a zip archive.": "Bundle one or more files into a zip archive.", + "Extract every file inside a zip archive.": "Extract every file inside a zip archive.", + "Return a file's name, including its extension.": "Return a file's name, including its extension.", "File": "File", - "Output format": "Output format", + "Output Format": "Output Format", "Content": "Content", - "File name": "File name", + "File Name": "File Name", "Encoding": "Encoding", - "Source file": "Source file", - "Source encoding": "Source encoding", - "Output file name": "Output file name", - "Output encoding": "Output encoding", - "File to Check": "File to Check", - "Select MIME Types": "Select MIME Types", + "Source File": "Source File", + "Source Encoding": "Source Encoding", + "Output File Name": "Output File Name", + "Output Encoding": "Output Encoding", + "Expected File Type": "Expected File Type", "Files": "Files", - "Name of zipped file": "Name of zipped file", - "Use password": "Use password", - "Password options": "Password options", + "Use Password": "Use Password", + "Password Options": "Password Options", "Zip File": "Zip File", - "Max Results": "Max Results", - "The output format": "The output format", - "Choose one or more MIME types to check against the file.": "Choose one or more MIME types to check against the file.", - "Enable password protection for the zip file": "Enable password protection for the zip file", - "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n": "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n", - "Enable if the zip file is password protected": "Enable if the zip file is password protected", + "Max Files": "Max Files", + "Pick a file from an earlier step or paste a URL to download.": "Pick a file from an earlier step or paste a URL to download.", + "Text for text files, Base64 for images, PDFs and other binaries.": "Text for text files, Base64 for images, PDFs and other binaries.", + "The text to write into the file.": "The text to write into the file.", + "Include the extension so other steps know the file type.": "Include the extension so other steps know the file type.", + "How to read the content. Keep UTF-8 for text, Base64 for binary data.": "How to read the content. Keep UTF-8 for text, Base64 for binary data.", + "The encoding the file is in now. A wrong choice garbles characters.": "The encoding the file is in now. A wrong choice garbles characters.", + "Include the file extension.": "Include the file extension.", + "The encoding the new file is saved in. UTF-8 works almost everywhere.": "The encoding the new file is saved in. UTF-8 works almost everywhere.", + "Matched against the type worked out from the file extension.": "Matched against the type worked out from the file extension.", + "One row per file to add to the archive.": "One row per file to add to the archive.", + "Include the .zip extension.": "Include the .zip extension.", + "Require a password to open the archive.": "Require a password to open the archive.", + "Pick a zip from an earlier step or paste a URL to download.": "Pick a zip from an earlier step or paste a URL to download.", + "Fail if the zip holds more files than this. 0 or empty: no limit.": "Fail if the zip holds more files than this. 0 or empty: no limit.", + "Turn on if the zip needs a password to open.": "Turn on if the zip needs a password to open.", "Text": "Text", "Base64": "Base64", "ASCII": "ASCII", @@ -117,7 +127,5 @@ "FreeARC Archive": "FreeARC Archive", "CD Audio": "CD Audio", "MPEG Transport Stream": "MPEG Transport Stream", - "Opus Audio in Ogg Container": "Opus Audio in Ogg Container", - "Get File Name": "Get File Name", - "Get the name of a file": "Get the name of a file" + "Opus Audio in Ogg Container": "Opus Audio in Ogg Container" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/vi.json b/packages/pieces/core/file-helper/src/i18n/vi.json index 05aa20f5195f..aa9b9a79db49 100644 --- a/packages/pieces/core/file-helper/src/i18n/vi.json +++ b/packages/pieces/core/file-helper/src/i18n/vi.json @@ -1,27 +1,10 @@ { - "Files Helper": "Files Helper", - "Read file content and return it in different formats.": "Read file content and return it in different formats.", "Read File": "Read File", - "Create file": "Create file", "Change File Encoding": "Change File Encoding", - "Check file type": "Check file type", - "Read a file from the file system": "Read a file from the file system", - "Create file from content": "Create file from content", - "Changes the encoding of a file": "Changes the encoding of a file", - "Check MIME type of a file and filter based on selected types": "Check MIME type of a file and filter based on selected types", + "Get File Name": "Get File Name", "File": "File", - "Output format": "Output format", "Content": "Content", - "File name": "File name", "Encoding": "Encoding", - "Source file": "Source file", - "Source encoding": "Source encoding", - "Output file name": "Output file name", - "Output encoding": "Output encoding", - "File to Check": "File to Check", - "Select MIME Types": "Select MIME Types", - "The output format": "The output format", - "Choose one or more MIME types to check against the file.": "Choose one or more MIME types to check against the file.", "Text": "Text", "Base64": "Base64", "ASCII": "ASCII", @@ -105,7 +88,5 @@ "FreeARC Archive": "FreeARC Archive", "CD Audio": "CD Audio", "MPEG Transport Stream": "MPEG Transport Stream", - "Opus Audio in Ogg Container": "Opus Audio in Ogg Container", - "Get File Name": "Get File Name", - "Get the name of a file": "Get the name of a file" + "Opus Audio in Ogg Container": "Opus Audio in Ogg Container" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/i18n/zh.json b/packages/pieces/core/file-helper/src/i18n/zh.json index 8102c512b4a6..9af843dd43f9 100644 --- a/packages/pieces/core/file-helper/src/i18n/zh.json +++ b/packages/pieces/core/file-helper/src/i18n/zh.json @@ -1,39 +1,14 @@ { - "Read file content and return it in different formats.": "Read file content and return it in different formats.", "Read File": "Read File", - "Create file": "Create file", "Change File Encoding": "Change File Encoding", - "Check file type": "Check file type", "Zip Files": "Zip Files", "Unzip File": "Unzip File", - "Read a file from the file system": "Read a file from the file system", - "Create file from content": "Create file from content", - "Changes the encoding of a file": "Changes the encoding of a file", - "Check MIME type of a file and filter based on selected types": "Check MIME type of a file and filter based on selected types", - "Create compressed zip file from one or many files": "Create compressed zip file from one or many files", - "Unzip compressed zip file": "Unzip compressed zip file", + "Get File Name": "Get File Name", "File": "文件", - "Output format": "Output format", "Content": "Content", - "File name": "File name", "Encoding": "Encoding", - "Source file": "Source file", - "Source encoding": "Source encoding", - "Output file name": "Output file name", - "Output encoding": "Output encoding", - "File to Check": "File to Check", - "Select MIME Types": "Select MIME Types", "Files": "Files", - "Name of zipped file": "Name of zipped file", - "Use password": "Use password", - "Password options": "Password options", "Zip File": "Zip File", - "Max Results": "Max Results", - "The output format": "The output format", - "Choose one or more MIME types to check against the file.": "Choose one or more MIME types to check against the file.", - "Enable password protection for the zip file": "Enable password protection for the zip file", - "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n": "\nThrow an error if zip file has more than expected entries.\n- This is a safe measure when working with untrusted zip files.\n- Exclude this field or set to 0 to skip this check.\n", - "Enable if the zip file is password protected": "Enable if the zip file is password protected", "Text": "文本", "Base64": "Base64", "ASCII": "ASCII", @@ -117,7 +92,5 @@ "FreeARC Archive": "FreeARC Archive", "CD Audio": "CD Audio", "MPEG Transport Stream": "MPEG Transport Stream", - "Opus Audio in Ogg Container": "Opus Audio in Ogg Container", - "Get File Name": "Get File Name", - "Get the name of a file": "Get the name of a file" + "Opus Audio in Ogg Container": "Opus Audio in Ogg Container" } \ No newline at end of file diff --git a/packages/pieces/core/file-helper/src/index.ts b/packages/pieces/core/file-helper/src/index.ts index 88fc2f5f8eea..b4c1185b3be3 100644 --- a/packages/pieces/core/file-helper/src/index.ts +++ b/packages/pieces/core/file-helper/src/index.ts @@ -10,9 +10,9 @@ import { getFileName } from './lib/actions/get-file-name'; export const filesHelper = createPiece({ displayName: 'Files Helper', - description: 'Read file content and return it in different formats.', + description: 'Read, create, convert, check, zip and unzip files in your flow.', auth: PieceAuth.None(), - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/new-core/file-helper.svg', categories: [PieceCategory.CORE], authors: ['kishanprmr', 'MoShizzle', 'abuaboud', 'Seb-C', 'danielpoonwj'], diff --git a/packages/pieces/core/file-helper/src/lib/actions/change-file-encoding.ts b/packages/pieces/core/file-helper/src/lib/actions/change-file-encoding.ts index 58ed2d5446eb..c93c807500f5 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/change-file-encoding.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/change-file-encoding.ts @@ -7,27 +7,32 @@ export const changeFileEncoding = createAction({ name: 'change_file_encoding', classification: 'READ', displayName: 'Change File Encoding', - description: 'Changes the encoding of a file', + description: 'Convert a file from one text encoding to another.', aiMetadata: { description: 'Re-encodes the bytes of a file from one character encoding to another (e.g. latin1 to utf8), writing the result to a new file under the output name you give. Use it when a downstream step mis-reads a file because of its character set; use Read File to simply get the content as text, or Create File to build a file from a string. The declared source encoding must actually match the file, since it is decoded blindly - a wrong choice silently corrupts characters rather than failing; deterministic and idempotent.', idempotent: true }, outputSchema: changeFileEncodingActionOutputSchema, props: { inputFile: Property.File({ - displayName: 'Source file', + displayName: 'Source File', + description: 'Pick a file from an earlier step or paste a URL to download.', required: true, }), inputEncoding: Property.StaticDropdown({ - displayName: 'Source encoding', + displayName: 'Source Encoding', + description: 'The encoding the file is in now. A wrong choice garbles characters.', required: true, options: { options: encodings, }, }), outputFileName: Property.ShortText({ - displayName: 'Output file name', + displayName: 'Output File Name', + description: 'Include the file extension.', + placeholder: 'converted.txt', required: true, }), outputEncoding: Property.StaticDropdown({ - displayName: 'Output encoding', + displayName: 'Output Encoding', + description: 'The encoding the new file is saved in. UTF-8 works almost everywhere.', required: true, options: { options: encodings, @@ -40,9 +45,7 @@ export const changeFileEncoding = createAction({ const outputFileName = context.propsValue.outputFileName; const outputEncoding = context.propsValue.outputEncoding as BufferEncoding; - // First decode the input buffer using the source encoding const decodedString = inputFile.toString(inputEncoding); - // Then encode to the target encoding const encodedBuffer = Buffer.from(decodedString, outputEncoding); return context.files.write({ diff --git a/packages/pieces/core/file-helper/src/lib/actions/check-file-type.ts b/packages/pieces/core/file-helper/src/lib/actions/check-file-type.ts index cb895db73b1f..579c2cd4fdcf 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/check-file-type.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/check-file-type.ts @@ -7,37 +7,37 @@ export const checkFileType = createAction({ audience: 'both', name: 'checkFileType', classification: 'READ', - displayName: 'Check file type', - description: 'Check MIME type of a file and filter based on selected types', + displayName: 'Check File Type', + description: "Detect a file's type and check it against the one you pick.", aiMetadata: { description: 'Determines the MIME type of a file from its extension and reports whether it matches the selected MIME type. Use it as a guard before a format-specific step (e.g. only continue for images or PDFs); use Get File Name if you only want the name, or Read File to get the contents. Detection is extension-based only - contents are never sniffed, so the extension is trusted as-is and only a missing or unrecognized one falls back to application/octet-stream; read-only and idempotent.', idempotent: true }, outputSchema: checkFileTypeActionOutputSchema, props: { file: Property.File({ - displayName: 'File to Check', + displayName: 'File', + description: 'Pick a file from an earlier step or paste a URL to download.', required: true, }), mimeTypes: Property.StaticDropdown({ - displayName: 'Select MIME Types', + displayName: 'Expected File Type', required: true, options: { options: predefinedMimeTypes, - }, - description: 'Choose one or more MIME types to check against the file.', + }, + description: 'Matched against the type worked out from the file extension.', }), }, async run(context) { const file = context.propsValue.file; - - const selectedMimeTypes = context.propsValue.mimeTypes; - // Determine the MIME type of the file + const selectedMimeType = context.propsValue.mimeTypes; const fileType = file.extension ? mime.lookup(file.extension) || 'application/octet-stream' : 'application/octet-stream'; - // Check if the file's MIME type matches any of the selected MIME types. - const isMatch = fileType && selectedMimeTypes.includes(fileType); + const isMatch = Array.isArray(selectedMimeType) + ? selectedMimeType.includes(fileType) + : fileType === selectedMimeType; return { - mimeType: fileType || 'unknown', + mimeType: fileType, isMatch, }; }, diff --git a/packages/pieces/core/file-helper/src/lib/actions/create-file.ts b/packages/pieces/core/file-helper/src/lib/actions/create-file.ts index 4c42e9555b91..b86eeb7e88ce 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/create-file.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/create-file.ts @@ -4,18 +4,28 @@ import { createFileActionOutputSchema } from '../output-schemas'; export const createFile = createAction({ audience: 'both', - // auth: check https://www.activepieces.com/docs/developers/piece-reference/authentication, name: 'createFile', classification: 'READ', - displayName: 'Create file', - description: 'Create file from content', + displayName: 'Create File', + description: 'Save text as a new file.', aiMetadata: { description: 'Writes a text string out to a new file with the given file name and character encoding (utf8 by default). Use it when a downstream step needs an actual file rather than a value - for example to attach generated CSV or JSON text to an email - and use Read File for the opposite direction. Requires the content and file name, and the encoding must be one of the supported Buffer encodings; nothing outside the run is changed, so it is idempotent.', idempotent: true }, outputSchema: createFileActionOutputSchema, props: { - content: Property.LongText({ displayName: 'Content', required: true }), - fileName: Property.ShortText({ displayName: 'File name', required: true }), + content: Property.LongText({ + displayName: 'Content', + description: 'The text to write into the file.', + placeholder: 'Hello, world', + required: true, + }), + fileName: Property.ShortText({ + displayName: 'File Name', + description: 'Include the extension so other steps know the file type.', + placeholder: 'report.txt', + required: true, + }), encoding: Property.StaticDropdown({ displayName: 'Encoding', + description: 'How to read the content. Keep UTF-8 for text, Base64 for binary data.', required: true, defaultValue: 'utf8', options: { diff --git a/packages/pieces/core/file-helper/src/lib/actions/get-file-name.ts b/packages/pieces/core/file-helper/src/lib/actions/get-file-name.ts index 0d1e196623ba..e60a6a7fd69e 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/get-file-name.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/get-file-name.ts @@ -6,12 +6,13 @@ export const getFileName = createAction({ name: 'get_file_name', classification: 'READ', displayName: 'Get File Name', - description: 'Get the name of a file', + description: "Return a file's name, including its extension.", aiMetadata: { description: 'Returns only the file name of an input file, without reading or decoding its contents. Use it when the name itself is what you need - to build a path, log it, or branch on the extension - and prefer Read File to get the content or Check File Type to resolve the MIME type. Requires a file input; read-only and idempotent.', idempotent: true }, outputSchema: getFileNameActionOutputSchema, props: { file: Property.File({ displayName: 'File', + description: 'Pick a file from an earlier step or paste a URL to download.', required: true, }), }, diff --git a/packages/pieces/core/file-helper/src/lib/actions/read-file.ts b/packages/pieces/core/file-helper/src/lib/actions/read-file.ts index bf7736461993..13ac47ca4c95 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/read-file.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/read-file.ts @@ -12,17 +12,18 @@ export const readFileAction = createAction({ name: 'read_file', classification: 'READ', displayName: 'Read File', - description: 'Read a file from the file system', + description: "Return a file's contents as text or Base64.", aiMetadata: { description: 'Decodes an input file and returns its contents as either UTF-8 text or a base64 string, selected by the output-format option. Use it to turn a file from a trigger or earlier step into a usable value - Text for text-based files such as .txt/.json, Base64 for binary files or API payloads; use Create File for the reverse direction, and Convert CSV to JSON in the CSV piece when you need parsed rows. Requires a file input and an output format (any other value errors); read-only and idempotent.', idempotent: true }, outputSchema: readFileActionOutputSchema, props: { file: Property.File({ displayName: 'File', + description: 'Pick a file from an earlier step or paste a URL to download.', required: true, }), readOptions: Property.StaticDropdown({ - displayName: 'Output format', - description: 'The output format', + displayName: 'Output Format', + description: 'Text for text files, Base64 for images, PDFs and other binaries.', required: true, options: { options: [ diff --git a/packages/pieces/core/file-helper/src/lib/actions/unzip-file.ts b/packages/pieces/core/file-helper/src/lib/actions/unzip-file.ts index 4f611c0984c2..128c57875445 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/unzip-file.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/unzip-file.ts @@ -17,39 +17,35 @@ interface Result { filePath: string; } -const maxResultsDescription = ` -Throw an error if zip file has more than expected entries. -- This is a safe measure when working with untrusted zip files. -- Exclude this field or set to 0 to skip this check. -`; - export const unzipFile = createAction({ audience: 'both', name: 'unzipFile', classification: 'READ', displayName: 'Unzip File', - description: 'Unzip compressed zip file', - aiMetadata: { description: 'Extracts every file entry from a zip archive, writing each one out as its own file; supports password-protected archives. Use it to reach the contents of a zip attachment before processing them; use Zip Files for the reverse direction. Set Max Results to throw when an untrusted archive exceeds that entry count; directory entries are skipped, a wrong or missing password makes extraction fail, and the source archive is unchanged, so it is idempotent.', idempotent: true }, + description: 'Extract every file inside a zip archive.', + aiMetadata: { description: 'Extracts every file entry from a zip archive, writing each one out as its own file; supports password-protected archives. Use it to reach the contents of a zip attachment before processing them; use Zip Files for the reverse direction. Set Max Files to throw when an untrusted archive exceeds that entry count; directory entries are skipped, a wrong or missing password makes extraction fail, and the source archive is unchanged, so it is idempotent.', idempotent: true }, outputSchema: unzipFileActionOutputSchema, props: { file: Property.File({ displayName: 'Zip File', + description: 'Pick a zip from an earlier step or paste a URL to download.', required: true, }), maxResults: Property.Number({ - displayName: 'Max Results', - description: maxResultsDescription, + displayName: 'Max Files', + description: 'Fail if the zip holds more files than this. 0 or empty: no limit.', defaultValue: 0, required: false, + advanced: true, }), usePassword: Property.Checkbox({ - displayName: 'Use password', - description: 'Enable if the zip file is password protected', + displayName: 'Use Password', + description: 'Turn on if the zip needs a password to open.', required: false, defaultValue: false, }), passwordOptions: Property.DynamicProperties({ - displayName: 'Password options', + displayName: 'Password Options', auth: PieceAuth.None(), required: false, refreshers: ['usePassword'], @@ -74,14 +70,17 @@ export const unzipFile = createAction({ const zipReader = new ZipReader(new BlobReader(blob)); const entries = await zipReader.getEntries(); + const fileEntries = entries.filter((entry) => !entry.directory); + const maxResults = context.propsValue.maxResults || 0; - if (maxResults !== 0 && entries.length > maxResults) { + if (maxResults !== 0 && fileEntries.length > maxResults) { await zipReader.close(); - throw `Zip file contains more entries than allowed: ${entries.length}`; + throw new Error( + `Zip file contains more files than allowed: ${fileEntries.length}` + ); } - // Prepare options for getData, including password if provided const getDataOptions: EntryGetDataOptions = {}; if (context.propsValue.usePassword) { const password = context.propsValue.passwordOptions?.[ @@ -92,28 +91,26 @@ export const unzipFile = createAction({ const results: Result[] = []; - for (const entry of entries) { - if (!entry.directory) { - const mimeType = getMimeType(entry.filename); + for (const entry of fileEntries) { + const mimeType = getMimeType(entry.filename); - const blob = await entry.getData( - new BlobWriter(mimeType), - getDataOptions - ); - const arrayBuffer = await blob.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); + const blob = await entry.getData( + new BlobWriter(mimeType), + getDataOptions + ); + const arrayBuffer = await blob.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); - const fileBaseName = entry.filename.split('/').pop() || entry.filename; - const fileReference = await context.files.write({ - data: buffer, - fileName: fileBaseName, - }); + const fileBaseName = entry.filename.split('/').pop() || entry.filename; + const fileReference = await context.files.write({ + data: buffer, + fileName: fileBaseName, + }); - results.push({ - file: fileReference, - filePath: entry.filename, - }); - } + results.push({ + file: fileReference, + filePath: entry.filename, + }); } await zipReader.close(); diff --git a/packages/pieces/core/file-helper/src/lib/actions/zip-files.ts b/packages/pieces/core/file-helper/src/lib/actions/zip-files.ts index 992d2ec32c9e..6ffb59ff0e3a 100644 --- a/packages/pieces/core/file-helper/src/lib/actions/zip-files.ts +++ b/packages/pieces/core/file-helper/src/lib/actions/zip-files.ts @@ -17,53 +17,47 @@ interface FileObject { filePath: string; } -const filePathDescription = ` -- You can put files in specific folders eg. foo/test.txt -- If not specified, the file would be at the top level of the zip file. -- If the same File Path is specified for different files, the later file would take precedence. -`; - -const encryptionMethodDescription = ` -- ZipCrypto: Legacy encryption method with wide compatibility (not recommended for sensitive data) -- AES-256: Modern encryption with strong security (may not be supported by older zip clients) -`; - export const zipFiles = createAction({ audience: 'both', name: 'zipFiles', classification: 'READ', displayName: 'Zip Files', - description: 'Create compressed zip file from one or many files', + description: 'Bundle one or more files into a zip archive.', aiMetadata: { description: 'Compresses one or many input files into a single zip archive, optionally placing each entry at a custom path inside the archive (e.g. foo/test.txt) and optionally password-protecting it with ZipCrypto or AES-256. Use it to bundle several files into one attachment or download; use Unzip File for the reverse direction. Requires the list of files and an output file name; entries default to their own file names, reusing the same in-zip path keeps only the later file, and there are no external side effects, so it is idempotent.', idempotent: true }, outputSchema: zipFilesActionOutputSchema, props: { files: Property.Array({ displayName: 'Files', + description: 'One row per file to add to the archive.', properties: { file: Property.File({ displayName: 'File', + description: 'Pick a file from an earlier step or paste a URL to download.', required: true, }), filePath: Property.ShortText({ - displayName: 'File Path in zip', - description: filePathDescription, + displayName: 'Path in Zip', + description: 'Folder and name inside the zip. Empty: the file name at the top level.', + placeholder: 'reports/summary.pdf', required: false, }), }, required: true, }), outputFileName: Property.ShortText({ - displayName: 'Name of zipped file', + displayName: 'Output File Name', + description: 'Include the .zip extension.', + placeholder: 'archive.zip', required: true, }), usePassword: Property.Checkbox({ - displayName: 'Use password', - description: 'Enable password protection for the zip file', + displayName: 'Use Password', + description: 'Require a password to open the archive.', required: false, defaultValue: false, }), passwordOptions: Property.DynamicProperties({ - displayName: 'Password options', + displayName: 'Password Options', required: false, auth: PieceAuth.None(), refreshers: ['usePassword'], @@ -79,7 +73,7 @@ export const zipFiles = createAction({ }), encryptionMethod: Property.StaticDropdown({ displayName: 'Encryption Method', - description: encryptionMethodDescription, + description: 'AES-256 is far safer; ZipCrypto opens in every unzip app but is weak.', required: true, defaultValue: 'zipcrypto', options: { @@ -104,7 +98,6 @@ export const zipFiles = createAction({ const fileAddOptions: ZipWriterAddDataOptions = {}; - // Add encryption if password is provided if (context.propsValue.usePassword) { const password = context.propsValue.passwordOptions?.[ 'password' @@ -123,15 +116,13 @@ export const zipFiles = createAction({ fileAddOptions.encryptionStrength = 3; break; default: - // Default to ZipCrypto for compatibility fileAddOptions.zipCrypto = true; break; } } for (const fileProp of fileProps) { - // default to file name if filePath not explicitly provided - const zipFilePath = fileProp.filePath ?? fileProp.file.filename; + const zipFilePath = fileProp.filePath || fileProp.file.filename; const blob = new Blob([new Uint8Array(fileProp.file.data)]); await zipWriter.add(zipFilePath, new BlobReader(blob), fileAddOptions); } diff --git a/packages/pieces/core/file-helper/test/check-file-type.test.ts b/packages/pieces/core/file-helper/test/check-file-type.test.ts index b711af18aba6..63920d5478de 100644 --- a/packages/pieces/core/file-helper/test/check-file-type.test.ts +++ b/packages/pieces/core/file-helper/test/check-file-type.test.ts @@ -19,6 +19,36 @@ describe('checkFileType', () => { }); }); + test('matches a single selected MIME type exactly', async () => { + const file = new ApFile('photo.png', Buffer.from('fake-png'), 'png'); + const ctx = createMockActionContext({ + propsValue: { + file, + mimeTypes: 'image/png', + }, + }); + const result = await checkFileType.run(ctx); + expect(result).toEqual({ + mimeType: 'image/png', + isMatch: true, + }); + }); + + test('does not match a different single MIME type', async () => { + const file = new ApFile('photo.png', Buffer.from('fake-png'), 'png'); + const ctx = createMockActionContext({ + propsValue: { + file, + mimeTypes: 'image/jpeg', + }, + }); + const result = await checkFileType.run(ctx); + expect(result).toEqual({ + mimeType: 'image/png', + isMatch: false, + }); + }); + test('does not match incorrect MIME type', async () => { const file = new ApFile('doc.pdf', Buffer.from('fake-pdf'), 'pdf'); const ctx = createMockActionContext({ From 74d7dfad200cae0365a94d7996b078fdbfaaa70b Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Wed, 23 Sep 2026 01:46:41 +0300 Subject: [PATCH 6/7] feat(airtable): clearer setup forms and working record lookups (#15483) Co-authored-by: ibrahim-abuznaid --- bun.lock | 2 +- docs/install/reference/breaking-changes.mdx | 10 + .../pieces/community/airtable/package.json | 2 +- .../airtable/src/i18n/translation.json | 68 +-- .../pieces/community/airtable/src/index.ts | 2 +- .../src/lib/actions/add-comment-to-record.ts | 9 +- .../airtable/src/lib/actions/clean-record.ts | 4 +- .../airtable/src/lib/actions/create-base.ts | 3 +- .../airtable/src/lib/actions/create-record.ts | 2 +- .../airtable/src/lib/actions/create-table.ts | 6 +- .../airtable/src/lib/actions/delete-record.ts | 2 +- .../airtable/src/lib/actions/find-base.ts | 5 +- .../airtable/src/lib/actions/find-record.ts | 4 +- .../airtable/src/lib/actions/find-table.ts | 3 +- .../src/lib/actions/get-base-schema.ts | 3 +- .../airtable/src/lib/actions/update-record.ts | 3 +- .../src/lib/actions/upload-file-to-column.ts | 72 ++-- .../airtable/src/lib/common/index.ts | 390 ++++++++++++------ .../src/lib/trigger/update-record.trigger.ts | 62 ++- 19 files changed, 420 insertions(+), 232 deletions(-) diff --git a/bun.lock b/bun.lock index 100b56c42231..90f93bbf9b98 100644 --- a/bun.lock +++ b/bun.lock @@ -435,7 +435,7 @@ }, "packages/pieces/community/airtable": { "name": "@activepieces/piece-airtable", - "version": "0.8.0", + "version": "0.9.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx index 45d3f11b44b1..1170d8bb627d 100644 --- a/docs/install/reference/breaking-changes.mdx +++ b/docs/install/reference/breaking-changes.mdx @@ -176,6 +176,16 @@ Runs for a project assigned to a worker group used to fall back to the shared qu If you assign projects to worker groups, keep at least one worker online per assigned group. If a group is down, reassign the project to another group or unassign it (set its `workerGroupId` to `null`) — any assignment change moves the project's waiting runs to the new queue. +#### Airtable's Find Record returns up to 1000 matches and the Record picker lists up to 500 records + +Find Record used to return only the first page Airtable sent back, 100 records, and silently dropped the rest. It now follows Airtable's paging and returns up to 1000 matching records. The Record dropdown on Get Record by ID listed the first 50 records of a table; it now lists up to 500. + +Update Record and Clean Record now send `typecast: true`, as Create Record always has. Airtable then converts text into the field's type instead of rejecting it: a date typed as text is parsed, a select value that does not exist yet is created as a new option, and a linked-record name is resolved to the record. Before, those requests failed with `INVALID_VALUE_FOR_COLUMN`. + +#### What you need to do + +Nothing to configure or migrate. Check any Update Record or Clean Record step that writes to a single-select or multiple-select field: a value that used to fail because the option did not exist now creates that option in Airtable. Review flows that loop over Find Record's output or count its rows: a search that matched more than 100 records now returns more rows than before, and each extra page is one more request against Airtable's limit of 5 requests per second per base. + ## 0.90.4 ### What has changed? diff --git a/packages/pieces/community/airtable/package.json b/packages/pieces/community/airtable/package.json index 2b25e752a891..62610e359b86 100644 --- a/packages/pieces/community/airtable/package.json +++ b/packages/pieces/community/airtable/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-airtable", - "version": "0.8.0", + "version": "0.9.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/airtable/src/i18n/translation.json b/packages/pieces/community/airtable/src/i18n/translation.json index 06b9b6932a9e..0fdcaee5bdfb 100644 --- a/packages/pieces/community/airtable/src/i18n/translation.json +++ b/packages/pieces/community/airtable/src/i18n/translation.json @@ -35,12 +35,12 @@ "Update Field (Agent)": "Update Field (Agent)", "List Record Comments (Agent)": "List Record Comments (Agent)", "Custom API Call": "Custom API Call", - "Adds a record into an airtable": "Adds a record into an airtable", - "Find a record in airtable": "Find a record in airtable", - "Update a record in airtable": "Update a record in airtable", - "Clears fields in a record. Empty values will clear the corresponding fields.": "Clears fields in a record. Empty values will clear the corresponding fields.", - "Deletes a record in airtable": "Deletes a record in airtable", - "Uploads a file to attachment type column.": "Uploads a file to attachment type column.", + "Adds a new record to a table.": "Adds a new record to a table.", + "Returns up to 1000 records whose field contains a value.": "Returns up to 1000 records whose field contains a value.", + "Changes the fields you fill in on an existing record.": "Changes the fields you fill in on an existing record.", + "Updates a record and empties every field you leave blank.": "Updates a record and empties every field you leave blank.", + "Deletes a record from a table.": "Deletes a record from a table.", + "Uploads a file to an attachment field of a record.": "Uploads a file to an attachment field of a record.", "Adds a comment to an existing record.": "Adds a comment to an existing record.", "Create a new base with a specified table structure.": "Create a new base with a specified table structure.", "Create a new table in an existing base.": "Create a new table in an existing base.", @@ -48,7 +48,7 @@ "Get a table's details and schema using its ID.": "Get a table's details and schema using its ID.", "Retrieve a single record from a table by its unique ID.": "Retrieve a single record from a table by its unique ID.", "Find a table in a given base by its name.": "Find a table in a given base by its name.", - "Retrieve the schema for a specific base, including all its tables and fields.": "Retrieve the schema for a specific base, including all its tables and fields.", + "Lists every table in a base with its fields and views.": "Lists every table in a base with its fields and views.", "Adds a record to an Airtable table.": "Adds a record to an Airtable table.", "Retrieve a single Airtable record by its ID.": "Retrieve a single Airtable record by its ID.", "Update fields on an existing Airtable record by its ID.": "Update fields on an existing Airtable record by its ID.", @@ -70,6 +70,7 @@ "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", "Base": "Base", "Table": "Table", + "Fields": "Fields", "Search Field": "Search Field", "Search Value": "Search Value", "View": "View", @@ -78,15 +79,13 @@ "File": "File", "File Content Type": "File Content Type", "File Name": "File Name", - "Comment Text": "Comment Text", + "Comment": "Comment", "Parent Comment ID": "Parent Comment ID", "Workspace": "Workspace", "Base Name": "Base Name", "Tables": "Tables", "Table Name": "Table Name", "Description": "Description", - "Fields": "Fields", - "Base Name or Keyword": "Base Name or Keyword", "Record": "Record", "Base ID": "Base ID", "Table ID or Name": "Table ID or Name", @@ -102,6 +101,7 @@ "Typecast": "Typecast", "Record IDs": "Record IDs", "Match Mode": "Match Mode", + "Comment Text": "Comment Text", "Workspace ID": "Workspace ID", "New Name": "New Name", "New Description": "New Description", @@ -116,24 +116,26 @@ "Query Parameters": "Query Parameters", "Body Type": "Body Type", "Body": "Body", - "Response is Binary ?": "Response is Binary ?", - "No Error on Failure": "No Error on Failure", - "Timeout (in seconds)": "Timeout (in seconds)", + "Response is Binary": "Response is Binary", + "Return Error as Output": "Return Error as Output", + "Timeout": "Timeout", "Follow redirects": "Follow redirects", - "The ID of the record.": "The ID of the record.", - "The ID of the record to which you want to upload the file.": "The ID of the record to which you want to upload the file.", - "The file to be uploaded, which can be provided either as a public file URL or in Base64 encoded format.": "The file to be uploaded, which can be provided either as a public file URL or in Base64 encoded format.", - "Specifies the MIME type of the file being uploaded (e.g., 'image/png', 'application/pdf').": "Specifies the MIME type of the file being uploaded (e.g., 'image/png', 'application/pdf').", - "The name of the file as it should appear after upload.": "The name of the file as it should appear after upload.", - "The content of the comment. To mention a user, use the format `@[userId]` or `@[userEmail]`.": "The content of the comment. To mention a user, use the format `@[userId]` or `@[userEmail]`.", - "Optional. The ID of a parent comment to create a threaded reply.": "Optional. The ID of a parent comment to create a threaded reply.", - "The name for the new base.": "The name for the new base.", - "Define the tables for the new base. Use the default value as a template. The first field for each table will be its primary field.": "Define the tables for the new base. Use the default value as a template. The first field for each table will be its primary field.", - "The name for the new table.": "The name for the new table.", - "An optional description for the new table.": "An optional description for the new table.", - "A JSON array of fields for the new table. The first field in the array will become the primary field.": "A JSON array of fields for the new table. The first field in the array will become the primary field.", - "The name or keyword to search for within your base names.": "The name or keyword to search for within your base names.", - "The exact name of the table you want to find.": "The exact name of the table you want to find.", + "Case-sensitive. Records whose field contains this text are returned.": "Case-sensitive. Records whose field contains this text are returned.", + "Only records in this view are used. Empty: the whole table.": "Only records in this view are used. Empty: the whole table.", + "Starts with rec. Copy it from the record URL or an earlier step.": "Starts with rec. Copy it from the record URL or an earlier step.", + "Only attachment-type fields are listed.": "Only attachment-type fields are listed.", + "A file from an earlier step or a public URL.": "A file from an earlier step or a public URL.", + "MIME type of the file.": "MIME type of the file.", + "Name shown in Airtable. Empty: the uploaded file's own name.": "Name shown in Airtable. Empty: the uploaded file's own name.", + "Mention someone with @[userId] or @[userEmail].": "Mention someone with @[userId] or @[userEmail].", + "Reply in this comment's thread. Empty: a new top-level comment.": "Reply in this comment's thread. Empty: a new top-level comment.", + "Listed by ID because Airtable has no workspace name API.": "Listed by ID because Airtable has no workspace name API.", + "Tables to create. The first field of each table is its primary field.": "Tables to create. The first field of each table is its primary field.", + "Shown under the table name in Airtable.": "Shown under the table name in Airtable.", + "Fields for the table. The first one becomes the primary field.": "Fields for the table. The first one becomes the primary field.", + "Bases whose name contains this text are returned.": "Bases whose name contains this text are returned.", + "Lists the first 500 records by their primary field.": "Lists the first 500 records by their primary field.", + "Exact table name. Case does not matter.": "Exact table name. Case does not matter.", "The Airtable base ID (e.g. \"appXXXXXXXXXXXXXX\"). Resolve it with List Bases (Agent).": "The Airtable base ID (e.g. \"appXXXXXXXXXXXXXX\"). Resolve it with List Bases (Agent).", "The table ID (e.g. \"tblXXXXXXXXXXXXXX\") or its exact name. Resolve it with Get Base Schema (Agent).": "The table ID (e.g. \"tblXXXXXXXXXXXXXX\") or its exact name. Resolve it with Get Base Schema (Agent).", "A JSON object mapping field names to values, e.g. {\"Name\": \"Acme\", \"Status\": \"Active\"}. Use Get Base Schema (Agent) to learn field names and types. Linked-record and multi-select fields take arrays of ids/strings; attachment fields take an array of objects like [{\"url\": \"https://...\"}]. Values are sent with typecast enabled so plain strings are coerced where possible.": "A JSON object mapping field names to values, e.g. {\"Name\": \"Acme\", \"Status\": \"Active\"}. Use Get Base Schema (Agent) to learn field names and types. Linked-record and multi-select fields take arrays of ids/strings; attachment fields take an array of objects like [{\"url\": \"https://...\"}]. Values are sent with typecast enabled so plain strings are coerced where possible.", @@ -163,9 +165,11 @@ "The record ID (e.g. \"recXXXXXXXXXXXXXX\") to comment on.": "The record ID (e.g. \"recXXXXXXXXXXXXXX\") to comment on.", "The comment content. Mention a user with @[userId] or @[userEmail].": "The comment content. Mention a user with @[userId] or @[userEmail].", "Optional ID of a parent comment to reply to (creates a threaded reply). Find IDs with List Record Comments (Agent).": "Optional ID of a parent comment to reply to (creates a threaded reply). Find IDs with List Record Comments (Agent).", + "The name for the new table.": "The name for the new table.", "Optional description for the new table.": "Optional description for the new table.", "A JSON array of field definitions; the first entry becomes the primary field. Each entry is {\"name\": \"...\", \"type\": \"singleLineText\", \"description\"?: \"...\"}. Select fields need {\"type\": \"singleSelect\", \"options\": {\"choices\": [{\"name\": \"Todo\"}]}}.": "A JSON array of field definitions; the first entry becomes the primary field. Each entry is {\"name\": \"...\", \"type\": \"singleLineText\", \"description\"?: \"...\"}. Select fields need {\"type\": \"singleSelect\", \"options\": {\"choices\": [{\"name\": \"Todo\"}]}}.", "The workspace ID (e.g. \"wspXXXXXXXXXXXXXX\") to create the base in. Airtable has no list-workspaces API; get it from a base's metadata or the Airtable UI URL.": "The workspace ID (e.g. \"wspXXXXXXXXXXXXXX\") to create the base in. Airtable has no list-workspaces API; get it from a base's metadata or the Airtable UI URL.", + "The name for the new base.": "The name for the new base.", "A JSON array of tables for the new base. Each table is {\"name\": \"...\", \"fields\": [{\"name\": \"Name\", \"type\": \"singleLineText\"}, ...]}. The first field of each table becomes its primary field.": "A JSON array of tables for the new base. Each table is {\"name\": \"...\", \"fields\": [{\"name\": \"Name\", \"type\": \"singleLineText\"}, ...]}. The first field of each table becomes its primary field.", "Optional new name for the table.": "Optional new name for the table.", "Optional new description for the table.": "Optional new description for the table.", @@ -181,7 +185,11 @@ "Optional number of comments per page (max 100).": "Optional number of comments per page (max 100).", "Optional pagination offset returned by a previous call to fetch the next page.": "Optional pagination offset returned by a previous call to fetch the next page.", "Authorization headers are injected automatically from your connection.": "Authorization headers are injected automatically from your connection.", + "Appended to the URL as ?key=value.": "Appended to the URL as ?key=value.", "Enable for files like PDFs, images, etc.": "Enable for files like PDFs, images, etc.", + "On a failed request, output the error instead of failing the step.": "On a failed request, output the error instead of failing the step.", + "Seconds to wait for a response. Empty: up to the flow limit (10 min).": "Seconds to wait for a response. Empty: up to the flow limit (10 min).", + "Follow 3xx redirects instead of returning them as the response.": "Follow 3xx redirects instead of returning them as the response.", "Contains": "Contains", "Exact": "Exact", "GET": "GET", @@ -197,7 +205,7 @@ "New Record": "New Record", "New or Updated Record": "New or Updated Record", "Triggers when a new record is added to the selected table.": "Triggers when a new record is added to the selected table.", - "Triggers when a record is created or updated in selected table.": "Triggers when a record is created or updated in selected table.", - "Trigger field": "Trigger field", - "**Last Modified Time** field will be used to watch new or updated records.Please create **Last Modified Time** field in your schema,if you don't have any timestamp field.": "**Last Modified Time** field will be used to watch new or updated records.Please create **Last Modified Time** field in your schema,if you don't have any timestamp field." + "Triggers when a record is created or changed in the selected table.": "Triggers when a record is created or changed in the selected table.", + "Trigger Field": "Trigger Field", + "A Last Modified Time field of the table. Add one if none exists.": "A Last Modified Time field of the table. Add one if none exists." } \ No newline at end of file diff --git a/packages/pieces/community/airtable/src/index.ts b/packages/pieces/community/airtable/src/index.ts index 0faa000213c6..636c63bf6ffe 100644 --- a/packages/pieces/community/airtable/src/index.ts +++ b/packages/pieces/community/airtable/src/index.ts @@ -44,7 +44,7 @@ export const airtable = createPiece({ displayName: 'Airtable', description: 'Low‒code platform to build apps.', - minimumSupportedRelease: '0.86.4', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/airtable.png', authors: [ 'kanarelo', diff --git a/packages/pieces/community/airtable/src/lib/actions/add-comment-to-record.ts b/packages/pieces/community/airtable/src/lib/actions/add-comment-to-record.ts index 1158063419d2..343e7d455e00 100644 --- a/packages/pieces/community/airtable/src/lib/actions/add-comment-to-record.ts +++ b/packages/pieces/community/airtable/src/lib/actions/add-comment-to-record.ts @@ -21,16 +21,17 @@ export const airtableAddCommentToRecordAction = createAction({ tableId: airtableCommon.tableId, recordId: airtableCommon.recordId, text: Property.LongText({ - displayName: 'Comment Text', - description: - 'The content of the comment. To mention a user, use the format `@[userId]` or `@[userEmail]`.', + displayName: 'Comment', + description: 'Mention someone with @[userId] or @[userEmail].', required: true, }), parentCommentId: Property.ShortText({ displayName: 'Parent Comment ID', description: - 'Optional. The ID of a parent comment to create a threaded reply.', + "Reply in this comment's thread. Empty: a new top-level comment.", + placeholder: 'comXXXXXXXXXXXXXX', required: false, + advanced: true, }), }, async run(context) { diff --git a/packages/pieces/community/airtable/src/lib/actions/clean-record.ts b/packages/pieces/community/airtable/src/lib/actions/clean-record.ts index 89d567e55bcb..bb1f48d40788 100644 --- a/packages/pieces/community/airtable/src/lib/actions/clean-record.ts +++ b/packages/pieces/community/airtable/src/lib/actions/clean-record.ts @@ -11,8 +11,7 @@ export const airtableCleanRecordAction = createAction({ name: 'airtable_clean_record', classification: 'DESTRUCTIVE', displayName: 'Clean Record', - description: - 'Clears fields in a record. Empty values will clear the corresponding fields.', + description: 'Updates a record and empties every field you leave blank.', audience: 'both', outputSchema: cleanRecordActionOutputSchema, aiMetadata: { @@ -45,6 +44,7 @@ export const airtableCleanRecordAction = createAction({ tableId: tableId as string, recordId: recordId as string, fields: updatedFields as Record, + typecast: true, }); }, }); diff --git a/packages/pieces/community/airtable/src/lib/actions/create-base.ts b/packages/pieces/community/airtable/src/lib/actions/create-base.ts index 8387a1c04c5b..b5540e9288e7 100644 --- a/packages/pieces/community/airtable/src/lib/actions/create-base.ts +++ b/packages/pieces/community/airtable/src/lib/actions/create-base.ts @@ -21,13 +21,12 @@ export const airtableCreateBaseAction = createAction({ workspaceId: airtableCommon.workspaceId, name: Property.ShortText({ displayName: 'Base Name', - description: 'The name for the new base.', required: true, }), tables: Property.Json({ displayName: 'Tables', description: - 'Define the tables for the new base. Use the default value as a template. The first field for each table will be its primary field.', + 'Tables to create. The first field of each table is its primary field.', required: true, defaultValue: [ { diff --git a/packages/pieces/community/airtable/src/lib/actions/create-record.ts b/packages/pieces/community/airtable/src/lib/actions/create-record.ts index 415d218347d7..93adae647283 100644 --- a/packages/pieces/community/airtable/src/lib/actions/create-record.ts +++ b/packages/pieces/community/airtable/src/lib/actions/create-record.ts @@ -11,7 +11,7 @@ export const airtableCreateRecordAction = createAction({ name: 'airtable_create_record', classification: 'WRITE', displayName: 'Create Airtable Record', - description: 'Adds a record into an airtable', + description: 'Adds a new record to a table.', audience: 'human', outputSchema: createRecordActionOutputSchema, aiMetadata: { diff --git a/packages/pieces/community/airtable/src/lib/actions/create-table.ts b/packages/pieces/community/airtable/src/lib/actions/create-table.ts index 4f78f51c007f..76928c1c4ffa 100644 --- a/packages/pieces/community/airtable/src/lib/actions/create-table.ts +++ b/packages/pieces/community/airtable/src/lib/actions/create-table.ts @@ -21,18 +21,18 @@ export const airtableCreateTableAction = createAction({ base: airtableCommon.base, name: Property.ShortText({ displayName: 'Table Name', - description: 'The name for the new table.', required: true, }), description: Property.LongText({ displayName: 'Description', - description: 'An optional description for the new table.', + description: 'Shown under the table name in Airtable.', required: false, + advanced: true, }), fields: Property.Json({ displayName: 'Fields', description: - 'A JSON array of fields for the new table. The first field in the array will become the primary field.', + 'Fields for the table. The first one becomes the primary field.', required: true, defaultValue: [ { diff --git a/packages/pieces/community/airtable/src/lib/actions/delete-record.ts b/packages/pieces/community/airtable/src/lib/actions/delete-record.ts index b6bf5ba18676..4000ffbc4026 100644 --- a/packages/pieces/community/airtable/src/lib/actions/delete-record.ts +++ b/packages/pieces/community/airtable/src/lib/actions/delete-record.ts @@ -8,7 +8,7 @@ export const airtableDeleteRecordAction = createAction({ name: 'airtable_delete_record', classification: 'DESTRUCTIVE', displayName: 'Delete Airtable Record', - description: 'Deletes a record in airtable', + description: 'Deletes a record from a table.', audience: 'human', outputSchema: deleteRecordActionOutputSchema, aiMetadata: { diff --git a/packages/pieces/community/airtable/src/lib/actions/find-base.ts b/packages/pieces/community/airtable/src/lib/actions/find-base.ts index 6509e1e810e3..20a3ee390ba1 100644 --- a/packages/pieces/community/airtable/src/lib/actions/find-base.ts +++ b/packages/pieces/community/airtable/src/lib/actions/find-base.ts @@ -19,8 +19,9 @@ export const airtableFindBaseAction = createAction({ }, props: { baseName: Property.ShortText({ - displayName: 'Base Name or Keyword', - description: 'The name or keyword to search for within your base names.', + displayName: 'Base Name', + description: 'Bases whose name contains this text are returned.', + placeholder: 'e.g. CRM', required: true, }), }, diff --git a/packages/pieces/community/airtable/src/lib/actions/find-record.ts b/packages/pieces/community/airtable/src/lib/actions/find-record.ts index 9fce43e9643b..ea5c13ebfc4b 100644 --- a/packages/pieces/community/airtable/src/lib/actions/find-record.ts +++ b/packages/pieces/community/airtable/src/lib/actions/find-record.ts @@ -9,7 +9,7 @@ export const airtableFindRecordAction = createAction({ name: 'airtable_find_record', classification: 'SEARCH', displayName: 'Find Airtable Record', - description: 'Find a record in airtable', + description: 'Returns up to 1000 records whose field contains a value.', audience: 'human', outputSchema: findRecordActionOutputSchema, aiMetadata: { @@ -24,6 +24,8 @@ export const airtableFindRecordAction = createAction({ searchValue: Property.ShortText({ displayName: 'Search Value', required: true, + description: 'Case-sensitive. Records whose field contains this text are returned.', + placeholder: 'e.g. jane@example.com', }), limitToView: airtableCommon.views, }, diff --git a/packages/pieces/community/airtable/src/lib/actions/find-table.ts b/packages/pieces/community/airtable/src/lib/actions/find-table.ts index 33366e74cfd1..50e42e8342ec 100644 --- a/packages/pieces/community/airtable/src/lib/actions/find-table.ts +++ b/packages/pieces/community/airtable/src/lib/actions/find-table.ts @@ -21,7 +21,8 @@ export const airtableFindTableAction = createAction({ base: airtableCommon.base, tableName: Property.ShortText({ displayName: 'Table Name', - description: 'The exact name of the table you want to find.', + description: 'Exact table name. Case does not matter.', + placeholder: 'e.g. Contacts', required: true, }), }, diff --git a/packages/pieces/community/airtable/src/lib/actions/get-base-schema.ts b/packages/pieces/community/airtable/src/lib/actions/get-base-schema.ts index 16c58f2c8fb5..036dc72f4b0c 100644 --- a/packages/pieces/community/airtable/src/lib/actions/get-base-schema.ts +++ b/packages/pieces/community/airtable/src/lib/actions/get-base-schema.ts @@ -8,8 +8,7 @@ export const airtableGetBaseSchemaAction = createAction({ name: 'airtable_get_base_schema', classification: 'READ', displayName: 'Get Base Schema', - description: - 'Retrieve the schema for a specific base, including all its tables and fields.', + description: 'Lists every table in a base with its fields and views.', audience: 'human', outputSchema: getBaseSchemaActionOutputSchema, aiMetadata: { diff --git a/packages/pieces/community/airtable/src/lib/actions/update-record.ts b/packages/pieces/community/airtable/src/lib/actions/update-record.ts index 252ac6854b71..16a7330f6194 100644 --- a/packages/pieces/community/airtable/src/lib/actions/update-record.ts +++ b/packages/pieces/community/airtable/src/lib/actions/update-record.ts @@ -12,7 +12,7 @@ export const airtableUpdateRecordAction = createAction({ name: 'airtable_update_record', classification: 'WRITE', displayName: 'Update Airtable Record', - description: 'Update a record in airtable', + description: 'Changes the fields you fill in on an existing record.', audience: 'human', outputSchema: updateRecordActionOutputSchema, aiMetadata: { @@ -56,6 +56,7 @@ export const airtableUpdateRecordAction = createAction({ tableId: tableId as string, recordId: recordId as string, fields: updatedFields as Record, + typecast: true, }); }, }); diff --git a/packages/pieces/community/airtable/src/lib/actions/upload-file-to-column.ts b/packages/pieces/community/airtable/src/lib/actions/upload-file-to-column.ts index c8e8e159280d..63dc1b18e3ec 100644 --- a/packages/pieces/community/airtable/src/lib/actions/upload-file-to-column.ts +++ b/packages/pieces/community/airtable/src/lib/actions/upload-file-to-column.ts @@ -15,7 +15,7 @@ export const airtableUploadFileToColumnAction = createAction({ name: 'airtable_upload_file_to_column', classification: 'WRITE', displayName: 'Upload File to Column', - description: 'Uploads a file to attachment type column.', + description: 'Uploads a file to an attachment field of a record.', audience: 'human', outputSchema: uploadFileToColumnActionOutputSchema, aiMetadata: { @@ -26,60 +26,84 @@ export const airtableUploadFileToColumnAction = createAction({ props: { base: airtableCommon.base, tableId: airtableCommon.tableId, - attachment_column: Property.Dropdown({ + attachment_column: Property.Dropdown({ auth: airtableAuth, displayName: 'Attachment Column', + description: 'Only attachment-type fields are listed.', required: true, refreshers: ['base', 'tableId'], options: async ({ auth, base, tableId }) => { - if (!auth || !base || !tableId) { + if (!auth) { return { - placeholder: 'Please select a base and table first', + disabled: true, options: [], + placeholder: 'Connect your Airtable account first', + }; + } + if (!base) { + return { disabled: true, + options: [], + placeholder: 'Select a base first', + }; + } + if (!tableId) { + return { + disabled: true, + options: [], + placeholder: 'Select a table first', }; } - const airtable: AirtableTable = await airtableCommon.fetchTable({ - token: auth as unknown as string, - baseId: base as unknown as string, - tableId: tableId as unknown as string, - }); - - return { - disabled: false, + try { + const airtable: AirtableTable = await airtableCommon.fetchTable({ + token: auth.secret_text, + baseId: base as string, + tableId: tableId as string, + }); - options: airtable.fields - .filter((field) => field.type === 'multipleAttachments') - .map((field) => { - return { + return { + disabled: false, + options: airtable.fields + .filter((field) => field.type === 'multipleAttachments') + .map((field) => ({ label: field.name, value: field.id, - }; - }), - }; + })), + }; + } catch (e) { + console.debug(e); + return { + disabled: true, + options: [], + placeholder: `Could not load. Check the token's scopes.`, + }; + } }, }), recordId: Property.ShortText({ displayName: 'Record ID', required: true, - description: 'The ID of the record to which you want to upload the file.', + description: + 'Starts with rec. Copy it from the record URL or an earlier step.', + placeholder: 'recXXXXXXXXXXXXXX', }), file: Property.File({ displayName: 'File', required: true, - description: - 'The file to be uploaded, which can be provided either as a public file URL or in Base64 encoded format.', + description: 'A file from an earlier step or a public URL.', }), file_content_type: Property.ShortText({ displayName: 'File Content Type', required: true, - description: `Specifies the MIME type of the file being uploaded (e.g., 'image/png', 'application/pdf').`, + description: 'MIME type of the file.', + placeholder: 'image/png', }), filename: Property.ShortText({ displayName: 'File Name', - description: 'The name of the file as it should appear after upload.', + description: `Name shown in Airtable. Empty: the uploaded file's own name.`, required: false, + advanced: true, }), }, async run(context) { diff --git a/packages/pieces/community/airtable/src/lib/common/index.ts b/packages/pieces/community/airtable/src/lib/common/index.ts index 31245ad888fc..6aa6849f2447 100644 --- a/packages/pieces/community/airtable/src/lib/common/index.ts +++ b/packages/pieces/community/airtable/src/lib/common/index.ts @@ -2,6 +2,7 @@ import { AuthenticationType, HttpMethod, HttpRequest, + HttpResponse, httpClient, } from '@activepieces/pieces-common'; import { @@ -26,6 +27,37 @@ import { import { isNil } from '@activepieces/pieces-framework'; import { airtableAuth } from '../auth'; +const MAX_FIND_RECORDS = 1000; +const RATE_LIMIT_STATUS = 429; +const RATE_LIMIT_PAUSE_MS = 30000; +const RATE_LIMIT_RETRIES = 2; +const DROPDOWN_RATE_LIMIT_RETRIES = 0; + +function isRateLimited(error: unknown): boolean { + return ( + (error as { response?: { status?: number } })?.response?.status === + RATE_LIMIT_STATUS + ); +} + +async function sendPage( + request: HttpRequest, + rateLimitRetries: number +): Promise> { + for (let attempt = 0; ; attempt++) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_PAUSE_MS)); + } + try { + return await httpClient.sendRequest(request); + } catch (error) { + if (!isRateLimited(error) || attempt >= rateLimitRetries) { + throw error; + } + } + } +} + interface Params { personalToken: string; @@ -43,6 +75,7 @@ interface Params { workspaceId?: string; name?: string; tables?: AirtableTableConfig[]; + typecast?: boolean; } async function fetchAllBases({ @@ -102,28 +135,42 @@ async function listRecords({ token, baseId, tableId, - pageSize = 50, + pageSize = 100, + maxRecords = 500, }: { token: string; baseId: string; tableId: string; pageSize?: number; + maxRecords?: number; }): Promise { - const response = await httpClient.sendRequest<{ records: AirtableRecord[] }>({ - method: HttpMethod.GET, - url: `https://api.airtable.com/v0/${baseId}/${tableId}`, - authentication: { - type: AuthenticationType.BEARER_TOKEN, - token, - }, - queryParams: { - pageSize: pageSize.toString(), - }, - }); - if (response.status === 200) { - return response.body.records; - } - return []; + const allRecords: AirtableRecord[] = []; + let offset: string | undefined = undefined; + + do { + const request: HttpRequest = { + method: HttpMethod.GET, + url: `https://api.airtable.com/v0/${baseId}/${tableId}`, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token, + }, + queryParams: { + pageSize: pageSize.toString(), + ...(offset ? { offset } : {}), + }, + retries: 3, + }; + const response = await sendPage<{ + records: AirtableRecord[]; + offset?: string; + }>(request, DROPDOWN_RATE_LIMIT_RETRIES); + + allRecords.push(...response.body.records); + offset = response.body.offset; + } while (offset && allRecords.length < maxRecords); + + return allRecords.slice(0, maxRecords); } async function fetchTableList({ @@ -214,28 +261,44 @@ async function findRecord({ baseId, limitToView, }: Params) { - const request: HttpRequest = { - method: HttpMethod.GET, - url: `https://api.airtable.com/v0/${baseId}/${tableId}`, - authentication: { - type: AuthenticationType.BEARER_TOKEN, - token, - }, - queryParams: { - filterByFormula: `FIND("${searchValue}",{${searchField}})`, - view: limitToView ?? '', - }, - }; + const escapedSearchValue = (searchValue ?? '') + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"'); + if (/[{}]/.test(searchField ?? '')) { + throw new Error( + 'Search Field must not contain "{" or "}" because Airtable cannot escape braces inside a field reference.' + ); + } + const allRecords: AirtableRecord[] = []; + let offset: string | undefined = undefined; - const response = await httpClient.sendRequest<{ - records: AirtableRecord[]; - }>(request); + do { + const request: HttpRequest = { + method: HttpMethod.GET, + url: `https://api.airtable.com/v0/${baseId}/${tableId}`, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token, + }, + queryParams: { + filterByFormula: `FIND("${escapedSearchValue}",{${searchField}})`, + pageSize: '100', + ...(limitToView ? { view: limitToView } : {}), + ...(offset ? { offset } : {}), + }, + retries: 3, + }; - if (response.status === 200) { - return response.body.records; - } + const response = await sendPage<{ + records: AirtableRecord[]; + offset?: string; + }>(request, RATE_LIMIT_RETRIES); - return []; + allRecords.push(...response.body.records); + offset = response.body.offset; + } while (offset && allRecords.length < MAX_FIND_RECORDS); + + return allRecords.slice(0, MAX_FIND_RECORDS); } async function updateRecord({ personalToken: token, @@ -243,6 +306,7 @@ async function updateRecord({ recordId, tableId, baseId, + typecast, }: Params) { const request: HttpRequest = { method: HttpMethod.PATCH, @@ -253,6 +317,7 @@ async function updateRecord({ }, body: { fields, + ...(typecast ? { typecast: true } : {}), }, }; @@ -432,7 +497,7 @@ export const airtableCommon = { return { disabled: true, options: [], - placeholder: 'Please connect your account', + placeholder: 'Connect your Airtable account first', }; } @@ -451,7 +516,7 @@ export const airtableCommon = { return { disabled: true, options: [], - placeholder: 'Please check your permission scope', + placeholder: "Could not load. Check the token's scopes.", }; } }, @@ -460,6 +525,8 @@ export const airtableCommon = { workspaceId: Property.Dropdown({ auth: airtableAuth, displayName: 'Workspace', + description: + 'Listed by ID because Airtable has no workspace name API.', required: true, refreshers: [], options: async ({ auth }) => { @@ -467,35 +534,42 @@ export const airtableCommon = { return { disabled: true, options: [], - placeholder: 'Please connect your account', + placeholder: 'Connect your Airtable account first', }; } - // Although there is no direct way to get a list of workspaces, - // we can get a list of bases and then fetch each base to get the workspace id. - const bases = await fetchAllBases({ - token: auth.secret_text, - }); - const workspacePromises = bases.map((base) => - fetchBase({ token: auth.secret_text, baseId: base.id }) - ); - const basesWithWorkspaces = await Promise.all(workspacePromises); + try { + const bases = await fetchAllBases({ + token: auth.secret_text, + }); - const workspaces = basesWithWorkspaces.reduce((acc, base) => { - if (base.workspaceId) { - // Since we don't have the workspace name, we will use the ID as the name. - acc[base.workspaceId] = base.workspaceId; - } - return acc; - }, {} as Record); - - return { - disabled: false, - options: Object.entries(workspaces).map(([id, name]) => ({ - label: name, - value: id, - })), - }; + const workspacePromises = bases.map((base) => + fetchBase({ token: auth.secret_text, baseId: base.id }) + ); + const basesWithWorkspaces = await Promise.all(workspacePromises); + + const workspaces = basesWithWorkspaces.reduce((acc, base) => { + if (base.workspaceId) { + acc[base.workspaceId] = base.workspaceId; + } + return acc; + }, {} as Record); + + return { + disabled: false, + options: Object.entries(workspaces).map(([id, name]) => ({ + label: name, + value: id, + })), + }; + } catch (e) { + console.debug(e); + return { + disabled: true, + options: [], + placeholder: "Could not load. Check the token's scopes.", + }; + } }, }), @@ -509,14 +583,14 @@ export const airtableCommon = { return { disabled: true, options: [], - placeholder: 'Please connect your account', + placeholder: 'Connect your Airtable account first', }; } if (!base) { return { disabled: true, options: [], - placeholder: 'Please select a base first', + placeholder: 'Select a base first', }; } @@ -538,7 +612,7 @@ export const airtableCommon = { return { disabled: true, options: [], - placeholder: 'Please check your permission scope', + placeholder: "Could not load. Check the token's scopes.", }; } }, @@ -547,6 +621,7 @@ export const airtableCommon = { views: Property.Dropdown({ auth: airtableAuth, displayName: 'View', + description: 'Only records in this view are used. Empty: the whole table.', required: false, refreshers: ['base', 'tableId'], options: async ({ auth, base, tableId }): Promise> => { @@ -554,49 +629,60 @@ export const airtableCommon = { return { disabled: true, options: [], - placeholder: 'Please connect your account', + placeholder: 'Connect your Airtable account first', }; } if (!base) { return { disabled: true, options: [], - placeholder: 'Please select a base first', + placeholder: 'Select a base first', }; } if (!tableId) { return { disabled: true, options: [], - placeholder: 'Please select a table first', + placeholder: 'Select a table first', }; } - const views: AirtableView[] = await fetchViews({ - token: auth.secret_text, - baseId: base as string, - tableId: tableId as string, - }); + try { + const views: AirtableView[] = await fetchViews({ + token: auth.secret_text, + baseId: base as string, + tableId: tableId as string, + }); - return { - disabled: false, - options: views.map((view) => ({ - value: view.id, - label: view.name, - })), - }; + return { + disabled: false, + options: views.map((view) => ({ + value: view.id, + label: view.name, + })), + }; + } catch (e) { + console.debug(e); + return { + disabled: true, + options: [], + placeholder: "Could not load. Check the token's scopes.", + }; + } }, }), recordId: Property.ShortText({ displayName: 'Record ID', required: true, - description: 'The ID of the record.', + description: + 'Starts with rec. Copy it from the record URL or an earlier step.', + placeholder: 'recXXXXXXXXXXXXXX', }), fields: Property.DynamicProperties({ auth: airtableAuth, - displayName: 'Table', + displayName: 'Fields', required: true, refreshers: ['base', 'tableId'], @@ -614,12 +700,14 @@ export const airtableCommon = { if (!AirtableEnterpriseFields.includes(field.type)) { const params = { displayName: field.name, - description: ['date', 'dateTime'].includes(field.type) - ? `${ - field.description ? field.description : '' - }Expected format: mmmm d,yyyy` - : field.description, + description: field.description, required: false, + ...(field.type === 'date' + ? { placeholder: '2024-03-05' } + : {}), + ...(field.type === 'dateTime' + ? { placeholder: '2024-03-05T10:30:00Z' } + : {}), }; if (isNil(AirtableFieldMapping[field.type])) { @@ -658,51 +746,73 @@ export const airtableCommon = { recordIdDropdown: Property.Dropdown({ auth: airtableAuth, displayName: 'Record', + description: + 'Lists the first 500 records by their primary field.', required: true, refreshers: ['base', 'tableId'], options: async ({ auth, base, tableId }) => { - if (!auth || !base || !tableId) { + if (!auth) { return { disabled: true, options: [], - placeholder: 'Please select a base and table first', + placeholder: 'Connect your Airtable account first', + }; + } + if (!base) { + return { + disabled: true, + options: [], + placeholder: 'Select a base first', + }; + } + if (!tableId) { + return { + disabled: true, + options: [], + placeholder: 'Select a table first', }; } + try { + const table = await fetchTable({ + token: auth.secret_text, + baseId: base as string, + tableId: tableId as string, + }); + const primaryField = table.fields.find( + (f) => f.id === table.primaryFieldId + ); + const primaryFieldName = primaryField?.name; + + const records = await listRecords({ + token: auth.secret_text, + baseId: base as string, + tableId: tableId as string, + }); + + const options = records.map((record) => { + let label = record.id; + if (primaryFieldName && record.fields[primaryFieldName]) { + label = record.fields[primaryFieldName] as string; + } + return { + label: label, + value: record.id, + }; + }); - const table = await fetchTable({ - token: auth.secret_text, - baseId: base as string, - tableId: tableId as string, - }); - const primaryField = table.fields.find( - (f) => f.id === table.primaryFieldId - ); - const primaryFieldName = primaryField?.name; - - - const records = await listRecords({ - token: auth.secret_text, - baseId: base as string, - tableId: tableId as string, - }); - - - const options = records.map((record) => { - let label = record.id; - if (primaryFieldName && record.fields[primaryFieldName]) { - label = record.fields[primaryFieldName] as string; - } return { - label: label, - value: record.id, + disabled: false, + options: options, }; - }); - - return { - disabled: false, - options: options, - }; + } catch (e) { + console.debug(e); + return { + disabled: true, + options: [], + placeholder: "Could not load. Check the token's scopes.", + }; + } }, }), @@ -716,35 +826,45 @@ export const airtableCommon = { return { disabled: true, options: [], - placeholder: 'Please connect your account', + placeholder: 'Connect your Airtable account first', }; } if (!base) { return { disabled: true, options: [], - placeholder: 'Please select a base first', + placeholder: 'Select a base first', }; } if (!tableId) { return { disabled: true, options: [], - placeholder: 'Please select a table first', + placeholder: 'Select a table first', + }; + } + + try { + const airtable: AirtableTable = await fetchTable({ + token: auth.secret_text, + baseId: base as string, + tableId: tableId as string, + }); + return { + disabled: false, + options: airtable.fields.map((field: AirtableField) => ({ + label: field.name, + value: field.name, + })), + }; + } catch (e) { + console.debug(e); + return { + disabled: true, + options: [], + placeholder: "Could not load. Check the token's scopes.", }; } - const airtable: AirtableTable = await fetchTable({ - token: auth.secret_text, - baseId: base as string, - tableId: tableId as string, - }); - return { - disabled: false, - options: airtable.fields.map((field: AirtableField) => ({ - label: field.name, - value: field.name, - })), - }; }, }), diff --git a/packages/pieces/community/airtable/src/lib/trigger/update-record.trigger.ts b/packages/pieces/community/airtable/src/lib/trigger/update-record.trigger.ts index 4d3180756aac..9db93fb9eb7d 100644 --- a/packages/pieces/community/airtable/src/lib/trigger/update-record.trigger.ts +++ b/packages/pieces/community/airtable/src/lib/trigger/update-record.trigger.ts @@ -5,6 +5,7 @@ import { } from '@activepieces/pieces-common'; import { AppConnectionValueForAuthProperty, + DropdownState, Property, StaticPropsValue, TriggerStrategy, @@ -20,49 +21,70 @@ import { updatedRecordTriggerOutputSchema } from '../output-schemas'; const props = { base: airtableCommon.base, tableId: airtableCommon.tableId, - sortFields: Property.Dropdown({ + sortFields: Property.Dropdown({ auth: airtableAuth, - displayName: 'Trigger field', - description: `**Last Modified Time** field will be used to watch new or updated records.Please create **Last Modified Time** field in your schema,if you don't have any timestamp field.`, + displayName: 'Trigger Field', + description: + 'A Last Modified Time field of the table. Add one if none exists.', required: true, refreshers: ['base', 'tableId'], - options: async ({ auth, base, tableId }) => { + options: async ({ auth, base, tableId }): Promise> => { if (!auth) { return { disabled: true, options: [], - placeholder: 'Please connect your account', + placeholder: 'Connect your Airtable account first', }; } if (!base) { return { disabled: true, options: [], - placeholder: 'Please select a base first', + placeholder: 'Select a base first', }; } if (!tableId) { return { disabled: true, options: [], - placeholder: 'Please select a table first', + placeholder: 'Select a table first', }; } - const airtable: AirtableTable = await airtableCommon.fetchTable({ - token: auth as unknown as string, - baseId: base as unknown as string, - tableId: tableId as unknown as string, - }); - return { - disabled: false, - options: airtable.fields - .filter((field: AirtableField) => field.type == 'lastModifiedTime') + try { + const airtable: AirtableTable = await airtableCommon.fetchTable({ + token: auth.secret_text, + baseId: base as string, + tableId: tableId as string, + }); + + const options = airtable.fields + .filter((field: AirtableField) => field.type === 'lastModifiedTime') .map((field: AirtableField) => ({ label: field.name, value: field.name, - })), - }; + })); + + if (options.length === 0) { + return { + disabled: true, + options: [], + placeholder: 'This table has no Last Modified Time field', + }; + } + + return { + disabled: false, + options, + }; + } catch (e) { + console.debug(e); + return { + disabled: true, + options: [], + placeholder: "Could not load. Check the token's scopes.", + }; + } }, }), viewId: airtableCommon.views, @@ -87,7 +109,7 @@ const polling: Polling, S filterByFormula: `IS_AFTER({${ propsValue.sortFields as string }},DATETIME_PARSE("${lastUpdateDate}","YYYY-MM-DD HH:mm:ss.SSS"))`, - view: propsValue.viewId ?? '', + ...(propsValue.viewId ? { view: propsValue.viewId } : {}), }) .all(); @@ -108,7 +130,7 @@ export const airtableUpdatedRecordTrigger = createTrigger({ classification: 'READ', displayName: 'New or Updated Record', description: - 'Triggers when a record is created or updated in selected table.', + 'Triggers when a record is created or changed in the selected table.', outputSchema: updatedRecordTriggerOutputSchema, aiMetadata: { description: From 9efb68c3a44d0ce4f565a6e0168c3b5986e365af Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Wed, 23 Sep 2026 01:47:58 +0300 Subject: [PATCH 7/7] feat(delay): improve the step forms (#15318) --- bun.lock | 4 +- packages/pieces/core/delay/package.json | 8 +- .../core/delay/src/i18n/translation.json | 10 +-- packages/pieces/core/delay/src/index.ts | 4 +- .../delay/src/lib/actions/delay-for-action.ts | 17 ++-- .../src/lib/actions/delay-until-action.ts | 13 ++-- .../core/delay/test/delay-for-action.test.ts | 78 +++++++++++++++++++ packages/pieces/core/delay/vitest.config.ts | 17 ++++ 8 files changed, 120 insertions(+), 31 deletions(-) create mode 100644 packages/pieces/core/delay/test/delay-for-action.test.ts create mode 100644 packages/pieces/core/delay/vitest.config.ts diff --git a/bun.lock b/bun.lock index 90f93bbf9b98..2466e08667f3 100644 --- a/bun.lock +++ b/bun.lock @@ -10379,7 +10379,7 @@ }, "packages/pieces/core/delay": { "name": "@activepieces/piece-delay", - "version": "0.3.34", + "version": "0.3.35", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", @@ -10626,7 +10626,7 @@ }, "packages/pieces/core/tables": { "name": "@activepieces/piece-tables", - "version": "0.5.1", + "version": "0.5.2", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/core/delay/package.json b/packages/pieces/core/delay/package.json index 2f30646c239b..6fcdf7b5c6eb 100644 --- a/packages/pieces/core/delay/package.json +++ b/packages/pieces/core/delay/package.json @@ -1,12 +1,13 @@ { "name": "@activepieces/piece-delay", - "version": "0.3.34", + "version": "0.3.35", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { "build": "tsc -p tsconfig.lib.json && cp package.json dist/", "bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle", - "lint": "eslint 'src/**/*.ts'" + "lint": "eslint 'src/**/*.ts'", + "test": "vitest run" }, "dependencies": { "@activepieces/pieces-common": "workspace:*", @@ -17,6 +18,7 @@ "@activepieces/core-utils": "workspace:*" }, "devDependencies": { - "tslib": "2.6.2" + "tslib": "2.6.2", + "vitest": "3.2.6" } } diff --git a/packages/pieces/core/delay/src/i18n/translation.json b/packages/pieces/core/delay/src/i18n/translation.json index 8bbca67bec39..abe75f193b30 100644 --- a/packages/pieces/core/delay/src/i18n/translation.json +++ b/packages/pieces/core/delay/src/i18n/translation.json @@ -1,17 +1,15 @@ { - "Use it to delay the execution of the next action": "Use it to delay the execution of the next action", + "Pause your flow for a set duration or until a specific time": "Pause your flow for a set duration or until a specific time", "Delay For": "Delay For", "Delay Until": "Delay Until", "Delays the execution of the next action for a given duration": "Delays the execution of the next action for a given duration", "Delays the execution of the next action until a given timestamp": "Delays the execution of the next action until a given timestamp", - "Markdown": "Markdown", - "Unit": "Unit", "Amount": "Amount", + "Unit": "Unit", + "Markdown": "Markdown", "Date and Time": "Date and Time", "\n**Note:** The maximum duration per step is {{pausedFlowTimeoutDays}} days.\n": "\n**Note:** The maximum duration per step is {{pausedFlowTimeoutDays}} days.\n", - "The unit of time to delay the execution of the next action": "The unit of time to delay the execution of the next action", - "The number of units to delay the execution of the next action": "The number of units to delay the execution of the next action", - "Specifies the date and time until which the execution of the next action should be delayed. It supports multiple formats, including ISO format.": "Specifies the date and time until which the execution of the next action should be delayed. It supports multiple formats, including ISO format.", + "A time without a timezone is read as UTC.": "A time without a timezone is read as UTC.", "Seconds": "Seconds", "Minutes": "Minutes", "Hours": "Hours", diff --git a/packages/pieces/core/delay/src/index.ts b/packages/pieces/core/delay/src/index.ts index 3510e6be9027..7835fac30d08 100644 --- a/packages/pieces/core/delay/src/index.ts +++ b/packages/pieces/core/delay/src/index.ts @@ -5,8 +5,8 @@ import { delayUntilAction } from './lib/actions/delay-until-action'; export const delay = createPiece({ displayName: 'Delay', - description: 'Use it to delay the execution of the next action', - minimumSupportedRelease: '0.82.0', + description: 'Pause your flow for a set duration or until a specific time', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/new-core/delay.svg', authors: ["Nilesh","kishanprmr","MoShizzle","AbdulTheActivePiecer","khaledmashaly","abuaboud"], categories: [PieceCategory.CORE, PieceCategory.FLOW_CONTROL], diff --git a/packages/pieces/core/delay/src/lib/actions/delay-for-action.ts b/packages/pieces/core/delay/src/lib/actions/delay-for-action.ts index f05fe563675f..f28fbfa702bc 100644 --- a/packages/pieces/core/delay/src/lib/actions/delay-for-action.ts +++ b/packages/pieces/core/delay/src/lib/actions/delay-for-action.ts @@ -19,6 +19,7 @@ enum TimeUnit { // that a real run would reject. const propsSchema = { delayFor: z.number().check(z.minimum(0)), + unit: z.nullish(z.enum(TimeUnit)), }; export const delayForAction = createAction({ @@ -37,12 +38,13 @@ export const delayForAction = createAction({ }, }, props: { - markdown: Property.MarkDown({ - value: markdownDescription, + delayFor: Property.Number({ + displayName: 'Amount', + required: true, + defaultValue: 5, }), unit: Property.StaticDropdown({ displayName: 'Unit', - description: 'The unit of time to delay the execution of the next action', required: true, options: { options: [ @@ -54,11 +56,8 @@ export const delayForAction = createAction({ }, defaultValue: TimeUnit.SECONDS, }), - delayFor: Property.Number({ - displayName: 'Amount', - description: - 'The number of units to delay the execution of the next action', - required: true, + markdown: Property.MarkDown({ + value: markdownDescription, }), }, outputSchema: delayForActionOutputSchema, @@ -73,7 +72,6 @@ export const delayForAction = createAction({ success: true, }; } else if (delayInMs > 1 * 10 * 1000) { - // use flow pause const currentTime = new Date(); const futureTime = new Date(currentTime.getTime() + delayInMs); const waitpoint = await ctx.run.createWaitpoint({ @@ -83,7 +81,6 @@ export const delayForAction = createAction({ ctx.run.waitForWaitpoint(waitpoint.id); return {}; } else { - // use setTimeout await new Promise((resolve) => setTimeout(resolve, delayInMs)); return { delayForInMs: delayInMs, diff --git a/packages/pieces/core/delay/src/lib/actions/delay-until-action.ts b/packages/pieces/core/delay/src/lib/actions/delay-until-action.ts index ae2cbd919f34..7b55a12d80ab 100644 --- a/packages/pieces/core/delay/src/lib/actions/delay-until-action.ts +++ b/packages/pieces/core/delay/src/lib/actions/delay-until-action.ts @@ -21,15 +21,15 @@ export const delayUntilAction = createAction({ }, }, props: { - markdown: Property.MarkDown({ - value: markdownDescription, - }), delayUntilTimestamp: Property.DateTime({ displayName: 'Date and Time', - description: - 'Specifies the date and time until which the execution of the next action should be delayed. It supports multiple formats, including ISO format.', + description: 'A time without a timezone is read as UTC.', + placeholder: '2026-08-05T14:30:00Z', required: true, }), + markdown: Property.MarkDown({ + value: markdownDescription, + }), }, outputSchema: delayUntilActionOutputSchema, async run(ctx) { @@ -41,13 +41,11 @@ export const delayUntilAction = createAction({ success: true, }; } else if (delayInMs <= 0) { - // resume immediately return { delayTill: delayTill, success: true, }; } else if (delayInMs > 1 * 60 * 1000) { - // use flow pause const currentTime = new Date(); const futureTime = dayjs(currentTime.getTime() + delayInMs); const waitpoint = await ctx.run.createWaitpoint({ @@ -57,7 +55,6 @@ export const delayUntilAction = createAction({ ctx.run.waitForWaitpoint(waitpoint.id); return {}; } else { - // use setTimeout for delayTill between 0 and 5 seconds await new Promise((resolve) => setTimeout(resolve, delayInMs)); return { delayTill: delayTill, diff --git a/packages/pieces/core/delay/test/delay-for-action.test.ts b/packages/pieces/core/delay/test/delay-for-action.test.ts new file mode 100644 index 000000000000..71541ccfdaba --- /dev/null +++ b/packages/pieces/core/delay/test/delay-for-action.test.ts @@ -0,0 +1,78 @@ +/// + +import { ExecutionType } from '@activepieces/pieces-framework'; +import { delayForAction } from '../src/lib/actions/delay-for-action'; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +function context(propsValue: Record) { + return { propsValue, executionType: ExecutionType.BEGIN } as never; +} + +describe('delayFor unit conversion', () => { + it.each([ + ['seconds', 5, 5 * SECOND], + ['minutes', 2, 2 * MINUTE], + ['hours', 3, 3 * HOUR], + ['days', 1, DAY], + ])('converts %s to milliseconds', async (unit, delayFor, expected) => { + await expect( + delayForAction.test(context({ delayFor, unit })), + ).resolves.toEqual({ delayForInMs: expected, success: true }); + }); + + it.each([[undefined], [null]])( + 'falls back to seconds when unit is %s', + async (unit) => { + await expect( + delayForAction.test(context({ delayFor: 3, unit })), + ).resolves.toEqual({ delayForInMs: 3 * SECOND, success: true }); + }, + ); + + it('falls back to seconds when unit is absent', async () => { + await expect( + delayForAction.test(context({ delayFor: 3 })), + ).resolves.toEqual({ delayForInMs: 3 * SECOND, success: true }); + }); +}); + +describe('delayFor rejects a unit outside the enum', () => { + const unsupported = ['Minute', 'minute', 'weeks', 'MINUTES', '']; + + it.each(unsupported)('rejects %j in test mode', async (unit) => { + await expect( + delayForAction.test(context({ delayFor: 1, unit })), + ).rejects.toThrow(); + }); + + it.each(unsupported)('rejects %j in run mode', async (unit) => { + await expect( + delayForAction.run(context({ delayFor: 1, unit })), + ).rejects.toThrow(); + }); + + it('does not report success for an unsupported unit', async () => { + const result = await delayForAction + .test(context({ delayFor: 10, unit: 'Minute' })) + .catch(() => undefined); + expect(result).toBeUndefined(); + }); +}); + +describe('delayFor amount validation', () => { + it.each([[-1], [-0.5]])('rejects a negative amount of %s', async (delayFor) => { + await expect( + delayForAction.test(context({ delayFor, unit: 'seconds' })), + ).rejects.toThrow(); + }); + + it('accepts zero', async () => { + await expect( + delayForAction.test(context({ delayFor: 0, unit: 'seconds' })), + ).resolves.toEqual({ delayForInMs: 0, success: true }); + }); +}); diff --git a/packages/pieces/core/delay/vitest.config.ts b/packages/pieces/core/delay/vitest.config.ts new file mode 100644 index 000000000000..ba8ade4a1780 --- /dev/null +++ b/packages/pieces/core/delay/vitest.config.ts @@ -0,0 +1,17 @@ +import path from 'path' +import { defineConfig } from 'vitest/config' + +const repoRoot = path.resolve(__dirname, '../../../..') + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, + resolve: { + alias: { + '@activepieces/pieces-framework': path.resolve(repoRoot, 'packages/pieces/framework/src/index.ts'), + '@activepieces/pieces-common': path.resolve(repoRoot, 'packages/pieces/common/src/index.ts'), + }, + }, +})