Files
jellyfin-multilang/src/Jellyfin.Plugin.Multilang/Configuration/userRulesPage.html
T

1328 lines
58 KiB
HTML

<div id="ml-user" data-role="page" class="page type-interior pluginConfigurationPage">
<div class="content-primary ml-root">
<link rel="stylesheet" href="/Multilang/shared.css">
<h2>Metadata Language Rules</h2>
<form id="ml-user-form">
<fieldset class="ml-section">
<legend>Preferences</legend>
<div class="inputContainer ml-enable-row">
<label><input id="ml-enabled" type="checkbox"> Enable Multilang for this user</label>
</div>
<div class="inputContainer ml-sort-row">
<label class="inputLabel inputLabelUnfocused" for="ml-sort-locale">Sort locale:</label>
<select id="ml-sort-locale" class="emby-select ml-select"></select>
<div id="ml-sort-locale-auto" class="fieldDescription ml-muted"></div>
</div>
</fieldset>
<fieldset class="ml-section">
<legend>Classification into categories</legend>
<div id="ml-categories" class="ml-category-list"></div>
<button id="ml-add-category" is="emby-button" type="button" class="raised"><span>Add classification rule</span></button>
</fieldset>
<fieldset class="ml-section">
<legend>Translation of categories</legend>
<div class="inputContainer ml-translation-option">
<label><input id="ml-trust-collections" type="checkbox"> Trust TMDb collections metadata</label>
</div>
<div id="ml-action-source" class="ml-action-source"></div>
<div id="ml-action-matrix" class="ml-action-matrix"></div>
</fieldset>
<fieldset class="ml-section">
<legend>Import and export</legend>
<div class="ml-actions">
<button id="ml-user-export" is="emby-button" type="button" class="raised"><span>Export my settings</span></button>
<button id="ml-user-import-open" is="emby-button" type="button" class="raised"><span>Import settings...</span></button>
</div>
</fieldset>
<fieldset class="ml-section">
<legend>Debug</legend>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ml-debug-item-id">Jellyfin item ID</label>
<input id="ml-debug-item-id" type="text" class="emby-input ml-input" placeholder="32-character item ID">
</div>
<div class="ml-actions">
<button id="ml-debug-open" is="emby-button" type="button" class="raised"><span>Open debug view</span></button>
</div>
<div class="fieldDescription">Shows how your classification and translation rules resolve for this item.</div>
</fieldset>
<button id="ml-save-user" is="emby-button" type="submit" class="raised button-submit"><span>Save</span></button>
<button id="ml-reset-user" is="emby-button" type="button" class="raised"><span>Reload</span></button>
</form>
<div id="ml-user-status" class="ml-status"></div>
<div id="ml-user-import-modal" class="ml-modal" hidden>
<div class="ml-modal-panel">
<h2>Import user settings</h2>
<div class="inputContainer">
<input id="ml-user-import-file" type="file" accept=".zip,application/zip">
</div>
<div id="ml-user-import-choice" class="inputContainer" hidden>
<label class="inputLabel inputLabelUnfocused" for="ml-user-import-source">Import settings from user ID</label>
<select id="ml-user-import-source" class="emby-select ml-select"></select>
</div>
<div class="ml-actions">
<button id="ml-user-import-run" is="emby-button" type="button" class="raised button-submit"><span>Import</span></button>
<button id="ml-user-import-cancel" is="emby-button" type="button" class="raised"><span>Cancel</span></button>
</div>
</div>
</div>
<script src="/Multilang/shared.js"></script>
<script>
(function () {
var M = window.Multilang;
var page = document.getElementById("ml-user");
var form = document.getElementById("ml-user-form");
var status = document.getElementById("ml-user-status");
var state = { allowed: [], rules: null };
var fields = [
{ id: "title", label: "Title", lockedJ: true },
{ id: "overview", label: "Overview" },
{ id: "tagline", label: "Tagline" },
{ id: "poster", label: "Poster", lockedJ: true },
{ id: "logo", label: "Logo" },
{ id: "banner", label: "Banner" },
{ id: "thumb", label: "Thumb" },
{ id: "backdrop", label: "Backdrop" }
];
var criteriaFields = [
{ id: "original_language", label: "Original language" },
{ id: "spoken_languages", label: "Spoken languages" },
{ id: "audio_language", label: "Audio language" },
{ id: "origin_countries", label: "Origin countries" },
{ id: "production_countries", label: "Production countries" }
];
var relations = [
{ id: "is", label: "is", pluralLabel: "are" },
{ id: "is_not", label: "is not", pluralLabel: "are not" },
{ id: "contains", label: "contains", pluralLabel: "contain" },
{ id: "not_contains", label: "does not contain", pluralLabel: "do not contain" }
];
var ISO6391 = M.staticData.iso6391;
var ISO3166 = M.staticData.iso3166;
var LANG_REGION_COMBOS = M.staticData.langRegionCombos;
function $(id) {
return document.getElementById(id);
}
function make(tag, className, text) {
var el = document.createElement(tag);
if (className) el.className = className;
if (text != null) el.textContent = text;
return el;
}
function option(label, value, selected) {
var opt = document.createElement("option");
opt.textContent = label;
opt.value = value;
opt.selected = !!selected;
return opt;
}
function randomId() {
return "cat-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 8);
}
function defaultActionLists() {
var result = {};
fields.forEach(function (field) { result[field.id] = ["Jellyfin"]; });
return result;
}
function categories() {
state.rules.Categories = state.rules.Categories || [];
return state.rules.Categories;
}
function categoryId(category) {
return category.Id || category.id || "";
}
function categoryLabel(category, index) {
var label = String(category.Label || category.label || "").trim();
return label || categoryPlaceholder(index);
}
function categoryPlaceholder(index) {
var used = {};
categories().forEach(function (category) {
var label = String(category.Label || category.label || "").trim().toLowerCase();
if (label) used[label] = true;
});
var n = Math.max(1, index + 1);
while (used[("Category " + n).toLowerCase()]) n++;
return "Category " + n;
}
function newCategory(label, criteria) {
return {
Id: randomId(),
Label: label || "",
CriteriaText: criteria || "",
Requirements: [emptyRequirement()],
MatchAllConditions: true,
Scopes: ["M", "S", "C"],
FieldActionLists: defaultActionLists()
};
}
function emptyRequirement() {
return { Field: "", Fields: [], UseFieldOr: false, Relation: "", UseOr: false, Values: [] };
}
function normalizeActionValue(fieldId, value) {
value = String(value || "").trim();
if (!value) return "";
if (value.toLowerCase() === "j" || value.toLowerCase() === "jellyfin") return "Jellyfin";
if (value.toLowerCase() === "o" || value.toLowerCase() === "original") return "Original";
var lang = value.toLowerCase().indexOf("language:") === 0 ? value.slice(9) : value;
var canonical = state.allowed.find(function (allowed) { return allowed.toLowerCase() === lang.toLowerCase(); });
return canonical ? "Language:" + canonical : "";
}
function legacyActionToList(fieldId, legacy) {
if (!legacy || legacy.toLowerCase() === "fallback") {
return ["Jellyfin"];
}
return [legacy];
}
function normalizeActionList(fieldId, values) {
var field = fields.find(function (f) { return f.id === fieldId; });
var result = [];
(values || []).forEach(function (value) {
var normalized = normalizeActionValue(fieldId, value);
if (!normalized) return;
if (result.some(function (existing) { return existing.toLowerCase() === normalized.toLowerCase(); })) return;
result.push(normalized);
});
if (field && field.lockedJ && !result.some(function (value) { return value.toLowerCase() === "jellyfin"; })) {
result.push("Jellyfin");
}
return result.length || !(field && field.lockedJ) ? result : ["Jellyfin"];
}
function normalizeActionMap(source, legacy) {
if (legacy && looksLikeDefaultActionMap(source) && hasMeaningfulLegacyActions(legacy)) {
source = null;
}
var result = {};
fields.forEach(function (field) {
var values = source && (source[field.id] || source[field.label]);
if (!values && legacy) values = legacyActionToList(field.id, legacy[field.id] || legacy[field.label]);
result[field.id] = normalizeActionList(field.id, Array.isArray(values) ? values : values ? [values] : ["Jellyfin"]);
});
return result;
}
function looksLikeDefaultActionMap(source) {
if (!source) return true;
return fields.every(function (field) {
var values = source[field.id] || source[field.label];
return Array.isArray(values) && values.length === 1 && String(values[0]).toLowerCase() === "jellyfin";
});
}
function hasMeaningfulLegacyActions(legacy) {
return Object.keys(legacy || {}).some(function (key) {
var action = String(legacy[key] || "").toLowerCase();
return action && action !== "fallback" && action !== "jellyfin";
});
}
function normalizeRules() {
state.rules = state.rules || {};
state.rules.Categories = (state.rules.Categories || []).filter(function (category) {
var id = categoryId(category).toLowerCase();
var label = String(category.Label || category.label || "").trim().toLowerCase();
var criteria = String(category.CriteriaText || category.criteriaText || "").trim();
return !(id === "fallback" && label === "fallback" && criteria.length === 0);
}).map(function (category, index) {
category.Id = categoryId(category) || randomId();
category.Label = String(category.Label || category.label || "").trim();
category.CriteriaText = category.CriteriaText || category.criteriaText || "";
category.Requirements = normalizeRequirements(category.Requirements || category.requirements);
category.MatchAllConditions = category.MatchAllConditions !== false && category.matchAllConditions !== false;
category.Scopes = category.Scopes || category.scopes || ["M", "S", "C"];
category.FieldActionLists = normalizeActionMap(category.FieldActionLists || category.fieldActionLists, category.FieldActions || category.fieldActions);
return category;
});
state.rules.FallbackFieldActions = normalizeActionMap(state.rules.FallbackFieldActions || state.rules.fallbackFieldActions, null);
}
function normalizeRequirements(requirements) {
var normalized = (requirements || []).map(function (requirement) {
var fields = normalizeRequirementFields(requirement.Fields || requirement.fields, requirement.Field || requirement.field);
return {
Field: fields[0] || "",
Fields: fields,
UseFieldOr: requirement.UseFieldOr === true || requirement.useFieldOr === true,
Relation: String(requirement.Relation || requirement.relation || "").trim(),
UseOr: requirement.UseOr === true || requirement.useOr === true,
Values: orderedRequirementValues(fields[0] || "", requirement.Values || requirement.values || [])
};
});
return normalized.length ? normalized : [emptyRequirement()];
}
function completeRequirement(requirement) {
return !!(requirement && requirementFields(requirement).length && requirement.Relation && requirement.Values && requirement.Values.length);
}
function unique(values) {
var seen = {};
var out = [];
values.forEach(function (value) {
var key = String(value).toLowerCase();
if (seen[key]) return;
seen[key] = true;
out.push(value);
});
return out;
}
function criteriaField(id) {
return criteriaFields.find(function (item) { return item.id === id; });
}
function normalizeRequirementFields(values, legacy) {
var raw = [];
(values || []).forEach(function (value) {
value = String(value || "").trim();
if (value) raw.push(value);
});
legacy = String(legacy || "").trim();
if (legacy) raw.push(legacy);
var wanted = {};
raw.forEach(function (value) {
var id = value.toLowerCase();
if (criteriaField(id)) wanted[id] = true;
});
var result = [];
var kind = "";
criteriaFields.forEach(function (item) {
if (!wanted[item.id]) return;
var itemKind = fieldKind(item.id);
if (!kind) kind = itemKind;
if (itemKind === kind) result.push(item.id);
});
return result;
}
function requirementFields(requirement) {
return normalizeRequirementFields(requirement && (requirement.Fields || requirement.fields), requirement && (requirement.Field || requirement.field));
}
function criteriaLabel(id) {
var item = criteriaField(id);
return item ? item.label : id;
}
function renderSortLocales() {
var select = $("ml-sort-locale");
select.replaceChildren(option("Auto", "Auto", false));
state.allowed.forEach(function (lang) {
select.appendChild(option(lang, lang, false));
});
select.value = state.rules.SortLocale || "Auto";
renderSortLocaleAutoText();
}
function detectedLocale() {
return (navigator.languages && navigator.languages[0]) || navigator.language || "";
}
function renderSortLocaleAutoText() {
var el = $("ml-sort-locale-auto");
if (!el) return;
var selected = $("ml-sort-locale").value || "Auto";
var detected = detectedLocale();
el.textContent = selected === "Auto"
? (detected ? "Auto detected: " + detected : "Auto detected: none")
: "";
}
function listButton(label, action) {
var button = make("button", "raised ml-list-action");
button.type = "button";
button.setAttribute("is", "emby-button");
button.appendChild(make("span", "", label));
button.addEventListener("click", action);
return button;
}
function syncCategoryFromRow(row, category) {
category.Label = row.querySelector("[data-category-label]").value;
category.MatchAllConditions = row.querySelector("[data-category-match-all]").checked;
category.CriteriaText = categoryCriteriaText(category);
category.Scopes = Array.prototype.slice.call(row.querySelectorAll("[data-category-scope]:checked"))
.map(function (input) { return input.value; });
}
function syncCategoriesFromDom() {
Array.prototype.slice.call(document.querySelectorAll(".ml-category-card[data-category-id]")).forEach(function (row) {
var category = categories().find(function (item) { return categoryId(item) === row.dataset.categoryId; });
if (category) syncCategoryFromRow(row, category);
});
}
function addSelectOptions(select, items, selectedValue) {
select.replaceChildren();
items.forEach(function (item) {
select.appendChild(option(item.label, item.id, item.id === selectedValue));
});
}
function fieldKind(fieldId) {
return fieldId && fieldId.indexOf("countries") >= 0 ? "country" : "language";
}
function baseLang(lang) {
return String(lang || "").split("-")[0].toLowerCase();
}
function regionFromLang(lang) {
var parts = String(lang || "").split("-");
return parts.length > 1 ? parts[1].toUpperCase() : "";
}
function likelyValues(kind) {
if (kind === "country") {
return unique(state.allowed.map(regionFromLang).filter(Boolean)).sort(function (a, b) { return a.localeCompare(b); });
}
return unique(state.allowed.concat(state.allowed.map(baseLang)).filter(Boolean)).sort(function (a, b) { return a.localeCompare(b); });
}
function allValues(kind) {
return (kind === "country"
? ISO3166.slice()
: unique(ISO6391.concat(LANG_REGION_COMBOS))).sort(function (a, b) { return a.localeCompare(b); });
}
function valueOrder(kind) {
var likely = likelyValues(kind);
return likely.concat(allValues(kind).filter(function (value) {
return !likely.some(function (top) { return top.toLowerCase() === value.toLowerCase(); });
}));
}
function orderValues(values, ordered) {
var selected = {};
unique((values || []).map(function (value) { return String(value || "").trim(); }).filter(Boolean)).forEach(function (value) {
selected[value.toLowerCase()] = value;
});
var result = [];
ordered.forEach(function (value) {
var key = value.toLowerCase();
if (!selected[key]) return;
result.push(selected[key]);
delete selected[key];
});
Object.keys(selected).sort().forEach(function (key) { result.push(selected[key]); });
return result;
}
function orderedRequirementValues(fieldId, values) {
if (!fieldId) return unique((values || []).map(function (value) { return String(value || "").trim(); }).filter(Boolean));
return orderValues(values, valueOrder(fieldKind(fieldId)));
}
function fieldSummary(requirement) {
var selected = requirementFields(requirement);
if (!selected.length) return "Choose item property";
var joiner = requirement.UseFieldOr ? " or " : " and ";
if (selected.length === 1) return criteriaLabel(selected[0]);
var suffix = fieldKind(selected[0]) === "country" ? " countries" : " languages";
return selected.map(shortCriteriaLabel).join(joiner) + suffix;
}
function shortCriteriaLabel(id) {
return {
original_language: "Orig",
spoken_languages: "Spok",
audio_language: "Aud",
origin_countries: "Orig",
production_countries: "Prod"
}[id] || criteriaLabel(id);
}
function valueSummary(requirement) {
var values = orderedRequirementValues((requirementFields(requirement)[0] || ""), requirement.Values || []);
if (!values.length) return "Choose values";
var joiner = requirement.UseOr ? " or " : " and ";
return values.join(joiner);
}
function requirementText(requirement) {
if (!completeRequirement(requirement)) return "";
var values = orderedRequirementValues(requirementFields(requirement)[0] || "", requirement.Values || []);
var right = values.length === 1 ? values[0] : "(" + values.join(requirement.UseOr ? " or " : " and ") + ")";
var fields = requirementFields(requirement);
var left = fields.length === 1 ? fields[0] : "(" + fields.join(requirement.UseFieldOr ? " or " : " and ") + ")";
return left + " " + requirement.Relation + " " + right;
}
function categoryCriteriaText(category) {
var joiner = category.MatchAllConditions === false ? " or " : " and ";
return (category.Requirements || [])
.filter(completeRequirement)
.map(requirementText)
.join(joiner);
}
function makeRequirementRow(category, requirement, index) {
var row = make("div", "ml-requirement-row");
var selectedFields = requirementFields(requirement);
var value = null;
var field = makeFieldDropdown(requirement, function (previousKind, nextKind) {
selectedFields = requirementFields(requirement);
relation.disabled = !selectedFields.length;
renderRelationOptions(relation, requirement);
if (!selectedFields.length || (previousKind && nextKind && previousKind !== nextKind)) {
requirement.Relation = "";
requirement.Values = [];
requirement.UseOr = false;
}
var nextValue = makeValueDropdown(requirement, !!(selectedFields.length && requirement.Relation));
value.replaceWith(nextValue);
value = nextValue;
});
var relation = make("select", "emby-select ml-select ml-relation-select");
relation.disabled = !selectedFields.length;
renderRelationOptions(relation, requirement);
value = makeValueDropdown(requirement, !!(selectedFields.length && requirement.Relation));
var del = listButton("X", function () {
if ((category.Requirements || []).length <= 1) return;
category.Requirements.splice(index, 1);
renderAll();
});
del.classList.add("ml-danger");
del.title = "Delete condition";
del.disabled = (category.Requirements || []).length <= 1;
relation.addEventListener("change", function () {
requirement.Relation = relation.value;
renderAll();
});
row.append(field, relation, value, del);
return row;
}
function renderRelationOptions(select, requirement) {
var current = requirement.Relation || "";
var fields = requirementFields(requirement);
var multiple = fields.length > 1;
select.replaceChildren(option("Choose relation", "", !current));
if (fields.length) {
relations.forEach(function (item) {
select.appendChild(option(multiple ? item.pluralLabel : item.label, item.id, item.id === current));
});
}
select.value = current;
}
function makeFieldDropdown(requirement, onChange) {
var root = make("div", "ml-value-dropdown ml-field-dropdown");
var button = make("button", "raised ml-value-button", fieldSummary(requirement));
button.type = "button";
var panel = make("div", "ml-value-panel");
panel.hidden = true;
var orLabel = make("label", "ml-value-option");
var orBox = make("input", "");
orBox.type = "checkbox";
orBox.checked = requirement.UseFieldOr === true;
orBox.addEventListener("change", function () {
requirement.UseFieldOr = orBox.checked;
button.textContent = fieldSummary(requirement);
});
orLabel.append(orBox, make("span", "", "Use \"or\""));
panel.appendChild(orLabel);
panel.appendChild(make("div", "ml-value-separator"));
var selected = requirementFields(requirement);
var selectedKind = selected.length ? fieldKind(selected[0]) : "";
criteriaFields.forEach(function (item) {
var label = make("label", "ml-value-option");
var input = make("input", "");
var checked = selected.indexOf(item.id) >= 0;
input.type = "checkbox";
input.value = item.id;
input.checked = checked;
input.disabled = !!(selectedKind && fieldKind(item.id) !== selectedKind && !checked);
input.addEventListener("change", function () {
var currentFields = requirementFields(requirement);
var previousKind = currentFields.length ? fieldKind(currentFields[0]) : "";
var fields = requirementFields(requirement);
if (input.checked) fields.push(item.id);
else fields = fields.filter(function (value) { return value !== item.id; });
fields = normalizeRequirementFields(fields, "");
var nextKind = fields.length ? fieldKind(fields[0]) : "";
requirement.Fields = fields;
requirement.Field = fields[0] || "";
button.textContent = fieldSummary(requirement);
refreshFieldPanelOptions(panel, requirement);
if (onChange) onChange(previousKind, nextKind);
});
label.append(input, make("span", "", item.label));
panel.appendChild(label);
});
button.addEventListener("click", function (event) {
event.stopPropagation();
closeValuePanels(panel);
panel.hidden = !panel.hidden;
});
panel.addEventListener("click", function (event) { event.stopPropagation(); });
root.append(button, panel);
return root;
}
function refreshFieldPanelOptions(panel, requirement) {
var selected = requirementFields(requirement);
var selectedKind = selected.length ? fieldKind(selected[0]) : "";
Array.prototype.slice.call(panel.querySelectorAll("input[value]")).forEach(function (input) {
if (!criteriaField(input.value)) return;
var checked = selected.indexOf(input.value) >= 0;
input.checked = checked;
input.disabled = !!(selectedKind && fieldKind(input.value) !== selectedKind && !checked);
});
}
function makeValueDropdown(requirement, enabled) {
var root = make("div", "ml-value-dropdown");
var button = make("button", "raised ml-value-button", valueSummary(requirement));
button.type = "button";
button.disabled = !enabled;
var panel = make("div", "ml-value-panel");
panel.hidden = true;
if (enabled) {
var orLabel = make("label", "ml-value-option");
var orBox = make("input", "");
orBox.type = "checkbox";
orBox.checked = requirement.UseOr === true;
orBox.addEventListener("change", function () {
requirement.UseOr = orBox.checked;
button.textContent = valueSummary(requirement);
});
orLabel.append(orBox, make("span", "", "use \"or\""));
panel.appendChild(orLabel);
panel.appendChild(make("div", "ml-value-separator"));
var likely = likelyValues(fieldKind(requirementFields(requirement)[0]));
appendValueOptions(panel, requirement, button, likely);
panel.appendChild(make("div", "ml-value-separator"));
appendValueOptions(panel, requirement, button, allValues(fieldKind(requirementFields(requirement)[0])).filter(function (value) {
return !likely.some(function (top) { return top.toLowerCase() === value.toLowerCase(); });
}));
}
button.addEventListener("click", function (event) {
if (button.disabled) return;
event.stopPropagation();
closeValuePanels(panel);
panel.hidden = !panel.hidden;
});
panel.addEventListener("click", function (event) { event.stopPropagation(); });
root.append(button, panel);
return root;
}
function closeValuePanels(except) {
Array.prototype.slice.call(document.querySelectorAll(".ml-value-panel")).forEach(function (panel) {
if (panel !== except) panel.hidden = true;
});
}
function appendValueOptions(panel, requirement, button, values) {
values.forEach(function (item) {
var label = make("label", "ml-value-option");
var input = make("input", "");
input.type = "checkbox";
input.value = item;
input.checked = (requirement.Values || []).some(function (value) { return value.toLowerCase() === item.toLowerCase(); });
input.addEventListener("change", function () {
var values = requirement.Values || [];
if (input.checked) values.push(item);
else values = values.filter(function (value) { return value.toLowerCase() !== item.toLowerCase(); });
requirement.Values = orderedRequirementValues(requirementFields(requirement)[0] || "", values);
button.textContent = valueSummary(requirement);
});
label.append(input, make("span", "", item));
panel.appendChild(label);
});
}
function categoryScope(label, value, category) {
var wrapper = make("label", "ml-inline-check");
var input = make("input", "");
input.type = "checkbox";
input.value = value;
input.dataset.categoryScope = "1";
input.checked = (category.Scopes || []).indexOf(value) >= 0;
wrapper.append(input, make("span", "", label));
return wrapper;
}
function cloneRequirements(requirements) {
return normalizeRequirements(requirements).map(function (requirement) {
return {
Field: requirement.Field,
Fields: (requirement.Fields || []).slice(),
UseFieldOr: requirement.UseFieldOr === true,
Relation: requirement.Relation,
UseOr: requirement.UseOr === true,
Values: (requirement.Values || []).slice()
};
});
}
function copyCategory(category) {
return {
Id: randomId(),
Label: "",
CriteriaText: categoryCriteriaText(category),
Requirements: cloneRequirements(category.Requirements),
MatchAllConditions: category.MatchAllConditions !== false,
Scopes: (category.Scopes || ["M", "S", "C"]).slice(),
FieldActionLists: defaultActionLists()
};
}
function renderCategories() {
var root = $("ml-categories");
root.replaceChildren();
categories().forEach(function (category, index) {
var row = make("div", "ml-classification-rule");
row.dataset.categoryId = categoryId(category);
var main = make("div", "ml-category-card ml-classification-main");
var top = make("div", "ml-category-top");
var label = make("input", "emby-input ml-input");
label.type = "text";
label.placeholder = categoryPlaceholder(index);
label.value = String(category.Label || "").trim();
label.dataset.categoryLabel = "1";
var controls = make("div", "ml-classification-controls");
controls.appendChild(listButton("Up", function () {
syncCategoriesFromDom();
if (index <= 0) return;
var cats = categories();
var temp = cats[index - 1];
cats[index - 1] = cats[index];
cats[index] = temp;
renderAll();
}));
var deleteCategory = listButton("Delete", function () {
syncCategoriesFromDom();
categories().splice(index, 1);
renderAll();
});
deleteCategory.classList.add("ml-danger");
controls.appendChild(deleteCategory);
controls.appendChild(listButton("Down", function () {
syncCategoriesFromDom();
var cats = categories();
if (index >= cats.length - 1) return;
var temp = cats[index + 1];
cats[index + 1] = cats[index];
cats[index] = temp;
renderAll();
}));
var scopes = make("div", "ml-category-scopes");
scopes.append(categoryScope("Movies", "M", category), categoryScope("Shows", "S", category), categoryScope("Collections", "C", category));
top.append(label, scopes);
var requirements = make("div", "ml-requirements");
category.Requirements = normalizeRequirements(category.Requirements);
category.Requirements.forEach(function (requirement, requirementIndex) {
requirements.appendChild(makeRequirementRow(category, requirement, requirementIndex));
});
var addRequirement = listButton("Add condition", function () {
syncCategoryFromRow(row, category);
category.Requirements.push(emptyRequirement());
renderAll();
});
var conditionActions = make("div", "ml-category-condition-actions");
var leftConditionActions = make("div", "ml-category-condition-left");
var matchAll = make("label", "ml-inline-check ml-match-all-check");
var matchAllInput = make("input", "");
matchAllInput.type = "checkbox";
matchAllInput.dataset.categoryMatchAll = "1";
matchAllInput.checked = category.MatchAllConditions !== false;
matchAll.append(matchAllInput, make("span", "", "Match all conditions"));
leftConditionActions.append(addRequirement, matchAll);
var copyRule = listButton("Copy rule", function () {
syncCategoriesFromDom();
updateActionStateFromBoxes();
categories().splice(index + 1, 0, copyCategory(category));
renderAll();
});
conditionActions.append(leftConditionActions, copyRule);
main.append(top, requirements, conditionActions);
row.append(main, controls);
row.addEventListener("input", function () {
syncCategoryFromRow(row, category);
renderActionMatrix();
});
row.addEventListener("change", function () {
syncCategoryFromRow(row, category);
renderActionMatrix();
});
root.appendChild(row);
});
}
function actionSourceValues() {
return ["Jellyfin", "Original"].concat(state.allowed.map(function (lang) { return "Language:" + lang; }));
}
function tokenLabel(value, source) {
if (source && value === "Jellyfin") return "Jellyfin default";
if (source && value === "Original") return "Original language";
if (value === "__all__") return "X";
if (value === "Jellyfin") return "J";
if (value === "Original") return "O";
if (value.indexOf("Language:") === 0) return value.slice(9);
return value;
}
function makeActionToken(value, locked, source) {
var token = make("span", "ml-action-token", tokenLabel(value, source));
token.title = value;
token.draggable = false;
token.dataset.value = value;
if (source) token.dataset.actionSource = "1";
if (locked) token.dataset.locked = "1";
if (source) {
token.classList.add("ml-action-token-source");
} else if (locked) {
token.classList.add("ml-action-token-locked");
}
return token;
}
function renderActionSource() {
var root = $("ml-action-source");
root.replaceChildren();
actionSourceValues().forEach(function (value) {
root.appendChild(makeActionToken(value, false, true));
});
}
function actionRows() {
var seen = {};
var rows = [];
categories().forEach(function (category, index) {
var label = categoryLabel(category, index);
if (seen[label]) return;
seen[label] = true;
rows.push({ id: categoryId(category), label: label, actions: category.FieldActionLists, fallback: false });
});
rows.push({ id: "__fallback__", label: "Fallback", actions: state.rules.FallbackFieldActions, fallback: true });
return rows;
}
function renderActionMatrix() {
renderActionSource();
var root = $("ml-action-matrix");
root.replaceChildren();
actionRows().forEach(function (row) {
var line = make("div", "ml-action-category-row");
if (row.fallback) line.classList.add("ml-action-category-row-fallback");
var nameCell = make("div", "ml-action-category-name");
nameCell.appendChild(make("div", "", row.label));
var allLabel = make("label", "ml-action-field ml-action-all-field");
allLabel.appendChild(make("span", "ml-action-field-label", "All"));
var allBox = make("div", "ml-action-box ml-action-all-box");
allBox.dataset.actionBox = row.id + "|__all__";
allBox.dataset.categoryId = row.id;
allBox.dataset.fieldId = "__all__";
allBox.appendChild(makeActionToken("__all__", false, false));
allLabel.appendChild(allBox);
nameCell.appendChild(allLabel);
line.appendChild(nameCell);
var fieldsRoot = make("div", "ml-action-fields");
fields.forEach(function (field) {
var group = make("label", "ml-action-field");
group.appendChild(make("span", "ml-action-field-label", field.label));
var box = make("div", "ml-action-box");
box.dataset.actionBox = row.id + "|" + field.id;
box.dataset.categoryId = row.id;
box.dataset.fieldId = field.id;
normalizeActionList(field.id, row.actions[field.id]).forEach(function (value) {
box.appendChild(makeActionToken(value, field.lockedJ && value === "Jellyfin", false));
});
group.appendChild(box);
fieldsRoot.appendChild(group);
});
line.appendChild(fieldsRoot);
root.appendChild(line);
});
}
function dropIndex(box, clientX, draggedValue) {
var tokens = Array.prototype.slice.call(box.querySelectorAll(".ml-action-token"));
for (var i = 0; i < tokens.length; i++) {
var token = tokens[i];
if (token === actionPlaceholder) continue;
if (token.dataset.value === draggedValue) continue;
var rect = token.getBoundingClientRect();
if (clientX < rect.left + rect.width / 2) return i;
}
return tokens.length;
}
var actionDrag = null;
var actionGhost = null;
var actionPlaceholder = make("span", "ml-action-token ml-action-placeholder", "");
function actionBoxFromPoint(x, y) {
var el = document.elementFromPoint(x, y);
return el && el.closest ? el.closest(".ml-action-box") : null;
}
function hasActionToken(box, value, except) {
return Array.prototype.slice.call(box.querySelectorAll(".ml-action-token")).some(function (token) {
return token !== except && token !== actionPlaceholder && token.dataset.value && token.dataset.value.toLowerCase() === value.toLowerCase();
});
}
function actionFieldLocksJ(fieldId) {
var field = fields.find(function (f) { return f.id === fieldId; });
return !!(field && field.lockedJ);
}
function cleanupActionDrag() {
actionPlaceholder.remove();
if (actionGhost) actionGhost.remove();
actionGhost = null;
actionDrag = null;
Array.prototype.slice.call(document.querySelectorAll(".ml-action-box.ml-over")).forEach(function (box) {
box.classList.remove("ml-over");
});
}
function restoreActionDrag() {
if (!actionDrag || !actionDrag.sourceBox || !actionDrag.sourceToken) return;
var ref = Array.prototype.slice.call(actionDrag.sourceBox.children)[actionDrag.sourceIndex] || null;
actionDrag.sourceBox.insertBefore(actionDrag.sourceToken, ref);
}
function actionTargetTokenFromPoint(box, x, y) {
var els = document.elementsFromPoint(x, y);
for (var i = 0; i < els.length; i++) {
var token = els[i] && els[i].closest ? els[i].closest(".ml-action-token") : null;
if (!token) continue;
if (token === actionPlaceholder) continue;
if (token === actionDrag.sourceToken) continue;
if (token.dataset.actionSource === "1") continue;
if (token.parentElement !== box) continue;
return token;
}
return null;
}
function positionActionPlaceholder(box, x, y) {
if (!actionDrag) return;
if (box.dataset.fieldId === "__all__") {
if (actionDrag.value === "__all__") {
actionPlaceholder.remove();
return;
}
actionPlaceholder.textContent = tokenLabel(actionDrag.value, false);
actionPlaceholder.dataset.value = actionDrag.value;
var xToken = Array.prototype.slice.call(box.querySelectorAll(".ml-action-token")).find(function (token) {
return token.dataset.value === "__all__";
});
if (!xToken) {
box.appendChild(actionPlaceholder);
return;
}
var xRect = xToken.getBoundingClientRect();
box.insertBefore(actionPlaceholder, x < xRect.left + xRect.width / 2 ? xToken : xToken.nextSibling);
return;
}
var value = normalizeActionValue(box.dataset.fieldId, actionDrag.value);
if (!value) {
actionPlaceholder.remove();
return;
}
if (actionDrag.locked && actionDrag.sourceBox !== box) {
actionPlaceholder.remove();
return;
}
if (hasActionToken(box, value, actionDrag.sourceToken)) {
actionPlaceholder.remove();
return;
}
actionPlaceholder.textContent = tokenLabel(value, false);
actionPlaceholder.dataset.value = value;
var target = actionTargetTokenFromPoint(box, x, y);
if (!target) {
if (actionPlaceholder.parentElement !== box) box.appendChild(actionPlaceholder);
return;
}
var rect = target.getBoundingClientRect();
var before = x < rect.left + rect.width / 2;
box.insertBefore(actionPlaceholder, before ? target : target.nextSibling);
}
function beginActionDrag(event) {
var token = event.target.closest && event.target.closest(".ml-action-token");
if (!token || token === actionPlaceholder || !token.dataset.value) return;
var sourceBox = token.closest("[data-action-box]");
var fromSource = token.dataset.actionSource === "1";
if (!sourceBox && !fromSource) return;
event.preventDefault();
event.stopPropagation();
var rect = token.getBoundingClientRect();
actionDrag = {
pointerId: event.pointerId,
value: token.dataset.value,
locked: token.dataset.locked === "1",
sourceToken: fromSource ? null : token,
sourceBox: sourceBox,
sourceIndex: sourceBox ? Array.prototype.indexOf.call(sourceBox.children, token) : -1,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top
};
if (sourceBox) {
sourceBox.insertBefore(actionPlaceholder, token);
token.remove();
}
actionGhost = token.cloneNode(true);
actionGhost.classList.add("ml-action-drag-ghost");
actionGhost.style.position = "fixed";
actionGhost.style.margin = "0";
actionGhost.style.zIndex = "99999";
document.body.appendChild(actionGhost);
moveActionDrag(event);
}
function moveActionDrag(event) {
if (!actionDrag || event.pointerId !== actionDrag.pointerId) return;
if (actionGhost) {
actionGhost.style.left = event.clientX - actionDrag.offsetX + "px";
actionGhost.style.top = event.clientY - actionDrag.offsetY + "px";
}
var box = actionBoxFromPoint(event.clientX, event.clientY);
Array.prototype.slice.call(document.querySelectorAll(".ml-action-box.ml-over")).forEach(function (el) {
el.classList.toggle("ml-over", el === box);
});
if (box) positionActionPlaceholder(box, event.clientX, event.clientY);
else actionPlaceholder.remove();
}
function endActionDrag(event) {
if (!actionDrag || event.pointerId !== actionDrag.pointerId) return;
var box = actionBoxFromPoint(event.clientX, event.clientY);
if (actionDrag.value === "__all__") {
if (!box || box !== actionDrag.sourceBox) clearCategoryActions(actionDrag.sourceBox.dataset.categoryId);
restoreActionDrag();
cleanupActionDrag();
updateActionStateFromBoxes();
renderActionMatrix();
return;
}
if (box && box.dataset.fieldId === "__all__" && actionPlaceholder.parentElement === box) {
applyAllAction(box, actionDrag.value, actionPlaceholder.nextElementSibling && actionPlaceholder.nextElementSibling.dataset.value === "__all__");
if (actionDrag.sourceToken) restoreActionDrag();
cleanupActionDrag();
updateActionStateFromBoxes();
renderActionMatrix();
return;
}
if (box && actionPlaceholder.parentElement === box) {
var value = normalizeActionValue(box.dataset.fieldId, actionDrag.value);
var locked = actionFieldLocksJ(box.dataset.fieldId) && value === "Jellyfin";
var token = actionDrag.sourceToken || makeActionToken(value, locked, false);
box.insertBefore(token, actionPlaceholder);
cleanupActionDrag();
updateActionStateFromBoxes();
return;
}
if (box && actionDrag.sourceToken) restoreActionDrag();
else if (actionDrag.sourceToken && actionDrag.locked) restoreActionDrag();
cleanupActionDrag();
updateActionStateFromBoxes();
}
function readBox(box) {
if (box.dataset.fieldId === "__all__") return [];
return normalizeActionList(box.dataset.fieldId, Array.prototype.slice.call(box.querySelectorAll(".ml-action-token")).map(function (token) {
return token.dataset.value;
}));
}
function boxesForCategory(categoryId) {
return Array.prototype.slice.call(document.querySelectorAll('[data-action-box]')).filter(function (box) {
return box.dataset.categoryId === categoryId && box.dataset.fieldId !== "__all__";
});
}
function clearCategoryActions(categoryId) {
boxesForCategory(categoryId).forEach(function (box) {
box.replaceChildren();
if (actionFieldLocksJ(box.dataset.fieldId)) box.appendChild(makeActionToken("Jellyfin", true, false));
});
}
function applyAllAction(allBox, rawValue, prepend) {
var categoryId = allBox.dataset.categoryId;
boxesForCategory(categoryId).forEach(function (box) {
var value = normalizeActionValue(box.dataset.fieldId, rawValue);
if (!value) return;
var existing = Array.prototype.slice.call(box.querySelectorAll(".ml-action-token")).filter(function (token) {
return token.dataset.value && token.dataset.value.toLowerCase() !== value.toLowerCase();
});
if (rawValue === "Original") existing = existing.filter(function (token) {
return token.dataset.value !== "Jellyfin";
});
var token = makeActionToken(value, actionFieldLocksJ(box.dataset.fieldId) && value === "Jellyfin", false);
box.replaceChildren();
if (prepend) box.appendChild(token);
existing.forEach(function (item) { box.appendChild(item); });
if (!prepend) box.appendChild(token);
});
}
function updateActionStateFromBoxes() {
Array.prototype.slice.call(document.querySelectorAll("[data-action-box]")).forEach(function (box) {
var categoryId = box.dataset.categoryId;
if (box.dataset.fieldId === "__all__") return;
var fieldId = box.dataset.fieldId;
var category = categories().find(function (category) { return categoryId === category.Id; });
var target = categoryId === "__fallback__"
? state.rules.FallbackFieldActions
: (category || {}).FieldActionLists;
if (!target) return;
target[fieldId] = readBox(box);
if (category) {
categories().forEach(function (other, index) {
if (other === category) return;
if (categoryLabel(other, index) !== categoryLabel(category, categories().indexOf(category))) return;
other.FieldActionLists = target;
});
}
});
}
function renderAll() {
normalizeRules();
$("ml-enabled").checked = state.rules.Enabled === true;
$("ml-trust-collections").checked = state.rules.TrustTmdbCollections !== false;
renderSortLocales();
renderCategories();
renderActionMatrix();
}
function collect() {
syncCategoriesFromDom();
updateActionStateFromBoxes();
categories().forEach(function (category) {
var complete = (category.Requirements || []).filter(completeRequirement);
category.Requirements = complete.length ? complete : [emptyRequirement()];
category.CriteriaText = complete.map(requirementText).join(" and ");
});
return {
Enabled: $("ml-enabled").checked,
SortLocale: $("ml-sort-locale").value || "Auto",
TrustTmdbCollections: $("ml-trust-collections").checked,
FallbackFieldActions: state.rules.FallbackFieldActions,
Categories: categories().map(function (category, index) {
var complete = (category.Requirements || []).filter(completeRequirement);
return {
Id: category.Id,
Label: categoryLabel(category, index),
CriteriaText: complete.map(requirementText).join(category.MatchAllConditions === false ? " or " : " and "),
Requirements: complete,
MatchAllConditions: category.MatchAllConditions !== false,
Scopes: category.Scopes,
FieldActionLists: category.FieldActionLists,
FieldActions: {}
};
})
};
}
async function load() {
M.setStatus(status, "Loading...");
try {
var all = await Promise.all([
M.get("/Multilang/AllowedLanguages"),
M.get("/Multilang/UserRules/self")
]);
state.allowed = (all[0] && all[0].Languages) || [];
state.rules = all[1] || {};
renderAll();
M.setStatus(status, "");
} catch (err) {
M.setStatus(status, String(err), true);
}
}
async function downloadUserExport() {
M.setStatus(status, "Creating export...");
var response = await fetch(M.basePath() + "/Multilang/UserRules/self/export", {
cache: "no-store",
headers: M.authHeaders()
});
if (!response.ok) throw new Error("HTTP " + response.status);
var blob = await response.blob();
M.downloadBlob(blob, "multilang-user-settings.zip");
M.setStatus(status, "Export created.");
}
function userImportModal(show) {
$("ml-user-import-modal").hidden = !show;
if (!show) {
$("ml-user-import-choice").hidden = true;
$("ml-user-import-source").replaceChildren();
}
}
function userImportFormData(sourceUserId) {
var file = $("ml-user-import-file").files[0];
if (!file) throw new Error("Choose an export zip first.");
var formData = new FormData();
formData.append("file", file);
if (sourceUserId) formData.append("sourceUserId", sourceUserId);
return formData;
}
async function inspectUserImport() {
var response = await fetch(M.basePath() + "/Multilang/UserRules/self/import/inspect", {
method: "POST",
cache: "no-store",
headers: M.authHeaders(),
body: userImportFormData("")
});
if (!response.ok) throw new Error("HTTP " + response.status);
return await response.json();
}
async function postUserImport(sourceUserId) {
var response = await fetch(M.basePath() + "/Multilang/UserRules/self/import", {
method: "POST",
cache: "no-store",
headers: M.authHeaders(),
body: userImportFormData(sourceUserId)
});
if (!response.ok) throw new Error("HTTP " + response.status);
return await response.json();
}
async function runUserImport() {
M.setStatus(status, "Inspecting import...");
var choice = $("ml-user-import-choice");
if (!choice.hidden) {
var selected = $("ml-user-import-source").value;
if (!selected) throw new Error("Choose a source user ID.");
var chosen = await postUserImport(selected);
if ((chosen.UserSettingsImported ?? chosen.userSettingsImported ?? 0) <= 0) throw new Error("No user settings were imported.");
userImportModal(false);
await load();
M.setStatus(status, "Imported user settings.");
return;
}
var inspect = await inspectUserImport();
var count = inspect.UserSettingsCount ?? inspect.userSettingsCount ?? 0;
var containsCurrent = inspect.ContainsCurrentUser ?? inspect.containsCurrentUser;
var ids = inspect.UserIds || inspect.userIds || [];
if (count <= 0) {
M.setStatus(status, "No user settings were found in this export.", true);
return;
}
if (containsCurrent) {
var result = await postUserImport("");
if ((result.UserSettingsImported ?? result.userSettingsImported ?? 0) <= 0) throw new Error("No user settings were imported.");
userImportModal(false);
await load();
M.setStatus(status, "Imported user settings.");
return;
}
var select = $("ml-user-import-source");
select.replaceChildren();
ids.forEach(function (id) { select.appendChild(option(id, id, false)); });
choice.hidden = false;
M.setStatus(status, "No matching user ID was found. Choose which exported user settings to import.", true);
}
function bindEvents() {
if (page.dataset.bound === "1") return;
page.dataset.bound = "1";
form.addEventListener("submit", async function (event) {
event.preventDefault();
M.setStatus(status, "Saving...");
try {
state.rules = await M.post("/Multilang/UserRules/self", collect());
renderAll();
M.setStatus(status, "Saved.");
} catch (err) {
M.setStatus(status, String(err), true);
}
});
$("ml-reset-user").addEventListener("click", load);
$("ml-sort-locale").addEventListener("change", function () {
state.rules.SortLocale = $("ml-sort-locale").value || "Auto";
renderSortLocaleAutoText();
});
$("ml-debug-open").addEventListener("click", function () {
var itemId = $("ml-debug-item-id").value.trim();
if (!itemId) {
M.setStatus(status, "Enter a Jellyfin item ID.", true);
return;
}
window.open(M.debugUrl(itemId), "_blank", "noopener");
});
$("ml-user-export").addEventListener("click", function () {
downloadUserExport().catch(function (err) { M.setStatus(status, String(err), true); });
});
$("ml-user-import-open").addEventListener("click", function () {
$("ml-user-import-file").value = "";
userImportModal(true);
});
$("ml-user-import-cancel").addEventListener("click", function () { userImportModal(false); });
$("ml-user-import-run").addEventListener("click", function () {
runUserImport().catch(function (err) { M.setStatus(status, String(err), true); });
});
document.addEventListener("click", function () { closeValuePanels(null); });
$("ml-add-category").addEventListener("click", function () {
syncCategoriesFromDom();
updateActionStateFromBoxes();
categories().push(newCategory("", ""));
renderAll();
});
document.addEventListener("dragstart", function (event) {
if (event.target.closest && event.target.closest(".ml-action-token")) event.preventDefault();
}, true);
page.addEventListener("pointerdown", beginActionDrag, true);
document.addEventListener("pointermove", moveActionDrag, true);
document.addEventListener("pointerup", endActionDrag, true);
document.addEventListener("pointercancel", function (event) {
if (!actionDrag || event.pointerId !== actionDrag.pointerId) return;
restoreActionDrag();
cleanupActionDrag();
updateActionStateFromBoxes();
}, true);
document.addEventListener("viewshow", function (event) {
if (event.target && event.target.id === "ml-user") load();
});
}
bindEvents();
load();
})();
</script>
</div>
</div>