diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 39a3d935d0a07..dffe16ffb4837 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -40,6 +40,7 @@ "@fortawesome/free-solid-svg-icons": "^5.15.3", "@fortawesome/vue-fontawesome": "^2.0.2", "axios": "^0.21.1", + "codemirror-lang-html-n8n": "^1.0.0", "codemirror-lang-n8n-expression": "^0.1.0", "dateformat": "^3.0.3", "esprima-next": "5.8.4", @@ -62,6 +63,7 @@ "n8n-workflow": "~0.133.2", "normalize-wheel": "^1.0.1", "pinia": "^2.0.22", + "prettier": "^2.8.2", "prismjs": "^1.17.1", "timeago.js": "^4.0.2", "uuid": "^8.3.2", diff --git a/packages/editor-ui/src/Interface.ts b/packages/editor-ui/src/Interface.ts index 0dea128204359..66d207ee4244e 100644 --- a/packages/editor-ui/src/Interface.ts +++ b/packages/editor-ui/src/Interface.ts @@ -1102,7 +1102,7 @@ export interface IModalState { httpNodeParameters?: string; } -export type IRunDataDisplayMode = 'table' | 'json' | 'binary' | 'schema'; +export type IRunDataDisplayMode = 'table' | 'json' | 'binary' | 'schema' | 'html'; export type NodePanelType = 'input' | 'output'; export interface TargetItem { diff --git a/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue b/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue new file mode 100644 index 0000000000000..9ebb7749134a6 --- /dev/null +++ b/packages/editor-ui/src/components/HtmlEditor/HtmlEditor.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/packages/editor-ui/src/components/HtmlEditor/theme.ts b/packages/editor-ui/src/components/HtmlEditor/theme.ts new file mode 100644 index 0000000000000..f0460af501428 --- /dev/null +++ b/packages/editor-ui/src/components/HtmlEditor/theme.ts @@ -0,0 +1,85 @@ +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'; +import { EditorView } from '@codemirror/view'; +import { tags } from '@lezer/highlight'; + +export const theme = [ + EditorView.theme({ + '&': { + 'font-size': '0.8em', + border: 'var(--border-base)', + borderRadius: 'var(--border-radius-base)', + backgroundColor: 'var(--color-code-background)', + color: 'var(--color-code-foreground)', + }, + '.cm-content': { + fontFamily: "Menlo, Consolas, 'DejaVu Sans Mono', monospace !important", + caretColor: 'var(--color-code-caret)', + }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: 'var(--color-code-caret)', + }, + '&.cm-focused .cm-selectionBackgroundm .cm-selectionBackground, .cm-content ::selection': { + backgroundColor: 'var(--color-code-selection)', + }, + '.cm-activeLine': { + backgroundColor: 'var(--color-code-lineHighlight)', + }, + '.cm-activeLineGutter': { + backgroundColor: 'var(--color-code-lineHighlight)', + }, + '.cm-gutters': { + backgroundColor: 'var(--color-code-gutterBackground)', + color: 'var(--color-code-gutterForeground)', + }, + '.cm-scroller': { + overflow: 'auto', + maxHeight: '350px', + }, + }), + syntaxHighlighting( + HighlightStyle.define([ + { tag: tags.keyword, color: '#c678dd' }, + { + tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName], + color: '#e06c75', + }, + { tag: [tags.function(tags.variableName), tags.labelName], color: '#61afef' }, + { tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)], color: '#d19a66' }, + { tag: [tags.definition(tags.name), tags.separator], color: '#abb2bf' }, + { + tag: [ + tags.typeName, + tags.className, + tags.number, + tags.changed, + tags.annotation, + tags.modifier, + tags.self, + tags.namespace, + ], + color: '#e06c75', + }, + { + tag: [ + tags.operator, + tags.operatorKeyword, + tags.url, + tags.escape, + tags.regexp, + tags.link, + tags.special(tags.string), + ], + color: '#56b6c2', + }, + { tag: [tags.meta, tags.comment], color: '#7d8799' }, + { tag: tags.strong, fontWeight: 'bold' }, + { tag: tags.emphasis, fontStyle: 'italic' }, + { tag: tags.strikethrough, textDecoration: 'line-through' }, + { tag: tags.link, color: '#7d8799', textDecoration: 'underline' }, + { tag: tags.heading, fontWeight: 'bold', color: '#e06c75' }, + { tag: [tags.atom, tags.bool, tags.special(tags.variableName)], color: '#d19a66' }, + { tag: [tags.processingInstruction, tags.string, tags.inserted], color: '#98c379' }, + { tag: tags.invalid, color: 'red', 'font-weight': 'bold' }, + ]), + ), +]; diff --git a/packages/editor-ui/src/components/HtmlEditor/types.ts b/packages/editor-ui/src/components/HtmlEditor/types.ts new file mode 100644 index 0000000000000..0c6db2f950391 --- /dev/null +++ b/packages/editor-ui/src/components/HtmlEditor/types.ts @@ -0,0 +1,7 @@ +export type Range = [number, number]; + +export type Section = { + kind: 'html' | 'script' | 'style'; + content: string; + range: Range; +}; diff --git a/packages/editor-ui/src/components/HtmlEditor/utils.ts b/packages/editor-ui/src/components/HtmlEditor/utils.ts new file mode 100644 index 0000000000000..239be2b831c1e --- /dev/null +++ b/packages/editor-ui/src/components/HtmlEditor/utils.ts @@ -0,0 +1,40 @@ +import type { Range } from './types'; + +/** + * Return the ranges of a full range that are _not_ within the taken ranges, + * assuming sorted taken ranges. e.g. `[0, 10]` and `[[2, 3], [7, 8]]` + * return `[[0, 1], [4, 6], [9, 10]]` + */ +export function nonTakenRanges(fullRange: Range, takenRanges: Range[]) { + const found = []; + + const [fullStart, fullEnd] = fullRange; + let i = fullStart; + let curStart = fullStart; + + takenRanges = [...takenRanges]; + + while (i < fullEnd) { + if (takenRanges.length === 0) { + found.push([curStart, fullEnd]); + break; + } + + const [takenStart, takenEnd] = takenRanges[0]; + + if (i < takenStart) { + i++; + continue; + } + + if (takenStart !== fullStart) { + found.push([curStart, i - 1]); + } + + i = takenEnd + 1; + curStart = takenEnd + 1; + takenRanges.shift(); + } + + return found; +} diff --git a/packages/editor-ui/src/components/ParameterInput.vue b/packages/editor-ui/src/components/ParameterInput.vue index 0f1825c12e12d..f46debb344ce9 100644 --- a/packages/editor-ui/src/components/ParameterInput.vue +++ b/packages/editor-ui/src/components/ParameterInput.vue @@ -79,6 +79,13 @@ @valueChanged="valueChangedDebounced" /> + +
{ + if ( + this.ndvStore.activeNode?.type === HTML_NODE_TYPE && + this.ndvStore.activeNode?.parameters.operation === 'generateHtmlTemplate' + ) { + return [ + { + label: 'Format HTML', + value: 'formatHtml', + }, + ]; + } + const actions = [ { label: this.$locale.baseText('parameterInput.resetValue'), diff --git a/packages/editor-ui/src/components/RunData.vue b/packages/editor-ui/src/components/RunData.vue index 7ef714f3d04e9..a2513e3de3468 100644 --- a/packages/editor-ui/src/components/RunData.vue +++ b/packages/editor-ui/src/components/RunData.vue @@ -316,6 +316,11 @@ :totalRuns="maxRunIndex" /> + + import('@/components/RunDataTable.vue'); const RunDataJson = () => import('@/components/RunDataJson.vue'); const RunDataSchema = () => import('@/components/RunDataSchema.vue'); +const RunDataHtml = () => import('@/components/RunDataHtml.vue'); export type EnterEditModeArgs = { origin: 'editIconButton' | 'insertTestDataLink'; @@ -512,6 +519,7 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).exten RunDataTable, RunDataJson, RunDataSchema, + RunDataHtml, }, props: { nodeUi: { @@ -598,6 +606,8 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).exten pane: this.paneType as 'input' | 'output', branchIndex: this.currentOutputIndex, }); + + if (this.paneType === 'output') this.setDisplayMode(); }, destroyed() { this.hidePinDataDiscoveryTooltip(); @@ -651,6 +661,14 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).exten defaults.unshift({ label: this.$locale.baseText('runData.schema'), value: 'schema' }); } + if ( + this.isPaneTypeOutput && + this.activeNode?.type === HTML_NODE_TYPE && + this.activeNode.parameters.operation === 'generateHtmlTemplate' + ) { + defaults.unshift({ label: 'HTML', value: 'html' }); + } + return defaults; }, hasNodeRun(): boolean { @@ -833,6 +851,9 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).exten isPaneTypeInput(): boolean { return this.paneType === 'input'; }, + isPaneTypeOutput(): boolean { + return this.paneType === 'output'; + }, }, methods: { onItemHover(itemIndex: number | null) { @@ -1275,11 +1296,26 @@ export default mixins(externalHooks, genericHelpers, nodeHelpers, pinData).exten this.ndvStore.activeNodeName = this.node.name; } }, + setDisplayMode() { + if (!this.activeNode) return; + + const shouldDisplayHtml = + this.activeNode.type === HTML_NODE_TYPE && + this.activeNode.parameters.operation === 'generateHtmlTemplate'; + + this.ndvStore.setPanelDisplayMode({ + pane: 'output', + mode: shouldDisplayHtml ? 'html' : 'table', + }); + }, }, watch: { node() { this.init(); }, + hasNodeRun() { + if (this.paneType === 'output') this.setDisplayMode(); + }, inputData: { handler(data: INodeExecutionData[]) { if (this.paneType && data) { diff --git a/packages/editor-ui/src/components/RunDataHtml.vue b/packages/editor-ui/src/components/RunDataHtml.vue new file mode 100644 index 0000000000000..53aff0c9eb883 --- /dev/null +++ b/packages/editor-ui/src/components/RunDataHtml.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/packages/editor-ui/src/constants.ts b/packages/editor-ui/src/constants.ts index 7e6924654cf31..cd105b1f6e66d 100644 --- a/packages/editor-ui/src/constants.ts +++ b/packages/editor-ui/src/constants.ts @@ -92,6 +92,7 @@ export const ELASTIC_SECURITY_NODE_TYPE = 'n8n-nodes-base.elasticSecurity'; export const EMAIL_SEND_NODE_TYPE = 'n8n-nodes-base.emailSend'; export const EMAIL_IMAP_NODE_TYPE = 'n8n-nodes-base.emailReadImap'; export const EXECUTE_COMMAND_NODE_TYPE = 'n8n-nodes-base.executeCommand'; +export const HTML_NODE_TYPE = 'n8n-nodes-base.html'; export const HTTP_REQUEST_NODE_TYPE = 'n8n-nodes-base.httpRequest'; export const HUBSPOT_TRIGGER_NODE_TYPE = 'n8n-nodes-base.hubspotTrigger'; export const IF_NODE_TYPE = 'n8n-nodes-base.if'; diff --git a/packages/editor-ui/src/event-bus/html-editor-event-bus.ts b/packages/editor-ui/src/event-bus/html-editor-event-bus.ts new file mode 100644 index 0000000000000..0476b2bf2cd67 --- /dev/null +++ b/packages/editor-ui/src/event-bus/html-editor-event-bus.ts @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export const htmlEditorEventBus = new Vue(); diff --git a/packages/editor-ui/src/mixins/expressionManager.ts b/packages/editor-ui/src/mixins/expressionManager.ts index a199c309197ed..09632813cd3b7 100644 --- a/packages/editor-ui/src/mixins/expressionManager.ts +++ b/packages/editor-ui/src/mixins/expressionManager.ts @@ -1,6 +1,6 @@ import mixins from 'vue-typed-mixins'; import { mapStores } from 'pinia'; -import { ensureSyntaxTree, syntaxTree } from '@codemirror/language'; +import { ensureSyntaxTree } from '@codemirror/language'; import { workflowHelpers } from '@/mixins/workflowHelpers'; import { useNDVStore } from '@/stores/ndv'; @@ -9,7 +9,7 @@ import { EXPRESSION_EDITOR_PARSER_TIMEOUT } from '@/constants'; import type { PropType } from 'vue'; import type { EditorView } from '@codemirror/view'; import type { TargetItem } from '@/Interface'; -import type { Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions'; +import type { Html, Plaintext, RawSegment, Resolvable, Segment } from '@/types/expressions'; export const expressionManager = mixins(workflowHelpers).extend({ props: { @@ -56,6 +56,10 @@ export const expressionManager = mixins(workflowHelpers).extend({ return this.segments.filter((s): s is Plaintext => s.kind === 'plaintext'); }, + htmlSegments(): Html[] { + return this.segments.filter((s): s is Html => s.kind !== 'resolvable'); + }, + segments(): Segment[] { if (!this.editor) return []; diff --git a/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts b/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts index cb3f77b5ffdb6..2020812e7a1e7 100644 --- a/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts +++ b/packages/editor-ui/src/plugins/codemirror/inputHandlers/expression.inputHandler.ts @@ -29,7 +29,19 @@ const handler = EditorView.inputHandler.of((view, from, to, insert) => { const transaction = insertBracket(view.state, insert); - if (!transaction) return false; + if (!transaction) { + // customization: brace setup when surrounded by HTML tags:
->
{| }
+ if (insert === '{') { + const cursor = view.state.selection.main.head; + view.dispatch({ + changes: { from: cursor, insert: '{ }' }, + selection: { anchor: cursor + 1 }, + }); + return true; + } + + return false; + } view.dispatch(transaction); @@ -90,6 +102,7 @@ const [_, bracketState] = closeBrackets() as readonly Extension[]; * - prevent token autoclosing during autocompletion (exception: `{`), * - prevent square bracket autoclosing prior to `.json` * - inject whitespace and braces for resolvables + * - set up braces when surrounded by HTML tags * * Other than segments marked `customization`, this is a copy of the [original](https://github.com/codemirror/closebrackets/blob/0a56edfaf2c6d97bc5e88f272de0985b4f41e37a/src/closebrackets.ts#L79). */ diff --git a/packages/editor-ui/src/types/expressions.ts b/packages/editor-ui/src/types/expressions.ts index 00efc61e34b4e..39a0ee771c776 100644 --- a/packages/editor-ui/src/types/expressions.ts +++ b/packages/editor-ui/src/types/expressions.ts @@ -6,6 +6,8 @@ export type Segment = Plaintext | Resolvable; export type Plaintext = { kind: 'plaintext'; plaintext: string } & Range; +export type Html = Plaintext; // for n8n parser, functionally identical to plaintext + export type Resolvable = { kind: 'resolvable'; resolvable: string; diff --git a/packages/nodes-base/nodes/Html/Html.node.json b/packages/nodes-base/nodes/Html/Html.node.json new file mode 100644 index 0000000000000..b23e6c8cf9a24 --- /dev/null +++ b/packages/nodes-base/nodes/Html/Html.node.json @@ -0,0 +1,17 @@ +{ + "node": "n8n-nodes-base.html", + "nodeVersion": "1.0", + "codexVersion": "1.0", + "categories": ["Core Nodes"], + "resources": { + "primaryDocumentation": [ + { + "url": "https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.html/" + } + ] + }, + "subcategories": { + "Core Nodes": ["Helpers", "Data Transformation"] + }, + "alias": ["extract", "template"] +} diff --git a/packages/nodes-base/nodes/Html/Html.node.ts b/packages/nodes-base/nodes/Html/Html.node.ts new file mode 100644 index 0000000000000..81f2c82c97fb0 --- /dev/null +++ b/packages/nodes-base/nodes/Html/Html.node.ts @@ -0,0 +1,376 @@ +import cheerio from 'cheerio'; +import { + INodeExecutionData, + IExecuteFunctions, + INodeType, + INodeTypeDescription, + IDataObject, + NodeOperationError, +} from 'n8n-workflow'; +import { placeholder } from './placeholder'; +import { getResolvables, getValue } from './utils'; +import type { IValueData } from './types'; + +export class Html implements INodeType { + description: INodeTypeDescription = { + displayName: 'HTML', + name: 'html', + icon: 'file:html.svg', + group: ['transform'], + version: 1, + subtitle: '={{ $parameter["operation"] }}', + description: 'Work with HTML', + defaults: { + name: 'HTML', + }, + inputs: ['main'], + outputs: ['main'], + parameterPane: 'wide', + properties: [ + { + displayName: 'Operation', + name: 'operation', + type: 'options', + noDataExpression: true, + options: [ + { + name: 'Generate HTML Template', + value: 'generateHtmlTemplate', + action: 'Generate HTML template', + }, + { + name: 'Extract HTML Content', + value: 'extractHtmlContent', + action: 'Extract HTML Content', + }, + ], + default: 'generateHtmlTemplate', + }, + { + displayName: 'HTML Template', + name: 'html', + typeOptions: { + editor: 'htmlEditor', + }, + type: 'string', + default: placeholder, + noDataExpression: true, + description: 'HTML template to render', + displayOptions: { + show: { + operation: ['generateHtmlTemplate'], + }, + }, + }, + { + displayName: + 'Tips: Type ctrl+space for completions. Use {{ }} for expressions and <style> tags for CSS. JS in <script> tags is included but not executed in n8n.', + name: 'notice', + type: 'notice', + default: '', + displayOptions: { + show: { + operation: ['generateHtmlTemplate'], + }, + }, + }, + { + displayName: 'Source Data', + name: 'sourceData', + type: 'options', + options: [ + { + name: 'Binary', + value: 'binary', + }, + { + name: 'JSON', + value: 'json', + }, + ], + default: 'json', + description: 'If HTML should be read from binary or JSON data', + displayOptions: { + show: { + operation: ['extractHtmlContent'], + }, + }, + }, + { + displayName: 'Binary Property', + name: 'dataPropertyName', + type: 'string', + displayOptions: { + show: { + operation: ['extractHtmlContent'], + sourceData: ['binary'], + }, + }, + default: 'data', + required: true, + description: + 'Name of the binary property in which the HTML to extract the data from can be found', + }, + { + displayName: 'JSON Property', + name: 'dataPropertyName', + type: 'string', + displayOptions: { + show: { + operation: ['extractHtmlContent'], + sourceData: ['json'], + }, + }, + default: 'data', + required: true, + description: + 'Name of the JSON property in which the HTML to extract the data from can be found. The property can either contain a string or an array of strings.', + }, + { + displayName: 'Extraction Values', + name: 'extractionValues', + placeholder: 'Add Value', + type: 'fixedCollection', + typeOptions: { + multipleValues: true, + }, + displayOptions: { + show: { + operation: ['extractHtmlContent'], + }, + }, + default: {}, + options: [ + { + name: 'values', + displayName: 'Values', + values: [ + { + displayName: 'Key', + name: 'key', + type: 'string', + default: '', + description: 'The key under which the extracted value should be saved', + }, + { + displayName: 'CSS Selector', + name: 'cssSelector', + type: 'string', + default: '', + placeholder: '.price', + description: 'The CSS selector to use', + }, + { + displayName: 'Return Value', + name: 'returnValue', + type: 'options', + options: [ + { + name: 'Attribute', + value: 'attribute', + description: 'Get an attribute value like "class" from an element', + }, + { + name: 'HTML', + value: 'html', + description: 'Get the HTML the element contains', + }, + { + name: 'Text', + value: 'text', + description: 'Get only the text content of the element', + }, + { + name: 'Value', + value: 'value', + description: 'Get value of an input, select or textarea', + }, + ], + default: 'text', + description: 'What kind of data should be returned', + }, + { + displayName: 'Attribute', + name: 'attribute', + type: 'string', + displayOptions: { + show: { + returnValue: ['attribute'], + }, + }, + default: '', + placeholder: 'class', + description: 'The name of the attribute to return the value off', + }, + { + displayName: 'Return Array', + name: 'returnArray', + type: 'boolean', + default: false, + description: + 'Whether to return the values as an array so if multiple ones get found they also get returned separately. If not set all will be returned as a single string.', + }, + ], + }, + ], + }, + + { + displayName: 'Options', + name: 'options', + type: 'collection', + placeholder: 'Add Option', + default: {}, + displayOptions: { + show: { + operation: ['extractHtmlContent'], + }, + }, + options: [ + { + displayName: 'Trim Values', + name: 'trimValues', + type: 'boolean', + default: true, + description: + 'Whether to remove automatically all spaces and newlines from the beginning and end of the values', + }, + ], + }, + ], + }; + + async execute(this: IExecuteFunctions): Promise { + const items = this.getInputData(); + let item: INodeExecutionData; + const returnData: INodeExecutionData[] = []; + const operation = this.getNodeParameter('operation', 0); + + for (let itemIndex = 0; itemIndex < items.length; itemIndex++) { + try { + if (operation === 'generateHtmlTemplate') { + // ---------------------------------- + // generateHtmlTemplate + // ---------------------------------- + + let html = this.getNodeParameter('html', itemIndex) as string; + + for (const resolvable of getResolvables(html)) { + html = html.replace(resolvable, this.evaluateExpression(resolvable, itemIndex) as any); + } + + const result = this.helpers.constructExecutionMetaData( + this.helpers.returnJsonArray({ html }), + { + itemData: { item: itemIndex }, + }, + ); + + returnData.push(...result); + } else if (operation === 'extractHtmlContent') { + // ---------------------------------- + // extractHtmlContent + // ---------------------------------- + + const dataPropertyName = this.getNodeParameter('dataPropertyName', itemIndex); + const extractionValues = this.getNodeParameter( + 'extractionValues', + itemIndex, + ) as IDataObject; + const options = this.getNodeParameter('options', itemIndex, {}); + const sourceData = this.getNodeParameter('sourceData', itemIndex) as string; + + item = items[itemIndex]; + + let htmlArray: string[] | string = []; + if (sourceData === 'json') { + if (item.json[dataPropertyName] === undefined) { + throw new NodeOperationError( + this.getNode(), + `No property named "${dataPropertyName}" exists!`, + { itemIndex }, + ); + } + htmlArray = item.json[dataPropertyName] as string; + } else { + if (item.binary === undefined) { + throw new NodeOperationError( + this.getNode(), + 'No item does not contain binary data!', + { + itemIndex, + }, + ); + } + if (item.binary[dataPropertyName] === undefined) { + throw new NodeOperationError( + this.getNode(), + `No property named "${dataPropertyName}" exists!`, + { itemIndex }, + ); + } + + const binaryDataBuffer = await this.helpers.getBinaryDataBuffer( + itemIndex, + dataPropertyName, + ); + htmlArray = binaryDataBuffer.toString('utf-8'); + } + + // Convert it always to array that it works with a string or an array of strings + if (!Array.isArray(htmlArray)) { + htmlArray = [htmlArray]; + } + + for (const html of htmlArray as string[]) { + const $ = cheerio.load(html); + + const newItem: INodeExecutionData = { + json: {}, + pairedItem: { + item: itemIndex, + }, + }; + + // Itterate over all the defined values which should be extracted + let htmlElement; + for (const valueData of extractionValues.values as IValueData[]) { + htmlElement = $(valueData.cssSelector); + + if (valueData.returnArray) { + // An array should be returned so itterate over one + // value at a time + newItem.json[valueData.key] = []; + htmlElement.each((i, el) => { + (newItem.json[valueData.key] as Array).push( + getValue($(el), valueData, options), + ); + }); + } else { + // One single value should be returned + newItem.json[valueData.key] = getValue(htmlElement, valueData, options); + } + } + returnData.push(newItem); + } + } + } catch (error) { + if (this.continueOnFail()) { + returnData.push({ + json: { + error: error.message, + }, + pairedItem: { + item: itemIndex, + }, + }); + continue; + } + + throw error; + } + } + + return this.prepareOutputData(returnData); + } +} diff --git a/packages/nodes-base/nodes/Html/html.svg b/packages/nodes-base/nodes/Html/html.svg new file mode 100644 index 0000000000000..80cab9e7ace63 --- /dev/null +++ b/packages/nodes-base/nodes/Html/html.svg @@ -0,0 +1 @@ + diff --git a/packages/nodes-base/nodes/Html/placeholder.ts b/packages/nodes-base/nodes/Html/placeholder.ts new file mode 100644 index 0000000000000..8d8a9faab45bc --- /dev/null +++ b/packages/nodes-base/nodes/Html/placeholder.ts @@ -0,0 +1,44 @@ +export const placeholder = ` + + + + + + My HTML document + + +
+

This is an H1 heading

+

This is an H2 heading

+

This is a paragraph

+
+ + + + + + +`.trim(); diff --git a/packages/nodes-base/nodes/Html/types.ts b/packages/nodes-base/nodes/Html/types.ts new file mode 100644 index 0000000000000..bbe5ab68ac375 --- /dev/null +++ b/packages/nodes-base/nodes/Html/types.ts @@ -0,0 +1,11 @@ +import type cheerio from 'cheerio'; + +export type Cheerio = ReturnType; + +export interface IValueData { + attribute?: string; + cssSelector: string; + returnValue: string; + key: string; + returnArray: boolean; +} diff --git a/packages/nodes-base/nodes/Html/utils.ts b/packages/nodes-base/nodes/Html/utils.ts new file mode 100644 index 0000000000000..3172908a872a4 --- /dev/null +++ b/packages/nodes-base/nodes/Html/utils.ts @@ -0,0 +1,46 @@ +import type { IDataObject } from 'n8n-workflow'; +import type { IValueData, Cheerio } from './types'; + +/** + * @TECH_DEBT Explore replacing with handlebars + */ +export function getResolvables(html: string) { + if (!html) return []; + + const resolvables = []; + const resolvableRegex = /({{[\s\S]*?}})/g; + + let match; + + while ((match = resolvableRegex.exec(html)) !== null) { + if (match[1]) { + resolvables.push(match[1]); + } + } + + return resolvables; +} + +// The extraction functions +const extractFunctions: { + [key: string]: ($: Cheerio, valueData: IValueData) => string | undefined; +} = { + attribute: ($: Cheerio, valueData: IValueData): string | undefined => + $.attr(valueData.attribute!), + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + html: ($: Cheerio, _valueData: IValueData): string | undefined => $.html() || undefined, + text: ($: Cheerio, _valueData: IValueData): string | undefined => $.text(), + value: ($: Cheerio, _valueData: IValueData): string | undefined => $.val(), +}; + +/** + * Simple helper function which applies options + */ +export function getValue($: Cheerio, valueData: IValueData, options: IDataObject) { + const value = extractFunctions[valueData.returnValue]($, valueData); + if (options.trimValues === false || value === undefined) { + return value; + } + + return value.trim(); +} diff --git a/packages/nodes-base/nodes/HtmlExtract/HtmlExtract.node.ts b/packages/nodes-base/nodes/HtmlExtract/HtmlExtract.node.ts index cf731ad498821..843f0c874a4ac 100644 --- a/packages/nodes-base/nodes/HtmlExtract/HtmlExtract.node.ts +++ b/packages/nodes-base/nodes/HtmlExtract/HtmlExtract.node.ts @@ -48,6 +48,7 @@ export class HtmlExtract implements INodeType { icon: 'fa:cut', group: ['transform'], version: 1, + hidden: true, subtitle: '={{$parameter["sourceData"] + ": " + $parameter["dataPropertyName"]}}', description: 'Extracts data from HTML', defaults: { diff --git a/packages/nodes-base/package.json b/packages/nodes-base/package.json index a2429dbc13cdf..f5cb67fdfce91 100644 --- a/packages/nodes-base/package.json +++ b/packages/nodes-base/package.json @@ -501,6 +501,7 @@ "dist/nodes/HighLevel/HighLevel.node.js", "dist/nodes/HomeAssistant/HomeAssistant.node.js", "dist/nodes/HtmlExtract/HtmlExtract.node.js", + "dist/nodes/Html/Html.node.js", "dist/nodes/HttpRequest/HttpRequest.node.js", "dist/nodes/Hubspot/Hubspot.node.js", "dist/nodes/Hubspot/HubspotTrigger.node.js", diff --git a/packages/workflow/src/Interfaces.ts b/packages/workflow/src/Interfaces.ts index 5169359a64d3c..799872891343e 100644 --- a/packages/workflow/src/Interfaces.ts +++ b/packages/workflow/src/Interfaces.ts @@ -980,7 +980,7 @@ export type NodePropertyTypes = export type CodeAutocompleteTypes = 'function' | 'functionItem'; -export type EditorTypes = 'code' | 'codeNodeEditor' | 'json'; +export type EditorTypes = 'code' | 'codeNodeEditor' | 'htmlEditor' | 'json'; export interface ILoadOptions { routing?: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9ae5ec2560c7..7e77d41d41dbb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -542,6 +542,7 @@ importers: '@vitejs/plugin-vue2': ^1.1.2 axios: ^0.21.1 c8: ^7.12.0 + codemirror-lang-html-n8n: ^1.0.0 codemirror-lang-n8n-expression: ^0.1.0 dateformat: ^3.0.3 esprima-next: 5.8.4 @@ -565,6 +566,7 @@ importers: n8n-workflow: ~0.133.2 normalize-wheel: ^1.0.1 pinia: ^2.0.22 + prettier: ^2.8.2 prismjs: ^1.17.1 sass: ^1.55.0 sass-loader: ^10.1.1 @@ -605,6 +607,7 @@ importers: '@fortawesome/free-solid-svg-icons': 5.15.4 '@fortawesome/vue-fontawesome': 2.0.8_tc4irwwlc7tvswdic4b5cxexom axios: 0.21.4 + codemirror-lang-html-n8n: 1.0.0 codemirror-lang-n8n-expression: 0.1.0_zyklskjzaprvz25ee7sq7godcq dateformat: 3.0.3 esprima-next: 5.8.4 @@ -627,6 +630,7 @@ importers: n8n-workflow: link:../workflow normalize-wheel: 1.0.1 pinia: 2.0.23_xjcbg5znturqejtkpd33hx726m + prettier: 2.8.2 prismjs: 1.29.0 timeago.js: 4.0.2 uuid: 8.3.2 @@ -2713,6 +2717,18 @@ packages: '@lezer/common': 1.0.1 dev: false + /@codemirror/lang-css/6.0.1_gu445lycfriim3kznnyeahleva: + resolution: {integrity: sha512-rlLq1Dt0WJl+2epLQeAsfqIsx3lGu4HStHCJu95nGGuz2P2fNugbU3dQYafr2VRjM4eMC9HviI6jvS98CNtG5w==} + dependencies: + '@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i + '@codemirror/language': 6.2.1 + '@codemirror/state': 6.1.4 + '@lezer/css': 1.1.1 + transitivePeerDependencies: + - '@codemirror/view' + - '@lezer/common' + dev: false + /@codemirror/lang-javascript/6.1.2: resolution: {integrity: sha512-OcwLfZXdQ1OHrLiIcKCn7MqZ7nx205CMKlhe+vL88pe2ymhT9+2P+QhwkYGxMICj8TDHyp8HFKVwpiisUT7iEQ==} dependencies: @@ -3315,12 +3331,27 @@ packages: resolution: {integrity: sha512-8TR5++Q/F//tpDsLd5zkrvEX5xxeemafEaek7mUp7Y+bI8cKQXdSqhzTOBaOogETcMOVr0pT3BBPXp13477ciw==} dev: false + /@lezer/css/1.1.1: + resolution: {integrity: sha512-mSjx+unLLapEqdOYDejnGBokB5+AiJKZVclmud0MKQOKx3DLJ5b5VTCstgDDknR6iIV4gVrN6euzsCnj0A2gQA==} + dependencies: + '@lezer/highlight': 1.1.1 + '@lezer/lr': 1.2.3 + dev: false + /@lezer/highlight/1.1.1: resolution: {integrity: sha512-duv9D23O9ghEDnnUDmxu+L8pJy4nYo4AbCOHIudUhscrLSazqeJeK1V50EU6ZufWF1zv0KJwu/frFRyZWXxHBQ==} dependencies: '@lezer/common': 1.0.1 dev: false + /@lezer/html/1.3.0: + resolution: {integrity: sha512-jU/ah8DEoiECLTMouU/X/ujIg6k9WQMIOFMaCLebzaXfrguyGaR3DpTgmk0tbljiuIJ7hlmVJPcJcxGzmCd0Mg==} + dependencies: + '@lezer/common': 1.0.1 + '@lezer/highlight': 1.1.1 + '@lezer/lr': 1.2.3 + dev: false + /@lezer/javascript/1.0.2: resolution: {integrity: sha512-IjOVeIRhM8IuafWNnk+UzRz7p4/JSOKBNINLYLsdSGuJS9Ju7vFdc82AlTt0jgtV5D8eBZf4g0vK4d3ttBNz7A==} dependencies: @@ -8562,7 +8593,7 @@ packages: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} dependencies: pascal-case: 3.1.2 - tslib: 2.4.0 + tslib: 2.4.1 /camelcase-css/2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} @@ -9085,6 +9116,22 @@ packages: engines: {node: '>=0.10.0'} dev: true + /codemirror-lang-html-n8n/1.0.0: + resolution: {integrity: sha512-ofNP6VTDGJ5rue+kTCZlDZdF1PnE0sl2cAkfrsCAd5MlBgDmqTwuFJIkTI6KXOJXs0ucdTYH6QLhy9BSW7EaOQ==} + dependencies: + '@codemirror/autocomplete': 6.4.0_wo7q3lvweq5evsu423o7qzum5i + '@codemirror/lang-css': 6.0.1_gu445lycfriim3kznnyeahleva + '@codemirror/lang-javascript': 6.1.2 + '@codemirror/language': 6.2.1 + '@codemirror/state': 6.1.4 + '@codemirror/view': 6.5.1 + '@lezer/common': 1.0.1 + '@lezer/css': 1.1.1 + '@lezer/highlight': 1.1.1 + '@lezer/html': 1.3.0 + '@lezer/lr': 1.2.3 + dev: false + /codemirror-lang-n8n-expression/0.1.0_zyklskjzaprvz25ee7sq7godcq: resolution: {integrity: sha512-20ss5p0koTu5bfivr1sBHYs7cpjWT2JhVB5gn7TX9WWPt+v/9p9tEcYSOyL/sm+OFuWh698Cgnmrba4efQnMCQ==} dependencies: @@ -10464,7 +10511,7 @@ packages: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} dependencies: no-case: 3.0.4 - tslib: 2.4.0 + tslib: 2.4.1 /dotenv-expand/5.1.0: resolution: {integrity: sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==} @@ -15401,7 +15448,7 @@ packages: /lower-case/2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} dependencies: - tslib: 2.4.0 + tslib: 2.4.1 /lru-cache/4.0.2: resolution: {integrity: sha512-uQw9OqphAGiZhkuPlpFGmdTU2tEuhxTourM/19qGJrxBPHAr/f8BT1a0i/lOclESnGatdJG/UCkP9kZB/Lh1iw==} @@ -16217,7 +16264,7 @@ packages: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} dependencies: lower-case: 2.0.2 - tslib: 2.4.0 + tslib: 2.4.1 /nock/13.2.9: resolution: {integrity: sha512-1+XfJNYF1cjGB+TKMWi29eZ0b82QOvQs2YoLNzbpWGqFMtRQHTa57osqdGj4FrFPgkO4D4AZinzUJR9VvW3QUA==} @@ -16933,7 +16980,7 @@ packages: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} dependencies: dot-case: 3.0.4 - tslib: 2.4.0 + tslib: 2.4.1 /parent-module/1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} @@ -17050,7 +17097,7 @@ packages: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} dependencies: no-case: 3.0.4 - tslib: 2.4.0 + tslib: 2.4.1 /pascalcase/0.1.1: resolution: {integrity: sha512-XHXfu/yOQRy9vYOtUDVMN60OEJjW013GoObG1o+xwQTpB9eYJX/BjXMsdW13ZDPruFhYYn0AG22w0xgQMwl3Nw==} @@ -17652,6 +17699,12 @@ packages: hasBin: true dev: true + /prettier/2.8.2: + resolution: {integrity: sha512-BtRV9BcncDyI2tsuS19zzhzoxD8Dh8LiCx7j7tHzrkz8GFXAexeWFdi22mjE1d16dftH2qNaytVxqiRTGlMfpw==} + engines: {node: '>=10.13.0'} + hasBin: true + dev: false + /pretty-bytes/5.6.0: resolution: {integrity: sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==} engines: {node: '>=6'} @@ -20813,6 +20866,9 @@ packages: /tslib/2.4.0: resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + /tslib/2.4.1: + resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} + /tsscmp/1.0.6: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'}