docs: add CardDAV contacts sync design spec
- Multi-account CardDAV support with addressbook selection - Inbound-only sync (CardDAV → Oikos) - Smart merge by email/phone, preserve manual changes - Extended contacts schema: organization, job_title, birthday, website, photo, nickname - Separate tables for multiple phones/emails/addresses - UI integration in Settings → Calendar tab - Issue #10 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,506 @@
|
|||||||
|
# CardDAV Contacts Sync Design
|
||||||
|
|
||||||
|
**Issue:** #10 – CardDAV provider for Contacts
|
||||||
|
**Date:** 2026-05-04
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Enable multi-account CardDAV synchronization for the Contacts module, allowing family members to sync their phone contacts into Oikos. This implements inbound-only sync (CardDAV → Oikos) with smart merging, multiple values per contact (phones, emails, addresses), and per-account addressbook selection.
|
||||||
|
|
||||||
|
## Requirements Summary
|
||||||
|
|
||||||
|
Based on Issue #10 and design discussion:
|
||||||
|
|
||||||
|
1. **Multi-Account Support** – Connect multiple CardDAV servers simultaneously (iCloud, Nextcloud, company servers)
|
||||||
|
2. **Addressbook Selection** – Checkbox-based enable/disable per addressbook (like CalDAV calendar selection)
|
||||||
|
3. **Inbound-Only Sync** – CardDAV → Oikos; no outbound sync (read-only from server perspective)
|
||||||
|
4. **Smart Merge** – Match by email/phone; update existing contacts instead of creating duplicates
|
||||||
|
5. **Editable with Merge** – Synced contacts are editable in Oikos; manual changes preserved (only NULL fields filled on sync)
|
||||||
|
6. **Hybrid Sync** – Auto-sync via cron + manual "Sync Now" button
|
||||||
|
7. **Visual Source Marking** – Icon/badge shows which account synced each contact
|
||||||
|
8. **Keep on Delete** – When account/addressbook deleted, contacts remain (lose CardDAV link, become manual contacts)
|
||||||
|
9. **Settings Integration** – New "Contacts Sync" section in Settings → Calendar tab
|
||||||
|
10. **Full Field Support** – Extended schema for all iOS/Android contact fields (organization, job title, birthday, website, photo, nickname)
|
||||||
|
11. **Multiple Values** – Separate tables for phones/emails/addresses with labels (mobile, work, home)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
- **Service:** `server/services/cardav-sync.js` – Account management, addressbook discovery, contact sync
|
||||||
|
- **API Routes:** `server/routes/contacts.js` extended + new `/cardav/*` endpoints
|
||||||
|
- **DB Tables:** 6 new/extended tables (Migration 30)
|
||||||
|
- **UI:** Settings → Calendar tab extended with "Contacts Sync" section
|
||||||
|
- **Library:** `tsdav` (already present as optionalDependency)
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
CardDAV Server → tsdav → cardav-sync.js → Smart Merge → contacts + contact_phones/emails/addresses
|
||||||
|
↓
|
||||||
|
UI (Settings, Contacts List)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Schema (Migration 30)
|
||||||
|
|
||||||
|
### New Table: `cardav_accounts`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE cardav_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL, -- User-defined label ("iCloud", "Nextcloud")
|
||||||
|
cardav_url TEXT NOT NULL, -- CardDAV server base URL
|
||||||
|
username TEXT NOT NULL, -- CardDAV username
|
||||||
|
password TEXT NOT NULL, -- Encrypted if DB_ENCRYPTION_KEY set
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
last_sync TEXT, -- ISO 8601, nullable
|
||||||
|
UNIQUE(cardav_url, username)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Table: `cardav_addressbook_selection`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE cardav_addressbook_selection (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id INTEGER NOT NULL, -- FK → cardav_accounts
|
||||||
|
addressbook_url TEXT NOT NULL, -- CardDAV addressbook URL
|
||||||
|
addressbook_name TEXT NOT NULL, -- Display name from provider
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1, -- 0 = disabled, 1 = enabled
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
UNIQUE(account_id, addressbook_url),
|
||||||
|
FOREIGN KEY(account_id) REFERENCES cardav_accounts(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extended Table: `contacts`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE contacts ADD COLUMN organization TEXT; -- Company/Organization
|
||||||
|
ALTER TABLE contacts ADD COLUMN job_title TEXT; -- Job title
|
||||||
|
ALTER TABLE contacts ADD COLUMN birthday TEXT; -- ISO 8601 date (YYYY-MM-DD)
|
||||||
|
ALTER TABLE contacts ADD COLUMN website TEXT; -- URL
|
||||||
|
ALTER TABLE contacts ADD COLUMN photo TEXT; -- Base64 data URL
|
||||||
|
ALTER TABLE contacts ADD COLUMN nickname TEXT;
|
||||||
|
ALTER TABLE contacts ADD COLUMN cardav_account_id INTEGER; -- FK → cardav_accounts, nullable
|
||||||
|
ALTER TABLE contacts ADD COLUMN cardav_uid TEXT; -- vCard UID from server, nullable
|
||||||
|
ALTER TABLE contacts ADD COLUMN cardav_addressbook_url TEXT; -- Source addressbook, nullable
|
||||||
|
|
||||||
|
-- Indices for Smart Merge
|
||||||
|
CREATE INDEX idx_contacts_cardav_uid ON contacts(cardav_uid);
|
||||||
|
CREATE INDEX idx_contacts_email ON contacts(email);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Existing `phone`, `email`, `address` columns remain for backward compatibility and as fallback for primary values.
|
||||||
|
|
||||||
|
### New Table: `contact_phones`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE contact_phones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
contact_id INTEGER NOT NULL,
|
||||||
|
label TEXT, -- 'mobile', 'work', 'home', 'other', 'iphone', 'main', 'fax'
|
||||||
|
value TEXT NOT NULL, -- Phone number
|
||||||
|
is_primary INTEGER NOT NULL DEFAULT 0, -- 1 = primary number
|
||||||
|
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_contact_phones_contact ON contact_phones(contact_id);
|
||||||
|
CREATE INDEX idx_contact_phones_value ON contact_phones(value);
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Table: `contact_emails`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE contact_emails (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
contact_id INTEGER NOT NULL,
|
||||||
|
label TEXT, -- 'work', 'home', 'other', 'icloud'
|
||||||
|
value TEXT NOT NULL, -- Email address
|
||||||
|
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_contact_emails_contact ON contact_emails(contact_id);
|
||||||
|
CREATE INDEX idx_contact_emails_value ON contact_emails(value);
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Table: `contact_addresses`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE contact_addresses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
contact_id INTEGER NOT NULL,
|
||||||
|
label TEXT, -- 'home', 'work', 'other'
|
||||||
|
street TEXT,
|
||||||
|
city TEXT,
|
||||||
|
state TEXT,
|
||||||
|
postal_code TEXT,
|
||||||
|
country TEXT,
|
||||||
|
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_contact_addresses_contact ON contact_addresses(contact_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design Decisions
|
||||||
|
|
||||||
|
- **`cardav_uid`** stores vCard UID from server for re-sync identification
|
||||||
|
- **`cardav_account_id`** is NULL for manual contacts, set for synced contacts
|
||||||
|
- **Account deletion:** Sets `cardav_account_id = NULL` (contacts remain as manual contacts)
|
||||||
|
- **`is_primary`** flag marks primary phone/email/address for UI display and tel:/mailto: links
|
||||||
|
- **Backward compatibility:** Existing `phone`, `email`, `address` columns remain; synced contacts also populate these with primary values
|
||||||
|
|
||||||
|
## Sync Service (`server/services/cardav-sync.js`)
|
||||||
|
|
||||||
|
### Structure
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Account Management
|
||||||
|
addAccount(name, cardavUrl, username, password)
|
||||||
|
→ Test connection via tsdav
|
||||||
|
→ Store encrypted password
|
||||||
|
→ Insert into cardav_accounts
|
||||||
|
→ Discover and insert addressbooks
|
||||||
|
→ Return { account, addressbooks }
|
||||||
|
|
||||||
|
deleteAccount(accountId)
|
||||||
|
→ SET cardav_account_id = NULL for all contacts (keep contacts)
|
||||||
|
→ DELETE from cardav_accounts (CASCADE deletes addressbook_selection)
|
||||||
|
|
||||||
|
testConnection(cardavUrl, username, password)
|
||||||
|
→ Use tsdav.createDAVClient() to connect
|
||||||
|
→ Fetch addressbooks to verify
|
||||||
|
→ Return { ok: true, addressbooks } or throw error
|
||||||
|
|
||||||
|
getAllAccounts()
|
||||||
|
→ SELECT * FROM cardav_accounts
|
||||||
|
|
||||||
|
// Addressbook Discovery
|
||||||
|
discoverAddressbooks(accountId)
|
||||||
|
→ Fetch addressbooks from server via tsdav
|
||||||
|
→ UPSERT into cardav_addressbook_selection
|
||||||
|
→ Return list with enabled status
|
||||||
|
|
||||||
|
// Contact Sync
|
||||||
|
syncAccount(accountId)
|
||||||
|
→ Get all enabled addressbooks for account
|
||||||
|
→ For each: syncAddressbook(accountId, addressbookUrl)
|
||||||
|
→ Update last_sync timestamp
|
||||||
|
→ Return { synced: count, errors: count }
|
||||||
|
|
||||||
|
syncAddressbook(accountId, addressbookUrl)
|
||||||
|
→ Fetch all vCards from addressbook via tsdav
|
||||||
|
→ For each vCard: parseAndMergeContact(vCardText, accountId, addressbookUrl)
|
||||||
|
|
||||||
|
parseAndMergeContact(vCardText, accountId, addressbookUrl)
|
||||||
|
→ Parse vCard fields (see Field Mapping below)
|
||||||
|
→ Apply Smart Merge Logic
|
||||||
|
→ Insert/Update contacts + contact_phones/emails/addresses
|
||||||
|
```
|
||||||
|
|
||||||
|
### Smart Merge Logic
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Extract UID from vCard
|
||||||
|
|
||||||
|
2. Check: EXISTS contact WHERE cardav_uid = UID?
|
||||||
|
→ YES:
|
||||||
|
- UPDATE existing contact (only NULL fields are filled)
|
||||||
|
- Preserve manual changes in non-NULL fields
|
||||||
|
- UPDATE cardav_account_id, cardav_addressbook_url
|
||||||
|
→ NO:
|
||||||
|
Check: EXISTS contact WHERE email IN vCard.emails OR phone IN vCard.phones?
|
||||||
|
→ YES:
|
||||||
|
- UPDATE existing contact (fill NULL fields)
|
||||||
|
- SET cardav_uid, cardav_account_id (establish link)
|
||||||
|
→ NO:
|
||||||
|
- INSERT new contact with all vCard fields
|
||||||
|
- SET cardav_uid, cardav_account_id
|
||||||
|
|
||||||
|
3. Update contact_phones/emails/addresses:
|
||||||
|
- DELETE existing entries for this contact WHERE is_primary = 0
|
||||||
|
- INSERT new entries from vCard
|
||||||
|
- Keep entries WHERE is_primary = 1 (manually marked)
|
||||||
|
- If no primary exists, mark first entry as primary
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Mapping (vCard → Oikos)
|
||||||
|
|
||||||
|
| vCard Property | Oikos Field(s) | Notes |
|
||||||
|
|----------------|----------------|-------|
|
||||||
|
| `FN` | `name` | Formatted name |
|
||||||
|
| `N` | `name` | Fallback if FN missing |
|
||||||
|
| `TEL` | `contact_phones` | Multiple entries with labels |
|
||||||
|
| `EMAIL` | `contact_emails` | Multiple entries with labels |
|
||||||
|
| `ADR` | `contact_addresses` | Multiple entries with labels |
|
||||||
|
| `ORG` | `organization` | Company/organization |
|
||||||
|
| `TITLE` | `job_title` | Job title |
|
||||||
|
| `URL` | `website` | First URL (if multiple, take first) |
|
||||||
|
| `BDAY` | `birthday` | ISO 8601 date (YYYY-MM-DD) |
|
||||||
|
| `PHOTO` | `photo` | Base64 data URL |
|
||||||
|
| `NICKNAME` | `nickname` | Nickname |
|
||||||
|
| `NOTE` | `notes` | Notes |
|
||||||
|
| `CATEGORIES` | `category` | Map to Oikos categories or use 'Sonstiges' |
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
- **Connection failures:** Log error, skip sync, return error to UI
|
||||||
|
- **Invalid vCards:** Log warning, skip contact, continue with next
|
||||||
|
- **Database errors:** Rollback transaction, return error
|
||||||
|
- **Auth failures:** Log error, mark account as "needs re-auth" (future enhancement)
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
### New CardDAV Management Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/contacts/cardav/accounts
|
||||||
|
Body: { name, cardavUrl, username, password }
|
||||||
|
Response: { data: { account, addressbooks: [...] } }
|
||||||
|
|
||||||
|
GET /api/v1/contacts/cardav/accounts
|
||||||
|
Response: { data: [{ id, name, cardavUrl, username, lastSync }] }
|
||||||
|
|
||||||
|
DELETE /api/v1/contacts/cardav/accounts/:id
|
||||||
|
Response: { data: { deleted: true } }
|
||||||
|
|
||||||
|
POST /api/v1/contacts/cardav/accounts/:id/test
|
||||||
|
Response: { data: { ok: true } }
|
||||||
|
|
||||||
|
GET /api/v1/contacts/cardav/accounts/:id/addressbooks
|
||||||
|
Response: { data: [{ id, url, name, enabled }] }
|
||||||
|
|
||||||
|
POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh
|
||||||
|
Response: { data: [{ id, url, name, enabled }] }
|
||||||
|
|
||||||
|
PUT /api/v1/contacts/cardav/addressbooks/:id
|
||||||
|
Body: { enabled: true/false }
|
||||||
|
Response: { data: { id, enabled } }
|
||||||
|
|
||||||
|
POST /api/v1/contacts/cardav/accounts/:id/sync
|
||||||
|
Response: { data: { synced: 15, errors: 0 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extended Contacts Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/contacts/:id
|
||||||
|
Response: {
|
||||||
|
data: {
|
||||||
|
id, name, category, notes, organization, jobTitle, birthday,
|
||||||
|
website, photo, nickname, cardavAccountId, cardavUid,
|
||||||
|
phones: [{ id, label, value, isPrimary }],
|
||||||
|
emails: [{ id, label, value, isPrimary }],
|
||||||
|
addresses: [{ id, label, street, city, state, postalCode, country, isPrimary }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
POST /api/v1/contacts
|
||||||
|
Body: { name, ..., phones: [...], emails: [...], addresses: [...] }
|
||||||
|
Response: { data: Contact }
|
||||||
|
|
||||||
|
PUT /api/v1/contacts/:id
|
||||||
|
Body: { name, ..., phones: [...], emails: [...], addresses: [...] }
|
||||||
|
Response: { data: Contact }
|
||||||
|
```
|
||||||
|
|
||||||
|
## UI Integration
|
||||||
|
|
||||||
|
### Settings → Calendar Tab (Extended)
|
||||||
|
|
||||||
|
Restructure with two sections:
|
||||||
|
|
||||||
|
```
|
||||||
|
Settings → Calendar
|
||||||
|
|
||||||
|
[Section 1: Calendar Sync]
|
||||||
|
- Google Calendar OAuth
|
||||||
|
- CalDAV Accounts
|
||||||
|
- ICS Subscriptions
|
||||||
|
|
||||||
|
[Section 2: Contacts Sync] ← NEW
|
||||||
|
- CardDAV Accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
**CardDAV Account Card:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="sync-account-card">
|
||||||
|
<div class="account-header">
|
||||||
|
<strong>iCloud</strong>
|
||||||
|
<span class="last-sync">Last sync: 2 minutes ago</span>
|
||||||
|
</div>
|
||||||
|
<div class="account-actions">
|
||||||
|
<button class="refresh-addressbooks">Refresh Addressbooks</button>
|
||||||
|
<button class="sync-now">Sync Now</button>
|
||||||
|
<button class="delete-account">Delete</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Addressbook Selection (expandable) -->
|
||||||
|
<div class="addressbook-list">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked data-id="1">
|
||||||
|
📇 Personal (enabled)
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" data-id="2">
|
||||||
|
💼 Work (disabled)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Add Account Modal:**
|
||||||
|
- Fields: Name, CardDAV URL, Username, Password
|
||||||
|
- Test connection on save
|
||||||
|
- On success: Show addressbook list immediately
|
||||||
|
|
||||||
|
### Contact List (`public/pages/contacts.js`)
|
||||||
|
|
||||||
|
**Source Badge:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="contact-card">
|
||||||
|
<div class="contact-header">
|
||||||
|
<strong>Max Mustermann</strong>
|
||||||
|
<span class="contact-source-badge" v-if="contact.cardavAccountId">
|
||||||
|
<i data-lucide="cloud"></i> iCloud
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="contact-phones">
|
||||||
|
📱 +49 123 456 (mobile) · 🏢 +49 789 (work)
|
||||||
|
</div>
|
||||||
|
<div class="contact-emails">
|
||||||
|
✉️ max@example.com (home) · 💼 max@work.com (work)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contact Modal (Extended)
|
||||||
|
|
||||||
|
**New Fields:**
|
||||||
|
- Organization (text input)
|
||||||
|
- Job Title (text input)
|
||||||
|
- Birthday (date picker)
|
||||||
|
- Website (URL input)
|
||||||
|
- Nickname (text input)
|
||||||
|
- Photo (upload button, like Birthdays module)
|
||||||
|
|
||||||
|
**Multiple Values UI:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Phone Numbers</label>
|
||||||
|
<div id="phones-list">
|
||||||
|
<div class="multi-value-row">
|
||||||
|
<select class="phone-label">
|
||||||
|
<option value="mobile">Mobile</option>
|
||||||
|
<option value="work">Work</option>
|
||||||
|
<option value="home">Home</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
<input type="tel" class="phone-value" value="+49 123">
|
||||||
|
<label class="checkbox-inline">
|
||||||
|
<input type="checkbox" class="is-primary"> Primary
|
||||||
|
</label>
|
||||||
|
<button class="btn-remove">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="add-phone" class="btn btn--secondary">+ Add Phone</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Same pattern for Emails and Addresses -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing (`test-cardav.js`)
|
||||||
|
|
||||||
|
Uses Node's built-in test runner with in-memory SQLite (like `test-caldav.js`).
|
||||||
|
|
||||||
|
### Test Coverage
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// DB Schema
|
||||||
|
- should create cardav_accounts table
|
||||||
|
- should create cardav_addressbook_selection table with FK CASCADE
|
||||||
|
- should add new columns to contacts table
|
||||||
|
- should create contact_phones/emails/addresses tables
|
||||||
|
- should enforce UNIQUE constraint on (cardav_url, username)
|
||||||
|
|
||||||
|
// Account Management
|
||||||
|
- should add account and store encrypted password
|
||||||
|
- should reject duplicate accounts (same URL + username)
|
||||||
|
- should delete account and set contacts' cardav_account_id = NULL
|
||||||
|
- should keep contacts when account is deleted
|
||||||
|
|
||||||
|
// Addressbook Selection
|
||||||
|
- should insert addressbook selection
|
||||||
|
- should CASCADE delete when account deleted
|
||||||
|
- should toggle enabled/disabled status
|
||||||
|
|
||||||
|
// Smart Merge Logic
|
||||||
|
- should create new contact when cardav_uid not found
|
||||||
|
- should update existing contact when cardav_uid matches
|
||||||
|
- should match by email and link to CardDAV
|
||||||
|
- should match by phone and link to CardDAV
|
||||||
|
- should fill only NULL fields on merge (preserve manual changes)
|
||||||
|
|
||||||
|
// Multiple Values
|
||||||
|
- should insert multiple phones/emails/addresses
|
||||||
|
- should mark is_primary correctly
|
||||||
|
- should CASCADE delete when contact deleted
|
||||||
|
|
||||||
|
// vCard Parsing
|
||||||
|
- should parse FN, N, TEL, EMAIL, ADR, ORG, TITLE, URL, BDAY, PHOTO, NOTE
|
||||||
|
- should handle missing optional fields
|
||||||
|
- should handle multiple TEL/EMAIL/ADR entries with labels
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mock Strategy
|
||||||
|
|
||||||
|
- In-memory SQLite (no persistent DB)
|
||||||
|
- Mock `tsdav` imports with fixture vCard data
|
||||||
|
- No real CardDAV server calls in tests
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Phase 1: Database & Core Service
|
||||||
|
1. Migration 30 (all tables)
|
||||||
|
2. `server/services/cardav-sync.js` (account management, sync logic)
|
||||||
|
3. Tests for DB schema and sync logic
|
||||||
|
|
||||||
|
### Phase 2: API Routes
|
||||||
|
4. New `/api/v1/contacts/cardav/*` routes
|
||||||
|
5. Extended `/api/v1/contacts` routes for multiple values
|
||||||
|
6. Tests for API routes
|
||||||
|
|
||||||
|
### Phase 3: UI Integration
|
||||||
|
7. Settings → Calendar tab extended
|
||||||
|
8. Contact list with source badges
|
||||||
|
9. Contact modal extended (new fields, multiple values)
|
||||||
|
10. Tests for UI interactions
|
||||||
|
|
||||||
|
### Phase 4: Cron Integration
|
||||||
|
11. Add CardDAV sync to existing cron job (like CalDAV)
|
||||||
|
12. Use same `SYNC_INTERVAL_MINUTES` env var
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **Password Encryption:** Use same encryption as CalDAV (DB_ENCRYPTION_KEY)
|
||||||
|
- **CSRF Protection:** All POST/PUT/DELETE routes use existing CSRF middleware
|
||||||
|
- **Session Auth:** All routes require authenticated session
|
||||||
|
- **Input Validation:** Validate all fields (max lengths, URL format, email format)
|
||||||
|
- **SQL Injection:** Use parameterized queries (better-sqlite3)
|
||||||
|
- **XSS Prevention:** Use `esc()` for all user-generated content in UI
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- **Conflict Resolution UI:** Show conflicts when manual changes differ from server
|
||||||
|
- **Selective Field Sync:** Choose which fields to sync per addressbook
|
||||||
|
- **Sync Statistics:** Show detailed sync logs (added, updated, skipped)
|
||||||
|
- **vCard Export (Multi):** Export all contacts as single .vcf file
|
||||||
|
- **CardDAV Server Mode:** Oikos as CardDAV server (Issue #10 mentioned this as possible future)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Design Status:** ✅ Approved
|
||||||
|
**Next Step:** Create implementation plan via `writing-plans` skill
|
||||||
Reference in New Issue
Block a user