from __future__ import annotations
import io
import json
import logging
from pathlib import Path
from typing import Callable
from PySide6.QtWidgets import QWidget
from forkbit_sdk.git_client import GitClient
log = logging.getLogger(__name__)
[docs]
class BasePlugin:
"""Base class for all ForkBit plugins.
Subclass this and override :meth:`on_ready` to build your plugin UI.
The app creates an instance, injects context (project, settings, git),
and calls ``on_ready()`` when everything is wired up.
Example::
from forkbit_sdk import BasePlugin
from PySide6.QtWidgets import QLabel, QVBoxLayout
class MyPlugin(BasePlugin):
def on_ready(self):
layout = QVBoxLayout(self.container)
layout.addWidget(QLabel("Hello!"))
"""
[docs]
def __init__(self):
self.container = QWidget()
"""The root widget for your plugin UI. Build your layout inside this."""
self.container.setObjectName("pluginContainer")
self.git = GitClient()
"""Git operations for the current project. See :class:`GitClient`."""
self._settings: dict = {}
self._project_id: str = ""
self._project_path: str = ""
self._plugin_id: str = ""
self._version: str = ""
self._plugin_name: str = ""
self._plugin_subtitle: str = ""
self._plugin_icon: str = ""
self._plugin_icon_colors: tuple[str, str] = ("", "")
self._plugin_icon_path: str = ""
self._data_dir: Path | None = None
self._notify_fn: Callable[[str, str], None] | None = None
self._subtitle_changed_fn: Callable[[str], None] | None = None
self._translate_fn: Callable[
[str, str, str, Callable[[str], None] | None, Callable[[str], None] | None],
str,
] | None = None
self._status_chip: QWidget | None = None
@property
def plugin_id(self) -> str:
"""The unique plugin identifier (e.g. ``com.mycompany.myplugin``)."""
return self._plugin_id
@property
def version(self) -> str:
"""The plugin version from ``plugin.json``."""
return self._version
@property
def plugin_name(self) -> str:
"""The display name shown in the sidebar."""
return self._plugin_name
@property
def plugin_subtitle(self) -> str:
"""The subtitle shown below the plugin name in the header."""
return self._plugin_subtitle
@property
def plugin_icon(self) -> str:
"""The icon name (Lucide icon ID) for this plugin."""
return self._plugin_icon
@property
def plugin_icon_colors(self) -> tuple[str, str]:
"""Gradient colors ``(start, end)`` for the plugin icon background."""
return self._plugin_icon_colors
@property
def plugin_icon_path(self) -> str:
"""Path to a custom icon file, if set."""
return self._plugin_icon_path
@property
def data_dir(self) -> Path:
"""Per-instance directory for plugin file storage.
Created automatically by the app before :meth:`on_ready` is called.
Use :meth:`read_json` and :meth:`write_json` for convenient access.
:raises RuntimeError: If accessed before ``on_ready()``.
"""
if self._data_dir is None:
raise RuntimeError("data_dir is not available before on_ready()")
return self._data_dir
[docs]
def read_json(self, filename: str) -> dict:
"""Read a JSON file from :attr:`data_dir`.
:param filename: Name of the JSON file (e.g. ``"state.json"``).
:returns: Parsed dict, or empty dict if the file doesn't exist.
"""
path = self.data_dir / filename
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
[docs]
def write_json(self, filename: str, data: dict) -> None:
"""Atomic-write a JSON file to :attr:`data_dir`.
Writes to a temporary file first, then renames — safe against crashes.
:param filename: Name of the JSON file (e.g. ``"state.json"``).
:param data: The dict to serialize.
"""
path = self.data_dir / filename
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
tmp.replace(path)
@property
def settings(self) -> dict:
"""Current plugin settings values as defined in ``settings.json``."""
return self._settings
[docs]
def secret_file(self, field_id: str) -> bytes:
"""Return the raw bytes for a ``secret_file`` settings field.
:param field_id: The field ID from ``settings.json``.
:returns: Raw file content as bytes.
:raises KeyError: If no secret file is loaded for the given field.
"""
value = self._settings.get(field_id)
if not isinstance(value, bytes):
raise KeyError(f"No secret file loaded for '{field_id}'")
return value
[docs]
def secret_file_stream(self, field_id: str) -> io.BytesIO:
"""Return a file-like ``BytesIO`` for a ``secret_file`` settings field.
Useful when a library expects a file object instead of raw bytes.
:param field_id: The field ID from ``settings.json``.
:returns: A seekable ``BytesIO`` stream.
"""
return io.BytesIO(self.secret_file(field_id))
@property
def project_id(self) -> str:
"""The unique ID of the currently open project."""
return self._project_id
@property
def project_path(self) -> str:
"""Filesystem path to the project directory."""
return self._project_path
[docs]
def on_ready(self) -> None:
"""Called when the plugin is fully initialized.
Override this to build your UI inside :attr:`container` and set up
initial state. All context properties (:attr:`settings`, :attr:`data_dir`,
:attr:`git`, etc.) are available at this point.
"""
pass
[docs]
def on_settings_changed(self, settings: dict) -> None:
"""Called when the user saves plugin settings.
Override this to react to settings changes at runtime (e.g. refresh
data with a new API key). The base implementation updates
:attr:`settings` — call ``super()`` if you override.
:param settings: The full updated settings dict.
"""
self._settings = settings
@property
def status_chip(self) -> QWidget | None:
"""Optional status chip widget in the plugin header.
Injected by the app — ``None`` until :meth:`on_ready` runs.
"""
return self._status_chip
[docs]
def set_subtitle(self, text: str) -> None:
"""Update the subtitle shown in the plugin header.
:param text: New subtitle text (e.g. ``"v1.2.0 — 3 locales"``).
"""
self._plugin_subtitle = text
if self._subtitle_changed_fn:
self._subtitle_changed_fn(text)
[docs]
def notify(self, message: str, level: str = "info") -> None:
"""Show a toast notification to the user.
:param message: The notification text.
:param level: One of ``"info"``, ``"success"``, ``"warning"``, ``"error"``.
"""
if self._notify_fn:
self._notify_fn(message, level)
else:
log.info("[%s] %s: %s", self._plugin_id, level, message)
[docs]
def translate(
self,
text: str,
source_lang: str,
target_lang: str,
on_done: Callable[[str], None] | None = None,
on_error: Callable[[str], None] | None = None,
) -> str:
"""Request an asynchronous translation via the app's translation service.
The translation runs in a background thread. Results arrive via callbacks.
:param text: The text to translate.
:param source_lang: Source language code (e.g. ``"en"``).
:param target_lang: Target language code (e.g. ``"de"``).
:param on_done: Callback receiving the translated text.
:param on_error: Callback receiving an error message.
:returns: A request ID for tracking.
:raises RuntimeError: If no translation service is configured.
"""
if self._translate_fn:
return self._translate_fn(text, source_lang, target_lang, on_done, on_error)
raise RuntimeError("Translation service is not available")