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
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.auth import token_required
from app.extensions import db
from app.models.contact import AddressBook, Contact, AddressBookShare
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):
@ -26,7 +45,224 @@ def _get_addressbook_or_err(book_id, user, need_write=False):
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'])
@token_required
@ -49,6 +285,9 @@ def list_addressbooks():
address_book_id=b.id, shared_with_id=user.id
).first()
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['contact_count'] = b.contacts.count()
result.append(d)
@ -61,13 +300,19 @@ def list_addressbooks():
def create_addressbook():
user = request.current_user
data = request.get_json()
name = data.get('name', '').strip()
name = (data.get('name') or '').strip()
if not name:
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.commit()
_notify_addressbook(user.id, book.id, 'created')
return jsonify(book.to_dict()), 201
@ -77,31 +322,66 @@ def update_addressbook(book_id):
user = request.current_user
book = db.session.get(AddressBook, book_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()
if 'name' in data:
book.name = data['name'].strip()
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()
_notify_addressbook(book.owner_id, book.id, 'updated',
shared_with=_book_recipients(book))
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'])
@token_required
def delete_addressbook(book_id):
user = request.current_user
book = db.session.get(AddressBook, book_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.commit()
_notify_addressbook(owner_id, bid, 'deleted', shared_with=recipients)
return jsonify({'message': 'Adressbuch geloescht'}), 200
# --- Contacts ---
# ---------------------------------------------------------------------------
# Contacts
# ---------------------------------------------------------------------------
@api_bp.route('/addressbooks/<int:book_id>/contacts', methods=['GET'])
@token_required
@ -111,11 +391,16 @@ def list_contacts(book_id):
if err:
return err
search = request.args.get('search', '').strip()
query = Contact.query.filter_by(address_book_id=book_id)
search = (request.args.get('q') or '').strip()
q = Contact.query.filter_by(address_book_id=book_id)
if search:
query = query.filter(Contact.display_name.ilike(f'%{search}%'))
contacts = query.order_by(Contact.display_name).all()
like = f'%{search}%'
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
@ -127,29 +412,16 @@ def create_contact(book_id):
if err:
return err
data = request.get_json()
display_name = data.get('display_name', '').strip()
if not display_name:
return jsonify({'error': 'Name erforderlich'}), 400
contact_uid = str(uuid.uuid4())
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,
)
data = request.get_json() or {}
contact = Contact(address_book_id=book_id, uid=str(uuid.uuid4()), vcard_data='')
_apply_fields_to_contact(contact, data)
if not contact.display_name:
return jsonify({'error': 'Name oder Firma erforderlich'}), 400
contact.vcard_data = _build_vcard(contact)
db.session.add(contact)
db.session.commit()
_notify_addressbook(book.owner_id, book.id, 'contact',
shared_with=_book_recipients(book))
return jsonify(contact.to_dict()), 201
@ -160,11 +432,9 @@ def get_contact(contact_id):
contact = db.session.get(Contact, contact_id)
if not contact:
return jsonify({'error': 'Kontakt nicht gefunden'}), 404
book, err = _get_addressbook_or_err(contact.address_book_id, user)
if err:
return err
result = contact.to_dict()
result['vcard_data'] = contact.vcard_data
return jsonify(result), 200
@ -177,29 +447,17 @@ def update_contact(contact_id):
contact = db.session.get(Contact, contact_id)
if not contact:
return jsonify({'error': 'Kontakt nicht gefunden'}), 404
book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True)
if err:
return err
data = request.get_json()
if 'display_name' in data:
contact.display_name = data['display_name'].strip()
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', ''),
)
data = request.get_json() or {}
_apply_fields_to_contact(contact, data)
contact.vcard_data = _build_vcard(contact)
contact.updated_at = datetime.now(timezone.utc)
db.session.commit()
_notify_addressbook(book.owner_id, book.id, 'contact',
shared_with=_book_recipients(book))
return jsonify(contact.to_dict()), 200
@ -210,17 +468,19 @@ def delete_contact(contact_id):
contact = db.session.get(Contact, contact_id)
if not contact:
return jsonify({'error': 'Kontakt nicht gefunden'}), 404
book, err = _get_addressbook_or_err(contact.address_book_id, user, need_write=True)
if err:
return err
db.session.delete(contact)
db.session.commit()
_notify_addressbook(book.owner_id, book.id, 'contact',
shared_with=_book_recipients(book))
return jsonify({'message': 'Kontakt geloescht'}), 200
# --- Sharing ---
# ---------------------------------------------------------------------------
# Sharing
# ---------------------------------------------------------------------------
@api_bp.route('/addressbooks/<int:book_id>/share', methods=['POST'])
@token_required
@ -230,10 +490,9 @@ def share_addressbook(book_id):
if not book or book.owner_id != user.id:
return jsonify({'error': 'Nur der Eigentuemer kann teilen'}), 403
data = request.get_json()
username = data.get('username', '').strip()
data = request.get_json() or {}
username = (data.get('username') or '').strip()
permission = data.get('permission', 'read')
if permission not in ('read', 'readwrite'):
return jsonify({'error': 'Ungueltige Berechtigung'}), 400
@ -246,7 +505,6 @@ def share_addressbook(book_id):
existing = AddressBookShare.query.filter_by(
address_book_id=book_id, shared_with_id=target.id
).first()
is_new = not existing
if existing:
existing.permission = permission
else:
@ -254,16 +512,9 @@ def share_addressbook(book_id):
address_book_id=book_id, shared_with_id=target.id, permission=permission
)
db.session.add(share)
db.session.commit()
if is_new:
try:
from app.services.system_mail import notify_contacts_shared
notify_contacts_shared(book.name, user.username, target, permission)
except Exception:
pass
_notify_addressbook(book.owner_id, book.id, 'share',
shared_with=[target.id, *_book_recipients(book)])
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)
if not book or book.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404
shares = AddressBookShare.query.filter_by(address_book_id=book_id).all()
return jsonify([{
'id': s.id,
@ -291,17 +541,20 @@ def remove_addressbook_share(book_id, share_id):
book = db.session.get(AddressBook, book_id)
if not book or book.owner_id != user.id:
return jsonify({'error': 'Nicht gefunden'}), 404
share = db.session.get(AddressBookShare, share_id)
if not share or share.address_book_id != book_id:
return jsonify({'error': 'Freigabe nicht gefunden'}), 404
target_id = share.shared_with_id
db.session.delete(share)
db.session.commit()
_notify_addressbook(book.owner_id, book.id, 'share',
shared_with=[target_id, *_book_recipients(book)])
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'])
@token_required
@ -310,40 +563,9 @@ def export_contacts(book_id):
book, err = _get_addressbook_or_err(book_id, user)
if err:
return err
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
parts = [c.vcard_data for c in book.contacts]
return Response(
vcards,
mimetype='text/vcard',
'\r\n'.join(parts),
mimetype='text/vcard; charset=utf-8',
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')
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
home = ET.SubElement(prop, _qn('c', 'calendar-home-set'))
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)
@ -265,7 +269,7 @@ def propfind(subpath=''):
multistatus.append(_principal_response(user))
return _xml_response(multistatus)
# /dav/<username>/ : principal + list calendars
# /dav/<username>/ : principal + list calendars AND addressbooks
if len(parts) == 1:
if parts[0] != user.username:
return Response('', 403)
@ -273,6 +277,11 @@ def propfind(subpath=''):
if depth != '0':
for cal in _user_calendars(user):
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)
# /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)
name = db.Column(db.String(255), nullable=False)
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))
updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
@ -18,6 +19,7 @@ class AddressBook(db.Model):
cascade='all, delete-orphan')
shares = db.relationship('AddressBookShare', backref='address_book', lazy='dynamic',
cascade='all, delete-orphan')
# `owner` wird automatisch durch User.address_books backref erzeugt
def to_dict(self):
return {
@ -25,6 +27,7 @@ class AddressBook(db.Model):
'owner_id': self.owner_id,
'name': self.name,
'description': self.description,
'color': self.color,
'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'),
nullable=False, index=True)
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)
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)
phone = db.Column(db.String(50), nullable=True)
created_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))
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 {
'id': self.id,
'address_book_id': self.address_book_id,
'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,
'email': self.email,
'phone': self.phone,
'nickname': self.nickname,
'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,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
@ -65,6 +138,7 @@ class AddressBookShare(db.Model):
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')
color = db.Column(db.String(7), nullable=True) # personal display color
shared_with = db.relationship('User', backref='shared_address_books')

View File

@ -10,25 +10,43 @@
<div class="contacts-layout">
<aside class="books-sidebar">
<h4>Adressbücher</h4>
<div v-for="book in addressBooks" :key="book.id"
class="book-item" :class="{ active: selectedBookId === book.id }"
@click="selectBook(book.id)">
<i class="pi pi-book"></i>
<span>{{ book.name }}</span>
<span class="book-color" :style="{ background: book.color }"></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>
<Button icon="pi pi-ellipsis-v" text size="small" class="book-menu"
@click.stop="openBookMenu(book)" />
</div>
</aside>
<div class="contacts-main">
<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>
<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>
<Column field="display_name" header="Name" sortable />
<Column field="email" header="E-Mail" sortable />
<Column field="phone" header="Telefon" />
<Column header="Name" sortable sortField="display_name">
<template #body="{ data }">
<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">
<template #body="{ data }">
<Button icon="pi pi-trash" text size="small" severity="danger" @click.stop="confirmDeleteContact(data)" />
@ -44,182 +62,700 @@
<label>Name</label>
<InputText v-model="newBookName" fluid autofocus @keyup.enter="createBook" />
</div>
<div class="field">
<label>Farbe</label>
<InputText v-model="newBookColor" type="color" style="width: 60px; height: 36px" />
</div>
<template #footer>
<Button label="Abbrechen" text @click="showNewBook = false" />
<Button label="Erstellen" @click="createBook" />
</template>
</Dialog>
<!-- Contact Form -->
<Dialog v-model:visible="showContactForm" :header="editingContact ? 'Kontakt bearbeiten' : 'Neuer Kontakt'" modal :style="{ width: '500px' }">
<div class="field">
<label>Name</label>
<InputText v-model="contactForm.display_name" fluid autofocus />
</div>
<div class="field">
<label>E-Mail</label>
<InputText v-model="contactForm.email" type="email" fluid />
</div>
<div class="field">
<label>Telefon</label>
<InputText v-model="contactForm.phone" fluid />
</div>
<div class="field">
<label>Organisation</label>
<InputText v-model="contactForm.organization" fluid />
</div>
<div class="field">
<label>Notizen</label>
<Textarea v-model="contactForm.notes" rows="3" fluid />
<!-- Book Menu (3-dot) -->
<Dialog v-model:visible="showBookMenu" header="Adressbuch-Optionen" modal :style="{ width: '560px' }">
<div v-if="menuBook" class="book-menu-content">
<p><strong>{{ menuBook.name }}</strong></p>
<div class="field">
<label>
{{ menuBook.permission === 'owner' ? 'Farbe' : 'Persönliche Anzeigefarbe' }}
</label>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<InputText :modelValue="menuBook.color" @change="onBookColorChange($event)"
type="color" style="width: 60px; height: 36px" />
<span v-if="menuBook.permission !== 'owner'" class="hint">
Nur für deine Ansicht {{ menuBook.owner_name }} behält seine Farbe
</span>
</div>
</div>
<div v-if="menuBook.permission === 'owner'" class="field">
<label>Mit Benutzer teilen</label>
<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>
</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>
<Button label="Abbrechen" text @click="showContactForm = false" />
<Button :label="editingContact ? 'Speichern' : 'Erstellen'" @click="saveContact" />
<Button label="Abbrechen" text @click="showContactDialog = false" />
<Button :label="editingContactId ? 'Speichern' : 'Erstellen'" @click="saveContact" />
</template>
</Dialog>
<!-- Delete Confirm -->
<Dialog v-model:visible="showDeleteConfirm" header="Kontakt loeschen" modal :style="{ width: '400px' }">
<p>Moechtest du <strong>{{ deleteTarget?.display_name }}</strong> wirklich loeschen?</p>
<Dialog v-model:visible="confirmDeleteContactDialog" header="Kontakt löschen" modal :style="{ width: '400px' }">
<p>Möchtest du <strong>{{ deleteContactTarget?.display_name }}</strong> wirklich löschen?</p>
<template #footer>
<Button label="Abbrechen" text @click="showDeleteConfirm = false" />
<Button label="Loeschen" severity="danger" @click="doDeleteContact" />
<Button label="Abbrechen" text @click="confirmDeleteContactDialog = false" />
<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>
</Dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useAuthStore } from '../stores/auth'
import apiClient from '../api/client'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Select from 'primevue/select'
import DataTable from 'primevue/datatable'
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 auth = useAuthStore()
const addressBooks = ref([])
const contacts = ref([])
const selectedBookId = ref(null)
const loading = ref(false)
const searchQuery = ref('')
let searchTimer = null
const origin = computed(() => window.location.origin)
const username = computed(() => auth.user?.username || '')
const showNewBook = ref(false)
const newBookName = ref('')
const newBookColor = ref('#3788d8')
const showContactForm = ref(false)
const editingContact = ref(null)
const contactForm = ref({ display_name: '', email: '', phone: '', organization: '', notes: '' })
const showBookMenu = ref(false)
const menuBook = ref(null)
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() {
const res = await apiClient.get('/addressbooks')
addressBooks.value = res.data
if (addressBooks.value.length && !selectedBookId.value) {
selectedBookId.value = addressBooks.value[0].id
await loadContacts()
}
if (!addressBooks.value.length) {
await apiClient.post('/addressbooks', { name: 'Kontakte' })
await loadBooks()
await apiClient.post('/addressbooks', { name: 'Meine Kontakte', color: '#3788d8' })
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() {
if (!selectedBookId.value) return
loading.value = true
try {
const params = searchQuery.value ? { search: searchQuery.value } : {}
const res = await apiClient.get(`/addressbooks/${selectedBookId.value}/contacts`, { params })
const res = await apiClient.get(`/addressbooks/${selectedBookId.value}/contacts`,
{ params: { q: searchQuery.value || undefined } })
contacts.value = res.data
} finally {
loading.value = false
}
} finally { loading.value = false }
}
function selectBook(id) {
selectedBookId.value = id
loadContacts()
function onSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(loadContacts, 250)
}
async function createBook() {
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
newBookName.value = ''
await loadBooks()
}
function openNewContact() {
editingContact.value = null
contactForm.value = { display_name: '', email: '', phone: '', organization: '', notes: '' }
showContactForm.value = true
function openBookMenu(book) {
menuBook.value = book
shareUsername.value = ''
shareSearchResults.value = []
editingShareId.value = null
showBookMenu.value = true
loadShares()
}
function openEditContact(event) {
const c = event.data
editingContact.value = c
contactForm.value = {
display_name: c.display_name || '',
email: c.email || '',
phone: c.phone || '',
organization: '',
notes: '',
async function loadShares() {
if (!menuBook.value || menuBook.value.permission !== 'owner') {
bookShares.value = []
return
}
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() {
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 {
if (editingContact.value) {
await apiClient.put(`/contacts/${editingContact.value.id}`, contactForm.value)
if (editingContactId.value) {
await apiClient.put(`/contacts/${editingContactId.value}`, payload)
} else {
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, contactForm.value)
await apiClient.post(`/addressbooks/${selectedBookId.value}/contacts`, payload)
}
showContactForm.value = false
await loadContacts()
showContactDialog.value = false
await loadBooks()
await loadContacts()
} catch (err) {
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
}
}
const showDeleteConfirm = ref(false)
const deleteTarget = ref(null)
function confirmDeleteContact(contact) {
deleteTarget.value = contact
showDeleteConfirm.value = true
function confirmDeleteContact(row) {
deleteContactTarget.value = row
confirmDeleteContactDialog.value = true
}
async function doDeleteContact() {
if (!deleteTarget.value) return
await apiClient.delete(`/contacts/${deleteTarget.value.id}`)
showDeleteConfirm.value = false
await loadContacts()
async function deleteContact() {
if (!deleteContactTarget.value) return
await apiClient.delete(`/contacts/${deleteContactTarget.value.id}`)
confirmDeleteContactDialog.value = false
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>
<style scoped>
.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; }
.header-actions { display: flex; gap: 0.5rem; }
.contacts-layout { display: flex; gap: 1rem; }
.books-sidebar { width: 220px; 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; }
.book-item:hover { background: var(--p-surface-100); }
.book-item.active { background: var(--p-primary-50); color: var(--p-primary-color); }
.book-item .count { margin-left: auto; color: var(--p-text-muted-color); font-size: 0.75rem; }
.contacts-main { flex: 1; }
.search-bar { margin-bottom: 1rem; }
.field { margin-bottom: 1rem; }
.field label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.875rem; }
.contacts-layout { display: flex; gap: 1rem; align-items: flex-start; }
.books-sidebar { width: 260px; flex-shrink: 0; }
.books-sidebar h4 { margin: 0 0 0.5rem; font-size: 0.85rem; text-transform: uppercase; color: var(--p-text-muted-color); }
.book-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem;
border-radius: 4px; cursor: pointer; font-size: 0.875rem; }
.book-item:hover { background: var(--p-surface-50); }
.book-item.active { background: var(--p-primary-50); }
.book-color { width: 12px; height: 12px; border-radius: 3px; flex-shrink: 0; }
.book-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.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; }
.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>