banyaro/backend/routes/import_data.py
rene 553e9e7854 Sprint 12+13: Tagebuch Day-One-Redesign, Notiz-Feature, Icon-Fixes, SW by-v405
Tagebuch:
- Day-One-Listenansicht: Wochentag + Tageszahl + Meta-Zeile (Zeit/Ort/Wetter)
- 4 Ansichten: Liste, Medien-Mosaik, Kalender (mit Sprungbuttons), Karte (GPS-Marker)
- Detail-Ansicht inline im Content-Bereich (kein Fullscreen-Overlay mehr)
- Hero-Bild vollständig sichtbar (object-fit:contain), Lightbox mit Safe-Area
- 2-Spalten-Layout Desktop: Text + Leaflet-Karte + POI-Liste
- EXIF-GPS-Extraktion bei Foto-Upload, historisches Wetter via Archive-API
- NoteStation-Import: Fotos in diary_media (80 Einträge migriert, 94 Medien)
- Stats-Endpoints: /diary/stats, /diary/calendar, /diary/locations

Notiz-Feature:
- Generische notes-Tabelle (parent_type + parent_id + meta_json)
- 📝-Button in 8 Bereichen, Notizblock-Seite mit KI-Analyse
- KI-Toggle in Einstellungen, notes_ki_enabled in User-Profil

Icons & Design:
- fill:currentColor Fix für welcome/onboarding/friends.js
- --c-icon Variable, --c-text-muted Dark Mode aufgehellt
- 15+ neue Phosphor-Icons aus lokaler Kopie
- CSS Network-First im SW, Cache-Control-Middleware

Infrastruktur:
- Wiki-Anreicherungs-Scheduler-Jobs entfernt (abgeschlossen)
- auth.py: notes_ki_enabled + is_social_media im User-Response
2026-04-25 20:44:46 +02:00

280 lines
10 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),
)
# Anhänge in diary_media speichern (statt veraltetem media_url-Feld)
attachments = note.get("attachment") or {}
first = True
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:
conn.execute(
"INSERT INTO diary_media (diary_id, url, media_type, sort_order, is_cover) VALUES (?,?,?,?,?)",
(entry_id, media_url, "image", 0 if first else 1, 1 if first else 0),
)
first = False
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}