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>
This commit is contained in:
Lucas Orth
2026-09-06 15:19:10 +02:00
parent bf0a397597
commit 1e8918e0cd
14 changed files with 592 additions and 110 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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