26f8ac477d
- Pillow installiert (fehlte → try/except lief ins Leere) - _detect_image_mime(): Magic-Bytes-Erkennung (JPEG/PNG/WebP/HEIC) - _gemini_generate_image(): kein hartes 'image/jpeg' mehr - PIL-Resize 1920×1080 greift jetzt korrekt
2251 lines
96 KiB
Python
2251 lines
96 KiB
Python
#!/usr/bin/env python3
|
||
"""Article Index Server — with IDs, meta extraction, image upload, and multi-threading.
|
||
v2: Added 'Markierte veröffentlichen' (Joomla API publish), article-image association,
|
||
and automatic VG-Wort tracking pixel injection.
|
||
v3: Added NanoBanana Image Generator (/nanobanana)."""
|
||
|
||
import os
|
||
import re
|
||
import glob
|
||
import json
|
||
import base64
|
||
import hashlib
|
||
import socket
|
||
import urllib.parse
|
||
import urllib.request
|
||
import io
|
||
import email.parser
|
||
import tempfile
|
||
import subprocess
|
||
import time
|
||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||
from datetime import datetime
|
||
from socketserver import ThreadingMixIn
|
||
|
||
WORKSPACE = "/home/hermes/workspace"
|
||
MEDIA_DIR = os.path.join(WORKSPACE, "media")
|
||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||
PORT = int(os.environ.get("ARTICLE_SERVER_PORT", "8890"))
|
||
ACCESS_TOKEN = "br3ttsp1el-n3ws-2026"
|
||
USERNAME = "DK-Adminchef"
|
||
PASSWORD = "Mcik7%vbdhXa_"
|
||
|
||
# ── Joomla API Configuration ──────────────────────────────────────────────
|
||
JOOMLA_API_URL = "https://www.brettspiel-news.de/api/index.php/v1/content/articles"
|
||
# JOOMLA_TOKEN is read from environment or from a token file
|
||
JOOMLA_TOKEN = os.environ.get("JOOMLA_API_TOKEN", "")
|
||
if not JOOMLA_TOKEN:
|
||
token_paths = [
|
||
"/tmp/alicia_skills/KEYS.txt",
|
||
"/home/hermes/.hermes/joomla_token.txt",
|
||
]
|
||
for tp in token_paths:
|
||
if os.path.exists(tp):
|
||
with open(tp, "r") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line and not line.startswith("#"):
|
||
JOOMLA_TOKEN = line
|
||
break
|
||
if JOOMLA_TOKEN:
|
||
break
|
||
|
||
# ── VG-Wort Configuration ─────────────────────────────────────────────────
|
||
VG_WORT_COUNTER_ID = os.environ.get("VG_WORT_COUNTER_ID", "")
|
||
VG_WORT_PIXEL_HTML = (
|
||
f'<img src="https://vg08.met.vgwort.de/na/{VG_WORT_COUNTER_ID}" '
|
||
f'width="1" height="1" alt="">'
|
||
) if VG_WORT_COUNTER_ID else ""
|
||
|
||
# ── Article-Image Mapping ─────────────────────────────────────────────────
|
||
IMAGE_MAP_FILE = os.path.join(WORKSPACE, "article_images.json")
|
||
|
||
MONTHS = {
|
||
"Januar": 1, "Februar": 2, "März": 3, "April": 4,
|
||
"Mai": 5, "Juni": 6, "Juli": 7, "August": 8,
|
||
"September": 9, "Oktober": 10, "November": 11, "Dezember": 12
|
||
}
|
||
|
||
# ── Gemini Flash Image API Configuration ────────────────────────────────────
|
||
GEMINI_IMAGE_MODEL = "gemini-2.5-flash-image"
|
||
GEMINI_API_URL = (
|
||
"https://generativelanguage.googleapis.com/v1beta/"
|
||
"models/" + GEMINI_IMAGE_MODEL + ":generateContent"
|
||
)
|
||
GEMINI_KEY_SECRET = "bsn/google-imagen-key"
|
||
|
||
|
||
def _get_gemini_key() -> str:
|
||
"""Google Gemini API-Key aus bsn-secrets lesen."""
|
||
try:
|
||
r = subprocess.run(
|
||
["/home/hermes/bin/bsn-secrets", "get", GEMINI_KEY_SECRET],
|
||
capture_output=True, text=True, timeout=5
|
||
)
|
||
return r.stdout.strip()
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _detect_image_mime(image_bytes: bytes) -> str:
|
||
"""MIME-Type anhand Magic-Bytes erkennen."""
|
||
if len(image_bytes) < 8:
|
||
return "image/jpeg" # Fallback
|
||
if image_bytes[:4] == b"\x89PNG":
|
||
return "image/png"
|
||
if image_bytes[:2] == b"\xff\xd8":
|
||
return "image/jpeg"
|
||
if image_bytes[:4] == b"RIFF" and image_bytes[8:12] == b"WEBP":
|
||
return "image/webp"
|
||
if image_bytes[:4] in (b"ftyp", b"heic", b"heix", b"hevc", b"hevx"):
|
||
return "image/heic"
|
||
return "image/jpeg" # Fallback
|
||
|
||
|
||
def _gemini_generate_image(
|
||
prompt: str,
|
||
image_base64: str | None,
|
||
aspect_ratio: str,
|
||
resolution: str
|
||
) -> dict:
|
||
"""Gemini Flash Image aufrufen — Text-to-Image und Image-to-Image.
|
||
|
||
Args:
|
||
prompt: Text-Prompt.
|
||
image_base64: Optionales Referenzbild als Base64 (Image-to-Image).
|
||
aspect_ratio: Seitenverhältnis (z.B. "16:9").
|
||
resolution: Behält Kompatibilität (von Gemini ignoriert).
|
||
|
||
Returns:
|
||
dict mit "image_base64" (PNG) oder "error".
|
||
"""
|
||
import base64 as b64
|
||
|
||
key = _get_gemini_key()
|
||
if not key:
|
||
return {"error": "Kein Gemini API-Key"}
|
||
|
||
parts = [{"text": f"{prompt}\n\nAspect ratio: {aspect_ratio}."}]
|
||
if image_base64:
|
||
# MIME-Type aus den tatsächlichen Bilddaten erkennen
|
||
raw_bytes = b64.b64decode(image_base64)
|
||
mime_type = _detect_image_mime(raw_bytes)
|
||
parts.append({
|
||
"inlineData": {
|
||
"mimeType": mime_type,
|
||
"data": image_base64
|
||
}
|
||
})
|
||
|
||
body = json.dumps({
|
||
"contents": [{"parts": parts}],
|
||
"generationConfig": {
|
||
"responseModalities": ["image", "text"]
|
||
}
|
||
}).encode()
|
||
|
||
req = urllib.request.Request(GEMINI_API_URL, data=body, method="POST")
|
||
req.add_header("x-goog-api-key", key)
|
||
req.add_header("Content-Type", "application/json")
|
||
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=45) as resp:
|
||
result = json.loads(resp.read())
|
||
candidates = result.get("candidates") or []
|
||
for cand in candidates:
|
||
for part in cand.get("content", {}).get("parts", []):
|
||
inline = part.get("inlineData")
|
||
if inline and inline.get("data"):
|
||
return {"image_base64": inline["data"]}
|
||
return {"error": "Kein Bild in Gemini-Antwort"}
|
||
except urllib.error.HTTPError as e:
|
||
return {"error": f"HTTP {e.code}: {e.read().decode()[:300]}"}
|
||
|
||
def _get_unpublished_articles() -> list[dict]:
|
||
"""Liste aller unveröffentlichten Artikel (ohne joomla-id)."""
|
||
articles = []
|
||
for f in sorted(glob.glob(os.path.join(WORKSPACE, "*.html")), key=os.path.getmtime, reverse=True):
|
||
name = os.path.basename(f)
|
||
if any(re.search(p, name) for p in SKIP_PATTERNS):
|
||
continue
|
||
try:
|
||
with open(f, "r") as fh:
|
||
content = fh.read(5000)
|
||
has_joomla_id = '<meta name="joomla-id"' in content
|
||
if not has_joomla_id:
|
||
title_match = re.search(r'<title>(.*?)</title>', content)
|
||
title = title_match.group(1).strip() if title_match else name
|
||
articles.append({"filename": name, "title": title})
|
||
except Exception:
|
||
pass
|
||
return articles[:50] # Max 50
|
||
|
||
SKIP_PATTERNS = [
|
||
r'^ddg\d*\.html', r'^google', r'^bgg_(search|thread|game|price)',
|
||
r'^bk_', r'^backerkit', r'^brave_search', r'^fb_post', r'^reddit',
|
||
r'^rp_mg', r'^rp_gladbach', r'^spiegel', r'^sportschau', r'^transfermarkt',
|
||
r'^tm_', r'^kicker', r'^brettspielbox', r'^bsg_', r'^bbb_',
|
||
r'^envyborn', r'^brettspiel_news_newsletter', r'^boardgamewire',
|
||
r'^ddg_', r'^blocked', r'^error', r'^just.a.moment',
|
||
r'^duckduckgo', r'^update.regarding',
|
||
]
|
||
|
||
|
||
# ── Image Mapping Helpers ─────────────────────────────────────────────────
|
||
def get_image_map() -> dict[str, list[str]]:
|
||
"""Load article→image mapping from JSON."""
|
||
if os.path.exists(IMAGE_MAP_FILE):
|
||
with open(IMAGE_MAP_FILE, "r") as f:
|
||
return json.load(f)
|
||
return {}
|
||
|
||
|
||
def save_image_map(mapping: dict[str, list[str]]) -> None:
|
||
"""Save article→image mapping to JSON."""
|
||
with open(IMAGE_MAP_FILE, "w") as f:
|
||
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):
|
||
return False
|
||
return filename.endswith(".html")
|
||
|
||
|
||
def extract_meta(filepath: str) -> dict | None:
|
||
"""Extract title, date, meta description, keywords, and Joomla ID from an article."""
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
|
||
title_match = re.search(r'<title>([^<]+)</title>', content)
|
||
if not title_match:
|
||
return None
|
||
title = title_match.group(1)
|
||
|
||
date_match = re.search(r'<p class="meta">[^<]*?(\d{1,2}\.\s*(?:Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\s*\d{4})', content)
|
||
if not date_match:
|
||
return None
|
||
date_str = date_match.group(1)
|
||
|
||
try:
|
||
parts = date_str.replace(".", "").split()
|
||
day, month_name, year = int(parts[0]), parts[1], int(parts[2])
|
||
month = MONTHS.get(month_name, 1)
|
||
sort_key = f"{year:04d}-{month:02d}-{day:02d}"
|
||
display_date = f"{day}.{month_name} {year}"
|
||
except (ValueError, IndexError):
|
||
return None
|
||
|
||
meta_desc = ""
|
||
desc_match = re.search(r'<meta\s+name="description"\s+content="([^"]*)"', content, re.IGNORECASE)
|
||
if desc_match:
|
||
meta_desc = desc_match.group(1)
|
||
|
||
meta_tags = ""
|
||
tag_match = re.search(r'<meta\s+name="keywords"\s+content="([^"]*)"', content, re.IGNORECASE)
|
||
if tag_match:
|
||
meta_tags = tag_match.group(1)
|
||
|
||
joomla_id = ""
|
||
jid_match = re.search(r'<meta\s+name="joomla-id"\s+content="(\d+|XXXXX)"', content, re.IGNORECASE)
|
||
if jid_match:
|
||
joomla_id = jid_match.group(1)
|
||
|
||
return {
|
||
"filename": os.path.basename(filepath),
|
||
"title": title,
|
||
"date_sort": sort_key,
|
||
"date_display": display_date,
|
||
"mtime": os.path.getmtime(filepath),
|
||
"meta_desc": meta_desc,
|
||
"meta_tags": meta_tags,
|
||
"joomla_id": joomla_id,
|
||
}
|
||
|
||
|
||
def get_articles() -> list[dict]:
|
||
articles = []
|
||
for f in glob.glob(os.path.join(WORKSPACE, "*.html")):
|
||
filename = os.path.basename(f)
|
||
if filename == "index.html":
|
||
continue
|
||
if not is_article(filename):
|
||
continue
|
||
meta = extract_meta(f)
|
||
if meta:
|
||
articles.append(meta)
|
||
|
||
articles.sort(key=lambda a: (a["date_sort"], a["filename"]), reverse=True)
|
||
for i, a in enumerate(articles, 1):
|
||
a["local_id"] = i
|
||
return articles
|
||
|
||
|
||
def build_index(articles: list[dict]) -> str:
|
||
by_date_sort: dict[str, list[dict]] = {}
|
||
for a in articles:
|
||
by_date_sort.setdefault(a["date_sort"], []).append(a)
|
||
|
||
month_names = {"01":"Januar","02":"Februar","03":"März","04":"April","05":"Mai","06":"Juni",
|
||
"07":"Juli","08":"August","09":"September","10":"Oktober","11":"November","12":"Dezember"}
|
||
|
||
# Read image map for badge display
|
||
img_map = get_image_map()
|
||
|
||
items_html = ""
|
||
for sort_key in sorted(by_date_sort.keys(), reverse=True):
|
||
day_articles = by_date_sort[sort_key]
|
||
try:
|
||
y, m, d = sort_key.split("-")
|
||
nice_date = f"{int(d)}. {month_names.get(m, m)} {y}"
|
||
except ValueError:
|
||
nice_date = day_articles[0]["date_display"]
|
||
items_html += f'<div class="date-group">\n'
|
||
items_html += f'<div class="date-header">{nice_date}<span class="date-count">{len(day_articles)}</span></div>\n'
|
||
for a in day_articles:
|
||
jid = a.get("joomla_id", "")
|
||
local_id = a.get("local_id", "")
|
||
id_str = f"#{jid}" if jid else f"#{local_id}"
|
||
id_class = "id-joomla" if jid else "id-local"
|
||
meta_desc = a.get("meta_desc", "")
|
||
meta_tags = a.get("meta_tags", "")
|
||
meta_preview = ""
|
||
if meta_desc or meta_tags:
|
||
meta_parts = []
|
||
if meta_desc:
|
||
meta_parts.append(meta_desc[:120] + ("…" if len(meta_desc) > 120 else ""))
|
||
if meta_tags:
|
||
meta_parts.append(f'<span class="tags">🏷 {meta_tags[:60]}{"…" if len(meta_tags) > 60 else ""}</span>')
|
||
meta_preview = '<div class="meta-preview">' + " · ".join(meta_parts) + '</div>'
|
||
|
||
# Image badge
|
||
image_badge = ""
|
||
image_ok = False
|
||
if a["filename"] in img_map and img_map[a["filename"]]:
|
||
img_count = len(img_map[a["filename"]])
|
||
image_badge = f'<span class="img-badge" title="{img_count} Bild(er) zugeordnet">🖼{img_count}</span>'
|
||
image_ok = True
|
||
|
||
# Readiness indicators
|
||
meta_desc_ok = bool(meta_desc)
|
||
meta_tags_ok = bool(meta_tags)
|
||
all_ready = meta_desc_ok and meta_tags_ok and image_ok
|
||
|
||
def ready_dot(ok: bool, title: str) -> str:
|
||
cls = "ready-ok" if ok else "ready-missing"
|
||
symbol = "●" if ok else "○"
|
||
return f'<span class="ready-dot {cls}" title="{title}: {"✓" if ok else "✗ fehlt"}">{symbol}</span>'
|
||
|
||
readiness = (
|
||
ready_dot(meta_desc_ok, "Meta-Description") +
|
||
ready_dot(meta_tags_ok, "Keywords") +
|
||
ready_dot(image_ok, "Bild")
|
||
)
|
||
|
||
items_html += f"""<div class="article-row" data-ready="{str(all_ready).lower()}" data-filename="{a['filename']}">
|
||
<label class="checkbox-label">
|
||
<input type="checkbox" class="article-check" value="{a['filename']}">
|
||
<span class="checkmark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||
</label>
|
||
<span class="article-id {id_class}">{id_str}</span>
|
||
<div class="article-info">
|
||
<a href="{a['filename']}?key={ACCESS_TOKEN}" class="article-link" target="_blank">{a['title']}</a>
|
||
{meta_preview}
|
||
</div>
|
||
<span class="readiness">{readiness}</span>
|
||
{image_badge}
|
||
<label class="row-upload-btn" title="Bild für diesen Artikel hochladen">
|
||
<input type="file" class="row-file-input" onchange="uploadForRow(this, '{a['filename']}')">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||
</label>
|
||
</div>\n"""
|
||
items_html += "</div>\n"
|
||
|
||
count = len(articles)
|
||
has_token = bool(JOOMLA_TOKEN)
|
||
token_status = "🟢 API-Token vorhanden" if has_token else "🔴 Kein API-Token — Veröffentlichung deaktiviert"
|
||
publish_disabled = "" if has_token else "disabled"
|
||
publish_title = "" if has_token else ' title="Kein Joomla API-Token konfiguriert (JOOMLA_API_TOKEN env oder /tmp/alicia_skills/KEYS.txt)"'
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Artikel-Übersicht · brettspiel-news.de</title>
|
||
<style>
|
||
:root {{ --accent: #20228a; --accent-light: #3b3da8; --accent-ghost: rgba(32,34,138,0.06); --accent-ghost-hover: rgba(32,34,138,0.10); --danger: #c0392b; --danger-hover: #a93226; --success: #10b981; --success-hover: #059669; --bg: #f8f9fb; --surface: #ffffff; --text: #1a1a2e; --text-muted: #6b7280; --border: #e5e7eb; --radius: 10px; --shadow-sm: 0 1px 3px rgba(0,0,0,0.04); --shadow: 0 2px 12px rgba(0,0,0,0.06); --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; }}
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{ font-family: var(--font); background: var(--bg); color: var(--text); line-height: 1.5; -webkit-font-smoothing: antialiased; }}
|
||
.container {{ max-width: 900px; margin: 0 auto; padding: 0 1.5rem 2rem; }}
|
||
|
||
header {{ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%); padding: 2rem 0; margin-bottom: 2rem; box-shadow: 0 4px 24px rgba(32,34,138,0.15); }}
|
||
header .header-inner {{ max-width: 900px; margin: 0 auto; padding: 0 1.5rem; display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 0.5rem; }}
|
||
header h1 {{ font-size: 1.4rem; font-weight: 700; color: #fff; letter-spacing: -0.02em; }}
|
||
header .subtitle {{ color: rgba(255,255,255,0.8); font-size: 0.88rem; font-weight: 400; }}
|
||
header .subtitle a {{ color: rgba(255,255,255,0.95); text-decoration: underline; text-underline-offset: 3px; }}
|
||
header .subtitle a:hover {{ color: #fff; }}
|
||
|
||
.toolbar {{ display: flex; gap: 0.6rem; align-items: center; margin-bottom: 1.8rem; flex-wrap: wrap; }}
|
||
.btn {{ padding: 0.45rem 1rem; border: none; border-radius: 7px; font-size: 0.84rem; cursor: pointer; font-weight: 500; font-family: var(--font); transition: all 0.15s ease; white-space: nowrap; }}
|
||
.btn-select {{ background: var(--surface); color: var(--text); border: 1px solid var(--border); box-shadow: var(--shadow-sm); }}
|
||
.btn-select:hover {{ background: var(--accent-ghost-hover); border-color: var(--accent); color: var(--accent); }}
|
||
.btn-upload {{ background: var(--accent); color: #fff; box-shadow: 0 2px 8px rgba(32,34,138,0.25); }}
|
||
.btn-upload:hover {{ background: var(--accent-light); box-shadow: 0 4px 14px rgba(32,34,138,0.35); }}
|
||
.btn-delete {{ background: var(--danger); color: #fff; box-shadow: 0 2px 8px rgba(192,57,43,0.25); }}
|
||
.btn-delete:hover {{ background: var(--danger-hover); box-shadow: 0 4px 14px rgba(192,57,43,0.35); }}
|
||
.btn-delete:disabled {{ background: #e5e7eb; color: #9ca3af; cursor: not-allowed; box-shadow: none; }}
|
||
.btn-publish {{ background: var(--success); color: #fff; box-shadow: 0 2px 8px rgba(16,185,129,0.25); }}
|
||
.btn-publish:hover {{ background: var(--success-hover); box-shadow: 0 4px 14px rgba(16,185,129,0.35); }}
|
||
.btn-publish:disabled {{ background: #e5e7eb; color: #9ca3af; cursor: not-allowed; box-shadow: none; }}
|
||
.live-toggle-label {{ display: flex; align-items: center; gap: 0.3rem; cursor: pointer; font-size: 0.82rem; font-weight: 600; padding: 0.35rem 0.6rem; border-radius: 7px; background: var(--surface); border: 2px solid var(--border); transition: all 0.15s; user-select: none; }}
|
||
.live-toggle-label:hover {{ border-color: var(--accent); }}
|
||
.live-toggle-label:has(input:checked) {{ background: #fef2f2; border-color: #dc3545; color: #dc3545; }}
|
||
.live-toggle-label input {{ accent-color: #dc3545; }}
|
||
|
||
.rubric-live-select {{ font-family: var(--font); font-size: 0.8rem; padding: 0.35rem 0.7rem; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); color: var(--text); cursor: pointer; }}
|
||
.rubric-live-select:focus {{ outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px rgba(32,34,138,0.15); }}
|
||
.upload-label {{ display: inline-flex; }}
|
||
|
||
.token-indicator {{ font-size: 0.7rem; margin-left: 0.6rem; opacity: 0.8; }}
|
||
|
||
.date-group {{ margin-bottom: 1.6rem; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; }}
|
||
.date-header {{ font-size: 0.82rem; font-weight: 600; color: var(--accent); text-transform: uppercase; letter-spacing: 0.06em; padding: 0.7rem 1rem; background: var(--accent-ghost); border-bottom: 1px solid rgba(32,34,138,0.08); display: flex; align-items: center; justify-content: space-between; }}
|
||
.date-count {{ background: var(--accent); color: #fff; font-size: 0.7rem; padding: 0.15rem 0.55rem; border-radius: 20px; font-weight: 600; letter-spacing: 0; }}
|
||
|
||
.article-row {{ display: flex; align-items: flex-start; gap: 0.6rem; padding: 0.6rem 1rem; transition: background 0.12s; border-bottom: 1px solid rgba(0,0,0,0.03); }}
|
||
.article-row:last-child {{ border-bottom: none; }}
|
||
.article-row:hover {{ background: var(--accent-ghost); }}
|
||
.article-row.selected {{ background: var(--accent-ghost-hover); }}
|
||
.article-row.selected .article-link {{ color: var(--accent); }}
|
||
|
||
.checkbox-label {{ position: relative; display: flex; align-items: flex-start; cursor: pointer; flex-shrink: 0; padding-top: 2px; }}
|
||
.checkbox-label input {{ display: none; }}
|
||
.checkmark {{ width: 20px; height: 20px; border: 2px solid #d1d5db; border-radius: 5px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all 0.15s; color: transparent; }}
|
||
.checkmark svg {{ width: 13px; height: 13px; display: none; }}
|
||
.checkbox-label input:checked + .checkmark {{ background: var(--accent); border-color: var(--accent); color: #fff; }}
|
||
.checkbox-label input:checked + .checkmark svg {{ display: block; }}
|
||
.article-row:hover .checkmark {{ border-color: var(--accent); }}
|
||
|
||
.article-id {{ font-size: 0.75rem; font-weight: 600; padding: 2px 7px; border-radius: 4px; flex-shrink: 0; margin-top: 1px; min-width: 32px; text-align: center; }}
|
||
.id-joomla {{ background: #dbeafe; color: #1e40af; }}
|
||
.id-local {{ background: #f3f4f6; color: #6b7280; }}
|
||
|
||
.article-info {{ flex: 1; min-width: 0; }}
|
||
.article-link {{ color: var(--text); text-decoration: none; font-size: 0.93rem; line-height: 1.4; display: block; }}
|
||
.article-link:hover {{ color: var(--accent); }}
|
||
.meta-preview {{ font-size: 0.78rem; color: var(--text-muted); margin-top: 0.2rem; line-height: 1.35; }}
|
||
.meta-preview .tags {{ color: var(--accent); font-size: 0.75rem; }}
|
||
|
||
.img-badge {{ font-size: 0.7rem; background: #dbeafe; color: #1e40af; padding: 1px 6px; border-radius: 10px; margin-left: 0.5rem; white-space: nowrap; flex-shrink: 0; }}
|
||
|
||
.readiness {{ display: flex; gap: 3px; align-items: center; flex-shrink: 0; margin: 0 0.3rem; }}
|
||
.ready-dot {{ font-size: 0.62rem; line-height: 1; }}
|
||
.ready-ok {{ color: #10b981; }}
|
||
.ready-missing {{ color: #d1d5db; }}
|
||
|
||
.counter {{ color: var(--text-muted); font-size: 0.84rem; margin-left: auto; font-variant-numeric: tabular-nums; }}
|
||
|
||
.upload-zone {{ display: none; margin-bottom: 1.5rem; padding: 2rem; border: 2px dashed var(--border); border-radius: var(--radius); text-align: center; background: var(--surface); transition: all 0.2s; }}
|
||
.upload-zone.active {{ display: block; }}
|
||
.upload-zone.dragover {{ border-color: var(--accent); background: var(--accent-ghost); }}
|
||
.upload-zone h3 {{ font-size: 1rem; color: var(--text); margin-bottom: 0.5rem; }}
|
||
.upload-zone p {{ font-size: 0.84rem; color: var(--text-muted); margin-bottom: 0.25rem; }}
|
||
.upload-zone .upload-help {{ font-size: 0.75rem; color: var(--text-muted); margin-bottom: 1rem; font-style: italic; }}
|
||
.upload-zone input[type="file"] {{ font-family: var(--font); font-size: 0.84rem; }}
|
||
.upload-zone .upload-row {{ display: flex; gap: 0.5rem; align-items: center; justify-content: center; flex-wrap: wrap; }}
|
||
.upload-result {{ margin-top: 0.8rem; font-size: 0.82rem; font-family: monospace; word-break: break-all; padding: 0.5rem; background: #f0fdf4; border-radius: 6px; display: none; }}
|
||
.upload-result.show {{ display: block; }}
|
||
|
||
.row-upload-btn {{ display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; cursor: pointer; color: var(--accent); background: var(--accent-ghost); border: 1px solid transparent; transition: all 0.15s; flex-shrink: 0; margin-left: 0.25rem; }}
|
||
.row-upload-btn:hover {{ background: var(--accent); color: #fff; border-color: var(--accent); }}
|
||
.row-upload-btn:active {{ transform: scale(0.93); }}
|
||
.row-upload-btn.uploading {{ background: var(--success); color: #fff; pointer-events: none; animation: pulse 0.8s infinite; }}
|
||
@keyframes pulse {{ 0%,100%{{opacity:1}} 50%{{opacity:0.5}} }}
|
||
|
||
.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); }}
|
||
.empty {{ text-align: center; color: var(--text-muted); padding: 4rem; font-size: 1.1rem; }}
|
||
|
||
.rubric-legend {{ margin-bottom: 1.2rem; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow-sm); overflow: hidden; }}
|
||
.rubric-legend summary {{ padding: 0.5rem 1rem; font-size: 0.78rem; font-weight: 600; color: var(--text-muted); cursor: pointer; user-select: none; }}
|
||
.rubric-legend summary:hover {{ color: var(--accent); }}
|
||
.rubric-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 0.3rem; padding: 0.5rem 1rem 0.7rem; }}
|
||
.rubric-item {{ font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 4px; background: var(--accent-ghost); }}
|
||
.rubric-item .rubric-id {{ font-weight: 600; color: var(--accent); margin-right: 0.3rem; }}
|
||
.rubric-item .rubric-sub {{ color: var(--text-muted); font-size: 0.7rem; }}
|
||
.rubric-item .rubric-lock {{ margin-left: 0.3rem; font-size: 0.65rem; }}
|
||
|
||
@media (max-width: 600px) {{
|
||
header {{ padding: 1.5rem 0; }}
|
||
header h1 {{ font-size: 1.2rem; }}
|
||
.container {{ padding: 0 0.8rem 1.5rem; }}
|
||
.toolbar {{ gap: 0.4rem; }}
|
||
.btn {{ padding: 0.4rem 0.7rem; font-size: 0.8rem; }}
|
||
.article-row {{ flex-wrap: wrap; }}
|
||
.article-id {{ order: -1; }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<div class="header-inner">
|
||
<h1>Artikel-Übersicht</h1>
|
||
<p class="subtitle">{count} Artikel im Archiv · <a href="/?key={ACCESS_TOKEN}">Aktualisieren</a> · <a href="/nanobanana?key={ACCESS_TOKEN}" style="font-weight:600">🎨 Bild-Generator</a><span class="token-indicator">{token_status}</span></p>
|
||
</div>
|
||
</header>
|
||
<div class="container">
|
||
<details class="rubric-legend">
|
||
<summary>📂 Rubriken (Kategorien) — zum Nachschlagen · 📌 Öffentliche = 3 Tage Hauptartikel</summary>
|
||
<div class="rubric-grid">
|
||
<div class="rubric-item"><span class="rubric-id">61</span> Korrektur <span class="rubric-lock">🔒 Redaktion</span></div>
|
||
<div class="rubric-item"><span class="rubric-id">9</span> Nachrichten</div>
|
||
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 64</span> Angebot</div>
|
||
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 65</span> Crowdfunding</div>
|
||
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 66</span> Branche</div>
|
||
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 67</span> Neuerscheinungen</div>
|
||
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 68</span> Ankündigungen</div>
|
||
<div class="rubric-item"><span class="rubric-id rubric-sub">└ 69</span> Zubehör</div>
|
||
<div class="rubric-item"><span class="rubric-id">8</span> Brettspieltest</div>
|
||
<div class="rubric-item"><span class="rubric-id">11</span> Magazin</div>
|
||
<div class="rubric-item"><span class="rubric-id">10</span> Video</div>
|
||
<div class="rubric-item"><span class="rubric-id">55</span> Podcast</div>
|
||
</div>
|
||
</details>
|
||
<div class="toolbar">
|
||
<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-upload" onclick="toggleUpload()">📷 Bild hochladen</button>
|
||
<label class="live-toggle-label" title="Ohne Haken: ALLE Artikel gehen auf Korrektur (Redaktion-only). MIT Haken: Live (öffentlich).">
|
||
<input type="checkbox" id="liveToggle" onchange="updateLiveMode()">
|
||
<span class="live-toggle-text">🔴 Live</span>
|
||
</label>
|
||
<select id="rubricSelect" class="rubric-live-select" onchange="updateLiveMode()">
|
||
<option value="0">📂 Rubrik wählen…</option>
|
||
<option value="9">📰 Nachrichten</option>
|
||
<option value="64" class="sub">└ Angebot</option>
|
||
<option value="65" class="sub">└ Crowdfunding</option>
|
||
<option value="66" class="sub">└ Branche</option>
|
||
<option value="67" class="sub">└ Neuerscheinungen</option>
|
||
<option value="68" class="sub">└ Ankündigungen</option>
|
||
<option value="69" class="sub">└ Zubehör</option>
|
||
<option value="8">🎲 Brettspieltest</option>
|
||
<option value="11">📖 Magazin</option>
|
||
<option value="10">🎬 Video</option>
|
||
<option value="55">🎙 Podcast</option>
|
||
</select>
|
||
<button class="btn btn-publish" id="publishBtn" onclick="publishSelected()" disabled{publish_title}>📢 Veröffentlichen</button>
|
||
<button class="btn btn-delete" id="deleteBtn" onclick="deleteSelected()" disabled>Markierte löschen</button>
|
||
<span class="counter" id="counter">0 markiert</span>
|
||
</div>
|
||
<div class="upload-zone" id="uploadZone">
|
||
<h3>📷 Bild hochladen</h3>
|
||
<p style="margin-bottom:0.5rem;">Ziehe Bilder hierher oder klicke zum Auswählen.</p>
|
||
<p class="upload-help">Das Bild wird automatisch dem <strong>ersten markierten Artikel</strong> zugeordnet.<br>Oder nutze das 📷 Icon direkt neben dem gewünschten Artikel.</p>
|
||
<div class="upload-row">
|
||
<input type="file" id="fileInput" onchange="uploadFile()" style="font-family:var(--font);font-size:0.84rem;">
|
||
</div>
|
||
<div class="upload-result" id="uploadResult"></div>
|
||
</div>
|
||
{items_html}
|
||
</div>
|
||
<div class="toast" id="toast"></div>
|
||
<script>
|
||
const checks = document.querySelectorAll('.article-check');
|
||
const deleteBtn = document.getElementById('deleteBtn');
|
||
const publishBtn = document.getElementById('publishBtn');
|
||
const counter = document.getElementById('counter');
|
||
const toast = document.getElementById('toast');
|
||
|
||
function updateUI() {{
|
||
const checked = document.querySelectorAll('.article-check:checked');
|
||
const count = checked.length;
|
||
counter.textContent = count + ' markiert';
|
||
deleteBtn.disabled = count === 0;
|
||
if (publishBtn) publishBtn.disabled = count === 0;
|
||
document.querySelectorAll('.article-row').forEach(row => row.classList.remove('selected'));
|
||
checked.forEach(cb => cb.closest('.article-row').classList.add('selected'));
|
||
}}
|
||
|
||
checks.forEach(cb => cb.addEventListener('change', updateUI));
|
||
|
||
function selectAll() {{ checks.forEach(cb => {{ cb.checked = true; }}); updateUI(); }}
|
||
function deselectAll() {{ checks.forEach(cb => {{ cb.checked = false; }}); updateUI(); }}
|
||
|
||
function toggleUpload() {{
|
||
const zone = document.getElementById('uploadZone');
|
||
zone.classList.toggle('active');
|
||
}}
|
||
|
||
function uploadFile() {{
|
||
const file = document.getElementById('fileInput').files[0];
|
||
if (!file) return;
|
||
|
||
// Nimm den ersten markierten Artikel als Ziel
|
||
const checked = document.querySelectorAll('.article-check:checked');
|
||
if (checked.length === 0) {{
|
||
showToast('Bitte zuerst einen Artikel markieren (Checkbox links)', true);
|
||
return;
|
||
}}
|
||
const articleFile = checked[0].value;
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('article', articleFile);
|
||
const result = document.getElementById('uploadResult');
|
||
result.textContent = 'Lade hoch…';
|
||
result.classList.add('show');
|
||
fetch('/upload?key={ACCESS_TOKEN}', {{ method: 'POST', body: formData }})
|
||
.then(r => r.json())
|
||
.then(data => {{
|
||
if (data.ok) {{
|
||
result.textContent = '✅ ' + file.name + ' → ' + articleFile;
|
||
result.style.background = '#f0fdf4';
|
||
showToast('✅ Bild zugeordnet: ' + articleFile.split('_2026')[0].replace(/_/g,' ').replace('artikel ',''), false);
|
||
// Reload nach 1.5s um Badge-Counter zu aktualisieren
|
||
setTimeout(() => location.reload(), 1500);
|
||
}} else {{
|
||
result.textContent = '❌ ' + (data.error || 'Fehler');
|
||
result.style.background = '#fef2f2';
|
||
}}
|
||
}})
|
||
.catch(e => {{
|
||
result.textContent = '❌ ' + e.message;
|
||
result.style.background = '#fef2f2';
|
||
}});
|
||
}}
|
||
|
||
async function uploadForRow(input, articleFile) {{
|
||
const file = input.files[0];
|
||
if (!file) return;
|
||
const btn = input.closest('.row-upload-btn');
|
||
if (btn) btn.classList.add('uploading');
|
||
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
formData.append('article', articleFile);
|
||
|
||
try {{
|
||
const resp = await fetch('/upload?key={ACCESS_TOKEN}', {{ method: 'POST', body: formData }});
|
||
const data = await resp.json();
|
||
if (btn) btn.classList.remove('uploading');
|
||
if (data.ok) {{
|
||
showToast('✅ ' + file.name + ' → ' + articleFile.split('_2026')[0].replace(/_/g,' ').replace('artikel ',''), false);
|
||
// Update image badge (bump count visually)
|
||
const row = input.closest('.article-row');
|
||
if (row) {{
|
||
const badge = row.querySelector('.img-badge');
|
||
if (badge) {{
|
||
const match = badge.textContent.match(/\\d+/);
|
||
const current = match ? parseInt(match[0]) : 0;
|
||
badge.textContent = '🖼' + (current + 1);
|
||
}} else {{
|
||
const info = row.querySelector('.article-info');
|
||
if (info) info.insertAdjacentHTML('afterend', '<span class=\"img-badge\">🖼1</span>');
|
||
}}
|
||
}}
|
||
// Clear the file input so the same file can be re-uploaded
|
||
input.value = '';
|
||
}} else {{
|
||
showToast('❌ ' + (data.error || 'Fehler'), true);
|
||
}}
|
||
}} catch(e) {{
|
||
if (btn) btn.classList.remove('uploading');
|
||
showToast('❌ ' + e.message, true);
|
||
}}
|
||
}}
|
||
|
||
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'));
|
||
uploadZone.addEventListener('drop', e => {{
|
||
e.preventDefault();
|
||
uploadZone.classList.remove('dragover');
|
||
document.getElementById('fileInput').files = e.dataTransfer.files;
|
||
uploadFile();
|
||
}});
|
||
|
||
function showToast(msg, isError) {{
|
||
toast.textContent = msg;
|
||
toast.className = 'toast' + (isError ? ' error' : '') + ' show';
|
||
setTimeout(() => toast.classList.remove('show'), 3000);
|
||
}}
|
||
|
||
function updateLiveMode() {{
|
||
const live = document.getElementById('liveToggle').checked;
|
||
const publishBtn = document.getElementById('publishBtn');
|
||
if (live) {{
|
||
publishBtn.textContent = '🔴 LIVE veröffentlichen';
|
||
publishBtn.style.background = '#dc3545';
|
||
publishBtn.style.boxShadow = '0 2px 8px rgba(220,53,69,0.25)';
|
||
}} else {{
|
||
publishBtn.textContent = '📢 Veröffentlichen';
|
||
publishBtn.style.background = '';
|
||
publishBtn.style.boxShadow = '';
|
||
}}
|
||
}}
|
||
|
||
async function publishSelected() {{
|
||
const selected = Array.from(document.querySelectorAll('.article-check:checked')).map(cb => cb.value);
|
||
if (selected.length === 0) return;
|
||
const live = document.getElementById('liveToggle').checked;
|
||
const rubric = document.getElementById('rubricSelect').value;
|
||
|
||
if (live && rubric === '0') {{
|
||
showToast('❌ Bitte eine Rubrik wählen (Dropdown rechts neben 🔴 Live)', true);
|
||
return;
|
||
}}
|
||
|
||
// Readiness-Warnung (soft, blockiert nicht)
|
||
const notReady = [];
|
||
document.querySelectorAll('.article-check:checked').forEach(cb => {{
|
||
const row = cb.closest('.article-row');
|
||
if (row && row.dataset.ready === 'false') {{
|
||
notReady.push(row.querySelector('.article-link')?.textContent || cb.value);
|
||
}}
|
||
}});
|
||
let confirmMsg;
|
||
if (live) {{
|
||
const rubricName = document.getElementById('rubricSelect').selectedOptions[0].text;
|
||
confirmMsg = '🔴 LIVE in »' + rubricName + '«: ' + selected.length + ' Artikel ÖFFENTLICH veröffentlichen?\\n\\nDies ist für 10.000+ Leser sichtbar!';
|
||
}} else {{
|
||
confirmMsg = selected.length + ' Artikel auf KORREKTUR hochladen?\\n\\nNur Redaktion sichtbar. Sicher.';
|
||
}}
|
||
if (notReady.length > 0) {{
|
||
confirmMsg += '\\n\\n⚠️ ' + notReady.length + ' Artikel ohne Meta-Daten/Bild.';
|
||
}}
|
||
if (!confirm(confirmMsg)) return;
|
||
|
||
publishBtn.disabled = true;
|
||
publishBtn.textContent = 'Veröffentliche…';
|
||
|
||
try {{
|
||
const auth = btoa('{USERNAME}:{PASSWORD}');
|
||
const resp = await fetch('/publish', {{
|
||
method: 'POST',
|
||
headers: {{'Content-Type': 'application/json', 'Authorization': 'Basic ' + auth}},
|
||
body: JSON.stringify({{files: selected, live: live, rubric: parseInt(rubric)}})
|
||
}});
|
||
const data = await resp.json();
|
||
if (data.ok) {{
|
||
showToast(data.published + ' Artikel veröffentlicht (' + (data.live_mode ? 'LIVE' : 'Korrektur') + '). ' + (data.images_deleted || 0) + ' Bilder gelöscht. Seite wird neu geladen...', false);
|
||
setTimeout(() => location.reload(), 2000);
|
||
}} else {{
|
||
showToast('Fehler: ' + (data.error || 'unbekannt'), true);
|
||
}}
|
||
}} catch(e) {{
|
||
showToast('Fehler: ' + e.message, true);
|
||
}}
|
||
publishBtn.disabled = false;
|
||
publishBtn.textContent = '📢 Veröffentlichen';
|
||
}}
|
||
|
||
async function deleteSelected() {{
|
||
const selected = Array.from(document.querySelectorAll('.article-check:checked')).map(cb => cb.value);
|
||
if (selected.length === 0) return;
|
||
if (!confirm(selected.length + ' Artikel wirklich löschen? Das kann nicht rückgängig gemacht werden.')) return;
|
||
|
||
deleteBtn.disabled = true;
|
||
deleteBtn.textContent = 'Lösche…';
|
||
|
||
try {{
|
||
const auth = btoa('{USERNAME}:{PASSWORD}');
|
||
const resp = await fetch('/delete', {{
|
||
method: 'POST',
|
||
headers: {{'Content-Type': 'application/json', 'Authorization': 'Basic ' + auth}},
|
||
body: JSON.stringify({{files: selected}})
|
||
}});
|
||
const data = await resp.json();
|
||
if (data.ok) {{
|
||
showToast(data.deleted + ' Artikel gelöscht. Seite wird neu geladen...', false);
|
||
setTimeout(() => location.reload(), 1500);
|
||
}} else {{
|
||
showToast('Fehler: ' + (data.error || 'unbekannt'), true);
|
||
}}
|
||
}} catch(e) {{
|
||
showToast('Fehler: ' + e.message, true);
|
||
}}
|
||
deleteBtn.disabled = false;
|
||
deleteBtn.textContent = 'Markierte löschen';
|
||
}}
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
class ThreadingArticleServer(ThreadingMixIn, HTTPServer):
|
||
"""HTTPServer with threading support — handles multiple requests concurrently."""
|
||
daemon_threads = True
|
||
allow_reuse_address = True
|
||
|
||
class ArticleHandler(BaseHTTPRequestHandler):
|
||
timeout = 30
|
||
|
||
def _check_auth(self) -> bool:
|
||
auth_header = self.headers.get("Authorization", "")
|
||
if not auth_header.startswith("Basic "):
|
||
self._send_auth_required()
|
||
return False
|
||
try:
|
||
creds = base64.b64decode(auth_header[6:]).decode("utf-8")
|
||
username, password = creds.split(":", 1)
|
||
except Exception:
|
||
self._send_auth_required()
|
||
return False
|
||
if username == USERNAME and password == PASSWORD:
|
||
return True
|
||
self._send_auth_required()
|
||
return False
|
||
|
||
def _send_auth_required(self):
|
||
self.send_response(401)
|
||
self.send_header("WWW-Authenticate", 'Basic realm="Artikel-Archiv"')
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.end_headers()
|
||
html = """<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head><meta charset="UTF-8"><title>Zugriff verweigert</title>
|
||
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f0ea;color:#2c2416;}
|
||
.box{text-align:center;padding:2rem;background:#fff;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.08);}
|
||
h1{color:#c0392b;}p{color:#888;}</style></head>
|
||
<body><div class="box"><h1>Zugriff verweigert</h1><p>Bitte einloggen, um die Artikel-Übersicht zu sehen.</p></div></body></html>"""
|
||
self.wfile.write(html.encode("utf-8"))
|
||
|
||
def _check_token(self) -> bool:
|
||
parsed = urllib.parse.urlparse(self.path)
|
||
params = urllib.parse.parse_qs(parsed.query)
|
||
key = params.get("key", [None])[0]
|
||
if key == ACCESS_TOKEN:
|
||
return True
|
||
self.send_response(403)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.end_headers()
|
||
html = "<!DOCTYPE html><html lang=\"de\"><head><meta charset=\"UTF-8\"><title>Zugriff verweigert</title><style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f0ea;color:#2c2416;}.box{text-align:center;padding:2rem;background:#fff;border-radius:8px;box-shadow:0 2px 12px rgba(0,0,0,.08);}h1{color:#c0392b;}p{color:#888;}</style></head><body><div class=\"box\"><h1>Zugriff verweigert</h1><p>Diese Seite ist nicht öffentlich.</p></div></body></html>"
|
||
self.wfile.write(html.encode("utf-8"))
|
||
return False
|
||
|
||
def do_GET(self):
|
||
try:
|
||
self._do_GET_impl()
|
||
except (ConnectionResetError, BrokenPipeError, socket.timeout):
|
||
pass
|
||
except Exception as e:
|
||
try:
|
||
self.send_response(500)
|
||
self.end_headers()
|
||
self.wfile.write(b"Internal Server Error")
|
||
except Exception:
|
||
pass
|
||
print(f"[ERROR] {self.client_address} - {e}", flush=True)
|
||
|
||
def _do_GET_impl(self):
|
||
if not self._check_token():
|
||
return
|
||
|
||
parsed = urllib.parse.urlparse(self.path)
|
||
path = parsed.path
|
||
|
||
if path == "/" or path == "/index.html":
|
||
articles = get_articles()
|
||
html = build_index(articles)
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.end_headers()
|
||
self.wfile.write(html.encode("utf-8"))
|
||
elif path == "/nanobanana":
|
||
html = self._build_nanobanana_page()
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.end_headers()
|
||
self.wfile.write(html.encode("utf-8"))
|
||
elif path == "/nanobanana/articles":
|
||
self._handle_nanobanana_articles()
|
||
elif path.startswith("/media/"):
|
||
rel = path[len("/"):]
|
||
filepath = os.path.normpath(os.path.join(WORKSPACE, rel))
|
||
if os.path.isfile(filepath) and filepath.startswith(WORKSPACE):
|
||
with open(filepath, "rb") as f:
|
||
data = f.read()
|
||
self.send_response(200)
|
||
self.send_header("Content-Length", str(len(data)))
|
||
ext = os.path.splitext(filepath)[1].lower()
|
||
mime_map = {".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
|
||
".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml"}
|
||
self.send_header("Content-Type", mime_map.get(ext, "application/octet-stream"))
|
||
self.send_header("Cache-Control", "public, max-age=86400")
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
else:
|
||
self.send_response(404)
|
||
self.end_headers()
|
||
elif path.startswith("/"):
|
||
filepath = os.path.join(WORKSPACE, path.lstrip("/"))
|
||
if os.path.isfile(filepath) and filepath.startswith(WORKSPACE):
|
||
with open(filepath, "rb") as f:
|
||
data = f.read()
|
||
self.send_response(200)
|
||
self.send_header("Content-Length", str(len(data)))
|
||
if filepath.endswith(".html"):
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
elif filepath.endswith(".css"):
|
||
self.send_header("Content-Type", "text/css")
|
||
elif filepath.endswith(".js"):
|
||
self.send_header("Content-Type", "application/javascript")
|
||
else:
|
||
self.send_header("Content-Type", "application/octet-stream")
|
||
self.end_headers()
|
||
self.wfile.write(data)
|
||
else:
|
||
self.send_response(404)
|
||
self.end_headers()
|
||
self.wfile.write(b"Not found")
|
||
else:
|
||
self.send_response(404)
|
||
self.end_headers()
|
||
|
||
def do_POST(self):
|
||
try:
|
||
self._do_POST_impl()
|
||
except Exception as e:
|
||
print(f"[POST ERROR] {e}", flush=True)
|
||
try:
|
||
self._json_reply(500, {"ok": False, "error": str(e)})
|
||
except Exception:
|
||
pass
|
||
|
||
def _do_POST_impl(self):
|
||
parsed = urllib.parse.urlparse(self.path)
|
||
path = parsed.path
|
||
|
||
if path == "/upload":
|
||
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 == "/nanobanana/generate":
|
||
if not self._check_token():
|
||
return
|
||
self._handle_image_generate()
|
||
elif path == "/nanobanana/articles":
|
||
if not self._check_token():
|
||
return
|
||
self._handle_nanobanana_articles()
|
||
elif path == "/nanobanana/assign":
|
||
if not self._check_token():
|
||
return
|
||
self._handle_nanobanana_assign()
|
||
elif path == "/delete":
|
||
if not self._check_auth():
|
||
return
|
||
self._handle_delete()
|
||
elif path == "/publish":
|
||
if not self._check_auth():
|
||
return
|
||
self._handle_publish()
|
||
else:
|
||
self.send_response(404)
|
||
self.end_headers()
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# Imagen Handler (Google Imagen — synchron, kein Polling)
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def _handle_image_generate(self):
|
||
"""Bildgenerierung via Google Imagen (synchron, Base64→JPEG)."""
|
||
import base64 as b64
|
||
|
||
content_type = self.headers.get("Content-Type", "")
|
||
if "multipart/form-data" not in content_type:
|
||
self._json_reply(400, {"ok": False, "error": "Expected multipart/form-data"})
|
||
return
|
||
|
||
boundary = content_type.split("boundary=")[-1].strip()
|
||
if not boundary:
|
||
self._json_reply(400, {"ok": False, "error": "Kein Boundary"})
|
||
return
|
||
|
||
length = int(self.headers.get("Content-Length", 0))
|
||
raw = self.rfile.read(length)
|
||
|
||
# Multipart manuell parsen (Python 3.13 hat kein cgi)
|
||
parts = raw.split(b"--" + boundary.encode())
|
||
prompt = ""
|
||
aspect_ratio = "16:9"
|
||
resolution = "1K"
|
||
image_base64 = None
|
||
|
||
for part in parts:
|
||
if b"Content-Disposition" not in part:
|
||
continue
|
||
headers_end = part.find(b"\r\n\r\n")
|
||
if headers_end == -1:
|
||
continue
|
||
headers_raw = part[:headers_end].decode("utf-8", errors="ignore")
|
||
content = part[headers_end + 4:].rstrip(b"\r\n")
|
||
|
||
if 'name="prompt"' in headers_raw:
|
||
prompt = content.decode("utf-8", errors="ignore")
|
||
elif 'name="aspectRatio"' in headers_raw:
|
||
aspect_ratio = content.decode("utf-8", errors="ignore")
|
||
elif 'name="resolution"' in headers_raw:
|
||
resolution = content.decode("utf-8", errors="ignore")
|
||
elif 'name="image"' in headers_raw and len(content) > 100:
|
||
# Referenzbild als Base64 für Image-to-Image
|
||
image_base64 = b64.b64encode(content).decode()
|
||
|
||
if not prompt:
|
||
self._json_reply(400, {"ok": False, "error": "Prompt fehlt"})
|
||
return
|
||
|
||
# Gemini Flash Image aufrufen (synchron!)
|
||
result = _gemini_generate_image(prompt, image_base64, aspect_ratio, resolution)
|
||
if "error" in result:
|
||
self._json_reply(500, {"ok": False, "error": result["error"]})
|
||
return
|
||
|
||
# Base64-Bild (PNG) dekodieren
|
||
b64_data = result.get("image_base64", "")
|
||
if not b64_data:
|
||
self._json_reply(500, {"ok": False, "error": "Leeres Bild von Gemini"})
|
||
return
|
||
|
||
img_bytes = b64.b64decode(b64_data)
|
||
|
||
# Im media-Ordner speichern
|
||
now = datetime.now()
|
||
month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m"))
|
||
os.makedirs(month_dir, exist_ok=True)
|
||
base_name = f"gemini_{now.strftime('%H%M%S')}"
|
||
ext = ".png" # Gemini liefert PNG
|
||
filepath = os.path.join(month_dir, base_name + ext)
|
||
|
||
with open(filepath, "wb") as f:
|
||
f.write(img_bytes)
|
||
|
||
# Optional: PIL-Konvertierung zu JPEG (1920x1080 @ 72 DPI)
|
||
try:
|
||
from PIL import Image
|
||
img = Image.open(filepath)
|
||
img = img.convert("RGB")
|
||
img = img.resize((1920, 1080), Image.LANCZOS)
|
||
jpg_path = os.path.join(month_dir, base_name + ".jpg")
|
||
img.save(jpg_path, "JPEG", dpi=(72, 72), quality=92)
|
||
filepath = jpg_path
|
||
ext = ".jpg"
|
||
except Exception:
|
||
pass # PNG behalten wenn PIL fehlschlägt
|
||
|
||
img_url = f"/media/{now.strftime('%Y')}/{now.strftime('%m')}/{base_name}{ext}?key={ACCESS_TOKEN}"
|
||
self._json_reply(200, {
|
||
"ok": True,
|
||
"imageUrl": img_url,
|
||
"filepath": filepath,
|
||
})
|
||
|
||
def _handle_nanobanana_articles(self):
|
||
"""Liste unveröffentlichter Artikel als JSON."""
|
||
articles = _get_unpublished_articles()
|
||
self._json_reply(200, {"ok": True, "articles": articles})
|
||
|
||
def _handle_nanobanana_assign(self):
|
||
"""Bild einem Artikel zuordnen."""
|
||
content_type = self.headers.get("Content-Type", "")
|
||
if "application/json" not in content_type:
|
||
self._json_reply(400, {"ok": False, "error": "Expected JSON"})
|
||
return
|
||
|
||
length = int(self.headers.get("Content-Length", 0))
|
||
raw = self.rfile.read(length)
|
||
try:
|
||
data = json.loads(raw)
|
||
except json.JSONDecodeError:
|
||
self._json_reply(400, {"ok": False, "error": "Invalid JSON"})
|
||
return
|
||
|
||
article = data.get("article", "")
|
||
image_url = data.get("imageUrl", "")
|
||
|
||
if not article or not image_url:
|
||
self._json_reply(400, {"ok": False, "error": "article und imageUrl erforderlich"})
|
||
return
|
||
|
||
# Bild herunterladen oder lokal lesen
|
||
try:
|
||
if image_url.startswith("/media/"):
|
||
# Lokales Bild — direkt von Platte lesen
|
||
local_path = os.path.join(WORKSPACE, image_url.lstrip("/"))
|
||
if not os.path.isfile(local_path):
|
||
self._json_reply(500, {"ok": False, "error": f"Lokales Bild nicht gefunden: {local_path}"})
|
||
return
|
||
with open(local_path, "rb") as lf:
|
||
img_data = lf.read()
|
||
else:
|
||
req = urllib.request.Request(image_url)
|
||
req.add_header("User-Agent", "Mozilla/5.0")
|
||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||
img_data = resp.read()
|
||
except Exception as e:
|
||
self._json_reply(500, {"ok": False, "error": f"Download fehlgeschlagen: {e}"})
|
||
return
|
||
|
||
# In media/YYYY/MM/ speichern
|
||
now = datetime.now()
|
||
month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m"))
|
||
os.makedirs(month_dir, exist_ok=True)
|
||
ts = now.strftime("%H%M%S")
|
||
fname = f"nanobanana_{ts}.jpg"
|
||
fpath = os.path.join(month_dir, fname)
|
||
|
||
with open(fpath, "wb") as f:
|
||
f.write(img_data)
|
||
|
||
# article_images.json updaten
|
||
img_map = get_image_map()
|
||
if article not in img_map:
|
||
img_map[article] = []
|
||
img_map[article].append(fpath)
|
||
save_image_map(img_map)
|
||
|
||
self._json_reply(200, {"ok": True, "path": fpath, "filename": fname})
|
||
|
||
def _build_nanobanana_page(self) -> str:
|
||
"""Baut die NanoBanana Editor HTML-Seite."""
|
||
articles = _get_unpublished_articles()
|
||
article_options = ""
|
||
for a in articles[:30]:
|
||
article_options += f'<option value="{a["filename"]}">{a["title"]}</option>\n'
|
||
|
||
return NANOBANANA_HTML.format(
|
||
key=ACCESS_TOKEN,
|
||
article_options=article_options,
|
||
)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
# End NanoBanana Handler
|
||
# ═══════════════════════════════════════════════════════════════════
|
||
|
||
def _handle_upload(self):
|
||
"""Handle multipart file upload — now accepts 'article' field for association."""
|
||
content_type = self.headers.get("Content-Type", "")
|
||
if "multipart/form-data" not in content_type:
|
||
self._json_reply(400, {"ok": False, "error": "Expected multipart/form-data"})
|
||
return
|
||
|
||
content_length = int(self.headers.get("Content-Length", 0))
|
||
body = self.rfile.read(content_length)
|
||
|
||
boundary = None
|
||
for part in content_type.split(";"):
|
||
part = part.strip()
|
||
if part.startswith("boundary="):
|
||
boundary = part[9:].strip('"').strip("'").strip()
|
||
break
|
||
|
||
if not boundary:
|
||
self._json_reply(400, {"ok": False, "error": "Kein Boundary in Content-Type"})
|
||
return
|
||
|
||
b_boundary = boundary.encode()
|
||
parts = body.split(b"--" + b_boundary)
|
||
file_data = None
|
||
filename = None
|
||
article_file = None
|
||
|
||
for part in parts:
|
||
if b"Content-Disposition" not in part:
|
||
continue
|
||
header_end = part.find(b"\r\n\r\n")
|
||
if header_end == -1:
|
||
continue
|
||
headers = part[:header_end].decode("utf-8", errors="replace")
|
||
|
||
if b"filename=" in part:
|
||
fn_match = re.search(r'filename="([^"]*)"', headers)
|
||
if fn_match:
|
||
filename = os.path.basename(fn_match.group(1))
|
||
file_data = part[header_end + 4:]
|
||
if file_data:
|
||
file_data = file_data.rstrip(b"\r\n").rstrip(b"-")
|
||
elif 'name="article"' in headers:
|
||
article_file = part[header_end + 4:].decode("utf-8", errors="replace").strip()
|
||
if article_file:
|
||
article_file = article_file.rstrip("\r\n").rstrip("-")
|
||
|
||
if not file_data or not filename:
|
||
self._json_reply(400, {"ok": False, "error": "Keine Datei gefunden"})
|
||
return
|
||
|
||
ext = os.path.splitext(filename)[1].lower()
|
||
if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg"):
|
||
self._json_reply(400, {"ok": False, "error": f"Nicht erlaubtes Format: {ext}. Erlaubt: jpg, png, webp, gif, svg"})
|
||
return
|
||
|
||
now = datetime.now()
|
||
month_dir = os.path.join(MEDIA_DIR, now.strftime("%Y"), now.strftime("%m"))
|
||
os.makedirs(month_dir, exist_ok=True)
|
||
|
||
base, ext = os.path.splitext(filename)
|
||
safe_base = re.sub(r'[^a-zA-Z0-9_-]', '_', base)
|
||
unique_name = f"{safe_base}_{now.strftime('%H%M%S')}{ext}"
|
||
filepath = os.path.join(month_dir, unique_name)
|
||
|
||
with open(filepath, "wb") as f:
|
||
f.write(file_data)
|
||
|
||
# ── Article-Image Association ──────────────────────────────────────
|
||
association_msg = ""
|
||
if article_file:
|
||
img_map = get_image_map()
|
||
if article_file not in img_map:
|
||
img_map[article_file] = []
|
||
img_map[article_file].append(filepath)
|
||
save_image_map(img_map)
|
||
association_msg = f" → zugeordnet zu: {article_file}"
|
||
|
||
rel_path = os.path.relpath(filepath, WORKSPACE)
|
||
self._json_reply(200, {
|
||
"ok": True,
|
||
"filename": unique_name,
|
||
"path": filepath,
|
||
"url": f"MEDIA:{filepath}",
|
||
"html_src": f"/media/{now.strftime('%Y')}/{now.strftime('%m')}/{unique_name}",
|
||
"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)
|
||
try:
|
||
data = json.loads(body)
|
||
files = data.get("files", [])
|
||
except json.JSONDecodeError:
|
||
self._json_reply(400, {"ok": False, "error": "Invalid JSON"})
|
||
return
|
||
|
||
deleted = 0
|
||
errors = []
|
||
for fname in files:
|
||
fname = os.path.basename(fname)
|
||
if not fname.endswith(".html"):
|
||
errors.append(f"{fname}: not an HTML file")
|
||
continue
|
||
filepath = os.path.join(WORKSPACE, fname)
|
||
if not os.path.exists(filepath):
|
||
errors.append(f"{fname}: not found")
|
||
continue
|
||
try:
|
||
os.remove(filepath)
|
||
deleted += 1
|
||
except OSError as e:
|
||
errors.append(f"{fname}: {e}")
|
||
|
||
self._json_reply(200, {"ok": True, "deleted": deleted, "errors": errors})
|
||
|
||
def _handle_publish(self):
|
||
"""Publish selected articles via Joomla API with VG-Wort pixel injection
|
||
and automatic cleanup of associated images.
|
||
|
||
SAFETY GATE (Daniel 18.06.): Ohne 'live': true im Request-Payload
|
||
gehen ALLE Artikel auf Korrektur (catid=61, state=0) — niemals live.
|
||
"""
|
||
if not JOOMLA_TOKEN:
|
||
self._json_reply(400, {
|
||
"ok": False,
|
||
"error": "Kein Joomla API-Token konfiguriert. "
|
||
"Bitte JOOMLA_API_TOKEN Umgebungsvariable setzen "
|
||
"oder /tmp/alicia_skills/KEYS.txt anlegen."
|
||
})
|
||
return
|
||
|
||
length = int(self.headers.get("Content-Length", 0))
|
||
body = self.rfile.read(length)
|
||
try:
|
||
data = json.loads(body)
|
||
files = data.get("files", [])
|
||
allow_live = data.get("live", False) # SAFETY GATE: Nur wenn explizit true
|
||
rubric = data.get("rubric", 0) # 0 = nicht gesetzt
|
||
except json.JSONDecodeError:
|
||
self._json_reply(400, {"ok": False, "error": "Invalid JSON"})
|
||
return
|
||
|
||
published = 0
|
||
published_ids = []
|
||
images_deleted_total = 0
|
||
errors = []
|
||
img_map = get_image_map()
|
||
|
||
# Safety log
|
||
mode = "🔴 LIVE (state=1)" if allow_live else "🟡 KORREKTUR (state=0, Redaktion-only)"
|
||
print(f"[PUBLISH] {len(files)} Artikel, Modus: {mode}", flush=True)
|
||
|
||
# Try importing requests
|
||
try:
|
||
import requests
|
||
except ImportError:
|
||
self._json_reply(500, {
|
||
"ok": False,
|
||
"error": "Python 'requests' Modul nicht installiert. "
|
||
"Bitte ausführen: /home/hermes/venv/bin/pip install requests"
|
||
})
|
||
return
|
||
|
||
for fname in files:
|
||
fname = os.path.basename(fname)
|
||
if not fname.endswith(".html"):
|
||
errors.append(f"{fname}: not an HTML file")
|
||
continue
|
||
filepath = os.path.join(WORKSPACE, fname)
|
||
if not os.path.exists(filepath):
|
||
errors.append(f"{fname}: not found")
|
||
continue
|
||
|
||
try:
|
||
with open(filepath, "r", encoding="utf-8") as f:
|
||
html_content = f.read()
|
||
|
||
# ── Extract article metadata ───────────────────────────────
|
||
title_match = re.search(r'<title>([^<]+)</title>', html_content)
|
||
if not title_match:
|
||
errors.append(f"{fname}: kein <title> gefunden")
|
||
continue
|
||
title = title_match.group(1)
|
||
|
||
# ── Readiness-Warnung (soft, blockiert nicht) ───────────
|
||
readiness_warnings = []
|
||
|
||
meta_desc_match = re.search(
|
||
r'<meta\s+name="description"\s+content="([^"]+)"',
|
||
html_content, re.IGNORECASE
|
||
)
|
||
if not (meta_desc_match and meta_desc_match.group(1).strip()):
|
||
readiness_warnings.append("keine Meta-Description")
|
||
|
||
meta_key_match = re.search(
|
||
r'<meta\s+name="keywords"\s+content="([^"]+)"',
|
||
html_content, re.IGNORECASE
|
||
)
|
||
if not (meta_key_match and meta_key_match.group(1).strip()):
|
||
readiness_warnings.append("keine Meta-Keywords")
|
||
|
||
if fname not in img_map or not img_map[fname]:
|
||
readiness_warnings.append("kein Bild zugewiesen")
|
||
|
||
if readiness_warnings:
|
||
# Soft warning — Publizieren trotzdem möglich
|
||
errors.append(f"{fname}: ⚠️ {', '.join(readiness_warnings)} (trotzdem publiziert)")
|
||
|
||
# Extract the article body (content inside <article> or <body>)
|
||
body_match = re.search(r'<article[^>]*>(.*?)</article>', html_content, re.DOTALL)
|
||
if not body_match:
|
||
# Fallback: use everything between </header> and <footer> or end
|
||
body_match = re.search(r'(?:</header>|</head>)(.*?)(?:<footer|<div class="quellen"|</body)', html_content, re.DOTALL)
|
||
article_body = body_match.group(1) if body_match else html_content
|
||
|
||
# ── Strip internal markers from article body ──────────────
|
||
# These are editorial metadata, not part of the published article.
|
||
# Applied AFTER extraction but BEFORE read-more split.
|
||
article_body = re.sub(r'<h1[^>]*>.*?</h1>\s*', '', article_body, flags=re.DOTALL)
|
||
article_body = re.sub(r'<p\s+class="meta"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL)
|
||
article_body = re.sub(r'<p\s+class="qc"[^>]*>.*?</p>\s*', '', article_body, flags=re.DOTALL)
|
||
# ── Umbruch vor Überschriften (Daniel 19.06.: bessere Lesbarkeit) ──
|
||
article_body = re.sub(r'(</(?:p|/div|/blockquote)>)\\s*(<h[234])', r'\\1\\n<br>\\n\\3', article_body)
|
||
# ── Remove <br> after read-more (muss NACH Umbruch-Insertion laufen!) ──
|
||
article_body = re.sub(
|
||
r'(<hr\s+id="system-readmore"\s*/?>)\s*<br\s*/?>\s*',
|
||
r'\1\n',
|
||
article_body, flags=re.IGNORECASE
|
||
)
|
||
# ── Strip trailing whitespace/newlines before closing </article> ──
|
||
article_body = re.sub(r'\s*</article>', '</article>', article_body)
|
||
|
||
# Extract Joomla ID if present (for updates)
|
||
jid_match = re.search(r'<meta\s+name="joomla-id"\s+content="(\d+|XXXXX)"', html_content, re.IGNORECASE)
|
||
joomla_id = jid_match.group(1) if jid_match else None
|
||
|
||
# ── Kategorie: Rubrik vom Dropdown oder meta ──────────
|
||
catid = 61 # Default: Korrektur
|
||
if allow_live and rubric > 0:
|
||
catid = rubric
|
||
elif allow_live:
|
||
cat_match = re.search(r'<meta\s+name="category-id"\s+content="(\d+)"', html_content, re.IGNORECASE)
|
||
if cat_match:
|
||
catid = int(cat_match.group(1))
|
||
|
||
# Korrektur (61) → state=0 (unpublished/Redaktion-only)
|
||
# Alles andere → state=1 (published/public) – NUR bei allow_live
|
||
state = 0 if catid == 61 else 1
|
||
access = 6 if catid == 61 else 1 # 6=Redaktion, 1=Public
|
||
|
||
# ── Featured (Hauptartikel) ─────────────────────────
|
||
featured = 0
|
||
featured_up = None
|
||
featured_down = None
|
||
if allow_live and catid != 61:
|
||
# Default: 3 Tage featured, via <meta name="featured-days"> anpassbar
|
||
featured_days = 3
|
||
fd_match = re.search(
|
||
r'<meta\s+name="featured-days"\s+content="(\d+)"',
|
||
html_content, re.IGNORECASE
|
||
)
|
||
if fd_match:
|
||
featured_days = int(fd_match.group(1))
|
||
|
||
from datetime import datetime as dt, timedelta
|
||
now = dt.now()
|
||
featured = 1
|
||
featured_up = now.strftime("%Y-%m-%d %H:%M:%S")
|
||
featured_down = (now + timedelta(days=featured_days)).strftime("%Y-%m-%d %H:%M:%S")
|
||
|
||
# Generate alias from title
|
||
alias = re.sub(r'[^a-z0-9]+', '-', title.lower().strip())[:100]
|
||
|
||
# ── VG-Wort Zählmarke IMMER einbauen (auch bei Korrektur) ──
|
||
# Daniel 19.06.: Pixel muss bei JEDEM Publish rein, weil er
|
||
# später manuell live stellt und sonst kein Geld bekommt.
|
||
vgwort_pixel_html = ""
|
||
try:
|
||
import subprocess
|
||
result = subprocess.run(
|
||
["python3", os.path.expanduser("~/.hermes/vgwort_pool.py"), "get"],
|
||
capture_output=True, text=True, timeout=30
|
||
)
|
||
if result.returncode == 0:
|
||
pixel_data = json.loads(result.stdout)
|
||
if pixel_data.get("ok"):
|
||
vgwort_pixel_html = pixel_data.get("pixel_html", "")
|
||
print(f"[VG-Wort] Zählmarke {pixel_data.get('public_id')} eingebaut, "
|
||
f"Pool: {pixel_data.get('pool_remaining')} übrig", flush=True)
|
||
else:
|
||
print(f"[VG-Wort] FEHLER: {pixel_data.get('error', 'unbekannt')}", flush=True)
|
||
else:
|
||
print(f"[VG-Wort] Pool-Script fehlgeschlagen: {result.stderr}", flush=True)
|
||
except Exception as e:
|
||
print(f"[VG-Wort] Ausnahme: {e}", flush=True)
|
||
|
||
if vgwort_pixel_html:
|
||
if '</article>' in article_body:
|
||
article_body = article_body.replace('</article>', f'{vgwort_pixel_html}\n</article>')
|
||
else:
|
||
article_body = article_body.rstrip() + f'\n{vgwort_pixel_html}\n'
|
||
|
||
# ── Joomla API call ─────────────────────────────────────────
|
||
headers = {
|
||
"Accept": "application/vnd.api+json",
|
||
"Content-Type": "application/json",
|
||
"X-Joomla-Token": JOOMLA_TOKEN,
|
||
}
|
||
|
||
# Joomla 5/6 PATCH-Fix: articletext wird ignoriert, muss in introtext + fulltext aufgeteilt werden
|
||
introtext = ""
|
||
fulltext = article_body.strip()
|
||
sr_match = re.search(r'<hr\s+id="system-readmore"\s*/?>', article_body, re.IGNORECASE)
|
||
if sr_match:
|
||
split_idx = sr_match.end()
|
||
introtext = article_body[:split_idx].strip()
|
||
fulltext = article_body[split_idx:].strip()
|
||
|
||
article_payload = {
|
||
"title": title,
|
||
"alias": alias,
|
||
"introtext": introtext,
|
||
"fulltext": fulltext,
|
||
"catid": catid,
|
||
"language": "*",
|
||
"state": state,
|
||
"access": access,
|
||
"featured": featured,
|
||
"created_by": 560, # Daniel Krause
|
||
}
|
||
# ── Meta-Description & Keywords ─────────────────────────
|
||
if meta_desc_match and meta_desc_match.group(1).strip():
|
||
article_payload["metadesc"] = meta_desc_match.group(1).strip()
|
||
if meta_key_match and meta_key_match.group(1).strip():
|
||
article_payload["metakey"] = meta_key_match.group(1).strip()
|
||
# ── Image upload to Joomla (Joomla 5/6: Base64 + json, nicht multipart) ──
|
||
if fname in img_map and img_map[fname]:
|
||
image_path = img_map[fname][0] # First image
|
||
if os.path.exists(image_path):
|
||
try:
|
||
import base64 as b64
|
||
from datetime import datetime as dt
|
||
img_filename = os.path.basename(image_path)
|
||
year = dt.now().strftime("%Y")
|
||
j_img_path = f"Spiele/{year}/{img_filename}" # OHNE images/ prefix (local-images root = images/)
|
||
|
||
with open(image_path, "rb") as img_f:
|
||
img_content = b64.b64encode(img_f.read()).decode()
|
||
|
||
img_resp = requests.post(
|
||
"https://www.brettspiel-news.de/api/index.php/v1/media/files",
|
||
headers={
|
||
"X-Joomla-Token": JOOMLA_TOKEN,
|
||
"Accept": "application/vnd.api+json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={"path": j_img_path, "content": img_content},
|
||
timeout=30
|
||
)
|
||
|
||
if img_resp.status_code == 400 and "exists" in img_resp.text:
|
||
# Überschreiben existierender Datei
|
||
print(f"[Image] {img_filename} existiert — überschreibe via PATCH", flush=True)
|
||
img_resp = requests.patch(
|
||
f"https://www.brettspiel-news.de/api/index.php/v1/media/files/local-images:/{j_img_path}",
|
||
headers={
|
||
"X-Joomla-Token": JOOMLA_TOKEN,
|
||
"Accept": "application/vnd.api+json",
|
||
"Content-Type": "application/json",
|
||
},
|
||
json={"path": j_img_path, "content": img_content},
|
||
timeout=30
|
||
)
|
||
|
||
if img_resp.status_code in (200, 201):
|
||
j_img_url = f"https://www.brettspiel-news.de/images/{j_img_path}" # Web-URL braucht images/ Prefix
|
||
# Joomla requires relative + joomlaImage pseudo-protocol
|
||
# Format: images/Spiele/2026/file.jpg#joomlaImage://local-images/Spiele/2026/file.jpg
|
||
# image_intro braucht images/ prefix, joomlaImage NICHT
|
||
image_intro_value = f"images/{j_img_path}#joomlaImage://local-images/{j_img_path}"
|
||
article_payload["images"] = {
|
||
"image_intro": image_intro_value,
|
||
"image_intro_alt": title,
|
||
"image_fulltext": image_intro_value,
|
||
"image_fulltext_alt": title,
|
||
}
|
||
print(f"[Image] Uploaded {img_filename} → {j_img_path}", flush=True)
|
||
else:
|
||
print(f"[Image] Upload failed HTTP {img_resp.status_code}: {img_resp.text[:200]}", flush=True)
|
||
except Exception as e:
|
||
print(f"[Image] Upload error: {e}", flush=True)
|
||
if featured_up:
|
||
article_payload["featured_up"] = featured_up
|
||
if featured_down:
|
||
article_payload["featured_down"] = featured_down
|
||
|
||
if joomla_id:
|
||
# Update existing article
|
||
resp = requests.patch(
|
||
f"{JOOMLA_API_URL}/{joomla_id}",
|
||
headers=headers, json=article_payload, timeout=30
|
||
)
|
||
else:
|
||
# Create new article
|
||
resp = requests.post(
|
||
JOOMLA_API_URL,
|
||
headers=headers, json=article_payload, timeout=30
|
||
)
|
||
|
||
if resp.status_code in (200, 201):
|
||
published += 1
|
||
|
||
# ── Write back Joomla ID to HTML ────────────────────────
|
||
resp_data = resp.json()
|
||
new_jid = None
|
||
if "data" in resp_data and "id" in resp_data["data"]:
|
||
new_jid = str(resp_data["data"]["id"])
|
||
elif "data" in resp_data and "attributes" in resp_data["data"]:
|
||
# Some responses nest the id differently
|
||
pass
|
||
|
||
if new_jid:
|
||
published_ids.append(new_jid)
|
||
|
||
if new_jid:
|
||
# Immer Joomla-ID in die HTML-Datei schreiben (auch bei Updates)
|
||
if '<meta name="joomla-id"' not in html_content:
|
||
jid_meta = f'\n<meta name="joomla-id" content="{new_jid}">'
|
||
html_content = re.sub(
|
||
r'(<meta[^>]*>)',
|
||
lambda m: m.group(1) + jid_meta,
|
||
html_content,
|
||
count=1
|
||
)
|
||
else:
|
||
html_content = re.sub(
|
||
r'<meta\s+name="joomla-id"\s+content="([^"]*)"',
|
||
f'<meta name="joomla-id" content="{new_jid}"',
|
||
html_content
|
||
)
|
||
with open(filepath, "w", encoding="utf-8") as f:
|
||
f.write(html_content)
|
||
|
||
# ── Image mapping cleanup: remove association but keep files ──
|
||
# Daniel 19.06.: Bilder NICHT löschen — sie werden für
|
||
# spätere Live-Schaltungen und Referenz benötigt.
|
||
if fname in img_map:
|
||
del img_map[fname]
|
||
save_image_map(img_map)
|
||
|
||
else:
|
||
error_msg = f"HTTP {resp.status_code}"
|
||
try:
|
||
err_data = resp.json()
|
||
if "errors" in err_data:
|
||
error_msg += ": " + str(err_data["errors"][0].get("title", err_data["errors"]))
|
||
except Exception:
|
||
error_msg += ": " + resp.text[:200]
|
||
errors.append(f"{fname}: {error_msg}")
|
||
|
||
except Exception as e:
|
||
errors.append(f"{fname}: {e}")
|
||
|
||
self._json_reply(200 if published > 0 else 500, {
|
||
"ok": published > 0,
|
||
"published": published,
|
||
"published_ids": published_ids,
|
||
"live_mode": allow_live,
|
||
"images_deleted": images_deleted_total,
|
||
"errors": errors,
|
||
"vg_wort_enabled": not allow_live or True, # immer enabled, aber nur bei live genutzt
|
||
})
|
||
|
||
def _json_reply(self, status, data):
|
||
self.send_response(status)
|
||
self.send_header("Content-Type", "application/json")
|
||
self.end_headers()
|
||
self.wfile.write(json.dumps(data).encode("utf-8"))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# NanoBanana Editor HTML
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
NANOBANANA_HTML = """<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>BSN Bild-Generator — Google Imagen</title>
|
||
<style>
|
||
:root {{
|
||
--bsn-blue: #20228a;
|
||
--bsn-blue-light: #2d30b0;
|
||
--bg: #f5f6fa;
|
||
--card-bg: #fff;
|
||
--text: #1a1a2e;
|
||
--text-muted: #666;
|
||
--radius: 12px;
|
||
--shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||
}}
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
}}
|
||
.header {{
|
||
background: var(--bsn-blue);
|
||
color: #fff;
|
||
padding: 16px 24px;
|
||
font-size: 1.3em;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}}
|
||
.header a {{
|
||
color: #fff;
|
||
font-size: 0.7em;
|
||
font-weight: 400;
|
||
opacity: 0.8;
|
||
text-decoration: none;
|
||
margin-left: auto;
|
||
}}
|
||
.container {{
|
||
max-width: 900px;
|
||
margin: 0 auto;
|
||
padding: 20px;
|
||
}}
|
||
.card {{
|
||
background: var(--card-bg);
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
padding: 24px;
|
||
margin-bottom: 20px;
|
||
}}
|
||
.card h2 {{
|
||
font-size: 1.1em;
|
||
margin-bottom: 16px;
|
||
color: var(--bsn-blue);
|
||
}}
|
||
/* Upload-Zone */
|
||
.dropzone {{
|
||
border: 2px dashed #ccc;
|
||
border-radius: var(--radius);
|
||
padding: 40px 20px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: all 0.3s;
|
||
background: #fafbfc;
|
||
min-height: 120px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
}}
|
||
.dropzone:hover, .dropzone.drag-over {{
|
||
border-color: var(--bsn-blue);
|
||
background: #eef0ff;
|
||
}}
|
||
.dropzone .icon {{ font-size: 2.5em; }}
|
||
.dropzone .hint {{ color: var(--text-muted); font-size: 0.9em; }}
|
||
.dropzone input[type=file] {{ display: none; }}
|
||
.preview-box {{
|
||
position: relative;
|
||
display: inline-block;
|
||
margin-top: 12px;
|
||
}}
|
||
.preview-box img {{
|
||
max-height: 200px;
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow);
|
||
}}
|
||
.preview-box .remove-btn {{
|
||
position: absolute;
|
||
top: -8px;
|
||
right: -8px;
|
||
background: #e74c3c;
|
||
color: #fff;
|
||
border: none;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
font-size: 1.1em;
|
||
line-height: 1;
|
||
}}
|
||
/* Form */
|
||
.form-row {{
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
flex-wrap: wrap;
|
||
}}
|
||
.form-row label {{
|
||
font-weight: 600;
|
||
font-size: 0.85em;
|
||
color: var(--text-muted);
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
}}
|
||
.form-row > div {{ flex: 1; min-width: 140px; }}
|
||
textarea, select, button {{
|
||
font-family: inherit;
|
||
font-size: 1em;
|
||
border-radius: 8px;
|
||
border: 1px solid #ddd;
|
||
padding: 10px 14px;
|
||
width: 100%;
|
||
background: #fff;
|
||
color: var(--text);
|
||
}}
|
||
textarea {{
|
||
min-height: 100px;
|
||
resize: vertical;
|
||
line-height: 1.5;
|
||
}}
|
||
textarea:focus, select:focus {{ border-color: var(--bsn-blue); outline: none; box-shadow: 0 0 0 3px rgba(32,34,138,0.1); }}
|
||
.char-count {{ text-align: right; font-size: 0.8em; color: var(--text-muted); margin-top: 4px; }}
|
||
/* Buttons */
|
||
.btn {{
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
border: none;
|
||
border-radius: 8px;
|
||
padding: 12px 24px;
|
||
font-size: 1em;
|
||
transition: all 0.2s;
|
||
min-height: 48px;
|
||
}}
|
||
.btn-primary {{
|
||
background: var(--bsn-blue);
|
||
color: #fff;
|
||
}}
|
||
.btn-primary:hover {{ background: var(--bsn-blue-light); }}
|
||
.btn-primary:disabled {{
|
||
background: #999;
|
||
cursor: not-allowed;
|
||
}}
|
||
.btn-secondary {{
|
||
background: #e0e0e0;
|
||
color: var(--text);
|
||
}}
|
||
.btn-download {{
|
||
background: #27ae60;
|
||
color: #fff;
|
||
}}
|
||
.btn-download:hover {{ background: #219a52; }}
|
||
/* Ergebnis */
|
||
.result-box {{
|
||
display: none;
|
||
}}
|
||
.result-box.visible {{
|
||
display: block;
|
||
}}
|
||
.result-box img {{
|
||
width: 100%;
|
||
max-height: 500px;
|
||
object-fit: contain;
|
||
border-radius: var(--radius);
|
||
box-shadow: var(--shadow);
|
||
margin-bottom: 16px;
|
||
}}
|
||
.result-actions {{
|
||
display: flex;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}}
|
||
/* Spinner */
|
||
.spinner {{
|
||
display: none;
|
||
text-align: center;
|
||
padding: 40px 20px;
|
||
}}
|
||
.spinner.active {{
|
||
display: block;
|
||
}}
|
||
.spinner-icon {{
|
||
width: 48px;
|
||
height: 48px;
|
||
border: 4px solid #e0e0e0;
|
||
border-top-color: var(--bsn-blue);
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
margin: 0 auto 16px;
|
||
}}
|
||
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
|
||
.spinner-text {{ color: var(--text-muted); font-size: 0.95em; }}
|
||
/* Toast */
|
||
.toast {{
|
||
position: fixed;
|
||
bottom: 20px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: #333;
|
||
color: #fff;
|
||
padding: 12px 24px;
|
||
border-radius: 8px;
|
||
font-size: 0.95em;
|
||
z-index: 1000;
|
||
opacity: 0;
|
||
transition: opacity 0.3s;
|
||
pointer-events: none;
|
||
}}
|
||
.toast.show {{ opacity: 1; }}
|
||
.toast.success {{ background: #27ae60; }}
|
||
.toast.error {{ background: #e74c3c; }}
|
||
/* Mobile */
|
||
@media (max-width: 600px) {{
|
||
.container {{ padding: 12px; }}
|
||
.card {{ padding: 16px; }}
|
||
.header {{ padding: 12px 16px; font-size: 1.1em; }}
|
||
.form-row {{ flex-direction: column; gap: 8px; }}
|
||
.result-actions {{ flex-direction: column; }}
|
||
.result-actions .btn {{ width: 100%; }}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
🎨 BSN Bild-Generator
|
||
<a href="/?key={key}">← Zurück zur Artikelübersicht</a>
|
||
</div>
|
||
|
||
<div class="container">
|
||
|
||
<!-- Foto-Upload -->
|
||
<div class="card">
|
||
<h2>📷 Referenzbild (optional)</h2>
|
||
<div class="dropzone" id="dropZone">
|
||
<span class="icon">🖼️</span>
|
||
<span class="hint">Foto hier ablegen oder klicken zum Auswählen</span>
|
||
<input type="file" id="fileInput" accept="image/*">
|
||
</div>
|
||
<div class="preview-box" id="previewBox" style="display:none">
|
||
<img id="previewImg" src="" alt="Vorschau">
|
||
<button class="remove-btn" id="removeBtn" title="Entfernen">✕</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Prompt -->
|
||
<div class="card">
|
||
<h2>✍️ Prompt</h2>
|
||
<textarea id="prompt" placeholder="Beschreibe das gewünschte Bild… Beispiele: • Professional board game photography of Catan, warm lighting, product shot • Transform this cover into a cinematic movie poster style, epic composition"></textarea>
|
||
<div class="char-count"><span id="charCount">0</span> Zeichen</div>
|
||
|
||
<div class="form-row" style="margin-top:12px">
|
||
<div>
|
||
<label>📐 Seitenverhältnis</label>
|
||
<select id="aspectRatio">
|
||
<option value="16:9">16:9 — Header (Standard)</option>
|
||
<option value="1:1">1:1 — Quadrat</option>
|
||
<option value="2:3">2:3 — Brettspiel-Box</option>
|
||
<option value="9:16">9:16 — Instagram Story</option>
|
||
<option value="21:9">21:9 — Breitformat</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label>🔍 Auflösung</label>
|
||
<select id="resolution">
|
||
<option value="1K">1K — Schnell (geringere Qualität)</option>
|
||
<option value="2K">2K — 1920×1080 (Standard)</option>
|
||
<option value="4K">4K — Höchste Qualität</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Start Button -->
|
||
<button class="btn btn-primary" id="startBtn" style="width:100%; font-size:1.1em; padding:16px">
|
||
🚀 Bild generieren
|
||
</button>
|
||
|
||
<!-- Spinner -->
|
||
<div class="spinner" id="spinner">
|
||
<div class="spinner-icon"></div>
|
||
<div class="spinner-text" id="spinnerText">Generiere Bild…</div>
|
||
</div>
|
||
|
||
<!-- Ergebnis -->
|
||
<div class="result-box card" id="resultBox">
|
||
<h2>✅ Ergebnis</h2>
|
||
<img id="resultImg" src="" alt="Generiertes Bild">
|
||
<div class="result-actions">
|
||
<button class="btn btn-download" id="downloadBtn">⬇ Download</button>
|
||
<button class="btn btn-secondary" id="retryBtn">🔄 Neues Bild</button>
|
||
</div>
|
||
<div style="margin-top:16px">
|
||
<label style="font-weight:600;font-size:0.85em;color:var(--text-muted)">📄 Artikel zuordnen</label>
|
||
<div class="form-row" style="margin-top:4px">
|
||
<select id="articleSelect">
|
||
<option value="">-- Artikel wählen --</option>
|
||
{article_options}
|
||
</select>
|
||
<button class="btn btn-primary" id="assignBtn" style="flex:0 0 auto;min-width:auto">Zuordnen</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
const KEY = "{key}";
|
||
const BASE = window.location.origin;
|
||
let uploadedImageData = null; // File object
|
||
let currentResultUrl = null;
|
||
|
||
// ── Upload-Zone ──
|
||
const dropZone = document.getElementById('dropZone');
|
||
const fileInput = document.getElementById('fileInput');
|
||
const previewBox = document.getElementById('previewBox');
|
||
const previewImg = document.getElementById('previewImg');
|
||
const removeBtn = document.getElementById('removeBtn');
|
||
|
||
dropZone.addEventListener('click', () => fileInput.click());
|
||
fileInput.addEventListener('change', handleFile);
|
||
|
||
dropZone.addEventListener('dragover', (e) => {{ e.preventDefault(); dropZone.classList.add('drag-over'); }});
|
||
dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
|
||
dropZone.addEventListener('drop', (e) => {{
|
||
e.preventDefault();
|
||
dropZone.classList.remove('drag-over');
|
||
const files = e.dataTransfer.files;
|
||
if (files.length) handleFile({{ target: {{ files }} }});
|
||
}});
|
||
|
||
function handleFile(e) {{
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
uploadedImageData = file;
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => {{
|
||
previewImg.src = ev.target.result;
|
||
previewBox.style.display = 'inline-block';
|
||
dropZone.querySelector('.icon').textContent = '✅';
|
||
dropZone.querySelector('.hint').textContent = file.name + ' (' + (file.size/1024).toFixed(1) + ' KB)';
|
||
}};
|
||
reader.readAsDataURL(file);
|
||
}}
|
||
|
||
removeBtn.addEventListener('click', () => {{
|
||
uploadedImageData = null;
|
||
fileInput.value = '';
|
||
previewBox.style.display = 'none';
|
||
dropZone.querySelector('.icon').textContent = '🖼️';
|
||
dropZone.querySelector('.hint').textContent = 'Foto hier ablegen oder klicken zum Auswählen';
|
||
}});
|
||
|
||
// ── Char-Zähler ──
|
||
const promptEl = document.getElementById('prompt');
|
||
const charCount = document.getElementById('charCount');
|
||
promptEl.addEventListener('input', () => charCount.textContent = promptEl.value.length);
|
||
|
||
// ── Start ──
|
||
document.getElementById('startBtn').addEventListener('click', startGeneration);
|
||
|
||
async function startGeneration() {{
|
||
const prompt = promptEl.value.trim();
|
||
if (!prompt) {{
|
||
showToast('Bitte gib einen Prompt ein', 'error');
|
||
return;
|
||
}}
|
||
|
||
const btn = document.getElementById('startBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳ Generiere…';
|
||
|
||
document.getElementById('resultBox').classList.remove('visible');
|
||
document.getElementById('spinner').classList.add('active');
|
||
document.getElementById('spinnerText').textContent = 'Google Imagen generiert…';
|
||
|
||
try {{
|
||
const formData = new FormData();
|
||
formData.append('prompt', prompt);
|
||
formData.append('aspectRatio', document.getElementById('aspectRatio').value);
|
||
formData.append('resolution', document.getElementById('resolution').value);
|
||
if (uploadedImageData) {{
|
||
formData.append('image', uploadedImageData);
|
||
}}
|
||
|
||
const resp = await fetch(BASE + '/nanobanana/generate?key=' + KEY, {{
|
||
method: 'POST',
|
||
body: formData
|
||
}});
|
||
|
||
let data;
|
||
try {{
|
||
data = await resp.json();
|
||
}} catch (e) {{
|
||
const text = await resp.text().catch(() => '');
|
||
throw new Error('Server-Antwort nicht lesbar (Status ' + resp.status + '): ' + text.substring(0, 200));
|
||
}}
|
||
|
||
if (!data.ok) {{
|
||
throw new Error(data.error || 'Unbekannter Fehler (Status ' + resp.status + ')');
|
||
}}
|
||
|
||
// DIREKT Ergebnis anzeigen — Google Imagen ist synchron!
|
||
document.getElementById('spinner').classList.remove('active');
|
||
btn.disabled = false;
|
||
btn.textContent = '🚀 Bild generieren';
|
||
|
||
currentResultUrl = data.imageUrl;
|
||
document.getElementById('resultImg').src = currentResultUrl;
|
||
document.getElementById('resultBox').classList.add('visible');
|
||
showToast('✅ Bild fertig!', 'success');
|
||
loadArticles();
|
||
|
||
}} catch (err) {{
|
||
document.getElementById('spinner').classList.remove('active');
|
||
btn.disabled = false;
|
||
btn.textContent = '🚀 Bild generieren';
|
||
showToast('❌ Fehler: ' + err.message, 'error');
|
||
}}
|
||
}}
|
||
|
||
// ── Artikel laden ──
|
||
async function loadArticles() {{
|
||
try {{
|
||
const resp = await fetch(BASE + '/nanobanana/articles?key=' + KEY);
|
||
const data = await resp.json();
|
||
const sel = document.getElementById('articleSelect');
|
||
sel.innerHTML = '<option value="">-- Artikel wählen --</option>';
|
||
(data.articles || []).forEach(a => {{
|
||
sel.innerHTML += '<option value="' + a.filename + '">' + a.title + '</option>';
|
||
}});
|
||
}} catch (e) {{}}
|
||
}}
|
||
|
||
// ── Zuordnen ──
|
||
document.getElementById('assignBtn').addEventListener('click', async () => {{
|
||
const article = document.getElementById('articleSelect').value;
|
||
if (!article) {{
|
||
showToast('Bitte Artikel auswählen', 'error');
|
||
return;
|
||
}}
|
||
if (!currentResultUrl) {{
|
||
showToast('Kein Bild zum Zuordnen', 'error');
|
||
return;
|
||
}}
|
||
|
||
const btn = document.getElementById('assignBtn');
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳…';
|
||
|
||
try {{
|
||
const resp = await fetch(BASE + '/nanobanana/assign?key=' + KEY, {{
|
||
method: 'POST',
|
||
headers: {{ 'Content-Type': 'application/json' }},
|
||
body: JSON.stringify({{ article: article, imageUrl: currentResultUrl }})
|
||
}});
|
||
const data = await resp.json();
|
||
|
||
if (data.ok) {{
|
||
showToast('✅ Bild zugeordnet: ' + data.filename, 'success');
|
||
btn.textContent = '✓ Zugeordnet';
|
||
}} else {{
|
||
throw new Error(data.error);
|
||
}}
|
||
}} catch (err) {{
|
||
showToast('Fehler: ' + err.message, 'error');
|
||
btn.disabled = false;
|
||
btn.textContent = 'Zuordnen';
|
||
}}
|
||
}});
|
||
|
||
// ── Download ──
|
||
document.getElementById('downloadBtn').addEventListener('click', async () => {{
|
||
if (!currentResultUrl) return;
|
||
try {{
|
||
const resp = await fetch(currentResultUrl);
|
||
const blob = await resp.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'bsn-bild-' + Date.now() + '.jpg';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}} catch (err) {{
|
||
// Fallback: direkt öffnen
|
||
window.open(currentResultUrl, '_blank');
|
||
}}
|
||
}});
|
||
|
||
// ── Neues Bild ──
|
||
document.getElementById('retryBtn').addEventListener('click', () => {{
|
||
document.getElementById('resultBox').classList.remove('visible');
|
||
currentResultUrl = null;
|
||
window.scrollTo({{ top: 0, behavior: 'smooth' }});
|
||
}});
|
||
|
||
// ── Toast ──
|
||
function showToast(msg, type) {{
|
||
const t = document.getElementById('toast');
|
||
t.textContent = msg;
|
||
t.className = 'toast ' + type + ' show';
|
||
clearTimeout(t._timeout);
|
||
t._timeout = setTimeout(() => t.classList.remove('show'), 3000);
|
||
}}
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def main():
|
||
import sys
|
||
os.chdir(WORKSPACE)
|
||
server = ThreadingArticleServer(("0.0.0.0", PORT), ArticleHandler)
|
||
has_token = "JA" if JOOMLA_TOKEN else "NEIN (Veröffentlichung deaktiviert)"
|
||
has_vgwort = VG_WORT_COUNTER_ID or "NICHT KONFIGURIERT"
|
||
print(f"Article server running on http://0.0.0.0:{PORT}")
|
||
print(f" Joomla-Token: {has_token}")
|
||
print(f" VG-Wort-ID: {has_vgwort}")
|
||
sys.stdout.flush()
|
||
try:
|
||
server.serve_forever()
|
||
except KeyboardInterrupt:
|
||
print("\nShutting down.")
|
||
server.server_close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|