CronEditor, use in Copy/Sync, type

This commit is contained in:
FTCHD
2025-08-05 05:27:11 +03:00
parent b3d6d49975
commit 9205af78fd
4 changed files with 240 additions and 2 deletions
+184
View File
@@ -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 (
<div className="flex flex-col w-full gap-2">
<Input
value={cronExpression || ''}
onChange={(e) => {
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={<ClockIcon className="text-default-400" />}
isClearable={true}
onClear={() => setCronExpression(null)}
/>
<div className="grid grid-cols-5 gap-2">
<CronField
label="Minute"
value={minute}
onChange={(v) => handleFieldChange('minute', v)}
options={generateOptions(0, 59)}
/>
<CronField
label="Hour"
value={hour}
onChange={(v) => handleFieldChange('hour', v)}
options={generateOptions(0, 23)}
/>
<CronField
label="Day (Month)"
value={dayOfMonth}
onChange={(v) => handleFieldChange('dayOfMonth', v)}
options={generateOptions(1, 31)}
/>
<CronField
label="Month"
value={month}
onChange={(v) => handleFieldChange('month', v)}
options={generateOptions(1, 12)}
/>
<CronField
label="Day (Week)"
value={dayOfWeek}
onChange={(v) => handleFieldChange('dayOfWeek', v)}
options={generateOptions(0, 7)}
/>
</div>
<div className="text-sm text-neutral-500">{readableDescription}</div>
</div>
)
}
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<HTMLInputElement>) => {
onChange(e.target.value)
}
return (
<div>
{isCustom ? (
<Input
label={label}
value={value}
onChange={handleCustomChange}
className="max-w-xs"
endContent={
<Button
isIconOnly={true}
size="sm"
variant="light"
onPress={() => setIsCustom(false)}
>
<XIcon className="w-4 h-4 text-default-400" />
</Button>
}
/>
) : (
<Select
label={label}
selectedKeys={[value]}
onSelectionChange={(key) => {
const item = key.currentKey
if (!item) return
if (item === 'custom') {
setIsCustom(true)
} else {
onChange(item)
}
}}
className="max-w-xs"
items={[
...DEFAULT_OPTIONS.map((option) => ({ key: option, label: option })),
...options.map((option) => ({ key: option, label: option })),
{ key: 'custom', label: 'Custom' },
]}
>
{(item) => (
<SelectItem key={item.key} title={item.label}>
{item.key}
</SelectItem>
)}
</Select>
)}
</div>
)
}
function generateOptions(start: number, end: number): string[] {
return Array.from({ length: end - start + 1 }, (_, i) => (start + i).toString())
}
+22 -1
View File
@@ -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<Record<string, string>>({})
const [filterOptionsJson, setFilterOptionsJson] = useState<string>('{}')
const [cronExpression, setCronExpression] = useState<string | null>(null)
const [globalOptions, setGlobalOptions] = useState<any[]>([])
// 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}
/>
</AccordionItem>
<AccordionItem
key="cron"
startContent={
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
}
indicator={<ClockIcon />}
subtitle="Tap to toggle cron options for this operation"
title="Cron"
>
<CronEditor expression={cronExpression} onChange={setCronExpression} />
</AccordionItem>
</Accordion>
</div>
+22 -1
View File
@@ -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<Record<string, string>>({})
const [filterOptionsJson, setFilterOptionsJson] = useState<string>('{}')
const [cronExpression, setCronExpression] = useState<string | null>(null)
const [globalOptions, setGlobalOptions] = useState<any[]>([])
// 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}
/>
</AccordionItem>
<AccordionItem
key="cron"
startContent={
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
}
indicator={<ClockIcon />}
subtitle="Tap to toggle cron options for this operation"
title="Cron"
>
<CronEditor expression={cronExpression} onChange={setCronExpression} />
</AccordionItem>
</Accordion>
</div>
+12
View File
@@ -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<string, any>
error?: string
}