Notiz-Medien & Sprachnachrichten: Fotos/Videos/Dateien + Audio an Notizen
Wiederverwendbarer UI.noteMediaAttacher für beide Notiz-Stellen (UI.noteModal
+ Notizblock-Seite). note_media-Tabelle + POST/DELETE /api/notes/{id}/media
(vor der gierigen /{parent_type}/{parent_id}-Route). Audio per MediaRecorder,
serverseitig nach m4a/AAC transkodiert (ffmpeg) — iOS spielt Chrome-Opus-webm
nicht ab. UI.lightbox global eingeführt. Mikrofon-Policy microphone=(self) +
CSP media-src 'self' blob:, Datenschutz v6. Disk-Cleanup für note_media bei
Notiz-, Account- und Admin-User-Delete. Reine Medien-Notiz ohne Text erlaubt.
noteModal-Bug gefixt: notes.get() liefert Array -> existing[0] statt
existing?.id (verhinderte Bearbeiten, erzeugte Duplikate). 12 neue Tests.
admin.py enthält außerdem KI-Vision-Statusfelder aus paralleler Arbeit
(nicht sauber trennbar ohne interaktives Staging).
This commit is contained in:
parent
203da50e1d
commit
e86d89f3d9
12 changed files with 947 additions and 59 deletions
|
|
@ -1303,6 +1303,23 @@ def _migrate(conn_factory):
|
||||||
""")
|
""")
|
||||||
logger.info("Migration: notes Tabelle bereit.")
|
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("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS ki_health_reports (
|
CREATE TABLE IF NOT EXISTS ki_health_reports (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=(self)"
|
response.headers["Permissions-Policy"] = "camera=(), microphone=(self), geolocation=(self)"
|
||||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||||
response.headers["Content-Security-Policy"] = (
|
response.headers["Content-Security-Policy"] = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
|
|
@ -114,6 +114,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
||||||
"worker-src 'self' blob:; " # 'self' = Service Worker (sw.js); blob: = MapLibre-GL-Worker
|
"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)
|
"style-src 'self' 'unsafe-inline'; " # Inline-Styles bleiben (zu viele Fundstellen für jetzt)
|
||||||
"img-src 'self' data: blob: https:; "
|
"img-src 'self' data: blob: https:; "
|
||||||
|
"media-src 'self' blob:; " # Audio/Video-Wiedergabe + lokale blob:-Vorschau (Sprachnotizen)
|
||||||
"connect-src 'self' https:; "
|
"connect-src 'self' https:; "
|
||||||
"frame-ancestors 'none'; "
|
"frame-ancestors 'none'; "
|
||||||
"base-uri 'self'; "
|
"base-uri 'self'; "
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ from typing import Tuple
|
||||||
|
|
||||||
_HEIC_EXTS = {".heic", ".heif"}
|
_HEIC_EXTS = {".heic", ".heif"}
|
||||||
_VIDEO_EXTS = {".mov", ".avi", ".m4v"}
|
_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
|
# Magic-Byte-Signaturen erlaubter Medientypen
|
||||||
_IMAGE_MAGIC = [
|
_IMAGE_MAGIC = [
|
||||||
|
|
@ -51,6 +54,34 @@ def validate_upload(data: bytes, filename: str) -> None:
|
||||||
# HEIC, MOV, AVI, M4V: Pillow/FFmpeg prüfen beim Konvertieren
|
# 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:
|
def safe_media_path(media_dir: str, url: str) -> str | None:
|
||||||
"""
|
"""
|
||||||
Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL.
|
Konstruiert einen sicheren Dateipfad aus einer gespeicherten URL.
|
||||||
|
|
@ -69,6 +100,21 @@ def safe_media_path(media_dir: str, url: str) -> str | None:
|
||||||
return candidate
|
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]:
|
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."""
|
"""Convert HEIC/HEIF to JPEG; return (data, ext) unchanged for all other types."""
|
||||||
ext = os.path.splitext(filename or "")[1].lower()
|
ext = os.path.splitext(filename or "")[1].lower()
|
||||||
|
|
@ -122,6 +168,44 @@ def to_mp4_if_needed(data: bytes, filename: str) -> Tuple[bytes, str]:
|
||||||
pass
|
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:
|
def extract_gps_from_exif(data: bytes) -> tuple | None:
|
||||||
"""EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurück oder None."""
|
"""EXIF-GPS aus Bilddaten lesen. Gibt (lat, lon) zurück oder None."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -483,6 +483,16 @@ async def delete_user(uid: int, user=Depends(require_admin)):
|
||||||
conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,))
|
conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,))
|
||||||
conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,))
|
conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,))
|
||||||
conn.execute("DELETE FROM forum_posts 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,))
|
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||||
_audit(conn, user, "user_delete", f"user:{uid} ({target['name']})")
|
_audit(conn, user, "user_delete", f"user:{uid} ({target['name']})")
|
||||||
|
|
||||||
|
|
@ -728,7 +738,7 @@ async def ki_history(user=Depends(require_mod)):
|
||||||
@router.get("/ki/status")
|
@router.get("/ki/status")
|
||||||
async def ki_status(user=Depends(require_mod)):
|
async def ki_status(user=Depends(require_mod)):
|
||||||
import httpx
|
import httpx
|
||||||
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, ANTHROPIC_KEY
|
from ki import KI_MODE, LOCAL_BASE_URL, LOCAL_MODEL, CLOUD_MODEL, VISION_MODEL, ANTHROPIC_KEY
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"mode": KI_MODE,
|
"mode": KI_MODE,
|
||||||
|
|
@ -737,6 +747,7 @@ async def ki_status(user=Depends(require_mod)):
|
||||||
"local_reachable": False,
|
"local_reachable": False,
|
||||||
"local_model_loaded": None,
|
"local_model_loaded": None,
|
||||||
"cloud_model": CLOUD_MODEL,
|
"cloud_model": CLOUD_MODEL,
|
||||||
|
"vision_model": VISION_MODEL,
|
||||||
"cloud_key_set": bool(ANTHROPIC_KEY),
|
"cloud_key_set": bool(ANTHROPIC_KEY),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -944,7 +955,7 @@ async def wiki_enrich(data: WikiEnrichBody, user=Depends(require_mod)):
|
||||||
async def wiki_evaluate(sample: int = 20, user=Depends(require_mod)):
|
async def wiki_evaluate(sample: int = 20, user=Depends(require_mod)):
|
||||||
from scraper.breed_evaluator import evaluate_enrichment
|
from scraper.breed_evaluator import evaluate_enrichment
|
||||||
sample = max(5, min(sample, 50))
|
sample = max(5, min(sample, 50))
|
||||||
return await evaluate_enrichment(sample_size=sample)
|
return await evaluate_enrichment(sample_size=sample, user_id=user["id"])
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,33 @@
|
||||||
"""BAN YARO — Notizen Routes"""
|
"""BAN YARO — Notizen Routes"""
|
||||||
|
|
||||||
|
import os
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional, Any, List
|
from typing import Optional, Any, List
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from timeutils import safe_client_time
|
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()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Schemas
|
# Schemas
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
class NoteCreate(BaseModel):
|
class NoteCreate(BaseModel):
|
||||||
text: str = Field(..., min_length=1, max_length=5000)
|
# Leerer Text erlaubt: eine reine Medien-Notiz (nur Foto/Sprachnachricht)
|
||||||
|
# wird zuerst leer angelegt, dann werden die Medien angehängt.
|
||||||
|
text: str = Field("", max_length=5000)
|
||||||
meta_json: Optional[Any] = None
|
meta_json: Optional[Any] = None
|
||||||
location_name: Optional[str] = Field(None, max_length=300)
|
location_name: Optional[str] = Field(None, max_length=300)
|
||||||
parent_label: Optional[str] = Field(None, max_length=200)
|
parent_label: Optional[str] = Field(None, max_length=200)
|
||||||
|
|
@ -35,16 +44,81 @@ class NoteUpdate(BaseModel):
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Hilfsfunktionen
|
# Hilfsfunktionen
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def _serialize(row) -> dict:
|
def _serialize(row, media_map: Optional[dict] = None) -> dict:
|
||||||
d = dict(row)
|
d = dict(row)
|
||||||
if d.get("meta_json") and isinstance(d["meta_json"], str):
|
if d.get("meta_json") and isinstance(d["meta_json"], str):
|
||||||
try:
|
try:
|
||||||
d["meta_json"] = json.loads(d["meta_json"])
|
d["meta_json"] = json.loads(d["meta_json"])
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
if media_map is not None:
|
||||||
|
d["media_items"] = media_map.get(d["id"], [])
|
||||||
return d
|
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
|
# GET /api/notes — Gesamt-Notizblock mit Filtern
|
||||||
# Alias: GET /api/notes/all/0 (Rückwärtskompatibilität)
|
# Alias: GET /api/notes/all/0 (Rückwärtskompatibilität)
|
||||||
|
|
@ -97,8 +171,9 @@ async def list_all_notes_filtered(
|
||||||
f"SELECT * FROM notes WHERE {where} ORDER BY {order}",
|
f"SELECT * FROM notes WHERE {where} ORDER BY {order}",
|
||||||
params
|
params
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
|
||||||
|
|
||||||
return [_serialize(r) for r in rows]
|
return [_serialize(r, media_map) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/all/0")
|
@router.get("/all/0")
|
||||||
|
|
@ -109,7 +184,8 @@ async def list_all_notes(user=Depends(get_current_user)):
|
||||||
"SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC",
|
"SELECT * FROM notes WHERE user_id=? ORDER BY created_at DESC",
|
||||||
(user["id"],)
|
(user["id"],)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [_serialize(r) for r in rows]
|
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
|
||||||
|
return [_serialize(r, media_map) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -169,6 +245,99 @@ async def ki_analyse(user=Depends(get_current_user)):
|
||||||
return {"suggestions": suggestions, "note_count": note_count}
|
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}
|
# GET /api/notes/{parent_type}/{parent_id}
|
||||||
# parent_id kann ein Integer oder ein String-Schlüssel sein.
|
# parent_id kann ein Integer oder ein String-Schlüssel sein.
|
||||||
|
|
@ -184,7 +353,8 @@ async def list_notes(parent_type: str, parent_id: str,
|
||||||
ORDER BY created_at DESC""",
|
ORDER BY created_at DESC""",
|
||||||
(user["id"], parent_type, parent_id)
|
(user["id"], parent_type, parent_id)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [_serialize(r) for r in rows]
|
media_map = _fetch_note_media(conn, [r["id"] for r in rows])
|
||||||
|
return [_serialize(r, media_map) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -193,9 +363,6 @@ async def list_notes(parent_type: str, parent_id: str,
|
||||||
@router.post("/{parent_type}/{parent_id}", status_code=201)
|
@router.post("/{parent_type}/{parent_id}", status_code=201)
|
||||||
async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
|
async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
|
||||||
user=Depends(get_current_user)):
|
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
|
meta_str = json.dumps(data.meta_json) if data.meta_json is not None else None
|
||||||
now = safe_client_time(data.client_time)
|
now = safe_client_time(data.client_time)
|
||||||
|
|
||||||
|
|
@ -212,7 +379,7 @@ async def create_note(parent_type: str, parent_id: str, data: NoteCreate,
|
||||||
"SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1",
|
"SELECT * FROM notes WHERE user_id=? AND parent_type=? AND parent_id=? ORDER BY id DESC LIMIT 1",
|
||||||
(user["id"], parent_type, parent_id)
|
(user["id"], parent_type, parent_id)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return _serialize(row)
|
return _serialize(row, {}) # frisch erstellt → media_items=[]; Upload folgt separat
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -230,8 +397,7 @@ async def update_note(note_id: int, data: NoteUpdate,
|
||||||
|
|
||||||
updates = {}
|
updates = {}
|
||||||
if data.text is not None:
|
if data.text is not None:
|
||||||
if not data.text.strip():
|
# Leer erlaubt — Medien können die Notiz tragen.
|
||||||
raise HTTPException(400, "Notiz darf nicht leer sein.")
|
|
||||||
updates["text"] = data.text.strip()
|
updates["text"] = data.text.strip()
|
||||||
if data.meta_json is not None:
|
if data.meta_json is not None:
|
||||||
updates["meta_json"] = json.dumps(data.meta_json)
|
updates["meta_json"] = json.dumps(data.meta_json)
|
||||||
|
|
@ -241,14 +407,16 @@ async def update_note(note_id: int, data: NoteUpdate,
|
||||||
updates["parent_label"] = data.parent_label
|
updates["parent_label"] = data.parent_label
|
||||||
|
|
||||||
if not updates:
|
if not updates:
|
||||||
return _serialize(note)
|
media_map = _fetch_note_media(conn, [note_id])
|
||||||
|
return _serialize(note, media_map)
|
||||||
|
|
||||||
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
updates["updated_at"] = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
set_clause = ", ".join(f"{k}=?" for k in updates)
|
set_clause = ", ".join(f"{k}=?" for k in updates)
|
||||||
values = list(updates.values()) + [note_id]
|
values = list(updates.values()) + [note_id]
|
||||||
conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values)
|
conn.execute(f"UPDATE notes SET {set_clause} WHERE id=?", values)
|
||||||
row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone()
|
row = conn.execute("SELECT * FROM notes WHERE id=?", (note_id,)).fetchone()
|
||||||
return _serialize(row)
|
media_map = _fetch_note_media(conn, [note_id])
|
||||||
|
return _serialize(row, media_map)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -262,5 +430,8 @@ async def delete_note(note_id: int, user=Depends(get_current_user)):
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if not note:
|
if not note:
|
||||||
raise HTTPException(404, "Notiz nicht gefunden.")
|
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,))
|
conn.execute("DELETE FROM notes WHERE id=?", (note_id,))
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,16 @@ async def delete_account(user=Depends(get_current_user)):
|
||||||
if col in cols and (tbl, col) not in handled_fk_cols and (tbl, col) not in _ACTOR_COLUMNS:
|
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,))
|
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.
|
# Räumt alle verbliebenen ON-DELETE-CASCADE-Tabellen automatisch ab.
|
||||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@
|
||||||
<li><strong>Accountdaten:</strong> Benutzername, E-Mail-Adresse, Passphrase (verschlüsselt gespeichert)</li>
|
<li><strong>Accountdaten:</strong> Benutzername, E-Mail-Adresse, Passphrase (verschlüsselt gespeichert)</li>
|
||||||
<li><strong>Hundeprofil:</strong> Name, Rasse, Alter, Foto (freiwillig)</li>
|
<li><strong>Hundeprofil:</strong> Name, Rasse, Alter, Foto (freiwillig)</li>
|
||||||
<li><strong>Gesundheitsdaten deines Hundes:</strong> Gewicht, Impfungen, Tierarztbesuche, Medikamente (freiwillig, nur für dich sichtbar)</li>
|
<li><strong>Gesundheitsdaten deines Hundes:</strong> Gewicht, Impfungen, Tierarztbesuche, Medikamente (freiwillig, nur für dich sichtbar)</li>
|
||||||
<li><strong>Tagebuch & Notizen:</strong> Texte, Fotos, Stimmungseinträge (privat, nur für dich)</li>
|
<li><strong>Tagebuch & Notizen:</strong> Texte, Fotos, Videos, Sprachnachrichten, Stimmungseinträge (privat, nur für dich)</li>
|
||||||
<li><strong>Standortdaten:</strong> Nur nach expliziter Browser-Freigabe — für Karte, Gassi-Treffen,
|
<li><strong>Standortdaten:</strong> Nur nach expliziter Browser-Freigabe — für Karte, Gassi-Treffen,
|
||||||
Giftköder-Meldungen, Nearby-Alerts und Routenaufzeichnung. Standortdaten werden nicht dauerhaft
|
Giftköder-Meldungen, Nearby-Alerts und Routenaufzeichnung. Standortdaten werden nicht dauerhaft
|
||||||
gespeichert, außer du speicherst selbst eine Route oder Meldung.</li>
|
gespeichert, außer du speicherst selbst eine Route oder Meldung.</li>
|
||||||
|
|
@ -94,6 +94,9 @@
|
||||||
<li><strong>Fotos & EXIF-Daten:</strong> Beim Hochladen von Bildern können GPS-Koordinaten
|
<li><strong>Fotos & EXIF-Daten:</strong> Beim Hochladen von Bildern können GPS-Koordinaten
|
||||||
in den EXIF-Metadaten enthalten sein. Diese werden serverseitig ausgelesen, um Fotos auf der
|
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.</li>
|
Karte zu verorten — sofern vorhanden. Die Rohdaten werden nicht separat gespeichert.</li>
|
||||||
|
<li><strong>Mikrofon (Sprachnachrichten):</strong> 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.</li>
|
||||||
<li><strong>Inhalte:</strong> Forenbeiträge, Chatnachrichten, öffentliche Gassi-Treffen</li>
|
<li><strong>Inhalte:</strong> Forenbeiträge, Chatnachrichten, öffentliche Gassi-Treffen</li>
|
||||||
<li><strong>Technische Daten:</strong> IP-Adresse (für Sicherheit und Rate-Limiting, max. 30 Tage),
|
<li><strong>Technische Daten:</strong> IP-Adresse (für Sicherheit und Rate-Limiting, max. 30 Tage),
|
||||||
Browser-Typ</li>
|
Browser-Typ</li>
|
||||||
|
|
@ -508,7 +511,7 @@
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
|
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin:0">
|
||||||
Stand: Juni 2026 · Version 5
|
Stand: Juni 2026 · Version 6
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -675,6 +675,12 @@ const API = (() => {
|
||||||
delete(id) {
|
delete(id) {
|
||||||
return del(`/notes/${id}`);
|
return del(`/notes/${id}`);
|
||||||
},
|
},
|
||||||
|
uploadMedia(noteId, formData) {
|
||||||
|
return upload(`/notes/${noteId}/media`, formData);
|
||||||
|
},
|
||||||
|
deleteMedia(noteId, mediaId) {
|
||||||
|
return del(`/notes/${noteId}/media/${mediaId}`);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,34 @@ window.Page_notes = (() => {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Medien-Helfer: Preview-URL ableiten + Indikator-Strip für die Karte
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function _notePreview(url) {
|
||||||
|
if (!url) return url;
|
||||||
|
const dot = url.lastIndexOf('.');
|
||||||
|
if (dot < 0) return url;
|
||||||
|
if (/\.(mp4|webm|mov|avi|m4v)$/i.test(url)) return url.slice(0, dot) + '_thumb.jpg';
|
||||||
|
if (/\.(m4a|aac|mp3|ogg|oga|wav|pdf)$/i.test(url)) return url;
|
||||||
|
return url.slice(0, dot) + '_preview.webp';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _noteMediaStrip(note) {
|
||||||
|
const items = note.media_items || [];
|
||||||
|
if (!items.length) return '';
|
||||||
|
const n = { image: 0, video: 0, audio: 0, pdf: 0, file: 0 };
|
||||||
|
items.forEach(m => { n[m.media_type] = (n[m.media_type] || 0) + 1; });
|
||||||
|
const parts = [];
|
||||||
|
if (n.image) parts.push(['image', n.image]);
|
||||||
|
if (n.video) parts.push(['video-camera', n.video]);
|
||||||
|
if (n.audio) parts.push(['microphone', n.audio]);
|
||||||
|
if (n.pdf + n.file) parts.push(['paperclip', n.pdf + n.file]);
|
||||||
|
if (!parts.length) return '';
|
||||||
|
return `<div class="notes-media-strip" style="display:flex;align-items:center;gap:12px;margin-top:6px;font-size:12px;color:var(--c-text-secondary)">
|
||||||
|
${parts.map(([icon, count]) => `<span style="display:inline-flex;align-items:center;gap:4px">${UI.icon(icon)} ${count}</span>`).join('')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// Notiz-Karte HTML
|
// Notiz-Karte HTML
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -342,7 +370,9 @@ window.Page_notes = (() => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notiztext -->
|
<!-- Notiztext -->
|
||||||
<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p>
|
${note.text ? `<p class="list-item-text notes-card-text">${UI.escape(_truncate(note.text))}</p>` : ''}
|
||||||
|
|
||||||
|
${_noteMediaStrip(note)}
|
||||||
|
|
||||||
<!-- Micro-Badges -->
|
<!-- Micro-Badges -->
|
||||||
${microBadges.length ? `
|
${microBadges.length ? `
|
||||||
|
|
@ -495,7 +525,20 @@ window.Page_notes = (() => {
|
||||||
${note.parent_label
|
${note.parent_label
|
||||||
? `<div class="text-sm-secondary"><strong>${UI.escape(note.parent_label)}</strong></div>` : ''}
|
? `<div class="text-sm-secondary"><strong>${UI.escape(note.parent_label)}</strong></div>` : ''}
|
||||||
|
|
||||||
<p class="notes-detail-text">${UI.escape(note.text || '')}</p>
|
${note.text ? `<p class="notes-detail-text">${UI.escape(note.text)}</p>` : ''}
|
||||||
|
|
||||||
|
${(note.media_items && note.media_items.length) ? `
|
||||||
|
<div style="display:flex;flex-direction:column;gap:8px">
|
||||||
|
${note.media_items.map(m => {
|
||||||
|
if (m.media_type === 'image')
|
||||||
|
return `<img class="notes-detail-media-img" data-full="${m.url}" src="${_notePreview(m.url)}" alt="" style="width:100%;max-height:320px;object-fit:cover;border-radius:var(--radius-md);cursor:pointer">`;
|
||||||
|
if (m.media_type === 'video')
|
||||||
|
return `<video src="${m.url}" controls playsinline style="width:100%;max-height:320px;border-radius:var(--radius-md);background:#000"></video>`;
|
||||||
|
if (m.media_type === 'audio')
|
||||||
|
return `<audio controls src="${m.url}" style="width:100%"></audio>`;
|
||||||
|
return `<a href="${m.url}" target="_blank" rel="noopener" class="btn btn-secondary" style="justify-content:flex-start;gap:8px">${UI.icon('file-text')} ${m.media_type === 'pdf' ? 'PDF öffnen' : 'Datei öffnen'}</a>`;
|
||||||
|
}).join('')}
|
||||||
|
</div>` : ''}
|
||||||
|
|
||||||
${microBadges.length ? `
|
${microBadges.length ? `
|
||||||
<div class="list-item-micro-badges">
|
<div class="list-item-micro-badges">
|
||||||
|
|
@ -523,6 +566,16 @@ window.Page_notes = (() => {
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
_openEditModal(note);
|
_openEditModal(note);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bild-Thumbnails → Lightbox; Preview→Original-Fallback (CSP-konform)
|
||||||
|
const _imgItems = (note.media_items || []).filter(m => m.media_type === 'image').map(m => ({ url: m.url, type: 'image' }));
|
||||||
|
document.querySelectorAll('.notes-detail-media-img').forEach(img => {
|
||||||
|
img.addEventListener('error', () => { if (img.src !== img.dataset.full) img.src = img.dataset.full; }, { once: true });
|
||||||
|
img.addEventListener('click', () => {
|
||||||
|
const idx = _imgItems.findIndex(it => it.url === img.dataset.full);
|
||||||
|
UI.lightbox?.show(_imgItems, Math.max(0, idx));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -587,6 +640,12 @@ window.Page_notes = (() => {
|
||||||
box-sizing:border-box"></textarea>
|
box-sizing:border-box"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Medien -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:600;color:var(--c-text);margin-bottom:var(--space-2)">Medien</label>
|
||||||
|
<div id="nc-media"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex-gap-3">
|
<div class="flex-gap-3">
|
||||||
<button id="nc-cancel" class="btn btn-ghost flex-1">Abbrechen</button>
|
<button id="nc-cancel" class="btn btn-ghost flex-1">Abbrechen</button>
|
||||||
<button id="nc-save" class="btn btn-primary flex-1">Speichern</button>
|
<button id="nc-save" class="btn btn-primary flex-1">Speichern</button>
|
||||||
|
|
@ -597,40 +656,52 @@ window.Page_notes = (() => {
|
||||||
overlay.innerHTML = _buildContent();
|
overlay.innerHTML = _buildContent();
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
const _rebind = () => {
|
const _media = UI.noteMediaAttacher({ containerId: 'nc-media' });
|
||||||
overlay.querySelectorAll('.nc-cat').forEach(btn => {
|
const _remove = () => { _media.destroy(); overlay.remove(); };
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
_selType = btn.dataset.type;
|
// Kategorie-Wechsel: nur Auswahl + Button-Styles aktualisieren — KEIN
|
||||||
overlay.innerHTML = _buildContent();
|
// innerHTML-Rebuild, sonst gingen eingegebener Text & angehängte Medien
|
||||||
_rebind();
|
// (und eine laufende Sprachaufnahme) verloren.
|
||||||
overlay.querySelector('#nc-text')?.focus();
|
overlay.querySelectorAll('.nc-cat').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
_selType = btn.dataset.type;
|
||||||
|
overlay.querySelectorAll('.nc-cat').forEach(b => {
|
||||||
|
const r = _rubrik(b.dataset.type);
|
||||||
|
const active = b.dataset.type === _selType;
|
||||||
|
b.style.borderColor = active ? r.color : 'var(--c-border)';
|
||||||
|
b.style.background = active ? r.color + '22' : 'var(--c-surface-2)';
|
||||||
|
b.style.color = active ? r.color : 'var(--c-text-secondary)';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
overlay.querySelector('#nc-cancel')?.addEventListener('click', () => overlay.remove());
|
overlay.querySelector('#nc-cancel')?.addEventListener('click', _remove);
|
||||||
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
|
overlay.addEventListener('click', e => { if (e.target === overlay) _remove(); });
|
||||||
|
|
||||||
overlay.querySelector('#nc-save')?.addEventListener('click', async () => {
|
overlay.querySelector('#nc-save')?.addEventListener('click', async () => {
|
||||||
const text = overlay.querySelector('#nc-text')?.value?.trim();
|
const text = overlay.querySelector('#nc-text')?.value?.trim();
|
||||||
if (!text) { UI.toast.warning('Bitte einen Text eingeben.'); return; }
|
if (!text && !_media.hasPending()) {
|
||||||
const btn = overlay.querySelector('#nc-save');
|
UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.');
|
||||||
await UI.asyncButton(btn, async () => {
|
return;
|
||||||
const rb = _rubrik(_selType);
|
}
|
||||||
await API.notes.create(_selType, 'standalone', {
|
const btn = overlay.querySelector('#nc-save');
|
||||||
text,
|
await UI.asyncButton(btn, async () => {
|
||||||
parent_label: rb.label,
|
const rb = _rubrik(_selType);
|
||||||
});
|
const created = await API.notes.create(_selType, 'standalone', {
|
||||||
overlay.remove();
|
text: text || '',
|
||||||
_filterType = _selType;
|
parent_label: rb.label,
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
@ -732,6 +803,13 @@ window.Page_notes = (() => {
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Medien -->
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:var(--text-sm);font-weight:var(--weight-semibold);
|
||||||
|
color:var(--c-text);margin-bottom:var(--space-2)">Medien</label>
|
||||||
|
<div id="notes-edit-media"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
|
|
@ -760,6 +838,12 @@ window.Page_notes = (() => {
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
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 selErfolgsquote = meta.erfolgsquote || null;
|
||||||
let selUmgebung = meta.umgebung || null;
|
let selUmgebung = meta.umgebung || null;
|
||||||
let selStimmung = meta.hund_stimmung || null;
|
let selStimmung = meta.hund_stimmung || null;
|
||||||
|
|
@ -796,14 +880,17 @@ window.Page_notes = (() => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function _close() { overlay.remove(); }
|
function _close() { _media.destroy(); overlay.remove(); }
|
||||||
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
|
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
|
||||||
overlay.querySelector('#notes-edit-cancel').addEventListener('click', _close);
|
overlay.querySelector('#notes-edit-cancel').addEventListener('click', _close);
|
||||||
|
|
||||||
// Speichern
|
// Speichern
|
||||||
overlay.querySelector('#notes-edit-save').addEventListener('click', async () => {
|
overlay.querySelector('#notes-edit-save').addEventListener('click', async () => {
|
||||||
const text = overlay.querySelector('#notes-edit-text').value.trim();
|
const text = overlay.querySelector('#notes-edit-text').value.trim();
|
||||||
if (!text) { UI.toast.warning('Notiz darf nicht leer sein.'); return; }
|
if (!text && !_media.hasPending() && !(note.media_items || []).length) {
|
||||||
|
UI.toast.warning('Bitte einen Text eingeben oder ein Medium anhängen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const saveBtn = overlay.querySelector('#notes-edit-save');
|
const saveBtn = overlay.querySelector('#notes-edit-save');
|
||||||
saveBtn.disabled = true;
|
saveBtn.disabled = true;
|
||||||
|
|
@ -819,6 +906,10 @@ window.Page_notes = (() => {
|
||||||
text,
|
text,
|
||||||
meta_json: Object.keys(metaObj).length > 0 ? metaObj : null,
|
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);
|
const idx = _notes.findIndex(n => n.id === note.id);
|
||||||
if (idx >= 0) _notes[idx] = updated;
|
if (idx >= 0) _notes[idx] = updated;
|
||||||
_render();
|
_render();
|
||||||
|
|
|
||||||
|
|
@ -1706,6 +1706,270 @@ const UI = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// NOTE-MEDIA-ATTACHER — wiederverwendbarer Medien-Anhang für Notizen.
|
||||||
|
// Buttons (Mediathek/Aufnehmen/Datei/Sprachnachricht), Liste anhängiger
|
||||||
|
// Dateien, Sprachaufnahme (MediaRecorder) und bereits gespeicherte Medien
|
||||||
|
// (mit Löschen). Genutzt von noteModal (ui.js) UND der Notizblock-Seite
|
||||||
|
// (pages/notes.js) — eine Quelle statt Duplikat.
|
||||||
|
// const m = UI.noteMediaAttacher({ containerId, noteId, existingMedia });
|
||||||
|
// …im Submit nach create/update: await m.uploadAll(noteId, onProgress);
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
function noteMediaAttacher({ containerId, noteId = null, existingMedia = [] } = {}) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
const _noop = { uploadAll: async () => ({ uploaded: [], failed: 0 }), hasPending: () => false, destroy: () => {} };
|
||||||
|
if (!container) return _noop;
|
||||||
|
|
||||||
|
const p = containerId.replace(/[^a-z0-9]/gi, '-');
|
||||||
|
const ids = {
|
||||||
|
bar: `${p}-bar`, pending: `${p}-pending`, existing: `${p}-existing`,
|
||||||
|
rec: `${p}-rec`, recTimer: `${p}-rec-timer`, recStop: `${p}-rec-stop`,
|
||||||
|
recCancel: `${p}-rec-cancel`, mic: `${p}-mic`,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _noteId = noteId;
|
||||||
|
const _pending = []; // [{ file, url }]
|
||||||
|
let _existing = (existingMedia || []).slice();
|
||||||
|
const RECORDER_OK = !!(window.MediaRecorder && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
|
||||||
|
let _stream = null, _rec = null, _chunks = [], _tick = null, _recStart = 0, _recMax = null, _recCancelled = false;
|
||||||
|
|
||||||
|
const BTN = 'display:inline-flex;align-items:center;gap:6px;font-size:var(--text-xs);font-weight:600;' +
|
||||||
|
'padding:7px 12px;border-radius:var(--radius-full);border:1.5px solid var(--c-border);' +
|
||||||
|
'background:var(--c-surface-2);color:var(--c-text-secondary);cursor:pointer';
|
||||||
|
const DEL = 'flex-shrink:0;width:26px;height:26px;border-radius:50%;border:none;background:rgba(0,0,0,.08);' +
|
||||||
|
'color:var(--c-text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;font-size:14px;line-height:1';
|
||||||
|
const ROW = 'display:flex;align-items:center;gap:10px;padding:6px;border:1px solid var(--c-border);border-radius:var(--radius-md)';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div id="${ids.existing}"></div>
|
||||||
|
<div id="${ids.bar}" style="display:flex;flex-wrap:wrap;gap:8px;margin:6px 0">
|
||||||
|
<button type="button" id="${p}-gallery" style="${BTN}">${_svgIcon('paperclip')} Foto / Video / Datei</button>
|
||||||
|
${RECORDER_OK ? `<button type="button" id="${ids.mic}" style="${BTN}">${_svgIcon('microphone')} Sprachnachricht</button>` : ''}
|
||||||
|
</div>
|
||||||
|
<div id="${ids.rec}" style="display:none;align-items:center;gap:10px;margin:6px 0;padding:8px 12px;border-radius:var(--radius-md);background:var(--c-danger-subtle,rgba(220,53,53,.12))">
|
||||||
|
<span style="width:10px;height:10px;border-radius:50%;background:var(--c-danger,#dc3535);flex-shrink:0"></span>
|
||||||
|
<span id="${ids.recTimer}" style="font-variant-numeric:tabular-nums;font-weight:600;color:var(--c-text)">0:00</span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button type="button" id="${ids.recCancel}" style="${BTN}">Verwerfen</button>
|
||||||
|
<button type="button" id="${ids.recStop}" style="${BTN};border-color:var(--c-danger,#dc3535);color:var(--c-danger,#dc3535)">${_svgIcon('stop')} Stop</button>
|
||||||
|
</div>
|
||||||
|
<div id="${ids.pending}" style="display:flex;flex-direction:column;gap:6px;margin-top:6px"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ---- 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 = `<img src="${it.url}" alt="" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0">`;
|
||||||
|
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Bild')}</span>`;
|
||||||
|
} else if (t.startsWith('video/')) {
|
||||||
|
preview = `<video src="${it.url}" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0" muted playsinline></video>`;
|
||||||
|
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Video')}</span>`;
|
||||||
|
} else if (t.startsWith('audio/')) {
|
||||||
|
preview = `<audio controls src="${it.url}" style="height:36px;flex:1;min-width:0"></audio>`;
|
||||||
|
} else {
|
||||||
|
preview = `<svg class="ph-icon" style="width:30px;height:30px;color:var(--c-text-secondary);flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>`;
|
||||||
|
label = `<span style="flex:1;min-width:0;font-size:var(--text-xs);color:var(--c-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape(f.name || 'Datei')}</span>`;
|
||||||
|
}
|
||||||
|
return `<div style="${ROW}" data-pidx="${i}">${preview}${label}<button type="button" data-pidx="${i}" aria-label="Entfernen" style="${DEL}">✕</button></div>`;
|
||||||
|
}).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 = `<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:4px">${_existing.map(m => {
|
||||||
|
let preview, label = '';
|
||||||
|
if (m.media_type === 'image') {
|
||||||
|
preview = `<img src="${_previewOf(m.url)}" data-full="${m.url}" alt="" style="width:48px;height:48px;border-radius:6px;object-fit:cover;flex-shrink:0;cursor:pointer" data-full-open="${m.url}">`;
|
||||||
|
} else if (m.media_type === 'video') {
|
||||||
|
preview = `<video src="${m.url}" controls playsinline style="height:48px;border-radius:6px;flex:1;min-width:0"></video>`;
|
||||||
|
} else if (m.media_type === 'audio') {
|
||||||
|
preview = `<audio controls src="${m.url}" style="height:36px;flex:1;min-width:0"></audio>`;
|
||||||
|
} else {
|
||||||
|
preview = `<a href="${m.url}" target="_blank" rel="noopener" style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;color:var(--c-text-secondary);text-decoration:none"><svg class="ph-icon" style="width:28px;height:28px;flex-shrink:0" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg><span style="font-size:var(--text-xs);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escape((m.media_type === 'pdf' ? 'PDF' : 'Datei'))}</span></a>`;
|
||||||
|
}
|
||||||
|
return `<div style="${ROW}" data-mid="${m.id}">${preview}${label}<button type="button" data-mid="${m.id}" aria-label="Löschen" style="${DEL}">✕</button></div>`;
|
||||||
|
}).join('')}</div>`;
|
||||||
|
// 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)
|
// NOTE-MODAL — Notiz zu einem beliebigen Objekt (parentType/parentId)
|
||||||
// erstellen/bearbeiten. Zentral, damit nicht jede Seite eine eigene Kopie hat.
|
// erstellen/bearbeiten. Zentral, damit nicht jede Seite eine eigene Kopie hat.
|
||||||
|
|
@ -1735,6 +1999,7 @@ const UI = (() => {
|
||||||
placeholder="Notiz eingeben…"
|
placeholder="Notiz eingeben…"
|
||||||
style="width:100%;resize:vertical"></textarea>
|
style="width:100%;resize:vertical"></textarea>
|
||||||
</form>
|
</form>
|
||||||
|
<div id="by-note-media" style="margin-top:var(--space-3)"></div>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
|
<div style="padding:var(--space-3) var(--space-5);border-top:1px solid var(--c-border);
|
||||||
display:flex;gap:var(--space-2);flex-shrink:0">
|
display:flex;gap:var(--space-2);flex-shrink:0">
|
||||||
|
|
@ -1752,17 +2017,27 @@ const UI = (() => {
|
||||||
const closeBtn = document.getElementById('by-note-close');
|
const closeBtn = document.getElementById('by-note-close');
|
||||||
|
|
||||||
let existingNoteId = null;
|
let existingNoteId = null;
|
||||||
|
let existingNote = null;
|
||||||
try {
|
try {
|
||||||
const existing = await API.notes.get(parentType, parentId);
|
const res = await API.notes.get(parentType, parentId);
|
||||||
if (existing?.id) {
|
// GET /notes/{type}/{id} liefert ein Array (neueste zuerst) — die jüngste
|
||||||
existingNoteId = existing.id;
|
// Notiz bearbeiten statt bei jedem Öffnen eine neue anzulegen (Duplikate).
|
||||||
textarea.value = existing.text || '';
|
existingNote = Array.isArray(res) ? res[0] : res;
|
||||||
|
if (existingNote?.id) {
|
||||||
|
existingNoteId = existingNote.id;
|
||||||
|
textarea.value = existingNote.text || '';
|
||||||
}
|
}
|
||||||
} catch (_) { /* keine Notiz vorhanden — ok */ }
|
} catch (_) { /* keine Notiz vorhanden — ok */ }
|
||||||
|
|
||||||
|
const _media = noteMediaAttacher({
|
||||||
|
containerId: 'by-note-media',
|
||||||
|
noteId: existingNoteId,
|
||||||
|
existingMedia: existingNote?.media_items || [],
|
||||||
|
});
|
||||||
|
|
||||||
setTimeout(() => textarea.focus(), 100);
|
setTimeout(() => textarea.focus(), 100);
|
||||||
|
|
||||||
const _close = () => overlay.remove();
|
const _close = () => { _media.destroy(); overlay.remove(); };
|
||||||
closeBtn.addEventListener('click', _close);
|
closeBtn.addEventListener('click', _close);
|
||||||
cancelBtn.addEventListener('click', _close);
|
cancelBtn.addEventListener('click', _close);
|
||||||
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
|
overlay.addEventListener('click', e => { if (e.target === overlay) _close(); });
|
||||||
|
|
@ -1770,13 +2045,24 @@ const UI = (() => {
|
||||||
document.getElementById('by-note-form').addEventListener('submit', async e => {
|
document.getElementById('by-note-form').addEventListener('submit', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const text = textarea.value.trim();
|
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);
|
setLoading(saveBtn, true);
|
||||||
try {
|
try {
|
||||||
const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() };
|
const payload = { text, parent_label: parentLabel, location_name: locationName || null, client_time: API.clientNow() };
|
||||||
if (existingNoteId) {
|
let noteId = existingNoteId;
|
||||||
await API.notes.update(existingNoteId, payload);
|
if (noteId) {
|
||||||
|
await API.notes.update(noteId, payload);
|
||||||
} else {
|
} else {
|
||||||
await API.notes.create(parentType, parentId, payload);
|
const created = await API.notes.create(parentType, parentId, payload);
|
||||||
|
noteId = created?.id;
|
||||||
|
}
|
||||||
|
if (noteId && _media.hasPending()) {
|
||||||
|
await _media.uploadAll(noteId, (d, t) => { saveBtn.textContent = `${d}/${t} hochgeladen…`; });
|
||||||
}
|
}
|
||||||
toast.success('Notiz gespeichert.');
|
toast.success('Notiz gespeichert.');
|
||||||
_close();
|
_close();
|
||||||
|
|
@ -1787,10 +2073,62 @@ const UI = (() => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// LIGHTBOX — Vollbild-Viewer für Bilder/Videos mit Vor/Zurück.
|
||||||
|
// items: [{ url, type }] (type 'image'|'video', Default 'image') oder
|
||||||
|
// reine URL-Strings. Global, damit Notizen, Tagebuch & Co. EINEN Viewer
|
||||||
|
// teilen (app.js erwartet UI.lightbox.show bereits).
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const lightbox = (() => {
|
||||||
|
function show(items, startIdx = 0) {
|
||||||
|
const list = (Array.isArray(items) ? items : [items])
|
||||||
|
.map(it => (typeof it === 'string' ? { url: it } : it))
|
||||||
|
.filter(it => it && it.url);
|
||||||
|
if (!list.length) return;
|
||||||
|
let idx = Math.min(Math.max(0, startIdx | 0), list.length - 1);
|
||||||
|
|
||||||
|
const lb = document.createElement('div');
|
||||||
|
lb.id = 'by-lightbox';
|
||||||
|
lb.style.cssText = 'position:fixed;inset:0;z-index:3000;background:#000;display:flex;flex-direction:column';
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
const m = list[idx];
|
||||||
|
const media = (m.type === 'video')
|
||||||
|
? `<video src="${escape(m.url)}" controls autoplay playsinline style="max-width:100%;max-height:100%;display:block"></video>`
|
||||||
|
: `<img src="${escape(m.url)}" alt="" style="max-width:100%;max-height:100%;object-fit:contain;display:block">`;
|
||||||
|
lb.innerHTML = `
|
||||||
|
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">${media}</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr auto 1fr;align-items:center;padding-top:10px;
|
||||||
|
padding-bottom:calc(env(safe-area-inset-bottom,0px) + 12px);
|
||||||
|
padding-left:calc(env(safe-area-inset-left,0px) + 16px);
|
||||||
|
padding-right:calc(env(safe-area-inset-right,0px) + 16px);
|
||||||
|
flex-shrink:0;background:rgba(0,0,0,.5);gap:8px">
|
||||||
|
<button id="by-lb-close" style="background:rgba(255,255,255,.15);border:none;border-radius:24px;height:48px;
|
||||||
|
color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;padding:0 16px;
|
||||||
|
font-size:15px;font-weight:500">${_svgIcon('arrow-left')} Schließen</button>
|
||||||
|
<span style="color:rgba(255,255,255,.7);font-size:14px;text-align:center;white-space:nowrap">${list.length > 1 ? `${idx + 1} / ${list.length}` : ''}</span>
|
||||||
|
${list.length > 1 ? `
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button id="by-lb-prev" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;width:48px;height:48px;
|
||||||
|
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center${idx === 0 ? ';opacity:.3;pointer-events:none' : ''}">‹</button>
|
||||||
|
<button id="by-lb-next" style="background:rgba(255,255,255,.15);border:none;border-radius:50%;width:48px;height:48px;
|
||||||
|
color:#fff;font-size:24px;cursor:pointer;display:flex;align-items:center;justify-content:center${idx === list.length - 1 ? ';opacity:.3;pointer-events:none' : ''}">›</button>
|
||||||
|
</div>` : '<div></div>'}
|
||||||
|
</div>`;
|
||||||
|
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
|
// Öffentliche API
|
||||||
return {
|
return {
|
||||||
toast, modal,
|
toast, modal,
|
||||||
noteModal,
|
noteModal, noteMediaAttacher, lightbox,
|
||||||
setLoading, asyncButton,
|
setLoading, asyncButton,
|
||||||
formData, setFormError, clearFormErrors,
|
formData, setFormError, clearFormErrors,
|
||||||
emptyState, errorState, time, text, money,
|
emptyState, errorState, time, text, money,
|
||||||
|
|
|
||||||
|
|
@ -59,3 +59,30 @@ def test_delete_account_minimal_user(client):
|
||||||
assert resp.status_code == 200, resp.text
|
assert resp.status_code == 200, resp.text
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
assert conn.execute("SELECT 1 FROM users WHERE id=?", (uid,)).fetchone() is None
|
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"
|
||||||
|
|
|
||||||
129
tests/test_notes_media.py
Normal file
129
tests/test_notes_media.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
"""Tests für Notiz-Medien: Bild-/Audio-Upload, GET liefert media_items,
|
||||||
|
Löschen entfernt DB-Zeile + Datei, Notiz-Delete räumt Dateien, Validierung."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _jpeg_bytes(color=(200, 100, 50)):
|
||||||
|
from PIL import Image
|
||||||
|
buf = io.BytesIO()
|
||||||
|
Image.new("RGB", (12, 12), color).save(buf, format="JPEG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# Minimaler MP4/M4A-Header (ftyp-Box) — genügt für validate_audio(audio/mp4).
|
||||||
|
_M4A_BYTES = b"\x00\x00\x00\x18ftypM4A \x00\x00\x00\x00M4A mp42isom" + b"\x00" * 32
|
||||||
|
|
||||||
|
|
||||||
|
def _create_note(client, user, text="Notiz mit Medien"):
|
||||||
|
r = client.post("/api/notes/diary/1", headers=user["headers"],
|
||||||
|
json={"text": text, "parent_label": "Testobjekt"})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def _media_path(url):
|
||||||
|
media_dir = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
rel = url[len("/media/"):] if url.startswith("/media/") else url.lstrip("/")
|
||||||
|
return os.path.join(media_dir, rel)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_image_to_note(client, user):
|
||||||
|
note = _create_note(client, user)
|
||||||
|
r = client.post(
|
||||||
|
f"/api/notes/{note['id']}/media",
|
||||||
|
headers=user["headers"],
|
||||||
|
files={"file": ("foto.jpg", _jpeg_bytes(), "image/jpeg")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
m = r.json()
|
||||||
|
assert m["media_type"] == "image"
|
||||||
|
assert m["url"].startswith("/media/notes/")
|
||||||
|
assert os.path.exists(_media_path(m["url"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_get_includes_media_items(client, user):
|
||||||
|
note = _create_note(client, user)
|
||||||
|
client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
|
||||||
|
files={"file": ("a.jpg", _jpeg_bytes(), "image/jpeg")})
|
||||||
|
r = client.get("/api/notes/diary/1", headers=user["headers"])
|
||||||
|
assert r.status_code == 200
|
||||||
|
target = next(n for n in r.json() if n["id"] == note["id"])
|
||||||
|
assert len(target["media_items"]) == 1
|
||||||
|
assert target["media_items"][0]["media_type"] == "image"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_audio_to_note(client, user):
|
||||||
|
# Reine Sprachnotiz: leerer Text ist erlaubt, das Medium trägt die Notiz.
|
||||||
|
note = _create_note(client, user, text="")
|
||||||
|
r = client.post(
|
||||||
|
f"/api/notes/{note['id']}/media",
|
||||||
|
headers=user["headers"],
|
||||||
|
files={"file": ("sprachnachricht.m4a", _M4A_BYTES, "audio/mp4")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
m = r.json()
|
||||||
|
assert m["media_type"] == "audio"
|
||||||
|
assert m["url"].endswith(".m4a")
|
||||||
|
assert os.path.exists(_media_path(m["url"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_media_removes_row_and_file(client, user):
|
||||||
|
note = _create_note(client, user)
|
||||||
|
up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
|
||||||
|
files={"file": ("x.jpg", _jpeg_bytes(), "image/jpeg")}).json()
|
||||||
|
path = _media_path(up["url"])
|
||||||
|
assert os.path.exists(path)
|
||||||
|
|
||||||
|
r = client.delete(f"/api/notes/{note['id']}/media/{up['id']}", headers=user["headers"])
|
||||||
|
assert r.status_code == 204
|
||||||
|
assert not os.path.exists(path)
|
||||||
|
|
||||||
|
g = client.get("/api/notes/diary/1", headers=user["headers"]).json()
|
||||||
|
target = next(n for n in g if n["id"] == note["id"])
|
||||||
|
assert target["media_items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_note_removes_media_files(client, user):
|
||||||
|
note = _create_note(client, user)
|
||||||
|
up = client.post(f"/api/notes/{note['id']}/media", headers=user["headers"],
|
||||||
|
files={"file": ("y.jpg", _jpeg_bytes(), "image/jpeg")}).json()
|
||||||
|
path = _media_path(up["url"])
|
||||||
|
assert os.path.exists(path)
|
||||||
|
|
||||||
|
r = client.delete(f"/api/notes/{note['id']}", headers=user["headers"])
|
||||||
|
assert r.status_code == 204
|
||||||
|
assert not os.path.exists(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_rejects_corrupt_image(client, user):
|
||||||
|
note = _create_note(client, user)
|
||||||
|
r = client.post(
|
||||||
|
f"/api/notes/{note['id']}/media",
|
||||||
|
headers=user["headers"],
|
||||||
|
files={"file": ("fake.jpg", b"this is not a jpeg", "image/jpeg")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 415
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_media_requires_own_note(client, user):
|
||||||
|
r = client.post(
|
||||||
|
"/api/notes/999999/media",
|
||||||
|
headers=user["headers"],
|
||||||
|
files={"file": ("z.jpg", _jpeg_bytes(), "image/jpeg")},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_audio_utils_unit():
|
||||||
|
"""to_m4a reicht bereits-AAC durch; validate_audio prüft Magic-Bytes."""
|
||||||
|
from media_utils import to_m4a, validate_audio
|
||||||
|
data, ext = to_m4a(_M4A_BYTES, ".m4a")
|
||||||
|
assert ext == ".m4a"
|
||||||
|
assert data == _M4A_BYTES # schon AAC → keine Transkodierung
|
||||||
|
validate_audio(_M4A_BYTES, "audio/mp4") # kein Raise
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
validate_audio(b"xxxx", "audio/mp4")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue