637 lines
17 KiB
JavaScript
637 lines
17 KiB
JavaScript
/**
|
|
* Utility functions for Transmission RSS Manager
|
|
*/
|
|
|
|
/**
|
|
* Format a byte value to a human-readable string
|
|
* @param {number} bytes - Bytes to format
|
|
* @param {number} decimals - Number of decimal places to show
|
|
* @returns {string} - Formatted string (e.g., "1.5 MB")
|
|
*/
|
|
export function formatBytes(bytes, decimals = 2) {
|
|
if (bytes === 0) return '0 Bytes';
|
|
|
|
const k = 1024;
|
|
const dm = decimals < 0 ? 0 : decimals;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
|
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
}
|
|
|
|
/**
|
|
* Create a debounced version of a function
|
|
* @param {Function} func - Function to debounce
|
|
* @param {number} wait - Milliseconds to wait
|
|
* @returns {Function} - Debounced function
|
|
*/
|
|
export function debounce(func, wait) {
|
|
let timeout;
|
|
return function(...args) {
|
|
const context = this;
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(() => func.apply(context, args), wait);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a throttled version of a function
|
|
* @param {Function} func - Function to throttle
|
|
* @param {number} limit - Milliseconds to throttle
|
|
* @returns {Function} - Throttled function
|
|
*/
|
|
export function throttle(func, limit) {
|
|
let inThrottle;
|
|
return function(...args) {
|
|
const context = this;
|
|
if (!inThrottle) {
|
|
func.apply(context, args);
|
|
inThrottle = true;
|
|
setTimeout(() => { inThrottle = false; }, limit);
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Safely parse JSON with error handling
|
|
* @param {string} json - JSON string to parse
|
|
* @param {*} fallback - Fallback value if parsing fails
|
|
* @returns {*} - Parsed object or fallback
|
|
*/
|
|
export function safeJsonParse(json, fallback = {}) {
|
|
try {
|
|
return JSON.parse(json);
|
|
} catch (e) {
|
|
console.error('Error parsing JSON:', e);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Escape HTML special characters
|
|
* @param {string} html - String potentially containing HTML
|
|
* @returns {string} - Escaped string
|
|
*/
|
|
export function escapeHtml(html) {
|
|
if (!html) return '';
|
|
const entities = {
|
|
'&': '&',
|
|
'<': '<',
|
|
'>': '>',
|
|
'"': '"',
|
|
"'": ''',
|
|
'/': '/'
|
|
};
|
|
return String(html).replace(/[&<>"'/]/g, match => entities[match]);
|
|
}
|
|
|
|
/**
|
|
* Get URL query parameters as an object
|
|
* @returns {Object} - Object containing query parameters
|
|
*/
|
|
export function getQueryParams() {
|
|
const params = {};
|
|
new URLSearchParams(window.location.search).forEach((value, key) => {
|
|
params[key] = value;
|
|
});
|
|
return params;
|
|
}
|
|
|
|
/**
|
|
* Add query parameters to a URL
|
|
* @param {string} url - Base URL
|
|
* @param {Object} params - Parameters to add
|
|
* @returns {string} - URL with parameters
|
|
*/
|
|
export function addQueryParams(url, params) {
|
|
const urlObj = new URL(url, window.location.origin);
|
|
Object.keys(params).forEach(key => {
|
|
if (params[key] !== null && params[key] !== undefined) {
|
|
urlObj.searchParams.append(key, params[key]);
|
|
}
|
|
});
|
|
return urlObj.toString();
|
|
}
|
|
|
|
/**
|
|
* Create a simple hash of a string
|
|
* @param {string} str - String to hash
|
|
* @returns {number} - Numeric hash
|
|
*/
|
|
export function simpleHash(str) {
|
|
let hash = 0;
|
|
if (str.length === 0) return hash;
|
|
|
|
for (let i = 0; i < str.length; i++) {
|
|
const char = str.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash; // Convert to 32bit integer
|
|
}
|
|
|
|
return hash;
|
|
}
|
|
|
|
/**
|
|
* Generate a random string of specified length
|
|
* @param {number} length - Length of the string
|
|
* @returns {string} - Random string
|
|
*/
|
|
export function randomString(length = 8) {
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
let result = '';
|
|
for (let i = 0; i < length; i++) {
|
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Format a date to a readable string
|
|
* @param {string|Date} date - Date to format
|
|
* @param {boolean} includeTime - Whether to include time
|
|
* @returns {string} - Formatted date string
|
|
*/
|
|
export function formatDate(date, includeTime = false) {
|
|
try {
|
|
const d = new Date(date);
|
|
const options = {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
...(includeTime ? { hour: '2-digit', minute: '2-digit' } : {})
|
|
};
|
|
return d.toLocaleDateString(undefined, options);
|
|
} catch (e) {
|
|
console.error('Error formatting date:', e);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a date is today
|
|
* @param {string|Date} date - Date to check
|
|
* @returns {boolean} - True if date is today
|
|
*/
|
|
export function isToday(date) {
|
|
const d = new Date(date);
|
|
const today = new Date();
|
|
return d.getDate() === today.getDate() &&
|
|
d.getMonth() === today.getMonth() &&
|
|
d.getFullYear() === today.getFullYear();
|
|
}
|
|
|
|
/**
|
|
* Get file extension from path
|
|
* @param {string} path - File path
|
|
* @returns {string} - File extension
|
|
*/
|
|
export function getFileExtension(path) {
|
|
if (!path) return '';
|
|
return path.split('.').pop().toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Check if file is an image based on extension
|
|
* @param {string} path - File path
|
|
* @returns {boolean} - True if file is an image
|
|
*/
|
|
export function isImageFile(path) {
|
|
const ext = getFileExtension(path);
|
|
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'].includes(ext);
|
|
}
|
|
|
|
/**
|
|
* Check if file is a video based on extension
|
|
* @param {string} path - File path
|
|
* @returns {boolean} - True if file is a video
|
|
*/
|
|
export function isVideoFile(path) {
|
|
const ext = getFileExtension(path);
|
|
return ['mp4', 'mkv', 'avi', 'mov', 'webm', 'wmv', 'flv', 'm4v'].includes(ext);
|
|
}
|
|
|
|
/**
|
|
* Check if file is an audio file based on extension
|
|
* @param {string} path - File path
|
|
* @returns {boolean} - True if file is audio
|
|
*/
|
|
export function isAudioFile(path) {
|
|
const ext = getFileExtension(path);
|
|
return ['mp3', 'wav', 'ogg', 'flac', 'm4a', 'aac'].includes(ext);
|
|
}
|
|
|
|
/**
|
|
* Extract base filename without extension
|
|
* @param {string} path - File path
|
|
* @returns {string} - Base filename
|
|
*/
|
|
export function getBaseName(path) {
|
|
if (!path) return '';
|
|
const fileName = path.split('/').pop();
|
|
return fileName.substring(0, fileName.lastIndexOf('.')) || fileName;
|
|
}
|
|
|
|
/**
|
|
* Copy text to clipboard
|
|
* @param {string} text - Text to copy
|
|
* @returns {Promise<boolean>} - Success status
|
|
*/
|
|
export async function copyToClipboard(text) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
return true;
|
|
} catch (err) {
|
|
console.error('Failed to copy text: ', err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Download data as a file
|
|
* @param {string} content - Content to download
|
|
* @param {string} fileName - Name of the file
|
|
* @param {string} contentType - MIME type of the file
|
|
*/
|
|
export function downloadFile(content, fileName, contentType = 'text/plain') {
|
|
const a = document.createElement('a');
|
|
const file = new Blob([content], { type: contentType });
|
|
a.href = URL.createObjectURL(file);
|
|
a.download = fileName;
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
}
|
|
|
|
/**
|
|
* Sort array of objects by a property
|
|
* @param {Array} array - Array to sort
|
|
* @param {string} property - Property to sort by
|
|
* @param {boolean} ascending - Sort direction
|
|
* @returns {Array} - Sorted array
|
|
*/
|
|
export function sortArrayByProperty(array, property, ascending = true) {
|
|
const sortFactor = ascending ? 1 : -1;
|
|
return [...array].sort((a, b) => {
|
|
if (a[property] < b[property]) return -1 * sortFactor;
|
|
if (a[property] > b[property]) return 1 * sortFactor;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter array by a search term across multiple properties
|
|
* @param {Array} array - Array to filter
|
|
* @param {string} search - Search term
|
|
* @param {Array<string>} properties - Properties to search in
|
|
* @returns {Array} - Filtered array
|
|
*/
|
|
export function filterArrayBySearch(array, search, properties) {
|
|
if (!search || !properties || properties.length === 0) return array;
|
|
|
|
const term = search.toLowerCase();
|
|
return array.filter(item => {
|
|
return properties.some(prop => {
|
|
const value = item[prop];
|
|
if (typeof value === 'string') {
|
|
return value.toLowerCase().includes(term);
|
|
}
|
|
return false;
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Deep clone an object
|
|
* @param {Object} obj - Object to clone
|
|
* @returns {Object} - Cloned object
|
|
*/
|
|
export function deepClone(obj) {
|
|
if (!obj) return obj;
|
|
return JSON.parse(JSON.stringify(obj));
|
|
}
|
|
|
|
/**
|
|
* Get readable torrent status
|
|
* @param {number} status - Transmission status code
|
|
* @returns {string} - Human-readable status
|
|
*/
|
|
export function getTorrentStatus(status) {
|
|
const statusMap = {
|
|
0: 'Stopped',
|
|
1: 'Check Waiting',
|
|
2: 'Checking',
|
|
3: 'Download Waiting',
|
|
4: 'Downloading',
|
|
5: 'Seed Waiting',
|
|
6: 'Seeding'
|
|
};
|
|
|
|
return statusMap[status] || 'Unknown';
|
|
}
|
|
|
|
/**
|
|
* Get appropriate CSS class for a torrent status badge
|
|
* @param {number} status - Torrent status code
|
|
* @returns {string} - CSS class
|
|
*/
|
|
export function getBadgeClassForStatus(status) {
|
|
switch (status) {
|
|
case 0: return 'badge-danger'; // Stopped
|
|
case 1: case 2: case 3: return 'badge-warning'; // Checking/Waiting
|
|
case 4: return 'badge-primary'; // Downloading
|
|
case 5: case 6: return 'badge-success'; // Seeding
|
|
default: return 'badge-secondary';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get appropriate CSS class for a torrent progress bar
|
|
* @param {number} status - Torrent status code
|
|
* @returns {string} - CSS class
|
|
*/
|
|
export function getProgressBarClassForStatus(status) {
|
|
switch (status) {
|
|
case 0: return 'bg-danger'; // Stopped
|
|
case 4: return 'bg-primary'; // Downloading
|
|
case 5: case 6: return 'bg-success'; // Seeding
|
|
default: return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get cookie value by name
|
|
* @param {string} name - Cookie name
|
|
* @returns {string|null} - Cookie value or null
|
|
*/
|
|
export function getCookie(name) {
|
|
const value = `; ${document.cookie}`;
|
|
const parts = value.split(`; ${name}=`);
|
|
if (parts.length === 2) return parts.pop().split(';').shift();
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Set a cookie
|
|
* @param {string} name - Cookie name
|
|
* @param {string} value - Cookie value
|
|
* @param {number} days - Days until expiry
|
|
*/
|
|
export function setCookie(name, value, days = 30) {
|
|
const date = new Date();
|
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
|
const expires = `expires=${date.toUTCString()}`;
|
|
document.cookie = `${name}=${value};${expires};path=/;SameSite=Strict`;
|
|
}
|
|
|
|
/**
|
|
* Delete a cookie
|
|
* @param {string} name - Cookie name
|
|
*/
|
|
export function deleteCookie(name) {
|
|
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Strict`;
|
|
}
|
|
|
|
/**
|
|
* Handle common API response with error checking
|
|
* @param {Response} response - Fetch API response
|
|
* @returns {Promise} - Resolves to response data
|
|
*/
|
|
export function handleApiResponse(response) {
|
|
if (!response.ok) {
|
|
// Try to get error message from response
|
|
return response.json()
|
|
.then(data => {
|
|
throw new Error(data.message || `HTTP error ${response.status}`);
|
|
})
|
|
.catch(e => {
|
|
// If JSON parsing fails, throw generic error
|
|
if (e instanceof SyntaxError) {
|
|
throw new Error(`HTTP error ${response.status}`);
|
|
}
|
|
throw e;
|
|
});
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
/**
|
|
* Encrypt a string using AES (for client-side only, not secure)
|
|
* @param {string} text - Text to encrypt
|
|
* @param {string} key - Encryption key
|
|
* @returns {string} - Encrypted text
|
|
*/
|
|
export function encrypt(text, key) {
|
|
// This is a simple XOR "encryption" - NOT SECURE!
|
|
// Only for basic obfuscation
|
|
let result = '';
|
|
for (let i = 0; i < text.length; i++) {
|
|
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
|
}
|
|
return btoa(result); // Base64 encode
|
|
}
|
|
|
|
/**
|
|
* Decrypt a string encrypted with the encrypt function
|
|
* @param {string} encrypted - Encrypted text
|
|
* @param {string} key - Encryption key
|
|
* @returns {string} - Decrypted text
|
|
*/
|
|
export function decrypt(encrypted, key) {
|
|
try {
|
|
const text = atob(encrypted); // Base64 decode
|
|
let result = '';
|
|
for (let i = 0; i < text.length; i++) {
|
|
result += String.fromCharCode(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
|
|
}
|
|
return result;
|
|
} catch (e) {
|
|
console.error('Decryption error:', e);
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the title display name for a media category
|
|
* @param {string} category - Category key
|
|
* @returns {string} - Formatted category title
|
|
*/
|
|
export function getCategoryTitle(category) {
|
|
switch(category) {
|
|
case 'movies': return 'Movies';
|
|
case 'tvShows': return 'TV Shows';
|
|
case 'music': return 'Music';
|
|
case 'books': return 'Books';
|
|
case 'magazines': return 'Magazines';
|
|
case 'software': return 'Software';
|
|
default: return category.charAt(0).toUpperCase() + category.slice(1);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for an element to exist in the DOM
|
|
* @param {string} selector - CSS selector
|
|
* @param {number} timeout - Timeout in milliseconds
|
|
* @returns {Promise<Element>} - Element when found
|
|
*/
|
|
export function waitForElement(selector, timeout = 5000) {
|
|
return new Promise((resolve, reject) => {
|
|
const element = document.querySelector(selector);
|
|
if (element) return resolve(element);
|
|
|
|
const observer = new MutationObserver((mutations) => {
|
|
const element = document.querySelector(selector);
|
|
if (element) {
|
|
observer.disconnect();
|
|
resolve(element);
|
|
}
|
|
});
|
|
|
|
observer.observe(document.body, {
|
|
childList: true,
|
|
subtree: true
|
|
});
|
|
|
|
setTimeout(() => {
|
|
observer.disconnect();
|
|
reject(new Error(`Element ${selector} not found within ${timeout}ms`));
|
|
}, timeout);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a notification message
|
|
* @param {string} message - Message to display
|
|
* @param {string} type - Type of notification (success, danger, warning, info)
|
|
* @param {number} duration - Display duration in milliseconds
|
|
*/
|
|
export function showNotification(message, type = 'info', duration = 5000) {
|
|
// Create notifications container if it doesn't exist
|
|
let container = document.getElementById('notifications-container');
|
|
if (!container) {
|
|
container = document.createElement('div');
|
|
container.id = 'notifications-container';
|
|
container.style.position = 'fixed';
|
|
container.style.top = '20px';
|
|
container.style.right = '20px';
|
|
container.style.zIndex = '1060';
|
|
document.body.appendChild(container);
|
|
}
|
|
|
|
// Create notification element
|
|
const notification = document.createElement('div');
|
|
notification.className = `alert alert-${type}`;
|
|
notification.innerHTML = message;
|
|
notification.style.opacity = '0';
|
|
notification.style.transform = 'translateY(-20px)';
|
|
notification.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
|
|
|
|
container.appendChild(notification);
|
|
|
|
// Fade in
|
|
setTimeout(() => {
|
|
notification.style.opacity = '1';
|
|
notification.style.transform = 'translateY(0)';
|
|
}, 10);
|
|
|
|
// Auto-remove after the specified duration
|
|
setTimeout(() => {
|
|
notification.style.opacity = '0';
|
|
notification.style.transform = 'translateY(-20px)';
|
|
|
|
setTimeout(() => {
|
|
notification.remove();
|
|
}, 300);
|
|
}, duration);
|
|
}
|
|
|
|
/**
|
|
* Create authorization headers for API requests
|
|
* @param {string} token - Auth token
|
|
* @returns {Object} - Headers object
|
|
*/
|
|
export function createAuthHeaders(token) {
|
|
return token ? {
|
|
'Authorization': `Bearer ${token}`
|
|
} : {};
|
|
}
|
|
|
|
/**
|
|
* Validate common input types
|
|
*/
|
|
export const validator = {
|
|
/**
|
|
* Validate email
|
|
* @param {string} email - Email to validate
|
|
* @returns {boolean} - True if valid
|
|
*/
|
|
isEmail: (email) => {
|
|
const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
|
return re.test(String(email).toLowerCase());
|
|
},
|
|
|
|
/**
|
|
* Validate URL
|
|
* @param {string} url - URL to validate
|
|
* @returns {boolean} - True if valid
|
|
*/
|
|
isUrl: (url) => {
|
|
try {
|
|
new URL(url);
|
|
return true;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Validate number
|
|
* @param {string|number} value - Value to validate
|
|
* @returns {boolean} - True if valid
|
|
*/
|
|
isNumeric: (value) => {
|
|
return !isNaN(parseFloat(value)) && isFinite(value);
|
|
},
|
|
|
|
/**
|
|
* Validate field is not empty
|
|
* @param {string} value - Value to validate
|
|
* @returns {boolean} - True if not empty
|
|
*/
|
|
isRequired: (value) => {
|
|
return value !== null && value !== undefined && value !== '';
|
|
},
|
|
|
|
/**
|
|
* Validate file path
|
|
* @param {string} path - Path to validate
|
|
* @returns {boolean} - True if valid
|
|
*/
|
|
isValidPath: (path) => {
|
|
// Simple path validation - should start with / for Unix-like systems
|
|
return /^(\/[\w.-]+)+\/?$/.test(path);
|
|
},
|
|
|
|
/**
|
|
* Validate password complexity
|
|
* @param {string} password - Password to validate
|
|
* @returns {boolean} - True if valid
|
|
*/
|
|
isStrongPassword: (password) => {
|
|
return password && password.length >= 8 &&
|
|
/[A-Z]/.test(password) &&
|
|
/[a-z]/.test(password) &&
|
|
/[0-9]/.test(password);
|
|
},
|
|
|
|
/**
|
|
* Validate a value is in range
|
|
* @param {number} value - Value to validate
|
|
* @param {number} min - Minimum value
|
|
* @param {number} max - Maximum value
|
|
* @returns {boolean} - True if in range
|
|
*/
|
|
isInRange: (value, min, max) => {
|
|
const num = parseFloat(value);
|
|
return !isNaN(num) && num >= min && num <= max;
|
|
}
|
|
}; |