Skip to content

Commit fd5a52f

Browse files
refactor: enhance task scheduling with flexible time options
1 parent bce38f0 commit fd5a52f

File tree

6 files changed

+179
-44
lines changed

6 files changed

+179
-44
lines changed

schtasks/task.go

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ type Task struct {
3030
Principals Principals `xml:"Principals"`
3131
Settings Settings `xml:"Settings"`
3232
Actions Actions `xml:"Actions"`
33+
fromNow time.Time `xml:"-"`
3334
}
3435

35-
func NewTask() Task {
36+
func NewTask(options ...TaskOption) Task {
3637
var userID string
3738
if currentUser, err := user.Current(); err == nil {
3839
userID = currentUser.Uid
@@ -69,6 +70,11 @@ func NewTask() Task {
6970
Actions: Actions{
7071
Context: author,
7172
},
73+
fromNow: time.Now(),
74+
}
75+
76+
for _, option := range options {
77+
option.apply(&task)
7278
}
7379
return task
7480
}
@@ -105,9 +111,14 @@ func (t *Task) AddSchedules(schedules []*calendar.Event) {
105111
}
106112
}
107113

114+
func (t *Task) setFromNow(fromNow time.Time) {
115+
t.fromNow = fromNow
116+
t.RegistrationInfo.Date = fromNow.Format(dateFormat)
117+
}
118+
108119
func (t *Task) addTimeTrigger(triggerOnce time.Time) {
109120
timeTrigger := TimeTrigger{
110-
StartBoundary: triggerOnce.Format(dateFormat),
121+
StartBoundary: &triggerOnce,
111122
}
112123
if t.Triggers.TimeTrigger == nil {
113124
t.Triggers.TimeTrigger = []TimeTrigger{timeTrigger}
@@ -125,7 +136,7 @@ func (t *Task) addCalendarTrigger(trigger CalendarTrigger) {
125136
}
126137

127138
func (t *Task) addDailyTrigger(schedule *calendar.Event) {
128-
start := schedule.Next(time.Now())
139+
start := schedule.Next(t.fromNow)
129140
// get all recurrences in the same day
130141
recurrences := schedule.GetAllInBetween(start, start.Add(24*time.Hour))
131142
if len(recurrences) == 0 {
@@ -135,7 +146,7 @@ func (t *Task) addDailyTrigger(schedule *calendar.Event) {
135146
// Is it only once a day?
136147
if len(recurrences) == 1 {
137148
t.addCalendarTrigger(CalendarTrigger{
138-
StartBoundary: recurrences[0].Format(dateFormat),
149+
StartBoundary: &recurrences[0],
139150
ScheduleByDay: &ScheduleByDay{
140151
DaysInterval: 1,
141152
},
@@ -149,7 +160,7 @@ func (t *Task) addDailyTrigger(schedule *calendar.Event) {
149160
// case with regular repetition
150161
interval := period.NewOf(compactDifferences[0])
151162
t.addCalendarTrigger(CalendarTrigger{
152-
StartBoundary: start.Format(dateFormat),
163+
StartBoundary: &start,
153164
ScheduleByDay: &ScheduleByDay{
154165
DaysInterval: 1,
155166
},
@@ -168,7 +179,7 @@ func (t *Task) addDailyTrigger(schedule *calendar.Event) {
168179
// install them all
169180
for _, recurrence := range recurrences {
170181
t.addCalendarTrigger(CalendarTrigger{
171-
StartBoundary: recurrence.Format(dateFormat),
182+
StartBoundary: &recurrence,
172183
ScheduleByDay: &ScheduleByDay{
173184
DaysInterval: 1,
174185
},
@@ -177,7 +188,7 @@ func (t *Task) addDailyTrigger(schedule *calendar.Event) {
177188
}
178189

179190
func (t *Task) addWeeklyTrigger(schedule *calendar.Event) {
180-
start := schedule.Next(time.Now())
191+
start := schedule.Next(t.fromNow)
181192
// get all recurrences in the same day
182193
recurrences := schedule.GetAllInBetween(start, start.Add(24*time.Hour))
183194
if len(recurrences) == 0 {
@@ -187,7 +198,7 @@ func (t *Task) addWeeklyTrigger(schedule *calendar.Event) {
187198
// Is it only once per 24h?
188199
if len(recurrences) == 1 {
189200
t.addCalendarTrigger(CalendarTrigger{
190-
StartBoundary: recurrences[0].Format(dateFormat),
201+
StartBoundary: &recurrences[0],
191202
ScheduleByWeek: &ScheduleByWeek{
192203
WeeksInterval: 1,
193204
DaysOfWeek: convertWeekdays(schedule.WeekDay.GetRangeValues()),
@@ -202,7 +213,7 @@ func (t *Task) addWeeklyTrigger(schedule *calendar.Event) {
202213
// case with regular repetition
203214
interval := period.NewOf(compactDifferences[0])
204215
t.addCalendarTrigger(CalendarTrigger{
205-
StartBoundary: start.Format(dateFormat),
216+
StartBoundary: &start,
206217
ScheduleByWeek: &ScheduleByWeek{
207218
WeeksInterval: 1,
208219
DaysOfWeek: convertWeekdays(schedule.WeekDay.GetRangeValues()),
@@ -222,7 +233,7 @@ func (t *Task) addWeeklyTrigger(schedule *calendar.Event) {
222233
// install them all
223234
for _, recurrence := range recurrences {
224235
t.addCalendarTrigger(CalendarTrigger{
225-
StartBoundary: recurrence.Format(dateFormat),
236+
StartBoundary: &recurrence,
226237
ScheduleByWeek: &ScheduleByWeek{
227238
WeeksInterval: 1,
228239
DaysOfWeek: convertWeekdays(schedule.WeekDay.GetRangeValues()),
@@ -232,7 +243,7 @@ func (t *Task) addWeeklyTrigger(schedule *calendar.Event) {
232243
}
233244

234245
func (t *Task) addMonthlyTrigger(schedule *calendar.Event) {
235-
start := schedule.Next(time.Now())
246+
start := schedule.Next(t.fromNow)
236247
// get all recurrences in the same day
237248
recurrences := schedule.GetAllInBetween(start, start.Add(24*time.Hour))
238249
if len(recurrences) == 0 {
@@ -252,7 +263,7 @@ func (t *Task) addMonthlyTrigger(schedule *calendar.Event) {
252263
}
253264
if schedule.WeekDay.HasValue() {
254265
t.addCalendarTrigger(CalendarTrigger{
255-
StartBoundary: recurrence.Format(dateFormat),
266+
StartBoundary: &recurrence,
256267
ScheduleByMonthDayOfWeek: &ScheduleByMonthDayOfWeek{
257268
DaysOfWeek: convertWeekdays(schedule.WeekDay.GetRangeValues()),
258269
Weeks: AllWeeks,
@@ -262,7 +273,7 @@ func (t *Task) addMonthlyTrigger(schedule *calendar.Event) {
262273
continue
263274
}
264275
t.addCalendarTrigger(CalendarTrigger{
265-
StartBoundary: recurrence.Format(dateFormat),
276+
StartBoundary: &recurrence,
266277
ScheduleByMonth: &ScheduleByMonth{
267278
DaysOfMonth: convertDaysOfMonth(schedule.Day.GetRangeValues()),
268279
Months: convertMonths(schedule.Month.GetRangeValues()),

schtasks/task_options.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//go:build windows
2+
3+
package schtasks
4+
5+
import "time"
6+
7+
type TaskOption interface {
8+
apply(*Task)
9+
}
10+
11+
type WithFromNowOption struct {
12+
now time.Time
13+
}
14+
15+
func WithFromNow(now time.Time) WithFromNowOption {
16+
return WithFromNowOption{now: now}
17+
}
18+
19+
func (w WithFromNowOption) apply(t *Task) {
20+
t.setFromNow(w.now)
21+
}

schtasks/taskscheduler.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"slices"
2929
"strings"
3030
"text/tabwriter"
31+
"time"
3132

3233
"github.com/creativeprojects/clog"
3334
"github.com/creativeprojects/resticprofile/calendar"
@@ -53,8 +54,7 @@ func Create(config *Config, schedules []*calendar.Event, permission Permission)
5354
return fmt.Errorf("cannot delete existing task to replace it: %w", err)
5455
}
5556
}
56-
57-
task := createTaskDefinition(config, schedules)
57+
task := createTaskDefinition(config, schedules, time.Time{})
5858
task.RegistrationInfo.URI = taskPath
5959

6060
switch config.RunLevel {
@@ -181,8 +181,12 @@ func getTaskPath(profileName, commandName string) string {
181181
return fmt.Sprintf("%s%s %s", tasksPathPrefix, profileName, commandName)
182182
}
183183

184-
func createTaskDefinition(config *Config, schedules []*calendar.Event) Task {
185-
task := NewTask()
184+
func createTaskDefinition(config *Config, schedules []*calendar.Event, from time.Time) Task {
185+
options := make([]TaskOption, 0, 1)
186+
if !from.IsZero() {
187+
options = append(options, WithFromNow(from))
188+
}
189+
task := NewTask(options...)
186190
task.RegistrationInfo.Description = config.JobDescription
187191
task.AddExecAction(ExecAction{
188192
Command: config.Command,

schtasks/taskscheduler_test.go

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,98 +125,131 @@ func TestTaskSchedulerIntegration(t *testing.T) {
125125
fixtures := []struct {
126126
description string
127127
schedules []string
128+
fromNow time.Time
128129
}{
129130
{
130131
"only once",
131132
[]string{"2020-01-02 03:04"},
133+
time.Time{},
132134
},
133135
// daily
134136
{
135137
"once every day",
136138
[]string{"*-*-* 03:04"},
139+
time.Time{},
137140
},
138141
{
139142
"every hour",
140143
[]string{"*-*-* *:04"},
144+
time.Time{},
141145
},
142146
{
143147
"every minute",
144148
[]string{"*-*-* *:*"},
149+
time.Time{},
145150
},
146151
{
147-
"every minute at 12",
152+
"every minute at 12 before 12",
148153
[]string{"*-*-* 12:*"},
154+
time.Date(2025, 7, 27, 11, 20, 0, 0, time.UTC),
155+
},
156+
// this creates 60 triggers
157+
// {
158+
// "every minute at 12",
159+
// []string{"*-*-* 12:*"},
160+
// time.Date(2025, 7, 27, 12, 20, 0, 0, time.UTC),
161+
// },
162+
{
163+
"every minute at 12 after 12",
164+
[]string{"*-*-* 12:*"},
165+
time.Date(2025, 7, 27, 13, 20, 0, 0, time.UTC),
149166
},
150167
// daily - more than one
151168
{
152169
"three times a day",
153170
[]string{"*-*-* 03..05:04"},
171+
time.Time{},
154172
},
155173
{
156174
"twice every hour",
157175
[]string{"*-*-* *:04..05"},
176+
time.Time{},
158177
},
159178
// weekly
160179
{
161180
"once weekly",
162181
[]string{"mon *-*-* 03:04"},
182+
time.Time{},
163183
},
164184
{
165185
"every hour on mondays",
166186
[]string{strings.ToLower(fixedDay)[:3] + " *-*-* *:04"},
187+
time.Time{},
167188
},
168189
{
169190
"every minute on mondays",
170191
[]string{strings.ToLower(fixedDay)[:3] + " *-*-* *:*"},
192+
time.Time{},
171193
},
172194
{
173195
"every minute at 12 on mondays",
174196
[]string{"mon *-*-* 12:*"},
197+
time.Time{},
175198
},
176199
// more than once weekly
177200
{
178201
"twice weekly",
179202
[]string{"mon *-*-* 03..04:04"},
203+
time.Time{},
180204
},
181205
{
182206
"twice mondays and tuesdays",
183207
[]string{"mon,tue *-*-* 03:04..06"},
208+
time.Time{},
184209
},
185210
{
186211
"twice on fridays",
187212
[]string{"fri *-*-* *:04..05"},
213+
time.Time{},
188214
},
189215
// monthly
190216
{
191217
"once monthly",
192218
[]string{"*-01-* 03:04"},
219+
time.Time{},
193220
},
194221
{
195222
"every hour in january",
196223
[]string{"*-01-* *:04"},
224+
time.Time{},
197225
},
198226
// monthly with weekdays
199227
{
200228
"mondays in January",
201229
[]string{"mon *-01-* 03:04"},
230+
time.Time{},
202231
},
203232
{
204233
"every hour on Mondays in january",
205234
[]string{"mon *-01-* *:04"},
235+
time.Time{},
206236
},
207237
// some days every month
208238
{
209239
"one day per month",
210240
[]string{"*-*-0" + dayOfTheMonth + " 03:04"},
241+
time.Time{},
211242
},
212243
{
213244
"every hour on the 1st of each month",
214245
[]string{"*-*-0" + dayOfTheMonth + " *:04"},
246+
time.Time{},
215247
},
216248
// more than once per month
217249
{
218250
"twice in one day per month",
219251
[]string{"*-*-0" + dayOfTheMonth + " 03..04:04"},
252+
time.Time{},
220253
},
221254
}
222255

@@ -247,13 +280,15 @@ func TestTaskSchedulerIntegration(t *testing.T) {
247280
defer file.Close()
248281

249282
taskPath := getTaskPath(config.ProfileName, config.CommandName)
250-
sourceTask := createTaskDefinition(config, schedules)
283+
sourceTask := createTaskDefinition(config, schedules, fixture.fromNow)
251284
sourceTask.RegistrationInfo.URI = taskPath
252285

253286
err = createTaskFile(sourceTask, file)
254287
require.NoError(t, err)
255288
file.Close()
256289

290+
t.Logf("task contains %d time triggers and %d calendar triggers", len(sourceTask.Triggers.TimeTrigger), len(sourceTask.Triggers.CalendarTrigger))
291+
257292
result, err := createTask(taskPath, file.Name(), "", "")
258293
t.Log(result)
259294
require.NoError(t, err)
@@ -271,6 +306,9 @@ func TestTaskSchedulerIntegration(t *testing.T) {
271306
err = decoder.Decode(&readTask)
272307
require.NoError(t, err)
273308

309+
sourceTask.fromNow = time.Time{} // ignore fromNow in the source task
310+
taskInUTC(&sourceTask)
311+
taskInUTC(readTask)
274312
assert.Equal(t, sourceTask, *readTask)
275313

276314
result, err = deleteTask(taskPath)
@@ -287,3 +325,21 @@ func TestRunLevelOption(t *testing.T) {
287325
// see related: https://github.com/creativeprojects/resticprofile/issues/545
288326
// TODO: implement test when possible
289327
}
328+
329+
func taskInUTC(task *Task) {
330+
// Windows Task Scheduler is using the current timezone when loading dates into the XML definition.
331+
// This is a workaround to ensure that the tests run consistently.
332+
for i := range task.Triggers.TimeTrigger {
333+
if task.Triggers.TimeTrigger[i].StartBoundary != nil {
334+
*task.Triggers.TimeTrigger[i].StartBoundary = task.Triggers.TimeTrigger[i].StartBoundary.UTC()
335+
}
336+
}
337+
for i := range task.Triggers.CalendarTrigger {
338+
if task.Triggers.CalendarTrigger[i].StartBoundary != nil {
339+
*task.Triggers.CalendarTrigger[i].StartBoundary = task.Triggers.CalendarTrigger[i].StartBoundary.UTC()
340+
}
341+
if task.Triggers.CalendarTrigger[i].EndBoundary != nil {
342+
*task.Triggers.CalendarTrigger[i].EndBoundary = task.Triggers.CalendarTrigger[i].EndBoundary.UTC()
343+
}
344+
}
345+
}

0 commit comments

Comments
 (0)