/** * 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 = `

${config.title}

${config.description || ''}

`; 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 += `
`; return html; } /** * Generates HTML for a list of fields. * @param {Array} 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} 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 += `
`; html += `
`; html += `
Click to load Function Specific Webhooks...
`; html += `
`; } return html; } /** * Renders a section of fields with performance optimizations */ function renderFieldSection(fields, data, sectionId) { if (!fields.length) return ''; return `
` + 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('') + `
`; } /** * 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 ? '*' : ''; let inputHTML = ''; switch (field.type) { case 'boolean': inputHTML = ` `; break; case 'section_header': inputHTML = `

${field.label}

`; 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 = `
`; // 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 = '
Failed to load documentation
'; }); } }, 100); break; case 'button': inputHTML = ` `; break; case 'number': inputHTML = ` `; break; case 'password': // Import the eye icons at the top of the file const EYE_ICON_SVG = ``; inputHTML = `
`; break; case 'select': const options = field.options || []; inputHTML = ` `; 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 = `
`; break; case 'textarea': inputHTML = ` `; break; case 'array': inputHTML = generateArrayFieldHTML(field, value, fieldName); break; default: // text // Check if this field should use category dropdown if (field.useCategoryDropdown) { inputHTML = ` `; } else { inputHTML = ` `; } break; } return `
${inputHTML} ${field.description ? `
${field.description}
` : ''}
`; } /** * Generates HTML for an array field. * @param {object} field - The field definition for the array. * @param {Array} 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 ? '*' : ''; let html = `
`; arrayValue.forEach((item, index) => { // Check if array items should use category dropdown const useDropdown = field.items && field.items.useCategoryDropdown; let inputHTML; if (useDropdown) { inputHTML = ` `; } else { inputHTML = ` `; } html += `
${inputHTML}
`; }); html += `
`; 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 = `

${isCatChange ? 'Category Changes' : 'Categories'}

`; 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 = ` `; } else { keyInputHTML = ` `; } // Generate value input (new category or save path) if (useDropdownForValue) { valueInputHTML = ` `; } else { valueInputHTML = ` `; } html += `
${keyInputHTML}
${valueInputHTML}
`; }); html += `
`; 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 = `
`; // Documentation is now handled in generateSectionHTML, so we don't need to duplicate it here html += `

${config.title}

`; Object.entries(complexObjectData).forEach(([key, value]) => { html += generateComplexObjectEntryHTML(key, value, config); }); html += `
`; 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 += `
`; }); valueInputHTML = `
${itemsHTML}
${valueDescription ? `
${valueDescription}
` : ''} `; } else if (typeof entryValue === 'string') { valueInputHTML = ` ${valueDescription ? `
${valueDescription}
` : ''}
`; } else { valueInputHTML = `
Invalid value for ${entryKey}. Expected a string.
`; } let html = `
${valueInputHTML}
`; 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 = ` `; } else { // Use regular text input keyInputHTML = ` `; } let html = `
${keyInputHTML}
`; // 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 += `
`; 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 = ` `; 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; }