1 Commits
v1.1.0 ... main

Author SHA1 Message Date
Lucas Orth
1e8918e0cd Bildeditor, klickbare Felder, Einstellungen; Kopfzeile und Namensfeld raus
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>
2026-09-06 15:19:10 +02:00
14 changed files with 592 additions and 110 deletions

View File

@@ -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

View File

@@ -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]:

View File

@@ -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'

View File

@@ -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.

View File

@@ -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."""

View File

@@ -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
""" """

View File

@@ -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:

View File

@@ -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"

View File

@@ -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.");
} }

View File

@@ -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>

View File

@@ -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;

View File

@@ -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"

View File

@@ -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) == []

View File

@@ -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"