Initial commit - Help Service for Coolify
This commit is contained in:
551
app.py
Normal file
551
app.py
Normal file
@@ -0,0 +1,551 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user