[feat] Update extension builder for 1.4.1 (#3725)

Co-authored-by: dni  <office@dnilabs.com>
This commit is contained in:
Vlad Stan
2026-02-25 07:53:31 +01:00
committed by dni ⚡
co-authored by dni ⚡
parent 6cb9b7da1f
commit 2c61e219b8
11 changed files with 190 additions and 50 deletions
+21 -1
View File
@@ -269,10 +269,30 @@ class ExtensionRelease(BaseModel):
async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]: async def get_github_releases(cls, org: str, repo: str) -> list[ExtensionRelease]:
try: try:
github_releases = await cls.fetch_github_releases(org, repo) github_releases = await cls.fetch_github_releases(org, repo)
return [ extension_releases = [
ExtensionRelease.from_github_release(f"{org}/{repo}", r) ExtensionRelease.from_github_release(f"{org}/{repo}", r)
for r in github_releases for r in github_releases
] ]
for release in extension_releases:
if not release.details_link:
continue
try:
config = await ExtensionConfig.fetch_github_release_config(
org, repo, release.version
)
except Exception as e:
logger.warning(e)
config = None
if not config:
continue
release.min_lnbits_version = config.min_lnbits_version
release.max_lnbits_version = config.max_lnbits_version
release.is_version_compatible = config.is_version_compatible()
release.icon = icon_to_github_url(f"{org}/{repo}", config.tile)
return extension_releases
except Exception as e: except Exception as e:
logger.warning(e) logger.warning(e)
return [] return []
+25
View File
@@ -1,6 +1,8 @@
from __future__ import annotations from __future__ import annotations
import json import json
import random
import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Literal from typing import Any, Literal
@@ -55,6 +57,29 @@ class DataField(BaseModel):
field_type += ' = "sat"' field_type += ' = "sat"'
return f"{field_name}: {field_type}" return f"{field_name}: {field_type}"
def field_to_random_value(self) -> str:
field_name = camel_to_snake(self.name)
field_value: Any = f'"{self.type}"'
if self.type == "json":
field_value = '"{}"'
elif self.type == "wallet":
field_value = f'"{uuid.uuid4()}"'
elif self.type == "currency":
field_value = '"sat"'
elif self.type in ["str", "text"]:
field_value = f'"{field_name}_{urlsafe_short_hash()}"'
elif self.type == "int":
field_value = random.randint(1, 100) # noqa: S311
elif self.type == "float":
field_value = random.uniform(1.0, 100.0) # noqa: S311
elif self.type == "bool":
field_value = random.choice([True, False]) # noqa: S311
elif self.type == "datetime":
random_days = random.randint(-30, 30) # noqa: S311
random_date = datetime.now(timezone.utc) - timedelta(days=random_days)
field_value = f"""datetime.fromisoformat("{random_date.isoformat()}")"""
return f"{field_name} = {field_value},"
def field_to_js(self) -> str: def field_to_js(self) -> str:
field_name = camel_to_snake(self.name) field_name = camel_to_snake(self.name)
default_value = "null" default_value = "null"
+44 -11
View File
@@ -20,6 +20,7 @@ from lnbits.helpers import (
camel_to_words, camel_to_words,
download_url, download_url,
lowercase_first_letter, lowercase_first_letter,
snake_to_camel,
) )
from lnbits.settings import settings from lnbits.settings import settings
@@ -195,13 +196,29 @@ def _copy_ext_stub_to_build_dir(
ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id) ext_build_dir = Path(working_dir, new_ext_id, working_dir_name, new_ext_id)
shutil.rmtree(ext_build_dir, True) shutil.rmtree(ext_build_dir, True)
shutil.copytree(ext_stub_cache_dir, ext_build_dir) shutil.copytree(
ext_stub_cache_dir,
ext_build_dir,
ignore=shutil.ignore_patterns(
"__pycache__",
".git",
".env",
".venv",
"*.env",
"*.log",
"node_modules",
".mypy_cache",
".pytest_cache",
".ruff_cache",
),
)
return ext_build_dir return ext_build_dir
def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None: def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None:
parsed_data = _parse_extension_data(data) parsed_data = _parse_extension_data(data)
for py_file in py_files: test_files = [f"tests/{p.name}" for p in Path(ext_stub_dir, "tests").glob("*.py")]
for py_file in py_files + test_files:
template_path = Path(ext_stub_dir, py_file).as_posix() template_path = Path(ext_stub_dir, py_file).as_posix()
rederer = _render_file(template_path, parsed_data) rederer = _render_file(template_path, parsed_data)
with open(template_path, "w", encoding="utf-8") as f: with open(template_path, "w", encoding="utf-8") as f:
@@ -209,7 +226,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
_remove_lines_with_string(template_path, remove_line_marker) _remove_lines_with_string(template_path, remove_line_marker)
template_path = Path(ext_stub_dir, "static", "js", "index.js").as_posix() template_path = Path(ext_stub_dir, "static", "index.js").as_posix()
rederer = _render_file( rederer = _render_file(
template_path, {"preview": data.preview_action, **parsed_data} template_path, {"preview": data.preview_action, **parsed_data}
) )
@@ -234,9 +251,7 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"settingsFormDialog.data", "settingsFormDialog.data",
ext_stub_dir, ext_stub_dir,
) )
template_path = Path( template_path = Path(ext_stub_dir, "static", "index.vue").as_posix()
ext_stub_dir, "templates", "extension_builder_stub", "index.html"
).as_posix()
rederer = _render_file( rederer = _render_file(
template_path, template_path,
{ {
@@ -263,12 +278,12 @@ def _replace_jinja_placeholders(data: ExtensionData, ext_stub_dir: Path) -> None
"publicClientData", "publicClientData",
ext_stub_dir, ext_stub_dir,
) )
public_template_path = Path( public_template_path = Path(ext_stub_dir, "static", "public_page.vue")
ext_stub_dir, "templates", "extension_builder_stub", "public_page.html" public_component_path = Path(ext_stub_dir, "static", "public_page.js")
)
template_path = public_template_path.as_posix() template_path = public_template_path.as_posix()
if not data.public_page.has_public_page: if not data.public_page.has_public_page:
public_template_path.unlink(missing_ok=True) public_template_path.unlink(missing_ok=True)
public_component_path.unlink(missing_ok=True)
else: else:
rederer = _render_file( rederer = _render_file(
template_path, template_path,
@@ -308,8 +323,10 @@ def zip_directory(source_dir, zip_path):
def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None: def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) -> None:
extension_dir_path = extension_dir.as_posix() extension_dir_path = extension_dir.as_posix()
# the order of fields is important, do not chage
rename_values = { rename_values = {
"extension_builder_stub_name": data.name, "extension_builder_stub_name": data.name,
"extension_builder_stub_camel_name": snake_to_camel(data.id, True),
"extension_builder_stub_short_description": data.short_description or "", "extension_builder_stub_short_description": data.short_description or "",
"extension_builder_stub": data.id, "extension_builder_stub": data.id,
"OwnerData": data.owner_data.name, "OwnerData": data.owner_data.name,
@@ -328,7 +345,7 @@ def _rename_extension_builder_stub(data: ExtensionData, extension_dir: Path) ->
directory=extension_dir_path, directory=extension_dir_path,
old_text=old_text, old_text=old_text,
new_text=new_text, new_text=new_text,
file_extensions=[".py", ".js", ".html", ".md", ".json", ".toml"], file_extensions=[".py", ".js", ".vue", ".html", ".md", ".json", ".toml"],
) )
_rename_files_and_dirs_in_directory( _rename_files_and_dirs_in_directory(
@@ -406,6 +423,7 @@ def _parse_extension_data(data: ExtensionData) -> dict:
"owner_data": { "owner_data": {
"name": data.owner_data.name, "name": data.owner_data.name,
"editable": data.owner_data.editable, "editable": data.owner_data.editable,
"fields": [field.name for field in data.owner_data.fields],
"js_fields": [ "js_fields": [
field.field_to_js() field.field_to_js()
for field in data.owner_data.fields for field in data.owner_data.fields
@@ -432,6 +450,18 @@ def _parse_extension_data(data: ExtensionData) -> dict:
], ],
"db_fields": [field.field_to_db() for field in data.owner_data.fields], "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], "all_fields": [field.field_to_py() for field in data.owner_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.owner_data.fields
],
"public_fields": [
field.field_to_py()
for field in data.owner_data.fields
if field.name
in [
data.public_page.owner_data_fields.name,
data.public_page.owner_data_fields.description,
]
],
}, },
"client_data": { "client_data": {
"name": data.client_data.name, "name": data.client_data.name,
@@ -457,6 +487,9 @@ def _parse_extension_data(data: ExtensionData) -> dict:
], ],
"db_fields": [field.field_to_db() for field in data.client_data.fields], "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], "all_fields": [field.field_to_py() for field in data.client_data.fields],
"random_fields_values": [
field.field_to_random_value() for field in data.client_data.fields
],
}, },
"settings_data": { "settings_data": {
"enabled": data.settings_data.enabled, "enabled": data.settings_data.enabled,
@@ -536,7 +569,7 @@ def _rename_files_and_dirs_in_directory(directory, old_text, new_text):
logger.warning(f"Failed to rename directory {old_dir_path}: {e}") logger.warning(f"Failed to rename directory {old_dir_path}: {e}")
def _is_excluded_dir(path): def _is_excluded_dir(path: str) -> bool:
for excluded_dir in excluded_dirs: for excluded_dir in excluded_dirs:
if path.startswith(excluded_dir): if path.startswith(excluded_dir):
return True return True
+18 -31
View File
@@ -42,54 +42,38 @@ async def robots():
@generic_router.get( @generic_router.get(
"/extensions/builder/preview/{ext_id}", "/extensions/builder/preview/{ext_id}/{resource}",
name="extensions builder", name="extensions builder",
dependencies=[Depends(check_extension_builder)], dependencies=[Depends(check_extension_builder)],
) )
async def extensions_builder_preview( async def extensions_builder_preview(
request: Request,
ext_id: str, ext_id: str,
resource: str | None = None,
page_name: str | None = None, page_name: str | None = None,
user: User = Depends(check_user_exists), user: User = Depends(check_user_exists),
) -> HTMLResponse: ) -> FileResponse:
working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest() working_dir_name = "preview_" + sha256(user.id.encode("utf-8")).hexdigest()
html_file_name = "index.html"
if page_name == "public_page":
html_file_name = "public_page.html"
html_file_path = Path( file_name = "index"
if page_name == "public_page":
file_name = "public_page"
resource_path = Path(
settings.extension_builder_working_dir_path,
"extension_builder_stub", "extension_builder_stub",
ext_id, ext_id,
working_dir_name, working_dir_name,
ext_id, ext_id,
"templates", "static",
ext_id,
html_file_name,
) )
html_file_full_path = Path( file_ext = ".vue" if resource == "template" else ".js"
settings.extension_builder_working_dir_path, html_file_path file_full_path = Path(resource_path, file_name + file_ext)
)
if not html_file_full_path.is_file(): if not file_full_path.is_file():
return template_renderer().TemplateResponse( raise HTTPException(status_code=HTTPStatus.NOT_FOUND)
request,
"error.html",
{
"status_code": 404,
"message": f"Extension {ext_id} not found, refresh Preview.",
},
status_code=HTTPStatus.NOT_FOUND,
)
response = template_renderer().TemplateResponse(
request,
html_file_path.as_posix(),
{
"user": user.json(),
},
)
response = FileResponse(file_full_path.absolute().as_posix())
response.headers["Content-Security-Policy"] = ( response.headers["Content-Security-Policy"] = (
"default-src 'self'; " "default-src 'self'; "
"style-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; "
@@ -208,6 +192,9 @@ admin_ui_checks = [Depends(check_admin), Depends(check_admin_ui)]
@generic_router.get( @generic_router.get(
"/extensions/builder", dependencies=[Depends(check_extension_builder)] "/extensions/builder", dependencies=[Depends(check_extension_builder)]
) )
@generic_router.get(
"/extensions/builder/preview", dependencies=[Depends(check_extension_builder)]
)
async def index( async def index(
request: Request, user: User = Depends(check_user_exists) request: Request, user: User = Depends(check_user_exists)
) -> HTMLResponse: ) -> HTMLResponse:
+8
View File
@@ -391,6 +391,14 @@ def camel_to_snake(name: str) -> str:
return name.lower() return name.lower()
def snake_to_camel(name: str, capitalize_first: bool | None = False) -> str:
components = name.split("_")
camel = components[0] + "".join(x.capitalize() for x in components[1:])
if capitalize_first:
camel = camel[0].upper() + camel[1:]
return camel
def is_camel_case(v: str) -> bool: def is_camel_case(v: str) -> bool:
return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None return re.match(r"^[A-Z][a-z0-9]+([A-Z][a-z0-9]+)*$", v) is not None
File diff suppressed because one or more lines are too long
+5
View File
@@ -106,6 +106,11 @@ const routes = [
name: 'ExtensionsBuilder', name: 'ExtensionsBuilder',
component: PageExtensionBuilder component: PageExtensionBuilder
}, },
{
path: '/extensions/builder/preview',
name: 'ExtensionsBuilderPreview',
component: PageExtensionBuilderPreview
},
{ {
path: '/extensions', path: '/extensions',
name: 'Extensions', name: 'Extensions',
+18 -6
View File
@@ -288,11 +288,10 @@ window.PageExtensionBuilder = {
`/api/v1/extension/${stub_ext_id}/releases` `/api/v1/extension/${stub_ext_id}/releases`
) )
this.extensionStubVersions = data.sort((a, b) => this.extensionStubVersions = data
a.version < b.version ? 1 : -1 const validVersions = data.filter(v => v.is_version_compatible)
) this.extensionData.stub_version = validVersions[0]
this.extensionData.stub_version = this.extensionStubVersions[0] ? validVersions[0].version
? this.extensionStubVersions[0].version
: '' : ''
} catch (error) { } catch (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
@@ -311,7 +310,20 @@ window.PageExtensionBuilder = {
iframeDoc.body.style.transform = 'scale(0.8)' iframeDoc.body.style.transform = 'scale(0.8)'
iframeDoc.body.style.transformOrigin = 'center top' iframeDoc.body.style.transformOrigin = 'center top'
} }
iframe.src = `/extensions/builder/preview/${this.extensionData.id}?page_name=${previewPageName}`
let componentName =
'Page' +
this.extensionData.id
.toLowerCase()
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join('')
if (previewPageName === 'public_page') componentName += 'Public'
iframe.src =
`/extensions/builder/preview?` +
`ext_id=${this.extensionData.id}` +
`&page=${previewPageName}` +
`&component=${componentName}`
}, },
initBasicData() { initBasicData() {
this.extensionData.owner_data.fields = [ this.extensionData.owner_data.fields = [
@@ -0,0 +1,46 @@
window.PageExtensionBuilderPreview = {
template: '#page-extension-builder-preview',
mixins: [windowMixin],
watch: {
name: 'reload'
},
data() {
return {
extId: '',
pageName: '',
componentName: null
}
},
methods: {
async reload() {
await LNbits.utils.loadTemplate(
`/extensions/builder/preview/${this.extId}/template?page_name=${this.pageName}`
)
await LNbits.utils.loadScript(
`/extensions/builder/preview/${this.extId}/component?page_name=${this.pageName}`
)
this._component = window[this.componentName]
console.log(
'LNbits preview reloaded componentName:',
this.componentName,
!!this._component
)
this.$forceUpdate()
}
},
async created() {
const urlParams = new URLSearchParams(window.location.search)
this.extId = urlParams.get('ext_id') || ''
this.pageName = urlParams.get('page') || ''
this.componentName = urlParams.get('component') || ''
await this.reload()
},
render() {
if (this._component) {
return Vue.h(this._component)
}
return Vue.h('div', 'Loading...')
}
}
+2
View File
@@ -45,6 +45,8 @@
"js/pages/error.js", "js/pages/error.js",
"js/pages/home.js", "js/pages/home.js",
"js/pages/extensions_builder.js", "js/pages/extensions_builder.js",
"js/pages/extensions_builder.js",
"js/pages/extensions_builder_preview.js",
"js/pages/extensions.js", "js/pages/extensions.js",
"js/pages/first-install.js", "js/pages/first-install.js",
"js/pages/payments.js", "js/pages/payments.js",
+2
View File
@@ -98,6 +98,8 @@
"js/pages/error.js", "js/pages/error.js",
"js/pages/home.js", "js/pages/home.js",
"js/pages/extensions_builder.js", "js/pages/extensions_builder.js",
"js/pages/extensions_builder.js",
"js/pages/extensions_builder_preview.js",
"js/pages/extensions.js", "js/pages/extensions.js",
"js/pages/first-install.js", "js/pages/first-install.js",
"js/pages/payments.js", "js/pages/payments.js",