diff --git a/article_server.py b/article_server.py index f38f706..eef61e9 100644 --- a/article_server.py +++ b/article_server.py @@ -88,6 +88,28 @@ def save_image_map(mapping: dict[str, list[str]]) -> None: json.dump(mapping, f, indent=2) +def find_orphan_images() -> list[str]: + """Find images in workspace that are NOT in article_images.json.""" + import glob as _glob + existing = set() + for paths in get_image_map().values(): + for p in paths: + existing.add(os.path.abspath(p)) + + orphans = [] + for ext in ("*.jpg", "*.jpeg", "*.png", "*.webp", "*.gif"): + for p in _glob.iglob(os.path.join(WORKSPACE, "**", ext), recursive=True): + # Skip bsn-chatbot, media dir (already managed), __pycache__, .git, venv + if "/bsn-chatbot/" in p or "/__pycache__/" in p or "/.git/" in p or "/venv/" in p: + continue + if "/image_cache/" in p: + continue + abspath = os.path.abspath(p) + if abspath not in existing: + orphans.append(abspath) + return sorted(orphans) + + def is_article(filename: str) -> bool: for pattern in SKIP_PATTERNS: if re.search(pattern, filename, re.IGNORECASE): @@ -175,6 +197,28 @@ def build_index(articles: list[dict]) -> str: # Read image map for badge display img_map = get_image_map() + # ── Orphaned images section ── + orphan_imgs = find_orphan_images() + orphan_section = "" + if orphan_imgs: + article_opts = "\\n".join( + f'' + for a in articles + ) + orphan_rows = "\\n".join( + f'''
+ 🖼️ {os.path.basename(p)} + +
''' + for p in orphan_imgs[:20] # max 20 to keep page fast + ) + orphan_section = f'''
+ 🖼️ {len(orphan_imgs)} verwaiste Bilder — keinem Artikel zugeordnet +
{orphan_rows}
+
''' items_html = "" for sort_key in sorted(by_date_sort.keys(), reverse=True): day_articles = by_date_sort[sort_key] @@ -345,6 +389,15 @@ def build_index(articles: list[dict]) -> str: .row-file-input {{ display: none; }} + /* Orphan images */ + .orphan-box {{ margin-bottom: 1rem; background: #fffbeb; border: 1px solid #fcd34d; border-radius: var(--radius); overflow: hidden; }} + .orphan-box summary {{ padding: 0.5rem 1rem; font-size: 0.82rem; font-weight: 600; color: #92400e; cursor: pointer; user-select: none; }} + .orphan-box summary:hover {{ color: #78350f; }} + .orphan-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0.4rem; padding: 0.5rem 1rem 0.7rem; }} + .orphan-row {{ display: flex; align-items: center; gap: 0.5rem; font-size: 0.78rem; }} + .orphan-preview {{ color: #1a1a2e; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200px; }} + .orphan-select {{ font-family: var(--font); font-size: 0.75rem; padding: 0.25rem 0.5rem; border: 1px solid #d1d5db; border-radius: 5px; background: #fff; max-width: 220px; }} + .toast {{ position: fixed; bottom: 2rem; right: 2rem; background: var(--success); color: #fff; padding: 0.8rem 1.6rem; border-radius: 10px; font-weight: 500; font-family: var(--font); box-shadow: 0 6px 24px rgba(16,185,129,0.3); opacity: 0; transform: translateY(16px); transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); pointer-events: none; z-index: 1000; }} .toast.show {{ opacity: 1; transform: translateY(0); }} .toast.error {{ background: var(--danger); box-shadow: 0 6px 24px rgba(192,57,43,0.3); }} @@ -395,6 +448,7 @@ def build_index(articles: list[dict]) -> str:
55 Podcast
+ {orphan_section}
@@ -524,6 +578,29 @@ def build_index(articles: list[dict]) -> str: }} }} + async function assignOrphan(select, imagePath) {{ + const article = select.value; + if (!article) return; + try {{ + const resp = await fetch('/assign-image?key={ACCESS_TOKEN}', {{ + method: 'POST', + headers: {{'Content-Type': 'application/json'}}, + body: JSON.stringify({{image: imagePath, article: article}}) + }}); + const data = await resp.json(); + if (data.ok) {{ + showToast('✅ Bild zugeordnet', false); + select.closest('.orphan-row').style.opacity = '0.4'; + select.disabled = true; + setTimeout(() => location.reload(), 1200); + }} else {{ + showToast('❌ ' + (data.error || 'Fehler'), true); + }} + }} catch(e) {{ + showToast('❌ ' + e.message, true); + }} + }} + const uploadZone = document.getElementById('uploadZone'); uploadZone.addEventListener('dragover', e => {{ e.preventDefault(); uploadZone.classList.add('dragover'); }}); uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover')); @@ -775,6 +852,10 @@ h1{color:#c0392b;}p{color:#888;} if not self._check_token(): return self._handle_upload() + elif path == "/assign-image": + if not self._check_token(): + return + self._handle_assign_image() elif path == "/delete": if not self._check_auth(): return @@ -875,6 +956,33 @@ h1{color:#c0392b;}p{color:#888;} "article": article_file, }) + def _handle_assign_image(self): + """Associate an existing image file with an article.""" + length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(length) + try: + data = json.loads(body) + image_path = data.get("image", "") + article_file = data.get("article", "") + except json.JSONDecodeError: + self._json_reply(400, {"ok": False, "error": "Invalid JSON"}) + return + + if not image_path or not os.path.exists(image_path): + self._json_reply(400, {"ok": False, "error": f"Bild nicht gefunden: {image_path}"}) + return + if not article_file: + self._json_reply(400, {"ok": False, "error": "Kein Artikel angegeben"}) + return + + img_map = get_image_map() + if article_file not in img_map: + img_map[article_file] = [] + if image_path not in img_map[article_file]: + img_map[article_file].append(image_path) + save_image_map(img_map) + self._json_reply(200, {"ok": True, "image": image_path, "article": article_file}) + def _handle_delete(self): length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length)