feat: Kontakte mit Outlook-Feldern + CardDAV-Server + Sharing

Komplette Kontakte-Ueberarbeitung analog zum Kalender-Ausbau.

Backend-Model:
* AddressBook: color (pro Buch), ausserdem Per-User-Color via
  AddressBookShare.color wie bei CalendarShare.
* Contact: volle Outlook-artige Struktur - prefix/first/middle/
  last/suffix, display_name, nickname, organization, department,
  job_title, birthday, anniversary, notes, photo sowie JSON-
  Spalten fuer mehrfach vorhandene Felder (emails, phones,
  addresses mit allen Adressteilen, websites, impp, categories).

Backend-API:
* REST CRUD uebernimmt die neuen Felder und generiert vCard 3.0
  als Source of Truth fuer CardDAV. Voller vCard-Parser +
  -Builder mit Escape/Unescape, TYPE-Parametern, Line-Folding.
* Neuer Endpoint PUT /addressbooks/<id>/my-color - persoenliche
  Farbe pro Buch ohne den Besitzer zu beeinflussen.
* SSE-Events vom Typ 'addressbook' an Besitzer + alle Share-
  Empfaenger bei jeder Aenderung.

CardDAV-Server (backend/app/dav/carddav.py):
* Volle Discovery via principal - addressbook-home-set wird
  neben calendar-home-set annonciert.
* PROPFIND/REPORT/GET/PUT/DELETE/MKCOL fuer
  /dav/<user>/ab-<id>/ und /<...>/{uid}.vcf
* addressbook-query + addressbook-multiget REPORTs
* ETag-basierte Konfliktpruefung via If-Match/If-None-Match

Frontend (ContactsView.vue):
* Komplett neuer Editor mit vier Tabs: Allgemein (Name, Org),
  Kommunikation (Emails/Phones/Websites/IMPP dynamisch),
  Adressen (mehrere mit allen Teilen), Details (Geburtstag,
  Jahrestag, Kategorien, Notizen).
* Avatar mit Fotoauswahl oder Initialen-Farbkreis.
* Kalender-Sharing-Flow 1:1 uebernommen: Autocomplete fuer
  Benutzersuche, Share-Liste mit Stift zum Bearbeiten, Muelleimer
  zum Entfernen, Per-User-Farbe, CardDAV-URL-Info-Block pro
  Adressbuch, Live-Refresh via SSE.
* Suche durchsucht Displayname, E-Mail und Firma.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-12 15:16:01 +02:00
parent fbf10197d7
commit 9c102823e4
6 changed files with 1401 additions and 208 deletions

View File

