diff --git a/src/components/CronEditor.tsx b/src/components/CronEditor.tsx new file mode 100644 index 0000000..e026972 --- /dev/null +++ b/src/components/CronEditor.tsx @@ -0,0 +1,184 @@ +import { Button, Input, Select, SelectItem } from '@heroui/react' +import cronstrue from 'cronstrue' +import { ClockIcon, XIcon } from 'lucide-react' +import type React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' + +interface CronEditorProps { + expression: string | null + onChange: (newExpression: string | null) => void +} + +interface CronFieldProps { + label: string + value: string + onChange: (value: string) => void + options: string[] +} + +const DEFAULT_OPTIONS = ['*', '*/5', '*/10', '*/15', '*/30'] + +export default function CronEditor({ expression, onChange }: CronEditorProps) { + const [cronExpression, setCronExpression] = useState(expression) + + const [minute, hour, dayOfMonth, month, dayOfWeek] = useMemo( + () => (cronExpression || '* * * * *').split(' '), + [cronExpression] + ) + + const readableDescription = useMemo(() => { + if (!cronExpression) return 'This task is not scheduled' + let description: string + try { + description = cronstrue.toString(cronExpression) + description += + '. Jobs trigger when the UI is running, if the active config is the same.' + } catch { + description = 'Invalid cron expression' + } + return description + }, [cronExpression]) + + const handleFieldChange = useCallback( + (field: string, value: string) => { + const parts = (cronExpression || '* * * * *').split(' ') + const index = ['minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek'].indexOf(field) + if (index !== -1) { + parts[index] = value + setCronExpression(parts.join(' ')) + } + }, + [cronExpression] + ) + + useEffect(() => { + onChange(cronExpression) + }, [cronExpression, onChange]) + + return ( +
+ { + const expression = e.target.value + + if (expression.length > 0) { + setCronExpression(expression) + } else { + setCronExpression(null) + } + }} + placeholder="Enter cron expression (e.g. 0 0 * * *)" + size="lg" + startContent={} + isClearable={true} + onClear={() => setCronExpression(null)} + /> + +
+ handleFieldChange('minute', v)} + options={generateOptions(0, 59)} + /> + handleFieldChange('hour', v)} + options={generateOptions(0, 23)} + /> + handleFieldChange('dayOfMonth', v)} + options={generateOptions(1, 31)} + /> + handleFieldChange('month', v)} + options={generateOptions(1, 12)} + /> + handleFieldChange('dayOfWeek', v)} + options={generateOptions(0, 7)} + /> +
+ +
{readableDescription}
+
+ ) +} + +function CronField({ label, value, onChange, options }: CronFieldProps) { + const [isCustom, setIsCustom] = useState(false) + + useEffect(() => { + const isCustom = + !DEFAULT_OPTIONS.includes(value) && + !options.includes(value) && + value !== '*' && + value !== 'custom' + setIsCustom(isCustom) + }, [value, options]) + + const handleCustomChange = (e: React.ChangeEvent) => { + onChange(e.target.value) + } + + return ( +
+ {isCustom ? ( + setIsCustom(false)} + > + + + } + /> + ) : ( + + )} +
+ ) +} + +function generateOptions(start: number, end: number): string[] { + return Array.from({ length: end - start + 1 }, (_, i) => (start + i).toString()) +} diff --git a/src/pages/Copy.tsx b/src/pages/Copy.tsx index 241da63..f0fdc47 100644 --- a/src/pages/Copy.tsx +++ b/src/pages/Copy.tsx @@ -1,7 +1,14 @@ import { Accordion, AccordionItem, Avatar, Button } from '@heroui/react' import { message } from '@tauri-apps/plugin-dialog' import { exists } from '@tauri-apps/plugin-fs' -import { AlertOctagonIcon, CopyIcon, FilterIcon, FoldersIcon, PlayIcon } from 'lucide-react' +import { + AlertOctagonIcon, + ClockIcon, + CopyIcon, + FilterIcon, + FoldersIcon, + PlayIcon, +} from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { getRemoteName } from '../../lib/format' @@ -9,6 +16,7 @@ import { isRemotePath } from '../../lib/fs' import { getCopyFlags, getFilterFlags, getGlobalFlags, startCopy } from '../../lib/rclone/api' import { usePersistedStore } from '../../lib/store' import { openWindow } from '../../lib/window' +import CronEditor from '../components/CronEditor' import OptionsSection from '../components/OptionsSection' import PathFinder from '../components/PathFinder' @@ -32,6 +40,8 @@ export default function Copy() { const [filterOptions, setFilterOptions] = useState>({}) const [filterOptionsJson, setFilterOptionsJson] = useState('{}') + const [cronExpression, setCronExpression] = useState(null) + const [globalOptions, setGlobalOptions] = useState([]) // biome-ignore lint/correctness/useExhaustiveDependencies: when unlocking, we don't want to re-run the effect @@ -236,6 +246,17 @@ export default function Copy() { setIsLocked={setFilterOptionsLocked} /> + } /> + } + indicator={} + subtitle="Tap to toggle cron options for this operation" + title="Cron" + > + + diff --git a/src/pages/Sync.tsx b/src/pages/Sync.tsx index 888538d..0c2feab 100644 --- a/src/pages/Sync.tsx +++ b/src/pages/Sync.tsx @@ -1,7 +1,14 @@ import { Accordion, AccordionItem, Avatar, Button } from '@heroui/react' import { message } from '@tauri-apps/plugin-dialog' import { exists } from '@tauri-apps/plugin-fs' -import { AlertOctagonIcon, FilterIcon, FolderSyncIcon, FoldersIcon, PlayIcon } from 'lucide-react' +import { + AlertOctagonIcon, + ClockIcon, + FilterIcon, + FolderSyncIcon, + FoldersIcon, + PlayIcon, +} from 'lucide-react' import { useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { getRemoteName } from '../../lib/format' @@ -9,6 +16,7 @@ import { isRemotePath } from '../../lib/fs' import { getFilterFlags, getGlobalFlags, getSyncFlags, startSync } from '../../lib/rclone/api' import { usePersistedStore } from '../../lib/store' import { openWindow } from '../../lib/window' +import CronEditor from '../components/CronEditor' import OptionsSection from '../components/OptionsSection' import PathFinder from '../components/PathFinder' @@ -32,6 +40,8 @@ export default function Sync() { const [filterOptions, setFilterOptions] = useState>({}) const [filterOptionsJson, setFilterOptionsJson] = useState('{}') + const [cronExpression, setCronExpression] = useState(null) + const [globalOptions, setGlobalOptions] = useState([]) // biome-ignore lint/correctness/useExhaustiveDependencies: when unlocking, we don't want to re-run the effect @@ -236,6 +246,17 @@ export default function Sync() { setIsLocked={setFilterOptionsLocked} /> + } /> + } + indicator={} + subtitle="Tap to toggle cron options for this operation" + title="Cron" + > + + diff --git a/types/task.d.ts b/types/task.d.ts new file mode 100644 index 0000000..f5ffe21 --- /dev/null +++ b/types/task.d.ts @@ -0,0 +1,12 @@ +export interface ScheduledTask { + id: string + type: 'delete' | 'sync' | 'copy' + cron: string + isRunning: boolean + isEnabled: boolean + currentRunId?: string + lastRun?: string + configId: string + args: Record + error?: string +}