552 lines
18 KiB
Python
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)
|