qbit_manage/web-ui/js/utils/form-renderer.js
bobokun 156291723f
4.5.5 (#922)
# Improvements
- **ci(docker)**: add OCI labels and build metadata to Docker images
- **Web UI**: Show an "Update available" badge next to the version and a
toast notification when a newer version is detected
- **Web UI**: Add integrated docs with collapsible sections
- **ci(build)**: Publish to PyPI
- **Category**: Allow category changes regardless of the "Category
Update All" status (Fixes #913)

# Bug Fixes
- Fixes container hanging when using run command with QBT_RUN flag
(Fixes #911)
- Fixes bug on interval scheduler not displaying the correct next run
time
- Fix bug on webAPI requests not being queued correctly when called
during a scheduled run

**Full Changelog**:
https://github.com/StuffAnThings/qbit_manage/compare/v4.5.4...v4.5.5

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Actionbot <actions@github.com>
Co-authored-by: bakerboy448 <55419169+bakerboy448@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ineednewpajamas <73252768+ineednewpajamas@users.noreply.github.com>
2025-08-24 18:44:54 -04:00

1049 lines
44 KiB
JavaScript

/**
* Form Renderer Utility Module
* Responsible for generating HTML forms based on schema definitions.
*/
import { getNestedValue } from './utils.js';
import { CLOSE_ICON_SVG } from './icons.js';
import { generateCategoryDropdownHTML } from './categories.js';
/**
* Generates attributes to prevent password manager detection and autofill
* Only applied to specific fields that cause performance issues
* @param {string} inputType - The type of input ('password', 'text', etc.)
* @returns {string} HTML attributes string
*/
function getPasswordManagerPreventionAttributes(inputType = 'text') {
const attributes = [
'autocomplete="off"',
'data-lpignore="true"',
'data-form-type="other"',
'data-1p-ignore="true"',
'data-bwignore="true"',
'spellcheck="false"'
];
// For password fields, use more specific autocomplete value
if (inputType === 'password') {
attributes[0] = 'autocomplete="new-password"';
}
return attributes.join(' ');
}
/**
* Determines if a field should have password manager prevention attributes
* Only applies to the specific fields that cause performance issues
* @param {string} fieldName - The field name
* @param {object} field - The field definition
* @returns {boolean} Whether to apply prevention attributes
*/
function shouldPreventPasswordManager(fieldName, field) {
if (!fieldName) return false;
// Only these exact fields need password manager prevention
const targetFields = [
'notifiarr.apikey', // Notifiarr API key (password field)
'user', // qBittorrent username (text field)
'pass' // qBittorrent password (password field)
];
return targetFields.includes(fieldName);
}
/**
* Generates the HTML for a given section.
* @param {object} config - The configuration object for the section.
* @param {object} data - The current data for the section.
* @returns {string} The HTML string for the section.
*/
export function generateSectionHTML(config, data) {
let html = `
<div class="section-header">
<h2 class="section-title">${config.title}</h2>
<p class="section-description">${config.description || ''}</p>
</div>
<div class="section-content">
`;
if (config.type === 'dynamic-key-value-list') {
// Process documentation fields first
if (config.fields) {
const docFields = config.fields.filter(field => field.type === 'documentation');
html += generateFieldsHTML(docFields, data);
}
html += generateDynamicKeyValueListHTML(config, data);
} else if (config.type === 'fixed-object-config') {
// For fixed-object-config, render fields of the first (and usually only) field directly
// The schema for fixed-object-config should have a single field which is an object
const mainField = config.fields[0];
html += generateFieldsHTML(Object.values(mainField.properties), data[mainField.name] || {}, mainField.name);
} else if (config.type === 'share-limits-config') {
// Process documentation fields first
if (config.fields) {
const docFields = config.fields.filter(field => field.type === 'documentation');
html += generateFieldsHTML(docFields, data);
}
html += generateShareLimitsHTML(config, data);
} else if (config.type === 'complex-object') {
// Process documentation fields first
if (config.fields) {
const docFields = config.fields.filter(field => field.type === 'documentation');
html += generateFieldsHTML(docFields, data);
}
html += generateComplexObjectHTML(config, data);
} else if (config.type === 'multi-root-object') {
// For multi-root-object, render fields directly without nesting under section name
// Performance optimization for notifications section
if (config.title === 'Notifications') {
html += generateNotificationsFieldsHTML(config.fields, data);
} else {
html += generateFieldsHTML(config.fields, data);
}
} else {
html += generateFieldsHTML(config.fields, data);
}
html += `
</div>
<div class="section-actions">
<button type="button" class="btn btn-secondary" id="reset-section-btn">
Reset Section
</button>
</div>
`;
return html;
}
/**
* Generates HTML for a list of fields.
* @param {Array<object>} fields - An array of field definitions.
* @param {object} data - The current data for the section.
* @param {string} [prefix=''] - Prefix for field names (for nested fields).
* @returns {string} The HTML string for the fields.
*/
function generateFieldsHTML(fields, data, prefix = '') {
return fields.map(field => {
if (field.type === 'section_header') {
return generateFieldHTML(field, null, null); // No value or fieldName needed for section_header
}
let fieldName, value;
if (field.name) {
fieldName = prefix ? `${prefix}.${field.name}` : field.name;
value = getNestedValue(data, fieldName) ?? field.default ?? '';
} else {
fieldName = null;
value = null;
}
return generateFieldHTML(field, value, fieldName);
}).join('');
}
/**
* Optimized field generation for notifications section to reduce DOM complexity
* @param {Array<object>} fields - An array of field definitions.
* @param {object} data - The current data for the section.
* @returns {string} The HTML string for the fields.
*/
function generateNotificationsFieldsHTML(fields, data) {
// Render fields in their original order to preserve documentation placement
let html = '';
let functionWebhooksFields = [];
let inFunctionWebhooks = false;
fields.forEach(field => {
// Check if we're entering the Function Specific Webhooks section
if (field.type === 'section_header' && field.label.includes('Function Specific')) {
inFunctionWebhooks = true;
// Start the lazy loading container for function webhooks
html += `<div class="webhook-sections-container">`;
html += `<div class="function-webhooks-lazy" data-section="function-webhooks">`;
html += `<div class="lazy-load-placeholder">Click to load Function Specific Webhooks...</div>`;
html += `<div class="lazy-content hidden">`;
}
if (inFunctionWebhooks) {
// Collect function webhook fields for lazy loading
functionWebhooksFields.push(field);
} else {
// Render field normally in original order
if (field.type === 'section_header') {
html += generateFieldHTML(field, null, null);
} else {
let fieldName, value;
if (field.name) {
fieldName = field.name;
value = getNestedValue(data, fieldName) ?? field.default ?? '';
} else {
fieldName = null;
value = null;
}
html += generateFieldHTML(field, value, fieldName);
}
}
});
// Render function webhooks in lazy loading container
if (functionWebhooksFields.length > 0) {
functionWebhooksFields.forEach(field => {
if (field.type === 'section_header') {
html += generateFieldHTML(field, null, null);
} else {
let fieldName, value;
if (field.name) {
fieldName = field.name;
value = getNestedValue(data, fieldName) ?? field.default ?? '';
} else {
fieldName = null;
value = null;
}
html += generateFieldHTML(field, value, fieldName);
}
});
html += `</div></div></div>`;
}
return html;
}
/**
* Renders a section of fields with performance optimizations
*/
function renderFieldSection(fields, data, sectionId) {
if (!fields.length) return '';
return `<div class="field-section" id="${sectionId}">` +
fields.map(field => {
if (field.type === 'section_header') {
return generateFieldHTML(field, null, null);
}
let fieldName, value;
if (field.name) {
fieldName = field.name;
value = getNestedValue(data, fieldName) ?? field.default ?? '';
} else {
fieldName = null;
value = null;
}
return generateFieldHTML(field, value, fieldName);
}).join('') +
`</div>`;
}
/**
* Generates HTML for a single field.
* @param {object} field - The field definition.
* @param {*} value - The current value of the field.
* @param {string} fieldName - The full name of the field (e.g., 'parent.child').
* @returns {string} The HTML string for the field.
*/
export function generateFieldHTML(field, value, fieldName) {
const fieldId = fieldName ? `field-${fieldName.replace(/\./g, '-')}` : '';
const isRequired = field.required ? 'required' : '';
const requiredMark = field.required ? '<span class="required-mark">*</span>' : '';
let inputHTML = '';
switch (field.type) {
case 'boolean':
inputHTML = `
<label class="checkbox-label">
<input type="checkbox" id="${fieldId}" name="${fieldName}"
${value === true ? 'checked' : ''} class="form-checkbox">
<span class="checkmark"></span>
${field.label}
</label>
`;
break;
case 'section_header':
inputHTML = `
<h3 class="section-subheader">${field.label}</h3>
`;
break;
case 'documentation':
// Create a placeholder div that will be replaced with the documentation component
const docId = `doc-${Math.random().toString(36).substr(2, 9)}`;
inputHTML = `<div id="${docId}" class="documentation-placeholder"
data-title="${field.title || 'Documentation'}"
data-file-path="${field.filePath || ''}"
data-section="${field.section || ''}"
data-heading-level="${field.headingLevel || 2}"
data-default-expanded="${field.defaultExpanded || false}"></div>`;
// Schedule the documentation component creation for after DOM insertion
setTimeout(() => {
const placeholder = document.getElementById(docId);
if (placeholder && window.DocumentationViewer) {
window.DocumentationViewer.createDocumentationSection({
title: placeholder.dataset.title,
filePath: placeholder.dataset.filePath,
section: placeholder.dataset.section || null,
headingLevel: parseInt(placeholder.dataset.headingLevel),
defaultExpanded: placeholder.dataset.defaultExpanded === 'true'
}).then(docSection => {
placeholder.parentNode.replaceChild(docSection, placeholder);
}).catch(error => {
console.error('Failed to create documentation section:', error);
placeholder.innerHTML = '<div class="documentation-error">Failed to load documentation</div>';
});
}
}, 100);
break;
case 'button':
inputHTML = `
<button type="button" class="btn btn-secondary apply-to-all-btn"
data-action="${field.action}">
${field.label}
</button>
`;
break;
case 'number':
inputHTML = `
<label for="${fieldId}" class="form-label ${isRequired}">
${field.label} ${requiredMark}
</label>
<input type="number" id="${fieldId}" name="${fieldName}"
class="form-input" value="${value}"
${field.min !== undefined ? `min="${field.min}"` : ''}
${field.max !== undefined ? `max="${field.max}"` : ''}
${field.step !== undefined ? `step="${field.step}"` : ''}
${field.placeholder ? `placeholder="${field.placeholder}"` : ''}
${isRequired}>
`;
break;
case 'password':
// Import the eye icons at the top of the file
const EYE_ICON_SVG = `<svg class="icon" viewBox="0 0 24 24"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>`;
inputHTML = `
<label for="${fieldId}" class="form-label ${isRequired}">
${field.label} ${requiredMark}
</label>
<div class="password-input-group">
<input type="password" id="${fieldId}" name="${fieldName}"
class="form-input ${shouldPreventPasswordManager(fieldName, field) ? 'hide-password-toggle' : ''}" value="${value}"
${field.placeholder ? `placeholder="${field.placeholder}"` : ''}
${shouldPreventPasswordManager(fieldName, field) ? getPasswordManagerPreventionAttributes('password') : ''}
${isRequired}>
<button type="button" class="btn btn-icon password-toggle"
data-target="${fieldId}">
${EYE_ICON_SVG}
</button>
</div>
`;
break;
case 'select':
const options = field.options || [];
inputHTML = `
<label for="${fieldId}" class="form-label ${isRequired}">
${field.label} ${requiredMark}
</label>
<select id="${fieldId}" name="${fieldName}" class="form-select" ${isRequired}>
${field.placeholder ? `<option value="">${field.placeholder}</option>` : ''}
${options.map(option => {
const optionValue = typeof option === 'object' ? option.value : option;
const optionLabel = typeof option === 'object' ? option.label : option;
const selected = value === optionValue ? 'selected' : '';
return `<option value="${optionValue}" ${selected}>${optionLabel}</option>`;
}).join('')}
</select>
`;
break;
case 'dynamic_select_text':
const selectOptions = field.options || ['apprise', 'notifiarr', 'webhook'];
let selectedOption = '';
let customValue = '';
// Determine which option is selected based on the current value
if (value === 'apprise' || value === 'notifiarr') {
selectedOption = value;
} else if (value && value !== '' && !['apprise', 'notifiarr'].includes(value)) {
// If value is not empty and not one of the predefined options, it's a custom URL
selectedOption = 'webhook';
customValue = value;
}
inputHTML = `
<label for="${fieldId}" class="form-label ${isRequired}">
${field.label} ${requiredMark}
</label>
<div class="dynamic-select-text-group" data-field="${fieldName}">
<select id="${fieldId}" name="${fieldName}_type" class="form-select dynamic-select" ${isRequired}>
<option value="">Select type...</option>
${selectOptions.map(option => {
const selected = selectedOption === option ? 'selected' : '';
const label = option === 'webhook' ? 'Custom URL' : option;
return `<option value="${option}" ${selected}>${label}</option>`;
}).join('')}
</select>
<input type="text" name="${fieldName}"
class="form-input dynamic-text-input"
value="${customValue}"
placeholder="Enter webhook URL"
id="${fieldId}_custom_url"
style="display: ${selectedOption === 'webhook' ? 'block' : 'none'}; margin-top: 8px;">
<input type="hidden" name="${fieldName}" value="${value}" class="dynamic-hidden-input">
</div>
`;
break;
case 'textarea':
inputHTML = `
<label for="${fieldId}" class="form-label ${isRequired}">
${field.label} ${requiredMark}
</label>
<textarea id="${fieldId}" name="${fieldName}" class="form-textarea"
${field.placeholder ? `placeholder="${field.placeholder}"` : ''}
${field.rows ? `rows="${field.rows}"` : 'rows="4"'}
${isRequired}>${value}</textarea>
`;
break;
case 'array':
inputHTML = generateArrayFieldHTML(field, value, fieldName);
break;
default: // text
// Check if this field should use category dropdown
if (field.useCategoryDropdown) {
inputHTML = `
<label for="${fieldId}" class="form-label ${isRequired}">
${field.label} ${requiredMark}
</label>
<select id="${fieldId}" name="${fieldName}" class="form-select category-dropdown" ${isRequired}>
<option value="${value}" selected>${value || 'Select Category'}</option>
</select>
`;
} else {
inputHTML = `
<label for="${fieldId}" class="form-label ${isRequired}">
${field.label} ${requiredMark}
</label>
<input type="text" id="${fieldId}" name="${fieldName}"
class="form-input" value="${value}"
${field.placeholder ? `placeholder="${field.placeholder}"` : ''}
${shouldPreventPasswordManager(fieldName, field) ? getPasswordManagerPreventionAttributes('text') : ''}
${isRequired}>
`;
}
break;
}
return `
<div class="form-group" data-field="${fieldName}">
${inputHTML}
${field.description ? `<div class="form-help">${field.description}</div>` : ''}
<div class="field-validation"></div>
</div>
`;
}
/**
* Generates HTML for an array field.
* @param {object} field - The field definition for the array.
* @param {Array<string>} value - The current array values.
* @param {string} fieldName - The full name of the array field.
* @returns {string} The HTML string for the array field.
*/
function generateArrayFieldHTML(field, value, fieldName) {
const fieldId = `field-${fieldName.replace(/\./g, '-')}`;
const arrayValue = Array.isArray(value) ? value : [];
const requiredMark = field.required ? '<span class="required-mark">*</span>' : '';
let html = `
<label class="form-label ${field.required ? 'required' : ''}">
${field.label} ${requiredMark}
</label>
<div class="array-field" data-field="${fieldName}">
<div class="array-items">
`;
arrayValue.forEach((item, index) => {
// Check if array items should use category dropdown
const useDropdown = field.items && field.items.useCategoryDropdown;
let inputHTML;
if (useDropdown) {
inputHTML = `
<select class="form-select category-dropdown array-item-input"
id="${fieldId}-item-${index}"
data-field="${fieldName}" data-index="${index}"
name="${fieldName}[${index}]">
<option value="${item}" selected>${item || 'Select Category'}</option>
</select>
`;
} else {
inputHTML = `
<input type="text" class="form-input array-item-input"
id="${fieldId}-item-${index}"
value="${item}" data-field="${fieldName}" data-index="${index}"
name="${fieldName}[${index}]">
`;
}
html += `
<div class="array-item" data-index="${index}">
<label for="${fieldId}-item-${index}" class="form-label sr-only">Item ${index + 1}</label>
<div class="array-item-input-group">
${inputHTML}
<button type="button" class="btn btn-icon btn-close-icon remove-array-item">
${CLOSE_ICON_SVG}
</button>
</div>
</div>
`;
});
html += `
</div>
<button type="button" class="btn btn-secondary add-array-item"
data-field="${fieldName}">
Add Item
</button>
</div>
`;
return html;
}
/**
* Generates HTML for a key-value list (e.g., Categories, Category Changes).
* @param {object} config - The section configuration.
* @param {object} data - The current data for the section.
* @returns {string} The HTML string for the key-value list.
*/
function generateDynamicKeyValueListHTML(config, data) {
const categories = data || {};
const isCatChange = config.title.includes('Category Change');
let html = `
<div class="key-value-list">
<div class="key-value-header">
<h3>${isCatChange ? 'Category Changes' : 'Categories'}</h3>
<button type="button" class="btn btn-primary add-category-btn">
Add ${isCatChange ? 'Category Change' : 'Category'}
</button>
</div>
<div class="key-value-items">
`;
Object.entries(categories).forEach(([key, value]) => {
// Check if this config should use category dropdowns
const useDropdownForKey = config.useCategoryDropdown && isCatChange;
const useDropdownForValue = isCatChange && config.fields && config.fields[0] &&
config.fields[0].properties && config.fields[0].properties.new_category &&
config.fields[0].properties.new_category.useCategoryDropdown;
let keyInputHTML, valueInputHTML;
// Generate key input (old category)
if (useDropdownForKey) {
keyInputHTML = `
<select class="form-select category-dropdown category-key" name="category-key-${key}">
<option value="${key}" selected>${key}</option>
</select>
`;
} else {
keyInputHTML = `
<input type="text" class="form-input category-key" value="${key}"
name="category-key-${key}">
`;
}
// Generate value input (new category or save path)
if (useDropdownForValue) {
valueInputHTML = `
<select class="form-select category-dropdown category-value" name="category-value-${key}">
<option value="${value || ''}" selected>${value || 'Select Category'}</option>
</select>
`;
} else {
valueInputHTML = `
<input type="text" class="form-input category-value"
value="${value || ''}"
placeholder="${isCatChange ? 'New Category Name' : '/path/to/category'}"
name="category-value-${key}">
`;
}
html += `
<div class="key-value-item category-row" data-key="${key}">
<div class="category-inputs">
<div class="form-group category-name-group">
<label class="form-label">${isCatChange ? 'Old Category' : 'Category Name'}</label>
${keyInputHTML}
</div>
<div class="form-group category-path-group">
<label class="form-label">${isCatChange ? 'New Category' : 'Save Path'}</label>
${valueInputHTML}
</div>
</div>
<button type="button" class="btn btn-icon btn-close-icon remove-category-btn">
${CLOSE_ICON_SVG}
</button>
</div>
`;
});
html += `
</div>
</div>
`;
return html;
}
/**
* Generates HTML for complex objects (currently a placeholder).
* @param {object} config - The section configuration.
* @param {object} data - The current data for the section.
* @returns {string} The HTML string for the complex object.
*/
function generateComplexObjectHTML(config, data) {
const complexObjectData = data || {};
let html = `
<div class="complex-object">
`;
// Documentation is now handled in generateSectionHTML, so we don't need to duplicate it here
html += `
<div class="complex-object-header">
<h3>${config.title}</h3>
<button type="button" class="btn btn-primary add-complex-object-item-btn">
Add New Entry
</button>
</div>
<div class="complex-object-items">
`;
Object.entries(complexObjectData).forEach(([key, value]) => {
html += generateComplexObjectEntryHTML(key, value, config);
});
html += `
</div>
</div>
`;
return html;
}
/**
* Generates HTML for a single complex object entry (e.g., a tracker entry).
* @param {string} entryKey - The key of the complex object entry (e.g., tracker URL).
* @param {object} entryValue - The value object of the complex object entry (e.g., {tag, cat, notifiarr}).
* @param {object} config - The schema configuration for the complex object.
* @returns {string} The HTML string for the complex object entry.
*/
function generateComplexObjectEntryHTML(entryKey, entryValue, config) {
const isOther = entryKey === 'other';
// Handle flat string values (like categories: "category_name": "/path/to/save")
if (config.flatStringValues && (typeof entryValue === 'string' || Array.isArray(entryValue))) {
const keyLabel = config.keyLabel || 'Key';
const valueSchema = config.patternProperties?.[".*"] || config.additionalProperties;
const valueLabel = valueSchema?.label || 'Value';
const valueDescription = valueSchema?.description || '';
let valueInputHTML;
if (entryKey === 'Uncategorized') {
const arrayValue = Array.isArray(entryValue) ? entryValue : (entryValue ? [entryValue] : []);
const fieldId = `field-${entryKey.replace(/\./g, '-')}`;
let itemsHTML = '';
arrayValue.forEach((item, index) => {
itemsHTML += `
<div class="array-item" data-index="${index}">
<label for="${fieldId}-item-${index}" class="form-label sr-only">Item ${index + 1}</label>
<div class="array-item-input-group">
<input type="text" class="form-input array-item-input"
id="${fieldId}-item-${index}"
value="${item}" data-field="${entryKey}" data-index="${index}"
name="${entryKey}[${index}]">
<button type="button" class="btn btn-icon btn-close-icon remove-array-item">
${CLOSE_ICON_SVG}
</button>
</div>
</div>
`;
});
valueInputHTML = `
<div class="array-field" data-field="${entryKey}">
<div class="array-items">
${itemsHTML}
</div>
<button type="button" class="btn btn-secondary add-array-item"
data-field="${entryKey}">
Add Path
</button>
</div>
${valueDescription ? `<div class="form-help">${valueDescription}</div>` : ''}
`;
} else if (typeof entryValue === 'string') {
valueInputHTML = `
<input type="text" class="form-input" name="${entryKey}::value" value="${entryValue}">
${valueDescription ? `<div class="form-help">${valueDescription}</div>` : ''}
<div class="field-validation"></div>
`;
} else {
valueInputHTML = `<div class="alert alert-error">Invalid value for ${entryKey}. Expected a string.</div>`;
}
let html = `
<div class="complex-object-item complex-object-entry-card" data-key="${entryKey}">
<div class="complex-object-item-content">
<div class="category-inputs">
<div class="category-labels-row">
<div class="category-name-group">
<label class="form-label">${keyLabel}</label>
</div>
<div class="category-path-group">
<label class="form-label">${valueLabel}</label>
</div>
</div>
<div class="category-inputs-row">
<div class="form-group category-name-group">
<input type="text" class="form-input complex-object-key" value="${entryKey}" data-original-key="${entryKey}" ${isOther || entryKey === 'Uncategorized' ? 'readonly' : ''}>
</div>
<div class="form-group category-path-group">
${valueInputHTML}
</div>
</div>
</div>
<button type="button" class="btn btn-icon btn-close-icon remove-complex-object-item" data-key="${entryKey}">
${CLOSE_ICON_SVG}
</button>
</div>
</div>
`;
return html;
}
// Original logic for object-based entries
let schemaProperties;
if (config.patternProperties) {
if (isOther && config.patternProperties.other) {
schemaProperties = config.patternProperties.other.properties;
} else if (config.patternProperties["^(?!other$).*$"]) {
schemaProperties = config.patternProperties["^(?!other$).*$"].properties;
} else if (config.patternProperties[".*"]) {
// For nohardlinks schema which uses ".*" pattern
schemaProperties = config.patternProperties[".*"].properties;
} else {
console.error("Could not find matching pattern properties for complex object entry:", config);
return '';
}
} else if (config.fields && config.fields.length > 0 && config.fields[0].properties) {
// Fallback for old field-based schemas
schemaProperties = config.fields[0].properties;
} else {
console.error("Could not determine schema properties for complex object entry:", config);
return ''; // Or handle error appropriately
}
// Use custom key label if provided, otherwise default to "Tracker URL"
const keyLabel = config.keyLabel || 'Tracker URL';
let keyInputHTML;
// Check if this schema should use category dropdown for key editing
if (config.useCategoryDropdown && !isOther) {
// Generate dropdown for category selection
keyInputHTML = `
<select class="form-input complex-object-key-dropdown" data-original-key="${entryKey}">
<option value="${entryKey}" selected>${entryKey}</option>
</select>
`;
} else {
// Use regular text input
keyInputHTML = `
<input type="text" class="form-input complex-object-key" value="${entryKey}" data-original-key="${entryKey}" ${isOther ? 'readonly' : ''}>
`;
}
let html = `
<div class="complex-object-item complex-object-entry-card" data-key="${entryKey}">
<button type="button" class="btn btn-icon btn-close-icon remove-complex-object-item complex-object-close-btn" data-key="${entryKey}">
${CLOSE_ICON_SVG}
</button>
<div class="complex-object-item-content">
<div class="form-group complex-object-key-group">
<label class="form-label">${keyLabel}</label>
${keyInputHTML}
</div>
`;
// Render fields based on schemaProperties
Object.entries(schemaProperties).forEach(([propName, propSchema]) => {
// Use a custom separator for complex object fields to avoid issues with dots in keys
const fieldName = `${entryKey}::${propName}`;
const value = entryValue[propName] ?? propSchema.default ?? '';
if (propSchema.type === 'array') {
html += generateArrayFieldHTML(propSchema, value, fieldName);
} else {
html += generateFieldHTML(propSchema, value, fieldName);
}
});
html += `
</div>
</div>
`;
return html;
}
/**
* Build defaults for share-limit group properties directly from the schema.
* This avoids duplicating defaults in code and uses the schema as source of truth.
* @param {object} config - The section configuration (share-limits schema).
* @returns {object} defaults map of property -> default value
*/
function getShareLimitSchemaDefaults(config) {
// share_limits schema defines a single field object with name 'share_limit_groups'
const groupField = Array.isArray(config?.fields)
? (config.fields.find(f => f.name === 'share_limit_groups') || config.fields[0])
: null;
const properties = groupField?.properties || {};
const defaults = {};
for (const [propName, propSchema] of Object.entries(properties)) {
if (Object.prototype.hasOwnProperty.call(propSchema, 'default')) {
defaults[propName] = propSchema.default;
} else {
// Fallbacks when schema doesn't provide a default
switch (propSchema.type) {
case 'array':
defaults[propName] = [];
break;
case 'boolean':
defaults[propName] = false;
break;
case 'number':
// No assumed numeric default; undefined means "no value set"
defaults[propName] = undefined;
break;
default:
// text and others: default to empty string so we don't show in summary
defaults[propName] = '';
}
}
}
return defaults;
}
/**
* Generates HTML for Share Limits configuration with drag-and-drop interface.
* @param {object} config - The section configuration.
* @param {object} data - The current data for the section.
* @returns {string} The HTML string for the share limits configuration.
*/
export function generateShareLimitsHTML(config, data) {
const shareLimitsData = data || {};
const defaults = getShareLimitSchemaDefaults(config);
// Convert data to array format sorted by priority for display
const groupsArray = Object.entries(shareLimitsData)
.map(([key, value]) => ({
key,
priority: value.priority || 999,
...value
}))
.sort((a, b) => a.priority - b.priority);
let html = `
<div class="share-limits-config">
<div class="share-limits-header">
<h3>Share Limit Groups</h3>
<p class="share-limits-description">
Drag and drop to reorder priority. Click on a group to configure its settings.
</p>
<button type="button" class="btn btn-primary add-share-limit-group-btn">
Add New Group
</button>
</div>
<div class="share-limits-list" id="share-limits-sortable">
`;
groupsArray.forEach((group, index) => {
const summaryText = generateGroupSummary(group, defaults);
html += `
<div class="share-limit-group-item" data-key="${group.key}" data-priority="${group.priority}">
<div class="share-limit-group-handle">
<svg class="drag-handle-icon" viewBox="0 0 24 24" width="16" height="16">
<path d="M9 3h2v2H9V3zm0 4h2v2H9V7zm0 4h2v2H9v-2zm0 4h2v2H9v-2zm0 4h2v2H9v-2zm4-16h2v2h-2V3zm0 4h2v2h-2V7zm0 4h2v2h-2v-2zm0 4h2v2h-2v-2zm0 4h2v2h-2v-2z"/>
</svg>
</div>
<div class="share-limit-group-content" data-key="${group.key}">
<div class="share-limit-group-main">
<div class="share-limit-group-info">
<div class="share-limit-group-name">${group.key}</div>
<div class="share-limit-group-priority">Priority: ${group.priority}</div>
</div>
<div class="share-limit-group-summary">${summaryText}</div>
</div>
<button type="button" class="btn btn-icon btn-close-icon remove-share-limit-group" data-key="${group.key}">
${CLOSE_ICON_SVG}
</button>
</div>
</div>
`;
});
html += `
</div>
</div>
`;
return html;
}
/**
* Generates a summary text for a share limit group.
* @param {object} group - The share limit group data.
* @returns {string} Summary text describing the group's configuration.
*/
function generateGroupSummary(group, defaults) {
// defaults are supplied from schema via getShareLimitSchemaDefaults(config)
const summaryParts = [];
// Helper function to check if value is not default
const isNotDefault = (value, defaultValue) => {
// Handle arrays
if (Array.isArray(value) && Array.isArray(defaultValue)) {
return value.length > 0;
}
// Handle null/undefined values
if (value === undefined || value === null) {
return false;
}
// Handle mixed type comparisons (number vs string)
// This handles cases where default is number but value could be string like "1d", "2h1m"
if (typeof value !== typeof defaultValue) {
// If value is a string and default is a number, check if string represents the default number
if (typeof value === 'string' && typeof defaultValue === 'number') {
// If the string is a pure number, convert and compare
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue.toString() === value) {
return numValue !== defaultValue;
}
// If it's a duration string like "1d", "2h1m", it's definitely not default
return true;
}
// If value is number and default is string, convert default and compare
if (typeof value === 'number' && typeof defaultValue === 'string') {
const numDefault = parseFloat(defaultValue);
if (!isNaN(numDefault)) {
return value !== numDefault;
}
}
// Different types that can't be meaningfully compared - assume not default
return true;
}
// Same type comparisons
return value !== defaultValue;
};
// 1) Share limits (share ratio, max/min seeding time, last active, min num seeds, limit upload speed)
// Priority order: max_ratio, max_seeding_time, min_seeding_time, max_last_active, min_last_active, min_num_seeds, limit_upload_speed
if (isNotDefault(group.max_ratio, defaults.max_ratio)) {
summaryParts.push(`Max Ratio: ${group.max_ratio}`);
}
if (isNotDefault(group.max_seeding_time, defaults.max_seeding_time)) {
summaryParts.push(`Max Seed Time: ${group.max_seeding_time}`);
}
if (isNotDefault(group.min_seeding_time, defaults.min_seeding_time)) {
summaryParts.push(`Min Seed Time: ${group.min_seeding_time}`);
}
if (isNotDefault(group.max_last_active, defaults.max_last_active)) {
summaryParts.push(`Max Last Active: ${group.max_last_active}`);
}
if (isNotDefault(group.min_last_active, defaults.min_last_active)) {
summaryParts.push(`Min Last Active: ${group.min_last_active}`);
}
if (isNotDefault(group.min_num_seeds, defaults.min_num_seeds)) {
summaryParts.push(`Min Seeds: ${group.min_num_seeds}`);
}
if (isNotDefault(group.limit_upload_speed, defaults.limit_upload_speed)) {
summaryParts.push(`Upload Limit: ${group.limit_upload_speed} KiB/s`);
}
if (isNotDefault(group.upload_speed_on_limit_reached, defaults.upload_speed_on_limit_reached)) {
summaryParts.push(`Upload Speed on Limit Reached: ${group.upload_speed_on_limit_reached} KiB/s`);
}
// Size filters (display values as entered)
if (isNotDefault(group.min_torrent_size, defaults.min_torrent_size)) {
summaryParts.push(`Min Size ${group.min_torrent_size}`);
}
if (isNotDefault(group.max_torrent_size, defaults.max_torrent_size)) {
summaryParts.push(`Max Size ${group.max_torrent_size}`);
}
// 2) Tag/Category filters
const tagFilters = [];
if (isNotDefault(group.include_all_tags, defaults.include_all_tags)) {
tagFilters.push(`Include All: ${group.include_all_tags.slice(0, 2).join(', ')}${group.include_all_tags.length > 2 ? '...' : ''}`);
}
if (isNotDefault(group.include_any_tags, defaults.include_any_tags)) {
tagFilters.push(`Include Any: ${group.include_any_tags.slice(0, 2).join(', ')}${group.include_any_tags.length > 2 ? '...' : ''}`);
}
if (isNotDefault(group.exclude_all_tags, defaults.exclude_all_tags)) {
tagFilters.push(`Exclude All: ${group.exclude_all_tags.slice(0, 2).join(', ')}${group.exclude_all_tags.length > 2 ? '...' : ''}`);
}
if (isNotDefault(group.exclude_any_tags, defaults.exclude_any_tags)) {
tagFilters.push(`Exclude Any: ${group.exclude_any_tags.slice(0, 2).join(', ')}${group.exclude_any_tags.length > 2 ? '...' : ''}`);
}
if (tagFilters.length > 0) {
summaryParts.push(`Tags: ${tagFilters.join(', ')}`);
}
if (isNotDefault(group.categories, defaults.categories)) {
summaryParts.push(`Categories: ${group.categories.slice(0, 2).join(', ')}${group.categories.length > 2 ? '...' : ''}`);
}
// 3) Any other options (Cleanup enabled, Group Upload Speed, Custom Tag)
if (isNotDefault(group.cleanup, defaults.cleanup)) {
summaryParts.push('Cleanup: Enabled');
}
if (isNotDefault(group.enable_group_upload_speed, defaults.enable_group_upload_speed)) {
summaryParts.push('Group Upload Speed: Enabled');
}
if (isNotDefault(group.custom_tag, defaults.custom_tag)) {
summaryParts.push(`Custom Tag: ${group.custom_tag}`);
}
const result = summaryParts.length > 0 ? summaryParts.join(' • ') : 'No specific configuration';
return result;
}