1328 lines
58 KiB
HTML
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>
|