diff --git a/lnbits/core/__init__.py b/lnbits/core/__init__.py
index 6a5cc699..64d84f5a 100644
--- a/lnbits/core/__init__.py
+++ b/lnbits/core/__init__.py
@@ -7,6 +7,7 @@ from .views.audit_api import audit_router
from .views.auth_api import auth_router
from .views.callback_api import callback_router
from .views.extension_api import extension_router
+from .views.extensions_builder_api import extension_builder_router
from .views.fiat_api import fiat_router
# this compat is needed for usermanager extension
@@ -31,6 +32,7 @@ def init_core_routers(app: FastAPI):
app.include_router(admin_router)
app.include_router(node_router)
app.include_router(extension_router)
+ app.include_router(extension_builder_router)
app.include_router(super_node_router)
app.include_router(public_node_router)
app.include_router(payment_router)
diff --git a/lnbits/core/models/extensions.py b/lnbits/core/models/extensions.py
index 684488dc..9ec83e4c 100644
--- a/lnbits/core/models/extensions.py
+++ b/lnbits/core/models/extensions.py
@@ -409,7 +409,6 @@ class InstallableExtension(BaseModel):
tmp_dir = Path(settings.lnbits_data_folder, "unzip-temp", self.hash)
shutil.rmtree(tmp_dir, True)
-
with zipfile.ZipFile(self.zip_path, "r") as zip_ref:
zip_ref.extractall(tmp_dir)
generated_dir_name = os.listdir(tmp_dir)[0]
@@ -628,8 +627,11 @@ class InstallableExtension(BaseModel):
@classmethod
async def get_extension_releases(cls, ext_id: str) -> list[ExtensionRelease]:
extension_releases: list[ExtensionRelease] = []
-
- for url in settings.lnbits_extensions_manifests:
+ all_manifests = [
+ *settings.lnbits_extensions_manifests,
+ settings.lnbits_extensions_builder_manifest_url,
+ ]
+ for url in all_manifests:
try:
manifest = await cls.fetch_manifest(url)
for r in manifest.repos:
diff --git a/lnbits/core/models/extensions_builder.py b/lnbits/core/models/extensions_builder.py
new file mode 100644
index 00000000..fd871b81
--- /dev/null
+++ b/lnbits/core/models/extensions_builder.py
@@ -0,0 +1,460 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime, timedelta, timezone
+from typing import Any, Literal
+
+from pydantic import BaseModel, validator
+
+from lnbits.helpers import (
+ camel_to_snake,
+ is_camel_case,
+ is_snake_case,
+ urlsafe_short_hash,
+)
+
+
+class DataField(BaseModel):
+ name: str
+ type: str
+ label: str | None = None
+ hint: str | None = None
+ optional: bool = False
+ editable: bool = False
+ searchable: bool = False
+ sortable: bool = False
+ fields: list[DataField] = []
+
+ def normalize(self) -> None:
+ self.name = self.name.strip()
+ self.type = self.type.strip()
+ if self.label:
+ self.label = self.label.strip()
+ if self.hint:
+ self.hint = self.hint.strip()
+ if self.type == "json":
+ self.editable = False
+ self.searchable = False
+ self.sortable = False
+ else:
+ self.fields = []
+
+ for field in self.fields:
+ field.normalize()
+
+ def field_to_py(self) -> str:
+ field_name = camel_to_snake(self.name)
+ field_type = self.type
+ if self.type == "json":
+ field_type = "dict"
+ elif self.type in ["wallet", "currency", "text"]:
+ field_type = "str"
+ if self.optional:
+ field_type += " | None"
+ if self.type == "currency":
+ field_type += ' = "sat"'
+ return f"{field_name}: {field_type}"
+
+ def field_to_js(self) -> str:
+ field_name = camel_to_snake(self.name)
+ default_value = "null"
+ if self.type == "json":
+ default_value = "{}"
+ if self.type == "currency":
+ default_value = '"sat"'
+ return f"{field_name}: {default_value}"
+
+ def field_to_ui_table_column(self) -> str:
+ column = {
+ "name": self.name,
+ "align": "left",
+ "label": self.label or self.name,
+ "field": self.name,
+ "sortable": self.sortable,
+ }
+
+ return json.dumps(column)
+
+ def field_to_db(self) -> str:
+ field_name = camel_to_snake(self.name)
+ field_type = self.type
+ if field_type == "str":
+ db_type = "TEXT"
+ elif field_type == "int":
+ db_type = "INT"
+ elif field_type == "float":
+ db_type = "REAL"
+ elif field_type == "bool":
+ db_type = "BOOLEAN"
+ elif field_type == "datetime":
+ db_type = "TIMESTAMP"
+ else:
+ db_type = "TEXT"
+
+ db_field = f"{field_name} {db_type}"
+ if not self.optional:
+ db_field += " NOT NULL"
+ if field_type == "json":
+ db_field += " DEFAULT '{empty_dict}'"
+ return db_field
+
+ def field_mock_value(self, index: int) -> Any:
+ if self.name == "id":
+ return urlsafe_short_hash()
+ if self.type == "int":
+ return index
+ elif self.type == "float":
+ return float(f"{index}.0{index * 2}")
+ elif self.type == "bool":
+ return True if index % 2 == 0 else False
+ elif self.type == "datetime":
+ return (datetime.now(timezone.utc) - timedelta(hours=index * 2)).isoformat()
+ elif self.type == "json":
+ return {"key": "value"}
+ elif self.type == "currency":
+ return "USD"
+ else:
+ return f"{self.name} {index}"
+
+ @validator("name")
+ def validate_name(cls, v: str) -> str:
+ if v.strip() == "":
+ raise ValueError("Field name is required.")
+ if not is_snake_case(v):
+ raise ValueError(f"Field Name must be snake_case. Found: {v}")
+ return v
+
+ @validator("type")
+ def validate_type(cls, v: str) -> str:
+ if v.strip() == "":
+ raise ValueError("Owner Data type is required")
+ if v not in [
+ "str",
+ "int",
+ "float",
+ "bool",
+ "datetime",
+ "json",
+ "wallet",
+ "currency",
+ "text",
+ ]:
+ raise ValueError(
+ "Field Type must be one of: "
+ "str, int, float, bool, datetime, json, wallet, currency, text."
+ f" Found: {v}"
+ )
+ return v
+
+ @validator("label")
+ def validate_label(cls, v: str | None) -> str | None:
+ if v and '"' in v:
+ raise ValueError(
+ f'Field label cannot contain double quotes ("). Value: {v}'
+ )
+ return v
+
+ @validator("hint")
+ def validate_hint(cls, v: str | None) -> str | None:
+ if v and '"' in v:
+ raise ValueError(f'Field hint cannot contain double quotes ("). Value: {v}')
+ return v
+
+
+class DataFields(BaseModel):
+ name: str
+ editable: bool = True
+ fields: list[DataField] = []
+
+ def __init__(self, **data):
+ super().__init__(**data)
+ self.normalize()
+
+ def normalize(self) -> None:
+ self.name = self.name.strip()
+ for field in self.fields:
+ field.normalize()
+ if all(not field.editable for field in self.fields):
+ self.editable = False
+
+ def get_field_by_name(self, name: str | None) -> DataField | None:
+ if not name:
+ return None
+ for field in self.fields:
+ if field.name == name:
+ return field
+ return None
+
+ @validator("name")
+ def validate_name(cls, v: str) -> str:
+ if v.strip() == "":
+ raise ValueError("Data fields name is required")
+ if not is_camel_case(v):
+ raise ValueError(f"Data name must be CamelCase. Found: {v}")
+ return v
+
+
+class SettingsFields(DataFields):
+ enabled: bool = False
+ type: str = "user"
+
+ @validator("type")
+ def validate_type(cls, v: str) -> str:
+ if v.strip() == "":
+ raise ValueError("Settings type is required")
+ if v not in ["user", "admin"]:
+ raise ValueError("Field Type must be one of: user, admin." f" Found: {v}")
+ return v
+
+
+class ActionFields(BaseModel):
+ generate_action: bool = False
+ generate_payment_logic: bool = False
+ wallet_id: str | None = None
+ currency: str | None = None
+ amount: str | None = None
+ amount_source: Literal["owner_data", "client_data"] | None = None
+ paid_flag: str | None = None
+
+
+class OwnerDataFields(BaseModel):
+ name: str | None = None
+ description: str | None = None
+
+
+class ClientDataFields(BaseModel):
+ public_inputs: list[str] = []
+
+
+class PublicPageFields(BaseModel):
+ has_public_page: bool = False
+ owner_data_fields: OwnerDataFields
+ client_data_fields: ClientDataFields
+ action_fields: ActionFields
+
+
+class PreviewAction(BaseModel):
+ is_preview_mode: bool = False
+ is_settings_preview: bool = False
+ is_owner_data_preview: bool = False
+ is_client_data_preview: bool = False
+ is_public_page_preview: bool = False
+
+ def __init__(self, **data):
+ super().__init__(**data)
+ if not self.is_preview_mode:
+ self.is_settings_preview = False
+ self.is_owner_data_preview = False
+ self.is_client_data_preview = False
+ self.is_public_page_preview = False
+
+
+class ExtensionData(BaseModel):
+ id: str
+ name: str
+ stub_version: str | None
+ short_description: str | None = None
+ description: str | None = None
+ owner_data: DataFields
+ client_data: DataFields
+ settings_data: SettingsFields
+ public_page: PublicPageFields
+ preview_action: PreviewAction = PreviewAction()
+
+ def __init__(self, **data):
+ super().__init__(**data)
+ self.validate_data()
+ self.normalize()
+
+ def normalize(self) -> None:
+ self.id = self.id.strip()
+ self.name = self.name.strip()
+ if self.stub_version:
+ self.stub_version = self.stub_version.strip()
+ if self.short_description:
+ self.short_description = self.short_description.strip()
+ if self.description:
+ self.description = self.description.strip()
+ if not self.public_page.has_public_page:
+ self.public_page.action_fields.generate_action = False
+ self.public_page.action_fields.generate_payment_logic = False
+ if not self.public_page.action_fields.generate_action:
+ self.public_page.action_fields.generate_payment_logic = False
+
+ def validate_data(self) -> None:
+ self._validate_field_names()
+ self._validate_public_page_fields()
+ self._validate_action_fields()
+
+ def _validate_public_page_fields(self) -> None:
+ if not self.public_page.has_public_page:
+ return
+
+ public_page_name = self.public_page.owner_data_fields.name
+ if public_page_name:
+ public_page_name_field = self.owner_data.get_field_by_name(public_page_name)
+ if not public_page_name_field:
+ raise ValueError(
+ "Public Page Name must be one of the owner data fields."
+ f" Received: {public_page_name}."
+ )
+
+ public_page_description = self.public_page.owner_data_fields.description
+ if public_page_description:
+ public_page_description_field = self.owner_data.get_field_by_name(
+ public_page_description
+ )
+ if not public_page_description_field:
+ raise ValueError(
+ "Public Page Description must be one of the owner data fields."
+ f" Received: {public_page_description}."
+ )
+
+ public_page_inputs = self.public_page.client_data_fields.public_inputs
+ if public_page_inputs:
+ for input_field in public_page_inputs:
+ input_field_obj = self.client_data.get_field_by_name(input_field)
+ if not input_field_obj:
+ raise ValueError(
+ "Public Page Input fields"
+ " must be one of the client data fields."
+ f" Received: {input_field}."
+ )
+
+ def _validate_action_fields(self) -> None:
+ if not self.public_page.action_fields.generate_action:
+ return
+ if not self.public_page.action_fields.generate_payment_logic:
+ return
+
+ self._validate_owner_data_fields()
+ self._validate_client_data_fields()
+
+ def _validate_owner_data_fields(self) -> None:
+ wallet_id = self.public_page.action_fields.wallet_id
+ if wallet_id:
+ wallet_id_field = self.owner_data.get_field_by_name(wallet_id)
+ if not wallet_id_field:
+ raise ValueError(
+ "Action Wallet ID must be one of the owner data fields."
+ f" Received: {wallet_id}."
+ )
+ if wallet_id_field.type != "wallet":
+ raise ValueError(
+ "Action Wallet ID field type must be 'wallet'."
+ f" Received: {wallet_id_field.type}."
+ )
+ currency = self.public_page.action_fields.currency
+ if currency:
+ currency_field = self.owner_data.get_field_by_name(currency)
+ if not currency_field:
+ raise ValueError(
+ "Action Currency must be one of the owner data fields."
+ f" Received: {currency}."
+ )
+ if currency_field.type != "currency":
+ raise ValueError(
+ "Action Currency field type must be 'currency'."
+ f" Received: {currency_field.type}."
+ )
+
+ def _validate_field_names(self) -> None:
+ reserved_names = {"id", "created_at", "updated_at"}
+ nok = {f.name for f in self.owner_data.fields}.intersection(reserved_names)
+ if nok:
+ raise ValueError(
+ f"Owner Data fields cannot have reserved names: '{', '.join(nok)}.'"
+ )
+ nok = {f.name for f in self.client_data.fields}.intersection(reserved_names)
+ if nok:
+ raise ValueError(
+ f"Client Data fields cannot have reserved names: '{', '.join(nok)}.'"
+ )
+ nok = {f.name for f in self.settings_data.fields}.intersection(reserved_names)
+ if nok:
+ raise ValueError(
+ f"Settings fields cannot have reserved names: '{', '.join(nok)}.'"
+ )
+
+ def _validate_client_data_fields(self) -> None:
+ amount = self.public_page.action_fields.amount
+ amount_source = self.public_page.action_fields.amount_source
+ if amount_source and amount:
+ if amount_source == "owner_data":
+ amount_field = self.owner_data.get_field_by_name(amount)
+ else:
+ amount_field = self.client_data.get_field_by_name(amount)
+ if not amount_field:
+ raise ValueError(
+ "Action Amount must be one of the "
+ "client data or owner data fields."
+ f" Received: {amount}."
+ )
+ if amount_field.type not in ["int", "float"]:
+ raise ValueError(
+ "Action Amount field type must be 'int' or 'float'."
+ f" Received: {amount_field.type}."
+ )
+ paid_flag = self.public_page.action_fields.paid_flag
+ if paid_flag:
+ paid_flag_field = self.client_data.get_field_by_name(paid_flag)
+ if not paid_flag_field:
+ raise ValueError(
+ "Action Paid Flag must be one of the client data fields."
+ f" Received: {paid_flag}."
+ )
+ if paid_flag_field.type != "bool":
+ raise ValueError(
+ "Action Paid Flag field type must be 'bool'."
+ f" Received: {paid_flag_field.type}."
+ )
+
+ @validator("id")
+ def validate_id(cls, v: str) -> str:
+ if v.strip() == "":
+ raise ValueError("Extension ID is required")
+ if not is_snake_case(v):
+ raise ValueError(f"Extension Id must be snake_case. Found: {v}")
+ return v
+
+ @validator("name")
+ def validate_name(cls, v: str) -> str:
+ if v.strip() == "":
+ raise ValueError("Extension name is required")
+ return v
+
+ @validator("stub_version")
+ def validate_stub_version(cls, v: str | None) -> str | None:
+ if v and '"' in v:
+ raise ValueError(
+ f'Extension stub version cannot contain double quotes ("). Value: {v}'
+ )
+ return v
+
+ @validator("short_description")
+ def validate_short_description(cls, v: str | None) -> str | None:
+ if v and '"' in v:
+ raise ValueError(
+ f'Field short description cannot contain double quotes ("). Value: {v}'
+ )
+ return v
+
+ @validator("description")
+ def validate_description(cls, v: str | None) -> str | None:
+ if v and '"' in v:
+ raise ValueError(
+ f'Field description cannot contain double quotes ("). Value: {v}'
+ )
+ return v
+
+ @validator("owner_data")
+ def validate_owner_data(cls, v: DataFields) -> DataFields:
+ if len(v.fields) == 0:
+ raise ValueError("At least one owner data field is required")
+ return v
+
+ @validator("client_data")
+ def validate_client_data(cls, v: DataFields) -> DataFields:
+ if len(v.fields) == 0:
+ raise ValueError("At least one client data field is required")
+ return v
diff --git a/lnbits/core/services/extensions.py b/lnbits/core/services/extensions.py
index 0f85064a..b2a561d8 100644
--- a/lnbits/core/services/extensions.py
+++ b/lnbits/core/services/extensions.py
@@ -21,7 +21,9 @@ from lnbits.settings import settings
from ..models.extensions import Extension, ExtensionMeta, InstallableExtension
-async def install_extension(ext_info: InstallableExtension) -> Extension:
+async def install_extension(
+ ext_info: InstallableExtension, skip_download: bool | None = False
+) -> Extension:
ext_info.meta = ext_info.meta or ExtensionMeta()
@@ -35,7 +37,8 @@ async def install_extension(ext_info: InstallableExtension) -> Extension:
if installed_ext and installed_ext.meta:
ext_info.meta.payments = installed_ext.meta.payments
- await ext_info.download_archive()
+ if not skip_download:
+ await ext_info.download_archive()
ext_info.extract_archive()
diff --git a/lnbits/core/services/extensions_builder.py b/lnbits/core/services/extensions_builder.py
new file mode 100644
index 00000000..8187c911
--- /dev/null
+++ b/lnbits/core/services/extensions_builder.py
@@ -0,0 +1,543 @@
+import asyncio
+import json
+import os
+import shutil
+import zipfile
+from hashlib import sha256
+from pathlib import Path
+from time import time
+from uuid import uuid4
+
+import shortuuid
+from jinja2 import Environment, FileSystemLoader
+from loguru import logger
+
+from lnbits.core.models.extensions import ExtensionRelease, InstallableExtension
+from lnbits.core.models.extensions_builder import DataField, ExtensionData
+from lnbits.db import dict_to_model
+from lnbits.helpers import (
+ camel_to_snake,
+ camel_to_words,
+ download_url,
+ lowercase_first_letter,
+)
+from lnbits.settings import settings
+
+py_files = [
+ "__init__.py",
+ "models.py",
+ "migrations.py",
+ "views_api.py",
+ "crud.py",
+ "views.py",
+ "tasks.py",
+ "services.py",
+]
+
+remove_line_marker = "{remove_line_marker}}"
+
+ui_table_columns = [
+ DataField(
+ name="updated_at",
+ type="datetime",
+ label="Updated At",
+ hint="Timestamp of the last update",
+ optional=False,
+ editable=False,
+ searchable=False,
+ sortable=True,
+ ),
+ DataField(
+ name="id",
+ type="str",
+ label="ID",
+ hint="Unique identifier",
+ optional=False,
+ editable=False,
+ searchable=False,
+ sortable=True,
+ ),
+]
+
+
+excluded_dirs = {"./.", "./__pycache__", "./node_modules", "./transform"}
+
+
+async def build_extension_from_data(
+ data: ExtensionData, stub_ext_id: str, working_dir_name: str | None = None
+):
+ release = await _get_extension_stub_release(stub_ext_id, data.stub_version)
+ release.hash = sha256(uuid4().hex.encode("utf-8")).hexdigest()
+ release.icon = f"/{data.id}/static/image/{data.id}.png"
+ release.is_github_release = False
+ await _fetch_extension_builder_stub(stub_ext_id, release)
+ build_dir = _copy_ext_stub_to_build_dir(
+ stub_ext_id=stub_ext_id,
+ stub_version=release.version,
+ new_ext_id=data.id,
+ working_dir_name=working_dir_name,
+ )
+ _transform_extension_builder_stub(data, build_dir)
+ _export_extension_data_json(data, build_dir)
+ return release, build_dir
+
+
+def clean_extension_builder_data() -> None:
+ working_dir = Path(settings.extension_builder_working_dir_path)
+ if working_dir.is_dir():
+ shutil.rmtree(working_dir, True)
+ working_dir.mkdir(parents=True, exist_ok=True)
+
+
+def _transform_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
+ _replace_jinja_placeholders(data, extension_dir)
+ _rename_extension_builder_stub(data, extension_dir)
+
+
+def _export_extension_data_json(data: ExtensionData, build_dir: Path):
+ json.dump(
+ data.dict(),
+ open(Path(build_dir, "builder.json"), "w", encoding="utf-8"),
+ indent=4,
+ )
+
+
+async def _get_extension_stub_release(
+ stub_ext_id: str, stub_version: str | None = None
+) -> ExtensionRelease:
+ working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
+ cache_dir = Path(working_dir, f"cache-{stub_version}")
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ release_cache_file = Path(cache_dir, "release.json")
+
+ if stub_version:
+ cached_release = _load_extension_stub_release_from_cache(
+ stub_ext_id, stub_version
+ )
+ if cached_release:
+ logger.debug(f"Loading release from cache {stub_ext_id} ({stub_version}).")
+ return cached_release
+
+ releases: list[ExtensionRelease] = (
+ await InstallableExtension.get_extension_releases(stub_ext_id)
+ )
+
+ release = next((r for r in releases if r.version == stub_version), None)
+
+ if not release and len(releases) > 0:
+ release = releases[0]
+
+ if not release:
+ raise ValueError(f"Release {stub_ext_id} ({stub_version}) not found.")
+
+ logger.debug(f"Save release cache {stub_ext_id} ({stub_version}).")
+ with open(release_cache_file, "w", encoding="utf-8") as f:
+ f.write(json.dumps(release.dict(), indent=4))
+
+ return release
+
+
+def _load_extension_stub_release_from_cache(
+ stub_ext_id: str, stub_version: str
+) -> ExtensionRelease | None:
+ working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
+ cache_dir = Path(working_dir, f"cache-{stub_version}")
+ release_cache_file = Path(cache_dir, "release.json")
+ if release_cache_file.is_file():
+ with open(release_cache_file, encoding="utf-8") as f:
+ return dict_to_model(json.load(f), ExtensionRelease)
+ return None
+
+
+async def _fetch_extension_builder_stub(
+ stub_ext_id: str, release: ExtensionRelease
+) -> Path:
+ working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
+ cache_dir = Path(working_dir, f"cache-{release.version}")
+ cache_dir.mkdir(parents=True, exist_ok=True)
+
+ stub_ext_zip_path = Path(cache_dir, release.version + ".zip")
+ ext_stub_cache_dir = Path(cache_dir, stub_ext_id)
+
+ if not stub_ext_zip_path.is_file():
+ await asyncio.to_thread(download_url, release.archive_url, stub_ext_zip_path)
+ shutil.rmtree(ext_stub_cache_dir, True)
+
+ if not ext_stub_cache_dir.is_dir():
+ tmp_dir = Path(cache_dir, "tmp")
+ shutil.rmtree(tmp_dir, True)
+ tmp_dir.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(stub_ext_zip_path, "r") as zip_ref:
+ zip_ref.extractall(tmp_dir)
+ generated_dir = Path(tmp_dir, os.listdir(tmp_dir)[0])
+ shutil.copytree(generated_dir, Path(ext_stub_cache_dir))
+ shutil.rmtree(tmp_dir, True)
+
+ return ext_stub_cache_dir
+
+
+def _copy_ext_stub_to_build_dir(
+ stub_ext_id: str,
+ stub_version: str,
+ new_ext_id: str,
+ working_dir_name: str | None = None,
+) -> Path:
+ working_dir = Path(settings.extension_builder_working_dir_path, stub_ext_id)
+ cache_dir = Path(working_dir, f"cache-{stub_version}")
+
+ ext_stub_cache_dir = Path(cache_dir, stub_ext_id)
+ if not ext_stub_cache_dir.is_dir():
+ raise ValueError(
+ f"Extension stub cache dir not found: {stub_ext_id} ({stub_version})"
+ )
+
+ working_dir_name = working_dir_name or f"ext-{int(time())}-{shortuuid.uuid()}"
+ ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
+ shutil.rmtree(ext_build_dir, True)
+
+ shutil.copytree(ext_stub_cache_dir, ext_build_dir)
+ return ext_build_dir
+
+
+def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
+ parsed_data = _parse_extension_data(data)
+ for py_file in py_files:
+ template_path = Path(ext_stub_dir, py_file).as_posix()
+ rederer = _render_file(template_path, parsed_data)
+ with open(template_path, "w", encoding="utf-8") as f:
+ f.write(rederer)
+
+ _remove_lines_with_string(template_path, remove_line_marker)
+
+ template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix()
+ rederer = _render_file(
+ template_path, {"preview": data.preview_action, **parsed_data}
+ )
+ embeded_index_js = rederer
+ with open(template_path, "w", encoding="utf-8") as f:
+ f.write(rederer)
+
+ _remove_lines_with_string(template_path, remove_line_marker)
+
+ owner_inputs = _fields_to_html_input(
+ [f for f in data.owner_data.fields if f.editable],
+ "ownerDataFormDialog.data",
+ ext_stub_dir,
+ )
+ client_inputs = _fields_to_html_input(
+ [f for f in data.client_data.fields if f.editable],
+ "clientDataFormDialog.data",
+ ext_stub_dir,
+ )
+ settings_inputs = _fields_to_html_input(
+ [f for f in data.settings_data.fields if f.editable],
+ "settingsFormDialog.data",
+ ext_stub_dir,
+ )
+ template_path = Path(
+ ext_stub_dir, "templates", "extension_builder_stub", "index.html"
+ ).as_posix()
+ rederer = _render_file(
+ template_path,
+ {
+ "embeded_index_js": embeded_index_js,
+ "extension_builder_stub_owner_inputs": owner_inputs,
+ "extension_builder_stub_settings_inputs": settings_inputs,
+ "extension_builder_stub_client_inputs": client_inputs,
+ "preview": data.preview_action,
+ "cancel_comment": remove_line_marker,
+ **parsed_data,
+ },
+ )
+ with open(template_path, "w", encoding="utf-8") as f:
+ f.write(rederer)
+
+ _remove_lines_with_string(template_path, remove_line_marker)
+
+ public_client_inputs = _fields_to_html_input(
+ [
+ f
+ for f in data.client_data.fields
+ if f.name in data.public_page.client_data_fields.public_inputs
+ ],
+ "publicClientData",
+ ext_stub_dir,
+ )
+ public_template_path = Path(
+ ext_stub_dir, "templates", "extension_builder_stub", "public_page.html"
+ )
+ template_path = public_template_path.as_posix()
+ if not data.public_page.has_public_page:
+ public_template_path.unlink(missing_ok=True)
+ else:
+ rederer = _render_file(
+ template_path,
+ {
+ "extension_builder_stub_public_client_inputs": public_client_inputs,
+ "preview": data.preview_action,
+ **data.public_page.action_fields.dict(),
+ "cancel_comment": remove_line_marker,
+ },
+ )
+
+ with open(template_path, "w", encoding="utf-8") as f:
+ f.write(rederer)
+
+ _remove_lines_with_string(template_path, remove_line_marker)
+
+
+def zip_directory(source_dir, zip_path):
+ """
+ Zips the contents of a directory (including subdirectories and files).
+
+ Parameters:
+ - source_dir (str): The path of the directory to zip.
+ - zip_path (str): The path where the .zip file will be saved.
+ """
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
+ for root, _, files in os.walk(source_dir):
+ if _is_excluded_dir(root):
+ continue
+
+ for file in files:
+ full_path = os.path.join(root, file)
+ # Add file with a relative path inside the zip
+ relative_path = os.path.relpath(full_path, start=source_dir)
+ zipf.write(full_path, arcname=relative_path)
+
+
+def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
+ extension_dir_path = extension_dir.as_posix()
+ rename_values = {
+ "extension_builder_stub_name": data.name,
+ "extension_builder_stub_short_description": data.short_description or "",
+ "extension_builder_stub": data.id,
+ "OwnerData": data.owner_data.name,
+ "ownerData": lowercase_first_letter(data.owner_data.name),
+ "Owner Data": camel_to_words(data.owner_data.name),
+ "owner data": camel_to_words(data.owner_data.name).lower(),
+ "owner_data": camel_to_snake(data.owner_data.name),
+ "ClientData": data.client_data.name,
+ "clientData": lowercase_first_letter(data.client_data.name),
+ "Client Data": camel_to_words(data.client_data.name),
+ "client data": camel_to_words(data.client_data.name).lower(),
+ "client_data": camel_to_snake(data.client_data.name),
+ }
+ for old_text, new_text in rename_values.items():
+ _replace_text_in_files(
+ directory=extension_dir_path,
+ old_text=old_text,
+ new_text=new_text,
+ file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"],
+ )
+
+ _rename_files_and_dirs_in_directory(
+ directory=extension_dir_path,
+ old_text="extension_builder_stub",
+ new_text=data.id,
+ )
+ _rename_files_and_dirs_in_directory(
+ directory=extension_dir_path,
+ old_text="owner_data",
+ new_text=camel_to_snake(data.owner_data.name),
+ )
+
+
+def _replace_text_in_files(
+ directory: str,
+ old_text: str,
+ new_text: str,
+ file_extensions: list[str] | None = None,
+):
+ """
+ Recursively replaces text in all files under the given directory.
+ """
+ for root, _, files in os.walk(directory):
+ if _is_excluded_dir(root):
+ continue
+
+ for filename in files:
+ if file_extensions:
+ if not any(filename.endswith(ext) for ext in file_extensions):
+ continue
+
+ file_path = os.path.join(root, filename)
+ try:
+ with open(file_path, encoding="utf-8") as f:
+ content = f.read()
+ if old_text in content:
+ new_content = content.replace(old_text, new_text)
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.write(new_content)
+ logger.trace(f"Updated: {file_path}")
+ except (UnicodeDecodeError, PermissionError, FileNotFoundError) as e:
+ logger.debug(f"Skipped {file_path}: {e}")
+
+
+def _render_file(template_path: str, data: dict) -> str:
+ # Extract directory and file name
+ template_dir = os.path.dirname(template_path)
+ template_file = os.path.basename(template_path)
+
+ # Create Jinja environment
+ # env = Environment(loader=FileSystemLoader(template_dir))
+ env = _jinja_env(template_dir)
+ template = env.get_template(template_file)
+
+ # Render the template with data
+ return template.render(**data)
+
+
+def _jinja_env(template_dir: str) -> Environment:
+ return Environment(
+ loader=FileSystemLoader(template_dir),
+ variable_start_string="<<",
+ variable_end_string=">>",
+ block_start_string="<%",
+ block_end_string="%>",
+ comment_start_string="<#",
+ comment_end_string="#>",
+ autoescape=False,
+ )
+
+
+def _parse_extension_data(data: ExtensionData) -> dict:
+ return {
+ "owner_data": {
+ "name": data.owner_data.name,
+ "editable": data.owner_data.editable,
+ "js_fields": [
+ field.field_to_js()
+ for field in data.owner_data.fields
+ if field.editable
+ ],
+ "search_fields": [
+ camel_to_snake(field.name)
+ for field in data.owner_data.fields
+ if field.searchable
+ ],
+ "ui_table_columns": [
+ field.field_to_ui_table_column()
+ for field in data.owner_data.fields + ui_table_columns
+ if field.sortable
+ ],
+ "ui_mock_data": [
+ json.dumps(
+ {
+ field.name: field.field_mock_value(index=index)
+ for field in data.owner_data.fields + ui_table_columns
+ }
+ )
+ for index in range(1, 5)
+ ],
+ "db_fields": [field.field_to_db() for field in data.owner_data.fields],
+ "all_fields": [field.field_to_py() for field in data.owner_data.fields],
+ },
+ "client_data": {
+ "name": data.client_data.name,
+ "editable": data.client_data.editable,
+ "search_fields": [
+ camel_to_snake(field.name)
+ for field in data.client_data.fields
+ if field.searchable
+ ],
+ "ui_table_columns": [
+ field.field_to_ui_table_column()
+ for field in data.client_data.fields + ui_table_columns
+ if field.sortable
+ ],
+ "ui_mock_data": [
+ json.dumps(
+ {
+ field.name: field.field_mock_value(index=index)
+ for field in data.client_data.fields + ui_table_columns
+ }
+ )
+ for index in range(1, 7)
+ ],
+ "db_fields": [field.field_to_db() for field in data.client_data.fields],
+ "all_fields": [field.field_to_py() for field in data.client_data.fields],
+ },
+ "settings_data": {
+ "enabled": data.settings_data.enabled,
+ "is_admin_settings_only": data.settings_data.type == "admin",
+ "db_fields": [field.field_to_db() for field in data.settings_data.fields],
+ "all_fields": [field.field_to_py() for field in data.settings_data.fields],
+ },
+ "public_page": data.public_page,
+ "cancel_comment": remove_line_marker,
+ }
+
+
+def _fields_to_html_input(
+ fields: list[DataField], model_name: str, ext_stub_dir: Path
+) -> str:
+ template_path = Path(
+ ext_stub_dir, "templates", "extension_builder_stub", "_input_fields.html"
+ ).as_posix()
+
+ rederer = _render_file(
+ template_path,
+ {
+ "fields": fields,
+ "model_name": model_name,
+ },
+ )
+ return rederer
+
+
+def _remove_lines_with_string(file_path: str, target: str) -> None:
+ """
+ Removes lines from a file that contain the given target string.
+
+ Args:
+ file_path (str): Path to the file.
+ target (str): Substring to search for in lines to remove.
+ """
+ with open(file_path, encoding="utf-8") as f:
+ lines = f.readlines()
+
+ filtered_lines = [line for line in lines if target not in line]
+
+ with open(file_path, "w", encoding="utf-8") as f:
+ f.writelines(filtered_lines)
+
+
+def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
+ """
+ Recursively renames files and directories by replacing part of their names.
+ """
+ # First rename directories (bottom-up) so we don't lose paths while renaming
+ for root, dirs, files in os.walk(directory, topdown=False):
+ if _is_excluded_dir(root):
+ continue
+ # Rename files
+ for filename in files:
+ if old_text in filename:
+ old_path = os.path.join(root, filename)
+ new_filename = filename.replace(old_text, new_text)
+ new_path = os.path.join(root, new_filename)
+ try:
+ os.rename(old_path, new_path)
+ logger.trace(f"Renamed file: {old_path} -> {new_path}")
+ except Exception as e:
+ logger.warning(f"Failed to rename file {old_path}: {e}")
+
+ # Rename directories
+ for dirname in dirs:
+ if old_text in dirname:
+ old_dir_path = os.path.join(root, dirname)
+ new_dir_name = dirname.replace(old_text, new_text)
+ new_dir_path = os.path.join(root, new_dir_name)
+ try:
+ os.rename(old_dir_path, new_dir_path)
+ logger.trace(f"Renamed directory: {old_dir_path} -> {new_dir_path}")
+ except Exception as e:
+ logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
+
+
+def _is_excluded_dir(path):
+ for excluded_dir in excluded_dirs:
+ if path.startswith(excluded_dir):
+ return True
+ return False
diff --git a/lnbits/core/templates/admin/_tab_extensions.html b/lnbits/core/templates/admin/_tab_extensions.html
index 14ed75f6..9acf114d 100644
--- a/lnbits/core/templates/admin/_tab_extensions.html
+++ b/lnbits/core/templates/admin/_tab_extensions.html
@@ -84,6 +84,27 @@
/>
+
+
+ +
+`name` and
+ `sort description` fields are what the users will
+ see when browsing the list of extensions.
+ `id` field is used internally and in the URL of
+ your extension.
+ created_at, updated_at and
+ extra.
+
+ (Owner Data) that will be used as a title for the public
+ page.
+ (Owner Data) that will be used as a description for the
+ public page. (Client Data) that will be shown as inputs in
+ the public page form.
+ (Client Data) as parameters.
+ (Owner Data) that represents the wallet which
+ will generate the invoice and receive the payments.
+ Wallet will be
+ shown.
+ (Owner Data) that represents the currency
+ which will be used to for the amount.
+ Currency will
+ be shown.
+ (Owner Data) or
+ (Client Data) that represents the amount (in
+ the selected currency).
+ Integer and
+ Float will be shown.
+ (Client Data) that will be set to true when
+ the invoice is paid.
+ Boolean will be
+ shown.
+