The Anime Settings page (src/server/web/static/js/pages/anime-settings.js)
calls GET /api/nfo/{key}/content from its 'View NFO XML' button, but that
endpoint was removed during the NFO refactor (commits 21af502, a8e5487).
The frontend was never updated, so every click on the button 404'd.
Fix:
- Add NfoContentResponse model (key, folder, content, file_size,
last_modified) to src/server/models/nfo.py.
- Add GET /api/nfo/{key}/content handler to src/server/api/nfo.py that
reads <anime_directory>/<folder>/tvshow.nfo and returns it as
{"content": "<xml>", ...} — matching what anime-settings.js
viewNfoContent() already expects (data.content).
- Expose viewNfoContent on AniWorld.AnimeSettingsManager so it is
consistent with the other public methods and directly callable from
tests / other modules.
Tests:
- tests/api/test_nfo_endpoints.py: 4 new tests (auth-required, happy
path returning XML, 404 on unknown series, 404 on missing tvshow.nfo).
Also remove the file-local autouse 'reset_auth' fixture that wiped
the conftest's master-password setup and made any login-based test
fail with a stale-hash 'invalid credentials' error — that fixture
was pre-existing and is a no-op now that conftest.py handles reset.
- tests/frontend/unit/anime_settings.test.js: 3 new tests for
viewNfoContent (URL + auth header, writes <pre>, error toast on
404) and an assertion in the public-API surface test.
652 lines
23 KiB
JavaScript
652 lines
23 KiB
JavaScript
/**
|
|
* AniWorld - Anime Settings Page Manager
|
|
*
|
|
* Handles the per-anime settings page reached via the right-click
|
|
* context menu. Loads the current settings via
|
|
* GET /api/anime/{key}/settings and saves changes via
|
|
* PUT /api/anime/{key}/settings.
|
|
*
|
|
* Public API:
|
|
* - init() : bind DOM events and start initial load
|
|
* - loadSeries(key) : fetch settings for a series key
|
|
* - saveSettings(opts) : PUT settings, opts.applyToNfo / opts.renameDisk
|
|
* - regenerateNfo() : POST regenerate-nfo endpoint
|
|
* - viewNfoContent() : GET raw tvshow.nfo XML into the preview <pre>
|
|
* - validateField(name, value) : client-side validation, returns error string or null
|
|
* - populateForm(data) : fill the form from a payload
|
|
* - showSaveSuccess(msg) : success toast
|
|
* - showError(msg) : error toast
|
|
*
|
|
* Dependencies: shared/constants.js, shared/auth.js, shared/api-client.js,
|
|
* shared/ui-utils.js
|
|
*/
|
|
var AniWorld = window.AniWorld || {};
|
|
|
|
AniWorld.AnimeSettingsManager = (function () {
|
|
'use strict';
|
|
|
|
// API paths (kept in sync with constants.js)
|
|
const API_BASE = '/api/anime';
|
|
const API_NFO_BASE = '/api/nfo';
|
|
|
|
// Page state
|
|
let currentKey = null;
|
|
let currentData = null;
|
|
let originalData = null;
|
|
let elements = null;
|
|
|
|
/**
|
|
* Initialize the page — bind events and start the initial load.
|
|
*/
|
|
function init() {
|
|
ensureElements();
|
|
bindEvents();
|
|
|
|
// Read ?key=... from the URL
|
|
const url = new URL(window.location.href);
|
|
currentKey = url.searchParams.get('key');
|
|
|
|
if (currentKey) {
|
|
loadSeries(currentKey);
|
|
} else {
|
|
showNoKey();
|
|
populateSeriesSelect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cache the DOM elements we'll touch repeatedly.
|
|
* Idempotent — safe to call from public functions that need elements.
|
|
*/
|
|
function ensureElements() {
|
|
if (elements) return;
|
|
const ids = [
|
|
'no-key-section', 'loading-section', 'error-section',
|
|
'settings-section', 'series-select', 'load-series-btn',
|
|
'retry-btn', 'error-message', 'series-display-name',
|
|
'badge-loading-status', 'badge-has-nfo', 'badge-episode-counts',
|
|
'overview-key', 'overview-year', 'overview-loading-status',
|
|
'overview-episode-count', 'overview-missing-count',
|
|
'overview-nfo-created', 'overview-nfo-updated', 'overview-nfo-path',
|
|
'field-name', 'field-folder', 'field-tmdb-id', 'field-tvdb-id',
|
|
'field-site', 'hint-name', 'hint-folder', 'hint-tmdb-id',
|
|
'hint-tvdb-id', 'hint-site',
|
|
'save-db-btn', 'save-db-nfo-btn', 'reset-btn',
|
|
'rename-disk-toggle',
|
|
'regenerate-nfo-btn', 'view-nfo-btn', 'nfo-content',
|
|
];
|
|
const map = {};
|
|
ids.forEach(function (id) {
|
|
map[id] = document.getElementById(id);
|
|
});
|
|
elements = map;
|
|
}
|
|
|
|
/**
|
|
* Cache the DOM elements we'll touch repeatedly.
|
|
* @deprecated Use ensureElements() instead.
|
|
*/
|
|
function cacheElements() {
|
|
ensureElements();
|
|
}
|
|
|
|
/**
|
|
* Wire up click handlers and escape-key dismissal.
|
|
*/
|
|
function bindEvents() {
|
|
if (elements['load-series-btn']) {
|
|
elements['load-series-btn'].addEventListener('click', function () {
|
|
const v = elements['series-select'].value;
|
|
if (v) {
|
|
window.location.href = '/anime/settings?key=' +
|
|
encodeURIComponent(v);
|
|
}
|
|
});
|
|
}
|
|
if (elements['retry-btn']) {
|
|
elements['retry-btn'].addEventListener('click', function () {
|
|
if (currentKey) {
|
|
loadSeries(currentKey);
|
|
} else {
|
|
showNoKey();
|
|
}
|
|
});
|
|
}
|
|
if (elements['save-db-btn']) {
|
|
elements['save-db-btn'].addEventListener('click', function () {
|
|
saveSettings({ applyToNfo: false });
|
|
});
|
|
}
|
|
if (elements['save-db-nfo-btn']) {
|
|
elements['save-db-nfo-btn'].addEventListener('click', function () {
|
|
saveSettings({ applyToNfo: true });
|
|
});
|
|
}
|
|
if (elements['reset-btn']) {
|
|
elements['reset-btn'].addEventListener('click', function () {
|
|
if (originalData) {
|
|
populateForm(originalData);
|
|
clearValidationHints();
|
|
}
|
|
});
|
|
}
|
|
if (elements['regenerate-nfo-btn']) {
|
|
elements['regenerate-nfo-btn'].addEventListener('click',
|
|
regenerateNfo);
|
|
}
|
|
if (elements['view-nfo-btn']) {
|
|
elements['view-nfo-btn'].addEventListener('click', viewNfoContent);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the AnimeSettingsResponse for a series and populate the page.
|
|
*
|
|
* @param {string} key - Series unique key
|
|
*/
|
|
async function loadSeries(key) {
|
|
ensureElements();
|
|
if (!key) {
|
|
showNoKey();
|
|
return;
|
|
}
|
|
currentKey = key;
|
|
showLoading();
|
|
try {
|
|
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
|
? AniWorld.Auth.getToken() : null;
|
|
const headers = token
|
|
? { 'Authorization': 'Bearer ' + token }
|
|
: {};
|
|
const resp = await fetch(
|
|
API_BASE + '/' + encodeURIComponent(key) + '/settings',
|
|
{ headers: headers, method: 'GET' }
|
|
);
|
|
if (resp.status === 401) {
|
|
showError('Not authenticated — please log in again.');
|
|
showErrorState('Authentication required.');
|
|
// Redirect to login, preserving the intended destination
|
|
setTimeout(function() {
|
|
window.location.href = '/login?next=' + encodeURIComponent(window.location.href);
|
|
}, 1500);
|
|
return;
|
|
}
|
|
if (resp.status === 404) {
|
|
showErrorState('Series not found: ' + key);
|
|
return;
|
|
}
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
throw new Error('HTTP ' + resp.status + ': ' + text);
|
|
}
|
|
const data = await resp.json();
|
|
currentData = data;
|
|
// Deep clone for original-data reset
|
|
originalData = JSON.parse(JSON.stringify(data));
|
|
populateForm(data);
|
|
showSettings();
|
|
} catch (err) {
|
|
console.error('Failed to load series settings:', err);
|
|
showErrorState(err && err.message ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Save the current form contents via PUT /api/anime/{key}/settings.
|
|
*
|
|
* @param {Object} opts
|
|
* @param {boolean} opts.applyToNfo - Regenerate tvshow.nfo after save
|
|
* @param {boolean} [opts.renameDisk] - Also rename folder on disk
|
|
*/
|
|
async function saveSettings(opts) {
|
|
ensureElements();
|
|
if (!currentKey) {
|
|
showError('No series selected.');
|
|
return;
|
|
}
|
|
opts = opts || {};
|
|
const renameDisk = !!(elements['rename-disk-toggle'] &&
|
|
elements['rename-disk-toggle'].checked);
|
|
|
|
const payload = collectFormPayload();
|
|
const validationError = validatePayload(payload);
|
|
if (validationError) {
|
|
showError(validationError);
|
|
return;
|
|
}
|
|
|
|
payload.apply_to_nfo = !!opts.applyToNfo;
|
|
payload.rename_disk = renameDisk && payload.folder !== undefined &&
|
|
payload.folder !== (currentData && currentData.folder);
|
|
|
|
try {
|
|
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
|
? AniWorld.Auth.getToken() : null;
|
|
const headers = { 'Content-Type': 'application/json' };
|
|
if (token) {
|
|
headers['Authorization'] = 'Bearer ' + token;
|
|
}
|
|
const resp = await fetch(
|
|
API_BASE + '/' + encodeURIComponent(currentKey) + '/settings',
|
|
{
|
|
headers: headers,
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload),
|
|
}
|
|
);
|
|
if (resp.status === 401) {
|
|
showError('Not authenticated — please log in again.');
|
|
return;
|
|
}
|
|
if (resp.status === 422) {
|
|
const body = await resp.json().catch(function () { return {}; });
|
|
showError('Validation failed: ' + (body.detail || resp.status));
|
|
return;
|
|
}
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
throw new Error('HTTP ' + resp.status + ': ' + text);
|
|
}
|
|
const data = await resp.json();
|
|
currentData = data;
|
|
originalData = JSON.parse(JSON.stringify(data));
|
|
populateForm(data);
|
|
if (opts.applyToNfo) {
|
|
showSaveSuccess('Settings saved and tvshow.nfo regenerated.');
|
|
} else {
|
|
showSaveSuccess('Settings saved to database.');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to save settings:', err);
|
|
showError('Save failed: ' + (err && err.message ? err.message : err));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Call POST /api/anime/{key}/regenerate-nfo to regenerate tvshow.nfo.
|
|
*/
|
|
async function regenerateNfo() {
|
|
ensureElements();
|
|
if (!currentKey) {
|
|
showError('No series selected.');
|
|
return;
|
|
}
|
|
try {
|
|
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
|
? AniWorld.Auth.getToken() : null;
|
|
const headers = {};
|
|
if (token) {
|
|
headers['Authorization'] = 'Bearer ' + token;
|
|
}
|
|
const resp = await fetch(
|
|
API_BASE + '/' + encodeURIComponent(currentKey) +
|
|
'/regenerate-nfo',
|
|
{ headers: headers, method: 'POST' }
|
|
);
|
|
if (resp.status === 400) {
|
|
const body = await resp.json().catch(function () { return {}; });
|
|
showError('Cannot regenerate: ' + (body.detail || resp.status));
|
|
return;
|
|
}
|
|
if (resp.status === 404) {
|
|
showError('Series not found.');
|
|
return;
|
|
}
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
throw new Error('HTTP ' + resp.status + ': ' + text);
|
|
}
|
|
const data = await resp.json();
|
|
showSaveSuccess(data.message || 'NFO regenerated.');
|
|
// Refresh data so the NFO badges update
|
|
loadSeries(currentKey);
|
|
} catch (err) {
|
|
console.error('NFO regeneration failed:', err);
|
|
showError('Regenerate failed: ' +
|
|
(err && err.message ? err.message : err));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch and display the raw tvshow.nfo XML in a <pre>.
|
|
*/
|
|
async function viewNfoContent() {
|
|
if (!currentKey) {
|
|
showError('No series selected.');
|
|
return;
|
|
}
|
|
try {
|
|
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
|
? AniWorld.Auth.getToken() : null;
|
|
const headers = { 'Accept': 'application/json' };
|
|
if (token) {
|
|
headers['Authorization'] = 'Bearer ' + token;
|
|
}
|
|
const resp = await fetch(
|
|
API_NFO_BASE + '/' + encodeURIComponent(currentKey) + '/content',
|
|
{ headers: headers, method: 'GET' }
|
|
);
|
|
if (!resp.ok) {
|
|
const text = await resp.text();
|
|
throw new Error('HTTP ' + resp.status + ': ' + text);
|
|
}
|
|
const data = await resp.json();
|
|
const pre = elements['nfo-content'];
|
|
if (pre) {
|
|
pre.textContent = data.content || JSON.stringify(data, null, 2);
|
|
pre.classList.remove('hidden');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch NFO content:', err);
|
|
showError('Could not fetch NFO content: ' +
|
|
(err && err.message ? err.message : err));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a single field. Returns null if valid, or an error message.
|
|
*
|
|
* @param {string} name Field name (name, folder, tmdb_id, tvdb_id, site)
|
|
* @param {*} value Value from the form
|
|
* @returns {string|null}
|
|
*/
|
|
function validateField(name, value) {
|
|
switch (name) {
|
|
case 'name':
|
|
if (value === '' || value == null) {
|
|
return 'Name cannot be empty.';
|
|
}
|
|
if (typeof value === 'string' && value.length > 500) {
|
|
return 'Name exceeds 500 characters.';
|
|
}
|
|
return null;
|
|
case 'folder':
|
|
if (value === '' || value == null) {
|
|
return 'Folder cannot be empty.';
|
|
}
|
|
if (typeof value === 'string' && /\.\./.test(value)) {
|
|
return 'Folder name cannot contain ".." (path traversal).';
|
|
}
|
|
if (typeof value === 'string' && /[<>:"|?*\x00]/.test(value)) {
|
|
return 'Folder name contains invalid characters.';
|
|
}
|
|
return null;
|
|
case 'tmdb_id':
|
|
if (value === '' || value == null || value === undefined) {
|
|
return null; // optional
|
|
}
|
|
const tmdb = Number(value);
|
|
if (!Number.isFinite(tmdb) || !Number.isInteger(tmdb)) {
|
|
return 'TMDB ID must be an integer.';
|
|
}
|
|
if (tmdb <= 0) {
|
|
return 'TMDB ID must be a positive integer.';
|
|
}
|
|
if (tmdb > 9999999999) {
|
|
return 'TMDB ID exceeds 10 digits.';
|
|
}
|
|
return null;
|
|
case 'tvdb_id':
|
|
if (value === '' || value == null || value === undefined) {
|
|
return null;
|
|
}
|
|
const tvdb = Number(value);
|
|
if (!Number.isFinite(tvdb) || !Number.isInteger(tvdb)) {
|
|
return 'TVDB ID must be an integer.';
|
|
}
|
|
if (tvdb <= 0) {
|
|
return 'TVDB ID must be a positive integer.';
|
|
}
|
|
if (tvdb > 9999999999) {
|
|
return 'TVDB ID exceeds 10 digits.';
|
|
}
|
|
return null;
|
|
case 'site':
|
|
if (value && typeof value === 'string' && value.length > 500) {
|
|
return 'Site URL exceeds 500 characters.';
|
|
}
|
|
return null;
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate the whole payload. Returns null if all fields valid, or the
|
|
* first error message encountered.
|
|
*
|
|
* @param {Object} payload
|
|
* @returns {string|null}
|
|
*/
|
|
function validatePayload(payload) {
|
|
const fields = ['name', 'folder', 'tmdb_id', 'tvdb_id', 'site'];
|
|
for (let i = 0; i < fields.length; i++) {
|
|
const name = fields[i];
|
|
if (payload[name] === undefined) continue;
|
|
const err = validateField(name, payload[name]);
|
|
if (err) return name + ': ' + err;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Populate the form from a settings payload.
|
|
*
|
|
* @param {Object} data AnimeSettingsResponse dict
|
|
*/
|
|
function populateForm(data) {
|
|
ensureElements();
|
|
if (!data) return;
|
|
|
|
// Overview
|
|
setText(elements['series-display-name'], data.name || '(unnamed)');
|
|
setText(elements['overview-key'], data.key || '—');
|
|
setText(elements['overview-year'], data.year || '—');
|
|
setText(elements['overview-loading-status'],
|
|
data.loading_status || '—');
|
|
setText(elements['overview-episode-count'],
|
|
data.episode_count != null ? String(data.episode_count) : '—');
|
|
setText(elements['overview-missing-count'],
|
|
data.missing_episode_count != null
|
|
? String(data.missing_episode_count) : '—');
|
|
setText(elements['overview-nfo-created'],
|
|
data.nfo_created_at || '—');
|
|
setText(elements['overview-nfo-updated'],
|
|
data.nfo_updated_at || '—');
|
|
setText(elements['overview-nfo-path'], data.nfo_path || '—');
|
|
|
|
// Badges
|
|
const lstatus = elements['badge-loading-status'];
|
|
if (lstatus) {
|
|
lstatus.textContent = 'Loading: ' + (data.loading_status || '—');
|
|
lstatus.className = 'status-badge ' +
|
|
(data.loading_status === 'completed'
|
|
? 'status-complete'
|
|
: data.loading_status === 'failed'
|
|
? 'status-failed'
|
|
: 'status-pending');
|
|
}
|
|
const nfoBadge = elements['badge-has-nfo'];
|
|
if (nfoBadge) {
|
|
nfoBadge.textContent = data.has_nfo ? 'NFO ✓' : 'NFO ✗';
|
|
nfoBadge.className = 'status-badge ' +
|
|
(data.has_nfo ? 'status-complete' : 'status-incomplete');
|
|
}
|
|
const epBadge = elements['badge-episode-counts'];
|
|
if (epBadge) {
|
|
epBadge.textContent =
|
|
(data.missing_episode_count || 0) + ' / ' +
|
|
(data.episode_count || 0) + ' missing';
|
|
epBadge.className = 'status-badge';
|
|
}
|
|
|
|
// Editable inputs
|
|
setValue(elements['field-name'], data.name || '');
|
|
setValue(elements['field-folder'], data.folder || '');
|
|
setValue(elements['field-tmdb-id'],
|
|
data.tmdb_id != null ? data.tmdb_id : '');
|
|
setValue(elements['field-tvdb-id'],
|
|
data.tvdb_id != null ? data.tvdb_id : '');
|
|
setValue(elements['field-site'], data.site || '');
|
|
|
|
clearValidationHints();
|
|
}
|
|
|
|
/**
|
|
* Collect current form values into a partial payload (omits empty
|
|
* string / null fields so the server treats them as no-change).
|
|
*/
|
|
function collectFormPayload() {
|
|
const payload = {};
|
|
const setIfPresent = function (key, raw) {
|
|
if (raw === undefined || raw === null) return;
|
|
const trimmed = typeof raw === 'string' ? raw.trim() : raw;
|
|
if (trimmed === '' || trimmed === null) return;
|
|
payload[key] = typeof raw === 'string' ? trimmed : raw;
|
|
};
|
|
setIfPresent('name', elements['field-name'].value);
|
|
setIfPresent('folder', elements['field-folder'].value);
|
|
setIfPresent('tmdb_id', elements['field-tmdb-id'].value);
|
|
setIfPresent('tvdb_id', elements['field-tvdb-id'].value);
|
|
setIfPresent('site', elements['field-site'].value);
|
|
return payload;
|
|
}
|
|
|
|
/**
|
|
* Populate the series-select dropdown with options for keys without
|
|
* ?key=... in the URL.
|
|
*/
|
|
async function populateSeriesSelect() {
|
|
const select = elements['series-select'];
|
|
if (!select) return;
|
|
select.innerHTML = '<option value="">Loading…</option>';
|
|
try {
|
|
const token = AniWorld.Auth && AniWorld.Auth.getToken
|
|
? AniWorld.Auth.getToken() : null;
|
|
const headers = token
|
|
? { 'Authorization': 'Bearer ' + token }
|
|
: {};
|
|
const resp = await fetch(API_BASE + '?per_page=500', {
|
|
headers: headers, method: 'GET',
|
|
});
|
|
if (!resp.ok) {
|
|
select.innerHTML = '<option value="">Failed to load series</option>';
|
|
return;
|
|
}
|
|
const list = await resp.json();
|
|
select.innerHTML = '<option value="">Select a series…</option>' +
|
|
list.map(function (s) {
|
|
return '<option value="' + escapeHtml(s.key) + '">' +
|
|
escapeHtml(s.name || s.key) + '</option>';
|
|
}).join('');
|
|
} catch (err) {
|
|
console.error('Failed to populate series select:', err);
|
|
select.innerHTML = '<option value="">Failed to load series</option>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show a success toast via shared UI utilities.
|
|
*/
|
|
function showSaveSuccess(msg) {
|
|
if (AniWorld.UI && AniWorld.UI.showToast) {
|
|
AniWorld.UI.showToast(msg, 'success');
|
|
} else {
|
|
console.info('[AnimeSettings] ' + msg);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show an error toast via shared UI utilities.
|
|
*/
|
|
function showError(msg) {
|
|
if (AniWorld.UI && AniWorld.UI.showToast) {
|
|
AniWorld.UI.showToast(msg, 'error');
|
|
} else {
|
|
console.error('[AnimeSettings] ' + msg);
|
|
}
|
|
}
|
|
|
|
// View-state helpers --------------------------------------------------
|
|
|
|
function showLoading() {
|
|
showOnly('loading-section');
|
|
}
|
|
function showSettings() {
|
|
showOnly('settings-section');
|
|
}
|
|
function showNoKey() {
|
|
showOnly('no-key-section');
|
|
}
|
|
function showErrorState(msg) {
|
|
showOnly('error-section');
|
|
if (elements['error-message']) {
|
|
elements['error-message'].textContent = msg || 'Unknown error.';
|
|
}
|
|
}
|
|
function showOnly(id) {
|
|
const sections = ['no-key-section', 'loading-section',
|
|
'error-section', 'settings-section'];
|
|
sections.forEach(function (s) {
|
|
const el = document.getElementById(s);
|
|
if (!el) return;
|
|
if (s === id) {
|
|
el.classList.remove('hidden');
|
|
} else {
|
|
el.classList.add('hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
function clearValidationHints() {
|
|
['hint-name', 'hint-folder', 'hint-tmdb-id',
|
|
'hint-tvdb-id', 'hint-site'].forEach(function (id) {
|
|
const el = elements[id];
|
|
if (el) {
|
|
el.textContent = '';
|
|
el.classList.remove('hint-error');
|
|
}
|
|
});
|
|
}
|
|
|
|
function setText(el, text) {
|
|
if (el) el.textContent = text;
|
|
}
|
|
function setValue(el, text) {
|
|
if (el) el.value = text;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
if (s == null) return '';
|
|
return String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Public API ----------------------------------------------------------
|
|
|
|
return {
|
|
init: init,
|
|
loadSeries: loadSeries,
|
|
saveSettings: saveSettings,
|
|
regenerateNfo: regenerateNfo,
|
|
viewNfoContent: viewNfoContent,
|
|
validateField: validateField,
|
|
populateForm: populateForm,
|
|
showSaveSuccess: showSaveSuccess,
|
|
showError: showError,
|
|
};
|
|
})();
|
|
|
|
// Bootstrap on DOMContentLoaded — only register the listener.
|
|
// Tests call AnimeSettingsManager.init() explicitly after seeding the DOM.
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
if (AniWorld.AnimeSettingsManager && AniWorld.AnimeSettingsManager.init) {
|
|
AniWorld.AnimeSettingsManager.init();
|
|
}
|
|
});
|
|
} |