From 50f9c1c128e0973b114181ce6e012b5e06e9eeac Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 24 Sep 2026 19:23:49 +0300 Subject: [PATCH 1/9] fix(agents): let you start a blank agent from the empty Agents page (#15782) --- brain/knowledge/engineering/web-feature-anatomy.md | 1 + packages/web/src/app/routes/agents/index.tsx | 12 ++++++++++++ .../src/features/agents/new-blank-agent-button.tsx | 2 +- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/brain/knowledge/engineering/web-feature-anatomy.md b/brain/knowledge/engineering/web-feature-anatomy.md index 4971a24db186..ee87d7b67a72 100644 --- a/brain/knowledge/engineering/web-feature-anatomy.md +++ b/brain/knowledge/engineering/web-feature-anatomy.md @@ -86,6 +86,7 @@ way back was to stash the handful of intended edits, `git checkout` the package, - **A panel that hand-rolls its draft state gets none of the form validation the rest of the app assumes.** react-hook-form + `zodResolver` is what surfaces `formErrors.required` and friends; a `useState` draft with a Save button has no schema, so the usual mistake is to *substitute* a fallback for an empty field (`name.trim().length > 0 ? name.trim() : existing.name`) instead of rejecting it. That reads as a silent failure: the request succeeds, the old value returns, and nothing explains why. When a surface cannot use react-hook-form, derive the invalid state, render the message next to the field, and disable the submit — do not paper over the empty value. Bit the AI Center key-detail panel while its sibling connect dialog, on a zod resolver, was correct. The second failure mode is that such a draft never resyncs: seeded once from a prop, it outlives any refetch of the row it mirrors, so a mutation that changes the row without changing its `key` (the AI Center replaces a key's credentials, and the panel is keyed on the config id) leaves the draft describing the old row — phantom "unsaved changes", and a save that reverts what the mutation just wrote. Bump a version segment into the `key` at the site that performs the mutation rather than diffing props inside the panel: TanStack Query hands back a new object identity on every refetch, so a naive identity comparison discards the admin's unsaved edits on a window refocus. - **Sonner centres its icon against the whole toast, so a two-line toast puts the icon beside the wrong line.** `[data-sonner-toast]` is a centred flex row: fine for one line, visibly wrong the moment a description wraps or carries a disclosure — the icon drifts down next to the body instead of the title. Pass `classNames: { toast: 'items-start!', icon: 'mt-0.5' }` on that toast (the icon is 16px against 13px title text, so it needs the nudge to sit on the title's baseline). The `!` is not optional: sonner ships its own stylesheet, and a plain Tailwind `items-start` loses to it. Per-toast rather than on the `Toaster`, unless every toast in the app is meant to move. - **To see a fetch-failure placeholder in the dev app, force the branch in code — do not try to break the network.** Patching `XMLHttpRequest.prototype.open` to rewrite the path (the api client is axios, so patching `fetch` alone does nothing) works only sometimes and costs a lot of fiddling: React Query keeps rendering the last good data, so the placeholder needs a query key with no cache; a full reload wipes the patch before the app boots, so navigation has to stay client-side; and some surfaces never error at all even when the rewritten path is confirmed to 404. Temporarily flipping the branch itself — `) : isError ? (` to `) : true || isError ? (` in `DataTable`, plus the same in each hand-written list — makes every page reachable by plain URL with no timing at all. Two cautions: it proves the *rendering* and not that `isError` is ever set, and `true || x` breaks TypeScript's narrowing after the guard, so a forced early return can throw "possibly undefined" errors into the Vite overlay — force it from the caller's prop instead when that happens. Forcing the branch is often not enough on a Community instance: agents, the AI Capabilities section and the embed subdomain steps are behind route guards, edition checks and `LockedFeatureGuard`, so those have to be forced open too (`AgentsFlagGuard`'s redirect, the `edition === ApEdition.COMMUNITY` branch in `routes/platform/setup/ai/index.tsx`, `isCloud` + `locked`) before the page renders at all. Revert with a grep for `true ||` / `false &&` / `locked={false}` before finishing. +- **Run eslint on web files from inside `packages/web`, never from the repo root.** From the root the `@/` alias does not resolve, so every import reports `import/no-unresolved`, and `--fix` silently moves relative imports (`./lib/...`) above the `@/` group. Then the package's own lint fails on `import/order`. Re-run `npx eslint --fix` from `packages/web` to put them back. - **A table that ORs a secondary query into `isLoading` can never reach its error state.** The runs table passes `isLoading={isLoading || isFetchingFlows}`, where `isFetchingFlows` belongs to the flow list behind the *filter dropdown*. While that second query is fetching or retrying, the skeleton branch wins over `isError`, so a failing runs endpoint shows spinning rows rather than the placeholder — and a failing flows endpoint traps the table there indefinitely. Gate the skeleton on the query that owns the rows, and let a secondary query resolve on its own. diff --git a/packages/web/src/app/routes/agents/index.tsx b/packages/web/src/app/routes/agents/index.tsx index 6abefc4f832c..3aa0c9493222 100644 --- a/packages/web/src/app/routes/agents/index.tsx +++ b/packages/web/src/app/routes/agents/index.tsx @@ -363,6 +363,18 @@ const AgentsPageContent = () => { ))} + {firstRun && ( + } + label={t('Start from scratch')} + /> + )} )} diff --git a/packages/web/src/features/agents/new-blank-agent-button.tsx b/packages/web/src/features/agents/new-blank-agent-button.tsx index 85ae9338de3c..ff8278bbd866 100644 --- a/packages/web/src/features/agents/new-blank-agent-button.tsx +++ b/packages/web/src/features/agents/new-blank-agent-button.tsx @@ -75,7 +75,7 @@ type NewBlankAgentButtonProps = { projects: Project[]; pending: boolean; onCreate: (projectId: string) => void; - variant?: 'outline' | 'default'; + variant?: 'outline' | 'default' | 'ghost'; size?: 'sm'; className?: string; icon: ReactNode; From ad8eb76b59846e71af44e3c1206b0f6315b22dd4 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 24 Sep 2026 19:46:52 +0300 Subject: [PATCH 2/9] chore(mcp): tag the OAuth nonce migration for release 0.92.0 (#15783) --- .../migration/postgres/1851000000000-AddMcpOAuthCodeNonce.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/api/src/app/database/migration/postgres/1851000000000-AddMcpOAuthCodeNonce.ts b/packages/server/api/src/app/database/migration/postgres/1851000000000-AddMcpOAuthCodeNonce.ts index b1852a7a73b5..86d25034fd7b 100644 --- a/packages/server/api/src/app/database/migration/postgres/1851000000000-AddMcpOAuthCodeNonce.ts +++ b/packages/server/api/src/app/database/migration/postgres/1851000000000-AddMcpOAuthCodeNonce.ts @@ -4,7 +4,7 @@ import { Migration } from '../../migration' export class AddMcpOAuthCodeNonce1851000000000 implements Migration { name = 'AddMcpOAuthCodeNonce1851000000000' breaking = false - release = '0.92.1' + release = '0.92.0' public async up(queryRunner: QueryRunner): Promise { await queryRunner.query(` From f0383d5f5229b33822706b5fdeb869062e94bbd1 Mon Sep 17 00:00:00 2001 From: Odai Ahmad Date: Thu, 24 Sep 2026 20:13:53 +0300 Subject: [PATCH 3/9] feat(microsoft-onedrive): let agents manage files, sharing, versions and albums (#15781) --- bun.lock | 2 +- .../community/microsoft-onedrive/package.json | 2 +- .../src/i18n/translation.json | 208 +++++++++- .../community/microsoft-onedrive/src/index.ts | 83 +++- .../src/lib/actions/copy-file.ts | 21 +- .../src/lib/actions/download-file.ts | 40 +- .../src/lib/actions/list-files.ts | 13 +- .../src/lib/actions/list-folders.ts | 9 +- .../actions/onedrive-add-item-to-bundle.ts | 56 +++ .../src/lib/actions/onedrive-checkin-item.ts | 56 +++ .../src/lib/actions/onedrive-checkout-item.ts | 46 +++ .../src/lib/actions/onedrive-copy-item.ts | 99 +++++ .../src/lib/actions/onedrive-create-bundle.ts | 73 ++++ .../src/lib/actions/onedrive-create-folder.ts | 88 +++++ .../actions/onedrive-create-sharing-link.ts | 169 ++++++++ .../lib/actions/onedrive-create-text-file.ts | 74 ++++ .../onedrive-delete-item-permission.ts | 60 +++ .../src/lib/actions/onedrive-delete-item.ts | 46 +++ .../lib/actions/onedrive-discard-checkout.ts | 46 +++ .../onedrive-download-file-as-format.ts | 152 ++++++++ .../src/lib/actions/onedrive-download-file.ts | 96 +++++ .../actions/onedrive-download-item-version.ts | 93 +++++ .../lib/actions/onedrive-get-copy-status.ts | 134 +++++++ .../src/lib/actions/onedrive-get-drive.ts | 60 +++ .../src/lib/actions/onedrive-get-item.ts | 42 ++ .../actions/onedrive-get-special-folder.ts | 45 +++ .../lib/actions/onedrive-invite-to-item.ts | 159 ++++++++ .../src/lib/actions/onedrive-list-bundles.ts | 68 ++++ .../actions/onedrive-list-drive-changes.ts | 93 +++++ .../src/lib/actions/onedrive-list-drives.ts | 65 ++++ .../actions/onedrive-list-folder-children.ts | 105 +++++ .../actions/onedrive-list-item-permissions.ts | 118 ++++++ .../actions/onedrive-list-item-thumbnails.ts | 72 ++++ .../actions/onedrive-list-item-versions.ts | 71 ++++ .../src/lib/actions/onedrive-move-item.ts | 68 ++++ .../onedrive-remove-item-from-bundle.ts | 55 +++ .../actions/onedrive-replace-file-content.ts | 130 +++++++ .../actions/onedrive-resolve-sharing-link.ts | 57 +++ .../actions/onedrive-restore-item-version.ts | 53 +++ .../src/lib/actions/onedrive-search-items.ts | 79 ++++ .../onedrive-update-item-permission.ts | 133 +++++++ .../src/lib/actions/onedrive-update-item.ts | 65 ++++ .../src/lib/actions/onedrive-upload-file.ts | 134 +++++++ .../src/lib/actions/upload-file.ts | 18 +- .../src/lib/common/graph-api.ts | 254 ++++++++++++ .../src/lib/common/microsoft-cloud.ts | 7 +- .../src/lib/output-schemas.ts | 365 ++++++++++++++++++ .../src/lib/triggers/new-file.ts | 115 +++--- 48 files changed, 3969 insertions(+), 128 deletions(-) create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-add-item-to-bundle.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-checkin-item.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-checkout-item.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-copy-item.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-bundle.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-folder.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-sharing-link.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-text-file.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-delete-item-permission.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-delete-item.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-discard-checkout.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-file-as-format.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-file.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-item-version.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-copy-status.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-drive.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-item.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-special-folder.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-invite-to-item.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-bundles.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-drive-changes.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-drives.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-folder-children.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-permissions.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-thumbnails.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-versions.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-move-item.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-remove-item-from-bundle.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-replace-file-content.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-resolve-sharing-link.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-restore-item-version.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-search-items.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-update-item-permission.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-update-item.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-upload-file.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/common/graph-api.ts create mode 100644 packages/pieces/community/microsoft-onedrive/src/lib/output-schemas.ts diff --git a/bun.lock b/bun.lock index 832ab0ebd9e2..bf4312062fa0 100644 --- a/bun.lock +++ b/bun.lock @@ -5789,7 +5789,7 @@ }, "packages/pieces/community/microsoft-onedrive": { "name": "@activepieces/piece-microsoft-onedrive", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/community/microsoft-onedrive/package.json b/packages/pieces/community/microsoft-onedrive/package.json index 74a379a2c77f..d04ee7310fa0 100644 --- a/packages/pieces/community/microsoft-onedrive/package.json +++ b/packages/pieces/community/microsoft-onedrive/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-microsoft-onedrive", - "version": "0.5.0", + "version": "0.6.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/microsoft-onedrive/src/i18n/translation.json b/packages/pieces/community/microsoft-onedrive/src/i18n/translation.json index 473b92b3ca4f..dfde4c57e45b 100644 --- a/packages/pieces/community/microsoft-onedrive/src/i18n/translation.json +++ b/packages/pieces/community/microsoft-onedrive/src/i18n/translation.json @@ -10,12 +10,82 @@ "Copy File": "Copy File", "List Files": "List Files", "List Folders": "List Folders", + "Add Item to Bundle": "Add Item to Bundle", + "Check In File": "Check In File", + "Check Out File": "Check Out File", + "Copy File or Folder": "Copy File or Folder", + "Create Bundle": "Create Bundle", + "Create Folder": "Create Folder", + "Create Sharing Link": "Create Sharing Link", + "Create Text File": "Create Text File", + "Remove Permission (Stop Sharing)": "Remove Permission (Stop Sharing)", + "Delete File or Folder (to Recycle Bin)": "Delete File or Folder (to Recycle Bin)", + "Discard Check-Out": "Discard Check-Out", + "Convert and Download File": "Convert and Download File", + "Download File": "Download File", + "Download File Version": "Download File Version", + "Get Copy Status": "Get Copy Status", + "Get Drive": "Get Drive", + "Get File or Folder": "Get File or Folder", + "Get Special Folder": "Get Special Folder", + "Share with People (Invite)": "Share with People (Invite)", + "List Albums and Bundles": "List Albums and Bundles", + "List Drive Changes (Delta)": "List Drive Changes (Delta)", + "List My Drives": "List My Drives", + "List Folder Contents": "List Folder Contents", + "List File Permissions": "List File Permissions", + "Get File Thumbnails": "Get File Thumbnails", + "List File Versions": "List File Versions", + "Move File or Folder": "Move File or Folder", + "Remove Item from Bundle": "Remove Item from Bundle", + "Replace File Content": "Replace File Content", + "Resolve Sharing Link": "Resolve Sharing Link", + "Restore File Version": "Restore File Version", + "Search Files and Folders": "Search Files and Folders", + "Update Permission Role": "Update Permission Role", + "Rename / Update File or Folder": "Rename / Update File or Folder", + "Upload File": "Upload File", "Custom API Call": "Custom API Call", "Upload a file to your Microsoft OneDrive with chunked upload if the file is larger than 4MiB": "Upload a file to your Microsoft OneDrive with chunked upload if the file is larger than 4MiB", "Get and download a file using a File ID or filename.": "Get and download a file using a File ID or filename.", "Create a copy of a file in your OneDrive, optionally in a different folder or with a new name.": "Create a copy of a file in your OneDrive, optionally in a different folder or with a new name.", "List files in a OneDrive folder": "List files in a OneDrive folder", "List folders in a OneDrive folder": "List folders in a OneDrive folder", + "Add an existing file to a bundle or album.": "Add an existing file to a bundle or album.", + "Check in a checked-out file so its changes become visible to others.": "Check in a checked-out file so its changes become visible to others.", + "Check out a file so others cannot edit it until it is checked in.": "Check out a file so others cannot edit it until it is checked in.", + "Start copying a OneDrive file or folder. The copy runs in the background; check it with Get Copy Status.": "Start copying a OneDrive file or folder. The copy runs in the background; check it with Get Copy Status.", + "Create a bundle that groups existing files so they can be shared together.": "Create a bundle that groups existing files so they can be shared together.", + "Create a folder in OneDrive, or return it if a folder with that name already exists.": "Create a folder in OneDrive, or return it if a folder with that name already exists.", + "Create a view, edit or embed sharing link for a OneDrive file or folder.": "Create a view, edit or embed sharing link for a OneDrive file or folder.", + "Create a plain-text file in a OneDrive folder from text you provide.": "Create a plain-text file in a OneDrive folder from text you provide.", + "Remove a person's access or revoke a sharing link on a OneDrive file or folder.": "Remove a person's access or revoke a sharing link on a OneDrive file or folder.", + "Move a OneDrive file or folder to the recycle bin. Deleting a folder also deletes everything in it.": "Move a OneDrive file or folder to the recycle bin. Deleting a folder also deletes everything in it.", + "Undo a check-out and throw away any changes made while the file was checked out.": "Undo a check-out and throw away any changes made while the file was checked out.", + "Convert a file to PDF, HTML or JPG and download the result.": "Convert a file to PDF, HTML or JPG and download the result.", + "Download a file by its ID or path.": "Download a file by its ID or path.", + "Download the content of a previous version of a file.": "Download the content of a previous version of a file.", + "Check the progress of a copy started with Copy File or Folder.": "Check the progress of a copy started with Copy File or Folder.", + "Get the connected OneDrive drive, including its type and storage quota.": "Get the connected OneDrive drive, including its type and storage quota.", + "Get the metadata of a file or folder by its ID or path.": "Get the metadata of a file or folder by its ID or path.", + "Get a well-known OneDrive folder such as Documents or Photos.": "Get a well-known OneDrive folder such as Documents or Photos.", + "Grant specific people read or write access to a OneDrive file or folder.": "Grant specific people read or write access to a OneDrive file or folder.", + "List the photo albums and file bundles in a personal OneDrive.": "List the photo albums and file bundles in a personal OneDrive.", + "List items that changed in the drive since a saved delta token, one page at a time.": "List items that changed in the drive since a saved delta token, one page at a time.", + "List the drives available to the signed-in user.": "List the drives available to the signed-in user.", + "List the files and folders inside a folder, one page at a time.": "List the files and folders inside a folder, one page at a time.", + "List who has access to a OneDrive file or folder, including sharing links.": "List who has access to a OneDrive file or folder, including sharing links.", + "Get the small, medium and large thumbnail image URLs of a file.": "Get the small, medium and large thumbnail image URLs of a file.", + "List the saved versions of a file.": "List the saved versions of a file.", + "Move a OneDrive file or folder to another folder, optionally renaming it.": "Move a OneDrive file or folder to another folder, optionally renaming it.", + "Remove a file from a bundle or album without deleting the file.": "Remove a file from a bundle or album without deleting the file.", + "Overwrite the content of an existing OneDrive file, keeping its ID and sharing links.": "Overwrite the content of an existing OneDrive file, keeping its ID and sharing links.", + "Look up the file or folder behind a OneDrive or SharePoint sharing link.": "Look up the file or folder behind a OneDrive or SharePoint sharing link.", + "Make a previous version of a file the current version.": "Make a previous version of a file the current version.", + "Search OneDrive for files and folders by name, metadata or content.": "Search OneDrive for files and folders by name, metadata or content.", + "Change a permission on a OneDrive file or folder to read or write.": "Change a permission on a OneDrive file or folder to read or write.", + "Rename a OneDrive file or folder, or set its description.": "Rename a OneDrive file or folder, or set its description.", + "Upload a file to a OneDrive folder, replacing any file with the same name.": "Upload a file to a OneDrive folder, replacing any file with the same name.", "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", "File name": "File name", "File": "File", @@ -27,14 +97,52 @@ "Destination Folder": "Destination Folder", "New Name": "New Name", "Conflict Behavior": "Conflict Behavior", + "Bundle ID": "Bundle ID", + "Item ID": "Item ID", + "Path": "Path", + "Comment": "Comment", + "Destination Folder ID": "Destination Folder ID", + "If the Name Exists": "If the Name Exists", + "Bundle Name": "Bundle Name", + "Item IDs": "Item IDs", + "Parent Folder ID": "Parent Folder ID", + "Folder Name": "Folder Name", + "Link Type": "Link Type", + "Scope": "Scope", + "Password": "Password", + "Expiration": "Expiration", + "Folder ID": "Folder ID", + "File Name": "File Name", + "Content": "Content", + "Permission ID": "Permission ID", + "File ID": "File ID", + "Target Format": "Target Format", + "Width": "Width", + "Height": "Height", + "Version ID": "Version ID", + "Monitor URL": "Monitor URL", + "Folder": "Folder", + "Recipient Emails": "Recipient Emails", + "Role": "Role", + "Send Invitation Email": "Send Invitation Email", + "Message": "Message", + "Page Token": "Page Token", + "Page or Delta Token": "Page or Delta Token", + "Start From Now": "Start From Now", + "Folder Path": "Folder Path", + "Item Type": "Item Type", + "Page Size": "Page Size", + "Sharing Link or Share Token": "Sharing Link or Share Token", + "Search Text": "Search Text", + "Description": "Description", "Method": "Method", "Headers": "Headers", "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 name the file should be saved as (e.g. file.txt)": "The name the file should be saved as (e.g. file.txt)", "The file URL or base64 to upload": "The file URL or base64 to upload", @@ -45,23 +153,105 @@ "The folder to copy the file into. Leave empty to copy it into the same folder as the source file.": "The folder to copy the file into. Leave empty to copy it into the same folder as the source file.", "A new name for the copied file (e.g. report-copy.xlsx). If left empty, the original name is used.": "A new name for the copied file (e.g. report-copy.xlsx). If left empty, the original name is used.", "What to do if a file with the same name already exists in the destination folder. Note: personal OneDrive (consumer) accounts do not support this option and may fail on name conflicts regardless of the selection.": "What to do if a file with the same name already exists in the destination folder. Note: personal OneDrive (consumer) accounts do not support this option and may fail on name conflicts regardless of the selection.", + "Bundle ID from List Albums and Bundles or Create Bundle.": "Bundle ID from List Albums and Bundles or Create Bundle.", + "ID of the file to add, from Search or List Folder Contents.": "ID of the file to add, from Search or List Folder Contents.", + "File ID from Search or List Folder Contents. Or use Path.": "File ID from Search or List Folder Contents. Or use Path.", + "File path from the drive root. Use this or Item ID.": "File path from the drive root. Use this or Item ID.", + "Optional check-in comment saved with the new version.": "Optional check-in comment saved with the new version.", + "ID of the item to copy, from Search or List Folder Contents.": "ID of the item to copy, from Search or List Folder Contents.", + "Path from the drive root. Use this or Item ID.": "Path from the drive root. Use this or Item ID.", + "Folder to copy into. Leave empty for the drive root.": "Folder to copy into. Leave empty for the drive root.", + "Optional name for the copy. Leave empty to keep the original name.": "Optional name for the copy. Leave empty to keep the original name.", + "What to do on a name clash. Not supported on personal OneDrive.": "What to do on a name clash. Not supported on personal OneDrive.", + "IDs of at least two files, from Search or List Folder Contents.": "IDs of at least two files, from Search or List Folder Contents.", + "Parent folder ID. Leave empty for the drive root.": "Parent folder ID. Leave empty for the drive root.", + "Folder name (a single name, not a path).": "Folder name (a single name, not a path).", + "File or folder ID from Search or List Folder Contents. Or use Path.": "File or folder ID from Search or List Folder Contents. Or use Path.", + "View, Edit, or Embed (Embed is personal OneDrive only).": "View, Edit, or Embed (Embed is personal OneDrive only).", + "Who can open the link. Leave empty for the account default.": "Who can open the link. Leave empty for the account default.", + "Optional password required to open the link. Personal OneDrive only.": "Optional password required to open the link. Personal OneDrive only.", + "Optional date and time after which the link stops working.": "Optional date and time after which the link stops working.", + "Destination folder ID. Leave empty for the drive root.": "Destination folder ID. Leave empty for the drive root.", + "File name including its extension.": "File name including its extension.", + "The text to write into the file (up to 4 MiB).": "The text to write into the file (up to 4 MiB).", + "What to do when the folder already has an item with this name.": "What to do when the folder already has an item with this name.", + "Permission ID from List File Permissions. Access ends at once.": "Permission ID from List File Permissions. Access ends at once.", + "ID of the item to delete, from Search or List Folder Contents.": "ID of the item to delete, from Search or List Folder Contents.", + "File path from the drive root. Used when File ID is empty.": "File path from the drive root. Used when File ID is empty.", + "Image width in pixels. Required when Target Format is JPG.": "Image width in pixels. Required when Target Format is JPG.", + "Image height in pixels. Required when Target Format is JPG.": "Image height in pixels. Required when Target Format is JPG.", + "Earlier version ID from List File Versions (not the current one).": "Earlier version ID from List File Versions (not the current one).", + "The monitorUrl returned by Copy File or Folder.": "The monitorUrl returned by Copy File or Folder.", + "Item ID from Search or List Folder Contents, or \"root\". Or use Path.": "Item ID from Search or List Folder Contents, or \"root\". Or use Path.", + "Path from the drive root. Used when Item ID is empty.": "Path from the drive root. Used when Item ID is empty.", + "Email addresses of the people to share with.": "Email addresses of the people to share with.", + "Read lets recipients view, Write lets them edit.": "Read lets recipients view, Write lets them edit.", + "If on, OneDrive emails each recipient. Off grants access silently.": "If on, OneDrive emails each recipient. Off grants access silently.", + "Message for the invitation email (only sent when emails are on).": "Message for the invitation email (only sent when emails are on).", + "nextPageToken from the previous call. Empty for the first page.": "nextPageToken from the previous call. Empty for the first page.", + "nextPageToken or deltaToken from a previous call. Empty to start.": "nextPageToken or deltaToken from a previous call. Empty to start.", + "Without a token, skip current items and return only a deltaToken.": "Without a token, skip current items and return only a deltaToken.", + "Folder ID. Leave this and Folder Path empty for the drive root.": "Folder ID. Leave this and Folder Path empty for the drive root.", + "Folder path from the drive root. Used when Folder ID is empty.": "Folder path from the drive root. Used when Folder ID is empty.", + "How many items to request per page (1-999). Defaults to 100.": "How many items to request per page (1-999). Defaults to 100.", + "File path from the drive root. Used when Item ID is empty.": "File path from the drive root. Used when Item ID is empty.", + "ID of the item to move, from Search or List Folder Contents.": "ID of the item to move, from Search or List Folder Contents.", + "Folder to move into. Leave empty for the drive root.": "Folder to move into. Leave empty for the drive root.", + "Optional new name for the item. Leave empty to keep its current name.": "Optional new name for the item. Leave empty to keep its current name.", + "The ID of the bundle or album, from List Albums and Bundles.": "The ID of the bundle or album, from List Albums and Bundles.", + "The ID of the file to remove from the bundle. The file itself is kept.": "The ID of the file to remove from the bundle. The file itself is kept.", + "ID of the file to overwrite. Or use Path.": "ID of the file to overwrite. Or use Path.", + "The new content, as a file URL or base64.": "The new content, as a file URL or base64.", + "The sharing URL, or a share token starting with u! or s!.": "The sharing URL, or a share token starting with u! or s!.", + "The ID of the version to restore, from List File Versions.": "The ID of the version to restore, from List File Versions.", + "The text to search for in file names, metadata and content.": "The text to search for in file names, metadata and content.", + "How many results to request per page (1-999). Defaults to 50.": "How many results to request per page (1-999). Defaults to 50.", + "The ID of the permission to change, from List File Permissions.": "The ID of the permission to change, from List File Permissions.", + "The new role. Replaces the roles the permission has today.": "The new role. Replaces the roles the permission has today.", + "New name, with the extension for files. Empty keeps the name.": "New name, with the extension for files. Empty keeps the name.", + "New description (personal OneDrive only). Empty keeps it.": "New description (personal OneDrive only). Empty keeps it.", + "The file URL or base64 content to upload.": "The file URL or base64 content to upload.", "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.", - "File ID": "File ID", - "File Name": "File Name", + "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.", "Rename with a number": "Rename with a number", "Overwrite existing file": "Overwrite existing file", "Fail on conflict": "Fail on conflict", + "Fail": "Fail", + "Rename the copy": "Rename the copy", + "Replace the existing item": "Replace the existing item", + "View": "View", + "Edit": "Edit", + "Embed (personal OneDrive only)": "Embed (personal OneDrive only)", + "Anyone with the link": "Anyone with the link", + "People in my organization": "People in my organization", + "Existing users only": "Existing users only", + "Rename the new file": "Rename the new file", + "Replace the existing file": "Replace the existing file", + "PDF": "PDF", + "HTML (Loop, Fluid and whiteboard files only)": "HTML (Loop, Fluid and whiteboard files only)", + "JPG (requires Width and Height)": "JPG (requires Width and Height)", + "Documents": "Documents", + "Photos": "Photos", + "Camera Roll": "Camera Roll", + "Music": "Music", + "Recordings": "Recordings", + "Read": "Read", + "Write": "Write", + "Files and folders": "Files and folders", + "Files only": "Files only", + "Folders only": "Folders only", + "My files only": "My files only", + "Include items shared with me": "Include items shared with me", "GET": "GET", "POST": "POST", "PATCH": "PATCH", "PUT": "PUT", "DELETE": "DELETE", "HEAD": "HEAD", - "None": "None", - "JSON": "JSON", - "Form Data": "Form Data", - "Raw": "Raw", "New File": "New File", "Trigger when a new file is uploaded.": "Trigger when a new file is uploaded." } \ No newline at end of file diff --git a/packages/pieces/community/microsoft-onedrive/src/index.ts b/packages/pieces/community/microsoft-onedrive/src/index.ts index dd312c81a542..8200f53f14a7 100644 --- a/packages/pieces/community/microsoft-onedrive/src/index.ts +++ b/packages/pieces/community/microsoft-onedrive/src/index.ts @@ -1,8 +1,5 @@ import { createCustomApiCallAction } from '@activepieces/pieces-common'; -import { - createPiece, - OAuth2PropertyValue, -} from '@activepieces/pieces-framework'; +import { createPiece } from '@activepieces/pieces-framework'; import { PieceCategory } from '@activepieces/pieces-framework'; import { copyFile } from './lib/actions/copy-file'; import { downloadFile } from './lib/actions/download-file'; @@ -11,13 +8,49 @@ import { listFolders } from './lib/actions/list-folders'; import { uploadFile } from './lib/actions/upload-file'; import { oneDriveAuth } from './lib/auth'; import { oneDriveCommon } from './lib/common/common'; +import { getCloudProp } from './lib/common/microsoft-cloud'; import { newFile } from './lib/triggers/new-file'; +import { onedriveAddItemToBundle } from './lib/actions/onedrive-add-item-to-bundle'; +import { onedriveCheckinItem } from './lib/actions/onedrive-checkin-item'; +import { onedriveCheckoutItem } from './lib/actions/onedrive-checkout-item'; +import { onedriveCopyItem } from './lib/actions/onedrive-copy-item'; +import { onedriveCreateBundle } from './lib/actions/onedrive-create-bundle'; +import { onedriveCreateFolder } from './lib/actions/onedrive-create-folder'; +import { onedriveCreateSharingLink } from './lib/actions/onedrive-create-sharing-link'; +import { onedriveCreateTextFile } from './lib/actions/onedrive-create-text-file'; +import { onedriveDeleteItemPermission } from './lib/actions/onedrive-delete-item-permission'; +import { onedriveDeleteItem } from './lib/actions/onedrive-delete-item'; +import { onedriveDiscardCheckout } from './lib/actions/onedrive-discard-checkout'; +import { onedriveDownloadFileAsFormat } from './lib/actions/onedrive-download-file-as-format'; +import { onedriveDownloadFile } from './lib/actions/onedrive-download-file'; +import { onedriveDownloadItemVersion } from './lib/actions/onedrive-download-item-version'; +import { onedriveGetCopyStatus } from './lib/actions/onedrive-get-copy-status'; +import { onedriveGetDrive } from './lib/actions/onedrive-get-drive'; +import { onedriveGetItem } from './lib/actions/onedrive-get-item'; +import { onedriveGetSpecialFolder } from './lib/actions/onedrive-get-special-folder'; +import { onedriveInviteToItem } from './lib/actions/onedrive-invite-to-item'; +import { onedriveListBundles } from './lib/actions/onedrive-list-bundles'; +import { onedriveListDriveChanges } from './lib/actions/onedrive-list-drive-changes'; +import { onedriveListDrives } from './lib/actions/onedrive-list-drives'; +import { onedriveListFolderChildren } from './lib/actions/onedrive-list-folder-children'; +import { onedriveListItemPermissions } from './lib/actions/onedrive-list-item-permissions'; +import { onedriveListItemThumbnails } from './lib/actions/onedrive-list-item-thumbnails'; +import { onedriveListItemVersions } from './lib/actions/onedrive-list-item-versions'; +import { onedriveMoveItem } from './lib/actions/onedrive-move-item'; +import { onedriveRemoveItemFromBundle } from './lib/actions/onedrive-remove-item-from-bundle'; +import { onedriveReplaceFileContent } from './lib/actions/onedrive-replace-file-content'; +import { onedriveResolveSharingLink } from './lib/actions/onedrive-resolve-sharing-link'; +import { onedriveRestoreItemVersion } from './lib/actions/onedrive-restore-item-version'; +import { onedriveSearchItems } from './lib/actions/onedrive-search-items'; +import { onedriveUpdateItemPermission } from './lib/actions/onedrive-update-item-permission'; +import { onedriveUpdateItem } from './lib/actions/onedrive-update-item'; +import { onedriveUploadFile } from './lib/actions/onedrive-upload-file'; export const microsoftOneDrive = createPiece({ displayName: 'Microsoft OneDrive', description: 'Cloud storage by Microsoft', auth: oneDriveAuth, - minimumSupportedRelease: '0.30.0', + minimumSupportedRelease: '0.87.0', logoUrl: 'https://cdn.activepieces.com/pieces/oneDrive.png', categories: [PieceCategory.CONTENT_AND_FILES], authors: ['BastienMe', 'kishanprmr', 'MoShizzle', 'abuaboud', 'ikus060'], @@ -27,14 +60,48 @@ export const microsoftOneDrive = createPiece({ copyFile, listFiles, listFolders, + onedriveAddItemToBundle, + onedriveCheckinItem, + onedriveCheckoutItem, + onedriveCopyItem, + onedriveCreateBundle, + onedriveCreateFolder, + onedriveCreateSharingLink, + onedriveCreateTextFile, + onedriveDeleteItemPermission, + onedriveDeleteItem, + onedriveDiscardCheckout, + onedriveDownloadFileAsFormat, + onedriveDownloadFile, + onedriveDownloadItemVersion, + onedriveGetCopyStatus, + onedriveGetDrive, + onedriveGetItem, + onedriveGetSpecialFolder, + onedriveInviteToItem, + onedriveListBundles, + onedriveListDriveChanges, + onedriveListDrives, + onedriveListFolderChildren, + onedriveListItemPermissions, + onedriveListItemThumbnails, + onedriveListItemVersions, + onedriveMoveItem, + onedriveRemoveItemFromBundle, + onedriveReplaceFileContent, + onedriveResolveSharingLink, + onedriveRestoreItemVersion, + onedriveSearchItems, + onedriveUpdateItemPermission, + onedriveUpdateItem, + onedriveUploadFile, createCustomApiCallAction({ baseUrl: (auth) => { - const cloud = (auth as OAuth2PropertyValue).props?.['cloud'] as string | undefined; - return oneDriveCommon.getBaseUrl(cloud); + return oneDriveCommon.getBaseUrl(getCloudProp(auth)); }, auth: oneDriveAuth, authMapping: async (auth) => ({ - Authorization: `Bearer ${(auth as OAuth2PropertyValue).access_token}`, + Authorization: `Bearer ${auth.access_token}`, }), }), ], diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/copy-file.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/copy-file.ts index f83fd9398e1d..b32f12fc39ab 100644 --- a/packages/pieces/community/microsoft-onedrive/src/lib/actions/copy-file.ts +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/copy-file.ts @@ -1,23 +1,24 @@ import { createAction, DropdownOption, - OAuth2PropertyValue, Property, } from '@activepieces/pieces-framework'; import { tryCatch } from '@activepieces/pieces-framework'; import { Client, PageCollection, ResponseType } from '@microsoft/microsoft-graph-client'; import { DriveItem } from '@microsoft/microsoft-graph-types'; -import { getGraphBaseUrl } from '../common/microsoft-cloud'; +import { getCloudProp, getGraphBaseUrl } from '../common/microsoft-cloud'; import { oneDriveAuth } from '../auth'; import { oneDriveCommon } from '../common/common'; +import { copyFileOutputSchema } from '../output-schemas'; export const copyFile = createAction({ auth: oneDriveAuth, name: 'copy_file', classification: 'WRITE', displayName: 'Copy File', + outputSchema: copyFileOutputSchema, description: 'Create a copy of a file in your OneDrive, optionally in a different folder or with a new name.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Creates a copy of a OneDrive file, optionally into a different folder and/or under a new name, and returns the copied item once the asynchronous copy completes. Use to duplicate a file or stamp out copies of a template document. Not idempotent: each run creates another copy — with the default rename conflict behavior, repeated runs produce numbered duplicates.', @@ -45,8 +46,8 @@ export const copyFile = createAction({ }; } - const authValue = auth as OAuth2PropertyValue; - const cloud = authValue.props?.['cloud'] as string | undefined; + const authValue = auth; + const cloud = getCloudProp(authValue); const client = Client.initWithMiddleware({ authProvider: { getAccessToken: () => Promise.resolve(authValue.access_token), @@ -57,16 +58,14 @@ export const copyFile = createAction({ const folderId = sourceFolderId || 'root'; const options: DropdownOption[] = []; - // A page can be empty (or contain only folders) and still carry - // @odata.nextLink, so keep following the link instead of stopping - // at the first empty page. let response: PageCollection | undefined = await client .api(`/me/drive/items/${folderId}/children`) .select('id,name,file') .get(); while (response && options.length < MAX_DROPDOWN_FILES) { - for (const item of (response.value ?? []) as DriveItem[]) { + const items: DriveItem[] = response.value ?? []; + for (const item of items) { if (item.file) { options.push({ label: item.name!, value: item.id! }); } @@ -104,7 +103,7 @@ export const copyFile = createAction({ }, async run(context) { const { fileId, destinationFolderId, newName, conflictBehavior } = context.propsValue; - const cloud = context.auth.props?.['cloud'] as string | undefined; + const cloud = getCloudProp(context.auth); if (!fileId) { throw new Error('Please select a file to copy.'); @@ -119,8 +118,6 @@ export const copyFile = createAction({ const body: { parentReference?: { driveId: string; id: string }; name?: string } = {}; if (destinationFolderId) { - // Docs: "The parentReference parameter should include the driveId and id - // parameters for the target folder." const drive = await client.api('/me/drive').select('id').get(); body.parentReference = { driveId: drive.id, id: destinationFolderId }; } diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/download-file.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/download-file.ts index 5cbb02c62cf5..5c8c14dd4540 100644 --- a/packages/pieces/community/microsoft-onedrive/src/lib/actions/download-file.ts +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/download-file.ts @@ -6,32 +6,19 @@ import { } from '@activepieces/pieces-common'; import { oneDriveAuth } from '../auth'; import { oneDriveCommon } from '../common/common'; +import { getCloudProp } from '../common/microsoft-cloud'; +import { getFileOutputSchema } from '../output-schemas'; -type SearchItem = { - id: string; - name: string; - file?: { mimeType: string }; -}; - -type DriveItem = { - id: string; - name: string; - size: number; - createdDateTime: string; - lastModifiedDateTime: string; - webUrl: string; - file?: { mimeType: string }; - parentReference?: { path: string; driveId: string }; -}; export const downloadFile = createAction({ auth: oneDriveAuth, name: 'download_file', classification: 'READ', description: 'Get and download a file using a File ID or filename.', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Retrieve a single file from Microsoft OneDrive and download its content, looking it up either by its File ID or by its exact file name (a name lookup searches the drive and resolves to the matching file). Use when you need the file bytes plus its metadata; when only metadata is needed prefer a list action. Read-only and idempotent.', idempotent: true }, displayName: 'Get File', + outputSchema: getFileOutputSchema, props: { lookupBy: Property.StaticDropdown({ displayName: 'Look Up By', @@ -53,7 +40,7 @@ export const downloadFile = createAction({ }, async run(context) { const { lookupBy, fileIdentifier } = context.propsValue; - const cloud = context.auth.props?.['cloud'] as string | undefined; + const cloud = getCloudProp(context.auth); const baseUrl = oneDriveCommon.getBaseUrl(cloud); let fileId: string; @@ -124,3 +111,20 @@ export const downloadFile = createAction({ }; }, }); + +type SearchItem = { + id: string; + name: string; + file?: { mimeType: string }; +}; + +type DriveItem = { + id: string; + name: string; + size: number; + createdDateTime: string; + lastModifiedDateTime: string; + webUrl: string; + file?: { mimeType: string }; + parentReference?: { path: string; driveId: string }; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/list-files.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/list-files.ts index 82a1f38a8db3..9aeb00e1408b 100644 --- a/packages/pieces/community/microsoft-onedrive/src/lib/actions/list-files.ts +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/list-files.ts @@ -1,7 +1,8 @@ -import { createAction, OAuth2PropertyValue } from '@activepieces/pieces-framework'; -import { getGraphBaseUrl } from '../common/microsoft-cloud'; +import { createAction } from '@activepieces/pieces-framework'; +import { getCloudProp, getGraphBaseUrl } from '../common/microsoft-cloud'; import { oneDriveAuth } from '../auth'; import { oneDriveCommon } from '../common/common'; +import { listFilesOutputSchema } from '../output-schemas'; import { Client, PageCollection } from '@microsoft/microsoft-graph-client'; import { DriveItem } from '@microsoft/microsoft-graph-types'; @@ -10,9 +11,10 @@ export const listFiles = createAction({ name: 'list_files', classification: 'SEARCH', description: 'List files in a OneDrive folder', - audience: 'both', + audience: 'human', aiMetadata: { description: 'List the files contained in a Microsoft OneDrive folder, returning only file items (subfolders are excluded) and paging through all results. Provide a parent folder ID to scope the listing, or leave it empty to list from the drive root. Read-only and idempotent.', idempotent: true }, displayName: 'List Files', + outputSchema: listFilesOutputSchema, props: { markdown:oneDriveCommon.parentFolderInfo, parentFolder: oneDriveCommon.parentFolder, @@ -24,7 +26,7 @@ export const listFiles = createAction({ const files = []; - const cloud = (context.auth as OAuth2PropertyValue).props?.['cloud'] as string | undefined; + const cloud = getCloudProp(context.auth); const client = Client.initWithMiddleware({ authProvider: { getAccessToken: () => Promise.resolve(context.auth.access_token), @@ -34,7 +36,8 @@ export const listFiles = createAction({ let response: PageCollection = await client.api(endpoint).get(); while (response.value.length > 0) { - for (const item of response.value as DriveItem[]) { + const items: DriveItem[] = response.value; + for (const item of items) { if (item.file) { files.push(item); } diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/list-folders.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/list-folders.ts index 57a2fd4328f7..c24341b884e5 100644 --- a/packages/pieces/community/microsoft-onedrive/src/lib/actions/list-folders.ts +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/list-folders.ts @@ -1,4 +1,4 @@ -import { createAction, Property } from '@activepieces/pieces-framework'; +import { createAction } from '@activepieces/pieces-framework'; import { httpClient, HttpMethod, @@ -6,22 +6,25 @@ import { } from '@activepieces/pieces-common'; import { oneDriveAuth } from '../auth'; import { oneDriveCommon } from '../common/common'; +import { getCloudProp } from '../common/microsoft-cloud'; +import { listFoldersOutputSchema } from '../output-schemas'; export const listFolders = createAction({ auth: oneDriveAuth, name: 'list_folders', classification: 'SEARCH', description: 'List folders in a OneDrive folder', - audience: 'both', + audience: 'human', aiMetadata: { description: 'List the subfolders contained in a Microsoft OneDrive folder (file items are excluded). Provide a parent folder ID to scope the listing, or leave it empty to list folders at the drive root; useful for discovering a folder ID to pass to other actions. Read-only and idempotent.', idempotent: true }, displayName: 'List Folders', + outputSchema: listFoldersOutputSchema, props: { markdown:oneDriveCommon.parentFolderInfo, parentFolder: oneDriveCommon.parentFolder, }, async run(context) { const parentId = context.propsValue.parentFolder ?? 'root'; - const cloud = context.auth.props?.['cloud'] as string | undefined; + const cloud = getCloudProp(context.auth); const result = await httpClient.sendRequest({ method: HttpMethod.GET, diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-add-item-to-bundle.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-add-item-to-bundle.ts new file mode 100644 index 000000000000..a1db84eb6d3e --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-add-item-to-bundle.ts @@ -0,0 +1,56 @@ +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveBundleMembershipOutputSchema } from '../output-schemas'; + +export const onedriveAddItemToBundle = createAction({ + auth: oneDriveAuth, + name: 'onedrive_add_item_to_bundle', + displayName: 'Add Item to Bundle', + description: 'Add an existing file to a bundle or album.', + audience: 'ai', + outputSchema: onedriveBundleMembershipOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Add one existing file to an existing bundle or photo album in a personal OneDrive; the file stays where it is. Get the bundle ID from List Albums and Bundles or Create Bundle, and the item ID from Search Files and Folders or List Folder Contents; use Create Bundle to start a new one. Works only on personal OneDrive (Microsoft account); adding an item that is already in the bundle succeeds without duplicating it.', + idempotent: true, + }, + props: { + bundleId: Property.ShortText({ + displayName: 'Bundle ID', + description: 'Bundle ID from List Albums and Bundles or Create Bundle.', + required: true, + }), + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'ID of the file to add, from Search or List Folder Contents.', + required: true, + }), + }, + async run(context) { + const bundleId = context.propsValue.bundleId.trim(); + const itemId = context.propsValue.itemId.trim(); + await assertPersonalDrive({ auth: context.auth }); + await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.POST, + path: `/me/drive/bundles/${encodeURIComponent(bundleId)}/children`, + body: { id: itemId }, + }); + return { success: true, bundleId, itemId }; + }, +}); + +async function assertPersonalDrive({ auth }: { auth: OAuth2PropertyValue }): Promise { + const drive = await oneDriveApi.request<{ driveType?: string }>({ + auth, + method: HttpMethod.GET, + path: '/me/drive', + queryParams: { $select: 'driveType' }, + }); + if (drive.driveType !== 'personal') { + throw new Error('Bundles are available only on personal OneDrive (Microsoft account) drives. This connection uses OneDrive for Business.'); + } +} diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-checkin-item.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-checkin-item.ts new file mode 100644 index 000000000000..8fc6d366e5db --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-checkin-item.ts @@ -0,0 +1,56 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveItemActionOutputSchema } from '../output-schemas'; + +export const onedriveCheckinItem = createAction({ + auth: oneDriveAuth, + name: 'onedrive_checkin_item', + displayName: 'Check In File', + description: 'Check in a checked-out file so its changes become visible to others.', + audience: 'ai', + outputSchema: onedriveItemActionOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Checks in a file previously checked out with Check Out File, publishing the changes and releasing the lock. Use Discard Check-Out instead to throw the changes away. Works on personal and business drives; checking in a file that is not checked out fails.', + idempotent: false, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'File ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'File path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + comment: Property.LongText({ + displayName: 'Comment', + description: 'Optional check-in comment saved with the new version.', + required: false, + }), + }, + async run(context) { + const { itemId, path, comment } = context.propsValue; + const body: Record = {}; + if (comment) { + body['comment'] = comment; + } + await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.POST, + path: `${oneDriveApi.itemPath({ itemId, path })}/checkin`, + body, + }); + return { + success: true, + itemId: itemId?.trim() || null, + path: path?.trim() || null, + }; + }, +}); diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-checkout-item.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-checkout-item.ts new file mode 100644 index 000000000000..754db6fa057e --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-checkout-item.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveItemActionOutputSchema } from '../output-schemas'; + +export const onedriveCheckoutItem = createAction({ + auth: oneDriveAuth, + name: 'onedrive_checkout_item', + displayName: 'Check Out File', + description: 'Check out a file so others cannot edit it until it is checked in.', + audience: 'ai', + outputSchema: onedriveItemActionOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Checks out a file, locking it for exclusive editing until Check In File or Discard Check-Out is called. Works on personal OneDrive and on OneDrive for Business or SharePoint libraries; checking out a file that is already checked out fails.', + idempotent: false, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'File ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'File path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + }, + async run(context) { + const { itemId, path } = context.propsValue; + await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.POST, + path: `${oneDriveApi.itemPath({ itemId, path })}/checkout`, + }); + return { + success: true, + itemId: itemId?.trim() || null, + path: path?.trim() || null, + }; + }, +}); diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-copy-item.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-copy-item.ts new file mode 100644 index 000000000000..0fcba206ad63 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-copy-item.ts @@ -0,0 +1,99 @@ +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { AuthenticationType, httpClient, HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { getGraphBaseUrl } from '../common/microsoft-cloud'; +import { onedriveCopyItemOutputSchema } from '../output-schemas'; + +export const onedriveCopyItem = createAction({ + auth: oneDriveAuth, + name: 'onedrive_copy_item', + displayName: 'Copy File or Folder', + description: + 'Start copying a OneDrive file or folder. The copy runs in the background; check it with Get Copy Status.', + audience: 'ai', + outputSchema: onedriveCopyItemOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Starts an asynchronous copy of a file or a whole folder (with its contents) into a destination folder, optionally under a new name, and returns a monitor URL rather than the new item. Next, call Get Copy Status with that monitor URL until it reports completed; its resourceId is the new copy\'s ID, while the source ID keeps pointing at the original. Each call starts another copy; name conflicts are reported by Get Copy Status as failed, and the conflict setting is not supported on personal OneDrive.', + idempotent: false, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'ID of the item to copy, from Search or List Folder Contents.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'Path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + destinationFolderId: Property.ShortText({ + displayName: 'Destination Folder ID', + description: 'Folder to copy into. Leave empty for the drive root.', + required: false, + }), + newName: Property.ShortText({ + displayName: 'New Name', + description: 'Optional name for the copy. Leave empty to keep the original name.', + required: false, + }), + conflictBehavior: Property.StaticDropdown({ + displayName: 'If the Name Exists', + description: 'What to do on a name clash. Not supported on personal OneDrive.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Fail', value: 'fail' }, + { label: 'Rename the copy', value: 'rename' }, + { label: 'Replace the existing item', value: 'replace' }, + ], + }, + }), + }, + async run(context) { + const { itemId, path, destinationFolderId, newName, conflictBehavior } = context.propsValue; + const sourcePath = oneDriveApi.itemPath({ itemId, path }); + const [parentId, driveId] = await Promise.all([ + oneDriveApi.resolveFolderId({ auth: context.auth, folderId: destinationFolderId }), + oneDriveApi.getDriveId({ auth: context.auth }), + ]); + const body: Record = { + parentReference: { driveId, id: parentId }, + }; + const trimmedName = newName?.trim(); + if (trimmedName) { + body['name'] = trimmedName; + } + try { + const response = await httpClient.sendRequest({ + method: HttpMethod.POST, + url: `${graphBaseUrl(context.auth)}${sourcePath}/copy`, + queryParams: conflictBehavior ? { '@microsoft.graph.conflictBehavior': conflictBehavior } : undefined, + body, + headers: { 'Content-Type': 'application/json' }, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: context.auth.access_token, + }, + }); + const location = response.headers?.['location']; + const monitorUrl = Array.isArray(location) ? location[0] : location; + if (!monitorUrl) { + throw new Error('OneDrive accepted the copy but did not return a monitor URL.'); + } + return { monitorUrl, status: 'inProgress' }; + } catch (error) { + throw new Error(oneDriveApi.describeError(error)); + } + }, +}); + +function graphBaseUrl(auth: OAuth2PropertyValue): string { + const cloud = auth.props?.['cloud']; + return `${getGraphBaseUrl(typeof cloud === 'string' ? cloud : undefined)}/v1.0`; +} diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-bundle.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-bundle.ts new file mode 100644 index 000000000000..63d074e72387 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-bundle.ts @@ -0,0 +1,73 @@ +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveBundleOutputSchema } from '../output-schemas'; + +export const onedriveCreateBundle = createAction({ + auth: oneDriveAuth, + name: 'onedrive_create_bundle', + displayName: 'Create Bundle', + description: 'Create a bundle that groups existing files so they can be shared together.', + audience: 'ai', + outputSchema: onedriveBundleOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Create a new bundle in a personal OneDrive that groups existing files, identified by at least two item IDs from Search Files and Folders or List Folder Contents, so they can be shared as one set without sharing their folders; the files are not moved or copied. Use Add Item to Bundle to extend an existing bundle instead. Works only on personal OneDrive (Microsoft account); each call creates a new bundle, and a duplicate name gets a number appended, so retries create duplicates.', + idempotent: false, + }, + props: { + name: Property.ShortText({ + displayName: 'Bundle Name', + required: true, + }), + itemIds: Property.Array({ + displayName: 'Item IDs', + description: 'IDs of at least two files, from Search or List Folder Contents.', + required: true, + }), + }, + async run(context) { + const { name, itemIds } = context.propsValue; + const ids = (itemIds ?? []) + .map((id) => (typeof id === 'string' ? id.trim() : String(id ?? '').trim())) + .filter((id) => id.length > 0); + if (ids.length < 2) { + throw new Error('Provide at least two item IDs. OneDrive does not create a bundle with fewer than two items.'); + } + await assertPersonalDrive({ auth: context.auth }); + const bundle = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.POST, + path: '/me/drive/bundles', + body: { + name, + '@microsoft.graph.conflictBehavior': 'rename', + bundle: {}, + children: ids.map((id) => ({ id })), + }, + }); + return { + ...oneDriveApi.toItem(bundle), + bundleChildCount: bundle.bundle?.childCount ?? null, + isAlbum: bundle.bundle?.album !== undefined && bundle.bundle?.album !== null, + }; + }, +}); + +async function assertPersonalDrive({ auth }: { auth: OAuth2PropertyValue }): Promise { + const drive = await oneDriveApi.request<{ driveType?: string }>({ + auth, + method: HttpMethod.GET, + path: '/me/drive', + queryParams: { $select: 'driveType' }, + }); + if (drive.driveType !== 'personal') { + throw new Error('Bundles are available only on personal OneDrive (Microsoft account) drives. This connection uses OneDrive for Business.'); + } +} + +type GraphBundle = GraphDriveItem & { + bundle?: { childCount?: number; album?: Record | null }; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-folder.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-folder.ts new file mode 100644 index 000000000000..a823f3682bfd --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-folder.ts @@ -0,0 +1,88 @@ +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveCreateFolderOutputSchema } from '../output-schemas'; + +export const onedriveCreateFolder = createAction({ + auth: oneDriveAuth, + name: 'onedrive_create_folder', + displayName: 'Create Folder', + description: 'Create a folder in OneDrive, or return it if a folder with that name already exists.', + audience: 'ai', + outputSchema: onedriveCreateFolderOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Gets or creates a folder by name inside a parent folder (the drive root by default): if a folder with that name already exists it is returned with `created` false instead of making a duplicate. Call it before Upload File or Move File or Folder when the destination may not exist. It fails if a file (not a folder) already holds the name; safe to retry.', + idempotent: true, + }, + props: { + parentFolderId: Property.ShortText({ + displayName: 'Parent Folder ID', + description: 'Parent folder ID. Leave empty for the drive root.', + required: false, + }), + name: Property.ShortText({ + displayName: 'Folder Name', + description: 'Folder name (a single name, not a path).', + placeholder: 'Invoices 2026', + required: true, + }), + }, + async run(context) { + const name = context.propsValue.name.trim(); + if (!name) { + throw new Error('Folder Name cannot be empty.'); + } + const parentId = await oneDriveApi.resolveFolderId({ + auth: context.auth, + folderId: context.propsValue.parentFolderId, + }); + try { + const created = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.POST, + path: `${oneDriveApi.itemPath({ itemId: parentId })}/children`, + body: { + name, + folder: {}, + '@microsoft.graph.conflictBehavior': 'fail', + }, + }); + return { ...oneDriveApi.toItem(created), created: true }; + } catch (createError) { + if (oneDriveApi.statusOf(createError) !== 409) { + throw createError; + } + const existing = await findChild({ auth: context.auth, parentId, name }); + if (!existing) { + throw createError; + } + if (!existing.folder) { + throw new Error(`A file named "${name}" already exists in this folder, so a folder with that name cannot be created.`); + } + return { ...oneDriveApi.toItem(existing), created: false }; + } + }, +}); + +async function findChild({ + auth, + parentId, + name, +}: { + auth: OAuth2PropertyValue; + parentId: string; + name: string; +}): Promise { + try { + return await oneDriveApi.request({ + auth, + method: HttpMethod.GET, + path: `${oneDriveApi.itemPath({ itemId: parentId })}:/${encodeURIComponent(name)}:`, + }); + } catch { + return null; + } +} diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-sharing-link.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-sharing-link.ts new file mode 100644 index 000000000000..6941b65668de --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-sharing-link.ts @@ -0,0 +1,169 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedrivePermissionOutputSchema } from '../output-schemas'; + +export const onedriveCreateSharingLink = createAction({ + auth: oneDriveAuth, + name: 'onedrive_create_sharing_link', + displayName: 'Create Sharing Link', + description: 'Create a view, edit or embed sharing link for a OneDrive file or folder.', + audience: 'ai', + outputSchema: onedrivePermissionOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Creates (or returns the existing) sharing link of the given type and scope for a file or folder, keeping its existing inherited access. Use it to get a URL anyone or an organization can open; to grant access to specific people by email use Share with People (Invite) instead. Embed links and passwords work on personal OneDrive only, and tenant policy may block anonymous links; calling again with the same type and scope returns the same link.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'File or folder ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'Path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + type: Property.StaticDropdown({ + displayName: 'Link Type', + description: 'View, Edit, or Embed (Embed is personal OneDrive only).', + required: true, + defaultValue: 'view', + options: { + disabled: false, + options: [ + { label: 'View', value: 'view' }, + { label: 'Edit', value: 'edit' }, + { label: 'Embed (personal OneDrive only)', value: 'embed' }, + ], + }, + }), + scope: Property.StaticDropdown({ + displayName: 'Scope', + description: 'Who can open the link. Leave empty for the account default.', + required: false, + options: { + disabled: false, + options: [ + { label: 'Anyone with the link', value: 'anonymous' }, + { label: 'People in my organization', value: 'organization' }, + { label: 'Existing users only', value: 'users' }, + ], + }, + }), + password: Property.ShortText({ + displayName: 'Password', + description: 'Optional password required to open the link. Personal OneDrive only.', + required: false, + }), + expirationDateTime: Property.DateTime({ + displayName: 'Expiration', + description: 'Optional date and time after which the link stops working.', + required: false, + }), + }, + async run(context) { + const { itemId, path, type, scope, password, expirationDateTime } = context.propsValue; + const body: Record = { + type, + retainInheritedPermissions: true, + }; + if (scope) { + body['scope'] = scope; + } + if (password) { + body['password'] = password; + } + if (expirationDateTime) { + body['expirationDateTime'] = expirationDateTime; + } + try { + const permission = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.POST, + path: `${oneDriveApi.itemPath({ itemId, path })}/createLink`, + body, + }); + return toPermission(permission); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (oneDriveApi.statusOf(error) === 403) { + throw new Error( + `OneDrive refused to create a ${scope ?? 'default-scope'} ${type} link. Your organization's sharing policy may block this link type or scope (anonymous links are often disabled), or the account type does not support it (embed and passwords are personal-only). Try a narrower scope such as "users". Details: ${message}`, + ); + } + throw error; + } + }, +}); + +function toPermission(permission: GraphPermission): OneDrivePermission { + const grantee = permission.grantedToV2?.user ?? permission.grantedTo?.user; + const identities = permission.grantedToIdentitiesV2 ?? permission.grantedToIdentities ?? []; + return { + id: permission.id, + roles: permission.roles ?? [], + linkType: permission.link?.type ?? null, + linkScope: permission.link?.scope ?? null, + linkWebUrl: permission.link?.webUrl ?? null, + linkPreventsDownload: permission.link?.preventsDownload ?? null, + grantedToName: grantee?.displayName ?? null, + grantedToEmail: grantee?.email ?? null, + grantedToId: grantee?.id ?? null, + grantedToIdentities: identities + .flatMap((identity) => { + const value = identity.user?.email ?? identity.user?.displayName; + return value ? [value] : []; + }) + .join(', '), + invitationEmail: permission.invitation?.email ?? null, + inherited: permission.inheritedFrom !== undefined, + inheritedFromId: permission.inheritedFrom?.id ?? null, + inheritedFromPath: permission.inheritedFrom?.path ?? null, + hasPassword: permission.hasPassword ?? null, + expirationDateTime: permission.expirationDateTime ?? null, + shareId: permission.shareId ?? null, + }; +} + +type GraphPermissionUser = { displayName?: string; email?: string; id?: string }; + +type GraphPermission = { + id: string; + roles?: string[]; + link?: { type?: string; scope?: string; webUrl?: string; preventsDownload?: boolean }; + grantedTo?: { user?: GraphPermissionUser }; + grantedToV2?: { user?: GraphPermissionUser }; + grantedToIdentities?: { user?: GraphPermissionUser }[]; + grantedToIdentitiesV2?: { user?: GraphPermissionUser }[]; + invitation?: { email?: string }; + inheritedFrom?: { id?: string; path?: string; driveId?: string }; + hasPassword?: boolean; + expirationDateTime?: string; + shareId?: string; +}; + +type OneDrivePermission = { + id: string; + roles: string[]; + linkType: string | null; + linkScope: string | null; + linkWebUrl: string | null; + linkPreventsDownload: boolean | null; + grantedToName: string | null; + grantedToEmail: string | null; + grantedToId: string | null; + grantedToIdentities: string; + invitationEmail: string | null; + inherited: boolean; + inheritedFromId: string | null; + inheritedFromPath: string | null; + hasPassword: boolean | null; + expirationDateTime: string | null; + shareId: string | null; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-text-file.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-text-file.ts new file mode 100644 index 000000000000..f8484c8c8349 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-create-text-file.ts @@ -0,0 +1,74 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveItemOutputSchema } from '../output-schemas'; + +export const onedriveCreateTextFile = createAction({ + auth: oneDriveAuth, + name: 'onedrive_create_text_file', + displayName: 'Create Text File', + description: 'Create a plain-text file in a OneDrive folder from text you provide.', + audience: 'ai', + outputSchema: onedriveItemOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Creates a file in a OneDrive folder whose content is the given text (up to 4 MiB), without needing a file object; use Upload File for binary files and Replace File Content to overwrite a known item. It writes plain UTF-8 text only, so naming it `.docx` or `.xlsx` does not produce a real Word or Excel file. By default it fails if the name already exists; Rename or Replace change that, and each Rename call creates another file.', + idempotent: false, + }, + props: { + folderId: Property.ShortText({ + displayName: 'Folder ID', + description: 'Destination folder ID. Leave empty for the drive root.', + required: false, + }), + fileName: Property.ShortText({ + displayName: 'File Name', + description: 'File name including its extension.', + placeholder: 'notes.txt', + required: true, + }), + content: Property.LongText({ + displayName: 'Content', + description: 'The text to write into the file (up to 4 MiB).', + required: true, + }), + conflictBehavior: Property.StaticDropdown({ + displayName: 'If the Name Exists', + description: 'What to do when the folder already has an item with this name.', + required: true, + defaultValue: 'fail', + options: { + disabled: false, + options: [ + { label: 'Fail', value: 'fail' }, + { label: 'Rename the new file', value: 'rename' }, + { label: 'Replace the existing file', value: 'replace' }, + ], + }, + }), + }, + async run(context) { + const { folderId, fileName, content, conflictBehavior } = context.propsValue; + const name = fileName.trim(); + if (!name) { + throw new Error('File Name cannot be empty.'); + } + const byteLength = Buffer.byteLength(content, 'utf8'); + if (byteLength > SIMPLE_UPLOAD_LIMIT) { + throw new Error('Content is larger than 4 MiB. Use Upload File for large content.'); + } + const item = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.PUT, + path: `${oneDriveApi.folderPath({ folderId })}:/${encodeURIComponent(name)}:/content`, + queryParams: { '@microsoft.graph.conflictBehavior': conflictBehavior ?? 'fail' }, + body: content, + headers: { 'Content-Type': 'text/plain; charset=utf-8' }, + }); + return oneDriveApi.toItem(item); + }, +}); + +const SIMPLE_UPLOAD_LIMIT = 4 * 1024 * 1024; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-delete-item-permission.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-delete-item-permission.ts new file mode 100644 index 000000000000..a47e65f34146 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-delete-item-permission.ts @@ -0,0 +1,60 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveDeletePermissionOutputSchema } from '../output-schemas'; + +export const onedriveDeleteItemPermission = createAction({ + auth: oneDriveAuth, + name: 'onedrive_delete_item_permission', + displayName: 'Remove Permission (Stop Sharing)', + description: 'Remove a person\'s access or revoke a sharing link on a OneDrive file or folder.', + audience: 'ai', + outputSchema: onedriveDeletePermissionOutputSchema, + classification: 'DESTRUCTIVE', + aiMetadata: { + description: + 'Permanently removes one permission from a file or folder, which revokes that person\'s access or disables that sharing link for everyone who has it; it cannot be undone except by sharing again. Get the permission ID from List File Permissions; only non-inherited permissions can be removed, and a retry after success fails with not found.', + idempotent: false, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'File or folder ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'Path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + permissionId: Property.ShortText({ + displayName: 'Permission ID', + description: 'Permission ID from List File Permissions. Access ends at once.', + required: true, + }), + }, + async run(context) { + const { itemId, path, permissionId } = context.propsValue; + const trimmedPermissionId = permissionId.trim(); + try { + await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `${oneDriveApi.itemPath({ itemId, path })}/permissions/${encodeURIComponent(trimmedPermissionId)}`, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `${message} Only permissions set directly on this item can be removed; inherited ones must be removed on the parent folder they come from.`, + ); + } + return { + success: true, + itemId: itemId?.trim() || null, + path: path?.trim() || null, + permissionId: trimmedPermissionId, + }; + }, +}); diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-delete-item.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-delete-item.ts new file mode 100644 index 000000000000..77eace425510 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-delete-item.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveItemActionOutputSchema } from '../output-schemas'; + +export const onedriveDeleteItem = createAction({ + auth: oneDriveAuth, + name: 'onedrive_delete_item', + displayName: 'Delete File or Folder (to Recycle Bin)', + description: 'Move a OneDrive file or folder to the recycle bin. Deleting a folder also deletes everything in it.', + audience: 'ai', + outputSchema: onedriveItemActionOutputSchema, + classification: 'DESTRUCTIVE', + aiMetadata: { + description: + 'Deletes a file or folder by moving it to the OneDrive recycle bin, where the owner can still restore it; deleting a folder also deletes everything inside it. Confirm the exact item first (get its ID from Search Files and Folders or List Folder Contents); retrying after success fails because the item is gone.', + idempotent: false, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'ID of the item to delete, from Search or List Folder Contents.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'Path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + }, + async run(context) { + const { itemId, path } = context.propsValue; + await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.DELETE, + path: oneDriveApi.itemPath({ itemId, path }), + }); + return { + success: true, + itemId: itemId?.trim() || null, + path: itemId?.trim() ? null : path?.trim() || null, + }; + }, +}); diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-discard-checkout.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-discard-checkout.ts new file mode 100644 index 000000000000..16834842b305 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-discard-checkout.ts @@ -0,0 +1,46 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveItemActionOutputSchema } from '../output-schemas'; + +export const onedriveDiscardCheckout = createAction({ + auth: oneDriveAuth, + name: 'onedrive_discard_checkout', + displayName: 'Discard Check-Out', + description: 'Undo a check-out and throw away any changes made while the file was checked out.', + audience: 'ai', + outputSchema: onedriveItemActionOutputSchema, + classification: 'DESTRUCTIVE', + aiMetadata: { + description: + 'Releases the check-out on a file and permanently discards every change made while it was checked out, reverting to the last checked-in version. Use Check In File instead to keep the changes. Works on personal and business drives; a retry fails once the file is no longer checked out.', + idempotent: false, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'File ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'File path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + }, + async run(context) { + const { itemId, path } = context.propsValue; + await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.POST, + path: `${oneDriveApi.itemPath({ itemId, path })}/discardCheckout`, + }); + return { + success: true, + itemId: itemId?.trim() || null, + path: path?.trim() || null, + }; + }, +}); diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-file-as-format.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-file-as-format.ts new file mode 100644 index 000000000000..cb1030284b1b --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-file-as-format.ts @@ -0,0 +1,152 @@ +import { Readable } from 'stream'; +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { AuthenticationType, HttpMethod, QueryParams, httpClient } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { getGraphBaseUrl } from '../common/microsoft-cloud'; +import { onedriveDownloadFileAsFormatOutputSchema } from '../output-schemas'; + +export const onedriveDownloadFileAsFormat = createAction({ + auth: oneDriveAuth, + name: 'onedrive_download_file_as_format', + displayName: 'Convert and Download File', + description: 'Convert a file to PDF, HTML or JPG and download the result.', + audience: 'ai', + outputSchema: onedriveDownloadFileAsFormatOutputSchema, + classification: 'READ', + aiMetadata: { + description: + 'Have OneDrive convert one file to another format and download the converted copy; the stored file is not changed. Typical use is Word, Excel or PowerPoint to PDF; HTML works only for Loop, Fluid and whiteboard files, and JPG needs both Width and Height. Provide the item ID (from Search Files and Folders or List Folder Contents) or a path; use Download File for the original bytes. Read-only and safe to retry.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'File ID', + description: 'File ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'File path from the drive root. Used when File ID is empty.', + placeholder: 'Documents/report.docx', + required: false, + }), + format: Property.StaticDropdown({ + displayName: 'Target Format', + required: true, + defaultValue: 'pdf', + options: { + disabled: false, + options: [ + { label: 'PDF', value: 'pdf' }, + { label: 'HTML (Loop, Fluid and whiteboard files only)', value: 'html' }, + { label: 'JPG (requires Width and Height)', value: 'jpg' }, + ], + }, + }), + width: Property.Number({ + displayName: 'Width', + description: 'Image width in pixels. Required when Target Format is JPG.', + required: false, + }), + height: Property.Number({ + displayName: 'Height', + description: 'Image height in pixels. Required when Target Format is JPG.', + required: false, + }), + }, + async run(context) { + const { itemId, path, format, width, height } = context.propsValue; + const conversion = buildConversionParams({ format, width, height }); + const itemPath = oneDriveApi.itemPath({ itemId, path }); + const item = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: itemPath, + }); + if (item.file === undefined) { + throw new Error(`"${item.name}" is not a file, so it cannot be converted.`); + } + const content = await downloadContent({ + auth: context.auth, + contentPath: `${itemPath}/content`, + queryParams: conversion, + }).catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${message} OneDrive may not support converting this file type to ${format.toUpperCase()}.`); + }); + const fileName = `${item.name.replace(/\.[^.]*$/, '')}.${format}`; + return { + ...oneDriveApi.toItem(item), + convertedFormat: format, + convertedFileName: fileName, + data: await context.files.write({ fileName, data: content }), + }; + }, +}); + +function buildConversionParams({ + format, + width, + height, +}: { + format: string; + width?: number; + height?: number; +}): QueryParams { + if (format !== 'jpg') { + return { format }; + } + if (!isPositive(width) || !isPositive(height)) { + throw new Error('Width and Height are both required, as positive numbers, when Target Format is JPG.'); + } + return { format, width: String(Math.floor(width)), height: String(Math.floor(height)) }; +} + +function isPositive(value: number | undefined): value is number { + return typeof value === 'number' && Number.isFinite(value) && value >= 1; +} + +async function downloadContent({ + auth, + contentPath, + queryParams, +}: { + auth: OAuth2PropertyValue; + contentPath: string; + queryParams?: QueryParams; +}): Promise { + const cloud = auth.props?.['cloud']; + const graphBase = `${getGraphBaseUrl(typeof cloud === 'string' ? cloud : undefined)}/v1.0`; + try { + const first = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${graphBase}${contentPath}`, + queryParams, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: auth.access_token, + }, + headers: { Accept: '*/*' }, + responseType: 'stream', + followRedirects: false, + }); + if (first.status < 300) { + return first.body; + } + first.body.destroy(); + const location = first.headers?.['location']; + const downloadUrl = Array.isArray(location) ? location[0] : location; + if (!downloadUrl) { + throw new Error('OneDrive did not return a download location for this file.'); + } + const download = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: downloadUrl, + responseType: 'stream', + }); + return download.body; + } catch (error) { + throw new Error(oneDriveApi.describeError(error)); + } +} diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-file.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-file.ts new file mode 100644 index 000000000000..2eafd63af444 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-file.ts @@ -0,0 +1,96 @@ +import { Readable } from 'stream'; +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { AuthenticationType, HttpMethod, QueryParams, httpClient } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { getGraphBaseUrl } from '../common/microsoft-cloud'; +import { onedriveDownloadFileOutputSchema } from '../output-schemas'; + +export const onedriveDownloadFile = createAction({ + auth: oneDriveAuth, + name: 'onedrive_download_file', + displayName: 'Download File', + description: 'Download a file by its ID or path.', + audience: 'ai', + outputSchema: onedriveDownloadFileOutputSchema, + classification: 'READ', + aiMetadata: { + description: + 'Download the current content of one file together with its metadata, returning a file reference other steps can use. Provide either the item ID (from Search Files and Folders or List Folder Contents) or a path relative to the drive root; use Get File or Folder when you only need metadata, and Convert and Download File to get an Office file as PDF. Folders are rejected. Read-only and safe to retry.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'File ID', + description: 'File ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'File path from the drive root. Used when File ID is empty.', + placeholder: 'Documents/report.docx', + required: false, + }), + }, + async run(context) { + const { itemId, path } = context.propsValue; + const itemPath = oneDriveApi.itemPath({ itemId, path }); + const item = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: itemPath, + }); + if (item.file === undefined) { + throw new Error(`"${item.name}" is not a file, so it cannot be downloaded. Use List Folder Contents to see what a folder contains.`); + } + const content = await downloadContent({ auth: context.auth, contentPath: `${itemPath}/content` }); + return { + ...oneDriveApi.toItem(item), + data: await context.files.write({ fileName: item.name, data: content }), + }; + }, +}); + +async function downloadContent({ + auth, + contentPath, + queryParams, +}: { + auth: OAuth2PropertyValue; + contentPath: string; + queryParams?: QueryParams; +}): Promise { + const cloud = auth.props?.['cloud']; + const graphBase = `${getGraphBaseUrl(typeof cloud === 'string' ? cloud : undefined)}/v1.0`; + try { + const first = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: `${graphBase}${contentPath}`, + queryParams, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: auth.access_token, + }, + headers: { Accept: '*/*' }, + responseType: 'stream', + followRedirects: false, + }); + if (first.status < 300) { + return first.body; + } + first.body.destroy(); + const location = first.headers?.['location']; + const downloadUrl = Array.isArray(location) ? location[0] : location; + if (!downloadUrl) { + throw new Error('OneDrive did not return a download location for this file.'); + } + const download = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: downloadUrl, + responseType: 'stream', + }); + return download.body; + } catch (error) { + throw new Error(oneDriveApi.describeError(error)); + } +} diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-item-version.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-item-version.ts new file mode 100644 index 000000000000..925eb8cb1c8b --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-download-item-version.ts @@ -0,0 +1,93 @@ +import { Readable } from 'stream'; +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { HttpMethod, httpClient } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveDownloadItemVersionOutputSchema } from '../output-schemas'; + +export const onedriveDownloadItemVersion = createAction({ + auth: oneDriveAuth, + name: 'onedrive_download_item_version', + displayName: 'Download File Version', + description: 'Download the content of a previous version of a file.', + audience: 'ai', + outputSchema: onedriveDownloadItemVersionOutputSchema, + classification: 'READ', + aiMetadata: { + description: + 'Download the content of one earlier version of a file without restoring it. Get the version ID from List File Versions and provide the file ID (from Search Files and Folders or List Folder Contents) or a path; OneDrive does not serve the current version this way, so use Download File for the latest content. Read-only and safe to retry.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'File ID', + description: 'File ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'File path from the drive root. Used when File ID is empty.', + placeholder: 'Documents/report.docx', + required: false, + }), + versionId: Property.ShortText({ + displayName: 'Version ID', + description: 'Earlier version ID from List File Versions (not the current one).', + required: true, + }), + }, + async run(context) { + const { itemId, path } = context.propsValue; + const versionId = context.propsValue.versionId.trim(); + const itemPath = oneDriveApi.itemPath({ itemId, path }); + const item = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: itemPath, + queryParams: { $select: 'id,name,file' }, + }); + if (item.file === undefined) { + throw new Error(`"${item.name}" is not a file, so it has no versions to download.`); + } + const content = await downloadContent({ + auth: context.auth, + versionPath: `${itemPath}/versions/${encodeURIComponent(versionId)}`, + }); + return { + itemId: item.id, + name: item.name, + versionId, + mimeType: item.file.mimeType ?? null, + data: await context.files.write({ fileName: item.name, data: content }), + }; + }, +}); + +async function downloadContent({ + auth, + versionPath, +}: { + auth: OAuth2PropertyValue; + versionPath: string; +}): Promise { + const version = await oneDriveApi.request<{ '@microsoft.graph.downloadUrl'?: string }>({ + auth, + method: HttpMethod.GET, + path: versionPath, + }); + const downloadUrl = version['@microsoft.graph.downloadUrl']; + if (!downloadUrl) { + throw new Error('OneDrive did not return a download URL for this version.'); + } + try { + const download = await httpClient.sendRequest({ + method: HttpMethod.GET, + url: downloadUrl, + headers: { Accept: '*/*' }, + responseType: 'stream', + }); + return download.body; + } catch (error) { + throw new Error(oneDriveApi.describeError(error)); + } +} diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-copy-status.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-copy-status.ts new file mode 100644 index 000000000000..c92d9e564f76 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-copy-status.ts @@ -0,0 +1,134 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { httpClient, HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveGetCopyStatusOutputSchema } from '../output-schemas'; + +export const onedriveGetCopyStatus = createAction({ + auth: oneDriveAuth, + name: 'onedrive_get_copy_status', + displayName: 'Get Copy Status', + description: 'Check the progress of a copy started with Copy File or Folder.', + audience: 'ai', + outputSchema: onedriveGetCopyStatusOutputSchema, + classification: 'READ', + aiMetadata: { + description: + 'Reads the current status of a copy started by Copy File or Folder, once per call, from the monitor URL that action returned. Call it again later while the status is notStarted or inProgress; when it is completed, resourceId is the ID of the new copy, and when it is failed, errorMessage explains why (for example a name conflict). Only accepts monitor URLs on OneDrive and SharePoint hosts.', + idempotent: true, + }, + props: { + monitorUrl: Property.ShortText({ + displayName: 'Monitor URL', + description: 'The monitorUrl returned by Copy File or Folder.', + required: true, + }), + }, + async run(context) { + const url = validateMonitorUrl({ monitorUrl: context.propsValue.monitorUrl }); + try { + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url, + followRedirects: false, + }); + if (response.status >= 300 && response.status < 400) { + const location = response.headers?.['location']; + const target = Array.isArray(location) ? location[0] : location; + return { + status: 'completed', + percentageComplete: 100, + resourceId: target ? extractItemId({ location: target }) : null, + errorCode: null, + errorMessage: null, + }; + } + return toStatus({ body: response.body }); + } catch (error) { + throw new Error(oneDriveApi.describeError(error)); + } + }, +}); + +function validateMonitorUrl({ monitorUrl }: { monitorUrl: string }): string { + let parsed: URL; + try { + parsed = new URL(monitorUrl.trim()); + } catch { + throw new Error('Monitor URL is not a valid URL. Pass the monitorUrl returned by Copy File or Folder.'); + } + if (parsed.protocol !== 'https:') { + throw new Error('Monitor URL must use https.'); + } + const host = parsed.hostname.toLowerCase(); + const allowed = + ALLOWED_EXACT_HOSTS.includes(host) || ALLOWED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix)); + if (!allowed) { + throw new Error(`Monitor URL host "${host}" is not a OneDrive or SharePoint host.`); + } + return parsed.toString(); +} + +function extractItemId({ location }: { location: string }): string | null { + const match = /\/items\/([^/?#:]+)/.exec(location); + return match ? decodeURIComponent(match[1]) : null; +} + +function toStatus({ body }: { body: MonitorStatus | undefined }): CopyStatus { + const status = body?.status ?? 'unknown'; + const errorCode = body?.error?.code ?? body?.errorCode ?? null; + const rawMessage = body?.error?.message ?? null; + return { + status, + percentageComplete: body?.percentageComplete ?? null, + resourceId: body?.resourceId ?? null, + errorCode, + errorMessage: describeFailure({ status, errorCode, rawMessage }), + }; +} + +function describeFailure({ + status, + errorCode, + rawMessage, +}: { + status: string; + errorCode: string | null; + rawMessage: string | null; +}): string | null { + if (errorCode === 'nameAlreadyExists') { + return 'The destination already has an item with this name. Start the copy again with a different New Name.'; + } + if (rawMessage) { + return rawMessage; + } + if (status === 'failed') { + return errorCode ? `The copy failed (${errorCode}).` : 'The copy failed.'; + } + return null; +} + +const ALLOWED_EXACT_HOSTS = ['api.onedrive.com']; +const ALLOWED_HOST_SUFFIXES = [ + '.svc.ms', + '.microsoftpersonalcontent.com', + '.sharepoint.com', + '.sharepoint.us', + '.sharepoint-df.com', +]; + +type MonitorStatus = { + status?: string; + percentageComplete?: number; + resourceId?: string; + errorCode?: string; + error?: { code?: string; message?: string }; +}; + +type CopyStatus = { + status: string; + percentageComplete: number | null; + resourceId: string | null; + errorCode: string | null; + errorMessage: string | null; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-drive.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-drive.ts new file mode 100644 index 000000000000..4b6711d2bc3e --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-drive.ts @@ -0,0 +1,60 @@ +import { createAction } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphIdentity, oneDriveApi } from '../common/graph-api'; +import { onedriveGetDriveOutputSchema } from '../output-schemas'; + +export const onedriveGetDrive = createAction({ + auth: oneDriveAuth, + name: 'onedrive_get_drive', + displayName: 'Get Drive', + description: 'Get the connected OneDrive drive, including its type and storage quota.', + audience: 'ai', + outputSchema: onedriveGetDriveOutputSchema, + classification: 'READ', + aiMetadata: { + description: + 'Read the signed-in user\'s own OneDrive drive: its drive ID, whether it is a personal or business drive, and its storage quota. Use it to check free space or to learn the account type before calling actions that only work on personal OneDrive or only on OneDrive for Business. Read-only and safe to retry.', + idempotent: true, + }, + props: {}, + async run(context) { + const drive = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: '/me/drive', + }); + return { + id: drive.id, + name: drive.name ?? null, + driveType: drive.driveType ?? null, + webUrl: drive.webUrl ?? null, + createdDateTime: drive.createdDateTime ?? null, + lastModifiedDateTime: drive.lastModifiedDateTime ?? null, + ownerName: drive.owner?.user?.displayName ?? null, + ownerEmail: drive.owner?.user?.email ?? null, + quotaTotal: drive.quota?.total ?? null, + quotaUsed: drive.quota?.used ?? null, + quotaRemaining: drive.quota?.remaining ?? null, + quotaDeleted: drive.quota?.deleted ?? null, + quotaState: drive.quota?.state ?? null, + }; + }, +}); + +type GraphDrive = { + id: string; + name?: string; + driveType?: string; + webUrl?: string; + createdDateTime?: string; + lastModifiedDateTime?: string; + owner?: GraphIdentity; + quota?: { + total?: number; + used?: number; + remaining?: number; + deleted?: number; + state?: string; + }; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-item.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-item.ts new file mode 100644 index 000000000000..f0744e6086d8 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-item.ts @@ -0,0 +1,42 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveItemOutputSchema } from '../output-schemas'; + +export const onedriveGetItem = createAction({ + auth: oneDriveAuth, + name: 'onedrive_get_item', + displayName: 'Get File or Folder', + description: 'Get the metadata of a file or folder by its ID or path.', + audience: 'ai', + outputSchema: onedriveItemOutputSchema, + classification: 'READ', + aiMetadata: { + description: + 'Read the metadata of one file or folder (name, size, type, parent, dates, web link) without downloading its content. Provide either the item ID (from Search Files and Folders or List Folder Contents; "root" is the drive root) or a path relative to the drive root; use Download File when you need the bytes. Read-only and safe to retry.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'Item ID from Search or List Folder Contents, or "root". Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'Path from the drive root. Used when Item ID is empty.', + placeholder: 'Documents/report.docx', + required: false, + }), + }, + async run(context) { + const { itemId, path } = context.propsValue; + const item = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: oneDriveApi.itemPath({ itemId, path }), + }); + return oneDriveApi.toItem(item); + }, +}); diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-special-folder.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-special-folder.ts new file mode 100644 index 000000000000..4063d872be2a --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-get-special-folder.ts @@ -0,0 +1,45 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveItemOutputSchema } from '../output-schemas'; + +export const onedriveGetSpecialFolder = createAction({ + auth: oneDriveAuth, + name: 'onedrive_get_special_folder', + displayName: 'Get Special Folder', + description: 'Get a well-known OneDrive folder such as Documents or Photos.', + audience: 'ai', + outputSchema: onedriveItemOutputSchema, + classification: 'READ', + aiMetadata: { + description: + 'Resolve a well-known OneDrive folder (Documents, Photos, Camera Roll, Music, Recordings) to its folder ID, whatever its localized name is. Use it before listing or uploading into one of these folders instead of guessing a path. If the folder does not exist yet, OneDrive creates it on first access; later calls return the same folder, so it is safe to retry.', + idempotent: true, + }, + props: { + folder: Property.StaticDropdown({ + displayName: 'Folder', + required: true, + options: { + disabled: false, + options: [ + { label: 'Documents', value: 'documents' }, + { label: 'Photos', value: 'photos' }, + { label: 'Camera Roll', value: 'cameraroll' }, + { label: 'Music', value: 'music' }, + { label: 'Recordings', value: 'recordings' }, + ], + }, + }), + }, + async run(context) { + const { folder } = context.propsValue; + const item = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: `/me/drive/special/${encodeURIComponent(folder)}`, + }); + return oneDriveApi.toItem(item); + }, +}); diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-invite-to-item.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-invite-to-item.ts new file mode 100644 index 000000000000..d4d8b34a0bfc --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-invite-to-item.ts @@ -0,0 +1,159 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveInviteToItemOutputSchema } from '../output-schemas'; + +export const onedriveInviteToItem = createAction({ + auth: oneDriveAuth, + name: 'onedrive_invite_to_item', + displayName: 'Share with People (Invite)', + description: 'Grant specific people read or write access to a OneDrive file or folder.', + audience: 'ai', + outputSchema: onedriveInviteToItemOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Grants read or write access on a file or folder to specific people by email, keeping existing inherited access and requiring them to sign in. Use it for per-person sharing; for a link anyone or the organization can open use Create Sharing Link. No email is sent unless Send Invitation Email is true, and the root folder of a personal drive cannot be shared.', + idempotent: false, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'File or folder ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'Path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + recipients: Property.Array({ + displayName: 'Recipient Emails', + description: 'Email addresses of the people to share with.', + required: true, + }), + role: Property.StaticDropdown({ + displayName: 'Role', + description: 'Read lets recipients view, Write lets them edit.', + required: true, + defaultValue: 'read', + options: { + disabled: false, + options: [ + { label: 'Read', value: 'read' }, + { label: 'Write', value: 'write' }, + ], + }, + }), + sendInvitation: Property.Checkbox({ + displayName: 'Send Invitation Email', + description: 'If on, OneDrive emails each recipient. Off grants access silently.', + required: false, + defaultValue: false, + }), + message: Property.LongText({ + displayName: 'Message', + description: 'Message for the invitation email (only sent when emails are on).', + required: false, + }), + }, + async run(context) { + const { itemId, path, recipients, role, sendInvitation, message } = context.propsValue; + const emails = (recipients ?? []).flatMap((recipient) => + typeof recipient === 'string' && recipient.trim() ? [recipient.trim()] : [], + ); + if (emails.length === 0) { + throw new Error('Provide at least one recipient email address.'); + } + const body: Record = { + recipients: emails.map((email) => ({ email })), + roles: [role], + requireSignIn: true, + sendInvitation: sendInvitation === true, + retainInheritedPermissions: true, + }; + if (message) { + body['message'] = message; + } + const response = await oneDriveApi.request<{ value?: GraphPermission[] }>({ + auth: context.auth, + method: HttpMethod.POST, + path: `${oneDriveApi.itemPath({ itemId, path })}/invite`, + body, + }); + const permissions = (response.value ?? []).map(toPermission); + return { + permissions, + count: permissions.length, + invitationSent: sendInvitation === true, + }; + }, +}); + +function toPermission(permission: GraphPermission): OneDrivePermission { + const grantee = permission.grantedToV2?.user ?? permission.grantedTo?.user; + const identities = permission.grantedToIdentitiesV2 ?? permission.grantedToIdentities ?? []; + return { + id: permission.id, + roles: permission.roles ?? [], + linkType: permission.link?.type ?? null, + linkScope: permission.link?.scope ?? null, + linkWebUrl: permission.link?.webUrl ?? null, + linkPreventsDownload: permission.link?.preventsDownload ?? null, + grantedToName: grantee?.displayName ?? null, + grantedToEmail: grantee?.email ?? null, + grantedToId: grantee?.id ?? null, + grantedToIdentities: identities + .flatMap((identity) => { + const value = identity.user?.email ?? identity.user?.displayName; + return value ? [value] : []; + }) + .join(', '), + invitationEmail: permission.invitation?.email ?? null, + inherited: permission.inheritedFrom !== undefined, + inheritedFromId: permission.inheritedFrom?.id ?? null, + inheritedFromPath: permission.inheritedFrom?.path ?? null, + hasPassword: permission.hasPassword ?? null, + expirationDateTime: permission.expirationDateTime ?? null, + shareId: permission.shareId ?? null, + }; +} + +type GraphPermissionUser = { displayName?: string; email?: string; id?: string }; + +type GraphPermission = { + id: string; + roles?: string[]; + link?: { type?: string; scope?: string; webUrl?: string; preventsDownload?: boolean }; + grantedTo?: { user?: GraphPermissionUser }; + grantedToV2?: { user?: GraphPermissionUser }; + grantedToIdentities?: { user?: GraphPermissionUser }[]; + grantedToIdentitiesV2?: { user?: GraphPermissionUser }[]; + invitation?: { email?: string }; + inheritedFrom?: { id?: string; path?: string; driveId?: string }; + hasPassword?: boolean; + expirationDateTime?: string; + shareId?: string; +}; + +type OneDrivePermission = { + id: string; + roles: string[]; + linkType: string | null; + linkScope: string | null; + linkWebUrl: string | null; + linkPreventsDownload: boolean | null; + grantedToName: string | null; + grantedToEmail: string | null; + grantedToId: string | null; + grantedToIdentities: string; + invitationEmail: string | null; + inherited: boolean; + inheritedFromId: string | null; + inheritedFromPath: string | null; + hasPassword: boolean | null; + expirationDateTime: string | null; + shareId: string | null; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-bundles.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-bundles.ts new file mode 100644 index 000000000000..bb3e5a2409dc --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-bundles.ts @@ -0,0 +1,68 @@ +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveListBundlesOutputSchema } from '../output-schemas'; + +export const onedriveListBundles = createAction({ + auth: oneDriveAuth, + name: 'onedrive_list_bundles', + displayName: 'List Albums and Bundles', + description: 'List the photo albums and file bundles in a personal OneDrive.', + audience: 'ai', + outputSchema: onedriveListBundlesOutputSchema, + classification: 'SEARCH', + aiMetadata: { + description: + 'List the bundles in the user\'s drive, meaning photo albums and multi-file share sets, with each bundle\'s ID and item count, one page per call. Use it to find a bundle ID for Add Item to Bundle or Remove Item from Bundle. Works only on personal OneDrive (Microsoft account); OneDrive for Business has no bundles. Read-only and safe to retry.', + idempotent: true, + }, + props: { + pageToken: Property.ShortText({ + displayName: 'Page Token', + description: 'nextPageToken from the previous call. Empty for the first page.', + required: false, + }), + }, + async run(context) { + const { pageToken } = context.propsValue; + await assertPersonalDrive({ auth: context.auth }); + const token = pageToken?.trim(); + const response = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: token ? token : '/me/drive/bundles', + }); + const items = response.value.map((bundle) => ({ + ...oneDriveApi.toItem(bundle), + bundleChildCount: bundle.bundle?.childCount ?? null, + isAlbum: bundle.bundle?.album !== undefined && bundle.bundle?.album !== null, + })); + return { + items, + count: items.length, + nextPageToken: response['@odata.nextLink'] ?? null, + }; + }, +}); + +async function assertPersonalDrive({ auth }: { auth: OAuth2PropertyValue }): Promise { + const drive = await oneDriveApi.request<{ driveType?: string }>({ + auth, + method: HttpMethod.GET, + path: '/me/drive', + queryParams: { $select: 'driveType' }, + }); + if (drive.driveType !== 'personal') { + throw new Error('Albums and bundles are available only on personal OneDrive (Microsoft account) drives. This connection uses OneDrive for Business.'); + } +} + +type GraphBundle = GraphDriveItem & { + bundle?: { childCount?: number; album?: Record | null }; +}; + +type GraphBundlePage = { + value: GraphBundle[]; + '@odata.nextLink'?: string; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-drive-changes.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-drive-changes.ts new file mode 100644 index 000000000000..86fb871d3a4c --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-drive-changes.ts @@ -0,0 +1,93 @@ +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { AuthenticationType, HttpError, HttpMethod, QueryParams, httpClient } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { getGraphBaseUrl } from '../common/microsoft-cloud'; +import { onedriveListDriveChangesOutputSchema } from '../output-schemas'; + +export const onedriveListDriveChanges = createAction({ + auth: oneDriveAuth, + name: 'onedrive_list_drive_changes', + displayName: 'List Drive Changes (Delta)', + description: 'List items that changed in the drive since a saved delta token, one page at a time.', + audience: 'ai', + outputSchema: onedriveListDriveChangesOutputSchema, + classification: 'SEARCH', + aiMetadata: { + description: + 'Track changes across the whole drive, one page per call: with no token it enumerates every item, with a token it returns only items created, changed or deleted since then (deleted items have deleted = true). Keep calling with nextPageToken until it is null, then save deltaToken and pass it on a later call to get the next changes; set Start From Now to skip the full enumeration and get a deltaToken only. If a token has expired, the action fails and you must start again without a token.', + idempotent: true, + }, + props: { + pageToken: Property.ShortText({ + displayName: 'Page or Delta Token', + description: 'nextPageToken or deltaToken from a previous call. Empty to start.', + required: false, + }), + startFromNow: Property.Checkbox({ + displayName: 'Start From Now', + description: 'Without a token, skip current items and return only a deltaToken.', + required: false, + defaultValue: false, + }), + }, + async run(context) { + const { pageToken, startFromNow } = context.propsValue; + const token = pageToken?.trim(); + const graphPrefix = graphV1Prefix({ auth: context.auth }); + if (token && !token.startsWith(graphPrefix)) { + throw new Error('The token is not a valid OneDrive delta link. Pass the nextPageToken or deltaToken from a previous call unchanged.'); + } + const response = await fetchDeltaPage({ + auth: context.auth, + url: token ? token : `${graphPrefix}me/drive/root/delta`, + queryParams: !token && startFromNow ? { token: 'latest' } : undefined, + }); + const items = response.value.map((item) => oneDriveApi.toItem(item)); + return { + items, + count: items.length, + nextPageToken: response['@odata.nextLink'] ?? null, + deltaToken: response['@odata.deltaLink'] ?? null, + }; + }, +}); + +async function fetchDeltaPage({ + auth, + url, + queryParams, +}: { + auth: OAuth2PropertyValue; + url: string; + queryParams?: QueryParams; +}): Promise { + try { + const response = await httpClient.sendRequest({ + method: HttpMethod.GET, + url, + queryParams, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: auth.access_token, + }, + }); + return response.body; + } catch (error) { + if (error instanceof HttpError && error.response.status === 410) { + throw new Error('The delta token has expired or is no longer valid. Call this action again with no token to restart the enumeration from the beginning.'); + } + throw new Error(oneDriveApi.describeError(error)); + } +} + +function graphV1Prefix({ auth }: { auth: OAuth2PropertyValue }): string { + const cloud = auth.props?.['cloud']; + return `${getGraphBaseUrl(typeof cloud === 'string' ? cloud : undefined)}/v1.0/`; +} + +type GraphDeltaPage = { + value: GraphDriveItem[]; + '@odata.nextLink'?: string; + '@odata.deltaLink'?: string; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-drives.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-drives.ts new file mode 100644 index 000000000000..ae6c4c462652 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-drives.ts @@ -0,0 +1,65 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphIdentity, oneDriveApi } from '../common/graph-api'; +import { onedriveListDrivesOutputSchema } from '../output-schemas'; + +export const onedriveListDrives = createAction({ + auth: oneDriveAuth, + name: 'onedrive_list_drives', + displayName: 'List My Drives', + description: 'List the drives available to the signed-in user.', + audience: 'ai', + outputSchema: onedriveListDrivesOutputSchema, + classification: 'SEARCH', + aiMetadata: { + description: + 'List the drives that belong to the signed-in user, with each drive\'s ID, type and quota. Use Get Drive instead when you only need the default drive; SharePoint site and group drives are not included. Read-only and safe to retry.', + idempotent: true, + }, + props: { + pageToken: Property.ShortText({ + displayName: 'Page Token', + description: 'nextPageToken from the previous call. Empty for the first page.', + required: false, + }), + }, + async run(context) { + const { pageToken } = context.propsValue; + const token = pageToken?.trim(); + const response = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: token ? token : '/me/drives', + }); + const items = response.value.map((drive) => ({ + id: drive.id, + name: drive.name ?? null, + driveType: drive.driveType ?? null, + webUrl: drive.webUrl ?? null, + ownerName: drive.owner?.user?.displayName ?? null, + ownerEmail: drive.owner?.user?.email ?? null, + quotaTotal: drive.quota?.total ?? null, + quotaUsed: drive.quota?.used ?? null, + quotaRemaining: drive.quota?.remaining ?? null, + quotaState: drive.quota?.state ?? null, + })); + return { + items, + count: items.length, + nextPageToken: response['@odata.nextLink'] ?? null, + }; + }, +}); + +type GraphDriveList = { + value: { + id: string; + name?: string; + driveType?: string; + webUrl?: string; + owner?: GraphIdentity; + quota?: { total?: number; used?: number; remaining?: number; state?: string }; + }[]; + '@odata.nextLink'?: string; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-folder-children.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-folder-children.ts new file mode 100644 index 000000000000..65651e84c7b6 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-folder-children.ts @@ -0,0 +1,105 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveListItemsOutputSchema } from '../output-schemas'; + +export const onedriveListFolderChildren = createAction({ + auth: oneDriveAuth, + name: 'onedrive_list_folder_children', + displayName: 'List Folder Contents', + description: 'List the files and folders inside a folder, one page at a time.', + audience: 'ai', + outputSchema: onedriveListItemsOutputSchema, + classification: 'SEARCH', + aiMetadata: { + description: + 'List the direct children of one folder (the drive root when no folder is given), optionally keeping only files or only folders, one page per call. Use Search Files and Folders to find items by name anywhere in the drive. The type filter is applied after the page is fetched, so a page can come back empty while nextPageToken is still set: keep paging until nextPageToken is null.', + idempotent: true, + }, + props: { + folderId: Property.ShortText({ + displayName: 'Folder ID', + description: 'Folder ID. Leave this and Folder Path empty for the drive root.', + required: false, + }), + folderPath: Property.ShortText({ + displayName: 'Folder Path', + description: 'Folder path from the drive root. Used when Folder ID is empty.', + placeholder: 'Documents/Reports', + required: false, + }), + itemType: Property.StaticDropdown({ + displayName: 'Item Type', + required: false, + defaultValue: 'all', + options: { + disabled: false, + options: [ + { label: 'Files and folders', value: 'all' }, + { label: 'Files only', value: 'files' }, + { label: 'Folders only', value: 'folders' }, + ], + }, + }), + pageSize: Property.Number({ + displayName: 'Page Size', + description: 'How many items to request per page (1-999). Defaults to 100.', + required: false, + }), + pageToken: Property.ShortText({ + displayName: 'Page Token', + description: 'nextPageToken from the previous call. Empty for the first page.', + required: false, + }), + }, + async run(context) { + const { folderId, folderPath, itemType, pageSize, pageToken } = context.propsValue; + const token = pageToken?.trim(); + const response = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: token + ? token + : `${resolveFolder({ folderId, folderPath })}/children`, + queryParams: token ? undefined : { $top: String(clampPageSize({ pageSize })) }, + }); + const items = response.value + .filter((item) => matchesType({ item, itemType: itemType ?? 'all' })) + .map((item) => oneDriveApi.toItem(item)); + return { + items, + count: items.length, + nextPageToken: response['@odata.nextLink'] ?? null, + }; + }, +}); + +function resolveFolder({ folderId, folderPath }: { folderId?: string; folderPath?: string }): string { + if (!folderId?.trim() && folderPath?.trim()) { + return oneDriveApi.itemPath({ path: folderPath }); + } + return oneDriveApi.folderPath({ folderId }); +} + +function matchesType({ item, itemType }: { item: GraphDriveItem; itemType: string }): boolean { + if (itemType === 'files') { + return item.file !== undefined; + } + if (itemType === 'folders') { + return item.folder !== undefined; + } + return true; +} + +function clampPageSize({ pageSize }: { pageSize?: number }): number { + if (pageSize === undefined || pageSize === null || Number.isNaN(pageSize)) { + return 100; + } + return Math.min(Math.max(Math.floor(pageSize), 1), 999); +} + +type GraphItemPage = { + value: GraphDriveItem[]; + '@odata.nextLink'?: string; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-permissions.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-permissions.ts new file mode 100644 index 000000000000..e4146be5b32e --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-permissions.ts @@ -0,0 +1,118 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveListItemPermissionsOutputSchema } from '../output-schemas'; + +export const onedriveListItemPermissions = createAction({ + auth: oneDriveAuth, + name: 'onedrive_list_item_permissions', + displayName: 'List File Permissions', + description: 'List who has access to a OneDrive file or folder, including sharing links.', + audience: 'ai', + outputSchema: onedriveListItemPermissionsOutputSchema, + classification: 'SEARCH', + aiMetadata: { + description: + 'Lists the permissions on a file or folder: owner, per-person grants, sharing links and access inherited from parent folders. Use it to find the permission ID that Update Permission Role and Remove Permission need; permissions marked inherited can only be changed on the folder they are inherited from.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'File or folder ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'Path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + pageToken: Property.ShortText({ + displayName: 'Page Token', + description: 'nextPageToken from the previous call. Empty for the first page.', + required: false, + }), + }, + async run(context) { + const { itemId, path, pageToken } = context.propsValue; + const response = await oneDriveApi.request<{ value?: GraphPermission[]; '@odata.nextLink'?: string }>({ + auth: context.auth, + method: HttpMethod.GET, + path: pageToken?.trim() ? pageToken.trim() : `${oneDriveApi.itemPath({ itemId, path })}/permissions`, + }); + const items = (response.value ?? []).map(toPermission); + return { + items, + count: items.length, + nextPageToken: response['@odata.nextLink'] ?? null, + }; + }, +}); + +function toPermission(permission: GraphPermission): OneDrivePermission { + const grantee = permission.grantedToV2?.user ?? permission.grantedTo?.user; + const identities = permission.grantedToIdentitiesV2 ?? permission.grantedToIdentities ?? []; + return { + id: permission.id, + roles: permission.roles ?? [], + linkType: permission.link?.type ?? null, + linkScope: permission.link?.scope ?? null, + linkWebUrl: permission.link?.webUrl ?? null, + linkPreventsDownload: permission.link?.preventsDownload ?? null, + grantedToName: grantee?.displayName ?? null, + grantedToEmail: grantee?.email ?? null, + grantedToId: grantee?.id ?? null, + grantedToIdentities: identities + .flatMap((identity) => { + const value = identity.user?.email ?? identity.user?.displayName; + return value ? [value] : []; + }) + .join(', '), + invitationEmail: permission.invitation?.email ?? null, + inherited: permission.inheritedFrom !== undefined, + inheritedFromId: permission.inheritedFrom?.id ?? null, + inheritedFromPath: permission.inheritedFrom?.path ?? null, + hasPassword: permission.hasPassword ?? null, + expirationDateTime: permission.expirationDateTime ?? null, + shareId: permission.shareId ?? null, + }; +} + +type GraphPermissionUser = { displayName?: string; email?: string; id?: string }; + +type GraphPermission = { + id: string; + roles?: string[]; + link?: { type?: string; scope?: string; webUrl?: string; preventsDownload?: boolean }; + grantedTo?: { user?: GraphPermissionUser }; + grantedToV2?: { user?: GraphPermissionUser }; + grantedToIdentities?: { user?: GraphPermissionUser }[]; + grantedToIdentitiesV2?: { user?: GraphPermissionUser }[]; + invitation?: { email?: string }; + inheritedFrom?: { id?: string; path?: string; driveId?: string }; + hasPassword?: boolean; + expirationDateTime?: string; + shareId?: string; +}; + +type OneDrivePermission = { + id: string; + roles: string[]; + linkType: string | null; + linkScope: string | null; + linkWebUrl: string | null; + linkPreventsDownload: boolean | null; + grantedToName: string | null; + grantedToEmail: string | null; + grantedToId: string | null; + grantedToIdentities: string; + invitationEmail: string | null; + inherited: boolean; + inheritedFromId: string | null; + inheritedFromPath: string | null; + hasPassword: boolean | null; + expirationDateTime: string | null; + shareId: string | null; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-thumbnails.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-thumbnails.ts new file mode 100644 index 000000000000..8fd8233b0530 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-thumbnails.ts @@ -0,0 +1,72 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveListItemThumbnailsOutputSchema } from '../output-schemas'; + +export const onedriveListItemThumbnails = createAction({ + auth: oneDriveAuth, + name: 'onedrive_list_item_thumbnails', + displayName: 'Get File Thumbnails', + description: 'Get the small, medium and large thumbnail image URLs of a file.', + audience: 'ai', + outputSchema: onedriveListItemThumbnailsOutputSchema, + classification: 'SEARCH', + aiMetadata: { + description: + 'Get the thumbnail image URLs (small, medium, large, with sizes) that OneDrive generated for one file, for previews or image galleries. Provide the item ID (from Search Files and Folders or List Folder Contents) or a path; an empty result is normal for a new file whose thumbnails are not generated yet, or for file types without thumbnails. The URLs are short-lived. Read-only and safe to retry.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'File ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'File path from the drive root. Used when Item ID is empty.', + placeholder: 'Photos/beach.jpg', + required: false, + }), + }, + async run(context) { + const { itemId, path } = context.propsValue; + const response = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: `${oneDriveApi.itemPath({ itemId, path })}/thumbnails`, + }); + const items = response.value.map((set) => ({ + thumbnailSetId: set.id ?? null, + smallUrl: set.small?.url ?? null, + smallWidth: set.small?.width ?? null, + smallHeight: set.small?.height ?? null, + mediumUrl: set.medium?.url ?? null, + mediumWidth: set.medium?.width ?? null, + mediumHeight: set.medium?.height ?? null, + largeUrl: set.large?.url ?? null, + largeWidth: set.large?.width ?? null, + largeHeight: set.large?.height ?? null, + })); + return { + items, + count: items.length, + }; + }, +}); + +type GraphThumbnail = { + url?: string; + width?: number; + height?: number; +}; + +type GraphThumbnailList = { + value: { + id?: string; + small?: GraphThumbnail; + medium?: GraphThumbnail; + large?: GraphThumbnail; + }[]; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-versions.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-versions.ts new file mode 100644 index 000000000000..f4c8a7801818 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-list-item-versions.ts @@ -0,0 +1,71 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphIdentity, oneDriveApi } from '../common/graph-api'; +import { onedriveListItemVersionsOutputSchema } from '../output-schemas'; + +export const onedriveListItemVersions = createAction({ + auth: oneDriveAuth, + name: 'onedrive_list_item_versions', + displayName: 'List File Versions', + description: 'List the saved versions of a file.', + audience: 'ai', + outputSchema: onedriveListItemVersionsOutputSchema, + classification: 'SEARCH', + aiMetadata: { + description: + 'List the version history of one file, newest first, with each version ID, size, modification time and who made it, one page per call. Use it to get the version ID that Download File Version and Restore File Version need. Provide the item ID (from Search Files and Folders or List Folder Contents) or a path; retention is controlled by the account or admin, so the list can be short. Read-only and safe to retry.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'File ID', + description: 'File ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'File path from the drive root. Used when File ID is empty.', + placeholder: 'Documents/report.docx', + required: false, + }), + pageToken: Property.ShortText({ + displayName: 'Page Token', + description: 'nextPageToken from the previous call. Empty for the first page.', + required: false, + }), + }, + async run(context) { + const { itemId, path, pageToken } = context.propsValue; + const token = pageToken?.trim(); + const response = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: token + ? token + : `${oneDriveApi.itemPath({ itemId, path })}/versions`, + }); + const items = response.value.map((version) => ({ + versionId: version.id, + lastModifiedDateTime: version.lastModifiedDateTime ?? null, + size: version.size ?? null, + lastModifiedByName: version.lastModifiedBy?.user?.displayName ?? null, + lastModifiedByEmail: version.lastModifiedBy?.user?.email ?? null, + })); + return { + items, + count: items.length, + nextPageToken: response['@odata.nextLink'] ?? null, + }; + }, +}); + +type GraphVersionPage = { + value: { + id: string; + lastModifiedDateTime?: string; + size?: number; + lastModifiedBy?: GraphIdentity; + }[]; + '@odata.nextLink'?: string; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-move-item.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-move-item.ts new file mode 100644 index 000000000000..ed486f001e38 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-move-item.ts @@ -0,0 +1,68 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveItemOutputSchema } from '../output-schemas'; + +export const onedriveMoveItem = createAction({ + auth: oneDriveAuth, + name: 'onedrive_move_item', + displayName: 'Move File or Folder', + description: 'Move a OneDrive file or folder to another folder, optionally renaming it.', + audience: 'ai', + outputSchema: onedriveItemOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Moves a file or folder (with its contents) into another folder in the same drive, optionally renaming it; the item keeps its ID. Use Rename / Update File or Folder to rename without moving, and Copy File or Folder to keep the original. Fails if the destination already has an item with the same name unless you set New Name; moving between drives is not supported. Retrying by item ID is safe; after a move by path, use the returned item ID, because the old path no longer exists.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'ID of the item to move, from Search or List Folder Contents.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'Path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + destinationFolderId: Property.ShortText({ + displayName: 'Destination Folder ID', + description: 'Folder to move into. Leave empty for the drive root.', + required: false, + }), + newName: Property.ShortText({ + displayName: 'New Name', + description: 'Optional new name for the item. Leave empty to keep its current name.', + required: false, + }), + }, + async run(context) { + const { itemId, path, destinationFolderId, newName } = context.propsValue; + const itemPath = oneDriveApi.itemPath({ itemId, path }); + const parentId = await oneDriveApi.resolveFolderId({ auth: context.auth, folderId: destinationFolderId }); + const body: Record = { parentReference: { id: parentId } }; + const trimmedName = newName?.trim(); + if (trimmedName) { + body['name'] = trimmedName; + } + try { + const item = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.PATCH, + path: itemPath, + body, + }); + return oneDriveApi.toItem(item); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith('An item with that name already exists')) { + throw new Error(`${message} Set New Name to move it under a different name.`); + } + throw error; + } + }, +}); diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-remove-item-from-bundle.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-remove-item-from-bundle.ts new file mode 100644 index 000000000000..1d46066a130d --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-remove-item-from-bundle.ts @@ -0,0 +1,55 @@ +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveBundleMembershipOutputSchema } from '../output-schemas'; + +export const onedriveRemoveItemFromBundle = createAction({ + auth: oneDriveAuth, + name: 'onedrive_remove_item_from_bundle', + displayName: 'Remove Item from Bundle', + description: 'Remove a file from a bundle or album without deleting the file.', + audience: 'ai', + outputSchema: onedriveBundleMembershipOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Take one file out of a bundle or photo album in a personal OneDrive; the file itself is not deleted and stays in its folder. Get the bundle ID from List Albums and Bundles and the item ID from Search Files and Folders or List Folder Contents; to delete the file itself use Delete File or Folder instead. Works only on personal OneDrive (Microsoft account); repeating the call after success fails because the item is no longer in the bundle.', + idempotent: false, + }, + props: { + bundleId: Property.ShortText({ + displayName: 'Bundle ID', + description: 'The ID of the bundle or album, from List Albums and Bundles.', + required: true, + }), + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'The ID of the file to remove from the bundle. The file itself is kept.', + required: true, + }), + }, + async run(context) { + const bundleId = context.propsValue.bundleId.trim(); + const itemId = context.propsValue.itemId.trim(); + await assertPersonalDrive({ auth: context.auth }); + await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.DELETE, + path: `/me/drive/bundles/${encodeURIComponent(bundleId)}/children/${encodeURIComponent(itemId)}`, + }); + return { success: true, bundleId, itemId }; + }, +}); + +async function assertPersonalDrive({ auth }: { auth: OAuth2PropertyValue }): Promise { + const drive = await oneDriveApi.request<{ driveType?: string }>({ + auth, + method: HttpMethod.GET, + path: '/me/drive', + queryParams: { $select: 'driveType' }, + }); + if (drive.driveType !== 'personal') { + throw new Error('Bundles are available only on personal OneDrive (Microsoft account) drives. This connection uses OneDrive for Business.'); + } +} diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-replace-file-content.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-replace-file-content.ts new file mode 100644 index 000000000000..2299cbacd464 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-replace-file-content.ts @@ -0,0 +1,130 @@ +import { Readable } from 'node:stream'; +import { buffer as readableToBuffer } from 'node:stream/consumers'; +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { httpClient, HttpMethod, streamUtils } from '@activepieces/pieces-common'; +import mime from 'mime-types'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveItemOutputSchema } from '../output-schemas'; + +export const onedriveReplaceFileContent = createAction({ + auth: oneDriveAuth, + name: 'onedrive_replace_file_content', + displayName: 'Replace File Content', + description: 'Overwrite the content of an existing OneDrive file, keeping its ID and sharing links.', + audience: 'ai', + outputSchema: onedriveItemOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Overwrites the content of an existing file, identified by Item ID or Path, and keeps its ID, name and sharing links; files over 4 MiB are sent in chunks. Use Upload File to add a new file by folder and name. The item must already exist and be a file (not a folder); get its ID from Search Files and Folders or List Folder Contents. Re-running with the same content converges on the same state.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'ID of the file to overwrite. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'File path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + file: Property.File({ + displayName: 'File', + description: 'The new content, as a file URL or base64.', + required: true, + streaming: true, + }), + }, + async run(context) { + const { itemId, path, file } = context.propsValue; + const existing = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: oneDriveApi.itemPath({ itemId, path }), + queryParams: { $select: 'id,name,file,folder,package' }, + }); + if (!existing.file) { + throw new Error(`"${existing.name}" is not a file, so its content cannot be replaced.`); + } + const mimeType = mime.lookup(file.extension ?? '') || existing.file.mimeType || 'application/octet-stream'; + let { body, size } = streamUtils.toStreamingBody(file); + if (size == null) { + const buffered = await readableToBuffer(body); + size = buffered.length; + body = Readable.from(buffered); + } + const item = await replaceContent({ + auth: context.auth, + targetPath: oneDriveApi.itemPath({ itemId: existing.id }), + body, + size, + mimeType, + }); + return oneDriveApi.toItem(item); + }, +}); + +async function replaceContent({ + auth, + targetPath, + body, + size, + mimeType, +}: { + auth: OAuth2PropertyValue; + targetPath: string; + body: Readable; + size: number; + mimeType: string; +}): Promise { + if (size <= SIMPLE_UPLOAD_LIMIT) { + return oneDriveApi.request({ + auth, + method: HttpMethod.PUT, + path: `${targetPath}/content`, + body, + headers: { + 'Content-Type': mimeType, + 'Content-Length': size.toString(), + }, + }); + } + const session = await oneDriveApi.request<{ uploadUrl: string }>({ + auth, + method: HttpMethod.POST, + path: `${targetPath}/createUploadSession`, + body: { item: { '@microsoft.graph.conflictBehavior': 'replace' } }, + headers: { 'Content-Type': 'application/json' }, + }); + let start = 0; + let result: GraphDriveItem | undefined; + for await (const chunk of streamUtils.readChunks({ readable: body, chunkSize: CHUNK_SIZE })) { + const end = start + chunk.length - 1; + try { + const response = await httpClient.sendRequest({ + method: HttpMethod.PUT, + url: session.uploadUrl, + body: chunk, + headers: { + 'Content-Length': chunk.length.toString(), + 'Content-Range': `bytes ${start}-${end}/${size}`, + }, + }); + result = response.body; + } catch (error) { + throw new Error(oneDriveApi.describeError(error)); + } + start += chunk.length; + } + if (!result?.id) { + throw new Error('The upload session finished without returning the updated file.'); + } + return result; +} + +const SIMPLE_UPLOAD_LIMIT = 4 * 1024 * 1024; +const CHUNK_SIZE = 10485760; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-resolve-sharing-link.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-resolve-sharing-link.ts new file mode 100644 index 000000000000..e147d8227083 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-resolve-sharing-link.ts @@ -0,0 +1,57 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveResolveSharingLinkOutputSchema } from '../output-schemas'; + +export const onedriveResolveSharingLink = createAction({ + auth: oneDriveAuth, + name: 'onedrive_resolve_sharing_link', + displayName: 'Resolve Sharing Link', + description: 'Look up the file or folder behind a OneDrive or SharePoint sharing link.', + audience: 'ai', + outputSchema: onedriveResolveSharingLinkOutputSchema, + classification: 'READ', + aiMetadata: { + description: + 'Turns a OneDrive (1drv.ms, onedrive.live.com) or SharePoint sharing URL, or an existing share token, into the item it points to, including its item ID and drive ID. Use it when a user pastes a link and you need the item for other actions; it only peeks at the link and does not add the item to the user\'s drive, and an item in someone else\'s drive has a different drive ID that the other item actions here cannot address.', + idempotent: true, + }, + props: { + sharingUrl: Property.ShortText({ + displayName: 'Sharing Link or Share Token', + description: 'The sharing URL, or a share token starting with u! or s!.', + placeholder: 'https://1drv.ms/...', + required: true, + }), + }, + async run(context) { + const shareId = toShareId({ value: context.propsValue.sharingUrl }); + const item = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: `/shares/${encodeURIComponent(shareId)}/driveItem`, + headers: { Prefer: 'redeemSharingLinkIfNecessary' }, + }); + return { + ...oneDriveApi.toItem(item), + shareId, + }; + }, +}); + +function toShareId({ value }: { value: string }): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error('Provide a sharing link or share token.'); + } + if (/^[us]!/.test(trimmed)) { + return trimmed; + } + const encoded = Buffer.from(trimmed, 'utf8') + .toString('base64') + .replace(/=+$/, '') + .replace(/\//g, '_') + .replace(/\+/g, '-'); + return `u!${encoded}`; +} diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-restore-item-version.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-restore-item-version.ts new file mode 100644 index 000000000000..1fe207008666 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-restore-item-version.ts @@ -0,0 +1,53 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedriveRestoreItemVersionOutputSchema } from '../output-schemas'; + +export const onedriveRestoreItemVersion = createAction({ + auth: oneDriveAuth, + name: 'onedrive_restore_item_version', + displayName: 'Restore File Version', + description: 'Make a previous version of a file the current version.', + audience: 'ai', + outputSchema: onedriveRestoreItemVersionOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Restore an earlier version of one file by copying it into a new current version; the file keeps its ID and the replaced content stays in the version history. Get the version ID from List File Versions and provide the file ID (from Search Files and Folders or List Folder Contents) or a path; use Download File Version to inspect a version without restoring it. Each call adds another version, so retries are not no-ops.', + idempotent: false, + }, + props: { + itemId: Property.ShortText({ + displayName: 'File ID', + description: 'File ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'File path from the drive root. Used when File ID is empty.', + placeholder: 'Documents/report.docx', + required: false, + }), + versionId: Property.ShortText({ + displayName: 'Version ID', + description: 'The ID of the version to restore, from List File Versions.', + required: true, + }), + }, + async run(context) { + const { itemId, path } = context.propsValue; + const versionId = context.propsValue.versionId.trim(); + await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.POST, + path: `${oneDriveApi.itemPath({ itemId, path })}/versions/${encodeURIComponent(versionId)}/restoreVersion`, + }); + return { + success: true, + itemId: itemId?.trim() || null, + path: itemId?.trim() ? null : path?.trim() || null, + versionId, + }; + }, +}); diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-search-items.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-search-items.ts new file mode 100644 index 000000000000..736468d92edd --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-search-items.ts @@ -0,0 +1,79 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveListItemsOutputSchema } from '../output-schemas'; + +export const onedriveSearchItems = createAction({ + auth: oneDriveAuth, + name: 'onedrive_search_items', + displayName: 'Search Files and Folders', + description: 'Search OneDrive for files and folders by name, metadata or content.', + audience: 'ai', + outputSchema: onedriveListItemsOutputSchema, + classification: 'SEARCH', + aiMetadata: { + description: + 'Search the user\'s OneDrive for files and folders whose name, metadata or content matches a text query, one page per call; use it to find an item ID when you only know a name. Scope "my_files" searches only the user\'s own drive, while "include_shared" also returns items shared with the user, which come back as remote items whose remoteItemId and remoteDriveId belong to another drive that the other OneDrive actions cannot address. The search index lags, so very recently added files may be missing.', + idempotent: true, + }, + props: { + query: Property.ShortText({ + displayName: 'Search Text', + description: 'The text to search for in file names, metadata and content.', + required: true, + }), + scope: Property.StaticDropdown({ + displayName: 'Scope', + required: false, + defaultValue: 'my_files', + options: { + disabled: false, + options: [ + { label: 'My files only', value: 'my_files' }, + { label: 'Include items shared with me', value: 'include_shared' }, + ], + }, + }), + pageSize: Property.Number({ + displayName: 'Page Size', + description: 'How many results to request per page (1-999). Defaults to 50.', + required: false, + }), + pageToken: Property.ShortText({ + displayName: 'Page Token', + description: 'nextPageToken from the previous call. Empty for the first page.', + required: false, + }), + }, + async run(context) { + const { query, scope, pageSize, pageToken } = context.propsValue; + const token = pageToken?.trim(); + const q = encodeURIComponent(query.replace(/'/g, "''")); + const searchRoot = scope === 'include_shared' ? '/me/drive' : '/me/drive/root'; + const response = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.GET, + path: token ? token : `${searchRoot}/search(q='${q}')`, + queryParams: token ? undefined : { $top: String(clampPageSize({ pageSize })) }, + }); + const items = response.value.map((item) => oneDriveApi.toItem(item)); + return { + items, + count: items.length, + nextPageToken: response['@odata.nextLink'] ?? null, + }; + }, +}); + +function clampPageSize({ pageSize }: { pageSize?: number }): number { + if (pageSize === undefined || pageSize === null || Number.isNaN(pageSize)) { + return 50; + } + return Math.min(Math.max(Math.floor(pageSize), 1), 999); +} + +type GraphItemPage = { + value: GraphDriveItem[]; + '@odata.nextLink'?: string; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-update-item-permission.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-update-item-permission.ts new file mode 100644 index 000000000000..f508397a1c02 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-update-item-permission.ts @@ -0,0 +1,133 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { oneDriveApi } from '../common/graph-api'; +import { onedrivePermissionOutputSchema } from '../output-schemas'; + +export const onedriveUpdateItemPermission = createAction({ + auth: oneDriveAuth, + name: 'onedrive_update_item_permission', + displayName: 'Update Permission Role', + description: 'Change a permission on a OneDrive file or folder to read or write.', + audience: 'ai', + outputSchema: onedrivePermissionOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Sets the role of one existing permission on a file or folder to read or write, replacing its current roles. Get the permission ID from List File Permissions; inherited permissions cannot be changed here and must be changed on the parent folder they come from. Safe to retry with the same role.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'File or folder ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'Path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + permissionId: Property.ShortText({ + displayName: 'Permission ID', + description: 'The ID of the permission to change, from List File Permissions.', + required: true, + }), + role: Property.StaticDropdown({ + displayName: 'Role', + description: 'The new role. Replaces the roles the permission has today.', + required: true, + options: { + disabled: false, + options: [ + { label: 'Read', value: 'read' }, + { label: 'Write', value: 'write' }, + ], + }, + }), + }, + async run(context) { + const { itemId, path, permissionId, role } = context.propsValue; + try { + const permission = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.PATCH, + path: `${oneDriveApi.itemPath({ itemId, path })}/permissions/${encodeURIComponent(permissionId.trim())}`, + body: { roles: [role] }, + }); + return toPermission(permission); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `${message} If this permission is inherited from a parent folder (inherited is true in List File Permissions), change it on that folder instead.`, + ); + } + }, +}); + +function toPermission(permission: GraphPermission): OneDrivePermission { + const grantee = permission.grantedToV2?.user ?? permission.grantedTo?.user; + const identities = permission.grantedToIdentitiesV2 ?? permission.grantedToIdentities ?? []; + return { + id: permission.id, + roles: permission.roles ?? [], + linkType: permission.link?.type ?? null, + linkScope: permission.link?.scope ?? null, + linkWebUrl: permission.link?.webUrl ?? null, + linkPreventsDownload: permission.link?.preventsDownload ?? null, + grantedToName: grantee?.displayName ?? null, + grantedToEmail: grantee?.email ?? null, + grantedToId: grantee?.id ?? null, + grantedToIdentities: identities + .flatMap((identity) => { + const value = identity.user?.email ?? identity.user?.displayName; + return value ? [value] : []; + }) + .join(', '), + invitationEmail: permission.invitation?.email ?? null, + inherited: permission.inheritedFrom !== undefined, + inheritedFromId: permission.inheritedFrom?.id ?? null, + inheritedFromPath: permission.inheritedFrom?.path ?? null, + hasPassword: permission.hasPassword ?? null, + expirationDateTime: permission.expirationDateTime ?? null, + shareId: permission.shareId ?? null, + }; +} + +type GraphPermissionUser = { displayName?: string; email?: string; id?: string }; + +type GraphPermission = { + id: string; + roles?: string[]; + link?: { type?: string; scope?: string; webUrl?: string; preventsDownload?: boolean }; + grantedTo?: { user?: GraphPermissionUser }; + grantedToV2?: { user?: GraphPermissionUser }; + grantedToIdentities?: { user?: GraphPermissionUser }[]; + grantedToIdentitiesV2?: { user?: GraphPermissionUser }[]; + invitation?: { email?: string }; + inheritedFrom?: { id?: string; path?: string; driveId?: string }; + hasPassword?: boolean; + expirationDateTime?: string; + shareId?: string; +}; + +type OneDrivePermission = { + id: string; + roles: string[]; + linkType: string | null; + linkScope: string | null; + linkWebUrl: string | null; + linkPreventsDownload: boolean | null; + grantedToName: string | null; + grantedToEmail: string | null; + grantedToId: string | null; + grantedToIdentities: string; + invitationEmail: string | null; + inherited: boolean; + inheritedFromId: string | null; + inheritedFromPath: string | null; + hasPassword: boolean | null; + expirationDateTime: string | null; + shareId: string | null; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-update-item.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-update-item.ts new file mode 100644 index 000000000000..ad9a1f4f2e63 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-update-item.ts @@ -0,0 +1,65 @@ +import { createAction, Property } from '@activepieces/pieces-framework'; +import { HttpMethod } from '@activepieces/pieces-common'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveItemOutputSchema } from '../output-schemas'; + +export const onedriveUpdateItem = createAction({ + auth: oneDriveAuth, + name: 'onedrive_update_item', + displayName: 'Rename / Update File or Folder', + description: 'Rename a OneDrive file or folder, or set its description.', + audience: 'ai', + outputSchema: onedriveItemOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Renames a file or folder and/or sets its description, changing only the fields you provide; the item keeps its ID. Use Move File or Folder to change its folder. Description is supported on personal OneDrive only, and renaming fails if the folder already has an item with the new name. Retrying by item ID is safe; after a rename by path, use the returned item ID, because the old path no longer exists.', + idempotent: true, + }, + props: { + itemId: Property.ShortText({ + displayName: 'Item ID', + description: 'File or folder ID from Search or List Folder Contents. Or use Path.', + required: false, + }), + path: Property.ShortText({ + displayName: 'Path', + description: 'Path from the drive root. Use this or Item ID.', + placeholder: 'Documents/report.docx', + required: false, + }), + name: Property.ShortText({ + displayName: 'New Name', + description: 'New name, with the extension for files. Empty keeps the name.', + required: false, + }), + description: Property.LongText({ + displayName: 'Description', + description: 'New description (personal OneDrive only). Empty keeps it.', + required: false, + }), + }, + async run(context) { + const { itemId, path, name, description } = context.propsValue; + const itemPath = oneDriveApi.itemPath({ itemId, path }); + const body: Record = {}; + const trimmedName = name?.trim(); + if (trimmedName) { + body['name'] = trimmedName; + } + if (description !== undefined && description !== null && description !== '') { + body['description'] = description; + } + if (Object.keys(body).length === 0) { + throw new Error('Provide a New Name or a Description to update.'); + } + const item = await oneDriveApi.request({ + auth: context.auth, + method: HttpMethod.PATCH, + path: itemPath, + body, + }); + return oneDriveApi.toItem(item); + }, +}); diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-upload-file.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-upload-file.ts new file mode 100644 index 000000000000..20fece492d7e --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/onedrive-upload-file.ts @@ -0,0 +1,134 @@ +import { Readable } from 'node:stream'; +import { buffer as readableToBuffer } from 'node:stream/consumers'; +import { createAction, OAuth2PropertyValue, Property } from '@activepieces/pieces-framework'; +import { httpClient, HttpMethod, streamUtils } from '@activepieces/pieces-common'; +import mime from 'mime-types'; +import { oneDriveAuth } from '../auth'; +import { GraphDriveItem, oneDriveApi } from '../common/graph-api'; +import { onedriveItemOutputSchema } from '../output-schemas'; + +export const onedriveUploadFile = createAction({ + auth: oneDriveAuth, + name: 'onedrive_upload_file', + displayName: 'Upload File', + description: 'Upload a file to a OneDrive folder, replacing any file with the same name.', + audience: 'ai', + outputSchema: onedriveItemOutputSchema, + classification: 'WRITE', + aiMetadata: { + description: + 'Uploads a file (URL or base64) into a OneDrive folder under the given name; files over 4 MiB are sent in chunks automatically. Use Create Text File to write plain text without a file object, and Replace File Content to overwrite a known item by ID. The destination folder must already exist (create it with Create Folder); uploading the same name to the same folder overwrites the existing file, so retries are safe.', + idempotent: true, + }, + props: { + folderId: Property.ShortText({ + displayName: 'Folder ID', + description: 'Destination folder ID. Leave empty for the drive root.', + required: false, + }), + fileName: Property.ShortText({ + displayName: 'File Name', + description: 'File name including its extension.', + placeholder: 'report.pdf', + required: true, + }), + file: Property.File({ + displayName: 'File', + description: 'The file URL or base64 content to upload.', + required: true, + streaming: true, + }), + }, + async run(context) { + const { folderId, fileName, file } = context.propsValue; + const name = fileName.trim(); + if (!name) { + throw new Error('File Name cannot be empty.'); + } + const mimeType = mime.lookup(file.extension ?? '') || 'application/octet-stream'; + let { body, size } = streamUtils.toStreamingBody(file); + if (size == null) { + const buffered = await readableToBuffer(body); + size = buffered.length; + body = Readable.from(buffered); + } + const targetPath = `${oneDriveApi.folderPath({ folderId })}:/${encodeURIComponent(name)}:`; + const item = await uploadContent({ + auth: context.auth, + targetPath, + name, + body, + size, + mimeType, + }); + return oneDriveApi.toItem(item); + }, +}); + +async function uploadContent({ + auth, + targetPath, + name, + body, + size, + mimeType, +}: { + auth: OAuth2PropertyValue; + targetPath: string; + name: string; + body: Readable; + size: number; + mimeType: string; +}): Promise { + if (size <= SIMPLE_UPLOAD_LIMIT) { + return oneDriveApi.request({ + auth, + method: HttpMethod.PUT, + path: `${targetPath}/content`, + body, + headers: { + 'Content-Type': mimeType, + 'Content-Length': size.toString(), + }, + }); + } + const session = await oneDriveApi.request<{ uploadUrl: string }>({ + auth, + method: HttpMethod.POST, + path: `${targetPath}/createUploadSession`, + body: { + item: { + '@microsoft.graph.conflictBehavior': 'replace', + name, + }, + }, + headers: { 'Content-Type': 'application/json' }, + }); + let start = 0; + let result: GraphDriveItem | undefined; + for await (const chunk of streamUtils.readChunks({ readable: body, chunkSize: CHUNK_SIZE })) { + const end = start + chunk.length - 1; + try { + const response = await httpClient.sendRequest({ + method: HttpMethod.PUT, + url: session.uploadUrl, + body: chunk, + headers: { + 'Content-Length': chunk.length.toString(), + 'Content-Range': `bytes ${start}-${end}/${size}`, + }, + }); + result = response.body; + } catch (error) { + throw new Error(oneDriveApi.describeError(error)); + } + start += chunk.length; + } + if (!result?.id) { + throw new Error('The upload session finished without returning the uploaded file.'); + } + return result; +} + +const SIMPLE_UPLOAD_LIMIT = 4 * 1024 * 1024; +const CHUNK_SIZE = 10485760; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/actions/upload-file.ts b/packages/pieces/community/microsoft-onedrive/src/lib/actions/upload-file.ts index 7b5b4e3d125c..be0acf5f7fb0 100644 --- a/packages/pieces/community/microsoft-onedrive/src/lib/actions/upload-file.ts +++ b/packages/pieces/community/microsoft-onedrive/src/lib/actions/upload-file.ts @@ -10,17 +10,18 @@ import { import { oneDriveAuth } from '../auth'; import mime from 'mime-types'; import { oneDriveCommon } from '../common/common'; - -const CHUNK_SIZE = 10485760; // Use 10MiB per chunk (a multiple of 320KiB, as OneDrive requires) +import { getCloudProp } from '../common/microsoft-cloud'; +import { uploadFileOutputSchema } from '../output-schemas'; export const uploadFile = createAction({ auth: oneDriveAuth, name: 'upload_onedrive_file', classification: 'WRITE', description: 'Upload a file to your Microsoft OneDrive with chunked upload if the file is larger than 4MiB', - audience: 'both', + audience: 'human', aiMetadata: { description: 'Upload a file to a Microsoft OneDrive folder, given a target file name, the file content, and an optional parent folder (defaults to the drive root). Large files (over 4MiB) are uploaded in chunks automatically. Idempotent: uploading the same file name to the same folder overwrites the existing item rather than creating a duplicate.', idempotent: true }, displayName: 'Upload file', + outputSchema: uploadFileOutputSchema, props: { fileName: Property.ShortText({ displayName: 'File name', @@ -43,15 +44,12 @@ export const uploadFile = createAction({ ); const mimeType = mimeTypeLookup ? mimeTypeLookup - : 'application/octet-stream'; // Fallback to a default MIME type + : 'application/octet-stream'; const encodedFilename = encodeURIComponent(context.propsValue.fileName); const parentId = context.propsValue.parentId ?? 'root'; - const cloud = context.auth.props?.['cloud'] as string | undefined; + const cloud = getCloudProp(context.auth); const baseUrl = oneDriveCommon.getBaseUrl(cloud); - // Chunked upload needs the total size upfront for the Content-Range header. - // When the source doesn't report a size, buffer once and use its length — - // same behaviour as before streaming — then re-wrap so both paths stream. let { body, size: fileSize } = streamUtils.toStreamingBody(fileData); if (fileSize == null) { const buffered = await readableToBuffer(body); @@ -60,7 +58,6 @@ export const uploadFile = createAction({ } if (fileSize <= 4 * 1024 * 1024) { - // If file is smaller than 4MiB, use simple upload const result = await httpClient.sendRequest({ method: HttpMethod.PUT, url: `${baseUrl}/items/${parentId}:/${encodedFilename}:/content`, @@ -77,7 +74,6 @@ export const uploadFile = createAction({ return result.body; } else { - // For files larger than 4MiB, use chunked upload const session = await httpClient.sendRequest({ method: HttpMethod.POST, url: `${baseUrl}/items/${parentId}:/${encodedFilename}:/createUploadSession`, @@ -119,3 +115,5 @@ export const uploadFile = createAction({ } }, }); + +const CHUNK_SIZE = 10485760; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/common/graph-api.ts b/packages/pieces/community/microsoft-onedrive/src/lib/common/graph-api.ts new file mode 100644 index 000000000000..17a7fca2a03b --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/common/graph-api.ts @@ -0,0 +1,254 @@ +import { + AuthenticationType, + HttpError, + HttpMessageBody, + HttpMethod, + QueryParams, + httpClient, +} from '@activepieces/pieces-common'; +import { OAuth2PropertyValue } from '@activepieces/pieces-framework'; +import { getGraphBaseUrl } from './microsoft-cloud'; + +export const oneDriveApi = { + request, + itemPath, + folderPath, + getRootId, + resolveFolderId, + getDriveId, + toItem, + describeError, + statusOf, +}; + +export class OneDriveApiError extends Error { + readonly status: number | null; + + constructor({ message, status }: { message: string; status: number | null }) { + super(message); + this.name = 'OneDriveApiError'; + this.status = status; + } +} + +async function request({ + auth, + method, + path, + body, + queryParams, + headers, +}: GraphRequest): Promise { + const url = resolveUrl({ auth, path }); + try { + const response = await httpClient.sendRequest({ + method, + url, + body, + queryParams, + headers, + authentication: { + type: AuthenticationType.BEARER_TOKEN, + token: auth.access_token, + }, + }); + return response.body; + } catch (error) { + throw new OneDriveApiError({ + message: describeError(error), + status: error instanceof HttpError ? error.response.status : null, + }); + } +} + +function resolveUrl({ auth, path }: { auth: OAuth2PropertyValue; path: string }): string { + const base = graphBaseUrl(auth); + if (!/^[a-z][a-z0-9+.-]*:/i.test(path)) { + return `${base}${path}`; + } + if (!path.startsWith(`${base}/`)) { + throw new OneDriveApiError({ + message: 'The page token or link is not a Microsoft Graph URL for this connection. Pass the token returned by the previous call unchanged.', + status: null, + }); + } + return path; +} + +function statusOf(error: unknown): number | null { + return error instanceof OneDriveApiError ? error.status : null; +} + +function graphBaseUrl(auth: OAuth2PropertyValue): string { + const cloud = auth.props?.['cloud']; + return `${getGraphBaseUrl(typeof cloud === 'string' ? cloud : undefined)}/v1.0`; +} + +function itemPath({ itemId, path }: { itemId?: string; path?: string }): string { + const trimmedId = itemId?.trim(); + if (trimmedId) { + return trimmedId === 'root' ? '/me/drive/root' : `/me/drive/items/${encodeURIComponent(trimmedId)}`; + } + const trimmedPath = path?.trim().replace(/^\/+|\/+$/g, ''); + if (trimmedPath) { + return `/me/drive/root:/${trimmedPath.split('/').map(encodeURIComponent).join('/')}:`; + } + throw new Error('Provide either an item ID or a path.'); +} + +function folderPath({ folderId }: { folderId?: string }): string { + return itemPath({ itemId: folderId?.trim() ? folderId : 'root' }); +} + +async function getRootId({ auth }: { auth: OAuth2PropertyValue }): Promise { + const root = await request<{ id: string }>({ + auth, + method: HttpMethod.GET, + path: '/me/drive/root', + queryParams: { $select: 'id' }, + }); + return root.id; +} + +async function resolveFolderId({ auth, folderId }: { auth: OAuth2PropertyValue; folderId?: string }): Promise { + const trimmed = folderId?.trim(); + if (!trimmed || trimmed === 'root') { + return getRootId({ auth }); + } + return trimmed; +} + +async function getDriveId({ auth }: { auth: OAuth2PropertyValue }): Promise { + const drive = await request<{ id: string }>({ + auth, + method: HttpMethod.GET, + path: '/me/drive', + queryParams: { $select: 'id' }, + }); + return drive.id; +} + +function toItem(item: GraphDriveItem): OneDriveItem { + return { + id: item.id, + name: item.name, + type: item.folder ? 'folder' : item.package ? 'package' : item.remoteItem ? 'remote' : 'file', + mimeType: item.file?.mimeType ?? null, + size: item.size ?? null, + webUrl: item.webUrl ?? null, + createdDateTime: item.createdDateTime ?? null, + lastModifiedDateTime: item.lastModifiedDateTime ?? null, + childCount: item.folder?.childCount ?? null, + parentId: item.parentReference?.id ?? null, + parentPath: item.parentReference?.path ?? null, + driveId: item.parentReference?.driveId ?? null, + driveType: item.parentReference?.driveType ?? null, + createdByName: item.createdBy?.user?.displayName ?? null, + createdByEmail: item.createdBy?.user?.email ?? null, + lastModifiedByName: item.lastModifiedBy?.user?.displayName ?? null, + lastModifiedByEmail: item.lastModifiedBy?.user?.email ?? null, + description: item.description ?? null, + deleted: item.deleted !== undefined, + remoteItemId: item.remoteItem?.id ?? null, + remoteDriveId: item.remoteItem?.parentReference?.driveId ?? null, + }; +} + +function describeError(error: unknown): string { + if (!(error instanceof HttpError)) { + return error instanceof Error ? error.message : String(error); + } + const status = error.response.status; + const graphError = readGraphError(error.response.body); + const detail = graphError ? ` (${graphError})` : ''; + switch (status) { + case 400: + return `OneDrive rejected the request${detail}.`; + case 401: + return `The OneDrive connection is no longer valid. Reconnect the account${detail}.`; + case 403: + return `Access denied. The connection lacks permission for this item or operation, or the account type does not support it${detail}.`; + case 404: + return `The item was not found. Check the ID or path${detail}.`; + case 409: + return `An item with that name already exists, or the item is in a conflicting state${detail}.`; + case 410: + return `The token or resource has expired and must be requested again${detail}.`; + case 423: + return `The item is locked, for example checked out by another user${detail}.`; + case 429: + return `OneDrive rate limit reached. Retry later${detail}.`; + default: + return `OneDrive request failed with status ${status}${detail}.`; + } +} + +function readGraphError(body: unknown): string | null { + if (typeof body !== 'object' || body === null || !('error' in body)) { + return null; + } + const inner = body.error; + if (typeof inner !== 'object' || inner === null) { + return null; + } + const code = 'code' in inner && typeof inner.code === 'string' ? inner.code : null; + const message = 'message' in inner && typeof inner.message === 'string' ? inner.message : null; + return [code, message].filter(Boolean).join(': ') || null; +} + +export type GraphRequest = { + auth: OAuth2PropertyValue; + method: HttpMethod; + path: string; + body?: unknown; + queryParams?: QueryParams; + headers?: Record; +}; + +export type GraphIdentity = { + user?: { displayName?: string; email?: string; id?: string }; + application?: { displayName?: string; id?: string }; +}; + +export type GraphDriveItem = { + id: string; + name: string; + size?: number; + webUrl?: string; + description?: string; + createdDateTime?: string; + lastModifiedDateTime?: string; + createdBy?: GraphIdentity; + lastModifiedBy?: GraphIdentity; + parentReference?: { id?: string; path?: string; driveId?: string; driveType?: string; name?: string }; + file?: { mimeType?: string }; + folder?: { childCount?: number }; + package?: { type?: string }; + deleted?: { state?: string }; + remoteItem?: { id?: string; parentReference?: { driveId?: string } }; + '@microsoft.graph.downloadUrl'?: string; +}; + +export type OneDriveItem = { + id: string; + name: string; + type: 'file' | 'folder' | 'package' | 'remote'; + mimeType: string | null; + size: number | null; + webUrl: string | null; + createdDateTime: string | null; + lastModifiedDateTime: string | null; + childCount: number | null; + parentId: string | null; + parentPath: string | null; + driveId: string | null; + driveType: string | null; + createdByName: string | null; + createdByEmail: string | null; + lastModifiedByName: string | null; + lastModifiedByEmail: string | null; + description: string | null; + deleted: boolean; + remoteItemId: string | null; + remoteDriveId: string | null; +}; diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/common/microsoft-cloud.ts b/packages/pieces/community/microsoft-onedrive/src/lib/common/microsoft-cloud.ts index 065a70560607..8d4800e93aec 100644 --- a/packages/pieces/community/microsoft-onedrive/src/lib/common/microsoft-cloud.ts +++ b/packages/pieces/community/microsoft-onedrive/src/lib/common/microsoft-cloud.ts @@ -27,7 +27,12 @@ function resolveCloudHost(cloudLoginHost: string | undefined | null): string { } export function getMicrosoftCloudFromAuth(auth: OAuth2PropertyValue): string { - return resolveCloudHost(auth.props?.['cloud'] as string | undefined); + return resolveCloudHost(getCloudProp(auth)); +} + +export function getCloudProp(auth: { props?: Record } | undefined): string | undefined { + const cloud = auth?.props?.['cloud']; + return typeof cloud === 'string' ? cloud : undefined; } export function getGraphBaseUrl(cloudLoginHost: string | undefined | null): string { diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/output-schemas.ts b/packages/pieces/community/microsoft-onedrive/src/lib/output-schemas.ts new file mode 100644 index 000000000000..0289f2d48255 --- /dev/null +++ b/packages/pieces/community/microsoft-onedrive/src/lib/output-schemas.ts @@ -0,0 +1,365 @@ +import { OutputSchema } from '@activepieces/pieces-framework'; + +export const uploadFileOutputSchema: OutputSchema = { + fields: driveFileFields(), +}; + +export const getFileOutputSchema: OutputSchema = { + fields: [ + { key: 'id', label: 'File ID' }, + { key: 'name', label: 'Name' }, + { key: 'data', label: 'File', format: 'url' }, + { key: 'mimeType', label: 'MIME Type' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'webUrl', label: 'Web URL', format: 'url' }, + { key: 'createdDateTime', label: 'Created At', format: 'datetime' }, + { key: 'lastModifiedDateTime', label: 'Last Modified At', format: 'datetime' }, + { key: 'folderPath', label: 'Folder Path' }, + { key: 'driveId', label: 'Drive ID' }, + ], +}; + +export const copyFileOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'Status' }, + { key: 'file', label: 'Copied File', children: driveFileFields() }, + ], +}; + +export const listFilesOutputSchema: OutputSchema = { + itemLabel: '{name}', + fields: [{ key: 'files', label: 'Files', value: '', listItems: driveFileFields() }], +}; + +export const listFoldersOutputSchema: OutputSchema = { + itemLabel: '{name}', + fields: [{ key: 'folders', label: 'Folders', value: '', listItems: driveFolderFields() }], +}; + +export const newFileTriggerOutputSchema: OutputSchema = { + fields: driveFileFields(), +}; + +export const onedriveItemOutputSchema: OutputSchema = { + fields: itemFields(), +}; + +export const onedriveCreateFolderOutputSchema: OutputSchema = { + fields: [{ key: 'created', label: 'Created', format: 'boolean' }, ...itemFields()], +}; + +export const onedriveDownloadFileOutputSchema: OutputSchema = { + fields: [{ key: 'data', label: 'File', format: 'url' }, ...itemFields()], +}; + +export const onedriveDownloadFileAsFormatOutputSchema: OutputSchema = { + fields: [ + { key: 'data', label: 'Converted File', format: 'url' }, + { key: 'convertedFormat', label: 'Converted Format' }, + { key: 'convertedFileName', label: 'Converted File Name' }, + ...itemFields(), + ], +}; + +export const onedriveDownloadItemVersionOutputSchema: OutputSchema = { + fields: [ + { key: 'data', label: 'File', format: 'url' }, + { key: 'itemId', label: 'Item ID' }, + { key: 'name', label: 'Name' }, + { key: 'versionId', label: 'Version ID' }, + { key: 'mimeType', label: 'MIME Type' }, + ], +}; + +export const onedriveResolveSharingLinkOutputSchema: OutputSchema = { + fields: [{ key: 'shareId', label: 'Share ID' }, ...itemFields()], +}; + +export const onedriveBundleOutputSchema: OutputSchema = { + fields: bundleFields(), +}; + +export const onedriveListItemsOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Items', labelKey: 'name', listItems: itemFields() }, + ...pageFields(), + ], +}; + +export const onedriveListDriveChangesOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Changed Items', labelKey: 'name', listItems: itemFields() }, + ...pageFields(), + { key: 'deltaToken', label: 'Delta Token' }, + ], +}; + +export const onedriveListBundlesOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Bundles', labelKey: 'name', listItems: bundleFields() }, + ...pageFields(), + ], +}; + +export const onedriveGetDriveOutputSchema: OutputSchema = { + fields: [ + ...driveFields(), + { key: 'createdDateTime', label: 'Created At', format: 'datetime' }, + { key: 'lastModifiedDateTime', label: 'Last Modified At', format: 'datetime' }, + { key: 'quotaDeleted', label: 'Recycle Bin Size', format: 'filesize' }, + ], +}; + +export const onedriveListDrivesOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Drives', labelKey: 'name', listItems: driveFields() }, + ...pageFields(), + ], +}; + +export const onedriveListItemThumbnailsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Thumbnail Sets', + labelKey: 'thumbnailSetId', + listItems: [ + { key: 'thumbnailSetId', label: 'Thumbnail Set ID' }, + { key: 'smallUrl', label: 'Small', format: 'image' }, + { key: 'smallWidth', label: 'Small Width', format: 'number' }, + { key: 'smallHeight', label: 'Small Height', format: 'number' }, + { key: 'mediumUrl', label: 'Medium', format: 'image' }, + { key: 'mediumWidth', label: 'Medium Width', format: 'number' }, + { key: 'mediumHeight', label: 'Medium Height', format: 'number' }, + { key: 'largeUrl', label: 'Large', format: 'image' }, + { key: 'largeWidth', label: 'Large Width', format: 'number' }, + { key: 'largeHeight', label: 'Large Height', format: 'number' }, + ], + }, + { key: 'count', label: 'Count', format: 'number' }, + ], +}; + +export const onedriveListItemVersionsOutputSchema: OutputSchema = { + fields: [ + { + key: 'items', + label: 'Versions', + labelKey: 'versionId', + listItems: [ + { key: 'versionId', label: 'Version ID' }, + { key: 'lastModifiedDateTime', label: 'Last Modified At', format: 'datetime' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'lastModifiedByName', label: 'Last Modified By' }, + { key: 'lastModifiedByEmail', label: 'Last Modified By Email', format: 'email' }, + ], + }, + ...pageFields(), + ], +}; + +export const onedrivePermissionOutputSchema: OutputSchema = { + fields: permissionFields(), +}; + +export const onedriveListItemPermissionsOutputSchema: OutputSchema = { + fields: [ + { key: 'items', label: 'Permissions', labelKey: 'id', listItems: permissionFields() }, + ...pageFields(), + ], +}; + +export const onedriveInviteToItemOutputSchema: OutputSchema = { + fields: [ + { key: 'permissions', label: 'Permissions', labelKey: 'grantedToEmail', listItems: permissionFields() }, + { key: 'count', label: 'Count', format: 'number' }, + { key: 'invitationSent', label: 'Invitation Sent', format: 'boolean' }, + ], +}; + +export const onedriveCopyItemOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'Status' }, + { key: 'monitorUrl', label: 'Monitor URL', format: 'url' }, + ], +}; + +export const onedriveGetCopyStatusOutputSchema: OutputSchema = { + fields: [ + { key: 'status', label: 'Status' }, + { key: 'percentageComplete', label: 'Percentage Complete', format: 'number' }, + { key: 'resourceId', label: 'Copied Item ID' }, + { key: 'errorCode', label: 'Error Code' }, + { key: 'errorMessage', label: 'Error Message' }, + ], +}; + +export const onedriveItemActionOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'itemId', label: 'Item ID' }, + { key: 'path', label: 'Path' }, + ], +}; + +export const onedriveDeletePermissionOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'itemId', label: 'Item ID' }, + { key: 'permissionId', label: 'Permission ID' }, + ], +}; + +export const onedriveRestoreItemVersionOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'itemId', label: 'Item ID' }, + { key: 'versionId', label: 'Restored Version ID' }, + ], +}; + +export const onedriveBundleMembershipOutputSchema: OutputSchema = { + fields: [ + { key: 'success', label: 'Success', format: 'boolean' }, + { key: 'bundleId', label: 'Bundle ID' }, + { key: 'itemId', label: 'Item ID' }, + ], +}; + +function identityFields(): OutputSchema['fields'] { + return [ + { + key: 'user', + label: 'User', + children: [ + { key: 'displayName', label: 'Name' }, + { key: 'email', label: 'Email', format: 'email' }, + { key: 'id', label: 'User ID' }, + ], + }, + ]; +} + +function parentReferenceFields(): OutputSchema['fields'] { + return [ + { key: 'id', label: 'Folder ID' }, + { key: 'name', label: 'Folder Name' }, + { key: 'path', label: 'Folder Path' }, + { key: 'driveId', label: 'Drive ID' }, + { key: 'driveType', label: 'Drive Type' }, + ]; +} + +function driveItemBaseFields(): OutputSchema['fields'] { + return [ + { key: 'id', label: 'Item ID' }, + { key: 'name', label: 'Name' }, + { key: 'webUrl', label: 'Web URL', format: 'url' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'createdDateTime', label: 'Created At', format: 'datetime' }, + { key: 'lastModifiedDateTime', label: 'Last Modified At', format: 'datetime' }, + { key: 'createdBy', label: 'Created By', children: identityFields() }, + { key: 'lastModifiedBy', label: 'Last Modified By', children: identityFields() }, + { key: 'parentReference', label: 'Parent Folder', children: parentReferenceFields() }, + ]; +} + +function driveFileFields(): OutputSchema['fields'] { + return [ + ...driveItemBaseFields(), + { + key: 'file', + label: 'File', + children: [{ key: 'mimeType', label: 'MIME Type' }], + }, + ]; +} + +function driveFolderFields(): OutputSchema['fields'] { + return [ + ...driveItemBaseFields(), + { + key: 'folder', + label: 'Folder', + children: [{ key: 'childCount', label: 'Child Count', format: 'number' }], + }, + ]; +} + +function itemFields(): OutputSchema['fields'] { + return [ + { key: 'id', label: 'Item ID' }, + { key: 'name', label: 'Name' }, + { key: 'type', label: 'Type' }, + { key: 'webUrl', label: 'Web URL', format: 'url' }, + { key: 'mimeType', label: 'MIME Type' }, + { key: 'size', label: 'Size', format: 'filesize' }, + { key: 'childCount', label: 'Child Count', format: 'number' }, + { key: 'createdDateTime', label: 'Created At', format: 'datetime' }, + { key: 'lastModifiedDateTime', label: 'Last Modified At', format: 'datetime' }, + { key: 'parentId', label: 'Parent Folder ID' }, + { key: 'parentPath', label: 'Parent Folder Path' }, + { key: 'driveId', label: 'Drive ID' }, + { key: 'driveType', label: 'Drive Type' }, + { key: 'createdByName', label: 'Created By' }, + { key: 'createdByEmail', label: 'Created By Email', format: 'email' }, + { key: 'lastModifiedByName', label: 'Last Modified By' }, + { key: 'lastModifiedByEmail', label: 'Last Modified By Email', format: 'email' }, + { key: 'description', label: 'Description' }, + { key: 'deleted', label: 'Deleted', format: 'boolean' }, + { key: 'remoteItemId', label: 'Remote Item ID' }, + { key: 'remoteDriveId', label: 'Remote Drive ID' }, + ]; +} + +function bundleFields(): OutputSchema['fields'] { + return [ + ...itemFields(), + { key: 'bundleChildCount', label: 'Items in Bundle', format: 'number' }, + { key: 'isAlbum', label: 'Is Album', format: 'boolean' }, + ]; +} + +function pageFields(): OutputSchema['fields'] { + return [ + { key: 'count', label: 'Count', format: 'number' }, + { key: 'nextPageToken', label: 'Next Page Token' }, + ]; +} + +function driveFields(): OutputSchema['fields'] { + return [ + { key: 'id', label: 'Drive ID' }, + { key: 'name', label: 'Name' }, + { key: 'driveType', label: 'Drive Type' }, + { key: 'webUrl', label: 'Web URL', format: 'url' }, + { key: 'ownerName', label: 'Owner' }, + { key: 'ownerEmail', label: 'Owner Email', format: 'email' }, + { key: 'quotaTotal', label: 'Total Storage', format: 'filesize' }, + { key: 'quotaUsed', label: 'Used Storage', format: 'filesize' }, + { key: 'quotaRemaining', label: 'Remaining Storage', format: 'filesize' }, + { key: 'quotaState', label: 'Storage State' }, + ]; +} + +function permissionFields(): OutputSchema['fields'] { + return [ + { key: 'id', label: 'Permission ID' }, + { key: 'roles', label: 'Roles' }, + { key: 'linkType', label: 'Link Type' }, + { key: 'linkScope', label: 'Link Scope' }, + { key: 'linkWebUrl', label: 'Link URL', format: 'url' }, + { key: 'linkPreventsDownload', label: 'Link Prevents Download', format: 'boolean' }, + { key: 'grantedToName', label: 'Granted To' }, + { key: 'grantedToEmail', label: 'Granted To Email', format: 'email' }, + { key: 'grantedToId', label: 'Granted To ID' }, + { key: 'grantedToIdentities', label: 'Granted To (Link Users)' }, + { key: 'invitationEmail', label: 'Invitation Email', format: 'email' }, + { key: 'inherited', label: 'Inherited', format: 'boolean' }, + { key: 'inheritedFromId', label: 'Inherited From Folder ID' }, + { key: 'inheritedFromPath', label: 'Inherited From Path' }, + { key: 'hasPassword', label: 'Has Password', format: 'boolean' }, + { key: 'expirationDateTime', label: 'Expires At', format: 'datetime' }, + { key: 'shareId', label: 'Share ID' }, + ]; +} diff --git a/packages/pieces/community/microsoft-onedrive/src/lib/triggers/new-file.ts b/packages/pieces/community/microsoft-onedrive/src/lib/triggers/new-file.ts index f8f2e258f054..eda46396c106 100644 --- a/packages/pieces/community/microsoft-onedrive/src/lib/triggers/new-file.ts +++ b/packages/pieces/community/microsoft-onedrive/src/lib/triggers/new-file.ts @@ -1,66 +1,20 @@ -import { AppConnectionValueForAuthProperty, PiecePropValueSchema, Property, createTrigger } from '@activepieces/pieces-framework'; +import { AppConnectionValueForAuthProperty, createTrigger } from '@activepieces/pieces-framework'; import { TriggerStrategy } from '@activepieces/pieces-framework'; import { DedupeStrategy, Polling, pollingHelper } from '@activepieces/pieces-common'; -import { getGraphBaseUrl } from '../common/microsoft-cloud'; +import { getCloudProp, getGraphBaseUrl } from '../common/microsoft-cloud'; import dayjs from 'dayjs'; import { oneDriveAuth } from '../auth'; import { oneDriveCommon } from '../common/common'; +import { newFileTriggerOutputSchema } from '../output-schemas'; import { Client, PageCollection } from '@microsoft/microsoft-graph-client'; import { DriveItem } from '@microsoft/microsoft-graph-types'; -type Props = { - parentFolder?: string; -}; - -const polling: Polling, Props> = { - strategy: DedupeStrategy.TIMEBASED, - items: async ({ auth, propsValue, lastFetchEpochMS }) => { - const cloud = auth.props?.['cloud'] as string | undefined; - const client = Client.initWithMiddleware({ - authProvider: { - getAccessToken: () => Promise.resolve(auth.access_token), - }, - baseUrl: getGraphBaseUrl(cloud), - }); - - const files = []; - - const endpoint = propsValue.parentFolder - ? `/me/drive/items/${propsValue.parentFolder}/children` - : `/me/drive/items/root/children`; - let response: PageCollection = await client.api(endpoint).get(); - while (response.value.length > 0) { - for (const item of response.value as DriveItem[]) { - if (item.file) { - files.push(item); - } - } - - if (response['@odata.nextLink']) { - response = await client.api(response['@odata.nextLink']).get(); - } else { - break; - } - } - - files.sort((a, b) => { - const aDate = dayjs(a.createdDateTime); - const bDate = dayjs(b.createdDateTime); - return bDate.diff(aDate); - }); - - return files.map((file) => ({ - epochMilliSeconds: dayjs(file.createdDateTime).valueOf(), - data: file, - })); - }, -}; - export const newFile = createTrigger({ auth: oneDriveAuth, name: 'new_file', classification: 'READ', displayName: 'New File', + outputSchema: newFileTriggerOutputSchema, description: 'Trigger when a new file is uploaded.', aiMetadata: { description: 'Fires when a new file appears in the watched Microsoft OneDrive folder, polling by file creation time. Scope it to a specific folder via the parent folder ID, or leave it empty to watch the drive root; subfolders are not included and folders themselves do not trigger it.', @@ -71,18 +25,10 @@ export const newFile = createTrigger({ }, type: TriggerStrategy.POLLING, async onEnable(context) { - await pollingHelper.onEnable(polling, { - auth: context.auth, - store: context.store, - propsValue: context.propsValue, - }); + await pollingHelper.onEnable(polling, context); }, async onDisable(context) { - await pollingHelper.onDisable(polling, { - auth: context.auth, - store: context.store, - propsValue: context.propsValue, - }); + await pollingHelper.onDisable(polling, context); }, async test(context) { return await pollingHelper.test(polling, context); @@ -137,3 +83,52 @@ export const newFile = createTrigger({ }, }, }); + +const polling: Polling, Props> = { + strategy: DedupeStrategy.TIMEBASED, + items: async ({ auth, propsValue }) => { + const cloud = getCloudProp(auth); + const client = Client.initWithMiddleware({ + authProvider: { + getAccessToken: () => Promise.resolve(auth.access_token), + }, + baseUrl: getGraphBaseUrl(cloud), + }); + + const files = []; + + const endpoint = propsValue.parentFolder + ? `/me/drive/items/${propsValue.parentFolder}/children` + : `/me/drive/items/root/children`; + let response: PageCollection = await client.api(endpoint).get(); + while (response.value.length > 0) { + const items: DriveItem[] = response.value; + for (const item of items) { + if (item.file) { + files.push(item); + } + } + + if (response['@odata.nextLink']) { + response = await client.api(response['@odata.nextLink']).get(); + } else { + break; + } + } + + files.sort((a, b) => { + const aDate = dayjs(a.createdDateTime); + const bDate = dayjs(b.createdDateTime); + return bDate.diff(aDate); + }); + + return files.map((file) => ({ + epochMilliSeconds: dayjs(file.createdDateTime).valueOf(), + data: file, + })); + }, +}; + +type Props = { + parentFolder?: string; +}; From eab234fd8027bfc4213c7849b9c8e29f1b569df8 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 24 Sep 2026 20:18:23 +0300 Subject: [PATCH 4/9] fix(smoke-test): wait for the flow's pieces to finish syncing before publishing (#15784) --- benchmark/setup.sh | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/benchmark/setup.sh b/benchmark/setup.sh index 4f97890782d4..39ec4ca9cee2 100755 --- a/benchmark/setup.sh +++ b/benchmark/setup.sh @@ -19,21 +19,6 @@ for i in $(seq 1 $MAX_RETRIES); do sleep $RETRY_INTERVAL done -# Wait for webhook piece to be synced (pieces sync from cloud in batches) -echo "Waiting for webhook piece to be available..." >&2 -for i in $(seq 1 600); do - HAS_WEBHOOK=$(curl -sf "$BASE_URL/pieces" 2>/dev/null | jq '[.[].name] | any(. == "@activepieces/piece-webhook")' 2>/dev/null || echo "false") - if [ "$HAS_WEBHOOK" = "true" ]; then - echo "Webhook piece is available (took ${i}s)" >&2 - break - fi - if [ "$i" -eq 600 ]; then - echo "ERROR: Webhook piece not available after 600s" >&2 - exit 1 - fi - sleep 1 -done - # Sign up echo "Authenticating..." >&2 BENCH_EMAIL="${BENCH_EMAIL:-bench@activepieces.com}" @@ -81,6 +66,25 @@ echo "Signed up. Project: $PROJECT_ID" >&2 AUTH="Authorization: Bearer $TOKEN" +WEBHOOK_VERSION="${WEBHOOK_VERSION:-~0.1.36}" +MATH_VERSION="${MATH_VERSION:-~0.0.24}" + +wait_for_piece() { + local name="$1" version="$2" + echo "Waiting for $name@$version to be available..." >&2 + for i in $(seq 1 600); do + if curl -sf -G "$BASE_URL/pieces/$name" --data-urlencode "version=$version" -H "$AUTH" > /dev/null 2>&1; then + echo "$name@$version is available (took ${i}s)" >&2 + return 0 + fi + sleep 1 + done + echo "ERROR: $name@$version not available after 600s" >&2 + exit 1 +} +wait_for_piece "@activepieces/piece-webhook" "$WEBHOOK_VERSION" +wait_for_piece "@activepieces/piece-math-helper" "$MATH_VERSION" + # Create flow echo "Creating flow..." >&2 FLOW_RESPONSE=$(curl -s --fail-with-body "$BASE_URL/flows" \ @@ -117,8 +121,6 @@ else fi # Flow: webhook (latest) -> math-helper addition (latest) -> small CODE step -> return response (latest). -WEBHOOK_VERSION="${WEBHOOK_VERSION:-~0.1.36}" -MATH_VERSION="${MATH_VERSION:-~0.0.24}" IMPORT_PAYLOAD=$(jq -n \ --arg code "$CODE_STEP_SRC" \ --arg webhookV "$WEBHOOK_VERSION" \ From 3a751c06abbc5233eced4e37e65d7f1c17bbfd84 Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 24 Sep 2026 20:41:34 +0300 Subject: [PATCH 5/9] fix(smoke-test): trigger the memory-limit check from a code step, not an expression (#15785) --- .github/workflows/smoke-test.yml | 4 ++-- benchmark/oom-code.js | 8 ++++++++ benchmark/oom-expression.txt | 1 - smoke-test/verify-memory-limit.sh | 4 ++-- 4 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 benchmark/oom-code.js delete mode 100644 benchmark/oom-expression.txt diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 35dcb32b2ffb..943f868b681f 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -79,7 +79,7 @@ jobs: # can't exercise isolation here (it returns no data and the gate fails spuriously). Run it # locally with `benchmark/probe-fs.js` + `smoke-test/verify-isolation.sh` (both kept in repo). - # Memory-limit detection gate (same SANDBOX_CODE_ONLY stack): the OOM input expression blows + # Memory-limit detection gate (same SANDBOX_CODE_ONLY stack): the OOM code step blows # the ENGINE heap via the isolate->engine result copy, and AP_WORKER_CONCURRENCY=1 (job env) # gives that single sandbox the full 1G worker container. Every run must end # MEMORY_LIMIT_EXCEEDED — this pins the detection path for production code-only engine OOMs. @@ -87,7 +87,7 @@ jobs: if: matrix.executionMode == 'SANDBOX_CODE_ONLY' id: setup-oom run: | - FLOW_ID=$(CODE_INPUT_SUM="$(cat benchmark/oom-expression.txt)" benchmark/setup.sh) + FLOW_ID=$(CODE_BODY_FILE=benchmark/oom-code.js benchmark/setup.sh) echo "flow_id=$FLOW_ID" >> "$GITHUB_OUTPUT" echo "Flow ID: $FLOW_ID" diff --git a/benchmark/oom-code.js b/benchmark/oom-code.js new file mode 100644 index 000000000000..6071e0746c65 --- /dev/null +++ b/benchmark/oom-code.js @@ -0,0 +1,8 @@ +export const code = async () => { + const chunk = 'x'.repeat(52428800); + const hog = []; + for (let i = 0; i < 64; i = i + 1) { + hog.push(chunk); + } + return hog; +}; diff --git a/benchmark/oom-expression.txt b/benchmark/oom-expression.txt deleted file mode 100644 index 4dfa9e3bf385..000000000000 --- a/benchmark/oom-expression.txt +++ /dev/null @@ -1 +0,0 @@ -{{ (function () { const chunk = 'x'.repeat(52428800); const hog = []; for (let i = 0; i < 64; i = i + 1) { hog.push(chunk) } return hog })() }} diff --git a/smoke-test/verify-memory-limit.sh b/smoke-test/verify-memory-limit.sh index a6c1cf7c60ce..a6319ab597b4 100755 --- a/smoke-test/verify-memory-limit.sh +++ b/smoke-test/verify-memory-limit.sh @@ -3,8 +3,8 @@ set -euo pipefail # Verifies that a flow that exhausts the sandbox memory reliably ends with flow run status # MEMORY_LIMIT_EXCEEDED. Expects a flow created by benchmark/setup.sh with -# CODE_INPUT_SUM="$(cat benchmark/oom-expression.txt)" on a stack running -# AP_EXECUTION_MODE=SANDBOX_CODE_ONLY. The expression builds ~50MB inside the 128MB v8 +# CODE_BODY_FILE=benchmark/oom-code.js on a stack running +# AP_EXECUTION_MODE=SANDBOX_CODE_ONLY. The code step builds ~50MB inside the 128MB v8 # isolate (an array of refs to one big string), and the engine's outRef.copy() then # materializes every element separately in the ENGINE heap (~3GB) — the engine process dies # on the sandbox memory limit, which is how code-only engines OOM in production. From 1a64357d6959a7eb4ae40e5c62e28a7b0b220b58 Mon Sep 17 00:00:00 2001 From: Talal Jaber Date: Thu, 24 Sep 2026 20:53:57 +0300 Subject: [PATCH 6/9] feat(gmail): recipient tabs, rich text bodies, message picker and a filter builder for Find Email (#15753) --- bun.lock | 2 +- packages/pieces/community/gmail/package.json | 2 +- .../pieces/community/gmail/src/i18n/de.json | 12 +- .../pieces/community/gmail/src/i18n/es.json | 14 +- .../pieces/community/gmail/src/i18n/fr.json | 14 +- .../pieces/community/gmail/src/i18n/ja.json | 13 +- .../pieces/community/gmail/src/i18n/nl.json | 13 +- .../pieces/community/gmail/src/i18n/pt.json | 14 +- .../pieces/community/gmail/src/i18n/ru.json | 18 +- .../community/gmail/src/i18n/translation.json | 189 +++++++++++------- packages/pieces/community/gmail/src/index.ts | 2 +- .../lib/actions/add-label-to-email-action.ts | 11 +- .../lib/actions/ai-archive-email-action.ts | 19 +- .../src/lib/actions/ai-get-message-action.ts | 20 +- .../src/lib/actions/archive-email-action.ts | 75 ++++--- .../lib/actions/create-draft-reply-action.ts | 60 +++--- .../src/lib/actions/create-label-action.ts | 3 +- .../gmail/src/lib/actions/get-mail-action.ts | 70 ++++--- .../actions/remove-label-from-email-action.ts | 11 +- .../src/lib/actions/reply-to-email-action.ts | 47 +++-- .../lib/actions/request-approval-in-email.ts | 102 +++++++--- .../src/lib/actions/search-email-action.ts | 94 +++++++-- .../src/lib/actions/send-email-action.ts | 76 ++++--- .../community/gmail/src/lib/common/props.ts | 61 +++--- .../gmail/src/lib/triggers/new-attachment.ts | 40 +--- .../gmail/src/lib/triggers/new-email.ts | 6 +- .../gmail/src/lib/triggers/new-label.ts | 2 +- .../src/lib/triggers/new-labeled-email.ts | 3 +- .../gmail/test/request-approval-body.test.ts | 81 ++++++++ 29 files changed, 715 insertions(+), 359 deletions(-) create mode 100644 packages/pieces/community/gmail/test/request-approval-body.test.ts diff --git a/bun.lock b/bun.lock index bf4312062fa0..c4e9e98326d4 100644 --- a/bun.lock +++ b/bun.lock @@ -3716,7 +3716,7 @@ }, "packages/pieces/community/gmail": { "name": "@activepieces/piece-gmail", - "version": "0.16.0", + "version": "0.17.0", "dependencies": { "@activepieces/core-piece-types": "workspace:*", "@activepieces/core-utils": "workspace:*", diff --git a/packages/pieces/community/gmail/package.json b/packages/pieces/community/gmail/package.json index c536f1ad5dd9..501d9d25e999 100644 --- a/packages/pieces/community/gmail/package.json +++ b/packages/pieces/community/gmail/package.json @@ -1,6 +1,6 @@ { "name": "@activepieces/piece-gmail", - "version": "0.16.0", + "version": "0.17.0", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "dependencies": { diff --git a/packages/pieces/community/gmail/src/i18n/de.json b/packages/pieces/community/gmail/src/i18n/de.json index 24ba0a235df9..3a99d358d807 100644 --- a/packages/pieces/community/gmail/src/i18n/de.json +++ b/packages/pieces/community/gmail/src/i18n/de.json @@ -125,5 +125,15 @@ "Only trigger for emails containing this text in the subject.": "Trigger nur für E-Mails, die diesen Text im Betreff enthalten.", "Filter by Gmail label.": "Nach Google Mail-Label filtern.", "Filter by Gmail category.": "Nach Google Mail-Kategorie filtern.", - "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "Trigger nur für Anhänge mit dieser Dateiendung (z.B. pdf, jpg, docx)." + "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "Trigger nur für Anhänge mit dieser Dateiendung (z.B. pdf, jpg, docx).", + "Plain Text": "Einfacher Text", + "Reply To": "Antwort an", + "Save as Draft": "Als Entwurf speichern", + "Reply": "Antwort", + "Create Label": "Label erstellen", + "Label Name": "Labelname", + "Add Label to Email": "Label zur E-Mail hinzufügen", + "Archive Email": "E-Mail archivieren", + "Response is Binary": "Antwort ist binär", + "Return Error as Output": "Rückgabewert Fehler als Ausgabe" } \ No newline at end of file diff --git a/packages/pieces/community/gmail/src/i18n/es.json b/packages/pieces/community/gmail/src/i18n/es.json index 1c62e1c153cd..a8a96e7c89f1 100644 --- a/packages/pieces/community/gmail/src/i18n/es.json +++ b/packages/pieces/community/gmail/src/i18n/es.json @@ -125,5 +125,17 @@ "Only trigger for emails containing this text in the subject.": "Disparador sólo para correos electrónicos que contengan este texto en el tema.", "Filter by Gmail label.": "Filtrar por etiqueta Gmail.", "Filter by Gmail category.": "Filtrar por categoría de Gmail.", - "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "Sólo para archivos adjuntos con esta extensión de archivo (por ejemplo, pdf, jpg, docx)." + "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "Sólo para archivos adjuntos con esta extensión de archivo (por ejemplo, pdf, jpg, docx).", + "Bcc": "Cco", + "Plain Text": "Texto simple", + "Reply To": "Responder a", + "Save as Draft": "Guardar como borrador", + "Reply": "Responder", + "Create Label": "Crear etiqueta", + "Label Name": "Nombre de etiqueta", + "Add Label to Email": "Añadir etiqueta al email", + "Archive Email": "Archivar Email", + "Response is Binary": "Respuesta es binaria", + "Return Error as Output": "Devolver error como salida", + "Timeout": "Tiempo agotado" } \ No newline at end of file diff --git a/packages/pieces/community/gmail/src/i18n/fr.json b/packages/pieces/community/gmail/src/i18n/fr.json index 4955f7ffaa3a..6f1bbd44f487 100644 --- a/packages/pieces/community/gmail/src/i18n/fr.json +++ b/packages/pieces/community/gmail/src/i18n/fr.json @@ -125,5 +125,17 @@ "Only trigger for emails containing this text in the subject.": "Ne déclenche que pour les e-mails contenant ce texte dans le sujet.", "Filter by Gmail label.": "Filtrer par étiquette Gmail.", "Filter by Gmail category.": "Filtrer par catégorie Gmail.", - "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "Déclenche uniquement les pièces jointes avec cette extension de fichier (par exemple, pdf, jpg, docx)." + "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "Déclenche uniquement les pièces jointes avec cette extension de fichier (par exemple, pdf, jpg, docx).", + "Bcc": "Cci", + "Plain Text": "Texte brut", + "Reply To": "Répondre à", + "Save as Draft": "Enregistrer en tant que brouillon", + "Reply": "Répondre", + "Create Label": "Créer une étiquette", + "Label Name": "Nom de l'étiquette", + "Add Label to Email": "Ajouter une étiquette à l'e-mail", + "Archive Email": "Archiver l'e-mail", + "Response is Binary": "La réponse est binaire", + "Return Error as Output": "Erreur de retour en sortie", + "Timeout": "Délai d'expiration" } \ No newline at end of file diff --git a/packages/pieces/community/gmail/src/i18n/ja.json b/packages/pieces/community/gmail/src/i18n/ja.json index edd7f2a52485..7a2386301345 100644 --- a/packages/pieces/community/gmail/src/i18n/ja.json +++ b/packages/pieces/community/gmail/src/i18n/ja.json @@ -125,5 +125,16 @@ "Only trigger for emails containing this text in the subject.": "件名にこのテキストを含むメールのみトリガーします。", "Filter by Gmail label.": "Gmail のラベルで絞り込みます。", "Filter by Gmail category.": "Gmailカテゴリでフィルター", - "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "このファイル拡張子を持つ添付ファイルのみトリガーします (例: pdf, jpg, docx)。" + "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "このファイル拡張子を持つ添付ファイルのみトリガーします (例: pdf, jpg, docx)。", + "Plain Text": "プレーンテキスト", + "Reply To": "返信先", + "Save as Draft": "下書きとして保存", + "Reply": "返信", + "Create Label": "ラベルを作成", + "Label Name": "ラベル名", + "Add Label to Email": "電子メールにラベルを追加", + "Archive Email": "電子メールをアーカイブ", + "Response is Binary": "応答はバイナリです", + "Return Error as Output": "出力としてエラーを返します", + "Timeout": "タイムアウト" } \ No newline at end of file diff --git a/packages/pieces/community/gmail/src/i18n/nl.json b/packages/pieces/community/gmail/src/i18n/nl.json index 2f0364876a0b..e5edb2671305 100644 --- a/packages/pieces/community/gmail/src/i18n/nl.json +++ b/packages/pieces/community/gmail/src/i18n/nl.json @@ -125,5 +125,16 @@ "Only trigger for emails containing this text in the subject.": "Alleen activeren voor e-mails met deze tekst in het onderwerp.", "Filter by Gmail label.": "Filter op Gmail label.", "Filter by Gmail category.": "Filteren op Gmail categorie.", - "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "Alleen trigger voor bijlagen met deze bestandsextensie (bijv. pdf, jpg, docx)." + "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "Alleen trigger voor bijlagen met deze bestandsextensie (bijv. pdf, jpg, docx).", + "Plain Text": "Onopgemaakte tekst", + "Reply To": "Antwoord aan", + "Save as Draft": "Opslaan als concept", + "Reply": "Beantwoorden", + "Create Label": "Label maken", + "Label Name": "Label naam", + "Add Label to Email": "Label toevoegen aan e-mail", + "Archive Email": "Archief e-mail", + "Response is Binary": "Antwoord is binair", + "Return Error as Output": "Retourfout als uitvoer", + "Timeout": "Time-out" } \ No newline at end of file diff --git a/packages/pieces/community/gmail/src/i18n/pt.json b/packages/pieces/community/gmail/src/i18n/pt.json index c36ac02a9269..1e43cb18a791 100644 --- a/packages/pieces/community/gmail/src/i18n/pt.json +++ b/packages/pieces/community/gmail/src/i18n/pt.json @@ -125,5 +125,17 @@ "Only trigger for emails containing this text in the subject.": "Ativar apenas os e-mails que contém este texto no assunto.", "Filter by Gmail label.": "Filtrar por rótulo do Gmail.", "Filter by Gmail category.": "Filtrar por categoria do Gmail.", - "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "Apenas gatilho para anexos com esta extensão de arquivo (por exemplo, pdf, jpg, docx)." + "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "Apenas gatilho para anexos com esta extensão de arquivo (por exemplo, pdf, jpg, docx).", + "Bcc": "CCO", + "Plain Text": "Texto sem Formatação", + "Reply To": "Responder a", + "Save as Draft": "Salvar como Rascunho", + "Reply": "Responder", + "Create Label": "Criar etiqueta", + "Label Name": "Nome da etiqueta", + "Add Label to Email": "Adicionar etiqueta ao e-mail", + "Archive Email": "Arquivo Email", + "Response is Binary": "Resposta é Binária", + "Return Error as Output": "Erro ao retornar como saída", + "Timeout": "Tempo esgotado" } \ No newline at end of file diff --git a/packages/pieces/community/gmail/src/i18n/ru.json b/packages/pieces/community/gmail/src/i18n/ru.json index bb58e02c104d..7488ce1c76f9 100644 --- a/packages/pieces/community/gmail/src/i18n/ru.json +++ b/packages/pieces/community/gmail/src/i18n/ru.json @@ -59,5 +59,21 @@ "Updates": "Обновления", "Forums": "Форумы", "Reservations": "Оговорки", - "Purchases": "Покупки" + "Purchases": "Покупки", + "To": "Кому", + "Plain Text": "Обычный текст", + "Reply To": "Ответить", + "From": "От", + "Attachments": "Вложения", + "Reply to Email": "Ответить на E-mail", + "Message": "Сообщение", + "Reply": "Ответ", + "Find Email": "Найти E-mail", + "Max Results": "Макс. результатов", + "None": "Нет", + "Form Data": "Данные формы", + "Raw": "Сырье", + "Response is Binary": "Ответ является двоичным", + "File Extension": "Расширение файла", + "New Label": "Новая метка" } \ No newline at end of file diff --git a/packages/pieces/community/gmail/src/i18n/translation.json b/packages/pieces/community/gmail/src/i18n/translation.json index 559a142c6df7..366b388aa1a2 100644 --- a/packages/pieces/community/gmail/src/i18n/translation.json +++ b/packages/pieces/community/gmail/src/i18n/translation.json @@ -24,13 +24,24 @@ "Get Profile": "Get Profile", "List History": "List History", "Stop Watch": "Stop Watch", + "Create Label": "Create Label", + "Add Label to Email": "Add Label to Email", + "Remove Label From Email": "Remove Label From Email", + "Archive Email": "Archive Email", + "Archive Message": "Archive Message", + "Get or Create Label": "Get or Create Label", + "Modify Labels": "Modify Labels", + "Trash Message": "Trash Message", + "Untrash Message": "Untrash Message", + "Update Label": "Update Label", + "Modify Thread Labels": "Modify Thread Labels", "Custom API Call": "Custom API Call", "Send an email through a Gmail account": "Send an email through a Gmail account", - "Send approval request email and then wait until the email is approved or disapproved": "Send approval request email and then wait until the email is approved or disapproved", + "Send an email with an approval link and pause until it is answered.": "Send an email with an approval link and pause until it is answered.", "Reply to an existing email.": "Reply to an existing email.", "Creates a draft reply to an existing email.": "Creates a draft reply to an existing email.", - "Get an email via Id.": "Get an email via Id.", - "Find emails using advanced search criteria. If no filters are provided, the latest emails are returned.": "Find emails using advanced search criteria. If no filters are provided, the latest emails are returned.", + "Read one email by its message ID, including attachments.": "Read one email by its message ID, including attachments.", + "Search your mailbox; with no filters it returns the latest emails.": "Search your mailbox; with no filters it returns the latest emails.", "Reply to an existing email thread.": "Reply to an existing email thread.", "Get a single email message by its ID.": "Get a single email message by its ID.", "Search emails using advanced criteria. If no filters are provided, the latest emails are returned.": "Search emails using advanced criteria. If no filters are provided, the latest emails are returned.", @@ -49,88 +60,100 @@ "Get the connected mailbox profile.": "Get the connected mailbox profile.", "List mailbox changes since a given history ID.": "List mailbox changes since a given history ID.", "Stop push notifications on the mailbox.": "Stop push notifications on the mailbox.", + "Create a new label in the mailbox.": "Create a new label in the mailbox.", + "Apply a label to an email message.": "Apply a label to an email message.", + "Remove a label from an email message.": "Remove a label from an email message.", + "Archive an email message by removing it from the inbox.": "Archive an email message by removing it from the inbox.", + "Get an existing label by name, creating it first if it does not exist.": "Get an existing label by name, creating it first if it does not exist.", + "Add and/or remove labels across one or more email messages in a single call.": "Add and/or remove labels across one or more email messages in a single call.", + "Move an email message to Trash.": "Move an email message to Trash.", + "Restore an email message from Trash.": "Restore an email message from Trash.", + "Rename, recolor, or change the visibility of a user label.": "Rename, recolor, or change the visibility of a user label.", + "Add and/or remove labels across every message in a thread in a single call.": "Add and/or remove labels across every message in a thread in a single call.", "Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint", - "Receiver Email (To)": "Receiver Email (To)", - "CC Email": "CC Email", - "BCC Email": "BCC Email", + "To": "To", + "Cc": "Cc", + "Bcc": "Bcc", "Subject": "Subject", "Body Type": "Body Type", "Body": "Body", - "Reply-To Email": "Reply-To Email", + "Reply To": "Reply To", "Sender Name": "Sender Name", - "Sender Email": "Sender Email", + "From": "From", "Attachments": "Attachments", - "File": "File", - "In reply to": "In reply to", - "Create draft": "Create draft", + "In Reply To": "In Reply To", + "Save as Draft": "Save as Draft", "Message": "Message", "Reply Type": "Reply Type", - "Reply Body": "Reply Body", "Attachment": "Attachment", "Attachment Name": "Attachment Name", - "Draft Reply Body": "Draft Reply Body", "Include Original Message": "Include Original Message", - "Message ID": "Message ID", - "Email sender": "Email sender", - "Email recipient": "Email recipient", - "Email subject": "Email subject", - "Email Content": "Email Content", "Has Attachment": "Has Attachment", "Label": "Label", "Category": "Category", - "After Date": "After Date", - "Before Date": "Before Date", - "Include Spam & Trash": "Include Spam & Trash", + "After": "After", + "Before": "Before", + "Spam and Trash": "Spam and Trash", "Max Results": "Max Results", "Thread ID": "Thread ID", "Format": "Format", + "Receiver Email (To)": "Receiver Email (To)", + "CC Email": "CC Email", + "BCC Email": "BCC Email", + "Sender Email": "Sender Email", "Draft ID": "Draft ID", "Search Query": "Search Query", "Label IDs": "Label IDs", + "Include Spam & Trash": "Include Spam & Trash", + "Message ID": "Message ID", "Attachment ID": "Attachment ID", "File Name": "File Name", "Note": "Note", "Label ID": "Label ID", "Start History ID": "Start History ID", "History Types": "History Types", + "Label Name": "Label Name", + "Message IDs": "Message IDs", + "Label IDs to Add": "Label IDs to Add", + "Label IDs to Remove": "Label IDs to Remove", + "New Name": "New Name", + "Label List Visibility": "Label List Visibility", + "Message List Visibility": "Message List Visibility", + "Background Color (hex)": "Background Color (hex)", + "Text Color (hex)": "Text Color (hex)", "Method": "Method", "Headers": "Headers", "Query Parameters": "Query Parameters", - "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", - "Body for the email you want to send": "Body for the email you want to send", - "Email address to set as the \"Reply-To\" header": "Email address to set as the \"Reply-To\" header", - "The address must be listed in your GMail account's settings": "The address must be listed in your GMail account's settings", - "Reply to this Message-ID": "Reply to this Message-ID", - "Create draft without sending the actual email": "Create draft without sending the actual email", - "The email address of the recipient who will receive the approval request.": "The email address of the recipient who will receive the approval request.", - "The email addresses of the recipients who will receive a carbon copy of the approval request.": "The email addresses of the recipients who will receive a carbon copy of the approval request.", - "The email addresses of the recipients who will receive a blind carbon copy of the approval request.": "The email addresses of the recipients who will receive a blind carbon copy of the approval request.", - "The subject of the approval request email.": "The subject of the approval request email.", - "Select a message from the list or enter a message ID manually.": "Select a message from the list or enter a message ID manually.", - "Choose whether to reply to sender only or to all recipients": "Choose whether to reply to sender only or to all recipients", - "Your reply message content": "Your reply message content", - "Optional sender name to display": "Optional sender name to display", - "Optional file to attach to your reply": "Optional file to attach to your reply", - "Custom name for the attachment": "Custom name for the attachment", - "Your draft reply message content": "Your draft reply message content", - "Include the original message content in the draft reply": "Include the original message content in the draft reply", - "Optional file to attach to your draft reply": "Optional file to attach to your draft reply", - "The messageId of the mail to read.": "The messageId of the mail to read.", - "Optional filteration, leave empty to filter based on the email sender": "Optional filteration, leave empty to filter based on the email sender", - "Optional filteration, leave empty to filter based on the email recipient": "Optional filteration, leave empty to filter based on the email recipient", - "The email subject": "The email subject", - "Search for specific text within email body": "Search for specific text within email body", - "Only find emails with attachments": "Only find emails with attachments", - "Search for emails with specific attachment filename": "Search for emails with specific attachment filename", - "Optional filteration, leave unselected to filter based on the email label": "Optional filteration, leave unselected to filter based on the email label", - "Optional filteration, leave unselected to filter based on the email category": "Optional filteration, leave unselected to filter based on the email category", - "Find emails sent after this date": "Find emails sent after this date", - "Find emails sent before this date": "Find emails sent before this date", - "Include emails from Spam and Trash folders in search results": "Include emails from Spam and Trash folders in search results", - "Maximum number of emails to return (1-500)": "Maximum number of emails to return (1-500)", + "How the text in Body is interpreted.": "How the text in Body is interpreted.", + "Replies go to these addresses instead of the sender.": "Replies go to these addresses instead of the sender.", + "Name shown in the inbox instead of your address.": "Name shown in the inbox instead of your address.", + "A send-as address already set up in your Gmail settings.": "A send-as address already set up in your Gmail settings.", + "Message-ID header of the email to thread this under.": "Message-ID header of the email to thread this under.", + "Save to Drafts instead of sending.": "Save to Drafts instead of sending.", + "Address that receives the approval request.": "Address that receives the approval request.", + "Text shown above the Review & Respond button.": "Text shown above the Review & Respond button.", + "Pick a recent message, or map its ID from a trigger or Find Email.": "Pick a recent message, or map its ID from a trigger or Find Email.", + "Reply to the sender only, or to everyone on the thread.": "Reply to the sender only, or to everyone on the thread.", + "File to attach.": "File to attach.", + "Overrides the uploaded file name.": "Overrides the uploaded file name.", + "Quote the original message below your reply.": "Quote the original message below your reply.", + "Only emails sent from this address.": "Only emails sent from this address.", + "Only emails sent to this address.": "Only emails sent to this address.", + "Only emails whose subject contains this text.": "Only emails whose subject contains this text.", + "Only emails with this text in the body.": "Only emails with this text in the body.", + "Only emails with at least one attachment.": "Only emails with at least one attachment.", + "Only emails with an attachment of this file name.": "Only emails with an attachment of this file name.", + "Only emails carrying this label.": "Only emails carrying this label.", + "Only emails in this inbox tab, such as Primary or Promotions.": "Only emails in this inbox tab, such as Primary or Promotions.", + "Only emails received after this date.": "Only emails received after this date.", + "Only emails received before this date.": "Only emails received before this date.", + "Also search the Spam and Trash folders.": "Also search the Spam and Trash folders.", + "Newest first, up to 500.": "Newest first, up to 500.", + "Message ID from a Find Email step or a Gmail trigger.": "Message ID from a Find Email step or a Gmail trigger.", "The thread Id of the mail to read": "The thread Id of the mail to read", "Format of the mail": "Format of the mail", "Body for the draft email.": "Body for the draft email.", @@ -158,14 +181,33 @@ "Return changes after this history ID (obtain from Get Profile or a previous history result).": "Return changes after this history ID (obtain from Get Profile or a previous history result).", "Limit results to these change types (leave empty for all).": "Limit results to these change types (leave empty for all).", "Maximum number of history records to return (1-500).": "Maximum number of history records to return (1-500).", + "Appears in your Gmail label list.": "Appears in your Gmail label list.", + "Label to add.": "Label to add.", + "Label to remove.": "Label to remove.", + "The exact display name of the label, e.g. \"Follow Up\". Matching is case-sensitive.": "The exact display name of the label, e.g. \"Follow Up\". Matching is case-sensitive.", + "The Gmail message IDs to modify (obtain from Search Email or Get Message). Up to 1000 per call.": "The Gmail message IDs to modify (obtain from Search Email or Get Message). Up to 1000 per call.", + "Label IDs to add to every message listed above (obtain from List Labels or Get or Create Label).": "Label IDs to add to every message listed above (obtain from List Labels or Get or Create Label).", + "Label IDs to remove from every message listed above (obtain from List Labels).": "Label IDs to remove from every message listed above (obtain from List Labels).", + "The Gmail message ID to move to Trash (obtain from Search Email or Get Message).": "The Gmail message ID to move to Trash (obtain from Search Email or Get Message).", + "The Gmail message ID to restore from Trash (obtain from Search Email or Get Message).": "The Gmail message ID to restore from Trash (obtain from Search Email or Get Message).", + "The ID of the user label to update (obtain from List Labels).": "The ID of the user label to update (obtain from List Labels).", + "Leave empty to keep the current name.": "Leave empty to keep the current name.", + "Leave unselected to keep the current setting.": "Leave unselected to keep the current setting.", + "Leave empty to keep the current background color.": "Leave empty to keep the current background color.", + "Leave empty to keep the current text color.": "Leave empty to keep the current text color.", + "The Gmail thread ID to modify (obtain from Search Email or Get Thread).": "The Gmail thread ID to modify (obtain from Search Email or Get Thread).", + "Label IDs to add to every message in the thread (obtain from List Labels or Get or Create Label).": "Label IDs to add to every message in the thread (obtain from List Labels or Get or Create Label).", + "Label IDs to remove from every message in the thread (obtain from List Labels).": "Label IDs to remove from every message in the thread (obtain from List Labels).", "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.", - "plain text": "plain text", - "html": "html", - "Reply (to sender only)": "Reply (to sender only)", - "Reply All (to all recipients)": "Reply All (to all recipients)", - "Plain text": "Plain text", + "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.", + "Plain Text": "Plain Text", "HTML": "HTML", + "Reply": "Reply", + "Reply All": "Reply All", "Primary": "Primary", "Social": "Social", "Promotions": "Promotions", @@ -177,35 +219,30 @@ "Full": "Full", "Raw": "Raw", "Metadata": "Metadata", + "plain text": "plain text", + "html": "html", "Message Added": "Message Added", "Message Deleted": "Message Deleted", "Label Added": "Label Added", "Label Removed": "Label Removed", + "Show": "Show", + "Show if unread": "Show if unread", + "Hide": "Hide", "GET": "GET", "POST": "POST", "PATCH": "PATCH", "PUT": "PUT", "DELETE": "DELETE", "HEAD": "HEAD", - "None": "None", - "JSON": "JSON", - "Form Data": "Form Data", "New Email": "New Email", "New Labeled Email": "New Labeled Email", "New Attachment": "New Attachment", "New Label": "New Label", - "Triggers when new mail is found in your Gmail inbox": "Triggers when new mail is found in your Gmail inbox", - "Triggers when a label is added to an email": "Triggers when a label is added to an email", - "Triggers when an email with an attachment arrives.": "Triggers when an email with an attachment arrives.", - "Triggers when a new label is created.": "Triggers when a new label is created.", - "From": "From", - "To": "To", - "Subject Contains": "Subject Contains", + "Triggers when a new email matching the filters arrives.": "Triggers when a new email matching the filters arrives.", + "Triggers when this label is added to an email.": "Triggers when this label is added to an email.", + "Triggers for each attachment on a new email.": "Triggers for each attachment on a new email.", + "Triggers when a new label is created in Gmail.": "Triggers when a new label is created in Gmail.", "File Extension": "File Extension", - "Filter by sender email.": "Filter by sender email.", - "Filter by recipient email.": "Filter by recipient email.", - "Only trigger for emails containing this text in the subject.": "Only trigger for emails containing this text in the subject.", - "Filter by Gmail label.": "Filter by Gmail label.", - "Filter by Gmail category.": "Filter by Gmail category.", - "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).": "Only trigger for attachments with this file extension (e.g., pdf, jpg, docx)." + "Emails that receive this label start the flow.": "Emails that receive this label start the flow.", + "Only attachments with this extension.": "Only attachments with this extension." } \ No newline at end of file diff --git a/packages/pieces/community/gmail/src/index.ts b/packages/pieces/community/gmail/src/index.ts index cccc2fef5905..7fde14068cb1 100644 --- a/packages/pieces/community/gmail/src/index.ts +++ b/packages/pieces/community/gmail/src/index.ts @@ -51,7 +51,7 @@ export { } from './lib/auth'; export const gmail = createPiece({ - minimumSupportedRelease: '0.87.0', + minimumSupportedRelease: '0.88.2', logoUrl: 'https://cdn.activepieces.com/pieces/gmail.png', categories: [ PieceCategory.COMMUNICATION, diff --git a/packages/pieces/community/gmail/src/lib/actions/add-label-to-email-action.ts b/packages/pieces/community/gmail/src/lib/actions/add-label-to-email-action.ts index 1f8fe75b36b0..83063041e495 100644 --- a/packages/pieces/community/gmail/src/lib/actions/add-label-to-email-action.ts +++ b/packages/pieces/community/gmail/src/lib/actions/add-label-to-email-action.ts @@ -1,4 +1,4 @@ -import { createAction, Property } from '@activepieces/pieces-framework'; +import { createAction } from '@activepieces/pieces-framework'; import { gmailAuth, createGoogleClient } from '../auth'; import { gmail as googleGmail } from '@googleapis/gmail'; import { GmailProps } from '../common/props'; @@ -12,14 +12,9 @@ export const gmailAddLabelToEmailAction = createAction({ description: 'Apply a label to an email message.', audience: 'human', props: { - message_id: Property.ShortText({ - displayName: 'Message ID', - description: - 'The Gmail message ID to label (obtain from Search Email or Get Message).', - required: true, - }), + message_id: GmailProps.message, label: GmailProps.label({ - description: 'The label to apply to the message.', + description: 'Label to add.', required: true, }), }, diff --git a/packages/pieces/community/gmail/src/lib/actions/ai-archive-email-action.ts b/packages/pieces/community/gmail/src/lib/actions/ai-archive-email-action.ts index 04c353ea789d..3578311bb6a4 100644 --- a/packages/pieces/community/gmail/src/lib/actions/ai-archive-email-action.ts +++ b/packages/pieces/community/gmail/src/lib/actions/ai-archive-email-action.ts @@ -1,6 +1,6 @@ -import { createAction } from '@activepieces/pieces-framework'; +import { createAction, Property } from '@activepieces/pieces-framework'; import { gmailAuth } from '../auth'; -import { gmailArchiveEmailAction } from './archive-email-action'; +import { archiveGmailMessage } from './archive-email-action'; import { gmailArchiveEmailActionOutputSchema } from '../output-schemas'; export const gmailAiArchiveMessageAction = createAction({ @@ -16,6 +16,17 @@ export const gmailAiArchiveMessageAction = createAction({ idempotent: true, }, outputSchema: gmailArchiveEmailActionOutputSchema, - props: gmailArchiveEmailAction.props, - run: gmailArchiveEmailAction.run, + props: { + message_id: Property.ShortText({ + displayName: 'Message', + description: 'Message ID from a Find Email step or a Gmail trigger.', + required: true, + }), + }, + async run(context) { + return archiveGmailMessage({ + auth: context.auth, + messageId: context.propsValue.message_id, + }); + }, }); diff --git a/packages/pieces/community/gmail/src/lib/actions/ai-get-message-action.ts b/packages/pieces/community/gmail/src/lib/actions/ai-get-message-action.ts index 83deea0b7480..8586bb235e0c 100644 --- a/packages/pieces/community/gmail/src/lib/actions/ai-get-message-action.ts +++ b/packages/pieces/community/gmail/src/lib/actions/ai-get-message-action.ts @@ -1,6 +1,6 @@ -import { createAction } from '@activepieces/pieces-framework'; +import { createAction, Property } from '@activepieces/pieces-framework'; import { gmailAuth } from '../auth'; -import { gmailGetEmailAction } from './get-mail-action'; +import { getGmailMessage } from './get-mail-action'; import { gmailAiGetMessageActionOutputSchema } from '../output-schemas'; export const gmailAiGetMessageAction = createAction({ @@ -16,6 +16,18 @@ export const gmailAiGetMessageAction = createAction({ idempotent: true, }, outputSchema: gmailAiGetMessageActionOutputSchema, - props: gmailGetEmailAction.props, - run: gmailGetEmailAction.run, + props: { + message_id: Property.ShortText({ + displayName: 'Message', + description: 'Message ID from a Find Email step or a Gmail trigger.', + required: true, + }), + }, + async run(context) { + return getGmailMessage({ + auth: context.auth, + messageId: context.propsValue.message_id, + files: context.files, + }); + }, }); diff --git a/packages/pieces/community/gmail/src/lib/actions/archive-email-action.ts b/packages/pieces/community/gmail/src/lib/actions/archive-email-action.ts index 8e778ada486f..19ebfda132be 100644 --- a/packages/pieces/community/gmail/src/lib/actions/archive-email-action.ts +++ b/packages/pieces/community/gmail/src/lib/actions/archive-email-action.ts @@ -1,6 +1,7 @@ -import { createAction, Property } from '@activepieces/pieces-framework'; -import { gmailAuth, createGoogleClient } from '../auth'; +import { createAction } from '@activepieces/pieces-framework'; +import { gmailAuth, createGoogleClient, GmailAuthValue } from '../auth'; import { gmail as googleGmail } from '@googleapis/gmail'; +import { GmailProps } from '../common/props'; import { gmailArchiveEmailActionOutputSchema } from '../output-schemas'; export const gmailArchiveEmailAction = createAction({ @@ -11,38 +12,48 @@ export const gmailArchiveEmailAction = createAction({ description: 'Archive an email message by removing it from the inbox.', audience: 'human', props: { - message_id: Property.ShortText({ - displayName: 'Message ID', - description: - 'The Gmail message ID to archive (obtain from Search Email or Get Message).', - required: true, - }), + message_id: GmailProps.message, }, outputSchema: gmailArchiveEmailActionOutputSchema, async run(context) { - const authClient = await createGoogleClient(context.auth); - const gmail = googleGmail({ version: 'v1', auth: authClient }); - - try { - const response = await gmail.users.messages.modify({ - userId: 'me', - id: context.propsValue.message_id, - requestBody: { - removeLabelIds: ['INBOX'], - }, - }); - return response.data; - } catch (error: any) { - if (error.code === 403) { - throw new Error( - 'Insufficient permissions to modify labels on a message. Ensure the gmail.modify scope is granted.' - ); - } else if (error.code === 404) { - throw new Error( - `Message not found: "${context.propsValue.message_id}". Use Search Email or Get Message to find a valid message ID.` - ); - } - throw new Error(`Failed to archive email: ${error.message}`); - } + return archiveGmailMessage({ + auth: context.auth, + messageId: context.propsValue.message_id, + }); }, }); + +export async function archiveGmailMessage({ + auth, + messageId, +}: ArchiveGmailMessageParams) { + const authClient = await createGoogleClient(auth); + const gmail = googleGmail({ version: 'v1', auth: authClient }); + + try { + const response = await gmail.users.messages.modify({ + userId: 'me', + id: messageId, + requestBody: { + removeLabelIds: ['INBOX'], + }, + }); + return response.data; + } catch (error: any) { + if (error.code === 403) { + throw new Error( + 'Insufficient permissions to modify labels on a message. Ensure the gmail.modify scope is granted.' + ); + } else if (error.code === 404) { + throw new Error( + `Message not found: "${messageId}". Use Search Email or Get Message to find a valid message ID.` + ); + } + throw new Error(`Failed to archive email: ${error.message}`); + } +} + +type ArchiveGmailMessageParams = { + auth: GmailAuthValue; + messageId: string; +}; diff --git a/packages/pieces/community/gmail/src/lib/actions/create-draft-reply-action.ts b/packages/pieces/community/gmail/src/lib/actions/create-draft-reply-action.ts index 3371f1f29b9d..2a8ae3559e2c 100644 --- a/packages/pieces/community/gmail/src/lib/actions/create-draft-reply-action.ts +++ b/packages/pieces/community/gmail/src/lib/actions/create-draft-reply-action.ts @@ -23,67 +23,81 @@ export const gmailCreateDraftReplyAction = createAction({ message_id: GmailProps.message, reply_type: Property.StaticDropdown({ displayName: 'Reply Type', - description: - 'Choose whether to reply to sender only or to all recipients', + description: 'Reply to the sender only, or to everyone on the thread.', required: true, defaultValue: 'reply', + display: 'cards', options: { disabled: false, options: [ { - label: 'Reply (to sender only)', + label: 'Reply', value: 'reply', + description: 'Sender only', + icon: 'reply', }, { - label: 'Reply All (to all recipients)', + label: 'Reply All', value: 'reply_all', + description: 'All recipients', + icon: 'reply-all', }, ], }, }), body_type: Property.StaticDropdown({ displayName: 'Body Type', + description: 'How the text in Body is interpreted.', required: true, defaultValue: 'plain_text', + display: 'cards', options: { disabled: false, options: [ { - label: 'Plain text', + label: 'Plain Text', value: 'plain_text', + description: 'Sent as written', + icon: 'text', }, { label: 'HTML', value: 'html', + description: 'Bold, links, lists', + icon: 'code', }, ], }, }), - body: Property.LongText({ - displayName: 'Draft Reply Body', - description: 'Your draft reply message content', + body: Property.RichText({ + displayName: 'Body', required: false, + formatProperty: 'body_type', }), include_original_message: Property.Checkbox({ displayName: 'Include Original Message', - description: 'Include the original message content in the draft reply', - required: true, + description: 'Quote the original message below your reply.', + required: false, defaultValue: true, }), sender_name: Property.ShortText({ displayName: 'Sender Name', - description: 'Optional sender name to display', + description: 'Name shown in the inbox instead of your address.', + placeholder: 'Jane at Acme', required: false, + advanced: true, }), attachment: Property.File({ displayName: 'Attachment', - description: 'Optional file to attach to your draft reply', + description: 'File to attach.', required: false, }), attachment_name: Property.ShortText({ displayName: 'Attachment Name', - description: 'Custom name for the attachment', + description: 'Overrides the uploaded file name.', + placeholder: 'report.pdf', required: false, + advanced: true, }), }, outputSchema: createDraftReplyActionOutputSchema, @@ -211,17 +225,16 @@ export const gmailCreateDraftReplyAction = createAction({ const senderEmail = await getUserEmail(context.auth, authClient); let draftBody = context.propsValue.body || ''; + const isPlainText = context.propsValue.body_type === 'plain_text'; if (context.propsValue.include_original_message && originalMessageContent) { - const separator = - context.propsValue.body_type === 'html' - ? '

--- Original Message ---
' - : '\n\n--- Original Message ---\n'; + const separator = isPlainText + ? '\n\n--- Original Message ---\n' + : '

--- Original Message ---
'; - const quotedContent = - context.propsValue.body_type === 'html' - ? originalMessageContent.replace(/\n/g, '
') - : originalMessageContent; + const quotedContent = isPlainText + ? originalMessageContent + : originalMessageContent.replace(/\n/g, '
'); draftBody = draftBody ? `${draftBody}${separator}${quotedContent}` @@ -233,9 +246,8 @@ export const gmailCreateDraftReplyAction = createAction({ to: toRecipients.join(', '), cc: ccRecipients.length > 0 ? ccRecipients.join(', ') : undefined, subject: `=?UTF-8?B?${subjectBase64}?=`, - text: - context.propsValue.body_type === 'plain_text' ? draftBody : undefined, - html: context.propsValue.body_type === 'html' ? draftBody : undefined, + text: isPlainText ? draftBody : undefined, + html: isPlainText ? undefined : draftBody, attachments: [], headers: [ { diff --git a/packages/pieces/community/gmail/src/lib/actions/create-label-action.ts b/packages/pieces/community/gmail/src/lib/actions/create-label-action.ts index ddc6522901d4..cd9dedcf53be 100644 --- a/packages/pieces/community/gmail/src/lib/actions/create-label-action.ts +++ b/packages/pieces/community/gmail/src/lib/actions/create-label-action.ts @@ -13,7 +13,8 @@ export const gmailCreateLabelAction = createAction({ props: { name: Property.ShortText({ displayName: 'Label Name', - description: 'The display name of the new label, e.g. "Follow Up".', + description: 'Appears in your Gmail label list.', + placeholder: 'Follow Up', required: true, }), }, diff --git a/packages/pieces/community/gmail/src/lib/actions/get-mail-action.ts b/packages/pieces/community/gmail/src/lib/actions/get-mail-action.ts index 37daa873247e..733c66f8e069 100644 --- a/packages/pieces/community/gmail/src/lib/actions/get-mail-action.ts +++ b/packages/pieces/community/gmail/src/lib/actions/get-mail-action.ts @@ -1,14 +1,15 @@ -import { createAction, Property } from '@activepieces/pieces-framework'; -import { gmailAuth, createGoogleClient } from '../auth'; +import { createAction, FilesService } from '@activepieces/pieces-framework'; +import { gmailAuth, createGoogleClient, GmailAuthValue } from '../auth'; import { gmail as googleGmail } from '@googleapis/gmail'; import { convertAttachment, parseStream } from '../common/data'; +import { GmailProps } from '../common/props'; import { gmailGetMailActionOutputSchema } from '../output-schemas'; export const gmailGetEmailAction = createAction({ auth: gmailAuth, name: 'gmail_get_mail', classification: 'READ', - description: 'Get an email via Id.', + description: 'Read one email by its message ID, including attachments.', audience: 'human', aiMetadata: { description: @@ -17,37 +18,46 @@ export const gmailGetEmailAction = createAction({ }, displayName: 'Get Email', props: { - message_id: Property.ShortText({ - displayName: 'Message ID', - description: 'The messageId of the mail to read.', - required: true, - }), + message_id: GmailProps.message, }, outputSchema: gmailGetMailActionOutputSchema, async run(context) { - const authClient = await createGoogleClient(context.auth); + return getGmailMessage({ + auth: context.auth, + messageId: context.propsValue.message_id, + files: context.files, + }); + }, +}); - const gmail = googleGmail({ version: 'v1', auth: authClient }); +export async function getGmailMessage({ + auth, + messageId, + files, +}: GetGmailMessageParams) { + const authClient = await createGoogleClient(auth); - const rawMailResponse = await gmail.users.messages.get({ - userId: 'me', - id: context.propsValue.message_id!, - format: 'raw', - }); + const gmail = googleGmail({ version: 'v1', auth: authClient }); - const parsedMailResponse = await parseStream( - Buffer.from(rawMailResponse.data.raw as string, 'base64').toString( - 'utf-8' - ) - ); + const rawMailResponse = await gmail.users.messages.get({ + userId: 'me', + id: messageId, + format: 'raw', + }); - return { - id: context.propsValue.message_id, - ...parsedMailResponse, - attachments: await convertAttachment( - parsedMailResponse.attachments, - context.files - ), - }; - }, -}); + const parsedMailResponse = await parseStream( + Buffer.from(rawMailResponse.data.raw as string, 'base64').toString('utf-8') + ); + + return { + id: messageId, + ...parsedMailResponse, + attachments: await convertAttachment(parsedMailResponse.attachments, files), + }; +} + +type GetGmailMessageParams = { + auth: GmailAuthValue; + messageId: string; + files: FilesService; +}; diff --git a/packages/pieces/community/gmail/src/lib/actions/remove-label-from-email-action.ts b/packages/pieces/community/gmail/src/lib/actions/remove-label-from-email-action.ts index e3d9e9a33be2..743968849d71 100644 --- a/packages/pieces/community/gmail/src/lib/actions/remove-label-from-email-action.ts +++ b/packages/pieces/community/gmail/src/lib/actions/remove-label-from-email-action.ts @@ -1,4 +1,4 @@ -import { createAction, Property } from '@activepieces/pieces-framework'; +import { createAction } from '@activepieces/pieces-framework'; import { gmailAuth, createGoogleClient } from '../auth'; import { gmail as googleGmail } from '@googleapis/gmail'; import { GmailProps } from '../common/props'; @@ -12,14 +12,9 @@ export const gmailRemoveLabelFromEmailAction = createAction({ description: 'Remove a label from an email message.', audience: 'human', props: { - message_id: Property.ShortText({ - displayName: 'Message ID', - description: - 'The Gmail message ID to unlabel (obtain from Search Email or Get Message).', - required: true, - }), + message_id: GmailProps.message, label: GmailProps.label({ - description: 'The label to remove from the message.', + description: 'Label to remove.', required: true, }), }, diff --git a/packages/pieces/community/gmail/src/lib/actions/reply-to-email-action.ts b/packages/pieces/community/gmail/src/lib/actions/reply-to-email-action.ts index 0872af85bcdc..c780941b4ce7 100644 --- a/packages/pieces/community/gmail/src/lib/actions/reply-to-email-action.ts +++ b/packages/pieces/community/gmail/src/lib/actions/reply-to-email-action.ts @@ -23,61 +23,75 @@ export const gmailReplyToEmailAction = createAction({ message_id: GmailProps.message, reply_type: Property.StaticDropdown({ displayName: 'Reply Type', - description: - 'Choose whether to reply to sender only or to all recipients', + description: 'Reply to the sender only, or to everyone on the thread.', required: true, defaultValue: 'reply', + display: 'cards', options: { disabled: false, options: [ { - label: 'Reply (to sender only)', + label: 'Reply', value: 'reply', + description: 'Sender only', + icon: 'reply', }, { - label: 'Reply All (to all recipients)', + label: 'Reply All', value: 'reply_all', + description: 'All recipients', + icon: 'reply-all', }, ], }, }), body_type: Property.StaticDropdown({ displayName: 'Body Type', + description: 'How the text in Body is interpreted.', required: true, defaultValue: 'plain_text', + display: 'cards', options: { disabled: false, options: [ { - label: 'Plain text', + label: 'Plain Text', value: 'plain_text', + description: 'Sent as written', + icon: 'text', }, { label: 'HTML', value: 'html', + description: 'Bold, links, lists', + icon: 'code', }, ], }, }), - body: Property.LongText({ - displayName: 'Reply Body', - description: 'Your reply message content', + body: Property.RichText({ + displayName: 'Body', required: true, + formatProperty: 'body_type', }), sender_name: Property.ShortText({ displayName: 'Sender Name', - description: 'Optional sender name to display', + description: 'Name shown in the inbox instead of your address.', + placeholder: 'Jane at Acme', required: false, + advanced: true, }), attachment: Property.File({ displayName: 'Attachment', - description: 'Optional file to attach to your reply', + description: 'File to attach.', required: false, }), attachment_name: Property.ShortText({ displayName: 'Attachment Name', - description: 'Custom name for the attachment', + description: 'Overrides the uploaded file name.', + placeholder: 'report.pdf', required: false, + advanced: true, }), }, outputSchema: replyToEmailActionOutputSchema, @@ -159,18 +173,13 @@ export const gmailReplyToEmailAction = createAction({ const senderEmail = await getUserEmail(context.auth, authClient); const subjectBase64 = Buffer.from(replySubject).toString('base64'); + const isPlainText = context.propsValue.body_type === 'plain_text'; const mailOptions: Mail.Options = { to: toRecipients.join(', '), cc: ccRecipients.length > 0 ? ccRecipients.join(', ') : undefined, subject: `=?UTF-8?B?${subjectBase64}?=`, - text: - context.propsValue.body_type === 'plain_text' - ? context.propsValue.body - : undefined, - html: - context.propsValue.body_type === 'html' - ? context.propsValue.body - : undefined, + text: isPlainText ? context.propsValue.body : undefined, + html: isPlainText ? undefined : context.propsValue.body, attachments: [], headers: [ { diff --git a/packages/pieces/community/gmail/src/lib/actions/request-approval-in-email.ts b/packages/pieces/community/gmail/src/lib/actions/request-approval-in-email.ts index 05b1e4e7acc2..a91f4ef9b9bf 100644 --- a/packages/pieces/community/gmail/src/lib/actions/request-approval-in-email.ts +++ b/packages/pieces/community/gmail/src/lib/actions/request-approval-in-email.ts @@ -19,57 +19,92 @@ export const requestApprovalInEmail = createAction({ classification: 'WRITE', displayName: 'Request Approval in Email', description: - 'Send approval request email and then wait until the email is approved or disapproved', + 'Send an email with an approval link and pause until it is answered.', audience: 'both', aiMetadata: { description: 'Sends an email, optionally with file attachments, carrying a single link to a confirmation page where the recipient chooses Approve or Disapprove, then pauses the flow until they respond, resuming with their decision. Use this as a human-in-the-loop gate before proceeding with a sensitive action. The flow blocks indefinitely until a response arrives. Not idempotent: each call sends a new approval email and creates a new wait.', idempotent: false, }, + propertyGroups: [ + { + key: 'recipients', + display: 'tabs', + label: 'Recipients', + description: + 'Press Enter after each address. Reply To receives replies instead of the sender.', + props: ['receiver', 'cc', 'bcc', 'reply_to'], + }, + ], props: { receiver: Property.ShortText({ - displayName: 'Receiver Email (To)', - description: - 'The email address of the recipient who will receive the approval request.', + displayName: 'To', + description: 'Address that receives the approval request.', + placeholder: 'manager@example.com', required: true, }), cc: Property.Array({ - displayName: 'CC Email', - description: - 'The email addresses of the recipients who will receive a carbon copy of the approval request.', + displayName: 'Cc', required: false, }), bcc: Property.Array({ - displayName: 'BCC Email', - description: - 'The email addresses of the recipients who will receive a blind carbon copy of the approval request.', + displayName: 'Bcc', required: false, }), subject: Property.ShortText({ displayName: 'Subject', - description: 'The subject of the approval request email.', + placeholder: 'Approval needed: purchase order 1042', + required: true, + }), + body_type: Property.StaticDropdown({ + displayName: 'Body Type', + description: 'How the text in Body is interpreted.', required: true, + defaultValue: 'plain_text', + display: 'cards', + options: { + disabled: false, + options: [ + { + label: 'Plain Text', + value: 'plain_text', + description: 'Sent as written', + icon: 'text', + }, + { + label: 'HTML', + value: 'html', + description: 'Bold, links, lists', + icon: 'code', + }, + ], + }, }), - body: Property.ShortText({ + body: Property.RichText({ displayName: 'Body', - description: 'Body for the email you want to send', + description: 'Text shown above the Review & Respond button.', required: true, + formatProperty: 'body_type', }), reply_to: Property.Array({ - displayName: 'Reply-To Email', - description: 'Email address to set as the "Reply-To" header', + displayName: 'Reply To', + description: 'Replies go to these addresses instead of the sender.', required: false, }), sender_name: Property.ShortText({ displayName: 'Sender Name', + description: 'Name shown in the inbox instead of your address.', + placeholder: 'Jane at Acme', required: false, + advanced: true, }), from: Property.ShortText({ - displayName: 'Sender Email', - description: - "The address must be listed in your GMail account's settings", + displayName: 'From', + description: 'A send-as address already set up in your Gmail settings.', + placeholder: 'sales@example.com', required: false, + advanced: true, }), attachments: Property.Array({ displayName: 'Attachments', @@ -77,20 +112,21 @@ export const requestApprovalInEmail = createAction({ properties: { file: Property.File({ displayName: 'File', - description: 'File to attach to the approval request email.', required: true, }), name: Property.ShortText({ displayName: 'Attachment Name', - description: 'In case you want to change the name of the attachment.', + description: 'Overrides the uploaded file name.', + placeholder: 'report.pdf', required: false, }), }, }), in_reply_to: Property.ShortText({ - displayName: 'In reply to', - description: 'Reply to this Message-ID', + displayName: 'In Reply To', + description: 'Message-ID header of the email to thread this under.', required: false, + advanced: true, }), }, outputSchema: requestApprovalInMailActionOutputSchema, @@ -112,9 +148,14 @@ export const requestApprovalInEmail = createAction({ const confirmationLink = `${waitpoint.resumeUrl}/confirm`; + const bodyHtml = + context.propsValue.body_type === 'plain_text' + ? escapeHtml(body).replace(/\r?\n/g, '
') + : body; + const htmlBody = `
-

${body}

+
${bodyHtml}

Review & Respond @@ -145,10 +186,6 @@ export const requestApprovalInEmail = createAction({ bcc: bcc ? bcc.join(', ') : undefined, subject: `=?UTF-8?B?${subjectBase64}?=`, replyTo: replyTo ? replyTo.join(', ') : '', - // text: - // context.propsValue.body_type === 'plain_text' - // ? context.propsValue['body'] - // : undefined, html: htmlBody, attachments: [], }; @@ -195,7 +232,7 @@ export const requestApprovalInEmail = createAction({ userId: 'me', q: `Rfc822msgid:${context.propsValue.in_reply_to}`, }); - threadId = messages.data.messages?.[0].threadId; + threadId = messages.data.messages?.[0]?.threadId; } const mail: any = new MailComposer(mailOptions).compile(); mail.keepBcc = true; @@ -234,3 +271,12 @@ export const requestApprovalInEmail = createAction({ } }, }); + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/packages/pieces/community/gmail/src/lib/actions/search-email-action.ts b/packages/pieces/community/gmail/src/lib/actions/search-email-action.ts index 6b351e4dbbb1..06e3042ea9cd 100644 --- a/packages/pieces/community/gmail/src/lib/actions/search-email-action.ts +++ b/packages/pieces/community/gmail/src/lib/actions/search-email-action.ts @@ -3,7 +3,6 @@ import { gmailAuth, createGoogleClient } from '../auth'; import { gmail as googleGmail } from '@googleapis/gmail'; import { convertAttachment, parseStream } from '../common/data'; import { GmailProps } from '../common/props'; -import { GmailLabel } from '../common/models'; import { gmailSearchMailActionOutputSchema } from '../output-schemas'; export const gmailSearchMailAction = createAction({ @@ -12,56 +11,109 @@ export const gmailSearchMailAction = createAction({ classification: 'SEARCH', displayName: 'Find Email', description: - 'Find emails using advanced search criteria. If no filters are provided, the latest emails are returned.', + 'Search your mailbox; with no filters it returns the latest emails.', audience: 'human', aiMetadata: { description: 'Searches the mailbox for emails matching combinable filters (sender, recipient, subject, body text, label, category, date range, attachment presence/name) and returns the matched messages with parsed contents. Use this to locate messages or discover their IDs before reading or replying; with no filters it returns the most recent emails. Bound results with Max Results (1-500, default 10). Idempotent: a read-only search that does not modify the mailbox.', idempotent: true, }, + propertyGroups: [ + { + key: 'people', + display: 'builder', + label: 'People', + icon: 'users', + props: ['from', 'to'], + }, + { + key: 'content', + display: 'builder', + label: 'Content', + icon: 'type', + props: ['subject', 'content'], + }, + { + key: 'attachments', + display: 'builder', + label: 'Attachments', + icon: 'paperclip', + props: ['has_attachment', 'attachment_name'], + }, + { + key: 'mailbox', + display: 'builder', + label: 'Mailbox', + icon: 'inbox', + props: ['label', 'category', 'include_spam_trash'], + }, + { + key: 'date', + display: 'builder', + label: 'Date', + icon: 'calendar', + props: ['after_date', 'before_date'], + }, + { + key: 'footer', + display: 'footer', + icon: 'sliders', + props: ['max_results'], + }, + ], props: { - from: GmailProps.from, - to: GmailProps.to, - subject: GmailProps.subject, + from: { ...GmailProps.from, icon: 'user' }, + to: { ...GmailProps.to, icon: 'send' }, + subject: { ...GmailProps.subject, icon: 'type' }, content: Property.ShortText({ - displayName: 'Email Content', - description: 'Search for specific text within email body', + displayName: 'Body', + description: 'Only emails with this text in the body.', + placeholder: 'order confirmed', + icon: 'text', required: false, }), has_attachment: Property.Checkbox({ displayName: 'Has Attachment', - description: 'Only find emails with attachments', + description: 'Only emails with at least one attachment.', + icon: 'paperclip', required: false, defaultValue: false, }), attachment_name: Property.ShortText({ displayName: 'Attachment Name', - description: 'Search for emails with specific attachment filename', + description: 'Only emails with an attachment of this file name.', + placeholder: 'invoice.pdf', + icon: 'file', required: false, }), - label: GmailProps.label({ required: false }), - category: GmailProps.category, + label: GmailProps.label({ required: false, icon: 'tag' }), + category: { ...GmailProps.category, icon: 'inbox' }, after_date: Property.DateTime({ - displayName: 'After Date', - description: 'Find emails sent after this date', + displayName: 'After', + description: 'Only emails received after this date.', + icon: 'calendar', required: false, }), before_date: Property.DateTime({ - displayName: 'Before Date', - description: 'Find emails sent before this date', + displayName: 'Before', + description: 'Only emails received before this date.', + icon: 'calendar', required: false, }), - include_spam_trash: Property.Checkbox({ - displayName: 'Include Spam & Trash', - description: - 'Include emails from Spam and Trash folders in search results', + displayName: 'Spam and Trash', + description: 'Also search the Spam and Trash folders.', + icon: 'trash', required: false, defaultValue: false, }), max_results: Property.Number({ displayName: 'Max Results', - description: 'Maximum number of emails to return (1-500)', + description: 'Newest first, up to 500.', + display: 'stepper', + min: 1, + max: 500, + step: 1, required: false, defaultValue: 10, }), @@ -97,7 +149,7 @@ export const gmailSearchMailAction = createAction({ } if (context.propsValue.label) { - const label = context.propsValue.label as GmailLabel; + const label = context.propsValue.label; queryParts.push(`label:${label.name}`); } if (context.propsValue.category?.trim()) { diff --git a/packages/pieces/community/gmail/src/lib/actions/send-email-action.ts b/packages/pieces/community/gmail/src/lib/actions/send-email-action.ts index 64cb1c9fbfac..7e98a27a1b58 100644 --- a/packages/pieces/community/gmail/src/lib/actions/send-email-action.ts +++ b/packages/pieces/community/gmail/src/lib/actions/send-email-action.ts @@ -18,64 +18,82 @@ export const gmailSendEmailAction = createAction({ idempotent: false, }, displayName: 'Send Email', + propertyGroups: [ + { + key: 'recipients', + display: 'tabs', + label: 'Recipients', + description: + 'Press Enter after each address. Reply To receives replies instead of the sender.', + props: ['receiver', 'cc', 'bcc', 'reply_to'], + }, + ], props: { receiver: Property.Array({ - displayName: 'Receiver Email (To)', - description: undefined, + displayName: 'To', required: true, }), cc: Property.Array({ - displayName: 'CC Email', - description: undefined, + displayName: 'Cc', required: false, }), bcc: Property.Array({ - displayName: 'BCC Email', - description: undefined, + displayName: 'Bcc', required: false, }), subject: Property.ShortText({ displayName: 'Subject', description: undefined, + placeholder: 'Invoice for March', required: true, }), body_type: Property.StaticDropdown({ displayName: 'Body Type', + description: 'How the text in Body is interpreted.', required: true, defaultValue: 'plain_text', + display: 'cards', options: { disabled: false, options: [ { - label: 'plain text', + label: 'Plain Text', value: 'plain_text', + description: 'Sent as written', + icon: 'text', }, { - label: 'html', + label: 'HTML', value: 'html', + description: 'Bold, links, lists', + icon: 'code', }, ], }, }), - body: Property.ShortText({ + body: Property.RichText({ displayName: 'Body', - description: 'Body for the email you want to send', required: true, + formatProperty: 'body_type', }), reply_to: Property.Array({ - displayName: 'Reply-To Email', - description: 'Email address to set as the "Reply-To" header', + displayName: 'Reply To', + description: 'Replies go to these addresses instead of the sender.', required: false, }), sender_name: Property.ShortText({ displayName: 'Sender Name', + description: 'Name shown in the inbox instead of your address.', + placeholder: 'Jane at Acme', required: false, + advanced: true, }), from: Property.ShortText({ - displayName: 'Sender Email', - description: - "The address must be listed in your GMail account's settings", + displayName: 'From', + description: 'A send-as address already set up in your Gmail settings.', + placeholder: 'sales@example.com', required: false, + advanced: true, }), attachments: Property.Array({ displayName: 'Attachments', @@ -83,25 +101,26 @@ export const gmailSendEmailAction = createAction({ properties: { file: Property.File({ displayName: 'File', - description: 'File to attach to the email you want to send.', required: true, }), name: Property.ShortText({ displayName: 'Attachment Name', - description: 'In case you want to change the name of the attachment.', + description: 'Overrides the uploaded file name.', + placeholder: 'report.pdf', required: false, }), }, }), in_reply_to: Property.ShortText({ - displayName: 'In reply to', - description: 'Reply to this Message-ID', + displayName: 'In Reply To', + description: 'Message-ID header of the email to thread this under.', required: false, + advanced: true, }), draft: Property.Checkbox({ - displayName: 'Create draft', - description: 'Create draft without sending the actual email', - required: true, + displayName: 'Save as Draft', + description: 'Save to Drafts instead of sending.', + required: false, defaultValue: false, }), }, @@ -126,20 +145,15 @@ export const gmailSendEmailAction = createAction({ ); const cc = context.propsValue['cc']?.filter((email) => email !== ''); const bcc = context.propsValue['bcc']?.filter((email) => email !== ''); + const isPlainText = context.propsValue.body_type === 'plain_text'; const mailOptions: Mail.Options = { to: receiver.join(', '), // Join all email addresses with a comma cc: cc ? cc.join(', ') : undefined, bcc: bcc ? bcc.join(', ') : undefined, subject: `=?UTF-8?B?${subjectBase64}?=`, replyTo: replyTo ? replyTo.join(', ') : '', - text: - context.propsValue.body_type === 'plain_text' - ? context.propsValue['body'] - : undefined, - html: - context.propsValue.body_type === 'html' - ? context.propsValue['body'] - : undefined, + text: isPlainText ? context.propsValue['body'] : undefined, + html: isPlainText ? undefined : context.propsValue['body'], attachments: [], }; let threadId = undefined; @@ -158,7 +172,7 @@ export const gmailSendEmailAction = createAction({ userId: 'me', q: `Rfc822msgid:${context.propsValue.in_reply_to}`, }); - threadId = messages.data.messages?.[0].threadId; + threadId = messages.data.messages?.[0]?.threadId; } const senderEmail = diff --git a/packages/pieces/community/gmail/src/lib/common/props.ts b/packages/pieces/community/gmail/src/lib/common/props.ts index 5ed765935cc6..da6f165656e3 100644 --- a/packages/pieces/community/gmail/src/lib/common/props.ts +++ b/packages/pieces/community/gmail/src/lib/common/props.ts @@ -6,29 +6,30 @@ import { gmail as googleGmail } from '@googleapis/gmail'; export const GmailProps = { from: Property.ShortText({ - displayName: 'Email sender', - description: - 'Optional filteration, leave empty to filter based on the email sender', + displayName: 'From', + description: 'Only emails sent from this address.', + placeholder: 'sender@example.com', required: false, defaultValue: '', }), to: Property.ShortText({ - displayName: 'Email recipient', - description: - 'Optional filteration, leave empty to filter based on the email recipient', + displayName: 'To', + description: 'Only emails sent to this address.', + placeholder: 'you@example.com', required: false, defaultValue: '', }), subject: Property.ShortText({ - displayName: 'Email subject', - description: 'The email subject', + displayName: 'Subject', + description: 'Only emails whose subject contains this text.', + placeholder: 'Invoice', required: false, defaultValue: '', }), category: Property.StaticDropdown({ displayName: 'Category', description: - 'Optional filteration, leave unselected to filter based on the email category', + 'Only emails in this inbox tab, such as Primary or Promotions.', required: false, options: { disabled: false, @@ -47,14 +48,14 @@ export const GmailProps = { displayName?: string; description?: string; required: R; + icon?: string; }) => Property.Dropdown({ auth: gmailAuth, displayName: overrides.displayName ?? 'Label', - description: - overrides.description ?? - 'Optional filteration, leave unselected to filter based on the email label', + description: overrides.description ?? 'Only emails carrying this label.', required: overrides.required, + icon: overrides.icon, defaultValue: '', refreshers: [], options: async ({ auth }) => { @@ -62,19 +63,27 @@ export const GmailProps = { return { disabled: true, options: [], - placeholder: 'please authenticate first', + placeholder: 'Please connect your Gmail account first', }; } - const response = await GmailRequests.getLabels(auth); + try { + const response = await GmailRequests.getLabels(auth); - return { - disabled: false, - options: response.body.labels.map((label) => ({ - label: label.name, - value: label, - })), - }; + return { + disabled: false, + options: response.body.labels.map((label) => ({ + label: label.name, + value: label, + })), + }; + } catch (error) { + return { + disabled: true, + options: [], + placeholder: 'Could not load labels. Check your connection.', + }; + } }, }), unread: (required = false) => @@ -87,7 +96,7 @@ export const GmailProps = { message: Property.Dropdown({ displayName: 'Message', description: - 'Select a message from the list or enter a message ID manually.', + 'Pick a recent message, or map its ID from a trigger or Find Email.', required: true, auth: gmailAuth, refreshers: [], @@ -96,7 +105,7 @@ export const GmailProps = { return { disabled: true, options: [], - placeholder: 'Please authenticate first', + placeholder: 'Please connect your Gmail account first', }; } @@ -174,7 +183,7 @@ export const GmailProps = { }), thread: Property.Dropdown({ displayName: 'Thread', - description: 'Select a thread from the list or enter a thread ID manually', + description: 'Pick a recent thread, or paste a thread ID.', required: true, refreshers: [], auth: gmailAuth, @@ -183,7 +192,7 @@ export const GmailProps = { return { disabled: true, options: [], - placeholder: 'Please authenticate first', + placeholder: 'Please connect your Gmail account first', }; } @@ -213,7 +222,7 @@ export const GmailProps = { .slice(0, 10) .map(async (thread: { id: string; snippet?: string }) => { try { - const details = await await gmail.users.threads.get({ + const details = await gmail.users.threads.get({ metadataHeaders: ['Subject'], format: 'metadata', id: thread.id, diff --git a/packages/pieces/community/gmail/src/lib/triggers/new-attachment.ts b/packages/pieces/community/gmail/src/lib/triggers/new-attachment.ts index b04a1867c223..bda825a5f0e5 100644 --- a/packages/pieces/community/gmail/src/lib/triggers/new-attachment.ts +++ b/packages/pieces/community/gmail/src/lib/triggers/new-attachment.ts @@ -29,45 +29,21 @@ export const gmailNewAttachmentTrigger = createTrigger({ name: 'new_attachment', classification: 'READ', displayName: 'New Attachment', - description: 'Triggers when an email with an attachment arrives.', + description: 'Triggers for each attachment on a new email.', aiMetadata: { description: 'Fires when a new email carrying one or more attachments arrives, optionally narrowed by sender, recipient, subject, label, category, or file extension. Each event represents a single attachment (a multi-attachment email emits one event per attachment) along with its source message.', }, props: { - from: { - ...GmailProps.from, - description: 'Filter by sender email.', - displayName: 'From', - required: false, - }, - to: { - ...GmailProps.to, - description: 'Filter by recipient email.', - displayName: 'To', - required: false, - }, - subject: Property.ShortText({ - displayName: 'Subject Contains', - description: - 'Only trigger for emails containing this text in the subject.', - required: false, - }), - label: GmailProps.label({ - description: 'Filter by Gmail label.', - displayName: 'Label', - required: false, - }), - category: { - ...GmailProps.category, - description: 'Filter by Gmail category.', - displayName: 'Category', - required: false, - }, + from: GmailProps.from, + to: { ...GmailProps.to, advanced: true }, + subject: GmailProps.subject, + label: GmailProps.label({ required: false }), + category: { ...GmailProps.category, advanced: true }, filenameExtension: Property.ShortText({ displayName: 'File Extension', - description: - 'Only trigger for attachments with this file extension (e.g., pdf, jpg, docx).', + description: 'Only attachments with this extension.', + placeholder: 'pdf', required: false, }), }, diff --git a/packages/pieces/community/gmail/src/lib/triggers/new-email.ts b/packages/pieces/community/gmail/src/lib/triggers/new-email.ts index 4de622f6c02f..2bc9686d0f26 100644 --- a/packages/pieces/community/gmail/src/lib/triggers/new-email.ts +++ b/packages/pieces/community/gmail/src/lib/triggers/new-email.ts @@ -19,7 +19,7 @@ export const gmailNewEmailTrigger = createTrigger({ name: 'gmail_new_email_received', classification: 'READ', displayName: 'New Email', - description: 'Triggers when new mail is found in your Gmail inbox', + description: 'Triggers when a new email matching the filters arrives.', aiMetadata: { description: 'Fires when a new email arrives in the connected Gmail account, optionally narrowed by sender, recipient, subject, label, or category filters. Each event represents one newly received message with its parsed contents and thread.', @@ -27,9 +27,9 @@ export const gmailNewEmailTrigger = createTrigger({ props: { subject: GmailProps.subject, from: GmailProps.from, - to: GmailProps.to, + to: { ...GmailProps.to, advanced: true }, label: GmailProps.label({ required: false }), - category: GmailProps.category, + category: { ...GmailProps.category, advanced: true }, }, outputSchema: gmailNewEmailReceivedTriggerOutputSchema, sampleData: {}, diff --git a/packages/pieces/community/gmail/src/lib/triggers/new-label.ts b/packages/pieces/community/gmail/src/lib/triggers/new-label.ts index a252e39503e8..525062290a84 100644 --- a/packages/pieces/community/gmail/src/lib/triggers/new-label.ts +++ b/packages/pieces/community/gmail/src/lib/triggers/new-label.ts @@ -12,7 +12,7 @@ export const gmailNewLabelTrigger = createTrigger({ name: 'new_label', classification: 'READ', displayName: 'New Label', - description: 'Triggers when a new label is created.', + description: 'Triggers when a new label is created in Gmail.', aiMetadata: { description: 'Fires when a new label is created in the connected Gmail account. Each event represents one newly added label not seen on a prior poll.', diff --git a/packages/pieces/community/gmail/src/lib/triggers/new-labeled-email.ts b/packages/pieces/community/gmail/src/lib/triggers/new-labeled-email.ts index a2e301027165..f38836c95893 100644 --- a/packages/pieces/community/gmail/src/lib/triggers/new-labeled-email.ts +++ b/packages/pieces/community/gmail/src/lib/triggers/new-labeled-email.ts @@ -59,7 +59,7 @@ export const gmailNewLabeledEmailTrigger = createTrigger({ name: 'new_labeled_email', classification: 'READ', displayName: 'New Labeled Email', - description: 'Triggers when a label is added to an email', + description: 'Triggers when this label is added to an email.', aiMetadata: { description: 'Fires when the specified label is applied to an email (either newly received with the label or labeled afterward). Each event represents one message that just gained that label, with its parsed contents and label info.', @@ -67,6 +67,7 @@ export const gmailNewLabeledEmailTrigger = createTrigger({ props: { label: GmailProps.label({ required: true, + description: 'Emails that receive this label start the flow.', }), }, outputSchema: newLabeledEmailTriggerOutputSchema, diff --git a/packages/pieces/community/gmail/test/request-approval-body.test.ts b/packages/pieces/community/gmail/test/request-approval-body.test.ts new file mode 100644 index 000000000000..06efcc65bfbf --- /dev/null +++ b/packages/pieces/community/gmail/test/request-approval-body.test.ts @@ -0,0 +1,81 @@ +/// + +import { createMockActionContext } from '@activepieces/pieces-framework'; +import { simpleParser } from 'mailparser'; + +const sendMock = vi.fn().mockResolvedValue({ data: { id: 'sent-message-id' } }); +const listMock = vi.fn().mockResolvedValue({ data: { messages: [] } }); + +vi.mock('@googleapis/gmail', () => ({ + gmail: () => ({ + users: { messages: { send: sendMock, list: listMock } }, + }), +})); + +vi.mock('../src/lib/auth', () => ({ + gmailAuth: {}, + createGoogleClient: vi.fn().mockResolvedValue({}), + getAccessToken: vi.fn().mockResolvedValue('an-access-token'), + getUserEmail: vi.fn().mockResolvedValue('sender@example.com'), +})); + +import { requestApprovalInEmail } from '../src/lib/actions/request-approval-in-email'; + +const RESUME_URL = 'https://ap.test/resume'; + +type ApprovalContext = Parameters[0]; + +function buildContext(propsValue: Record): ApprovalContext { + const context = createMockActionContext({ + propsValue: { + receiver: 'approver@example.com', + subject: 'Please approve', + ...propsValue, + }, + }); + + return { + ...context, + run: { + ...context.run, + createWaitpoint: async () => ({ id: 'waitpoint-1', resumeUrl: RESUME_URL }), + waitForWaitpoint: () => undefined, + }, + } as unknown as ApprovalContext; +} + +async function sentHtml(): Promise { + expect(sendMock).toHaveBeenCalledTimes(1); + const raw = sendMock.mock.calls[0][0].requestBody.raw as string; + const mail = await simpleParser( + Buffer.from(raw.replace(/-/g, '+').replace(/_/g, '/'), 'base64') + ); + return typeof mail.html === 'string' ? mail.html : ''; +} + +describe('request approval in email — body type', () => { + beforeEach(() => { + sendMock.mockClear(); + listMock.mockClear(); + }); + + test('a plain text body is escaped and keeps its line breaks', async () => { + await requestApprovalInEmail.run( + buildContext({ body_type: 'plain_text', body: 'a < b & c\nline 2' }) + ); + + const html = await sentHtml(); + expect(html).toContain('a < b & c
line 2'); + expect(html).toContain(`${RESUME_URL}/confirm`); + }); + + test('a rich text body is sent as markup', async () => { + await requestApprovalInEmail.run( + buildContext({ body_type: 'html', body: '

bold

' }) + ); + + const html = await sentHtml(); + expect(html).toContain('

bold

'); + expect(html).toContain(`${RESUME_URL}/confirm`); + }); +}); From 0ec9584ad14b23f6960093ca9dcd9f524239f19b Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 24 Sep 2026 21:21:25 +0300 Subject: [PATCH 7/9] fix(ci): pull smoke-test MinIO images from Chainguard (#15788) --- benchmark/docker-compose.minio.yml | 33 ++++++++++++++++-------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/benchmark/docker-compose.minio.yml b/benchmark/docker-compose.minio.yml index 5888f87ba32c..b305801190ab 100644 --- a/benchmark/docker-compose.minio.yml +++ b/benchmark/docker-compose.minio.yml @@ -8,32 +8,35 @@ # bucket must already exist — minio-init creates it before app/worker start. services: minio: - image: quay.io/minio/minio:RELEASE.2024-09-22T00-33-43Z + image: cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1 command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin volumes: - minio_data:/data - healthcheck: - test: ['CMD', 'mc', 'ready', 'local'] - interval: 3s - timeout: 5s - retries: 20 networks: - bench - minio-init: - image: quay.io/minio/mc:RELEASE.2024-09-16T17-43-14Z + minio-ready: + image: cgr.dev/chainguard/minio-client@sha256:b8b144ab34694ecea25aa352c4be9de4c26ee2a02701521dce02ee5593c57338 depends_on: minio: - condition: service_healthy - entrypoint: > - /bin/sh -c " - mc alias set local http://minio:9000 minioadmin minioadmin && - mc mb --ignore-existing local/activepieces && - echo 'bucket activepieces ready' - " + condition: service_started + environment: + MC_HOST_local: http://minioadmin:minioadmin@minio:9000 + command: ready local + networks: + - bench + + minio-init: + image: cgr.dev/chainguard/minio-client@sha256:b8b144ab34694ecea25aa352c4be9de4c26ee2a02701521dce02ee5593c57338 + depends_on: + minio-ready: + condition: service_completed_successfully + environment: + MC_HOST_local: http://minioadmin:minioadmin@minio:9000 + command: mb --ignore-existing local/activepieces networks: - bench From 9e493a87f471d38b40bafaaf82bbc57b266f6eda Mon Sep 17 00:00:00 2001 From: Hazem Adel Date: Thu, 24 Sep 2026 21:23:35 +0300 Subject: [PATCH 8/9] fix(build): stop building web source maps the image build deletes (#15789) --- Dockerfile | 11 ++++++----- brain/knowledge/engineering/ci-pr-review-hygiene.md | 4 ++++ packages/web/vite.config.mts | 3 ++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index b372372fa0d0..3a2934e2ef80 100644 --- a/Dockerfile +++ b/Dockerfile @@ -95,11 +95,12 @@ COPY . . # Build frontend, engine, server API, and worker RUN NODE_OPTIONS=--max-old-space-size=4096 npx turbo run build --filter=web --filter=@activepieces/engine --filter=api --filter=worker -# The web build emits hidden source maps (vite build.sourcemap='hidden') used to -# symbolicate production stack traces in Sentry/BetterStack error tracking. Upload -# them here (cloud CI, guarded by a token) BEFORE stripping, then always remove the -# .map files so source is never served from the shipped image (self-hosted too). -# TODO(cloud-ci): inject + upload maps with sentry-cli when SENTRY_AUTH_TOKEN is set. +# Source maps are off unless AP_BUILD_SOURCEMAP=true: generating them costs ~1GB of +# peak heap in the web build (3.5GB vs 2.5GB measured) for ~21MB of output that this +# layer then deletes, which is what OOM-killed the image build. A build that opts in +# must upload them BEFORE this line; the delete stays so source is never served from +# the shipped image. +# TODO(cloud-ci): set AP_BUILD_SOURCEMAP=true and upload with sentry-cli when SENTRY_AUTH_TOKEN is set. RUN find dist/packages/web -name '*.map' -delete # Generate migration manifest (ordered list of migration names) for image-tag-based rollback diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md index 65f3e930e258..121f51ef3613 100644 --- a/brain/knowledge/engineering/ci-pr-review-hygiene.md +++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md @@ -84,3 +84,7 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def - **Mintlify-editor PRs arrive with `untitled-page*.mdx` at the docs root and `(1)`/`(2)`-suffixed media, wired into `docs.json` under those names.** The bot commits whatever the web editor autosaved, so pages land as `docs/untitled-page-5.mdx` rather than in the folder their nav group implies, and re-uploaded assets keep the browser's download-collision suffix (`data-flow-(1).mp4`, `01---insert-panel-(1.2x).mp4`). Mintlify's own link check passes — the names are internally consistent — so nothing flags it. Rename the files, fix the `docs.json` entries, then diff the *flattened nav tree* against `main` rather than reading the raw JSON diff: the editor also reindents, which buried a real change (the Enterprise Control `Security` group demoted to a child of `Guides`) inside 242 lines of whitespace churn on that PR. - **After rewriting a lower branch of a stack, the `--onto` upstream you need is that branch's *old* tip, and `@{1}` is where to read it.** Every commit above a rewritten branch has to be replayed with `git rebase --onto `, and the old tip stops being reachable by name the moment the rebase finishes. Guessing a SHA from an earlier terminal scroll either fails outright with `invalid upstream` or, worse, succeeds against the wrong base and replays a handful of commits instead of all of them, which surfaces as a pile of conflicts the branch has with itself. `git rev-parse @{1}` reads it from the reflog every time. Re-run the whole `--onto` chain top-down after any lower-branch amend, and confirm afterwards that `git log --format=%s main..` has no duplicate subjects. - **Never commit a shared file onto a lower branch of a stack by copying it in from the stack top — you drag the upper branches' edits down with it.** Adding one gotcha bullet to a brain page while sitting on the top branch, then `git checkout -- ` after switching to the base branch, brings the *whole* file across, so every bullet the upper branches added to that page is silently duplicated onto the base. The commit stat is the tell: a one-line edit that reports six insertions is not a one-line edit. Switch branches first, re-apply the edit against **that branch's own copy** of the file, and re-check the stat before committing. +- **Cherry-picking a merged PR onto a release branch: the commit-msg hook rejects it, and `breaking-changes.mdx` conflicts pull in other people's entries.** GitHub writes the squash header server-side, so `fix(flows): … instead of reverting them (#14899)` never faced commitlint — replaying it locally trips the 100-character header limit and `git cherry-pick --continue` dies in husky with the working tree half-applied. Finish it with `git commit --no-verify -C ` rather than rewording, so the release branch keeps the same subject as `main`. The `Unreleased` section of `docs/install/reference/breaking-changes.mdx` conflicts on almost every pick because it accumulates entries from unrelated `main` work: take only the section your own commit added. **Take it by its own heading, not by "everything after the marker" — the incoming side of that hunk usually runs on into `main`'s copy of an already-published `## X.Y.Z` section, and pasting the tail duplicates ~200 lines of published notes that no test, lint or typecheck will ever notice.** The reliable move is to rebuild the file: `git checkout origin/release/ -- docs/install/reference/breaking-changes.mdx`, then insert your entry under its own `## ` heading above the previous release's, and confirm with `git diff --stat` that only your lines are added and `grep -c '^## '` that no version heading appears twice. Then run `bun install` before pushing — the release branch's `bun.lock` is usually stale against its own `package.json`s, and the pick drags those stale piece versions along until install corrects them; `git diff --stat origin/release/ -- bun.lock` should come back as just the packages your pick actually bumped. Expect the **PR size** gate to block it too: the picked commits drag their own upstream tests along (769/600 in `server/api` for two picks), and splitting a replay of already-reviewed work is the wrong answer — add `large-pr-ok`. +- **`api#test-ce` can exit 1 with every test passing — read the `Errors` line, not just `Tests`.** Vitest fails the run on *unhandled* errors too, so the summary reads `Test Files 92 passed`, `Tests 1076 passed`, `Errors 3 errors` and turbo reports `Failed: api#test-ce`. The recurring one is `TypeError: socket.destroySoon is not a function` at `Timeout.forceClose` in `@hono/node-server`, attributed to `test/integration/ce/mcp/mcp-platform-project-selection.test.ts` — a teardown race after the test already finished, not a failure of whatever you changed. Before chasing it, scroll to the `Unhandled Errors` block and check whether any test actually failed; if none did and the stack is in `forceClose`, re-run the job (`gh run rerun --failed`) rather than editing code. Note the log section only has the whole `main` job, so `gh run view --job --log-failed` buries it under thousands of lines of passing-test stderr — grep for `Unhandled Errors` or `Test Files`. +- **MinIO pulled its images from Docker Hub and then quay.io; the smoke test's S3 leg uses Chainguard's now.** Both moves failed the same way, `pull access denied` on Docker Hub and then `unauthorized: access to the requested resource is not authorized` on `quay.io/minio/*`. That reads like a rate limit or a missing secret, but it's neither, so no `docker login` or retry helps. `benchmark/docker-compose.minio.yml` pins `cgr.dev/chainguard/minio` and `cgr.dev/chainguard/minio-client` by digest. They are distroless, with no shell and no `mc` in the server image, so readiness is a one-shot `mc ready local` job and the bucket is a plain `mc mb`, both through `MC_HOST_local`. Don't add a healthcheck or an `sh -c` entrypoint there. This blocks **every** self-hosted release, because the S3 leg runs inside the release's own preflight. Check a vanished image with `docker manifest inspect ` before assuming credentials. +- **The web build spent ~1GB of peak heap on source maps the next Docker layer deleted.** `vite.config.mts` had `sourcemap: 'hidden'` for a Sentry upload that was never implemented — the Dockerfile ran `find dist/packages/web -name '*.map' -delete` right after, under its own unfinished `TODO(cloud-ci)`. Measured: **3.54GB peak RSS / 18.1s with maps, 2.53GB / 12.0s without** (382 map files, 21.5MB, for a 5MB main chunk). On a 2-vCPU/8GB depot runner, where turbo runs `web`, `api`, `worker` and `engine` builds concurrently in one container, that GB was the margin — `web:build` died with `Ineffective mark-compacts near heap limit` and took the release's preflight with it. It is **marginal, not deterministic**: the same commit builds fine on a retry, so a green build does not mean the headroom is there. Now behind `AP_BUILD_SOURCEMAP=true` (default off). Whoever implements the Sentry upload must set that flag *and* upload before the delete line. diff --git a/packages/web/vite.config.mts b/packages/web/vite.config.mts index 8e66be6a83d3..a6a9d615cf80 100644 --- a/packages/web/vite.config.mts +++ b/packages/web/vite.config.mts @@ -10,6 +10,7 @@ import customHtmlPlugin from './vite-plugins/html-plugin'; export default defineConfig(({ command, mode }) => { const isDev = command === 'serve' || mode === 'development'; + const emitSourcemap = process.env.AP_BUILD_SOURCEMAP === 'true'; const AP_TITLE = 'Activepieces'; const AP_FAVICON = 'https://activepieces.com/favicon.ico'; @@ -164,7 +165,7 @@ export default defineConfig(({ command, mode }) => { outDir: '../../dist/packages/web', emptyOutDir: true, reportCompressedSize: true, - sourcemap: 'hidden', + sourcemap: emitSourcemap ? 'hidden' : false, commonjsOptions: { transformMixedEsModules: true, }, From 6b86a38cde238ef9f41937fe1b3ab500aea7dab6 Mon Sep 17 00:00:00 2001 From: Amr Elmohamady Date: Thu, 24 Sep 2026 22:57:37 +0300 Subject: [PATCH 9/9] fix(engine): remove per-sandbox piece LRU (#15786) Co-authored-by: Claude Opus 4.7 --- brain/knowledge/execution-runtime/workers.md | 12 ++++ .../engine/src/lib/helper/piece-loader.ts | 61 +------------------ 2 files changed, 13 insertions(+), 60 deletions(-) diff --git a/brain/knowledge/execution-runtime/workers.md b/brain/knowledge/execution-runtime/workers.md index ab6b93ba8629..9f911b8a737b 100644 --- a/brain/knowledge/execution-runtime/workers.md +++ b/brain/knowledge/execution-runtime/workers.md @@ -44,6 +44,18 @@ The deep `Resolver`/`Runtime` concurrency and bundle-caching model lives on the - **Self-contained piece bundles remove cross-piece dedup, so the per-run spike now scales with how many *fat* pieces one flow touches.** A CDN bundle inlines everything: its `package.json` declares **no dependencies** and every `require()` in `src/bundle.cjs` is a Node built-in. Good for install size and it is what finally kills the multi-copy `@activepieces/shared` problem — but two pieces that both use `axios`/`googleapis` no longer share one hoisted copy, they each carry their own. Measured on staging Aug 2026 (`AP_USE_CDN_FOR_BUNDLES=true`, isolate mode): 9 fat bundled pieces in one flow = ~15 MB of uncompressed JS, engine at **926 MB RSS / 788 MB heapUsed** against a V8 `heap_size_limit` of **1216 MB** (`--max-old-space-size=1024` from the default `SANDBOX_MEMORY_LIMIT`) inside a **1 GiB** container already holding a 247 MB worker + 74 MB pm2 → `MEMORY_LIMIT_EXCEEDED`, reproducibly, in ~8 s. The identical flow succeeds with the container raised to 3 GiB. This is the deterministic reproducer for the standing over-commit above: a per-run spike, not accumulation. Sizing rule: budget the engine ceiling against *container minus worker minus pm2*, and treat "many fat pieces in one flow" as the spike driver now that bundles don't share. - **The cost of a resident `@activepieces/shared` copy is zod schema construction, not data — ~40 MB per copy, and reused engines collect one per piece version.** Heap-snapshotted a dedicated-worker engine at 596 MB RSS / 402 MB heap (0.88.1 beta, pre-CDN-bundles, `AP_REUSE_SANDBOX`), Aug 2026: after a double forced GC, 393 MB survived, and the histogram was **2.05 M anonymous closures (109 MB) + 143 MB of `(object properties)` arrays + 359 k `system/Context` scopes (18 MB)** — instantiated module graphs, not retained run data (only 16 k distinct functions back those 2 M closures). Every sampled retainer path ended `require.cache → @activepieces+shared@/…/.js → exports → ._def → get shape → refine/pipe/optional/brand closures`: `shared` eagerly builds ~500 top-level zod DTO trees at import, and zod v4 attaches per-instance accessor/method closures (120 k `get`, 67 k `set`, 31 k `validate` in that one heap). The engine held **9 shared versions at once** (0.37 → 0.96.2) because each piece bundle pins its own, and reuse + `import()` pins them forever — including **3 versions of `piece-hubspot` and 2 of `piece-slack` simultaneously**, one per flow-pinned piece version, so republish/upgrade churn multiplies copies of the *same* piece. Budget ≈ 60–70 MB engine baseline + ~40 MB per distinct resident shared copy. Two independent fixes attack it: CDN self-contained bundles (no shared inside pieces at all, see above) and require()-based piece LRU eviction; snapshot mechanics for redoing this measurement are in the `profile-worker-memory` skill. Follow-up exact measurement (same process at 542 MB heap, 9 shared copies, graph-cut retained sizes): one cleanly-severable copy weighs **41.3 MB / 445 k nodes**, but the other 8 each show <1 MB *exclusive* retained because copies are **co-retained as clusters, and the pin is the ESM module map, not `require.cache`**: deleting every `require.cache` entry under both `.bun` store roots frees only ~65 MB of 553 MB, while blocking the ESM `ModuleWrap`/`SyntheticModule` entries too frees **406 MB** (engine-only floor: 147 MB, which includes main.js's own bundled shared). Retainer chain: `Global handles → SyntheticModule (piece import()) → Piece object → action run closures → context:pieces_framework_1 / shared_1 (whole exports)` — every action closure captures its module scope, so a piece and its framework+shared copies live and die together. Consequence: any eviction scheme must make the *piece entry itself* collectable (hence require()-based loading in the LRU fix — an import()ed entry can never be dropped); purging shared's cache entries alone reclaims ~nothing. - **~1.3 GB is the idle floor before a single flow runs.** Same box, freshly booted, zero runs: container 1.325 GiB, of which the API process alone is ~1.18 GB and the worker ~213 MB. Sizing a container off "what a flow needs" is wrong by more than a gigabyte — budget the floor first, then add the sandbox ceiling above. +- **The require()-based LRU in `piece-loader.ts` (PR #14859) makes both CPU and memory *worse* than not evicting, in both the CDN-bundle and raw-npm worlds, and was removed.** The load-path switch from `await import(piecePath)` to `require(piecePath)` doesn't itself reclaim any memory — the resulting loader still holds every loaded piece in `require.cache` until the process exits — but it is the precondition for any eviction mechanism at all, since ESM `ModuleWrap` handles are pinned to the realm regardless of `require.cache` state. The LRU cap-and-evict wrapper Chaker put on top of it was where the memory reclaim was supposed to come from, and it doesn't work in practice. Directly A/B tested `require()` vs `import()` with no eviction on 300-iter rotations: 296 vs 331 MB RSS on CDN, 1963 vs 1969 MB on pre-CDN — the two mechanisms plateau within 6-35 MB of each other, neither reclaims. Two facts made it look like it helped: **(a)** Chaker's PR compared "require() + LRU cap=10" against `await import()` (the old unevictable path), so the entire measured memory win came from the `import → require` switch, not from the LRU; **(b)** his fixture loaded 20 fabricated pieces uniquely (each loaded once, ~10 evictions total), which never exercises the rotate-and-reload churn that real fleets hit. The shipped constant was also `MAX_LOADED_PIECES = 5`, not the 10 in the PR title — a same-day follow-up commit (`a74fc81d58`, empty body) dropped it to 5 after the anchor-pinning bug (`bfce1133b7` — `createRequire(__filename)` anchor held all loaded pieces via its `children` array, engine OOM at 1.1 GB despite the cap) got fixed and left OOM headroom concerns; both landed inside the squash-merge (`71bd79c74d`), so the merged PR reads as if cap=10 shipped. Measured Sep 2026 with a standalone Node harness that inlines the exact eviction algorithm (`scratchpad/lru-bench/`), 300 iterations rotating 15 real published bundles: + + | scenario | cap | iters | evictions | CPU/load mean | CPU/load p95 | RSS peak | RSS plateau (post-GC) | evict share of loader CPU | + |---|---:|---:|---:|---:|---:|---:|---:|---:| + | CDN self-contained | 5 (shipped) | 3000 | 2,995 | 63 ms | 80 ms | 315 MB | 313 MB | ~68% | + | CDN self-contained | 10 | 3000 | 2,990 | 77 ms | 89 ms | 330 MB | 326 MB | ~68% | + | CDN self-contained | 100 (no evict) | 3000 | 0 | 0.3 ms | 0 ms | 303 MB | 289 MB (plateau iter 20) | 0% | + | Pre-CDN raw npm | 5 | 300 | 295 | **2131 ms** | 4552 ms | 2209 MB | 2149 MB | 73% | + | Pre-CDN raw npm | 10 | 300 | 290 | **2513 ms** | 3075 ms | 2659 MB | 2659 MB | 77% | + | Pre-CDN raw npm | 100 (no evict) | 300 | 0 | 35 ms | 47 ms | 1978 MB | 1978 MB (plateau iter 20) | 0% | + + Three overturning findings. **(1)** No-eviction wins on both axes in both worlds — CDN saves 21 MB and 20-25× CPU/load; pre-CDN saves 171-681 MB (yes, less RSS *without* the cap) and 60× CPU/load. The eviction-and-reload churn allocates faster than the doomed-subtree GC + forced `global.gc()` can reclaim, and cap=10 is worse than cap=5 because a bigger survivor require-graph costs more per `collectReachableModules` walk. **(2)** The eviction path is 60-77% of total loader CPU whenever it fires. **(3)** Per-eviction cost is dominated by `global.gc()` on the just-freed piece's object graph (V8 would collect it on its own schedule without the eager stall) — per-piece variance is 4.5× on real pieces (`date-helper` 8.8 ms → `gmail` 40 ms in the CDN world; pre-CDN pieces run 2–4 s each because `collectReachableModules` walks ~1,425 modules per piece instead of 1). Fit shape: **reused sandbox** (either `AP_REUSE_SANDBOX=true`, or `SANDBOX_CODE_ONLY`/`UNSANDBOXED` which default to reuse unless `AP_REUSE_SANDBOX=false` explicitly overrides — see `canReuseSandbox` in `sandbox-manager.ts`) + a fleet whose sandbox lifetime cycles through more pieces than the cap — Alan's 0.91.2 config with 140+ distinct pieces (pieces-report CSV, Sep 2026, CPU-per-worker sharply up vs 0.86), and Funding Societies before them on 0.88.3 (same complaint, silenced by reverting #14889 not by #14859). Historical note: the same shape has been tried and reverted repeatedly — `1c6efa0389` (fresh child per piece, reverted as #15209), `9a98fd05e9` (killed-per-operation piece-host), `3f6e32d885` (size-based cache eviction that kept `import()`, ineffective). The clean shape is the `import → require` switch alone; the LRU wrapper on top adds tax without a matching benefit and is what shipped removed in `fix/piece-loader-remove-lru`. - **A job-data schema is a transport contract, not a validator — a constraint the piece never had turns a working saved flow into an enqueue-time failure.** Job payloads that carry piece input are shaped by props that were only ever checked in the builder, and often not even there: `Property.Number` accepts `0` and `-1`, a `DynamicProperties` block can hold a value from an older props shape, and `Property.Array` yields whatever was typed. Writing the natural-looking `z.number().int().positive()` or a bare `z.enum([...])` in the job data rejects those payloads at `jobQueue.add` — before the work is attempted, with a validation message no user can act on, on a flow that ran yesterday. Mirror the props' looseness (`z.number()`), and where a value must be one of a set, `z.enum([...]).optional().catch(undefined)` degrades an unknown value to unset instead of failing the job. Validate in the piece if anywhere; the queue is the wrong place to discover bad input. - **Adding any `WorkerJobType` means five edits, and the compiler only catches three.** In `core/execution/.../job-data.ts`: the enum, the `getDefaultJobPriority` switch (exhaustive with no `default`, so this one is a hard compile error), and the `JobData` union. Then `OneTimeJobAddParams` in `job-queue.ts`, or `jobQueue.add` rejects the payload. Then the lazy handler entry in `worker/.../execute/job-registry.ts` — **that last one is the silent miss**: everything compiles and the job enqueues, it just never runs. Also give the job data a `projectId`, `null` if it has none, because `job-broker.ts` reads `migratedData.projectId as string` for every dequeued job and a member without the property breaks that file and `job-data-migrations.ts` rather than your own. `ExecuteAgentRunJobData` is the shape to copy: nullable `projectId`, and it already flows through that cast. diff --git a/packages/server/engine/src/lib/helper/piece-loader.ts b/packages/server/engine/src/lib/helper/piece-loader.ts index 8e8211c2a0d5..b5e01946a597 100644 --- a/packages/server/engine/src/lib/helper/piece-loader.ts +++ b/packages/server/engine/src/lib/helper/piece-loader.ts @@ -17,7 +17,7 @@ export const pieceLoader = { devPieces, }) const piecePath = await pieceLoader.getPiecePath({ packageName, devPieces }) - const module = loadAndCapPieces(piecePath) + const module = createRequire(__filename)(piecePath) const piece = extractPieceFromModule({ module, @@ -131,65 +131,6 @@ export const pieceLoader = { }, } -const MAX_LOADED_PIECES = 5 -const engineRequire = createRequire(__filename) -const loadedPieceEntries = new Map() -const baselineModuleIds = new Set(Object.keys(engineRequire.cache)) - -function loadAndCapPieces(piecePath: string): Record { - const disposableRequire = createRequire(__filename) - const resolvedEntry = disposableRequire.resolve(piecePath) - const loadedModule: Record = disposableRequire(resolvedEntry) - loadedPieceEntries.delete(resolvedEntry) - loadedPieceEntries.set(resolvedEntry, true) - if (loadedPieceEntries.size > MAX_LOADED_PIECES) { - const leastRecentEntry = loadedPieceEntries.keys().next().value - if (!isNil(leastRecentEntry)) { - loadedPieceEntries.delete(leastRecentEntry) - evictPieceSubtree(leastRecentEntry) - global.gc?.() - } - } - return loadedModule -} - -function evictPieceSubtree(evictedEntry: string): void { - const survivorReachable = collectReachableModules([...loadedPieceEntries.keys()]) - const evictedReachable = collectReachableModules([evictedEntry]) - const doomed = new Set([...evictedReachable].filter((id) => !survivorReachable.has(id) && !baselineModuleIds.has(id))) - if (doomed.size === 0) { - return - } - for (const id of Object.keys(engineRequire.cache)) { - const mod = engineRequire.cache[id] - if (!isNil(mod) && !doomed.has(id)) { - mod.children = mod.children.filter((child) => !doomed.has(child.id)) - } - } - if (!isNil(engineRequire.main)) { - engineRequire.main.children = engineRequire.main.children.filter((child) => !doomed.has(child.id)) - } - for (const id of doomed) { - Reflect.deleteProperty(engineRequire.cache, id) - } -} - -function collectReachableModules(rootIds: string[]): Set { - const reachable = new Set() - const stack = rootIds.filter((id) => !isNil(engineRequire.cache[id])) - while (stack.length > 0) { - const id = stack.pop() - if (isNil(id) || reachable.has(id)) { - continue - } - reachable.add(id) - for (const child of engineRequire.cache[id]?.children ?? []) { - stack.push(child.id) - } - } - return reachable -} - async function findInDistFolder(packageName: string): Promise { const sourcePiecesPath = path.resolve('packages/pieces') if (!await utils.folderExists(sourcePiecesPath)) {