aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rw-r--r--README.md15
-rw-r--r--shell.nix19
-rw-r--r--src/db/init.py18
-rw-r--r--src/db/tasks.py50
-rw-r--r--src/gui/icons.py16
-rw-r--r--src/gui/tasks/main.py39
-rw-r--r--src/gui/tasks/modal.py143
-rw-r--r--src/gui/tasks/signal.py27
-rw-r--r--src/gui/tasks/table/main.py46
-rw-r--r--src/gui/tasks/table/menu.py43
-rw-r--r--src/gui/tasks/table/model.py116
-rw-r--r--src/gui/window.py13
-rw-r--r--src/main.py22
-rw-r--r--src/model/task.py13
-rw-r--r--src/util/array.py5
-rw-r--r--src/util/range.py30
17 files changed, 617 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..42b5d44
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+__pycache__
+database
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1040b41
--- /dev/null
+++ b/README.md
@@ -0,0 +1,15 @@
+# todo-next
+
+Manage a context-based next-action list, compatible with the
+[GTD](https://en.wikipedia.org/wiki/Getting_Things_Done) method.
+
+## Getting started
+
+```bash
+nix-shell --run "python src/main.py"
+```
+
+## Links
+
+- [Python standard library](https://docs.python.org/3.8/library/index.html)
+- [Qt documentation](https://doc.qt.io/qt-5/qtwidgets-module.html)
diff --git a/shell.nix b/shell.nix
new file mode 100644
index 0000000..608e7c1
--- /dev/null
+++ b/shell.nix
@@ -0,0 +1,19 @@
+with (import (builtins.fetchGit {
+ name = "nixpkgs-20.03";
+ url = "git@github.com:nixos/nixpkgs.git";
+ rev = "5272327b81ed355bbed5659b8d303cf2979b6953";
+ ref = "refs/tags/20.03";
+}) {});
+
+mkShell {
+
+ buildInputs = [
+ qt5.full
+ sqlite
+ ];
+
+ propagatedBuildInputs = [
+ python38Packages.pyqt5
+ ];
+
+}
diff --git a/src/db/init.py b/src/db/init.py
new file mode 100644
index 0000000..9517714
--- /dev/null
+++ b/src/db/init.py
@@ -0,0 +1,18 @@
+import sqlite3
+import os.path
+import time
+
+def init(path):
+ is_db_new = not os.path.isfile(path)
+ database = sqlite3.connect('database')
+ if is_db_new:
+ database.cursor().execute(
+ " CREATE TABLE IF NOT EXISTS tasks("
+ " id INTEGER PRIMARY KEY,"
+ " created_at INTEGER NOT NULL,"
+ " modified_at INTEGER NOT NULL,"
+ " name TEXT NOT NULL,"
+ " tag TEXT"
+ " )")
+ database.commit()
+ return database
diff --git a/src/db/tasks.py b/src/db/tasks.py
new file mode 100644
index 0000000..26a430a
--- /dev/null
+++ b/src/db/tasks.py
@@ -0,0 +1,50 @@
+from sqlite3 import Cursor
+import time
+
+from model.task import Task, TaskForm
+
+def get(cursor: Cursor) -> Task:
+ cursor.execute('SELECT id, created_at, modified_at, name, tag FROM tasks')
+
+ res = []
+
+ for task in cursor.fetchall():
+ res.append(Task(
+ id = task[0],
+ created_at = task[1],
+ modified_at = task[2],
+ name = task[3],
+ tag = task[4]
+ ))
+
+ return res
+
+def insert(cursor: Cursor, taskForm):
+ now = int(time.time())
+ cursor.execute('INSERT INTO tasks(created_at, modified_at, name, tag) VALUES (?, ?, ?, ?)', (now, now, taskForm.name, taskForm.tag))
+ return Task(
+ id = cursor.lastrowid,
+ created_at = now,
+ modified_at = now,
+ name = taskForm.name,
+ tag = taskForm.tag
+ )
+
+def update(cursor: Cursor, task: Task, taskForm: TaskForm):
+ now = int(time.time())
+
+ cursor.execute(
+ 'UPDATE tasks SET modified_at = ?, name = ?, tag = ? WHERE id = ?',
+ (now, taskForm.name, taskForm.tag, task.id))
+
+ return Task(
+ id = task.id,
+ created_at = task.created_at,
+ modified_at = now,
+ name = taskForm.name,
+ tag = taskForm.tag
+ )
+
+def delete(cursor: Cursor, ids):
+ if len(ids) >= 1:
+ cursor.execute('DELETE FROM tasks WHERE id IN (%s)' % ','.join('?'*len(ids)), ids)
diff --git a/src/gui/icons.py b/src/gui/icons.py
new file mode 100644
index 0000000..2f8830e
--- /dev/null
+++ b/src/gui/icons.py
@@ -0,0 +1,16 @@
+from PyQt5 import QtWidgets
+
+def new_folder(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_FileDialogNewFolder)
+
+def dialog_open(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_DialogOpenButton)
+
+def dialog_apply(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_DialogApplyButton)
+
+def dialog_cancel(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_DialogCancelButton)
+
+def trash(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_TrashIcon)
diff --git a/src/gui/tasks/main.py b/src/gui/tasks/main.py
new file mode 100644
index 0000000..3c7d3db
--- /dev/null
+++ b/src/gui/tasks/main.py
@@ -0,0 +1,39 @@
+from PyQt5 import QtWidgets, QtCore
+
+import db.tasks
+import gui.tasks.signal
+import gui.tasks.table.main
+import gui.icons
+from model.task import TaskForm
+
+def widget(database, parent):
+ widget = QtWidgets.QWidget(parent)
+
+ layout = QtWidgets.QVBoxLayout(widget)
+ widget.setLayout(layout)
+
+ add_task_signal = gui.tasks.signal.AddTask()
+
+ add_task_button = QtWidgets.QPushButton('Add a task', widget)
+ add_task_button.setIcon(gui.icons.new_folder(widget.style()))
+ add_task_button.clicked.connect(lambda: show_add_dialog(database, widget, add_task_signal))
+ layout.addWidget(add_task_button)
+
+ table = gui.tasks.table.main.widget(database, widget, add_task_signal)
+ layout.addWidget(table)
+
+ return widget
+
+def show_add_dialog(database, parent_widget, add_task_signal):
+ dialog = gui.tasks.modal.dialog(
+ parent_widget,
+ 'Add a task',
+ 'add',
+ None,
+ lambda taskForm: on_add(database, taskForm, add_task_signal))
+ dialog.exec_()
+
+def on_add(database, taskForm: TaskForm, add_task_signal):
+ task = db.tasks.insert(database.cursor(), taskForm)
+ database.commit()
+ add_task_signal.emit(task)
diff --git a/src/gui/tasks/modal.py b/src/gui/tasks/modal.py
new file mode 100644
index 0000000..3ccf56e
--- /dev/null
+++ b/src/gui/tasks/modal.py
@@ -0,0 +1,143 @@
+from PyQt5 import QtCore, QtWidgets
+
+import db.tasks
+from model.task import Task, TaskForm
+
+import gui.icons
+
+def dialog(
+ parent: QtWidgets.QWidget,
+ title: str,
+ action_title: str,
+ task: Task,
+ on_validated):
+
+ dialog = QtWidgets.QDialog(parent)
+ dialog.setWindowTitle(title)
+ dialog.setMinimumSize(QtCore.QSize(320, 240))
+
+ layout = QtWidgets.QVBoxLayout(dialog)
+ dialog.setLayout(layout)
+
+ init_name = task.name if task is not None else ''
+ (name_labelled_input, name_input) = labelled_input(dialog, 'Name', init_name)
+ layout.addWidget(name_labelled_input)
+
+ init_tag = task.tag if task is not None else ''
+ (tag_labelled_input, tag_input) = labelled_input(dialog, 'Tag', init_tag)
+ layout.addWidget(tag_labelled_input)
+
+ task_form_edition = TaskFormEdition(
+ init_name,
+ name_input.textChanged,
+ init_tag,
+ tag_input.textChanged)
+
+ layout.addWidget(buttons(
+ parent = dialog,
+ action_title = action_title,
+ task_form_signal = task_form_edition.signal(),
+ on_validate = lambda: validate(dialog, task_form_edition.get(), on_validated),
+ on_cancel = lambda: dialog.reject()))
+
+ return dialog
+
+# Use grid ?
+def labelled_input(parent, label: str, default_value: str):
+ widget = QtWidgets.QWidget(parent)
+
+ layout = QtWidgets.QHBoxLayout(widget)
+ widget.setLayout(layout)
+
+ label = QtWidgets.QLabel(label, widget)
+ layout.addWidget(label)
+
+ line_edit = QtWidgets.QLineEdit(widget)
+ if default_value != None:
+ line_edit.setText(default_value)
+ layout.addWidget(line_edit)
+
+ return (widget, line_edit)
+
+def buttons(parent, action_title, task_form_signal, on_validate, on_cancel):
+ widget = QtWidgets.QWidget(parent)
+ layout = QtWidgets.QHBoxLayout(widget)
+
+ validate = QtWidgets.QPushButton(action_title, widget)
+ validate.setDisabled(True)
+ validate.setIcon(gui.icons.dialog_apply(validate.style()))
+ validate.clicked.connect(on_validate);
+ layout.addWidget(validate)
+
+ def on_task_form_signal(task_form):
+ if validate_form(task_form):
+ validate.setEnabled(True)
+ else:
+ validate.setDisabled(True)
+
+ task_form_signal.connect(on_task_form_signal)
+
+ cancel = QtWidgets.QPushButton('cancel', widget)
+ cancel.setIcon(gui.icons.dialog_cancel(cancel.style()))
+ cancel.clicked.connect(on_cancel)
+ layout.addWidget(cancel)
+
+ return widget
+
+def validate(dialog, task_form: TaskForm, on_validated):
+ valid_form = validate_form(task_form)
+ if valid_form:
+ on_validated(valid_form)
+ dialog.accept()
+
+def clean_form(task_form: TaskForm):
+ return TaskForm(
+ name = task_form.name.strip(),
+ tag = task_form.tag.strip())
+
+def validate_form(task_form: TaskForm):
+ task_form = clean_form(task_form)
+ if task_form.name:
+ return task_form
+ else:
+ return None
+
+#####################
+# Task form edition #
+#####################
+
+class TaskFormEdition:
+ def __init__(self, name, name_signal, tag, tag_signal):
+ self._name = name
+ self._tag = tag
+ self._signal = TaskFormSignal()
+ name_signal.connect(lambda name: self.on_name_signal(name))
+ tag_signal.connect(lambda tag: self.on_tag_signal(tag))
+
+ def get(self):
+ return TaskForm(
+ name = self._name,
+ tag = self._tag)
+
+ def on_name_signal(self, name: str):
+ self._name = name
+ self._signal.emit(self.get())
+
+ def on_tag_signal(self, tag: str):
+ self._tag = tag
+ self._signal.emit(self.get())
+
+ def signal(self):
+ return self._signal
+
+class TaskFormSignal(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(TaskForm, name = 'taskForm')
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self, taskForm):
+ self._signal.emit(taskForm)
+
+ def connect(self, f):
+ self._signal.connect(f)
diff --git a/src/gui/tasks/signal.py b/src/gui/tasks/signal.py
new file mode 100644
index 0000000..7d926e1
--- /dev/null
+++ b/src/gui/tasks/signal.py
@@ -0,0 +1,27 @@
+from PyQt5 import QtCore
+
+from model.task import Task
+
+class AddTask(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(Task, name = 'addTask')
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self, task):
+ self._signal.emit(task)
+
+ def get(self):
+ return self._signal
+
+class UpdateTask(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(int, Task, name = 'updateTask')
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self, row, task):
+ self._signal.emit(row, task)
+
+ def get(self):
+ return self._signal
diff --git a/src/gui/tasks/table/main.py b/src/gui/tasks/table/main.py
new file mode 100644
index 0000000..a990c0e
--- /dev/null
+++ b/src/gui/tasks/table/main.py
@@ -0,0 +1,46 @@
+from PyQt5 import QtWidgets
+from PyQt5.QtCore import Qt
+
+import db.tasks
+import gui.tasks.signal
+import gui.tasks.table.menu
+import gui.tasks.table.model
+
+def widget(database, parent, add_task_signal):
+ table = QtWidgets.QTableView(parent)
+
+ tasks = db.tasks.get(database.cursor())
+ table_model = gui.tasks.table.model.TableModel(tasks)
+
+ table.setModel(table_model)
+ table.sortByColumn(
+ gui.tasks.table.model.default_sort[0],
+ gui.tasks.table.model.default_sort[1])
+ table.setSortingEnabled(True)
+ table.setSelectionBehavior(QtWidgets.QTableView.SelectRows)
+ table.horizontalHeader().setStretchLastSection(True)
+ resizeColumns(table)
+
+ update_task_signal = gui.tasks.signal.UpdateTask()
+
+ # Menu
+ table.setContextMenuPolicy(Qt.CustomContextMenu)
+ table.customContextMenuRequested.connect(lambda position: gui.tasks.table.menu.open(database, table, update_task_signal, position))
+
+ add_task_signal.get().connect(lambda task: insert(table, task))
+ update_task_signal.get().connect(lambda row, task: update(table, row, task))
+
+ return table
+
+def insert(table, task):
+ table.model().insert_task(table.horizontalHeader(), task)
+ resizeColumns(table)
+
+def update(table, row, task):
+ row = table.model().update_task(table.horizontalHeader(), row, task)
+ table.selectRow(row)
+ resizeColumns(table)
+
+def resizeColumns(table):
+ for column in range(gui.tasks.table.model.columns):
+ table.resizeColumnToContents(column)
diff --git a/src/gui/tasks/table/menu.py b/src/gui/tasks/table/menu.py
new file mode 100644
index 0000000..4366c25
--- /dev/null
+++ b/src/gui/tasks/table/menu.py
@@ -0,0 +1,43 @@
+from PyQt5 import QtWidgets
+
+import db.tasks
+import gui.tasks.modal
+from model.task import Task, TaskForm
+
+def open(database, table, update_task_signal, position):
+ rows = set([index.row() for index in table.selectedIndexes()])
+
+ menu = QtWidgets.QMenu(table)
+
+ if len(rows) == 1:
+ modify_action = menu.addAction(gui.icons.dialog_open(menu.style()), 'modify')
+ else:
+ modify_action = QtWidgets.QAction(menu)
+
+ delete_action = menu.addAction(gui.icons.trash(menu.style()), 'Delete')
+
+ action = menu.exec_(table.mapToGlobal(position))
+ if action == modify_action and len(rows) == 1:
+ row = list(rows)[0]
+ task = table.model().get_at(row)
+ show_update_dialog(database, table, update_task_signal, row, task)
+ elif action == delete_action:
+ confirm = QtWidgets.QMessageBox.question(table, 'Task deletion', 'Do you really want to delete the selected tasks ?', QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.Yes)
+ if confirm == QtWidgets.QMessageBox.Yes:
+ db.tasks.delete(database.cursor(), table.model().row_ids(rows))
+ database.commit()
+ table.model().delete_tasks(rows)
+
+def show_update_dialog(database, parent_widget, update_task_signal, row, task):
+ dialog = gui.tasks.modal.dialog(
+ parent_widget,
+ 'Modify a task',
+ 'modify',
+ task,
+ lambda taskForm: on_update(database, update_task_signal, row, task, taskForm))
+ dialog.exec_()
+
+def on_update(database, update_task_signal, row, task: Task, taskForm: TaskForm):
+ task = db.tasks.update(database.cursor(), task, taskForm)
+ update_task_signal.emit(row, task)
+ database.commit()
diff --git a/src/gui/tasks/table/model.py b/src/gui/tasks/table/model.py
new file mode 100644
index 0000000..90bcc4c
--- /dev/null
+++ b/src/gui/tasks/table/model.py
@@ -0,0 +1,116 @@
+from PyQt5 import QtCore, QtWidgets
+from PyQt5.QtCore import Qt
+
+from model.task import Task
+import time
+import math
+import util.array
+import util.range
+
+columns = 3
+
+headers = ['Age', 'Name', 'Tag']
+
+default_sort = (0, Qt.AscendingOrder)
+
+class TableModel(QtCore.QAbstractTableModel):
+ def __init__(self, tasks):
+ super(TableModel, self).__init__()
+ self._tasks = tasks
+
+ def headerData(self, section, orientation, role):
+ if role == Qt.DisplayRole and orientation == Qt.Horizontal:
+ return headers[section]
+ elif role == Qt.DisplayRole and orientation == Qt.Vertical:
+ return section + 1
+ else:
+ return QtCore.QVariant()
+
+ def data(self, index, role):
+ if role == Qt.DisplayRole:
+ task = self._tasks[index.row()]
+ if index.column() == 0:
+ return age_since(task.created_at)
+ if index.column() == 1:
+ return task.name
+ if index.column() == 2:
+ return task.tag
+ else:
+ return QtCore.QVariant()
+
+ def rowCount(self, index):
+ return len(self._tasks)
+
+ def columnCount(self, index):
+ return columns
+
+ def get_at(self, row):
+ if row >= 0 and row < len(self._tasks):
+ return self._tasks[row]
+
+
+ def insert_task(self, header: QtWidgets.QHeaderView, task: Task) -> int:
+ at = self.insert_position(header, task)
+ self.beginInsertRows(QtCore.QModelIndex(), at, at)
+ self._tasks.insert(at, task)
+ self.endInsertRows()
+ return at
+
+ def insert_position(self, header: QtWidgets.QHeaderView, task: Task) -> int:
+ row = header.sortIndicatorSection()
+ order = header.sortIndicatorOrder()
+ return util.array.insert_position(
+ sort_key(task, row),
+ [sort_key(t, row) for t in self._tasks],
+ is_reversed(row, order))
+
+ def update_task(self, header: QtWidgets.QHeaderView, row, task: Task) -> int:
+ self.delete_task_range(row, 1)
+ return self.insert_task(header, task)
+
+ def delete_tasks(self, indexes):
+ for range in reversed(util.range.from_indexes(indexes)):
+ self.delete_task_range(range.start, range.length)
+ return True
+
+ def delete_task_range(self, row, rows):
+ self.beginRemoveRows(QtCore.QModelIndex(), row, row + rows - 1)
+ self._tasks = self._tasks[:row] + self._tasks[row + rows:]
+ self.endRemoveRows()
+ return True
+
+ def row_ids(self, rows):
+ return [task.id for i, task in enumerate(self._tasks) if i in rows]
+
+ def sort(self, row: int, order: Qt.SortOrder):
+ self.layoutAboutToBeChanged.emit()
+ self._tasks = sorted(
+ self._tasks,
+ key = lambda task: sort_key(task, row),
+ reverse = is_reversed(row, order))
+ self.layoutChanged.emit()
+
+def age_since(timestamp):
+ diff = int(time.time()) - timestamp
+ if diff >= 60 * 60 * 24:
+ return '' + str(math.floor(diff / 60 / 60 / 24)) + 'd'
+ elif diff >= 60 * 60:
+ return '' + str(math.floor(diff / 60 / 60)) + 'h'
+ elif diff >= 60:
+ return '' + str(math.floor(diff / 60)) + 'm'
+ else:
+ return '1m'
+
+def sort_key(task: Task, row: int):
+ if row == 0:
+ return task.created_at
+ elif row == 1:
+ return task.name.lower()
+ elif row == 2:
+ return task.tag.lower()
+
+def is_reversed(row: int, order: Qt.SortOrder) -> bool:
+ if row == 0:
+ return order == Qt.AscendingOrder
+ else:
+ return order == Qt.DescendingOrder
diff --git a/src/gui/window.py b/src/gui/window.py
new file mode 100644
index 0000000..67d1dea
--- /dev/null
+++ b/src/gui/window.py
@@ -0,0 +1,13 @@
+from PyQt5 import QtCore, QtWidgets
+
+import gui.tasks.main
+
+def get(database):
+ window = QtWidgets.QMainWindow()
+ window.setWindowTitle("todo-next")
+ window.setMinimumSize(QtCore.QSize(640, 480))
+
+ centralWidget = QtWidgets.QWidget(window)
+ window.setCentralWidget(gui.tasks.main.widget(database, centralWidget))
+
+ return window
diff --git a/src/main.py b/src/main.py
new file mode 100644
index 0000000..293ec8d
--- /dev/null
+++ b/src/main.py
@@ -0,0 +1,22 @@
+import sys
+from PyQt5 import QtCore, QtWidgets
+import sqlite3
+import os.path
+
+import db.init
+import gui.window
+
+database = db.init.init('database')
+app = QtWidgets.QApplication(sys.argv)
+
+# # Allows to catch Ctrl-C event
+# timer = QtCore.QTimer()
+# timer.timeout.connect(lambda: None)
+# timer.start(100)
+
+window = gui.window.get(database)
+window.show()
+res = app.exec_()
+
+database.close()
+sys.exit(res)
diff --git a/src/model/task.py b/src/model/task.py
new file mode 100644
index 0000000..26496c4
--- /dev/null
+++ b/src/model/task.py
@@ -0,0 +1,13 @@
+from typing import NamedTuple
+from datetime import datetime
+
+class Task(NamedTuple):
+ id: int
+ created_at: int
+ modified_at: int
+ name: str
+ tag: str
+
+class TaskForm(NamedTuple):
+ name: str
+ tag: str
diff --git a/src/util/array.py b/src/util/array.py
new file mode 100644
index 0000000..7dd0357
--- /dev/null
+++ b/src/util/array.py
@@ -0,0 +1,5 @@
+def insert_position(x, xs, is_reversed: bool) -> int:
+ for i, y in enumerate(xs):
+ if is_reversed and x > y or not is_reversed and x < y:
+ return i
+ return len(xs)
diff --git a/src/util/range.py b/src/util/range.py
new file mode 100644
index 0000000..c75232c
--- /dev/null
+++ b/src/util/range.py
@@ -0,0 +1,30 @@
+from typing import NamedTuple
+
+class Range(NamedTuple):
+ start: int
+ length: int
+
+def from_indexes(indexes):
+ ranges = []
+ curr_range_start = 0
+ curr_range_len = 0
+
+ last_index = -1
+
+ for index in sorted(indexes):
+ if index == curr_range_start + curr_range_len:
+ curr_range_len += 1
+ else:
+ if curr_range_len > 0:
+ ranges.append(Range(
+ start = curr_range_start,
+ length = curr_range_len))
+ curr_range_start = index
+ curr_range_len = 1
+
+ if curr_range_len > 0:
+ ranges.append(Range(
+ start = curr_range_start,
+ length = curr_range_len))
+
+ return ranges