diff --git a/extensions/webui/api_call_after/.gitkeep b/extensions/webui/fetch_api_call_after/.gitkeep similarity index 100% rename from extensions/webui/api_call_after/.gitkeep rename to extensions/webui/fetch_api_call_after/.gitkeep diff --git a/extensions/webui/api_call_before/.gitkeep b/extensions/webui/fetch_api_call_before/.gitkeep similarity index 100% rename from extensions/webui/api_call_before/.gitkeep rename to extensions/webui/fetch_api_call_before/.gitkeep diff --git a/extensions/webui/json_api_call_after/.gitkeep b/extensions/webui/json_api_call_after/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/extensions/webui/json_api_call_after/cache_reset.js b/extensions/webui/json_api_call_after/cache_reset.js new file mode 100644 index 000000000..b09e96bbc --- /dev/null +++ b/extensions/webui/json_api_call_after/cache_reset.js @@ -0,0 +1,14 @@ +import { clear } from "/js/cache.js"; + +export default async function resetCache(ctx) { + try { + // clear frontend cache areas when backend caches are cleared via API + if (ctx.endpoint == "cache_reset") { + for (const area of ctx.data.areas) { + clear(area); + } + } + } catch (e) { + console.error(e); + } +} diff --git a/extensions/webui/json_api_call_before/.gitkeep b/extensions/webui/json_api_call_before/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/helpers/plugins.py b/helpers/plugins.py index 8b1a53371..d29e6457d 100644 --- a/helpers/plugins.py +++ b/helpers/plugins.py @@ -71,7 +71,7 @@ class PluginListItem(BaseModel): toggle_state: ToggleState = "disabled" -def invalidate_plugin_cache(): +def clear_plugin_cache(): cache.clear("*(plugins)*") @@ -189,6 +189,7 @@ def delete_plugin(plugin_name: str): if not files.is_in_dir(plugin_dir, custom_plugins_dir): raise ValueError("Only custom plugins can be deleted") files.delete_dir(plugin_dir) + clear_plugin_cache() def get_plugin_paths(*subpaths: str) -> List[str]: @@ -347,6 +348,7 @@ def toggle_plugin( files.write_file(enabled_file, "") else: files.write_file(disabled_file, "") + clear_plugin_cache() def get_plugin_config( @@ -401,6 +403,7 @@ def save_plugin_config( ) if file_path: files.write_file(file_path, json.dumps(settings)) + clear_plugin_cache() def find_plugin_asset( diff --git a/jsconfig.json b/jsconfig.json index 17f9b2537..102bdfe5a 100644 --- a/jsconfig.json +++ b/jsconfig.json @@ -13,5 +13,5 @@ "/usr/plugins/*": ["usr/plugins/*"] } }, - "include": ["webui/**/*.js", "plugins/**/*.js", "usr/plugins/**/*.js"] + "include": ["webui/**/*.js", "extensions/**/*.js", "plugins/**/*.js", "usr/plugins/**/*.js"] } \ No newline at end of file diff --git a/plugins/plugin_installer/helpers/install.py b/plugins/plugin_installer/helpers/install.py index ed92fb063..db8542060 100644 --- a/plugins/plugin_installer/helpers/install.py +++ b/plugins/plugin_installer/helpers/install.py @@ -12,7 +12,7 @@ from helpers import files from helpers.plugins import ( META_FILE_NAME, PluginMetadata, - invalidate_plugin_cache, + clear_plugin_cache, ) from helpers import yaml as yaml_helper @@ -29,7 +29,7 @@ def _sanitize_plugin_name(name: str) -> str: Converts dots and dashes to underscores for Python import compatibility. Raises ValueError if the name is unsafe for filesystem use.""" name = name.strip().strip(".") - name = re.sub(r"[-.]", "_", name) + name = re.sub(r"[^a-zA-Z0-9]+", "_", name) if not name or not _SAFE_NAME_RE.match(name): raise ValueError( f"Invalid plugin name: '{name}'. " @@ -107,7 +107,7 @@ def install_from_zip(zip_path: str) -> dict: dest = os.path.join(_get_user_plugins_dir(), plugin_name) os.makedirs(os.path.dirname(dest), exist_ok=True) shutil.move(plugin_root, dest) - invalidate_plugin_cache() + clear_plugin_cache() return { "success": True, @@ -147,6 +147,7 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict: except Exception as e: # Cleanup partial clone shutil.rmtree(dest, ignore_errors=True) + clear_plugin_cache() raise ValueError(f"Git clone failed: {e}") from e try: @@ -154,9 +155,10 @@ def install_from_git(url: str, token: Optional[str] = None) -> dict: except ValueError: # No plugin.yaml — remove cloned repo shutil.rmtree(dest, ignore_errors=True) + clear_plugin_cache() raise - invalidate_plugin_cache() + clear_plugin_cache() return { "success": True, diff --git a/webui/js/api.js b/webui/js/api.js index 4eb6ee219..ee7e9d964 100644 --- a/webui/js/api.js +++ b/webui/js/api.js @@ -1,3 +1,22 @@ +let _extensionsModule = null; + +async function _getExtensions() { + if (!_extensionsModule) _extensionsModule = await import("./extensions.js"); + return _extensionsModule; +} + +async function _shouldCallApiExtensions(apiUrl) { + const extensions = await _getExtensions(); + const excluded = extensions.API_EXTENSION_EXCLUDED_ENDPOINTS; + return !(excluded instanceof Set && excluded.has(apiUrl)); +} + +function _normalizeApiUrl(url) { + return url.startsWith("/api/") || url.startsWith("api/") + ? `/${url.replace(/^\/+/, "")}` + : `/api/${url.replace(/^\/+/, "")}`; +} + /** * Call a JSON-in JSON-out API endpoint * Data is automatically serialized @@ -6,21 +25,54 @@ * @returns {Promise} The JSON response from the API */ export async function callJsonApi(endpoint, data) { - const response = await fetchApi(endpoint, { + const apiUrl = _normalizeApiUrl(endpoint); + + /** @type {{ endpoint: string, data: any, response: Response | null, result: any, error: Error | null }} */ + const ctx = { + endpoint, + data, + response: null, + result: null, + error: null, + }; + + if (await _shouldCallApiExtensions(apiUrl)) { + const extensions = await _getExtensions(); + await extensions.callJsExtensions("json_api_call_before", ctx); + } + + const response = await fetchApi(ctx.endpoint, { method: "POST", headers: { "Content-Type": "application/json", }, credentials: "same-origin", - body: JSON.stringify(data), + body: JSON.stringify(ctx.data), }); + ctx.response = response; if (!response.ok) { const error = await response.text(); - throw new Error(error); + ctx.error = new Error(error); + + if (await _shouldCallApiExtensions(apiUrl)) { + const extensions = await _getExtensions(); + await extensions.callJsExtensions("json_api_call_error", ctx); + } + + if (ctx.error) throw ctx.error; + + return ctx.result; } - const jsonResponse = await response.json(); - return jsonResponse; + + ctx.result = await response.json(); + + if (await _shouldCallApiExtensions(apiUrl)) { + const extensions = await _getExtensions(); + await extensions.callJsExtensions("json_api_call_after", ctx); + } + + return ctx.result; } /** @@ -31,25 +83,6 @@ export async function callJsonApi(endpoint, data) { * @returns {Promise} The fetch response */ export async function fetchApi(url, request) { - async function _getExtensions() { - try { - return await import("./extensions.js"); - } catch { - return null; - } - } - - /** - * @param {string} apiUrl - * @returns {Promise} - */ - async function _shouldCallApiExtensions(apiUrl) { - const extensions = await _getExtensions(); - if (!extensions) return false; - const excluded = extensions.API_EXTENSION_EXCLUDED_ENDPOINTS; - return !(excluded instanceof Set && excluded.has(apiUrl)); - } - async function _wrap(retry) { // get the CSRF token const token = await getCsrfToken(); @@ -64,7 +97,7 @@ export async function fetchApi(url, request) { finalRequest.headers["X-CSRF-Token"] = token; // perform the fetch with the updated request - const apiUrl = url.startsWith('/api/') || url.startsWith('api/') ? `/${url.replace(/^\/+/, '')}` : `/api/${url.replace(/^\/+/, '')}`; + const apiUrl = _normalizeApiUrl(url); /** @type {{ url: string, apiUrl: string, request: any, response: Response | null, retry: boolean }} */ const ctx = { @@ -77,19 +110,15 @@ export async function fetchApi(url, request) { if (await _shouldCallApiExtensions(apiUrl)) { const extensions = await _getExtensions(); - if (extensions) { - await extensions.callJsExtensions("api_call_before", ctx); - } + await extensions.callJsExtensions("fetch_api_call_before", ctx); } - const response = ctx.response || await fetch(ctx.apiUrl, ctx.request); + const response = ctx.response || (await fetch(ctx.apiUrl, ctx.request)); ctx.response = response; if (await _shouldCallApiExtensions(apiUrl)) { const extensions = await _getExtensions(); - if (extensions) { - await extensions.callJsExtensions("api_call_after", ctx); - } + await extensions.callJsExtensions("fetch_api_call_after", ctx); } const finalResponse = ctx.response;