304 lines
12 KiB
Python
304 lines
12 KiB
Python
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 "<unknown>"
|
|
|
|
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 "<unknown>"
|
|
|
|
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()
|