diff --git a/backend/main.py b/backend/main.py index 2b2b35c..4a6fe82 100644 --- a/backend/main.py +++ b/backend/main.py @@ -70,9 +70,10 @@ from routes.wiki import router as wiki_router from routes.movies import router as movies_router from routes.friends import router as friends_router from routes.chat import router as chat_router -from routes.admin import router as admin_router -from routes.webcal import router as webcal_router -from routes.profile import router as profile_router +from routes.admin import router as admin_router +from routes.webcal import router as webcal_router +from routes.profile import router as profile_router +from routes.import_data import router as import_router app.include_router(auth_router, prefix="/api/auth", tags=["Auth"]) app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"]) @@ -98,6 +99,7 @@ app.include_router(chat_router, prefix="/api/chat", tags=["Chat"]) app.include_router(admin_router, prefix="/api/admin", tags=["Admin"]) app.include_router(webcal_router, prefix="/api/webcal", tags=["WebCal"]) app.include_router(profile_router, prefix="/api/profile", tags=["Profil"]) +app.include_router(import_router, prefix="/api/import", tags=["Import"]) # ------------------------------------------------------------------ diff --git a/backend/routes/import_data.py b/backend/routes/import_data.py new file mode 100644 index 0000000..affa418 --- /dev/null +++ b/backend/routes/import_data.py @@ -0,0 +1,270 @@ +"""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): + def __init__(self): + super().__init__() + self.parts = [] + + def handle_data(self, data): + 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"]*>", "\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} diff --git a/backend/static/css/components.css b/backend/static/css/components.css index b9ae47e..648f647 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -224,6 +224,40 @@ flex-shrink: 0; } +/* Health: Transponder-Zeile */ +.health-transponder { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-4); + background: var(--c-surface); + border-bottom: 1px solid var(--c-border); + font-size: var(--text-sm); + color: var(--c-text-secondary); + flex-shrink: 0; +} +.health-transponder-label { color: var(--c-text-muted); } +.health-transponder-edit { margin-left: auto; } + +/* Import: Format-Auswahl-Karten */ +.import-format-card { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 2px solid var(--c-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); +} +.import-format-card:hover { border-color: var(--c-primary-light); } +.import-format-card--active { border-color: var(--c-primary); background: var(--c-primary-subtle); } +.import-format-icon { + font-size: 1.5rem; + color: var(--c-primary); + flex-shrink: 0; +} + /* ------------------------------------------------------------ 5. BADGES & STATUS-PILLS ------------------------------------------------------------ */ diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 51877d4..9ee07d3 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -408,6 +408,21 @@ const API = (() => { resetToken: () => del('/webcal/token'), }; + const importData = { + notestation(dogId, file) { + const fd = new FormData(); + fd.append('dog_id', dogId); + fd.append('file', file); + return upload('/import/notestation', fd); + }, + csv(dogId, file) { + const fd = new FormData(); + fd.append('dog_id', dogId); + fd.append('file', file); + return upload('/import/csv', fd); + }, + }; + // ---------------------------------------------------------- // ERROR-KLASSE // ---------------------------------------------------------- @@ -425,7 +440,7 @@ const API = (() => { get, post, put, patch, del, upload, auth, dogs, diary, health, tieraerzte, poison, places, routes, walks, events, sitting, forum, lost, knigge, weather, push, - friends, chat, webcal, + friends, chat, webcal, importData, subscribeToPush, getLocation, APIError, }; diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index 3454c50..42b8aa8 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -135,12 +135,20 @@ window.Page_diary = (() => { // ---------------------------------------------------------- async function _renderDiary() { _container.innerHTML = ` +
+ +
`; + _container.querySelector('#diary-import-btn') + ?.addEventListener('click', _showImport); _container.querySelector('#diary-btn-more') ?.addEventListener('click', () => _loadMore()); @@ -547,6 +555,125 @@ window.Page_diary = (() => { .replace(/"/g, '"'); } + // ---------------------------------------------------------- + // IMPORT + // ---------------------------------------------------------- + function _showImport() { + UI.modal.open({ + title: 'Tagebuch importieren', + body: ` +

+ Importiere Einträge aus einer anderen App in das Tagebuch von + ${_escape(_appState.activeDog?.name || 'deinem Hund')}. +

