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:
@@ -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'<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 = ""
|
||||
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:
|
||||
<div class="rubric-item"><span class="rubric-id">55</span> Podcast</div>
|
||||
</div>
|
||||
</details>
|
||||
{orphan_section}
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-select" onclick="selectAll()">Alle auswählen</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');
|
||||
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;}</style></head>
|
||||
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;}</style></head>
|
||||
"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)
|
||||
|
||||
Reference in New Issue
Block a user