273 lines
7.5 KiB
JavaScript
273 lines
7.5 KiB
JavaScript
#!/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}/b576a569-4af7-5812-7ddd-3c7cb8caf692/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);
|
||
});
|