banyaro/backend/routes/import_data.py
rene f8d354749d Feature: Tagebuch Ort/POI, Foto/Video-Edit, Modal-UX, iOS-Fixes
Tagebuch — Ort/POI (DayOne-ähnlich):
- diary.location_name Spalte, DiaryCreate/Update mit gps_lat/lon/location_name
- GET /api/dogs/{id}/diary/nearby: Overpass + Nominatim (vor {entry_id}-Route)
- Mini-Karte im Edit-Formular: Leaflet lazy, Edit-Modus, SVG-Pin
- Meilenstein-Toggle: Button statt Checkbox, Filter in Toolbar
- Datenmigration: 97 Ort-Einträge aus text → location_name

Tagebuch — Foto/Video:
- Foto/Video im Edit: Ersetzen + Löschen, DELETE media endpoint
- Media-Picker: Kamera/Mediathek/Datei Buttons
- Video-Wiedergabe (<video controls> in Detail + Edit)

Modal-UX (alle Edit-Karten vereinheitlicht):
- Footer-Pattern: [Speichern vollbreit] / [Löschen][Abbrechen]
- diary, dog-profile, events, health, places, walks, settings, sitting
- Löschen aus Detail-Modal → Edit-Form verschoben

iOS Mobile-Fixes:
- Auto-Zoom: input/select/textarea font-size 16px !important
- Scroll-Through: html.modal-open + touch-action:none auf Overlay
- Kein position:fixed mehr auf body (kein Scroll-Sprung)

PWA & Icons:
- icon-512-any.png + icon-192-any.png (quadratisch, maskable)
- manifest.json: purpose any/maskable getrennt
- Gesundheits-Icon: syringe → first-aid

Import-Fix:
- _HTMLStripper überspringt video/audio/script → kein "Video nicht gefunden" mehr
2026-04-18 11:56:54 +02:00

