taskcheck 1.1.2__tar.gz → 1.3.0__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-1.3.0/PKG-INFO +114 -0
- taskcheck-1.3.0/README.md +99 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/pyproject.toml +4 -1
- {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/__main__.py +12 -1
- {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/common.py +25 -10
- taskcheck-1.3.0/taskcheck/google_calendar.py +172 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/parallel.py +22 -7
- {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/report.py +13 -4
- taskcheck-1.3.0/taskcheck.egg-info/PKG-INFO +114 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck.egg-info/SOURCES.txt +2 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck.egg-info/requires.txt +3 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_common.py +25 -0
- taskcheck-1.3.0/tests/test_google_calendar.py +81 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_parallel.py +27 -1
- {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_report.py +14 -4
- taskcheck-1.1.2/PKG-INFO +0 -229
- taskcheck-1.1.2/README.md +0 -217
- taskcheck-1.1.2/taskcheck.egg-info/PKG-INFO +0 -229
- {taskcheck-1.1.2 → taskcheck-1.3.0}/LICENSE +0 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/setup.cfg +0 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/ical.py +0 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck/install.py +0 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck.egg-info/dependency_links.txt +0 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck.egg-info/entry_points.txt +0 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/taskcheck.egg-info/top_level.txt +0 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_ical.py +0 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_install.py +0 -0
- {taskcheck-1.1.2 → taskcheck-1.3.0}/tests/test_main.py +0 -0
taskcheck-1.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: taskcheck
|
|
3
|
+
Version: 1.3.0
|
|
4
|
+
Summary: Automatic scheduler for taskwarrior
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: appdirs>=1.4.4
|
|
8
|
+
Requires-Dist: google-api-python-client>=2.198.0
|
|
9
|
+
Requires-Dist: google-auth>=2.56.2
|
|
10
|
+
Requires-Dist: google-auth-oauthlib>=1.2.2
|
|
11
|
+
Requires-Dist: icalendar>=6.0.1
|
|
12
|
+
Requires-Dist: random-unicode-emoji>=2.8
|
|
13
|
+
Requires-Dist: requests>=2.32.3
|
|
14
|
+
Requires-Dist: rich>=13.9.4
|
|
15
|
+
|
|
16
|
+

|
|
17
|
+
|
|
18
|
+
A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
|
|
19
|
+
It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
|
|
20
|
+
|
|
21
|
+
Use it if you want to:
|
|
22
|
+
- stop guessing what to do next
|
|
23
|
+
- fit tasks into real availability
|
|
24
|
+
- be sure your long-time plan won't go overdue
|
|
25
|
+
- keep Taskwarrior as the source of truth
|
|
26
|
+
|
|
27
|
+
> [!IMPORTANT]
|
|
28
|
+
> This repo is actively maintained again
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- ✨ Auto-schedule tasks from working hours + calendar blocks
|
|
33
|
+
- 🧭 Support complex working-hour maps
|
|
34
|
+
- ⏱️ Consider urgency, due dates, and dependencies
|
|
35
|
+
- 🧪 Dry-run mode to preview changes
|
|
36
|
+
- 🧠 Auto-adjust urgency when deadlines cannot be met
|
|
37
|
+
- 🔄 Force-refresh iCal calendars
|
|
38
|
+
- 📊 Custom reports for planned and unplanned tasks
|
|
39
|
+
- 🎨 Report styling with emoji and extra attributes
|
|
40
|
+
- 🗓️ Block time with iCal or Google Calendar, including all-day events
|
|
41
|
+
|
|
42
|
+
## Quick start
|
|
43
|
+
|
|
44
|
+
1. `pipx install taskcheck`
|
|
45
|
+
2. `taskcheck --install` ← setup only
|
|
46
|
+
3. add `estimated` + `time_map` UDAs
|
|
47
|
+
4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
|
|
48
|
+
5. `taskcheck --schedule`
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes back a schedule.
|
|
53
|
+
It respects:
|
|
54
|
+
- your working hours
|
|
55
|
+
- task duration estimates
|
|
56
|
+
- calendar events and vacations from iCal / Google Calendar
|
|
57
|
+
- task urgency and due dates
|
|
58
|
+
|
|
59
|
+
It updates Taskwarrior with:
|
|
60
|
+
- `scheduled` → when work should start
|
|
61
|
+
- `completion_date` → when work is expected to finish
|
|
62
|
+
- a warning when a task will miss its due date
|
|
63
|
+
|
|
64
|
+
### Required UDAs
|
|
65
|
+
|
|
66
|
+
- `estimated`: expected duration in hours
|
|
67
|
+
- `time_map`: which working-hour profile applies to the task, for example `work` or `weekend`
|
|
68
|
+
|
|
69
|
+
Example:
|
|
70
|
+
|
|
71
|
+
```toml
|
|
72
|
+
[time_maps.work]
|
|
73
|
+
monday = [[9, 12.30], [14, 17]]
|
|
74
|
+
tuesday = [[9, 12.30], [14, 17]]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Reports
|
|
78
|
+
|
|
79
|
+
- `taskcheck -r today` → tasks planned for today
|
|
80
|
+
- `taskcheck -r 1w` → tasks planned for the next week
|
|
81
|
+
|
|
82
|
+
## Reference
|
|
83
|
+
|
|
84
|
+
📚 Full reference: [REFERENCE.md](REFERENCE.md)
|
|
85
|
+
|
|
86
|
+
In short:
|
|
87
|
+
- `taskcheck --install` is interactive
|
|
88
|
+
- it installs required Taskwarrior settings if you confirm the first prompt
|
|
89
|
+
- it installs optional urgency/report tuning if you confirm the second prompt
|
|
90
|
+
- it can also create the default config file if you confirm the third prompt
|
|
91
|
+
- it does **not** schedule tasks or fetch calendars
|
|
92
|
+
- scheduling happens with `taskcheck --schedule`
|
|
93
|
+
- all flags/config keys/settings are documented in `REFERENCE.md`
|
|
94
|
+
|
|
95
|
+
## Algorithm
|
|
96
|
+
|
|
97
|
+
The algorithm simulates a workday one chunk at a time.
|
|
98
|
+
|
|
99
|
+
For each day starting from today, it sorts tasks by urgency and picks the most urgent task that fits the available time.
|
|
100
|
+
It assigns a small block of work, then recomputes urgency exactly as Taskwarrior would on that day.
|
|
101
|
+
If urgency changes, the next task choice can change too.
|
|
102
|
+
|
|
103
|
+
For `today`, taskcheck skips past hours.
|
|
104
|
+
|
|
105
|
+
The default work chunk is 2 hours (or less if the task is shorter); you can tune it with the Taskwarrior UDA `min_block`.
|
|
106
|
+
|
|
107
|
+
If a task would finish after its `due_date`, taskcheck retries. It keeps that task's current non-due urgency weight, then lowers the weight by 0.1 only for tasks that have not missed a deadline. Due urgency is never reduced. Retries stop when deadlines are respected or unflagged tasks reach a weight of 0.
|
|
108
|
+
|
|
109
|
+
## Tips and Tricks
|
|
110
|
+
|
|
111
|
+
- You can exclude a task from being scheduled by removing the `time_map` or `estimated` attributes.
|
|
112
|
+
- You can see tasks that you can execute now with the `task ready` report.
|
|
113
|
+
|
|
114
|
+
See the reference section above for flags, config keys, and installed Taskwarrior settings.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
A Taskwarrior scheduler for people who want a realistic plan, not a manual to-do list.
|
|
4
|
+
It turns tasks, working hours, and calendar blocks into an actionable schedule — then keeps due dates visible.
|
|
5
|
+
|
|
6
|
+
Use it if you want to:
|
|
7
|
+
- stop guessing what to do next
|
|
8
|
+
- fit tasks into real availability
|
|
9
|
+
- be sure your long-time plan won't go overdue
|
|
10
|
+
- keep Taskwarrior as the source of truth
|
|
11
|
+
|
|
12
|
+
> [!IMPORTANT]
|
|
13
|
+
> This repo is actively maintained again
|
|
14
|
+
|
|
15
|
+
## Features
|
|
16
|
+
|
|
17
|
+
- ✨ Auto-schedule tasks from working hours + calendar blocks
|
|
18
|
+
- 🧭 Support complex working-hour maps
|
|
19
|
+
- ⏱️ Consider urgency, due dates, and dependencies
|
|
20
|
+
- 🧪 Dry-run mode to preview changes
|
|
21
|
+
- 🧠 Auto-adjust urgency when deadlines cannot be met
|
|
22
|
+
- 🔄 Force-refresh iCal calendars
|
|
23
|
+
- 📊 Custom reports for planned and unplanned tasks
|
|
24
|
+
- 🎨 Report styling with emoji and extra attributes
|
|
25
|
+
- 🗓️ Block time with iCal or Google Calendar, including all-day events
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
1. `pipx install taskcheck`
|
|
30
|
+
2. `taskcheck --install` ← setup only
|
|
31
|
+
3. add `estimated` + `time_map` UDAs
|
|
32
|
+
4. edit `~/.config/task/taskcheck.toml` (see [Reference](#Reference))
|
|
33
|
+
5. `taskcheck --schedule`
|
|
34
|
+
|
|
35
|
+
## How it works
|
|
36
|
+
|
|
37
|
+
Taskcheck reads your pending and waiting tasks, estimates when each one can be worked on, and writes back a schedule.
|
|
38
|
+
It respects:
|
|
39
|
+
- your working hours
|
|
40
|
+
- task duration estimates
|
|
41
|
+
- calendar events and vacations from iCal / Google Calendar
|
|
42
|
+
- task urgency and due dates
|
|
43
|
+
|
|
44
|
+
It updates Taskwarrior with:
|
|
45
|
+
- `scheduled` → when work should start
|
|
46
|
+
- `completion_date` → when work is expected to finish
|
|
47
|
+
- a warning when a task will miss its due date
|
|
48
|
+
|
|
49
|
+
### Required UDAs
|
|
50
|
+
|
|
51
|
+
- `estimated`: expected duration in hours
|
|
52
|
+
- `time_map`: which working-hour profile applies to the task, for example `work` or `weekend`
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
|
|
56
|
+
```toml
|
|
57
|
+
[time_maps.work]
|
|
58
|
+
monday = [[9, 12.30], [14, 17]]
|
|
59
|
+
tuesday = [[9, 12.30], [14, 17]]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Reports
|
|
63
|
+
|
|
64
|
+
- `taskcheck -r today` → tasks planned for today
|
|
65
|
+
- `taskcheck -r 1w` → tasks planned for the next week
|
|
66
|
+
|
|
67
|
+
## Reference
|
|
68
|
+
|
|
69
|
+
📚 Full reference: [REFERENCE.md](REFERENCE.md)
|
|
70
|
+
|
|
71
|
+
In short:
|
|
72
|
+
- `taskcheck --install` is interactive
|
|
73
|
+
- it installs required Taskwarrior settings if you confirm the first prompt
|
|
74
|
+
- it installs optional urgency/report tuning if you confirm the second prompt
|
|
75
|
+
- it can also create the default config file if you confirm the third prompt
|
|
76
|
+
- it does **not** schedule tasks or fetch calendars
|
|
77
|
+
- scheduling happens with `taskcheck --schedule`
|
|
78
|
+
- all flags/config keys/settings are documented in `REFERENCE.md`
|
|
79
|
+
|
|
80
|
+
## Algorithm
|
|
81
|
+
|
|
82
|
+
The algorithm simulates a workday one chunk at a time.
|
|
83
|
+
|
|
84
|
+
For each day starting from today, it sorts tasks by urgency and picks the most urgent task that fits the available time.
|
|
85
|
+
It assigns a small block of work, then recomputes urgency exactly as Taskwarrior would on that day.
|
|
86
|
+
If urgency changes, the next task choice can change too.
|
|
87
|
+
|
|
88
|
+
For `today`, taskcheck skips past hours.
|
|
89
|
+
|
|
90
|
+
The default work chunk is 2 hours (or less if the task is shorter); you can tune it with the Taskwarrior UDA `min_block`.
|
|
91
|
+
|
|
92
|
+
If a task would finish after its `due_date`, taskcheck retries. It keeps that task's current non-due urgency weight, then lowers the weight by 0.1 only for tasks that have not missed a deadline. Due urgency is never reduced. Retries stop when deadlines are respected or unflagged tasks reach a weight of 0.
|
|
93
|
+
|
|
94
|
+
## Tips and Tricks
|
|
95
|
+
|
|
96
|
+
- You can exclude a task from being scheduled by removing the `time_map` or `estimated` attributes.
|
|
97
|
+
- You can see tasks that you can execute now with the `task ready` report.
|
|
98
|
+
|
|
99
|
+
See the reference section above for flags, config keys, and installed Taskwarrior settings.
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "taskcheck"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.3.0"
|
|
4
4
|
description = "Automatic scheduler for taskwarrior"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
7
7
|
dependencies = [
|
|
8
8
|
"appdirs>=1.4.4",
|
|
9
|
+
"google-api-python-client>=2.198.0",
|
|
10
|
+
"google-auth>=2.56.2",
|
|
11
|
+
"google-auth-oauthlib>=1.2.2",
|
|
9
12
|
"icalendar>=6.0.1",
|
|
10
13
|
"random-unicode-emoji>=2.8",
|
|
11
14
|
"requests>=2.32.3",
|
|
@@ -52,7 +52,12 @@ arg_parser.add_argument(
|
|
|
52
52
|
dest="auto_adjust_urgency",
|
|
53
53
|
action="store_false",
|
|
54
54
|
default=True,
|
|
55
|
-
help="Disable automatic
|
|
55
|
+
help="Disable automatic per-task urgency adjustment when tasks cannot be completed on time.",
|
|
56
|
+
)
|
|
57
|
+
arg_parser.add_argument(
|
|
58
|
+
"--add-google-calendar",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="Authenticate with Google, list calendars, and print a config section for the selected calendar.",
|
|
56
61
|
)
|
|
57
62
|
|
|
58
63
|
|
|
@@ -100,6 +105,12 @@ def main():
|
|
|
100
105
|
install()
|
|
101
106
|
return
|
|
102
107
|
|
|
108
|
+
if getattr(args, "add_google_calendar", False) is True:
|
|
109
|
+
from taskcheck.google_calendar import add_google_calendar
|
|
110
|
+
|
|
111
|
+
add_google_calendar(taskrc=args.taskrc)
|
|
112
|
+
return
|
|
113
|
+
|
|
103
114
|
if args.schedule:
|
|
104
115
|
config = load_config()
|
|
105
116
|
check_tasks_kwargs = dict(
|
|
@@ -211,16 +211,31 @@ def mark_end_date(
|
|
|
211
211
|
def get_calendars(config, verbose=False, force_update=False):
|
|
212
212
|
calendars = []
|
|
213
213
|
for calname in config["calendars"]:
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
214
|
+
calendar_config = config["calendars"][calname]
|
|
215
|
+
if (
|
|
216
|
+
calendar_config.get("provider") == "google"
|
|
217
|
+
or calendar_config["url"].startswith("google://")
|
|
218
|
+
):
|
|
219
|
+
from taskcheck.google_calendar import google_calendar_to_dict
|
|
220
|
+
|
|
221
|
+
calendar = google_calendar_to_dict(
|
|
222
|
+
calendar_config.get(
|
|
223
|
+
"calendar_id", calendar_config["url"].removeprefix("google://")
|
|
224
|
+
),
|
|
225
|
+
calendar_config["token_path"],
|
|
226
|
+
config["scheduler"]["days_ahead"],
|
|
227
|
+
all_day=calendar_config["event_all_day_is_blocking"],
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
calendar = ical_to_dict(
|
|
231
|
+
calendar_config["url"],
|
|
232
|
+
config["scheduler"]["days_ahead"],
|
|
233
|
+
all_day=calendar_config["event_all_day_is_blocking"],
|
|
234
|
+
expiration=calendar_config["expiration"],
|
|
235
|
+
verbose=verbose,
|
|
236
|
+
tz_name=calendar_config.get("timezone"),
|
|
237
|
+
force_update=force_update,
|
|
238
|
+
)
|
|
224
239
|
calendar.sort(key=lambda e: e["start"])
|
|
225
240
|
calendars.append(calendar)
|
|
226
241
|
if verbose:
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import warnings
|
|
3
|
+
from datetime import datetime, time, timedelta
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import urlparse
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from google.auth.transport.requests import Request
|
|
9
|
+
from google.oauth2.credentials import Credentials
|
|
10
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
11
|
+
from googleapiclient.discovery import build
|
|
12
|
+
|
|
13
|
+
from taskcheck.common import config_dir, get_task_env
|
|
14
|
+
|
|
15
|
+
SCOPES = [
|
|
16
|
+
"openid",
|
|
17
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
18
|
+
"https://www.googleapis.com/auth/userinfo.profile",
|
|
19
|
+
"https://www.googleapis.com/auth/calendar.readonly",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_google_data_dir(taskrc=None):
|
|
24
|
+
env = get_task_env(taskrc)
|
|
25
|
+
base = Path(env.get("TASKDATA", config_dir))
|
|
26
|
+
return base / "google"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_client_secrets_path(taskrc=None):
|
|
30
|
+
repo_secret = Path.cwd() / "client_secret.json"
|
|
31
|
+
if repo_secret.exists():
|
|
32
|
+
return repo_secret
|
|
33
|
+
return get_google_data_dir(taskrc) / "client_secret.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_token_path(account_id, taskrc=None):
|
|
37
|
+
return get_google_data_dir(taskrc) / f"{account_id}.token.json"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _safe_account_id(email):
|
|
41
|
+
return email.replace("@", "_").replace(".", "_")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_credentials(taskrc=None):
|
|
45
|
+
secrets_path = get_client_secrets_path(taskrc)
|
|
46
|
+
if not secrets_path.exists():
|
|
47
|
+
raise FileNotFoundError(f"Missing Google OAuth client secrets: {secrets_path}")
|
|
48
|
+
|
|
49
|
+
token_dir = get_google_data_dir(taskrc)
|
|
50
|
+
token_dir.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
token_path = token_dir / "current.token.json"
|
|
52
|
+
creds = None
|
|
53
|
+
if token_path.exists():
|
|
54
|
+
creds = Credentials.from_authorized_user_file(token_path, SCOPES)
|
|
55
|
+
if creds and creds.expired and creds.refresh_token:
|
|
56
|
+
creds.refresh(Request())
|
|
57
|
+
if not creds or not creds.valid:
|
|
58
|
+
flow = InstalledAppFlow.from_client_secrets_file(str(secrets_path), SCOPES)
|
|
59
|
+
with warnings.catch_warnings():
|
|
60
|
+
warnings.filterwarnings("ignore", message=r'^Scope has changed from .*')
|
|
61
|
+
creds = flow.run_local_server(port=0)
|
|
62
|
+
token_path.write_text(creds.to_json())
|
|
63
|
+
return creds
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def get_google_user_email(credentials):
|
|
67
|
+
response = requests.get(
|
|
68
|
+
"https://openidconnect.googleapis.com/v1/userinfo",
|
|
69
|
+
headers={"Authorization": f"Bearer {credentials.token}"},
|
|
70
|
+
timeout=30,
|
|
71
|
+
)
|
|
72
|
+
response.raise_for_status()
|
|
73
|
+
payload = response.json()
|
|
74
|
+
return payload["email"], payload.get("name", payload["email"])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def list_calendars(credentials):
|
|
78
|
+
service = build("calendar", "v3", credentials=credentials, cache_discovery=False)
|
|
79
|
+
result = service.calendarList().list().execute()
|
|
80
|
+
return result.get("items", [])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def google_calendar_to_dict(calendar_id, token_path, days_ahead, all_day=True):
|
|
84
|
+
token_path = Path(token_path).expanduser()
|
|
85
|
+
if not token_path.exists():
|
|
86
|
+
raise FileNotFoundError(f"Missing Google OAuth token: {token_path}")
|
|
87
|
+
|
|
88
|
+
credentials = Credentials.from_authorized_user_file(str(token_path), SCOPES)
|
|
89
|
+
if credentials.expired and credentials.refresh_token:
|
|
90
|
+
credentials.refresh(Request())
|
|
91
|
+
token_path.write_text(credentials.to_json())
|
|
92
|
+
if not credentials.valid:
|
|
93
|
+
raise RuntimeError(f"Invalid Google OAuth token: {token_path}")
|
|
94
|
+
|
|
95
|
+
service = build("calendar", "v3", credentials=credentials, cache_discovery=False)
|
|
96
|
+
timezone = datetime.now().astimezone().tzinfo
|
|
97
|
+
start = datetime.combine(datetime.now().date(), time.min, timezone)
|
|
98
|
+
end = start + timedelta(days=days_ahead + 1)
|
|
99
|
+
params = {
|
|
100
|
+
"calendarId": calendar_id,
|
|
101
|
+
"timeMin": start.isoformat(),
|
|
102
|
+
"timeMax": end.isoformat(),
|
|
103
|
+
"singleEvents": True,
|
|
104
|
+
"orderBy": "startTime",
|
|
105
|
+
}
|
|
106
|
+
events = []
|
|
107
|
+
while True:
|
|
108
|
+
result = service.events().list(**params).execute()
|
|
109
|
+
for item in result.get("items", []):
|
|
110
|
+
event_start = item.get("start", {})
|
|
111
|
+
event_end = item.get("end", {})
|
|
112
|
+
if "dateTime" in event_start and "dateTime" in event_end:
|
|
113
|
+
events.append(
|
|
114
|
+
{"start": event_start["dateTime"], "end": event_end["dateTime"]}
|
|
115
|
+
)
|
|
116
|
+
elif all_day and "date" in event_start and "date" in event_end:
|
|
117
|
+
events.append(
|
|
118
|
+
{
|
|
119
|
+
"start": datetime.fromisoformat(event_start["date"]).isoformat(),
|
|
120
|
+
"end": datetime.fromisoformat(event_end["date"]).isoformat(),
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
page_token = result.get("nextPageToken")
|
|
124
|
+
if not page_token:
|
|
125
|
+
break
|
|
126
|
+
params["pageToken"] = page_token
|
|
127
|
+
return events
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def select_from_list(items, title_key="summary"):
|
|
131
|
+
for idx, item in enumerate(items, start=1):
|
|
132
|
+
print(f"{idx}. {item.get(title_key, item.get('id'))}")
|
|
133
|
+
while True:
|
|
134
|
+
choice = input("Select one calendar: ").strip()
|
|
135
|
+
if choice.isdigit() and 1 <= int(choice) <= len(items):
|
|
136
|
+
return items[int(choice) - 1]
|
|
137
|
+
print("Invalid selection")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def render_calendar_config(account_id, calendar, taskrc=None):
|
|
141
|
+
token_path = get_token_path(account_id, taskrc)
|
|
142
|
+
return f'''[calendars."{account_id}.{calendar["id"]}"]
|
|
143
|
+
url = "google://{calendar["id"]}"
|
|
144
|
+
provider = "google"
|
|
145
|
+
account = "{account_id}"
|
|
146
|
+
calendar_id = "{calendar["id"]}"
|
|
147
|
+
token_path = "{token_path}"
|
|
148
|
+
event_all_day_is_blocking = true
|
|
149
|
+
expiration = 0.25
|
|
150
|
+
'''
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def add_google_calendar(taskrc=None):
|
|
154
|
+
credentials = load_credentials(taskrc)
|
|
155
|
+
email, name = get_google_user_email(credentials)
|
|
156
|
+
account_id = _safe_account_id(email)
|
|
157
|
+
token_path = get_token_path(account_id, taskrc)
|
|
158
|
+
token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
token_path.write_text(credentials.to_json())
|
|
160
|
+
|
|
161
|
+
calendars = list_calendars(credentials)
|
|
162
|
+
if not calendars:
|
|
163
|
+
raise RuntimeError("No Google calendars found for this account")
|
|
164
|
+
calendar = select_from_list(calendars)
|
|
165
|
+
print(render_calendar_config(account_id, calendar, taskrc=taskrc))
|
|
166
|
+
return {
|
|
167
|
+
"account_id": account_id,
|
|
168
|
+
"email": email,
|
|
169
|
+
"name": name,
|
|
170
|
+
"calendar": calendar,
|
|
171
|
+
"token_path": str(token_path),
|
|
172
|
+
}
|
|
@@ -111,6 +111,7 @@ def check_tasks_parallel(
|
|
|
111
111
|
urgency_coefficients = get_urgency_coefficients(taskrc=taskrc)
|
|
112
112
|
|
|
113
113
|
current_weight = initial_weight_urgency
|
|
114
|
+
frozen_weights = {}
|
|
114
115
|
|
|
115
116
|
# Always re-initialize task_info inside the loop to avoid mutated state
|
|
116
117
|
task_info_original = initialize_task_info(
|
|
@@ -132,6 +133,7 @@ def check_tasks_parallel(
|
|
|
132
133
|
urgency_coefficients,
|
|
133
134
|
verbose,
|
|
134
135
|
current_weight,
|
|
136
|
+
frozen_weights,
|
|
135
137
|
)
|
|
136
138
|
|
|
137
139
|
# Check if any tasks cannot be completed on time
|
|
@@ -159,7 +161,9 @@ def check_tasks_parallel(
|
|
|
159
161
|
if not tasks_overdue or not auto_adjust_urgency:
|
|
160
162
|
break
|
|
161
163
|
|
|
162
|
-
|
|
164
|
+
for task in tasks_overdue:
|
|
165
|
+
frozen_weights.setdefault(task["uuid"], current_weight)
|
|
166
|
+
|
|
163
167
|
if verbose:
|
|
164
168
|
console.print(
|
|
165
169
|
f"[red]{len(tasks_overdue)} task(s) cannot be completed on time[/red]"
|
|
@@ -175,12 +179,12 @@ def check_tasks_parallel(
|
|
|
175
179
|
if tasks_overdue:
|
|
176
180
|
# We tried to adjust but still have overdue tasks
|
|
177
181
|
console.print(
|
|
178
|
-
"[red]Warning: cannot find a solution
|
|
182
|
+
"[red]Warning: cannot find a solution after per-task urgency adjustment[/red]"
|
|
179
183
|
)
|
|
180
184
|
else:
|
|
181
185
|
# We successfully resolved overdue tasks by adjusting weight
|
|
182
186
|
console.print(
|
|
183
|
-
f"[green]
|
|
187
|
+
f"[green]Resolved deadlines with per-task urgency adjustment[/green]"
|
|
184
188
|
)
|
|
185
189
|
|
|
186
190
|
if dry_run:
|
|
@@ -275,6 +279,7 @@ def allocate_time_for_day(
|
|
|
275
279
|
urgency_coefficients,
|
|
276
280
|
verbose,
|
|
277
281
|
weight_urgency,
|
|
282
|
+
frozen_weights=None,
|
|
278
283
|
):
|
|
279
284
|
date = datetime.today().date() + timedelta(days=day_offset)
|
|
280
285
|
total_available_hours = compute_total_available_hours(task_info, day_offset)
|
|
@@ -287,7 +292,13 @@ def allocate_time_for_day(
|
|
|
287
292
|
tasks_remaining = prepare_tasks_remaining(task_info, day_offset)
|
|
288
293
|
|
|
289
294
|
while day_remaining_hours > 0 and tasks_remaining:
|
|
290
|
-
recompute_urgencies(
|
|
295
|
+
recompute_urgencies(
|
|
296
|
+
tasks_remaining,
|
|
297
|
+
urgency_coefficients,
|
|
298
|
+
date,
|
|
299
|
+
weight_urgency,
|
|
300
|
+
frozen_weights,
|
|
301
|
+
)
|
|
291
302
|
sorted_task_ids = sorted(
|
|
292
303
|
tasks_remaining.keys(),
|
|
293
304
|
key=lambda x: tasks_remaining[x]["urgency"],
|
|
@@ -423,8 +434,11 @@ def update_urgency(info, urgency_key, urgency_compute_fn, urgency_coefficients,
|
|
|
423
434
|
info["urgency"] = info["urgency"] - old_urgency + urgency_value
|
|
424
435
|
|
|
425
436
|
|
|
426
|
-
def recompute_urgencies(
|
|
427
|
-
|
|
437
|
+
def recompute_urgencies(
|
|
438
|
+
tasks_remaining, urgency_coefficients, date, weight_urgency, frozen_weights=None
|
|
439
|
+
):
|
|
440
|
+
"""Recompute urgency simulating that today is `date`."""
|
|
441
|
+
frozen_weights = frozen_weights or {}
|
|
428
442
|
# Recompute estimated urgencies as before
|
|
429
443
|
for info in tasks_remaining.values():
|
|
430
444
|
# Update estimated urgency
|
|
@@ -438,7 +452,8 @@ def recompute_urgencies(tasks_remaining, urgency_coefficients, date, weight_urge
|
|
|
438
452
|
|
|
439
453
|
# Apply weights to create a combined score
|
|
440
454
|
base_urgency = info["urgency"] - info["due_urgency"]
|
|
441
|
-
|
|
455
|
+
task_weight = frozen_weights.get(info["task"]["uuid"], weight_urgency)
|
|
456
|
+
base_urgency *= task_weight
|
|
442
457
|
# Always add due_urgency, only weight estimated and age
|
|
443
458
|
weighted_urgency = base_urgency + info["due_urgency"]
|
|
444
459
|
info["urgency"] = weighted_urgency
|
|
@@ -187,7 +187,7 @@ def get_tasks(config, tasks, year, month, day):
|
|
|
187
187
|
if m:
|
|
188
188
|
due = task.get("due", "")
|
|
189
189
|
if due:
|
|
190
|
-
due =
|
|
190
|
+
due = parse_taskwarrior_timestamp(due)
|
|
191
191
|
due = due - datetime.today()
|
|
192
192
|
valid_tasks.append(
|
|
193
193
|
{
|
|
@@ -219,7 +219,7 @@ def get_taskwarrior_date(date, _retry=True, taskrc=None):
|
|
|
219
219
|
)
|
|
220
220
|
date = date.stdout.strip()
|
|
221
221
|
try:
|
|
222
|
-
date =
|
|
222
|
+
date = parse_taskwarrior_timestamp(date)
|
|
223
223
|
except Exception as _:
|
|
224
224
|
if _retry:
|
|
225
225
|
return get_taskwarrior_date("today+" + date, False, taskrc)
|
|
@@ -247,7 +247,7 @@ def tostring(value):
|
|
|
247
247
|
return value.strftime("%Y-%m-%d %H:%M")
|
|
248
248
|
elif isinstance(value, str):
|
|
249
249
|
try:
|
|
250
|
-
date =
|
|
250
|
+
date = parse_taskwarrior_timestamp(value)
|
|
251
251
|
return date.strftime("%Y-%m-%d %H:%M")
|
|
252
252
|
except ValueError:
|
|
253
253
|
return value
|
|
@@ -275,7 +275,7 @@ def get_unplanned_tasks(config, tasks, taskrc=None):
|
|
|
275
275
|
task
|
|
276
276
|
for task in tasks
|
|
277
277
|
if not task.get("due")
|
|
278
|
-
or
|
|
278
|
+
or parse_taskwarrior_timestamp(task["due"]) <= due_limit
|
|
279
279
|
]
|
|
280
280
|
|
|
281
281
|
|
|
@@ -298,6 +298,15 @@ def generate_report(config, constraint, verbose=False, force_update=False, taskr
|
|
|
298
298
|
display_tasks_table(console, config, this_day_tasks)
|
|
299
299
|
|
|
300
300
|
|
|
301
|
+
def parse_taskwarrior_timestamp(value):
|
|
302
|
+
for fmt in ("%Y%m%dT%H%M%SZ", "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S"):
|
|
303
|
+
try:
|
|
304
|
+
return datetime.strptime(value, fmt)
|
|
305
|
+
except ValueError:
|
|
306
|
+
pass
|
|
307
|
+
raise ValueError(value)
|
|
308
|
+
|
|
309
|
+
|
|
301
310
|
def fetch_tasks(taskrc=None):
|
|
302
311
|
"""Fetch tasks from the task manager and return them as a JSON object."""
|
|
303
312
|
from taskcheck.common import get_task_env
|