commit 858003cb0bfd90c39a311b07abf69b7de539ee9b Author: rattatwinko Date: Sun Nov 16 18:01:30 2025 +0100 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cf9638 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Flask +instance/ +.webassets-cache + +# Cache +cache.db +*.db + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment variables +.env +.env.local + diff --git a/README.md b/README.md new file mode 100644 index 0000000..55819a8 --- /dev/null +++ b/README.md @@ -0,0 +1,4 @@ +# PyPages + +A _currently_ experimental PyDoc WebPage, which gets documentation from Python itself! + diff --git a/app.py b/app.py new file mode 100644 index 0000000..a3f816d --- /dev/null +++ b/app.py @@ -0,0 +1,23 @@ +""" +Main Flask application entry point. +""" +import os +from flask import Flask, send_from_directory +from routes.api import api_bp + +# Get the directory where this script is located +basedir = os.path.abspath(os.path.dirname(__file__)) + +app = Flask(__name__, static_folder='static', static_url_path='/static') + +# Register API blueprint (FastAPI-compatible route structure) +app.register_blueprint(api_bp) + +# Serve index.html at root +@app.route('/') +def index(): + return send_from_directory(os.path.join(basedir, 'static'), 'index.html') + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=5000) + diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..1594f98 --- /dev/null +++ b/modules/__init__.py @@ -0,0 +1,2 @@ +"""Modules package for pydoc translation service.""" + diff --git a/modules/cache.py b/modules/cache.py new file mode 100644 index 0000000..f63cb47 --- /dev/null +++ b/modules/cache.py @@ -0,0 +1,252 @@ +""" +Caching module with extensible backend support. + +To add a new cache backend: +1. Create a class that inherits from CacheBackend +2. Implement get(), set(), and clear() methods +3. Register it in the CacheService class +""" +from abc import ABC, abstractmethod +from typing import Optional +import json +import hashlib +import sqlite3 +import os +from datetime import datetime, timedelta + + +class CacheBackend(ABC): + """Abstract base class for cache backends.""" + + @abstractmethod + def get(self, key: str) -> Optional[str]: + """Get value from cache.""" + pass + + @abstractmethod + def set(self, key: str, value: str, ttl: Optional[int] = None) -> bool: + """Set value in cache with optional TTL (time to live in seconds).""" + pass + + @abstractmethod + def clear(self) -> bool: + """Clear all cache entries.""" + pass + + +class InMemoryCache(CacheBackend): + """ + Simple in-memory cache backend. + + Stores data in a dictionary. Data is lost on application restart. + """ + + def __init__(self): + self._cache = {} + self._ttl = {} # Store expiration times + + def get(self, key: str) -> Optional[str]: + """Get value from in-memory cache.""" + if key in self._cache: + # Check TTL + if key in self._ttl: + if datetime.now() > self._ttl[key]: + del self._cache[key] + del self._ttl[key] + return None + return self._cache[key] + return None + + def set(self, key: str, value: str, ttl: Optional[int] = None) -> bool: + """Set value in in-memory cache.""" + self._cache[key] = value + if ttl: + self._ttl[key] = datetime.now() + timedelta(seconds=ttl) + return True + + def clear(self) -> bool: + """Clear all cache entries.""" + self._cache.clear() + self._ttl.clear() + return True + + +class SQLiteCache(CacheBackend): + """ + SQLite-based cache backend. + + Persists data to a SQLite database file. + """ + + def __init__(self, db_path: str = "cache.db"): + self.db_path = db_path + self._init_db() + + def _init_db(self): + """Initialize the SQLite database.""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + expires_at REAL, + created_at REAL NOT NULL + ) + ''') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_expires ON cache(expires_at)') + conn.commit() + conn.close() + + def _cleanup_expired(self): + """Remove expired entries.""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute('DELETE FROM cache WHERE expires_at IS NOT NULL AND expires_at < ?', + (datetime.now().timestamp(),)) + conn.commit() + conn.close() + + def get(self, key: str) -> Optional[str]: + """Get value from SQLite cache.""" + self._cleanup_expired() + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute('SELECT value FROM cache WHERE key = ?', (key,)) + result = cursor.fetchone() + conn.close() + return result[0] if result else None + + def set(self, key: str, value: str, ttl: Optional[int] = None) -> bool: + """Set value in SQLite cache.""" + expires_at = None + if ttl: + expires_at = (datetime.now() + timedelta(seconds=ttl)).timestamp() + + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute(''' + INSERT OR REPLACE INTO cache (key, value, expires_at, created_at) + VALUES (?, ?, ?, ?) + ''', (key, value, expires_at, datetime.now().timestamp())) + conn.commit() + conn.close() + return True + + def clear(self) -> bool: + """Clear all cache entries.""" + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute('DELETE FROM cache') + conn.commit() + conn.close() + return True + + +class RedisCache(CacheBackend): + """ + Redis-based cache backend. + + Requires redis library and REDIS_URL environment variable. + """ + + def __init__(self, redis_url: Optional[str] = None): + try: + import redis + self.redis_url = redis_url or os.getenv('REDIS_URL', 'redis://localhost:6379/0') + self.client = redis.from_url(self.redis_url, decode_responses=True) + # Test connection + self.client.ping() + except ImportError: + raise ImportError("redis library not installed. Install with: pip install redis") + except Exception as e: + raise ConnectionError(f"Failed to connect to Redis: {e}") + + def get(self, key: str) -> Optional[str]: + """Get value from Redis cache.""" + try: + return self.client.get(key) + except Exception as e: + print(f"Redis get error: {e}") + return None + + def set(self, key: str, value: str, ttl: Optional[int] = None) -> bool: + """Set value in Redis cache.""" + try: + if ttl: + self.client.setex(key, ttl, value) + else: + self.client.set(key, value) + return True + except Exception as e: + print(f"Redis set error: {e}") + return False + + def clear(self) -> bool: + """Clear all cache entries.""" + try: + self.client.flushdb() + return True + except Exception as e: + print(f"Redis clear error: {e}") + return False + + +class CacheService: + """ + Cache service that manages cache backends. + + Provides a unified interface for caching translated documentation. + """ + + def __init__(self, backend: Optional[str] = None, **kwargs): + """ + Initialize cache service. + + Args: + backend: Backend name ('memory', 'sqlite', 'redis'). Defaults to 'sqlite'. + **kwargs: Additional arguments for backend initialization. + """ + self.backend_name = backend or os.getenv('CACHE_BACKEND', 'sqlite') + self.backend = self._create_backend(self.backend_name, **kwargs) + + def _create_backend(self, backend_name: str, **kwargs) -> CacheBackend: + """Create a cache backend instance.""" + backends = { + 'memory': InMemoryCache, + 'sqlite': SQLiteCache, + 'redis': RedisCache, + } + + backend_class = backends.get(backend_name.lower()) + if not backend_class: + raise ValueError(f"Unknown cache backend: {backend_name}") + + return backend_class(**kwargs) + + def _make_key(self, object_name: str, target_lang: str) -> str: + """Generate a cache key from object name and target language.""" + key_string = f"{object_name}:{target_lang}" + return hashlib.md5(key_string.encode()).hexdigest() + + def get(self, object_name: str, target_lang: str) -> Optional[dict]: + """Get cached translation.""" + key = self._make_key(object_name, target_lang) + value = self.backend.get(key) + if value: + try: + return json.loads(value) + except json.JSONDecodeError: + return None + return None + + def set(self, object_name: str, target_lang: str, data: dict, ttl: Optional[int] = None) -> bool: + """Cache a translation.""" + key = self._make_key(object_name, target_lang) + value = json.dumps(data) + return self.backend.set(key, value, ttl) + + def clear(self) -> bool: + """Clear all cache entries.""" + return self.backend.clear() + diff --git a/modules/course_scraper.py b/modules/course_scraper.py new file mode 100644 index 0000000..ac5cd5d --- /dev/null +++ b/modules/course_scraper.py @@ -0,0 +1,228 @@ +""" +Module for scraping and organizing Python course content. +""" +import requests +from bs4 import BeautifulSoup +from typing import Dict, List, Optional +import re + + +class CourseScraper: + """Scrapes Python course content from external sources.""" + + @staticmethod + def scrape_course_content(url: str = None) -> Dict[str, any]: + """ + Scrape course content from URLs. + + Args: + url: Base URL to scrape (optional, will use default if not provided) + + Returns: + Dictionary with course structure and content + """ + course_data = { + 'title': 'Python Kurs - Gymnasium Hartberg', + 'sections': [], + 'navigation': [] + } + + # List of course pages to scrape + course_pages = [ + ('https://benschi11.github.io/python/class5.html', '5. Klasse'), + ('https://benschi11.github.io/python/', 'Overview') + ] + + for page_url, section_title in course_pages: + try: + response = requests.get(page_url, timeout=10, headers={ + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }) + response.raise_for_status() + soup = BeautifulSoup(response.content, 'html.parser') + + # Find main content + main_content = soup.find('main', {'id': 'content'}) or soup.find('main') or soup.find('body') + + if not main_content: + continue + + # Extract markdown content from main content + markdown_content = [] + current_section = None + current_subsection = None + section_id = None + + # Process all elements in order to build markdown + for elem in main_content.find_all(['h1', 'h2', 'h3', 'h4', 'p', 'ul', 'li', 'div', 'pre', 'code']): + tag_name = elem.name + + # Handle headings + if tag_name in ['h1', 'h2']: + # Save previous section + if current_section and markdown_content: + current_section['markdown'] = '\n\n'.join(markdown_content) + course_data['sections'].append(current_section) + course_data['navigation'].append({ + 'title': current_section['title'], + 'level': current_section['level'], + 'id': section_id + }) + + # Start new section + text = elem.get_text().strip() + if text and not text.startswith('Python Kurs'): + section_id = CourseScraper._slugify(text) + current_section = { + 'title': text, + 'level': int(tag_name[1]), + 'markdown': '', + 'id': section_id + } + markdown_content = [f"{'#' * int(tag_name[1])} {text}"] + current_subsection = None + + elif tag_name == 'h3' and current_section: + # Subsection heading + text = elem.get_text().strip() + if text: + markdown_content.append(f"\n### {text}") + current_subsection = text + + elif tag_name == 'h4' and current_section: + # Sub-subsection heading + text = elem.get_text().strip() + if text: + markdown_content.append(f"\n#### {text}") + + elif current_section and tag_name == 'p': + # Paragraph + text = elem.get_text().strip() + if text and len(text) > 5: + markdown_content.append(text) + + elif current_section and tag_name == 'ul': + # Unordered list + list_items = [] + for li in elem.find_all('li', recursive=False): + li_text = li.get_text().strip() + if li_text: + list_items.append(f"- {li_text}") + if list_items: + markdown_content.append('\n'.join(list_items)) + + elif current_section and tag_name == 'div': + # Check for code blocks + code_elem = elem.find('code', class_=lambda x: x and 'language-python' in ' '.join(x)) + if code_elem: + code_text = code_elem.get_text().strip() + if code_text: + markdown_content.append(f"```python\n{code_text}\n```") + elif 'language-python' in str(elem.get('class', [])): + code_text = elem.get_text().strip() + if code_text: + markdown_content.append(f"```python\n{code_text}\n```") + + elif current_section and tag_name == 'pre': + # Preformatted code + code_elem = elem.find('code') + if code_elem: + code_text = code_elem.get_text().strip() + if code_text: + # Check if it's Python code + lang = 'python' + if 'language-python' in str(code_elem.get('class', [])): + lang = 'python' + markdown_content.append(f"```{lang}\n{code_text}\n```") + + # Save last section + if current_section: + if markdown_content: + current_section['markdown'] = '\n\n'.join(markdown_content) + course_data['sections'].append(current_section) + course_data['navigation'].append({ + 'title': current_section['title'], + 'level': current_section['level'], + 'id': section_id + }) + + except Exception as e: + print(f"Error scraping {page_url}: {e}") + continue + + # If no content was scraped, return default + if not course_data['sections']: + return CourseScraper._get_default_course() + + return course_data + + @staticmethod + def _slugify(text: str) -> str: + """Convert text to URL-friendly slug.""" + text = text.lower() + text = re.sub(r'[^\w\s-]', '', text) + text = re.sub(r'[-\s]+', '-', text) + return text.strip('-') + + @staticmethod + def _get_default_course() -> Dict[str, any]: + """Get default course structure when scraping fails.""" + return { + 'title': 'Python Kurs - Gymnasium Hartberg', + 'sections': [ + { + 'title': '5. Klasse', + 'level': 2, + 'content': [ + 'Grundlagen der Programmierung mit Python', + 'Variablen und Datentypen', + 'Eingabe und Ausgabe', + 'Bedingte Anweisungen', + 'Schleifen', + 'Listen und Dictionaries' + ], + 'subsections': [] + }, + { + 'title': '6. Klasse', + 'level': 2, + 'content': [ + 'Funktionen', + 'Module und Pakete', + 'Dateiverarbeitung', + 'Fehlerbehandlung', + 'Objektorientierte Programmierung' + ], + 'subsections': [] + }, + { + 'title': 'Objektorientierte Programmierung', + 'level': 2, + 'content': [ + 'Klassen und Objekte', + 'Vererbung', + 'Polymorphismus', + 'Abstrakte Klassen' + ], + 'subsections': [] + }, + { + 'title': 'Grafische Oberflächen', + 'level': 2, + 'content': [ + 'Einführung in GUI-Programmierung', + 'Tkinter Grundlagen', + 'Event-Handling', + 'Layout-Management' + ], + 'subsections': [] + } + ], + 'navigation': [ + {'title': '5. Klasse', 'level': 2, 'id': '5-klasse'}, + {'title': '6. Klasse', 'level': 2, 'id': '6-klasse'}, + {'title': 'Objektorientierte Programmierung', 'level': 2, 'id': 'objektorientierte-programmierung'}, + {'title': 'Grafische Oberflächen', 'level': 2, 'id': 'grafische-oberflaechen'} + ] + } + diff --git a/modules/doc_extractor.py b/modules/doc_extractor.py new file mode 100644 index 0000000..47bb9d2 --- /dev/null +++ b/modules/doc_extractor.py @@ -0,0 +1,242 @@ +""" +Module for extracting documentation from Python objects using pydoc and inspect. +""" +import pydoc +import inspect +from typing import Optional, Dict, Any + + +class DocExtractor: + """ + Extracts documentation from Python objects. + + Supports: + - Modules + - Classes + - Functions + - Methods + - Builtins + - Any object accessible through pydoc + """ + + @staticmethod + def extract_doc(object_name: str) -> Dict[str, Any]: + """ + Extract documentation for a Python object. + + Args: + object_name: Dot-separated path to the object (e.g., 'dict.update', 'os.path', 'builtins.BaseException') + + Returns: + Dictionary containing: + - 'original': Original English documentation + - 'object_name': Name of the object + - 'object_type': Type of object (module, class, function, etc.) + - 'signature': Function/method signature if applicable + - 'error': Error message if extraction failed + """ + try: + obj = None + resolved_name = object_name + + # For builtins, resolve directly first (pydoc.resolve can be unreliable) + if object_name.startswith('builtins.'): + try: + import builtins + name = object_name.replace('builtins.', '', 1) + if hasattr(builtins, name): + obj = getattr(builtins, name) + # Verify we got the right object + obj_name = getattr(obj, '__name__', None) + if obj_name == name: + resolved_name = object_name + else: + obj = None # Wrong object, try other methods + except Exception: + pass + + # If not a builtin or builtin resolution failed, try direct import first + # This is more reliable than pydoc.resolve for standard library modules + if obj is None: + try: + parts = object_name.split('.') + if len(parts) == 1: + # Simple module name (e.g., 'asyncio') + obj = __import__(object_name) + # Verify it's actually a module + if not inspect.ismodule(obj): + obj = None + elif len(parts) > 1: + # Dotted name (e.g., 'os.path', 'collections.abc') + module_name = '.'.join(parts[:-1]) + attr_name = parts[-1] + module = __import__(module_name, fromlist=[attr_name]) + obj = getattr(module, attr_name) + resolved_name = object_name + except Exception: + pass + + # If direct import failed, try pydoc.resolve as fallback + if obj is None: + try: + resolved_obj = pydoc.resolve(object_name) + # Verify the resolved object is correct + obj = resolved_obj + except (ImportError, AttributeError, ValueError) as e: + pass + + if obj is None: + raise ValueError(f"Could not resolve object: {object_name}") + + # Verify we got the right object by checking its name and type + # This helps catch cases where pydoc.resolve returns wrong object + try: + parts = object_name.split('.') + expected_name = parts[-1] + actual_name = getattr(obj, '__name__', None) or getattr(obj, '__qualname__', None) + + # For modules, check module name + if inspect.ismodule(obj): + module_name = getattr(obj, '__name__', '') + if module_name != object_name and not module_name.endswith('.' + object_name): + # Wrong module - try direct import + try: + correct_obj = __import__(object_name) + if inspect.ismodule(correct_obj) and getattr(correct_obj, '__name__', '') == object_name: + obj = correct_obj + except Exception: + pass + # For non-modules, verify the name matches + elif actual_name and actual_name != expected_name: + # Object name doesn't match - try to get it more directly + if len(parts) == 2 and parts[0] == 'builtins': + import builtins + if hasattr(builtins, parts[1]): + new_obj = getattr(builtins, parts[1]) + new_name = getattr(new_obj, '__name__', None) + if new_name == expected_name: + obj = new_obj + elif len(parts) > 1: + # Try direct import for standard library + try: + module_name = '.'.join(parts[:-1]) + attr_name = parts[-1] + module = __import__(module_name, fromlist=[attr_name]) + new_obj = getattr(module, attr_name) + new_name = getattr(new_obj, '__name__', None) or getattr(new_obj, '__qualname__', None) + if new_name == expected_name or new_name == attr_name: + obj = new_obj + except Exception: + pass + except Exception: + pass # Continue even if verification fails + + # Get the docstring + docstring = inspect.getdoc(obj) or pydoc.getdoc(obj) or "" + + # Additional verification: check if docstring matches tuple (common wrong result) + # This catches cases where pydoc.resolve returns tuple instead of the requested object + if docstring and "Built-in immutable sequence" in docstring and "tuple" in docstring.lower(): + # This looks like tuple documentation - verify we didn't request tuple + if object_name.lower() != 'tuple' and not object_name.lower().endswith('.tuple'): + # We got tuple docs but didn't ask for tuple - this is wrong! + # Try to get the correct object + try: + parts = object_name.split('.') + if len(parts) == 1: + # Simple module - try direct import + correct_obj = __import__(object_name) + if inspect.ismodule(correct_obj): + correct_doc = inspect.getdoc(correct_obj) or pydoc.getdoc(correct_obj) or "" + # If the correct doc doesn't mention tuple, use it + if "tuple" not in correct_doc.lower() or "Built-in immutable sequence" not in correct_doc: + obj = correct_obj + docstring = correct_doc + elif len(parts) > 1: + # Dotted name - try direct import + module_name = '.'.join(parts[:-1]) + attr_name = parts[-1] + module = __import__(module_name, fromlist=[attr_name]) + correct_obj = getattr(module, attr_name) + correct_doc = inspect.getdoc(correct_obj) or pydoc.getdoc(correct_obj) or "" + # If the correct doc doesn't mention tuple, use it + if "tuple" not in correct_doc.lower() or "Built-in immutable sequence" not in correct_doc: + obj = correct_obj + docstring = correct_doc + except Exception: + pass # If correction fails, continue with what we have + + # Determine object type + if inspect.ismodule(obj): + obj_type = "module" + elif inspect.isclass(obj): + obj_type = "class" + elif inspect.isfunction(obj) or inspect.ismethod(obj): + obj_type = "function" + else: + obj_type = "object" + + # Get signature if it's a callable + signature = None + if inspect.isclass(obj) or inspect.isfunction(obj) or inspect.ismethod(obj): + try: + sig = inspect.signature(obj) + signature = str(sig) + except (ValueError, TypeError): + pass + + # If docstring is empty, try to get help text + if not docstring: + try: + help_text = pydoc.render_doc(obj, renderer=pydoc.plaintext) + # Extract just the docstring part (first paragraph after object name) + lines = help_text.split('\n') + # Skip empty lines and find the actual docstring + start_idx = 0 + for i, line in enumerate(lines): + if line.strip() and not line.strip().startswith(object_name.split('.')[-1]): + start_idx = i + break + docstring = '\n'.join(lines[start_idx:]).strip() + except Exception: + pass + + # Final fallback: use help() output + if not docstring: + try: + import io + import sys + help_output = io.StringIO() + sys.stdout = help_output + help(obj) + sys.stdout = sys.__stdout__ + help_text = help_output.getvalue() + # Extract meaningful parts + lines = help_text.split('\n') + docstring = '\n'.join([l for l in lines if l.strip() and not l.strip().startswith('Help on')])[:500] + except Exception: + pass + + return { + 'original': docstring, + 'object_name': resolved_name, # Use resolved name, not original + 'object_type': obj_type, + 'signature': signature, + 'error': None + } + + except Exception as e: + import traceback + error_msg = str(e) + # Don't expose full traceback to user, but log it + print(f"Error extracting doc for {object_name}: {error_msg}") + print(traceback.format_exc()) + + return { + 'original': None, + 'object_name': object_name, + 'object_type': None, + 'signature': None, + 'error': f"Could not extract documentation: {error_msg}" + } + diff --git a/modules/module_list.py b/modules/module_list.py new file mode 100644 index 0000000..2784d55 --- /dev/null +++ b/modules/module_list.py @@ -0,0 +1,152 @@ +""" +Module for listing available Python modules and objects. +""" +import pydoc +import inspect +import sys +from typing import List, Dict, Any + + +class ModuleList: + """ + Provides lists of available Python modules and objects for documentation. + """ + + @staticmethod + def get_standard_modules() -> List[Dict[str, Any]]: + """ + Get list of standard library modules. + + Returns: + List of dictionaries with module information + """ + modules = [] + stdlib_paths = [p for p in sys.path if 'site-packages' not in p] + + # Common standard library modules + common_modules = [ + 'os', 'sys', 'json', 'datetime', 'collections', 'itertools', + 'functools', 'operator', 'string', 're', 'math', 'random', + 'statistics', 'decimal', 'fractions', 'array', 'bisect', + 'heapq', 'copy', 'pickle', 'sqlite3', 'hashlib', 'hmac', + 'secrets', 'uuid', 'pathlib', 'shutil', 'glob', 'fnmatch', + 'linecache', 'tempfile', 'fileinput', 'csv', 'configparser', + 'netrc', 'xdrlib', 'plistlib', 'codecs', 'unicodedata', + 'stringprep', 'readline', 'rlcompleter', 'struct', 'codecs', + 'types', 'copyreg', 'pprint', 'reprlib', 'enum', 'numbers', + 'collections.abc', 'io', 'argparse', 'getopt', 'logging', + 'getpass', 'curses', 'platform', 'errno', 'ctypes', 'threading', + 'multiprocessing', 'concurrent', 'subprocess', 'sched', 'queue', + 'select', 'selectors', 'asyncio', 'socket', 'ssl', 'email', + 'html', 'http', 'urllib', 'xml', 'webbrowser', 'cgi', 'cgitb', + 'wsgiref', 'urllib', 'xmlrpc', 'ipaddress', 'audioop', 'aifc', + 'sunau', 'wave', 'chunk', 'colorsys', 'imghdr', 'sndhdr', + 'ossaudiodev', 'gettext', 'locale', 'calendar', 'cmd', 'shlex', + 'tkinter', 'turtle', 'pydoc', 'doctest', 'unittest', 'test', + 'lib2to3', 'typing', 'dataclasses', 'contextlib', 'abc', + 'atexit', 'traceback', 'future', 'gc', 'inspect', 'site', + 'fpectl', 'distutils', 'ensurepip', 'venv', 'zipapp', 'faulthandler', + 'pdb', 'profile', 'pstats', 'timeit', 'trace', 'tracemalloc', + 'warnings', 'contextvars', 'dataclasses', 'weakref', 'types', + 'copy', 'pprint', 'reprlib', 'enum', 'numbers', 'collections.abc' + ] + + for mod_name in common_modules: + try: + if mod_name in sys.modules: + mod = sys.modules[mod_name] + else: + mod = __import__(mod_name) + + if inspect.ismodule(mod): + modules.append({ + 'name': mod_name, + 'type': 'module', + 'doc': inspect.getdoc(mod) or '' + }) + except (ImportError, AttributeError): + continue + + return sorted(modules, key=lambda x: x['name']) + + @staticmethod + def get_builtin_objects() -> List[Dict[str, Any]]: + """ + Get list of builtin objects (types, functions, etc.). + + Returns: + List of dictionaries with builtin object information + """ + objects = [] + builtins_module = __import__('builtins') + + for name in dir(builtins_module): + if not name.startswith('_'): + try: + obj = getattr(builtins_module, name) + obj_type = 'function' if inspect.isbuiltin(obj) or inspect.isfunction(obj) else 'type' + objects.append({ + 'name': name, + 'type': obj_type, + 'full_name': f'builtins.{name}', + 'doc': '' + }) + except Exception: + continue + + return sorted(objects, key=lambda x: x['name']) + + @staticmethod + def get_module_contents(module_name: str) -> List[Dict[str, Any]]: + """ + Get list of objects in a module. + + Args: + module_name: Name of the module + + Returns: + List of dictionaries with object information + """ + objects = [] + try: + if module_name in sys.modules: + mod = sys.modules[module_name] + else: + mod = __import__(module_name) + + if not inspect.ismodule(mod): + return objects + + for name in dir(mod): + if name.startswith('_'): + continue + + try: + obj = getattr(mod, name) + obj_type = 'unknown' + if inspect.ismodule(obj): + obj_type = 'module' + elif inspect.isclass(obj): + obj_type = 'class' + elif inspect.isfunction(obj) or inspect.ismethod(obj): + obj_type = 'function' + elif inspect.isbuiltin(obj): + obj_type = 'function' + else: + obj_type = 'object' + + full_name = f"{module_name}.{name}" + objects.append({ + 'name': name, + 'type': obj_type, + 'full_name': full_name, + 'doc': '' + }) + except Exception: + continue + + except Exception as e: + print(f"Error getting module contents for {module_name}: {e}") + + return sorted(objects, key=lambda x: x['name']) + diff --git a/modules/translator.py b/modules/translator.py new file mode 100644 index 0000000..a3ba608 --- /dev/null +++ b/modules/translator.py @@ -0,0 +1,288 @@ +""" +Translation module with extensible backend support. + +To add a new translation provider: +1. Create a class that inherits from TranslationBackend +2. Implement the translate() method +3. Register it in the TranslationService class +""" +from abc import ABC, abstractmethod +from typing import Optional +import os + + +class TranslationBackend(ABC): + """Abstract base class for translation backends.""" + + @abstractmethod + def translate(self, text: str, target_lang: str, source_lang: str = "en") -> Optional[str]: + """ + Translate text from source language to target language. + + Args: + text: Text to translate + target_lang: Target language code (e.g., 'de', 'fr', 'es') + source_lang: Source language code (default: 'en') + + Returns: + Translated text, or None if translation fails + """ + pass + + +class HuggingFaceTranslator(TranslationBackend): + """ + Translation backend using HuggingFace transformers. + + Uses Helsinki-NLP models for translation. + """ + + def __init__(self): + self._model = None + self._tokenizer = None + self._model_name = None + self._device = 'cpu' # Default device + + def _load_model(self, target_lang: str): + """Lazy load the translation model.""" + try: + from transformers import MarianMTModel, MarianTokenizer + import torch + except ImportError as e: + raise ImportError( + "transformers library not installed. " + "Install with: pip install transformers torch" + ) from e + + try: + # Check for SentencePiece (required by MarianTokenizer) + import sentencepiece + except ImportError: + raise ImportError( + "SentencePiece library not installed. " + "Install with: pip install sentencepiece" + ) + + # Map language codes to model names + model_map = { + 'de': 'Helsinki-NLP/opus-mt-en-de', + 'fr': 'Helsinki-NLP/opus-mt-en-fr', + 'es': 'Helsinki-NLP/opus-mt-en-es', + 'it': 'Helsinki-NLP/opus-mt-en-it', + 'pt': 'Helsinki-NLP/opus-mt-en-pt', + 'ru': 'Helsinki-NLP/opus-mt-en-ru', + } + + model_name = model_map.get(target_lang) + if not model_name: + raise ValueError(f"No model available for language: {target_lang}") + + # Only reload if language changed + if self._model_name != model_name: + device = 'cuda' if torch.cuda.is_available() else 'cpu' + + # Load tokenizer first (doesn't need device) + self._tokenizer = MarianTokenizer.from_pretrained(model_name) + + # Load model - try to load directly to device to avoid meta tensor issues + try: + # For CPU, load normally + if device == 'cpu': + self._model = MarianMTModel.from_pretrained(model_name) + self._model.eval() + else: + # For CUDA, try loading with device_map or load then move + try: + # Try loading with device_map if supported + self._model = MarianMTModel.from_pretrained( + model_name, + device_map='auto' + ) + self._model.eval() + # Update device based on where model actually ended up + actual_device = next(self._model.parameters()).device.type + device = actual_device if actual_device in ['cuda', 'cpu'] else 'cpu' + except (TypeError, ValueError): + # Fallback: load to CPU first, then move + self._model = MarianMTModel.from_pretrained(model_name) + self._model.eval() + try: + self._model = self._model.to(device) + except Exception: + # If moving fails, keep on CPU + device = 'cpu' + except Exception as e: + # Ultimate fallback: load to CPU + print(f"Warning: Error loading model to {device}, using CPU: {e}") + self._model = MarianMTModel.from_pretrained(model_name) + self._model.eval() + device = 'cpu' + + self._model_name = model_name + self._device = device + + def translate(self, text: str, target_lang: str, source_lang: str = "en") -> Optional[str]: + """Translate using HuggingFace model.""" + if not text: + return "" + + try: + self._load_model(target_lang) + import torch + + # Split text into paragraphs first, then sentences + paragraphs = text.split('\n\n') + translated_paragraphs = [] + + for para in paragraphs: + if not para.strip(): + translated_paragraphs.append(para) + continue + + # Split into sentences (simple approach) + sentences = para.split('\n') + translated_sentences = [] + + for sentence in sentences: + if not sentence.strip(): + translated_sentences.append(sentence) + continue + + try: + # Tokenize and move to device + inputs = self._tokenizer( + [sentence], + return_tensors="pt", + padding=True, + truncation=True, + max_length=512 + ).to(self._device) + + # Generate translation + with torch.no_grad(): + translated = self._model.generate(**inputs, max_length=512) + + translated_text = self._tokenizer.decode(translated[0], skip_special_tokens=True) + translated_sentences.append(translated_text) + except Exception as e: + print(f"Error translating sentence: {e}") + translated_sentences.append(sentence) # Fallback to original + + translated_paragraphs.append('\n'.join(translated_sentences)) + + return '\n\n'.join(translated_paragraphs) + + except Exception as e: + import traceback + print(f"Translation error: {e}") + print(traceback.format_exc()) + return None + + +class GoogleTranslateBackend(TranslationBackend): + """ + Translation backend using Google Translate API. + + Requires GOOGLE_TRANSLATE_API_KEY environment variable. + """ + + def __init__(self): + self.api_key = os.getenv('GOOGLE_TRANSLATE_API_KEY') + if not self.api_key: + raise ValueError("GOOGLE_TRANSLATE_API_KEY environment variable not set") + + def translate(self, text: str, target_lang: str, source_lang: str = "en") -> Optional[str]: + """Translate using Google Translate API.""" + try: + from googletrans import Translator + translator = Translator() + result = translator.translate(text, dest=target_lang, src=source_lang) + return result.text + except ImportError: + raise ImportError("googletrans library not installed. Install with: pip install googletrans==4.0.0rc1") + except Exception as e: + print(f"Google Translate error: {e}") + return None + + +class DeepLTranslator(TranslationBackend): + """ + Translation backend using DeepL API. + + Requires DEEPL_API_KEY environment variable. + """ + + def __init__(self): + self.api_key = os.getenv('DEEPL_API_KEY') + if not self.api_key: + raise ValueError("DEEPL_API_KEY environment variable not set") + + def translate(self, text: str, target_lang: str, source_lang: str = "en") -> Optional[str]: + """Translate using DeepL API.""" + try: + import deepl + translator = deepl.Translator(self.api_key) + result = translator.translate_text(text, target_lang=target_lang.upper(), source_lang=source_lang.upper()) + return result.text + except ImportError: + raise ImportError("deepl library not installed. Install with: pip install deepl") + except Exception as e: + print(f"DeepL translation error: {e}") + return None + + +class TranslationService: + """ + Translation service that manages multiple translation backends. + + Automatically selects the best available backend based on configuration. + """ + + def __init__(self, backend: Optional[str] = None): + """ + Initialize translation service. + + Args: + backend: Backend name ('huggingface', 'google', 'deepl'). + If None, auto-selects based on availability. + """ + self.backend_name = backend or self._auto_select_backend() + self.backend = self._create_backend(self.backend_name) + + def _auto_select_backend(self) -> str: + """Auto-select the best available backend.""" + # Priority: DeepL > Google > HuggingFace + if os.getenv('DEEPL_API_KEY'): + return 'deepl' + elif os.getenv('GOOGLE_TRANSLATE_API_KEY'): + return 'google' + else: + return 'huggingface' # Default to local model + + def _create_backend(self, backend_name: str) -> TranslationBackend: + """Create a translation backend instance.""" + backends = { + 'huggingface': HuggingFaceTranslator, + 'google': GoogleTranslateBackend, + 'deepl': DeepLTranslator, + } + + backend_class = backends.get(backend_name.lower()) + if not backend_class: + raise ValueError(f"Unknown backend: {backend_name}") + + try: + return backend_class() + except Exception as e: + # Fallback to HuggingFace if other backends fail + if backend_name != 'huggingface': + print(f"Failed to initialize {backend_name}, falling back to HuggingFace: {e}") + return HuggingFaceTranslator() + raise + + def translate(self, text: str, target_lang: str, source_lang: str = "en") -> Optional[str]: + """Translate text using the configured backend.""" + if not text: + return "" + return self.backend.translate(text, target_lang, source_lang) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3f387fd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +Flask==3.0.0 +# Translation backends (install as needed) +transformers==4.35.0 # For HuggingFace backend +sentencepiece>=0.1.99 # Required by MarianTokenizer +sacremoses>=0.0.53 # Required by MarianTokenizer for tokenization +# torch==2.1.0 # Required by transformers (install separately: pip install torch) +# googletrans==4.0.0rc1 # For Google Translate backend +# deepl==1.15.0 # For DeepL backend +# redis==5.0.1 # For Redis cache backend +# Course scraping +beautifulsoup4>=4.12.0 # For parsing HTML content +requests>=2.31.0 # For HTTP requests + diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..afe3a8e --- /dev/null +++ b/routes/__init__.py @@ -0,0 +1,2 @@ +"""Routes package for Flask API.""" + diff --git a/routes/api.py b/routes/api.py new file mode 100644 index 0000000..a5fa74a --- /dev/null +++ b/routes/api.py @@ -0,0 +1,157 @@ +""" +API routes for the pydoc translation service. + +FastAPI-compatible route structure implemented in Flask. +""" +from flask import Blueprint, request, jsonify +from modules.doc_extractor import DocExtractor +from modules.translator import TranslationService +from modules.cache import CacheService +from modules.module_list import ModuleList +from modules.course_scraper import CourseScraper + +api_bp = Blueprint('api', __name__, url_prefix='') + +# Initialize services (singleton pattern) +doc_extractor = DocExtractor() +translator = TranslationService() +cache = CacheService() +module_list = ModuleList() + + +@api_bp.route('/docs', methods=['GET']) +def get_docs(): + """ + Get documentation for a Python object with optional translation. + + Query parameters: + object: Python object name (e.g., 'dict.update', 'os.path') + lang: Target language code (e.g., 'de', 'fr', 'es'). Optional. + + Returns: + JSON response with: + - original: Original English documentation + - translated: Translated documentation (if lang provided) + - object_name: Name of the object + - object_type: Type of object + - signature: Function signature if applicable + - error: Error message if any + - cached: Whether the result was served from cache + """ + object_name = request.args.get('object') + target_lang = request.args.get('lang') + + if not object_name: + return jsonify({ + 'error': 'Missing required parameter: object', + 'original': None, + 'translated': None + }), 400 + + # Check cache first if language is specified + cached_result = None + if target_lang: + cached_result = cache.get(object_name, target_lang) + if cached_result: + # Verify cached result matches the requested object + if cached_result.get('object_name') == object_name: + cached_result['cached'] = True + return jsonify(cached_result) + else: + # Cache mismatch - clear it and continue + print(f"Cache mismatch for {object_name}: got {cached_result.get('object_name')}") + + # Extract documentation + doc_data = doc_extractor.extract_doc(object_name) + + if doc_data.get('error'): + return jsonify({ + 'error': doc_data['error'], + 'original': None, + 'translated': None, + 'object_name': object_name + }), 404 + + # Always translate if language is specified (auto-translate) + translated = None + if target_lang and doc_data.get('original'): + try: + translated = translator.translate(doc_data['original'], target_lang) + if not translated: + # If translation fails, try to return original with error note + translated = None + except Exception as e: + import traceback + print(f"Translation error: {e}") + print(traceback.format_exc()) + translated = None + + # Prepare response + response = { + 'original': doc_data['original'], + 'translated': translated, + 'object_name': doc_data['object_name'], + 'object_type': doc_data['object_type'], + 'signature': doc_data['signature'], + 'error': doc_data['error'], + 'cached': False + } + + # Cache the result if translation was successful + if target_lang and translated: + cache.set(object_name, target_lang, response, ttl=86400) # Cache for 24 hours + + return jsonify(response) + + +@api_bp.route('/modules', methods=['GET']) +def get_modules(): + """ + Get list of available Python modules. + + Query parameters: + module: Optional module name to get contents of that module + + Returns: + List of available modules or module contents + """ + module_name = request.args.get('module') + + if module_name: + # Get contents of specific module + contents = module_list.get_module_contents(module_name) + return jsonify({ + 'module': module_name, + 'contents': contents + }) + else: + # Get list of standard modules + modules = module_list.get_standard_modules() + builtins = module_list.get_builtin_objects() + return jsonify({ + 'modules': modules, + 'builtins': builtins + }) + + +@api_bp.route('/course', methods=['GET']) +def get_course(): + """ + Get Python course content. + + Returns: + Course structure and content + """ + scraper = CourseScraper() + course_data = scraper.scrape_course_content() + return jsonify(course_data) + + +@api_bp.route('/health', methods=['GET']) +def health(): + """Health check endpoint.""" + return jsonify({ + 'status': 'healthy', + 'service': 'pydoc-translation-service' + }) + diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..6168a44 --- /dev/null +++ b/static/app.js @@ -0,0 +1,136 @@ +// Main Application +import { ThemeToggle } from './components/ThemeToggle.js'; +import { Sidebar } from './components/Sidebar.js'; +import { SearchForm } from './components/SearchForm.js'; +import { SearchBar } from './components/SearchBar.js'; +import { LanguageSelector } from './components/LanguageSelector.js'; +import { ModuleList } from './components/ModuleList.js'; +import { CourseList } from './components/CourseList.js'; +import { Results } from './components/Results.js'; +import { Tabs } from './components/Tabs.js'; + +class App { + constructor() { + this.results = new Results(); + this.searchBar = null; + this.languageSelector = null; + this.moduleList = null; + this.courseList = null; + this.currentObject = ''; + this.currentLang = ''; + this.init(); + } + + async init() { + // Initialize components + new ThemeToggle(); + new Sidebar(); + new SearchForm(); + + // Initialize language selector + this.languageSelector = new LanguageSelector((langCode) => { + this.currentLang = langCode; + if (this.currentObject) { + this.fetchDocumentation(this.currentObject, langCode); + } + }); + + // Initialize search bar + this.searchBar = new SearchBar( + (query) => { + this.currentObject = query; + this.fetchDocumentation(query, this.languageSelector.getCurrentLanguage()); + }, + (fullName) => { + this.currentObject = fullName; + } + ); + + this.moduleList = new ModuleList((fullName) => { + this.searchBar.setValue(fullName); + this.currentObject = fullName; + this.fetchDocumentation(fullName, this.languageSelector.getCurrentLanguage()); + }); + + this.courseList = new CourseList((section) => { + this.results.scrollToSection(section); + }); + + new Tabs((tabName) => { + if (tabName === 'course') { + this.loadCourseContent(); + } + }); + } + + async fetchDocumentation(objectName, targetLang) { + if (!objectName || objectName.trim().length === 0) { + return; + } + + // Show loading with progress bar + const loadingMessage = targetLang + ? `Translating documentation to ${this.getLanguageName(targetLang)}...` + : 'Loading documentation...'; + this.results.showLoading(loadingMessage, true); + + try { + const params = new URLSearchParams({ object: objectName.trim() }); + if (targetLang) { + params.append('lang', targetLang); + } + + const response = await fetch(`/docs?${params}`); + const data = await response.json(); + + // Complete progress animation + if (this.results.loadingProgress) { + this.results.loadingProgress.complete(); + } + + // Small delay for smooth transition + await new Promise(resolve => setTimeout(resolve, 200)); + + if (data.error) { + this.results.showError(`Error: ${data.error}`); + return; + } + + this.results.displayDocumentation(data); + + } catch (error) { + if (this.results.loadingProgress) { + this.results.loadingProgress.hide(); + } + this.results.showError(`Network error: ${error.message}`); + } + } + + getLanguageName(langCode) { + const langMap = { + 'de': 'German', + 'fr': 'French', + 'es': 'Spanish', + 'it': 'Italian', + 'pt': 'Portuguese', + 'ru': 'Russian' + }; + return langMap[langCode] || langCode; + } + + async loadCourseContent() { + await this.courseList.loadCourseContent(); + const courseData = this.courseList.getCourseData(); + if (courseData) { + this.results.displayCourse(courseData); + } + } +} + +// Initialize app when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => new App()); +} else { + new App(); +} + diff --git a/static/components/CourseList.js b/static/components/CourseList.js new file mode 100644 index 0000000..ba73ce5 --- /dev/null +++ b/static/components/CourseList.js @@ -0,0 +1,88 @@ +// Course List Component +export class CourseList { + constructor(onSectionClick) { + this.container = document.getElementById('courseList'); + this.onSectionClick = onSectionClick; + this.courseData = null; + } + + async loadCourseContent() { + if (this.container?.querySelector('.course-loaded')) { + return; // Already loaded + } + + try { + const response = await fetch('/course'); + const data = await response.json(); + this.courseData = data; + + const list = document.createElement('div'); + list.className = 'module-items'; + list.classList.add('course-loaded'); + + if (data.sections && data.sections.length > 0) { + data.sections.forEach(section => { + const sectionDiv = this.createSectionItem(section); + list.appendChild(sectionDiv); + }); + } + + if (this.container) { + this.container.innerHTML = ''; + this.container.appendChild(list); + } + } catch (error) { + if (this.container) { + this.container.innerHTML = `
Error loading course: ${error.message}
`; + } + } + } + + createSectionItem(section) { + const sectionDiv = document.createElement('div'); + sectionDiv.className = 'module-section'; + + const sectionTitle = document.createElement('h4'); + sectionTitle.textContent = section.title; + sectionTitle.className = 'module-section-title'; + sectionDiv.appendChild(sectionTitle); + + const itemsList = document.createElement('div'); + itemsList.className = 'module-items-list'; + + // Create clickable navigation item + const navBtn = document.createElement('button'); + navBtn.className = 'module-item'; + navBtn.textContent = section.title; + navBtn.addEventListener('click', () => { + if (this.onSectionClick) { + this.onSectionClick(section); + } + }); + itemsList.appendChild(navBtn); + + // Add subsections if any + if (section.subsections && section.subsections.length > 0) { + section.subsections.forEach(subsection => { + const subBtn = document.createElement('button'); + subBtn.className = 'module-item'; + subBtn.style.paddingLeft = '1.5rem'; + subBtn.textContent = subsection.title; + subBtn.addEventListener('click', () => { + if (this.onSectionClick) { + this.onSectionClick(subsection); + } + }); + itemsList.appendChild(subBtn); + }); + } + + sectionDiv.appendChild(itemsList); + return sectionDiv; + } + + getCourseData() { + return this.courseData; + } +} + diff --git a/static/components/LanguageSelector.js b/static/components/LanguageSelector.js new file mode 100644 index 0000000..5d19bc7 --- /dev/null +++ b/static/components/LanguageSelector.js @@ -0,0 +1,134 @@ +// Language Selector Component with Emojis +export class LanguageSelector { + constructor(onLanguageChange) { + this.onLanguageChange = onLanguageChange; + this.currentLang = ''; + this.languages = [ + { code: '', name: 'English', emoji: '🇬🇧', native: 'English' }, + { code: 'de', name: 'German', emoji: '🇩🇪', native: 'Deutsch' }, + { code: 'fr', name: 'French', emoji: '🇫🇷', native: 'Français' }, + { code: 'es', name: 'Spanish', emoji: '🇪🇸', native: 'Español' }, + { code: 'it', name: 'Italian', emoji: '🇮🇹', native: 'Italiano' }, + { code: 'pt', name: 'Portuguese', emoji: '🇵🇹', native: 'Português' }, + { code: 'ru', name: 'Russian', emoji: '🇷🇺', native: 'Русский' } + ]; + this.init(); + } + + init() { + // Load saved language preference + const savedLang = localStorage.getItem('preferredLanguage') || ''; + this.currentLang = savedLang; + this.createSelector(); + } + + createSelector() { + // Create floating language selector + const selector = document.createElement('div'); + selector.className = 'language-selector'; + selector.id = 'languageSelector'; + + const button = document.createElement('button'); + button.className = 'language-selector-button'; + button.setAttribute('aria-label', 'Select language'); + button.setAttribute('aria-haspopup', 'true'); + + const currentLang = this.languages.find(lang => lang.code === this.currentLang) || this.languages[0]; + button.innerHTML = ` + ${currentLang.emoji} + ${currentLang.native} + + `; + + const dropdown = document.createElement('div'); + dropdown.className = 'language-dropdown'; + dropdown.id = 'languageDropdown'; + + this.languages.forEach(lang => { + const option = document.createElement('button'); + option.className = `language-option ${lang.code === this.currentLang ? 'active' : ''}`; + option.dataset.code = lang.code; + option.innerHTML = ` + ${lang.emoji} + ${lang.native} + ${lang.name} + `; + + option.addEventListener('click', () => { + this.selectLanguage(lang.code); + dropdown.classList.remove('active'); + }); + + dropdown.appendChild(option); + }); + + button.addEventListener('click', (e) => { + e.stopPropagation(); + const isActive = dropdown.classList.contains('active'); + dropdown.classList.toggle('active'); + // Update arrow rotation + const arrow = button.querySelector('.language-arrow'); + if (arrow) { + arrow.style.transform = isActive ? 'rotate(0deg)' : 'rotate(180deg)'; + } + }); + + selector.appendChild(button); + selector.appendChild(dropdown); + + // Store reference for arrow rotation + this.button = button; + this.dropdown = dropdown; + + // Insert into content area (top right) + const content = document.getElementById('content'); + if (content) { + content.insertBefore(selector, content.firstChild); + } + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + if (!selector.contains(e.target)) { + dropdown.classList.remove('active'); + const arrow = button.querySelector('.language-arrow'); + if (arrow) { + arrow.style.transform = 'rotate(0deg)'; + } + } + }); + } + + selectLanguage(code) { + this.currentLang = code; + localStorage.setItem('preferredLanguage', code); + + // Update button display + const currentLang = this.languages.find(lang => lang.code === code) || this.languages[0]; + const button = document.querySelector('.language-selector-button'); + if (button) { + button.innerHTML = ` + ${currentLang.emoji} + ${currentLang.native} + + `; + } + + // Update active option + document.querySelectorAll('.language-option').forEach(option => { + if (option.dataset.code === code) { + option.classList.add('active'); + } else { + option.classList.remove('active'); + } + }); + + if (this.onLanguageChange) { + this.onLanguageChange(code); + } + } + + getCurrentLanguage() { + return this.currentLang; + } +} + diff --git a/static/components/LoadingProgress.js b/static/components/LoadingProgress.js new file mode 100644 index 0000000..c1d9c1d --- /dev/null +++ b/static/components/LoadingProgress.js @@ -0,0 +1,158 @@ +// Loading Progress Component with Qt-style progress bar +export class LoadingProgress { + constructor(container) { + this.container = container; + this.progressBar = null; + this.progressFill = null; + this.animationFrame = null; + this.progress = 0; + this.targetProgress = 0; + this.isAnimating = false; + } + + show(message = 'Loading...', showProgress = true) { + if (!this.container) return; + + const loadingHTML = ` +
+
+
+
+
+
+
+
${message}
+ ${showProgress ? ` +
+
+
+
+
+
0%
+
+ ` : ''} +
+ `; + + this.container.innerHTML = loadingHTML; + + if (showProgress) { + this.progressBar = this.container.querySelector('.progress-bar'); + this.progressFill = document.getElementById('progressFill'); + this.progressText = document.getElementById('progressText'); + this.startProgressAnimation(); + } + } + + startProgressAnimation() { + this.progress = 0; + this.targetProgress = 0; + this.isAnimating = true; + + // More stable progress simulation with smoother steps + const steps = [ + { progress: 8, delay: 150 }, + { progress: 20, delay: 250 }, + { progress: 35, delay: 400 }, + { progress: 50, delay: 350 }, + { progress: 65, delay: 500 }, + { progress: 78, delay: 450 }, + { progress: 88, delay: 600 }, + { progress: 95, delay: 700 }, + ]; + + let stepIndex = 0; + let isCancelled = false; + this.cancelProgress = () => { isCancelled = true; }; + + const updateProgress = () => { + if (isCancelled || !this.isAnimating) return; + + if (stepIndex < steps.length) { + const step = steps[stepIndex]; + this.targetProgress = step.progress; + stepIndex++; + setTimeout(updateProgress, step.delay); + } else { + // Keep at 95% until actual loading completes + this.targetProgress = 95; + } + }; + + updateProgress(); + this.animateProgress(); + } + + animateProgress() { + if (!this.isAnimating || !this.progressFill) return; + + // Smooth interpolation + const diff = this.targetProgress - this.progress; + if (Math.abs(diff) > 0.1) { + this.progress += diff * 0.15; // Smooth easing + } else { + this.progress = this.targetProgress; + } + + if (this.progressFill) { + this.progressFill.style.width = `${this.progress}%`; + } + + if (this.progressText) { + this.progressText.textContent = `${Math.round(this.progress)}%`; + } + + // Add class when progress starts + if (this.progressBar) { + if (this.progress > 0) { + this.progressBar.classList.add('has-progress'); + } else { + this.progressBar.classList.remove('has-progress'); + } + } + + if (this.isAnimating) { + this.animationFrame = requestAnimationFrame(() => this.animateProgress()); + } + } + + setProgress(value) { + this.targetProgress = Math.min(100, Math.max(0, value)); + if (!this.isAnimating && this.progressFill) { + this.startProgressAnimation(); + } + } + + complete() { + this.targetProgress = 100; + // Animate to 100% and then hide + const checkComplete = () => { + if (this.progress >= 99.9) { + setTimeout(() => { + this.hide(); + }, 200); + } else { + setTimeout(checkComplete, 50); + } + }; + checkComplete(); + } + + hide() { + this.isAnimating = false; + if (this.cancelProgress) { + this.cancelProgress(); + } + if (this.animationFrame) { + cancelAnimationFrame(this.animationFrame); + } + // Clear progress elements + this.progressBar = null; + this.progressFill = null; + this.progressText = null; + if (this.container) { + this.container.innerHTML = ''; + } + } +} + diff --git a/static/components/ModuleList.js b/static/components/ModuleList.js new file mode 100644 index 0000000..7efa6d6 --- /dev/null +++ b/static/components/ModuleList.js @@ -0,0 +1,79 @@ +// Module List Component +export class ModuleList { + constructor(onModuleClick) { + this.container = document.getElementById('moduleList'); + this.onModuleClick = onModuleClick; + this.init(); + } + + async init() { + await this.loadModuleList(); + } + + async loadModuleList() { + try { + const response = await fetch('/modules'); + const data = await response.json(); + + const list = document.createElement('div'); + list.className = 'module-items'; + + // Add builtins section + if (data.builtins && data.builtins.length > 0) { + const builtinsSection = this.createSection('Builtins', data.builtins.slice(0, 20)); + list.appendChild(builtinsSection); + } + + // Add modules section + if (data.modules && data.modules.length > 0) { + const modulesSection = this.createSection('Standard Library', data.modules); + list.appendChild(modulesSection); + } + + if (this.container) { + this.container.innerHTML = ''; + this.container.appendChild(list); + } + } catch (error) { + if (this.container) { + this.container.innerHTML = `
Error loading modules: ${error.message}
`; + } + } + } + + createSection(title, items) { + const section = document.createElement('div'); + section.className = 'module-section'; + + const sectionTitle = document.createElement('h4'); + sectionTitle.textContent = title; + sectionTitle.className = 'module-section-title'; + section.appendChild(sectionTitle); + + const itemsList = document.createElement('div'); + itemsList.className = 'module-items-list'; + + items.forEach(item => { + const fullName = item.full_name || (title === 'Builtins' ? `builtins.${item.name}` : item.name); + const btn = this.createModuleButton(fullName, item.name); + itemsList.appendChild(btn); + }); + + section.appendChild(itemsList); + return section; + } + + createModuleButton(fullName, displayName) { + const btn = document.createElement('button'); + btn.className = 'module-item'; + btn.textContent = displayName; + btn.title = fullName; + btn.addEventListener('click', () => { + if (this.onModuleClick) { + this.onModuleClick(fullName); + } + }); + return btn; + } +} + diff --git a/static/components/Results.js b/static/components/Results.js new file mode 100644 index 0000000..36d37df --- /dev/null +++ b/static/components/Results.js @@ -0,0 +1,233 @@ +// Results Component +import { LoadingProgress } from './LoadingProgress.js'; + +export class Results { + constructor() { + this.container = document.getElementById('results'); + this.loadingProgress = new LoadingProgress(this.container); + this.init(); + } + + init() { + // Show welcome message initially + this.showWelcome(); + } + + showWelcome() { + if (this.container) { + this.container.innerHTML = ` +
+

Python Documentation

+

Search for any Python object in the sidebar to view its documentation.

+

Select a language to automatically translate the documentation.

+
+ `; + } + } + + showLoading(message = 'Loading documentation...', showProgress = true) { + if (this.container) { + this.loadingProgress.show(message, showProgress); + } + } + + hideLoading() { + if (this.loadingProgress) { + this.loadingProgress.complete(); + } + } + + showError(message) { + if (this.container) { + this.container.innerHTML = `
${message}
`; + } + } + + displayDocumentation(data) { + if (!this.container) return; + + const wrapper = document.createElement('div'); + + // Header with title + const header = document.createElement('div'); + header.className = 'doc-header'; + + const title = document.createElement('h1'); + title.className = 'doc-title'; + title.textContent = data.object_name; + + const meta = document.createElement('div'); + meta.className = 'doc-meta'; + meta.innerHTML = ` + ${data.object_type || 'unknown'} + ${data.cached ? 'Cached' : ''} + `; + + header.appendChild(title); + header.appendChild(meta); + wrapper.appendChild(header); + + // Signature + if (data.signature) { + const signature = document.createElement('div'); + signature.className = 'doc-signature'; + signature.textContent = data.signature; + wrapper.appendChild(signature); + } + + // Main documentation content + const docText = data.translated || data.original; + + if (docText) { + const docSection = document.createElement('section'); + docSection.className = 'doc-section'; + + const docTextEl = document.createElement('div'); + docTextEl.className = 'doc-text'; + docTextEl.textContent = docText; + + docSection.appendChild(docTextEl); + wrapper.appendChild(docSection); + } + + // Show original if translation exists (collapsible) + if (data.translated && data.original) { + const originalSection = document.createElement('details'); + originalSection.className = 'doc-section'; + originalSection.innerHTML = ` + + Original Documentation (English) + +
${data.original}
+ `; + wrapper.appendChild(originalSection); + } + + if (!data.original && !data.translated) { + const noDoc = document.createElement('div'); + noDoc.className = 'doc-text'; + noDoc.textContent = 'No documentation available for this object.'; + wrapper.appendChild(noDoc); + } + + this.container.innerHTML = ''; + this.container.appendChild(wrapper); + } + + displayCourse(courseData) { + if (!this.container) return; + + const wrapper = document.createElement('div'); + wrapper.className = 'course-content'; + + const title = document.createElement('h1'); + title.textContent = courseData.title || 'Python Course'; + wrapper.appendChild(title); + + if (courseData.sections && courseData.sections.length > 0) { + courseData.sections.forEach(section => { + const sectionDiv = this.createCourseSection(section); + wrapper.appendChild(sectionDiv); + }); + } + + this.container.innerHTML = ''; + this.container.appendChild(wrapper); + } + + createCourseSection(section) { + const sectionDiv = document.createElement('section'); + sectionDiv.className = 'course-section'; + sectionDiv.id = `section-${section.id || section.title.toLowerCase().replace(/\s+/g, '-')}`; + + // Parse and render markdown + if (section.markdown) { + // Configure marked options + if (typeof marked !== 'undefined') { + marked.setOptions({ + highlight: function(code, lang) { + if (typeof hljs !== 'undefined' && lang && hljs.getLanguage(lang)) { + try { + return hljs.highlight(code, { language: lang }).value; + } catch (err) { + console.error('Highlight error:', err); + } + } + if (typeof hljs !== 'undefined') { + return hljs.highlightAuto(code).value; + } + return code; + }, + breaks: true, + gfm: true + }); + + // Convert markdown to HTML + const htmlContent = marked.parse(section.markdown); + + // Create a container for the markdown content + const contentDiv = document.createElement('div'); + contentDiv.className = 'markdown-content'; + contentDiv.innerHTML = htmlContent; + + // Add IDs to headings for navigation + contentDiv.querySelectorAll('h1, h2, h3, h4').forEach((heading) => { + const text = heading.textContent.trim(); + const id = text.toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim(); + heading.id = id; + }); + + // Highlight code blocks + if (typeof hljs !== 'undefined') { + contentDiv.querySelectorAll('pre code').forEach((block) => { + hljs.highlightElement(block); + }); + } + + sectionDiv.appendChild(contentDiv); + } + } else if (section.content && section.content.length > 0) { + // Fallback to old format + section.content.forEach(item => { + if (item.startsWith('```')) { + const codeDiv = document.createElement('pre'); + codeDiv.className = 'doc-signature'; + codeDiv.textContent = item.replace(/```python\n?/g, '').replace(/```/g, '').trim(); + sectionDiv.appendChild(codeDiv); + } else { + const itemDiv = document.createElement('p'); + itemDiv.className = 'doc-text'; + itemDiv.textContent = item; + sectionDiv.appendChild(itemDiv); + } + }); + } + + return sectionDiv; + } + + scrollToSection(section) { + const sectionId = section.id || section.title.toLowerCase().replace(/\s+/g, '-'); + const sectionElement = document.getElementById(`section-${sectionId}`); + if (sectionElement) { + sectionElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + return; + } + + // Try to find by heading ID + const headingId = section.title.toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim(); + const headingElement = document.getElementById(headingId); + if (headingElement) { + headingElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } +} + diff --git a/static/components/SearchBar.js b/static/components/SearchBar.js new file mode 100644 index 0000000..27c4db8 --- /dev/null +++ b/static/components/SearchBar.js @@ -0,0 +1,281 @@ +// Search Bar Component with Autocomplete +export class SearchBar { + constructor(onSearch, onSelect) { + this.onSearch = onSearch; + this.onSelect = onSelect; + this.searchInput = null; + this.suggestionsContainer = null; + this.allItems = []; + this.filteredItems = []; + this.selectedIndex = -1; + this.debounceTimer = null; + this.isOpen = false; + this.init(); + } + + async init() { + await this.loadAllItems(); + this.createSearchBar(); + } + + async loadAllItems() { + try { + const response = await fetch('/modules'); + const data = await response.json(); + + this.allItems = []; + + // Add builtins + if (data.builtins && data.builtins.length > 0) { + data.builtins.forEach(item => { + this.allItems.push({ + name: item.name, + fullName: item.full_name || `builtins.${item.name}`, + type: 'builtin', + display: item.name + }); + }); + } + + // Add modules + if (data.modules && data.modules.length > 0) { + data.modules.forEach(item => { + this.allItems.push({ + name: item.name, + fullName: item.name, + type: 'module', + display: item.name + }); + }); + } + } catch (error) { + console.error('Error loading items:', error); + this.allItems = []; + } + } + + createSearchBar() { + const searchForm = document.querySelector('.search-form'); + if (!searchForm) return; + + // Create search input + const searchWrapper = document.createElement('div'); + searchWrapper.className = 'search-bar-wrapper'; + + const searchIcon = document.createElement('span'); + searchIcon.className = 'search-icon'; + searchIcon.innerHTML = '🔍'; + searchIcon.setAttribute('aria-hidden', 'true'); + + this.searchInput = document.createElement('input'); + this.searchInput.type = 'text'; + this.searchInput.className = 'search-bar-input'; + this.searchInput.placeholder = 'Search Python objects, modules, builtins...'; + this.searchInput.autocomplete = 'off'; + this.searchInput.setAttribute('aria-label', 'Search Python documentation'); + + // Create suggestions dropdown + this.suggestionsContainer = document.createElement('div'); + this.suggestionsContainer.className = 'search-suggestions'; + this.suggestionsContainer.id = 'searchSuggestions'; + + searchWrapper.appendChild(searchIcon); + searchWrapper.appendChild(this.searchInput); + searchWrapper.appendChild(this.suggestionsContainer); + + // Replace the old input group + const oldInputGroup = searchForm.querySelector('.input-group'); + if (oldInputGroup) { + oldInputGroup.replaceWith(searchWrapper); + } else { + searchForm.insertBefore(searchWrapper, searchForm.firstChild); + } + + this.setupEventListeners(); + } + + setupEventListeners() { + // Debounced search on input + this.searchInput.addEventListener('input', (e) => { + clearTimeout(this.debounceTimer); + const query = e.target.value.trim(); + + if (query.length === 0) { + this.hideSuggestions(); + return; + } + + this.debounceTimer = setTimeout(() => { + this.filterItems(query); + this.showSuggestions(); + }, 150); + }); + + // Handle keyboard navigation + this.searchInput.addEventListener('keydown', (e) => { + if (!this.isOpen || this.filteredItems.length === 0) { + if (e.key === 'Enter' && this.searchInput.value.trim()) { + this.performSearch(this.searchInput.value.trim()); + } + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + this.selectedIndex = Math.min(this.selectedIndex + 1, this.filteredItems.length - 1); + this.updateSelection(); + break; + case 'ArrowUp': + e.preventDefault(); + this.selectedIndex = Math.max(this.selectedIndex - 1, -1); + this.updateSelection(); + break; + case 'Enter': + e.preventDefault(); + if (this.selectedIndex >= 0 && this.selectedIndex < this.filteredItems.length) { + this.selectItem(this.filteredItems[this.selectedIndex]); + } else if (this.searchInput.value.trim()) { + this.performSearch(this.searchInput.value.trim()); + } + break; + case 'Escape': + this.hideSuggestions(); + break; + } + }); + + // Close suggestions when clicking outside + document.addEventListener('click', (e) => { + if (!this.searchInput.contains(e.target) && + !this.suggestionsContainer.contains(e.target)) { + this.hideSuggestions(); + } + }); + + // Auto-search on blur if there's a value and user typed something + let hasTyped = false; + this.searchInput.addEventListener('input', () => { + hasTyped = true; + }); + + this.searchInput.addEventListener('blur', () => { + // Delay to allow click events on suggestions + setTimeout(() => { + const query = this.searchInput.value.trim(); + if (query && hasTyped && !this.isOpen) { + this.performSearch(query); + } + hasTyped = false; + }, 200); + }); + } + + filterItems(query) { + const lowerQuery = query.toLowerCase(); + this.filteredItems = this.allItems + .filter(item => { + const nameMatch = item.name.toLowerCase().includes(lowerQuery); + const fullNameMatch = item.fullName.toLowerCase().includes(lowerQuery); + return nameMatch || fullNameMatch; + }) + .slice(0, 10); // Limit to 10 suggestions + + this.selectedIndex = -1; + } + + showSuggestions() { + if (this.filteredItems.length === 0) { + this.hideSuggestions(); + return; + } + + this.isOpen = true; + this.suggestionsContainer.innerHTML = ''; + this.suggestionsContainer.classList.add('active'); + + this.filteredItems.forEach((item, index) => { + const suggestion = document.createElement('div'); + suggestion.className = 'search-suggestion'; + suggestion.dataset.index = index; + + const icon = item.type === 'builtin' ? '⚡' : '📦'; + suggestion.innerHTML = ` + ${icon} + ${this.highlightMatch(item.name, this.searchInput.value)} + ${item.type} + `; + + suggestion.addEventListener('click', () => { + this.selectItem(item); + }); + + suggestion.addEventListener('mouseenter', () => { + this.selectedIndex = index; + this.updateSelection(); + }); + + this.suggestionsContainer.appendChild(suggestion); + }); + } + + highlightMatch(text, query) { + if (!query) return text; + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const index = lowerText.indexOf(lowerQuery); + + if (index === -1) return text; + + const before = text.substring(0, index); + const match = text.substring(index, index + query.length); + const after = text.substring(index + query.length); + + return `${before}${match}${after}`; + } + + updateSelection() { + const suggestions = this.suggestionsContainer.querySelectorAll('.search-suggestion'); + suggestions.forEach((suggestion, index) => { + if (index === this.selectedIndex) { + suggestion.classList.add('selected'); + suggestion.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } else { + suggestion.classList.remove('selected'); + } + }); + } + + selectItem(item) { + this.searchInput.value = item.fullName; + this.hideSuggestions(); + + if (this.onSelect) { + this.onSelect(item.fullName); + } + + this.performSearch(item.fullName); + } + + performSearch(query) { + if (!query || query.trim().length === 0) return; + + if (this.onSearch) { + this.onSearch(query.trim()); + } + } + + hideSuggestions() { + this.isOpen = false; + this.selectedIndex = -1; + this.suggestionsContainer.classList.remove('active'); + this.suggestionsContainer.innerHTML = ''; + } + + setValue(value) { + if (this.searchInput) { + this.searchInput.value = value; + } + } +} + diff --git a/static/components/SearchForm.js b/static/components/SearchForm.js new file mode 100644 index 0000000..039ecac --- /dev/null +++ b/static/components/SearchForm.js @@ -0,0 +1,8 @@ +// Search Form Component (simplified - now just a container) +export class SearchForm { + constructor() { + // This component is now just a container + // Actual search is handled by SearchBar component + } +} + diff --git a/static/components/Sidebar.js b/static/components/Sidebar.js new file mode 100644 index 0000000..92e251f --- /dev/null +++ b/static/components/Sidebar.js @@ -0,0 +1,123 @@ +// Sidebar Component +export class Sidebar { + constructor() { + this.sidebar = document.getElementById('sidebar'); + this.resizeHandle = document.getElementById('resizeHandle'); + this.menuToggle = null; + this.isResizing = false; + this.startX = 0; + this.startWidth = 0; + this.init(); + } + + init() { + this.createMenuToggle(); + this.setupResize(); + this.setupMobileBehavior(); + } + + createMenuToggle() { + // Create mobile menu toggle button + this.menuToggle = document.createElement('button'); + this.menuToggle.className = 'mobile-menu-toggle'; + this.menuToggle.innerHTML = '☰'; + this.menuToggle.setAttribute('aria-label', 'Toggle menu'); + this.menuToggle.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleMobile(); + }); + + // Create overlay for mobile + this.overlay = document.createElement('div'); + this.overlay.className = 'sidebar-overlay'; + this.overlay.addEventListener('click', () => this.closeMobile()); + + // Insert at the beginning of body + document.body.insertBefore(this.menuToggle, document.body.firstChild); + document.body.appendChild(this.overlay); + } + + setupResize() { + if (!this.resizeHandle || !this.sidebar) return; + + this.resizeHandle.addEventListener('mousedown', (e) => { + if (window.innerWidth <= 768) return; // Disable resize on mobile + + this.isResizing = true; + this.startX = e.clientX; + this.startWidth = parseInt(window.getComputedStyle(this.sidebar).width, 10); + document.addEventListener('mousemove', this.handleResize.bind(this)); + document.addEventListener('mouseup', this.stopResize.bind(this)); + e.preventDefault(); + }); + } + + handleResize(e) { + if (!this.isResizing) return; + const width = this.startWidth + e.clientX - this.startX; + const minWidth = 200; + const maxWidth = 600; + if (width >= minWidth && width <= maxWidth) { + this.sidebar.style.width = `${width}px`; + document.documentElement.style.setProperty('--sidebar-width', `${width}px`); + } + } + + stopResize() { + this.isResizing = false; + document.removeEventListener('mousemove', this.handleResize); + document.removeEventListener('mouseup', this.stopResize); + } + + setupMobileBehavior() { + // Close sidebar on window resize if switching to desktop + window.addEventListener('resize', () => { + if (window.innerWidth > 768) { + this.closeMobile(); + } + }); + + // Close sidebar when clicking on module items or search button on mobile + if (this.sidebar) { + this.sidebar.addEventListener('click', (e) => { + if (window.innerWidth <= 768) { + // Close if clicking on interactive elements (but not the toggle itself) + if (e.target.closest('.module-item') || + e.target.closest('.search-btn') || + e.target.closest('.nav-tab')) { + // Small delay to allow the click to register + setTimeout(() => this.closeMobile(), 100); + } + } + }); + } + } + + toggleMobile() { + const isOpen = this.sidebar?.classList.contains('open'); + if (isOpen) { + this.closeMobile(); + } else { + this.openMobile(); + } + } + + openMobile() { + this.sidebar?.classList.add('open'); + if (this.overlay) { + this.overlay.classList.add('active'); + } + // Prevent body scroll when sidebar is open + document.body.style.overflow = 'hidden'; + } + + closeMobile() { + this.sidebar?.classList.remove('open'); + if (this.overlay) { + this.overlay.classList.remove('active'); + } + // Restore body scroll + document.body.style.overflow = ''; + } +} + diff --git a/static/components/Tabs.js b/static/components/Tabs.js new file mode 100644 index 0000000..c18a892 --- /dev/null +++ b/static/components/Tabs.js @@ -0,0 +1,54 @@ +// Tabs Component +export class Tabs { + constructor(onTabChange) { + this.navTabs = document.querySelectorAll('.nav-tab'); + this.docsTab = document.getElementById('docsTab'); + this.courseTab = document.getElementById('courseTab'); + this.onTabChange = onTabChange; + this.init(); + } + + init() { + this.navTabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabName = tab.dataset.tab; + this.switchTab(tabName); + }); + }); + } + + switchTab(tabName) { + // Update active tab + this.navTabs.forEach(t => t.classList.remove('active')); + const activeTab = Array.from(this.navTabs).find(t => t.dataset.tab === tabName); + if (activeTab) { + activeTab.classList.add('active'); + } + + // Show/hide tab content + if (tabName === 'docs') { + if (this.docsTab) { + this.docsTab.style.display = 'flex'; + this.docsTab.classList.add('active'); + } + if (this.courseTab) { + this.courseTab.style.display = 'none'; + this.courseTab.classList.remove('active'); + } + } else { + if (this.docsTab) { + this.docsTab.style.display = 'none'; + this.docsTab.classList.remove('active'); + } + if (this.courseTab) { + this.courseTab.style.display = 'flex'; + this.courseTab.classList.add('active'); + } + + if (this.onTabChange) { + this.onTabChange(tabName); + } + } + } +} + diff --git a/static/components/ThemeToggle.js b/static/components/ThemeToggle.js new file mode 100644 index 0000000..60bf10d --- /dev/null +++ b/static/components/ThemeToggle.js @@ -0,0 +1,26 @@ +// Theme Toggle Component +export class ThemeToggle { + constructor() { + this.html = document.documentElement; + this.toggle = document.getElementById('themeToggle'); + this.init(); + } + + init() { + // Load saved theme + const savedTheme = localStorage.getItem('theme') || 'light'; + this.html.setAttribute('data-theme', savedTheme); + + if (this.toggle) { + this.toggle.addEventListener('click', () => this.toggleTheme()); + } + } + + toggleTheme() { + const currentTheme = this.html.getAttribute('data-theme'); + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + this.html.setAttribute('data-theme', newTheme); + localStorage.setItem('theme', newTheme); + } +} + diff --git a/static/index.css b/static/index.css new file mode 100644 index 0000000..d7d2a89 --- /dev/null +++ b/static/index.css @@ -0,0 +1,1436 @@ +/* Python Documentation Style - Inspired by docs.python.org */ +:root { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --text-primary: #1a1a1a; + --text-secondary: #4a4a4a; + --border-color: #e1e4e8; + --accent-color: #3776ab; + --accent-hover: #2d5f8a; + --code-bg: #f6f8fa; + --code-border: #e1e4e8; + --sidebar-bg: #fafbfc; + --sidebar-width: 280px; + --sidebar-collapsed-width: 60px; + --transition-speed: 0.2s; + --font-mono: 'Consolas', 'Monaco', 'Courier New', monospace; +} + +[data-theme="dark"] { + --bg-primary: #1e1e1e; + --bg-secondary: #252526; + --text-primary: #d4d4d4; + --text-secondary: #858585; + --border-color: #3e3e42; + --accent-color: #4a9eff; + --accent-hover: #6bb3ff; + --code-bg: #252526; + --code-border: #3e3e42; + --sidebar-bg: #252526; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + transition: background-color var(--transition-speed), color var(--transition-speed); + display: flex; + min-height: 100vh; + overflow-x: hidden; + line-height: 1.6; + font-size: 15px; + margin: 0; + padding: 0; +} + +/* Sidebar */ +.sidebar { + position: fixed; + left: 0; + top: 0; + height: 100vh; + width: var(--sidebar-width); + background-color: var(--sidebar-bg); + border-right: 1px solid var(--border-color); + display: flex; + flex-direction: column; + z-index: 1000; + overflow: hidden; +} + +.sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid var(--border-color); + min-height: 60px; + background-color: var(--bg-primary); +} + +.sidebar-title { + font-size: 1.1rem; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + + + +.sidebar-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + min-height: 0; +} + +/* Navigation Tabs */ +.nav-tabs { + display: flex; + border-bottom: 1px solid var(--border-color); + background-color: var(--bg-primary); +} + +.nav-tab { + flex: 1; + padding: 0.75rem 1rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + color: var(--text-secondary); + font-size: 0.9rem; + font-weight: 500; + transition: all var(--transition-speed); +} + +.nav-tab:hover { + color: var(--text-primary); + background-color: var(--bg-secondary); +} + +.nav-tab.active { + color: var(--accent-color); + border-bottom-color: var(--accent-color); +} + +.tab-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} + +/* Search Form */ +.search-form { + padding: 1rem; + border-bottom: 1px solid var(--border-color); + background-color: var(--bg-primary); +} + +/* Search Bar */ +.search-bar-wrapper { + position: relative; + width: 100%; +} + +.search-icon { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + font-size: 1.1rem; + z-index: 1; + pointer-events: none; + color: var(--text-secondary); +} + +.search-bar-input { + width: 100%; + padding: 0.75rem 1rem; + padding-left: 2.5rem; + border: 2px solid var(--border-color); + border-radius: 8px; + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: 0.95rem; + transition: all var(--transition-speed); + box-sizing: border-box; +} + +.search-bar-input:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(55, 118, 171, 0.1); +} + +.search-bar-input::placeholder { + color: var(--text-secondary); +} + +/* Search Suggestions */ +.search-suggestions { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + right: 0; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + max-height: 300px; + overflow-y: auto; + z-index: 1000; + display: none; + margin-top: 0.25rem; +} + +[data-theme="dark"] .search-suggestions { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.search-suggestions.active { + display: block; +} + +.search-suggestion { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + cursor: pointer; + transition: background-color var(--transition-speed); + border-bottom: 1px solid var(--border-color); +} + +.search-suggestion:last-child { + border-bottom: none; +} + +.search-suggestion:hover, +.search-suggestion.selected { + background-color: var(--bg-secondary); +} + +.search-suggestion .suggestion-icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.search-suggestion .suggestion-name { + flex: 1; + color: var(--text-primary); + font-size: 0.9rem; +} + +.search-suggestion .suggestion-name mark { + background-color: var(--accent-color); + color: white; + padding: 0.1rem 0.2rem; + border-radius: 2px; + font-weight: 600; +} + +.search-suggestion .suggestion-type { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + padding: 0.2rem 0.5rem; + background-color: var(--bg-secondary); + border-radius: 4px; +} + +/* Language Selector */ +.language-selector { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; +} + +.language-selector-button { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + cursor: pointer; + transition: all var(--transition-speed); + font-size: 0.9rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +[data-theme="dark"] .language-selector-button { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.language-selector-button:hover { + background-color: var(--bg-secondary); + border-color: var(--accent-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.language-emoji { + font-size: 1.2rem; + line-height: 1; +} + +.language-name { + color: var(--text-primary); + font-weight: 500; +} + +.language-arrow { + color: var(--text-secondary); + font-size: 0.7rem; + transition: transform var(--transition-speed); +} + +.language-selector-button:active .language-arrow { + transform: rotate(180deg); +} + +.language-dropdown.active + .language-selector-button .language-arrow, +.language-selector:has(.language-dropdown.active) .language-arrow { + transform: rotate(180deg); +} + +.language-dropdown { + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 200px; + max-height: 400px; + overflow-y: auto; + display: none; + z-index: 1001; +} + +[data-theme="dark"] .language-dropdown { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.language-dropdown.active { + display: block; +} + +.language-option { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 1rem; + background: none; + border: none; + text-align: left; + cursor: pointer; + transition: background-color var(--transition-speed); + border-bottom: 1px solid var(--border-color); +} + +.language-option:last-child { + border-bottom: none; +} + +.language-option:hover { + background-color: var(--bg-secondary); +} + +.language-option.active { + background-color: var(--accent-color); + color: white; +} + +.language-option.active .language-name, +.language-option.active .language-name-en { + color: white; +} + +.language-name-en { + margin-left: auto; + font-size: 0.85rem; + color: var(--text-secondary); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .language-selector { + top: 0.75rem; + right: 0.75rem; + } + + .language-selector-button { + padding: 0.4rem 0.6rem; + font-size: 0.85rem; + } + + .language-name { + display: none; + } + + .language-dropdown { + right: 0; + min-width: 180px; + } + + .search-suggestions { + max-height: 250px; + } +} + +.input-group { + margin-bottom: 0.75rem; +} + +.input-group:last-child { + margin-bottom: 0; +} + +.input-group label { + display: block; + margin-bottom: 0.25rem; + font-weight: 500; + color: var(--text-primary); + font-size: 0.85rem; +} + +.input-group input, +.input-group select { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 4px; + background-color: var(--bg-primary); + color: var(--text-primary); + font-size: 0.9rem; + transition: border-color var(--transition-speed); +} + +.input-group input:focus, +.input-group select:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(55, 118, 171, 0.1); +} + +.search-btn { + width: 100%; + padding: 0.6rem; + background-color: var(--accent-color); + color: white; + border: none; + border-radius: 4px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: background-color var(--transition-speed); + margin-top: 0.5rem; +} + +.search-btn:hover { + background-color: var(--accent-hover); +} + +.search-btn:disabled { + background-color: var(--text-secondary); + cursor: not-allowed; +} + +/* Module List */ +.module-list-container { + flex: 1; + overflow-y: auto; + padding: 1rem; + min-height: 0; +} + +.module-list-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border-color); + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 0.75rem; +} + +.module-section { + margin-bottom: 1.5rem; +} + +.module-section-title { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.module-items-list { + display: flex; + flex-direction: column; + gap: 0.15rem; +} + +.module-item { + width: 100%; + text-align: left; + padding: 0.4rem 0.6rem; + background: none; + border: none; + border-radius: 3px; + cursor: pointer; + color: var(--text-primary); + font-size: 0.85rem; + transition: all var(--transition-speed); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.module-item:hover { + background-color: var(--bg-secondary); + color: var(--accent-color); +} + +.module-item:active { + background-color: var(--accent-color); + color: white; +} + +/* Theme Toggle */ +.theme-toggle-container { + padding: 1rem; + border-top: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + background-color: var(--bg-primary); +} + +.theme-toggle-label { + font-size: 0.85rem; + color: var(--text-secondary); + white-space: nowrap; +} + +.theme-toggle { + background: none; + border: 1px solid var(--border-color); + cursor: pointer; + padding: 0.5rem; + border-radius: 4px; + color: var(--text-primary); + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-speed); + width: 2.5rem; + height: 2.5rem; +} + +.theme-toggle:hover { + background-color: var(--bg-secondary); + border-color: var(--accent-color); +} + +.theme-toggle__inner-moon { + transition: transform var(--transition-speed); +} + +[data-theme="dark"] .theme-toggle__inner-moon { + transform: rotate(180deg); +} + +.theme-toggle-sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} + +.resize-handle { + position: absolute; + right: 0; + top: 0; + width: 4px; + height: 100%; + cursor: ew-resize; + background-color: transparent; + transition: background-color var(--transition-speed); +} + +.resize-handle:hover { + background-color: var(--accent-color); +} + + +/* Main Content */ +.content { + margin-left: var(--sidebar-width); + padding: 2rem 3rem; + max-width: 100%; + width: 100%; + transition: margin-left var(--transition-speed); + background-color: var(--bg-primary); +} + + +/* Typography - Python Docs Style */ +h1 { + font-size: 2.5rem; + font-weight: 400; + margin-bottom: 1rem; + color: var(--text-primary); + line-height: 1.2; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.5rem; +} + +h2 { + font-size: 1.75rem; + font-weight: 400; + margin-top: 2rem; + margin-bottom: 1rem; + color: var(--text-primary); + line-height: 1.3; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.3rem; +} + +h3 { + font-size: 1.25rem; + font-weight: 500; + margin-top: 1.5rem; + margin-bottom: 0.75rem; + color: var(--text-primary); +} + +p { + margin-bottom: 1rem; + color: var(--text-primary); + line-height: 1.7; +} + +/* Documentation Display - Python Docs Style */ +.results-container { + max-width: 900px; +} + +.doc-header { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.doc-title { + font-size: 2rem; + font-weight: 400; + color: var(--text-primary); + margin-bottom: 0.5rem; +} + +.doc-meta { + font-size: 0.9rem; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.type-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + background-color: var(--bg-secondary); + color: var(--text-primary); + border-radius: 3px; + font-size: 0.75rem; + border: 1px solid var(--border-color); + font-weight: 500; +} + +.doc-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + background-color: var(--accent-color); + color: white; + border-radius: 3px; + font-size: 0.75rem; + font-weight: 500; +} + +.doc-signature { + font-family: var(--font-mono); + background-color: var(--code-bg); + padding: 1rem; + border-radius: 4px; + margin: 1.5rem 0; + border: 1px solid var(--code-border); + color: var(--text-primary); + font-size: 0.9rem; + overflow-x: auto; +} + +.doc-section { + margin-bottom: 2rem; +} + +.doc-section-title { + font-size: 1.1rem; + font-weight: 500; + margin-bottom: 0.75rem; + color: var(--text-primary); +} + +.doc-text { + white-space: pre-wrap; + line-height: 1.8; + color: var(--text-primary); + font-size: 0.95rem; + margin-bottom: 1rem; +} + +/* Code blocks */ +.doc-text code, +.doc-signature code { + font-family: var(--font-mono); + background-color: var(--code-bg); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-size: 0.9em; + border: 1px solid var(--code-border); +} + +/* Welcome Message */ +.welcome-message { + text-align: center; + padding: 4rem 2rem; + color: var(--text-secondary); + max-width: 600px; + margin: 0 auto; +} + +.welcome-message h1 { + border: none; + margin-bottom: 1.5rem; + color: var(--text-primary); +} + +.welcome-message p { + margin-bottom: 0.75rem; + line-height: 1.8; + font-size: 1rem; +} + +/* Course Content */ +.course-content { + max-width: 900px; +} + +.course-section { + margin-bottom: 3rem; + scroll-margin-top: 2rem; +} + +.course-section h2 { + margin-top: 0; +} + +.markdown-content { + line-height: 1.8; +} + +.markdown-content h1, +.markdown-content h2, +.markdown-content h3, +.markdown-content h4 { + margin-top: 2rem; + margin-bottom: 1rem; + color: var(--text-primary); + font-weight: 500; +} + +.markdown-content h1 { + font-size: 2rem; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.5rem; +} + +.markdown-content h2 { + font-size: 1.5rem; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.3rem; +} + +.markdown-content h3 { + font-size: 1.25rem; +} + +.markdown-content h4 { + font-size: 1.1rem; +} + +.markdown-content p { + margin-bottom: 1rem; + color: var(--text-primary); +} + +.markdown-content ul, +.markdown-content ol { + margin-bottom: 1rem; + padding-left: 2rem; +} + +.markdown-content li { + margin-bottom: 0.5rem; + color: var(--text-primary); +} + +.markdown-content code { + font-family: var(--font-mono); + background-color: var(--code-bg); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-size: 0.9em; + border: 1px solid var(--code-border); + color: var(--text-primary); +} + +.markdown-content pre { + background-color: var(--code-bg); + border: 1px solid var(--code-border); + border-radius: 4px; + padding: 1rem; + overflow-x: auto; + margin: 1.5rem 0; +} + +.markdown-content pre code { + background: none; + border: none; + padding: 0; + font-size: 0.9rem; + line-height: 1.5; +} + +.markdown-content blockquote { + border-left: 4px solid var(--accent-color); + padding-left: 1rem; + margin: 1rem 0; + color: var(--text-secondary); + font-style: italic; +} + +.markdown-content a { + color: var(--accent-color); + text-decoration: none; +} + +.markdown-content a:hover { + text-decoration: underline; +} + +.course-item { + margin-bottom: 1.5rem; + padding-left: 1.5rem; + position: relative; +} + +.course-item::before { + content: "→"; + position: absolute; + left: 0; + color: var(--accent-color); + font-weight: bold; +} + +.course-item a { + color: var(--accent-color); + text-decoration: none; + font-weight: 500; +} + +.course-item a:hover { + text-decoration: underline; +} + +/* Error Message */ +.error-message { + background-color: #fee; + border: 1px solid #fcc; + color: #c33; + padding: 1rem; + border-radius: 4px; + margin-top: 1rem; +} + +[data-theme="dark"] .error-message { + background-color: #3a1f1f; + border-color: #5a2f2f; + color: #ff6b6b; +} + +.loading { + text-align: center; + padding: 3rem; + color: var(--text-secondary); + font-size: 1rem; +} + +/* Loading Progress Container */ +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + min-height: 400px; + gap: 2rem; +} + +/* Spinner Animation */ +.loading-spinner { + position: relative; + width: 64px; + height: 64px; +} + +.spinner-ring { + position: absolute; + width: 100%; + height: 100%; + border: 4px solid transparent; + border-top-color: var(--accent-color); + border-radius: 50%; + animation: spin 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; +} + +.spinner-ring:nth-child(1) { + animation-delay: -0.45s; + border-top-color: var(--accent-color); + opacity: 1; +} + +.spinner-ring:nth-child(2) { + animation-delay: -0.3s; + border-top-color: var(--accent-hover); + opacity: 0.8; + width: 80%; + height: 80%; + top: 10%; + left: 10%; +} + +.spinner-ring:nth-child(3) { + animation-delay: -0.15s; + border-top-color: var(--accent-color); + opacity: 0.6; + width: 60%; + height: 60%; + top: 20%; + left: 20%; +} + +.spinner-ring:nth-child(4) { + animation-delay: 0s; + border-top-color: var(--accent-hover); + opacity: 0.4; + width: 40%; + height: 40%; + top: 30%; + left: 30%; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.loading-text { + font-size: 1.1rem; + color: var(--text-primary); + font-weight: 500; + text-align: center; + margin-top: 1rem; +} + +/* Qt-style Progress Bar */ +.progress-container { + width: 100%; + max-width: 400px; + margin-top: 1rem; +} + +.progress-bar { + position: relative; + width: 100%; + height: 24px; + background-color: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 12px; + overflow: hidden; + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.progress-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 0%; + background: linear-gradient( + 90deg, + var(--accent-color) 0%, + var(--accent-hover) 50%, + var(--accent-color) 100% + ); + background-size: 200% 100%; + border-radius: 11px; + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + animation: progressShine 2s linear infinite; + box-shadow: 0 0 8px rgba(55, 118, 171, 0.3); +} + +[data-theme="dark"] .progress-fill { + background: linear-gradient( + 90deg, + var(--accent-color) 0%, + #6bb3ff 50%, + var(--accent-color) 100% + ); + background-size: 200% 100%; + box-shadow: 0 0 12px rgba(74, 158, 255, 0.4); +} + +.progress-shine { + position: absolute; + top: 0; + left: 0; + width: 30%; + height: 100%; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.5) 50%, + transparent 100% + ); + animation: shine 2.5s ease-in-out infinite; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s; +} + +.progress-bar .progress-shine { + opacity: 0; +} + +.progress-bar.has-progress .progress-shine { + opacity: 1; +} + +@keyframes progressShine { + 0% { + background-position: 200% 0; + } + 100% { + background-position: -200% 0; + } +} + +@keyframes shine { + 0% { + transform: translateX(-100%); + } + 50% { + transform: translateX(100%); + } + 100% { + transform: translateX(100%); + } +} + +.progress-text { + text-align: center; + margin-top: 0.75rem; + font-size: 0.9rem; + color: var(--text-secondary); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +/* Responsive adjustments for loading */ +@media (max-width: 768px) { + .loading-container { + padding: 3rem 1.5rem; + min-height: 300px; + gap: 1.5rem; + } + + .loading-spinner { + width: 48px; + height: 48px; + } + + .spinner-ring { + border-width: 3px; + } + + .loading-text { + font-size: 1rem; + } + + .progress-container { + max-width: 100%; + } + + .progress-bar { + height: 20px; + } + + .progress-text { + font-size: 0.85rem; + margin-top: 0.5rem; + } +} + +.loading-modules { + text-align: center; + padding: 1rem; + color: var(--text-secondary); + font-size: 0.9rem; +} + +/* Scrollbar styling */ +.sidebar-content::-webkit-scrollbar, +.content::-webkit-scrollbar { + width: 8px; +} + +.sidebar-content::-webkit-scrollbar-track, +.content::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +.sidebar-content::-webkit-scrollbar-thumb, +.content::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.sidebar-content::-webkit-scrollbar-thumb:hover, +.content::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Mobile Menu Toggle */ +.mobile-menu-toggle { + display: none; + position: fixed; + top: 1rem; + left: 1rem; + z-index: 1001; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + color: var(--text-primary); + padding: 0.75rem; + border-radius: 4px; + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + transition: all var(--transition-speed); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.mobile-menu-toggle:hover { + background-color: var(--bg-secondary); + border-color: var(--accent-color); +} + +[data-theme="dark"] .mobile-menu-toggle { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +/* Sidebar Overlay */ +.sidebar-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 999; + opacity: 0; + transition: opacity 0.3s ease-in-out; + pointer-events: none; +} + +@media (max-width: 768px) { + .sidebar-overlay.active { + display: block; + opacity: 1; + pointer-events: all; + } + + [data-theme="dark"] .sidebar-overlay { + background-color: rgba(0, 0, 0, 0.7); + } +} + +/* Responsive */ +@media (max-width: 768px) { + .mobile-menu-toggle { + display: block; + } + + .sidebar { + transform: translateX(-100%); + transition: transform 0.3s ease-in-out; + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1); + } + + .sidebar.open { + transform: translateX(0); + box-shadow: 2px 0 12px rgba(0, 0, 0, 0.15); + } + + .content { + margin-left: 0; + padding: 1rem; + width: 100%; + } + + .sidebar-header { + padding: 0.75rem; + min-height: 50px; + } + + .sidebar-title { + font-size: 1rem; + } + + .search-form { + padding: 0.75rem; + } + + .module-list-container { + padding: 0.75rem; + } + + .theme-toggle-container { + padding: 0.75rem; + } + + .resize-handle { + display: none; + } + + h1 { + font-size: 1.75rem; + line-height: 1.3; + } + + h2 { + font-size: 1.5rem; + } + + h3 { + font-size: 1.1rem; + } + + .doc-title { + font-size: 1.5rem; + } + + .welcome-message { + padding: 2rem 1rem; + } + + .welcome-message h1 { + font-size: 1.75rem; + } + + .doc-signature { + padding: 0.75rem; + font-size: 0.85rem; + overflow-x: auto; + } + + .markdown-content { + overflow-wrap: break-word; + word-wrap: break-word; + } + + .markdown-content pre { + padding: 0.75rem; + overflow-x: auto; + font-size: 0.85rem; + } + + .markdown-content code { + font-size: 0.85rem; + } + + .nav-tab { + padding: 0.6rem 0.75rem; + font-size: 0.85rem; + } +} + +/* Tablet */ +@media (min-width: 769px) and (max-width: 1024px) { + .content { + padding: 1.5rem 2rem; + } + + .sidebar { + width: 260px; + } + + :root { + --sidebar-width: 260px; + } +} + +/* Small mobile */ +@media (max-width: 480px) { + .content { + padding: 0.75rem; + } + + .sidebar { + width: 85vw; + max-width: 300px; + } + + .mobile-menu-toggle { + top: 0.75rem; + left: 0.75rem; + padding: 0.6rem; + font-size: 1.25rem; + } + + h1 { + font-size: 1.5rem; + } + + .doc-title { + font-size: 1.25rem; + } + + .welcome-message { + padding: 1.5rem 0.75rem; + } + + .welcome-message h1 { + font-size: 1.5rem; + } + + .search-form { + padding: 0.5rem; + } + + .input-group { + margin-bottom: 0.5rem; + } + + .input-group input, + .input-group select { + padding: 0.4rem 0.6rem; + font-size: 0.85rem; + } + + .search-btn { + padding: 0.5rem; + font-size: 0.85rem; + margin-top: 0.4rem; + } +} + +/* Prevent horizontal scroll on content */ +.content { + overflow-x: hidden; + width: 100%; + box-sizing: border-box; +} + +/* Improve touch targets on mobile */ +@media (max-width: 768px) { + .module-item, + .nav-tab, + .search-btn { + min-height: 44px; + touch-action: manipulation; + } + + .theme-toggle { + min-width: 44px; + min-height: 44px; + } +} + +/* Fix overflow issues */ +.sidebar-content { + -webkit-overflow-scrolling: touch; +} + +.module-list-container { + -webkit-overflow-scrolling: touch; +} + +/* Ensure proper text wrapping */ +.doc-text, +.markdown-content p { + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; +} + +/* Fix code block overflow */ +.doc-signature, +.markdown-content pre { + word-break: break-all; + white-space: pre-wrap; +} + +/* Improve focus states for accessibility */ +@media (max-width: 768px) { + .module-item:focus, + .nav-tab:focus, + .search-btn:focus, + .theme-toggle:focus, + .mobile-menu-toggle:focus { + outline: 2px solid var(--accent-color); + outline-offset: 2px; + } +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..501cf1e --- /dev/null +++ b/static/index.html @@ -0,0 +1,84 @@ + + + + + +Python Documentation - Auto Translator + + + + + + + +
+
+
+

Python Documentation

+

Search for any Python object in the sidebar to view its documentation.

+

Select a language to automatically translate the documentation.

+
+
+
+ + + diff --git a/static/index.js b/static/index.js new file mode 100644 index 0000000..193d7f7 --- /dev/null +++ b/static/index.js @@ -0,0 +1,461 @@ +// Theme management +const themeToggle = document.getElementById('themeToggle'); +const html = document.documentElement; + +// Load saved theme +const savedTheme = localStorage.getItem('theme') || 'light'; +html.setAttribute('data-theme', savedTheme); + +themeToggle.addEventListener('click', () => { + const currentTheme = html.getAttribute('data-theme'); + const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + html.setAttribute('data-theme', newTheme); + localStorage.setItem('theme', newTheme); +}); + +// Sidebar reference (no collapse functionality) +const sidebar = document.getElementById('sidebar'); + +// Sidebar resizing +const resizeHandle = document.getElementById('resizeHandle'); +let isResizing = false; +let startX = 0; +let startWidth = 0; + +resizeHandle.addEventListener('mousedown', (e) => { + isResizing = true; + startX = e.clientX; + startWidth = parseInt(window.getComputedStyle(sidebar).width, 10); + document.addEventListener('mousemove', handleResize); + document.addEventListener('mouseup', stopResize); + e.preventDefault(); +}); + +function handleResize(e) { + if (!isResizing) return; + const width = startWidth + e.clientX - startX; + const minWidth = 200; + const maxWidth = 600; + if (width >= minWidth && width <= maxWidth) { + sidebar.style.width = `${width}px`; + document.documentElement.style.setProperty('--sidebar-width', `${width}px`); + } +} + +function stopResize() { + isResizing = false; + document.removeEventListener('mousemove', handleResize); + document.removeEventListener('mouseup', stopResize); +} + +// Tab switching +const navTabs = document.querySelectorAll('.nav-tab'); +const docsTab = document.getElementById('docsTab'); +const courseTab = document.getElementById('courseTab'); + +navTabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabName = tab.dataset.tab; + + // Update active tab + navTabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + + // Show/hide tab content + if (tabName === 'docs') { + docsTab.style.display = 'block'; + courseTab.style.display = 'none'; + } else { + docsTab.style.display = 'none'; + courseTab.style.display = 'block'; + loadCourseContent(); + } + }); +}); + +// API interaction +const objectInput = document.getElementById('objectInput'); +const langSelect = document.getElementById('langSelect'); +const searchBtn = document.getElementById('searchBtn'); +const resultsContainer = document.getElementById('results'); +const moduleListContainer = document.getElementById('moduleList'); +const courseListContainer = document.getElementById('courseList'); + +// Current state +let currentObject = ''; +let currentLang = ''; + +// Load module list +async function loadModuleList() { + try { + const response = await fetch('/modules'); + const data = await response.json(); + + const list = document.createElement('div'); + list.className = 'module-items'; + + // Add builtins section + if (data.builtins && data.builtins.length > 0) { + const builtinsSection = document.createElement('div'); + builtinsSection.className = 'module-section'; + const builtinsTitle = document.createElement('h4'); + builtinsTitle.textContent = 'Builtins'; + builtinsTitle.className = 'module-section-title'; + builtinsSection.appendChild(builtinsTitle); + + const builtinsList = document.createElement('div'); + builtinsList.className = 'module-items-list'; + data.builtins.slice(0, 20).forEach(item => { + const btn = createModuleButton(item.full_name || `builtins.${item.name}`, item.name); + builtinsList.appendChild(btn); + }); + builtinsSection.appendChild(builtinsList); + list.appendChild(builtinsSection); + } + + // Add modules section + if (data.modules && data.modules.length > 0) { + const modulesSection = document.createElement('div'); + modulesSection.className = 'module-section'; + const modulesTitle = document.createElement('h4'); + modulesTitle.textContent = 'Standard Library'; + modulesTitle.className = 'module-section-title'; + modulesSection.appendChild(modulesTitle); + + const modulesList = document.createElement('div'); + modulesList.className = 'module-items-list'; + data.modules.forEach(item => { + const btn = createModuleButton(item.name, item.name); + modulesList.appendChild(btn); + }); + modulesSection.appendChild(modulesList); + list.appendChild(modulesSection); + } + + moduleListContainer.innerHTML = ''; + moduleListContainer.appendChild(list); + } catch (error) { + moduleListContainer.innerHTML = `
Error loading modules: ${error.message}
`; + } +} + +function createModuleButton(fullName, displayName) { + const btn = document.createElement('button'); + btn.className = 'module-item'; + btn.textContent = displayName; + btn.title = fullName; + btn.addEventListener('click', () => { + objectInput.value = fullName; + currentObject = fullName; + fetchDocumentation(); + }); + return btn; +} + +// Load modules on page load +loadModuleList(); + +// Auto-translate when language changes +langSelect.addEventListener('change', () => { + if (currentObject) { + currentLang = langSelect.value; + fetchDocumentation(); + } +}); + +async function fetchDocumentation() { + const objectName = objectInput.value.trim(); + const targetLang = langSelect.value; + + if (!objectName) { + resultsContainer.innerHTML = '
Please enter a Python object name.
'; + return; + } + + currentObject = objectName; + currentLang = targetLang; + + searchBtn.disabled = true; + searchBtn.textContent = 'Loading...'; + resultsContainer.innerHTML = '
Loading documentation...
'; + + try { + const params = new URLSearchParams({ object: objectName }); + if (targetLang) { + params.append('lang', targetLang); + } + + const response = await fetch(`/docs?${params}`); + const data = await response.json(); + + if (data.error) { + resultsContainer.innerHTML = `
Error: ${data.error}
`; + return; + } + + displayResults(data, targetLang); + + } catch (error) { + resultsContainer.innerHTML = `
Network error: ${error.message}
`; + } finally { + searchBtn.disabled = false; + searchBtn.textContent = 'Get Documentation'; + } +} + +function displayResults(data, targetLang) { + // Python docs style - no card, clean layout + const wrapper = document.createElement('div'); + + // Header with title + const header = document.createElement('div'); + header.className = 'doc-header'; + + const title = document.createElement('h1'); + title.className = 'doc-title'; + title.textContent = data.object_name; + + const meta = document.createElement('div'); + meta.className = 'doc-meta'; + meta.innerHTML = ` + ${data.object_type || 'unknown'} + ${data.cached ? 'Cached' : ''} + `; + + header.appendChild(title); + header.appendChild(meta); + wrapper.appendChild(header); + + // Signature + if (data.signature) { + const signature = document.createElement('div'); + signature.className = 'doc-signature'; + signature.textContent = data.signature; + wrapper.appendChild(signature); + } + + // Main documentation content + const docText = data.translated || data.original; + + if (docText) { + const docSection = document.createElement('section'); + docSection.className = 'doc-section'; + + const docTextEl = document.createElement('div'); + docTextEl.className = 'doc-text'; + docTextEl.textContent = docText; + + docSection.appendChild(docTextEl); + wrapper.appendChild(docSection); + } + + // Show original if translation exists (collapsible) + if (data.translated && data.original) { + const originalSection = document.createElement('details'); + originalSection.className = 'doc-section'; + originalSection.innerHTML = ` + + Original Documentation (English) + +
${data.original}
+ `; + wrapper.appendChild(originalSection); + } + + if (!data.original && !data.translated) { + const noDoc = document.createElement('div'); + noDoc.className = 'doc-text'; + noDoc.textContent = 'No documentation available for this object.'; + wrapper.appendChild(noDoc); + } + + resultsContainer.innerHTML = ''; + resultsContainer.appendChild(wrapper); +} + +// Load course content +async function loadCourseContent() { + if (courseListContainer.querySelector('.course-loaded')) { + return; // Already loaded + } + + try { + const response = await fetch('/course'); + const data = await response.json(); + + const list = document.createElement('div'); + list.className = 'module-items'; + list.classList.add('course-loaded'); + + if (data.sections && data.sections.length > 0) { + data.sections.forEach(section => { + const sectionDiv = document.createElement('div'); + sectionDiv.className = 'module-section'; + + const sectionTitle = document.createElement('h4'); + sectionTitle.textContent = section.title; + sectionTitle.className = 'module-section-title'; + sectionDiv.appendChild(sectionTitle); + + const itemsList = document.createElement('div'); + itemsList.className = 'module-items-list'; + + // Create clickable navigation item + const navBtn = document.createElement('button'); + navBtn.className = 'module-item'; + navBtn.textContent = section.title; + navBtn.addEventListener('click', () => { + // Scroll to section in main content + const sectionId = section.id || section.title.toLowerCase().replace(/\s+/g, '-'); + const sectionElement = document.getElementById(`section-${sectionId}`); + if (sectionElement) { + sectionElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } else { + // Try to find by heading ID + const headingId = section.title.toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim(); + const headingElement = document.getElementById(headingId); + if (headingElement) { + headingElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + }); + itemsList.appendChild(navBtn); + + // Add subsections if any + if (section.subsections && section.subsections.length > 0) { + section.subsections.forEach(subsection => { + const subBtn = document.createElement('button'); + subBtn.className = 'module-item'; + subBtn.style.paddingLeft = '1.5rem'; + subBtn.textContent = subsection.title; + subBtn.addEventListener('click', () => { + const subId = subsection.id || subsection.title.toLowerCase().replace(/\s+/g, '-'); + const subElement = document.getElementById(`subsection-${subId}`); + if (subElement) { + subElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + itemsList.appendChild(subBtn); + }); + } + + sectionDiv.appendChild(itemsList); + list.appendChild(sectionDiv); + }); + } + + courseListContainer.innerHTML = ''; + courseListContainer.appendChild(list); + + // Also display course content in main area + displayCourseContent(data); + } catch (error) { + courseListContainer.innerHTML = `
Error loading course: ${error.message}
`; + } +} + +function displayCourseContent(courseData) { + const wrapper = document.createElement('div'); + wrapper.className = 'course-content'; + + const title = document.createElement('h1'); + title.textContent = courseData.title || 'Python Course'; + wrapper.appendChild(title); + + if (courseData.sections && courseData.sections.length > 0) { + courseData.sections.forEach(section => { + const sectionDiv = document.createElement('section'); + sectionDiv.className = 'course-section'; + sectionDiv.id = `section-${section.id || section.title.toLowerCase().replace(/\s+/g, '-')}`; + + // Parse and render markdown + if (section.markdown) { + // Configure marked options + marked.setOptions({ + highlight: function(code, lang) { + if (lang && hljs.getLanguage(lang)) { + try { + return hljs.highlight(code, { language: lang }).value; + } catch (err) { + console.error('Highlight error:', err); + } + } + return hljs.highlightAuto(code).value; + }, + breaks: true, + gfm: true + }); + + // Convert markdown to HTML + const htmlContent = marked.parse(section.markdown); + + // Create a container for the markdown content + const contentDiv = document.createElement('div'); + contentDiv.className = 'markdown-content'; + contentDiv.innerHTML = htmlContent; + + // Add IDs to headings for navigation + contentDiv.querySelectorAll('h1, h2, h3, h4').forEach((heading) => { + const text = heading.textContent.trim(); + const id = text.toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .trim(); + heading.id = id; + }); + + // Highlight code blocks + contentDiv.querySelectorAll('pre code').forEach((block) => { + hljs.highlightElement(block); + }); + + sectionDiv.appendChild(contentDiv); + } else if (section.content && section.content.length > 0) { + // Fallback to old format + section.content.forEach(item => { + if (item.startsWith('```')) { + const codeDiv = document.createElement('pre'); + codeDiv.className = 'doc-signature'; + codeDiv.textContent = item.replace(/```python\n?/g, '').replace(/```/g, '').trim(); + sectionDiv.appendChild(codeDiv); + } else { + const itemDiv = document.createElement('p'); + itemDiv.className = 'doc-text'; + itemDiv.textContent = item; + sectionDiv.appendChild(itemDiv); + } + }); + } + + wrapper.appendChild(sectionDiv); + }); + } + + resultsContainer.innerHTML = ''; + resultsContainer.appendChild(wrapper); +} + +function getLanguageName(langCode) { + const langMap = { + 'de': 'German', + 'fr': 'French', + 'es': 'Spanish', + 'it': 'Italian', + 'pt': 'Portuguese', + 'ru': 'Russian' + }; + return langMap[langCode] || langCode; +} + +searchBtn.addEventListener('click', fetchDocumentation); + +objectInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + fetchDocumentation(); + } +});