1432 lines
54 KiB
JavaScript
1432 lines
54 KiB
JavaScript
// WebSocket and application logic for BDFR Web Interface
|
|
|
|
class BDFRApp {
|
|
constructor() {
|
|
this.ws = null;
|
|
this.reconnectAttempts = 0;
|
|
this.maxReconnectAttempts = 5;
|
|
this.reconnectDelay = 1000;
|
|
this.downloads = new Map();
|
|
this.scheduledTasks = new Map();
|
|
this.authState = null;
|
|
this.authenticated = false;
|
|
|
|
this.initializeElements();
|
|
this.bindEvents();
|
|
this.connectWebSocket();
|
|
this.updateStatus();
|
|
this.startStatusPolling();
|
|
|
|
// Check for stored auth state first
|
|
this.authState = this.getStoredAuthState();
|
|
this.checkAuthentication();
|
|
|
|
// Load scheduled tasks
|
|
this.loadScheduledTasks();
|
|
this.startQueuePolling();
|
|
}
|
|
|
|
initializeElements() {
|
|
// Forms
|
|
this.unifiedForm = document.getElementById('unifiedForm');
|
|
this.subredditForm = document.getElementById('subredditForm');
|
|
this.userForm = document.getElementById('userForm');
|
|
|
|
// Progress containers
|
|
this.progressContainer = document.getElementById('progressContainer');
|
|
this.downloadsList = document.getElementById('downloadsList');
|
|
this.downloadsItems = document.getElementById('downloadsItems');
|
|
|
|
// Scheduled tasks containers
|
|
this.scheduledContainer = document.getElementById('scheduledContainer');
|
|
this.scheduledList = document.getElementById('scheduledList');
|
|
this.scheduledItems = document.getElementById('scheduledItems');
|
|
this.queueStatus = document.getElementById('queueStatus');
|
|
this.queueCount = document.getElementById('queueCount');
|
|
|
|
// Status elements
|
|
this.wsStatus = document.getElementById('wsStatus');
|
|
this.bdfrStatus = document.getElementById('bdfrStatus');
|
|
|
|
// Authentication elements
|
|
this.authSection = document.getElementById('authSection');
|
|
this.authUser = document.getElementById('authUser');
|
|
this.authStatus = document.getElementById('authStatus');
|
|
this.loginBtn = document.getElementById('loginBtn');
|
|
this.logoutBtn = document.getElementById('logoutBtn');
|
|
this.authStateInput = document.getElementById('authState');
|
|
this.userAuthStateInput = document.getElementById('userAuthState');
|
|
|
|
// User downloads section
|
|
this.userDownloadsSection = document.getElementById('userDownloadsSection');
|
|
this.downloadLikesBtn = document.getElementById('downloadLikesBtn');
|
|
this.downloadSavedBtn = document.getElementById('downloadSavedBtn');
|
|
this.userScheduleOptions = document.getElementById('userScheduleOptions');
|
|
this.userRunTimeInput = document.getElementById('userRunTime');
|
|
|
|
// Scheduled task form elements
|
|
this.runDailyCheckbox = document.getElementById('runDaily');
|
|
this.scheduleOptions = document.getElementById('scheduleOptions');
|
|
this.taskNameInput = document.getElementById('taskName');
|
|
this.runTimeInput = document.getElementById('runTime');
|
|
}
|
|
|
|
bindEvents() {
|
|
// Form submissions
|
|
if (this.unifiedForm) {
|
|
this.unifiedForm.addEventListener('submit', (e) => this.handleUnifiedSubmit(e));
|
|
|
|
// Source type toggle
|
|
const sourceTypeRadios = this.unifiedForm.querySelectorAll('input[name="source_type"]');
|
|
sourceTypeRadios.forEach(radio => {
|
|
radio.addEventListener('change', (e) => this.updateSourceTypeUI(e.target.value));
|
|
});
|
|
}
|
|
|
|
// Run Daily checkbox toggle
|
|
if (this.runDailyCheckbox) {
|
|
this.runDailyCheckbox.addEventListener('change', (e) => {
|
|
this.scheduleOptions.style.display = e.target.checked ? 'block' : 'none';
|
|
if (e.target.checked && !this.taskNameInput.value) {
|
|
// Auto-generate task name
|
|
const sourceType = document.querySelector('input[name="source_type"]:checked').value;
|
|
const sourceName = document.getElementById('sourceName').value.trim();
|
|
if (sourceName) {
|
|
this.taskNameInput.value = `Daily ${sourceName} ${sourceType}`;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (this.subredditForm) {
|
|
this.subredditForm.addEventListener('submit', (e) => this.handleSubredditSubmit(e));
|
|
}
|
|
if (this.userForm) {
|
|
this.userForm.addEventListener('submit', (e) => this.handleUserSubmit(e));
|
|
}
|
|
|
|
// Authentication events
|
|
if (this.loginBtn) {
|
|
this.loginBtn.addEventListener('click', () => this.handleLogin());
|
|
}
|
|
if (this.logoutBtn) {
|
|
this.logoutBtn.addEventListener('click', () => this.handleLogout());
|
|
}
|
|
|
|
// User downloads events
|
|
if (this.downloadLikesBtn) {
|
|
this.downloadLikesBtn.addEventListener('click', () => this.handleDownloadLikes());
|
|
}
|
|
if (this.downloadSavedBtn) {
|
|
this.downloadSavedBtn.addEventListener('click', () => this.handleDownloadSaved());
|
|
}
|
|
|
|
// User schedule toggle
|
|
const userScheduleRadios = document.querySelectorAll('input[name="user_schedule_type"]');
|
|
userScheduleRadios.forEach(radio => {
|
|
radio.addEventListener('change', (e) => this.updateUserScheduleUI(e.target.value));
|
|
});
|
|
// Real-time input validation
|
|
['subreddit', 'username', 'sourceName'].forEach(id => {
|
|
const input = document.getElementById(id);
|
|
if (input) {
|
|
input.addEventListener('input', (e) => this.validateInput(e.target));
|
|
}
|
|
});
|
|
}
|
|
|
|
async connectWebSocket() {
|
|
try {
|
|
// Force HTTP for development - change this for production
|
|
const protocol = 'ws:';
|
|
const host = window.location.hostname || 'localhost';
|
|
const port = window.location.port || '8000';
|
|
const wsUrl = `${protocol}//${host}:${port}/ws/progress`;
|
|
|
|
console.log('[FRONTEND-WS] Attempting to connect to WebSocket:', wsUrl);
|
|
console.log('[FRONTEND-WS] Window location:', window.location.href);
|
|
console.log('[FRONTEND-WS] Host:', window.location.host);
|
|
console.log('[FRONTEND-WS] Hostname:', window.location.hostname);
|
|
console.log('[FRONTEND-WS] Port:', window.location.port);
|
|
|
|
this.ws = new WebSocket(wsUrl);
|
|
|
|
this.ws.onopen = () => {
|
|
console.log('[FRONTEND-WS] WebSocket connected successfully');
|
|
this.reconnectAttempts = 0;
|
|
this.updateWSStatus('Connected');
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
console.log('[FRONTEND-WS] Received message:', event.data);
|
|
const data = JSON.parse(event.data);
|
|
this.handleWebSocketMessage(data);
|
|
};
|
|
|
|
this.ws.onclose = (event) => {
|
|
console.log('[FRONTEND-WS] WebSocket disconnected:', event.code, event.reason);
|
|
this.updateWSStatus('Disconnected');
|
|
this.scheduleReconnect();
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error('[FRONTEND-WS] WebSocket error:', error);
|
|
this.updateWSStatus('Error');
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Failed to connect WebSocket:', error);
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
scheduleReconnect() {
|
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
this.reconnectAttempts++;
|
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
|
|
console.log(`[FRONTEND-WS] Scheduling reconnect attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`);
|
|
|
|
setTimeout(() => {
|
|
console.log(`[FRONTEND-WS] Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
|
this.connectWebSocket();
|
|
}, delay);
|
|
} else {
|
|
console.error('[FRONTEND-WS] Failed to connect after maximum attempts');
|
|
this.updateWSStatus('Failed to connect');
|
|
}
|
|
}
|
|
|
|
handleWebSocketMessage(data) {
|
|
console.log('WebSocket message received:', data);
|
|
|
|
// Ensure data has the required fields
|
|
if (!data.download_id && data.id) {
|
|
data.download_id = data.id;
|
|
}
|
|
|
|
switch (data.type) {
|
|
case 'progress':
|
|
case 'status_update':
|
|
this.updateProgress(data);
|
|
break;
|
|
case 'status':
|
|
this.showNotification(`Download ${data.download_id}: ${data.message}`, 'info');
|
|
break;
|
|
case 'completed':
|
|
this.showSuccess(`Download ${data.download_id} completed!`);
|
|
this.updateProgress(data); // Also update progress for completion
|
|
break;
|
|
case 'error':
|
|
this.showError(`Download ${data.download_id} failed: ${data.message}`);
|
|
this.updateProgress(data); // Also update progress for errors
|
|
break;
|
|
default:
|
|
console.log('Unknown message type:', data.type, data);
|
|
}
|
|
}
|
|
|
|
async handleSubredditSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
const subreddit = formData.get('subreddit').trim();
|
|
|
|
if (!this.validateSubreddit(subreddit)) {
|
|
this.showError('Please enter a valid subreddit name');
|
|
return;
|
|
}
|
|
|
|
// Get no_dupes checkbox value and add it to formData
|
|
const noDupes = document.getElementById('noDupes').checked;
|
|
formData.set('no_dupes', noDupes ? 'true' : 'false');
|
|
|
|
// Show additional options for confirmation
|
|
const limit = formData.get('limit');
|
|
const sort = formData.get('sort');
|
|
|
|
if (confirm(`Start download for r/${subreddit}?\n\nOptions:\n- Limit: ${limit}\n- Sort: ${sort}\n- No duplicates: ${noDupes ? 'Yes' : 'No'}`)) {
|
|
try {
|
|
this.showLoading(e.target.querySelector('button'));
|
|
|
|
const response = await fetch('/api/download/subreddit', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.showSuccess(`Subreddit download started! ID: ${result.download_id}`);
|
|
e.target.reset();
|
|
} else {
|
|
this.showError(result.detail || 'Failed to start download');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
this.showError('Network error occurred');
|
|
} finally {
|
|
this.hideLoading(e.target.querySelector('button'));
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleUserSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
const username = formData.get('username').trim();
|
|
|
|
if (!this.validateUsername(username)) {
|
|
this.showError('Please enter a valid username');
|
|
return;
|
|
}
|
|
|
|
// Get no_dupes checkbox value and add it to formData
|
|
const noDupes = document.getElementById('userNoDupes').checked;
|
|
formData.set('no_dupes', noDupes ? 'true' : 'false');
|
|
|
|
// Get content type for confirmation
|
|
const contentType = formData.get('content_type');
|
|
const contentTypeLabel = contentType === 'submitted' ? 'submitted posts' :
|
|
contentType === 'upvoted' ? 'upvoted posts' : 'saved posts';
|
|
|
|
if (confirm(`Start download for u/${username}?\n\nContent: ${contentTypeLabel}\nLimit: ${formData.get('limit')}`)) {
|
|
try {
|
|
this.showLoading(e.target.querySelector('button'));
|
|
|
|
const response = await fetch('/api/download/user', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.showSuccess(`User download started! ID: ${result.download_id}`);
|
|
e.target.reset();
|
|
} else {
|
|
this.showError(result.detail || 'Failed to start download');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
this.showError('Network error occurred');
|
|
} finally {
|
|
this.hideLoading(e.target.querySelector('button'));
|
|
}
|
|
}
|
|
}
|
|
|
|
updateSourceTypeUI(sourceType) {
|
|
const sourceNameLabel = document.getElementById('sourceNameLabel');
|
|
const sourceNameInput = document.getElementById('sourceName');
|
|
const sourceNameHelp = document.getElementById('sourceNameHelp');
|
|
|
|
if (sourceType === 'subreddit') {
|
|
sourceNameLabel.textContent = 'Subreddit Name:';
|
|
sourceNameInput.placeholder = 'e.g., python, machinelearning';
|
|
sourceNameHelp.textContent = "Enter subreddit name without 'r/'";
|
|
} else {
|
|
sourceNameLabel.textContent = 'Username:';
|
|
sourceNameInput.placeholder = 'e.g., spez, your_username';
|
|
sourceNameHelp.textContent = "Enter Reddit username without 'u/'";
|
|
}
|
|
}
|
|
|
|
async handleUnifiedSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
const downloadMode = formData.get('download_mode');
|
|
const sourceType = formData.get('source_type');
|
|
const sourceName = formData.get('source_name').trim();
|
|
const limit = formData.get('limit');
|
|
const sort = formData.get('sort');
|
|
const noDupes = document.getElementById('noDupes').checked;
|
|
const simpleCheck = document.getElementById('simpleCheck').checked;
|
|
const runDaily = this.runDailyCheckbox.checked;
|
|
|
|
// Validate source name
|
|
if (sourceType === 'subreddit' && !this.validateSubreddit(sourceName)) {
|
|
this.showError('Please enter a valid subreddit name');
|
|
return;
|
|
}
|
|
if (sourceType === 'user' && !this.validateUsername(sourceName)) {
|
|
this.showError('Please enter a valid username');
|
|
return;
|
|
}
|
|
|
|
// If Run Daily is checked, create scheduled task instead
|
|
if (runDaily) {
|
|
await this.createScheduledTask(e);
|
|
return;
|
|
}
|
|
|
|
// Build confirmation message
|
|
const modeLabels = {
|
|
'download': 'Download (media files)',
|
|
'archive': 'Archive (metadata only)',
|
|
'clone': 'Clone (media + metadata)'
|
|
};
|
|
const sourceLabel = sourceType === 'subreddit' ? `r/${sourceName}` : `u/${sourceName}`;
|
|
|
|
if (confirm(`Start ${modeLabels[downloadMode]} for ${sourceLabel}?\n\nOptions:\n- Limit: ${limit}\n- Sort: ${sort}\n- No duplicates: ${noDupes ? 'Yes' : 'No'}\n- Simple check: ${simpleCheck ? 'Yes' : 'No'}`)) {
|
|
try {
|
|
this.showLoading(e.target.querySelector('button'));
|
|
|
|
// Prepare form data for backend
|
|
const backendFormData = new FormData();
|
|
if (sourceType === 'subreddit') {
|
|
backendFormData.append('subreddit', sourceName);
|
|
} else {
|
|
backendFormData.append('username', sourceName);
|
|
}
|
|
backendFormData.append('limit', limit);
|
|
backendFormData.append('sort', sort);
|
|
backendFormData.append('time_filter', formData.get('time_filter') || '');
|
|
backendFormData.append('min_score', formData.get('min_score') || '');
|
|
backendFormData.append('no_dupes', noDupes ? 'true' : 'false');
|
|
backendFormData.append('simple_check', simpleCheck ? 'true' : 'false');
|
|
backendFormData.append('make_hard_links', formData.get('make_hard_links') ? 'true' : 'false');
|
|
backendFormData.append('download_mode', downloadMode);
|
|
if (this.authState) {
|
|
backendFormData.append('auth_state', this.authState);
|
|
}
|
|
|
|
// Determine endpoint based on source type
|
|
const endpoint = sourceType === 'subreddit'
|
|
? '/api/download/subreddit'
|
|
: '/api/download/user';
|
|
|
|
// For user downloads, always set submitted=true (default)
|
|
if (sourceType === 'user') {
|
|
backendFormData.append('submitted', 'true');
|
|
}
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
body: backendFormData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.showSuccess(`${modeLabels[downloadMode]} started! ID: ${result.download_id}`);
|
|
e.target.reset();
|
|
} else {
|
|
this.showError(result.detail || 'Failed to start download');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
this.showError('Network error occurred');
|
|
} finally {
|
|
this.hideLoading(e.target.querySelector('button'));
|
|
}
|
|
}
|
|
}
|
|
|
|
updateProgress(data) {
|
|
const download_id = data.download_id || data.id;
|
|
console.log('Updating progress for:', download_id, data);
|
|
|
|
if (!this.downloads.has(download_id)) {
|
|
console.log('Creating new progress card for:', download_id);
|
|
this.createProgressCard(download_id, data);
|
|
}
|
|
|
|
const card = this.downloads.get(download_id);
|
|
if (card) {
|
|
console.log('Updating existing progress card for:', download_id);
|
|
this.updateProgressCard(card, data);
|
|
}
|
|
|
|
this.showDownloadsList();
|
|
}
|
|
|
|
createProgressCard(downloadId, data) {
|
|
const card = document.createElement('div');
|
|
card.className = 'progress-card';
|
|
card.id = `progress-${downloadId}`;
|
|
|
|
const typeLabel = data.subreddit ? 'Subreddit' : 'User';
|
|
const targetName = data.subreddit || data.username || 'Unknown';
|
|
const itemsProcessedInit = Number.isFinite(data.items_processed) ? data.items_processed : (data.data && Number.isFinite(data.data.items_processed) ? data.data.items_processed : 0);
|
|
const limitInit = Number.isFinite(data.limit) ? data.limit : (data.data && Number.isFinite(data.data.limit) ? data.data.limit : undefined);
|
|
const itemsFoundInit = Number.isFinite(data.items_found) ? data.items_found : (data.data && Number.isFinite(data.data.items_found) ? data.data.items_found : undefined);
|
|
const totalInit = (limitInit && limitInit > 0) ? limitInit : itemsFoundInit;
|
|
|
|
card.innerHTML = `
|
|
<div class="progress-header">
|
|
<div class="progress-info">
|
|
<h4>${typeLabel}: ${targetName}</h4>
|
|
<div class="progress-meta">ID: ${downloadId}</div>
|
|
<div class="progress-phase">Phase: ${this.getPhaseLabel(data.phase || 'queued')}</div>
|
|
<div class="current-item" id="current-item-${downloadId}" style="display: ${data.current_item ? 'block' : 'none'};">
|
|
Current: ${data.current_item_type === 'submission' ? 'Post' : 'Entry'} ${data.current_item || ''}
|
|
</div>
|
|
</div>
|
|
<div class="progress-controls">
|
|
</div>
|
|
<div class="progress-status status-${data.status}">${data.status}</div>
|
|
</div>
|
|
<div class="progress-bar-container">
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: ${data.progress || 0}%"></div>
|
|
</div>
|
|
<div class="progress-text">
|
|
${Math.round(data.progress || 0)}% complete
|
|
${Number.isFinite(totalInit) ? `(${itemsProcessedInit}/${totalInit} items)` : ''}
|
|
- ${data.message || 'Starting...'}
|
|
</div>
|
|
<div class="progress-details" id="progress-details-${downloadId}">
|
|
${this.getProgressDetails(data)}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Persist basics for retry
|
|
card.dataset.subreddit = data.subreddit || '';
|
|
card.dataset.username = data.username || '';
|
|
if (Number.isFinite(totalInit)) {
|
|
card.dataset.limit = String(totalInit);
|
|
}
|
|
|
|
// Attach retry button immediately if already failed due to rate limit
|
|
this.maybeAttachRetry(card, data, downloadId);
|
|
|
|
this.downloads.set(downloadId, card);
|
|
this.downloadsItems.appendChild(card);
|
|
}
|
|
|
|
updateProgressCard(card, data) {
|
|
const statusElement = card.querySelector('.progress-status');
|
|
const progressFill = card.querySelector('.progress-fill');
|
|
const progressText = card.querySelector('.progress-text');
|
|
const phaseElement = card.querySelector('.progress-phase');
|
|
const downloadId = data.download_id || data.id;
|
|
const currentItemElement = card.querySelector(`#current-item-${downloadId}`);
|
|
const progressDetailsElement = card.querySelector(`#progress-details-${downloadId}`);
|
|
|
|
// Update status
|
|
statusElement.textContent = data.status;
|
|
statusElement.className = `progress-status status-${data.status}`;
|
|
|
|
// Update phase
|
|
if (phaseElement) {
|
|
phaseElement.textContent = `Phase: ${this.getPhaseLabel(data.phase || 'queued')}`;
|
|
}
|
|
|
|
// Update current item
|
|
if (currentItemElement) {
|
|
if (data.current_item) {
|
|
const itemTypeLabel = data.current_item_type === 'submission' ? 'Post' : 'Entry';
|
|
currentItemElement.textContent = `Current: ${itemTypeLabel} ${data.current_item}`;
|
|
currentItemElement.style.display = 'block';
|
|
} else {
|
|
currentItemElement.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Update progress bar
|
|
if (progressFill) {
|
|
progressFill.style.width = `${data.progress || 0}%`;
|
|
}
|
|
|
|
// Update progress text
|
|
if (progressText) {
|
|
const itemsProcessed = Number.isFinite(data.items_processed) ? data.items_processed : (data.data && Number.isFinite(data.data.items_processed) ? data.data.items_processed : 0);
|
|
const limit = Number.isFinite(data.limit) ? data.limit : (data.data && Number.isFinite(data.data.limit) ? data.data.limit : undefined);
|
|
const itemsFound = Number.isFinite(data.items_found) ? data.items_found : (data.data && Number.isFinite(data.data.items_found) ? data.data.items_found : undefined);
|
|
const totalItems = (limit && limit > 0) ? limit : itemsFound;
|
|
|
|
let progressTextContent = `${Math.round(data.progress || 0)}% complete`;
|
|
if (Number.isFinite(totalItems)) {
|
|
progressTextContent += ` (${itemsProcessed}/${totalItems} items)`;
|
|
}
|
|
progressTextContent += ` - ${data.message || 'Processing...'}`;
|
|
progressText.textContent = progressTextContent;
|
|
}
|
|
|
|
// Update progress details
|
|
if (progressDetailsElement) {
|
|
progressDetailsElement.innerHTML = this.getProgressDetails(data);
|
|
}
|
|
|
|
// Update card class for animations
|
|
card.className = `progress-card ${data.status}`;
|
|
|
|
// Persist latest basics for retry
|
|
if (data.subreddit) card.dataset.subreddit = data.subreddit;
|
|
if (data.username) card.dataset.username = data.username;
|
|
if (Number.isFinite(data.limit)) {
|
|
card.dataset.limit = String(data.limit);
|
|
} else if (data.data && Number.isFinite(data.data.limit)) {
|
|
card.dataset.limit = String(data.data.limit);
|
|
}
|
|
|
|
// Attach retry button if applicable
|
|
this.maybeAttachRetry(card, data, downloadId);
|
|
}
|
|
|
|
getPhaseLabel(phase) {
|
|
const phaseLabels = {
|
|
'queued': 'Queued',
|
|
'fetching_submissions': 'Fetching Posts',
|
|
'preparing_download': 'Preparing Download',
|
|
'downloading_submission': 'Downloading Post',
|
|
'writing_file': 'Writing Files',
|
|
'writing_entry': 'Writing Entry',
|
|
'calculating_hashes': 'Calculating Hashes',
|
|
'completed': 'Completed',
|
|
'failed': 'Failed',
|
|
'cancelled': 'Cancelled'
|
|
};
|
|
return phaseLabels[phase] || phase.charAt(0).toUpperCase() + phase.slice(1);
|
|
}
|
|
|
|
getProgressDetails(data) {
|
|
let details = '';
|
|
|
|
if (data.current_subreddit) {
|
|
details += `<div>Subreddit: ${data.current_subreddit}</div>`;
|
|
}
|
|
|
|
if (data.file_count) {
|
|
details += `<div>Files to Hash: ${data.file_count}</div>`;
|
|
}
|
|
|
|
if (data.current_file) {
|
|
const shortPath = data.current_file.length > 50
|
|
? '...' + data.current_file.slice(-47)
|
|
: data.current_file;
|
|
details += `<div>Writing: ${shortPath}</div>`;
|
|
}
|
|
|
|
return details;
|
|
}
|
|
|
|
showDownloadsList() {
|
|
if (this.downloads.size > 0) {
|
|
this.progressContainer.style.display = 'none';
|
|
this.downloadsList.style.display = 'block';
|
|
} else {
|
|
this.progressContainer.style.display = 'flex';
|
|
this.downloadsList.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
validateInput(input) {
|
|
const isValid = input.value.trim().length > 0;
|
|
input.style.borderColor = isValid ? '#28a745' : '#dc3545';
|
|
return isValid;
|
|
}
|
|
|
|
validateSubreddit(subreddit) {
|
|
return subreddit.length > 0 && subreddit.match(/^[a-zA-Z0-9_]+$/);
|
|
}
|
|
|
|
validateUsername(username) {
|
|
return username.length > 0 && username.match(/^[a-zA-Z0-9_-]+$/);
|
|
}
|
|
|
|
showLoading(button) {
|
|
if (button) {
|
|
button.disabled = true;
|
|
button.textContent = 'Processing...';
|
|
}
|
|
}
|
|
|
|
hideLoading(button) {
|
|
if (button) {
|
|
button.disabled = false;
|
|
button.textContent = button.classList.contains('btn-primary') ? 'Start Download' : 'Start Download';
|
|
}
|
|
}
|
|
|
|
showSuccess(message) {
|
|
this.showNotification(message, 'success');
|
|
}
|
|
|
|
showError(message) {
|
|
this.showNotification(message, 'error');
|
|
}
|
|
|
|
showNotification(message, type) {
|
|
// Create notification element
|
|
const notification = document.createElement('div');
|
|
notification.className = `notification notification-${type}`;
|
|
notification.textContent = message;
|
|
|
|
// Style the notification
|
|
Object.assign(notification.style, {
|
|
position: 'fixed',
|
|
top: '20px',
|
|
right: '20px',
|
|
padding: '15px 20px',
|
|
borderRadius: '8px',
|
|
color: 'white',
|
|
fontWeight: '600',
|
|
zIndex: '1000',
|
|
opacity: '0',
|
|
transform: 'translateY(-20px)',
|
|
transition: 'all 0.3s ease',
|
|
backgroundColor: type === 'success' ? '#28a745' : '#dc3545'
|
|
});
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
// Animate in
|
|
setTimeout(() => {
|
|
notification.style.opacity = '1';
|
|
notification.style.transform = 'translateY(0)';
|
|
}, 100);
|
|
|
|
// Remove after 5 seconds
|
|
setTimeout(() => {
|
|
notification.style.opacity = '0';
|
|
notification.style.transform = 'translateY(-20px)';
|
|
setTimeout(() => {
|
|
document.body.removeChild(notification);
|
|
}, 300);
|
|
}, 5000);
|
|
}
|
|
|
|
updateWSStatus(status) {
|
|
if (this.wsStatus) {
|
|
this.wsStatus.textContent = status;
|
|
this.wsStatus.className = `status-value status-${status.toLowerCase()}`;
|
|
}
|
|
}
|
|
|
|
async updateStatus() {
|
|
// try {
|
|
// // Update server time
|
|
// this.serverTime.textContent = new Date().toLocaleTimeString();
|
|
|
|
// // Check BDFR status
|
|
// const response = await fetch('/api/bdfr/status');
|
|
// const status = await response.json();
|
|
|
|
// if (this.bdfrStatus) {
|
|
// this.bdfrStatus.textContent = status.bdfr_available ? 'Online' : 'Offline';
|
|
// this.bdfrStatus.className = `status-value status-${status.bdfr_available ? 'online' : 'offline'}`;
|
|
// }
|
|
|
|
// } catch (error) {
|
|
// console.error('Failed to update status:', error);
|
|
// }
|
|
}
|
|
|
|
startStatusPolling() {
|
|
// Update status every 30 seconds
|
|
setInterval(() => {
|
|
this.updateStatus();
|
|
}, 30000);
|
|
|
|
// Initial update
|
|
this.updateStatus();
|
|
}
|
|
|
|
async clearCompletedDownloads() {
|
|
const completedCards = Array.from(this.downloadsItems.querySelectorAll('.progress-card.completed'));
|
|
|
|
if (completedCards.length === 0) {
|
|
this.showNotification('No completed downloads to clear', 'info');
|
|
return;
|
|
}
|
|
|
|
if (confirm(`Clear ${completedCards.length} completed download(s)?`)) {
|
|
completedCards.forEach(card => {
|
|
const downloadId = card.id.replace('progress-', '');
|
|
this.downloads.delete(downloadId);
|
|
card.remove();
|
|
});
|
|
|
|
this.showDownloadsList();
|
|
this.showSuccess('Completed downloads cleared');
|
|
}
|
|
}
|
|
|
|
|
|
// Retry helpers
|
|
isRateLimited(data) {
|
|
const msg = (data && data.message) ? String(data.message) : '';
|
|
const ex = (data && data.data && data.data.exception) ? String(data.data.exception) : '';
|
|
const phase = data && data.data && data.data.phase;
|
|
return phase === 'rate_limited' || msg.includes('429') || ex.includes('429');
|
|
}
|
|
|
|
maybeAttachRetry(card, data, downloadId) {
|
|
try {
|
|
const controls = card.querySelector('.progress-controls');
|
|
if (!controls) return;
|
|
|
|
const existing = controls.querySelector('.btn-retry');
|
|
|
|
// Detect current event as rate-limited and persist this state on the card
|
|
const detected = this.isRateLimited(data);
|
|
if (detected) {
|
|
card.dataset.rateLimited = 'true';
|
|
}
|
|
const persisted = card.dataset.rateLimited === 'true';
|
|
const shouldShow = data.status === 'failed' && (detected || persisted);
|
|
|
|
// Remove if no longer applicable; otherwise ensure present
|
|
if (!shouldShow) {
|
|
if (existing) existing.remove();
|
|
return;
|
|
}
|
|
|
|
if (!existing) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn-retry';
|
|
btn.textContent = 'Retry';
|
|
btn.title = 'Rate limited (429). Retry now.';
|
|
btn.onclick = () => this.retryDownload(downloadId);
|
|
controls.appendChild(btn);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to attach retry button', e);
|
|
}
|
|
}
|
|
|
|
async retryDownload(downloadId) {
|
|
try {
|
|
const card = this.downloads.get(downloadId) || document.getElementById(`progress-${downloadId}`);
|
|
if (!card) {
|
|
this.showError(`Cannot retry; card not found for ${downloadId}`);
|
|
return;
|
|
}
|
|
|
|
const subreddit = card.dataset.subreddit || '';
|
|
const username = card.dataset.username || '';
|
|
const limitStr = card.dataset.limit || '';
|
|
const limit = parseInt(limitStr, 10);
|
|
const hasLimit = Number.isFinite(limit);
|
|
|
|
let endpoint = '';
|
|
const formData = new FormData();
|
|
|
|
if (subreddit) {
|
|
endpoint = '/api/download/subreddit';
|
|
formData.append('subreddit', subreddit);
|
|
formData.append('limit', hasLimit ? String(limit) : '10');
|
|
formData.append('sort', 'hot');
|
|
// Enable no_dupes for retry to avoid re-downloading same files
|
|
formData.append('no_dupes', 'true');
|
|
if (this.authState) formData.append('auth_state', this.authState);
|
|
} else if (username) {
|
|
endpoint = '/api/download/user';
|
|
formData.append('username', username);
|
|
formData.append('limit', hasLimit ? String(limit) : '10');
|
|
formData.append('submitted', 'true');
|
|
// Enable no_dupes for retry to avoid re-downloading same files
|
|
formData.append('no_dupes', 'true');
|
|
if (this.authState) formData.append('auth_state', this.authState);
|
|
} else {
|
|
this.showError('Cannot determine original request (subreddit/user) for retry');
|
|
return;
|
|
}
|
|
|
|
// Remove old failed card before retrying
|
|
if (card.remove) card.remove();
|
|
this.downloads.delete(downloadId);
|
|
|
|
this.showNotification(`Retrying ${subreddit ? `r/${subreddit}` : `u/${username}`}...`, 'info');
|
|
|
|
const response = await fetch(endpoint, { method: 'POST', body: formData });
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.showSuccess(`Retry started: ${result.download_id}`);
|
|
// New download card will appear via websocket updates
|
|
} else {
|
|
this.showError(result.detail || 'Failed to start retry');
|
|
}
|
|
} catch (error) {
|
|
console.error('Retry error:', error);
|
|
this.showError('Retry failed');
|
|
}
|
|
}
|
|
|
|
// Authentication methods
|
|
async checkAuthentication() {
|
|
try {
|
|
// Use stored auth state if available
|
|
const stateToCheck = this.authState || this.getStoredAuthState();
|
|
|
|
const response = await fetch(`/api/auth/status${stateToCheck ? `?state=${stateToCheck}` : ''}`);
|
|
const authData = await response.json();
|
|
|
|
console.log('Auth check:', {
|
|
stateUsed: stateToCheck,
|
|
authenticated: authData.authenticated,
|
|
message: authData.message
|
|
});
|
|
|
|
this.authenticated = authData.authenticated;
|
|
|
|
// If authenticated, store the state for future use
|
|
if (authData.authenticated && stateToCheck) {
|
|
this.authState = stateToCheck;
|
|
this.storeAuthState(stateToCheck);
|
|
}
|
|
|
|
this.updateAuthDisplay(authData);
|
|
|
|
} catch (error) {
|
|
console.error('Failed to check authentication:', error);
|
|
}
|
|
}
|
|
|
|
getStoredAuthState() {
|
|
// Try to get stored state from sessionStorage
|
|
return sessionStorage.getItem('bdfr_auth_state');
|
|
}
|
|
|
|
storeAuthState(state) {
|
|
// Store state in sessionStorage for persistence
|
|
sessionStorage.setItem('bdfr_auth_state', state);
|
|
}
|
|
|
|
updateAuthDisplay(authData) {
|
|
if (authData.authenticated) {
|
|
this.authSection.style.display = 'block';
|
|
this.authStatus.textContent = '🟢 Connected';
|
|
this.authStatus.className = 'auth-status-indicator connected';
|
|
this.loginBtn.style.display = 'none';
|
|
this.logoutBtn.style.display = 'inline-block';
|
|
|
|
// Show Reddit username when authenticated
|
|
if (this.authUser) this.authUser.textContent = (authData.username || '').toString() || '-';
|
|
|
|
// Update forms with auth state
|
|
if (this.authStateInput) this.authStateInput.value = this.authState || '';
|
|
if (this.userAuthStateInput) this.userAuthStateInput.value = this.authState || '';
|
|
|
|
// Show user downloads section
|
|
if (this.userDownloadsSection) {
|
|
this.userDownloadsSection.style.display = 'block';
|
|
}
|
|
|
|
} else {
|
|
this.authSection.style.display = 'block';
|
|
this.authStatus.textContent = '🔴 Not Connected';
|
|
this.authStatus.className = 'auth-status-indicator disconnected';
|
|
this.loginBtn.style.display = 'inline-block';
|
|
this.logoutBtn.style.display = 'none';
|
|
|
|
// Reset Reddit username display
|
|
if (this.authUser) this.authUser.textContent = '-';
|
|
|
|
// Clear auth state from forms
|
|
if (this.authStateInput) this.authStateInput.value = '';
|
|
if (this.userAuthStateInput) this.userAuthStateInput.value = '';
|
|
|
|
// Hide user downloads section
|
|
if (this.userDownloadsSection) {
|
|
this.userDownloadsSection.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleLogin() {
|
|
try {
|
|
// Get OAuth2 authorization URL
|
|
const response = await fetch('/auth/login');
|
|
const authData = await response.json();
|
|
|
|
if (response.ok) {
|
|
// Store state for later use
|
|
this.authState = authData.state;
|
|
this.storeAuthState(this.authState);
|
|
|
|
// Redirect to Reddit OAuth2
|
|
window.location.href = authData.authorization_url;
|
|
} else {
|
|
this.showError(authData.detail || 'Failed to initiate login');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Login error:', error);
|
|
this.showError('Failed to connect to Reddit');
|
|
}
|
|
}
|
|
|
|
async handleLogout() {
|
|
if (!this.authState) {
|
|
this.showError('No active session to logout');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('state', this.authState);
|
|
|
|
const response = await fetch('/auth/logout', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
this.authState = null;
|
|
this.authenticated = false;
|
|
// Clear stored auth state
|
|
sessionStorage.removeItem('bdfr_auth_state');
|
|
this.updateAuthDisplay({ authenticated: false });
|
|
this.showSuccess('Successfully logged out');
|
|
} else {
|
|
const error = await response.json();
|
|
this.showError(error.detail || 'Logout failed');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Logout error:', error);
|
|
this.showError('Logout failed');
|
|
}
|
|
}
|
|
|
|
// Handle OAuth2 callback
|
|
handleOAuth2Callback() {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const code = urlParams.get('code');
|
|
const state = urlParams.get('state');
|
|
const error = urlParams.get('error');
|
|
|
|
if (error) {
|
|
this.showError(`OAuth2 error: ${error}`);
|
|
return;
|
|
}
|
|
|
|
if (code && state) {
|
|
this.completeOAuth2Flow(code, state);
|
|
}
|
|
}
|
|
|
|
updateUserScheduleUI(scheduleType) {
|
|
if (this.userScheduleOptions) {
|
|
this.userScheduleOptions.style.display = scheduleType === 'scheduled' ? 'block' : 'none';
|
|
}
|
|
}
|
|
|
|
async handleDownloadLikes() {
|
|
await this.handleUserDownload('likes');
|
|
}
|
|
|
|
async handleDownloadSaved() {
|
|
await this.handleUserDownload('saved');
|
|
}
|
|
|
|
async handleUserDownload(type) {
|
|
const downloadMode = document.querySelector('input[name="user_download_mode"]:checked').value;
|
|
const scheduleType = document.querySelector('input[name="user_schedule_type"]:checked').value;
|
|
const runNow = scheduleType === 'now';
|
|
const runTime = this.userRunTimeInput ? this.userRunTimeInput.value : '02:00';
|
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
|
|
const params = {
|
|
limit: 25,
|
|
sort: 'hot',
|
|
download_mode: downloadMode,
|
|
run_now: runNow,
|
|
run_time: runTime,
|
|
timezone: timezone,
|
|
auth_state: this.authState
|
|
};
|
|
|
|
const endpoint = type === 'likes' ? '/api/scheduled-tasks/create-likes' : '/api/scheduled-tasks/create-saved';
|
|
|
|
try {
|
|
this.showLoading(this[`download${type.charAt(0).toUpperCase() + type.slice(1)}Btn`]);
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(params)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
const action = runNow ? 'started' : 'scheduled';
|
|
this.showSuccess(`Your ${type} download has been ${action}!`);
|
|
await this.loadScheduledTasks();
|
|
} else {
|
|
this.showError(result.detail || `Failed to ${runNow ? 'start' : 'schedule'} ${type} download`);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
this.showError(`Network error occurred while ${runNow ? 'starting' : 'scheduling'} ${type} download`);
|
|
} finally {
|
|
this.hideLoading(this[`download${type.charAt(0).toUpperCase() + type.slice(1)}Btn`]);
|
|
}
|
|
}
|
|
|
|
async completeOAuth2Flow(code, state) {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('code', code);
|
|
formData.append('state', state);
|
|
|
|
const response = await fetch('/auth/callback', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Check if response is HTML (success page) or JSON
|
|
const contentType = response.headers.get('content-type');
|
|
|
|
if (contentType && contentType.includes('text/html')) {
|
|
// It's the success page, just show success message
|
|
this.showSuccess('Successfully authenticated with Reddit!');
|
|
this.authenticated = true;
|
|
|
|
// Store auth state for persistence (use a default since we don't have it from HTML response)
|
|
// The actual state will be retrieved when checking auth status
|
|
this.storeAuthState('active_session');
|
|
|
|
this.updateAuthDisplay({ authenticated: true });
|
|
|
|
// Clean URL
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|
} else {
|
|
// It's JSON response (fallback)
|
|
const result = await response.json();
|
|
this.authState = result.tokens ? result.tokens.state : null;
|
|
this.authenticated = true;
|
|
this.updateAuthDisplay({ authenticated: true });
|
|
|
|
// Update user display if available
|
|
if (result.user && this.authUser) {
|
|
this.authUser.textContent = result.user.name;
|
|
}
|
|
|
|
this.showSuccess('Successfully authenticated with Reddit!');
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|
}
|
|
} else {
|
|
// Try to parse as JSON, but handle HTML error pages gracefully
|
|
try {
|
|
const result = await response.json();
|
|
this.showError(result.detail || 'Authentication failed');
|
|
} catch (parseError) {
|
|
// If it's not JSON, it might be an HTML error page
|
|
this.showError('Authentication failed - please check your OAuth configuration');
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('OAuth2 completion error:', error);
|
|
this.showError('Authentication failed');
|
|
}
|
|
}
|
|
|
|
// ===== Scheduled Tasks Methods =====
|
|
|
|
async loadScheduledTasks() {
|
|
try {
|
|
const response = await fetch('/api/scheduled-tasks');
|
|
const tasks = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.scheduledTasks.clear();
|
|
tasks.forEach(task => {
|
|
this.scheduledTasks.set(task.id, task);
|
|
});
|
|
this.renderScheduledTasks();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load scheduled tasks:', error);
|
|
}
|
|
}
|
|
|
|
renderScheduledTasks() {
|
|
if (this.scheduledTasks.size > 0) {
|
|
this.scheduledContainer.style.display = 'none';
|
|
this.scheduledList.style.display = 'block';
|
|
this.scheduledItems.innerHTML = '';
|
|
|
|
this.scheduledTasks.forEach(task => {
|
|
const card = this.createTaskCard(task);
|
|
this.scheduledItems.appendChild(card);
|
|
});
|
|
} else {
|
|
this.scheduledContainer.style.display = 'flex';
|
|
this.scheduledList.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
createTaskCard(task) {
|
|
const card = document.createElement('div');
|
|
card.className = `task-card ${task.enabled ? '' : 'disabled'}`;
|
|
card.id = `task-${task.id}`;
|
|
|
|
const sourceLabel = task.source_type === 'subreddit' ? `r/${task.source_name}` : `u/${task.source_name}`;
|
|
const modeLabel = task.download_mode.charAt(0).toUpperCase() + task.download_mode.slice(1);
|
|
|
|
const lastRun = task.last_run_at ? new Date(task.last_run_at).toLocaleString() : 'Never';
|
|
const nextRun = task.next_run_at ? new Date(task.next_run_at).toLocaleString() : 'Not scheduled';
|
|
|
|
card.innerHTML = `
|
|
<div class="task-header">
|
|
<div class="task-info">
|
|
<h4>${task.name}</h4>
|
|
<div class="task-meta">
|
|
<div class="task-meta-item">
|
|
<strong>Source:</strong> ${sourceLabel}
|
|
</div>
|
|
<div class="task-meta-item">
|
|
<strong>Mode:</strong> ${modeLabel}
|
|
</div>
|
|
<div class="task-meta-item">
|
|
<strong>Schedule:</strong> Daily at ${task.run_time}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="task-status">
|
|
<span class="status-badge ${task.enabled ? 'enabled' : 'disabled'}">
|
|
${task.enabled ? '✓ Enabled' : '✗ Disabled'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="task-schedule">
|
|
<div class="task-schedule-item"><strong>Last Run:</strong> ${lastRun}</div>
|
|
<div class="task-schedule-item"><strong>Next Run:</strong> ${nextRun}</div>
|
|
</div>
|
|
<div class="task-controls">
|
|
<button class="btn-task btn-toggle ${task.enabled ? '' : 'disabled'}" data-task-id="${task.id}" data-action="toggle">
|
|
${task.enabled ? 'Disable' : 'Enable'}
|
|
</button>
|
|
<button class="btn-task btn-run" data-task-id="${task.id}" data-action="run">
|
|
Run Now
|
|
</button>
|
|
<button class="btn-task btn-delete" data-task-id="${task.id}" data-action="delete">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
// Add event listeners to buttons
|
|
const toggleBtn = card.querySelector('[data-action="toggle"]');
|
|
const runBtn = card.querySelector('[data-action="run"]');
|
|
const deleteBtn = card.querySelector('[data-action="delete"]');
|
|
|
|
if (toggleBtn) {
|
|
toggleBtn.addEventListener('click', () => this.toggleTask(task.id));
|
|
}
|
|
if (runBtn) {
|
|
runBtn.addEventListener('click', () => this.runTaskNow(task.id));
|
|
}
|
|
if (deleteBtn) {
|
|
deleteBtn.addEventListener('click', () => this.deleteTask(task.id));
|
|
}
|
|
|
|
return card;
|
|
}
|
|
|
|
async createScheduledTask(e) {
|
|
const formData = new FormData(e.target);
|
|
const taskName = this.taskNameInput.value.trim();
|
|
const runTime = this.runTimeInput.value;
|
|
|
|
if (!taskName) {
|
|
this.showError('Please enter a task name');
|
|
return;
|
|
}
|
|
|
|
if (!runTime) {
|
|
this.showError('Please select a run time');
|
|
return;
|
|
}
|
|
|
|
const downloadMode = formData.get('download_mode');
|
|
const sourceType = formData.get('source_type');
|
|
const sourceName = formData.get('source_name').trim();
|
|
const limit = formData.get('limit');
|
|
const sort = formData.get('sort');
|
|
|
|
// Get browser timezone
|
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
|
|
const taskData = {
|
|
name: taskName,
|
|
source_type: sourceType,
|
|
source_name: sourceName,
|
|
download_mode: downloadMode,
|
|
limit: parseInt(limit),
|
|
sort: sort,
|
|
run_time: runTime,
|
|
timezone: timezone,
|
|
enabled: true
|
|
};
|
|
|
|
try {
|
|
this.showLoading(e.target.querySelector('button'));
|
|
|
|
const response = await fetch('/api/scheduled-tasks', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(taskData)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.showSuccess(`Scheduled task created: ${taskName}`);
|
|
e.target.reset();
|
|
this.runDailyCheckbox.checked = false;
|
|
this.scheduleOptions.style.display = 'none';
|
|
await this.loadScheduledTasks();
|
|
} else {
|
|
this.showError(result.detail || 'Failed to create scheduled task');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
this.showError('Network error occurred');
|
|
} finally {
|
|
this.hideLoading(e.target.querySelector('button'));
|
|
}
|
|
}
|
|
|
|
async toggleTask(taskId) {
|
|
try {
|
|
const response = await fetch(`/api/scheduled-tasks/${taskId}/toggle`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.showSuccess(`Task ${result.enabled ? 'enabled' : 'disabled'}`);
|
|
await this.loadScheduledTasks();
|
|
} else {
|
|
this.showError(result.detail || 'Failed to toggle task');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
this.showError('Failed to toggle task');
|
|
}
|
|
}
|
|
|
|
async runTaskNow(taskId) {
|
|
if (!confirm('Run this scheduled task now?')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/scheduled-tasks/${taskId}/run-now`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.showSuccess('Task added to queue');
|
|
await this.loadScheduledTasks();
|
|
} else {
|
|
this.showError(result.detail || 'Failed to queue task');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
this.showError('Failed to queue task');
|
|
}
|
|
}
|
|
|
|
async deleteTask(taskId) {
|
|
if (!confirm('Delete this scheduled task? This cannot be undone.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/scheduled-tasks/${taskId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
this.showSuccess('Task deleted');
|
|
await this.loadScheduledTasks();
|
|
} else {
|
|
const result = await response.json();
|
|
this.showError(result.detail || 'Failed to delete task');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
this.showError('Failed to delete task');
|
|
}
|
|
}
|
|
|
|
async updateQueueStatus() {
|
|
try {
|
|
const response = await fetch('/api/scheduled-tasks/queue/status');
|
|
const status = await response.json();
|
|
|
|
if (response.ok) {
|
|
const queueSize = status.queue_size || 0;
|
|
if (queueSize > 0 || status.current_task) {
|
|
this.queueStatus.style.display = 'block';
|
|
this.queueCount.textContent = queueSize;
|
|
} else {
|
|
this.queueStatus.style.display = 'none';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to update queue status:', error);
|
|
}
|
|
}
|
|
|
|
startQueuePolling() {
|
|
// Update queue status every 10 seconds
|
|
setInterval(() => {
|
|
this.updateQueueStatus();
|
|
}, 10000);
|
|
|
|
// Initial update
|
|
this.updateQueueStatus();
|
|
}
|
|
}
|
|
|
|
// Initialize the application when DOM is loaded
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.bdfrApp = new BDFRApp();
|
|
|
|
// Check for OAuth2 callback parameters
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (urlParams.has('code') || urlParams.has('error')) {
|
|
window.bdfrApp.handleOAuth2Callback();
|
|
}
|
|
|
|
// Check for authentication success indicator
|
|
if (urlParams.has('authenticated')) {
|
|
window.bdfrApp.showSuccess('Successfully authenticated with Reddit!');
|
|
window.bdfrApp.authenticated = true;
|
|
window.bdfrApp.updateAuthDisplay({ authenticated: true });
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|
}
|
|
|
|
// Check for authentication error indicator
|
|
if (urlParams.has('auth_error')) {
|
|
window.bdfrApp.showError('Authentication failed - please check your OAuth configuration');
|
|
window.history.replaceState({}, document.title, window.location.pathname);
|
|
}
|
|
});
|
|
|
|
// Handle page visibility changes
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible' && window.bdfrApp) {
|
|
window.bdfrApp.updateStatus();
|
|
window.bdfrApp.checkAuthentication();
|
|
}
|
|
}); |