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