|
|
|
@@ -33,7 +33,7 @@
|
|
|
|
|
* togglePlugin, the GitHub token helpers, handleGitHubPluginInstall,
|
|
|
|
|
* checkGitHubAuthStatus); then the plugin-manager IIFE (private state:
|
|
|
|
|
* installedPlugins, the store cache, the on-demand poller); then the
|
|
|
|
|
* array-of-objects field helpers; then the Starlark IIFE.
|
|
|
|
|
* Starlark IIFE.
|
|
|
|
|
*
|
|
|
|
|
* The installed list is published only by renderInstalledPlugins: it sets
|
|
|
|
|
* window.installedPlugins and dispatches one `pluginsUpdated` event, which
|
|
|
|
@@ -44,9 +44,6 @@
|
|
|
|
|
* configurePlugin, togglePlugin, uninstallPlugin, installPlugin,
|
|
|
|
|
* installFromCustomRegistry, removeSavedRepository, executePluginAction,
|
|
|
|
|
* openOnDemandModal, requestOnDemandStop, loadOnDemandStatus,
|
|
|
|
|
* renderArrayObjectItem, addArrayObjectItem, removeArrayObjectItem,
|
|
|
|
|
* updateArrayObjectData, handleArrayObjectFileUpload, removeArrayObjectFile,
|
|
|
|
|
* removeKeyValuePair, updateKeyValuePairData, getSchemaProperty,
|
|
|
|
|
* installStarlarkApp, installPixlet, the GitHub token functions,
|
|
|
|
|
* window.installedPlugins and window.currentPluginConfig.
|
|
|
|
|
*/
|
|
|
|
@@ -923,6 +920,12 @@ function loadInstalledPlugins(forceRefresh = false) {
|
|
|
|
|
|
|
|
|
|
debugLog('[FETCH] Loading installed plugins...');
|
|
|
|
|
|
|
|
|
|
// A forced refresh must not be answered from PluginAPI's own GET cache
|
|
|
|
|
// (RequestThrottler, 5s) either, or it re-renders the list it replaces.
|
|
|
|
|
if (forceRefresh && window.PluginAPI && typeof window.PluginAPI.clearCache === 'function') {
|
|
|
|
|
window.PluginAPI.clearCache();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use PluginAPI if available, otherwise fall back to direct fetch
|
|
|
|
|
const fetchPromise = (window.PluginAPI && window.PluginAPI.getInstalledPlugins) ?
|
|
|
|
|
window.PluginAPI.getInstalledPlugins().then(plugins => {
|
|
|
|
@@ -1883,516 +1886,6 @@ function closeOnDemandModalOnBackdrop(event) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper function to get the full property object from schema
|
|
|
|
|
// Uses greedy longest-match to handle schema keys containing dots (e.g., "eng.1")
|
|
|
|
|
function getSchemaProperty(schema, path) {
|
|
|
|
|
if (!schema || !schema.properties) return null;
|
|
|
|
|
|
|
|
|
|
const parts = path.split('.');
|
|
|
|
|
let current = schema.properties;
|
|
|
|
|
let i = 0;
|
|
|
|
|
|
|
|
|
|
while (i < parts.length) {
|
|
|
|
|
let matched = false;
|
|
|
|
|
// Try progressively longer candidates, longest first
|
|
|
|
|
for (let j = parts.length; j > i; j--) {
|
|
|
|
|
const candidate = parts.slice(i, j).join('.');
|
|
|
|
|
if (current && current[candidate]) {
|
|
|
|
|
if (j === parts.length) {
|
|
|
|
|
// Consumed all remaining parts — done
|
|
|
|
|
return current[candidate];
|
|
|
|
|
}
|
|
|
|
|
if (current[candidate].properties) {
|
|
|
|
|
current = current[candidate].properties;
|
|
|
|
|
i = j;
|
|
|
|
|
matched = true;
|
|
|
|
|
break;
|
|
|
|
|
} else {
|
|
|
|
|
return null; // Can't navigate deeper
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!matched) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// "x-display": "hidden" (or an object of nothing but hidden children).
|
|
|
|
|
// Mirrors prop_is_hidden in plugin_config.html.
|
|
|
|
|
function isHiddenSchemaProp(schema) {
|
|
|
|
|
if (!schema || typeof schema !== 'object') return false;
|
|
|
|
|
if (schema['x-display'] === 'hidden') return true;
|
|
|
|
|
const children = schema.properties;
|
|
|
|
|
if (children && typeof children === 'object') {
|
|
|
|
|
const values = Object.values(children);
|
|
|
|
|
return values.length > 0 && values.every(isHiddenSchemaProp);
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper function to render a single item in an array of objects
|
|
|
|
|
function renderArrayObjectItem(fieldId, fullKey, itemProperties, itemValue, index, itemsSchema) {
|
|
|
|
|
const item = itemValue || {};
|
|
|
|
|
const itemId = `${escapeAttribute(fieldId)}_item_${index}`;
|
|
|
|
|
// Store original item data in data attribute to preserve non-editable properties after reindexing
|
|
|
|
|
const itemDataJson = JSON.stringify(item);
|
|
|
|
|
const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson)));
|
|
|
|
|
let html = `<div id="${itemId}" class="border border-gray-300 rounded-lg p-4 bg-gray-50 array-object-item" data-index="${index}" data-item-data="${escapeAttribute(itemDataBase64)}">`;
|
|
|
|
|
|
|
|
|
|
// Render each property of the object
|
|
|
|
|
const propertyOrder = itemsSchema['x-propertyOrder'] || Object.keys(itemProperties);
|
|
|
|
|
propertyOrder.forEach(propKey => {
|
|
|
|
|
if (!itemProperties[propKey]) return;
|
|
|
|
|
// "x-display": "hidden": no control. The stored value survives through
|
|
|
|
|
// data-item-data, which updateArrayObjectData overlays edits onto.
|
|
|
|
|
if (isHiddenSchemaProp(itemProperties[propKey])) return;
|
|
|
|
|
|
|
|
|
|
const propSchema = itemProperties[propKey];
|
|
|
|
|
const propValue = item[propKey] !== undefined ? item[propKey] : propSchema.default;
|
|
|
|
|
const propLabel = propSchema.title || propKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
|
|
|
const propDescription = propSchema.description || '';
|
|
|
|
|
const propFullKey = `${fullKey}[${index}].${propKey}`;
|
|
|
|
|
|
|
|
|
|
html += `<div class="mb-3">`;
|
|
|
|
|
|
|
|
|
|
// Handle file-upload widget (for logo field)
|
|
|
|
|
if (propSchema['x-widget'] === 'file-upload') {
|
|
|
|
|
html += `<label class="block text-sm font-medium text-gray-700 mb-1">${escapeHtml(propLabel)}</label>`;
|
|
|
|
|
if (propDescription) {
|
|
|
|
|
html += `<p class="text-xs text-gray-500 mb-2">${escapeHtml(propDescription)}</p>`;
|
|
|
|
|
}
|
|
|
|
|
const uploadConfig = propSchema['x-upload-config'] || {};
|
|
|
|
|
// Derive pluginId strictly from uploadConfig or currentPluginConfig, no hard-coded fallback
|
|
|
|
|
const pluginId = uploadConfig.plugin_id || (typeof currentPluginConfig !== 'undefined' ? currentPluginConfig?.pluginId : null) || (typeof window.currentPluginConfig !== 'undefined' ? window.currentPluginConfig?.pluginId : null) || null;
|
|
|
|
|
const logoValue = propValue || {};
|
|
|
|
|
// Use base64 encoding for JSON in data attributes to safely handle all characters
|
|
|
|
|
const logoDataJson = logoValue && Object.keys(logoValue).length > 0 ? JSON.stringify(logoValue) : '';
|
|
|
|
|
const logoDataBase64 = logoDataJson ? btoa(unescape(encodeURIComponent(logoDataJson))) : '';
|
|
|
|
|
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/bmp'];
|
|
|
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
|
|
|
const pluginIdParam = pluginId ? `'${escapeAttribute(pluginId)}'` : 'null';
|
|
|
|
|
const uploadConfigJson = JSON.stringify({ allowed_types: allowedTypes, max_size_mb: maxSizeMB });
|
|
|
|
|
const uploadConfigBase64 = btoa(unescape(encodeURIComponent(uploadConfigJson)));
|
|
|
|
|
|
|
|
|
|
html += `
|
|
|
|
|
<div class="file-upload-widget-inline"${logoDataBase64 ? ` data-file-data="${escapeAttribute(logoDataBase64)}" data-prop-key="${escapeAttribute(propKey)}"` : ` data-prop-key="${escapeAttribute(propKey)}"`} data-upload-config="${escapeAttribute(uploadConfigBase64)}">
|
|
|
|
|
<input type="file"
|
|
|
|
|
id="${escapeAttribute(itemId)}_logo_file"
|
|
|
|
|
accept="${escapeAttribute(allowedTypes.join(','))}"
|
|
|
|
|
style="display: none;"
|
|
|
|
|
onchange="handleArrayObjectFileUpload(event, '${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}', ${pluginIdParam})">
|
|
|
|
|
<button type="button"
|
|
|
|
|
onclick="document.getElementById('${escapeAttribute(itemId)}_logo_file').click()"
|
|
|
|
|
class="px-3 py-2 text-sm bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md transition-colors">
|
|
|
|
|
<i class="fas fa-upload mr-1"></i> Upload Logo
|
|
|
|
|
</button>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
if (logoValue.path) {
|
|
|
|
|
html += `
|
|
|
|
|
<div class="mt-2 flex items-center space-x-2 uploaded-image-container">
|
|
|
|
|
<img src="/${escapeAttribute(logoValue.path.replace(/^\/+/, ''))}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
|
|
|
|
|
<button type="button"
|
|
|
|
|
onclick="removeArrayObjectFile('${escapeAttribute(fieldId)}', ${index}, '${escapeAttribute(propKey)}')"
|
|
|
|
|
class="text-red-600 hover:text-red-800">
|
|
|
|
|
<i class="fas fa-trash"></i> Remove
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
} else if (propSchema.type === 'boolean') {
|
|
|
|
|
// Boolean checkbox
|
|
|
|
|
html += `
|
|
|
|
|
<label class="flex items-center">
|
|
|
|
|
<input type="checkbox"
|
|
|
|
|
id="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}"
|
|
|
|
|
data-prop-key="${escapeAttribute(propKey)}"
|
|
|
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
|
|
|
|
${propValue ? 'checked' : ''}
|
|
|
|
|
onchange="updateArrayObjectData('${escapeAttribute(fieldId)}')">
|
|
|
|
|
<span class="ml-2 text-sm text-gray-700">${escapeHtml(propLabel)}</span>
|
|
|
|
|
</label>
|
|
|
|
|
`;
|
|
|
|
|
} else {
|
|
|
|
|
// Regular text/string input
|
|
|
|
|
html += `
|
|
|
|
|
<label for="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}" class="block text-sm font-medium text-gray-700 mb-1">
|
|
|
|
|
${escapeHtml(propLabel)}
|
|
|
|
|
</label>
|
|
|
|
|
`;
|
|
|
|
|
if (propDescription) {
|
|
|
|
|
html += `<p class="text-xs text-gray-500 mb-1">${escapeHtml(propDescription)}</p>`;
|
|
|
|
|
}
|
|
|
|
|
const placeholder = propSchema.format === 'uri' ? 'https://example.com/feed' : '';
|
|
|
|
|
html += `
|
|
|
|
|
<input type="${propSchema.format === 'uri' ? 'url' : 'text'}"
|
|
|
|
|
id="${escapeAttribute(itemId)}_${escapeAttribute(propKey)}"
|
|
|
|
|
data-prop-key="${escapeAttribute(propKey)}"
|
|
|
|
|
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm bg-white text-black"
|
|
|
|
|
value="${escapeAttribute(propValue || '')}"
|
|
|
|
|
placeholder="${escapeAttribute(placeholder)}"
|
|
|
|
|
onchange="updateArrayObjectData('${escapeAttribute(fieldId)}')">
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Use schema-driven label for remove button, fallback to generic "Remove item"
|
|
|
|
|
const removeLabel = itemsSchema['x-removeLabel'] || 'Remove item';
|
|
|
|
|
html += `
|
|
|
|
|
<button type="button"
|
|
|
|
|
onclick="removeArrayObjectItem('${escapeAttribute(fieldId)}', ${index})"
|
|
|
|
|
class="mt-2 px-3 py-2 text-sm text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors">
|
|
|
|
|
<i class="fas fa-trash mr-1"></i> ${escapeHtml(removeLabel)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
return html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Functions to handle patternProperties key-value pairs
|
|
|
|
|
window.removeKeyValuePair = function(fieldId, index) {
|
|
|
|
|
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
|
|
|
|
if (!pairsContainer) return;
|
|
|
|
|
|
|
|
|
|
const pair = pairsContainer.querySelector(`.key-value-pair[data-index="${index}"]`);
|
|
|
|
|
if (pair) {
|
|
|
|
|
pair.remove();
|
|
|
|
|
// Re-index remaining pairs
|
|
|
|
|
const remainingPairs = pairsContainer.querySelectorAll('.key-value-pair');
|
|
|
|
|
remainingPairs.forEach((p, newIndex) => {
|
|
|
|
|
p.setAttribute('data-index', newIndex);
|
|
|
|
|
const keyInput = p.querySelector('[data-key-index]');
|
|
|
|
|
const valueInput = p.querySelector('[data-value-index]');
|
|
|
|
|
if (keyInput) {
|
|
|
|
|
keyInput.setAttribute('name', keyInput.getAttribute('name').replace(/\[key_\d+\]/, `[key_${newIndex}]`));
|
|
|
|
|
keyInput.setAttribute('data-key-index', newIndex);
|
|
|
|
|
keyInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${keyInput.getAttribute('name').split('[')[0]}')`);
|
|
|
|
|
}
|
|
|
|
|
if (valueInput) {
|
|
|
|
|
valueInput.setAttribute('name', valueInput.getAttribute('name').replace(/\[value_\d+\]/, `[value_${newIndex}]`));
|
|
|
|
|
valueInput.setAttribute('data-value-index', newIndex);
|
|
|
|
|
valueInput.setAttribute('onchange', `updateKeyValuePairData('${fieldId}', '${valueInput.getAttribute('name').split('[')[0]}')`);
|
|
|
|
|
}
|
|
|
|
|
const removeButton = p.querySelector('button[onclick*="removeKeyValuePair"]');
|
|
|
|
|
if (removeButton) {
|
|
|
|
|
removeButton.setAttribute('onclick', `removeKeyValuePair('${fieldId}', ${newIndex})`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
const hiddenInput = pairsContainer.closest('.key-value-pairs-container').querySelector('input[type="hidden"]');
|
|
|
|
|
if (hiddenInput) {
|
|
|
|
|
const hiddenName = hiddenInput.getAttribute('name').replace(/_data$/, '');
|
|
|
|
|
updateKeyValuePairData(fieldId, hiddenName);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update add button state
|
|
|
|
|
const addButton = pairsContainer.nextElementSibling;
|
|
|
|
|
if (addButton) {
|
|
|
|
|
const maxProperties = parseInt(addButton.getAttribute('onclick').match(/\d+/)[0]);
|
|
|
|
|
if (remainingPairs.length < maxProperties) {
|
|
|
|
|
addButton.disabled = false;
|
|
|
|
|
addButton.style.opacity = '1';
|
|
|
|
|
addButton.style.cursor = 'pointer';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.updateKeyValuePairData = function(fieldId, fullKey) {
|
|
|
|
|
const pairsContainer = document.getElementById(fieldId + '_pairs');
|
|
|
|
|
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
|
|
|
if (!pairsContainer || !hiddenInput) return;
|
|
|
|
|
|
|
|
|
|
const pairs = {};
|
|
|
|
|
const keyInputs = pairsContainer.querySelectorAll('[data-key-index]');
|
|
|
|
|
const valueInputs = pairsContainer.querySelectorAll('[data-value-index]');
|
|
|
|
|
|
|
|
|
|
keyInputs.forEach((keyInput, idx) => {
|
|
|
|
|
const key = keyInput.value.trim();
|
|
|
|
|
const valueInput = Array.from(valueInputs).find(v => v.getAttribute('data-value-index') === keyInput.getAttribute('data-key-index'));
|
|
|
|
|
if (key && valueInput) {
|
|
|
|
|
const value = valueInput.value.trim();
|
|
|
|
|
if (value) {
|
|
|
|
|
pairs[key] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
hiddenInput.value = JSON.stringify(pairs);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.updateArrayObjectData = function(fieldId) {
|
|
|
|
|
const itemsContainer = document.getElementById(fieldId + '_items');
|
|
|
|
|
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
|
|
|
if (!itemsContainer || !hiddenInput) return;
|
|
|
|
|
|
|
|
|
|
// Get existing items from hidden input to preserve non-editable properties
|
|
|
|
|
let existingItems = [];
|
|
|
|
|
try {
|
|
|
|
|
const existingData = hiddenInput.value.trim();
|
|
|
|
|
if (existingData) {
|
|
|
|
|
existingItems = JSON.parse(existingData);
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Error parsing existing items data:', e);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const items = [];
|
|
|
|
|
const itemElements = itemsContainer.querySelectorAll('.array-object-item');
|
|
|
|
|
|
|
|
|
|
itemElements.forEach((itemEl, index) => {
|
|
|
|
|
// Start with original item data from data attribute to preserve non-editable properties
|
|
|
|
|
// This avoids index-based corruption after deletions/reindexing
|
|
|
|
|
let existingItem = {};
|
|
|
|
|
const itemDataBase64 = itemEl.getAttribute('data-item-data');
|
|
|
|
|
if (itemDataBase64) {
|
|
|
|
|
try {
|
|
|
|
|
const itemDataJson = decodeURIComponent(escape(atob(itemDataBase64)));
|
|
|
|
|
existingItem = JSON.parse(itemDataJson);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Error parsing item data from data attribute:', e);
|
|
|
|
|
// Fallback to index-based lookup if data attribute is missing/corrupt
|
|
|
|
|
if (index < existingItems.length && existingItems[index]) {
|
|
|
|
|
existingItem = existingItems[index];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Fallback to index-based lookup if data attribute is missing
|
|
|
|
|
if (index < existingItems.length && existingItems[index]) {
|
|
|
|
|
existingItem = existingItems[index];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const item = Object.assign({}, existingItem); // Copy existing item
|
|
|
|
|
|
|
|
|
|
// Get all text inputs in this item and overlay their values with type coercion
|
|
|
|
|
itemEl.querySelectorAll('input[type="text"], input[type="url"], input[type="number"]').forEach(input => {
|
|
|
|
|
const propKey = input.getAttribute('data-prop-key');
|
|
|
|
|
if (propKey && propKey !== 'logo_file') {
|
|
|
|
|
let value = input.value.trim();
|
|
|
|
|
|
|
|
|
|
// Type coercion: check input type or data-prop-type attribute
|
|
|
|
|
const inputType = input.type;
|
|
|
|
|
const propType = input.getAttribute('data-prop-type');
|
|
|
|
|
|
|
|
|
|
if (inputType === 'number' || propType === 'number') {
|
|
|
|
|
// Use valueAsNumber if available, fallback to Number()
|
|
|
|
|
const numValue = input.valueAsNumber !== undefined && !isNaN(input.valueAsNumber)
|
|
|
|
|
? input.valueAsNumber
|
|
|
|
|
: Number(value);
|
|
|
|
|
item[propKey] = isNaN(numValue) ? value : numValue;
|
|
|
|
|
} else if (propType === 'array' || input.getAttribute('data-prop-is-list') === 'true') {
|
|
|
|
|
// Try to parse as JSON array, fallback to comma splitting
|
|
|
|
|
try {
|
|
|
|
|
const parsed = JSON.parse(value);
|
|
|
|
|
item[propKey] = Array.isArray(parsed) ? parsed : value;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Fallback to comma-splitting for arrays
|
|
|
|
|
item[propKey] = value ? value.split(',').map(v => v.trim()).filter(v => v) : [];
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// String value - keep as-is
|
|
|
|
|
item[propKey] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Handle checkboxes
|
|
|
|
|
itemEl.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
|
|
|
|
|
const propKey = checkbox.getAttribute('data-prop-key');
|
|
|
|
|
if (propKey) {
|
|
|
|
|
item[propKey] = checkbox.checked;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Handle file upload data (stored in data attributes, base64-encoded)
|
|
|
|
|
itemEl.querySelectorAll('[data-file-data]').forEach(fileEl => {
|
|
|
|
|
const fileDataBase64 = fileEl.getAttribute('data-file-data');
|
|
|
|
|
if (fileDataBase64) {
|
|
|
|
|
try {
|
|
|
|
|
// Decode base64-encoded JSON
|
|
|
|
|
const fileDataJson = decodeURIComponent(escape(atob(fileDataBase64)));
|
|
|
|
|
const data = JSON.parse(fileDataJson);
|
|
|
|
|
const propKey = fileEl.getAttribute('data-prop-key');
|
|
|
|
|
if (propKey) {
|
|
|
|
|
item[propKey] = data;
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Error parsing file data:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
items.push(item);
|
|
|
|
|
|
|
|
|
|
// Update data-item-data attribute with the merged item to keep it in sync
|
|
|
|
|
try {
|
|
|
|
|
const itemDataJson = JSON.stringify(item);
|
|
|
|
|
const itemDataBase64 = btoa(unescape(encodeURIComponent(itemDataJson)));
|
|
|
|
|
itemEl.setAttribute('data-item-data', itemDataBase64);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Error updating data-item-data attribute:', e);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
hiddenInput.value = JSON.stringify(items);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.handleArrayObjectFileUpload = async function(event, fieldId, itemIndex, propKey, pluginId) {
|
|
|
|
|
const file = event.target.files[0];
|
|
|
|
|
if (!file) return;
|
|
|
|
|
|
|
|
|
|
// Derive item element from event instead of constructing ID (works after reindexing)
|
|
|
|
|
const itemEl = event.target.closest('.array-object-item');
|
|
|
|
|
if (!itemEl) {
|
|
|
|
|
console.error('Array object item element not found');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find file upload container within the item element, scoped to propKey
|
|
|
|
|
const fileUploadContainer = itemEl.querySelector(`.file-upload-widget-inline[data-prop-key="${propKey}"]`);
|
|
|
|
|
if (!fileUploadContainer) {
|
|
|
|
|
console.error('File upload container not found for propKey:', propKey);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get upload config from data attribute
|
|
|
|
|
let uploadConfig = { allowed_types: ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'], max_size_mb: 5 };
|
|
|
|
|
const uploadConfigBase64 = fileUploadContainer.getAttribute('data-upload-config');
|
|
|
|
|
if (uploadConfigBase64) {
|
|
|
|
|
try {
|
|
|
|
|
const uploadConfigJson = decodeURIComponent(escape(atob(uploadConfigBase64)));
|
|
|
|
|
uploadConfig = JSON.parse(uploadConfigJson);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Error parsing upload config from data attribute:', e);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate file type using uploadConfig
|
|
|
|
|
const allowedTypes = uploadConfig.allowed_types || ['image/png', 'image/jpeg', 'image/jpg', 'image/bmp'];
|
|
|
|
|
if (!allowedTypes.includes(file.type)) {
|
|
|
|
|
showNotification(`File ${file.name} is not a valid image type`, 'error');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate file size using uploadConfig
|
|
|
|
|
const maxSizeMB = uploadConfig.max_size_mb || 5;
|
|
|
|
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
|
|
|
|
showNotification(`File ${file.name} exceeds ${maxSizeMB}MB limit`, 'error');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate pluginId before upload (fail fast)
|
|
|
|
|
if (!pluginId || pluginId === 'null' || pluginId === 'undefined' || (typeof pluginId === 'string' && pluginId.trim() === '')) {
|
|
|
|
|
showNotification('Plugin ID is required for file upload', 'error');
|
|
|
|
|
console.error('File upload failed: pluginId is required');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Upload file
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('plugin_id', pluginId);
|
|
|
|
|
formData.append('files', file);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch('/api/v3/plugins/assets/upload', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: formData
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Check response.ok before parsing JSON to avoid parsing errors on HTTP errors
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const errorText = await response.text();
|
|
|
|
|
let errorMessage = `Upload failed: HTTP ${response.status}`;
|
|
|
|
|
try {
|
|
|
|
|
const errorData = JSON.parse(errorText);
|
|
|
|
|
errorMessage = errorData.message || errorMessage;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// If response isn't JSON, use the text or status
|
|
|
|
|
if (errorText) {
|
|
|
|
|
errorMessage = `Upload failed: ${errorText}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
showNotification(errorMessage, 'error');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
|
|
|
|
|
if (data.status === 'success' && data.uploaded_files && data.uploaded_files.length > 0) {
|
|
|
|
|
const uploadedFile = data.uploaded_files[0];
|
|
|
|
|
|
|
|
|
|
// Store file data in data-file-data attribute on the container (base64-encoded)
|
|
|
|
|
const fileDataJson = JSON.stringify(uploadedFile);
|
|
|
|
|
const fileDataBase64 = btoa(unescape(encodeURIComponent(fileDataJson)));
|
|
|
|
|
fileUploadContainer.setAttribute('data-file-data', fileDataBase64);
|
|
|
|
|
fileUploadContainer.setAttribute('data-prop-key', propKey);
|
|
|
|
|
|
|
|
|
|
// Update the display to show the uploaded image
|
|
|
|
|
const existingImage = fileUploadContainer.querySelector('.uploaded-image-container');
|
|
|
|
|
if (existingImage) {
|
|
|
|
|
existingImage.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const imageContainer = document.createElement('div');
|
|
|
|
|
imageContainer.className = 'mt-2 flex items-center space-x-2 uploaded-image-container';
|
|
|
|
|
const escapedPath = escapeAttribute(uploadedFile.path.replace(/^\/+/, ''));
|
|
|
|
|
const escapedFieldId = escapeAttribute(fieldId);
|
|
|
|
|
const escapedPropKey = escapeAttribute(propKey);
|
|
|
|
|
// Get current item index from data-index attribute for remove button
|
|
|
|
|
const currentItemIndex = itemEl.getAttribute('data-index') || itemIndex;
|
|
|
|
|
imageContainer.innerHTML = `
|
|
|
|
|
<img src="/${escapedPath}" alt="Logo" loading="lazy" decoding="async" class="w-16 h-16 object-cover rounded border">
|
|
|
|
|
<button type="button"
|
|
|
|
|
onclick="removeArrayObjectFile('${escapedFieldId}', ${currentItemIndex}, '${escapedPropKey}')"
|
|
|
|
|
class="text-red-600 hover:text-red-800">
|
|
|
|
|
<i class="fas fa-trash"></i> Remove
|
|
|
|
|
</button>
|
|
|
|
|
`;
|
|
|
|
|
fileUploadContainer.appendChild(imageContainer);
|
|
|
|
|
|
|
|
|
|
// Update the hidden input with the new file data
|
|
|
|
|
updateArrayObjectData(fieldId);
|
|
|
|
|
|
|
|
|
|
showNotification('Logo uploaded successfully', 'success');
|
|
|
|
|
} else {
|
|
|
|
|
showNotification(`Upload failed: ${data.message || 'Unknown error'}`, 'error');
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Upload error:', error);
|
|
|
|
|
showNotification(`Upload error: ${error.message}`, 'error');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear file input
|
|
|
|
|
event.target.value = '';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.removeArrayObjectFile = function(fieldId, itemIndex, propKey) {
|
|
|
|
|
const itemId = `${fieldId}_item_${itemIndex}`;
|
|
|
|
|
const fileUploadContainer = document.querySelector(`#${itemId} .file-upload-widget-inline`);
|
|
|
|
|
if (!fileUploadContainer) {
|
|
|
|
|
console.error('File upload container not found');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove file data from data attribute
|
|
|
|
|
fileUploadContainer.removeAttribute('data-file-data');
|
|
|
|
|
|
|
|
|
|
// Remove the image display
|
|
|
|
|
const imageContainer = fileUploadContainer.querySelector('.uploaded-image-container');
|
|
|
|
|
if (imageContainer) {
|
|
|
|
|
imageContainer.remove();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update the hidden input to remove the file data
|
|
|
|
|
updateArrayObjectData(fieldId);
|
|
|
|
|
|
|
|
|
|
showNotification('Logo removed', 'success');
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Generic Plugin Action Handler
|
|
|
|
|
window.executePluginAction = function(actionId, actionIndex, pluginIdParam = null) {
|
|
|
|
|
debugLog('[DEBUG] executePluginAction called - actionId:', actionId, 'actionIndex:', actionIndex, 'pluginIdParam:', pluginIdParam);
|
|
|
|
@@ -2770,9 +2263,12 @@ function handleUninstallSuccess(pluginId) {
|
|
|
|
|
renderInstalledPlugins(updatedPlugins);
|
|
|
|
|
showNotification(`Plugin uninstalled successfully`, 'success');
|
|
|
|
|
|
|
|
|
|
// Also refresh from server to ensure consistency
|
|
|
|
|
// Also refresh from server to ensure consistency. The uninstall went
|
|
|
|
|
// through a plain fetch, so both list caches still hold the plugin:
|
|
|
|
|
// refreshInstalledPlugins() bypasses them.
|
|
|
|
|
pluginLoadCache.invalidate();
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
loadInstalledPlugins();
|
|
|
|
|
refreshInstalledPlugins();
|
|
|
|
|
}, 1000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -3978,10 +3474,6 @@ window.dismissGithubWarning = function() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Used by the array-of-objects helpers below, which live outside this IIFE.
|
|
|
|
|
window.renderArrayObjectItem = renderArrayObjectItem;
|
|
|
|
|
window.getSchemaProperty = getSchemaProperty;
|
|
|
|
|
|
|
|
|
|
// Assigned here because the functions are IIFE-local; the load-time and
|
|
|
|
|
// htmx:afterSettle wiring below, outside the IIFE, reaches them through window.
|
|
|
|
|
window.attachInstallButtonHandler = attachInstallButtonHandler;
|
|
|
|
@@ -3989,134 +3481,6 @@ window.setupGitHubInstallHandlers = setupGitHubInstallHandlers;
|
|
|
|
|
|
|
|
|
|
})(); // End IIFE
|
|
|
|
|
|
|
|
|
|
// Array-of-objects config fields: add and remove items (the inline onclick
|
|
|
|
|
// handlers that renderArrayObjectItem writes call these).
|
|
|
|
|
if (typeof window !== 'undefined') {
|
|
|
|
|
window.addArrayObjectItem = function(fieldId, fullKey, maxItems) {
|
|
|
|
|
const itemsContainer = document.getElementById(fieldId + '_items');
|
|
|
|
|
const hiddenInput = document.getElementById(fieldId + '_data');
|
|
|
|
|
if (!itemsContainer || !hiddenInput) return;
|
|
|
|
|
|
|
|
|
|
const currentItems = itemsContainer.querySelectorAll('.array-object-item');
|
|
|
|
|
if (currentItems.length >= maxItems) {
|
|
|
|
|
alert(`Maximum ${maxItems} items allowed`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get schema for item properties - ensure currentPluginConfig is available
|
|
|
|
|
// Try window.currentPluginConfig first (most reliable), then currentPluginConfig
|
|
|
|
|
const schema = (typeof window.currentPluginConfig !== 'undefined' && window.currentPluginConfig?.schema) ||
|
|
|
|
|
(typeof currentPluginConfig !== 'undefined' && currentPluginConfig?.schema);
|
|
|
|
|
if (!schema) {
|
|
|
|
|
console.error('addArrayObjectItem: Schema not available. currentPluginConfig may not be set.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Use getSchemaProperty to properly handle nested schemas (e.g., news.custom_feeds)
|
|
|
|
|
const arraySchema = window.getSchemaProperty(schema, fullKey);
|
|
|
|
|
if (!arraySchema || arraySchema.type !== 'array' || !arraySchema.items) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const itemsSchema = arraySchema.items;
|
|
|
|
|
if (!itemsSchema || !itemsSchema.properties) return;
|
|
|
|
|
|
|
|
|
|
const newIndex = currentItems.length;
|
|
|
|
|
// renderArrayObjectItem is exported by the plugin-manager IIFE above.
|
|
|
|
|
const itemHtml = window.renderArrayObjectItem(fieldId, fullKey, itemsSchema.properties, {}, newIndex, itemsSchema);
|
|
|
|
|
itemsContainer.insertAdjacentHTML('beforeend', itemHtml);
|
|
|
|
|
window.updateArrayObjectData(fieldId);
|
|
|
|
|
|
|
|
|
|
// Update add button state
|
|
|
|
|
const addButton = itemsContainer.nextElementSibling;
|
|
|
|
|
if (addButton && currentItems.length + 1 >= maxItems) {
|
|
|
|
|
addButton.disabled = true;
|
|
|
|
|
addButton.style.opacity = '0.5';
|
|
|
|
|
addButton.style.cursor = 'not-allowed';
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
window.removeArrayObjectItem = function(fieldId, index) {
|
|
|
|
|
const itemsContainer = document.getElementById(fieldId + '_items');
|
|
|
|
|
if (!itemsContainer) return;
|
|
|
|
|
|
|
|
|
|
const item = itemsContainer.querySelector(`.array-object-item[data-index="${index}"]`);
|
|
|
|
|
if (item) {
|
|
|
|
|
item.remove();
|
|
|
|
|
// Re-index remaining items
|
|
|
|
|
// Use data-index for index storage - no need to encode index in onclick strings or IDs
|
|
|
|
|
const remainingItems = itemsContainer.querySelectorAll('.array-object-item');
|
|
|
|
|
remainingItems.forEach((itemEl, newIndex) => {
|
|
|
|
|
itemEl.setAttribute('data-index', newIndex);
|
|
|
|
|
// Update all inputs within this item - only update index in array bracket notation
|
|
|
|
|
itemEl.querySelectorAll('input, select, textarea').forEach(input => {
|
|
|
|
|
const name = input.getAttribute('name');
|
|
|
|
|
const id = input.id;
|
|
|
|
|
if (name) {
|
|
|
|
|
// Only replace index in bracket notation like [0], [1], etc.
|
|
|
|
|
// Match pattern: field_name[index] but not field_name123
|
|
|
|
|
const newName = name.replace(/\[(\d+)\]/, `[${newIndex}]`);
|
|
|
|
|
input.setAttribute('name', newName);
|
|
|
|
|
}
|
|
|
|
|
if (id) {
|
|
|
|
|
// Only update index in specific patterns like _item_0, _item_1
|
|
|
|
|
// Match pattern: _item_<digits> but be careful not to break other numeric IDs
|
|
|
|
|
const newId = id.replace(/_item_(\d+)/, `_item_${newIndex}`);
|
|
|
|
|
input.id = newId;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// The buttons' inline onclick handlers carry the item index, so
|
|
|
|
|
// rewrite it in each one.
|
|
|
|
|
itemEl.querySelectorAll('button[onclick]').forEach(button => {
|
|
|
|
|
const onclick = button.getAttribute('onclick');
|
|
|
|
|
if (onclick) {
|
|
|
|
|
// Match patterns like:
|
|
|
|
|
// removeArrayObjectItem('fieldId', 0)
|
|
|
|
|
// handleArrayObjectFileUpload(event, 'fieldId', 0, 'propKey', 'pluginId')
|
|
|
|
|
// removeArrayObjectFile('fieldId', 0, 'propKey')
|
|
|
|
|
// Only replace the numeric index parameter (second or third argument depending on function)
|
|
|
|
|
let newOnclick = onclick;
|
|
|
|
|
// For removeArrayObjectItem('fieldId', index) - second param
|
|
|
|
|
newOnclick = newOnclick.replace(
|
|
|
|
|
/removeArrayObjectItem\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*\)/g,
|
|
|
|
|
`removeArrayObjectItem('$1', ${newIndex})`
|
|
|
|
|
);
|
|
|
|
|
// For handleArrayObjectFileUpload(event, 'fieldId', index, ...) - third param
|
|
|
|
|
newOnclick = newOnclick.replace(
|
|
|
|
|
/handleArrayObjectFileUpload\s*\(\s*event\s*,\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
|
|
|
|
|
`handleArrayObjectFileUpload(event, '$1', ${newIndex},`
|
|
|
|
|
);
|
|
|
|
|
// For removeArrayObjectFile('fieldId', index, ...) - second param
|
|
|
|
|
newOnclick = newOnclick.replace(
|
|
|
|
|
/removeArrayObjectFile\s*\(\s*['"]([^'"]+)['"]\s*,\s*\d+\s*,/g,
|
|
|
|
|
`removeArrayObjectFile('$1', ${newIndex},`
|
|
|
|
|
);
|
|
|
|
|
button.setAttribute('onclick', newOnclick);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
window.updateArrayObjectData(fieldId);
|
|
|
|
|
|
|
|
|
|
// Update add button state
|
|
|
|
|
const addButton = itemsContainer.nextElementSibling;
|
|
|
|
|
if (addButton && addButton.getAttribute('onclick')) {
|
|
|
|
|
// Extract maxItems from onclick attribute more safely
|
|
|
|
|
// Pattern: addArrayObjectItem('fieldId', 'fullKey', maxItems)
|
|
|
|
|
const onclickMatch = addButton.getAttribute('onclick').match(/addArrayObjectItem\s*\([^,]+,\s*[^,]+,\s*(\d+)\)/);
|
|
|
|
|
if (onclickMatch && onclickMatch[1]) {
|
|
|
|
|
const maxItems = parseInt(onclickMatch[1]);
|
|
|
|
|
if (remainingItems.length < maxItems) {
|
|
|
|
|
addButton.disabled = false;
|
|
|
|
|
addButton.style.opacity = '1';
|
|
|
|
|
addButton.style.cursor = 'pointer';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Force initialization immediately when script loads (for HTMX swapped content)
|
|
|
|
|
debugLog('Plugins script loaded, checking for elements...');
|
|
|
|
|
|
|
|
|
|