refactor: remove Edit Metadata right-click option

Removes the Edit Metadata feature from the anime context menu and
deletes all related backend/frontend code:

Backend:
- DELETE PUT /api/anime/{anime_key} endpoint
- DELETE AnimeMetadataUpdate model and KEY_PATTERN regex
- DELETE MAX_INPUT_LENGTH constant

Frontend:
- DELETE edit-modal.js component
- DELETE edit metadata modal HTML from index.html
- DELETE edit-modal.js script tag
- DELETE edit-only CSS (.edit-modal-content, .edit-section,
  .field-error, .input-error, .key-warning, .info-grid,
  .info-item, .input-with-action, .btn-fetch-tmdb, .tmdb-*)
- REMOVE 'Edit Metadata' item from context menu (NFO Diagnostics
  preserved)

Tests:
- DELETE test_anime_edit_endpoints.py
- DELETE test_edit_modal.py
- DELETE test_anime_key_rename.py
This commit is contained in:
2026-06-20 21:02:22 +02:00
parent a8e54876e3
commit e050f6fa2d
9 changed files with 3 additions and 1589 deletions

View File

@@ -2,9 +2,9 @@
* AniWorld - Context Menu Component
*
* Right-click context menu for anime series cards.
* Provides quick access to edit metadata.
* Provides quick access to NFO diagnostics.
*
* Dependencies: ui-utils.js, edit-modal.js
* Dependencies: ui-utils.js
*/
var AniWorld = window.AniWorld || {};
@@ -67,10 +67,6 @@ AniWorld.ContextMenu = (function() {
menuElement = document.createElement('div');
menuElement.className = 'context-menu';
menuElement.innerHTML = `
<div class="context-menu-item" data-action="edit">
<i class="fa-solid fa-pen-to-square"></i>
<span>Edit Metadata</span>
</div>
<div class="context-menu-item" data-action="nfo-diagnostics">
<i class="fa-solid fa-file-circle-check"></i>
<span>NFO Diagnostics</span>
@@ -100,13 +96,6 @@ AniWorld.ContextMenu = (function() {
menuElement.style.top = posY + 'px';
// Attach action handlers
menuElement.querySelector('[data-action="edit"]').addEventListener('click', function() {
hide();
if (AniWorld.EditModal) {
AniWorld.EditModal.open(currentSeriesKey);
}
});
// NFO Diagnostics - opens the full NFO settings page
menuElement.querySelector('[data-action="nfo-diagnostics"]').addEventListener('click', function() {
hide();

View File

@@ -1,630 +0,0 @@
/**
* 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';
const API = AniWorld.Constants ? AniWorld.Constants.API : {};
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 {
// Always fetch fresh data from API for edit modal to ensure accuracy
// This is more reliable than local cache which may be stale or missing
let seriesData = await fetchSeriesDetails(seriesKey);
// Fallback: try local data if API fails
if (!seriesData) {
seriesData = findSeriesData(seriesKey);
}
originalData = {
key: seriesKey,
tmdb_id: seriesData ? seriesData.tmdb_id : null,
tvdb_id: seriesData ? seriesData.tvdb_id : null,
name: seriesData ? seriesData.name : seriesKey,
year: seriesData ? seriesData.year : null,
status: seriesData ? seriesData.status : null,
genres: seriesData ? (seriesData.genres || []) : [],
studio: seriesData ? (seriesData.studio || []) : [],
premiered: seriesData ? seriesData.premiered : null,
rating: seriesData ? seriesData.rating : null,
rating_votes: seriesData ? seriesData.rating_votes : null,
};
// Populate form fields
setFieldValue('edit-key', originalData.key);
setFieldValue('edit-tmdb-id', originalData.tmdb_id || '');
setFieldValue('edit-tvdb-id', originalData.tvdb_id || '');
// Populate display fields
populateDisplayFields(originalData);
// Show/hide TMDB fetch button based on whether TMDB ID exists
updateTmdbFetchButtonState();
// 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();
}
/**
* Fetch series details from the API.
* @param {string} key - Series key
* @returns {Promise<Object|null>} Series details or null on error
*/
async function fetchSeriesDetails(key) {
try {
const response = await AniWorld.ApiClient.get(
'/api/anime/' + encodeURIComponent(key) + '/details'
);
if (response && response.ok) {
return await response.json();
}
} catch (err) {
console.error('Failed to fetch series details:', err);
}
return null;
}
/**
* Populate display-only fields in the modal.
* @param {Object} data - Series data
*/
function populateDisplayFields(data) {
const fields = [
{ id: 'edit-name', value: data.name || '' },
{ id: 'edit-year', value: data.year || '' },
{ id: 'edit-status', value: data.status || '' },
{ id: 'edit-genres', value: (data.genres || []).join(', ') },
{ id: 'edit-studio', value: (data.studio || []).join(', ') },
{ id: 'edit-premiered', value: data.premiered || '' },
{ id: 'edit-rating', value: data.rating ? data.rating.toFixed(1) + '/10' : '' },
];
fields.forEach(function(field) {
const el = document.getElementById(field.id);
if (el) el.textContent = field.value;
});
}
/**
* Update the TMDB fetch button visibility.
*/
function updateTmdbFetchButtonState() {
const btn = document.getElementById('btn-fetch-tmdb');
const tmdbValue = getFieldValue('edit-tmdb-id').trim();
if (btn) {
btn.style.display = tmdbValue ? 'none' : 'inline-flex';
}
}
/**
* Fetch TMDB data for auto-fill.
*/
async function fetchTmdbData() {
const btn = document.getElementById('btn-fetch-tmdb');
const resultsContainer = document.getElementById('tmdb-search-results');
if (!btn || !resultsContainer) return;
// Show loading state
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Searching...';
resultsContainer.innerHTML = '';
resultsContainer.style.display = 'block';
try {
const response = await AniWorld.ApiClient.get(
'/api/anime/' + encodeURIComponent(currentKey) + '/tmdb-search'
);
if (!response) {
resultsContainer.innerHTML = '<p class="tmdb-error">Connection error</p>';
return;
}
if (response.status === 400) {
resultsContainer.innerHTML = '<p class="tmdb-error">TMDB not configured</p>';
return;
}
if (!response.ok) {
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
return;
}
const results = await response.json();
if (results.length === 0) {
resultsContainer.innerHTML = '<p class="tmdb-no-results">No matches found</p>';
return;
}
// Render results
resultsContainer.innerHTML = results.slice(0, 5).map(function(r) {
const year = r.year ? ' (' + r.year + ')' : '';
const rating = r.vote_average ? ' ★' + r.vote_average.toFixed(1) : '';
return '<div class="tmdb-result-item" data-tmdb-id="' + r.tmdb_id + '">' +
'<span class="tmdb-result-title">' + escapeHtml(r.title) + year + rating + '</span>' +
'<span class="tmdb-result-overview">' + escapeHtml(r.overview || '') + '</span>' +
'</div>';
}).join('');
// Attach click handlers to results
resultsContainer.querySelectorAll('.tmdb-result-item').forEach(function(item) {
item.addEventListener('click', function() {
const tmdbId = this.getAttribute('data-tmdb-id');
setFieldValue('edit-tmdb-id', tmdbId);
resultsContainer.innerHTML = '<p class="tmdb-selected">TMDB ID ' + tmdbId + ' selected</p>';
updateTmdbFetchButtonState();
});
});
} catch (err) {
resultsContainer.innerHTML = '<p class="tmdb-error">Search failed</p>';
console.error('TMDB search error:', err);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-search"></i> Fetch from TMDB';
}
}
/**
* 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');
const pathDisplay = document.getElementById('nfo-path-display');
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';
}
}
// Show NFO path if available
if (pathDisplay) {
if (data.nfo_path) {
// Extract just the relative path from the full path
const parts = data.nfo_path.split('/');
const relativePath = parts.slice(-3).join('/'); // folder/tvshow.nfo
pathDisplay.textContent = relativePath;
pathDisplay.title = data.nfo_path;
} else {
pathDisplay.textContent = '';
}
}
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 diagnosticsLink = document.getElementById('btn-open-nfo-diagnostics');
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';
}
// Show link to full diagnostics page
if (diagnosticsLink && currentKey) {
diagnosticsLink.href = '/settings/nfo?key=' + encodeURIComponent(currentKey);
diagnosticsLink.style.display = 'inline-flex';
}
}
// ---- 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 fetchTmdbBtn = document.getElementById('btn-fetch-tmdb');
const overlay = modalElement ? modalElement.querySelector('.modal-overlay') : null;
const keyInput = document.getElementById('edit-key');
const tmdbInput = document.getElementById('edit-tmdb-id');
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 (fetchTmdbBtn) {
var fetchTmdbFn = function() { fetchTmdbData(); };
fetchTmdbBtn.addEventListener('click', fetchTmdbFn);
listeners.push({ el: fetchTmdbBtn, event: 'click', fn: fetchTmdbFn });
}
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 });
}
if (tmdbInput) {
var tmdbFn = function() {
updateTmdbFetchButtonState();
};
tmdbInput.addEventListener('input', tmdbFn);
listeners.push({ el: tmdbInput, event: 'input', fn: tmdbFn });
}
}
function detachListeners() {
listeners.forEach(function(l) {
l.el.removeEventListener(l.event, l.fn);
});
listeners = [];
}
return {
open: open,
close: close,
save: save,
repairNfo: repairNfo
};
})();