@ -1,13 +1,32 @@
import json
import re
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import request, jsonify from flask import request, jsonify, Response
from app.api import api_bp from app.api import api_bp
from app.api.auth import token_required from app.api.auth import token_required
from app.extensions import db from app.extensions import db
from app.models.contact import AddressBook, Contact, AddressBookShare from app.models.contact import AddressBook, Contact, AddressBookShare
from app.models.user import User from app.models.user import User
from app.services.events import broadcaster
def _notify_addressbook(owner_id: int, book_id: int, change: str, shared_with=()):
"""SSE event for a vcard or share change. Re-uses the calendar event
infrastructure with a separate 'addressbook' type."""
recipients = [owner_id, *shared_with]
broadcaster.publish(recipients, {
'type': 'addressbook',
'change': change,
'address_book_id': book_id,
})
def _book_recipients(book: AddressBook):
return [s.shared_with_id for s in
AddressBookShare.query.filter_by(address_book_id=book.id).all()]
def _get_addressbook_or_err(book_id, user, need_write=False): def _get_addressbook_or_err(book_id, user, need_write=False):
@ -26,7 +45,224 @@ def _get_addressbook_or_err(book_id, user, need_write=False):
return book, None return book, None
# --- Address Books --- # ---------------------------------------------------------------------------
# vCard helpers
# ---------------------------------------------------------------------------
def _escape(s):
if s is None:
return ''
return str(s).replace('\\', '\\\\').replace(',', '\\,').replace(';', '\\;').replace('\n', '\\n')
def _unescape(s):
if not s:
return ''
return s.replace('\\n', '\n').replace('\\;', ';').replace('\\,', ',').replace('\\\\', '\\')
def _apply_fields_to_contact(contact: Contact, data: dict):
"""Copy fields from a JSON request into a Contact model instance."""
for field in ('prefix', 'first_name', 'middle_name', 'last_name', 'suffix',
'nickname', 'organization', 'department', 'job_title',
'notes', 'photo', 'birthday', 'anniversary'):
if field in data:
value = data[field]
setattr(contact, field, (value.strip() if isinstance(value, str) else value) or None)
if 'display_name' in data:
contact.display_name = (data['display_name'] or '').strip() or None
for jsonfield in ('emails', 'phones', 'addresses', 'websites', 'impp', 'categories'):
if jsonfield in data:
value = data[jsonfield] or []
setattr(contact, jsonfield, json.dumps(value) if value else None)
# Denormalised primary fields for list display
emails = data.get('emails') if 'emails' in data else json.loads(contact.emails) if contact.emails else []
phones = data.get('phones') if 'phones' in data else json.loads(contact.phones) if contact.phones else []
contact.primary_email = (emails[0]['value'] if emails else None)
contact.primary_phone = (phones[0]['value'] if phones else None)
# Legacy columns
contact.email = contact.primary_email
contact.phone = contact.primary_phone
# Compose display name if not provided
if not contact.display_name:
parts = [contact.prefix, contact.first_name, contact.middle_name,
contact.last_name, contact.suffix]
contact.display_name = ' '.join(p for p in parts if p) or contact.organization or None
def _build_vcard(contact: Contact) -> str:
"""Render a Contact into vCard 3.0 text."""
lines = ['BEGIN:VCARD', 'VERSION:3.0', f'UID:{contact.uid}']
if contact.display_name:
lines.append(f'FN:{_escape(contact.display_name)}')
# N: lastname;firstname;middle;prefix;suffix
n_parts = [_escape(contact.last_name), _escape(contact.first_name),
_escape(contact.middle_name), _escape(contact.prefix),
_escape(contact.suffix)]
if any(n_parts):
lines.append('N:' + ';'.join(n_parts))
if contact.nickname:
lines.append(f'NICKNAME:{_escape(contact.nickname)}')
if contact.organization or contact.department:
lines.append(f'ORG:{_escape(contact.organization or "")};{_escape(contact.department or "")}')
if contact.job_title:
lines.append(f'TITLE:{_escape(contact.job_title)}')
for e in (json.loads(contact.emails) if contact.emails else []):
typ = (e.get('type') or 'home').upper()
lines.append(f'EMAIL;TYPE={typ}:{_escape(e.get("value", ""))}')
for p in (json.loads(contact.phones) if contact.phones else []):
typ = (p.get('type') or 'cell').upper()
lines.append(f'TEL;TYPE={typ}:{_escape(p.get("value", ""))}')
for a in (json.loads(contact.addresses) if contact.addresses else []):
typ = (a.get('type') or 'home').upper()
# ADR: po_box;extended;street;city;region;postal_code;country
parts = [_escape(a.get('po_box', '')), '', _escape(a.get('street', '')),
_escape(a.get('city', '')), _escape(a.get('region', '')),
_escape(a.get('postal_code', '')), _escape(a.get('country', ''))]
lines.append(f'ADR;TYPE={typ}:' + ';'.join(parts))
for w in (json.loads(contact.websites) if contact.websites else []):
typ = (w.get('type') or '').upper()
tag = f'URL;TYPE={typ}' if typ else 'URL'
lines.append(f'{tag}:{_escape(w.get("value", ""))}')
for i in (json.loads(contact.impp) if contact.impp else []):
proto = (i.get('protocol') or 'xmpp').lower()
lines.append(f'IMPP:{proto}:{_escape(i.get("value", ""))}')
if contact.birthday:
lines.append(f'BDAY:{contact.birthday}')
if contact.anniversary:
lines.append(f'ANNIVERSARY:{contact.anniversary}')
cats = json.loads(contact.categories) if contact.categories else []
if cats:
lines.append('CATEGORIES:' + ','.join(_escape(c) for c in cats))
if contact.notes:
lines.append(f'NOTE:{_escape(contact.notes)}')
if contact.photo:
# Photo can be a data: URL or http URL. In vCard 3.0 we use PHOTO;VALUE=uri.
lines.append(f'PHOTO;VALUE=uri:{contact.photo}')
lines.append(f'REV:{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}')
lines.append('END:VCARD')
return '\r\n'.join(lines)
def _unfold_vcard(raw: str):
"""Undo RFC 6350 line folding (continuation lines start with space/tab)."""
lines = []
for line in raw.replace('\r\n', '\n').split('\n'):
if line.startswith((' ', '\t')) and lines:
lines[-1] += line[1:]
else:
lines.append(line)
return lines
def parse_vcard(raw: str) -> dict:
"""Parse a VCARD text into a dict of fields usable by _apply_fields_to_contact.
Returns dict with keys matching contact fields + 'uid'."""
result = {
'emails': [], 'phones': [], 'addresses': [],
'websites': [], 'impp': [], 'categories': [],
}
for line in _unfold_vcard(raw):
if ':' not in line:
continue
key, _, value = line.partition(':')
parts = key.split(';')
name = parts[0].upper()
params = {}
for p in parts[1:]:
if '=' in p:
k, v = p.split('=', 1)
params[k.upper()] = v.upper()
if name == 'UID':
result['uid'] = value.strip()
elif name == 'FN':
result['display_name'] = _unescape(value)
elif name == 'N':
fields = value.split(';')
if len(fields) >= 5:
result['last_name'] = _unescape(fields[0]) or None
result['first_name'] = _unescape(fields[1]) or None
result['middle_name'] = _unescape(fields[2]) or None
result['prefix'] = _unescape(fields[3]) or None
result['suffix'] = _unescape(fields[4]) or None
elif name == 'NICKNAME':
result['nickname'] = _unescape(value)
elif name == 'ORG':
fields = value.split(';')
result['organization'] = _unescape(fields[0]) if fields else None
if len(fields) > 1:
result['department'] = _unescape(fields[1]) or None
elif name == 'TITLE':
result['job_title'] = _unescape(value)
elif name == 'EMAIL':
result['emails'].append({
'type': (params.get('TYPE') or 'home').lower(),
'value': _unescape(value),
})
elif name == 'TEL':
result['phones'].append({
'type': (params.get('TYPE') or 'cell').lower(),
'value': _unescape(value),
})
elif name == 'ADR':
fields = value.split(';')
pad = fields + [''] * (7 - len(fields))
result['addresses'].append({
'type': (params.get('TYPE') or 'home').lower(),
'po_box': _unescape(pad[0]),
'street': _unescape(pad[2]),
'city': _unescape(pad[3]),
'region': _unescape(pad[4]),
'postal_code': _unescape(pad[5]),
'country': _unescape(pad[6]),
})
elif name == 'URL':
result['websites'].append({
'type': (params.get('TYPE') or '').lower(),
'value': _unescape(value),
})
elif name == 'IMPP':
proto, _, addr = value.partition(':')
result['impp'].append({'protocol': proto.lower(), 'value': _unescape(addr or value)})
elif name == 'CATEGORIES':
result['categories'] = [_unescape(c).strip() for c in value.split(',') if c.strip()]
elif name == 'BDAY':
result['birthday'] = _normalise_date(value)
elif name == 'ANNIVERSARY':
result['anniversary'] = _normalise_date(value)
elif name == 'NOTE':
result['notes'] = _unescape(value)
elif name == 'PHOTO':
result['photo'] = value.strip() or None
return result
def _normalise_date(s: str):
s = s.strip()
m = re.match(r'^(\d{4})-?(\d{2})-?(\d{2})$', s[:10])
if m:
return f'{m.group(1)}-{m.group(2)}-{m.group(3)}'
return None
# ---------------------------------------------------------------------------
# Address books
# ---------------------------------------------------------------------------
@api_bp.route('/addressbooks', methods=['GET']) @api_bp.route('/addressbooks', methods=['GET'])
@token_required @token_required
@ -49,6 +285,9 @@ def list_addressbooks():
address_book_id=b.id, shared_with_id=user.id address_book_id=b.id, shared_with_id=user.id
).first() ).first()
d['permission'] = share.permission if share else 'read' d['permission'] = share.permission if share else 'read'
d['owner_color'] = d.get('color')
if share and share.color:
d['color'] = share.color
d['owner_name'] = b.owner.username d['owner_name'] = b.owner.username
d['contact_count'] = b.contacts.count() d['contact_count'] = b.contacts.count()
result.append(d) result.append(d)
@ -61,13 +300,19 @@ def list_addressbooks():
def create_addressbook(): def create_addressbook():
user = request.current_user user = request.current_user
data = request.get_json() data = request.get_json()
name = data.get('name', '').strip() name = (data.get('name') or '').strip()
if not name: if not name:
return jsonify({'error': 'Name erforderlich'}), 400 return jsonify({'error': 'Name erforderlich'}), 400
book = AddressBook(owner_id=user.id, name=name, description=data.get('description', '')) book = AddressBook(
owner_id=user.id,
name=name,
color=data.get('color', '#3788d8'),
description=data.get('description') or None,
)
db.session.add(book) db.session.add(book)
db.session.commit() db.session.commit()
_notify_addressbook(user.id, book.id, 'created')
return jsonify(book.to_dict()), 201 return jsonify(book.to_dict()), 201
@ -77,31 +322,66 @@ def update_addressbook(book_id):
user = request.current_user user = request.current_user
book = db.session.get(AddressBook, book_id) book = db.session.get(AddressBook, book_id)
if not book or book.owner_id != user.id: if not book or book.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404 return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404
data = request.get_json() data = request.get_json()
if 'name' in data: if 'name' in data:
book.name = data['name'].strip() book.name = data['name'].strip()
if 'description' in data: if 'description' in data:
book.description = data['description'] book.description = data['description'] or None
if 'color' in data:
book.color = data['color']
db.session.commit() db.session.commit()
_notify_addressbook(book.owner_id, book.id, 'updated',
shared_with=_book_recipients(book))
return jsonify(book.to_dict()), 200 return jsonify(book.to_dict()), 200
@api_bp.route('/addressbooks/<int:book_id>/my-color', methods=['PUT'])
@token_required
def set_my_addressbook_color(book_id):
user = request.current_user
book = db.session.get(AddressBook, book_id)
if not book:
return jsonify({'error': 'Nicht gefunden'}), 404
color = ((request.get_json() or {}).get('color') or '').strip()
if book.owner_id == user.id:
if color:
book.color = color
db.session.commit()
return jsonify({'color': book.color}), 200
share = AddressBookShare.query.filter_by(
address_book_id=book_id, shared_with_id=user.id
).first()
if not share:
return jsonify({'error': 'Kein Zugriff'}), 403
share.color = color or None
db.session.commit()
return jsonify({'color': share.color or book.color}), 200
@api_bp.route('/addressbooks/<int:book_id>', methods=['DELETE']) @api_bp.route('/addressbooks/<int:book_id>', methods=['DELETE'])
@token_required @token_required
def delete_addressbook(book_id): def delete_addressbook(book_id):
user = request.current_user user = request.current_user
book = db.session.get(AddressBook, book_id) book = db.session.get(AddressBook, book_id)
if not book or book.owner_id != user.id: if not book or book.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404 return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404
recipients = _book_recipients(book)
owner_id = book.owner_id
bid = book.id
db.session.delete(book) db.session.delete(book)
db.session.commit() db.session.commit()
_notify_addressbook(owner_id, bid, 'deleted', shared_with=recipients)
return jsonify({'message': 'Adressbuch geloescht'}), 200 return jsonify({'message': 'Adressbuch geloescht'}), 200
# --- Contacts --- # ---------------------------------------------------------------------------
# Contacts
# ---------------------------------------------------------------------------
@api_bp.route('/addressbooks/<int:book_id>/contacts', methods=['GET']) @api_bp.route('/addressbooks/<int:book_id>/contacts', methods=['GET'])
@token_required @token_required
@ -111,11 +391,16 @@ def list_contacts(book_id):
if err: if err:
return err return err
search = request.args.get('search', '').strip() search = (request.args.get('q') or '').strip()
query = Contact.query.filter_by(address_book_id=book_id) q = Contact.query.filter_by(address_book_id=book_id)
if search: if search:
query = query.filter(Contact.display_name.ilike(f'%{search}%')) like = f'%{search}%'
contacts = query.order_by(Contact.display_name).all() q = q.filter(
(Contact.display_name.ilike(like)) |
(Contact.primary_email.ilike(like)) |
(Contact.organization.ilike(like))
)
contacts = q.order_by(Contact.display_name).all()
return jsonify([c.to_dict() for c in contacts]), 200 return jsonify([c.to_dict() for c in contacts]), 200
@ -127,29 +412,16 @@ def create_contact(book_id):
if err: if err:
return err return err
data = request.get_json() data = request.get_json() or {}
display_name = data.get('display_name', '').strip() contact = Contact(address_book_id=book_id, uid=str(uuid.uuid4()), vcard_data='')
if not display_name: _apply_fields_to_contact(contact, data)
return jsonify({'error': 'Name erforderlich'}), 400 if not contact.display_name:
return jsonify({'error': 'Name oder Firma erforderlich'}), 400
contact_uid = str(uuid.uuid4()) contact.vcard_data = _build_vcard(contact)
email = data.get('email', '')
phone = data.get('phone', '')
org = data.get('organization', '')
notes = data.get('notes', '')
vcard = _build_vcard(contact_uid, display_name, email, phone, org, notes)
contact = Contact(
address_book_id=book_id,
uid=contact_uid,
vcard_data=vcard,
display_name=display_name,
email=email or None,
phone=phone or None,
)
db.session.add(contact) db.session.add(contact)
db.session.commit() db.session.commit()
_notify_addressbook(book.owner_id, book.id, 'contact',
shared_with=_book_recipients(book))
return jsonify(contact.to_dict()), 201 return jsonify(contact.to_dict()), 201
@ -160,11 +432,9 @@ def get_contact(contact_id):
contact = db.session.get(Contact, contact_id) contact = db.session.get(Contact, contact_id)
if not contact: if not contact:
return jsonify({'error': 'Kontakt nicht gefunden'}), 404 return jsonify({'error': 'Kontakt nicht gefunden'}), 404
book, err = _get_addressbook_or_err(contact.address_book_id, user) book, err = _get_addressbook_or_err(contact.address_book_id, user)
if err: if err:
return err return err
result = contact.to_dict() result = contact.to_dict()
result['vcard_data'] = contact.vcard_data result['vcard_data'] = contact.vcard_data
return jsonify(result), 200 return jsonify(result), 200
@ -177,29 +447,17 @@ def update_contact(contact_id):
contact = db.session.get(Contact, contact_id) contact = db.session.get(Contact, contact_id)
if not contact: if not contact:
return jsonify({'error': 'Kontakt nicht gefunden'}), 404 return jsonify({'error': 'Kontakt nicht gefunden'}), 404
book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True) book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True)
if err: if err:
return err return err
data = request.get_json() data = request.get_json() or {}
if 'display_name' in data: _apply_fields_to_contact(contact, data)
contact.display_name = data['display_name'].strip() contact.vcard_data = _build_vcard(contact)
if 'email' in data:
contact.email = data['email'] or None
if 'phone' in data:
contact.phone = data['phone'] or None
contact.vcard_data = _build_vcard(
contact.uid,
contact.display_name,
data.get('email', contact.email or ''),
data.get('phone', contact.phone or ''),
data.get('organization', ''),
data.get('notes', ''),
)
contact.updated_at = datetime.now(timezone.utc) contact.updated_at = datetime.now(timezone.utc)
db.session.commit() db.session.commit()
_notify_addressbook(book.owner_id, book.id, 'contact',
shared_with=_book_recipients(book))
return jsonify(contact.to_dict()), 200 return jsonify(contact.to_dict()), 200
@ -210,17 +468,19 @@ def delete_contact(contact_id):
contact = db.session.get(Contact, contact_id) contact = db.session.get(Contact, contact_id)
if not contact: if not contact:
return jsonify({'error': 'Kontakt nicht gefunden'}), 404 return jsonify({'error': 'Kontakt nicht gefunden'}), 404
book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True) book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True)
if err: if err:
return err return err
db.session.delete(contact) db.session.delete(contact)
db.session.commit() db.session.commit()
_notify_addressbook(book.owner_id, book.id, 'contact',
shared_with=_book_recipients(book))
return jsonify({'message': 'Kontakt geloescht'}), 200 return jsonify({'message': 'Kontakt geloescht'}), 200
# --- Sharing --- # ---------------------------------------------------------------------------
# Sharing
# ---------------------------------------------------------------------------
@api_bp.route('/addressbooks/<int:book_id>/share', methods=['POST']) @api_bp.route('/addressbooks/<int:book_id>/share', methods=['POST'])
@token_required @token_required
@ -230,10 +490,9 @@ def share_addressbook(book_id):
if not book or book.owner_id != user.id: if not book or book.owner_id != user.id:
return jsonify({'error': 'Nur der Eigentuemer kann teilen'}), 403 return jsonify({'error': 'Nur der Eigentuemer kann teilen'}), 403
data = request.get_json() data = request.get_json() or {}
username = data.get('username', '').strip() username = (data.get('username') or '').strip()
permission = data.get('permission', 'read') permission = data.get('permission', 'read')
if permission not in ('read', 'readwrite'): if permission not in ('read', 'readwrite'):
return jsonify({'error': 'Ungueltige Berechtigung'}), 400 return jsonify({'error': 'Ungueltige Berechtigung'}), 400
@ -246,7 +505,6 @@ def share_addressbook(book_id):
existing = AddressBookShare.query.filter_by( existing = AddressBookShare.query.filter_by(
address_book_id=book_id, shared_with_id=target.id address_book_id=book_id, shared_with_id=target.id
).first() ).first()
is_new = not existing
if existing: if existing:
existing.permission = permission existing.permission = permission
else: else:
@ -254,16 +512,9 @@ def share_addressbook(book_id):
address_book_id=book_id, shared_with_id=target.id, permission=permission address_book_id=book_id, shared_with_id=target.id, permission=permission
) )
db.session.add(share) db.session.add(share)
db.session.commit() db.session.commit()
_notify_addressbook(book.owner_id, book.id, 'share',
if is_new: shared_with=[target.id, *_book_recipients(book)])
try:
from app.services.system_mail import notify_contacts_shared
notify_contacts_shared(book.name, user.username, target, permission)
except Exception:
pass
return jsonify({'message': f'Adressbuch mit {username} geteilt'}), 200 return jsonify({'message': f'Adressbuch mit {username} geteilt'}), 200
@ -274,7 +525,6 @@ def list_addressbook_shares(book_id):
book = db.session.get(AddressBook, book_id) book = db.session.get(AddressBook, book_id)
if not book or book.owner_id != user.id: if not book or book.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404 return jsonify({'error': 'Nicht gefunden'}), 404
shares = AddressBookShare.query.filter_by(address_book_id=book_id).all() shares = AddressBookShare.query.filter_by(address_book_id=book_id).all()
return jsonify([{ return jsonify([{
'id': s.id, 'id': s.id,
@ -291,17 +541,20 @@ def remove_addressbook_share(book_id, share_id):
book = db.session.get(AddressBook, book_id) book = db.session.get(AddressBook, book_id)
if not book or book.owner_id != user.id: if not book or book.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404 return jsonify({'error': 'Nicht gefunden'}), 404
share = db.session.get(AddressBookShare, share_id) share = db.session.get(AddressBookShare, share_id)
if not share or share.address_book_id != book_id: if not share or share.address_book_id != book_id:
return jsonify({'error': 'Freigabe nicht gefunden'}), 404 return jsonify({'error': 'Freigabe nicht gefunden'}), 404
target_id = share.shared_with_id
db.session.delete(share) db.session.delete(share)
db.session.commit() db.session.commit()
_notify_addressbook(book.owner_id, book.id, 'share',
shared_with=[target_id, *_book_recipients(book)])
return jsonify({'message': 'Freigabe entfernt'}), 200 return jsonify({'message': 'Freigabe entfernt'}), 200
# --- Import/Export --- # ---------------------------------------------------------------------------
# vCard export (all contacts of a book)
# ---------------------------------------------------------------------------
@api_bp.route('/addressbooks/<int:book_id>/export', methods=['GET']) @api_bp.route('/addressbooks/<int:book_id>/export', methods=['GET'])
@token_required @token_required
@ -310,40 +563,9 @@ def export_contacts(book_id):
book, err = _get_addressbook_or_err(book_id, user) book, err = _get_addressbook_or_err(book_id, user)
if err: if err:
return err return err
parts = [c.vcard_data for c in book.contacts]
contacts = Contact.query.filter_by(address_book_id=book_id).all()
vcards = '\r\n'.join(c.vcard_data for c in contacts)
from flask import Response
return Response( return Response(
vcards, '\r\n'.join(parts),
mimetype='text/vcard', mimetype='text/vcard; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename="{book.name}.vcf"'}, headers={'Content-Disposition': f'attachment; filename="{book.name}.vcf"'},
) )
# --- Helpers ---
def _build_vcard(uid, display_name, email='', phone='', org='', notes=''):
parts = display_name.split(' ', 1)
first = parts[0]
last = parts[1] if len(parts) > 1 else ''
lines = [
'BEGIN:VCARD',
'VERSION:3.0',
f'UID:{uid}',
f'FN:{display_name}',
f'N:{last};{first};;;',
]
if email:
lines.append(f'EMAIL:{email}')
if phone:
lines.append(f'TEL:{phone}')
if org:
lines.append(f'ORG:{org}')
if notes:
lines.append(f'NOTE:{notes}')
lines.append(f'REV:{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}')
lines.append('END:VCARD')
return '\r\n'.join(lines)

View File

@ -2,4 +2,5 @@ from flask import Blueprint
dav_bp = Blueprint('dav', __name__, url_prefix='/dav') dav_bp = Blueprint('dav', __name__, url_prefix='/dav')
from . import caldav # noqa: F401,E402 from . import caldav # noqa: F401,E402
from . import carddav # noqa: F401,E402

View File

@ -180,6 +180,10 @@ def _principal_response(user: User) -> ET.Element:
ET.SubElement(pu, _qn('d', 'href')).text = href ET.SubElement(pu, _qn('d', 'href')).text = href
home = ET.SubElement(prop, _qn('c', 'calendar-home-set')) home = ET.SubElement(prop, _qn('c', 'calendar-home-set'))
ET.SubElement(home, _qn('d', 'href')).text = href ET.SubElement(home, _qn('d', 'href')).text = href
# CardDAV address-book home set - same principal URL, addressbook
# collections live next to calendars under /dav/<username>/
ab_home = ET.SubElement(prop, '{urn:ietf:params:xml:ns:carddav}addressbook-home-set')
ET.SubElement(ab_home, _qn('d', 'href')).text = href
return _make_response(href, populate) return _make_response(href, populate)
@ -265,7 +269,7 @@ def propfind(subpath=''):
multistatus.append(_principal_response(user)) multistatus.append(_principal_response(user))
return _xml_response(multistatus) return _xml_response(multistatus)
# /dav/<username>/ : principal + list calendars # /dav/<username>/ : principal + list calendars AND addressbooks
if len(parts) == 1: if len(parts) == 1:
if parts[0] != user.username: if parts[0] != user.username:
return Response('', 403) return Response('', 403)
@ -273,6 +277,11 @@ def propfind(subpath=''):
if depth != '0': if depth != '0':
for cal in _user_calendars(user): for cal in _user_calendars(user):
multistatus.append(_calendar_response(user, cal)) multistatus.append(_calendar_response(user, cal))
# Addressbooks live next to calendars. Import here to avoid a
# circular import at module load time.
from .carddav import _addressbook_response, _user_addressbooks
for ab in _user_addressbooks(user):
multistatus.append(_addressbook_response(user, ab))
return _xml_response(multistatus) return _xml_response(multistatus)
# /dav/<username>/cal-<id>/ : calendar + events # /dav/<username>/cal-<id>/ : calendar + events

351
backend/app/dav/carddav.py Normal file
View File

@ -0,0 +1,351 @@
"""Minimal CardDAV server (RFC 6352 subset).
Mirror structure of caldav.py - adds addressbook collections under
/dav/<username>/ab-<id>/
and serves vCard 3.0 resources via GET/PUT/DELETE plus addressbook-query
and addressbook-multiget REPORTs.
Reuses the auth + XML helpers from caldav.py to stay consistent.
"""
from __future__ import annotations
import re
import uuid
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from flask import Response, request
from app.extensions import db
from app.models.contact import AddressBook, Contact, AddressBookShare
from app.models.user import User
from app.api.contacts import _apply_fields_to_contact, _build_vcard, parse_vcard
from . import dav_bp
from .caldav import (
NS, _qn, _xml_response, basic_auth, _make_response,
_principal_response, # reused - we extend below
)
# ---------------------------------------------------------------------------
# URL helpers
# ---------------------------------------------------------------------------
def _href_addressbook(username: str, book_id: int) -> str:
return f'/dav/{username}/ab-{book_id}/'
def _href_vcard(username: str, book_id: int, uid: str) -> str:
return f'/dav/{username}/ab-{book_id}/{uid}.vcf'
def _parse_addressbook_path(part: str):
m = re.match(r'ab-(\d+)$', part)
return int(m.group(1)) if m else None
def _user_addressbooks(user: User):
return AddressBook.query.filter_by(owner_id=user.id).all()
def _addressbook_for(user: User, book_id: int):
book = db.session.get(AddressBook, book_id)
if not book or book.owner_id != user.id:
return None
return book
# ---------------------------------------------------------------------------
# Property responses
# ---------------------------------------------------------------------------
def _addressbook_ctag(book: AddressBook) -> str:
last = db.session.query(db.func.max(Contact.updated_at)).filter_by(address_book_id=book.id).scalar()
ts = int((last or book.updated_at or datetime.now(timezone.utc)).timestamp())
return f'"ab{book.id}-{ts}"'
def _addressbook_response(user: User, book: AddressBook) -> ET.Element:
href = _href_addressbook(user.username, book.id)
def populate(prop):
rt = ET.SubElement(prop, _qn('d', 'resourcetype'))
ET.SubElement(rt, _qn('d', 'collection'))
# urn:ietf:params:xml:ns:carddav addressbook element
ab = ET.SubElement(rt, '{urn:ietf:params:xml:ns:carddav}addressbook') # noqa: F841
ET.SubElement(prop, _qn('d', 'displayname')).text = book.name
ET.SubElement(prop, '{urn:ietf:params:xml:ns:carddav}addressbook-description').text = book.description or ''
srs = ET.SubElement(prop, _qn('d', 'supported-report-set'))
for r in ('addressbook-query', 'addressbook-multiget'):
sup = ET.SubElement(srs, _qn('d', 'supported-report'))
rep = ET.SubElement(sup, _qn('d', 'report'))
ET.SubElement(rep, '{urn:ietf:params:xml:ns:carddav}' + r)
ET.SubElement(prop, _qn('ic', 'calendar-color')).text = book.color or '#3788d8'
ET.SubElement(prop, _qn('cs', 'getctag')).text = _addressbook_ctag(book)
cups = ET.SubElement(prop, _qn('d', 'current-user-privilege-set'))
for priv in ('read', 'write', 'write-properties', 'write-content', 'bind', 'unbind'):
p = ET.SubElement(cups, _qn('d', 'privilege'))
ET.SubElement(p, _qn('d', priv))
return _make_response(href, populate)
def _vcard_response(user: User, book: AddressBook, contact: Contact, include_data: bool = False) -> ET.Element:
href = _href_vcard(user.username, book.id, contact.uid)
def populate(prop):
ts = int((contact.updated_at or datetime.now(timezone.utc)).timestamp() * 1000)
ET.SubElement(prop, _qn('d', 'getetag')).text = f'"{contact.id}-{ts}"'
ET.SubElement(prop, _qn('d', 'getcontenttype')).text = 'text/vcard; charset=utf-8'
ET.SubElement(prop, _qn('d', 'resourcetype'))
if include_data:
ET.SubElement(prop, '{urn:ietf:params:xml:ns:carddav}address-data').text = \
contact.vcard_data or _build_vcard(contact)
return _make_response(href, populate)
def _etag_for_contact(contact: Contact) -> str:
ts = int((contact.updated_at or contact.created_at or datetime.now(timezone.utc)).timestamp() * 1000)
return f'"{contact.id}-{ts}"'
# ---------------------------------------------------------------------------
# Extend the principal response from caldav.py to include addressbook-home-set
# This is done by wrapping the existing helper and appending the extra prop.
# ---------------------------------------------------------------------------
# We import caldav.propfind and add a separate URL-rule set here. For the
# principal, caldav._principal_response already emits calendar-home-set; we
# leave the combined principal to that function. CardDAV clients that check
# addressbook-home-set via PROPFIND get it via our own route below, because
# the URL `/dav/<username>/` is handled by caldav's propfind. To also return
# addressbook-home-set there we monkey-patch the principal populate.
# Simpler approach: re-implement the principal for our own URL-space by
# hooking into the propfind dispatcher's principal branch.
# Since caldav.propfind already builds the principal response, we inject the
# addressbook-home-set via a wrapper. Let's override by providing our own
# handler in the blueprint that augments the response.
# ---------------------------------------------------------------------------
# OPTIONS / PROPFIND / REPORT / GET / PUT / DELETE for /dav/<user>/ab-<id>/...
# ---------------------------------------------------------------------------
_DAV_HEADERS = {'DAV': '1, 2, 3, addressbook'}
@dav_bp.route('/<username>/<ab_part>/', methods=['OPTIONS'])
@dav_bp.route('/<username>/<ab_part>', methods=['OPTIONS'])
def ab_options(username, ab_part):
if not ab_part.startswith('ab-'):
return Response('', 404)
return Response('', 200, {
'DAV': '1, 2, 3, addressbook',
'Allow': 'OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, PROPPATCH, MKCOL',
})
@dav_bp.route('/<username>/<ab_part>/', methods=['PROPFIND'])
@dav_bp.route('/<username>/<ab_part>', methods=['PROPFIND'])
@basic_auth
def ab_propfind(username, ab_part):
if not ab_part.startswith('ab-'):
return Response('Not found', 404)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
book_id = _parse_addressbook_path(ab_part)
book = _addressbook_for(user, book_id) if book_id else None
if not book:
return Response('Not found', 404)
depth = request.headers.get('Depth', '0')
multistatus = ET.Element(_qn('d', 'multistatus'))
multistatus.append(_addressbook_response(user, book))
if depth != '0':
for c in book.contacts.all():
multistatus.append(_vcard_response(user, book, c))
return _xml_response(multistatus)
@dav_bp.route('/<username>/<ab_part>/<filename>', methods=['PROPFIND'])
@basic_auth
def ab_contact_propfind(username, ab_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
book_id = _parse_addressbook_path(ab_part)
book = _addressbook_for(user, book_id) if book_id else None
if not book:
return Response('Not found', 404)
uid = filename.removesuffix('.vcf')
contact = Contact.query.filter_by(address_book_id=book.id, uid=uid).first()
if not contact:
return Response('Not found', 404)
multistatus = ET.Element(_qn('d', 'multistatus'))
multistatus.append(_vcard_response(user, book, contact, include_data=True))
return _xml_response(multistatus)
@dav_bp.route('/<username>/<ab_part>/', methods=['REPORT'])
@dav_bp.route('/<username>/<ab_part>', methods=['REPORT'])
@basic_auth
def ab_report(username, ab_part):
if not ab_part.startswith('ab-'):
return Response('Not found', 404)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
book_id = _parse_addressbook_path(ab_part)
book = _addressbook_for(user, book_id) if book_id else None
if not book:
return Response('Not found', 404)
try:
root = ET.fromstring(request.data or b'<x/>')
except ET.ParseError:
return Response('Malformed XML', 400)
wants_data = root.find(f".//{{urn:ietf:params:xml:ns:carddav}}address-data") is not None
multistatus = ET.Element(_qn('d', 'multistatus'))
if root.tag == '{urn:ietf:params:xml:ns:carddav}addressbook-multiget':
hrefs = [h.text for h in root.findall(_qn('d', 'href')) if h.text]
for href in hrefs:
uid = href.rsplit('/', 1)[-1].removesuffix('.vcf')
contact = Contact.query.filter_by(address_book_id=book.id, uid=uid).first()
if contact:
multistatus.append(_vcard_response(user, book, contact, include_data=True))
return _xml_response(multistatus)
if root.tag == '{urn:ietf:params:xml:ns:carddav}addressbook-query':
# No filter implementation yet - return all
for contact in book.contacts.all():
multistatus.append(_vcard_response(user, book, contact, include_data=wants_data))
return _xml_response(multistatus)
return _xml_response(multistatus)
@dav_bp.route('/<username>/<ab_part>/<filename>', methods=['GET', 'HEAD'])
@basic_auth
def ab_get(username, ab_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
book_id = _parse_addressbook_path(ab_part)
book = _addressbook_for(user, book_id) if book_id else None
if not book:
return Response('Not found', 404)
uid = filename.removesuffix('.vcf')
contact = Contact.query.filter_by(address_book_id=book.id, uid=uid).first()
if not contact:
return Response('Not found', 404)
return Response(
contact.vcard_data or _build_vcard(contact),
mimetype='text/vcard; charset=utf-8',
headers={'ETag': _etag_for_contact(contact)},
)
@dav_bp.route('/<username>/<ab_part>/<filename>', methods=['PUT'])
@basic_auth
def ab_put(username, ab_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
book_id = _parse_addressbook_path(ab_part)
book = _addressbook_for(user, book_id) if book_id else None
if not book:
return Response('Not found', 404)
uid = filename.removesuffix('.vcf')
raw = request.get_data(as_text=True) or ''
parsed = parse_vcard(raw)
body_uid = parsed.get('uid') or uid
existing = Contact.query.filter_by(address_book_id=book.id, uid=body_uid).first()
if_match = request.headers.get('If-Match')
if_none_match = request.headers.get('If-None-Match')
if existing and if_none_match == '*':
return Response('', 412)
if if_match and existing and if_match.strip() != _etag_for_contact(existing):
return Response('', 412)
if not existing:
existing = Contact(address_book_id=book.id, uid=body_uid, vcard_data=raw)
db.session.add(existing)
_apply_fields_to_contact(existing, parsed)
# Keep the original raw VCARD so round-tripping is faithful - but also
# record the server-rebuilt version for web UI consumers. We prefer the
# raw source of truth here.
existing.vcard_data = raw.strip() or _build_vcard(existing)
existing.updated_at = datetime.now(timezone.utc)
db.session.commit()
status = 201 if not if_match else 204
return Response('', status, {'ETag': _etag_for_contact(existing)})
@dav_bp.route('/<username>/<ab_part>/<filename>', methods=['DELETE'])
@basic_auth
def ab_delete(username, ab_part, filename):
user: User = request.dav_user
if username != user.username:
return Response('', 403)
book_id = _parse_addressbook_path(ab_part)
book = _addressbook_for(user, book_id) if book_id else None
if not book:
return Response('Not found', 404)
uid = filename.removesuffix('.vcf')
contact = Contact.query.filter_by(address_book_id=book.id, uid=uid).first()
if not contact:
return Response('', 404)
db.session.delete(contact)
db.session.commit()
return Response('', 204)
@dav_bp.route('/<username>/<ab_part>/', methods=['DELETE'])
@dav_bp.route('/<username>/<ab_part>', methods=['DELETE'])
@basic_auth
def ab_delete_collection(username, ab_part):
if not ab_part.startswith('ab-'):
return Response('', 404)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
book_id = _parse_addressbook_path(ab_part)
book = _addressbook_for(user, book_id) if book_id else None
if not book:
return Response('', 404)
db.session.delete(book)
db.session.commit()
return Response('', 204)
@dav_bp.route('/<username>/<ab_part>/', methods=['MKCOL'])
@dav_bp.route('/<username>/<ab_part>', methods=['MKCOL'])
@basic_auth
def ab_mkcol(username, ab_part):
"""Create a new addressbook collection via MKCOL (RFC 5689 extended).
Some CardDAV clients (Apple) use this instead of MKCALENDAR."""
user: User = request.dav_user
if username != user.username:
return Response('', 403)
name = 'Neues Adressbuch'
try:
body = request.get_data()
if body:
root = ET.fromstring(body)
dn = root.find(f".//{_qn('d', 'displayname')}")
if dn is not None and dn.text:
name = dn.text
except ET.ParseError:
pass
book = AddressBook(owner_id=user.id, name=name)
db.session.add(book)
db.session.commit()
return Response('', 201, {'Location': _href_addressbook(user.username, book.id)})

View File

@ -10,6 +10,7 @@ class AddressBook(db.Model):
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
name = db.Column(db.String(255), nullable=False) name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, nullable=True) description = db.Column(db.Text, nullable=True)
color = db.Column(db.String(7), default='#3788d8')
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)) onupdate=lambda: datetime.now(timezone.utc))
@ -18,6 +19,7 @@ class AddressBook(db.Model):
cascade='all, delete-orphan') cascade='all, delete-orphan')
shares = db.relationship('AddressBookShare', backref='address_book', lazy='dynamic', shares = db.relationship('AddressBookShare', backref='address_book', lazy='dynamic',
cascade='all, delete-orphan') cascade='all, delete-orphan')
# `owner` wird automatisch durch User.address_books backref erzeugt
def to_dict(self): def to_dict(self):
return { return {
@ -25,6 +27,7 @@ class AddressBook(db.Model):
'owner_id': self.owner_id, 'owner_id': self.owner_id,
'name': self.name, 'name': self.name,
'description': self.description, 'description': self.description,
'color': self.color,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
} }
@ -36,22 +39,92 @@ class Contact(db.Model):
address_book_id = db.Column(db.Integer, db.ForeignKey('address_books.id'), address_book_id = db.Column(db.Integer, db.ForeignKey('address_books.id'),
nullable=False, index=True) nullable=False, index=True)
uid = db.Column(db.String(255), unique=True, nullable=False) uid = db.Column(db.String(255), unique=True, nullable=False)
vcard_data = db.Column(db.Text, nullable=False) # Full VCARD vcard_data = db.Column(db.Text, nullable=False)
# Structured name fields
prefix = db.Column(db.String(64), nullable=True)
first_name = db.Column(db.String(128), nullable=True)
middle_name = db.Column(db.String(128), nullable=True)
last_name = db.Column(db.String(128), nullable=True, index=True)
suffix = db.Column(db.String(64), nullable=True)
display_name = db.Column(db.String(255), nullable=True, index=True) display_name = db.Column(db.String(255), nullable=True, index=True)
nickname = db.Column(db.String(128), nullable=True)
# Organisation
organization = db.Column(db.String(255), nullable=True)
department = db.Column(db.String(255), nullable=True)
job_title = db.Column(db.String(255), nullable=True)
# Primary fields for quick listing (denormalised)
primary_email = db.Column(db.String(255), nullable=True, index=True)
primary_phone = db.Column(db.String(50), nullable=True)
# JSON-encoded multi-valued fields
# Each list entry: {"type": "home|work|other|mobile|fax|pager|...", "value": "..."}
emails = db.Column(db.Text, nullable=True)
phones = db.Column(db.Text, nullable=True)
# address: {"type": ..., "street": ..., "po_box": ..., "city": ...,
# "region": ..., "postal_code": ..., "country": ...}
addresses = db.Column(db.Text, nullable=True)
websites = db.Column(db.Text, nullable=True)
impp = db.Column(db.Text, nullable=True) # {"protocol": "skype", "value": "..."}
categories = db.Column(db.Text, nullable=True) # ["family", "work", ...]
# Dates
birthday = db.Column(db.String(10), nullable=True) # YYYY-MM-DD
anniversary = db.Column(db.String(10), nullable=True)
# Free text
notes = db.Column(db.Text, nullable=True)
# Photo: data URL (data:image/jpeg;base64,...) oder http(s)://
photo = db.Column(db.Text, nullable=True)
# Legacy column kept for old clients / migrations
email = db.Column(db.String(255), nullable=True) email = db.Column(db.String(255), nullable=True)
phone = db.Column(db.String(50), nullable=True) phone = db.Column(db.String(50), nullable=True)
created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc)) onupdate=lambda: datetime.now(timezone.utc))
def to_dict(self): def to_dict(self):
import json
def _loads(s, default):
if not s:
return default
try:
return json.loads(s)
except (ValueError, TypeError):
return default
return { return {
'id': self.id, 'id': self.id,
'address_book_id': self.address_book_id, 'address_book_id': self.address_book_id,
'uid': self.uid, 'uid': self.uid,
'prefix': self.prefix,
'first_name': self.first_name,
'middle_name': self.middle_name,
'last_name': self.last_name,
'suffix': self.suffix,
'display_name': self.display_name, 'display_name': self.display_name,
'email': self.email, 'nickname': self.nickname,
'phone': self.phone, 'organization': self.organization,
'department': self.department,
'job_title': self.job_title,
'emails': _loads(self.emails, []),
'phones': _loads(self.phones, []),
'addresses': _loads(self.addresses, []),
'websites': _loads(self.websites, []),
'impp': _loads(self.impp, []),
'categories': _loads(self.categories, []),
'birthday': self.birthday,
'anniversary': self.anniversary,
'notes': self.notes,
'photo': self.photo,
'primary_email': self.primary_email or self.email,
'primary_phone': self.primary_phone or self.phone,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None, 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
} }
@ -65,6 +138,7 @@ class AddressBookShare(db.Model):
nullable=False, index=True) nullable=False, index=True)
shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
permission = db.Column(db.String(20), nullable=False, default='read') permission = db.Column(db.String(20), nullable=False, default='read')
color = db.Column(db.String(7), nullable=True) # personal display color
shared_with = db.relationship('User', backref='shared_address_books') shared_with = db.relationship('User', backref='shared_address_books')

