diff --git a/README.md b/README.md
index cb85f2e..78fef98 100644
--- a/README.md
+++ b/README.md
@@ -47,6 +47,15 @@ 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“.
+**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
direkt in die Kontakte-App übernimmt. *Als vCard teilen* geht über das
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
segment.py OpenCV-Pipeline: Karten finden und entzerren
extract.py Vision-Modell, strukturiertes JSON
- imaging.py EXIF-Auswertung, Bildkonvertierung
duplicates.py Ähnliche Karten finden
+ imaging.py EXIF, Drehen, Zuschneiden
vcard.py vCard 3.0
reminders.py Scheduler für die Erinnerungsmails
static/ PWA: HTML, CSS, JavaScript, Service Worker
diff --git a/app/db.py b/app/db.py
index 32cdcec..1efdd6d 100644
--- a/app/db.py
+++ b/app/db.py
@@ -92,6 +92,24 @@ def _migrate(conn: sqlite3.Connection) -> None:
for name, kind in LATER_COLUMNS.items():
if name not in present:
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]:
diff --git a/app/duplicates.py b/app/duplicates.py
index 4586392..904b184 100644
--- a/app/duplicates.py
+++ b/app/duplicates.py
@@ -39,7 +39,7 @@ def find(card) -> list:
"""Aehnliche Karten, neueste zuerst."""
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
FROM cards
WHERE id <> ? AND extract_status <> 'offen'
diff --git a/app/extract.py b/app/extract.py
index 001be7a..2a97b1e 100644
--- a/app/extract.py
+++ b/app/extract.py
@@ -16,7 +16,7 @@ from . import config, imaging
log = logging.getLogger(__name__)
FIELDS = [
- "full_name", "first_name", "last_name", "job_title", "company", "department",
+ "first_name", "last_name", "job_title", "company", "department",
"email", "phone", "mobile", "fax", "website",
"street", "postal_code", "city", "country",
]
@@ -52,8 +52,8 @@ Regeln:
- phone ist die Festnetznummer, mobile die Mobilnummer. Ist nur eine Nummer
ohne Kennzeichnung angegeben, gehoert sie in phone.
- company ist der Firmenname, job_title die Funktion der Person.
-- full_name ist der vollstaendige Name inklusive Titeln wie "Dr.",
- first_name und last_name ohne Titel.
+- first_name ist der Vorname einschliesslich akademischer Titel ("Dr. Anna"),
+ last_name der Nachname.
- website ohne Protokollpraefix.
- Zeigt der Ausschnitt keine Visitenkarte (Tischkante, Notizzettel, leere
Flaeche), setze is_business_card auf false und alle Felder auf null.
diff --git a/app/imaging.py b/app/imaging.py
index b1c4b90..f4804f2 100644
--- a/app/imaging.py
+++ b/app/imaging.py
@@ -80,6 +80,35 @@ def encode_jpeg(rgb_or_bgr: np.ndarray, quality: int = 88, bgr: bool = True) ->
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:
"""Im Uhrzeigersinn drehen. Arbeitet auf dem fertigen JPEG, nicht auf dem
grossen Rohbild - gebraucht wird das nur fuer einzelne Karten."""
diff --git a/app/main.py b/app/main.py
index cd5f561..574b7d3 100644
--- a/app/main.py
+++ b/app/main.py
@@ -22,9 +22,9 @@ STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
EDITABLE = [
- "full_name", "first_name", "last_name", "job_title", "company", "department",
+ "first_name", "last_name", "job_title", "company", "department",
"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
)
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
@@ -255,7 +255,7 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
if q.strip():
needle = f"%{q.strip()}%"
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",
]
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":
where.append(
"(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":
where.append(
@@ -340,6 +340,7 @@ def delete_card(card_id: str) -> dict:
for name in (row["image_file"], row["back_image_file"]):
if name:
(config.IMAGE_DIR / name).unlink(missing_ok=True)
+ _original_of(name).unlink(missing_ok=True)
db.execute("DELETE FROM cards WHERE id = ?", (card_id,))
return {"ok": True}
@@ -354,6 +355,51 @@ def card_image(card_id: str, side: str = "front") -> FileResponse:
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])
async def add_back(card_id: str, file: UploadFile) -> dict:
"""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:
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
ORDER BY r.sent_at IS NOT NULL, r.due_at
"""
diff --git a/app/reminders.py b/app/reminders.py
index f5dbae9..0a878c9 100644
--- a/app/reminders.py
+++ b/app/reminders.py
@@ -19,10 +19,14 @@ def _now() -> str:
return datetime.now(timezone.utc).isoformat()
+def _name(row) -> str:
+ return " ".join(filter(None, (row["first_name"], row["last_name"])))
+
+
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}"]
- if row["company"] and row["full_name"]:
+ if row["company"] and _name(row):
lines.append(f"Firma: {row['company']}")
if row["email"]:
lines.append(f"E-Mail: {row['email']}")
@@ -41,7 +45,7 @@ def due_reminders() -> list:
return db.query(
"""
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
JOIN cards c ON c.id = r.card_id
WHERE r.sent_at IS NULL AND r.due_at <= ?
@@ -62,7 +66,7 @@ def process_due() -> int:
sent = 0
for row in rows:
- who = row["full_name"] or row["company"] or "Kontakt"
+ who = _name(row) or row["company"] or "Kontakt"
try:
mailer.send(f"Erinnerung: {who}", _body(row))
except Exception as exc:
diff --git a/app/vcard.py b/app/vcard.py
index 2387f8c..1e5399e 100644
--- a/app/vcard.py
+++ b/app/vcard.py
@@ -16,8 +16,6 @@ def _line(name: str, value) -> list:
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"))))
return joined or card.get("company") or "Unbenannt"
diff --git a/static/app.js b/static/app.js
index a20fb80..87ef231 100644
--- a/static/app.js
+++ b/static/app.js
@@ -3,7 +3,6 @@
"use strict";
const FIELD_LABELS = {
- full_name: "Name",
first_name: "Vorname",
last_name: "Nachname",
job_title: "Position",
@@ -19,6 +18,7 @@
city: "Ort",
country: "Land",
occasion: "Anlass",
+ place: "Scan-Ort",
};
const FIELD_ORDER = Object.keys(FIELD_LABELS);
@@ -38,6 +38,7 @@
karten: $("screen-cards"),
karte: $("screen-detail"),
erinnerungen: $("screen-reminders"),
+ mehr: $("screen-mehr"),
};
const esc = (value) =>
@@ -103,11 +104,37 @@
};
const displayName = (card) =>
- card.full_name ||
[card.first_name, card.last_name].filter(Boolean).join(" ") ||
card.company ||
"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) =>
+ `${esc(text)}`;
+
const subtitle = (card) =>
[card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt";
@@ -115,11 +142,17 @@
* genauer, deshalb blockiert das hier nichts und scheitert leise. */
function browserPosition() {
return new Promise((resolve) => {
- if (!navigator.geolocation) return resolve(null);
+ if (!navigator.geolocation) return resolve({ error: "Gerät liefert keinen Standort" });
navigator.geolocation.getCurrentPosition(
- (position) => resolve(position.coords),
- () => resolve(null),
- { timeout: 6000, maximumAge: 300000 }
+ (position) => resolve({ coords: position.coords }),
+ (error) =>
+ 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 === "karte") loadCard(state.cardId);
if (route === "erinnerungen") loadReminders();
+ if (route === "mehr") renderSettings(state.session || {});
}
// ------------------------------------------------------------------- Scan
@@ -183,17 +217,21 @@
const body = new FormData();
body.append("file", file);
body.append("occasion", $("occasion-input").value.trim());
- const coords = await browserPosition();
- if (coords) {
- body.append("lat", coords.latitude);
- body.append("lon", coords.longitude);
+ const position = await browserPosition();
+ if (position.coords) {
+ body.append("lat", position.coords.latitude);
+ body.append("lon", position.coords.longitude);
}
try {
const result = await api("/api/scan", { method: "POST", body });
renderScanResult(result);
await refreshStats();
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) {
toast(error.message);
} 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) {
- const rows = FIELD_ORDER.filter((field) => card[field])
- .map((field) => `
| ${FIELD_LABELS[field]} | ${esc(card[field])} |
`)
- .join("");
- return rows
- ? ``
+ const postal = address(card);
+ const rows = DISPLAY_ORDER.map((field) => {
+ if (field === "address") {
+ if (!postal) return "";
+ return `| Adresse | ${link(NAV_APPS[navApp()].url(postal), postal)} |
`;
+ }
+ if (!card[field]) return "";
+ return `| ${FIELD_LABELS[field]} | ${fieldCell(card, field)} |
`;
+ }).filter(Boolean);
+
+ return rows.length
+ ? ``
: `Keine Felder erkannt. Über „Bearbeiten“ nachtragen.
`;
}
@@ -345,6 +409,8 @@
`;
}
+ const GEO_SOURCE = { exif: "aus dem Foto", browser: "vom Gerät" };
+
const QUICK_DATES = [
["In 3 Tagen", 3],
["In 1 Woche", 7],
@@ -391,17 +457,23 @@
${esc(formatDate(card.captured_at || card.created_at))}
- Ort
+ Ort${card.geo_source ? " · " + GEO_SOURCE[card.geo_source] : ""}
${esc(card.place || "Unbekannt")}
${card.image_url
- ? `
`
+ ? `
+ `
: ""}
${card.back_image_url
- ? `
`
+ ? `
+ `
: `