aboutsummaryrefslogtreecommitdiff
path: root/todo
diff options
context:
space:
mode:
authorJoris2020-06-06 17:44:26 +0200
committerJoris2020-06-06 19:54:03 +0200
commit1595e0de940a86a7810df0e02e43838d97c0d846 (patch)
tree9701eeec0d98baa9f6044b1911df68e4c8539819 /todo
parent6b9195000eb5404c247288b384d7ca2bacc1ab23 (diff)
downloadtodo-1595e0de940a86a7810df0e02e43838d97c0d846.tar.gz
todo-1595e0de940a86a7810df0e02e43838d97c0d846.tar.bz2
todo-1595e0de940a86a7810df0e02e43838d97c0d846.zip
Provide nix build
Diffstat (limited to 'todo')
-rw-r--r--todo/__init__.py0
-rw-r--r--todo/arguments.py13
-rw-r--r--todo/database.py19
-rw-r--r--todo/db/__init__.py0
-rw-r--r--todo/db/init.py51
-rw-r--r--todo/db/tags.py72
-rw-r--r--todo/db/task_tags.py39
-rw-r--r--todo/db/tasks.py103
-rw-r--r--todo/gui/__init__.py0
-rw-r--r--todo/gui/color.py21
-rw-r--r--todo/gui/icon.py27
-rw-r--r--todo/gui/signal.py13
-rw-r--r--todo/gui/tags/__init__.py0
-rw-r--r--todo/gui/tags/list.py47
-rw-r--r--todo/gui/tags/panel/__init__.py0
-rw-r--r--todo/gui/tags/panel/dialog.py64
-rw-r--r--todo/gui/tags/panel/form/__init__.py0
-rw-r--r--todo/gui/tags/panel/form/state.py58
-rw-r--r--todo/gui/tags/panel/form/widget.py137
-rw-r--r--todo/gui/tags/panel/signal.py27
-rw-r--r--todo/gui/tags/panel/table/__init__.py0
-rw-r--r--todo/gui/tags/panel/table/menu.py35
-rw-r--r--todo/gui/tags/panel/table/model.py103
-rw-r--r--todo/gui/tags/panel/table/widget.py73
-rw-r--r--todo/gui/tags/panel/widget.py30
-rw-r--r--todo/gui/tasks/__init__.py0
-rw-r--r--todo/gui/tasks/dialog.py76
-rw-r--r--todo/gui/tasks/duration.py50
-rw-r--r--todo/gui/tasks/form/__init__.py0
-rw-r--r--todo/gui/tasks/form/state.py100
-rw-r--r--todo/gui/tasks/form/widget.py184
-rw-r--r--todo/gui/tasks/signal.py28
-rw-r--r--todo/gui/tasks/table/__init__.py0
-rw-r--r--todo/gui/tasks/table/menu.py48
-rw-r--r--todo/gui/tasks/table/widget.py283
-rw-r--r--todo/gui/tasks/test_duration.py21
-rw-r--r--todo/gui/tasks/widget.py30
-rw-r--r--todo/gui/window.py36
-rw-r--r--todo/model/__init__.py0
-rw-r--r--todo/model/difficulty.py30
-rw-r--r--todo/model/priority.py30
-rw-r--r--todo/model/status.py30
-rw-r--r--todo/model/tag.py12
-rw-r--r--todo/model/task.py22
-rw-r--r--todo/model/task_tag.py5
-rw-r--r--todo/service/__init__.py0
-rw-r--r--todo/service/tasks.py32
-rw-r--r--todo/util/__init__.py0
-rw-r--r--todo/util/array.py5
-rw-r--r--todo/util/range.py30
-rw-r--r--todo/util/test_array.py12
-rw-r--r--todo/util/test_range.py6
52 files changed, 2002 insertions, 0 deletions
diff --git a/todo/__init__.py b/todo/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/__init__.py
diff --git a/todo/arguments.py b/todo/arguments.py
new file mode 100644
index 0000000..bb60dce
--- /dev/null
+++ b/todo/arguments.py
@@ -0,0 +1,13 @@
+import argparse
+
+def parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ usage = "%(prog)s [OPTION]",
+ description = "Manage a context-base next-action list."
+ )
+ parser.add_argument(
+ "-v", "--version", action = "version",
+ version = f"{parser.prog} version 1.0.0"
+ )
+ parser.add_argument("-d", "--database")
+ return parser
diff --git a/todo/database.py b/todo/database.py
new file mode 100644
index 0000000..b571e88
--- /dev/null
+++ b/todo/database.py
@@ -0,0 +1,19 @@
+import todo.db.init
+
+_database = None
+
+def init(path):
+ global _database
+ _database = todo.db.init.init(path)
+
+def cursor():
+ global _database
+ return _database.cursor()
+
+def commit():
+ global _database
+ _database.commit()
+
+def close():
+ global _database
+ _database.close()
diff --git a/todo/db/__init__.py b/todo/db/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/db/__init__.py
diff --git a/todo/db/init.py b/todo/db/init.py
new file mode 100644
index 0000000..5d847a3
--- /dev/null
+++ b/todo/db/init.py
@@ -0,0 +1,51 @@
+import sqlite3
+import os.path
+import time
+
+def init(path):
+
+ is_db_new = not os.path.isfile(path)
+
+ database = sqlite3.connect(path)
+
+ cursor = database.cursor()
+
+ if is_db_new:
+
+ cursor.execute(
+ " CREATE TABLE IF NOT EXISTS tasks("
+ " id INTEGER PRIMARY KEY,"
+ " created_at INTEGER NOT NULL,"
+ " updated_at INTEGER NOT NULL,"
+ " name TEXT NOT NULL,"
+ " duration INTEGER,"
+ " difficulty INT,"
+ " priority INT,"
+ " description TEXT,"
+ " status TEXT"
+ " )")
+
+ cursor.execute(
+ " CREATE TABLE IF NOT EXISTS tags("
+ " id INTEGER PRIMARY KEY,"
+ " created_at INTEGER NOT NULL,"
+ " updated_at INTEGER NOT NULL,"
+ " name TEXT NOT NULL,"
+ " color TEXT NOT NULL"
+ " )")
+
+ cursor.execute(
+ " CREATE TABLE IF NOT EXISTS task_tags("
+ " task_id INTEGER NOT NULL,"
+ " tag_id INTEGER NOT NULL,"
+ " created_at INTEGER NOT NULL,"
+ " FOREIGN KEY (task_id) REFERENCES tasks(id),"
+ " FOREIGN KEY (tag_id) REFERENCES tags(id),"
+ " PRIMARY KEY (task_id, tag_id)"
+ " )")
+
+ cursor.execute("PRAGMA foreign_keys = ON")
+
+ database.commit()
+
+ return database
diff --git a/todo/db/tags.py b/todo/db/tags.py
new file mode 100644
index 0000000..c5ce33c
--- /dev/null
+++ b/todo/db/tags.py
@@ -0,0 +1,72 @@
+from sqlite3 import Cursor
+import time
+from typing import List
+
+from todo.model.tag import Tag, ValidTagForm
+
+def get(cursor: Cursor) -> List[Tag]:
+ cursor.execute(
+ " SELECT"
+ " id,"
+ " created_at,"
+ " updated_at,"
+ " name,"
+ " color"
+ " FROM tags")
+
+ res = []
+
+ for tag in cursor.fetchall():
+ res.append(Tag(
+ id = tag[0],
+ created_at = tag[1],
+ updated_at = tag[2],
+ name = tag[3],
+ color = tag[4]
+ ))
+
+ return res
+
+def insert(cursor: Cursor, form: ValidTagForm):
+ now = int(time.time())
+ cursor.execute(
+ " INSERT INTO tags("
+ " created_at,"
+ " updated_at,"
+ " name,"
+ " color"
+ " ) VALUES (?, ?, ?, ?)",
+ (now, now, form.name, form.color))
+
+ return Tag(
+ id = cursor.lastrowid,
+ created_at = now,
+ updated_at = now,
+ name = form.name,
+ color = form.color
+ )
+
+def update(cursor: Cursor, tag: Tag, form: ValidTagForm):
+ now = int(time.time())
+
+ cursor.execute(
+ " UPDATE tags SET"
+ " updated_at = ?,"
+ " name = ?,"
+ " color = ?"
+ " WHERE id = ?",
+ (now, form.name, form.color, tag.id))
+
+ return Tag(
+ id = tag.id,
+ created_at = tag.created_at,
+ updated_at = now,
+ name = form.name,
+ color = form.color
+ )
+
+def delete(cursor: Cursor, ids):
+ if len(ids) >= 1:
+ cursor.execute(
+ "DELETE FROM tags WHERE id IN (%s)" % ",".join("?"*len(ids)),
+ ids)
diff --git a/todo/db/task_tags.py b/todo/db/task_tags.py
new file mode 100644
index 0000000..0fae5f9
--- /dev/null
+++ b/todo/db/task_tags.py
@@ -0,0 +1,39 @@
+from sqlite3 import Cursor
+import time
+from typing import List
+
+from todo.model.task_tag import TaskTag
+
+def one_is_used(cursor: Cursor, tag_ids: List[int]) -> bool:
+ if len(tag_ids) >= 1:
+ cursor.execute(
+ "SELECT task_id FROM task_tags WHERE tag_id IN (%s) LIMIT 1" % ",".join("?"*len(tag_ids)),
+ tag_ids)
+ return len(cursor.fetchall()) == 1
+ else:
+ return False
+
+def get(cursor: Cursor) -> List[TaskTag]:
+ cursor.execute("SELECT task_id, tag_id FROM task_tags")
+ return [TaskTag(r[0], r[1]) for r in cursor.fetchall()]
+
+def insert_many(cursor: Cursor, task_id: int, tag_ids: List[int]) -> List[TaskTag] :
+ now = int(time.time())
+
+ task_tags = [TaskTag(task_id = task_id, tag_id = tag) for tag in tag_ids]
+
+ cursor.executemany(
+ " INSERT INTO task_tags("
+ " task_id,"
+ " tag_id,"
+ " created_at"
+ " ) VALUES (?, ?, ?)",
+ [(t.task_id, t.tag_id, now) for t in task_tags])
+
+ return task_tags
+
+def delete(cursor: Cursor, task_ids: List[int]):
+ if len(task_ids) >= 1:
+ cursor.execute(
+ "DELETE FROM task_tags WHERE task_id IN (%s)" % ",".join("?"*len(task_ids)),
+ task_ids)
diff --git a/todo/db/tasks.py b/todo/db/tasks.py
new file mode 100644
index 0000000..fc23bf0
--- /dev/null
+++ b/todo/db/tasks.py
@@ -0,0 +1,103 @@
+from sqlite3 import Cursor
+import time
+from typing import List
+
+from todo.model.task import Task, ValidTaskForm
+from todo.model.status import Status
+from todo.model import difficulty, priority, status
+
+def get(cursor: Cursor, s: Status) -> List[Task]:
+ cursor.execute(
+ " SELECT"
+ " id,"
+ " created_at,"
+ " updated_at,"
+ " name,"
+ " duration,"
+ " difficulty,"
+ " priority,"
+ " description"
+ " FROM"
+ " tasks"
+ " WHERE"
+ " status = ?",
+ (status.format(s),))
+
+ res = []
+
+ for task in cursor.fetchall():
+ res.append(Task(
+ id = task[0],
+ created_at = task[1],
+ updated_at = task[2],
+ name = task[3],
+ duration = task[4],
+ difficulty = difficulty.parse(task[5]),
+ priority = priority.parse(task[6]),
+ description = task[7]
+ ))
+
+ return res
+
+def insert(cursor: Cursor, s: Status, form: ValidTaskForm):
+ now = int(time.time())
+ cursor.execute(
+ " INSERT INTO tasks("
+ " created_at,"
+ " updated_at,"
+ " name,"
+ " duration,"
+ " difficulty,"
+ " priority,"
+ " description,"
+ " status"
+ " ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ (now, now, form.name, form.duration, difficulty.format(form.difficulty), priority.format(form.priority), form.description, status.format(s)))
+
+ return Task(
+ id = cursor.lastrowid,
+ created_at = now,
+ updated_at = now,
+ name = form.name,
+ duration = form.duration,
+ difficulty = form.difficulty,
+ priority = form.priority,
+ description = form.description
+ )
+
+def update(cursor: Cursor, task: Task, form: ValidTaskForm):
+ now = int(time.time())
+
+ cursor.execute(
+ " UPDATE tasks SET"
+ " updated_at = ?,"
+ " name = ?,"
+ " duration = ?,"
+ " difficulty = ?,"
+ " priority = ?,"
+ " description = ?"
+ " WHERE id = ?",
+ (now, form.name, form.duration, difficulty.format(form.difficulty), priority.format(form.priority), form.description, task.id))
+
+ return Task(
+ id = task.id,
+ created_at = task.created_at,
+ updated_at = now,
+ name = form.name,
+ duration = form.duration,
+ difficulty = form.difficulty,
+ priority = form.priority,
+ description = form.description
+ )
+
+def delete(cursor: Cursor, ids: List[int]):
+ if len(ids) >= 1:
+ cursor.execute(
+ "DELETE FROM tasks WHERE id IN (%s)" % ",".join("?"*len(ids)),
+ ids)
+
+def update_status(cursor: Cursor, ids: List[int], s: Status):
+ if len(ids) >= 1:
+ cursor.execute(
+ "UPDATE tasks SET status = ? WHERE id IN (%s)" % ",".join("?"*len(ids)),
+ [status.format(s)] + ids)
diff --git a/todo/gui/__init__.py b/todo/gui/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/gui/__init__.py
diff --git a/todo/gui/color.py b/todo/gui/color.py
new file mode 100644
index 0000000..cc7e5a8
--- /dev/null
+++ b/todo/gui/color.py
@@ -0,0 +1,21 @@
+from PyQt5 import QtGui
+
+black = QtGui.QColor(0, 0, 0)
+red = QtGui.QColor(200, 30, 30)
+orange = QtGui.QColor(200, 100, 30)
+green = QtGui.QColor(30, 180, 30)
+blue = QtGui.QColor(30, 30, 200)
+
+text = black
+
+easy_difficulty = green
+normal_difficulty = orange
+hard_difficulty = red
+
+low_priority = green
+middle_priority = orange
+high_priority = red
+
+short_duration = green
+medium_duration = orange
+long_duration = red
diff --git a/todo/gui/icon.py b/todo/gui/icon.py
new file mode 100644
index 0000000..7e2156d
--- /dev/null
+++ b/todo/gui/icon.py
@@ -0,0 +1,27 @@
+from PyQt5 import QtWidgets
+
+# List of icons: https://joekuan.wordpress.com/2015/09/23/list-of-qt-icons/
+
+def task_ready(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_DialogApplyButton)
+
+def task_waiting(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_BrowserReload)
+
+def task_maybe(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_TitleBarContextHelpButton)
+
+def new_folder(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_FileDialogNewFolder)
+
+def dialog_open(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_DialogOpenButton)
+
+def dialog_ok(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_DialogOkButton)
+
+def dialog_cancel(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_DialogCancelButton)
+
+def trash(style):
+ return style.standardIcon(QtWidgets.QStyle.SP_TrashIcon)
diff --git a/todo/gui/signal.py b/todo/gui/signal.py
new file mode 100644
index 0000000..99100f1
--- /dev/null
+++ b/todo/gui/signal.py
@@ -0,0 +1,13 @@
+from PyQt5 import QtCore
+
+class Reload(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(name = "reload")
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self):
+ self._signal.emit()
+
+ def connect(self, f):
+ self._signal.connect(f)
diff --git a/todo/gui/tags/__init__.py b/todo/gui/tags/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/gui/tags/__init__.py
diff --git a/todo/gui/tags/list.py b/todo/gui/tags/list.py
new file mode 100644
index 0000000..d0374ed
--- /dev/null
+++ b/todo/gui/tags/list.py
@@ -0,0 +1,47 @@
+from PyQt5 import QtWidgets, QtCore
+from typing import List, Tuple
+
+from todo.model.tag import Tag
+import todo.db.tags
+import todo.database
+
+class SelectionSignal(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(list, name = "selection")
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self, tag_ids):
+ self._signal.emit(tag_ids)
+
+ def connect(self, f):
+ self._signal.connect(f)
+
+def widget(parent, init_tags: List[int]) -> Tuple[QtWidgets.QWidget, SelectionSignal]:
+ widget = QtWidgets.QWidget(parent)
+ signal = SelectionSignal()
+
+ layout = QtWidgets.QVBoxLayout(widget)
+ widget.setLayout(layout)
+
+ layout.addWidget(QtWidgets.QLabel("Tags"))
+
+ list_widget = QtWidgets.QListWidget()
+ list_widget.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
+ layout.addWidget(list_widget)
+
+ tags = todo.db.tags.get(todo.database.cursor())
+
+ for tag in tags:
+ item = QtWidgets.QListWidgetItem(tag.name)
+ list_widget.addItem(item)
+ if tag.id in init_tags:
+ item.setSelected(True)
+
+ def on_item_selection_changed():
+ tag_texts = [item.text() for item in list_widget.selectedItems()]
+ signal.emit([tag.id for tag in tags if tag.name in tag_texts])
+
+ list_widget.itemSelectionChanged.connect(on_item_selection_changed)
+
+ return (widget, signal)
diff --git a/todo/gui/tags/panel/__init__.py b/todo/gui/tags/panel/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/gui/tags/panel/__init__.py
diff --git a/todo/gui/tags/panel/dialog.py b/todo/gui/tags/panel/dialog.py
new file mode 100644
index 0000000..f1a669c
--- /dev/null
+++ b/todo/gui/tags/panel/dialog.py
@@ -0,0 +1,64 @@
+from PyQt5 import QtCore, QtWidgets
+
+from todo.model.tag import Tag, ValidTagForm
+import todo.db.tags
+import todo.gui.tags.panel.form.widget
+import todo.database
+
+def add(parent_widget, add_tag_signal):
+
+ def on_add(form: ValidTagForm):
+ tag = todo.db.tags.insert(todo.database.cursor(), form)
+ todo.database.commit()
+ add_tag_signal.emit(tag)
+
+ return widget(parent_widget, "Add a tag", "add", None, on_add)
+
+def update(parent_widget, update_tag_signal, row, tag):
+
+ def on_update(form: ValidTagForm):
+ updated_tag = todo.db.tags.update(todo.database.cursor(), tag, form)
+ update_tag_signal.emit(row, updated_tag)
+ todo.database.commit()
+
+ return widget(parent_widget, "Modify a tag", "modify", tag, on_update)
+
+def show_delete(table, rows):
+ confirm = QtWidgets.QMessageBox.question(
+ table,
+ "Tag deletion",
+ "Do you really want to delete the selected tags ?",
+ QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes,
+ QtWidgets.QMessageBox.Yes)
+
+ if confirm == QtWidgets.QMessageBox.Yes:
+ todo.db.tags.delete(todo.database.cursor(), table.model().row_ids(rows))
+ todo.database.commit()
+ table.model().delete_tags(rows)
+
+def widget(
+ parent: QtWidgets.QWidget,
+ title: str,
+ action_title: str,
+ tag: Tag,
+ on_validated):
+
+ dialog = QtWidgets.QDialog(parent)
+ dialog.setWindowTitle(title)
+ dialog.setMinimumSize(QtCore.QSize(320, 240))
+
+ layout = QtWidgets.QVBoxLayout(dialog)
+ dialog.setLayout(layout)
+
+ def on_dialog_validated(form):
+ dialog.accept()
+ on_validated(form)
+
+ layout.addWidget(todo.gui.tags.panel.form.widget.widget(
+ parent = dialog,
+ action_title = action_title,
+ tag = tag,
+ on_validated = on_dialog_validated,
+ on_cancel = lambda: dialog.reject()))
+
+ return dialog
diff --git a/todo/gui/tags/panel/form/__init__.py b/todo/gui/tags/panel/form/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/gui/tags/panel/form/__init__.py
diff --git a/todo/gui/tags/panel/form/state.py b/todo/gui/tags/panel/form/state.py
new file mode 100644
index 0000000..f898ac4
--- /dev/null
+++ b/todo/gui/tags/panel/form/state.py
@@ -0,0 +1,58 @@
+from PyQt5 import QtCore
+from typing import Optional
+
+from todo.model.tag import ValidTagForm
+
+class TagFormEdition:
+ def __init__(
+ self,
+ name,
+ name_signal,
+ color,
+ color_signal):
+
+ self._name = name
+ self._color = color
+ self._signal = ValidTagFormSignal()
+
+ name_signal.connect(lambda n: self.on_name_signal(n))
+ color_signal.connect(lambda d: self.on_color_signal(d))
+
+ def get(self) -> Optional[ValidTagForm]:
+ name = self._name.strip()
+ color = self._color.strip()
+
+ if name and color:
+ return ValidTagForm(
+ name = name,
+ color = color)
+ else:
+ return None
+
+ def on_name_signal(self, name: str):
+ self._name = name
+ self.emit()
+
+ def on_color_signal(self, color: str):
+ self._color = color
+ self.emit()
+
+ def emit(self):
+ validForm = self.get()
+ if validForm:
+ self._signal.emit(validForm)
+
+ def signal(self):
+ return self._signal
+
+class ValidTagFormSignal(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(ValidTagForm, name = "validTagForm")
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self, form: Optional[ValidTagForm]):
+ self._signal.emit(form)
+
+ def connect(self, f):
+ self._signal.connect(f)
diff --git a/todo/gui/tags/panel/form/widget.py b/todo/gui/tags/panel/form/widget.py
new file mode 100644
index 0000000..9ac4eb1
--- /dev/null
+++ b/todo/gui/tags/panel/form/widget.py
@@ -0,0 +1,137 @@
+from PyQt5 import QtWidgets, QtCore, QtGui
+from typing import Optional, Tuple, List, Any
+
+from todo.model.tag import Tag, ValidTagForm
+from todo.model import difficulty, priority
+import todo.gui.icon
+import todo.gui.tags.panel.form.state
+import todo.gui.color
+
+def widget(
+ parent: QtWidgets.QWidget,
+ action_title: str,
+ tag: Tag,
+ on_validated,
+ on_cancel):
+
+ widget = QtWidgets.QWidget(parent)
+ layout = QtWidgets.QVBoxLayout(widget)
+ widget.setLayout(layout)
+
+ grid = QtWidgets.QWidget(widget)
+ layout.addWidget(grid)
+ grid_layout = QtWidgets.QGridLayout(grid)
+ grid.setLayout(grid_layout)
+
+ init_name = tag.name if tag is not None else ""
+ name_input = line_edit(grid, grid_layout, 0, "Name", init_name)
+
+ init_color = tag.color if tag is not None else "#FFFFFF"
+ color_input = color_edit(grid, grid_layout, 1, "Color", QtGui.QColor(init_color))
+
+ tag_form_edition = todo.gui.tags.panel.form.state.TagFormEdition(
+ init_name,
+ name_input.textChanged,
+ init_color,
+ color_input.textChanged)
+
+ def on_validate():
+ form = tag_form_edition.get()
+ if form:
+ on_validated(form)
+
+ layout.addWidget(buttons(
+ parent = widget,
+ action_title = action_title,
+ tag_form_signal = tag_form_edition.signal(),
+ on_validate = on_validate,
+ on_cancel = on_cancel))
+
+ return widget
+
+def line_edit(
+ parent,
+ layout: QtWidgets.QGridLayout,
+ n: int,
+ label: str,
+ default_value: str) -> QtWidgets.QLineEdit:
+
+ label = QtWidgets.QLabel(label, parent)
+ layout.addWidget(label, n, 0)
+
+ edit = QtWidgets.QLineEdit(parent)
+ if default_value != None:
+ edit.setText(default_value)
+ layout.addWidget(edit, n, 1)
+
+ return edit
+
+def color_edit(
+ parent,
+ layout: QtWidgets.QGridLayout,
+ n: int,
+ label: str,
+ init_color: QtGui.QColor) -> QtWidgets.QLineEdit:
+
+ label = QtWidgets.QLabel(label, parent)
+ layout.addWidget(label, n, 0)
+
+ edit = ColorInput(init_color, parent)
+ layout.addWidget(edit, n, 1)
+
+ return edit
+
+class ColorInput(QtWidgets.QLineEdit):
+
+ def __init__(self, init_color: QtGui.QColor, parent):
+ super().__init__(parent)
+ self.setReadOnly(True)
+ self.installEventFilter(self)
+ self._color = init_color
+ self.update(init_color)
+ self._is_editing = False
+
+ def eventFilter(self, source, event):
+ if source is self and event.type() == QtCore.QEvent.FocusIn:
+ if not self._is_editing:
+ self._is_editing = True
+ color = QtWidgets.QColorDialog.getColor(self._color, self)
+ if color.isValid():
+ self.update(color)
+ else:
+ self._is_editing = False
+ self.clearFocus()
+ return super(ColorInput, self).eventFilter(source, event)
+
+ def update(self, color: QtGui.QColor):
+ self._color = color
+ self.setText(color.name().upper())
+ palette = QtGui.QPalette()
+ palette.setColor(QtGui.QPalette.Base, color)
+ palette.setColor(QtGui.QPalette.Text, color)
+ self.setPalette(palette)
+
+def buttons(parent, action_title, tag_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(todo.gui.icon.dialog_ok(validate.style()))
+ validate.clicked.connect(on_validate);
+ layout.addWidget(validate)
+
+ def on_tag_form_signal(form: Optional[ValidTagForm]):
+ if form:
+ validate.setEnabled(True)
+ else:
+ validate.setDisabled(True)
+
+ tag_form_signal.connect(on_tag_form_signal)
+
+ cancel = QtWidgets.QPushButton("cancel", widget)
+ cancel.setIcon(todo.gui.icon.dialog_cancel(cancel.style()))
+ cancel.clicked.connect(on_cancel)
+ layout.addWidget(cancel)
+
+ return widget
diff --git a/todo/gui/tags/panel/signal.py b/todo/gui/tags/panel/signal.py
new file mode 100644
index 0000000..97065cd
--- /dev/null
+++ b/todo/gui/tags/panel/signal.py
@@ -0,0 +1,27 @@
+from PyQt5 import QtCore
+
+from todo.model.tag import Tag
+
+class AddTag(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(Tag, name = "addTag")
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self, tag):
+ self._signal.emit(tag)
+
+ def connect(self, f):
+ self._signal.connect(f)
+
+class UpdateTag(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(int, Tag, name = "updateTag")
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self, row, tag):
+ self._signal.emit(row, tag)
+
+ def connect(self, f):
+ self._signal.connect(f)
diff --git a/todo/gui/tags/panel/table/__init__.py b/todo/gui/tags/panel/table/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/gui/tags/panel/table/__init__.py
diff --git a/todo/gui/tags/panel/table/menu.py b/todo/gui/tags/panel/table/menu.py
new file mode 100644
index 0000000..e286051
--- /dev/null
+++ b/todo/gui/tags/panel/table/menu.py
@@ -0,0 +1,35 @@
+from PyQt5 import QtWidgets, QtCore
+
+from todo.model.tag import Tag, ValidTagForm
+import todo.database
+import todo.db.task_tags
+import todo.gui.tags.panel.dialog
+
+def open(table, update_tag_signal, position):
+ rows = set([index.row() for index in table.selectedIndexes()])
+
+ menu = QtWidgets.QMenu(table)
+
+ actions = 0
+
+ if len(rows) == 1:
+ modify_action = menu.addAction(todo.gui.icon.dialog_open(menu.style()), "modify")
+ actions += 1
+ else:
+ modify_action = QtWidgets.QAction(menu)
+
+ tags = table.model().row_ids(rows)
+ if not todo.db.task_tags.one_is_used(todo.database.cursor(), tags):
+ delete_action = menu.addAction(todo.gui.icon.trash(menu.style()), "delete")
+ actions += 1
+ else:
+ delete_action = QtWidgets.QAction(menu)
+
+ if actions > 0:
+ action = menu.exec_(table.mapToGlobal(position + QtCore.QPoint(15, 20)))
+ if action == modify_action and len(rows) == 1:
+ row = list(rows)[0]
+ tag = table.model().get_at(row)
+ todo.gui.tags.panel.dialog.update(table, update_tag_signal, row, tag).exec_()
+ elif action == delete_action:
+ todo.gui.tags.panel.dialog.show_delete(table, rows)
diff --git a/todo/gui/tags/panel/table/model.py b/todo/gui/tags/panel/table/model.py
new file mode 100644
index 0000000..6f9d71a
--- /dev/null
+++ b/todo/gui/tags/panel/table/model.py
@@ -0,0 +1,103 @@
+from PyQt5 import QtCore, QtWidgets, QtGui
+from PyQt5.QtCore import Qt
+from typing import List
+import time
+import math
+
+from todo.model.tag import Tag
+import todo.util.array
+import todo.util.range
+
+columns = 1
+
+headers = ["Name", "Color"]
+
+default_sort = (0, Qt.AscendingOrder)
+
+class TableModel(QtCore.QAbstractTableModel):
+ def __init__(self, tags):
+ super(TableModel, self).__init__()
+ self._tags = tags
+
+ 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):
+ tag = self._tags[index.row()]
+
+ if role == Qt.DisplayRole:
+ if index.column() == 0:
+ return tag.name
+ elif index.column() == 1:
+ return tag.color
+ elif role == Qt.BackgroundRole:
+ return QtGui.QBrush(QtGui.QColor(tag.color))
+ else:
+ return QtCore.QVariant()
+
+ def rowCount(self, index):
+ return len(self._tags)
+
+ def columnCount(self, index):
+ return columns
+
+ def get_at(self, row):
+ if row >= 0 and row < len(self._tags):
+ return self._tags[row]
+
+ def insert_tag(self, header: QtWidgets.QHeaderView, tag: Tag) -> int:
+ at = self.insert_position(header, tag)
+ self.beginInsertRows(QtCore.QModelIndex(), at, at)
+ self._tags.insert(at, tag)
+ self.endInsertRows()
+ return at
+
+ def insert_position(self, header: QtWidgets.QHeaderView, tag: Tag) -> int:
+ row = header.sortIndicatorSection()
+ order = header.sortIndicatorOrder()
+ is_rev = is_reversed(row, order)
+ return todo.util.array.insert_position(
+ sort_key(tag, row, is_rev),
+ [sort_key(t, row, is_rev) for t in self._tags],
+ is_rev)
+
+ def update_tag(self, header: QtWidgets.QHeaderView, row, tag: Tag) -> int:
+ self.delete_tag_range(row, 1)
+ return self.insert_tag(header, tag)
+
+ def delete_tags(self, indexes):
+ for range in reversed(todo.util.range.from_indexes(indexes)):
+ self.delete_tag_range(range.start, range.length)
+ return True
+
+ def delete_tag_range(self, row, rows):
+ self.beginRemoveRows(QtCore.QModelIndex(), row, row + rows - 1)
+ self._tags = self._tags[:row] + self._tags[row + rows:]
+ self.endRemoveRows()
+ return True
+
+ def row_ids(self, rows):
+ return [tag.id for i, tag in enumerate(self._tags) if i in rows]
+
+ def sort(self, row: int, order: Qt.SortOrder):
+ self.layoutAboutToBeChanged.emit()
+ is_rev = is_reversed(row, order)
+ self._tags = sorted(
+ self._tags,
+ key = lambda tag: sort_key(tag, row, is_rev),
+ reverse = is_rev)
+ self.layoutChanged.emit()
+
+def sort_key(tag: Tag, row: int, is_reversed: bool):
+ if row == 0:
+ return tag.name.lower()
+ elif row == 1:
+ return tag.color
+
+def is_reversed(row: int, order: Qt.SortOrder) -> bool:
+ return order == Qt.DescendingOrder
diff --git a/todo/gui/tags/panel/table/widget.py b/todo/gui/tags/panel/table/widget.py
new file mode 100644
index 0000000..b36759d
--- /dev/null
+++ b/todo/gui/tags/panel/table/widget.py
@@ -0,0 +1,73 @@
+from PyQt5 import QtWidgets
+from PyQt5.QtCore import Qt
+
+from todo.model.tag import Tag, ValidTagForm
+import todo.database
+import todo.db.tags
+import todo.db.task_tags
+import todo.gui.tags.panel.dialog
+import todo.gui.tags.panel.signal
+import todo.gui.tags.panel.table.menu
+import todo.gui.tags.panel.table.model
+
+class Widget(QtWidgets.QTableView):
+
+ def __init__(self, parent, add_tag_signal):
+ super().__init__(parent)
+
+ self._update_tag_signal = todo.gui.tags.panel.signal.UpdateTag()
+
+ tags = todo.db.tags.get(todo.database.cursor())
+ table_model = todo.gui.tags.panel.table.model.TableModel(tags)
+
+ self.setModel(table_model)
+ self.sortByColumn(
+ todo.gui.tags.panel.table.model.default_sort[0],
+ todo.gui.tags.panel.table.model.default_sort[1])
+ self.setSortingEnabled(True)
+ self.setSelectionBehavior(QtWidgets.QTableView.SelectRows)
+ self.horizontalHeader().setStretchLastSection(True)
+ self.resizeColumns()
+
+ self.doubleClicked.connect(lambda index: self.on_double_click(index.row()))
+
+ # # Menu
+ self.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.customContextMenuRequested.connect(lambda position: todo.gui.tags.panel.table.menu.open(self, self._update_tag_signal, position))
+
+ add_tag_signal.connect(lambda tag: self.insert(tag))
+ self._update_tag_signal.connect(lambda row, tag: self.update(row, tag))
+
+ def insert(self, tag):
+ self.model().insert_tag(self.horizontalHeader(), tag)
+ self.resizeColumns()
+
+ def update(self, row, tag):
+ row = self.model().update_tag(self.horizontalHeader(), row, tag)
+ self.selectRow(row)
+ self.resizeColumns()
+
+ def resizeColumns(self):
+ for column in range(todo.gui.tags.panel.table.model.columns):
+ self.resizeColumnToContents(column)
+
+ def keyPressEvent(self, event):
+ super().keyPressEvent(event)
+ if event.key() in (Qt.Key_Return, Qt.Key_Enter):
+ rows = self.get_selected_rows()
+ if len(rows) == 1:
+ row = rows[0]
+ tag = self.model().get_at(row)
+ todo.gui.tags.panel.dialog.update(self, self._update_tag_signal, row, tag).exec_()
+ elif event.key() == Qt.Key_Delete:
+ rows = self.get_selected_rows()
+ tags = self.model().row_ids(rows)
+ if not db.task_tags.one_is_used(todo.database.cursor(), tags):
+ todo.gui.tags.panel.dialog.show_delete(self, rows)
+
+ def get_selected_rows(self):
+ return list(set([index.row() for index in self.selectedIndexes()]))
+
+ def on_double_click(self, row: int):
+ tag = self.model().get_at(row)
+ todo.gui.tags.panel.dialog.update(self, self._update_tag_signal, row, tag).exec_()
diff --git a/todo/gui/tags/panel/widget.py b/todo/gui/tags/panel/widget.py
new file mode 100644
index 0000000..071442e
--- /dev/null
+++ b/todo/gui/tags/panel/widget.py
@@ -0,0 +1,30 @@
+from PyQt5 import QtWidgets
+
+import todo.gui.tags.panel.dialog
+import todo.gui.tags.panel.signal
+import todo.gui.tags.panel.table.widget
+import todo.gui.icon
+
+def widget(parent):
+ widget = QtWidgets.QWidget(parent)
+
+ layout = QtWidgets.QVBoxLayout(widget)
+ widget.setLayout(layout)
+
+ layout.addSpacing(15)
+
+ add_tag_signal = todo.gui.tags.panel.signal.AddTag()
+
+ add_tag_button = QtWidgets.QPushButton(" Add a tag", widget)
+ add_tag_button.setFixedHeight(30)
+ add_tag_button.setIcon(todo.gui.icon.new_folder(widget.style()))
+
+ add_tag_button.clicked.connect(lambda: todo.gui.tags.panel.dialog.add(widget, add_tag_signal).exec_())
+ layout.addWidget(add_tag_button)
+
+ layout.addSpacing(20)
+
+ table = todo.gui.tags.panel.table.widget.Widget(widget, add_tag_signal)
+ layout.addWidget(table)
+
+ return widget
diff --git a/todo/gui/tasks/__init__.py b/todo/gui/tasks/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/gui/tasks/__init__.py
diff --git a/todo/gui/tasks/dialog.py b/todo/gui/tasks/dialog.py
new file mode 100644
index 0000000..c2bd164
--- /dev/null
+++ b/todo/gui/tasks/dialog.py
@@ -0,0 +1,76 @@
+from PyQt5 import QtCore, QtWidgets
+from typing import List
+
+from todo.model.tag import Tag
+from todo.model.task import Task, ValidTaskForm
+from todo.model.status import Status
+import todo.database
+import todo.gui.tasks.form.widget
+import todo.service.tasks
+
+def add(parent_widget, status: Status, add_task_signal):
+
+ def on_add(task_form: ValidTaskForm):
+ task = todo.service.tasks.create(todo.database.cursor(), status, task_form)
+ add_task_signal.emit(task, task_form.tags)
+
+ return widget(parent_widget, "Add a task", "add", None, [], on_add)
+
+def update(parent_widget, update_task_signal, row: int, task: Task, tags: List[int]):
+
+ def on_update(task_form: ValidTaskForm):
+ updated_task = todo.service.tasks.update(todo.database.cursor(), task, task_form)
+ update_task_signal.emit(row, updated_task, task_form.tags)
+
+ return widget(parent_widget, "Modify a task", "modify", task, tags, on_update)
+
+def confirm_delete(parent, rows: List[int], on_confirm):
+ confirm = QtWidgets.QMessageBox.question(
+ parent,
+ "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:
+ on_confirm()
+
+def confirm_move(parent, rows: List[int], move_to: Status, on_confirm):
+ confirm = QtWidgets.QMessageBox.question(
+ parent,
+ "Task move",
+ "Do you really want to move the selected tasks ?",
+ QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes,
+ QtWidgets.QMessageBox.Yes)
+
+ if confirm == QtWidgets.QMessageBox.Yes:
+ on_confirm()
+
+def widget(
+ parent: QtWidgets.QWidget,
+ title: str,
+ action_title: str,
+ task: Task,
+ tags: List[int],
+ on_validated):
+
+ dialog = QtWidgets.QDialog(parent)
+ dialog.setWindowTitle(title)
+ dialog.setMinimumSize(QtCore.QSize(320, 240))
+
+ layout = QtWidgets.QVBoxLayout(dialog)
+ dialog.setLayout(layout)
+
+ def on_dialog_validated(form):
+ dialog.accept()
+ on_validated(form)
+
+ layout.addWidget(todo.gui.tasks.form.widget.widget(
+ parent = dialog,
+ action_title = action_title,
+ task = task,
+ tags = tags,
+ on_validated = on_dialog_validated,
+ on_cancel = lambda: dialog.reject()))
+
+ return dialog
diff --git a/todo/gui/tasks/duration.py b/todo/gui/tasks/duration.py
new file mode 100644
index 0000000..81db661
--- /dev/null
+++ b/todo/gui/tasks/duration.py
@@ -0,0 +1,50 @@
+from PyQt5 import QtGui
+from typing import Optional
+import math
+import re
+
+import todo.gui.color
+
+def format(minutes: int):
+ if minutes >= 60 * 24:
+ return "" + format_decimal(minutes / 60 / 24) + "d"
+ elif minutes >= 60:
+ return "" + format_decimal(minutes / 60) + "h"
+ elif minutes > 0:
+ return "" + str(minutes) + "m"
+ else:
+ return ""
+
+def format_decimal(d: float) -> str:
+ return "{0:.2g}".format(d)
+
+def parse(duration: str) -> Optional[int]:
+ duration = duration.strip()
+ if duration:
+ result = re.match("^(\d+)(\.(\d+))?([mhd])$", duration.strip())
+ if result:
+ n = int(result.group(1))
+ if result.group(3):
+ d = int(result.group(3)) * pow(10, -1 * len(result.group(3)))
+ else:
+ d = 0
+ num = n + d
+ unit = result.group(4)
+ if unit == "m":
+ return math.floor(num)
+ elif unit == "h":
+ return math.floor(num * 60)
+ elif unit == "d":
+ return math.floor(num * 60 * 24)
+ else:
+ return None
+ else:
+ return 0
+
+def color(minutes: int):
+ if minutes <= 15:
+ return todo.gui.color.short_duration
+ elif minutes < 60:
+ return todo.gui.color.medium_duration
+ else:
+ return todo.gui.color.long_duration
diff --git a/todo/gui/tasks/form/__init__.py b/todo/gui/tasks/form/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/gui/tasks/form/__init__.py
diff --git a/todo/gui/tasks/form/state.py b/todo/gui/tasks/form/state.py
new file mode 100644
index 0000000..33b0873
--- /dev/null
+++ b/todo/gui/tasks/form/state.py
@@ -0,0 +1,100 @@
+from PyQt5 import QtCore
+from typing import Optional
+
+from todo.model.task import ValidTaskForm
+from todo.model.difficulty import Difficulty
+from todo.model.priority import Priority
+import todo.gui.tasks.duration
+import todo.gui.tags.list
+
+class TaskFormEdition:
+ def __init__(
+ self,
+ name,
+ name_signal,
+ duration,
+ duration_signal,
+ difficulty,
+ difficulty_signal,
+ priority,
+ priority_signal,
+ tags_signal: todo.gui.tags.list.SelectionSignal,
+ description,
+ description_signal):
+
+ self._name = name
+ self._duration = duration
+ self._difficulty = difficulty
+ self._priority = priority
+ self._tags = []
+ self._description = description
+ self._signal = ValidTaskFormSignal()
+
+ name_signal.connect(lambda n: self.on_name_signal(n))
+ duration_signal.connect(lambda d: self.on_duration_signal(d))
+ difficulty_signal.connect(lambda d: self.on_difficulty_signal(d))
+ priority_signal.connect(lambda p: self.on_priority_signal(p))
+ tags_signal.connect(lambda ts: self.on_tags_signal(ts))
+ description_signal.connect(lambda d: self.on_description_signal(d))
+
+ def get(self) -> Optional[ValidTaskForm]:
+ name = self._name.strip()
+ duration = todo.gui.tasks.duration.parse(self._duration)
+ difficulty = self._difficulty
+ priority = self._priority
+ description = self._description.strip()
+
+ if name and duration != None:
+ return ValidTaskForm(
+ name = name,
+ duration = duration,
+ difficulty = difficulty,
+ priority = priority,
+ tags = self._tags,
+ description = description)
+ else:
+ return None
+
+ def on_name_signal(self, name: str):
+ self._name = name
+ self.emit()
+
+ def on_duration_signal(self, duration: str):
+ self._duration = duration
+ self.emit()
+
+ def on_difficulty_signal(self, index: int):
+ self._difficulty = Difficulty(index)
+ self.emit()
+
+ def on_priority_signal(self, index: int):
+ self._priority = Priority(index)
+ self.emit()
+
+ def on_tags_signal(self, tags: [int]):
+ self._tags = tags
+ self.emit()
+
+ def on_description_signal(self, description: str):
+ self._description = description
+ self.emit()
+
+ def emit(self):
+ validForm = self.get()
+ if validForm:
+ self._signal.emit(validForm)
+
+ def signal(self):
+ return self._signal
+
+class ValidTaskFormSignal(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(ValidTaskForm, name = "validTaskForm")
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self, form: Optional[ValidTaskForm]):
+ self._signal.emit(form)
+
+ def connect(self, f):
+ self._signal.connect(f)
diff --git a/todo/gui/tasks/form/widget.py b/todo/gui/tasks/form/widget.py
new file mode 100644
index 0000000..15be21b
--- /dev/null
+++ b/todo/gui/tasks/form/widget.py
@@ -0,0 +1,184 @@
+from PyQt5 import QtWidgets, QtCore
+from typing import Optional, Tuple, List, Any
+
+from todo.model.task import Task, ValidTaskForm
+from todo.model.tag import Tag
+from todo.model import difficulty, priority
+import todo.gui.icon
+import todo.gui.tasks.form.state
+import todo.gui.tasks.duration
+import todo.gui.tags.list
+
+class TextEditSignal(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(str, name = "textEdit")
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self, text: str):
+ self._signal.emit(text)
+
+ def connect(self, f):
+ self._signal.connect(f)
+
+def widget(
+ parent: QtWidgets.QWidget,
+ action_title: str,
+ task: Task,
+ tags: List[int],
+ on_validated,
+ on_cancel):
+
+ widget = QtWidgets.QWidget(parent)
+ layout = QtWidgets.QVBoxLayout(widget)
+ widget.setLayout(layout)
+
+ grid = QtWidgets.QWidget(widget)
+ layout.addWidget(grid)
+ grid_layout = QtWidgets.QGridLayout(grid)
+ grid.setLayout(grid_layout)
+
+ init_name = task.name if task is not None else ""
+ name_input = line_edit(grid, grid_layout, 0, "Name", init_name)
+
+ init_duration = todo.gui.tasks.duration.format(task.duration) if task is not None else ""
+ duration_input = line_edit(grid, grid_layout, 1, "Duration", init_duration)
+
+ init_difficulty = task.difficulty if task is not None else difficulty.Difficulty.NORMAL
+ difficulty_input = combo_box(
+ grid,
+ grid_layout,
+ 3,
+ "Difficulty",
+ [difficulty.format(d) for d in difficulty.values],
+ int(init_difficulty))
+
+ init_priority = task.priority if task is not None else priority.Priority.MIDDLE
+ priority_input = combo_box(
+ grid,
+ grid_layout,
+ 4,
+ "Priority",
+ [priority.format(d) for d in priority.values],
+ int(init_priority))
+
+ (tags_list_widget, tags_signal) = tags_selection(widget, tags)
+ layout.addWidget(tags_list_widget)
+
+ init_description = task.description if task is not None else ""
+ (description_input, description_signal) = text_edit(widget, "Description", init_description)
+ layout.addWidget(description_input)
+
+ task_form_edition = todo.gui.tasks.form.state.TaskFormEdition(
+ init_name,
+ name_input.textChanged,
+ init_duration,
+ duration_input.textChanged,
+ init_difficulty,
+ difficulty_input.currentIndexChanged,
+ init_priority,
+ priority_input.currentIndexChanged,
+ tags_signal,
+ init_description,
+ description_signal)
+
+ def on_validate():
+ form = task_form_edition.get()
+ if form:
+ on_validated(form)
+
+ layout.addWidget(buttons(
+ parent = widget,
+ action_title = action_title,
+ task_form_signal = task_form_edition.signal(),
+ on_validate = on_validate,
+ on_cancel = on_cancel))
+
+ return widget
+
+def line_edit(
+ parent,
+ layout: QtWidgets.QGridLayout,
+ n: int,
+ label: str,
+ default_value: str) -> QtWidgets.QLineEdit:
+
+ label = QtWidgets.QLabel(label, parent)
+ layout.addWidget(label, n, 0)
+
+ edit = QtWidgets.QLineEdit(parent)
+ if default_value != None:
+ edit.setText(default_value)
+ layout.addWidget(edit, n, 1)
+
+ return edit
+
+def combo_box(
+ parent,
+ layout: QtWidgets.QGridLayout,
+ n: int,
+ label: str,
+ values: List[str],
+ default_value: int) -> QtWidgets.QComboBox:
+
+ label = QtWidgets.QLabel(label, parent)
+ layout.addWidget(label, n, 0)
+
+ box = QtWidgets.QComboBox(parent)
+ for value in values:
+ box.addItem(value)
+ if default_value != None:
+ box.setCurrentIndex(default_value)
+ layout.addWidget(box, n, 1)
+
+ return box
+
+def tags_selection(parent, init_tags: List[int]) -> QtWidgets.QWidget:
+ return todo.gui.tags.list.widget(parent, init_tags)
+
+def text_edit(
+ parent,
+ label: str,
+ default_value: str) -> Tuple[QtWidgets.QWidget, TextEditSignal]:
+
+ widget = QtWidgets.QWidget(parent)
+ layout = QtWidgets.QVBoxLayout(widget)
+
+ signal = TextEditSignal()
+
+ label = QtWidgets.QLabel(label, parent)
+ layout.addWidget(label)
+
+ edit = QtWidgets.QTextEdit(parent)
+ if default_value != None:
+ edit.insertPlainText(default_value)
+ layout.addWidget(edit)
+
+ edit.textChanged.connect(lambda: signal.emit(edit.toPlainText()))
+
+ return (widget, signal)
+
+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(todo.gui.icon.dialog_ok(validate.style()))
+ validate.clicked.connect(on_validate);
+ layout.addWidget(validate)
+
+ def on_task_form_signal(form: Optional[ValidTaskForm]):
+ if form:
+ validate.setEnabled(True)
+ else:
+ validate.setDisabled(True)
+
+ task_form_signal.connect(on_task_form_signal)
+
+ cancel = QtWidgets.QPushButton("cancel", widget)
+ cancel.setIcon(todo.gui.icon.dialog_cancel(cancel.style()))
+ cancel.clicked.connect(on_cancel)
+ layout.addWidget(cancel)
+
+ return widget
diff --git a/todo/gui/tasks/signal.py b/todo/gui/tasks/signal.py
new file mode 100644
index 0000000..52f65d1
--- /dev/null
+++ b/todo/gui/tasks/signal.py
@@ -0,0 +1,28 @@
+from PyQt5 import QtCore
+from typing import List
+
+from todo.model.task import Task
+
+class AddTask(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(Task, list, name = "addTask")
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self, task: Task, tags: List[int]):
+ self._signal.emit(task, tags)
+
+ def connect(self, f):
+ self._signal.connect(f)
+
+class UpdateTask(QtCore.QObject):
+ _signal = QtCore.pyqtSignal(int, Task, list, name = "updateTask")
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+ def emit(self, row: int, task: Task, tags: List[int]):
+ self._signal.emit(row, task, tags)
+
+ def connect(self, f):
+ self._signal.connect(f)
diff --git a/todo/gui/tasks/table/__init__.py b/todo/gui/tasks/table/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/gui/tasks/table/__init__.py
diff --git a/todo/gui/tasks/table/menu.py b/todo/gui/tasks/table/menu.py
new file mode 100644
index 0000000..bc039b2
--- /dev/null
+++ b/todo/gui/tasks/table/menu.py
@@ -0,0 +1,48 @@
+from PyQt5 import QtWidgets, QtCore
+from typing import List
+
+import todo.gui.tasks.dialog
+from todo.model.status import Status
+from todo.model.task import Task, ValidTaskForm
+from todo.model.tag import Tag
+
+def open(table: QtWidgets.QTableWidget, status: Status, 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(todo.gui.icon.dialog_open(menu.style()), "modify")
+ else:
+ modify_action = QtWidgets.QAction(menu)
+
+ delete_action = menu.addAction(todo.gui.icon.trash(menu.style()), "delete")
+
+ if status != Status.READY:
+ move_to_ready = menu.addAction(todo.gui.icon.task_ready(menu.style()), "move to ready")
+ else:
+ move_to_ready = QtWidgets.QAction(menu)
+
+ if status != Status.WAITING:
+ move_to_waiting = menu.addAction(todo.gui.icon.task_waiting(menu.style()), "move to waiting")
+ else:
+ move_to_waiting = QtWidgets.QAction(menu)
+
+ if status != Status.MAYBE:
+ move_to_maybe = menu.addAction(todo.gui.icon.task_maybe(menu.style()), "move to maybe")
+ else:
+ move_to_maybe = QtWidgets.QAction(menu)
+
+ action = menu.exec_(table.mapToGlobal(position + QtCore.QPoint(15, 20)))
+ if action == modify_action and len(rows) == 1:
+ row = list(rows)[0]
+ (task, tags) = table.get_at(row)
+ todo.gui.tasks.dialog.update(table, update_task_signal, row, task, tags).exec_()
+ elif action == delete_action:
+ todo.gui.tasks.dialog.confirm_delete(table, rows, lambda: table.delete_rows(rows))
+ elif action == move_to_ready:
+ todo.gui.tasks.dialog.confirm_move(table, rows, Status.READY, lambda: table.update_status(rows, Status.READY))
+ elif action == move_to_waiting:
+ todo.gui.tasks.dialog.confirm_move(table, rows, Status.WAITING, lambda: table.update_status(rows, Status.WAITING))
+ elif action == move_to_maybe:
+ todo.gui.tasks.dialog.confirm_move(table, rows, Status.MAYBE, lambda: table.update_status(rows, Status.MAYBE))
diff --git a/todo/gui/tasks/table/widget.py b/todo/gui/tasks/table/widget.py
new file mode 100644
index 0000000..e06c921
--- /dev/null
+++ b/todo/gui/tasks/table/widget.py
@@ -0,0 +1,283 @@
+from PyQt5 import QtWidgets, QtCore, QtGui
+from PyQt5.QtCore import Qt
+from typing import List, Tuple
+import time
+import math
+
+from todo.model import difficulty, priority
+from todo.model.difficulty import Difficulty
+from todo.model.priority import Priority
+from todo.model.tag import Tag
+from todo.model.task import Task
+from todo.model.task_tag import TaskTag
+from todo.model.status import Status
+import todo.database
+import todo.db.tags
+import todo.db.task_tags
+import todo.gui.color
+import todo.gui.signal
+import todo.gui.tasks.dialog
+import todo.gui.tasks.duration
+import todo.gui.tasks.signal
+import todo.gui.tasks.signal
+import todo.gui.tasks.table.menu
+import todo.service.tasks
+import todo.util.array
+
+class Widget(QtWidgets.QTableWidget):
+ def __init__(
+ self,
+ parent,
+ on_show: todo.gui.signal.Reload,
+ add_task_signal: todo.gui.tasks.signal.AddTask,
+ status: Status):
+ super().__init__(parent)
+
+ self.init_state(status)
+ self.sort()
+
+ self.setSelectionBehavior(QtWidgets.QTableView.SelectRows)
+ self.init_header()
+ self.setRowCount(len(self._tasks))
+ self.setColumnCount(len(self.header_labels()))
+ self.setColumnWidth(1, 500)
+
+ self.update_view()
+ self.horizontalHeader().setStretchLastSection(True)
+
+ # Menu
+ self.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.customContextMenuRequested.connect(lambda position: todo.gui.tasks.table.menu.open(self, status, self._update_task_signal, position))
+
+ self.doubleClicked.connect(lambda index: self.on_double_click(index.row()))
+ add_task_signal.connect(lambda task, tags: self.insert(task, tags))
+ self._update_task_signal.connect(lambda row, task, tags: self.update_task(row, task, tags))
+ on_show.connect(lambda: self.on_show())
+
+ def init_state(self, status: Status):
+ self._update_task_signal = todo.gui.tasks.signal.UpdateTask()
+ cursor = todo.database.cursor()
+ self._status = status
+ self._tasks = todo.service.tasks.get(cursor, self._status)
+ self._task_tags = todo.db.task_tags.get(cursor)
+ self._tags = todo.db.tags.get(cursor)
+ self._sort_column = 0
+ self._sort_is_ascending = True
+
+ def init_header(self):
+ self._header_view = QtWidgets.QHeaderView(Qt.Horizontal, self)
+ self._header_model = QtGui.QStandardItemModel()
+ self._header_model.setHorizontalHeaderLabels(self.header_labels())
+ self._header_view.setModel(self._header_model)
+ self._header_view.setSectionsClickable(True)
+ self._header_view.sectionClicked.connect(self.on_header_click)
+ self.setHorizontalHeader(self._header_view)
+
+ def on_show(self):
+ cursor = todo.database.cursor()
+ self._tasks = todo.service.tasks.get(cursor, self._status)
+ self._task_tags = todo.db.task_tags.get(cursor)
+ self._tags = todo.db.tags.get(cursor)
+ self.setRowCount(len(self._tasks))
+ self.sort()
+ self.update_view()
+
+ def on_header_click(self, column):
+ if self._sort_column == column:
+ self._sort_is_ascending = not self._sort_is_ascending
+ else:
+ self._sort_is_ascending = True
+ self._sort_column = column
+ self.sort()
+ self._header_model.setHorizontalHeaderLabels(self.header_labels())
+ self.update_view()
+
+ def sort(self):
+ is_rev = self.is_reversed()
+ self._tasks = sorted(
+ self._tasks,
+ key = lambda task: self.sort_key(task, is_rev),
+ reverse = is_rev)
+
+ def update_task(self, row, task: Task, tags: List[int]):
+ self._tasks[row] = task
+ filtred_task_tags = [tt for tt in self._task_tags if tt.task_id in [t.id for t in self._tasks if t.id != task.id]]
+ new_task_tags = [TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tags]
+ self._task_tags = filtred_task_tags + new_task_tags
+
+ # Update task in table
+ self.sort()
+ row_after_sort = [i for i in range(len(self._tasks)) if self._tasks[i].id == task.id][0]
+ if row_after_sort == row:
+ self.update_row(row)
+ else:
+ self.removeRow(row)
+ self.insertRow(row_after_sort)
+ self.update_row(row_after_sort)
+ self.selectRow(row_after_sort)
+
+ def update_view(self):
+ for row in range(len(self._tasks)):
+ self.update_row(row)
+
+ def update_row(self, row: int):
+ task = self._tasks[row]
+ self.setItem(row, 0, item(age_since(task.created_at)))
+ self.setItem(row, 1, item(task.name))
+ self.setCellWidget(row, 2, colored_label(self, todo.gui.tasks.duration.format(task.duration), todo.gui.tasks.duration.color(task.duration)))
+ self.setCellWidget(row, 3, colored_label(self, difficulty.format(task.difficulty), difficulty_color(task.difficulty)))
+ self.setCellWidget(row, 4, colored_label(self, priority.format(task.priority), priority_color(task.priority)))
+ tag_ids = [tt.tag_id for tt in self._task_tags if tt.task_id == task.id]
+ res_tags = sorted([tag for tag in self._tags if tag.id in tag_ids], key=lambda t: t.name)
+ self.setCellWidget(row, 5, render_tags(self, res_tags))
+ self.setRowHeight(row, 45)
+
+ def insert(self, task: Task, tags: List[int]) -> int:
+ is_rev = self.is_reversed()
+ row = todo.util.array.insert_position(
+ self.sort_key(task, is_rev),
+ [self.sort_key(t, is_rev) for t in self._tasks],
+ is_rev)
+ self._tasks.insert(row, task)
+ self._task_tags += [TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tags]
+ self.insertRow(row)
+ self.update_row(row)
+ self._task_tags += [TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tags]
+ self.setRowCount(len(self._tasks))
+ return row
+
+ def is_reversed(self) -> bool:
+ if self._sort_column == 0:
+ return self._sort_is_ascending
+ else:
+ return not self._sort_is_ascending
+
+ def sort_key(self, task: Task, is_reversed: bool):
+ row = self._sort_column
+ if row == 0:
+ return task.created_at
+ elif row == 1:
+ return task.name.lower()
+ elif row == 2:
+ if is_reversed:
+ return task.duration
+ else:
+ return (task.duration == 0, task.duration)
+ elif row == 3:
+ return task.difficulty
+ elif row == 4:
+ return task.priority
+ elif row == 5:
+ tag_ids = [tt.tag_id for tt in self._task_tags if tt.task_id == task.id]
+ tags = sorted([tag.name.lower() for tag in self._tags if tag.id in tag_ids])
+ key = "".join(tags)
+ if is_reversed:
+ return key
+ else:
+ return (key == "", key)
+
+ def keyPressEvent(self, event):
+ super().keyPressEvent(event)
+ if event.key() in (Qt.Key_Return, Qt.Key_Enter):
+ rows = self.get_selected_rows()
+ if len(rows) == 1:
+ row = rows[0]
+ (task, tags) = self.get_at(row)
+ todo.gui.tasks.dialog.update(self, self._update_task_signal, row, task, tags).exec_()
+ elif event.key() == Qt.Key_Delete:
+ rows = self.get_selected_rows()
+ todo.gui.tasks.dialog.show_delete(self, rows, lambda: self.delete_rows(rows))
+
+ def delete_rows(self, rows: List[int]):
+ task_ids = [task.id for i, task in enumerate(self._tasks) if i in rows]
+ todo.service.tasks.delete(todo.database.cursor(), task_ids)
+ self.remove_rows_from_table(rows, task_ids)
+
+ def update_status(self, rows: List[int], status: Status):
+ task_ids = [task.id for i, task in enumerate(self._tasks) if i in rows]
+ todo.service.tasks.update_status(todo.database.cursor(), task_ids, status)
+ self.remove_rows_from_table(rows, task_ids)
+
+ def remove_rows_from_table(self, rows: List[int], task_ids: List[int]):
+ for row in sorted(rows, reverse=True):
+ self.removeRow(row)
+ self._tasks = [t for t in self._tasks if t.id not in task_ids]
+ self._task_tags = [tt for tt in self._task_tags if tt.task_id in [t.id for t in self._tasks]]
+ self.setRowCount(len(self._tasks))
+
+ def get_selected_rows(self):
+ return list(set([index.row() for index in self.selectedIndexes()]))
+
+ def on_double_click(self, row: int):
+ (task, tags) = self.get_at(row)
+ todo.gui.tasks.dialog.update(self, self._update_task_signal, row, task, tags).exec_()
+
+ def get_at(self, row: int) -> Tuple[Task, List[int]]:
+ task = self._tasks[row]
+ tags = [tt.tag_id for tt in self._task_tags if tt.task_id == task.id]
+ return (task, tags)
+
+ def header_labels(self):
+ labels = ["Age", "Name", "Duration", "Difficulty", "Priority", "Tag"]
+ if self._sort_is_ascending:
+ sign = "▼"
+ else:
+ sign = "▲"
+ labels[self._sort_column] = labels[self._sort_column] + " " + sign
+ return labels
+
+def item(text: str):
+ item = QtWidgets.QTableWidgetItem(text)
+ item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
+ return item
+
+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 colored_label(parent, text: str, color: QtGui.QColor):
+ label = QtWidgets.QLabel(text)
+ palette = QtGui.QPalette()
+ palette.setColor(QtGui.QPalette.Text, color)
+ label.setPalette(palette)
+ return label
+
+def render_tags(parent, tags: List[Tag]):
+ widget = QtWidgets.QWidget(parent)
+
+ layout = QtWidgets.QHBoxLayout(widget)
+ widget.setLayout(layout)
+
+ for tag in tags:
+ label = QtWidgets.QLabel(tag.name)
+ label.setContentsMargins(3, 3, 3, 3)
+ palette = QtGui.QPalette()
+ palette.setColor(QtGui.QPalette.Base, QtGui.QColor(tag.color))
+ label.setAutoFillBackground(True)
+ label.setPalette(palette)
+ layout.addWidget(label)
+
+ return widget
+
+def difficulty_color(d: Difficulty) -> QtGui.QColor:
+ if d == Difficulty.EASY:
+ return todo.gui.color.easy_difficulty
+ elif d == Difficulty.NORMAL:
+ return todo.gui.color.normal_difficulty
+ elif d == Difficulty.HARD:
+ return todo.gui.color.hard_difficulty
+
+def priority_color(p: Priority) -> QtGui.QColor:
+ if p == Priority.LOW:
+ return todo.gui.color.low_priority
+ elif p == Priority.MIDDLE:
+ return todo.gui.color.middle_priority
+ elif p == Priority.HIGH:
+ return todo.gui.color.high_priority
diff --git a/todo/gui/tasks/test_duration.py b/todo/gui/tasks/test_duration.py
new file mode 100644
index 0000000..1435e2d
--- /dev/null
+++ b/todo/gui/tasks/test_duration.py
@@ -0,0 +1,21 @@
+from todo.gui.tasks.duration import format, parse
+
+def test_format():
+ assert format(0) == ""
+ assert format(0.5) == "0.5m"
+ assert format(35) == "35m"
+ assert format(60) == "1h"
+ assert format(61) == "1h"
+ assert format(90) == "1.5h"
+ assert format(1440) == "1d"
+
+def test_parse():
+ assert parse("") == 0
+ assert parse("42") == None
+ assert parse("hey") == None
+ assert parse("1h30") == None
+ assert parse("1h30m") == None
+ assert parse("17m") == 17
+ assert parse("90m") == 90
+ assert parse("1.5h") == 90
+ assert parse("2d") == 2880
diff --git a/todo/gui/tasks/widget.py b/todo/gui/tasks/widget.py
new file mode 100644
index 0000000..cc7fe96
--- /dev/null
+++ b/todo/gui/tasks/widget.py
@@ -0,0 +1,30 @@
+from PyQt5 import QtWidgets
+
+from todo.model.status import Status
+import todo.gui.tasks.signal
+import todo.gui.tasks.table.widget
+import todo.gui.icon
+import todo.gui.signal
+
+def widget(parent, on_show: todo.gui.signal.Reload, status: Status):
+ widget = QtWidgets.QWidget(parent)
+
+ layout = QtWidgets.QVBoxLayout(widget)
+ widget.setLayout(layout)
+
+ layout.addSpacing(15)
+
+ add_task_signal = todo.gui.tasks.signal.AddTask()
+
+ add_task_button = QtWidgets.QPushButton(" Add a task", widget)
+ add_task_button.setFixedHeight(30)
+ add_task_button.setIcon(todo.gui.icon.new_folder(widget.style()))
+ add_task_button.clicked.connect(lambda: todo.gui.tasks.dialog.add(widget, status, add_task_signal).exec_())
+ layout.addWidget(add_task_button)
+
+ layout.addSpacing(20)
+
+ table = todo.gui.tasks.table.widget.Widget(widget, on_show, add_task_signal, status)
+ layout.addWidget(table)
+
+ return widget
diff --git a/todo/gui/window.py b/todo/gui/window.py
new file mode 100644
index 0000000..0391ee9
--- /dev/null
+++ b/todo/gui/window.py
@@ -0,0 +1,36 @@
+from PyQt5 import QtCore, QtWidgets
+
+import todo.gui.tasks.widget
+import todo.gui.tasks.widget
+import todo.gui.tags.panel.widget
+import todo.gui.signal
+from todo.model.status import Status
+
+def get():
+ window = QtWidgets.QMainWindow()
+ window.setWindowTitle("todo")
+ window.setMinimumSize(QtCore.QSize(640, 480))
+
+ tabs = QtWidgets.QTabWidget(window)
+ window.setCentralWidget(tabs)
+
+ show_ready = todo.gui.signal.Reload()
+ show_waiting = todo.gui.signal.Reload()
+ show_maybe = todo.gui.signal.Reload()
+
+ def on_current_tab_changed(index: int):
+ if index == 0:
+ show_ready.emit()
+ elif index == 1:
+ show_waiting.emit()
+ elif index == 2:
+ show_maybe.emit()
+
+ tabs.currentChanged.connect(on_current_tab_changed)
+
+ tabs.addTab(todo.gui.tasks.widget.widget(tabs, show_ready, Status.READY), "Ready")
+ tabs.addTab(todo.gui.tasks.widget.widget(tabs, show_waiting, Status.WAITING), "Waiting")
+ tabs.addTab(todo.gui.tasks.widget.widget(tabs, show_maybe, Status.MAYBE), "Maybe")
+ tabs.addTab(todo.gui.tags.panel.widget.widget(tabs), "Tags")
+
+ return window
diff --git a/todo/model/__init__.py b/todo/model/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/model/__init__.py
diff --git a/todo/model/difficulty.py b/todo/model/difficulty.py
new file mode 100644
index 0000000..526cdb9
--- /dev/null
+++ b/todo/model/difficulty.py
@@ -0,0 +1,30 @@
+from enum import IntEnum
+from typing import Optional
+
+class Difficulty(IntEnum):
+ EASY = 0
+ NORMAL = 1
+ HARD = 2
+
+values = [
+ Difficulty.EASY,
+ Difficulty.NORMAL,
+ Difficulty.HARD]
+
+def format(difficulty: Difficulty) -> str:
+ if difficulty == Difficulty.EASY:
+ return "Easy"
+ elif difficulty == Difficulty.NORMAL:
+ return "Normal"
+ elif difficulty == Difficulty.HARD:
+ return "Hard"
+
+def parse(string: str) -> Optional[Difficulty]:
+ if string == "Easy":
+ return Difficulty.EASY
+ elif string == "Normal":
+ return Difficulty.NORMAL
+ elif string == "Hard":
+ return Difficulty.HARD
+ else:
+ return None
diff --git a/todo/model/priority.py b/todo/model/priority.py
new file mode 100644
index 0000000..5948104
--- /dev/null
+++ b/todo/model/priority.py
@@ -0,0 +1,30 @@
+from enum import IntEnum
+from typing import Optional
+
+class Priority(IntEnum):
+ LOW = 0
+ MIDDLE = 1
+ HIGH = 2
+
+values = [
+ Priority.LOW,
+ Priority.MIDDLE,
+ Priority.HIGH]
+
+def format(priority: Priority) -> str:
+ if priority == Priority.LOW:
+ return "Low"
+ elif priority == Priority.MIDDLE:
+ return "Middle"
+ elif priority == Priority.HIGH:
+ return "High"
+
+def parse(string: str) -> Optional[Priority]:
+ if string == "Low":
+ return Priority.LOW
+ elif string == "Middle":
+ return Priority.MIDDLE
+ elif string == "High":
+ return Priority.HIGH
+ else:
+ return None
diff --git a/todo/model/status.py b/todo/model/status.py
new file mode 100644
index 0000000..6881e0a
--- /dev/null
+++ b/todo/model/status.py
@@ -0,0 +1,30 @@
+from enum import IntEnum
+from typing import Optional
+
+class Status(IntEnum):
+ READY = 0
+ WAITING = 1
+ MAYBE = 2
+
+values = [
+ Status.READY,
+ Status.WAITING,
+ Status.MAYBE]
+
+def format(status: Status) -> str:
+ if status == Status.READY:
+ return "Ready"
+ elif status == Status.WAITING:
+ return "Waiting"
+ elif status == Status.MAYBE:
+ return "Maybe"
+
+def parse(string: str) -> Optional[Status]:
+ if string == "Ready":
+ return Status.READY
+ elif string == "Waiting":
+ return Status.WAITING
+ elif string == "Maybe":
+ return Status.MAYBE
+ else:
+ return None
diff --git a/todo/model/tag.py b/todo/model/tag.py
new file mode 100644
index 0000000..030b223
--- /dev/null
+++ b/todo/model/tag.py
@@ -0,0 +1,12 @@
+from typing import NamedTuple
+
+class Tag(NamedTuple):
+ id: int
+ created_at: int
+ updated_at: int
+ name: str
+ color: str
+
+class ValidTagForm(NamedTuple):
+ name: str
+ color: str
diff --git a/todo/model/task.py b/todo/model/task.py
new file mode 100644
index 0000000..f20cbc9
--- /dev/null
+++ b/todo/model/task.py
@@ -0,0 +1,22 @@
+from typing import NamedTuple, List
+
+from todo.model.difficulty import Difficulty
+from todo.model.priority import Priority
+
+class Task(NamedTuple):
+ id: int
+ created_at: int
+ updated_at: int
+ name: str
+ duration: int
+ difficulty: Difficulty
+ priority: Priority
+ description: str
+
+class ValidTaskForm(NamedTuple):
+ name: str
+ duration: int
+ difficulty: Difficulty
+ priority: Priority
+ tags: List[int]
+ description: str
diff --git a/todo/model/task_tag.py b/todo/model/task_tag.py
new file mode 100644
index 0000000..0a33c66
--- /dev/null
+++ b/todo/model/task_tag.py
@@ -0,0 +1,5 @@
+from typing import NamedTuple
+
+class TaskTag(NamedTuple):
+ task_id: int
+ tag_id: int
diff --git a/todo/service/__init__.py b/todo/service/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/service/__init__.py
diff --git a/todo/service/tasks.py b/todo/service/tasks.py
new file mode 100644
index 0000000..2cebf00
--- /dev/null
+++ b/todo/service/tasks.py
@@ -0,0 +1,32 @@
+from typing import List
+
+from todo.model.task import Task, ValidTaskForm
+from todo.model.status import Status
+import todo.db.tasks
+import todo.db.task_tags
+import todo.database
+
+def get(cursor, status: Status) -> List[Task]:
+ return todo.db.tasks.get(cursor, status)
+
+def create(cursor, status: Status, task_form: ValidTaskForm) -> Task:
+ task = todo.db.tasks.insert(cursor, status, task_form)
+ todo.db.task_tags.insert_many(cursor, task.id, task_form.tags)
+ todo.database.commit()
+ return task
+
+def update(cursor, task: Task, task_form: ValidTaskForm) -> Task:
+ todo.db.task_tags.delete(cursor, [task.id])
+ updated_task = todo.db.tasks.update(cursor, task, task_form)
+ todo.db.task_tags.insert_many(cursor, task.id, task_form.tags)
+ todo.database.commit()
+ return updated_task
+
+def delete(cursor, task_ids: List[int]):
+ todo.db.task_tags.delete(cursor, task_ids)
+ todo.db.tasks.delete(cursor, task_ids)
+ todo.database.commit()
+
+def update_status(cursor, task_ids: List[int], status: Status) -> List[Task]:
+ todo.db.tasks.update_status(cursor, task_ids, status)
+ todo.database.commit()
diff --git a/todo/util/__init__.py b/todo/util/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/todo/util/__init__.py
diff --git a/todo/util/array.py b/todo/util/array.py
new file mode 100644
index 0000000..bb4eee3
--- /dev/null
+++ b/todo/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/todo/util/range.py b/todo/util/range.py
new file mode 100644
index 0000000..bd4b27e
--- /dev/null
+++ b/todo/util/range.py
@@ -0,0 +1,30 @@
+from typing import NamedTuple, List
+
+class Range(NamedTuple):
+ start: int
+ length: int
+
+def from_indexes(indexes: List[int]) -> List[Range]:
+ 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
diff --git a/todo/util/test_array.py b/todo/util/test_array.py
new file mode 100644
index 0000000..0186403
--- /dev/null
+++ b/todo/util/test_array.py
@@ -0,0 +1,12 @@
+from todo.util.array import insert_position
+
+def test_insert_position():
+ assert insert_position(0, [], False) == 0
+ assert insert_position(1, [1, 2, 3], False) == 0
+ assert insert_position(2, [1, 2, 3], False) == 1
+ assert insert_position(3, [1, 2, 3], False) == 2
+ assert insert_position(8, [1, 2, 3], False) == 3
+ assert insert_position(8, [3, 2, 1], True) == 0
+ assert insert_position(3, [3, 2, 1], True) == 0
+ assert insert_position(2, [3, 2, 1], True) == 1
+ assert insert_position(1, [3, 2, 1], True) == 2
diff --git a/todo/util/test_range.py b/todo/util/test_range.py
new file mode 100644
index 0000000..8a96636
--- /dev/null
+++ b/todo/util/test_range.py
@@ -0,0 +1,6 @@
+from todo.util.range import from_indexes, Range
+
+def test_from_indexes():
+ assert from_indexes([]) == []
+ assert from_indexes([1]) == [Range(1, 1)]
+ assert from_indexes([9, 6, 0, 10]) == [Range(0, 1), Range(6, 1), Range(9, 2)]