Function to parse vCard into JSON elements

I have users contact info saved as a vCard within a JSON object. I’d like to access this and extract the elements (Name, Tel, Email etc) into JSON elements to that I can access/reference them individually within n8n.

I’ve found a few resources online to support with this - like this npm library (vcard - npm) and whilst I read that it is possible to use external libraries with n8n - I’m not sure if it is whilst hosting my instance on n8n Cloud.

Would love to hear from other n8n user who either know how to do this, or have accomplished something similar via any means that could sit in n8n.

Below is an example of a typical vCard:

BEGIN:VCARD
VERSION:3.0
PRODID:-//Apple Inc.//iPhone OS 15.1//EN
N:Bucket;Sam;;;
FN:Sam Bucket
TEL;type=CELL;type=VOICE;type=pref:+14183838117
item1.TEL:+1201649394
item1.X-ABLabel:InReach
item2.TEL:+881632722637
item2.X-ABLabel:Sat Phone
ADR;type=HOME;type=pref:;;Kings Beach;California;96188;United States
item3.URL;type=pref:www.url.org
item3.X-ABLabel:$!!$
BDAY:1979-01-03
item4.IMPP;X-SERVICE-TYPE=Facebook;type=pref:xmpp:username
item4.X-ABLabel:Facebook
END:VCARD

Hi @Felix_is_stuck, this sounds like quite a challenge to build yourself without a designated node. Luckily, this problem has already been solved, for example here:

You could for example use such parser code in a Function node. Here is an example using the above parser and then reading the full name (which just takes the first value from the FN field) as well as the home address (which performs a bit of filtering for demo purposes):

