diff --git a/MARKETING.md b/MARKETING.md index 63fcbc0..735a061 100644 --- a/MARKETING.md +++ b/MARKETING.md @@ -18,7 +18,7 @@ _Stand: 2026-06-09_ | Influencer | 🟡 2 Runden (Mai), kaum Resonanz | Runde 3 erst ab ~50 aktiven Usern — jetzt mit Partner-Paket als konkretem Angebot | | Presse / Blogs | 🟡 1 Runde, kaum Resonanz | keine Massenwelle; Nische zuerst | | Verzeichnisse / Listings | ⬜ offen | Product Hunt, PWA-Dirs, Google Business EBE | -| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Backlinks (Blog-Testberichte) | +| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | Rechtsseiten crawlbar (v1278) + 3 URLs (datenschutz/agb/impressum) am 09.06. in GSC zur Indexierung eingereicht — in ~Tagen auf „indexiert" prĂŒfen; llms.txt aktuell. NĂ€chster echter Hebel: Backlinks (Blog-Testberichte) | | Landing Page | 🟡 Redesign-Briefing da | 3 Einstiege, Outcomes statt Features | | App Store (iOS) | 🟱 **LIVE im App Store** (09.06., Apple-ID 6775012705) | Landing bewirbt „Ban Yaro Go" (Hero + iOS-Abschnitt `#ios-app`) + Profil-Hinweis (Settings → App installieren). Offizielles „Laden im App Store"-Badge nachgebaut als `/img/appstore-badge-de.svg` (brauner Rand #C4843A). **LIVE auf Produktion v1276** (banyaro.app/.de, 09.06.) — Hero-Badge bewusst weggelassen (sonst Eindruck: ganze App im Store) | | Play Store (Android) | 🔮 ON HOLD | 12 Closed-Tester / 14 Tage fehlen | @@ -41,7 +41,9 @@ Legende: 🟱 lĂ€uft/erledigt · 🟡 angefangen · ⬜ offen · 💡 Idee · ## ✅ Erledigt - [x] 1000 Flyer A5 (zweiseitig) gedruckt — 03.06.2026 - [x] iOS-App nativ gebaut + **im App Store freigegeben** (Ban Yaro Go, 09.06.) — Details im Repo `banyaro-ios` -- [x] Landing-Promotion fĂŒr „Ban Yaro Go" gebaut (Hero-Badge + iOS-Abschnitt) — 09.06., develop (URL-Platzhalter offen) +- [x] Landing-Promotion fĂŒr „Ban Yaro Go" LIVE (iOS-Abschnitt + Profil, eigenes braunes App-Store-Badge; Hero bewusst ohne Badge) — 09.06., Prod v1278 +- [x] Datenschutz v4 + AGB v3 (iOS-App-Verarbeitung, kein App-Store-IAP) — 09.06., Prod +- [x] Rechtsseiten crawlbar gemacht (/datenschutz /agb /impressum, einzige Quelle static/*.html) + 3 URLs in GSC zur Indexierung eingereicht — 09.06., Prod v1278 - [x] Influencer-Outreach Runde 1 (5) + Runde 2 (13) — Mai 2026 - [x] SEO-Grundlagen (llms.txt, Landing About-Section) diff --git a/Makefile b/Makefile index 4c7cefe..7fc461a 100644 --- a/Makefile +++ b/Makefile @@ -296,7 +296,9 @@ dev: KI_MODE=off ENV=development \ JWT_SECRET=dev-secret \ DB_PATH=./dev.db \ - uvicorn main:app --reload --port 8001 + MEDIA_DIR=$${MEDIA_DIR:-/tmp/banyaro-media} \ + BREEDER_DOCS_DIR=$${BREEDER_DOCS_DIR:-/tmp/banyaro-breeder} \ + uvicorn main:app --reload --port 8001 --host $${HOST:-127.0.0.1} # ---------------------------------------------------------- # REPORTS — Quartalsberichte generieren und committen diff --git a/VERSION b/VERSION index d1e5ab4..364aacb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1278 \ No newline at end of file +1292 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 8065d48..4f3ce10 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1303,6 +1303,23 @@ def _migrate(conn_factory): """) logger.info("Migration: notes Tabelle bereit.") + # Notizen: mehrere Mediendateien pro Notiz (Bild/Video/Audio/Datei) + conn.executescript(""" + CREATE TABLE IF NOT EXISTS note_media ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + url TEXT NOT NULL, + media_type TEXT NOT NULL DEFAULT 'image', -- image|video|audio|pdf|file + sort_order INTEGER NOT NULL DEFAULT 0, + img_width INTEGER, + img_height INTEGER, + duration_s INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_note_media_note ON note_media(note_id, sort_order); + """) + logger.info("Migration: note_media Tabelle bereit.") + conn.execute(""" CREATE TABLE IF NOT EXISTS ki_health_reports ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -2673,6 +2690,44 @@ def _migrate(conn_factory): """) logger.info("Migration: failed_emails Tabelle bereit.") + # iOS-Voll-App M0: Media-Registry — Originale liegen in der privaten + # CloudKit-DB des Nutzers, der Server hĂ€lt nur Previews. Die Registry ist + # die Quelle der Wahrheit ĂŒber Existenz + Speicherort (storage: server|icloud). + # ON DELETE CASCADE → Self-Delete (FK-Introspektion in profile.py) und + # Admin-Delete (finaler users-DELETE) rĂ€umen automatisch mit ab. + conn.executescript(""" + CREATE TABLE IF NOT EXISTS media_registry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + url TEXT NOT NULL UNIQUE, + storage TEXT NOT NULL DEFAULT 'icloud', + ck_record_name TEXT, + ck_state TEXT NOT NULL DEFAULT 'pending', + kind TEXT NOT NULL DEFAULT 'image', + context TEXT, + context_id INTEGER, + bytes_original INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_media_registry_user ON media_registry(user_id); + CREATE INDEX IF NOT EXISTS idx_media_registry_ctx ON media_registry(context, context_id); + """) + logger.info("Migration: media_registry Tabelle bereit (iCloud-Hybrid).") + + # daily_photo_cache wurde bisher NUR lazy in routes/dogs.py (Tagesfoto) + # angelegt — delete_diary/delete_media_item referenzieren sie aber auch. + # Auf einer frischen DB ohne Tagesfoto-Abruf warf Löschen daher 500. + # Struktur identisch zum Lazy-CREATE in dogs.py:241. + conn.executescript(""" + CREATE TABLE IF NOT EXISTS daily_photo_cache ( + dog_id INTEGER NOT NULL, + datum TEXT NOT NULL, + photo_url TEXT NOT NULL, + PRIMARY KEY (dog_id, datum) + ); + """) + logger.info("Migration: daily_photo_cache Tabelle bereit.") + # Second-Pass der ALTER-TABLE-Migrations: # Manche Migrations (z.B. diary_media.img_width) referenzieren Tabellen, # die erst weiter unten in dieser Funktion per CREATE IF NOT EXISTS angelegt diff --git a/backend/ki.py b/backend/ki.py index 188056f..7e258c8 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -25,6 +25,7 @@ KI_MODE = os.getenv("KI_MODE", "local") # off | local | cl LOCAL_BASE_URL = os.getenv("KI_LOCAL_URL", "http://10.47.11.70:11435/v1") LOCAL_MODEL = os.getenv("KI_LOCAL_MODEL", "gemma-4-31b-it") CLOUD_MODEL = os.getenv("KI_CLOUD_MODEL", "claude-sonnet-4-6") +VISION_MODEL = os.getenv("KI_VISION_MODEL", "claude-opus-4-8") # Bild-Analyse (Rassenerkennung) ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") CLOUD_WEEKLY_LIMIT = int(os.getenv("KI_CLOUD_WEEKLY_LIMIT", "20")) diff --git a/backend/main.py b/backend/main.py index 22fe399..21a5069 100644 --- a/backend/main.py +++ b/backend/main.py @@ -106,7 +106,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" - response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" + response.headers["Permissions-Policy"] = "camera=(), microphone=(self), geolocation=(self)" response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " @@ -114,6 +114,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): "worker-src 'self' blob:; " # 'self' = Service Worker (sw.js); blob: = MapLibre-GL-Worker "style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen fĂŒr jetzt) "img-src 'self' data: blob: https:; " + "media-src 'self' blob:; " # Audio/Video-Wiedergabe + lokale blob:-Vorschau (Sprachnotizen) "connect-src 'self' https:; " "frame-ancestors 'none'; " "base-uri 'self'; " @@ -237,6 +238,7 @@ from routes.wiki import router as wiki_router from routes.movies import router as movies_router from routes.friends import router as friends_router from routes.chat import router as chat_router +from routes.media import router as media_router from routes.admin import router as admin_router from routes.webcal import router as webcal_router from routes.profile import router as profile_router @@ -306,6 +308,7 @@ app.include_router(wiki_router, prefix="/api/wiki", tags=["Wiki"]) app.include_router(movies_router, prefix="/api/movies", tags=["Filme"]) app.include_router(friends_router, prefix="/api/friends", tags=["Freunde"]) app.include_router(chat_router, prefix="/api/chat", tags=["Chat"]) +app.include_router(media_router, prefix="/api/media", tags=["Medien"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) app.include_router(breeder_router, prefix="/api", tags=["ZĂŒchter"]) app.include_router(litters_router, prefix="/api", tags=["WĂŒrfe"]) @@ -583,6 +586,22 @@ async def serve_media(path: str, request: _Request): filepath = _resolve_media_path(path) if not filepath: + # iCloud-Hybrid (M0): Original liegt in der privaten iCloud des Besitzers, + # auf dem Server existiert nur das Preview. Marker-404, damit Clients + # unterscheiden können — Web fĂ€llt via onerror aufs Preview zurĂŒck. + # Lookup nur im Miss-Pfad → keine Kosten fĂŒr normale Medien-Requests. + from database import db as _db + from fastapi.responses import JSONResponse as _JSONResponse + with _db() as conn: + reg = conn.execute( + "SELECT storage FROM media_registry WHERE url=?", ("/media/" + path,) + ).fetchone() + if reg and reg["storage"] == "icloud": + return _JSONResponse( + {"detail": "Original liegt in der iCloud des Besitzers.", + "storage": "icloud"}, + status_code=404, + ) raise _HE(404, "Nicht gefunden.") return _media_response(filepath) diff --git a/backend/media_utils.py b/backend/media_utils.py index 4fa6a82..6f70fa5 100644 --- a/backend/media_utils.py +++ b/backend/media_utils.py @@ -6,6 +6,9 @@ from typing import Tuple _HEIC_EXTS = {".heic", ".heif"} _VIDEO_EXTS = {".mov", ".avi", ".m4v"} +# Audio-Endungen, die bereits AAC in einem MP4-Container sind (iOS-Recorder +# liefert audio/mp4) — keine Transkodierung nötig. +_AUDIO_AAC_EXTS = {".m4a", ".aac", ".mp4"} # Magic-Byte-Signaturen erlaubter Medientypen _IMAGE_MAGIC = [ @@ -51,6 +54,34 @@ def validate_upload(data: bytes, filename: str) -> None: # HEIC, MOV, AVI, M4V: Pillow/FFmpeg prĂŒfen beim Konvertieren +def validate_audio(data: bytes, content_type: str) -> None: + """ + PrĂŒft Magic Bytes einer Audio-Datei gegen den vom Client gemeldeten content_type. + Wirft ValueError bei Mismatch. WICHTIG: Audio-WebM und Video-WebM teilen sich + dieselbe Magic-Byte-Signatur (Matroska) — die Unterscheidung ist NUR ĂŒber den + content_type möglich, deshalb diese eigene Funktion (validate_upload hat keinen). + """ + if not data: + raise ValueError("Leere Audiodatei.") + ct = (content_type or "").lower().split(";")[0].strip() + if ct in ("audio/mp4", "audio/aac", "audio/x-m4a", "audio/m4a"): + if not (len(data) >= 8 and data[4:8] in (b'ftyp', b'mdat', b'moov', b'free')): + raise ValueError("Datei ist kein gĂŒltiges MP4/AAC-Audio.") + elif ct == "audio/webm": + if not data[:4] == b'\x1a\x45\xdf\xa3': + raise ValueError("Datei ist kein gĂŒltiges WebM-Audio.") + elif ct in ("audio/ogg", "audio/opus"): + if not data[:4] == b'OggS': + raise ValueError("Datei ist kein gĂŒltiges Ogg-Audio.") + elif ct == "audio/mpeg": + if not (data[:3] == b'ID3' or (len(data) >= 2 and data[0] == 0xFF and (data[1] & 0xE0) == 0xE0)): + raise ValueError("Datei ist kein gĂŒltiges MP3.") + elif ct in ("audio/wav", "audio/x-wav", "audio/wave"): + if not (data[:4] == b'RIFF' and data[8:12] == b'WAVE'): + raise ValueError("Datei ist kein gĂŒltiges WAV.") + # Andere/unbekannte Audio-Typen: ffmpeg prĂŒft beim Transkodieren. + + def safe_media_path(media_dir: str, url: str) -> str | None: """ Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL. @@ -69,6 +100,21 @@ def safe_media_path(media_dir: str, url: str) -> str | None: return candidate +def delete_media_files(media_dir: str, urls) -> None: + """Löscht mehrere Mediendateien samt _preview.webp/_thumb.jpg-Leichen von Disk + (best-effort, Path-Traversal-sicher). FĂŒr Cascade-Cleanup bei Lösch-Operationen.""" + for url in urls or []: + fp = safe_media_path(media_dir, url) + if not fp: + continue + base = os.path.splitext(fp)[0] + for path in (fp, base + "_preview.webp", base + "_thumb.jpg"): + try: + os.remove(path) + except OSError: + pass + + def to_jpeg_if_heic(data: bytes, filename: str) -> Tuple[bytes, str]: """Convert HEIC/HEIF to JPEG; return (data, ext) unchanged for all other types.""" ext = os.path.splitext(filename or "")[1].lower() @@ -122,6 +168,44 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]: pass +def to_m4a(data: bytes, src_ext: str) -> Tuple[bytes, str]: + """Transkodiert Audio nach m4a/AAC — universell abspielbar, auch auf iOS. + Nötig, weil Chrome/Firefox Opus-in-WebM/Ogg aufnehmen, das iOS Safari NICHT + abspielen kann. Bereits-AAC-Container (.m4a/.aac/.mp4, u.a. iOS-Recorder) + werden ohne Transkodierung durchgereicht. Bei ffmpeg-Fehler: Original zurĂŒck.""" + ext = (src_ext or "").lower() + if not ext.startswith("."): + ext = ("." + ext) if ext else ".webm" + if ext in _AUDIO_AAC_EXTS: + return data, ".m4a" + src_path = dst_path = None + try: + with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as src: + src.write(data) + src_path = src.name + dst_path = src_path[: -len(ext)] + ".m4a" + result = subprocess.run( + ["ffmpeg", "-i", src_path, + "-vn", "-c:a", "aac", "-b:a", "128k", + "-movflags", "+faststart", + "-y", dst_path], + capture_output=True, timeout=120, + ) + if result.returncode == 0: + with open(dst_path, "rb") as f: + return f.read(), ".m4a" + return data, ext + except Exception: + return data, ext + finally: + for p in [src_path, dst_path]: + if p: + try: + os.unlink(p) + except OSError: + pass + + def extract_gps_from_exif(data: bytes) -> tuple | None: """EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurĂŒck oder None.""" try: diff --git a/backend/routes/admin.py b/backend/routes/admin.py index 375cf3a..3155cd0 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -483,6 +483,16 @@ async def delete_user(uid: int, user=Depends(require_admin)): conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,)) conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,)) conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,)) + # Notiz-Medien: erst Dateien von Disk, dann DB-Zeilen (note_media + notes). + import os as _os + from media_utils import delete_media_files + _nm_urls = [r["url"] for r in conn.execute( + "SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?", + (uid,) + ).fetchall()] + delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _nm_urls) + conn.execute("DELETE FROM note_media WHERE note_id IN (SELECT id FROM notes WHERE user_id=?)", (uid,)) + conn.execute("DELETE FROM notes WHERE user_id=?", (uid,)) conn.execute("DELETE FROM users WHERE id=?", (uid,)) _audit(conn, user, "user_delete", f"user:{uid} ({target['name']})") @@ -728,7 +738,7 @@ async def ki_history(user=Depends(require_mod)): @router.get("/ki/status") async def ki_status(user=Depends(require_mod)): import httpx - from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY + from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, VISION_MODEL, ANTHROPIC_KEY result = { "mode": KI_MODE, @@ -737,6 +747,7 @@ async def ki_status(user=Depends(require_mod)): "local_reachable": False, "local_model_loaded": None, "cloud_model": CLOUD_MODEL, + "vision_model": VISION_MODEL, "cloud_key_set": bool(ANTHROPIC_KEY), } @@ -944,7 +955,7 @@ async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)): async def wiki_evaluate(sample: int = 20, user=Depends(require_mod)): from scraper.breed_evaluator import evaluate_enrichment sample = max(5, min(sample, 50)) - return await evaluate_enrichment(sample_size=sample) + return await evaluate_enrichment(sample_size=sample, user_id=user["id"]) # ------------------------------------------------------------------ diff --git a/backend/routes/diary.py b/backend/routes/diary.py index 2dfcccb..c122e92 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -122,9 +122,14 @@ def _fetch_media_items(conn, entry_ids: list[int]) -> dict: if not entry_ids: return {} ph = ",".join("?" * len(entry_ids)) + # LEFT JOIN media_registry: iCloud-Hybrid-Medien tragen storage='icloud' + + # ck_record_name — die iOS-App holt das Original dann aus CloudKit statt + # von der (Phantom-)URL. Server-Medien: storage NULL/'server'. rows = conn.execute( - f"SELECT id, diary_id, url, media_type, sort_order, is_cover FROM diary_media " - f"WHERE diary_id IN ({ph}) ORDER BY diary_id, sort_order", + f"SELECT dm.id, dm.diary_id, dm.url, dm.media_type, dm.sort_order, dm.is_cover, " + f" mr.storage AS storage, mr.ck_record_name AS ck_record_name " + f"FROM diary_media dm LEFT JOIN media_registry mr ON mr.url = dm.url " + f"WHERE dm.diary_id IN ({ph}) ORDER BY dm.diary_id, dm.sort_order", entry_ids ).fetchall() result = {} @@ -135,6 +140,8 @@ def _fetch_media_items(conn, entry_ids: list[int]) -> dict: "preview_url": preview_url_from(url), "media_type": r["media_type"], "sort_order": r["sort_order"], "is_cover": r["is_cover"], + "storage": r["storage"] or "server", + "ck_record_name": r["ck_record_name"], }) return result @@ -634,6 +641,9 @@ async def delete_diary(dog_id: int, entry_id: int, user=Depends(get_current_user ).fetchall()] for u in media_urls: conn.execute("DELETE FROM daily_photo_cache WHERE photo_url=?", (u,)) + # iCloud-Hybrid: Registry-Row mitlöschen — der nĂ€chste App-Sync + # (GET /api/media/mine) rĂ€umt dann den verwaisten CKRecord ab. + conn.execute("DELETE FROM media_registry WHERE url=?", (u,)) conn.execute( "DELETE FROM diary WHERE id=? AND dog_id=?", (entry_id, dog_id) ) @@ -792,9 +802,16 @@ async def delete_media_item(dog_id: int, entry_id: int, media_id: int, if file_path: try: os.remove(file_path) except OSError: pass + # Preview/Thumb mit-entfernen — bei iCloud-Medien die einzige + # Server-Datei, bei Server-Medien lagen sie bisher als Leichen herum. + base = os.path.splitext(file_path)[0] + for leftover in (base + "_preview.webp", base + "_thumb.jpg"): + try: os.remove(leftover) + except OSError: pass # daily_photo_cache mit-bereinigen falls das Bild als Tagesfoto # gewĂ€hlt war (sonst lĂ€dt der Client 404). conn.execute("DELETE FROM daily_photo_cache WHERE photo_url=?", (row["url"],)) + conn.execute("DELETE FROM media_registry WHERE url=?", (row["url"],)) conn.execute("DELETE FROM diary_media WHERE id=?", (media_id,)) diff --git a/backend/routes/ki.py b/backend/routes/ki.py index 5169634..a2a9bc3 100644 --- a/backend/routes/ki.py +++ b/backend/routes/ki.py @@ -298,7 +298,7 @@ Falls kein Hund erkennbar: ist_hund=false und leeres rassen-Array.""" def _sync_call(): client = anthropic.Anthropic(api_key=api_key) return client.messages.create( - model="claude-opus-4-7", + model=ki_module.VISION_MODEL, max_tokens=500, messages=[{ "role": "user", diff --git a/backend/routes/media.py b/backend/routes/media.py new file mode 100644 index 0000000..09e20d2 --- /dev/null +++ b/backend/routes/media.py @@ -0,0 +1,243 @@ +""" +Media-Registry — iOS-Voll-App M0 (iCloud-Hybrid, Plan: banyaro-ios/PLAN_VOLLAPP.md). + +Originale liegen in der privaten CloudKit-DB des Nutzers (die App lĂ€dt sie dorthin), +der Server speichert nur Previews. Die Registry ist die Quelle der Wahrheit ĂŒber +Existenz und Speicherort jedes Mediums; bei storage='icloud' sieht der Server die +Original-Bytes nie (Apple erlaubt Server-to-Server-Zugriff nur auf Public-DBs). + +Zwei Kontext-Modi: +- diary: Phantom-Original-URL (Datei existiert nicht) + echtes _preview.webp bzw. + _thumb.jpg daneben. Original-Request → 404 + {"storage":"icloud"} + (serve_media in main.py), Web fĂ€llt via onerror aufs Preview zurĂŒck. +- route: Das 800px-Preview wird ALS Datei unter der foto_url gespeichert — + Routen-Fotos haben keine Preview-Konvention, Web bleibt unverĂ€ndert. + +Geteilte Inhalte (GassiTreffen-Fotos, Forum, Challenges) bleiben bewusst komplett +auf dem Server — fremde Nutzer können nicht in eine private iCloud schauen. + +Quota-Fallback: Ist die iCloud des Nutzers voll (CKError.quotaExceeded), lĂ€dt die +App das Original klassisch ĂŒber POST /api/media/{id}/original nach → storage='server'. +""" +import asyncio +import json +import os +import uuid + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from pydantic import BaseModel + +from auth import get_current_user +from database import db +from media_utils import ( + convert_media, generate_preview, preview_url_from, safe_media_path, validate_upload, +) + +router = APIRouter() + +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") + +_PREVIEW_MAX_BYTES = 2 * 1024 * 1024 # Previews/Poster sind ≀800px-JPEGs +_CONTEXTS = {"diary", "route"} +_SUBDIR = {"diary": "diary", "route": "routes"} + + +def _own_diary_entry(conn, entry_id: int, user_id: int): + """Eintrag gehört dem User (direkt ĂŒber dog_id oder via diary_dogs).""" + return conn.execute( + """SELECT d.id FROM diary d + WHERE d.id=? AND ( + d.dog_id IN (SELECT id FROM dogs WHERE user_id=?) + OR EXISTS (SELECT 1 FROM diary_dogs dd + JOIN dogs g ON g.id = dd.dog_id + WHERE dd.diary_id = d.id AND g.user_id=?))""", + (entry_id, user_id, user_id) + ).fetchone() + + +def _own_registry_row(conn, media_id: int, user_id: int): + row = conn.execute( + "SELECT * FROM media_registry WHERE id=? AND user_id=?", (media_id, user_id) + ).fetchone() + if not row: + raise HTTPException(404, "Medium nicht gefunden.") + return row + + +def _write_bytes(path: str, data: bytes) -> None: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as f: + f.write(data) + + +@router.post("/register", status_code=201) +async def register_media( + context: str = Form(...), + context_id: int = Form(...), + kind: str = Form("image"), + img_width: int | None = Form(None), + img_height: int | None = Form(None), + gps_lat: float | None = Form(None), + gps_lon: float | None = Form(None), + bytes_original: int | None = Form(None), + preview: UploadFile = File(...), + user=Depends(get_current_user), +): + """Registriert ein iCloud-Medium: Metadaten + Preview zum Server, Original → CloudKit.""" + if context not in _CONTEXTS: + raise HTTPException(422, "Unbekannter Kontext.") + if kind not in ("image", "video"): + raise HTTPException(422, "kind muss image oder video sein.") + if context == "route" and kind != "image": + raise HTTPException(422, "Routen-Fotos sind Bilder.") + + raw = await preview.read() + if len(raw) > _PREVIEW_MAX_BYTES: + raise HTTPException(413, "Preview zu groß (max 2 MB).") + try: + validate_upload(raw, preview.filename or "preview.jpg") + except ValueError as e: + raise HTTPException(415, str(e)) + prev_ext = os.path.splitext(preview.filename or "")[1].lower() or ".jpg" + + with db() as conn: + if context == "diary": + if not _own_diary_entry(conn, context_id, user["id"]): + raise HTTPException(404, "Eintrag nicht gefunden.") + else: + row = conn.execute( + "SELECT user_id FROM routes WHERE id=?", (context_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Route nicht gefunden.") + if row["user_id"] != user["id"]: + raise HTTPException(403, "Nicht berechtigt.") + + orig_ext = ".jpg" if kind == "image" else ".mp4" + filename = f"{context}_{context_id}_{uuid.uuid4().hex[:8]}{orig_ext}" + subdir = _SUBDIR[context] + url = f"/media/{subdir}/{filename}" + disk = os.path.join(MEDIA_DIR, subdir, filename) + base = os.path.splitext(disk)[0] + + loop = asyncio.get_event_loop() + if context == "diary": + if kind == "image": + webp = await loop.run_in_executor(None, lambda: generate_preview(raw, prev_ext)) + if not webp: + raise HTTPException(415, "Preview konnte nicht verarbeitet werden.") + await loop.run_in_executor(None, lambda: _write_bytes(base + "_preview.webp", webp)) + else: + # Poster-Frame des Videos — gleiche Konvention wie extract_video_thumb() + await loop.run_in_executor(None, lambda: _write_bytes(base + "_thumb.jpg", raw)) + else: + # route: das Preview wird die Datei selbst (Web kennt dort keine Previews) + await loop.run_in_executor(None, lambda: _write_bytes(disk, raw)) + + with db() as conn: + cur = conn.execute( + "INSERT INTO media_registry (user_id, url, storage, ck_state, kind, context, context_id, bytes_original) " + "VALUES (?,?,?,?,?,?,?,?)", + (user["id"], url, "icloud", "pending", kind, context, context_id, bytes_original) + ) + rid = cur.lastrowid + ck_name = f"media-{rid}" + conn.execute("UPDATE media_registry SET ck_record_name=? WHERE id=?", (ck_name, rid)) + + if context == "diary": + max_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) FROM diary_media WHERE diary_id=?", + (context_id,) + ).fetchone()[0] + # Erstes Item eines Eintrags wird automatisch Cover (wie diary.upload_media) + is_cover = 1 if max_order == -1 else 0 + cur2 = conn.execute( + "INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover, img_width, img_height) " + "VALUES (?,?,?,?,?,?,?)", + (context_id, url, kind, max_order + 1, is_cover, img_width, img_height) + ) + dm_id = cur2.lastrowid + # GPS vom GerĂ€t — wie der EXIF-Pfad in diary.upload_media, aber ohne + # Wetter/POI-Nachladen (iOS-EintrĂ€ge bringen i.d.R. Track-GPS mit). + if gps_lat is not None and gps_lon is not None: + existing = conn.execute( + "SELECT gps_lat FROM diary WHERE id=?", (context_id,) + ).fetchone() + if existing and existing["gps_lat"] is None: + conn.execute( + "UPDATE diary SET gps_lat=?, gps_lon=? WHERE id=?", + (gps_lat, gps_lon, context_id) + ) + return {"id": dm_id, "url": url, "preview_url": preview_url_from(url), + "media_type": kind, "sort_order": max_order + 1, + "is_cover": is_cover, "media_id": rid, "ck_record_name": ck_name} + + # route: foto_urls-Append wie routen.add_route_photo + row = conn.execute("SELECT foto_urls FROM routes WHERE id=?", (context_id,)).fetchone() + urls = json.loads(dict(row)["foto_urls"] or "[]") + urls.append(url) + conn.execute("UPDATE routes SET foto_urls=? WHERE id=?", (json.dumps(urls), context_id)) + return {"foto_url": url, "foto_urls": urls, "media_id": rid, "ck_record_name": ck_name} + + +@router.get("/mine") +async def my_media(user=Depends(get_current_user)): + """Sync-Grundlage der App: sie gleicht ihre CloudKit-Zone gegen diese Liste ab + und löscht verwaiste CKRecords (Web-Deletes kennt CloudKit sonst nicht).""" + with db() as conn: + rows = conn.execute( + "SELECT id, url, storage, ck_record_name, ck_state, kind, context, context_id, created_at " + "FROM media_registry WHERE user_id=? ORDER BY id", (user["id"],) + ).fetchall() + return [dict(r) for r in rows] + + +class ConfirmBody(BaseModel): + ck_record_name: str | None = None + + +@router.patch("/{media_id}") +async def confirm_media(media_id: int, body: ConfirmBody, user=Depends(get_current_user)): + """BestĂ€tigt den erfolgreichen CloudKit-Upload des Originals.""" + with db() as conn: + row = _own_registry_row(conn, media_id, user["id"]) + ck_name = body.ck_record_name or row["ck_record_name"] + conn.execute( + "UPDATE media_registry SET ck_state='confirmed', ck_record_name=? WHERE id=?", + (ck_name, media_id) + ) + return {"id": media_id, "storage": "icloud", "ck_state": "confirmed", + "ck_record_name": ck_name} + + +@router.post("/{media_id}/original", status_code=201) +async def upload_original(media_id: int, file: UploadFile = File(...), + user=Depends(get_current_user)): + """Quota-Fallback: iCloud des Nutzers voll → Original klassisch zum Server. + Schreibt die Datei an die registrierte URL (diary: fĂŒllt die Phantom-URL, + route: ersetzt das Preview durch das Original).""" + with db() as conn: + row = _own_registry_row(conn, media_id, user["id"]) + + raw = await file.read() + try: + validate_upload(raw, file.filename or "") + except ValueError as e: + raise HTTPException(415, str(e)) + + loop = asyncio.get_event_loop() + raw, _ext = await loop.run_in_executor( + None, lambda: convert_media(raw, file.filename or "") + ) + + disk = safe_media_path(MEDIA_DIR, row["url"]) + if not disk: + raise HTTPException(400, "UngĂŒltiger Medienpfad.") + await loop.run_in_executor(None, lambda: _write_bytes(disk, raw)) + + with db() as conn: + conn.execute( + "UPDATE media_registry SET storage='server', ck_state='none' WHERE id=?", + (media_id,) + ) + return {"id": media_id, "storage": "server", "url": row["url"]} diff --git a/backend/routes/notes.py b/backend/routes/notes.py index 5a70c2b..f7dac31 100644 --- a/backend/routes/notes.py +++ b/backend/routes/notes.py @@ -1,24 +1,33 @@ """BAN YARO — Notizen Routes""" +import os import json +import uuid +import asyncio import logging from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File from pydantic import BaseModel, Field from typing import Optional, Any, List from database import db from auth import get_current_user from timeutils import safe_client_time +from media_utils import (convert_media, extract_video_thumb, safe_media_path, + validate_upload, validate_audio, to_m4a, + generate_preview, get_image_size) router = APIRouter() logger = logging.getLogger(__name__) +MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ class NoteCreate(BaseModel): - text: str = Field(..., min_length=1, max_length=5000) + # Leerer Text erlaubt: eine reine Medien-Notiz (nur Foto/Sprachnachricht) + # wird zuerst leer angelegt, dann werden die Medien angehĂ€ngt. + text: str = Field("", max_length=5000) meta_json: Optional[Any] = None location_name: Optional[str] = Field(None, max_length=300) parent_label: Optional[str] = Field(None, max_length=200) @@ -35,16 +44,81 @@ class NoteUpdate(BaseModel): # ------------------------------------------------------------------ # Hilfsfunktionen # ------------------------------------------------------------------ -def _serialize(row) -> dict: +def _serialize(row, media_map: Optional[dict] = None) -> dict: d = dict(row) if d.get("meta_json") and isinstance(d["meta_json"], str): try: d["meta_json"] = json.loads(d["meta_json"]) except Exception: pass + if media_map is not None: + d["media_items"] = media_map.get(d["id"], []) return d +def _fetch_note_media(conn, note_ids: list) -> dict: + """LĂ€dt alle Medien zu den gegebenen Notiz-IDs als {note_id: [items]}.""" + if not note_ids: + return {} + placeholders = ",".join("?" * len(note_ids)) + rows = conn.execute( + f"""SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s + FROM note_media WHERE note_id IN ({placeholders}) + ORDER BY sort_order, id""", + note_ids + ).fetchall() + out: dict = {} + for r in rows: + out.setdefault(r["note_id"], []).append(dict(r)) + return out + + +def _guess_note_media_type(content_type: str, filename: str) -> str: + ct = (content_type or "").lower() + if ct == "application/pdf" or (filename or "").lower().endswith(".pdf"): + return "pdf" + if ct.startswith("audio/"): + return "audio" + if ct.startswith("video/"): + return "video" + if ct.startswith("image/"): + return "image" + ext = os.path.splitext(filename or "")[1].lower() + if ext in {".mp4", ".mov", ".webm", ".m4v", ".avi"}: + return "video" + if ext in {".m4a", ".aac", ".mp3", ".ogg", ".oga", ".wav", ".opus"}: + return "audio" + if ext in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif"}: + return "image" + return "file" + + +def _delete_note_media_file(url: str) -> None: + """Löscht eine Mediendatei + zugehörige Preview/Thumb-Leichen von Disk.""" + file_path = safe_media_path(MEDIA_DIR, url) + if not file_path: + return + try: + os.remove(file_path) + except OSError: + pass + base = os.path.splitext(file_path)[0] + for leftover in (base + "_preview.webp", base + "_thumb.jpg"): + try: + os.remove(leftover) + except OSError: + pass + + +def _own_note(note_id: int, user_id: int, conn): + row = conn.execute( + "SELECT id FROM notes WHERE id=? AND user_id=?", (note_id, user_id) + ).fetchone() + if not row: + raise HTTPException(404, "Notiz nicht gefunden.") + return row + + # ------------------------------------------------------------------ # GET /api/notes — Gesamt-Notizblock mit Filtern # Alias: GET /api/notes/all/0 (RĂŒckwĂ€rtskompatibilitĂ€t) @@ -97,8 +171,9 @@ async def list_all_notes_filtered( f"SELECT * FROM notes WHERE {where} ORDER BY {order}", params ).fetchall() + media_map = _fetch_note_media(conn, [r["id"] for r in rows]) - return [_serialize(r) for r in rows] + return [_serialize(r, media_map) for r in rows] @router.get("/all/0") @@ -109,7 +184,8 @@ async def list_all_notes(user=Depends(get_current_user)): "SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC", (user["id"],) ).fetchall() - return [_serialize(r) for r in rows] + media_map = _fetch_note_media(conn, [r["id"] for r in rows]) + return [_serialize(r, media_map) for r in rows] # ------------------------------------------------------------------ @@ -169,6 +245,99 @@ async def ki_analyse(user=Depends(get_current_user)): return {"suggestions": suggestions, "note_count": note_count} +# ------------------------------------------------------------------ +# Medien-AnhĂ€nge an Notizen (Bild/Video/Audio/Datei) +# WICHTIG: Diese Routen MÜSSEN vor /{parent_type}/{parent_id} stehen, +# sonst matcht POST /123/media als parent_type=123, parent_id="media"! +# ------------------------------------------------------------------ +@router.post("/{note_id}/media") +async def upload_note_media(note_id: int, + file: UploadFile = File(...), + user=Depends(get_current_user)): + with db() as conn: + _own_note(note_id, user["id"], conn) + + ct = (file.content_type or "").lower().split(";")[0].strip() + raw_data = await file.read() + loop = asyncio.get_event_loop() + + if ct.startswith("audio/"): + media_type = "audio" + try: + validate_audio(raw_data, ct) + except ValueError as e: + raise HTTPException(415, str(e)) + src_ext = os.path.splitext(file.filename or "")[1].lower() or ".webm" + raw_data, ext = await loop.run_in_executor(None, lambda: to_m4a(raw_data, src_ext)) + else: + # Bild/Video/PDF/sonstige Datei — gleiche Pipeline wie Tagebuch. + try: + validate_upload(raw_data, file.filename or "") + except ValueError as e: + raise HTTPException(415, str(e)) + media_type = _guess_note_media_type(ct, file.filename or "") + raw_data, ext = await loop.run_in_executor( + None, lambda: convert_media(raw_data, file.filename or "") + ) + if not ext: + ext = ".bin" + + filename = f"note_{note_id}_{uuid.uuid4().hex[:8]}{ext}" + path = os.path.join(MEDIA_DIR, "notes", filename) + os.makedirs(os.path.dirname(path), exist_ok=True) + + def _write_bytes(p: str, data: bytes) -> None: + with open(p, "wb") as f: + f.write(data) + + await loop.run_in_executor(None, lambda: _write_bytes(path, raw_data)) + + img_size = None + if media_type == "video": + await loop.run_in_executor(None, lambda: extract_video_thumb(path)) + elif media_type == "image": + preview_bytes = await loop.run_in_executor(None, lambda: generate_preview(raw_data, ext)) + if preview_bytes: + preview_path = os.path.splitext(path)[0] + "_preview.webp" + await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes)) + img_size = await loop.run_in_executor(None, lambda: get_image_size(raw_data)) + + media_url = f"/media/notes/{filename}" + with db() as conn: + max_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) FROM note_media WHERE note_id=?", + (note_id,) + ).fetchone()[0] + conn.execute( + """INSERT INTO note_media (note_id, url, media_type, sort_order, img_width, img_height) + VALUES (?,?,?,?,?,?)""", + (note_id, media_url, media_type, max_order + 1, + img_size[0] if img_size else None, img_size[1] if img_size else None) + ) + row = conn.execute( + """SELECT id, note_id, url, media_type, sort_order, img_width, img_height, duration_s + FROM note_media WHERE note_id=? ORDER BY id DESC LIMIT 1""", + (note_id,) + ).fetchone() + return dict(row) + + +@router.delete("/{note_id}/media/{media_id}", status_code=204) +async def delete_note_media(note_id: int, media_id: int, + user=Depends(get_current_user)): + with db() as conn: + _own_note(note_id, user["id"], conn) + row = conn.execute( + "SELECT id, url FROM note_media WHERE id=? AND note_id=?", + (media_id, note_id) + ).fetchone() + if not row: + raise HTTPException(404, "Medium nicht gefunden.") + _delete_note_media_file(row["url"]) + conn.execute("DELETE FROM note_media WHERE id=?", (media_id,)) + return None + + # ------------------------------------------------------------------ # GET /api/notes/{parent_type}/{parent_id} # parent_id kann ein Integer oder ein String-SchlĂŒssel sein. @@ -184,7 +353,8 @@ async def list_notes(parent_type: str, parent_id: str, ORDER BY created_at DESC""", (user["id"], parent_type, parent_id) ).fetchall() - return [_serialize(r) for r in rows] + media_map = _fetch_note_media(conn, [r["id"] for r in rows]) + return [_serialize(r, media_map) for r in rows] # ------------------------------------------------------------------ @@ -193,9 +363,6 @@ async def list_notes(parent_type: str, parent_id: str, @router.post("/{parent_type}/{parent_id}", status_code=201) async def create_note(parent_type: str, parent_id: str, data: NoteCreate, user=Depends(get_current_user)): - if not data.text.strip(): - raise HTTPException(400, "Notiz darf nicht leer sein.") - meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None now = safe_client_time(data.client_time) @@ -212,7 +379,7 @@ async def create_note(parent_type: str, parent_id: str, data: NoteCreate, "SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1", (user["id"], parent_type, parent_id) ).fetchone() - return _serialize(row) + return _serialize(row, {}) # frisch erstellt → media_items=[]; Upload folgt separat # ------------------------------------------------------------------ @@ -230,8 +397,7 @@ async def update_note(note_id: int, data: NoteUpdate, updates = {} if data.text is not None: - if not data.text.strip(): - raise HTTPException(400, "Notiz darf nicht leer sein.") + # Leer erlaubt — Medien können die Notiz tragen. updates["text"] = data.text.strip() if data.meta_json is not None: updates["meta_json"] = json.dumps(data.meta_json) @@ -241,14 +407,16 @@ async def update_note(note_id: int, data: NoteUpdate, updates["parent_label"] = data.parent_label if not updates: - return _serialize(note) + media_map = _fetch_note_media(conn, [note_id]) + return _serialize(note, media_map) updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") set_clause = ", ".join(f"{k}=?" for k in updates) values = list(updates.values()) + [note_id] conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values) row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone() - return _serialize(row) + media_map = _fetch_note_media(conn, [note_id]) + return _serialize(row, media_map) # ------------------------------------------------------------------ @@ -262,5 +430,8 @@ async def delete_note(note_id: int, user=Depends(get_current_user)): ).fetchone() if not note: raise HTTPException(404, "Notiz nicht gefunden.") + # Medien-Dateien von Disk rĂ€umen (FK-Cascade löscht nur die DB-Zeilen). + for m in conn.execute("SELECT url FROM note_media WHERE note_id=?", (note_id,)).fetchall(): + _delete_note_media_file(m["url"]) conn.execute("DELETE FROM notes WHERE id=?", (note_id,)) return None diff --git a/backend/routes/profile.py b/backend/routes/profile.py index f840ec8..bf16ce0 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -216,6 +216,16 @@ async def delete_account(user=Depends(get_current_user)): if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS: conn.execute(f"DELETE FROM {tbl} WHERE {col}=?", (uid,)) + # note_media-Dateien von Disk rĂ€umen — der FK-Cascade beim users-DELETE + # entfernt nur die DB-Zeilen, nicht die Dateien. + import os as _os + from media_utils import delete_media_files + _note_media_urls = [r["url"] for r in conn.execute( + "SELECT nm.url FROM note_media nm JOIN notes n ON n.id = nm.note_id WHERE n.user_id=?", + (uid,) + ).fetchall()] + delete_media_files(_os.getenv("MEDIA_DIR", "/data/media"), _note_media_urls) + # RĂ€umt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab. conn.execute("DELETE FROM users WHERE id=?", (uid,)) return {"status": "deleted"} diff --git a/backend/routes/routen.py b/backend/routes/routen.py index ee3bac7..6f7968b 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -480,6 +480,12 @@ async def delete_route(route_id: int, user=Depends(get_current_user)): raise HTTPException(404, "Route nicht gefunden.") if row['user_id'] != user['id']: raise HTTPException(403, "Nicht berechtigt.") + # iCloud-Hybrid: Registry-Rows der Routen-Fotos mitlöschen — der nĂ€chste + # App-Sync (GET /api/media/mine) rĂ€umt dann die verwaisten CKRecords ab. + conn.execute( + "DELETE FROM media_registry WHERE context='route' AND context_id=?", + (route_id,) + ) conn.execute("DELETE FROM routes WHERE id = ?", (route_id,)) diff --git a/backend/scheduler.py b/backend/scheduler.py index 8c8009c..c296701 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -989,6 +989,7 @@ async def _generate_praise_for_dog(dog: dict, user_id: int) -> str: from datetime import date, timedelta since = (date.today() - timedelta(days=7)).isoformat() + week_num = date.today().isocalendar()[1] name = dog["name"] rasse = dog.get("rasse") or "Hund" @@ -1029,16 +1030,30 @@ async def _generate_praise_for_dog(dog: dict, user_id: int) -> str: else: aktivitaet_text = ", ".join(aktivitaet_parts) + # W\u00f6chentlich rotierender Fokus \u2192 die KI klingt nicht jede Woche gleich. + _toene = [ + "Betone die enge Verbundenheit zwischen {name} und dir.", + "Hebe die kleinen Abenteuer und besonderen Momente hervor.", + "W\u00fcrdige die sch\u00f6ne gemeinsame Routine und Verl\u00e4sslichkeit.", + "Feiere das gemeinsame Wachsen und die Fortschritte.", + "Betone Ruhe, Geborgenheit und Vertrauen.", + "Schreibe verspielt und mit einem Augenzwinkern.", + ] + ton = _toene[week_num % len(_toene)].replace("{name}", name) + prompt = f"""Du bist ein warmer, wohlwollender Begleiter f\u00fcr Hundebesitzer. Schreibe eine kurze pers\u00f6nliche Lob-Nachricht (2-3 S\u00e4tze) f\u00fcr die vergangene Woche. Hund: {name} ({rasse}) Letzte 7 Tage: {aktivitaet_text} -Dabei seit: {stats.get('weeks_total', 1)} Wochen + +Fokus dieser Woche: {ton} Regeln (unbedingt einhalten): - Nur loben, NIEMALS Ratschl\u00e4ge geben oder auf Fehlendes hinweisen - Sprich \u00fcber den Hund: "{name} hatte eine tolle Woche" \u2014 nicht \u00fcber den Besitzer - Auch bei 0 Aktivit\u00e4ten: positive Formulierung (\u201eAuch ruhige Wochen geh\u00f6ren dazu\u201c) +- Variiere Einstieg und Wortwahl \u2014 klinge NICHT wie letzte Woche +- Erw\u00e4hne KEINE Wochenzahl und keine nackten Statistik-Zahlen - Maximal 3 kurze S\u00e4tze - Warm, pers\u00f6nlich, keine Floskeln - Kein "Du solltest...", kein "Vergiss nicht...", keine Empfehlungen""" @@ -1051,11 +1066,24 @@ Regeln (unbedingt einhalten): ) return text.strip() except Exception: - # Fallback wenn KI nicht verfĂŒgbar - if aktivitaet_parts: - return f"{name} hatte eine aktive Woche \u2014 {aktivitaet_text}. Das ist toll! \U0001f43e" - else: - return f"Auch ruhige Wochen geh\u00f6ren dazu. {name} wei\u00df, dass du f\u00fcr ihn da bist. \U0001f43e" + # Fallback wenn KI nicht verfĂŒgbar — Varianten-Pool, deterministisch pro + # Woche+Hund gewĂ€hlt, damit der Text nicht jede Woche identisch klingt. + aktiv_varianten = [ + f"{name} hatte eine richtig aktive Woche — {aktivitaet_text}. Stark! đŸŸ", + f"Was fĂŒr eine Woche, {name}! {aktivitaet_text} — das kann sich sehen lassen. 🌟", + f"{name} war diese Woche voll dabei: {aktivitaet_text}. Weiter so! đŸ¶", + f"Tolle Woche mit {name} — {aktivitaet_text}. Ihr seid ein super Team! đŸŸ", + f"{aktivitaet_text} — dafĂŒr hat sich {name} eine extra Streicheleinheit verdient. ✹", + ] + ruhig_varianten = [ + f"Auch ruhige Wochen gehören dazu. {name} weiß, dass du fĂŒr ihn da bist. đŸŸ", + f"Diese Woche war's gemĂŒtlich — und das ist völlig okay. {name} genießt die Zeit mit dir. 🌿", + f"Nicht jede Woche muss voll sein. {name} fĂŒhlt sich bei dir einfach wohl. ☀", + f"Eine entspannte Woche mit {name} — manchmal ist genau das das Schönste. đŸŸ", + f"{name} und du — auch ohne großes Programm seid ihr ein eingespieltes Team. đŸ¶", + ] + pool = aktiv_varianten if aktivitaet_parts else ruhig_varianten + return pool[(week_num + dog["id"]) % len(pool)] # ------------------------------------------------------------------ diff --git a/backend/scraper/breed_enricher.py b/backend/scraper/breed_enricher.py index 8aed584..9d0cb97 100644 --- a/backend/scraper/breed_enricher.py +++ b/backend/scraper/breed_enricher.py @@ -360,30 +360,47 @@ async def _fetch_wikimedia_photo(name: str) -> str | None: return None -async def _haiku_complete(prompt: str) -> str: - """Claude Haiku direkt aufrufen (immer Cloud, fĂŒr maximale Genauigkeit).""" - import anthropic +async def _haiku_complete(prompt: str) -> tuple[str, str]: + """ + Fakten-Extraktion. Bevorzugt Claude Haiku (gĂŒnstig + genau); ist kein + Cloud-Key gesetzt oder die Cloud nicht erreichbar, fĂ€llt es sauber auf das + lokale Modell (LM Studio) zurĂŒck, statt hart abzubrechen. + Returns (text, model) — model fließt in wiki_rassen.ki_model, damit der + Evaluator lokal-angereicherte Rassen weiterhin zur QC erkennt. + """ key = os.getenv("ANTHROPIC_API_KEY", "") - if not key: - raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt") - def _call(): - client = anthropic.Anthropic(api_key=key) - return client.messages.create( - model=_HAIKU_MODEL, - max_tokens=700, - system=[{ - "type": "text", - "text": _SYSTEM, - "cache_control": {"type": "ephemeral"}, - }], - messages=[{"role": "user", "content": prompt}], - ) + # 1. Bevorzugt: Claude Haiku direkt (gĂŒnstigstes Cloud-Modell) + if key: + try: + import anthropic - loop = asyncio.get_event_loop() - resp = await loop.run_in_executor(None, _call) - return resp.content[0].text.strip() + def _call(): + client = anthropic.Anthropic(api_key=key) + return client.messages.create( + model=_HAIKU_MODEL, + max_tokens=700, + system=[{ + "type": "text", + "text": _SYSTEM, + "cache_control": {"type": "ephemeral"}, + }], + messages=[{"role": "user", "content": prompt}], + ) + + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor(None, _call) + return resp.content[0].text.strip(), _HAIKU_MODEL + except Exception as e: + logger.warning("Haiku (Cloud) nicht erreichbar, Fallback lokal: %s", e) + + # 2. Fallback: lokales Modell ĂŒber die zentrale KI-Abstraktion + import ki + if ki.KI_MODE == "off": + raise RuntimeError("Kein Cloud-Key und KI_MODE=off — Anreicherung nicht möglich.") + text = await ki._local_complete(prompt, _SYSTEM, max_tokens=700, json_mode=False) + return text, ki.LOCAL_MODEL async def _enrich_one(rasse, dry_run: bool = False) -> bool: @@ -411,12 +428,12 @@ async def _enrich_one(rasse, dry_run: bool = False) -> bool: logger.info("[DRY-RUN] Gefunden: %s (WP-%s, %d Zeichen)", name, wiki_lang.upper(), len(wiki_text)) return True - # 2. Haiku extrahiert Fakten aus dem Quelltext + # 2. KI extrahiert Fakten aus dem Quelltext (Haiku, sonst lokaler Fallback) prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text) try: - raw = await _haiku_complete(prompt) + raw, used_model = await _haiku_complete(prompt) except Exception as e: - logger.error("Haiku-Anfrage fehlgeschlagen fĂŒr %s: %s", name, e) + logger.error("KI-Anfrage fehlgeschlagen fĂŒr %s: %s", name, e) await asyncio.sleep(3) return False @@ -435,7 +452,7 @@ async def _enrich_one(rasse, dry_run: bool = False) -> bool: if "temperament" in updates: updates["temperament"] = translate_temperament(updates["temperament"]) updates["ki_enriched"] = 1 - updates["ki_model"] = _HAIKU_MODEL + updates["ki_model"] = used_model updates["ki_source"] = f"wikipedia_{wiki_lang}" cols = ", ".join(f"{k}=?" for k in updates) diff --git a/backend/scraper/breed_evaluator.py b/backend/scraper/breed_evaluator.py index c78bbfd..30c352f 100644 --- a/backend/scraper/breed_evaluator.py +++ b/backend/scraper/breed_evaluator.py @@ -43,19 +43,23 @@ AktivitĂ€t zur Erfahrung)? ''' -async def evaluate_enrichment(sample_size: int = 20) -> dict: +async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) -> dict: """ - Bewertet `sample_size` zufĂ€llig gewĂ€hlte angereicherte Rassen via Claude. + Bewertet `sample_size` zufĂ€llig gewĂ€hlte angereicherte Rassen als LLM-as-Judge. + + LĂ€uft ĂŒber die zentrale KI-Abstraktion (ki.complete). Admins/Moderatoren werden + dort Cloud-priorisiert (Claude); ist die Cloud nicht erreichbar, fĂ€llt die + Bewertung sauber auf das lokale Modell zurĂŒck, statt hart abzubrechen. Returns dict mit aggregierten Scores und Einzelergebnissen. """ import sys, os sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from database import db + import ki - ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") - if not ANTHROPIC_KEY: - raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt — Evaluierung benötigt Cloud.") + if ki.KI_MODE == "off": + raise RuntimeError("KI ist deaktiviert (KI_MODE=off) — Evaluierung nicht möglich.") with db() as conn: rassen = conn.execute( @@ -65,8 +69,7 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: wohnung_geeignet, temperament, ki_model FROM wiki_rassen WHERE ki_enriched = 1 - AND ki_model IS NOT NULL - AND ki_model NOT LIKE 'claude%' + AND (ki_model IS NULL OR ki_model NOT LIKE 'claude%') ORDER BY RANDOM() LIMIT ?""", (sample_size,), @@ -75,10 +78,10 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: if not rassen: return {"error": "Keine angereicherten Rassen gefunden."} - import anthropic - client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) + _EVAL_SYSTEM = "Du bist ein prĂ€ziser QualitĂ€tsprĂŒfer. Antworte ausschließlich als JSON." results = [] + sources = set() totals = {"vollstaendigkeit": 0, "korrektheit": 0, "sprachqualitaet": 0, "konsistenz": 0, "gesamt": 0} @@ -102,22 +105,17 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: data=json.dumps(data, ensure_ascii=False, indent=2), ) try: - def _call(): - return client.messages.create( - model="claude-haiku-4-5-20251001", - max_tokens=256, - system=[{ - "type": "text", - "text": "Du bist ein prĂ€ziser QualitĂ€tsprĂŒfer. Antworte ausschließlich als JSON.", - "cache_control": {"type": "ephemeral"}, - }], - messages=[{"role": "user", "content": prompt}], - ) - loop = asyncio.get_event_loop() - resp = await loop.run_in_executor(None, _call) - raw = resp.content[0].text.strip() + raw, source = await ki.complete( + prompt, + system=_EVAL_SYSTEM, + max_tokens=256, + json_mode=True, + user_id=user_id, + return_source=True, + ) + sources.add(source) - # JSON extrahieren + # JSON extrahieren (lokale Modelle wrappen gern in ```json 
 ```) import re match = re.search(r"\{[\s\S]+\}", raw) scores = json.loads(match.group(0)) if match else {} @@ -136,9 +134,12 @@ async def evaluate_enrichment(sample_size: int = 20) -> dict: count = len([r for r in results if "error" not in r]) averages = {k: round(v / count, 2) for k, v in totals.items()} if count else {} + judge_source = "/".join(sorted(sources)) if sources else "unbekannt" + return { "sample_size": len(rassen), "evaluated": count, "averages": averages, + "judge_source": judge_source, # "cloud" (Claude) oder "local" (LM Studio) "results": results, } diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 8d537f5..a96b2ab 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -3088,12 +3088,23 @@ html.modal-open { } .rdr-play svg { width: 14px; height: 14px; } .rdr-play:active { background: var(--c-border); } -.rdr-slider { flex: 1; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; } +.rdr-track-wrap { position: relative; flex: 1; min-width: 0; display: flex; align-items: center; } +.rdr-slider { width: 100%; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; } +/* "Jetzt"-Markierung in der Mitte der Zeitleiste (Fangpunkt) */ +.rdr-now-tick { + position: absolute; left: 50%; top: 50%; + transform: translate(-50%, -50%); + width: 2px; height: 13px; + background: var(--c-primary); opacity: 0.45; + border-radius: 1px; pointer-events: none; z-index: 1; +} .rdr-time { flex-shrink: 0; font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; - min-width: 74px; text-align: right; color: var(--c-text-secondary); + width: 112px; /* FESTE Breite → Regler bleibt immer gleich lang */ + white-space: nowrap; overflow: hidden; + text-align: right; color: var(--c-text-secondary); } .rdr-time.is-forecast { color: var(--c-primary); } /* Nowcast/Vorhersage-Frames hervorgehoben */ diff --git a/backend/static/datenschutz.html b/backend/static/datenschutz.html index 8d5a58c..31e2dab 100644 --- a/backend/static/datenschutz.html +++ b/backend/static/datenschutz.html @@ -85,7 +85,7 @@
  • Accountdaten: Benutzername, E-Mail-Adresse, Passphrase (verschlĂŒsselt gespeichert)
  • Hundeprofil: Name, Rasse, Alter, Foto (freiwillig)
  • Gesundheitsdaten deines Hundes: Gewicht, Impfungen, Tierarztbesuche, Medikamente (freiwillig, nur fĂŒr dich sichtbar)
  • -
  • Tagebuch & Notizen: Texte, Fotos, StimmungseintrĂ€ge (privat, nur fĂŒr dich)
  • +
  • Tagebuch & Notizen: Texte, Fotos, Videos, Sprachnachrichten, StimmungseintrĂ€ge (privat, nur fĂŒr dich)
  • Standortdaten: Nur nach expliziter Browser-Freigabe — fĂŒr Karte, Gassi-Treffen, Giftköder-Meldungen, Nearby-Alerts und Routenaufzeichnung. Standortdaten werden nicht dauerhaft gespeichert, außer du speicherst selbst eine Route oder Meldung.
  • @@ -94,6 +94,9 @@
  • Fotos & EXIF-Daten: Beim Hochladen von Bildern können GPS-Koordinaten in den EXIF-Metadaten enthalten sein. Diese werden serverseitig ausgelesen, um Fotos auf der Karte zu verorten — sofern vorhanden. Die Rohdaten werden nicht separat gespeichert.
  • +
  • Mikrofon (Sprachnachrichten): Nur nach expliziter Browser-Freigabe und nur, + wĂ€hrend du in einer Notiz aktiv eine Sprachnachricht aufnimmst. Die Aufnahme wird ausschließlich + auf unseren eigenen Servern gespeichert (kein Drittanbieter), ist privat und nur fĂŒr dich sichtbar.
  • Inhalte: ForenbeitrĂ€ge, Chatnachrichten, öffentliche Gassi-Treffen
  • Technische Daten: IP-Adresse (fĂŒr Sicherheit und Rate-Limiting, max. 30 Tage), Browser-Typ
  • @@ -312,6 +315,19 @@ schreibt die App abgeschlossene Touren als „Walking"-Workout inkl. Route in Apple Health. Es werden keine Gesundheitsdaten aus Apple Health gelesen. Diese Daten verbleiben auf deinem GerĂ€t bzw. in deiner iCloud. +
  • iCloud-Fotospeicher (CloudKit): Fotos, die du in der App zu + Tagebuch oder Touren hinzufĂŒgst, speichert die App in voller Auflösung in + deiner privaten iCloud (Apple CloudKit); sie zĂ€hlen zu deinem + iCloud-Speicherplatz. Unser Server erhĂ€lt und speichert nur eine verkleinerte + Vorschau (max. 800 px), damit deine Inhalte auch im Web sichtbar sind — auf + die Originale in deiner iCloud können wir nicht zugreifen. + Ist kein iCloud-Konto angemeldet oder dein iCloud-Speicher voll, wird stattdessen + wie bisher eine komprimierte Fassung auf unserem Server gespeichert. Löschst du + ein Medium in der App, wird auch der iCloud-Eintrag entfernt; löschst du es im + Web, rĂ€umt die App den iCloud-Eintrag bei der nĂ€chsten Synchronisierung auf. + Nach einer Account-Löschung entfernen wir Vorschauen und Verweise auf unserem + Server — die Originale in deiner iCloud bleiben unter deiner alleinigen Kontrolle + (Einstellungen â†’ iCloud â†’ Speicher verwalten â†’ Ban Yaro).
  • Apple Maps: Zur Navigation (z. B. zu Gassi-Treffen) kann auf deinen Wunsch Apple Maps geöffnet werden.
  • GPX-Import: Aus anderen Apps geteilte GPX-Dateien werden lokal auf @@ -495,7 +511,7 @@

    - Stand: Juni 2026 · Version 4 + Stand: Juni 2026 · Version 6

    diff --git a/backend/static/index.html b/backend/static/index.html index 385e4ae..830ff54 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -620,12 +620,12 @@ - - - - - - + + + + + + @@ -635,7 +635,7 @@ - + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 7b0e5d8..5b71533 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -675,6 +675,12 @@ const API = (() => { delete(id) { return del(`/notes/${id}`); }, + uploadMedia(noteId, formData) { + return upload(`/notes/${noteId}/media`, formData); + }, + deleteMedia(noteId, mediaId) { + return del(`/notes/${noteId}/media/${mediaId}`); + }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 9b7942e..2c2232a 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1278'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1292'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfĂŒgbar fĂŒr andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index f5ad0ee..ae8d0db 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -1419,7 +1419,10 @@ window.Page_admin = (() => { ${rows} `; - res.textContent = `✓ Bewertung abgeschlossen`; + const judge = d.judge_source === 'cloud' ? 'Claude (Cloud)' + : d.judge_source === 'local' ? 'lokales Modell ⚠' + : (d.judge_source || '–'); + res.textContent = `✓ Bewertung abgeschlossen — PrĂŒfer: ${judge}`; } catch (err) { res.textContent = '✗ Fehler: ' + (err.message || err); } finally { diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index f6d07e7..d34e04f 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -283,6 +283,11 @@ window.Page_diary = (() => { if (!data?.praise) return; + // Weggeklickten WochenrĂŒckblick nicht erneut zeigen (pro Kalenderwoche). + // NĂ€chste Woche (neuer week_key) erscheint er wieder. + const praiseKey = data.week_key || ''; + if (praiseKey && localStorage.getItem('by_diary_praise_dismissed') === praiseKey) return; + const card = document.createElement('div'); card.id = 'diary-praise-card'; card.style.cssText = ` @@ -316,6 +321,7 @@ window.Page_diary = (() => { if (list && list.parentNode) list.parentNode.insertBefore(card, list); card.querySelector('#diary-praise-close')?.addEventListener('click', () => { + if (praiseKey) { try { localStorage.setItem('by_diary_praise_dismissed', praiseKey); } catch (_) {} } card.style.opacity = '0'; card.style.transition = 'opacity .2s'; setTimeout(() => card.remove(), 200); diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index 130dd4f..fad2572 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -451,6 +451,9 @@ window.Page_map = (() => { let _radarNowIdx = 0; // Index des "jetzt"-Frames (letzte Vergangenheit) let _radarPlaying = false; let _radarPlayTimer = null; + let _radarLayerKind = null; // 'rv' (RainViewer-PNG) | 'dwd' (pmtiles) — fĂŒr sauberen Layer-Wechsel + let _rdrPendingIdx = null; // Regler-Entprellung: zuletzt gewĂŒnschter Frame + let _rdrRaf = null; // requestAnimationFrame-Handle fĂŒr die Koaleszenz async function _toggleRadar() { if (!App.hasPro(_appState?.user)) { @@ -461,7 +464,9 @@ window.Page_map = (() => { if (_radarActive) { _radarActive = false; _radarPause(); - if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; } + if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; _radarLayerKind = null; } + if (_rdrRaf != null) { cancelAnimationFrame(_rdrRaf); _rdrRaf = null; } + _rdrPendingIdx = null; clearInterval(_radarTimer); document.getElementById('map-radar-timeline')?.remove(); btn?.classList.remove('active'); @@ -602,67 +607,107 @@ window.Page_map = (() => { async function _loadRadar() { if (!_radarActive || !_map) return; try { - const resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { cache: 'no-store' }); + // Cache-Buster: sonst liefert der Service-Worker u. U. einen alten RainViewer-Stand + // (Frames hingen ~50 min nach → DWD-Frische-Check fiel durch, GerĂ€tetest 2026-06-09). + const resp = await fetch(`https://api.rainviewer.com/public/weather-maps.json?_t=${Date.now()}`, { cache: 'no-store' }); const data = await resp.json(); const past = data.radar?.past || [], nowcast = data.radar?.nowcast || []; if (!past.length && !nowcast.length) return; _radarHost = data.host || _radarHost; const rvUrl = f => `${_radarHost}${f.path}/256/{z}/{x}/{y}/4/1_1.png`; - // Default: RainViewer komplett (~2h Vergangenheit + ~30 min Nowcast) - let frames = [...past, ...nowcast].map(f => ({ url: rvUrl(f), time: f.time })); - let nowIdx = Math.max(0, past.length - 1); // "jetzt" = letzter Vergangenheits-Frame + // Symmetrische ±2h-Zeitleiste: letzte 2 h (RainViewer) | jetzt | nĂ€chste 2 h (DWD/Nowcast) + const WINDOW = 2 * 60 * 60; // 2 h je Seite + const nowSec = Math.floor(Date.now() / 1000); // Echtzeit-Referenz (GerĂ€teuhr ist zuverlĂ€ssig) - // DWD-Vorhersage (0–120 min, 5-Min-Schritte): ersetzt den RainViewer-Nowcast, - // Vergangenheit bleibt RainViewer (docs/DWD_RAIN_FORECAST_PLAN.md). + // Vergangenheit: RainViewer der letzten 2 h + let pastFrames = past + .filter(f => f.time >= nowSec - WINDOW && f.time <= nowSec) + .map(f => ({ url: rvUrl(f), time: f.time })); + + // "Jetzt" + Zukunft — Default: RainViewer-Nowcast (~30 min) + let nowFrame = null; + let futureFrames = nowcast + .filter(f => f.time > nowSec && f.time <= nowSec + WINDOW) + .map(f => ({ url: rvUrl(f), time: f.time })); + + // DWD-Vorhersage (0–120 min, 5-Min-Schritte) bevorzugt (docs/DWD_RAIN_FORECAST_PLAN.md) if (_dwdEnabled() && _engineGL && _mapInDwdCoverage()) { try { const r = await fetch('/radar/manifest.json', { cache: 'no-store' }); if (r.ok) { const man = await r.json(); const runT = Math.floor(Date.parse(man.run_time_utc) / 1000); - // Nur wenn der Lauf frisch ist (< 30 min) — sonst RainViewer-Fallback - if (man.frames?.length && (Date.now() / 1000 - runT) < 1800) { - const pastRv = past.filter(f => f.time <= runT).map(f => ({ url: rvUrl(f), time: f.time })); + // Frische des DWD-Laufs gegen die ECHTZEIT prĂŒfen (< 30 min) — NICHT gegen den + // jĂŒngsten RainViewer-Frame, der deutlich nachhĂ€ngen kann (sonst fĂ€llt DWD raus). + if (man.frames?.length && Math.abs(nowSec - runT) < 1800) { const dwd = man.frames.map(fr => ({ url: `pmtiles://${location.origin}/radar/${man.path}/${fr.file}/{z}/{x}/{y}`, time: runT + fr.lead_min * 60, + lead: fr.lead_min, dwd: true, })); - frames = [...pastRv, ...dwd]; - nowIdx = pastRv.length; // DWD lead 0 = "jetzt" + nowFrame = dwd.find(f => f.lead === 0) || null; // lead 0 = "jetzt" + futureFrames = dwd.filter(f => f.lead > 0 && f.time <= runT + WINDOW); + pastFrames = pastFrames.filter(f => f.time < runT); // Überlappung mit DWD-"jetzt" vermeiden } } } catch (e) { /* offline/kein Manifest → RainViewer-Fallback */ } } - _radarFrames = frames; - _radarNowIdx = nowIdx; + // Kein DWD-"jetzt"? → jĂŒngsten Vergangenheits-Frame (sonst Ă€ltesten Zukunfts-Frame) als "jetzt" + if (!nowFrame) { + if (pastFrames.length) nowFrame = pastFrames.pop(); + else if (futureFrames.length) nowFrame = futureFrames.shift(); + } + if (!nowFrame) return; + + _radarFrames = [...pastFrames, nowFrame, ...futureFrames]; + _radarNowIdx = pastFrames.length; // "jetzt" liegt direkt nach der Vergangenheit if (_radarIdx == null || _radarIdx >= _radarFrames.length) _radarIdx = _radarNowIdx; _showRadarFrame(_radarIdx); _buildRadarTimeline(); } catch { /* still */ } } - function _radarUrl(idx) { - return _radarFrames[idx].url; - } - // Frame anzeigen — wenn möglich smooth via setTiles (kein Flackern), sonst Layer neu. function _showRadarFrame(idx) { if (!_radarActive || !_radarFrames[idx]) return; _radarIdx = idx; - const url = _radarUrl(idx); + const f = _radarFrames[idx]; + const url = f.url; + const kind = f.dwd ? 'dwd' : 'rv'; const src = _engineGL && _radarLayer && _map.getSource && _map.getSource('wx-radar'); - if (src && src.setTiles) { + // setTiles nur innerhalb DESSELBEN Quell-Typs (png↔png bzw. pmtiles↔pmtiles). + // Beim Wechsel RainViewer↔DWD den Layer komplett neu aufbauen — sonst bleiben die + // alten Kacheln stehen (DWD "neutralisiert" die RainViewer-Wolken nicht). + if (src && src.setTiles && kind === _radarLayerKind) { src.setTiles([url]); } else { if (_radarLayer) _wxRemoveRaster(_radarLayer); _radarLayer = _wxAddRaster('radar', url, 0.7, 7); + _radarLayerKind = kind; } _updateRadarTimelineUI(); } + // Slider-Position (0–1000) ↔ Frame-Index. "jetzt" liegt fix bei 500 (Mitte): + // Vergangenheit nutzt die linke, Vorhersage die rechte HĂ€lfte — unabhĂ€ngig von der + // Frame-Anzahl je Seite. So sitzt "jetzt" optisch mittig (Fangpunkt). + const RDR_MID = 500, RDR_SNAP = 28; + function _radarPosToIdx(pos) { + const now = _radarNowIdx, last = _radarFrames.length - 1; + if (pos <= RDR_MID) return now > 0 ? Math.round((pos / RDR_MID) * now) : 0; + const fut = last - now; + return fut > 0 ? now + Math.round(((pos - RDR_MID) / RDR_MID) * fut) : now; + } + function _radarIdxToPos(idx) { + const now = _radarNowIdx, last = _radarFrames.length - 1; + if (idx <= now) return now > 0 ? Math.round((idx / now) * RDR_MID) : RDR_MID; + const fut = last - now; + return fut > 0 ? RDR_MID + Math.round(((idx - now) / fut) * RDR_MID) : RDR_MID; + } + function _buildRadarTimeline() { if (!_radarFrames.length) return; let el = document.getElementById('map-radar-timeline'); @@ -674,17 +719,27 @@ window.Page_map = (() => { - +
    + + +
    `; document.getElementById('central-map')?.appendChild(el); el.querySelector('#rdr-play').addEventListener('click', _toggleRadarPlay); el.querySelector('#rdr-slider').addEventListener('input', e => { - const idx = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurĂŒck + let pos = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurĂŒck + if (Math.abs(pos - RDR_MID) <= RDR_SNAP) { pos = RDR_MID; e.target.value = RDR_MID; } // Fangpunkt "jetzt" _radarPause(); - _showRadarFrame(idx); + // Entprellen: pro Animationsframe nur EIN setTiles, egal wie schnell gezogen wird + // (sonst bricht jeder neue Frame die laufenden Kachel-Requests ab → AbortError-Spam). + _rdrPendingIdx = _radarPosToIdx(pos); + if (_rdrRaf == null) { + _rdrRaf = requestAnimationFrame(() => { + _rdrRaf = null; + if (_rdrPendingIdx != null) { _showRadarFrame(_rdrPendingIdx); _rdrPendingIdx = null; } + }); + } }); - } else { - el.querySelector('#rdr-slider').max = _radarFrames.length - 1; } // Breite an die Status-Pill angleichen → gleiche linke + rechte Kante. const pill = document.querySelector('.map-statusbar'); @@ -696,7 +751,7 @@ window.Page_map = (() => { const slider = document.getElementById('rdr-slider'); const timeEl = document.getElementById('rdr-time'); const playBtn = document.getElementById('rdr-play'); - if (slider) slider.value = _radarIdx; + if (slider) slider.value = _radarIdxToPos(_radarIdx); if (playBtn) playBtn.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${_radarPlaying ? 'pause' : 'play'}`); const f = _radarFrames[_radarIdx]; if (timeEl && f) { @@ -704,8 +759,8 @@ window.Page_map = (() => { const hhmm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; const diffMin = Math.round((f.time - _radarFrames[_radarNowIdx].time) / 60); const rel = diffMin === 0 ? 'jetzt' : (diffMin > 0 ? `+${diffMin} Min` : `${diffMin} Min`); - timeEl.textContent = `${hhmm} · ${rel}${f.dwd && diffMin > 0 ? ' · DWD' : ''}`; - timeEl.classList.toggle('is-forecast', diffMin > 0); + timeEl.textContent = `${hhmm} · ${rel}`; // feste Breite (CSS) → Regler springt nicht + timeEl.classList.toggle('is-forecast', diffMin > 0); // Vorhersage-Frames farblich (statt "· DWD"-Text) } } @@ -1008,6 +1063,14 @@ window.Page_map = (() => { center, zoom, attributionControl: false, maxZoom: 19, dragRotate: false, pitchWithRotate: false, }); + // setTiles bricht beim schnellen Regler-Ziehen laufende Kachel-Requests ab → harmloser + // AbortError. Eigener error-Handler verschluckt ihn, lĂ€sst echte Fehler aber durch. + _map.on('error', (e) => { + const err = e && e.error; + const msg = (err && ((err.name || '') + ' ' + (err.message || ''))) || String(e || ''); + if (/abort/i.test(msg)) return; + console.warn('MapLibre:', err || e); + }); // Zwei-Finger-Rotation aus → Pinch ist reines Zoom (weniger moveend, klarere Geste). _map.touchZoomRotate.disableRotation(); _map.touchPitch.disable(); diff --git a/backend/static/js/pages/notes.js b/backend/static/js/pages/notes.js index 6fceffe..b281a46 100644 --- a/backend/static/js/pages/notes.js +++ b/backend/static/js/pages/notes.js @@ -305,6 +305,34 @@ window.Page_notes = (() => { `; } + // ---------------------------------------------------------- + // Medien-Helfer: Preview-URL ableiten + Indikator-Strip fĂŒr die Karte + // ---------------------------------------------------------- + function _notePreview(url) { + if (!url) return url; + const dot = url.lastIndexOf('.'); + if (dot < 0) return url; + if (/\.(mp4|webm|mov|avi|m4v)$/i.test(url)) return url.slice(0, dot) + '_thumb.jpg'; + if (/\.(m4a|aac|mp3|ogg|oga|wav|pdf)$/i.test(url)) return url; + return url.slice(0, dot) + '_preview.webp'; + } + + function _noteMediaStrip(note) { + const items = note.media_items || []; + if (!items.length) return ''; + const n = { image: 0, video: 0, audio: 0, pdf: 0, file: 0 }; + items.forEach(m => { n[m.media_type] = (n[m.media_type] || 0) + 1; }); + const parts = []; + if (n.image) parts.push(['image', n.image]); + if (n.video) parts.push(['video-camera', n.video]); + if (n.audio) parts.push(['microphone', n.audio]); + if (n.pdf + n.file) parts.push(['paperclip', n.pdf + n.file]); + if (!parts.length) return ''; + return `
    + ${parts.map(([icon, count]) => `${UI.icon(icon)} ${count}`).join('')} +
    `; + } + // ---------------------------------------------------------- // Notiz-Karte HTML // ---------------------------------------------------------- @@ -342,7 +370,9 @@ window.Page_notes = (() => { -

    ${UI.escape(_truncate(note.text))}

    + ${note.text ? `

    ${UI.escape(_truncate(note.text))}

    ` : ''} + + ${_noteMediaStrip(note)} ${microBadges.length ? ` @@ -495,7 +525,20 @@ window.Page_notes = (() => { ${note.parent_label ? `
    ${UI.escape(note.parent_label)}
    ` : ''} -

    ${UI.escape(note.text || '')}

    + ${note.text ? `

    ${UI.escape(note.text)}

    ` : ''} + + ${(note.media_items && note.media_items.length) ? ` +
    + ${note.media_items.map(m => { + if (m.media_type === 'image') + return ``; + if (m.media_type === 'video') + return ``; + if (m.media_type === 'audio') + return ``; + return `${UI.icon('file-text')} ${m.media_type === 'pdf' ? 'PDF öffnen' : 'Datei öffnen'}`; + }).join('')} +
    ` : ''} ${microBadges.length ? `
    @@ -523,6 +566,16 @@ window.Page_notes = (() => { UI.modal.close(); _openEditModal(note); }); + + // Bild-Thumbnails → Lightbox; Preview→Original-Fallback (CSP-konform) + const _imgItems = (note.media_items || []).filter(m => m.media_type === 'image').map(m => ({ url: m.url, type: 'image' })); + document.querySelectorAll('.notes-detail-media-img').forEach(img => { + img.addEventListener('error', () => { if (img.src !== img.dataset.full) img.src = img.dataset.full; }, { once: true }); + img.addEventListener('click', () => { + const idx = _imgItems.findIndex(it => it.url === img.dataset.full); + UI.lightbox?.show(_imgItems, Math.max(0, idx)); + }); + }); } // ---------------------------------------------------------- @@ -587,6 +640,12 @@ window.Page_notes = (() => { box-sizing:border-box">
    + +
    + +
    +
    +
    @@ -597,40 +656,52 @@ window.Page_notes = (() => { overlay.innerHTML = _buildContent(); document.body.appendChild(overlay); - const _rebind = () => { - overlay.querySelectorAll('.nc-cat').forEach(btn => { - btn.addEventListener('click', () => { - _selType = btn.dataset.type; - overlay.innerHTML = _buildContent(); - _rebind(); - overlay.querySelector('#nc-text')?.focus(); + const _media = UI.noteMediaAttacher({ containerId: 'nc-media' }); + const _remove = () => { _media.destroy(); overlay.remove(); }; + + // Kategorie-Wechsel: nur Auswahl + Button-Styles aktualisieren — KEIN + // innerHTML-Rebuild, sonst gingen eingegebener Text & angehĂ€ngte Medien + // (und eine laufende Sprachaufnahme) verloren. + overlay.querySelectorAll('.nc-cat').forEach(btn => { + btn.addEventListener('click', () => { + _selType = btn.dataset.type; + overlay.querySelectorAll('.nc-cat').forEach(b => { + const r = _rubrik(b.dataset.type); + const active = b.dataset.type === _selType; + b.style.borderColor = active ? r.color : 'var(--c-border)'; + b.style.background = active ? r.color + '22' : 'var(--c-surface-2)'; + b.style.color = active ? r.color : 'var(--c-text-secondary)'; }); }); + }); - overlay.querySelector('#nc-cancel')?.addEventListener('click', () => overlay.remove()); - overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); + overlay.querySelector('#nc-cancel')?.addEventListener('click', _remove); + overlay.addEventListener('click', e => { if (e.target === overlay) _remove(); }); - overlay.querySelector('#nc-save')?.addEventListener('click', async () => { - const text = overlay.querySelector('#nc-text')?.value?.trim(); - if (!text) { UI.toast.warning('Bitte einen Text eingeben.'); return; } - const btn = overlay.querySelector('#nc-save'); - await UI.asyncButton(btn, async () => { - const rb = _rubrik(_selType); - await API.notes.create(_selType, 'standalone', { - text, - parent_label: rb.label, - }); - overlay.remove(); - _filterType = _selType; - await _reload(); - UI.toast.success('Notiz gespeichert.'); + overlay.querySelector('#nc-save')?.addEventListener('click', async () => { + const text = overlay.querySelector('#nc-text')?.value?.trim(); + if (!text && !_media.hasPending()) { + UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhĂ€ngen.'); + return; + } + const btn = overlay.querySelector('#nc-save'); + await UI.asyncButton(btn, async () => { + const rb = _rubrik(_selType); + const created = await API.notes.create(_selType, 'standalone', { + text: text || '', + parent_label: rb.label, }); + if (created?.id && _media.hasPending()) { + await _media.uploadAll(created.id, (d, t) => { btn.textContent = `${d}/${t} hochgeladen
`; }); + } + _remove(); + _filterType = _selType; + await _reload(); + UI.toast.success('Notiz gespeichert.'); }); + }); - setTimeout(() => overlay.querySelector('#nc-text')?.focus(), 100); - }; - - _rebind(); + setTimeout(() => overlay.querySelector('#nc-text')?.focus(), 100); } // ---------------------------------------------------------- @@ -732,6 +803,13 @@ window.Page_notes = (() => {
    ` : ''} + +
    + +
    +
    + @@ -760,6 +838,12 @@ window.Page_notes = (() => { document.body.appendChild(overlay); + const _media = UI.noteMediaAttacher({ + containerId: 'notes-edit-media', + noteId: note.id, + existingMedia: note.media_items || [], + }); + let selErfolgsquote = meta.erfolgsquote || null; let selUmgebung = meta.umgebung || null; let selStimmung = meta.hund_stimmung || null; @@ -796,14 +880,17 @@ window.Page_notes = (() => { }); }); - function _close() { overlay.remove(); } + function _close() { _media.destroy(); overlay.remove(); } overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); overlay.querySelector('#notes-edit-cancel').addEventListener('click', _close); // Speichern overlay.querySelector('#notes-edit-save').addEventListener('click', async () => { const text = overlay.querySelector('#notes-edit-text').value.trim(); - if (!text) { UI.toast.warning('Notiz darf nicht leer sein.'); return; } + if (!text && !_media.hasPending() && !(note.media_items || []).length) { + UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhĂ€ngen.'); + return; + } const saveBtn = overlay.querySelector('#notes-edit-save'); saveBtn.disabled = true; @@ -819,6 +906,10 @@ window.Page_notes = (() => { text, meta_json: Object.keys(metaObj).length > 0 ? metaObj : null, }); + if (_media.hasPending()) { + const { uploaded } = await _media.uploadAll(note.id, (d, t) => { saveBtn.textContent = `${d}/${t} hochgeladen
`; }); + updated.media_items = (updated.media_items || []).concat(uploaded); + } const idx = _notes.findIndex(n => n.id === note.id); if (idx >= 0) _notes[idx] = updated; _render(); diff --git a/backend/static/js/pages/routes.js b/backend/static/js/pages/routes.js index ac1d151..d57863b 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -1996,6 +1996,15 @@ window.Page_routes = (() => { // alles grĂŒn, 99 % ab Start (Praxistest RenĂ© 2026-06-07, Gassirunde Siegenhofen). // Global nur beim ersten Fix oder wenn verloren (Fenster-Treffer > 300 m entfernt). let _navIdxInit = false; + // Runde erkennen: Start ≈ Ende (< 60 m). An einem solchen Start/Ende-Knoten ist der + // ENDPUNKT oft ein paar Meter nĂ€her als der Startpunkt — die globale Erst-Suche sprang + // dann sofort ans Track-ENDE → 100 % / 0 km ab Sekunde 1, kein Bellen, alles grĂŒn, und + // der gelaufene-Weg-Eintrag wurde fĂ€lschlich als komplett gespeichert. Der alte 25-m- + // Gleichstand reichte nicht, wenn der Start >28 m weg lag (Siegenhofen RenĂ© 2026-06-07, + // Deining Angie 2026-06-09). + const _navIsLoop = track.length > 2 && + _haversineKm(track[0].lat, track[0].lon, + track[track.length - 1].lat, track[track.length - 1].lon) < 0.06; const _closestIdx = (lat, lon) => { const search = (from, to) => { let best = from, bestD = Infinity; @@ -2007,8 +2016,16 @@ window.Page_routes = (() => { }; if (!_navIdxInit) { _navIdxInit = true; - // Erster Fix: global, aber bei Quasi-Gleichstand (< 25 m) den START bevorzugen (Loop!) const g = search(0, track.length - 1); + if (_navIsLoop) { + // Runde: steht man irgendwo in StartnĂ€he (< 150 m), bei 0 % beginnen statt ans + // nahe Track-Ende zu springen. Erst wer weit vom Start steht, ist mitten in die + // Runde eingestiegen → globaler Treffer. Startfenster = erste 15 % (mind. 30 Pkt.). + const win = Math.min(track.length - 1, Math.max(30, Math.floor(track.length * 0.15))); + const s = search(0, win); + return s.bestD < 0.15 ? s.best : g.best; + } + // Punkt-zu-Punkt: bei Quasi-Gleichstand (< 25 m) den START bevorzugen. const s = search(0, Math.min(track.length - 1, 30)); return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best; } diff --git a/backend/static/js/ui.js b/backend/static/js/ui.js index f7e5de4..297fa15 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -1706,6 +1706,270 @@ const UI = (() => { }); } + // ---------------------------------------------------------- + // NOTE-MEDIA-ATTACHER — wiederverwendbarer Medien-Anhang fĂŒr Notizen. + // Buttons (Mediathek/Aufnehmen/Datei/Sprachnachricht), Liste anhĂ€ngiger + // Dateien, Sprachaufnahme (MediaRecorder) und bereits gespeicherte Medien + // (mit Löschen). Genutzt von noteModal (ui.js) UND der Notizblock-Seite + // (pages/notes.js) — eine Quelle statt Duplikat. + // const m = UI.noteMediaAttacher({ containerId, noteId, existingMedia }); + // 
im Submit nach create/update: await m.uploadAll(noteId, onProgress); + // ---------------------------------------------------------- + function noteMediaAttacher({ containerId, noteId = null, existingMedia = [] } = {}) { + const container = document.getElementById(containerId); + const _noop = { uploadAll: async () => ({ uploaded: [], failed: 0 }), hasPending: () => false, destroy: () => {} }; + if (!container) return _noop; + + const p = containerId.replace(/[^a-z0-9]/gi, '-'); + const ids = { + bar: `${p}-bar`, pending: `${p}-pending`, existing: `${p}-existing`, + rec: `${p}-rec`, recTimer: `${p}-rec-timer`, recStop: `${p}-rec-stop`, + recCancel: `${p}-rec-cancel`, mic: `${p}-mic`, + }; + + let _noteId = noteId; + const _pending = []; // [{ file, url }] + let _existing = (existingMedia || []).slice(); + const RECORDER_OK = !!(window.MediaRecorder && navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + let _stream = null, _rec = null, _chunks = [], _tick = null, _recStart = 0, _recMax = null, _recCancelled = false; + + const BTN = 'display:inline-flex;align-items:center;gap:6px;font-size:var(--text-xs);font-weight:600;' + + 'padding:7px 12px;border-radius:var(--radius-full);border:1.5px solid var(--c-border);' + + 'background:var(--c-surface-2);color:var(--c-text-secondary);cursor:pointer'; + const DEL = 'flex-shrink:0;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.08);' + + 'color:var(--c-text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:14px;line-height:1'; + const ROW = 'display:flex;align-items:center;gap:10px;padding:6px;border:1px solid var(--c-border);border-radius:var(--radius-md)'; + + container.innerHTML = ` +
    +
    + + ${RECORDER_OK ? `` : ''} +
    + +
    + `; + + // ---- Datei-Picker (nativer Browser-Dialog) ---- + function _openPicker(opts = {}) { + const tmp = document.createElement('input'); + tmp.type = 'file'; + tmp.multiple = true; + tmp.accept = 'image/*,video/*'; + tmp.style.display = 'none'; + if (opts.capture) tmp.setAttribute('capture', opts.capture); + if (opts.noAccept) tmp.removeAttribute('accept'); + tmp.addEventListener('change', () => { if (tmp.files.length) _addFiles(tmp.files); tmp.remove(); }); + document.body.appendChild(tmp); + tmp.click(); + } + + function _addFiles(list) { + for (const f of list) _pending.push({ file: f, url: URL.createObjectURL(f) }); + _renderPending(); + } + + function _renderPending() { + const grid = document.getElementById(ids.pending); + if (!grid) return; + grid.innerHTML = _pending.map((it, i) => { + const f = it.file, t = f.type || ''; + let preview, label = ''; + if (t.startsWith('image/')) { + preview = ``; + label = `${escape(f.name || 'Bild')}`; + } else if (t.startsWith('video/')) { + preview = ``; + label = `${escape(f.name || 'Video')}`; + } else if (t.startsWith('audio/')) { + preview = ``; + } else { + preview = ``; + label = `${escape(f.name || 'Datei')}`; + } + return `
    ${preview}${label}
    `; + }).join(''); + grid.querySelectorAll('button[data-pidx]').forEach(btn => { + btn.addEventListener('click', () => { + const i = parseInt(btn.dataset.pidx, 10); + if (_pending[i]) { try { URL.revokeObjectURL(_pending[i].url); } catch (_) {} _pending.splice(i, 1); } + _renderPending(); + }); + }); + } + + function _previewOf(url) { + if (!url) return url; + if (/\.(mp4|webm|mov|avi|m4a|aac|mp3|ogg|oga|wav|pdf)$/i.test(url)) return url; + const dot = url.lastIndexOf('.'); + return dot > 0 ? url.slice(0, dot) + '_preview.webp' : url; + } + + function _renderExisting() { + const wrap = document.getElementById(ids.existing); + if (!wrap) return; + if (!_existing.length) { wrap.innerHTML = ''; return; } + wrap.innerHTML = `
    ${_existing.map(m => { + let preview, label = ''; + if (m.media_type === 'image') { + preview = ``; + } else if (m.media_type === 'video') { + preview = ``; + } else if (m.media_type === 'audio') { + preview = ``; + } else { + preview = `${escape((m.media_type === 'pdf' ? 'PDF' : 'Datei'))}`; + } + return `
    ${preview}${label}
    `; + }).join('')}
    `; + // CSP-konformer Preview→Original-Fallback + wrap.querySelectorAll('img[data-full]').forEach(img => { + img.addEventListener('error', () => { if (img.src !== img.dataset.full) img.src = img.dataset.full; }, { once: true }); + }); + wrap.querySelectorAll('img[data-full-open]').forEach(img => { + img.addEventListener('click', () => { + const imgs = _existing.filter(m => m.media_type === 'image').map(m => ({ url: m.url, type: 'image' })); + const idx = imgs.findIndex(it => it.url === img.dataset.fullOpen); + if (UI.lightbox) UI.lightbox.show(imgs, Math.max(0, idx)); + }); + }); + wrap.querySelectorAll('button[data-mid]').forEach(btn => { + btn.addEventListener('click', async () => { + const mid = parseInt(btn.dataset.mid, 10); + if (_noteId == null) { _existing = _existing.filter(m => m.id !== mid); _renderExisting(); return; } + btn.disabled = true; + try { + await API.notes.deleteMedia(_noteId, mid); + } catch (e) { + if (e?.status !== 404) { btn.disabled = false; toast.error(e.message || 'Löschen fehlgeschlagen.'); return; } + } + _existing = _existing.filter(m => m.id !== mid); + _renderExisting(); + toast.success('Medium entfernt.'); + }); + }); + } + + // ---- Sprachaufnahme ---- + function _stopTracks() { if (_stream) { _stream.getTracks().forEach(t => t.stop()); _stream = null; } } + + function _setRecState(on) { + const rec = document.getElementById(ids.rec); + const bar = document.getElementById(ids.bar); + if (rec) rec.style.display = on ? 'flex' : 'none'; + if (bar) { bar.style.opacity = on ? '.4' : ''; bar.querySelectorAll('button').forEach(b => b.disabled = on); } + } + + function _updateTimer() { + const el = document.getElementById(ids.recTimer); + if (!el) return; + const s = Math.floor((Date.now() - _recStart) / 1000); + el.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; + } + + async function _startRecording() { + try { + _stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (_) { + toast.error('Kein Mikrofon-Zugriff. Bitte in den GerĂ€te-Einstellungen erlauben.'); + return; + } + const cands = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus']; + const mime = cands.find(t => { try { return MediaRecorder.isTypeSupported(t); } catch (_) { return false; } }) || ''; + try { _rec = mime ? new MediaRecorder(_stream, { mimeType: mime }) : new MediaRecorder(_stream); } + catch (_) { _rec = new MediaRecorder(_stream); } + _chunks = []; + _recCancelled = false; + _rec.addEventListener('dataavailable', e => { if (e.data && e.data.size) _chunks.push(e.data); }); + _rec.addEventListener('stop', () => { + _stopTracks(); + if (_tick) { clearInterval(_tick); _tick = null; } + if (_recMax) { clearTimeout(_recMax); _recMax = null; } + _setRecState(false); + if (_recCancelled) { _chunks = []; return; } + const type = _rec.mimeType || mime || 'audio/webm'; + const ext = type.includes('mp4') ? '.m4a' : type.includes('ogg') ? '.ogg' : '.webm'; + const blob = new Blob(_chunks, { type }); + _chunks = []; + if (blob.size > 0) _addFiles([new File([blob], `sprachnachricht${ext}`, { type })]); + }); + _rec.start(); + _recStart = Date.now(); + _setRecState(true); + _updateTimer(); + _tick = setInterval(_updateTimer, 250); + _recMax = setTimeout(() => { if (_rec && _rec.state === 'recording') { _recCancelled = false; try { _rec.stop(); } catch (_) {} } }, 5 * 60 * 1000); + } + + function _stopRecording(cancel) { + _recCancelled = !!cancel; + if (_rec && _rec.state !== 'inactive') { try { _rec.stop(); } catch (_) {} } + else { _stopTracks(); _setRecState(false); } + } + + // ---- Event-Bindung ---- + // Ein Button ohne accept: iOS zeigt im Aktionsblatt von sich aus Mediathek, + // Kamera UND Datei-Auswahl — separate Buttons dafĂŒr sind ĂŒberflĂŒssig. + document.getElementById(`${p}-gallery`)?.addEventListener('click', () => _openPicker({ noAccept: true })); + if (RECORDER_OK) document.getElementById(ids.mic)?.addEventListener('click', _startRecording); + document.getElementById(ids.recStop)?.addEventListener('click', () => _stopRecording(false)); + document.getElementById(ids.recCancel)?.addEventListener('click', () => _stopRecording(true)); + + _renderExisting(); + _renderPending(); + + // ---- Öffentliche API ---- + async function uploadAll(noteIdArg, onProgress) { + if (noteIdArg != null) _noteId = noteIdArg; + if (!_pending.length) return { uploaded: [], failed: 0 }; + const total = _pending.length; + let done = 0; + onProgress?.(0, total); + const results = await Promise.all(_pending.map(async (it) => { + try { + const toUpload = await API.compressImage(it.file); // nur Bilder werden komprimiert + const fd = new FormData(); + fd.append('file', toUpload); + const m = await API.notes.uploadMedia(_noteId, fd); + onProgress?.(++done, total); + return { ok: true, m }; + } catch (_) { + onProgress?.(++done, total); + return { ok: false }; + } + })); + const uploaded = results.filter(r => r.ok).map(r => r.m); + const failed = results.filter(r => !r.ok).length; + if (failed) toast.warning(`${failed} Medi${failed > 1 ? 'en' : 'um'} konnte${failed > 1 ? 'n' : ''} nicht hochgeladen werden.`); + _pending.forEach(it => { try { URL.revokeObjectURL(it.url); } catch (_) {} }); + _pending.length = 0; + _renderPending(); + _existing = _existing.concat(uploaded); + return { uploaded, failed }; + } + + function destroy() { + _stopRecording(true); + _stopTracks(); + if (_tick) { clearInterval(_tick); _tick = null; } + if (_recMax) { clearTimeout(_recMax); _recMax = null; } + _pending.forEach(it => { try { URL.revokeObjectURL(it.url); } catch (_) {} }); + _pending.length = 0; + } + + return { + uploadAll, + hasPending: () => _pending.length > 0 || (_rec && _rec.state === 'recording'), + destroy, + }; + } + // ---------------------------------------------------------- // NOTE-MODAL — Notiz zu einem beliebigen Objekt (parentType/parentId) // erstellen/bearbeiten. Zentral, damit nicht jede Seite eine eigene Kopie hat. @@ -1735,6 +1999,7 @@ const UI = (() => { placeholder="Notiz eingeben
" style="width:100%;resize:vertical"> +
    @@ -1752,17 +2017,27 @@ const UI = (() => { const closeBtn = document.getElementById('by-note-close'); let existingNoteId = null; + let existingNote = null; try { - const existing = await API.notes.get(parentType, parentId); - if (existing?.id) { - existingNoteId = existing.id; - textarea.value = existing.text || ''; + const res = await API.notes.get(parentType, parentId); + // GET /notes/{type}/{id} liefert ein Array (neueste zuerst) — die jĂŒngste + // Notiz bearbeiten statt bei jedem Öffnen eine neue anzulegen (Duplikate). + existingNote = Array.isArray(res) ? res[0] : res; + if (existingNote?.id) { + existingNoteId = existingNote.id; + textarea.value = existingNote.text || ''; } } catch (_) { /* keine Notiz vorhanden — ok */ } + const _media = noteMediaAttacher({ + containerId: 'by-note-media', + noteId: existingNoteId, + existingMedia: existingNote?.media_items || [], + }); + setTimeout(() => textarea.focus(), 100); - const _close = () => overlay.remove(); + const _close = () => { _media.destroy(); overlay.remove(); }; closeBtn.addEventListener('click', _close); cancelBtn.addEventListener('click', _close); overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); @@ -1770,13 +2045,24 @@ const UI = (() => { document.getElementById('by-note-form').addEventListener('submit', async e => { e.preventDefault(); const text = textarea.value.trim(); + // Reine Medien-Notiz (nur Foto/Sprachnachricht) ist erlaubt — nur ganz + // leer (kein Text, keine Medien) verhindern. + if (!text && !_media.hasPending() && !existingNoteId) { + toast.warning('Bitte einen Text eingeben oder ein Medium anhĂ€ngen.'); + return; + } setLoading(saveBtn, true); try { const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() }; - if (existingNoteId) { - await API.notes.update(existingNoteId, payload); + let noteId = existingNoteId; + if (noteId) { + await API.notes.update(noteId, payload); } else { - await API.notes.create(parentType, parentId, payload); + const created = await API.notes.create(parentType, parentId, payload); + noteId = created?.id; + } + if (noteId && _media.hasPending()) { + await _media.uploadAll(noteId, (d, t) => { saveBtn.textContent = `${d}/${t} hochgeladen
`; }); } toast.success('Notiz gespeichert.'); _close(); @@ -1787,10 +2073,62 @@ const UI = (() => { }); } + // ---------------------------------------------------------- + // LIGHTBOX — Vollbild-Viewer fĂŒr Bilder/Videos mit Vor/ZurĂŒck. + // items: [{ url, type }] (type 'image'|'video', Default 'image') oder + // reine URL-Strings. Global, damit Notizen, Tagebuch & Co. EINEN Viewer + // teilen (app.js erwartet UI.lightbox.show bereits). + // ---------------------------------------------------------- + const lightbox = (() => { + function show(items, startIdx = 0) { + const list = (Array.isArray(items) ? items : [items]) + .map(it => (typeof it === 'string' ? { url: it } : it)) + .filter(it => it && it.url); + if (!list.length) return; + let idx = Math.min(Math.max(0, startIdx | 0), list.length - 1); + + const lb = document.createElement('div'); + lb.id = 'by-lightbox'; + lb.style.cssText = 'position:fixed;inset:0;z-index:3000;background:#000;display:flex;flex-direction:column'; + + const render = () => { + const m = list[idx]; + const media = (m.type === 'video') + ? `` + : ``; + lb.innerHTML = ` +
    ${media}
    +
    + + ${list.length > 1 ? `${idx + 1} / ${list.length}` : ''} + ${list.length > 1 ? ` +
    + + +
    ` : '
    '} +
    `; + lb.querySelector('#by-lb-close').addEventListener('click', () => lb.remove()); + lb.querySelector('#by-lb-prev')?.addEventListener('click', () => { if (idx > 0) { idx--; render(); } }); + lb.querySelector('#by-lb-next')?.addEventListener('click', () => { if (idx < list.length - 1) { idx++; render(); } }); + }; + render(); + document.body.appendChild(lb); + } + return { show }; + })(); + // Öffentliche API return { toast, modal, - noteModal, + noteModal, noteMediaAttacher, lightbox, setLoading, asyncButton, formData, setFormError, clearFormErrors, emptyState, errorState, time, text, money, diff --git a/backend/static/js/worlds.js b/backend/static/js/worlds.js index da1e422..2c44e93 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -1791,6 +1791,146 @@ window.Worlds = (() => { { t:'Kein Psychiater der Welt kann so gut zuhören wie ein Hund.', a:'Unbekannt' }, { t:'Wo Hunde sind, da ist das Zuhause.', a:'Unbekannt' }, { t:'Der Hund hat keinen Begriff von Vergangenheit oder Zukunft. Er lebt.', a:'Milan Kundera' }, + { t:"Wenn du einen verhungernden Hund aufnimmst und es ihm gutgehen lĂ€sst, wird er dich nicht beißen. Das ist der wesentliche Unterschied zwischen Hund und Mensch.", a:"Mark Twain" }, + { t:"Der Hund ist ein Gentleman. Ich hoffe, in seinen Himmel zu kommen, nicht in den der Menschen.", a:"Mark Twain" }, + { t:"Je mehr ich ĂŒber die Menschen lerne, desto mehr liebe ich meinen Hund.", a:"Mark Twain" }, + { t:"Der Himmel vergibt nach Gunst. Ginge es nach Verdienst, bliebest du draußen und dein Hund kĂ€me hinein.", a:"Mark Twain" }, + { t:"Die Treue eines Hundes ist ein kostbares Geschenk, das nicht weniger bindende Pflichten auferlegt als die Freundschaft eines Menschen.", a:"Konrad Lorenz" }, + { t:"Es gibt keine Treue, die nicht schon einmal gebrochen worden wĂ€re, außer der eines wahrhaft treuen Hundes.", a:"Konrad Lorenz" }, + { t:"Die Bindung an einen echten Hund ist so bestĂ€ndig, wie es Bande auf dieser Erde nur sein können.", a:"Konrad Lorenz" }, + { t:"Der Wunsch, einen Hund zu halten, entspringt der uralten Sehnsucht des zivilisierten Menschen nach dem verlorenen Paradies.", a:"Konrad Lorenz" }, + { t:"Wenn ich daran denke, dass mein Hund mich mehr liebt, als ich ihn, dann beschĂ€mt mich das.", a:"Konrad Lorenz" }, + { t:"Der Hund ist, mit Recht, das Sinnbild der Treue.", a:"Arthur Schopenhauer" }, + { t:"Woran sollte man sich von der Falschheit der Menschen erholen, wenn die Hunde nicht wĂ€ren, in deren ehrliches Gesicht man ohne Misstrauen blicken kann.", a:"Arthur Schopenhauer" }, + { t:"Der Anblick jedes Tieres erfreut mich unmittelbar und mir geht dabei das Herz auf, am meisten bei dem der Hunde.", a:"Arthur Schopenhauer" }, + { t:"Hunde lieben ihre Freunde und beißen ihre Feinde, ganz anders als Menschen, die niemals rein lieben, sondern stets Liebe und Hass vermengen.", a:"Sigmund Freud" }, + { t:"Hunde schenken Zuneigung ohne Zwiespalt und die Schönheit eines Daseins, das ganz in sich ruht.", a:"Sigmund Freud" }, + { t:"Gibt es im Himmel keine Hunde, dann will ich, wenn ich sterbe, dorthin gehen, wo sie hingekommen sind.", a:"Will Rogers" }, + { t:"FĂŒr seinen Hund ist jeder Mensch ein Napoleon, daher die anhaltende Beliebtheit der Hunde.", a:"Aldous Huxley" }, + { t:"Bevor du einen Hund hast, kannst du dir kaum vorstellen, wie das Leben mit ihm wĂ€re. Danach kannst du dir kein anderes Leben mehr vorstellen.", a:"Caroline Knapp" }, + { t:"Wer einmal einen wundervollen Hund hatte, dessen Leben ist ohne einen Ă€rmer.", a:"Dean Koontz" }, + { t:"Einen Hund zu streicheln und zu kraulen kann den Geist so beruhigen wie tiefe Meditation und ist fast so gut fĂŒr die Seele wie ein Gebet.", a:"Dean Koontz" }, + { t:"Tausendmal hat er mir gesagt, dass ich sein Grund zu leben bin, durch die Art, wie er sich an mein Bein lehnt.", a:"Gene Hill" }, + { t:"Hunde sind unser Bindeglied zum Paradies. Sie kennen weder Bosheit noch Neid noch Unzufriedenheit.", a:"Milan Kundera" }, + { t:"Mit einem Hund an einem schönen Nachmittag auf einem HĂŒgel zu sitzen ist wie eine RĂŒckkehr nach Eden.", a:"Milan Kundera" }, + { t:"Hunde sprechen sehr wohl, doch nur zu denen, die zu lauschen verstehen.", a:"Orhan Pamuk" }, + { t:"Tiere sind so angenehme Freunde, sie stellen keine Fragen und ĂŒben keine Kritik.", a:"George Eliot" }, + { t:"Das grĂ¶ĂŸte VergnĂŒgen mit einem Hund ist, dass man sich vor ihm zum Narren machen kann und er nicht nur nicht tadelt, sondern selbst mitmacht.", a:"Samuel Butler" }, + { t:"Bedenke, dass der AllmĂ€chtige, der uns den Hund zum GefĂ€hrten gab, ihm ein edles Wesen verlieh, das des Betrugs unfĂ€hig ist.", a:"Sir Walter Scott" }, + { t:"Ich habe oft ĂŒber den Grund nachgedacht, warum Hunde so kurz leben, und bin ĂŒberzeugt, es geschieht aus Mitleid mit dem Menschen.", a:"Sir Walter Scott" }, + { t:"Hunde beißen mich nie. Nur Menschen.", a:"Marilyn Monroe" }, + { t:"Wer hĂ€lt dich fĂŒr so großartig wie dein Hund.", a:"Audrey Hepburn" }, + { t:"Ich gehe mit meinen Hunden, das hĂ€lt mich fit. Ich rede mit meinen Hunden, das hĂ€lt mich gesund.", a:"Audrey Hepburn" }, + { t:"Sobald er seinen Herrn erblickte, ließ Argos die Ohren sinken und wedelte mit dem Schwanz, doch zu ihm hin zu kommen vermochte er nicht mehr.", a:"Homer" }, + { t:"Hunde und Philosophen tun das meiste Gute und erhalten den geringsten Lohn.", a:"Diogenes" }, + { t:"Hunde sind besser als Menschen, denn sie wissen, doch sie verraten es nicht.", a:"Emily Dickinson" }, + { t:"Bei Schlachten, die das Schicksal von Völkern entschieden, blieb ich ungerĂŒhrt. Hier aber, beim Kummer eines einzigen Hundes, war ich zu TrĂ€nen gerĂŒhrt.", a:"Napoleon Bonaparte" }, + { t:"Wenn Hunde in den Himmel kommen, brauchen sie keine FlĂŒgel, denn Gott weiß, dass Hunde das Laufen am meisten lieben.", a:"Cynthia Rylant" }, + { t:"Meine ganze Bestimmung war es, ihn zu lieben, bei ihm zu sein und ihn glĂŒcklich zu machen.", a:"W. Bruce Cameron" }, + { t:"Meine Hundefreunde scheinen meine Grenzen zu verstehen und bleiben immer dicht an meiner Seite.", a:"Helen Keller" }, + { t:"Gib einem Hund dein Herz, das er zerreißen kann.", a:"Rudyard Kipling" }, + { t:"Ein Hund lehrt einen Jungen Treue, Beharrlichkeit und sich dreimal zu drehen, bevor man sich hinlegt.", a:"Robert Benchley" }, + { t:"Du kannst zu einem Hund den grĂ¶ĂŸten Unsinn sagen, und er schaut dich an, als wollte er sagen: Donnerwetter, du hast recht, darauf wĂ€re ich nie gekommen.", a:"Dave Barry" }, + { t:"Wenn ich an die Unsterblichkeit glaube, dann daran, dass gewisse Hunde in den Himmel kommen, und nur sehr, sehr wenige Menschen.", a:"James Thurber" }, + { t:"Eine TĂŒr ist das, auf deren falscher Seite ein Hund sich stĂ€ndig befindet.", a:"Ogden Nash" }, + { t:"NatĂŒrlich kann man ohne Hund leben, es lohnt sich nur nicht.", a:"Heinz RĂŒhmann" }, + { t:"Ein Leben ohne Mops ist möglich, aber sinnlos.", a:"Loriot" }, + { t:"Die Welt wĂ€re ein schönerer Ort, wenn jeder so bedingungslos lieben könnte wie ein Hund.", a:"M.K. Clinton" }, + { t:"Der durchschnittliche Hund ist ein netterer Mensch als der durchschnittliche Mensch.", a:"Andy Rooney" }, + { t:"Niemand schĂ€tzt das ganz besondere Genie deiner Unterhaltung so sehr wie dein Hund.", a:"Christopher Morley" }, + { t:"Ich habe meinem Schmerz einen Namen gegeben und nenne ihn Hund, denn er ist ebenso treu und klug wie jeder andere Hund.", a:"Friedrich Nietzsche" }, + { t:"Dem Hunde, wenn er gut gezogen, wird selbst ein weiser Mann gewogen.", a:"Johann Wolfgang von Goethe" }, + { t:"VerstĂ¶ĂŸt ein Herr seinen Hund, weil er ihm das Brot nicht mehr verdienen kann, so zeigt das stets eine sehr kleine Seele des Herrn an.", a:"Immanuel Kant" }, + { t:"Wenn du keinen Hund besitzt, ist nicht unbedingt etwas mit dir verkehrt, aber vielleicht stimmt etwas mit deinem Leben nicht.", a:"Roger Caras" }, + { t:"Alte Hunde sind wie alte Schuhe, bequem. Sie sind vielleicht etwas aus der Form, aber sie passen einfach gut.", a:"Bonnie Wilcox" }, + { t:"Kommt ein Hund nicht zu dir, nachdem er dir ins Gesicht gesehen hat, so solltest du heimgehen und dein Gewissen prĂŒfen.", a:"Woodrow Wilson" }, + { t:"Springt ein Hund auf deinen Schoß, dann weil er dich mag. Tut eine Katze dasselbe, ist es nur, weil dein Schoß wĂ€rmer ist.", a:"Alfred North Whitehead" }, + { t:"Geld kann dir einen feinen Hund kaufen, aber nur Liebe bringt ihn dazu, mit dem Schwanz zu wedeln.", a:"Kinky Friedman" }, + { t:"Wenn ich fĂŒr eine Reise den Koffer hervorhole, weiß er es lange vorher und gerĂ€t in einen Zustand milder Aufregung.", a:"John Steinbeck" }, + { t:"Ein Hund ist ein Band zwischen Fremden.", a:"John Steinbeck" }, + { t:"Mein kleiner Hund, ein Herzschlag zu meinen FĂŒĂŸen.", a:"Edith Wharton" }, + { t:"Geschaffen wurde der Hund eigens fĂŒr die Kinder. Er ist der Gott des Übermuts.", a:"Henry Ward Beecher" }, + { t:"Hunde sind klug. Sie kriechen in eine stille Ecke und lecken ihre Wunden und kehren erst in die Welt zurĂŒck, wenn sie wieder heil sind.", a:"Agatha Christie" }, + { t:"Schönheit ohne Eitelkeit, StĂ€rke ohne Übermut, Mut ohne Wildheit und alle Tugenden des Menschen ohne seine Laster.", a:"Lord Byron" }, + { t:"Gott dreht Wolken um und um, um den Hunden im Hundehimmel flauschige Betten zu bereiten.", a:"Cynthia Rylant" }, + { t:"Hunde besitzen eine Eigenschaft, die unter Menschen selten ist, nĂ€mlich zu erkennen, wer Hilfe braucht, und sie zu geben.", a:"Caroline Knapp" }, + { t:"In manchen Dingen ist mein Hund klĂŒger als ich, in anderen ist er bodenlos unwissend.", a:"John Steinbeck" }, + { t:"Ein Hund zur Hand ist besser als ein Bruder in der Ferne.", a:"Persisches Sprichwort" }, + { t:"Wenn du bei jedem bellenden Hund stehen bleibst, beendest du deine Reise nie.", a:"Arabisches Sprichwort" }, + { t:"Es ist schwer, einen so treuen GefĂ€hrten zu finden wie einen Hund.", a:"Mongolisches Sprichwort" }, + { t:"Ein Hund ohne Schwanz kann nicht zeigen, dass er sich freut.", a:"Albanisches Sprichwort" }, + { t:"Ein Hund, der mit dem Schwanz wedelt, bezieht keine PrĂŒgel.", a:"Japanisches Sprichwort" }, + { t:"Der hungrige Hund fĂŒrchtet den Stock nicht.", a:"Japanisches Sprichwort" }, + { t:"Treffen sich im Paradies eine Menschenseele und eine Hundeseele, verneigt sich der Mensch vor dem Hund.", a:"Sibirisches Sprichwort" }, + { t:"Solange der Mensch denkt, Tiere fĂŒhlten nicht, fĂŒhlen Tiere, dass der Mensch nicht denkt.", a:"Indianische Weisheit" }, + { t:"Mit Hunden zu leben tut dem Menschen gut.", a:"Tibetisches Sprichwort" }, + { t:"Ein Hund ist ein Herz auf vier Beinen.", a:"Irisches Sprichwort" }, + { t:"Der Hund vergisst den einen Bissen nicht, und wirfst du ihm auch hundert Steine nach.", a:"Chinesisches Sprichwort" }, + { t:"Sei der Freund meines Hundes, dann bist du auch der meine.", a:"Indianische Weisheit" }, + { t:"HĂŒte dich vor dem Menschen, der nicht spricht, und vor dem Hund, der nicht bellt.", a:"Indianische Weisheit" }, + { t:"Ein kluger Hund bellt nicht ohne Grund.", a:"Französisches Sprichwort" }, + { t:"In seiner eigenen HĂŒtte ist jeder Hund ein Löwe.", a:"Französisches Sprichwort" }, + { t:"Hunde, die sich beißen, halten gegen den Wolf zusammen.", a:"Armenisches Sprichwort" }, + { t:"Der Hund bellt, doch die Karawane zieht weiter.", a:"TĂŒrkisches Sprichwort" }, + { t:"Hat ein Armer den Hund großgezogen, folgt er keinem Reichen mehr.", a:"Mongolisches Sprichwort" }, + { t:"Hat der Hund zu viele Herren, schlĂ€ft er hungrig ein.", a:"Afrikanisches Sprichwort" }, + { t:"Ich hoffe, einmal der Mensch zu werden, fĂŒr den mein Hund mich hĂ€lt.", a:"Ungarisches Sprichwort" }, + { t:"Eines Hundes Treue wĂ€hrt ein ganzes Leben lang.", a:"Spanisches Sprichwort" }, + { t:"Faule SchĂ€fer haben die besten Hunde.", a:"Deutsches Sprichwort" }, + { t:"Hunde, die viel bellen, beißen selten.", a:"Italienisches Sprichwort" }, + { t:"Wer einen guten Hund hat, braucht keinen WĂ€chter.", a:"Italienisches Sprichwort" }, + { t:"Wo der Hund frei laufen darf, ist das GlĂŒck nicht weit.", a:"Unbekannt" }, + { t:"Ein Hund schaut nicht auf deinen Stand, nur auf dein Herz.", a:"Unbekannt" }, + { t:"Dem Hund ist gleich, ob du reich bist; ihm reicht, dass du heimkommst.", a:"Unbekannt" }, + { t:"Ein Hund braucht keine Worte, um dich zu trösten.", a:"Unbekannt" }, + { t:"Der beste Platz der Welt ist neben einem Hund.", a:"Unbekannt" }, + { t:"Ein Tag mit Hund ist nie ganz verloren.", a:"Unbekannt" }, + { t:"Ein Hund fĂŒllt die Stille im Haus mit leisem GlĂŒck.", a:"Unbekannt" }, + { t:"Hunde messen die Zeit nicht in Stunden, sondern in SpaziergĂ€ngen.", a:"Unbekannt" }, + { t:"Ein Hund findet zum GlĂŒck immer den kĂŒrzesten Weg.", a:"Unbekannt" }, + { t:"Mit einem Hund an der Seite lĂ€uft man nie allein.", a:"Unbekannt" }, + { t:"Ein Hund vergibt schneller, als wir uns entschuldigen können.", a:"Unbekannt" }, + { t:"Wer die Sprache der Hunde lernt, hört auf zu reden und beginnt zu fĂŒhlen.", a:"Unbekannt" }, + { t:"Ein Hund kennt deinen Namen nicht, aber er kennt dein Herz.", a:"Unbekannt" }, + { t:"Hunde sind die PĂŒnktlichsten, wenn es ums GlĂŒcklichsein geht.", a:"Unbekannt" }, + { t:"Ein Hund wartet nicht auf morgen, um dich heute zu lieben.", a:"Unbekannt" }, + { t:"Manche Engel haben Fell und kalte Pfoten.", a:"Unbekannt" }, + { t:"Ein Hund nimmt dich, wie du bist, und macht dich trotzdem besser.", a:"Unbekannt" }, + { t:"Was ein Hund ĂŒber Freundschaft weiß, lernt der Mensch ein Leben lang.", a:"Unbekannt" }, + { t:"Hunde haben kurze Leben, weil sie das Lieben so gut können, dass sie keine Zeit verschwenden.", a:"Unbekannt" }, + { t:"GlĂŒck hat vier Pfoten und einen wedelnden Schwanz.", a:"Unbekannt" }, + { t:"Ein Hund teilt dein Schweigen, ohne es zu fĂŒllen.", a:"Unbekannt" }, + { t:"Der treueste Blick der Welt kommt von unten und wedelt dabei.", a:"Unbekannt" }, + { t:"Ein Hund braucht keinen Sonntag, jeder Tag mit dir ist ihm Feiertag.", a:"Unbekannt" }, + { t:"Wo ein Hund die Stiefel bringt, fehlt es nie an Liebe.", a:"Unbekannt" }, + { t:"Hunde rechnen nicht nach, wie viel du gibst; sie geben einfach alles zurĂŒck.", a:"Unbekannt" }, + { t:"Ein Hund sieht nicht, wie du aussiehst, sondern wer du bist.", a:"Unbekannt" }, + { t:"Ein Hund macht aus einem Spaziergang ein kleines Abenteuer.", a:"Unbekannt" }, + { t:"Wer einem Hund ins Auge sieht, schaut der Ehrlichkeit beim Atmen zu.", a:"Unbekannt" }, + { t:"Ein Hund spĂŒrt deinen schlechten Tag und bleibt trotzdem.", a:"Unbekannt" }, + { t:"Das Schwierigste am Hundeleben ist, dass es zu kurz fĂŒr so viel Liebe ist.", a:"Unbekannt" }, + { t:"Ein Hund braucht wenig und schenkt davon das Meiste.", a:"Unbekannt" }, + { t:"Hunde sind der Beweis, dass Treue keine Worte braucht.", a:"Unbekannt" }, + { t:"Ein wedelnder Schwanz hat schon manchen Tag gerettet.", a:"Unbekannt" }, + { t:"Wer einen Hund versteht, braucht den Menschen weniger zu erklĂ€ren.", a:"Unbekannt" }, + { t:"Ein Hund spart seine Liebe nicht auf, er verschenkt sie sofort und vollstĂ€ndig.", a:"Unbekannt" }, + { t:"Die kĂŒrzeste Verbindung zwischen zwei Menschen ist manchmal eine Hundeleine.", a:"Unbekannt" }, + { t:"Ein Hund kennt keinen Stolz, nur Wiedersehensfreude.", a:"Unbekannt" }, + { t:"Wer mit einem Hund alt wird, lernt das GlĂŒck im Kleinen.", a:"Unbekannt" }, + { t:"Ein Hund hĂ€lt dir keine Reden, er hĂ€lt dir die Treue.", a:"Unbekannt" }, + { t:"Vor einem Hund muss man nichts vorgeben; er liebt das Echte.", a:"Unbekannt" }, + { t:"Ein Hund schreibt keine Briefe, doch sein Schwanz erzĂ€hlt alles.", a:"Unbekannt" }, + { t:"Hunde nehmen die kleinen Dinge ernst, darum sind sie so groß im Lieben.", a:"Unbekannt" }, + { t:"Ein Hund verzeiht dir den Regen, solange du mit ihm hinausgehst.", a:"Unbekannt" }, + { t:"Wer das Vertrauen eines Hundes gewinnt, hat etwas Selteneres als Gold.", a:"Unbekannt" }, + { t:"Ein Hund macht stille Tage warm und laute Tage leiser.", a:"Unbekannt" }, + { t:"Die treueste Uhr im Haus ist der Hund vor dem Futternapf.", a:"Unbekannt" }, + { t:"Ein Hund fragt nicht, wie der Tag war; er macht ihn einfach besser.", a:"Unbekannt" }, + { t:"Wer einen Hund an der Seite hat, ist nirgends ganz fremd.", a:"Unbekannt" }, + { t:"Ein Hund kennt nur ein Tempo bei der Liebe: sofort.", a:"Unbekannt" }, + { t:"Ein Hund am Feuer wĂ€rmt mehr als das Feuer selbst.", a:"Unbekannt" }, + { t:"Wer den Hund gut behandelt, dem öffnet sich das Herz von selbst.", a:"Unbekannt" }, + { t:"Ein Hund braucht keinen Kalender, er weiß genau, wann du nach Hause kommst.", a:"Unbekannt" }, ]; function _renderWelt() { diff --git a/backend/static/landing.html b/backend/static/landing.html index 272122c..9cb3d3f 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App fĂŒr Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index 119cc71..c7e474e 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle fĂŒr die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1278'; +const VER = '1292'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt ĂŒber SW-Updates erhalten diff --git a/docker-compose.yml b/docker-compose.yml index d984dcd..240f881 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: - DB_PATH=/data/banyaro.db - MEDIA_DIR=/data/media - UMAMI_URL=https://umami.motocamp.de + - KI_MODE=cloud # VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY / VAPID_CONTACT # → kommen aus .env (nicht in Git) healthcheck: diff --git a/tests/js/README.md b/tests/js/README.md index 1b5675e..f0428e7 100644 --- a/tests/js/README.md +++ b/tests/js/README.md @@ -13,5 +13,13 @@ for f in tests/js/test-map-offline*.js; do node "$f" backend/static/js/map-offli - r6: Standort-Grundversorgung (ensureHomeArea: lĂ€dt/skippt/Cap, ĂŒberlebt clear) - r7: selektives Löschen (Korridor-Keep via keepTracks, manuelle Gebiete weg, Komplett-Wipe-Fallback) +EigenstĂ€ndig (kein Stub-Argument nötig): + +``` +node tests/js/test-nav-loop-closestidx.js +``` + +- nav-loop-closestidx: Navi-Erst-Fix bei Runden springt nicht ans Track-Ende (spiegelt `_closestIdx` aus `js/pages/routes.js`) — Bugfix Angie/Deining 09.06.2026 + ⚠ Node 21+: eingebautes `navigator`-Global — Stubs via `Object.defineProperty(globalThis, 'navigator', 
)`, ein einfaches `global.navigator =` wird still verschluckt. diff --git a/tests/js/test-nav-loop-closestidx.js b/tests/js/test-nav-loop-closestidx.js new file mode 100644 index 0000000..df45372 --- /dev/null +++ b/tests/js/test-nav-loop-closestidx.js @@ -0,0 +1,98 @@ +// Navi-Erst-Fix bei RUNDEN: der Startindex darf nicht ans Track-Ende springen. +// +// Spiegelt die _closestIdx-Erst-Fix-Logik aus js/pages/routes.js (_startNav). An einem +// Start/Ende-Knoten einer Runde ist der ENDPUNKT oft ein paar Meter nĂ€her als der +// Startpunkt; die alte globale Suche sprang dann sofort ans Track-Ende → 100 % / 0 km ab +// Sekunde 1 (Angie, Deining-Runde 09.06.2026). Bei Änderung BEIDE Stellen anpassen. +// +// Hinweis: bewusst eine Nachbildung — die echte Funktion ist eine Closure in _startNav +// und nicht exportierbar, ohne routes.js umzubauen. + +const _haversineKm = (lat1, lon1, lat2, lon2) => { + const R = 6371, dLat = (lat2 - lat1) * Math.PI / 180, dLon = (lon2 - lon1) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +}; + +// Erst-Fix-Index fĂŒr gegebenen track + Userposition (1:1 aus routes.js). +function firstFixIdx(track, lat, lon) { + const search = (from, to) => { + let best = from, bestD = Infinity; + for (let i = from; i <= to; i++) { + const d = _haversineKm(lat, lon, track[i].lat, track[i].lon); + if (d < bestD) { bestD = d; best = i; } + } + return { best, bestD }; + }; + const isLoop = track.length > 2 && + _haversineKm(track[0].lat, track[0].lon, + track[track.length - 1].lat, track[track.length - 1].lon) < 0.06; + const g = search(0, track.length - 1); + if (isLoop) { + const win = Math.min(track.length - 1, Math.max(30, Math.floor(track.length * 0.15))); + const s = search(0, win); + return { idx: s.bestD < 0.15 ? s.best : g.best, isLoop }; + } + const s = search(0, Math.min(track.length - 1, 30)); + return { idx: (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best, isLoop }; +} + +// Die ALTE Logik (vor dem Fix) — nur zum Beweis, dass der Fix wirklich etwas Ă€ndert. +function firstFixIdxOld(track, lat, lon) { + const search = (from, to) => { + let best = from, bestD = Infinity; + for (let i = from; i <= to; i++) { + const d = _haversineKm(lat, lon, track[i].lat, track[i].lon); + if (d < bestD) { bestD = d; best = i; } + } + return { best, bestD }; + }; + const g = search(0, track.length - 1); + const s = search(0, Math.min(track.length - 1, 30)); + return (s.bestD - g.bestD) * 1000 < 25 ? s.best : g.best; +} + +// --- Synthetische Deining-artige Runde ------------------------------------- +const C = { lat: 48.07, lon: 11.50 }; +const mLat = m => m / 111320; +const mLon = (m, lat) => m / (111320 * Math.cos(lat * Math.PI / 180)); +// Punkt auf einem Kreis: Winkel von Nord, im Uhrzeigersinn. +const onCircle = (deg, r) => { + const rad = deg * Math.PI / 180; + return { lat: C.lat + mLat(r * Math.cos(rad)), lon: C.lon + mLon(r * Math.sin(rad), C.lat) }; +}; + +const N = 40, R = 80; // 40 Punkte auf 80-m-Kreis, lange Runde von 0°→329° +const track = []; +for (let i = 0; i < N; i++) track.push(onCircle(i / (N - 1) * 329, R)); +// User steht 3 m außerhalb des ENDpunkts (329°) → nĂ€her am Ende als am Start. +const user = onCircle(329, R + 3); + +const startEndM = _haversineKm(track[0].lat, track[0].lon, + track[N - 1].lat, track[N - 1].lon) * 1000; +const dStart = _haversineKm(user.lat, user.lon, track[0].lat, track[0].lon) * 1000; +const dEnd = _haversineKm(user.lat, user.lon, track[N - 1].lat, track[N - 1].lon) * 1000; +console.log(`Runde: Start↔Ende ${startEndM.toFixed(0)} m | User→Start ${dStart.toFixed(0)} m, User→Ende ${dEnd.toFixed(0)} m`); + +// 1. Loop wird erkannt (Start ≈ Ende < 60 m) +const res = firstFixIdx(track, user.lat, user.lon); +if (!res.isLoop) throw new Error('Runde nicht als Loop erkannt'); + +// 2. Erst-Fix landet im STARTbereich, NICHT am Track-Ende +console.log('Erst-Fix-Index:', res.idx, '(von', N - 1 + ')'); +if (res.idx > Math.floor(N * 0.15)) throw new Error(`Erst-Fix sprang weg vom Start (idx ${res.idx})`); + +// 3. Beweis: die alte Logik wĂ€re hier ans Ende gesprungen (100 %) +const old = firstFixIdxOld(track, user.lat, user.lon); +console.log('Alte Logik-Index:', old); +if (old !== N - 1) throw new Error('Erwartet: alte Logik springt ans Ende — Testfall trifft den Bug nicht mehr'); + +// 4. Punkt-zu-Punkt-Route (kein Loop): User am Start → 0 %, am Ende → bleibt sinnvoll +const ptp = []; +for (let i = 0; i < N; i++) ptp.push({ lat: C.lat + mLat(i * 25), lon: C.lon }); // 25-m-Schritte nach Norden +const ptpRes = firstFixIdx(ptp, ptp[0].lat, ptp[0].lon); +if (ptpRes.isLoop) throw new Error('Gerade Route fĂ€lschlich als Loop erkannt'); +if (ptpRes.idx !== 0) throw new Error(`Punkt-zu-Punkt am Start sollte idx 0 sein, war ${ptpRes.idx}`); + +console.log('\nALLE NAV-LOOP-TESTS BESTANDEN'); diff --git a/tests/test_account_deletion.py b/tests/test_account_deletion.py index e016966..63df268 100644 --- a/tests/test_account_deletion.py +++ b/tests/test_account_deletion.py @@ -59,3 +59,30 @@ def test_delete_account_minimal_user(client): assert resp.status_code == 200, resp.text with db() as conn: assert conn.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone() is None + + +def test_delete_account_purges_note_media(client): + """Account-Löschung entfernt Notiz-Medien — DB-Zeilen UND Dateien auf Disk.""" + import io, os + from database import db + from PIL import Image + + uid, headers = _make_user(client) + nid = client.post("/api/notes/diary/1", headers=headers, + json={"text": "Mit Foto", "parent_label": "X"}).json()["id"] + + buf = io.BytesIO(); Image.new("RGB", (10, 10), (1, 2, 3)).save(buf, format="JPEG") + up = client.post(f"/api/notes/{nid}/media", headers=headers, + files={"file": ("f.jpg", buf.getvalue(), "image/jpeg")}) + assert up.status_code == 200, up.text + url = up.json()["url"] + fpath = os.path.join(os.getenv("MEDIA_DIR", "/data/media"), url[len("/media/"):]) + assert os.path.exists(fpath) + + resp = client.delete("/api/profile/account", headers=headers) + assert resp.status_code == 200, resp.text + + with db() as conn: + assert conn.execute("SELECT COUNT(*) c FROM note_media WHERE note_id=?", (nid,)).fetchone()["c"] == 0 + assert conn.execute("SELECT COUNT(*) c FROM notes WHERE user_id=?", (uid,)).fetchone()["c"] == 0 + assert not os.path.exists(fpath), "note_media-Datei blieb als Leiche auf Disk" diff --git a/tests/test_media_registry.py b/tests/test_media_registry.py new file mode 100644 index 0000000..b79583d --- /dev/null +++ b/tests/test_media_registry.py @@ -0,0 +1,227 @@ +"""Tests fuer die Media-Registry (iCloud-Hybrid, iOS-Voll-App M0). + +Architektur: Originale liegen in der privaten CloudKit-DB des Nutzers, der Server +haelt nur Previews. diary = Phantom-URL + _preview.webp + Marker-404; +route = Preview wird als Datei selbst gespeichert. Siehe backend/routes/media.py. +""" + +import io +import os +import secrets + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ +def _jpeg(px: int = 900) -> bytes: + from PIL import Image + buf = io.BytesIO() + Image.new("RGB", (px, px), (200, 120, 40)).save(buf, format="JPEG", quality=85) + return buf.getvalue() + + +def _entry(client, user, dog, titel="Medien-Eintrag") -> int: + r = client.post( + f"/api/dogs/{dog['id']}/diary", + headers=user["headers"], + json={"titel": titel, "text": "."}, + ) + assert r.status_code == 201, r.text + return r.json()["id"] + + +def _register(client, user, context, context_id, **extra): + data = {"context": context, "context_id": str(context_id), "kind": "image", + "img_width": "4032", "img_height": "3024", **extra} + return client.post( + "/api/media/register", + headers=user["headers"], + data=data, + files={"preview": ("preview.jpg", _jpeg(), "image/jpeg")}, + ) + + +def _media_dir() -> str: + return os.environ["MEDIA_DIR"] + + +def _disk_path(url: str) -> str: + return os.path.join(_media_dir(), url.removeprefix("/media/")) + + +# ------------------------------------------------------------------ +# diary: Phantom-URL + Preview +# ------------------------------------------------------------------ +def test_register_diary_creates_preview_only(client, user, dog): + """register legt _preview.webp an, aber KEIN Original (das geht nach iCloud).""" + eid = _entry(client, user, dog) + r = _register(client, user, "diary", eid) + assert r.status_code == 201, r.text + j = r.json() + assert j["url"].startswith("/media/diary/") + assert j["preview_url"] == os.path.splitext(j["url"])[0] + "_preview.webp" + assert j["ck_record_name"] == f"media-{j['media_id']}" + assert j["is_cover"] == 1 # erstes Medium wird Cover + + assert not os.path.exists(_disk_path(j["url"])) # Phantom + assert os.path.exists(_disk_path(j["preview_url"])) # echtes Preview + + # Eintrag listet das Medium mit Preview-URL (Web-Anzeige unveraendert) + r2 = client.get(f"/api/dogs/{dog['id']}/diary/{eid}", headers=user["headers"]) + items = r2.json()["media_items"] + assert len(items) == 1 and items[0]["url"] == j["url"] + + +def test_original_404_carries_icloud_marker(client, user, dog): + """GET auf die Phantom-URL -> 404 mit storage:'icloud'; Preview liefert 200.""" + eid = _entry(client, user, dog) + j = _register(client, user, "diary", eid).json() + + cookies = {"by_token": user["token"]} # /media/diary/ verlangt Login-Cookie + r = client.get(j["url"], cookies=cookies) + assert r.status_code == 404 + assert r.json().get("storage") == "icloud" + + r2 = client.get(j["preview_url"], cookies=cookies) + assert r2.status_code == 200 + assert r2.headers["content-type"].startswith("image/webp") + + +def test_confirm_and_mine(client, user, dog): + """PATCH bestaetigt den CloudKit-Upload; /mine ist die Sync-Wahrheit der App.""" + eid = _entry(client, user, dog) + j = _register(client, user, "diary", eid).json() + + r = client.patch(f"/api/media/{j['media_id']}", headers=user["headers"], json={}) + assert r.status_code == 200 + assert r.json()["ck_state"] == "confirmed" + + mine = client.get("/api/media/mine", headers=user["headers"]).json() + assert [m for m in mine if m["id"] == j["media_id"]][0]["ck_state"] == "confirmed" + + +def test_quota_fallback_uploads_original(client, user, dog): + """iCloud voll -> Original klassisch zum Server, danach liefert die URL 200.""" + eid = _entry(client, user, dog) + j = _register(client, user, "diary", eid).json() + + r = client.post( + f"/api/media/{j['media_id']}/original", + headers=user["headers"], + files={"file": ("original.jpg", _jpeg(2000), "image/jpeg")}, + ) + assert r.status_code == 201, r.text + assert r.json()["storage"] == "server" + + r2 = client.get(j["url"], cookies={"by_token": user["token"]}) + assert r2.status_code == 200 + + mine = client.get("/api/media/mine", headers=user["headers"]).json() + assert [m for m in mine if m["id"] == j["media_id"]][0]["storage"] == "server" + + +# ------------------------------------------------------------------ +# Ownership +# ------------------------------------------------------------------ +def _second_user(client) -> dict: + email = f"fremd-{secrets.token_hex(4)}@example.com" + pw = "TestPass123!" + r = client.post("/api/auth/register", + json={"email": email, "password": pw, + "name": f"fremd{secrets.token_hex(3)}"}) + assert r.status_code == 200, r.text + from database import db + with db() as conn: + conn.execute("UPDATE users SET email_verified=1 WHERE email=?", (email,)) + token = client.post("/api/auth/login", + json={"email": email, "password": pw}).json()["token"] + return {"token": token, "headers": {"Authorization": f"Bearer {token}"}} + + +def test_foreign_user_blocked(client, user, dog): + """Fremder User kann weder registrieren noch bestaetigen noch fallbacken.""" + eid = _entry(client, user, dog) + j = _register(client, user, "diary", eid).json() + fremd = _second_user(client) + + assert _register(client, fremd, "diary", eid).status_code == 404 + assert client.patch(f"/api/media/{j['media_id']}", + headers=fremd["headers"], json={}).status_code == 404 + r = client.post(f"/api/media/{j['media_id']}/original", + headers=fremd["headers"], + files={"file": ("x.jpg", _jpeg(100), "image/jpeg")}) + assert r.status_code == 404 + + +# ------------------------------------------------------------------ +# Loeschen raeumt Registry + Dateien +# ------------------------------------------------------------------ +def test_delete_media_item_cleans_registry_and_preview(client, user, dog): + eid = _entry(client, user, dog) + j = _register(client, user, "diary", eid).json() + prev_path = _disk_path(j["preview_url"]) + assert os.path.exists(prev_path) + + r = client.delete(f"/api/dogs/{dog['id']}/diary/{eid}/media/{j['id']}", + headers=user["headers"]) + assert r.status_code == 204 + + assert not os.path.exists(prev_path) + mine = client.get("/api/media/mine", headers=user["headers"]).json() + assert not [m for m in mine if m["id"] == j["media_id"]] + + +def test_entry_delete_cleans_registry(client, user, dog): + eid = _entry(client, user, dog) + j = _register(client, user, "diary", eid).json() + + r = client.delete(f"/api/dogs/{dog['id']}/diary/{eid}", headers=user["headers"]) + assert r.status_code == 204 + + mine = client.get("/api/media/mine", headers=user["headers"]).json() + assert not [m for m in mine if m["id"] == j["media_id"]] + + +# ------------------------------------------------------------------ +# route: Preview wird die Datei selbst +# ------------------------------------------------------------------ +def _route(client, user) -> int: + r = client.post("/api/routes", headers=user["headers"], json={ + "name": "Testrunde", + "gps_track": [{"lat": 48.10, "lon": 11.50}, {"lat": 48.11, "lon": 11.51}], + "distanz_km": 1.2, "dauer_min": 20, + }) + assert r.status_code == 201, r.text + return r.json()["id"] + + +def test_register_route_stores_preview_as_file(client, user, dog): + rid = _route(client, user) + r = _register(client, user, "route", rid) + assert r.status_code == 201, r.text + j = r.json() + assert j["foto_url"].startswith("/media/routes/") + assert j["foto_urls"] == [j["foto_url"]] + assert os.path.exists(_disk_path(j["foto_url"])) # Datei existiert (= Preview) + + r2 = client.get(j["foto_url"]) # routes/ ist nicht auth-pflichtig + assert r2.status_code == 200 + + # Route-Delete raeumt die Registry-Row ab (App-Sync loescht dann den CKRecord) + assert client.delete(f"/api/routes/{rid}", + headers=user["headers"]).status_code == 204 + mine = client.get("/api/media/mine", headers=user["headers"]).json() + assert not [m for m in mine if m["id"] == j["media_id"]] + + +def test_register_validates_context_and_kind(client, user, dog): + eid = _entry(client, user, dog) + r = client.post("/api/media/register", headers=user["headers"], + data={"context": "forum", "context_id": "1", "kind": "image"}, + files={"preview": ("p.jpg", _jpeg(100), "image/jpeg")}) + assert r.status_code == 422 + + r2 = client.post("/api/media/register", headers=user["headers"], + data={"context": "diary", "context_id": str(eid), "kind": "egal"}, + files={"preview": ("p.jpg", _jpeg(100), "image/jpeg")}) + assert r2.status_code == 422 diff --git a/tests/test_notes_media.py b/tests/test_notes_media.py new file mode 100644 index 0000000..a1003f9 --- /dev/null +++ b/tests/test_notes_media.py @@ -0,0 +1,129 @@ +"""Tests fĂŒr Notiz-Medien: Bild-/Audio-Upload, GET liefert media_items, +Löschen entfernt DB-Zeile + Datei, Notiz-Delete rĂ€umt Dateien, Validierung.""" + +import io +import os + +import pytest + + +def _jpeg_bytes(color=(200, 100, 50)): + from PIL import Image + buf = io.BytesIO() + Image.new("RGB", (12, 12), color).save(buf, format="JPEG") + return buf.getvalue() + + +# Minimaler MP4/M4A-Header (ftyp-Box) — genĂŒgt fĂŒr validate_audio(audio/mp4). +_M4A_BYTES = b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00M4A mp42isom" + b"\x00" * 32 + + +def _create_note(client, user, text="Notiz mit Medien"): + r = client.post("/api/notes/diary/1", headers=user["headers"], + json={"text": text, "parent_label": "Testobjekt"}) + assert r.status_code == 201, r.text + return r.json() + + +def _media_path(url): + media_dir = os.getenv("MEDIA_DIR", "/data/media") + rel = url[len("/media/"):] if url.startswith("/media/") else url.lstrip("/") + return os.path.join(media_dir, rel) + + +def test_upload_image_to_note(client, user): + note = _create_note(client, user) + r = client.post( + f"/api/notes/{note['id']}/media", + headers=user["headers"], + files={"file": ("foto.jpg", _jpeg_bytes(), "image/jpeg")}, + ) + assert r.status_code == 200, r.text + m = r.json() + assert m["media_type"] == "image" + assert m["url"].startswith("/media/notes/") + assert os.path.exists(_media_path(m["url"])) + + +def test_note_get_includes_media_items(client, user): + note = _create_note(client, user) + client.post(f"/api/notes/{note['id']}/media", headers=user["headers"], + files={"file": ("a.jpg", _jpeg_bytes(), "image/jpeg")}) + r = client.get("/api/notes/diary/1", headers=user["headers"]) + assert r.status_code == 200 + target = next(n for n in r.json() if n["id"] == note["id"]) + assert len(target["media_items"]) == 1 + assert target["media_items"][0]["media_type"] == "image" + + +def test_upload_audio_to_note(client, user): + # Reine Sprachnotiz: leerer Text ist erlaubt, das Medium trĂ€gt die Notiz. + note = _create_note(client, user, text="") + r = client.post( + f"/api/notes/{note['id']}/media", + headers=user["headers"], + files={"file": ("sprachnachricht.m4a", _M4A_BYTES, "audio/mp4")}, + ) + assert r.status_code == 200, r.text + m = r.json() + assert m["media_type"] == "audio" + assert m["url"].endswith(".m4a") + assert os.path.exists(_media_path(m["url"])) + + +def test_delete_media_removes_row_and_file(client, user): + note = _create_note(client, user) + up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"], + files={"file": ("x.jpg", _jpeg_bytes(), "image/jpeg")}).json() + path = _media_path(up["url"]) + assert os.path.exists(path) + + r = client.delete(f"/api/notes/{note['id']}/media/{up['id']}", headers=user["headers"]) + assert r.status_code == 204 + assert not os.path.exists(path) + + g = client.get("/api/notes/diary/1", headers=user["headers"]).json() + target = next(n for n in g if n["id"] == note["id"]) + assert target["media_items"] == [] + + +def test_delete_note_removes_media_files(client, user): + note = _create_note(client, user) + up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"], + files={"file": ("y.jpg", _jpeg_bytes(), "image/jpeg")}).json() + path = _media_path(up["url"]) + assert os.path.exists(path) + + r = client.delete(f"/api/notes/{note['id']}", headers=user["headers"]) + assert r.status_code == 204 + assert not os.path.exists(path) + + +def test_upload_rejects_corrupt_image(client, user): + note = _create_note(client, user) + r = client.post( + f"/api/notes/{note['id']}/media", + headers=user["headers"], + files={"file": ("fake.jpg", b"this is not a jpeg", "image/jpeg")}, + ) + assert r.status_code == 415 + + +def test_upload_media_requires_own_note(client, user): + r = client.post( + "/api/notes/999999/media", + headers=user["headers"], + files={"file": ("z.jpg", _jpeg_bytes(), "image/jpeg")}, + ) + assert r.status_code == 404 + + +def test_audio_utils_unit(): + """to_m4a reicht bereits-AAC durch; validate_audio prĂŒft Magic-Bytes.""" + from media_utils import to_m4a, validate_audio + data, ext = to_m4a(_M4A_BYTES, ".m4a") + assert ext == ".m4a" + assert data == _M4A_BYTES # schon AAC → keine Transkodierung + validate_audio(_M4A_BYTES, "audio/mp4") # kein Raise + with pytest.raises(ValueError): + validate_audio(b"xxxx", "audio/mp4")