taskcheck 0.1.1__tar.gz → 0.1.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {taskcheck-0.1.1/taskcheck.egg-info → taskcheck-0.1.3}/PKG-INFO +18 -6
- {taskcheck-0.1.1 → taskcheck-0.1.3}/README.md +17 -4
- {taskcheck-0.1.1 → taskcheck-0.1.3}/pyproject.toml +2 -7
- {taskcheck-0.1.1 → taskcheck-0.1.3}/taskcheck/__main__.py +76 -32
- {taskcheck-0.1.1 → taskcheck-0.1.3}/taskcheck/ical.py +51 -30
- {taskcheck-0.1.1 → taskcheck-0.1.3/taskcheck.egg-info}/PKG-INFO +18 -6
- {taskcheck-0.1.1 → taskcheck-0.1.3}/taskcheck.egg-info/requires.txt +0 -1
- {taskcheck-0.1.1 → taskcheck-0.1.3}/LICENSE +0 -0
- {taskcheck-0.1.1 → taskcheck-0.1.3}/setup.cfg +0 -0
- {taskcheck-0.1.1 → taskcheck-0.1.3}/taskcheck.egg-info/SOURCES.txt +0 -0
- {taskcheck-0.1.1 → taskcheck-0.1.3}/taskcheck.egg-info/dependency_links.txt +0 -0
- {taskcheck-0.1.1 → taskcheck-0.1.3}/taskcheck.egg-info/entry_points.txt +0 -0
- {taskcheck-0.1.1 → taskcheck-0.1.3}/taskcheck.egg-info/top_level.txt +0 -0
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: taskcheck
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Automatic scheduler for taskwarrior
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Dist: appdirs>=1.4.4
|
|
9
9
|
Requires-Dist: icalendar>=6.0.1
|
|
10
|
-
Requires-Dist: requests-cache>=1.2.1
|
|
11
10
|
Requires-Dist: requests>=2.32.3
|
|
12
11
|
|
|
13
12
|
|
|
@@ -46,6 +45,8 @@ You can exclude a task from being scheduled by removing the `time_map` or `estim
|
|
|
46
45
|
|
|
47
46
|
You can see tasks that you can execute now with the `task ready` report.
|
|
48
47
|
|
|
48
|
+
You can see the schedule for a task in the `scheduling` UDA.
|
|
49
|
+
|
|
49
50
|
## Configuration
|
|
50
51
|
|
|
51
52
|
1. Create a TOML file at `~/.config/task/taskcheck.toml` with the following format:
|
|
@@ -72,6 +73,7 @@ days_ahead = 1000 # how far go with the schedule (lower values make the computat
|
|
|
72
73
|
[calendars.1]
|
|
73
74
|
url = "https://your/url/to/calendar.ics"
|
|
74
75
|
expiration = 0.08 # in hours (0.08 hours =~ 5 minutes)
|
|
76
|
+
timezone = "Europe/Rome" # if set, force timezone for this calendar; timezone values are TZ identifiers (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
|
|
75
77
|
|
|
76
78
|
[calendars.holidays]
|
|
77
79
|
url = "https://www.officeholidays.com/ics-clean/italy/milan"
|
|
@@ -90,18 +92,25 @@ uda.estimated.type = duration
|
|
|
90
92
|
uda.estimated.label = Estimated Time
|
|
91
93
|
uda.completion_date.type = date
|
|
92
94
|
uda.completion_date.label = Expected Completion Date
|
|
95
|
+
uda.scheduling.type = string
|
|
96
|
+
uda.scheduling.label = Scheduling
|
|
93
97
|
|
|
94
98
|
# Adjust urgency for taskcheck
|
|
95
|
-
|
|
96
|
-
urgency.waiting.coefficient = 0.0
|
|
99
|
+
# you will use `wait:` to avoid scheduling tasks too much sooner
|
|
100
|
+
urgency.waiting.coefficient = 0.0
|
|
101
|
+
# you will use `active:` to prioritize tasks already started even if the estimated amount is decreased
|
|
102
|
+
urgency.active.coefficient = 3.0
|
|
103
|
+
urgency.scheduled.coefficient = 0.0 # tasks that are not scheduled may still be urgent
|
|
97
104
|
|
|
98
105
|
# we need to be able to modify recurrent tasks without prompt
|
|
99
106
|
recurrence.confirmation=no
|
|
100
107
|
|
|
101
108
|
# suggested
|
|
102
|
-
urgency.inherit=1
|
|
109
|
+
urgency.inherit=1 # use dependencies to automatically prioritize tasks
|
|
103
110
|
urgency.blocking.coefficient=2
|
|
104
111
|
|
|
112
|
+
# give urgeny values to the estimated time
|
|
113
|
+
# this is log(estimation) + 1, with base so that 36 hours has ~12 urgency
|
|
105
114
|
urgency.uda.estimated.PT1H.coefficient = 1
|
|
106
115
|
urgency.uda.estimated.PT2H.coefficient = 2.32
|
|
107
116
|
urgency.uda.estimated.PT3H.coefficient = 3.67
|
|
@@ -138,11 +147,14 @@ urgency.uda.estimated.PT33H.coefficient = 11.73
|
|
|
138
147
|
urgency.uda.estimated.PT34H.coefficient = 11.81
|
|
139
148
|
urgency.uda.estimated.PT35H.coefficient = 11.89
|
|
140
149
|
urgency.uda.estimated.PT36H.coefficient = 11.96
|
|
150
|
+
# maximum duration is 36 hours
|
|
151
|
+
|
|
141
152
|
```
|
|
142
153
|
|
|
143
154
|
## CLI Options
|
|
144
155
|
|
|
145
156
|
```
|
|
157
|
+
|
|
146
158
|
-v, --verbose: increase output verbosity
|
|
147
|
-
|
|
159
|
+
|
|
148
160
|
```
|
|
@@ -34,6 +34,8 @@ You can exclude a task from being scheduled by removing the `time_map` or `estim
|
|
|
34
34
|
|
|
35
35
|
You can see tasks that you can execute now with the `task ready` report.
|
|
36
36
|
|
|
37
|
+
You can see the schedule for a task in the `scheduling` UDA.
|
|
38
|
+
|
|
37
39
|
## Configuration
|
|
38
40
|
|
|
39
41
|
1. Create a TOML file at `~/.config/task/taskcheck.toml` with the following format:
|
|
@@ -60,6 +62,7 @@ days_ahead = 1000 # how far go with the schedule (lower values make the computat
|
|
|
60
62
|
[calendars.1]
|
|
61
63
|
url = "https://your/url/to/calendar.ics"
|
|
62
64
|
expiration = 0.08 # in hours (0.08 hours =~ 5 minutes)
|
|
65
|
+
timezone = "Europe/Rome" # if set, force timezone for this calendar; timezone values are TZ identifiers (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
|
|
63
66
|
|
|
64
67
|
[calendars.holidays]
|
|
65
68
|
url = "https://www.officeholidays.com/ics-clean/italy/milan"
|
|
@@ -78,18 +81,25 @@ uda.estimated.type = duration
|
|
|
78
81
|
uda.estimated.label = Estimated Time
|
|
79
82
|
uda.completion_date.type = date
|
|
80
83
|
uda.completion_date.label = Expected Completion Date
|
|
84
|
+
uda.scheduling.type = string
|
|
85
|
+
uda.scheduling.label = Scheduling
|
|
81
86
|
|
|
82
87
|
# Adjust urgency for taskcheck
|
|
83
|
-
|
|
84
|
-
urgency.waiting.coefficient = 0.0
|
|
88
|
+
# you will use `wait:` to avoid scheduling tasks too much sooner
|
|
89
|
+
urgency.waiting.coefficient = 0.0
|
|
90
|
+
# you will use `active:` to prioritize tasks already started even if the estimated amount is decreased
|
|
91
|
+
urgency.active.coefficient = 3.0
|
|
92
|
+
urgency.scheduled.coefficient = 0.0 # tasks that are not scheduled may still be urgent
|
|
85
93
|
|
|
86
94
|
# we need to be able to modify recurrent tasks without prompt
|
|
87
95
|
recurrence.confirmation=no
|
|
88
96
|
|
|
89
97
|
# suggested
|
|
90
|
-
urgency.inherit=1
|
|
98
|
+
urgency.inherit=1 # use dependencies to automatically prioritize tasks
|
|
91
99
|
urgency.blocking.coefficient=2
|
|
92
100
|
|
|
101
|
+
# give urgeny values to the estimated time
|
|
102
|
+
# this is log(estimation) + 1, with base so that 36 hours has ~12 urgency
|
|
93
103
|
urgency.uda.estimated.PT1H.coefficient = 1
|
|
94
104
|
urgency.uda.estimated.PT2H.coefficient = 2.32
|
|
95
105
|
urgency.uda.estimated.PT3H.coefficient = 3.67
|
|
@@ -126,11 +136,14 @@ urgency.uda.estimated.PT33H.coefficient = 11.73
|
|
|
126
136
|
urgency.uda.estimated.PT34H.coefficient = 11.81
|
|
127
137
|
urgency.uda.estimated.PT35H.coefficient = 11.89
|
|
128
138
|
urgency.uda.estimated.PT36H.coefficient = 11.96
|
|
139
|
+
# maximum duration is 36 hours
|
|
140
|
+
|
|
129
141
|
```
|
|
130
142
|
|
|
131
143
|
## CLI Options
|
|
132
144
|
|
|
133
145
|
```
|
|
146
|
+
|
|
134
147
|
-v, --verbose: increase output verbosity
|
|
135
|
-
|
|
148
|
+
|
|
136
149
|
```
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "taskcheck"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.3"
|
|
4
4
|
description = "Automatic scheduler for taskwarrior"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
|
-
dependencies = [
|
|
8
|
-
"appdirs>=1.4.4",
|
|
9
|
-
"icalendar>=6.0.1",
|
|
10
|
-
"requests-cache>=1.2.1",
|
|
11
|
-
"requests>=2.32.3",
|
|
12
|
-
]
|
|
7
|
+
dependencies = ["appdirs>=1.4.4", "icalendar>=6.0.1", "requests>=2.32.3"]
|
|
13
8
|
|
|
14
9
|
[project.scripts]
|
|
15
10
|
taskcheck = "taskcheck.__main__:main"
|
|
@@ -11,14 +11,6 @@ from taskcheck.ical import ical_to_dict
|
|
|
11
11
|
config_dir = Path(appdirs.user_config_dir("task"))
|
|
12
12
|
|
|
13
13
|
arg_parser = argparse.ArgumentParser()
|
|
14
|
-
arg_parser.add_argument(
|
|
15
|
-
"-t",
|
|
16
|
-
"--today",
|
|
17
|
-
type=float,
|
|
18
|
-
action="store",
|
|
19
|
-
help="specify how many hours you have already worked today (default: 0)",
|
|
20
|
-
default=0,
|
|
21
|
-
)
|
|
22
14
|
arg_parser.add_argument(
|
|
23
15
|
"-v", "--verbose", action="store_true", help="increase output verbosity"
|
|
24
16
|
)
|
|
@@ -54,11 +46,12 @@ def _hours_to_decimal(hour):
|
|
|
54
46
|
|
|
55
47
|
def _hours_to_time(hour):
|
|
56
48
|
hours = int(hour)
|
|
57
|
-
minutes = int((hour - hours) *
|
|
49
|
+
minutes = int((hour - hours) * 100)
|
|
58
50
|
return datetime.strptime(f"{hours}:{minutes}", "%H:%M").time()
|
|
59
51
|
|
|
60
52
|
|
|
61
|
-
def
|
|
53
|
+
def _time_to_decimal(time):
|
|
54
|
+
# round to 2 digits after the point
|
|
62
55
|
return time.hour + time.minute / 60
|
|
63
56
|
|
|
64
57
|
|
|
@@ -75,6 +68,7 @@ def get_available_hours(time_map, date, calendars):
|
|
|
75
68
|
# so let's convert them to datetime.time objects
|
|
76
69
|
schedule_start = _hours_to_time(schedule_start)
|
|
77
70
|
schedule_end = _hours_to_time(schedule_end)
|
|
71
|
+
schedule_blocked_hours = 0
|
|
78
72
|
for calendar in calendars:
|
|
79
73
|
for event in calendar:
|
|
80
74
|
# we use str to make object serializable as jsons
|
|
@@ -87,13 +81,24 @@ def get_available_hours(time_map, date, calendars):
|
|
|
87
81
|
break
|
|
88
82
|
elif event["end"].date() < date:
|
|
89
83
|
continue
|
|
84
|
+
|
|
90
85
|
# check if the event overlaps with one of the working hours
|
|
91
86
|
event_start = event["start"].time()
|
|
92
87
|
event_end = event["end"].time()
|
|
88
|
+
if event["start"].date() < date:
|
|
89
|
+
event_start = datetime(date.year, date.month, date.day, 0, 0).time()
|
|
90
|
+
if event["end"].date() > date:
|
|
91
|
+
event_end = datetime(date.year, date.month, date.day, 23, 59).time()
|
|
92
|
+
|
|
93
93
|
if event_start < schedule_end and event_end > schedule_start:
|
|
94
|
-
|
|
94
|
+
schedule_blocked_hours += _time_to_decimal(
|
|
95
95
|
min(schedule_end, event_end)
|
|
96
|
-
) -
|
|
96
|
+
) - _time_to_decimal(max(schedule_start, event_start))
|
|
97
|
+
if args.verbose and schedule_blocked_hours > 0:
|
|
98
|
+
print(
|
|
99
|
+
f"Blocked hours on {date} between {schedule_start} and {schedule_end}: {schedule_blocked_hours}"
|
|
100
|
+
)
|
|
101
|
+
blocked_hours += schedule_blocked_hours
|
|
97
102
|
available_hours -= blocked_hours
|
|
98
103
|
return available_hours
|
|
99
104
|
|
|
@@ -130,7 +135,20 @@ def get_long_range_time_map(time_maps, time_map_names, days_ahead, calendars):
|
|
|
130
135
|
task_time_map.append(daily_hours)
|
|
131
136
|
long_range_time_map[key] = task_time_map
|
|
132
137
|
|
|
133
|
-
|
|
138
|
+
today_time = _time_to_decimal(datetime.now().time())
|
|
139
|
+
today_weekday = datetime.today().strftime("%A").lower()
|
|
140
|
+
today_used_hours = 0
|
|
141
|
+
# compare with the time_maps of today
|
|
142
|
+
for time_map_name in time_map_names:
|
|
143
|
+
time_map = time_maps[time_map_name].get(today_weekday)
|
|
144
|
+
if time_map:
|
|
145
|
+
for schedule_start, schedule_end in time_map:
|
|
146
|
+
if schedule_start <= today_time <= schedule_end:
|
|
147
|
+
today_used_hours += today_time - schedule_start
|
|
148
|
+
break
|
|
149
|
+
elif today_time > schedule_end:
|
|
150
|
+
today_used_hours += schedule_end - schedule_start
|
|
151
|
+
return task_time_map, today_used_hours
|
|
134
152
|
|
|
135
153
|
|
|
136
154
|
def schedule_task_on_day(
|
|
@@ -143,14 +161,17 @@ def schedule_task_on_day(
|
|
|
143
161
|
today,
|
|
144
162
|
used_hours,
|
|
145
163
|
wait,
|
|
164
|
+
scheduling_note,
|
|
146
165
|
):
|
|
147
166
|
# we can schedule task on this day
|
|
148
167
|
employable_hours = task_time_map[day_offset] - used_hours[day_offset]
|
|
168
|
+
# avoid using too small values
|
|
169
|
+
employable_hours = 0.01 if 0.01 > employable_hours > 0 else employable_hours
|
|
149
170
|
current_date = today + timedelta(days=day_offset)
|
|
150
171
|
if wait and current_date <= wait:
|
|
151
172
|
if args.verbose:
|
|
152
173
|
print(f"Skipping date {current_date} because of wait date {wait}")
|
|
153
|
-
return start_date, end_date, task_remaining_hours, is_starting
|
|
174
|
+
return start_date, end_date, task_remaining_hours, is_starting, scheduling_note
|
|
154
175
|
|
|
155
176
|
if is_starting:
|
|
156
177
|
if args.verbose:
|
|
@@ -158,8 +179,12 @@ def schedule_task_on_day(
|
|
|
158
179
|
is_starting = False
|
|
159
180
|
start_date = current_date
|
|
160
181
|
|
|
161
|
-
|
|
182
|
+
# minimum value we admit is 0.01 (1 minute)
|
|
183
|
+
if task_remaining_hours <= employable_hours + 0.01:
|
|
162
184
|
# consume all the remaining task's hours
|
|
185
|
+
if scheduling_note != "":
|
|
186
|
+
scheduling_note += "\n"
|
|
187
|
+
scheduling_note += f"{current_date}: {task_remaining_hours:.2f} hours"
|
|
163
188
|
used_hours[day_offset] += task_remaining_hours
|
|
164
189
|
task_remaining_hours = 0
|
|
165
190
|
end_date = current_date
|
|
@@ -168,14 +193,19 @@ def schedule_task_on_day(
|
|
|
168
193
|
print(f"Used hours on {current_date}: {used_hours[day_offset]}")
|
|
169
194
|
else:
|
|
170
195
|
# consume all the available hours on this task
|
|
196
|
+
if scheduling_note != "":
|
|
197
|
+
scheduling_note += "\n"
|
|
198
|
+
scheduling_note += f"{current_date}: {employable_hours:.2f} hours"
|
|
171
199
|
task_remaining_hours -= employable_hours
|
|
172
200
|
used_hours[day_offset] += employable_hours
|
|
173
201
|
if args.verbose:
|
|
174
202
|
print(f"Working for {employable_hours} hours on task on {current_date}")
|
|
175
|
-
return start_date, end_date, task_remaining_hours, is_starting
|
|
203
|
+
return start_date, end_date, task_remaining_hours, is_starting, scheduling_note
|
|
176
204
|
|
|
177
205
|
|
|
178
|
-
def mark_end_date(
|
|
206
|
+
def mark_end_date(
|
|
207
|
+
due_date, end_date, start_date, scheduling_note, id, description=None
|
|
208
|
+
):
|
|
179
209
|
start_end_fields = [f"scheduled:{start_date}", f"completion_date:{end_date}"]
|
|
180
210
|
|
|
181
211
|
subprocess.run(
|
|
@@ -184,6 +214,7 @@ def mark_end_date(due_date, end_date, start_date, id, description=None):
|
|
|
184
214
|
str(id),
|
|
185
215
|
"modify",
|
|
186
216
|
*start_end_fields,
|
|
217
|
+
f'scheduling:"{scheduling_note}"',
|
|
187
218
|
],
|
|
188
219
|
stdout=subprocess.DEVNULL,
|
|
189
220
|
stderr=subprocess.DEVNULL,
|
|
@@ -204,6 +235,7 @@ def get_calendars(config):
|
|
|
204
235
|
all_day=calendar["event_all_day_is_blocking"],
|
|
205
236
|
expiration=calendar["expiration"],
|
|
206
237
|
verbose=args.verbose,
|
|
238
|
+
tz_name=calendar.get("timezone"),
|
|
207
239
|
)
|
|
208
240
|
calendar.sort(key=lambda e: e["start"])
|
|
209
241
|
calendars.append(calendar)
|
|
@@ -217,7 +249,7 @@ def check_tasks_sequentially(tasks, config):
|
|
|
217
249
|
time_maps = config["time_maps"]
|
|
218
250
|
today = datetime.today().date()
|
|
219
251
|
todo = [True if t["status"] not in AVOID_STATUS else False for t in tasks]
|
|
220
|
-
used_hours = [
|
|
252
|
+
used_hours = [0] * (config["scheduler"]["days_ahead"] + 1)
|
|
221
253
|
calendars = get_calendars(config)
|
|
222
254
|
|
|
223
255
|
while any(todo):
|
|
@@ -255,33 +287,45 @@ def check_tasks_sequentially(tasks, config):
|
|
|
255
287
|
)
|
|
256
288
|
|
|
257
289
|
task_remaining_hours = estimated_hours
|
|
258
|
-
task_time_map = get_long_range_time_map(
|
|
290
|
+
task_time_map, today_used_hours = get_long_range_time_map(
|
|
259
291
|
time_maps, time_map_names, config["scheduler"]["days_ahead"], calendars
|
|
260
292
|
)
|
|
293
|
+
used_hours[0] += today_used_hours
|
|
261
294
|
|
|
262
295
|
# Simulate work day-by-day until task is complete or past due
|
|
263
296
|
is_starting = True
|
|
297
|
+
scheduling_note = ""
|
|
264
298
|
start_date = end_date = None
|
|
265
299
|
for offset in range(len(task_time_map)):
|
|
266
300
|
if task_time_map[offset] > used_hours[offset]:
|
|
267
|
-
(
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
301
|
+
(
|
|
302
|
+
start_date,
|
|
303
|
+
end_date,
|
|
304
|
+
task_remaining_hours,
|
|
305
|
+
is_starting,
|
|
306
|
+
scheduling_note,
|
|
307
|
+
) = schedule_task_on_day(
|
|
308
|
+
is_starting,
|
|
309
|
+
offset,
|
|
310
|
+
start_date,
|
|
311
|
+
end_date,
|
|
312
|
+
task_remaining_hours,
|
|
313
|
+
task_time_map,
|
|
314
|
+
today,
|
|
315
|
+
used_hours,
|
|
316
|
+
wait_date,
|
|
317
|
+
scheduling_note,
|
|
279
318
|
)
|
|
280
319
|
|
|
281
320
|
if end_date is not None:
|
|
282
321
|
todo[i] = False
|
|
283
322
|
mark_end_date(
|
|
284
|
-
due_date,
|
|
323
|
+
due_date,
|
|
324
|
+
end_date,
|
|
325
|
+
start_date,
|
|
326
|
+
scheduling_note,
|
|
327
|
+
task["id"],
|
|
328
|
+
task["description"],
|
|
285
329
|
)
|
|
286
330
|
break
|
|
287
331
|
|
|
@@ -3,38 +3,29 @@ import json
|
|
|
3
3
|
from icalendar import Calendar
|
|
4
4
|
from datetime import datetime, timedelta
|
|
5
5
|
from dateutil.rrule import rruleset, rrulestr
|
|
6
|
-
|
|
6
|
+
import requests
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
import appdirs
|
|
9
|
+
import zoneinfo
|
|
9
10
|
|
|
10
11
|
import time
|
|
11
12
|
|
|
12
13
|
CACHE = Path(appdirs.user_cache_dir("taskcheck"))
|
|
13
14
|
|
|
14
15
|
|
|
15
|
-
def fetch_ical_data(url
|
|
16
|
-
|
|
17
|
-
"taskcheck",
|
|
18
|
-
use_cache_dir=True, # Save files in the default user cache dir
|
|
19
|
-
cache_control=True, # Use Cache-Control response headers for expiration, if available
|
|
20
|
-
urls_expire_after={url: expiration}, # Set expiration time
|
|
21
|
-
allowable_codes=[
|
|
22
|
-
200,
|
|
23
|
-
],
|
|
24
|
-
allowable_methods=["GET", "POST"], # Cache whatever HTTP methods you want
|
|
25
|
-
stale_if_error=True, # In case of request errors, use stale cache data if possible
|
|
26
|
-
)
|
|
27
|
-
response = session.get(url)
|
|
16
|
+
def fetch_ical_data(url):
|
|
17
|
+
response = requests.get(url)
|
|
28
18
|
response.raise_for_status()
|
|
29
19
|
return response.text
|
|
30
20
|
|
|
31
21
|
|
|
32
|
-
def parse_ical_events(ical_text, days_ahead, all_day):
|
|
22
|
+
def parse_ical_events(ical_text, days_ahead, all_day, tz_name=None, verbose=False):
|
|
33
23
|
cal = Calendar.from_ical(ical_text)
|
|
34
24
|
today = datetime.now().date()
|
|
35
25
|
end_date = today + timedelta(days=days_ahead)
|
|
36
26
|
|
|
37
27
|
events = []
|
|
28
|
+
exceptions = set()
|
|
38
29
|
|
|
39
30
|
for component in cal.walk():
|
|
40
31
|
if component.name == "VEVENT":
|
|
@@ -61,9 +52,12 @@ def parse_ical_events(ical_text, days_ahead, all_day):
|
|
|
61
52
|
)
|
|
62
53
|
event_end = event_start + timedelta(days=1) - timedelta(seconds=1)
|
|
63
54
|
if recurrence_id:
|
|
64
|
-
|
|
55
|
+
if end_date >= recurrence_id.dt.date() >= today:
|
|
56
|
+
# this was an occurrence of a recurring event
|
|
57
|
+
# but has been moved, let's record the exception that must be removed
|
|
58
|
+
exceptions.add(recurrence_id.dt.isoformat())
|
|
65
59
|
|
|
66
|
-
|
|
60
|
+
occurrences = rruleset()
|
|
67
61
|
if recurrence_rule:
|
|
68
62
|
# avoid handling rules that are already ended
|
|
69
63
|
# and don't create events beyond end_date
|
|
@@ -72,26 +66,49 @@ def parse_ical_events(ical_text, days_ahead, all_day):
|
|
|
72
66
|
until = until[0].date()
|
|
73
67
|
if until < today:
|
|
74
68
|
continue
|
|
75
|
-
elif until > end_date:
|
|
76
|
-
recurrence_rule["UNTIL"] = end_date
|
|
77
69
|
rrule = rrulestr(
|
|
78
70
|
str(recurrence_rule.to_ical(), "utf-8"),
|
|
79
71
|
dtstart=event_start,
|
|
80
72
|
)
|
|
81
|
-
|
|
73
|
+
occurrences.rrule(rrule) # type: ignore
|
|
74
|
+
# exceptions to recurring events
|
|
75
|
+
excdates = component.get("exdate")
|
|
76
|
+
if excdates is not None:
|
|
77
|
+
if isinstance(excdates, list):
|
|
78
|
+
# concatenate all the .dts into one list
|
|
79
|
+
excdates = [e for exdate in excdates for e in exdate.dts]
|
|
80
|
+
else:
|
|
81
|
+
excdates = excdates.dts
|
|
82
|
+
for excdate in excdates:
|
|
83
|
+
if end_date >= excdate.dt.date() >= today:
|
|
84
|
+
exceptions.add(excdate.dt.isoformat())
|
|
82
85
|
else:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
# if event is not recurring, add it as a single event
|
|
87
|
+
if event_start.date() >= today and event_end.date() <= end_date:
|
|
88
|
+
occurrences.rdate(event_start)
|
|
89
|
+
|
|
90
|
+
for occurrence in occurrences:
|
|
91
|
+
if tz_name is not None:
|
|
92
|
+
occurrence = occurrence.astimezone(zoneinfo.ZoneInfo(tz_name))
|
|
93
|
+
end = occurrence + (event_end - event_start)
|
|
94
|
+
if occurrence.date() >= today and end.date() <= end_date:
|
|
87
95
|
events.append(
|
|
88
96
|
{
|
|
89
|
-
"summary": component.get("summary"),
|
|
90
|
-
"start":
|
|
91
|
-
"end":
|
|
97
|
+
# "summary": component.get("summary"),
|
|
98
|
+
"start": occurrence.isoformat(),
|
|
99
|
+
"end": end.isoformat(),
|
|
92
100
|
}
|
|
93
101
|
)
|
|
102
|
+
elif end.date() > end_date:
|
|
103
|
+
break
|
|
94
104
|
|
|
105
|
+
with_exc_len = len(events)
|
|
106
|
+
events = [event for event in events if event["start"] not in exceptions]
|
|
107
|
+
if verbose:
|
|
108
|
+
print(
|
|
109
|
+
f"Removed {with_exc_len - len(events)} events that are exceptions to recurring events"
|
|
110
|
+
)
|
|
111
|
+
events.sort(key=lambda x: x["start"])
|
|
95
112
|
return events
|
|
96
113
|
|
|
97
114
|
|
|
@@ -100,7 +117,9 @@ def get_cache_filename(url):
|
|
|
100
117
|
return CACHE / f"{hash_object.hexdigest()}.json"
|
|
101
118
|
|
|
102
119
|
|
|
103
|
-
def ical_to_dict(
|
|
120
|
+
def ical_to_dict(
|
|
121
|
+
url, days_ahead=7, all_day=False, expiration=0.25, verbose=False, tz_name=None
|
|
122
|
+
):
|
|
104
123
|
CACHE.mkdir(exist_ok=True, parents=True)
|
|
105
124
|
cache_file = CACHE / get_cache_filename(url)
|
|
106
125
|
current_time = time.time()
|
|
@@ -117,11 +136,13 @@ def ical_to_dict(url, days_ahead=7, all_day=False, expiration=0.25, verbose=Fals
|
|
|
117
136
|
|
|
118
137
|
# Fetch and parse iCal data
|
|
119
138
|
ttt = time.time()
|
|
120
|
-
ical_text = fetch_ical_data(url
|
|
139
|
+
ical_text = fetch_ical_data(url)
|
|
121
140
|
if verbose:
|
|
122
141
|
print("Time taken to fetch ical data: ", time.time() - ttt)
|
|
123
142
|
ttt = time.time()
|
|
124
|
-
events = parse_ical_events(
|
|
143
|
+
events = parse_ical_events(
|
|
144
|
+
ical_text, days_ahead, all_day, tz_name=tz_name, verbose=verbose
|
|
145
|
+
)
|
|
125
146
|
if verbose:
|
|
126
147
|
print("Time taken to parse ical data: ", time.time() - ttt)
|
|
127
148
|
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: taskcheck
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Automatic scheduler for taskwarrior
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
7
7
|
License-File: LICENSE
|
|
8
8
|
Requires-Dist: appdirs>=1.4.4
|
|
9
9
|
Requires-Dist: icalendar>=6.0.1
|
|
10
|
-
Requires-Dist: requests-cache>=1.2.1
|
|
11
10
|
Requires-Dist: requests>=2.32.3
|
|
12
11
|
|
|
13
12
|
|
|
@@ -46,6 +45,8 @@ You can exclude a task from being scheduled by removing the `time_map` or `estim
|
|
|
46
45
|
|
|
47
46
|
You can see tasks that you can execute now with the `task ready` report.
|
|
48
47
|
|
|
48
|
+
You can see the schedule for a task in the `scheduling` UDA.
|
|
49
|
+
|
|
49
50
|
## Configuration
|
|
50
51
|
|
|
51
52
|
1. Create a TOML file at `~/.config/task/taskcheck.toml` with the following format:
|
|
@@ -72,6 +73,7 @@ days_ahead = 1000 # how far go with the schedule (lower values make the computat
|
|
|
72
73
|
[calendars.1]
|
|
73
74
|
url = "https://your/url/to/calendar.ics"
|
|
74
75
|
expiration = 0.08 # in hours (0.08 hours =~ 5 minutes)
|
|
76
|
+
timezone = "Europe/Rome" # if set, force timezone for this calendar; timezone values are TZ identifiers (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
|
|
75
77
|
|
|
76
78
|
[calendars.holidays]
|
|
77
79
|
url = "https://www.officeholidays.com/ics-clean/italy/milan"
|
|
@@ -90,18 +92,25 @@ uda.estimated.type = duration
|
|
|
90
92
|
uda.estimated.label = Estimated Time
|
|
91
93
|
uda.completion_date.type = date
|
|
92
94
|
uda.completion_date.label = Expected Completion Date
|
|
95
|
+
uda.scheduling.type = string
|
|
96
|
+
uda.scheduling.label = Scheduling
|
|
93
97
|
|
|
94
98
|
# Adjust urgency for taskcheck
|
|
95
|
-
|
|
96
|
-
urgency.waiting.coefficient = 0.0
|
|
99
|
+
# you will use `wait:` to avoid scheduling tasks too much sooner
|
|
100
|
+
urgency.waiting.coefficient = 0.0
|
|
101
|
+
# you will use `active:` to prioritize tasks already started even if the estimated amount is decreased
|
|
102
|
+
urgency.active.coefficient = 3.0
|
|
103
|
+
urgency.scheduled.coefficient = 0.0 # tasks that are not scheduled may still be urgent
|
|
97
104
|
|
|
98
105
|
# we need to be able to modify recurrent tasks without prompt
|
|
99
106
|
recurrence.confirmation=no
|
|
100
107
|
|
|
101
108
|
# suggested
|
|
102
|
-
urgency.inherit=1
|
|
109
|
+
urgency.inherit=1 # use dependencies to automatically prioritize tasks
|
|
103
110
|
urgency.blocking.coefficient=2
|
|
104
111
|
|
|
112
|
+
# give urgeny values to the estimated time
|
|
113
|
+
# this is log(estimation) + 1, with base so that 36 hours has ~12 urgency
|
|
105
114
|
urgency.uda.estimated.PT1H.coefficient = 1
|
|
106
115
|
urgency.uda.estimated.PT2H.coefficient = 2.32
|
|
107
116
|
urgency.uda.estimated.PT3H.coefficient = 3.67
|
|
@@ -138,11 +147,14 @@ urgency.uda.estimated.PT33H.coefficient = 11.73
|
|
|
138
147
|
urgency.uda.estimated.PT34H.coefficient = 11.81
|
|
139
148
|
urgency.uda.estimated.PT35H.coefficient = 11.89
|
|
140
149
|
urgency.uda.estimated.PT36H.coefficient = 11.96
|
|
150
|
+
# maximum duration is 36 hours
|
|
151
|
+
|
|
141
152
|
```
|
|
142
153
|
|
|
143
154
|
## CLI Options
|
|
144
155
|
|
|
145
156
|
```
|
|
157
|
+
|
|
146
158
|
-v, --verbose: increase output verbosity
|
|
147
|
-
|
|
159
|
+
|
|
148
160
|
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|