· 4 years ago · Jan 01, 2021, 08:52 PM
1#!/usr/bin/env python3
2# -*- encoding: utf-8 -*-
3
4# TODO: add import subparser (import from keep, json, csv, md, sqlite db)
5# TODO: add reminder feature to remind you to do a task in the i3 notification
6# on a specific date and time + recurring reminders
7# TODO: add search subparser to search tasks by date, keywords and done status
8# TODO: Improve help output of the app (on todo -h,
9# add all the subparser options, for instance)
10# TODO: Improve todo web app
11# - interactible checkbuttons should mark a task as done
12# - Add text box to add a note to a list
13# - Add buttons to import (json, md, keep, sqlite, csv)
14# - Add login feature (for encrypted notes)
15# - Add search feature
16# - Add drag and drop feature to move tasks from one list to another ?
17# TODO: Create todo lists from several code files
18# look through a specific folder (argument), and create a new todo list,
19# whose name is the file path, with all the TODO comments from every file
20# in that path. Option to configure the TODO comment syntax (regexp ?)
21# TODO: Allow configuration through a config file
22# in ~/.config/todorc or in ~/.todorc
23
24import argparse
25import base64
26import contextlib
27import csv
28from datetime import datetime, timedelta
29import getpass
30import hashlib
31import io
32import itertools
33import json
34import os
35import re
36import sqlite3
37import secrets
38import subprocess
39import sys
40from urllib.parse import parse_qs
41import webbrowser
42from wsgiref.simple_server import make_server
43
44try:
45 import gkeepapi
46except ImportError:
47 subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'gkeepapi'])
48 import gkeepapi
49
50try:
51 from Cryptodome.Cipher import AES
52except ImportError:
53 try:
54 from Crypto.Cipher import AES # noqa
55 except ImportError:
56 subprocess.check_call(
57 [sys.executable, '-m', 'pip', 'install', 'pycryptodomex']
58 )
59 from Cryptodome.Cipher import AES
60
61try:
62 import jinja2
63except ImportError:
64 subprocess.check_call(
65 [sys.executable, '-m', 'pip', 'install', 'jinja2']
66 )
67 import jinja2
68
69
70__version__ = '0.0.5'
71__prog__ = 'todo'
72__author__ = 'Simon Bordeyne'
73
74
75tdelta_re = re.compile((
76 r'(?P<weeks>\d+w)?(?P<days>\d+d)?(?P<hours>\d+h)?'
77 r'(?P<minutes>\d+s)?(?P<seconds>\d+s)?'
78))
79fromdate_re = re.compile((
80 r'from: ?((?P<year>\d{4})(-(?P<month>\d\d)(-(?P<day>\d\d)((T| )'
81 r'(?P<hour>\d\d):(?P<minute>\d\d)(:(?P<second>\d\d))?'
82 r'(\.(?P<micro>\d+))?(?P<tz>([+-]\d\d:\d\d)|Z)?)?)?)?)'
83), re.IGNORECASE)
84todate_re = re.compile((
85 r'to: ?((?P<year>\d{4})(-(?P<month>\d\d)(-(?P<day>\d\d)((T| )'
86 r'(?P<hour>\d\d):(?P<minute>\d\d)(:(?P<second>\d\d))?'
87 r'(\.(?P<micro>\d+))?(?P<tz>([+-]\d\d:\d\d)|Z)?)?)?)?)'
88), re.IGNORECASE)
89fullexpr_re = re.compile(r'\"(?P<expr>.+)\"')
90
91RESERVED_NAMES = (
92 'reminders', 'meta', 'info',
93 'sqlite_master', 'config',
94 'sqlite_sequence',
95)
96
97
98class DefaultConfig:
99 defaults = {
100 'default_list': 'default',
101 'view.bind': 'localhost:8080',
102 'lsl.sep': ', ',
103 'export.default': 'md',
104 'purge.older': '1w',
105 'login.username': '',
106 'login.password': '',
107 'login.salt': '',
108 'login.expires_in': '30m',
109 'add.encrypt': 'false',
110 }
111
112
113def is_logged_in(db: sqlite3.Connection) -> bool:
114 '''
115 Checks if the user is logged in
116
117 :param db: The sqlite3 database object
118 :type db: sqlite3.Connection
119 :return: whether the user is logged in.
120 :rtype: bool
121 '''
122 c = db.cursor()
123 c.execute('SELECT `value` FROM `meta` WHERE `key`="logged_in";')
124 now = datetime.now()
125 cutoff = datetime.fromisoformat(c.fetchone()[0])
126 return now <= cutoff
127
128
129def generate_encrypt_key():
130 return base64.b64encode(
131 secrets.token_bytes(32)
132 ).decode('utf8')
133
134
135def get_encrypt_key(db) -> bytes:
136 c = db.cursor()
137 c.execute('SELECT `value` FROM `meta` WHERE `key`="encrypt_key"')
138 key = c.fetchone()[0]
139 # The key is stored as a base64 encoded string in the db
140 return base64.b64decode(key)
141
142
143def encrypt(key: bytes, data: str) -> str:
144 cipher = AES.new(key, AES.MODE_EAX)
145 # Encode the nonce as a base 64 string
146 nonce = base64.b64encode(cipher.nonce).decode('utf8')
147 data = data.encode('utf8') # turn data to bytes
148 data, tag = cipher.encrypt_and_digest(data)
149 data = '$'.join((nonce, base64.b64encode(data).decode('utf8')))
150 return data
151
152
153def decrypt(key: bytes, data: str) -> str:
154 nonce, data = data.split('$')
155 nonce = base64.b64decode(nonce)
156 data = base64.b64decode(data)
157
158 cipher = AES.new(key, AES.MODE_EAX, nonce=nonce)
159 return cipher.decrypt(data).decode('utf8')
160
161
162def get_config(db, key: str) -> str:
163 c = db.cursor()
164 c.execute("SELECT `value` FROM `config` WHERE `key`=?;", [key])
165 rv = c.fetchone()
166 if rv:
167 return rv[0]
168 return rv
169
170
171def reserved_name(func):
172 def wrapper(**kwargs):
173 listname = kwargs.get('list', '')
174 if listname in RESERVED_NAMES:
175 print(f'List {listname} is reserved by the TODO app.')
176 return 1
177 return func(**kwargs)
178 return wrapper
179
180
181def get_gkeep_client() -> gkeepapi.Keep:
182 keep = gkeepapi.Keep()
183 emailp = subprocess.run(['pass', 'show', 'todo/gkeep_email'], text=True)
184 if emailp.returncode == 1:
185 # There is no email in the password store
186 email = input("Enter your google account's email : ")
187 subprocess.run(
188 ['pass', 'insert', 'todo/gkeep_email'],
189 stdin=io.StringIO(email)
190 )
191 else:
192 email = emailp.stdout
193 tokenp = subprocess.run(['pass', 'show', 'todo/gkeep_token'], text=True)
194 if tokenp.returncode == 0:
195 keep.resume(email, tokenp.stdout)
196 else:
197 while True:
198 password = getpass.getpass(
199 "Please enter your google password : "
200 )
201 password2 = getpass.getpass(
202 "Please confirm your google password : "
203 )
204 if password == password2:
205 break
206 print('The passwords do not match, please try again.')
207 subprocess.run(
208 ['pass', 'insert', 'todo/gkeep_password'],
209 stdin=io.StringIO(password)
210 )
211 keep.login(email, password)
212 subprocess.run(
213 ['pass', 'insert', 'todo/gkeep_token'],
214 stdin=io.StringIO(keep.getMasterToken())
215 )
216 return keep
217
218
219def is_pass_installed() -> bool:
220 '''
221 Checks whether or not `pass` is installed on the machine
222 Used to hold GKeep secrets, and todo encryption keys
223
224 :return: Whether or not `pass` is installed
225 :rtype: bool
226 '''
227 process = subprocess.run(['which', 'pass'], check=True)
228 return process.returncode == 0
229
230
231def setup_meta(db):
232 c = db.cursor()
233 c.execute((
234 "CREATE TABLE IF NOT EXISTS `meta` "
235 "(id integer PRIMARY KEY AUTOINCREMENT, "
236 "key text NOT NULL, value text NOT NULL);"
237 ))
238 db.commit()
239
240 # Insert the meta loggedin key once into the meta table
241 # This key holds the datetime at which the login expires
242 c.execute('SELECT `value` FROM `meta` WHERE `key`="logged_in";')
243 if c.fetchone() is None:
244 # If the key doesn't exists in the table
245 c.execute(
246 "INSERT INTO `meta` (`key`, `value`) VALUES (?, ?);",
247 ('logged_in', datetime.now().isoformat(sep=' ')),
248 )
249 db.commit()
250
251 c.execute('SELECT `value` FROM `meta` WHERE `key`="encrypt_key";')
252 if c.fetchone() is None:
253 # If the key doesn't exists in the table
254 c.execute(
255 "INSERT INTO `meta` (`key`, `value`) VALUES (?, ?);",
256 ('encrypt_key', generate_encrypt_key()),
257 )
258 db.commit()
259
260
261def todo_app(environ, start_response):
262 environ['TODO_DB_PATH'] = os.path.join(
263 os.path.dirname(os.path.abspath(__file__)), 'todo.db'
264 )
265 db = sqlite3.connect(environ['TODO_DB_PATH'])
266 method = environ['REQUEST_METHOD']
267 path = environ['PATH_INFO']
268 print(method, path)
269
270 if path == '/':
271 data = sql_to_dict(db)
272 c = db.cursor()
273 c.execute("SELECT `key`, `value` FROM `config`;")
274 config = dict(c.fetchall())
275
276 with open(
277 os.path.join(
278 os.path.dirname(os.path.abspath(__file__)),
279 'todo.html.jinja'
280 )
281 ) as tplf:
282 template = jinja2.Template(tplf.read())
283
284 status = '200 OK'
285 headers = [
286 ('Content-Type', 'text/html; charset=utf-8'),
287 ]
288 start_response(status, headers)
289 ret = template.render(data=data, config=config).encode('utf-8')
290 return [ret]
291
292 if path.startswith('/export'):
293 f = path.split('/')[-1]
294 fmt = {
295 'json': {'_json': True, 'md': False, 'keep': False, '_csv': False},
296 'md': {'_json': False, 'md': True, 'keep': False, '_csv': False},
297 'keep': {'_json': False, 'md': False, 'keep': True, '_csv': False},
298 'csv': {'_json': False, 'md': False, 'keep': False, '_csv': True},
299 }[f]
300 out = io.StringIO()
301 with contextlib.redirect_stdout(out):
302 export(db, **fmt)
303
304 status = '200 OK'
305 headers = [
306 ('Content-Type', 'text/html; charset=utf-8')
307 ]
308 if f != 'keep':
309 headers.append(
310 ('Content-Disposition', f'attachment; filename="todo-list.{f}')
311 )
312
313 start_response(status, headers)
314 print(out.getvalue())
315 return [out.getvalue().encode('utf-8')]
316
317 if path.startswith('/search'):
318 _, qs = path.split('?')
319 query = parse_qs(qs)['query']
320 out = io.StringIO()
321 with contextlib.redirect_stdout(out):
322 search(db, query, printjson=True)
323 status = '200 OK'
324 headers = [
325 ('Content-Type', 'application/json; charset=utf-8'),
326 ]
327 start_response(status, headers)
328 return [out.getvalue().encode('utf-8')]
329
330
331def create_table(db, tablename):
332 cursor = db.cursor()
333 cursor.execute((
334 "CREATE TABLE IF NOT EXISTS `%s` "
335 "(id integer PRIMARY KEY AUTOINCREMENT, "
336 "task text NOT NULL, date text, done integer DEFAULT 0, "
337 "encrypted integer DEFAULT 0);"
338 ) % tablename)
339 db.commit()
340
341
342def insert_into(db, tablename, task):
343 date = datetime.now().isoformat(sep=' ')
344 done = 0
345 cursor = db.cursor()
346 cursor.execute((
347 "INSERT INTO `%s` (task, date, done) VALUES (?, ?, ?);"
348 ) % tablename, (task, date, done))
349 db.commit()
350
351
352def get_version():
353 return f'{__prog__} v{__version__} by {__author__}'
354
355
356def get_lists(db):
357 c = db.cursor()
358 c.execute((
359 "SELECT name FROM sqlite_master WHERE type ='table' AND"
360 " name NOT IN %s;"
361 ) % str(RESERVED_NAMES))
362 return [table[0] for table in c]
363
364
365def sql_to_dict(db, do_decrypt=False):
366 def _dc(row):
367 id, task, date, done, encrypted = row
368 nonlocal do_decrypt
369 if do_decrypt and is_logged_in(db) and encrypted:
370 task = decrypt(get_encrypt_key(), task)
371 elif encrypted and (not is_logged_in(db) or not do_decrypt):
372 return False
373 return (id, task, date, done, encrypted)
374
375 c = db.cursor()
376 tablenames = get_lists(db)
377 data = {}
378 for table in tablenames:
379 c.execute("SELECT * from `%s`" % table)
380 rows = [_dc(row) for row in c if _dc(row)]
381 data[table] = [
382 dict(zip(("id", "task", "date", "done", "encrypted"), t))
383 for t in rows
384 ]
385 return data
386
387
388@reserved_name
389def add(db, task, list, encrypt):
390 create_table(db, list)
391 if encrypt:
392 task = encrypt(get_encrypt_key(db), task)
393 insert_into(db, list, task)
394 return 0
395
396
397@reserved_name
398def list_(list, db, only_done, do_decrypt=False):
399 if not is_logged_in(db):
400 do_decrypt = False
401 c = db.cursor()
402 c.execute((
403 "SELECT * "
404 "FROM `%s` WHERE (`done`=? AND `encrypted`=?);"
405 ) % list, (only_done, int(do_decrypt)))
406
407 has_tasks = False
408 for id, task, date, done, encrypted in c.fetchall():
409 if encrypted and do_decrypt:
410 task = decrypt(get_encrypt_key(db), task)
411 print(f"#{id}@{date}: {task}")
412 has_tasks = True
413 else:
414 if not has_tasks:
415 print("No tasks found.")
416 return 1
417 return 0
418
419
420@reserved_name
421def do(list, db, task_ids):
422 c = db.cursor()
423 c.executemany((
424 "UPDATE `%s` SET done = 1 WHERE id=?;"
425 ) % list, [[tid] for tid in task_ids])
426 db.commit()
427 return 0
428
429
430@reserved_name
431def undo(list, db, task_ids):
432 c = db.cursor()
433 c.executemany((
434 "UPDATE `%s` SET done = 0 WHERE id=?;"
435 ) % list, [task_ids])
436 db.commit()
437 return 0
438
439
440def export(db, _json=False, _csv=False, keep=False,
441 md=False, do_decrypt=False):
442 data = sql_to_dict(db, do_decrypt)
443 if _json:
444 print(json.dumps(data, indent=4))
445 return 0
446 if _csv:
447 with io.StringIO() as csvfile:
448 fieldnames = ("id", "task", "date", "done")
449 writer = csv.DictWriter(
450 csvfile, fieldnames=fieldnames, dialect='unix',
451 )
452 writer.writeheader()
453 d = [
454 {field: row[field] for field in fieldnames}
455 for row in itertools.chain(*data.values())
456 ]
457 for row in d:
458 writer.writerow(row)
459 csvfile.seek(0)
460 print(''.join(csvfile.readlines()))
461 return 0
462 if md:
463 print('# To-do list\n')
464 for listname, list_ in data.items():
465 print(f'## {listname.capitalize()}\n')
466 for task in list_:
467 t = dict(task.items())
468 t['done'] = [" ", "x"][int(task["done"])]
469 print('- [{done}] #{id} - {task} (*{date}*)'.format(**t))
470 return 0
471 if keep:
472 if not is_pass_installed():
473 print((
474 "`pass` is not installed. Please install it"
475 ", along with `gnupg` using your system's package manager"
476 ))
477 return 1
478 try:
479 keep = get_gkeep_client()
480 except gkeepapi.exception.LoginException:
481 print('Could not login to google keep.')
482 print('If you have 2FA enabled, create and use an App Password')
483 print((
484 'Otherwise, try visiting this link '
485 'https://accounts.google.com/b/0/DisplayUnlockCaptcha'
486 ' and clicking the continue button.'
487 ))
488 return 1
489
490 for note_title, notes in data.items():
491 # Try to find an existing note with the proper note title
492 gnotes = list(
493 keep.find(
494 func=lambda x: x.title == note_title.capitalize()
495 )
496 )
497 if len(gnotes) == 0:
498 # No notes exist yet, create a new note
499 tasks = [(t['task'], t['done'] == 1) for t in notes]
500 keep.createList(title=note_title.capitalize(), items=tasks)
501 else:
502 glist = gnotes[0]
503 for task in notes:
504 # Add only missing tasks
505 if task['task'] not in [i[0] for i in glist.items]:
506 glist.add(task['task'], task['done'] == 1)
507 else: # Mark existing tasks as done
508 gtask = [
509 glistitem for glistitem in glist.items
510 if glistitem.text == task['task']
511 ][0] # gets the note associated with the task
512 gtask.checked = task['done'] == 0
513 keep.sync()
514 return 1
515
516
517def view(db, bind):
518 host, port = bind.split(':')
519 port = int(port)
520 bind = host, port
521 with make_server(host, port, todo_app) as httpd:
522 webbrowser.open(f'http://{host}:{port}/')
523 try:
524 httpd.serve_forever()
525 except KeyboardInterrupt:
526 httpd.server_close()
527 print('Closing web server...')
528 return 0
529
530
531@reserved_name
532def purge(db, list, older_than, no_confirm, not_done):
533 def cmpdate(date1, date2):
534 date1 = datetime.fromisoformat(date1)
535 date2 = datetime.fromisoformat(date2)
536 return date1 < date2
537
538 match = tdelta_re.match(older_than)
539 if match is None:
540 print((
541 'Invalid format for `older_than`. Expected '
542 '\\d+w\\d+d\\d+h\\d+m\\d+s and got %s') % older_than
543 )
544 return 1
545
546 data = sql_to_dict(db)[list]
547 cutoff = datetime.now()
548 cutoff = (
549 cutoff - timedelta(**tdelta_re.match(older_than).groupdict())
550 ).isoformat()
551 ids_to_purge = []
552 for i, d in enumerate(data):
553 if cmpdate(d['date'], cutoff):
554 if not_done:
555 ids_to_purge.append(d['id'])
556 if not not_done and d['done']:
557 ids_to_purge.append(d['id'])
558
559 if not no_confirm:
560 print((
561 'This will delete the following tasks '
562 'id from %s : %s') % (list, ids_to_purge)
563 )
564 ans = ''
565 while ans.lower() not in ('y', 'yes', 'no', 'n'):
566 ans = input('Are you sure? ')
567 if ans.lower().startswith('n'):
568 return 0
569
570 c = db.cursor()
571 c.executemany((
572 'DELETE FROM `%s` WHERE `id`=?'
573 ) % list, ids_to_purge)
574 db.commit()
575
576
577def list_lists(db, sep):
578 tables = get_lists(db)
579 print(sep.join(table.capitalize() for table in tables))
580 return 0
581
582
583def setup_config(db):
584 c = db.cursor()
585 c.execute((
586 "CREATE TABLE IF NOT EXISTS `config` "
587 "(id integer PRIMARY KEY AUTOINCREMENT, "
588 "key text NOT NULL, value text NOT NULL);"
589 ))
590 db.commit()
591 c.execute("SELECT `key` from `config`;")
592 existing_conf = [k[0] for k in c]
593 cnf = [
594 [str(_) for _ in conf]
595 for conf in DefaultConfig.defaults.items()
596 if conf[0] not in existing_conf
597 ]
598
599 c.executemany((
600 "INSERT INTO `config` (key, value) VALUES (?, ?);"
601 ), cnf)
602 db.commit()
603
604
605def config(db, key, value):
606 if key not in DefaultConfig.defaults:
607 print(f'Key `{key}` is not a valid config key.')
608 print((
609 f'Available config keys: '
610 f'{" | ".join(DefaultConfig.defaults.keys())}'
611 ))
612 return 1
613
614 c = db.cursor()
615 c.execute("UPDATE `config` SET ?=?;", (key, value))
616 db.commit()
617 return 0
618
619
620def move(db, task_from, list_to):
621 task_id, list_from = task_from.rsplit('.', 1)
622 c = db.cursor()
623 c.execute('SELECT * FROM `%s` WHERE `id`=?;' % list_from, [task_id])
624 task = c.fetchone()[1:] # strip out the id, it's not necessary
625 c.execute((
626 'DELETE FROM `%s` WHERE `id`=?;'
627 ) % list_from, [task_id])
628 c.execute((
629 "INSERT INTO `%s` (task, date, done) VALUES (?, ?, ?);"
630 ) % list_to, task)
631 db.commit()
632 return 0
633
634
635def login(db, username, password, expires_in, register):
636 if username is None:
637 username = input('Enter your username : ')
638 if password is None:
639 password = getpass.getpass('Enter your password : ')
640
641 db_usr = get_config(db, 'login.username')
642 db_pass = get_config(db, 'login.password')
643 db_salt = get_config(db, 'login.salt')
644
645 c = db.cursor()
646
647 if not db_usr or not db_pass or not db_salt or register:
648 db_salt = salt = secrets.token_hex(8)
649 hashed_pass = hashlib.sha256(
650 (password + salt).encode('utf8')
651 ).hexdigest()
652 infos = {
653 'login.username': username,
654 'login.salt': salt,
655 'login.password': hashed_pass,
656 }
657 c.executemany("UPDATE `config` SET ?=?;", list(infos.items()))
658 c.execute("UPDATE `meta` SET encrypt_key=?;", [generate_encrypt_key()])
659
660 hashed_pass = hashlib.sha256(
661 (password + db_salt).encode('utf8')
662 ).hexdigest()
663 if hashed_pass == db_pass and username == db_usr:
664 cutoff = (
665 datetime.now() +
666 timedelta(**tdelta_re.match(expires_in).groupdict())
667 ).isoformat()
668 c.execute("UPDATE `meta` SET logged_in=?", [cutoff])
669 db.commit()
670 print('Successfully logged in.')
671 return 0
672 print('Invalid username or password.')
673 return 1
674
675
676def search(db, query, printjson=False):
677 '''
678 Searches through tasks.
679
680 :param db: The database connection
681 :type db: sqlite3.Connection
682 :param query: The query string
683 :type query: list[str]
684 '''
685 def add_date_defaults(date):
686 date = dict(date.items())
687 if date['month'] is None:
688 date['month'] = 1
689 if date['day'] is None:
690 date['day'] = 1
691 if date['hour'] is None:
692 date['hour'] = 0
693 if date['minute'] is None:
694 date['minute'] = 0
695 if date['second'] is None:
696 date['second'] = 0
697 date.pop('micro', None)
698 date.pop('tz', None)
699 date = {k: int(v) for k, v in date.items()}
700 print(date)
701 return date
702
703 query = ' '.join(query)
704 filters = ['(`encrypted`=0)']
705 fields = ('id', 'task', 'date', 'done')
706
707 if query.startswith('+'):
708 filters.append('(`done`=1)')
709 query = query[1:]
710 elif query.startswith('-'):
711 filters.append('(`done`=0)')
712 query = query[1:]
713
714 if m := fromdate_re.search(query):
715 fromdate = datetime(**add_date_defaults(m.groupdict())).isoformat()
716 query = fromdate_re.sub('', query)
717 if m := todate_re.search(query):
718 todate = datetime(**add_date_defaults(m.groupdict())).isoformat()
719 query = todate_re.sub('', query)
720 else:
721 todate = 'now'
722 filters.append(
723 "(`date` BETWEEN date('%s') AND date('%s'))" % (fromdate, todate)
724 )
725
726 for match in fullexpr_re.finditer(query):
727 expr = match.groupdict()['expr']
728 if expr.strip():
729 filters.append(f'(`task` LIKE "%{expr}%")')
730 else:
731 query = fullexpr_re.sub('', query).strip()
732
733 filters.extend(
734 [f'(`task` LIKE "%{keyword}%")' for keyword in query.split(' ')]
735 )
736
737 c = db.cursor()
738 results = {}
739 for table in get_lists(db):
740 sql_statement = 'SELECT {} FROM `{}` WHERE {}'.format(
741 ', '.join(fields), table, ' AND '.join(filters),
742 )
743 c.execute(sql_statement)
744 results[table] = [dict(zip(fields, row)) for row in c]
745
746 if printjson:
747 print(json.dumps(results))
748 return 0
749
750 for table, results in results.items():
751 for result in results:
752 donestr = 'x' if int(result['done']) else ' '
753 print("{table} : [{donestr}]#{id} - {task} @{date}".format(
754 table=table.capitalize(),
755 donestr=donestr,
756 **result
757 )
758 )
759 return 0
760
761
762def main():
763 path_to_db = os.path.join(
764 os.path.dirname(os.path.abspath(__file__)), 'todo.db'
765 )
766 os.environ['TODO_DB_PATH'] = path_to_db
767 db = sqlite3.connect(path_to_db)
768
769 setup_config(db)
770 setup_meta(db)
771
772 parser = argparse.ArgumentParser(
773 prog=__prog__,
774 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
775 description=(
776 "A simple TODO CLI that stores its data in an SQLite3 database."
777 )
778 )
779 parser.add_argument(
780 '--version', '-V', action='version',
781 version=get_version()
782 )
783
784 subparsers = parser.add_subparsers(
785 title='subcommands'
786 )
787
788 add_encrypt = str(
789 str(get_config(db, "add.encrypt")).lower() != 'true'
790 ).lower()
791
792 add_parser = subparsers.add_parser(
793 'add', formatter_class=argparse.ArgumentDefaultsHelpFormatter,
794 help="Adds a task to a list.", aliases=['+']
795 )
796 add_parser.add_argument('task', type=str, help='Task to do.')
797 add_parser.add_argument(
798 '--list', '-l', type=str, help='List to add the task to.',
799 default=get_config(db, 'default_list'),
800 )
801 add_parser.add_argument(
802 '--encrypt', action=f'store_{add_encrypt}',
803 help="Turns on on-the-fly encryption.",
804 )
805 add_parser.set_defaults(callback=add)
806
807 list_parser = subparsers.add_parser(
808 'list', formatter_class=argparse.ArgumentDefaultsHelpFormatter,
809 help="Lists tasks in a list. Can filter tasks by 'done' status",
810 aliases=['ls', 'l']
811 )
812 list_parser.add_argument(
813 '--list', '-l', help='List to get the tasks of.',
814 default=get_config(db, 'default_list'),
815 )
816 list_parser.add_argument(
817 '--onlydone', '-d', action='store_const', const=1, dest='only_done',
818 help='Only the tasks which have been done.', default=0,
819 )
820 list_parser.add_argument(
821 '--decrypt', '-c', action='store_const', const=is_logged_in(db),
822 dest='do_decrypt', default=False,
823 help='Decrypts the tasks on the fly. Requires a log in.',
824 )
825 list_parser.set_defaults(callback=list_)
826
827 list_lists_parser = subparsers.add_parser(
828 'listlists', formatter_class=argparse.ArgumentDefaultsHelpFormatter,
829 help="Lists all todo lists.",
830 aliases=['lsl', 'lslists', 'lslist', 'listlist', 'listls'],
831 )
832 list_lists_parser.add_argument(
833 '--sep', default=get_config(db, 'lsl.sep'), dest='sep',
834 help="Lists separator. List names are joined together by this value."
835 )
836 list_lists_parser.set_defaults(callback=list_lists)
837
838 do_parser = subparsers.add_parser(
839 'do', formatter_class=argparse.ArgumentDefaultsHelpFormatter,
840 help="Marks tasks as 'done'", aliases=['done']
841 )
842 do_parser.add_argument('task_ids', nargs='+')
843 do_parser.add_argument(
844 '--list', '-l', default='default',
845 help='List in which the task is.'
846 )
847 do_parser.set_defaults(callback=do)
848
849 undo_parser = subparsers.add_parser(
850 'undo', formatter_class=argparse.ArgumentDefaultsHelpFormatter,
851 help="Marks tasks as 'not done'.", aliases=['notdone', 'undone']
852 )
853 undo_parser.add_argument('task_ids', nargs='+')
854 undo_parser.add_argument(
855 '--list', '-l', default=get_config(db, 'default_list'),
856 help='List in which the task is.'
857 )
858 undo_parser.set_defaults(callback=undo)
859
860 export_parser = subparsers.add_parser(
861 'export', formatter_class=argparse.ArgumentDefaultsHelpFormatter,
862 help=(
863 'Exports the TODO lists to the specified'
864 ' format. Markdown by default.'
865 ),
866 aliases=['ex']
867 )
868 grp = export_parser.add_mutually_exclusive_group()
869 export_default = get_config(db, 'export.default')
870 json_default = export_default == 'json'
871 csv_default = export_default == 'csv'
872 md_default = export_default == 'md'
873 keep_default = export_default in ('gkeep', 'keep', 'google')
874
875 grp.add_argument(
876 '--json', action='store_true', dest='_json',
877 help='Prints the TODO list as json to stdout',
878 default=json_default,
879 )
880 grp.add_argument(
881 '--csv', action='store_true', dest='_csv',
882 help='Prints the TODO list as csv to stdout',
883 default=csv_default,
884 )
885 grp.add_argument(
886 '--md', action='store_true', default=md_default,
887 help='Prints the TODO list as md to stdout'
888 )
889 grp.add_argument(
890 '--keep', action='store_true', default=keep_default,
891 help='Exports the TODO list to Google Keep'
892 )
893 export_parser.add_argument(
894 '--decrypt', '-c', action='store_const', const=is_logged_in(db),
895 dest='do_decrypt', default=False,
896 help='Decrypts the tasks on the fly. Requires a log in.',
897 )
898 export_parser.set_defaults(callback=export)
899
900 view_parser = subparsers.add_parser(
901 'view', formatter_class=argparse.ArgumentDefaultsHelpFormatter,
902 help=(
903 'Views the TODO list as an HTML webpage. Starts '
904 'a WSGI app and opens the browser at that address.'
905 ),
906 aliases=['web', 'webapp', 'html']
907 )
908 view_parser.add_argument(
909 '--bind', default=get_config(db, 'view.bind'),
910 help='The address where to start the wsgi server'
911 )
912 view_parser.set_defaults(callback=view)
913
914 purge_parser = subparsers.add_parser(
915 'purge', formatter_class=argparse.ArgumentDefaultsHelpFormatter,
916 help=(
917 'Purges the database from old entries.'
918 ), aliases=['-', 'p', 'pg']
919 )
920 purge_parser.add_argument(
921 '--list', '-l', type=str, default=get_config(db, 'default_list'),
922 help='The TODO list to purge',
923 )
924 purge_parser.add_argument(
925 '--older', '-o', dest='older_than',
926 default=get_config(db, 'purge.older'),
927 help=(
928 'Purge entries that are older than that cutoff from today. '
929 'The format is "\\d+w\\d+d\\d+h\\d+m\\d+s".'
930 ),
931 )
932 purge_parser.add_argument(
933 '--no-confirm', '-f', dest='no_confirm', action='store_true',
934 help="Don't ask for confirmation before purging."
935 )
936 purge_parser.add_argument(
937 '--not-done', '-d', dest='not_done', action='store_true',
938 help="Include tasks that are not done yet."
939 )
940
941 config_parser = subparsers.add_parser(
942 'config', formatter_class=argparse.ArgumentDefaultsHelpFormatter,
943 help='Configures TODO app', aliases=['cnf', 'cf'],
944 )
945 config_parser.add_argument(
946 'key', type=str,
947 help=(
948 f'The config key to edit. Available config'
949 f' keys are {"|".join(DefaultConfig.defaults.keys())}'
950 )
951 )
952 config_parser.add_argument(
953 'value', type=str,
954 help='The value to assign to the config key.',
955 )
956 config_parser.set_defaults(callback=config)
957
958 move_parser = subparsers.add_parser(
959 'move', formatter_class=argparse.ArgumentDefaultsHelpFormatter,
960 help='Moves a task from one list to another', aliases=['mv'],
961 )
962 move_parser.add_argument(
963 'task_from', help='Task to move, in the format list.id'
964 )
965 move_parser.add_argument(
966 'list_to',
967 help='Destination list to move the task to.'
968 )
969 move_parser.set_defaults(callback=move)
970
971 login_parser = subparsers.add_parser(
972 'login', formatter_class=argparse.ArgumentDefaultsHelpFormatter,
973 help='Logs into TODO app for 30 minutes',
974 )
975 login_parser.add_argument(
976 '--username', '-u', default=None,
977 help=(
978 'Username to use for the login process. '
979 'If not present, you will be prompted for it.'
980 ),
981 )
982 login_parser.add_argument(
983 '--password', '-p', default=None,
984 help=(
985 'Password to use for the login process. '
986 'If not present, you will be prompted for it.'
987 )
988 )
989 login_parser.add_argument(
990 '--expires-in', '-e', default=get_config(db, 'login.expires_in'),
991 help='The amount of time the login will be valid for.',
992 )
993 login_parser.add_argument(
994 '--register', default=False,
995 help=(
996 'Forces the registration process. '
997 'Existing encrypted notes will be lost.'
998 )
999 )
1000 login_parser.set_defaults(callback=login)
1001
1002 search_parser = subparsers.add_parser(
1003 'search', aliases=['s', 'sch'],
1004 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
1005 help=(
1006 'Searches for tasks. Use the from:<iso8601 date>, to:<iso date>'
1007 ' "expression" and keywords to filter the search. Start the query'
1008 ' with + or - to filter tasks by "done" or "not done" status.'
1009 ' Encrypted tasks will not be searched through.'
1010 )
1011 )
1012 search_parser.add_argument('query', nargs='+', type=str)
1013 search_parser.set_defaults(callback=search)
1014
1015 args = vars(parser.parse_args())
1016 callback = args.pop('callback')
1017 args['db'] = db
1018 setup_config(args['db'])
1019 sys.exit(callback(**args))
1020
1021
1022if __name__ == '__main__':
1023 main()
1024