MovieMapper/utils/renderer/FileListManager.js
Jarian Cottingham 73f5585488 security: harden Electron app - context isolation, preload, fix XSS & command injection
- Enable contextIsolation, disable nodeIntegration, remove enableRemoteModule
- Add preload.js with safe contextBridge API exposure
- Fix OS command injection: exec() → execFile() in open-file-in-player
- Fix DOM XSS: replace innerHTML with textContent/createElement in FileListManager, UIManager, ModalManager
- Remove direct fs/electron access from renderer, route through IPC
- Add validate-folder and get-file-stats IPC handlers
- Load renderer modules via script tags with require polyfill
2026-07-05 07:31:06 +00:00

412 lines
12 KiB
JavaScript

/**
* FileListManager - Handles file list display and manipulation
*/
class FileListManager {
constructor(fileListEl, onFolderClick = null) {
this.fileListEl = fileListEl;
this.draggedItem = null;
this.onFolderClick = onFolderClick || null;
this.hoveredEpisodeRange = null;
this._setupHoverHandlers();
}
/**
* Set callback for folder click events
* @param {Function} callback - Function to call when folder is clicked
*/
setFolderClickCallback(callback) {
this.onFolderClick = callback;
}
/**
* Display files in the UI
* @param {Array} files - Array of file objects
*/
displayFiles(files) {
// Re-setup hover handlers when displaying files
this._setupHoverHandlers();
this.fileListEl.innerHTML = '';
if (files.length === 0) {
this.fileListEl.innerHTML = '<p>No media files found in this directory.</p>';
return;
}
// Separate folders from media files
const folders = files.filter(f => f.isFolder);
const mediaFiles = files.filter(f => !f.isFolder);
// Track episode number for media files only
let episodeNumber = 1;
// Display folders first (not draggable, no episode number)
folders.forEach(file => {
this._createFolderElement(file);
});
// Display media files with episode numbers and drag/drop
mediaFiles.forEach((file, index) => {
this._createMediaFileElement(file, episodeNumber, index);
episodeNumber++;
});
}
/**
* Create folder element
* @param {Object} file - File object
* @private
*/
_createFolderElement(file) {
const fileItem = document.createElement('div');
fileItem.className = 'file-item folder-item';
const icon = document.createElement('div');
icon.style.cssText = 'font-size: 18px; margin-right: 10px;';
icon.textContent = '\uD83D\uDCC1';
const nameEl = document.createElement('div');
nameEl.className = 'file-name folder-name';
nameEl.textContent = file.name;
const durationEl = document.createElement('div');
durationEl.className = 'file-duration';
const qualityEl = document.createElement('div');
qualityEl.className = 'file-quality';
const fpsEl = document.createElement('div');
fpsEl.className = 'file-fps';
const tagsEl = document.createElement('div');
tagsEl.className = 'file-tags';
fileItem.append(icon, nameEl, durationEl, qualityEl, fpsEl, tagsEl);
const filePath = file.path;
const fileName = file.name;
fileItem.addEventListener('click', (e) => {
if (e.target !== fileItem && e.target.className !== 'file-name folder-name') {
return;
}
if (this.onFolderClick) {
this.onFolderClick(filePath, fileName);
}
});
this.fileListEl.appendChild(fileItem);
}
/**
* Create media file element
* @param {Object} file - File object
* @param {number} episodeNumber - Episode number
* @param {number} index - File index
*/
_createMediaFileElement(file, episodeNumber, index) {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
fileItem.draggable = true;
fileItem.dataset.index = index;
fileItem.dataset.filePath = file.path;
const duration = file.duration || '00:00';
const quality = file.quality || 'unknown';
const fps = file.fps || 'unknown';
const dragHandle = document.createElement('div');
dragHandle.className = 'drag-handle';
dragHandle.textContent = '\u22EE\u22EE';
const episodeContainer = document.createElement('div');
episodeContainer.className = 'episode-number-container';
const leftArrow = document.createElement('button');
leftArrow.className = 'episode-arrow episode-arrow-left';
leftArrow.title = 'Decrease episode range';
leftArrow.textContent = '\u25C0';
const episodeNum = document.createElement('div');
episodeNum.className = 'episode-number';
episodeNum.dataset.episodeStart = episodeNumber;
episodeNum.dataset.episodeEnd = episodeNumber;
episodeNum.textContent = episodeNumber;
const rightArrow = document.createElement('button');
rightArrow.className = 'episode-arrow episode-arrow-right';
rightArrow.title = 'Increase episode range';
rightArrow.textContent = '\u25B6';
episodeContainer.append(leftArrow, episodeNum, rightArrow);
const nameEl = document.createElement('div');
nameEl.className = 'file-name';
nameEl.textContent = file.name;
if (file.isProblematic) {
const warn = document.createElement('span');
warn.className = 'problematic-label';
warn.textContent = '\u26A0\ufe0f';
nameEl.prepend(warn, ' ');
}
const durEl = document.createElement('div');
durEl.className = 'file-duration';
durEl.textContent = duration;
const qualEl = document.createElement('div');
qualEl.className = 'file-quality';
qualEl.textContent = quality;
const fpsEl = document.createElement('div');
fpsEl.className = 'file-fps';
fpsEl.textContent = fps;
const tagsEl = document.createElement('div');
tagsEl.className = 'file-tags';
const extraTag = document.createElement('span');
extraTag.className = 'tag-icon extra-tag';
extraTag.title = 'Mark as Extra';
extraTag.textContent = '\uD83C\uDFF7\uFE0F';
const btsTag = document.createElement('span');
btsTag.className = 'tag-icon behind-the-scenes-tag';
btsTag.title = 'Add Behind the Scenes';
btsTag.textContent = '\uD83C\uDFA5';
const delTag = document.createElement('span');
delTag.className = 'tag-icon delete-tag';
delTag.title = 'Mark for Deletion';
delTag.textContent = '\uD83D\uDDD1\uFE0F';
const previewBtn = document.createElement('span');
previewBtn.className = 'video-preview-btn';
previewBtn.title = 'Preview Video';
previewBtn.textContent = '\uD83C\uDFAC';
const playBtn = document.createElement('button');
playBtn.className = 'play-button';
playBtn.style.cssText = 'opacity: 0.3; cursor: default; flex-shrink: 0;';
playBtn.disabled = true;
playBtn.textContent = '\u25B6\uFE0F';
tagsEl.append(extraTag, btsTag, delTag, previewBtn, playBtn);
fileItem.append(dragHandle, episodeContainer, nameEl, durEl, qualEl, fpsEl, tagsEl);
fileItem.addEventListener('dragstart', this._handleDragStart.bind(this));
fileItem.addEventListener('dragend', this._handleDragEnd.bind(this));
fileItem.addEventListener('dragover', this._handleDragOver.bind(this));
fileItem.addEventListener('dragleave', this._handleDragLeave.bind(this));
fileItem.addEventListener('drop', this._handleDrop.bind(this));
this.fileListEl.appendChild(fileItem);
}
/**
* Handle drag start
* @param {Event} e - Drag event
* @private
*/
_handleDragStart(e) {
const fileItem = this;
this.draggedItem = fileItem;
fileItem.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', fileItem.dataset.index);
}
/**
* Handle drag end
* @param {Event} e - Drag event
* @private
*/
_handleDragEnd(e) {
const fileItem = this;
fileItem.classList.remove('dragging');
// Remove drag-over class from all items
document.querySelectorAll('.file-item').forEach(item => {
item.classList.remove('drag-over');
});
this.draggedItem = null;
}
/**
* Handle drag over
* @param {Event} e - Drag event
* @private
*/
_handleDragOver(e) {
const fileItem = this;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
// Only show drag-over for non-folder items
if (!fileItem.classList.contains('folder-item') && fileItem !== this.draggedItem) {
fileItem.classList.add('drag-over');
}
}
/**
* Handle drag leave
* @param {Event} e - Drag event
* @private
*/
_handleDragLeave(e) {
const fileItem = this;
fileItem.classList.remove('drag-over');
}
/**
* Handle drop
* @param {Event} e - Drop event
*/
_handleDrop(e) {
const fileItem = this;
e.preventDefault();
fileItem.classList.remove('drag-over');
if (fileItem === this.draggedItem || fileItem.classList.contains('folder-item')) return;
// Get all media file items (not folders)
const fileItems = Array.from(this.fileListEl.querySelectorAll('.file-item:not(.folder-item)'));
const draggedIndex = fileItems.indexOf(this.draggedItem);
const dropIndex = fileItems.indexOf(fileItem);
if (draggedIndex === -1 || dropIndex === -1) return;
// Move the dragged item in the DOM
if (draggedIndex < dropIndex) {
fileItem.parentNode.insertBefore(this.draggedItem, fileItem.nextSibling);
} else {
fileItem.parentNode.insertBefore(this.draggedItem, fileItem);
}
// Update episode numbers
this.updateEpisodeNumbers();
}
/**
* Update episode numbers
*/
updateEpisodeNumbers() {
const fileItems = this.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;
}
});
}
/**
* Clear the file list
*/
clear() {
this.fileListEl.innerHTML = '';
}
/**
* Get all media file items
* @returns {NodeList} Media file items
*/
getMediaFileItems() {
return this.fileListEl.querySelectorAll('.file-item:not(.folder-item)');
}
/**
* Get folder items
* @returns {NodeList} Folder items
*/
getFolderItems() {
return this.fileListEl.querySelectorAll('.folder-item');
}
/**
* Set up hover handlers for file items
* @private
*/
_setupHoverHandlers() {
// Use event delegation on the file list
this.fileListEl.addEventListener('mouseenter', (e) => {
const fileItem = e.target.closest('.file-item');
if (!fileItem || fileItem.classList.contains('folder-item')) return;
const episodeEl = fileItem.querySelector('.episode-number');
if (!episodeEl) return;
const startEpisode = parseInt(episodeEl.dataset.episodeStart) || 1;
const endEpisode = parseInt(episodeEl.dataset.episodeEnd) || startEpisode;
// Store hovered range for later use
this.hoveredEpisodeRange = { start: startEpisode, end: endEpisode };
// Notify UIManager to highlight episodes
if (this.onHoverStart && typeof this.onHoverStart === 'function') {
this.onHoverStart(startEpisode, endEpisode, fileItem);
}
});
this.fileListEl.addEventListener('mouseleave', (e) => {
const fileItem = e.target.closest('.file-item');
if (!fileItem) return;
// Remove from current file item
fileItem.classList.remove('hovered-file');
// Notify UIManager to remove highlights
if (this.onHoverEnd && typeof this.onHoverEnd === 'function') {
this.onHoverEnd(fileItem);
}
});
}
/**
* Highlight files matching episode range
* @param {number} startEpisode - Start episode number
* @param {number} endEpisode - End episode number
*/
highlightFilesMatchingEpisode(startEpisode, endEpisode) {
const mediaFiles = this.getMediaFileItems();
mediaFiles.forEach(fileItem => {
const episodeEl = fileItem.querySelector('.episode-number');
if (!episodeEl) return;
const fileStart = parseInt(episodeEl.dataset.episodeStart) || 1;
const fileEnd = parseInt(episodeEl.dataset.episodeEnd) || fileStart;
// Check if the file's episode range overlaps with the hovered range
const overlaps = !(endEpisode < fileStart || startEpisode > fileEnd);
if (overlaps) {
fileItem.classList.add('hovered-file');
} else {
fileItem.classList.remove('hovered-file');
}
});
}
/**
* Remove file highlights
*/
removeFileHighlights() {
const mediaFiles = this.getMediaFileItems();
mediaFiles.forEach(fileItem => {
fileItem.classList.remove('hovered-file');
});
}
}
module.exports = FileListManager;