import { useCallback, useEffect, useMemo, useState } from 'react'; import { invokeCommand as invoke } from '../ipc'; import { AlertCircle, CheckCircle2, Clock3, List, Moon, LockKeyhole, Pause, Play, Power, RotateCcw, Save } from 'lucide-react'; import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore'; import { MAIN_QUEUE_ID, useDownloadStore } from '../store/useDownloadStore'; import { isActiveDownloadStatus } from '../utils/downloads'; import { WindowDragRegion } from './WindowDragRegion'; import { useToast } from '../contexts/ToastContext'; import { usePlatformInfo } from '../utils/platform'; import { useTranslation } from 'react-i18next'; import { formatDateTime } from '../utils/dateTime'; const days = [ { value: 0, key: 'su' }, { value: 1, key: 'mo' }, { value: 2, key: 'tu' }, { value: 3, key: 'we' }, { value: 4, key: 'th' }, { value: 5, key: 'fr' }, { value: 6, key: 'sa' }, ] as const; const postActions: { value: PostQueueAction; icon: typeof Moon }[] = [ { value: 'none', icon: CheckCircle2 }, { value: 'sleep', icon: Moon }, { value: 'restart', icon: RotateCcw }, { value: 'shutdown', icon: Power }, ]; const minuteOfDay = (value: string) => { const [hour, minute] = value.split(':').map(Number); return hour * 60 + minute; }; function nextScheduledRun(settings: SchedulerSettings): Date | 'disabled' | 'none' { if (!settings.enabled) return 'disabled'; const [hour, minute] = settings.startTime.split(':').map(Number); const now = new Date(); for (let offset = 0; offset < 8; offset += 1) { const candidate = new Date(now); candidate.setDate(now.getDate() + offset); candidate.setHours(hour, minute, 0, 0); const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay()); if (allowedDay && candidate > now) { return candidate; } } return 'none'; } export default function SchedulerView() { const { t, i18n } = useTranslation(); const savedSettings = useSettingsStore(state => state.scheduler); const schedulerRunning = useSettingsStore(state => state.schedulerRunning); const calendarPreference = useSettingsStore(state => state.calendarPreference); const setScheduler = useSettingsStore(state => state.setScheduler); const queues = useDownloadStore(state => state.queues); const [draft, setDraft] = useState(savedSettings); const { addToast } = useToast(); const [permissionMessage, setPermissionMessage] = useState(''); const [automationPermissionGranted, setAutomationPermissionGranted] = useState(null); const platform = usePlatformInfo(); const isMac = platform.os === 'macos'; useEffect(() => { setDraft(savedSettings); }, [savedSettings]); const nextRun = useMemo(() => { const scheduledRun = nextScheduledRun(draft); if (scheduledRun === 'disabled') return t($ => $.scheduler.disabled); if (scheduledRun === 'none') return t($ => $.scheduler.noScheduledDay); return formatDateTime(scheduledRun, { locale: i18n.language, calendar: calendarPreference, options: { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' } }); }, [calendarPreference, draft, i18n.language, t]); const hasUnsavedChanges = useMemo( () => JSON.stringify(draft) !== JSON.stringify(savedSettings), [draft, savedSettings] ); const updateDraft = (key: K, value: SchedulerSettings[K]) => { setDraft(current => ({ ...current, [key]: value })); }; const toggleDay = (day: number) => { setDraft(current => ({ ...current, selectedDays: current.selectedDays.includes(day) ? current.selectedDays.filter(value => value !== day) : [...current.selectedDays, day].sort() })); }; const availableQueueIds = new Set(queues.map(queue => queue.id)); const selectedQueueIds = draft.selectedQueueIds.filter(queueId => availableQueueIds.has(queueId)); const effectiveSelectedQueueIds = selectedQueueIds; const toggleQueue = (queueId: string) => { setDraft(current => { const isSelected = current.selectedQueueIds.includes(queueId); const availableSelectionCount = current.selectedQueueIds .filter(id => availableQueueIds.has(id)) .length; if (isSelected && availableSelectionCount === 1) return current; return { ...current, selectedQueueIds: isSelected ? current.selectedQueueIds.filter(id => id !== queueId) : [...current.selectedQueueIds, queueId] }; }); }; const save = () => { if (draft.enabled && !draft.everyday && draft.selectedDays.length === 0) { addToast({ message: t($ => $.scheduler.validationDay), variant: 'error', isActionable: true }); return; } if (draft.enabled && effectiveSelectedQueueIds.length === 0) { addToast({ message: t($ => $.scheduler.validationQueue), variant: 'error', isActionable: true }); return; } if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) === minuteOfDay(draft.startTime)) { addToast({ message: t($ => $.scheduler.validationStopTime), variant: 'error', isActionable: true }); return; } const normalized = { ...draft, selectedDays: draft.selectedDays, selectedQueueIds: effectiveSelectedQueueIds }; setScheduler(normalized); setDraft(normalized); addToast({ message: t($ => $.scheduler.saved), variant: 'success' }); }; const runNow = async () => { const previouslyTrackedIds = new Set(useSettingsStore.getState().schedulerActiveDownloadIds); const results = await Promise.all( effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId)) ); const acceptedIds = results.flat(); const selectedQueueSet = new Set(effectiveSelectedQueueIds); const trackedIds = useDownloadStore.getState().downloads .filter(download => previouslyTrackedIds.has(download.id) && selectedQueueSet.has(download.queueId || MAIN_QUEUE_ID) && isActiveDownloadStatus(download.status) ) .map(download => download.id); const activeIds = [...new Set([...acceptedIds, ...trackedIds])]; if (activeIds.length > 0) { useSettingsStore.getState().setSchedulerRunning(true); useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds); addToast({ message: activeIds.length === 1 ? t($ => $.scheduler.trackingOne) : t($ => $.scheduler.trackingMany, { count: activeIds.length }), variant: 'success' }); } else { addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' }); } }; const pauseNow = async () => { const counts = await Promise.all( effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId)) ); const count = counts.reduce((total, queueCount) => total + queueCount, 0); useSettingsStore.getState().setSchedulerRunning(false); useSettingsStore.getState().setSchedulerActiveDownloadIds([]); addToast({ message: count === 0 ? t($ => $.scheduler.noActiveDownloads) : count === 1 ? t($ => $.scheduler.pausedOne) : t($ => $.scheduler.pausedMany, { count }), variant: 'info' }); }; const refreshPermissionStatus = useCallback(async (showMessage = false) => { if (!isMac) return; try { await invoke('check_automation_permission'); setAutomationPermissionGranted(true); if (showMessage) { setPermissionMessage(t($ => $.scheduler.permissionAvailable)); } } catch { setAutomationPermissionGranted(false); if (showMessage) { setPermissionMessage(t($ => $.scheduler.permissionMissingDetails)); } } }, [isMac]); useEffect(() => { if (!isMac) return; void refreshPermissionStatus(); const refreshOnFocus = () => { void refreshPermissionStatus(); }; const refreshOnVisibility = () => { if (document.visibilityState === 'visible') { void refreshPermissionStatus(); } }; window.addEventListener('focus', refreshOnFocus); document.addEventListener('visibilitychange', refreshOnVisibility); return () => { window.removeEventListener('focus', refreshOnFocus); document.removeEventListener('visibilitychange', refreshOnVisibility); }; }, [isMac, refreshPermissionStatus]); const openAutomationSettings = async (message: string) => { setPermissionMessage(message); try { await invoke('open_automation_settings'); } catch (error) { setPermissionMessage(String(error)); } }; const handlePermissionAction = async () => { if (automationPermissionGranted) { await openAutomationSettings(t($ => $.scheduler.revokePermissionDetails)); return; } setPermissionMessage(t($ => $.scheduler.requestingPermission)); try { await invoke('request_automation_permission'); setAutomationPermissionGranted(true); setPermissionMessage(t($ => $.scheduler.permissionAvailable)); } catch { setAutomationPermissionGranted(false); await openAutomationSettings(t($ => $.scheduler.enablePermissionDetails)); } }; return (
{t($ => $.scheduler.title)}
{schedulerRunning ? t($ => $.scheduler.running) : nextRun} {hasUnsavedChanges && ( {t($ => $.scheduler.unsavedChanges)} )}
{t($ => $.scheduler.timing)}
updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />

{t($ => $.scheduler.timingDescription)}

{!draft.everyday && (
{days.map(day => { const selected = draft.selectedDays.includes(day.value); return ( ); })}
)}
{t($ => $.scheduler.queuesToSchedule)}
{queues.map(queue => { const selected = draft.selectedQueueIds.includes(queue.id); return ( ); })}
{t($ => $.scheduler.afterCompletion)}

{t($ => $.scheduler.afterCompletionDescription)}

{postActions.map(action => { const Icon = action.icon; return ( ); })}
{draft.postQueueAction !== 'none' && (

{t($ => $.scheduler.actionWarning)}

)}
{isMac ? (
{t($ => $.scheduler.systemPermissions)}

{t($ => $.scheduler.macPermissionDescription)}

{automationPermissionGranted ? ( <> {t($ => $.scheduler.permissionGranted)} ) : ( <> {automationPermissionGranted === null ? t($ => $.scheduler.permissionChecking) : t($ => $.scheduler.permissionMissing)} )}
{permissionMessage &&

{permissionMessage}

}
) : (
{t($ => $.scheduler.systemActions)}

{platform.os === 'windows' ? t($ => $.scheduler.windowsActionsDescription) : t($ => $.scheduler.linuxActionsDescription)}

)}
); }