Auto-fill upload forms using torrent file data with configurable templates
当前为
// ==UserScript==
// @name GGn Upload Templator
// @namespace http://tampermonkey.net/
// @version 0.2
// @description Auto-fill upload forms using torrent file data with configurable templates
// @author leveldesigner
// @license Unlicense
// @icon https://gazellegames.net/favicon.ico
// @match https://*.gazellegames.net/upload.php*
// @grant none
// ==/UserScript==
(function () {
"use strict";
// Default configuration - can be overridden by user settings
const DEFAULT_CONFIG = {
TARGET_FORM_SELECTOR: "#upload_table",
SUBMIT_KEYBINDING: true,
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",
],
};
// CSS Styles - Dark Theme with Explicit Styles
const UI_STYLES = `
#ggn-upload-templator-ui {
background: #1a1a1a;
border: 1px solid #404040;
border-radius: 6px;
padding: 15px;
margin: 15px 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #e0e0e0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.ggn-upload-templator-controls {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.gut-btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
text-decoration: none;
outline: none;
box-sizing: border-box;
height: auto;
}
.gut-btn-primary {
background: #0d7377;
color: #ffffff;
border: 1px solid #0d7377;
}
.gut-btn-primary:hover {
background: #0a5d61;
border-color: #0a5d61;
transform: translateY(-1px);
}
.gut-btn-danger {
background: #d32f2f;
color: #ffffff;
border: 1px solid #d32f2f;
}
.gut-btn-danger:hover:not(:disabled) {
background: #b71c1c;
border-color: #b71c1c;
transform: translateY(-1px);
}
.gut-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.gut-btn:not(:disabled):active {
transform: translateY(0);
}
.gut-select {
padding: 8px 12px;
border: 1px solid #404040;
border-radius: 4px;
font-size: 14px;
min-width: 200px;
background: #2a2a2a;
color: #e0e0e0;
box-sizing: border-box;
outline: none;
height: auto;
margin: 0 !important;
}
.gut-select:focus {
border-color: #0d7377;
box-shadow: 0 0 0 2px rgba(13, 115, 119, 0.2);
}
.gut-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 20px;
box-sizing: border-box;
}
.gut-modal-content {
background: #1a1a1a;
border: 1px solid #404040;
border-radius: 8px;
padding: 24px;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
width: 90%;
color: #e0e0e0;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
box-sizing: border-box;
}
.gut-modal h2 {
margin: 0 0 20px 0;
color: #ffffff;
font-size: 24px;
font-weight: 600;
text-align: left;
position: relative;
display: flex;
align-items: center;
gap: 10px;
}
.gut-modal-back-btn {
background: none;
border: none;
color: #e0e0e0;
font-size: 16px;
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: color 0.2s ease, background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
font-family: monospace;
font-weight: bold;
}
.gut-modal-back-btn:hover {
color: #ffffff;
background-color: #333333;
}
.gut-form-group {
margin-bottom: 15px;
}
.gut-form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #b0b0b0;
font-size: 14px;
}
.gut-form-group input, .gut-form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #404040;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
background: #2a2a2a;
color: #e0e0e0;
outline: none;
transition: border-color 0.2s ease;
height: auto;
}
.gut-form-group input:focus, .gut-form-group textarea:focus {
border-color: #0d7377;
box-shadow: 0 0 0 2px rgba(13, 115, 119, 0.2);
}
.gut-form-group input::placeholder, .gut-form-group textarea::placeholder {
color: #666666;
}
.gut-field-list {
max-height: 300px;
overflow-y: auto;
border: 1px solid #404040;
border-radius: 4px;
padding: 10px;
background: #0f0f0f;
}
.gut-field-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
padding: 8px;
background: #2a2a2a;
border-radius: 4px;
border: 1px solid #404040;
flex-wrap: wrap;
}
.gut-field-row:hover {
background: #333333;
}
.gut-field-row:not(:has(input[type="checkbox"]:checked)) {
opacity: 0.6;
}
.gut-field-row.gut-hidden {
display: none;
}
.gut-field-row input[type="checkbox"] {
width: auto;
margin: 0;
accent-color: #0d7377;
cursor: pointer;
}
.gut-field-row label {
min-width: 150px;
margin: 0;
font-size: 13px;
color: #b0b0b0;
cursor: help;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gut-field-row input[type="text"], .gut-field-row select {
flex: 1;
margin: 0;
padding: 6px 8px;
border: 1px solid #404040;
border-radius: 3px;
background: #1a1a1a;
color: #e0e0e0;
font-size: 12px;
outline: none;
height: auto;
}
.gut-field-row input[type="text"]:focus {
border-color: #0d7377;
box-shadow: 0 0 0 1px rgba(13, 115, 119, 0.3);
}
.gut-preview {
color: #888888;
font-style: italic;
font-size: 11px;
word-break: break-all;
flex-basis: 100%;
margin-top: 4px;
padding-left: 20px;
}
.gut-preview.active {
color: #4dd0e1;
font-weight: bold;
font-style: normal;
}
.gut-modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #404040;
}
.gut-status {
position: fixed;
top: 20px;
right: 20px;
background: #2e7d32;
color: #ffffff;
padding: 12px 20px;
border-radius: 6px;
z-index: 10001;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
border: 1px solid #4caf50;
animation: slideInRight 0.3s ease-out;
}
.gut-status.error {
background: #d32f2f;
border-color: #f44336;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.gut-template-list {
max-height: 400px;
overflow-y: auto;
border: 1px solid #404040;
border-radius: 4px;
background: #0f0f0f;
}
.gut-template-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #404040;
background: #2a2a2a;
transition: background-color 0.2s ease;
}
.gut-template-item:hover {
background: #333333;
}
.gut-template-item:last-child {
border-bottom: none;
}
.gut-template-name {
font-weight: 500;
color: #e0e0e0;
flex: 1;
margin-right: 10px;
}
.gut-template-actions {
display: flex;
gap: 8px;
}
.gut-btn-small {
padding: 6px 12px;
font-size: 12px;
min-width: auto;
}
.gut-btn-secondary {
background: #555555;
color: #ffffff;
border: 1px solid #555555;
}
.gut-btn-secondary:hover:not(:disabled) {
background: #666666;
border-color: #666666;
transform: translateY(-1px);
}
/* Tab styles for modal */
.gut-modal-tabs {
display: flex;
border-bottom: 1px solid #404040;
margin-bottom: 20px;
}
.gut-tab-btn {
padding: 12px 20px;
background: transparent;
border: none;
color: #b0b0b0;
cursor: pointer;
font-size: 14px;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: all 0.2s ease;
height: auto;
}
.gut-tab-btn:hover {
color: #e0e0e0;
background: #2a2a2a;
}
.gut-tab-btn.active {
color: #ffffff;
border-bottom-color: #0d7377;
}
.gut-tab-content {
display: none;
}
.gut-tab-content.active {
display: block;
}
/* Checkbox label styling */
.gut-checkbox-label {
display: flex !important;
align-items: center !important;
gap: 10px !important;
padding: 8px 12px !important;
background: #2a2a2a !important;
border: 1px solid #404040 !important;
border-radius: 4px !important;
cursor: pointer !important;
transition: border-color 0.2s ease !important;
margin: 0 !important;
}
.gut-checkbox-label:hover {
border-color: #0d7377 !important;
}
.gut-checkbox-label input[type="checkbox"] {
width: auto !important;
margin: 0 !important;
accent-color: #0d7377 !important;
cursor: pointer !important;
}
.gut-checkbox-text {
font-size: 14px !important;
font-weight: 500 !important;
color: #b0b0b0 !important;
user-select: none !important;
}
/* Scrollbar styling for webkit browsers */
.gut-field-list::-webkit-scrollbar,
.gut-modal-content::-webkit-scrollbar {
width: 8px;
}
.gut-field-list::-webkit-scrollbar-track,
.gut-modal-content::-webkit-scrollbar-track {
background: #0f0f0f;
border-radius: 4px;
}
.gut-field-list::-webkit-scrollbar-thumb,
.gut-modal-content::-webkit-scrollbar-thumb {
background: #404040;
border-radius: 4px;
}
.gut-field-list::-webkit-scrollbar-thumb:hover,
.gut-modal-content::-webkit-scrollbar-thumb:hover {
background: #555555;
}
/* Extracted variables section */
.gut-extracted-vars {
border: 1px solid #404040;
border-radius: 4px;
background: #0f0f0f;
padding: 12px;
min-height: 80px;
max-height: 300px;
overflow-y: auto;
}
.gut-extracted-vars:has(.gut-no-variables) {
display: flex;
align-items: center;
justify-content: center;
}
.gut-no-variables {
color: #666666;
font-style: italic;
text-align: center;
padding: 20px 10px;
}
.gut-variable-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
margin-bottom: 6px;
background: #2a2a2a;
border: 1px solid #404040;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.gut-variable-item:last-child {
margin-bottom: 0;
}
.gut-variable-item:hover {
background: #333333;
}
.gut-variable-name {
font-weight: 500;
color: #4dd0e1;
font-family: monospace;
font-size: 13px;
}
.gut-variable-value {
color: #e0e0e0;
font-size: 12px;
max-width: 60%;
word-break: break-all;
text-align: right;
}
.gut-variable-value.empty {
color: #888888;
font-style: italic;
}
/* Edit template link hover effect */
#edit-selected-template-btn:hover {
color: #4dd0e1 !important;
}
`;
// Torrent utility class
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,
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, files: [] };
}
}
// 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");
}
}
class GGnUploadTemplator {
constructor() {
this.templates = JSON.parse(
localStorage.getItem("ggn-upload-templator-templates") || "{}",
);
this.selectedTemplate =
localStorage.getItem("ggn-upload-templator-selected") || null;
this.hideUnselectedFields = JSON.parse(
localStorage.getItem("ggn-upload-templator-hide-unselected") || "true",
);
// Load user settings or use defaults
this.config = {
...DEFAULT_CONFIG,
...JSON.parse(
localStorage.getItem("ggn-upload-templator-settings") || "{}",
),
};
this.init();
}
init() {
this.injectUI();
this.watchFileInputs();
if (this.config.SUBMIT_KEYBINDING) {
this.setupSubmitKeybinding();
}
}
// Parse torrent name using template mask
parseTemplate(mask, torrentName, greedyMatching = true) {
if (!mask || !torrentName) return {};
// Convert template mask to regex with named groups
// Support ${var_name} syntax with escaping for literal $, {, }
let regexPattern = mask
// First, temporarily replace escaped characters with placeholders
.replace(/\\\$/g, "___ESCAPED_DOLLAR___")
.replace(/\\\{/g, "___ESCAPED_LBRACE___")
.replace(/\\\}/g, "___ESCAPED_RBRACE___")
.replace(/\\\\/g, "___ESCAPED_BACKSLASH___")
// Escape all regex special characters
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
// Convert ${field} to named groups
// Use greedy or non-greedy based on the greedyMatching parameter
.replace(/\\\$\\\{([^}]+)\\\}/g, (match, varName, offset, string) => {
if (greedyMatching) {
// When greedy matching is enabled, use greedy quantifiers for all variables
return `(?<${varName}>.+)`;
} else {
// Default behavior: non-greedy for variables with more variables after them, greedy for the last
const remainingString = string.slice(offset + match.length);
const hasMoreVariables = /\\\$\\\{[^}]+\\\}/.test(remainingString);
if (hasMoreVariables) {
return `(?<${varName}>.*?)`; // Non-greedy for variables with more variables after them
} else {
return `(?<${varName}>.+)`; // Greedy for the last variable (requires at least 1 char)
}
}
})
// Restore escaped characters as literal matches
.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 {};
}
}
// Interpolate template string with extracted data
interpolate(template, data) {
if (!template || !data) return template;
return template.replace(
/\$\{([^}]+)\}/g,
(match, key) => data[key] || match,
);
}
// Get current form data
getCurrentFormData() {
const formData = {};
const formSelector = this.config.TARGET_FORM_SELECTOR || "form";
const targetForm = document.querySelector(formSelector);
const inputs = targetForm
? targetForm.querySelectorAll(
"input[name], select[name], textarea[name]",
)
: document.querySelectorAll(
"input[name], select[name], textarea[name]",
);
inputs.forEach((input) => {
if (
input.name &&
input.type !== "file" &&
input.type !== "button" &&
input.type !== "submit"
) {
// For radio buttons, only process if we haven't seen this group yet
if (input.type === "radio" && formData[input.name]) {
return; // Skip, already processed this radio group
}
const fieldInfo = {
value:
input.type === "checkbox" || input.type === "radio"
? input.checked
: input.value || "",
label: this.getFieldLabel(input),
type: input.tagName.toLowerCase(),
inputType: input.type,
};
// For radio buttons, we need to handle them specially - group by name
if (input.type === "radio") {
const radioGroup = document.querySelectorAll(
`input[name="${input.name}"][type="radio"]`,
);
fieldInfo.radioOptions = Array.from(radioGroup).map((radio) => ({
value: radio.value,
checked: radio.checked,
label: this.getFieldLabel(radio) || radio.value,
}));
// Find the selected value from the group
const selectedRadio = Array.from(radioGroup).find(
(radio) => radio.checked,
);
fieldInfo.selectedValue = selectedRadio ? selectedRadio.value : "";
fieldInfo.value = fieldInfo.selectedValue; // Override value for radio groups
}
// For select elements, capture all options and current selection
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[input.name] = fieldInfo;
}
});
return formData;
}
// Get field label from parent table structure
getFieldLabel(input) {
// For radio buttons, look for associated label elements first
if (input.type === "radio" && input.id) {
const parentTd = input.closest("td");
if (parentTd) {
const associatedLabel = parentTd.querySelector(
`label[for="${input.id}"]`,
);
if (associatedLabel) {
return associatedLabel.textContent.trim() || input.value;
}
}
}
const parentRow = input.closest("tr");
if (parentRow) {
const labelCell = parentRow.querySelector("td.label");
if (labelCell) {
const labelText = labelCell.textContent.trim();
return labelText ? `${labelText} (${input.name})` : input.name;
}
}
return input.name;
}
// Create and inject UI elements
injectUI() {
this.injectStyles();
// Always find the first file input on the page for UI injection
const fileInput = document.querySelector('input[type="file"]');
if (!fileInput) return;
// Create UI container
const uiContainer = document.createElement("div");
uiContainer.id = "ggn-upload-templator-ui";
uiContainer.innerHTML = `
<div 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" style="font-size: 12px; color: #b0b0b0; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; transition: color 0.2s ease; ${this.selectedTemplate && this.selectedTemplate !== "none" && this.templates[this.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>
<option value="none" ${this.selectedTemplate === "none" ? "selected" : ""}>None</option>
${Object.keys(this.templates)
.map(
(name) =>
`<option value="${name}" ${name === this.selectedTemplate ? "selected" : ""}>${name}</option>`,
)
.join("")}
</select>
</div>
</div>
<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>
`;
fileInput.parentNode.insertBefore(uiContainer, fileInput);
// Bind events
document
.getElementById("create-template-btn")
.addEventListener(
"click",
async () => await this.showTemplateCreator(),
);
document
.getElementById("template-selector")
.addEventListener("change", (e) => this.selectTemplate(e.target.value));
document
.getElementById("manage-templates-btn")
.addEventListener("click", () => this.showTemplateAndSettingsManager());
document
.getElementById("edit-selected-template-btn")
.addEventListener("click", (e) => {
e.preventDefault();
this.editTemplate(this.selectedTemplate);
});
}
// Inject CSS styles
injectStyles() {
if (document.getElementById("ggn-upload-templator-styles")) return;
const styles = document.createElement("style");
styles.id = "ggn-upload-templator-styles";
styles.textContent = UI_STYLES;
document.head.appendChild(styles);
}
// Show template creation modal
async showTemplateCreator(editTemplateName = null, editTemplate = null) {
const formData = this.getCurrentFormData();
if (Object.keys(formData).length === 0) {
alert("No form fields found on this page.");
return;
}
// Check if there's already a torrent file selected and parse it
let selectedTorrentName = "";
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],
);
selectedTorrentName = torrentData.name || "";
break;
} catch (error) {
console.warn("Could not parse selected torrent file:", error);
}
}
}
const modal = document.createElement("div");
modal.className = "gut-modal";
modal.innerHTML = `
<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 ? this.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="${this.escapeHtml(selectedTorrentName)}" placeholder="e.g., PCWorld - Issue 05 - 01-2024.zip">
</div>
<div class="gut-form-group" style="margin-bottom: 8px;">
<label for="torrent-mask">Torrent Name Mask:</label>
<input type="text" id="torrent-mask" placeholder="e.g., \${magazine} - Issue \${issue} - \${month}-\${year}.\${ext}" value="${editTemplate ? this.escapeHtml(editTemplate.mask) : ""}">
</div>
<div class="gut-form-group">
<label style="display: inline-flex; align-items: center; gap: 8px; margin: 0; font-size: 13px; color: #888888; font-weight: normal;" title="When enabled, patterns capture as much text as possible. When disabled, uses smart matching that's usually more precise.">
<input type="checkbox" id="greedy-matching" ${editTemplate ? (editTemplate.greedyMatching !== false ? "checked" : "") : "checked"} style="margin: 0; accent-color: #0d7377; width: auto;">
<span>Greedy matching</span>
</label>
</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..." 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 =
this.config.IGNORED_FIELDS_BY_DEFAULT.includes(
name.toLowerCase(),
);
// When editing, check if this field is in the template
const isInTemplate =
editTemplate &&
editTemplate.fieldMappings.hasOwnProperty(
name,
);
const templateValue = isInTemplate
? editTemplate.fieldMappings[name]
: null;
// Check if there's custom selection state for this field
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" : ""}">
<input type="checkbox" ${shouldBeChecked ? "checked" : ""} data-field="${name}">
<label title="${name}">${fieldData.label}:</label>
${
fieldData.type === "select"
? `<select data-template="${name}" class="template-input gut-select">
${fieldData.options
.map((option) => {
let selected = option.selected;
if (
templateValue &&
templateValue === option.value
) {
selected = true;
}
return `<option value="${this.escapeHtml(option.value)}" ${selected ? "selected" : ""}>${this.escapeHtml(option.text)}</option>`;
})
.join("")}
</select>`
: 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="${this.escapeHtml(option.value)}" ${selected ? "selected" : ""}>${this.escapeHtml(option.label)}</option>`;
})
.join("")}
</select>`
: `<input type="text" value="${templateValue !== null ? this.escapeHtml(String(templateValue)) : this.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>
`;
document.body.appendChild(modal);
// Setup live preview
const maskInput = modal.querySelector("#torrent-mask");
const sampleInput = modal.querySelector("#sample-torrent");
const templateInputs = modal.querySelectorAll(".template-input");
// Toggle unselected fields visibility
const toggleBtn = modal.querySelector("#toggle-unselected");
const filterInput = modal.querySelector("#field-filter");
// Field filtering functionality
const filterFields = () => {
const filterValue = filterInput.value.toLowerCase();
const fieldRows = modal.querySelectorAll(".gut-field-row");
const fieldList = modal.querySelector(".gut-field-list");
let visibleCount = 0;
// Remove existing no-results message
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();
// Check if field matches filter (substring search on field name and label)
const matchesFilter =
!filterValue ||
fieldName.includes(filterValue) ||
labelText.includes(filterValue);
// Apply visibility rules: filter + unselected visibility
const shouldShowBasedOnSelection =
checkbox.checked || !this.hideUnselectedFields;
const shouldShow = matchesFilter && shouldShowBasedOnSelection;
if (shouldShow) {
row.classList.remove("gut-hidden");
visibleCount++;
} else {
row.classList.add("gut-hidden");
}
});
// Show no results message if filter is active and no fields are visible
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 = () => {
this.hideUnselectedFields = !this.hideUnselectedFields;
localStorage.setItem(
"ggn-upload-templator-hide-unselected",
JSON.stringify(this.hideUnselectedFields),
);
toggleBtn.textContent = this.hideUnselectedFields
? "Show Unselected"
: "Hide Unselected";
// Re-apply filter which will handle all visibility logic
filterFields();
};
// Initialize button text and field visibility based on current state
toggleBtn.textContent = this.hideUnselectedFields
? "Show Unselected"
: "Hide Unselected";
// Apply initial visibility state
filterFields();
toggleBtn.addEventListener("click", toggleUnselectedFields);
filterInput.addEventListener("input", filterFields);
const updatePreviews = () => {
const mask = maskInput.value;
const sample = sampleInput.value;
const greedyMatching = modal.querySelector("#greedy-matching").checked;
const extracted = this.parseTemplate(mask, sample, greedyMatching);
// Update extracted variables section
const extractedVarsContainer = modal.querySelector(
"#extracted-variables",
);
if (Object.keys(extracted).length === 0) {
extractedVarsContainer.innerHTML =
'<div class="gut-no-variables">No variables defined yet. Add variables like ${name} to your mask.</div>';
} else {
extractedVarsContainer.innerHTML = Object.entries(extracted)
.map(
([varName, varValue]) => `
<div class="gut-variable-item">
<span class="gut-variable-name">\${${this.escapeHtml(varName)}}</span>
<span class="gut-variable-value ${varValue ? "" : "empty"}">${varValue ? this.escapeHtml(varValue) : "(empty)"}</span>
</div>
`,
)
.join("");
}
templateInputs.forEach((input) => {
const fieldName = input.dataset.template;
const preview = modal.querySelector(`[data-preview="${fieldName}"]`);
if (input.type === "checkbox") {
preview.textContent = input.checked ? "✓ checked" : "✗ unchecked";
preview.className = "gut-preview";
} else {
const inputValue = input.value || "";
const interpolated = this.interpolate(inputValue, extracted);
if (
inputValue.includes("${") &&
Object.keys(extracted).length > 0
) {
preview.textContent = `→ ${interpolated}`;
preview.className = "gut-preview active";
preview.style.display = "block";
} else {
preview.textContent = "";
preview.className = "gut-preview";
preview.style.display = "none";
}
}
});
};
[maskInput, sampleInput, ...templateInputs].forEach((input) => {
input.addEventListener("input", updatePreviews);
input.addEventListener("change", updatePreviews); // For select elements
});
// Initialize previews on modal open
updatePreviews();
// Update visibility when checkboxes change
modal.addEventListener("change", (e) => {
if (e.target.type === "checkbox") {
if (e.target.id === "greedy-matching") {
updatePreviews(); // Update previews when greedy matching changes
} else {
filterFields(); // Re-apply all visibility rules including filter
}
}
});
// Event handlers
modal.querySelector("#cancel-template").addEventListener("click", () => {
document.body.removeChild(modal);
});
modal.querySelector("#save-template").addEventListener("click", () => {
this.saveTemplate(modal, editTemplateName);
});
// Close on background click
modal.addEventListener("click", (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
// Close on Esc key
const handleEscKey = (e) => {
if (e.key === "Escape" && document.body.contains(modal)) {
document.body.removeChild(modal);
document.removeEventListener("keydown", handleEscKey);
}
};
document.addEventListener("keydown", handleEscKey);
// Back to template manager button
const backBtn = modal.querySelector("#back-to-manager");
if (backBtn) {
backBtn.addEventListener("click", () => {
document.body.removeChild(modal);
this.showTemplateAndSettingsManager();
});
}
}
// 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 editing and name changed, or creating new and name exists
if (
(editingTemplateName &&
name !== editingTemplateName &&
this.templates[name]) ||
(!editingTemplateName && this.templates[name])
) {
if (!confirm(`Template "${name}" already exists. Overwrite?`)) {
return;
}
}
const fieldMappings = {};
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 {
fieldMappings[fieldName] = templateInput.value;
}
}
});
// Capture custom unselected fields (different from default ignored list)
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 this field differs from the default behavior, track it
if (
(isDefaultIgnored && isCurrentlyChecked) ||
(!isDefaultIgnored && !isCurrentlyChecked)
) {
customUnselectedFields.push({
field: fieldName,
selected: isCurrentlyChecked,
});
}
}
});
// If editing and the name changed, delete the old template
if (editingTemplateName && name !== editingTemplateName) {
delete this.templates[editingTemplateName];
if (this.selectedTemplate === editingTemplateName) {
this.selectedTemplate = name;
localStorage.setItem("ggn-upload-templator-selected", name);
}
}
const greedyMatching = modal.querySelector("#greedy-matching").checked;
this.templates[name] = {
mask,
fieldMappings,
greedyMatching,
customUnselectedFields:
customUnselectedFields.length > 0
? customUnselectedFields
: undefined,
};
localStorage.setItem(
"ggn-upload-templator-templates",
JSON.stringify(this.templates),
);
this.updateTemplateSelector();
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 = `
<option value="">Select Template</option>
<option value="none" ${this.selectedTemplate === "none" ? "selected" : ""}>None</option>
${Object.keys(this.templates)
.map(
(name) =>
`<option value="${name}" ${name === this.selectedTemplate ? "selected" : ""}>${name}</option>`,
)
.join("")}
`;
// Update edit button visibility
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");
}
// Update edit button visibility
this.updateEditButtonVisibility();
if (templateName === "none") {
this.showStatus("No template selected - auto-fill disabled");
} else if (templateName) {
this.showStatus(`Template "${templateName}" selected`);
// Check if there's already a torrent file selected and apply template immediately
this.checkAndApplyToExistingTorrent(templateName);
}
}
// 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],
);
this.applyTemplate(templateName, torrentData.name);
return; // Apply to first found torrent file only
} catch (error) {
console.warn("Could not parse existing torrent file:", error);
}
}
}
}
// Setup global Ctrl+Enter keybinding for form submission
setupSubmitKeybinding() {
document.addEventListener("keydown", (e) => {
// Check for Ctrl+Enter (or Cmd+Enter on Mac)
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
const targetForm = document.querySelector(
this.config.TARGET_FORM_SELECTOR,
);
if (targetForm) {
// Look for submit button within the form
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 Ctrl+Enter");
submitButton.click();
} else {
// Fallback: submit the form directly
this.showStatus("Form submitted via Ctrl+Enter");
targetForm.submit();
}
}
}
});
}
// Show combined template and settings manager modal
showTemplateAndSettingsManager() {
const modal = document.createElement("div");
modal.className = "gut-modal";
modal.innerHTML = `
<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>
</div>
<div class="gut-tab-content active" id="templates-tab">
${
Object.keys(this.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(this.templates)
.map(
(name) => `
<div class="gut-template-item">
<span class="gut-template-name">${this.escapeHtml(name)}</span>
<div class="gut-template-actions">
<button class="gut-btn gut-btn-secondary gut-btn-small" data-action="edit" data-template="${this.escapeHtml(name)}">Edit</button>
<button class="gut-btn gut-btn-secondary gut-btn-small" data-action="clone" data-template="${this.escapeHtml(name)}">Clone</button>
<button class="gut-btn gut-btn-danger gut-btn-small" data-action="delete" data-template="${this.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="${this.escapeHtml(this.config.TARGET_FORM_SELECTOR)}" placeholder="#upload_table">
</div>
<div class="gut-form-group">
<label class="gut-checkbox-label">
<input type="checkbox" id="setting-submit-keybinding" ${this.config.SUBMIT_KEYBINDING ? "checked" : ""}>
<span class="gut-checkbox-text">⚡ Enable Ctrl+Enter form submission</span>
</label>
</div>
<div class="gut-form-group">
<label for="setting-ignored-fields">Ignored Fields (one per line):</label>
<textarea id="setting-ignored-fields" rows="8" placeholder="linkgroup groupid apikey">${this.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>
<div class="gut-modal-actions">
<button class="gut-btn" id="close-manager">Close</button>
</div>
</div>
`;
document.body.appendChild(modal);
// Tab switching
modal.querySelectorAll(".gut-tab-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
const tabName = e.target.dataset.tab;
// Update tab buttons
modal
.querySelectorAll(".gut-tab-btn")
.forEach((b) => b.classList.remove("active"));
e.target.classList.add("active");
// Update tab content
modal
.querySelectorAll(".gut-tab-content")
.forEach((c) => c.classList.remove("active"));
modal.querySelector(`#${tabName}-tab`).classList.add("active");
});
});
// Settings handlers
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(
"⚠️ 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();
}
});
// Template actions
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);
});
// Close on Esc key for template manager
const handleEscKey = (e) => {
if (e.key === "Escape" && document.body.contains(modal)) {
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 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,
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 };
// Update the form fields
modal.querySelector("#setting-form-selector").value =
this.config.TARGET_FORM_SELECTOR;
modal.querySelector("#setting-submit-keybinding").checked =
this.config.SUBMIT_KEYBINDING;
modal.querySelector("#setting-ignored-fields").value =
this.config.IGNORED_FIELDS_BY_DEFAULT.join("\n");
this.showStatus(
"Settings reset to defaults! Reload the page for changes to take effect.",
);
}
// Delete all local configuration
deleteAllConfig() {
// Remove all localStorage items related to GGn Upload Templator
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");
// Reset instance variables
this.templates = {};
this.selectedTemplate = null;
this.hideUnselectedFields = true;
this.config = { ...DEFAULT_CONFIG };
// Update UI
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]
: undefined,
};
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;
// Pre-populate the template creator with existing data
this.showTemplateCreator(templateName, template);
}
// Refresh template manager modal content
refreshTemplateManager(modal) {
const templateList = modal.querySelector(".gut-template-list");
if (!templateList) return;
if (Object.keys(this.templates).length === 0) {
templateList.innerHTML = `
<div style="padding: 20px; text-align: center; color: #888;">
No templates found. Close this dialog and create a template first.
</div>
`;
return;
}
templateList.innerHTML = Object.keys(this.templates)
.map(
(name) => `
<div class="gut-template-item">
<span class="gut-template-name">${this.escapeHtml(name)}</span>
<div class="gut-template-actions">
<button class="gut-btn gut-btn-secondary gut-btn-small" data-action="edit" data-template="${this.escapeHtml(name)}">Edit</button>
<button class="gut-btn gut-btn-secondary gut-btn-small" data-action="clone" data-template="${this.escapeHtml(name)}">Clone</button>
<button class="gut-btn gut-btn-danger gut-btn-small" data-action="delete" data-template="${this.escapeHtml(name)}">Delete</button>
</div>
</div>
`,
)
.join("");
}
// Watch file inputs for changes
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", async (e) => {
if (
!this.selectedTemplate ||
this.selectedTemplate === "none" ||
!e.target.files[0]
)
return;
const file = e.target.files[0];
if (!file.name.toLowerCase().endsWith(".torrent")) return;
try {
const torrentData = await TorrentUtils.parseTorrentFile(file);
this.applyTemplate(this.selectedTemplate, torrentData.name);
} catch (error) {
console.error("Error processing torrent file:", error);
this.showStatus("Error processing torrent file", "error");
}
});
});
}
// Apply template to form
applyTemplate(templateName, torrentName) {
const template = this.templates[templateName];
if (!template) return;
const extracted = this.parseTemplate(
template.mask,
torrentName,
template.greedyMatching !== false,
);
let appliedCount = 0;
Object.entries(template.fieldMappings).forEach(
([fieldName, valueTemplate]) => {
const formPrefix = this.config.TARGET_FORM_SELECTOR
? `${this.config.TARGET_FORM_SELECTOR} `
: "";
// First check if this is a radio button field
const firstElement = document.querySelector(
`${formPrefix}[name="${fieldName}"]`,
);
if (firstElement && firstElement.type === "radio") {
// For radio buttons, find all radio buttons with the same name
const radioButtons = document.querySelectorAll(
`${formPrefix}input[name="${fieldName}"][type="radio"]`,
);
const newValue = this.interpolate(String(valueTemplate), extracted);
radioButtons.forEach((radio) => {
// Remove disabled attribute if present
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) {
// Remove disabled attribute if present
if (firstElement.hasAttribute("disabled")) {
firstElement.removeAttribute("disabled");
}
if (firstElement.type === "checkbox") {
// For checkboxes, valueTemplate is a boolean or string that needs interpolation
let newChecked;
if (typeof valueTemplate === "boolean") {
newChecked = valueTemplate;
} else {
const interpolated = this.interpolate(
String(valueTemplate),
extracted,
);
// Convert string to boolean - "true", "1", "yes", "on" are truthy
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 newValue = this.interpolate(
String(valueTemplate),
extracted,
);
if (newValue !== firstElement.value) {
firstElement.value = newValue;
firstElement.dispatchEvent(
new Event("input", { bubbles: true }),
);
firstElement.dispatchEvent(
new Event("change", { bubbles: true }),
);
appliedCount++;
}
}
}
},
);
if (appliedCount > 0) {
this.showStatus(
`Applied template "${templateName}" - ${appliedCount} fields updated`,
);
}
}
// 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);
}
}, 3000);
}
// Utility: Escape HTML
escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
}
// Initialize when DOM is ready
if (document.readyState === "loading") {
document.addEventListener(
"DOMContentLoaded",
() => new GGnUploadTemplator(),
);
} else {
new GGnUploadTemplator();
}
})();