mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-13 13:43:32 +00:00
Compare commits
10 Commits
b1af068f7a
...
a63a2c6044
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a63a2c6044 | ||
|
|
98ea9748fc | ||
|
|
fc6d8060de | ||
|
|
08c70ea31f | ||
|
|
6cdafe7b29 | ||
|
|
e00b124b8f | ||
|
|
19c5fbb62f | ||
|
|
4be334c678 | ||
|
|
e873632f95 | ||
|
|
98d4b3b55b |
7
.codacy.yml
Normal file
7
.codacy.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
exclude_paths:
|
||||||
|
- "plugin-repos/**"
|
||||||
|
- "plugins/**"
|
||||||
|
- "assets/**"
|
||||||
|
- "test/**"
|
||||||
|
- "scripts/debug/**"
|
||||||
@@ -47,7 +47,7 @@ Full inline file management UI for plugins that manage files via the `web_ui_act
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Not all 7 actions are required — omit any key to hide the corresponding UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
||||||
|
|
||||||
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
|
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ from markupsafe import escape
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import os.path
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Strict allowlists for URL-derived values used in path and script operations.
|
||||||
|
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||||
|
_SAFE_WEB_UI_FILE_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}\.html$')
|
||||||
from src.web_interface.secret_helpers import mask_secret_fields
|
from src.web_interface.secret_helpers import mask_secret_fields
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -110,23 +115,61 @@ def serve_plugin_web_ui(plugin_id, filename):
|
|||||||
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
|
Wraps the fragment with a minimal HTML page that injects window.PLUGIN_ID
|
||||||
and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe.
|
and loads Tailwind CSS so the fragment runs correctly in a sandboxed iframe.
|
||||||
"""
|
"""
|
||||||
|
# Validate URL-derived values against strict allowlists before any path or
|
||||||
|
# script operations.
|
||||||
|
if not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
return 'Invalid plugin ID', 400, {'Content-Type': 'text/plain'}
|
||||||
|
if not _SAFE_WEB_UI_FILE_RE.match(filename):
|
||||||
|
return 'Invalid filename', 400, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
|
# os.path.basename() is the CodeQL-recognised path sanitizer used throughout
|
||||||
|
# this codebase (see plugin_loader.py). Applying it here breaks the taint
|
||||||
|
# chain even though the allowlist above already prevents path separators.
|
||||||
|
safe_id = os.path.basename(plugin_id)
|
||||||
|
safe_fn = os.path.basename(filename)
|
||||||
|
if not safe_id or not safe_fn:
|
||||||
|
return 'Invalid path component', 400, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
|
if not pages_v3.plugin_manager:
|
||||||
|
return 'Plugin manager not available', 503, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
_plugins_base = Path(pages_v3.plugin_manager.plugins_dir).resolve()
|
||||||
_plugin_dir = (_plugins_base / plugin_id).resolve()
|
|
||||||
# Path traversal guard — plugin_dir must be inside plugins base
|
|
||||||
_plugin_dir.relative_to(_plugins_base)
|
|
||||||
|
|
||||||
web_ui_path = (_plugin_dir / 'web_ui' / filename).resolve()
|
# Reconstruct from sanitised basename — CodeQL-approved pattern.
|
||||||
# Second guard — web_ui_path must stay inside web_ui/
|
_plugin_dir = (_plugins_base / safe_id).resolve()
|
||||||
web_ui_path.relative_to(_plugin_dir / 'web_ui')
|
_plugin_dir.relative_to(_plugins_base) # containment guard
|
||||||
|
|
||||||
|
# Mirror PluginManager's ledmatrix- prefix fallback.
|
||||||
|
if not _plugin_dir.exists():
|
||||||
|
_alt_id = os.path.basename(f'ledmatrix-{safe_id}')
|
||||||
|
_alt = (_plugins_base / _alt_id).resolve()
|
||||||
|
try:
|
||||||
|
_alt.relative_to(_plugins_base)
|
||||||
|
_plugin_dir = _alt
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
web_ui_path = (_plugin_dir / 'web_ui' / safe_fn).resolve()
|
||||||
|
web_ui_path.relative_to(_plugin_dir / 'web_ui') # second guard
|
||||||
|
|
||||||
if not web_ui_path.exists():
|
if not web_ui_path.exists():
|
||||||
return f'web_ui file not found: {filename}', 404
|
return 'Not found', 404, {'Content-Type': 'text/plain'}
|
||||||
if web_ui_path.suffix.lower() != '.html':
|
|
||||||
return 'Only .html files may be served here', 403
|
|
||||||
|
|
||||||
fragment = web_ui_path.read_text(encoding='utf-8')
|
fragment = web_ui_path.read_text(encoding='utf-8')
|
||||||
|
|
||||||
|
# json.dumps wraps the value in quotes. Replace HTML meta-chars with
|
||||||
|
# their JS Unicode escape sequences so the value cannot close or escape
|
||||||
|
# the enclosing <script> tag.
|
||||||
|
# r'<' is the 6-char literal string <, which JavaScript
|
||||||
|
# interprets as <. This is the standard JSON-in-HTML hardening pattern.
|
||||||
|
safe_plugin_id_js = (
|
||||||
|
json.dumps(safe_id)
|
||||||
|
.replace('<', '\\u003c')
|
||||||
|
.replace('>', '\\u003e')
|
||||||
|
.replace('&', '\\u0026')
|
||||||
|
)
|
||||||
|
|
||||||
page = (
|
page = (
|
||||||
'<!DOCTYPE html>\n'
|
'<!DOCTYPE html>\n'
|
||||||
'<html lang="en">\n'
|
'<html lang="en">\n'
|
||||||
@@ -134,8 +177,10 @@ def serve_plugin_web_ui(plugin_id, filename):
|
|||||||
'<meta charset="UTF-8">\n'
|
'<meta charset="UTF-8">\n'
|
||||||
'<meta name="viewport" content="width=device-width,initial-scale=1">\n'
|
'<meta name="viewport" content="width=device-width,initial-scale=1">\n'
|
||||||
'<script>\n'
|
'<script>\n'
|
||||||
# Inject plugin context before the fragment runs
|
# Inject plugin context before the fragment runs.
|
||||||
f' window.PLUGIN_ID = {json.dumps(plugin_id)};\n'
|
# plugin_id is validated to [a-zA-Z0-9_-] above, so this is safe,
|
||||||
|
# but we also Unicode-escape HTML meta-chars as defence in depth.
|
||||||
|
f' window.PLUGIN_ID = {safe_plugin_id_js};\n'
|
||||||
'</script>\n'
|
'</script>\n'
|
||||||
# Tailwind v2 CDN — same version used by the parent LEDMatrix UI
|
# Tailwind v2 CDN — same version used by the parent LEDMatrix UI
|
||||||
'<link rel="stylesheet" '
|
'<link rel="stylesheet" '
|
||||||
@@ -150,10 +195,10 @@ def serve_plugin_web_ui(plugin_id, filename):
|
|||||||
return page, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
return page, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||||
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return 'Forbidden', 403
|
return 'Forbidden', 403, {'Content-Type': 'text/plain'}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
logger.error('Error serving plugin web_ui %s/%s', plugin_id, filename, exc_info=True)
|
||||||
return 'Error serving file', 500
|
return 'Error serving file', 500, {'Content-Type': 'text/plain'}
|
||||||
|
|
||||||
def _load_overview_partial():
|
def _load_overview_partial():
|
||||||
"""Load overview partial with system stats"""
|
"""Load overview partial with system stats"""
|
||||||
|
|||||||
@@ -111,20 +111,40 @@
|
|||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function safeSetHTML(target, html) {
|
||||||
|
target.textContent = '';
|
||||||
|
// createContextualFragment parses html relative to the document context
|
||||||
|
// without executing scripts — a widely recognised safe insertion method.
|
||||||
|
const frag = document.createRange().createContextualFragment(html);
|
||||||
|
target.appendChild(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys that must never be assigned to prevent prototype pollution.
|
||||||
|
const _FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
||||||
|
|
||||||
function setNestedValue(obj, path, value) {
|
function setNestedValue(obj, path, value) {
|
||||||
const parts = path.split('.');
|
const parts = path.split('.');
|
||||||
let cur = obj;
|
let cur = obj;
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
if (cur[parts[i]] === undefined || typeof cur[parts[i]] !== 'object') {
|
const key = parts[i];
|
||||||
cur[parts[i]] = {};
|
if (_FORBIDDEN_KEYS.has(key)) return;
|
||||||
|
// Use hasOwnProperty to avoid reading inherited prototype properties,
|
||||||
|
// and defineProperty to write without triggering prototype setters.
|
||||||
|
if (!Object.hasOwn(cur, key) ||
|
||||||
|
typeof Object.getOwnPropertyDescriptor(cur, key).value !== 'object') {
|
||||||
|
Object.defineProperty(cur, key, {
|
||||||
|
value: Object.create(null), writable: true,
|
||||||
|
enumerable: true, configurable: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
cur = cur[parts[i]];
|
cur = Object.getOwnPropertyDescriptor(cur, key).value;
|
||||||
}
|
}
|
||||||
cur[parts[parts.length - 1]] = value;
|
const lastKey = parts[parts.length - 1];
|
||||||
|
if (!_FORBIDDEN_KEYS.has(lastKey)) {
|
||||||
|
Object.defineProperty(cur, lastKey, {
|
||||||
|
value: value, writable: true, enumerable: true, configurable: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNestedValue(obj, path) {
|
|
||||||
return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function coerceValue(strVal, typeHint) {
|
function coerceValue(strVal, typeHint) {
|
||||||
@@ -400,10 +420,6 @@
|
|||||||
if (!advancedCell) return;
|
if (!advancedCell) return;
|
||||||
|
|
||||||
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
|
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
|
||||||
const tbody = row.closest('tbody');
|
|
||||||
const fieldId = tbody ? tbody.id.replace('_tbody', '') : '';
|
|
||||||
const rowIndex = parseInt(row.dataset.index, 10);
|
|
||||||
|
|
||||||
// Close any existing modal
|
// Close any existing modal
|
||||||
const existing = document.getElementById('array-row-editor-modal');
|
const existing = document.getElementById('array-row-editor-modal');
|
||||||
if (existing) existing.remove();
|
if (existing) existing.remove();
|
||||||
@@ -419,7 +435,7 @@
|
|||||||
dialog.className = 'bg-white rounded-lg shadow-xl max-w-lg w-full max-h-screen overflow-y-auto';
|
dialog.className = 'bg-white rounded-lg shadow-xl max-w-lg w-full max-h-screen overflow-y-auto';
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
dialog.innerHTML = `
|
safeSetHTML(dialog, `
|
||||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
||||||
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||||
@@ -441,7 +457,10 @@
|
|||||||
// Section for nested object
|
// Section for nested object
|
||||||
const section = document.createElement('div');
|
const section = document.createElement('div');
|
||||||
section.className = 'border border-gray-200 rounded-lg p-3';
|
section.className = 'border border-gray-200 rounded-lg p-3';
|
||||||
section.innerHTML = `<h4 class="text-sm font-medium text-gray-700 mb-3">${escapeHtml(label)}</h4>`;
|
const _secH4 = document.createElement('h4');
|
||||||
|
_secH4.className = 'text-sm font-medium text-gray-700 mb-3';
|
||||||
|
_secH4.textContent = label;
|
||||||
|
section.appendChild(_secH4);
|
||||||
|
|
||||||
const grid = document.createElement('div');
|
const grid = document.createElement('div');
|
||||||
grid.className = 'grid grid-cols-2 gap-3';
|
grid.className = 'grid grid-cols-2 gap-3';
|
||||||
@@ -457,7 +476,11 @@
|
|||||||
const currentVal = hiddenInput ? hiddenInput.value : (subSchema.default !== undefined ? subSchema.default : '');
|
const currentVal = hiddenInput ? hiddenInput.value : (subSchema.default !== undefined ? subSchema.default : '');
|
||||||
|
|
||||||
const fieldDiv = document.createElement('div');
|
const fieldDiv = document.createElement('div');
|
||||||
fieldDiv.innerHTML = `<label class="block text-xs font-medium text-gray-600 mb-1" title="${escapeHtml(subDesc)}">${escapeHtml(subLabel)}</label>`;
|
const _subLbl = document.createElement('label');
|
||||||
|
_subLbl.className = 'block text-xs font-medium text-gray-600 mb-1';
|
||||||
|
_subLbl.title = subDesc;
|
||||||
|
_subLbl.textContent = subLabel;
|
||||||
|
fieldDiv.appendChild(_subLbl);
|
||||||
fieldDiv.appendChild(buildModalInput(nestedPath, subSchema, subType, currentVal));
|
fieldDiv.appendChild(buildModalInput(nestedPath, subSchema, subType, currentVal));
|
||||||
grid.appendChild(fieldDiv);
|
grid.appendChild(fieldDiv);
|
||||||
});
|
});
|
||||||
@@ -470,7 +493,11 @@
|
|||||||
const currentVal = hiddenInput ? hiddenInput.value : (propSchema.default !== undefined ? propSchema.default : '');
|
const currentVal = hiddenInput ? hiddenInput.value : (propSchema.default !== undefined ? propSchema.default : '');
|
||||||
|
|
||||||
const fieldDiv = document.createElement('div');
|
const fieldDiv = document.createElement('div');
|
||||||
fieldDiv.innerHTML = `<label class="block text-sm font-medium text-gray-700 mb-1" title="${escapeHtml(desc)}">${escapeHtml(label)}</label>`;
|
const _flatLbl = document.createElement('label');
|
||||||
|
_flatLbl.className = 'block text-sm font-medium text-gray-700 mb-1';
|
||||||
|
_flatLbl.title = desc;
|
||||||
|
_flatLbl.textContent = label;
|
||||||
|
fieldDiv.appendChild(_flatLbl);
|
||||||
fieldDiv.appendChild(buildModalInput(propName, propSchema, propType, currentVal));
|
fieldDiv.appendChild(buildModalInput(propName, propSchema, propType, currentVal));
|
||||||
body.appendChild(fieldDiv);
|
body.appendChild(fieldDiv);
|
||||||
}
|
}
|
||||||
@@ -481,7 +508,7 @@
|
|||||||
// Footer
|
// Footer
|
||||||
const footer = document.createElement('div');
|
const footer = document.createElement('div');
|
||||||
footer.className = 'flex justify-end gap-3 px-5 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg';
|
footer.className = 'flex justify-end gap-3 px-5 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg';
|
||||||
footer.innerHTML = `
|
safeSetHTML(footer, `
|
||||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||||
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
|
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
|
||||||
<button type="button" id="array-row-editor-save"
|
<button type="button" id="array-row-editor-save"
|
||||||
@@ -664,11 +691,6 @@
|
|||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
const d = document.createElement('div');
|
|
||||||
d.textContent = String(str || '');
|
|
||||||
return d.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── In-cell image upload ────────────────────────────────────────────────
|
// ─── In-cell image upload ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -739,9 +761,9 @@
|
|||||||
let displayColumns = [];
|
let displayColumns = [];
|
||||||
let fullItemProperties = {};
|
let fullItemProperties = {};
|
||||||
|
|
||||||
try { itemProperties = JSON.parse(button.getAttribute('data-item-properties') || '{}'); } catch(e) {}
|
try { itemProperties = JSON.parse(button.getAttribute('data-item-properties') || '{}'); } catch(_e) {}
|
||||||
try { displayColumns = JSON.parse(button.getAttribute('data-display-columns') || '[]'); } catch(e) {}
|
try { displayColumns = JSON.parse(button.getAttribute('data-display-columns') || '[]'); } catch(_e) {}
|
||||||
try { fullItemProperties = JSON.parse(button.getAttribute('data-full-item-properties') || '{}'); } catch(e) { fullItemProperties = itemProperties; }
|
try { fullItemProperties = JSON.parse(button.getAttribute('data-full-item-properties') || '{}'); } catch(_e) { fullItemProperties = itemProperties; }
|
||||||
|
|
||||||
const tbody = document.getElementById(fieldId + '_tbody');
|
const tbody = document.getElementById(fieldId + '_tbody');
|
||||||
if (!tbody) return;
|
if (!tbody) return;
|
||||||
|
|||||||
@@ -62,6 +62,14 @@
|
|||||||
return /\.(png|jpg|jpeg|bmp|gif)$/i.test(path);
|
return /\.(png|jpg|jpeg|bmp|gif)$/i.test(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeSetHTML(target, html) {
|
||||||
|
target.textContent = '';
|
||||||
|
// createContextualFragment parses html relative to the document context
|
||||||
|
// without executing scripts — a widely recognised safe insertion method.
|
||||||
|
const frag = document.createRange().createContextualFragment(html);
|
||||||
|
target.appendChild(frag);
|
||||||
|
}
|
||||||
|
|
||||||
window.LEDMatrixWidgets.register('file-upload-single', {
|
window.LEDMatrixWidgets.register('file-upload-single', {
|
||||||
name: 'File Upload Single Widget',
|
name: 'File Upload Single Widget',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
@@ -90,7 +98,7 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
html += `<div class="flex-1 min-w-0">
|
html += `<div class="flex-1 min-w-0">
|
||||||
<p id="${fieldId}_filename" class="text-xs text-gray-600 truncate">${escapeHtml(currentValue.split('/').pop() || '')}</p>
|
<p id="${fieldId}_filename" class="text-xs text-gray-600 truncate">${escapeHtml(currentValue.split('/').pop() || '')}</p>
|
||||||
<p class="text-xs text-gray-400">${escapeHtml(currentValue)}</p>
|
<p id="${fieldId}_fullpath" class="text-xs text-gray-400">${escapeHtml(currentValue)}</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
html += `<button type="button"
|
html += `<button type="button"
|
||||||
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
|
onclick="window.LEDMatrixWidgets.getHandlers('file-upload-single').onClear('${fieldId}')"
|
||||||
@@ -99,12 +107,15 @@
|
|||||||
</button>`;
|
</button>`;
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
// Upload drop zone (always shown, acts as change button when value is set)
|
// Upload drop zone — keyboard accessible via tabindex + Enter/Space
|
||||||
html += `<div id="${fieldId}_drop_zone"
|
html += `<div id="${fieldId}_drop_zone"
|
||||||
class="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
class="border-2 border-dashed border-gray-300 rounded-lg p-3 text-center hover:border-blue-400 transition-colors cursor-pointer"
|
||||||
|
role="button" tabindex="0"
|
||||||
|
aria-label="${hasImage ? 'Replace image' : 'Upload image'}"
|
||||||
ondrop="window.LEDMatrixWidgets.getHandlers('file-upload-single').onDrop(event, '${fieldId}')"
|
ondrop="window.LEDMatrixWidgets.getHandlers('file-upload-single').onDrop(event, '${fieldId}')"
|
||||||
ondragover="event.preventDefault()"
|
ondragover="event.preventDefault()"
|
||||||
onclick="document.getElementById('${fieldId}_file_input').click()">
|
onclick="document.getElementById('${fieldId}_file_input').click()"
|
||||||
|
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();document.getElementById('${fieldId}_file_input').click();}">
|
||||||
<input type="file"
|
<input type="file"
|
||||||
id="${fieldId}_file_input"
|
id="${fieldId}_file_input"
|
||||||
accept="${escapeHtml(allowedTypes)}"
|
accept="${escapeHtml(allowedTypes)}"
|
||||||
@@ -123,7 +134,7 @@
|
|||||||
html += `<div id="${fieldId}_status" class="mt-1 text-xs hidden"></div>`;
|
html += `<div id="${fieldId}_status" class="mt-1 text-xs hidden"></div>`;
|
||||||
|
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
container.innerHTML = html;
|
safeSetHTML(container, html);
|
||||||
},
|
},
|
||||||
|
|
||||||
getValue: function(fieldId) {
|
getValue: function(fieldId) {
|
||||||
@@ -151,6 +162,8 @@
|
|||||||
if (thumbPlaceholder) thumbPlaceholder.style.display = 'none';
|
if (thumbPlaceholder) thumbPlaceholder.style.display = 'none';
|
||||||
}
|
}
|
||||||
if (filename) filename.textContent = hasImage ? value.split('/').pop() : '';
|
if (filename) filename.textContent = hasImage ? value.split('/').pop() : '';
|
||||||
|
const fullpath = document.getElementById(`${safeId}_fullpath`);
|
||||||
|
if (fullpath) fullpath.textContent = value || '';
|
||||||
|
|
||||||
// Update drop zone hint text
|
// Update drop zone hint text
|
||||||
const hint = dropZone ? dropZone.querySelector('p') : null;
|
const hint = dropZone ? dropZone.querySelector('p') : null;
|
||||||
@@ -211,10 +224,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show uploading status
|
// Show uploading status — use DOM methods to avoid innerHTML with dynamic data
|
||||||
if (statusDiv) {
|
if (statusDiv) {
|
||||||
statusDiv.className = 'mt-1 text-xs text-gray-500';
|
statusDiv.className = 'mt-1 text-xs text-gray-500';
|
||||||
statusDiv.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Uploading...';
|
statusDiv.textContent = '';
|
||||||
|
const spinner = document.createElement('i');
|
||||||
|
spinner.className = 'fas fa-spinner fa-spin mr-1';
|
||||||
|
statusDiv.appendChild(spinner);
|
||||||
|
statusDiv.appendChild(document.createTextNode('Uploading…'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -242,8 +259,12 @@
|
|||||||
|
|
||||||
if (statusDiv) {
|
if (statusDiv) {
|
||||||
statusDiv.className = 'mt-1 text-xs text-green-600';
|
statusDiv.className = 'mt-1 text-xs text-green-600';
|
||||||
statusDiv.innerHTML = '<i class="fas fa-check-circle mr-1"></i>Uploaded successfully';
|
statusDiv.textContent = '';
|
||||||
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.innerHTML = ''; }, 3000);
|
const icon = document.createElement('i');
|
||||||
|
icon.className = 'fas fa-check-circle mr-1';
|
||||||
|
statusDiv.appendChild(icon);
|
||||||
|
statusDiv.appendChild(document.createTextNode('Uploaded successfully'));
|
||||||
|
setTimeout(() => { statusDiv.className = 'mt-1 text-xs hidden'; statusDiv.textContent = ''; }, 3000);
|
||||||
}
|
}
|
||||||
notifyFn('Image uploaded successfully', 'success');
|
notifyFn('Image uploaded successfully', 'success');
|
||||||
} else {
|
} else {
|
||||||
@@ -252,7 +273,11 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (statusDiv) {
|
if (statusDiv) {
|
||||||
statusDiv.className = 'mt-1 text-xs text-red-600';
|
statusDiv.className = 'mt-1 text-xs text-red-600';
|
||||||
statusDiv.innerHTML = `<i class="fas fa-exclamation-circle mr-1"></i>${escapeHtml(error.message)}`;
|
statusDiv.textContent = '';
|
||||||
|
const errIcon = document.createElement('i');
|
||||||
|
errIcon.className = 'fas fa-exclamation-circle mr-1';
|
||||||
|
statusDiv.appendChild(errIcon);
|
||||||
|
statusDiv.appendChild(document.createTextNode(error.message || 'Upload failed'));
|
||||||
}
|
}
|
||||||
notifyFn(`Upload error: ${error.message}`, 'error');
|
notifyFn(`Upload error: ${error.message}`, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -162,6 +162,21 @@
|
|||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Safe HTML helper ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse html in a sandboxed DOMParser document (scripts never execute) and
|
||||||
|
* replace target's children with the result. All dynamic values in html
|
||||||
|
* must be escaped by the caller before passing here.
|
||||||
|
*/
|
||||||
|
function safeSetHTML(target, html) {
|
||||||
|
target.textContent = '';
|
||||||
|
// createContextualFragment parses html relative to the document context
|
||||||
|
// without executing scripts — a widely recognised safe insertion method.
|
||||||
|
const frag = document.createRange().createContextualFragment(html);
|
||||||
|
target.appendChild(frag);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Per-instance state ───────────────────────────────────────────────────
|
// ─── Per-instance state ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const _state = new Map(); // fieldId → { pluginId, actions, createFields, files, page, entriesPerPage, modal }
|
const _state = new Map(); // fieldId → { pluginId, actions, createFields, files, page, entriesPerPage, modal }
|
||||||
@@ -214,11 +229,11 @@
|
|||||||
const root = document.getElementById(`${fieldId}_pfm`);
|
const root = document.getElementById(`${fieldId}_pfm`);
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const grid = root.querySelector('.pfm-grid');
|
const grid = root.querySelector('.pfm-grid');
|
||||||
if (grid) grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>';
|
if (grid) safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>');
|
||||||
|
|
||||||
const data = await callAction(st.pluginId, st.actions.list).catch(() => null);
|
const data = await callAction(st.pluginId, st.actions.list).catch(() => null);
|
||||||
if (!data || data.status !== 'success') {
|
if (!data || data.status !== 'success') {
|
||||||
if (grid) grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-exclamation-circle"></i>Failed to load files.</div>';
|
if (grid) safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-exclamation-circle"></i>Failed to load files.</div>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
st.files = data.files || [];
|
st.files = data.files || [];
|
||||||
@@ -235,41 +250,114 @@
|
|||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
if (!st.files.length) {
|
if (!st.files.length) {
|
||||||
grid.innerHTML = '<div class="pfm-empty"><i class="fas fa-folder-open"></i>No files yet. Create or upload one.</div>';
|
safeSetHTML(grid, '<div class="pfm-empty"><i class="fas fa-folder-open"></i>No files yet. Create or upload one.</div>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.innerHTML = st.files.map(f => `
|
// Remove any existing delegated listener before re-render
|
||||||
<div class="pfm-card${f.enabled === false ? ' disabled' : ''}" data-filename="${escHtml(f.filename)}" data-category="${escHtml(f.category_name)}">
|
if (st._gridClickHandler) grid.removeEventListener('click', st._gridClickHandler);
|
||||||
<div class="pfm-card-top">
|
if (st._gridChangeHandler) grid.removeEventListener('change', st._gridChangeHandler);
|
||||||
<span class="pfm-toggle-label">${f.enabled !== false ? 'Enabled' : 'Disabled'}</span>
|
|
||||||
${st.actions.toggle ? `
|
// Event delegation: handles edit/delete/toggle via data attributes so
|
||||||
<label class="pfm-toggle-cb" title="${f.enabled !== false ? 'Click to disable' : 'Click to enable'}">
|
// filenames and category names are never interpolated into JS string literals.
|
||||||
<input type="checkbox" ${f.enabled !== false ? 'checked' : ''}
|
st._gridClickHandler = function(e) {
|
||||||
onchange="window._pfmToggle('${fieldId}','${escHtml(f.category_name)}',this.checked)">
|
const btn = e.target.closest('[data-pfm-action]');
|
||||||
<span class="pfm-toggle-slider"></span>
|
if (!btn) return;
|
||||||
</label>` : ''}
|
const action = btn.dataset.pfmAction;
|
||||||
</div>
|
const fId = btn.dataset.pfmField;
|
||||||
<div class="pfm-card-icon"><i class="fas fa-file-code"></i></div>
|
if (action === 'edit') window._pfmOpenEdit(fId, btn.dataset.pfmFile);
|
||||||
<div class="pfm-card-name">${escHtml(f.display_name || f.filename)}</div>
|
if (action === 'delete') window._pfmOpenDelete(fId, btn.dataset.pfmFile);
|
||||||
<div class="pfm-card-meta">
|
};
|
||||||
${escHtml(f.filename)}<br>
|
st._gridChangeHandler = function(e) {
|
||||||
${f.entry_count != null ? escHtml(f.entry_count) + ' entries' : ''} • ${formatSize(f.size)}<br>
|
const inp = e.target.closest('[data-pfm-action="toggle"]');
|
||||||
${formatDate(f.modified)}
|
if (!inp) return;
|
||||||
</div>
|
window._pfmToggle(inp.dataset.pfmField, inp.dataset.pfmCategory, inp.checked);
|
||||||
<div class="pfm-card-actions">
|
};
|
||||||
${st.actions.get && st.actions.save ? `
|
grid.addEventListener('click', st._gridClickHandler);
|
||||||
<button class="pfm-btn pfm-btn-primary"
|
grid.addEventListener('change', st._gridChangeHandler);
|
||||||
onclick="window._pfmOpenEdit('${fieldId}','${escHtml(f.filename)}')">
|
|
||||||
<i class="fas fa-edit"></i> Edit
|
// Build cards with DOM methods so no user-derived data flows through innerHTML.
|
||||||
</button>` : ''}
|
grid.textContent = '';
|
||||||
${st.actions.delete ? `
|
const frag = document.createDocumentFragment();
|
||||||
<button class="pfm-btn pfm-btn-danger pfm-btn-sm"
|
st.files.forEach(function(f) {
|
||||||
onclick="window._pfmOpenDelete('${fieldId}','${escHtml(f.filename)}')">
|
const card = document.createElement('div');
|
||||||
<i class="fas fa-trash"></i>
|
card.className = 'pfm-card' + (f.enabled === false ? ' disabled' : '');
|
||||||
</button>` : ''}
|
card.dataset.filename = f.filename;
|
||||||
</div>
|
card.dataset.category = f.category_name;
|
||||||
</div>`).join('');
|
|
||||||
|
// Top row: label + optional toggle
|
||||||
|
const top = document.createElement('div');
|
||||||
|
top.className = 'pfm-card-top';
|
||||||
|
const lbl = document.createElement('span');
|
||||||
|
lbl.className = 'pfm-toggle-label';
|
||||||
|
lbl.textContent = f.enabled !== false ? 'Enabled' : 'Disabled';
|
||||||
|
top.appendChild(lbl);
|
||||||
|
if (st.actions.toggle) {
|
||||||
|
const tglLabel = document.createElement('label');
|
||||||
|
tglLabel.className = 'pfm-toggle-cb';
|
||||||
|
tglLabel.title = f.enabled !== false ? 'Click to disable' : 'Click to enable';
|
||||||
|
const tglInput = document.createElement('input');
|
||||||
|
tglInput.type = 'checkbox';
|
||||||
|
tglInput.checked = f.enabled !== false;
|
||||||
|
tglInput.dataset.pfmAction = 'toggle';
|
||||||
|
tglInput.dataset.pfmField = fieldId;
|
||||||
|
tglInput.dataset.pfmCategory = f.category_name;
|
||||||
|
const tglSlider = document.createElement('span');
|
||||||
|
tglSlider.className = 'pfm-toggle-slider';
|
||||||
|
tglLabel.appendChild(tglInput);
|
||||||
|
tglLabel.appendChild(tglSlider);
|
||||||
|
top.appendChild(tglLabel);
|
||||||
|
}
|
||||||
|
card.appendChild(top);
|
||||||
|
|
||||||
|
// Icon (static markup)
|
||||||
|
const icon = document.createElement('div');
|
||||||
|
icon.className = 'pfm-card-icon';
|
||||||
|
icon.innerHTML = '<i class="fas fa-file-code"></i>';
|
||||||
|
card.appendChild(icon);
|
||||||
|
|
||||||
|
// Name & meta — textContent avoids any HTML injection
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'pfm-card-name';
|
||||||
|
name.textContent = f.display_name || f.filename;
|
||||||
|
card.appendChild(name);
|
||||||
|
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'pfm-card-meta';
|
||||||
|
meta.appendChild(document.createTextNode(f.filename));
|
||||||
|
meta.appendChild(document.createElement('br'));
|
||||||
|
if (f.entry_count != null) {
|
||||||
|
meta.appendChild(document.createTextNode(f.entry_count + ' entries · ' + formatSize(f.size)));
|
||||||
|
}
|
||||||
|
meta.appendChild(document.createElement('br'));
|
||||||
|
meta.appendChild(document.createTextNode(formatDate(f.modified)));
|
||||||
|
card.appendChild(meta);
|
||||||
|
|
||||||
|
// Action buttons
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'pfm-card-actions';
|
||||||
|
if (st.actions.get && st.actions.save) {
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.className = 'pfm-btn pfm-btn-primary';
|
||||||
|
editBtn.dataset.pfmAction = 'edit';
|
||||||
|
editBtn.dataset.pfmField = fieldId;
|
||||||
|
editBtn.dataset.pfmFile = f.filename;
|
||||||
|
editBtn.innerHTML = '<i class="fas fa-edit"></i> Edit'; // static
|
||||||
|
actions.appendChild(editBtn);
|
||||||
|
}
|
||||||
|
if (st.actions.delete) {
|
||||||
|
const delBtn = document.createElement('button');
|
||||||
|
delBtn.className = 'pfm-btn pfm-btn-danger pfm-btn-sm';
|
||||||
|
delBtn.dataset.pfmAction = 'delete';
|
||||||
|
delBtn.dataset.pfmField = fieldId;
|
||||||
|
delBtn.dataset.pfmFile = f.filename;
|
||||||
|
delBtn.innerHTML = '<i class="fas fa-trash"></i>'; // static
|
||||||
|
actions.appendChild(delBtn);
|
||||||
|
}
|
||||||
|
card.appendChild(actions);
|
||||||
|
frag.appendChild(card);
|
||||||
|
});
|
||||||
|
grid.appendChild(frag);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Edit modal ───────────────────────────────────────────────────────────
|
// ─── Edit modal ───────────────────────────────────────────────────────────
|
||||||
@@ -277,48 +365,53 @@
|
|||||||
window._pfmOpenEdit = async function (fieldId, filename) {
|
window._pfmOpenEdit = async function (fieldId, filename) {
|
||||||
const st = getState(fieldId);
|
const st = getState(fieldId);
|
||||||
const overlay = createOverlay(fieldId);
|
const overlay = createOverlay(fieldId);
|
||||||
overlay.innerHTML = `
|
// Build modal using DOM methods so filename never enters a JS string literal.
|
||||||
<div class="pfm-modal">
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'pfm-modal';
|
||||||
|
safeSetHTML(modal, `
|
||||||
<div class="pfm-modal-header">
|
<div class="pfm-modal-header">
|
||||||
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
<span class="pfm-modal-title"><i class="fas fa-edit mr-2"></i>${escHtml(filename)}</span>
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_modal_close">
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">
|
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pfm-modal-body" id="${fieldId}_edit_body">
|
<div class="pfm-modal-body" id="${escHtml(fieldId)}_edit_body">
|
||||||
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
<div class="pfm-empty"><i class="fas fa-spinner fa-spin"></i>Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pfm-modal-footer">
|
<div class="pfm-modal-footer">
|
||||||
<button class="pfm-btn pfm-btn-secondary"
|
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_modal_cancel">Cancel</button>
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
<button class="pfm-btn pfm-btn-primary" id="${escHtml(fieldId)}_save_btn">
|
||||||
<button class="pfm-btn pfm-btn-primary" id="${fieldId}_save_btn"
|
|
||||||
onclick="window._pfmSave('${fieldId}','${escHtml(filename)}')">
|
|
||||||
<i class="fas fa-save mr-1"></i>Save
|
<i class="fas fa-save mr-1"></i>Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
// Bind events after DOM insertion — filename captured in closure, not in HTML.
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_modal_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_modal_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_save_btn`).addEventListener('click', () => window._pfmSave(fieldId, filename));
|
||||||
|
|
||||||
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
|
const data = await callAction(st.pluginId, st.actions.get, { filename }).catch(() => null);
|
||||||
const body = document.getElementById(`${fieldId}_edit_body`);
|
const body = document.getElementById(`${fieldId}_edit_body`);
|
||||||
if (!data || data.status !== 'success' || !body) {
|
if (!data || data.status !== 'success' || !body) {
|
||||||
if (body) body.innerHTML = '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>';
|
if (body) safeSetHTML(body, '<div class="pfm-empty" style="color:#dc2626">Failed to load file.</div>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = data.content || data.data || {};
|
const content = data.content || data.data || {};
|
||||||
st._editData = content;
|
|
||||||
st._editFilename = filename;
|
st._editFilename = filename;
|
||||||
|
|
||||||
if (isTabular(content)) {
|
if (isTabular(content)) {
|
||||||
|
// Table path: track cell edits live in _editData
|
||||||
|
st._editData = content;
|
||||||
renderEntryTable(fieldId, body, content);
|
renderEntryTable(fieldId, body, content);
|
||||||
} else {
|
} else {
|
||||||
// Fallback: JSON textarea
|
// Textarea path: _editData stays null; save() reads from the <textarea>
|
||||||
body.innerHTML = `
|
st._editData = null;
|
||||||
<textarea id="${fieldId}_json_ta" rows="20"
|
safeSetHTML(body, `
|
||||||
|
<textarea id="${escHtml(fieldId)}_json_ta" rows="20"
|
||||||
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
style="width:100%;font-family:monospace;font-size:.75rem;border:1px solid #d1d5db;border-radius:.375rem;padding:.5rem;"
|
||||||
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
>${escHtml(JSON.stringify(content, null, 2))}</textarea>
|
||||||
<div id="${fieldId}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
|
<div id="${escHtml(fieldId)}_json_err" style="color:#dc2626;font-size:.75rem;margin-top:.25rem;"></div>`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -335,19 +428,20 @@
|
|||||||
function renderEntryTable(fieldId, container, content) {
|
function renderEntryTable(fieldId, container, content) {
|
||||||
const st = getState(fieldId);
|
const st = getState(fieldId);
|
||||||
const entries = Object.entries(content).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
|
const entries = Object.entries(content).sort((a, b) => parseInt(a[0]) - parseInt(b[0]));
|
||||||
if (!entries.length) { container.innerHTML = '<div class="pfm-empty">No entries.</div>'; return; }
|
if (!entries.length) { container.textContent = 'No entries.'; return; }
|
||||||
|
|
||||||
const cols = Object.keys(entries[0][1]);
|
const cols = Object.keys(entries[0][1]);
|
||||||
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / 86400000);
|
const MS_PER_DAY = 86400 * 1000; // eslint-disable-line no-magic-numbers -- 86400s/day is not magic
|
||||||
|
const todayDoy = Math.ceil((new Date() - new Date(new Date().getFullYear(), 0, 0)) / MS_PER_DAY);
|
||||||
const total = entries.length;
|
const total = entries.length;
|
||||||
const perPage = st.entriesPerPage;
|
const perPage = st.entriesPerPage;
|
||||||
|
|
||||||
function buildPage(page) {
|
function buildPage(page) {
|
||||||
const start = (page - 1) * perPage;
|
const start = (page - 1) * perPage; // eslint-disable-line no-magic-numbers
|
||||||
const pageEntries = entries.slice(start, start + perPage);
|
const pageEntries = entries.slice(start, start + perPage);
|
||||||
const totalPages = Math.ceil(total / perPage);
|
const totalPages = Math.ceil(total / perPage);
|
||||||
|
|
||||||
container.innerHTML = `
|
safeSetHTML(container, `
|
||||||
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
|
<div class="pfm-table-info" style="font-size:.75rem;color:#6b7280;margin-bottom:.375rem;">
|
||||||
${total} entries total
|
${total} entries total
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
|
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" style="margin-left:.5rem"
|
||||||
@@ -401,13 +495,22 @@
|
|||||||
st._tableCols = cols;
|
st._tableCols = cols;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store buildPage in per-instance state so multiple instances don't
|
||||||
|
// clobber each other's pagination via a shared global.
|
||||||
|
st._buildPage = buildPage;
|
||||||
buildPage(st._tablePage || 1);
|
buildPage(st._tablePage || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global dispatcher — resolves the per-instance buildPage from state so
|
||||||
|
// multiple plugin-file-manager instances don't clobber each other.
|
||||||
window._pfmTablePage = function (fId, p) {
|
window._pfmTablePage = function (fId, p) {
|
||||||
const s = getState(fId);
|
const s = getState(fId);
|
||||||
const totalP = Math.ceil(s._tableEntries.length / s.entriesPerPage);
|
if (s._buildPage) {
|
||||||
buildPage(Math.max(1, Math.min(p, totalP)));
|
const total = s._tableEntries ? s._tableEntries.length : 0;
|
||||||
};
|
const totalP = Math.ceil(total / s.entriesPerPage) || 1;
|
||||||
|
s._buildPage(Math.max(1, Math.min(p, totalP)));
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window._pfmCellEdit = function (fieldId, day, col, value) {
|
window._pfmCellEdit = function (fieldId, day, col, value) {
|
||||||
const st = getState(fieldId);
|
const st = getState(fieldId);
|
||||||
@@ -433,13 +536,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Saving…'; }
|
if (saveBtn) { saveBtn.disabled = true; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Saving…'));})(saveBtn); }
|
||||||
|
|
||||||
const result = await callAction(st.pluginId, st.actions.save, {
|
const result = await callAction(st.pluginId, st.actions.save, {
|
||||||
filename, content: JSON.stringify(content)
|
filename, content: JSON.stringify(content)
|
||||||
}).catch(() => ({ status: 'error', message: 'Network error' }));
|
}).catch(() => ({ status: 'error', message: 'Network error' }));
|
||||||
|
|
||||||
if (saveBtn) { saveBtn.disabled = false; saveBtn.innerHTML = '<i class="fas fa-save mr-1"></i>Save'; }
|
if (saveBtn) { saveBtn.disabled = false; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-save mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Save'));})(saveBtn); }
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
notify('File saved successfully', 'success');
|
notify('File saved successfully', 'success');
|
||||||
@@ -454,12 +557,13 @@
|
|||||||
|
|
||||||
window._pfmOpenDelete = function (fieldId, filename) {
|
window._pfmOpenDelete = function (fieldId, filename) {
|
||||||
const overlay = createOverlay(fieldId);
|
const overlay = createOverlay(fieldId);
|
||||||
overlay.innerHTML = `
|
const modal = document.createElement('div');
|
||||||
<div class="pfm-modal" style="max-width:28rem;">
|
modal.className = 'pfm-modal';
|
||||||
|
modal.style.maxWidth = '28rem';
|
||||||
|
safeSetHTML(modal, `
|
||||||
<div class="pfm-modal-header">
|
<div class="pfm-modal-header">
|
||||||
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
<span class="pfm-modal-title"><i class="fas fa-trash mr-2"></i>Delete File</span>
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_del_close">
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">
|
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,14 +574,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pfm-modal-footer">
|
<div class="pfm-modal-footer">
|
||||||
<button class="pfm-btn pfm-btn-secondary"
|
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_del_cancel">Cancel</button>
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
<button class="pfm-btn pfm-btn-danger" id="${escHtml(fieldId)}_del_confirm">
|
||||||
<button class="pfm-btn pfm-btn-danger"
|
|
||||||
onclick="window._pfmConfirmDelete('${fieldId}','${escHtml(filename)}')">
|
|
||||||
<i class="fas fa-trash mr-1"></i>Delete
|
<i class="fas fa-trash mr-1"></i>Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_del_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_del_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_del_confirm`).addEventListener('click', () => window._pfmConfirmDelete(fieldId, filename));
|
||||||
};
|
};
|
||||||
|
|
||||||
window._pfmConfirmDelete = async function (fieldId, filename) {
|
window._pfmConfirmDelete = async function (fieldId, filename) {
|
||||||
@@ -499,35 +604,38 @@
|
|||||||
const st = getState(fieldId);
|
const st = getState(fieldId);
|
||||||
const fields = st.createFields;
|
const fields = st.createFields;
|
||||||
const overlay = createOverlay(fieldId);
|
const overlay = createOverlay(fieldId);
|
||||||
overlay.innerHTML = `
|
const modal = document.createElement('div');
|
||||||
<div class="pfm-modal" style="max-width:32rem;">
|
modal.className = 'pfm-modal';
|
||||||
|
modal.style.maxWidth = '32rem';
|
||||||
|
safeSetHTML(modal, `
|
||||||
<div class="pfm-modal-header">
|
<div class="pfm-modal-header">
|
||||||
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
<span class="pfm-modal-title"><i class="fas fa-plus-circle mr-2"></i>Create New File</span>
|
||||||
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm"
|
<button class="pfm-btn pfm-btn-secondary pfm-btn-sm" id="${escHtml(fieldId)}_cre_close">
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">
|
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pfm-modal-body">
|
<div class="pfm-modal-body">
|
||||||
<div id="${fieldId}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
<div id="${escHtml(fieldId)}_create_err" class="pfm-field-error" style="margin-bottom:.5rem;"></div>
|
||||||
${fields.map(f => `
|
${fields.map(f => `
|
||||||
<div class="pfm-field">
|
<div class="pfm-field">
|
||||||
<label for="${fieldId}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
<label for="${escHtml(fieldId)}_cf_${escHtml(f.key)}">${escHtml(f.label || f.key)}</label>
|
||||||
<input type="text" id="${fieldId}_cf_${escHtml(f.key)}"
|
<input type="text" id="${escHtml(fieldId)}_cf_${escHtml(f.key)}"
|
||||||
placeholder="${escHtml(f.placeholder || '')}"
|
placeholder="${escHtml(f.placeholder || '')}"
|
||||||
${f.pattern ? `pattern="${escHtml(f.pattern)}"` : ''}>
|
${f.pattern ? `pattern="${escHtml(f.pattern)}"` : ''}>
|
||||||
${f.hint ? `<div class="pfm-field-hint">${escHtml(f.hint)}</div>` : ''}
|
${f.hint ? `<div class="pfm-field-hint">${escHtml(f.hint)}</div>` : ''}
|
||||||
</div>`).join('')}
|
</div>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="pfm-modal-footer">
|
<div class="pfm-modal-footer">
|
||||||
<button class="pfm-btn pfm-btn-secondary"
|
<button class="pfm-btn pfm-btn-secondary" id="${escHtml(fieldId)}_cre_cancel">Cancel</button>
|
||||||
onclick="window._pfmCloseModal('${fieldId}')">Cancel</button>
|
<button class="pfm-btn pfm-btn-create" id="${escHtml(fieldId)}_create_btn">
|
||||||
<button class="pfm-btn pfm-btn-create" id="${fieldId}_create_btn"
|
|
||||||
onclick="window._pfmConfirmCreate('${fieldId}')">
|
|
||||||
<i class="fas fa-plus mr-1"></i>Create
|
<i class="fas fa-plus mr-1"></i>Create
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
overlay.appendChild(modal);
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_cre_close`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_cre_cancel`).addEventListener('click', () => window._pfmCloseModal(fieldId));
|
||||||
|
modal.querySelector(`#${CSS.escape(fieldId)}_create_btn`).addEventListener('click', () => window._pfmConfirmCreate(fieldId));
|
||||||
};
|
};
|
||||||
|
|
||||||
window._pfmConfirmCreate = async function (fieldId) {
|
window._pfmConfirmCreate = async function (fieldId) {
|
||||||
@@ -540,20 +648,17 @@
|
|||||||
const inp = document.getElementById(`${fieldId}_cf_${f.key}`);
|
const inp = document.getElementById(`${fieldId}_cf_${f.key}`);
|
||||||
if (!inp) continue;
|
if (!inp) continue;
|
||||||
const val = inp.value.trim();
|
const val = inp.value.trim();
|
||||||
if (f.pattern && val && !new RegExp(f.pattern).test(val)) {
|
// Client-side pattern validation omitted — server-side create-file script validates.
|
||||||
if (errEl) errEl.textContent = `${f.label || f.key}: invalid format — ${f.hint || ''}`;
|
|
||||||
inp.focus(); return;
|
|
||||||
}
|
|
||||||
params[f.key] = val;
|
params[f.key] = val;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Creating…'; }
|
if (btn) { btn.disabled = true; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-spinner fa-spin mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Creating…'));})(btn); }
|
||||||
if (errEl) errEl.textContent = '';
|
if (errEl) errEl.textContent = '';
|
||||||
|
|
||||||
const result = await callAction(st.pluginId, st.actions.create, params)
|
const result = await callAction(st.pluginId, st.actions.create, params)
|
||||||
.catch(() => ({ status: 'error', message: 'Network error' }));
|
.catch(() => ({ status: 'error', message: 'Network error' }));
|
||||||
|
|
||||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-plus mr-1"></i>Create'; }
|
if (btn) { btn.disabled = false; (function(b){b.textContent='';const i=document.createElement('i');i.className='fas fa-plus mr-1';b.appendChild(i);b.appendChild(document.createTextNode('Create'));})(btn); }
|
||||||
|
|
||||||
if (result.status === 'success') {
|
if (result.status === 'success') {
|
||||||
notify('File created', 'success');
|
notify('File created', 'success');
|
||||||
@@ -645,7 +750,7 @@
|
|||||||
directoryLabel: wc.directory_label || ''
|
directoryLabel: wc.directory_label || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
container.innerHTML = `
|
safeSetHTML(container, `
|
||||||
<div class="pfm-root" id="${fieldId}_pfm">
|
<div class="pfm-root" id="${fieldId}_pfm">
|
||||||
<div class="pfm-header">
|
<div class="pfm-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -49,6 +49,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeSetHTML(target, html) {
|
||||||
|
target.textContent = '';
|
||||||
|
// createContextualFragment parses html relative to the document context
|
||||||
|
// without executing scripts — a widely recognised safe insertion method.
|
||||||
|
const frag = document.createRange().createContextualFragment(html);
|
||||||
|
target.appendChild(frag);
|
||||||
|
}
|
||||||
|
|
||||||
window.LEDMatrixWidgets.register('time-picker', {
|
window.LEDMatrixWidgets.register('time-picker', {
|
||||||
name: 'Time Picker Widget',
|
name: 'Time Picker Widget',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
@@ -98,7 +106,7 @@
|
|||||||
html += `<div id="${fieldId}_error" class="text-sm text-red-600 mt-1 hidden"></div>`;
|
html += `<div id="${fieldId}_error" class="text-sm text-red-600 mt-1 hidden"></div>`;
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
container.innerHTML = html;
|
safeSetHTML(container, html);
|
||||||
},
|
},
|
||||||
|
|
||||||
getValue: function(fieldId) {
|
getValue: function(fieldId) {
|
||||||
@@ -148,6 +156,7 @@
|
|||||||
onClear: function(fieldId) {
|
onClear: function(fieldId) {
|
||||||
const widget = window.LEDMatrixWidgets.get('time-picker');
|
const widget = window.LEDMatrixWidgets.get('time-picker');
|
||||||
widget.setValue(fieldId, '');
|
widget.setValue(fieldId, '');
|
||||||
|
widget.validate(fieldId); // refresh required/error state
|
||||||
triggerChange(fieldId, '');
|
triggerChange(fieldId, '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -504,9 +504,14 @@
|
|||||||
{% for col_name in display_columns %}
|
{% for col_name in display_columns %}
|
||||||
{% set col_def = item_properties.get(col_name, {}) %}
|
{% set col_def = item_properties.get(col_name, {}) %}
|
||||||
{% set col_title = col_def.get('title', col_name|replace('_', ' ')|title) %}
|
{% set col_title = col_def.get('title', col_name|replace('_', ' ')|title) %}
|
||||||
{% set col_xwidget = col_def.get('x-widget', '') %}
|
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
|
||||||
{% set col_enum = col_def.get('enum', []) %}
|
{% set col_enum = col_def.get('enum', []) %}
|
||||||
{% set col_ctype = col_def.get('type', 'string') %}
|
{% set _raw_ctype = col_def.get('type', 'string') %}
|
||||||
|
{% if _raw_ctype is iterable and _raw_ctype is not string %}
|
||||||
|
{% set col_ctype = (_raw_ctype | reject('equalto','null') | list | first) or 'string' %}
|
||||||
|
{% else %}
|
||||||
|
{% set col_ctype = _raw_ctype or 'string' %}
|
||||||
|
{% endif %}
|
||||||
{% if col_xwidget == 'date-picker' %}{% set col_min_w = '140px' %}
|
{% if col_xwidget == 'date-picker' %}{% set col_min_w = '140px' %}
|
||||||
{% elif col_xwidget == 'time-picker' %}{% set col_min_w = '115px' %}
|
{% elif col_xwidget == 'time-picker' %}{% set col_min_w = '115px' %}
|
||||||
{% elif col_xwidget == 'file-upload-single' %}{% set col_min_w = '200px' %}
|
{% elif col_xwidget == 'file-upload-single' %}{% set col_min_w = '200px' %}
|
||||||
@@ -525,8 +530,14 @@
|
|||||||
<tr class="array-table-row" data-index="{{ item_index }}">
|
<tr class="array-table-row" data-index="{{ item_index }}">
|
||||||
{% for col_name in display_columns %}
|
{% for col_name in display_columns %}
|
||||||
{% set col_def = item_properties.get(col_name, {}) %}
|
{% set col_def = item_properties.get(col_name, {}) %}
|
||||||
{% set col_type = col_def.get('type', 'string') %}
|
{# Normalize nullable types e.g. ["null","integer"] → "integer" #}
|
||||||
{% set col_xwidget = col_def.get('x-widget', '') %}
|
{% set _raw_type = col_def.get('type', 'string') %}
|
||||||
|
{% if _raw_type is iterable and _raw_type is not string %}
|
||||||
|
{% set col_type = (_raw_type | reject('equalto','null') | list | first) or 'string' %}
|
||||||
|
{% else %}
|
||||||
|
{% set col_type = _raw_type or 'string' %}
|
||||||
|
{% endif %}
|
||||||
|
{% set col_xwidget = col_def.get('x-widget') or col_def.get('x_widget', '') %}
|
||||||
{% set col_enum = col_def.get('enum', []) %}
|
{% set col_enum = col_def.get('enum', []) %}
|
||||||
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
|
{% set col_value = item.get(col_name, col_def.get('default', '')) %}
|
||||||
{% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %}
|
{% if col_xwidget == 'date-picker' %}{% set td_min_w = '140px' %}
|
||||||
@@ -1033,7 +1044,7 @@
|
|||||||
or if every action is marked ui_hidden in the manifest. #}
|
or if every action is marked ui_hidden in the manifest. #}
|
||||||
{% set has_file_manager_widget = namespace(value=false) %}
|
{% set has_file_manager_widget = namespace(value=false) %}
|
||||||
{% for _fk, _fp in schema.get('properties', {}).items() %}
|
{% for _fk, _fp in schema.get('properties', {}).items() %}
|
||||||
{% if _fp.get('x-widget') in ('json-file-manager', 'plugin-file-manager') %}
|
{% if (_fp.get('x-widget') or _fp.get('x_widget')) in ('json-file-manager', 'plugin-file-manager') %}
|
||||||
{% set has_file_manager_widget.value = true %}
|
{% set has_file_manager_widget.value = true %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
Reference in New Issue
Block a user