Bildeditor, klickbare Felder, Einstellungen; Kopfzeile und Namensfeld raus
All checks were successful
Build & Deploy / deploy (push) Successful in 1m29s
All checks were successful
Build & Deploy / deploy (push) Successful in 1m29s
Bildbearbeitung im Nachgang: Drehen in 90-Grad-Schritten, Ausrichten per
Schieberegler, Zuschneiden per Rahmen mit anfassbaren Ecken. Der Server
rechnet jede Korrektur vom unbearbeiteten Original, das beim ersten
Eingriff daneben abgelegt wird - sonst summieren sich die Verluste über
mehrere Korrekturen, und Zurücksetzen wäre nicht möglich. Zwei
Zuschnitte hintereinander werden ineinander verrechnet.
Der Ort ließ sich nicht übernehmen, weil beide Quellen versagen können:
iOS entfernt beim Weitergeben an eine Webseite oft die GPS-Daten aus dem
Foto, und der Browserstandort scheiterte bisher stumm. Jetzt meldet er
seinen Grund ("Standortfreigabe fehlt"), die Detailansicht zeigt die
Herkunft des Ortes, und der Scan-Ort ist von Hand nachtragbar.
E-Mail, Web, Telefon und Mobil sind in der Tabelle jetzt Links; Straße,
PLZ, Ort und Land stehen als eine Adresszeile, die die eingestellte
Navigations-App öffnet.
Einstellungen als vierter Reiter: Navigations-App (Apple Karten, Google
Maps, OpenStreetMap), Statusübersicht und Abmelden - der Knopf saß
vorher in der Kopfzeile.
Die Kopfzeile ist weg. Sie kostete auf dem Telefon eine Bildschirmzeile
und wiederholte nur, was die Tableiste schon sagt.
Kein full_name mehr, nur Vor- und Nachname; akademische Titel gehören in
den Vornamen. Bestehende Datensätze werden bei der Migration am letzten
Leerzeichen aufgeteilt. Die Spalte bleibt ungenutzt stehen: SQLite baut
zum Löschen die ganze Tabelle neu, der Gewinn wären ein paar Byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
11
README.md
11
README.md
@@ -47,6 +47,15 @@ dort steht oft nur die Mobilnummer.
|
|||||||
**Übersicht.** Alle Karten mit Volltextsuche über Name, Firma, Anlass, Ort und
|
**Übersicht.** Alle Karten mit Volltextsuche über Name, Firma, Anlass, Ort und
|
||||||
Notiz. Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung“.
|
Notiz. Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung“.
|
||||||
|
|
||||||
|
**Bild nachbearbeiten.** Drehen in 90-Grad-Schritten, Ausrichten über einen
|
||||||
|
Schieberegler und Zuschneiden per Rahmen. Jede Korrektur rechnet der Server vom
|
||||||
|
unbearbeiteten Original, das beim ersten Eingriff daneben abgelegt wird – so
|
||||||
|
summieren sich keine Verluste und *Zurücksetzen* führt zurück zum Scan.
|
||||||
|
|
||||||
|
**Klickbare Felder.** E-Mail, Web, Telefon und Mobil sind Handlungen, keine
|
||||||
|
Zeichenketten. Die Adresse öffnet die Navigations-App, die unter *Mehr*
|
||||||
|
eingestellt ist (Apple Karten, Google Maps oder OpenStreetMap).
|
||||||
|
|
||||||
**Export und Teilen.** *In Kontakte speichern* liefert eine vCard 3.0, die iOS
|
**Export und Teilen.** *In Kontakte speichern* liefert eine vCard 3.0, die iOS
|
||||||
direkt in die Kontakte-App übernimmt. *Als vCard teilen* geht über das
|
direkt in die Kontakte-App übernimmt. *Als vCard teilen* geht über das
|
||||||
System-Teilenblatt – per AirDrop, Mail oder Nachricht, ohne Umweg über den
|
System-Teilenblatt – per AirDrop, Mail oder Nachricht, ohne Umweg über den
|
||||||
@@ -124,8 +133,8 @@ Karte und sagt das in der Oberfläche. Einzelne Fehlgriffe lassen sich pro Karte
|
|||||||
app/ FastAPI-Anwendung
|
app/ FastAPI-Anwendung
|
||||||
segment.py OpenCV-Pipeline: Karten finden und entzerren
|
segment.py OpenCV-Pipeline: Karten finden und entzerren
|
||||||
extract.py Vision-Modell, strukturiertes JSON
|
extract.py Vision-Modell, strukturiertes JSON
|
||||||
imaging.py EXIF-Auswertung, Bildkonvertierung
|
|
||||||
duplicates.py Ähnliche Karten finden
|
duplicates.py Ähnliche Karten finden
|
||||||
|
imaging.py EXIF, Drehen, Zuschneiden
|
||||||
vcard.py vCard 3.0
|
vcard.py vCard 3.0
|
||||||
reminders.py Scheduler für die Erinnerungsmails
|
reminders.py Scheduler für die Erinnerungsmails
|
||||||
static/ PWA: HTML, CSS, JavaScript, Service Worker
|
static/ PWA: HTML, CSS, JavaScript, Service Worker
|
||||||
|
|||||||
18
app/db.py
18
app/db.py
@@ -92,6 +92,24 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
for name, kind in LATER_COLUMNS.items():
|
for name, kind in LATER_COLUMNS.items():
|
||||||
if name not in present:
|
if name not in present:
|
||||||
conn.execute(f"ALTER TABLE cards ADD COLUMN {name} {kind}")
|
conn.execute(f"ALTER TABLE cards ADD COLUMN {name} {kind}")
|
||||||
|
_split_legacy_names(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_legacy_names(conn: sqlite3.Connection) -> None:
|
||||||
|
"""full_name ist als Feld entfallen. Aeltere Datensaetze haben nur diesen
|
||||||
|
einen Namen - am letzten Leerzeichen trennen, damit sie nicht namenlos
|
||||||
|
dastehen. Die Spalte bleibt: SQLite baut zum Loeschen die Tabelle neu,
|
||||||
|
und der Gewinn waere ein paar Byte."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, full_name FROM cards "
|
||||||
|
"WHERE full_name IS NOT NULL AND first_name IS NULL AND last_name IS NULL"
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
first, _, last = row["full_name"].strip().rpartition(" ")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE cards SET first_name = ?, last_name = ? WHERE id = ?",
|
||||||
|
(first or None, last or None, row["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def query(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
|
def query(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ def find(card) -> list:
|
|||||||
"""Aehnliche Karten, neueste zuerst."""
|
"""Aehnliche Karten, neueste zuerst."""
|
||||||
others = db.query(
|
others = db.query(
|
||||||
"""
|
"""
|
||||||
SELECT id, full_name, first_name, last_name, company, email, phone, mobile,
|
SELECT id, first_name, last_name, company, email, phone, mobile,
|
||||||
occasion, captured_at, created_at
|
occasion, captured_at, created_at
|
||||||
FROM cards
|
FROM cards
|
||||||
WHERE id <> ? AND extract_status <> 'offen'
|
WHERE id <> ? AND extract_status <> 'offen'
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from . import config, imaging
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
FIELDS = [
|
FIELDS = [
|
||||||
"full_name", "first_name", "last_name", "job_title", "company", "department",
|
"first_name", "last_name", "job_title", "company", "department",
|
||||||
"email", "phone", "mobile", "fax", "website",
|
"email", "phone", "mobile", "fax", "website",
|
||||||
"street", "postal_code", "city", "country",
|
"street", "postal_code", "city", "country",
|
||||||
]
|
]
|
||||||
@@ -52,8 +52,8 @@ Regeln:
|
|||||||
- phone ist die Festnetznummer, mobile die Mobilnummer. Ist nur eine Nummer
|
- phone ist die Festnetznummer, mobile die Mobilnummer. Ist nur eine Nummer
|
||||||
ohne Kennzeichnung angegeben, gehoert sie in phone.
|
ohne Kennzeichnung angegeben, gehoert sie in phone.
|
||||||
- company ist der Firmenname, job_title die Funktion der Person.
|
- company ist der Firmenname, job_title die Funktion der Person.
|
||||||
- full_name ist der vollstaendige Name inklusive Titeln wie "Dr.",
|
- first_name ist der Vorname einschliesslich akademischer Titel ("Dr. Anna"),
|
||||||
first_name und last_name ohne Titel.
|
last_name der Nachname.
|
||||||
- website ohne Protokollpraefix.
|
- website ohne Protokollpraefix.
|
||||||
- Zeigt der Ausschnitt keine Visitenkarte (Tischkante, Notizzettel, leere
|
- Zeigt der Ausschnitt keine Visitenkarte (Tischkante, Notizzettel, leere
|
||||||
Flaeche), setze is_business_card auf false und alle Felder auf null.
|
Flaeche), setze is_business_card auf false und alle Felder auf null.
|
||||||
|
|||||||
@@ -80,6 +80,35 @@ def encode_jpeg(rgb_or_bgr: np.ndarray, quality: int = 88, bgr: bool = True) ->
|
|||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def edit_jpeg(raw: bytes, rotate: int = 0, angle: float = 0.0, crop: dict = None) -> bytes:
|
||||||
|
"""Drehen, ausrichten, zuschneiden - in dieser Reihenfolge.
|
||||||
|
|
||||||
|
Wird immer auf das unbearbeitete Original angewandt, nie auf ein schon
|
||||||
|
bearbeitetes Bild: sonst summieren sich Verluste bei jeder Korrektur.
|
||||||
|
"""
|
||||||
|
image = Image.open(io.BytesIO(raw))
|
||||||
|
if rotate % 360:
|
||||||
|
image = image.rotate(-rotate, expand=True)
|
||||||
|
if angle:
|
||||||
|
# Ohne expand: der Rahmen bleibt, die Ecken laufen weiss aus. Was
|
||||||
|
# dabei ueber den Rand geht, schneidet der Nutzer anschliessend weg.
|
||||||
|
image = image.rotate(
|
||||||
|
-angle, expand=False, resample=Image.BICUBIC, fillcolor=(255, 255, 255)
|
||||||
|
)
|
||||||
|
if crop:
|
||||||
|
width, height = image.size
|
||||||
|
left = max(0, round(crop["x"] * width))
|
||||||
|
top = max(0, round(crop["y"] * height))
|
||||||
|
right = min(width, round((crop["x"] + crop["w"]) * width))
|
||||||
|
bottom = min(height, round((crop["y"] + crop["h"]) * height))
|
||||||
|
if right - left > 10 and bottom - top > 10:
|
||||||
|
image = image.crop((left, top, right, bottom))
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
image.convert("RGB").save(buffer, format="JPEG", quality=90, optimize=True)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
def rotate_jpeg(raw: bytes, degrees: int) -> bytes:
|
def rotate_jpeg(raw: bytes, degrees: int) -> bytes:
|
||||||
"""Im Uhrzeigersinn drehen. Arbeitet auf dem fertigen JPEG, nicht auf dem
|
"""Im Uhrzeigersinn drehen. Arbeitet auf dem fertigen JPEG, nicht auf dem
|
||||||
grossen Rohbild - gebraucht wird das nur fuer einzelne Karten."""
|
grossen Rohbild - gebraucht wird das nur fuer einzelne Karten."""
|
||||||
|
|||||||
58
app/main.py
58
app/main.py
@@ -22,9 +22,9 @@ STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
|||||||
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
||||||
|
|
||||||
EDITABLE = [
|
EDITABLE = [
|
||||||
"full_name", "first_name", "last_name", "job_title", "company", "department",
|
"first_name", "last_name", "job_title", "company", "department",
|
||||||
"email", "phone", "mobile", "fax", "website",
|
"email", "phone", "mobile", "fax", "website",
|
||||||
"street", "postal_code", "city", "country", "notes", "occasion",
|
"street", "postal_code", "city", "country", "notes", "occasion", "place",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ def card_dict(row) -> dict:
|
|||||||
f"/api/cards/{card['id']}/image?side=back" if card.get("back_image_file") else None
|
f"/api/cards/{card['id']}/image?side=back" if card.get("back_image_file") else None
|
||||||
)
|
)
|
||||||
card["has_contact"] = any(
|
card["has_contact"] = any(
|
||||||
card.get(f) for f in ("full_name", "company", "email", "phone", "mobile")
|
card.get(f) for f in ("last_name", "company", "email", "phone", "mobile")
|
||||||
)
|
)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
|
|||||||
if q.strip():
|
if q.strip():
|
||||||
needle = f"%{q.strip()}%"
|
needle = f"%{q.strip()}%"
|
||||||
searchable = [
|
searchable = [
|
||||||
"full_name", "company", "job_title", "email", "phone", "mobile",
|
"first_name", "last_name", "company", "job_title", "email", "phone", "mobile",
|
||||||
"website", "city", "place", "notes", "department", "street", "occasion",
|
"website", "city", "place", "notes", "department", "street", "occasion",
|
||||||
]
|
]
|
||||||
where.append("(" + " OR ".join(f"{c} LIKE ?" for c in searchable) + ")")
|
where.append("(" + " OR ".join(f"{c} LIKE ?" for c in searchable) + ")")
|
||||||
@@ -265,7 +265,7 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
|
|||||||
elif filter == "unvollstaendig":
|
elif filter == "unvollstaendig":
|
||||||
where.append(
|
where.append(
|
||||||
"(extract_status = 'fehler' "
|
"(extract_status = 'fehler' "
|
||||||
"OR (extract_status = 'ok' AND full_name IS NULL AND company IS NULL))"
|
"OR (extract_status = 'ok' AND last_name IS NULL AND company IS NULL))"
|
||||||
)
|
)
|
||||||
elif filter == "erinnerung":
|
elif filter == "erinnerung":
|
||||||
where.append(
|
where.append(
|
||||||
@@ -340,6 +340,7 @@ def delete_card(card_id: str) -> dict:
|
|||||||
for name in (row["image_file"], row["back_image_file"]):
|
for name in (row["image_file"], row["back_image_file"]):
|
||||||
if name:
|
if name:
|
||||||
(config.IMAGE_DIR / name).unlink(missing_ok=True)
|
(config.IMAGE_DIR / name).unlink(missing_ok=True)
|
||||||
|
_original_of(name).unlink(missing_ok=True)
|
||||||
db.execute("DELETE FROM cards WHERE id = ?", (card_id,))
|
db.execute("DELETE FROM cards WHERE id = ?", (card_id,))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
@@ -354,6 +355,51 @@ def card_image(card_id: str, side: str = "front") -> FileResponse:
|
|||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
def _original_of(name: str) -> Path:
|
||||||
|
"""Unbearbeitete Fassung neben dem Bild. Damit bleibt jede Korrektur
|
||||||
|
zuruecknehmbar und wird nicht auf eine schon bearbeitete Datei gerechnet."""
|
||||||
|
return config.IMAGE_DIR / (name[:-4] + "-orig.jpg")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cards/{card_id}/edit", dependencies=[auth])
|
||||||
|
async def edit_image(card_id: str, request: Request) -> dict:
|
||||||
|
row = _card_or_404(card_id)
|
||||||
|
payload = await request.json()
|
||||||
|
side = payload.get("side", "front")
|
||||||
|
name = row["back_image_file"] if side == "back" else row["image_file"]
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
|
||||||
|
|
||||||
|
current = config.IMAGE_DIR / name
|
||||||
|
original = _original_of(name)
|
||||||
|
if not original.exists():
|
||||||
|
original.write_bytes(current.read_bytes())
|
||||||
|
|
||||||
|
if payload.get("reset"):
|
||||||
|
current.write_bytes(original.read_bytes())
|
||||||
|
return card_dict(_card_or_404(card_id))
|
||||||
|
|
||||||
|
rotate = int(payload.get("rotate") or 0)
|
||||||
|
if rotate % 90:
|
||||||
|
raise HTTPException(status_code=400, detail="Drehung nur in 90-Grad-Schritten")
|
||||||
|
angle = max(-20.0, min(20.0, float(payload.get("angle") or 0)))
|
||||||
|
|
||||||
|
crop = payload.get("crop")
|
||||||
|
if crop is not None:
|
||||||
|
try:
|
||||||
|
crop = {key: float(crop[key]) for key in ("x", "y", "w", "h")}
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Zuschnitt unvollstaendig") from exc
|
||||||
|
if crop["w"] <= 0.02 or crop["h"] <= 0.02:
|
||||||
|
raise HTTPException(status_code=400, detail="Zuschnitt zu klein")
|
||||||
|
|
||||||
|
edited = await asyncio.to_thread(
|
||||||
|
imaging.edit_jpeg, original.read_bytes(), rotate, angle, crop
|
||||||
|
)
|
||||||
|
current.write_bytes(edited)
|
||||||
|
return card_dict(_card_or_404(card_id))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/cards/{card_id}/back", dependencies=[auth])
|
@app.post("/api/cards/{card_id}/back", dependencies=[auth])
|
||||||
async def add_back(card_id: str, file: UploadFile) -> dict:
|
async def add_back(card_id: str, file: UploadFile) -> dict:
|
||||||
"""Rueckseite nachtragen. Sie wird ausgelesen, fuellt aber nur Felder, die
|
"""Rueckseite nachtragen. Sie wird ausgelesen, fuellt aber nur Felder, die
|
||||||
@@ -402,7 +448,7 @@ def download_vcard(card_id: str) -> Response:
|
|||||||
def list_reminders() -> dict:
|
def list_reminders() -> dict:
|
||||||
rows = db.query(
|
rows = db.query(
|
||||||
"""
|
"""
|
||||||
SELECT r.*, c.full_name, c.company
|
SELECT r.*, c.first_name, c.last_name, c.company
|
||||||
FROM reminders r JOIN cards c ON c.id = r.card_id
|
FROM reminders r JOIN cards c ON c.id = r.card_id
|
||||||
ORDER BY r.sent_at IS NOT NULL, r.due_at
|
ORDER BY r.sent_at IS NOT NULL, r.due_at
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ def _now() -> str:
|
|||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _name(row) -> str:
|
||||||
|
return " ".join(filter(None, (row["first_name"], row["last_name"])))
|
||||||
|
|
||||||
|
|
||||||
def _body(row) -> str:
|
def _body(row) -> str:
|
||||||
who = row["full_name"] or row["company"] or "Unbekannter Kontakt"
|
who = _name(row) or row["company"] or "Unbekannter Kontakt"
|
||||||
lines = [row["text"], "", "-" * 40, f"Kontakt: {who}"]
|
lines = [row["text"], "", "-" * 40, f"Kontakt: {who}"]
|
||||||
if row["company"] and row["full_name"]:
|
if row["company"] and _name(row):
|
||||||
lines.append(f"Firma: {row['company']}")
|
lines.append(f"Firma: {row['company']}")
|
||||||
if row["email"]:
|
if row["email"]:
|
||||||
lines.append(f"E-Mail: {row['email']}")
|
lines.append(f"E-Mail: {row['email']}")
|
||||||
@@ -41,7 +45,7 @@ def due_reminders() -> list:
|
|||||||
return db.query(
|
return db.query(
|
||||||
"""
|
"""
|
||||||
SELECT r.id, r.card_id, r.text, r.due_at,
|
SELECT r.id, r.card_id, r.text, r.due_at,
|
||||||
c.full_name, c.company, c.email, c.phone, c.mobile, c.place
|
c.first_name, c.last_name, c.company, c.email, c.phone, c.mobile, c.place
|
||||||
FROM reminders r
|
FROM reminders r
|
||||||
JOIN cards c ON c.id = r.card_id
|
JOIN cards c ON c.id = r.card_id
|
||||||
WHERE r.sent_at IS NULL AND r.due_at <= ?
|
WHERE r.sent_at IS NULL AND r.due_at <= ?
|
||||||
@@ -62,7 +66,7 @@ def process_due() -> int:
|
|||||||
|
|
||||||
sent = 0
|
sent = 0
|
||||||
for row in rows:
|
for row in rows:
|
||||||
who = row["full_name"] or row["company"] or "Kontakt"
|
who = _name(row) or row["company"] or "Kontakt"
|
||||||
try:
|
try:
|
||||||
mailer.send(f"Erinnerung: {who}", _body(row))
|
mailer.send(f"Erinnerung: {who}", _body(row))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ def _line(name: str, value) -> list:
|
|||||||
|
|
||||||
|
|
||||||
def _display_name(card: dict) -> str:
|
def _display_name(card: dict) -> str:
|
||||||
if card.get("full_name"):
|
|
||||||
return card["full_name"]
|
|
||||||
joined = " ".join(filter(None, (card.get("first_name"), card.get("last_name"))))
|
joined = " ".join(filter(None, (card.get("first_name"), card.get("last_name"))))
|
||||||
return joined or card.get("company") or "Unbenannt"
|
return joined or card.get("company") or "Unbenannt"
|
||||||
|
|
||||||
|
|||||||
356
static/app.js
356
static/app.js
@@ -3,7 +3,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const FIELD_LABELS = {
|
const FIELD_LABELS = {
|
||||||
full_name: "Name",
|
|
||||||
first_name: "Vorname",
|
first_name: "Vorname",
|
||||||
last_name: "Nachname",
|
last_name: "Nachname",
|
||||||
job_title: "Position",
|
job_title: "Position",
|
||||||
@@ -19,6 +18,7 @@
|
|||||||
city: "Ort",
|
city: "Ort",
|
||||||
country: "Land",
|
country: "Land",
|
||||||
occasion: "Anlass",
|
occasion: "Anlass",
|
||||||
|
place: "Scan-Ort",
|
||||||
};
|
};
|
||||||
const FIELD_ORDER = Object.keys(FIELD_LABELS);
|
const FIELD_ORDER = Object.keys(FIELD_LABELS);
|
||||||
|
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
karten: $("screen-cards"),
|
karten: $("screen-cards"),
|
||||||
karte: $("screen-detail"),
|
karte: $("screen-detail"),
|
||||||
erinnerungen: $("screen-reminders"),
|
erinnerungen: $("screen-reminders"),
|
||||||
|
mehr: $("screen-mehr"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const esc = (value) =>
|
const esc = (value) =>
|
||||||
@@ -103,11 +104,37 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const displayName = (card) =>
|
const displayName = (card) =>
|
||||||
card.full_name ||
|
|
||||||
[card.first_name, card.last_name].filter(Boolean).join(" ") ||
|
[card.first_name, card.last_name].filter(Boolean).join(" ") ||
|
||||||
card.company ||
|
card.company ||
|
||||||
"Ohne Namen";
|
"Ohne Namen";
|
||||||
|
|
||||||
|
const NAV_APPS = {
|
||||||
|
apple: {
|
||||||
|
label: "Apple Karten",
|
||||||
|
url: (query) => `https://maps.apple.com/?q=${encodeURIComponent(query)}`,
|
||||||
|
},
|
||||||
|
google: {
|
||||||
|
label: "Google Maps",
|
||||||
|
url: (query) =>
|
||||||
|
`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`,
|
||||||
|
},
|
||||||
|
osm: {
|
||||||
|
label: "OpenStreetMap",
|
||||||
|
url: (query) =>
|
||||||
|
`https://www.openstreetmap.org/search?query=${encodeURIComponent(query)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const navApp = () => (NAV_APPS[store.get("navApp")] ? store.get("navApp") : "apple");
|
||||||
|
|
||||||
|
const address = (card) =>
|
||||||
|
[card.street, [card.postal_code, card.city].filter(Boolean).join(" "), card.country]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const link = (href, text) =>
|
||||||
|
`<a href="${esc(href)}" rel="noopener">${esc(text)}</a>`;
|
||||||
|
|
||||||
const subtitle = (card) =>
|
const subtitle = (card) =>
|
||||||
[card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt";
|
[card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt";
|
||||||
|
|
||||||
@@ -115,11 +142,17 @@
|
|||||||
* genauer, deshalb blockiert das hier nichts und scheitert leise. */
|
* genauer, deshalb blockiert das hier nichts und scheitert leise. */
|
||||||
function browserPosition() {
|
function browserPosition() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!navigator.geolocation) return resolve(null);
|
if (!navigator.geolocation) return resolve({ error: "Gerät liefert keinen Standort" });
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => resolve(position.coords),
|
(position) => resolve({ coords: position.coords }),
|
||||||
() => resolve(null),
|
(error) =>
|
||||||
{ timeout: 6000, maximumAge: 300000 }
|
resolve({
|
||||||
|
error:
|
||||||
|
error.code === error.PERMISSION_DENIED
|
||||||
|
? "Standortfreigabe fehlt"
|
||||||
|
: "Standort nicht ermittelbar",
|
||||||
|
}),
|
||||||
|
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -173,6 +206,7 @@
|
|||||||
if (route === "karten") loadCards();
|
if (route === "karten") loadCards();
|
||||||
if (route === "karte") loadCard(state.cardId);
|
if (route === "karte") loadCard(state.cardId);
|
||||||
if (route === "erinnerungen") loadReminders();
|
if (route === "erinnerungen") loadReminders();
|
||||||
|
if (route === "mehr") renderSettings(state.session || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------- Scan
|
// ------------------------------------------------------------------- Scan
|
||||||
@@ -183,17 +217,21 @@
|
|||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("file", file);
|
body.append("file", file);
|
||||||
body.append("occasion", $("occasion-input").value.trim());
|
body.append("occasion", $("occasion-input").value.trim());
|
||||||
const coords = await browserPosition();
|
const position = await browserPosition();
|
||||||
if (coords) {
|
if (position.coords) {
|
||||||
body.append("lat", coords.latitude);
|
body.append("lat", position.coords.latitude);
|
||||||
body.append("lon", coords.longitude);
|
body.append("lon", position.coords.longitude);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await api("/api/scan", { method: "POST", body });
|
const result = await api("/api/scan", { method: "POST", body });
|
||||||
renderScanResult(result);
|
renderScanResult(result);
|
||||||
await refreshStats();
|
await refreshStats();
|
||||||
loadRecent();
|
loadRecent();
|
||||||
toast(`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst, wird gelesen`);
|
const ohneOrt = result.cards.every((card) => !card.place);
|
||||||
|
toast(
|
||||||
|
`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst` +
|
||||||
|
(ohneOrt && position.error ? ` · ohne Ort (${position.error})` : ", wird gelesen")
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error.message);
|
toast(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -306,12 +344,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** E-Mail, Web, Nummern und Adresse sind Handlungen, keine Zeichenketten. */
|
||||||
|
function fieldCell(card, field) {
|
||||||
|
const value = card[field];
|
||||||
|
if (field === "email") return link(`mailto:${value}`, value);
|
||||||
|
if (field === "website") {
|
||||||
|
return link(/^https?:\/\//i.test(value) ? value : `https://${value}`, value);
|
||||||
|
}
|
||||||
|
if (["phone", "mobile", "fax"].includes(field)) {
|
||||||
|
return link(`tel:${value.replace(/[^\d+]/g, "")}`, value);
|
||||||
|
}
|
||||||
|
return esc(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Straße, PLZ, Ort und Land stehen in der Tabelle als eine Adresszeile.
|
||||||
|
const DISPLAY_ORDER = [
|
||||||
|
"first_name", "last_name", "job_title", "company", "department",
|
||||||
|
"email", "phone", "mobile", "fax", "website", "address", "occasion", "place",
|
||||||
|
];
|
||||||
|
|
||||||
function dataTable(card) {
|
function dataTable(card) {
|
||||||
const rows = FIELD_ORDER.filter((field) => card[field])
|
const postal = address(card);
|
||||||
.map((field) => `<tr><th>${FIELD_LABELS[field]}</th><td>${esc(card[field])}</td></tr>`)
|
const rows = DISPLAY_ORDER.map((field) => {
|
||||||
.join("");
|
if (field === "address") {
|
||||||
return rows
|
if (!postal) return "";
|
||||||
? `<table class="data">${rows}</table>`
|
return `<tr><th>Adresse</th><td>${link(NAV_APPS[navApp()].url(postal), postal)}</td></tr>`;
|
||||||
|
}
|
||||||
|
if (!card[field]) return "";
|
||||||
|
return `<tr><th>${FIELD_LABELS[field]}</th><td>${fieldCell(card, field)}</td></tr>`;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
return rows.length
|
||||||
|
? `<table class="data">${rows.join("")}</table>`
|
||||||
: `<p class="hint">Keine Felder erkannt. Über „Bearbeiten“ nachtragen.</p>`;
|
: `<p class="hint">Keine Felder erkannt. Über „Bearbeiten“ nachtragen.</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +409,8 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const GEO_SOURCE = { exif: "aus dem Foto", browser: "vom Gerät" };
|
||||||
|
|
||||||
const QUICK_DATES = [
|
const QUICK_DATES = [
|
||||||
["In 3 Tagen", 3],
|
["In 3 Tagen", 3],
|
||||||
["In 1 Woche", 7],
|
["In 1 Woche", 7],
|
||||||
@@ -391,17 +457,23 @@
|
|||||||
<span class="display d-md">${esc(formatDate(card.captured_at || card.created_at))}</span>
|
<span class="display d-md">${esc(formatDate(card.captured_at || card.created_at))}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="figure">
|
<span class="figure">
|
||||||
<span class="label">Ort</span>
|
<span class="label">Ort${card.geo_source ? " · " + GEO_SOURCE[card.geo_source] : ""}</span>
|
||||||
<span class="display d-md">${esc(card.place || "Unbekannt")}</span>
|
<span class="display d-md">${esc(card.place || "Unbekannt")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${card.image_url
|
${card.image_url
|
||||||
? `<img class="card-image" src="${esc(card.image_url)}" alt="Vorderseite">`
|
? `<img class="card-image" src="${esc(card.image_url)}" alt="Vorderseite">
|
||||||
|
<button class="btn btn-quiet" data-edit-image="front">
|
||||||
|
Bild bearbeiten <span class="arrow">✎</span>
|
||||||
|
</button>`
|
||||||
: ""}
|
: ""}
|
||||||
${card.back_image_url
|
${card.back_image_url
|
||||||
? `<img class="card-image" src="${esc(card.back_image_url)}" alt="Rückseite">`
|
? `<img class="card-image" src="${esc(card.back_image_url)}" alt="Rückseite">
|
||||||
|
<button class="btn btn-quiet" data-edit-image="back">
|
||||||
|
Rückseite bearbeiten <span class="arrow">✎</span>
|
||||||
|
</button>`
|
||||||
: `<label class="btn btn-quiet" for="input-back">
|
: `<label class="btn btn-quiet" for="input-back">
|
||||||
Rückseite hinzufügen <span class="arrow">+</span>
|
Rückseite hinzufügen <span class="arrow">+</span>
|
||||||
<input id="input-back" type="file" accept="image/*" capture="environment" hidden>
|
<input id="input-back" type="file" accept="image/*" capture="environment" hidden>
|
||||||
@@ -554,6 +626,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- Einstellungen
|
||||||
|
|
||||||
|
function renderSettings(session) {
|
||||||
|
const current = navApp();
|
||||||
|
screens.mehr.innerHTML = `
|
||||||
|
<div class="section-label"><span class="label">Navigation</span></div>
|
||||||
|
<p class="hint">Womit Adressen geöffnet werden.</p>
|
||||||
|
<div class="chips" id="nav-app-chips">
|
||||||
|
${Object.entries(NAV_APPS)
|
||||||
|
.map(
|
||||||
|
([key, app]) =>
|
||||||
|
`<button class="chip" data-nav-app="${key}" aria-pressed="${key === current}">
|
||||||
|
${app.label}
|
||||||
|
</button>`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-label"><span class="label">Status</span></div>
|
||||||
|
<table class="data">
|
||||||
|
<tr><th>Auslesen</th><td>${session.extraction_available ? "aktiv" : "kein API-Schlüssel"}</td></tr>
|
||||||
|
<tr><th>Erinnerungsmails</th><td>${session.mail_configured ? "aktiv" : "kein SMTP"}</td></tr>
|
||||||
|
<tr><th>Anmeldung</th><td>${session.auth_required ? "mit Passwort" : "offen"}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${session.auth_required
|
||||||
|
? `<div class="section-label"><span class="label">Zugang</span></div>
|
||||||
|
<button class="btn btn-quiet" id="logout-button">
|
||||||
|
Abmelden <span class="arrow">→</span>
|
||||||
|
</button>`
|
||||||
|
: `<div class="section-label"><span class="label">Zugang</span></div>
|
||||||
|
<p class="hint">Ohne Passwort erreichbar – APP_PASSWORD in der .env setzen.</p>`}`;
|
||||||
|
|
||||||
|
const logout = $("logout-button");
|
||||||
|
if (logout) {
|
||||||
|
logout.addEventListener("click", async () => {
|
||||||
|
await api("/api/logout", { method: "POST" }).catch(() => {});
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ Erinnerungen
|
// ------------------------------------------------------------ Erinnerungen
|
||||||
|
|
||||||
async function loadReminders() {
|
async function loadReminders() {
|
||||||
@@ -607,12 +721,200 @@
|
|||||||
} catch (_) { /* Zähler sind nicht kritisch */ }
|
} catch (_) { /* Zähler sind nicht kritisch */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Bildbearbeitung
|
||||||
|
|
||||||
|
/** Zwei Zuschnitte hintereinander: der zweite bezieht sich auf das Ergebnis
|
||||||
|
* des ersten, der Server rechnet aber immer vom Original. Also verrechnen. */
|
||||||
|
function composeCrop(previous, next) {
|
||||||
|
if (!previous) return next;
|
||||||
|
return {
|
||||||
|
x: previous.x + next.x * previous.w,
|
||||||
|
y: previous.y + next.y * previous.h,
|
||||||
|
w: next.w * previous.w,
|
||||||
|
h: next.h * previous.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drehen, ausrichten, zuschneiden. Jede Korrektur wird auf dem Server vom
|
||||||
|
* unbearbeiteten Original gerechnet - deshalb hält der Editor den ganzen
|
||||||
|
* Parametersatz und schickt ihn jedes Mal komplett mit. */
|
||||||
|
function openEditor(cardId, side) {
|
||||||
|
const overlay = $("editor-overlay");
|
||||||
|
const source = `/api/cards/${cardId}/image${side === "back" ? "?side=back" : ""}`;
|
||||||
|
const params = { rotate: 0, angle: 0, crop: null };
|
||||||
|
let box = null;
|
||||||
|
let drag = null;
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="editor-panel">
|
||||||
|
<p class="label" style="margin-bottom:12px">
|
||||||
|
${side === "back" ? "Rückseite" : "Vorderseite"} bearbeiten
|
||||||
|
</p>
|
||||||
|
<div class="editor-stage" id="editor-stage"><img id="editor-image" alt=""></div>
|
||||||
|
<div class="editor-row">
|
||||||
|
<button class="chip" data-rotate="-90" type="button">↺ 90°</button>
|
||||||
|
<button class="chip" data-rotate="90" type="button">↻ 90°</button>
|
||||||
|
<button class="chip" id="editor-crop" type="button" disabled>Zuschneiden</button>
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="label">Ausrichten</span>
|
||||||
|
<input type="range" id="editor-angle" min="-20" max="20" step="0.5" value="0">
|
||||||
|
<output id="editor-angle-out">0°</output>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin-top:12px">
|
||||||
|
Zum Zuschneiden über dem Bild einen Rahmen aufziehen, Ecken zum Feinjustieren anfassen.
|
||||||
|
</p>
|
||||||
|
<div class="editor-row">
|
||||||
|
<button class="chip" id="editor-reset" type="button">Zurücksetzen</button>
|
||||||
|
<button class="chip" id="editor-done" type="button" aria-pressed="true">Fertig</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.hidden = false;
|
||||||
|
|
||||||
|
const stage = $("editor-stage");
|
||||||
|
const image = $("editor-image");
|
||||||
|
const cropButton = $("editor-crop");
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
image.src = `${source}${source.includes("?") ? "&" : "?"}t=${Date.now()}`;
|
||||||
|
};
|
||||||
|
reload();
|
||||||
|
|
||||||
|
function drawBox() {
|
||||||
|
const existing = stage.querySelector(".crop-box");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
cropButton.disabled = !box;
|
||||||
|
if (!box) return;
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.className = "crop-box";
|
||||||
|
node.style.left = `${box.x * 100}%`;
|
||||||
|
node.style.top = `${box.y * 100}%`;
|
||||||
|
node.style.width = `${box.w * 100}%`;
|
||||||
|
node.style.height = `${box.h * 100}%`;
|
||||||
|
node.innerHTML = ["nw", "ne", "se", "sw"]
|
||||||
|
.map((corner) => `<span class="crop-handle" data-corner="${corner}"></span>`)
|
||||||
|
.join("");
|
||||||
|
stage.appendChild(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(event) {
|
||||||
|
const bounds = stage.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width)),
|
||||||
|
y: Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.addEventListener("pointerdown", (event) => {
|
||||||
|
const handle = event.target.closest(".crop-handle");
|
||||||
|
const point = relative(event);
|
||||||
|
stage.setPointerCapture(event.pointerId);
|
||||||
|
if (handle) {
|
||||||
|
drag = { corner: handle.dataset.corner };
|
||||||
|
} else {
|
||||||
|
drag = { corner: null, origin: point };
|
||||||
|
box = { x: point.x, y: point.y, w: 0, h: 0 };
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
stage.addEventListener("pointermove", (event) => {
|
||||||
|
if (!drag || !box) return;
|
||||||
|
const point = relative(event);
|
||||||
|
if (drag.corner) {
|
||||||
|
const right = box.x + box.w;
|
||||||
|
const bottom = box.y + box.h;
|
||||||
|
if (drag.corner.includes("w")) { box.w = right - point.x; box.x = point.x; }
|
||||||
|
else { box.w = point.x - box.x; }
|
||||||
|
if (drag.corner.includes("n")) { box.h = bottom - point.y; box.y = point.y; }
|
||||||
|
else { box.h = point.y - box.y; }
|
||||||
|
if (box.w < 0) { box.x += box.w; box.w = -box.w; }
|
||||||
|
if (box.h < 0) { box.y += box.h; box.h = -box.h; }
|
||||||
|
} else {
|
||||||
|
box.x = Math.min(drag.origin.x, point.x);
|
||||||
|
box.y = Math.min(drag.origin.y, point.y);
|
||||||
|
box.w = Math.abs(point.x - drag.origin.x);
|
||||||
|
box.h = Math.abs(point.y - drag.origin.y);
|
||||||
|
}
|
||||||
|
drawBox();
|
||||||
|
});
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
drag = null;
|
||||||
|
if (box && (box.w < 0.03 || box.h < 0.03)) box = null;
|
||||||
|
drawBox();
|
||||||
|
};
|
||||||
|
stage.addEventListener("pointerup", endDrag);
|
||||||
|
stage.addEventListener("pointercancel", endDrag);
|
||||||
|
|
||||||
|
async function apply(extra = {}) {
|
||||||
|
try {
|
||||||
|
await api(`/api/cards/${cardId}/edit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ side, ...params, ...extra }),
|
||||||
|
});
|
||||||
|
reload();
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.querySelectorAll("[data-rotate]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
params.rotate = (params.rotate + Number(button.dataset.rotate) + 360) % 360;
|
||||||
|
// Der Zuschnitt bezog sich auf die alte Lage und gilt nicht mehr.
|
||||||
|
params.crop = null;
|
||||||
|
box = null;
|
||||||
|
drawBox();
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let angleTimer;
|
||||||
|
const angleInput = $("editor-angle");
|
||||||
|
angleInput.addEventListener("input", () => {
|
||||||
|
params.angle = Number(angleInput.value);
|
||||||
|
$("editor-angle-out").textContent = `${params.angle}°`;
|
||||||
|
clearTimeout(angleTimer);
|
||||||
|
angleTimer = setTimeout(apply, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
cropButton.addEventListener("click", () => {
|
||||||
|
if (!box) return;
|
||||||
|
params.crop = composeCrop(params.crop, box);
|
||||||
|
box = null;
|
||||||
|
drawBox();
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("editor-reset").addEventListener("click", () => {
|
||||||
|
params.rotate = 0;
|
||||||
|
params.angle = 0;
|
||||||
|
params.crop = null;
|
||||||
|
box = null;
|
||||||
|
angleInput.value = 0;
|
||||||
|
$("editor-angle-out").textContent = "0°";
|
||||||
|
drawBox();
|
||||||
|
apply({ reset: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
$("editor-done").addEventListener("click", () => {
|
||||||
|
overlay.hidden = true;
|
||||||
|
overlay.innerHTML = "";
|
||||||
|
loadCard(cardId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- Ereignisse
|
// ------------------------------------------------------------- Ereignisse
|
||||||
|
|
||||||
document.addEventListener("click", (event) => {
|
document.addEventListener("click", (event) => {
|
||||||
const shareButton = event.target.closest("[data-share]");
|
const shareButton = event.target.closest("[data-share]");
|
||||||
if (shareButton) return shareCard(shareButton.dataset.share);
|
if (shareButton) return shareCard(shareButton.dataset.share);
|
||||||
|
|
||||||
|
const editImage = event.target.closest("[data-edit-image]");
|
||||||
|
if (editImage) return openEditor(state.cardId, editImage.dataset.editImage);
|
||||||
|
|
||||||
const cardButton = event.target.closest("[data-card]");
|
const cardButton = event.target.closest("[data-card]");
|
||||||
if (cardButton) return go("karte", cardButton.dataset.card);
|
if (cardButton) return go("karte", cardButton.dataset.card);
|
||||||
|
|
||||||
@@ -632,6 +934,15 @@
|
|||||||
const tab = event.target.closest(".tab");
|
const tab = event.target.closest(".tab");
|
||||||
if (tab) return go(tab.dataset.route);
|
if (tab) return go(tab.dataset.route);
|
||||||
|
|
||||||
|
const navChip = event.target.closest("[data-nav-app]");
|
||||||
|
if (navChip) {
|
||||||
|
store.set("navApp", navChip.dataset.navApp);
|
||||||
|
document.querySelectorAll("[data-nav-app]").forEach((c) =>
|
||||||
|
c.setAttribute("aria-pressed", c === navChip ? "true" : "false")
|
||||||
|
);
|
||||||
|
return toast(`Adressen öffnen in ${NAV_APPS[navChip.dataset.navApp].label}`);
|
||||||
|
}
|
||||||
|
|
||||||
const chip = event.target.closest("#filter-chips .chip");
|
const chip = event.target.closest("#filter-chips .chip");
|
||||||
if (chip) {
|
if (chip) {
|
||||||
state.filter = chip.dataset.filter;
|
state.filter = chip.dataset.filter;
|
||||||
@@ -703,11 +1014,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("logout-button").addEventListener("click", async () => {
|
|
||||||
await api("/api/logout", { method: "POST" }).catch(() => {});
|
|
||||||
location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("hashchange", applyRoute);
|
window.addEventListener("hashchange", applyRoute);
|
||||||
|
|
||||||
// --------------------------------------------------------------- Start
|
// --------------------------------------------------------------- Start
|
||||||
@@ -719,7 +1025,7 @@
|
|||||||
$("login-overlay").hidden = false;
|
$("login-overlay").hidden = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$("logout-button").hidden = !session.auth_required;
|
state.session = session;
|
||||||
if (!session.extraction_available) {
|
if (!session.extraction_available) {
|
||||||
toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten.");
|
toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,21 +16,6 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header class="topbar">
|
|
||||||
<span class="mark" aria-hidden="true">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2">
|
|
||||||
<rect x="2" y="5" width="20" height="14"></rect>
|
|
||||||
<path d="M6 10h5M6 14h8"></path>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<span class="wordmark">
|
|
||||||
<span class="display d-md">Visitenkarten</span>
|
|
||||||
<span class="label">Scanner</span>
|
|
||||||
</span>
|
|
||||||
<span class="spacer"></span>
|
|
||||||
<button class="chip" id="logout-button" hidden>Abmelden</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<!-- ------------------------------------------------------------- Scan -->
|
<!-- ------------------------------------------------------------- Scan -->
|
||||||
<section class="screen stack" id="screen-scan">
|
<section class="screen stack" id="screen-scan">
|
||||||
@@ -87,6 +72,9 @@
|
|||||||
<!-- ------------------------------------------------------------ Detail -->
|
<!-- ------------------------------------------------------------ Detail -->
|
||||||
<section class="screen stack" id="screen-detail" hidden></section>
|
<section class="screen stack" id="screen-detail" hidden></section>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------------ Einstellungen -->
|
||||||
|
<section class="screen stack" id="screen-mehr" hidden></section>
|
||||||
|
|
||||||
<!-- ------------------------------------------------------ Erinnerungen -->
|
<!-- ------------------------------------------------------ Erinnerungen -->
|
||||||
<section class="screen stack" id="screen-reminders" hidden>
|
<section class="screen stack" id="screen-reminders" hidden>
|
||||||
<div class="section-label"><span class="label">Erinnerungen</span></div>
|
<div class="section-label"><span class="label">Erinnerungen</span></div>
|
||||||
@@ -124,6 +112,15 @@
|
|||||||
<span class="tab-label">Termine</span>
|
<span class="tab-label">Termine</span>
|
||||||
<span class="tab-badge mono-num" id="tab-count-reminders" hidden>0</span>
|
<span class="tab-badge mono-num" id="tab-count-reminders" hidden>0</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="tab" role="tab" data-route="mehr" aria-selected="false">
|
||||||
|
<span class="tab-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||||
|
<circle cx="12" cy="12" r="3.2"></circle>
|
||||||
|
<path d="M12 3v2.4M12 18.6V21M3 12h2.4M18.6 12H21M5.6 5.6l1.7 1.7M16.7 16.7l1.7 1.7M18.4 5.6l-1.7 1.7M7.3 16.7l-1.7 1.7"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="tab-label">Mehr</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,6 +146,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ---------------------------------------------------------- Bildbearbeitung -->
|
||||||
|
<div class="overlay" id="editor-overlay" hidden></div>
|
||||||
|
|
||||||
<div class="toast" id="toast" hidden></div>
|
<div class="toast" id="toast" hidden></div>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
|
|||||||
129
static/style.css
129
static/style.css
@@ -42,7 +42,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Bedienelemente verhalten sich wie in einer App, nicht wie Fliesstext. */
|
/* Bedienelemente verhalten sich wie in einer App, nicht wie Fliesstext. */
|
||||||
.btn, .chip, .tab, .row, .badge, .pill, .action, .label, .topbar {
|
.btn, .chip, .tab, .row, .badge, .pill, .action, .label {
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@@ -95,38 +95,12 @@ a { color: inherit; }
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 20;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 13px var(--pad);
|
|
||||||
padding-top: calc(13px + env(safe-area-inset-top));
|
|
||||||
background: var(--bg);
|
|
||||||
border-bottom: 3px solid var(--ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
flex: 0 0 32px;
|
|
||||||
background: var(--ink);
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mark svg { width: 19px; height: 19px; }
|
|
||||||
|
|
||||||
.wordmark { line-height: 1; }
|
|
||||||
.wordmark .d-md { display: block; }
|
|
||||||
.wordmark .label { color: var(--ink-3); margin-top: 4px; }
|
|
||||||
|
|
||||||
.topbar .spacer { margin-left: auto; }
|
|
||||||
|
|
||||||
main { flex: 1; display: flex; flex-direction: column; }
|
main { flex: 1; display: flex; flex-direction: column; }
|
||||||
.screen { flex: 1; padding: var(--pad) var(--pad) 30px; animation: rise 0.18s ease-out; }
|
.screen {
|
||||||
|
flex: 1;
|
||||||
|
padding: calc(var(--pad) + env(safe-area-inset-top)) var(--pad) 30px;
|
||||||
|
animation: rise 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes rise {
|
@keyframes rise {
|
||||||
from { opacity: 0; transform: translateY(7px); }
|
from { opacity: 0; transform: translateY(7px); }
|
||||||
@@ -414,6 +388,19 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
|
|
||||||
.data td { font-weight: 600; word-break: break-word; }
|
.data td { font-weight: 600; word-break: break-word; }
|
||||||
|
|
||||||
|
/* Anrufen, schreiben, Route starten - direkt aus der Tabelle. */
|
||||||
|
.data td a {
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 2px solid var(--ink);
|
||||||
|
padding-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data td a::after {
|
||||||
|
content: " ↗";
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------------- Bilder */
|
/* ----------------------------------------------------------------------- Bilder */
|
||||||
|
|
||||||
.card-image {
|
.card-image {
|
||||||
@@ -504,6 +491,84 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
max-width: 380px;
|
max-width: 380px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Bildeditor */
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
align-content: start;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 3px solid var(--ink);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-stage {
|
||||||
|
position: relative;
|
||||||
|
background: var(--fill);
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-stage img { width: 100%; display: block; }
|
||||||
|
|
||||||
|
.crop-box {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid var(--bg);
|
||||||
|
outline: 9999px solid rgba(0, 0, 0, 0.55);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-handle[data-corner="nw"] { left: -13px; top: -13px; }
|
||||||
|
.crop-handle[data-corner="ne"] { right: -13px; top: -13px; }
|
||||||
|
.crop-handle[data-corner="se"] { right: -13px; bottom: -13px; }
|
||||||
|
.crop-handle[data-corner="sw"] { left: -13px; bottom: -13px; }
|
||||||
|
|
||||||
|
.editor-row { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.editor-row .chip { flex: 1; text-align: center; }
|
||||||
|
|
||||||
|
.slider-row { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
|
||||||
|
.slider-row .label { color: var(--ink-3); white-space: nowrap; }
|
||||||
|
.slider-row output { font-weight: 800; font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; }
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: var(--ink);
|
||||||
|
border: 3px solid var(--bg);
|
||||||
|
box-shadow: 0 0 0 1px var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--ink);
|
||||||
|
border: 3px solid var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
|
|||||||
@@ -75,10 +75,10 @@ def test_bearbeiten_und_suchen(client, scanned):
|
|||||||
|
|
||||||
patched = client.patch(
|
patched = client.patch(
|
||||||
f"/api/cards/{card_id}",
|
f"/api/cards/{card_id}",
|
||||||
json={"full_name": "Anna Berg", "company": "Berg & Partner", "notes": "Messe"},
|
json={"first_name": "Anna", "last_name": "Berg", "company": "Berg & Partner", "notes": "Messe"},
|
||||||
)
|
)
|
||||||
assert patched.status_code == 200
|
assert patched.status_code == 200
|
||||||
assert patched.json()["full_name"] == "Anna Berg"
|
assert patched.json()["last_name"] == "Berg"
|
||||||
|
|
||||||
hits = client.get("/api/cards", params={"q": "berg"}).json()["cards"]
|
hits = client.get("/api/cards", params={"q": "berg"}).json()["cards"]
|
||||||
assert [c["id"] for c in hits] == [card_id]
|
assert [c["id"] for c in hits] == [card_id]
|
||||||
@@ -97,7 +97,7 @@ def test_unbekannte_felder_werden_abgewiesen(client, scanned):
|
|||||||
|
|
||||||
def test_vcard_download_markiert_die_karte_als_exportiert(client, scanned):
|
def test_vcard_download_markiert_die_karte_als_exportiert(client, scanned):
|
||||||
card_id = scanned["cards"][1]["id"]
|
card_id = scanned["cards"][1]["id"]
|
||||||
client.patch(f"/api/cards/{card_id}", json={"full_name": "Bea Kurz"})
|
client.patch(f"/api/cards/{card_id}", json={"first_name": "Bea", "last_name": "Kurz"})
|
||||||
|
|
||||||
response = client.get(f"/vcf/{card_id}")
|
response = client.get(f"/vcf/{card_id}")
|
||||||
|
|
||||||
@@ -143,7 +143,7 @@ def test_erinnerung_ohne_datum_wird_abgewiesen(client, scanned):
|
|||||||
|
|
||||||
def test_faellige_erinnerung_wird_verschickt(client, scanned, monkeypatch):
|
def test_faellige_erinnerung_wird_verschickt(client, scanned, monkeypatch):
|
||||||
card_id = scanned["cards"][3]["id"]
|
card_id = scanned["cards"][3]["id"]
|
||||||
client.patch(f"/api/cards/{card_id}", json={"full_name": "Carl Spät"})
|
client.patch(f"/api/cards/{card_id}", json={"first_name": "Carl", "last_name": "Spät"})
|
||||||
past = datetime.now(timezone.utc) - timedelta(minutes=5)
|
past = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||||
client.post(
|
client.post(
|
||||||
f"/api/cards/{card_id}/reminders",
|
f"/api/cards/{card_id}/reminders",
|
||||||
@@ -254,13 +254,14 @@ def test_handeintrag_ueberlebt_den_hintergrundlauf(client, monkeypatch):
|
|||||||
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
).json()["cards"][0]["id"]
|
).json()["cards"][0]["id"]
|
||||||
|
|
||||||
client.patch(f"/api/cards/{card_id}", json={"full_name": "Von Hand", "notes": "Messe"})
|
client.patch(f"/api/cards/{card_id}", json={"first_name": "Von", "last_name": "Hand", "notes": "Messe"})
|
||||||
|
|
||||||
monkeypatch.undo()
|
monkeypatch.undo()
|
||||||
warte_bis_gelesen(client, [card_id])
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
|
||||||
card = client.get(f"/api/cards/{card_id}").json()
|
card = client.get(f"/api/cards/{card_id}").json()
|
||||||
assert card["full_name"] == "Von Hand"
|
assert card["first_name"] == "Von"
|
||||||
|
assert card["last_name"] == "Hand"
|
||||||
assert card["notes"] == "Messe"
|
assert card["notes"] == "Messe"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ def leere_tabelle():
|
|||||||
|
|
||||||
|
|
||||||
def test_gleiche_email_wird_gefunden():
|
def test_gleiche_email_wird_gefunden():
|
||||||
_card(full_name="Anna Berg", email="A.Berg@Firma.de")
|
_card(first_name="Anna", last_name="Berg", email="A.Berg@Firma.de")
|
||||||
neu = _card(full_name="A. Berg", email="a.berg@firma.de")
|
neu = _card(first_name="A.", last_name="Berg", email="a.berg@firma.de")
|
||||||
|
|
||||||
treffer = duplicates.find(neu)
|
treffer = duplicates.find(neu)
|
||||||
|
|
||||||
@@ -38,8 +38,8 @@ def test_gleiche_email_wird_gefunden():
|
|||||||
|
|
||||||
|
|
||||||
def test_telefonnummer_in_anderer_schreibweise():
|
def test_telefonnummer_in_anderer_schreibweise():
|
||||||
_card(full_name="Anna Berg", phone="+49 511 1234560")
|
_card(first_name="Anna", last_name="Berg", phone="+49 511 1234560")
|
||||||
neu = _card(full_name="Anna B.", mobile="0511/1234560")
|
neu = _card(first_name="Anna", last_name="B.", mobile="0511/1234560")
|
||||||
|
|
||||||
treffer = duplicates.find(neu)
|
treffer = duplicates.find(neu)
|
||||||
|
|
||||||
@@ -62,27 +62,27 @@ def test_gleicher_nachname_allein_reicht_nicht():
|
|||||||
|
|
||||||
def test_kurze_nummern_werden_nicht_verglichen():
|
def test_kurze_nummern_werden_nicht_verglichen():
|
||||||
# Eine dreistellige Durchwahl darf keine Dublette erzeugen.
|
# Eine dreistellige Durchwahl darf keine Dublette erzeugen.
|
||||||
_card(full_name="Anna Berg", phone="123")
|
_card(first_name="Anna", last_name="Berg", phone="123")
|
||||||
neu = _card(full_name="Bea Kurz", phone="123")
|
neu = _card(first_name="Bea", last_name="Kurz", phone="123")
|
||||||
|
|
||||||
assert duplicates.find(neu) == []
|
assert duplicates.find(neu) == []
|
||||||
|
|
||||||
|
|
||||||
def test_leere_felder_erzeugen_keine_treffer():
|
def test_leere_felder_erzeugen_keine_treffer():
|
||||||
_card(full_name="Anna Berg")
|
_card(first_name="Anna", last_name="Berg")
|
||||||
neu = _card(full_name="Bea Kurz")
|
neu = _card(first_name="Bea", last_name="Kurz")
|
||||||
|
|
||||||
assert duplicates.find(neu) == []
|
assert duplicates.find(neu) == []
|
||||||
|
|
||||||
|
|
||||||
def test_die_karte_selbst_ist_keine_dublette():
|
def test_die_karte_selbst_ist_keine_dublette():
|
||||||
karte = _card(full_name="Anna Berg", email="a@b.de")
|
karte = _card(first_name="Anna", last_name="Berg", email="a@b.de")
|
||||||
|
|
||||||
assert duplicates.find(karte) == []
|
assert duplicates.find(karte) == []
|
||||||
|
|
||||||
|
|
||||||
def test_noch_nicht_gelesene_karten_bleiben_aussen_vor():
|
def test_noch_nicht_gelesene_karten_bleiben_aussen_vor():
|
||||||
_card(full_name="Anna Berg", email="a@b.de", extract_status="offen")
|
_card(first_name="Anna", last_name="Berg", email="a@b.de", extract_status="offen")
|
||||||
neu = _card(full_name="Anna Berg", email="a@b.de")
|
neu = _card(first_name="Anna", last_name="Berg", email="a@b.de")
|
||||||
|
|
||||||
assert duplicates.find(neu) == []
|
assert duplicates.find(neu) == []
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from app import vcard
|
from app import vcard
|
||||||
|
|
||||||
BASE = {
|
BASE = {
|
||||||
"full_name": "Dr. Anna Berg",
|
"first_name": "Dr. Anna",
|
||||||
"first_name": "Anna",
|
|
||||||
"last_name": "Berg",
|
"last_name": "Berg",
|
||||||
"job_title": "Geschäftsführerin",
|
"job_title": "Geschäftsführerin",
|
||||||
"company": "Berg & Partner GmbH",
|
"company": "Berg & Partner GmbH",
|
||||||
@@ -28,7 +27,7 @@ def test_enthaelt_die_pflichtzeilen():
|
|||||||
assert output.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n")
|
assert output.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n")
|
||||||
assert output.endswith("END:VCARD\r\n")
|
assert output.endswith("END:VCARD\r\n")
|
||||||
assert "FN:Dr. Anna Berg" in output
|
assert "FN:Dr. Anna Berg" in output
|
||||||
assert "N:Berg;Anna;;;" in output
|
assert "N:Berg;Dr. Anna;;;" in output
|
||||||
assert "TEL;TYPE=CELL:+49 170 1234567" in output
|
assert "TEL;TYPE=CELL:+49 170 1234567" in output
|
||||||
|
|
||||||
|
|
||||||
@@ -52,6 +51,13 @@ def test_leere_karte_bekommt_trotzdem_einen_namen():
|
|||||||
assert "FN:Unbenannt" in output
|
assert "FN:Unbenannt" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_setzt_sich_aus_vor_und_nachname_zusammen():
|
||||||
|
output = vcard.build({"first_name": "Dr. Anna", "last_name": "Berg"})
|
||||||
|
|
||||||
|
assert "FN:Dr. Anna Berg" in output
|
||||||
|
assert "N:Berg;Dr. Anna;;;" in output
|
||||||
|
|
||||||
|
|
||||||
def test_firma_ersetzt_den_namen_wenn_keiner_erkannt_wurde():
|
def test_firma_ersetzt_den_namen_wenn_keiner_erkannt_wurde():
|
||||||
output = vcard.build({"company": "Berg & Partner GmbH"})
|
output = vcard.build({"company": "Berg & Partner GmbH"})
|
||||||
|
|
||||||
@@ -61,4 +67,4 @@ def test_firma_ersetzt_den_namen_wenn_keiner_erkannt_wurde():
|
|||||||
def test_dateiname_ist_dateisystemtauglich():
|
def test_dateiname_ist_dateisystemtauglich():
|
||||||
assert vcard.filename(BASE) == "Dr-Anna-Berg.vcf"
|
assert vcard.filename(BASE) == "Dr-Anna-Berg.vcf"
|
||||||
assert vcard.filename({}) == "Unbenannt.vcf"
|
assert vcard.filename({}) == "Unbenannt.vcf"
|
||||||
assert vcard.filename({"full_name": "?!"}) == "kontakt.vcf"
|
assert vcard.filename({"first_name": "?!"}) == "kontakt.vcf"
|
||||||
|
|||||||
Reference in New Issue
Block a user