diff --git a/MARKETING.md b/MARKETING.md index 735a061..63fcbc0 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 | 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) | +| SEO / KI-Auffindbarkeit | 🟡 technisch optimiert | 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,9 +41,7 @@ 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" 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] Landing-Promotion fĂŒr „Ban Yaro Go" gebaut (Hero-Badge + iOS-Abschnitt) — 09.06., develop (URL-Platzhalter offen) - [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 7fc461a..4c7cefe 100644 --- a/Makefile +++ b/Makefile @@ -296,9 +296,7 @@ dev: KI_MODE=off ENV=development \ JWT_SECRET=dev-secret \ DB_PATH=./dev.db \ - 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} + uvicorn main:app --reload --port 8001 # ---------------------------------------------------------- # REPORTS — Quartalsberichte generieren und committen diff --git a/VERSION b/VERSION index 364aacb..d1e5ab4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1292 \ No newline at end of file +1278 \ No newline at end of file diff --git a/backend/database.py b/backend/database.py index 4f3ce10..8065d48 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1303,23 +1303,6 @@ 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, @@ -2690,44 +2673,6 @@ 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 7e258c8..188056f 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -25,7 +25,6 @@ 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 21a5069..22fe399 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=(self), geolocation=(self)" + response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)" response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" response.headers["Content-Security-Policy"] = ( "default-src 'self'; " @@ -114,7 +114,6 @@ 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'; " @@ -238,7 +237,6 @@ 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 @@ -308,7 +306,6 @@ 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"]) @@ -586,22 +583,6 @@ 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 6f70fa5..4fa6a82 100644 --- a/backend/media_utils.py +++ b/backend/media_utils.py @@ -6,9 +6,6 @@ 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 = [ @@ -54,34 +51,6 @@ 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. @@ -100,21 +69,6 @@ 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() @@ -168,44 +122,6 @@ 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 3155cd0..375cf3a 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -483,16 +483,6 @@ 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']})") @@ -738,7 +728,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, VISION_MODEL, ANTHROPIC_KEY + from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY result = { "mode": KI_MODE, @@ -747,7 +737,6 @@ 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), } @@ -955,7 +944,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, user_id=user["id"]) + return await evaluate_enrichment(sample_size=sample) # ------------------------------------------------------------------ diff --git a/backend/routes/diary.py b/backend/routes/diary.py index c122e92..2dfcccb 100644 --- a/backend/routes/diary.py +++ b/backend/routes/diary.py @@ -122,14 +122,9 @@ 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 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", + 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", entry_ids ).fetchall() result = {} @@ -140,8 +135,6 @@ 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 @@ -641,9 +634,6 @@ 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) ) @@ -802,16 +792,9 @@ 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 a2a9bc3..5169634 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=ki_module.VISION_MODEL, + model="claude-opus-4-7", max_tokens=500, messages=[{ "role": "user", diff --git a/backend/routes/media.py b/backend/routes/media.py deleted file mode 100644 index 09e20d2..0000000 --- a/backend/routes/media.py +++ /dev/null @@ -1,243 +0,0 @@ -""" -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 f7dac31..5a70c2b 100644 --- a/backend/routes/notes.py +++ b/backend/routes/notes.py @@ -1,33 +1,24 @@ """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, UploadFile, File +from fastapi import APIRouter, Depends, HTTPException, Query 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): - # 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) + text: str = Field(..., min_length=1, 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) @@ -44,81 +35,16 @@ class NoteUpdate(BaseModel): # ------------------------------------------------------------------ # Hilfsfunktionen # ------------------------------------------------------------------ -def _serialize(row, media_map: Optional[dict] = None) -> dict: +def _serialize(row) -> 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) @@ -171,9 +97,8 @@ 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, media_map) for r in rows] + return [_serialize(r) for r in rows] @router.get("/all/0") @@ -184,8 +109,7 @@ async def list_all_notes(user=Depends(get_current_user)): "SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC", (user["id"],) ).fetchall() - media_map = _fetch_note_media(conn, [r["id"] for r in rows]) - return [_serialize(r, media_map) for r in rows] + return [_serialize(r) for r in rows] # ------------------------------------------------------------------ @@ -245,99 +169,6 @@ 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. @@ -353,8 +184,7 @@ async def list_notes(parent_type: str, parent_id: str, ORDER BY created_at DESC""", (user["id"], parent_type, parent_id) ).fetchall() - media_map = _fetch_note_media(conn, [r["id"] for r in rows]) - return [_serialize(r, media_map) for r in rows] + return [_serialize(r) for r in rows] # ------------------------------------------------------------------ @@ -363,6 +193,9 @@ 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) @@ -379,7 +212,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, {}) # frisch erstellt → media_items=[]; Upload folgt separat + return _serialize(row) # ------------------------------------------------------------------ @@ -397,7 +230,8 @@ async def update_note(note_id: int, data: NoteUpdate, updates = {} if data.text is not None: - # Leer erlaubt — Medien können die Notiz tragen. + if not data.text.strip(): + raise HTTPException(400, "Notiz darf nicht leer sein.") updates["text"] = data.text.strip() if data.meta_json is not None: updates["meta_json"] = json.dumps(data.meta_json) @@ -407,16 +241,14 @@ async def update_note(note_id: int, data: NoteUpdate, updates["parent_label"] = data.parent_label if not updates: - media_map = _fetch_note_media(conn, [note_id]) - return _serialize(note, media_map) + return _serialize(note) 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() - media_map = _fetch_note_media(conn, [note_id]) - return _serialize(row, media_map) + return _serialize(row) # ------------------------------------------------------------------ @@ -430,8 +262,5 @@ 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 bf16ce0..f840ec8 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -216,16 +216,6 @@ 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 6f7968b..ee3bac7 100644 --- a/backend/routes/routen.py +++ b/backend/routes/routen.py @@ -480,12 +480,6 @@ 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 c296701..8c8009c 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -989,7 +989,6 @@ 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" @@ -1030,30 +1029,16 @@ 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} - -Fokus dieser Woche: {ton} +Dabei seit: {stats.get('weeks_total', 1)} Wochen 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""" @@ -1066,24 +1051,11 @@ Regeln (unbedingt einhalten): ) return text.strip() except Exception: - # 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)] + # 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" # ------------------------------------------------------------------ diff --git a/backend/scraper/breed_enricher.py b/backend/scraper/breed_enricher.py index 9d0cb97..8aed584 100644 --- a/backend/scraper/breed_enricher.py +++ b/backend/scraper/breed_enricher.py @@ -360,47 +360,30 @@ async def _fetch_wikimedia_photo(name: str) -> str | None: return None -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. +async def _haiku_complete(prompt: str) -> str: + """Claude Haiku direkt aufrufen (immer Cloud, fĂŒr maximale Genauigkeit).""" + import anthropic - 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") - # 1. Bevorzugt: Claude Haiku direkt (gĂŒnstigstes Cloud-Modell) - if key: - try: - import anthropic + 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}], + ) - 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 + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor(None, _call) + return resp.content[0].text.strip() async def _enrich_one(rasse, dry_run: bool = False) -> bool: @@ -428,12 +411,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. KI extrahiert Fakten aus dem Quelltext (Haiku, sonst lokaler Fallback) + # 2. Haiku extrahiert Fakten aus dem Quelltext prompt = _PROMPT.format(name=name, lang=wiki_lang.upper(), wiki_text=wiki_text) try: - raw, used_model = await _haiku_complete(prompt) + raw = await _haiku_complete(prompt) except Exception as e: - logger.error("KI-Anfrage fehlgeschlagen fĂŒr %s: %s", name, e) + logger.error("Haiku-Anfrage fehlgeschlagen fĂŒr %s: %s", name, e) await asyncio.sleep(3) return False @@ -452,7 +435,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"] = used_model + updates["ki_model"] = _HAIKU_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 30c352f..c78bbfd 100644 --- a/backend/scraper/breed_evaluator.py +++ b/backend/scraper/breed_evaluator.py @@ -43,23 +43,19 @@ AktivitĂ€t zur Erfahrung)? ''' -async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) -> dict: +async def evaluate_enrichment(sample_size: int = 20) -> dict: """ - 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. + Bewertet `sample_size` zufĂ€llig gewĂ€hlte angereicherte Rassen via Claude. 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 - if ki.KI_MODE == "off": - raise RuntimeError("KI ist deaktiviert (KI_MODE=off) — Evaluierung nicht möglich.") + ANTHROPIC_KEY = os.getenv("ANTHROPIC_API_KEY", "") + if not ANTHROPIC_KEY: + raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt — Evaluierung benötigt Cloud.") with db() as conn: rassen = conn.execute( @@ -69,7 +65,8 @@ async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) wohnung_geeignet, temperament, ki_model FROM wiki_rassen WHERE ki_enriched = 1 - AND (ki_model IS NULL OR ki_model NOT LIKE 'claude%') + AND ki_model IS NOT NULL + AND ki_model NOT LIKE 'claude%' ORDER BY RANDOM() LIMIT ?""", (sample_size,), @@ -78,10 +75,10 @@ async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) if not rassen: return {"error": "Keine angereicherten Rassen gefunden."} - _EVAL_SYSTEM = "Du bist ein prĂ€ziser QualitĂ€tsprĂŒfer. Antworte ausschließlich als JSON." + import anthropic + client = anthropic.Anthropic(api_key=ANTHROPIC_KEY) results = [] - sources = set() totals = {"vollstaendigkeit": 0, "korrektheit": 0, "sprachqualitaet": 0, "konsistenz": 0, "gesamt": 0} @@ -105,17 +102,22 @@ async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) data=json.dumps(data, ensure_ascii=False, indent=2), ) try: - 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) + 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() - # JSON extrahieren (lokale Modelle wrappen gern in ```json 
 ```) + # JSON extrahieren import re match = re.search(r"\{[\s\S]+\}", raw) scores = json.loads(match.group(0)) if match else {} @@ -134,12 +136,9 @@ async def evaluate_enrichment(sample_size: int = 20, user_id: int | None = None) 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 a96b2ab..8d537f5 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -3088,23 +3088,12 @@ html.modal-open { } .rdr-play svg { width: 14px; height: 14px; } .rdr-play:active { background: var(--c-border); } -.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-slider { flex: 1; min-width: 0; height: 4px; accent-color: var(--c-primary); cursor: pointer; } .rdr-time { flex-shrink: 0; font-size: 11px; font-weight: 600; font-variant-numeric: tabular-nums; - width: 112px; /* FESTE Breite → Regler bleibt immer gleich lang */ - white-space: nowrap; overflow: hidden; - text-align: right; color: var(--c-text-secondary); + min-width: 74px; 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 31e2dab..8d5a58c 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, Videos, Sprachnachrichten, StimmungseintrĂ€ge (privat, nur fĂŒr dich)
  • +
  • Tagebuch & Notizen: Texte, Fotos, 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,9 +94,6 @@
  • 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
  • @@ -315,19 +312,6 @@ 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 @@ -511,7 +495,7 @@

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

    diff --git a/backend/static/index.html b/backend/static/index.html index 830ff54..385e4ae 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 5b71533..7b0e5d8 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -675,12 +675,6 @@ 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 2c2232a..9b7942e 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 = '1292'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1278'; // ← 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 ae8d0db..f5ad0ee 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -1419,10 +1419,7 @@ window.Page_admin = (() => { ${rows} `; - const judge = d.judge_source === 'cloud' ? 'Claude (Cloud)' - : d.judge_source === 'local' ? 'lokales Modell ⚠' - : (d.judge_source || '–'); - res.textContent = `✓ Bewertung abgeschlossen — PrĂŒfer: ${judge}`; + res.textContent = `✓ Bewertung abgeschlossen`; } 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 d34e04f..f6d07e7 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -283,11 +283,6 @@ 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 = ` @@ -321,7 +316,6 @@ 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 fad2572..130dd4f 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -451,9 +451,6 @@ 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)) { @@ -464,9 +461,7 @@ window.Page_map = (() => { if (_radarActive) { _radarActive = false; _radarPause(); - if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; _radarLayerKind = null; } - if (_rdrRaf != null) { cancelAnimationFrame(_rdrRaf); _rdrRaf = null; } - _rdrPendingIdx = null; + if (_radarLayer) { _wxRemoveRaster(_radarLayer); _radarLayer = null; } clearInterval(_radarTimer); document.getElementById('map-radar-timeline')?.remove(); btn?.classList.remove('active'); @@ -607,107 +602,67 @@ window.Page_map = (() => { async function _loadRadar() { if (!_radarActive || !_map) return; try { - // 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 resp = await fetch('https://api.rainviewer.com/public/weather-maps.json', { 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`; - // 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) + // 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 - // 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) + // DWD-Vorhersage (0–120 min, 5-Min-Schritte): ersetzt den RainViewer-Nowcast, + // Vergangenheit bleibt RainViewer (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); - // 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) { + // 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 })); 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, })); - 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 + frames = [...pastRv, ...dwd]; + nowIdx = pastRv.length; // DWD lead 0 = "jetzt" } } } catch (e) { /* offline/kein Manifest → RainViewer-Fallback */ } } - // 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 + _radarFrames = frames; + _radarNowIdx = nowIdx; 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 f = _radarFrames[idx]; - const url = f.url; - const kind = f.dwd ? 'dwd' : 'rv'; + const url = _radarUrl(idx); const src = _engineGL && _radarLayer && _map.getSource && _map.getSource('wx-radar'); - // 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) { + if (src && src.setTiles) { 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'); @@ -719,27 +674,17 @@ window.Page_map = (() => { -
    - - -
    + `; document.getElementById('central-map')?.appendChild(el); el.querySelector('#rdr-play').addEventListener('click', _toggleRadarPlay); el.querySelector('#rdr-slider').addEventListener('input', e => { - 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" + const idx = parseInt(e.target.value, 10); // ZUERST lesen: _radarPause() setzt slider.value zurĂŒck _radarPause(); - // 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; } - }); - } + _showRadarFrame(idx); }); + } 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'); @@ -751,7 +696,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 = _radarIdxToPos(_radarIdx); + if (slider) slider.value = _radarIdx; if (playBtn) playBtn.querySelector('use')?.setAttribute('href', `/icons/phosphor.svg#${_radarPlaying ? 'pause' : 'play'}`); const f = _radarFrames[_radarIdx]; if (timeEl && f) { @@ -759,8 +704,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}`; // feste Breite (CSS) → Regler springt nicht - timeEl.classList.toggle('is-forecast', diffMin > 0); // Vorhersage-Frames farblich (statt "· DWD"-Text) + timeEl.textContent = `${hhmm} · ${rel}${f.dwd && diffMin > 0 ? ' · DWD' : ''}`; + timeEl.classList.toggle('is-forecast', diffMin > 0); } } @@ -1063,14 +1008,6 @@ 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 b281a46..6fceffe 100644 --- a/backend/static/js/pages/notes.js +++ b/backend/static/js/pages/notes.js @@ -305,34 +305,6 @@ 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 // ---------------------------------------------------------- @@ -370,9 +342,7 @@ window.Page_notes = (() => { - ${note.text ? `

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

    ` : ''} - - ${_noteMediaStrip(note)} +

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

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

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

    ${microBadges.length ? `
    @@ -566,16 +523,6 @@ 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)); - }); - }); } // ---------------------------------------------------------- @@ -640,12 +587,6 @@ window.Page_notes = (() => { box-sizing:border-box">
    - -
    - -
    -
    -
    @@ -656,52 +597,40 @@ window.Page_notes = (() => { overlay.innerHTML = _buildContent(); document.body.appendChild(overlay); - 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)'; + const _rebind = () => { + overlay.querySelectorAll('.nc-cat').forEach(btn => { + btn.addEventListener('click', () => { + _selType = btn.dataset.type; + overlay.innerHTML = _buildContent(); + _rebind(); + overlay.querySelector('#nc-text')?.focus(); }); }); - }); - overlay.querySelector('#nc-cancel')?.addEventListener('click', _remove); - overlay.addEventListener('click', e => { if (e.target === overlay) _remove(); }); + overlay.querySelector('#nc-cancel')?.addEventListener('click', () => overlay.remove()); + overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); - 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, + 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.'); }); - 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); + setTimeout(() => overlay.querySelector('#nc-text')?.focus(), 100); + }; + + _rebind(); } // ---------------------------------------------------------- @@ -803,13 +732,6 @@ window.Page_notes = (() => {
    ` : ''} - -
    - -
    -
    - @@ -838,12 +760,6 @@ 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; @@ -880,17 +796,14 @@ window.Page_notes = (() => { }); }); - function _close() { _media.destroy(); overlay.remove(); } + function _close() { 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 && !_media.hasPending() && !(note.media_items || []).length) { - UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhĂ€ngen.'); - return; - } + if (!text) { UI.toast.warning('Notiz darf nicht leer sein.'); return; } const saveBtn = overlay.querySelector('#notes-edit-save'); saveBtn.disabled = true; @@ -906,10 +819,6 @@ 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 d57863b..ac1d151 100644 --- a/backend/static/js/pages/routes.js +++ b/backend/static/js/pages/routes.js @@ -1996,15 +1996,6 @@ 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; @@ -2016,16 +2007,8 @@ 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 297fa15..f7e5de4 100644 --- a/backend/static/js/ui.js +++ b/backend/static/js/ui.js @@ -1706,270 +1706,6 @@ 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. @@ -1999,7 +1735,6 @@ const UI = (() => { placeholder="Notiz eingeben
" style="width:100%;resize:vertical"> -
    @@ -2017,27 +1752,17 @@ const UI = (() => { const closeBtn = document.getElementById('by-note-close'); let existingNoteId = null; - let existingNote = null; try { - 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 || ''; + const existing = await API.notes.get(parentType, parentId); + if (existing?.id) { + existingNoteId = existing.id; + textarea.value = existing.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 = () => { _media.destroy(); overlay.remove(); }; + const _close = () => overlay.remove(); closeBtn.addEventListener('click', _close); cancelBtn.addEventListener('click', _close); overlay.addEventListener('click', e => { if (e.target === overlay) _close(); }); @@ -2045,24 +1770,13 @@ 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() }; - let noteId = existingNoteId; - if (noteId) { - await API.notes.update(noteId, payload); + if (existingNoteId) { + await API.notes.update(existingNoteId, payload); } else { - 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
`; }); + await API.notes.create(parentType, parentId, payload); } toast.success('Notiz gespeichert.'); _close(); @@ -2073,62 +1787,10 @@ 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, noteMediaAttacher, lightbox, + noteModal, 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 2c44e93..da1e422 100644 --- a/backend/static/js/worlds.js +++ b/backend/static/js/worlds.js @@ -1791,146 +1791,6 @@ 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 9cb3d3f..272122c 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 c7e474e..119cc71 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 = '1292'; +const VER = '1278'; 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 240f881..d984dcd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,6 @@ 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 f0428e7..1b5675e 100644 --- a/tests/js/README.md +++ b/tests/js/README.md @@ -13,13 +13,5 @@ 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 deleted file mode 100644 index df45372..0000000 --- a/tests/js/test-nav-loop-closestidx.js +++ /dev/null @@ -1,98 +0,0 @@ -// 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 63df268..e016966 100644 --- a/tests/test_account_deletion.py +++ b/tests/test_account_deletion.py @@ -59,30 +59,3 @@ 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 deleted file mode 100644 index b79583d..0000000 --- a/tests/test_media_registry.py +++ /dev/null @@ -1,227 +0,0 @@ -"""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 deleted file mode 100644 index a1003f9..0000000 --- a/tests/test_notes_media.py +++ /dev/null @@ -1,129 +0,0 @@ -"""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")