qbit_manage/web-ui/js/components/config-form.js
bobokun 13fab64d3c
4.5.2 (#889)
# Requirements Updated
- "GitPython==3.1.45"
- "retrying==1.4.1",


# New Features
- **Remove Orphaned**: Adds new `min_file_age_minutes` flag to prevent
files newer than a certain time from being deleted (Thanks to @H2OKing89
#859)
- Adds new standalone script `ban_peers.py` for banning selected peers
(Thanks to @tboy1337 #888)

# Improvements
- Adds timeout detectiono for stuck runs for web API rqeeusts

# Bug Fixes
- Fix bug in webUI deleting nohardlink section (Fixes #884)


**Full Changelog**:
https://github.com/StuffAnThings/qbit_manage/compare/v4.5.1...v4.5.2

---------

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: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: cat-of-wisdom <217637421+cat-of-wisdom@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Quentin <qking.dev@gmail.com>
Co-authored-by: ineednewpajamas <73252768+ineednewpajamas@users.noreply.github.com>
Co-authored-by: tboy1337 <30571311+tboy1337@users.noreply.github.com>
Co-authored-by: tboy1337 <tboy1337.unchanged733@aleeas.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-03 15:09:08 -04:00

1431 lines
60 KiB
JavaScript
Executable file

/**
* qBit Manage Web UI - Configuration Form Component
* Dynamic form generation for different configuration sections
*/
import { API } from '../api.js';
import { showToast } from '../utils/toast.js';
import { get, query, queryAll } from '../utils/dom.js';
import { CLOSE_ICON_SVG, EYE_ICON_SVG, EYE_SLASH_ICON_SVG } from '../utils/icons.js';
import { generateSectionHTML } from '../utils/form-renderer.js';
import { getNestedValue, setNestedValue, isValidHost } from '../utils/utils.js';
import { getAvailableCategories, populateCategoryDropdowns } from '../utils/categories.js';
// Import all section schemas
import { commandsSchema } from '../config-schemas/commands.js';
import { qbtSchema } from '../config-schemas/qbt.js';
import { settingsSchema } from '../config-schemas/settings.js';
import { directorySchema } from '../config-schemas/directory.js';
import { catSchema } from '../config-schemas/cat.js';
import { catChangeSchema } from '../config-schemas/cat_change.js';
import { trackerSchema } from '../config-schemas/tracker.js';
import { nohardlinksSchema } from '../config-schemas/nohardlinks.js';
import { shareLimitsSchema } from '../config-schemas/share_limits.js';
import { recyclebinSchema } from '../config-schemas/recyclebin.js';
import { orphanedSchema } from '../config-schemas/orphaned.js';
import { notificationsSchema } from '../config-schemas/notifications.js';
import { ShareLimitsComponent } from './share-limits.js';
class ConfigForm {
constructor(options = {}) {
this.container = options.container;
this.onDataChange = options.onDataChange || (() => {});
this.onValidationChange = options.onValidationChange || (() => {});
this.api = new API();
this.currentSection = null;
this.currentData = {};
this.originalData = {}; // Store original data for reset
this.initialSectionData = {}; // Store initial data per section
this.validationState = { valid: true, errors: [], warnings: [] };
this.shareLimitsComponent = null; // Store reference to share limits component
// Store bound function references for proper event listener management
this.boundHandleInputChange = this.handleInputChange.bind(this);
// Map section names to their imported schemas
this.schemas = {
commands: commandsSchema,
qbt: qbtSchema,
settings: settingsSchema,
directory: directorySchema,
cat: catSchema,
cat_change: catChangeSchema,
tracker: trackerSchema,
nohardlinks: nohardlinksSchema,
share_limits: shareLimitsSchema,
recyclebin: recyclebinSchema,
orphaned: orphanedSchema,
notifications: notificationsSchema,
};
this.init();
this.bindEvents(); // Bind events once during initialization
}
init() {
// No schema loading needed, as they are imported directly
}
async loadSection(sectionName, data = {}) {
this.currentSection = sectionName;
// Deep copy and preprocess data to ensure 'tag' is always an array
this.currentData = this._preprocessComplexObjectData(sectionName, data);
// Store initial data only once per section
if (!this.initialSectionData[sectionName]) {
this.initialSectionData[sectionName] = JSON.parse(JSON.stringify(this.currentData));
}
// Always reset to initial data when loading a section
this.originalData = JSON.parse(JSON.stringify(this.initialSectionData[sectionName]));
// Store original format for nohardlinks for bidirectional conversion
if (sectionName === 'nohardlinks' && Array.isArray(data.nohardlinks_categories)) {
this.currentData._originalNohardlinksFormat = 'array';
} else if (sectionName === 'nohardlinks' && typeof data.nohardlinks_categories === 'object' && data.nohardlinks_categories !== null) {
this.currentData._originalNohardlinksFormat = 'object';
}
// Ensure "Uncategorized" category exists for 'cat' section
if (sectionName === 'cat') {
if (!this.currentData.hasOwnProperty('Uncategorized')) {
this.currentData['Uncategorized'] = [];
}
}
await this.renderSection();
// Removed this.validateSection() from here to prevent premature validation display
}
async renderSection() {
if (!this.container || !this.currentSection) return;
const sectionConfig = this.schemas[this.currentSection];
if (!sectionConfig) {
console.error(`No schema found for section: ${this.currentSection}`);
this.container.innerHTML = `<div class="alert alert-error">Error: Configuration schema not found for section "${this.currentSection}".</div>`;
return;
}
const html = generateSectionHTML(sectionConfig, this.currentData);
this.container.innerHTML = html;
// Re-bind input events after rendering new content
this._bindInputEvents();
// Initialize ShareLimitsComponent for share_limits section
if (this.currentSection === 'share_limits') {
const shareLimitsContainer = this.container.querySelector('.share-limits-config');
if (shareLimitsContainer) {
this.shareLimitsComponent = new ShareLimitsComponent(
shareLimitsContainer,
this.currentData,
(newData) => {
this.currentData = newData;
this.onDataChange(this.currentData);
this._dispatchDirtyEvent();
}
);
}
}
// Initialize lazy loading for notifications section
if (this.currentSection === 'notifications') {
this.initializeLazyLoading();
}
// Populate category dropdowns after rendering is complete
await this.populateCategoryDropdowns();
}
initializeLazyLoading() {
const lazyContainers = this.container.querySelectorAll('.function-webhooks-lazy');
lazyContainers.forEach(container => {
const placeholder = container.querySelector('.lazy-load-placeholder');
const content = container.querySelector('.lazy-content');
if (placeholder && content) {
placeholder.addEventListener('click', () => {
placeholder.style.display = 'none';
content.classList.remove('hidden');
});
}
});
}
bindEvents() {
if (!this.container) return;
this._bindInputEvents();
this._bindClickEvents();
}
// This function should only be called once during initialization
_bindClickEvents() {
this.container.addEventListener('click', (e) => {
// Handle clicks on SVG elements inside buttons by finding the closest button
let targetElement = e.target;
if (e.target.tagName === 'svg' || e.target.tagName === 'path') {
const closestButton = e.target.closest('button');
if (closestButton) {
targetElement = closestButton;
}
}
if (targetElement.classList.contains('add-array-item')) {
this.addArrayItem(targetElement.dataset.field);
} else if (targetElement.classList.contains('remove-array-item')) {
this.removeArrayItem(targetElement.closest('.array-item'));
} else if (targetElement.classList.contains('add-category-btn')) {
if (this.schemas[this.currentSection].type === 'dynamic-key-value-list') {
this.addCategory();
}
} else if (targetElement.classList.contains('remove-category-btn')) {
if (this.schemas[this.currentSection].type === 'dynamic-key-value-list') {
this.removeCategory(targetElement.closest('.key-value-item'));
}
} else if (targetElement.classList.contains('password-toggle')) {
this.togglePasswordVisibility(targetElement.dataset.target);
} else if (targetElement.classList.contains('add-complex-object-item-btn')) {
this.addComplexObjectItem();
} else if (targetElement.classList.contains('remove-complex-object-item')) {
this.removeComplexObjectItem(targetElement.dataset.key);
} else if (targetElement.id === 'reset-section-btn') {
this.resetSection();
} else if (targetElement.id === 'validate-section-btn') {
this.validateSection();
} else if (targetElement.classList.contains('apply-to-all-btn')) {
e.preventDefault();
e.stopPropagation();
const action = targetElement.dataset.action;
if (action === 'apply-to-all') {
this.applyToAllWebhooks();
}
}
});
}
_bindInputEvents() {
// Remove existing listeners to prevent duplicates
this.container.removeEventListener('input', this.boundHandleInputChange);
this.container.removeEventListener('change', this.boundHandleInputChange);
// Add new listeners using the stored bound reference
this.container.addEventListener('input', this.boundHandleInputChange);
this.container.addEventListener('change', this.boundHandleInputChange);
}
_bindClickEvents() {
this.container.addEventListener('click', (e) => {
// Handle clicks on SVG elements inside buttons by finding the closest button
let targetElement = e.target;
if (e.target.tagName === 'svg' || e.target.tagName === 'path') {
const closestButton = e.target.closest('button');
if (closestButton) {
targetElement = closestButton;
}
}
if (targetElement.classList.contains('add-array-item')) {
this.addArrayItem(targetElement.dataset.field);
} else if (targetElement.classList.contains('remove-array-item')) {
this.removeArrayItem(targetElement.closest('.array-item'));
} else if (targetElement.classList.contains('add-category-btn')) {
if (this.schemas[this.currentSection].type === 'dynamic-key-value-list') {
this.addCategory();
}
} else if (targetElement.classList.contains('remove-category-btn')) {
if (this.schemas[this.currentSection].type === 'dynamic-key-value-list') {
this.removeCategory(targetElement.closest('.key-value-item'));
}
} else if (targetElement.classList.contains('password-toggle')) {
this.togglePasswordVisibility(targetElement.dataset.target);
} else if (targetElement.classList.contains('add-complex-object-item-btn')) {
this.addComplexObjectItem();
} else if (targetElement.classList.contains('remove-complex-object-item')) {
this.removeComplexObjectItem(targetElement.dataset.key);
} else if (targetElement.id === 'reset-section-btn') {
this.resetSection();
} else if (targetElement.id === 'validate-section-btn') {
this.validateSection();
} else if (targetElement.classList.contains('apply-to-all-btn')) {
e.preventDefault();
e.stopPropagation();
const action = targetElement.dataset.action;
if (action === 'apply-to-all') {
this.applyToAllWebhooks();
}
}
});
}
handleInputChange(e) {
// Ignore events from section headers
if (e.target.classList.contains('section-subheader')) {
return;
}
// Handle category key/value inputs specifically, only if the current section is a dynamic-key-value-list
if ((e.target.classList.contains('category-key') || e.target.classList.contains('category-value')) &&
this.schemas[this.currentSection].type === 'dynamic-key-value-list') {
this.updateCategoryValue(e.target);
return; // Exit after handling category specific logic
}
// Handle dynamic_select_text field changes
if (e.target.classList.contains('dynamic-select')) {
this.handleDynamicSelectChange(e.target);
return;
}
// Handle complex object key input and dropdown specifically
if (e.target.classList.contains('complex-object-key') || e.target.classList.contains('complex-object-key-dropdown')) {
this.updateComplexObjectKey(e.target);
return; // Exit after handling complex object key specific logic
}
const fieldName = e.target.name || e.target.dataset.field;
if (!fieldName) {
return;
}
// Check if this is a complex object field (e.g., "trackerKey::propName")
const isComplexObjectField = fieldName.includes('::');
let entryKey, propName;
if (isComplexObjectField) {
[entryKey, propName] = fieldName.split('::');
}
let value;
if (e.target.type === 'checkbox') {
value = e.target.checked;
} else if (e.target.type === 'number') {
value = e.target.value ? parseFloat(e.target.value) : null;
} else if (e.target.classList.contains('array-item-input')) {
const fullFieldName = e.target.name;
const match = fullFieldName.match(/(.*)\[(\d+)\]$/);
if (match) {
const baseFieldName = match[1];
const index = match[2];
this.updateArrayValue(baseFieldName, index, e.target.value);
} else {
this.updateArrayValue(fieldName, e.target.dataset.index, e.target.value);
}
return;
} else {
value = e.target.value;
}
if (isComplexObjectField) {
// Handle flat string values (like categories)
if (propName === 'value' && this.schemas[this.currentSection].flatStringValues) {
// For flat string values, store the value directly
this.currentData[entryKey] = value;
} else {
// Handle object-based complex fields
// Only create the object if we have a non-empty value to set
if (value !== '' && value !== null && value !== undefined) {
if (!this.currentData[entryKey]) {
this.currentData[entryKey] = {};
}
this.currentData[entryKey][propName] = value;
} else {
// Remove empty string values instead of setting them
if (this.currentData[entryKey]) {
delete this.currentData[entryKey][propName];
// If the entry object is now empty, remove it entirely
if (Object.keys(this.currentData[entryKey]).length === 0) {
delete this.currentData[entryKey];
}
}
}
}
} else {
// For regular fields, remove empty values instead of setting them
if (value === '' || value === null || value === undefined) {
// Use the existing setNestedValue logic to delete the field
setNestedValue(this.currentData, fieldName, null);
} else {
setNestedValue(this.currentData, fieldName, value);
}
}
this.onDataChange(this.currentData);
this.validateField(fieldName, value);
// Dispatch an event to notify that the form section is dirty
this._dispatchDirtyEvent();
}
_dispatchDirtyEvent() {
const dirtyEvent = new CustomEvent('form-dirty', {
detail: { section: this.currentSection },
bubbles: true,
composed: true
});
this.container.dispatchEvent(dirtyEvent);
}
updateArrayValue(fieldName, index, value) {
const isComplexObjectField = fieldName.includes('::');
let currentArray;
if (isComplexObjectField) {
const [entryKey, propName] = fieldName.split('::');
if (!this.currentData[entryKey]) {
this.currentData[entryKey] = {};
}
currentArray = this.currentData[entryKey][propName] || [];
currentArray[parseInt(index)] = value;
this.currentData[entryKey][propName] = currentArray;
} else {
// Handle Uncategorized array
if (fieldName === 'Uncategorized') {
currentArray = this.currentData[fieldName] || [];
if (!Array.isArray(currentArray)) {
currentArray = [currentArray];
}
} else {
currentArray = getNestedValue(this.currentData, fieldName) || [];
}
currentArray[parseInt(index)] = value;
setNestedValue(this.currentData, fieldName, currentArray);
}
this.onDataChange(this.currentData);
this._dispatchDirtyEvent();
}
updateCategoryValue(input) {
const item = input.closest('.key-value-item');
const keyInput = item.querySelector('.category-key');
const valueInput = item.querySelector('.category-value');
const oldKey = item.dataset.key;
const newKey = keyInput.value;
const value = valueInput.value;
if (!this.currentData) {
this.currentData = {};
}
// Remove old key if changed
if (oldKey && oldKey !== newKey) {
delete this.currentData[oldKey];
}
// Set new value
if (newKey) {
this.currentData[newKey] = value;
item.dataset.key = newKey;
}
this.onDataChange(this.currentData);
this._dispatchDirtyEvent();
}
shouldArrayItemUseCategoryDropdown(fieldName) {
// Get the field schema to check if array items should use category dropdown
const fieldSchema = this.getFieldSchema(fieldName);
if (!fieldSchema || fieldSchema.type !== 'array') {
return false;
}
// Check if the array items have useCategoryDropdown flag
return fieldSchema.items && fieldSchema.items.useCategoryDropdown === true;
}
getFieldSchema(fieldName) {
if (!this.schema || !this.schema.fields) {
return null;
}
// Handle complex object fields (containing ::)
if (fieldName.includes('::')) {
const [entryKey, propName] = fieldName.split('::');
// Find the complex object field in schema
// For share limits, we need to find the field that has the matching name
for (const field of this.schema.fields) {
if (field.name === entryKey && field.type === 'object' && field.properties && field.properties[propName]) {
return field.properties[propName];
}
}
return null;
}
// Handle regular fields
for (const field of this.schema.fields) {
if (field.name === fieldName) {
return field;
}
}
return null;
}
addArrayItem(fieldName) {
const arrayField = this.container.querySelector(`[data-field="${fieldName}"] .array-items`);
let currentArray;
if (fieldName.includes('::')) {
const [entryKey, propName] = fieldName.split('::');
if (!this.currentData[entryKey]) {
this.currentData[entryKey] = {};
}
currentArray = this.currentData[entryKey][propName] || [];
currentArray.push('');
this.currentData[entryKey][propName] = currentArray;
} else {
if (fieldName === 'Uncategorized') {
currentArray = this.currentData[fieldName] || [];
if (!Array.isArray(currentArray)) {
currentArray = [currentArray];
}
} else {
currentArray = getNestedValue(this.currentData, fieldName) || [];
}
currentArray.push('');
setNestedValue(this.currentData, fieldName, currentArray);
}
const newIndex = currentArray.length - 1;
// Check if this array field should use category dropdowns
const shouldUseCategoryDropdown = this.shouldArrayItemUseCategoryDropdown(fieldName);
let inputHTML;
if (shouldUseCategoryDropdown) {
inputHTML = `
<select class="form-select category-dropdown array-item-input"
id="${fieldName}-item-${newIndex}"
data-field="${fieldName}" data-index="${newIndex}"
name="${fieldName}[${newIndex}]">
<option value="">Select a category...</option>
</select>
`;
} else {
inputHTML = `
<input type="text" class="form-input array-item-input"
id="${fieldName}-item-${newIndex}"
value="" data-field="${fieldName}" data-index="${newIndex}"
name="${fieldName}[${newIndex}]">
`;
}
const itemHTML = `
<div class="array-item" data-index="${newIndex}">
<label for="${fieldName}-item-${newIndex}" class="form-label sr-only">Item ${newIndex + 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>
`;
arrayField.insertAdjacentHTML('beforeend', itemHTML);
// If we added a category dropdown, populate it
if (shouldUseCategoryDropdown) {
this.populateCategoryDropdowns();
}
this.onDataChange(this.currentData);
this._dispatchDirtyEvent();
}
removeArrayItem(item) {
const fieldName = item.querySelector('.array-item-input').dataset.field;
const index = parseInt(item.dataset.index);
if (fieldName.includes('::')) {
const [entryKey, propName] = fieldName.split('::');
if (this.currentData[entryKey] && this.currentData[entryKey][propName]) {
this.currentData[entryKey][propName].splice(index, 1);
}
} else {
let currentArray;
if (fieldName === 'Uncategorized') {
currentArray = this.currentData[fieldName] || [];
if (!Array.isArray(currentArray)) {
currentArray = [currentArray];
}
} else {
currentArray = getNestedValue(this.currentData, fieldName) || [];
}
currentArray.splice(index, 1);
setNestedValue(this.currentData, fieldName, currentArray);
}
item.remove();
// Update indices for remaining items
const arrayItems = this.container.querySelectorAll(`[data-field="${fieldName}"] .array-item`);
arrayItems.forEach((arrayItem, newIndex) => {
arrayItem.dataset.index = newIndex;
const input = arrayItem.querySelector('.array-item-input');
input.dataset.index = newIndex;
});
this.onDataChange(this.currentData);
this._dispatchDirtyEvent();
}
addCategory() {
// Check if this is a complex-object type (new category schema) or dynamic-key-value-list (old schema)
const sectionConfig = this.schemas[this.currentSection];
if (sectionConfig.type === 'complex-object') {
// Use the same pattern as addComplexObjectItem for consistency
this.addComplexObjectItem();
return;
}
// Legacy code for dynamic-key-value-list type (cat_change schema)
const categoriesContainer = this.container.querySelector('.key-value-items');
const newKey = `category-${Date.now()}`;
// Check if this config should use category dropdowns
const isCatChange = this.currentSection === 'cat_change';
const useDropdownForKey = sectionConfig.useCategoryDropdown && isCatChange;
const useDropdownForValue = isCatChange && sectionConfig.fields && sectionConfig.fields[0] &&
sectionConfig.fields[0].properties && sectionConfig.fields[0].properties.new_category &&
sectionConfig.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-${newKey}">
<option value="">Select Category</option>
</select>
`;
} else {
keyInputHTML = `
<input type="text" class="form-input category-key" value=""
name="category-key-${newKey}">
`;
}
// Generate value input (new category or save path)
if (useDropdownForValue) {
valueInputHTML = `
<select class="form-select category-dropdown category-value" name="category-value-${newKey}">
<option value="">Select Category</option>
</select>
`;
} else {
valueInputHTML = `
<input type="text" class="form-input category-value"
value=""
placeholder="${isCatChange ? 'New Category Name' : '/path/to/category'}"
name="category-value-${newKey}">
`;
}
const itemHTML = `
<div class="key-value-item category-row" data-key="${newKey}">
<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>
`;
categoriesContainer.insertAdjacentHTML('beforeend', itemHTML);
if (!this.currentData) {
this.currentData = {};
}
this.currentData[newKey] = '';
// Populate dropdowns for the newly added item if needed
if (useDropdownForKey || useDropdownForValue) {
this.populateCategoryDropdowns();
}
this.onDataChange(this.currentData);
this._dispatchDirtyEvent();
}
removeCategory(item) {
const key = item.dataset.key;
if (this.currentData && key) {
delete this.currentData[key];
}
item.remove();
this.onDataChange(this.currentData);
this._dispatchDirtyEvent();
}
async applyToAllWebhooks() {
// Get the selected value from the dropdown
const valueField = this.container.querySelector('[name="apply_to_all_value"]');
if (!valueField) {
showToast('Could not find apply to all value field', 'error');
return;
}
let value;
if (valueField.value === 'custom') {
// Only show one prompt for custom URL
const customUrl = prompt('Enter custom webhook URL:');
if (customUrl === null) return;
value = customUrl;
} else {
value = valueField.value;
}
// Combine both regular and function webhooks
const fields = [
'webhooks.error',
'webhooks.run_start',
'webhooks.run_end',
'webhooks.function.recheck',
'webhooks.function.cat_update',
'webhooks.function.tag_update',
'webhooks.function.rem_unregistered',
'webhooks.function.rem_orphaned',
'webhooks.function.tag_nohardlinks',
'webhooks.function.tag_tracker_error',
'webhooks.function.share_limits',
'webhooks.function.cleanup_dirs'
];
fields.forEach(field => {
setNestedValue(this.currentData, field, value);
});
// Notify of data change and mark as dirty
this.onDataChange(this.currentData);
this._dispatchDirtyEvent();
// Re-render the form to reflect changes
await this.renderSection();
showToast('Applied to all webhooks!', 'success');
}
async addComplexObjectItem() {
const sectionConfig = this.schemas[this.currentSection];
if (sectionConfig.type !== 'object' && sectionConfig.type !== 'complex-object') {
console.error('addComplexObjectItem called for a non-object or non-complex-object schema type.');
return;
}
let newKey;
// Check if this schema should use category dropdown
if (sectionConfig.useCategoryDropdown) {
newKey = await this.showCategoryDropdownModal();
if (!newKey) {
return; // User cancelled or no selection made
}
} else {
// Use schema's keyLabel and keyDescription for dynamic prompt text
const keyLabel = sectionConfig.keyLabel || 'Tracker URL';
const keyDescription = sectionConfig.keyDescription || keyLabel;
const promptText = `Enter ${keyDescription}:`;
newKey = prompt(promptText);
if (!newKey) {
return;
}
}
// Check if key already exists
if (this.currentData[newKey]) {
const keyLabel = sectionConfig.keyLabel || 'Entry';
showToast(`A ${keyLabel} with this name already exists.`, 'error');
return;
}
// Handle flat string values (like categories)
if (sectionConfig.flatStringValues) {
if (newKey === 'Uncategorized') {
this.currentData[newKey] = [];
} else {
const defaultSchema = sectionConfig.additionalProperties || sectionConfig.patternProperties[".*"];
const defaultValue = defaultSchema?.default || '';
this.currentData[newKey] = defaultValue;
}
} else {
// Initialize with default values based on schema for object entries
const newEntry = {};
if (this.currentSection === 'nohardlinks') {
newEntry.exclude_tags = [];
newEntry.ignore_root_dir = true;
} else {
const defaultSchema = sectionConfig.additionalProperties || sectionConfig.patternProperties["^(?!other$).*$"];
if (defaultSchema && defaultSchema.properties) {
Object.entries(defaultSchema.properties).forEach(([propName, propSchema]) => {
if (propSchema.default !== undefined) {
newEntry[propName] = propSchema.default;
} else if (propSchema.type === 'array') {
newEntry[propName] = [];
} else if (propSchema.oneOf) { // For 'tag' field
const stringSchema = propSchema.oneOf.find(s => s.type === 'string');
if (stringSchema && stringSchema.default !== undefined) {
newEntry[propName] = stringSchema.default;
} else {
newEntry[propName] = ''; // Default to empty string for 'tag'
}
} else if (defaultSchema.required && defaultSchema.required.includes(propName)) {
// Only set required fields to empty string, leave optional fields undefined
newEntry[propName] = '';
}
// Optional fields are left undefined and will not be included in the object
});
}
}
this.currentData[newKey] = newEntry;
}
await this.renderSection(); // Re-render to show the new item
this.onDataChange(this.currentData);
this._dispatchDirtyEvent();
}
async removeComplexObjectItem(keyToRemove) {
if (confirm(`Are you sure you want to remove the entry "${keyToRemove}"?`)) {
delete this.currentData[keyToRemove];
await this.renderSection(); // Re-render to remove the item
this.onDataChange(this.currentData);
this._dispatchDirtyEvent();
}
}
async showCategoryDropdownModal() {
return new Promise((resolve) => {
// Get available categories from the API or stored data
this.getAvailableCategories().then(categories => {
if (!categories || categories.length === 0) {
showToast('No categories available. Please configure categories first.', 'warning');
resolve(null);
return;
}
// Create modal HTML
const modalHTML = `
<div class="modal-overlay" id="category-dropdown-modal">
<div class="modal">
<div class="modal-header">
<h3>Select Category</h3>
<button type="button" class="btn btn-icon btn-close-icon modal-close">
${CLOSE_ICON_SVG}
</button>
</div>
<div class="modal-content">
<div class="form-group">
<label for="category-select" class="form-label">Choose a category:</label>
<select id="category-select" class="form-select">
<option value="">Select a category...</option>
${categories.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-cancel">Cancel</button>
<button type="button" class="btn btn-primary modal-confirm" disabled>Add Category</button>
</div>
</div>
</div>
`;
// Add modal to DOM
document.body.insertAdjacentHTML('beforeend', modalHTML);
const modal = document.getElementById('category-dropdown-modal');
const select = document.getElementById('category-select');
const confirmBtn = modal.querySelector('.modal-confirm');
const cancelBtn = modal.querySelector('.modal-cancel');
const closeBtn = modal.querySelector('.modal-close');
// Enable/disable confirm button based on selection
select.addEventListener('change', () => {
confirmBtn.disabled = !select.value;
});
// Handle confirm
confirmBtn.addEventListener('click', () => {
const selectedCategory = select.value;
modal.remove();
resolve(selectedCategory);
});
// Handle cancel/close
const closeModal = () => {
modal.remove();
resolve(null);
};
cancelBtn.addEventListener('click', closeModal);
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
});
// Handle escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
});
});
}
async getAvailableCategories() {
return getAvailableCategories();
}
async populateCategoryDropdowns() {
// Find all category dropdowns in the current form
const complexObjectDropdowns = this.container.querySelectorAll('.complex-object-key-dropdown');
// Handle complex object key dropdowns (these have special logic)
if (complexObjectDropdowns.length > 0) {
try {
const categories = await this.getAvailableCategories();
complexObjectDropdowns.forEach(dropdown => {
const currentValue = dropdown.dataset.originalKey;
// Clear existing options except the current one
dropdown.innerHTML = '';
// Add current value as selected option
const currentOption = document.createElement('option');
currentOption.value = currentValue;
currentOption.textContent = currentValue;
currentOption.selected = true;
dropdown.appendChild(currentOption);
// Add other available categories
categories.forEach(category => {
if (category !== currentValue) {
const option = document.createElement('option');
option.value = category;
option.textContent = category;
dropdown.appendChild(option);
}
});
});
} catch (error) {
console.error('Error populating complex object category dropdowns:', error);
}
}
// Handle regular field category dropdowns using the utility function
await populateCategoryDropdowns(this.container);
}
updateComplexObjectKey(input) {
const item = input.closest('.complex-object-item');
const originalKey = input.dataset.originalKey;
const newKey = input.value;
if (originalKey === newKey) return;
if (this.currentData[newKey]) {
showToast('An entry with this key already exists. Please choose a different key.', 'error');
input.value = originalKey; // Revert input
return;
}
// Create a new object with the updated key
const updatedData = {};
Object.entries(this.currentData).forEach(([key, value]) => {
if (key === originalKey) {
updatedData[newKey] = value;
} else {
updatedData[key] = value;
}
});
this.currentData = updatedData;
item.dataset.key = newKey; // Update data-key attribute
input.dataset.originalKey = newKey; // Update original-key dataset
this.onDataChange(this.currentData);
this._dispatchDirtyEvent();
}
togglePasswordVisibility(targetId) {
const input = get(targetId);
const button = query(`[data-target="${targetId}"]`);
if (!input) {
console.error('Password input not found:', targetId);
return;
}
if (!button) {
console.error('Password toggle button not found for input:', targetId);
return;
}
if (input.type === 'password') {
input.type = 'text';
button.innerHTML = EYE_SLASH_ICON_SVG;
} else {
input.type = 'password';
button.innerHTML = EYE_ICON_SVG;
}
}
handleDynamicSelectChange(selectElement) {
const container = selectElement.closest('.dynamic-select-text-group');
if (!container) {
return;
}
const textInput = container.querySelector('.dynamic-text-input');
const hiddenInput = container.querySelector('.dynamic-hidden-input');
if (!textInput || !hiddenInput) {
return;
}
// Performance optimization: use requestAnimationFrame for DOM updates
requestAnimationFrame(() => {
const updateValue = () => {
if (selectElement.value === 'webhook') {
textInput.style.display = 'block';
textInput.required = true;
hiddenInput.value = textInput.value;
} else {
textInput.style.display = 'none';
textInput.required = false;
textInput.value = '';
hiddenInput.value = selectElement.value;
}
};
// Update the display and values
updateValue();
// Add input listener to text field if not already added
if (!textInput.hasAttribute('data-listener-added')) {
// Debounce text input to reduce excessive updates
let debounceTimer;
textInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (selectElement.value === 'webhook') {
hiddenInput.value = textInput.value;
}
}, 150); // 150ms debounce
});
textInput.setAttribute('data-listener-added', 'true');
}
// Trigger form change event for the hidden input
const changeEvent = new Event('input', { bubbles: true });
hiddenInput.dispatchEvent(changeEvent);
});
}
resetSection() {
if (confirm('Are you sure you want to reset this section? All changes will be lost.')) {
this.currentData = JSON.parse(JSON.stringify(this.originalData)); // Revert to original data
this.loadSection(this.currentSection, this.originalData); // Load section with original data
this.onDataChange(this.currentData);
// Dispatch an event to notify that the form section has been reset
const resetEvent = new CustomEvent('form-reset', {
detail: { section: this.currentSection },
bubbles: true,
composed: true
});
this.container.dispatchEvent(resetEvent);
}
}
/**
* Preprocesses complex object data to ensure array fields (like 'tag') are always arrays.
* @param {string} sectionName - The name of the current section.
* @param {object} data - The raw data loaded for the section.
* @returns {object} The preprocessed data.
*/
_preprocessComplexObjectData(sectionName, data) {
const processedData = JSON.parse(JSON.stringify(data)); // Deep copy to avoid modifying original data
// Skip preprocessing for multi-root-object sections
const sectionConfig = this.schemas[sectionName];
if (sectionConfig && sectionConfig.type === 'multi-root-object') {
return processedData;
}
if (sectionConfig && sectionConfig.type === 'fixed-object-config') {
const mainFieldName = sectionConfig.fields[0]?.name;
const mainFieldProperties = sectionConfig.fields[0]?.properties || {};
const sectionData = data[mainFieldName] || {};
if (sectionName === 'nohardlinks') {
if (Array.isArray(sectionData)) {
// Handle array format: ["RadarrComplete", "RadarrComplete4k", ...]
const newNohardlinksCategories = {};
sectionData.forEach(categoryItem => {
if (typeof categoryItem === 'string') {
// Simple string category name
newNohardlinksCategories[categoryItem] = {
exclude_tags: [],
ignore_root_dir: true
};
} else if (typeof categoryItem === 'object') {
// Object with category name as key and properties as value
// Format: [{ "RadarrComplete": { exclude_tags: [...], ignore_root_dir: true } }]
for (const [categoryName, categoryProps] of Object.entries(categoryItem)) {
newNohardlinksCategories[categoryName] = {
exclude_tags: categoryProps?.exclude_tags || [],
ignore_root_dir: categoryProps?.ignore_root_dir !== undefined ? categoryProps.ignore_root_dir : true
};
}
}
});
processedData[mainFieldName] = newNohardlinksCategories;
} else if (typeof sectionData === 'object' && sectionData !== null) {
// Handle object format: { "RadarrComplete": { ... }, ... }
const newNohardlinksCategories = {};
Object.entries(sectionData).forEach(([categoryName, categoryProps]) => {
newNohardlinksCategories[categoryName] = {
exclude_tags: categoryProps?.exclude_tags || [],
ignore_root_dir: categoryProps?.ignore_root_dir !== undefined ? categoryProps.ignore_root_dir : true
};
});
processedData[mainFieldName] = newNohardlinksCategories;
}
} else if (mainFieldName) {
// For other fixed-object-config sections, iterate through properties
Object.entries(sectionData).forEach(([entryKey, entryValue]) => {
const entryProperties = mainFieldProperties[entryKey]?.properties;
if (entryProperties) {
Object.entries(entryValue).forEach(([propName, propValue]) => {
if (entryProperties[propName]?.type === 'array' && !Array.isArray(propValue)) {
processedData[mainFieldName][entryKey][propName] = propValue ? [propValue] : [];
}
});
}
});
}
} else if (sectionConfig && sectionConfig.type === 'complex-object' && sectionConfig.patternProperties) {
// Handle special case for nohardlinks which can have both array and object formats
if (sectionName === 'nohardlinks') {
// Check if the data is in array format and convert it to object format
if (Array.isArray(processedData)) {
const newNohardlinksData = {};
processedData.forEach(categoryItem => {
if (typeof categoryItem === 'string') {
// Simple string category name
newNohardlinksData[categoryItem] = {
exclude_tags: [],
ignore_root_dir: true
};
} else if (typeof categoryItem === 'object') {
// Object with category name as key and properties as value
for (const [categoryName, categoryProps] of Object.entries(categoryItem)) {
newNohardlinksData[categoryName] = {
exclude_tags: categoryProps?.exclude_tags || [],
ignore_root_dir: categoryProps?.ignore_root_dir !== undefined ? categoryProps.ignore_root_dir : true
};
}
}
});
return newNohardlinksData;
} else if (typeof processedData === 'object' && processedData !== null) {
// Handle object format: ensure all entries have proper structure
Object.entries(processedData).forEach(([categoryName, categoryProps]) => {
// Handle cases where category props are null, undefined, or an empty object
if (categoryProps === null || categoryProps === undefined || (typeof categoryProps === 'object' && Object.keys(categoryProps).length === 0)) {
processedData[categoryName] = {
exclude_tags: [],
ignore_root_dir: true
};
} else if (typeof categoryProps === 'object') {
processedData[categoryName] = {
exclude_tags: categoryProps.exclude_tags || [],
ignore_root_dir: categoryProps.ignore_root_dir !== undefined ? categoryProps.ignore_root_dir : true
};
}
});
}
} else {
// This is for sections like 'tracker'
Object.entries(processedData).forEach(([entryKey, entryValue]) => {
if (entryValue && typeof entryValue === 'object') {
// Find the matching schema for the current entry
let schemaProperties;
if (entryKey === 'other' && sectionConfig.patternProperties.other) {
schemaProperties = sectionConfig.patternProperties.other.properties;
} else if (sectionConfig.patternProperties["^(?!other$).*$"]) {
schemaProperties = sectionConfig.patternProperties["^(?!other$).*$"].properties;
} else if (sectionConfig.patternProperties[".*"]) {
schemaProperties = sectionConfig.patternProperties[".*"].properties;
}
if (schemaProperties) {
Object.entries(schemaProperties).forEach(([propName, propSchema]) => {
if (propSchema.type === 'array' && !Array.isArray(entryValue[propName])) {
entryValue[propName] = entryValue[propName] ? [entryValue[propName]] : [];
} else if (propSchema.oneOf) { // Handle fields like 'tag'
const isArray = propSchema.oneOf.some(s => s.type === 'array');
if (isArray && !Array.isArray(entryValue[propName])) {
entryValue[propName] = entryValue[propName] ? [entryValue[propName]] : [];
}
} else if (propSchema.type === 'string' && (entryValue[propName] === null || entryValue[propName] === undefined || (typeof entryValue[propName] === 'object' && Object.keys(entryValue[propName]).length === 0))) {
// Handle optional string fields that might be empty objects - convert to empty string
entryValue[propName] = '';
}
});
}
}
});
}
}
return processedData;
}
_postprocessDataForSave(sectionName, data) {
const sectionConfig = this.schemas[sectionName];
if (sectionConfig && sectionConfig.type === 'multi-root-object') {
const finalData = {};
Object.keys(data).forEach(key => {
// Filter out UI-only fields for notifications section
if (sectionName === 'notifications' && key === 'apply_to_all_value') {
return; // Skip this field
}
this._setNestedValue(finalData, key, data[key]);
});
return finalData;
}
const processedData = { ...data };
// Filter out UI-only fields for notifications section
if (sectionName === 'notifications') {
delete processedData.apply_to_all_value;
}
if (sectionName === 'nohardlinks' && processedData._originalNohardlinksFormat === 'array' && typeof processedData.nohardlinks_categories === 'object') {
processedData.nohardlinks_categories = Object.keys(processedData.nohardlinks_categories);
}
delete processedData._originalNohardlinksFormat;
return processedData;
}
/**
* Helper method to flatten nested objects into dot notation
* @param {object} obj - The object to flatten
* @param {string} prefix - The prefix for the keys
* @param {object} result - The result object to populate
*/
_flattenObject(obj, prefix, result) {
Object.keys(obj).forEach(key => {
const value = obj[key];
const newKey = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
// Recursively flatten nested objects
this._flattenObject(value, newKey, result);
} else {
// Set the flattened key-value pair
result[newKey] = value;
}
});
}
/**
* Helper method to set nested values in an object using dot notation
* @param {object} obj - The target object
* @param {string} path - The dot notation path (e.g., 'function.cat_update')
* @param {*} value - The value to set
*/
_setNestedValue(obj, path, value) {
const parts = path.split('.');
let current = obj;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
// If the current part doesn't exist, create a new object
if (!current[part]) {
current[part] = {};
}
// If the current part exists but is not an object, preserve the existing value
else if (typeof current[part] !== 'object' || current[part] === null) {
// Save the existing value under a special key
current[part] = {
_value: current[part]
};
}
current = current[part];
}
current[parts[parts.length - 1]] = value;
}
/**
* Recursively removes empty strings, empty objects, and empty arrays from a configuration object.
* @param {object} obj - The object to clean up.
* @returns {object} The cleaned up object.
*/
cleanupEmptyValues(obj) {
if (obj === null || obj === undefined) {
return null;
}
if (typeof obj !== 'object') {
return obj;
}
if (Array.isArray(obj)) {
const newArr = obj
.map(v => this.cleanupEmptyValues(v))
.filter(v => v !== null && v !== '' && (!Array.isArray(v) || v.length > 0));
return newArr.length > 0 ? newArr : null;
}
const newObj = {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
// Skip internal properties and UI-only fields
if (key === '_originalNohardlinksFormat' || key === 'apply_to_all_value') continue;
const value = this.cleanupEmptyValues(obj[key]);
// Special handling for notification sections - preserve them even if they appear empty
// This ensures that sections like 'notifiarr', 'apprise', 'webhooks' are not removed
if (['notifiarr', 'apprise', 'webhooks'].includes(key)) {
// Always preserve these sections, even if they appear empty
newObj[key] = value || {};
} else if (value !== null && value !== '' && (!Array.isArray(value) || value.length > 0)) {
newObj[key] = value;
}
}
}
// Don't return null for empty objects if they contain notification sections
// or if this might be a root-level config object
const hasNotificationSections = ['notifiarr', 'apprise', 'webhooks'].some(section => section in newObj);
if (hasNotificationSections || Object.keys(newObj).length > 0) {
return newObj;
}
return null;
}
validateSection() {
const sectionConfig = this.schemas[this.currentSection];
this.validationState = { valid: true, errors: [], warnings: [] };
if (!sectionConfig) {
this.updateValidationDisplay();
return;
}
// We will validate the entire currentData object, as some sections
// are nested (e.g., nohardlinks.nohardlinks_categories)
const dataToValidate = this.currentData;
(sectionConfig.fields || []).forEach(field => {
this.validateField(field.name, getNestedValue(dataToValidate, field.name));
});
// Specific validation for complex object types
if (sectionConfig.type === "complex-object") {
// Find properties to validate
}
this.onValidationChange(this.validationState);
this.updateValidationDisplay();
if (this.validationState.errors.length === 0) {
showToast('Section is valid!', 'success');
}
}
validateField(fieldName, value) {
// This is a simplified validation logic.
// A more robust implementation would deeply check the schema.
const sectionConfig = this.schemas[this.currentSection];
if (!sectionConfig) return;
let fieldSchema;
if (sectionConfig.type === 'fixed-object-config') {
// For schemas like nohardlinks, the fields are under a nested property
const mainFieldName = sectionConfig.fields[0]?.name;
const mainFieldProperties = sectionConfig.fields[0]?.properties || {};
// This is still not quite right for deeply nested fields.
// For now, let's assume simple structure within fixed-object-config
fieldSchema = mainFieldProperties[fieldName];
} else {
fieldSchema = sectionConfig.fields?.find(f => f.name === fieldName);
}
if (fieldSchema && fieldSchema.required && (value === null || value === undefined || value === '')) {
const error = `Field "${fieldSchema.label}" is required.`;
if (!this.validationState.errors.includes(error)) {
this.validationState.errors.push(error);
}
this.validationState.valid = false;
}
// Example custom validation for qbt host
if (this.currentSection === 'qbt' && fieldName === 'host' && value && !isValidHost(value)) {
const error = `Invalid host format for "${fieldName}". Should be http(s)://hostname:port.`;
if (!this.validationState.errors.includes(error)) {
this.validationState.errors.push(error);
}
this.validationState.valid = false;
}
}
updateValidationDisplay() {
// Clear previous messages
queryAll('.field-validation').forEach(el => el.textContent = '');
const sectionValidationEl = this.container.querySelector('.section-validation');
if (sectionValidationEl) {
sectionValidationEl.innerHTML = '';
}
if (this.validationState.errors.length > 0) {
const errorList = this.validationState.errors.map(err => `<li>${err}</li>`).join('');
if (sectionValidationEl) {
sectionValidationEl.innerHTML = `<div class="alert alert-error"><ul>${errorList}</ul></div>`;
}
}
if (this.validationState.warnings.length > 0) {
const warningList = this.validationState.warnings.map(warn => `<li>${warn}</li>`).join('');
if (sectionValidationEl) {
sectionValidationEl.innerHTML += `<div class="alert alert-warning"><ul>${warningList}</ul></div>`;
}
}
}
}
export { ConfigForm };