""" 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])([^>]*)>(.*?)', 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}' 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/') 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/') 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/') @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)