This commit is contained in:
Timothy Jaeryang Baek
2026-04-01 00:11:11 -05:00
parent 378673408e
commit 8a0794958d
3 changed files with 89 additions and 40 deletions
+19 -3
View File
@@ -38,10 +38,26 @@ AUTOMATION_POLL_INTERVAL = int(os.getenv('AUTOMATION_POLL_INTERVAL', '10'))
#################### ####################
def _parse_rule(s: str):
"""Parse RRULE with clock-aligned DTSTART for sub-daily frequencies.
MINUTELY/HOURLY rules use a fixed epoch DTSTART (2000-01-01 00:00)
so intervals snap to clock boundaries (e.g. every 5min = :00, :05, :10).
"""
raw = s.replace('RRULE:', '')
parts = dict(p.split('=', 1) for p in raw.split(';') if '=' in p)
freq = parts.get('FREQ', '')
if freq in ('MINUTELY', 'HOURLY'):
epoch = datetime(2000, 1, 1, 0, 0, 0)
return rrulestr(s, dtstart=epoch, ignoretz=True)
return rrulestr(s, ignoretz=True)
def validate_rrule(s: str) -> None: def validate_rrule(s: str) -> None:
"""Raise ValueError if the RRULE is malformed or exhausted.""" """Raise ValueError if the RRULE is malformed or exhausted."""
try: try:
rule = rrulestr(s, ignoretz=True) rule = _parse_rule(s)
except Exception as e: except Exception as e:
raise ValueError(f'Invalid RRULE: {e}') raise ValueError(f'Invalid RRULE: {e}')
if rule.after(datetime.now()) is None: if rule.after(datetime.now()) is None:
@@ -51,7 +67,7 @@ def validate_rrule(s: str) -> None:
def next_run_ns(s: str, tz: str = None) -> Optional[int]: def next_run_ns(s: str, tz: str = None) -> Optional[int]:
"""Next occurrence as epoch nanoseconds, respecting user timezone.""" """Next occurrence as epoch nanoseconds, respecting user timezone."""
now = datetime.now(ZoneInfo(tz)) if tz else datetime.now() now = datetime.now(ZoneInfo(tz)) if tz else datetime.now()
dt = rrulestr(s, ignoretz=True).after(now.replace(tzinfo=None)) dt = _parse_rule(s).after(now.replace(tzinfo=None))
if dt is None: if dt is None:
return None return None
if tz: if tz:
@@ -61,7 +77,7 @@ def next_run_ns(s: str, tz: str = None) -> Optional[int]:
def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]: def next_n_runs_ns(s: str, n: int = 5, tz: str = None) -> list[int]:
"""Compute next N occurrences for UI preview.""" """Compute next N occurrences for UI preview."""
rule = rrulestr(s, ignoretz=True) rule = _parse_rule(s)
result = [] result = []
dt = datetime.now() dt = datetime.now()
for _ in range(n): for _ in range(n):
+61 -37
View File
@@ -36,6 +36,8 @@
let minute = 0; let minute = 0;
let selectedDays: string[] = []; let selectedDays: string[] = [];
let monthDay = 1; let monthDay = 1;
let onceDate = '';
let onceTime = '09:00';
let loading = false; let loading = false;
let showScheduleDropdown = false; let showScheduleDropdown = false;
@@ -56,6 +58,7 @@
: $models; : $models;
const FREQUENCIES = [ const FREQUENCIES = [
{ key: 'ONCE', label: 'Once' },
{ key: 'HOURLY', label: 'Hourly' }, { key: 'HOURLY', label: 'Hourly' },
{ key: 'DAILY', label: 'Daily' }, { key: 'DAILY', label: 'Daily' },
{ key: 'WEEKLY', label: 'Weekly' }, { key: 'WEEKLY', label: 'Weekly' },
@@ -74,6 +77,10 @@
]; ];
const buildVisualRrule = (): string => { const buildVisualRrule = (): string => {
if (lastVisualFrequency === 'ONCE') {
const dt = onceDate.replace(/-/g, '') + 'T' + onceTime.replace(/:/g, '') + '00';
return `DTSTART:${dt}\nRRULE:FREQ=DAILY;COUNT=1`;
}
let parts = [`FREQ=${lastVisualFrequency}`]; let parts = [`FREQ=${lastVisualFrequency}`];
if (interval > 1) parts.push(`INTERVAL=${interval}`); if (interval > 1) parts.push(`INTERVAL=${interval}`);
if (lastVisualFrequency === 'WEEKLY' && selectedDays.length) { if (lastVisualFrequency === 'WEEKLY' && selectedDays.length) {
@@ -96,6 +103,12 @@
lastVisualFrequency = frequency; lastVisualFrequency = frequency;
} }
$: if (frequency === 'ONCE' && !onceDate) {
const soon = new Date(Date.now() + 5 * 60_000);
onceDate = soon.toISOString().split('T')[0];
onceTime = `${String(soon.getHours()).padStart(2, '0')}:${String(soon.getMinutes()).padStart(2, '0')}`;
}
$: { $: {
if (frequency === 'CUSTOM' && prevFrequency !== 'CUSTOM') { if (frequency === 'CUSTOM' && prevFrequency !== 'CUSTOM') {
customRrule = buildVisualRrule(); customRrule = buildVisualRrule();
@@ -105,6 +118,10 @@
const buildRrule = (): string => { const buildRrule = (): string => {
if (frequency === 'CUSTOM') return customRrule; if (frequency === 'CUSTOM') return customRrule;
if (frequency === 'ONCE') {
const dt = onceDate.replace(/-/g, '') + 'T' + onceTime.replace(/:/g, '') + '00';
return `DTSTART:${dt}\nRRULE:FREQ=DAILY;COUNT=1`;
}
let parts = [`FREQ=${frequency}`]; let parts = [`FREQ=${frequency}`];
if (interval > 1) parts.push(`INTERVAL=${interval}`); if (interval > 1) parts.push(`INTERVAL=${interval}`);
if (frequency === 'WEEKLY' && selectedDays.length) { if (frequency === 'WEEKLY' && selectedDays.length) {
@@ -121,6 +138,16 @@
}; };
const parseRrule = (s: string) => { const parseRrule = (s: string) => {
// Detect ONCE (COUNT=1 with DTSTART)
if (s.includes('COUNT=1')) {
frequency = 'ONCE';
const match = s.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (match) {
onceDate = `${match[1]}-${match[2]}-${match[3]}`;
onceTime = `${match[4]}:${match[5]}`;
}
return;
}
const parts: Record<string, string> = {}; const parts: Record<string, string> = {};
s.replace('RRULE:', '') s.replace('RRULE:', '')
.split(';') .split(';')
@@ -143,11 +170,7 @@
}; };
const scheduleLabel = (freq, intv, h, min, days, mDay) => { const scheduleLabel = (freq, intv, h, min, days, mDay) => {
const h12 = h > 12 ? h - 12 : h === 0 ? 12 : h; if (freq === 'ONCE') return 'Once';
const ampm = h >= 12 ? 'PM' : 'AM';
const m = String(min).padStart(2, '0');
const time = `${h12}:${m} ${ampm}`;
if (freq === 'HOURLY') return 'Hourly'; if (freq === 'HOURLY') return 'Hourly';
if (freq === 'DAILY') return 'Daily'; if (freq === 'DAILY') return 'Daily';
if (freq === 'WEEKLY') return 'Weekly'; if (freq === 'WEEKLY') return 'Weekly';
@@ -161,6 +184,13 @@
toast.error($i18n.t('Name, prompt, and model are required')); toast.error($i18n.t('Name, prompt, and model are required'));
return; return;
} }
if (frequency === 'ONCE') {
const scheduled = new Date(`${onceDate}T${onceTime}`);
if (scheduled <= new Date()) {
toast.error($i18n.t('Scheduled time must be in the future'));
return;
}
}
loading = true; loading = true;
try { try {
const form: AutomationForm = { const form: AutomationForm = {
@@ -203,6 +233,8 @@
is_active = true; is_active = true;
frequency = 'DAILY'; frequency = 'DAILY';
interval = 1; interval = 1;
onceDate = '';
onceTime = '09:00';
hour = 9; hour = 9;
minute = 0; minute = 0;
selectedDays = []; selectedDays = [];
@@ -287,24 +319,22 @@
<div <div
slot="content" slot="content"
class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1" class="rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-48 p-1"
> >
<div class="px-2 text-xs text-gray-500 py-1"> <div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Schedule')} {$i18n.t('Schedule')}
</div> </div>
<div class="flex gap-1 px-2 mb-2 overflow-x-auto scrollbar-none"> <div class="px-1.5 mt-0.5 mb-2">
{#each FREQUENCIES as f} <select
<button class="w-full bg-transparent rounded-xl text-xs py-1.5 px-1.5 outline-hidden"
type="button" bind:value={frequency}
class="px-2 py-1 rounded-xl text-xs transition shrink-0 {frequency === f.key on:click={(e) => e.stopPropagation()}
? 'bg-gray-50 dark:bg-gray-800 text-black dark:text-gray-100' >
: 'text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-200'}" {#each FREQUENCIES as f}
on:click={() => (frequency = f.key)} <option value={f.key}>{f.label}</option>
> {/each}
{f.label} </select>
</button>
{/each}
</div> </div>
{#if frequency === 'CUSTOM'} {#if frequency === 'CUSTOM'}
@@ -317,31 +347,25 @@
on:click={(e) => e.stopPropagation()} on:click={(e) => e.stopPropagation()}
/> />
</div> </div>
{:else} {:else if frequency !== 'HOURLY'}
<div class="flex gap-2 flex-wrap items-center px-3 pb-2 text-xs"> <div class="flex gap-2 flex-wrap items-center px-3 pb-2 text-xs">
{#if frequency === 'HOURLY'} {#if frequency === 'ONCE'}
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="text-xs text-gray-500">{$i18n.t('Every')}</span>
<input <input
type="number" type="date"
bind:value={interval} bind:value={onceDate}
min={1} min={new Date().toISOString().split('T')[0]}
max={60} class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
class="w-12 bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 border border-gray-200 dark:border-gray-700"
on:click={(e) => e.stopPropagation()} on:click={(e) => e.stopPropagation()}
/> />
<span class="text-xs text-gray-500">hr</span> </div>
<span class="text-xs text-gray-500">{$i18n.t('at')}</span> <div class="flex items-center gap-1.5">
<span class="text-gray-400">:</span>
<input <input
type="number" type="time"
bind:value={minute} bind:value={onceTime}
min={0} class="bg-transparent outline-hidden text-xs dark:color-scheme-dark"
max={59}
class="w-12 bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 border border-gray-200 dark:border-gray-700"
on:click={(e) => e.stopPropagation()} on:click={(e) => e.stopPropagation()}
/> />
<span class="text-xs text-gray-500">min</span>
</div> </div>
{:else} {:else}
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
@@ -354,7 +378,7 @@
hour = h; hour = h;
minute = m; minute = m;
}} }}
class="bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 px-2 border border-gray-200 dark:border-gray-700 dark:color-scheme-dark" class="bg-transparent text-center outline-hidden text-xs dark:color-scheme-dark"
on:click={(e) => e.stopPropagation()} on:click={(e) => e.stopPropagation()}
/> />
</div> </div>
@@ -368,7 +392,7 @@
bind:value={monthDay} bind:value={monthDay}
min={1} min={1}
max={31} max={31}
class="w-12 bg-gray-50 dark:bg-gray-800 rounded-lg text-center outline-hidden text-xs py-1 border border-gray-200 dark:border-gray-700" class="w-8 bg-transparent text-center outline-hidden text-xs"
on:click={(e) => e.stopPropagation()} on:click={(e) => e.stopPropagation()}
/> />
</div> </div>
@@ -100,6 +100,15 @@
}; };
const formatRRule = (rrule: string): string => { const formatRRule = (rrule: string): string => {
// Detect one-time schedule (ONCE)
if (rrule.includes('COUNT=1')) {
const match = rrule.match(/DTSTART:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})/);
if (match) {
const d = new Date(`${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`);
return `Once · ${d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} ${d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`;
}
return 'Once';
}
const parts: Record<string, string> = {}; const parts: Record<string, string> = {};
rrule rrule
.replace('RRULE:', '') .replace('RRULE:', '')