- Add PUT /anime/{key} endpoint for updating anime key, tmdb_id, tvdb_id
- Add NFO diagnostics and repair endpoints (GET/POST /nfo/diagnostics)
- Add edit modal UI with context menu integration
- Add frontend JS modules for context-menu and edit-modal
- Add comprehensive tests for edit, rename, and NFO repair flows
451 lines
15 KiB
JavaScript
451 lines
15 KiB
JavaScript
/**
|
|
* AniWorld - Edit Modal Component
|
|
*
|
|
* Modal dialog for viewing/editing anime metadata (key, tmdb_id, tvdb_id)
|
|
* and NFO diagnostics with repair functionality.
|
|
*
|
|
* Dependencies: api-client.js, ui-utils.js
|
|
*/
|
|
|
|
var AniWorld = window.AniWorld || {};
|
|
|
|
AniWorld.EditModal = (function() {
|
|
'use strict';
|
|
|
|
let modalElement = null;
|
|
let originalData = null;
|
|
let currentKey = null;
|
|
|
|
/**
|
|
* Open the edit modal for a specific anime series.
|
|
* @param {string} seriesKey - The series key to edit
|
|
*/
|
|
async function open(seriesKey) {
|
|
currentKey = seriesKey;
|
|
modalElement = document.getElementById('edit-metadata-modal');
|
|
if (!modalElement) return;
|
|
|
|
// Show modal
|
|
modalElement.classList.remove('hidden');
|
|
|
|
// Reset form state
|
|
setLoading(true);
|
|
clearErrors();
|
|
hideKeyWarning();
|
|
|
|
try {
|
|
// Find series data from the local series list
|
|
const seriesData = findSeriesData(seriesKey);
|
|
|
|
originalData = {
|
|
key: seriesKey,
|
|
tmdb_id: seriesData ? seriesData.tmdb_id : null,
|
|
tvdb_id: seriesData ? seriesData.tvdb_id : null,
|
|
};
|
|
|
|
// Populate form fields
|
|
setFieldValue('edit-key', originalData.key);
|
|
setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
|
|
setFieldValue('edit-tvdb-id', originalData.tvdb_id || '');
|
|
|
|
// Load NFO diagnostics
|
|
await loadDiagnostics(seriesKey);
|
|
|
|
} catch (err) {
|
|
AniWorld.UI.showToast('Failed to load series data', 'error');
|
|
console.error('Edit modal load error:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
|
|
// Attach event listeners
|
|
attachListeners();
|
|
}
|
|
|
|
/**
|
|
* Close the edit modal and reset state.
|
|
*/
|
|
function close() {
|
|
if (modalElement) {
|
|
modalElement.classList.add('hidden');
|
|
}
|
|
originalData = null;
|
|
currentKey = null;
|
|
detachListeners();
|
|
}
|
|
|
|
/**
|
|
* Save changed metadata to the backend.
|
|
*/
|
|
async function save() {
|
|
clearErrors();
|
|
|
|
const newKey = getFieldValue('edit-key').trim().toLowerCase();
|
|
const tmdbIdStr = getFieldValue('edit-tmdb-id').trim();
|
|
const tvdbIdStr = getFieldValue('edit-tvdb-id').trim();
|
|
|
|
// Validate key
|
|
if (!newKey) {
|
|
showFieldError('edit-key', 'Key cannot be empty');
|
|
return;
|
|
}
|
|
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(newKey)) {
|
|
showFieldError('edit-key', 'Key must contain only lowercase letters, numbers, and hyphens');
|
|
return;
|
|
}
|
|
|
|
// Validate IDs
|
|
const tmdbId = tmdbIdStr ? parseInt(tmdbIdStr, 10) : null;
|
|
const tvdbId = tvdbIdStr ? parseInt(tvdbIdStr, 10) : null;
|
|
|
|
if (tmdbIdStr && (isNaN(tmdbId) || tmdbId < 1)) {
|
|
showFieldError('edit-tmdb-id', 'TMDB ID must be a positive number');
|
|
return;
|
|
}
|
|
if (tvdbIdStr && (isNaN(tvdbId) || tvdbId < 1)) {
|
|
showFieldError('edit-tvdb-id', 'TVDB ID must be a positive number');
|
|
return;
|
|
}
|
|
|
|
// Check if key changed — show confirmation
|
|
if (newKey !== originalData.key) {
|
|
const confirmed = await AniWorld.UI.showConfirmModal(
|
|
'Rename Series Key',
|
|
`Changing the key from "${originalData.key}" to "${newKey}" will update the primary identifier. ` +
|
|
'This may affect provider linkage. Are you sure?'
|
|
);
|
|
if (!confirmed) return;
|
|
}
|
|
|
|
// Build update payload (only changed fields)
|
|
const payload = {};
|
|
if (newKey !== originalData.key) payload.key = newKey;
|
|
if (tmdbId !== originalData.tmdb_id) payload.tmdb_id = tmdbId;
|
|
if (tvdbId !== originalData.tvdb_id) payload.tvdb_id = tvdbId;
|
|
|
|
if (Object.keys(payload).length === 0) {
|
|
AniWorld.UI.showToast('No changes to save', 'info');
|
|
return;
|
|
}
|
|
|
|
// Send update
|
|
setSaveLoading(true);
|
|
try {
|
|
const response = await AniWorld.ApiClient.put(
|
|
'/api/anime/' + encodeURIComponent(currentKey),
|
|
payload
|
|
);
|
|
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
AniWorld.UI.showToast('Metadata updated successfully', 'success');
|
|
|
|
// Update local state
|
|
const oldKey = currentKey;
|
|
currentKey = result.key;
|
|
originalData = {
|
|
key: result.key,
|
|
tmdb_id: result.tmdb_id,
|
|
tvdb_id: result.tvdb_id,
|
|
};
|
|
|
|
// Update the card in the DOM
|
|
updateCardAfterSave(oldKey, result);
|
|
|
|
// Update repair button state
|
|
updateRepairButtonState();
|
|
|
|
} else if (response.status === 409) {
|
|
showFieldError('edit-key', 'A series with this key already exists');
|
|
} else if (response.status === 422) {
|
|
const err = await response.json();
|
|
AniWorld.UI.showToast('Validation error: ' + (err.detail || 'Invalid input'), 'error');
|
|
} else {
|
|
AniWorld.UI.showToast('Failed to update metadata', 'error');
|
|
}
|
|
} catch (err) {
|
|
AniWorld.UI.showToast('Connection error. Check your network.', 'error');
|
|
console.error('Save error:', err);
|
|
} finally {
|
|
setSaveLoading(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger NFO repair for the current series.
|
|
*/
|
|
async function repairNfo() {
|
|
setRepairLoading(true);
|
|
try {
|
|
const response = await AniWorld.ApiClient.post(
|
|
'/api/nfo/' + encodeURIComponent(currentKey) + '/repair',
|
|
{}
|
|
);
|
|
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
AniWorld.UI.showToast(result.message, 'success');
|
|
|
|
// Refresh diagnostics
|
|
await loadDiagnostics(currentKey);
|
|
} else if (response.status === 400) {
|
|
const err = await response.json();
|
|
AniWorld.UI.showToast(err.detail || 'Cannot repair NFO', 'error');
|
|
} else {
|
|
AniWorld.UI.showToast('Failed to repair NFO', 'error');
|
|
}
|
|
} catch (err) {
|
|
AniWorld.UI.showToast('Connection error during repair', 'error');
|
|
console.error('Repair error:', err);
|
|
} finally {
|
|
setRepairLoading(false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load NFO diagnostics for the current series.
|
|
* @param {string} key - Series key
|
|
*/
|
|
async function loadDiagnostics(key) {
|
|
const container = document.getElementById('nfo-diagnostics-container');
|
|
if (!container) return;
|
|
|
|
try {
|
|
const response = await AniWorld.ApiClient.get(
|
|
'/api/nfo/' + encodeURIComponent(key) + '/diagnostics'
|
|
);
|
|
|
|
if (!response || !response.ok) {
|
|
container.innerHTML = '<p class="nfo-error">Failed to load NFO diagnostics</p>';
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
renderDiagnostics(data);
|
|
updateRepairButtonState();
|
|
|
|
} catch (err) {
|
|
container.innerHTML = '<p class="nfo-error">Error loading diagnostics</p>';
|
|
console.error('Diagnostics error:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render NFO diagnostics data into the modal.
|
|
* @param {Object} data - NfoDiagnosticsResponse
|
|
*/
|
|
function renderDiagnostics(data) {
|
|
const badge = document.getElementById('nfo-status-badge');
|
|
const tagsList = document.getElementById('nfo-missing-tags');
|
|
|
|
if (badge) {
|
|
if (!data.has_nfo) {
|
|
badge.className = 'nfo-status-badge nfo-missing';
|
|
badge.textContent = 'No NFO File';
|
|
} else if (data.missing_tags.length === 0) {
|
|
badge.className = 'nfo-status-badge nfo-complete';
|
|
badge.textContent = 'Complete';
|
|
} else {
|
|
badge.className = 'nfo-status-badge nfo-incomplete';
|
|
badge.textContent = data.missing_tags.length + ' Missing';
|
|
}
|
|
}
|
|
|
|
if (tagsList) {
|
|
if (data.missing_tags.length === 0) {
|
|
tagsList.innerHTML = '<p class="nfo-all-good">All required tags present</p>';
|
|
} else {
|
|
tagsList.innerHTML = data.missing_tags.map(function(tag) {
|
|
return '<span class="missing-tag-chip">' + escapeHtml(tag) + '</span>';
|
|
}).join('');
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update repair button disabled state based on tmdb_id field.
|
|
*/
|
|
function updateRepairButtonState() {
|
|
const btn = document.getElementById('btn-repair-nfo');
|
|
const hint = document.getElementById('repair-hint');
|
|
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
|
|
|
|
if (btn) {
|
|
// Enable repair even without tmdb_id — the service can search by name
|
|
btn.disabled = false;
|
|
}
|
|
if (hint) {
|
|
hint.style.display = tmdbValue ? 'none' : 'block';
|
|
}
|
|
}
|
|
|
|
// ---- Helpers ----
|
|
|
|
function findSeriesData(key) {
|
|
// Access the series data from the series manager if available
|
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.getSeriesData) {
|
|
const allSeries = AniWorld.SeriesManager.getSeriesData();
|
|
if (allSeries) {
|
|
return allSeries.find(function(s) { return s.key === key; });
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function updateCardAfterSave(oldKey, result) {
|
|
const card = document.querySelector('[data-series-id="' + oldKey + '"]');
|
|
if (card) {
|
|
card.setAttribute('data-key', result.key);
|
|
card.setAttribute('data-series-id', result.key);
|
|
// Update checkbox data-key
|
|
const checkbox = card.querySelector('.series-checkbox');
|
|
if (checkbox) {
|
|
checkbox.setAttribute('data-key', result.key);
|
|
}
|
|
}
|
|
|
|
// Update local series data array
|
|
if (AniWorld.SeriesManager && AniWorld.SeriesManager.updateSeriesKey) {
|
|
AniWorld.SeriesManager.updateSeriesKey(oldKey, result.key);
|
|
}
|
|
}
|
|
|
|
function setFieldValue(id, value) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.value = value !== null && value !== undefined ? value : '';
|
|
}
|
|
|
|
function getFieldValue(id) {
|
|
const el = document.getElementById(id);
|
|
return el ? el.value : '';
|
|
}
|
|
|
|
function showFieldError(fieldId, message) {
|
|
const el = document.getElementById(fieldId);
|
|
if (el) {
|
|
const errorEl = el.parentElement.querySelector('.field-error');
|
|
if (errorEl) {
|
|
errorEl.textContent = message;
|
|
errorEl.style.display = 'block';
|
|
}
|
|
el.classList.add('input-error');
|
|
}
|
|
}
|
|
|
|
function clearErrors() {
|
|
if (!modalElement) return;
|
|
modalElement.querySelectorAll('.field-error').forEach(function(el) {
|
|
el.style.display = 'none';
|
|
el.textContent = '';
|
|
});
|
|
modalElement.querySelectorAll('.input-error').forEach(function(el) {
|
|
el.classList.remove('input-error');
|
|
});
|
|
}
|
|
|
|
function hideKeyWarning() {
|
|
const warning = document.getElementById('key-change-warning');
|
|
if (warning) warning.style.display = 'none';
|
|
}
|
|
|
|
function setLoading(loading) {
|
|
const form = document.getElementById('edit-metadata-form');
|
|
if (form) {
|
|
form.style.opacity = loading ? '0.5' : '1';
|
|
form.style.pointerEvents = loading ? 'none' : 'auto';
|
|
}
|
|
}
|
|
|
|
function setSaveLoading(loading) {
|
|
const btn = document.getElementById('btn-save-metadata');
|
|
if (btn) {
|
|
btn.disabled = loading;
|
|
btn.innerHTML = loading
|
|
? '<i class="fa-solid fa-spinner fa-spin"></i> Saving...'
|
|
: '<i class="fa-solid fa-floppy-disk"></i> Save';
|
|
}
|
|
}
|
|
|
|
function setRepairLoading(loading) {
|
|
const btn = document.getElementById('btn-repair-nfo');
|
|
if (btn) {
|
|
btn.disabled = loading;
|
|
btn.innerHTML = loading
|
|
? '<i class="fa-solid fa-spinner fa-spin"></i> Repairing...'
|
|
: '<i class="fa-solid fa-wrench"></i> Repair NFO';
|
|
}
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
var div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Event listener management
|
|
let listeners = [];
|
|
|
|
function attachListeners() {
|
|
detachListeners();
|
|
|
|
const saveBtn = document.getElementById('btn-save-metadata');
|
|
const cancelBtn = document.getElementById('btn-cancel-metadata');
|
|
const repairBtn = document.getElementById('btn-repair-nfo');
|
|
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
|
|
const keyInput = document.getElementById('edit-key');
|
|
|
|
if (saveBtn) {
|
|
var saveFn = function() { save(); };
|
|
saveBtn.addEventListener('click', saveFn);
|
|
listeners.push({ el: saveBtn, event: 'click', fn: saveFn });
|
|
}
|
|
|
|
if (cancelBtn) {
|
|
var cancelFn = function() { close(); };
|
|
cancelBtn.addEventListener('click', cancelFn);
|
|
listeners.push({ el: cancelBtn, event: 'click', fn: cancelFn });
|
|
}
|
|
|
|
if (repairBtn) {
|
|
var repairFn = function() { repairNfo(); };
|
|
repairBtn.addEventListener('click', repairFn);
|
|
listeners.push({ el: repairBtn, event: 'click', fn: repairFn });
|
|
}
|
|
|
|
if (overlay) {
|
|
var overlayFn = function() { close(); };
|
|
overlay.addEventListener('click', overlayFn);
|
|
listeners.push({ el: overlay, event: 'click', fn: overlayFn });
|
|
}
|
|
|
|
if (keyInput) {
|
|
var keyFn = function() {
|
|
var warning = document.getElementById('key-change-warning');
|
|
if (warning) {
|
|
warning.style.display = keyInput.value !== originalData.key ? 'block' : 'none';
|
|
}
|
|
};
|
|
keyInput.addEventListener('input', keyFn);
|
|
listeners.push({ el: keyInput, event: 'input', fn: keyFn });
|
|
}
|
|
}
|
|
|
|
function detachListeners() {
|
|
listeners.forEach(function(l) {
|
|
l.el.removeEventListener(l.event, l.fn);
|
|
});
|
|
listeners = [];
|
|
}
|
|
|
|
return {
|
|
open: open,
|
|
close: close,
|
|
save: save,
|
|
repairNfo: repairNfo
|
|
};
|
|
})();
|