From fcaa74f774175de1c75465e2af153f896f8610ff Mon Sep 17 00:00:00 2001 From: Stefan Koelle Date: Mon, 10 Aug 2026 17:42:13 +0200 Subject: [PATCH] demo showcase --- .gitignore | 2 + AGENTS.md | 12 ++ README.md | 32 ++++- demo.py | 359 +++++++++++++++++++++++++++++++++++++++++++++++++++++ demo.sh | 18 +++ 5 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 demo.py create mode 100755 demo.sh diff --git a/.gitignore b/.gitignore index 29fd4c8..cca5d6d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .env config/accounts.json .venv/ +.venv-demo/ +demo.db __pycache__/ *.pyc .DS_Store diff --git a/AGENTS.md b/AGENTS.md index b09b636..99969d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,8 @@ config/ accounts.json — Per-account credentials (NOT in git, volume-mounted) docker/ entrypoint.sh — Starts scheduler.py (or exec's custom command) +demo.py — SQLite demo app with fake contacts (screenshot/showcase) +demo.sh — Launches demo: creates venv, installs deps, starts server ``` ## Key Files @@ -50,6 +52,8 @@ docker/ | `src/db.py` | All MariaDB queries | | `sql/schema.sql` | Canonical schema definition | | `.env.example` | All supported environment variables | +| `demo.py` | SQLite demo app with fake contacts (screenshot/showcase) | +| `demo.sh` | Launches demo: creates venv, installs deps, starts server | ## Commands @@ -83,6 +87,14 @@ docker compose build docker compose up -d ``` +### Run demo (no MariaDB needed) + +```bash +./demo.sh +``` + +Starts SQLite-based demo on `0.0.0.0:8000` with 6 fake contacts. + ## Code Conventions - All source in `src/`, single package, no `setup.py`/`pyproject.toml`. diff --git a/README.md b/README.md index 910f234..0079cf3 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,37 @@ python3 sync.py python3 mailer.py ``` -## 10. CI/CD +## 10. Demo-Modus (Screenshot/Showcase) + +Lokale Demo mit SQLite-Backend und Fake-Kontakten, ohne MariaDB, +Apple-IDs oder Docker. Zeigt Dashboard, Kontakt-Detailseite und +Gruppen-Übersicht mit farbigen UI-Avatar-Bildern. + +### Starten + +```bash +./demo.sh +``` + +Das Script erstellt automatisch ein virtuelles Umfeld +(`.venv-demo/`), installiert die Dependencies und startet den +Server auf `0.0.0.0:8000`. + +### Was angezeigt wird + +- Dashboard mit 6 Kontakten, Geburtstagen der nächsten 7 Tage, + 2 Gruppen ("Familie", "Arbeit") und成功stem Sync-Status +- Kontakt-Detailseite mit E-Mail, Telefon, Adresse, Foto +- Farbige Initialen-Avatare via ui-avatars.com + +### Technisches + +- SQLite-Datenbank (`demo.db`) wird bei jedem Start frisch angelegt +- Kein `.env`, kein `accounts.json` nötig +- Templates und CSS werden aus `src/api/` wiederverwendet +- `.venv-demo/` und `demo.db` sind in `.gitignore` eingetragen + +## 11. CI/CD - Jeder Push auf `main` baut automatisch ein neues Image und pusht es nach `ghcr.io//icloud-contacts-sync`. diff --git a/demo.py b/demo.py new file mode 100644 index 0000000..5247085 --- /dev/null +++ b/demo.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +"""Lokale Demo-App: SQLite-Backend mit Fake-Kontakten für Dashboard + Kontakt-Detail.""" +import json +import logging +import secrets +import sqlite3 +from contextlib import contextmanager +from datetime import date, datetime, timedelta +from pathlib import Path +from urllib.parse import quote_plus + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from starlette.middleware.sessions import SessionMiddleware + +from utils import fmt_birthday_age, fmt_birthday_short, is_unknown_year + +logging.basicConfig(level="INFO", format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("demo") + +BASE_DIR = Path(__file__).resolve().parent +DB_PATH = BASE_DIR / "demo.db" +SRC_DIR = BASE_DIR / "src" + +app = FastAPI(title="iCloud Contacts Sync – Demo", version="1.0.0") +app.add_middleware(SessionMiddleware, secret_key=secrets.token_hex(32), session_cookie="ics_session") +app.mount("/static", StaticFiles(directory=str(SRC_DIR / "api" / "static")), name="static") +templates = Jinja2Templates(directory=str(SRC_DIR / "api" / "templates")) +templates.env.filters["urlquote"] = lambda s: quote_plus(s or "") +templates.env.filters["fmt_birthday"] = fmt_birthday_short +templates.env.filters["fmt_age"] = fmt_birthday_age +templates.env.filters["has_year"] = lambda b: not is_unknown_year(b) + +DEMO_USER = "demo" +DEMO_ACCOUNT = "demo-user" + + +def _fmt_ts(ts: str | None) -> str | None: + if not ts: + return None + try: + dt = datetime.fromisoformat(ts) + return dt.strftime("%d.%m.%Y %H:%M:%S") + except (ValueError, TypeError): + return ts + + +@contextmanager +def get_connection(): + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + try: + yield conn + finally: + conn.close() + + +def _build_full_name(row: dict) -> str | None: + parts = [ + row.get("given_name"), + row.get("middle_name"), + row.get("family_name"), + ] + return " ".join(p for p in parts if p) or None + + +def _row_to_contact_out(row: dict) -> dict: + row = dict(row) + for field in ("emails", "phones", "addresses", "urls", "social_profiles", "categories"): + raw = row.get(field) + row[field] = json.loads(raw) if raw else [] + if row.get("birthday") and isinstance(row["birthday"], str): + row["birthday"] = date.fromisoformat(row["birthday"]) + if not row.get("full_name"): + row["full_name"] = _build_full_name(row) + return row + + +def init_db(): + with get_connection() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT NOT NULL, + uid TEXT NOT NULL, + full_name TEXT, given_name TEXT, family_name TEXT, + middle_name TEXT, prefix TEXT, suffix TEXT, + organization TEXT, job_title TEXT, + birthday TEXT, notes TEXT, + emails TEXT, phones TEXT, addresses TEXT, + urls TEXT, social_profiles TEXT, categories TEXT, + photo_url TEXT, + raw_vcard TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(account, uid) + ); + CREATE TABLE IF NOT EXISTS groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + account TEXT NOT NULL, + uid TEXT NOT NULL, + name TEXT, + UNIQUE(account, uid) + ); + CREATE TABLE IF NOT EXISTS group_members ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + group_id INTEGER REFERENCES groups(id), + member_uid TEXT NOT NULL, + UNIQUE(group_id, member_uid) + ); + CREATE TABLE IF NOT EXISTS sync_runs ( + id TEXT PRIMARY KEY, + account TEXT NOT NULL, + sync_type TEXT NOT NULL DEFAULT 'delta', + started_at TEXT NOT NULL DEFAULT (datetime('now')), + finished_at TEXT, + status TEXT NOT NULL DEFAULT 'running', + contacts_upserted INTEGER, + contacts_deleted INTEGER, + error_message TEXT + ); + """) + + +def seed_data(): + with get_connection() as conn: + count = conn.execute("SELECT COUNT(*) FROM contacts").fetchone()[0] + if count > 0: + return + + today = date.today() + def _avatar(name: str, bg: str = "0066cc") -> str: + from urllib.parse import quote + return f"https://ui-avatars.com/api/?name={quote(name)}&background={bg}&color=fff&size=160&bold=true" + + contacts = [ + { + "uid": "demo-001", "full_name": "Erika Musterfrau", + "given_name": "Erika", "family_name": "Musterfrau", + "organization": "Muster GmbH", "job_title": "Geschäftsführerin", + "birthday": "1985-03-15", + "photo_url": _avatar("Erika Musterfrau", "0066cc"), + "emails": json.dumps([{"type": "work", "value": "erika@muster.de"}, {"type": "home", "value": "erika.privat@mail.de"}]), + "phones": json.dumps([{"type": "work", "value": "+49 30 1234567"}, {"type": "mobile", "value": "+49 170 1234567"}]), + "addresses": json.dumps([{"type": "work", "street": "Berliner Str. 1", "zip": "10115", "city": "Berlin", "region": "Berlin", "country": "Deutschland"}]), + }, + { + "uid": "demo-002", "full_name": "Thomas Testermann", + "given_name": "Thomas", "family_name": "Testermann", + "organization": "Test AG", "job_title": "Entwickler", + "birthday": "1990-07-22", + "photo_url": _avatar("Thomas Testermann", "28a745"), + "emails": json.dumps([{"type": "work", "value": "thomas@test.de"}]), + "phones": json.dumps([{"type": "mobile", "value": "+49 171 9876543"}]), + "addresses": json.dumps([{"type": "home", "street": "Musterweg 5", "zip": "80331", "city": "München", "region": "Bayern", "country": "Deutschland"}]), + }, + { + "uid": "demo-003", "full_name": "Anna Beispiel", + "given_name": "Anna", "family_name": "Beispiel", + "organization": "Beispiel & Partner", "job_title": "Designerin", + "birthday": "1992-11-08", + "photo_url": _avatar("Anna Beispiel", "dc3545"), + "emails": json.dumps([{"type": "home", "value": "anna@beispiel.de"}]), + "phones": json.dumps([{"type": "mobile", "value": "+49 172 5551234"}]), + "addresses": json.dumps([{"type": "home", "street": "Gartenstr. 12", "zip": "50667", "city": "Köln", "region": "Nordrhein-Westfalen", "country": "Deutschland"}]), + }, + { + "uid": "demo-004", "full_name": "Sabine Mustermann", + "given_name": "Sabine", "family_name": "Mustermann", + "organization": "", "job_title": "", + "birthday": f"1997-08-{(today + timedelta(days=5)).day:02d}", + "photo_url": _avatar("Sabine Mustermann", "fd7e14"), + "emails": json.dumps([{"type": "home", "value": "sabine@web.de"}]), + "phones": json.dumps([{"type": "home", "value": "+49 721 555999"}]), + "addresses": json.dumps([{"type": "home", "street": "Waldweg 3", "zip": "70173", "city": "Stuttgart", "region": "Baden-Württemberg", "country": "Deutschland"}]), + }, + { + "uid": "demo-005", "full_name": "Peter Beispiel", + "given_name": "Peter", "family_name": "Beispiel", + "organization": "Beispiel & Partner", "job_title": "Partner", + "birthday": "1960-05-01", + "photo_url": _avatar("Peter Beispiel", "6f42c1"), + "emails": json.dumps([{"type": "work", "value": "peter@beispiel.de"}]), + "phones": json.dumps([{"type": "work", "value": "+49 721 555888"}, {"type": "mobile", "value": "+49 175 1112233"}]), + "addresses": json.dumps([{"type": "work", "street": "Hauptstr. 42", "zip": "70173", "city": "Stuttgart", "region": "Baden-Württemberg", "country": "Deutschland"}]), + }, + { + "uid": "demo-006", "full_name": "Max Demo", + "given_name": "Max", "family_name": "Demo", + "organization": "Demo Inc.", "job_title": "Projektmanager", + "birthday": f"1991-08-{(today + timedelta(days=3)).day:02d}", + "photo_url": _avatar("Max Demo", "17a2b8"), + "emails": json.dumps([{"type": "work", "value": "max@demo.de"}, {"type": "home", "value": "max.privat@demo.de"}]), + "phones": json.dumps([{"type": "mobile", "value": "+49 176 4445566"}]), + "addresses": json.dumps([{"type": "home", "street": "Demoallee 7", "zip": "10115", "city": "Berlin", "region": "Berlin", "country": "Deutschland"}]), + }, + ] + + for c in contacts: + conn.execute( + """INSERT INTO contacts (account, uid, full_name, given_name, family_name, + organization, job_title, birthday, photo_url, emails, phones, addresses) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (DEMO_ACCOUNT, c["uid"], c["full_name"], c["given_name"], c["family_name"], + c["organization"], c["job_title"], c["birthday"], c["photo_url"], + c["emails"], c["phones"], c["addresses"]), + ) + + groups = [ + ("demo-grp-001", "Familie"), + ("demo-grp-002", "Arbeit"), + ] + for uid, name in groups: + conn.execute( + "INSERT INTO groups (account, uid, name) VALUES (?, ?, ?)", + (DEMO_ACCOUNT, uid, name), + ) + + family_id = conn.execute("SELECT id FROM groups WHERE uid = ?", ("demo-grp-001",)).fetchone()[0] + work_id = conn.execute("SELECT id FROM groups WHERE uid = ?", ("demo-grp-002",)).fetchone()[0] + + for uid in ["demo-001", "demo-004", "demo-005"]: + conn.execute("INSERT INTO group_members (group_id, member_uid) VALUES (?, ?)", (family_id, uid)) + for uid in ["demo-001", "demo-002", "demo-003", "demo-005"]: + conn.execute("INSERT INTO group_members (group_id, member_uid) VALUES (?, ?)", (work_id, uid)) + + conn.execute( + """INSERT INTO sync_runs (id, account, sync_type, started_at, finished_at, status, contacts_upserted, contacts_deleted) + VALUES (?, ?, ?, datetime('now', '-2 hours'), datetime('now', '-1 hour'), 'success', 6, 0)""", + ("demo-sync-001", DEMO_ACCOUNT, "delta"), + ) + + conn.commit() + logger.info("Demo-Daten erstellt: 6 Kontakte, 2 Gruppen") + + +@app.get("/", response_class=HTMLResponse) +def dashboard(request: Request): + with get_connection() as conn: + contact_count = conn.execute("SELECT COUNT(*) AS total FROM contacts").fetchone()["total"] + + today = date.today() + rows = conn.execute( + """SELECT id, full_name, given_name, middle_name, family_name, + organization, birthday, photo_url + FROM contacts + WHERE birthday IS NOT NULL + ORDER BY birthday""", + ).fetchall() + upcoming = [] + cutoff = today + timedelta(days=7) + for r in rows: + bday = date.fromisoformat(r["birthday"]) + bday_this_year = bday.replace(year=today.year) + if today <= bday_this_year <= cutoff: + d = dict(r) + d["birthday"] = bday + if not d.get("full_name"): + d["full_name"] = _build_full_name(d) + upcoming.append(d) + + groups = [dict(g) for g in conn.execute( + "SELECT id, name, uid FROM groups ORDER BY name" + ).fetchall()] + + last_sync_row = conn.execute( + """SELECT id, sync_type, started_at, finished_at, status, + contacts_upserted, contacts_deleted, error_message + FROM sync_runs ORDER BY started_at DESC LIMIT 1""" + ).fetchone() + last_sync = dict(last_sync_row) if last_sync_row else None + if last_sync: + last_sync["started_at"] = _fmt_ts(last_sync["started_at"]) + last_sync["finished_at"] = _fmt_ts(last_sync["finished_at"]) + + return templates.TemplateResponse( + "dashboard.html", + { + "request": request, + "current_user": DEMO_USER, + "is_admin": True, + "show_all": False, + "account_name": DEMO_ACCOUNT, + "contact_count": contact_count, + "upcoming_birthdays": upcoming, + "last_sync": last_sync, + "last_sync_with_changes": None, + "groups": groups, + "current_year": today.year, + "today": today, + }, + ) + + +@app.get("/contacts/{contact_id}", response_class=HTMLResponse) +def contact_detail(request: Request, contact_id: int): + with get_connection() as conn: + row = conn.execute( + """SELECT id, account, uid, full_name, given_name, middle_name, family_name, + organization, job_title, birthday, notes, photo_url, + emails, phones, addresses, urls, social_profiles, categories, updated_at + FROM contacts WHERE id = ?""", + (contact_id,), + ).fetchone() + + if not row: + return HTMLResponse("Kontakt nicht gefunden", status_code=404) + + contact = _row_to_contact_out(row) + + groups = [dict(g) for g in conn.execute( + """SELECT g.id, g.name, g.uid + FROM groups g + JOIN group_members gm ON gm.group_id = g.id + WHERE gm.member_uid = ? + ORDER BY g.name""", + (row["uid"],), + ).fetchall()] + + return templates.TemplateResponse( + "contact.html", + { + "request": request, + "current_user": DEMO_USER, + "is_admin": True, + "show_all": False, + "contact": contact, + "groups": groups, + "search": "", + "custom_links": [], + }, + ) + + +@app.get("/search", response_class=HTMLResponse) +def search_redirect(): + return HTMLResponse( + '' + '

Demo-Modus

← Zurück zum Dashboard

' + '', + status_code=302, + headers={"Location": "/"}, + ) + + +@app.get("/api/health") +def health(): + return {"status": "ok", "mode": "demo"} + + +if __name__ == "__main__": + import uvicorn + init_db() + seed_data() + logger.info("Starte Demo-Server auf http://127.0.0.1:8000") + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/demo.sh b/demo.sh new file mode 100755 index 0000000..f94bbd5 --- /dev/null +++ b/demo.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Startet die lokale Demo-App mit SQLite-Backend und Fake-Kontakten. +# Erstellt automatisch ein virtuelles Umfeld, wenn nicht vorhanden. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +VENV_DIR="$SCRIPT_DIR/.venv-demo" + +if [ ! -d "$VENV_DIR" ]; then + echo "Erstelle virtuelles Umfeld in $VENV_DIR ..." + python3 -m venv "$VENV_DIR" +fi + +echo "Installiere Dependencies ..." +"$VENV_DIR/bin/pip" install -q -r "$SCRIPT_DIR/requirements.txt" + +echo "Starte Demo-Server auf http://127.0.0.1:8000" +PYTHONPATH="$SCRIPT_DIR/src" exec "$VENV_DIR/bin/python" "$SCRIPT_DIR/demo.py"