Files
help-service/app.py

552 lines
18 KiB
Python

"""
Kurs-Booking Help Service
Python Flask App with Bootstrap GUI for plugin documentation.
Supports Markdown files with YAML frontmatter for easy content editing.
Hot-reload enabled - no container restart needed for content changes.
@package KursBooking
@since 1.4.0
"""
import os
import glob
from datetime import datetime
from functools import wraps
from flask import Flask, render_template, jsonify, request, Response
from flask_cors import CORS
import re
import yaml
import markdown
import frontmatter
app = Flask(__name__)
CORS(app)
# Internal area credentials (can be overridden via environment variables)
INTERN_USER = os.environ.get('INTERN_USER', 'admin')
INTERN_PASS = os.environ.get('INTERN_PASS', 'kurs2024!')
def check_intern_auth(username: str, password: str) -> bool:
"""Verify internal area credentials."""
return username == INTERN_USER and password == INTERN_PASS
def require_intern_auth(f):
"""Decorator for password-protected internal routes."""
@wraps(f)
def decorated(*args, **kwargs):
auth = request.authorization
if not auth or not check_intern_auth(auth.username, auth.password):
return Response(
'Zugang zum internen Bereich erfordert Anmeldung.',
401,
{'WWW-Authenticate': 'Basic realm="Interner Bereich"'}
)
return f(*args, **kwargs)
return decorated
# Content directory (supports DOCS_PATH env var for Coolify bind mounts)
CONTENT_DIR = os.environ.get('DOCS_PATH', os.path.join(os.path.dirname(__file__), 'content'))
# Cache for content (with timestamp for hot-reload)
_content_cache = {
'data': None,
'timestamp': 0
}
def generate_toc_from_html(html_content: str) -> tuple[str, list[dict]]:
"""
Extract headings from HTML and generate TOC.
Also adds IDs to headings if missing.
Returns:
tuple: (modified_html, toc_list)
"""
toc = []
heading_pattern = re.compile(r'<(h[2-3])([^>]*)>(.*?)</\1>', re.IGNORECASE | re.DOTALL)
def add_id_to_heading(match):
tag = match.group(1)
attrs = match.group(2)
text = match.group(3)
# Strip HTML tags from text for ID generation
clean_text = re.sub(r'<[^>]+>', '', text)
# Check if ID already exists
id_match = re.search(r'id=["\']([^"\']+)["\']', attrs)
if id_match:
heading_id = id_match.group(1)
else:
# Generate ID from text
heading_id = re.sub(r'[^\w\s-]', '', clean_text.lower())
heading_id = re.sub(r'[\s]+', '-', heading_id.strip())
heading_id = heading_id[:50] # Limit length
attrs = f' id="{heading_id}"{attrs}'
# Add to TOC
level = 2 if tag.lower() == 'h2' else 3
toc.append({
'id': heading_id,
'title': clean_text.strip(),
'level': level
})
return f'<{tag}{attrs}>{text}</{tag}>'
modified_html = heading_pattern.sub(add_id_to_heading, html_content)
return modified_html, toc
def get_content_mtime():
"""Get the latest modification time of content files (including subfolders)."""
mtime = 0
# Check config file
config_file = os.path.join(CONTENT_DIR, '_config.yaml')
if os.path.exists(config_file):
mtime = max(mtime, os.path.getmtime(config_file))
# Check all markdown files recursively (including subfolders)
for md_file in glob.glob(os.path.join(CONTENT_DIR, '**/*.md'), recursive=True):
mtime = max(mtime, os.path.getmtime(md_file))
return mtime
def load_help_content():
"""
Load help content from Markdown files with hot-reload support.
Structure:
- content/_config.yaml: Navigation/sections configuration
- content/*.md: Topic files with YAML frontmatter
- content/topic/*.md: Subpage files (e.g., sevdesk/rechnungsnummern.md)
"""
global _content_cache
# Check if cache is valid (hot-reload)
current_mtime = get_content_mtime()
if _content_cache['data'] and _content_cache['timestamp'] >= current_mtime:
return _content_cache['data']
# Load configuration
config_file = os.path.join(CONTENT_DIR, '_config.yaml')
config = {}
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f) or {}
# Load all markdown topics recursively (including subfolders)
topics = {}
md_extensions = ['tables', 'fenced_code', 'codehilite', 'toc', 'attr_list']
for md_file in glob.glob(os.path.join(CONTENT_DIR, '**/*.md'), recursive=True):
try:
post = frontmatter.load(md_file)
# Generate path-based ID: sevdesk/rechnungsnummern.md -> sevdesk/rechnungsnummern
rel_path = os.path.relpath(md_file, CONTENT_DIR)
path_id = os.path.splitext(rel_path)[0].replace(os.sep, '/')
# Use frontmatter id if provided, otherwise use path-based id
topic_id = post.get('id', path_id)
# Determine parent topic (sevdesk/rechnungsnummern -> sevdesk)
parent_id = os.path.dirname(path_id) if '/' in path_id else None
# Convert markdown to HTML
html_content = markdown.markdown(
post.content,
extensions=md_extensions,
output_format='html5'
)
# Auto-generate TOC from headings (or use manual if provided)
manual_toc = post.get('toc', [])
if manual_toc:
toc = manual_toc
else:
html_content, toc = generate_toc_from_html(html_content)
topics[topic_id] = {
'id': topic_id,
'path_id': path_id,
'parent': parent_id,
'children': [], # Will be populated after all topics loaded
'title': post.get('title', topic_id.split('/')[-1].replace('-', ' ').title()),
'icon': post.get('icon', 'file-text'),
'description': post.get('description', ''),
'section': post.get('section', 'Allgemein'),
'tags': post.get('tags', []),
'related': post.get('related', []),
'tips': post.get('tips', []),
'toc': toc,
'content': html_content,
'order': post.get('order', 999)
}
except Exception as e:
app.logger.error(f"Error loading {md_file}: {e}")
# Build parent-child relationships
for topic in topics.values():
parent_id = topic.get('parent')
if parent_id and parent_id in topics:
topics[parent_id]['children'].append(topic['id'])
# Build sections from config or auto-generate
sections = config.get('sections', [])
if sections:
# Use config-defined sections
for section in sections:
section_topics = []
for topic_id in section.get('topics', []):
if topic_id in topics:
section_topics.append(topics[topic_id])
section['topics'] = sorted(section_topics, key=lambda x: x.get('order', 999))
else:
# Auto-generate sections from topic metadata
section_map = {}
for topic in topics.values():
section_name = topic.get('section', 'Allgemein')
if section_name not in section_map:
section_map[section_name] = {
'title': section_name,
'icon': 'folder',
'topics': []
}
section_map[section_name]['topics'].append(topic)
sections = list(section_map.values())
for section in sections:
section['topics'] = sorted(section['topics'], key=lambda x: x.get('order', 999))
result = {
'sections': sections,
'all_topics': topics,
'last_updated': datetime.now().isoformat()
}
# Update cache
_content_cache['data'] = result
_content_cache['timestamp'] = current_mtime
return result
def find_topic(topic_id):
"""Find a topic by ID."""
content = load_help_content()
return content.get('all_topics', {}).get(topic_id)
@app.route('/health')
def health():
"""Health check endpoint for Coolify/Docker."""
return jsonify({'status': 'healthy', 'service': 'help-service'})
@app.route('/')
def index():
"""Main help page."""
content = load_help_content()
return render_template('index.html', content=content)
@app.route('/topic/<path:topic_id>')
def topic(topic_id):
"""Single topic page (supports subpages like /topic/sevdesk/rechnungsnummern)."""
content = load_help_content()
topic_data = find_topic(topic_id)
if not topic_data:
return render_template('404.html', content=content, topic_id=topic_id), 404
# Get parent topic for breadcrumb
parent_topic = None
if topic_data.get('parent'):
parent_topic = find_topic(topic_data['parent'])
# Get child topics
child_topics = []
for child_id in topic_data.get('children', []):
child = find_topic(child_id)
if child:
child_topics.append(child)
# Sort children by order
child_topics = sorted(child_topics, key=lambda x: x.get('order', 999))
# Get related topics
related_topics = []
for related_id in topic_data.get('related', []):
related = find_topic(related_id)
if related:
related_topics.append(related)
return render_template(
'topic.html',
topic=topic_data,
parent_topic=parent_topic,
child_topics=child_topics,
related_topics=related_topics,
content=content
)
@app.route('/search')
def search():
"""Search help content."""
query = request.args.get('q', '').lower().strip()
content = load_help_content()
results = []
if query and len(query) >= 2:
for topic in content.get('all_topics', {}).values():
score = 0
# Title match (highest priority)
if query in topic.get('title', '').lower():
score += 100
# Tag match
for tag in topic.get('tags', []):
if query in tag.lower():
score += 50
# Description match
if query in topic.get('description', '').lower():
score += 30
# Content match
if query in topic.get('content', '').lower():
score += 10
if score > 0:
results.append({
'id': topic.get('id'),
'title': topic.get('title'),
'description': topic.get('description', ''),
'section': topic.get('section'),
'score': score
})
# Sort by score
results = sorted(results, key=lambda x: x['score'], reverse=True)
return render_template('search.html', query=query, results=results, content=content)
@app.route('/api/content')
def api_content():
"""API endpoint for help content."""
content = load_help_content()
return jsonify(content)
@app.route('/api/topics')
def api_topics():
"""API endpoint for all topics."""
content = load_help_content()
return jsonify(list(content.get('all_topics', {}).values()))
@app.route('/api/topic/<path:topic_id>')
def api_topic(topic_id):
"""API endpoint for single topic (supports subpages)."""
topic = find_topic(topic_id)
if not topic:
return jsonify({'error': 'Topic not found'}), 404
return jsonify(topic)
@app.route('/api/search')
def api_search():
"""API endpoint for search."""
query = request.args.get('q', '').lower()
content = load_help_content()
results = []
if query:
for topic in content.get('all_topics', {}).values():
if (query in topic.get('title', '').lower() or
query in topic.get('content', '').lower() or
any(query in tag.lower() for tag in topic.get('tags', []))):
results.append({
'id': topic.get('id'),
'title': topic.get('title'),
'section': topic.get('section')
})
return jsonify({'query': query, 'results': results})
@app.route('/api/reload')
def api_reload():
"""Force reload content cache."""
global _content_cache
_content_cache = {'data': None, 'timestamp': 0}
content = load_help_content()
return jsonify({
'status': 'reloaded',
'topics_count': len(content.get('all_topics', {})),
'sections_count': len(content.get('sections', []))
})
# ============================================
# INTERNAL AREA (Password Protected)
# ============================================
# Internal content directory (relative to CONTENT_DIR parent)
INTERN_CONTENT_DIR = os.path.join(os.path.dirname(CONTENT_DIR), 'content-intern')
# Cache for internal content
_intern_cache = {
'data': None,
'timestamp': 0
}
def get_intern_mtime():
"""Get the latest modification time of internal content files."""
mtime = 0
config_file = os.path.join(INTERN_CONTENT_DIR, '_config.yaml')
if os.path.exists(config_file):
mtime = max(mtime, os.path.getmtime(config_file))
for md_file in glob.glob(os.path.join(INTERN_CONTENT_DIR, '**/*.md'), recursive=True):
mtime = max(mtime, os.path.getmtime(md_file))
return mtime
def load_intern_content():
"""Load internal help content with hot-reload support."""
global _intern_cache
# Check if directory exists
if not os.path.exists(INTERN_CONTENT_DIR):
return {'sections': [], 'all_topics': {}, 'last_updated': None}
# Check if cache is valid
current_mtime = get_intern_mtime()
if _intern_cache['data'] and _intern_cache['timestamp'] >= current_mtime:
return _intern_cache['data']
# Load configuration
config_file = os.path.join(INTERN_CONTENT_DIR, '_config.yaml')
config = {}
if os.path.exists(config_file):
with open(config_file, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f) or {}
# Load markdown topics
topics = {}
md_extensions = ['tables', 'fenced_code', 'codehilite', 'toc', 'attr_list']
for md_file in glob.glob(os.path.join(INTERN_CONTENT_DIR, '**/*.md'), recursive=True):
try:
post = frontmatter.load(md_file)
rel_path = os.path.relpath(md_file, INTERN_CONTENT_DIR)
path_id = os.path.splitext(rel_path)[0].replace(os.sep, '/')
topic_id = post.get('id', path_id)
parent_id = os.path.dirname(path_id) if '/' in path_id else None
html_content = markdown.markdown(
post.content,
extensions=md_extensions,
output_format='html5'
)
html_content, toc = generate_toc_from_html(html_content)
topics[topic_id] = {
'id': topic_id,
'path_id': path_id,
'parent': parent_id,
'children': [],
'title': post.get('title', topic_id.split('/')[-1].replace('-', ' ').title()),
'icon': post.get('icon', 'file-lock'),
'description': post.get('description', ''),
'toc': toc,
'content': html_content,
'order': post.get('order', 999)
}
except Exception as e:
app.logger.error(f"Error loading intern {md_file}: {e}")
# Build parent-child relationships
for topic in topics.values():
parent_id = topic.get('parent')
if parent_id and parent_id in topics:
topics[parent_id]['children'].append(topic['id'])
# Build sections from config
sections = config.get('sections', [])
if sections:
for section in sections:
section_topics = []
for topic_id in section.get('topics', []):
if topic_id in topics:
section_topics.append(topics[topic_id])
section['topics'] = sorted(section_topics, key=lambda x: x.get('order', 999))
result = {
'sections': sections,
'all_topics': topics,
'last_updated': datetime.now().isoformat()
}
_intern_cache['data'] = result
_intern_cache['timestamp'] = current_mtime
return result
def find_intern_topic(topic_id):
"""Find an internal topic by ID."""
content = load_intern_content()
return content.get('all_topics', {}).get(topic_id)
@app.route('/intern/')
@require_intern_auth
def intern_index():
"""Internal area main page."""
content = load_intern_content()
return render_template('intern/index.html', content=content)
@app.route('/intern/topic/<path:topic_id>')
@require_intern_auth
def intern_topic(topic_id):
"""Internal topic page."""
content = load_intern_content()
topic_data = find_intern_topic(topic_id)
if not topic_data:
return render_template('intern/404.html', content=content, topic_id=topic_id), 404
parent_topic = None
if topic_data.get('parent'):
parent_topic = find_intern_topic(topic_data['parent'])
child_topics = []
for child_id in topic_data.get('children', []):
child = find_intern_topic(child_id)
if child:
child_topics.append(child)
child_topics = sorted(child_topics, key=lambda x: x.get('order', 999))
return render_template(
'intern/topic.html',
topic=topic_data,
parent_topic=parent_topic,
child_topics=child_topics,
content=content
)
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True)