- Split large renderer.js file into 8 separate class files: - AppState.js: Manages application state - FileListManager.js: Handles file list display and manipulation - TagManager.js: Manages file tagging functionality - EpisodeManager.js: Handles episode number editing and highlighting - SearchManager.js: Manages TVDB search and show selection - FileManager.js: Handles file operations - ModalManager.js: Manages video preview modal - ProgressManager.js: Manages progress display - UIManager.js: Main coordinator for UI functionality - Follows SOLID principles and single responsibility - Improves code maintainability and testability - All syntax verified with node --check
594 lines
18 KiB
JavaScript
594 lines
18 KiB
JavaScript
const { ipcRenderer } = require('electron');
|
|
const AppState = require('./AppState');
|
|
const FileListManager = require('./FileListManager');
|
|
const TagManager = require('./TagManager');
|
|
const EpisodeManager = require('./EpisodeManager');
|
|
const SearchManager = require('./SearchManager');
|
|
const FileManager = require('./FileManager');
|
|
const ModalManager = require('./ModalManager');
|
|
const ProgressManager = require('./ProgressManager');
|
|
|
|
/**
|
|
* UIManager - Main coordinator for UI functionality
|
|
*/
|
|
class UIManager {
|
|
constructor() {
|
|
// Initialize managers
|
|
this.appState = new AppState();
|
|
this.fileManager = new FileManager();
|
|
this.modalManager = new ModalManager();
|
|
|
|
// Get DOM elements
|
|
this.selectDirBtn = document.getElementById('select-dir-btn');
|
|
this.selectedDirEl = document.getElementById('selected-dir');
|
|
this.searchInput = document.getElementById('search-input');
|
|
this.searchResultsEl = document.getElementById('search-results');
|
|
this.fileListEl = document.getElementById('file-list');
|
|
this.showDetailsEl = document.getElementById('show-details');
|
|
this.progressContainer = document.getElementById('progress-container');
|
|
this.progressText = document.getElementById('progress-text');
|
|
this.progressCount = document.getElementById('progress-count');
|
|
|
|
// Initialize UI managers
|
|
this.fileListManager = new FileListManager(this.fileListEl);
|
|
this.tagManager = new TagManager();
|
|
this.episodeManager = new EpisodeManager();
|
|
this.searchManager = new SearchManager(this.searchInput, this.searchResultsEl);
|
|
this.progressManager = new ProgressManager(
|
|
this.progressContainer,
|
|
this.progressText,
|
|
this.progressCount
|
|
);
|
|
|
|
// Set up event listeners
|
|
this._setupEventListeners();
|
|
}
|
|
|
|
/**
|
|
* Set up all event listeners
|
|
* @private
|
|
*/
|
|
_setupEventListeners() {
|
|
// Select directory button
|
|
this.selectDirBtn.addEventListener('click', () => this._handleSelectDirectory());
|
|
|
|
// Search input with debounce
|
|
this.searchInput.addEventListener('input', this._debounce(() => this._handleSearch(), 300));
|
|
|
|
// Episode number editing
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('episode-number')) {
|
|
e.stopPropagation();
|
|
this.episodeManager.makeEpisodeRangeEditable(e.target);
|
|
}
|
|
});
|
|
|
|
// Begin mapping button
|
|
const beginMappingBtn = document.getElementById('begin-mapping-btn');
|
|
if (beginMappingBtn) {
|
|
beginMappingBtn.addEventListener('click', () => this._handleBeginMapping());
|
|
}
|
|
|
|
// Floating circle (play button) to move all tagged files
|
|
const taggedCircle = document.getElementById('tagged-circle');
|
|
if (taggedCircle) {
|
|
taggedCircle.addEventListener('click', () => this._handleMoveAllTaggedFiles());
|
|
}
|
|
|
|
// Listen for scan progress updates from main process
|
|
ipcRenderer.on('scan-progress', (event, { current, total, fileName }) => {
|
|
this.progressManager.updateProgress(current, total, fileName);
|
|
});
|
|
|
|
// Listen for auto-select directory message
|
|
window.addEventListener('message', (event) => {
|
|
if (event.data.type === 'auto-select-directory') {
|
|
this._handleAutoSelectDirectory(event.data.directory);
|
|
}
|
|
});
|
|
|
|
// Listen for auto-select directory from main process
|
|
ipcRenderer.on('auto-select-directory', (event, directory) => {
|
|
this._handleAutoSelectDirectory(directory);
|
|
});
|
|
|
|
// Initialize tagged count on page load
|
|
this._updateTaggedCount();
|
|
}
|
|
|
|
/**
|
|
* Handle select directory
|
|
* @private
|
|
*/
|
|
async _handleSelectDirectory() {
|
|
try {
|
|
const result = await this.fileManager.selectDirectory();
|
|
|
|
if (result.success) {
|
|
const directory = result.directory;
|
|
this.appState.setCurrentDirectory(directory);
|
|
this.selectedDirEl.textContent = `Selected: ${directory}`;
|
|
|
|
// Log audit event for directory selection
|
|
await this._logAuditEvent('select_directory', { directory: directory });
|
|
|
|
// Scan the directory for media files
|
|
await this._scanDirectory(directory);
|
|
} else {
|
|
throw new Error(result.error);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error selecting directory:', error);
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle auto-select directory
|
|
* @param {string} directory - Directory path
|
|
* @private
|
|
*/
|
|
async _handleAutoSelectDirectory(directory) {
|
|
try {
|
|
console.log('Auto-selecting directory:', directory);
|
|
|
|
this.appState.setCurrentDirectory(directory);
|
|
this.selectedDirEl.textContent = `Selected: ${directory}`;
|
|
|
|
// Log audit event for directory selection
|
|
await this._logAuditEvent('select_directory', { directory: directory });
|
|
|
|
// Scan the directory for media files
|
|
await this._scanDirectory(directory);
|
|
} catch (error) {
|
|
console.error('Error auto-selecting directory:', error);
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a specific directory
|
|
* @param {string} directory - Directory path
|
|
*/
|
|
async openDirectory(directory) {
|
|
try {
|
|
console.log('Opening directory:', directory);
|
|
|
|
// Set the current directory
|
|
this.appState.setCurrentDirectory(directory);
|
|
this.selectedDirEl.textContent = `Selected: ${directory}`;
|
|
|
|
// Log audit event for directory selection
|
|
await this._logAuditEvent('select_directory', { directory: directory });
|
|
|
|
// Scan the directory for media files
|
|
await this._scanDirectory(directory);
|
|
} catch (error) {
|
|
console.error('Error opening directory:', error);
|
|
alert(`Error: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle search
|
|
* @private
|
|
*/
|
|
async _handleSearch() {
|
|
const query = this.searchInput.value.trim();
|
|
await this.searchManager.searchShows(query);
|
|
}
|
|
|
|
/**
|
|
* Scan directory
|
|
* @param {string} directoryPath - Directory path
|
|
* @private
|
|
*/
|
|
async _scanDirectory(directoryPath) {
|
|
try {
|
|
// Show progress bar
|
|
this.progressManager.showProgress();
|
|
|
|
const result = await this.fileManager.scanDirectory(directoryPath);
|
|
|
|
// Hide progress bar
|
|
this.progressManager.hideProgress();
|
|
|
|
if (result.success) {
|
|
this.appState.setCurrentFiles(result.files);
|
|
this.fileListManager.displayFiles(result.files);
|
|
} else {
|
|
throw new Error(result.error);
|
|
}
|
|
} catch (error) {
|
|
this.progressManager.hideProgress();
|
|
console.error('Error scanning directory:', error);
|
|
alert(`Error scanning directory: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle begin mapping
|
|
* @private
|
|
*/
|
|
async _handleBeginMapping() {
|
|
const btn = document.getElementById('begin-mapping-btn');
|
|
|
|
// Get all media file items (not folders)
|
|
const fileItems = this.fileListManager.getMediaFileItems();
|
|
|
|
if (fileItems.length === 0) {
|
|
alert('No media files to map. Please select a directory first.');
|
|
return;
|
|
}
|
|
|
|
if (!this.appState.getCurrentDirectory()) {
|
|
alert('No directory selected.');
|
|
return;
|
|
}
|
|
|
|
// Disable button during processing
|
|
btn.disabled = true;
|
|
btn.textContent = 'Mapping...';
|
|
|
|
try {
|
|
// Collect file data with episode ranges from UI order
|
|
const filesToMap = this.fileManager.collectFileData(this.fileListEl);
|
|
|
|
// Get TVDB ID from current show if selected
|
|
const tvdbId = this.appState.getCurrentShow() ? this.appState.getCurrentShow().id : null;
|
|
|
|
const result = await this.fileManager.beginMapping(
|
|
this.appState.getCurrentDirectory(),
|
|
filesToMap,
|
|
tvdbId
|
|
);
|
|
|
|
if (result.success) {
|
|
alert('Mapping Complete!');
|
|
// Update currentDirectory if it changed (show folder was renamed)
|
|
const newDir = result.newDirectory || this.appState.getCurrentDirectory();
|
|
this.appState.setCurrentDirectory(newDir);
|
|
// Refresh the file list to show new names
|
|
await this.openDirectory(newDir);
|
|
} else {
|
|
alert('Mapping failed: ' + (result.error || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
console.error('Mapping error:', error);
|
|
alert('Mapping failed: ' + error.message);
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Begin Mapping';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle move all tagged files
|
|
* @private
|
|
*/
|
|
async _handleMoveAllTaggedFiles() {
|
|
await this.tagManager.moveAllTaggedFiles(
|
|
() => this._updateTaggedCount(),
|
|
() => this.fileListManager.updateEpisodeNumbers(),
|
|
() => this._checkEpisodeCountMatch()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update tagged count
|
|
* @private
|
|
*/
|
|
_updateTaggedCount() {
|
|
const taggedItems = document.querySelectorAll('.file-item[data-tagged-extra], .file-item[data-tagged-commentary], .file-item[data-tagged-delete]');
|
|
const count = taggedItems.length;
|
|
const taggedCircle = document.getElementById('tagged-circle');
|
|
const taggedCount = document.getElementById('tagged-count');
|
|
|
|
if (taggedCount) {
|
|
taggedCount.textContent = count;
|
|
}
|
|
|
|
// Animate the circle position based on count
|
|
if (taggedCircle) {
|
|
if (count === 0) {
|
|
// Move down out of sight
|
|
taggedCircle.style.transform = 'translateY(100px)';
|
|
taggedCircle.style.bottom = '-100px';
|
|
} else {
|
|
// Move up to show count (appears at the same spot at bottom right)
|
|
taggedCircle.style.transform = 'translateY(0)';
|
|
taggedCircle.style.bottom = '20px';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check episode count match
|
|
* @private
|
|
*/
|
|
_checkEpisodeCountMatch() {
|
|
// Count only actual media files (not folders)
|
|
const mediaFiles = this.fileListManager.getMediaFileItems();
|
|
|
|
// Calculate total episode range (sum of all episode ranges)
|
|
let totalEpisodeCount = 0;
|
|
let lastEpisodeEnd = 0;
|
|
|
|
mediaFiles.forEach((item, index) => {
|
|
const episodeEl = item.querySelector('.episode-number');
|
|
if (episodeEl) {
|
|
const episodeStart = parseInt(episodeEl.dataset.episodeStart || (index + 1));
|
|
const episodeEnd = parseInt(episodeEl.dataset.episodeEnd || episodeStart);
|
|
|
|
// Calculate episodes in this range
|
|
const rangeSize = episodeEnd - episodeStart + 1;
|
|
totalEpisodeCount += rangeSize;
|
|
|
|
// Track the last episode end for sequential checking
|
|
if (index === 0 || episodeEnd > lastEpisodeEnd) {
|
|
lastEpisodeEnd = episodeEnd;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Use lastEpisodeEnd for comparison (handles ranges properly)
|
|
const selectedSeasonEpisodeCount = this.appState.getSelectedSeasonEpisodeCount();
|
|
if (selectedSeasonEpisodeCount > 0 && lastEpisodeEnd === selectedSeasonEpisodeCount) {
|
|
// Perfect match - add green outline
|
|
this.fileListEl.style.border = '3px solid #28a745';
|
|
this.fileListEl.style.boxShadow = '0 0 15px rgba(40, 167, 69, 0.4)';
|
|
} else {
|
|
// No match or no season selected - remove green outline
|
|
this.fileListEl.style.border = '';
|
|
this.fileListEl.style.boxShadow = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Log audit event
|
|
* @param {string} action - Action type
|
|
* @param {Object} details - Event details
|
|
* @private
|
|
*/
|
|
async _logAuditEvent(action, details) {
|
|
const currentDirectory = this.appState.getCurrentDirectory();
|
|
if (currentDirectory) {
|
|
await this.fileManager.logAuditEvent(currentDirectory, action, details);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Debounce function
|
|
* @param {Function} func - Function to debounce
|
|
* @param {number} wait - Wait time in ms
|
|
* @returns {Function} Debounced function
|
|
* @private
|
|
*/
|
|
_debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Display created folder
|
|
* @param {string} folderName - Folder name
|
|
*/
|
|
displayCreatedFolder(folderName) {
|
|
// Check if folder entry already exists
|
|
const existingFolder = document.querySelector(`.folder-item[data-folder-name="${folderName}"]`);
|
|
if (existingFolder) {
|
|
// Update the file count
|
|
const countEl = existingFolder.querySelector('.folder-count');
|
|
if (countEl) {
|
|
const currentCount = parseInt(countEl.textContent) || 0;
|
|
countEl.textContent = currentCount + 1;
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Create folder item element
|
|
const folderItem = document.createElement('div');
|
|
folderItem.className = 'folder-item';
|
|
folderItem.setAttribute('data-folder-name', folderName);
|
|
|
|
let displayName, folderIcon, folderColor;
|
|
if (folderName === 'extra') {
|
|
displayName = 'extras';
|
|
folderIcon = '📁';
|
|
folderColor = '#FFD700';
|
|
} else if (folderName === 'commentary') {
|
|
displayName = 'commentary';
|
|
folderIcon = '💬';
|
|
folderColor = '#17a2b8';
|
|
} else if (folderName === 'delete') {
|
|
displayName = 'delete';
|
|
folderIcon = '🗑️';
|
|
folderColor = '#dc3545';
|
|
}
|
|
|
|
folderItem.innerHTML = `
|
|
<div class="folder-icon" style="font-size: 24px;">${folderIcon}</div>
|
|
<div class="folder-name" style="font-weight: bold; color: ${folderColor};">${displayName}</div>
|
|
<div class="folder-count" style="background-color: ${folderColor}; color: white; border-radius: 12px; padding: 2px 8px; font-size: 12px;">1</div>
|
|
`;
|
|
|
|
folderItem.style.cssText = `
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px;
|
|
margin-bottom: 8px;
|
|
background-color: #16213e;
|
|
border: 2px solid ${folderColor};
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
`;
|
|
|
|
// Insert at the top of the file list
|
|
if (this.fileListEl.firstChild) {
|
|
this.fileListEl.insertBefore(folderItem, this.fileListEl.firstChild);
|
|
} else {
|
|
this.fileListEl.appendChild(folderItem);
|
|
}
|
|
|
|
console.log(`Folder "${displayName}" displayed in UI`);
|
|
}
|
|
|
|
/**
|
|
* Make file name editable
|
|
* @param {HTMLElement} element - File name element
|
|
*/
|
|
async makeEditable(element) {
|
|
// Prevent editing if already in edit mode
|
|
if (element.contentEditable === 'true') return;
|
|
|
|
// Store original text
|
|
const originalText = element.textContent;
|
|
|
|
// Make element editable
|
|
element.contentEditable = 'true';
|
|
element.focus();
|
|
element.classList.add('editing');
|
|
|
|
// Select all text when editing starts
|
|
const range = document.createRange();
|
|
range.selectNodeContents(element);
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
|
|
// Handle saving when user finishes editing
|
|
const saveEdit = async () => {
|
|
if (element.textContent.trim() !== originalText.trim()) {
|
|
// Send request to main process to rename the file
|
|
try {
|
|
const result = await this.fileManager.renameFile(
|
|
element.dataset.filePath,
|
|
element.textContent.trim()
|
|
);
|
|
|
|
if (result.success) {
|
|
console.log('File renamed successfully:', result.message);
|
|
// Update the file name in the UI to reflect the change
|
|
element.textContent = element.textContent.trim();
|
|
|
|
// Also update the data attribute to reflect the new path
|
|
const oldPath = element.dataset.filePath;
|
|
const newPath = oldPath.substring(0, oldPath.lastIndexOf(path.sep) + 1) + element.textContent.trim();
|
|
element.dataset.filePath = newPath;
|
|
} else {
|
|
console.error('Failed to rename file:', result.error);
|
|
// Revert to original name on failure
|
|
element.textContent = originalText;
|
|
}
|
|
} catch (error) {
|
|
console.error('Error renaming file:', error);
|
|
// Revert to original name on error
|
|
element.textContent = originalText;
|
|
}
|
|
}
|
|
|
|
// Clean up
|
|
element.contentEditable = 'false';
|
|
element.classList.remove('editing');
|
|
};
|
|
|
|
// Save on Enter key or blur
|
|
element.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
saveEdit();
|
|
}
|
|
});
|
|
|
|
element.addEventListener('blur', saveEdit);
|
|
}
|
|
|
|
/**
|
|
* Select show and display details
|
|
* @param {Object} show - Show object
|
|
*/
|
|
async selectShow(show) {
|
|
await this.searchManager.selectShow(
|
|
show,
|
|
(title) => {
|
|
document.getElementById('show-title').textContent = title;
|
|
},
|
|
(seasonsOrHtml) => {
|
|
if (typeof seasonsOrHtml === 'string') {
|
|
document.getElementById('seasons-container').innerHTML = seasonsOrHtml;
|
|
} else {
|
|
this.searchManager.displaySeasons(seasonsOrHtml,
|
|
(seasonNumber) => this._handleSeasonSelect(show.id, seasonNumber),
|
|
() => this._handleBackToSeasons()
|
|
);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle season select
|
|
* @param {string} showId - Show ID
|
|
* @param {number} seasonNumber - Season number
|
|
*/
|
|
async _handleSeasonSelect(showId, seasonNumber) {
|
|
await this.searchManager.fetchAndDisplayEpisodes(
|
|
showId,
|
|
seasonNumber,
|
|
(episodes, error) => {
|
|
if (error) {
|
|
document.getElementById('seasons-container').innerHTML = `
|
|
<div style="padding: 10px; background-color: #f8d7da; color: #721c24; border-radius: 4px;">
|
|
<p>Error loading episodes: ${error}</p>
|
|
<p style="font-size: 12px; margin-top: 5px;">Note: This may be due to API limitations with the TVDB v4 API.</p>
|
|
</div>
|
|
`;
|
|
} else {
|
|
this.searchManager.displayEpisodes(
|
|
episodes,
|
|
episodes.length,
|
|
(count) => this.appState.setSelectedSeasonEpisodeCount(count),
|
|
(episodes) => this.searchManager.updateFileListWithEpisodeInfo(episodes, this.appState.getCurrentFiles())
|
|
);
|
|
this._checkEpisodeCountMatch();
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle back to seasons
|
|
*/
|
|
_handleBackToSeasons() {
|
|
this.appState.setSelectedSeasonEpisodeCount(0);
|
|
this._checkEpisodeCountMatch();
|
|
// Re-display seasons would happen here
|
|
}
|
|
|
|
/**
|
|
* Debug problematic file
|
|
* @param {string} filePath - File path
|
|
*/
|
|
async debugProblematicFile(filePath) {
|
|
try {
|
|
const result = await this.fileManager.logFileInfo(filePath);
|
|
if (result.success) {
|
|
console.log('File info:', result.fileInfo);
|
|
} else {
|
|
console.error('Failed to get file info:', result.error);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error debugging file:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = UIManager; |