vCard download example

Here is the method that I followed to create a download vCard functionality (Name, Org, phone, email and Image). This is via ChatGPT.

The method I used was Openlink via a Button call with a URL that calls a Cloudflare Worker (on the free account). On iOS it asks to Add to Contacts; on MacOS it downloads to the download folder with the Contact-Name as the filename.

Not sure how it works on Android but ChatGPT assures me it will work flawlessly :grinning_face:.

  1. Create a Cloudflare account (https://dash.cloudflare.com/login).
  2. Create a worker ( below 1.)
  3. This is the worker code generate by ChatGPT ( below 2)
  4. Added a Construct URL to the Directory (Glide table) ( below 3)
  5. Call URL (openlink) from Button (below 4)

  1. Button Call

  1. Construct URL column’

1. Cloudflare Dashboard

  1. Cloudflare Worker code
*/\*\**

 *\* Welcome to Cloudflare Workers! This is your first worker.*

 *\**

 *\* - Run "npm run dev" in your terminal to start a development server*

 *\* - Open a browser tab at http://localhost:8787/ to see your worker in action*

 *\* - Run "npm run deploy" to publish your worker*

 *\**

 *\* Learn more at* https://developers.cloudflare.com/workers/

 *\*/*



*// Cloudflare Worker: vCard generator with embedded photo (vCard 3.0)*



export default {

async fetch(request, env, ctx) {

const url = new URL(request.url);

const origin = request.headers.get("Origin") || "\*";



if (request.method === "OPTIONS") {

return new Response(null, { headers: corsHeaders(origin) });

}



let input = {};

if (request.method === "POST") {

try { input = await request.json(); } catch (\_) {}

}

*// Allow GET query params too*

for (const k of \[

"name","phone","email","org","title","url",

"street","city","region","postal","country",

"photo","notes","filename"

    \]) {

if (url.searchParams.has(k)) input\[k\] = url.searchParams.get(k);

}



*// Basic validation*

input.name = (input.name || "").trim();

if (!input.name && !input.email && !input.phone) {

return json({ error: "Provide at least name or email or phone." }, 400, origin);

}



*// Fetch photo and convert to base64 (optional)*

let photoBase64 = null, photoType = "JPEG";

if (input.photo) {

try {

const res = await fetch(input.photo);

if (!res.ok) throw new Error(\`HTTP ${res.status}\`);

const ct = (res.headers.get("content-type") || "").toLowerCase();

if (!ct.startsWith("image/")) throw new Error("Not an image");

photoType = (ct.split("/")\[1\] || "jpeg").toUpperCase();

const ab = await res.arrayBuffer();

const b64 = base64FromArrayBuffer(ab);

photoBase64 = b64;

} catch (e) {

*// Continue without photo*

console.warn("Photo fetch failed:", e.message);

}

}



const vcf = buildVCard(input, photoBase64, photoType);

const filename = (input.last || input.name || "contact")

.replace(/\[^a-z0-9-\_\]/gi, "\_") + ".vcf";



return new Response(vcf, {

      status: 200,

      headers: {

...corsHeaders(origin),

"Content-Type": "text/vcard; charset=utf-8",

"Content-Disposition": \`attachment; filename="${filename}"\`,

*// Helpful for iOS/Android handlers*

"X-Content-Type-Options": "nosniff",

},

});

}

};



function json(obj, status = 200, origin = "\*") {

return new Response(JSON.stringify(obj), {

status,

    headers: { ...corsHeaders(origin), "Content-Type": "application/json" }

});

}



function corsHeaders(origin) {

return {

"Access-Control-Allow-Origin": origin,

"Access-Control-Allow-Methods": "GET,POST,OPTIONS",

"Access-Control-Allow-Headers": "Content-Type, Authorization",

};

}



*// ---- vCard helpers ----*



function splitName(full) {

if (!full) return \["","","","",""\]; *// last;first;middle;prefix;suffix*

const parts = full.trim().split(/\\s+/);

const first = parts.shift() || "";

const last = parts.pop() || "";

const middle = parts.join(" ");

return \[last, first, middle, "", ""\];

}



function foldVCard(v) {

*// vCard: lines folded at 75 chars; CRLF line endings*

const CRLF = "\\r\\n";

const out = \[\];

for (const raw of v.split("\\n")) {

let line = raw;

if (line.length <= 75) { out.push(line); continue; }

*// First chunk is as-is, subsequent chunks start with a single space (continuation)*

out.push(line.slice(0, 75));

line = line.slice(75);

while (line.length > 74) {

out.push(" " + line.slice(0, 74));

line = line.slice(74);

}

if (line.length) out.push(" " + line);

}

return out.join(CRLF) + CRLF;

}



function buildVCard(f, photoB64, photoType) {

const \[ln, fn, mn, px, sx\] = splitName(f.name || "");

const now = new Date().toISOString().replace(/\[-:\]/g, "").split(".")\[0\] + "Z";



const lines = \[

"BEGIN:VCARD",

"VERSION:3.0",

\`N:${escapeText(ln)};${escapeText(fn)};${escapeText(mn)};${escapeText(px)};${escapeText(sx)}\`,

\`FN:${escapeText(f.name || \`${fn} ${ln}\`.trim())}\`,

  \];



if (f.org)   lines.push(\`ORG:${escapeText(f.org)}\`);

if (f.title) lines.push(\`TITLE:${escapeText(f.title)}\`);

if (f.url)   lines.push(\`URL:${escapeText(f.url)}\`);

if (f.phone) lines.push(\`TEL;TYPE=CELL,VOICE:${escapeText(f.phone)}\`);

if (f.email) lines.push(\`EMAIL;TYPE=INTERNET:${escapeText(f.email)}\`);



const adr = \[

"", "",  *// PO Box; Extended*

f.street || "", f.city || "", f.region || "", f.postal || "", f.country || ""

  \].map(escapeText).join(";");



*// only include if any address fields present*

if (/(^|;)\\\\S/.test(adr)) lines.push(\`ADR;TYPE=WORK:;${adr}\`);



if (photoB64) {

lines.push(\`PHOTO;ENCODING=b;TYPE=${photoType}:${photoB64}\`);

}



if (f.notes) lines.push(\`NOTE:${escapeText(f.notes)}\`);

lines.push(\`REV:${now}\`);

lines.push("END:VCARD");



return foldVCard(lines.join("\\n"));

}



function escapeText(s = "") {

*// vCard text escaping: commas, semicolons, and backslashes*

return String(s).replace(/\\\\/g, "\\\\\\\\").replace(/,/g, "\\\\,").replace(/;/g, "\\\\;");

}



function base64FromArrayBuffer(ab) {

*// Convert ArrayBuffer to base64 safely in Workers*

const bytes = new Uint8Array(ab);

let binary = "";

const chunk = 0x8000; *// 32KB*

for (let i = 0; i < bytes.length; i += chunk) {

binary += String.fromCharCode(...bytes.subarray(i, i + chunk));

}

return btoa(binary);

}
2 Likes

@ThinhDinh I would move this to Community Resources

1 Like

The numbering of steps here was a bit confusing to me @MattLB . Can you explain to me why you number it that way?

1 Like

Tried to do it step by step but the code was so long…so I moved everything to the bottom and added a number….except for the code where I put the number at the top cause the code was soooo long.

Also I forgot step - @Jeff_Hager JS code to convert to base64 for the vCard URL using the Contacts image.

function convertImageToBase64(url) {
return fetch(url)
.then(response => response.blob())
.then(blob => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
});
}

return convertImageToBase64(p1);

1 Like

Also, the Glide screen looks like this:

and on my iPhone it opens up as:

On the Mac it is downloaded as Bono.vcf

1 Like

Just as an aside, whenever you share code, please enclose it in triple backticks, then it will render like code and is easily copied. When you just paste it without the backticks, any double-quotes are replaced with “smart” quotes, which renders the code unusable.

Here is what the above looks like when it’s added correctly:

function convertImageToBase64(url) {
  return fetch(url)
  .then(response => response.blob())
  .then(blob => {
    return new Promise((resolve, reject) => {
      const reader = new FileReader();
      reader.onloadend = () => resolve(reader.result);
      reader.onerror = reject;
      reader.readAsDataURL(blob);
    });
  });
}

return convertImageToBase64(p1);
5 Likes