您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Auto-fill upload forms using torrent file data with configurable templates
// ==UserScript== // @name GGn Upload Templator // @namespace https://greasyfork.org/ // @version 0.14 // @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_setValue // @grant GM_getValue // @grant GM_addStyle // @grant unsafeWindow // ==/UserScript== (function() { "use strict"; const version = "0.14"; const DEFAULT_CONFIG = { TARGET_FORM_SELECTOR: "#upload_table", SUBMIT_KEYBINDING: true, CUSTOM_SUBMIT_KEYBINDING: "Ctrl+Enter", APPLY_KEYBINDING: true, CUSTOM_APPLY_KEYBINDING: "Alt+A", HELP_KEYBINDING: true, CUSTOM_HELP_KEYBINDING: "Shift+?", 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 [torrent2] = TorrentUtils.decodeBencode(data); return { name: torrent2.info?.name || file.name, comment: torrent2.comment || "", files: torrent2.info?.files?.map((f) => ({ path: f.path.join("/"), length: f.length })) || [ { path: torrent2.info?.name || file.name, length: torrent2.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"); } } const torrent = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, TorrentUtils }, Symbol.toStringTag, { value: "Module" })); const MAX_VARIABLE_NAME_LENGTH = 50; function parseVariableWithHint(varString) { const colonIndex = varString.indexOf(":"); if (colonIndex === -1) { return { varName: varString, hint: null }; } return { varName: varString.substring(0, colonIndex), hint: varString.substring(colonIndex + 1) }; } function parseHint(hintString, availableHints = {}) { if (!hintString) { return { type: "none", data: null }; } if (hintString.startsWith("/")) { const regexPattern = hintString.slice(1).replace(/\/$/, ""); return { type: "regex", data: regexPattern }; } if (/[*#@?]/.test(hintString)) { return { type: "pattern", data: hintString }; } const namedHint = availableHints[hintString]; if (namedHint) { return { type: namedHint.type, data: namedHint }; } return { type: "unknown", data: hintString }; } function compileHintToRegex(hint, availableHints = {}) { const parsed = parseHint(hint, availableHints); switch (parsed.type) { case "regex": return typeof parsed.data === "string" ? parsed.data : parsed.data.pattern; case "pattern": return compileSimplePattern(parsed.data); case "map": const mappings = typeof parsed.data === "object" && parsed.data.mappings ? parsed.data.mappings : parsed.data; const keys = Object.keys(mappings || {}); if (keys.length === 0) return ".+"; const escapedKeys = keys.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); return `(?:${escapedKeys.join("|")})`; case "unknown": case "none": default: return null; } } function escapeSpecialChars(text) { return text.replace(/\\\$/g, "___ESCAPED_DOLLAR___").replace(/\\\{/g, "___ESCAPED_LBRACE___").replace(/\\\}/g, "___ESCAPED_RBRACE___").replace(/\\\\/g, "___ESCAPED_BACKSLASH___"); } function unescapeSpecialChars(text) { return text.replace(/___ESCAPED_DOLLAR___/g, "\\$").replace(/___ESCAPED_LBRACE___/g, "\\{").replace(/___ESCAPED_RBRACE___/g, "\\}").replace(/___ESCAPED_BACKSLASH___/g, "\\\\"); } function extractVariablePlaceholders(text, startIndex = 0) { const variablePlaceholders = []; let placeholderIndex = startIndex; const result = text.replace(/\$\{([^}]+)\}/g, (match, varString) => { const placeholder = `___VAR_PLACEHOLDER_${placeholderIndex}___`; variablePlaceholders.push({ placeholder, varString, match }); placeholderIndex++; return placeholder; }); return { result, variablePlaceholders }; } function compileSimplePattern(pattern) { let regex = ""; let i = 0; while (i < pattern.length) { const char = pattern[i]; const nextChar = pattern[i + 1]; if (char === "*") { regex += ".*?"; i++; } else if (char === "#") { if (nextChar === "+") { regex += "\\d+"; i += 2; } else { let count = 1; while (pattern[i + count] === "#") { count++; } if (count > 1) { regex += `\\d{${count}}`; i += count; } else { regex += "\\d"; i++; } } } else if (char === "@") { if (nextChar === "+") { regex += "[a-zA-Z]+"; i += 2; } else { let count = 1; while (pattern[i + count] === "@") { count++; } if (count > 1) { regex += `[a-zA-Z]{${count}}`; i += count; } else { regex += "[a-zA-Z]"; i++; } } } else if (char === "?") { regex += "."; i++; } else { regex += char.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); i++; } } return regex; } function determineCaptureGroup(varName, hint, isLastVariable, afterPlaceholder, availableHints = {}) { const hintPattern = hint ? compileHintToRegex(hint, availableHints) : null; if (hintPattern) { return `(?<${varName}>${hintPattern})`; } if (isLastVariable || !afterPlaceholder) { return `(?<${varName}>.+)`; } const nextTwoChars = afterPlaceholder.substring(0, 2); const nextChar = nextTwoChars[0]; if (nextChar === " ") { const afterSpace = afterPlaceholder.substring(1); const boundaryMatch = afterSpace.match(/^(\\?.)/); const boundaryChar = boundaryMatch ? boundaryMatch[1] : null; if (boundaryChar && boundaryChar.startsWith("\\") && boundaryChar.length === 2) { const actualChar = boundaryChar[1]; if (actualChar === "]") { return `(?<${varName}>[^\\]]+)`; } return `(?<${varName}>[^\\${actualChar}]+)`; } if (boundaryChar) { return `(?<${varName}>[^${boundaryChar}]+)`; } return `(?<${varName}>[^ ]+)`; } if (nextTwoChars.startsWith("\\") && nextTwoChars.length >= 2) { const escapedChar = nextTwoChars[1]; if (escapedChar === "]") { return `(?<${varName}>[^\\]]+)`; } if (escapedChar === "[" || escapedChar === "(" || escapedChar === ")" || escapedChar === "." || escapedChar === "-" || escapedChar === "_") { return `(?<${varName}>[^\\${escapedChar}]+)`; } } return `(?<${varName}>.+?)`; } function determineCaptureGroupWithOptionals(varName, hint, isLastVariable, afterPlaceholder, availableHints = {}) { const hintPattern = hint ? compileHintToRegex(hint, availableHints) : null; if (hintPattern) { return `(?<${varName}>${hintPattern})`; } if (isLastVariable || !afterPlaceholder) { return `(?<${varName}>.+)`; } const nextFourChars = afterPlaceholder.substring(0, 4); const nextTwoChars = afterPlaceholder.substring(0, 2); const atEndOfOptional = nextTwoChars === ")?"; if (atEndOfOptional) { const afterOptional = afterPlaceholder.substring(2); if (afterOptional.startsWith("(?:")) { const nextOptionalMatch = afterOptional.match(/^\(\?:\(\?<_opt\d+>\)(.+?)\)\?/); if (nextOptionalMatch) { const nextOptionalContent = nextOptionalMatch[1]; const literalMatch = nextOptionalContent.match(/^([^_]+?)___VAR/); const firstLiteral = literalMatch ? literalMatch[1] : nextOptionalContent; if (firstLiteral && firstLiteral.trim()) { const escapedLiteral = firstLiteral.replace(/\\/g, "\\"); return `(?<${varName}>(?:(?!${escapedLiteral}).)+)`; } } } return `(?<${varName}>.+)`; } if (nextFourChars.startsWith("(?:")) { const boundaries = []; let remaining = afterPlaceholder; while (remaining.startsWith("(?:")) { const optionalMatch = remaining.match(/^\(\?:\(\?<_opt\d+>\)(.+?)\)\?/); if (optionalMatch) { const optionalContent = optionalMatch[1]; const literalMatch = optionalContent.match(/^([^_]+?)___VAR/); const firstLiteral = literalMatch ? literalMatch[1] : optionalContent.substring(0, 10); if (firstLiteral && firstLiteral.trim()) { boundaries.push(firstLiteral.replace(/\\/g, "\\")); } remaining = remaining.substring(optionalMatch[0].length); } else { break; } } if (boundaries.length > 0) { const lookaheads = boundaries.map((b) => `(?!${b})`).join(""); return `(?<${varName}>(?:${lookaheads}.)+)`; } return `(?<${varName}>.+?)`; } return determineCaptureGroup(varName, hint, false, afterPlaceholder, availableHints); } function applyValueMap(variables, mask, availableHints = {}) { const mapped = {}; const varPattern = /\$\{([^}]+)\}/g; let match; while ((match = varPattern.exec(mask)) !== null) { const { varName, hint } = parseVariableWithHint(match[1]); if (hint && variables[varName] !== void 0) { const parsed = parseHint(hint, availableHints); if (parsed.type === "map" && parsed.data.mappings) { const mappedValue = parsed.data.mappings[variables[varName]]; if (mappedValue !== void 0) { mapped[varName] = mappedValue; } else if (parsed.data.strict === false) { mapped[varName] = variables[varName]; } } else { mapped[varName] = variables[varName]; } } else if (variables[varName] !== void 0) { mapped[varName] = variables[varName]; } } return mapped; } function compileMaskToRegexPattern(mask, useNonGreedy = true, availableHints = {}) { let regexPattern = escapeSpecialChars(mask); const { result, variablePlaceholders } = extractVariablePlaceholders(regexPattern); regexPattern = result; regexPattern = regexPattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); for (let i = 0; i < variablePlaceholders.length; i++) { const { placeholder, varString } = variablePlaceholders[i]; const { varName, hint } = parseVariableWithHint(varString); const isLastVariable = i === variablePlaceholders.length - 1; const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const placeholderPos = regexPattern.indexOf(escapedPlaceholder); const afterPlaceholder = regexPattern.substring(placeholderPos + escapedPlaceholder.length); const captureGroup = determineCaptureGroup(varName, hint, isLastVariable, afterPlaceholder, availableHints); regexPattern = regexPattern.replace(escapedPlaceholder, captureGroup); } regexPattern = unescapeSpecialChars(regexPattern); return regexPattern; } function validateMaskWithDetails(mask, availableHints = {}) { 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 fullVarString = match[1]; const { varName, hint } = parseVariableWithHint(fullVarString); const position = match.index; if (fullVarString !== fullVarString.trim()) { warnings.push({ type: "warning", message: `Variable "\${${fullVarString}}" 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 (hint) { const parsed = parseHint(hint, availableHints); if (parsed.type === "unknown") { warnings.push({ type: "warning", message: `Unknown hint "${hint}" for variable "\${${varName}}" - will be treated as literal pattern`, position }); } else if (parsed.type === "regex") { try { new RegExp(parsed.data); } catch (e) { errors.push({ type: "error", message: `Invalid regex pattern in hint for "\${${varName}}": ${e.message}`, position, rangeEnd: position + match[0].length }); } } } if (/^\d/.test(varName)) { warnings.push({ type: "warning", message: `Variable "\${${varName}}" starts with a number (potentially confusing)`, position }); } if (varName.length > MAX_VARIABLE_NAME_LENGTH) { 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 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() === "" && current === "") { 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 }); } return { parts, optionalCount }; } function parseTemplateWithOptionals(mask, torrentName, availableHints = {}) { if (!mask || !torrentName) return {}; try { const parsed = parseMaskStructure(mask); const regexPattern = compileUserMaskToRegex(mask, availableHints); const regex = new RegExp(regexPattern, "i"); const match = torrentName.match(regex); if (!match) return {}; const extracted = match.groups || {}; const matchedOptionals = []; if (parsed.optionalCount > 0) { for (let i = 0; i < parsed.optionalCount; i++) { const markerKey = `_opt${i}`; matchedOptionals.push(extracted[markerKey] !== void 0); delete extracted[markerKey]; } } const result = applyValueMap(extracted, mask, availableHints); if (parsed.optionalCount > 0) { result._matchedOptionals = matchedOptionals; result._optionalCount = parsed.optionalCount; } return result; } catch (e) { console.warn("Invalid template with optionals:", e); return {}; } } function compileUserMaskToRegex(mask, availableHints = {}) { if (!mask) return ""; try { const parsed = parseMaskStructure(mask); if (parsed.optionalCount === 0) { return compileMaskToRegexPattern(mask, true, availableHints); } const regexPattern = compileMaskToRegexPatternWithOptionals(parsed, availableHints); return regexPattern; } catch (e) { return compileMaskToRegexPattern(mask, true, availableHints); } } function compileMaskToRegexPatternWithOptionals(parsed, availableHints = {}) { const parts = parsed.parts; const processedParts = []; let placeholderIndex = 0; for (const part of parts) { const escapedContent = escapeSpecialChars(part.content); const { result, variablePlaceholders } = extractVariablePlaceholders(escapedContent, placeholderIndex); placeholderIndex += variablePlaceholders.length; const finalContent = result.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); processedParts.push({ type: part.type, content: finalContent, variablePlaceholders }); } let regexPattern = ""; let optionalIndex = 0; for (const part of processedParts) { if (part.type === "optional") { regexPattern += `(?:(?<_opt${optionalIndex}>)${part.content})?`; optionalIndex++; } else { regexPattern += part.content; } } const allVariablePlaceholders = processedParts.flatMap((p) => p.variablePlaceholders); for (let i = 0; i < allVariablePlaceholders.length; i++) { const { placeholder, varString } = allVariablePlaceholders[i]; const { varName, hint } = parseVariableWithHint(varString); const isLastVariable = i === allVariablePlaceholders.length - 1; const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const placeholderPos = regexPattern.indexOf(escapedPlaceholder); const afterPlaceholder = regexPattern.substring(placeholderPos + escapedPlaceholder.length); const captureGroup = determineCaptureGroupWithOptionals(varName, hint, isLastVariable, afterPlaceholder, availableHints); regexPattern = regexPattern.replace(escapedPlaceholder, captureGroup); } regexPattern = unescapeSpecialChars(regexPattern); return regexPattern; } function testMaskAgainstSamples(mask, sampleNames, availableHints = {}) { const validation = validateMaskWithDetails(mask, availableHints); 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, availableHints); const 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 }; } catch (e) { return { name, matched: false, variables: {}, positions: {}, error: e.message }; } }) }; } function updateMaskHighlighting(maskInput, overlayDiv, availableHints = {}) { 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, availableHints); overlayDiv.innerHTML = highlightedHTML; overlayDiv.scrollTop = maskInput.scrollTop; overlayDiv.scrollLeft = maskInput.scrollLeft; } function buildLayeredHighlighting(text, optionalBlocks, varMatches, nestedOptionalErrors, availableHints = {}) { 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 html2 = escapeHtml$2(content); if (segment.inNestedError) { if (segment.inOptional) { html2 = `<span class="gut-highlight-optional"><span class="gut-highlight-error">${html2}</span></span>`; } else { html2 = `<span class="gut-highlight-error">${html2}</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"; } const hintData = getHintDataAttributes(varName, availableHints); if (segment.inOptional) { html2 = `<span class="gut-highlight-optional"><span class="${varClass}"${hintData}>${html2}</span></span>`; } else { html2 = `<span class="${varClass}"${hintData}>${html2}</span>`; } } else if (segment.inOptional) { html2 = `<span class="gut-highlight-optional">${html2}</span>`; } result += html2; } 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 html2 = 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$2(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 = html2 + remainingHtml; } else { container.innerHTML = html2; } container.classList.add("visible"); } function getHintDataAttributes(varString, availableHints = {}) { if (!varString || !varString.includes(":")) { return ""; } const colonIndex = varString.indexOf(":"); const hint = varString.substring(colonIndex + 1); if (!hint) return ""; let hintType = ""; let hintPattern = ""; if (hint.startsWith("/")) { hintType = "regex"; hintPattern = hint.slice(1).replace(/\/$/, ""); } else if (/[*#@?]/.test(hint)) { hintType = "pattern"; hintPattern = hint; } else if (availableHints[hint]) { const namedHint = availableHints[hint]; hintType = namedHint.type; if (namedHint.type === "pattern") { hintPattern = namedHint.pattern; } else if (namedHint.type === "regex") { hintPattern = namedHint.pattern; } else if (namedHint.type === "map" && namedHint.mappings) { hintPattern = `${Object.keys(namedHint.mappings).length} values`; } } if (hintType && hintPattern) { const escapedType = escapeHtml$2(hintType); const escapedPattern = escapeHtml$2(hintPattern); return ` data-hint-type="${escapedType}" data-hint-pattern="${escapedPattern}"`; } return ""; } function escapeHtml$2(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } function html(strings, ...values) { return strings.reduce((result, str, i) => { const value = values[i]; if (value === void 0) return result + str; if (value && typeof value === "object" && value.__html !== void 0) { return result + str + value.__html; } if (typeof value === "string") { return result + str + escapeHtml$1(value); } if (typeof value === "boolean") { return result + str + (value ? "true" : "false"); } if (typeof value === "number") { return result + str + String(value); } return result + str + String(value); }, ""); } function raw(htmlString) { return { __html: htmlString, toString: () => htmlString }; } function escapeHtml$1(str) { if (!str) return ""; return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'"); } function map(array, fn) { if (!array || !Array.isArray(array)) return raw(""); return raw(array.map(fn).join("")); } function when(condition, truthyValue, falsyValue = "") { return condition ? truthyValue : falsyValue; } const TEMPLATE_LIST_HTML = (instance) => Object.keys(instance.templates).length === 0 ? html`<div style="padding: 20px; text-align: center; color: #888;">No templates found. Close this dialog and create a template first.</div>` : html`<div class="gut-template-list"> ${map(Object.keys(instance.templates), (name) => html` <div class="gut-template-item"> <span class="gut-template-name">${name}</span> <div class="gut-template-actions"> <button class="gut-btn gut-btn-secondary gut-btn-small" data-action="edit" data-template="${name}">Edit</button> <button class="gut-btn gut-btn-secondary gut-btn-small" data-action="clone" data-template="${name}">Clone</button> <button class="gut-btn gut-btn-danger gut-btn-small" data-action="delete" data-template="${name}">Delete</button> </div> </div> `)} </div>`; const DEFAULT_HINTS = { number: { type: "pattern", pattern: "#+", description: "Digits only" }, alpha: { type: "pattern", pattern: "@+", description: "Letters only" }, beta: { type: "pattern", pattern: "@+", description: "Letters only" }, alnum: { type: "pattern", pattern: "*", description: "Alphanumeric characters" }, version: { type: "regex", pattern: "v\\d+(?:\\.\\d+)*", description: 'Version numbers starting with "v" (e.g., v1, v2.0)' }, date_ymd_dots: { type: "pattern", pattern: "####.##.##", description: "Date in YYYY.MM.DD format" }, date_ymd_dashes: { type: "pattern", pattern: "####-##-##", description: "Date in YYYY-MM-DD format" }, date_dmy_dots: { type: "pattern", pattern: "##.##.####", description: "Date in DD.MM.YYYY format" }, date_dmy_dashes: { type: "pattern", pattern: "##-##-####", description: "Date in DD-MM-YYYY format" }, date_mdy_dots: { type: "pattern", pattern: "##.##.####", description: "Date in MM.DD.YYYY format" }, date_mdy_dashes: { type: "pattern", pattern: "##-##-####", description: "Date in MM-DD-YYYY format" }, lang_codes: { type: "map", description: "Common language codes to full names", strict: false, mappings: { "en-US": "English", "en-GB": "English", en: "English", "fr-FR": "French", fr: "French", "de-DE": "German", de: "German", "es-ES": "Spanish", es: "Spanish", "it-IT": "Italian", it: "Italian", "ja-JP": "Japanese", ja: "Japanese", "zh-CN": "Chinese", zh: "Chinese", "ko-KR": "Korean", ko: "Korean", "pt-BR": "Portuguese", pt: "Portuguese", "ru-RU": "Russian", ru: "Russian", ar: "Arabic", nl: "Dutch", pl: "Polish", sv: "Swedish", no: "Norwegian", da: "Danish", fi: "Finnish", tr: "Turkish", el: "Greek", he: "Hebrew", th: "Thai", vi: "Vietnamese", id: "Indonesian", ms: "Malay", hi: "Hindi" } } }; function loadHints() { try { const stored = GM_getValue("hints", null); return stored ? JSON.parse(stored) : { ...DEFAULT_HINTS }; } catch (e) { console.error("Failed to load hints:", e); return { ...DEFAULT_HINTS }; } } function saveHints(hints) { try { GM_setValue("hints", JSON.stringify(hints)); return true; } catch (e) { console.error("Failed to save hints:", e); return false; } } function resetAllHints() { try { GM_setValue("hints", JSON.stringify({ ...DEFAULT_HINTS })); return true; } catch (e) { console.error("Failed to reset hints:", e); return false; } } function isDefaultHint(name) { return !!DEFAULT_HINTS[name]; } function loadIgnoredHints() { try { const stored = GM_getValue("ignoredHints", null); return stored ? JSON.parse(stored) : []; } catch (e) { console.error("Failed to load ignored hints:", e); return []; } } function saveIgnoredHints(ignoredHints) { try { GM_setValue("ignoredHints", JSON.stringify(ignoredHints)); return true; } catch (e) { console.error("Failed to save ignored hints:", e); return false; } } function addToIgnoredHints(hintName) { const ignoredHints = loadIgnoredHints(); if (!ignoredHints.includes(hintName)) { ignoredHints.push(hintName); return saveIgnoredHints(ignoredHints); } return true; } function removeFromIgnoredHints(hintName) { const ignoredHints = loadIgnoredHints(); const filtered = ignoredHints.filter((name) => name !== hintName); return saveIgnoredHints(filtered); } function isHintIgnored(hintName) { const ignoredHints = loadIgnoredHints(); return ignoredHints.includes(hintName); } function loadDeletedDefaultHints() { try { const stored = GM_getValue("deletedDefaultHints", null); return stored ? JSON.parse(stored) : []; } catch (e) { console.error("Failed to load deleted default hints:", e); return []; } } function saveDeletedDefaultHints(deletedHints) { try { GM_setValue("deletedDefaultHints", JSON.stringify(deletedHints)); return true; } catch (e) { console.error("Failed to save deleted default hints:", e); return false; } } function addToDeletedDefaultHints(hintName) { const deletedHints = loadDeletedDefaultHints(); if (!deletedHints.includes(hintName)) { deletedHints.push(hintName); return saveDeletedDefaultHints(deletedHints); } return true; } function removeFromDeletedDefaultHints(hintName) { const deletedHints = loadDeletedDefaultHints(); const filtered = deletedHints.filter((name) => name !== hintName); return saveDeletedDefaultHints(filtered); } function getNewDefaultHints(userHints) { const newHints = {}; const ignoredHints = loadIgnoredHints(); const deletedHints = loadDeletedDefaultHints(); for (const [name, def] of Object.entries(DEFAULT_HINTS)) { if (!userHints[name] && !ignoredHints.includes(name) && !deletedHints.includes(name)) { newHints[name] = def; } } return newHints; } const HELP_ICON_HTML = (tooltipKey, customClass = "") => { const classes = customClass ? `gut-help-icon ${customClass}` : "gut-help-icon"; return html`<span class="${classes}" data-tooltip="${tooltipKey}">?</span>`; }; const HINTS_TAB_HTML = (instance) => { const hints = instance.hints || {}; const renderHintRow = (name, hint) => { const mappingsHtml = hint.type === "map" && hint.mappings ? html` <div class="gut-hint-mappings-inline"> <div class="gut-hint-mappings-header"> <div style="display: flex; align-items: center; gap: 6px; cursor: pointer;" class="gut-hint-mappings-toggle" data-hint="${name}"> <svg class="gut-hint-caret" width="12" height="12" viewBox="0 0 12 12" style="transition: transform 0.2s ease;"> <path d="M4 3 L8 6 L4 9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg> <span>${Object.keys(hint.mappings).length} mappings${hint.strict === false ? " (non-strict)" : ""}</span> </div> <div style="display: flex; gap: 8px; align-items: center;"> <a href="#" class="gut-link" data-action="mass-edit-mappings" data-hint="${name}">Mass Edit</a> </div> </div> <div class="gut-hint-mappings-content" style="display: none; max-height: 0; overflow: hidden; transition: max-height 0.2s ease;"> <div style="max-height: 200px; overflow-y: auto;"> ${map(Object.entries(hint.mappings), ([key, value]) => html` <div class="gut-variable-item"> <span class="gut-variable-name">${key}</span> <span class="gut-variable-value">${value}</span> </div> `)} </div> </div> </div> ` : ""; return html` <div class="gut-hint-item" data-hint="${name}"> <div class="gut-hint-header"> <div class="gut-hint-name-group"> <span class="gut-hint-name">${name}</span> <span class="gut-hint-type-badge">${hint.type}</span> </div> <div class="gut-hint-actions"> <a href="#" class="gut-link" data-action="edit-hint">Edit</a> <span class="gut-hint-actions-separator">•</span> <a href="#" class="gut-link gut-link-danger" data-action="delete-hint">Delete</a> </div> </div> ${raw(when(hint.description, html`<div class="gut-hint-description">${hint.description}</div>`))} ${raw(when(hint.type === "pattern", html`<div class="gut-hint-pattern"><code>${hint.pattern}</code></div>`))} ${raw(when(hint.type === "regex", html`<div class="gut-hint-pattern"><code>/${hint.pattern}/</code></div>`))} ${raw(mappingsHtml)} </div> `; }; return html` <div class="gut-tab-content" id="hints-tab"> <div class="gut-form-group"> <div style="display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 10px;"> <input type="text" id="hint-filter-input" class="gut-input" placeholder="Filter hints by name, description, pattern..." style="flex: 1;"> <button class="gut-btn gut-btn-primary gut-btn-small" id="add-hint-btn">+ Add Hint</button> </div> <div id="hint-filter-count" style="font-size: 11px; color: #888; margin-top: 5px;"></div> </div> <div class="gut-form-group"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"> <label>Hints ${raw(HELP_ICON_HTML("variable-hints"))}</label> <div style="display: flex; gap: 8px; align-items: center;"> ${raw((() => { const newHints = getNewDefaultHints(instance.hints); const newHintCount = Object.keys(newHints).length; return newHintCount > 0 ? `<a href="#" class="gut-link" id="import-new-hints-btn">Import New Hints (${newHintCount})</a>` : ""; })())} <a href="#" class="gut-link" id="reset-defaults-btn">Reset Defaults</a> <a href="#" class="gut-link" id="delete-all-hints-btn" style="color: #f44336;">Delete All</a> </div> </div> <div class="gut-hints-list" id="hints-list"> ${map(Object.entries(hints), ([name, hint]) => raw(renderHintRow(name, hint)))} </div> </div> </div> `; }; const SANDBOX_TAB_HTML = (instance) => { const savedSets = instance.sandboxSets || {}; const currentSet = instance.currentSandboxSet || ""; return html` <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> ${map(Object.keys(savedSets), (name) => html`<option value="${name}" ${raw(name === currentSet ? "selected" : "")}>${name}</option>`)} </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"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 5px;"> <label for="sandbox-mask-input" style="margin-bottom: 0;">Mask: ${raw(HELP_ICON_HTML("mask-syntax"))}</label> <a href="#" id="toggle-compiled-regex" class="gut-link" style="font-size: 11px;">Show compiled regex</a> </div> <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-compiled-regex-display" id="sandbox-compiled-regex"></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): ${raw(HELP_ICON_HTML("mask-sandbox"))}</label> <textarea id="sandbox-sample-input" style="font-family: 'Fira Code', monospace; font-size: 13px; resize: vertical; width: 100%; line-height: 1.4; overflow-y: auto; box-sizing: border-box;" placeholder="Artist Name - Album Title [2024]\nAnother Artist - Some Album\nThird 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 MODAL_HTML = (instance) => html` <div class="gut-modal"> <div class="gut-modal-content"> <div class="gut-modal-header"> <button class="gut-modal-close-btn" id="modal-close-x" title="Close">×</button> <div class="gut-modal-tabs"> <button class="gut-tab-btn active" data-tab="templates">Templates</button> <button class="gut-tab-btn" data-tab="hints">Variable Hints</button> <button class="gut-tab-btn" data-tab="sandbox">Mask Sandbox</button> <button class="gut-tab-btn" data-tab="settings">Settings</button> </div> </div> <div class="gut-modal-body"> <div class="gut-tab-content active" id="templates-tab"> ${raw(TEMPLATE_LIST_HTML(instance))} </div> <div class="gut-tab-content" id="settings-tab"> <div class="gut-form-group"> <label for="setting-form-selector">Target Form Selector: ${raw(HELP_ICON_HTML("form-selector"))}</label> <input type="text" id="setting-form-selector" value="${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" ${raw(instance.config.SUBMIT_KEYBINDING ? "checked" : "")}> <span class="gut-checkbox-text">⚡ 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" ${raw(instance.config.APPLY_KEYBINDING ? "checked" : "")}> <span class="gut-checkbox-text">⚡ 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"> <div class="gut-keybinding-controls"> <label class="gut-checkbox-label"> <input type="checkbox" id="setting-help-keybinding" ${raw(instance.config.HELP_KEYBINDING ? "checked" : "")}> <span class="gut-checkbox-text">⚡ Enable help modal keybinding: <span class="gut-keybinding-text">${instance.config.CUSTOM_HELP_KEYBINDING || "?"}</span></span> </label> <button type="button" id="record-help-keybinding-btn" class="gut-btn gut-btn-secondary gut-btn-small">Record</button> </div> <input type="hidden" id="custom-help-keybinding-input" value="${instance.config.CUSTOM_HELP_KEYBINDING || "?"}"> </div> <div class="gut-form-group"> <label for="setting-custom-selectors">Custom Field Selectors (one per line): ${raw(HELP_ICON_HTML("custom-selectors"))}</label> <textarea id="setting-custom-selectors" rows="4" placeholder="div[data-field]\n.custom-input[name]\nbutton[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> (<a href="https://greasyfork.org/en/scripts/543815-ggn-infobox-builder" target="_blank" rel="noopener noreferrer" class="gut-link">userscript</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): ${raw(HELP_ICON_HTML("ignored-fields"))}</label> <textarea id="setting-ignored-fields" rows="6" placeholder="linkgroup\ngroupid\napikey">${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> ${raw(HINTS_TAB_HTML(instance))} ${raw(SANDBOX_TAB_HTML(instance))} </div> <div class="gut-modal-footer"> <button class="gut-btn" id="close-manager">Close</button> </div> </div> </div> `; const VARIABLES_MODAL_HTML = (instance) => html` <div class="gut-modal-content"> <div class="gut-modal-header"> <button class="gut-modal-close-btn" id="modal-close-x" title="Close">×</button> <h2>Available Variables</h2> </div> <div class="gut-modal-body"> <div class="gut-form-group"> <div id="variables-results-container" class="gut-extracted-vars"> <div class="gut-no-variables">No variables available. Select a template with a torrent name mask to see extracted variables.</div> </div> </div> </div> <div class="gut-modal-footer"> <button class="gut-btn" id="close-variables-modal">Close</button> </div> </div> `; const renderSelectFieldVariableToggle = (name, editTemplate2) => { const hasVariableMatching = editTemplate2 && editTemplate2.variableMatching && editTemplate2.variableMatching[name]; const isVariableMode = hasVariableMatching; return html` <div style="display: flex; align-items: flex-start; width: 100%; gap: 4px;"> <a href="#" class="gut-link gut-variable-toggle" data-field="${name}" data-state="${isVariableMode ? "on" : "off"}">Match from variable: ${isVariableMode ? "ON" : "OFF"}</a> ${raw(HELP_ICON_HTML("variable-matching"))} </div> `; }; const renderSelectFieldInput = (name, fieldData, templateValue, editTemplate2) => { const hasVariableMatching = editTemplate2 && editTemplate2.variableMatching && editTemplate2.variableMatching[name]; const variableConfig = hasVariableMatching ? editTemplate2.variableMatching[name] : null; const isVariableMode = hasVariableMatching; return html` <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%; ${raw(isVariableMode ? "display: none;" : "")}"> ${map(fieldData.options, (option) => { const selected = templateValue && templateValue === option.value ? true : option.selected; return html`<option value="${option.value}" ${raw(selected ? "selected" : "")}>${option.text}</option>`; })} </select> </div> <div class="gut-variable-controls" data-field="${name}" style="display: ${raw(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" ${raw(variableConfig && variableConfig.matchType === "exact" ? "selected" : "")}>Is exactly</option> <option value="contains" ${raw(variableConfig && variableConfig.matchType === "contains" ? "selected" : "")}>Contains</option> <option value="starts" ${raw(variableConfig && variableConfig.matchType === "starts" ? "selected" : "")}>Starts with</option> <option value="ends" ${raw(variableConfig && variableConfig.matchType === "ends" ? "selected" : "")}>Ends with</option> </select> <input type="text" class="gut-variable-input" data-field="${name}" placeholder="\${variable_name}" value="${variableConfig ? variableConfig.variableName : ""}" style="flex: 1; padding: 6px 8px; border: 1px solid #404040; border-radius: 3px; background: #1a1a1a; color: #e0e0e0; font-size: 12px;"> </div> </div> `; }; const renderFieldInput = (name, fieldData, templateValue, editTemplate2) => { if (fieldData.type === "select") { return renderSelectFieldInput(name, fieldData, templateValue, editTemplate2); } else if (fieldData.inputType === "checkbox") { const checked = templateValue !== null ? templateValue : fieldData.value; return html`<input type="checkbox" ${raw(checked ? "checked" : "")} data-template="${name}" class="template-input">`; } else if (fieldData.inputType === "radio") { return html` <select data-template="${name}" class="template-input gut-select"> ${map(fieldData.radioOptions, (option) => { const selected = templateValue && templateValue === option.value ? true : option.checked; return html`<option value="${option.value}" ${raw(selected ? "selected" : "")}>${option.label}</option>`; })} </select> `; } else if (fieldData.type === "textarea") { const value = templateValue !== null ? String(templateValue) : String(fieldData.value); return html`<textarea data-template="${name}" class="template-input" rows="4" style="resize: vertical; width: 100%;">${value}</textarea>`; } else { const value = templateValue !== null ? String(templateValue) : String(fieldData.value); return html`<input type="text" value="${value}" data-template="${name}" class="template-input">`; } }; const TEMPLATE_CREATOR_HTML = (formData, instance, editTemplateName, editTemplate2, selectedTorrentName, openMode = "manage") => { const renderFieldRow = ([name, fieldData]) => { const isIgnoredByDefault = instance.config.IGNORED_FIELDS_BY_DEFAULT.includes( name.toLowerCase() ); const isInTemplate = editTemplate2 && editTemplate2.fieldMappings.hasOwnProperty(name); const templateValue = isInTemplate ? editTemplate2.fieldMappings[name] : null; let shouldBeChecked = isInTemplate || !isIgnoredByDefault; if (editTemplate2 && editTemplate2.customUnselectedFields) { const customField = editTemplate2.customUnselectedFields.find( (f) => f.field === name ); if (customField) { shouldBeChecked = customField.selected; } } const hiddenClass = isIgnoredByDefault && !isInTemplate && !shouldBeChecked ? " gut-hidden" : ""; return html` <div class="gut-field-row${raw(hiddenClass)}"> ${raw(when(fieldData.type === "select", raw(renderSelectFieldVariableToggle(name, editTemplate2))))} <input type="checkbox" ${raw(shouldBeChecked ? "checked" : "")} data-field="${name}"> <label title="${name}">${fieldData.label}:</label> ${raw(renderFieldInput(name, fieldData, templateValue, editTemplate2))} <span class="gut-preview" data-preview="${name}"></span> </div> `; }; return html` <div class="gut-modal-content"> <div class="gut-modal-header"> <button class="gut-modal-close-btn" id="modal-close-x" title="Close">×</button> <h2> ${raw(when(editTemplateName && openMode === "manage", raw('<button class="gut-modal-back-btn" id="back-to-manager" title="Back to Template Manager"><</button>')))} ${editTemplateName ? "Edit Template" : "Create Template"} </h2> </div> <div class="gut-modal-body"> <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 || ""}"> </div> <div class="gut-form-group"> <label for="sample-torrent">Sample Torrent Name (for preview):</label> <input type="text" id="sample-torrent" value="${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: ${raw(HELP_ICON_HTML("mask-syntax"))}</label> <a href="#" id="test-mask-sandbox-link" class="gut-link" style="font-size: 11px;">Test mask in sandbox →</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="${editTemplate2 ? editTemplate2.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: ${raw(HELP_ICON_HTML("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: ${raw(HELP_ICON_HTML("field-mappings"))}</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 style="display: flex; gap: 8px; margin-bottom: 12px; font-size: 12px;"> <a href="#" class="gut-link" id="template-select-all-btn">Select All</a> <span style="color: #666;">•</span> <a href="#" class="gut-link" id="template-select-none-btn">Select None</a> </div> <div class="gut-field-list"> ${map(Object.entries(formData), renderFieldRow)} </div> </div> </div> <div class="gut-modal-footer"> <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 HINT_EDITOR_MODAL_HTML = (instance, hintName = null, hintData = null) => { const isEdit = !!hintName; const hint = hintData || { type: "pattern", pattern: "", description: "", mappings: {}, strict: true }; const mappingsArray = hint.type === "map" && hint.mappings ? Object.entries(hint.mappings) : [["", ""]]; return html` <div class="gut-modal"> <div class="gut-modal-content gut-hint-editor-modal"> <div class="gut-modal-header"> <button class="gut-modal-close-btn" id="modal-close-x" title="Close">×</button> <h2>${isEdit ? "Edit Hint" : "Create New Hint"}</h2> </div> <div class="gut-modal-body"> <div class="gut-form-group"> <label for="hint-editor-name">Hint Name *</label> <input type="text" id="hint-editor-name" class="gut-input" placeholder="e.g., my_hint" value="${isEdit ? hintName : ""}" ${isEdit ? "readonly" : ""} pattern="[a-zA-Z0-9_]+" > <div style="font-size: 11px; color: #888; margin-top: 4px;"> Letters, numbers, and underscores only </div> </div> <div class="gut-form-group"> <label for="hint-editor-description">Description</label> <textarea id="hint-editor-description" class="gut-input" rows="1" placeholder="Describe what this hint matches" >${hint.description || ""}</textarea> </div> <div class="gut-form-group"> <label>Hint Type * ${raw(HELP_ICON_HTML("hint-types"))}</label> <div class="gut-hint-type-selector"> <label class="gut-radio-label" title="Use # for digits, @ for letters, * for alphanumeric"> <input type="radio" name="hint-type" value="pattern" ${hint.type === "pattern" ? "checked" : ""}> <span>Pattern</span> </label> <label class="gut-radio-label" title="Regular expression pattern"> <input type="radio" name="hint-type" value="regex" ${hint.type === "regex" ? "checked" : ""}> <span>Regex</span> </label> <label class="gut-radio-label" title="Map input values to output values"> <input type="radio" name="hint-type" value="map" ${hint.type === "map" ? "checked" : ""}> <span>Value Map</span> </label> </div> </div> <div class="gut-form-group" id="hint-pattern-group" style="display: ${hint.type === "pattern" || hint.type === "regex" ? "block" : "none"};"> <label for="hint-editor-pattern"> <span id="hint-pattern-label">${hint.type === "regex" ? "Regex Pattern" : "Pattern"} *</span> ${raw(hint.type === "pattern" ? HELP_ICON_HTML("hint-pattern-syntax") : hint.type === "regex" ? HELP_ICON_HTML("hint-regex-syntax") : "")} </label> <input type="text" id="hint-editor-pattern" class="gut-input" placeholder="${hint.type === "regex" ? "e.g., v\\d+(?:\\.\\d+)*" : "e.g., ##.##.####"}" value="${hint.type !== "map" ? hint.pattern || "" : ""}" > </div> <div class="gut-form-group" id="hint-mappings-group" style="display: ${hint.type === "map" ? "block" : "none"};"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"> <label style="margin: 0;">Value Mappings * ${raw(HELP_ICON_HTML("hint-value-mappings"))}</label> <div style="display: flex; gap: 8px; align-items: center;"> <a href="#" class="gut-link" id="hint-editor-import-btn">Import</a> <a href="#" class="gut-link" id="hint-editor-mass-edit-btn">Mass Edit</a> </div> </div> <label class="gut-checkbox-label" style="margin-top: 10px;"> <input type="checkbox" id="hint-editor-strict" ${hint.strict === false ? "" : "checked"}> <span class="gut-checkbox-text">Strict mode (reject values not in map) ${raw(HELP_ICON_HTML("hint-strict-mode"))}</span> </label> <div id="hint-mappings-table"> <div class="gut-mappings-table-header"> <span style="flex: 1;">Input Value</span> <span style="flex: 1;">Output Value</span> <span style="width: 40px;"></span> </div> <div id="hint-mappings-rows"> ${map(mappingsArray, ([key, value], idx) => html` <div class="gut-mappings-row" data-row-index="${idx}"> <input type="text" class="gut-input gut-mapping-key" placeholder="e.g., en" value="${key}"> <input type="text" class="gut-input gut-mapping-value" placeholder="e.g., English" value="${value}"> <button class="gut-btn gut-btn-danger gut-btn-small gut-remove-mapping" title="Remove">−</button> </div> `)} </div> <button class="gut-btn gut-btn-secondary gut-btn-small" id="hint-add-mapping">+ Add Mapping</button> </div> </div> </div> <div class="gut-modal-footer"> <button class="gut-btn" id="hint-editor-cancel">Cancel</button> <button class="gut-btn gut-btn-primary" id="hint-editor-save">${isEdit ? "Save Changes" : "Create Hint"}</button> </div> </div> </div> `; }; const MAP_IMPORT_MODAL_HTML = (instance, hintName, existingMappings = {}, mode = "import") => { const isMassEdit = mode === "mass-edit"; const prefilledText = isMassEdit ? Object.entries(existingMappings).map(([k, v]) => `${k},${v}`).join("\n") : ""; return html` <div class="gut-modal"> <div class="gut-modal-content"> <div class="gut-modal-header"> <button class="gut-modal-close-btn" id="modal-close-x" title="Close">×</button> <h2>${isMassEdit ? "Mass Edit" : "Import"} Mappings for "${hintName}"</h2> </div> <div class="gut-modal-body"> <div class="gut-form-group"> <label for="import-separator-select">Separator:</label> <div style="display: flex; gap: 8px; align-items: center;"> <select id="import-separator-select" class="gut-select" style="flex: 1;"> <option value="," selected>Comma (,)</option> <option value="\t">Tab</option> <option value=";">Semicolon (;)</option> <option value="|">Pipe (|)</option> <option value=":">Colon (:)</option> <option value="=">Equals (=)</option> <option value="custom">Custom...</option> </select> <input type="text" id="import-custom-separator" class="gut-input" placeholder="Enter separator" maxlength="3" style="display: none; width: 100px;" > </div> </div> <div class="gut-form-group"> <label for="import-mappings-textarea">Mappings (one per line):</label> <textarea id="import-mappings-textarea" class="gut-input" placeholder="en,English\nfr,French\nde,German" style="font-family: 'Fira Code', monospace; font-size: 13px; resize: vertical; width: 100%; line-height: 1.4;" >${prefilledText}</textarea> <div style="font-size: 11px; color: #888; margin-top: 4px;"> Format: key${isMassEdit ? "" : "<separator>"}value (one mapping per line) </div> </div> ${raw(when(!isMassEdit, html` <div class="gut-form-group"> <label class="gut-checkbox-label"> <input type="checkbox" id="import-overwrite-checkbox"> <span class="gut-checkbox-text">Overwrite existing mappings</span> </label> <div style="font-size: 11px; color: #888; margin-top: 4px;"> If unchecked, only new keys will be added (existing keys will be kept) </div> </div> `))} <div class="gut-form-group" id="import-preview-group" style="display: none;"> <label>Preview:</label> <div id="import-preview-content" class="gut-extracted-vars" style="max-height: 200px; overflow-y: auto;"> </div> <div id="import-preview-summary" style="font-size: 11px; color: #888; margin-top: 4px;"></div> </div> </div> <div class="gut-modal-footer"> <button class="gut-btn" id="import-cancel-btn">Cancel</button> <button class="gut-btn gut-btn-primary" id="import-confirm-btn">${isMassEdit ? "Apply Changes" : "Import"}</button> </div> </div> </div> `; }; const IMPORT_NEW_HINTS_MODAL_HTML = (newHints, ignoredHints, instance) => { const hintEntries = Object.entries(newHints); const selectedCount = hintEntries.filter(([name]) => !ignoredHints.includes(name)).length; const renderHintRow = (name, hint, isIgnored) => { const mappingsHtml = hint.type === "map" && hint.mappings ? html` <div class="gut-hint-mappings-inline"> <div class="gut-hint-mappings-header"> <div style="display: flex; align-items: center; gap: 6px; cursor: pointer;" class="gut-hint-mappings-toggle" data-hint="${name}"> <svg class="gut-hint-caret" width="12" height="12" viewBox="0 0 12 12" style="transition: transform 0.2s ease;"> <path d="M4 3 L8 6 L4 9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg> <span>${Object.keys(hint.mappings).length} mappings${hint.strict === false ? " (non-strict)" : ""}</span> </div> </div> <div class="gut-hint-mappings-content" style="display: none; max-height: 0; overflow: hidden; transition: max-height 0.2s ease;"> <div style="max-height: 200px; overflow-y: auto;"> ${map(Object.entries(hint.mappings), ([key, value]) => html` <div class="gut-variable-item"> <span class="gut-variable-name">${key}</span> <span class="gut-variable-value">${value}</span> </div> `)} </div> </div> </div> ` : ""; return html` <div class="gut-hint-item gut-hint-import-item" data-hint-name="${name}"> <div class="gut-hint-header"> <div style="display: flex; align-items: center; gap: 12px; flex: 1;"> <input type="checkbox" class="hint-select-checkbox" data-hint-name="${name}" ${raw(isIgnored ? "" : "checked")} > <div class="gut-hint-name-group"> <span class="gut-hint-name">${name}</span> <span class="gut-hint-type-badge">${hint.type}</span> </div> </div> <div class="gut-hint-actions"> <a href="#" class="gut-link hint-ignore-btn" data-hint-name="${name}" > ${isIgnored ? "Unignore" : "Ignore"} </a> </div> </div> ${raw(when(hint.description, html`<div class="gut-hint-description">${hint.description}</div>`))} ${raw(when(hint.type === "pattern", html`<div class="gut-hint-pattern"><code>${hint.pattern}</code></div>`))} ${raw(when(hint.type === "regex", html`<div class="gut-hint-pattern"><code>/${hint.pattern}/</code></div>`))} ${raw(mappingsHtml)} </div> `; }; const buttonText = selectedCount === 0 ? "Import Selected" : selectedCount === hintEntries.length ? "Import All" : `Import ${selectedCount}/${hintEntries.length} Selected`; return html` <div class="gut-modal"> <div class="gut-modal-content" style="max-width: 700px;"> <div class="gut-modal-header"> <button class="gut-modal-close-btn" id="modal-close-x" title="Close">×</button> <h2>Import New Default Hints</h2> </div> <div class="gut-modal-body"> <div style="padding: 12px; background: #2a3a4a; border-left: 3px solid #4caf50; margin-bottom: 16px; border-radius: 4px;"> <strong style="color: #4caf50;">New default hints are available!</strong> <p style="margin: 8px 0 0 0; color: #b0b0b0; font-size: 13px;"> Select which hints you'd like to import. You can ignore hints you don't need. </p> </div> <div style="display: flex; gap: 8px; margin-bottom: 12px; font-size: 12px;"> <a href="#" class="gut-link" id="import-select-all-btn">Select All</a> <span style="color: #666;">•</span> <a href="#" class="gut-link" id="import-select-none-btn">Select None</a> </div> <div class="gut-hints-list"> ${map(hintEntries, ([name, hint]) => { const isIgnored = ignoredHints.includes(name); return raw(renderHintRow(name, hint, isIgnored)); })} </div> </div> <div class="gut-modal-footer"> <button class="gut-btn" id="import-hints-cancel-btn">Cancel</button> <button class="gut-btn gut-btn-primary" id="import-hints-confirm-btn" ${raw(selectedCount === 0 ? "disabled" : "")}>${buttonText}</button> </div> </div> </div> `; }; const RESET_DEFAULTS_MODAL_HTML = (userHints, ignoredHints, deletedHints, instance) => { const defaultEntries = Object.entries(DEFAULT_HINTS); const hintsWithStatus = defaultEntries.map(([name, def]) => { const userHint = userHints[name]; const isDeleted = deletedHints.includes(name); const isEdited = userHint && !isDeleted && JSON.stringify(userHint) !== JSON.stringify(def); const isMissing = !userHint && !isDeleted; const isIgnored = ignoredHints.includes(name); return { name, def, isEdited, isMissing, isDeleted, isIgnored }; }); const selectedCount = hintsWithStatus.filter((h) => !h.isIgnored).length; const buttonText = selectedCount === 0 ? "Reset Selected" : selectedCount === defaultEntries.length ? "Reset All" : `Reset ${selectedCount}/${defaultEntries.length} Selected`; const renderHintRow = (name, hint, isIgnored, statusBadge) => { const mappingsHtml = hint.type === "map" && hint.mappings ? html` <div class="gut-hint-mappings-inline"> <div class="gut-hint-mappings-header"> <div style="display: flex; align-items: center; gap: 6px; cursor: pointer;" class="gut-hint-mappings-toggle" data-hint="${name}"> <svg class="gut-hint-caret" width="12" height="12" viewBox="0 0 12 12" style="transition: transform 0.2s ease;"> <path d="M4 3 L8 6 L4 9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/> </svg> <span>${Object.keys(hint.mappings).length} mappings${hint.strict === false ? " (non-strict)" : ""}</span> </div> </div> <div class="gut-hint-mappings-content" style="display: none; max-height: 0; overflow: hidden; transition: max-height 0.2s ease;"> <div style="max-height: 200px; overflow-y: auto;"> ${map(Object.entries(hint.mappings), ([key, value]) => html` <div class="gut-variable-item"> <span class="gut-variable-name">${key}</span> <span class="gut-variable-value">${value}</span> </div> `)} </div> </div> </div> ` : ""; return html` <div class="gut-hint-item gut-hint-import-item" data-hint-name="${name}"> <div class="gut-hint-header"> <div style="display: flex; align-items: center; gap: 12px; flex: 1;"> <input type="checkbox" class="hint-select-checkbox" data-hint-name="${name}" ${raw(isIgnored ? "" : "checked")} > <div class="gut-hint-name-group"> <span class="gut-hint-name">${name}</span> <span class="gut-hint-type-badge">${hint.type}</span> ${raw(statusBadge)} </div> </div> <div class="gut-hint-actions"> <a href="#" class="gut-link hint-ignore-btn" data-hint-name="${name}" > ${isIgnored ? "Unignore" : "Ignore"} </a> </div> </div> ${raw(when(hint.description, html`<div class="gut-hint-description">${hint.description}</div>`))} ${raw(when(hint.type === "pattern", html`<div class="gut-hint-pattern"><code>${hint.pattern}</code></div>`))} ${raw(when(hint.type === "regex", html`<div class="gut-hint-pattern"><code>/${hint.pattern}/</code></div>`))} ${raw(mappingsHtml)} </div> `; }; return html` <div class="gut-modal"> <div class="gut-modal-content" style="max-width: 700px;"> <div class="gut-modal-header"> <button class="gut-modal-close-btn" id="modal-close-x" title="Close">×</button> <h2>Reset Default Hints</h2> </div> <div class="gut-modal-body"> <div style="padding: 12px; background: #4a3a2a; border-left: 3px solid #ff9800; margin-bottom: 16px; border-radius: 4px;"> <strong style="color: #ff9800;">⚠ Warning</strong> <p style="margin: 8px 0 0 0; color: #b0b0b0; font-size: 13px;"> Selected hints will be reset to their default values. This will overwrite any customizations you've made to these hints. </p> </div> <div style="display: flex; gap: 8px; margin-bottom: 12px; font-size: 12px;"> <a href="#" class="gut-link" id="reset-select-all-btn">Select All</a> <span style="color: #666;">•</span> <a href="#" class="gut-link" id="reset-select-none-btn">Select None</a> </div> <div class="gut-hints-list"> ${map(hintsWithStatus, ({ name, def, isEdited, isMissing, isDeleted, isIgnored }) => { let statusBadge = ""; if (isDeleted) { statusBadge = '<span style="padding: 2px 6px; background: #4a2a2a; color: #f44336; border-radius: 3px; font-size: 11px; font-weight: 500;">Deleted</span>'; } else if (isMissing) { statusBadge = '<span style="padding: 2px 6px; background: #3a2a4a; color: #9c27b0; border-radius: 3px; font-size: 11px; font-weight: 500;">Missing</span>'; } else if (isEdited) { statusBadge = '<span style="padding: 2px 6px; background: #4a3a2a; color: #ff9800; border-radius: 3px; font-size: 11px; font-weight: 500;">Edited</span>'; } return raw(renderHintRow(name, def, isIgnored, statusBadge)); })} </div> </div> <div class="gut-modal-footer"> <button class="gut-btn" id="reset-hints-cancel-btn">Cancel</button> <button class="gut-btn gut-btn-primary" id="reset-hints-confirm-btn" ${raw(selectedCount === 0 ? "disabled" : "")}>${buttonText}</button> </div> </div> </div> `; }; const DELETE_ALL_HINTS_MODAL_HTML = (instance) => { return html` <div class="gut-modal"> <div class="gut-modal-content" style="max-width: 500px;"> <div class="gut-modal-header"> <button class="gut-modal-close-btn" id="modal-close-x" title="Close">×</button> <h2>Delete All Hints</h2> </div> <div class="gut-modal-body"> <div style="padding: 12px; background: #4a2a2a; border-left: 3px solid #f44336; margin-bottom: 16px; border-radius: 4px;"> <strong style="color: #f44336;">⚠ Critical Warning</strong> <p style="margin: 8px 0 0 0; color: #b0b0b0; font-size: 13px;"> This will permanently delete <strong>ALL</strong> variable hints, including: </p> <ul style="margin: 8px 0 0 20px; color: #b0b0b0; font-size: 13px;"> <li>All default hints</li> <li>All custom hints you've created</li> <li>All edited hints</li> </ul> <p style="margin: 8px 0 0 0; color: #b0b0b0; font-size: 13px;"> <strong>This action cannot be undone.</strong> You can restore default hints later, but custom hints will be lost forever. </p> </div> <div style="padding: 12px; background: #1a1a1a; border-radius: 4px;"> <p style="margin: 0; color: #b0b0b0; font-size: 13px;"> Are you absolutely sure you want to delete all hints? </p> </div> </div> <div class="gut-modal-footer"> <button class="gut-btn" id="delete-all-hints-cancel-btn">Cancel</button> <button class="gut-btn gut-btn-danger" id="delete-all-hints-confirm-btn">Delete All Hints</button> </div> </div> </div> `; }; const APPLY_CONFIRMATION_MODAL_HTML = (changes, instance) => { const changesCount = changes.length; const fieldWord = changesCount === 1 ? "field" : "fields"; return html` <div class="gut-modal"> <div class="gut-modal-content gut-confirmation-modal" style="max-width: 800px;"> <div class="gut-modal-header"> <button class="gut-modal-close-btn" id="modal-close-x" title="Close">×</button> <h2>⚠️ Confirm Template Application</h2> </div> <div class="gut-modal-body"> <div style="padding: 10px 12px; background: #4a3a2a; border-left: 3px solid #ff9800; margin-bottom: 12px; border-radius: 4px;"> <p style="margin: 0; color: #e0e0e0; font-size: 13px;"> <strong>Warning:</strong> ${changesCount} ${fieldWord} will be overwritten </p> </div> <div class="gut-field-changes-list"> ${map(changes, (change) => html` <div class="gut-field-change-item"> <div class="gut-field-change-row"> <div class="gut-field-name"> <strong>${change.label || change.fieldName}</strong> <span class="gut-field-type-badge">${change.fieldType || "text"}</span> </div> <div class="gut-field-values"> <span class="gut-value gut-value-old">${String(change.currentValue)}</span> <span class="gut-value-arrow">→</span> <span class="gut-value gut-value-new">${String(change.newValue)}</span> </div> </div> </div> `)} </div> </div> <div class="gut-modal-footer"> <button class="gut-btn" id="apply-confirm-cancel-btn">Cancel</button> <button class="gut-btn gut-btn-primary" id="apply-confirm-apply-btn">Apply Template</button> </div> </div> </div> `; }; const UNSAVED_CHANGES_CONFIRMATION_MODAL_HTML = () => { return html` <div class="gut-modal"> <div class="gut-modal-content" style="max-width: 500px;"> <div class="gut-modal-header"> <h2>⚠️ Unsaved Changes</h2> </div> <div class="gut-modal-body"> <p>You have unsaved changes. Are you sure you want to close without saving?</p> </div> <div class="gut-modal-footer"> <button class="gut-btn" id="unsaved-keep-editing">Keep Editing</button> <button class="gut-btn gut-btn-danger" id="unsaved-discard">Discard Changes</button> </div> </div> </div> `; }; const INTRO_MODAL_HTML = (content, isNewUser, currentVersion) => html` <div class="gut-modal"> <div class="gut-modal-content gut-intro-modal"> <div class="gut-modal-header"> <h2 class="gut-intro-modal-header-centered">${content.title}</h2> </div> <div class="gut-modal-body"> ${raw(content.content)} <div class="gut-intro-help-box"> <p>Help is always available:</p> <ul> <li>Look for ${raw(HELP_ICON_HTML("help-icon-example", "gut-help-icon-no-margin"))} icons throughout the UI</li> <li>Press <kbd class="gut-kbd">?</kbd> to open the help modal anytime</li> </ul> </div> </div> <div class="gut-modal-footer gut-intro-footer-centered"> <button class="gut-btn gut-btn-primary" id="intro-get-started">Get Started</button> </div> </div> </div> `; const HELP_MODAL_HTML = (sections, currentVersion) => { const sectionsList = Object.entries(sections).map(([id, section]) => ({ id, ...section })); return html` <div class="gut-modal"> <div class="gut-modal-content gut-help-modal"> <div class="gut-modal-header"> <button class="gut-modal-close-btn" id="modal-close-x" title="Close">×</button> <h2 style="margin: 0; flex: 1; text-align: center;">Help & Documentation</h2> </div> <div class="gut-modal-body"> <div class="gut-help-subheader"> <button class="gut-btn gut-btn-secondary" id="help-toc-toggle" title="Toggle table of contents" style="padding: 8px 12px;">☰ Topics</button> <input type="text" id="help-search-input" class="gut-help-search" placeholder="Search help..." autocomplete="off" style="flex: 1;"> </div> <div class="gut-help-container"> <div class="gut-help-toc" id="help-toc" style="display: none;"> <div class="gut-help-toc-content"> ${map(sectionsList, (section) => html` <div class="gut-help-toc-item" data-section="${section.id}"> ${section.title} </div> `)} </div> </div> <div class="gut-help-content" id="help-content"> <div class="gut-help-search-info" id="help-search-info" style="display: none;"> <span id="help-search-count"></span> <button class="gut-btn gut-btn-secondary gut-btn-small" id="help-clear-search">Clear Search</button> </div> ${map(sectionsList, (section) => html` <div class="gut-help-section" data-section-id="${section.id}"> <h2 class="gut-help-section-title">${section.title}</h2> <div class="gut-help-section-content"> ${raw(section.content)} </div> </div> `)} </div> </div> </div> <div class="gut-modal-footer" style="display: flex; justify-content: center; align-items: center; gap: 8px; font-size: 12px; color: #888;"> <a href="#" id="help-version-link">GGn Upload Templator v${currentVersion}</a> <span>•</span> <span>Press <kbd class="gut-kbd">?</kbd> to toggle help</span> </div> </div> </div> `; }; const TEMPLATE_SELECTOR_HTML = (instance) => html` <option value="">Select Template</option> ${map(Object.keys(instance.templates), (name) => html`<option value="${name}" ${raw(name === instance.selectedTemplate ? "selected" : "")}>${name}</option>`)} `; const MAIN_UI_HTML = (instance) => html` <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> ${map(Object.keys(instance.templates), (name) => html`<option value="${name}" ${raw(name === instance.selectedTemplate ? "selected" : "")}>${name}</option>`)} </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"> Manage </button> </div> <div id="variables-row" style="display: none; padding: 10px 0; font-size: 12px; cursor: pointer; user-select: none;"></div> `; function loadTemplates() { try { return JSON.parse( localStorage.getItem("ggn-upload-templator-templates") || "{}" ); } catch (error) { console.error("Failed to load templates:", error); return {}; } } function saveTemplates(templates) { try { localStorage.setItem( "ggn-upload-templator-templates", JSON.stringify(templates) ); } catch (error) { console.error("Failed to save templates:", error); } } function loadSelectedTemplate() { try { return localStorage.getItem("ggn-upload-templator-selected") || null; } catch (error) { console.error("Failed to load selected template:", error); return null; } } function saveSelectedTemplate(name) { try { localStorage.setItem("ggn-upload-templator-selected", name); } catch (error) { console.error("Failed to save selected template:", error); } } function removeSelectedTemplate() { try { localStorage.removeItem("ggn-upload-templator-selected"); } catch (error) { console.error("Failed to remove selected template:", error); } } function loadHideUnselected() { try { return JSON.parse( localStorage.getItem("ggn-upload-templator-hide-unselected") || "true" ); } catch (error) { console.error("Failed to load hide unselected setting:", error); return true; } } function loadSettings() { try { return JSON.parse( localStorage.getItem("ggn-upload-templator-settings") || "{}" ); } catch (error) { console.error("Failed to load settings:", error); return {}; } } function saveSettings(settings) { try { localStorage.setItem( "ggn-upload-templator-settings", JSON.stringify(settings) ); } catch (error) { console.error("Failed to save settings:", error); } } function removeSettings() { try { localStorage.removeItem("ggn-upload-templator-settings"); } catch (error) { console.error("Failed to remove settings:", error); } } function loadSandboxSets() { try { return JSON.parse( localStorage.getItem("ggn-upload-templator-sandbox-sets") || "{}" ); } catch (error) { console.error("Failed to load sandbox sets:", error); return {}; } } function saveSandboxSets(sets) { try { localStorage.setItem( "ggn-upload-templator-sandbox-sets", JSON.stringify(sets) ); } catch (error) { console.error("Failed to save sandbox sets:", error); } } function loadCurrentSandboxSet() { try { return localStorage.getItem("ggn-upload-templator-sandbox-current") || ""; } catch (error) { console.error("Failed to load current sandbox set:", error); return ""; } } function saveCurrentSandboxSet(name) { try { localStorage.setItem("ggn-upload-templator-sandbox-current", name); } catch (error) { console.error("Failed to save current sandbox set:", error); } } function deleteAllConfig$1() { try { 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"); } catch (error) { console.error("Failed to delete config:", error); } } function loadModalWidth() { try { const width = localStorage.getItem("ggn-upload-templator-modal-width"); return width ? parseInt(width, 10) : null; } catch (error) { console.error("Failed to load modal width:", error); return null; } } function saveModalWidth(width) { try { localStorage.setItem("ggn-upload-templator-modal-width", width.toString()); } catch (error) { console.error("Failed to save modal width:", error); } } class ModalStackManager { constructor() { this.stack = []; this.baseZIndex = 1e4; this.keybindingRecorderActive = false; this.escapeHandlers = []; this.resizeHandleWidth = 10; this.isResizing = false; this.currentResizeModal = null; this.resizeStartX = 0; this.resizeStartWidth = 0; this.resizeSide = null; this.changeTracking = /* @__PURE__ */ new Map(); this.modalIdCounter = 0; this.unsavedChangesHandler = null; this.setupGlobalHandlers(); } push(element, options = {}) { if (!element) { console.error("ModalStack.push: element is required"); return; } const type = options.type || "stack"; if (type !== "stack" && type !== "replace") { console.error('ModalStack.push: type must be "stack" or "replace"'); return; } if (type === "replace" && this.stack.length > 0) { const current = this.stack[this.stack.length - 1]; if (current.element && document.body.contains(current.element)) { this.removeResizeHandles(current.element); document.body.removeChild(current.element); } } const overlayClickHandler = (e) => { if (e.target === element && !this.isResizing) { this.pop(); } }; element.addEventListener("click", overlayClickHandler); const closeButtons = element.querySelectorAll('.gut-modal-close-btn, #modal-close-x, [data-modal-action="close"]'); const closeButtonHandlers = []; closeButtons.forEach((btn) => { const handler = () => this.pop(); btn.addEventListener("click", handler); closeButtonHandlers.push({ button: btn, handler }); }); const keyboardHandler = options.keyboardHandler || null; if (keyboardHandler) { document.addEventListener("keydown", keyboardHandler); } const modalId = ++this.modalIdCounter; const entry = { element, type, onClose: options.onClose || null, canGoBack: options.canGoBack || false, backFactory: options.backFactory || null, metadata: options.metadata || {}, originalDimensions: null, overlayClickHandler, closeButtonHandlers, keyboardHandler, id: modalId, trackChanges: options.trackChanges || false, formSelector: options.formSelector || "input, textarea, select", onUnsavedClose: options.onUnsavedClose || null, customChangeCheck: options.customChangeCheck || null }; this.stack.push(entry); if (!document.body.contains(element)) { document.body.appendChild(element); } if (this.stack.length === 1) { document.body.style.overflow = "hidden"; } this.updateZIndices(); this.updateResizeHandles(); if (entry.trackChanges) { setTimeout(() => { this.captureFormState(modalId, element, entry.formSelector); }, 0); } } replace(element, options = {}) { this.push(element, { ...options, type: "replace" }); } async pop(force = false) { if (this.stack.length === 0) { return null; } const entry = this.stack[this.stack.length - 1]; if (!force && entry.trackChanges && this.hasUnsavedChanges(entry.id)) { const shouldDiscard = await this.showUnsavedChangesConfirmation(); if (!shouldDiscard) { return null; } } this.stack.pop(); if (entry.onClose) { entry.onClose(); } if (entry.overlayClickHandler && entry.element) { entry.element.removeEventListener("click", entry.overlayClickHandler); } if (entry.closeButtonHandlers) { entry.closeButtonHandlers.forEach(({ button, handler }) => { button.removeEventListener("click", handler); }); } if (entry.keyboardHandler) { document.removeEventListener("keydown", entry.keyboardHandler); } if (entry.element && document.body.contains(entry.element)) { this.removeResizeHandles(entry.element); document.body.removeChild(entry.element); } if (entry.trackChanges) { this.stopTrackingChanges(entry.id); } if (this.stack.length === 0) { this.clearEscapeHandlers(); document.body.style.overflow = ""; } this.updateZIndices(); this.updateResizeHandles(); return entry; } async back() { if (this.stack.length === 0) { return; } const current = this.stack[this.stack.length - 1]; if (current.type !== "replace" || !current.canGoBack || !current.backFactory) { console.warn( "ModalStack.back: current modal does not support back navigation" ); return; } const poppedEntry = await this.pop(); if (poppedEntry) { current.backFactory(); } } clear() { while (this.stack.length > 0) { this.pop(true); } this.clearEscapeHandlers(); } getCurrentModal() { return this.stack.length > 0 ? this.stack[this.stack.length - 1] : null; } getStackDepth() { return this.stack.length; } setKeybindingRecorderActive(active) { this.keybindingRecorderActive = active; } isKeybindingRecorderActive() { return this.keybindingRecorderActive; } pushEscapeHandler(handler) { if (typeof handler !== "function") { console.error("ModalStack.pushEscapeHandler: handler must be a function"); return; } this.escapeHandlers.push(handler); } popEscapeHandler() { return this.escapeHandlers.pop(); } clearEscapeHandlers() { this.escapeHandlers = []; } hasEscapeHandlers() { return this.escapeHandlers.length > 0; } isResizingModal() { return this.isResizing; } parseDimensionWithUnit(value) { if (!value || value === "auto" || value === "none") { return { value: null, unit: null }; } const match = value.match(/^([\d.]+)(px|vh|vw|%)?$/); if (match) { return { value: parseFloat(match[1]), unit: match[2] || "px" }; } return { value: parseFloat(value), unit: "px" }; } detectViewportUnit(computedValue, dimension) { if (!computedValue) return null; const pxValue = parseFloat(computedValue); if (isNaN(pxValue)) return null; const vh = window.innerHeight; const vw = window.innerWidth; if (dimension === "height" || dimension === "maxHeight") { const percentOfVh = pxValue / vh * 100; if (Math.abs(percentOfVh - 90) < 0.5) return { value: 90, unit: "vh" }; if (Math.abs(percentOfVh - 85) < 0.5) return { value: 85, unit: "vh" }; if (Math.abs(percentOfVh - 80) < 0.5) return { value: 80, unit: "vh" }; } if (dimension === "width" || dimension === "maxWidth") { const percentOfVw = pxValue / vw * 100; if (Math.abs(percentOfVw - 90) < 0.5) return { value: 90, unit: "vw" }; if (Math.abs(percentOfVw - 85) < 0.5) return { value: 85, unit: "vw" }; if (Math.abs(percentOfVw - 80) < 0.5) return { value: 80, unit: "vw" }; } return null; } formatDimension(value, unit) { return `${value}${unit}`; } updateZIndices() { let previousWidth = null; let previousMaxWidth = null; let previousHeight = null; let previousMaxHeight = null; let previousWidthUnit = "px"; let previousMaxWidthUnit = "px"; let previousHeightUnit = "px"; let previousMaxHeightUnit = "px"; let previousAlpha = 0.4; this.stack.forEach((entry, index) => { if (entry.element) { entry.element.style.zIndex = this.baseZIndex + index * 10; const isStacked = index > 0 && entry.type === "stack"; if (isStacked) { this.stack.slice(0, index).filter((e) => e.type === "stack").length + 1; const alpha = Math.max(0.05, previousAlpha * 0.5); entry.element.style.background = `rgba(0, 0, 0, ${alpha})`; previousAlpha = alpha; } else { entry.element.style.background = "rgba(0, 0, 0, 0.4)"; previousAlpha = 0.4; } const modalContent = entry.element.querySelector(".gut-modal-content"); if (isStacked && modalContent) { entry.element.classList.add("gut-modal-stacked"); if (!entry.originalDimensions) { const inlineStyle = modalContent.style; entry.originalDimensions = { width: inlineStyle.width || null, maxWidth: inlineStyle.maxWidth || null, height: inlineStyle.height || null, maxHeight: inlineStyle.maxHeight || null }; if (!entry.originalDimensions.width) { const computedStyle = window.getComputedStyle(modalContent); entry.originalDimensions.width = computedStyle.width; } if (!entry.originalDimensions.maxWidth) { const computedStyle = window.getComputedStyle(modalContent); entry.originalDimensions.maxWidth = computedStyle.maxWidth; } if (!entry.originalDimensions.height) { const computedStyle = window.getComputedStyle(modalContent); entry.originalDimensions.height = computedStyle.height; } if (!entry.originalDimensions.maxHeight) { const computedStyle = window.getComputedStyle(modalContent); entry.originalDimensions.maxHeight = computedStyle.maxHeight; } } const stackDepth = this.stack.slice(0, index).filter((e) => e.type === "stack").length + 1; const offsetAmount = stackDepth * 20; let targetWidth = previousWidth; let targetMaxWidth = previousMaxWidth; let targetHeight = previousHeight; let targetMaxHeight = previousMaxHeight; let targetWidthUnit = previousWidthUnit; let targetMaxWidthUnit = previousMaxWidthUnit; let targetHeightUnit = previousHeightUnit; let targetMaxHeightUnit = previousMaxHeightUnit; if (targetWidth === null) { const parsed = this.parseDimensionWithUnit(entry.originalDimensions.width); targetWidth = parsed.value; targetWidthUnit = parsed.unit; } if (targetMaxWidth === null) { const parsed = this.parseDimensionWithUnit(entry.originalDimensions.maxWidth); targetMaxWidth = parsed.value; targetMaxWidthUnit = parsed.unit; } if (targetHeight === null) { const parsed = this.parseDimensionWithUnit(entry.originalDimensions.height); targetHeight = parsed.value; targetHeightUnit = parsed.unit; } if (targetMaxHeight === null) { const parsed = this.parseDimensionWithUnit(entry.originalDimensions.maxHeight); targetMaxHeight = parsed.value; targetMaxHeightUnit = parsed.unit; } const scaledWidth = targetWidth * 0.95; const scaledMaxWidth = targetMaxWidth * 0.95; const scaledHeight = targetHeight * 0.9; const scaledMaxHeight = targetMaxHeight * 0.9; if (entry.originalDimensions.width && entry.originalDimensions.width !== "auto") { modalContent.style.width = this.formatDimension(scaledWidth, targetWidthUnit); previousWidth = scaledWidth; previousWidthUnit = targetWidthUnit; } if (entry.originalDimensions.maxWidth && entry.originalDimensions.maxWidth !== "none") { modalContent.style.maxWidth = this.formatDimension(scaledMaxWidth, targetMaxWidthUnit); previousMaxWidth = scaledMaxWidth; previousMaxWidthUnit = targetMaxWidthUnit; } if (entry.originalDimensions.height && entry.originalDimensions.height !== "auto") { modalContent.style.height = this.formatDimension(scaledHeight, targetHeightUnit); previousHeight = scaledHeight; previousHeightUnit = targetHeightUnit; } if (entry.originalDimensions.maxHeight && entry.originalDimensions.maxHeight !== "none") { modalContent.style.maxHeight = this.formatDimension(scaledMaxHeight, targetMaxHeightUnit); previousMaxHeight = scaledMaxHeight; previousMaxHeightUnit = targetMaxHeightUnit; } modalContent.style.marginTop = `${offsetAmount}px`; } else { entry.element.classList.remove("gut-modal-stacked"); if (modalContent) { const savedWidth = loadModalWidth(); modalContent.style.width = ""; modalContent.style.height = ""; modalContent.style.marginTop = ""; if (savedWidth) { modalContent.style.maxWidth = `${savedWidth}px`; } else { modalContent.style.maxWidth = ""; } const computedStyle = window.getComputedStyle(modalContent); const inlineStyle = modalContent.style; let widthParsed = this.parseDimensionWithUnit(inlineStyle.width); if (!widthParsed.value && computedStyle.width) { const detected = this.detectViewportUnit(computedStyle.width, "width"); widthParsed = detected || this.parseDimensionWithUnit(computedStyle.width); } let maxWidthParsed = this.parseDimensionWithUnit(inlineStyle.maxWidth || (savedWidth ? `${savedWidth}px` : null)); if (!maxWidthParsed.value && computedStyle.maxWidth) { const detected = this.detectViewportUnit(computedStyle.maxWidth, "maxWidth"); maxWidthParsed = detected || this.parseDimensionWithUnit(computedStyle.maxWidth); } let heightParsed = this.parseDimensionWithUnit(inlineStyle.height); if (!heightParsed.value && computedStyle.height) { const detected = this.detectViewportUnit(computedStyle.height, "height"); heightParsed = detected || this.parseDimensionWithUnit(computedStyle.height); } let maxHeightParsed = this.parseDimensionWithUnit(inlineStyle.maxHeight); if (!maxHeightParsed.value && computedStyle.maxHeight) { const detected = this.detectViewportUnit(computedStyle.maxHeight, "maxHeight"); maxHeightParsed = detected || this.parseDimensionWithUnit(computedStyle.maxHeight); } previousWidth = widthParsed.value; previousWidthUnit = widthParsed.unit; previousMaxWidth = maxWidthParsed.value; previousMaxWidthUnit = maxWidthParsed.unit; previousHeight = heightParsed.value; previousHeightUnit = heightParsed.unit; previousMaxHeight = maxHeightParsed.value; previousMaxHeightUnit = maxHeightParsed.unit; } } } }); } updateResizeHandles() { this.stack.forEach((entry, index) => { const isTopModal = index === this.stack.length - 1; if (isTopModal) { this.addResizeHandles(entry.element); } else { this.removeResizeHandles(entry.element); } }); } addResizeHandles(modalElement) { if (!modalElement) return; const modalContent = modalElement.querySelector(".gut-modal-content"); if (!modalContent) return; if (modalContent.querySelector(".gut-resize-handle")) { return; } const leftHandle = document.createElement("div"); leftHandle.className = "gut-resize-handle gut-resize-handle-left"; leftHandle.dataset.side = "left"; const rightHandle = document.createElement("div"); rightHandle.className = "gut-resize-handle gut-resize-handle-right"; rightHandle.dataset.side = "right"; modalContent.appendChild(leftHandle); modalContent.appendChild(rightHandle); [leftHandle, rightHandle].forEach((handle) => { handle.addEventListener("mouseenter", () => { if (!this.isResizing) { handle.classList.add("gut-resize-handle-hover"); } }); handle.addEventListener("mouseleave", () => { if (!this.isResizing) { handle.classList.remove("gut-resize-handle-hover"); } }); handle.addEventListener("mousedown", (e) => { e.preventDefault(); e.stopPropagation(); this.startResize(e, modalContent, handle.dataset.side); }); }); } removeResizeHandles(modalElement) { if (!modalElement) return; const modalContent = modalElement.querySelector(".gut-modal-content"); if (!modalContent) return; const handles = modalContent.querySelectorAll(".gut-resize-handle"); handles.forEach((handle) => handle.remove()); } startResize(e, modalContent, side) { this.isResizing = true; this.currentResizeModal = modalContent; this.resizeStartX = e.clientX; this.resizeSide = side; const computedStyle = window.getComputedStyle(modalContent); this.resizeStartWidth = parseFloat(computedStyle.width); document.body.style.cursor = "ew-resize"; document.body.style.userSelect = "none"; const handles = modalContent.querySelectorAll(".gut-resize-handle"); handles.forEach( (handle) => handle.classList.add("gut-resize-handle-active") ); } handleResize(e) { if (!this.isResizing || !this.currentResizeModal) return; const deltaX = e.clientX - this.resizeStartX; const adjustedDelta = this.resizeSide === "left" ? -deltaX : deltaX; const newWidth = Math.max( 400, Math.min(2e3, this.resizeStartWidth + adjustedDelta) ); this.currentResizeModal.style.maxWidth = `${newWidth}px`; } endResize() { if (!this.isResizing || !this.currentResizeModal) return; const computedStyle = window.getComputedStyle(this.currentResizeModal); const finalWidth = parseFloat(computedStyle.maxWidth); saveModalWidth(Math.round(finalWidth)); document.body.style.cursor = ""; document.body.style.userSelect = ""; const handles = this.currentResizeModal.querySelectorAll(".gut-resize-handle"); handles.forEach((handle) => { handle.classList.remove("gut-resize-handle-active"); handle.classList.remove("gut-resize-handle-hover"); }); setTimeout(() => { this.isResizing = false; this.currentResizeModal = null; this.resizeSide = null; }, 50); } captureFormState(modalId, modalElement, formSelector) { if (!modalElement) return; const fields = modalElement.querySelectorAll(formSelector); const state = {}; fields.forEach((field, index) => { const key = field.id || field.name || `field_${index}`; state[key] = this.serializeFormValue(field); }); this.changeTracking.set(modalId, { initialState: state, formSelector }); } serializeFormValue(element) { if (!element) return null; const tagName = element.tagName.toLowerCase(); const type = element.type ? element.type.toLowerCase() : ""; if (tagName === "input") { if (type === "checkbox" || type === "radio") { return element.checked; } return element.value; } if (tagName === "textarea") { return element.value; } if (tagName === "select") { if (element.multiple) { return Array.from(element.selectedOptions).map((opt) => opt.value); } return element.value; } if (element.isContentEditable) { return element.textContent; } return element.value || null; } hasUnsavedChanges(modalId) { const tracking = this.changeTracking.get(modalId); if (!tracking) return false; const entry = this.stack.find((e) => e.id === modalId); if (!entry || !entry.element) return false; if (entry.customChangeCheck) { return entry.customChangeCheck(entry.element); } const currentState = {}; const fields = entry.element.querySelectorAll(tracking.formSelector); fields.forEach((field, index) => { const key = field.id || field.name || `field_${index}`; currentState[key] = this.serializeFormValue(field); }); return !this.compareFormStates(tracking.initialState, currentState); } compareFormStates(state1, state2) { const keys1 = Object.keys(state1); const keys2 = Object.keys(state2); if (keys1.length !== keys2.length) return false; for (const key of keys1) { const val1 = state1[key]; const val2 = state2[key]; if (Array.isArray(val1) && Array.isArray(val2)) { if (val1.length !== val2.length) return false; for (let i = 0; i < val1.length; i++) { if (val1[i] !== val2[i]) return false; } } else if (val1 !== val2) { return false; } } return true; } markChangesSaved(modalId) { const entry = this.stack.find((e) => e.id === modalId); if (!entry || !entry.trackChanges) return; const tracking = this.changeTracking.get(modalId); if (!tracking) return; this.captureFormState(modalId, entry.element, tracking.formSelector); } stopTrackingChanges(modalId) { this.changeTracking.delete(modalId); } setUnsavedChangesHandler(handler) { this.unsavedChangesHandler = handler; } async showUnsavedChangesConfirmation() { if (!this.unsavedChangesHandler) { console.error("ModalStack: No unsaved changes handler registered"); return true; } return new Promise((resolve) => { this.unsavedChangesHandler(resolve); }); } setupGlobalHandlers() { document.addEventListener("keydown", async (e) => { if (e.key === "Escape" && this.stack.length > 0) { if (this.isKeybindingRecorderActive()) { return; } if (this.hasEscapeHandlers()) { const handler = this.escapeHandlers[this.escapeHandlers.length - 1]; const result = handler(e); if (result === true) { return; } } const current = this.stack[this.stack.length - 1]; if (current.type === "stack") { await this.pop(); } else if (current.type === "replace") { if (current.canGoBack) { await this.back(); } else { await this.pop(); } } } }); document.addEventListener("mousemove", (e) => { this.handleResize(e); }); document.addEventListener("mouseup", () => { this.endResize(); }); } } const ModalStack = new ModalStackManager(); function createModal(htmlContent, options = {}) { const container = document.createElement("div"); container.innerHTML = htmlContent; const modal = container.firstElementChild; ModalStack.push(modal, options); return modal; } function setupMaskValidation(maskInput, cursorInfoElement, statusContainer, overlayElement, onValidationChange = null, availableHints = {}) { let autocompleteDropdown = null; let selectedIndex = -1; let filteredHints = []; const closeAutocomplete = () => { if (autocompleteDropdown) { autocompleteDropdown.remove(); autocompleteDropdown = null; selectedIndex = -1; filteredHints = []; ModalStack.popEscapeHandler(); } }; const showAutocomplete = (hints, cursorPos) => { closeAutocomplete(); if (hints.length === 0) return; filteredHints = hints; selectedIndex = 0; autocompleteDropdown = document.createElement("div"); autocompleteDropdown.className = "gut-hint-autocomplete"; const rect = maskInput.getBoundingClientRect(); const inputContainer = maskInput.parentElement; const containerRect = inputContainer.getBoundingClientRect(); autocompleteDropdown.style.position = "absolute"; autocompleteDropdown.style.top = `${rect.bottom - containerRect.top + 2}px`; autocompleteDropdown.style.left = `${rect.left - containerRect.left}px`; autocompleteDropdown.style.minWidth = `${rect.width}px`; hints.forEach((hint, index) => { const item = document.createElement("div"); item.className = "gut-hint-autocomplete-item"; if (index === 0) item.classList.add("selected"); item.innerHTML = ` <div class="gut-hint-autocomplete-name">${escapeHtml(hint.name)}</div> <div class="gut-hint-autocomplete-type">${hint.type}</div> <div class="gut-hint-autocomplete-desc">${escapeHtml(hint.description || "")}</div> `; item.addEventListener("mouseenter", () => { autocompleteDropdown.querySelectorAll(".gut-hint-autocomplete-item").forEach((i) => i.classList.remove("selected")); item.classList.add("selected"); selectedIndex = index; }); item.addEventListener("click", () => { insertHint(hint.name); closeAutocomplete(); }); autocompleteDropdown.appendChild(item); }); inputContainer.style.position = "relative"; inputContainer.appendChild(autocompleteDropdown); ModalStack.pushEscapeHandler(() => { closeAutocomplete(); return true; }); }; const insertHint = (hintName) => { const value = maskInput.value; const cursorPos = maskInput.selectionStart; const beforeCursor = value.substring(0, cursorPos); const afterCursor = value.substring(cursorPos); const match = beforeCursor.match(/\$\{([a-zA-Z0-9_]+):([a-zA-Z0-9_]*)$/); if (match) { const [fullMatch, varName, partialHint] = match; const newValue = beforeCursor.substring(0, beforeCursor.length - partialHint.length) + hintName + afterCursor; maskInput.value = newValue; maskInput.selectionStart = maskInput.selectionEnd = cursorPos - partialHint.length + hintName.length; maskInput.dispatchEvent(new Event("input")); } }; const updateAutocomplete = () => { const cursorPos = maskInput.selectionStart; const value = maskInput.value; const beforeCursor = value.substring(0, cursorPos); const match = beforeCursor.match(/\$\{([a-zA-Z0-9_]+):([a-zA-Z0-9_]*)$/); if (match) { const [, varName, partialHint] = match; const hints = Object.entries(availableHints).filter( ([name]) => name.toLowerCase().startsWith(partialHint.toLowerCase()) ).map(([name, hint]) => ({ name, type: hint.type, description: hint.description || "" })).slice(0, 10); if (hints.length > 0) { showAutocomplete(hints); } else { closeAutocomplete(); } } else { closeAutocomplete(); } }; const handleKeyDown = (e) => { if (!autocompleteDropdown) return; if (e.key === "ArrowDown") { e.preventDefault(); selectedIndex = Math.min(selectedIndex + 1, filteredHints.length - 1); updateSelection(); } else if (e.key === "ArrowUp") { e.preventDefault(); selectedIndex = Math.max(selectedIndex - 1, 0); updateSelection(); } else if (e.key === "Enter") { if (selectedIndex >= 0 && selectedIndex < filteredHints.length) { e.preventDefault(); insertHint(filteredHints[selectedIndex].name); closeAutocomplete(); } } else if (e.key === "Escape") { return; } else if (e.key === "Tab") { if (selectedIndex >= 0 && selectedIndex < filteredHints.length) { e.preventDefault(); insertHint(filteredHints[selectedIndex].name); closeAutocomplete(); } } }; const updateSelection = () => { if (!autocompleteDropdown) return; const items = autocompleteDropdown.querySelectorAll( ".gut-hint-autocomplete-item" ); items.forEach((item, index) => { if (index === selectedIndex) { item.classList.add("selected"); item.scrollIntoView({ block: "nearest" }); } else { item.classList.remove("selected"); } }); }; const findVariableAtCursor = (mask, cursorPos) => { const varPattern = /\$\{([^}]+)\}/g; let match; while ((match = varPattern.exec(mask)) !== null) { const varStart = match.index; const varEnd = varStart + match[0].length; if (cursorPos >= varStart && cursorPos <= varEnd) { const content = match[1]; const colonIndex = content.indexOf(":"); if (colonIndex === -1) { return null; } const hintName = content.substring(colonIndex + 1).trim(); const hintStartInVar = colonIndex + 1; const hintStart = varStart + 2 + hintStartInVar; const hintEnd = varEnd - 1; if (cursorPos >= hintStart && cursorPos <= hintEnd) { return { hintName, varContent: content, hintStart, hintEnd }; } } } return null; }; const formatHintInfo = (varName, hint) => { if (!hint) { return `<span style="color: #888;">No hint defined</span>`; } const parts = []; if (hint.type === "pattern") { parts.push(`<span style="color: #4dd0e1;">pattern:</span> <span style="color: #a5d6a7;">${escapeHtml(hint.pattern)}</span>`); } else if (hint.type === "regex") { parts.push(`<span style="color: #4dd0e1;">regex:</span> <span style="color: #a5d6a7;">${escapeHtml(hint.pattern)}</span>`); } else if (hint.type === "map") { const count = Object.keys(hint.mappings || {}).length; const preview = Object.keys(hint.mappings || {}).slice(0, 3).join(", "); parts.push(`<span style="color: #4dd0e1;">map:</span> <span style="color: #b39ddb;">${count} value${count !== 1 ? "s" : ""}</span> <span style="color: #888;">(${escapeHtml(preview)}${count > 3 ? "..." : ""})</span>`); } if (hint.description) { parts.push(`<span style="color: #999;">${escapeHtml(hint.description)}</span>`); } return parts.join(" \xB7 "); }; const updateCursorInfo = (validation) => { const pos = maskInput.selectionStart; const maskValue = maskInput.value; const variable = findVariableAtCursor(maskValue, pos); if (variable) { const hint = availableHints[variable.hintName]; cursorInfoElement.style.display = "block"; cursorInfoElement.innerHTML = `<span style="color: #64b5f6; font-weight: 500;">\${${escapeHtml(variable.varContent)}}</span> \xB7 ${formatHintInfo(variable.hintName, hint)}`; return; } 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; } 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, availableHints); updateMaskHighlighting(maskInput, overlayElement, availableHints); renderStatusMessages(statusContainer, validation); updateCursorInfo(validation); updateAutocomplete(); if (onValidationChange) { onValidationChange(validation); } return validation; }; maskInput.addEventListener("input", performValidation); maskInput.addEventListener("click", () => { const validation = validateMaskWithDetails(maskInput.value, availableHints); updateCursorInfo(validation); }); maskInput.addEventListener("keyup", (e) => { if (!["ArrowDown", "ArrowUp", "ArrowLeft", "ArrowRight", "Enter", "Escape", "Tab"].includes(e.key)) { const validation = validateMaskWithDetails( maskInput.value, availableHints ); updateCursorInfo(validation); updateAutocomplete(); } else if (["ArrowLeft", "ArrowRight"].includes(e.key)) { const validation = validateMaskWithDetails( maskInput.value, availableHints ); updateCursorInfo(validation); } }); maskInput.addEventListener("keydown", handleKeyDown); maskInput.addEventListener("focus", () => { const validation = validateMaskWithDetails(maskInput.value, availableHints); updateCursorInfo(validation); updateAutocomplete(); }); maskInput.addEventListener("blur", () => { setTimeout(closeAutocomplete, 200); }); 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(null, null, "direct") ); } 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, "direct"); }); } if (applyBtn) { applyBtn.addEventListener( "click", () => instance.applyTemplateToCurrentTorrent() ); } const variablesRow = document.getElementById("variables-row"); if (variablesRow) { variablesRow.addEventListener("click", async () => { const variables = await instance.getCurrentVariables(); const totalCount = Object.keys(variables.all).length; if (totalCount > 0) { instance.showVariablesModal(); } }); } } catch (error) { console.error("Failed to bind UI events:", error); } } async function showTemplateCreator(instance, editTemplateName = null, editTemplate2 = null, openMode = "manage") { 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, editTemplate2, selectedTorrentName, openMode ); const canGoBack = openMode === "manage" && editTemplateName !== null; ModalStack.replace(modal, { type: "replace", canGoBack, backFactory: canGoBack ? () => instance.showTemplateAndSettingsManager() : null, metadata: { instance, editTemplateName, editTemplate: editTemplate2 }, trackChanges: true, formSelector: 'input, textarea, select, [contenteditable="true"]' }); 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 selectAllBtn = modal.querySelector("#template-select-all-btn"); const selectNoneBtn = modal.querySelector("#template-select-none-btn"); if (selectAllBtn) { selectAllBtn.addEventListener("click", (e) => { e.preventDefault(); const fieldCheckboxes = modal.querySelectorAll('.gut-field-row input[type="checkbox"][data-field]'); fieldCheckboxes.forEach((cb) => cb.checked = true); filterFields(); }); } if (selectNoneBtn) { selectNoneBtn.addEventListener("click", (e) => { e.preventDefault(); const fieldCheckboxes = modal.querySelectorAll('.gut-field-row input[type="checkbox"][data-field]'); fieldCheckboxes.forEach((cb) => cb.checked = false); 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(); }, instance.hints ); const updatePreviews = () => { const mask = maskInput.value; const sample = sampleInput.value; const validation = validateMaskWithDetails(mask, instance.hints); const parseResult = parseTemplateWithOptionals( mask, sample, instance.hints ); 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", () => { if (canGoBack) { ModalStack.back(); } else { ModalStack.pop(); } }); const closeX = modal.querySelector("#modal-close-x"); if (closeX) { closeX.addEventListener("click", () => { if (canGoBack) { ModalStack.back(); } else { ModalStack.pop(); } }); } modal.querySelector("#save-template").addEventListener("click", () => { instance.saveTemplate(modal, editTemplateName); }); modal.addEventListener("click", (e) => { if (e.target === modal) { if (canGoBack) { ModalStack.back(); } else { ModalStack.pop(); } } }); 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.style.display = "none"; variableControls.style.display = "flex"; } else { staticSelect.style.display = ""; variableControls.style.display = "none"; } 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", () => { ModalStack.back(); }); } const sandboxLink = modal.querySelector("#test-mask-sandbox-link"); if (sandboxLink) { sandboxLink.addEventListener("click", (e) => { e.preventDefault(); const mask = maskInput.value; const sample = sampleInput.value; ModalStack.pop(); instance.showSandboxWithMask(mask, sample); }); } } function showVariablesModal(instance, variables, torrentName = "", mask = "") { const modal = document.createElement("div"); modal.className = "gut-modal"; modal.innerHTML = VARIABLES_MODAL_HTML(); ModalStack.push(modal, { type: "stack", metadata: { instance, variables, torrentName, mask } }); const resultsContainer = modal.querySelector("#variables-results-container"); if (torrentName && mask) { const testResults = testMaskAgainstSamples( mask, [torrentName], instance.hints ); renderMatchResults(resultsContainer, testResults, { showLabel: false }); } else if (Object.keys(variables).length > 0) { const testResults = { results: [ { name: torrentName || "Unknown", matched: true, variables, positions: {} } ] }; renderMatchResults(resultsContainer, testResults, { showLabel: false }); } modal.querySelector("#close-variables-modal").addEventListener("click", () => { ModalStack.pop(); }); const closeX = modal.querySelector("#modal-close-x"); if (closeX) { closeX.addEventListener("click", () => { ModalStack.pop(); }); } modal.addEventListener("click", (e) => { if (e.target === modal) { ModalStack.pop(); } }); } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } function truncateValue(value, threshold = 20) { if (!value || value.length <= threshold) { return value; } const words = value.split(/\s+/); if (words.length <= 2) { return value; } let firstWord = words[0]; let lastWord = words[words.length - 1]; if (firstWord.length > 10) { firstWord = firstWord.substring(0, 10); } if (lastWord.length > 10) { lastWord = lastWord.substring(lastWord.length - 10); } return `${firstWord}<span class="gut-truncate-ellipsis">...</span>${lastWord}`; } function wrapWordsInSpans(text) { const chars = text.split(""); return chars.map( (char, i) => `<span class="gut-char-span" data-char-index="${i}">${escapeHtml(char)}</span>` ).join(""); } function renderMatchResults(container, testResults, options = {}) { const { showLabel = true, labelElement = null } = options; if (!container || !testResults || testResults.results.length === 0) { container.innerHTML = '<div class="gut-no-variables">Enter a mask and sample names to see match results.</div>'; if (showLabel && labelElement) { labelElement.textContent = "Match Results:"; } return; } const matchCount = testResults.results.filter((r) => r.matched).length; const totalCount = testResults.results.length; if (showLabel && labelElement) { labelElement.textContent = `Match Results (${matchCount}/${totalCount} matched):`; } const html2 = 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-match-variables-container">' + Object.entries(result.variables).filter( ([key]) => key !== "_matchedOptionals" && key !== "_optionalCount" ).map(([key, value]) => { const displayValue = value ? truncateValue(escapeHtml(value)) : "(empty)"; return `<div class="gut-variable-item gut-match-variable-item" data-result-index="${resultIndex}" data-var-name="${escapeHtml(key)}"> <span class="gut-variable-name">\${${escapeHtml(key)}}</span><span class="gut-match-separator"> = </span><span class="gut-variable-value">${displayValue}</span> </div>`; }).join("") + "</div>"; if (result.optionalInfo) { variablesHtml += `<div class="gut-match-optional-info"> Optional blocks: ${result.optionalInfo.matched}/${result.optionalInfo.total} matched </div>`; } } return ` <div class="${className} gut-match-result-item" data-result-index="${resultIndex}"> <div class="gut-match-result-header"> <span class="gut-match-icon gut-match-icon-${isMatch ? "success" : "error"}">${icon}</span> <div class="gut-sandbox-sample-name" data-result-index="${resultIndex}">${wrapWordsInSpans(result.name)}</div> </div> ${variablesHtml} </div> `; }).join(""); container.innerHTML = html2; container._testResults = testResults; if (!container._hasEventListeners) { container.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 = container._testResults; if (!currentResults || !currentResults.results[resultIndex]) { return; } const result = currentResults.results[resultIndex]; if (result.positions && result.positions[varName]) { const sampleNameEl = container.querySelector( `.gut-sandbox-sample-name[data-result-index="${resultIndex}"]` ); const pos = result.positions[varName]; const charSpans = sampleNameEl.querySelectorAll(".gut-char-span"); charSpans.forEach((span) => { const charIndex = parseInt(span.dataset.charIndex); if (charIndex >= pos.start && charIndex < pos.end) { span.classList.add("gut-match-highlight"); } }); } } }, true ); container.addEventListener( "mouseleave", (e) => { if (e.target.classList.contains("gut-variable-item")) { const resultIndex = parseInt(e.target.dataset.resultIndex); const currentResults = container._testResults; if (!currentResults || !currentResults.results[resultIndex]) { return; } currentResults.results[resultIndex]; const sampleNameEl = container.querySelector( `.gut-sandbox-sample-name[data-result-index="${resultIndex}"]` ); const charSpans = sampleNameEl.querySelectorAll(".gut-char-span"); charSpans.forEach((span) => span.classList.remove("gut-match-highlight")); } }, true ); container._hasEventListeners = true; } } function renderSandboxResults(modal, testResults) { const resultsContainer = modal.querySelector("#sandbox-results"); const resultsLabel = modal.querySelector("#sandbox-results-label"); renderMatchResults(resultsContainer, testResults, { showLabel: true, labelElement: resultsLabel }); } function saveTemplate(instance, 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 && instance.templates[name] || !editingTemplateName && instance.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 = instance.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 instance.templates[editingTemplateName]; if (instance.selectedTemplate === editingTemplateName) { instance.selectedTemplate = name; saveSelectedTemplate(name); } } instance.templates[name] = { mask, fieldMappings, customUnselectedFields: customUnselectedFields.length > 0 ? customUnselectedFields : void 0, variableMatching: Object.keys(variableMatchingConfig).length > 0 ? variableMatchingConfig : void 0 }; saveTemplates(instance.templates); updateTemplateSelector(instance); instance.updateVariableCount(); const action = editingTemplateName ? "updated" : "saved"; instance.showStatus(`Template "${name}" ${action} successfully!`); const currentModal = ModalStack.getCurrentModal(); if (currentModal) { ModalStack.markChangesSaved(currentModal.id); } ModalStack.pop(); } function deleteTemplate(instance, templateName) { delete instance.templates[templateName]; saveTemplates(instance.templates); if (instance.selectedTemplate === templateName) { instance.selectedTemplate = null; removeSelectedTemplate(); } updateTemplateSelector(instance); instance.showStatus(`Template "${templateName}" deleted`); } function cloneTemplate(instance, templateName) { const originalTemplate = instance.templates[templateName]; if (!originalTemplate) return; const cloneName = `${templateName} (Clone)`; instance.templates[cloneName] = { mask: originalTemplate.mask, fieldMappings: { ...originalTemplate.fieldMappings }, customUnselectedFields: originalTemplate.customUnselectedFields ? [...originalTemplate.customUnselectedFields] : void 0, variableMatching: originalTemplate.variableMatching ? { ...originalTemplate.variableMatching } : void 0 }; saveTemplates(instance.templates); updateTemplateSelector(instance); instance.showStatus(`Template "${cloneName}" created`); } function editTemplate(instance, templateName, openMode = "manage") { const template = instance.templates[templateName]; if (!template) return; instance.showTemplateCreator(templateName, template, openMode); } function selectTemplate(instance, templateName) { instance.selectedTemplate = templateName || null; if (templateName) { saveSelectedTemplate(templateName); } else { removeSelectedTemplate(); } updateEditButtonVisibility(instance); instance.updateVariableCount(); if (templateName === "none") { instance.showStatus("No template selected - auto-fill disabled"); } else if (templateName) { instance.showStatus(`Template "${templateName}" selected`); } } function updateTemplateSelector(instance) { const selector = document.getElementById("template-selector"); if (!selector) return; selector.innerHTML = TEMPLATE_SELECTOR_HTML(instance); updateEditButtonVisibility(instance); } function updateEditButtonVisibility(instance) { const editBtn = document.getElementById("edit-selected-template-btn"); if (!editBtn) return; const shouldShow = instance.selectedTemplate && instance.selectedTemplate !== "none" && instance.templates[instance.selectedTemplate]; editBtn.style.display = shouldShow ? "" : "none"; } function refreshTemplateManager(instance, modal) { const templateList = modal.querySelector(".gut-template-list"); if (!templateList) return; templateList.innerHTML = TEMPLATE_LIST_HTML(instance); } function setupAutoResize(textarea, options = {}) { if (!textarea || textarea.tagName !== "TEXTAREA") { console.warn("setupAutoResize: Invalid textarea element provided"); return () => { }; } const { minLines = 3, maxLines = 7, initialResize = true } = options; const autoResize = () => { textarea.style.height = "auto"; const computedStyle = window.getComputedStyle(textarea); const lineHeight = parseFloat(computedStyle.lineHeight); const fontSize = parseFloat(computedStyle.fontSize); const actualLineHeight = lineHeight && lineHeight !== 0 ? lineHeight : fontSize * 1.4; const minHeight = actualLineHeight * minLines; const maxHeight = actualLineHeight * maxLines; const contentHeight = textarea.scrollHeight; const newHeight = Math.min(Math.max(contentHeight, minHeight), maxHeight); textarea.style.height = newHeight + "px"; }; textarea.addEventListener("input", autoResize); textarea.addEventListener("change", autoResize); if (initialResize) { setTimeout(autoResize, 0); } return autoResize; } 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("+"); } function showTemplateAndSettingsManager(instance) { const modal = createModal(MODAL_HTML(instance), { type: "replace", canGoBack: false, trackChanges: true, formSelector: "#settings-tab input, #settings-tab textarea, #settings-tab select, #sandbox-tab input, #sandbox-tab textarea", metadata: { instance } }); 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 = instance.config.CUSTOM_FIELD_SELECTORS; instance.config.CUSTOM_FIELD_SELECTORS = selectors; if (selectors.length === 0) { previewGroup.style.display = "none"; instance.config.CUSTOM_FIELD_SELECTORS = originalSelectors; return; } previewGroup.style.display = "block"; let matchedElements = []; const formSelector = modal.querySelector("#setting-form-selector").value.trim() || instance.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, instance.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">${instance.escapeHtml(displayName)}</span> <span class="gut-variable-value">${instance.escapeHtml(displayInfo)}</span> </div> `; }).join(""); } instance.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", () => { saveSettingsFromModal(instance, modal); }); modal.querySelector("#reset-settings")?.addEventListener("click", () => { if (confirm( "Reset all settings to defaults? This will require a page reload." )) { resetSettings(instance, 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?" )) { deleteAllConfig(instance); } }); 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"); const toggleCompiledRegexLink = modal.querySelector("#toggle-compiled-regex"); const sandboxCompiledRegexDisplay = modal.querySelector( "#sandbox-compiled-regex" ); let sandboxDebounceTimeout = null; let currentLoadedSet = instance.currentSandboxSet || ""; let showingCompiledRegex = localStorage.getItem("ggn-upload-templator-show-compiled-regex") === "true"; 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, instance.hints); renderSandboxResults(modal, result); }; const updateCompiledRegex = () => { if (showingCompiledRegex) { const mask = sandboxMaskInput.value; if (mask) { try { const compiledRegex = compileUserMaskToRegex(mask, instance.hints); sandboxCompiledRegexDisplay.textContent = compiledRegex; } catch (error) { sandboxCompiledRegexDisplay.textContent = `Error: ${error.message}`; } } else { sandboxCompiledRegexDisplay.textContent = ""; } } }; const debouncedUpdateSandboxTest = () => { if (sandboxDebounceTimeout) { clearTimeout(sandboxDebounceTimeout); } sandboxDebounceTimeout = setTimeout(() => { updateSandboxTest(); updateCompiledRegex(); }, 300); }; setupMaskValidation( sandboxMaskInput, sandboxCursorInfo, sandboxStatusContainer, sandboxMaskDisplay, () => { debouncedUpdateSandboxTest(); }, instance.hints ); 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 = ""; instance.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 || ""; sandboxMaskInput.dispatchEvent(new Event("change")); sandboxSampleInput.dispatchEvent(new Event("change")); updateMaskHighlighting(sandboxMaskInput, sandboxMaskDisplay, instance.hints); updateSandboxTest(); currentLoadedSet = value; instance.currentSandboxSet = value; localStorage.setItem("ggn-upload-templator-sandbox-current", value); updateButtonStates(); if (showingCompiledRegex) { updateCompiledRegex(); sandboxCompiledRegexDisplay.classList.add("visible"); toggleCompiledRegexLink.textContent = "Hide compiled regex"; } } }); if (sandboxSampleInput) { setupAutoResize(sandboxSampleInput, { minLines: 3, maxLines: 7, initialResize: true }); } saveBtn?.addEventListener("click", () => { if (currentLoadedSet && currentLoadedSet !== "") { const data = { mask: sandboxMaskInput.value, samples: sandboxSampleInput.value }; saveSandboxSet(instance, currentLoadedSet, data); const currentModal = ModalStack.getCurrentModal(); if (currentModal) { ModalStack.markChangesSaved(currentModal.id); } instance.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 }; saveSandboxSet(instance, trimmedName, data); instance.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(); const currentModal = ModalStack.getCurrentModal(); if (currentModal) { ModalStack.markChangesSaved(currentModal.id); } instance.showStatus(`Test set "${trimmedName}" saved successfully!`); } } }); deleteBtn?.addEventListener("click", () => { if (!currentLoadedSet || currentLoadedSet === "") { return; } if (confirm(`Delete test set "${currentLoadedSet}"?`)) { deleteSandboxSet(instance, currentLoadedSet); const option = sandboxSetSelect.querySelector( `option[value="${currentLoadedSet}"]` ); if (option) { option.remove(); } sandboxSetSelect.value = ""; currentLoadedSet = ""; instance.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(); instance.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 (instance.sandboxSets[trimmedName]) { alert(`A test set named "${trimmedName}" already exists.`); return; } const data = instance.sandboxSets[currentLoadedSet]; instance.sandboxSets[trimmedName] = data; delete instance.sandboxSets[currentLoadedSet]; localStorage.setItem( "ggn-upload-templator-sandbox-sets", JSON.stringify(instance.sandboxSets) ); const option = sandboxSetSelect.querySelector( `option[value="${currentLoadedSet}"]` ); if (option) { option.value = trimmedName; option.textContent = trimmedName; option.selected = true; } currentLoadedSet = trimmedName; instance.currentSandboxSet = trimmedName; localStorage.setItem("ggn-upload-templator-sandbox-current", trimmedName); instance.showStatus(`Test set renamed to "${trimmedName}" successfully!`); }); toggleCompiledRegexLink?.addEventListener("click", (e) => { e.preventDefault(); showingCompiledRegex = !showingCompiledRegex; localStorage.setItem( "ggn-upload-templator-show-compiled-regex", showingCompiledRegex ); if (showingCompiledRegex) { const mask = sandboxMaskInput.value; if (!mask) { instance.showStatus("Enter a mask first", "error"); showingCompiledRegex = false; localStorage.setItem( "ggn-upload-templator-show-compiled-regex", "false" ); return; } updateCompiledRegex(); sandboxCompiledRegexDisplay.classList.add("visible"); toggleCompiledRegexLink.textContent = "Hide compiled regex"; } else { sandboxCompiledRegexDisplay.classList.remove("visible"); toggleCompiledRegexLink.textContent = "Show compiled regex"; } }); 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, instance.hints); if (showingCompiledRegex) { updateCompiledRegex(); } }); 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 || ""; sandboxMaskInput.dispatchEvent(new Event("change")); sandboxSampleInput.dispatchEvent(new Event("change")); updateMaskHighlighting(sandboxMaskInput, sandboxMaskDisplay, instance.hints); updateSandboxTest(); if (showingCompiledRegex) { updateCompiledRegex(); sandboxCompiledRegexDisplay.classList.add("visible"); toggleCompiledRegexLink.textContent = "Hide compiled regex"; } } } else if (sandboxMaskInput) { updateMaskHighlighting(sandboxMaskInput, sandboxMaskDisplay, instance.hints); if (sandboxMaskInput.value && sandboxSampleInput.value) { updateSandboxTest(); } if (showingCompiledRegex) { updateCompiledRegex(); sandboxCompiledRegexDisplay.classList.add("visible"); toggleCompiledRegexLink.textContent = "Hide compiled regex"; } } 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); const escapeHandler = (e) => { recordBtn.textContent = "Record"; recordBtn.disabled = false; ModalStack.setKeybindingRecorderActive(false); ModalStack.popEscapeHandler(); document.removeEventListener("keydown", handleKeydown); return true; }; recordBtn.textContent = "Press keys..."; recordBtn.disabled = true; ModalStack.setKeybindingRecorderActive(true); ModalStack.pushEscapeHandler(escapeHandler); const handleKeydown = (e) => { e.preventDefault(); const isModifierKey = ["Control", "Alt", "Shift", "Meta"].includes( e.key ); if (e.key === "Escape") { escapeHandler(); return; } if (!isModifierKey) { const keybinding = buildKeybindingFromEvent(e); input.value = keybinding; if (keybindingSpan) { keybindingSpan.textContent = keybinding; } recordBtn.textContent = "Record"; recordBtn.disabled = false; ModalStack.setKeybindingRecorderActive(false); ModalStack.popEscapeHandler(); 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" ); setupRecordKeybindingHandler( "#custom-help-keybinding-input", 2, "#record-help-keybinding-btn" ); modal.addEventListener("click", (e) => { const action = e.target.dataset.action; const templateName = e.target.dataset.template; if (action && templateName) { switch (action) { case "edit": editTemplate(instance, templateName, "manage"); break; case "clone": cloneTemplate(instance, templateName); refreshTemplateManager(instance, modal); break; case "delete": if (confirm(`Delete template "${templateName}"?`)) { deleteTemplate(instance, templateName); refreshTemplateManager(instance, modal); } break; } } }); modal.querySelectorAll('[data-action="delete-hint"]').forEach((btn) => { btn.addEventListener("click", (e) => { e.preventDefault(); const hintItem = e.target.closest(".gut-hint-item"); const hintName = hintItem?.dataset.hint; if (hintName && confirm(`Delete hint "${hintName}"?`)) { if (isDefaultHint(hintName)) { addToDeletedDefaultHints(hintName); } delete instance.hints[hintName]; saveHints(instance.hints); hintItem.remove(); const customHintsList = modal.querySelector("#custom-hints-list"); const customHintsSection = modal.querySelector("#custom-hints-section"); if (customHintsList && customHintsList.children.length === 0 && customHintsSection) { customHintsSection.style.display = "none"; } } }); }); const editHintButtons = modal.querySelectorAll('[data-action="edit-hint"]'); editHintButtons.forEach((btn) => { btn.addEventListener("click", (e) => { e.preventDefault(); const hintItem = e.target.closest(".gut-hint-item"); const hintName = hintItem?.dataset.hint; if (hintName && instance.hints[hintName]) { showHintEditor(instance, modal, hintName, instance.hints[hintName]); } }); }); const importNewHintsBtn = modal.querySelector("#import-new-hints-btn"); if (importNewHintsBtn) { importNewHintsBtn.addEventListener("click", (e) => { e.preventDefault(); try { showImportNewHintsModal(instance); } catch (error) { console.error("Error showing import new hints modal:", error); instance.showStatus("Error showing modal: " + error.message, "error"); } }); } const resetDefaultsBtn = modal.querySelector("#reset-defaults-btn"); if (resetDefaultsBtn) { resetDefaultsBtn.addEventListener("click", (e) => { e.preventDefault(); try { showResetDefaultsModal(instance); } catch (error) { console.error("Error showing reset defaults modal:", error); instance.showStatus("Error showing modal: " + error.message, "error"); } }); } const deleteAllHintsBtn = modal.querySelector("#delete-all-hints-btn"); if (deleteAllHintsBtn) { deleteAllHintsBtn.addEventListener("click", (e) => { e.preventDefault(); try { showDeleteAllHintsModal(instance); } catch (error) { console.error("Error showing delete all modal:", error); instance.showStatus("Error showing modal: " + error.message, "error"); } }); } modal.querySelectorAll('[data-action="import-mappings"]').forEach((btn) => { btn.addEventListener("click", (e) => { e.preventDefault(); const hintName = e.target.dataset.hint; if (hintName && instance.hints[hintName]) { const hintData = instance.hints[hintName]; showMapImportModal( instance, hintName, hintData.mappings || {}, "import" ); } }); }); modal.querySelectorAll('[data-action="mass-edit-mappings"]').forEach((btn) => { btn.addEventListener("click", (e) => { e.preventDefault(); const hintName = e.target.dataset.hint; if (hintName && instance.hints[hintName]) { const hintData = instance.hints[hintName]; showMapImportModal( instance, hintName, hintData.mappings || {}, "mass-edit" ); } }); }); modal.querySelectorAll(".gut-hint-mappings-toggle").forEach((btn) => { btn.addEventListener("click", (e) => { e.preventDefault(); const target = e.target.closest(".gut-hint-mappings-toggle"); const hintName = target.dataset.hint; const hintItem = modal.querySelector( `.gut-hint-item[data-hint="${hintName}"]` ); const content = hintItem?.querySelector(".gut-hint-mappings-content"); const caret = target.querySelector(".gut-hint-caret"); if (content) { if (content.style.display === "none") { content.style.display = "block"; content.style.maxHeight = content.scrollHeight + "px"; if (caret) caret.style.transform = "rotate(90deg)"; } else { content.style.maxHeight = "0"; if (caret) caret.style.transform = "rotate(0deg)"; setTimeout(() => { content.style.display = "none"; }, 200); } } }); }); const hintFilterInput = modal.querySelector("#hint-filter-input"); if (hintFilterInput) { hintFilterInput.addEventListener("input", (e) => { const filterText = e.target.value.toLowerCase().trim(); const hintsList = modal.querySelector("#hints-list"); const filterCount = modal.querySelector("#hint-filter-count"); let visibleCount = 0; let totalCount = 0; const filterHints = (list) => { if (!list) return; const hintItems = list.querySelectorAll(".gut-hint-item"); hintItems.forEach((item) => { totalCount++; const hintName = item.dataset.hint?.toLowerCase() || ""; const description = item.querySelector(".gut-hint-description")?.textContent?.toLowerCase() || ""; const pattern = item.querySelector(".gut-hint-pattern")?.textContent?.toLowerCase() || ""; const type = item.querySelector(".gut-hint-type-badge")?.textContent?.toLowerCase() || ""; const matches = !filterText || hintName.includes(filterText) || description.includes(filterText) || pattern.includes(filterText) || type.includes(filterText); if (matches) { item.style.display = ""; visibleCount++; } else { item.style.display = "none"; } }); }; filterHints(hintsList); if (filterText) { filterCount.textContent = `Showing ${visibleCount} of ${totalCount} hints`; filterCount.style.display = "block"; } else { filterCount.style.display = "none"; } }); } const addHintBtn = modal.querySelector("#add-hint-btn"); if (addHintBtn) { addHintBtn.addEventListener("click", () => { showHintEditor(instance, modal, null, null); }); } } function saveSettingsFromModal(instance, 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 helpKeybinding = modal.querySelector( "#setting-help-keybinding" ).checked; const customHelpKeybinding = modal.querySelector("#custom-help-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); instance.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, HELP_KEYBINDING: helpKeybinding, CUSTOM_HELP_KEYBINDING: customHelpKeybinding || DEFAULT_CONFIG.CUSTOM_HELP_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 }; saveSettings(instance.config); const currentModal = ModalStack.getCurrentModal(); if (currentModal) { ModalStack.markChangesSaved(currentModal.id); } instance.showStatus( "Settings saved successfully! Reload the page for some changes to take effect." ); } function showHintEditor(instance, parentModal, hintName = null, hintData = null) { const modal = createModal(HINT_EDITOR_MODAL_HTML(instance, hintName, hintData), { type: "stack", trackChanges: true, formSelector: "input, textarea, select", onClose: null, metadata: { instance, parentModal, hintName, hintData } }); const saveBtn = modal.querySelector("#hint-editor-save"); const nameInput = modal.querySelector("#hint-editor-name"); const typeInputs = modal.querySelectorAll('input[name="hint-type"]'); const patternGroup = modal.querySelector("#hint-pattern-group"); const mappingsGroup = modal.querySelector("#hint-mappings-group"); const patternInput = modal.querySelector("#hint-editor-pattern"); const patternLabel = modal.querySelector("#hint-pattern-label"); const descriptionInput = modal.querySelector("#hint-editor-description"); const strictInput = modal.querySelector("#hint-editor-strict"); const addMappingBtn = modal.querySelector("#hint-add-mapping"); const mappingsRows = modal.querySelector("#hint-mappings-rows"); typeInputs.forEach((input) => { input.addEventListener("change", () => { modal.querySelectorAll(".gut-radio-label").forEach((label) => { label.classList.remove("selected"); }); const checkedInput = modal.querySelector( 'input[name="hint-type"]:checked' ); if (checkedInput) { checkedInput.closest(".gut-radio-label").classList.add("selected"); } }); }); const initialChecked = modal.querySelector('input[name="hint-type"]:checked'); if (initialChecked) { initialChecked.closest(".gut-radio-label").classList.add("selected"); } setupAutoResize(descriptionInput, { minLines: 1, maxLines: 5 }); typeInputs.forEach((input) => { input.addEventListener("change", (e) => { const type = e.target.value; const helpIcon = modal.querySelector("#hint-pattern-group label .gut-help-icon"); if (type === "pattern") { patternGroup.style.display = "block"; mappingsGroup.style.display = "none"; patternLabel.textContent = "Pattern *"; patternInput.placeholder = "e.g., ##.##.####"; if (helpIcon) { helpIcon.dataset.tooltip = "hint-pattern-syntax"; } } else if (type === "regex") { patternGroup.style.display = "block"; mappingsGroup.style.display = "none"; patternLabel.textContent = "Regex Pattern *"; patternInput.placeholder = "e.g., v\\d+(?:\\.\\d+)*"; if (helpIcon) { helpIcon.dataset.tooltip = "hint-regex-syntax"; } } else if (type === "map") { patternGroup.style.display = "none"; mappingsGroup.style.display = "block"; } }); }); addMappingBtn.addEventListener("click", () => { const newRow = document.createElement("div"); newRow.className = "gut-mappings-row"; newRow.innerHTML = ` <input type="text" class="gut-input gut-mapping-key" placeholder="e.g., en"> <input type="text" class="gut-input gut-mapping-value" placeholder="e.g., English"> <button class="gut-btn gut-btn-danger gut-btn-small gut-remove-mapping" title="Remove">\u2212</button> `; mappingsRows.appendChild(newRow); newRow.querySelector(".gut-remove-mapping").addEventListener("click", () => { newRow.remove(); }); }); mappingsRows.querySelectorAll(".gut-remove-mapping").forEach((btn) => { btn.addEventListener("click", (e) => { const row = e.target.closest(".gut-mappings-row"); if (mappingsRows.querySelectorAll(".gut-mappings-row").length > 1) { row.remove(); } else { alert("You must have at least one mapping row."); } }); }); const importBtn = modal.querySelector("#hint-editor-import-btn"); const massEditBtn = modal.querySelector("#hint-editor-mass-edit-btn"); importBtn?.addEventListener("click", (e) => { e.preventDefault(); const currentMappings = getCurrentMappingsFromEditor(); showMapImportModal( instance, hintName || "new_hint", currentMappings, "import", modal, updateEditorMappingsFromImport ); }); massEditBtn?.addEventListener("click", (e) => { e.preventDefault(); const currentMappings = getCurrentMappingsFromEditor(); showMapImportModal( instance, hintName || "new_hint", currentMappings, "mass-edit", modal, updateEditorMappingsFromImport ); }); function getCurrentMappingsFromEditor() { const mappings = {}; mappingsRows.querySelectorAll(".gut-mappings-row").forEach((row) => { const key = row.querySelector(".gut-mapping-key").value.trim(); const value = row.querySelector(".gut-mapping-value").value.trim(); if (key) { mappings[key] = value; } }); return mappings; } function updateEditorMappingsFromImport(newMappings) { mappingsRows.innerHTML = ""; const entries = Object.entries(newMappings); if (entries.length === 0) { entries.push(["", ""]); } entries.forEach(([key, value]) => { const newRow = document.createElement("div"); newRow.className = "gut-mappings-row"; newRow.innerHTML = ` <input type="text" class="gut-input gut-mapping-key" placeholder="e.g., en" value="${instance.escapeHtml(key)}"> <input type="text" class="gut-input gut-mapping-value" placeholder="e.g., English" value="${instance.escapeHtml(value)}"> <button class="gut-btn gut-btn-danger gut-btn-small gut-remove-mapping" title="Remove">\u2212</button> `; mappingsRows.appendChild(newRow); newRow.querySelector(".gut-remove-mapping").addEventListener("click", () => { if (mappingsRows.querySelectorAll(".gut-mappings-row").length > 1) { newRow.remove(); } else { alert("You must have at least one mapping row."); } }); }); } saveBtn.addEventListener("click", () => { const name = nameInput.value.trim(); if (!name || !/^[a-zA-Z0-9_]+$/.test(name)) { alert("Invalid hint name. Use only letters, numbers, and underscores."); return; } if (!hintName && instance.hints[name]) { alert(`Hint "${name}" already exists.`); return; } const type = modal.querySelector('input[name="hint-type"]:checked').value; const description = descriptionInput.value.trim(); let hintDef = { type, description }; if (type === "pattern" || type === "regex") { const pattern = patternInput.value.trim(); if (!pattern) { alert("Pattern is required."); return; } if (type === "regex") { try { new RegExp(pattern); } catch (e) { alert(`Invalid regex: ${e.message}`); return; } } hintDef.pattern = pattern; } else if (type === "map") { const mappings = {}; const rows = mappingsRows.querySelectorAll(".gut-mappings-row"); let hasEmptyRow = false; rows.forEach((row) => { const key = row.querySelector(".gut-mapping-key").value.trim(); const value = row.querySelector(".gut-mapping-value").value.trim(); if (key && value) { mappings[key] = value; } else if (key || value) { hasEmptyRow = true; } }); if (Object.keys(mappings).length === 0) { alert("At least one complete mapping is required."); return; } if (hasEmptyRow) { if (!confirm( "Some mapping rows are incomplete and will be ignored. Continue?" )) { return; } } hintDef.mappings = mappings; hintDef.strict = strictInput.checked; } instance.hints[name] = hintDef; saveHints(instance.hints); const currentModal = ModalStack.getCurrentModal(); if (currentModal) { ModalStack.markChangesSaved(currentModal.id); } ModalStack.pop(); ModalStack.pop(); showTemplateAndSettingsManager(instance); const hintsTab = document.querySelector('.gut-tab-btn[data-tab="hints"]'); if (hintsTab) hintsTab.click(); }); } function resetSettings(instance, modal) { removeSettings(); instance.config = { ...DEFAULT_CONFIG }; modal.querySelector("#setting-form-selector").value = instance.config.TARGET_FORM_SELECTOR; modal.querySelector("#setting-submit-keybinding").checked = instance.config.SUBMIT_KEYBINDING; modal.querySelector("#custom-submit-keybinding-input").value = instance.config.CUSTOM_SUBMIT_KEYBINDING; modal.querySelector("#setting-apply-keybinding").checked = instance.config.APPLY_KEYBINDING; modal.querySelector("#custom-apply-keybinding-input").value = instance.config.CUSTOM_APPLY_KEYBINDING; modal.querySelector("#setting-help-keybinding").checked = instance.config.HELP_KEYBINDING; modal.querySelector("#custom-help-keybinding-input").value = instance.config.CUSTOM_HELP_KEYBINDING; modal.querySelector("#setting-custom-selectors").value = instance.config.CUSTOM_FIELD_SELECTORS.join("\n"); modal.querySelector("#setting-ignored-fields").value = instance.config.IGNORED_FIELDS_BY_DEFAULT.join("\n"); const keybindingSpans = modal.querySelectorAll(".gut-keybinding-text"); if (keybindingSpans[0]) { keybindingSpans[0].textContent = instance.config.CUSTOM_SUBMIT_KEYBINDING; } if (keybindingSpans[1]) { keybindingSpans[1].textContent = instance.config.CUSTOM_APPLY_KEYBINDING; } if (keybindingSpans[2]) { keybindingSpans[2].textContent = instance.config.CUSTOM_HELP_KEYBINDING; } instance.showStatus( "Settings reset to defaults! Reload the page for changes to take effect." ); } function deleteAllConfig(instance) { deleteAllConfig$1(); instance.templates = {}; instance.selectedTemplate = null; instance.hideUnselectedFields = true; instance.config = { ...DEFAULT_CONFIG }; instance.updateTemplateSelector(); instance.showStatus( "All local configuration deleted! Reload the page for changes to take full effect.", "success" ); } function saveSandboxSet(instance, name, data) { instance.sandboxSets[name] = data; saveSandboxSets(instance.sandboxSets); } function deleteSandboxSet(instance, name) { delete instance.sandboxSets[name]; saveSandboxSets(instance.sandboxSets); if (instance.currentSandboxSet === name) { instance.currentSandboxSet = ""; saveCurrentSandboxSet(""); } } function showSandboxWithMask(instance, mask, sample) { showTemplateAndSettingsManager(instance); 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, instance.hints); sandboxMaskInput.dispatchEvent(new Event("input", { bubbles: true })); sandboxSampleInput.dispatchEvent( new Event("change", { bubbles: true }) ); } }, 50); }, 50); } function showMapImportModal(instance, hintName, existingMappings, mode = "import", editorModal = null, onComplete = null) { const modal = createModal(MAP_IMPORT_MODAL_HTML( instance, hintName, existingMappings, mode ), { type: "stack", trackChanges: true, formSelector: 'textarea, select, input[type="checkbox"]', metadata: { instance, hintName, existingMappings, mode, editorModal, onComplete } }); const textarea = modal.querySelector("#import-mappings-textarea"); const separatorSelect = modal.querySelector("#import-separator-select"); const customSeparatorInput = modal.querySelector("#import-custom-separator"); const overwriteCheckbox = modal.querySelector("#import-overwrite-checkbox"); const previewGroup = modal.querySelector("#import-preview-group"); const previewContent = modal.querySelector("#import-preview-content"); const previewSummary = modal.querySelector("#import-preview-summary"); const confirmBtn = modal.querySelector("#import-confirm-btn"); setupAutoResize(textarea, { minLines: 5, maxLines: 15 }); separatorSelect.addEventListener("change", () => { if (separatorSelect.value === "custom") { customSeparatorInput.style.display = "block"; } else { customSeparatorInput.style.display = "none"; } updatePreview(); }); customSeparatorInput.addEventListener("input", updatePreview); textarea.addEventListener("input", updatePreview); function getSeparator() { if (separatorSelect.value === "custom") { return customSeparatorInput.value || ","; } return separatorSelect.value === " " ? " " : separatorSelect.value; } function parseMappings(text, separator) { const lines = text.split("\n").map((l) => l.trim()).filter((l) => l); const mappings = {}; const errors = []; lines.forEach((line, idx) => { const parts = line.split(separator).map((p) => p.trim()); if (parts.length >= 2) { const key = parts[0]; const value = parts.slice(1).join(separator).trim(); if (key && value) { mappings[key] = value; } else { errors.push(`Line ${idx + 1}: Empty key or value`); } } else if (parts.length === 1 && parts[0]) { errors.push(`Line ${idx + 1}: Missing separator or value`); } }); return { mappings, errors }; } function updatePreview() { const text = textarea.value.trim(); if (!text) { previewGroup.style.display = "none"; confirmBtn.disabled = true; return; } const separator = getSeparator(); const { mappings, errors } = parseMappings(text, separator); if (Object.keys(mappings).length === 0 && errors.length === 0) { previewGroup.style.display = "none"; confirmBtn.disabled = true; return; } previewGroup.style.display = "block"; mode === "mass-edit" || overwriteCheckbox && overwriteCheckbox.checked; const newKeys = []; const updateKeys = []; const unchangedKeys = []; Object.keys(mappings).forEach((key) => { if (existingMappings[key]) { if (existingMappings[key] === mappings[key]) { unchangedKeys.push(key); } else { updateKeys.push(key); } } else { newKeys.push(key); } }); let html2 = ""; if (errors.length > 0) { html2 += `<div style="color: #f44336; margin-bottom: 8px; font-size: 11px;"> <strong>Errors:</strong><br>${errors.map((e) => `\u2022 ${e}`).join("<br>")} </div>`; } if (Object.keys(mappings).length > 0) { html2 += Object.entries(mappings).map(([key, value]) => { let badge = ""; let style2 = ""; if (newKeys.includes(key)) { badge = '<span style="color: #4caf50; font-size: 10px; margin-left: 4px;">(new)</span>'; style2 = "border-left: 3px solid #4caf50;"; } else if (updateKeys.includes(key)) { badge = `<span style="color: #ff9800; font-size: 10px; margin-left: 4px;">(update: "${instance.escapeHtml(existingMappings[key])}")</span>`; style2 = "border-left: 3px solid #ff9800;"; } else if (unchangedKeys.includes(key)) { badge = '<span style="color: #888; font-size: 10px; margin-left: 4px;">(unchanged)</span>'; style2 = "border-left: 3px solid #444;"; } return ` <div class="gut-variable-item" style="${style2}"> <span class="gut-variable-name">${instance.escapeHtml(key)}${badge}</span> <span class="gut-variable-value">${instance.escapeHtml(value)}</span> </div> `; }).join(""); } previewContent.innerHTML = html2; const summaryParts = []; if (newKeys.length > 0) summaryParts.push(`${newKeys.length} new`); if (updateKeys.length > 0) summaryParts.push(`${updateKeys.length} updates`); if (unchangedKeys.length > 0) summaryParts.push(`${unchangedKeys.length} unchanged`); if (errors.length > 0) summaryParts.push(`${errors.length} errors`); previewSummary.textContent = summaryParts.join(", "); confirmBtn.disabled = Object.keys(mappings).length === 0 || errors.length > 0; } function applyImport() { const text = textarea.value.trim(); if (!text) return; const separator = getSeparator(); const { mappings } = parseMappings(text, separator); if (Object.keys(mappings).length === 0) { alert("No valid mappings to import."); return; } const overwrite = mode === "mass-edit" || overwriteCheckbox && overwriteCheckbox.checked; let finalMappings; if (mode === "mass-edit") { finalMappings = mappings; } else if (overwrite) { finalMappings = { ...existingMappings, ...mappings }; } else { finalMappings = { ...mappings, ...existingMappings }; } if (onComplete) { onComplete(finalMappings); const currentModal = ModalStack.getCurrentModal(); if (currentModal) { ModalStack.markChangesSaved(currentModal.id); } ModalStack.pop(); } else { const hintData = instance.hints[hintName] || {}; instance.hints[hintName] = { ...hintData, mappings: finalMappings }; saveHints(instance.hints); const currentModal = ModalStack.getCurrentModal(); if (currentModal) { ModalStack.markChangesSaved(currentModal.id); } ModalStack.pop(); ModalStack.pop(); showTemplateAndSettingsManager(instance); const hintsTab = document.querySelector('.gut-tab-btn[data-tab="hints"]'); if (hintsTab) hintsTab.click(); } } confirmBtn.addEventListener("click", applyImport); updatePreview(); } function showImportNewHintsModal(instance) { const userHints = instance.hints; const newHints = getNewDefaultHints(userHints); const ignoredHints = loadIgnoredHints(); if (Object.keys(newHints).length === 0) { instance.showStatus("No new hints available to import!", "info"); return; } const modal = createModal(IMPORT_NEW_HINTS_MODAL_HTML( newHints, ignoredHints ), { type: "stack", metadata: { instance, newHints, ignoredHints } }); const checkboxes = modal.querySelectorAll(".hint-select-checkbox"); const importBtn = modal.querySelector("#import-hints-confirm-btn"); const selectAllBtn = modal.querySelector("#import-select-all-btn"); const selectNoneBtn = modal.querySelector("#import-select-none-btn"); function updateSelectedCount() { const checkedCount = Array.from(checkboxes).filter( (cb) => cb.checked ).length; const totalCount = checkboxes.length; if (checkedCount === 0) { importBtn.textContent = "Import Selected"; importBtn.disabled = true; } else if (checkedCount === totalCount) { importBtn.textContent = "Import All"; importBtn.disabled = false; } else { importBtn.textContent = `Import ${checkedCount}/${totalCount} Selected`; importBtn.disabled = false; } } checkboxes.forEach((checkbox) => { checkbox.addEventListener("change", updateSelectedCount); }); selectAllBtn.addEventListener("click", (e) => { e.preventDefault(); checkboxes.forEach((cb) => cb.checked = true); updateSelectedCount(); }); selectNoneBtn.addEventListener("click", (e) => { e.preventDefault(); checkboxes.forEach((cb) => cb.checked = false); updateSelectedCount(); }); modal.querySelectorAll(".hint-ignore-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.preventDefault(); const hintName = e.target.dataset.hintName; const row = e.target.closest(".gut-hint-import-item"); const checkbox = row.querySelector(".hint-select-checkbox"); if (isHintIgnored(hintName)) { removeFromIgnoredHints(hintName); e.target.textContent = "Ignore"; checkbox.checked = true; } else { addToIgnoredHints(hintName); e.target.textContent = "Unignore"; checkbox.checked = false; } updateSelectedCount(); }); }); importBtn.addEventListener("click", () => { const selectedHints = Array.from(checkboxes).filter((cb) => cb.checked).map((cb) => cb.dataset.hintName); if (selectedHints.length === 0) { instance.showStatus("No hints selected for import!", "error"); return; } selectedHints.forEach((hintName) => { instance.hints[hintName] = newHints[hintName]; removeFromDeletedDefaultHints(hintName); }); saveHints(instance.hints); ModalStack.pop(); ModalStack.pop(); showTemplateAndSettingsManager(instance); const hintsTab = document.querySelector('.gut-tab-btn[data-tab="hints"]'); if (hintsTab) hintsTab.click(); instance.showStatus( `Successfully imported ${selectedHints.length} hint(s)!`, "success" ); }); updateSelectedCount(); } function showResetDefaultsModal(instance) { const userHints = instance.hints; const ignoredHints = loadIgnoredHints(); const deletedHints = loadDeletedDefaultHints(); const modal = createModal(RESET_DEFAULTS_MODAL_HTML( userHints, ignoredHints, deletedHints ), { type: "stack", metadata: { instance, ignoredHints } }); const checkboxes = modal.querySelectorAll(".hint-select-checkbox"); const resetBtn = modal.querySelector("#reset-hints-confirm-btn"); const selectAllBtn = modal.querySelector("#reset-select-all-btn"); const selectNoneBtn = modal.querySelector("#reset-select-none-btn"); function updateSelectedCount() { const checkedCount = Array.from(checkboxes).filter( (cb) => cb.checked ).length; const totalCount = checkboxes.length; if (checkedCount === 0) { resetBtn.textContent = "Reset Selected"; resetBtn.disabled = true; } else if (checkedCount === totalCount) { resetBtn.textContent = "Reset All"; resetBtn.disabled = false; } else { resetBtn.textContent = `Reset ${checkedCount}/${totalCount} Selected`; resetBtn.disabled = false; } } checkboxes.forEach((checkbox) => { checkbox.addEventListener("change", updateSelectedCount); }); selectAllBtn.addEventListener("click", (e) => { e.preventDefault(); checkboxes.forEach((cb) => cb.checked = true); updateSelectedCount(); }); selectNoneBtn.addEventListener("click", (e) => { e.preventDefault(); checkboxes.forEach((cb) => cb.checked = false); updateSelectedCount(); }); modal.querySelectorAll(".hint-ignore-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.preventDefault(); const hintName = e.target.dataset.hintName; const row = e.target.closest(".gut-hint-import-item"); const checkbox = row.querySelector(".hint-select-checkbox"); if (isHintIgnored(hintName)) { removeFromIgnoredHints(hintName); e.target.textContent = "Ignore"; checkbox.checked = true; } else { addToIgnoredHints(hintName); e.target.textContent = "Unignore"; checkbox.checked = false; } updateSelectedCount(); }); }); resetBtn.addEventListener("click", () => { const selectedHints = Array.from(checkboxes).filter((cb) => cb.checked).map((cb) => cb.dataset.hintName); if (selectedHints.length === 0) { instance.showStatus("No hints selected for reset!", "error"); return; } selectedHints.forEach((hintName) => { instance.hints[hintName] = DEFAULT_HINTS[hintName]; removeFromDeletedDefaultHints(hintName); }); saveHints(instance.hints); ModalStack.pop(); ModalStack.pop(); showTemplateAndSettingsManager(instance); const hintsTab = document.querySelector('.gut-tab-btn[data-tab="hints"]'); if (hintsTab) hintsTab.click(); instance.showStatus( `Successfully reset ${selectedHints.length} hint(s) to defaults!`, "success" ); }); updateSelectedCount(); } function showDeleteAllHintsModal(instance) { const modal = createModal(DELETE_ALL_HINTS_MODAL_HTML(), { type: "stack", metadata: { instance } }); const deleteBtn = modal.querySelector("#delete-all-hints-confirm-btn"); deleteBtn.addEventListener("click", () => { if (resetAllHints()) { instance.hints = loadHints(); ModalStack.pop(); ModalStack.pop(); showTemplateAndSettingsManager(instance); const hintsTab = document.querySelector('.gut-tab-btn[data-tab="hints"]'); if (hintsTab) hintsTab.click(); instance.showStatus( "All hints deleted and reset to defaults!", "success" ); } else { instance.showStatus("Failed to delete hints!", "error"); } }); } function showApplyConfirmationModal(instance, changes, onConfirm) { const modal = createModal(APPLY_CONFIRMATION_MODAL_HTML(changes), { type: "stack", metadata: { instance, changes, onConfirm } }); const applyBtn = modal.querySelector("#apply-confirm-apply-btn"); const handleConfirm = () => { ModalStack.pop(); if (onConfirm) { onConfirm(); } }; applyBtn.addEventListener("click", handleConfirm); setTimeout(() => { applyBtn.focus(); }, 0); } function showUnsavedChangesConfirmationModal(resolve) { const modal = createModal(UNSAVED_CHANGES_CONFIRMATION_MODAL_HTML(), { type: "stack", metadata: { resolve } }); const keepEditingBtn = modal.querySelector("#unsaved-keep-editing"); const discardBtn = modal.querySelector("#unsaved-discard"); const handleKeepEditing = () => { ModalStack.popEscapeHandler(); ModalStack.pop(true); resolve(false); }; const handleDiscard = () => { ModalStack.popEscapeHandler(); ModalStack.pop(true); resolve(true); }; keepEditingBtn.addEventListener("click", handleKeepEditing); discardBtn.addEventListener("click", handleDiscard); const escapeHandler = () => { handleKeepEditing(); return true; }; ModalStack.pushEscapeHandler(escapeHandler); setTimeout(() => { discardBtn.focus(); }, 0); } ModalStack.setUnsavedChangesHandler(showUnsavedChangesConfirmationModal); async function getCurrentVariables(instance) { const commentVariables = {}; const maskVariables = {}; let hasBothConditions = false; if (instance.selectedTemplate && instance.selectedTemplate !== "none") { const template = instance.templates[instance.selectedTemplate]; if (template) { 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")) { hasBothConditions = true; try { const torrentData = await TorrentUtils.parseTorrentFile( input.files[0] ); Object.assign( commentVariables, TorrentUtils.parseCommentVariables(torrentData.comment) ); const parseResult = parseTemplateWithOptionals( template.mask, torrentData.name, instance.hints ); 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, hasBothConditions }; } async function updateVariableCount(instance) { const variables = await getCurrentVariables(instance); 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 (!variables.hasBothConditions) { variablesRow.style.display = "none"; } else { variablesRow.style.display = ""; if (totalCount === 0) { variablesRow.innerHTML = `Available variables: 0`; variablesRow.style.cursor = "default"; variablesRow.style.opacity = "0.6"; } else { const parts = []; if (commentCount > 0) { parts.push(`Comment [${commentCount}]`); } if (maskCount > 0) { parts.push(`Mask [${maskCount}]`); } variablesRow.innerHTML = `Available variables: ${parts.join(", ")}`; variablesRow.style.cursor = "pointer"; variablesRow.style.opacity = "1"; } } } } function applyTemplate(instance, templateName, torrentName, commentVariables = {}) { const template = instance.templates[templateName]; if (!template) return; const extracted = parseTemplateWithOptionals(template.mask, torrentName, instance.hints); let appliedCount = 0; Object.entries(template.fieldMappings).forEach( ([fieldName, valueTemplate]) => { const firstElement = findElementByFieldName(fieldName, instance.config); if (firstElement && firstElement.type === "radio") { const formPrefix = instance.config.TARGET_FORM_SELECTOR ? `${instance.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) { instance.showStatus( `Template "${templateName}" applied to ${appliedCount} field(s)` ); } } async function checkAndApplyToExistingTorrent(instance, templateName) { if (!templateName || templateName === "none") return; 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] ); const commentVariables = TorrentUtils.parseCommentVariables( torrentData.comment ); applyTemplate(instance, templateName, torrentData.name, commentVariables); return; } catch (error) { console.warn("Could not parse existing torrent file:", error); } } } } function watchFileInputs(instance) { const fileInputs = instance.config.TARGET_FORM_SELECTOR ? document.querySelectorAll( `${instance.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")) { instance.showStatus( "Torrent file selected. Click 'Apply Template' to fill form." ); updateVariableCount(instance); } }); }); } async function applyTemplateToCurrentTorrent(instance) { if (!instance.selectedTemplate || instance.selectedTemplate === "none") { instance.showStatus("No template selected", "error"); return; } 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] ); const commentVariables = TorrentUtils.parseCommentVariables( torrentData.comment ); const changes = detectFieldChanges( instance, instance.selectedTemplate, torrentData.name, commentVariables ); if (changes.length > 0) { showApplyConfirmationModal(instance, changes, () => { applyTemplate( instance, instance.selectedTemplate, torrentData.name, commentVariables ); }); } else { applyTemplate( instance, instance.selectedTemplate, torrentData.name, commentVariables ); } return; } catch (error) { console.error("Error processing torrent file:", error); instance.showStatus("Error processing torrent file", "error"); } } } instance.showStatus("No torrent file selected", "error"); } function detectFieldChanges(instance, templateName, torrentName, commentVariables = {}) { const template = instance.templates[templateName]; if (!template) return []; const extracted = parseTemplateWithOptionals( template.mask, torrentName, instance.hints ); const changes = []; Object.entries(template.fieldMappings).forEach( ([fieldName, valueTemplate]) => { const firstElement = findElementByFieldName(fieldName, instance.config); if (firstElement && firstElement.type === "radio") { const formPrefix = instance.config.TARGET_FORM_SELECTOR ? `${instance.config.TARGET_FORM_SELECTOR} ` : ""; const radioButtons = document.querySelectorAll( `${formPrefix}input[name="${fieldName}"][type="radio"]` ); const newValue = interpolate( String(valueTemplate), extracted, commentVariables ); const currentlyChecked = Array.from(radioButtons).find( (radio) => radio.checked ); const currentValue = currentlyChecked ? currentlyChecked.value : ""; if (currentValue !== newValue) { changes.push({ fieldName, label: getFieldLabel(firstElement, instance.config), currentValue: currentValue || "(empty)", newValue, fieldType: "radio" }); } } else if (firstElement) { 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); } const currentChecked = firstElement.checked; if (currentChecked !== newChecked) { changes.push({ fieldName, label: getFieldLabel(firstElement, instance.config), currentValue: currentChecked ? "Checked" : "Unchecked", newValue: newChecked ? "Checked" : "Unchecked", fieldType: "checkbox" }); } } else { const interpolated = interpolate( String(valueTemplate), extracted, commentVariables ); const currentValue = firstElement.value || ""; if (currentValue !== interpolated) { changes.push({ fieldName, label: getFieldLabel(firstElement, instance.config), currentValue: currentValue || "(empty)", newValue: interpolated, fieldType: firstElement.tagName.toLowerCase() === "select" ? "select" : firstElement.type || "text" }); } } } } ); return changes; } const HELP_SECTIONS = { "quick-start": { title: "Quick Start", content: html` <h3>Welcome to GGn Upload Templator</h3> <p> This userscript helps automate your torrent upload workflow by extracting information from torrent filenames and auto-filling form fields. </p> <h4>Basic Workflow</h4> <ol> <li> <strong>Create a Template:</strong> Click "+ Create Template" and define a mask pattern that matches your torrent naming convention </li> <li> <strong>Define Variables:</strong> Use <code>\${variable}</code> syntax in your mask to extract data from torrent names </li> <li> <strong>Map Fields:</strong> Choose which form fields should be filled with which variables </li> <li> <strong>Apply Template:</strong> Select your template and click "Apply Template" to auto-fill the form </li> </ol> <h4>Example</h4> <p>For a torrent named: <code>PCWorld - Issue 05 - 01-2024.zip</code></p> <p> You could create a mask: <code>\${magazine} - Issue \${issue} - \${month}-\${year}.\${ext}</code> </p> <p> This extracts: magazine="PCWorld", issue="05", month="01", year="2024", ext="zip" </p> `, keywords: ["getting started", "begin", "tutorial", "intro", "basics"] }, templates: { title: "Templates", content: html` <h3>Creating and Managing Templates</h3> <p> Templates define how to extract information from torrent names and which form fields to fill. </p> <h4>Creating a Template</h4> <ol> <li>Click "+ Create Template" button</li> <li>Enter a descriptive template name</li> <li> Paste a sample torrent name (or select a torrent first and we'll use the extracted filename) </li> <li>Define your mask pattern (see Masks & Variables section)</li> <li>Select which form fields to fill and with what values</li> <li>Click "Save Template"</li> </ol> <h4>Editing Templates</h4> <p> Click "Manage" button, then click "Edit" next to any template. You can update the mask, field mappings, and other settings. </p> <h4>Cloning Templates</h4> <p> Use the "Clone" button to create a copy of an existing template as a starting point for a new one. </p> <h4>Field Selection</h4> <p> Use the "Show Unselected" button to see ignored fields. Check/uncheck fields to include or exclude them from the template. </p> `, keywords: ["template", "create", "edit", "manage", "clone", "delete"] }, masks: { title: "Masks & Variables", content: html` <h3>Torrent Name Masks</h3> <p> Masks define patterns to extract variables from torrent filenames. Variables are defined using <code>\${variable_name}</code> syntax. </p> <h4>Variable Sources</h4> <p>Variables can be extracted from two sources:</p> <ul> <li> <strong>Torrent filename:</strong> The primary source for variable extraction using masks </li> <li> <strong>Torrent comment field:</strong> Define variables in the torrent file's comment using <code>variable=value</code> format (separated by semicolons, e.g. <code>var1=value1;var2=value2</code>) </li> </ul> <h4>Variable Syntax</h4> <p> <code>\${variable}</code> - Extracts any characters (non-greedy by default) </p> <p> <code>\${variable:pattern}</code> - Extracts characters matching a specific pattern. The pattern can be defined inline or in a named variable hint (see Variable Hints section for more details) </p> <h4>Pattern Types</h4> <ul> <li><code>#</code> - Matches a single digit (0-9)</li> <li><code>@</code> - Matches a single letter (a-z, A-Z)</li> <li><code>*</code> - Matches any alphanumeric character</li> <li><code>##</code> - Matches exactly 2 digits</li> <li><code>@@@</code> - Matches exactly 3 letters</li> </ul> <h4>Examples</h4> <p><code>\${artist} - \${album} [\${year:####}]</code></p> <p>Matches: "Artist Name - Album Title [2024]"</p> <p><code>\${magazine} Issue \${issue:##}</code></p> <p>Matches: "PCWorld Issue 05"</p> <h4>Literal Text</h4> <p> Any text outside of <code>\${...}</code> is treated as literal text that must match exactly. </p> <h4>Testing Masks</h4> <p> Use the Mask Sandbox tab to test your masks against multiple torrent names at once. </p> `, keywords: [ "mask", "variable", "pattern", "${", "syntax", "extract", "regex", "comment", "torrent" ] }, hints: { title: "Variable Hints", content: html` <h3>Variable Hints</h3> <p> Hints help disambiguate extracted variables by validating or transforming them based on patterns or mappings. </p> <h4>Hint Types</h4> <h5>Pattern Hints</h5> <p>Validate that a variable matches a specific pattern:</p> <ul> <li><code>##</code> - Two digits</li> <li><code>####</code> - Four digits (year)</li> <li><code>@@@@</code> - Four letters</li> </ul> <h5>Regex Hints</h5> <p>Validate using regular expressions:</p> <ul> <li><code>v\\d+(\\.\\d+)*</code> - Version numbers like v1.2.3</li> <li><code>[A-Z]{2,4}</code> - 2-4 uppercase letters</li> </ul> <h5>Value Maps</h5> <p>Transform input values to output values:</p> <ul> <li>Input: "en" → Output: "English"</li> <li>Input: "fr" → Output: "French"</li> </ul> <p> Maps can be strict (reject unknown values) or non-strict (pass through unknown values). </p> <h4>Creating Hints</h4> <ol> <li>Go to "Variable Hints" tab</li> <li>Click "+ Add Hint"</li> <li>Name your hint (use the variable name it applies to)</li> <li>Choose hint type and define the pattern or mappings</li> <li>Save the hint</li> </ol> <h4>Mass Editing Maps</h4> <p> For value map hints, use "Mass Edit" or "Import" to quickly add many mappings in CSV or other delimited formats. </p> `, keywords: [ "hint", "pattern", "regex", "map", "validation", "transform", "disambiguation" ] }, "optional-variables": { title: "Optional Variables", content: html` <h3>Optional Sections</h3> <p> Optional sections allow parts of your mask to be present or absent in torrent names. </p> <h4>Syntax</h4> <p> <code>{?optional content?}</code> - Everything between <code>{?</code> and <code>?}</code> is optional </p> <h4>Use Cases</h4> <ul> <li> Optional year in brackets: <code>\${title} {?[\${year}]?}</code> </li> <li>Optional version: <code>\${software} {?v\${version}?}</code></li> <li> Optional episode info: <code>\${series} {?S\${season}E\${episode}?}</code> </li> </ul> <h4>Examples</h4> <p> <strong>Mask:</strong> <code>\${artist} - \${album} {?[\${year}]?}</code> </p> <p><strong>Matches:</strong></p> <ul> <li> "Artist - Album [2024]" → artist="Artist", album="Album", year="2024" </li> <li> "Artist - Album" → artist="Artist", album="Album", year=undefined </li> </ul> `, keywords: ["optional", "{?", "?}", "conditional", "maybe"] }, "form-operations": { title: "Form Operations", content: html` <h3>Applying Templates</h3> <p> Once you've created a template, you can apply it to auto-fill form fields. </p> <h4>Applying a Template</h4> <ol> <li>Select your template from the dropdown</li> <li>Click "Apply Template" button (or use the keybinding)</li> <li>Review the changes in the confirmation dialog</li> <li>Confirm to apply changes to the form</li> </ol> <h4>Variable Interpolation</h4> <p>Form field values can reference extracted variables:</p> <ul> <li><code>\${variable}</code> - Insert variable value</li> <li> <code>Static text \${variable}</code> - Mix static text with variables </li> <li><code>\${var1} - \${var2}</code> - Combine multiple variables</li> </ul> <h4>Variable Matching for Selects</h4> <p> For select/dropdown fields, you can match options based on extracted variables: </p> <ol> <li>Click "Match from variable: OFF" link next to a select field</li> <li>Choose match type (exact, contains, starts with, ends with)</li> <li>Enter the variable name (e.g., <code>\${category}</code>)</li> <li> The template will automatically select the matching option when applied </li> </ol> <h4>Field Previews</h4> <p> When creating/editing a template, field previews show what the final value will look like with sample data. </p> <h4>Confirmation Dialog</h4> <p> Before applying changes, you'll see which fields will be modified and their new values. This prevents accidental overwrites. </p> `, keywords: [ "apply", "form", "field", "mapping", "interpolation", "variable", "select", "dropdown" ] }, sandbox: { title: "Mask Sandbox", content: html` <h3>Testing Masks</h3> <p> The Mask Sandbox lets you test mask patterns against multiple torrent names to verify they work correctly. </p> <h4>Using the Sandbox</h4> <ol> <li>Go to "Mask Sandbox" tab in the manager</li> <li>Enter your mask pattern</li> <li>Enter sample torrent names (one per line)</li> <li>View match results showing extracted variables</li> </ol> <h4>Match Results</h4> <p>For each sample, you'll see:</p> <ul> <li><strong>✓ Match:</strong> Variables successfully extracted</li> <li><strong>✗ No Match:</strong> Pattern didn't match</li> <li> <strong>Variable Values:</strong> All extracted variables with their values </li> </ul> <h4>Compiled Regex View</h4> <p> Click "Show compiled regex" to see the actual regular expression generated from your mask. Useful for debugging complex patterns. </p> <h4>Saving Test Sets</h4> <p>Save your mask and sample names as a test set for later reuse:</p> <ol> <li>Enter your mask and samples</li> <li>Click "Save" button</li> <li>Enter a name for your test set</li> <li>Load it later from the dropdown</li> </ol> <h4>Quick Testing from Template Creator</h4> <p> When creating/editing a template, click "Test mask in sandbox →" to jump directly to the sandbox with your current mask pre-filled. </p> `, keywords: ["sandbox", "test", "mask", "preview", "debug", "sample"] }, settings: { title: "Settings", content: html` <h3>Configuration Options</h3> <h4>Target Form Selector</h4> <p> CSS selector for the upload form. Default: <code>#upload_table</code> </p> <p> Change this if the form has a different ID or selector on your tracker. </p> <h4>Keybindings</h4> <p> <strong>Form Submission:</strong> Quickly submit the form (default: Ctrl+Enter) </p> <p> <strong>Apply Template:</strong> Apply selected template (default: Ctrl+Shift+A) </p> <p><strong>Help:</strong> Open help modal (default: ?)</p> <p> Click "Record" to set a custom keybinding - press your desired key combination when prompted. </p> <h4>Custom Field Selectors</h4> <p> Additional CSS selectors to find form fields beyond standard inputs. One selector per line. </p> <p> Example: <code>div[data-field]</code> for custom field implementations. </p> <h4>Ignored Fields</h4> <p>Field names to exclude from templates by default. One per line.</p> <p> These won't show up in the field list when creating templates unless you click "Show Unselected". </p> <h4>Reset to Defaults</h4> <p>Restores all settings to their default values.</p> <h4>Delete All Local Config</h4> <p> ⚠️ Deletes ALL local data including templates, hints, settings, and test sets. Cannot be undone. </p> `, keywords: [ "settings", "config", "keybinding", "shortcut", "selector", "ignored", "fields" ] }, "keyboard-shortcuts": { title: "Keyboard Shortcuts", content: html` <h3>Keyboard Shortcuts</h3> <p>Speed up your workflow with these keyboard shortcuts.</p> <h4>Global Shortcuts</h4> <table class="gut-help-table"> <tr> <td><strong>?</strong></td> <td>Open help modal (configurable)</td> </tr> <tr> <td><strong>Ctrl+Enter</strong></td> <td>Submit upload form (configurable)</td> </tr> <tr> <td><strong>Ctrl+Shift+A</strong></td> <td>Apply selected template (configurable)</td> </tr> </table> <h4>Modal Shortcuts</h4> <table class="gut-help-table"> <tr> <td><strong>Esc</strong></td> <td>Close current modal or go back</td> </tr> <tr> <td><strong>Tab</strong></td> <td>Navigate between fields</td> </tr> </table> <h4>Help Modal Shortcuts</h4> <table class="gut-help-table"> <tr> <td><strong>Enter</strong></td> <td>Cycle through search results</td> </tr> </table> <h4>Customizing Shortcuts</h4> <p> Go to Settings tab → Click "Record" next to any keybinding → Press your desired key combination. </p> `, keywords: ["keyboard", "shortcuts", "keybinding", "hotkey", "keys"] }, api: { title: "API for Other Userscripts", content: html` <h3>Userscript API</h3> <p> Other userscripts can interact with GGn Upload Templator programmatically. </p> <h4>Accessing the API</h4> <p> The API is available at: <code>window.GGnUploadTemplator</code> </p> <h4>Key Methods</h4> <p><strong>Get API version:</strong></p> <pre class="gut-help-pre" ><code>const version = window.GGnUploadTemplator.version;</code></pre> <p><strong>Get all templates:</strong></p> <pre class="gut-help-pre" ><code>const templates = window.GGnUploadTemplator.getTemplates(); // Returns array of template objects with name, mask, fieldMappings, etc.</code></pre> <p><strong>Get specific template:</strong></p> <pre class="gut-help-pre" ><code>const template = window.GGnUploadTemplator.getTemplate('Template Name'); // Returns template object or null if not found</code></pre> <p><strong>Extract variables from torrent name:</strong></p> <pre class="gut-help-pre" ><code>const vars = window.GGnUploadTemplator.extractVariables( 'Template Name', 'torrent-name.zip' ); // Returns object with extracted variables</code></pre> <p><strong>Get instance for advanced usage:</strong></p> <pre class="gut-help-pre" ><code>const instance = window.GGnUploadTemplator.getInstance(); // Returns the internal GGnUploadTemplator instance</code></pre> <h4>Full API Documentation</h4> <p> See <a href="https://github.com/lvldesigner/userscripts/blob/main/ggn-upload-templator/docs/api.md" target="_blank" class="gut-link" >API Documentation</a > for complete details. </p> `, keywords: ["api", "userscript", "integration", "programming", "event"] }, changelog: { title: "Changelog", content: "", keywords: ["changelog", "version", "release", "updates", "history"] } }; function getChangelogContent() { const changelogEntries = Object.entries(INTRO_CONTENT.changelog).sort( ([versionA], [versionB]) => { const parseVersion = (v) => { const parts = v.replace("v", "").split(".").map(Number); return parts[0] * 1e4 + (parts[1] || 0) * 100 + (parts[2] || 0); }; return parseVersion(versionB) - parseVersion(versionA); } ); let content = "<div>"; for (const [version2, entry] of changelogEntries) { content += ` <div class="gut-changelog-entry"> <h3 class="gut-changelog-version">${version2}</h3> <div class="gut-changelog-content"> ${entry.content} </div> </div> `; } content += "</div>"; return content; } const HELP_TOOLTIPS = { "mask-syntax": { text: "Define patterns to extract variables from torrent names using <code>${variable}</code> syntax", example: "${title} - ${episode}", helpSection: "masks" }, "optional-syntax": { text: "Wrap optional sections in <code>{?...?}</code> to handle variations in torrent names", example: "${title} {?[${year}]?}", helpSection: "optional-variables" }, "extracted-variables": { text: "Variables extracted from the sample torrent name using your mask. Use these in form field values with <code>${variable}</code> syntax", example: "If mask extracts 'year', use ${year} in fields", helpSection: "masks" }, "field-mappings": { text: "Map form fields to variables or static values. Use <code>${variable}</code> to reference extracted data", example: "Title: ${magazine} Issue ${issue}", helpSection: "form-operations" }, "variable-hints": { text: "Hints validate or transform variables using patterns, regex, or value mappings to ensure correct data extraction", example: "year \u2192 ####, language \u2192 en=English", helpSection: "hints" }, "hint-types": { text: "<strong>Pattern:</strong> Simple patterns (#=digit, @=letter)<br><strong>Regex:</strong> Full regex support<br><strong>Map:</strong> Input\u2192Output value transformations", example: "", helpSection: "hints" }, "hint-pattern-syntax": { text: "Use # for digits, @ for letters, * for alphanumeric. Repeat for exact length", example: "#### = 4 digits (2024), @@ = 2 letters (EN)", helpSection: "hints" }, "hint-regex-syntax": { text: "Full JavaScript regex support for complex patterns. Use capturing groups, quantifiers, and anchors", example: "v\\d+(\\.\\d+)* = v1.2.3, [A-Z]{2,4} = 2-4 uppercase letters", helpSection: "hints" }, "hint-value-mappings": { text: "Map input values to output values. Useful for transforming abbreviations to full names", example: "en \u2192 English, fr \u2192 French, de \u2192 German", helpSection: "hints" }, "hint-strict-mode": { text: "When enabled, rejects values not in the mapping. When disabled, passes through unknown values unchanged", example: "", helpSection: "hints" }, "variable-matching": { text: "Automatically select dropdown options by matching against extracted variable values", example: 'If ${format}="FLAC", select "FLAC" option', helpSection: "form-operations" }, "mask-sandbox": { text: "Test your mask patterns against sample torrent names to verify they extract variables correctly", example: "", helpSection: "sandbox" }, "form-selector": { text: "CSS selector to identify the upload form on the page", example: "#upload_table or .upload-form", helpSection: "settings" }, "custom-selectors": { text: "Additional CSS selectors to find custom form fields beyond standard inputs", example: "div[data-field], .custom-input", helpSection: "settings" }, "ignored-fields": { text: "Field names excluded from templates by default. These won't appear unless you click 'Show Unselected'", example: "submit, csrf_token, form_id", helpSection: "settings" }, "help-icon-example": { text: "Yep, this is one of those help icons! Click any of them throughout the UI for context-specific help", example: "", helpSection: "quick-start" } }; const INTRO_CONTENT = { "new-user": { title: "Welcome to GGn Upload Templator!", content: html` <p> Automate your torrent upload workflow with templates that extract data from filenames and auto-fills the form for you. </p> <div class="gut-intro-section-box"> <h4 class="gut-intro-section-title">Key Features</h4> <ul class="gut-intro-section-list"> <li> Create templates from torrent filenames with variable extraction </li> <li> Define variables in torrent comment fields for additional metadata </li> <li>Auto-fill form fields with extracted variables</li> <li>Support for complex patterns and optional sections</li> <li>Test your masks in the built-in sandbox</li> <li>Variable hints for validation and transformation</li> </ul> </div> ` }, changelog: { "v0.14": { title: "What's New in v0.14?", content: html` <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> Added Select All / Select None to form fields list in template creator/editor modal </li> <li>Fix help modal default keybinding (?) now functions properly</li> </ul> </div> ` }, "v0.13": { title: "What's New in v0.13?", content: html` <p> This version introduces a comprehensive help system to make using the templator easier. </p> <img src="https://files.catbox.moe/en4jfi.png" alt="v0.13 screenshot" style="max-width: 100%; height: auto; margin-bottom: 1em;" /> <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> <strong>Built-in Help System:</strong> Access contextual help via ${raw( HELP_ICON_HTML("help-icon-example", "gut-help-icon-no-margin") )} icons or press <kbd class="gut-kbd">?</kbd> anytime </li> <li> <strong>Rich Tooltips:</strong> Hover over help icons for quick explanations </li> <li> <strong>Searchable Help Modal:</strong> Find answers quickly with full-text search </li> <li> <strong>First-Run Experience:</strong> Welcome modal for new users and version updates </li> <li> <strong>Unsaved Changes Warning:</strong> Show warning modal on close if there are unsaved changes </li> </ul> </div> ` }, "v0.12": { title: "What's New in v0.12?", content: html` <img src="https://files.catbox.moe/kkbd0a.png" alt="v0.12 screenshot" style="max-width: 100%; height: auto; margin-bottom: 1em;" /> <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> Other scripts can now use the API exposed via <code>window.GGnUploadTemplator</code>, see API section for details </li> <li> Ask for confirmation before applying the template, show preview of value changes </li> <li>Show variable hint information when editing a mask</li> <li> Managing variable hints is now more flexible: they can be reset to default / all deleted / only import new ones </li> </ul> </div> ` }, "v0.11": { title: "What's New in v0.11?", content: html` <p>This is mostly a UX/UI fixes and improvements release.</p> <img src="https://files.catbox.moe/mum36l.png" alt="v0.11 screenshot" style="max-width: 100%; height: auto; margin-bottom: 1em;" /> <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> All variable hints are now treated equally, there's no more default/custom hints, this allows editing/deleting any hint, including previously default ones. You can reset to default using the new button </li> <li> Matched variable values are now truncated to sane lengths, hover over them to see full match highlighted in the torrent name </li> <li> Fix match highlights not working properly when the matched value is too long </li> <li> Modals get scaled down the further up the stack you go, i.e: if you have one modal open then you open another on top of it, that gets scaled down so you visually distinguish there are two modals </li> <li> Modal width can be resized to your liking by dragging on the left/right edge </li> <li> Show number of extracted variables under the template selector, even if the number is 0 </li> <li> Fix regression: Changing selected template MUST NOT automatically apply the template </li> </ul> </div> ` }, "v0.10": { title: "What's New in v0.10?", content: html` <img src="https://files.catbox.moe/qtnzfw.png" alt="v0.10 screenshot" style="max-width: 100%; height: auto; margin-bottom: 1em;" /> <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> Add variable hints that allows for variable disambiguation and advanced pattern matching </li> <li>Toggle to show compiled regex in Mask Sandbox</li> <li>Fix: Allow optionals to consist of white space</li> <li>UX: Modals have fixed headers and footers now</li> </ul> </div> ` }, "v0.9": { title: "What's New in v0.9?", content: html` <img src="https://files.catbox.moe/g4mclk.png" alt="v0.9 screenshot" style="max-width: 100%; height: auto; margin-bottom: 1em;" /> <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> Add Mask Sandbox for testing masks against multiple sample names </li> </ul> </div> ` }, "v0.8": { title: "What's New in v0.8?", content: html` <img src="https://files.catbox.moe/7xkrsw.png" alt="v0.8 screenshot" style="max-width: 100%; height: auto; margin-bottom: 1em;" /> <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> Add optional variables with <code>{? ... ?}</code> syntax for flexible filename matching </li> <li> Remove greedy matching setting (now uses smart non-greedy parsing by default) </li> </ul> </div> ` }, "v0.7": { title: "What's New in v0.7?", content: html` <img src="https://files.catbox.moe/snd92p.png" alt="v0.7 screenshot" style="max-width: 100%; height: auto; margin-bottom: 1em;" /> <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li>Add mask validation and highlighting with helpful messages</li> <li> Fix: No longer inserts <code>\${varname}</code> in fields if <code>\${varname}</code> is empty/not found </li> </ul> </div> ` }, "v0.6": { title: "What's New in v0.6?", content: html` <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> Added support for variables defined in the comment field of a torrent file. These are extracted as <code>\${_foo}</code>, <code>\${_bar}</code>, starting with an underscore. Mask variables cannot be defined with an underscore in the beginning of their name </li> <li> The format for variables in the comment field is: <code>foo=value1;bar=value2;</code> </li> <li> Show variable count under the template selector. Clicking it shows a modal with all variables and their values </li> </ul> </div> ` }, "v0.5.1": { title: "What's New in v0.5.1?", content: html` <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> Fix: Use textarea for textarea fields instead of text input, respect newlines </li> </ul> </div> ` }, "v0.5": { title: "What's New in v0.5?", content: html` <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> <strong>BREAKING CHANGE:</strong> Templates are no longer auto-applied when a file is selected. You have to either press the new Apply Template button or use the default keybinding: Ctrl+Shift+A </li> <li> You can now customize keybindings for Form submission and Apply Template in the settings </li> </ul> </div> ` }, "v0.4.1": { title: "What's New in v0.4.1?", content: html` <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> Going forward, posting the unminified version of the userscript </li> </ul> </div> ` }, "v0.4": { title: "What's New in v0.4?", content: html` <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> Added support for choosing select field values based on extracted variables </li> </ul> </div> ` }, "v0.3": { title: "What's New in v0.3?", content: html` <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li> Added support for extra custom fields to be included in the template (e.g: GGn Infobox Builder) </li> </ul> </div> ` }, "v0.2": { title: "What's New in v0.2?", content: html` <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li>Changed variable format from {var} to \${var}</li> <li>Added support to escape special characters, e.g: $ { }</li> <li>Added section to show list of extracted variables</li> <li>Add edit shortcut for selected template</li> </ul> </div> ` }, "v0.1": { title: "What's New in v0.1?", content: html` <div class="gut-intro-section-box"> <ul class="gut-intro-section-list"> <li>Initial version</li> </ul> </div> ` } } }; let helpModal = null; let currentSection = "quick-start"; let helpSectionsCache = null; function toggleHelpModal(sectionId = "quick-start") { if (helpModal) { closeHelpModal(); return; } openHelpModal(sectionId); } function openHelpModal(sectionId = "quick-start") { if (helpModal) { closeHelpModal(); } currentSection = sectionId; helpSectionsCache = { ...HELP_SECTIONS }; helpSectionsCache.changelog.content = getChangelogContent(); helpModal = createModal(HELP_MODAL_HTML(helpSectionsCache, version), { keyboardHandler: handleHelpKeyboard, onClose: () => { if (helpModal) { helpModal = null; helpSectionsCache = null; } } }); setupHelpModal(); showSection(sectionId); } function closeHelpModal() { ModalStack.pop(); } function setupHelpModal() { const searchInput = helpModal.querySelector("#help-search-input"); const tocToggle = helpModal.querySelector("#help-toc-toggle"); const toc = helpModal.querySelector("#help-toc"); const tocItems = helpModal.querySelectorAll(".gut-help-toc-item"); const versionLink = helpModal.querySelector("#help-version-link"); searchInput?.addEventListener("input", handleSearch); tocToggle?.addEventListener("click", () => { if (toc) { toc.style.display = toc.style.display === "none" ? "block" : "none"; } }); tocItems.forEach((item) => { item.addEventListener("click", () => { const sectionId = item.dataset.section; showSection(sectionId); }); }); versionLink?.addEventListener("click", (e) => { e.preventDefault(); showSection("changelog"); }); } function showSection(sectionId) { const sections = helpSectionsCache || HELP_SECTIONS; if (!sections[sectionId]) { sectionId = "quick-start"; } currentSection = sectionId; const sectionElements = helpModal.querySelectorAll(".gut-help-section"); sectionElements.forEach((section) => { section.classList.remove("active"); }); const activeSection = helpModal.querySelector(`[data-section-id="${sectionId}"]`); if (activeSection) { activeSection.classList.add("active"); } const tocItems = helpModal.querySelectorAll(".gut-help-toc-item"); tocItems.forEach((item) => { item.classList.remove("active"); if (item.dataset.section === sectionId) { item.classList.add("active"); } }); const contentArea = helpModal.querySelector(".gut-help-content"); if (contentArea) { contentArea.scrollTop = 0; } const searchInput = helpModal.querySelector("#help-search-input"); if (searchInput) { searchInput.value = ""; } } function handleSearch(e) { const query = e.target.value.toLowerCase().trim(); if (!query) { showAllSections(); return; } const matchingSections = []; const sections = helpSectionsCache || HELP_SECTIONS; Object.keys(sections).forEach((sectionId) => { const section = sections[sectionId]; const titleMatch = section.title.toLowerCase().includes(query); const contentMatch = section.content.toLowerCase().includes(query); const keywordMatch = section.keywords?.some( (keyword) => keyword.toLowerCase().includes(query) ); if (titleMatch || contentMatch || keywordMatch) { matchingSections.push(sectionId); } }); const tocItems = helpModal.querySelectorAll(".gut-help-toc-item"); const sectionElements = helpModal.querySelectorAll(".gut-help-section"); if (matchingSections.length === 0) { showNoResults(); return; } tocItems.forEach((item) => { const sectionId = item.dataset.section; if (matchingSections.includes(sectionId)) { item.style.display = "block"; } else { item.style.display = "none"; } }); sectionElements.forEach((section) => { section.classList.remove("active"); }); const firstMatch = matchingSections[0]; const firstSection = helpModal.querySelector(`[data-section-id="${firstMatch}"]`); if (firstSection) { firstSection.classList.add("active"); } tocItems.forEach((item) => { item.classList.remove("active"); if (item.dataset.section === firstMatch) { item.classList.add("active"); } }); } function showAllSections() { const tocItems = helpModal.querySelectorAll(".gut-help-toc-item"); tocItems.forEach((item) => { item.style.display = "block"; }); showSection(currentSection); } function showNoResults() { const sections = helpModal.querySelectorAll(".gut-help-section"); sections.forEach((section) => { section.classList.remove("active"); }); const noResults = helpModal.querySelector(".gut-help-no-results"); if (noResults) { noResults.style.display = "block"; } } function handleHelpKeyboard(e) { if (!helpModal) return; if (e.key === "Escape") { e.preventDefault(); closeHelpModal(); } if ((e.ctrlKey || e.metaKey) && e.key === "f") { e.preventDefault(); const searchInput = helpModal.querySelector("#help-search-input"); if (searchInput) { searchInput.focus(); searchInput.select(); } } } function navigateToHelpSection(sectionId) { if (!helpModal) { openHelpModal(sectionId); } else { showSection(sectionId); } } function setupSubmitKeybinding(instance) { const keybinding = instance.config.CUSTOM_SUBMIT_KEYBINDING || "Ctrl+Enter"; const keys = parseKeybinding(keybinding); document.addEventListener("keydown", (e) => { if (matchesKeybinding(e, keys)) { e.preventDefault(); const targetForm = document.querySelector( instance.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) { instance.showStatus(`Form submitted via ${keybinding}`); submitButton.click(); } else { instance.showStatus(`Form submitted via ${keybinding}`); targetForm.submit(); } } } }); } function setupApplyKeybinding(instance) { const keybinding = instance.config.CUSTOM_APPLY_KEYBINDING || "Alt+A"; const keys = parseKeybinding(keybinding); document.addEventListener("keydown", (e) => { if (matchesKeybinding(e, keys)) { e.preventDefault(); instance.applyTemplateToCurrentTorrent(); } }); } function setupHelpKeybinding(instance) { const keybinding = instance.config.CUSTOM_HELP_KEYBINDING || "Shift+?"; const keys = parseKeybinding(keybinding); document.addEventListener("keydown", (e) => { const activeElement = document.activeElement; const isInputField = activeElement && (activeElement.tagName === "INPUT" || activeElement.tagName === "TEXTAREA" || activeElement.isContentEditable); if (isInputField) { return; } if (matchesKeybinding(e, keys)) { e.preventDefault(); toggleHelpModal(); } }); } let activeTooltip = null; let tooltipTimeout = null; let hideTimeout = null; let currentAnchor = null; let visibilityCheckInterval = null; function initializeHelpTooltips() { document.addEventListener("mouseover", handleMouseOver); document.addEventListener("mouseout", handleMouseOut); document.addEventListener("click", handleTooltipClick); } function handleMouseOver(e) { const helpIcon = e.target.closest(".gut-help-icon"); const tooltip = e.target.closest(".gut-help-tooltip"); if (tooltip || helpIcon) { clearTimeout(hideTimeout); clearTimeout(tooltipTimeout); if (helpIcon) { const tooltipId = helpIcon.dataset.tooltip; if (!tooltipId || !HELP_TOOLTIPS[tooltipId]) return; if (activeTooltip && currentAnchor !== helpIcon) { hideTooltip(); } if (!activeTooltip) { tooltipTimeout = setTimeout(() => { showTooltip(helpIcon, tooltipId); }, 300); } } } } function handleMouseOut(e) { const helpIcon = e.target.closest(".gut-help-icon"); const tooltip = e.target.closest(".gut-help-tooltip"); if (tooltip || helpIcon) { clearTimeout(hideTimeout); clearTimeout(tooltipTimeout); hideTimeout = setTimeout(() => { const stillHoveringIcon = document.querySelector(".gut-help-icon:hover"); const stillHoveringTooltip = document.querySelector(".gut-help-tooltip:hover"); if (!stillHoveringIcon && !stillHoveringTooltip) { hideTooltip(); } }, 300); } } function handleTooltipClick(e) { const helpIcon = e.target.closest(".gut-help-icon"); if (!helpIcon) return; e.preventDefault(); e.stopPropagation(); const tooltipId = helpIcon.dataset.tooltip; if (!tooltipId || !HELP_TOOLTIPS[tooltipId]) return; const tooltipData = HELP_TOOLTIPS[tooltipId]; const sectionId = tooltipData.helpSection || "quick-start"; hideTooltip(); navigateToHelpSection(sectionId); } function showTooltip(anchorElement, tooltipId) { hideTooltip(); const tooltipData = HELP_TOOLTIPS[tooltipId]; if (!tooltipData) return; const tooltip = document.createElement("div"); tooltip.className = "gut-help-tooltip"; let html2 = ""; if (tooltipData.title) { html2 += `<strong>${tooltipData.title}</strong><br>`; } html2 += tooltipData.text || tooltipData.content || ""; if (tooltipData.example) { html2 += `<br><em>Example: ${tooltipData.example}</em>`; } html2 += `<div class="gut-tooltip-help-link" style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #444; font-size: 11px; color: #888;">Click for more help</div>`; tooltip.innerHTML = html2; const helpLink = tooltip.querySelector(".gut-tooltip-help-link"); if (helpLink) { helpLink.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); const sectionId = tooltipData.helpSection || "quick-start"; hideTooltip(); navigateToHelpSection(sectionId); }); } tooltip.style.userSelect = "text"; tooltip.style.cursor = "auto"; document.body.appendChild(tooltip); activeTooltip = tooltip; currentAnchor = anchorElement; positionTooltip(tooltip, anchorElement); window.addEventListener("scroll", () => hideTooltip(), { once: true }); window.addEventListener("resize", () => hideTooltip(), { once: true }); visibilityCheckInterval = setInterval(() => { if (!document.body.contains(currentAnchor) || !isElementVisible(currentAnchor)) { hideTooltip(); } }, 100); } function positionTooltip(tooltip, anchor) { const anchorRect = anchor.getBoundingClientRect(); const tooltipRect = tooltip.getBoundingClientRect(); const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; let left = anchorRect.left + anchorRect.width / 2 - tooltipRect.width / 2; let top = anchorRect.bottom + 8; if (left < 10) { left = 10; } else if (left + tooltipRect.width > viewportWidth - 10) { left = viewportWidth - tooltipRect.width - 10; } if (top + tooltipRect.height > viewportHeight - 10) { top = anchorRect.top - tooltipRect.height - 8; } tooltip.style.left = `${left}px`; tooltip.style.top = `${top}px`; } function isElementVisible(element) { if (!element) return false; const rect = element.getBoundingClientRect(); return rect.width > 0 && rect.height > 0 && window.getComputedStyle(element).visibility !== "hidden" && window.getComputedStyle(element).display !== "none"; } function hideTooltip() { if (activeTooltip) { activeTooltip.remove(); activeTooltip = null; } currentAnchor = null; clearTimeout(tooltipTimeout); clearTimeout(hideTimeout); if (visibilityCheckInterval) { clearInterval(visibilityCheckInterval); visibilityCheckInterval = null; } } const STORAGE_KEY = "gut_last_seen_version"; let introModal = null; function checkAndShowIntro() { const lastSeenVersion = localStorage.getItem(STORAGE_KEY); if (!lastSeenVersion) { showIntroModal("welcome"); } else if (lastSeenVersion !== version) { showIntroModal("update"); } } function showIntroModal(mode = "welcome") { if (introModal) { closeIntroModal(); } const isNewUser = mode === "welcome"; const content = isNewUser ? INTRO_CONTENT["new-user"] : INTRO_CONTENT.changelog[`v${version}`]; introModal = createModal(INTRO_MODAL_HTML(content), { keyboardHandler: handleIntroKeyboard, onClose: () => { localStorage.setItem(STORAGE_KEY, version); if (introModal) { introModal = null; } } }); setupIntroModal(); } function setupIntroModal(mode) { introModal.querySelector(".gut-modal-close-btn"); const getStartedBtn = introModal.querySelector("#intro-get-started"); const learnMoreBtn = introModal.querySelector("#intro-learn-more"); getStartedBtn?.addEventListener("click", () => ModalStack.pop()); learnMoreBtn?.addEventListener("click", () => { ModalStack.pop(); openHelpModal("quick-start"); }); } function closeIntroModal() { ModalStack.pop(); } function handleIntroKeyboard(e) { if (!introModal) return; if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); } } const style = `:root { --gut-ui-spacing: 10px; --gut-ui-gap: 8px; --gut-ui-gap-small: 4px; --gut-hint-min-width: 320px; } #ggn-upload-templator-ui *::-webkit-scrollbar, .gut-modal *::-webkit-scrollbar, .gut-hint-editor-modal *::-webkit-scrollbar, .gut-field-list::-webkit-scrollbar, .gut-extracted-vars::-webkit-scrollbar, .gut-template-list::-webkit-scrollbar, .gut-hints-list::-webkit-scrollbar, .gut-sandbox-results::-webkit-scrollbar, .gut-modal-content::-webkit-scrollbar, .gut-tab-content::-webkit-scrollbar { width: 12px; height: 12px; } #ggn-upload-templator-ui *::-webkit-scrollbar-track, .gut-modal *::-webkit-scrollbar-track, .gut-hint-editor-modal *::-webkit-scrollbar-track, .gut-field-list::-webkit-scrollbar-track, .gut-extracted-vars::-webkit-scrollbar-track, .gut-template-list::-webkit-scrollbar-track, .gut-hints-list::-webkit-scrollbar-track, .gut-sandbox-results::-webkit-scrollbar-track, .gut-modal-content::-webkit-scrollbar-track, .gut-tab-content::-webkit-scrollbar-track { background: #1a1a1a; border-radius: 6px; } #ggn-upload-templator-ui *::-webkit-scrollbar-thumb, .gut-modal *::-webkit-scrollbar-thumb, .gut-hint-editor-modal *::-webkit-scrollbar-thumb, .gut-field-list::-webkit-scrollbar-thumb, .gut-extracted-vars::-webkit-scrollbar-thumb, .gut-template-list::-webkit-scrollbar-thumb, .gut-hints-list::-webkit-scrollbar-thumb, .gut-sandbox-results::-webkit-scrollbar-thumb, .gut-modal-content::-webkit-scrollbar-thumb, .gut-tab-content::-webkit-scrollbar-thumb { background: #404040; border-radius: 6px; border: 2px solid #1a1a1a; } #ggn-upload-templator-ui *::-webkit-scrollbar-thumb:hover, .gut-modal *::-webkit-scrollbar-thumb:hover, .gut-hint-editor-modal *::-webkit-scrollbar-thumb:hover, .gut-field-list::-webkit-scrollbar-thumb:hover, .gut-extracted-vars::-webkit-scrollbar-thumb:hover, .gut-template-list::-webkit-scrollbar-thumb:hover, .gut-hints-list::-webkit-scrollbar-thumb:hover, .gut-sandbox-results::-webkit-scrollbar-thumb:hover, .gut-modal-content::-webkit-scrollbar-thumb:hover, .gut-tab-content::-webkit-scrollbar-thumb:hover { background: #505050; } #ggn-upload-templator-ui { background: #1a1a1a; border: 1px solid #404040; border-radius: 6px; padding: 15px; margin: 15px 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #e0e0e0; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); } .ggn-upload-templator-controls { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .gut-btn { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s ease; text-decoration: none; outline: none; box-sizing: border-box; height: auto; } .gut-btn-primary { background: #0d7377; color: #ffffff; border: 1px solid #0d7377; } .gut-btn-primary:hover { background: #0a5d61; border-color: #0a5d61; transform: translateY(-1px); } .gut-btn-danger { background: #d32f2f; color: #ffffff; border: 1px solid #d32f2f; } .gut-btn-danger:hover:not(:disabled) { background: #b71c1c; border-color: #b71c1c; transform: translateY(-1px); } .gut-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; } .gut-btn:not(:disabled):active { transform: translateY(0); } .gut-select { padding: 8px 12px; border: 1px solid #404040; border-radius: 4px; font-size: 14px; min-width: 200px; background: #2a2a2a; color: #e0e0e0; box-sizing: border-box; outline: none; height: auto; margin: 0 !important; } .gut-select:focus { border-color: #0d7377; box-shadow: 0 0 0 2px rgba(13, 115, 119, 0.2); } .gut-modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.4); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; z-index: 10000; padding: 20px; box-sizing: border-box; } .gut-modal-content { background: #1a1a1a; border: 1px solid #404040; border-radius: 8px; max-width: 1200px; height: 90vh; width: 90%; color: #e0e0e0; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); box-sizing: border-box; position: relative; display: flex; flex-direction: column; } .gut-modal-content.gut-hint-editor-modal { max-width: 1100px; height: 80vh; width: 85%; } .gut-modal-header { flex-shrink: 0; padding: 16px 20px; border-bottom: 1px solid #404040; position: relative; } .gut-modal-body { flex: 1; overflow-y: auto; padding: 20px; } .gut-modal-footer { flex-shrink: 0; padding: 16px 20px; border-top: 1px solid #404040; display: flex; justify-content: flex-end; gap: 10px; } .gut-modal-footer .gut-kbd { background: #0a0a0a; } .gut-modal h2 { margin: 0; color: #ffffff; font-size: 20px; font-weight: 600; text-align: left; display: flex; align-items: center; gap: 10px; } .gut-modal-back-btn { background: none; border: none; color: #e0e0e0; font-size: 16px; cursor: pointer; padding: 6px; border-radius: 4px; transition: color 0.2s ease, background-color 0.2s ease; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex-shrink: 0; font-family: monospace; font-weight: bold; } .gut-modal-back-btn:hover { color: #ffffff; background-color: #333333; } .gut-modal-close-btn { position: absolute; top: 12px; right: 16px; background: none; border: none; color: #e0e0e0; font-size: 28px; cursor: pointer; padding: 4px; border-radius: 4px; transition: color 0.2s ease, background-color 0.2s ease; display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex-shrink: 0; line-height: 1; font-weight: normal; z-index: 1; } .gut-modal-close-btn:hover { color: #ffffff; background-color: #333333; } .gut-form-group { margin-bottom: 15px; } .gut-form-group label { display: block; margin-bottom: 5px; font-weight: 500; color: #b0b0b0; font-size: 14px; } .gut-form-group input, .gut-form-group textarea { width: 100%; padding: 8px 12px; border: 1px solid #404040; border-radius: 4px; font-size: 14px; box-sizing: border-box; background: #2a2a2a; color: #e0e0e0; outline: none; transition: border-color 0.2s ease; height: auto; } .gut-form-group input:focus, .gut-form-group textarea:focus { border-color: #0d7377; box-shadow: 0 0 0 2px rgba(13, 115, 119, 0.2); } .gut-form-group input::placeholder, .gut-form-group textarea::placeholder { color: #666666; } .gut-field-list { max-height: 300px; overflow-y: auto; border: 1px solid #404040; border-radius: 4px; padding: 10px; background: #0f0f0f; } .gut-field-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; padding: 8px; background: #2a2a2a; border-radius: 4px; border: 1px solid #404040; flex-wrap: wrap; } .gut-field-row:hover { background: #333333; } .gut-field-row:not(:has(input[type="checkbox"]:checked)) { opacity: 0.6; } .gut-field-row.gut-hidden { display: none; } .gut-field-row input[type="checkbox"] { width: auto; margin: 0; accent-color: #0d7377; cursor: pointer; } .gut-field-row label { min-width: 150px; margin: 0; font-size: 13px; color: #b0b0b0; cursor: help; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .gut-field-row input[type="text"], .gut-field-row select { flex: 1; margin: 0; padding: 6px 8px; border: 1px solid #404040; border-radius: 3px; background: #1a1a1a; color: #e0e0e0; font-size: 12px; outline: none; height: auto; } .gut-field-row input[type="text"]:focus { border-color: #0d7377; box-shadow: 0 0 0 1px rgba(13, 115, 119, 0.3); } .gut-preview { color: #888888; font-style: italic; font-size: 11px; word-break: break-all; flex-basis: 100%; margin-top: 4px; padding-left: 20px; white-space: pre-wrap; display: none; } .gut-preview.active { color: #4dd0e1; font-weight: bold; font-style: normal; } .gut-preview.visible { display: block; } .gut-modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; padding-top: 20px; border-top: 1px solid #404040; } .gut-status { position: fixed; top: 20px; right: 20px; background: #2e7d32; color: #ffffff; padding: 12px 20px; border-radius: 6px; z-index: 10001; font-size: 14px; font-weight: 500; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); border: 1px solid #4caf50; animation: slideInRight 0.3s ease-out; } .gut-status.error { background: #d32f2f; border-color: #f44336; } @keyframes slideInRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } .gut-template-list { max-height: 400px; overflow-y: auto; border: 1px solid #404040; border-radius: 4px; background: #0f0f0f; } .gut-template-item { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid #404040; background: #2a2a2a; transition: background-color 0.2s ease; } .gut-template-item:hover { background: #333333; } .gut-template-item:last-child { border-bottom: none; } .gut-template-name { font-weight: 500; color: #e0e0e0; flex: 1; margin-right: 10px; } .gut-template-actions { display: flex; gap: 8px; } .gut-btn-small { padding: 6px 12px; font-size: 12px; min-width: auto; } .gut-btn-secondary { background: #555555; color: #ffffff; border: 1px solid #555555; } .gut-btn-secondary:hover:not(:disabled) { background: #666666; border-color: #666666; transform: translateY(-1px); } .gut-btn-warning { background: #ff9800; color: #ffffff; border: 1px solid #ff9800; } .gut-btn-warning:hover:not(:disabled) { background: #f57c00; border-color: #f57c00; transform: translateY(-1px); } /* Tab styles for modal */ .gut-modal-tabs { display: flex; border-bottom: none; margin-bottom: 0; } .gut-tab-btn { padding: 10px 16px; background: transparent; border: none; color: #b0b0b0; cursor: pointer; font-size: 13px; font-weight: 500; border-bottom: 2px solid transparent; transition: all 0.2s ease; height: auto; } .gut-tab-btn:hover { color: #e0e0e0; background: #2a2a2a; } .gut-tab-btn.active { color: #ffffff; background: #2a2a2a; border-bottom-color: transparent; } .gut-tab-content { display: none; } .gut-tab-content.active { display: block; } /* Keybinding controls styling */ .gut-keybinding-controls { display: flex !important; align-items: center !important; gap: 10px !important; padding: 8px 12px !important; background: #2a2a2a !important; border: 1px solid #404040 !important; border-radius: 4px !important; transition: border-color 0.2s ease !important; margin: 0 !important; } .gut-keybinding-controls:hover { border-color: #0d7377 !important; } /* Checkbox label styling */ .gut-checkbox-label { display: flex !important; align-items: center !important; gap: 10px !important; cursor: pointer !important; margin: 0 !important; } .gut-checkbox-label input[type="checkbox"] { width: auto !important; margin: 0 !important; accent-color: #0d7377 !important; cursor: pointer !important; } .gut-checkbox-text { font-size: 14px !important; font-weight: 500 !important; color: #b0b0b0 !important; user-select: none !important; } .gut-keybinding-text { color: #4dd0e1 !important; font-family: monospace !important; } .gut-variable-toggle { font-size: 11px !important; padding: 2px 6px !important; white-space: nowrap !important; } /* Scrollbar styling for webkit browsers */ .gut-field-list::-webkit-scrollbar, .gut-modal-content::-webkit-scrollbar { width: 8px; } .gut-field-list::-webkit-scrollbar-track, .gut-modal-content::-webkit-scrollbar-track { background: #0f0f0f; border-radius: 4px; } .gut-field-list::-webkit-scrollbar-thumb, .gut-modal-content::-webkit-scrollbar-thumb { background: #404040; border-radius: 4px; } .gut-field-list::-webkit-scrollbar-thumb:hover, .gut-modal-content::-webkit-scrollbar-thumb:hover { background: #555555; } /* Extracted variables section */ .gut-extracted-vars { border: 1px solid #404040; border-radius: 4px; background: #0f0f0f; padding: 12px; min-height: 80px; max-height: 300px; overflow-y: auto; } .gut-extracted-vars:has(.gut-no-variables) { display: flex; align-items: center; justify-content: center; } .gut-no-variables { color: #666666; font-style: italic; text-align: center; padding: 20px 10px; } .gut-variable-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; margin-bottom: 6px; background: #2a2a2a; border: 1px solid #404040; border-radius: 4px; transition: background-color 0.2s ease; } .gut-variable-item:last-child { margin-bottom: 0; } .gut-variable-item:hover { background: #333333; } .gut-variable-name { font-weight: 500; color: #4dd0e1; font-family: monospace; font-size: 13px; } .gut-variable-value { color: #e0e0e0; font-size: 12px; max-width: 60%; word-break: break-all; text-align: right; } .gut-variable-value.empty { color: #888888; font-style: italic; } /* Generic hyperlink style for secondary links */ .gut-link { font-size: 12px !important; color: #b0b0b0 !important; text-decoration: underline !important; text-underline-offset: 2px !important; cursor: pointer !important; transition: color 0.2s ease !important; } .gut-link:hover { color: #4dd0e1 !important; } .gut-link-danger { color: #ef5350 !important; } .gut-link-danger:hover { color: #ff7961 !important; } .gut-variable-toggle { font-size: 11px !important; padding: 2px 6px !important; margin-left: auto !important; align-self: flex-start !important; white-space: nowrap !important; } #variables-row { cursor: pointer; color: #b0b0b0; transition: color 0.2s ease; display: inline-block; } #variables-row:hover { color: #4dd0e1; } #mask-validation-warning { display: none; background: #b63535; color: #ffffff; padding: 10px 12px; border-radius: 4px; margin-top: 8px; font-size: 13px; border: 1px solid #b71c1c; } #mask-validation-warning.visible { display: block; } .gut-variable-controls { display: none; gap: 8px; align-items: center; flex: 1; } .gut-variable-controls.visible { display: flex; } .gut-variable-input { flex: 1; min-width: 120px; } .gut-match-type { min-width: 100px; } .select-static-mode { display: block; } .select-static-mode.hidden { display: none; } .gut-mask-input-container { position: relative; width: 100%; } .gut-mask-highlight-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; padding: 8px 12px; border: 1px solid transparent; border-radius: 4px; font-size: 14px; font-family: "Fira Code", monospace !important; color: transparent; background: #2a2a2a; pointer-events: none; overflow: hidden; white-space: pre; word-wrap: normal; box-sizing: border-box; line-height: normal; letter-spacing: normal; word-spacing: normal; font-variant-ligatures: none; } .gut-mask-input { position: relative; z-index: 1; background: transparent !important; caret-color: #e0e0e0; font-family: "Fira Code", monospace !important; font-variant-ligatures: none; letter-spacing: normal; word-spacing: normal; } .gut-highlight-variable { color: transparent; background: #2d5a5e; padding: 2px 0; border-radius: 2px; } .gut-highlight-optional { color: transparent; background: #4f2d6a; padding: 2px 0; border-radius: 2px; } .gut-highlight-warning { color: transparent; background: #4d3419; padding: 2px 0; border-radius: 2px; } .gut-highlight-error { color: transparent; background: #963a33; padding: 2px 0; border-radius: 2px; } .gut-tooltip { position: fixed; background: #2a2a2a; border: 1px solid #505050; border-radius: 4px; padding: 6px 10px; font-size: 12px; color: #e0e0e0; font-family: 'Courier New', monospace; z-index: 10000; pointer-events: none; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); white-space: nowrap; max-width: 400px; overflow: hidden; text-overflow: ellipsis; } .gut-mask-cursor-info { font-size: 12px; color: #e0e0e0; margin-top: 4px; min-height: 16px; font-family: monospace; line-height: 1.5; } .gut-mask-cursor-info:empty { display: none !important; } .gut-mask-status-container { display: none; margin-top: 8px; padding: 8px 12px; border-radius: 4px; background: #0f0f0f; border: 1px solid #404040; animation: slideDown 0.2s ease-out; } .gut-mask-status-container.visible { display: block; } .gut-status-message { font-size: 13px; padding: 4px 0; line-height: 1.4; display: flex; align-items: center; gap: 6px; } .gut-status-message svg { flex-shrink: 0; vertical-align: middle; } .gut-status-message:not(:last-child) { margin-bottom: 6px; padding-bottom: 6px; border-bottom: 1px solid #2a2a2a; } .gut-status-error { color: #f44336; } .gut-status-warning { color: #ff9800; } .gut-status-info { color: #888888; } .gut-status-success { color: #4caf50; } @keyframes slideDown { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } .gut-compiled-regex-display { display: none; margin-top: 8px; padding: 8px 12px; border-radius: 4px; background: #0f0f0f; border: 1px solid #404040; font-family: "Fira Code", monospace; font-size: 12px; color: #888888; word-break: break-all; animation: slideDown 0.2s ease-out; } .gut-compiled-regex-display.visible { display: block; } .gut-sandbox-results { margin-top: 12px; padding: 12px; background: #0f0f0f; border: 1px solid #404040; border-radius: 4px; max-height: 400px; overflow-y: auto; } .gut-sandbox-match, .gut-sandbox-no-match { padding: 10px; margin-bottom: 10px; border-radius: 4px; border-left: 3px solid; } .gut-sandbox-match { background: rgba(76, 175, 80, 0.1); border-left-color: #4caf50; } .gut-sandbox-no-match { background: rgba(244, 67, 54, 0.1); border-left-color: #f44336; } .gut-sandbox-sample-name { font-family: "Fira Code", monospace; font-size: 13px; margin-bottom: 6px; display: flex; align-items: center; gap: 8px; } .gut-sandbox-sample-name svg { flex-shrink: 0; } .gut-sandbox-variables { margin-top: 8px; padding-top: 8px; border-top: 1px solid #2a2a2a; } .gut-sandbox-variables-title { font-size: 11px; font-weight: 500; color: #888; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; } .gut-sandbox-variable-item { font-size: 12px; padding: 3px 0; display: flex; gap: 8px; align-items: baseline; } .gut-sandbox-variable-name { color: #64b5f6; font-family: "Fira Code", monospace; } .gut-sandbox-variable-value { color: #a5d6a7; font-family: "Fira Code", monospace; } .gut-sandbox-optionals { margin-top: 6px; font-size: 11px; color: #b39ddb; } .gut-hints-list { display: grid; grid-template-columns: repeat( auto-fill, minmax(var(--gut-hint-min-width), 1fr) ); gap: var(--gut-ui-spacing); } .gut-hint-item { background: #2a2a2a; border: 1px solid #404040; border-radius: 4px; padding: var(--gut-ui-gap); display: flex; flex-direction: column; gap: var(--gut-ui-gap-small); } .gut-hint-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; } .gut-hint-name-group { display: flex; align-items: center; gap: 6px; flex: 1; } .gut-hint-name { font-weight: 500; color: #64b5f6; font-family: "Fira Code", monospace; font-size: 13px; } .gut-hint-type-badge { font-size: 9px; padding: 2px 5px; background: #404040; border-radius: 3px; color: #b0b0b0; text-transform: uppercase; letter-spacing: 0.5px; } .gut-hint-default-badge { font-size: 9px; padding: 2px 5px; background: #505050; border-radius: 3px; color: #888; } .gut-hint-override-indicator { font-size: 9px; padding: 2px 5px; background: #3d2a0f; border-radius: 3px; color: #ffa726; font-weight: 500; } .gut-hint-description { font-size: 11px; color: #999; line-height: 1.4; } .gut-hint-pattern { font-size: 11px; color: #a5d6a7; font-family: "Fira Code", monospace; } .gut-hint-pattern code { background: #1a1a1a; padding: 2px 6px; border-radius: 3px; } .gut-hint-actions { display: flex; gap: 0; font-size: 11px; align-items: center; white-space: nowrap; } .gut-hint-actions .gut-link { text-decoration: none !important; } .gut-hint-actions .gut-link:hover { text-decoration: underline !important; } .gut-hint-actions-separator { color: #666; font-size: 12px; margin: 0 6px; user-select: none; } .gut-hint-mappings { font-size: 11px; color: #b39ddb; } .gut-hint-mappings-inline { margin-top: 2px; } .gut-hint-mappings-header { display: flex; justify-content: space-between; align-items: center; font-size: 10px; color: #b39ddb; margin-bottom: 4px; } .gut-hint-mappings-toggle { background: transparent; border: 1px solid #404040; color: #64b5f6; padding: 2px 6px; border-radius: 3px; cursor: pointer; font-size: 9px; transition: all 0.2s ease; } .gut-hint-mappings-toggle:hover { background: #404040; border-color: #64b5f6; } .gut-hint-mappings-content { background: #1a1a1a; border: 1px solid #404040; border-radius: 4px; padding: 6px; max-height: 200px; overflow-y: auto; } .gut-hint-mappings-content .gut-variable-item { padding: 3px 6px; margin-bottom: 2px; font-size: 11px; } .gut-hint-mappings-content .gut-variable-item:last-child { margin-bottom: 0; } .gut-hint-type-selector { display: flex; flex-direction: row; gap: 8px; flex-wrap: wrap; } .gut-radio-label { display: inline-flex !important; align-items: center !important; cursor: pointer !important; padding: 8px 12px !important; border: 1px solid #404040 !important; border-radius: 4px !important; transition: all 0.2s ease !important; gap: 8px !important; } .gut-radio-label:hover { background: #333 !important; border-color: #555 !important; } .gut-radio-label input[type="radio"] { flex-shrink: 0 !important; accent-color: #0d7377 !important; margin: 0 !important; width: auto !important; height: auto !important; } .gut-radio-label.selected { background: rgb(77 208 225 / 15%); border-color: rgb(77 208 225 / 67%) !important; } .gut-radio-label > span:first-of-type { font-weight: 500; color: #e0e0e0; white-space: nowrap; } .gut-mappings-table-header { display: flex; gap: 8px; padding: 8px 0; font-size: 12px; font-weight: 500; color: #b0b0b0; border-bottom: 1px solid #404040; margin-bottom: 8px; } .gut-mappings-row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; } .gut-mappings-row .gut-input { flex: 1; min-width: 0; } .gut-remove-mapping { width: 32px; height: 32px; padding: 0; display: flex; align-items: center; justify-content: center; font-size: 18px; line-height: 1; } #hint-mappings-rows { margin-bottom: 12px; } .gut-hint-autocomplete { position: absolute; background: #2a2a2a; border: 1px solid #404040; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); max-height: 180px; overflow-y: auto; z-index: 10002; margin-top: 2px; } .gut-hint-autocomplete-item { padding: 6px 10px; cursor: pointer; border-bottom: 1px solid #333; transition: background 0.15s ease; } .gut-hint-autocomplete-item:last-child { border-bottom: none; } .gut-hint-autocomplete-item:hover, .gut-hint-autocomplete-item.selected { background: #333; } .gut-hint-autocomplete-name { font-family: "Fira Code", monospace; font-size: 12px; font-weight: 500; color: #64b5f6; margin-bottom: 1px; } .gut-hint-autocomplete-type { display: inline-block; font-size: 9px; padding: 1px 4px; border-radius: 2px; background: #404040; color: #b39ddb; margin-bottom: 2px; } .gut-hint-autocomplete-desc { font-size: 10px; color: #999; margin-top: 1px; line-height: 1.2; } .gut-resize-handle { position: absolute; top: 0; bottom: 0; width: 4px; cursor: ew-resize; z-index: 10; transition: background-color 0.15s ease; } .gut-resize-handle-left { left: 0; } .gut-resize-handle-right { right: 0; } .gut-resize-handle-hover { background-color: rgba(13, 115, 119, 0.3); } .gut-resize-handle-active { background-color: rgba(13, 115, 119, 0.5); } .gut-modal-stacked .gut-resize-handle { display: none; } .gut-truncate-ellipsis { color: #4dd0e1; font-weight: bold; position: relative; transform: translateY(-20%); display: inline-block; margin: 0 4px; letter-spacing: 3px; } .gut-match-variables-container { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 8px; } .gut-match-variable-item { margin: 0; flex: 0 0 auto; cursor: pointer; } .gut-match-separator { display: inline-block; color: #898989; margin: 0 8px; } .gut-match-optional-info { margin-top: 8px; font-size: 11px; color: #4caf50; } .gut-match-result-item { margin-bottom: 12px; padding: 8px; background: #1e1e1e; border-left: 3px solid #4caf50; border-radius: 4px; } .gut-match-result-item.gut-sandbox-no-match { border-left-color: #f44336; } .gut-match-result-header { display: flex; align-items: flex-start; gap: 8px; } .gut-match-icon { font-size: 16px; flex-shrink: 0; } .gut-match-icon-success { color: #4caf50; } .gut-match-icon-error { color: #f44336; } .gut-sandbox-sample-name { font-family: "Fira Code", monospace; font-size: 13px; line-height: 1.4; display: inline; } .gut-char-span { display: inline; transition: background 0.2s ease; } .gut-match-highlight { background: rgba(77, 208, 225, 0.3); } .gut-confirmation-modal .gut-modal-body { max-height: 70vh; overflow-y: auto; } .gut-field-changes-list { display: flex; flex-direction: column; gap: 6px; padding: 2px; } .gut-field-change-item { background: #1e1e1e; border: 1px solid #333; border-radius: 4px; padding: 8px 10px; } .gut-field-change-row { display: flex; flex-direction: column; gap: 6px; } .gut-field-name { display: flex; justify-content: space-between; align-items: center; color: #e0e0e0; font-size: 13px; } .gut-field-type-badge { background: #2a2a2a; color: #888; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-family: 'Fira Code', monospace; text-transform: lowercase; } .gut-field-values { display: flex; align-items: center; gap: 8px; font-size: 12px; font-family: 'Fira Code', monospace; } .gut-value { flex: 1; padding: 4px 6px; border-radius: 3px; word-break: break-word; min-width: 0; } .gut-value-old { background: #252525; border: 1px solid #444; color: #b0b0b0; } .gut-value-new { background: #1a2f2f; border: 1px solid #0d7377; color: #4dd0e1; } .gut-value-arrow { color: #0d7377; font-size: 14px; font-weight: bold; flex-shrink: 0; } .gut-help-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #404040; color: #b0b0b0; font-size: 12px; font-weight: bold; font-family: 'Fira Code', monospace; cursor: pointer; margin-left: 6px; transition: all 0.2s ease; border: 1px solid #555555; flex-shrink: 0; } .gut-help-icon.gut-help-icon-no-margin { margin-left: 0; } .gut-help-icon:hover { background: #0d7377; color: #ffffff; border-color: #0d7377; } .gut-help-tooltip { position: fixed; background: #2a2a2a; border: 1px solid #0d7377; border-radius: 6px; padding: 10px 12px; font-size: 12px; color: #e0e0e0; z-index: 99999; pointer-events: auto; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6); max-width: 300px; line-height: 1.5; } .gut-help-tooltip strong { color: #4dd0e1; display: block; margin-bottom: 4px; font-size: 13px; } .gut-help-tooltip code { background: #1a1a1a; padding: 2px 5px; border-radius: 3px; font-family: 'Fira Code', monospace; font-size: 11px; color: #4dd0e1; } .gut-help-modal .gut-modal-content { max-width: 1400px; } .gut-help-header { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid #404040; background: #1a1a1a; } .gut-help-title { font-size: 20px; font-weight: 600; color: #ffffff; margin: 0; } .gut-help-search-container { flex: 1; max-width: 400px; position: relative; } .gut-help-search { width: 100%; padding: 8px 12px; border: 1px solid #404040; border-radius: 4px; background: #2a2a2a; color: #e0e0e0; font-size: 14px; outline: none; transition: border-color 0.2s ease; } .gut-help-search:focus { border-color: #0d7377; box-shadow: 0 0 0 2px rgba(13, 115, 119, 0.2); } .gut-help-search-icon { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: #888888; pointer-events: none; } .gut-help-modal .gut-modal-body { display: flex; flex-direction: column; padding: 0; overflow: hidden; } .gut-help-modal .gut-modal-header { display: flex; align-items: center; } .gut-help-subheader { display: flex; align-items: center; gap: 12px; padding: 16px; border-bottom: 1px solid #333; } .gut-help-container { display: flex; flex: 1; overflow: hidden; } .gut-help-body { display: flex; height: calc(100% - 65px); overflow: hidden; } .gut-help-toc { width: 200px; flex-shrink: 0; border-right: 1px solid #333; overflow-y: auto; padding: 16px; } .gut-help-toc-title { font-size: 12px; font-weight: 600; color: #888888; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 12px; } .gut-help-toc-item { padding: 8px 12px; margin-bottom: 4px; border-radius: 4px; cursor: pointer; font-size: 13px; color: #b0b0b0; transition: all 0.2s ease; border-left: 3px solid transparent; } .gut-help-toc-item:hover { background: #2a2a2a; color: #e0e0e0; } .gut-help-toc-item.active { background: rgba(13, 115, 119, 0.15); color: #4dd0e1; border-left-color: #0d7377; font-weight: 500; } .gut-help-content { flex: 1; overflow-y: auto; padding: 20px; } .gut-help-section { display: none; animation: fadeIn 0.3s ease-in; } .gut-help-section.active { display: block; } @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } .gut-help-section h2 { font-size: 24px; font-weight: 600; color: #ffffff; margin: 0 0 16px 0; padding-bottom: 12px; border-bottom: 2px solid #404040; } .gut-help-section h3 { font-size: 18px; font-weight: 600; color: #e0e0e0; margin: 24px 0 12px 0; } .gut-help-section h4 { font-size: 16px; font-weight: 500; color: #b0b0b0; margin: 16px 0 8px 0; } .gut-help-section p { font-size: 14px; line-height: 1.7; color: #b0b0b0; margin: 0 0 12px 0; } .gut-help-section ul, .gut-help-section ol { margin: 0 0 16px 0; padding-left: 24px; } .gut-help-section li { font-size: 14px; line-height: 1.7; color: #b0b0b0; margin-bottom: 8px; } .gut-help-section code { background: #2a2a2a; padding: 2px 6px; border-radius: 3px; font-family: 'Fira Code', monospace; font-size: 13px; color: #4dd0e1; border: 1px solid #404040; } .gut-help-section pre { background: #0f0f0f; border: 1px solid #404040; border-radius: 6px; padding: 16px; overflow-x: auto; margin: 16px 0; } .gut-help-section pre code { background: transparent; padding: 0; border: none; font-size: 13px; line-height: 1.6; } .gut-help-section strong { color: #e0e0e0; font-weight: 600; } .gut-help-section em { color: #4dd0e1; font-style: italic; } .gut-help-section a { color: #4dd0e1; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s ease; } .gut-help-section a:hover { border-bottom-color: #4dd0e1; } .gut-help-example { background: rgba(13, 115, 119, 0.1); border-left: 3px solid #0d7377; border-radius: 4px; padding: 12px 16px; margin: 16px 0; } .gut-help-example-title { font-size: 12px; font-weight: 600; color: #4dd0e1; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; } .gut-help-note { background: rgba(255, 152, 0, 0.1); border-left: 3px solid #ff9800; border-radius: 4px; padding: 12px 16px; margin: 16px 0; } .gut-help-note-title { font-size: 12px; font-weight: 600; color: #ff9800; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; } .gut-help-shortcut { display: inline-block; background: #2a2a2a; border: 1px solid #404040; border-radius: 4px; padding: 4px 8px; font-family: 'Fira Code', monospace; font-size: 12px; color: #4dd0e1; margin: 0 4px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } .gut-help-no-results { text-align: center; padding: 48px 24px; color: #888888; } .gut-help-no-results-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.5; } .gut-help-no-results-text { font-size: 16px; margin-bottom: 8px; } .gut-help-no-results-hint { font-size: 13px; color: #666666; } .gut-intro-modal .gut-modal-content { max-width: 700px; width: 85%; } .gut-intro-header { text-align: center; padding: 32px 24px 24px; border-bottom: 1px solid #404040; } .gut-intro-icon { font-size: 48px; margin-bottom: 16px; } .gut-intro-title { font-size: 28px; font-weight: 600; color: #ffffff; margin: 0 0 8px 0; } .gut-intro-subtitle { font-size: 16px; color: #888888; margin: 0; } .gut-intro-body { padding: 24px 32px; overflow-y: auto; max-height: 50vh; } .gut-intro-section { margin-bottom: 24px; } .gut-intro-section:last-child { margin-bottom: 0; } .gut-intro-section h3 { font-size: 18px; font-weight: 600; color: #4dd0e1; margin: 0 0 12px 0; display: flex; align-items: center; gap: 8px; } .gut-intro-section p { font-size: 14px; line-height: 1.7; color: #b0b0b0; margin: 0 0 12px 0; } .gut-intro-section ul { margin: 0 0 12px 0; padding-left: 24px; } .gut-intro-section li { font-size: 14px; line-height: 1.7; color: #b0b0b0; margin-bottom: 6px; } .gut-intro-section code { background: #2a2a2a; padding: 2px 6px; border-radius: 3px; font-family: 'Fira Code', monospace; font-size: 13px; color: #4dd0e1; } .gut-intro-footer { padding: 16px 24px; border-top: 1px solid #404040; display: flex; justify-content: space-between; align-items: center; gap: 16px; } .gut-intro-checkbox-label { display: flex; align-items: center; gap: 8px; cursor: pointer; font-size: 13px; color: #b0b0b0; margin: 0; } .gut-intro-checkbox-label input[type="checkbox"] { accent-color: #0d7377; cursor: pointer; width: auto; margin: 0; } .gut-intro-actions { display: flex; gap: 10px; } .gut-intro-section-box { background: #2a2a2a; border-radius: 6px; padding: 16px; margin-bottom: 20px; } .gut-intro-section-title { margin: 0 0 12px 0; font-size: 14px; } .gut-intro-section-list { margin: 0; padding-left: 20px; line-height: 1.8; } .gut-help-table { width: 100%; border-collapse: collapse; margin: 10px 0; } .gut-help-table tr { border-bottom: 1px solid #444; } .gut-help-table td { padding: 8px; } .gut-help-section-highlight { background: #2a3a2a; border-radius: 6px; padding: 16px; margin-bottom: 20px; } .gut-help-section-highlight-title { margin: 0 0 12px 0; font-size: 14px; color: #4caf50; } .gut-help-pre { background: #1a1a1a; padding: 10px; border-radius: 4px; overflow-x: auto; } .gut-intro-modal-header-centered { margin: 0; text-align: center; width: 100%; } .gut-intro-help-box { background: #2a2a2a; border-radius: 6px; padding: 16px; margin-top: 20px; } .gut-intro-help-box p { margin: 0 0 8px 0; font-weight: 600; } .gut-intro-help-box ul { margin: 0; padding-left: 20px; line-height: 1.8; } .gut-intro-footer-centered { display: flex; justify-content: center; gap: 12px; } .gut-intro-footer-centered .gut-btn { min-width: 120px; } .gut-kbd { padding: 2px 6px; background: #1a1a1a; border-radius: 3px; font-family: monospace; } .gut-changelog-entry { margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid #404040; } .gut-changelog-entry:last-child { border-bottom: none; } .gut-changelog-version { font-size: 20px; font-weight: 600; color: #4dd0e1; margin: 0 0 16px 0; } .gut-changelog-content { color: #b0b0b0; } #help-version-link { color: #888 !important; text-decoration: none !important; transition: color 0.2s ease; } #help-version-link:hover { color: #4dd0e1 !important; text-decoration: underline !important; } `; 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() { this.templates = loadTemplates(); this.selectedTemplate = loadSelectedTemplate(); this.hideUnselectedFields = loadHideUnselected(); this.config = { ...DEFAULT_CONFIG, ...loadSettings() }; this.sandboxSets = loadSandboxSets(); this.currentSandboxSet = loadCurrentSandboxSet(); this.hints = loadHints(); logDebug("Initialized core state", { templates: Object.keys(this.templates), selectedTemplate: this.selectedTemplate, hideUnselectedFields: this.hideUnselectedFields, config: this.config, hints: Object.keys(this.hints) }); this.init(); } init() { logDebug("Initializing..."); try { injectUI(this); } catch (error) { console.error("UI injection failed:", error); } try { watchFileInputs(this); } catch (error) { console.error("File input watching setup failed:", error); } if (this.config.SUBMIT_KEYBINDING) { try { setupSubmitKeybinding(this); } catch (error) { console.error("Submit keybinding setup failed:", error); } } if (this.config.APPLY_KEYBINDING) { try { setupApplyKeybinding(this); } catch (error) { console.error("Apply keybinding setup failed:", error); } } if (this.config.HELP_KEYBINDING) { try { setupHelpKeybinding(this); } catch (error) { console.error("Help keybinding setup failed:", error); } } try { initializeHelpTooltips(); } catch (error) { console.error("Help tooltips initialization failed:", error); } try { checkAndShowIntro(); } catch (error) { console.error("Intro modal check failed:", error); } logDebug("Initialized"); } async showTemplateCreator(editTemplateName = null, editTemplate2 = null, openMode = "manage") { await showTemplateCreator(this, editTemplateName, editTemplate2, openMode); } async getCurrentVariables() { return await getCurrentVariables(this); } async showVariablesModal() { const variables = await this.getCurrentVariables(); const fileInputs = this.config.TARGET_FORM_SELECTOR ? document.querySelectorAll( `${this.config.TARGET_FORM_SELECTOR} input[type="file"]` ) : document.querySelectorAll('input[type="file"]'); let torrentName = ""; for (const input of fileInputs) { if (input.files && input.files[0] && input.files[0].name.toLowerCase().endsWith(".torrent")) { try { const { TorrentUtils: TorrentUtils2 } = await Promise.resolve().then(() => torrent); const torrentData = await TorrentUtils2.parseTorrentFile(input.files[0]); torrentName = torrentData.name || ""; break; } catch (error) { console.warn("Could not parse torrent file:", error); } } } const mask = this.selectedTemplate && this.templates[this.selectedTemplate] ? this.templates[this.selectedTemplate].mask : ""; showVariablesModal(this, variables.all, torrentName, mask); } async updateVariableCount() { await updateVariableCount(this); } saveTemplate(modal, editingTemplateName = null) { saveTemplate(this, modal, editingTemplateName); } updateTemplateSelector() { updateTemplateSelector(this); } selectTemplate(templateName) { selectTemplate(this, templateName); } applyTemplate(templateName, torrentName, commentVariables = {}) { applyTemplate(this, templateName, torrentName, commentVariables); } async checkAndApplyToExistingTorrent(templateName) { await checkAndApplyToExistingTorrent(this, templateName); } async applyTemplateToCurrentTorrent() { await applyTemplateToCurrentTorrent(this); } showTemplateAndSettingsManager() { showTemplateAndSettingsManager(this); } deleteTemplate(templateName) { deleteTemplate(this, templateName); } cloneTemplate(templateName) { cloneTemplate(this, templateName); } editTemplate(templateName, openMode) { editTemplate(this, templateName, openMode); } showSandboxWithMask(mask, sample) { showSandboxWithMask(this, mask, sample); } saveHints(hints) { this.hints = hints; return saveHints(hints); } getHints() { return this.hints; } 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); } escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } } logDebug("Script loaded (readyState:", document.readyState, ")"); let ggnInstance = null; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => { logDebug("Initializing after DOMContentLoaded"); try { ggnInstance = new GGnUploadTemplator(); } catch (error) { console.error("Failed to initialize:", error); } }); } else { logDebug("Initializing immediately (DOM already ready)"); try { ggnInstance = new GGnUploadTemplator(); } catch (error) { console.error("Failed to initialize:", error); } } const GGnUploadTemplatorAPI = { version, getTemplates() { if (!ggnInstance) { console.warn("GGnUploadTemplator not initialized yet"); return []; } return Object.keys(ggnInstance.templates).map((name) => ({ name, mask: ggnInstance.templates[name].mask, fieldMappings: ggnInstance.templates[name].fieldMappings, variableMatching: ggnInstance.templates[name].variableMatching, customUnselectedFields: ggnInstance.templates[name].customUnselectedFields })); }, getTemplate(templateName) { if (!ggnInstance) { console.warn("GGnUploadTemplator not initialized yet"); return null; } const template = ggnInstance.templates[templateName]; if (!template) { return null; } return { name: templateName, mask: template.mask, fieldMappings: template.fieldMappings, variableMatching: template.variableMatching, customUnselectedFields: template.customUnselectedFields }; }, extractVariables(templateName, torrentName) { if (!ggnInstance) { console.warn("GGnUploadTemplator not initialized yet"); return {}; } const template = ggnInstance.templates[templateName]; if (!template) { console.warn(`Template "${templateName}" not found`); return {}; } return parseTemplateWithOptionals(template.mask, torrentName, ggnInstance.hints); }, getInstance() { return ggnInstance; } }; if (typeof unsafeWindow !== "undefined") { unsafeWindow.GGnUploadTemplator = GGnUploadTemplatorAPI; } else { window.GGnUploadTemplator = GGnUploadTemplatorAPI; } })();