Merge branch 'feature/cardav-contacts'
# Conflicts: # package-lock.json
This commit is contained in:
+314
@@ -0,0 +1,314 @@
|
|||||||
|
# CardDAV API Routes Implementation - Fortschritt
|
||||||
|
|
||||||
|
**Stand:** 2026-05-04, alle 15 Tasks abgeschlossen ✅
|
||||||
|
**Plan:** `docs/superpowers/plans/2026-05-04-cardav-api-routes.md`
|
||||||
|
**Status:** Implementierung vollständig, bereit für Final Review
|
||||||
|
|
||||||
|
## Abgeschlossene Tasks
|
||||||
|
|
||||||
|
### ✅ Task 1: Multi-Value Array Validators
|
||||||
|
**Commit:** a715475 + 930800e (fixes)
|
||||||
|
|
||||||
|
- Implementiert: `validatePhones()`, `validateEmails()`, `validateAddresses()`
|
||||||
|
- Location: `server/routes/contacts.js`
|
||||||
|
- Tests: 33 neue Tests in test-carddav.js
|
||||||
|
- Validierungen: Arrays, Objekt-Struktur, Pflichtfelder, Max-Längen, Email-Format, isPrimary-Typ, Array-Länge (max 20)
|
||||||
|
|
||||||
|
**Review-Findings & Fixes:**
|
||||||
|
- Spec Compliance: Minor extras (logging) - akzeptabel
|
||||||
|
- Code Quality Issues behoben:
|
||||||
|
- Whitespace-Validierung ergänzt
|
||||||
|
- Null-Guards hinzugefügt
|
||||||
|
- Email-Format-Check ergänzt
|
||||||
|
- isPrimary Typ-Validierung
|
||||||
|
- DoS-Schutz: Array-Länge begrenzt auf 20
|
||||||
|
|
||||||
|
### ✅ Task 2: CardDAV Router Setup
|
||||||
|
**Commits:** cf68bff, 930800e
|
||||||
|
|
||||||
|
- Erstellt: `server/routes/cardav.js` mit Express Router
|
||||||
|
- Implementiert: GET /accounts Endpoint
|
||||||
|
- Test-Infrastruktur:
|
||||||
|
- `_setTestDatabase()` / `_resetTestDatabase()` in `server/db.js`
|
||||||
|
- before()/after() Hooks in test-carddav.js
|
||||||
|
- Migration 30 wird in Tests angewendet
|
||||||
|
- Tests: 2 Tests (empty array, populated with shape validation)
|
||||||
|
|
||||||
|
**Review-Findings & Fixes:**
|
||||||
|
- Unused imports entfernt (wurden für Task 3 wieder gebraucht)
|
||||||
|
- Error-Message leakage behoben (generic "Interner Fehler")
|
||||||
|
- Test-Cleanup mit after() Hook
|
||||||
|
- `after` aus node:test importiert
|
||||||
|
|
||||||
|
### ✅ Task 3: POST /accounts - Create Account
|
||||||
|
**Commit:** f7eb73b
|
||||||
|
|
||||||
|
- Implementiert: POST /accounts mit Validation
|
||||||
|
- Validierung: name, cardavUrl, username, password (alle required, mit max lengths)
|
||||||
|
- Delegiert an: `CardDAVSync.addAccount()`
|
||||||
|
- Response: 201 mit `{ account, addressbooks }`
|
||||||
|
- Tests: 2 Tests (success case, validation failure)
|
||||||
|
|
||||||
|
**Test-Mocking:**
|
||||||
|
- `_mockTestConnection()` in cardav-sync.js hinzugefügt
|
||||||
|
- Mock gibt fake addressbooks zurück für Tests
|
||||||
|
- Mock wird in before() gesetzt, in after() zurückgesetzt
|
||||||
|
|
||||||
|
**Wichtige Änderung:**
|
||||||
|
- `addAccount()` Return-Wert geändert von `{ accountId, addressbooks }` zu `{ account: { id, name, cardavUrl, username, createdAt, lastSync }, addressbooks }`
|
||||||
|
|
||||||
|
### ✅ Task 4: DELETE /accounts/:id - Delete Account
|
||||||
|
**Commit:** ca92cb2
|
||||||
|
|
||||||
|
- Implementiert: DELETE /accounts/:id mit ID-Validierung
|
||||||
|
- Validierung: ID muss positive Ganzzahl sein
|
||||||
|
- Delegiert an: `CardDAVSync.deleteAccount(id)`
|
||||||
|
- Response: 200 mit `{ deleted: true }`
|
||||||
|
- Tests: 2 Tests (success case mit cascade, invalid ID → 400)
|
||||||
|
- CASCADE-Verhalten: Foreign Key Constraints löschen addressbooks + contacts automatisch
|
||||||
|
|
||||||
|
### ✅ Task 5: POST /accounts/:id/test - Test Connection
|
||||||
|
**Commit:** dd5ac88
|
||||||
|
|
||||||
|
- Implementiert: POST /accounts/:id/test mit ID-Validierung
|
||||||
|
- Lädt Account aus DB (404 wenn nicht gefunden)
|
||||||
|
- Delegiert an: `CardDAVSync.testConnection(url, username, password)`
|
||||||
|
- Response: 200 mit `{ ok, addressbooks }`
|
||||||
|
- Test: 1 Test (success case mit addressbooks)
|
||||||
|
- Verwendet gemockten testConnection für konsistente Test-Results
|
||||||
|
|
||||||
|
### ✅ Task 6: GET /accounts/:id/addressbooks - List Addressbooks
|
||||||
|
**Commit:** 12e8edf
|
||||||
|
|
||||||
|
- Implementiert: GET /accounts/:id/addressbooks mit ID-Validierung
|
||||||
|
- Query: carddav_addressbook_selection table, ORDER BY addressbook_name
|
||||||
|
- Response: 200 mit Array von `{ id, url, name, enabled }`
|
||||||
|
- Tests: 2 Tests (success case mit shape validation, empty array für non-existent account)
|
||||||
|
|
||||||
|
### ✅ Task 7: POST /accounts/:id/addressbooks/refresh - Refresh Addressbooks
|
||||||
|
**Commit:** c078a48
|
||||||
|
|
||||||
|
- Implementiert: POST /accounts/:id/addressbooks/refresh mit ID-Validierung
|
||||||
|
- Lädt Account aus DB (404 wenn nicht gefunden)
|
||||||
|
- Delegiert an: `CardDAVSync.discoverAddressbooks(accountId)` für PROPFIND
|
||||||
|
- Query updated addressbooks nach Discovery
|
||||||
|
- Response: 200 mit Array von addressbooks
|
||||||
|
- Test: 1 Test (success case)
|
||||||
|
|
||||||
|
### ✅ Task 8: Add bool Validator
|
||||||
|
**Commit:** 362f711
|
||||||
|
|
||||||
|
- Implementiert: `bool(val, field)` Validator in server/middleware/validate.js
|
||||||
|
- Validiert: type === 'boolean', required by default
|
||||||
|
- Exportiert: bool in export statement
|
||||||
|
- Keine Tests (wird in Task 9 verwendet)
|
||||||
|
|
||||||
|
### ✅ Task 9: PUT /addressbooks/:id - Toggle Addressbook
|
||||||
|
**Commit:** 9ec7fda
|
||||||
|
|
||||||
|
- Implementiert: PUT /addressbooks/:id Route in server/routes/cardav.js
|
||||||
|
- Validierung: ID muss positive Ganzzahl sein, enabled muss boolean sein
|
||||||
|
- Delegiert an: `CardDAVSync.toggleAddressbook(id, enabled)`
|
||||||
|
- Response: 200 mit `{ updated: true, enabled: boolean }`
|
||||||
|
- Tests: 2 Tests (success case mit toggle, validation failure für invalid enabled)
|
||||||
|
- bool Validator verwendet
|
||||||
|
|
||||||
|
### ✅ Task 10: POST /accounts/:id/sync - Sync Account
|
||||||
|
**Commit:** 674fe79
|
||||||
|
|
||||||
|
- Implementiert: POST /accounts/:id/sync Route in server/routes/cardav.js
|
||||||
|
- Validierung: ID muss positive Ganzzahl sein
|
||||||
|
- Lädt Account aus DB (404 wenn nicht gefunden)
|
||||||
|
- Delegiert an: `CardDAVSync.syncAccount(accountId)`
|
||||||
|
- Response: 200 mit `{ synced, contactsAdded, contactsUpdated }`
|
||||||
|
- Tests: 2 Tests (success case, 404 für non-existent account)
|
||||||
|
- Mock: `_mockSyncAccount()` für Tests hinzugefügt (Pattern wie `_mockTestConnection`)
|
||||||
|
|
||||||
|
### ✅ Task 11: GET /contacts/:id - With Multi-Value Fields
|
||||||
|
**Commit:** fe8af33
|
||||||
|
|
||||||
|
- Implementiert: GET /contacts/:id Route in `server/routes/contacts.js`
|
||||||
|
- Queries: Separate Abfragen für `contact_phones`, `contact_emails`, `contact_addresses`
|
||||||
|
- Mapping: is_primary (Integer DB) → isPrimary (Boolean Response), snake_case → camelCase
|
||||||
|
- Sortierung: ORDER BY is_primary DESC, id ASC (Primary-Einträge zuerst)
|
||||||
|
- Response-Format: `{ ...contact, phones: [], emails: [], addresses: [] }`
|
||||||
|
- Tests: 2 neue Tests
|
||||||
|
- Contact mit allen Multi-Value Fields (phones, emails, addresses)
|
||||||
|
- Contact ohne Multi-Value Fields (leere Arrays)
|
||||||
|
- TDD-Workflow eingehalten: RED → GREEN → Commit
|
||||||
|
|
||||||
|
### ✅ Task 12: POST /contacts - Create With Multi-Value Fields
|
||||||
|
**Commit:** 966a6d4
|
||||||
|
|
||||||
|
- Implementiert: POST /contacts erweitert um phones, emails, addresses Arrays
|
||||||
|
- Validierung: `validatePhones()`, `validateEmails()`, `validateAddresses()` vor Insert
|
||||||
|
- Transaktionen: `db.transaction()` für atomare Contact + Multi-Values Inserts
|
||||||
|
- Backward Compatible: Optional fields, leere Arrays wenn nicht angegeben
|
||||||
|
- Response: Contact mit allen Multi-Value Fields inkl. generierte IDs
|
||||||
|
- Tests: 3 neue Tests
|
||||||
|
- Contact mit allen Multi-Value Fields erstellen
|
||||||
|
- Validierung: Fehler bei invaliden Phone-Daten (400)
|
||||||
|
- Backward Compatibility: Contact ohne Multi-Values (leere Arrays)
|
||||||
|
- Refactoring: `loadMultiValueFields(contactId)` Helper extrahiert (DRY)
|
||||||
|
- TDD-Workflow eingehalten: RED → GREEN → REFACTOR → Commit
|
||||||
|
|
||||||
|
### ✅ Task 13: PUT /contacts/:id - Update With Multi-Value Fields
|
||||||
|
**Commit:** 0dc303b
|
||||||
|
|
||||||
|
- Implementiert: PUT /contacts/:id erweitert um phones, emails, addresses Arrays
|
||||||
|
- Validierung: `validatePhones()`, `validateEmails()`, `validateAddresses()` vor Update
|
||||||
|
- Replacement-Semantik: DELETE alle existierenden Multi-Values, dann INSERT neue
|
||||||
|
- Transaktionen: `db.transaction()` für atomare Contact + Multi-Values Updates
|
||||||
|
- Backward Compatible: Multi-Value Fields nur ersetzt wenn im Body vorhanden
|
||||||
|
- Response: Contact mit allen Multi-Value Fields via `loadMultiValueFields()`
|
||||||
|
- Tests: 3 neue Tests
|
||||||
|
- Update mit Multi-Value Fields (Replacement-Semantik verifiziert)
|
||||||
|
- Validierung: Fehler bei invaliden Phone-Daten (400)
|
||||||
|
- Backward Compatibility: Update ohne Multi-Values lässt sie unverändert
|
||||||
|
- TDD-Workflow eingehalten: RED → Verify RED → GREEN → Verify GREEN → Commit
|
||||||
|
|
||||||
|
### ✅ Task 14: OpenAPI Documentation
|
||||||
|
**Commit:** d0eb638
|
||||||
|
|
||||||
|
- Dokumentiert: Alle 8 CardDAV Management Routes in `server/openapi.js`
|
||||||
|
- GET /api/v1/contacts/cardav/accounts
|
||||||
|
- POST /api/v1/contacts/cardav/accounts
|
||||||
|
- DELETE /api/v1/contacts/cardav/accounts/{id}
|
||||||
|
- POST /api/v1/contacts/cardav/accounts/{id}/test
|
||||||
|
- GET /api/v1/contacts/cardav/accounts/{id}/addressbooks
|
||||||
|
- POST /api/v1/contacts/cardav/accounts/{id}/addressbooks/refresh
|
||||||
|
- PUT /api/v1/contacts/cardav/addressbooks/{id}
|
||||||
|
- POST /api/v1/contacts/cardav/accounts/{id}/sync
|
||||||
|
- Aktualisiert: Bestehende Contacts-Routes mit Multi-Value-Beschreibungen
|
||||||
|
- POST /api/v1/contacts: "Create contact with multi-value fields"
|
||||||
|
- GET /api/v1/contacts/{id}: "Get contact with multi-value fields"
|
||||||
|
- PUT /api/v1/contacts/{id}: "Update contact with multi-value fields"
|
||||||
|
- Alle Routes mit korrektem Tag ('Contacts'), CSRF-Header-Params für stateChanging
|
||||||
|
- OpenAPI Schema baut ohne Fehler
|
||||||
|
|
||||||
|
### ✅ Task 15: Mount CardDAV Router
|
||||||
|
**Commit:** 8891097
|
||||||
|
|
||||||
|
- Import hinzugefügt: `import cardavRouter from './routes/cardav.js'`
|
||||||
|
- Router gemountet: `app.use('/api/v1/contacts/cardav', cardavRouter)`
|
||||||
|
- Montage-Reihenfolge: CardDAV-Router VOR Contacts-Router (Express route matching order)
|
||||||
|
- Auth-Middleware: Bereits global angewendet via `requireAuth` in index.js
|
||||||
|
- CSRF-Middleware: Automatisch über stateChanging-Flag in OpenAPI + CSRF-Middleware
|
||||||
|
- Integration Tests: Alle 109 Tests bestehen (20 Suites)
|
||||||
|
- Server startet erfolgreich auf Port 3000
|
||||||
|
|
||||||
|
## Wichtige Erkenntnisse
|
||||||
|
|
||||||
|
### Test-Infrastruktur
|
||||||
|
1. **DB-Mocking:** `_setTestDatabase()` / `_resetTestDatabase()` in db.js
|
||||||
|
2. **CardDAV-Mocking:** `_mockTestConnection()` in cardav-sync.js
|
||||||
|
3. **before()/after() Pattern:** Setup in before(), Cleanup in after()
|
||||||
|
4. **Migration 30:** Muss in jedem Test-Setup angewendet werden für CardDAV-Tabellen
|
||||||
|
|
||||||
|
### Code-Patterns
|
||||||
|
1. **Error-Handling:** Immer generic "Interner Fehler", niemals err.message leaken
|
||||||
|
2. **Validation:** str(), collectErrors(), MAX_TITLE (100), MAX_URL (500)
|
||||||
|
3. **Response-Format:** `{ data: ... }` für Success, `{ error: ..., code: ... }` für Fehler
|
||||||
|
4. **Status Codes:** 200 (GET), 201 (POST create), 400 (validation), 500 (server error)
|
||||||
|
|
||||||
|
### Konventionen
|
||||||
|
- Tests nutzen Node built-in test runner (`node:test`)
|
||||||
|
- Test-DB ist in-memory SQLite (`:memory:`)
|
||||||
|
- Commits mit Co-Authored-By: Claude Sonnet 4.5
|
||||||
|
- TDD-Workflow: Test → Run (fail) → Implement → Run (pass) → Commit
|
||||||
|
|
||||||
|
## Zusammenfassung
|
||||||
|
|
||||||
|
**Alle 15 Tasks vollständig implementiert:**
|
||||||
|
- ✅ Phase 0: Setup & Validators (Tasks 1-2)
|
||||||
|
- ✅ Phase 1: Account Management Routes (Tasks 3-4)
|
||||||
|
- ✅ Phase 2: Connection & Discovery Routes (Tasks 5-7)
|
||||||
|
- ✅ Phase 3: Toggle & Sync Routes (Tasks 8-10)
|
||||||
|
- ✅ Phase 4: Extended Contacts Routes (Tasks 11-13)
|
||||||
|
- ✅ Phase 5: OpenAPI Integration (Task 14)
|
||||||
|
- ✅ Phase 6: Server Integration (Task 15)
|
||||||
|
|
||||||
|
**Nächste Schritte:**
|
||||||
|
1. Final Code Review (optional)
|
||||||
|
2. Release Prep via `/release-prep` Skill
|
||||||
|
3. Merge in main Branch
|
||||||
|
|
||||||
|
## Commits-Übersicht
|
||||||
|
|
||||||
|
```
|
||||||
|
a715475 feat(contacts): add multi-value array validators
|
||||||
|
cf68bff feat(cardav): create cardav router with GET /accounts
|
||||||
|
930800e fix(cardav): improve router security and test coverage
|
||||||
|
f7eb73b feat(cardav): implement POST /accounts endpoint
|
||||||
|
ca92cb2 feat(cardav): implement DELETE /accounts/:id endpoint
|
||||||
|
38fa84c docs: update PROGRESS.md for completed Task 4
|
||||||
|
dd5ac88 feat(cardav): implement POST /accounts/:id/test endpoint
|
||||||
|
2964696 docs: update PROGRESS.md for completed Task 5
|
||||||
|
12e8edf feat(cardav): implement GET /accounts/:id/addressbooks endpoint
|
||||||
|
c078a48 feat(cardav): implement POST /accounts/:id/addressbooks/refresh endpoint
|
||||||
|
362f711 feat(validate): add bool validator
|
||||||
|
9ec7fda feat(cardav): implement PUT /addressbooks/:id endpoint
|
||||||
|
674fe79 feat(cardav): implement POST /accounts/:id/sync endpoint
|
||||||
|
fe8af33 feat(contacts): extend GET /contacts/:id with multi-value fields
|
||||||
|
966a6d4 feat(contacts): extend POST /contacts with multi-value fields
|
||||||
|
0dc303b feat(contacts): extend PUT /contacts/:id with multi-value fields
|
||||||
|
d0eb638 docs(openapi): add CardDAV routes and update contacts routes
|
||||||
|
8891097 feat(server): mount CardDAV router at /api/v1/contacts/cardav
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test-Status
|
||||||
|
|
||||||
|
- **Gesamt:** 109 Tests, alle bestehen
|
||||||
|
- **Suites:** 20 Suites
|
||||||
|
- **CardDAV API Routes Suite:** 14 Tests
|
||||||
|
- **Contacts API - Multi-Value Fields Suite:** 8 Tests
|
||||||
|
- GET /contacts/:id: 2 Tests
|
||||||
|
- POST /contacts: 3 Tests
|
||||||
|
- PUT /contacts/:id: 3 Tests
|
||||||
|
- Account Management (6 Tests):
|
||||||
|
- GET /accounts (empty)
|
||||||
|
- GET /accounts (populated with shape)
|
||||||
|
- POST /accounts (success)
|
||||||
|
- POST /accounts (validation failure)
|
||||||
|
- DELETE /accounts/:id (success with cascade)
|
||||||
|
- DELETE /accounts/:id (invalid ID → 400)
|
||||||
|
- Connection & Discovery (4 Tests):
|
||||||
|
- POST /accounts/:id/test (success)
|
||||||
|
- GET /accounts/:id/addressbooks (success with addressbooks)
|
||||||
|
- GET /accounts/:id/addressbooks (empty array)
|
||||||
|
- POST /accounts/:id/addressbooks/refresh (success)
|
||||||
|
- Addressbook Management (2 Tests):
|
||||||
|
- PUT /addressbooks/:id (toggle enabled/disabled)
|
||||||
|
- PUT /addressbooks/:id (validation failure)
|
||||||
|
- Sync (2 Tests):
|
||||||
|
- POST /accounts/:id/sync (success)
|
||||||
|
- POST /accounts/:id/sync (404 for non-existent account)
|
||||||
|
|
||||||
|
## Branch & Remote
|
||||||
|
|
||||||
|
- **Branch:** feature/cardav-contacts
|
||||||
|
- **Worktree:** /home/ulsklyc/Workspace/oikos/.worktrees/feature/cardav-contacts
|
||||||
|
- **Base:** main (commit 6cc7267)
|
||||||
|
- **Bereit zum Pushen:** Ja, nach diesem Status-Commit
|
||||||
|
|
||||||
|
## Task-Liste Status
|
||||||
|
|
||||||
|
```
|
||||||
|
#1. [completed] Task 1: Multi-Value Array Validators
|
||||||
|
#2. [completed] Task 2: CardDAV Router Setup
|
||||||
|
#3. [completed] Task 3: POST /accounts - Create Account
|
||||||
|
#4. [completed] Task 4: DELETE /accounts/:id - Delete Account
|
||||||
|
#5. [completed] Task 5: POST /accounts/:id/test - Test Connection
|
||||||
|
#6. [completed] Task 6: GET /accounts/:id/addressbooks - List Addressbooks
|
||||||
|
#7. [completed] Task 7: POST /accounts/:id/addressbooks/refresh - Refresh Addressbooks
|
||||||
|
#8. [completed] Task 8: Add bool validator to validate.js
|
||||||
|
#9. [completed] Task 9: PUT /addressbooks/:id - Toggle Addressbook
|
||||||
|
#10. [completed] Task 10: POST /accounts/:id/sync - Sync Account
|
||||||
|
#11. [completed] Task 11: GET /contacts/:id - With Multi-Values
|
||||||
|
#12. [completed] Task 12: POST /contacts - Create With Multi-Values
|
||||||
|
#13. [completed] Task 13: PUT /contacts/:id - Update With Multi-Values
|
||||||
|
#14. [completed] Task 14: Document All Routes in OpenAPI
|
||||||
|
#15. [completed] Task 15: Mount CardDAV Router
|
||||||
|
```
|
||||||
|
|
||||||
|
**Alle 15 Tasks abgeschlossen! 🎉**
|
||||||
@@ -0,0 +1,910 @@
|
|||||||
|
# CardDAV API Routes — Implementation Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-04
|
||||||
|
**Status:** Approved
|
||||||
|
**Related:** [CardDAV Contacts Design](../../designs/2026-05-04-cardav-contacts-design.md)
|
||||||
|
|
||||||
|
## Überblick
|
||||||
|
|
||||||
|
Implementierung von 11 API Routes für CardDAV Contacts Integration:
|
||||||
|
- 8 neue CardDAV Management Routes (Account CRUD, Addressbook Discovery, Sync)
|
||||||
|
- 3 erweiterte Contacts Routes (Multi-Value-Felder: phones, emails, addresses)
|
||||||
|
|
||||||
|
## Entscheidungen
|
||||||
|
|
||||||
|
### Route-Organisation
|
||||||
|
- **CardDAV Management Routes:** Neue Datei `server/routes/cardav.js`
|
||||||
|
- **Extended Contacts Routes:** Existierende `server/routes/contacts.js` erweitern
|
||||||
|
- **Rationale:** Klare Trennung (Contact CRUD vs. CardDAV Management), folgt Oikos One-Router-Per-Module Pattern
|
||||||
|
|
||||||
|
### Implementierungs-Reihenfolge
|
||||||
|
**User Flow Approach:**
|
||||||
|
1. Account Management (POST/GET/DELETE)
|
||||||
|
2. Connection Test
|
||||||
|
3. Addressbook Discovery & Toggle
|
||||||
|
4. Sync Operations
|
||||||
|
5. Extended Contacts Routes
|
||||||
|
|
||||||
|
**Rationale:** Natürliche User Journey, einfacher zu testen
|
||||||
|
|
||||||
|
### Architektur
|
||||||
|
**Route-Level Validation mit Service Delegation:**
|
||||||
|
- Routes validieren Input mit `validate.js` Middleware
|
||||||
|
- Routes delegieren Business Logic an `cardav-sync.js`
|
||||||
|
- **Rationale:** Konsistent mit existierenden Oikos-Routes, bessere User-facing Error Messages
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
**Einfaches Fallback:**
|
||||||
|
```javascript
|
||||||
|
catch (err) {
|
||||||
|
log.error('CardDAV error:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
**Rationale:** Funktioniert sofort, Error-Klassen können später eingeführt werden
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
### Neue Dateien
|
||||||
|
- `server/routes/cardav.js` — CardDAV Management Routes
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- `server/routes/contacts.js` — Extended Contacts Routes (Multi-Values)
|
||||||
|
- `server/index.js` — Mount cardav.js Router
|
||||||
|
- `server/openapi.js` — 11 neue Path Definitionen
|
||||||
|
- `test-carddav.js` — API Route Tests
|
||||||
|
|
||||||
|
### Mount Point
|
||||||
|
```javascript
|
||||||
|
// server/index.js
|
||||||
|
import cardavRouter from './routes/cardav.js';
|
||||||
|
app.use('/api/v1/contacts/cardav', cardavRouter);
|
||||||
|
```
|
||||||
|
|
||||||
|
Alle CardDAV-Routes unter `/api/v1/contacts/cardav/*`, Extended Contacts unter `/api/v1/contacts/*`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Route Definitions
|
||||||
|
|
||||||
|
### CardDAV Management Routes
|
||||||
|
|
||||||
|
#### 1. POST /api/v1/contacts/cardav/accounts
|
||||||
|
**Zweck:** Account erstellen und Addressbooks discovern
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "iCloud",
|
||||||
|
"cardavUrl": "https://contacts.icloud.com",
|
||||||
|
"username": "user@icloud.com",
|
||||||
|
"password": "app-specific-password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- `name`: str, max MAX_TITLE, required
|
||||||
|
- `cardavUrl`: str, max MAX_URL, required
|
||||||
|
- `username`: str, max MAX_TITLE, required
|
||||||
|
- `password`: str, max MAX_TITLE, required
|
||||||
|
|
||||||
|
**Service Call:**
|
||||||
|
```javascript
|
||||||
|
const result = await CardDAVSync.addAccount(name, cardavUrl, username, password);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `201 Created`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"account": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "iCloud",
|
||||||
|
"cardavUrl": "https://contacts.icloud.com",
|
||||||
|
"username": "user@icloud.com",
|
||||||
|
"lastSync": null
|
||||||
|
},
|
||||||
|
"addressbooks": [
|
||||||
|
{ "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. GET /api/v1/contacts/cardav/accounts
|
||||||
|
**Zweck:** Alle Accounts auflisten
|
||||||
|
|
||||||
|
**Service Call:**
|
||||||
|
```javascript
|
||||||
|
const accounts = await CardDAVSync.getAllAccounts();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "iCloud",
|
||||||
|
"cardavUrl": "https://contacts.icloud.com",
|
||||||
|
"username": "user@icloud.com",
|
||||||
|
"lastSync": "2026-05-04T10:30:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. DELETE /api/v1/contacts/cardav/accounts/:id
|
||||||
|
**Zweck:** Account löschen (CASCADE löscht addressbooks + contacts)
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- `id`: parseInt, must be > 0
|
||||||
|
|
||||||
|
**Service Call:**
|
||||||
|
```javascript
|
||||||
|
await CardDAVSync.deleteAccount(id);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": { "deleted": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. POST /api/v1/contacts/cardav/accounts/:id/test
|
||||||
|
**Zweck:** Connection testen (ohne Account zu erstellen)
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- `id`: parseInt, must be > 0
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Account aus DB laden
|
||||||
|
2. `testConnection(cardavUrl, username, password)` aufrufen
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"ok": true,
|
||||||
|
"addressbooks": [...]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. GET /api/v1/contacts/cardav/accounts/:id/addressbooks
|
||||||
|
**Zweck:** Addressbooks für Account auflisten
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- `id`: parseInt, must be > 0
|
||||||
|
|
||||||
|
**DB Query:**
|
||||||
|
```sql
|
||||||
|
SELECT id, addressbook_url as url, addressbook_name as name, enabled
|
||||||
|
FROM carddav_addressbook_selection
|
||||||
|
WHERE account_id = ?
|
||||||
|
ORDER BY addressbook_name
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 },
|
||||||
|
{ "id": 2, "url": "https://...", "name": "Work", "enabled": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 6. POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh
|
||||||
|
**Zweck:** Addressbooks neu discovern (PROPFIND)
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- `id`: parseInt, must be > 0
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Account aus DB laden
|
||||||
|
2. `discoverAddressbooks(account)` aufrufen
|
||||||
|
3. Addressbooks aus DB neu laden
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{ "id": 1, "url": "https://...", "name": "Personal", "enabled": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 7. PUT /api/v1/contacts/cardav/addressbooks/:id
|
||||||
|
**Zweck:** Addressbook enable/disable
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- `id`: parseInt, must be > 0
|
||||||
|
- `enabled`: bool, required
|
||||||
|
|
||||||
|
**Service Call:**
|
||||||
|
```javascript
|
||||||
|
await CardDAVSync.toggleAddressbook(id, enabled);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": { "id": 1, "enabled": true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 8. POST /api/v1/contacts/cardav/accounts/:id/sync
|
||||||
|
**Zweck:** Account syncen (alle enabled addressbooks)
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- `id`: parseInt, must be > 0
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
1. Account aus DB laden
|
||||||
|
2. `syncAccount(account)` aufrufen
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"synced": 15,
|
||||||
|
"errors": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Extended Contacts Routes
|
||||||
|
|
||||||
|
#### 9. GET /api/v1/contacts/:id
|
||||||
|
**Zweck:** Kontakt mit allen Multi-Value-Feldern laden
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- `id`: parseInt, must be > 0
|
||||||
|
|
||||||
|
**DB Queries:**
|
||||||
|
```sql
|
||||||
|
SELECT * FROM contacts WHERE id = ?;
|
||||||
|
SELECT * FROM contact_phones WHERE contact_id = ?;
|
||||||
|
SELECT * FROM contact_emails WHERE contact_id = ?;
|
||||||
|
SELECT * FROM contact_addresses WHERE contact_id = ?;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Alice Smith",
|
||||||
|
"category": "Sonstiges",
|
||||||
|
"organization": "Tech Corp",
|
||||||
|
"jobTitle": "Developer",
|
||||||
|
"birthday": "1990-01-15",
|
||||||
|
"website": "https://alice.dev",
|
||||||
|
"nickname": "Ali",
|
||||||
|
"notes": "Great developer",
|
||||||
|
"cardavAccountId": 1,
|
||||||
|
"cardavUid": "urn:uuid:alice-123",
|
||||||
|
"phones": [
|
||||||
|
{ "id": 1, "label": "mobile", "value": "+1234567890", "isPrimary": 1 },
|
||||||
|
{ "id": 2, "label": "work", "value": "+0987654321", "isPrimary": 0 }
|
||||||
|
],
|
||||||
|
"emails": [
|
||||||
|
{ "id": 1, "label": "home", "value": "alice@home.com", "isPrimary": 1 }
|
||||||
|
],
|
||||||
|
"addresses": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"label": "home",
|
||||||
|
"street": "123 Main St",
|
||||||
|
"city": "Springfield",
|
||||||
|
"state": "IL",
|
||||||
|
"postalCode": "62701",
|
||||||
|
"country": "USA",
|
||||||
|
"isPrimary": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 10. POST /api/v1/contacts
|
||||||
|
**Zweck:** Kontakt mit Multi-Values erstellen
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Bob Jones",
|
||||||
|
"category": "Sonstiges",
|
||||||
|
"phones": [
|
||||||
|
{ "label": "mobile", "value": "+1111111111", "isPrimary": true }
|
||||||
|
],
|
||||||
|
"emails": [
|
||||||
|
{ "label": "work", "value": "bob@work.com", "isPrimary": true }
|
||||||
|
],
|
||||||
|
"addresses": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- `name`: str, max MAX_TITLE, required
|
||||||
|
- `category`: oneOf(VALID_CATEGORIES), default 'Sonstiges'
|
||||||
|
- `phones`: validatePhones() (siehe Validation Schema)
|
||||||
|
- `emails`: validateEmails()
|
||||||
|
- `addresses`: validateAddresses()
|
||||||
|
- Alle anderen Felder optional
|
||||||
|
|
||||||
|
**Logic (Transaction):**
|
||||||
|
```javascript
|
||||||
|
const transaction = db.get().transaction(() => {
|
||||||
|
// 1. INSERT contact
|
||||||
|
const result = db.get().prepare(`
|
||||||
|
INSERT INTO contacts (name, category, ...)
|
||||||
|
VALUES (?, ?, ...)
|
||||||
|
`).run(...);
|
||||||
|
const contactId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
// 2. INSERT phones (bulk)
|
||||||
|
if (phones?.length) {
|
||||||
|
const placeholders = phones.map(() => '(?, ?, ?, ?)').join(', ');
|
||||||
|
const values = phones.flatMap(p => [contactId, p.label, p.value, p.isPrimary ? 1 : 0]);
|
||||||
|
db.get().prepare(`INSERT INTO contact_phones (...) VALUES ${placeholders}`).run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. INSERT emails (bulk)
|
||||||
|
// 4. INSERT addresses (bulk)
|
||||||
|
|
||||||
|
return contactId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactId = transaction();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `201 Created`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": { /* Contact mit allen Multi-Values */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 11. PUT /api/v1/contacts/:id
|
||||||
|
**Zweck:** Kontakt mit Multi-Values updaten
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Bob Jones Updated",
|
||||||
|
"phones": [
|
||||||
|
{ "label": "mobile", "value": "+2222222222", "isPrimary": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
- `id`: parseInt, must be > 0
|
||||||
|
- Alle Felder optional (nur gesendete werden geupdatet)
|
||||||
|
- **Multi-Value-Felder (phones/emails/addresses):** REPLACEMENT-Semantik — wenn gesendet, werden ALLE existierenden Werte gelöscht und durch die gesendeten ersetzt. Client muss vollständiges Array schicken, nicht nur Änderungen.
|
||||||
|
|
||||||
|
**Logic (Transaction):**
|
||||||
|
```javascript
|
||||||
|
const transaction = db.get().transaction(() => {
|
||||||
|
// 1. UPDATE contacts (nur gesendete Felder)
|
||||||
|
const updates = [];
|
||||||
|
const params = [];
|
||||||
|
if (req.body.name !== undefined) {
|
||||||
|
updates.push('name = ?');
|
||||||
|
params.push(req.body.name);
|
||||||
|
}
|
||||||
|
// ... andere Felder
|
||||||
|
params.push(id);
|
||||||
|
db.get().prepare(`UPDATE contacts SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||||
|
|
||||||
|
// 2. Wenn phones gesendet: DELETE + INSERT
|
||||||
|
if (req.body.phones !== undefined) {
|
||||||
|
db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ?').run(id);
|
||||||
|
// ... bulk INSERT wie in POST
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Wenn emails gesendet: DELETE + INSERT
|
||||||
|
// 4. Wenn addresses gesendet: DELETE + INSERT
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `200 OK`
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": { /* Updated Contact mit allen Multi-Values */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Schema
|
||||||
|
|
||||||
|
### CardDAV Routes
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { str, bool, collectErrors, MAX_TITLE, MAX_URL } from '../middleware/validate.js';
|
||||||
|
|
||||||
|
// POST /accounts
|
||||||
|
const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
|
||||||
|
const vUrl = str(req.body.cardavUrl, 'CardDAV URL', { max: MAX_URL });
|
||||||
|
const vUsername = str(req.body.username, 'Username', { max: MAX_TITLE });
|
||||||
|
const vPassword = str(req.body.password, 'Password', { max: MAX_TITLE });
|
||||||
|
const errors = collectErrors([vName, vUrl, vUsername, vPassword]);
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
|
// PUT /addressbooks/:id
|
||||||
|
const vEnabled = bool(req.body.enabled, 'Enabled');
|
||||||
|
if (vEnabled.error) return res.status(400).json({ error: vEnabled.error, code: 400 });
|
||||||
|
|
||||||
|
// Alle :id params
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Extended Contacts Routes
|
||||||
|
|
||||||
|
**Multi-Value Array Validators:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// phones: [{ label, value, isPrimary? }]
|
||||||
|
function validatePhones(phones) {
|
||||||
|
if (!Array.isArray(phones)) return { valid: false, error: 'Phones must be an array' };
|
||||||
|
for (let p of phones) {
|
||||||
|
if (!p.label || !p.value) return { valid: false, error: 'Phone requires label and value' };
|
||||||
|
if (typeof p.label !== 'string' || p.label.length > 50) {
|
||||||
|
return { valid: false, error: 'Phone label invalid or too long' };
|
||||||
|
}
|
||||||
|
if (typeof p.value !== 'string' || p.value.length > 50) {
|
||||||
|
return { valid: false, error: 'Phone value invalid or too long' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// emails: [{ label, value, isPrimary? }]
|
||||||
|
function validateEmails(emails) {
|
||||||
|
if (!Array.isArray(emails)) return { valid: false, error: 'Emails must be an array' };
|
||||||
|
for (let e of emails) {
|
||||||
|
if (!e.label || !e.value) return { valid: false, error: 'Email requires label and value' };
|
||||||
|
if (typeof e.label !== 'string' || e.label.length > 50) {
|
||||||
|
return { valid: false, error: 'Email label invalid or too long' };
|
||||||
|
}
|
||||||
|
if (typeof e.value !== 'string' || e.value.length > 255) {
|
||||||
|
return { valid: false, error: 'Email value invalid or too long' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// addresses: [{ label, street?, city?, state?, postalCode?, country?, isPrimary? }]
|
||||||
|
function validateAddresses(addresses) {
|
||||||
|
if (!Array.isArray(addresses)) return { valid: false, error: 'Addresses must be an array' };
|
||||||
|
for (let a of addresses) {
|
||||||
|
if (!a.label) return { valid: false, error: 'Address requires label' };
|
||||||
|
if (typeof a.label !== 'string' || a.label.length > 50) {
|
||||||
|
return { valid: false, error: 'Address label invalid or too long' };
|
||||||
|
}
|
||||||
|
// street, city, state, postalCode, country sind optional
|
||||||
|
// Wenn vorhanden: Type-Check + Max-Length (255 für Text-Felder)
|
||||||
|
const fields = ['street', 'city', 'state', 'postalCode', 'country'];
|
||||||
|
for (let field of fields) {
|
||||||
|
if (a[field] !== undefined && (typeof a[field] !== 'string' || a[field].length > 255)) {
|
||||||
|
return { valid: false, error: `Address ${field} invalid or too long` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in Routes:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// POST/PUT /contacts
|
||||||
|
if (req.body.phones !== undefined) {
|
||||||
|
const phoneCheck = validatePhones(req.body.phones);
|
||||||
|
if (!phoneCheck.valid) return res.status(400).json({ error: phoneCheck.error, code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.emails !== undefined) {
|
||||||
|
const emailCheck = validateEmails(req.body.emails);
|
||||||
|
if (!emailCheck.valid) return res.status(400).json({ error: emailCheck.error, code: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.addresses !== undefined) {
|
||||||
|
const addressCheck = validateAddresses(req.body.addresses);
|
||||||
|
if (!addressCheck.valid) return res.status(400).json({ error: addressCheck.error, code: 400 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Service Integration
|
||||||
|
|
||||||
|
### Import Pattern
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// server/routes/cardav.js
|
||||||
|
import { createLogger } from '../logger.js';
|
||||||
|
import express from 'express';
|
||||||
|
import * as db from '../db.js';
|
||||||
|
import * as CardDAVSync from '../services/cardav-sync.js';
|
||||||
|
import { str, bool, collectErrors, MAX_TITLE, MAX_URL } from '../middleware/validate.js';
|
||||||
|
|
||||||
|
const log = createLogger('CardDAV');
|
||||||
|
const router = express.Router();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service Call Examples
|
||||||
|
|
||||||
|
**Async/Await Pattern:**
|
||||||
|
```javascript
|
||||||
|
router.post('/accounts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Validation...
|
||||||
|
|
||||||
|
const result = await CardDAVSync.addAccount(
|
||||||
|
vName.value,
|
||||||
|
vUrl.value,
|
||||||
|
vUsername.value,
|
||||||
|
vPassword.value
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({ data: result });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error adding CardDAV account:', err);
|
||||||
|
res.status(500).json({ error: err.message || 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**DB-Direct Queries** (wo kein Service existiert):
|
||||||
|
```javascript
|
||||||
|
router.get('/accounts/:id/addressbooks', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!id) return res.status(400).json({ error: 'Invalid ID', code: 400 });
|
||||||
|
|
||||||
|
const addressbooks = db.get().prepare(`
|
||||||
|
SELECT id, addressbook_url as url, addressbook_name as name, enabled
|
||||||
|
FROM carddav_addressbook_selection
|
||||||
|
WHERE account_id = ?
|
||||||
|
ORDER BY addressbook_name
|
||||||
|
`).all(id);
|
||||||
|
|
||||||
|
res.json({ data: addressbooks });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error fetching addressbooks:', err);
|
||||||
|
res.status(500).json({ error: err.message, code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Transaction Handling
|
||||||
|
|
||||||
|
**Extended Contacts Routes** nutzen Transactions für atomare Multi-Value-Updates:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Validation...
|
||||||
|
|
||||||
|
const transaction = db.get().transaction(() => {
|
||||||
|
// 1. Insert contact
|
||||||
|
const result = db.get().prepare(`
|
||||||
|
INSERT INTO contacts (name, category, organization, job_title, birthday, website, nickname, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
vName.value,
|
||||||
|
vCategory.value || 'Sonstiges',
|
||||||
|
req.body.organization || null,
|
||||||
|
req.body.jobTitle || null,
|
||||||
|
req.body.birthday || null,
|
||||||
|
req.body.website || null,
|
||||||
|
req.body.nickname || null,
|
||||||
|
req.body.notes || null
|
||||||
|
);
|
||||||
|
const contactId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
// 2. Insert phones (bulk)
|
||||||
|
if (req.body.phones?.length) {
|
||||||
|
const phonePlaceholders = req.body.phones.map(() => '(?, ?, ?, ?)').join(', ');
|
||||||
|
const phoneValues = req.body.phones.flatMap(p => [
|
||||||
|
contactId,
|
||||||
|
p.label,
|
||||||
|
p.value,
|
||||||
|
p.isPrimary ? 1 : 0
|
||||||
|
]);
|
||||||
|
db.get().prepare(`
|
||||||
|
INSERT INTO contact_phones (contact_id, label, value, is_primary)
|
||||||
|
VALUES ${phonePlaceholders}
|
||||||
|
`).run(...phoneValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Insert emails (analog)
|
||||||
|
// 4. Insert addresses (analog)
|
||||||
|
|
||||||
|
return contactId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactId = transaction();
|
||||||
|
|
||||||
|
// Fetch full contact with multi-values
|
||||||
|
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
|
||||||
|
const phones = db.get().prepare('SELECT * FROM contact_phones WHERE contact_id = ?').all(contactId);
|
||||||
|
const emails = db.get().prepare('SELECT * FROM contact_emails WHERE contact_id = ?').all(contactId);
|
||||||
|
const addresses = db.get().prepare('SELECT * FROM contact_addresses WHERE contact_id = ?').all(contactId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
data: {
|
||||||
|
...contact,
|
||||||
|
phones,
|
||||||
|
emails,
|
||||||
|
addresses
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error creating contact:', err);
|
||||||
|
res.status(500).json({ error: err.message, code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OpenAPI Integration
|
||||||
|
|
||||||
|
Alle 11 Routes werden in `server/openapi.js` dokumentiert:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// CardDAV Management Routes
|
||||||
|
'/api/v1/contacts/cardav/accounts': {
|
||||||
|
get: op({ summary: 'List CardDAV accounts', tag: 'Contacts' }),
|
||||||
|
post: op({ summary: 'Add CardDAV account', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/contacts/cardav/accounts/{id}': {
|
||||||
|
delete: op({ summary: 'Delete CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/contacts/cardav/accounts/{id}/test': {
|
||||||
|
post: op({ summary: 'Test CardDAV connection', tag: 'Contacts', params: [idParam()] }),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/contacts/cardav/accounts/{id}/addressbooks': {
|
||||||
|
get: op({ summary: 'List addressbooks for account', tag: 'Contacts', params: [idParam()] }),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/contacts/cardav/accounts/{id}/addressbooks/refresh': {
|
||||||
|
post: op({ summary: 'Refresh addressbooks for account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/contacts/cardav/addressbooks/{id}': {
|
||||||
|
put: op({ summary: 'Toggle addressbook enabled state', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/contacts/cardav/accounts/{id}/sync': {
|
||||||
|
post: op({ summary: 'Sync CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Extended Contacts Routes (ersetzen existierende Definitionen)
|
||||||
|
'/api/v1/contacts/{id}': {
|
||||||
|
get: op({ summary: 'Get contact with multi-value fields', tag: 'Contacts', params: [idParam()] }),
|
||||||
|
put: op({ summary: 'Update contact with multi-value fields', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
|
||||||
|
delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||||
|
},
|
||||||
|
|
||||||
|
'/api/v1/contacts': {
|
||||||
|
get: op({ summary: 'List contacts', tag: 'Contacts' }),
|
||||||
|
post: op({ summary: 'Create contact with multi-value fields', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**Hinweis:** Alle Routes bleiben unter dem `'Contacts'` Tag für konsistente Swagger-Gruppierung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Test File Structure
|
||||||
|
|
||||||
|
Neue Suite in `test-carddav.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
describe('CardDAV API Routes', () => {
|
||||||
|
|
||||||
|
describe('Account Management', () => {
|
||||||
|
it('POST /accounts - should create account and discover addressbooks');
|
||||||
|
it('POST /accounts - should return 400 for missing fields');
|
||||||
|
it('GET /accounts - should list all accounts');
|
||||||
|
it('GET /accounts - should return empty array when no accounts');
|
||||||
|
it('DELETE /accounts/:id - should delete account and cascade addressbooks');
|
||||||
|
it('DELETE /accounts/:id - should return 400 for invalid ID');
|
||||||
|
it('POST /accounts/:id/test - should test connection');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Addressbook Management', () => {
|
||||||
|
it('GET /accounts/:id/addressbooks - should list addressbooks');
|
||||||
|
it('GET /accounts/:id/addressbooks - should return empty array when none');
|
||||||
|
it('POST /accounts/:id/addressbooks/refresh - should refresh addressbooks');
|
||||||
|
it('PUT /addressbooks/:id - should enable addressbook');
|
||||||
|
it('PUT /addressbooks/:id - should disable addressbook');
|
||||||
|
it('PUT /addressbooks/:id - should return 400 for missing enabled field');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sync Operations', () => {
|
||||||
|
it('POST /accounts/:id/sync - should return sync result structure');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Extended Contacts Routes', () => {
|
||||||
|
it('POST /contacts - should create contact with phones/emails/addresses');
|
||||||
|
it('POST /contacts - should create contact without multi-values');
|
||||||
|
it('POST /contacts - should return 400 for invalid phone array');
|
||||||
|
it('POST /contacts - should return 400 for invalid email array');
|
||||||
|
it('GET /contacts/:id - should return contact with all multi-values');
|
||||||
|
it('GET /contacts/:id - should return 404 for non-existent contact');
|
||||||
|
it('PUT /contacts/:id - should update contact and replace phones');
|
||||||
|
it('PUT /contacts/:id - should update contact and keep existing multi-values if not sent');
|
||||||
|
it('PUT /contacts/:id - should handle transaction rollback on error');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Approach
|
||||||
|
|
||||||
|
**Direct Handler Testing:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Mock Express req/res
|
||||||
|
function mockRequest(body = {}, params = {}, query = {}) {
|
||||||
|
return { body, params, query };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockResponse() {
|
||||||
|
const res = {};
|
||||||
|
res.status = (code) => { res.statusCode = code; return res; };
|
||||||
|
res.json = (data) => { res.data = data; return res; };
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example Test
|
||||||
|
it('POST /accounts - should create account', async () => {
|
||||||
|
const req = mockRequest({
|
||||||
|
name: 'Test Account',
|
||||||
|
cardavUrl: 'https://example.com/carddav',
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass'
|
||||||
|
});
|
||||||
|
const res = mockResponse();
|
||||||
|
|
||||||
|
// Note: Actual handler testing requires importing route handlers
|
||||||
|
// This is simplified pseudo-code
|
||||||
|
|
||||||
|
assert.strictEqual(res.statusCode, 201);
|
||||||
|
assert.ok(res.data.data.account);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mocking External CardDAV
|
||||||
|
|
||||||
|
**Strategie:** Tests fokussieren auf HTTP-Layer (Validation, Response Format, DB-Operations).
|
||||||
|
|
||||||
|
Integration Tests für `cardav-sync.js` existieren bereits (Task #2), daher müssen API Route Tests nicht externe CardDAV-Server mocken.
|
||||||
|
|
||||||
|
**Für Sync/Discovery Routes:**
|
||||||
|
- Setup: Account + Addressbooks direkt in DB anlegen
|
||||||
|
- Test: Response-Struktur validieren
|
||||||
|
- Skip: Echte PROPFIND/REPORT Requests
|
||||||
|
|
||||||
|
### Test Coverage Goals
|
||||||
|
|
||||||
|
- ✅ Alle 11 Routes: mindestens 1 Happy-Path-Test
|
||||||
|
- ✅ Validation Errors (400) für alle POST/PUT Routes
|
||||||
|
- ✅ Not Found (404) für invalide IDs
|
||||||
|
- ✅ Multi-Value-Arrays korrekt gespeichert/geladen
|
||||||
|
- ✅ Transaction Rollback bei Fehlern
|
||||||
|
- ✅ Error Messages sind user-facing (nicht technische Stack Traces)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
### Phase 1: CardDAV Management (Routes 1-3)
|
||||||
|
1. POST /accounts — Account erstellen
|
||||||
|
2. GET /accounts — Accounts auflisten
|
||||||
|
3. DELETE /accounts/:id — Account löschen
|
||||||
|
|
||||||
|
**Tests:** Account CRUD Happy Paths + Validation Errors
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: Connection & Discovery (Routes 4-6)
|
||||||
|
4. POST /accounts/:id/test — Connection testen
|
||||||
|
5. GET /accounts/:id/addressbooks — Addressbooks auflisten
|
||||||
|
6. POST /accounts/:id/addressbooks/refresh — Addressbooks refreshen
|
||||||
|
|
||||||
|
**Tests:** Discovery Flow + Error Handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: Addressbook Toggle & Sync (Routes 7-8)
|
||||||
|
7. PUT /addressbooks/:id — Addressbook togglen
|
||||||
|
8. POST /accounts/:id/sync — Sync triggern
|
||||||
|
|
||||||
|
**Tests:** Toggle + Sync Response Structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: Extended Contacts (Routes 9-11)
|
||||||
|
9. GET /contacts/:id — Mit Multi-Values
|
||||||
|
10. POST /contacts — Mit Multi-Values erstellen
|
||||||
|
11. PUT /contacts/:id — Mit Multi-Values updaten
|
||||||
|
|
||||||
|
**Tests:** Multi-Value CRUD + Transaction Safety
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
Nach Approval dieses Designs:
|
||||||
|
1. **Invoke `writing-plans` skill** — Detaillierten Implementation Plan erstellen
|
||||||
|
2. **TDD Approach** — Tests vor Implementation schreiben
|
||||||
|
3. **Code Review** nach jeder Phase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anhang: Service Functions Reference
|
||||||
|
|
||||||
|
Aus `server/services/cardav-sync.js`:
|
||||||
|
|
||||||
|
**Account Management:**
|
||||||
|
- `addAccount(name, cardavUrl, username, password)` → `{ account, addressbooks }`
|
||||||
|
- `getAllAccounts()` → `Account[]`
|
||||||
|
- `deleteAccount(accountId)` → `void`
|
||||||
|
- `testConnection(cardavUrl, username, password)` → `{ ok, addressbooks }`
|
||||||
|
|
||||||
|
**Addressbook Discovery:**
|
||||||
|
- `discoverAddressbooks(account)` → `Addressbook[]`
|
||||||
|
- `toggleAddressbook(addressbookId, enabled)` → `void`
|
||||||
|
|
||||||
|
**Contact Sync:**
|
||||||
|
- `syncAccount(account)` → `{ synced, errors }`
|
||||||
|
- `syncAddressbook(account, addressbook)` → `void`
|
||||||
|
- `parseAndMergeContact(vCardText, accountId, addressbookUrl)` → `void`
|
||||||
|
|
||||||
|
**Helpers:**
|
||||||
|
- `parseVCard(vCardText)` → `ContactData`
|
||||||
+2
-1
@@ -28,7 +28,8 @@
|
|||||||
"test:ics-sub": "node --experimental-sqlite test-ics-subscription.js",
|
"test:ics-sub": "node --experimental-sqlite test-ics-subscription.js",
|
||||||
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
|
"test:backup-scheduler": "node --experimental-sqlite test-backup-scheduler.js",
|
||||||
"test:caldav": "node --experimental-sqlite test-caldav-sync.js",
|
"test:caldav": "node --experimental-sqlite test-caldav-sync.js",
|
||||||
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav"
|
"test:carddav": "node --experimental-sqlite test-carddav.js",
|
||||||
|
"test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub && npm run test:setup && npm run test:kitchen-tabs && npm run test:backup-scheduler && npm run test:caldav && npm run test:carddav"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
+128
-1
@@ -1074,6 +1074,112 @@ const MIGRATIONS = [
|
|||||||
db.exec(`CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id)`);
|
db.exec(`CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id)`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 30,
|
||||||
|
description: 'CardDAV multi-account contacts sync',
|
||||||
|
up: `
|
||||||
|
-- ========================================
|
||||||
|
-- CardDAV Accounts
|
||||||
|
-- ========================================
|
||||||
|
CREATE TABLE carddav_accounts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
carddav_url TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
last_sync TEXT,
|
||||||
|
UNIQUE(carddav_url, username)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- CardDAV Addressbook Selection
|
||||||
|
-- ========================================
|
||||||
|
CREATE TABLE carddav_addressbook_selection (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
addressbook_url TEXT NOT NULL,
|
||||||
|
addressbook_name TEXT NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
UNIQUE(account_id, addressbook_url),
|
||||||
|
FOREIGN KEY(account_id) REFERENCES carddav_accounts(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_carddav_addressbook_account
|
||||||
|
ON carddav_addressbook_selection(account_id, enabled);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- Extend Contacts Table for CardDAV
|
||||||
|
-- ========================================
|
||||||
|
ALTER TABLE contacts ADD COLUMN organization TEXT;
|
||||||
|
ALTER TABLE contacts ADD COLUMN job_title TEXT;
|
||||||
|
ALTER TABLE contacts ADD COLUMN birthday TEXT;
|
||||||
|
ALTER TABLE contacts ADD COLUMN website TEXT;
|
||||||
|
ALTER TABLE contacts ADD COLUMN photo TEXT;
|
||||||
|
ALTER TABLE contacts ADD COLUMN nickname TEXT;
|
||||||
|
ALTER TABLE contacts ADD COLUMN carddav_account_id INTEGER
|
||||||
|
REFERENCES carddav_accounts(id) ON DELETE SET NULL;
|
||||||
|
ALTER TABLE contacts ADD COLUMN carddav_uid TEXT;
|
||||||
|
ALTER TABLE contacts ADD COLUMN carddav_addressbook_url TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX idx_contacts_carddav_uid ON contacts(carddav_uid);
|
||||||
|
CREATE INDEX idx_contacts_email ON contacts(email);
|
||||||
|
|
||||||
|
-- UNIQUE constraint for CardDAV UIDs (prevents duplicates per account+addressbook)
|
||||||
|
CREATE UNIQUE INDEX idx_contacts_carddav_uid_unique
|
||||||
|
ON contacts(carddav_account_id, carddav_addressbook_url, carddav_uid)
|
||||||
|
WHERE carddav_uid IS NOT NULL;
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- Contact Phones (Multiple per Contact)
|
||||||
|
-- ========================================
|
||||||
|
CREATE TABLE contact_phones (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
contact_id INTEGER NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
is_primary INTEGER NOT NULL DEFAULT 0,
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- Contact Emails (Multiple per Contact)
|
||||||
|
-- ========================================
|
||||||
|
CREATE TABLE contact_emails (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
contact_id INTEGER NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- ========================================
|
||||||
|
-- Contact Addresses (Multiple per Contact)
|
||||||
|
-- ========================================
|
||||||
|
CREATE TABLE contact_addresses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
contact_id INTEGER NOT NULL,
|
||||||
|
label TEXT,
|
||||||
|
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);
|
||||||
|
`,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1247,6 +1353,27 @@ function transaction(fn) {
|
|||||||
return get().transaction(fn)();
|
return get().transaction(fn)();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let _originalDb = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONLY FOR TESTING: Override the internal db instance
|
||||||
|
* @param {import('better-sqlite3').Database} testDb
|
||||||
|
*/
|
||||||
|
function _setTestDatabase(testDb) {
|
||||||
|
if (!_originalDb) _originalDb = db;
|
||||||
|
db = testDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONLY FOR TESTING: Restore the original db instance
|
||||||
|
*/
|
||||||
|
function _resetTestDatabase() {
|
||||||
|
if (_originalDb) {
|
||||||
|
db = _originalDb;
|
||||||
|
_originalDb = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
init(); // auto-initialise when module is first imported
|
init(); // auto-initialise when module is first imported
|
||||||
|
|
||||||
export { init, get, transaction, currentVersion, getPath, backupToFile, restoreFromFile };
|
export { init, get, transaction, currentVersion, getPath, backupToFile, restoreFromFile, MIGRATIONS, _setTestDatabase, _resetTestDatabase };
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import recipesRouter from './routes/recipes.js';
|
|||||||
import calendarRouter from './routes/calendar.js';
|
import calendarRouter from './routes/calendar.js';
|
||||||
import notesRouter from './routes/notes.js';
|
import notesRouter from './routes/notes.js';
|
||||||
import contactsRouter from './routes/contacts.js';
|
import contactsRouter from './routes/contacts.js';
|
||||||
|
import cardavRouter from './routes/cardav.js';
|
||||||
import birthdaysRouter from './routes/birthdays.js';
|
import birthdaysRouter from './routes/birthdays.js';
|
||||||
import budgetRouter from './routes/budget.js';
|
import budgetRouter from './routes/budget.js';
|
||||||
import documentsRouter from './routes/documents.js';
|
import documentsRouter from './routes/documents.js';
|
||||||
@@ -194,6 +195,7 @@ app.use('/api/v1/meals', mealsRouter);
|
|||||||
app.use('/api/v1/recipes', recipesRouter);
|
app.use('/api/v1/recipes', recipesRouter);
|
||||||
app.use('/api/v1/calendar', calendarRouter);
|
app.use('/api/v1/calendar', calendarRouter);
|
||||||
app.use('/api/v1/notes', notesRouter);
|
app.use('/api/v1/notes', notesRouter);
|
||||||
|
app.use('/api/v1/contacts/cardav', cardavRouter);
|
||||||
app.use('/api/v1/contacts', contactsRouter);
|
app.use('/api/v1/contacts', contactsRouter);
|
||||||
app.use('/api/v1/birthdays', birthdaysRouter);
|
app.use('/api/v1/birthdays', birthdaysRouter);
|
||||||
app.use('/api/v1/budget', budgetRouter);
|
app.use('/api/v1/budget', budgetRouter);
|
||||||
|
|||||||
@@ -156,8 +156,24 @@ function id(val, field) {
|
|||||||
return { value: n, error: null };
|
return { value: n, error: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validiert einen Boolean-Wert.
|
||||||
|
* @param {any} val
|
||||||
|
* @param {string} field
|
||||||
|
* @returns {{ value: boolean|null, error: string|null }}
|
||||||
|
*/
|
||||||
|
function bool(val, field) {
|
||||||
|
if (val === undefined || val === null) {
|
||||||
|
return { value: null, error: `${field} is required.` };
|
||||||
|
}
|
||||||
|
if (typeof val !== 'boolean') {
|
||||||
|
return { value: null, error: `${field} must be a boolean.` };
|
||||||
|
}
|
||||||
|
return { value: val, error: null };
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
str, oneOf, date, time, datetime, month, num, color, rrule, id, collectErrors,
|
str, oneOf, date, time, datetime, month, num, color, rrule, id, bool, collectErrors,
|
||||||
MAX_TITLE, MAX_TEXT, MAX_SHORT, MAX_RRULE,
|
MAX_TITLE, MAX_TEXT, MAX_SHORT, MAX_RRULE,
|
||||||
DATE_RE, TIME_RE, DATETIME_RE, COLOR_RE, MONTH_RE,
|
DATE_RE, TIME_RE, DATETIME_RE, COLOR_RE, MONTH_RE,
|
||||||
};
|
};
|
||||||
|
|||||||
+25
-2
@@ -457,11 +457,34 @@ function buildPaths() {
|
|||||||
},
|
},
|
||||||
'/api/v1/contacts': {
|
'/api/v1/contacts': {
|
||||||
get: op({ summary: 'List contacts', tag: 'Contacts' }),
|
get: op({ summary: 'List contacts', tag: 'Contacts' }),
|
||||||
post: op({ summary: 'Create contact', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
|
post: op({ summary: 'Create contact with multi-value fields', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
|
||||||
},
|
},
|
||||||
'/api/v1/contacts/meta': { get: op({ summary: 'Get contact metadata', tag: 'Contacts' }) },
|
'/api/v1/contacts/meta': { get: op({ summary: 'Get contact metadata', tag: 'Contacts' }) },
|
||||||
|
'/api/v1/contacts/cardav/accounts': {
|
||||||
|
get: op({ summary: 'List CardDAV accounts', tag: 'Contacts' }),
|
||||||
|
post: op({ summary: 'Add CardDAV account', tag: 'Contacts', stateChanging: true, requestBody: jsonBody(null) }),
|
||||||
|
},
|
||||||
|
'/api/v1/contacts/cardav/accounts/{id}': {
|
||||||
|
delete: op({ summary: 'Delete CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||||
|
},
|
||||||
|
'/api/v1/contacts/cardav/accounts/{id}/test': {
|
||||||
|
post: op({ summary: 'Test CardDAV connection', tag: 'Contacts', params: [idParam()] }),
|
||||||
|
},
|
||||||
|
'/api/v1/contacts/cardav/accounts/{id}/addressbooks': {
|
||||||
|
get: op({ summary: 'List addressbooks for account', tag: 'Contacts', params: [idParam()] }),
|
||||||
|
},
|
||||||
|
'/api/v1/contacts/cardav/accounts/{id}/addressbooks/refresh': {
|
||||||
|
post: op({ summary: 'Refresh addressbooks for account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||||
|
},
|
||||||
|
'/api/v1/contacts/cardav/addressbooks/{id}': {
|
||||||
|
put: op({ summary: 'Toggle addressbook enabled state', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
|
||||||
|
},
|
||||||
|
'/api/v1/contacts/cardav/accounts/{id}/sync': {
|
||||||
|
post: op({ summary: 'Sync CardDAV account', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||||
|
},
|
||||||
'/api/v1/contacts/{id}': {
|
'/api/v1/contacts/{id}': {
|
||||||
put: op({ summary: 'Update contact', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
|
get: op({ summary: 'Get contact with multi-value fields', tag: 'Contacts', params: [idParam()] }),
|
||||||
|
put: op({ summary: 'Update contact with multi-value fields', tag: 'Contacts', params: [idParam()], stateChanging: true, requestBody: jsonBody(null) }),
|
||||||
delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
delete: op({ summary: 'Delete contact', tag: 'Contacts', params: [idParam()], stateChanging: true }),
|
||||||
},
|
},
|
||||||
'/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) },
|
'/api/v1/contacts/{id}/vcard': { get: op({ summary: 'Download contact as vCard', tag: 'Contacts', params: [idParam()] }) },
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Modul: CardDAV Management
|
||||||
|
* Zweck: REST-API-Routen für CardDAV Account Management, Addressbook Discovery, Sync
|
||||||
|
* Abhängigkeiten: express, server/db.js, server/services/cardav-sync.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '../logger.js';
|
||||||
|
import express from 'express';
|
||||||
|
import * as db from '../db.js';
|
||||||
|
import * as CardDAVSync from '../services/cardav-sync.js';
|
||||||
|
import { str, bool, collectErrors, MAX_TITLE } from '../middleware/validate.js';
|
||||||
|
|
||||||
|
const log = createLogger('CardDAV');
|
||||||
|
const MAX_URL = 500;
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/contacts/cardav/accounts
|
||||||
|
* Liste aller CardDAV Accounts.
|
||||||
|
* Response: { data: Account[] }
|
||||||
|
*/
|
||||||
|
router.get('/accounts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const accounts = await CardDAVSync.getAllAccounts();
|
||||||
|
res.json({ data: accounts });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error fetching accounts:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/contacts/cardav/accounts
|
||||||
|
* Neuen CardDAV Account erstellen und Addressbooks discovern.
|
||||||
|
* Body: { name, cardavUrl, username, password }
|
||||||
|
* Response: { data: { account, addressbooks } }
|
||||||
|
*/
|
||||||
|
router.post('/accounts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const vName = str(req.body.name, 'Name', { max: MAX_TITLE });
|
||||||
|
const vUrl = str(req.body.cardavUrl, 'CardDAV URL', { max: MAX_URL });
|
||||||
|
const vUsername = str(req.body.username, 'Username', { max: MAX_TITLE });
|
||||||
|
const vPassword = str(req.body.password, 'Password', { max: MAX_TITLE });
|
||||||
|
const errors = collectErrors([vName, vUrl, vUsername, vPassword]);
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
|
const result = await CardDAVSync.addAccount(
|
||||||
|
vName.value,
|
||||||
|
vUrl.value,
|
||||||
|
vUsername.value,
|
||||||
|
vPassword.value
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(201).json({ data: result });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error adding CardDAV account:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/contacts/cardav/accounts/:id
|
||||||
|
* CardDAV Account löschen (CASCADE löscht addressbooks + contacts).
|
||||||
|
* Response: { data: { deleted: true } }
|
||||||
|
*/
|
||||||
|
router.delete('/accounts/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
|
||||||
|
|
||||||
|
await CardDAVSync.deleteAccount(id);
|
||||||
|
|
||||||
|
res.json({ data: { deleted: true } });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error deleting CardDAV account:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/contacts/cardav/accounts/:id/test
|
||||||
|
* Connection testen (ohne Account zu speichern).
|
||||||
|
* Response: { data: { ok, addressbooks } }
|
||||||
|
*/
|
||||||
|
router.post('/accounts/:id/test', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
|
||||||
|
|
||||||
|
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id);
|
||||||
|
if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
const result = await CardDAVSync.testConnection(
|
||||||
|
account.carddav_url,
|
||||||
|
account.username,
|
||||||
|
account.password
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error testing CardDAV connection:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/contacts/cardav/accounts/:id/addressbooks
|
||||||
|
* Addressbooks für Account auflisten.
|
||||||
|
* Response: { data: Addressbook[] }
|
||||||
|
*/
|
||||||
|
router.get('/accounts/:id/addressbooks', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
|
||||||
|
|
||||||
|
const addressbooks = db.get().prepare(`
|
||||||
|
SELECT id, addressbook_url as url, addressbook_name as name, enabled
|
||||||
|
FROM carddav_addressbook_selection
|
||||||
|
WHERE account_id = ?
|
||||||
|
ORDER BY addressbook_name
|
||||||
|
`).all(id);
|
||||||
|
|
||||||
|
res.json({ data: addressbooks });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error fetching addressbooks:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/contacts/cardav/accounts/:id/addressbooks/refresh
|
||||||
|
* Addressbooks neu discovern (PROPFIND).
|
||||||
|
* Response: { data: Addressbook[] }
|
||||||
|
*/
|
||||||
|
router.post('/accounts/:id/addressbooks/refresh', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
|
||||||
|
|
||||||
|
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id);
|
||||||
|
if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
await CardDAVSync.discoverAddressbooks(id);
|
||||||
|
|
||||||
|
const addressbooks = db.get().prepare(`
|
||||||
|
SELECT id, addressbook_url as url, addressbook_name as name, enabled
|
||||||
|
FROM carddav_addressbook_selection
|
||||||
|
WHERE account_id = ?
|
||||||
|
ORDER BY addressbook_name
|
||||||
|
`).all(id);
|
||||||
|
|
||||||
|
res.json({ data: addressbooks });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error refreshing addressbooks:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/v1/contacts/cardav/addressbooks/:id
|
||||||
|
* Toggle Addressbook enabled/disabled.
|
||||||
|
* Body: { enabled: boolean }
|
||||||
|
* Response: { data: { updated: true, enabled: boolean } }
|
||||||
|
*/
|
||||||
|
router.put('/addressbooks/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
|
||||||
|
|
||||||
|
const vEnabled = bool(req.body.enabled, 'enabled');
|
||||||
|
const errors = collectErrors([vEnabled]);
|
||||||
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
|
CardDAVSync.toggleAddressbook(id, vEnabled.value);
|
||||||
|
|
||||||
|
res.json({ data: { updated: true, enabled: vEnabled.value } });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error toggling addressbook:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/contacts/cardav/accounts/:id/sync
|
||||||
|
* Sync all enabled addressbooks for account.
|
||||||
|
* Response: { data: { synced: boolean, contactsAdded: number, contactsUpdated: number } }
|
||||||
|
*/
|
||||||
|
router.post('/accounts/:id/sync', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!id || id < 1) return res.status(400).json({ error: 'Invalid ID', code: 400 });
|
||||||
|
|
||||||
|
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(id);
|
||||||
|
if (!account) return res.status(404).json({ error: 'Account nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
const result = await CardDAVSync.syncAccount(id);
|
||||||
|
|
||||||
|
res.json({ data: result });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Error syncing account:', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
+331
-5
@@ -16,6 +16,140 @@ const router = express.Router();
|
|||||||
const VALID_CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung',
|
const VALID_CATEGORIES = ['Arzt', 'Schule/Kita', 'Behörde', 'Versicherung',
|
||||||
'Handwerker', 'Notfall', 'Sonstiges'];
|
'Handwerker', 'Notfall', 'Sonstiges'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads multi-value fields (phones, emails, addresses) for a contact.
|
||||||
|
* @param {number} contactId - Contact ID
|
||||||
|
* @returns {{ phones: Array, emails: Array, addresses: Array }}
|
||||||
|
*/
|
||||||
|
function loadMultiValueFields(contactId) {
|
||||||
|
const phones = db.get().prepare(`
|
||||||
|
SELECT id, label, value, is_primary FROM contact_phones
|
||||||
|
WHERE contact_id = ?
|
||||||
|
ORDER BY is_primary DESC, id ASC
|
||||||
|
`).all(contactId).map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
value: p.value,
|
||||||
|
isPrimary: p.is_primary === 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
const emails = db.get().prepare(`
|
||||||
|
SELECT id, label, value, is_primary FROM contact_emails
|
||||||
|
WHERE contact_id = ?
|
||||||
|
ORDER BY is_primary DESC, id ASC
|
||||||
|
`).all(contactId).map(e => ({
|
||||||
|
id: e.id,
|
||||||
|
label: e.label,
|
||||||
|
value: e.value,
|
||||||
|
isPrimary: e.is_primary === 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
const addresses = db.get().prepare(`
|
||||||
|
SELECT id, label, street, city, state, postal_code, country, is_primary
|
||||||
|
FROM contact_addresses
|
||||||
|
WHERE contact_id = ?
|
||||||
|
ORDER BY is_primary DESC, id ASC
|
||||||
|
`).all(contactId).map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
label: a.label,
|
||||||
|
street: a.street,
|
||||||
|
city: a.city,
|
||||||
|
state: a.state,
|
||||||
|
postalCode: a.postal_code,
|
||||||
|
country: a.country,
|
||||||
|
isPrimary: a.is_primary === 1
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { phones, emails, addresses };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates phones array for multi-value contact fields.
|
||||||
|
* @param {Array} phones - Array of { label, value, isPrimary? }
|
||||||
|
* @returns {{ valid: boolean, error?: string }}
|
||||||
|
*/
|
||||||
|
function validatePhones(phones) {
|
||||||
|
if (!Array.isArray(phones)) return { valid: false, error: 'Phones must be an array' };
|
||||||
|
if (phones.length > 20) return { valid: false, error: 'Too many phone entries (max 20)' };
|
||||||
|
for (const p of phones) {
|
||||||
|
if (!p || typeof p !== 'object') return { valid: false, error: 'Phone entry must be an object' };
|
||||||
|
if (!p.label || !p.value) return { valid: false, error: 'Phone requires label and value' };
|
||||||
|
if (typeof p.label !== 'string' || p.label.trim().length === 0 || p.label.length > 50) {
|
||||||
|
return { valid: false, error: 'Phone label invalid or too long' };
|
||||||
|
}
|
||||||
|
if (typeof p.value !== 'string' || p.value.trim().length === 0 || p.value.length > 50) {
|
||||||
|
return { valid: false, error: 'Phone value invalid or too long' };
|
||||||
|
}
|
||||||
|
if (p.isPrimary !== undefined && typeof p.isPrimary !== 'boolean') {
|
||||||
|
return { valid: false, error: 'Phone isPrimary must be boolean' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates emails array for multi-value contact fields.
|
||||||
|
* @param {Array} emails - Array of { label, value, isPrimary? }
|
||||||
|
* @returns {{ valid: boolean, error?: string }}
|
||||||
|
*/
|
||||||
|
function validateEmails(emails) {
|
||||||
|
if (!Array.isArray(emails)) return { valid: false, error: 'Emails must be an array' };
|
||||||
|
if (emails.length > 20) return { valid: false, error: 'Too many email entries (max 20)' };
|
||||||
|
for (const e of emails) {
|
||||||
|
if (!e || typeof e !== 'object') return { valid: false, error: 'Email entry must be an object' };
|
||||||
|
if (!e.label || !e.value) return { valid: false, error: 'Email requires label and value' };
|
||||||
|
if (typeof e.label !== 'string' || e.label.trim().length === 0 || e.label.length > 50) {
|
||||||
|
return { valid: false, error: 'Email label invalid or too long' };
|
||||||
|
}
|
||||||
|
if (typeof e.value !== 'string' || e.value.trim().length === 0 || e.value.length > 255) {
|
||||||
|
return { valid: false, error: 'Email value invalid or too long' };
|
||||||
|
}
|
||||||
|
if (!/^.+@.+$/.test(e.value)) {
|
||||||
|
return { valid: false, error: 'Email value must be a valid email address' };
|
||||||
|
}
|
||||||
|
if (e.isPrimary !== undefined && typeof e.isPrimary !== 'boolean') {
|
||||||
|
return { valid: false, error: 'Email isPrimary must be boolean' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates addresses array for multi-value contact fields.
|
||||||
|
* @param {Array} addresses - Array of { label, street?, city?, state?, postalCode?, country?, isPrimary? }
|
||||||
|
* @returns {{ valid: boolean, error?: string }}
|
||||||
|
*/
|
||||||
|
function validateAddresses(addresses) {
|
||||||
|
if (!Array.isArray(addresses)) return { valid: false, error: 'Addresses must be an array' };
|
||||||
|
if (addresses.length > 20) return { valid: false, error: 'Too many address entries (max 20)' };
|
||||||
|
for (const a of addresses) {
|
||||||
|
if (!a || typeof a !== 'object') return { valid: false, error: 'Address entry must be an object' };
|
||||||
|
if (!a.label) return { valid: false, error: 'Address requires label' };
|
||||||
|
if (typeof a.label !== 'string' || a.label.trim().length === 0 || a.label.length > 50) {
|
||||||
|
return { valid: false, error: 'Address label invalid or too long' };
|
||||||
|
}
|
||||||
|
if (a.street !== undefined && (typeof a.street !== 'string' || a.street.length > 255)) {
|
||||||
|
return { valid: false, error: 'Address street invalid or too long' };
|
||||||
|
}
|
||||||
|
if (a.city !== undefined && (typeof a.city !== 'string' || a.city.length > 255)) {
|
||||||
|
return { valid: false, error: 'Address city invalid or too long' };
|
||||||
|
}
|
||||||
|
if (a.state !== undefined && (typeof a.state !== 'string' || a.state.length > 255)) {
|
||||||
|
return { valid: false, error: 'Address state invalid or too long' };
|
||||||
|
}
|
||||||
|
if (a.postalCode !== undefined && (typeof a.postalCode !== 'string' || a.postalCode.length > 255)) {
|
||||||
|
return { valid: false, error: 'Address postalCode invalid or too long' };
|
||||||
|
}
|
||||||
|
if (a.country !== undefined && (typeof a.country !== 'string' || a.country.length > 255)) {
|
||||||
|
return { valid: false, error: 'Address country invalid or too long' };
|
||||||
|
}
|
||||||
|
if (a.isPrimary !== undefined && typeof a.isPrimary !== 'boolean') {
|
||||||
|
return { valid: false, error: 'Address isPrimary must be boolean' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/contacts
|
* GET /api/v1/contacts
|
||||||
* Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht.
|
* Alle Kontakte, optional nach Kategorie gefiltert und nach Name gesucht.
|
||||||
@@ -53,7 +187,7 @@ router.get('/', (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* POST /api/v1/contacts
|
* POST /api/v1/contacts
|
||||||
* Neuen Kontakt anlegen.
|
* Neuen Kontakt anlegen.
|
||||||
* Body: { name, category?, phone?, email?, address?, notes? }
|
* Body: { name, category?, phone?, email?, address?, notes?, phones?, emails?, addresses? }
|
||||||
* Response: { data: Contact }
|
* Response: { data: Contact }
|
||||||
*/
|
*/
|
||||||
router.post('/', (req, res) => {
|
router.post('/', (req, res) => {
|
||||||
@@ -67,14 +201,95 @@ router.post('/', (req, res) => {
|
|||||||
const errors = collectErrors([vName, vCat, vPhone, vEmail, vAddress, vNotes]);
|
const errors = collectErrors([vName, vCat, vPhone, vEmail, vAddress, vNotes]);
|
||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
|
// Validate multi-value fields if provided
|
||||||
|
if (req.body.phones !== undefined) {
|
||||||
|
const phonesValidation = validatePhones(req.body.phones);
|
||||||
|
if (!phonesValidation.valid) {
|
||||||
|
return res.status(400).json({ error: phonesValidation.error, code: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.emails !== undefined) {
|
||||||
|
const emailsValidation = validateEmails(req.body.emails);
|
||||||
|
if (!emailsValidation.valid) {
|
||||||
|
return res.status(400).json({ error: emailsValidation.error, code: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.addresses !== undefined) {
|
||||||
|
const addressesValidation = validateAddresses(req.body.addresses);
|
||||||
|
if (!addressesValidation.valid) {
|
||||||
|
return res.status(400).json({ error: addressesValidation.error, code: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert contact and multi-value fields in a transaction
|
||||||
|
const transaction = db.get().transaction(() => {
|
||||||
const result = db.get().prepare(`
|
const result = db.get().prepare(`
|
||||||
INSERT INTO contacts (name, category, phone, email, address, notes)
|
INSERT INTO contacts (name, category, phone, email, address, notes)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`).run(vName.value, vCat.value || 'Sonstiges', vPhone.value, vEmail.value,
|
`).run(vName.value, vCat.value || 'Sonstiges', vPhone.value, vEmail.value,
|
||||||
vAddress.value, vNotes.value);
|
vAddress.value, vNotes.value);
|
||||||
|
|
||||||
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(result.lastInsertRowid);
|
const contactId = result.lastInsertRowid;
|
||||||
res.status(201).json({ data: contact });
|
|
||||||
|
// Insert phones
|
||||||
|
if (req.body.phones && Array.isArray(req.body.phones)) {
|
||||||
|
const insertPhone = db.get().prepare(`
|
||||||
|
INSERT INTO contact_phones (contact_id, label, value, is_primary)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const phone of req.body.phones) {
|
||||||
|
insertPhone.run(contactId, phone.label, phone.value, phone.isPrimary ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert emails
|
||||||
|
if (req.body.emails && Array.isArray(req.body.emails)) {
|
||||||
|
const insertEmail = db.get().prepare(`
|
||||||
|
INSERT INTO contact_emails (contact_id, label, value, is_primary)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const email of req.body.emails) {
|
||||||
|
insertEmail.run(contactId, email.label, email.value, email.isPrimary ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert addresses
|
||||||
|
if (req.body.addresses && Array.isArray(req.body.addresses)) {
|
||||||
|
const insertAddress = db.get().prepare(`
|
||||||
|
INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const address of req.body.addresses) {
|
||||||
|
insertAddress.run(
|
||||||
|
contactId,
|
||||||
|
address.label,
|
||||||
|
address.street || null,
|
||||||
|
address.city || null,
|
||||||
|
address.state || null,
|
||||||
|
address.postalCode || null,
|
||||||
|
address.country || null,
|
||||||
|
address.isPrimary ? 1 : 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return contactId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const contactId = transaction();
|
||||||
|
|
||||||
|
// Query the created contact with multi-value fields
|
||||||
|
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
|
||||||
|
const multiValueFields = loadMultiValueFields(contactId);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
data: {
|
||||||
|
...contact,
|
||||||
|
...multiValueFields
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('', err);
|
log.error('', err);
|
||||||
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
@@ -84,7 +299,7 @@ router.post('/', (req, res) => {
|
|||||||
/**
|
/**
|
||||||
* PUT /api/v1/contacts/:id
|
* PUT /api/v1/contacts/:id
|
||||||
* Kontakt bearbeiten.
|
* Kontakt bearbeiten.
|
||||||
* Body: alle Felder optional
|
* Body: alle Felder optional, phones/emails/addresses mit Replacement-Semantik
|
||||||
* Response: { data: Contact }
|
* Response: { data: Contact }
|
||||||
*/
|
*/
|
||||||
router.put('/:id', (req, res) => {
|
router.put('/:id', (req, res) => {
|
||||||
@@ -103,6 +318,31 @@ router.put('/:id', (req, res) => {
|
|||||||
const errors = collectErrors(checks);
|
const errors = collectErrors(checks);
|
||||||
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 });
|
||||||
|
|
||||||
|
// Validate multi-value fields if provided
|
||||||
|
if (req.body.phones !== undefined) {
|
||||||
|
const phonesValidation = validatePhones(req.body.phones);
|
||||||
|
if (!phonesValidation.valid) {
|
||||||
|
return res.status(400).json({ error: phonesValidation.error, code: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.emails !== undefined) {
|
||||||
|
const emailsValidation = validateEmails(req.body.emails);
|
||||||
|
if (!emailsValidation.valid) {
|
||||||
|
return res.status(400).json({ error: emailsValidation.error, code: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.addresses !== undefined) {
|
||||||
|
const addressesValidation = validateAddresses(req.body.addresses);
|
||||||
|
if (!addressesValidation.valid) {
|
||||||
|
return res.status(400).json({ error: addressesValidation.error, code: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update contact and multi-value fields in a transaction
|
||||||
|
const transaction = db.get().transaction(() => {
|
||||||
|
// Update scalar fields
|
||||||
db.get().prepare(`
|
db.get().prepare(`
|
||||||
UPDATE contacts
|
UPDATE contacts
|
||||||
SET name = COALESCE(?, name),
|
SET name = COALESCE(?, name),
|
||||||
@@ -122,8 +362,67 @@ router.put('/:id', (req, res) => {
|
|||||||
id
|
id
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Replace phones (delete all, insert new)
|
||||||
|
if (req.body.phones !== undefined && Array.isArray(req.body.phones)) {
|
||||||
|
db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ?').run(id);
|
||||||
|
|
||||||
|
const insertPhone = db.get().prepare(`
|
||||||
|
INSERT INTO contact_phones (contact_id, label, value, is_primary)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const phone of req.body.phones) {
|
||||||
|
insertPhone.run(id, phone.label, phone.value, phone.isPrimary ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace emails (delete all, insert new)
|
||||||
|
if (req.body.emails !== undefined && Array.isArray(req.body.emails)) {
|
||||||
|
db.get().prepare('DELETE FROM contact_emails WHERE contact_id = ?').run(id);
|
||||||
|
|
||||||
|
const insertEmail = db.get().prepare(`
|
||||||
|
INSERT INTO contact_emails (contact_id, label, value, is_primary)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const email of req.body.emails) {
|
||||||
|
insertEmail.run(id, email.label, email.value, email.isPrimary ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace addresses (delete all, insert new)
|
||||||
|
if (req.body.addresses !== undefined && Array.isArray(req.body.addresses)) {
|
||||||
|
db.get().prepare('DELETE FROM contact_addresses WHERE contact_id = ?').run(id);
|
||||||
|
|
||||||
|
const insertAddress = db.get().prepare(`
|
||||||
|
INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const address of req.body.addresses) {
|
||||||
|
insertAddress.run(
|
||||||
|
id,
|
||||||
|
address.label,
|
||||||
|
address.street || null,
|
||||||
|
address.city || null,
|
||||||
|
address.state || null,
|
||||||
|
address.postalCode || null,
|
||||||
|
address.country || null,
|
||||||
|
address.isPrimary ? 1 : 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction();
|
||||||
|
|
||||||
|
// Query the updated contact with multi-value fields
|
||||||
const updated = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
|
const updated = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
|
||||||
res.json({ data: updated });
|
const multiValueFields = loadMultiValueFields(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: {
|
||||||
|
...updated,
|
||||||
|
...multiValueFields
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error('', err);
|
log.error('', err);
|
||||||
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
@@ -169,6 +468,32 @@ router.get('/meta', (_req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/contacts/:id
|
||||||
|
* Einzelnen Kontakt abrufen mit Multi-Value Fields (phones, emails, addresses).
|
||||||
|
* Response: { data: Contact }
|
||||||
|
*/
|
||||||
|
router.get('/:id', (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id);
|
||||||
|
if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 });
|
||||||
|
|
||||||
|
// Load multi-value fields
|
||||||
|
const multiValueFields = loadMultiValueFields(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: {
|
||||||
|
...contact,
|
||||||
|
...multiValueFields
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
log.error('', err);
|
||||||
|
res.status(500).json({ error: 'Interner Fehler', code: 500 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/contacts/:id/vcard
|
* GET /api/v1/contacts/:id/vcard
|
||||||
* Kontakt als vCard 3.0 (.vcf) exportieren.
|
* Kontakt als vCard 3.0 (.vcf) exportieren.
|
||||||
@@ -208,3 +533,4 @@ router.get('/:id/vcard', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
export { validatePhones, validateEmails, validateAddresses };
|
||||||
|
|||||||
@@ -0,0 +1,916 @@
|
|||||||
|
/**
|
||||||
|
* Modul: CardDAV Contacts Sync
|
||||||
|
* Zweck: Multi-Account CardDAV synchronization with addressbook selection
|
||||||
|
* Abhängigkeiten: tsdav, server/db.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createLogger } from '../logger.js';
|
||||||
|
const log = createLogger('CardDAV');
|
||||||
|
|
||||||
|
import * as db from '../db.js';
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Helper Functions
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse vCard text into structured object
|
||||||
|
* @param {string} vCardText - Raw vCard data
|
||||||
|
* @returns {Object} Parsed vCard object
|
||||||
|
*/
|
||||||
|
function parseVCard(vCardText) {
|
||||||
|
const lines = vCardText.split(/\r?\n/).filter(line => line.trim());
|
||||||
|
const vcard = {
|
||||||
|
uid: null,
|
||||||
|
name: null,
|
||||||
|
phones: [],
|
||||||
|
emails: [],
|
||||||
|
addresses: [],
|
||||||
|
organization: null,
|
||||||
|
jobTitle: null,
|
||||||
|
website: null,
|
||||||
|
birthday: null,
|
||||||
|
photo: null,
|
||||||
|
nickname: null,
|
||||||
|
notes: null,
|
||||||
|
categories: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
let line = lines[i];
|
||||||
|
|
||||||
|
// Handle line folding (continuation lines start with space or tab)
|
||||||
|
while (i + 1 < lines.length && /^[ \t]/.test(lines[i + 1])) {
|
||||||
|
line += lines[i + 1].substring(1);
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonIndex = line.indexOf(':');
|
||||||
|
if (colonIndex === -1) continue;
|
||||||
|
|
||||||
|
const fullKey = line.substring(0, colonIndex);
|
||||||
|
const value = line.substring(colonIndex + 1).trim();
|
||||||
|
|
||||||
|
// Parse property and parameters
|
||||||
|
const [prop, ...params] = fullKey.split(';');
|
||||||
|
const property = prop.toUpperCase();
|
||||||
|
|
||||||
|
switch (property) {
|
||||||
|
case 'UID':
|
||||||
|
vcard.uid = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'FN':
|
||||||
|
if (!vcard.name) vcard.name = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'N':
|
||||||
|
// N is fallback if FN is not present
|
||||||
|
// Format: Family;Given;Middle;Prefix;Suffix
|
||||||
|
if (!vcard.name) {
|
||||||
|
const parts = value.split(';').filter(p => p);
|
||||||
|
vcard.name = parts.join(' ').trim();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'TEL':
|
||||||
|
const phoneType = extractType(params) || 'other';
|
||||||
|
vcard.phones.push({ label: phoneType, value: value });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'EMAIL':
|
||||||
|
const emailType = extractType(params) || 'other';
|
||||||
|
vcard.emails.push({ label: emailType, value: value });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ADR':
|
||||||
|
// Format: POBox;Extended;Street;City;State;Postal;Country
|
||||||
|
const adrParts = value.split(';');
|
||||||
|
const adrType = extractType(params) || 'other';
|
||||||
|
vcard.addresses.push({
|
||||||
|
label: adrType,
|
||||||
|
street: adrParts[2] || null,
|
||||||
|
city: adrParts[3] || null,
|
||||||
|
state: adrParts[4] || null,
|
||||||
|
postalCode: adrParts[5] || null,
|
||||||
|
country: adrParts[6] || null,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ORG':
|
||||||
|
vcard.organization = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'TITLE':
|
||||||
|
vcard.jobTitle = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'URL':
|
||||||
|
// Take first URL if multiple exist
|
||||||
|
if (!vcard.website) vcard.website = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'BDAY':
|
||||||
|
// Parse birthday to ISO format (YYYY-MM-DD)
|
||||||
|
vcard.birthday = parseBirthday(value);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'PHOTO':
|
||||||
|
// Handle base64 encoded photos
|
||||||
|
if (params.some(p => p.toUpperCase().includes('ENCODING=BASE64') || p.toUpperCase().includes('ENCODING=B'))) {
|
||||||
|
// Photo might span multiple lines in old vCard format
|
||||||
|
vcard.photo = value;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NICKNAME':
|
||||||
|
vcard.nickname = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'NOTE':
|
||||||
|
vcard.notes = value;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'CATEGORIES':
|
||||||
|
vcard.categories = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vcard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract TYPE parameter from vCard property parameters
|
||||||
|
* @param {Array<string>} params - Property parameters
|
||||||
|
* @returns {string|null} Type value
|
||||||
|
*/
|
||||||
|
function extractType(params) {
|
||||||
|
// Priority order: more specific types first
|
||||||
|
const typeHierarchy = ['CELL', 'MOBILE', 'HOME', 'WORK', 'FAX', 'OTHER', 'VOICE'];
|
||||||
|
|
||||||
|
let foundType = null;
|
||||||
|
|
||||||
|
for (const param of params) {
|
||||||
|
const upper = param.toUpperCase();
|
||||||
|
if (upper.startsWith('TYPE=')) {
|
||||||
|
return param.substring(5).toLowerCase();
|
||||||
|
}
|
||||||
|
// Some vCards use TYPE without =
|
||||||
|
if (typeHierarchy.includes(upper)) {
|
||||||
|
// Keep the most specific type (earlier in hierarchy)
|
||||||
|
const currentIndex = typeHierarchy.indexOf(upper);
|
||||||
|
const foundIndex = foundType ? typeHierarchy.indexOf(foundType.toUpperCase()) : -1;
|
||||||
|
|
||||||
|
if (foundIndex === -1 || currentIndex < foundIndex) {
|
||||||
|
foundType = upper.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return foundType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse birthday from various vCard formats to ISO date
|
||||||
|
* @param {string} value - Birthday value from vCard
|
||||||
|
* @returns {string|null} ISO date (YYYY-MM-DD) or null
|
||||||
|
*/
|
||||||
|
function parseBirthday(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
// Remove any non-numeric characters except hyphens
|
||||||
|
const cleaned = value.replace(/[^\d-]/g, '');
|
||||||
|
|
||||||
|
// Try ISO format (YYYY-MM-DD)
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(cleaned)) {
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try compact format (YYYYMMDD)
|
||||||
|
if (/^\d{8}$/.test(cleaned)) {
|
||||||
|
return `${cleaned.slice(0, 4)}-${cleaned.slice(4, 6)}-${cleaned.slice(6, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try year only
|
||||||
|
if (/^\d{4}$/.test(cleaned)) {
|
||||||
|
return `${cleaned}-01-01`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Account Management
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test CardDAV connection
|
||||||
|
* @param {string} cardavUrl - CardDAV server URL
|
||||||
|
* @param {string} username - Username
|
||||||
|
* @param {string} password - Password
|
||||||
|
* @returns {Promise<Object>} { ok: true, addressbooks: [...] }
|
||||||
|
*/
|
||||||
|
async function testConnection(cardavUrl, username, password) {
|
||||||
|
// Use mock if set (for testing)
|
||||||
|
if (_testConnectionMock) {
|
||||||
|
return _testConnectionMock(cardavUrl, username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { createDAVClient } = await import('tsdav');
|
||||||
|
const client = await createDAVClient({
|
||||||
|
serverUrl: cardavUrl,
|
||||||
|
credentials: { username, password },
|
||||||
|
authMethod: 'Basic',
|
||||||
|
defaultAccountType: 'carddav',
|
||||||
|
});
|
||||||
|
|
||||||
|
const addressbooks = await client.fetchAddressBooks();
|
||||||
|
if (!addressbooks.length) {
|
||||||
|
throw new Error('Connected, but no addressbooks found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, addressbooks };
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Connection test failed:', err.message);
|
||||||
|
throw new Error(`CardDAV connection failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add new CardDAV account
|
||||||
|
* @param {string} name - Account display name
|
||||||
|
* @param {string} cardavUrl - CardDAV server URL
|
||||||
|
* @param {string} username - Username
|
||||||
|
* @param {string} password - Password
|
||||||
|
* @returns {Promise<Object>} { accountId, addressbooks }
|
||||||
|
*/
|
||||||
|
async function addAccount(name, cardavUrl, username, password) {
|
||||||
|
try {
|
||||||
|
// Validate inputs
|
||||||
|
if (!name || !cardavUrl || !username || !password) {
|
||||||
|
throw new Error('All fields required: name, cardavUrl, username, password');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection first
|
||||||
|
const { addressbooks } = await testConnection(cardavUrl, username, password);
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
const existing = db.get().prepare(
|
||||||
|
'SELECT id FROM carddav_accounts WHERE carddav_url = ? AND username = ?'
|
||||||
|
).get(cardavUrl, username);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('Account with this URL and username already exists.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if DB_ENCRYPTION_KEY not set
|
||||||
|
if (!process.env.DB_ENCRYPTION_KEY) {
|
||||||
|
log.warn('WARNING: DB_ENCRYPTION_KEY is not set - CardDAV credentials will be stored unencrypted.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert account
|
||||||
|
const result = db.get().prepare(`
|
||||||
|
INSERT INTO carddav_accounts (name, carddav_url, username, password)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(name, cardavUrl, username, password);
|
||||||
|
|
||||||
|
const accountId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
// Insert addressbook selections (all enabled by default)
|
||||||
|
const addressbookData = [];
|
||||||
|
for (const abook of addressbooks) {
|
||||||
|
const abookName = abook.displayName || 'Unnamed Addressbook';
|
||||||
|
|
||||||
|
db.get().prepare(`
|
||||||
|
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled)
|
||||||
|
VALUES (?, ?, ?, 1)
|
||||||
|
`).run(accountId, abook.url, abookName);
|
||||||
|
|
||||||
|
addressbookData.push({ url: abook.url, name: abookName, enabled: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Added CardDAV account "${name}" with ${addressbooks.length} addressbooks.`);
|
||||||
|
|
||||||
|
const account = {
|
||||||
|
id: accountId,
|
||||||
|
name,
|
||||||
|
cardavUrl,
|
||||||
|
username,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastSync: null
|
||||||
|
};
|
||||||
|
|
||||||
|
return { account, addressbooks: addressbookData };
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Failed to add account:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all CardDAV accounts
|
||||||
|
* @returns {Array<Object>} Array of account objects (without passwords)
|
||||||
|
*/
|
||||||
|
function getAllAccounts() {
|
||||||
|
try {
|
||||||
|
const accounts = db.get().prepare(`
|
||||||
|
SELECT id, name, carddav_url, username, created_at, last_sync
|
||||||
|
FROM carddav_accounts
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
return accounts.map(acc => ({
|
||||||
|
id: acc.id,
|
||||||
|
name: acc.name,
|
||||||
|
cardavUrl: acc.carddav_url,
|
||||||
|
username: acc.username,
|
||||||
|
createdAt: acc.created_at,
|
||||||
|
lastSync: acc.last_sync,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Failed to get accounts:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete CardDAV account
|
||||||
|
* @param {number} accountId - Account ID
|
||||||
|
* @returns {Object} { success: true }
|
||||||
|
*/
|
||||||
|
function deleteAccount(accountId) {
|
||||||
|
try {
|
||||||
|
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error(`Account ${accountId} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CASCADE will delete carddav_addressbook_selection entries
|
||||||
|
// Contacts will have carddav_account_id SET NULL (see migration)
|
||||||
|
db.get().prepare('DELETE FROM carddav_accounts WHERE id = ?').run(accountId);
|
||||||
|
|
||||||
|
log.info(`Deleted CardDAV account ${accountId} ("${account.name}").`);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Failed to delete account:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Addressbook Discovery & Selection
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover addressbooks for an account (refresh from server)
|
||||||
|
* @param {number} accountId - Account ID
|
||||||
|
* @returns {Promise<Array<Object>>} Array of addressbook objects
|
||||||
|
*/
|
||||||
|
async function discoverAddressbooks(accountId) {
|
||||||
|
try {
|
||||||
|
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error(`Account ${accountId} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { addressbooks } = await testConnection(account.carddav_url, account.username, account.password);
|
||||||
|
|
||||||
|
// UPSERT into carddav_addressbook_selection
|
||||||
|
const result = [];
|
||||||
|
for (const abook of addressbooks) {
|
||||||
|
const abookName = abook.displayName || 'Unnamed Addressbook';
|
||||||
|
|
||||||
|
// Check if exists
|
||||||
|
const existing = db.get().prepare(`
|
||||||
|
SELECT id, enabled FROM carddav_addressbook_selection
|
||||||
|
WHERE account_id = ? AND addressbook_url = ?
|
||||||
|
`).get(accountId, abook.url);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update name only (preserve enabled state)
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE carddav_addressbook_selection
|
||||||
|
SET addressbook_name = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(abookName, existing.id);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: existing.id,
|
||||||
|
url: abook.url,
|
||||||
|
name: abookName,
|
||||||
|
enabled: existing.enabled === 1
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Insert new (enabled by default)
|
||||||
|
const insertResult = db.get().prepare(`
|
||||||
|
INSERT INTO carddav_addressbook_selection (account_id, addressbook_url, addressbook_name, enabled)
|
||||||
|
VALUES (?, ?, ?, 1)
|
||||||
|
`).run(accountId, abook.url, abookName);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
id: insertResult.lastInsertRowid,
|
||||||
|
url: abook.url,
|
||||||
|
name: abookName,
|
||||||
|
enabled: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Discovered ${addressbooks.length} addressbooks for account ${accountId}.`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Failed to discover addressbooks:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle addressbook enabled state
|
||||||
|
* @param {number} addressbookId - Addressbook selection ID
|
||||||
|
* @param {boolean} enabled - Enable or disable
|
||||||
|
* @returns {Object} { success: true }
|
||||||
|
*/
|
||||||
|
function toggleAddressbook(addressbookId, enabled) {
|
||||||
|
try {
|
||||||
|
const enabledValue = enabled ? 1 : 0;
|
||||||
|
|
||||||
|
const result = db.get().prepare(`
|
||||||
|
UPDATE carddav_addressbook_selection
|
||||||
|
SET enabled = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(enabledValue, addressbookId);
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
throw new Error(`Addressbook ${addressbookId} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Addressbook ${addressbookId} ${enabled ? 'enabled' : 'disabled'}.`);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Failed to toggle addressbook:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Contact Sync
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync all enabled addressbooks for an account
|
||||||
|
* @param {number} accountId - Account ID
|
||||||
|
* @returns {Promise<Object>} { synced, errors }
|
||||||
|
*/
|
||||||
|
async function syncAccount(accountId) {
|
||||||
|
// Use mock if set (for testing)
|
||||||
|
if (_syncAccountMock) {
|
||||||
|
return _syncAccountMock(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error(`Account ${accountId} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Syncing CardDAV account ${accountId} ("${account.name}")...`);
|
||||||
|
|
||||||
|
// Create tsdav client
|
||||||
|
const { createDAVClient } = await import('tsdav');
|
||||||
|
const client = await createDAVClient({
|
||||||
|
serverUrl: account.carddav_url,
|
||||||
|
credentials: { username: account.username, password: account.password },
|
||||||
|
authMethod: 'Basic',
|
||||||
|
defaultAccountType: 'carddav',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get enabled addressbooks for this account
|
||||||
|
const enabledAddressbooks = db.get().prepare(`
|
||||||
|
SELECT id, addressbook_url, addressbook_name
|
||||||
|
FROM carddav_addressbook_selection
|
||||||
|
WHERE account_id = ? AND enabled = 1
|
||||||
|
`).all(accountId);
|
||||||
|
|
||||||
|
if (enabledAddressbooks.length === 0) {
|
||||||
|
log.info(`Account ${accountId}: no enabled addressbooks, skipping.`);
|
||||||
|
return { synced: 0, errors: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSynced = 0;
|
||||||
|
let totalErrors = 0;
|
||||||
|
|
||||||
|
// Fetch all addressbooks from server
|
||||||
|
const serverAddressbooks = await client.fetchAddressBooks();
|
||||||
|
|
||||||
|
for (const selAbook of enabledAddressbooks) {
|
||||||
|
// Find matching addressbook from server
|
||||||
|
const serverAbook = serverAddressbooks.find(sa => sa.url === selAbook.addressbook_url);
|
||||||
|
|
||||||
|
if (!serverAbook) {
|
||||||
|
log.warn(`Addressbook ${selAbook.addressbook_url} not found on server, disabling.`);
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE carddav_addressbook_selection SET enabled = 0
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(selAbook.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync this addressbook
|
||||||
|
const { synced, errors } = await syncAddressbook(accountId, selAbook.addressbook_url, client, serverAbook);
|
||||||
|
totalSynced += synced;
|
||||||
|
totalErrors += errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last_sync for account
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE carddav_accounts SET last_sync = ? WHERE id = ?
|
||||||
|
`).run(new Date().toISOString(), accountId);
|
||||||
|
|
||||||
|
log.info(`Account ${accountId} sync complete: ${totalSynced} contacts synced, ${totalErrors} errors.`);
|
||||||
|
|
||||||
|
return { synced: totalSynced, errors: totalErrors };
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`Sync failed for account ${accountId}:`, err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a specific addressbook
|
||||||
|
* @param {number} accountId - Account ID
|
||||||
|
* @param {string} addressbookUrl - Addressbook URL
|
||||||
|
* @param {Object} client - tsdav client instance (optional, will create if not provided)
|
||||||
|
* @param {Object} serverAddressbook - Server addressbook object (optional)
|
||||||
|
* @returns {Promise<Object>} { synced, errors }
|
||||||
|
*/
|
||||||
|
async function syncAddressbook(accountId, addressbookUrl, client = null, serverAddressbook = null) {
|
||||||
|
try {
|
||||||
|
const account = db.get().prepare('SELECT * FROM carddav_accounts WHERE id = ?').get(accountId);
|
||||||
|
if (!account) {
|
||||||
|
throw new Error(`Account ${accountId} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create client if not provided
|
||||||
|
if (!client) {
|
||||||
|
const { createDAVClient } = await import('tsdav');
|
||||||
|
client = await createDAVClient({
|
||||||
|
serverUrl: account.carddav_url,
|
||||||
|
credentials: { username: account.username, password: account.password },
|
||||||
|
authMethod: 'Basic',
|
||||||
|
defaultAccountType: 'carddav',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find addressbook if not provided
|
||||||
|
if (!serverAddressbook) {
|
||||||
|
const addressbooks = await client.fetchAddressBooks();
|
||||||
|
serverAddressbook = addressbooks.find(ab => ab.url === addressbookUrl);
|
||||||
|
|
||||||
|
if (!serverAddressbook) {
|
||||||
|
throw new Error(`Addressbook ${addressbookUrl} not found on server.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch vCards from addressbook
|
||||||
|
let vcardObjects;
|
||||||
|
try {
|
||||||
|
vcardObjects = await client.fetchVCards({ addressBook: serverAddressbook });
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`Failed to fetch vCards from ${addressbookUrl}:`, err.message);
|
||||||
|
return { synced: 0, errors: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let synced = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
// Parse and merge each vCard
|
||||||
|
for (const vcardObj of vcardObjects) {
|
||||||
|
try {
|
||||||
|
const vCardText = vcardObj.data || '';
|
||||||
|
if (!vCardText.trim()) continue;
|
||||||
|
|
||||||
|
await parseAndMergeContact(vCardText, accountId, addressbookUrl);
|
||||||
|
synced++;
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`Failed to parse/merge vCard:`, err.message);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`Addressbook ${addressbookUrl}: ${synced} contacts synced, ${errors} errors.`);
|
||||||
|
|
||||||
|
return { synced, errors };
|
||||||
|
} catch (err) {
|
||||||
|
log.error(`Failed to sync addressbook ${addressbookUrl}:`, err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse vCard and merge with existing contact using Smart Merge Logic
|
||||||
|
* @param {string} vCardText - Raw vCard data
|
||||||
|
* @param {number} accountId - Account ID
|
||||||
|
* @param {string} addressbookUrl - Addressbook URL
|
||||||
|
* @returns {Promise<number>} Contact ID
|
||||||
|
*/
|
||||||
|
async function parseAndMergeContact(vCardText, accountId, addressbookUrl) {
|
||||||
|
try {
|
||||||
|
const vcard = parseVCard(vCardText);
|
||||||
|
|
||||||
|
if (!vcard.uid) {
|
||||||
|
throw new Error('vCard missing UID, skipping.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vcard.name) {
|
||||||
|
throw new Error('vCard missing name (FN/N), skipping.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smart Merge Logic (see design doc)
|
||||||
|
|
||||||
|
// Step 1: Check for existing contact by cardav_uid
|
||||||
|
let contact = db.get().prepare(`
|
||||||
|
SELECT * FROM contacts
|
||||||
|
WHERE carddav_account_id = ? AND carddav_addressbook_url = ? AND carddav_uid = ?
|
||||||
|
`).get(accountId, addressbookUrl, vcard.uid);
|
||||||
|
|
||||||
|
if (contact) {
|
||||||
|
// Update existing contact (only fill NULL fields to preserve manual changes)
|
||||||
|
updateContact(contact.id, vcard, false);
|
||||||
|
updateContactMultiValues(contact.id, vcard);
|
||||||
|
return contact.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Check for existing contact by email or phone match
|
||||||
|
contact = findContactByEmailOrPhone(vcard.emails, vcard.phones);
|
||||||
|
|
||||||
|
if (contact) {
|
||||||
|
// Update existing contact and establish CardDAV link
|
||||||
|
updateContact(contact.id, vcard, true);
|
||||||
|
|
||||||
|
// Set CardDAV link
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE contacts
|
||||||
|
SET carddav_account_id = ?, carddav_uid = ?, carddav_addressbook_url = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(accountId, vcard.uid, addressbookUrl, contact.id);
|
||||||
|
|
||||||
|
updateContactMultiValues(contact.id, vcard);
|
||||||
|
return contact.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: No match - insert new contact
|
||||||
|
const result = db.get().prepare(`
|
||||||
|
INSERT INTO contacts (
|
||||||
|
name, category, organization, job_title, birthday, website,
|
||||||
|
photo, nickname, notes,
|
||||||
|
carddav_account_id, carddav_uid, carddav_addressbook_url
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
vcard.name,
|
||||||
|
vcard.categories || 'Sonstiges',
|
||||||
|
vcard.organization,
|
||||||
|
vcard.jobTitle,
|
||||||
|
vcard.birthday,
|
||||||
|
vcard.website,
|
||||||
|
vcard.photo,
|
||||||
|
vcard.nickname,
|
||||||
|
vcard.notes,
|
||||||
|
accountId,
|
||||||
|
vcard.uid,
|
||||||
|
addressbookUrl
|
||||||
|
);
|
||||||
|
|
||||||
|
const contactId = result.lastInsertRowid;
|
||||||
|
|
||||||
|
// Insert multi-value fields
|
||||||
|
insertContactMultiValues(contactId, vcard);
|
||||||
|
|
||||||
|
return contactId;
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Failed to parse and merge contact:', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find existing contact by email or phone match
|
||||||
|
* @param {Array<Object>} emails - Array of email objects
|
||||||
|
* @param {Array<Object>} phones - Array of phone objects
|
||||||
|
* @returns {Object|null} Contact object or null
|
||||||
|
*/
|
||||||
|
function findContactByEmailOrPhone(emails, phones) {
|
||||||
|
// Try email match first
|
||||||
|
for (const email of emails) {
|
||||||
|
const contact = db.get().prepare(`
|
||||||
|
SELECT c.* FROM contacts c
|
||||||
|
LEFT JOIN contact_emails ce ON c.id = ce.contact_id
|
||||||
|
WHERE c.email = ? OR ce.value = ?
|
||||||
|
LIMIT 1
|
||||||
|
`).get(email.value, email.value);
|
||||||
|
|
||||||
|
if (contact) return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try phone match
|
||||||
|
for (const phone of phones) {
|
||||||
|
const contact = db.get().prepare(`
|
||||||
|
SELECT c.* FROM contacts c
|
||||||
|
LEFT JOIN contact_phones cp ON c.id = cp.contact_id
|
||||||
|
WHERE c.phone = ? OR cp.value = ?
|
||||||
|
LIMIT 1
|
||||||
|
`).get(phone.value, phone.value);
|
||||||
|
|
||||||
|
if (contact) return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update existing contact with vCard data (only NULL fields)
|
||||||
|
* @param {number} contactId - Contact ID
|
||||||
|
* @param {Object} vcard - Parsed vCard object
|
||||||
|
* @param {boolean} fillAll - If true, update all fields; if false, only update NULL fields
|
||||||
|
*/
|
||||||
|
function updateContact(contactId, vcard, fillAll = false) {
|
||||||
|
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(contactId);
|
||||||
|
if (!contact) return;
|
||||||
|
|
||||||
|
const updates = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
// Helper to conditionally update field
|
||||||
|
const maybeUpdate = (field, dbColumn, vcardValue) => {
|
||||||
|
if (vcardValue !== null && vcardValue !== undefined) {
|
||||||
|
if (fillAll || contact[dbColumn] === null) {
|
||||||
|
updates.push(`${dbColumn} = ?`);
|
||||||
|
values.push(vcardValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
maybeUpdate('name', 'name', vcard.name);
|
||||||
|
maybeUpdate('organization', 'organization', vcard.organization);
|
||||||
|
maybeUpdate('jobTitle', 'job_title', vcard.jobTitle);
|
||||||
|
maybeUpdate('birthday', 'birthday', vcard.birthday);
|
||||||
|
maybeUpdate('website', 'website', vcard.website);
|
||||||
|
maybeUpdate('photo', 'photo', vcard.photo);
|
||||||
|
maybeUpdate('nickname', 'nickname', vcard.nickname);
|
||||||
|
maybeUpdate('notes', 'notes', vcard.notes);
|
||||||
|
maybeUpdate('categories', 'category', vcard.categories);
|
||||||
|
|
||||||
|
if (updates.length === 0) return;
|
||||||
|
|
||||||
|
values.push(contactId);
|
||||||
|
|
||||||
|
db.get().prepare(`
|
||||||
|
UPDATE contacts SET ${updates.join(', ')} WHERE id = ?
|
||||||
|
`).run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update contact multi-value fields (phones, emails, addresses)
|
||||||
|
* Preserves primary entries, replaces non-primary entries
|
||||||
|
* @param {number} contactId - Contact ID
|
||||||
|
* @param {Object} vcard - Parsed vCard object
|
||||||
|
*/
|
||||||
|
function updateContactMultiValues(contactId, vcard) {
|
||||||
|
const transaction = db.get().transaction(() => {
|
||||||
|
// Delete non-primary entries
|
||||||
|
db.get().prepare('DELETE FROM contact_phones WHERE contact_id = ? AND is_primary = 0').run(contactId);
|
||||||
|
db.get().prepare('DELETE FROM contact_emails WHERE contact_id = ? AND is_primary = 0').run(contactId);
|
||||||
|
db.get().prepare('DELETE FROM contact_addresses WHERE contact_id = ? AND is_primary = 0').run(contactId);
|
||||||
|
|
||||||
|
// Insert new entries from vCard
|
||||||
|
insertContactMultiValues(contactId, vcard);
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert contact multi-value fields (phones, emails, addresses)
|
||||||
|
* @param {number} contactId - Contact ID
|
||||||
|
* @param {Object} vcard - Parsed vCard object
|
||||||
|
*/
|
||||||
|
function insertContactMultiValues(contactId, vcard) {
|
||||||
|
// Check if primary entries exist
|
||||||
|
const hasPrimaryPhone = db.get().prepare(
|
||||||
|
'SELECT 1 FROM contact_phones WHERE contact_id = ? AND is_primary = 1'
|
||||||
|
).get(contactId);
|
||||||
|
|
||||||
|
const hasPrimaryEmail = db.get().prepare(
|
||||||
|
'SELECT 1 FROM contact_emails WHERE contact_id = ? AND is_primary = 1'
|
||||||
|
).get(contactId);
|
||||||
|
|
||||||
|
const hasPrimaryAddress = db.get().prepare(
|
||||||
|
'SELECT 1 FROM contact_addresses WHERE contact_id = ? AND is_primary = 1'
|
||||||
|
).get(contactId);
|
||||||
|
|
||||||
|
// Batch insert phones
|
||||||
|
if (vcard.phones && vcard.phones.length > 0) {
|
||||||
|
const placeholders = vcard.phones.map(() => '(?, ?, ?, ?)').join(', ');
|
||||||
|
const values = vcard.phones.flatMap((phone, i) => [
|
||||||
|
contactId,
|
||||||
|
phone.label || null,
|
||||||
|
phone.value,
|
||||||
|
(!hasPrimaryPhone && i === 0) ? 1 : 0
|
||||||
|
]);
|
||||||
|
|
||||||
|
db.get().prepare(`
|
||||||
|
INSERT INTO contact_phones (contact_id, label, value, is_primary)
|
||||||
|
VALUES ${placeholders}
|
||||||
|
`).run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert emails
|
||||||
|
if (vcard.emails && vcard.emails.length > 0) {
|
||||||
|
const placeholders = vcard.emails.map(() => '(?, ?, ?, ?)').join(', ');
|
||||||
|
const values = vcard.emails.flatMap((email, i) => [
|
||||||
|
contactId,
|
||||||
|
email.label || null,
|
||||||
|
email.value,
|
||||||
|
(!hasPrimaryEmail && i === 0) ? 1 : 0
|
||||||
|
]);
|
||||||
|
|
||||||
|
db.get().prepare(`
|
||||||
|
INSERT INTO contact_emails (contact_id, label, value, is_primary)
|
||||||
|
VALUES ${placeholders}
|
||||||
|
`).run(...values);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch insert addresses
|
||||||
|
if (vcard.addresses && vcard.addresses.length > 0) {
|
||||||
|
const placeholders = vcard.addresses.map(() => '(?, ?, ?, ?, ?, ?, ?, ?)').join(', ');
|
||||||
|
const values = vcard.addresses.flatMap((addr, i) => [
|
||||||
|
contactId,
|
||||||
|
addr.label || null,
|
||||||
|
addr.street,
|
||||||
|
addr.city,
|
||||||
|
addr.state,
|
||||||
|
addr.postalCode,
|
||||||
|
addr.country,
|
||||||
|
(!hasPrimaryAddress && i === 0) ? 1 : 0
|
||||||
|
]);
|
||||||
|
|
||||||
|
db.get().prepare(`
|
||||||
|
INSERT INTO contact_addresses (contact_id, label, street, city, state, postal_code, country, is_primary)
|
||||||
|
VALUES ${placeholders}
|
||||||
|
`).run(...values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Exports
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
export {
|
||||||
|
// Account Management
|
||||||
|
addAccount,
|
||||||
|
getAllAccounts,
|
||||||
|
deleteAccount,
|
||||||
|
testConnection,
|
||||||
|
|
||||||
|
// Addressbook Discovery
|
||||||
|
discoverAddressbooks,
|
||||||
|
toggleAddressbook,
|
||||||
|
|
||||||
|
// Contact Sync
|
||||||
|
syncAccount,
|
||||||
|
syncAddressbook,
|
||||||
|
parseAndMergeContact,
|
||||||
|
|
||||||
|
// Helpers (exported for testing)
|
||||||
|
parseVCard,
|
||||||
|
_mockTestConnection,
|
||||||
|
_mockSyncAccount,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------
|
||||||
|
// Test Mocking Support
|
||||||
|
// --------------------------------------------------------
|
||||||
|
|
||||||
|
let _testConnectionMock = null;
|
||||||
|
let _syncAccountMock = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONLY FOR TESTING: Mock testConnection for unit tests
|
||||||
|
* @param {Function|null} mockFn - Mock function or null to reset
|
||||||
|
*/
|
||||||
|
function _mockTestConnection(mockFn) {
|
||||||
|
_testConnectionMock = mockFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ONLY FOR TESTING: Mock syncAccount for unit tests
|
||||||
|
* @param {Function|null} mockFn - Mock function or null to reset
|
||||||
|
*/
|
||||||
|
function _mockSyncAccount(mockFn) {
|
||||||
|
_syncAccountMock = mockFn;
|
||||||
|
}
|
||||||
+2645
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user