refactor(homarr): extract layout packing to filter plugin
This commit is contained in:
parent
e0cb1ac68c
commit
61193e26f4
6 changed files with 405 additions and 58 deletions
|
|
@ -13,7 +13,7 @@ and customizable application tiles.
|
|||
- a local admin user with bcrypt-hashed password
|
||||
- OIDC and credentials admin groups with full permissions
|
||||
- application tiles defined in `homarr_apps`, auto-laid-out across all
|
||||
three screen sizes
|
||||
three screen sizes via the bundled `homarr_compute_layouts` filter
|
||||
- Skips the onboarding wizard so the instance is usable right after deploy
|
||||
- Restarts the container via handler when the seed or compose file changes
|
||||
|
||||
|
|
@ -100,6 +100,53 @@ homarr_apps:
|
|||
width: 2
|
||||
```
|
||||
|
||||
## Layout filter plugin
|
||||
|
||||
The grid-packing algorithm that places tiles on the desktop, tablet
|
||||
and mobile layouts lives in `filter_plugins/homarr_layout.py` rather
|
||||
than inside the Jinja seed template. This keeps the SQL template
|
||||
readable and lets the algorithm be unit-tested in isolation.
|
||||
|
||||
The filter is invoked once from `tasks/main.yml`:
|
||||
|
||||
```yaml
|
||||
- name: Compute Homarr app layouts
|
||||
ansible.builtin.set_fact:
|
||||
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
|
||||
```
|
||||
|
||||
This produces a `homarr_layout` fact with two keys, both consumed by
|
||||
`templates/homarr_seed.sql.j2`:
|
||||
|
||||
| Key | Shape | Purpose |
|
||||
|---|---|---|
|
||||
| `apps` | list, same order as `homarr_apps` | each entry enriched with `desktop`, `tablet`, `mobile` sub-dicts of `{x, y, w, h}` |
|
||||
| `section_height` | dict with `desktop`, `tablet`, `mobile` | minimum height of the parent section so all tiles fit |
|
||||
|
||||
The filter signature accepts custom column counts if Homarr ever
|
||||
changes the breakpoint widths:
|
||||
|
||||
```jinja
|
||||
{{ homarr_apps | homarr_compute_layouts(desktop_cols=12, tablet_cols=8, mobile_cols=4) }}
|
||||
```
|
||||
|
||||
To debug a layout without running the full deploy, run the play with
|
||||
`-vv` — the `Show computed app layouts` task dumps the full
|
||||
`homarr_layout` fact.
|
||||
|
||||
### Running the filter tests
|
||||
|
||||
The filter is covered by unit tests in
|
||||
`filter_plugins/tests/test_homarr_layout.py`:
|
||||
|
||||
```bash
|
||||
pip install pytest ansible-core
|
||||
pytest filter_plugins/tests/
|
||||
```
|
||||
|
||||
15 tests cover packing, width clamping, height/section-height,
|
||||
input validation and custom grid sizes.
|
||||
|
||||
## First login
|
||||
|
||||
After the role completes, log in at `{{ homarr_base_url }}` with:
|
||||
|
|
|
|||
140
roles/homarr/filter_plugins/homarr_layout.py
Normal file
140
roles/homarr/filter_plugins/homarr_layout.py
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# SPDX-License-Identifier: MIT-0
|
||||
|
||||
"""Custom Ansible filter plugin for computing Homarr grid layouts.
|
||||
|
||||
The Homarr SQL seed needs item_layout rows for three breakpoints
|
||||
(desktop / tablet / mobile). Rather than embedding the packing
|
||||
algorithm in Jinja with namespace gymnastics, this filter does the
|
||||
computation in Python and hands the seed template a ready-to-render
|
||||
data structure.
|
||||
|
||||
Usage in tasks:
|
||||
|
||||
- name: Compute Homarr app layouts
|
||||
ansible.builtin.set_fact:
|
||||
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
|
||||
|
||||
The result is a dict with two keys:
|
||||
|
||||
apps — original homarr_apps in order, each enriched with
|
||||
'desktop', 'tablet', 'mobile' sub-dicts of
|
||||
{'x', 'y', 'w', 'h'} ready for SQL templating.
|
||||
section_height — dict with 'desktop', 'tablet', 'mobile' keys
|
||||
giving the minimum height (in grid cells) the
|
||||
parent section must have to fit all tiles.
|
||||
"""
|
||||
|
||||
from ansible.errors import AnsibleFilterError
|
||||
|
||||
|
||||
def _pack(apps, cols):
|
||||
"""Greedy left-to-right packing into a fixed-column grid.
|
||||
|
||||
Width values larger than the grid are clamped to the grid width
|
||||
rather than overflowing — so a tile declared with width=8 still
|
||||
renders on the 6-column tablet grid (as a full-width tile) and on
|
||||
the 2-column mobile grid (as a full-width tile) without breaking
|
||||
the layout.
|
||||
|
||||
Returns (placements, total_height) where placements is a list of
|
||||
{'id', 'x', 'y', 'w', 'h'} dicts, one per input app, in the same
|
||||
order. total_height is the y-coordinate of the bottom of the last
|
||||
occupied row (i.e. max(y + h) across placements).
|
||||
"""
|
||||
x = 0
|
||||
y = 0
|
||||
row_h = 0
|
||||
max_y = 0
|
||||
placements = []
|
||||
|
||||
for app in apps:
|
||||
w = min(int(app.get('width', 1)), cols)
|
||||
h = int(app.get('height', 1))
|
||||
|
||||
# Wrap to the next row when the tile would overflow the grid.
|
||||
if x + w > cols:
|
||||
x = 0
|
||||
y += row_h
|
||||
row_h = 0
|
||||
|
||||
placements.append({
|
||||
'id': app['id'],
|
||||
'x': x,
|
||||
'y': y,
|
||||
'w': w,
|
||||
'h': h,
|
||||
})
|
||||
|
||||
x += w
|
||||
if h > row_h:
|
||||
row_h = h
|
||||
if y + h > max_y:
|
||||
max_y = y + h
|
||||
|
||||
return placements, max_y
|
||||
|
||||
|
||||
def homarr_compute_layouts(apps, desktop_cols=10, tablet_cols=6,
|
||||
mobile_cols=2):
|
||||
"""Compute responsive layouts for a list of Homarr apps.
|
||||
|
||||
Input validation is intentionally strict — a malformed apps list
|
||||
should fail the play with a clear message rather than produce a
|
||||
broken SQL seed and a silently misconfigured dashboard.
|
||||
|
||||
Note: uniqueness of app ids is NOT checked here. The role's
|
||||
`Validate homarr_apps have unique ids` assert task runs earlier
|
||||
and is the single source of truth for that check.
|
||||
"""
|
||||
if not isinstance(apps, list):
|
||||
raise AnsibleFilterError(
|
||||
"homarr_compute_layouts: expected a list of apps, "
|
||||
"got {0}".format(type(apps).__name__)
|
||||
)
|
||||
|
||||
for index, app in enumerate(apps):
|
||||
if not isinstance(app, dict):
|
||||
raise AnsibleFilterError(
|
||||
"homarr_compute_layouts: app at index {0} is not a "
|
||||
"dict (got {1})".format(index, type(app).__name__)
|
||||
)
|
||||
for required in ('id', 'width'):
|
||||
if required not in app:
|
||||
raise AnsibleFilterError(
|
||||
"homarr_compute_layouts: app at index {0} is "
|
||||
"missing required key '{1}'".format(index, required)
|
||||
)
|
||||
|
||||
desktop, h_desktop = _pack(apps, desktop_cols)
|
||||
tablet, h_tablet = _pack(apps, tablet_cols)
|
||||
mobile, h_mobile = _pack(apps, mobile_cols)
|
||||
|
||||
enriched = []
|
||||
for src, d, t, m in zip(apps, desktop, tablet, mobile):
|
||||
enriched.append({
|
||||
**src,
|
||||
'desktop': {'x': d['x'], 'y': d['y'], 'w': d['w'], 'h': d['h']},
|
||||
'tablet': {'x': t['x'], 'y': t['y'], 'w': t['w'], 'h': t['h']},
|
||||
'mobile': {'x': m['x'], 'y': m['y'], 'w': m['w'], 'h': m['h']},
|
||||
})
|
||||
|
||||
# Floor section height at 1 so the section stays visible even
|
||||
# when homarr_apps is empty.
|
||||
return {
|
||||
'apps': enriched,
|
||||
'section_height': {
|
||||
'desktop': max(h_desktop, 1),
|
||||
'tablet': max(h_tablet, 1),
|
||||
'mobile': max(h_mobile, 1),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FilterModule(object):
|
||||
"""Ansible filter plugin entry point."""
|
||||
|
||||
def filters(self):
|
||||
return {
|
||||
'homarr_compute_layouts': homarr_compute_layouts,
|
||||
}
|
||||
178
roles/homarr/filter_plugins/tests/test_homarr_layout.py
Normal file
178
roles/homarr/filter_plugins/tests/test_homarr_layout.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# SPDX-License-Identifier: MIT-0
|
||||
|
||||
"""Unit tests for the homarr_layout filter plugin.
|
||||
|
||||
Run from the role root:
|
||||
|
||||
pytest filter_plugins/tests/
|
||||
|
||||
Requires `pytest` and `ansible-core` in the environment.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Make the filter importable without having Ansible auto-discovery in
|
||||
# the way (it would only run during a real `ansible-playbook` invocation).
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from ansible.errors import AnsibleFilterError # noqa: E402
|
||||
from homarr_layout import homarr_compute_layouts # noqa: E402
|
||||
|
||||
|
||||
def _app(app_id, width, height=1):
|
||||
"""Build a minimal app dict for tests."""
|
||||
return {
|
||||
'id': app_id,
|
||||
'name': app_id.title(),
|
||||
'icon': 'https://example.com/{0}.png'.format(app_id),
|
||||
'href': 'https://{0}.example.com'.format(app_id),
|
||||
'width': width,
|
||||
'height': height,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Happy-path packing
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def test_empty_apps_returns_empty_list_and_min_height():
|
||||
result = homarr_compute_layouts([])
|
||||
assert result['apps'] == []
|
||||
# Even an empty grid keeps section_height >= 1 so the section
|
||||
# renders in the UI.
|
||||
assert result['section_height'] == {
|
||||
'desktop': 1, 'tablet': 1, 'mobile': 1,
|
||||
}
|
||||
|
||||
|
||||
def test_single_app_positioned_at_origin_in_all_grids():
|
||||
result = homarr_compute_layouts([_app('a', width=2)])
|
||||
a = result['apps'][0]
|
||||
assert a['desktop'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
|
||||
assert a['tablet'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
|
||||
assert a['mobile'] == {'x': 0, 'y': 0, 'w': 2, 'h': 1}
|
||||
|
||||
|
||||
def test_original_app_keys_are_preserved():
|
||||
apps = [_app('nextcloud', width=2)]
|
||||
apps[0]['description'] = 'Cloud Storage'
|
||||
result = homarr_compute_layouts(apps)
|
||||
a = result['apps'][0]
|
||||
# Original fields survive the layout enrichment.
|
||||
assert a['name'] == 'Nextcloud'
|
||||
assert a['description'] == 'Cloud Storage'
|
||||
assert a['icon'] == 'https://example.com/nextcloud.png'
|
||||
|
||||
|
||||
def test_desktop_wraps_after_filling_row():
|
||||
# 5 apps of width 2 fill the 10-col desktop row exactly; 6th wraps.
|
||||
apps = [_app('a{0}'.format(i), width=2) for i in range(6)]
|
||||
result = homarr_compute_layouts(apps)
|
||||
assert result['apps'][4]['desktop'] == {'x': 8, 'y': 0, 'w': 2, 'h': 1}
|
||||
assert result['apps'][5]['desktop'] == {'x': 0, 'y': 1, 'w': 2, 'h': 1}
|
||||
|
||||
|
||||
def test_mobile_wraps_after_every_app():
|
||||
# Mobile is only 2 cols wide → every app of width 2 starts a new row.
|
||||
apps = [_app('a{0}'.format(i), width=2) for i in range(3)]
|
||||
result = homarr_compute_layouts(apps)
|
||||
assert [a['mobile']['y'] for a in result['apps']] == [0, 1, 2]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Width clamping
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def test_width_clamped_per_grid():
|
||||
result = homarr_compute_layouts([_app('big', width=8)])
|
||||
# Desktop has room (8 <= 10), tablet clamps to 6, mobile clamps to 2.
|
||||
a = result['apps'][0]
|
||||
assert a['desktop']['w'] == 8
|
||||
assert a['tablet']['w'] == 6
|
||||
assert a['mobile']['w'] == 2
|
||||
|
||||
|
||||
def test_width_larger_than_desktop_still_clamps():
|
||||
# A pathological width=20 still works — it just becomes a full-width
|
||||
# tile on every grid.
|
||||
result = homarr_compute_layouts([_app('huge', width=20)])
|
||||
a = result['apps'][0]
|
||||
assert a['desktop']['w'] == 10
|
||||
assert a['tablet']['w'] == 6
|
||||
assert a['mobile']['w'] == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Height handling
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def test_section_height_grows_with_rows():
|
||||
# 6 apps of width 2 on desktop → 5 in row 1, 1 in row 2.
|
||||
apps = [_app('a{0}'.format(i), width=2) for i in range(6)]
|
||||
result = homarr_compute_layouts(apps)
|
||||
assert result['section_height']['desktop'] == 2
|
||||
# On mobile every app is on its own row.
|
||||
assert result['section_height']['mobile'] == 6
|
||||
|
||||
|
||||
def test_tall_app_extends_row_height():
|
||||
apps = [
|
||||
_app('tall', width=2, height=3),
|
||||
_app('short', width=2, height=1),
|
||||
]
|
||||
result = homarr_compute_layouts(apps)
|
||||
# Both fit in row 0 horizontally, but the section must be 3 tall.
|
||||
assert result['section_height']['desktop'] == 3
|
||||
|
||||
|
||||
def test_tall_app_pushes_subsequent_row_down():
|
||||
# tall (h=3) fills full desktop width → next app wraps to y=3.
|
||||
result = homarr_compute_layouts([
|
||||
_app('tall', width=10, height=3),
|
||||
_app('next', width=2, height=1),
|
||||
])
|
||||
assert result['apps'][1]['desktop'] == {'x': 0, 'y': 3, 'w': 2, 'h': 1}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Input validation
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def test_rejects_non_list_input():
|
||||
with pytest.raises(AnsibleFilterError, match='expected a list'):
|
||||
homarr_compute_layouts('not a list')
|
||||
|
||||
|
||||
def test_rejects_non_dict_entry():
|
||||
with pytest.raises(AnsibleFilterError, match='not a dict'):
|
||||
homarr_compute_layouts(['just a string'])
|
||||
|
||||
|
||||
def test_rejects_app_without_id():
|
||||
with pytest.raises(AnsibleFilterError, match="missing required key 'id'"):
|
||||
homarr_compute_layouts([{'name': 'no id', 'width': 2}])
|
||||
|
||||
|
||||
def test_rejects_app_without_width():
|
||||
with pytest.raises(AnsibleFilterError,
|
||||
match="missing required key 'width'"):
|
||||
homarr_compute_layouts([{'id': 'no-width', 'name': 'x'}])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Configurable grid sizes
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
def test_custom_grid_sizes():
|
||||
# If Homarr ever switches to 12-col desktop, the filter still works.
|
||||
result = homarr_compute_layouts(
|
||||
[_app('a', width=4), _app('b', width=4), _app('c', width=4)],
|
||||
desktop_cols=12, tablet_cols=8, mobile_cols=4,
|
||||
)
|
||||
# All three fit in desktop row 0 (4+4+4 = 12).
|
||||
assert [a['desktop']['x'] for a in result['apps']] == [0, 4, 8]
|
||||
assert result['section_height']['desktop'] == 1
|
||||
|
|
@ -128,7 +128,23 @@
|
|||
no_log: true
|
||||
|
||||
# =====================================================================
|
||||
# 5. SEED DATABASE (only if local admin user does not exist yet)
|
||||
# 5. COMPUTE APP LAYOUTS
|
||||
# =====================================================================
|
||||
# Packing is done by the homarr_compute_layouts filter plugin (Python)
|
||||
# rather than inline Jinja, so the seed template stays readable and the
|
||||
# packing algorithm can be unit-tested in isolation.
|
||||
|
||||
- name: Compute Homarr app layouts
|
||||
ansible.builtin.set_fact:
|
||||
homarr_layout: "{{ homarr_apps | homarr_compute_layouts }}"
|
||||
|
||||
- name: Show computed app layouts
|
||||
ansible.builtin.debug:
|
||||
var: homarr_layout
|
||||
verbosity: 1
|
||||
|
||||
# =====================================================================
|
||||
# 6. SEED DATABASE (only if local admin user does not exist yet)
|
||||
# =====================================================================
|
||||
|
||||
- name: Check if local admin user exists
|
||||
|
|
|
|||
|
|
@ -1,35 +1,13 @@
|
|||
{#-
|
||||
Auto-layout packing macro.
|
||||
Homarr database seed.
|
||||
|
||||
Greedy left-to-right packing of apps into a grid with `cols` columns.
|
||||
Returns the list of apps with computed x/y/w/h fields.
|
||||
|
||||
Width is clamped to cols (so an app wider than the grid is downsized
|
||||
rather than overflowing). Height is taken as-is.
|
||||
The packing algorithm previously lived in this template as a Jinja
|
||||
`pack()` macro with from_json/to_json round-trips. It has been
|
||||
extracted to the `homarr_compute_layouts` filter plugin (see
|
||||
filter_plugins/homarr_layout.py) and the result is provided as the
|
||||
`homarr_layout` fact set in tasks/main.yml. This template therefore
|
||||
only renders SQL — no logic.
|
||||
-#}
|
||||
{%- macro pack(apps, cols) -%}
|
||||
{%- set ns = namespace(x=0, y=0, row_h=0, out=[]) -%}
|
||||
{%- for app in apps -%}
|
||||
{%- set w = [app.width, cols] | min -%}
|
||||
{%- set h = app.height | default(1) -%}
|
||||
{%- if ns.x + w > cols -%}
|
||||
{%- set ns.x = 0 -%}
|
||||
{%- set ns.y = ns.y + ns.row_h -%}
|
||||
{%- set ns.row_h = 0 -%}
|
||||
{%- endif -%}
|
||||
{%- set _ = ns.out.append({'id': app.id, 'x': ns.x, 'y': ns.y, 'w': w, 'h': h}) -%}
|
||||
{%- set ns.x = ns.x + w -%}
|
||||
{%- if h > ns.row_h -%}
|
||||
{%- set ns.row_h = h -%}
|
||||
{%- endif -%}
|
||||
{%- endfor -%}
|
||||
{{- ns.out | to_json -}}
|
||||
{%- endmacro -%}
|
||||
|
||||
{%- set desktop_layout = pack(homarr_apps, 10) | from_json -%}
|
||||
{%- set tablet_layout = pack(homarr_apps, 6) | from_json -%}
|
||||
{%- set mobile_layout = pack(homarr_apps, 2) | from_json -%}
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- =====================================================================
|
||||
|
|
@ -148,11 +126,14 @@ VALUES (
|
|||
'{"json": {}}'
|
||||
);
|
||||
|
||||
-- Section height is sized to fit the computed layout (see
|
||||
-- homarr_compute_layouts filter). It grows automatically when more
|
||||
-- apps or taller tiles are added.
|
||||
INSERT OR REPLACE INTO section_layout (section_id, layout_id, parent_section_id, x_offset, y_offset, width, height)
|
||||
VALUES
|
||||
('section-apps', 'layout-desktop', NULL, 0, 0, 10, 3),
|
||||
('section-apps', 'layout-tablet', NULL, 0, 0, 6, 4),
|
||||
('section-apps', 'layout-mobile', NULL, 0, 0, 2, 6);
|
||||
('section-apps', 'layout-desktop', NULL, 0, 0, 10, {{ homarr_layout.section_height.desktop }}),
|
||||
('section-apps', 'layout-tablet', NULL, 0, 0, 6, {{ homarr_layout.section_height.tablet }}),
|
||||
('section-apps', 'layout-mobile', NULL, 0, 0, 2, {{ homarr_layout.section_height.mobile }});
|
||||
|
||||
-- Board permissions
|
||||
INSERT OR IGNORE INTO boardGroupPermission (board_id, group_id, permission)
|
||||
|
|
@ -161,11 +142,11 @@ VALUES
|
|||
('board-default', 'group-credentials-admin', 'full-access');
|
||||
|
||||
-- =====================================================================
|
||||
-- APPS (auto-generated from homarr_apps variable)
|
||||
-- APPS (positions pre-computed by homarr_compute_layouts filter)
|
||||
-- =====================================================================
|
||||
|
||||
{% if homarr_apps | length > 0 %}
|
||||
{% for app in homarr_apps %}
|
||||
{% for app in homarr_layout.apps %}
|
||||
-- {{ app.name }}
|
||||
INSERT OR IGNORE INTO app (id, name, description, icon_url, href)
|
||||
VALUES (
|
||||
'app-{{ app.id }}',
|
||||
|
|
@ -184,28 +165,11 @@ VALUES (
|
|||
'{"json": {}}'
|
||||
);
|
||||
|
||||
{% endfor %}
|
||||
|
||||
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
|
||||
VALUES
|
||||
{% for entry in desktop_layout %}
|
||||
('item-{{ entry.id }}', 'section-apps', 'layout-desktop', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
;
|
||||
('item-{{ app.id }}', 'section-apps', 'layout-desktop', {{ app.desktop.x }}, {{ app.desktop.y }}, {{ app.desktop.w }}, {{ app.desktop.h }}),
|
||||
('item-{{ app.id }}', 'section-apps', 'layout-tablet', {{ app.tablet.x }}, {{ app.tablet.y }}, {{ app.tablet.w }}, {{ app.tablet.h }}),
|
||||
('item-{{ app.id }}', 'section-apps', 'layout-mobile', {{ app.mobile.x }}, {{ app.mobile.y }}, {{ app.mobile.w }}, {{ app.mobile.h }});
|
||||
|
||||
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
|
||||
VALUES
|
||||
{% for entry in tablet_layout %}
|
||||
('item-{{ entry.id }}', 'section-apps', 'layout-tablet', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
;
|
||||
|
||||
INSERT OR REPLACE INTO item_layout (item_id, section_id, layout_id, x_offset, y_offset, width, height)
|
||||
VALUES
|
||||
{% for entry in mobile_layout %}
|
||||
('item-{{ entry.id }}', 'section-apps', 'layout-mobile', {{ entry.x }}, {{ entry.y }}, {{ entry.w }}, {{ entry.h }}){% if not loop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
;
|
||||
{% endif %}
|
||||
|
||||
COMMIT;
|
||||
COMMIT;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue