/** * Radicale CardDAV Sync Utility * Automatically syncs contacts to Radicale server */ // VCARD generation helper export 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, ''); } // Get Radicale configuration from environment export function getRadicaleConfig() { const url = process.env.RADICALE_URL; const username = process.env.RADICALE_USERNAME; const password = process.env.RADICALE_PASSWORD; // Return null if not configured if (!url || !username || !password) { return null; } return { url, username, password }; } // Check if Radicale sync is enabled export function isRadicaleEnabled() { return getRadicaleConfig() !== null; } // Sync contact to Radicale (internal function with retry logic) async function syncContactToRadicaleInternal(contact, forceUpdate = false) { const config = getRadicaleConfig(); // Skip if not configured if (!config) { return { success: false, reason: 'not_configured' }; } try { const vcard = generateVCard(contact); const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64'); // Ensure URL ends with / const baseUrl = config.url.endsWith('/') ? config.url : config.url + '/'; // Construct the URL for this specific contact const vcardUrl = `${baseUrl}${config.username}/b576a569-4af7-5812-7ddd-3c7cb8caf692/contact-${contact.contact_id}.vcf`; 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 syncContactToRadicaleInternal(contact, 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 }; } } // Sync contact to Radicale export async function syncContactToRadicale(contact) { const config = getRadicaleConfig(); // Skip if not configured if (!config) { console.log('Radicale sync skipped - not configured'); return { success: false, reason: 'not_configured' }; } // Skip if contact is inactive if (contact.is_active === 0) { console.log(`Skipping inactive contact ${contact.contact_id}`); return { success: true, reason: 'inactive_skipped' }; } const result = await syncContactToRadicaleInternal(contact); if (result.success) { const action = result.updated ? 'updated' : 'created'; console.log(`✅ Synced contact ${contact.contact_id} to Radicale (${action})`); } else if (result.reason !== 'not_configured') { console.error(`❌ Failed to sync contact ${contact.contact_id} to Radicale: ${result.status} - ${result.error || result.reason}`); } return result; } // Delete contact from Radicale export async function deleteContactFromRadicale(contactId) { const config = getRadicaleConfig(); // Skip if not configured if (!config) { console.log('Radicale delete skipped - not configured'); return { success: false, reason: 'not_configured' }; } try { const auth = Buffer.from(`${config.username}:${config.password}`).toString('base64'); // Ensure URL ends with / const baseUrl = config.url.endsWith('/') ? config.url : config.url + '/'; // Construct the URL for this specific contact const vcardUrl = `${baseUrl}${config.username}/b576a569-4af7-5812-7ddd-3c7cb8caf692/contact-${contactId}.vcf`; const response = await fetch(vcardUrl, { method: 'DELETE', headers: { 'Authorization': `Basic ${auth}` } }); if (response.ok || response.status === 204 || response.status === 404) { console.log(`✅ Deleted contact ${contactId} from Radicale`); return { success: true, status: response.status }; } else { const text = await response.text(); console.error(`❌ Failed to delete contact ${contactId} from Radicale: ${response.status} - ${text}`); return { success: false, status: response.status, error: text }; } } catch (error) { console.error(`❌ Error deleting contact ${contactId} from Radicale:`, error); return { success: false, error: error.message }; } } // Sync contact asynchronously (fire and forget) export function syncContactAsync(contact) { if (!isRadicaleEnabled()) { return; } // Run sync in background without blocking syncContactToRadicale(contact).catch(error => { console.error('Background sync failed:', error); }); } // Delete contact asynchronously (fire and forget) export function deleteContactAsync(contactId) { if (!isRadicaleEnabled()) { return; } // Run delete in background without blocking deleteContactFromRadicale(contactId).catch(error => { console.error('Background delete failed:', error); }); }