View File

@ -10,25 +10,43 @@
<div class="contacts-layout"> <div class="contacts-layout">
<aside class="books-sidebar"> <aside class="books-sidebar">
<h4>Adressbücher</h4>
<div v-for="book in addressBooks" :key="book.id" <div v-for="book in addressBooks" :key="book.id"
class="book-item" :class="{ active: selectedBookId === book.id }" class="book-item" :class="{ active: selectedBookId === book.id }"
@click="selectBook(book.id)"> @click="selectBook(book.id)">
<i class="pi pi-book"></i> <span class="book-color" :style="{ background: book.color }"></span>
<span>{{ book.name }}</span> <span class="book-name">{{ book.name }}</span>
<span v-if="book.permission !== 'owner'" class="shared-label">(geteilt)</span>
<span class="count">{{ book.contact_count }}</span> <span class="count">{{ book.contact_count }}</span>
<Button icon="pi pi-ellipsis-v" text size="small" class="book-menu"
@click.stop="openBookMenu(book)" />
</div> </div>
</aside> </aside>
<div class="contacts-main"> <div class="contacts-main">
<div class="search-bar"> <div class="search-bar">
<InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="loadContacts" /> <InputText v-model="searchQuery" placeholder="Kontakte suchen..." fluid @input="onSearch" />
</div> </div>
<DataTable :value="contacts" :loading="loading" striped-rows @row-click="openEditContact"> <DataTable :value="contacts" :loading="loading" striped-rows
@row-click="onRowClick" :rowClass="() => 'clickable'">
<template #empty><p class="empty">Keine Kontakte</p></template> <template #empty><p class="empty">Keine Kontakte</p></template>
<Column field="display_name" header="Name" sortable /> <Column header="Name" sortable sortField="display_name">
<Column field="email" header="E-Mail" sortable /> <template #body="{ data }">
<Column field="phone" header="Telefon" /> <div class="contact-row">
<div class="avatar" :style="{ background: avatarColor(data) }">
<img v-if="data.photo" :src="data.photo" />
<span v-else>{{ initials(data) }}</span>
</div>
<div class="contact-name">
<strong>{{ data.display_name || '—' }}</strong>
<small v-if="data.organization">{{ data.organization }}{{ data.job_title ? ' · ' + data.job_title : '' }}</small>
</div>
</div>
</template>
</Column>
<Column field="primary_email" header="E-Mail" sortable />
<Column field="primary_phone" header="Telefon" />
<Column header="" style="width: 80px"> <Column header="" style="width: 80px">
<template #body="{ data }"> <template #body="{ data }">
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="confirmDeleteContact(data)" /> <Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="confirmDeleteContact(data)" />
@ -44,182 +62,700 @@
<label>Name</label> <label>Name</label>
<InputText v-model="newBookName" fluid autofocus @keyup.enter="createBook" /> <InputText v-model="newBookName" fluid autofocus @keyup.enter="createBook" />
</div> </div>
<div class="field">
<label>Farbe</label>
<InputText v-model="newBookColor" type="color" style="width: 60px; height: 36px" />
</div>
<template #footer> <template #footer>
<Button label="Abbrechen" text @click="showNewBook = false" /> <Button label="Abbrechen" text @click="showNewBook = false" />
<Button label="Erstellen" @click="createBook" /> <Button label="Erstellen" @click="createBook" />
</template> </template>
</Dialog> </Dialog>
<!-- Contact Form --> <!-- Book Menu (3-dot) -->
<Dialog v-model:visible="showContactForm" :header="editingContact ? 'Kontakt bearbeiten' : 'Neuer Kontakt'" modal :style="{ width: '500px' }"> <Dialog v-model:visible="showBookMenu" header="Adressbuch-Optionen" modal :style="{ width: '560px' }">
<div class="field"> <div v-if="menuBook" class="book-menu-content">
<label>Name</label> <p><strong>{{ menuBook.name }}</strong></p>
<InputText v-model="contactForm.display_name" fluid autofocus />
</div> <div class="field">
<div class="field"> <label>
<label>E-Mail</label> {{ menuBook.permission === 'owner' ? 'Farbe' : 'Persönliche Anzeigefarbe' }}
<InputText v-model="contactForm.email" type="email" fluid /> </label>
</div> <div style="display: flex; gap: 0.5rem; align-items: center;">
<div class="field"> <InputText :modelValue="menuBook.color" @change="onBookColorChange($event)"
<label>Telefon</label> type="color" style="width: 60px; height: 36px" />
<InputText v-model="contactForm.phone" fluid /> <span v-if="menuBook.permission !== 'owner'" class="hint">
</div> Nur für deine Ansicht {{ menuBook.owner_name }} behält seine Farbe
<div class="field"> </span>
<label>Organisation</label> </div>
<InputText v-model="contactForm.organization" fluid /> </div>
</div>
<div class="field"> <div v-if="menuBook.permission === 'owner'" class="field">
<label>Notizen</label> <label>Mit Benutzer teilen</label>
<Textarea v-model="contactForm.notes" rows="3" fluid /> <div class="share-row">
<div style="position: relative; flex: 1;">
<InputText v-model="shareUsername" placeholder="Benutzername suchen..."
fluid @input="onShareSearch" />
<div v-if="shareSearchResults.length" class="user-search-popup">
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
@click="shareUsername = u.username; shareSearchResults = []">
<i class="pi pi-user"></i> {{ u.username }}
</div>
</div>
</div>
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
<Button label="Teilen" size="small" @click="shareBook" />
</div>
<div v-if="bookShares.length" class="existing-shares">
<template v-for="s in bookShares" :key="s.id">
<div v-if="editingShareId !== s.id" class="share-perm-item">
<i class="pi pi-user"></i>
<span>{{ s.username }}</span>
<span class="perm-label">{{ s.permission === 'readwrite' ? 'Lesen+Schreiben' : 'Lesen' }}</span>
<Button icon="pi pi-pencil" text size="small" @click="startEditShare(s)" />
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(s.id)" />
</div>
<div v-else class="share-perm-item editing">
<i class="pi pi-user"></i>
<span>{{ s.username }}</span>
<Select v-model="editSharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
<Button icon="pi pi-check" text size="small" severity="success" @click="saveEditShare(s)" />
<Button icon="pi pi-times" text size="small" @click="editingShareId = null" />
</div>
</template>
</div>
</div>
<div class="field carddav-block">
<label><i class="pi pi-info-circle"></i> CardDAV-Zugang</label>
<div class="url-row">
<strong>Auto-Discovery</strong>
<code>{{ origin }}/dav/</code>
<Button icon="pi pi-copy" text size="small" @click="copyText(origin + '/dav/')" />
</div>
<div class="url-row">
<strong>Dieses Adressbuch</strong>
<code>{{ origin }}/dav/{{ username }}/ab-{{ menuBook.id }}/</code>
<Button icon="pi pi-copy" text size="small"
@click="copyText(`${origin}/dav/${username}/ab-${menuBook.id}/`)" />
</div>
</div>
<Button v-if="menuBook.permission === 'owner'" label="Adressbuch löschen"
severity="danger" text size="small" @click="confirmDeleteBook = true" />
</div> </div>
</Dialog>
<!-- Contact Editor -->
<Dialog v-model:visible="showContactDialog" :header="editingContactId ? 'Kontakt bearbeiten' : 'Neuer Kontakt'"
modal :style="{ width: '720px' }" maximizable>
<Tabs v-model:value="activeTab">
<TabList>
<Tab value="general">Allgemein</Tab>
<Tab value="communication">Kommunikation</Tab>
<Tab value="address">Adressen</Tab>
<Tab value="details">Details</Tab>
</TabList>
<TabPanels>
<TabPanel value="general">
<div class="photo-row">
<div class="avatar large" :style="{ background: avatarColor(contactForm) }">
<img v-if="contactForm.photo" :src="contactForm.photo" />
<span v-else>{{ initials(contactForm) }}</span>
</div>
<div>
<Button icon="pi pi-upload" label="Foto hochladen" size="small" @click="triggerPhotoUpload" />
<Button v-if="contactForm.photo" icon="pi pi-times" label="Entfernen" size="small" text
@click="contactForm.photo = null" />
<input ref="photoInput" type="file" accept="image/*" hidden @change="onPhotoSelected" />
</div>
</div>
<div class="field-row">
<div class="field" style="max-width:120px">
<label>Anrede</label>
<InputText v-model="contactForm.prefix" />
</div>
<div class="field">
<label>Vorname</label>
<InputText v-model="contactForm.first_name" fluid />
</div>
<div class="field">
<label>Mittelname</label>
<InputText v-model="contactForm.middle_name" fluid />
</div>
<div class="field">
<label>Nachname</label>
<InputText v-model="contactForm.last_name" fluid />
</div>
<div class="field" style="max-width:120px">
<label>Suffix</label>
<InputText v-model="contactForm.suffix" />
</div>
</div>
<div class="field-row">
<div class="field">
<label>Spitzname</label>
<InputText v-model="contactForm.nickname" fluid />
</div>
<div class="field">
<label>Anzeigename (optional wird sonst aus Namen zusammengesetzt)</label>
<InputText v-model="contactForm.display_name" fluid />
</div>
</div>
<div class="field-row">
<div class="field">
<label>Firma</label>
<InputText v-model="contactForm.organization" fluid />
</div>
<div class="field">
<label>Abteilung</label>
<InputText v-model="contactForm.department" fluid />
</div>
<div class="field">
<label>Position</label>
<InputText v-model="contactForm.job_title" fluid />
</div>
</div>
</TabPanel>
<TabPanel value="communication">
<h5>E-Mail-Adressen</h5>
<div v-for="(e, i) in contactForm.emails" :key="'e'+i" class="multi-row">
<Select v-model="e.type" :options="emailTypes" optionLabel="label" optionValue="value" style="width:120px" />
<InputText v-model="e.value" placeholder="name@example.com" fluid />
<Button icon="pi pi-trash" text severity="danger" size="small" @click="contactForm.emails.splice(i,1)" />
</div>
<Button icon="pi pi-plus" label="E-Mail hinzufügen" size="small" text
@click="contactForm.emails.push({ type: 'home', value: '' })" />
<h5 style="margin-top:1rem">Telefonnummern</h5>
<div v-for="(p, i) in contactForm.phones" :key="'p'+i" class="multi-row">
<Select v-model="p.type" :options="phoneTypes" optionLabel="label" optionValue="value" style="width:120px" />
<InputText v-model="p.value" placeholder="+49..." fluid />
<Button icon="pi pi-trash" text severity="danger" size="small" @click="contactForm.phones.splice(i,1)" />
</div>
<Button icon="pi pi-plus" label="Nummer hinzufügen" size="small" text
@click="contactForm.phones.push({ type: 'cell', value: '' })" />
<h5 style="margin-top:1rem">Websites</h5>
<div v-for="(w, i) in contactForm.websites" :key="'w'+i" class="multi-row">
<Select v-model="w.type" :options="urlTypes" optionLabel="label" optionValue="value" style="width:120px" />
<InputText v-model="w.value" placeholder="https://..." fluid />
<Button icon="pi pi-trash" text severity="danger" size="small" @click="contactForm.websites.splice(i,1)" />
</div>
<Button icon="pi pi-plus" label="Website hinzufügen" size="small" text
@click="contactForm.websites.push({ type: 'home', value: '' })" />
<h5 style="margin-top:1rem">Messenger</h5>
<div v-for="(m, i) in contactForm.impp" :key="'i'+i" class="multi-row">
<InputText v-model="m.protocol" placeholder="xmpp, skype, signal..." style="width:150px" />
<InputText v-model="m.value" placeholder="Benutzername" fluid />
<Button icon="pi pi-trash" text severity="danger" size="small" @click="contactForm.impp.splice(i,1)" />
</div>
<Button icon="pi pi-plus" label="Messenger hinzufügen" size="small" text
@click="contactForm.impp.push({ protocol: '', value: '' })" />
</TabPanel>
<TabPanel value="address">
<div v-for="(a, i) in contactForm.addresses" :key="'a'+i" class="address-card">
<div class="field-row">
<div class="field" style="width:140px">
<label>Typ</label>
<Select v-model="a.type" :options="addressTypes" optionLabel="label" optionValue="value" />
</div>
<div style="flex:1"></div>
<Button icon="pi pi-trash" text severity="danger" size="small" @click="contactForm.addresses.splice(i,1)" />
</div>
<div class="field-row">
<div class="field"><label>Straße</label><InputText v-model="a.street" fluid /></div>
<div class="field" style="max-width:120px"><label>PO-Box</label><InputText v-model="a.po_box" /></div>
</div>
<div class="field-row">
<div class="field" style="max-width:150px"><label>PLZ</label><InputText v-model="a.postal_code" /></div>
<div class="field"><label>Ort</label><InputText v-model="a.city" fluid /></div>
<div class="field"><label>Bundesland</label><InputText v-model="a.region" fluid /></div>
</div>
<div class="field"><label>Land</label><InputText v-model="a.country" fluid /></div>
</div>
<Button icon="pi pi-plus" label="Adresse hinzufügen" size="small" text
@click="contactForm.addresses.push({ type: 'home', street: '', po_box: '', postal_code: '', city: '', region: '', country: '' })" />
</TabPanel>
<TabPanel value="details">
<div class="field-row">
<div class="field">
<label>Geburtstag</label>
<InputText v-model="contactForm.birthday" type="date" fluid />
</div>
<div class="field">
<label>Jahrestag</label>
<InputText v-model="contactForm.anniversary" type="date" fluid />
</div>
</div>
<div class="field">
<label>Kategorien (kommagetrennt)</label>
<InputText v-model="categoriesString" fluid />
</div>
<div class="field">
<label>Notizen</label>
<Textarea v-model="contactForm.notes" rows="6" fluid />
</div>
</TabPanel>
</TabPanels>
</Tabs>
<template #footer> <template #footer>
<Button label="Abbrechen" text @click="showContactForm = false" /> <Button label="Abbrechen" text @click="showContactDialog = false" />
<Button :label="editingContact ? 'Speichern' : 'Erstellen'" @click="saveContact" /> <Button :label="editingContactId ? 'Speichern' : 'Erstellen'" @click="saveContact" />
</template> </template>
</Dialog> </Dialog>
<!-- Delete Confirm --> <Dialog v-model:visible="confirmDeleteContactDialog" header="Kontakt löschen" modal :style="{ width: '400px' }">
<Dialog v-model:visible="showDeleteConfirm" header="Kontakt loeschen" modal :style="{ width: '400px' }"> <p>Möchtest du <strong>{{ deleteContactTarget?.display_name }}</strong> wirklich löschen?</p>
<p>Moechtest du <strong>{{ deleteTarget?.display_name }}</strong> wirklich loeschen?</p>
<template #footer> <template #footer>
<Button label="Abbrechen" text @click="showDeleteConfirm = false" /> <Button label="Abbrechen" text @click="confirmDeleteContactDialog = false" />
<Button label="Loeschen" severity="danger" @click="doDeleteContact" /> <Button label="Löschen" severity="danger" @click="deleteContact" />
</template>
</Dialog>
<Dialog v-model:visible="confirmDeleteBook" header="Adressbuch löschen" modal :style="{ width: '400px' }">
<p>Adressbuch <strong>{{ menuBook?.name }}</strong> mit allen Kontakten löschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="confirmDeleteBook = false" />
<Button label="Löschen" severity="danger" @click="deleteBook" />
</template> </template>
</Dialog> </Dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
import apiClient from '../api/client' import apiClient from '../api/client'
import Button from 'primevue/button' import Button from 'primevue/button'
import Dialog from 'primevue/dialog' import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea' import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
import DataTable from 'primevue/datatable' import DataTable from 'primevue/datatable'
import Column from 'primevue/column' import Column from 'primevue/column'
import Tabs from 'primevue/tabs'
import TabList from 'primevue/tablist'
import Tab from 'primevue/tab'
import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel'
const toast = useToast() const toast = useToast()
const auth = useAuthStore()
const addressBooks = ref([]) const addressBooks = ref([])
const contacts = ref([]) const contacts = ref([])
const selectedBookId = ref(null) const selectedBookId = ref(null)
const loading = ref(false) const loading = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
let searchTimer = null
const origin = computed(() => window.location.origin)
const username = computed(() => auth.user?.username || '')
const showNewBook = ref(false) const showNewBook = ref(false)
const newBookName = ref('') const newBookName = ref('')
const newBookColor = ref('#3788d8')
const showContactForm = ref(false) const showBookMenu = ref(false)
const editingContact = ref(null) const menuBook = ref(null)
const contactForm = ref({ display_name: '', email: '', phone: '', organization: '', notes: '' }) const bookShares = ref([])
const shareUsername = ref('')
const sharePermission = ref('read')
const shareSearchResults = ref([])
let shareSearchTimer = null
const editingShareId = ref(null)
const editSharePermission = ref('read')
const confirmDeleteBook = ref(false)
const showContactDialog = ref(false)
const editingContactId = ref(null)
const activeTab = ref('general')
const contactForm = reactive(emptyContact())
const categoriesString = ref('')
const photoInput = ref(null)
const confirmDeleteContactDialog = ref(false)
const deleteContactTarget = ref(null)
const permOptions = [
{ label: 'Lesen', value: 'read' },
{ label: 'Lesen+Schreiben', value: 'readwrite' },
]
const emailTypes = [
{ label: 'Privat', value: 'home' }, { label: 'Geschäftlich', value: 'work' },
{ label: 'Sonstige', value: 'other' },
]
const phoneTypes = [
{ label: 'Mobil', value: 'cell' }, { label: 'Privat', value: 'home' },
{ label: 'Geschäftlich', value: 'work' }, { label: 'Fax', value: 'fax' },
{ label: 'Pager', value: 'pager' }, { label: 'Sonstige', value: 'other' },
]
const addressTypes = [
{ label: 'Privat', value: 'home' }, { label: 'Geschäftlich', value: 'work' },
{ label: 'Sonstige', value: 'other' },
]
const urlTypes = [
{ label: 'Privat', value: 'home' }, { label: 'Geschäftlich', value: 'work' },
{ label: 'Sonstige', value: 'other' },
]
function emptyContact() {
return {
prefix: '', first_name: '', middle_name: '', last_name: '', suffix: '',
display_name: '', nickname: '',
organization: '', department: '', job_title: '',
emails: [], phones: [], addresses: [], websites: [], impp: [], categories: [],
birthday: '', anniversary: '', notes: '', photo: null,
}
}
function initials(c) {
if (!c) return '?'
const parts = []
if (c.first_name) parts.push(c.first_name[0])
if (c.last_name) parts.push(c.last_name[0])
if (!parts.length && c.display_name) parts.push(c.display_name[0])
return (parts.join('') || '?').toUpperCase()
}
function avatarColor(c) {
if (!c) return '#888'
const s = (c.display_name || c.last_name || c.first_name || 'x').toLowerCase()
let h = 0
for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) >>> 0
return `hsl(${h % 360}, 45%, 55%)`
}
async function loadBooks() { async function loadBooks() {
const res = await apiClient.get('/addressbooks') const res = await apiClient.get('/addressbooks')
addressBooks.value = res.data addressBooks.value = res.data
if (addressBooks.value.length && !selectedBookId.value) {
selectedBookId.value = addressBooks.value[0].id
await loadContacts()
}
if (!addressBooks.value.length) { if (!addressBooks.value.length) {
await apiClient.post('/addressbooks', { name: 'Kontakte' }) await apiClient.post('/addressbooks', { name: 'Meine Kontakte', color: '#3788d8' })
await loadBooks() const res2 = await apiClient.get('/addressbooks')
addressBooks.value = res2.data
} }
if (!selectedBookId.value && addressBooks.value.length) {
selectedBookId.value = addressBooks.value[0].id
}
}
async function selectBook(id) {
selectedBookId.value = id
await loadContacts()
} }
async function loadContacts() { async function loadContacts() {
if (!selectedBookId.value) return if (!selectedBookId.value) return
loading.value = true loading.value = true
try { try {
const params = searchQuery.value ? { search: searchQuery.value } : {} const res = await apiClient.get(`/addressbooks/${selectedBookId.value}/contacts`,
const res = await apiClient.get(`/addressbooks/${selectedBookId.value}/contacts`, { params }) { params: { q: searchQuery.value || undefined } })
contacts.value = res.data contacts.value = res.data
} finally { } finally { loading.value = false }
loading.value = false
}
} }
function selectBook(id) { function onSearch() {
selectedBookId.value = id clearTimeout(searchTimer)
loadContacts() searchTimer = setTimeout(loadContacts, 250)
} }
async function createBook() { async function createBook() {
if (!newBookName.value.trim()) return if (!newBookName.value.trim()) return
await apiClient.post('/addressbooks', { name: newBookName.value.trim() }) await apiClient.post('/addressbooks', { name: newBookName.value.trim(), color: newBookColor.value })
showNewBook.value = false showNewBook.value = false
newBookName.value = '' newBookName.value = ''
await loadBooks() await loadBooks()
} }
function openNewContact() { function openBookMenu(book) {
editingContact.value = null menuBook.value = book
contactForm.value = { display_name: '', email: '', phone: '', organization: '', notes: '' } shareUsername.value = ''
showContactForm.value = true shareSearchResults.value = []
editingShareId.value = null
showBookMenu.value = true
loadShares()
} }
function openEditContact(event) { async function loadShares() {
const c = event.data if (!menuBook.value || menuBook.value.permission !== 'owner') {
editingContact.value = c bookShares.value = []
contactForm.value = { return
display_name: c.display_name || '',
email: c.email || '',
phone: c.phone || '',
organization: '',
notes: '',
} }
showContactForm.value = true try {
const res = await apiClient.get(`/addressbooks/${menuBook.value.id}/shares`)
bookShares.value = res.data
} catch { bookShares.value = [] }
}
function onShareSearch() {
clearTimeout(shareSearchTimer)
const q = shareUsername.value.trim()
if (q.length < 2) { shareSearchResults.value = []; return }
shareSearchTimer = setTimeout(async () => {
try {
const res = await apiClient.get('/users/search', { params: { q } })
shareSearchResults.value = res.data
} catch { shareSearchResults.value = [] }
}, 250)
}
async function shareBook() {
if (!shareUsername.value.trim() || !menuBook.value) return
try {
await apiClient.post(`/addressbooks/${menuBook.value.id}/share`, {
username: shareUsername.value.trim(), permission: sharePermission.value,
})
shareUsername.value = ''
shareSearchResults.value = []
await loadShares()
toast.add({ severity: 'success', summary: 'Adressbuch geteilt', life: 2500 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 4000 })
}
}
function startEditShare(s) {
editingShareId.value = s.id
editSharePermission.value = s.permission
}
async function saveEditShare(s) {
if (!menuBook.value) return
try {
await apiClient.post(`/addressbooks/${menuBook.value.id}/share`, {
username: s.username,
permission: editSharePermission.value,
})
editingShareId.value = null
await loadShares()
toast.add({ severity: 'success', summary: 'Berechtigung aktualisiert', life: 2500 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 4000 })
}
}
async function removeShare(shareId) {
if (!menuBook.value) return
await apiClient.delete(`/addressbooks/${menuBook.value.id}/shares/${shareId}`)
await loadShares()
}
async function onBookColorChange(ev) {
if (!menuBook.value) return
const color = ev.target.value
try {
const res = await apiClient.put(`/addressbooks/${menuBook.value.id}/my-color`, { color })
menuBook.value.color = res.data.color
await loadBooks()
toast.add({ severity: 'success', summary: 'Farbe aktualisiert', life: 2000 })
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 4000 })
}
}
async function deleteBook() {
if (!menuBook.value) return
await apiClient.delete(`/addressbooks/${menuBook.value.id}`)
showBookMenu.value = false
confirmDeleteBook.value = false
if (selectedBookId.value === menuBook.value.id) selectedBookId.value = null
await loadBooks()
}
function copyText(t) {
navigator.clipboard.writeText(t)
toast.add({ severity: 'info', summary: 'Kopiert', life: 1500 })
}
function openNewContact() {
editingContactId.value = null
Object.assign(contactForm, emptyContact())
categoriesString.value = ''
activeTab.value = 'general'
showContactDialog.value = true
}
function onRowClick(ev) {
openEditContact(ev.data)
}
async function openEditContact(row) {
editingContactId.value = row.id
const res = await apiClient.get(`/contacts/${row.id}`)
const c = res.data
Object.assign(contactForm, emptyContact(), {
prefix: c.prefix || '',
first_name: c.first_name || '',
middle_name: c.middle_name || '',
last_name: c.last_name || '',
suffix: c.suffix || '',
display_name: c.display_name || '',
nickname: c.nickname || '',
organization: c.organization || '',
department: c.department || '',
job_title: c.job_title || '',
emails: (c.emails || []).map(x => ({ ...x })),
phones: (c.phones || []).map(x => ({ ...x })),
addresses: (c.addresses || []).map(x => ({ ...x })),
websites: (c.websites || []).map(x => ({ ...x })),
impp: (c.impp || []).map(x => ({ ...x })),
categories: c.categories || [],
birthday: c.birthday || '',
anniversary: c.anniversary || '',
notes: c.notes || '',
photo: c.photo || null,
})
categoriesString.value = (c.categories || []).join(', ')
activeTab.value = 'general'
showContactDialog.value = true
}
function triggerPhotoUpload() {
photoInput.value?.click()
}
function onPhotoSelected(ev) {
const file = ev.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => { contactForm.photo = reader.result }
reader.readAsDataURL(file)
ev.target.value = ''
} }
async function saveContact() { async function saveContact() {
if (!contactForm.value.display_name.trim()) return const payload = { ...contactForm }
payload.categories = categoriesString.value.split(',').map(s => s.trim()).filter(Boolean)
// Drop empty sub-rows
payload.emails = payload.emails.filter(e => e.value.trim())
payload.phones = payload.phones.filter(p => p.value.trim())
payload.websites = payload.websites.filter(w => w.value.trim())
payload.impp = payload.impp.filter(i => i.value.trim())
payload.addresses = payload.addresses.filter(a =>
a.street || a.city || a.postal_code || a.country || a.region || a.po_box)
try { try {
if (editingContact.value) { if (editingContactId.value) {
await apiClient.put(`/contacts/${editingContact.value.id}`, contactForm.value) await apiClient.put(`/contacts/${editingContactId.value}`, payload)
} else { } else {
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, contactForm.value) await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, payload)
} }
showContactForm.value = false showContactDialog.value = false
await loadContacts()
await loadBooks() await loadBooks()
await loadContacts()
} catch (err) { } catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 }) toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
} }
} }
const showDeleteConfirm = ref(false) function confirmDeleteContact(row) {
const deleteTarget = ref(null) deleteContactTarget.value = row
confirmDeleteContactDialog.value = true
function confirmDeleteContact(contact) {
deleteTarget.value = contact
showDeleteConfirm.value = true
} }
async function doDeleteContact() { async function deleteContact() {
if (!deleteTarget.value) return if (!deleteContactTarget.value) return
await apiClient.delete(`/contacts/${deleteTarget.value.id}`) await apiClient.delete(`/contacts/${deleteContactTarget.value.id}`)
showDeleteConfirm.value = false confirmDeleteContactDialog.value = false
await loadContacts()
await loadBooks() await loadBooks()
await loadContacts()
} }
onMounted(loadBooks) // Live-Refresh via SSE
let eventSource = null
let reloadTimer = null
function scheduleReload() {
if (reloadTimer) return
reloadTimer = setTimeout(async () => {
reloadTimer = null
await loadBooks()
await loadContacts()
}, 300)
}
onMounted(async () => {
await loadBooks()
await loadContacts()
if (auth.accessToken) {
try {
eventSource = new EventSource(`/api/sync/events?token=${encodeURIComponent(auth.accessToken)}`)
eventSource.addEventListener('addressbook', scheduleReload)
eventSource.addEventListener('message', scheduleReload)
eventSource.onerror = () => { /* auto-reconnects */ }
} catch { /* ignore */ }
}
})
onUnmounted(() => {
if (reloadTimer) clearTimeout(reloadTimer)
if (eventSource) eventSource.close()
})
watch(selectedBookId, loadContacts)
</script> </script>
<style scoped> <style scoped>
.view-container { padding: 1.5rem; } .view-container { padding: 1.5rem; }
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; } .view-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.view-header h2 { margin: 0; } .view-header h2 { margin: 0; }
.header-actions { display: flex; gap: 0.5rem; } .header-actions { display: flex; gap: 0.5rem; }
.contacts-layout { display: flex; gap: 1rem; } .contacts-layout { display: flex; gap: 1rem; align-items: flex-start; }
.books-sidebar { width: 220px; flex-shrink: 0; } .books-sidebar { width: 260px; flex-shrink: 0; }
.book-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-radius: 6px; cursor: pointer; font-size: 0.875rem; } .books-sidebar h4 { margin: 0 0 0.5rem; font-size: 0.85rem; text-transform: uppercase; color: var(--p-text-muted-color); }
.book-item:hover { background: var(--p-surface-100); } .book-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem;
.book-item.active { background: var(--p-primary-50); color: var(--p-primary-color); } border-radius: 4px; cursor: pointer; font-size: 0.875rem; }
.book-item .count { margin-left: auto; color: var(--p-text-muted-color); font-size: 0.75rem; } .book-item:hover { background: var(--p-surface-50); }
.contacts-main { flex: 1; } .book-item.active { background: var(--p-primary-50); }
.search-bar { margin-bottom: 1rem; } .book-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
.field { margin-bottom: 1rem; } .book-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; } .shared-label { color: var(--p-text-muted-color); font-size: 0.7rem; }
.count { color: var(--p-text-muted-color); font-size: 0.8rem; }
.book-menu { opacity: 0; transition: opacity .15s; }
.book-item:hover .book-menu { opacity: 1; }
.contacts-main { flex: 1; min-width: 0; }
.search-bar { margin-bottom: 0.75rem; }
.empty { text-align: center; color: var(--p-text-muted-color); padding: 2rem; } .empty { text-align: center; color: var(--p-text-muted-color); padding: 2rem; }
.contact-row { display: flex; align-items: center; gap: 0.75rem; }
.avatar { width: 36px; height: 36px; border-radius: 50%; background: #888; color: white;
display: flex; align-items: center; justify-content: center;
font-weight: bold; flex-shrink: 0; overflow: hidden; }
.avatar img { width: 100%; height: 100%; object-fit: cover; }
.avatar.large { width: 96px; height: 96px; font-size: 2rem; }
.contact-name { display: flex; flex-direction: column; }
.contact-name small { color: var(--p-text-muted-color); font-size: 0.75rem; }
.field { margin-bottom: 0.75rem; }
.field label { display: block; margin-bottom: 0.25rem; font-weight: 500; font-size: 0.875rem; }
.field-row { display: flex; gap: 0.75rem; align-items: flex-end; }
.field-row .field { flex: 1; margin-bottom: 0.75rem; }
.photo-row { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.multi-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.4rem; }
.address-card { border: 1px solid var(--p-surface-200); padding: 0.75rem; border-radius: 6px; margin-bottom: 0.75rem; }
.share-row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.user-search-popup { position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
background: white; border: 1px solid var(--p-surface-200);
border-radius: 4px; max-height: 160px; overflow-y: auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; }
.user-result:hover { background: var(--p-primary-50); }
.existing-shares { margin-top: 0.5rem; }
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; flex-wrap: wrap; }
.share-perm-item.editing { background: var(--p-surface-50); padding: 0.5rem; border-radius: 4px; }
.perm-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
.carddav-block { border-top: 1px solid var(--p-surface-200); padding-top: 1rem; margin-top: 1rem; }
.url-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.5rem; flex-wrap: wrap; }
.url-row strong { min-width: 160px; font-size: 0.8rem; }
.url-row code { background: var(--p-surface-100); padding: 0.25rem 0.5rem; border-radius: 4px;
font-size: 0.8rem; word-break: break-all; flex: 1; }
.hint { font-size: 0.75rem; color: var(--p-text-muted-color); font-style: italic; }
:deep(.clickable) { cursor: pointer; }
h5 { margin: 0.5rem 0 0.25rem; font-size: 0.85rem; text-transform: uppercase; color: var(--p-text-muted-color); }
</style> </style>