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
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""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}
|