- Split renderer.js into 9 manager classes following SOLID principles: * AppState - State management * EpisodeManager - Episode numbering and range editing * TagManager - File tagging system * SearchManager - TheTVDB search integration * FileManager - Directory and file operations * ModalManager - Video preview modals * ProgressManager - Progress display * FileListManager - File list rendering and drag/drop * UIManager - Main UI coordinator - Add comprehensive test suite (test-renderer-classes-comprehensive.js) with 100+ tests covering all business logic - Fix drag/drop handler 'this' binding issues - Add CSS for highlighted episodes and improved file list styling - Restore all original functionality including episode cascading, tagging system, TVDB search, and Jellyfin naming
301 lines
9.0 KiB
JavaScript
301 lines
9.0 KiB
JavaScript
/**
|
|
* EpisodeManager - Manages episode number editing and highlighting
|
|
*/
|
|
class EpisodeManager {
|
|
constructor() {
|
|
this.isUpdatingEpisodeNumbers = false;
|
|
}
|
|
|
|
/**
|
|
* Update episode numbers in the file list
|
|
* @param {HTMLElement} fileListEl - File list element
|
|
*/
|
|
updateEpisodeNumbers(fileListEl) {
|
|
if (this.isUpdatingEpisodeNumbers) {
|
|
console.log('[updateEpisodeNumbers] Already updating, skipping re-entrant call');
|
|
return;
|
|
}
|
|
|
|
this.isUpdatingEpisodeNumbers = true;
|
|
|
|
try {
|
|
const fileItems = fileListEl.querySelectorAll('.file-item:not(.folder-item)');
|
|
let episodeNum = 1;
|
|
|
|
fileItems.forEach((item, index) => {
|
|
const episodeEl = item.querySelector('.episode-number');
|
|
if (episodeEl) {
|
|
const storedStart = episodeEl.dataset.episodeStart;
|
|
const storedEnd = episodeEl.dataset.episodeEnd;
|
|
|
|
const start = storedStart ? parseInt(storedStart) : episodeNum;
|
|
const end = storedEnd ? parseInt(storedEnd) : start;
|
|
|
|
const rangeText = start === end ? `${start}` : `${start}-${end}`;
|
|
episodeEl.textContent = rangeText;
|
|
episodeEl.dataset.episode = episodeNum;
|
|
episodeNum = end + 1;
|
|
}
|
|
});
|
|
} finally {
|
|
this.isUpdatingEpisodeNumbers = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make episode range editable
|
|
* @param {HTMLElement} element - Episode number element
|
|
*/
|
|
makeEpisodeRangeEditable(element) {
|
|
if (element.contentEditable === 'true') return;
|
|
|
|
const originalText = element.textContent;
|
|
const originalStart = element.dataset.episodeStart;
|
|
const originalEnd = element.dataset.episodeEnd;
|
|
|
|
element.contentEditable = 'true';
|
|
element.focus();
|
|
element.classList.add('editing');
|
|
|
|
const range = document.createRange();
|
|
range.selectNodeContents(element);
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
|
|
const saveEdit = () => {
|
|
const newText = element.textContent.trim();
|
|
|
|
if (newText !== originalText) {
|
|
let start, end;
|
|
|
|
if (newText.includes('-')) {
|
|
const parts = newText.split('-').map(p => parseInt(p.trim()));
|
|
start = parts[0];
|
|
end = parts[1] || start;
|
|
} else {
|
|
const num = parseInt(newText);
|
|
start = num;
|
|
end = num;
|
|
}
|
|
|
|
if (!isNaN(start) && !isNaN(end)) {
|
|
element.dataset.episodeStart = start;
|
|
element.dataset.episodeEnd = end;
|
|
console.log(`Episode range updated: ${start}-${end}`);
|
|
this.updateEpisodeNumbers(element.closest('#file-list'));
|
|
} else {
|
|
element.textContent = originalText;
|
|
element.dataset.episodeStart = originalStart;
|
|
element.dataset.episodeEnd = originalEnd;
|
|
}
|
|
}
|
|
|
|
element.contentEditable = 'false';
|
|
element.classList.remove('editing');
|
|
};
|
|
|
|
element.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
saveEdit();
|
|
}
|
|
}, { once: true });
|
|
|
|
element.addEventListener('blur', saveEdit, { once: true });
|
|
}
|
|
|
|
/**
|
|
* Handle episode arrow click
|
|
* @param {HTMLElement} element - Arrow element
|
|
* @param {string} direction - Direction (left or right)
|
|
* @param {HTMLElement} fileListEl - File list element
|
|
*/
|
|
handleEpisodeArrowClick(element, direction, fileListEl) {
|
|
const fileItem = element.closest('.file-item');
|
|
const episodeEl = fileItem.querySelector('.episode-number');
|
|
|
|
if (!episodeEl) return;
|
|
|
|
// Get current values before updating
|
|
let start = parseInt(episodeEl.dataset.episodeStart) || 1;
|
|
let end = parseInt(episodeEl.dataset.episodeEnd) || start;
|
|
|
|
const oldStart = start;
|
|
const oldEnd = end;
|
|
|
|
// Update the episode range
|
|
if (direction === 'left') {
|
|
// Decrease range - move start back by 1
|
|
if (start > 1) {
|
|
start--;
|
|
}
|
|
} else {
|
|
// Increase range - move end forward by 1
|
|
end++;
|
|
}
|
|
|
|
// Update this episode's data attributes
|
|
episodeEl.dataset.episodeStart = start;
|
|
episodeEl.dataset.episodeEnd = end;
|
|
|
|
// Get all media file items (not folders) in order
|
|
const allFileItems = Array.from(fileListEl.querySelectorAll('.file-item:not(.folder-item)'));
|
|
|
|
// Find current index by matching file path
|
|
const filePath = fileItem.dataset.filePath;
|
|
let currentIndex = -1;
|
|
for (let i = 0; i < allFileItems.length; i++) {
|
|
if (allFileItems[i].dataset.filePath === filePath) {
|
|
currentIndex = i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (currentIndex === -1) return;
|
|
|
|
// For right arrow: shift all subsequent episodes forward by 1
|
|
if (direction === 'right') {
|
|
for (let i = currentIndex + 1; i < allFileItems.length; i++) {
|
|
const nextItem = allFileItems[i];
|
|
const nextEpisodeEl = nextItem.querySelector('.episode-number');
|
|
|
|
if (nextEpisodeEl) {
|
|
let nextStart = parseInt(nextEpisodeEl.dataset.episodeStart) || 1;
|
|
const nextEnd = parseInt(nextEpisodeEl.dataset.episodeEnd) || nextStart;
|
|
|
|
nextEpisodeEl.dataset.episodeStart = nextStart + 1;
|
|
nextEpisodeEl.dataset.episodeEnd = nextEnd + 1;
|
|
}
|
|
}
|
|
} else {
|
|
// For left arrow: all subsequent episodes shift down
|
|
const shiftAmount = oldStart - start;
|
|
for (let i = currentIndex + 1; i < allFileItems.length; i++) {
|
|
const nextItem = allFileItems[i];
|
|
const nextEpisodeEl = nextItem.querySelector('.episode-number');
|
|
|
|
if (nextEpisodeEl) {
|
|
let nextStart = parseInt(nextEpisodeEl.dataset.episodeStart) || 1;
|
|
const nextEnd = parseInt(nextEpisodeEl.dataset.episodeEnd) || nextStart;
|
|
|
|
nextEpisodeEl.dataset.episodeStart = nextStart - shiftAmount;
|
|
nextEpisodeEl.dataset.episodeEnd = nextEnd - shiftAmount;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Force immediate DOM update
|
|
this.updateEpisodeNumbers(fileListEl);
|
|
}
|
|
|
|
/**
|
|
* Highlight episodes in the episodes container
|
|
* @param {number} startEpisode - Start episode number
|
|
* @param {number} endEpisode - End episode number
|
|
* @param {HTMLElement} episodesContainer - Episodes container element
|
|
*/
|
|
highlightEpisodes(startEpisode, endEpisode, episodesContainer) {
|
|
if (!episodesContainer) return;
|
|
|
|
const episodeItems = episodesContainer.querySelectorAll('.episode-item');
|
|
|
|
episodeItems.forEach(item => {
|
|
const episodeText = item.textContent.trim();
|
|
const episodeMatch = episodeText.match(/E(\d+)/);
|
|
|
|
if (episodeMatch) {
|
|
const episodeNum = parseInt(episodeMatch[1]);
|
|
|
|
if (episodeNum >= startEpisode && episodeNum <= endEpisode) {
|
|
item.classList.add('highlighted-episode');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Remove highlights from all episodes
|
|
* @param {HTMLElement} episodesContainer - Episodes container element
|
|
*/
|
|
removeEpisodeHighlights(episodesContainer) {
|
|
if (!episodesContainer) return;
|
|
|
|
const episodeItems = episodesContainer.querySelectorAll('.episode-item');
|
|
|
|
episodeItems.forEach(item => {
|
|
item.classList.remove('highlighted-episode');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get episode range from element
|
|
* @param {HTMLElement} episodeEl - Episode element
|
|
* @returns {Object} Episode range object
|
|
*/
|
|
getEpisodeRange(episodeEl) {
|
|
const episodeStart = parseInt(episodeEl.dataset.episodeStart) || 1;
|
|
const episodeEnd = parseInt(episodeEl.dataset.episodeEnd) || episodeStart;
|
|
return { start: episodeStart, end: episodeEnd };
|
|
}
|
|
|
|
/**
|
|
* Calculate total episode count from file items
|
|
* @param {HTMLElement} fileListEl - File list element
|
|
* @returns {number} Total episode count
|
|
*/
|
|
calculateTotalEpisodeCount(fileListEl) {
|
|
const mediaFiles = fileListEl.querySelectorAll('.file-item:not(.folder-item)');
|
|
let totalEpisodeCount = 0;
|
|
|
|
mediaFiles.forEach(item => {
|
|
const episodeEl = item.querySelector('.episode-number');
|
|
if (episodeEl) {
|
|
const { start, end } = this.getEpisodeRange(episodeEl);
|
|
totalEpisodeCount += (end - start + 1);
|
|
}
|
|
});
|
|
|
|
return totalEpisodeCount;
|
|
}
|
|
|
|
/**
|
|
* Get last episode end from file items
|
|
* @param {HTMLElement} fileListEl - File list element
|
|
* @returns {number} Last episode end number
|
|
*/
|
|
getLastEpisodeEnd(fileListEl) {
|
|
const mediaFiles = fileListEl.querySelectorAll('.file-item:not(.folder-item)');
|
|
let lastEpisodeEnd = 0;
|
|
|
|
mediaFiles.forEach((item, index) => {
|
|
const episodeEl = item.querySelector('.episode-number');
|
|
if (episodeEl) {
|
|
const episodeEnd = parseInt(episodeEl.dataset.episodeEnd) || 1;
|
|
if (index === 0 || episodeEnd > lastEpisodeEnd) {
|
|
lastEpisodeEnd = episodeEnd;
|
|
}
|
|
}
|
|
});
|
|
|
|
return lastEpisodeEnd;
|
|
}
|
|
|
|
/**
|
|
* Get updating episode numbers flag
|
|
* @returns {boolean} Updating state
|
|
*/
|
|
getIsUpdatingEpisodeNumbers() {
|
|
return this.isUpdatingEpisodeNumbers;
|
|
}
|
|
|
|
/**
|
|
* Set updating episode numbers flag
|
|
* @param {boolean} updating - Updating state
|
|
*/
|
|
setIsUpdatingEpisodeNumbers(updating) {
|
|
this.isUpdatingEpisodeNumbers = updating;
|
|
}
|
|
}
|
|
|
|
module.exports = EpisodeManager; |