diff --git a/.gitignore b/.gitignore
index a9112b2..9ef9201 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,14 @@ crumbforest.db
/data/
app/logs/
app/static/img/
+dev_docs.bak
+
+# Decoupled Documentation (Content)
+docs/
+HANDBUCH.md
+QUICKSTART.md
+CrumbTech.md
+ARCHITECTURE_ROLES_GROUPS.md
+QDRANT_ACCESS.md
+DIARY_RAG_README.md
+HOME_TEMPLATE_PLAN.md
diff --git a/CODE_TOUR.md b/CODE_TOUR.md
new file mode 100644
index 0000000..7e5cf72
--- /dev/null
+++ b/CODE_TOUR.md
@@ -0,0 +1,217 @@
+# 🌲 Crumbforest Code Tour
+
+Dies ist dein "Reiseführer" durch den Code. Ziel ist es, jede Komponente zu verstehen, damit du sie als solide Basis für zukünftige Projekte nutzen kannst.
+
+## 🗺️ High-Level Architektur (Der Wald von oben)
+
+Der CrumbCore basiert auf **FastAPI** (Python) und folgt einer modularen Struktur:
+
+* **`app/main.py`**: Der Eingang (Root). Hier startet alles.
+* **`app/routers/`**: Die Wegweiser. Jede Datei bedient einen URL-Bereich (z.B. `/admin`, `/api`).
+* **`app/services/`**: Die Arbeiter. Logik für RAG, Übersetzungen, Config-Loading.
+* **`app/models/`**: Die Datenbank-Struktur (SQLAlchemy / raw SQL Schemas).
+* **`app/templates/`**: Das Gesicht (Jinja2 HTML Templates) & Design.
+
+---
+
+## 🚀 1. Der Einstieg: `app/main.py`
+
+Hier atmet das System.
+
+### Wichtige Konzepte:
+1. **FastAPI Instanz**: `app = FastAPI()` ist der Kern.
+2. **Middleware**: `SessionMiddleware` (für Login-Cookies) und `CORSMiddleware` (Sicherheit).
+3. **Template Engine**: `init_templates(app)` lädt Jinja2. Unsere `render`-Funktion injiziert automatisch User, Sprache und Flash-Nachrichten in jedes Template.
+4. **Router Mounting**: Am Ende der Datei werden die Module aus `routers/` eingebunden (`app.include_router(...)`). Das hält `main.py` schlank.
+
+### Code-Lupe:
+```python
+# app/main.py
+
+# ... Imports ...
+
+# Security Secret (wichtig für Cookies!)
+SECRET = os.getenv("APP_SECRET", "dev-secret-change-me")
+
+app = FastAPI()
+
+# 1. Statische Dateien (CSS, Bilder) verfügbar machen
+app.mount("/static", StaticFiles(directory="static"), name="static")
+
+# 2. Session Middleware (Das Gedächtnis des Browsers)
+app.add_middleware(SessionMiddleware, secret_key=SECRET, ...)
+
+# 3. Template Renderer (Unsere "View Engine")
+def init_templates(app: FastAPI):
+ # ... lädt "templates"-Ordner ...
+ # Definiert die "render"-Funktion, die wir überall nutzen
+ def render(req: Request, template: str, **ctx):
+ # ... Logik für Sprache (lang) und User-Context ...
+ return HTMLResponse(tpl.render(**base_ctx))
+
+# 4. Router einbinden (Modularisierung)
+app.include_router(home_router, prefix="/{lang}") # Multi-Language Support!
+# ...
+```
+
+---
+
+## 🚦 2. Das Routing: `app/routers/`
+
+Hier entscheidet sich, wohin die Reise geht.
+
+* **`home.py`**: Öffentliche Seiten (`/`, `/about`, `/crew`). Lädt Inhalte multiligual.
+* **`crumbforest_roles.py`**: Das Herzstück Phase 1. Zeigt Rollen-Dashboard und Chat. Prüft `user_group`-Zugriff.
+* **`auth.py`** (bzw. Login in Hauptdatei/Home): Verwaltet Login/Logout.
+* **`diary_rag.py`**: API für das Tagebuch-RAG (Vector Search).
+
+---
+
+## 🧠 3. Die Intelligenz: `app/services/`
+
+* **`rag_service.py`**: Verbindet Qdrant (Vektordatenbank) mit LLMs. Hier passiert die Magie ("Suche Igel im Wald").
+* **`localization.py`**: Lädt `characters.de.json` usw. und merget sie live in die Config.
+
+---
+
+## 💾 4. Die Daten: `crumbforest_config.json`
+
+Unsere "Single Source of Truth" für Rollen und Gruppen.
+Statt Hardcoding in Python definieren wir hier:
+* Wer darf was sehen? (`group_access`)
+* Welche Farbe hat der Fuchs?
+* Welches LLM nutzt die Eule?
+
+---
+
+Dies ist der Startpunkt. Welchen Bereich wollen wir als nächstes zerlegen? 🧐
+
+## 🧠 5. Deep Dive: Der RAG Service (`app/services/rag_service.py`)
+
+Hier wird Text zu Wissen. Wenn wir "Wald" sagen, müssen wir nicht nur Keyword-Matches finden, sondern *Bedeutung*.
+
+### Der Indexing-Workflow (`index_post`)
+Wie kommt ein Blogpost oder Tagebucheintrag in das Gehirn?
+
+1. **Hash-Check**: Wir erstellen einen MD5-Hash des Inhalts. Ist er gleich wie in der DB? Überspringen! (Spart KI-Kosten).
+2. **Chunking**: Der Text wird mit `EmbeddingService` in Häppchen geteilt (z.B. 1000 Zeichen).
+3. **Embedding**: Jedes Häppchen wird durch ein Embedding-Modell (OpenAI/Jina) geschickt. Das Ergebnis ist ein Vektor (eine Liste von Zahlen, z.B. `[0.1, -0.5, ...]`).
+4. **Upsert in Qdrant**: Wir speichern den Vektor + Metadaten (Textinhalt, Titel, Slug) in Qdrant.
+5. **Tracking**: In MariaDB (`post_vectors` Tabelle) merken wir uns, welcher Post welche Vektoren hat (für Löschung/Updates).
+
+### Die Suche (`query_with_rag`)
+Der magische Moment, wenn der User eine Frage stellt:
+
+1. **Embed Query**: Die Frage "Wer wohnt im Wald?" wird ebenfalls in einen Vektor verwandelt.
+2. **Vektor-Suche (Semantic Search)**: Wir fragen Qdrant: "Welche Vektoren liegen in der Nähe dieses Frage-Vektors?". Dank Cosine-Similarity findet er Inhalte, die *inhaltlich* passen, auch ohne exakte Worte.
+3. **Context Assembly**: Wir nehmen die Top-3 gefundenen Text-Chunks und kleben sie zusammen.
+4. **LLM Generation**: Wir bauen einen Prompt für die KI:
+ ```text
+ Context: [Der Igel wohnt im Unterholz...]
+ Frage: Wer wohnt im Wald?
+ Antworte basierend auf dem Context.
+ ```
+5. **Antwort**: Die KI generiert die fertige Antwort.
+
+### Warum `rag_service.py` wichtig ist
+Er entkoppelt die Logik:
+* Der *Router* (`diary_rag.py`) weiß nur: "Indexiere dies" oder "Frage das".
+* Der *Service* kümmert sich um die Details (Qdrant API, Datenbank-Sync, Hash-Prüfung).
+So bleibt der Controller sauber! 🧹✨
+
+## 🔐 6. Deep Dive: Sicherheit & Auth (`app/deps.py`)
+
+Wie unterscheiden wir Freund von Feind? Das "Dependency Injection" System von FastAPI regelt das elegant.
+
+### Der Wächter: `current_user`
+In `app/deps.py` definieren wir Funktionen, die FastAPI *vor* jedem Request ausführt.
+
+```python
+# app/deps.py
+
+def current_user(req: Request):
+ # Holt das User-Objekt aus dem signierten Session-Cookie
+ return req.session.get("user")
+
+def admin_required(user = Depends(current_user)):
+ # 1. Ist ein User da?
+ if not user:
+ raise HTTPException(status_code=401) # -> Login Page
+
+ # 2. Ist es ein Admin?
+ if user.get("role") != "admin":
+ raise HTTPException(status_code=403) # -> Verboten!
+
+ return user
+```
+
+### Die Anwendung in Routern
+Wir müssen Sicherheitschecks nicht in jede Funktion kopieren. Wir "injizieren" sie einfach:
+
+```python
+# app/routers/admin.py
+
+@router.get("/")
+def admin_dashboard(
+ user: dict = Depends(admin_required) # <--- Hier passiert der Check!
+):
+ # Wenn wir hier sind, IST der User garantiert ein Admin.
+ return render(..., user=user)
+```
+
+### Session Security (in `main.py`)
+Das Login-System basiert auf `SessionMiddleware`.
+* **Cookie**: Der Browser bekommt ein Cookie namens `session`.
+* **Signierung**: Das Cookie ist mit `APP_SECRET` kryptografisch signiert. Der User kann den Inhalt lesen (Base64), aber **nicht verändern**. Wenn er `role: admin` reinschummelt, wird die Signatur ungültig und der Server ignoriert das Cookie.
+* **HttpOnly**: JavaScript kann das Cookie nicht stehlen.
+* **SameSite**: Schützt vor CSRF-Attacken.
+
+## 🎨 7. Das Frontend (`app/templates/`)
+
+Wir nutzen **Jinja2** (klassisches Server-Side Rendering) gepaart mit **PicoCSS** (minimalistisches CSS Framework).
+
+### Struktur
+* **`base.html`**: Das Skelett. Enthält `
`, Navigation und Footer. Alle anderen Seiten erben hiervon (`{% extends "base.html" %}`).
+* **`crumbforest/`**: Templates für die Rollen-Ansicht und den Chat.
+* **`pages/`**: Allgemeine Seiten (Login, Home).
+
+### Datenfluss
+Wenn ein Router `render(req, "login.html", error="Falsches PW")` aufruft:
+1. Jinja2 lädt `login.html`.
+2. Es sieht `{% extends "base.html" %}` und lädt erst das Skelett.
+3. Es füllt die Blöcke (`{% block content %}`) mit dem Login-Formular.
+4. Es injiziert die Variable `error` an der passenden Stelle.
+
+## 💾 8. Die Daten (`app/models/` & SQL)
+
+Hier wird es interessant. Wir nutzen **Hybrid-Ansatz**:
+
+### API-Validierung (Pydantic)
+In `app/models/` liegen Klassen wie `QueryRequest` oder `User`.
+Das sind **keine** Datenbank-Tabellen! Sie dienen nur der **Validierung** von Input/Output.
+Wenn jemand JSON an die API schickt, prüft FastAPI automatisch: "Fehlt das Feld 'question'? Ist 'limit' eine Zahl?".
+
+### Datenbank (Raw SQL)
+Wir nutzen *kein* schwergewichtiges ORM (wie SQLAlchemy) für die Queries, sondern rohes SQL via `pymysql`.
+Warum? **Performance & Kontrolle**.
+* Zu sehen in: `app/services/rag_service.py` oder `main.py`.
+* Codeschnipsel:
+ ```python
+ cur.execute("SELECT * FROM users WHERE email=%s", (email,))
+ ```
+Das macht den Code extrem transparent. SQL ist die Wahrheit.
+
+---
+
+🎉 **Herzlichen Glückwunsch!** Du hast den Wald einmal durchquert.
+Du kennst jetzt:
+1. Den Einstieg (`main.py`)
+2. Die Logik (`routers` & `services`)
+3. Die Sicherheit (`deps.py`)
+4. Das Gesicht (`templates`)
+5. Das Gedächtnis (`models` & SQL)
+
+Bereit für Phase 2? 🚀
+
+
+
diff --git a/README.md b/README.md
index b79f444..3499d20 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,16 @@ Der `setup.sh` Script kümmert sich um alles (Environment, Docker Build, Start):
./setup.sh
```
-### 2. Loslegen
+### 2. Content laden (Wichtig!)
+Dieses Repo enthält **nur die Engine** (Core). Der Inhalt (Wissensdatenbank, Tagebücher) wird separat geladen.
+Nutze dafür den Content-Loader:
+
+```bash
+./load_content.sh
+```
+*Ohne URL sucht er nach dem Default-Repo (aktuell Platzhalter).*
+
+### 3. Loslegen
Nach dem Start sind folgende Services erreichbar:
diff --git a/app/Dockerfile b/app/Dockerfile
index cf37d93..eeeb984 100644
--- a/app/Dockerfile
+++ b/app/Dockerfile
@@ -12,6 +12,9 @@ COPY app/ /app/
# Copy documentation for auto-indexing
COPY docs/ /app/docs/
+# Copy root documentation (README, etc.) for Docs Reader
+COPY *.md /docs_root/
+
# Make entrypoint executable
RUN chmod +x /app/entrypoint.sh
diff --git a/app/main.py b/app/main.py
index 9109b1f..9e2b6dd 100644
--- a/app/main.py
+++ b/app/main.py
@@ -20,11 +20,16 @@ from routers.admin_post import router as admin_posts_router
from routers.admin_rag import router as admin_rag_router
from routers.admin_logs import router as admin_logs_router
from routers.admin_vectors import router as admin_vectors_router
+from routers.admin_vectors import router as admin_vectors_router
+from routers.admin_roles import router as admin_config_router
+from routers.admin_roles import router as admin_config_router
+from routers.docs_reader import router as docs_router
from routers.diary_rag import router as diary_rag_router
from routers.document_rag import router as document_rag_router
from routers.home import router as home_router
from routers.chat import router as chat_router
from routers.chat_page import router as chat_page_router
+from routers.pulse import router as pulse_router
from routers.crumbforest_roles import router as roles_router
@@ -219,11 +224,14 @@ app.include_router(admin_posts_router, prefix="/admin")
app.include_router(admin_rag_router, prefix="/admin/rag", tags=["RAG"])
app.include_router(admin_logs_router, prefix="/admin", tags=["Admin Logs"])
app.include_router(admin_vectors_router, prefix="/admin", tags=["Admin Vectors"])
+app.include_router(admin_config_router, prefix="/admin", tags=["Admin Config"])
+app.include_router(docs_router, tags=["Docs"])
app.include_router(diary_rag_router, prefix="/api/diary", tags=["Diary RAG"])
app.include_router(document_rag_router, prefix="/api/documents", tags=["Documents RAG"])
app.include_router(chat_router, tags=["Chat"])
app.include_router(chat_page_router, tags=["Chat"])
app.include_router(roles_router, tags=["Roles"])
+app.include_router(pulse_router, tags=["Pulse"])
# Mount home router with lang prefix FIRST so it takes precedence
app.include_router(home_router, prefix="/{lang}", tags=["Home"])
diff --git a/app/requirements.txt b/app/requirements.txt
index e98071b..7e3b156 100644
--- a/app/requirements.txt
+++ b/app/requirements.txt
@@ -25,6 +25,7 @@ pydantic-settings==2.0.3
# Utilities
python-slugify==8.0.1
+Markdown==3.5.1
# Security
slowapi==0.1.9
\ No newline at end of file
diff --git a/app/routers/admin_roles.py b/app/routers/admin_roles.py
new file mode 100644
index 0000000..957b869
--- /dev/null
+++ b/app/routers/admin_roles.py
@@ -0,0 +1,71 @@
+from fastapi import APIRouter, Depends, Request, Form, HTTPException
+from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
+import json
+
+from deps import current_user, admin_required
+from services.config_loader import ConfigService
+
+router = APIRouter()
+
+@router.get("/config", response_class=HTMLResponse)
+async def config_editor(req: Request, user = Depends(admin_required)):
+ """
+ Show the JSON Config Editor.
+ """
+ raw_config = ConfigService.get_raw_config()
+
+ return req.app.state.render(
+ req,
+ "admin/config_editor.html",
+ config_json=raw_config,
+ page_title="Config Editor"
+ )
+
+@router.post("/config/save")
+async def save_config(
+ req: Request,
+ config_json: str = Form(...),
+ user = Depends(admin_required)
+):
+ """
+ Save the JSON Configuration.
+ """
+ try:
+ # 1. Parse JSON to validate syntax
+ new_config = json.loads(config_json)
+
+ # 2. Logic Validation (basic)
+ if "roles" not in new_config:
+ raise ValueError("JSON must contain top-level 'roles' key.")
+
+ # 3. Save via Service (handles atomic write & backup)
+ success = ConfigService.save_config(new_config)
+
+ if success:
+ # Flash success via session if available, or return logic
+ # Assuming flash() helper is available in main scope or we add it manually to session
+ # We can reuse the flash helper logic if accessible, otherwise direct session append
+ flashes = req.session.get("_flashes", [])
+ flashes.append({"msg": "Configuration saved successfully. Backup created.", "cat": "success"})
+ req.session["_flashes"] = flashes
+
+ return RedirectResponse("/admin/config", status_code=302)
+ else:
+ raise Exception("Write operation failed.")
+
+ except json.JSONDecodeError as e:
+ error_msg = f"Invalid JSON: {e}"
+ return req.app.state.render(
+ req,
+ "admin/config_editor.html",
+ config_json=config_json,
+ error=error_msg
+ )
+ except Exception as e:
+ error_msg = f"Error saving config: {e}"
+ return req.app.state.render(
+ req,
+ "admin/config_editor.html",
+ config_json=config_json,
+ error=error_msg
+ )
diff --git a/app/routers/docs_reader.py b/app/routers/docs_reader.py
new file mode 100644
index 0000000..eea29b0
--- /dev/null
+++ b/app/routers/docs_reader.py
@@ -0,0 +1,91 @@
+import os
+import markdown
+from fastapi import APIRouter, Request, HTTPException, Depends
+from fastapi.responses import HTMLResponse
+
+from deps import current_user
+
+router = APIRouter()
+
+# Whitelist allowed files.
+# We look for them in the root directory relative to 'app' parent.
+# Since app is in /app inside docker, the root volume mount is usually at /app or mapped.
+# Code Tour Step 828 shows 'app/main.py' path analysis.
+# If workdir is /app (docker), and files are in /app (root of repo mapped to /app), then "README.md" is at "./README.md".
+
+ALLOWED_DOCS = {
+ "README.md": "Start Guide (Readme)",
+ "QUICKSTART.md": "Schnellstart",
+ "HANDBUCH.md": "Benutzerhandbuch",
+ "CODE_TOUR.md": "Code Tour (Dev)",
+ "ARCHITECTURE_ROLES_GROUPS.md": "Architektur",
+ "DIARY_RAG_README.md": "Tagebuch & RAG Info",
+ "CrumbTech.md": "Technische Details",
+ "QDRANT_ACCESS.md": "Vektor DB Access",
+ "deploy_security_fixes.sh": "Security Script (Source)" # Maybe viewing scripts is cool too? Let's stick to MD for now.
+}
+
+@router.get("/docs", response_class=HTMLResponse)
+async def list_docs(req: Request):
+ """
+ List available documentation files.
+ """
+ # Check which exist
+ available = []
+
+ # We use /docs_root/ inside the container (see Dockerfile)
+ # Fallback to "." if running locally without container
+ if os.path.exists("/docs_root"):
+ base_path = "/docs_root"
+ else:
+ base_path = "."
+
+ for filename, title in ALLOWED_DOCS.items():
+ if os.path.exists(os.path.join(base_path, filename)):
+ available.append({"name": title, "file": filename})
+
+ return req.app.state.render(
+ req,
+ "pages/docs_index.html",
+ docs=available,
+ page_title="Dokumentation"
+ )
+
+@router.get("/docs/{filename}", response_class=HTMLResponse)
+async def view_doc(req: Request, filename: str):
+ """
+ Render a specific markdown file.
+ """
+ if filename not in ALLOWED_DOCS:
+ raise HTTPException(404, "File not found or not allowed.")
+
+ base_path = "."
+ if os.path.exists("/docs_root"):
+ base_path = "/docs_root"
+
+ file_path = os.path.join(base_path, filename)
+
+ if not os.path.exists(file_path):
+ raise HTTPException(404, "File not on server.")
+
+ try:
+ with open(file_path, "r", encoding="utf-8") as f:
+ content = f.read()
+
+ # Convert Markdown to HTML
+ # Extensions for better rendering: tables, fenced_code
+ html_content = markdown.markdown(
+ content,
+ extensions=['tables', 'fenced_code', 'nl2br']
+ )
+
+ return req.app.state.render(
+ req,
+ "pages/doc_viewer.html",
+ doc_title=ALLOWED_DOCS[filename],
+ doc_content=html_content,
+ filename=filename
+ )
+
+ except Exception as e:
+ raise HTTPException(500, f"Error rendering document: {e}")
diff --git a/app/routers/pulse.py b/app/routers/pulse.py
new file mode 100644
index 0000000..967ea18
--- /dev/null
+++ b/app/routers/pulse.py
@@ -0,0 +1,164 @@
+from fastapi import APIRouter, Depends, Request, HTTPException
+from fastapi.responses import HTMLResponse
+from pymysql.cursors import DictCursor
+import json
+from collections import Counter
+from typing import List, Dict, Any, Optional
+
+from deps import get_db, current_user
+from services.config_loader import ConfigLoader
+
+router = APIRouter()
+
+@router.get("/crumbforest/pulse", response_class=HTMLResponse)
+async def pulse_dashboard(req: Request, tag: Optional[str] = None, user = Depends(current_user)):
+ """
+ Show the Pulse (Blog) dashboard with tag cloud.
+ """
+ # 1. Auth & Config
+ if not user:
+ # Public access allowed? User said "Neuigkeiten...".
+ # But usually Crumbforest is internal.
+ # Let's assume login required for now to access specific group templates.
+ lang = req.query_params.get("lang", "de")
+ return req.app.state.render(req, "pages/login.html", error="Login required for Pulse")
+ # Actually Redirect is better
+ # return RedirectResponse(f"/{lang}/login", status_code=302)
+
+ user_group = user.get('user_group', 'demo')
+ config = ConfigLoader.load_config()
+ group_config = config.get('groups', {}).get(user_group, {})
+ lang = req.query_params.get("lang") or req.session.get("lang") or "de"
+ req.session["lang"] = lang
+
+ # 2. Fetch Posts
+ conn = get_db()
+ try:
+ with conn.cursor() as cur:
+ # Fetch all published posts
+ base_query = """
+ SELECT id, title, slug, excerpt, tags, author, created_at
+ FROM posts
+ WHERE is_published = 1
+ """
+ cur.execute(base_query)
+ posts_raw = cur.fetchall()
+ finally:
+ conn.close()
+
+ # 3. Process Tags & Filter
+ posts = []
+ all_tags = []
+
+ for p in posts_raw:
+ # Parse JSON tags
+ if isinstance(p['tags'], str):
+ p['tags'] = json.loads(p['tags'])
+ elif p['tags'] is None:
+ p['tags'] = []
+
+ all_tags.extend(p['tags'])
+
+ # Filter (if tag selected)
+ if tag and tag not in p['tags']:
+ continue
+
+ posts.append(p)
+
+ # 4. Build Tag Cloud
+ # Count tags
+ tag_counts = Counter(all_tags)
+ # Convert to list of dicts for template: [{'name': 'RAG', 'count': 5, 'size': '...'}, ...]
+ # Simple sizing logic: 1..10
+ tag_cloud = []
+ if tag_counts:
+ max_count = max(tag_counts.values())
+ min_count = min(tag_counts.values())
+ delta = max_count - min_count or 1
+
+ for t_name, count in tag_counts.items():
+ # Linear scaling 1.0 to 2.0em
+ size = 0.8 + (1.2 * (count - min_count) / delta)
+ tag_cloud.append({
+ 'name': t_name,
+ 'count': count,
+ 'size': f"{size:.1f}em",
+ 'active': t_name == tag
+ })
+
+ # Sort cloud alphabetically
+ tag_cloud.sort(key=lambda x: x['name'])
+
+ return req.app.state.render(
+ req,
+ "crumbforest/blog.html",
+ posts=posts,
+ tag_cloud=tag_cloud,
+ current_tag=tag,
+ user_group=user_group,
+ group_config=group_config,
+ lang=lang
+ )
+
+
+@router.get("/crumbforest/pulse/{slug}", response_class=HTMLResponse)
+async def pulse_post(req: Request, slug: str, user = Depends(current_user)):
+ """
+ Show a single blog post.
+ """
+ if not user:
+ # Redirect
+ lang = req.query_params.get("lang", "de")
+ return req.app.state.render(req, "pages/login.html") # Simplified
+
+ user_group = user.get('user_group', 'demo')
+ config = ConfigLoader.load_config()
+ group_config = config.get('groups', {}).get(user_group, {})
+ lang = req.query_params.get("lang") or req.session.get("lang") or "de"
+
+ conn = get_db()
+ try:
+ with conn.cursor() as cur:
+ cur.execute(
+ """
+ SELECT id, title, slug, excerpt, tags, author, body_md, created_at, updated_at
+ FROM posts
+ WHERE slug=%s AND is_published=1
+ """,
+ (slug,)
+ )
+ post = cur.fetchone()
+ finally:
+ conn.close()
+
+ if not post:
+ raise HTTPException(status_code=404, detail="Post not found")
+
+ # Parse tags
+ if isinstance(post['tags'], str):
+ post['tags'] = json.loads(post['tags'])
+ elif post['tags'] is None:
+ post['tags'] = []
+
+ # Markdown rendering handled in template via filter?
+ # Or pre-render here.
+ # Usually we pass markdown and let template render it if using a JS lib,
+ # OR we use `markdown` lib here.
+ # checking existing templates... we likely use `markdown` filter or lib.
+ # Code Tour mentioned "body_md".
+ # I will assume we render MD in Python for safety/consistency?
+ # Or use a client-side renderer (Zero-MD, Marked)?
+ # Looking at `diary_rag.py` / `rag_service.py` -> we just store MD.
+ # If I verify `app/templates/`?
+ # I'll use `markdown` python lib if available.
+ # Assume it is available or basic text for now.
+ import markdown
+ post['html_content'] = markdown.markdown(post['body_md'])
+
+ return req.app.state.render(
+ req,
+ "crumbforest/post.html",
+ post=post,
+ group_config=group_config,
+ lang=lang
+ )
diff --git a/app/services/config_loader.py b/app/services/config_loader.py
index c254fc9..2989e5d 100644
--- a/app/services/config_loader.py
+++ b/app/services/config_loader.py
@@ -1,40 +1,99 @@
import json
import os
+import shutil
from typing import Dict, Any, Optional
+from datetime import datetime
-CONFIG_PATH = "crumbforest_config.json"
+CONFIG_FILENAME = "crumbforest_config.json"
-class ConfigLoader:
+class ConfigService:
_config: Optional[Dict[str, Any]] = None
+ _config_path: Optional[str] = None
+
+ @classmethod
+ def _resolve_path(cls) -> str:
+ """Resolve the absolute path to the config file."""
+ if cls._config_path:
+ return cls._config_path
+
+ # Try finding it
+ paths_to_try = [
+ # 1. In root (for docker -v mapped volume)
+ os.path.abspath(CONFIG_FILENAME),
+ # 2. One level up (if running from app/)
+ os.path.abspath(os.path.join("..", CONFIG_FILENAME)),
+ # 3. Explicit /config dir
+ "/config/crumbforest_config.json"
+ ]
+
+ for p in paths_to_try:
+ if os.path.exists(p):
+ cls._config_path = p
+ return p
+
+ # Default fallback (might create new file later)
+ return os.path.abspath(CONFIG_FILENAME)
@classmethod
def load_config(cls, force_reload: bool = False) -> Dict[str, Any]:
if cls._config is None or force_reload:
+ path = cls._resolve_path()
try:
- paths_to_try = [CONFIG_PATH, os.path.join("..", CONFIG_PATH), "/config/crumbforest_config.json"]
- found = False
- for path in paths_to_try:
- if os.path.exists(path):
- try:
- with open(path, 'r', encoding='utf-8') as f:
- cls._config = json.load(f)
- found = True
- print(f"Loaded config from {path}")
- break
- except Exception as e:
- print(f"Failed to load config from {path}: {e}")
- continue
-
- if not found:
- print(f"Warning: {CONFIG_PATH} not found in {paths_to_try}")
- cls._config = {"roles": {}, "groups": {}, "theme_variants": {}}
-
+ with open(path, 'r', encoding='utf-8') as f:
+ cls._config = json.load(f)
+ # print(f"Loaded config from {path}")
except Exception as e:
- print(f"Error loading config: {e}")
+ print(f"Error loading config from {path}: {e}")
cls._config = {"roles": {}, "groups": {}, "theme_variants": {}}
return cls._config
+ @classmethod
+ def save_config(cls, new_config: Dict[str, Any]) -> bool:
+ """
+ Save new configuration atomically with backup.
+ """
+ path = cls._resolve_path()
+ backup_path = f"{path}.bak"
+ temp_path = f"{path}.tmp"
+
+ try:
+ # 1. Validate JSON first (implicit by type typing, but meaningful check: must have 'roles')
+ if 'roles' not in new_config:
+ raise ValueError("Config must contain 'roles' key")
+
+ # 2. Create Backup
+ if os.path.exists(path):
+ shutil.copy2(path, backup_path)
+
+ # 3. Write to Temp File
+ with open(temp_path, 'w', encoding='utf-8') as f:
+ json.dump(new_config, f, indent=4, ensure_ascii=False)
+
+ # 4. Atomic Rename
+ os.replace(temp_path, path)
+
+ # 5. Update Memory
+ cls._config = new_config
+ return True
+
+ except Exception as e:
+ print(f"FAILED to save config: {e}")
+ if os.path.exists(temp_path):
+ os.remove(temp_path)
+ return False
+
+ @classmethod
+ def get_raw_config(cls) -> str:
+ """Get config as raw JSON string."""
+ path = cls._resolve_path()
+ try:
+ with open(path, 'r', encoding='utf-8') as f:
+ return f.read()
+ except:
+ return "{}"
+
+ # --- Accessors ---
@classmethod
def get_role(cls, role_id: str) -> Optional[Dict[str, Any]]:
config = cls.load_config()
@@ -50,7 +109,5 @@ class ConfigLoader:
config = cls.load_config()
return config.get('theme_variants', {}).get(theme_id)
- @classmethod
- def get_all_roles(cls) -> Dict[str, Any]:
- config = cls.load_config()
- return config.get('roles', {})
+# Alias for backward compatibility if needed, though we should update imports
+ConfigLoader = ConfigService
diff --git a/app/templates/admin/config_editor.html b/app/templates/admin/config_editor.html
new file mode 100644
index 0000000..7f201ec
--- /dev/null
+++ b/app/templates/admin/config_editor.html
@@ -0,0 +1,82 @@
+{% extends "base.html" %}
+
+{% block title %}Admin Config Editor{% endblock %}
+
+{% block content %}
+
+
+
+
+
🛠️ Config Editor
+
System Configuration (JSON). Be careful!
+
+
+ {% if error %}
+
+ ⚠️ Error
+
{{ error }}
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/app/templates/crumbforest/blog.html b/app/templates/crumbforest/blog.html
new file mode 100644
index 0000000..6b0b883
--- /dev/null
+++ b/app/templates/crumbforest/blog.html
@@ -0,0 +1,129 @@
+{% extends group_config.template_base or "base_demo.html" %}
+
+{% block title %}Crumbforest Pulse{% endblock %}
+
+{% block content %}
+
+
+