您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Auto-fill upload forms using torrent file data with configurable templates
当前为
// ==UserScript== // @name GGn Upload Templator // @namespace https://greatest.deepsurf.us/ // @version 0.9 // @description Auto-fill upload forms using torrent file data with configurable templates // @author leveldesigner // @license Unlicense // @source https://github.com/lvldesigner/userscripts/tree/main/ggn-upload-templator // @supportURL https://github.com/lvldesigner/userscripts/tree/main/ggn-upload-templator // @icon https://gazellegames.net/favicon.ico // @match https://*.gazellegames.net/upload.php* // @grant GM_addStyle // ==/UserScript== (function() { "use strict"; const DEFAULT_CONFIG = { TARGET_FORM_SELECTOR: "#upload_table", SUBMIT_KEYBINDING: true, CUSTOM_SUBMIT_KEYBINDING: "Ctrl+Enter", APPLY_KEYBINDING: true, CUSTOM_APPLY_KEYBINDING: "Ctrl+Shift+A", CUSTOM_FIELD_SELECTORS: [], IGNORED_FIELDS_BY_DEFAULT: [ "linkgroup", "groupid", "apikey", "type", "amazonuri", "googleplaybooksuri", "goodreadsuri", "isbn", "scan_dpi", "other_dpi", "release_desc", "anonymous", "dont_check_rules", "title", "tags", "image", "gameswebsiteuri", "wikipediauri", "album_desc", "submit_upload" ] }; const logDebug = (...messages) => { const css = "color: #4dd0e1; font-weight: 900;"; console.debug("%c[GGn Upload Templator]", css, ...messages); }; function getCurrentFormData(config) { const formData = {}; const formSelector = config.TARGET_FORM_SELECTOR || "form"; const targetForm = document.querySelector(formSelector); const defaultSelector = "input[name], select[name], textarea[name]"; const customSelectors = config.CUSTOM_FIELD_SELECTORS || []; const fieldSelector = customSelectors.length > 0 ? `${defaultSelector}, ${customSelectors.join(", ")}` : defaultSelector; const inputs = targetForm ? targetForm.querySelectorAll(fieldSelector) : document.querySelectorAll(fieldSelector); inputs.forEach((input) => { const isCustomField = isElementMatchedByCustomSelector(input, config); const hasValidIdentifier = isCustomField ? input.name || input.id || input.getAttribute("data-field") || input.getAttribute("data-name") : input.name; if (!hasValidIdentifier) return; if (!isCustomField && (input.type === "file" || input.type === "button" || input.type === "submit")) { return; } const fieldName = input.name || input.id || input.getAttribute("data-field") || input.getAttribute("data-name"); if (fieldName) { if (input.type === "radio" && formData[fieldName]) { return; } const fieldInfo = { value: isCustomField ? input.value || input.textContent || input.getAttribute("data-value") || "" : input.type === "checkbox" || input.type === "radio" ? input.checked : input.value || "", label: getFieldLabel(input, config), type: input.tagName.toLowerCase(), inputType: input.type || "custom" }; if (input.type === "radio") { const radioGroup = document.querySelectorAll( `input[name="${fieldName}"][type="radio"]` ); fieldInfo.radioOptions = Array.from(radioGroup).map((radio) => ({ value: radio.value, checked: radio.checked, label: getFieldLabel(radio, config) || radio.value })); const selectedRadio = Array.from(radioGroup).find( (radio) => radio.checked ); fieldInfo.selectedValue = selectedRadio ? selectedRadio.value : ""; fieldInfo.value = fieldInfo.selectedValue; } if (input.tagName.toLowerCase() === "select") { fieldInfo.options = Array.from(input.options).map((option) => ({ value: option.value, text: option.textContent.trim(), selected: option.selected })); fieldInfo.selectedValue = input.value; } formData[fieldName] = fieldInfo; } }); return formData; } function isElementMatchedByCustomSelector(element, config) { const customSelectors = config.CUSTOM_FIELD_SELECTORS || []; if (customSelectors.length === 0) return false; return customSelectors.some((selector) => { try { return element.matches(selector); } catch (e) { console.warn(`Invalid custom selector: ${selector}`, e); return false; } }); } function cleanLabelText(text) { if (!text) return text; const tempElement = document.createElement("div"); tempElement.innerHTML = text; const linkElements = tempElement.querySelectorAll("a"); linkElements.forEach((link) => { link.remove(); }); let cleanedText = tempElement.textContent || tempElement.innerText || ""; cleanedText = cleanedText.trim(); if (cleanedText.endsWith(":")) { cleanedText = cleanedText.slice(0, -1).trim(); } return cleanedText; } function getFieldLabel(input, config) { const isCustomField = isElementMatchedByCustomSelector(input, config); if (isCustomField) { const parent = input.parentElement; if (parent) { const labelElement = parent.querySelector("label"); if (labelElement) { const rawText = labelElement.innerHTML || labelElement.textContent || ""; const cleanedText = cleanLabelText(rawText); return cleanedText || input.id || input.name || "Custom Field"; } const labelClassElement = parent.querySelector('*[class*="label"]'); if (labelClassElement) { const rawText = labelClassElement.innerHTML || labelClassElement.textContent || ""; const cleanedText = cleanLabelText(rawText); return cleanedText || input.id || input.name || "Custom Field"; } } return input.id || input.name || "Custom Field"; } if (input.type === "radio" && input.id) { const parentTd = input.closest("td"); if (parentTd) { const associatedLabel = parentTd.querySelector( `label[for="${input.id}"]` ); if (associatedLabel) { const rawText = associatedLabel.innerHTML || associatedLabel.textContent || ""; const cleanedText = cleanLabelText(rawText); return cleanedText || input.value; } } } const parentRow = input.closest("tr"); if (parentRow) { const labelCell = parentRow.querySelector("td.label"); if (labelCell) { const rawText = labelCell.innerHTML || labelCell.textContent || ""; const cleanedText = cleanLabelText(rawText); return cleanedText ? `${cleanedText} (${input.name})` : input.name; } } return input.name; } function findElementByFieldName(fieldName, config) { config.TARGET_FORM_SELECTOR ? `${config.TARGET_FORM_SELECTOR} ` : ""; const defaultSelector = "input[name], select[name], textarea[name]"; const customSelectors = config.CUSTOM_FIELD_SELECTORS || []; const fieldSelector = customSelectors.length > 0 ? `${defaultSelector}, ${customSelectors.join(", ")}` : defaultSelector; const targetForm = config.TARGET_FORM_SELECTOR ? document.querySelector(config.TARGET_FORM_SELECTOR) : null; const inputs = targetForm ? targetForm.querySelectorAll(fieldSelector) : document.querySelectorAll(fieldSelector); for (const input of inputs) { const isCustomField = isElementMatchedByCustomSelector(input, config); const hasValidIdentifier = isCustomField ? input.name || input.id || input.getAttribute("data-field") || input.getAttribute("data-name") : input.name; if (!hasValidIdentifier) continue; if (!isCustomField && (input.type === "file" || input.type === "button" || input.type === "submit")) { continue; } const elementFieldName = input.name || input.id || input.getAttribute("data-field") || input.getAttribute("data-name"); if (elementFieldName === fieldName) { return input; } } return null; } class TorrentUtils { // Parse torrent file for metadata static async parseTorrentFile(file) { const arrayBuffer = await file.arrayBuffer(); const data = new Uint8Array(arrayBuffer); try { const [torrent] = TorrentUtils.decodeBencode(data); return { name: torrent.info?.name || file.name, comment: torrent.comment || "", files: torrent.info?.files?.map((f) => ({ path: f.path.join("/"), length: f.length })) || [ { path: torrent.info?.name || file.name, length: torrent.info?.length } ] }; } catch (e) { console.warn("Could not parse torrent file:", e); return { name: file.name, comment: "", files: [] }; } } static parseCommentVariables(comment) { if (!comment || typeof comment !== "string") return {}; const variables = {}; const pairs = comment.split(";"); for (const pair of pairs) { const trimmedPair = pair.trim(); if (!trimmedPair) continue; const eqIndex = trimmedPair.indexOf("="); if (eqIndex === -1) continue; const key = trimmedPair.substring(0, eqIndex).trim(); const value = trimmedPair.substring(eqIndex + 1).trim(); if (key) { variables[`_${key}`] = value; } } return variables; } // Simple bencode decoder static decodeBencode(data, offset = 0) { const char = String.fromCharCode(data[offset]); if (char === "d") { const dict = {}; offset++; while (data[offset] !== 101) { const [key, newOffset1] = TorrentUtils.decodeBencode(data, offset); const [value, newOffset2] = TorrentUtils.decodeBencode( data, newOffset1 ); dict[key] = value; offset = newOffset2; } return [dict, offset + 1]; } if (char === "l") { const list = []; offset++; while (data[offset] !== 101) { const [value, newOffset] = TorrentUtils.decodeBencode(data, offset); list.push(value); offset = newOffset; } return [list, offset + 1]; } if (char === "i") { offset++; let num = ""; while (data[offset] !== 101) { num += String.fromCharCode(data[offset]); offset++; } return [parseInt(num), offset + 1]; } if (char >= "0" && char <= "9") { let lengthStr = ""; while (data[offset] !== 58) { lengthStr += String.fromCharCode(data[offset]); offset++; } const length = parseInt(lengthStr); offset++; const str = new TextDecoder("utf-8", { fatal: false }).decode( data.slice(offset, offset + length) ); return [str, offset + length]; } throw new Error("Invalid bencode data"); } } function parseTemplate(mask, torrentName, greedyMatching = true) { if (!mask || !torrentName) return {}; let regexPattern = mask.replace(/\\\$/g, "___ESCAPED_DOLLAR___").replace(/\\\{/g, "___ESCAPED_LBRACE___").replace(/\\\}/g, "___ESCAPED_RBRACE___").replace(/\\\\/g, "___ESCAPED_BACKSLASH___").replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\$\\\{([^}]+)\\\}/g, (match, varName, offset, string) => { if (greedyMatching) { return `(?<${varName}>.+)`; } else { const remainingString = string.slice(offset + match.length); const hasMoreVariables = /\\\$\\\{[^}]+\\\}/.test(remainingString); if (hasMoreVariables) { return `(?<${varName}>.*?)`; } else { return `(?<${varName}>.+)`; } } }).replace(/___ESCAPED_DOLLAR___/g, "\\$").replace(/___ESCAPED_LBRACE___/g, "\\{").replace(/___ESCAPED_RBRACE___/g, "\\}").replace(/___ESCAPED_BACKSLASH___/g, "\\\\"); try { const regex = new RegExp(regexPattern, "i"); const match = torrentName.match(regex); return match?.groups || {}; } catch (e) { console.warn("Invalid template regex:", e); return {}; } } function validateMaskWithDetails(mask) { if (!mask) { return { valid: true, errors: [], warnings: [], info: [], variables: { valid: [], invalid: [], reserved: [] } }; } const errors = []; const warnings = []; const info = []; const validVars = []; const invalidVars = []; const reservedVars = []; const seenVars = /* @__PURE__ */ new Set(); const duplicates = /* @__PURE__ */ new Set(); try { const parsed = parseMaskStructure(mask); if (parsed.optionalCount > 0) { info.push({ type: "info", message: `${parsed.optionalCount} optional block${parsed.optionalCount === 1 ? "" : "s"} defined` }); } } catch (e) { const posMatch = e.message.match(/position (\d+)/); const position = posMatch ? parseInt(posMatch[1], 10) : 0; const rangeEnd = e.rangeEnd !== void 0 ? e.rangeEnd : position + 2; errors.push({ type: "error", message: e.message, position, rangeEnd }); } const unclosedPattern = /\$\{[^}]*$/; if (unclosedPattern.test(mask)) { const position = mask.lastIndexOf("${"); const rangeEnd = mask.length; errors.push({ type: "error", message: 'Unclosed variable: missing closing brace "}"', position, rangeEnd }); } const emptyVarPattern = /\$\{\s*\}/g; let emptyMatch; while ((emptyMatch = emptyVarPattern.exec(mask)) !== null) { const position = emptyMatch.index; const rangeEnd = position + emptyMatch[0].length; errors.push({ type: "error", message: "Empty variable: ${}", position, rangeEnd }); } const nestedPattern = /\$\{[^}]*\$\{/g; let nestedMatch; while ((nestedMatch = nestedPattern.exec(mask)) !== null) { const position = nestedMatch.index; const rangeEnd = nestedMatch.index + nestedMatch[0].length; errors.push({ type: "error", message: "Nested braces are not allowed", position, rangeEnd }); } const varPattern = /\$\{([^}]+)\}/g; let match; const varPositions = /* @__PURE__ */ new Map(); while ((match = varPattern.exec(mask)) !== null) { const varName = match[1].trim(); const position = match.index; if (varName !== match[1]) { warnings.push({ type: "warning", message: `Variable "\${${match[1]}}" has leading or trailing whitespace`, position }); } if (!/^[a-zA-Z0-9_]+$/.test(varName)) { invalidVars.push(varName); const rangeEnd = position + match[0].length; errors.push({ type: "error", message: `Invalid variable name "\${${varName}}": only letters, numbers, and underscores allowed`, position, rangeEnd }); continue; } if (varName.startsWith("_")) { reservedVars.push(varName); warnings.push({ type: "warning", message: `Variable "\${${varName}}" uses reserved prefix "_" (reserved for comment variables)`, position }); continue; } if (/^\d/.test(varName)) { warnings.push({ type: "warning", message: `Variable "\${${varName}}" starts with a number (potentially confusing)`, position }); } if (varName.length > 50) { warnings.push({ type: "warning", message: `Variable "\${${varName}}" is very long (${varName.length} characters)`, position }); } if (seenVars.has(varName)) { duplicates.add(varName); if (!varPositions.has(varName)) { varPositions.set(varName, position); } } else { seenVars.add(varName); varPositions.set(varName, position); } validVars.push(varName); } if (duplicates.size > 0) { const firstDuplicatePos = Math.min(...Array.from(duplicates).map((v) => varPositions.get(v))); warnings.push({ type: "warning", message: `Duplicate variables: ${Array.from(duplicates).map((v) => `\${${v}}`).join(", ")}`, position: firstDuplicatePos }); } const totalVars = validVars.length + reservedVars.length; if (totalVars > 0) { info.push({ type: "info", message: `${totalVars} variable${totalVars === 1 ? "" : "s"} defined` }); } if (totalVars === 0 && mask.length > 0) { info.push({ type: "info", message: "No variables defined. Add variables like ${name} to extract data." }); } return { valid: errors.length === 0, errors, warnings, info, variables: { valid: validVars, invalid: invalidVars, reserved: reservedVars } }; } function interpolate(template, data, commentVariables = {}) { if (!template) return template; const allData = { ...data, ...commentVariables }; return template.replace(/\$\{([^}]+)\}/g, (match, key) => { const value = allData[key]; return value !== void 0 && value !== null && value !== "" ? value : ""; }); } function findMatchingOption(options, variableValue, matchType) { if (!options || !variableValue) return null; const normalizedValue = variableValue.toLowerCase(); for (const option of options) { const optionText = option.textContent ? option.textContent.toLowerCase() : option.text.toLowerCase(); const optionValue = option.value.toLowerCase(); let matches = false; switch (matchType) { case "exact": matches = optionText === normalizedValue || optionValue === normalizedValue; break; case "contains": matches = optionText.includes(normalizedValue) || optionValue.includes(normalizedValue); break; case "starts": matches = optionText.startsWith(normalizedValue) || optionValue.startsWith(normalizedValue); break; case "ends": matches = optionText.endsWith(normalizedValue) || optionValue.endsWith(normalizedValue); break; } if (matches) { return { value: option.value, text: option.textContent || option.text }; } } return null; } function generateCombinationsDescending(count) { const total = Math.pow(2, count); const combinations = []; for (let i = total - 1; i >= 0; i--) { const combo = []; for (let j = 0; j < count; j++) { combo.push((i & 1 << j) !== 0); } combinations.push(combo); } return combinations; } function buildMaskFromCombination(parts, combo) { let result = ""; let optionalIndex = 0; for (const part of parts) { if (part.type === "required") { result += part.content; } else if (part.type === "optional") { if (combo[optionalIndex]) { result += part.content; } optionalIndex++; } } return result; } function parseMaskStructure(mask) { if (!mask) { return { parts: [], optionalCount: 0 }; } const parts = []; let current = ""; let i = 0; let optionalCount = 0; let inOptional = false; let optionalStart = -1; while (i < mask.length) { if (mask[i] === "\\" && i + 1 < mask.length) { current += mask.slice(i, i + 2); i += 2; continue; } if (mask[i] === "{" && mask[i + 1] === "?") { if (inOptional) { let nestedEnd = i + 2; while (nestedEnd < mask.length) { if (mask[nestedEnd] === "\\" && nestedEnd + 1 < mask.length) { nestedEnd += 2; continue; } if (mask[nestedEnd] === "?" && mask[nestedEnd + 1] === "}") { nestedEnd += 2; break; } nestedEnd++; } const error = new Error(`Nested optional blocks not allowed at position ${i}`); error.rangeEnd = nestedEnd; throw error; } if (current) { parts.push({ type: "required", content: current }); current = ""; } inOptional = true; optionalStart = i; i += 2; continue; } if (mask[i] === "?" && mask[i + 1] === "}" && inOptional) { if (current.trim() === "") { throw new Error(`Empty optional block at position ${optionalStart}`); } parts.push({ type: "optional", content: current }); current = ""; inOptional = false; optionalCount++; i += 2; continue; } current += mask[i]; i++; } if (inOptional) { throw new Error(`Unclosed optional block starting at position ${optionalStart}`); } if (current) { parts.push({ type: "required", content: current }); } if (optionalCount > 8) { throw new Error(`Too many optional blocks (${optionalCount}). Maximum is 8.`); } return { parts, optionalCount }; } function parseTemplateWithOptionals(mask, torrentName) { try { const parsed = parseMaskStructure(mask); if (parsed.optionalCount === 0) { return parseTemplate(mask, torrentName); } const combinations = generateCombinationsDescending(parsed.optionalCount); for (const combo of combinations) { const maskVariant = buildMaskFromCombination(parsed.parts, combo); const extracted = parseTemplate(maskVariant, torrentName); if (Object.keys(extracted).length > 0) { return { ...extracted, _matchedOptionals: combo, _optionalCount: parsed.optionalCount }; } } return {}; } catch (e) { throw e; } } function testMaskAgainstSamples(mask, sampleNames) { const validation = validateMaskWithDetails(mask); const sampleArray = Array.isArray(sampleNames) ? sampleNames : sampleNames.split("\n").map((s) => s.trim()).filter((s) => s); return { validation, results: sampleArray.map((name) => { try { const parsed = parseTemplateWithOptionals(mask, name); const { _matchedOptionals, _optionalCount, ...variables } = parsed; const matched = Object.keys(variables).length > 0; const positions = {}; if (matched) { for (const [varName, value] of Object.entries(variables)) { const index = name.indexOf(value); if (index !== -1) { positions[varName] = { start: index, end: index + value.length }; } } } return { name, matched, variables, positions, optionalInfo: _matchedOptionals ? { matched: _matchedOptionals.filter((x) => x).length, total: _optionalCount } : null }; } catch (e) { return { name, matched: false, variables: {}, positions: {}, error: e.message }; } }) }; } function updateMaskHighlighting(maskInput, overlayDiv) { if (!maskInput || !overlayDiv) return; const text = maskInput.value; const varPattern = /\$\{([^}]*)\}?/g; const optionalBlocks = findOptionalBlocks(text); const nestedOptionalErrors = findNestedOptionalErrors(text); const varMatches = []; let match; while ((match = varPattern.exec(text)) !== null) { varMatches.push({ match, index: match.index }); } let highlightedHTML = buildLayeredHighlighting(text, optionalBlocks, varMatches, nestedOptionalErrors); overlayDiv.innerHTML = highlightedHTML; overlayDiv.scrollTop = maskInput.scrollTop; overlayDiv.scrollLeft = maskInput.scrollLeft; } function buildLayeredHighlighting(text, optionalBlocks, varMatches, nestedOptionalErrors) { let result = ""; const segments = []; for (let i = 0; i < text.length; i++) { const inOptional = optionalBlocks.find((block) => i >= block.start && i < block.end); const varMatch = varMatches.find((v) => i >= v.index && i < v.index + v.match[0].length); const inNestedError = nestedOptionalErrors.find((err) => i >= err.start && i < err.end); const currentSegment = segments[segments.length - 1]; if (currentSegment && currentSegment.inOptional === !!inOptional && currentSegment.varMatch === varMatch && currentSegment.inNestedError === !!inNestedError) { currentSegment.end = i + 1; } else { segments.push({ start: i, end: i + 1, inOptional: !!inOptional, varMatch, inNestedError: !!inNestedError }); } } for (const segment of segments) { const content = text.slice(segment.start, segment.end); let html = escapeHtml$1(content); if (segment.inNestedError) { if (segment.inOptional) { html = `<span class="gut-highlight-optional"><span class="gut-highlight-error">${html}</span></span>`; } else { html = `<span class="gut-highlight-error">${html}</span>`; } } else if (segment.varMatch) { const varName = segment.varMatch.match[1]; const fullMatch = segment.varMatch.match[0]; const isUnclosed = !fullMatch.endsWith("}"); const isEmpty = varName.trim() === ""; const isInvalid = varName && !/^[a-zA-Z0-9_]+$/.test(varName.trim()); const isReserved = varName.trim().startsWith("_"); let varClass = "gut-highlight-variable"; if (isUnclosed || isEmpty) { varClass = "gut-highlight-error"; } else if (isInvalid) { varClass = "gut-highlight-error"; } else if (isReserved) { varClass = "gut-highlight-warning"; } if (segment.inOptional) { html = `<span class="gut-highlight-optional"><span class="${varClass}">${html}</span></span>`; } else { html = `<span class="${varClass}">${html}</span>`; } } else if (segment.inOptional) { html = `<span class="gut-highlight-optional">${html}</span>`; } result += html; } return result; } function findOptionalBlocks(text) { const blocks = []; let i = 0; while (i < text.length) { if (text[i] === "\\" && i + 1 < text.length) { i += 2; continue; } if (text[i] === "{" && text[i + 1] === "?") { const start = i; i += 2; let depth = 1; while (i < text.length && depth > 0) { if (text[i] === "\\" && i + 1 < text.length) { i += 2; continue; } if (text[i] === "{" && text[i + 1] === "?") { depth++; i += 2; } else if (text[i] === "?" && text[i + 1] === "}") { depth--; if (depth === 0) { i += 2; blocks.push({ start, end: i }); break; } i += 2; } else { i++; } } if (depth > 0) { blocks.push({ start, end: text.length }); } } else { i++; } } return blocks; } function findNestedOptionalErrors(text) { const errors = []; let i = 0; let inOptional = false; while (i < text.length) { if (text[i] === "\\" && i + 1 < text.length) { i += 2; continue; } if (text[i] === "{" && text[i + 1] === "?") { if (inOptional) { const nestedStart = i; i += 2; let nestedEnd = i; while (nestedEnd < text.length) { if (text[nestedEnd] === "\\" && nestedEnd + 1 < text.length) { nestedEnd += 2; continue; } if (text[nestedEnd] === "?" && text[nestedEnd + 1] === "}") { nestedEnd += 2; break; } nestedEnd++; } errors.push({ start: nestedStart, end: nestedEnd }); continue; } inOptional = true; i += 2; continue; } if (text[i] === "?" && text[i + 1] === "}") { inOptional = false; i += 2; continue; } i++; } return errors; } const ICON_ERROR = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1.5"/><path d="M4.5 4.5L9.5 9.5M9.5 4.5L4.5 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'; const ICON_WARNING = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7 1L13 12H1L7 1Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M7 5.5V8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="7" cy="10" r="0.5" fill="currentColor"/></svg>'; const ICON_INFO = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="7" cy="7" r="6" stroke="currentColor" stroke-width="1.5"/><path d="M7 6.5V10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><circle cx="7" cy="4.5" r="0.5" fill="currentColor"/></svg>'; function renderStatusMessages(container, validation) { if (!container || !validation) return; const { errors, warnings, info, valid } = validation; const messages = [...errors, ...warnings, ...info]; if (messages.length === 0 && valid) { container.innerHTML = `<div class="gut-status-message gut-status-info">${ICON_INFO} Add variables like \${name} to extract data.</div>`; container.classList.add("visible"); return; } if (messages.length === 0) { container.innerHTML = ""; container.classList.remove("visible"); return; } const sortedMessages = messages.sort((a, b) => { if (a.position !== void 0 && b.position !== void 0) { return a.position - b.position; } if (a.position !== void 0) return -1; if (b.position !== void 0) return 1; const priority = { error: 0, warning: 1, info: 2 }; return priority[a.type] - priority[b.type]; }); const priorityMessage = sortedMessages.slice(0, 3); const html = priorityMessage.map((msg) => { let className = "gut-status-message"; let icon = ""; switch (msg.type) { case "error": className += " gut-status-error"; icon = ICON_ERROR; break; case "warning": className += " gut-status-warning"; icon = ICON_WARNING; break; case "info": className += " gut-status-info"; icon = ICON_INFO; break; } return `<div class="${className}">${icon} ${escapeHtml$1(msg.message)}</div>`; }).join(""); if (sortedMessages.length > 3) { const remaining = sortedMessages.length - 3; const remainingHtml = `<div class="gut-status-message gut-status-info">+ ${remaining} more message${remaining === 1 ? "" : "s"}</div>`; container.innerHTML = html + remainingHtml; } else { container.innerHTML = html; } container.classList.add("visible"); } function escapeHtml$1(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } const MODAL_HTML = (instance) => ` <div class="gut-modal-content"> <div class="gut-modal-tabs"> <button class="gut-tab-btn active" data-tab="templates">Templates</button> <button class="gut-tab-btn" data-tab="settings">Settings</button> <button class="gut-tab-btn" data-tab="sandbox">Mask Sandbox</button> </div> <div class="gut-tab-content active" id="templates-tab"> ${Object.keys(instance.templates).length === 0 ? '<div style="padding: 20px; text-align: center; color: #888;">No templates found. Create a template first.</div>' : `<div class="gut-template-list"> ${Object.keys(instance.templates).map( (name) => ` <div class="gut-template-item"> <span class="gut-template-name">${instance.escapeHtml(name)}</span> <div class="gut-template-actions"> <button class="gut-btn gut-btn-secondary gut-btn-small" data-action="edit" data-template="${instance.escapeHtml(name)}">Edit</button> <button class="gut-btn gut-btn-secondary gut-btn-small" data-action="clone" data-template="${instance.escapeHtml(name)}">Clone</button> <button class="gut-btn gut-btn-danger gut-btn-small" data-action="delete" data-template="${instance.escapeHtml(name)}">Delete</button> </div> </div> ` ).join("")} </div>`} </div> <div class="gut-tab-content" id="settings-tab"> <div class="gut-form-group"> <label for="setting-form-selector">Target Form Selector:</label> <input type="text" id="setting-form-selector" value="${instance.escapeHtml(instance.config.TARGET_FORM_SELECTOR)}" placeholder="#upload_table"> </div> <div class="gut-form-group"> <div class="gut-keybinding-controls"> <label class="gut-checkbox-label"> <input type="checkbox" id="setting-submit-keybinding" ${instance.config.SUBMIT_KEYBINDING ? "checked" : ""}> <span class="gut-checkbox-text">\u26A1 Enable form submission keybinding: <span class="gut-keybinding-text">${instance.config.CUSTOM_SUBMIT_KEYBINDING || "Ctrl+Enter"}</span></span> </label> <button type="button" id="record-submit-keybinding-btn" class="gut-btn gut-btn-secondary gut-btn-small">Record</button> </div> <input type="hidden" id="custom-submit-keybinding-input" value="${instance.config.CUSTOM_SUBMIT_KEYBINDING || "Ctrl+Enter"}"> </div> <div class="gut-form-group"> <div class="gut-keybinding-controls"> <label class="gut-checkbox-label"> <input type="checkbox" id="setting-apply-keybinding" ${instance.config.APPLY_KEYBINDING ? "checked" : ""}> <span class="gut-checkbox-text">\u26A1 Enable apply template keybinding: <span class="gut-keybinding-text">${instance.config.CUSTOM_APPLY_KEYBINDING || "Ctrl+Shift+A"}</span></span> </label> <button type="button" id="record-apply-keybinding-btn" class="gut-btn gut-btn-secondary gut-btn-small">Record</button> </div> <input type="hidden" id="custom-apply-keybinding-input" value="${instance.config.CUSTOM_APPLY_KEYBINDING || "Ctrl+Shift+A"}"> </div> <div class="gut-form-group"> <label for="setting-custom-selectors">Custom Field Selectors (one per line):</label> <textarea id="setting-custom-selectors" rows="4" placeholder="div[data-field] .custom-input[name] button[data-value]">${(instance.config.CUSTOM_FIELD_SELECTORS || []).join("\n")}</textarea> <div style="font-size: 12px; color: #888; margin-top: 5px;"> Additional CSS selectors to find form fields. e.g: <a href="#" id="ggn-infobox-link" class="gut-link">GGn Infobox</a> </div> </div> <div class="gut-form-group" id="custom-selectors-preview-group" style="display: none;"> <label id="matched-elements-label">Matched Elements:</label> <div id="custom-selectors-matched" class="gut-extracted-vars"> <div class="gut-no-variables">No elements matched by custom selectors.</div> </div> </div> <div class="gut-form-group"> <label for="setting-ignored-fields">Ignored Fields (one per line):</label> <textarea id="setting-ignored-fields" rows="6" placeholder="linkgroup groupid apikey">${instance.config.IGNORED_FIELDS_BY_DEFAULT.join("\n")}</textarea> </div> <div class="gut-form-group"> <div style="display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; gap: 10px;"> <button class="gut-btn gut-btn-primary" id="save-settings">Save Settings</button> <button class="gut-btn gut-btn-secondary" id="reset-settings">Reset to Defaults</button> </div> <button class="gut-btn gut-btn-danger" id="delete-all-config">Delete All Local Config</button> </div> </div> </div> ${SANDBOX_TAB_HTML(instance)} <div class="gut-modal-actions"> <button class="gut-btn" id="close-manager">Close</button> </div> </div> `; const VARIABLES_MODAL_HTML = (variables) => ` <div class="gut-modal-content"> <h2>Available Variables</h2> <div class="gut-form-group"> <div class="gut-extracted-vars"> ${Object.keys(variables).length === 0 ? '<div class="gut-no-variables">No variables available. Select a template with a torrent name mask to see extracted variables.</div>' : Object.entries(variables).map( ([name, value]) => ` <div class="gut-variable-item"> <span class="gut-variable-name">\${${name}}</span> <span class="gut-variable-value">${value || '<em style="color: #666;">empty</em>'}</span> </div> ` ).join("")} </div> </div> <div class="gut-modal-actions"> <button class="gut-btn" id="close-variables-modal">Close</button> </div> </div> `; const TEMPLATE_SELECTOR_HTML = (instance) => ` <option value="">Select Template</option> ${Object.keys(instance.templates).map( (name) => `<option value="${name}" ${name === instance.selectedTemplate ? "selected" : ""}>${name}</option>` ).join("")} `; const TEMPLATE_LIST_HTML = (instance) => Object.keys(instance.templates).length === 0 ? '<div style="padding: 20px; text-align: center; color: #888;">No templates found. Close this dialog and create a template first.</div>' : `<div class="gut-template-list"> ${Object.keys(instance.templates).map( (name) => ` <div class="gut-template-item"> <span class="gut-template-name">${instance.escapeHtml(name)}</span> <div class="gut-template-actions"> <button class="gut-btn gut-btn-secondary gut-btn-small" data-action="edit" data-template="${instance.escapeHtml(name)}">Edit</button> <button class="gut-btn gut-btn-secondary gut-btn-small" data-action="clone" data-template="${instance.escapeHtml(name)}">Clone</button> <button class="gut-btn gut-btn-danger gut-btn-small" data-action="delete" data-template="${instance.escapeHtml(name)}">Delete</button> </div> </div> ` ).join("")} </div>`; const TEMPLATE_CREATOR_HTML = (formData, instance, editTemplateName, editTemplate, selectedTorrentName) => ` <div class="gut-modal-content"> <h2> ${editTemplateName ? '<button class="gut-modal-back-btn" id="back-to-manager" title="Back to Template Manager"><</button>' : ""} ${editTemplateName ? "Edit Template" : "Create Template"} </h2> <div class="gut-form-group"> <label for="template-name">Template Name:</label> <input type="text" id="template-name" placeholder="e.g., Magazine Template" value="${editTemplateName ? instance.escapeHtml(editTemplateName) : ""}"> </div> <div class="gut-form-group"> <label for="sample-torrent">Sample Torrent Name (for preview):</label> <input type="text" id="sample-torrent" value="${instance.escapeHtml(selectedTorrentName)}" placeholder="e.g., PCWorld - Issue 05 - 01-2024.zip"> </div> <div class="gut-form-group" style="margin-bottom: 8px;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px;"> <label for="torrent-mask" style="margin-bottom: 0;">Torrent Name Mask:</label> <a href="#" id="test-mask-sandbox-link" class="gut-link" style="font-size: 11px;">Test mask in sandbox \u2192</a> </div> <div class="gut-mask-input-container"> <div class="gut-mask-highlight-overlay" id="mask-highlight-overlay"></div> <input type="text" id="torrent-mask" autocomplete="off" class="gut-mask-input" placeholder="e.g., \${magazine} - Issue \${issue} - \${month}-\${year}.\${ext}" value="${editTemplate ? instance.escapeHtml(editTemplate.mask) : ""}"> </div> <div class="gut-mask-cursor-info" id="mask-cursor-info"></div> <div class="gut-mask-status-container" id="mask-status-container"></div> </div> <div class="gut-form-group"> <label>Extracted Variables:</label> <div id="extracted-variables" class="gut-extracted-vars"> <div class="gut-no-variables">No variables defined yet. Add variables like \${name} to your mask.</div> </div> </div> <div class="gut-form-group"> <div style="display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 10px;"> <label style="margin: 0;">Form Fields:</label> <div style="display: flex; align-items: center; gap: 10px;"> <input type="text" id="field-filter" placeholder="Filter fields..." autocomplete="off" style="padding: 6px 8px; border: 1px solid #404040; border-radius: 3px; background: #2a2a2a; color: #e0e0e0; font-size: 12px; min-width: 150px;"> <button type="button" class="gut-btn gut-btn-secondary" id="toggle-unselected" style="padding: 6px 12px; font-size: 12px; white-space: nowrap;">Show Unselected</button> </div> </div> <div class="gut-field-list"> ${Object.entries(formData).map(([name, fieldData]) => { const isIgnoredByDefault = instance.config.IGNORED_FIELDS_BY_DEFAULT.includes( name.toLowerCase() ); const isInTemplate = editTemplate && editTemplate.fieldMappings.hasOwnProperty(name); const templateValue = isInTemplate ? editTemplate.fieldMappings[name] : null; let shouldBeChecked = isInTemplate || !isIgnoredByDefault; if (editTemplate && editTemplate.customUnselectedFields) { const customField = editTemplate.customUnselectedFields.find( (f) => f.field === name ); if (customField) { shouldBeChecked = customField.selected; } } return ` <div class="gut-field-row ${isIgnoredByDefault && !isInTemplate && !shouldBeChecked ? "gut-hidden" : ""}"> ${fieldData.type === "select" ? (() => { const hasVariableMatching = editTemplate && editTemplate.variableMatching && editTemplate.variableMatching[name]; hasVariableMatching ? editTemplate.variableMatching[name] : null; const isVariableMode = hasVariableMatching; return `<div style="display: flex; align-items: flex-start; width: 100%;"> <a href="#" class="gut-link gut-variable-toggle" data-field="${name}" data-state="${isVariableMode ? "on" : "off"}">Match from variable: ${isVariableMode ? "ON" : "OFF"}</a> </div>`; })() : ""} <input type="checkbox" ${shouldBeChecked ? "checked" : ""} data-field="${name}"> <label title="${name}">${fieldData.label}:</label> ${fieldData.type === "select" ? (() => { const hasVariableMatching = editTemplate && editTemplate.variableMatching && editTemplate.variableMatching[name]; const variableConfig = hasVariableMatching ? editTemplate.variableMatching[name] : null; const isVariableMode = hasVariableMatching; return `<div class="gut-select-container" style="display: flex; flex-direction: column; gap: 4px; flex: 1;"> <div style="display: flex; flex-direction: column; align-items: flex-end;"> <select data-template="${name}" class="template-input gut-select select-static-mode" style="width: 100%; ${isVariableMode ? "display: none;" : ""}"> ${fieldData.options.map((option) => { let selected = option.selected; if (templateValue && templateValue === option.value) { selected = true; } return `<option value="${instance.escapeHtml(option.value)}" ${selected ? "selected" : ""}>${instance.escapeHtml(option.text)}</option>`; }).join("")} </select> </div> <div class="gut-variable-controls" data-field="${name}" style="display: ${isVariableMode ? "flex" : "none"}; gap: 8px;"> <select class="gut-match-type" data-field="${name}" style="padding: 6px 8px; border: 1px solid #404040; border-radius: 3px; background: #1a1a1a; color: #e0e0e0; font-size: 12px;"> <option value="exact" ${variableConfig && variableConfig.matchType === "exact" ? "selected" : ""}>Is exactly</option> <option value="contains" ${variableConfig && variableConfig.matchType === "contains" ? "selected" : ""}>Contains</option> <option value="starts" ${variableConfig && variableConfig.matchType === "starts" ? "selected" : ""}>Starts with</option> <option value="ends" ${variableConfig && variableConfig.matchType === "ends" ? "selected" : ""}>Ends with</option> </select> <input type="text" class="gut-variable-input" data-field="${name}" placeholder="\${variable_name}" value="${variableConfig ? instance.escapeHtml(variableConfig.variableName) : ""}" style="flex: 1; padding: 6px 8px; border: 1px solid #404040; border-radius: 3px; background: #1a1a1a; color: #e0e0e0; font-size: 12px;"> </div> </div>`; })() : fieldData.inputType === "checkbox" ? `<input type="checkbox" ${templateValue !== null ? templateValue ? "checked" : "" : fieldData.value ? "checked" : ""} data-template="${name}" class="template-input">` : fieldData.inputType === "radio" ? `<select data-template="${name}" class="template-input gut-select"> ${fieldData.radioOptions.map((option) => { let selected = option.checked; if (templateValue && templateValue === option.value) { selected = true; } return `<option value="${instance.escapeHtml(option.value)}" ${selected ? "selected" : ""}>${instance.escapeHtml(option.label)}</option>`; }).join("")} </select>` : fieldData.type === "textarea" ? `<textarea data-template="${name}" class="template-input" rows="4" style="resize: vertical; width: 100%;">${templateValue !== null ? instance.escapeHtml(String(templateValue)) : instance.escapeHtml(String(fieldData.value))}</textarea>` : `<input type="text" value="${templateValue !== null ? instance.escapeHtml(String(templateValue)) : instance.escapeHtml(String(fieldData.value))}" data-template="${name}" class="template-input">`} <span class="gut-preview" data-preview="${name}"></span> </div> `; }).join("")} </div> </div> <div class="gut-modal-actions"> <button class="gut-btn" id="cancel-template">Cancel</button> <button class="gut-btn gut-btn-primary" id="save-template">${editTemplateName ? "Update Template" : "Save Template"}</button> </div> </div> `; const SANDBOX_TAB_HTML = (instance) => { const savedSets = instance.sandboxSets || {}; const currentSet = instance.currentSandboxSet || ""; return ` <div class="gut-tab-content" id="sandbox-tab"> <div style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 15px;"> <div style="display: flex; align-items: center; gap: 8px;"> <select id="sandbox-set-select" class="gut-select" style="flex: 1;"> <option value="">New test set</option> ${Object.keys(savedSets).map( (name) => `<option value="${instance.escapeHtml(name)}" ${name === currentSet ? "selected" : ""}>${instance.escapeHtml(name)}</option>` ).join("")} </select> <button class="gut-btn gut-btn-secondary gut-btn-small" id="save-sandbox-set" title="Save or update test set">Save</button> <button class="gut-btn gut-btn-secondary gut-btn-small" id="rename-sandbox-set" style="display: none;" title="Rename current test set">Rename</button> <button class="gut-btn gut-btn-danger gut-btn-small" id="delete-sandbox-set" style="display: none;" title="Delete current test set">Delete</button> </div> <div style="display: flex; justify-content: flex-start;"> <a href="#" id="reset-sandbox-fields" class="gut-link" style="font-size: 11px;">Reset fields</a> </div> </div> <div class="gut-form-group"> <label for="sandbox-mask-input">Mask:</label> <div class="gut-mask-input-container"> <div class="gut-mask-highlight-overlay" id="sandbox-mask-display"></div> <input type="text" id="sandbox-mask-input" autocomplete="off" class="gut-mask-input" placeholder="\${artist} - \${album} {?[\${year}]?}"> </div> <div class="gut-mask-cursor-info" id="sandbox-mask-cursor-info"></div> <div class="gut-mask-status-container" id="sandbox-mask-status"></div> </div> <div class="gut-form-group"> <label for="sandbox-sample-input">Sample Torrent Names (one per line):</label> <textarea id="sandbox-sample-input" rows="8" style="font-family: 'Fira Code', monospace; font-size: 13px; resize: vertical; width: 100%;" placeholder="Artist Name - Album Title [2024] Another Artist - Some Album Third Example - Test [2023]"></textarea> </div> <div class="gut-form-group"> <label id="sandbox-results-label">Match Results:</label> <div id="sandbox-results" class="gut-sandbox-results"> <div class="gut-no-variables">Enter a mask and sample names to see match results.</div> </div> </div> </div> `; }; const MAIN_UI_HTML = (instance) => ` <div id="ggn-upload-templator-controls" class="ggn-upload-templator-controls" style="align-items: flex-end;"> <div style="display: flex; flex-direction: column; gap: 5px;"> <div style="display: flex; justify-content: space-between; align-items: center;"> <label for="template-selector" style="font-size: 12px; color: #b0b0b0; margin: 0;">Select template</label> <a href="#" id="edit-selected-template-btn" class="gut-link" style="${instance.selectedTemplate && instance.selectedTemplate !== "none" && instance.templates[instance.selectedTemplate] ? "" : "display: none;"}">Edit</a> </div> <div style="display: flex; gap: 10px; align-items: center;"> <select id="template-selector" class="gut-select"> <option value="">Select Template</option> ${Object.keys(instance.templates).map( (name) => `<option value="${name}" ${name === instance.selectedTemplate ? "selected" : ""}>${name}</option>` ).join("")} </select> </div> </div> <button type="button" id="apply-template-btn" class="gut-btn gut-btn-primary">Apply Template</button> <button type="button" id="create-template-btn" class="gut-btn gut-btn-primary">+ Create Template</button> <button id="manage-templates-btn" type="button" class="gut-btn gut-btn-secondary" title="Manage Templates & Settings">Settings</button> </div> <div id="variables-row" style="display: none; padding: 10px 0; font-size: 12px; cursor: pointer; user-select: none;"></div> `; function setupMaskValidation(maskInput, cursorInfoElement, statusContainer, overlayElement, onValidationChange = null) { const updateCursorInfo = (validation) => { if (!validation || validation.errors.length === 0) { cursorInfoElement.textContent = ""; cursorInfoElement.style.display = "none"; return; } const firstError = validation.errors[0]; const errorPos = firstError.position !== void 0 ? firstError.position : null; if (errorPos === null) { cursorInfoElement.textContent = ""; cursorInfoElement.style.display = "none"; return; } const pos = maskInput.selectionStart; const maskValue = maskInput.value; cursorInfoElement.style.display = "block"; const errorRangeEnd = firstError.rangeEnd !== void 0 ? firstError.rangeEnd : errorPos + 1; if (pos >= errorPos && pos < errorRangeEnd) { const charAtError = errorPos < maskValue.length ? maskValue[errorPos] : ""; cursorInfoElement.innerHTML = `<span style="color: #f44336;">\u26A0 Error at position ${errorPos}${charAtError ? ` ('${escapeHtml(charAtError)}')` : " (end)"}</span>`; } else { const charAtPos = pos !== null && pos < maskValue.length ? maskValue[pos] : ""; const charAtError = errorPos < maskValue.length ? maskValue[errorPos] : ""; cursorInfoElement.innerHTML = `Cursor: ${pos}${charAtPos ? ` ('${escapeHtml(charAtPos)}')` : " (end)"} | <span style="color: #f44336;">Error: ${errorPos}${charAtError ? ` ('${escapeHtml(charAtError)}')` : " (end)"}</span>`; } }; const performValidation = () => { const validation = validateMaskWithDetails(maskInput.value); updateMaskHighlighting(maskInput, overlayElement); renderStatusMessages(statusContainer, validation); updateCursorInfo(validation); if (onValidationChange) { onValidationChange(validation); } return validation; }; maskInput.addEventListener("input", performValidation); maskInput.addEventListener("click", () => { const validation = validateMaskWithDetails(maskInput.value); updateCursorInfo(validation); }); maskInput.addEventListener("keyup", () => { const validation = validateMaskWithDetails(maskInput.value); updateCursorInfo(validation); }); maskInput.addEventListener("focus", () => { const validation = validateMaskWithDetails(maskInput.value); updateCursorInfo(validation); }); return performValidation; } function injectUI(instance) { const fileInput = document.querySelector('input[type="file"]'); if (!fileInput) { console.warn("No file input found on page, UI injection aborted"); return; } const existingUI = document.getElementById("ggn-upload-templator-ui"); if (existingUI) { existingUI.remove(); } const uiContainer = document.createElement("div"); uiContainer.id = "ggn-upload-templator-ui"; uiContainer.innerHTML = MAIN_UI_HTML(instance); try { fileInput.parentNode.insertBefore(uiContainer, fileInput); } catch (error) { console.error("Failed to insert UI container:", error); return; } try { const createBtn = document.getElementById("create-template-btn"); const templateSelector = document.getElementById("template-selector"); const manageBtn = document.getElementById("manage-templates-btn"); const editBtn = document.getElementById("edit-selected-template-btn"); const applyBtn = document.getElementById("apply-template-btn"); if (createBtn) { createBtn.addEventListener( "click", async () => await instance.showTemplateCreator() ); } if (templateSelector) { templateSelector.addEventListener( "change", (e) => instance.selectTemplate(e.target.value) ); } if (manageBtn) { manageBtn.addEventListener( "click", () => instance.showTemplateAndSettingsManager() ); } if (editBtn) { editBtn.addEventListener("click", (e) => { e.preventDefault(); instance.editTemplate(instance.selectedTemplate); }); } if (applyBtn) { applyBtn.addEventListener( "click", () => instance.applyTemplateToCurrentTorrent() ); } const variablesRow = document.getElementById("variables-row"); if (variablesRow) { variablesRow.addEventListener("click", () => { instance.showVariablesModal(); }); } } catch (error) { console.error("Failed to bind UI events:", error); } } async function showTemplateCreator(instance, editTemplateName = null, editTemplate = null) { const formData = getCurrentFormData(instance.config); if (Object.keys(formData).length === 0) { alert("No form fields found on this page."); return; } let selectedTorrentName = ""; let commentVariables = {}; const fileInputs = instance.config.TARGET_FORM_SELECTOR ? document.querySelectorAll( `${instance.config.TARGET_FORM_SELECTOR} input[type="file"]` ) : document.querySelectorAll('input[type="file"]'); for (const input of fileInputs) { if (input.files && input.files[0] && input.files[0].name.toLowerCase().endsWith(".torrent")) { try { const torrentData = await TorrentUtils.parseTorrentFile(input.files[0]); selectedTorrentName = torrentData.name || ""; commentVariables = TorrentUtils.parseCommentVariables( torrentData.comment ); break; } catch (error) { console.warn("Could not parse selected torrent file:", error); } } } const modal = document.createElement("div"); modal.className = "gut-modal"; modal.innerHTML = TEMPLATE_CREATOR_HTML( formData, instance, editTemplateName, editTemplate, selectedTorrentName ); document.body.appendChild(modal); const maskInput = modal.querySelector("#torrent-mask"); const sampleInput = modal.querySelector("#sample-torrent"); const templateInputs = modal.querySelectorAll(".template-input"); const cursorInfo = modal.querySelector("#mask-cursor-info"); const toggleBtn = modal.querySelector("#toggle-unselected"); const filterInput = modal.querySelector("#field-filter"); const filterFields = () => { const filterValue = filterInput.value.toLowerCase(); const fieldRows = modal.querySelectorAll(".gut-field-row"); const fieldList = modal.querySelector(".gut-field-list"); let visibleCount = 0; const existingMessage = fieldList.querySelector(".gut-no-results"); if (existingMessage) { existingMessage.remove(); } fieldRows.forEach((row) => { const checkbox = row.querySelector('input[type="checkbox"]'); const label = row.querySelector("label"); const fieldName = checkbox.dataset.field.toLowerCase(); const labelText = label.textContent.toLowerCase(); const matchesFilter = !filterValue || fieldName.includes(filterValue) || labelText.includes(filterValue); const shouldShowBasedOnSelection = checkbox.checked || !instance.hideUnselectedFields; const shouldShow = matchesFilter && shouldShowBasedOnSelection; if (shouldShow) { row.classList.remove("gut-hidden"); visibleCount++; } else { row.classList.add("gut-hidden"); } }); if (filterValue && visibleCount === 0) { const noResultsMessage = document.createElement("div"); noResultsMessage.className = "gut-no-results"; noResultsMessage.style.cssText = "padding: 20px; text-align: center; color: #888; font-style: italic;"; noResultsMessage.textContent = `No fields found matching "${filterValue}"`; fieldList.appendChild(noResultsMessage); } }; const toggleUnselectedFields = () => { instance.hideUnselectedFields = !instance.hideUnselectedFields; localStorage.setItem( "ggn-upload-templator-hide-unselected", JSON.stringify(instance.hideUnselectedFields) ); toggleBtn.textContent = instance.hideUnselectedFields ? "Show Unselected" : "Hide Unselected"; filterFields(); }; toggleBtn.textContent = instance.hideUnselectedFields ? "Show Unselected" : "Hide Unselected"; filterFields(); toggleBtn.addEventListener("click", toggleUnselectedFields); filterInput.addEventListener("input", filterFields); const overlayDiv = modal.querySelector("#mask-highlight-overlay"); const statusContainer = modal.querySelector("#mask-status-container"); const saveButton = modal.querySelector("#save-template"); const performValidation = setupMaskValidation( maskInput, cursorInfo, statusContainer, overlayDiv, (validation) => { saveButton.disabled = !validation.valid; updatePreviews(); } ); const updatePreviews = () => { const mask = maskInput.value; const sample = sampleInput.value; const validation = validateMaskWithDetails(mask); const parseResult = parseTemplateWithOptionals(mask, sample); const maskExtracted = { ...parseResult }; delete maskExtracted._matchedOptionals; delete maskExtracted._optionalCount; const allVariables = { ...commentVariables, ...maskExtracted }; const extractedVarsContainer = modal.querySelector("#extracted-variables"); if (Object.keys(allVariables).length === 0) { const hasMaskVariables = validation.variables.valid.length > 0 || validation.variables.reserved.length > 0; if (hasMaskVariables) { extractedVarsContainer.innerHTML = '<div class="gut-no-variables">Select a torrent file or provide a sample torrent name to extract variables.</div>'; } else { extractedVarsContainer.innerHTML = '<div class="gut-no-variables">No variables defined yet. Add variables like ${name} to your mask.</div>'; } } else { extractedVarsContainer.innerHTML = Object.entries(allVariables).map( ([varName, varValue]) => ` <div class="gut-variable-item"> <span class="gut-variable-name">\${${escapeHtml(varName)}}</span> <span class="gut-variable-value ${varValue ? "" : "empty"}">${varValue ? escapeHtml(varValue) : "(empty)"}</span> </div> ` ).join(""); } if (parseResult._matchedOptionals && parseResult._optionalCount) { const matchCount = parseResult._matchedOptionals.filter((x) => x).length; const optionalInfo = document.createElement("div"); optionalInfo.className = "gut-variable-item"; optionalInfo.style.cssText = "background: #2a4a3a; border-left: 3px solid #4caf50;"; optionalInfo.innerHTML = ` <span class="gut-variable-name" style="color: #4caf50;">Optional blocks</span> <span class="gut-variable-value">Matched ${matchCount}/${parseResult._optionalCount}</span> `; extractedVarsContainer.appendChild(optionalInfo); } templateInputs.forEach((input) => { const fieldName = input.dataset.template; const preview = modal.querySelector(`[data-preview="${fieldName}"]`); if (input.type === "checkbox") { preview.textContent = input.checked ? "\u2713 checked" : "\u2717 unchecked"; preview.className = "gut-preview"; } else if (input.tagName.toLowerCase() === "select") { const variableToggle = modal.querySelector( `.gut-variable-toggle[data-field="${fieldName}"]` ); const isVariableMode = variableToggle && variableToggle.dataset.state === "on"; if (isVariableMode) { const variableInput = modal.querySelector( `.gut-variable-input[data-field="${fieldName}"]` ); const matchTypeSelect = modal.querySelector( `.gut-match-type[data-field="${fieldName}"]` ); const variableName = variableInput ? variableInput.value.trim() : ""; const matchType = matchTypeSelect ? matchTypeSelect.value : "exact"; if (variableName && allVariables[variableName.replace(/^\$\{|\}$/g, "")]) { const variableValue = allVariables[variableName.replace(/^\$\{|\}$/g, "")]; const matchedOption = findMatchingOption( input.options, variableValue, matchType ); if (matchedOption) { preview.textContent = `\u2192 "${matchedOption.text}" (matched "${variableValue}" using ${matchType})`; preview.className = "gut-preview active visible"; } else { preview.textContent = `\u2192 No match found for "${variableValue}" using ${matchType}`; preview.className = "gut-preview visible"; } } else if (variableName) { preview.textContent = `\u2192 Variable ${variableName} not found in extracted data`; preview.className = "gut-preview visible"; } else { preview.textContent = ""; preview.className = "gut-preview"; } } else { preview.textContent = ""; preview.className = "gut-preview"; } } else { const inputValue = input.value || ""; const interpolated = interpolate(inputValue, allVariables); if (inputValue.includes("${") && Object.keys(allVariables).length > 0) { preview.textContent = `\u2192 ${interpolated}`; preview.className = "gut-preview active visible"; } else { preview.textContent = ""; preview.className = "gut-preview"; } } }); }; [maskInput, sampleInput, ...templateInputs].forEach((input) => { input.addEventListener("input", updatePreviews); input.addEventListener("change", updatePreviews); }); maskInput.addEventListener("scroll", () => { const overlayDiv2 = modal.querySelector("#mask-highlight-overlay"); if (overlayDiv2) { overlayDiv2.scrollTop = maskInput.scrollTop; overlayDiv2.scrollLeft = maskInput.scrollLeft; } }); performValidation(); updatePreviews(); modal.addEventListener("change", (e) => { if (e.target.type === "checkbox") { filterFields(); } }); modal.querySelector("#cancel-template").addEventListener("click", () => { document.body.removeChild(modal); }); modal.querySelector("#save-template").addEventListener("click", () => { instance.saveTemplate(modal, editTemplateName); }); modal.addEventListener("click", (e) => { if (e.target === modal) { document.body.removeChild(modal); } }); const handleEscKey = (e) => { if (e.key === "Escape" && document.body.contains(modal)) { document.body.removeChild(modal); document.removeEventListener("keydown", handleEscKey); } }; document.addEventListener("keydown", handleEscKey); modal.addEventListener("click", (e) => { if (e.target.classList.contains("gut-variable-toggle")) { e.preventDefault(); const fieldName = e.target.dataset.field; const currentState = e.target.dataset.state; const newState = currentState === "off" ? "on" : "off"; e.target.dataset.state = newState; e.target.textContent = `Match from variable: ${newState.toUpperCase()}`; const staticSelect = modal.querySelector( `select.select-static-mode[data-template="${fieldName}"]` ); const variableControls = modal.querySelector( `.gut-variable-controls[data-field="${fieldName}"]` ); if (newState === "on") { staticSelect.classList.add("hidden"); variableControls.classList.add("visible"); } else { staticSelect.classList.remove("hidden"); variableControls.classList.remove("visible"); } updatePreviews(); } }); const variableInputs = modal.querySelectorAll( ".gut-variable-input, .gut-match-type" ); variableInputs.forEach((input) => { input.addEventListener("input", updatePreviews); input.addEventListener("change", updatePreviews); }); const backBtn = modal.querySelector("#back-to-manager"); if (backBtn) { backBtn.addEventListener("click", () => { document.body.removeChild(modal); instance.showTemplateAndSettingsManager(); }); } const sandboxLink = modal.querySelector("#test-mask-sandbox-link"); if (sandboxLink) { sandboxLink.addEventListener("click", (e) => { e.preventDefault(); const mask = maskInput.value; const sample = sampleInput.value; document.body.removeChild(modal); instance.showSandboxWithMask(mask, sample); }); } } function showVariablesModal(instance, variables) { const modal = document.createElement("div"); modal.className = "gut-modal"; modal.innerHTML = VARIABLES_MODAL_HTML(variables); document.body.appendChild(modal); modal.querySelector("#close-variables-modal").addEventListener("click", () => { document.body.removeChild(modal); }); modal.addEventListener("click", (e) => { if (e.target === modal) { document.body.removeChild(modal); } }); const handleEscKey = (e) => { if (e.key === "Escape" && document.body.contains(modal)) { document.body.removeChild(modal); document.removeEventListener("keydown", handleEscKey); } }; document.addEventListener("keydown", handleEscKey); } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } function renderSandboxResults(modal, testResults) { const resultsContainer = modal.querySelector("#sandbox-results"); const resultsLabel = modal.querySelector("#sandbox-results-label"); if (!resultsContainer || !testResults || testResults.results.length === 0) { resultsContainer.innerHTML = '<div class="gut-no-variables">Enter a mask and sample names to see match results.</div>'; resultsLabel.textContent = "Match Results:"; return; } const matchCount = testResults.results.filter((r) => r.matched).length; const totalCount = testResults.results.length; resultsLabel.textContent = `Match Results (${matchCount}/${totalCount} matched):`; const html = testResults.results.map((result, resultIndex) => { const isMatch = result.matched; const icon = isMatch ? "\u2713" : "\u2717"; const className = isMatch ? "gut-sandbox-match" : "gut-sandbox-no-match"; let variablesHtml = ""; if (isMatch && Object.keys(result.variables).length > 0) { variablesHtml = '<div class="gut-sandbox-variables" style="display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px;">' + Object.entries(result.variables).map( ([key, value]) => `<div class="gut-variable-item" style="margin: 0; flex: 0 0 auto; cursor: pointer;" data-result-index="${resultIndex}" data-var-name="${escapeHtml(key)}"> <span class="gut-variable-name">\${${escapeHtml(key)}}</span><span style="display: inline-block; color: #898989; margin: 0 8px;"> = </span><span class="gut-variable-value">${value ? escapeHtml(value) : "(empty)"}</span> </div>` ).join("") + "</div>"; if (result.optionalInfo) { variablesHtml += `<div style="margin-top: 8px; font-size: 11px; color: #4caf50;"> Optional blocks: ${result.optionalInfo.matched}/${result.optionalInfo.total} matched </div>`; } } return ` <div class="${className}" style="margin-bottom: 12px; padding: 8px; background: #1e1e1e; border-left: 3px solid ${isMatch ? "#4caf50" : "#f44336"}; border-radius: 4px;" data-result-index="${resultIndex}"> <div style="display: flex; align-items: center; gap: 8px;"> <span style="font-size: 16px; color: ${isMatch ? "#4caf50" : "#f44336"};">${icon}</span> <span class="gut-sandbox-sample-name" style="flex: 1; font-family: 'Fira Code', monospace; font-size: 13px;" data-result-index="${resultIndex}">${escapeHtml(result.name)}</span> </div> ${variablesHtml} </div> `; }).join(""); resultsContainer.innerHTML = html; resultsContainer._testResults = testResults; if (!resultsContainer._hasEventListeners) { resultsContainer.addEventListener( "mouseenter", (e) => { if (e.target.classList.contains("gut-variable-item")) { const resultIndex = parseInt(e.target.dataset.resultIndex); const varName = e.target.dataset.varName; const currentResults = resultsContainer._testResults; if (!currentResults || !currentResults.results[resultIndex]) { return; } const result = currentResults.results[resultIndex]; if (result.positions && result.positions[varName]) { const sampleNameEl = resultsContainer.querySelector( `.gut-sandbox-sample-name[data-result-index="${resultIndex}"]` ); const pos = result.positions[varName]; const name = result.name; const before = escapeHtml(name.substring(0, pos.start)); const highlight = escapeHtml(name.substring(pos.start, pos.end)); const after = escapeHtml(name.substring(pos.end)); sampleNameEl.innerHTML = `${before}<span style="background: #bb86fc; color: #000; padding: 2px 4px; border-radius: 2px;">${highlight}</span>${after}`; } } }, true ); resultsContainer.addEventListener( "mouseleave", (e) => { if (e.target.classList.contains("gut-variable-item")) { const resultIndex = parseInt(e.target.dataset.resultIndex); const currentResults = resultsContainer._testResults; if (!currentResults || !currentResults.results[resultIndex]) { return; } const result = currentResults.results[resultIndex]; const sampleNameEl = resultsContainer.querySelector( `.gut-sandbox-sample-name[data-result-index="${resultIndex}"]` ); sampleNameEl.textContent = result.name; } }, true ); resultsContainer._hasEventListeners = true; } } function parseKeybinding(keybinding) { const parts = keybinding.split("+").map((k) => k.trim().toLowerCase()); return { ctrl: parts.includes("ctrl"), meta: parts.includes("cmd") || parts.includes("meta"), shift: parts.includes("shift"), alt: parts.includes("alt"), key: parts.find((k) => !["ctrl", "cmd", "meta", "shift", "alt"].includes(k)) || "enter" }; } function matchesKeybinding(event, keys) { return event.key.toLowerCase() === keys.key && !!event.ctrlKey === keys.ctrl && !!event.metaKey === keys.meta && !!event.shiftKey === keys.shift && !!event.altKey === keys.alt; } function buildKeybindingFromEvent(event) { const keys = []; if (event.ctrlKey) keys.push("Ctrl"); if (event.metaKey) keys.push("Cmd"); if (event.shiftKey) keys.push("Shift"); if (event.altKey) keys.push("Alt"); keys.push(event.key.charAt(0).toUpperCase() + event.key.slice(1)); return keys.join("+"); } const style = '#ggn-upload-templator-ui {\n background: #1a1a1a;\n border: 1px solid #404040;\n border-radius: 6px;\n padding: 15px;\n margin: 15px 0;\n font-family:\n -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;\n color: #e0e0e0;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);\n}\n\n.ggn-upload-templator-controls {\n display: flex;\n gap: 10px;\n align-items: center;\n flex-wrap: wrap;\n}\n\n.gut-btn {\n padding: 8px 16px;\n border: none;\n border-radius: 4px;\n cursor: pointer;\n font-size: 14px;\n font-weight: 500;\n transition: all 0.2s ease;\n text-decoration: none;\n outline: none;\n box-sizing: border-box;\n height: auto;\n}\n\n.gut-btn-primary {\n background: #0d7377;\n color: #ffffff;\n border: 1px solid #0d7377;\n}\n\n.gut-btn-primary:hover {\n background: #0a5d61;\n border-color: #0a5d61;\n transform: translateY(-1px);\n}\n\n.gut-btn-danger {\n background: #d32f2f;\n color: #ffffff;\n border: 1px solid #d32f2f;\n}\n\n.gut-btn-danger:hover:not(:disabled) {\n background: #b71c1c;\n border-color: #b71c1c;\n transform: translateY(-1px);\n}\n\n.gut-btn:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n transform: none;\n}\n\n.gut-btn:not(:disabled):active {\n transform: translateY(0);\n}\n\n.gut-select {\n padding: 8px 12px;\n border: 1px solid #404040;\n border-radius: 4px;\n font-size: 14px;\n min-width: 200px;\n background: #2a2a2a;\n color: #e0e0e0;\n box-sizing: border-box;\n outline: none;\n height: auto;\n margin: 0 !important;\n}\n\n.gut-select:focus {\n border-color: #0d7377;\n box-shadow: 0 0 0 2px rgba(13, 115, 119, 0.2);\n}\n\n.gut-modal {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background: rgba(0, 0, 0, 0.8);\n display: flex;\n align-items: center;\n justify-content: center;\n z-index: 10000;\n padding: 20px;\n box-sizing: border-box;\n}\n\n.gut-modal-content {\n background: #1a1a1a;\n border: 1px solid #404040;\n border-radius: 8px;\n padding: 24px;\n max-width: 800px;\n max-height: 80vh;\n overflow-y: auto;\n width: 90%;\n color: #e0e0e0;\n box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);\n box-sizing: border-box;\n}\n\n.gut-modal h2 {\n margin: 0 0 20px 0;\n color: #ffffff;\n font-size: 24px;\n font-weight: 600;\n text-align: left;\n position: relative;\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.gut-modal-back-btn {\n background: none;\n border: none;\n color: #e0e0e0;\n font-size: 16px;\n cursor: pointer;\n padding: 8px;\n border-radius: 4px;\n transition:\n color 0.2s ease,\n background-color 0.2s ease;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 40px;\n height: 40px;\n flex-shrink: 0;\n font-family: monospace;\n font-weight: bold;\n}\n\n.gut-modal-back-btn:hover {\n color: #ffffff;\n background-color: #333333;\n}\n\n.gut-form-group {\n margin-bottom: 15px;\n}\n\n.gut-form-group label {\n display: block;\n margin-bottom: 5px;\n font-weight: 500;\n color: #b0b0b0;\n font-size: 14px;\n}\n\n.gut-form-group input,\n.gut-form-group textarea {\n width: 100%;\n padding: 8px 12px;\n border: 1px solid #404040;\n border-radius: 4px;\n font-size: 14px;\n box-sizing: border-box;\n background: #2a2a2a;\n color: #e0e0e0;\n outline: none;\n transition: border-color 0.2s ease;\n height: auto;\n}\n\n.gut-form-group input:focus,\n.gut-form-group textarea:focus {\n border-color: #0d7377;\n box-shadow: 0 0 0 2px rgba(13, 115, 119, 0.2);\n}\n\n.gut-form-group input::placeholder,\n.gut-form-group textarea::placeholder {\n color: #666666;\n}\n\n.gut-field-list {\n max-height: 300px;\n overflow-y: auto;\n border: 1px solid #404040;\n border-radius: 4px;\n padding: 10px;\n background: #0f0f0f;\n}\n\n.gut-field-row {\n display: flex;\n align-items: center;\n gap: 10px;\n margin-bottom: 8px;\n padding: 8px;\n background: #2a2a2a;\n border-radius: 4px;\n border: 1px solid #404040;\n flex-wrap: wrap;\n}\n\n.gut-field-row:hover {\n background: #333333;\n}\n\n.gut-field-row:not(:has(input[type="checkbox"]:checked)) {\n opacity: 0.6;\n}\n\n.gut-field-row.gut-hidden {\n display: none;\n}\n\n.gut-field-row input[type="checkbox"] {\n width: auto;\n margin: 0;\n accent-color: #0d7377;\n cursor: pointer;\n}\n\n.gut-field-row label {\n min-width: 150px;\n margin: 0;\n font-size: 13px;\n color: #b0b0b0;\n cursor: help;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n\n.gut-field-row input[type="text"],\n.gut-field-row select {\n flex: 1;\n margin: 0;\n padding: 6px 8px;\n border: 1px solid #404040;\n border-radius: 3px;\n background: #1a1a1a;\n color: #e0e0e0;\n font-size: 12px;\n outline: none;\n height: auto;\n}\n\n.gut-field-row input[type="text"]:focus {\n border-color: #0d7377;\n box-shadow: 0 0 0 1px rgba(13, 115, 119, 0.3);\n}\n\n.gut-preview {\n color: #888888;\n font-style: italic;\n font-size: 11px;\n word-break: break-all;\n flex-basis: 100%;\n margin-top: 4px;\n padding-left: 20px;\n white-space: pre-wrap;\n display: none;\n}\n\n.gut-preview.active {\n color: #4dd0e1;\n font-weight: bold;\n font-style: normal;\n}\n\n.gut-preview.visible {\n display: block;\n}\n\n.gut-modal-actions {\n display: flex;\n gap: 10px;\n justify-content: flex-end;\n margin-top: 20px;\n padding-top: 20px;\n border-top: 1px solid #404040;\n}\n\n.gut-status {\n position: fixed;\n top: 20px;\n right: 20px;\n background: #2e7d32;\n color: #ffffff;\n padding: 12px 20px;\n border-radius: 6px;\n z-index: 10001;\n font-size: 14px;\n font-weight: 500;\n box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);\n border: 1px solid #4caf50;\n animation: slideInRight 0.3s ease-out;\n}\n\n.gut-status.error {\n background: #d32f2f;\n border-color: #f44336;\n}\n\n@keyframes slideInRight {\n from {\n transform: translateX(100%);\n opacity: 0;\n }\n to {\n transform: translateX(0);\n opacity: 1;\n }\n}\n\n.gut-template-list {\n max-height: 400px;\n overflow-y: auto;\n border: 1px solid #404040;\n border-radius: 4px;\n background: #0f0f0f;\n}\n\n.gut-template-item {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 12px 16px;\n border-bottom: 1px solid #404040;\n background: #2a2a2a;\n transition: background-color 0.2s ease;\n}\n\n.gut-template-item:hover {\n background: #333333;\n}\n\n.gut-template-item:last-child {\n border-bottom: none;\n}\n\n.gut-template-name {\n font-weight: 500;\n color: #e0e0e0;\n flex: 1;\n margin-right: 10px;\n}\n\n.gut-template-actions {\n display: flex;\n gap: 8px;\n}\n\n.gut-btn-small {\n padding: 6px 12px;\n font-size: 12px;\n min-width: auto;\n}\n\n.gut-btn-secondary {\n background: #555555;\n color: #ffffff;\n border: 1px solid #555555;\n}\n\n.gut-btn-secondary:hover:not(:disabled) {\n background: #666666;\n border-color: #666666;\n transform: translateY(-1px);\n}\n\n/* Tab styles for modal */\n.gut-modal-tabs {\n display: flex;\n border-bottom: 1px solid #404040;\n margin-bottom: 20px;\n}\n\n.gut-tab-btn {\n padding: 12px 20px;\n background: transparent;\n border: none;\n color: #b0b0b0;\n cursor: pointer;\n font-size: 14px;\n font-weight: 500;\n border-bottom: 2px solid transparent;\n transition: all 0.2s ease;\n height: auto;\n}\n\n.gut-tab-btn:hover {\n color: #e0e0e0;\n background: #2a2a2a;\n}\n\n.gut-tab-btn.active {\n color: #ffffff;\n border-bottom-color: #0d7377;\n}\n\n.gut-tab-content {\n display: none;\n}\n\n.gut-tab-content.active {\n display: block;\n}\n\n/* Keybinding controls styling */\n.gut-keybinding-controls {\n display: flex !important;\n align-items: center !important;\n gap: 10px !important;\n padding: 8px 12px !important;\n background: #2a2a2a !important;\n border: 1px solid #404040 !important;\n border-radius: 4px !important;\n transition: border-color 0.2s ease !important;\n margin: 0 !important;\n}\n\n.gut-keybinding-controls:hover {\n border-color: #0d7377 !important;\n}\n\n/* Checkbox label styling */\n.gut-checkbox-label {\n display: flex !important;\n align-items: center !important;\n gap: 10px !important;\n cursor: pointer !important;\n margin: 0 !important;\n}\n\n.gut-checkbox-label input[type="checkbox"] {\n width: auto !important;\n margin: 0 !important;\n accent-color: #0d7377 !important;\n cursor: pointer !important;\n}\n\n.gut-checkbox-text {\n font-size: 14px !important;\n font-weight: 500 !important;\n color: #b0b0b0 !important;\n user-select: none !important;\n}\n\n.gut-keybinding-text {\n color: #4dd0e1 !important;\n font-family: monospace !important;\n}\n\n.gut-variable-toggle {\n font-size: 11px !important;\n padding: 2px 6px !important;\n white-space: nowrap !important;\n}\n\n/* Scrollbar styling for webkit browsers */\n.gut-field-list::-webkit-scrollbar,\n.gut-modal-content::-webkit-scrollbar {\n width: 8px;\n}\n\n.gut-field-list::-webkit-scrollbar-track,\n.gut-modal-content::-webkit-scrollbar-track {\n background: #0f0f0f;\n border-radius: 4px;\n}\n\n.gut-field-list::-webkit-scrollbar-thumb,\n.gut-modal-content::-webkit-scrollbar-thumb {\n background: #404040;\n border-radius: 4px;\n}\n\n.gut-field-list::-webkit-scrollbar-thumb:hover,\n.gut-modal-content::-webkit-scrollbar-thumb:hover {\n background: #555555;\n}\n\n/* Extracted variables section */\n.gut-extracted-vars {\n border: 1px solid #404040;\n border-radius: 4px;\n background: #0f0f0f;\n padding: 12px;\n min-height: 80px;\n max-height: 300px;\n overflow-y: auto;\n}\n\n.gut-extracted-vars:has(.gut-no-variables) {\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.gut-no-variables {\n color: #666666;\n font-style: italic;\n text-align: center;\n padding: 20px 10px;\n}\n\n.gut-variable-item {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 8px 12px;\n margin-bottom: 6px;\n background: #2a2a2a;\n border: 1px solid #404040;\n border-radius: 4px;\n transition: background-color 0.2s ease;\n}\n\n.gut-variable-item:last-child {\n margin-bottom: 0;\n}\n\n.gut-variable-item:hover {\n background: #333333;\n}\n\n.gut-variable-name {\n font-weight: 500;\n color: #4dd0e1;\n font-family: monospace;\n font-size: 13px;\n}\n\n.gut-variable-value {\n color: #e0e0e0;\n font-size: 12px;\n max-width: 60%;\n word-break: break-all;\n text-align: right;\n}\n\n.gut-variable-value.empty {\n color: #888888;\n font-style: italic;\n}\n\n/* Generic hyperlink style for secondary links */\n.gut-link {\n font-size: 12px !important;\n color: #b0b0b0 !important;\n text-decoration: underline !important;\n text-underline-offset: 2px !important;\n cursor: pointer !important;\n transition: color 0.2s ease !important;\n}\n\n.gut-link:hover {\n color: #4dd0e1 !important;\n}\n\n.gut-variable-toggle {\n font-size: 11px !important;\n padding: 2px 6px !important;\n margin-left: auto !important;\n align-self: flex-start !important;\n white-space: nowrap !important;\n}\n\n#variables-row {\n cursor: pointer;\n color: #b0b0b0;\n transition: color 0.2s ease;\n display: inline-block;\n}\n\n#variables-row:hover {\n color: #4dd0e1;\n}\n\n#mask-validation-warning {\n display: none;\n background: #b63535;\n color: #ffffff;\n padding: 10px 12px;\n border-radius: 4px;\n margin-top: 8px;\n font-size: 13px;\n border: 1px solid #b71c1c;\n}\n\n#mask-validation-warning.visible {\n display: block;\n}\n\n.gut-variable-controls {\n display: none;\n gap: 8px;\n align-items: center;\n flex: 1;\n}\n\n.gut-variable-controls.visible {\n display: flex;\n}\n\n.gut-variable-input {\n flex: 1;\n min-width: 120px;\n}\n\n.gut-match-type {\n min-width: 100px;\n}\n\n.select-static-mode {\n display: block;\n}\n\n.select-static-mode.hidden {\n display: none;\n}\n\n.gut-mask-input-container {\n position: relative;\n width: 100%;\n}\n\n.gut-mask-highlight-overlay {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n padding: 8px 12px;\n border: 1px solid transparent;\n border-radius: 4px;\n font-size: 14px;\n font-family: "Fira Code", monospace;\n color: transparent;\n background: #2a2a2a;\n pointer-events: none;\n overflow: hidden;\n white-space: pre;\n word-wrap: normal;\n box-sizing: border-box;\n line-height: normal;\n letter-spacing: normal;\n word-spacing: normal;\n font-variant-ligatures: none;\n}\n\n.gut-mask-input {\n position: relative;\n z-index: 1;\n background: transparent !important;\n caret-color: #e0e0e0;\n font-family: "Fira Code", monospace;\n font-variant-ligatures: none;\n letter-spacing: normal;\n word-spacing: normal;\n}\n\n.gut-highlight-variable {\n color: transparent;\n background: #2d5a5e;\n padding: 2px 0;\n border-radius: 2px;\n}\n\n.gut-highlight-optional {\n color: transparent;\n background: #4f2d6a;\n padding: 2px 0;\n border-radius: 2px;\n}\n\n.gut-highlight-warning {\n color: transparent;\n background: #4d3419;\n padding: 2px 0;\n border-radius: 2px;\n}\n\n.gut-highlight-error {\n color: transparent;\n background: #963a33;\n padding: 2px 0;\n border-radius: 2px;\n}\n\n.gut-mask-cursor-info {\n font-size: 11px;\n color: #888888;\n margin-top: 4px;\n min-height: 16px;\n font-family: monospace;\n display: none;\n}\n\n.gut-mask-cursor-info:empty {\n display: none;\n}\n\n.gut-mask-status-container {\n display: none;\n margin-top: 8px;\n padding: 8px 12px;\n border-radius: 4px;\n background: #0f0f0f;\n border: 1px solid #404040;\n animation: slideDown 0.2s ease-out;\n}\n\n.gut-mask-status-container.visible {\n display: block;\n}\n\n.gut-status-message {\n font-size: 13px;\n padding: 4px 0;\n line-height: 1.4;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n\n.gut-status-message svg {\n flex-shrink: 0;\n vertical-align: middle;\n}\n\n.gut-status-message:not(:last-child) {\n margin-bottom: 6px;\n padding-bottom: 6px;\n border-bottom: 1px solid #2a2a2a;\n}\n\n.gut-status-error {\n color: #f44336;\n}\n\n.gut-status-warning {\n color: #ff9800;\n}\n\n.gut-status-info {\n color: #888888;\n}\n\n.gut-status-success {\n color: #4caf50;\n}\n\n@keyframes slideDown {\n from {\n opacity: 0;\n transform: translateY(-4px);\n }\n to {\n opacity: 1;\n transform: translateY(0);\n }\n}\n\n.gut-sandbox-results {\n margin-top: 12px;\n padding: 12px;\n background: #0f0f0f;\n border: 1px solid #404040;\n border-radius: 4px;\n max-height: 400px;\n overflow-y: auto;\n}\n\n.gut-sandbox-match,\n.gut-sandbox-no-match {\n padding: 10px;\n margin-bottom: 10px;\n border-radius: 4px;\n border-left: 3px solid;\n}\n\n.gut-sandbox-match {\n background: rgba(76, 175, 80, 0.1);\n border-left-color: #4caf50;\n}\n\n.gut-sandbox-no-match {\n background: rgba(244, 67, 54, 0.1);\n border-left-color: #f44336;\n}\n\n.gut-sandbox-sample-name {\n font-family: "Fira Code", monospace;\n font-size: 13px;\n margin-bottom: 6px;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.gut-sandbox-sample-name svg {\n flex-shrink: 0;\n}\n\n.gut-sandbox-variables {\n margin-top: 8px;\n padding-top: 8px;\n border-top: 1px solid #2a2a2a;\n}\n\n.gut-sandbox-variables-title {\n font-size: 11px;\n font-weight: 500;\n color: #888;\n margin-bottom: 6px;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n\n.gut-sandbox-variable-item {\n font-size: 12px;\n padding: 3px 0;\n display: flex;\n gap: 8px;\n align-items: baseline;\n}\n\n.gut-sandbox-variable-name {\n color: #64b5f6;\n font-family: "Fira Code", monospace;\n}\n\n.gut-sandbox-variable-value {\n color: #a5d6a7;\n font-family: "Fira Code", monospace;\n}\n\n.gut-sandbox-optionals {\n margin-top: 6px;\n font-size: 11px;\n color: #b39ddb;\n}\n'; const firaCodeFont = ` @import url('https://fonts.googleapis.com/css2?family=Fira+Code:[email protected]&display=swap'); `; GM_addStyle(firaCodeFont); GM_addStyle(style); class GGnUploadTemplator { constructor() { try { this.templates = JSON.parse( localStorage.getItem("ggn-upload-templator-templates") || "{}" ); } catch (error) { console.error("Failed to load templates:", error); this.templates = {}; } try { this.selectedTemplate = localStorage.getItem("ggn-upload-templator-selected") || null; } catch (error) { console.error("Failed to load selected template:", error); this.selectedTemplate = null; } try { this.hideUnselectedFields = JSON.parse( localStorage.getItem("ggn-upload-templator-hide-unselected") || "true" ); } catch (error) { console.error("Failed to load hide unselected setting:", error); this.hideUnselectedFields = true; } try { this.config = { ...DEFAULT_CONFIG, ...JSON.parse( localStorage.getItem("ggn-upload-templator-settings") || "{}" ) }; } catch (error) { console.error("Failed to load config:", error); this.config = { ...DEFAULT_CONFIG }; } try { this.sandboxSets = JSON.parse( localStorage.getItem("ggn-upload-templator-sandbox-sets") || "{}" ); } catch (error) { console.error("Failed to load sandbox sets:", error); this.sandboxSets = {}; } try { this.currentSandboxSet = localStorage.getItem("ggn-upload-templator-sandbox-current") || ""; } catch (error) { console.error("Failed to load current sandbox set:", error); this.currentSandboxSet = ""; } logDebug("Initialized core state", { templates: Object.keys(this.templates), selectedTemplate: this.selectedTemplate, hideUnselectedFields: this.hideUnselectedFields, config: this.config }); this.init(); } init() { logDebug("Initializing..."); try { injectUI(this); } catch (error) { console.error("UI injection failed:", error); } try { this.watchFileInputs(); } catch (error) { console.error("File input watching setup failed:", error); } if (this.config.SUBMIT_KEYBINDING) { try { this.setupSubmitKeybinding(); } catch (error) { console.error("Submit keybinding setup failed:", error); } } if (this.config.APPLY_KEYBINDING) { try { this.setupApplyKeybinding(); } catch (error) { console.error("Apply keybinding setup failed:", error); } } logDebug("Initialized"); } // Show template creation modal async showTemplateCreator(editTemplateName = null, editTemplate = null) { await showTemplateCreator(this, editTemplateName, editTemplate); } // Get current variables (mask + comment) async getCurrentVariables() { const commentVariables = {}; const maskVariables = {}; if (this.selectedTemplate && this.selectedTemplate !== "none") { const template = this.templates[this.selectedTemplate]; if (template) { const fileInputs = this.config.TARGET_FORM_SELECTOR ? document.querySelectorAll( `${this.config.TARGET_FORM_SELECTOR} input[type="file"]` ) : document.querySelectorAll('input[type="file"]'); for (const input of fileInputs) { if (input.files && input.files[0] && input.files[0].name.toLowerCase().endsWith(".torrent")) { try { const torrentData = await TorrentUtils.parseTorrentFile( input.files[0] ); Object.assign( commentVariables, TorrentUtils.parseCommentVariables(torrentData.comment) ); const parseResult = parseTemplateWithOptionals( template.mask, torrentData.name ); const { _matchedOptionals, _optionalCount, ...extracted } = parseResult; Object.assign(maskVariables, extracted); break; } catch (error) { console.warn("Could not parse torrent file:", error); } } } } } return { all: { ...commentVariables, ...maskVariables }, comment: commentVariables, mask: maskVariables }; } // Show variables modal async showVariablesModal() { const variables = await this.getCurrentVariables(); showVariablesModal(this, variables.all); } // Update variable count display async updateVariableCount() { const variables = await this.getCurrentVariables(); const commentCount = Object.keys(variables.comment).length; const maskCount = Object.keys(variables.mask).length; const totalCount = commentCount + maskCount; const variablesRow = document.getElementById("variables-row"); if (variablesRow) { if (totalCount === 0) { variablesRow.style.display = "none"; } else { variablesRow.style.display = ""; const parts = []; if (commentCount > 0) { parts.push(`Comment [${commentCount}]`); } if (maskCount > 0) { parts.push(`Mask [${maskCount}]`); } variablesRow.innerHTML = `Available variables: ${parts.join(", ")}`; } } } // Save template from modal saveTemplate(modal, editingTemplateName = null) { const name = modal.querySelector("#template-name").value.trim(); const mask = modal.querySelector("#torrent-mask").value.trim(); if (!name || !mask) { alert("Please provide both template name and torrent mask."); return; } if (editingTemplateName && name !== editingTemplateName && this.templates[name] || !editingTemplateName && this.templates[name]) { if (!confirm(`Template "${name}" already exists. Overwrite?`)) { return; } } const fieldMappings = {}; const variableMatchingConfig = {}; const checkedFields = modal.querySelectorAll( '.gut-field-row input[type="checkbox"]:checked' ); checkedFields.forEach((checkbox) => { const fieldName = checkbox.dataset.field; const templateInput = modal.querySelector( `[data-template="${fieldName}"]` ); if (templateInput) { if (templateInput.type === "checkbox") { fieldMappings[fieldName] = templateInput.checked; } else if (templateInput.tagName.toLowerCase() === "select") { const variableToggle = modal.querySelector( `.gut-variable-toggle[data-field="${fieldName}"]` ); const isVariableMode = variableToggle && variableToggle.dataset.state === "on"; if (isVariableMode) { const variableInput = modal.querySelector( `.gut-variable-input[data-field="${fieldName}"]` ); const matchTypeSelect = modal.querySelector( `.gut-match-type[data-field="${fieldName}"]` ); variableMatchingConfig[fieldName] = { variableName: variableInput ? variableInput.value.trim() : "", matchType: matchTypeSelect ? matchTypeSelect.value : "exact" }; fieldMappings[fieldName] = variableInput ? variableInput.value.trim() : ""; } else { fieldMappings[fieldName] = templateInput.value; } } else { fieldMappings[fieldName] = templateInput.value; } } }); const allFieldRows = modal.querySelectorAll(".gut-field-row"); const customUnselectedFields = []; allFieldRows.forEach((row) => { const checkbox = row.querySelector('input[type="checkbox"]'); if (checkbox) { const fieldName = checkbox.dataset.field; const isDefaultIgnored = this.config.IGNORED_FIELDS_BY_DEFAULT.includes( fieldName.toLowerCase() ); const isCurrentlyChecked = checkbox.checked; if (isDefaultIgnored && isCurrentlyChecked || !isDefaultIgnored && !isCurrentlyChecked) { customUnselectedFields.push({ field: fieldName, selected: isCurrentlyChecked }); } } }); if (editingTemplateName && name !== editingTemplateName) { delete this.templates[editingTemplateName]; if (this.selectedTemplate === editingTemplateName) { this.selectedTemplate = name; localStorage.setItem("ggn-upload-templator-selected", name); } } this.templates[name] = { mask, fieldMappings, customUnselectedFields: customUnselectedFields.length > 0 ? customUnselectedFields : void 0, variableMatching: Object.keys(variableMatchingConfig).length > 0 ? variableMatchingConfig : void 0 }; localStorage.setItem( "ggn-upload-templator-templates", JSON.stringify(this.templates) ); this.updateTemplateSelector(); this.updateVariableCount(); const action = editingTemplateName ? "updated" : "saved"; this.showStatus(`Template "${name}" ${action} successfully!`); document.body.removeChild(modal); } // Update template selector dropdown updateTemplateSelector() { const selector = document.getElementById("template-selector"); if (!selector) return; selector.innerHTML = TEMPLATE_SELECTOR_HTML(this); this.updateEditButtonVisibility(); } // Update edit button visibility based on selected template updateEditButtonVisibility() { const editBtn = document.getElementById("edit-selected-template-btn"); if (!editBtn) return; const shouldShow = this.selectedTemplate && this.selectedTemplate !== "none" && this.templates[this.selectedTemplate]; editBtn.style.display = shouldShow ? "" : "none"; } // Select template selectTemplate(templateName) { this.selectedTemplate = templateName || null; if (templateName) { localStorage.setItem("ggn-upload-templator-selected", templateName); } else { localStorage.removeItem("ggn-upload-templator-selected"); } this.updateEditButtonVisibility(); this.updateVariableCount(); if (templateName === "none") { this.showStatus("No template selected - auto-fill disabled"); } else if (templateName) { this.showStatus(`Template "${templateName}" selected`); this.checkAndApplyToExistingTorrent(templateName); } } // Apply template to form applyTemplate(templateName, torrentName, commentVariables = {}) { const template = this.templates[templateName]; if (!template) return; const extracted = parseTemplateWithOptionals(template.mask, torrentName); let appliedCount = 0; Object.entries(template.fieldMappings).forEach( ([fieldName, valueTemplate]) => { const firstElement = findElementByFieldName(fieldName, this.config); if (firstElement && firstElement.type === "radio") { const formPrefix = this.config.TARGET_FORM_SELECTOR ? `${this.config.TARGET_FORM_SELECTOR} ` : ""; const radioButtons = document.querySelectorAll( `${formPrefix}input[name="${fieldName}"][type="radio"]` ); const newValue = interpolate( String(valueTemplate), extracted, commentVariables ); radioButtons.forEach((radio) => { if (radio.hasAttribute("disabled")) { radio.removeAttribute("disabled"); } const shouldBeChecked = radio.value === newValue; if (shouldBeChecked !== radio.checked) { radio.checked = shouldBeChecked; if (shouldBeChecked) { radio.dispatchEvent(new Event("input", { bubbles: true })); radio.dispatchEvent(new Event("change", { bubbles: true })); appliedCount++; } } }); } else if (firstElement) { if (firstElement.hasAttribute("disabled")) { firstElement.removeAttribute("disabled"); } if (firstElement.type === "checkbox") { let newChecked; if (typeof valueTemplate === "boolean") { newChecked = valueTemplate; } else { const interpolated = interpolate( String(valueTemplate), extracted, commentVariables ); newChecked = /^(true|1|yes|on)$/i.test(interpolated); } if (newChecked !== firstElement.checked) { firstElement.checked = newChecked; firstElement.dispatchEvent(new Event("input", { bubbles: true })); firstElement.dispatchEvent( new Event("change", { bubbles: true }) ); appliedCount++; } } else { const interpolated = interpolate( String(valueTemplate), extracted, commentVariables ); if (firstElement.value !== interpolated) { firstElement.value = interpolated; firstElement.dispatchEvent(new Event("input", { bubbles: true })); firstElement.dispatchEvent( new Event("change", { bubbles: true }) ); appliedCount++; } } } } ); if (appliedCount > 0) { this.showStatus( `Template "${templateName}" applied to ${appliedCount} field(s)` ); } } // Check for existing torrent file and apply template async checkAndApplyToExistingTorrent(templateName) { if (!templateName || templateName === "none") return; const fileInputs = this.config.TARGET_FORM_SELECTOR ? document.querySelectorAll( `${this.config.TARGET_FORM_SELECTOR} input[type="file"]` ) : document.querySelectorAll('input[type="file"]'); for (const input of fileInputs) { if (input.files && input.files[0] && input.files[0].name.toLowerCase().endsWith(".torrent")) { try { const torrentData = await TorrentUtils.parseTorrentFile( input.files[0] ); const commentVariables = TorrentUtils.parseCommentVariables( torrentData.comment ); this.applyTemplate(templateName, torrentData.name, commentVariables); return; } catch (error) { console.warn("Could not parse existing torrent file:", error); } } } } // Watch file inputs for changes (no auto-application) watchFileInputs() { const fileInputs = this.config.TARGET_FORM_SELECTOR ? document.querySelectorAll( `${this.config.TARGET_FORM_SELECTOR} input[type="file"]` ) : document.querySelectorAll('input[type="file"]'); fileInputs.forEach((input) => { input.addEventListener("change", (e) => { if (e.target.files[0] && e.target.files[0].name.toLowerCase().endsWith(".torrent")) { this.showStatus( "Torrent file selected. Click 'Apply Template' to fill form." ); this.updateVariableCount(); } }); }); } // Apply template to the currently selected torrent file async applyTemplateToCurrentTorrent() { if (!this.selectedTemplate || this.selectedTemplate === "none") { this.showStatus("No template selected", "error"); return; } const fileInputs = this.config.TARGET_FORM_SELECTOR ? document.querySelectorAll( `${this.config.TARGET_FORM_SELECTOR} input[type="file"]` ) : document.querySelectorAll('input[type="file"]'); for (const input of fileInputs) { if (input.files && input.files[0] && input.files[0].name.toLowerCase().endsWith(".torrent")) { try { const torrentData = await TorrentUtils.parseTorrentFile( input.files[0] ); const commentVariables = TorrentUtils.parseCommentVariables( torrentData.comment ); this.applyTemplate( this.selectedTemplate, torrentData.name, commentVariables ); return; } catch (error) { console.error("Error processing torrent file:", error); this.showStatus("Error processing torrent file", "error"); } } } this.showStatus("No torrent file selected", "error"); } // Setup global keybinding for form submission setupSubmitKeybinding() { const keybinding = this.config.CUSTOM_SUBMIT_KEYBINDING || "Ctrl+Enter"; const keys = parseKeybinding(keybinding); document.addEventListener("keydown", (e) => { if (matchesKeybinding(e, keys)) { e.preventDefault(); const targetForm = document.querySelector( this.config.TARGET_FORM_SELECTOR ); if (targetForm) { const submitButton = targetForm.querySelector( 'input[type="submit"], button[type="submit"]' ) || targetForm.querySelector( 'input[name*="submit"], button[name*="submit"]' ) || targetForm.querySelector(".submit-btn, #submit-btn"); if (submitButton) { this.showStatus(`Form submitted via ${keybinding}`); submitButton.click(); } else { this.showStatus(`Form submitted via ${keybinding}`); targetForm.submit(); } } } }); } // Setup global keybinding for applying template setupApplyKeybinding() { const keybinding = this.config.CUSTOM_APPLY_KEYBINDING || "Ctrl+Shift+A"; const keys = parseKeybinding(keybinding); document.addEventListener("keydown", (e) => { if (matchesKeybinding(e, keys)) { e.preventDefault(); this.applyTemplateToCurrentTorrent(); } }); } // Show combined template and settings manager modal showTemplateAndSettingsManager() { const modal = document.createElement("div"); modal.className = "gut-modal"; modal.innerHTML = MODAL_HTML(this); document.body.appendChild(modal); modal.querySelectorAll(".gut-tab-btn").forEach((btn) => { btn.addEventListener("click", (e) => { const tabName = e.target.dataset.tab; modal.querySelectorAll(".gut-tab-btn").forEach((b) => b.classList.remove("active")); e.target.classList.add("active"); modal.querySelectorAll(".gut-tab-content").forEach((c) => c.classList.remove("active")); modal.querySelector(`#${tabName}-tab`).classList.add("active"); }); }); const customSelectorsTextarea = modal.querySelector( "#setting-custom-selectors" ); const previewGroup = modal.querySelector("#custom-selectors-preview-group"); const matchedContainer = modal.querySelector("#custom-selectors-matched"); const updateCustomSelectorsPreview = () => { const selectorsText = customSelectorsTextarea.value.trim(); const selectors = selectorsText.split("\n").map((selector) => selector.trim()).filter((selector) => selector); const originalSelectors = this.config.CUSTOM_FIELD_SELECTORS; this.config.CUSTOM_FIELD_SELECTORS = selectors; if (selectors.length === 0) { previewGroup.style.display = "none"; this.config.CUSTOM_FIELD_SELECTORS = originalSelectors; return; } previewGroup.style.display = "block"; let matchedElements = []; const formSelector = modal.querySelector("#setting-form-selector").value.trim() || this.config.TARGET_FORM_SELECTOR; const targetForm = document.querySelector(formSelector); selectors.forEach((selector) => { try { const elements = targetForm ? targetForm.querySelectorAll(selector) : document.querySelectorAll(selector); Array.from(elements).forEach((element) => { const tagName = element.tagName.toLowerCase(); const id = element.id; const name = element.name || element.getAttribute("name"); const classes = element.className || ""; const label = getFieldLabel(element, this.config); const elementId = element.id || element.name || `${tagName}-${Array.from(element.parentNode.children).indexOf(element)}`; if (!matchedElements.find((e) => e.elementId === elementId)) { matchedElements.push({ elementId, element, tagName, id, name, classes, label, selector }); } }); } catch (e) { console.warn(`Invalid custom selector: ${selector}`, e); } }); const matchedElementsLabel = modal.querySelector( "#matched-elements-label" ); if (matchedElements.length === 0) { matchedElementsLabel.textContent = "Matched Elements:"; matchedContainer.innerHTML = '<div class="gut-no-variables">No elements matched by custom selectors.</div>'; } else { matchedElementsLabel.textContent = `Matched Elements (${matchedElements.length}):`; matchedContainer.innerHTML = matchedElements.map((item) => { const displayName = item.label || item.name || item.id || `${item.tagName}`; const displayInfo = [ item.tagName.toUpperCase(), item.id ? `#${item.id}` : "", item.name ? `name="${item.name}"` : "", item.classes ? `.${item.classes.split(" ").filter((c) => c).join(".")}` : "" ].filter((info) => info).join(" "); return ` <div class="gut-variable-item"> <span class="gut-variable-name">${this.escapeHtml(displayName)}</span> <span class="gut-variable-value">${this.escapeHtml(displayInfo)}</span> </div> `; }).join(""); } this.config.CUSTOM_FIELD_SELECTORS = originalSelectors; }; updateCustomSelectorsPreview(); customSelectorsTextarea.addEventListener( "input", updateCustomSelectorsPreview ); modal.querySelector("#setting-form-selector").addEventListener("input", updateCustomSelectorsPreview); modal.querySelector("#ggn-infobox-link")?.addEventListener("click", (e) => { e.preventDefault(); const currentValue = customSelectorsTextarea.value.trim(); const ggnInfoboxSelector = ".infobox-input-holder input"; if (!currentValue.includes(ggnInfoboxSelector)) { const newValue = currentValue ? `${currentValue} ${ggnInfoboxSelector}` : ggnInfoboxSelector; customSelectorsTextarea.value = newValue; updateCustomSelectorsPreview(); } }); modal.querySelector("#save-settings")?.addEventListener("click", () => { this.saveSettings(modal); }); modal.querySelector("#reset-settings")?.addEventListener("click", () => { if (confirm( "Reset all settings to defaults? This will require a page reload." )) { this.resetSettings(modal); } }); modal.querySelector("#delete-all-config")?.addEventListener("click", () => { if (confirm( "\u26A0\uFE0F WARNING: This will permanently delete ALL GGn Upload Templator data including templates, settings, and selected template.\n\nThis action CANNOT be undone!\n\nAre you sure you want to continue?" )) { this.deleteAllConfig(); } }); const sandboxMaskInput = modal.querySelector("#sandbox-mask-input"); const sandboxMaskDisplay = modal.querySelector("#sandbox-mask-display"); const sandboxSampleInput = modal.querySelector("#sandbox-sample-input"); const sandboxResultsContainer = modal.querySelector("#sandbox-results"); const sandboxSetSelect = modal.querySelector("#sandbox-set-select"); const saveBtn = modal.querySelector("#save-sandbox-set"); const renameBtn = modal.querySelector("#rename-sandbox-set"); const deleteBtn = modal.querySelector("#delete-sandbox-set"); const sandboxCursorInfo = modal.querySelector("#sandbox-mask-cursor-info"); const sandboxStatusContainer = modal.querySelector("#sandbox-mask-status"); let sandboxDebounceTimeout = null; let currentLoadedSet = this.currentSandboxSet || ""; const updateButtonStates = () => { if (currentLoadedSet && currentLoadedSet !== "") { saveBtn.textContent = "Update"; renameBtn.style.display = ""; deleteBtn.style.display = ""; } else { saveBtn.textContent = "Save"; renameBtn.style.display = "none"; deleteBtn.style.display = "none"; } }; updateButtonStates(); const updateSandboxTest = () => { const mask = sandboxMaskInput.value; const sampleText = sandboxSampleInput.value.trim(); const samples = sampleText.split("\n").map((s) => s.trim()).filter((s) => s); if (!mask || samples.length === 0) { sandboxResultsContainer.innerHTML = '<div class="gut-no-variables">Enter a mask and sample torrent names to test.</div>'; return; } const result = testMaskAgainstSamples(mask, samples); renderSandboxResults(modal, result); }; const debouncedUpdateSandboxTest = () => { if (sandboxDebounceTimeout) { clearTimeout(sandboxDebounceTimeout); } sandboxDebounceTimeout = setTimeout(updateSandboxTest, 300); }; setupMaskValidation( sandboxMaskInput, sandboxCursorInfo, sandboxStatusContainer, sandboxMaskDisplay, () => { debouncedUpdateSandboxTest(); } ); sandboxMaskInput?.addEventListener("scroll", () => { sandboxMaskDisplay.scrollTop = sandboxMaskInput.scrollTop; sandboxMaskDisplay.scrollLeft = sandboxMaskInput.scrollLeft; }); sandboxSampleInput?.addEventListener("input", debouncedUpdateSandboxTest); sandboxSetSelect?.addEventListener("change", () => { const value = sandboxSetSelect.value; if (!value || value === "") { currentLoadedSet = ""; this.currentSandboxSet = ""; updateButtonStates(); return; } const sets = JSON.parse( localStorage.getItem("ggn-upload-templator-sandbox-sets") || "{}" ); const data = sets[value]; if (data) { sandboxMaskInput.value = data.mask || ""; sandboxSampleInput.value = data.samples || ""; updateMaskHighlighting(sandboxMaskInput, sandboxMaskDisplay); updateSandboxTest(); currentLoadedSet = value; this.currentSandboxSet = value; localStorage.setItem("ggn-upload-templator-sandbox-current", value); updateButtonStates(); } }); saveBtn?.addEventListener("click", () => { if (currentLoadedSet && currentLoadedSet !== "") { const data = { mask: sandboxMaskInput.value, samples: sandboxSampleInput.value }; this.saveSandboxSet(currentLoadedSet, data); this.showStatus(`Test set "${currentLoadedSet}" updated successfully!`); } else { const name = prompt("Enter a name for this test set:"); if (name && name.trim()) { const trimmedName = name.trim(); const data = { mask: sandboxMaskInput.value, samples: sandboxSampleInput.value }; this.saveSandboxSet(trimmedName, data); this.currentSandboxSet = trimmedName; currentLoadedSet = trimmedName; localStorage.setItem("ggn-upload-templator-sandbox-current", trimmedName); const existingOption = sandboxSetSelect.querySelector(`option[value="${trimmedName}"]`); if (existingOption) { existingOption.selected = true; } else { const newOption = document.createElement("option"); newOption.value = trimmedName; newOption.textContent = trimmedName; sandboxSetSelect.appendChild(newOption); newOption.selected = true; } updateButtonStates(); this.showStatus(`Test set "${trimmedName}" saved successfully!`); } } }); deleteBtn?.addEventListener("click", () => { if (!currentLoadedSet || currentLoadedSet === "") { return; } if (confirm(`Delete test set "${currentLoadedSet}"?`)) { this.deleteSandboxSet(currentLoadedSet); const option = sandboxSetSelect.querySelector( `option[value="${currentLoadedSet}"]` ); if (option) { option.remove(); } sandboxSetSelect.value = ""; currentLoadedSet = ""; this.currentSandboxSet = ""; localStorage.setItem("ggn-upload-templator-sandbox-current", ""); sandboxMaskInput.value = ""; sandboxSampleInput.value = ""; sandboxResultsContainer.innerHTML = '<div class="gut-no-variables">Enter a mask and sample torrent names to test.</div>'; updateButtonStates(); this.showStatus(`Test set deleted successfully!`); } }); renameBtn?.addEventListener("click", () => { if (!currentLoadedSet || currentLoadedSet === "") { return; } const newName = prompt(`Rename test set "${currentLoadedSet}" to:`, currentLoadedSet); if (!newName || !newName.trim() || newName.trim() === currentLoadedSet) { return; } const trimmedName = newName.trim(); if (this.sandboxSets[trimmedName]) { alert(`A test set named "${trimmedName}" already exists.`); return; } const data = this.sandboxSets[currentLoadedSet]; this.sandboxSets[trimmedName] = data; delete this.sandboxSets[currentLoadedSet]; localStorage.setItem( "ggn-upload-templator-sandbox-sets", JSON.stringify(this.sandboxSets) ); const option = sandboxSetSelect.querySelector( `option[value="${currentLoadedSet}"]` ); if (option) { option.value = trimmedName; option.textContent = trimmedName; option.selected = true; } currentLoadedSet = trimmedName; this.currentSandboxSet = trimmedName; localStorage.setItem("ggn-upload-templator-sandbox-current", trimmedName); this.showStatus(`Test set renamed to "${trimmedName}" successfully!`); }); const resetFieldsLink = modal.querySelector("#reset-sandbox-fields"); resetFieldsLink?.addEventListener("click", (e) => { e.preventDefault(); sandboxMaskInput.value = ""; sandboxSampleInput.value = ""; sandboxResultsContainer.innerHTML = '<div class="gut-no-variables">Enter a mask and sample names to see match results.</div>'; const resultsLabel = modal.querySelector("#sandbox-results-label"); if (resultsLabel) { resultsLabel.textContent = "Match Results:"; } updateMaskHighlighting(sandboxMaskInput, sandboxMaskDisplay); }); if (sandboxMaskInput && currentLoadedSet && currentLoadedSet !== "") { const sets = JSON.parse( localStorage.getItem("ggn-upload-templator-sandbox-sets") || "{}" ); const data = sets[currentLoadedSet]; if (data) { sandboxMaskInput.value = data.mask || ""; sandboxSampleInput.value = data.samples || ""; updateMaskHighlighting(sandboxMaskInput, sandboxMaskDisplay); updateSandboxTest(); } } else if (sandboxMaskInput) { updateMaskHighlighting(sandboxMaskInput, sandboxMaskDisplay); if (sandboxMaskInput.value && sandboxSampleInput.value) { updateSandboxTest(); } } let isRecording = false; const setupRecordKeybindingHandler = (inputSelector, keybindingSpanIndex, recordBtnSelector) => { modal.querySelector(recordBtnSelector)?.addEventListener("click", () => { const input = modal.querySelector(inputSelector); const keybindingSpans = modal.querySelectorAll(".gut-keybinding-text"); const keybindingSpan = keybindingSpans[keybindingSpanIndex]; const recordBtn = modal.querySelector(recordBtnSelector); recordBtn.textContent = "Press keys..."; recordBtn.disabled = true; isRecording = true; const handleKeydown = (e) => { e.preventDefault(); const isModifierKey = ["Control", "Alt", "Shift", "Meta"].includes( e.key ); if (e.key === "Escape") { recordBtn.textContent = "Record"; recordBtn.disabled = false; isRecording = false; document.removeEventListener("keydown", handleKeydown); return; } if (!isModifierKey) { const keybinding = buildKeybindingFromEvent(e); input.value = keybinding; if (keybindingSpan) { keybindingSpan.textContent = keybinding; } recordBtn.textContent = "Record"; recordBtn.disabled = false; isRecording = false; document.removeEventListener("keydown", handleKeydown); } }; document.addEventListener("keydown", handleKeydown); }); }; setupRecordKeybindingHandler( "#custom-submit-keybinding-input", 0, "#record-submit-keybinding-btn" ); setupRecordKeybindingHandler( "#custom-apply-keybinding-input", 1, "#record-apply-keybinding-btn" ); modal.addEventListener("click", (e) => { if (e.target === modal) { document.body.removeChild(modal); return; } const action = e.target.dataset.action; const templateName = e.target.dataset.template; if (action && templateName) { switch (action) { case "edit": document.body.removeChild(modal); this.editTemplate(templateName); break; case "clone": this.cloneTemplate(templateName); this.refreshTemplateManager(modal); break; case "delete": if (confirm(`Delete template "${templateName}"?`)) { this.deleteTemplate(templateName); this.refreshTemplateManager(modal); } break; } } }); modal.querySelector("#close-manager").addEventListener("click", () => { document.body.removeChild(modal); }); const handleEscKey = (e) => { if (e.key === "Escape" && document.body.contains(modal) && !isRecording) { document.body.removeChild(modal); document.removeEventListener("keydown", handleEscKey); } }; document.addEventListener("keydown", handleEscKey); } // Save settings from modal saveSettings(modal) { const formSelector = modal.querySelector("#setting-form-selector").value.trim(); const submitKeybinding = modal.querySelector( "#setting-submit-keybinding" ).checked; const customSubmitKeybinding = modal.querySelector("#custom-submit-keybinding-input").value.trim(); const applyKeybinding = modal.querySelector( "#setting-apply-keybinding" ).checked; const customApplyKeybinding = modal.querySelector("#custom-apply-keybinding-input").value.trim(); const customSelectorsText = modal.querySelector("#setting-custom-selectors").value.trim(); const customSelectors = customSelectorsText.split("\n").map((selector) => selector.trim()).filter((selector) => selector); const ignoredFieldsText = modal.querySelector("#setting-ignored-fields").value.trim(); const ignoredFields = ignoredFieldsText.split("\n").map((field) => field.trim()).filter((field) => field); this.config = { TARGET_FORM_SELECTOR: formSelector || DEFAULT_CONFIG.TARGET_FORM_SELECTOR, SUBMIT_KEYBINDING: submitKeybinding, CUSTOM_SUBMIT_KEYBINDING: customSubmitKeybinding || DEFAULT_CONFIG.CUSTOM_SUBMIT_KEYBINDING, APPLY_KEYBINDING: applyKeybinding, CUSTOM_APPLY_KEYBINDING: customApplyKeybinding || DEFAULT_CONFIG.CUSTOM_APPLY_KEYBINDING, CUSTOM_FIELD_SELECTORS: customSelectors.length > 0 ? customSelectors : DEFAULT_CONFIG.CUSTOM_FIELD_SELECTORS, IGNORED_FIELDS_BY_DEFAULT: ignoredFields.length > 0 ? ignoredFields : DEFAULT_CONFIG.IGNORED_FIELDS_BY_DEFAULT }; localStorage.setItem( "ggn-upload-templator-settings", JSON.stringify(this.config) ); this.showStatus( "Settings saved successfully! Reload the page for some changes to take effect." ); } // Reset settings to defaults resetSettings(modal) { localStorage.removeItem("ggn-upload-templator-settings"); this.config = { ...DEFAULT_CONFIG }; modal.querySelector("#setting-form-selector").value = this.config.TARGET_FORM_SELECTOR; modal.querySelector("#setting-submit-keybinding").checked = this.config.SUBMIT_KEYBINDING; modal.querySelector("#custom-submit-keybinding-input").value = this.config.CUSTOM_SUBMIT_KEYBINDING; modal.querySelector("#setting-apply-keybinding").checked = this.config.APPLY_KEYBINDING; modal.querySelector("#custom-apply-keybinding-input").value = this.config.CUSTOM_APPLY_KEYBINDING; modal.querySelector("#setting-custom-selectors").value = this.config.CUSTOM_FIELD_SELECTORS.join("\n"); modal.querySelector("#setting-ignored-fields").value = this.config.IGNORED_FIELDS_BY_DEFAULT.join("\n"); const submitKeybindingSpan = modal.querySelector(".gut-keybinding-text"); submitKeybindingSpan.textContent = this.config.CUSTOM_SUBMIT_KEYBINDING; const applyKeybindingSpans = modal.querySelectorAll(".gut-keybinding-text"); if (applyKeybindingSpans.length > 1) { applyKeybindingSpans[1].textContent = this.config.CUSTOM_APPLY_KEYBINDING; } this.showStatus( "Settings reset to defaults! Reload the page for changes to take effect." ); } // Delete all local configuration deleteAllConfig() { localStorage.removeItem("ggn-upload-templator-templates"); localStorage.removeItem("ggn-upload-templator-selected"); localStorage.removeItem("ggn-upload-templator-hide-unselected"); localStorage.removeItem("ggn-upload-templator-settings"); this.templates = {}; this.selectedTemplate = null; this.hideUnselectedFields = true; this.config = { ...DEFAULT_CONFIG }; this.updateTemplateSelector(); this.showStatus( "All local configuration deleted! Reload the page for changes to take full effect.", "success" ); } // Delete template by name deleteTemplate(templateName) { delete this.templates[templateName]; localStorage.setItem( "ggn-upload-templator-templates", JSON.stringify(this.templates) ); if (this.selectedTemplate === templateName) { this.selectedTemplate = null; localStorage.removeItem("ggn-upload-templator-selected"); } this.updateTemplateSelector(); this.showStatus(`Template "${templateName}" deleted`); } // Clone template cloneTemplate(templateName) { const originalTemplate = this.templates[templateName]; if (!originalTemplate) return; const cloneName = `${templateName} (Clone)`; this.templates[cloneName] = { mask: originalTemplate.mask, fieldMappings: { ...originalTemplate.fieldMappings }, customUnselectedFields: originalTemplate.customUnselectedFields ? [...originalTemplate.customUnselectedFields] : void 0, variableMatching: originalTemplate.variableMatching ? { ...originalTemplate.variableMatching } : void 0 }; localStorage.setItem( "ggn-upload-templator-templates", JSON.stringify(this.templates) ); this.updateTemplateSelector(); this.showStatus(`Template "${cloneName}" created`); } // Edit template editTemplate(templateName) { const template = this.templates[templateName]; if (!template) return; this.showTemplateCreator(templateName, template); } // Refresh template manager modal content refreshTemplateManager(modal) { const templateList = modal.querySelector(".gut-template-list"); if (!templateList) return; templateList.innerHTML = TEMPLATE_LIST_HTML(this); } // Show status message showStatus(message, type = "success") { const existing = document.querySelector(".gut-status"); if (existing) existing.remove(); const status = document.createElement("div"); status.className = "gut-status"; status.textContent = message; if (type === "error") { status.classList.add("error"); } document.body.appendChild(status); setTimeout(() => { if (status.parentNode) { status.parentNode.removeChild(status); } }, 3e3); } // Escape HTML to prevent XSS escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } saveSandboxSet(name, data) { this.sandboxSets[name] = data; localStorage.setItem( "ggn-upload-templator-sandbox-sets", JSON.stringify(this.sandboxSets) ); } deleteSandboxSet(name) { delete this.sandboxSets[name]; localStorage.setItem( "ggn-upload-templator-sandbox-sets", JSON.stringify(this.sandboxSets) ); if (this.currentSandboxSet === name) { this.currentSandboxSet = ""; localStorage.setItem("ggn-upload-templator-sandbox-current", ""); } } showSandboxWithMask(mask, sample) { this.showTemplateAndSettingsManager(); setTimeout(() => { const modal = document.querySelector(".gut-modal"); if (!modal) return; const sandboxTabBtn = modal.querySelector('[data-tab="sandbox"]'); if (sandboxTabBtn) { sandboxTabBtn.click(); } setTimeout(() => { const sandboxMaskInput = modal.querySelector("#sandbox-mask-input"); const sandboxMaskDisplay = modal.querySelector("#sandbox-mask-display"); const sandboxSampleInput = modal.querySelector("#sandbox-sample-input"); if (sandboxMaskInput && sandboxSampleInput) { sandboxMaskInput.value = mask; sandboxSampleInput.value = sample; updateMaskHighlighting(sandboxMaskInput, sandboxMaskDisplay); sandboxMaskInput.dispatchEvent(new Event("input", { bubbles: true })); } }, 50); }, 50); } } logDebug("Script loaded (readyState:", document.readyState, ")"); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { logDebug("Initializing after DOMContentLoaded"); try { new GGnUploadTemplator(); } catch (error) { console.error("Failed to initialize:", error); } }); } else { logDebug("Initializing immediately (DOM already ready)"); try { new GGnUploadTemplator(); } catch (error) { console.error("Failed to initialize:", error); } } })();