feat: Verwaiste-Bilder-Scanner — Orphans automatisch finden + per Dropdown zuordnen

- find_orphan_images(): scannt Workspace nach Bildern ohne article_images.json-Eintrag
- Gelbe Orphan-Box oben auf der Seite: alle verwaisten Bilder mit Artikel-Dropdown
- assignOrphan() JS: Dropdown-Auswahl → fetch /assign-image → Toast + Reload
- _handle_assign_image(): Backend validiert Bildpfad + Artikel + speichert Mapping
- Test-JPEGs aus media/ bereinigt, article_images.json sauber initialisiert
This commit is contained in:
Hermes Agent
2026-06-19 00:12:59 +02:00
parent 32d1e1ef86
commit 6e20684af4
+108
View File
@@ -88,6 +88,28 @@ def save_image_map(mapping: dict[str, list[str]]) -> None:
json.dump(mapping, f, indent=2) 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: def is_article(filename: str) -> bool:
for pattern in SKIP_PATTERNS: for pattern in SKIP_PATTERNS:
if re.search(pattern, filename, re.IGNORECASE): if re.search(pattern, filename, re.IGNORECASE):
@@ -175,6 +197,28 @@ def build_index(articles: list[dict]) -> str:
# Read image map for badge display # Read image map for badge display
img_map = get_image_map() img_map = get_image_map()
# ── Orphaned images section ──
orphan_imgs = find_orphan_images()
orphan_section = ""
if orphan_imgs:
article_opts = "\\n".join(
f'<option value="{a["filename"]}">{a["title"][:80]}</option>'
for a in articles
)
orphan_rows = "\\n".join(
f'''<div class="orphan-row" data-img="{p}">
<span class="orphan-preview">🖼️ {os.path.basename(p)}</span>
<select class="orphan-select" onchange="assignOrphan(this, '{p}')">
<option value="">→ Artikel zuordnen…</option>
{article_opts}
</select>
</div>'''
for p in orphan_imgs[:20] # max 20 to keep page fast
)
orphan_section = f'''<details class="orphan-box" open>
<summary>🖼️ {len(orphan_imgs)} verwaiste Bilder — keinem Artikel zugeordnet</summary>
<div class="orphan-grid">{orphan_rows}</div>
</details>'''
items_html = "" items_html = ""
for sort_key in sorted(by_date_sort.keys(), reverse=True): for sort_key in sorted(by_date_sort.keys(), reverse=True):
day_articles = by_date_sort[sort_key] day_articles = by_date_sort[sort_key]
@@ -345,6 +389,15 @@ def build_index(articles: list[dict]) -> str:
.row-file-input {{ display: none; }} .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 {{ 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.show {{ opacity: 1; transform: translateY(0); }}
.toast.error {{ background: var(--danger); box-shadow: 0 6px 24px rgba(192,57,43,0.3); }} .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:
<div class="rubric-item"><span class="rubric-id">55</span> Podcast</div> <div class="rubric-item"><span class="rubric-id">55</span> Podcast</div>
</div> </div>
</details> </details>
{orphan_section}
<div class="toolbar"> <div class="toolbar">
<button class="btn btn-select" onclick="selectAll()">Alle auswählen</button> <button class="btn btn-select" onclick="selectAll()">Alle auswählen</button>
<button class="btn btn-select" onclick="deselectAll()">Auswahl aufheben</button> <button class="btn btn-select" onclick="deselectAll()">Auswahl aufheben</button>
@@ -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'); const uploadZone = document.getElementById('uploadZone');
uploadZone.addEventListener('dragover', e => {{ e.preventDefault(); uploadZone.classList.add('dragover'); }}); uploadZone.addEventListener('dragover', e => {{ e.preventDefault(); uploadZone.classList.add('dragover'); }});
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover')); uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
@@ -775,6 +852,10 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
if not self._check_token(): if not self._check_token():
return return
self._handle_upload() self._handle_upload()
elif path == "/assign-image":
if not self._check_token():
return
self._handle_assign_image()
elif path == "/delete": elif path == "/delete":
if not self._check_auth(): if not self._check_auth():
return return
@@ -875,6 +956,33 @@ h1{color:#c0392b;}p{color:#888;}</style></head>
"article": article_file, "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): def _handle_delete(self):
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length) body = self.rfile.read(length)