ggltasks 0.1.7__py3-none-any.whl

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.
ggltasks/__init__.py ADDED
File without changes
ggltasks/auth.py ADDED
@@ -0,0 +1,53 @@
1
+ import os
2
+ import google.oauth2.credentials
3
+ from google_auth_oauthlib.flow import InstalledAppFlow
4
+ from google.auth.transport.requests import Request
5
+
6
+ SCOPES = ['https://www.googleapis.com/auth/tasks']
7
+ HOME_DIR = os.path.expanduser('~')
8
+ GGLTASKS_DIR = os.path.join(HOME_DIR, '.ggltasks')
9
+ TOKEN_PATH = os.path.join(GGLTASKS_DIR, 'token.json')
10
+ CLIENT_SECRETS_PATH = os.path.join(GGLTASKS_DIR, 'client_secrets.json')
11
+
12
+
13
+ def _ensure_dir_exists():
14
+ os.makedirs(GGLTASKS_DIR, exist_ok=True)
15
+
16
+
17
+ def get_credentials():
18
+ _ensure_dir_exists()
19
+
20
+ if not os.path.exists(CLIENT_SECRETS_PATH):
21
+ raise FileNotFoundError(
22
+ f"\n\n Google API credentials not found!\n"
23
+ f" Expected location: {CLIENT_SECRETS_PATH}\n\n"
24
+ f" Steps to fix:\n"
25
+ f" 1. Go to https://console.cloud.google.com/\n"
26
+ f" 2. APIs & Services → Credentials → Create Credentials → OAuth 2.0 Client ID\n"
27
+ f" 3. Application type: Desktop App\n"
28
+ f" 4. Download the JSON file, rename it to 'client_secrets.json'\n"
29
+ f" 5. Move it to: {CLIENT_SECRETS_PATH}\n"
30
+ )
31
+
32
+ creds = None
33
+ if os.path.exists(TOKEN_PATH):
34
+ try:
35
+ creds = google.oauth2.credentials.Credentials.from_authorized_user_file(TOKEN_PATH, SCOPES)
36
+ except Exception as e:
37
+ print(f"Error loading token.json: {e}. Initiating full re-authentication.")
38
+ creds = None
39
+
40
+ if not creds or not creds.valid:
41
+ if creds and creds.expired and creds.refresh_token:
42
+ try:
43
+ creds.refresh(Request())
44
+ except Exception as e:
45
+ print(f"Token refresh failed: {e}. Initiating full re-authentication.")
46
+ creds = None
47
+ if not creds:
48
+ flow = InstalledAppFlow.from_client_secrets_file(CLIENT_SECRETS_PATH, SCOPES)
49
+ creds = flow.run_local_server(port=0)
50
+ with open(TOKEN_PATH, 'w') as token:
51
+ token.write(creds.to_json())
52
+
53
+ return creds
@@ -0,0 +1,37 @@
1
+ import os
2
+ import json
3
+
4
+ HOME_DIR = os.path.expanduser('~')
5
+ GGLTASKS_DIR = os.path.join(HOME_DIR, '.ggltasks')
6
+ STORAGE_FILE = os.path.join(GGLTASKS_DIR, 'local_tasks.json')
7
+
8
+
9
+ def _ensure_dir_exists():
10
+ os.makedirs(GGLTASKS_DIR, exist_ok=True)
11
+
12
+
13
+ def load_data():
14
+ _ensure_dir_exists()
15
+ if not os.path.exists(STORAGE_FILE):
16
+ return {'task_lists': [], 'tasks': {}}
17
+ try:
18
+ with open(STORAGE_FILE, 'r') as f:
19
+ return json.load(f)
20
+ except (json.JSONDecodeError, IOError):
21
+ return {'task_lists': [], 'tasks': {}}
22
+
23
+
24
+ def save_data(data):
25
+ _ensure_dir_exists()
26
+ tmp_path = STORAGE_FILE + '.tmp'
27
+ try:
28
+ with open(tmp_path, 'w') as f:
29
+ json.dump(data, f, indent=4)
30
+ os.replace(tmp_path, STORAGE_FILE)
31
+ return True
32
+ except IOError:
33
+ try:
34
+ os.remove(tmp_path)
35
+ except OSError:
36
+ pass
37
+ return False
ggltasks/main.py ADDED
@@ -0,0 +1,444 @@
1
+ from unicurses import *
2
+ from .task_service import TaskService
3
+ from .ui_manager import UIManager
4
+ import sys
5
+ import curses
6
+ from dateutil.parser import ParserError, isoparse
7
+ import os
8
+ import subprocess
9
+ import tempfile
10
+ import threading
11
+ import queue
12
+ from unicurses import wrapper
13
+
14
+ _sync_result_queue = queue.Queue()
15
+ _sync_thread = None
16
+
17
+
18
+ def _run_sync(service, result_queue):
19
+ try:
20
+ service.sync_to_google()
21
+ result_queue.put(('ok', None))
22
+ except Exception as exc:
23
+ result_queue.put(('err', str(exc)))
24
+
25
+
26
+ def _trigger_background_sync(service, ui_manager):
27
+ global _sync_thread
28
+ if _sync_thread is not None and _sync_thread.is_alive():
29
+ return
30
+ ui_manager.start_sync_animation()
31
+ _sync_thread = threading.Thread(
32
+ target=_run_sync,
33
+ args=(service, _sync_result_queue),
34
+ daemon=True,
35
+ )
36
+ _sync_thread.start()
37
+
38
+
39
+ def open_editor_for_task_notes(stdscr, app_state, ui_manager):
40
+ selected_task = app_state.tasks[ui_manager.selected_task_idx]
41
+ initial_content = selected_task.get('notes', '')
42
+ editor = os.environ.get('EDITOR', 'vim')
43
+
44
+ with tempfile.NamedTemporaryFile(suffix=".tmp", delete=False, mode='w+', encoding='utf-8') as tf:
45
+ tf.write(initial_content)
46
+ temp_path = tf.name
47
+
48
+ def_prog_mode()
49
+ endwin()
50
+ subprocess.call([editor, temp_path])
51
+ reset_prog_mode()
52
+ doupdate()
53
+
54
+ with open(temp_path, 'r', encoding='utf-8') as tf:
55
+ new_note = tf.read()
56
+
57
+ os.remove(temp_path)
58
+
59
+ if new_note != initial_content:
60
+ app_state.service.change_detail_task(app_state.active_list_id, selected_task['id'], new_note)
61
+ app_state.refresh_data()
62
+
63
+
64
+ def is_valid_date(date_str):
65
+ try:
66
+ isoparse(date_str)
67
+ return True
68
+ except (ParserError, ValueError):
69
+ return False
70
+
71
+
72
+ class AppState:
73
+ def __init__(self, task_service):
74
+ self.service = task_service
75
+ self.task_lists = self.service.get_task_lists()
76
+ self.active_list_id = self.service.active_list_id
77
+ self.current_parent_task_id = None
78
+ self.viewing_completed_tasks = False
79
+ self.filtered_tasks_cache = {}
80
+ self.task_counts = {}
81
+ self.list_buffer = ""
82
+ self.task_buffer = ""
83
+ self.parent_task_id_stack = []
84
+ self.parent_task_idx_stack = []
85
+ self.tasks = self.get_tasks_for_active_list()
86
+ self.tasks_due_today = self.service.get_tasks_due_today()
87
+ self.calculate_task_counts()
88
+ self.show_help = False
89
+
90
+ def calculate_task_counts(self):
91
+ for task_list in self.task_lists:
92
+ list_id = task_list['id']
93
+ self.task_counts[list_id] = len([
94
+ t for t in self.service.get_tasks_for_list(list_id)
95
+ if t.get('status') != 'completed'
96
+ ])
97
+
98
+ def get_tasks_for_active_list(self):
99
+ if self.current_parent_task_id:
100
+ all_tasks = self.service.get_subtasks(self.active_list_id, self.current_parent_task_id)
101
+ else:
102
+ if self.active_list_id not in self.filtered_tasks_cache or self.service.dirty:
103
+ fetched_tasks = self.service.get_tasks_for_list(self.active_list_id)
104
+ self.filtered_tasks_cache[self.active_list_id] = fetched_tasks
105
+ all_tasks = self.filtered_tasks_cache[self.active_list_id]
106
+
107
+ if self.viewing_completed_tasks:
108
+ return [t for t in all_tasks if t.get('status') == 'completed']
109
+ else:
110
+ filtered = [t for t in all_tasks if t.get('status') != 'completed']
111
+ has_completed = any(t.get('status') == 'completed' for t in all_tasks)
112
+ if has_completed:
113
+ filtered.append({"id": "COMPLETED_SEPARATOR", "title": "", "status": "separator", "is_button": True})
114
+ filtered.append({"id": "SHOW_COMPLETED_BTN", "title": "--- Show Completed Tasks ---", "status": "button", "is_button": True})
115
+ return filtered
116
+
117
+ def refresh_data(self):
118
+ self.task_lists = self.service.get_task_lists()
119
+ if not self.active_list_id and self.task_lists:
120
+ self.active_list_id = self.task_lists[0]['id']
121
+ self.service.active_list_id = self.active_list_id
122
+ self.filtered_tasks_cache.clear()
123
+ self.tasks = self.get_tasks_for_active_list()
124
+ self.tasks_due_today = self.service.get_tasks_due_today()
125
+ self.calculate_task_counts()
126
+
127
+ def change_active_list(self, list_id):
128
+ if self.service.set_active_list(list_id):
129
+ self.active_list_id = list_id
130
+ self.current_parent_task_id = None
131
+ self.viewing_completed_tasks = False
132
+ self.tasks = self.get_tasks_for_active_list()
133
+ return True
134
+ return False
135
+
136
+
137
+ def handle_input(stdscr, app_state, ui_manager):
138
+ try:
139
+ key = getch()
140
+ except curses.error:
141
+ return True
142
+
143
+ if key == -1:
144
+ return True
145
+
146
+ if key in [ord('q'), ord('Q')]:
147
+ if _sync_thread is not None and _sync_thread.is_alive():
148
+ ui_manager.show_temporary_message("Waiting for sync to finish...")
149
+ _sync_thread.join(timeout=10.0)
150
+ if app_state.service.dirty:
151
+ ui_manager.start_sync_animation()
152
+ app_state.service.sync_to_google()
153
+ ui_manager.stop_sync_animation()
154
+ return False
155
+
156
+ if key == KEY_RESIZE:
157
+ return True
158
+
159
+ elif key == KEY_UP or key == ord('k'):
160
+ if ui_manager.active_panel == 'tasks':
161
+ ui_manager.update_task_selection(app_state.tasks, -1)
162
+ elif ui_manager.active_panel == 'due_today':
163
+ if ui_manager.selected_due_today_idx == 0:
164
+ ui_manager.active_panel = 'lists'
165
+ ui_manager.selected_list_idx = len(app_state.task_lists) - 1
166
+ else:
167
+ ui_manager.update_due_today_selection(app_state.tasks_due_today, -1)
168
+ elif ui_manager.active_panel == 'lists':
169
+ ui_manager.update_list_selection(app_state.task_lists, -1)
170
+ elif key == KEY_DOWN or key == ord('j'):
171
+ if ui_manager.active_panel == 'tasks':
172
+ ui_manager.update_task_selection(app_state.tasks, 1)
173
+ elif ui_manager.active_panel == 'lists':
174
+ if ui_manager.selected_list_idx == len(app_state.task_lists) - 1 and app_state.tasks_due_today:
175
+ ui_manager.active_panel = 'due_today'
176
+ ui_manager.selected_due_today_idx = 0
177
+ else:
178
+ ui_manager.update_list_selection(app_state.task_lists, 1)
179
+ elif ui_manager.active_panel == 'due_today':
180
+ ui_manager.update_due_today_selection(app_state.tasks_due_today, 1)
181
+ elif key == KEY_LEFT or key == ord('h'):
182
+ if app_state.viewing_completed_tasks:
183
+ app_state.viewing_completed_tasks = False
184
+ app_state.refresh_data()
185
+ ui_manager.reset_task_scroll()
186
+ elif app_state.current_parent_task_id:
187
+ app_state.current_parent_task_id = app_state.parent_task_id_stack.pop()
188
+ app_state.refresh_data()
189
+ if app_state.parent_task_idx_stack:
190
+ ui_manager.selected_task_idx = app_state.parent_task_idx_stack.pop()
191
+ elif ui_manager.active_panel == 'tasks':
192
+ ui_manager.toggle_panel()
193
+ elif key == KEY_RIGHT or key == ord('l'):
194
+ if ui_manager.active_panel == 'due_today' and app_state.tasks_due_today:
195
+ selected_task = app_state.tasks_due_today[ui_manager.selected_due_today_idx]
196
+ target_list_id = selected_task.get('_list_id')
197
+ if target_list_id:
198
+ if app_state.active_list_id != target_list_id:
199
+ app_state.change_active_list(target_list_id)
200
+ # Find the index of the task in the active list
201
+ task_idx = 0
202
+ for i, t in enumerate(app_state.tasks):
203
+ if t['id'] == selected_task['id']:
204
+ task_idx = i
205
+ break
206
+ ui_manager.selected_task_idx = task_idx
207
+ ui_manager.active_panel = 'tasks'
208
+ elif ui_manager.active_panel == 'lists':
209
+ selected_list = app_state.task_lists[ui_manager.selected_list_idx]
210
+ if app_state.active_list_id != selected_list['id']:
211
+ app_state.change_active_list(selected_list["id"])
212
+ ui_manager.reset_task_scroll()
213
+ ui_manager.toggle_panel()
214
+ elif ui_manager.active_panel == 'tasks' and app_state.tasks:
215
+ selected_task = app_state.tasks[ui_manager.selected_task_idx]
216
+ if selected_task.get("is_button"):
217
+ app_state.viewing_completed_tasks = True
218
+ app_state.refresh_data()
219
+ ui_manager.reset_task_scroll()
220
+ else:
221
+ app_state.parent_task_id_stack.append(app_state.current_parent_task_id)
222
+ app_state.parent_task_idx_stack.append(ui_manager.selected_task_idx)
223
+ app_state.current_parent_task_id = selected_task['id']
224
+ app_state.refresh_data()
225
+ ui_manager.reset_task_scroll()
226
+
227
+ elif key == ord('c'):
228
+ selected_task = app_state.tasks[ui_manager.selected_task_idx]
229
+ if not selected_task.get("is_button"):
230
+ app_state.service.toggle_task_status(app_state.active_list_id, selected_task["id"])
231
+ app_state.refresh_data()
232
+
233
+ elif key == ord('w'):
234
+ _trigger_background_sync(app_state.service, ui_manager)
235
+
236
+ elif key == ord('r'):
237
+ if ui_manager.active_panel == 'tasks' and app_state.tasks:
238
+ selected_task = app_state.tasks[ui_manager.selected_task_idx]
239
+ if not selected_task.get("is_button"):
240
+ new_title = ui_manager.get_user_input("New Task Title: ")
241
+ app_state.service.rename_task(app_state.active_list_id, selected_task["id"], new_title)
242
+ app_state.refresh_data()
243
+ elif ui_manager.active_panel == 'lists' and app_state.task_lists:
244
+ new_title = ui_manager.get_user_input("New List Title: ")
245
+ if new_title:
246
+ selected_list = app_state.task_lists[ui_manager.selected_list_idx]
247
+ app_state.service.rename_list(selected_list['id'], new_title)
248
+ app_state.refresh_data()
249
+
250
+ elif key == ord('a'):
251
+ if ui_manager.active_panel == 'tasks' and app_state.tasks:
252
+ selected_task = app_state.tasks[ui_manager.selected_task_idx]
253
+ if not selected_task.get("is_button"):
254
+ new_date = ui_manager.get_user_input("Due Date: ", example="2026-12-25 14:30")
255
+ if is_valid_date(new_date):
256
+ app_state.service.change_date_task(app_state.active_list_id, selected_task['id'], new_date)
257
+ app_state.refresh_data()
258
+ else:
259
+ ui_manager.show_temporary_message(f"Invalid date format: '{new_date}'")
260
+
261
+ elif key == ord('i'):
262
+ if ui_manager.active_panel == 'tasks' and app_state.tasks:
263
+ selected_task = app_state.tasks[ui_manager.selected_task_idx]
264
+ if not selected_task.get("is_button"):
265
+ open_editor_for_task_notes(stdscr, app_state, ui_manager)
266
+
267
+ elif key == ord('K'):
268
+ if ui_manager.active_panel == 'tasks' and app_state.tasks and ui_manager.selected_task_idx > 0:
269
+ task = app_state.tasks[ui_manager.selected_task_idx]
270
+ if not task.get('is_button'):
271
+ if ui_manager.selected_task_idx == 1:
272
+ prev_id = None
273
+ else:
274
+ prev_id = app_state.tasks[ui_manager.selected_task_idx - 2]['id']
275
+ app_state.service.move_task(app_state.active_list_id, task['id'], previous_id=prev_id, parent_id=task.get('parent'))
276
+ app_state.refresh_data()
277
+ ui_manager.selected_task_idx -= 1
278
+
279
+ elif key == ord('J'):
280
+ if ui_manager.active_panel == 'tasks' and app_state.tasks and ui_manager.selected_task_idx < len(app_state.tasks) - 1:
281
+ task = app_state.tasks[ui_manager.selected_task_idx]
282
+ if not task.get('is_button'):
283
+ next_task = app_state.tasks[ui_manager.selected_task_idx + 1]
284
+ if not next_task.get('is_button'):
285
+ app_state.service.move_task(app_state.active_list_id, task['id'], previous_id=next_task['id'], parent_id=task.get('parent'))
286
+ app_state.refresh_data()
287
+ ui_manager.selected_task_idx += 1
288
+
289
+ elif key == ord('X'):
290
+ if ui_manager.active_panel == 'tasks':
291
+ confirm = ui_manager.get_confirm_keypress("Clear all completed tasks?")
292
+ if confirm.lower() == 'y':
293
+ app_state.service.clear_completed_tasks(app_state.active_list_id)
294
+ app_state.refresh_data()
295
+ ui_manager.selected_task_idx = 0
296
+
297
+ elif key == ord('d'):
298
+ if ui_manager.active_panel == 'tasks' and app_state.tasks:
299
+ selected_task = app_state.tasks[ui_manager.selected_task_idx]
300
+ if not selected_task.get("is_button"):
301
+ confirm = ui_manager.get_confirm_keypress(f"Delete '{selected_task['title'][:30]}'?")
302
+ if confirm.lower() == 'y':
303
+ app_state.task_buffer = app_state.service.get_task(app_state.active_list_id, selected_task['id'])
304
+ app_state.service.delete_task(app_state.active_list_id, selected_task["id"])
305
+ app_state.refresh_data()
306
+ if ui_manager.selected_task_idx >= len(app_state.tasks) and len(app_state.tasks) > 0:
307
+ ui_manager.selected_task_idx = len(app_state.tasks) - 1
308
+ elif ui_manager.active_panel == 'lists' and app_state.task_lists:
309
+ selected_list = app_state.task_lists[ui_manager.selected_list_idx]
310
+ confirm = ui_manager.get_confirm_keypress(f"Delete list '{selected_list['title']}'?")
311
+ if confirm.lower() == 'y':
312
+ app_state.list_buffer = selected_list['title']
313
+ app_state.service.delete_list(selected_list["id"])
314
+ app_state.task_lists = app_state.service.get_task_lists()
315
+ if app_state.task_lists:
316
+ app_state.change_active_list(app_state.task_lists[0]['id'])
317
+ else:
318
+ app_state.active_list_id = None
319
+ app_state.refresh_data()
320
+
321
+ elif key == ord('p'):
322
+ if ui_manager.active_panel == 'tasks':
323
+ if app_state.tasks:
324
+ current_task = app_state.tasks[ui_manager.selected_task_idx]
325
+ if current_task.get("is_button"):
326
+ app_state.service.add_task_body(app_state.active_list_id, app_state.task_buffer)
327
+ else:
328
+ unfiltered_tasks = app_state.service.data['tasks'][app_state.active_list_id]
329
+ unfiltered_index = -1
330
+ for i, task in enumerate(unfiltered_tasks):
331
+ if task['id'] == current_task['id']:
332
+ unfiltered_index = i
333
+ break
334
+ if unfiltered_index != -1:
335
+ app_state.service.add_task_body(app_state.active_list_id, app_state.task_buffer, unfiltered_index)
336
+ else:
337
+ app_state.service.add_task_body(app_state.active_list_id, app_state.task_buffer)
338
+ else:
339
+ app_state.service.add_task_body(app_state.active_list_id, app_state.task_buffer)
340
+ app_state.refresh_data()
341
+ else:
342
+ app_state.service.add_list(app_state.list_buffer)
343
+ app_state.refresh_data()
344
+
345
+ elif key == ord('o'):
346
+ if ui_manager.active_panel == 'tasks':
347
+ new_title = ui_manager.get_user_input("New Task Title: ")
348
+ if new_title:
349
+ if app_state.current_parent_task_id:
350
+ app_state.service.add_task(app_state.active_list_id, new_title, parent=app_state.current_parent_task_id)
351
+ else:
352
+ app_state.service.add_task(app_state.active_list_id, new_title)
353
+ app_state.refresh_data()
354
+ else:
355
+ new_title = ui_manager.get_user_input("New List Title: ")
356
+ if new_title:
357
+ app_state.service.add_list(new_title)
358
+ app_state.refresh_data()
359
+
360
+ elif key == ord('?'):
361
+ ui_manager.toggle_help()
362
+
363
+ if app_state.service.dirty:
364
+ _trigger_background_sync(app_state.service, ui_manager)
365
+
366
+ return True
367
+ def _run_initial_sync(service, result_queue):
368
+ try:
369
+ service.sync_from_google()
370
+ result_queue.put(('ok', None))
371
+ except Exception as exc:
372
+ result_queue.put(('err', str(exc)))
373
+
374
+ def _trigger_initial_sync(service, ui_manager):
375
+ ui_manager.start_sync_animation()
376
+ threading.Thread(
377
+ target=_run_initial_sync,
378
+ args=(service, _sync_result_queue),
379
+ daemon=True,
380
+ ).start()
381
+
382
+ def main_loop(stdscr):
383
+ task_service = TaskService()
384
+ ui_manager = UIManager(stdscr)
385
+ app_state = AppState(task_service)
386
+
387
+ curs_set(0)
388
+ noecho()
389
+ halfdelay(2)
390
+ keypad(stdscr, True)
391
+
392
+ _trigger_initial_sync(app_state.service, ui_manager)
393
+ app_state.refresh_data()
394
+
395
+ running = True
396
+ while running:
397
+ try:
398
+ result_kind, result_msg = _sync_result_queue.get_nowait()
399
+ ui_manager.stop_sync_animation()
400
+ if result_kind == 'ok':
401
+ app_state.refresh_data()
402
+ if app_state.service.dirty:
403
+ _trigger_background_sync(app_state.service, ui_manager)
404
+ else:
405
+ app_state.service.sync_from_google()
406
+ app_state.refresh_data()
407
+ ui_manager.show_temporary_message(f"Sync failed (reverted): {result_msg}")
408
+ except queue.Empty:
409
+ pass
410
+
411
+ try:
412
+ parent_task = None
413
+ if app_state.current_parent_task_id:
414
+ parent_task = app_state.service.get_task(app_state.active_list_id, app_state.current_parent_task_id)
415
+
416
+ parent_ids = app_state.service.get_parent_task_ids(app_state.active_list_id)
417
+ children_counts = app_state.service.get_children_counts(app_state.active_list_id)
418
+
419
+ ui_manager.draw_layout(
420
+ app_state.task_lists,
421
+ app_state.tasks,
422
+ app_state.active_list_id,
423
+ app_state.task_counts,
424
+ parent_task=parent_task,
425
+ parent_ids=parent_ids,
426
+ children_counts=children_counts,
427
+ viewing_completed_tasks=app_state.viewing_completed_tasks,
428
+ tasks_due_today=app_state.tasks_due_today
429
+ )
430
+ except Exception as e:
431
+ ui_manager.show_temporary_message(f"Error: {e}")
432
+
433
+ running = handle_input(stdscr, app_state, ui_manager)
434
+
435
+
436
+ def cli():
437
+ try:
438
+ wrapper(main_loop)
439
+ except Exception as e:
440
+ print(f"An error occurred: {e}", file=sys.stderr)
441
+
442
+
443
+ if __name__ == "__main__":
444
+ cli()