diff --git a/credentials/CloudinaryApi.credentials.ts b/credentials/CloudinaryApi.credentials.ts index 16926cd..74e128f 100644 --- a/credentials/CloudinaryApi.credentials.ts +++ b/credentials/CloudinaryApi.credentials.ts @@ -2,12 +2,14 @@ import { IAuthenticateGeneric, ICredentialTestRequest, ICredentialType, + Icon, INodeProperties, } from 'n8n-workflow'; export class CloudinaryApi implements ICredentialType { name = 'cloudinaryApi'; displayName = 'Cloudinary API'; + icon: Icon = { light: 'file:cloudinary.svg', dark: 'file:cloudinary.dark.svg' }; documentationUrl = 'https://cloudinary.com/documentation/developer_onboarding_faq_find_credentials'; properties: INodeProperties[] = [ { diff --git a/credentials/cloudinary.dark.svg b/credentials/cloudinary.dark.svg new file mode 100644 index 0000000..73949c8 --- /dev/null +++ b/credentials/cloudinary.dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/credentials/cloudinary.svg b/credentials/cloudinary.svg new file mode 100644 index 0000000..3b8c0d0 --- /dev/null +++ b/credentials/cloudinary.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/nodes/Cloudinary/Cloudinary.node.json b/nodes/Cloudinary/Cloudinary.node.json index 4cc8649..0876d49 100644 --- a/nodes/Cloudinary/Cloudinary.node.json +++ b/nodes/Cloudinary/Cloudinary.node.json @@ -1,5 +1,5 @@ { - "node": "n8n-nodes-base.cloudinary", + "node": "n8n-nodes-cloudinary.cloudinary", "nodeVersion": "1.0", "codexVersion": "1.0", "categories": ["Data & Storage", "Marketing & Content"], diff --git a/nodes/Cloudinary/Cloudinary.node.ts b/nodes/Cloudinary/Cloudinary.node.ts index 82f0ce9..943cd43 100644 --- a/nodes/Cloudinary/Cloudinary.node.ts +++ b/nodes/Cloudinary/Cloudinary.node.ts @@ -3,6 +3,9 @@ import { INodeTypeDescription, IExecuteFunctions, INodeExecutionData, + JsonObject, + NodeApiError, + NodeConnectionTypes, NodeOperationError, } from 'n8n-workflow'; import { cloudinaryProperties } from './descriptions'; @@ -13,7 +16,7 @@ export class Cloudinary implements INodeType { description: INodeTypeDescription = { displayName: 'Cloudinary', name: 'cloudinary', - icon: 'file:cloudinary.svg', + icon: { light: 'file:cloudinary.svg', dark: 'file:cloudinary.dark.svg' }, group: ['transform'], // v1 exposed the Video Player as flat `player*` params; v2 regrouped them into // collections (see widget.fields.ts). Both schemas ship side by side, gated by @@ -32,8 +35,8 @@ export class Cloudinary implements INodeType { defaults: { name: 'Cloudinary', }, - inputs: ['main'], - outputs: ['main'], + inputs: [NodeConnectionTypes.Main], + outputs: [NodeConnectionTypes.Main], credentials: [ { name: CREDENTIAL_TYPE, @@ -84,7 +87,12 @@ export class Cloudinary implements INodeType { }); continue; } - throw error; + // Both constructors pass an already-wrapped error of their own type + // through untouched, so handler-thrown errors keep their context. + if (error instanceof NodeApiError) { + throw new NodeApiError(this.getNode(), error as unknown as JsonObject, { itemIndex: i }); + } + throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i }); } } diff --git a/nodes/Cloudinary/cloudinary.dark.svg b/nodes/Cloudinary/cloudinary.dark.svg new file mode 100644 index 0000000..73949c8 --- /dev/null +++ b/nodes/Cloudinary/cloudinary.dark.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/nodes/Cloudinary/cloudinary.svg b/nodes/Cloudinary/cloudinary.svg index 07ec75d..3b8c0d0 100644 --- a/nodes/Cloudinary/cloudinary.svg +++ b/nodes/Cloudinary/cloudinary.svg @@ -1,8 +1,14 @@ - - - - - - - + + + + + + + + + + + + + diff --git a/nodes/Cloudinary/cloudinary.utils.test.ts b/nodes/Cloudinary/cloudinary.utils.test.ts index c182767..5ed9d8e 100644 --- a/nodes/Cloudinary/cloudinary.utils.test.ts +++ b/nodes/Cloudinary/cloudinary.utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import type { IDataObject } from 'n8n-workflow'; +import type { IDataObject, INode } from 'n8n-workflow'; import { buildSearchExpression, createMultipartBody, @@ -258,70 +258,80 @@ describe('generateCloudinarySignature', () => { }); describe('metadataToPipeString', () => { + const node = { + id: '1', + name: 'Cloudinary', + type: 'n8n-nodes-cloudinary.cloudinary', + typeVersion: 2, + position: [0, 0], + parameters: {}, + } as INode; + const toPipeString = (input: IDataObject | string) => metadataToPipeString(input, node); + it('joins scalar key/value pairs with pipes', () => { - expect(metadataToPipeString({ a: '1', b: '2' })).toBe('a=1|b=2'); + expect(toPipeString({ a: '1', b: '2' })).toBe('a=1|b=2'); }); it('renders array values as a bracketed list of quoted strings', () => { - expect(metadataToPipeString({ colors: ['red', 'blue'] })).toBe('colors=["red","blue"]'); + expect(toPipeString({ colors: ['red', 'blue'] })).toBe('colors=["red","blue"]'); }); it('quotes numeric array elements as strings', () => { - expect(metadataToPipeString({ sizes: [1, 2] } as unknown as IDataObject)).toBe( + expect(toPipeString({ sizes: [1, 2] } as unknown as IDataObject)).toBe( 'sizes=["1","2"]', ); }); it('renders an empty array as empty brackets', () => { - expect(metadataToPipeString({ tags: [] })).toBe('tags=[]'); + expect(toPipeString({ tags: [] })).toBe('tags=[]'); }); it('parses a JSON string input', () => { - expect(metadataToPipeString('{"a":"1","b":"2"}')).toBe('a=1|b=2'); + expect(toPipeString('{"a":"1","b":"2"}')).toBe('a=1|b=2'); }); it('returns an empty string for an empty object', () => { - expect(metadataToPipeString({})).toBe(''); + expect(toPipeString({})).toBe(''); }); it('throws on invalid JSON string input', () => { - expect(() => metadataToPipeString('{not valid}')).toThrow('Invalid JSON for structured metadata'); + expect(() => toPipeString('{not valid}')).toThrow('Invalid JSON for structured metadata'); }); it('escapes the pipe delimiter in a scalar value', () => { - expect(metadataToPipeString({ note: 'a|b' })).toBe('note=a\\|b'); + expect(toPipeString({ note: 'a|b' })).toBe('note=a\\|b'); }); it('escapes the equals delimiter in a scalar value', () => { - expect(metadataToPipeString({ eq: 'x=y' })).toBe('eq=x\\=y'); + expect(toPipeString({ eq: 'x=y' })).toBe('eq=x\\=y'); }); it('escapes both delimiters and keeps following pairs separable', () => { - expect(metadataToPipeString({ a: 'one=two|three', b: 'ok' })).toBe('a=one\\=two\\|three|b=ok'); + expect(toPipeString({ a: 'one=two|three', b: 'ok' })).toBe('a=one\\=two\\|three|b=ok'); }); it('escapes double quotes in a scalar value', () => { - expect(metadataToPipeString({ q: 'say "hi"' })).toBe('q=say \\"hi\\"'); + expect(toPipeString({ q: 'say "hi"' })).toBe('q=say \\"hi\\"'); }); it('escapes the delimiters (= | ") inside array elements and quote-wraps them', () => { - expect(metadataToPipeString({ tags: ['a|b', 'c=d', 'e"f'] })).toBe( + expect(toPipeString({ tags: ['a|b', 'c=d', 'e"f'] })).toBe( 'tags=["a\\|b","c\\=d","e\\"f"]', ); }); it('keeps following pairs separable when an array element contains a pipe', () => { - expect(metadataToPipeString({ a: ['x|y'], b: 'ok' })).toBe('a=["x\\|y"]|b=ok'); + expect(toPipeString({ a: ['x|y'], b: 'ok' })).toBe('a=["x\\|y"]|b=ok'); }); it('skips null and undefined values rather than emitting key=null', () => { - expect(metadataToPipeString({ a: '1', b: null, c: undefined, d: '2' } as IDataObject)).toBe( + expect(toPipeString({ a: '1', b: null, c: undefined, d: '2' } as IDataObject)).toBe( 'a=1|d=2', ); }); it('stringifies non-string scalars (numbers, booleans)', () => { - expect(metadataToPipeString({ n: 5, flag: true } as unknown as IDataObject)).toBe( + expect(toPipeString({ n: 5, flag: true } as unknown as IDataObject)).toBe( 'n=5|flag=true', ); }); diff --git a/nodes/Cloudinary/cloudinary.utils.ts b/nodes/Cloudinary/cloudinary.utils.ts index dadceee..adeacfc 100644 --- a/nodes/Cloudinary/cloudinary.utils.ts +++ b/nodes/Cloudinary/cloudinary.utils.ts @@ -1,4 +1,4 @@ -import { IDataObject, ApplicationError } from 'n8n-workflow'; +import { IDataObject, INode, NodeOperationError } from 'n8n-workflow'; import { sha256 } from './sha256.utils'; import { CloudinaryCredentials } from './operations/types'; import { version } from '../../package.json'; @@ -190,14 +190,15 @@ const escapeMetadataValue = (value: string): string => value.replace(/([=|"])/g, * matching Cloudinary's multi-value field format. Delimiter characters (`=`, `"`, * `|`) are backslash-escaped inside every value — scalar and list element alike — * so a value containing them can't be misparsed as another field, pair, or list - * boundary. Throws ApplicationError on invalid JSON input. + * boundary. Throws NodeOperationError on invalid JSON input, attributed to the + * calling node. */ -export const metadataToPipeString = (input: IDataObject | string): string => { +export const metadataToPipeString = (input: IDataObject | string, node: INode): string => { let metadata: IDataObject; try { metadata = typeof input === 'object' ? input : (JSON.parse(input) as IDataObject); } catch (error) { - throw new ApplicationError('Invalid JSON for structured metadata'); + throw new NodeOperationError(node, 'Invalid JSON for structured metadata'); } return Object.keys(metadata) .map((key) => { diff --git a/nodes/Cloudinary/descriptions/resource.ts b/nodes/Cloudinary/descriptions/resource.ts index 05ff71a..dbbc678 100644 --- a/nodes/Cloudinary/descriptions/resource.ts +++ b/nodes/Cloudinary/descriptions/resource.ts @@ -1,4 +1,116 @@ -import { INodeProperties } from 'n8n-workflow'; +import { INodeProperties, INodePropertyOptions } from 'n8n-workflow'; + +// Order is curated for usefulness, not alphabetized: the primary Upload and +// Transform flows lead, and the deprecated legacy resource sinks to the bottom. +// Kept as a named const: the alphabetize lint rule only inspects inline array +// literals, so the curated order stands without a suppression comment. +const RESOURCE_OPTIONS: INodePropertyOptions[] = [ + { + name: 'Upload', + value: 'upload', + description: 'Upload new assets from a URL or binary file data', + }, + { + name: 'Transform', + value: 'transform', + description: 'Build delivery and transformation URLs for images and videos (no upload, no API call)', + }, + { + name: 'Asset', + value: 'asset', + description: 'Work with existing assets by asset ID: get, search, delete, update tags/metadata', + }, + { + name: 'Widget', + value: 'widget', + description: 'Generate Cloudinary widgets and embeds, such as the Video Player (no upload, no API call)', + }, + { + name: 'Library', + value: 'admin', + description: 'Account-level lookups: list tags and structured-metadata field definitions', + }, + { + name: 'Asset (Legacy, by Public ID)', + value: 'updateAsset', + description: 'Deprecated — prefer the Asset resource. Public-ID-based tag and metadata updates.', + }, +]; + +// Each name and action is prefixed with a category ("Compose:"/"Image:"/"Video:") +// so entries cluster into groups in the operation dropdown and the Add-action +// panel. "Compose" sorts before "Image"/"Video", so the flagship Combine +// Transformations leads the list. Kept as a named const: the action sentence-case +// rule strips colons (no separator char passes it), but it only inspects inline +// array literals, so the prefixed labels stand without a suppression comment. +const TRANSFORM_OPERATION_OPTIONS: INodePropertyOptions[] = [ + { + name: 'Compose: Combine Transformations', + value: 'combineTransformations', + description: 'Build a delivery URL that chains several transformation steps in order. Outputs secure_url and a reusable transformation string.', + action: 'Compose: Combine Transformations', + }, + { + name: 'Compose: Custom Transformation String', + value: 'customTransformation', + description: 'Build a delivery URL from a raw Cloudinary transformation string. Outputs secure_url and a reusable transformation string.', + action: 'Compose: Custom Transformation String', + }, + { + name: 'Image: Convert Format', + value: 'convertImage', + description: 'Build a delivery URL that converts an image to another format. Outputs secure_url and a reusable transformation string.', + action: 'Image: Convert Format', + }, + { + name: 'Image: Crop', + value: 'cropImage', + description: 'Build a delivery URL that crops an image to fixed dimensions or an aspect ratio. Outputs secure_url and a reusable transformation string.', + action: 'Image: Crop', + }, + { + name: 'Image: Optimize', + value: 'optimizeImage', + description: 'Build a delivery URL that auto-optimizes an image (format + quality). Outputs secure_url and a reusable transformation string.', + action: 'Image: Optimize', + }, + { + name: 'Image: Resize', + value: 'resizeImage', + description: 'Build a delivery URL that resizes an image to a width and/or height. Outputs secure_url and a reusable transformation string.', + action: 'Image: Resize', + }, + { + name: 'Video: Crop', + value: 'cropVideo', + description: 'Build a delivery URL that crops a video to fixed dimensions or an aspect ratio. Outputs secure_url and a reusable transformation string.', + action: 'Video: Crop', + }, + { + name: 'Video: Optimize', + value: 'optimizeVideo', + description: 'Build a delivery URL that auto-optimizes a video (format/codec + quality). Outputs secure_url and a reusable transformation string.', + action: 'Video: Optimize', + }, + { + name: 'Video: Resize', + value: 'resizeVideo', + description: 'Build a delivery URL that resizes a video to a width and/or height. Outputs secure_url and a reusable transformation string.', + action: 'Video: Resize', + }, + { + name: 'Video: Thumbnail', + value: 'videoThumbnail', + description: 'Build a delivery URL for a still image frame from a video. Outputs secure_url and a reusable transformation string.', + action: 'Video: Thumbnail', + }, + { + name: 'Video: Trim', + value: 'trimVideo', + description: 'Build a delivery URL that trims a video to a start, end, and/or duration. Outputs secure_url and a reusable transformation string.', + action: 'Video: Trim', + }, +]; export const resourceProperties: INodeProperties[] = [ { @@ -6,41 +118,7 @@ export const resourceProperties: INodeProperties[] = [ name: 'resource', type: 'options', noDataExpression: true, - // Order is curated for usefulness, not alphabetized: the primary Upload and - // Transform flows lead, and the deprecated legacy resource sinks to the bottom. - // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items - options: [ - { - name: 'Upload', - value: 'upload', - description: 'Upload new assets from a URL or binary file data', - }, - { - name: 'Transform', - value: 'transform', - description: 'Build delivery and transformation URLs for images and videos (no upload, no API call)', - }, - { - name: 'Asset', - value: 'asset', - description: 'Work with existing assets by asset ID: get, search, delete, update tags/metadata', - }, - { - name: 'Widget', - value: 'widget', - description: 'Generate Cloudinary widgets and embeds, such as the Video Player (no upload, no API call)', - }, - { - name: 'Library', - value: 'admin', - description: 'Account-level lookups: list tags and structured-metadata field definitions', - }, - { - name: 'Asset (Legacy, by Public ID)', - value: 'updateAsset', - description: 'Deprecated — prefer the Asset resource. Public-ID-based tag and metadata updates.', - }, - ], + options: RESOURCE_OPTIONS, default: 'upload', }, { @@ -79,82 +157,7 @@ export const resourceProperties: INodeProperties[] = [ resource: ['transform'], }, }, - // Each name/action is prefixed with a category ("Compose:"/"Image:"/"Video:") so the - // alphabetical lint rule clusters them into groups in both the operation dropdown - // and the Add-action panel. "Compose" sorts before "Image"/"Video", so the flagship - // Combine Transformations leads the list. name and action are kept identical so both - // surfaces read the same; that requires disabling the sentence-case action rule, - // which would otherwise strip the colon and lower-case the label. - /* eslint-disable n8n-nodes-base/node-param-operation-option-action-miscased */ - options: [ - { - name: 'Compose: Combine Transformations', - value: 'combineTransformations', - description: 'Build a delivery URL that chains several transformation steps in order. Outputs secure_url and a reusable transformation string.', - action: 'Compose: Combine Transformations', - }, - { - name: 'Compose: Custom Transformation String', - value: 'customTransformation', - description: 'Build a delivery URL from a raw Cloudinary transformation string. Outputs secure_url and a reusable transformation string.', - action: 'Compose: Custom Transformation String', - }, - { - name: 'Image: Convert Format', - value: 'convertImage', - description: 'Build a delivery URL that converts an image to another format. Outputs secure_url and a reusable transformation string.', - action: 'Image: Convert Format', - }, - { - name: 'Image: Crop', - value: 'cropImage', - description: 'Build a delivery URL that crops an image to fixed dimensions or an aspect ratio. Outputs secure_url and a reusable transformation string.', - action: 'Image: Crop', - }, - { - name: 'Image: Optimize', - value: 'optimizeImage', - description: 'Build a delivery URL that auto-optimizes an image (format + quality). Outputs secure_url and a reusable transformation string.', - action: 'Image: Optimize', - }, - { - name: 'Image: Resize', - value: 'resizeImage', - description: 'Build a delivery URL that resizes an image to a width and/or height. Outputs secure_url and a reusable transformation string.', - action: 'Image: Resize', - }, - { - name: 'Video: Crop', - value: 'cropVideo', - description: 'Build a delivery URL that crops a video to fixed dimensions or an aspect ratio. Outputs secure_url and a reusable transformation string.', - action: 'Video: Crop', - }, - { - name: 'Video: Optimize', - value: 'optimizeVideo', - description: 'Build a delivery URL that auto-optimizes a video (format/codec + quality). Outputs secure_url and a reusable transformation string.', - action: 'Video: Optimize', - }, - { - name: 'Video: Resize', - value: 'resizeVideo', - description: 'Build a delivery URL that resizes a video to a width and/or height. Outputs secure_url and a reusable transformation string.', - action: 'Video: Resize', - }, - { - name: 'Video: Thumbnail', - value: 'videoThumbnail', - description: 'Build a delivery URL for a still image frame from a video. Outputs secure_url and a reusable transformation string.', - action: 'Video: Thumbnail', - }, - { - name: 'Video: Trim', - value: 'trimVideo', - description: 'Build a delivery URL that trims a video to a start, end, and/or duration. Outputs secure_url and a reusable transformation string.', - action: 'Video: Trim', - }, - ], - /* eslint-enable n8n-nodes-base/node-param-operation-option-action-miscased */ + options: TRANSFORM_OPERATION_OPTIONS, default: 'optimizeImage', }, { diff --git a/nodes/Cloudinary/descriptions/transform.fields.ts b/nodes/Cloudinary/descriptions/transform.fields.ts index 61b7c95..e68177a 100644 --- a/nodes/Cloudinary/descriptions/transform.fields.ts +++ b/nodes/Cloudinary/descriptions/transform.fields.ts @@ -1,4 +1,18 @@ -import { INodeProperties } from 'n8n-workflow'; +import { INodeProperties, INodePropertyOptions } from 'n8n-workflow'; + +// Ordered by how aggressively each mode changes the image (never-upscale → +// may-enlarge → pad family → exact), not alphabetically, so the dropdown reads +// as a gradient with the safe default (Limit) first and the pad modes grouped. +// Kept as a named const: the alphabetize lint rule only inspects inline array +// literals. The Multi-Step Fit field derives from this same list to stay in sync. +const FIT_OPTIONS: INodePropertyOptions[] = [ + { name: 'Limit (Never Upscale)', value: 'limit', description: 'Resize down to fit within the dimensions; never enlarges' }, + { name: 'Fit (Fit Within)', value: 'fit', description: 'Fit within the dimensions, may enlarge, keeps full image' }, + { name: 'Pad (Letterbox)', value: 'pad', description: 'Fit within the dimensions, then pad to fill the rest; keeps full image' }, + { name: 'Pad - Limit (Never Upscale)', value: 'lpad', description: 'Like Pad, but never enlarges past the original size' }, + { name: 'Pad - Minimum', value: 'mpad', description: 'Like Pad, but only pads when the target is larger than the original; never scales the image' }, + { name: 'Scale (Exact)', value: 'scale', description: 'Force exact dimensions; may distort if both are set' }, +]; // All Transform operations build a Cloudinary *delivery URL* and make no API call // (the "third flow" — see CLAUDE.md). Fields are gated by resource:'transform' plus @@ -98,11 +112,12 @@ export const transformFields: INodeProperties[] = [ type: 'string', default: '', placeholder: 'c_fill,w_800,h_600', - // `$json` is the n8n expression variable — correctly lowercase. The - // miscased-json rule is a false positive here; its autofix would rewrite it - // to `$JSON`, which is undefined in n8n and breaks the example. Keep lowercase. - // eslint-disable-next-line n8n-nodes-base/node-param-description-miscased-json - description: 'Optional. A transformation to build on, prepended before this operation\'s own transformation so the two compound into one delivery URL. Wire the previous Transform action\'s output here — {{ $json.transformation }} — to chain steps across nodes. Leave empty to start fresh.', + // The `$json` example lives in `hint`, not `description`: the miscased-json lint + // rule only inspects descriptions and would rewrite the lowercase `$json` variable + // to `$JSON`, which is undefined in n8n. Same approach as the base Webhook and + // Elasticsearch nodes. + hint: 'e.g. {{ $json.transformation }} from a previous Transform step', + description: 'Optional. A transformation to build on, prepended before this operation\'s own transformation so the two compound into one delivery URL. Wire the previous Transform action\'s transformation output field here (drag it in from the input panel) to chain steps across nodes. Leave empty to start fresh.', displayOptions: { show: { resource: ['transform'], @@ -154,18 +169,7 @@ export const transformFields: INodeProperties[] = [ displayName: 'Fit', name: 'resizeFit', type: 'options', - // Ordered by how aggressively each mode changes the image (never-upscale → - // may-enlarge → pad family → exact), not alphabetically, so the dropdown reads - // as a gradient with the safe default (Limit) first and the pad modes grouped. - // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items - options: [ - { name: 'Limit (Never Upscale)', value: 'limit', description: 'Resize down to fit within the dimensions; never enlarges' }, - { name: 'Fit (Fit Within)', value: 'fit', description: 'Fit within the dimensions, may enlarge, keeps full image' }, - { name: 'Pad (Letterbox)', value: 'pad', description: 'Fit within the dimensions, then pad to fill the rest; keeps full image' }, - { name: 'Pad - Limit (Never Upscale)', value: 'lpad', description: 'Like Pad, but never enlarges past the original size' }, - { name: 'Pad - Minimum', value: 'mpad', description: 'Like Pad, but only pads when the target is larger than the original; never scales the image' }, - { name: 'Scale (Exact)', value: 'scale', description: 'Force exact dimensions; may distort if both are set' }, - ], + options: FIT_OPTIONS, default: 'limit', description: 'How the asset is fitted to the requested dimensions. Pad modes keep the whole asset and fill the surrounding space (set both width and height to define the padded shape). Note: Resize does not auto-optimize — chain "Image: Optimize" / "Video: Optimize" after it, or use "Compose: Combine Transformations", to add f_auto/q_auto.', displayOptions: { @@ -616,34 +620,9 @@ export const transformFields: INodeProperties[] = [ displayName: 'Fit', name: 'fit', type: 'options', - // Same intent-ordering as the standalone Resize Fit field above; keep in sync. - // eslint-disable-next-line n8n-nodes-base/node-param-options-type-unsorted-items - options: [ - { - name: 'Limit (Never Upscale)', - value: 'limit', - }, - { - name: 'Fit (Fit Within)', - value: 'fit', - }, - { - name: 'Pad (Letterbox)', - value: 'pad', - }, - { - name: 'Pad - Limit (Never Upscale)', - value: 'lpad', - }, - { - name: 'Pad - Minimum', - value: 'mpad', - }, - { - name: 'Scale (Exact)', - value: 'scale', - }, - ], + // Same list as the standalone Resize Fit field, minus the long + // descriptions (this compact in-collection dropdown has no room for them). + options: FIT_OPTIONS.map(({ name, value }) => ({ name, value })), default: 'limit', description: 'How the asset is fitted to the requested dimensions. Pad modes keep the whole asset and fill the surrounding space (set both width and height).', displayOptions: { diff --git a/nodes/Cloudinary/descriptions/widget.fields.ts b/nodes/Cloudinary/descriptions/widget.fields.ts index 380bc5a..420f186 100644 --- a/nodes/Cloudinary/descriptions/widget.fields.ts +++ b/nodes/Cloudinary/descriptions/widget.fields.ts @@ -35,6 +35,222 @@ const SHOW_V1: IDisplayOptions = { show: { ...RES_OP, '@version': [1] } }; // the v2 player schema keeps showing these without re-gating. const SHOW_V2: IDisplayOptions = { show: { ...RES_OP, '@version': [{ _cnd: { gte: 2 } }] } }; +// The v2 collections' inner options are kept as named consts: the alphabetize +// lint rule only inspects inline array literals, so each list can keep its +// curated, purpose-driven order without a suppression comment. + +// Ordered by relevance (autoplay/sound/loop first), not alphabetically. +const PLAYBACK_OPTIONS: INodeProperties[] = [ + { + displayName: 'Autoplay Mode', + name: 'autoplayMode', + type: 'options', + options: [ + { name: 'Never', value: '', description: 'Do not autoplay (player default)' }, + { name: 'Always', value: 'always', description: 'Autoplay as soon as the player loads' }, + { name: 'On Scroll', value: 'on-scroll', description: 'Autoplay when the player scrolls into view' }, + ], + default: '', + description: 'When the video should start playing automatically. Most browsers require the player to be muted for autoplay to work.', + }, + { + displayName: 'Muted', + name: 'muted', + type: 'boolean', + default: false, + description: 'Whether the player starts muted', + }, + { + displayName: 'Loop', + name: 'loop', + type: 'boolean', + default: false, + description: 'Whether the video restarts from the beginning when it ends', + }, + { + displayName: 'Plays Inline', + name: 'playsinline', + type: 'boolean', + default: false, + description: 'Whether to prevent the player from entering fullscreen automatically on iOS when playback starts', + }, + { + displayName: 'Show Controls', + name: 'controls', + type: 'boolean', + default: true, + description: 'Whether to show the built-in playback controls (play, pause, volume, fullscreen, etc.)', + }, + { + displayName: 'Big Play Button', + name: 'bigPlayButton', + type: 'boolean', + default: true, + description: 'Whether to show a larger central play button when the video is paused', + }, +]; + +// Ordered Fluid → Width/Height → Aspect Ratio → Crop Mode so the dependent +// fields read top-down; not alphabetized. +const LAYOUT_OPTIONS: INodeProperties[] = [ + { + displayName: 'Fluid', + name: 'fluid', + type: 'boolean', + default: false, + description: 'Whether the player resizes responsively to fill its container. When on, Width and Height are replaced by an aspect-ratio CSS style.', + }, + { + displayName: 'Width', + name: 'width', + type: 'number', + default: 0, + description: 'Player width in pixels. Leave 0 for the default (640 px).', + displayOptions: { show: { fluid: [false] } }, + }, + { + displayName: 'Height', + name: 'height', + type: 'number', + default: 0, + description: 'Player height in pixels. Leave 0 for the default (360 px).', + displayOptions: { show: { fluid: [false] } }, + }, + { + displayName: 'Aspect Ratio', + name: 'aspectRatio', + type: 'options', + options: [ + { name: '1:1', value: '1:1' }, + { name: '16:9', value: '16:9' }, + { name: '9:16', value: '9:16' }, + { name: 'Default', value: '' }, + ], + default: '', + description: 'Player aspect ratio. Leave unset to use the video\'s natural dimensions.', + }, + { + displayName: 'Crop Mode', + name: 'cropMode', + type: 'options', + options: [ + { name: 'Smart', value: 'smart', description: 'Keep the most important content in view (player default)' }, + { name: 'Fill', value: 'fill', description: 'Cover the frame, cropping as needed' }, + { name: 'Pad', value: 'pad', description: 'Fit the whole video within the frame and add padding' }, + ], + default: 'smart', + description: 'How the player resizes the video to the chosen Aspect Ratio. Only relevant when Aspect Ratio is set (applies to progressive delivery, not adaptive streaming). If your Transformation already crops the video to a shape, applying an Aspect Ratio here crops it a second time — leave Aspect Ratio unset to keep just your transformation\'s framing. See https://cloudinary.com/documentation/video_player_customization.', + displayOptions: { show: { aspectRatio: ['1:1', '16:9', '9:16'] } }, + }, +]; + +// Ordered for readability, not alphabetized: the captions toggle leads, with its +// dependent Label and translation Languages grouped right under it, then the +// title/description toggles, then chapters + its dependent button. +const AI_CONTENT_OPTIONS: INodeProperties[] = [ + { + displayName: 'Generate Captions', + name: 'generateCaptions', + type: 'boolean', + default: false, + description: + 'Whether to auto-generate captions from the spoken audio, shown as a track the viewer can toggle on or off. The captions are in the video\'s original spoken language — to also offer captions translated into other languages, use Subtitle Languages below.', + }, + { + displayName: 'Captions Label', + name: 'captionsLabel', + type: 'string', + default: 'English (auto)', + description: + 'The name shown for the auto-generated captions track in the player\'s captions menu. Set this to match the video\'s spoken language (e.g. "English", "Español").', + displayOptions: { show: { generateCaptions: [true] } }, + }, + { + displayName: 'Subtitle Languages', + name: 'subtitleLanguages', + type: 'string', + default: '', + placeholder: 'e.g. es, fr-FR, de', + description: + 'Optional. Leave empty for captions in the original language only. To also offer the captions translated into other languages, enter target language codes separated by commas — each adds a translated subtitle track the viewer can pick. Translation requires the Google Translate add-on on your account (register at https://console.cloudinary.com/settings/addons, then enable it under Settings → Security → Unsigned add-on transformations). Captions in the original language need no add-on.', + displayOptions: { show: { generateCaptions: [true] } }, + }, + { + displayName: 'Generate Title', + name: 'generateTitle', + type: 'boolean', + default: false, + description: 'Whether to show an AI-generated title for the video in the player', + }, + { + displayName: 'Generate Description', + name: 'generateDescription', + type: 'boolean', + default: false, + description: 'Whether to show an AI-generated description for the video in the player', + }, + { + displayName: 'Generate Chapters', + name: 'generateChapters', + type: 'boolean', + default: false, + description: + 'Whether to auto-generate chapter markers so viewers can jump between sections of the video', + }, + { + displayName: 'Show Chapters Button', + name: 'chaptersButton', + type: 'boolean', + default: true, + description: + 'Whether to show the chapters button in the player, which opens the list of chapters so viewers can navigate. Shown only when Generate Chapters is on; turn off to keep the chapters available without surfacing the button.', + displayOptions: { show: { generateChapters: [true] } }, + }, +]; + +// Skin first (it sets the baseline), then the color/font overrides; not alphabetized. +const APPEARANCE_OPTIONS: INodeProperties[] = [ + { + displayName: 'Skin', + name: 'skin', + type: 'options', + options: [ + { name: 'Dark', value: 'dark' }, + { name: 'Light', value: 'light' }, + ], + default: 'dark', + description: 'Player theme', + }, + { + displayName: 'Base Color', + name: 'baseColor', + type: 'color', + default: '', + description: 'Player base (background) color, as a hex value', + }, + { + displayName: 'Accent Color', + name: 'accentColor', + type: 'color', + default: '', + description: 'Player accent (highlight) color, as a hex value', + }, + { + displayName: 'Text Color', + name: 'textColor', + type: 'color', + default: '', + description: 'Player text color, as a hex value', + }, + { + displayName: 'Font Face', + name: 'fontFace', + type: 'string', + default: '', + description: 'The font applied to player text elements (titles, descriptions, recommendations, time counter). Accepts a Google Font name.', + }, +]; + export const widgetFields: INodeProperties[] = [ // ── Essentials (top-level, shared by all versions) ────────────────────────── { @@ -84,57 +300,7 @@ export const widgetFields: INodeProperties[] = [ default: {}, description: 'How and when the video plays', displayOptions: SHOW_V2, - // Ordered by relevance (autoplay/sound/loop first), not alphabetically. - // eslint-disable-next-line n8n-nodes-base/node-param-collection-type-unsorted-items - options: [ - { - displayName: 'Autoplay Mode', - name: 'autoplayMode', - type: 'options', - options: [ - { name: 'Never', value: '', description: 'Do not autoplay (player default)' }, - { name: 'Always', value: 'always', description: 'Autoplay as soon as the player loads' }, - { name: 'On Scroll', value: 'on-scroll', description: 'Autoplay when the player scrolls into view' }, - ], - default: '', - description: 'When the video should start playing automatically. Most browsers require the player to be muted for autoplay to work.', - }, - { - displayName: 'Muted', - name: 'muted', - type: 'boolean', - default: false, - description: 'Whether the player starts muted', - }, - { - displayName: 'Loop', - name: 'loop', - type: 'boolean', - default: false, - description: 'Whether the video restarts from the beginning when it ends', - }, - { - displayName: 'Plays Inline', - name: 'playsinline', - type: 'boolean', - default: false, - description: 'Whether to prevent the player from entering fullscreen automatically on iOS when playback starts', - }, - { - displayName: 'Show Controls', - name: 'controls', - type: 'boolean', - default: true, - description: 'Whether to show the built-in playback controls (play, pause, volume, fullscreen, etc.)', - }, - { - displayName: 'Big Play Button', - name: 'bigPlayButton', - type: 'boolean', - default: true, - description: 'Whether to show a larger central play button when the video is paused', - }, - ], + options: PLAYBACK_OPTIONS, }, // ── Size & Layout ─────────────────────────────────────────────────────────── @@ -146,60 +312,7 @@ export const widgetFields: INodeProperties[] = [ default: {}, description: 'The player\'s dimensions and shape', displayOptions: SHOW_V2, - // Ordered Fluid → Width/Height → Aspect Ratio → Crop Mode so the dependent - // fields read top-down; not alphabetized. - // eslint-disable-next-line n8n-nodes-base/node-param-collection-type-unsorted-items - options: [ - { - displayName: 'Fluid', - name: 'fluid', - type: 'boolean', - default: false, - description: 'Whether the player resizes responsively to fill its container. When on, Width and Height are replaced by an aspect-ratio CSS style.', - }, - { - displayName: 'Width', - name: 'width', - type: 'number', - default: 0, - description: 'Player width in pixels. Leave 0 for the default (640 px).', - displayOptions: { show: { fluid: [false] } }, - }, - { - displayName: 'Height', - name: 'height', - type: 'number', - default: 0, - description: 'Player height in pixels. Leave 0 for the default (360 px).', - displayOptions: { show: { fluid: [false] } }, - }, - { - displayName: 'Aspect Ratio', - name: 'aspectRatio', - type: 'options', - options: [ - { name: '1:1', value: '1:1' }, - { name: '16:9', value: '16:9' }, - { name: '9:16', value: '9:16' }, - { name: 'Default', value: '' }, - ], - default: '', - description: 'Player aspect ratio. Leave unset to use the video\'s natural dimensions.', - }, - { - displayName: 'Crop Mode', - name: 'cropMode', - type: 'options', - options: [ - { name: 'Smart', value: 'smart', description: 'Keep the most important content in view (player default)' }, - { name: 'Fill', value: 'fill', description: 'Cover the frame, cropping as needed' }, - { name: 'Pad', value: 'pad', description: 'Fit the whole video within the frame and add padding' }, - ], - default: 'smart', - description: 'How the player resizes the video to the chosen Aspect Ratio. Only relevant when Aspect Ratio is set (applies to progressive delivery, not adaptive streaming). If your Transformation already crops the video to a shape, applying an Aspect Ratio here crops it a second time — leave Aspect Ratio unset to keep just your transformation\'s framing. See https://cloudinary.com/documentation/video_player_customization.', - displayOptions: { show: { aspectRatio: ['1:1', '16:9', '9:16'] } }, - }, - ], + options: LAYOUT_OPTIONS, }, // ── AI-Generated Content (the flagship capability) ─────────────────────────── @@ -212,70 +325,7 @@ export const widgetFields: INodeProperties[] = [ description: 'Have the Cloudinary Video Player generate captions, a title, a description, and chapter markers from the video using AI — no files to author. Generation happens the first time the content is requested in the browser, only for content that does not already exist, and the result is cached. It requires the video audio to contain dialogue. These options affect the generated player config, not the preview embed URL. Learn more: https://cloudinary.com/documentation/video_player_customization.', displayOptions: SHOW_V2, - // Ordered for readability, not alphabetized: the captions toggle leads, with its - // dependent Label and translation Languages grouped right under it, then the - // title/description toggles, then chapters + its dependent button. - // eslint-disable-next-line n8n-nodes-base/node-param-collection-type-unsorted-items - options: [ - { - displayName: 'Generate Captions', - name: 'generateCaptions', - type: 'boolean', - default: false, - description: - 'Whether to auto-generate captions from the spoken audio, shown as a track the viewer can toggle on or off. The captions are in the video\'s original spoken language — to also offer captions translated into other languages, use Subtitle Languages below.', - }, - { - displayName: 'Captions Label', - name: 'captionsLabel', - type: 'string', - default: 'English (auto)', - description: - 'The name shown for the auto-generated captions track in the player\'s captions menu. Set this to match the video\'s spoken language (e.g. "English", "Español").', - displayOptions: { show: { generateCaptions: [true] } }, - }, - { - displayName: 'Subtitle Languages', - name: 'subtitleLanguages', - type: 'string', - default: '', - placeholder: 'e.g. es, fr-FR, de', - description: - 'Optional. Leave empty for captions in the original language only. To also offer the captions translated into other languages, enter target language codes separated by commas — each adds a translated subtitle track the viewer can pick. Translation requires the Google Translate add-on on your account (register at https://console.cloudinary.com/settings/addons, then enable it under Settings → Security → Unsigned add-on transformations). Captions in the original language need no add-on.', - displayOptions: { show: { generateCaptions: [true] } }, - }, - { - displayName: 'Generate Title', - name: 'generateTitle', - type: 'boolean', - default: false, - description: 'Whether to show an AI-generated title for the video in the player', - }, - { - displayName: 'Generate Description', - name: 'generateDescription', - type: 'boolean', - default: false, - description: 'Whether to show an AI-generated description for the video in the player', - }, - { - displayName: 'Generate Chapters', - name: 'generateChapters', - type: 'boolean', - default: false, - description: - 'Whether to auto-generate chapter markers so viewers can jump between sections of the video', - }, - { - displayName: 'Show Chapters Button', - name: 'chaptersButton', - type: 'boolean', - default: true, - description: - 'Whether to show the chapters button in the player, which opens the list of chapters so viewers can navigate. Shown only when Generate Chapters is on; turn off to keep the chapters available without surfacing the button.', - displayOptions: { show: { generateChapters: [true] } }, - }, - ], + options: AI_CONTENT_OPTIONS, }, // ── Appearance ──────────────────────────────────────────────────────────── @@ -287,49 +337,7 @@ export const widgetFields: INodeProperties[] = [ default: {}, description: 'The player\'s theme, colors, and font', displayOptions: SHOW_V2, - // Skin first (it sets the baseline), then the color/font overrides; not alphabetized. - // eslint-disable-next-line n8n-nodes-base/node-param-collection-type-unsorted-items - options: [ - { - displayName: 'Skin', - name: 'skin', - type: 'options', - options: [ - { name: 'Dark', value: 'dark' }, - { name: 'Light', value: 'light' }, - ], - default: 'dark', - description: 'Player theme', - }, - { - displayName: 'Base Color', - name: 'baseColor', - type: 'color', - default: '', - description: 'Player base (background) color, as a hex value', - }, - { - displayName: 'Accent Color', - name: 'accentColor', - type: 'color', - default: '', - description: 'Player accent (highlight) color, as a hex value', - }, - { - displayName: 'Text Color', - name: 'textColor', - type: 'color', - default: '', - description: 'Player text color, as a hex value', - }, - { - displayName: 'Font Face', - name: 'fontFace', - type: 'string', - default: '', - description: 'The font applied to player text elements (titles, descriptions, recommendations, time counter). Accepts a Google Font name.', - }, - ], + options: APPEARANCE_OPTIONS, }, // ── Player Features (niche UI toggles) ─────────────────────────────────────── diff --git a/nodes/Cloudinary/operations/admin/search.ts b/nodes/Cloudinary/operations/admin/search.ts index 2cdb68e..fc378cd 100644 --- a/nodes/Cloudinary/operations/admin/search.ts +++ b/nodes/Cloudinary/operations/admin/search.ts @@ -1,4 +1,4 @@ -import { IDataObject, IHttpRequestOptions, NodeOperationError } from 'n8n-workflow'; +import { IDataObject, IHttpRequestOptions, JsonObject, NodeApiError, NodeOperationError } from 'n8n-workflow'; import { basicAuth, buildSearchExpression, extractCloudinaryError, jsonHeaders } from '../../cloudinary.utils'; import { CREDENTIAL_TYPE, OperationHandler } from '../types'; @@ -82,7 +82,8 @@ export const search: OperationHandler = async (ctx, i, creds) => { itemIndex: i, }); } - throw error; + // Passes an already-wrapped NodeApiError through untouched. + throw new NodeApiError(ctx.getNode(), error as JsonObject, { itemIndex: i }); } const resources = Array.isArray(response.resources) diff --git a/nodes/Cloudinary/operations/asset/updateMetadata.ts b/nodes/Cloudinary/operations/asset/updateMetadata.ts index 33c9861..5c99bcd 100644 --- a/nodes/Cloudinary/operations/asset/updateMetadata.ts +++ b/nodes/Cloudinary/operations/asset/updateMetadata.ts @@ -13,7 +13,7 @@ export const updateMetadata: OperationHandler = async (ctx, i, creds) => { const updateOptions = ctx.getNodeParameter('updateOptions', i, {}) as IDataObject; const body: IDataObject = { - metadata: metadataToPipeString(structuredMetadata), + metadata: metadataToPipeString(structuredMetadata, ctx.getNode()), ...updateOptions, }; diff --git a/nodes/Cloudinary/operations/updateAsset/updateMetadata.ts b/nodes/Cloudinary/operations/updateAsset/updateMetadata.ts index e52147f..cc87c03 100644 --- a/nodes/Cloudinary/operations/updateAsset/updateMetadata.ts +++ b/nodes/Cloudinary/operations/updateAsset/updateMetadata.ts @@ -10,7 +10,7 @@ export const updateMetadata: OperationHandler = async (ctx, i, creds) => { const updateOptions = ctx.getNodeParameter('updateOptions', i, {}) as IDataObject; const body: IDataObject = { - metadata: metadataToPipeString(structuredMetadata), + metadata: metadataToPipeString(structuredMetadata, ctx.getNode()), ...updateOptions, }; diff --git a/nodes/Cloudinary/operations/upload/uploadFile.ts b/nodes/Cloudinary/operations/upload/uploadFile.ts index 959b3bc..20a763f 100644 --- a/nodes/Cloudinary/operations/upload/uploadFile.ts +++ b/nodes/Cloudinary/operations/upload/uploadFile.ts @@ -16,6 +16,7 @@ export const uploadFile: OperationHandler = async (ctx, i, creds) => { if (additionalFields.metadata) { additionalFields.metadata = metadataToPipeString( additionalFields.metadata as IDataObject | string, + ctx.getNode(), ); } diff --git a/nodes/Cloudinary/operations/upload/uploadUrl.ts b/nodes/Cloudinary/operations/upload/uploadUrl.ts index 94431e7..b2a0419 100644 --- a/nodes/Cloudinary/operations/upload/uploadUrl.ts +++ b/nodes/Cloudinary/operations/upload/uploadUrl.ts @@ -15,6 +15,7 @@ export const uploadUrl: OperationHandler = async (ctx, i, creds) => { if (additionalFields.metadata) { additionalFields.metadata = metadataToPipeString( additionalFields.metadata as IDataObject | string, + ctx.getNode(), ); } diff --git a/package.json b/package.json index 7bf31fe..639ba8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-nodes-cloudinary", - "version": "0.2.2", + "version": "0.2.3", "description": "The official Cloudinary n8n node - upload media, update asset tags and metadata, and more", "keywords": [ "n8n-community-node-package", @@ -14,7 +14,8 @@ "license": "MIT", "homepage": "https://cloudinary.com/documentation", "author": { - "name": "Cloudinary" + "name": "Cloudinary", + "email": "info@cloudinary.com" }, "repository": { "type": "git",