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/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..911c021 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, 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..fb6e1e3 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'; " 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/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/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/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..a348003 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
  • @@ -511,7 +508,7 @@

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

    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_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")