fix(fuenfvoacht): UNIQUE constraint bug + Einplanen für zukünftige Tage
- DB-Migration: UNIQUE(date) → UNIQUE(date, post_time) — alte DBs werden automatisch beim Start migriert (database.py init_db) - api_save: gibt article_id zurück für nachgelagerte Operationen - confirmPlan(): speichert auf selectedDate, verschiebt dann ggf. per reschedule auf Zieldatum — fixes "Kein Artikel für diesen Tag vorhanden" - Alle Source-Dateien (app.py, database.py, templates, ...) hinzugefügt - arakava-news: cursor-memory-system Artikel + SVG-Diagramm hinzugefügt Made-with: Cursor
This commit is contained in:
parent
e83c7292a8
commit
92645521b2
15 changed files with 3619 additions and 0 deletions
201
arakava-news/artikel/cursor-memory-system-artikel.md
Normal file
201
arakava-news/artikel/cursor-memory-system-artikel.md
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
# Wie ich meiner KI ein Gedächtnis gebaut habe — und warum das alles verändert
|
||||
|
||||
**Kategorie:** Technik / KI / Homelab
|
||||
**Tags:** Cursor, Claude, AI Memory, Homelab, Git, Automatisierung, Prompt Engineering
|
||||
**Lesezeit:** ca. 8 Minuten
|
||||
|
||||
---
|
||||
|
||||
<p><strong>Dieser Artikel erscheint zusammen mit einem öffentlichen GitHub-Repository: <a href="https://github.com/Orbitalo/cursor-memory-system">cursor-memory-system</a> — alles was du brauchst um das System nachzubauen.</strong></p>
|
||||
|
||||
---
|
||||
|
||||
## Das Problem: Die KI weiß nach jedem Gespräch nichts mehr
|
||||
|
||||
Ich nutze Cursor mit Claude täglich für mein Homelab. Server einrichten, Code schreiben, Automatisierungen bauen. Das funktioniert gut — bis die Session endet.
|
||||
|
||||
Beim nächsten Tag fängt die KI wieder bei null an. Ich erkläre zum dritten Mal welche Container laufen, welche IP mein WordPress hat, was das Projekt eigentlich soll. Wertvolle Minuten, manchmal eine halbe Stunde, nur um den Kontext wiederherzustellen.
|
||||
|
||||
**Das ist kein Bug. Das ist wie diese KI-Systeme funktionieren.** Jedes Gespräch ist eine leere Seite.
|
||||
|
||||
Ich wollte das ändern.
|
||||
|
||||
---
|
||||
|
||||
## Die Idee: Ein Git-Repository als Gehirn
|
||||
|
||||
Die Lösung kam aus einer simplen Beobachtung: Die KI kann Dateien lesen. Also gebe ich ihr die richtigen Dateien — mit allem was sie über mein System wissen muss.
|
||||
|
||||
Kein Plugin. Kein teurer API-Wrapper. Nur ein Git-Repository mit Markdown-Dateien.
|
||||
|
||||
Ich nenne es **homelab-brain**.
|
||||
|
||||
*[Grafik: Animiertes Flussdiagramm — Live-Infrastruktur links, homelab-brain Repo mitte, Cursor/KI rechts, Datenpfeile fließen von links nach rechts]*
|
||||
|
||||
---
|
||||
|
||||
## Wie es funktioniert
|
||||
|
||||
### 1. Die Routing-Tabelle
|
||||
|
||||
Die wichtigste Datei ist `.cursorrules` im Workspace-Root. Sie ist die **Telefonzentrale** des Systems:
|
||||
|
||||
```markdown
|
||||
## Routing-Tabelle
|
||||
| Aufgabe betrifft... | Lade diese Datei |
|
||||
|-----------------------------|-------------------------------|
|
||||
| WordPress / RSS | arakava-news/STATE.md |
|
||||
| KI-Redakteur | redax-wp/STATE.md |
|
||||
| Smart Home / ioBroker | smart-home/STATE.md |
|
||||
| ESP32 / Display / Heizung | esp32/PLAN.md |
|
||||
| Server / Container / Proxmox| infrastructure/STATE.md |
|
||||
```
|
||||
|
||||
Wenn ich eine neue Cursor-Session öffne und sage "richte mir einen neuen Container ein", liest die KI zuerst `.cursorrules` und weiß: *Infrastruktur → lade `infrastructure/STATE.md`*. Nur diese eine Datei. Das Kontextfenster bleibt sauber.
|
||||
|
||||
### 2. Die STATE.md Dateien
|
||||
|
||||
Für jedes Projekt gibt es eine `STATE.md`. Sie enthält alles was die KI wissen muss:
|
||||
|
||||
```markdown
|
||||
# STATE: Redax-WP
|
||||
**Stand: 28.02.2026**
|
||||
|
||||
## Zugang
|
||||
| Was | URL |
|
||||
|-----|-----|
|
||||
| Dashboard | https://redax.orbitalo.net |
|
||||
| Login | admin / astral66 |
|
||||
|
||||
## Stack
|
||||
redax-web Flask Dashboard (:8080)
|
||||
redax-wordpress WordPress intern (:80)
|
||||
redax-db MySQL 8
|
||||
|
||||
## Letzter Stand
|
||||
- Multi-Publish zu 2 WordPress-Instanzen aktiv
|
||||
- Drag & Drop im Redaktionsplan
|
||||
- ESP32-Serie Teil 2 als Entwurf (Post 1340)
|
||||
```
|
||||
|
||||
IPs, Passwörter, Container-Nummern, letzter Entwicklungsstand — alles drin. Die KI fragt nicht nach.
|
||||
|
||||
### 3. Das Auto-Sync-Script
|
||||
|
||||
Das Geniale: Die STATE.md-Dateien für laufende Services werden **automatisch aktualisiert**. Ein Cron-Job auf dem Server läuft alle 15 Minuten:
|
||||
|
||||
```bash
|
||||
# Läuft auf pve-hetzner, alle 15 Min
|
||||
*/15 * * * * /opt/homelab-brain/scripts/sync-state.sh
|
||||
```
|
||||
|
||||
Das Script fragt live die Container ab — ist der RSS-Manager aktiv? Wie viel OpenRouter-Guthaben ist noch da? Wie voll ist die Festplatte? — und schreibt die Ergebnisse in die STATE.md. Dann committed und pushed es auf Forgejo (mein internes Git).
|
||||
|
||||
*[Screenshot: STATE.md mit Live-Daten — Timestamp, Service-Status grün, OpenRouter-Guthaben]*
|
||||
|
||||
Wenn ich morgens Cursor öffne, hat die KI den **Stand von heute Nacht** — nicht von vor drei Wochen.
|
||||
|
||||
---
|
||||
|
||||
## Was sich verändert hat
|
||||
|
||||
**Vorher:**
|
||||
> Ich: "Richte mir auf CT 113 den Redakteur ein."
|
||||
> KI: "Was ist CT 113? Auf welchem Server? Welches OS? Was soll der Redakteur tun?"
|
||||
|
||||
**Nachher:**
|
||||
> Ich: "Richte mir auf CT 113 den Redakteur ein."
|
||||
> KI: *liest infrastructure/STATE.md* → weiß: CT 113 ist auf pve-hetzner, Debian 12, IP 10.10.10.113, Docker läuft, Tailscale aktiv. Schreibt direkt den Deployment-Befehl.
|
||||
|
||||
Der Unterschied ist nicht nur Zeit. Es ist **Qualität**. Die KI macht keine Annahmen mehr über meine Infrastruktur — sie kennt sie.
|
||||
|
||||
---
|
||||
|
||||
## Das Ergebnis in Zahlen
|
||||
|
||||
Ich betreibe damit:
|
||||
- **7 aktive Projekte** (WordPress-Blog, KI-Redakteur, Edelmetall-Bot, Flugpreisscanner, Smart-Home, ESP32-Heizung, FünfVorAcht-Telegram-Bot)
|
||||
- **12 Container** auf 2 Proxmox-Servern
|
||||
- **Auto-Sync alle 15 Minuten** für 4 Live-Services
|
||||
|
||||
Und ich muss der KI **nie wieder erklären** was mein System ist.
|
||||
|
||||
---
|
||||
|
||||
## Der Trick mit dem Kontextfenster
|
||||
|
||||
Ein häufiger Fehler: alles in eine riesige Datei packen und immer komplett laden. Das funktioniert nicht — Kontextfenster sind begrenzt, und je mehr irrelevanter Kontext drin ist, desto schlechter werden die Antworten.
|
||||
|
||||
Meine Lösung: **Routing + Lazy Loading**.
|
||||
|
||||
```
|
||||
.cursorrules ← wird IMMER geladen (klein, ~30 Zeilen)
|
||||
└── Routing-Tabelle
|
||||
├── Aufgabe A → lade STATE-A.md
|
||||
├── Aufgabe B → lade STATE-B.md
|
||||
└── Aufgabe C → lade STATE-C.md
|
||||
```
|
||||
|
||||
Die KI lädt nur was gerade relevant ist. Bei 7 Projekten mit je 100-200 Zeilen STATE.md würde alles auf einmal ~1500 Zeilen Kontext fressen. Mit Routing sind es ~30 Zeilen Basis + ~150 Zeilen pro Session.
|
||||
|
||||
*[Grafik: Vergleich Kontextfenster — ohne Routing (voll, rot) vs. mit Routing (schmal, grün)]*
|
||||
|
||||
---
|
||||
|
||||
## Wie du es nachbaust
|
||||
|
||||
Ich habe das komplette System als Template auf GitHub veröffentlicht:
|
||||
|
||||
**→ [github.com/Orbitalo/cursor-memory-system](https://github.com/Orbitalo/cursor-memory-system)**
|
||||
|
||||
Das Repository enthält:
|
||||
- `.cursorrules` Vorlage mit Routing-Tabelle
|
||||
- `STATE-template.md` zum Anpassen
|
||||
- `sync-state.sh` Auto-Sync-Script (für Linux-Server)
|
||||
- `SETUP.md` — Schritt-für-Schritt Anleitung
|
||||
|
||||
**Minimale Version in 10 Minuten:**
|
||||
|
||||
1. Repo klonen oder Template kopieren
|
||||
2. `.cursorrules` in deinen Workspace-Root
|
||||
3. Routing-Tabelle auf deine Projekte anpassen
|
||||
4. Eine `STATE.md` für dein erstes Projekt anlegen
|
||||
5. Cursor neu starten
|
||||
|
||||
Das Auto-Sync ist optional — auch eine manuell gepflegte STATE.md ist 10x besser als keine.
|
||||
|
||||
---
|
||||
|
||||
## Was kommt als nächstes
|
||||
|
||||
Das System wächst mit dem Homelab. Geplant:
|
||||
|
||||
- **ESP32-Integration**: Sobald die Heizungssteuerung läuft, werden Echtzeit-Temperaturen direkt in `smart-home/STATE.md` geschrieben — die KI sieht live ob der Pufferspeicher warm genug ist
|
||||
- **Alert-System**: Wenn ein Service ausfällt, schreibt der Watchdog ein `⚠️ ALERT` in die STATE.md — die KI sieht es beim nächsten Öffnen sofort
|
||||
- **Mehrsprachig**: README und Templates auf Englisch für die internationale Community
|
||||
|
||||
---
|
||||
|
||||
## Fazit
|
||||
|
||||
Das Konzept ist simpel bis zur Peinlichkeit: **Gib der KI die richtigen Dateien, und sie wird klüger.**
|
||||
|
||||
Kein Plugin, kein Abo, keine schwarze Magie. Ein Git-Repository, ein paar Markdown-Dateien, optional ein Cron-Job. Das war's.
|
||||
|
||||
Die KI hat kein schlechtes Gedächtnis. Sie hat nur kein Gedächtnis bekommen.
|
||||
|
||||
---
|
||||
|
||||
*Dieser Artikel beschreibt mein persönliches Homelab-Setup. Das GitHub-Repository ist ein Template — anpassen erwünscht.*
|
||||
|
||||
*→ GitHub: [cursor-memory-system](https://github.com/Orbitalo/cursor-memory-system)*
|
||||
*→ Nächster Artikel: [ESP32-Heizungsprojekt Teil 2 — Die Hardware](/...)*
|
||||
|
||||
---
|
||||
|
||||
### Grafik-Ideen für diesen Artikel
|
||||
|
||||
1. **Animiertes Flussdiagramm**: Infrastruktur → homelab-brain → KI — mit leuchtenden Datenpfeilen (SVG, wie Fließschaltbild)
|
||||
2. **Kontextfenster-Vergleich**: Balkendiagramm "ohne System" (voll, rot) vs. "mit Routing" (schlank, grün)
|
||||
3. **Screenshot**: Cursor öffnet sich, KI antwortet sofort mit korrekten IPs/Befehlen ohne Nachfragen
|
||||
4. **Repo-Struktur**: Dateibaum als schöne Grafik (dark theme)
|
||||
BIN
arakava-news/artikel/memory-system-diagram.svg
Normal file
BIN
arakava-news/artikel/memory-system-diagram.svg
Normal file
Binary file not shown.
0
fuenfvoacht/docker-compose.yml
Normal file
0
fuenfvoacht/docker-compose.yml
Normal file
555
fuenfvoacht/src/app.py
Normal file
555
fuenfvoacht/src/app.py
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
from flask import Flask, render_template, request, jsonify, redirect, url_for, Response
|
||||
from functools import wraps
|
||||
from datetime import datetime, date, timedelta
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
import requests as req_lib
|
||||
|
||||
import database as db
|
||||
import openrouter
|
||||
import logger as flog
|
||||
|
||||
app = Flask(__name__)
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TZ_NAME = os.environ.get('TIMEZONE', 'Europe/Berlin')
|
||||
BOT_TOKEN = os.environ.get('TELEGRAM_BOT_TOKEN', '')
|
||||
POST_TIME = os.environ.get('POST_TIME', '19:55')
|
||||
|
||||
AUTH_USER = os.environ.get('AUTH_USER', 'Holgerhh')
|
||||
AUTH_PASS = os.environ.get('AUTH_PASS', 'ddlhh')
|
||||
|
||||
BRAND_MARKER = "Pax et Lux Terranaut01 https://t.me/DieneDemLeben"
|
||||
BRAND_SIGNATURE = (
|
||||
"Wir schützen die Zukunft unserer Kinder und das Leben❤️\n\n"
|
||||
"Pax et Lux Terranaut01 https://t.me/DieneDemLeben\n\n"
|
||||
"Unterstützt die Menschen, die für Uns einstehen❗️"
|
||||
)
|
||||
|
||||
|
||||
def check_auth(username, password):
|
||||
return username == AUTH_USER and password == AUTH_PASS
|
||||
|
||||
|
||||
def authenticate():
|
||||
return Response(
|
||||
'Zugang verweigert.', 401,
|
||||
{'WWW-Authenticate': 'Basic realm="FünfVorAcht"'})
|
||||
|
||||
|
||||
@app.before_request
|
||||
def before_request_auth():
|
||||
auth = request.authorization
|
||||
if not auth or not check_auth(auth.username, auth.password):
|
||||
return authenticate()
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_no_cache(response):
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
return response
|
||||
|
||||
|
||||
def today_str():
|
||||
import pytz
|
||||
return datetime.now(pytz.timezone(TZ_NAME)).strftime('%Y-%m-%d')
|
||||
|
||||
|
||||
def today_display():
|
||||
import pytz
|
||||
return datetime.now(pytz.timezone(TZ_NAME)).strftime('%d. %B %Y')
|
||||
|
||||
|
||||
def week_range():
|
||||
today = date.today()
|
||||
start = today - timedelta(days=today.weekday())
|
||||
return [(start + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(7)]
|
||||
|
||||
|
||||
def planning_days(count=7):
|
||||
import pytz
|
||||
tz = pytz.timezone(TZ_NAME)
|
||||
t = datetime.now(tz).date()
|
||||
return [(t + timedelta(days=i)).strftime('%Y-%m-%d') for i in range(count)]
|
||||
|
||||
|
||||
def with_branding(content: str) -> str:
|
||||
text = (content or "").rstrip()
|
||||
if BRAND_MARKER in text:
|
||||
return text
|
||||
return f"{text}\n\n{BRAND_SIGNATURE}" if text else BRAND_SIGNATURE
|
||||
|
||||
|
||||
def send_telegram_message(chat_id, text, reply_markup=None):
|
||||
url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage"
|
||||
payload = {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}
|
||||
if reply_markup:
|
||||
import json
|
||||
payload["reply_markup"] = json.dumps(reply_markup)
|
||||
try:
|
||||
r = req_lib.post(url, json=payload, timeout=10)
|
||||
return r.json()
|
||||
except Exception as e:
|
||||
logger.error("Telegram send fehlgeschlagen: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def notify_all_reviewers(text, reply_markup=None):
|
||||
results = []
|
||||
for chat_id in db.get_reviewer_chat_ids():
|
||||
result = send_telegram_message(chat_id, text, reply_markup)
|
||||
results.append({'chat_id': chat_id, 'ok': bool(result and result.get('ok'))})
|
||||
return results
|
||||
|
||||
|
||||
# ── Main Dashboard ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
today = today_str()
|
||||
articles_today = db.get_articles_by_date(today)
|
||||
article_today = articles_today[0] if articles_today else None
|
||||
week_days = week_range()
|
||||
week_articles_raw = db.get_week_articles(week_days[0], week_days[-1])
|
||||
# Mehrere Artikel pro Tag: dict date → list
|
||||
week_articles = {}
|
||||
for a in week_articles_raw:
|
||||
week_articles.setdefault(a['date'], []).append(a)
|
||||
|
||||
recent = db.get_recent_articles(10)
|
||||
stats = db.get_monthly_stats()
|
||||
channel = db.get_channel()
|
||||
prompts = db.get_prompts()
|
||||
tags = db.get_tags()
|
||||
favorites = db.get_favorites()
|
||||
locations = db.get_locations()
|
||||
current_location = db.get_current_location()
|
||||
reviewers = db.get_reviewers()
|
||||
last_posted = db.get_last_posted()
|
||||
|
||||
plan_days = planning_days(7)
|
||||
plan_raw = db.get_week_articles(plan_days[0], plan_days[-1])
|
||||
plan_articles = {}
|
||||
for a in plan_raw:
|
||||
plan_articles.setdefault(a['date'], []).append(a)
|
||||
|
||||
month_start = date.today().replace(day=1).strftime('%Y-%m-%d')
|
||||
month_end = (date.today().replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
|
||||
month_articles = {}
|
||||
for a in db.get_week_articles(month_start, month_end.strftime('%Y-%m-%d')):
|
||||
month_articles.setdefault(a['date'], []).append(a['status'])
|
||||
|
||||
return render_template('index.html',
|
||||
today=today,
|
||||
article_today=article_today,
|
||||
articles_today=articles_today,
|
||||
week_days=week_days,
|
||||
week_articles=week_articles,
|
||||
plan_days=plan_days,
|
||||
plan_articles=plan_articles,
|
||||
month_articles=month_articles,
|
||||
recent=recent,
|
||||
stats=stats,
|
||||
channel=channel,
|
||||
post_time=POST_TIME,
|
||||
prompts=prompts,
|
||||
tags=tags,
|
||||
favorites=favorites,
|
||||
locations=locations,
|
||||
current_location=current_location,
|
||||
reviewers=reviewers,
|
||||
last_posted=last_posted)
|
||||
|
||||
|
||||
# ── History ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/history')
|
||||
def history():
|
||||
articles = db.get_recent_articles(30)
|
||||
return render_template('history.html', articles=articles)
|
||||
|
||||
|
||||
# ── Prompts ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/prompts')
|
||||
def prompts():
|
||||
all_prompts = db.get_prompts()
|
||||
return render_template('prompts.html', prompts=all_prompts)
|
||||
|
||||
|
||||
@app.route('/prompts/save', methods=['POST'])
|
||||
def save_prompt():
|
||||
pid = request.form.get('id')
|
||||
name = request.form.get('name', '').strip()
|
||||
system_prompt = request.form.get('system_prompt', '').strip()
|
||||
if pid:
|
||||
db.save_prompt(int(pid), name, system_prompt)
|
||||
else:
|
||||
db.create_prompt(name, system_prompt)
|
||||
return redirect(url_for('prompts'))
|
||||
|
||||
|
||||
@app.route('/prompts/default/<int:pid>', methods=['POST'])
|
||||
def set_default_prompt(pid):
|
||||
db.set_default_prompt(pid)
|
||||
return redirect(url_for('prompts'))
|
||||
|
||||
|
||||
@app.route('/prompts/delete/<int:pid>', methods=['POST'])
|
||||
def delete_prompt(pid):
|
||||
db.delete_prompt(pid)
|
||||
return redirect(url_for('prompts'))
|
||||
|
||||
|
||||
@app.route('/prompts/test', methods=['POST'])
|
||||
def test_prompt():
|
||||
data = request.get_json()
|
||||
system_prompt = data.get('system_prompt', '')
|
||||
source = data.get('source', 'https://tagesschau.de')
|
||||
tag = data.get('tag', 'Politik')
|
||||
prompt_id = data.get('prompt_id')
|
||||
import pytz
|
||||
date_display = datetime.now(pytz.timezone(TZ_NAME)).strftime('%d. %B %Y')
|
||||
try:
|
||||
result = asyncio.run(openrouter.generate_article(source, system_prompt, date_display, tag))
|
||||
if prompt_id:
|
||||
db.save_prompt_test_result(int(prompt_id), result)
|
||||
return jsonify({'success': True, 'result': result})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
# ── Hilfe ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/hilfe')
|
||||
def hilfe():
|
||||
return render_template('hilfe.html')
|
||||
|
||||
|
||||
# ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/settings')
|
||||
def settings():
|
||||
channel = db.get_channel()
|
||||
favorites = db.get_favorites()
|
||||
tags = db.get_tags()
|
||||
reviewers = db.get_reviewers(active_only=False)
|
||||
return render_template('settings.html', channel=channel,
|
||||
favorites=favorites, tags=tags,
|
||||
reviewers=reviewers)
|
||||
|
||||
|
||||
@app.route('/settings/channel', methods=['POST'])
|
||||
def save_channel():
|
||||
telegram_id = request.form.get('telegram_id', '').strip()
|
||||
post_time = request.form.get('post_time', '19:55').strip()
|
||||
db.update_channel(telegram_id, post_time)
|
||||
return redirect(url_for('settings'))
|
||||
|
||||
|
||||
@app.route('/settings/favorite/add', methods=['POST'])
|
||||
def add_favorite():
|
||||
label = request.form.get('label', '').strip()
|
||||
url = request.form.get('url', '').strip()
|
||||
if label and url:
|
||||
db.add_favorite(label, url)
|
||||
return redirect(url_for('settings'))
|
||||
|
||||
|
||||
# ── Reviewer API ──────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/api/reviewers', methods=['GET'])
|
||||
def api_reviewers():
|
||||
return jsonify(db.get_reviewers(active_only=False))
|
||||
|
||||
|
||||
@app.route('/api/reviewers/add', methods=['POST'])
|
||||
def api_add_reviewer():
|
||||
data = request.get_json()
|
||||
chat_id = data.get('chat_id')
|
||||
name = data.get('name', '').strip()
|
||||
if not chat_id or not name:
|
||||
return jsonify({'success': False, 'error': 'chat_id und name erforderlich'})
|
||||
try:
|
||||
chat_id = int(chat_id)
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'error': 'Ungültige Chat-ID'})
|
||||
added = db.add_reviewer(chat_id, name)
|
||||
if not added:
|
||||
return jsonify({'success': False, 'error': 'Chat-ID bereits vorhanden'})
|
||||
# Willkommensnachricht
|
||||
welcome = (
|
||||
f"👋 <b>Willkommen bei FünfVorAcht!</b>\n\n"
|
||||
f"Du wurdest als Redakteur hinzugefügt.\n"
|
||||
f"Ab jetzt erhältst du Reviews, Reminder und Status-Meldungen.\n\n"
|
||||
f"/start für eine Übersicht aller Befehle."
|
||||
)
|
||||
send_telegram_message(chat_id, welcome)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/reviewers/remove', methods=['POST'])
|
||||
def api_remove_reviewer():
|
||||
data = request.get_json()
|
||||
chat_id = data.get('chat_id')
|
||||
if not chat_id:
|
||||
return jsonify({'success': False, 'error': 'chat_id erforderlich'})
|
||||
db.remove_reviewer(int(chat_id))
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
# ── API Endpoints ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/api/generate', methods=['POST'])
|
||||
def api_generate():
|
||||
data = request.get_json()
|
||||
source = data.get('source', '').strip()
|
||||
tag = data.get('tag', 'allgemein')
|
||||
prompt_id = data.get('prompt_id')
|
||||
date_str = data.get('date', today_str())
|
||||
post_time = data.get('post_time', POST_TIME)
|
||||
if not source:
|
||||
return jsonify({'success': False, 'error': 'Keine Quelle angegeben'})
|
||||
prompt = None
|
||||
if prompt_id:
|
||||
all_prompts = db.get_prompts()
|
||||
prompt = next((p for p in all_prompts if str(p['id']) == str(prompt_id)), None)
|
||||
if not prompt:
|
||||
prompt = db.get_default_prompt()
|
||||
if not prompt:
|
||||
return jsonify({'success': False, 'error': 'Kein Prompt konfiguriert'})
|
||||
try:
|
||||
content = asyncio.run(
|
||||
openrouter.generate_article(source, prompt['system_prompt'], today_display(), tag)
|
||||
)
|
||||
existing = db.get_article_by_date(date_str, post_time)
|
||||
if existing:
|
||||
db.update_article_content(date_str, content, new_version=True, post_time=post_time)
|
||||
conn = db.get_conn()
|
||||
conn.execute(
|
||||
"UPDATE articles SET source_input=?, tag=?, status='draft' WHERE date=? AND post_time=?",
|
||||
(source, tag, date_str, post_time)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
else:
|
||||
db.create_article(date_str, source, content, prompt['id'], tag, post_time)
|
||||
flog.article_generated(date_str, source, 1, tag)
|
||||
return jsonify({'success': True, 'content': content})
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)})
|
||||
|
||||
|
||||
@app.route('/api/article/<date_str>/save', methods=['POST'])
|
||||
def api_save(date_str):
|
||||
data = request.get_json()
|
||||
content = data.get('content', '').strip()
|
||||
post_time = data.get('post_time', POST_TIME)
|
||||
if not content:
|
||||
return jsonify({'success': False, 'error': 'Kein Inhalt'})
|
||||
existing = db.get_article_by_date(date_str, post_time)
|
||||
if existing:
|
||||
db.update_article_content(date_str, content, post_time=post_time)
|
||||
flog.article_saved(date_str, post_time)
|
||||
return jsonify({'success': True, 'article_id': existing['id']})
|
||||
all_today = db.get_articles_by_date(date_str)
|
||||
candidate = next((a for a in all_today if a['status'] in ('draft', 'scheduled', 'sent_to_bot', 'approved', 'pending_review')), None)
|
||||
if candidate:
|
||||
old_time = candidate['post_time']
|
||||
conn = db.get_conn()
|
||||
conn.execute("UPDATE articles SET post_time=? WHERE date=? AND post_time=?", (post_time, date_str, old_time))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
db.update_article_content(date_str, content, post_time=post_time)
|
||||
flog.article_saved(date_str, post_time)
|
||||
return jsonify({'success': True, 'article_id': candidate['id']})
|
||||
return jsonify({'success': False, 'error': 'Kein Artikel für diesen Tag vorhanden'})
|
||||
|
||||
|
||||
@app.route('/api/article/<date_str>/schedule', methods=['POST'])
|
||||
def api_schedule(date_str):
|
||||
"""Artikel einplanen: post_time + notify_at setzen."""
|
||||
data = request.get_json()
|
||||
post_time = data.get('post_time', POST_TIME)
|
||||
notify_mode = data.get('notify_mode', 'auto') # sofort | auto | custom
|
||||
notify_at_custom = data.get('notify_at')
|
||||
|
||||
existing = db.get_article_by_date(date_str, post_time)
|
||||
if not existing:
|
||||
all_today = db.get_articles_by_date(date_str)
|
||||
candidate = next((a for a in all_today if a['status'] in ('draft', 'scheduled', 'sent_to_bot', 'pending_review')), None)
|
||||
if not candidate:
|
||||
return jsonify({'success': False, 'error': 'Kein Artikel für diesen Tag gefunden'})
|
||||
old_time = candidate['post_time']
|
||||
conn = db.get_conn()
|
||||
conn.execute(
|
||||
"UPDATE articles SET post_time=? WHERE date=? AND post_time=?",
|
||||
(post_time, date_str, old_time)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
conn = db.get_conn()
|
||||
ts = datetime.utcnow().isoformat()
|
||||
conn.execute(
|
||||
"UPDATE articles SET status='approved', scheduled_at=?, notify_at=? WHERE date=? AND post_time=?",
|
||||
(ts, ts, date_str, post_time)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
notify_all_reviewers(
|
||||
f"📅 <b>Artikel eingeplant</b>\n\n"
|
||||
f"📆 {date_str} um <b>{post_time} Uhr</b>\n"
|
||||
f"Wird automatisch gepostet."
|
||||
)
|
||||
|
||||
flog.article_scheduled(date_str, post_time, ts)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/article/<int:article_id>/reschedule', methods=['POST'])
|
||||
def api_reschedule(article_id):
|
||||
data = request.get_json()
|
||||
new_date = data.get('date')
|
||||
new_time = data.get('post_time')
|
||||
if not new_date or not new_time:
|
||||
return jsonify({'success': False, 'error': 'date und post_time erforderlich'})
|
||||
# Slot-Konflikt prüfen
|
||||
if db.slot_is_taken(new_date, new_time, exclude_id=article_id):
|
||||
taken = db.get_taken_slots(new_date)
|
||||
flog.slot_conflict(new_date, new_time)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Slot {new_date} {new_time} ist bereits belegt.',
|
||||
'taken_slots': taken
|
||||
})
|
||||
ok = db.reschedule_article(article_id, new_date, new_time)
|
||||
if ok:
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False, 'error': 'Fehler beim Umplanen'})
|
||||
|
||||
|
||||
@app.route('/api/article/<int:article_id>/delete', methods=['POST'])
|
||||
def api_delete(article_id):
|
||||
db.delete_article(article_id)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/slots/<date_str>')
|
||||
def api_slots(date_str):
|
||||
"""Gibt belegte Slots für einen Tag zurück."""
|
||||
taken = db.get_taken_slots(date_str)
|
||||
return jsonify({'date': date_str, 'taken': taken})
|
||||
|
||||
|
||||
@app.route('/api/article/<date_str>/send-to-bot', methods=['POST'])
|
||||
def api_send_to_bot(date_str):
|
||||
data = request.get_json() or {}
|
||||
post_time = data.get('post_time', POST_TIME)
|
||||
article = db.get_article_by_date(date_str, post_time)
|
||||
if not article or not article.get('content_final'):
|
||||
return jsonify({'success': False, 'error': 'Kein Artikel vorhanden'})
|
||||
channel = db.get_channel()
|
||||
pt = channel.get('post_time', post_time)
|
||||
branded = with_branding(article['content_final'])
|
||||
text = (
|
||||
f"📋 <b>Review: {date_str} · {post_time} Uhr</b>\n"
|
||||
f"Version {article['version']}\n"
|
||||
f"──────────────────────\n\n"
|
||||
f"{branded}\n\n"
|
||||
f"──────────────────────\n"
|
||||
f"Freigeben oder bearbeiten?"
|
||||
)
|
||||
keyboard = {
|
||||
"inline_keyboard": [[
|
||||
{"text": "✅ Freigeben", "callback_data": f"approve:{date_str}:{post_time}"},
|
||||
{"text": "✏️ Bearbeiten", "callback_data": f"edit:{date_str}:{post_time}"}
|
||||
]]
|
||||
}
|
||||
results = notify_all_reviewers(text, keyboard)
|
||||
ok_count = sum(1 for r in results if r['ok'])
|
||||
if ok_count > 0:
|
||||
db.update_article_status(date_str, 'sent_to_bot', post_time=post_time)
|
||||
flog.article_sent_to_bot(date_str, post_time, [r['chat_id'] for r in results if r['ok']])
|
||||
return jsonify({'success': True})
|
||||
return jsonify({'success': False, 'error': 'Kein Reviewer erreichbar'})
|
||||
|
||||
|
||||
@app.route('/api/settings/post-time', methods=['POST'])
|
||||
def api_save_post_time():
|
||||
data = request.get_json()
|
||||
post_time = data.get('post_time', '19:55')
|
||||
channel = db.get_channel()
|
||||
db.update_channel(channel.get('telegram_id', ''), post_time)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/settings/location', methods=['POST'])
|
||||
def api_set_location():
|
||||
data = request.get_json()
|
||||
location_id = data.get('location_id')
|
||||
if not location_id:
|
||||
return jsonify({'success': False, 'error': 'Keine Location-ID'})
|
||||
db.set_location(location_id)
|
||||
loc = db.get_current_location()
|
||||
morning, afternoon = db.get_reminder_times_in_berlin(loc)
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'location': loc,
|
||||
'reminders_berlin': {
|
||||
'morning': f"{morning[0]:02d}:{morning[1]:02d}",
|
||||
'afternoon': f"{afternoon[0]:02d}:{afternoon[1]:02d}"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@app.route('/api/balance')
|
||||
def api_balance():
|
||||
try:
|
||||
balance = asyncio.run(openrouter.get_balance())
|
||||
return jsonify(balance)
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/article/<date_str>')
|
||||
def api_article(date_str):
|
||||
post_time = request.args.get('post_time')
|
||||
article = db.get_article_by_date(date_str, post_time)
|
||||
if not article:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
return jsonify(article)
|
||||
|
||||
|
||||
@app.route('/api/articles/<date_str>')
|
||||
def api_articles_for_date(date_str):
|
||||
"""Alle Artikel eines Tages (alle Slots)."""
|
||||
articles = db.get_articles_by_date(date_str)
|
||||
return jsonify(articles)
|
||||
|
||||
|
||||
@app.route('/api/article/<date_str>/approve', methods=['POST'])
|
||||
def api_approve(date_str):
|
||||
data = request.get_json() or {}
|
||||
post_time = data.get('post_time', POST_TIME)
|
||||
db.update_article_status(date_str, 'approved', post_time=post_time)
|
||||
flog.article_approved(date_str, post_time, 0)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
@app.route('/api/article/<date_str>/skip', methods=['POST'])
|
||||
def api_skip(date_str):
|
||||
data = request.get_json() or {}
|
||||
post_time = data.get('post_time', POST_TIME)
|
||||
db.update_article_status(date_str, 'skipped', post_time=post_time)
|
||||
flog.article_skipped(date_str, post_time)
|
||||
return jsonify({'success': True})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
db.init_db()
|
||||
app.run(host='0.0.0.0', port=8080, debug=False)
|
||||
732
fuenfvoacht/src/database.py
Normal file
732
fuenfvoacht/src/database.py
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
import sqlite3
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
import logger as flog
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
DB_PATH = os.environ.get('DB_PATH', '/data/fuenfvoracht.db')
|
||||
|
||||
DEFAULT_PROMPT = '''Du erstellst einen strukturierten Beitrag für den Telegram-Kanal "Fünf vor Acht".
|
||||
Der Beitrag präsentiert einen Inhalt (Video, Artikel, Vortrag) neutral und informativ.
|
||||
Leser sollen sich selbst ein Bild machen können.
|
||||
|
||||
EINGABE: {source}
|
||||
DATUM: {date}
|
||||
THEMA: {tag}
|
||||
|
||||
AUFGABE:
|
||||
Analysiere die Quelle und erstelle einen Telegram-Beitrag nach exakt diesem FORMAT.
|
||||
Wähle passende Emojis für die Sektions-Überschriften je nach Thema.
|
||||
Schreibe sachlich und ohne eigene Wertung.
|
||||
|
||||
FORMAT — exakt so ausgeben (Telegram-kompatibel, kein HTML):
|
||||
|
||||
[Kategorie-Emoji] [Typ]: [Vollständiger Titel]
|
||||
|
||||
🔗 [Quelle ansehen / Artikel lesen / Video ansehen]:
|
||||
[URL aus der Eingabe]
|
||||
|
||||
[Themen-Emoji] Inhaltlicher Schwerpunkt
|
||||
|
||||
[2-3 Sätze: Wer spricht/schreibt worüber und in welchem Kontext]
|
||||
|
||||
Themen im Überblick:
|
||||
• [Kernthema 1]
|
||||
• [Kernthema 2]
|
||||
• [Kernthema 3]
|
||||
• [Kernthema 4]
|
||||
• [Kernthema 5]
|
||||
|
||||
[2. Themen-Emoji] [Zweiter Schwerpunkt falls vorhanden]
|
||||
|
||||
[2-3 Sätze zum zweiten Teil]
|
||||
|
||||
• [Unterpunkt 1]
|
||||
• [Unterpunkt 2]
|
||||
• [Unterpunkt 3]
|
||||
|
||||
📌 Einordnung
|
||||
|
||||
[2-3 Sätze: Neutrale Beschreibung des Formats/der Methode. Kein Urteil. Leser können Quellen selbst prüfen.]'''
|
||||
|
||||
|
||||
def get_conn():
|
||||
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
return conn
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_conn()
|
||||
c = conn.cursor()
|
||||
|
||||
# Basis-Tabellen
|
||||
c.executescript('''
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
post_time TEXT NOT NULL DEFAULT '19:55',
|
||||
source_input TEXT,
|
||||
content_raw TEXT,
|
||||
content_final TEXT,
|
||||
status TEXT DEFAULT 'draft',
|
||||
version INTEGER DEFAULT 1,
|
||||
review_message_id INTEGER,
|
||||
review_chat_id INTEGER,
|
||||
prompt_id INTEGER,
|
||||
tag TEXT,
|
||||
notify_at TEXT,
|
||||
scheduled_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
sent_to_bot_at TEXT,
|
||||
approved_at TEXT,
|
||||
posted_at TEXT,
|
||||
UNIQUE(date, post_time)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS article_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
article_id INTEGER NOT NULL,
|
||||
version_nr INTEGER NOT NULL,
|
||||
content TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (article_id) REFERENCES articles(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS post_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
article_id INTEGER NOT NULL,
|
||||
channel_message_id INTEGER,
|
||||
posted_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (article_id) REFERENCES articles(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS prompts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
system_prompt TEXT NOT NULL,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
last_tested_at TEXT,
|
||||
test_result TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sources_favorites (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
label TEXT NOT NULL,
|
||||
url TEXT,
|
||||
used_count INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS article_tags (
|
||||
article_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (article_id, tag_id),
|
||||
FOREIGN KEY (article_id) REFERENCES articles(id),
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
telegram_id TEXT,
|
||||
post_time TEXT DEFAULT '19:55',
|
||||
timezone TEXT DEFAULT 'Europe/Berlin',
|
||||
active INTEGER DEFAULT 1
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS locations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
flag TEXT NOT NULL,
|
||||
timezone TEXT NOT NULL,
|
||||
reminder_morning TEXT DEFAULT '10:00',
|
||||
reminder_afternoon TEXT DEFAULT '18:00'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS reviewers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chat_id INTEGER NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
active INTEGER DEFAULT 1,
|
||||
added_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
''')
|
||||
|
||||
# Migration: post_time Spalte zu bestehender articles-Tabelle hinzufügen falls fehlt
|
||||
cols = [r[1] for r in c.execute("PRAGMA table_info(articles)").fetchall()]
|
||||
if 'post_time' not in cols:
|
||||
c.execute("ALTER TABLE articles ADD COLUMN post_time TEXT NOT NULL DEFAULT '19:55'")
|
||||
_logger.info("Migration: post_time Spalte hinzugefügt")
|
||||
if 'notify_at' not in cols:
|
||||
c.execute("ALTER TABLE articles ADD COLUMN notify_at TEXT")
|
||||
if 'scheduled_at' not in cols:
|
||||
c.execute("ALTER TABLE articles ADD COLUMN scheduled_at TEXT")
|
||||
|
||||
# Migration: UNIQUE(date) → UNIQUE(date, post_time)
|
||||
# Alte DBs hatten nur UNIQUE auf date; das verhindert mehrere Artikel pro Tag
|
||||
unique_idx_cols = [r[2] for r in c.execute("PRAGMA index_info(sqlite_autoindex_articles_1)").fetchall()]
|
||||
if unique_idx_cols == ['date']:
|
||||
_logger.info("Migration: UNIQUE(date) → UNIQUE(date, post_time) wird durchgeführt")
|
||||
c.executescript("""
|
||||
CREATE TABLE articles_migrated (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
post_time TEXT NOT NULL DEFAULT '19:55',
|
||||
source_input TEXT,
|
||||
content_raw TEXT,
|
||||
content_final TEXT,
|
||||
status TEXT DEFAULT 'draft',
|
||||
version INTEGER DEFAULT 1,
|
||||
review_message_id INTEGER,
|
||||
review_chat_id INTEGER,
|
||||
prompt_id INTEGER,
|
||||
tag TEXT,
|
||||
notify_at TEXT,
|
||||
scheduled_at TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
sent_to_bot_at TEXT,
|
||||
approved_at TEXT,
|
||||
posted_at TEXT,
|
||||
UNIQUE(date, post_time)
|
||||
);
|
||||
INSERT INTO articles_migrated
|
||||
SELECT id, date, post_time, source_input, content_raw, content_final,
|
||||
status, version, review_message_id, review_chat_id, prompt_id,
|
||||
tag, notify_at, scheduled_at, created_at, sent_to_bot_at,
|
||||
approved_at, posted_at
|
||||
FROM articles;
|
||||
DROP TABLE articles;
|
||||
ALTER TABLE articles_migrated RENAME TO articles;
|
||||
""")
|
||||
_logger.info("Migration: UNIQUE(date, post_time) erfolgreich")
|
||||
|
||||
# reviewers-Tabelle Migration
|
||||
reviewer_cols = [r[1] for r in c.execute("PRAGMA table_info(reviewers)").fetchall()]
|
||||
if not reviewer_cols:
|
||||
_logger.info("Migration: reviewers Tabelle erstellt")
|
||||
|
||||
# Standard-Daten
|
||||
c.execute("SELECT COUNT(*) FROM prompts")
|
||||
if c.fetchone()[0] == 0:
|
||||
c.execute(
|
||||
"INSERT INTO prompts (name, system_prompt, is_default) VALUES (?, ?, 1)",
|
||||
("Standard", DEFAULT_PROMPT)
|
||||
)
|
||||
c.execute("SELECT COUNT(*) FROM channels")
|
||||
if c.fetchone()[0] == 0:
|
||||
c.execute(
|
||||
"INSERT INTO channels (name, telegram_id, post_time, timezone) VALUES (?, ?, ?, ?)",
|
||||
("Fünf vor Acht", "", "19:55", "Europe/Berlin")
|
||||
)
|
||||
for tag in ["Politik", "Wirtschaft", "Tech", "Gesellschaft", "Umwelt", "Kultur", "Sport"]:
|
||||
c.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag,))
|
||||
|
||||
locations = [
|
||||
("Deutschland", "🇩🇪", "Europe/Berlin", "10:00", "18:00"),
|
||||
("Kambodscha", "🇰🇭", "Asia/Phnom_Penh", "10:00", "18:00"),
|
||||
("Thailand", "🇹🇭", "Asia/Bangkok", "10:00", "18:00"),
|
||||
("USA Ostküste", "🇺🇸", "America/New_York", "10:00", "18:00"),
|
||||
("USA Westküste","🇺🇸", "America/Los_Angeles", "10:00", "18:00"),
|
||||
("Spanien", "🇪🇸", "Europe/Madrid", "10:00", "18:00"),
|
||||
]
|
||||
c.execute("SELECT COUNT(*) FROM locations")
|
||||
if c.fetchone()[0] == 0:
|
||||
for loc in locations:
|
||||
c.execute(
|
||||
"INSERT INTO locations (name, flag, timezone, reminder_morning, reminder_afternoon) VALUES (?,?,?,?,?)",
|
||||
loc
|
||||
)
|
||||
|
||||
c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES ('user_location_id', '1')")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
_logger.info("DB initialisiert: %s", DB_PATH)
|
||||
|
||||
|
||||
# ── Article CRUD ──────────────────────────────────────────────────────────────
|
||||
|
||||
def get_article_by_date(date_str, post_time=None):
|
||||
"""Gibt ersten Artikel des Tages zurück, oder den mit spezifischer post_time."""
|
||||
conn = get_conn()
|
||||
if post_time:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM articles WHERE date=? AND post_time=?", (date_str, post_time)
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM articles WHERE date=? ORDER BY post_time ASC LIMIT 1", (date_str,)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_articles_by_date(date_str):
|
||||
"""Gibt alle Artikel eines Tages zurück (mehrere Slots)."""
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM articles WHERE date=? ORDER BY post_time ASC", (date_str,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_article_by_id(article_id):
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM articles WHERE id=?", (article_id,)).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def slot_is_taken(date_str, post_time, exclude_id=None):
|
||||
"""Prüft ob ein Zeitslot bereits belegt ist."""
|
||||
conn = get_conn()
|
||||
if exclude_id:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM articles WHERE date=? AND post_time=? AND id!=? AND status NOT IN ('skipped','rejected')",
|
||||
(date_str, post_time, exclude_id)
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM articles WHERE date=? AND post_time=? AND status NOT IN ('skipped','rejected')",
|
||||
(date_str, post_time)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return row is not None
|
||||
|
||||
|
||||
def get_taken_slots(date_str):
|
||||
"""Gibt alle belegten Zeitslots eines Tages zurück."""
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT post_time FROM articles WHERE date=? AND status NOT IN ('skipped','rejected')",
|
||||
(date_str,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [r[0] for r in rows]
|
||||
|
||||
|
||||
def create_article(date_str, source_input, content, prompt_id, tag="allgemein", post_time="19:55"):
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO articles (date, post_time, source_input, content_raw, content_final, prompt_id, tag) VALUES (?,?,?,?,?,?,?)",
|
||||
(date_str, post_time, source_input, content, content, prompt_id, tag)
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO article_versions (article_id, version_nr, content) "
|
||||
"VALUES ((SELECT id FROM articles WHERE date=? AND post_time=?), 1, ?)",
|
||||
(date_str, post_time, content)
|
||||
)
|
||||
conn.commit()
|
||||
flog.article_generated(date_str, source_input or '', 1, tag)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def reschedule_article(article_id, new_date, new_post_time):
|
||||
"""Verschiebt einen Artikel auf einen neuen Datum/Zeit-Slot."""
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE articles SET date=?, post_time=? WHERE id=?",
|
||||
(new_date, new_post_time, article_id)
|
||||
)
|
||||
conn.commit()
|
||||
flog.info('article_rescheduled', article_id=article_id,
|
||||
new_date=new_date, new_post_time=new_post_time)
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
flog.slot_conflict(new_date, new_post_time)
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_article(article_id):
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM article_versions WHERE article_id=?", (article_id,))
|
||||
conn.execute("DELETE FROM articles WHERE id=?", (article_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flog.info('article_deleted', article_id=article_id)
|
||||
|
||||
|
||||
def update_article_status(date_str, status, message_id=None, chat_id=None, post_time=None):
|
||||
conn = get_conn()
|
||||
ts = datetime.utcnow().isoformat()
|
||||
where = "date=? AND post_time=?" if post_time else "date=?"
|
||||
params_base = (date_str, post_time) if post_time else (date_str,)
|
||||
|
||||
if status == 'approved':
|
||||
conn.execute(
|
||||
f"UPDATE articles SET status=?, approved_at=?, review_message_id=?, review_chat_id=? WHERE {where}",
|
||||
(status, ts, message_id, chat_id) + params_base
|
||||
)
|
||||
elif status == 'posted':
|
||||
conn.execute(
|
||||
f"UPDATE articles SET status=?, posted_at=? WHERE {where}",
|
||||
(status, ts) + params_base
|
||||
)
|
||||
elif status == 'sent_to_bot':
|
||||
conn.execute(
|
||||
f"UPDATE articles SET status=?, sent_to_bot_at=?, review_message_id=?, review_chat_id=? WHERE {where}",
|
||||
(status, ts, message_id, chat_id) + params_base
|
||||
)
|
||||
elif status == 'scheduled':
|
||||
conn.execute(
|
||||
f"UPDATE articles SET status=?, scheduled_at=? WHERE {where}",
|
||||
(status, ts) + params_base
|
||||
)
|
||||
else:
|
||||
conn.execute(f"UPDATE articles SET status=? WHERE {where}", (status,) + params_base)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def schedule_article(date_str, post_time, notify_at):
|
||||
"""Artikel einplanen mit Bot-Benachrichtigungs-Zeitpunkt."""
|
||||
conn = get_conn()
|
||||
ts = datetime.utcnow().isoformat()
|
||||
conn.execute(
|
||||
"UPDATE articles SET status='scheduled', scheduled_at=?, post_time=?, notify_at=? WHERE date=? AND post_time=?",
|
||||
(ts, post_time, notify_at, date_str, post_time)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flog.article_scheduled(date_str, post_time, notify_at)
|
||||
|
||||
|
||||
def get_due_notifications():
|
||||
"""Gibt alle scheduled-Artikel zurück deren notify_at <= jetzt."""
|
||||
conn = get_conn()
|
||||
now = datetime.utcnow().isoformat()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM articles WHERE status='scheduled' AND notify_at IS NOT NULL AND notify_at <= ?",
|
||||
(now,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def update_article_content(date_str, content, new_version=False, post_time=None):
|
||||
conn = get_conn()
|
||||
where = "date=? AND post_time=?" if post_time else "date=?"
|
||||
params_base = (date_str, post_time) if post_time else (date_str,)
|
||||
|
||||
if new_version:
|
||||
version = conn.execute(
|
||||
f"SELECT version FROM articles WHERE {where}", params_base
|
||||
).fetchone()
|
||||
new_v = (version[0] or 1) + 1
|
||||
conn.execute(
|
||||
f"UPDATE articles SET content_raw=?, content_final=?, version=?, status='pending_review' WHERE {where}",
|
||||
(content, content, new_v) + params_base
|
||||
)
|
||||
article_id = conn.execute(
|
||||
f"SELECT id FROM articles WHERE {where}", params_base
|
||||
).fetchone()[0]
|
||||
conn.execute(
|
||||
"INSERT INTO article_versions (article_id, version_nr, content) VALUES (?,?,?)",
|
||||
(article_id, new_v, content)
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
f"UPDATE articles SET content_final=? WHERE {where}", (content,) + params_base
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_post_history(date_str, channel_message_id, post_time=None):
|
||||
conn = get_conn()
|
||||
where = "date=? AND post_time=?" if post_time else "date=?"
|
||||
params = (date_str, post_time) if post_time else (date_str,)
|
||||
article_id = conn.execute(
|
||||
f"SELECT id FROM articles WHERE {where}", params
|
||||
).fetchone()
|
||||
if article_id:
|
||||
conn.execute(
|
||||
"INSERT INTO post_history (article_id, channel_message_id) VALUES (?,?)",
|
||||
(article_id[0], channel_message_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_recent_articles(limit=30):
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM articles ORDER BY date DESC, post_time ASC LIMIT ?", (limit,)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_week_articles(from_date, to_date):
|
||||
conn = get_conn()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM articles WHERE date BETWEEN ? AND ? ORDER BY date ASC, post_time ASC",
|
||||
(from_date, to_date)
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_last_posted():
|
||||
conn = get_conn()
|
||||
row = conn.execute(
|
||||
"SELECT date, post_time, posted_at FROM articles WHERE status='posted' ORDER BY posted_at DESC LIMIT 1"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── Prompts ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_prompts():
|
||||
conn = get_conn()
|
||||
rows = conn.execute("SELECT * FROM prompts ORDER BY is_default DESC, id ASC").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_default_prompt():
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM prompts WHERE is_default=1 LIMIT 1").fetchone()
|
||||
if not row:
|
||||
row = conn.execute("SELECT * FROM prompts LIMIT 1").fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def save_prompt(prompt_id, name, system_prompt):
|
||||
conn = get_conn()
|
||||
conn.execute("UPDATE prompts SET name=?, system_prompt=? WHERE id=?", (name, system_prompt, prompt_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_prompt(name, system_prompt):
|
||||
conn = get_conn()
|
||||
conn.execute("INSERT INTO prompts (name, system_prompt) VALUES (?,?)", (name, system_prompt))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_default_prompt(prompt_id):
|
||||
conn = get_conn()
|
||||
conn.execute("UPDATE prompts SET is_default=0")
|
||||
conn.execute("UPDATE prompts SET is_default=1 WHERE id=?", (prompt_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_prompt(prompt_id):
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM prompts WHERE id=? AND is_default=0", (prompt_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_prompt_test_result(prompt_id, result):
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"UPDATE prompts SET test_result=?, last_tested_at=? WHERE id=?",
|
||||
(result, datetime.utcnow().isoformat(), prompt_id)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Channel / Settings ────────────────────────────────────────────────────────
|
||||
|
||||
def get_channel():
|
||||
conn = get_conn()
|
||||
row = conn.execute("SELECT * FROM channels WHERE active=1 LIMIT 1").fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else {}
|
||||
|
||||
|
||||
def update_channel(telegram_id, post_time="19:55"):
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"UPDATE channels SET telegram_id=?, post_time=? WHERE active=1",
|
||||
(telegram_id, post_time)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Reviewers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_reviewers(active_only=True):
|
||||
conn = get_conn()
|
||||
if active_only:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM reviewers WHERE active=1 ORDER BY added_at ASC"
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM reviewers ORDER BY added_at ASC").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_reviewer(chat_id: int, name: str):
|
||||
conn = get_conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO reviewers (chat_id, name, active) VALUES (?,?,1)",
|
||||
(chat_id, name)
|
||||
)
|
||||
conn.commit()
|
||||
flog.reviewer_added(chat_id, name)
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def remove_reviewer(chat_id: int):
|
||||
conn = get_conn()
|
||||
conn.execute("UPDATE reviewers SET active=0 WHERE chat_id=?", (chat_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
flog.reviewer_removed(chat_id)
|
||||
|
||||
|
||||
def get_reviewer_chat_ids():
|
||||
"""Gibt aktive Reviewer-Chat-IDs aus DB zurück, Fallback auf ENV."""
|
||||
reviewers = get_reviewers(active_only=True)
|
||||
if reviewers:
|
||||
return [r['chat_id'] for r in reviewers]
|
||||
# Fallback: ENV
|
||||
import os
|
||||
ids = []
|
||||
raw = os.environ.get('REVIEW_CHAT_IDS', '')
|
||||
if raw.strip():
|
||||
for part in raw.split(','):
|
||||
try:
|
||||
ids.append(int(part.strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
admin = os.environ.get('ADMIN_CHAT_ID', '')
|
||||
if admin:
|
||||
try:
|
||||
ids.append(int(admin))
|
||||
except ValueError:
|
||||
pass
|
||||
unique = []
|
||||
for cid in ids:
|
||||
if cid not in unique:
|
||||
unique.append(cid)
|
||||
return unique
|
||||
|
||||
|
||||
# ── Favorites ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_favorites():
|
||||
conn = get_conn()
|
||||
rows = conn.execute("SELECT * FROM sources_favorites ORDER BY used_count DESC").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def add_favorite(label, url):
|
||||
conn = get_conn()
|
||||
conn.execute("INSERT INTO sources_favorites (label, url) VALUES (?,?)", (label, url))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def increment_favorite(fav_id):
|
||||
conn = get_conn()
|
||||
conn.execute("UPDATE sources_favorites SET used_count=used_count+1 WHERE id=?", (fav_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Tags ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_tags():
|
||||
conn = get_conn()
|
||||
rows = conn.execute("SELECT * FROM tags ORDER BY name").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Locations ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_locations():
|
||||
conn = get_conn()
|
||||
rows = conn.execute("SELECT * FROM locations ORDER BY id").fetchall()
|
||||
conn.close()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def get_current_location():
|
||||
conn = get_conn()
|
||||
loc_id = conn.execute(
|
||||
"SELECT value FROM settings WHERE key='user_location_id'"
|
||||
).fetchone()
|
||||
if not loc_id:
|
||||
conn.close()
|
||||
return None
|
||||
row = conn.execute("SELECT * FROM locations WHERE id=?", (loc_id[0],)).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def set_location(location_id):
|
||||
conn = get_conn()
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('user_location_id', ?)",
|
||||
(str(location_id),)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_reminder_times_in_berlin(location: dict) -> tuple:
|
||||
import pytz
|
||||
from datetime import datetime, date
|
||||
user_tz = pytz.timezone(location['timezone'])
|
||||
berlin_tz = pytz.timezone('Europe/Berlin')
|
||||
today = date.today()
|
||||
|
||||
def convert(local_time_str):
|
||||
h, m = map(int, local_time_str.split(':'))
|
||||
local_dt = user_tz.localize(datetime(today.year, today.month, today.day, h, m))
|
||||
berlin_dt = local_dt.astimezone(berlin_tz)
|
||||
return berlin_dt.hour, berlin_dt.minute
|
||||
|
||||
return convert(location['reminder_morning']), convert(location['reminder_afternoon'])
|
||||
|
||||
|
||||
# ── Stats ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_monthly_stats():
|
||||
conn = get_conn()
|
||||
from datetime import date
|
||||
month = date.today().strftime('%Y-%m')
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM articles WHERE date LIKE ?", (f"{month}%",)
|
||||
).fetchone()[0]
|
||||
posted = conn.execute(
|
||||
"SELECT COUNT(*) FROM articles WHERE date LIKE ? AND status='posted'", (f"{month}%",)
|
||||
).fetchone()[0]
|
||||
skipped = conn.execute(
|
||||
"SELECT COUNT(*) FROM articles WHERE date LIKE ? AND status='skipped'", (f"{month}%",)
|
||||
).fetchone()[0]
|
||||
avg_version = conn.execute(
|
||||
"SELECT AVG(version) FROM articles WHERE date LIKE ?", (f"{month}%",)
|
||||
).fetchone()[0] or 0
|
||||
conn.close()
|
||||
return {"total": total, "posted": posted, "skipped": skipped, "avg_version": round(avg_version, 1)}
|
||||
100
fuenfvoacht/src/logger.py
Normal file
100
fuenfvoacht/src/logger.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""
|
||||
Strukturiertes Logging für FünfVorAcht.
|
||||
Schreibt JSON-Lines nach /logs/fuenfvoracht.log
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
LOG_PATH = os.environ.get('LOG_PATH', '/logs/fuenfvoracht.log')
|
||||
|
||||
_file_handler = None
|
||||
|
||||
|
||||
def _get_file_handler():
|
||||
global _file_handler
|
||||
if _file_handler is None:
|
||||
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
|
||||
_file_handler = logging.FileHandler(LOG_PATH, encoding='utf-8')
|
||||
_file_handler.setLevel(logging.DEBUG)
|
||||
return _file_handler
|
||||
|
||||
|
||||
def _write(level: str, event: str, **kwargs):
|
||||
entry = {
|
||||
'ts': datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
'level': level,
|
||||
'event': event,
|
||||
**kwargs,
|
||||
}
|
||||
line = json.dumps(entry, ensure_ascii=False)
|
||||
try:
|
||||
handler = _get_file_handler()
|
||||
record = logging.LogRecord(
|
||||
name='fuenfvoracht', level=getattr(logging, level),
|
||||
pathname='', lineno=0, msg=line, args=(), exc_info=None
|
||||
)
|
||||
handler.emit(record)
|
||||
except Exception:
|
||||
pass
|
||||
# Auch in stdout damit docker logs es zeigt
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
def info(event: str, **kwargs):
|
||||
_write('INFO', event, **kwargs)
|
||||
|
||||
|
||||
def warning(event: str, **kwargs):
|
||||
_write('WARNING', event, **kwargs)
|
||||
|
||||
|
||||
def error(event: str, **kwargs):
|
||||
_write('ERROR', event, **kwargs)
|
||||
|
||||
|
||||
# Kurzformen für häufige Events
|
||||
def article_generated(date: str, source: str, version: int, tag: str):
|
||||
info('article_generated', date=date, source=source[:120], version=version, tag=tag)
|
||||
|
||||
|
||||
def article_saved(date: str, post_time: str):
|
||||
info('article_saved', date=date, post_time=post_time)
|
||||
|
||||
|
||||
def article_scheduled(date: str, post_time: str, notify_at: str):
|
||||
info('article_scheduled', date=date, post_time=post_time, notify_at=notify_at)
|
||||
|
||||
|
||||
def article_sent_to_bot(date: str, post_time: str, chat_ids: list):
|
||||
info('article_sent_to_bot', date=date, post_time=post_time, chat_ids=chat_ids)
|
||||
|
||||
|
||||
def article_approved(date: str, post_time: str, by_chat_id: int):
|
||||
info('article_approved', date=date, post_time=post_time, by_chat_id=by_chat_id)
|
||||
|
||||
|
||||
def article_posted(date: str, post_time: str, channel_id: str, message_id: int):
|
||||
info('article_posted', date=date, post_time=post_time,
|
||||
channel_id=channel_id, message_id=message_id)
|
||||
|
||||
|
||||
def article_skipped(date: str, post_time: str):
|
||||
info('article_skipped', date=date, post_time=post_time)
|
||||
|
||||
|
||||
def posting_failed(date: str, post_time: str, reason: str):
|
||||
error('posting_failed', date=date, post_time=post_time, reason=reason[:300])
|
||||
|
||||
|
||||
def reviewer_added(chat_id: int, name: str):
|
||||
info('reviewer_added', chat_id=chat_id, name=name)
|
||||
|
||||
|
||||
def reviewer_removed(chat_id: int):
|
||||
info('reviewer_removed', chat_id=chat_id)
|
||||
|
||||
|
||||
def slot_conflict(date: str, post_time: str):
|
||||
warning('slot_conflict', date=date, post_time=post_time)
|
||||
72
fuenfvoacht/src/openrouter.py
Normal file
72
fuenfvoacht/src/openrouter.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import os
|
||||
import logging
|
||||
import aiohttp
|
||||
import asyncio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
OPENROUTER_API_KEY = os.environ.get('OPENROUTER_API_KEY', '')
|
||||
OPENROUTER_BASE = "https://openrouter.ai/api/v1"
|
||||
DEFAULT_MODEL = os.environ.get('AI_MODEL', 'openai/gpt-4o-mini')
|
||||
|
||||
|
||||
async def generate_article(source: str, prompt_template: str, date_str: str, tag: str = "allgemein") -> str:
|
||||
system_prompt = prompt_template.format(
|
||||
source=source,
|
||||
date=date_str,
|
||||
tag=tag.lower().replace(" ", "")
|
||||
)
|
||||
payload = {
|
||||
"model": DEFAULT_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": f"Schreibe jetzt den Artikel basierend auf dieser Quelle:\n\n{source}"}
|
||||
],
|
||||
"max_tokens": 600,
|
||||
"temperature": 0.8
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://fuenfvoracht.orbitalo.net",
|
||||
"X-Title": "FünfVorAcht Bot"
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(f"{OPENROUTER_BASE}/chat/completions", json=payload, headers=headers) as resp:
|
||||
data = await resp.json()
|
||||
if resp.status != 200:
|
||||
raise Exception(f"OpenRouter Fehler {resp.status}: {data}")
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
|
||||
async def get_balance() -> dict:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(f"{OPENROUTER_BASE}/auth/key", headers=headers) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
key_data = data.get("data", {})
|
||||
limit = key_data.get("limit")
|
||||
usage = key_data.get("usage", 0)
|
||||
if limit:
|
||||
remaining = round(limit - usage, 4)
|
||||
else:
|
||||
remaining = None
|
||||
return {
|
||||
"usage": round(usage, 4),
|
||||
"limit": limit,
|
||||
"remaining": remaining,
|
||||
"label": key_data.get("label", ""),
|
||||
"is_free_tier": key_data.get("is_free_tier", False)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Balance-Abfrage fehlgeschlagen: %s", e)
|
||||
return {"usage": None, "limit": None, "remaining": None}
|
||||
|
||||
|
||||
def get_balance_sync() -> dict:
|
||||
return asyncio.run(get_balance())
|
||||
4
fuenfvoacht/src/requirements-bot.txt
Normal file
4
fuenfvoacht/src/requirements-bot.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
python-telegram-bot==20.7
|
||||
apscheduler==3.10.4
|
||||
aiohttp==3.9.3
|
||||
pytz==2024.1
|
||||
5
fuenfvoacht/src/requirements-web.txt
Normal file
5
fuenfvoacht/src/requirements-web.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
flask==3.0.2
|
||||
aiohttp==3.9.3
|
||||
pytz==2024.1
|
||||
gunicorn==21.2.0
|
||||
requests==2.31.0
|
||||
386
fuenfvoacht/src/templates/hilfe.html
Normal file
386
fuenfvoacht/src/templates/hilfe.html
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Anleitung — FünfVorAcht</title>
|
||||
<link rel="stylesheet" href="/static/tailwind.min.css">
|
||||
<style>
|
||||
body { background: #0f172a; color: #e2e8f0; }
|
||||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
|
||||
.step { background: #0f172a; border: 1px solid #334155; border-radius: 8px; }
|
||||
.badge { border-radius: 999px; font-size: 0.7rem; font-weight: 700; padding: 2px 10px; }
|
||||
.tag-new { background: #1e3a5f; color: #60a5fa; }
|
||||
.tag-bot { background: #14532d; color: #4ade80; }
|
||||
.tag-plan { background: #4c1d95; color: #c4b5fd; }
|
||||
details summary { cursor: pointer; list-style: none; }
|
||||
details summary::-webkit-details-marker { display: none; }
|
||||
details[open] summary .chevron { transform: rotate(90deg); }
|
||||
.chevron { display: inline-block; transition: transform .2s; }
|
||||
code { background: #0f172a; border: 1px solid #334155; border-radius: 4px; padding: 1px 6px; font-size: 0.8rem; color: #94a3b8; }
|
||||
kbd { background: #334155; border-radius: 4px; padding: 1px 6px; font-size: 0.8rem; color: #e2e8f0; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-3 flex items-center justify-between sticky top-0 z-50">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1 bg-slate-800 border border-slate-700 rounded-lg p-1">
|
||||
<span class="flex items-center gap-1.5 bg-slate-700 text-white text-xs font-semibold px-3 py-1.5 rounded-md">🕗 FünfVorAcht</span>
|
||||
<a href="https://redakteur.orbitalo.net" target="_blank"
|
||||
class="flex items-center gap-1.5 text-slate-500 hover:text-slate-200 hover:bg-slate-700 text-xs font-medium px-3 py-1.5 rounded-md transition">📝 Redakteur</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-5 text-sm">
|
||||
<a href="/" class="text-slate-400 hover:text-white">Studio</a>
|
||||
<a href="/history" class="text-slate-400 hover:text-white">History</a>
|
||||
<a href="/prompts" class="text-slate-400 hover:text-white">Prompts</a>
|
||||
<a href="/settings" class="text-slate-400 hover:text-white">Einstellungen</a>
|
||||
<a href="/hilfe" class="text-blue-400 font-semibold">❓ Hilfe</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-6 py-8 space-y-4">
|
||||
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Anleitung</h1>
|
||||
<span class="text-slate-500 text-sm">FünfVorAcht — KI-gestützter Telegram-Poster</span>
|
||||
</div>
|
||||
|
||||
<!-- Schnellübersicht -->
|
||||
<div class="card p-5">
|
||||
<h2 class="text-base font-semibold text-white mb-3">⚡ Schnellübersicht — Normaler Tagesablauf</h2>
|
||||
<div class="flex flex-wrap gap-2 items-center text-sm text-slate-300">
|
||||
<div class="step px-3 py-2">1. Quelle eingeben</div>
|
||||
<span class="text-slate-600">→</span>
|
||||
<div class="step px-3 py-2">2. Artikel generieren</div>
|
||||
<span class="text-slate-600">→</span>
|
||||
<div class="step px-3 py-2">3. Redigieren & speichern</div>
|
||||
<span class="text-slate-600">→</span>
|
||||
<div class="step px-3 py-2">4. Einplanen (Uhrzeit)</div>
|
||||
<span class="text-slate-600">→</span>
|
||||
<div class="step px-3 py-2">5. Zum Bot senden</div>
|
||||
<span class="text-slate-600">→</span>
|
||||
<div class="step px-3 py-2">6. Im Bot freigeben ✅</div>
|
||||
<span class="text-slate-600">→</span>
|
||||
<div class="step bg-green-900/30 border-green-700 px-3 py-2 text-green-400">7. Automatisch gepostet 📤</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 1. Artikel erstellen -->
|
||||
<details class="card" open>
|
||||
<summary class="p-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl">✏️</span>
|
||||
<h2 class="text-base font-semibold text-white">1. Artikel erstellen</h2>
|
||||
</div>
|
||||
<span class="chevron text-slate-400 text-sm">▶</span>
|
||||
</summary>
|
||||
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-blue-400">Quelle eingeben</div>
|
||||
<p class="text-xs text-slate-400">URL eines Artikels, Videos oder Vortrags einfügen — oder ein Thema als Text beschreiben.</p>
|
||||
<p class="text-xs text-slate-500">Tipp: Häufig genutzte Quellen als <span class="text-slate-300">Favoriten</span> speichern → Dropdown nutzen.</p>
|
||||
</div>
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-blue-400">Thema & Prompt wählen</div>
|
||||
<p class="text-xs text-slate-400">Tag (z.B. Politik, Tech) und den gewünschten KI-Prompt auswählen.</p>
|
||||
<p class="text-xs text-slate-500">Prompts können unter <a href="/prompts" class="text-blue-400 hover:underline">Prompts</a> bearbeitet und getestet werden.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-blue-400">⚡ Generieren</div>
|
||||
<p class="text-xs text-slate-400">Button <kbd>Artikel generieren</kbd> klicken — die KI erstellt einen fertigen Telegram-Beitrag. Rechts erscheint sofort die Telegram-Vorschau.</p>
|
||||
<p class="text-xs text-slate-500">Nicht zufrieden? <kbd>Neu generieren</kbd> erstellt eine neue Version (v2, v3 …). Alle Versionen werden gespeichert.</p>
|
||||
</div>
|
||||
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-blue-400">✍️ Redigieren</div>
|
||||
<p class="text-xs text-slate-400">Text im Editor direkt bearbeiten. Die Telegram-Vorschau aktualisiert sich in Echtzeit. Zeichenanzahl wird live angezeigt (max. 4096).</p>
|
||||
<p class="text-xs text-slate-500">Das Markenzeichen wird automatisch am Ende eingefügt — nicht manuell nötig.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 2. Zeitlich einplanen -->
|
||||
<details class="card">
|
||||
<summary class="p-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl">📅</span>
|
||||
<h2 class="text-base font-semibold text-white">2. Zeitlich einplanen</h2>
|
||||
<span class="badge tag-new">NEU</span>
|
||||
</div>
|
||||
<span class="chevron text-slate-400 text-sm">▶</span>
|
||||
</summary>
|
||||
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
|
||||
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-purple-400">📅 Einplanen-Panel</div>
|
||||
<p class="text-xs text-slate-400">Nach dem Generieren auf <kbd>Einplanen</kbd> klicken. Ein Panel öffnet sich:</p>
|
||||
<ul class="text-xs text-slate-400 space-y-1 mt-2 ml-3">
|
||||
<li>• <span class="text-white">Datum</span> — aus dem Redaktionsplan übernommen oder frei wählbar</li>
|
||||
<li>• <span class="text-white">Uhrzeit</span> — 15-Minuten-Raster (z.B. 07:00, 19:45, 19:55)</li>
|
||||
<li>• <span class="text-white">Bot-Benachrichtigung</span> — Sofort / Vortag 17:00 / Posting-Tag 10:00</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div class="step p-3">
|
||||
<div class="text-xs font-semibold text-slate-300 mb-1">Artikel für heute</div>
|
||||
<p class="text-xs text-slate-500">Bot-Benachrichtigung: <span class="text-yellow-400">Sofort</span> vorausgewählt</p>
|
||||
</div>
|
||||
<div class="step p-3">
|
||||
<div class="text-xs font-semibold text-slate-300 mb-1">Artikel für morgen/später</div>
|
||||
<p class="text-xs text-slate-500">Automatisch: <span class="text-yellow-400">Vortag 17:00 Uhr</span> vorausgewählt</p>
|
||||
</div>
|
||||
<div class="step p-3">
|
||||
<div class="text-xs font-semibold text-slate-300 mb-1">Zeitkonflikt</div>
|
||||
<p class="text-xs text-slate-500"><span class="text-red-400">Blockiert</span> wenn Slot belegt — belegte Zeiten sind ausgegraut</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-900/20 border border-blue-800/50 rounded-lg p-3 text-xs text-slate-300">
|
||||
<span class="text-blue-400 font-semibold">ℹ️ Mehrere Posts pro Tag:</span> Für jeden Zeitslot einen eigenen Artikel anlegen. Jeder Slot wird unabhängig eingeplant und gepostet.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 3. Freigabe & Review -->
|
||||
<details class="card">
|
||||
<summary class="p-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl">📱</span>
|
||||
<h2 class="text-base font-semibold text-white">3. Freigabe & Review im Telegram-Bot</h2>
|
||||
<span class="badge tag-bot">BOT</span>
|
||||
</div>
|
||||
<span class="chevron text-slate-400 text-sm">▶</span>
|
||||
</summary>
|
||||
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-green-400">Review erhalten</div>
|
||||
<p class="text-xs text-slate-400">Zum geplanten Zeitpunkt (oder sofort bei manuell senden) schickt der Bot den Artikel an alle Redakteure mit zwei Buttons:</p>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<span class="text-xs bg-green-900/40 border border-green-700 text-green-400 px-2 py-1 rounded">✅ Freigeben</span>
|
||||
<span class="text-xs bg-slate-700 border border-slate-600 text-slate-300 px-2 py-1 rounded">✏️ Bearbeiten</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-yellow-400">Bearbeiten im Bot</div>
|
||||
<p class="text-xs text-slate-400">✏️ drücken → Bot zeigt aktuellen Text → einfach neue Version als nächste Nachricht schicken → Bot bestätigt + zeigt erneut Review-Buttons.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step p-4">
|
||||
<div class="text-sm font-semibold text-slate-300 mb-2">Bot-Befehle</div>
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div><code>/start</code> <span class="text-slate-400 ml-2">Übersicht & Hilfe</span></div>
|
||||
<div><code>/heute</code> <span class="text-slate-400 ml-2">Alle Slots von heute</span></div>
|
||||
<div><code>/queue</code> <span class="text-slate-400 ml-2">Nächste 3 Tage</span></div>
|
||||
<div><code>/skip</code> <span class="text-slate-400 ml-2">Hauptslot heute überspringen</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-blue-400">☀️ Morgen-Briefing (10:00 Uhr MEZ)</div>
|
||||
<p class="text-xs text-slate-400">Täglich um 10:00 Uhr schickt der Bot automatisch einen Überblick:</p>
|
||||
<ul class="text-xs text-slate-400 space-y-1 mt-1 ml-3">
|
||||
<li>• Welche Slots heute geplant sind (mit Status)</li>
|
||||
<li>• Ob noch etwas fehlt oder freigegeben werden muss</li>
|
||||
<li>• Ausblick auf die nächsten 3 Tage</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 4. Automatisches Posting -->
|
||||
<details class="card">
|
||||
<summary class="p-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl">📤</span>
|
||||
<h2 class="text-base font-semibold text-white">4. Automatisches Posting</h2>
|
||||
</div>
|
||||
<span class="chevron text-slate-400 text-sm">▶</span>
|
||||
</summary>
|
||||
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
|
||||
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-slate-300">Ablauf</div>
|
||||
<p class="text-xs text-slate-400">Der Scheduler prüft jede Minute: gibt es einen <span class="text-green-400">freigegebenen</span> Artikel dessen Uhrzeit jetzt fällig ist?</p>
|
||||
<ul class="text-xs text-slate-400 space-y-1 mt-2 ml-3">
|
||||
<li>• <span class="text-green-400">Freigegeben + Uhrzeit erreicht</span> → wird in den Kanal gepostet</li>
|
||||
<li>• <span class="text-yellow-400">Nicht freigegeben</span> → Nachmittags-Reminder (18:00 Uhr)</li>
|
||||
<li>• <span class="text-red-400">Fehler beim Posting</span> → sofortiger Fehler-Alarm an alle Redakteure</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3 text-center text-xs">
|
||||
<div class="step p-3">
|
||||
<div class="text-lg mb-1">✅</div>
|
||||
<div class="text-slate-300 font-semibold">Freigegeben</div>
|
||||
<div class="text-slate-500 mt-1">Postet automatisch</div>
|
||||
</div>
|
||||
<div class="step p-3">
|
||||
<div class="text-lg mb-1">⚠️</div>
|
||||
<div class="text-slate-300 font-semibold">Kein Artikel</div>
|
||||
<div class="text-slate-500 mt-1">Alarm + überspringen</div>
|
||||
</div>
|
||||
<div class="step p-3">
|
||||
<div class="text-lg mb-1">❌</div>
|
||||
<div class="text-slate-300 font-semibold">Posting-Fehler</div>
|
||||
<div class="text-slate-500 mt-1">Sofort-Alarm mit Ursache</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-700/30 rounded-lg p-3 text-xs text-slate-400">
|
||||
Das <span class="text-white">Markenzeichen</span> wird automatisch unter jeden Beitrag gesetzt — auch wenn es im Editor noch nicht sichtbar ist.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 5. Board verwalten -->
|
||||
<details class="card">
|
||||
<summary class="p-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl">🗂️</span>
|
||||
<h2 class="text-base font-semibold text-white">5. Redaktionsplan verwalten</h2>
|
||||
<span class="badge tag-new">NEU</span>
|
||||
</div>
|
||||
<span class="chevron text-slate-400 text-sm">▶</span>
|
||||
</summary>
|
||||
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-blue-400">📅 Tag anklicken</div>
|
||||
<p class="text-xs text-slate-400">Klick auf einen Tag im Redaktionsplan lädt den Artikel direkt ins Studio — ohne neu generieren.</p>
|
||||
</div>
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-yellow-400">🔄 Umplanen</div>
|
||||
<p class="text-xs text-slate-400">Direkt im Board: neues Datum oder Uhrzeit wählen. Bei Zeitkonflikt wird geblockt und ein freier Slot vorgeschlagen.</p>
|
||||
</div>
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-red-400">🗑️ Löschen</div>
|
||||
<p class="text-xs text-slate-400">Artikel aus einem Slot entfernen — mit Sicherheitsabfrage. Slot wird danach wieder als frei angezeigt.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step p-4">
|
||||
<div class="text-sm font-semibold text-slate-300 mb-2">Status-Übersicht</div>
|
||||
<div class="grid grid-cols-3 md:grid-cols-6 gap-2 text-xs text-center">
|
||||
<div><span class="text-slate-400 text-base">📝</span><div class="text-slate-500 mt-1">Entwurf</div></div>
|
||||
<div><span class="text-purple-400 text-base">🗓️</span><div class="text-slate-500 mt-1">Eingeplant</div></div>
|
||||
<div><span class="text-blue-400 text-base">📱</span><div class="text-slate-500 mt-1">Beim Bot</div></div>
|
||||
<div><span class="text-green-400 text-base">✅</span><div class="text-slate-500 mt-1">Freigegeben</div></div>
|
||||
<div><span class="text-sky-400 text-base">📤</span><div class="text-slate-500 mt-1">Gepostet</div></div>
|
||||
<div><span class="text-slate-600 text-base">⏭️</span><div class="text-slate-500 mt-1">Skip</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 6. Einstellungen -->
|
||||
<details class="card">
|
||||
<summary class="p-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl">⚙️</span>
|
||||
<h2 class="text-base font-semibold text-white">6. Einstellungen</h2>
|
||||
</div>
|
||||
<span class="chevron text-slate-400 text-sm">▶</span>
|
||||
</summary>
|
||||
<div class="px-5 pb-5 space-y-4 border-t border-slate-700 pt-4">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-slate-300">📢 Telegram Kanal</div>
|
||||
<p class="text-xs text-slate-400">Kanal-ID oder <code>@username</code> des Ziel-Kanals eintragen. Der Bot muss Admin im Kanal sein.</p>
|
||||
</div>
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-slate-300">⏰ Standard-Posting-Zeit</div>
|
||||
<p class="text-xs text-slate-400">Default-Uhrzeit für neue Artikel. Kann pro Artikel beim Einplanen überschrieben werden.</p>
|
||||
</div>
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-slate-300">👥 Redakteure</div>
|
||||
<p class="text-xs text-slate-400">Neue Redakteure per Chat-ID hinzufügen. Beim Hinzufügen erhält der neue Redakteur automatisch eine Willkommensnachricht. Chat-ID herausfinden: <code>@userinfobot</code> in Telegram.</p>
|
||||
</div>
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-slate-300">📌 Quellen-Favoriten</div>
|
||||
<p class="text-xs text-slate-400">Häufig genutzte URLs speichern — erscheinen im Studio als Dropdown für schnellen Zugriff.</p>
|
||||
</div>
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-slate-300">🌍 Aufenthaltsort</div>
|
||||
<p class="text-xs text-slate-400">Aktuellen Standort einstellen. Die Reminder-Zeiten werden automatisch auf MEZ umgerechnet.</p>
|
||||
</div>
|
||||
<div class="step p-4 space-y-2">
|
||||
<div class="text-sm font-semibold text-slate-300">🧠 Prompts</div>
|
||||
<p class="text-xs text-slate-400">KI-Prompts erstellen, bearbeiten und mit einer Testquelle ausprobieren. Default-Prompt für neue Artikel festlegen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- FAQ -->
|
||||
<details class="card">
|
||||
<summary class="p-5 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl">❓</span>
|
||||
<h2 class="text-base font-semibold text-white">Häufige Fragen</h2>
|
||||
</div>
|
||||
<span class="chevron text-slate-400 text-sm">▶</span>
|
||||
</summary>
|
||||
<div class="px-5 pb-5 space-y-3 border-t border-slate-700 pt-4">
|
||||
|
||||
<div class="step p-4">
|
||||
<div class="text-sm font-semibold text-slate-300 mb-1">Was passiert wenn ich vergesse freizugeben?</div>
|
||||
<p class="text-xs text-slate-400">Um 18:00 Uhr kommt ein Reminder. Falls bis zur Posting-Zeit kein freigegebener Artikel vorhanden ist, wird der Slot übersprungen und ein Alarm gesendet.</p>
|
||||
</div>
|
||||
|
||||
<div class="step p-4">
|
||||
<div class="text-sm font-semibold text-slate-300 mb-1">Kann ich einen bereits geposteten Artikel bearbeiten?</div>
|
||||
<p class="text-xs text-slate-400">Im Dashboard nicht rückwirkend — aber in Telegram kannst du die Nachricht direkt bearbeiten (Telegram-Editier-Funktion).</p>
|
||||
</div>
|
||||
|
||||
<div class="step p-4">
|
||||
<div class="text-sm font-semibold text-slate-300 mb-1">Wo finde ich die Chat-ID für einen neuen Redakteur?</div>
|
||||
<p class="text-xs text-slate-400">In Telegram <code>@userinfobot</code> anschreiben → gibt die eigene Chat-ID zurück. Oder die Person schreibt dem <code>@Diendemleben_bot</code> — die ID erscheint dann im Bot-Log.</p>
|
||||
</div>
|
||||
|
||||
<div class="step p-4">
|
||||
<div class="text-sm font-semibold text-slate-300 mb-1">Wie sehe ich ob der Bot läuft?</div>
|
||||
<p class="text-xs text-slate-400">Im Dashboard-Header: letzter Post-Zeitstempel. Im Bot: <code>/start</code> senden — Antwort bedeutet Bot ist aktiv. Auf dem Server: <code>docker ps</code> in CT 112.</p>
|
||||
</div>
|
||||
|
||||
<div class="step p-4">
|
||||
<div class="text-sm font-semibold text-slate-300 mb-1">Kann ich mehrere Artikel pro Tag planen?</div>
|
||||
<p class="text-xs text-slate-400">Ja — jeden Zeitslot (15-Min-Raster) einmal belegen. Jeder Slot wird unabhängig gepostet. Doppelt belegte Slots werden automatisch blockiert.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="text-center text-xs text-slate-600 pt-4 pb-8">
|
||||
FünfVorAcht · CT 112 auf pve-hetzner · Dashboard: <a href="https://fuenfvoracht.orbitalo.net" class="text-blue-400 hover:underline">fuenfvoracht.orbitalo.net</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Alle details initial geschlossen außer erstem
|
||||
document.querySelectorAll('details').forEach((d, i) => {
|
||||
if (i > 0) d.open = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
72
fuenfvoacht/src/templates/history.html
Normal file
72
fuenfvoacht/src/templates/history.html
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>History — FünfVorAcht</title>
|
||||
<link rel="stylesheet" href="/static/tailwind.min.css">
|
||||
<style>
|
||||
body { background: #0f172a; color: #e2e8f0; }
|
||||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
|
||||
.tg-preview { background: #212d3b; border-left: 3px solid #3b82f6; font-family: system-ui; white-space: pre-wrap; line-height: 1.6; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1 bg-slate-800 border border-slate-700 rounded-lg p-1">
|
||||
<span class="flex items-center gap-1.5 bg-slate-700 text-white text-xs font-semibold px-3 py-1.5 rounded-md">🕗 FünfVorAcht</span>
|
||||
<a href="https://redakteur.orbitalo.net" target="_blank"
|
||||
class="flex items-center gap-1.5 text-slate-500 hover:text-slate-200 hover:bg-slate-700 text-xs font-medium px-3 py-1.5 rounded-md transition">📝 Redakteur</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a href="/" class="text-slate-400 hover:text-white">Studio</a>
|
||||
<a href="/history" class="text-blue-400 font-semibold">History</a>
|
||||
<a href="/prompts" class="text-slate-400 hover:text-white">Prompts</a>
|
||||
<a href="/settings" class="text-slate-400 hover:text-white">Einstellungen</a>
|
||||
<a href="/hilfe" class="text-slate-400 hover:text-white">❓ Hilfe</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-4xl mx-auto px-6 py-8">
|
||||
<h1 class="text-2xl font-bold text-white mb-6">📋 Artikel-History</h1>
|
||||
<div class="space-y-4">
|
||||
{% for art in articles %}
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-semibold text-white">{{ art.date }}</span>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full
|
||||
{% if art.status == 'posted' %}bg-blue-900 text-blue-300
|
||||
{% elif art.status == 'approved' %}bg-green-900 text-green-300
|
||||
{% elif art.status == 'pending_review' %}bg-yellow-900 text-yellow-300
|
||||
{% else %}bg-slate-700 text-slate-400{% endif %}">
|
||||
{{ {'posted': '📤 Gepostet', 'approved': '✅ Freigegeben',
|
||||
'pending_review': '⏳ Offen', 'skipped': '⏭️ Skip'}.get(art.status, art.status) }}
|
||||
</span>
|
||||
<span class="text-xs text-slate-500">v{{ art.version }}</span>
|
||||
</div>
|
||||
<button onclick="toggleArticle({{ art.id }})"
|
||||
class="text-xs text-blue-400 hover:underline">anzeigen</button>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mb-2">
|
||||
Quelle: {{ art.source_input[:80] if art.source_input else '—' }}
|
||||
</div>
|
||||
<div id="art-{{ art.id }}" class="hidden">
|
||||
<div class="tg-preview rounded-lg p-4 text-sm text-slate-200 mt-2">{{ art.content_final or '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-slate-400">Noch keine Artikel vorhanden.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function toggleArticle(id) {
|
||||
const el = document.getElementById('art-' + id);
|
||||
el.classList.toggle('hidden');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
961
fuenfvoacht/src/templates/index.html
Normal file
961
fuenfvoacht/src/templates/index.html
Normal file
|
|
@ -0,0 +1,961 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🕗 FünfVorAcht Dashboard</title>
|
||||
<link rel="stylesheet" href="/static/tailwind.min.css">
|
||||
<style>
|
||||
body { background: #0f172a; color: #e2e8f0; }
|
||||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
|
||||
.status-draft { background: #1e293b22; border-color: #475569; color: #94a3b8; }
|
||||
.status-pending { background: #92400e22; border-color: #d97706; color: #fbbf24; }
|
||||
.status-sent { background: #4c1d9522; border-color: #7c3aed; color: #c4b5fd; }
|
||||
.status-approved { background: #14532d22; border-color: #16a34a; color: #4ade80; }
|
||||
.status-posted { background: #1e3a5f22; border-color: #3b82f6; color: #60a5fa; }
|
||||
.status-skipped { background: #1e293b; border-color: #475569; color: #64748b; }
|
||||
.tg-preview { background: #17212b; border-left: 3px solid #3b82f6; font-family: -apple-system,system-ui,sans-serif; white-space: pre-wrap; line-height: 1.7; }
|
||||
textarea { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; border-radius: 8px; resize: vertical; font-family: inherit; line-height: 1.6; }
|
||||
textarea:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 2px #3b82f620; }
|
||||
input[type=text], input[type=time], select {
|
||||
background: #0f172a; border: 1px solid #334155; color: #e2e8f0;
|
||||
border-radius: 8px; padding: 0.5rem 0.75rem; font-size: 0.875rem;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: #3b82f6; }
|
||||
.btn { border-radius: 8px; font-size: 0.875rem; font-weight: 500; padding: 0.5rem 1.25rem; transition: all .15s; cursor: pointer; }
|
||||
.btn-primary { background: #2563eb; color: #fff; }
|
||||
.btn-primary:hover { background: #1d4ed8; }
|
||||
.btn-success { background: #15803d; color: #fff; }
|
||||
.btn-success:hover { background: #166534; }
|
||||
.btn-purple { background: #6d28d9; color: #fff; }
|
||||
.btn-purple:hover { background: #5b21b6; }
|
||||
.btn-ghost { background: #334155; color: #cbd5e1; }
|
||||
.btn-ghost:hover { background: #475569; }
|
||||
.btn-danger { background: #991b1b; color: #fff; }
|
||||
.btn-danger:hover { background: #7f1d1d; }
|
||||
.spinner { animation: spin 1s linear infinite; display: inline-block; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.countdown { font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-3 flex items-center justify-between sticky top-0 z-50">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Tool-Switcher -->
|
||||
<div class="flex items-center gap-1 bg-slate-800 border border-slate-700 rounded-lg p-1">
|
||||
<span class="flex items-center gap-1.5 bg-slate-700 text-white text-xs font-semibold px-3 py-1.5 rounded-md">
|
||||
🕗 FünfVorAcht
|
||||
</span>
|
||||
<a href="https://redakteur.orbitalo.net" target="_blank"
|
||||
class="flex items-center gap-1.5 text-slate-500 hover:text-slate-200 hover:bg-slate-700 text-xs font-medium px-3 py-1.5 rounded-md transition">
|
||||
📝 Redakteur
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-5 text-sm">
|
||||
<a href="/" class="text-blue-400 font-semibold">Studio</a>
|
||||
<a href="/history" class="text-slate-400 hover:text-white">History</a>
|
||||
<a href="/prompts" class="text-slate-400 hover:text-white">Prompts</a>
|
||||
<a href="/settings" class="text-slate-400 hover:text-white">Einstellungen</a>
|
||||
<a href="/hilfe" class="text-slate-400 hover:text-white">❓ Hilfe</a>
|
||||
|
||||
<!-- Aufenthaltsort-Schalter -->
|
||||
<div class="relative">
|
||||
<button onclick="toggleLocationMenu()"
|
||||
class="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 border border-slate-600 px-3 py-1.5 rounded-lg text-sm transition"
|
||||
id="location-btn">
|
||||
<span id="location-flag">{{ current_location.flag if current_location else '🌍' }}</span>
|
||||
<span id="location-name" class="text-slate-200">{{ current_location.name if current_location else 'Ort wählen' }}</span>
|
||||
<span class="text-slate-500">▾</span>
|
||||
</button>
|
||||
<div id="location-menu" class="hidden absolute right-0 top-10 bg-slate-800 border border-slate-600 rounded-xl shadow-xl z-50 w-52 py-1 overflow-hidden">
|
||||
{% for loc in locations %}
|
||||
<button onclick="setLocation({{ loc.id }}, '{{ loc.flag }}', '{{ loc.name }}')"
|
||||
class="w-full text-left px-4 py-2.5 hover:bg-slate-700 flex items-center gap-3 text-sm
|
||||
{% if current_location and current_location.id == loc.id %}bg-slate-700 text-blue-400{% else %}text-slate-200{% endif %}">
|
||||
<span class="text-base">{{ loc.flag }}</span>
|
||||
<div>
|
||||
<div class="font-medium">{{ loc.name }}</div>
|
||||
<div class="text-xs text-slate-400">{{ loc.timezone }}</div>
|
||||
</div>
|
||||
{% if current_location and current_location.id == loc.id %}
|
||||
<span class="ml-auto text-blue-400">✓</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live-Uhr + Countdown -->
|
||||
<div class="text-right">
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<span class="text-white font-mono text-sm" id="live-clock-berlin">--:--:--</span>
|
||||
<span class="text-xs text-slate-500">🇩🇪</span>
|
||||
</div>
|
||||
<div id="local-clock-row" class="flex items-center gap-2 justify-end" style="display:none">
|
||||
<span class="text-slate-400 font-mono text-xs" id="live-clock-local">--:--:--</span>
|
||||
<span class="text-xs text-slate-600" id="local-flag"></span>
|
||||
</div>
|
||||
<div class="text-xs text-green-400 countdown" id="countdown">--</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-6 py-6 space-y-6">
|
||||
|
||||
<!-- ═══ ARTIKEL-STUDIO ═══ -->
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-lg font-bold text-white">✏️ Artikel-Studio — <span id="studio-date" class="text-blue-400">{{ today }}</span></h2>
|
||||
<div class="flex items-center gap-3">
|
||||
{% if article_today %}
|
||||
<span id="status-badge" class="border text-xs px-3 py-1 rounded-full
|
||||
{% if article_today.status == 'draft' %}status-draft
|
||||
{% elif article_today.status == 'sent_to_bot' %}status-sent
|
||||
{% elif article_today.status == 'approved' %}status-approved
|
||||
{% elif article_today.status == 'posted' %}status-posted
|
||||
{% elif article_today.status == 'skipped' %}status-skipped
|
||||
{% else %}status-pending{% endif %}">
|
||||
{{ {'draft':'📝 Entwurf','sent_to_bot':'📱 Beim Bot','approved':'✅ Freigegeben','posted':'📤 Gepostet','skipped':'⏭️ Übersprungen','pending_review':'⏳ Offen'}.get(article_today.status, article_today.status) }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span id="status-badge" class="border text-xs px-3 py-1 rounded-full status-draft">📝 Neu</span>
|
||||
{% endif %}
|
||||
<button onclick="clearStudio()" class="text-slate-500 hover:text-white text-xs px-2 py-1 rounded hover:bg-slate-700 transition" title="Editor leeren">✕ Leeren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Linke Spalte: Eingabe + Editor -->
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- Quelle -->
|
||||
<div>
|
||||
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1 block">Quelle / Inhaltswunsch</label>
|
||||
<div class="flex gap-2">
|
||||
<input type="text" id="source-input"
|
||||
class="flex-1"
|
||||
placeholder="URL einfügen oder Thema beschreiben…"
|
||||
value="{{ article_today.source_input if article_today and article_today.source_input else '' }}">
|
||||
<!-- Favoriten-Dropdown -->
|
||||
<select id="fav-select" class="w-40" onchange="useFavorite(this)">
|
||||
<option value="">📌 Favoriten</option>
|
||||
{% for f in favorites %}
|
||||
<option value="{{ f.url }}">{{ f.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag + Prompt -->
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1 block">Thema</label>
|
||||
<select id="tag-select" class="w-full">
|
||||
{% for t in tags %}
|
||||
<option value="{{ t.name }}" {% if article_today and article_today.tag == t.name %}selected{% endif %}>{{ t.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-1 block">Prompt</label>
|
||||
<select id="prompt-select" class="w-full">
|
||||
{% for p in prompts %}
|
||||
<option value="{{ p.id }}" {% if p.is_default %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generieren -->
|
||||
<button onclick="generateArticle()" id="gen-btn" class="btn btn-primary w-full py-2.5">
|
||||
⚡ Artikel generieren
|
||||
</button>
|
||||
|
||||
<!-- Editor -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Artikel bearbeiten</label>
|
||||
<span id="char-count" class="text-xs text-slate-500"></span>
|
||||
</div>
|
||||
<textarea id="article-editor" rows="14"
|
||||
class="w-full px-3 py-2.5 text-sm"
|
||||
placeholder="Hier erscheint der generierte Artikel — direkt bearbeitbar…"
|
||||
oninput="updatePreview()">{{ article_today.content_final if article_today and article_today.content_final else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Aktions-Buttons -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button onclick="regenerate()" class="btn btn-ghost">🔄 Neu generieren</button>
|
||||
<button onclick="togglePlanPanel()" id="plan-btn" class="btn btn-purple flex-1">
|
||||
📅 Einplanen
|
||||
</button>
|
||||
<button onclick="skipToday()" class="btn btn-danger">⏭️ Überspringen</button>
|
||||
</div>
|
||||
|
||||
<!-- Einplan-Panel -->
|
||||
<div id="plan-panel" class="hidden mt-1 rounded-xl border border-purple-700 bg-purple-950/30 p-4 space-y-3">
|
||||
<div class="text-sm font-semibold text-purple-300">📅 Artikel einplanen</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-xs text-slate-400 block mb-1">Datum</label>
|
||||
<input type="date" id="plan-date" class="w-full text-sm py-1.5 px-2"
|
||||
onchange="checkSlot()">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-400 block mb-1">Uhrzeit (15-Min-Raster)</label>
|
||||
<input type="time" id="plan-time" step="900" class="w-full text-sm py-1.5 px-2"
|
||||
onchange="checkSlot()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" id="plan-notify" value="sofort">
|
||||
|
||||
<div id="slot-status" class="text-xs hidden"></div>
|
||||
|
||||
<div class="flex gap-2 pt-1">
|
||||
<button onclick="confirmPlan()" id="confirm-plan-btn"
|
||||
class="btn btn-success flex-1">✅ Einplanen bestätigen</button>
|
||||
<button onclick="togglePlanPanel()" class="btn btn-ghost">✕ Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Rechte Spalte: Telegram-Vorschau + Timer -->
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- Telegram-Vorschau -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<label class="text-xs font-semibold text-slate-400 uppercase tracking-wide">📱 Telegram-Vorschau</label>
|
||||
<span id="tg-char" class="text-xs text-slate-500"></span>
|
||||
</div>
|
||||
<div class="tg-preview rounded-xl p-4 text-sm min-h-[200px]" id="tg-preview-box">
|
||||
<span class="text-slate-500 italic">Vorschau erscheint beim Bearbeiten…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posting-Timer kompakt -->
|
||||
<div class="card p-4 border border-slate-600">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-white font-semibold text-sm">⏰ Posting</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="time" id="post-time-input" value="{{ channel.post_time or '19:55' }}" class="w-28 text-xs py-1">
|
||||
<span class="text-xs text-slate-500">🇩🇪</span>
|
||||
<button onclick="savePostTime()" class="text-xs text-slate-500 hover:text-white">💾</button>
|
||||
<span id="save-time-ok" class="text-green-400 text-xs hidden">✓</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-400">
|
||||
<span class="text-white font-semibold" id="next-post-time">{{ channel.post_time or '19:55' }} Uhr 🇩🇪</span>
|
||||
<span id="next-post-local" class="text-slate-500 ml-2"></span>
|
||||
</div>
|
||||
<div id="studio-status-line" class="mt-1 text-xs text-slate-500"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ REDAKTIONSPLAN ═══ -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
<!-- Planungsliste: Nächste 7 Tage -->
|
||||
<div class="card p-5 lg:col-span-2">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold text-white">📅 Redaktionsplan — Nächste 7 Tage</h2>
|
||||
<span class="text-xs text-slate-500">Posting: {{ channel.post_time or '19:55' }} Uhr 🇩🇪</span>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
{% set status_icons = {'draft':'📝','scheduled':'🗓️','sent_to_bot':'📱','approved':'✅','posted':'📤','skipped':'⏭️','pending_review':'⏳'} %}
|
||||
{% set status_labels = {'draft':'Entwurf','scheduled':'Eingeplant','sent_to_bot':'Beim Bot','approved':'Freigegeben','posted':'Gepostet','skipped':'Skip','pending_review':'Offen'} %}
|
||||
{% set status_css = {'posted':'status-posted','approved':'status-approved','sent_to_bot':'status-sent','scheduled':'status-sent','draft':'status-pending','pending_review':'status-pending','skipped':'status-skipped'} %}
|
||||
|
||||
{% for d in plan_days %}
|
||||
{% set arts = plan_articles.get(d, []) %}
|
||||
{% set is_today = (d == today) %}
|
||||
|
||||
<!-- Tag-Header -->
|
||||
<div class="flex items-center gap-2 pt-2 pb-1 px-1
|
||||
{% if is_today %}text-blue-400{% else %}text-slate-400{% endif %}">
|
||||
<span class="text-xs font-bold">{{ d[8:] }}.{{ d[5:7] }}.</span>
|
||||
{% if is_today %}<span class="text-xs bg-blue-900/40 border border-blue-700 text-blue-400 px-2 py-0.5 rounded-full">Heute</span>{% endif %}
|
||||
{% if not arts %}<span class="text-xs text-slate-600 italic">— kein Artikel</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{% for art in arts %}
|
||||
<!-- Artikel-Slot -->
|
||||
<div class="rounded-lg border border-slate-700/50 hover:border-slate-600 transition bg-slate-800/30"
|
||||
id="plan-row-{{ art.id }}">
|
||||
|
||||
<!-- Haupt-Zeile -->
|
||||
<div class="flex items-center gap-3 px-3 py-2.5 cursor-pointer"
|
||||
onclick="loadDate('{{ d }}')">
|
||||
<span class="text-base w-5 shrink-0 text-center">{{ status_icons.get(art.status, '❓') }}</span>
|
||||
<span class="text-xs font-mono text-slate-400 w-12 shrink-0">{{ art.post_time }}</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
{% if art.content_final %}
|
||||
<div class="text-sm text-slate-300 truncate">{{ art.content_final[:70] }}</div>
|
||||
<div class="text-xs text-slate-500">v{{ art.version }}{% if art.tag %} · {{ art.tag }}{% endif %}</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-slate-600 italic">Kein Inhalt</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full border shrink-0 {{ status_css.get(art.status, 'status-pending') }}">
|
||||
{{ status_labels.get(art.status, art.status) }}
|
||||
</span>
|
||||
|
||||
<!-- Aktions-Buttons (nur wenn nicht gepostet) -->
|
||||
{% if art.status != 'posted' %}
|
||||
<div class="flex items-center gap-1 shrink-0" onclick="event.stopPropagation()">
|
||||
<button onclick="openReschedule({{ art.id }}, '{{ d }}', '{{ art.post_time }}')"
|
||||
class="text-slate-500 hover:text-yellow-400 text-xs px-1.5 py-1 rounded hover:bg-slate-700 transition"
|
||||
title="Umplanen">🔄</button>
|
||||
<button onclick="deleteArticle({{ art.id }}, '{{ d }}', '{{ art.post_time }}')"
|
||||
class="text-slate-500 hover:text-red-400 text-xs px-1.5 py-1 rounded hover:bg-slate-700 transition"
|
||||
title="Löschen">🗑️</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Umplan-Panel (eingeklappt) -->
|
||||
<div id="reschedule-panel-{{ art.id }}" class="hidden border-t border-slate-700 px-3 py-3 bg-slate-900/50 rounded-b-lg">
|
||||
<div class="text-xs text-yellow-400 font-semibold mb-2">🔄 Umplanen</div>
|
||||
<div class="flex gap-2 items-end flex-wrap">
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1">Datum</label>
|
||||
<input type="date" id="rs-date-{{ art.id }}" value="{{ d }}"
|
||||
class="text-xs py-1 px-2 w-36" onchange="checkRescheduleSlot({{ art.id }})">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-500 block mb-1">Uhrzeit</label>
|
||||
<input type="time" id="rs-time-{{ art.id }}" value="{{ art.post_time }}" step="900"
|
||||
class="text-xs py-1 px-2 w-28" onchange="checkRescheduleSlot({{ art.id }})">
|
||||
</div>
|
||||
<button onclick="confirmReschedule({{ art.id }})"
|
||||
id="rs-confirm-{{ art.id }}"
|
||||
class="text-xs bg-yellow-700 hover:bg-yellow-600 text-white px-3 py-1.5 rounded-lg transition">
|
||||
✓ Bestätigen
|
||||
</button>
|
||||
<button onclick="closeReschedule({{ art.id }})"
|
||||
class="text-xs text-slate-500 hover:text-white px-2 py-1.5 rounded-lg transition">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
<div id="rs-status-{{ art.id }}" class="text-xs mt-2 hidden"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if not arts %}
|
||||
<div class="h-1"></div>
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Monatskalender + Stats -->
|
||||
<div class="space-y-4">
|
||||
|
||||
<!-- Mini-Monatskalender -->
|
||||
<div class="card p-4">
|
||||
<h3 class="text-white font-semibold mb-3 text-sm">📆 Monatsübersicht</h3>
|
||||
<div class="grid grid-cols-7 gap-1 text-center text-xs mb-2">
|
||||
<div class="text-slate-500 font-semibold">Mo</div>
|
||||
<div class="text-slate-500 font-semibold">Di</div>
|
||||
<div class="text-slate-500 font-semibold">Mi</div>
|
||||
<div class="text-slate-500 font-semibold">Do</div>
|
||||
<div class="text-slate-500 font-semibold">Fr</div>
|
||||
<div class="text-slate-500 font-semibold">Sa</div>
|
||||
<div class="text-slate-500 font-semibold">So</div>
|
||||
</div>
|
||||
<div id="month-grid" class="grid grid-cols-7 gap-1 text-center text-xs"></div>
|
||||
<div class="flex items-center gap-4 mt-3 text-xs text-slate-500">
|
||||
<span><span class="inline-block w-2 h-2 rounded-full bg-blue-500 mr-1"></span>Gepostet</span>
|
||||
<span><span class="inline-block w-2 h-2 rounded-full bg-green-500 mr-1"></span>Geplant</span>
|
||||
<span><span class="inline-block w-2 h-2 rounded-full bg-yellow-500 mr-1"></span>Entwurf</span>
|
||||
<span><span class="inline-block w-2 h-2 rounded-full bg-slate-600 mr-1"></span>Leer</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="card p-4 border border-slate-700">
|
||||
<h3 class="text-slate-300 font-semibold mb-2 text-sm">📊 {{ today[:7] }}</h3>
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-green-400">{{ stats.posted }}</div>
|
||||
<div class="text-xs text-slate-400">Gepostet</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-slate-400">{{ stats.skipped }}</div>
|
||||
<div class="text-xs text-slate-400">Skip</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-blue-400">{{ stats.avg_version }}×</div>
|
||||
<div class="text-xs text-slate-400">Ø Regen.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 pt-3 border-t border-slate-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-slate-400">💰 OpenRouter</span>
|
||||
<button onclick="loadBalance()" class="text-xs text-slate-500 hover:text-white">🔄</button>
|
||||
</div>
|
||||
<div id="balance-inline" class="text-sm font-semibold text-slate-500 mt-1">— 🔄 klicken</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzte Posts kompakt -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-slate-300 font-semibold text-sm">📋 Letzte Posts</h3>
|
||||
<a href="/history" class="text-blue-400 text-xs hover:underline">Alle →</a>
|
||||
</div>
|
||||
{% for art in recent[:4] %}
|
||||
<div class="flex items-center gap-2 py-1.5 border-b border-slate-700/30 last:border-0 cursor-pointer hover:bg-slate-700/30 rounded px-1 -mx-1"
|
||||
onclick="loadDate('{{ art.date }}')">
|
||||
<span class="text-xs text-slate-500 w-16 shrink-0">{{ art.date[5:] }}</span>
|
||||
<span class="text-xs text-slate-400 flex-1 truncate">{{ art.content_final[:50] if art.content_final else '—' }}</span>
|
||||
<span class="text-xs">{{ {'posted':'📤','approved':'✅','sent_to_bot':'📱','draft':'📝','skipped':'⏭️'}.get(art.status,'?') }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-slate-500 text-xs py-2">Keine Artikel.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const TODAY = "{{ today }}";
|
||||
let selectedDate = TODAY;
|
||||
let userTimezone = "{{ current_location.timezone if current_location else 'Europe/Berlin' }}";
|
||||
let userFlag = "{{ current_location.flag if current_location else '🇩🇪' }}";
|
||||
const BERLIN_TZ = "Europe/Berlin";
|
||||
const MONTH_ARTICLES = {{ month_articles | tojson }};
|
||||
|
||||
function getBerlinTime(date) {
|
||||
return new Date(date.toLocaleString('en-US', {timeZone: BERLIN_TZ}));
|
||||
}
|
||||
|
||||
function formatTime(date, tz) {
|
||||
return date.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', second:'2-digit', timeZone: tz});
|
||||
}
|
||||
|
||||
function formatTimeShort(date, tz) {
|
||||
return date.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', timeZone: tz});
|
||||
}
|
||||
|
||||
function updateClock() {
|
||||
const now = new Date();
|
||||
|
||||
document.getElementById('live-clock-berlin').textContent = formatTime(now, BERLIN_TZ);
|
||||
|
||||
const isLocal = userTimezone !== BERLIN_TZ;
|
||||
const localRow = document.getElementById('local-clock-row');
|
||||
if (isLocal) {
|
||||
localRow.style.display = 'flex';
|
||||
document.getElementById('live-clock-local').textContent = formatTime(now, userTimezone);
|
||||
document.getElementById('local-flag').textContent = userFlag;
|
||||
} else {
|
||||
localRow.style.display = 'none';
|
||||
}
|
||||
|
||||
const postTime = document.getElementById('post-time-input').value || '19:55';
|
||||
const [ph, pm] = postTime.split(':').map(Number);
|
||||
|
||||
const berlinNow = getBerlinTime(now);
|
||||
const berlinPost = new Date(berlinNow);
|
||||
berlinPost.setHours(ph, pm, 0, 0);
|
||||
if (berlinPost <= berlinNow) berlinPost.setDate(berlinPost.getDate() + 1);
|
||||
|
||||
const diff = Math.floor((berlinPost - berlinNow) / 1000);
|
||||
const h = Math.floor(diff / 3600);
|
||||
const m = Math.floor((diff % 3600) / 60);
|
||||
const s = diff % 60;
|
||||
document.getElementById('countdown').textContent =
|
||||
`Posting in ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
|
||||
|
||||
updateLocalPostTime();
|
||||
}
|
||||
|
||||
function updateLocalPostTime() {
|
||||
const el = document.getElementById('next-post-local');
|
||||
if (userTimezone === BERLIN_TZ) {
|
||||
el.textContent = '';
|
||||
return;
|
||||
}
|
||||
const postTime = document.getElementById('post-time-input').value || '19:55';
|
||||
const [ph, pm] = postTime.split(':').map(Number);
|
||||
const now = new Date();
|
||||
const berlinStr = now.toLocaleDateString('en-US', {timeZone: BERLIN_TZ});
|
||||
const berlinDate = new Date(berlinStr);
|
||||
berlinDate.setHours(ph, pm, 0, 0);
|
||||
const utcOffset = berlinDate.getTime() - new Date(berlinDate.toLocaleString('en-US', {timeZone: BERLIN_TZ})).getTime();
|
||||
const postUTC = new Date(berlinDate.getTime() + utcOffset);
|
||||
const localTimeStr = postUTC.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit', timeZone: userTimezone});
|
||||
el.textContent = `= ${localTimeStr} Uhr ${userFlag}`;
|
||||
}
|
||||
|
||||
setInterval(updateClock, 1000);
|
||||
updateClock();
|
||||
|
||||
// ── Telegram-Vorschau rendern ──────────────────────────────────────────────
|
||||
function renderTelegram(raw) {
|
||||
if (!raw) return '<span class="text-slate-500 italic">Vorschau erscheint beim Bearbeiten…</span>';
|
||||
let html = raw
|
||||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
.replace(/<b>([\s\S]*?)<\/b>/g, '<strong>$1</strong>')
|
||||
.replace(/<i>([\s\S]*?)<\/i>/g, '<em class="text-slate-300">$1</em>')
|
||||
.replace(/<a href="(.*?)">(.*?)<\/a>/g, '<a href="$1" class="text-blue-400 underline">$2</a>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return html;
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
const text = document.getElementById('article-editor').value;
|
||||
const len = text.length;
|
||||
document.getElementById('tg-preview-box').innerHTML = renderTelegram(text);
|
||||
document.getElementById('char-count').textContent = `${len} Zeichen`;
|
||||
document.getElementById('tg-char').textContent = `${len}/4096 ${len > 4096 ? '⚠️' : '✓'}`;
|
||||
}
|
||||
|
||||
// Init Preview wenn Artikel vorhanden
|
||||
const initText = document.getElementById('article-editor').value;
|
||||
if (initText) updatePreview();
|
||||
|
||||
// ── Favorit verwenden ──────────────────────────────────────────────────────
|
||||
function useFavorite(sel) {
|
||||
if (sel.value) {
|
||||
document.getElementById('source-input').value = sel.value;
|
||||
sel.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Editor leeren ─────────────────────────────────────────────────────────
|
||||
function clearStudio() {
|
||||
selectedDate = TODAY;
|
||||
document.getElementById('studio-date').textContent = TODAY;
|
||||
document.getElementById('source-input').value = '';
|
||||
document.getElementById('article-editor').value = '';
|
||||
document.getElementById('tg-preview-box').innerHTML = '<span class="text-slate-500 italic">Vorschau erscheint beim Bearbeiten…</span>';
|
||||
document.getElementById('char-count').textContent = '';
|
||||
document.getElementById('tg-char').textContent = '';
|
||||
updateStatusBadge('draft', '📝 Neu');
|
||||
document.querySelectorAll('[id^="plan-row-"]').forEach(el => {
|
||||
el.classList.remove('ring-2', 'ring-yellow-500');
|
||||
});
|
||||
}
|
||||
|
||||
// ── Datum wechseln (Redaktionsplan klick) ─────────────────────────────────
|
||||
async function loadDate(dateStr) {
|
||||
selectedDate = dateStr;
|
||||
document.getElementById('studio-date').textContent = dateStr;
|
||||
|
||||
document.querySelectorAll('[id^="plan-row-"]').forEach(el => {
|
||||
el.classList.remove('ring-2', 'ring-yellow-500');
|
||||
});
|
||||
const row = document.getElementById('plan-row-' + dateStr);
|
||||
if (row) row.classList.add('ring-2', 'ring-yellow-500');
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/article/' + dateStr);
|
||||
if (r.ok) {
|
||||
const art = await r.json();
|
||||
document.getElementById('source-input').value = art.source_input || '';
|
||||
document.getElementById('article-editor').value = art.content_final || '';
|
||||
const statusMap = {draft:'📝 Entwurf',sent_to_bot:'📱 Beim Bot',approved:'✅ Freigegeben',posted:'📤 Gepostet',skipped:'⏭️ Übersprungen',pending_review:'⏳ Offen'};
|
||||
updateStatusBadge(art.status, statusMap[art.status] || art.status);
|
||||
updatePreview();
|
||||
} else {
|
||||
document.getElementById('source-input').value = '';
|
||||
document.getElementById('article-editor').value = '';
|
||||
updateStatusBadge('draft', '📝 Neu');
|
||||
document.getElementById('tg-preview-box').innerHTML = '<span class="text-slate-500 italic">Kein Artikel für dieses Datum</span>';
|
||||
document.getElementById('char-count').textContent = '';
|
||||
document.getElementById('tg-char').textContent = '';
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('loadDate error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Artikel generieren ─────────────────────────────────────────────────────
|
||||
async function generateArticle() {
|
||||
const source = document.getElementById('source-input').value.trim();
|
||||
if (!source) { alert('Bitte Quelle oder Thema eingeben'); return; }
|
||||
const tag = document.getElementById('tag-select').value;
|
||||
const promptId = document.getElementById('prompt-select').value;
|
||||
const btn = document.getElementById('gen-btn');
|
||||
|
||||
btn.innerHTML = '<span class="spinner">⚙️</span> Generiere…';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/generate', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({source, tag, prompt_id: promptId, date: selectedDate})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
document.getElementById('article-editor').value = d.content;
|
||||
updatePreview();
|
||||
updateStatusBadge('draft', '📝 Entwurf');
|
||||
} else {
|
||||
alert('Fehler: ' + d.error);
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Verbindungsfehler: ' + e);
|
||||
} finally {
|
||||
btn.innerHTML = '⚡ Artikel generieren';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerate() { await generateArticle(); }
|
||||
|
||||
// ── Artikel speichern (stiller Zwischenspeicher) ───────────────────────────
|
||||
async function saveArticle() {
|
||||
const content = document.getElementById('article-editor').value.trim();
|
||||
if (!content) return;
|
||||
const channel = document.getElementById('post-time-input')?.value || '19:55';
|
||||
await fetch('/api/article/' + selectedDate + '/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({content, post_time: channel})
|
||||
});
|
||||
showToast('💾 Gespeichert (' + selectedDate + ')');
|
||||
}
|
||||
|
||||
// ── Einplan-Panel ──────────────────────────────────────────────────────────
|
||||
function togglePlanPanel() {
|
||||
const panel = document.getElementById('plan-panel');
|
||||
const content = document.getElementById('article-editor').value.trim();
|
||||
if (!content) { showToast('⚠️ Bitte erst Artikel generieren oder eingeben.'); return; }
|
||||
|
||||
const isHidden = panel.classList.contains('hidden');
|
||||
panel.classList.toggle('hidden');
|
||||
|
||||
if (isHidden) {
|
||||
// Defaults setzen: ausgewähltes Datum + Standard-Uhrzeit
|
||||
const dateInput = document.getElementById('plan-date');
|
||||
const timeInput = document.getElementById('plan-time');
|
||||
dateInput.value = selectedDate;
|
||||
// Nächsten vollen 15-Min-Slot ab jetzt berechnen
|
||||
const now = new Date();
|
||||
const m = Math.ceil(now.getMinutes() / 15) * 15;
|
||||
const h = m === 60 ? now.getHours() + 1 : now.getHours();
|
||||
const defaultTime = `${String(h % 24).padStart(2,'0')}:${String(m % 60).padStart(2,'0')}`;
|
||||
timeInput.value = defaultTime;
|
||||
// Notify-Modus anpassen
|
||||
const notifySelect = document.getElementById('plan-notify');
|
||||
if (selectedDate === TODAY) {
|
||||
notifySelect.value = 'sofort';
|
||||
} else {
|
||||
notifySelect.value = 'auto';
|
||||
}
|
||||
checkSlot();
|
||||
document.getElementById('plan-date').focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSlot() {
|
||||
const date = document.getElementById('plan-date').value;
|
||||
const time = document.getElementById('plan-time').value;
|
||||
const statusEl = document.getElementById('slot-status');
|
||||
const confirmBtn = document.getElementById('confirm-plan-btn');
|
||||
if (!date || !time) return;
|
||||
|
||||
// Zeit auf 15-Min-Raster runden
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
const roundedM = Math.round(m / 15) * 15;
|
||||
const roundedH = roundedM === 60 ? h + 1 : h;
|
||||
const roundedTime = `${String(roundedH % 24).padStart(2,'0')}:${String(roundedM % 60).padStart(2,'0')}`;
|
||||
document.getElementById('plan-time').value = roundedTime;
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/slots/${date}`);
|
||||
const d = await r.json();
|
||||
statusEl.classList.remove('hidden');
|
||||
if (d.taken && d.taken.includes(roundedTime)) {
|
||||
statusEl.className = 'text-xs text-red-400 bg-red-900/20 rounded p-2';
|
||||
statusEl.textContent = `❌ Slot ${date} ${roundedTime} Uhr ist bereits belegt. Bitte andere Uhrzeit wählen.`;
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.classList.add('opacity-50');
|
||||
} else {
|
||||
statusEl.className = 'text-xs text-green-400 bg-green-900/20 rounded p-2';
|
||||
statusEl.textContent = `✅ Slot ${date} ${roundedTime} Uhr ist frei.`;
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.classList.remove('opacity-50');
|
||||
}
|
||||
} catch(e) {
|
||||
statusEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPlan() {
|
||||
const content = document.getElementById('article-editor').value.trim();
|
||||
const date = document.getElementById('plan-date').value;
|
||||
const time = document.getElementById('plan-time').value;
|
||||
const notify = document.getElementById('plan-notify').value;
|
||||
const btn = document.getElementById('confirm-plan-btn');
|
||||
|
||||
if (!content || !date || !time) return;
|
||||
|
||||
btn.innerHTML = '<span class="spinner">⚙️</span> Wird eingeplant…';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
// 1. Speichern auf selectedDate (dort liegt der generierte Artikel)
|
||||
const saveRes = await fetch(`/api/article/${selectedDate}/save`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({content, post_time: time})
|
||||
});
|
||||
const saveData = await saveRes.json();
|
||||
if (!saveData.success) throw new Error(saveData.error || 'Speichern fehlgeschlagen');
|
||||
|
||||
// 2. Falls Zieldatum != Quelldatum: Artikel verschieben
|
||||
if (date !== selectedDate && saveData.article_id) {
|
||||
const reRes = await fetch(`/api/article/${saveData.article_id}/reschedule`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({date, post_time: time})
|
||||
});
|
||||
const reData = await reRes.json();
|
||||
if (!reData.success) throw new Error(reData.error || 'Verschieben fehlgeschlagen');
|
||||
}
|
||||
|
||||
// 3. Einplanen + notify_at setzen
|
||||
const schedRes = await fetch(`/api/article/${date}/schedule`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({post_time: time, notify_mode: notify})
|
||||
});
|
||||
const schedData = await schedRes.json();
|
||||
if (!schedData.success) throw new Error(schedData.error || 'Einplanen fehlgeschlagen');
|
||||
|
||||
// Panel schließen
|
||||
document.getElementById('plan-panel').classList.add('hidden');
|
||||
updateStatusBadge('approved', '✅ Freigegeben');
|
||||
|
||||
showToast(`✅ Eingeplant — wird ${date} um ${time} Uhr automatisch gepostet.`);
|
||||
btn.innerHTML = '✅ Einplanen bestätigen';
|
||||
|
||||
// Seite nach kurzem Delay neu laden damit Redaktionsplan aktuell ist
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
|
||||
} catch(e) {
|
||||
alert('Fehler: ' + e.message);
|
||||
btn.innerHTML = '✅ Einplanen bestätigen';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Überspringen ───────────────────────────────────────────────────────────
|
||||
async function skipToday() {
|
||||
if (!confirm(selectedDate + ' überspringen?')) return;
|
||||
await fetch('/api/article/' + selectedDate + '/skip', {method:'POST'});
|
||||
updateStatusBadge('skipped', '⏭️ Übersprungen');
|
||||
}
|
||||
|
||||
// ── Posting-Zeit speichern ─────────────────────────────────────────────────
|
||||
async function savePostTime() {
|
||||
const t = document.getElementById('post-time-input').value;
|
||||
await fetch('/api/settings/post-time', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({post_time: t})
|
||||
});
|
||||
document.getElementById('next-post-time').textContent = t + ' Uhr';
|
||||
const ok = document.getElementById('save-time-ok');
|
||||
ok.classList.remove('hidden');
|
||||
setTimeout(() => ok.classList.add('hidden'), 2000);
|
||||
}
|
||||
|
||||
// ── Status-Badge aktualisieren ─────────────────────────────────────────────
|
||||
function updateStatusBadge(status, label) {
|
||||
const badge = document.getElementById('status-badge');
|
||||
badge.textContent = label;
|
||||
badge.className = 'border text-xs px-3 py-1 rounded-full status-' +
|
||||
(status === 'sent_to_bot' ? 'sent' : status === 'pending_review' ? 'pending' : status);
|
||||
}
|
||||
|
||||
// ── Board: Umplanen ────────────────────────────────────────────────────────
|
||||
function openReschedule(id, date, time) {
|
||||
closeAllReschedule();
|
||||
document.getElementById(`reschedule-panel-${id}`).classList.remove('hidden');
|
||||
document.getElementById(`rs-date-${id}`).value = date;
|
||||
document.getElementById(`rs-time-${id}`).value = time;
|
||||
checkRescheduleSlot(id);
|
||||
}
|
||||
|
||||
function closeReschedule(id) {
|
||||
document.getElementById(`reschedule-panel-${id}`).classList.add('hidden');
|
||||
}
|
||||
|
||||
function closeAllReschedule() {
|
||||
document.querySelectorAll('[id^="reschedule-panel-"]').forEach(el => el.classList.add('hidden'));
|
||||
}
|
||||
|
||||
async function checkRescheduleSlot(id) {
|
||||
const date = document.getElementById(`rs-date-${id}`).value;
|
||||
const time = document.getElementById(`rs-time-${id}`).value;
|
||||
const statusEl = document.getElementById(`rs-status-${id}`);
|
||||
const confirmBtn = document.getElementById(`rs-confirm-${id}`);
|
||||
if (!date || !time) return;
|
||||
|
||||
const [h, m] = time.split(':').map(Number);
|
||||
const roundedM = Math.round(m / 15) * 15;
|
||||
const roundedH = roundedM === 60 ? h + 1 : h;
|
||||
const roundedTime = `${String(roundedH % 24).padStart(2,'0')}:${String(roundedM % 60).padStart(2,'0')}`;
|
||||
document.getElementById(`rs-time-${id}`).value = roundedTime;
|
||||
|
||||
const r = await fetch(`/api/slots/${date}`);
|
||||
const d = await r.json();
|
||||
statusEl.classList.remove('hidden');
|
||||
if (d.taken && d.taken.includes(roundedTime)) {
|
||||
statusEl.className = 'text-xs mt-2 text-red-400';
|
||||
statusEl.textContent = `❌ Slot ${date} ${roundedTime} bereits belegt`;
|
||||
confirmBtn.disabled = true;
|
||||
confirmBtn.classList.add('opacity-50');
|
||||
} else {
|
||||
statusEl.className = 'text-xs mt-2 text-green-400';
|
||||
statusEl.textContent = `✅ Slot ${date} ${roundedTime} ist frei`;
|
||||
confirmBtn.disabled = false;
|
||||
confirmBtn.classList.remove('opacity-50');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmReschedule(id) {
|
||||
const date = document.getElementById(`rs-date-${id}`).value;
|
||||
const time = document.getElementById(`rs-time-${id}`).value;
|
||||
const r = await fetch(`/api/article/${id}/reschedule`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({date, post_time: time})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
showToast(`✅ Umgeplant auf ${date} ${time} Uhr`);
|
||||
setTimeout(() => location.reload(), 1200);
|
||||
} else {
|
||||
showToast('❌ ' + (d.error || 'Fehler beim Umplanen'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Board: Löschen ─────────────────────────────────────────────────────────
|
||||
async function deleteArticle(id, date, time) {
|
||||
if (!confirm(`Artikel vom ${date} ${time} Uhr wirklich löschen?\n\nDieser Vorgang kann nicht rückgängig gemacht werden.`)) return;
|
||||
const r = await fetch(`/api/article/${id}/delete`, {method: 'POST'});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
showToast(`🗑️ Artikel ${date} ${time} gelöscht`);
|
||||
setTimeout(() => location.reload(), 1000);
|
||||
} else {
|
||||
showToast('❌ Fehler beim Löschen');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Aufenthaltsort ─────────────────────────────────────────────────────────
|
||||
function toggleLocationMenu() {
|
||||
const menu = document.getElementById('location-menu');
|
||||
menu.classList.toggle('hidden');
|
||||
}
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#location-btn') && !e.target.closest('#location-menu')) {
|
||||
document.getElementById('location-menu').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
async function setLocation(id, flag, name) {
|
||||
const r = await fetch('/api/settings/location', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({location_id: id})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
document.getElementById('location-flag').textContent = flag;
|
||||
document.getElementById('location-name').textContent = name;
|
||||
document.getElementById('location-menu').classList.add('hidden');
|
||||
userTimezone = d.location.timezone;
|
||||
userFlag = flag;
|
||||
updateClock();
|
||||
showToast(`📍 ${flag} ${name} — Reminder: ${d.reminders_berlin.morning} & ${d.reminders_berlin.afternoon} Uhr (Berlin)`);
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const t = document.createElement('div');
|
||||
t.className = 'fixed bottom-6 left-1/2 -translate-x-1/2 bg-slate-700 border border-slate-500 text-white text-sm px-5 py-3 rounded-xl shadow-xl z-50 transition-opacity';
|
||||
t.textContent = msg;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), 500); }, 4000);
|
||||
}
|
||||
|
||||
// ── OpenRouter Balance ─────────────────────────────────────────────────────
|
||||
async function loadBalance() {
|
||||
const el = document.getElementById('balance-inline');
|
||||
try {
|
||||
const r = await fetch('/api/balance');
|
||||
const d = await r.json();
|
||||
if (d.remaining !== null && d.remaining !== undefined) {
|
||||
el.textContent = '$' + d.remaining.toFixed(4);
|
||||
el.className = d.remaining < 0.5
|
||||
? 'text-sm font-semibold text-red-400 mt-1'
|
||||
: 'text-sm font-semibold text-green-400 mt-1';
|
||||
} else if (d.limit === null) {
|
||||
el.textContent = '∞ Free Tier';
|
||||
el.className = 'text-sm font-semibold text-blue-400 mt-1';
|
||||
} else {
|
||||
el.textContent = '—';
|
||||
}
|
||||
} catch { el.textContent = 'Fehler'; }
|
||||
}
|
||||
// Balance nur auf Knopfdruck laden, nicht automatisch beim Start
|
||||
|
||||
// ── Monatskalender rendern ────────────────────────────────────────────────
|
||||
function renderMonthCalendar() {
|
||||
const grid = document.getElementById('month-grid');
|
||||
if (!grid) return;
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
let startWeekday = firstDay.getDay() - 1;
|
||||
if (startWeekday < 0) startWeekday = 6;
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const todayDate = now.getDate();
|
||||
|
||||
let html = '';
|
||||
for (let i = 0; i < startWeekday; i++) {
|
||||
html += '<div></div>';
|
||||
}
|
||||
for (let d = 1; d <= daysInMonth; d++) {
|
||||
const dateStr = `${year}-${String(month+1).padStart(2,'0')}-${String(d).padStart(2,'0')}`;
|
||||
const status = MONTH_ARTICLES[dateStr];
|
||||
const isToday = d === todayDate;
|
||||
let dotColor = 'bg-slate-700';
|
||||
if (status === 'posted') dotColor = 'bg-blue-500';
|
||||
else if (status === 'approved' || status === 'sent_to_bot') dotColor = 'bg-green-500';
|
||||
else if (status === 'draft' || status === 'pending_review') dotColor = 'bg-yellow-500';
|
||||
else if (status === 'skipped') dotColor = 'bg-slate-600';
|
||||
|
||||
html += `<div onclick="loadDate('${dateStr}')"
|
||||
class="py-1 rounded cursor-pointer hover:bg-slate-600/50 transition
|
||||
${isToday ? 'ring-1 ring-blue-400 font-bold text-blue-400' : 'text-slate-400'}">
|
||||
${d}
|
||||
<div class="w-1.5 h-1.5 rounded-full ${dotColor} mx-auto mt-0.5"></div>
|
||||
</div>`;
|
||||
}
|
||||
grid.innerHTML = html;
|
||||
}
|
||||
renderMonthCalendar();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
211
fuenfvoacht/src/templates/logs.html
Normal file
211
fuenfvoacht/src/templates/logs.html
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Logs — FünfVorAcht</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { background: #0f172a; color: #e2e8f0; }
|
||||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
|
||||
.log-container {
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
height: 520px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
.log-line { padding: 2px 0; border-bottom: 1px solid #1a2030; white-space: pre-wrap; word-break: break-all; }
|
||||
.log-line:last-child { border-bottom: none; }
|
||||
.log-ERROR { color: #f87171; }
|
||||
.log-CRITICAL { color: #fb923c; font-weight: bold; }
|
||||
.log-WARNING { color: #fbbf24; }
|
||||
.log-INFO { color: #94a3b8; }
|
||||
.log-DEBUG { color: #475569; }
|
||||
.tab-btn { padding: 6px 16px; border-radius: 6px; font-size: 13px; cursor: pointer; transition: all .15s; }
|
||||
.tab-active { background: #3b82f6; color: white; }
|
||||
.tab-inactive { background: #1e293b; color: #94a3b8; border: 1px solid #334155; }
|
||||
.badge-error { background: #7f1d1d; color: #fca5a5; }
|
||||
.badge-warn { background: #713f12; color: #fde68a; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-2xl">🕗</span>
|
||||
<span class="text-xl font-bold text-white">FünfVorAcht</span>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a href="/" class="text-slate-400 hover:text-white">Übersicht</a>
|
||||
<a href="/history" class="text-slate-400 hover:text-white">History</a>
|
||||
<a href="/prompts" class="text-slate-400 hover:text-white">Prompts</a>
|
||||
<a href="/settings" class="text-slate-400 hover:text-white">Einstellungen</a>
|
||||
<a href="/logs" class="text-blue-400 font-semibold">Logs</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-5xl mx-auto px-6 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">📋 System-Logs</h1>
|
||||
<div class="flex items-center gap-3">
|
||||
<span id="error-badge" class="hidden text-xs px-2 py-1 rounded-full badge-error font-semibold"></span>
|
||||
<button onclick="loadLogs()" class="text-sm bg-slate-700 hover:bg-slate-600 text-white px-4 py-2 rounded-lg">
|
||||
🔄 Aktualisieren
|
||||
</button>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-400 cursor-pointer">
|
||||
<input type="checkbox" id="auto-refresh" onchange="toggleAutoRefresh()" class="rounded">
|
||||
Live (10s)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab-Auswahl -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button id="tab-app" onclick="switchTab('app.log')" class="tab-btn tab-active">
|
||||
🌐 Web App
|
||||
</button>
|
||||
<button id="tab-bot" onclick="switchTab('bot.log')" class="tab-btn tab-inactive">
|
||||
🤖 Bot
|
||||
</button>
|
||||
<button id="tab-error" onclick="switchTab('error.log')" class="tab-btn tab-inactive">
|
||||
⚠️ Fehler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filter-Leiste -->
|
||||
<div class="flex gap-3 mb-4 items-center">
|
||||
<input id="filter-input" type="text" placeholder="Filter (z.B. ERROR, /api, generate…)"
|
||||
oninput="filterLogs()"
|
||||
class="flex-1 bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500">
|
||||
<select id="level-filter" onchange="filterLogs()"
|
||||
class="bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500">
|
||||
<option value="">Alle Level</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="INFO">INFO</option>
|
||||
</select>
|
||||
<select id="lines-select" onchange="loadLogs()"
|
||||
class="bg-slate-800 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500">
|
||||
<option value="100">100 Zeilen</option>
|
||||
<option value="200" selected>200 Zeilen</option>
|
||||
<option value="500">500 Zeilen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Log-Container -->
|
||||
<div class="log-container" id="log-output">
|
||||
<span class="text-slate-500">Lade Logs…</span>
|
||||
</div>
|
||||
|
||||
<!-- Statistiken -->
|
||||
<div class="mt-4 flex gap-6 text-xs text-slate-500" id="log-stats"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentFile = 'app.log';
|
||||
let allEntries = [];
|
||||
let autoTimer = null;
|
||||
|
||||
async function loadLogs() {
|
||||
const lines = document.getElementById('lines-select').value;
|
||||
const box = document.getElementById('log-output');
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/logs/${currentFile}?lines=${lines}`);
|
||||
const d = await r.json();
|
||||
allEntries = d.entries || [];
|
||||
renderLogs();
|
||||
updateStats();
|
||||
} catch (e) {
|
||||
box.innerHTML = `<span class="text-red-400">Fehler beim Laden: ${e.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
const box = document.getElementById('log-output');
|
||||
const text = document.getElementById('filter-input').value.toLowerCase();
|
||||
const level = document.getElementById('level-filter').value;
|
||||
const bottom = box.scrollTop + box.clientHeight >= box.scrollHeight - 20;
|
||||
|
||||
const filtered = allEntries.filter(e => {
|
||||
if (level && e.level !== level) return false;
|
||||
if (text && !e.text.toLowerCase().includes(text)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (filtered.length === 0) {
|
||||
box.innerHTML = '<span class="text-slate-500">Keine Einträge für diesen Filter.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
box.innerHTML = filtered.map(e =>
|
||||
`<div class="log-line log-${e.level}">${escHtml(e.text)}</div>`
|
||||
).join('');
|
||||
|
||||
if (bottom) box.scrollTop = box.scrollHeight;
|
||||
}
|
||||
|
||||
function filterLogs() { renderLogs(); updateStats(); }
|
||||
|
||||
function updateStats() {
|
||||
const errors = allEntries.filter(e => e.level === 'ERROR').length;
|
||||
const warnings = allEntries.filter(e => e.level === 'WARNING').length;
|
||||
const total = allEntries.length;
|
||||
|
||||
document.getElementById('log-stats').innerHTML =
|
||||
`<span>${total} Einträge</span>` +
|
||||
(errors ? `<span class="text-red-400">● ${errors} Fehler</span>` : '') +
|
||||
(warnings ? `<span class="text-yellow-400">● ${warnings} Warnungen</span>` : '');
|
||||
|
||||
const badge = document.getElementById('error-badge');
|
||||
if (errors > 0) {
|
||||
badge.textContent = `${errors} Fehler`;
|
||||
badge.classList.remove('hidden');
|
||||
} else {
|
||||
badge.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(file) {
|
||||
currentFile = file;
|
||||
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||
b.classList.replace('tab-active', 'tab-inactive');
|
||||
});
|
||||
const map = {'app.log': 'tab-app', 'bot.log': 'tab-bot', 'error.log': 'tab-error'};
|
||||
document.getElementById(map[file]).classList.replace('tab-inactive', 'tab-active');
|
||||
document.getElementById('filter-input').value = '';
|
||||
document.getElementById('level-filter').value = '';
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
if (document.getElementById('auto-refresh').checked) {
|
||||
autoTimer = setInterval(loadLogs, 10000);
|
||||
} else {
|
||||
clearInterval(autoTimer);
|
||||
autoTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(t) {
|
||||
return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// Frontend-Fehler abfangen und serverseitig loggen
|
||||
window.onerror = function(msg, url, line) {
|
||||
fetch('/api/log-error', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({message: msg, url, line})
|
||||
});
|
||||
};
|
||||
|
||||
loadLogs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
224
fuenfvoacht/src/templates/prompts.html
Normal file
224
fuenfvoacht/src/templates/prompts.html
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Prompts — FünfVorAcht</title>
|
||||
<link rel="stylesheet" href="/static/tailwind.min.css">
|
||||
<style>
|
||||
body { background: #0f172a; color: #e2e8f0; }
|
||||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
|
||||
.tg-preview { background: #212d3b; border-left: 3px solid #3b82f6; font-family: system-ui; white-space: pre-wrap; line-height: 1.6; }
|
||||
textarea { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; border-radius: 8px; resize: vertical; }
|
||||
textarea:focus { outline: none; border-color: #3b82f6; }
|
||||
input[type=text] { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; border-radius: 8px; }
|
||||
input[type=text]:focus { outline: none; border-color: #3b82f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
|
||||
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1 bg-slate-800 border border-slate-700 rounded-lg p-1">
|
||||
<span class="flex items-center gap-1.5 bg-slate-700 text-white text-xs font-semibold px-3 py-1.5 rounded-md">🕗 FünfVorAcht</span>
|
||||
<a href="https://redakteur.orbitalo.net" target="_blank"
|
||||
class="flex items-center gap-1.5 text-slate-500 hover:text-slate-200 hover:bg-slate-700 text-xs font-medium px-3 py-1.5 rounded-md transition">📝 Redakteur</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a href="/" class="text-slate-400 hover:text-white">Studio</a>
|
||||
<a href="/history" class="text-slate-400 hover:text-white">History</a>
|
||||
<a href="/prompts" class="text-blue-400 font-semibold">Prompts</a>
|
||||
<a href="/settings" class="text-slate-400 hover:text-white">Einstellungen</a>
|
||||
<a href="/hilfe" class="text-slate-400 hover:text-white">❓ Hilfe</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">🧠 Prompt-Bibliothek</h1>
|
||||
<button onclick="showNewPrompt()"
|
||||
class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg text-sm transition">
|
||||
+ Neuer Prompt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
<!-- Prompt Liste -->
|
||||
<div class="space-y-4">
|
||||
{% for p in prompts %}
|
||||
<div class="card p-4 cursor-pointer hover:border-blue-500 transition {% if p.is_default %}border-green-600{% endif %}"
|
||||
onclick="loadPrompt({{ p.id }}, {{ p.system_prompt|tojson }}, {{ p.name|tojson }}, {{ p.test_result|tojson if p.test_result else 'null' }}, {{ p.is_default }})">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-white">{{ p.name }}</span>
|
||||
{% if p.is_default %}
|
||||
<span class="bg-green-800 text-green-300 text-xs px-2 py-0.5 rounded-full">Standard</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not p.is_default %}
|
||||
<form action="/prompts/delete/{{ p.id }}" method="POST" onclick="event.stopPropagation()" onsubmit="return confirm('Prompt «{{ p.name }}» wirklich löschen?')">
|
||||
<button type="submit" class="text-red-400 hover:text-red-300 text-xs px-2 py-1 rounded hover:bg-red-900/30 transition">🗑</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-slate-400 truncate">{{ p.system_prompt[:100] }}…</div>
|
||||
{% if p.last_tested_at %}
|
||||
<div class="text-xs text-slate-500 mt-1">Zuletzt getestet: {{ p.last_tested_at[:16] }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Editor + Preview -->
|
||||
<div class="space-y-4">
|
||||
<div class="card p-4" id="editor-card">
|
||||
<h2 class="text-white font-semibold mb-4" id="editor-title">Prompt auswählen</h2>
|
||||
<input type="hidden" id="prompt-id">
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="text-xs text-slate-400 mb-1 block">Name</label>
|
||||
<input type="text" id="prompt-name" class="w-full px-3 py-2 text-sm" placeholder="z.B. Standard">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="text-xs text-slate-400 mb-1 block">
|
||||
System-Prompt
|
||||
<span class="text-slate-600 ml-2">Variablen: {source} {date} {tag}</span>
|
||||
</label>
|
||||
<textarea id="prompt-text" class="w-full px-3 py-2 text-sm" rows="12" placeholder="Prompt hier eingeben…"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="text-xs text-slate-400 mb-1 block">Test-Quelle (URL oder Text)</label>
|
||||
<input type="text" id="test-source" class="w-full px-3 py-2 text-sm"
|
||||
value="https://tagesschau.de" placeholder="URL oder Text zum Testen">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button onclick="testPrompt()"
|
||||
class="bg-yellow-700 hover:bg-yellow-600 text-white px-4 py-2 rounded-lg text-sm transition">
|
||||
🧪 Testen
|
||||
</button>
|
||||
<button onclick="savePrompt()"
|
||||
class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg text-sm transition">
|
||||
💾 Speichern
|
||||
</button>
|
||||
<button onclick="setDefault()"
|
||||
class="bg-green-700 hover:bg-green-600 text-white px-4 py-2 rounded-lg text-sm transition">
|
||||
⭐ Als Standard
|
||||
</button>
|
||||
<button onclick="deleteCurrentPrompt()" id="btn-delete"
|
||||
class="bg-red-800 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm transition ml-auto"
|
||||
style="display:none">
|
||||
🗑 Löschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test-Preview -->
|
||||
<div class="card p-4" id="preview-card" style="display:none">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-white font-semibold">📱 Telegram-Vorschau</h3>
|
||||
<span class="text-xs text-slate-500" id="char-count"></span>
|
||||
</div>
|
||||
<div id="tg-preview" class="tg-preview rounded-lg p-4 text-sm text-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentPromptId = null;
|
||||
let currentIsDefault = false;
|
||||
|
||||
function loadPrompt(id, text, name, testResult, isDefault) {
|
||||
currentPromptId = id;
|
||||
currentIsDefault = !!isDefault;
|
||||
document.getElementById('prompt-id').value = id;
|
||||
document.getElementById('prompt-name').value = name;
|
||||
document.getElementById('prompt-text').value = text;
|
||||
document.getElementById('editor-title').textContent = `Bearbeiten: ${name}`;
|
||||
document.getElementById('btn-delete').style.display = isDefault ? 'none' : 'block';
|
||||
if (testResult) {
|
||||
showPreview(testResult);
|
||||
}
|
||||
}
|
||||
|
||||
function showNewPrompt() {
|
||||
currentPromptId = null;
|
||||
currentIsDefault = false;
|
||||
document.getElementById('prompt-id').value = '';
|
||||
document.getElementById('prompt-name').value = '';
|
||||
document.getElementById('prompt-text').value = '';
|
||||
document.getElementById('editor-title').textContent = 'Neuer Prompt';
|
||||
document.getElementById('btn-delete').style.display = 'none';
|
||||
}
|
||||
|
||||
async function deleteCurrentPrompt() {
|
||||
if (!currentPromptId) return;
|
||||
if (currentIsDefault) return alert('Standard-Prompt kann nicht gelöscht werden');
|
||||
const name = document.getElementById('prompt-name').value;
|
||||
if (!confirm(`Prompt «${name}» wirklich löschen?`)) return;
|
||||
await fetch(`/prompts/delete/${currentPromptId}`, {method: 'POST'});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function testPrompt() {
|
||||
const system_prompt = document.getElementById('prompt-text').value;
|
||||
const source = document.getElementById('test-source').value;
|
||||
const btn = event.target;
|
||||
btn.textContent = '⏳ Generiere...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch('/prompts/test', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({system_prompt, source, tag: 'Politik', prompt_id: currentPromptId})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) {
|
||||
showPreview(d.result);
|
||||
} else {
|
||||
alert('Fehler: ' + d.error);
|
||||
}
|
||||
} catch(e) {
|
||||
alert('Verbindungsfehler: ' + e);
|
||||
} finally {
|
||||
btn.textContent = '🧪 Testen';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showPreview(text) {
|
||||
const preview = document.getElementById('tg-preview');
|
||||
const card = document.getElementById('preview-card');
|
||||
// Render Telegram HTML tags visually
|
||||
let html = text
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/<b>(.*?)<\/b>/gs, '<strong>$1</strong>')
|
||||
.replace(/<i>(.*?)<\/i>/gs, '<em>$1</em>')
|
||||
.replace(/<a href="(.*?)">(.*?)<\/a>/gs, '<a href="$1" class="text-blue-400 underline">$2</a>');
|
||||
preview.innerHTML = html;
|
||||
document.getElementById('char-count').textContent = `${text.length} Zeichen ${text.length > 4096 ? '⚠️ ZU LANG' : '✓'}`;
|
||||
card.style.display = 'block';
|
||||
}
|
||||
|
||||
async function savePrompt() {
|
||||
const form = new FormData();
|
||||
form.append('id', document.getElementById('prompt-id').value);
|
||||
form.append('name', document.getElementById('prompt-name').value);
|
||||
form.append('system_prompt', document.getElementById('prompt-text').value);
|
||||
const r = await fetch('/prompts/save', {method: 'POST', body: form});
|
||||
location.reload();
|
||||
}
|
||||
|
||||
async function setDefault() {
|
||||
if (!currentPromptId) return alert('Zuerst Prompt auswählen');
|
||||
const r = await fetch(`/prompts/default/${currentPromptId}`, {method: 'POST'});
|
||||
location.reload();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
96
fuenfvoacht/src/templates/settings.html
Normal file
96
fuenfvoacht/src/templates/settings.html
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Einstellungen — FünfVorAcht</title>
|
||||
<link rel="stylesheet" href="/static/tailwind.min.css">
|
||||
<style>
|
||||
body { background: #0f172a; color: #e2e8f0; }
|
||||
.card { background: #1e293b; border: 1px solid #334155; border-radius: 12px; }
|
||||
input, select { background: #0f172a; border: 1px solid #334155; color: #e2e8f0; border-radius: 8px; }
|
||||
input:focus, select:focus { outline: none; border-color: #3b82f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<nav class="bg-slate-900 border-b border-slate-700 px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1 bg-slate-800 border border-slate-700 rounded-lg p-1">
|
||||
<span class="flex items-center gap-1.5 bg-slate-700 text-white text-xs font-semibold px-3 py-1.5 rounded-md">🕗 FünfVorAcht</span>
|
||||
<a href="https://redakteur.orbitalo.net" target="_blank"
|
||||
class="flex items-center gap-1.5 text-slate-500 hover:text-slate-200 hover:bg-slate-700 text-xs font-medium px-3 py-1.5 rounded-md transition">📝 Redakteur</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 text-sm">
|
||||
<a href="/" class="text-slate-400 hover:text-white">Studio</a>
|
||||
<a href="/history" class="text-slate-400 hover:text-white">History</a>
|
||||
<a href="/prompts" class="text-slate-400 hover:text-white">Prompts</a>
|
||||
<a href="/settings" class="text-blue-400 font-semibold">Einstellungen</a>
|
||||
<a href="/hilfe" class="text-slate-400 hover:text-white">❓ Hilfe</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="max-w-2xl mx-auto px-6 py-8 space-y-6">
|
||||
<h1 class="text-2xl font-bold text-white">⚙️ Einstellungen</h1>
|
||||
|
||||
<!-- Kanal -->
|
||||
<div class="card p-6">
|
||||
<h2 class="text-white font-semibold mb-4">📢 Telegram Kanal</h2>
|
||||
<form method="POST" action="/settings/channel" class="space-y-4">
|
||||
<div>
|
||||
<label class="text-xs text-slate-400 block mb-1">Kanal-ID oder @username</label>
|
||||
<input type="text" name="telegram_id" value="{{ channel.telegram_id or '' }}"
|
||||
class="w-full px-3 py-2 text-sm" placeholder="@meinkanal oder -1001234567890">
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-slate-400 block mb-1">Posting-Uhrzeit (HH:MM)</label>
|
||||
<input type="text" name="post_time" value="{{ channel.post_time or '19:55' }}"
|
||||
class="w-full px-3 py-2 text-sm" placeholder="19:55">
|
||||
</div>
|
||||
<button type="submit"
|
||||
class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg text-sm transition">
|
||||
💾 Speichern
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Quellen-Favoriten -->
|
||||
<div class="card p-6">
|
||||
<h2 class="text-white font-semibold mb-4">📌 Quellen-Favoriten</h2>
|
||||
{% if favorites %}
|
||||
<div class="space-y-2 mb-4">
|
||||
{% for f in favorites %}
|
||||
<div class="flex items-center justify-between py-2 border-b border-slate-700">
|
||||
<div>
|
||||
<span class="text-sm text-white">{{ f.label }}</span>
|
||||
<span class="text-xs text-slate-400 ml-2">{{ f.url[:50] }}{% if f.url|length > 50 %}…{% endif %}</span>
|
||||
</div>
|
||||
<span class="text-xs text-slate-500">{{ f.used_count }}×</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="POST" action="/settings/favorite/add" class="flex gap-2">
|
||||
<input type="text" name="label" placeholder="Name" class="px-3 py-2 text-sm w-32">
|
||||
<input type="text" name="url" placeholder="https://…" class="px-3 py-2 text-sm flex-1">
|
||||
<button type="submit"
|
||||
class="bg-slate-600 hover:bg-slate-500 text-white px-3 py-2 rounded-lg text-sm transition">
|
||||
+ Hinzufügen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="card p-6">
|
||||
<h2 class="text-white font-semibold mb-3">🏷️ Themen-Tags</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for tag in tags %}
|
||||
<span class="bg-slate-700 text-slate-300 text-xs px-3 py-1 rounded-full">#{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mt-3">Tags werden im Bot beim Generieren automatisch aus dem Prompt übernommen.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Reference in a new issue