+ +
+ + + + +
+ +
+ + +
+ + `, + + footer: ` + + `, + }); + + // Format-Karten klickbar machen + document.querySelectorAll('.import-format-card').forEach(card => { + card.addEventListener('click', () => { + document.querySelectorAll('.import-format-card').forEach(c => c.classList.remove('import-format-card--active')); + card.classList.add('import-format-card--active'); + card.querySelector('input[type=radio]').checked = true; + // Accept-Attribut anpassen + const fmt = card.querySelector('input').value; + document.getElementById('import-file-input').accept = fmt === 'nsx' ? '.nsx' : '.csv'; + }); + }); + // Erste Karte direkt aktiv setzen + document.getElementById('fmt-nsx')?.classList.add('import-format-card--active'); + + document.getElementById('import-start-btn').addEventListener('click', async () => { + const fileInput = document.getElementById('import-file-input'); + const fmt = document.querySelector('input[name="import-fmt"]:checked')?.value; + const btn = document.getElementById('import-start-btn'); + const resultEl = document.getElementById('import-result'); + + if (!fileInput.files.length) { + UI.toast('Bitte zuerst eine Datei auswählen.', 'warning'); + return; + } + const file = fileInput.files[0]; + const dogId = _appState.activeDog?.id; + + UI.setLoading(btn, true); + resultEl.style.display = 'none'; + + try { + const res = fmt === 'nsx' + ? await API.importData.notestation(dogId, file) + : await API.importData.csv(dogId, file); + + const errHtml = res.errors?.length + ? `
${res.errors.length} Fehler anzeigen +
${_escape(res.errors.join('\n'))}
` + : ''; + + resultEl.innerHTML = ` +
+ ${res.imported} Einträge importiert + ${res.skipped ? ` · ${res.skipped} übersprungen` : ''} + ${errHtml} +
`; + resultEl.style.display = 'block'; + UI.setLoading(btn, false); + + // Diary neu laden falls etwas importiert wurde + if (res.imported > 0) { + _offset = 0; + _entries = []; + await _load(); + _renderList(); + } + } catch (e) { + resultEl.innerHTML = ` +
+ Fehler: ${_escape(e.message || String(e))} +
`; + resultEl.style.display = 'block'; + UI.setLoading(btn, false); + } + }); + } + // ---------------------------------------------------------- // PUBLIC // ---------------------------------------------------------- diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 701b1fb..6e321b4 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -125,12 +125,26 @@ window.Page_health = (() => { // HEALTH-ANSICHT — Tabs mit Einträgen // ---------------------------------------------------------- async function _renderHealth() { + const dog = _appState.activeDog; + const transponderHtml = ` +
+ + Transponder: + + ${dog?.chip_nr ? `${_esc(dog.chip_nr)}` : 'nicht eingetragen'} + + +
`; _container.innerHTML = `
+ ${transponderHtml}
@@ -139,6 +153,8 @@ window.Page_health = (() => { _renderTabBar(); _container.querySelector('#health-ki-btn') .addEventListener('click', _showKiSummary); + _container.querySelector('#health-transponder-edit') + .addEventListener('click', () => _editTransponder(dog)); await _loadAll(); _renderErinnerungen(); @@ -1519,6 +1535,42 @@ window.Page_health = (() => { }); } + // ---------------------------------------------------------- + // TRANSPONDER-BEARBEITUNG + // ---------------------------------------------------------- + async function _editTransponder(dog) { + const currentNr = dog?.chip_nr || ''; + UI.modal.open({ + title: 'Transpondernummer', + body: ` +
+ + +
`, + footer: ` + + `, + }); + document.getElementById('transponder-save-btn').addEventListener('click', async () => { + const nr = document.getElementById('transponder-input').value.trim() || null; + const btn = document.getElementById('transponder-save-btn'); + UI.setLoading(btn, true); + try { + await API.dogs.update(dog.id, { chip_nr: nr }); + _appState.activeDog.chip_nr = nr; + UI.modal.close(); + const nrEl = _container.querySelector('#health-transponder-nr'); + if (nrEl) nrEl.innerHTML = nr + ? `${_esc(nr)}` + : 'nicht eingetragen'; + } catch (e) { + UI.setLoading(btn, false); + UI.toast('Fehler beim Speichern', 'error'); + } + }); + } + // ---------------------------------------------------------- // KI-ZUSAMMENFASSUNG // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index 5f08bc8..223defb 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v141'; +const CACHE_VERSION = 'by-v142'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten