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:
parent
fbf10197d7
commit
9c102823e4
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)})
|
||||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue