import os import re import time from pathlib import Path from lupa import LuaRuntime, LuaError from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler from log.Logger import Logger from .PluginFSHandler import PluginFSHandler import json from .luarails import get_file_contents PLUGINS_DIR = Path(__file__).parent / "plugins" HTML_DIR = Path(__file__).parent / "../html" MARKDOWN_DIR = Path(__file__).parent / ".." / "markdown" class PluginManager: def __init__(self): self.lua = LuaRuntime(unpack_returned_tuples=True) self.plugins = {} # name -> dict{path, lua_module, hooks, routes} self.routes = { "GET": {}, # path -> list of (plugin_name, lua_fn, priority) "POST": {} # path -> list of (plugin_name, lua_fn, priority) } self.hooks = {} # hook_name -> list of (plugin_name, lua_fn, priority) # Create directories PLUGINS_DIR.mkdir(exist_ok=True) HTML_DIR.mkdir(exist_ok=True) MARKDOWN_DIR.mkdir(exist_ok=True) # Expose helpers to Lua self._setup_lua_globals() self._start_watcher() def _setup_lua_globals(self): """Set up all Lua global functions and guardrails""" from .Actions import Actions g = self.lua.globals() self.actions = Actions() # Route and hook registration with priority support g.add_route = self._expose_add_route g.add_get_route = lambda path, fn, priority=50: self._expose_add_route_method(path, fn, "GET", priority) g.add_post_route = lambda path, fn, priority=50: self._expose_add_route_method(path, fn, "POST", priority) g.register_hook = self._expose_register_hook # Logging g.log = lambda msg: Logger.log_lua_info(f"PLUGIN => {msg}") g.log_warn = lambda msg: Logger.log_lua_warning(f"PLUGIN => {msg}") g.log_error = lambda msg: Logger.log_lua_error(f"PLUGIN => {msg}") # File operations g.read_file = self.actions._safe_read_file g.write_file = self.actions._safe_write_file g.file_exists = self.actions._file_exists g.file_size = self.actions._file_size # Convert Python list to Lua table g.list_directory = lambda path: self.lua.table_from(self.actions._list_directory(path)) # HTML operations g.read_html = lambda fn: self.actions._read_content(self.actions.html_dir, fn) g.write_html = lambda fn, c: self.actions._write_content(self.actions.html_dir, fn, c) # Convert Python list to Lua table so # operator works g.list_html_files = lambda: self.lua.table_from(self.actions._list_files(self.actions.html_dir, ".html")) # Markdown operations g.read_markdown = lambda fn: self.actions._read_content(self.actions.markdown_dir, fn) g.write_markdown = lambda fn, c: self.actions._write_content(self.actions.markdown_dir, fn, c) # Convert Python list to Lua table so # operator works g.list_markdown_files = lambda: self.lua.table_from(self.actions._list_files(self.actions.markdown_dir, ".md")) # HTML manipulation helpers g.html_find_tag = self.actions._html_find_tag g.html_replace_tag = self.actions._html_replace_tag g.html_insert_before = self.actions._html_insert_before g.html_insert_after = self.actions._html_insert_after g.html_wrap_content = self.actions._html_wrap_content g.html_remove_tag = self.actions._html_remove_tag g.html_add_class = self.actions._html_add_class g.html_add_attribute = self.actions._html_add_attribute # Markdown manipulation helpers g.md_add_header = self.actions._md_add_header g.md_replace_section = self.actions._md_replace_section g.md_append_content = self.actions._md_append_content g.md_prepend_content = self.actions._md_prepend_content g.md_insert_at_position = self.actions._md_insert_at_position g.md_find_section = self.actions._md_find_section g.md_remove_section = self.actions._md_remove_section g.md_add_list_item = self.actions._md_add_list_item g.md_wrap_code_block = self.actions._md_wrap_code_block # JSON helpers g.table_to_json = self.actions._table_to_json g.json_to_table = self.actions._json_to_table g.json_parse = self.actions._json_parse g.json_stringify = self.actions._json_stringify # String utilities - also convert list results to Lua tables g.string_split = lambda text, delim: self.lua.table_from(self.actions._string_split(text, delim)) g.string_join = self.actions._string_join g.string_replace = self.actions._string_replace g.string_match = self.actions._string_match g.string_match_all = lambda text, pattern: self.lua.table_from(self.actions._string_match_all(text, pattern)) # Guardrails - predefined safe patterns self._setup_lua_guardrails() def _setup_lua_guardrails(self): try: self.lua.execute(get_file_contents("luarails.lua")) except LuaError as e: Logger.log_lua_error(f"Failed to initialize Lua guardrails: {e}") """ Lifecycle of Plugin """ def load_all(self): for p in PLUGINS_DIR.glob("*.lua"): self.load_plugin(p) def load_plugin(self, path: Path): name = path.name try: code = path.read_text(encoding="utf-8") self._current_plugin = name lua_module = self.lua.execute(code) if lua_module is None: lua_module = {} self.plugins[name] = {"path": path, "module": lua_module} Logger.log_lua_info(f"Loaded plugin: {name}") except LuaError as e: Logger.log_lua_error(f"Lua error while loading {name}: {e}") except Exception as e: Logger.log_lua_error(f"Error loading plugin {name}: {e}") finally: self._current_plugin = None def reload_plugin(self, path: Path): name = path.name self.unload_plugin(name) time.sleep(0.05) if path.exists(): self.load_plugin(path) else: Logger.log_lua_warning(f"Tried to reload {name}, but file no longer exists") def unload_plugin(self, name: str): # Remove routes from this plugin (both GET and POST) for method in ["GET", "POST"]: for route_path in list(self.routes[method].keys()): self.routes[method][route_path] = [ x for x in self.routes[method][route_path] if x[0] != name ] if not self.routes[method][route_path]: del self.routes[method][route_path] # Remove hooks from this plugin for hook_name in list(self.hooks.keys()): self.hooks[hook_name] = [x for x in self.hooks[hook_name] if x[0] != name] if not self.hooks[hook_name]: del self.hooks[hook_name] if name in self.plugins: del self.plugins[name] Logger.log_lua_info(f"Unloaded plugin {name}") else: Logger.log_lua_warning(f"Tried to unload {name}, but it was not loaded") """ Expose API routes """ def _expose_add_route(self, path, lua_fn, priority=50): """Legacy: Called from Lua as add_route(path, function(req) ... end) - defaults to GET""" return self._expose_add_route_method(path, lua_fn, "GET", priority) def _expose_add_route_method(self, path, lua_fn, method="GET", priority=50): """ Register a route with a specific HTTP method and priority. Lower priority numbers run first (0 = highest priority). """ p = str(path) m = str(method).upper() plugin_name = self._current_loading_plugin_name() or "" if m not in self.routes: self.routes[m] = {} if p not in self.routes[m]: self.routes[m][p] = [] # Add route with priority self.routes[m][p].append((plugin_name, lua_fn, int(priority))) # Sort by priority (lower number = higher priority) self.routes[m][p].sort(key=lambda x: x[2]) Logger.log_lua_info(f"Plugin {plugin_name} registered {m} route {p} (priority: {priority})") def _expose_register_hook(self, hook_name, lua_fn, priority=50): """ Register a hook with priority support. Hooks are chained - each hook receives the output of the previous one. Lower priority numbers run first (0 = highest priority). """ hook = str(hook_name) plugin_name = self._current_loading_plugin_name() or "" if hook not in self.hooks: self.hooks[hook] = [] self.hooks[hook].append((plugin_name, lua_fn, int(priority))) # Sort by priority (lower number = higher priority) self.hooks[hook].sort(key=lambda x: x[2]) Logger.log_lua_info(f"Plugin {plugin_name} registered hook {hook} (priority: {priority})") def _current_loading_plugin_name(self): return getattr(self, "_current_plugin", None) """ Running hooks & handling routes """ def handle_request(self, path, request_info=None, method="GET"): """ Handle HTTP requests by calling registered plugin routes. First matching route that returns a response wins. """ method = method.upper() if method not in self.routes: return None if path not in self.routes[method]: return None # Try each registered handler in priority order for plugin_name, lua_fn, priority in self.routes[method][path]: try: lua_req = request_info or {} res = lua_fn(lua_req) # If handler returns None, try next handler if res is None: continue # Handle tuple response (status, headers, body) if isinstance(res, tuple) and len(res) == 3: return res # Handle string response return (200, {"Content-Type": "text/html"}, str(res)) except LuaError as e: Logger.log_lua_error(f"Lua error in {method} route {path} from {plugin_name}: {e}") # Continue to next handler instead of failing completely continue return None def run_hook(self, hook_name: str, *args): """ Run all registered hook functions for hook_name in priority order. Each hook receives the output of the previous hook (chain pattern). This allows multiple plugins to modify content without overwriting each other. Example: Initial content -> Plugin A modifies -> Plugin B modifies -> Final content """ if hook_name not in self.hooks: return None # Start with the initial input (first arg for content hooks) result = args[0] if args else None for plugin_name, fn, priority in list(self.hooks[hook_name]): try: # Pass the current result as the first argument, plus any other args hook_args = (result,) + args[1:] if len(args) > 1 else (result,) output = fn(*hook_args) # If hook returns something, use it as input for next hook if output is not None: result = output Logger.log_lua_debug(f"Hook {hook_name} from {plugin_name} modified content") except LuaError as e: Logger.log_lua_error(f"Lua error in hook {hook_name} from {plugin_name}: {e}") # Continue with current result even if this hook fails continue return result """ File watcher """ def _start_watcher(self): self.observer = Observer() self.fs_handler = PluginFSHandler(self) self.observer.schedule(self.fs_handler, str(PLUGINS_DIR), recursive=False) self.observer.start() Logger.log_lua_info("Started plugin folder watcher") def stop(self): self.observer.stop() self.observer.join()