Guest User

Declutter App (V1.1)

a guest
Mar 26th, 2026
36
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 129.21 KB | Software | 0 0
  1. #!/usr/bin/env python3
  2. import os
  3. import sys
  4. import shutil
  5. import random
  6. import json
  7. from pathlib import Path
  8. from datetime import datetime, timedelta
  9.  
  10. # DECLUTTER APP VERSION 1.1
  11.  
  12. # ── Defaults (overschrijfbaar via .env) ───────────────────────────────────────
  13.  
  14. BASE_DIR       = Path("./test-nas")
  15. ARCHIEF_DIR    = "verwerkt"
  16. UITZOEKEN_DIR  = "in_behandeling"
  17. DUMP_DIR       = "ruwe_data"
  18. PRULLENBAK_DIR = "prullenbak"
  19. DATUMLOOS_DIR  = "datumloos"
  20. PORT           = 8765
  21. IMMICH_URL     = "http://localhost:2283"
  22. IMMICH_API_KEY = ""
  23. DEBUG          = False
  24. PRESORT_DEBUG  = False
  25.  
  26. RECENTS_FILE      = Path(__file__).parent / "recents.json"
  27. THUMBCACHE_DIR    = Path(__file__).parent / ".thumbcache"
  28. DATUMCACHE_FILE   = Path(__file__).parent / ".datumcache.json"
  29. LOG_FILE          = Path(__file__).parent / "declutter.log"
  30.  
  31. # ── Logging ───────────────────────────────────────────────────────────────────
  32.  
  33. def _log(msg: str):
  34.     """Schrijft naar stdout én logbestand met tijdstempel."""
  35.     ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
  36.     line = f"{ts}  {msg}"
  37.     print(line)
  38.     try:
  39.         with LOG_FILE.open("a", encoding="utf-8") as f:
  40.             f.write(line + "\n")
  41.     except Exception:
  42.         pass
  43.  
  44. # ── .env loader ───────────────────────────────────────────────────────────────
  45.  
  46. def _laad_env_bestand(pad: Path) -> dict:
  47.     """Leest een .env bestand, geeft {key: value}. Negeert # en lege regels."""
  48.     result = {}
  49.     try:
  50.         for regel in pad.read_text(encoding="utf-8").splitlines():
  51.             regel = regel.strip()
  52.             if not regel or regel.startswith("#"):
  53.                 continue
  54.             if "=" in regel:
  55.                 key, _, val = regel.partition("=")
  56.                 key, val = key.strip(), val.strip()
  57.                 if key and val:
  58.                     result[key] = val
  59.     except FileNotFoundError:
  60.         pass
  61.     return result
  62.  
  63. def _toepas_env(cfg: dict):
  64.     global BASE_DIR, ARCHIEF_DIR, UITZOEKEN_DIR, DUMP_DIR, PRULLENBAK_DIR, DATUMLOOS_DIR, PORT
  65.     global IMMICH_URL, IMMICH_API_KEY, DEBUG, PRESORT_DEBUG
  66.     if "BASE_DIR"        in cfg: BASE_DIR        = Path(cfg["BASE_DIR"])
  67.     if "ARCHIEF_DIR"     in cfg: ARCHIEF_DIR     = cfg["ARCHIEF_DIR"]
  68.     if "UITZOEKEN_DIR"   in cfg: UITZOEKEN_DIR   = cfg["UITZOEKEN_DIR"]
  69.     if "DUMP_DIR"        in cfg: DUMP_DIR        = cfg["DUMP_DIR"]
  70.     if "PRULLENBAK_DIR"  in cfg: PRULLENBAK_DIR  = cfg["PRULLENBAK_DIR"]
  71.     if "DATUMLOOS_DIR"   in cfg: DATUMLOOS_DIR   = cfg["DATUMLOOS_DIR"]
  72.     if "PORT"           in cfg:
  73.         try: PORT = int(cfg["PORT"])
  74.         except ValueError: pass
  75.     if "IMMICH_URL"     in cfg: IMMICH_URL     = cfg["IMMICH_URL"]
  76.     if "IMMICH_API_KEY" in cfg: IMMICH_API_KEY = cfg["IMMICH_API_KEY"]
  77.     if "DEBUG"          in cfg: DEBUG          = cfg["DEBUG"].lower() in ("1", "true", "yes")
  78.     if "PRESORT_DEBUG"  in cfg: PRESORT_DEBUG  = cfg["PRESORT_DEBUG"].lower() in ("1", "true", "yes")
  79.  
  80. # Laad .env bij import (voor normale modus)
  81. _toepas_env(_laad_env_bestand(Path(__file__).parent / ".env"))
  82.  
  83. # ── Dependency check ──────────────────────────────────────────────────────────
  84.  
  85. def check_dependencies():
  86.     ok = True
  87.     if sys.version_info < (3, 8):
  88.         print(f"[FOUT] Python 3.8+ vereist (gevonden: {sys.version})")
  89.         ok = False
  90.     try:
  91.         import PIL  # noqa: F401
  92.     except ImportError:
  93.         print("[FOUT] Pillow niet gevonden. Installeer via: pip install Pillow")
  94.         ok = False
  95.     try:
  96.         import pillow_heif  # noqa: F401
  97.     except ImportError:
  98.         print("[WAARSCHUWING] pillow-heif niet gevonden. HEIC-bestanden worden niet ondersteund. Installeer via: pip install pillow-heif")
  99.     try:
  100.         import rawpy  # noqa: F401
  101.     except ImportError:
  102.         print("[WAARSCHUWING] rawpy niet gevonden. ARW/RAW-thumbnails niet beschikbaar. Installeer via: pip install rawpy")
  103.     if not ok:
  104.         sys.exit(1)
  105.  
  106. # ── Stap 1: Testdata ──────────────────────────────────────────────────────────
  107.  
  108. def maak_testdata(aantal=40, progress_cb=None):
  109.     import urllib.request
  110.     if BASE_DIR.exists():
  111.         shutil.rmtree(BASE_DIR)
  112.  
  113.     for m in [BASE_DIR / DUMP_DIR, BASE_DIR / UITZOEKEN_DIR, BASE_DIR / ARCHIEF_DIR, BASE_DIR / DATUMLOOS_DIR]:
  114.         m.mkdir(parents=True, exist_ok=True)
  115.  
  116.     dump = BASE_DIR / DUMP_DIR
  117.     foto_paden = []
  118.     for n in range(1, aantal + 1):
  119.         pad = dump / f"foto_{n:03d}.jpg"
  120.         url = f"https://picsum.photos/800/600?random={n}"
  121.         _log(f"  Download {n}/{aantal}: {url}")
  122.         if progress_cb:
  123.             progress_cb(n, aantal, f"Foto {n} van {aantal} downloaden…")
  124.         urllib.request.urlretrieve(url, str(pad))
  125.         foto_paden.append(pad)
  126.  
  127.         ts_start = datetime(2019, 1, 1).timestamp()
  128.         ts_eind  = datetime(2025, 12, 31).timestamp()
  129.         t = random.uniform(ts_start, ts_eind)
  130.         os.utime(pad, (t, t))
  131.  
  132.     # Duplicaten (±10% van aantal, minimaal 2)
  133.     n_dup = max(2, aantal // 10)
  134.     for i in range(min(n_dup, len(foto_paden))):
  135.         shutil.copy2(foto_paden[i], dump / f"dup_{i+1:03d}.jpg")
  136.  
  137.     # 3 naamconflicten (als er genoeg fotos zijn)
  138.     if len(foto_paden) > 12:
  139.         for j in range(3):
  140.             shutil.copy2(foto_paden[10], dump / f"IMG_000{j+1}.jpg")
  141.  
  142.     _log(f"Testdata aangemaakt: {aantal} foto's in {BASE_DIR}/")
  143.  
  144. # ── Stap 2: Pre-sorteren ──────────────────────────────────────────────────────
  145.  
  146. def presort(debug=False, progress_cb=None):
  147.     import time
  148.     bronmappen = [BASE_DIR / DUMP_DIR]
  149.     uitzoeken = BASE_DIR / UITZOEKEN_DIR
  150.  
  151.     alle_fotos = []
  152.     for bron in bronmappen:
  153.         if bron.exists():
  154.             alle_fotos.extend(zoek_fotos_recursief(bron))
  155.     totaal = len(alle_fotos)
  156.  
  157.     if totaal == 0:
  158.         _log("Presort: geen foto's gevonden.")
  159.         if progress_cb:
  160.             progress_cb(0, 0, "Geen foto's gevonden.")
  161.         return
  162.  
  163.     balk_breedte = 20
  164.     verplaatst = 0
  165.     overgeslagen = 0
  166.     start = time.time()
  167.  
  168.     for i, foto in enumerate(alle_fotos, 1):
  169.         datum = lees_datum(foto, debug=debug)
  170.         doel_map = uitzoeken / datum.strftime("%Y-%m")
  171.         doel_map.mkdir(parents=True, exist_ok=True)
  172.         doel = doel_map / foto.name
  173.         was_conflict = doel.exists()
  174.         teller = 2
  175.         while doel.exists():
  176.             doel = doel_map / f"{foto.stem}_{teller}{foto.suffix}"
  177.             teller += 1
  178.         if was_conflict:
  179.             overgeslagen += 1
  180.         shutil.move(str(foto), doel)
  181.         verplaatst += 1
  182.  
  183.         label = f"{doel.parent.name}/{doel.name}"
  184.         if progress_cb:
  185.             progress_cb(i, totaal, label)
  186.         else:
  187.             pct = i / totaal
  188.             gevuld = int(pct * balk_breedte)
  189.             balk = "█" * gevuld + "░" * (balk_breedte - gevuld)
  190.             print(f"\r[{balk}] {i}/{totaal} ({pct:.0%}) — {label:<40}", end="", flush=True)
  191.  
  192.     duur = time.time() - start
  193.     if not progress_cb:
  194.         print(f"\n\n✓ {verplaatst} foto('s) verplaatst naar {uitzoeken}/")
  195.         print(f"  Naamconflicten hernoemd : {overgeslagen}")
  196.         print(f"  Tijd                    : {duur:.1f}s")
  197.     _log(f"[Presort] {verplaatst} verplaatst, {overgeslagen} hernoemd ({duur:.1f}s)")
  198.  
  199. # ── Bestandstypen ─────────────────────────────────────────────────────────────
  200.  
  201. FOTO_EXTS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.heic', '.heif', '.arw'}
  202.  
  203. def zoek_fotos(map_pad: Path):
  204.     """Alle ondersteunde fotobestanden in map_pad (case-insensitive), gesorteerd op mtime."""
  205.     bestanden = [
  206.         f for f in map_pad.iterdir()
  207.         if f.is_file() and f.suffix.lower() in FOTO_EXTS
  208.     ]
  209.     return sorted(bestanden, key=lambda f: f.stat().st_mtime)
  210.  
  211. def zoek_fotos_recursief(map_pad: Path):
  212.     """Alle ondersteunde fotobestanden recursief onder map_pad."""
  213.     return [
  214.         f for f in map_pad.rglob("*")
  215.         if f.is_file() and f.suffix.lower() in FOTO_EXTS
  216.     ]
  217.  
  218. # ── Datum lezen + cache ────────────────────────────────────────────────────────
  219.  
  220. _datumcache: dict = {}
  221.  
  222. def _laad_datumcache():
  223.     global _datumcache
  224.     try:
  225.         _datumcache = json.loads(DATUMCACHE_FILE.read_text())
  226.     except Exception:
  227.         _datumcache = {}
  228.  
  229. def _sla_datumcache_op():
  230.     try:
  231.         DATUMCACHE_FILE.write_text(json.dumps(_datumcache))
  232.     except Exception:
  233.         pass
  234.  
  235. _laad_datumcache()
  236.  
  237. def lees_datum(pad: Path, debug=False) -> datetime:
  238.     """Leest datum uit cache, dan EXIF, dan bestandsnaam, dan mtime."""
  239.     stat = pad.stat()
  240.     cache_key = f"{pad}|{int(stat.st_mtime)}|{stat.st_size}"
  241.     if cache_key in _datumcache:
  242.         try:
  243.             datum = datetime.strptime(_datumcache[cache_key][0], "%Y-%m-%d %H:%M:%S")
  244.             if debug:
  245.                 print(f"  [cache]     {pad.name} → {datum:%Y-%m-%d} ({_datumcache[cache_key][1]})")
  246.             return datum
  247.         except Exception:
  248.             pass
  249.     datum, bron = _lees_datum_intern(pad)
  250.     _datumcache[cache_key] = [datum.strftime("%Y-%m-%d %H:%M:%S"), bron]
  251.     _sla_datumcache_op()
  252.     if debug:
  253.         print(f"  [{bron:<12}] {pad.name} → {datum:%Y-%m-%d}")
  254.     return datum
  255.  
  256. def _lees_datum_intern(pad: Path):
  257.     """Geeft (datetime, bron_label). Volgorde: EXIF → bestandsnaam → mtime."""
  258.     import re
  259.     # 1-3: EXIF
  260.     try:
  261.         from PIL import Image
  262.         img = Image.open(pad)
  263.         exif = img.getexif()
  264.         labels = {36867: "EXIF-origineel", 36868: "EXIF-digitized", 306: "EXIF-datetime"}
  265.         for tag, label in labels.items():
  266.             raw = exif.get(tag)
  267.             if raw:
  268.                 try:
  269.                     return datetime.strptime(raw.strip(), "%Y:%m:%d %H:%M:%S"), label
  270.                 except ValueError:
  271.                     continue
  272.     except Exception:
  273.         pass
  274.  
  275.     # 4: Datum uit bestandsnaam
  276.     naam = pad.stem
  277.     patronen = [
  278.         (r"IMG_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})", "naam-android"),
  279.         (r"IMG-(\d{4})(\d{2})(\d{2})-WA",                     "naam-whatsapp"),
  280.         (r"(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})",      "naam-datetime"),
  281.         (r"(\d{4})-(\d{2})-(\d{2}) (\d{2})\.(\d{2})\.(\d{2})","naam-dots"),
  282.         (r"(\d{4})-(\d{2})-(\d{2})",                           "naam-datum"),
  283.         (r"(\d{4})_(\d{2})_(\d{2})",                           "naam-underscore"),
  284.     ]
  285.     for patroon, label in patronen:
  286.         m = re.search(patroon, naam)
  287.         if m:
  288.             g = m.groups()
  289.             try:
  290.                 if len(g) >= 6:
  291.                     return datetime(int(g[0]), int(g[1]), int(g[2]),
  292.                                     int(g[3]), int(g[4]), int(g[5])), label
  293.                 else:
  294.                     return datetime(int(g[0]), int(g[1]), int(g[2])), label
  295.             except ValueError:
  296.                 continue
  297.  
  298.     # 5: mtime
  299.     return datetime.fromtimestamp(pad.stat().st_mtime), "mtime"
  300.  
  301. # ── Recents ───────────────────────────────────────────────────────────────────
  302.  
  303. def laad_recents():
  304.     try:
  305.         return json.loads(RECENTS_FILE.read_text())
  306.     except Exception:
  307.         return []
  308.  
  309. def sla_recent_op(album):
  310.     recents = laad_recents()
  311.     if album in recents:
  312.         recents.remove(album)
  313.     recents.insert(0, album)
  314.     recents = recents[:10]
  315.     RECENTS_FILE.write_text(json.dumps(recents))
  316.     return recents
  317.  
  318. # ── Stap 3–5: Webapp ──────────────────────────────────────────────────────────
  319.  
  320. def start_server():
  321.     import base64
  322.     import json as _json
  323.     import socket
  324.     import threading
  325.     from http.server import BaseHTTPRequestHandler, HTTPServer, ThreadingHTTPServer
  326.     from urllib.parse import parse_qs, urlparse
  327.  
  328.     uitzoeken = BASE_DIR / UITZOEKEN_DIR
  329.     archief   = BASE_DIR / ARCHIEF_DIR
  330.  
  331.     try:
  332.         from PIL import Image
  333.         import io as _io
  334.         HAS_PILLOW = True
  335.     except ImportError:
  336.         HAS_PILLOW = False
  337.  
  338.     import warnings as _warnings
  339.     try:
  340.         with _warnings.catch_warnings():
  341.             _warnings.simplefilter("ignore")
  342.             import rawpy as _rawpy
  343.         HAS_RAWPY = True
  344.     except ImportError:
  345.         HAS_RAWPY = False
  346.  
  347.     thumbcache = THUMBCACHE_DIR
  348.     thumbcache.mkdir(parents=True, exist_ok=True)
  349.  
  350.     _geo_cache: dict = {}
  351.  
  352.     def _lees_gps(pad: Path):
  353.         if not HAS_PILLOW:
  354.             return None
  355.         try:
  356.             from PIL.ExifTags import TAGS, GPSTAGS
  357.             img = Image.open(pad)
  358.             try:
  359.                 exif_raw = img._getexif() or {}
  360.             except Exception:
  361.                 exif_raw = {}
  362.             gps_raw = None
  363.             for tag_id, val in exif_raw.items():
  364.                 if TAGS.get(tag_id) == 'GPSInfo':
  365.                     gps_raw = val
  366.                     break
  367.             if not gps_raw:
  368.                 return None
  369.             gps = {GPSTAGS.get(k, k): v for k, v in gps_raw.items()}
  370.             def _to_dec(coords, ref):
  371.                 d, m, s = [float(x) for x in coords]
  372.                 dd = d + m / 60 + s / 3600
  373.                 if ref in ('S', 'W'):
  374.                     dd = -dd
  375.                 return round(dd, 6)
  376.             lat = _to_dec(gps['GPSLatitude'], gps.get('GPSLatitudeRef', 'N'))
  377.             lng = _to_dec(gps['GPSLongitude'], gps.get('GPSLongitudeRef', 'E'))
  378.             return {'lat': lat, 'lng': lng}
  379.         except Exception:
  380.             return None
  381.  
  382.     def _reverse_geocode(lat: float, lng: float):
  383.         key = f"{lat:.3f},{lng:.3f}"
  384.         if key in _geo_cache:
  385.             return _geo_cache[key]
  386.         try:
  387.             import urllib.request as _ur, json as _j
  388.             url = f"https://nominatim.openstreetmap.org/reverse?lat={lat}&lon={lng}&format=json&accept-language=nl"
  389.             req = _ur.Request(url, headers={'User-Agent': 'Declutter/1.0'})
  390.             data = _j.loads(_ur.urlopen(req, timeout=4).read())
  391.             addr = data.get('address', {})
  392.             city = (addr.get('city') or addr.get('town') or
  393.                     addr.get('village') or addr.get('municipality') or '')
  394.             country = addr.get('country', '')
  395.             result = ', '.join(x for x in [city, country] if x) or None
  396.             _geo_cache[key] = result
  397.             return result
  398.         except Exception:
  399.             return None
  400.  
  401.     import queue as _queue
  402.     _sse_queues = []
  403.     _sse_lock = threading.Lock()
  404.  
  405.     def _push_sse(msg):
  406.         with _sse_lock:
  407.             dood = []
  408.             for q in _sse_queues:
  409.                 try: q.put_nowait(msg)
  410.                 except Exception: dood.append(q)
  411.             for q in dood:
  412.                 try: _sse_queues.remove(q)
  413.                 except ValueError: pass
  414.  
  415.     def _auto_presort(pad):
  416.         datum = lees_datum(pad)
  417.         doel_map = (BASE_DIR / UITZOEKEN_DIR) / datum.strftime("%Y-%m")
  418.         doel_map.mkdir(parents=True, exist_ok=True)
  419.         doel = doel_map / pad.name
  420.         teller = 2
  421.         while doel.exists():
  422.             doel = doel_map / f"{pad.stem}_{teller}{pad.suffix}"
  423.             teller += 1
  424.         shutil.move(str(pad), doel)
  425.         _log(f"[Watcher] {pad.name} \u2192 {doel.parent.name}/")
  426.         return doel
  427.  
  428.     _watcher_pauze = [False]  # mutable flag: True = watcher slaat scan over
  429.  
  430.     def _watcher():
  431.         import time, re as _re
  432.         bronmappen = [BASE_DIR / DUMP_DIR]
  433.         uits = BASE_DIR / UITZOEKEN_DIR
  434.         datumloos_pad = BASE_DIR / DATUMLOOS_DIR
  435.  
  436.         def scan():
  437.             gevonden = set()
  438.             for bron in bronmappen:
  439.                 if bron.exists():
  440.                     for f in bron.rglob("*"):
  441.                         if f.is_file() and f.suffix.lower() in FOTO_EXTS:
  442.                             gevonden.add(str(f))
  443.             if uits.exists():
  444.                 for sub in uits.iterdir():
  445.                     if not sub.is_dir(): continue
  446.                     if _re.match(r'^\d{4}-\d{2}$', sub.name):
  447.                         for f in sub.iterdir():
  448.                             if f.is_file() and f.suffix.lower() in FOTO_EXTS:
  449.                                 gevonden.add(str(f))
  450.                     else:
  451.                         for f in sub.rglob("*"):
  452.                             if f.is_file() and f.suffix.lower() in FOTO_EXTS:
  453.                                 gevonden.add(str(f))
  454.             return gevonden
  455.  
  456.         bekende = scan()
  457.         while True:
  458.             time.sleep(5)
  459.             if _watcher_pauze[0]:
  460.                 bekende = set()  # reset bekende zodat na pauze alles als nieuw geldt
  461.                 continue
  462.             try:
  463.                 huidige = scan()
  464.                 nieuw = huidige - bekende
  465.                 if nieuw:
  466.                     verplaatste = []
  467.                     for pad_str in sorted(nieuw):
  468.                         pad = Path(pad_str)
  469.                         # Datumloos-map nooit pre-sorten
  470.                         in_datumloos = datumloos_pad.exists() and pad.is_relative_to(datumloos_pad)
  471.                         moet_presorten = not in_datumloos and any(
  472.                             pad.is_relative_to(b) for b in bronmappen if b.exists()
  473.                         )
  474.                         if not moet_presorten and not in_datumloos and uits.exists():
  475.                             try:
  476.                                 if pad.is_relative_to(uits):
  477.                                     rel = pad.relative_to(uits)
  478.                                     top = rel.parts[0] if rel.parts else ''
  479.                                     if not _re.match(r'^\d{4}-\d{2}$', top):
  480.                                         moet_presorten = True
  481.                             except Exception:
  482.                                 pass
  483.                         if moet_presorten:
  484.                             try:
  485.                                 doel = _auto_presort(pad)
  486.                                 try:
  487.                                     verplaatste.append({
  488.                                         "naam": pad.name,
  489.                                         "van": str(pad.relative_to(BASE_DIR)),
  490.                                         "naar": str(doel.relative_to(BASE_DIR)),
  491.                                     })
  492.                                 except Exception:
  493.                                     pass
  494.                             except Exception as e:
  495.                                 _log(f"[Watcher] Presort fout {pad.name}: {e}")
  496.                         else:
  497.                             try:
  498.                                 verplaatste.append({
  499.                                     "naam": pad.name,
  500.                                     "van": None,
  501.                                     "naar": str(pad.relative_to(BASE_DIR)),
  502.                                 })
  503.                             except Exception:
  504.                                 pass
  505.                     bekende = scan()
  506.                     _push_sse(_json.dumps({"type": "update", "bestanden": verplaatste, "jm": jaren_maanden()}))
  507.                 else:
  508.                     bekende = huidige
  509.             except Exception as e:
  510.                 _log(f"[Watcher] Scanfout: {e}")
  511.  
  512.     threading.Thread(target=_watcher, daemon=True).start()
  513.  
  514.     def get_thumbnail(pad: Path) -> bytes:
  515.         stat = pad.stat()
  516.         cache_key = f"{pad.name}_{int(stat.st_mtime)}_{stat.st_size}.jpg"
  517.         cache_pad = thumbcache / cache_key
  518.         if cache_pad.exists():
  519.             return cache_pad.read_bytes()
  520.  
  521.         ext = pad.suffix.lower()
  522.  
  523.         # ARW (Sony RAW): probeer ingebedde JPEG-preview te extraheren
  524.         if ext == '.arw':
  525.             img = None
  526.             if HAS_RAWPY:
  527.                 try:
  528.                     import io as _io
  529.                     with _rawpy.imread(str(pad)) as raw:
  530.                         thumb = raw.extract_thumb()
  531.                     if thumb.format.name == 'JPEG':
  532.                         img = Image.open(_io.BytesIO(thumb.data))
  533.                     elif thumb.format.name == 'BITMAP':
  534.                         img = Image.fromarray(thumb.data)
  535.                 except Exception:
  536.                     pass
  537.             if img is None:
  538.                 data = _maak_placeholder(pad.name)
  539.                 cache_pad.write_bytes(data)
  540.                 return data
  541.             # Gevallen door naar Pillow resize hieronder
  542.  
  543.         # HEIC/HEIF: probeer via pillow-heif of pyheif
  544.         elif ext in ('.heic', '.heif'):
  545.             img = None
  546.             try:
  547.                 import pillow_heif
  548.                 pillow_heif.register_heif_opener()
  549.                 img = Image.open(pad)
  550.             except ImportError:
  551.                 pass
  552.             if img is None:
  553.                 try:
  554.                     import pyheif
  555.                     import io as _io
  556.                     heif = pyheif.read(pad)
  557.                     img = Image.frombytes(heif.mode, heif.size, heif.data)
  558.                 except ImportError:
  559.                     pass
  560.             if img is None:
  561.                 data = _maak_placeholder(pad.name)
  562.                 cache_pad.write_bytes(data)
  563.                 return data
  564.             # Gevallen door naar Pillow resize hieronder
  565.         else:
  566.             img = None  # gewone formaten: Pillow opent ze zelf hieronder
  567.  
  568.         if HAS_PILLOW:
  569.             try:
  570.                 import io as _io
  571.                 if img is None:
  572.                     img = Image.open(pad)
  573.                 from PIL import ImageOps
  574.                 img = ImageOps.exif_transpose(img)
  575.                 img = img.convert("RGB")
  576.                 img.thumbnail((400, 400))
  577.                 buf = _io.BytesIO()
  578.                 img.save(buf, format="JPEG", quality=70)
  579.                 data = buf.getvalue()
  580.                 cache_pad.write_bytes(data)
  581.                 return data
  582.             except Exception:
  583.                 pass
  584.  
  585.         # Fallback: ruwe bytes (alleen zinvol voor echte JPEG)
  586.         data = pad.read_bytes()
  587.         cache_pad.write_bytes(data)
  588.         return data
  589.  
  590.     def _maak_placeholder(naam: str) -> bytes:
  591.         """Grijs placeholder JPEG met bestandsextensie als label."""
  592.         import io as _io
  593.         ext = Path(naam).suffix.upper().lstrip('.')
  594.         if HAS_PILLOW:
  595.             try:
  596.                 from PIL import ImageDraw, ImageFont
  597.                 img = Image.new("RGB", (200, 200), color=(60, 60, 60))
  598.                 draw = ImageDraw.Draw(img)
  599.                 draw.text((100, 90), ext, fill=(180, 180, 180), anchor="mm")
  600.                 draw.text((100, 115), naam[:20], fill=(120, 120, 120), anchor="mm")
  601.                 buf = _io.BytesIO()
  602.                 img.save(buf, format="JPEG", quality=70)
  603.                 return buf.getvalue()
  604.             except Exception:
  605.                 pass
  606.         # Ultra-minimaal grijs JPEG (1x1 pixel, grijs)
  607.         return bytes([
  608.             0xFF,0xD8,0xFF,0xE0,0x00,0x10,0x4A,0x46,0x49,0x46,0x00,0x01,
  609.             0x01,0x00,0x00,0x01,0x00,0x01,0x00,0x00,0xFF,0xDB,0x00,0x43,
  610.             0x00,0x08,0x06,0x06,0x07,0x06,0x05,0x08,0x07,0x07,0x07,0x09,
  611.             0x09,0x08,0x0A,0x0C,0x14,0x0D,0x0C,0x0B,0x0B,0x0C,0x19,0x12,
  612.             0x13,0x0F,0x14,0x1D,0x1A,0x1F,0x1E,0x1D,0x1A,0x1C,0x1C,0x20,
  613.             0x24,0x2E,0x27,0x20,0x22,0x2C,0x23,0x1C,0x1C,0x28,0x37,0x29,
  614.             0x2C,0x30,0x31,0x34,0x34,0x34,0x1F,0x27,0x39,0x3D,0x38,0x32,
  615.             0x3C,0x2E,0x33,0x34,0x32,0xFF,0xC0,0x00,0x0B,0x08,0x00,0x01,
  616.             0x00,0x01,0x01,0x01,0x11,0x00,0xFF,0xC4,0x00,0x1F,0x00,0x00,
  617.             0x01,0x05,0x01,0x01,0x01,0x01,0x01,0x01,0x00,0x00,0x00,0x00,
  618.             0x00,0x00,0x00,0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,
  619.             0x09,0x0A,0x0B,0xFF,0xC4,0x00,0xB5,0x10,0x00,0x02,0x01,0x03,
  620.             0x03,0x02,0x04,0x03,0x05,0x05,0x04,0x04,0x00,0x00,0x01,0x7D,
  621.             0x01,0x02,0x03,0x00,0x04,0x11,0x05,0x12,0x21,0x31,0x41,0x06,
  622.             0x13,0x51,0x61,0x07,0x22,0x71,0x14,0x32,0x81,0x91,0xA1,0x08,
  623.             0x23,0x42,0xB1,0xC1,0x15,0x52,0xD1,0xF0,0x24,0x33,0x62,0x72,
  624.             0x82,0x09,0x0A,0x16,0x17,0x18,0x19,0x1A,0x25,0x26,0x27,0x28,
  625.             0x29,0x2A,0x34,0x35,0x36,0x37,0x38,0x39,0x3A,0x43,0x44,0x45,
  626.             0x46,0x47,0x48,0x49,0x4A,0x53,0x54,0x55,0x56,0x57,0x58,0x59,
  627.             0x5A,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6A,0x73,0x74,0x75,
  628.             0x76,0x77,0x78,0x79,0x7A,0x83,0x84,0x85,0x86,0x87,0x88,0x89,
  629.             0x8A,0x92,0x93,0x94,0x95,0x96,0x97,0x98,0x99,0x9A,0xA2,0xA3,
  630.             0xA4,0xA5,0xA6,0xA7,0xA8,0xA9,0xAA,0xB2,0xB3,0xB4,0xB5,0xB6,
  631.             0xB7,0xB8,0xB9,0xBA,0xC2,0xC3,0xC4,0xC5,0xC6,0xC7,0xC8,0xC9,
  632.             0xCA,0xD2,0xD3,0xD4,0xD5,0xD6,0xD7,0xD8,0xD9,0xDA,0xE1,0xE2,
  633.             0xE3,0xE4,0xE5,0xE6,0xE7,0xE8,0xE9,0xEA,0xF1,0xF2,0xF3,0xF4,
  634.             0xF5,0xF6,0xF7,0xF8,0xF9,0xFA,0xFF,0xDA,0x00,0x08,0x01,0x01,
  635.             0x00,0x00,0x3F,0x00,0xFB,0xD3,0xFF,0xD9
  636.         ])
  637.  
  638.     def jaren_maanden():
  639.         """Geeft dict {jaar: [{naam, aantal}, ...]} van aanwezige submappen."""
  640.         result = {}
  641.         if not uitzoeken.exists():
  642.             return result
  643.         for submap in sorted(uitzoeken.iterdir()):
  644.             if not submap.is_dir():
  645.                 continue
  646.             try:
  647.                 jaar, _ = submap.name.split("-")
  648.                 n = sum(1 for f in submap.iterdir()
  649.                         if f.is_file() and f.suffix.lower() in FOTO_EXTS)
  650.                 result.setdefault(jaar, []).append({"naam": submap.name, "aantal": n})
  651.             except ValueError:
  652.                 pass
  653.         return result
  654.  
  655.     # Systeem-/NAS-bestanden die een "lege" map toch niet-leeg maken
  656.     _SYS_NAMEN = {'desktop.ini', 'thumbs.db', '.ds_store', '@eadir', '#recycle'}
  657.  
  658.     def _heeft_echte_inhoud(pad):
  659.         """True als pad echte bestanden/mappen bevat (geen NAS/OS-systeembestanden)."""
  660.         for f in pad.iterdir():
  661.             if f.name.startswith('.') or f.name.startswith('@'):
  662.                 continue
  663.             if f.name.lower() in _SYS_NAMEN:
  664.                 continue
  665.             return True
  666.         return False
  667.  
  668.     def verwijder_lege_mappen():
  669.         """Verwijdert lege YYYY-MM submappen uit in_behandeling."""
  670.         import re
  671.         if uitzoeken.exists():
  672.             for sub in list(uitzoeken.iterdir()):
  673.                 if sub.is_dir() and re.match(r'^\d{4}-\d{2}$', sub.name):
  674.                     if not _heeft_echte_inhoud(sub):
  675.                         try:
  676.                             shutil.rmtree(sub)
  677.                             _log(f"[Opruimen] Lege map verwijderd: {sub.name}")
  678.                         except OSError:
  679.                             pass
  680.  
  681.     def fotos_in_map(maand_key):
  682.         """Geeft lijst van foto-paden gesorteerd op mtime."""
  683.         pad = uitzoeken / maand_key
  684.         if not pad.exists():
  685.             return []
  686.         return zoek_fotos(pad)
  687.  
  688.     def burst_groepen(fotos):
  689.         """Groepeert foto's. Geeft lijst van dicts met 'fotos' en 'type'.
  690.        type: 'tight' (≤30s), 'moment' (31-300s), 'los' (>300s of enkelvoudig)."""
  691.         if not fotos:
  692.             return []
  693.         # Groepeer op 300s venster
  694.         ruw = []
  695.         huidige = [fotos[0]]
  696.         for foto in fotos[1:]:
  697.             dt = abs(foto.stat().st_mtime - huidige[-1].stat().st_mtime)
  698.             if dt <= 300:
  699.                 huidige.append(foto)
  700.             else:
  701.                 ruw.append(huidige)
  702.                 huidige = [foto]
  703.         ruw.append(huidige)
  704.         # Bepaal type per groep
  705.         result = []
  706.         for groep in ruw:
  707.             if len(groep) < 2:
  708.                 type_ = "los"
  709.                 span_sec = 0
  710.             else:
  711.                 mtimes = [f.stat().st_mtime for f in groep]
  712.                 max_gap = max(abs(mtimes[i+1] - mtimes[i]) for i in range(len(mtimes)-1))
  713.                 type_ = "tight" if max_gap <= 30 else "moment"
  714.                 span_sec = int(max(mtimes) - min(mtimes))
  715.             result.append({"fotos": groep, "type": type_, "span_sec": span_sec})
  716.         return result
  717.  
  718.     def uniek_doel(doel_map: Path, naam: str) -> Path:
  719.         doel = doel_map / naam
  720.         teller = 2
  721.         stem = Path(naam).stem
  722.         suffix = Path(naam).suffix
  723.         while doel.exists():
  724.             doel = doel_map / f"{stem}_{teller}{suffix}"
  725.             teller += 1
  726.         return doel
  727.  
  728.     _immich_timer    = None
  729.     _immich_lib_id   = None   # gecached library ID
  730.  
  731.     def _immich_haal_lib_id():
  732.         """Haalt het eerste library ID op via GET /api/libraries. Cachet in geheugen."""
  733.         nonlocal _immich_lib_id
  734.         if _immich_lib_id:
  735.             return _immich_lib_id
  736.         try:
  737.             import urllib.request, json as _j
  738.             req = urllib.request.Request(
  739.                 f"{IMMICH_URL.rstrip('/')}/api/libraries",
  740.                 headers={"x-api-key": IMMICH_API_KEY} if IMMICH_API_KEY else {},
  741.             )
  742.             with urllib.request.urlopen(req, timeout=5) as resp:
  743.                 libs = _j.loads(resp.read())
  744.             if libs:
  745.                 _immich_lib_id = libs[0]["id"]
  746.                 return _immich_lib_id
  747.         except Exception as e:
  748.             _log(f"[Immich] Library ID ophalen mislukt: {e}")
  749.         return None
  750.  
  751.     def _doe_rescan():
  752.         lib_id = _immich_haal_lib_id()
  753.         if not lib_id:
  754.             _log("[Immich] Geen library ID — rescan overgeslagen.")
  755.             return
  756.         try:
  757.             import urllib.request
  758.             req = urllib.request.Request(
  759.                 f"{IMMICH_URL.rstrip('/')}/api/libraries/{lib_id}/scan",
  760.                 data=b"{}",
  761.                 method="POST",
  762.                 headers={
  763.                     "Content-Type": "application/json",
  764.                     **({"x-api-key": IMMICH_API_KEY} if IMMICH_API_KEY else {}),
  765.                 },
  766.             )
  767.             urllib.request.urlopen(req, timeout=10)
  768.             _log("[Immich] Rescan getriggerd.")
  769.         except Exception as e:
  770.             _log(f"[Immich] Rescan mislukt: {e}")
  771.  
  772.     def immich_rescan():
  773.         nonlocal _immich_timer
  774.         if not IMMICH_URL:
  775.             return
  776.         if _immich_timer is not None:
  777.             _immich_timer.cancel()
  778.         _log("[Immich] Rescan gepland over 3600s...")
  779.         _immich_timer = threading.Timer(3600, _doe_rescan)
  780.         _immich_timer.daemon = True
  781.         _immich_timer.start()
  782.  
  783.     def render_hoofdpagina():
  784.  
  785.         return f"""<!DOCTYPE html>
  786. <html lang="nl">
  787. <head>
  788. <meta charset="utf-8">
  789. <meta name="viewport" content="width=device-width,initial-scale=1">
  790. <title>Declutter</title>
  791. <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%231a1a1a'/%3E%3Crect x='3' y='10' width='26' height='17' rx='3' fill='%232a6a9a'/%3E%3Cpath d='M11 10 L13 6 H19 L21 10Z' fill='%232a6a9a'/%3E%3Ccircle cx='16' cy='18.5' r='5' fill='%230d0d0d'/%3E%3Ccircle cx='16' cy='18.5' r='3.5' fill='%234a9abf'/%3E%3Ccircle cx='16' cy='18.5' r='1.5' fill='%23c8eef8'/%3E%3Ccircle cx='24.5' cy='13.5' r='1.5' fill='%23ffc107'/%3E%3C/svg%3E">
  792. <style>
  793.  :root{{--foto-breedte:220px;--left-w:240px;--right-w:210px}}
  794.  *{{box-sizing:border-box}}
  795.  body{{font-family:sans-serif;margin:0;background:#1a1a1a;color:#eee;overflow-x:hidden}}
  796.  /* ── Toolbar ── */
  797.  .toolbar{{position:sticky;top:0;background:#111;padding:.5rem .8rem;display:flex;align-items:center;z-index:20;border-bottom:1px solid #333}}
  798.  .toolbar h1{{margin:0;font-size:1rem;white-space:nowrap;color:#f90;letter-spacing:.04em}}
  799.  input[type=text]{{width:100%;padding:.35rem .6rem;background:#222;border:1px solid #444;color:#eee;border-radius:4px;font-size:.85rem}}
  800.  .album-staat{{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:8px;height:8px;border-radius:50%;display:none}}
  801.  .album-staat.groen{{background:#4c4;display:block}}
  802.  .album-staat.oranje{{background:#f80;display:block}}
  803.  .album-staat.rood{{background:#c44;display:block}}
  804.  button{{padding:.35rem .7rem;border:none;border-radius:4px;cursor:pointer;font-size:.82rem}}
  805.  .btn-reset{{background:#c33;color:#fff}}
  806.  .btn-alles{{background:#383838;color:#ccc}}
  807.  .btn-presort{{background:#1e4a1e;color:#8f8;border:1px solid #2d6a2d}}
  808.  .btn-opruim{{background:#1a2a3a;color:#7af;border:1px solid #2a4a6a}}
  809.  .btn-cache{{background:#3a2a00;color:#f80;border:1px solid #5a4000}}
  810.  .btn-later{{background:#1e1e3a;color:#aaf;border:1px solid #336}}
  811.  .btn-verwijder{{background:#5a1010;color:#faa;border:1px solid #8a2020}}
  812.  .btn-verwijder:disabled{{background:#2a1a1a;color:#664;border-color:#3a1a1a;cursor:default}}
  813.  /* Prullenbak: geen acties beschikbaar */
  814.  .prullenbak-modus .foto-del{{display:none!important}}
  815.  .prullenbak-modus .foto-zoom{{display:none!important}}
  816.  .prullenbak-modus .foto{{cursor:default}}
  817.  /* Prullenbak tree: geen hover-acties */
  818.  .boom-rij.pb-readonly{{cursor:default}}
  819.  .boom-rij.pb-readonly .boom-rij-acties{{display:none!important}}
  820.  #status{{font-size:.72rem;color:#8f8;padding:.25rem .7rem;background:#111;border-top:1px solid #1e1e1e;flex-shrink:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}
  821.  /* ── 3-koloms layout ── */
  822.  html,body{{height:100%;overflow:hidden}}
  823.  body{{display:flex;flex-direction:column}}
  824.  #progress-wrap{{height:4px;flex-shrink:0}}
  825.  #progress{{height:4px;background:#4a8abf;transition:width .1s;width:0%}}
  826.  #main-wrap{{flex:1;display:flex;min-height:0;overflow:hidden;flex-direction:column}}
  827.  #cols-wrap{{flex:1;display:flex;min-height:0;overflow:hidden}}
  828.  /* ── Linkerpaneel (treeview) ── */
  829.  #left-panel{{width:var(--left-w);flex-shrink:0;background:#141414;overflow-y:auto;display:flex;flex-direction:column}}
  830.  #left-resizer{{width:5px;flex-shrink:0;cursor:col-resize;background:#2a2a2a;transition:background .1s;z-index:10}}
  831.  #left-resizer:hover,#left-resizer.dragging{{background:#4a8abf}}
  832.  .ts-hdr{{display:flex;align-items:center;gap:.3rem;padding:.35rem .6rem;font-size:.75rem;font-weight:bold;color:#888;cursor:pointer;user-select:none;border-bottom:1px solid #222;background:#111}}
  833.  .ts-hdr:hover{{color:#bbb}}
  834.  .ts-pijl{{font-size:.55rem;transition:transform .15s;display:inline-block;width:10px;text-align:center}}
  835.  .ts-hdr.open .ts-pijl{{transform:rotate(90deg)}}
  836.  .ts-body{{display:none;padding:.2rem 0}}
  837.  .ts-body.open{{display:block}}
  838.  /* Tree nodes */
  839.  .boom-rij{{display:flex;align-items:center;gap:.25rem;padding:2px 0 2px 8px;cursor:pointer;border-radius:3px;margin:0 3px}}
  840.  .boom-rij:hover{{background:#1e1e1e}}
  841.  .boom-pijl{{width:12px;font-size:.5rem;color:#555;flex-shrink:0;text-align:center;user-select:none}}
  842.  .boom-naam{{font-size:.75rem;color:#9bc;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}
  843.  .boom-count{{color:#555;font-size:.68rem;font-weight:normal}}
  844.  .boom-naam:hover{{color:#ddf}}
  845.  .boom-naam.actief{{color:#f90;font-weight:bold}}
  846.  .boom-sub{{display:none}}
  847.  .maand-rij{{display:flex;align-items:center;gap:.25rem;padding:2px 0 2px 20px;cursor:pointer;border-radius:3px;margin:0 3px}}
  848.  .maand-rij:hover{{background:#1e1e1e}}
  849.  .maand-rij span{{font-size:.73rem;color:#9bc}}
  850.  .maand-rij.actief span{{color:#f90;font-weight:bold}}
  851.  .jaar-rij{{display:flex;align-items:center;gap:.25rem;padding:.25rem .6rem;cursor:pointer;user-select:none}}
  852.  .jaar-rij:hover .jaar-label{{color:#eee}}
  853.  .jaar-label{{font-size:.72rem;font-weight:bold;color:#666}}
  854.  .jaar-pijl{{font-size:.5rem;color:#444;width:10px;text-align:center}}
  855.  .jaar-sub{{display:none;padding:.1rem 0}}
  856.  /* Activiteitlog in linkerpaneel */
  857.  #ts-activiteit .act-rij{{font-size:.68rem;padding:.2rem .6rem;border-bottom:1px solid #1e1e1e;line-height:1.4}}
  858.  #ts-activiteit .act-rij .act-ts{{color:#444}}
  859.  #ts-activiteit .act-rij .act-naam{{color:#ccc;font-weight:bold}}
  860.  #ts-activiteit .act-rij .act-naar{{color:#4a8a4a}}
  861.  /* ── Middenpaneel (foto's) ── */
  862.  #fotos-wrap{{flex:1;min-width:0;overflow:hidden;display:flex;flex-direction:column}}
  863.  #thumb-toolbar{{display:flex;align-items:center;gap:.5rem;padding:.35rem .7rem;background:#111;border-bottom:1px solid #222;font-size:.72rem;color:#666;flex-shrink:0;position:sticky;top:0;z-index:5}}
  864.  #thumb-toolbar input[type=range]{{width:90px;accent-color:#4a8abf;cursor:pointer}}
  865.  #fotos{{flex:1;padding:.75rem;min-width:0;overflow-y:auto}}
  866.  /* ── Rechterpaneel (selectie) ── */
  867.  #sidebar{{width:var(--right-w);flex-shrink:0;background:#111;border-left:1px solid #2a2a2a;overflow-y:auto;padding:.6rem}}
  868.  #sidebar h3{{margin:0 0 .4rem;font-size:.78rem;color:#888;text-transform:uppercase;letter-spacing:.05em}}
  869.  .sb-item{{display:flex;align-items:center;gap:.35rem;margin-bottom:.35rem;background:#1a1a1a;border-radius:4px;padding:3px 4px}}
  870.  .sb-thumb{{width:34px;height:34px;object-fit:cover;border-radius:3px;flex-shrink:0}}
  871.  .sb-naam{{font-size:.62rem;color:#bbb;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
  872.  .sb-x{{background:none;border:none;color:#c66;cursor:pointer;font-size:.85rem;padding:0 2px;flex-shrink:0}}
  873.  .sb-leeg{{font-size:.72rem;color:#444}}
  874.  /* ── Foto-grid ── */
  875.  .grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--foto-breedte),1fr));gap:8px}}
  876.  .dag-header{{font-size:.88rem;font-weight:bold;color:#bbb;padding:.5rem 0 .25rem;border-bottom:1px solid #2a2a2a;margin-bottom:.1rem;grid-column:1/-1}}
  877.  .bladwijzer{{display:flex;align-items:center;gap:.5rem;margin:.5rem 0;color:#c44;font-size:.72rem;font-style:italic}}
  878.  .bladwijzer::before{{content:'';flex:0 0 10px;height:10px;background:#c44;border-radius:50%}}
  879.  .bladwijzer::after{{content:'';flex:1;height:1px;background:#c44}}
  880.  .burst{{border-radius:6px;padding:5px;margin-bottom:6px}}
  881.  .burst.tight{{background:#2d2010}}
  882.  .burst.moment{{background:#1a1a2d}}
  883.  .burst-label{{font-size:.67rem;margin-bottom:3px;cursor:pointer;display:inline-block;padding:2px 5px;border-radius:4px}}
  884.  .burst-label:hover{{opacity:.8}}
  885.  .burst.tight .burst-label{{color:#c8860a}}
  886.  .burst.moment .burst-label{{color:#7788cc}}
  887.  /* ── Lightbox ── */
  888.  #lb{{display:none;position:fixed;inset:0;background:rgba(0,0,0,.93);z-index:100;flex-direction:column;align-items:center;justify-content:center}}
  889.  #lb-inner{{display:flex;flex-direction:column;width:100%;max-width:1200px;height:100vh;padding:1rem;gap:.75rem;box-sizing:border-box}}
  890.  #lb-header{{display:flex;justify-content:space-between;align-items:center;color:#aaa;font-size:.82rem}}
  891.  #lb-teller{{color:#eee;font-weight:bold}}
  892.  #lb-fotos{{display:flex;gap:1rem;flex:1;min-height:0}}
  893.  .lb-foto-wrap{{flex:1;display:flex;flex-direction:column;gap:.4rem;min-width:0}}
  894.  .lb-foto-wrap img{{width:100%;height:calc(100% - 56px);object-fit:contain;border-radius:6px;border:3px solid transparent;cursor:pointer}}
  895.  .lb-foto-wrap img.met-hart{{border-color:#e33}}
  896.  .lb-foto-info{{font-size:.7rem;color:#aaa;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:.3rem}}
  897.  .lb-hart{{background:none;border:2px solid #555;border-radius:50%;width:34px;height:34px;font-size:1rem;cursor:pointer;color:#aaa;flex-shrink:0}}
  898.  .lb-hart.actief{{border-color:#e33;color:#e33}}
  899.  #lb-nav{{display:flex;align-items:center;justify-content:center;gap:1rem}}
  900.  .lb-pijl{{background:#333;border:none;color:#eee;font-size:1.3rem;width:40px;height:40px;border-radius:50%;cursor:pointer}}
  901.  .lb-pijl:disabled{{opacity:.3;cursor:default}}
  902.  #lb-acties{{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap}}
  903.  #lb-info{{flex:1;font-size:.78rem;color:#aaa}}
  904.  .lb-btn-over{{background:#383838;border:none;color:#eee;padding:.4rem .9rem;border-radius:4px;cursor:pointer}}
  905.  .lb-btn-bevestig{{background:#2a7a2a;border:none;color:#eee;padding:.4rem .9rem;border-radius:4px;cursor:pointer;font-weight:bold}}
  906.  .lb-btn-bevestig:disabled{{background:#2a2a2a;color:#555;cursor:default}}
  907.  /* ── Foto-kaarten ── */
  908.  .foto{{cursor:pointer;border:2px solid transparent;border-radius:5px;overflow:hidden;width:100%;background:#222;position:relative}}
  909.  .foto.geselecteerd{{border-color:#f90}}
  910.  .foto img{{width:100%;display:block;min-height:70px;background:#2a2a2a}}
  911.  .naam{{font-size:.58rem;padding:2px 4px;color:#888;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}}
  912.  .ext-badge{{position:absolute;top:3px;left:3px;background:rgba(0,0,0,.7);color:#fc0;font-size:.52rem;font-weight:bold;padding:1px 3px;border-radius:3px;pointer-events:none;text-transform:uppercase}}
  913.  .foto-del{{position:absolute;top:3px;right:3px;background:rgba(140,0,0,.85);color:#fff;border:none;border-radius:50%;width:18px;height:18px;font-size:.8rem;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;opacity:0;transition:opacity .15s;z-index:2}}
  914.  .foto:hover .foto-del{{opacity:1}}
  915.  .foto-zoom,.foto-info-btn{{position:absolute;left:3px;background:rgba(0,0,0,.7);color:#fff;border-radius:3px;width:18px;height:18px;font-size:.7rem;display:flex;align-items:center;justify-content:center;opacity:0;transition:opacity .15s;text-decoration:none;z-index:2;border:none;cursor:pointer;padding:0}}
  916.  .foto-zoom{{top:20px}}
  917.  .foto-info-btn{{top:41px;font-size:.65rem}}
  918.  .foto:hover .foto-zoom,.foto:hover .foto-info-btn{{opacity:1}}
  919.  .foto-tijdstip{{font-size:.52rem;color:#555;margin-left:.25rem}}
  920.  /* ── Info-panel ── */
  921.  #info-panel{{position:fixed;z-index:500;background:#1e1e1e;border:1px solid #444;border-radius:7px;padding:.7rem .9rem;width:260px;font-size:.76rem;color:#ccc;box-shadow:0 4px 24px rgba(0,0,0,.85);display:none}}
  922.  .info-rij{{display:flex;gap:.4rem;margin-bottom:.25rem;align-items:flex-start;line-height:1.4}}
  923.  .info-lbl{{color:#555;white-space:nowrap;min-width:50px;flex-shrink:0}}
  924.  .info-val{{color:#ccc;word-break:break-all}}
  925.  #info-kaart{{width:100%;height:200px;border:0;border-radius:4px;margin-top:.4rem;display:none}}
  926.  .datum{{font-size:.7rem;padding:2px 4px 3px;color:#888}}
  927.  #thumb-toolbar button{{padding:.18rem .45rem;font-size:.7rem}}
  928.  /* ── Activiteitslog (full-width) ── */
  929.  #activiteit-wrap{{flex-shrink:0;border-top:1px solid #222;background:#111}}
  930.  #activiteit-hdr{{padding:.3rem .8rem;font-size:.75rem;font-weight:bold;color:#555;cursor:pointer;user-select:none;display:flex;align-items:center;gap:.3rem}}
  931.  #activiteit-hdr:hover{{color:#888}}
  932.  #activiteit-pijl{{font-size:.5rem;transition:transform .15s}}
  933.  #activiteit-body{{max-height:130px;overflow-y:auto;padding:.2rem 0}}
  934.  #activiteit-lijst{{display:flex;flex-wrap:wrap;gap:0;font-size:.68rem}}
  935.  .act-rij{{padding:2px 10px;border-right:1px solid #1e1e1e;white-space:nowrap}}
  936.  .act-ts{{color:#3a3a3a}}
  937.  .act-naam{{color:#888}}
  938.  .act-naar{{color:#4a7a4a}}
  939.  /* ── Tree drag-over ── */
  940.  .boom-rij.drag-over{{background:#1a3a1a;outline:1px solid #4a8}}
  941.  .maand-rij.drag-over{{background:#1a3a1a;outline:1px solid #4a8}}
  942.  .ts-hdr.drag-over{{background:#1e3a1e;outline:1px solid #4a8}}
  943.  /* ── Tree rename + add ── */
  944.  .boom-rij-acties{{display:none;gap:2px;margin-left:auto}}
  945.  .boom-rij:hover .boom-rij-acties{{display:flex}}
  946.  .boom-rij-btn{{background:none;border:none;color:#555;cursor:pointer;font-size:.65rem;padding:1px 3px;line-height:1;border-radius:2px}}
  947.  .boom-rij-btn:hover{{color:#ccc;background:#333}}
  948.  .ts-hdr-acties{{margin-left:auto;display:flex;gap:2px}}
  949.  .ts-hdr-btn{{background:none;border:none;color:#555;cursor:pointer;font-size:.72rem;padding:1px 4px;border-radius:3px;line-height:1}}
  950.  .ts-hdr-btn:hover{{color:#8f8;background:#1e2e1e}}
  951.  /* ── Info-help knop ── */
  952.  #info-help-btn{{position:fixed;right:1rem;bottom:1rem;width:32px;height:32px;border-radius:50%;background:#2a2a2a;color:#888;border:1px solid #444;font-size:1rem;font-weight:bold;cursor:pointer;z-index:200;display:flex;align-items:center;justify-content:center;line-height:1}}
  953.  #info-help-btn:hover{{background:#383838;color:#ccc}}
  954.  @media(max-width:700px){{
  955.    html,body{{height:auto;overflow:auto}}
  956.    :root{{--foto-breedte:130px;--left-w:100%;--right-w:100%}}
  957.    #main-wrap{{flex-direction:column;overflow:visible}}
  958.    #left-panel{{width:100%!important;max-height:240px;border-right:none;border-bottom:1px solid #2a2a2a}}
  959.    #left-resizer{{display:none}}
  960.    #fotos-wrap{{overflow:visible}}
  961.    #sidebar{{width:100%;border-left:none;border-top:1px solid #2a2a2a}}
  962.  }}
  963. </style>
  964. </head>
  965. <body>
  966. <div class="toolbar">
  967.  <h1>Declutter</h1>
  968. </div>
  969. <div id="progress-wrap"><div id="progress"></div></div>
  970. <div id="main-wrap">
  971. <div id="cols-wrap">
  972.  <!-- Linkerpaneel: treeview -->
  973.  <div id="left-panel">
  974.    <div class="ts-hdr open" id="tsh-behandeling" onclick="toggleTS('behandeling')">
  975.      <span class="ts-pijl">&#9658;</span> In behandeling
  976.    </div>
  977.    <div class="ts-body open" id="tsb-behandeling"><div id="tree-behandeling" style="padding:.2rem 0"><span style="color:#444;font-size:.7rem;padding:4px 10px">Laden…</span></div></div>
  978.    <div class="ts-hdr open" id="tsh-verwerkt" onclick="toggleTS('verwerkt')"
  979.         ondragover="_sectionDragOver(event,this)" ondragleave="_boomDragLeave(this)"
  980.         ondrop="_sectionDrop(event,'verwerkt')">
  981.      <span class="ts-pijl">&#9658;</span> Verwerkt (met datum)
  982.      <span class="ts-hdr-acties"><button class="ts-hdr-btn" title="Nieuwe map in Verwerkt" onclick="event.stopPropagation();toonNieuweMapModal('verwerkt')">+</button></span>
  983.    </div>
  984.    <div class="ts-body open" id="tsb-verwerkt"><div id="tree-verwerkt"></div></div>
  985.    <div class="ts-hdr open" id="tsh-datumloos" onclick="toggleTS('datumloos')"
  986.         ondragover="_sectionDragOver(event,this)" ondragleave="_boomDragLeave(this)"
  987.         ondrop="_sectionDrop(event,'datumloos')">
  988.      <span class="ts-pijl">&#9658;</span> Verwerkt (zonder datum)
  989.      <span class="ts-hdr-acties"><button class="ts-hdr-btn" title="Nieuwe map in Zonder datum" onclick="event.stopPropagation();toonNieuweMapModal('datumloos')">+</button></span>
  990.    </div>
  991.    <div class="ts-body open" id="tsb-datumloos"><div id="tree-datumloos"></div></div>
  992.    <div class="ts-hdr" id="tsh-prullenbak" onclick="toggleTS('prullenbak')">
  993.      <span class="ts-pijl">&#9658;</span> &#128465; Prullenbak
  994.    </div>
  995.    <div class="ts-body" id="tsb-prullenbak"><div id="tree-prullenbak"></div></div>
  996.  </div>
  997.  <div id="left-resizer"></div>
  998.  <!-- Middenpaneel: foto's -->
  999.  <div id="fotos-wrap">
  1000.    <div id="thumb-toolbar">
  1001.      <span>Grootte:</span>
  1002.      <input type="range" id="breedte-slider" min="100" max="400" value="220"
  1003.             oninput="sliderVerander(this.value)">
  1004.      <span id="breedte-label">220px</span>
  1005.      <div style="flex:1"></div>
  1006.      <button type="button" class="btn-later" onclick="bewaarLater()" title="Zet selectie in 'Later uitzoeken'">⏳ Later</button>
  1007.      <button type="button" id="btn-verwijder" class="btn-verwijder" onclick="verwijderSelectie()" title="Verplaats selectie naar prullenbak" disabled>&#128465; Verwijder</button>
  1008.      <button type="button" class="btn-alles" onclick="allesToggle()" title="Alles (de)selecteren">&#9745; Alles</button>
  1009.      <button type="button" class="btn-presort" onclick="presortRun()" title="Foto's uit ruwe_data indelen op datum">&#9881; Presort</button>
  1010.      <button type="button" class="btn-opruim" onclick="opruimLege()" title="Verwijder lege mappen uit 'In behandeling'">&#129529; Opruimen</button>
  1011.      <button type="button" class="btn-cache" onclick="clearThumbs()" title="Thumbnail-cache leegmaken">&#128465; Cache</button>
  1012.      <button type="button" class="btn-reset" onclick="resetTest()" title="Testdata opnieuw aanmaken">&#8635; Reset</button>
  1013.    </div>
  1014.    <div id="fotos"><p style="color:#555;padding:1rem">Kies een map in de boom links.</p></div>
  1015.    <div id="status">Kies een map in de boom links.</div>
  1016.  </div>
  1017.  <!-- Rechterpaneel: geselecteerd -->
  1018.  <div id="sidebar"><h3>Geselecteerd</h3><p class="sb-leeg">Nog niets geselecteerd.</p></div>
  1019. </div><!-- /cols-wrap -->
  1020. <!-- Activiteitslog: full-width onder de 3 kolommen -->
  1021. <div id="activiteit-wrap">
  1022.  <div id="activiteit-hdr" onclick="toggleActiviteit()">
  1023.    <span id="activiteit-pijl">&#9658;</span> Activiteitslog
  1024.    <span id="activiteit-count" style="color:#555;margin-left:.5rem;font-weight:normal"></span>
  1025.  </div>
  1026.  <div id="activiteit-body" style="display:none">
  1027.    <div id="activiteit-lijst"><span style="color:#444;font-size:.72rem;padding:4px 10px;display:block">Nog geen activiteit in deze sessie.</span></div>
  1028.  </div>
  1029. </div>
  1030. </div><!-- /main-wrap -->
  1031.  
  1032. <!-- Drag-tooltip -->
  1033. <div id="drag-tooltip" style="position:fixed;display:none;background:rgba(30,30,30,.92);color:#f90;border:1px solid #555;border-radius:6px;padding:4px 10px;font-size:.78rem;pointer-events:none;z-index:600"></div>
  1034.  
  1035. <!-- Nieuwe map modal -->
  1036. <div id="nieuwmap-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:300;align-items:center;justify-content:center">
  1037.  <div style="background:#1e1e1e;border:1px solid #555;border-radius:8px;padding:1.4rem 1.8rem;max-width:420px;width:90%;color:#eee">
  1038.    <h2 style="margin:0 0 .8rem;font-size:1rem;color:#eee" id="nieuwmap-titel">Nieuwe map aanmaken</h2>
  1039.    <p style="margin:0 0 .6rem;font-size:.82rem;color:#888" id="nieuwmap-uitleg"></p>
  1040.    <div style="position:relative;margin-bottom:.5rem">
  1041.      <input type="text" id="nieuwmap-naam" placeholder="2026-04-02 Vakantie  of  Marktplaats/Babybedje"
  1042.             oninput="valideerNieuweMap()" style="width:100%;padding:.4rem 2rem .4rem .6rem;background:#222;border:1px solid #444;color:#eee;border-radius:4px;font-size:.88rem">
  1043.      <span class="album-staat" id="nieuwmap-staat"></span>
  1044.    </div>
  1045.    <div id="nieuwmap-hint" style="font-size:.72rem;color:#666;margin-bottom:.8rem;min-height:1.2rem"></div>
  1046.    <div style="display:flex;gap:.6rem;justify-content:flex-end">
  1047.      <button type="button" onclick="sluitNieuweMapModal()" style="background:#383838;color:#ccc;padding:.45rem 1rem;border:none;border-radius:4px;cursor:pointer">Annuleren</button>
  1048.      <button type="button" id="nieuwmap-ok" onclick="bevestigNieuweMap()" style="background:#2a6a2a;color:#eee;padding:.45rem 1rem;border:none;border-radius:4px;cursor:pointer;font-weight:bold" disabled>Aanmaken</button>
  1049.    </div>
  1050.  </div>
  1051. </div>
  1052.  
  1053. <!-- Hernoem-modal -->
  1054. <div id="hernoem-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:300;align-items:center;justify-content:center">
  1055.  <div style="background:#1e1e1e;border:1px solid #555;border-radius:8px;padding:1.4rem 1.8rem;max-width:420px;width:90%;color:#eee">
  1056.    <h2 style="margin:0 0 .8rem;font-size:1rem;color:#eee">Map hernoemen</h2>
  1057.    <p style="margin:0 0 .5rem;font-size:.78rem;color:#666" id="hernoem-huidig"></p>
  1058.    <input type="text" id="hernoem-naam" placeholder="Nieuwe naam"
  1059.           oninput="valideerHernoem()" style="width:100%;padding:.4rem .6rem;background:#222;border:1px solid #444;color:#eee;border-radius:4px;font-size:.88rem;margin-bottom:.3rem">
  1060.    <div id="hernoem-hint" style="font-size:.72rem;color:#f80;margin-bottom:.8rem;min-height:1.2rem"></div>
  1061.    <div style="display:flex;gap:.6rem;justify-content:flex-end">
  1062.      <button type="button" onclick="sluitHernoemModal()" style="background:#383838;color:#ccc;padding:.45rem 1rem;border:none;border-radius:4px;cursor:pointer">Annuleren</button>
  1063.      <button type="button" id="hernoem-ok" onclick="bevestigHernoem()" style="background:#2a5a8a;color:#eee;padding:.45rem 1rem;border:none;border-radius:4px;cursor:pointer;font-weight:bold" disabled>Hernoemen</button>
  1064.    </div>
  1065.  </div>
  1066. </div>
  1067.  
  1068. <!-- Info-knop rechtsonder -->
  1069. <button id="info-help-btn" onclick="toonInfoHelp()" title="Uitleg over de interface">?</button>
  1070.  
  1071. <!-- Info-help modal -->
  1072. <div id="info-help-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.8);z-index:400;align-items:center;justify-content:center;overflow:auto">
  1073.  <div style="background:#1e1e1e;border:1px solid #555;border-radius:8px;padding:1.5rem 2rem;max-width:560px;width:90%;color:#ccc;position:relative;margin:auto">
  1074.    <button onclick="document.getElementById('info-help-modal').style.display='none'" style="position:absolute;top:.6rem;right:.8rem;background:none;border:none;color:#888;font-size:1.2rem;cursor:pointer">×</button>
  1075.    <h2 style="margin:0 0 1rem;color:#eee;font-size:1rem">Declutter — gebruiksaanwijzing</h2>
  1076.    <div style="font-size:.82rem;line-height:1.7;display:flex;flex-direction:column;gap:.5rem">
  1077.      <div><strong style="color:#f90">Linkerpaneel (boom)</strong><br>
  1078.        Klik op een maand of album om de foto's te laden.<br>
  1079.        Dubbelklik op een mapnaam om hem te hernoemen, of gebruik het ✎-icoon.<br>
  1080.        Sleep een <em>map</em> naar een andere map of sectie-header om hem te verplaatsen.<br>
  1081.        Gebruik het <strong>+</strong>-icoon naast een sectie of map voor een nieuwe submap.</div>
  1082.      <div><strong style="color:#f90">Foto's selecteren &amp; verplaatsen</strong><br>
  1083.        Klik op een thumbnail om te selecteren. <strong>Shift+klik</strong> voor bereik. <strong>☑ Alles</strong> selecteert alles in de huidige weergave.<br>
  1084.        Sleep geselecteerde foto's naar een map in de boom — een tooltip toont het aantal.<br>
  1085.        <strong>⏳ Later</strong> verplaatst de selectie naar de map 'Later uitzoeken'.</div>
  1086.      <div><strong style="color:#f90">Nieuwe map</strong><br>
  1087.        Klik <strong>+</strong> naast een sectie of map. Als foto's geselecteerd zijn worden ze er direct naartoe verplaatst.<br>
  1088.        <em>Verwerkt (met datum)</em>: begin met een datum, bijv. <code>2026-04-02 Vakantie</code>.<br>
  1089.        <em>Zonder datum</em>: geen datum nodig, bijv. <code>Marktplaats/Babybedje</code>.</div>
  1090.      <div><strong style="color:#f90">Werkbalk (boven het overzicht)</strong><br>
  1091.        <strong>⚙ Presort</strong> — verdeelt foto's uit ruwe_data automatisch op maand.<br>
  1092.        <strong>🧹 Opruimen</strong> — verwijdert lege YYYY-MM mappen uit 'In behandeling', zodat je weet welke periodes al afgehandeld zijn.<br>
  1093.        <strong>🗑 Cache</strong> — wist de thumbnail-cache (nuttig na handmatige wijzigingen).<br>
  1094.        <strong>↺ Reset</strong> — maakt testdata opnieuw aan (wist alle bestanden!).</div>
  1095.      <div><strong style="color:#f90">Burst-groepen</strong><br>
  1096.        Foto's die snel achter elkaar genomen zijn, worden gegroepeerd. Klik op het label om de lichtbak te openen voor vergelijking.</div>
  1097.      <div><strong style="color:#f90">Kleurcodes album-invoer</strong><br>
  1098.        <span style="color:#4c4">●</span> Groen = geldig formaat &nbsp;
  1099.        <span style="color:#f80">●</span> Oranje = vrije tekst zonder datum &nbsp;
  1100.        <span style="color:#c44">●</span> Rood = ongeldige datum</div>
  1101.      <div><strong style="color:#f90">Sneltoetsen (lichtbak)</strong><br>
  1102.        ← → bladeren &nbsp;·&nbsp; H = hartje &nbsp;·&nbsp; Esc = sluiten</div>
  1103.    </div>
  1104.  </div>
  1105. </div>
  1106.  
  1107. <div id="info-panel">
  1108.  <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.6rem">
  1109.    <strong id="info-naam" style="font-size:.8rem;color:#eee;word-break:break-all;flex:1;margin-right:.5rem"></strong>
  1110.    <button onclick="sluitInfoPanel()" style="background:none;border:none;color:#888;font-size:1.1rem;cursor:pointer;line-height:1;flex-shrink:0">×</button>
  1111.  </div>
  1112.  <div id="info-inhoud"></div>
  1113.  <iframe id="info-kaart" sandbox="allow-scripts allow-same-origin"></iframe>
  1114. </div>
  1115.  
  1116. <div id="reset-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:200;align-items:center;justify-content:center">
  1117.  <div style="background:#1e1e1e;border:1px solid #555;border-radius:8px;padding:1.5rem 2rem;max-width:440px;width:90%;color:#eee">
  1118.    <div id="reset-keuze">
  1119.      <h2 style="margin:0 0 .8rem;font-size:1.1rem;color:#f66">Reset test — weet je het zeker?</h2>
  1120.      <p style="margin:0 0 .6rem;font-size:.9rem;color:#ccc">Dit gaat het volgende doen:</p>
  1121.      <ul style="margin:.3rem 0 .8rem;padding-left:1.4rem;font-size:.85rem;color:#aaa;line-height:1.7">
  1122.        <li>De volledige map <code style="color:#f90">{BASE_DIR}</code> wordt gewist</li>
  1123.        <li>Gekozen aantal foto's worden gedownload van picsum.photos</li>
  1124.        <li>Foto's worden ingedeeld op datum via presort</li>
  1125.        <li>De pagina wordt herladen</li>
  1126.      </ul>
  1127.      <p style="margin:0 0 .8rem;font-size:.8rem;color:#888">Bestanden buiten <code style="color:#f90">{BASE_DIR}</code> worden niet aangeraakt.</p>
  1128.      <label style="display:flex;align-items:center;gap:.8rem;font-size:.9rem;margin-bottom:1rem">
  1129.        <span style="white-space:nowrap;color:#ccc">Aantal foto's:</span>
  1130.        <input type="range" id="reset-aantal" min="20" max="400" step="10" value="40"
  1131.               oninput="document.getElementById('reset-aantal-label').textContent=this.value"
  1132.               style="flex:1;accent-color:#4a8abf;cursor:pointer">
  1133.        <span id="reset-aantal-label" style="width:2.5rem;text-align:right;color:#f90;font-weight:bold">40</span>
  1134.      </label>
  1135.      <div style="display:flex;gap:.7rem;justify-content:flex-end">
  1136.        <button type="button" onclick="document.getElementById('reset-modal').style.display='none'" style="background:#444;color:#eee;padding:.5rem 1.1rem;border:none;border-radius:4px;cursor:pointer;font-size:.9rem">Annuleren</button>
  1137.        <button type="button" onclick="resetTestBevestigd()" style="background:#c33;color:#fff;padding:.5rem 1.1rem;border:none;border-radius:4px;cursor:pointer;font-size:.9rem;font-weight:bold">Ja, reset</button>
  1138.      </div>
  1139.    </div>
  1140.    <div id="reset-voortgang" style="display:none">
  1141.      <h2 style="margin:0 0 1rem;font-size:1rem;color:#ccc">Reset bezig…</h2>
  1142.      <div id="reset-fase-label" style="font-size:.8rem;color:#888;margin-bottom:.4rem">Starten…</div>
  1143.      <div style="background:#111;border-radius:4px;height:12px;overflow:hidden;margin-bottom:.5rem">
  1144.        <div id="reset-balk" style="height:100%;width:0%;background:#4a8abf;transition:width .2s"></div>
  1145.      </div>
  1146.      <div id="reset-bericht" style="font-size:.78rem;color:#666;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></div>
  1147.    </div>
  1148.  </div>
  1149. </div>
  1150.  
  1151. <div id="lb">
  1152.  <div id="lb-inner">
  1153.    <div id="lb-header">
  1154.      <span id="lb-teller"></span>
  1155.      <span style="font-size:.75rem;color:#666">H = hartje &nbsp;·&nbsp; ← → = bladeren &nbsp;·&nbsp; Esc = sluiten</span>
  1156.    </div>
  1157.    <div id="lb-fotos"></div>
  1158.    <div id="lb-nav">
  1159.      <button type="button" class="lb-pijl" id="lb-prev" onclick="lbStap(-1)">&#8592;</button>
  1160.      <button type="button" class="lb-pijl" id="lb-next" onclick="lbStap(1)">&#8594;</button>
  1161.    </div>
  1162.    <div id="lb-acties">
  1163.      <span id="lb-info"></span>
  1164.      <button type="button" class="lb-btn-over" onclick="sluitLightbox()">Sla over</button>
  1165.      <button type="button" class="lb-btn-bevestig" id="lb-bevestig" onclick="bevestigLightbox()">Bevestig keuze</button>
  1166.    </div>
  1167.  </div>
  1168. </div>
  1169.  
  1170. <script>
  1171. // Persistente selectie over mappen heen: pad -> {{naam, thumb}}
  1172. const selectie = new Map();
  1173. let huidigeMaand = null;
  1174. let huidigePad   = null;
  1175. let _archiefDir   = '';
  1176. let _datumloosDir = '';
  1177. let _prullenbakDir = '';
  1178. let _herstelde_anker = localStorage.getItem('fc_anker');
  1179.  
  1180. // ── Tree state ─────────────────────────────────────────────────────────────
  1181. let _treeData = null;  // cache van laatste /tree respons
  1182.  
  1183. function toggleTS(sectie) {{
  1184.  const hdr  = document.getElementById('tsh-' + sectie);
  1185.  const body = document.getElementById('tsb-' + sectie);
  1186.  if (!hdr || !body) return;
  1187.  const open = body.classList.toggle('open');
  1188.  hdr.classList.toggle('open', open);
  1189. }}
  1190.  
  1191. function _renderMaandBoom(jm) {{
  1192.  const container = document.getElementById('tree-behandeling');
  1193.  if (!container) return;
  1194.  // Totaal over alle maanden → header bijwerken
  1195.  const totaal = jm ? Object.values(jm).flat().reduce((s, mo) => s + (typeof mo === 'object' ? (mo.aantal||0) : 0), 0) : 0;
  1196.  const hdr = document.getElementById('tsh-behandeling');
  1197.  if (hdr) {{
  1198.    let lbl = hdr.querySelector('.ts-behandeling-lbl');
  1199.    if (!lbl) {{
  1200.      lbl = document.createElement('span');
  1201.      lbl.className = 'ts-behandeling-lbl';
  1202.      hdr.appendChild(lbl);
  1203.    }}
  1204.    lbl.innerHTML = totaal ? ` <span class="boom-count">(${{totaal}})</span>` : '';
  1205.  }}
  1206.  if (!jm || !Object.keys(jm).length) {{
  1207.    container.innerHTML = '<span style="color:#444;font-size:.7rem;padding:4px 10px;display:block">Geen mappen.</span>';
  1208.    return;
  1209.  }}
  1210.  const MAANDEN = ['jan','feb','mrt','apr','mei','jun','jul','aug','sep','okt','nov','dec'];
  1211.  let html = '';
  1212.  for (const jaar of Object.keys(jm).sort().reverse()) {{
  1213.    const jaarAantal = jm[jaar].reduce((s, mo) => s + (typeof mo === 'object' ? (mo.aantal||0) : 0), 0);
  1214.    const jaarCnt = jaarAantal ? ` <span class="boom-count">(${{jaarAantal}})</span>` : '';
  1215.    html += `<div class="jaar-rij" onclick="toggleJaar('${{jaar}}')">
  1216.      <span class="jaar-pijl" id="jp-${{jaar}}">&#9658;</span>
  1217.      <span class="jaar-label">${{jaar}}${{jaarCnt}}</span>
  1218.    </div>
  1219.    <div class="jaar-sub" id="js-${{jaar}}">`;
  1220.    for (const mo of jm[jaar]) {{
  1221.      const maand = typeof mo === 'string' ? mo : mo.naam;
  1222.      const aantal = (typeof mo === 'object' && mo.aantal) ? mo.aantal : 0;
  1223.      const mn = parseInt(maand.slice(5)) - 1;
  1224.      const lbl = MAANDEN[mn] || maand.slice(5);
  1225.      const maandPad = ('{UITZOEKEN_DIR}/' + maand).replace(/\\\\/g,'/');
  1226.      const cntHtml = aantal ? ` <span class="boom-count">(${{aantal}})</span>` : '';
  1227.      html += `<div class="maand-rij" id="mr-${{maand}}" data-maand="${{maand}}"
  1228.        onclick="laadMaand('${{maand}}',this)"
  1229.        ondragover="_boomDragOver(event,this)"
  1230.        ondragleave="_boomDragLeave(this)"
  1231.        ondrop="_boomDrop(event,'${{maandPad}}')">
  1232.        <span>${{lbl}}${{cntHtml}}</span>
  1233.      </div>`;
  1234.    }}
  1235.    html += '</div>';
  1236.  }}
  1237.  container.innerHTML = html;
  1238.  // Open het meest recente jaar automatisch
  1239.  const jaren = Object.keys(jm).sort();
  1240.  if (jaren.length) _openJaar(jaren[jaren.length - 1]);
  1241. }}
  1242.  
  1243. function toggleJaar(jaar) {{
  1244.  const sub  = document.getElementById('js-' + jaar);
  1245.  const pijl = document.getElementById('jp-' + jaar);
  1246.  if (!sub) return;
  1247.  const open = sub.style.display !== 'block';
  1248.  sub.style.display = open ? 'block' : 'none';
  1249.  if (pijl) pijl.innerHTML = open ? '&#9660;' : '&#9658;';
  1250. }}
  1251.  
  1252. function _openJaar(jaar) {{
  1253.  const sub  = document.getElementById('js-' + jaar);
  1254.  const pijl = document.getElementById('jp-' + jaar);
  1255.  if (sub) {{ sub.style.display = 'block'; }}
  1256.  if (pijl) pijl.innerHTML = '&#9660;';
  1257. }}
  1258.  
  1259. function _renderAlbumBoom(nodes, containerId) {{
  1260.  const el = document.getElementById(containerId);
  1261.  if (!el) return;
  1262.  if (!nodes || !nodes.length) {{
  1263.    el.innerHTML = '<span style="color:#444;font-size:.7rem;padding:4px 10px;display:block">Leeg.</span>';
  1264.    return;
  1265.  }}
  1266.  el.innerHTML = _renderBoomNodes(nodes, 0);
  1267.  el.addEventListener('click', _boomKlik);
  1268. }}
  1269.  
  1270. function _renderPrullenbakBoom(nodes, containerId) {{
  1271.  const el = document.getElementById(containerId);
  1272.  if (!el) return;
  1273.  if (!nodes || !nodes.length) {{
  1274.    el.innerHTML = '<span style="color:#444;font-size:.7rem;padding:4px 10px;display:block">Leeg.</span>';
  1275.    return;
  1276.  }}
  1277.  el.innerHTML = _renderPbNodes(nodes, 0);
  1278.  el.addEventListener('click', e => {{
  1279.    const naam = e.target.closest('.boom-naam');
  1280.    if (naam) bladernInMap(naam.closest('.boom-node').dataset.pad);
  1281.    const pijl = e.target.closest('.boom-pijl');
  1282.    if (pijl) {{
  1283.      const node = pijl.closest('.boom-node');
  1284.      const sub  = node && node.querySelector(':scope > .boom-sub');
  1285.      if (sub) {{
  1286.        const open = sub.style.display === 'block';
  1287.        sub.style.display = open ? 'none' : 'block';
  1288.        pijl.innerHTML = open ? '&#9658;' : '&#9660;';
  1289.      }}
  1290.    }}
  1291.  }});
  1292. }}
  1293.  
  1294. function _renderPbNodes(nodes, diepte) {{
  1295.  return nodes.map(n => {{
  1296.    const esc     = n.pad.replace(/"/g,'&quot;');
  1297.    const naamEsc = n.naam.replace(/</g,'&lt;').replace(/>/g,'&gt;');
  1298.    const heeftSub = n.submappen && n.submappen.length > 0;
  1299.    const sub = heeftSub
  1300.      ? `<div class="boom-sub">${{_renderPbNodes(n.submappen, diepte+1)}}</div>` : '';
  1301.    const cntHtml = n.aantal ? ` <span class="boom-count">(${{n.aantal}})</span>` : '';
  1302.    return `<div class="boom-node" data-pad="${{esc}}">
  1303.      <div class="boom-rij pb-readonly" style="padding-left:${{8+diepte*12}}px">
  1304.        <span class="boom-pijl">${{heeftSub ? '&#9658;' : '&nbsp;'}}</span>
  1305.        <span class="boom-naam">${{naamEsc}}${{cntHtml}}</span>
  1306.      </div>${{sub}}</div>`;
  1307.  }}).join('');
  1308. }}
  1309.  
  1310. function _renderBoomNodes(nodes, diepte) {{
  1311.  return nodes.map(n => {{
  1312.    const esc = n.pad.replace(/"/g,'&quot;');
  1313.    const naamEsc = n.naam.replace(/</g,'&lt;').replace(/>/g,'&gt;');
  1314.    const heeftSub = n.submappen && n.submappen.length > 0;
  1315.    const sub = heeftSub
  1316.      ? `<div class="boom-sub">${{_renderBoomNodes(n.submappen, diepte+1)}}</div>` : '';
  1317.    const cntHtml = n.aantal ? ` <span class="boom-count">(${{n.aantal}})</span>` : '';
  1318.    return `<div class="boom-node" data-pad="${{esc}}">
  1319.      <div class="boom-rij" draggable="true" style="padding-left:${{8+diepte*12}}px"
  1320.           ondragstart="mapDragStart(event,'${{esc}}')"
  1321.           ondragover="_boomDragOver(event,this)"
  1322.           ondragleave="_boomDragLeave(this)"
  1323.           ondrop="_boomDrop(event,'${{esc}}')">
  1324.        <span class="boom-pijl">${{heeftSub ? '&#9658;' : '&nbsp;'}}</span>
  1325.        <span class="boom-naam" ondblclick="toonHernoemModal('${{esc}}')">${{naamEsc}}${{cntHtml}}</span>
  1326.        <span class="boom-rij-acties">
  1327.          <button class="boom-rij-btn" title="Nieuwe submap" onclick="event.stopPropagation();toonNieuweMapModal(null,'${{esc}}')">+</button>
  1328.          <button class="boom-rij-btn" title="Hernoemen" onclick="event.stopPropagation();toonHernoemModal('${{esc}}')">✎</button>
  1329.        </span>
  1330.      </div>${{sub}}</div>`;
  1331.  }}).join('');
  1332. }}
  1333.  
  1334. function _boomKlik(e) {{
  1335.  const pijl = e.target.closest('.boom-pijl');
  1336.  const naam = e.target.closest('.boom-naam');
  1337.  if (pijl) {{
  1338.    const node = pijl.closest('.boom-node');
  1339.    const sub  = node && node.querySelector(':scope > .boom-sub');
  1340.    if (sub) {{
  1341.      const open = sub.style.display === 'block';
  1342.      sub.style.display = open ? 'none' : 'block';
  1343.      pijl.innerHTML    = open ? '&#9658;' : '&#9660;';
  1344.    }}
  1345.  }} else if (naam) {{
  1346.    bladernInMap(naam.closest('.boom-node').dataset.pad);
  1347.  }}
  1348. }}
  1349.  
  1350. async function laadTree(forceer) {{
  1351.  if (_treeData && !forceer) return;
  1352.  try {{
  1353.    const resp = await fetch('/tree');
  1354.    if (!resp.ok) throw new Error('HTTP ' + resp.status);
  1355.    const d = await resp.json();
  1356.    _treeData = d;
  1357.    _archiefDir    = d.archief    || '';
  1358.    _datumloosDir  = d.datumloos  || '';
  1359.    _prullenbakDir = d.prullenbak || '';
  1360.    _renderMaandBoom(d.jm || {{}});
  1361.    _renderAlbumBoom(d.archief_boom    || [], 'tree-verwerkt');
  1362.    _renderAlbumBoom(d.datumloos_boom  || [], 'tree-datumloos');
  1363.    _renderPrullenbakBoom(d.prullenbak_boom || [], 'tree-prullenbak');
  1364.  }} catch(err) {{
  1365.    console.error('Tree laden mislukt:', err);
  1366.    document.getElementById('tree-verwerkt').innerHTML =
  1367.      `<span style="color:#c66;font-size:.7rem;padding:4px 10px;display:block">Fout: ${{err.message}}</span>`;
  1368.  }}
  1369. }}
  1370.  
  1371. function _verversTree() {{
  1372.  _treeData = null;
  1373.  laadTree(true);
  1374. }}
  1375.  
  1376.  
  1377. // ── Drag foto's en mappen ─────────────────────────────────────────────────────
  1378. const _dragTooltip = () => document.getElementById('drag-tooltip');
  1379. let _dragMapPad = null;
  1380.  
  1381. function fotoDragStart(e, el) {{
  1382.  if (!el.classList.contains('geselecteerd')) toggle(el);
  1383.  e.dataTransfer.effectAllowed = 'move';
  1384.  e.dataTransfer.setData('fc-foto', '1');
  1385.  const n = selectie.size || 1;
  1386.  const tt = _dragTooltip();
  1387.  if (tt) {{
  1388.    tt.textContent = n + ' foto' + (n !== 1 ? "'s" : '');
  1389.    tt.style.display = 'block';
  1390.  }}
  1391. }}
  1392.  
  1393. function mapDragStart(e, pad) {{
  1394.  e.stopPropagation();
  1395.  _dragMapPad = pad;
  1396.  e.dataTransfer.effectAllowed = 'move';
  1397.  e.dataTransfer.setData('fc-map', pad);
  1398.  const tt = _dragTooltip();
  1399.  if (tt) {{
  1400.    tt.textContent = '\U0001F4C1 ' + pad.split('/').pop();
  1401.    tt.style.display = 'block';
  1402.  }}
  1403. }}
  1404.  
  1405. document.addEventListener('dragend', () => {{
  1406.  _dragMapPad = null;
  1407.  const tt = _dragTooltip();
  1408.  if (tt) tt.style.display = 'none';
  1409.  document.querySelectorAll('.boom-rij.drag-over,.maand-rij.drag-over,.ts-hdr.drag-over').forEach(r => r.classList.remove('drag-over'));
  1410. }});
  1411.  
  1412. document.addEventListener('dragover', e => {{
  1413.  const tt = _dragTooltip();
  1414.  if (tt && tt.style.display !== 'none') {{
  1415.    tt.style.left = (e.clientX + 16) + 'px';
  1416.    tt.style.top  = (e.clientY - 10) + 'px';
  1417.  }}
  1418. }});
  1419.  
  1420. function _boomDragOver(e, rij) {{
  1421.  if (!e.dataTransfer.types.includes('fc-foto') && !e.dataTransfer.types.includes('fc-map')) return;
  1422.  e.preventDefault();
  1423.  e.dataTransfer.dropEffect = 'move';
  1424.  rij.classList.add('drag-over');
  1425. }}
  1426.  
  1427. function _sectionDragOver(e, rij) {{
  1428.  if (!e.dataTransfer.types.includes('fc-map')) return;
  1429.  e.preventDefault();
  1430.  e.dataTransfer.dropEffect = 'move';
  1431.  rij.classList.add('drag-over');
  1432. }}
  1433.  
  1434. function _boomDragLeave(rij) {{
  1435.  rij.classList.remove('drag-over');
  1436. }}
  1437.  
  1438. async function _boomDrop(e, pad) {{
  1439.  e.preventDefault();
  1440.  document.querySelectorAll('.boom-rij.drag-over,.maand-rij.drag-over,.ts-hdr.drag-over').forEach(r => r.classList.remove('drag-over'));
  1441.  if (e.dataTransfer.types.includes('fc-map')) {{
  1442.    const vanPad = _dragMapPad; _dragMapPad = null;
  1443.    if (!vanPad || vanPad === pad || pad.startsWith(vanPad + '/')) return;
  1444.    const r = await fetch('/verplaatsmap', {{
  1445.      method: 'POST',
  1446.      headers: {{'Content-Type': 'application/json'}},
  1447.      body: JSON.stringify({{van: vanPad, naar: pad}})
  1448.    }});
  1449.    const d = await r.json();
  1450.    document.getElementById('status').textContent = d.bericht || d.fout || '';
  1451.    if (d.fout) alert('Fout: ' + d.fout);
  1452.    else {{ _verversTree(); if (huidigePad && huidigePad.startsWith(vanPad)) bladernInMap(d.nieuw_pad); }}
  1453.    return;
  1454.  }}
  1455.  if (!e.dataTransfer.types.includes('fc-foto')) return;
  1456.  const paden = [...selectie.keys()];
  1457.  if (!paden.length) return;
  1458.  const r = await fetch('/verplaats', {{
  1459.    method: 'POST',
  1460.    headers: {{'Content-Type': 'application/json'}},
  1461.    body: JSON.stringify({{paden, pad}})
  1462.  }});
  1463.  const d = await r.json();
  1464.  document.getElementById('status').textContent = d.bericht;
  1465.  if (d.jm) verversNav(d.jm);
  1466.  selectie.clear();
  1467.  updateSidebar();
  1468.  _verversTree();
  1469.  _herlaadHuidig();
  1470. }}
  1471.  
  1472. async function _sectionDrop(e, sectie) {{
  1473.  e.preventDefault();
  1474.  document.querySelectorAll('.ts-hdr.drag-over').forEach(r => r.classList.remove('drag-over'));
  1475.  if (!e.dataTransfer.types.includes('fc-map')) return;
  1476.  const vanPad = _dragMapPad; _dragMapPad = null;
  1477.  if (!vanPad) return;
  1478.  const naarRoot = sectie === 'datumloos' ? _datumloosDir : _archiefDir;
  1479.  if (!naarRoot) return;
  1480.  const r = await fetch('/verplaatsmap', {{
  1481.    method: 'POST',
  1482.    headers: {{'Content-Type': 'application/json'}},
  1483.    body: JSON.stringify({{van: vanPad, naar: naarRoot}})
  1484.  }});
  1485.  const d = await r.json();
  1486.  document.getElementById('status').textContent = d.bericht || d.fout || '';
  1487.  if (d.fout) alert('Fout: ' + d.fout);
  1488.  else _verversTree();
  1489. }}
  1490.  
  1491. const MAANDEN_NL = ['januari','februari','maart','april','mei','juni','juli','augustus','september','oktober','november','december'];
  1492. function formatDag(ddmmyyyy) {{
  1493.  const [d,m,y] = ddmmyyyy.split('-');
  1494.  return `${{parseInt(d)}} ${{MAANDEN_NL[parseInt(m)-1]}} ${{y}}`;
  1495. }}
  1496.  
  1497. function verversNav(jm) {{
  1498.  // Ververst alleen de in-behandeling-sectie van de boom
  1499.  if (!jm) return;
  1500.  _renderMaandBoom(jm);
  1501. }}
  1502.  
  1503. async function bladernInMap(pad) {{
  1504.  huidigePad   = pad;
  1505.  huidigeMaand = null;
  1506.  document.querySelectorAll('.maand-rij').forEach(r => r.classList.remove('actief'));
  1507.  document.querySelectorAll('.boom-naam').forEach(n => n.classList.remove('actief'));
  1508.  const node = [...document.querySelectorAll('.boom-node')].find(n => n.dataset.pad === pad);
  1509.  if (node) node.querySelector('.boom-naam').classList.add('actief');
  1510.  const inPb = _prullenbakDir && pad.startsWith(_prullenbakDir);
  1511.  document.getElementById('fotos-wrap').classList.toggle('prullenbak-modus', !!inPb);
  1512.  document.getElementById('status').textContent = 'Huidige map: ' + pad;
  1513.  updateSidebar();
  1514.  let data;
  1515.  try {{
  1516.    const resp = await fetch('/fotos?pad=' + encodeURIComponent(pad));
  1517.    data = await resp.json();
  1518.  }} catch(e) {{
  1519.    document.getElementById('fotos').innerHTML = `<p style="color:#c66">Fout: ${{e.message}}</p>`;
  1520.    return;
  1521.  }}
  1522.  renderFotos(data.groepen || []);
  1523. }}
  1524.  
  1525. function _herlaadHuidig() {{
  1526.  if (huidigeMaand) laadMaand(huidigeMaand, null);
  1527.  else if (huidigePad) bladernInMap(huidigePad);
  1528. }}
  1529.  
  1530. // ── Activiteitslog (full-width) ───────────────────────────────────────────────
  1531. const recenteActiviteit = [];
  1532. function toggleActiviteit() {{
  1533.  const body  = document.getElementById('activiteit-body');
  1534.  const pijl  = document.getElementById('activiteit-pijl');
  1535.  const open = body.style.display === 'block';
  1536.  body.style.display = open ? 'none' : 'block';
  1537.  if (pijl) pijl.innerHTML = open ? '&#9658;' : '&#9660;';
  1538. }}
  1539.  
  1540. function voegActiviteitToe(bestanden) {{
  1541.  if (!bestanden || !bestanden.length) return;
  1542.  const nu = new Date().toLocaleTimeString('nl-NL', {{hour:'2-digit',minute:'2-digit'}});
  1543.  for (const b of bestanden) recenteActiviteit.unshift({{...b, ts: nu}});
  1544.  if (recenteActiviteit.length > 100) recenteActiviteit.length = 100;
  1545.  const el = document.getElementById('activiteit-lijst');
  1546.  if (!el) return;
  1547.  el.innerHTML = recenteActiviteit.map(b => {{
  1548.    const map = b.naar ? b.naar.split('/').slice(0, -1).join('/') : '';
  1549.    return `<div class="act-rij">
  1550.      <span class="act-ts">${{b.ts}}</span>
  1551.      <span class="act-naam"> ${{b.naam}}</span>
  1552.      <span class="act-naar"> → ${{map || b.naar || ''}}</span>
  1553.    </div>`;
  1554.  }}).join('');
  1555.  const cnt = document.getElementById('activiteit-count');
  1556.  if (cnt) cnt.textContent = '(' + recenteActiviteit.length + ')';
  1557.  // Open log
  1558.  const body = document.getElementById('activiteit-body');
  1559.  if (body && body.style.display !== 'block') toggleActiviteit();
  1560. }}
  1561.  
  1562. // ── Hernoemen ─────────────────────────────────────────────────────────────────
  1563. let _hernoemPad = null;
  1564.  
  1565. function toonHernoemModal(pad) {{
  1566.  _hernoemPad = pad;
  1567.  const naam = pad.split('/').pop();
  1568.  document.getElementById('hernoem-huidig').textContent = 'Huidige naam: ' + naam;
  1569.  const inp = document.getElementById('hernoem-naam');
  1570.  inp.value = naam;
  1571.  document.getElementById('hernoem-hint').textContent = '';
  1572.  document.getElementById('hernoem-ok').disabled = true;
  1573.  document.getElementById('hernoem-modal').style.display = 'flex';
  1574.  inp.focus(); inp.select();
  1575. }}
  1576.  
  1577. function sluitHernoemModal() {{
  1578.  document.getElementById('hernoem-modal').style.display = 'none';
  1579.  _hernoemPad = null;
  1580. }}
  1581.  
  1582. function valideerHernoem() {{
  1583.  const val = document.getElementById('hernoem-naam').value.trim();
  1584.  const huidig = _hernoemPad ? _hernoemPad.split('/').pop() : '';
  1585.  const hint = document.getElementById('hernoem-hint');
  1586.  const ok   = document.getElementById('hernoem-ok');
  1587.  if (!val || val === huidig) {{ hint.textContent = ''; ok.disabled = true; return; }}
  1588.  if (/[/\\\\:*?"<>|]/.test(val)) {{
  1589.    hint.textContent = 'Naam mag geen / \\ : * ? " < > | bevatten.'; ok.disabled = true; return;
  1590.  }}
  1591.  hint.textContent = '';
  1592.  ok.disabled = false;
  1593. }}
  1594.  
  1595. async function bevestigHernoem() {{
  1596.  const nieuw = document.getElementById('hernoem-naam').value.trim();
  1597.  if (!nieuw || !_hernoemPad) return;
  1598.  const r = await fetch('/hernoem', {{
  1599.    method: 'POST',
  1600.    headers: {{'Content-Type': 'application/json'}},
  1601.    body: JSON.stringify({{pad: _hernoemPad, naam: nieuw}})
  1602.  }});
  1603.  const d = await r.json();
  1604.  sluitHernoemModal();
  1605.  if (d.fout) {{ alert('Fout: ' + d.fout); return; }}
  1606.  document.getElementById('status').textContent = 'Hernoemd naar ' + nieuw;
  1607.  _verversTree();
  1608.  if (huidigePad && huidigePad.startsWith(_hernoemPad)) bladernInMap(d.nieuw_pad || huidigePad);
  1609.  else _herlaadHuidig();
  1610. }}
  1611.  
  1612. // ── Nieuwe map ────────────────────────────────────────────────────────────────
  1613. let _nieuweMapSectie = null;   // 'verwerkt' | 'datumloos' | null
  1614. let _nieuweMapOuder  = null;   // pad van oudermap of null
  1615.  
  1616. function toonNieuweMapModal(sectie, ouder) {{
  1617.  _nieuweMapSectie = sectie;
  1618.  _nieuweMapOuder  = ouder || null;
  1619.  const inp  = document.getElementById('nieuwmap-naam');
  1620.  const uitl = document.getElementById('nieuwmap-uitleg');
  1621.  let placeholder = '2026-04-02 Vakantie';
  1622.  if (sectie === 'datumloos' || (_nieuweMapOuder && _nieuweMapOuder.startsWith(_datumloosDir))) {{
  1623.    placeholder = 'Marktplaats/Babybedje';
  1624.    uitl.textContent = 'Vrije mapnaam. Gebruik / voor submappen.';
  1625.  }} else if (ouder) {{
  1626.    placeholder = 'Subalbum naam';
  1627.    uitl.textContent = 'Submap van: ' + ouder;
  1628.  }} else {{
  1629.    uitl.textContent = 'Begin bij voorkeur met een datum: 2026-04-02 Omschrijving';
  1630.  }}
  1631.  const aantalSel = selectie.size;
  1632.  document.getElementById('nieuwmap-titel').textContent =
  1633.    aantalSel > 0 ? `Nieuwe map + ${{aantalSel}} foto'${{aantalSel!==1?'s':''}} verplaatsen` : 'Nieuwe map aanmaken';
  1634.  inp.placeholder = placeholder;
  1635.  
  1636.  // Datumrange voorvullen vanuit geselecteerde foto's (alleen voor verwerkt-sectie)
  1637.  const isDatumloosCtx = sectie === 'datumloos' ||
  1638.    (_nieuweMapOuder && _nieuweMapOuder.startsWith(_datumloosDir));
  1639.  let voorvul = '';
  1640.  if (!isDatumloosCtx && aantalSel > 0) {{
  1641.    const datums = [...document.querySelectorAll('.foto.geselecteerd[data-datum]')]
  1642.      .map(el => el.dataset.datum).filter(d => d && d.length === 10).sort();
  1643.    if (datums.length) {{
  1644.      const eerste = datums[0];
  1645.      const laatste = datums[datums.length - 1];
  1646.      voorvul = eerste === laatste ? eerste + ' ' : eerste + ' - ' + laatste + ' ';
  1647.    }}
  1648.  }}
  1649.  inp.value = voorvul;
  1650.  document.getElementById('nieuwmap-hint').textContent = '';
  1651.  document.getElementById('nieuwmap-ok').disabled = true;
  1652.  document.getElementById('nieuwmap-modal').style.display = 'flex';
  1653.  // Cursor aan het einde plaatsen
  1654.  inp.focus();
  1655.  inp.setSelectionRange(inp.value.length, inp.value.length);
  1656.  if (voorvul) valideerNieuweMap();
  1657. }}
  1658.  
  1659. function sluitNieuweMapModal() {{
  1660.  document.getElementById('nieuwmap-modal').style.display = 'none';
  1661.  _nieuweMapSectie = null; _nieuweMapOuder = null;
  1662. }}
  1663.  
  1664. function valideerNieuweMap() {{
  1665.  const val  = document.getElementById('nieuwmap-naam').value.trim();
  1666.  const hint = document.getElementById('nieuwmap-hint');
  1667.  const staat = document.getElementById('nieuwmap-staat');
  1668.  const ok   = document.getElementById('nieuwmap-ok');
  1669.  if (!val) {{ hint.textContent = ''; staat.className = 'album-staat'; ok.disabled = true; return; }}
  1670.  if (/[\\\\:*?"<>|]/.test(val)) {{
  1671.    hint.textContent = 'Naam mag geen \\ : * ? " < > | bevatten.'; staat.className = 'album-staat rood'; ok.disabled = true; return;
  1672.  }}
  1673.  const isDatumloos = _nieuweMapSectie === 'datumloos' ||
  1674.    (_nieuweMapOuder && _nieuweMapOuder.startsWith(_datumloosDir));
  1675.  if (!isDatumloos && !/^\d{{4}}-\d{{2}}-\d{{2}}/.test(val) && !_nieuweMapOuder) {{
  1676.    hint.textContent = 'Tip: begin met een datum voor betere sortering.';
  1677.    staat.className = 'album-staat oranje';
  1678.  }} else {{
  1679.    hint.textContent = '';
  1680.    staat.className = 'album-staat groen';
  1681.  }}
  1682.  ok.disabled = false;
  1683. }}
  1684.  
  1685. async function bevestigNieuweMap() {{
  1686.  const naam = document.getElementById('nieuwmap-naam').value.trim();
  1687.  if (!naam) return;
  1688.  const paden = [...selectie.keys()];
  1689.  const r = await fetch('/maakmap', {{
  1690.    method: 'POST',
  1691.    headers: {{'Content-Type': 'application/json'}},
  1692.    body: JSON.stringify({{
  1693.      naam,
  1694.      sectie: _nieuweMapSectie,
  1695.      ouder: _nieuweMapOuder,
  1696.      paden: paden.length ? paden : []
  1697.    }})
  1698.  }});
  1699.  const d = await r.json();
  1700.  sluitNieuweMapModal();
  1701.  if (d.fout) {{ alert('Fout: ' + d.fout); return; }}
  1702.  document.getElementById('status').textContent = d.bericht;
  1703.  if (d.jm) verversNav(d.jm);
  1704.  if (paden.length) {{ selectie.clear(); updateSidebar(); _herlaadHuidig(); }}
  1705.  _verversTree();
  1706. }}
  1707.  
  1708. // ── Info-help ──────────────────────────────────────────────────────────────────
  1709. function toonInfoHelp() {{
  1710.  document.getElementById('info-help-modal').style.display = 'flex';
  1711. }}
  1712.  
  1713. async function laadMaand(maand, knop) {{
  1714.  huidigeMaand = maand;
  1715.  huidigePad   = null;
  1716.  localStorage.setItem('fc_maand', maand);
  1717.  document.getElementById('status').textContent = '';
  1718.  document.querySelectorAll('.boom-naam').forEach(n => n.classList.remove('actief'));
  1719.  document.querySelectorAll('.maand-rij').forEach(r => r.classList.remove('actief'));
  1720.  // Activeer juiste rij in behandeling-boom
  1721.  const rij = document.getElementById('mr-' + maand) ||
  1722.              document.querySelector(`.maand-rij[data-maand="${{maand}}"]`);
  1723.  if (rij) {{
  1724.    rij.classList.add('actief');
  1725.    // Zorg dat het juiste jaar open staat
  1726.    const jaar = maand.slice(0, 4);
  1727.    _openJaar(jaar);
  1728.    // Zorg dat sectie open is
  1729.    const tsb = document.getElementById('tsb-behandeling');
  1730.    if (tsb && !tsb.classList.contains('open')) toggleTS('behandeling');
  1731.  }}
  1732.  // Haal alleen metadata op — snel
  1733.  let data;
  1734.  try {{
  1735.    const resp = await fetch('/fotos?maand=' + maand);
  1736.    if (!resp.ok) throw new Error(`Server fout: ${{resp.status}}`);
  1737.    data = await resp.json();
  1738.  }} catch(e) {{
  1739.    document.getElementById('fotos').innerHTML = `<p style="color:#c66">Fout bij laden: ${{e.message}}</p>`;
  1740.    return;
  1741.  }}
  1742.  renderFotos(data.groepen || []);
  1743. }}
  1744.  
  1745. function renderFotos(groepen) {{
  1746.  _laatsteKlik = null;
  1747.  const div = document.getElementById('fotos');
  1748.  if (!groepen.length) {{ div.innerHTML = "<p style='color:#666'>Geen foto's.</p>"; return; }}
  1749.  let html = '';
  1750.  huidigeGroepen = groepen;
  1751.  let huidigedag = null;
  1752.  groepen.forEach((g, gi) => {{
  1753.    const fotos = g.fotos;
  1754.    const type  = g.type;
  1755.    const dagDatum = fotos.length ? fotos[0].datum : null;
  1756.    if (dagDatum && dagDatum !== huidigedag) {{
  1757.      huidigedag = dagDatum;
  1758.      html += `<div class="dag-header">${{formatDag(dagDatum)}}</div>`;
  1759.    }}
  1760.    if (type === 'tight') {{
  1761.      const span = g.span_sec < 60 ? `${{g.span_sec}} seconden` : `${{Math.round(g.span_sec/60)}} minuten`;
  1762.      const label = `${{fotos.length}} foto's geschoten binnen ${{span}} — klik om te reviewen`;
  1763.      html += `<div class="burst tight"><div class="burst-label" onclick="openLightbox(${{gi}}, event)">${{label}}</div><div class="grid">`;
  1764.    }} else if (type === 'moment') {{
  1765.      const span = g.span_sec < 60 ? `${{g.span_sec}} seconden` : `${{Math.round(g.span_sec/60)}} minuten`;
  1766.      const label = `${{fotos.length}} vergelijkbare foto's geschoten binnen ${{span}} — klik om te reviewen`;
  1767.      html += `<div class="burst moment"><div class="burst-label" onclick="openLightbox(${{gi}}, event)">${{label}}</div><div class="grid">`;
  1768.    }} else {{
  1769.      html += '<div class="grid" style="margin-bottom:8px">';
  1770.    }}
  1771.    fotos.forEach(f => {{
  1772.      const sel = selectie.has(f.rel) ? ' geselecteerd' : '';
  1773.      const thumbUrl = '/thumb?pad=' + encodeURIComponent(f.rel);
  1774.      const ext = f.naam.includes('.') ? f.naam.split('.').pop().toLowerCase() : '';
  1775.      html += `<div class="foto${{sel}}" draggable="true" data-pad="${{f.rel}}" data-naam="${{f.naam}}" data-thumb="${{thumbUrl}}" data-datum="${{f.datum_iso||''}}" data-datum-nl="${{f.datum||''}}" data-grootte="${{f.grootte||0}}" onclick="toggle(this,event)" ondragstart="fotoDragStart(event,this)">
  1776.        ${{ext ? `<span class="ext-badge">${{ext}}</span>` : ''}}
  1777.        <button class="foto-del" title="Naar prullenbak" onclick="verwijderFoto(event,this)">×</button>
  1778.        <button class="foto-zoom" title="Vergroten" onclick="openFotoZoom(event,this)">⤢</button>
  1779.        <button class="foto-info-btn" title="Info" onclick="toonFotoInfo(event,this)">ℹ</button>
  1780.        <img src="${{thumbUrl}}" alt="${{f.naam}}" draggable="false">
  1781.        <div class="naam">${{f.naam}}</div>
  1782.        <div class="datum">${{f.datum}}${{f.tijdstip ? `<span class="foto-tijdstip">${{f.tijdstip}}</span>` : ''}}</div></div>`;
  1783.    }});
  1784.    html += '</div>';
  1785.    if (type !== 'los') html += '</div>';
  1786.  }});
  1787.  div.innerHTML = html;
  1788.  
  1789.  // Bladwijzer: herstel na page load
  1790.  if (_herstelde_anker) {{
  1791.    const anker = _herstelde_anker;
  1792.    _herstelde_anker = null;
  1793.    setTimeout(() => {{
  1794.      const el = [...div.querySelectorAll('.foto')].find(f => f.dataset.pad === anker);
  1795.      if (el) {{
  1796.        plaatsBladwijzer(el);
  1797.        el.scrollIntoView({{block: 'center', behavior: 'smooth'}});
  1798.      }}
  1799.    }}, 150);
  1800.  }}
  1801.  
  1802.  // Progress op basis van daadwerkelijk geladen thumbnails
  1803.  const imgs = [...div.querySelectorAll('img')];
  1804.  const totaal = imgs.length;
  1805.  if (!totaal) return;
  1806.  const wrap = document.getElementById('progress-wrap');
  1807.  const bar = document.getElementById('progress');
  1808.  let geladen = 0;
  1809.  wrap.style.display = 'block';
  1810.  bar.style.width = '0%';
  1811.  function tick() {{
  1812.    geladen++;
  1813.    bar.style.width = Math.round(geladen / totaal * 100) + '%';
  1814.    if (geladen >= totaal) setTimeout(() => {{ wrap.style.display = 'none'; bar.style.width = '0%'; }}, 300);
  1815.  }}
  1816.  imgs.forEach(img => {{
  1817.    if (img.complete) {{ tick(); }} else {{ img.addEventListener('load', tick); img.addEventListener('error', tick); }}
  1818.  }});
  1819. }}
  1820.  
  1821. let _laatsteKlik = null;
  1822.  
  1823. function toggle(el, e) {{
  1824.  const pad = el.dataset.pad;
  1825.  const allesFotos = [...document.querySelectorAll('.foto')];
  1826.  
  1827.  if (e && e.shiftKey && _laatsteKlik && _laatsteKlik !== el) {{
  1828.    // Selecteer bereik tussen _laatsteKlik en el
  1829.    const van = allesFotos.indexOf(_laatsteKlik);
  1830.    const tot = allesFotos.indexOf(el);
  1831.    const [begin, eind] = van < tot ? [van, tot] : [tot, van];
  1832.    const aanzetten = !el.classList.contains('geselecteerd');
  1833.    allesFotos.slice(begin, eind + 1).forEach(f => {{
  1834.      if (aanzetten) {{
  1835.        f.classList.add('geselecteerd');
  1836.        selectie.set(f.dataset.pad, {{ naam: f.dataset.naam, thumb: f.dataset.thumb }});
  1837.      }} else {{
  1838.        f.classList.remove('geselecteerd');
  1839.        selectie.delete(f.dataset.pad);
  1840.      }}
  1841.    }});
  1842.  }} else {{
  1843.    if (el.classList.toggle('geselecteerd')) {{
  1844.      selectie.set(pad, {{ naam: el.dataset.naam, thumb: el.dataset.thumb }});
  1845.    }} else {{
  1846.      selectie.delete(pad);
  1847.    }}
  1848.  }}
  1849.  
  1850.  _laatsteKlik = el;
  1851.  updateSidebar();
  1852. }}
  1853.  
  1854. function deselecteer(pad) {{
  1855.  selectie.delete(pad);
  1856.  const el = document.querySelector(`.foto[data-pad="${{pad}}"]`);
  1857.  if (el) el.classList.remove('geselecteerd');
  1858.  updateSidebar();
  1859. }}
  1860.  
  1861. function updateSidebar() {{
  1862.  const sb  = document.getElementById('sidebar');
  1863.  const btn = document.getElementById('btn-verwijder');
  1864.  const n   = selectie.size;
  1865.  const inPb = huidigePad && _prullenbakDir && huidigePad.startsWith(_prullenbakDir);
  1866.  if (btn) {{
  1867.    btn.disabled = n === 0 || !!inPb;
  1868.    btn.textContent = n > 0 ? `\U0001F5D1 Verwijder (${{n}})` : '\U0001F5D1 Verwijder';
  1869.  }}
  1870.  if (n === 0) {{
  1871.    sb.innerHTML = '<h3>Geselecteerd</h3><p class="sb-leeg">Nog niets geselecteerd.</p>';
  1872.    return;
  1873.  }}
  1874.  let html = `<h3>Geselecteerd (${{n}})</h3>`;
  1875.  for (const [pad, {{naam, thumb}}] of selectie) {{
  1876.    const escapedPad = pad.replace(/'/g, "\\'");
  1877.    html += `<div class="sb-item">
  1878.      <img class="sb-thumb" src="${{thumb}}" alt="">
  1879.      <span class="sb-naam" title="${{naam}}">${{naam}}</span>
  1880.      <button type="button" class="sb-x" onclick="deselecteer('${{escapedPad}}')">✕</button>
  1881.    </div>`;
  1882.  }}
  1883.  sb.innerHTML = html;
  1884. }}
  1885.  
  1886. async function verwijderSelectie() {{
  1887.  const paden = [...selectie.keys()];
  1888.  if (!paden.length) return;
  1889.  const n = paden.length;
  1890.  if (!confirm(`Weet je zeker dat je ${{n}} foto${{n !== 1 ? "'s" : ''}} naar de prullenbak wilt verplaatsen?`)) return;
  1891.  const r = await fetch('/prullenbak', {{
  1892.    method: 'POST',
  1893.    headers: {{'Content-Type': 'application/json'}},
  1894.    body: JSON.stringify({{paden, submap: 'losse_items'}})
  1895.  }});
  1896.  const d = await r.json();
  1897.  document.getElementById('status').textContent = `${{d.verplaatst || 0}} foto\u2019s naar prullenbak verplaatst.`;
  1898.  if (d.jm) verversNav(d.jm);
  1899.  selectie.clear();
  1900.  updateSidebar();
  1901.  _verversTree();
  1902.  _herlaadHuidig();
  1903. }}
  1904.  
  1905. function allesToggle() {{
  1906.  const alle = [...document.querySelectorAll('.foto')];
  1907.  const aanZetten = alle.some(e => !e.classList.contains('geselecteerd'));
  1908.  alle.forEach(e => {{
  1909.    e.classList.toggle('geselecteerd', aanZetten);
  1910.    const pad = e.dataset.pad;
  1911.    if (aanZetten) selectie.set(pad, {{ naam: e.dataset.naam, thumb: e.dataset.thumb }});
  1912.    else selectie.delete(pad);
  1913.  }});
  1914.  updateSidebar();
  1915. }}
  1916.  
  1917. async function bewaarLater() {{
  1918.  if (!selectie.size) {{ alert("Selecteer eerst foto's"); return; }}
  1919.  const paden = [...selectie.keys()];
  1920.  const r = await fetch('/verplaats', {{method:'POST', headers:{{'Content-Type':'application/json'}}, body:JSON.stringify({{paden, album:'Later uitzoeken'}})}});
  1921.  const d = await r.json();
  1922.  document.getElementById('status').textContent = d.bericht;
  1923.  if (d.jm) verversNav(d.jm);
  1924.  selectie.clear();
  1925.  updateSidebar();
  1926.  _verversTree();
  1927.  _herlaadHuidig();
  1928. }}
  1929.  
  1930. let _infoPad = null;
  1931.  
  1932. function _infoRij(lbl, val) {{
  1933.  return `<div class="info-rij"><span class="info-lbl">${{lbl}}</span><span class="info-val">${{val}}</span></div>`;
  1934. }}
  1935.  
  1936. async function toonFotoInfo(e, btn) {{
  1937.  e.stopPropagation();
  1938.  const rel = btn.closest('.foto').dataset.pad;
  1939.  const panel = document.getElementById('info-panel');
  1940.  if (_infoPad === rel && panel.style.display !== 'none') {{
  1941.    sluitInfoPanel(); return;
  1942.  }}
  1943.  _infoPad = rel;
  1944.  // Positioneer naast de knop
  1945.  const rect = btn.getBoundingClientRect();
  1946.  const px = Math.min(rect.right + 8, window.innerWidth - 286);
  1947.  const py = Math.max(rect.top, 8);
  1948.  panel.style.left = px + 'px';
  1949.  panel.style.top  = py + 'px';
  1950.  panel.style.display = 'block';
  1951.  document.getElementById('info-naam').textContent = rel.split('/').pop();
  1952.  document.getElementById('info-inhoud').innerHTML = '<span style="color:#555">Laden…</span>';
  1953.  document.getElementById('info-kaart').style.display = 'none';
  1954.  try {{
  1955.    const d = await (await fetch('/info?pad=' + encodeURIComponent(rel))).json();
  1956.    let html = '';
  1957.    html += _infoRij('Map', d.map || '—');
  1958.    html += _infoRij('Datum', d.datum || '—');
  1959.    html += _infoRij('Grootte', fmtBytes(d.grootte));
  1960.    if (d.gps) {{
  1961.      const osmUrl = `https://www.openstreetmap.org/?mlat=${{d.gps.lat}}&mlon=${{d.gps.lng}}#map=15/${{d.gps.lat}}/${{d.gps.lng}}`;
  1962.      html += _infoRij('GPS', `<a href="${{osmUrl}}" target="_blank" style="color:#4a8abf">${{d.gps.lat}}, ${{d.gps.lng}}</a>`);
  1963.    }}
  1964.    if (d.locatie) html += _infoRij('Locatie', d.locatie);
  1965.    document.getElementById('info-inhoud').innerHTML = html || '<span style="color:#555">Geen extra info.</span>';
  1966.    if (d.gps) {{
  1967.      const kaart = document.getElementById('info-kaart');
  1968.      const b = 0.008;
  1969.      kaart.src = `https://www.openstreetmap.org/export/embed.html?bbox=${{d.gps.lng-b}},${{d.gps.lat-b}},${{d.gps.lng+b}},${{d.gps.lat+b}}&layer=mapnik&marker=${{d.gps.lat}},${{d.gps.lng}}`;
  1970.      kaart.style.display = 'block';
  1971.    }}
  1972.  }} catch(_) {{
  1973.    document.getElementById('info-inhoud').innerHTML = '<span style="color:#c66">Fout bij laden.</span>';
  1974.  }}
  1975. }}
  1976.  
  1977. function sluitInfoPanel() {{
  1978.  document.getElementById('info-panel').style.display = 'none';
  1979.  _infoPad = null;
  1980. }}
  1981.  
  1982. document.addEventListener('click', function(e) {{
  1983.  const panel = document.getElementById('info-panel');
  1984.  if (panel && panel.style.display !== 'none' &&
  1985.      !panel.contains(e.target) && !e.target.closest('.foto-info-btn')) {{
  1986.    sluitInfoPanel();
  1987.  }}
  1988. }});
  1989.  
  1990. async function verwijderFoto(e, btn) {{
  1991.  e.stopPropagation();
  1992.  const el = btn.closest('.foto');
  1993.  const rel = el.dataset.pad;
  1994.  el.style.opacity = '.3';
  1995.  el.style.pointerEvents = 'none';
  1996.  const r = await fetch('/prullenbak', {{
  1997.    method: 'POST',
  1998.    headers: {{'Content-Type': 'application/json'}},
  1999.    body: JSON.stringify({{paden: [rel], submap: 'losse_items'}})
  2000.  }});
  2001.  const d = await r.json();
  2002.  el.remove();
  2003.  selectie.delete(rel);
  2004.  updateSidebar();
  2005.  if (d.jm) verversNav(d.jm);
  2006. }}
  2007.  
  2008. function toonVoortgang(d) {{
  2009.  const modal = document.getElementById('reset-modal');
  2010.  if (modal.style.display !== 'none') {{
  2011.    // Reset bezig — toon in modal
  2012.    document.getElementById('reset-keuze').style.display = 'none';
  2013.    document.getElementById('reset-voortgang').style.display = '';
  2014.    const pct = d.totaal > 0 ? Math.round(d.stap / d.totaal * 100) : 0;
  2015.    document.getElementById('reset-balk').style.width = pct + '%';
  2016.    document.getElementById('reset-fase-label').textContent =
  2017.      (d.fase === 'download' ? 'Downloaden' : 'Presorteren') + ` (${{d.stap}}/${{d.totaal}})`;
  2018.    document.getElementById('reset-bericht').textContent = d.bericht || '';
  2019.  }} else {{
  2020.    // Standalone presort — toon in voortgangsbalk bovenin
  2021.    const pct = d.totaal > 0 ? Math.round(d.stap / d.totaal * 100) : 0;
  2022.    document.getElementById('progress').style.width = pct + '%';
  2023.    document.getElementById('status').textContent = `Presorteren: ${{d.stap}}/${{d.totaal}} — ${{d.bericht}}`;
  2024.  }}
  2025. }}
  2026.  
  2027. function resetTest() {{
  2028.  document.getElementById('reset-keuze').style.display = '';
  2029.  document.getElementById('reset-voortgang').style.display = 'none';
  2030.  document.getElementById('reset-modal').style.display = 'flex';
  2031. }}
  2032.  
  2033. async function resetTestBevestigd() {{
  2034.  const aantal = parseInt(document.getElementById('reset-aantal').value);
  2035.  document.getElementById('fotos').innerHTML = '';
  2036.  selectie.clear();
  2037.  updateSidebar();
  2038.  huidigeMaand = null;
  2039.  await fetch('/reset', {{
  2040.    method: 'POST',
  2041.    headers: {{'Content-Type': 'application/json'}},
  2042.    body: JSON.stringify({{aantal}})
  2043.  }});
  2044.  // Wacht op reset_klaar via SSE — modal blijft open en toont voortgang
  2045. }}
  2046.  
  2047. async function presortRun() {{
  2048.  document.getElementById('progress').style.width = '0%';
  2049.  document.getElementById('status').textContent = 'Presort starten…';
  2050.  await fetch('/presort', {{method: 'POST'}});
  2051. }}
  2052.  
  2053. async function clearThumbs() {{
  2054.  document.getElementById('status').textContent = 'Cache wissen…';
  2055.  const r = await fetch('/clearthumbs', {{method: 'POST'}});
  2056.  const d = await r.json();
  2057.  document.getElementById('status').textContent = d.ok ? 'Cache gewist.' : 'Fout bij wissen.';
  2058. }}
  2059.  
  2060. async function opruimLege() {{
  2061.  const r = await fetch('/opruim', {{method: 'POST'}});
  2062.  const d = await r.json();
  2063.  document.getElementById('status').textContent = d.bericht;
  2064.  if (d.verwijderd > 0) {{ verversNav(d.jm || {{}}); _verversTree(); }}
  2065. }}
  2066.  
  2067. // ── Zoom-knop → lightbox ─────────────────────────────────────────────────────
  2068. function openFotoZoom(e, btn) {{
  2069.  e.stopPropagation();
  2070.  const el = btn.closest('.foto');
  2071.  lb_groep = [{{
  2072.    rel:     el.dataset.pad,
  2073.    naam:    el.dataset.naam,
  2074.    datum:   el.dataset.datumNl || el.dataset.datum || '',
  2075.    grootte: parseInt(el.dataset.grootte || '0'),
  2076.  }}];
  2077.  lb_index = 0;
  2078.  lb_hartjes.clear();
  2079.  document.getElementById('lb').style.display = 'flex';
  2080.  document.body.style.overflow = 'hidden';
  2081.  renderLightbox();
  2082. }}
  2083.  
  2084. // ── Lightbox ──────────────────────────────────────────────────────────────────
  2085. let huidigeGroepen = [];   // wordt gezet in renderFotos
  2086. let lb_groep = [];         // fotos in huidige lightbox
  2087. let lb_index = 0;          // startindex huidige pagina
  2088. const lb_hartjes = new Set(); // rel-paden met hartje
  2089.  
  2090. function lbPerPagina() {{ return window.innerWidth < 600 ? 1 : 2; }}
  2091.  
  2092. function fmtBytes(b) {{
  2093.  if (b < 1024) return b + ' B';
  2094.  if (b < 1024*1024) return (b/1024).toFixed(0) + ' KB';
  2095.  return (b/1024/1024).toFixed(1) + ' MB';
  2096. }}
  2097.  
  2098. function openLightbox(gi, evt) {{
  2099.  evt.stopPropagation();
  2100.  lb_groep = huidigeGroepen[gi].fotos;
  2101.  lb_index = 0;
  2102.  lb_hartjes.clear();
  2103.  document.getElementById('lb').style.display = 'flex';
  2104.  document.body.style.overflow = 'hidden';
  2105.  renderLightbox();
  2106. }}
  2107.  
  2108. function sluitLightbox() {{
  2109.  document.getElementById('lb').style.display = 'none';
  2110.  document.body.style.overflow = '';
  2111. }}
  2112.  
  2113. function lbStap(richting) {{
  2114.  const pp = lbPerPagina();
  2115.  const nieuw = lb_index + richting * pp;
  2116.  if (nieuw < 0 || nieuw >= lb_groep.length) return;
  2117.  lb_index = nieuw;
  2118.  renderLightbox();
  2119. }}
  2120.  
  2121. function lbToggleHart(rel) {{
  2122.  if (lb_hartjes.has(rel)) lb_hartjes.delete(rel);
  2123.  else lb_hartjes.add(rel);
  2124.  renderLightbox();
  2125. }}
  2126.  
  2127. function renderLightbox() {{
  2128.  const pp = lbPerPagina();
  2129.  const totaal = lb_groep.length;
  2130.  const fotos = lb_groep.slice(lb_index, lb_index + pp);
  2131.  const eind = Math.min(lb_index + pp, totaal);
  2132.  
  2133.  document.getElementById('lb-teller').textContent =
  2134.    `foto ${{lb_index + 1}}–${{eind}} van ${{totaal}}`;
  2135.  
  2136.  // Foto-panelen
  2137.  const wrap = document.getElementById('lb-fotos');
  2138.  wrap.innerHTML = fotos.map(f => {{
  2139.    const metHart = lb_hartjes.has(f.rel);
  2140.    const hartKls = metHart ? ' actief' : '';
  2141.    const imgKls  = metHart ? ' met-hart' : '';
  2142.    const fullUrl = '/full?pad=' + encodeURIComponent(f.rel);
  2143.    return `<div class="lb-foto-wrap">
  2144.      <img src="${{fullUrl}}" class="${{imgKls}}" onclick="lbToggleHart('${{f.rel.replace(/'/g,"\\\\'")}}')" title="Klik of H voor hartje">
  2145.      <div class="lb-foto-info">
  2146.        <span>${{f.naam}}<br><span style="color:#666">${{f.datum}} · ${{fmtBytes(f.grootte)}}</span></span>
  2147.        <button type="button" class="lb-hart${{hartKls}}" onclick="lbToggleHart('${{f.rel.replace(/'/g,"\\\\'")}}')" title="H">❤</button>
  2148.      </div>
  2149.    </div>`;
  2150.  }}).join('');
  2151.  
  2152.  // Navigatie
  2153.  document.getElementById('lb-prev').disabled = lb_index <= 0;
  2154.  document.getElementById('lb-next').disabled = lb_index + pp >= totaal;
  2155.  
  2156.  // Info + bevestig-knop
  2157.  const bewaard = lb_hartjes.size;
  2158.  const weg     = totaal - bewaard;
  2159.  document.getElementById('lb-info').textContent =
  2160.    bewaard > 0
  2161.      ? `${{bewaard}} bewaard, ${{weg}} naar prullenbak`
  2162.      : 'Geef minstens 1 foto een hartje om te bevestigen';
  2163.  document.getElementById('lb-bevestig').disabled = bewaard === 0;
  2164. }}
  2165.  
  2166. async function bevestigLightbox() {{
  2167.  const bewaard  = new Set(lb_hartjes);
  2168.  const weg      = lb_groep.map(f => f.rel).filter(r => !bewaard.has(r));
  2169.  if (weg.length > 0) {{
  2170.    const resp = await fetch('/prullenbak', {{
  2171.      method: 'POST',
  2172.      headers: {{'Content-Type': 'application/json'}},
  2173.      body: JSON.stringify({{paden: weg, submap: 'duplicaten'}})
  2174.    }});
  2175.    const d = await resp.json();
  2176.    if (d.jm) verversNav(d.jm);
  2177.    _verversTree();
  2178.  }}
  2179.  sluitLightbox();
  2180.  _herlaadHuidig();
  2181. }}
  2182.  
  2183. // Toetsenbord
  2184. document.addEventListener('keydown', e => {{
  2185.  if (document.getElementById('lb').style.display !== 'flex') return;
  2186.  if (e.key === 'ArrowLeft')  {{ lbStap(-1); e.preventDefault(); }}
  2187.  if (e.key === 'ArrowRight') {{ lbStap(1);  e.preventDefault(); }}
  2188.  if (e.key === 'Escape') sluitLightbox();
  2189.  if (e.key === 'h' || e.key === 'H') {{
  2190.    // Hart geven aan alle zichtbare foto's op huidige pagina
  2191.    const pp = lbPerPagina();
  2192.    lb_groep.slice(lb_index, lb_index + pp).forEach(f => lb_hartjes.add(f.rel));
  2193.    renderLightbox();
  2194.  }}
  2195. }});
  2196.  
  2197. // Swipe (mobiel)
  2198. (function() {{
  2199.  let tx = 0;
  2200.  const el = document.getElementById('lb');
  2201.  el.addEventListener('touchstart', e => {{ tx = e.touches[0].clientX; }}, {{passive:true}});
  2202.  el.addEventListener('touchend', e => {{
  2203.    const dx = e.changedTouches[0].clientX - tx;
  2204.    if (Math.abs(dx) > 50) lbStap(dx < 0 ? 1 : -1);
  2205.  }}, {{passive:true}});
  2206. }})();
  2207.  
  2208. // Breedte slider + persistence
  2209. function sliderVerander(val) {{
  2210.  document.documentElement.style.setProperty('--foto-breedte', val + 'px');
  2211.  document.getElementById('breedte-label').textContent = val + 'px';
  2212.  localStorage.setItem('fc_breedte', val);
  2213. }}
  2214. (function() {{
  2215.  const opgeslagen = localStorage.getItem('fc_breedte');
  2216.  if (opgeslagen) {{
  2217.    const s = document.getElementById('breedte-slider');
  2218.    if (s) {{ s.value = opgeslagen; sliderVerander(opgeslagen); }}
  2219.  }}
  2220. }})();
  2221.  
  2222. // ── Resizable left-panel ──────────────────────────────────────────────────────
  2223. (function() {{
  2224.  const resizer = document.getElementById('left-resizer');
  2225.  const panel   = document.getElementById('left-panel');
  2226.  if (!resizer || !panel) return;
  2227.  const LS_KEY = 'fc_left_w';
  2228.  const opgeslagenW = localStorage.getItem(LS_KEY);
  2229.  if (opgeslagenW) document.documentElement.style.setProperty('--left-w', opgeslagenW + 'px');
  2230.  let startX, startW;
  2231.  resizer.addEventListener('mousedown', e => {{
  2232.    startX = e.clientX;
  2233.    startW = panel.offsetWidth;
  2234.    resizer.classList.add('dragging');
  2235.    document.addEventListener('mousemove', onMove);
  2236.    document.addEventListener('mouseup', onUp);
  2237.    e.preventDefault();
  2238.  }});
  2239.  function onMove(e) {{
  2240.    const w = Math.max(140, Math.min(520, startW + e.clientX - startX));
  2241.    document.documentElement.style.setProperty('--left-w', w + 'px');
  2242.  }}
  2243.  function onUp() {{
  2244.    resizer.classList.remove('dragging');
  2245.    document.removeEventListener('mousemove', onMove);
  2246.    document.removeEventListener('mouseup', onUp);
  2247.    localStorage.setItem(LS_KEY, panel.offsetWidth);
  2248.  }}
  2249. }})();
  2250.  
  2251. // Bladwijzer
  2252. function plaatsBladwijzer(el) {{
  2253.  document.querySelectorAll('.bladwijzer').forEach(b => b.remove());
  2254.  const fotos = document.getElementById('fotos');
  2255.  let node = el;
  2256.  while (node.parentElement && node.parentElement !== fotos) node = node.parentElement;
  2257.  const bw = document.createElement('div');
  2258.  bw.className = 'bladwijzer';
  2259.  bw.textContent = 'hier gebleven';
  2260.  fotos.insertBefore(bw, node);
  2261. }}
  2262.  
  2263. // Scroll positie + anker opslaan
  2264. let _scrollTimer = null;
  2265. document.addEventListener('scroll', (ev) => {{
  2266.  if (!ev.target.closest || !ev.target.closest('#fotos')) return;
  2267.  clearTimeout(_scrollTimer);
  2268.  _scrollTimer = setTimeout(() => {{
  2269.    if (!huidigeMaand) return;
  2270.    const eerst = [...document.querySelectorAll('.foto')].find(f => f.getBoundingClientRect().bottom > 80);
  2271.    if (eerst) localStorage.setItem('fc_anker', eerst.dataset.pad);
  2272.  }}, 600);
  2273. }}, {{passive: true, capture: true}});
  2274.  
  2275. // Tree laden bij start + herstel sessie
  2276. laadTree(true).then(() => {{
  2277.  const maand = localStorage.getItem('fc_maand');
  2278.  if (maand) laadMaand(maand, null);
  2279. }});
  2280.  
  2281. // SSE live updates
  2282. (function() {{
  2283.  function verbind() {{
  2284.    const es = new EventSource('/events');
  2285.    es.addEventListener('message', function(e) {{
  2286.      try {{
  2287.        const d = JSON.parse(e.data);
  2288.        if (d.type === 'update') {{
  2289.          if (d.jm) verversNav(d.jm);
  2290.          if (d.bestanden && d.bestanden.length) voegActiviteitToe(d.bestanden);
  2291.          if (huidigeMaand) laadMaand(huidigeMaand, null);
  2292.        }} else if (d.type === 'progress') {{
  2293.          toonVoortgang(d);
  2294.        }} else if (d.type === 'reset_klaar') {{
  2295.          location.reload();
  2296.        }} else if (d.type === 'reset_fout') {{
  2297.          document.getElementById('reset-fase-label').textContent = 'Fout!';
  2298.          document.getElementById('reset-bericht').textContent = d.bericht || 'Onbekende fout.';
  2299.          document.getElementById('reset-bericht').style.color = '#f66';
  2300.        }} else if (d.type === 'presort_klaar') {{
  2301.          document.getElementById('progress').style.width = '0%';
  2302.          document.getElementById('status').textContent = 'Presort klaar.';
  2303.          if (d.jm) verversNav(d.jm);
  2304.          _verversTree();
  2305.        }} else if (d.type === 'presort_fout') {{
  2306.          document.getElementById('progress').style.width = '0%';
  2307.          document.getElementById('status').textContent = 'Presort fout: ' + (d.bericht || '?');
  2308.        }}
  2309.      }} catch(_) {{}}
  2310.    }});
  2311.    es.addEventListener('error', function() {{ es.close(); setTimeout(verbind, 5000); }});
  2312.  }}
  2313.  verbind();
  2314. }})();
  2315. </script>
  2316. </body>
  2317. </html>"""
  2318.  
  2319.     class Handler(BaseHTTPRequestHandler):
  2320.         def log_message(self, *_): pass
  2321.  
  2322.         def send_json(self, data, code=200):
  2323.             body = _json.dumps(data).encode()
  2324.             self.send_response(code)
  2325.             self.send_header("Content-Type", "application/json")
  2326.             self.send_header("Content-Length", len(body))
  2327.             self.end_headers()
  2328.             self.wfile.write(body)
  2329.  
  2330.         def do_GET(self):
  2331.             parsed = urlparse(self.path)
  2332.  
  2333.             if parsed.path == "/info":
  2334.                 params = parse_qs(parsed.query)
  2335.                 rel = params.get("pad", [""])[0]
  2336.                 try:
  2337.                     pad = (BASE_DIR / rel).resolve()
  2338.                     if not pad.is_relative_to(BASE_DIR.resolve()) or not pad.is_file():
  2339.                         self.send_json({"fout": "niet gevonden"}, 404); return
  2340.                 except Exception:
  2341.                     self.send_json({"fout": "ongeldig pad"}, 400); return
  2342.                 stat = pad.stat()
  2343.                 datum = lees_datum(pad)
  2344.                 gps = _lees_gps(pad)
  2345.                 locatie = _reverse_geocode(gps['lat'], gps['lng']) if gps else None
  2346.                 self.send_json({
  2347.                     "map": str(pad.parent.relative_to(BASE_DIR)),
  2348.                     "grootte": stat.st_size,
  2349.                     "datum": datum.strftime("%d-%m-%Y %H:%M"),
  2350.                     "gps": gps,
  2351.                     "locatie": locatie,
  2352.                 })
  2353.                 return
  2354.  
  2355.             if parsed.path == "/thumb":
  2356.                 params = parse_qs(parsed.query)
  2357.                 rel = params.get("pad", [""])[0]
  2358.                 try:
  2359.                     pad = (BASE_DIR / rel).resolve()
  2360.                     if not pad.is_relative_to(BASE_DIR.resolve()):
  2361.                         raise ValueError("Pad buiten BASE_DIR")
  2362.                 except (ValueError, OSError):
  2363.                     self.send_response(403); self.end_headers(); return
  2364.                 if not pad.exists() or not pad.is_file():
  2365.                     self.send_response(404); self.end_headers(); return
  2366.                 data = get_thumbnail(pad)
  2367.                 self.send_response(200)
  2368.                 self.send_header("Content-Type", "image/jpeg")
  2369.                 self.send_header("Content-Length", len(data))
  2370.                 self.send_header("Cache-Control", "public, max-age=86400")
  2371.                 self.end_headers()
  2372.                 self.wfile.write(data)
  2373.                 return
  2374.  
  2375.             if parsed.path == "/full":
  2376.                 params = parse_qs(parsed.query)
  2377.                 rel = params.get("pad", [""])[0]
  2378.                 try:
  2379.                     pad = (BASE_DIR / rel).resolve()
  2380.                     if not pad.is_relative_to(BASE_DIR.resolve()):
  2381.                         raise ValueError("Pad buiten BASE_DIR")
  2382.                 except (ValueError, OSError):
  2383.                     self.send_response(403); self.end_headers(); return
  2384.                 if not pad.exists() or not pad.is_file():
  2385.                     self.send_response(404); self.end_headers(); return
  2386.                 # Serveer origineel, converteer non-JPEG naar JPEG via Pillow/rawpy
  2387.                 ext = pad.suffix.lower()
  2388.                 if ext in ('.jpg', '.jpeg'):
  2389.                     data = pad.read_bytes()
  2390.                 elif ext == '.arw' and HAS_RAWPY:
  2391.                     # ARW: extraheer ingebedde JPEG-preview op maximale grootte
  2392.                     try:
  2393.                         import io as _io
  2394.                         with _rawpy.imread(str(pad)) as raw:
  2395.                             thumb = raw.extract_thumb()
  2396.                         if thumb.format.name == 'JPEG':
  2397.                             data = thumb.data
  2398.                         elif thumb.format.name == 'BITMAP':
  2399.                             img = Image.fromarray(thumb.data).convert("RGB")
  2400.                             buf = _io.BytesIO()
  2401.                             img.save(buf, format="JPEG", quality=92)
  2402.                             data = buf.getvalue()
  2403.                         else:
  2404.                             data = _maak_placeholder(pad.name)
  2405.                     except Exception:
  2406.                         data = _maak_placeholder(pad.name)
  2407.                 else:
  2408.                     try:
  2409.                         import io as _io
  2410.                         img = Image.open(pad)
  2411.                         from PIL import ImageOps
  2412.                         img = ImageOps.exif_transpose(img).convert("RGB")
  2413.                         buf = _io.BytesIO()
  2414.                         img.save(buf, format="JPEG", quality=90)
  2415.                         data = buf.getvalue()
  2416.                     except Exception:
  2417.                         data = _maak_placeholder(pad.name)
  2418.                 self.send_response(200)
  2419.                 self.send_header("Content-Type", "image/jpeg")
  2420.                 self.send_header("Content-Length", len(data))
  2421.                 self.send_header("Cache-Control", "public, max-age=3600")
  2422.                 self.end_headers()
  2423.                 self.wfile.write(data)
  2424.                 return
  2425.  
  2426.             if parsed.path == "/fotos":
  2427.                 params = parse_qs(parsed.query)
  2428.                 maand    = params.get("maand", [""])[0]
  2429.                 pad_par  = params.get("pad",   [""])[0]
  2430.                 try:
  2431.                     if pad_par:
  2432.                         pad_abs = (BASE_DIR / pad_par).resolve()
  2433.                         if not pad_abs.is_relative_to(BASE_DIR.resolve()):
  2434.                             raise ValueError("Pad buiten BASE_DIR")
  2435.                         fotos = zoek_fotos(pad_abs) if pad_abs.is_dir() else []
  2436.                     else:
  2437.                         fotos = fotos_in_map(maand)
  2438.                     groepen_raw = burst_groepen(fotos)
  2439.                     groepen_out = []
  2440.                     for g in groepen_raw:
  2441.                         items = []
  2442.                         for f in g["fotos"]:
  2443.                             rel = str(f.relative_to(BASE_DIR))
  2444.                             datum = lees_datum(f)
  2445.                             grootte = f.stat().st_size
  2446.                             items.append({"rel": rel, "naam": f.name, "datum": datum.strftime("%d-%m-%Y"), "datum_iso": datum.strftime("%Y-%m-%d"), "tijdstip": datum.strftime("%H:%M"), "grootte": grootte})
  2447.                         groepen_out.append({"fotos": items, "type": g["type"], "span_sec": g.get("span_sec", 0)})
  2448.                     self.send_json({"groepen": groepen_out})
  2449.                 except Exception as e:
  2450.                     import traceback
  2451.                     traceback.print_exc()
  2452.                     self.send_json({"fout": str(e), "groepen": []}, 500)
  2453.                 return
  2454.  
  2455.             if parsed.path == "/jm":
  2456.                 self.send_json(jaren_maanden())
  2457.                 return
  2458.  
  2459.             if parsed.path == "/tree":
  2460.                 _skip = {'@eadir', '#recycle', '.recycle', '.ds_store', 'thumbs.db'}
  2461.                 def _boom(pad, diepte=0):
  2462.                     if diepte > 6:
  2463.                         return []
  2464.                     result = []
  2465.                     try:
  2466.                         for sub in sorted(pad.iterdir(), key=lambda x: x.name.lower()):
  2467.                             if sub.is_dir() and not sub.name.startswith(('.', '@', '#')) \
  2468.                                     and sub.name.lower() not in _skip:
  2469.                                 try:
  2470.                                     aantal = sum(1 for f in sub.rglob("*")
  2471.                                                  if f.is_file() and f.suffix.lower() in FOTO_EXTS)
  2472.                                 except Exception:
  2473.                                     aantal = 0
  2474.                                 result.append({
  2475.                                     "naam": sub.name,
  2476.                                     "pad": str(sub.relative_to(BASE_DIR)),
  2477.                                     "submappen": _boom(sub, diepte + 1),
  2478.                                     "aantal": aantal,
  2479.                                 })
  2480.                     except Exception:
  2481.                         pass
  2482.                     return result
  2483.  
  2484.                 datumloos_pad = BASE_DIR / DATUMLOOS_DIR
  2485.                 datumloos_pad.mkdir(parents=True, exist_ok=True)
  2486.                 prullenbak_pad = BASE_DIR / PRULLENBAK_DIR
  2487.                 self.send_json({
  2488.                     "archief": ARCHIEF_DIR,
  2489.                     "archief_boom": _boom(archief),
  2490.                     "datumloos": DATUMLOOS_DIR,
  2491.                     "datumloos_boom": _boom(datumloos_pad),
  2492.                     "prullenbak": PRULLENBAK_DIR,
  2493.                     "prullenbak_boom": _boom(prullenbak_pad) if prullenbak_pad.exists() else [],
  2494.                     "jm": jaren_maanden(),
  2495.                 })
  2496.                 return
  2497.  
  2498.             if parsed.path == "/events":
  2499.                 q = _queue.Queue()
  2500.                 with _sse_lock:
  2501.                     _sse_queues.append(q)
  2502.                 self.send_response(200)
  2503.                 self.send_header("Content-Type", "text/event-stream")
  2504.                 self.send_header("Cache-Control", "no-cache")
  2505.                 self.send_header("Connection", "keep-alive")
  2506.                 self.send_header("X-Accel-Buffering", "no")
  2507.                 self.end_headers()
  2508.                 try:
  2509.                     while True:
  2510.                         try:
  2511.                             msg = q.get(timeout=20)
  2512.                             self.wfile.write(f"data: {msg}\n\n".encode())
  2513.                             self.wfile.flush()
  2514.                         except _queue.Empty:
  2515.                             self.wfile.write(b": heartbeat\n\n")
  2516.                             self.wfile.flush()
  2517.                 except Exception:
  2518.                     pass
  2519.                 finally:
  2520.                     with _sse_lock:
  2521.                         try: _sse_queues.remove(q)
  2522.                         except ValueError: pass
  2523.                 return
  2524.  
  2525.             html = render_hoofdpagina().encode()
  2526.             self.send_response(200)
  2527.             self.send_header("Content-Type", "text/html; charset=utf-8")
  2528.             self.send_header("Content-Length", len(html))
  2529.             self.end_headers()
  2530.             self.wfile.write(html)
  2531.  
  2532.         def do_POST(self):
  2533.             path = urlparse(self.path).path
  2534.             length = int(self.headers.get("Content-Length", 0))
  2535.             body = self.rfile.read(length) if length else b""
  2536.  
  2537.             if path == "/verplaats":
  2538.                 data = _json.loads(body)
  2539.                 base_resolved = BASE_DIR.resolve()
  2540.                 # Ondersteuning voor zowel 'album' (→ archief/album) als 'pad' (→ BASE_DIR/pad)
  2541.                 if "pad" in data:
  2542.                     raw_pad = data["pad"]
  2543.                     try:
  2544.                         doel_map = (BASE_DIR / raw_pad).resolve()
  2545.                         if not doel_map.is_relative_to(base_resolved):
  2546.                             self.send_json({"fout": "pad buiten BASE_DIR"}, 400); return
  2547.                     except Exception:
  2548.                         self.send_json({"fout": "ongeldig pad"}, 400); return
  2549.                 else:
  2550.                     album = data["album"]
  2551.                     doel_map = archief / Path(album)
  2552.                 doel_map.mkdir(parents=True, exist_ok=True)
  2553.                 count = 0
  2554.                 for rel in data["paden"]:
  2555.                     try:
  2556.                         src = (BASE_DIR / rel).resolve()
  2557.                         if not src.is_relative_to(base_resolved):
  2558.                             continue
  2559.                     except (ValueError, OSError):
  2560.                         continue
  2561.                     if src.exists():
  2562.                         dst = uniek_doel(doel_map, src.name)
  2563.                         shutil.move(str(src), dst)
  2564.                         count += 1
  2565.                 album_label = str(doel_map.relative_to(BASE_DIR))
  2566.                 sla_recent_op(album_label)
  2567.                 verwijder_lege_mappen()
  2568.                 immich_rescan()
  2569.                 self.send_json({
  2570.                     "bericht": f"{count} foto('s) verplaatst naar '{album_label}'",
  2571.                     "jm": jaren_maanden(),
  2572.                 })
  2573.  
  2574.             elif path == "/prullenbak":
  2575.                 data = _json.loads(body)
  2576.                 submap = data.get("submap", "losse_items")
  2577.                 if submap not in ("duplicaten", "losse_items"):
  2578.                     submap = "losse_items"
  2579.                 prullenbak = BASE_DIR / PRULLENBAK_DIR / submap
  2580.                 prullenbak.mkdir(parents=True, exist_ok=True)
  2581.                 base_resolved = BASE_DIR.resolve()
  2582.                 count = 0
  2583.                 for rel in data["paden"]:
  2584.                     try:
  2585.                         src = (BASE_DIR / rel).resolve()
  2586.                         if not src.is_relative_to(base_resolved):
  2587.                             continue
  2588.                     except (ValueError, OSError):
  2589.                         continue
  2590.                     if src.exists():
  2591.                         dst = uniek_doel(prullenbak, src.name)
  2592.                         shutil.move(str(src), dst)
  2593.                         count += 1
  2594.                 verwijder_lege_mappen()
  2595.                 immich_rescan()
  2596.                 self.send_json({"verplaatst": count, "jm": jaren_maanden()})
  2597.  
  2598.             elif path == "/recents":
  2599.                 data = _json.loads(body)
  2600.                 recents = laad_recents()
  2601.                 if "verwijder" in data:
  2602.                     album = data["verwijder"]
  2603.                     if album in recents:
  2604.                         recents.remove(album)
  2605.                     RECENTS_FILE.write_text(_json.dumps(recents))
  2606.                     self.send_json({"recents": recents})
  2607.                 elif "volgorde" in data:
  2608.                     volgorde = [str(a) for a in data["volgorde"] if isinstance(a, str)]
  2609.                     RECENTS_FILE.write_text(_json.dumps(volgorde[:10]))
  2610.                     self.send_json({"recents": volgorde[:10]})
  2611.                 else:
  2612.                     self.send_json({"fout": "onbekende actie"}, 400)
  2613.  
  2614.             elif path == "/reset":
  2615.                 req = _json.loads(body) if body else {}
  2616.                 aantal = max(20, min(400, int(req.get("aantal", 40))))
  2617.  
  2618.                 def doe_reset(n=aantal):
  2619.                     _watcher_pauze[0] = True
  2620.                     try:
  2621.                         def dl_cb(stap, tot, bericht):
  2622.                             _push_sse(_json.dumps({"type": "progress", "fase": "download",
  2623.                                                    "stap": stap, "totaal": tot, "bericht": bericht}))
  2624.                         def ps_cb(stap, tot, bericht):
  2625.                             _push_sse(_json.dumps({"type": "progress", "fase": "presort",
  2626.                                                    "stap": stap, "totaal": tot, "bericht": bericht}))
  2627.                         maak_testdata(aantal=n, progress_cb=dl_cb)
  2628.                         presort(progress_cb=ps_cb)
  2629.                         _push_sse(_json.dumps({"type": "reset_klaar", "jm": jaren_maanden()}))
  2630.                     except Exception as _exc:
  2631.                         import traceback as _tb
  2632.                         _log(f"[Reset] Fout: {_exc}\n{_tb.format_exc()}")
  2633.                         _push_sse(_json.dumps({"type": "reset_fout", "bericht": str(_exc)}))
  2634.                     finally:
  2635.                         _watcher_pauze[0] = False
  2636.  
  2637.                 threading.Thread(target=doe_reset, daemon=True).start()
  2638.                 self.send_json({"ok": True})
  2639.  
  2640.             elif path == "/presort":
  2641.                 def doe_presort():
  2642.                     try:
  2643.                         def ps_cb(stap, tot, bericht):
  2644.                             _push_sse(_json.dumps({"type": "progress", "fase": "presort",
  2645.                                                    "stap": stap, "totaal": tot, "bericht": bericht}))
  2646.                         presort(progress_cb=ps_cb)
  2647.                         _push_sse(_json.dumps({"type": "presort_klaar", "jm": jaren_maanden()}))
  2648.                     except Exception as _exc:
  2649.                         import traceback as _tb
  2650.                         _log(f"[Presort] Fout: {_exc}\n{_tb.format_exc()}")
  2651.                         _push_sse(_json.dumps({"type": "presort_fout", "bericht": str(_exc)}))
  2652.  
  2653.                 threading.Thread(target=doe_presort, daemon=True).start()
  2654.                 self.send_json({"ok": True})
  2655.  
  2656.             elif path == "/clearthumbs":
  2657.                 gewist = 0
  2658.                 for p in [THUMBCACHE_DIR, DATUMCACHE_FILE]:
  2659.                     if p.exists():
  2660.                         if p.is_dir():
  2661.                             shutil.rmtree(p)
  2662.                         else:
  2663.                             p.unlink()
  2664.                         gewist += 1
  2665.                 THUMBCACHE_DIR.mkdir(exist_ok=True)
  2666.                 _log(f"[Cache] {gewist} cache-item(s) gewist")
  2667.                 self.send_json({"ok": True})
  2668.  
  2669.             elif path == "/opruim":
  2670.                 import re as _re
  2671.                 verwijderd = 0
  2672.                 if uitzoeken.exists():
  2673.                     for sub in list(uitzoeken.iterdir()):
  2674.                         if sub.is_dir() and _re.match(r'^\d{4}-\d{2}$', sub.name):
  2675.                             if not _heeft_echte_inhoud(sub):
  2676.                                 try:
  2677.                                     shutil.rmtree(sub)
  2678.                                     _log(f"[Opruimen] Lege map verwijderd: {sub.name}")
  2679.                                     verwijderd += 1
  2680.                                 except OSError as oe:
  2681.                                     _log(f"[Opruimen] Fout bij verwijderen {sub.name}: {oe}")
  2682.                 bericht = f"{verwijderd} lege map(pen) verwijderd." if verwijderd else "Geen lege mappen gevonden."
  2683.                 self.send_json({"bericht": bericht, "verwijderd": verwijderd, "jm": jaren_maanden()})
  2684.  
  2685.             elif path == "/hernoem":
  2686.                 data = _json.loads(body)
  2687.                 rel  = data.get("pad", "")
  2688.                 naam = data.get("naam", "").strip()
  2689.                 if not rel or not naam:
  2690.                     self.send_json({"fout": "pad of naam ontbreekt"}, 400); return
  2691.                 import re as _re
  2692.                 if _re.search(r'[/\\:*?"<>|]', naam):
  2693.                     self.send_json({"fout": "ongeldige tekens in naam"}, 400); return
  2694.                 try:
  2695.                     oud = (BASE_DIR / rel).resolve()
  2696.                     if not oud.is_relative_to(BASE_DIR.resolve()) or not oud.is_dir():
  2697.                         self.send_json({"fout": "map niet gevonden"}, 404); return
  2698.                     nieuw = oud.parent / naam
  2699.                     if nieuw.exists():
  2700.                         self.send_json({"fout": f"'{naam}' bestaat al"}); return
  2701.                     oud.rename(nieuw)
  2702.                     nieuw_rel = str(nieuw.relative_to(BASE_DIR))
  2703.                     _log(f"[Hernoem] {rel} → {nieuw_rel}")
  2704.                     self.send_json({"ok": True, "nieuw_pad": nieuw_rel})
  2705.                 except Exception as e:
  2706.                     self.send_json({"fout": str(e)}, 500)
  2707.  
  2708.             elif path == "/maakmap":
  2709.                 data   = _json.loads(body)
  2710.                 naam   = data.get("naam", "").strip()
  2711.                 sectie = data.get("sectie")  # 'verwerkt' | 'datumloos' | None
  2712.                 ouder  = data.get("ouder")   # pad relatief aan BASE_DIR of None
  2713.                 paden  = data.get("paden", [])
  2714.                 if not naam:
  2715.                     self.send_json({"fout": "naam ontbreekt"}, 400); return
  2716.                 import re as _re
  2717.                 if _re.search(r'[\\:*?"<>|]', naam):
  2718.                     self.send_json({"fout": "ongeldige tekens"}, 400); return
  2719.                 base_resolved = BASE_DIR.resolve()
  2720.                 try:
  2721.                     if ouder:
  2722.                         basis = (BASE_DIR / ouder).resolve()
  2723.                         if not basis.is_relative_to(base_resolved):
  2724.                             self.send_json({"fout": "ouder buiten BASE_DIR"}, 400); return
  2725.                     elif sectie == "datumloos":
  2726.                         basis = BASE_DIR / DATUMLOOS_DIR
  2727.                     else:
  2728.                         basis = archief  # standaard: verwerkt
  2729.                     doel_map = basis / Path(naam)
  2730.                     doel_map.mkdir(parents=True, exist_ok=True)
  2731.                     nieuw_rel = str(doel_map.relative_to(BASE_DIR))
  2732.                     count = 0
  2733.                     for rel in paden:
  2734.                         try:
  2735.                             src = (BASE_DIR / rel).resolve()
  2736.                             if not src.is_relative_to(base_resolved): continue
  2737.                         except (ValueError, OSError): continue
  2738.                         if src.exists():
  2739.                             dst = uniek_doel(doel_map, src.name)
  2740.                             shutil.move(str(src), dst)
  2741.                             count += 1
  2742.                     if count:
  2743.                         verwijder_lege_mappen()
  2744.                         immich_rescan()
  2745.                     _log(f"[NieuweMap] {nieuw_rel} ({count} foto's)")
  2746.                     self.send_json({
  2747.                         "bericht": f"Map '{naam}' aangemaakt" + (f", {count} foto('s) verplaatst" if count else ""),
  2748.                         "nieuw_pad": nieuw_rel,
  2749.                         "jm": jaren_maanden(),
  2750.                     })
  2751.                 except Exception as e:
  2752.                     self.send_json({"fout": str(e)}, 500)
  2753.  
  2754.             elif path == "/verplaatsmap":
  2755.                 data     = _json.loads(body)
  2756.                 van_rel  = data.get("van", "").strip()
  2757.                 naar_rel = data.get("naar", "").strip()
  2758.                 if not van_rel or not naar_rel:
  2759.                     self.send_json({"fout": "van of naar ontbreekt"}, 400); return
  2760.                 base_resolved = BASE_DIR.resolve()
  2761.                 try:
  2762.                     van_abs  = (BASE_DIR / van_rel).resolve()
  2763.                     naar_abs = (BASE_DIR / naar_rel).resolve()
  2764.                     if not van_abs.is_relative_to(base_resolved) or not van_abs.is_dir():
  2765.                         self.send_json({"fout": "bronmap niet gevonden"}, 404); return
  2766.                     if not naar_abs.is_relative_to(base_resolved):
  2767.                         self.send_json({"fout": "doelmap buiten BASE_DIR"}, 400); return
  2768.                     # Voorkom neerzetten in zichzelf of een submap
  2769.                     if naar_abs == van_abs or naar_abs.is_relative_to(van_abs):
  2770.                         self.send_json({"fout": "kan map niet in zichzelf verplaatsen"}, 400); return
  2771.                     naar_abs.mkdir(parents=True, exist_ok=True)
  2772.                     doel = uniek_doel(naar_abs, van_abs.name)
  2773.                     shutil.move(str(van_abs), doel)
  2774.                     nieuw_rel = str(doel.relative_to(BASE_DIR))
  2775.                     verwijder_lege_mappen()
  2776.                     _log(f"[VerplaatsMap] {van_rel} → {nieuw_rel}")
  2777.                     self.send_json({"bericht": f"Map verplaatst naar '{naar_rel}'", "nieuw_pad": nieuw_rel})
  2778.                 except Exception as e:
  2779.                     self.send_json({"fout": str(e)}, 500)
  2780.  
  2781.             else:
  2782.                 self.send_json({"fout": "onbekend pad"}, 404)
  2783.  
  2784.     try:
  2785.         s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
  2786.         s.connect(("8.8.8.8", 80))
  2787.         lan_ip = s.getsockname()[0]
  2788.         s.close()
  2789.     except Exception:
  2790.         lan_ip = "onbekend"
  2791.  
  2792.     print(f"Declutter draait op:")
  2793.     print(f"  http://localhost:{PORT}")
  2794.     print(f"  http://{lan_ip}:{PORT}")
  2795.  
  2796.     server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
  2797.     server.serve_forever()
  2798.  
  2799.  
  2800. # ── Entrypoint ────────────────────────────────────────────────────────────────
  2801.  
  2802. if __name__ == "__main__":
  2803.     args = set(sys.argv[1:])
  2804.  
  2805.     if "--clearcache" in args:
  2806.         for p in [THUMBCACHE_DIR, DATUMCACHE_FILE]:
  2807.             if p.exists():
  2808.                 if p.is_dir():
  2809.                     shutil.rmtree(p)
  2810.                 else:
  2811.                     p.unlink()
  2812.         print("Cache gewist.")
  2813.         sys.exit(0)
  2814.  
  2815.     check_dependencies()
  2816.     start_server()
  2817.  
Add Comment
Please, Sign In to add comment