Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf0a397597 |
29
README.md
29
README.md
@@ -31,16 +31,32 @@ der Browserstandort wäre dann das Wohnzimmer statt der Messe. Die Koordinaten
|
|||||||
löst Nominatim in einen Ortsnamen auf. Fehlen EXIF-Daten, greift der
|
löst Nominatim in einen Ortsnamen auf. Fehlen EXIF-Daten, greift der
|
||||||
Browserstandort als Rückfallebene.
|
Browserstandort als Rückfallebene.
|
||||||
|
|
||||||
**Übersicht.** Alle Karten mit Volltextsuche über Name, Firma, Ort und Notiz.
|
**Anlass.** Ein Feld auf der Startseite („Hannover Messe 2026“) gilt für alle
|
||||||
Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung“.
|
folgenden Scans und wird im Browser gemerkt. Verlässlicher als der Ortsname aus
|
||||||
|
den Koordinaten und die eigentliche Ordnung über den Bestand.
|
||||||
|
|
||||||
**Export.** Ein Tippen auf *In Kontakte speichern* liefert eine vCard 3.0, die
|
**Dubletten.** Auf Messen trifft man Leute wieder. Die Detailansicht zeigt
|
||||||
iOS direkt in die Kontakte-App übernimmt. Die Karte gilt danach als exportiert.
|
Karten, die dieselbe Person meinen könnten – gleiche E-Mail, gleiche
|
||||||
Bewusst einzeln: ein Sammelexport schiebt zwanzig Kontakte ungeprüft ins
|
Telefonnummer (Schreibweise egal, verglichen werden die letzten acht Ziffern)
|
||||||
Adressbuch.
|
oder gleicher Nachname bei gleicher Firma.
|
||||||
|
|
||||||
|
**Rückseite.** Nachträglich ein zweites Bild zur Karte aufnehmen. Es wird
|
||||||
|
ausgelesen, füllt aber nur Felder, die die Vorderseite offen gelassen hat –
|
||||||
|
dort steht oft nur die Mobilnummer.
|
||||||
|
|
||||||
|
**Übersicht.** Alle Karten mit Volltextsuche über Name, Firma, Anlass, Ort und
|
||||||
|
Notiz. Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung“.
|
||||||
|
|
||||||
|
**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
|
||||||
|
System-Teilenblatt – per AirDrop, Mail oder Nachricht, ohne Umweg über den
|
||||||
|
Download. Bewusst einzeln: ein Sammelexport schiebt zwanzig Kontakte ungeprüft
|
||||||
|
ins Adressbuch.
|
||||||
|
|
||||||
**Notizen und Erinnerungen.** Freitext pro Karte. Erinnerungen mit Termin gehen
|
**Notizen und Erinnerungen.** Freitext pro Karte. Erinnerungen mit Termin gehen
|
||||||
als Mail raus; verpasste Termine holt der Scheduler beim nächsten Start nach.
|
als Mail raus; verpasste Termine holt der Scheduler beim nächsten Start nach.
|
||||||
|
Schnellwahl für die üblichen Abstände, damit das Datumsfeld selten gebraucht
|
||||||
|
wird.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -109,6 +125,7 @@ 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
|
imaging.py EXIF-Auswertung, Bildkonvertierung
|
||||||
|
duplicates.py Ähnliche Karten finden
|
||||||
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
|
||||||
|
|||||||
19
app/db.py
19
app/db.py
@@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS cards (
|
|||||||
captured_at TEXT,
|
captured_at TEXT,
|
||||||
batch_id TEXT,
|
batch_id TEXT,
|
||||||
image_file TEXT,
|
image_file TEXT,
|
||||||
|
back_image_file TEXT,
|
||||||
source TEXT NOT NULL DEFAULT 'batch',
|
source TEXT NOT NULL DEFAULT 'batch',
|
||||||
|
occasion TEXT,
|
||||||
full_name TEXT,
|
full_name TEXT,
|
||||||
first_name TEXT,
|
first_name TEXT,
|
||||||
last_name TEXT,
|
last_name TEXT,
|
||||||
@@ -71,10 +73,27 @@ def connect() -> sqlite3.Connection:
|
|||||||
_conn.execute("PRAGMA journal_mode=WAL")
|
_conn.execute("PRAGMA journal_mode=WAL")
|
||||||
_conn.execute("PRAGMA foreign_keys=ON")
|
_conn.execute("PRAGMA foreign_keys=ON")
|
||||||
_conn.executescript(SCHEMA)
|
_conn.executescript(SCHEMA)
|
||||||
|
_migrate(_conn)
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
return _conn
|
return _conn
|
||||||
|
|
||||||
|
|
||||||
|
# Spalten, die nach dem ersten Deploy dazugekommen sind. CREATE TABLE IF NOT
|
||||||
|
# EXISTS ruehrt eine bestehende Tabelle nicht an - ohne das hier liefe die
|
||||||
|
# App auf dem Server mit dem alten Schema weiter.
|
||||||
|
LATER_COLUMNS = {
|
||||||
|
"back_image_file": "TEXT",
|
||||||
|
"occasion": "TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(conn: sqlite3.Connection) -> None:
|
||||||
|
present = {row["name"] for row in conn.execute("PRAGMA table_info(cards)")}
|
||||||
|
for name, kind in LATER_COLUMNS.items():
|
||||||
|
if name not in present:
|
||||||
|
conn.execute(f"ALTER TABLE cards ADD COLUMN {name} {kind}")
|
||||||
|
|
||||||
|
|
||||||
def query(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
|
def query(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
|
||||||
with _lock:
|
with _lock:
|
||||||
return connect().execute(sql, tuple(params)).fetchall()
|
return connect().execute(sql, tuple(params)).fetchall()
|
||||||
|
|||||||
55
app/duplicates.py
Normal file
55
app/duplicates.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Karten finden, die dieselbe Person meinen.
|
||||||
|
|
||||||
|
Auf Messen trifft man Leute wieder, und dieselbe Karte landet ein zweites Mal
|
||||||
|
im Stapel. Verglichen wird nicht der ganze Datensatz, sondern das, was eine
|
||||||
|
Person eindeutig macht - in dieser Reihenfolge, weil die Aussagekraft faellt.
|
||||||
|
"""
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
|
def _phone_key(value) -> str:
|
||||||
|
"""Nur die letzten acht Ziffern. Damit passen +49 511 123456,
|
||||||
|
0049511123456 und 0511/123456 aufeinander."""
|
||||||
|
digits = "".join(character for character in (value or "") if character.isdigit())
|
||||||
|
return digits[-8:] if len(digits) >= 8 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _phone_keys(row) -> set:
|
||||||
|
return {_phone_key(row["phone"]), _phone_key(row["mobile"])} - {""}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value) -> str:
|
||||||
|
return (value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _match(card, other) -> str:
|
||||||
|
email = _clean(card["email"])
|
||||||
|
if email and _clean(other["email"]) == email:
|
||||||
|
return "gleiche E-Mail"
|
||||||
|
if _phone_keys(card) & _phone_keys(other):
|
||||||
|
return "gleiche Telefonnummer"
|
||||||
|
last_name, company = _clean(card["last_name"]), _clean(card["company"])
|
||||||
|
if last_name and company:
|
||||||
|
if _clean(other["last_name"]) == last_name and _clean(other["company"]) == company:
|
||||||
|
return "gleicher Name und dieselbe Firma"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def find(card) -> list:
|
||||||
|
"""Aehnliche Karten, neueste zuerst."""
|
||||||
|
others = db.query(
|
||||||
|
"""
|
||||||
|
SELECT id, full_name, first_name, last_name, company, email, phone, mobile,
|
||||||
|
occasion, captured_at, created_at
|
||||||
|
FROM cards
|
||||||
|
WHERE id <> ? AND extract_status <> 'offen'
|
||||||
|
ORDER BY COALESCE(captured_at, created_at) DESC
|
||||||
|
""",
|
||||||
|
(card["id"],),
|
||||||
|
)
|
||||||
|
hits = []
|
||||||
|
for other in others:
|
||||||
|
reason = _match(card, other)
|
||||||
|
if reason:
|
||||||
|
hits.append({**dict(other), "reason": reason})
|
||||||
|
return hits
|
||||||
69
app/main.py
69
app/main.py
@@ -10,7 +10,10 @@ from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, Up
|
|||||||
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import config, db, extract, geocode, imaging, reminders, security, segment, vcard
|
from . import (
|
||||||
|
config, db, duplicates, extract, geocode, imaging, reminders, security,
|
||||||
|
segment, vcard,
|
||||||
|
)
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
log = logging.getLogger("bcs")
|
log = logging.getLogger("bcs")
|
||||||
@@ -21,7 +24,7 @@ MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
|||||||
EDITABLE = [
|
EDITABLE = [
|
||||||
"full_name", "first_name", "last_name", "job_title", "company", "department",
|
"full_name", "first_name", "last_name", "job_title", "company", "department",
|
||||||
"email", "phone", "mobile", "fax", "website",
|
"email", "phone", "mobile", "fax", "website",
|
||||||
"street", "postal_code", "city", "country", "notes",
|
"street", "postal_code", "city", "country", "notes", "occasion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -59,6 +62,9 @@ def now_iso() -> str:
|
|||||||
def card_dict(row) -> dict:
|
def card_dict(row) -> dict:
|
||||||
card = dict(row)
|
card = dict(row)
|
||||||
card["image_url"] = f"/api/cards/{card['id']}/image" if card.get("image_file") else None
|
card["image_url"] = f"/api/cards/{card['id']}/image" if card.get("image_file") else None
|
||||||
|
card["back_image_url"] = (
|
||||||
|
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 ("full_name", "company", "email", "phone", "mobile")
|
||||||
)
|
)
|
||||||
@@ -129,18 +135,19 @@ def _process_photo(raw: bytes) -> tuple:
|
|||||||
_reading: set = set()
|
_reading: set = set()
|
||||||
|
|
||||||
|
|
||||||
def start_reading(card_ids: list) -> None:
|
def start_reading(card_ids: list, side: str = "front") -> None:
|
||||||
task = asyncio.create_task(_read_cards(card_ids))
|
task = asyncio.create_task(_read_cards(card_ids, side))
|
||||||
_reading.add(task)
|
_reading.add(task)
|
||||||
task.add_done_callback(_reading.discard)
|
task.add_done_callback(_reading.discard)
|
||||||
|
|
||||||
|
|
||||||
async def _read_cards(card_ids: list) -> None:
|
async def _read_cards(card_ids: list, side: str = "front") -> None:
|
||||||
"""Das Auslesen laeuft nach der Antwort weiter - der Nutzer sieht seine
|
"""Das Auslesen laeuft nach der Antwort weiter - der Nutzer sieht seine
|
||||||
Karten sofort, die Felder tropfen nach."""
|
Karten sofort, die Felder tropfen nach."""
|
||||||
|
column = "back_image_file" if side == "back" else "image_file"
|
||||||
rows = [db.query_one("SELECT * FROM cards WHERE id = ?", (i,)) for i in card_ids]
|
rows = [db.query_one("SELECT * FROM cards WHERE id = ?", (i,)) for i in card_ids]
|
||||||
rows = [row for row in rows if row is not None]
|
rows = [row for row in rows if row is not None and row[column]]
|
||||||
images = [(config.IMAGE_DIR / row["image_file"]).read_bytes() for row in rows]
|
images = [(config.IMAGE_DIR / row[column]).read_bytes() for row in rows]
|
||||||
|
|
||||||
results = await extract.extract_all(images)
|
results = await extract.extract_all(images)
|
||||||
|
|
||||||
@@ -149,7 +156,7 @@ async def _read_cards(card_ids: list) -> None:
|
|||||||
if rotation:
|
if rotation:
|
||||||
# Die Lage der Karte auf dem Tisch ist geometrisch nicht
|
# Die Lage der Karte auf dem Tisch ist geometrisch nicht
|
||||||
# bestimmbar - das Modell sagt uns, wie herum sie gehoert.
|
# bestimmbar - das Modell sagt uns, wie herum sie gehoert.
|
||||||
path = config.IMAGE_DIR / row["image_file"]
|
path = config.IMAGE_DIR / row[column]
|
||||||
path.write_bytes(
|
path.write_bytes(
|
||||||
await asyncio.to_thread(imaging.rotate_jpeg, path.read_bytes(), rotation)
|
await asyncio.to_thread(imaging.rotate_jpeg, path.read_bytes(), rotation)
|
||||||
)
|
)
|
||||||
@@ -176,6 +183,7 @@ async def scan(
|
|||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
lat: float = Form(None),
|
lat: float = Form(None),
|
||||||
lon: float = Form(None),
|
lon: float = Form(None),
|
||||||
|
occasion: str = Form(""),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -219,6 +227,7 @@ async def scan(
|
|||||||
"lon": meta["lon"],
|
"lon": meta["lon"],
|
||||||
"place": place,
|
"place": place,
|
||||||
"geo_source": geo_source,
|
"geo_source": geo_source,
|
||||||
|
"occasion": occasion.strip() or None,
|
||||||
"extract_status": "offen",
|
"extract_status": "offen",
|
||||||
}
|
}
|
||||||
names = ", ".join(columns)
|
names = ", ".join(columns)
|
||||||
@@ -247,7 +256,7 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
|
|||||||
needle = f"%{q.strip()}%"
|
needle = f"%{q.strip()}%"
|
||||||
searchable = [
|
searchable = [
|
||||||
"full_name", "company", "job_title", "email", "phone", "mobile",
|
"full_name", "company", "job_title", "email", "phone", "mobile",
|
||||||
"website", "city", "place", "notes", "department", "street",
|
"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) + ")")
|
||||||
params += [needle] * len(searchable)
|
params += [needle] * len(searchable)
|
||||||
@@ -305,6 +314,7 @@ def get_card(card_id: str) -> dict:
|
|||||||
"SELECT * FROM reminders WHERE card_id = ? ORDER BY due_at", (card_id,)
|
"SELECT * FROM reminders WHERE card_id = ? ORDER BY due_at", (card_id,)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
card["duplicates"] = duplicates.find(card)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
@@ -327,21 +337,52 @@ async def update_card(card_id: str, request: Request) -> dict:
|
|||||||
@app.delete("/api/cards/{card_id}", dependencies=[auth])
|
@app.delete("/api/cards/{card_id}", dependencies=[auth])
|
||||||
def delete_card(card_id: str) -> dict:
|
def delete_card(card_id: str) -> dict:
|
||||||
row = _card_or_404(card_id)
|
row = _card_or_404(card_id)
|
||||||
if row["image_file"]:
|
for name in (row["image_file"], row["back_image_file"]):
|
||||||
(config.IMAGE_DIR / row["image_file"]).unlink(missing_ok=True)
|
if name:
|
||||||
|
(config.IMAGE_DIR / 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}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cards/{card_id}/image", dependencies=[auth])
|
@app.get("/api/cards/{card_id}/image", dependencies=[auth])
|
||||||
def card_image(card_id: str) -> FileResponse:
|
def card_image(card_id: str, side: str = "front") -> FileResponse:
|
||||||
row = _card_or_404(card_id)
|
row = _card_or_404(card_id)
|
||||||
path = config.IMAGE_DIR / (row["image_file"] or "")
|
name = row["back_image_file"] if side == "back" else row["image_file"]
|
||||||
if not row["image_file"] or not path.exists():
|
path = config.IMAGE_DIR / (name or "")
|
||||||
|
if not name or not path.exists():
|
||||||
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
|
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cards/{card_id}/back", dependencies=[auth])
|
||||||
|
async def add_back(card_id: str, file: UploadFile) -> dict:
|
||||||
|
"""Rueckseite nachtragen. Sie wird ausgelesen, fuellt aber nur Felder, die
|
||||||
|
die Vorderseite offen gelassen hat - dort steht oft nur die Mobilnummer."""
|
||||||
|
row = _card_or_404(card_id)
|
||||||
|
raw = await file.read()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail="Leere Datei")
|
||||||
|
if len(raw) > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Foto ist groesser als 30 MB")
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, encoded, _ = await asyncio.to_thread(_process_photo, raw)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Rueckseite konnte nicht verarbeitet werden")
|
||||||
|
raise HTTPException(status_code=400, detail=f"Bild nicht lesbar: {exc}") from exc
|
||||||
|
if not encoded:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Karte im Bild gefunden")
|
||||||
|
|
||||||
|
name = f"{card_id}-back.jpg"
|
||||||
|
(config.IMAGE_DIR / name).write_bytes(encoded[0])
|
||||||
|
db.execute(
|
||||||
|
"UPDATE cards SET back_image_file = ?, extract_status = 'offen' WHERE id = ?",
|
||||||
|
(name, card_id),
|
||||||
|
)
|
||||||
|
start_reading([card_id], side="back")
|
||||||
|
return card_dict(_card_or_404(card_id))
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------- Export
|
# ---------------------------------------------------------------------------- Export
|
||||||
|
|
||||||
@app.get("/vcf/{card_id}", dependencies=[auth])
|
@app.get("/vcf/{card_id}", dependencies=[auth])
|
||||||
|
|||||||
299
static/app.js
299
static/app.js
@@ -18,6 +18,7 @@
|
|||||||
postal_code: "PLZ",
|
postal_code: "PLZ",
|
||||||
city: "Ort",
|
city: "Ort",
|
||||||
country: "Land",
|
country: "Land",
|
||||||
|
occasion: "Anlass",
|
||||||
};
|
};
|
||||||
const FIELD_ORDER = Object.keys(FIELD_LABELS);
|
const FIELD_ORDER = Object.keys(FIELD_LABELS);
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
query: "",
|
query: "",
|
||||||
filter: "alle",
|
filter: "alle",
|
||||||
cards: [],
|
cards: [],
|
||||||
stats: { total: 0, offen: 0, erinnerungen: 0 },
|
stats: { total: 0, offen: 0, erinnerungen: 0, laeuft: 0 },
|
||||||
editing: false,
|
editing: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,6 +45,15 @@
|
|||||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
})[c]);
|
})[c]);
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
get(key) {
|
||||||
|
try { return localStorage.getItem(key) || ""; } catch (_) { return ""; }
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
try { localStorage.setItem(key, value); } catch (_) { /* privater Modus */ }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Helfer
|
// ------------------------------------------------------------------ Helfer
|
||||||
|
|
||||||
function toast(message) {
|
function toast(message) {
|
||||||
@@ -55,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function busy(on, text) {
|
function busy(on, text) {
|
||||||
$("busy-text").textContent = text || "Karten werden gelesen";
|
$("busy-text").textContent = text || "Karten werden freigestellt";
|
||||||
$("busy-overlay").hidden = !on;
|
$("busy-overlay").hidden = !on;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +108,9 @@
|
|||||||
card.company ||
|
card.company ||
|
||||||
"Ohne Namen";
|
"Ohne Namen";
|
||||||
|
|
||||||
|
const subtitle = (card) =>
|
||||||
|
[card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt";
|
||||||
|
|
||||||
/** Position des Browsers als Rückfallebene. Die EXIF-Daten des Fotos sind
|
/** Position des Browsers als Rückfallebene. Die EXIF-Daten des Fotos sind
|
||||||
* genauer, deshalb blockiert das hier nichts und scheitert leise. */
|
* genauer, deshalb blockiert das hier nichts und scheitert leise. */
|
||||||
function browserPosition() {
|
function browserPosition() {
|
||||||
@@ -111,24 +124,31 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zustand einer Karte als abgestufte Pillen: schwarz, was erledigt ist,
|
/** Zustand einer Karte als Pillen. Ob sie schon exportiert wurde, steht
|
||||||
* grau umrandet, was noch offen oder auffaellig ist. */
|
* bewusst nicht dabei - dafür gibt es den Filter. */
|
||||||
function cardTags(card) {
|
function cardTags(card) {
|
||||||
const tags = [];
|
const tags = [];
|
||||||
if (card.place) tags.push(["", card.place]);
|
if (card.occasion) tags.push(["pill-solid", card.occasion]);
|
||||||
if (card.extract_status === "offen") {
|
else if (card.place) tags.push(["", card.place]);
|
||||||
tags.push(["pill-solid", "wird gelesen"]);
|
if (card.extract_status === "offen") tags.push(["", "wird gelesen"]);
|
||||||
} else {
|
if (card.extract_status === "fehler") tags.push(["", "Lesefehler"]);
|
||||||
tags.push(
|
|
||||||
card.exported_at ? ["pill-solid", "exportiert"] : ["", "nicht exportiert"]
|
|
||||||
);
|
|
||||||
if (card.extract_status === "fehler") tags.push(["", "Lesefehler"]);
|
|
||||||
}
|
|
||||||
return tags
|
return tags
|
||||||
.map(([extra, text]) => `<span class="pill ${extra}">${esc(text)}</span>`)
|
.map(([extra, text]) => `<span class="pill ${extra}">${esc(text)}</span>`)
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cardRow(card) {
|
||||||
|
return `
|
||||||
|
<button class="row" data-card="${esc(card.id)}">
|
||||||
|
<span class="row-head">
|
||||||
|
<span class="row-title">${esc(displayName(card))}</span>
|
||||||
|
<span class="row-meta">${esc(formatDate(card.captured_at || card.created_at))}</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-sub">${esc(subtitle(card))}</span>
|
||||||
|
<span class="row-tags">${cardTags(card)}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------- Routing
|
// ----------------------------------------------------------------- Routing
|
||||||
|
|
||||||
function go(route, cardId) {
|
function go(route, cardId) {
|
||||||
@@ -143,11 +163,13 @@
|
|||||||
|
|
||||||
Object.entries(screens).forEach(([name, node]) => { node.hidden = name !== route; });
|
Object.entries(screens).forEach(([name, node]) => { node.hidden = name !== route; });
|
||||||
document.querySelectorAll(".tab").forEach((tab) => {
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
const active = tab.dataset.route === route || (route === "karte" && tab.dataset.route === "karten");
|
const active =
|
||||||
|
tab.dataset.route === route || (route === "karte" && tab.dataset.route === "karten");
|
||||||
tab.setAttribute("aria-selected", active ? "true" : "false");
|
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||||||
});
|
});
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
|
if (route === "scan") loadRecent();
|
||||||
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();
|
||||||
@@ -160,19 +182,18 @@
|
|||||||
busy(true, "Karten werden freigestellt");
|
busy(true, "Karten werden freigestellt");
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("file", file);
|
body.append("file", file);
|
||||||
|
body.append("occasion", $("occasion-input").value.trim());
|
||||||
const coords = await browserPosition();
|
const coords = await browserPosition();
|
||||||
if (coords) {
|
if (coords) {
|
||||||
body.append("lat", coords.latitude);
|
body.append("lat", coords.latitude);
|
||||||
body.append("lon", coords.longitude);
|
body.append("lon", coords.longitude);
|
||||||
}
|
}
|
||||||
busy(true, "Karten werden gelesen");
|
|
||||||
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();
|
||||||
toast(
|
loadRecent();
|
||||||
`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst, wird gelesen`
|
toast(`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst, wird gelesen`);
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error.message);
|
toast(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -181,31 +202,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderScanResult(result) {
|
function renderScanResult(result) {
|
||||||
const rows = result.cards
|
|
||||||
.map(
|
|
||||||
(card) => `
|
|
||||||
<button class="row" data-card="${esc(card.id)}">
|
|
||||||
<span class="row-head">
|
|
||||||
<span class="row-title">${esc(displayName(card))}</span>
|
|
||||||
<span class="row-meta">wird gelesen</span>
|
|
||||||
</span>
|
|
||||||
<span class="row-sub">${esc(card.company || card.email || "Zuschnitt gespeichert")}</span>
|
|
||||||
</button>`
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
$("scan-result").innerHTML = `
|
$("scan-result").innerHTML = `
|
||||||
<div class="section-label"><span class="label">Ergebnis</span></div>
|
<div class="section-label"><span class="label">Ergebnis</span></div>
|
||||||
<div class="strip" style="border-top:3px solid #000">
|
<div class="strip">
|
||||||
<span class="label">Gefundene Karten</span>
|
<span class="label">${result.count} Karte${result.count === 1 ? "" : "n"} gefunden</span>
|
||||||
<span class="display d-lg mono-num">${result.count}</span>
|
<span class="pill">wird gelesen</span>
|
||||||
</div>
|
</div>
|
||||||
${result.fallback
|
${result.fallback
|
||||||
? `<p class="hint" style="margin-top:10px">Kein Kartenrechteck erkannt – das ganze Foto
|
? `<p class="hint" style="margin-top:10px">Kein Kartenrechteck erkannt – das ganze Foto
|
||||||
wurde als eine Karte behandelt. Für einen Stapel hilft ein dunkler, einfarbiger
|
wurde als eine Karte behandelt. Für einen Stapel hilft ein dunkler, einfarbiger
|
||||||
Untergrund mit Abstand zwischen den Karten.</p>`
|
Untergrund mit Abstand zwischen den Karten.</p>`
|
||||||
: ""}
|
: ""}
|
||||||
<div class="rows" style="margin-top:14px">${rows}</div>`;
|
<div class="rows" style="margin-top:12px">
|
||||||
|
${result.cards.map(cardRow).join("")}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecent() {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/cards?filter=alle");
|
||||||
|
state.stats = data.stats;
|
||||||
|
updateCounters();
|
||||||
|
const recent = data.cards.slice(0, 3);
|
||||||
|
$("recent-list").innerHTML = recent.length
|
||||||
|
? `<div class="rows">${recent.map(cardRow).join("")}</div>
|
||||||
|
${data.cards.length > 3
|
||||||
|
? `<button class="btn btn-quiet" data-goto="karten" style="margin-top:12px">
|
||||||
|
Alle ${data.stats.total} Karten <span class="arrow">→</span>
|
||||||
|
</button>`
|
||||||
|
: ""}`
|
||||||
|
: `<div class="empty">
|
||||||
|
<p class="label">Noch nichts erfasst</p>
|
||||||
|
<p class="hint" style="margin:8px 0 0">Mach oben ein Foto – der Rest passiert von allein.</p>
|
||||||
|
</div>`;
|
||||||
|
schedulePoll();
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- Kartenliste
|
// ------------------------------------------------------------- Kartenliste
|
||||||
@@ -224,7 +257,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Das Auslesen laeuft auf dem Server weiter, nachdem der Scan geantwortet
|
/** Das Auslesen läuft auf dem Server weiter, nachdem der Scan geantwortet
|
||||||
* hat. Solange noch etwas offen ist, holen wir den Stand nach. */
|
* hat. Solange noch etwas offen ist, holen wir den Stand nach. */
|
||||||
function schedulePoll() {
|
function schedulePoll() {
|
||||||
clearTimeout(schedulePoll.timer);
|
clearTimeout(schedulePoll.timer);
|
||||||
@@ -232,6 +265,7 @@
|
|||||||
schedulePoll.timer = setTimeout(() => {
|
schedulePoll.timer = setTimeout(() => {
|
||||||
if (state.route === "karten") loadCards();
|
if (state.route === "karten") loadCards();
|
||||||
else if (state.route === "karte") loadCard(state.cardId);
|
else if (state.route === "karte") loadCard(state.cardId);
|
||||||
|
else if (state.route === "scan") loadRecent();
|
||||||
else refreshStats();
|
else refreshStats();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
@@ -254,23 +288,7 @@
|
|||||||
<span class="label">${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"}</span>
|
<span class="label">${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"}</span>
|
||||||
${state.stats.laeuft ? `<span class="pill">${state.stats.laeuft} werden gelesen</span>` : ""}
|
${state.stats.laeuft ? `<span class="pill">${state.stats.laeuft} werden gelesen</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="rows">
|
<div class="rows">${state.cards.map(cardRow).join("")}</div>`;
|
||||||
${state.cards
|
|
||||||
.map(
|
|
||||||
(card) => `
|
|
||||||
<button class="row" data-card="${esc(card.id)}">
|
|
||||||
<span class="row-head">
|
|
||||||
<span class="row-title">${esc(displayName(card))}</span>
|
|
||||||
<span class="row-meta">${esc(formatDate(card.captured_at || card.created_at))}</span>
|
|
||||||
</span>
|
|
||||||
<span class="row-sub">
|
|
||||||
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
|
|
||||||
</span>
|
|
||||||
<span class="row-tags">${cardTags(card)}</span>
|
|
||||||
</button>`
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------- Kartendetail
|
// ----------------------------------------------------------- Kartendetail
|
||||||
@@ -280,7 +298,7 @@
|
|||||||
const card = await api(`/api/cards/${cardId}`);
|
const card = await api(`/api/cards/${cardId}`);
|
||||||
renderCard(card);
|
renderCard(card);
|
||||||
if (card.extract_status === "offen") {
|
if (card.extract_status === "offen") {
|
||||||
state.stats = { ...state.stats, laeuft: 1 };
|
state.stats = { ...state.stats, laeuft: Math.max(1, state.stats.laeuft) };
|
||||||
schedulePoll();
|
schedulePoll();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -290,10 +308,7 @@
|
|||||||
|
|
||||||
function dataTable(card) {
|
function dataTable(card) {
|
||||||
const rows = FIELD_ORDER.filter((field) => card[field])
|
const rows = FIELD_ORDER.filter((field) => card[field])
|
||||||
.map(
|
.map((field) => `<tr><th>${FIELD_LABELS[field]}</th><td>${esc(card[field])}</td></tr>`)
|
||||||
(field) =>
|
|
||||||
`<tr><th>${FIELD_LABELS[field]}</th><td>${esc(card[field])}</td></tr>`
|
|
||||||
)
|
|
||||||
.join("");
|
.join("");
|
||||||
return rows
|
return rows
|
||||||
? `<table class="data">${rows}</table>`
|
? `<table class="data">${rows}</table>`
|
||||||
@@ -310,18 +325,47 @@
|
|||||||
).join("")}</div>`;
|
).join("")}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCard(card) {
|
function duplicateBlock(card) {
|
||||||
const meta = [];
|
if (!card.duplicates || !card.duplicates.length) return "";
|
||||||
if (card.place) meta.push(esc(card.place));
|
return `
|
||||||
if (card.lat != null) meta.push(`${card.lat.toFixed(4)}, ${card.lon.toFixed(4)}`);
|
<div class="section-label"><span class="label">Schon einmal erfasst?</span></div>
|
||||||
|
<div class="rows">
|
||||||
|
${card.duplicates
|
||||||
|
.map(
|
||||||
|
(other) => `
|
||||||
|
<button class="row" data-card="${esc(other.id)}">
|
||||||
|
<span class="row-head">
|
||||||
|
<span class="row-title">${esc(displayName(other))}</span>
|
||||||
|
<span class="row-meta">${esc(formatDate(other.captured_at || other.created_at))}</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-sub">${esc(other.reason)}${other.occasion ? " · " + esc(other.occasion) : ""}</span>
|
||||||
|
</button>`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QUICK_DATES = [
|
||||||
|
["In 3 Tagen", 3],
|
||||||
|
["In 1 Woche", 7],
|
||||||
|
["In 2 Wochen", 14],
|
||||||
|
["In 1 Monat", 30],
|
||||||
|
];
|
||||||
|
|
||||||
|
function localInputValue(date) {
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||||
|
`T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(card) {
|
||||||
const reminders = card.reminders.length
|
const reminders = card.reminders.length
|
||||||
? `<div class="rows">${card.reminders
|
? `<div class="rows">${card.reminders
|
||||||
.map(
|
.map(
|
||||||
(reminder) => `
|
(reminder) => `
|
||||||
<div class="row" style="cursor:default">
|
<div class="row" style="cursor:default">
|
||||||
<span class="row-head">
|
<span class="row-head">
|
||||||
<span class="row-title" style="font-size:14px">${esc(reminder.text)}</span>
|
<span class="row-title" style="font-size:15px">${esc(reminder.text)}</span>
|
||||||
<button class="badge" data-delete-reminder="${esc(reminder.id)}">Löschen</button>
|
<button class="badge" data-delete-reminder="${esc(reminder.id)}">Löschen</button>
|
||||||
</span>
|
</span>
|
||||||
<span class="row-sub">
|
<span class="row-sub">
|
||||||
@@ -335,14 +379,12 @@
|
|||||||
: `<p class="hint">Keine Erinnerung gesetzt.</p>`;
|
: `<p class="hint">Keine Erinnerung gesetzt.</p>`;
|
||||||
|
|
||||||
screens.karte.innerHTML = `
|
screens.karte.innerHTML = `
|
||||||
<button class="chip" data-back>← Zurück</button>
|
<button class="chip" data-back>← Karten</button>
|
||||||
|
|
||||||
<div class="block-invert">
|
<div class="block-invert">
|
||||||
<p class="label">${card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"}</p>
|
<p class="label">${esc(card.occasion || (card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"))}</p>
|
||||||
<h1 class="display d-lg" style="margin-top:8px">${esc(displayName(card))}</h1>
|
<h1 class="display d-lg" style="margin-top:8px">${esc(displayName(card))}</h1>
|
||||||
<p class="muted" style="margin:6px 0 0">
|
<p class="muted" style="margin:6px 0 0">${esc(subtitle(card))}</p>
|
||||||
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
|
|
||||||
</p>
|
|
||||||
<div class="figures">
|
<div class="figures">
|
||||||
<span class="figure">
|
<span class="figure">
|
||||||
<span class="label">Erfasst</span>
|
<span class="label">Erfasst</span>
|
||||||
@@ -350,18 +392,27 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="figure">
|
<span class="figure">
|
||||||
<span class="label">Ort</span>
|
<span class="label">Ort</span>
|
||||||
<span class="display d-md">${meta.length ? esc(meta[0]) : "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="Visitenkarte">`
|
? `<img class="card-image" src="${esc(card.image_url)}" alt="Vorderseite">`
|
||||||
: ""}
|
: ""}
|
||||||
|
${card.back_image_url
|
||||||
|
? `<img class="card-image" src="${esc(card.back_image_url)}" alt="Rückseite">`
|
||||||
|
: `<label class="btn btn-quiet" for="input-back">
|
||||||
|
Rückseite hinzufügen <span class="arrow">+</span>
|
||||||
|
<input id="input-back" type="file" accept="image/*" capture="environment" hidden>
|
||||||
|
</label>`}
|
||||||
|
|
||||||
<a class="btn btn-primary" href="/vcf/${esc(card.id)}" download>
|
<a class="btn btn-primary" href="/vcf/${esc(card.id)}" download>
|
||||||
In Kontakte speichern <span class="arrow">→</span>
|
In Kontakte speichern <span class="arrow">→</span>
|
||||||
</a>
|
</a>
|
||||||
|
<button class="btn" data-share="${esc(card.id)}">
|
||||||
|
Als vCard teilen <span class="arrow">↗</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
${card.extract_status === "offen"
|
${card.extract_status === "offen"
|
||||||
? `<p class="hint">Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.</p>`
|
? `<p class="hint">Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.</p>`
|
||||||
@@ -370,9 +421,9 @@
|
|||||||
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
|
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
|
||||||
: ""}
|
: ""}
|
||||||
|
|
||||||
<div class="section-label">
|
${duplicateBlock(card)}
|
||||||
<span class="label">Daten</span>
|
|
||||||
</div>
|
<div class="section-label"><span class="label">Daten</span></div>
|
||||||
<button class="chip" data-toggle-edit style="margin-bottom:10px">
|
<button class="chip" data-toggle-edit style="margin-bottom:10px">
|
||||||
${state.editing ? "Fertig" : "Bearbeiten"}
|
${state.editing ? "Fertig" : "Bearbeiten"}
|
||||||
</button>
|
</button>
|
||||||
@@ -390,7 +441,12 @@
|
|||||||
<span class="label">Text</span>
|
<span class="label">Text</span>
|
||||||
<input name="text" placeholder="Angebot nachfassen" required>
|
<input name="text" placeholder="Angebot nachfassen" required>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<div class="chips" style="margin-top:12px">
|
||||||
|
${QUICK_DATES.map(
|
||||||
|
([text, days]) => `<button class="chip" type="button" data-quick="${days}">${text}</button>`
|
||||||
|
).join("")}
|
||||||
|
</div>
|
||||||
|
<label class="field" style="margin-top:12px">
|
||||||
<span class="label">Termin</span>
|
<span class="label">Termin</span>
|
||||||
<input name="due" type="datetime-local" required>
|
<input name="due" type="datetime-local" required>
|
||||||
</label>
|
</label>
|
||||||
@@ -417,9 +473,39 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("reminder-form").addEventListener("submit", async (event) => {
|
const backInput = $("input-back");
|
||||||
|
if (backInput) {
|
||||||
|
backInput.addEventListener("change", async (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
busy(true, "Rückseite wird gelesen");
|
||||||
|
try {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
await api(`/api/cards/${card.id}/back`, { method: "POST", body });
|
||||||
|
toast("Rückseite hinzugefügt");
|
||||||
|
loadCard(card.id);
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
} finally {
|
||||||
|
busy(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = $("reminder-form");
|
||||||
|
form.querySelectorAll("[data-quick]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + Number(button.dataset.quick));
|
||||||
|
date.setHours(9, 0, 0, 0);
|
||||||
|
form.elements.due.value = localInputValue(date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.currentTarget;
|
|
||||||
const due = form.elements.due.value;
|
const due = form.elements.due.value;
|
||||||
if (!due) return;
|
if (!due) return;
|
||||||
try {
|
try {
|
||||||
@@ -440,6 +526,34 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Teilen über das System-Teilenblatt. Damit landet die vCard per AirDrop,
|
||||||
|
* Mail oder Nachricht beim Gegenüber, ohne Umweg über den Download. */
|
||||||
|
async function shareCard(cardId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/vcf/${cardId}`, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) throw new Error("vCard konnte nicht geladen werden");
|
||||||
|
const disposition = response.headers.get("content-disposition") || "";
|
||||||
|
const match = disposition.match(/filename="([^"]+)"/);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], match ? match[1] : "kontakt.vcf", {
|
||||||
|
type: "text/vcard",
|
||||||
|
});
|
||||||
|
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||||
|
await navigator.share({ files: [file] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Kein Teilenblatt (Desktop, ältere Browser): dann eben herunterladen.
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = file.name;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== "AbortError") toast(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ Erinnerungen
|
// ------------------------------------------------------------ Erinnerungen
|
||||||
|
|
||||||
async function loadReminders() {
|
async function loadReminders() {
|
||||||
@@ -473,11 +587,15 @@
|
|||||||
|
|
||||||
// -------------------------------------------------------------- Zähler
|
// -------------------------------------------------------------- Zähler
|
||||||
|
|
||||||
|
function badge(id, value) {
|
||||||
|
const node = $(id);
|
||||||
|
node.textContent = value;
|
||||||
|
node.hidden = !value;
|
||||||
|
}
|
||||||
|
|
||||||
function updateCounters() {
|
function updateCounters() {
|
||||||
$("figure-total").textContent = state.stats.total;
|
badge("tab-count-cards", state.stats.total);
|
||||||
$("figure-open").textContent = state.stats.offen;
|
badge("tab-count-reminders", state.stats.erinnerungen);
|
||||||
$("tab-count-cards").textContent = state.stats.total;
|
|
||||||
$("tab-count-reminders").textContent = state.stats.erinnerungen;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStats() {
|
async function refreshStats() {
|
||||||
@@ -492,9 +610,15 @@
|
|||||||
// ------------------------------------------------------------- Ereignisse
|
// ------------------------------------------------------------- Ereignisse
|
||||||
|
|
||||||
document.addEventListener("click", (event) => {
|
document.addEventListener("click", (event) => {
|
||||||
|
const shareButton = event.target.closest("[data-share]");
|
||||||
|
if (shareButton) return shareCard(shareButton.dataset.share);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
|
const goto = event.target.closest("[data-goto]");
|
||||||
|
if (goto) return go(goto.dataset.goto);
|
||||||
|
|
||||||
if (event.target.closest("[data-back]")) {
|
if (event.target.closest("[data-back]")) {
|
||||||
state.editing = false;
|
state.editing = false;
|
||||||
return go("karten");
|
return go("karten");
|
||||||
@@ -547,6 +671,12 @@
|
|||||||
loadCards();
|
loadCards();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Der Anlass gilt für eine ganze Messe, nicht für einen Scan.
|
||||||
|
$("occasion-input").value = store.get("occasion");
|
||||||
|
$("occasion-input").addEventListener("change", (event) => {
|
||||||
|
store.set("occasion", event.target.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
["input-camera", "input-library"].forEach((id) => {
|
["input-camera", "input-library"].forEach((id) => {
|
||||||
$(id).addEventListener("change", (event) => {
|
$(id).addEventListener("change", (event) => {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
@@ -560,10 +690,10 @@
|
|||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("password", $("login-password").value);
|
body.append("password", $("login-password").value);
|
||||||
try {
|
try {
|
||||||
await fetch("/api/login", { method: "POST", body, credentials: "same-origin" })
|
const response = await fetch("/api/login", {
|
||||||
.then((response) => {
|
method: "POST", body, credentials: "same-origin",
|
||||||
if (!response.ok) throw new Error("Passwort stimmt nicht");
|
});
|
||||||
});
|
if (!response.ok) throw new Error("Passwort stimmt nicht");
|
||||||
$("login-overlay").hidden = true;
|
$("login-overlay").hidden = true;
|
||||||
$("login-error").hidden = true;
|
$("login-error").hidden = true;
|
||||||
boot();
|
boot();
|
||||||
@@ -593,7 +723,6 @@
|
|||||||
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.");
|
||||||
}
|
}
|
||||||
await refreshStats();
|
|
||||||
applyRoute();
|
applyRoute();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast("Server nicht erreichbar");
|
toast("Server nicht erreichbar");
|
||||||
|
|||||||
@@ -34,46 +34,44 @@
|
|||||||
<main>
|
<main>
|
||||||
<!-- ------------------------------------------------------------- Scan -->
|
<!-- ------------------------------------------------------------- Scan -->
|
||||||
<section class="screen stack" id="screen-scan">
|
<section class="screen stack" id="screen-scan">
|
||||||
<div class="block-invert">
|
<label class="field">
|
||||||
<p class="label">Stapel oder Einzelkarte</p>
|
<span class="label">Anlass · gilt für die nächsten Scans</span>
|
||||||
<h1 class="display d-xl" style="margin-top:8px">Karten<br>erfassen</h1>
|
<input id="occasion-input" placeholder="z. B. Hannover Messe 2026"
|
||||||
<p class="muted" style="margin:12px 0 0">
|
autocomplete="off" enterkeyhint="done">
|
||||||
Karten nebeneinander auf einen dunklen, einfarbigen Untergrund legen,
|
</label>
|
||||||
Handy parallel darüber halten. Ein Foto genügt für den ganzen Tisch.
|
|
||||||
</p>
|
|
||||||
<div class="figures">
|
|
||||||
<span class="figure">
|
|
||||||
<span class="label">Erfasst</span>
|
|
||||||
<span class="display d-lg mono-num" id="figure-total">0</span>
|
|
||||||
</span>
|
|
||||||
<span class="figure">
|
|
||||||
<span class="label">Nicht exportiert</span>
|
|
||||||
<span class="display d-lg mono-num" id="figure-open">0</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="btn btn-primary" for="input-camera">
|
<label class="action" for="input-camera">
|
||||||
Foto aufnehmen <span class="arrow">→</span>
|
<span class="action-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M3 7h3l1.5-2h9L18 7h3v12H3z"></path>
|
||||||
|
<circle cx="12" cy="13" r="3.6"></circle>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="action-body">
|
||||||
|
<span class="display d-lg">Foto aufnehmen</span>
|
||||||
|
<span class="action-hint">
|
||||||
|
Karten nebeneinander auf einen dunklen Untergrund legen.
|
||||||
|
Ein Foto reicht für den ganzen Tisch.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<input id="input-camera" type="file" accept="image/*" capture="environment" hidden>
|
<input id="input-camera" type="file" accept="image/*" capture="environment" hidden>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="btn btn-quiet" for="input-library">
|
<label class="btn btn-quiet" for="input-library">
|
||||||
Aus Fotos wählen <span class="arrow">↗</span>
|
Aus Fotos wählen <span class="arrow">↗</span>
|
||||||
<input id="input-library" type="file" accept="image/*" hidden>
|
<input id="input-library" type="file" accept="image/*" hidden>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p class="hint">
|
|
||||||
Die Kamera liefert die volle Auflösung – bei zwanzig Karten auf einem Foto
|
|
||||||
ist das der Unterschied zwischen lesbar und nicht lesbar.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div id="scan-result"></div>
|
<div id="scan-result"></div>
|
||||||
|
|
||||||
|
<div class="section-label"><span class="label">Zuletzt erfasst</span></div>
|
||||||
|
<div id="recent-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ------------------------------------------------------------ Karten -->
|
<!-- ------------------------------------------------------------ Karten -->
|
||||||
<section class="screen stack" id="screen-cards" hidden>
|
<section class="screen stack" id="screen-cards" hidden>
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<input id="search-input" type="search" placeholder="Name, Firma, Ort, Notiz …"
|
<input id="search-input" type="search" placeholder="Name, Firma, Anlass, Notiz …"
|
||||||
autocomplete="off" enterkeyhint="search">
|
autocomplete="off" enterkeyhint="search">
|
||||||
<button id="search-clear" type="button" aria-label="Suche leeren">×</button>
|
<button id="search-clear" type="button" aria-label="Suche leeren">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,12 +95,34 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<nav class="tabs" role="tablist">
|
<nav class="tabs" role="tablist">
|
||||||
<button class="tab" role="tab" data-route="scan" aria-selected="true">Scan</button>
|
<button class="tab" role="tab" data-route="scan" aria-selected="true">
|
||||||
|
<span class="tab-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||||
|
<path d="M3 7h3l1.5-2h9L18 7h3v12H3z"></path>
|
||||||
|
<circle cx="12" cy="13" r="3.4"></circle>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="tab-label">Scannen</span>
|
||||||
|
</button>
|
||||||
<button class="tab" role="tab" data-route="karten" aria-selected="false">
|
<button class="tab" role="tab" data-route="karten" aria-selected="false">
|
||||||
<span class="count mono-num" id="tab-count-cards">0</span>Karten
|
<span class="tab-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||||
|
<rect x="3" y="6" width="18" height="13"></rect>
|
||||||
|
<path d="M7 11h5M7 15h8"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="tab-label">Karten</span>
|
||||||
|
<span class="tab-badge mono-num" id="tab-count-cards" hidden>0</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" role="tab" data-route="erinnerungen" aria-selected="false">
|
<button class="tab" role="tab" data-route="erinnerungen" aria-selected="false">
|
||||||
<span class="count mono-num" id="tab-count-reminders">0</span>Termine
|
<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="8.5"></circle>
|
||||||
|
<path d="M12 7v5.2l3.4 2"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="tab-label">Termine</span>
|
||||||
|
<span class="tab-badge mono-num" id="tab-count-reminders" hidden>0</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +145,7 @@
|
|||||||
<div class="overlay" id="busy-overlay" hidden>
|
<div class="overlay" id="busy-overlay" hidden>
|
||||||
<div class="block-invert stack" style="text-align:center">
|
<div class="block-invert stack" style="text-align:center">
|
||||||
<div class="spinner" style="margin:0 auto"></div>
|
<div class="spinner" style="margin:0 auto"></div>
|
||||||
<p class="label" id="busy-text">Karten werden gelesen</p>
|
<p class="label" id="busy-text">Karten werden freigestellt</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--page);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -35,7 +35,17 @@ html, body {
|
|||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body { overflow-x: hidden; }
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bedienelemente verhalten sich wie in einer App, nicht wie Fliesstext. */
|
||||||
|
.btn, .chip, .tab, .row, .badge, .pill, .action, .label, .topbar {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
button, input, textarea, select {
|
button, input, textarea, select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -81,8 +91,6 @@ a { color: inherit; }
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-left: 1px solid var(--ink);
|
|
||||||
border-right: 1px solid var(--ink);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
@@ -95,6 +103,7 @@ a { color: inherit; }
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 13px var(--pad);
|
padding: 13px var(--pad);
|
||||||
|
padding-top: calc(13px + env(safe-area-inset-top));
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-bottom: 3px solid var(--ink);
|
border-bottom: 3px solid var(--ink);
|
||||||
}
|
}
|
||||||
@@ -117,7 +126,12 @@ a { color: inherit; }
|
|||||||
.topbar .spacer { margin-left: auto; }
|
.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; }
|
.screen { flex: 1; padding: var(--pad) var(--pad) 30px; animation: rise 0.18s ease-out; }
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from { opacity: 0; transform: translateY(7px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
.screen[hidden] { display: none; }
|
.screen[hidden] { display: none; }
|
||||||
|
|
||||||
.stack > * + * { margin-top: 12px; }
|
.stack > * + * { margin-top: 12px; }
|
||||||
@@ -191,6 +205,7 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 54px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 2px solid var(--ink);
|
border: 2px solid var(--ink);
|
||||||
@@ -203,6 +218,24 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Die eine Sache, die man auf der Startseite tut. Grosse Flaeche statt
|
||||||
|
Textzeile - auf dem Telefon zaehlt die Trefferflaeche, nicht die Kante. */
|
||||||
|
.action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px 18px;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:active { background: #1f1f1f; }
|
||||||
|
.action-icon { flex: 0 0 42px; color: var(--bg); }
|
||||||
|
.action-icon svg { width: 42px; height: 42px; display: block; }
|
||||||
|
.action-body { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.action-hint { font-size: 12.5px; line-height: 1.45; color: #a9a9a9; }
|
||||||
|
|
||||||
/* Was man hier tun soll. */
|
/* Was man hier tun soll. */
|
||||||
.btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
.btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
||||||
|
|
||||||
@@ -274,7 +307,7 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
|
|
||||||
/* Ungewaehlt zurueckgenommen, damit die Auswahl ueberhaupt auffaellt. */
|
/* Ungewaehlt zurueckgenommen, damit die Auswahl ueberhaupt auffaellt. */
|
||||||
.chip {
|
.chip {
|
||||||
padding: 7px 11px;
|
padding: 9px 12px;
|
||||||
border: 1px solid var(--rule);
|
border: 1px solid var(--rule);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
@@ -402,15 +435,16 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 12px 4px calc(12px + env(safe-area-inset-bottom));
|
padding: 10px 4px calc(10px + env(safe-area-inset-bottom));
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 1px solid var(--rule);
|
border-left: 1px solid var(--rule);
|
||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -418,15 +452,27 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
.tab:first-child { border-left: none; }
|
.tab:first-child { border-left: none; }
|
||||||
.tab[aria-selected="true"] { background: var(--ink); color: var(--bg); }
|
.tab[aria-selected="true"] { background: var(--ink); color: var(--bg); }
|
||||||
|
|
||||||
.tab .count {
|
.tab-icon { display: block; width: 24px; height: 24px; margin: 0 auto 4px; }
|
||||||
display: block;
|
.tab-icon svg { width: 24px; height: 24px; display: block; }
|
||||||
font-size: 17px;
|
.tab-label { display: block; }
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 50%;
|
||||||
|
transform: translateX(26px);
|
||||||
|
min-width: 18px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--bg);
|
||||||
|
border: 1px solid var(--bg);
|
||||||
|
font-size: 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--ink);
|
letter-spacing: 0;
|
||||||
margin-bottom: 1px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab[aria-selected="true"] .count { color: var(--bg); }
|
.tab-badge[hidden] { display: none; }
|
||||||
|
.tab[aria-selected="true"] .tab-badge { background: var(--bg); color: var(--ink); border-color: var(--ink); }
|
||||||
|
|
||||||
/* ------------------------------------------------------------------- Zustaende */
|
/* ------------------------------------------------------------------- Zustaende */
|
||||||
|
|
||||||
@@ -484,8 +530,11 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
|
|
||||||
.toast[hidden] { display: none; }
|
.toast[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* Nur am grossen Bildschirm ein Rahmen - auf dem Telefon fuellt die App
|
||||||
|
den Schirm, wie eine App das tut. */
|
||||||
@media (min-width: 600px) {
|
@media (min-width: 600px) {
|
||||||
:root { --pad: 26px; }
|
:root { --pad: 26px; }
|
||||||
|
body { background: var(--page); }
|
||||||
.app { border: 1px solid var(--ink); margin: 26px auto; min-height: calc(100vh - 52px); }
|
.app { border: 1px solid var(--ink); margin: 26px auto; min-height: calc(100vh - 52px); }
|
||||||
.d-xl { font-size: 52px; }
|
.d-xl { font-size: 52px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,3 +262,96 @@ def test_handeintrag_ueberlebt_den_hintergrundlauf(client, monkeypatch):
|
|||||||
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["full_name"] == "Von Hand"
|
||||||
assert card["notes"] == "Messe"
|
assert card["notes"] == "Messe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anlass_wird_gespeichert_und_ist_durchsuchbar(client):
|
||||||
|
created = client.post(
|
||||||
|
"/api/scan",
|
||||||
|
files={"file": ("tisch.jpg", photo_bytes(2), "image/jpeg")},
|
||||||
|
data={"occasion": "Hannover Messe 2026"},
|
||||||
|
).json()
|
||||||
|
ids = [c["id"] for c in created["cards"]]
|
||||||
|
warte_bis_gelesen(client, ids)
|
||||||
|
|
||||||
|
assert all(c["occasion"] == "Hannover Messe 2026" for c in created["cards"])
|
||||||
|
|
||||||
|
gefunden = client.get("/api/cards", params={"q": "hannover messe"}).json()["cards"]
|
||||||
|
assert set(ids) <= {c["id"] for c in gefunden}
|
||||||
|
|
||||||
|
|
||||||
|
def test_anlass_ist_nachtraeglich_aenderbar(client):
|
||||||
|
card_id = client.post(
|
||||||
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
|
||||||
|
patched = client.patch(f"/api/cards/{card_id}", json={"occasion": "Kundentermin"})
|
||||||
|
|
||||||
|
assert patched.json()["occasion"] == "Kundentermin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dubletten_stehen_in_der_detailansicht(client):
|
||||||
|
erste = client.post(
|
||||||
|
"/api/scan", files={"file": ("a.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
zweite = client.post(
|
||||||
|
"/api/scan", files={"file": ("b.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
warte_bis_gelesen(client, [erste, zweite])
|
||||||
|
for card_id in (erste, zweite):
|
||||||
|
client.patch(f"/api/cards/{card_id}", json={"email": "doppelt@firma.de"})
|
||||||
|
|
||||||
|
detail = client.get(f"/api/cards/{zweite}").json()
|
||||||
|
|
||||||
|
assert erste in [d["id"] for d in detail["duplicates"]]
|
||||||
|
assert detail["duplicates"][0]["reason"] == "gleiche E-Mail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rueckseite_nachtragen(client):
|
||||||
|
card_id = client.post(
|
||||||
|
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/cards/{card_id}/back",
|
||||||
|
files={"file": ("hinten.jpg", photo_bytes(1), "image/jpeg")},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["back_image_url"].endswith("side=back")
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
|
||||||
|
bild = client.get(f"/api/cards/{card_id}/image", params={"side": "back"})
|
||||||
|
assert bild.status_code == 200
|
||||||
|
assert bild.headers["content-type"] == "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_rueckseite_gibt_es_kein_rueckseitenbild(client):
|
||||||
|
card_id = client.post(
|
||||||
|
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
|
||||||
|
assert client.get(
|
||||||
|
f"/api/cards/{card_id}/image", params={"side": "back"}
|
||||||
|
).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_loeschen_entfernt_auch_die_rueckseite(client):
|
||||||
|
from app import config
|
||||||
|
|
||||||
|
card_id = client.post(
|
||||||
|
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
client.post(
|
||||||
|
f"/api/cards/{card_id}/back",
|
||||||
|
files={"file": ("hinten.jpg", photo_bytes(1), "image/jpeg")},
|
||||||
|
)
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
row = db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))
|
||||||
|
|
||||||
|
client.delete(f"/api/cards/{card_id}")
|
||||||
|
|
||||||
|
assert not (config.IMAGE_DIR / row["image_file"]).exists()
|
||||||
|
assert not (config.IMAGE_DIR / row["back_image_file"]).exists()
|
||||||
|
|||||||
88
tests/test_duplicates.py
Normal file
88
tests/test_duplicates.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Dublettenerkennung. Auf Messen trifft man Leute wieder."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db, duplicates
|
||||||
|
|
||||||
|
|
||||||
|
def _card(**felder):
|
||||||
|
columns = {
|
||||||
|
"id": uuid.uuid4().hex,
|
||||||
|
"created_at": "2026-09-01T10:00:00+00:00",
|
||||||
|
"extract_status": "ok",
|
||||||
|
**felder,
|
||||||
|
}
|
||||||
|
names = ", ".join(columns)
|
||||||
|
db.execute(
|
||||||
|
f"INSERT INTO cards ({names}) VALUES ({', '.join('?' for _ in columns)})",
|
||||||
|
list(columns.values()),
|
||||||
|
)
|
||||||
|
return db.query_one("SELECT * FROM cards WHERE id = ?", (columns["id"],))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def leere_tabelle():
|
||||||
|
db.execute("DELETE FROM cards")
|
||||||
|
yield
|
||||||
|
db.execute("DELETE FROM cards")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gleiche_email_wird_gefunden():
|
||||||
|
_card(full_name="Anna Berg", email="A.Berg@Firma.de")
|
||||||
|
neu = _card(full_name="A. Berg", email="a.berg@firma.de")
|
||||||
|
|
||||||
|
treffer = duplicates.find(neu)
|
||||||
|
|
||||||
|
assert [t["reason"] for t in treffer] == ["gleiche E-Mail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_telefonnummer_in_anderer_schreibweise():
|
||||||
|
_card(full_name="Anna Berg", phone="+49 511 1234560")
|
||||||
|
neu = _card(full_name="Anna B.", mobile="0511/1234560")
|
||||||
|
|
||||||
|
treffer = duplicates.find(neu)
|
||||||
|
|
||||||
|
assert [t["reason"] for t in treffer] == ["gleiche Telefonnummer"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_und_firma_zusammen():
|
||||||
|
_card(last_name="Berg", company="Berg & Partner GmbH")
|
||||||
|
neu = _card(last_name="berg", company="berg & partner gmbh")
|
||||||
|
|
||||||
|
assert [t["reason"] for t in duplicates.find(neu)] == ["gleicher Name und dieselbe Firma"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gleicher_nachname_allein_reicht_nicht():
|
||||||
|
_card(last_name="Berg", company="Andere GmbH")
|
||||||
|
neu = _card(last_name="Berg", company="Berg & Partner GmbH")
|
||||||
|
|
||||||
|
assert duplicates.find(neu) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_kurze_nummern_werden_nicht_verglichen():
|
||||||
|
# Eine dreistellige Durchwahl darf keine Dublette erzeugen.
|
||||||
|
_card(full_name="Anna Berg", phone="123")
|
||||||
|
neu = _card(full_name="Bea Kurz", phone="123")
|
||||||
|
|
||||||
|
assert duplicates.find(neu) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_leere_felder_erzeugen_keine_treffer():
|
||||||
|
_card(full_name="Anna Berg")
|
||||||
|
neu = _card(full_name="Bea Kurz")
|
||||||
|
|
||||||
|
assert duplicates.find(neu) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_karte_selbst_ist_keine_dublette():
|
||||||
|
karte = _card(full_name="Anna Berg", email="a@b.de")
|
||||||
|
|
||||||
|
assert duplicates.find(karte) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_noch_nicht_gelesene_karten_bleiben_aussen_vor():
|
||||||
|
_card(full_name="Anna Berg", email="a@b.de", extract_status="offen")
|
||||||
|
neu = _card(full_name="Anna Berg", email="a@b.de")
|
||||||
|
|
||||||
|
assert duplicates.find(neu) == []
|
||||||
Reference in New Issue
Block a user