Files
panel/export-contacts-to-radicale.mjs

273 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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