From 22503e1ce016d69774dbc388c5335a4e5dc9b6d4 Mon Sep 17 00:00:00 2001 From: RKWojs Date: Thu, 4 Dec 2025 11:25:38 +0100 Subject: [PATCH] feat: add script to export contacts as VCARDs and upload to Radicale --- export-contacts-to-radicale.mjs | 272 ++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 export-contacts-to-radicale.mjs diff --git a/export-contacts-to-radicale.mjs b/export-contacts-to-radicale.mjs new file mode 100644 index 0000000..2d16346 --- /dev/null +++ b/export-contacts-to-radicale.mjs @@ -0,0 +1,272 @@ +#!/usr/bin/env node + +/** + * One-time script to export all contacts as VCARDs and upload to Radicale + * Usage: node export-contacts-to-radicale.mjs + */ + +import db from './src/lib/db.js'; +import readline from 'readline'; +import { createInterface } from 'readline'; + +// VCARD generation helper +function generateVCard(contact) { + const lines = ['BEGIN:VCARD', 'VERSION:3.0']; + + // UID - unique identifier + lines.push(`UID:contact-${contact.contact_id}@panel-app`); + + // Name (FN = Formatted Name, N = Structured Name) + if (contact.name) { + lines.push(`FN:${escapeVCardValue(contact.name)}`); + + // Try to split name into components (Last;First;Middle;Prefix;Suffix) + const nameParts = contact.name.trim().split(/\s+/); + if (nameParts.length === 1) { + lines.push(`N:${escapeVCardValue(nameParts[0])};;;;`); + } else if (nameParts.length === 2) { + lines.push(`N:${escapeVCardValue(nameParts[1])};${escapeVCardValue(nameParts[0])};;;`); + } else { + // More than 2 parts - first is first name, rest is last name + const firstName = nameParts[0]; + const lastName = nameParts.slice(1).join(' '); + lines.push(`N:${escapeVCardValue(lastName)};${escapeVCardValue(firstName)};;;`); + } + } + + // Organization + if (contact.company) { + lines.push(`ORG:${escapeVCardValue(contact.company)}`); + } + + // Title/Position + if (contact.position) { + lines.push(`TITLE:${escapeVCardValue(contact.position)}`); + } + + // Phone numbers - handle multiple phones + if (contact.phone) { + let phones = []; + try { + // Try to parse as JSON array + const parsed = JSON.parse(contact.phone); + phones = Array.isArray(parsed) ? parsed : [contact.phone]; + } catch { + // Fall back to comma-separated or single value + phones = contact.phone.includes(',') + ? contact.phone.split(',').map(p => p.trim()).filter(p => p) + : [contact.phone]; + } + + phones.forEach((phone, index) => { + if (phone) { + // First phone is WORK, others are CELL + const type = index === 0 ? 'WORK' : 'CELL'; + lines.push(`TEL;TYPE=${type},VOICE:${escapeVCardValue(phone)}`); + } + }); + } + + // Email + if (contact.email) { + lines.push(`EMAIL;TYPE=INTERNET,WORK:${escapeVCardValue(contact.email)}`); + } + + // Notes - combine contact type, position context, and notes + const noteParts = []; + if (contact.contact_type) { + const typeLabels = { + project: 'Kontakt projektowy', + contractor: 'Wykonawca', + office: 'Urząd', + supplier: 'Dostawca', + other: 'Inny' + }; + noteParts.push(`Typ: ${typeLabels[contact.contact_type] || contact.contact_type}`); + } + if (contact.notes) { + noteParts.push(contact.notes); + } + if (noteParts.length > 0) { + lines.push(`NOTE:${escapeVCardValue(noteParts.join('\\n'))}`); + } + + // Categories based on contact type + if (contact.contact_type) { + const categories = { + project: 'Projekty', + contractor: 'Wykonawcy', + office: 'Urzędy', + supplier: 'Dostawcy', + other: 'Inne' + }; + lines.push(`CATEGORIES:${categories[contact.contact_type] || 'Inne'}`); + } + + // Timestamps + if (contact.created_at) { + const created = new Date(contact.created_at).toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z'; + lines.push(`REV:${created}`); + } + + lines.push('END:VCARD'); + + return lines.join('\r\n') + '\r\n'; +} + +// Escape special characters in VCARD values +function escapeVCardValue(value) { + if (!value) return ''; + return value + .replace(/\\/g, '\\\\') + .replace(/;/g, '\\;') + .replace(/,/g, '\\,') + .replace(/\n/g, '\\n') + .replace(/\r/g, ''); +} + +// Prompt for input +function prompt(question) { + const rl = createInterface({ + input: process.stdin, + output: process.stdout + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +// Upload VCARD to Radicale via CardDAV +async function uploadToRadicale(vcard, contactId, radicaleUrl, username, password, forceUpdate = false) { + const auth = Buffer.from(`${username}:${password}`).toString('base64'); + + // Ensure URL ends with / + const baseUrl = radicaleUrl.endsWith('/') ? radicaleUrl : radicaleUrl + '/'; + + // Construct the URL for this specific contact + // Format: {base_url}{username}/{addressbook_name}/{contact_id}.vcf + const vcardUrl = `${baseUrl}${username}/a5ca5057-f295-b24a-f828-209786f581e3/contact-${contactId}.vcf`; + + try { + const headers = { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'text/vcard; charset=utf-8' + }; + + // If not forcing update, only create if doesn't exist + if (!forceUpdate) { + headers['If-None-Match'] = '*'; + } + + const response = await fetch(vcardUrl, { + method: 'PUT', + headers: headers, + body: vcard + }); + + // Handle conflict - try again with force update + if (response.status === 412 || response.status === 409) { + // Conflict - contact already exists, update it instead + return await uploadToRadicale(vcard, contactId, radicaleUrl, username, password, true); + } + + if (response.ok || response.status === 201 || response.status === 204) { + return { success: true, status: response.status, updated: forceUpdate }; + } else { + const text = await response.text(); + return { success: false, status: response.status, error: text }; + } + } catch (error) { + return { success: false, error: error.message }; + } +} + +// Main execution +async function main() { + console.log('🚀 Export Contacts to Radicale (CardDAV)\n'); + console.log('This script will export all active contacts as VCARDs and upload them to your Radicale server.\n'); + + // Get Radicale connection details + const radicaleUrl = await prompt('Radicale URL (e.g., http://localhost:5232): '); + const username = await prompt('Username: '); + const password = await prompt('Password: '); + + if (!radicaleUrl || !username || !password) { + console.error('❌ All fields are required!'); + process.exit(1); + } + + console.log('\n📊 Fetching contacts from database...\n'); + + // Get all active contacts + const contacts = db.prepare(` + SELECT * FROM contacts + WHERE is_active = 1 + ORDER BY name ASC + `).all(); + + if (contacts.length === 0) { + console.log('ℹ️ No active contacts found.'); + process.exit(0); + } + + console.log(`Found ${contacts.length} active contacts\n`); + console.log('📤 Uploading to Radicale...\n'); + + let uploaded = 0; + let updated = 0; + let failed = 0; + const errors = []; + + for (const contact of contacts) { + const vcard = generateVCard(contact); + const result = await uploadToRadicale(vcard, contact.contact_id, radicaleUrl, username, password); + + + if (result.success) { + if (result.updated) { + updated++; + console.log(`🔄 ${contact.name} (${contact.contact_id}) - updated`); + } else { + uploaded++; + console.log(`✅ ${contact.name} (${contact.contact_id}) - created`); + } + } else { + failed++; + const errorMsg = `❌ ${contact.name} (${contact.contact_id}): ${result.error || `HTTP ${result.status}`}`; + console.log(errorMsg); + errors.push(errorMsg); + } + + // Small delay to avoid overwhelming the server + await new Promise(resolve => setTimeout(resolve, 100)); + } + + console.log('\n' + '='.repeat(60)); + console.log('📊 Upload Summary:'); + console.log(` ✅ Created: ${uploaded}`); + console.log(` 🔄 Updated: ${updated}`); + console.log(` ❌ Failed: ${failed}`); + console.log(` 📋 Total: ${contacts.length}`); + + if (errors.length > 0 && errors.length <= 10) { + console.log('\n❌ Failed uploads:'); + errors.forEach(err => console.log(` ${err}`)); + } + + if (uploaded > 0 || updated > 0) { + console.log('\n✨ Success! Your contacts have been exported to Radicale.'); + console.log(` Access them at: ${radicaleUrl}`); + } + + console.log(''); +} + +main().catch(error => { + console.error('❌ Error:', error); + process.exit(1); +});