282 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""BAN YARO — Import: Synology NoteStation (.nsx) + CSV"""
import os, io, uuid, json, zipfile, csv, re, datetime
from html.parser import HTMLParser
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from database import db
from auth import get_current_user
router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
MAX_NSX_MB = 200
MAX_CSV_MB = 10
# ------------------------------------------------------------------
# HTML → Plaintext
# ------------------------------------------------------------------
class _HTMLStripper(HTMLParser):
_SKIP_TAGS = {"video", "audio", "script", "style"}
def __init__(self):
super().__init__()
self.parts = []
self._skip = 0
def handle_starttag(self, tag, attrs):
if tag.lower() in self._SKIP_TAGS:
self._skip += 1
def handle_endtag(self, tag):
if tag.lower() in self._SKIP_TAGS and self._skip > 0:
self._skip -= 1
def handle_data(self, data):
if not self._skip:
self.parts.append(data)
def handle_entityref(self, name):
if name == "nbsp":
self.parts.append(" ")
def handle_charref(self, name):
if name in ("160", "xa0"):
self.parts.append(" ")
def _html_to_text(html: str) -> str:
if not html:
return ""
# Replace block-level tags with newlines before stripping
html = re.sub(r"</?(div|p|br|h[1-6]|li)[^>]*>", "\n", html, flags=re.IGNORECASE)
s = _HTMLStripper()
s.feed(html)
text = "".join(s.parts)
# Collapse excessive blank lines
text = re.sub(r"\n{3,}", "\n\n", text).strip()
return text
# ------------------------------------------------------------------
# Helfer: Bild aus ZIP in Media-Ordner speichern
# ------------------------------------------------------------------
def _save_image_from_zip(zf: zipfile.ZipFile, md5: str, mime: str) -> str | None:
zip_name = f"file_{md5}"
if zip_name not in zf.namelist():
return None
ext = mime.split("/")[-1] if mime else "jpg"
ext = ext.replace("jpeg", "jpg")
dest_dir = os.path.join(MEDIA_DIR, "diary")
os.makedirs(dest_dir, exist_ok=True)
filename = f"import_{uuid.uuid4().hex}.{ext}"
dest = os.path.join(dest_dir, filename)
with zf.open(zip_name) as src, open(dest, "wb") as dst:
dst.write(src.read())
return f"/media/diary/{filename}"
# ------------------------------------------------------------------
# POST /api/import/notestation
# ------------------------------------------------------------------
@router.post("/notestation")
async def import_notestation(
dog_id: int = Form(...),
file: UploadFile = File(...),
user=Depends(get_current_user),
):
if not file.filename.lower().endswith(".nsx"):
raise HTTPException(400, "Bitte eine .nsx-Datei hochladen.")
raw = await file.read()
if len(raw) > MAX_NSX_MB * 1024 * 1024:
raise HTTPException(413, f"Datei zu groß (max {MAX_NSX_MB} MB).")
with db() as conn:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
try:
zf = zipfile.ZipFile(io.BytesIO(raw))
except zipfile.BadZipFile:
raise HTTPException(400, "Ungültige .nsx-Datei (kein ZIP).")
config = json.loads(zf.read("config.json"))
note_ids = config.get("note", [])
imported = 0
skipped = 0
errors = []
with db() as conn:
for nid in note_ids:
try:
note = json.loads(zf.read(nid))
except Exception as e:
errors.append(f"{nid}: Lesefehler {e}")
continue
if note.get("category") == "notebook":
skipped += 1
continue
# Datum aus ctime (Unix-Timestamp)
ctime = note.get("ctime") or note.get("mtime")
if ctime:
datum = datetime.datetime.fromtimestamp(ctime).strftime("%Y-%m-%d")
else:
datum = datetime.date.today().isoformat()
titel = (note.get("title") or "").strip() or None
text = _html_to_text(note.get("content", "")) or None
lat = note.get("latitude") or None
lon = note.get("longitude") or None
# Koordinate (0,0) ist kein echter Ort
if lat == 0.0:
lat = None
if lon == 0.0:
lon = None
tags = note.get("tag") or []
# Eintrag anlegen
conn.execute(
"""INSERT INTO diary (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone)
VALUES (?, ?, 'eintrag', ?, ?, ?, ?, ?, 0)""",
(dog_id, datum, titel, text, json.dumps(tags), lat, lon),
)
entry_id = conn.execute(
"SELECT last_insert_rowid() AS id"
).fetchone()["id"]
conn.execute(
"INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
(entry_id, dog_id),
)
# Erstes Bild speichern
attachments = note.get("attachment") or {}
media_url = None
for att in attachments.values():
md5 = att.get("md5", "")
mime = att.get("type", "image/jpeg")
if not mime.startswith("image/"):
continue
media_url = _save_image_from_zip(zf, md5, mime)
if media_url:
break
if media_url:
conn.execute(
"UPDATE diary SET media_url=? WHERE id=?",
(media_url, entry_id),
)
imported += 1
return {"imported": imported, "skipped": skipped, "errors": errors}
# ------------------------------------------------------------------
# POST /api/import/csv
# Spalten (Header-Zeile): datum, titel, text, tags, gps_lat, gps_lon, is_milestone
# Trenner: Komma oder Semikolon | Encoding: UTF-8 (mit oder ohne BOM)
# ------------------------------------------------------------------
@router.post("/csv")
async def import_csv(
dog_id: int = Form(...),
file: UploadFile = File(...),
user=Depends(get_current_user),
):
if not file.filename.lower().endswith(".csv"):
raise HTTPException(400, "Bitte eine .csv-Datei hochladen.")
raw = await file.read()
if len(raw) > MAX_CSV_MB * 1024 * 1024:
raise HTTPException(413, f"Datei zu groß (max {MAX_CSV_MB} MB).")
with db() as conn:
dog = conn.execute(
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])
).fetchone()
if not dog:
raise HTTPException(404, "Hund nicht gefunden.")
text = raw.decode("utf-8-sig") # utf-8-sig entfernt BOM falls vorhanden
# Semikolon oder Komma erkennen
first_line = text.split("\n")[0]
delimiter = ";" if first_line.count(";") >= first_line.count(",") else ","
reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
# Normalisierte Header (lowercase, strip)
reader.fieldnames = [h.strip().lower() for h in (reader.fieldnames or [])]
REQUIRED = {"datum", "titel", "text"}
if not REQUIRED.issubset(set(reader.fieldnames)):
missing = REQUIRED - set(reader.fieldnames)
raise HTTPException(
400,
f"CSV fehlen Pflicht-Spalten: {', '.join(sorted(missing))}. "
"Erwartet: datum, titel, text (+ optional: tags, gps_lat, gps_lon, is_milestone)"
)
imported = 0
skipped = 0
errors = []
with db() as conn:
for i, row in enumerate(reader, start=2):
try:
datum = (row.get("datum") or "").strip()
if not datum:
skipped += 1
continue
# Datum normalisieren: DD.MM.YYYY → YYYY-MM-DD
if re.match(r"^\d{1,2}\.\d{1,2}\.\d{4}$", datum):
d, m, y = datum.split(".")
datum = f"{y}-{int(m):02d}-{int(d):02d}"
titel = (row.get("titel") or "").strip() or None
text = (row.get("text") or "").strip() or None
raw_tags = (row.get("tags") or "").strip()
if raw_tags.startswith("["):
tags = json.loads(raw_tags)
else:
tags = [t.strip() for t in re.split(r"[;,]", raw_tags) if t.strip()]
lat = None
lon = None
try:
lat_s = (row.get("gps_lat") or "").strip().replace(",", ".")
lon_s = (row.get("gps_lon") or "").strip().replace(",", ".")
if lat_s:
lat = float(lat_s)
if lon_s:
lon = float(lon_s)
except ValueError:
pass
is_milestone = (row.get("is_milestone") or "").strip().lower() in (
"1", "true", "ja", "yes"
)
conn.execute(
"""INSERT INTO diary (dog_id, datum, typ, titel, text, tags, gps_lat, gps_lon, is_milestone)
VALUES (?, ?, 'eintrag', ?, ?, ?, ?, ?, ?)""",
(dog_id, datum, titel, text, json.dumps(tags), lat, lon, int(is_milestone)),
)
entry_id = conn.execute(
"SELECT last_insert_rowid() AS id"
).fetchone()["id"]
conn.execute(
"INSERT OR IGNORE INTO diary_dogs (diary_id, dog_id) VALUES (?,?)",
(entry_id, dog_id),
)
imported += 1
except Exception as e:
errors.append(f"Zeile {i}: {e}")
return {"imported": imported, "skipped": skipped, "errors": errors}