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 .
- Create a Cloudflare account (https://dash.cloudflare.com/login).
- Create a worker ( below 1.)
- This is the worker code generate by ChatGPT ( below 2)
- Added a Construct URL to the Directory (Glide table) ( below 3)
- Call URL (openlink) from Button (below 4)
- Button Call
- Construct URL column’
1. Cloudflare Dashboard
- 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);
}