Example Workflow
{
  "name": "vcard example",
  "nodes": [
    {
      "parameters": {},
      "name": "Start",
      "type": "n8n-nodes-base.start",
      "typeVersion": 1,
      "position": [
        240,
        300
      ]
    },
    {
      "parameters": {
        "values": {
          "string": [
            {
              "name": "vcard",
              "value": "BEGIN:VCARD\nVERSION:3.0\nPRODID:-//Apple Inc.//iPhone OS 15.1//EN\nN:Bucket;Sam;;;\nFN:Sam Bucket\nTEL;type=CELL;type=VOICE;type=pref:+14183838117\nitem1.TEL:+1201649394\nitem1.X-ABLabel:InReach\nitem2.TEL:+881632722637\nitem2.X-ABLabel:Sat Phone\nADR;type=HOME;type=pref:;;Kings Beach;California;96188;United States\nitem3.URL;type=pref:www.url.org\nitem3.X-ABLabel:$!!$\nBDAY:1979-01-03\nitem4.IMPP;X-SERVICE-TYPE=Facebook;type=pref:xmpp:username\nitem4.X-ABLabel:Facebook\nEND:VCARD"
            }
          ]
        },
        "options": {}
      },
      "name": "Set vcard",
      "type": "n8n-nodes-base.set",
      "typeVersion": 1,
      "position": [
        460,
        300
      ]
    },
    {
      "parameters": {
        "functionCode": "/* START vcard.js */\n/* Code from https://github.com/Heymdall/vcard/blob/master/lib/vcard.js */\n\nvar PREFIX = 'BEGIN:VCARD',\n    POSTFIX = 'END:VCARD';\n\n/**\n * Return json representation of vCard\n * @param {string} string raw vCard\n * @returns {*}\n */\nfunction parse(string) {\n    var result = {},\n        lines = string.split(/\\r\\n|\\r|\\n/),\n        count = lines.length,\n        pieces,\n        key,\n        value,\n        meta,\n        namespace;\n\n    for (var i = 0; i < count; i++) {\n        if (lines[i] === '') {\n            continue;\n        }\n        if (lines[i].toUpperCase() === PREFIX || lines[i].toUpperCase() === POSTFIX) {\n            continue;\n        }\n        var data = lines[i];\n\n        /**\n         * Check that next line continues current\n         * @param {number} i\n         * @returns {boolean}\n         */\n        var isValueContinued = function (i) {\n            return i + 1 < count && (lines[i + 1][0] === ' ' || lines[i + 1][0] === '\\t');\n        };\n        // handle multiline properties (i.e. photo).\n        // next line should start with space or tab character\n        if (isValueContinued(i)) {\n            while (isValueContinued(i)) {\n                data += lines[i + 1].trim();\n                i++;\n            }\n        }\n\n        pieces = data.split(':');\n        key = pieces.shift();\n        value = pieces.join(':');\n        namespace = false;\n        meta = {};\n\n        // meta fields in property\n        if (key.match(/;/)) {\n            key = key\n                .replace(/\\\\;/g, 'ΩΩΩ')\n                .replace(/\\\\,/, ',');\n            var metaArr = key.split(';').map(function (item) {\n                return item.replace(/ΩΩΩ/g, ';');\n            });\n            key = metaArr.shift();\n            metaArr.forEach(function (item) {\n                var arr = item.split('=');\n                arr[0] = arr[0].toLowerCase();\n                if (arr[0].length === 0) {\n                    return;\n                }\n                if (meta[arr[0]]) {\n                    meta[arr[0]].push(arr[1]);\n                } else {\n                    meta[arr[0]] = [arr[1]];\n                }\n            });\n        }\n\n        // values with \\n\n        value = value\n            .replace(/\\\\n/g, '\\n');\n\n        value = tryToSplit(value);\n\n        // Grouped properties\n        if (key.match(/\\./)) {\n            var arr = key.split('.');\n            key = arr[1];\n            namespace = arr[0];\n        }\n\n        var newValue = {\n            value: value\n        };\n        if (Object.keys(meta).length) {\n            newValue.meta = meta;\n        }\n        if (namespace) {\n            newValue.namespace = namespace;\n        }\n\n        if (key.indexOf('X-') !== 0) {\n            key = key.toLowerCase();\n        }\n\n        if (typeof result[key] === 'undefined') {\n            result[key] = [newValue];\n        } else {\n            result[key].push(newValue);\n        }\n\n    }\n\n    return result;\n}\n\nvar HAS_SEMICOLON_SEPARATOR = /[^\\\\];|^;/,\n    HAS_COMMA_SEPARATOR = /[^\\\\],|^,/;\n/**\n * Split value by \",\" or \";\" and remove escape sequences for this separators\n * @param {string} value\n * @returns {string|string[]\n */\nfunction tryToSplit(value) {\n    if (value.match(HAS_SEMICOLON_SEPARATOR)) {\n        value = value.replace(/\\\\,/g, ',');\n        return splitValue(value, ';');\n    } else if (value.match(HAS_COMMA_SEPARATOR)) {\n        value = value.replace(/\\\\;/g, ';');\n        return splitValue(value, ',');\n    } else {\n        return value\n            .replace(/\\\\,/g, ',')\n            .replace(/\\\\;/g, ';');\n    }\n}\n/**\n * Split vcard field value by separator\n * @param {string|string[]} value\n * @param {string} separator\n * @returns {string|string[]}\n */\nfunction splitValue(value, separator) {\n    var separatorRegexp = new RegExp(separator);\n    var escapedSeparatorRegexp = new RegExp('\\\\\\\\' + separator, 'g');\n    // easiest way, replace it with really rare character sequence\n    value = value.replace(escapedSeparatorRegexp, 'ΩΩΩ');\n    if (value.match(separatorRegexp)) {\n        value = value.split(separator);\n\n        value = value.map(function (item) {\n            return item.replace(/ΩΩΩ/g, separator);\n        });\n    } else {\n        value = value.replace(/ΩΩΩ/g, separator);\n    }\n    return value;\n}\n\nvar guid = (function() {\n    function s4() {\n        return Math.floor((1 + Math.random()) * 0x10000)\n            .toString(16)\n            .substring(1);\n    }\n    return function() {\n        return s4() + s4() + '-' + s4() + '-' + s4() + '-' +\n            s4() + '-' + s4() + s4() + s4();\n    };\n})();\n\nvar COMMA_SEPARATED_FIELDS = ['nickname', 'related', 'categories', 'pid'];\n\nvar REQUIRED_FIELDS = ['fn'];\n\n/**\n * Generate vCard representation af object\n * @param {*} data\n * @param {boolean=} addRequired determine if generator should add required properties (version and uid)\n * @returns {string}\n */\nfunction generate(data, addRequired) {\n    var lines = [PREFIX],\n        line = '';\n\n    if (addRequired && !data.version) {\n        data.version = [{value: '3.0'}];\n    }\n    if (addRequired && !data.uid) {\n        data.uid = [{value: guid()}];\n    }\n\n    var escapeCharacters = function (v) {\n        if (typeof v === 'undefined') {\n            return '';\n        }\n        return v\n            .replace(/\\n/g, '\\\\n')\n            .replace(/;/g, '\\\\;')\n            .replace(/,/g, '\\\\,')\n    };\n\n    var escapeTypeCharacters = function(v) {\n        if (typeof v === 'undefined') {\n            return '';\n        }\n        return v\n            .replace(/\\n/g, '\\\\n')\n            .replace(/;/g, '\\\\;')\n    };\n\n    Object.keys(data).forEach(function (key) {\n        if (!data[key] || typeof data[key].forEach !== 'function') {\n            return;\n        }\n        data[key].forEach(function (value) {\n            // ignore undefined values\n            if (typeof value.value === 'undefined') {\n                return;\n            }\n\n            // ignore empty values (unless it's a required field)\n            if (value.value === '' && REQUIRED_FIELDS.indexOf(key) === -1) {\n                return;\n            }\n\n            // ignore empty array values\n            if (value.value instanceof Array) {\n                var empty = true;\n                for (var i = 0; i < value.value.length; i++) {\n                    if (typeof value.value[i] !== 'undefined' && value.value[i] !== '') {\n                        empty = false;\n                        break;\n                    }\n                }\n                if (empty) {\n                    return;\n                }\n            }\n            line = '';\n\n            // add namespace if exists\n            if (value.namespace) {\n                line += value.namespace + '.';\n            }\n            line += key.indexOf('X-') === 0 ? key : key.toUpperCase();\n\n            // add meta properties\n            if (typeof value.meta === 'object') {\n                Object.keys(value.meta).forEach(function (metaKey) {\n                    // values of meta tags must be an array\n                    if (typeof value.meta[metaKey].forEach !== 'function') {\n                        return;\n                    }\n                    value.meta[metaKey].forEach(function (metaValue) {\n                        if (metaKey.length > 0) {\n                            if (metaKey.toUpperCase() === 'TYPE') {\n                                // Do not escape the comma when it is the type property. This breaks a lot.\n                                line += ';' + escapeCharacters(metaKey.toUpperCase()) + '=' + escapeTypeCharacters(metaValue);\n                            } else {\n                                line += ';' + escapeCharacters(metaKey.toUpperCase()) + '=' + escapeCharacters(metaValue);\n                            }\n                        }\n                    });\n                });\n            }\n\n            line += ':';\n\n\n\n            if (typeof value.value === 'string') {\n                line += escapeCharacters(value.value);\n            } else {\n                // list-values\n                var separator = COMMA_SEPARATED_FIELDS.indexOf(key) !== -1\n                    ? ','\n                    : ';';\n                line += value.value.map(function (item) {\n                    return escapeCharacters(item);\n                }).join(separator);\n            }\n\n            // line-length limit. Content lines\n            // SHOULD be folded to a maximum width of 75 octets, excluding the line break.\n            if (line.length > 75) {\n                var firstChunk = line.substr(0, 75),\n                    least = line.substr(75);\n                var splitted = least.match(/.{1,74}/g);\n                lines.push(firstChunk);\n                splitted.forEach(function (chunk) {\n                    lines.push(' ' + chunk);\n                });\n            } else {\n                lines.push(line);\n            }\n        });\n    });\n\n    lines.push(POSTFIX);\n    return lines.join('\\r\\n');\n}\n\n/* END vcard.js */\n\nfor (item of items) {\n    const parsed = parse(item.json.vcard);\n    item.json.full_name = parsed.fn[0].value;\n    item.json.home_address = parsed.adr\n        .find(e => e.meta.type.includes('HOME')).value\n        .filter(e => e)\n        .join(', ');\n}\n\nreturn items;"
      },
      "name": "Parse",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [
        680,
        300
      ]
    }
  ],
  "connections": {
    "Start": {
      "main": [
        [
          {
            "node": "Set vcard",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set vcard": {
      "main": [
        [
          {
            "node": "Parse",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Parse": {
      "main": [
        []
      ]
    }
  },
  "active": false,
  "settings": {},
  "id": 1155
}

Hope this helps! Let me know if you run into any trouble here.

Wow. You are my hero.

I tried to adapt the code to output an Email variable (if it’s present) but I’m clearly doing it wrong as it failed.

for (item of items) {
    const parsed = parse(item.json.VCard);
    item.json.full_name = parsed.fn[0].value;
    item.json.email = parsed.email[0].value;
    item.json.home_address = parsed.adr
        .find(e => e.meta.type.includes('HOME')).value
        .filter(e => e)
        .join(', ');
}

The function as pasted from your example above is also failing if I pass a different vCard to it - one that doesn’t have a ‘Home Address’ set, is that the expected behavior?

Ideally it would output all of the variables stored in the vCard, and only if they are present.

Ah, I didn’t add any checks as to whether the respective element exists in my example code. So when trying to extract a non-existing field, you’d indeed get an error (probably something like Cannot read property '0' of undefined).

I don’t know much about vcards and all the different formats they could have, but have added some checks using the ternary operator in the below example:

for (item of items) {
    const parsed = parse(item.json.vcard);
    console.log(parsed);
    item.json.full_name = parsed.fn && parsed.fn.length > 0 ? parsed.fn[0].value : null;
    item.json.home_address = parsed.adr && parsed.adr.find(e => e.meta.type.includes('HOME')) ? parsed.adr
        .find(e => e.meta.type.includes('HOME')).value
        .filter(e => e)
        .join(', ') : null;
    item.json.email = parsed.email && parsed.email.length > 0 ? parsed.email[0].value : null;
}

return items;

This would simply leave non-existent field empty instead of throwing an error:

I have also added console.log(parsed); to the example snippet above, so you can easily inspect the parsed data in your browser console:
image

Thank you!!

1 Like