284 lines
8.1 KiB
JavaScript
284 lines
8.1 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
}
|