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)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user