#!/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); });