From 604b485fab100785c4def10dc7242e6408e1f1c6 Mon Sep 17 00:00:00 2001 From: Louie S Date: Sat, 16 Sep 2023 09:36:14 -0400 Subject: Create project using setuptools --- src/__init__.py | 0 src/add_entry_form.py | 95 +++++++++++++ src/add_group_form.py | 76 ++++++++++ src/config.py | 74 ++++++++++ src/db_sqlite.py | 346 ++++++++++++++++++++++++++++++++++++++++++++++ src/edit_entry_form.py | 120 ++++++++++++++++ src/edit_group_form.py | 89 ++++++++++++ src/entry.py | 84 +++++++++++ src/globals.py | 3 + src/group.py | 25 ++++ src/main.py | 295 +++++++++++++++++++++++++++++++++++++++ src/preferences_dialog.py | 93 +++++++++++++ 12 files changed, 1300 insertions(+) create mode 100644 src/__init__.py create mode 100644 src/add_entry_form.py create mode 100644 src/add_group_form.py create mode 100644 src/config.py create mode 100644 src/db_sqlite.py create mode 100644 src/edit_entry_form.py create mode 100644 src/edit_group_form.py create mode 100644 src/entry.py create mode 100644 src/globals.py create mode 100644 src/group.py create mode 100644 src/main.py create mode 100644 src/preferences_dialog.py (limited to 'src') diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/add_entry_form.py b/src/add_entry_form.py new file mode 100644 index 0000000..9550108 --- /dev/null +++ b/src/add_entry_form.py @@ -0,0 +1,95 @@ +import sys +from PyQt5.QtWidgets import QApplication, QCheckBox, QDateTimeEdit, QDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton +from PyQt5.QtGui import QFont +from PyQt5.QtCore import QDate, Qt +from src.entry import Entry +import src.globals as Globals +import src.db_sqlite as DB + +class addEntryForm(QDialog): + def __init__(self, parent): + super().__init__() + self.initializeUI(parent) + + def initializeUI(self, parent): + self.resize(400, 1) + self.setWindowTitle("Add Entry") + self.displayWidgets(parent) + self.exec() + + def displayWidgets(self, parent): + entry_form_layout = QFormLayout() + + title = QLabel("Add Entry") + title.setFont(QFont("Arial", 18)) + title.setAlignment(Qt.AlignCenter) + entry_form_layout.addRow(title) + + self.new_entry_desc = QLineEdit() + entry_form_layout.addRow("Description:", self.new_entry_desc) + + self.due_hbox = QHBoxLayout() + self.new_entry_due = QDateTimeEdit(QDate.currentDate()) + self.new_entry_due.setDisplayFormat("MM/dd/yyyy") + self.due_hbox.addWidget(self.new_entry_due) + self.new_entry_due_checkbox = QCheckBox() + self.new_entry_due_checkbox.setChecked(True) + self.due_hbox.addWidget(self.new_entry_due_checkbox) + entry_form_layout.addRow("Due Date:", self.due_hbox) + + self.new_entry_due_alt = QLineEdit() + entry_form_layout.addRow("Due Date (Alt):", self.new_entry_due_alt) + + self.new_entry_link = QLineEdit() # TODO see if there is a widget specifically for URLs + entry_form_layout.addRow("Link:", self.new_entry_link) + + # TODO: + # depends + + self.new_entry_color = QLineEdit() + entry_form_layout.addRow("Color:", self.new_entry_color) + + self.new_entry_highlight = QLineEdit() + entry_form_layout.addRow("Highlight:", self.new_entry_highlight) + + # Submit and cancel buttons + buttons_h_box = QHBoxLayout() + buttons_h_box.addStretch() + close_button = QPushButton("Cancel") + close_button.clicked.connect(self.close) + buttons_h_box.addWidget(close_button) + submit_button = QPushButton("Submit") + submit_button.clicked.connect(lambda: self.handleSubmit(parent)) + buttons_h_box.addWidget(submit_button) + buttons_h_box.addStretch() + + entry_form_layout.addRow(buttons_h_box) + + self.setLayout(entry_form_layout) + + def handleSubmit(self, parent): + # Check that the new entry is not blank + desc_text = self.new_entry_desc.text() + due_text = "" + if self.new_entry_due_checkbox.isChecked(): + due_text = self.new_entry_due.date() # due_text is a QDate + due_alt_text = self.new_entry_due_alt.text() + link_text = self.new_entry_link.text() + color_text = self.new_entry_color.text() + highlight_text = self.new_entry_highlight.text() + + if not desc_text: + QMessageBox.warning(self, "Error Message", + "Description cannot be blank", + QMessageBox.Close, + QMessageBox.Close) + return + + new_id = DB.insertEntry(Entry(0, parent, desc_text, due_text, due_alt_text, link_text, color_text, highlight_text)) + Globals.entries.append(Entry(new_id, parent, desc_text, due_text, due_alt_text, link_text, color_text, highlight_text)) + self.close() + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = addEntryForm() + sys.exit(app.exec_()) diff --git a/src/add_group_form.py b/src/add_group_form.py new file mode 100644 index 0000000..b7144d0 --- /dev/null +++ b/src/add_group_form.py @@ -0,0 +1,76 @@ +import sys +from PyQt5.QtWidgets import QApplication, QComboBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton +from PyQt5.QtGui import QFont +from PyQt5.QtCore import Qt + +import src.globals as Globals +from src.group import Group +import src.db_sqlite as DB + +class addGroupForm(QDialog): + """ + Implemented so that it can be used for adding and editing groups + """ + def __init__(self): + super().__init__() + self.initializeUI() + + def initializeUI(self): + self.resize(400, 1) + self.setWindowTitle("Add Group") + self.displayWidgets() + self.exec() + + def displayWidgets(self): + group_form_layout = QFormLayout() + + title = QLabel("Add Group") + title.setFont(QFont("Arial", 18)) + title.setAlignment(Qt.AlignCenter) + group_form_layout.addRow(title) + + self.new_group_name = QLineEdit() + group_form_layout.addRow("Name:", self.new_group_name) + + self.new_group_column = QComboBox() + self.new_group_column.addItems(["Left", "Right"]) + group_form_layout.addRow("Column:", self.new_group_column) + + self.new_group_link = QLineEdit() # TODO see if there is a widget specifically for URLs + group_form_layout.addRow("Link:", self.new_group_link) + + # Submit and cancel buttons + buttons_h_box = QHBoxLayout() + buttons_h_box.addStretch() + close_button = QPushButton("Cancel") + close_button.clicked.connect(self.close) + buttons_h_box.addWidget(close_button) + submit_button = QPushButton("Submit") + submit_button.clicked.connect(self.handleSubmit) + buttons_h_box.addWidget(submit_button) + buttons_h_box.addStretch() + + group_form_layout.addRow(buttons_h_box) + + self.setLayout(group_form_layout) + + def handleSubmit(self): + name_text = self.new_group_name.text() + column_text = self.new_group_column.currentText() + link_text = self.new_group_link.text() + + if not name_text: + QMessageBox.warning(self, "Error Message", + "Name cannot be blank", + QMessageBox.Close, + QMessageBox.Close) + return + + new_id = DB.insertGroup(Group(0, name_text, column_text, link_text)) + Globals.groups.append(Group(new_id, name_text, column_text, link_text)) + self.close() + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = addGroupForm() + sys.exit(app.exec_()) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..2c80716 --- /dev/null +++ b/src/config.py @@ -0,0 +1,74 @@ +""" +Handle reading of the config file, which on POSIX-compliant systems will be +created in ~/.config/assignment-list-pyqt5/config.py +""" + +import configparser +import os +import sys + +import src.globals as Globals + +class Config(): + def __init__(self): + self.config_path = os.path.join( + os.path.expanduser("~"), + ".config", + "assignment-list-pyqt5", + "config") + + if not os.path.exists(self.config_path): + self.createConfig() + + self.loadConfig() + + def loadConfig(self): + self.config = configparser.ConfigParser() + + try: + self.config.read(self.config_path) + except: + print("Could not parse config file '{}'".format(self.config_path)) + + if "paths" in self.config: + if self.config["paths"]["db_path"]: + Globals.db_path = self.config["paths"]["db_path"] + + def createConfig(self): + self.config = configparser.ConfigParser() + self.config["paths"] = { + "db_path": os.path.join( + os.path.expanduser("~"), + ".local", + "share", + "assignment-list-pyqt5", + "data.db" + ) + } + + self.updateConfig() + + def updateConfig(self): + """ + Update the configuration file with values from self.config + """ + # Attempt to create directory if necessary + if not os.path.exists(os.path.dirname(self.config_path)): + try: + os.mkdir(os.path.dirname(self.config_path)) + except: + print("Error: Could not create config directory '{}'".format(os.path.dirname(self.config_path))) + sys.exit(1) + + # Attempt to write to file + try: + with open(self.config_path, 'w') as configfile: + self.config.write(configfile) + except: + print("Error: Could not open config file '{}'".format(self.config_path)) + sys.exit(1) + + print("Successfully created config at {}".format(self.config_path)) + +if __name__ == "__main__": + Config() diff --git a/src/db_sqlite.py b/src/db_sqlite.py new file mode 100644 index 0000000..88a64c2 --- /dev/null +++ b/src/db_sqlite.py @@ -0,0 +1,346 @@ +import os +import sys +from time import strptime +from PyQt5.QtCore import QDate +from PyQt5.QtSql import QSqlDatabase, QSqlQuery +import src.globals as Globals +from src.group import Group +from src.entry import Entry + +def initDB(): + """ + Check for existing database. If it doesn't exist, build it + """ + if not os.path.exists(Globals.db_path) or not os.stat(Globals.db_path).st_size: + createTables() + + loadFromTables() + +def createTables(): + """ + Create database at a specified Globals.db_path + """ + print(Globals.db_path) + database = QSqlDatabase.addDatabase("QSQLITE") # SQlite version 3 + database.setDatabaseName(Globals.db_path) + + # Create database parent directory if necessary + if not os.path.exists(os.path.dirname(Globals.db_path)): + try: + os.mkdir(os.path.dirname(Globals.db_path)) + except: + print("Unable to open data source file.") + sys.exit(1) + + if not database.open(): + print("Unable to open data source file.") + sys.exit(1) # Error out. TODO consider throwing a dialog instead + + query = QSqlQuery() + # Erase database contents so that we don't have duplicates + query.exec_("DROP TABLE groups") + query.exec_("DROP TABLE entries") + + query.exec_(""" + CREATE TABLE groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + column TINYINT(1) DEFAULT FALSE, + link VARCHAR(255) NOT NULL, + hidden TINYINT(1) DEFAULT FALSE + ) + """) + + query.exec_(""" + CREATE TABLE entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT UNIQUE NOT NULL, + parent_id REFERENCES groups (id), + description VARCHAR(255) NOT NULL, + due_date TEXT DEFAULT NULL, + alt_due_date VARCHAR(255) DEFAULT NULL, + link VARCHAR(255) DEFAULT NULL, + color VARCHAR(255) DEFAULT NULL, + highlight VARCHAR(255) DEFAULT NULL, + done TINYINT(1) DEFAULT FALSE, + hidden TINYINT(1) DEFAULT FALSE + ) + """) + + database.close() + +def loadFromTables(): + """ + Load groups and entries into global variables + """ + database = QSqlDatabase.addDatabase("QSQLITE") # SQlite version 3 + database.setDatabaseName(Globals.db_path) + + if not database.open(): + print("Unable to open data source file.") + sys.exit(1) # Error out. TODO consider throwing a dialog instead + + query = QSqlQuery() + + # Load groups + query.exec_("SELECT * FROM groups") + while query.next(): + record = query.record() + Globals.groups.append( + Group( + record.field("id").value(), + record.field("name").value(), + record.field("column").value(), + record.field("link").value(), + record.field("hidden").value())) + + # Load entries + query.exec_("SELECT * FROM entries") + while query.next(): + record = query.record() + # create a QDate if the due date is set + if record.field("due_date").value(): + due_date_struct = strptime(record.field("due_date").value(), "%Y-%m-%d") + due_date = QDate(due_date_struct.tm_year, due_date_struct.tm_mon, due_date_struct.tm_mday) + else: + due_date = "" + Globals.entries.append( + Entry( + record.field("id").value(), + record.field("parent_id").value(), + record.field("description").value(), + due_date, + record.field("alt_due_date").value(), + record.field("link").value(), + record.field("color").value(), + record.field("highlight").value(), + record.field("done").value(), + record.field("hidden").value())) + + database.close() + +def insertGroup(new_group): + """ + Insert group to the database at Globals.db_path + """ + output = -1 + + database = QSqlDatabase.addDatabase("QSQLITE") # SQlite version 3 + database.setDatabaseName(Globals.db_path) + + if not database.open(): + print("Unable to open data source file.") + sys.exit(1) # Error out. TODO consider throwing a dialog instead + + query = QSqlQuery() + + query.prepare(""" + INSERT INTO groups (name, column, link) VALUES (?, ?, ?) + """) + query.addBindValue(new_group.name) + query.addBindValue(new_group.column) + query.addBindValue(new_group.link) + query.exec_() + + output = query.lastInsertId() + + database.close() + + return output + +def insertEntry(new_entry): + """ + Insert entry to the database at Globals.db_path + """ + output = -1 + + database = QSqlDatabase.addDatabase("QSQLITE") # SQlite version 3 + database.setDatabaseName(Globals.db_path) + + if not database.open(): + print("Unable to open data source file.") + sys.exit(1) # Error out. TODO consider throwing a dialog instead + + query = QSqlQuery() + + query.prepare(""" + INSERT INTO entries (parent_id, description, due_date, alt_due_date, link, color, highlight) VALUES (:p_id, :desc, :due, :alt_due, :link, :color, :highlight) + """) + query.bindValue(":p_id", new_entry.parent_id) + query.bindValue(":desc", new_entry.desc) + if new_entry.due: + query.bindValue(":due", "{0}-{1}-{2}".format( + new_entry.due.year(), + new_entry.due.month(), + new_entry.due.day())) + else: + query.bindValue(":due", "") + query.bindValue(":alt_due", new_entry.due_alt) + query.bindValue(":link", new_entry.link) + query.bindValue(":color", new_entry.color) + query.bindValue(":highlight", new_entry.highlight) + success = query.exec_() + # DEBUG + #print(query.lastError().text()) + #print(query.boundValues()) + #if success: + # print("Query succeeded") + #else: + # print("Query failed") + + output = query.lastInsertId() + + database.close() + + return output + +def updateGroup(group): + """ + Update group by its id + """ + database = QSqlDatabase.addDatabase("QSQLITE") # SQlite version 3 + database.setDatabaseName(Globals.db_path) + + if not database.open(): + print("Unable to open data source file.") + sys.exit(1) # Error out. TODO consider throwing a dialog instead + + query = QSqlQuery() + + query.prepare(""" + UPDATE groups SET name = ?, column = ?, link = ?, hidden = ? WHERE id = ? + """) + query.addBindValue(group.name) + query.addBindValue(group.column) + query.addBindValue(group.link) + query.addBindValue(group.hidden) + query.addBindValue(group.id) + query.exec_() + + database.close() + +def updateEntry(entry): + """ + Update entry by its id + """ + database = QSqlDatabase.addDatabase("QSQLITE") # SQlite version 3 + database.setDatabaseName(Globals.db_path) + + if not database.open(): + print("Unable to open data source file.") + sys.exit(1) # Error out. TODO consider throwing a dialog instead + + query = QSqlQuery() + + query.prepare(""" + UPDATE entries SET + description = :desc, + due_date = :due, + alt_due_date = :alt_due, + link = :link, + color = :color, + highlight = :highlight, + done = :done, + hidden = :hidden + WHERE id = :id + """) + query.bindValue(":desc", entry.desc) + if entry.due: + query.bindValue(":due", "{0}-{1}-{2}".format( + entry.due.year(), + entry.due.month(), + entry.due.day())) + else: + query.bindValue(":due", "") + query.bindValue(":alt_due", entry.due_alt) + query.bindValue(":link", entry.link) + query.bindValue(":color", entry.color) + query.bindValue(":highlight", entry.highlight) + query.bindValue(":done", entry.done) + query.bindValue(":hidden", entry.hidden) + query.bindValue(":id", entry.id) + query.exec_() + + database.close() + +def removeGroup(group_id): + """ + Remove a group by id from the database + (actually set hidden to true, don't permanently delete it) + """ + + database = QSqlDatabase.addDatabase("QSQLITE") # SQlite version 3 + database.setDatabaseName(Globals.db_path) + + if not database.open(): + print("Unable to open data source file.") + sys.exit(1) # Error out. TODO consider throwing a dialog instead + + query = QSqlQuery() + + # First, set entries to hidden + query.prepare(""" + UPDATE entries SET hidden = 1 WHERE parent_id = ? + """) + query.addBindValue(group_id) + query.exec_() + + # Now, set the group to hidden + query.prepare(""" + UPDATE groups SET hidden = 1 WHERE id = ? + """) + query.addBindValue(group_id) + query.exec_() + + output = query.numRowsAffected() + database.close() + return output + +def removeEntry(entry_id): + """ + Remove a group by id from the database + (actually set hidden to true, don't permanently delete it) + """ + database = QSqlDatabase.addDatabase("QSQLITE") # SQlite version 3 + database.setDatabaseName(Globals.db_path) + + if not database.open(): + print("Unable to open data source file.") + sys.exit(1) # Error out. TODO consider throwing a dialog instead + + query = QSqlQuery() + + # Set entry to hidden + query.prepare(""" + UPDATE entries SET hidden = 1 WHERE id = ? + """) + query.addBindValue(entry_id) + query.exec_() + + output = query.numRowsAffected() + database.close() + return output + +def cleanHidden(): + """ + Permanently delete removed/hidden groups and entries + """ + database = QSqlDatabase.addDatabase("QSQLITE") # SQlite version 3 + database.setDatabaseName(Globals.db_path) + + if not database.open(): + print("Unable to open data source file.") + sys.exit(1) # Error out. TODO consider throwing a dialog instead + + query = QSqlQuery() + + # Remove hidden entries + query.exec_(""" + DELETE FROM entries WHERE hidden = 1 + """) + + # Remove hidden groups + query.exec_(""" + DELETE FROM groups WHERE hidden = 1 + """) + + database.close() diff --git a/src/edit_entry_form.py b/src/edit_entry_form.py new file mode 100644 index 0000000..55c388c --- /dev/null +++ b/src/edit_entry_form.py @@ -0,0 +1,120 @@ +import sys +from PyQt5.QtWidgets import QApplication, QCheckBox, QDateTimeEdit, QDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton +from PyQt5.QtGui import QFont +from PyQt5.QtCore import QDate, Qt + +import src.globals as Globals +from src.entry import Entry +import src.db_sqlite as DB + +class editEntryForm(QDialog): + """ + Form to edit/update an entry + """ + def __init__(self, id): + self.id = id + super().__init__() + self.initializeUI() + + def initializeUI(self): + self.resize(400, 1) + self.setWindowTitle("Edit Entry") + self.displayWidgets() + self.exec() + + def displayWidgets(self): + entry_form_layout = QFormLayout() + entry = list(filter(lambda e: e.id == self.id, Globals.entries))[0] + + title = QLabel("Edit Entry") + title.setFont(QFont("Arial", 18)) + title.setAlignment(Qt.AlignCenter) + entry_form_layout.addRow(title) + + self.entry_desc = QLineEdit() + self.entry_desc.setText(entry.desc) + entry_form_layout.addRow("Description:", self.entry_desc) + + self.due_hbox = QHBoxLayout() + self.entry_due = QDateTimeEdit(QDate.currentDate()) + self.entry_due.setDisplayFormat("MM/dd/yyyy") + if entry.due: + self.entry_due.setDate(entry.due) + self.due_hbox.addWidget(self.entry_due) + self.entry_due_checkbox = QCheckBox() + if entry.due: + self.entry_due_checkbox.setChecked(True) + else: + self.entry_due_checkbox.setChecked(False) + self.due_hbox.addWidget(self.entry_due_checkbox) + entry_form_layout.addRow("Due Date:", self.due_hbox) + + self.entry_due_alt = QLineEdit() + self.entry_due_alt.setText(entry.due_alt) + entry_form_layout.addRow("Due Date (Alt):", self.entry_due_alt) + + self.entry_link = QLineEdit() # TODO see if there is a widget specifically for URLs + self.entry_link.setText(entry.link) + entry_form_layout.addRow("Link:", self.entry_link) + + self.entry_color = QLineEdit() + self.entry_color.setText(entry.color) + entry_form_layout.addRow("Color:", self.entry_color) + + self.entry_highlight = QLineEdit() + self.entry_highlight.setText(entry.highlight) + entry_form_layout.addRow("Highlight:", self.entry_highlight) + + # Submit and cancel buttons + buttons_h_box = QHBoxLayout() + buttons_h_box.addStretch() + close_button = QPushButton("Cancel") + close_button.clicked.connect(self.close) + buttons_h_box.addWidget(close_button) + submit_button = QPushButton("Submit") + submit_button.clicked.connect(self.handleSubmit) + buttons_h_box.addWidget(submit_button) + buttons_h_box.addStretch() + + entry_form_layout.addRow(buttons_h_box) + + self.setLayout(entry_form_layout) + + def handleSubmit(self): + desc_text = self.entry_desc.text() + if self.entry_due_checkbox.isChecked(): + due_text = self.entry_due.date() # due_text is a QDate + else: + due_text = "" # due is unchecked + due_alt_text = self.entry_due_alt.text() + link_text = self.entry_link.text() + color_text = self.entry_color.text() + highlight_text = self.entry_highlight.text() + + if not desc_text: + QMessageBox.warning(self, "Error Message", + "Name cannot be blank", + QMessageBox.Close, + QMessageBox.Close) + return + + # Update DB + entry = list(filter(lambda e: e.id == self.id, Globals.entries))[0] + entry.desc = desc_text + entry.due = due_text + entry.due_alt = due_alt_text + entry.link = link_text + entry.color = color_text + entry.highlight = highlight_text + DB.updateEntry(entry) + + # Update global variables + Globals.entries = list(filter(lambda e: e.id != self.id, Globals.entries)) + Globals.entries.append(Entry(self.id, entry.parent_id, desc_text, due_text, due_alt_text, link_text, color_text, highlight_text, entry.done, entry.hidden)) + self.close() + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = editEntryForm() + sys.exit(app.exec_()) + diff --git a/src/edit_group_form.py b/src/edit_group_form.py new file mode 100644 index 0000000..c4a8622 --- /dev/null +++ b/src/edit_group_form.py @@ -0,0 +1,89 @@ +import sys +from PyQt5.QtWidgets import QApplication, QComboBox, QDialog, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton +from PyQt5.QtGui import QFont +from PyQt5.QtCore import Qt + +import src.globals as Globals +from src.group import Group +import src.db_sqlite as DB + +class editGroupForm(QDialog): + """ + Form to edit/update a group + """ + def __init__(self, id): + self.id = id + super().__init__() + self.initializeUI() + + def initializeUI(self): + self.resize(400, 1) + self.setWindowTitle("Edit Group") + self.displayWidgets() + self.exec() + + def displayWidgets(self): + group_form_layout = QFormLayout() + group = list(filter(lambda g: g.id == self.id, Globals.groups))[0] + + title = QLabel("Edit Group") + title.setFont(QFont("Arial", 18)) + title.setAlignment(Qt.AlignCenter) + group_form_layout.addRow(title) + + self.group_name = QLineEdit() + self.group_name.setText(group.name) + group_form_layout.addRow("Name:", self.group_name) + + self.group_column = QComboBox() + self.group_column.addItems(["Left", "Right"]) + self.group_column.setCurrentIndex(0 if group.column.lower() == "left" else 1) + group_form_layout.addRow("Column:", self.group_column) + + self.group_link = QLineEdit() # TODO see if there is a widget specifically for URLs + self.group_link.setText(group.link) + group_form_layout.addRow("Link:", self.group_link) + + # Submit and cancel buttons + buttons_h_box = QHBoxLayout() + buttons_h_box.addStretch() + close_button = QPushButton("Cancel") + close_button.clicked.connect(self.close) + buttons_h_box.addWidget(close_button) + submit_button = QPushButton("Submit") + submit_button.clicked.connect(self.handleSubmit) + buttons_h_box.addWidget(submit_button) + buttons_h_box.addStretch() + + group_form_layout.addRow(buttons_h_box) + + self.setLayout(group_form_layout) + + def handleSubmit(self): + name_text = self.group_name.text() + column_text = self.group_column.currentText() + link_text = self.group_link.text() + + if not name_text: + QMessageBox.warning(self, "Error Message", + "Name cannot be blank", + QMessageBox.Close, + QMessageBox.Close) + return + + # Update DB + group = list(filter(lambda g: g.id == self.id, Globals.groups))[0] + group.name = name_text + group.column = column_text + group.link = link_text + DB.updateGroup(group) + + # Update global variables + Globals.groups = list(filter(lambda g: g.id != self.id, Globals.groups)) + Globals.groups.append(Group(self.id, name_text, column_text, link_text, group.hidden)) + self.close() + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = editGroupForm() + sys.exit(app.exec_()) diff --git a/src/entry.py b/src/entry.py new file mode 100644 index 0000000..addc96a --- /dev/null +++ b/src/entry.py @@ -0,0 +1,84 @@ +from datetime import date, datetime +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import QHBoxLayout, QLabel + +class Entry: + def __init__(self, id, parent_id, desc, due = "", due_alt = "", link = "", color = "", highlight = "", done = False, hidden = False): + self.id = id + self.parent_id = parent_id + self.desc = desc + self.due = due + self.due_alt = due_alt + self.link = link + self.color = color + self.highlight = highlight + self.done = done + self.hidden = hidden + + def buildLayout(self): + output = QHBoxLayout() + bullet = QLabel() + body = QLabel() + + bullet.setFont(QFont("Arial", 11)) + + body.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse) + body.setFont(QFont("Arial", 11)) + body.setWordWrap(True) + body.setToolTip("Right-Click for actions") + + if self.done: + bullet.setText("\u2713 ") + bullet.setStyleSheet(""" + QLabel{ + color: green; + } + """) + else: + bullet.setText("- ") + output.addWidget(bullet) + + if self.due: + body.setText("{0}/{1}/{2}: ".format( + self.due.month(), + self.due.day(), + self.due.year() + )) + if self.link: + body.setOpenExternalLinks(True) + body.setText(body.text() + "".format( + self.link, + self.color if self.color else "default" + )) + body.setText(body.text() + self.desc) + if self.link: + body.setText(body.text() + "") + body.setToolTip("{}".format(self.link)) + output.addWidget(body) + + output.addStretch() + + if self.done: + font = body.font() + font.setStrikeOut(True) + body.setFont(font) + body.setStyleSheet(""" + QLabel{ + color: green; + } + """) + + else: + body.setStyleSheet(""" + QLabel{{ + color: {0}; + background-color: {1}; + font-weight: {2}; + }}""".format( + self.color if self.color else "default", + self.highlight if self.highlight else "none", + "bold" if self.due and self.due <= date.today() else "normal" + )) + + return output diff --git a/src/globals.py b/src/globals.py new file mode 100644 index 0000000..61c6156 --- /dev/null +++ b/src/globals.py @@ -0,0 +1,3 @@ +groups = [] +entries = [] +db_path = "./test.db" diff --git a/src/group.py b/src/group.py new file mode 100644 index 0000000..18b4836 --- /dev/null +++ b/src/group.py @@ -0,0 +1,25 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import QLabel, QVBoxLayout +import src.globals as Globals + +class Group: + def __init__(self, id, name, column = "left", link = "", hidden = False): + self.id = id + self.name = name + self.column = column + self.link = link + self.hidden = hidden + + def buildLayout(self): + output = QVBoxLayout() + output.setContentsMargins(0, 10, 0, 10) + + name = QLabel(self.name) + name.setTextInteractionFlags(Qt.TextSelectableByMouse) + name_font = QFont("Arial", 13) + name_font.setUnderline(True) + name.setFont(name_font) + output.addWidget(name) + + return output diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..dfdf8a7 --- /dev/null +++ b/src/main.py @@ -0,0 +1,295 @@ +#!/usr/bin/python3 +import sys +import time +from PyQt5.QtWidgets import QAction, QApplication, QGridLayout, QHBoxLayout, QLabel, QMainWindow, QMenu, QMessageBox, QScrollArea, QToolBar, QVBoxLayout, QWidget +from PyQt5.QtGui import QCursor, QFont +from PyQt5.QtCore import QDate, Qt +from src.config import Config +from src.preferences_dialog import PreferencesDialog +from src.add_group_form import addGroupForm +from src.edit_group_form import editGroupForm +from src.add_entry_form import addEntryForm +from src.edit_entry_form import editEntryForm +import src.globals as Globals +import src.db_sqlite as DB + +class AssignmentList(QMainWindow): + def __init__(self): + super().__init__() + + self.initializeUI() + + def initializeUI(self): + self.resize(640, 480) + self.setWindowTitle("Assignment List") + self.createMenu() + self.createToolbar() + Config() + self.setupDB() + self.displayWidgets() + self.show() + + def createMenu(self): + menu_bar = self.menuBar() + file_menu = menu_bar.addMenu("File") + edit_menu = menu_bar.addMenu("Edit") + help_menu = menu_bar.addMenu("Help") + + self.preferences_act = QAction("Preferences", self) + self.preferences_act.setShortcut("Alt+Return") + self.preferences_act.triggered.connect(PreferencesDialog) + file_menu.addAction(self.preferences_act) + # TODO implement reload of DB that works + self.reload_act = QAction("Reload [WIP]", self) + self.reload_act.setShortcut("F5") + #self.reload_act.triggered.connect(self.reload) + file_menu.addAction(self.reload_act) + file_menu.addSeparator() + exit_act = QAction("Exit", self) + exit_act.setShortcut("Ctrl+Q") + exit_act.triggered.connect(self.close) + file_menu.addAction(exit_act) + + self.add_group_act = QAction("Add Group", self) + self.add_group_act.triggered.connect(self.addGroup) + edit_menu.addAction(self.add_group_act) + edit_menu.addSeparator() + self.clean_hidden_act = QAction("Permanently Delete Removed Groups and Entries", self) + self.clean_hidden_act.triggered.connect(self.cleanHidden) + edit_menu.addAction(self.clean_hidden_act) + + about_act = QAction("About", self) + about_act.triggered.connect(self.aboutDialog) + help_menu.addAction(about_act) + + def createToolbar(self): + tool_bar = QToolBar("Toolbar") + self.addToolBar(tool_bar) + + tool_bar.addAction(self.add_group_act) + + def setupDB(self): + DB.initDB() + + def displayWidgets(self): + main_widget_scroll_area = QScrollArea(self) + main_widget_scroll_area.setWidgetResizable(True) + main_widget = QWidget() + self.setCentralWidget(main_widget_scroll_area) + + title = QLabel(time.strftime("%A, %b %d %Y")) + title.setFont(QFont("Arial", 17)) + title.setTextInteractionFlags(Qt.TextSelectableByMouse) + + title_h_box = QHBoxLayout() + title_h_box.addStretch() + title_h_box.addWidget(title) + title_h_box.addStretch() + + self.groups_layout = QGridLayout() + self.groups_layout.setContentsMargins(20, 5, 20, 5) + self.drawGroups() + + v_box = QVBoxLayout() + v_box.addLayout(title_h_box) + v_box.addLayout(self.groups_layout) + v_box.addStretch() + + main_widget.setLayout(v_box) + main_widget_scroll_area.setWidget(main_widget) + + def addGroup(self): + """ + Open the 'addGroup' form + """ + old_count = len(Globals.groups) + self.create_new_group_dialog = addGroupForm() + if old_count != len(Globals.groups): + self.drawGroups() + + def editGroup(self, id): + """ + Open the 'editGroup' form + """ + self.create_edit_group_dialog = editGroupForm(id) + self.drawGroups() + + def removeGroup(self, id): + """ + Delete a group with a given id + """ + # TODO might want to add a warning + # TODO might want to make part of the a destructor in the Group class + removed = DB.removeGroup(id) + if removed > 0: + Globals.entries = list(filter(lambda e: e.parent_id != id, Globals.entries)) + Globals.groups = list(filter(lambda g: g.id != id, Globals.groups)) + self.drawGroups() + + def groupContextMenu(self, group_id): + menu = QMenu() + + add_entry_act = QAction("Add Entry") + add_entry_act.triggered.connect((lambda id: lambda: self.addEntry(id))(group_id)) + menu.addAction(add_entry_act) + + edit_group_act = QAction("Edit Group") + edit_group_act.triggered.connect((lambda id: lambda: self.editGroup(id))(group_id)) + menu.addAction(edit_group_act) + + del_group_act = QAction("Remove Group") + del_group_act.triggered.connect((lambda id: lambda: self.removeGroup(id))(group_id)) + menu.addAction(del_group_act) + + menu.exec_(QCursor.pos()) + + def addEntry(self, parent): + """ + Open the 'addEntry' form + """ + old_count = len(Globals.entries) + self.create_new_entry_dialog = addEntryForm(parent) + if old_count != len(Globals.entries): + self.drawGroups() # TODO see if we can do this with only redrawing a single group + + def editEntry(self, id): + """ + Open the 'editEntry' form + """ + self.create_edit_entry_dialog = editEntryForm(id) + self.drawGroups() + + def toggleDoneEntry(self, id): + """ + Toggle the 'done' flag on the entry with the given id + """ + entry = list(filter(lambda e: e.id == id, Globals.entries))[0] + if entry.done: + entry.done = False + else: + entry.done = True + DB.updateEntry(entry) + Globals.entries = list(filter(lambda e: e.id != id, Globals.entries)) + Globals.entries.append(entry) + self.drawGroups() + + def removeEntry(self, id): + """ + Delete an entry with a given id + """ + # TODO might want to add a warning + # TODO might want to make part of the a destructor in the Entry class + removed = DB.removeEntry(id) + if removed > 0: + Globals.entries = list(filter(lambda e: e.id != id, Globals.entries)) + self.drawGroups() + + def entryContextMenu(self, entry_id): + menu = QMenu() + + edit_entry_act = QAction("Edit Entry") + edit_entry_act.triggered.connect((lambda id: lambda: self.editEntry(id))(entry_id)) + menu.addAction(edit_entry_act) + + mark_done_act = QAction("Done", checkable=True) + if list(filter(lambda e: e.id == entry_id, Globals.entries))[0].done: + mark_done_act.setChecked(True) + mark_done_act.triggered.connect((lambda id: lambda: self.toggleDoneEntry(id))(entry_id)) + menu.addAction(mark_done_act) + + del_entry_act = QAction("Remove Entry") + del_entry_act.triggered.connect((lambda id: lambda: self.removeEntry(id))(entry_id)) + menu.addAction(del_entry_act) + + menu.exec_(QCursor.pos()) + + def cleanHidden(self): + """ + Permanently delete removed groups and entries from db + """ + # TODO consider creating a warning dialogue for this + DB.cleanHidden() + + def drawGroups(self): + """ + Redraw the groups_layout + """ + # Remove all children from layout + def recursiveClear(layout): + while layout.count(): + child = layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + elif child.layout(): + recursiveClear(child) + + recursiveClear(self.groups_layout) + + # Sort the groups + Globals.groups = sorted(Globals.groups, key=lambda g: g.id) + + # Sort the entries + Globals.entries = sorted(Globals.entries, key=lambda e: (e.parent_id, (e.due if e.due else QDate.currentDate()), e.done, e.id)) + + # Create columns as vertical boxes + column_left = QVBoxLayout() + column_right = QVBoxLayout() + + for g in Globals.groups: + # skip if this group is set to hidden + if g.hidden: + continue + + g_layout = g.buildLayout() + + # Create custom context menu + g_layout.itemAt(0).widget().setToolTip("Right-Click for actions") + g_layout.itemAt(0).widget().setContextMenuPolicy(Qt.CustomContextMenu) + g_layout.itemAt(0).widget().customContextMenuRequested.connect((lambda id: lambda: self.groupContextMenu(id))(g.id)) + + # Draw entries belonging to this group + g_layout.addLayout(self.drawEntries(g.id)) + + if g.column.lower() == "left": + column_left.addLayout(g_layout) + else: + column_right.addLayout(g_layout) + + column_left.addStretch() + column_right.addStretch() + + self.groups_layout.addLayout(column_left, 0, 0) + self.groups_layout.addLayout(column_right, 0, 1) + + def drawEntries(self, group_id): + """ + Redraw the entries of a specific group + """ + # TODO consider having code to remove existing widgets to make this function more modular + entries = list(filter(lambda e: e.parent_id == group_id, Globals.entries)) + entries_vbox = QVBoxLayout() + entries_vbox.setContentsMargins(5, 0, 0, 0) + + for e in entries: + # skip if this entry is set to hidden + if e.hidden: + continue + + e_layout = e.buildLayout() + entries_vbox.addLayout(e_layout) + + # Create custom context menu + e_layout.itemAt(1).widget().setContextMenuPolicy(Qt.CustomContextMenu) + e_layout.itemAt(1).widget().customContextMenuRequested.connect((lambda id: lambda: self.entryContextMenu(id))(e.id)) + + return entries_vbox + + def aboutDialog(self): + QMessageBox.about(self, "About Assignment List", + "Created by Louie S. - 2023") + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = AssignmentList() + sys.exit(app.exec_()) + diff --git a/src/preferences_dialog.py b/src/preferences_dialog.py new file mode 100644 index 0000000..e8292d6 --- /dev/null +++ b/src/preferences_dialog.py @@ -0,0 +1,93 @@ +import sys +from PyQt5.QtWidgets import QApplication, QDialog, QFileDialog, QFormLayout, QHBoxLayout, QLineEdit, QPushButton, QTabWidget, QVBoxLayout, QWidget +from src.config import Config + +class PreferencesDialog(QDialog): + """ + Implemented to set configuration options in the program + """ + def __init__(self): + super().__init__() + + # class globals + self.config = Config() + + self.initializeUI() + + def initializeUI(self): + self.resize(500, 320) + self.setWindowTitle("Preferences") + self.displayWidgets() + self.exec() + + def displayWidgets(self): + # TODO make this a scrollable window + # FIXME could use some work on sizing + main_layout = QVBoxLayout() + tab_bar = QTabWidget(self) + paths_tab = self.pathsTabLayout() + + tab_bar.addTab(paths_tab, "Paths") + main_layout.addWidget(tab_bar) + main_layout.addStretch() + + buttons_hbox = QHBoxLayout() + buttons_hbox.addStretch() + + close_button = QPushButton("Close", self) + close_button.clicked.connect(self.close) + buttons_hbox.addWidget(close_button) + + apply_button = QPushButton("Apply", self) + apply_button.clicked.connect(self.apply) + buttons_hbox.addWidget(apply_button) + + main_layout.addLayout(buttons_hbox) + self.setLayout(main_layout) + + def pathsTabLayout(self): + output = QWidget() + output_layout = QFormLayout() + + # Dialog for setting the database file path + db_path_hbox = QHBoxLayout() + self.db_path_edit = QLineEdit() + if "paths" in self.config.config: + self.db_path_edit.setText(self.config.config["paths"]["db_path"]) + db_path_hbox.addWidget(self.db_path_edit) + db_path_button = QPushButton("...") + db_path_button.setMaximumWidth(25) + db_path_button.clicked.connect(self.dbPathDialog) + db_path_hbox.addWidget(db_path_button) + output_layout.addRow("Database File:", db_path_hbox) + + output.setLayout(output_layout) + return output + + def dbPathDialog(self): + file_dialog = QFileDialog(self) + # TODO create filter to only allow selecting .db files + new_path = file_dialog.getOpenFileName(self, "Open File") + + if new_path[0]: + self.db_path_edit.setText(new_path[0]) + + def apply(self): + """ + Update the configuration in the config file + """ + # Save paths + if "paths" in self.config.config: + try: + with open(self.db_path_edit.text(), 'a'): + self.config.config["paths"]["db_path"] = self.db_path_edit.text() + except: + print("Warning: db_path '{}' does not exist; skipping".format(self.db_path_edit.text())) + + self.config.updateConfig() + + +if __name__ == "__main__": + app = QApplication(sys.argv) + window = PreferencesDialog() + sys.exit(app.exec_()) -- cgit