diff --git a/README.md b/README.md index ece8828..af0e7d7 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -# tasker +# Time tracker __Description__ A program for logging time spent on different tasks. -Has three stopwatch frames on main screen for visually logging work process. +Has several stopwatch frames on main screen for visually tracking work process. Tasks can be filtered by tags and by dates. Every time the task is opened its state is saved to database and than user can find all tasks on which he had been working at in selected day. @@ -11,8 +11,8 @@ Filtered tasks list can be exported as .csv. __Installation__ -To run application, you need Python 3 with Tkinter framework to be installed. It can be found on https://www.python.org/downloads/ (tkinter is by default included) or in your repository if your operating system is Linux or *BSD. -After Python interpreter installation, just extract archive with the application and execute tasker.pyw. +To run application, you need Python 3 with Tkinter framework to be installed. It can be found on https://www.python.org/downloads/ (tkinter is by default included) or in your OS repository if your operating system is Linux or *BSD. +After Python interpreter installation, just extract archive with the application and execute tracker.pyw. __License__ diff --git a/changelog.txt b/changelog.txt deleted file mode 100644 index 8da3d1c..0000000 --- a/changelog.txt +++ /dev/null @@ -1,64 +0,0 @@ -v.1.5 -1. Опция: отображать только сегодняшний день в таймерах. -2. Кнопка "Отмена" в окне редактирования опций. -3. Опция: На основном экране при запуске одной задачи можно останавливать остальные. -_Багфиксы: -1. Размер текста сделан связанным во всём приложении. -2. Заданы имена окон приложения, отображаемые в панели задач (включая главное). -3. Поправлено поведение календаря в фильтре: при установке диапазона дат сбрасывать ранее установленное. -4. Исправлено отображение помощи. -5. Исправлено ошибочное сообщение о том, что задача уже открыта, если она ранее открывалась в другом фрейме. - - -v.1.4 -_Фичи -1. Отчётность с возможностью выбора, что именно выгружать в отчёт. Есть воможность выбора между датами и задачами. -2. В настройках фильтра теперь можно задавать диапазон дат (с помощью календаря). -_Багфиксы: -1. Название новой таски, состоящее только из цифр, некорректно учитывалось при фильтрации списка задач - на момент добавления этой таски. -2. Изменён дизайн кнопок увеличения и уменьшения количества отображаемых фреймов в настройках главного окна. -3. Изменён формат отображения даты и её хранения в БД. Это позволяет избежать лишних шагов по преобразованию - формата при сортировке. -4. Горячие клавиши не работали в главном окне. -5. Размер текста в приложении стандартизован. - - -v.1.3 -_Фичи: -1. Добавлен "компактный режим". -2. Добавлена поддержка кнопок главного окна: 'S' для остановки всех тасок, 'C' для очистки окна, - 'Q'/'Esc' для выхода. -3. Добавлена возможность сохранения списка открытых задач при выходе. -_Багфиксы: -1. Теперь закрываются ненужные соединения с БД. - -v.1.2.2 -_Фичи: -Теперь в окне с датами в свойствах задачи для каждой даты отображается время, -затраченное на данную задачу. - -v.1.2.1 -_Багфиксы: -1. Если включено "всегда наверху", диалоговое окно не позиционируется внутри границ экрана. -2. Поиск не работает, если у одной из тасок пустое описание. - -v1.2 -_Фичи: -1. Сделать кастомные кнопки с изображениями (Refresh, search, start/stop...). -2. Закрытие главного окна крестиком должно работать так же, как и кнопкой "Quit". -3. Опция "всегда наверху" для разных платформ. -4. Закрывать все мелкие окошки по Esc. -_Багфиксы: -1. Исправить поведение при прохождении таймера через начало суток. -2. Исправить интерфейс на Mac OS X (кастомные кнопки в этом помогли). -3. Сделать открытие новых окон над предыдущими, а не в дефолтном месте экрана. -4. Поменять местами кнопки Close и Delete в окне редактирования тегов. -5. Окошко настроек не получает фокус. -6. Переходить к найденному по кнопке "поиск". Если найденных много, то к первому. -7. Вставка текста в текстовое поле по правой мыши должна происходить в то место, куда установлен курсор. -8. Копирование через контекстное меню должно копировать только выделенный фрагмент, если он есть. -9. Сортировка по потраченному времени перестаёт работать после применения сортировки по дате. -10. "Ignore case" в поиске не работает для описания таски. -11. В режиме фильтра "OR" применяются только условия "слева", т.е. только соответствие дате. -12. В таблице tasks_tags дублируются записи: если такой тег для такой задачи уже есть, он всё равно добавляется повторно. diff --git a/core.py b/core.py deleted file mode 100644 index 3851f5e..0000000 --- a/core.py +++ /dev/null @@ -1,330 +0,0 @@ -#!/usr/bin/env python3 - -import os -import time -import datetime - -import sqlite3 -from collections import OrderedDict as odict - - -class DbErrors(Exception): - """Base class for errors in database operations.""" - pass - - -class Db: - """Class for interaction with database.""" - def __init__(self): - self.db_filename = TABLE_FILE - self.connect() - - def connect(self): - """Connection to database.""" - self.con = sqlite3.connect(self.db_filename) - self.cur = self.con.cursor() - - def reconnect(self): - """Used to reconnect after exception.""" - self.cur.close() - self.con.close() - self.connect() - - def exec_script(self, script, *values): - """Custom script execution and commit. Returns lastrowid. Raises DbErrors on database exceptions.""" - try: - if not values: - self.cur.execute(script) - else: - self.cur.execute(script, values) - except sqlite3.DatabaseError as err: - raise DbErrors(err) - else: - self.con.commit() - return self.cur.lastrowid - - def find_by_clause(self, table, field, value, searchfield, order=None): - """Returns "searchfield" for field=value.""" - order_by = '' - if order: - order_by = ' ORDER BY {0}'.format(order) - self.exec_script('SELECT {3} FROM {0} WHERE {1}="{2}"{4}'.format(table, field, value, searchfield, order_by)) - return self.cur.fetchall() - - def find_all(self, table, sortfield=None): - """Returns all contents for given tablename.""" - if not sortfield: - self.exec_script('SELECT * FROM {0}'.format(table)) - else: - self.exec_script('SELECT * FROM {0} ORDER BY {1} ASC'.format(table, sortfield)) - return self.cur.fetchall() - - def select_task(self, task_id): - """Returns tuple of values for given task_id.""" - task = list(self.find_by_clause(searchfield='*', field='id', value=task_id, table='tasks')[0]) - # Adding full spent time: - self.exec_script('SELECT sum(spent_time) FROM activity WHERE task_id=%s' % task_id) - # Adding spent time on position 3: - task.insert(2, self.cur.fetchone()[0]) - # Append today's spent time: - self.exec_script('SELECT spent_time FROM activity WHERE task_id={0} AND ' - 'date="{1}"'.format(task_id, date_format(datetime.datetime.now()))) - today_time = self.cur.fetchone() - if today_time: - task.append(today_time[0]) - else: - task.append(today_time) - return task - - def insert(self, table, fields, values): - """Insert into fields given values. Fields and values should be tuples of same length.""" - placeholder = "(" + ",".join(["?"] * len(values)) + ")" - return self.exec_script('INSERT INTO {0} {1} VALUES {2}'.format(table, fields, placeholder), *values) - - def insert_task(self, name): - """Insert task into database.""" - date = date_format(datetime.datetime.now()) - try: - rowid = self.insert('tasks', ('name', 'creation_date'), (name, date)) - except sqlite3.IntegrityError: - raise DbErrors("Task name already exists") - else: - task_id = self.find_by_clause("tasks", "rowid", rowid, "id")[0][0] - self.insert("activity", ("date", "task_id", "spent_time"), (date, task_id, 0)) - self.insert("tasks_tags", ("tag_id", "task_id"), (1, task_id)) - return task_id - - def update(self, field_id, field, value, table="tasks", updfiled="id"): - """Updates given field in given table with given id using given value :) """ - self.exec_script("UPDATE {0} SET {1}=? WHERE {3}='{2}'".format(table, field, field_id, updfiled), value) - - def update_task(self, task_id, field="spent_time", value=0): - """Updates some fields for given task id.""" - if field == 'spent_time': - self.exec_script("SELECT rowid FROM activity WHERE task_id={0} " - "AND date='{1}'".format(task_id, date_format(datetime.datetime.now()))) - daterow = self.cur.fetchone()[0] - self.update(daterow, table='activity', updfiled='rowid', field=field, value=value) - else: - self.update(task_id, field=field, value=value) - - def delete(self, table="tasks", **field_values): - """Removes several records using multiple "field in (values)" clauses. - field_values has to be a dictionary which values can be tuples: - field1=(value1, value), field2=value1, field3=(value1, value2, value3)""" - clauses = [] - for key in field_values: - value = field_values[key] - if type(value) in (list, tuple): - value = tuple(value) - if len(value) == 1: - value = "('%s')" % value[0] - clauses.append("{0} in {1}".format(key, value)) - else: - clauses.append("{0}='{1}'".format(key, value)) - clauses = " AND ".join(clauses) - if len(clauses) > 0: - clauses = " WHERE " + clauses - self.exec_script("DELETE FROM {0}{1}".format(table, clauses)) - - def delete_tasks(self, values): - """Removes task and all corresponding records. Values has to be tuple.""" - self.delete(id=values) - self.delete(task_id=values, table="activity") - self.delete(task_id=values, table="timestamps") - self.delete(task_id=values, table="tasks_tags") - - def tasks_to_export(self, ids): - """Prepare tasks list for export.""" - self.exec_script("select name, description, activity.date, activity.spent_time from tasks join activity " - "on tasks.id=activity.task_id where tasks.id in {0} order by tasks.name, activity.date". - format(tuple(ids))) - res = self.cur.fetchall() - result = odict() - for item in res: - if item[0] in result: - result[item[0]][1].append((item[2], time_format(item[3]))) - else: - result[item[0]] = [item[1] if item[1] else '', [(item[2], time_format(item[3]))]] - self.exec_script("select name, fulltime from tasks join (select task_id, sum(spent_time) as fulltime " - "from activity where task_id in {0} group by task_id) as act on tasks.id=act.task_id". - format(tuple(ids))) - res = self.cur.fetchall() - for item in res: - result[item[0]].append(time_format(item[1])) - return result - - def dates_to_export(self, ids): - """Prepare date-based tasks list for export.""" - self.exec_script("select date, tasks.name, tasks.description, spent_time from activity join tasks " - "on activity.task_id=tasks.id where task_id in {0} order by date, tasks.name". - format(tuple(ids))) - res = self.cur.fetchall() - result = odict() - for item in res: - if item[0] in result: - result[item[0]][0].append([item[1], item[2] if item[2] else '', time_format(item[3])]) - else: - result[item[0]] = [[[item[1], item[2] if item[2] else '', time_format(item[3])]]] - self.exec_script("select date, sum(spent_time) from activity where task_id in {0} group by date " - "order by date".format(tuple(ids))) - res = self.cur.fetchall() - for item in res: - result[item[0]].append(time_format(item[1])) - return result - - def tags_dict(self, taskid): - """Creates a list of tag ids, their values in (0, 1) and their names for given task id. - Tag has value 1 if a record for given task id exists in tags table. - """ - tagnames = self.find_all("tags", sortfield="name") # [(1, tagname), (2, tagname)] - self.exec_script("SELECT t.tag_id FROM tasks_tags AS t JOIN tags ON t.tag_id=tags.id WHERE t.task_id=%d" % taskid) - actual_tags = [x[0] for x in self.cur.fetchall()] # [1, 3, ...] - states_list = [] # [[1, [1, 'tag1']], [2, [0, 'tag2']], [3, [1, 'tag3']]] - for k in tagnames: - states_list.append([k[0], [1 if k[0] in actual_tags else 0, k[1]]]) - return states_list - - def simple_tagslist(self): - """Returns tags list just like tags_dict() but every tag value is 0.""" - tagslist = self.find_all("tags", sortfield="name") - res = [[y, [0, x]] for y, x in tagslist] - res.reverse() # Should be reversed to preserve order like in database. - return res - - def simple_dateslist(self): - """Returns simple list of all dates of activity without duplicates.""" - self.exec_script('SELECT DISTINCT date FROM activity ORDER BY date DESC') - return [x[0] for x in self.cur.fetchall()] - - def timestamps(self, taskid, current_time): - """Returns timestamps list in same format as simple_tagslist().""" - timestamps = self.find_by_clause('timestamps', 'task_id', taskid, 'timestamp') - res = [[x[0], [0, '{0}; {1} spent since that moment'.format( - time_format(x[0]), time_format(current_time - x[0]))]] for x in timestamps] - res.reverse() - return res - - -def check_database(): - """Check if database file exists.""" - if not os.path.exists(TABLE_FILE): - with sqlite3.connect(TABLE_FILE) as con: - con.executescript(TABLE_STRUCTURE) - con.commit() - patch_database() - - -def write_to_disk(filename, text): - """Creates file and fills it with given text.""" - expfile = open(filename, 'w') - expfile.write(text) - expfile.close() - - -def time_format(sec): - """Returns time string in readable format.""" - if sec < 86400: - return time.strftime("%H:%M:%S", time.gmtime(sec)) - else: - day = int(sec // 86400) - if day == 1: - return "1 day" - else: - return "{} days".format(day) - - -def date_format(date, template='%Y-%m-%d'): - """Returns formatted date (str). Accepts datetime.""" - return datetime.datetime.strftime(date, template) - - -def str_to_date(string, template='%Y-%m-%d'): - """Returns datetime from string.""" - return datetime.datetime.strptime(string, template) - - -def get_help(): - """Reading help from the file.""" - try: - with open('resource/help.txt', encoding='UTF-8') as helpfile: - helptext = helpfile.read() - except Exception: - helptext = '' - return helptext - - -def patch_database(): - """Apply patches to database.""" - con = sqlite3.connect(TABLE_FILE) - cur = con.cursor() - cur.execute("SELECT value FROM options WHERE name='patch_ver';") - res = cur.fetchone() - key = '0' - if not res: - for key in sorted(PATCH_SCRIPTS): - apply_script(PATCH_SCRIPTS[key], con) - res = (1, ) - else: - for key in sorted(PATCH_SCRIPTS): - if int(res[0]) < key: - apply_script(PATCH_SCRIPTS[key], con) - if res[0] != key: - con.executescript("UPDATE options SET value='{0}' WHERE name='patch_ver';".format(str(key))) - con.commit() - con.close() - - -def apply_script(scripts_list, db_connection): - for script in scripts_list: - try: - db_connection.executescript(script) - db_connection.commit() - except sqlite3.DatabaseError: - pass - - -HELP_TEXT = get_help() -TABLE_FILE = 'tasks.db' -TABLE_STRUCTURE = """\ - CREATE TABLE tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE, - description TEXT, - creation_date TEXT); - CREATE TABLE activity (date TEXT, - task_id INT, - spent_time INT); - CREATE TABLE tasks_tags (task_id INT, - tag_id INT); - CREATE TABLE timestamps (timestamp INT, - task_id INT); - CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT UNIQUE); - CREATE TABLE options (name TEXT UNIQUE, - value TEXT); - INSERT INTO tags VALUES (1, 'default'); - INSERT INTO options (name) VALUES ('filter'); - INSERT INTO options VALUES ('filter_tags', ''); - INSERT INTO options VALUES ('filter_dates', ''); - INSERT INTO options VALUES ('filter_operating_mode', 'AND'); - INSERT INTO options VALUES ('patch_ver', '0'); - INSERT INTO options VALUES ('timers_count', '3'); - INSERT INTO options VALUES ('minimize_to_tray', '0'); - INSERT INTO options VALUES ('always_on_top', '0'); - INSERT INTO options VALUES ('preserve_tasks', '0'); - INSERT INTO options VALUES ('show_today', '0'); - INSERT INTO options VALUES ('toggle_tasks', '0'); - INSERT INTO options VALUES ('tasks', ''); - INSERT INTO options VALUES ('compact_interface', '0'); - INSERT INTO options VALUES ('version', '1.5'); - INSERT INTO options VALUES ('install_time', datetime('now')); - """ -# PATCH_SCRIPTS = { - # 1: [ - # "INSERT INTO options VALUES ('toggle_tasks', '0');" - # ], - # 2: [ - # "UPDATE options SET value='2.0' WHERE name='version';" - # ] -# } -PATCH_SCRIPTS = {} diff --git a/dev/developing_notes.txt b/dev/developing_notes.txt new file mode 100644 index 0000000..3493005 --- /dev/null +++ b/dev/developing_notes.txt @@ -0,0 +1,3 @@ +Варианты ситуаций, когда требуется запись таймера, при условии смены даты: +1. Задача выполнялась, запись при update или stop, вчера прошло больше, чем сегодня. +2. Задача выполнялась, запись при update или stop, вчера прошло меньше, чем сегодня. diff --git a/task_generator.py b/dev/task_generator.py similarity index 98% rename from task_generator.py rename to dev/task_generator.py index 669cd0f..b27181f 100644 --- a/task_generator.py +++ b/dev/task_generator.py @@ -8,8 +8,7 @@ ### import random -import core - +from src import core TASK_LIMIT = 50 diff --git a/dev/timer_schema.epgz b/dev/timer_schema.epgz new file mode 100644 index 0000000..806041c Binary files /dev/null and b/dev/timer_schema.epgz differ diff --git a/resource/developing_notes.txt b/resource/developing_notes.txt deleted file mode 100644 index ed24d13..0000000 --- a/resource/developing_notes.txt +++ /dev/null @@ -1,35 +0,0 @@ -Что нужно в отчёте - -Вариант 1 -- Имя задачи -- Описание задачи -- Даты, в которые происходила работа над ней -- Время, затраченное на задачу в каждую дату -- Суммарное затраченное время - -Вариант 2 -- Дата -- Список задач, над которыми шла работа -- Описания задач -- Время, потраченное на каждую задачу -- Суммарное затраченное время на все задачи за эту дату - - -1) -select name, description, activity.date, activity.spent_time from tasks join activity on tasks.id=activity.task_id where tasks.id in (1,2) order by tasks.name, activity.date; -select name, fulltime from tasks join (select task_id, sum(spent_time) as fulltime from activity where task_id in (1,2, 3) group by task_id) as act on tasks.id=act.task_id; -{'task1': ['description', [('date1': 'time1'), ('date2': 'time2')], 'full time'], 'task2': ...} - -Task,Description,Dates,Time,Summarized working time -Task4,Another task,13.01.17,13:14:00,13:25:00 -,,14.02.17,00:43:12, -Task5,Also a task,13.01.17,00:11:00,00:11:00 - -2) -select date, tasks.name, tasks.description, spent_time from activity join tasks on activity.task_id=tasks.id where task_id in (1,2) order by date, tasks.name -select date, sum(spent_time) from activity where task_id in (1,2) group by date order by date -{'date1': [[['task1', 'description', 'time1'], ['task2', 'description', 'time1']], 'full time'], 'date2': ...} - -Date,Tasks,Description,Time,Summarized working time -13.01.17,Task4,Another task,13:14:00,13:25:00 -,Task5,Also a task,00:11:00, \ No newline at end of file diff --git a/resource/help.txt b/resource/help.txt deleted file mode 100644 index 3d8f942..0000000 --- a/resource/help.txt +++ /dev/null @@ -1,11 +0,0 @@ -На главном экране приложения расположены окошки, в которых может отображаться имя задачи и её описание, и самое главное - таймер. Рядом с таймером находится кнопка “Старт”, при нажатии на которую таймер и запускается. Таким образом, можно постоянно отслеживать потраченное на задачу время. При нажатии на кнопку “стоп” таймер останавливается, а его значение записывается в базу данных. Также туда записывается дата, когда эта запись была произведена. Это нужно для случая, когда над одной и той же задачей работа ведётся несколько дней. Тогда сохраняются все даты, когда над ней велась работа, и по ним можно потом сделать выборку. - -Таких таймеров на главном экране по умолчанию три (в дальнейшем будет добавлена настройка, с помощью которой можно будет менять количество произвольно), то есть можно одновременно следить за тремя задачами, запуская и останавливая их таймеры по отдельности или все сразу. - -Также для каждой задачи есть кнопки добавления и просмотра таймстемпов. Это нужно для случая, когда задача по ходу выполнения разбивается на временнЫе отрезки и требуется отслеживать, сколько времени потрачено на каждый. Ставим таймстемп (метку), а затем, когда потребуется посмотреть, сколько времени прошло с момента её установки, можно открыть окошко со списком таймстемпов и сразу увидеть там эту информацию. Там же можно удалить лишние таймстемпы. - -С помощью кнопки “Задача” (“Task”) с главного экрана можно попасть в окно, где отображается список задач и есть возможность их добавления. Сортировка списка производится по клику в заголовок соответствующей колонки. Кроме того, здесь же, с помощью кнопки “Фильтр” (“Filter”), можно отфильтровать задачи по датам, когда с ними производилась какая-либо работа, и по тегам. - -У каждой задачи есть набор свойств, которые можно увидеть с помощью нажатия на кнопку “Свойства” (“Properties”). При её нажатии открывается окно свойств, где можно отредактировать описание задачи, увидеть все даты, когда с ней производилась работа (на неё было потрачено время), а также задать для неё теги. Теги отображаются в виде списка чекбоксов. Рядом со списком есть кнопка редактирования тегов, открывающая окно, где можно добавлять новые и удалять старые теги. Свойства задачи также можно открыть с главного экрана. - -В окне выбора задач также находится кнопка “Экспорт” (“Export”). Эта кнопка позволяет экспортировать текущую выборку в список в формате .csv. В экспортируемом файле сохраняется название задачи, потраченное на неё время и дата её создания, а также суммарное время, потраченное на все задачи из представленного списка. \ No newline at end of file diff --git a/src/core.py b/src/core.py new file mode 100644 index 0000000..9bd015f --- /dev/null +++ b/src/core.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 + +from collections import OrderedDict, namedtuple +import datetime +import os +import sqlite3 +import time + + +DATE_TEMPLATE = "%Y-%m-%d" +DATE_STORAGE_TEMPLATE = "%Y-%m-%dT%H:%M:%S.%f" +DATE_FULL_HUMAN_READABLE_TEMPLATE = "%Y-%m-%d %H:%M:%S" + + +class DbErrors(Exception): + """Base class for errors in database operations.""" + pass + + +class Db: + """Class for interaction with database.""" + + def __init__(self): + self.db_filename = TABLE_FILE + self.connect() + + def connect(self): + """Connection to database.""" + self.con = sqlite3.connect(self.db_filename) + self.cur = self.con.cursor() + + def reconnect(self): + """Used to reconnect after exception.""" + self.cur.close() + self.con.close() + self.connect() + + def exec_script(self, script, *values): + """Custom script execution and commit. Returns lastrowid. + Raises DbErrors on database exceptions.""" + try: + if not values: + self.cur.execute(script) + else: + self.cur.execute(script, values) + except sqlite3.DatabaseError as err: + raise DbErrors(err) + else: + self.con.commit() + return self.cur.lastrowid + + def find_by_clause(self, table, field, value, searchfield, order=None): + """Returns "searchfield" for field=value.""" + order_by = '' + if order: + order_by = ' ORDER BY {0}'.format(order) + self.exec_script( + 'SELECT {3} FROM {0} WHERE {1}="{2}"{4}'.format(table, field, + value, searchfield, + order_by)) + return self.cur.fetchall() + + def find_all(self, table, sortfield=None): + """Returns all contents for given tablename.""" + if not sortfield: + self.exec_script('SELECT * FROM {0}'.format(table)) + else: + self.exec_script( + 'SELECT * FROM {0} ORDER BY {1} ASC'.format(table, sortfield)) + return self.cur.fetchall() + + def select_task(self, task_id): + """Returns dictionary of values for given task_id.""" + res = self.find_by_clause(searchfield='*', field='id', value=task_id, + table='tasks')[0] + task = {key: res[number] for number, key + in enumerate(["id", "name", "descr", "creation_date"])} + # Adding full spent time: + self.exec_script( + 'SELECT sum(spent_time) FROM activity WHERE task_id=%s' % task_id) + # Adding spent time on position 3: + task["spent_total"] = self.cur.fetchone()[0] + # Append today's spent time: + self.exec_script( + 'SELECT spent_time FROM activity WHERE task_id={0} AND ' + 'date="{1}"'.format(task_id, date_format(datetime.datetime.now()))) + today_time = self.cur.fetchone() + task["spent_today"] = today_time[0] if today_time else 0 + return task + + def insert(self, table, fields, values): + """Insert into fields given values. + Fields and values should be tuples of same length.""" + placeholder = "(" + ",".join(["?"] * len(values)) + ")" + return self.exec_script( + 'INSERT INTO {0} {1} VALUES {2}'.format(table, fields, + placeholder), *values) + + def insert_task(self, name): + """Insert task into database.""" + try: + rowid = self.insert('tasks', ('name', 'creation_date'), + (name, date_format(datetime.datetime.now(), + DATE_STORAGE_TEMPLATE))) + except sqlite3.IntegrityError: + raise DbErrors("Task name already exists") + else: + task_id = self.find_by_clause("tasks", "rowid", rowid, "id")[0][0] + self.insert_task_activity(task_id, 0) + self.insert("tasks_tags", ("tag_id", "task_id"), (1, task_id)) + return task_id + + def update(self, field_id, field, value, table="tasks", updfield="id"): + """Updates provided field in provided table with provided id + using provided value """ + self.exec_script( + "UPDATE {0} SET {1}=? WHERE {3}='{2}'".format(table, field, + field_id, updfield), + value) + + def check_task_activity_exists(self, task_id, date): + """Returns rowid of row with task activity for provided date + if such activity exists, otherwise returns None""" + self.exec_script("SELECT rowid FROM activity WHERE task_id={0}" + " AND date='{1}'".format(task_id, date)) + try: + return self.cur.fetchone()[0] + except TypeError: + return + + def update_task(self, task_id, field="spent_time", value=0, prev_date=None): + """Updates some fields for given task id.""" + res = None + if field == 'spent_time': + now = datetime.datetime.now() + current_date = today() + daterow = self.check_task_activity_exists(task_id, prev_date) + if current_date == prev_date: + if daterow: + self.update(daterow, table='activity', updfield='rowid', + field=field, value=value) + else: + self.insert_task_activity(task_id, value, prev_date) + else: + today_secs = datetime.timedelta( + hours=now.hour, minutes=now.minute, + seconds=now.second).total_seconds() + if daterow: + self.update(daterow, table='activity', updfield='rowid', + field=field, value=value - today_secs) + else: + self.insert_task_activity(task_id, value - today_secs, + prev_date) + self.insert_task_activity(task_id, today_secs, current_date) + res = namedtuple("res", "remained,current_date")(today_secs, current_date) + else: + self.update(task_id, field=field, value=value) + return res + + def insert_task_activity(self, task_id, spent_time, date=None): + self.insert("activity", ("date", "task_id", "spent_time"), + (date if date else date_format(datetime.datetime.now()), + task_id, + spent_time)) + + def update_preserved_tasks(self, tasks): + if type(tasks) is not str: + tasks = ','.join(map(str, tasks)) + self.update(table='options', field='value', value=tasks, + field_id='tasks', updfield='name') + + def delete(self, table="tasks", **field_values): + """Removes several records using multiple "field in (values)" clauses. + field_values has to be a dictionary which values can be tuples: + field1=(value1, value), field2=value1, field3=(value1, value2, value3) + """ + clauses = [] + for key in field_values: + value = field_values[key] + if type(value) in (list, tuple): + value = tuple(value) + clauses.append("{0} in ({1})".format(key, ",".join((map(str, value))))) + else: + clauses.append("{0}='{1}'".format(key, value)) + clauses = " AND ".join(clauses) + if len(clauses) > 0: + clauses = " WHERE " + clauses + self.exec_script("DELETE FROM {0}{1}".format(table, clauses)) + + def delete_tasks(self, values): + """Removes task and all corresponding records. Values has to be tuple. + """ + self.delete(id=values) + self.delete(task_id=values, table="activity") + self.delete(task_id=values, table="timestamps") + self.delete(task_id=values, table="tasks_tags") + + def tasks_to_export(self, ids): + """Prepare tasks list for export.""" + self.exec_script( + "select name, description, activity.date, activity.spent_time " + "from tasks join activity " + "on tasks.id=activity.task_id where tasks.id in ({0}) " + "order by tasks.name, activity.date". + format(",".join(map(str, ids)))) + db_response = [{"name": item[0], "descr": item[1] if item[1] else '', + "date": item[2], "spent_time": item[3]} + for item in self.cur.fetchall()] + prepared_data = OrderedDict() + for item in db_response: + if item["name"] in prepared_data: + prepared_data[item["name"]]["dates"].append( + (item["date"], time_format(item["spent_time"]))) + else: + prepared_data[item["name"]] = { + "descr": item['descr'], + "dates": [(item["date"], + time_format(item["spent_time"]))]} + self.exec_script( + "select name, fulltime from tasks join (select task_id, " + "sum(spent_time) as fulltime " + "from activity where task_id in ({0}) group by task_id) " + "as act on tasks.id=act.task_id". + format(",".join(map(str, ids)))) + for item in self.cur.fetchall(): + prepared_data[item[0]]["spent_total"] = time_format(item[1]) + + result = ['Task,Description,Dates,Time,Total working time'] + for key in prepared_data: + temp_list = [key, prepared_data[key]["descr"], + prepared_data[key]["dates"][0][0], + prepared_data[key]["dates"][0][1], + prepared_data[key]["spent_total"]] + result.append(','.join(temp_list)) + if len(prepared_data[key]["dates"]) > 1: + for i in range(1, len(prepared_data[key]["dates"])): + result.append(','.join( + ['', '', prepared_data[key]["dates"][i][0], + prepared_data[key]["dates"][i][1], ''])) + i += 1 + return result + + def dates_to_export(self, ids): + """Prepare date-based tasks list for export.""" + self.exec_script( + "select date, tasks.name, tasks.description, " + "spent_time from activity join tasks " + "on activity.task_id=tasks.id where task_id in ({0}) " + "order by date, tasks.name". + format(",".join(map(str, ids)))) + db_response = [{"date": item[0], "name": item[1], + "descr": item[2] if item[2] else '', + "spent_time": item[3]} for item in self.cur.fetchall()] + + prepared_data = OrderedDict() + for item in db_response: + if item["date"] in prepared_data: + prepared_data[item["date"]]["tasks"].append({ + "name": item["name"], "descr": item["descr"], + "spent_time": time_format(item["spent_time"])}) + else: + prepared_data[item["date"]] = { + "tasks": [{"name": item["name"], + "descr": item["descr"], + "spent_time": time_format(item["spent_time"])}]} + self.exec_script( + "select date, sum(spent_time) from activity where task_id " + "in ({0}) group by date order by date".format(",".join(map(str, + ids)))) + for item in self.cur.fetchall(): + prepared_data[item[0]]["spent_total"] = (time_format(item[1])) + + result = [ + 'Date,Tasks,Descriptions,Time,Summarized working time'] + for key in prepared_data: + temp_list = [key, + prepared_data[key]["tasks"][0]["name"], + prepared_data[key]["tasks"][0]["descr"], + prepared_data[key]["tasks"][0]["spent_time"], + prepared_data[key]["spent_total"]] + result.append(','.join(temp_list)) + if len(prepared_data[key]["tasks"]) > 1: + for i in range(1, len(prepared_data[key]["tasks"])): + result.append(','.join( + ['', + prepared_data[key]["tasks"][i]["name"], + prepared_data[key]["tasks"][i]["descr"], + prepared_data[key]["tasks"][i]["spent_time"], + ''])) + i += 1 + return result + + def tags_dict(self, taskid): + """Creates a list of tag ids, their values in (0, 1) and their names + for provided task id. + Tag has value 1 if a record for given task id exists in tags table. + """ + # [(1, tagname), (2, tagname)] + tagnames = self.find_all("tags", sortfield="name") + self.exec_script("SELECT t.tag_id FROM tasks_tags AS t JOIN tags " + "ON t.tag_id=tags.id WHERE t.task_id=%d" % taskid) + actual_tags = [x[0] for x in self.cur.fetchall()] # [1, 3, ...] + # [[1, [1, 'tag1']], [2, [0, 'tag2']], [3, [1, 'tag3']]] + states_list = [] + for k in tagnames: + states_list.append([k[0], [1 if k[0] in actual_tags else 0, k[1]]]) + return states_list + + def simple_tagslist(self): + """Returns tags list just like tags_dict() but every tag value is 0.""" + tagslist = self.find_all("tags", sortfield="name") + res = [[y, [0, x]] for y, x in tagslist] + res.reverse() # Should be reversed to preserve order like in database. + return res + + def simple_dateslist(self): + """Returns simple list of all dates of activity without duplicates.""" + self.exec_script( + 'SELECT DISTINCT date FROM activity ORDER BY date DESC') + return [x[0] for x in self.cur.fetchall()] + + def timestamps(self, taskid, task_total_spent_time): + """Returns timestamps list in same format as simple_tagslist().""" + timestamps = self.find_by_clause('timestamps', 'task_id', taskid, + 'timestamp') + res = [[x[0], [0, '{0}; {1} spent since that moment'.format( + time_format(x[0]), time_format(task_total_spent_time - x[0]))]] + for x in timestamps] + res.reverse() + return res + + +def prepare_filter_query(dates, tags, mode): + """Query to get filtered tasks data from database.""" + if mode == "OR": + return 'SELECT id, name, total_time, description, ' \ + 'creation_date FROM tasks JOIN activity ' \ + 'ON activity.task_id=tasks.id JOIN tasks_tags ' \ + 'ON tasks_tags.task_id=tasks.id ' \ + 'JOIN (SELECT task_id, sum(spent_time) ' \ + 'AS total_time ' \ + 'FROM activity GROUP BY task_id) AS act ' \ + 'ON act.task_id=tasks.id WHERE date IN ({1}) ' \ + 'OR tag_id IN ({0}) ' \ + 'GROUP BY act.task_id'. \ + format(",".join(map(str, tags)), "'%s'" % "','".join(dates)) + else: + if dates and tags: + return 'SELECT DISTINCT id, name, total_time, ' \ + 'description, creation_date FROM tasks JOIN ' \ + '(SELECT task_id, sum(spent_time) AS total_time ' \ + 'FROM activity WHERE activity.date IN ({0}) ' \ + 'GROUP BY task_id) AS act ' \ + 'ON act.task_id=tasks.id JOIN (SELECT tt.task_id' \ + ' FROM tasks_tags AS tt WHERE ' \ + 'tt.tag_id IN ({1}) GROUP BY tt.task_id ' \ + 'HAVING COUNT(DISTINCT tt.tag_id)={3}) AS x ON ' \ + 'x.task_id=tasks.id JOIN (SELECT act.task_id ' \ + 'FROM activity AS act WHERE act.date IN ({0}) ' \ + 'GROUP BY act.task_id HAVING ' \ + 'COUNT(DISTINCT act.date)={2}) AS y ON ' \ + 'y.task_id=tasks.id'. \ + format("'%s'" % "','".join(dates), + ",".join(map(str, tags)), len(dates), len(tags)) + elif not dates: + return 'SELECT DISTINCT id, name, total_time, ' \ + 'description, creation_date FROM tasks ' \ + 'JOIN (SELECT task_id, sum(spent_time) ' \ + 'AS total_time FROM activity GROUP BY ' \ + 'task_id) AS act ON act.task_id=tasks.id ' \ + 'JOIN (SELECT tt.task_id FROM tasks_tags ' \ + 'AS tt WHERE tt.tag_id IN ({0}) GROUP BY ' \ + 'tt.task_id HAVING ' \ + 'COUNT(DISTINCT tt.tag_id)={1}) AS x ON ' \ + 'x.task_id=tasks.id'. \ + format(",".join(map(str, tags)), len(tags)) + elif not tags: + return 'SELECT DISTINCT id, name, total_time, ' \ + 'description, creation_date FROM tasks ' \ + 'JOIN (SELECT task_id, sum(spent_time) ' \ + 'AS total_time FROM activity WHERE activity.date' \ + ' IN ({0}) GROUP BY task_id) AS act ' \ + 'ON act.task_id=tasks.id JOIN (SELECT ' \ + 'act.task_id FROM activity AS act ' \ + 'WHERE act.date IN ({0}) GROUP BY act.task_id ' \ + 'HAVING COUNT(DISTINCT act.date)={1}) AS y ' \ + 'ON y.task_id=tasks.id'.format("'%s'" % "','" + .join(dates), len(dates)) + + +def check_database(): + """Check if database file exists.""" + if not os.path.exists(TABLE_FILE): + with sqlite3.connect(TABLE_FILE) as con: + con.executescript(TABLE_STRUCTURE) + con.commit() + patch_database() + + +def write_to_disk(filename, text): + """Creates file and fills it with given text.""" + with open(filename, 'w') as expfile: + expfile.write(text) + + +def time_format(sec): + """Returns time string in readable format.""" + days = int(sec // 86400) + time_ = time.strftime("%H:%M:%S", time.gmtime(sec % 86400)) + prefix = "%d days, " % days + if days == 0: + prefix = '' + elif str(days).endswith("1"): + if days != 11: + prefix = "{} day, ".format(days) + return "{}{}".format(prefix, time_) + + +def date_format(date, template=DATE_TEMPLATE): + """Returns formatted date (str). Accepts datetime.""" + return datetime.datetime.strftime(date, template) + + +def str_to_date(string, template=DATE_TEMPLATE): + """Returns datetime from string.""" + return datetime.datetime.strptime(string, template) + + +def today(): + return date_format(datetime.datetime.now()) + + +def table_date_format(string, template=DATE_FULL_HUMAN_READABLE_TEMPLATE): + """Formats date stored in database to more human-readable""" + return date_format(str_to_date(string, DATE_STORAGE_TEMPLATE), template) + + +def get_help(): + """Reading help from the file.""" + try: + with open('resource/help.txt', encoding='UTF-8') as helpfile: + helptext = helpfile.read() + except Exception: + helptext = '' + return helptext + + +def patch_database(): + """Apply patches to database.""" + con = sqlite3.connect(TABLE_FILE) + cur = con.cursor() + cur.execute("SELECT value FROM options WHERE name='patch_ver';") + res = cur.fetchone() + key = 0 + if not res: + for key in sorted(PATCH_SCRIPTS): + apply_script(PATCH_SCRIPTS[key], con) + res = (1,) + else: + for key in sorted(PATCH_SCRIPTS): + if int(res[0]) < key: + apply_script(PATCH_SCRIPTS[key], con) + if res[0] != key: + con.executescript( + "UPDATE options SET value={0} WHERE name='patch_ver';".format(key)) + con.commit() + con.close() + + +def apply_script(scripts_list, db_connection): + for script in scripts_list: + try: + db_connection.executescript(script) + db_connection.commit() + except sqlite3.DatabaseError: + pass + + +CREATOR_NAME = "Alexey Kallistov" +TITLE = "Time tracker" +ABOUT_MESSAGE = "Time tracker {0}\nCopyright (c)\n{1},\n{2}" +HELP_TEXT = get_help() +TABLE_FILE = 'tasks.db' +LOG_EVENTS = { + "START": 0, + "STOP": 1, + "PAUSE": 2, + "RESUME": 3, + "CUSTOM": 9 +} +TABLE_STRUCTURE = """\ + CREATE TABLE tasks (id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE, + description TEXT, + creation_date TEXT); + CREATE TABLE activity (date TEXT, + task_id INT, + spent_time INT); + CREATE TABLE tasks_tags (task_id INT, + tag_id INT); + CREATE TABLE timestamps (timestamp INT, task_id INT, + event_type INT, datetime TEXT, comment TEXT); + CREATE TABLE tags (id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE); + CREATE TABLE options (name TEXT UNIQUE, value NUMERIC); + INSERT INTO tags VALUES (1, 'default'); + INSERT INTO options (name) VALUES ('filter'); + INSERT INTO options VALUES ('filter_tags', ''); + INSERT INTO options VALUES ('filter_dates', ''); + INSERT INTO options VALUES ('filter_operating_mode', 'AND'); + INSERT INTO options VALUES ('patch_ver', 0); + INSERT INTO options VALUES ('timers_count', 3); + INSERT INTO options VALUES ('always_on_top', 0); + INSERT INTO options VALUES ('preserve_tasks', 0); + INSERT INTO options VALUES ('show_today', 0); + INSERT INTO options VALUES ('toggle_tasks', 0); + INSERT INTO options VALUES ('tasks', ''); + INSERT INTO options VALUES ('compact_interface', 0); + INSERT INTO options VALUES ('version', '1.6.0'); + INSERT INTO options VALUES ('install_time', datetime('now')); + """ +# PATCH_SCRIPTS = { +# 1: [ +# "INSERT INTO options VALUES ('toggle_tasks', '0');" +# ], +# 2: [ +# "UPDATE options SET value='2.0' WHERE name='version';" +# ] +# } +PATCH_SCRIPTS = {} diff --git a/elements.py b/src/elements.py similarity index 70% rename from elements.py rename to src/elements.py index f3ce579..c554822 100644 --- a/elements.py +++ b/src/elements.py @@ -3,8 +3,11 @@ import tkinter as tk +FONTSIZE = 9 + + class Text(tk.Widget): - def __init__(self, fontsize=11, **kwargs): + def __init__(self, fontsize=FONTSIZE, **kwargs): super().__init__(**kwargs) big_font(self, fontsize) @@ -40,9 +43,13 @@ def __init__(self, master=None, **kwargs): class CanvasButton(Text, tk.Canvas): - """Button emulation based on Canvas() widget. Can have text and/or preconfigured image.""" - def __init__(self, master=None, image=None, text=None, variable=None, width=None, height=None, textwidth=None, - textheight=None, fontsize=11, opacity=None, relief='raised', bg=None, bd=2, state='normal', + """Button emulation based on Canvas() widget. Can have text + and/or preconfigured image.""" + + def __init__(self, master=None, image=None, text=None, variable=None, + width=None, height=None, textwidth=None, + textheight=None, fontsize=FONTSIZE, opacity=None, relief='raised', + bg=None, bd=2, state='normal', takefocus=True, command=None): super().__init__(master=master) self.pressed = False @@ -51,16 +58,21 @@ def __init__(self, master=None, image=None, text=None, variable=None, width=None self.bg = bg # configure canvas itself with applicable options: standard_options = {} - for item in ('width', 'height', 'relief', 'bg', 'bd', 'state', 'takefocus'): - if eval(item) is not None: # Such check because value of item can be 0. + for item in ( + 'width', 'height', 'relief', 'bg', 'bd', 'state', 'takefocus'): + if eval( + item) is not None: # Check because value of item can be 0. standard_options[item] = eval(item) super().config(**standard_options) self.bind("", self.press_button) self.bind("", self.release_button) - self.bind("", self._place) # Need to be before call of config_button()! + self.bind("", + self._place) # Need to be before call of config_button()! # Configure widget with specific options: - self.config_button(image=image, text=text, variable=variable, textwidth=textwidth, state=state, - textheight=textheight, fontsize=fontsize, opacity=opacity, bg=bg, command=command) + self.config_button(image=image, text=text, variable=variable, + textwidth=textwidth, state=state, + textheight=textheight, fontsize=fontsize, + opacity=opacity, bg=bg, command=command) # Get items dimensions: items_width = self.bbox('all')[2] - self.bbox('all')[0] items_height = self.bbox('all')[3] - self.bbox('all')[1] @@ -68,7 +80,9 @@ def __init__(self, master=None, image=None, text=None, variable=None, width=None if not width: self.config(width=items_width + items_width / 5 + bdsize * 2) if not height: - self.config(height=items_height + ((items_height / 5) if image else (items_height / 2)) + bdsize * 2) + self.config(height=items_height + ( + (items_height / 5) if image else ( + items_height / 2)) + bdsize * 2) # Place all contents in the middle of the widget: self.move('all', (self.winfo_reqwidth() - items_width) / 2, (self.winfo_reqheight() - items_height) / 2) @@ -100,7 +114,9 @@ def _place(self, event): def config_button(self, **kwargs): """Specific configuration of this widget.""" if 'image' in kwargs and kwargs['image']: - self.add_image(kwargs['image'], opacity='right' if 'opacity' not in kwargs else kwargs['opacity']) + self.add_image(kwargs['image'], + opacity='right' if 'opacity' not in kwargs else + kwargs['opacity']) if 'text' in kwargs and kwargs['text']: text = kwargs['text'] elif 'variable' in kwargs and kwargs['variable']: @@ -113,14 +129,16 @@ def config_button(self, **kwargs): if option in kwargs and kwargs[option]: self.textlabel.config(**{option: kwargs[option]}) if text: - self.add_text(text, **{key: kwargs[key] for key in ('fontsize', 'textwidth', 'textheight', 'bg', 'opacity') + self.add_text(text, **{key: kwargs[key] for key in ( + 'fontsize', 'textwidth', 'textheight', 'bg', 'opacity') if key in kwargs}) if 'command' in kwargs and kwargs['command']: self.command = kwargs['command'] def config(self, **kwargs): default_options = {} - for option in ('width', 'height', 'relief', 'bg', 'bd', 'state', 'takefocus'): + for option in ( + 'width', 'height', 'relief', 'bg', 'bd', 'state', 'takefocus'): if option in kwargs: default_options[option] = kwargs[option] if option not in ('bg', 'state', 'font'): @@ -132,12 +150,16 @@ def add_image(self, image, opacity='right'): """Add image.""" coords = [0, 0] if self.bbox('image'): - coords = self.coords('image') # New image will appear in the same position as previous. + coords = self.coords( + 'image') # New image will appear in the same position as previous. self.delete('image') - self.picture = tk.PhotoImage(file=image) # 'self' need to override garbage collection action. - self.create_image(coords[0], coords[1], image=self.picture, anchor='nw', tag='image') + self.picture = tk.PhotoImage( + file=image) # 'self' need to override garbage collection action. + self.create_image(coords[0], coords[1], image=self.picture, + anchor='nw', tag='image') - def add_text(self, textorvariable, fontsize=None, bg=None, opacity="right", textwidth=None, textheight=None): + def add_text(self, textorvariable, fontsize=None, bg=None, opacity="right", + textwidth=None, textheight=None): """Add text. Text can be tkinter.Variable() or string.""" if fontsize: font = tk.font.Font(size=fontsize) @@ -147,22 +169,29 @@ def add_text(self, textorvariable, fontsize=None, bg=None, opacity="right", text self.bg = bg recreate = False if hasattr(self, 'textlabel'): - coords = self.coords('text') # New text will appear in the same position as previous. + # New text will appear in the same position as previous: + coords = self.coords('text') recreate = True - self.delete(self.textlabel) + self.delete('text') if isinstance(textorvariable, tk.Variable): - self.textlabel = tk.Label(self, textvariable=textorvariable, bd=0, bg=self.bg, font=font, justify='center', - state=self.cget('state'), width=textwidth, height=textheight) + self.textlabel = tk.Label(self, textvariable=textorvariable, bd=0, + bg=self.bg, font=font, justify='center', + state=self.cget('state'), + width=textwidth, height=textheight) else: - self.textlabel = tk.Label(self, text=textorvariable, bd=0, bg=self.bg, font=font, justify='center', - state=self.cget('state'), width=textwidth, height=textheight) + self.textlabel = tk.Label(self, text=textorvariable, bd=0, + bg=self.bg, font=font, justify='center', + state=self.cget('state'), + width=textwidth, height=textheight) if self.bbox('image'): x_multiplier = self.bbox('image')[2] - self.bbox('image')[0] x_divider = x_multiplier / 6 - y_multiplier = ((self.bbox('image')[3] - self.bbox('image')[1]) - self.textlabel.winfo_reqheight()) / 2 + y_multiplier = ((self.bbox('image')[3] - self.bbox('image')[ + 1]) - self.textlabel.winfo_reqheight()) / 2 else: x_multiplier = x_divider = y_multiplier = 0 - self.create_window(coords[0] if recreate else x_multiplier + x_divider, coords[1] if recreate else y_multiplier, + self.create_window(coords[0] if recreate else x_multiplier + x_divider, + coords[1] if recreate else y_multiplier, anchor='nw', window=self.textlabel, tags='text') # Swap text and image if needed: if opacity == 'left': @@ -183,45 +212,65 @@ def release_button(self, event): if self.cget('state') == 'normal' and self.pressed: self.config(relief='raised') self.move('all', -1, -1) - if callable(self.command) and event.x_root in range(self.winfo_rootx(), self.winfo_rootx() + - self.winfo_width()) and event.y_root in range(self.winfo_rooty(), self.winfo_rooty() + - self.winfo_height()): + if callable(self.command) and event.x_root in range( + self.winfo_rootx(), self.winfo_rootx() + + self.winfo_width()) and event.y_root \ + in range(self.winfo_rooty(), self.winfo_rooty() + + self.winfo_height()): self.command() self.pressed = False class TaskButton(CanvasButton): """Just a button with some default parameters.""" + def __init__(self, parent, textwidth=8, **kwargs): super().__init__(master=parent, textwidth=textwidth, **kwargs) class ScrolledCanvas(tk.Frame): """Scrollable Canvas. Scroll may be horizontal or vertical.""" + def __init__(self, parent=None, orientation="vertical", bd=2, **options): super().__init__(master=parent, relief='groove', bd=bd) scroller = tk.Scrollbar(self, orient=orientation) self.canvbox = tk.Canvas(self, **options) - scroller.config(command=(self.canvbox.xview if orientation == "horizontal" else self.canvbox.yview)) + scroller.config(command=( + self.canvbox.xview if orientation == "horizontal" + else self.canvbox.yview)) if orientation == "horizontal": self.canvbox.config(xscrollcommand=scroller.set) else: self.canvbox.config(yscrollcommand=scroller.set) - scroller.pack(fill='x' if orientation == 'horizontal' else 'y', expand=1, + scroller.pack(fill='x' if orientation == 'horizontal' else 'y', + expand=1, side='bottom' if orientation == 'horizontal' else 'right', anchor='s' if orientation == 'horizontal' else 'e') self.content_frame = tk.Frame(self.canvbox) - self.canvbox.create_window((0, 0), window=self.content_frame, anchor='nw') + self.canvbox.create_window((0, 0), window=self.content_frame, + anchor='nw') self.canvbox.bind("", self.reconf_canvas) - self.canvbox.pack(fill="x" if orientation == "horizontal" else "both", expand=1) + self.canvbox.pack(fill="x" if orientation == "horizontal" else "both", + expand=1) + self.orientation = orientation def reconf_canvas(self, event): """Resizing of canvas scrollable region.""" self.canvbox.configure(scrollregion=self.canvbox.bbox('all')) self.canvbox.config(height=self.content_frame.winfo_height()) + def mouse_scroll(self, event): + if event.num == 4 or event.delta > 0: + delta = -1 + else: + delta = 1 + if self.orientation == 'vertical': + self.canvbox.yview_scroll(delta, 'units') + else: + self.canvbox.xview_scroll(delta, 'units') + -def big_font(unit, size=9): +def big_font(unit, size=FONTSIZE): """Font size of a given unit change.""" fontname = tk.font.Font(font=unit['font']).actual()['family'] - unit.config(font=(fontname, size)) \ No newline at end of file + unit.config(font=(fontname, size)) diff --git a/src/resource/help.txt b/src/resource/help.txt new file mode 100644 index 0000000..4e28a55 --- /dev/null +++ b/src/resource/help.txt @@ -0,0 +1,6 @@ +Main screen of the application contains pack of timers. These timers can display time spent on different tasks (up to 10 tasks at one time). To set quantity of timers, use Main menu - Options. +To open or add new task, press "Task..." button in any timer frame. Task selection window shall appear. To add new task, just enter new task's name in corresponding field in the top of the window and press "Add task". Or select task from list below and press "Open". +After that, task will appear in timer frame on main screen. Here one can start/stop timer. Any time start/stop button is pressed, new "Timestamp" is created. To view all timestamps, press "View timestamps" button. "Timestamp" and "Time spent since" columns display data in time spent on the task terms, not real time. And "date and time" column displays local time when timestamp created. It's possible also to create your own timestamps using "Add timestamp" button and delete any timestamp from the list. +Every task has its properties - name, description, tags. All of them, except name (it's unique parameter and cannot be changed) can be edited in Properties window (opened by pressing eponymous button in timer frame or in task selection window). Here one can set description and tags. New tags also can be created here, and after that, assigned to any task (by setting checkboxes). +In task selection window filter can be applied. Tasks can be filtered by date ot tag, or both. When filter is enabled, it's button is coloured blue. +Also here is export button. Tasks list can be exported to .csv reports. Csv contains exactly same tasks that are in the list, e.g. if some filter is enabled, exported tasks also will be filtered. Exported data can be date-based or task-based. That means different grouping of tasks: by date (which tasks were spent time on for each date) or by task (what dates had been every task worked on). \ No newline at end of file diff --git a/resource/magnifier.pgm b/src/resource/magnifier.pgm similarity index 100% rename from resource/magnifier.pgm rename to src/resource/magnifier.pgm diff --git a/resource/magnifier.png b/src/resource/magnifier.png similarity index 100% rename from resource/magnifier.png rename to src/resource/magnifier.png diff --git a/resource/refresh.pgm b/src/resource/refresh.pgm similarity index 100% rename from resource/refresh.pgm rename to src/resource/refresh.pgm diff --git a/resource/refresh.png b/src/resource/refresh.png similarity index 100% rename from resource/refresh.png rename to src/resource/refresh.png diff --git a/resource/start_disabled.pgm b/src/resource/start_disabled.pgm similarity index 100% rename from resource/start_disabled.pgm rename to src/resource/start_disabled.pgm diff --git a/resource/start_disabled.png b/src/resource/start_disabled.png similarity index 100% rename from resource/start_disabled.png rename to src/resource/start_disabled.png diff --git a/resource/start_normal.pgm b/src/resource/start_normal.pgm similarity index 100% rename from resource/start_normal.pgm rename to src/resource/start_normal.pgm diff --git a/resource/start_normal.png b/src/resource/start_normal.png similarity index 100% rename from resource/start_normal.png rename to src/resource/start_normal.png diff --git a/resource/stop.pgm b/src/resource/stop.pgm similarity index 100% rename from resource/stop.pgm rename to src/resource/stop.pgm diff --git a/resource/stop.png b/src/resource/stop.png similarity index 100% rename from resource/stop.png rename to src/resource/stop.png diff --git a/sel_cal.py b/src/sel_cal.py similarity index 66% rename from sel_cal.py rename to src/sel_cal.py index d0dd6f1..b93b40c 100644 --- a/sel_cal.py +++ b/src/sel_cal.py @@ -34,13 +34,14 @@ from Tkconstants import CENTER, LEFT, N, E, W, S from Tkinter import StringVar -except ImportError: # py3k +except ImportError: # py3k import tkinter as Tkinter import tkinter.font as tkFont import tkinter.ttk as ttk from tkinter.constants import CENTER, LEFT, N, E, W, S from tkinter import StringVar + import elements @@ -56,7 +57,12 @@ class Calendar(ttk.Frame): datetime = calendar.datetime.datetime timedelta = calendar.datetime.timedelta - def __init__(self, master=None, year=None, month=None, firstweekday=calendar.MONDAY, locale=None, activebackground='#b1dcfb', activeforeground='black', selectbackground='#003eff', selectforeground='white', command=None, borderwidth=1, relief="solid", on_click_month_button=None): + def __init__(self, master=None, year=None, month=None, + firstweekday=calendar.MONDAY, locale=None, + activebackground='#b1dcfb', activeforeground='black', + selectbackground='#003eff', selectforeground='white', + command=None, borderwidth=1, relief="solid", + on_click_month_button=None): """ WIDGET OPTIONS @@ -84,10 +90,13 @@ def __init__(self, master=None, year=None, month=None, firstweekday=calendar.MON self._selection_is_visible = False self._command = command - ttk.Frame.__init__(self, master, borderwidth=borderwidth, relief=relief) + ttk.Frame.__init__(self, master, borderwidth=borderwidth, + relief=relief) - self.bind("", lambda event:self.event_generate('<>')) - self.bind("", lambda event:self.event_generate('<>')) + self.bind("", + lambda event: self.event_generate('<>')) + self.bind("", + lambda event: self.event_generate('<>')) self._cal = get_calendar(locale, firstweekday) @@ -106,13 +115,16 @@ def __init__(self, master=None, year=None, month=None, firstweekday=calendar.MON # header frame and its widgets hframe = ttk.Frame(self) - lbtn = ttk.Button(hframe, style='L.TButton', command=self._on_press_left_button) + lbtn = ttk.Button(hframe, style='L.TButton', + command=self._on_press_left_button) lbtn.pack(side=LEFT) - self._header = elements.SimpleLabel(hframe, width=15, anchor=CENTER, textvariable=self._header_var) + self._header = elements.SimpleLabel(hframe, width=15, anchor=CENTER, + textvariable=self._header_var) self._header.pack(side=LEFT, padx=12) - rbtn = ttk.Button(hframe, style='R.TButton', command=self._on_press_right_button) + rbtn = ttk.Button(hframe, style='R.TButton', + command=self._on_press_right_button) rbtn.pack(side=LEFT) hframe.grid(columnspan=7, pady=4) @@ -121,15 +133,20 @@ def __init__(self, master=None, year=None, month=None, firstweekday=calendar.MON days_of_the_week = self._cal.formatweekheader(3).split() for i, day_of_the_week in enumerate(days_of_the_week): - elements.SimpleLabel(self, text=day_of_the_week, background='grey90').grid(row=1, column=i, sticky=N+E+W+S) + elements.SimpleLabel(self, text=day_of_the_week, + background='grey90').grid(row=1, column=i, + sticky=N + E + W + S) for i in range(6): for j in range(7): - self._day_labels[i,j] = label = elements.SimpleLabel(self, background = "white") + self._day_labels[i, j] = label = elements.SimpleLabel(self, + background="white") - label.grid(row=i+2, column=j, sticky=N+E+W+S) - label.bind("", lambda event: event.widget.configure(background=self._act_bg, foreground=self._act_fg)) - label.bind("", lambda event: event.widget.configure(background="white")) + label.grid(row=i + 2, column=j, sticky=N + E + W + S) + label.bind("", lambda event: event.widget.configure( + background=self._act_bg, foreground=self._act_fg)) + label.bind("", lambda event: event.widget.configure( + background="white")) label.bind("<1>", self._pressed) @@ -146,7 +163,7 @@ def __init__(self, master=None, year=None, month=None, firstweekday=calendar.MON self._build_calendar(year, month) def _build_calendar(self, year, month): - if not( self._year == year and self._month == month): + if not (self._year == year and self._month == month): self._year = year self._month = month @@ -163,26 +180,31 @@ def _build_calendar(self, year, month): fmt_week = [('%02d' % day) if day else '' for day in week] for j, day_number in enumerate(fmt_week): - self._day_labels[i,j]["text"] = day_number + self._day_labels[i, j]["text"] = day_number if len(cal) < 6: for j in range(7): - self._day_labels[5,j]["text"] = "" + self._day_labels[5, j]["text"] = "" - if self._selected_date is not None and self._selected_date.year == self._year and self._selected_date.month == self._month: + if self._selected_date is not None \ + and self._selected_date.year == self._year \ + and self._selected_date.month == self._month: self._show_selection() def _find_label_coordinates(self, date): - first_weekday_of_the_month = (date.weekday() - date.day) % 7 + first_weekday_of_the_month = (date.weekday() - date.day) % 7 - return divmod((first_weekday_of_the_month - self._cal.firstweekday)%7 + date.day, 7) + return divmod(( + first_weekday_of_the_month - self._cal.firstweekday) + % 7 + date.day, 7 + ) def _show_selection(self): """Show a new selection.""" - i,j = self._find_label_coordinates(self._selected_date) + i, j = self._find_label_coordinates(self._selected_date) - label = self._day_labels[i,j] + label = self._day_labels[i, j] label.configure(background=self._sel_bg, foreground=self._sel_fg) @@ -193,13 +215,15 @@ def _show_selection(self): def _clear_selection(self): """Show a new selection.""" - i,j = self._find_label_coordinates(self._selected_date) + i, j = self._find_label_coordinates(self._selected_date) - label = self._day_labels[i,j] - label.configure(background= "white", foreground="black") + label = self._day_labels[i, j] + label.configure(background="white", foreground="black") - label.bind("", lambda event: event.widget.configure(background=self._act_bg, foreground=self._act_fg)) - label.bind("", lambda event: event.widget.configure(background="white")) + label.bind("", lambda event: event.widget.configure( + background=self._act_bg, foreground=self._act_fg)) + label.bind("", + lambda event: event.widget.configure(background="white")) self._selection_is_visible = False @@ -215,7 +239,8 @@ def _pressed(self, evt): day_number = int(text) - new_selected_date = datetime.datetime(self._year, self._month, day_number) + new_selected_date = datetime.datetime(self._year, self._month, + day_number) if self._selected_date != new_selected_date: if self._selected_date is not None: self._clear_selection() @@ -247,7 +272,8 @@ def select_prev_day(self): self._clear_selection() self._selected_date = self._selected_date - self.timedelta(days=1) - self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar + self._build_calendar(self._selected_date.year, + self._selected_date.month) # reconstruct calendar def select_next_day(self): """Update calendar to show the next day.""" @@ -258,8 +284,8 @@ def select_next_day(self): self._clear_selection() self._selected_date = self._selected_date + self.timedelta(days=1) - self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar - + self._build_calendar(self._selected_date.year, + self._selected_date.month) # reconstruct calendar def select_prev_week_day(self): """Updated calendar to show the previous week.""" @@ -269,7 +295,8 @@ def select_prev_week_day(self): self._clear_selection() self._selected_date = self._selected_date - self.timedelta(days=7) - self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar + self._build_calendar(self._selected_date.year, + self._selected_date.month) # reconstruct calendar def select_next_week_day(self): """Update calendar to show the next week.""" @@ -279,44 +306,50 @@ def select_next_week_day(self): self._clear_selection() self._selected_date = self._selected_date + self.timedelta(days=7) - self._build_calendar(self._selected_date.year, self._selected_date.month) # reconstruct calendar + self._build_calendar(self._selected_date.year, + self._selected_date.month) # reconstruct calendar def select_current_date(self): """Update calendar to current date.""" if self._selection_is_visible: self._clear_selection() self._selected_date = datetime.datetime.now() - self._build_calendar(self._selected_date.year, self._selected_date.month) + self._build_calendar(self._selected_date.year, + self._selected_date.month) def prev_month(self): """Updated calendar to show the previous week.""" if self._selection_is_visible: self._clear_selection() - date = self.datetime(self._year, self._month, 1) - self.timedelta(days=1) - self._build_calendar(date.year, date.month) # reconstuct calendar + date = self.datetime(self._year, self._month, 1) - self.timedelta( + days=1) + self._build_calendar(date.year, date.month) # reconstuct calendar def next_month(self): """Update calendar to show the next month.""" if self._selection_is_visible: self._clear_selection() date = self.datetime(self._year, self._month, 1) + \ - self.timedelta(days=calendar.monthrange(self._year, self._month)[1] + 1) + self.timedelta( + days=calendar.monthrange(self._year, self._month)[1] + 1) - self._build_calendar(date.year, date.month) # reconstuct calendar + self._build_calendar(date.year, date.month) # reconstuct calendar def prev_year(self): """Updated calendar to show the previous year.""" if self._selection_is_visible: self._clear_selection() - self._build_calendar(self._year-1, self._month) # reconstruct calendar + self._build_calendar(self._year - 1, + self._month) # reconstruct calendar def next_year(self): """Update calendar to show the next year.""" if self._selection_is_visible: self._clear_selection() - self._build_calendar(self._year+1, self._month) # reconstruct calendar + self._build_calendar(self._year + 1, + self._month) # reconstruct calendar def get_selection(self): """Return a datetime representing the current selected date.""" @@ -331,13 +364,20 @@ def set_selection(self, date): self._selected_date = date - self._build_calendar(date.year, date.month) # reconstruct calendar + self._build_calendar(date.year, date.month) # reconstruct calendar + # see this URL for date format information: # https://docs.python.org/3/library/datetime.html#strftime-strptime-behavior class Datepicker(elements.SimpleTtkEntry): - def __init__(self, master, current_month=None, current_year=None, entrywidth=None, entrystyle=None, datevar=None, dateformat="%Y-%m-%d", onselect=None, firstweekday=calendar.MONDAY, locale=None, activebackground='#b1dcfb', activeforeground='black', selectbackground='#003eff', selectforeground='white', borderwidth=1, relief="solid"): + def __init__(self, master, current_month=None, current_year=None, + entrywidth=None, entrystyle=None, datevar=None, + dateformat="%Y-%m-%d", onselect=None, + firstweekday=calendar.MONDAY, locale=None, + activebackground='#b1dcfb', activeforeground='black', + selectbackground='#003eff', selectforeground='white', + borderwidth=1, relief="solid"): if datevar is not None: self.date_var = datevar @@ -351,57 +391,79 @@ def __init__(self, master, current_month=None, current_year=None, entrywidth=Non if entrystyle is not None: entry_config["style"] = entrystyle - elements.SimpleTtkEntry.__init__(self, master, textvariable=self.date_var, **entry_config) + elements.SimpleTtkEntry.__init__(self, master, + textvariable=self.date_var, + **entry_config) self.date_format = dateformat self._is_calendar_visible = False self._on_select_date_command = onselect - self.calendar_frame = Calendar(self.winfo_toplevel(), month=current_month, year=current_year, firstweekday=firstweekday, locale=locale, activebackground=activebackground, activeforeground=activeforeground, selectbackground=selectbackground, selectforeground=selectforeground, command=self._on_selected_date, on_click_month_button=lambda: self.focus()) + self.calendar_frame = Calendar(self.winfo_toplevel(), + month=current_month, year=current_year, + firstweekday=firstweekday, + locale=locale, + activebackground=activebackground, + activeforeground=activeforeground, + selectbackground=selectbackground, + selectforeground=selectforeground, + command=self._on_selected_date, + on_click_month_button=lambda: self.focus()) self.bind_all("<1>", self._on_click, "+") self.bind("", lambda event: self._on_entry_focus_out()) self.bind("", lambda event: self.hide_calendar()) - self.calendar_frame.bind("<>", lambda event: self._on_calendar_focus_out()) - + self.calendar_frame.bind("<>", + lambda event: self._on_calendar_focus_out()) # CTRL + PAGE UP: Move to the previous month. - self.bind("", lambda event: self.calendar_frame.prev_month()) + self.bind("", + lambda event: self.calendar_frame.prev_month()) # CTRL + PAGE DOWN: Move to the next month. - self.bind("", lambda event: self.calendar_frame.next_month()) + self.bind("", + lambda event: self.calendar_frame.next_month()) # CTRL + SHIFT + PAGE UP: Move to the previous year. - self.bind("", lambda event: self.calendar_frame.prev_year()) + self.bind("", + lambda event: self.calendar_frame.prev_year()) # CTRL + SHIFT + PAGE DOWN: Move to the next year. - self.bind("", lambda event: self.calendar_frame.next_year()) + self.bind("", + lambda event: self.calendar_frame.next_year()) # CTRL + LEFT: Move to the previous day. - self.bind("", lambda event: self.calendar_frame.select_prev_day()) + self.bind("", + lambda event: self.calendar_frame.select_prev_day()) # CTRL + RIGHT: Move to the next day. - self.bind("", lambda event: self.calendar_frame.select_next_day()) + self.bind("", + lambda event: self.calendar_frame.select_next_day()) # CTRL + UP: Move to the previous week. - self.bind("", lambda event: self.calendar_frame.select_prev_week_day()) + self.bind("", + lambda event: self.calendar_frame.select_prev_week_day()) # CTRL + DOWN: Move to the next week. - self.bind("", lambda event: self.calendar_frame.select_next_week_day()) + self.bind("", + lambda event: self.calendar_frame.select_next_week_day()) # CTRL + END: Close the datepicker and erase the date. self.bind("", lambda event: self.erase()) # CTRL + HOME: Move to the current month. - self.bind("", lambda event: self.calendar_frame.select_current_date()) + self.bind("", + lambda event: self.calendar_frame.select_current_date()) # CTRL + SPACE: Show date on calendar - self.bind("", lambda event: self.show_date_on_calendar()) + self.bind("", + lambda event: self.show_date_on_calendar()) # CTRL + Return: Set to entry current selection - self.bind("", lambda event: self.set_date_from_calendar()) + self.bind("", + lambda event: self.set_date_from_calendar()) def set_date_from_calendar(self): if self.is_calendar_visible: @@ -426,7 +488,8 @@ def current_text(self, text): @property def current_date(self): try: - date = datetime.datetime.strptime(self.date_var.get(), self.date_format) + date = datetime.datetime.strptime(self.date_var.get(), + self.date_format) return date except ValueError: return None @@ -492,7 +555,8 @@ def _on_click(self, event): if not self._is_calendar_visible: self.show_date_on_calendar() else: - if not str_widget.startswith(str(self.calendar_frame)) and self._is_calendar_visible: + if not str_widget.startswith( + str(self.calendar_frame)) and self._is_calendar_visible: self.hide_calendar() @@ -507,10 +571,10 @@ def _on_click(self, event): root = Tk() root.geometry("500x600") - main =Frame(root, pady =15, padx=15) + main = Frame(root, pady=15, padx=15) main.pack(expand=True, fill="both") - Label(main, justify="left", text=__doc__).pack(anchor="w", pady=(0,15)) + Label(main, justify="left", text=__doc__).pack(anchor="w", pady=(0, 15)) Datepicker(main).pack(anchor="w") @@ -518,4 +582,4 @@ def _on_click(self, event): style = ttk.Style() style.theme_use('clam') - root.mainloop() \ No newline at end of file + root.mainloop() diff --git a/src/tracker.pyw b/src/tracker.pyw new file mode 100755 index 0000000..c3fd24b --- /dev/null +++ b/src/tracker.pyw @@ -0,0 +1,2050 @@ +#!/usr/bin/env python3 + +import copy +import datetime +import os +import time +from collections import OrderedDict +from contextlib import suppress + +try: + import tkinter as tk +except ModuleNotFoundError: + exit("Unable to start GUI. Please install Tk for Python:\n" + "https://docs.python.org/3/library/tkinter.html.") + +from tkinter.filedialog import asksaveasfilename +from tkinter.messagebox import askyesno, showinfo +from tkinter import ttk +from tkinter import TclError + +import core +import elements +import sel_cal + + +class Window(tk.Toplevel): + """Universal class for dialogue windows creation.""" + + def __init__(self, master=None, **options): + super().__init__(master=master, **options) + self.db = core.Db() + self.master = master + self.bind("", lambda e: self.destroy()) + + def prepare(self): + self.grab_set() + self.on_top_wait() + self.place_window(self.master) + self.wait_window() + + def on_top_wait(self): + """Allows window to be on the top of others + when 'always on top' is enabled.""" + if GLOBAL_OPTIONS['always_on_top']: + self.wm_attributes("-topmost", 1) + + def place_window(self, parent): + """Place widget on top of parent.""" + if parent: + stored_xpos = parent.winfo_rootx() + self.geometry('+%d+%d' % (stored_xpos, parent.winfo_rooty())) + self.withdraw() # temporary hide window. + self.update_idletasks() + # Check if window will appear inside screen borders + # and move it if not: + if self.winfo_rootx() + self.winfo_width() \ + > self.winfo_screenwidth(): + stored_xpos = ( + self.winfo_screenwidth() - self.winfo_width() - 50) + self.geometry('+%d+%d' % (stored_xpos, parent.winfo_rooty())) + if self.winfo_rooty() + self.winfo_height() \ + > self.winfo_screenheight(): + self.geometry('+%d+%d' % (stored_xpos, ( + self.winfo_screenheight() - self.winfo_height() - + 150))) + self.deiconify() # restore hidden window. + + def raise_window(self): + self.grab_set() + self.lift() + + def destroy(self): + self.db.con.close() + if self.master: + self.master.focus_set() + self.master.lift() + super().destroy() + + +class TaskLabel(elements.SimpleLabel): + """Simple sunken text label.""" + + def __init__(self, parent, anchor='center', **kwargs): + super().__init__(master=parent, relief='sunken', anchor=anchor, + **kwargs) + context_menu = RightclickMenu() + self.bind("", context_menu.context_menu_show) + + +class Description(tk.Frame): + """Description frame - Text frame with scroll.""" + + def __init__(self, parent=None, copy_menu=True, paste_menu=False, + state='normal', **options): + super().__init__(master=parent) + self.text = elements.SimpleText(self, bg=GLOBAL_OPTIONS["colour"], + state=state, wrap='word', bd=2, + **options) + scroller = tk.Scrollbar(self) + scroller.config(command=self.text.yview) + self.text.config(yscrollcommand=scroller.set) + scroller.grid(row=0, column=1, sticky='ns') + self.text.grid(row=0, column=0, sticky='news') + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure('all', weight=1) + # Context menu for copying contents: + self.context_menu = RightclickMenu(copy_item=copy_menu, + paste_item=paste_menu) + self.text.bind("", self.context_menu.context_menu_show) + self.text.bind("", lambda e: self.text.tk_focusNext) + + def config(self, cnf=None, **kw): + """Text configuration method.""" + self.text.config(cnf=cnf, **kw) + + def insert(self, text): + self.text.insert('end', text) + + def get(self): + return self.text.get(1.0, 'end') + + def update_text(self, text): + """Refill text field.""" + self.config(state='normal') + self.text.delete(1.0, 'end') + if text is not None: + self.text.insert(1.0, text) + self.config(state='disabled') + + +class TaskFrame(tk.Frame): + """Task frame on application's main screen.""" + + def __init__(self, parent=None): + super().__init__(master=parent, relief='raised', bd=2) + self.db = core.Db() + self.create_content() + self.bind("", lambda e: GLOBAL_OPTIONS["selected_widget"]) + + def create_content(self): + """Creates all window elements.""" + self.startstop_var = tk.StringVar(value="Start") # Text on "Start" button. + # Fake name of running task (which actually is not selected yet). + self.task = None + if not GLOBAL_OPTIONS["compact_interface"]: + self.normal_interface() + # Task name field: + self.task_label = TaskLabel(self, width=50, anchor='w') + elements.big_font(self.task_label, size=elements.FONTSIZE + 3) + self.task_label.grid(row=1, column=0, columnspan=5, padx=5, pady=5, + sticky='ew') + self.open_button = elements.TaskButton(self, text="Task...", + command=self.name_dialogue) + self.open_button.grid(row=1, column=5, padx=5, pady=5, sticky='e') + self.start_button = elements.CanvasButton( + self, state='disabled', + fontsize=elements.FONTSIZE + 4, + command=self.start_stop, + variable=self.startstop_var, + image=os.curdir + '/resource/start_disabled.png' + if tk.TkVersion >= 8.6 + else os.curdir + '/resource/start_disabled.pgm', + opacity='left') + self.start_button.grid(row=3, column=0, sticky='wsn', padx=5) + # Counter frame: + self.timer_label = TaskLabel(self, width=16, state='disabled') + elements.big_font(self.timer_label, size=elements.FONTSIZE + 8) + self.timer_label.grid(row=3, column=1, pady=5) + self.add_timestamp_button = elements.CanvasButton( + self, + text='Add\ntimestamp', + state='disabled', + command=self.add_timestamp + ) + self.add_timestamp_button.grid(row=3, sticky='sn', column=2, padx=5) + self.timestamps_window_button = elements.CanvasButton( + self, + text='View\ntimestamps...', + state='disabled', + command=self.timestamps_window + ) + self.timestamps_window_button.grid(row=3, column=3, sticky='wsn', + padx=5) + self.properties_button = elements.TaskButton( + self, text="Properties...", textwidth=11, state='disabled', + command=self.properties_window) + self.properties_button.grid(row=3, column=4, sticky='e', padx=5) + # Clear frame button: + self.clear_button = elements.TaskButton(self, text='Clear', + state='disabled', textwidth=7, + command=self.clear) + self.clear_button.grid(row=3, column=5, sticky='e', padx=5) + self.running = False + self.paused = False + + def normal_interface(self): + """Creates elements which are visible only in full interface mode.""" + # 'Task name' text: + self.l1 = tk.Label(self, text='Task name:') + elements.big_font(self.l1, size=elements.FONTSIZE + 2) + self.l1.grid(row=0, column=1, columnspan=3) + # Task description field: + self.description_area = Description(self, width=60, height=3) + self.description_area.grid(row=2, column=0, columnspan=6, padx=5, + pady=6, sticky='we') + if self.task: + self.description_area.update_text(self.task['descr']) + + def small_interface(self): + """Destroy some interface elements when switching to 'compact' mode.""" + for widget in (self.l1, self.description_area): + widget.destroy() + if hasattr(self, "description_area"): + delattr(self, "description_area") + + def timestamps_window(self): + """Timestamps window opening.""" + TimestampsWindow(self.task["id"], self.task["spent_total"], + ROOT_WINDOW) + + def add_timestamp(self, event_type=core.LOG_EVENTS["CUSTOM"], + comment=None): + """Adding timestamp to database.""" + # Need to preserve time as it was at the moment of function calling: + timestamp = self.task["spent_total"] + current_time = core.date_format(datetime.datetime.now(), + core.DATE_STORAGE_TEMPLATE) + show_message = False + if comment is None: + apply_var = tk.BooleanVar() + comment_var = tk.StringVar() + TimestampCommentWindow(self, comment_var=comment_var, + apply_var=apply_var) + if apply_var.get(): + show_message = True + comment = comment_var.get() + if comment is not None: + self.db.insert('timestamps', + ('task_id', 'timestamp', 'event_type', + 'datetime', 'comment'), + (self.task["id"], timestamp, event_type, + current_time, comment)) + if show_message: + showinfo("Timestamp added", "Timestamp added.") + + def start_stop(self): + """Changes "Start/Stop" button state. """ + if self.running: + self.timer_stop() + else: + self.timer_start() + + def properties_window(self): + """Task properties window.""" + edited_var = tk.IntVar() + TaskEditWindow(self.task["id"], parent=ROOT_WINDOW, + variable=edited_var) + if edited_var.get() == 1: + self.update_description() + + def clear(self): + """Recreation of frame contents.""" + message = "Task frame cleared." + self.timer_stop(log_message=message) + if self.paused: + self.add_timestamp(core.LOG_EVENTS["STOP"], message) + if len(get_paused_taskframes()) == 0: + ROOT_WINDOW.change_paused_state() + if self.task: + GLOBAL_OPTIONS["tasks"].pop(self.task["id"]) + if GLOBAL_OPTIONS["preserve_tasks"]: + self.db.update_preserved_tasks(GLOBAL_OPTIONS["tasks"]) + for w in self.winfo_children(): + w.destroy() + self.create_content() + ROOT_WINDOW.taskframes.fill() + + def name_dialogue(self): + """Task selection window.""" + var = tk.IntVar() + TaskSelectionWindow(ROOT_WINDOW, taskvar=var) + if var.get(): + self.get_task_name(var.get()) + + def get_task_name(self, task_id): + """Getting selected task's name.""" + # Checking if task is already open in another frame: + if task_id not in GLOBAL_OPTIONS["tasks"]: + # Checking if there is open task in this frame: + if self.task: + # Stopping current timer and saving its state: + self.timer_stop() + # If there is open task, we remove it from running tasks set: + GLOBAL_OPTIONS["tasks"].pop(self.task["id"]) + if self.paused: + self.paused = False + self.add_timestamp(core.LOG_EVENTS["STOP"], + "Another task opened in the frame.") + if len(get_paused_taskframes()) == 0: + ROOT_WINDOW.change_paused_state() + self.get_restored_task_name(task_id) + else: + # If selected task is already opened in another frame: + message = "Task exists", "Task is already opened." + if not self.task: + showinfo(*message) + else: + if self.task["id"] != task_id: + showinfo(*message) + + def get_restored_task_name(self, taskid): + # Preparing new task: + self.set_task_data(taskid) + self.prepare_task() + + def set_task_data(self, taskid): + """Get task data from database""" + self.task = self.db.select_task(taskid) # Task parameters from database + + def prepare_task(self): + """Prepares frame elements to work with.""" + self.current_date = core.today() + # Adding task id and state to dictionary of running tasks: + GLOBAL_OPTIONS["tasks"][self.task["id"]] = False + self.configure_indicator() + self.task_label.config(text=self.task["name"]) + self.start_button.config(state='normal') + self.start_button.config(image=os.curdir + '/resource/start_normal.png' + if tk.TkVersion >= 8.6 + else os.curdir + '/resource/start_normal.pgm') + self.properties_button.config(state='normal') + self.clear_button.config(state='normal') + self.timer_label.config(state='normal') + self.add_timestamp_button.config(state='normal') + self.timestamps_window_button.config(state='normal') + if hasattr(self, "description_area"): + self.description_area.update_text(self.task["descr"]) + if GLOBAL_OPTIONS["preserve_tasks"]: + self.db.update_preserved_tasks(GLOBAL_OPTIONS["tasks"]) + + def configure_indicator(self): + """Configure timer indicator depending on time displaying options value.""" + if self.task: + if GLOBAL_OPTIONS["show_today"]: + spent = self.task["spent_today"] + else: + spent = self.task["spent_total"] + self.timer_label.config(text=core.time_format(spent)) + + def task_update(self): + """Updates time in the database.""" + res = self.db.update_task(self.task["id"], + value=self.task["spent_today"], + prev_date=self.current_date) + if res: + self.current_date = res.current_date + self.task["spent_today"] = res.remained + + def timer_update(self, counter=0): + """Renewal of the counter.""" + spent = time.time() - self.start_time + self.task["spent_today"] += spent + self.task["spent_total"] += spent + self.start_time = time.time() + self.configure_indicator() + # Every n seconds counter value is saved in database: + if counter >= GLOBAL_OPTIONS["SAVE_INTERVAL"]: + self.task_update() + counter = 0 + else: + counter += GLOBAL_OPTIONS["TIMER_INTERVAL"] + # self.timer variable becomes ID created by after(): + self.timer = self.timer_label.after( + GLOBAL_OPTIONS["TIMER_INTERVAL"], self.timer_update, counter) + + def timer_start(self, log=True, stop_all=True): + """Counter start.""" + if not self.running: + self.start_button.config( + image=os.curdir + '/resource/stop.png' if tk.TkVersion >= 8.6 + else os.curdir + '/resource/stop.pgm') + self.startstop_var.set("Stop") + was_paused = False + if log: + if self.paused: + event_id = core.LOG_EVENTS["RESUME"] + comment = "Task resumed." + was_paused = True + else: + event_id = core.LOG_EVENTS["START"] + comment = "Task started." + self.add_timestamp(event_id, comment) + if GLOBAL_OPTIONS["toggle_tasks"]: + if stop_all and not was_paused: + ROOT_WINDOW.stop_all() + GLOBAL_OPTIONS["tasks"][self.task["id"]] = True + self.current_date = core.today() + self.set_task_data(self.task["id"]) + self.configure_indicator() + # Setting current timestamp: + self.start_time = time.time() + self.running = True + self.paused = False + if not get_paused_taskframes(): + ROOT_WINDOW.change_paused_state() + self.timer_update() + + def timer_stop(self, log=True, log_message=None, paused=False): + """Stop counter and save its value to database.""" + event_id = core.LOG_EVENTS["STOP"] + comment = "Task stopped." if not log_message else log_message + if self.paused: + if log: + self.add_timestamp(event_id, comment) + if self.running: + # after_cancel() stops execution of callback with given ID. + self.timer_label.after_cancel(self.timer) + self.running = False + GLOBAL_OPTIONS["tasks"][self.task["id"]] = False + # Writing value into database: + self.task_update() + self.update_description() + if paused: + event_id = core.LOG_EVENTS["PAUSE"] + comment = "Task paused." + self.start_button.config( + image=os.curdir + '/resource/start_normal.png' + if tk.TkVersion >= 8.6 + else os.curdir + '/resource/start_normal.pgm') + self.startstop_var.set("Start") + if log: + self.add_timestamp(event_id, comment) + self.paused = paused + + def update_description(self): + """Update text in "Description" field.""" + self.task["descr"] = \ + self.db.find_by_clause("tasks", "id", self.task["id"], + "description")[0][0] + if hasattr(self, "description_area"): + self.description_area.update_text(self.task["descr"]) + + def destroy(self): + """Closes frame and writes counter value into database.""" + message = "Task stopped on application exit." + self.timer_stop(log_message=message) + if self.paused: + self.add_timestamp(core.LOG_EVENTS["STOP"], message) + if self.task: + GLOBAL_OPTIONS["tasks"].pop(self.task["id"]) + self.db.con.close() + tk.Frame.destroy(self) + + +class TimestampCommentWindow(Window): + """Task properties window.""" + + def __init__(self, parent=None, comment_var=None, apply_var=None, + **options): + super().__init__(master=parent, **options) + self.comment_var = comment_var + self.apply_var = apply_var + self.title("Timestamp comment") + elements.SimpleLabel(self, text="Enter comment:", fontsize=elements.FONTSIZE + 1).grid( + row=0, column=0, columnspan=2, pady=5, padx=5, sticky='we') + self.comment_area = elements.SimpleEntry(self) + self.comment_area.config(state='normal', bg='white') + self.comment_area.grid(row=1, column=0, columnspan=2, sticky='we') + + tk.Frame(self, height=40).grid(row=2) + elements.TaskButton(self, text='Ok', command=self.get_comment).grid( + row=3, column=0, sticky='sw', padx=5, pady=5) + elements.TaskButton(self, text='Cancel', command=self.destroy).grid( + row=3, column=1, sticky='se', padx=5, pady=5) + context_menu = RightclickMenu(paste_item=1, copy_item=0) + self.comment_area.bind("", context_menu.context_menu_show) + self.comment_area.bind("", lambda e: self.get_comment()) + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=1) + self.resizable(height=0, width=1) + self.minsize(width=500, height=10) + self.comment_area.focus_set() + self.prepare() + + def get_comment(self): + self.apply_var.set(True) + self.comment_var.set(self.comment_area.get()) + self.destroy() + + +class Table(tk.Frame): + + def __init__(self, columns, parent=None, **options): + super().__init__(master=parent, **options) + self.table = ttk.Treeview(self) + style = ttk.Style() + style.configure(".", font=('Helvetica', elements.FONTSIZE + 1)) + style.configure("Treeview.Heading", font=('Helvetica', elements.FONTSIZE + 1)) + scroller = tk.Scrollbar(self) + scroller.config(command=self.table.yview) + self.table.config(yscrollcommand=scroller.set) + scroller.pack(side='right', fill='y') + self.table.pack(fill='both', expand=1) + # Creating and naming columns: + self.table.config(columns=tuple([key for key in columns])) + for name in columns: + # Configuring columns with given ids: + self.table.column(name, width=100, minwidth=100, + anchor='center') + # Configuring headers of columns with given ids: + self.table.heading(name, text=columns[name], + command=lambda c=name: + self.sort_table_contents(c, True)) + self.table.column('#0', anchor='w', width=70, minwidth=50, + stretch=0) + + def _sort(self, position, reverse): + l = [] + for index, task in enumerate(self.table.get_children()): + l.append((self.data[index][position], task)) + # Sort tasks list by corresponding field to match current sorting: + self.data.sort(key=lambda x: x[position], reverse=reverse) + return l + + def sort_table_contents(self, col, reverse): + """Should be redefined by successors.""" + pass + + def focus_(self, item): + """Focuses on the row with provided id.""" + self.table.see(item) + self.table.selection_set(item) + self.table.focus_set() + self.table.focus(item) + + def update_data(self, data): + """Update contents of internal data used to fill the table.""" + for item in self.table.get_children(): + self.table.delete(item) + self.data = copy.deepcopy(data) + + def insert_rows(self, data): + """Insert rows in the table. Row contents + are tuples provided by 'values='.""" + for i, v in enumerate(data): # item, number, value: + self.table.insert('', i, text="#%d" % (i + 1), values=v) + + def select_all(self): + self.table.selection_set( + self.table.get_children()) + + def clear_all(self): + self.table.selection_remove( + self.table.get_children()) + + +class TaskTable(Table): + """Scrollable tasks table.""" + + def __init__(self, columns, parent=None, **options): + super().__init__(columns, parent=parent, **options) + self.table.column('taskname', width=600, anchor='w') + + def sort_table_contents(self, col, reverse): + """Sorting by click on column header.""" + if col == "spent_time": + shortlist = self._sort(1, reverse) + elif col == "creation_date": + shortlist = self._sort(2, reverse) + else: + shortlist = self._sort(0, reverse) + shortlist.sort(key=lambda x: x[0], reverse=reverse) + for index, value in enumerate(shortlist): + self.table.move(value[1], '', index) + self.table.heading(col, command=lambda: + self.sort_table_contents(col, not reverse)) + + def update_tasks_list(self, data): + """Refill table contents.""" + self.update_data(data) + for t in data: + t[1] = core.time_format(t[1]) + t[2] = core.table_date_format(t[2]) + self.insert_rows(data) + + +class TaskSelectionWindow(Window): + """Task selection and creation window.""" + + def __init__(self, parent=None, taskvar=None, **options): + super().__init__(master=parent, **options) + # Variable which will contain selected task id: + if taskvar: + self.task_id_var = taskvar + # Basic script for retrieving tasks from database: + self.main_script = 'SELECT id, name, total_time, description, ' \ + 'creation_date FROM tasks JOIN (SELECT task_id, ' \ + 'sum(spent_time) AS total_time FROM activity ' \ + 'GROUP BY task_id) AS act ON act.task_id=tasks.id' + self.title("Task selection") + self.minsize(width=500, height=350) + elements.SimpleLabel(self, text="New task:").grid(row=0, column=0, + sticky='w', pady=5, + padx=5) + # New task entry field: + self.add_entry = elements.SimpleEntry(self, width=50) + self.add_entry.grid(row=0, column=1, columnspan=3, sticky='we') + # Enter adds new task: + self.add_entry.bind('', lambda event: self.add_new_task()) + self.add_entry.focus_set() + # Context menu with 'Paste' option: + addentry_context_menu = RightclickMenu(paste_item=1, copy_item=0) + self.add_entry.bind("", + addentry_context_menu.context_menu_show) + # "Add task" button: + self.add_button = elements.TaskButton(self, text="Add task", + command=self.add_new_task, + takefocus=False) + self.add_button.grid(row=0, column=4, sticky='e', padx=6, pady=5) + # Entry for typing search requests: + self.search_entry = elements.SimpleEntry(self, width=25) + self.search_entry.grid(row=1, column=1, columnspan=2, sticky='we', + padx=5, pady=5) + searchentry_context_menu = RightclickMenu(paste_item=1, copy_item=0) + self.search_entry.bind("", + searchentry_context_menu.context_menu_show) + # Case sensitive checkbutton: + self.ignore_case_var = tk.IntVar(self, value=1) + elements.SimpleCheckbutton(self, text="Ignore case", takefocus=False, + variable=self.ignore_case_var).grid(row=1, + column=0, + padx=6, + pady=5, + sticky='w') + # Search button: + elements.CanvasButton(self, takefocus=False, text='Search', + image=os.curdir + '/resource/magnifier.png' + if tk.TkVersion >= 8.6 + else os.curdir + '/resource/magnifier.pgm', + command=self.locate_task).grid(row=1, column=3, + sticky='w', + padx=5, pady=5) + # Refresh button: + elements.TaskButton(self, takefocus=False, + image=os.curdir + '/resource/refresh.png' + if tk.TkVersion >= 8.6 + else os.curdir + '/resource/refresh.pgm', + command=self.update_table).grid(row=1, column=4, + sticky='e', padx=5, + pady=5) + # Naming of columns in tasks list: + column_names = OrderedDict({'taskname': 'Task name', + 'spent_time': 'Spent time', + 'creation_date': 'Created'}) + # Scrollable tasks table: + self.table_frame = TaskTable(column_names, self, takefocus=True) + self.table_frame.grid(row=2, column=0, columnspan=5, pady=10, + sticky='news') + elements.SimpleLabel(self, text="Summary time:").grid(row=3, column=0, + pady=5, padx=5, + sticky='w') + # Summarized time of all tasks in the table: + self.fulltime_frame = TaskLabel(self, width=16, anchor='center') + self.fulltime_frame.grid(row=3, column=1, padx=6, pady=5, sticky='e') + # Selected task description: + self.description_area = Description(self, height=4) + self.description_area.grid(row=3, column=2, rowspan=2, pady=5, padx=5, + sticky='news') + # "Select all" button: + sel_button = elements.TaskButton(self, text="Select all", + command=self.table_frame.select_all) + sel_button.grid(row=4, column=0, sticky='w', padx=5, pady=5) + # "Clear all" button: + clear_button = elements.TaskButton(self, text="Clear selection", + textwidth=12, + command=self.table_frame.clear_all) + clear_button.grid(row=4, column=1, sticky='e', padx=5, pady=5) + # Task properties button: + self.edit_button = elements.TaskButton(self, text="Properties...", + textwidth=10, command=self.edit) + self.edit_button.grid(row=3, column=3, sticky='w', padx=5, pady=5) + # Remove task button: + self.del_button = elements.TaskButton(self, text="Remove...", + textwidth=10, command=self.delete) + self.del_button.grid(row=4, column=3, sticky='w', padx=5, pady=5) + # Export button: + self.export_button = elements.TaskButton(self, text="Export...", + command=self.export) + self.export_button.grid(row=4, column=4, padx=5, pady=5, sticky='e') + # Filter button: + self.filter_button = elements.TaskButton(self, text="Filter...", + command=self.filterwindow) + self.filter_button.grid(row=3, column=4, padx=5, pady=5, sticky='e') + # Filter button context menu: + filter_context_menu = RightclickMenu(copy_item=False) + filter_context_menu.add_command(label='Clear filter', + command=self.apply_filter) + self.filter_button.bind("", + filter_context_menu.context_menu_show) + tk.Frame(self, height=40).grid(row=5, columnspan=5, sticky='news') + self.grid_columnconfigure(2, weight=1, minsize=50) + self.grid_rowconfigure(2, weight=1, minsize=50) + self.update_table() # Fill table contents. + self.current_task = '' # Current selected task. + self.table_frame.table.bind("", self.descr_down) + self.table_frame.table.bind("", self.descr_up) + self.table_frame.table.bind("", self.descr_click) + self.table_frame.bind("", + lambda e: self.focus_first_item(forced=False)) + # Need to avoid masquerading of default ttk.Treeview action + # on Shift+click and Control+click: + self.modifier_pressed = False + self.table_frame.table.bind("", + lambda e: self.shift_control_pressed()) + self.table_frame.table.bind("", + lambda e: self.shift_control_pressed()) + self.table_frame.table.bind("", + lambda e: self.shift_control_pressed()) + self.table_frame.table.bind("", + lambda e: self.shift_control_pressed()) + self.table_frame.table.bind("", + lambda e: self.shift_control_released()) + self.table_frame.table.bind("", + lambda e: self.shift_control_released()) + self.table_frame.table.bind("", + lambda e: self.shift_control_released()) + self.table_frame.table.bind("", + lambda e: self.shift_control_released()) + self.search_entry.bind("", lambda e: self.locate_task()) + self.bind("", lambda e: self.update_table()) + elements.TaskButton(self, text="Open", command=self.get_task).grid( + row=6, column=0, padx=5, pady=5, sticky='w') + elements.TaskButton(self, text="Cancel", command=self.destroy).grid( + row=6, column=4, padx=5, pady=5, sticky='e') + self.table_frame.table.bind("", self.get_task_id) + self.table_frame.table.bind("", self.get_task_id) + self.prepare() + + def check_row(self, event): + """Check if mouse click is over the row, + not another tasks_table element.""" + if (event.type == '4' and len( + self.table_frame.table.identify_row(event.y)) > 0) or ( + event.type == '2'): + return True + + def get_task(self): + """Get selected task id from database and close window.""" + # List of selected tasks item id's: + tasks = self.table_frame.table.selection() + if tasks: + self.task_id_var.set(self.tdict[tasks[0]]["id"]) + self.destroy() + + def get_task_id(self, event): + """For clicking on buttons and items.""" + if self.check_row(event): + self.get_task() + + def shift_control_pressed(self): + self.modifier_pressed = True + + def shift_control_released(self): + self.modifier_pressed = False + + def focus_first_item(self, forced=True): + """Selects first item in the table if no items selected.""" + if self.table_frame.table.get_children(): + item = self.table_frame.table.get_children()[0] + else: + return + if forced: + self.table_frame.focus_(item) + self.update_descr(item) + else: + if not self.table_frame.table.selection(): + self.table_frame.focus_(item) + self.update_descr(item) + else: + self.table_frame.table.focus_set() + + def locate_task(self): + """Search task by keywords.""" + searchword = self.search_entry.get() + if searchword: + self.table_frame.clear_all() + task_items = [] + if self.ignore_case_var.get(): + for key in self.tdict: + if searchword.lower() in self.tdict[key]["name"].lower(): + task_items.append(key) + # Need to be sure that there is non-empty description: + elif self.tdict[key]["descr"]: + if searchword.lower() in self.tdict[key]["descr"].lower(): + task_items.append(key) + else: + for key in self.tdict: + if searchword in self.tdict[key]["name"]: + task_items.append(key) + elif self.tdict[key]["descr"]: + if searchword in self.tdict[key]["descr"]: + task_items.append(key) + if task_items: + for item in task_items: + self.table_frame.table.selection_add(item) + item = self.table_frame.table.selection()[0] + self.table_frame.table.see(item) + self.table_frame.table.focus_set() + self.table_frame.table.focus(item) + self.update_descr(item) + else: + showinfo("No results", + "No tasks found." + "\nMaybe need to change filter settings?") + + def export(self): + """Export all tasks from the table into the file.""" + ExportWindow(self, self.tdict) + + def add_new_task(self): + """Adds new task into the database.""" + task_name = self.add_entry.get() + if task_name: + for x in ('"', "'", "`"): + task_name = task_name.replace(x, '') + try: + self.db.insert_task(task_name) + except core.DbErrors: + self.db.reconnect() + for item in self.tdict: + if self.tdict[item]["name"] == task_name: + self.table_frame.focus_(item) + self.update_descr(item) + break + else: + showinfo("Task exists", + "Task already exists. " + "Change filter configuration to see it.") + else: + self.update_table() + # If created task appears in the table, highlight it: + for item in self.tdict: + if self.tdict[item]["name"] == task_name: + self.table_frame.focus_(item) + break + else: + showinfo("Task created", + "Task successfully created. " + "Change filter configuration to see it.") + + def filter_query(self): + return self.db.find_by_clause(table='options', field='name', + value='filter', searchfield='value')[0][0] + + def update_table(self): + """Updating table contents using database query.""" + # Restoring filter value: + query = self.filter_query() + if query: + self.filter_button.config(bg='lightblue') + self.db.exec_script(query) + else: + self.filter_button.config(bg=GLOBAL_OPTIONS["colour"]) + self.db.exec_script(self.main_script) + tlist = [{"id": task[0], "name": task[1], "spent_time": task[2], + "descr": task[3], "creation_date": task[4]} + for task in self.db.cur.fetchall()] + self.table_frame.update_tasks_list( + [[f["name"], f["spent_time"], f["creation_date"]] for f in tlist]) + # Dictionary with row ids and tasks info: + self.tdict = {} + for n, task_id in enumerate(self.table_frame.table.get_children()): + self.tdict[task_id] = tlist[n] + self.update_descr(None) + self.update_fulltime() + + def update_fulltime(self): + """Updates value in "fulltime" frame.""" + self.fulltime = core.time_format( + sum([self.tdict[x]["spent_time"] for x in self.tdict])) + self.fulltime_frame.config(text=self.fulltime) + + def descr_click(self, event): + """Updates description for the task with item id of the row + selected by click.""" + pos = self.table_frame.table.identify_row(event.y) + if pos and pos != '#0' and not self.modifier_pressed: + self.table_frame.focus_(pos) + self.update_descr(self.table_frame.table.focus()) + + def descr_up(self, event): + """Updates description for the item id which is BEFORE selected.""" + item = self.table_frame.table.focus() + prev_item = self.table_frame.table.prev(item) + if prev_item == '': + self.update_descr(item) + else: + self.update_descr(prev_item) + + def descr_down(self, event): + """Updates description for the item id which is AFTER selected.""" + item = self.table_frame.table.focus() + next_item = self.table_frame.table.next(item) + if next_item == '': + self.update_descr(item) + else: + self.update_descr(next_item) + + def update_descr(self, item): + """Filling task description frame.""" + if item is None: + self.description_area.update_text('') + elif item != '': + self.description_area.update_text(self.tdict[item]["descr"]) + + def delete(self): + """Remove selected tasks from the database and the table.""" + ids = [self.tdict[x]["id"] for x in self.table_frame.table.selection() + if self.tdict[x]["id"] not in GLOBAL_OPTIONS["tasks"]] + items = [x for x in self.table_frame.table.selection() if + self.tdict[x]["id"] in ids] + if ids: + answer = askyesno("Warning", + "Are you sure you want to delete selected tasks?", + parent=self) + if answer: + self.db.delete_tasks(tuple(ids)) + self.table_frame.table.delete(*items) + for item in items: + self.tdict.pop(item) + self.update_descr(None) + self.update_fulltime() + + def edit(self): + """Show task edit window.""" + item = self.table_frame.table.focus() + try: + id_name = {"id": self.tdict[item]["id"], + "name": self.tdict[item]["name"]} + except KeyError: + pass + else: + task_changed = tk.IntVar() + TaskEditWindow(id_name["id"], self, variable=task_changed) + if task_changed.get() == 1: + # Reload task information from database: + new_task_info = self.db.select_task(id_name["id"]) + # Update description: + self.tdict[item]["descr"] = new_task_info["descr"] + self.update_descr(item) + self.raise_window() + + def filterwindow(self): + """Open filters window.""" + filter_changed = tk.IntVar() + FilterWindow(self, variable=filter_changed) + # Update tasks list only if filter parameters have been changed: + if filter_changed.get() == 1: + self.apply_filter(GLOBAL_OPTIONS["filter_dict"]['operating_mode'], + GLOBAL_OPTIONS["filter_dict"]['script'], + GLOBAL_OPTIONS["filter_dict"]['tags'], + GLOBAL_OPTIONS["filter_dict"]['dates']) + self.raise_window() + + def apply_filter(self, operating_mode='AND', script=None, tags='', + dates=''): + """Record filter parameters to database and apply it.""" + update = self.filter_query() + self.db.update('filter_operating_mode', field='value', + value=operating_mode, table='options', updfield='name') + self.db.update('filter', field='value', value=script, table='options', + updfield='name') + self.db.update('filter_tags', field='value', + value=','.join([str(x) for x in tags]), table='options', + updfield='name') + self.db.update('filter_dates', field='value', value=','.join(dates), + table='options', updfield='name') + if update != self.filter_query(): + self.update_table() + + +class TaskEditWindow(Window): + """Task properties window.""" + + def __init__(self, taskid, parent=None, variable=None, **options): + super().__init__(master=parent, **options) + # Connected with external IntVar. + # Needed to avoid unnecessary operations in parent window: + self.change_var = variable + # Task information from database: + self.task = self.db.select_task(taskid) + # List of dates connected with this task: + dates = [x[0] + " - " + core.time_format(x[1]) for x in + self.db.find_by_clause('activity', 'task_id', '%s' % + taskid, 'date, spent_time', 'date')] + self.title("Task properties: {}".format( + self.db.find_by_clause('tasks', 'id', taskid, 'name')[0][0])) + self.minsize(width=400, height=300) + elements.SimpleLabel(self, text="Task name:", fontsize=elements.FONTSIZE + 1).grid( + row=0, column=0, pady=5, padx=5, sticky='w') + # Frame containing task name: + TaskLabel(self, width=60, height=1, bg=GLOBAL_OPTIONS["colour"], + text=self.task["name"], + anchor='w').grid(row=1, columnspan=5, sticky='ew', padx=6) + tk.Frame(self, height=30).grid(row=2) + elements.SimpleLabel(self, text="Description:", fontsize=elements.FONTSIZE + 1).grid( + row=3, column=0, pady=5, padx=5, sticky='w') + # Task description frame. Editable: + self.description_area = Description(self, paste_menu=True, width=60, + height=6) + self.description_area.config(state='normal', bg='white') + if self.task["descr"]: + self.description_area.insert(self.task["descr"]) + self.description_area.grid(row=4, columnspan=5, sticky='ewns', padx=5) + # + elements.SimpleLabel(self, text='Tags:').grid(row=5, column=0, pady=5, + padx=5, sticky='nw') + # Place tags list: + self.tags_update() + elements.TaskButton(self, text='Edit tags', textwidth=10, + command=self.tags_edit).grid(row=5, column=4, + padx=5, pady=5, + sticky='e') + elements.SimpleLabel(self, text='Time spent:').grid(row=6, column=0, + padx=5, pady=5, + sticky='w') + # Frame containing time: + TaskLabel(self, width=16, + text='{}'.format( + core.time_format(self.task["spent_total"]))).grid( + row=6, column=1, pady=5, padx=5, sticky='w') + elements.SimpleLabel(self, text='Dates:').grid(row=6, column=2, + sticky='w') + # Frame containing list of dates connected with current task: + date_list = Description(self, height=3, width=30) + date_list.update_text('\n'.join(dates)) + date_list.grid(row=6, column=3, rowspan=3, columnspan=2, sticky='ew', + padx=5, pady=5) + # + tk.Frame(self, height=40).grid(row=9) + elements.TaskButton(self, text='Ok', command=self.update_task).grid( + row=10, column=0, sticky='sw', padx=5, pady=5) + elements.TaskButton(self, text='Cancel', command=self.destroy).grid( + row=10, column=4, sticky='se', padx=5, pady=5) + self.grid_columnconfigure(1, weight=1) + self.grid_columnconfigure(3, weight=10) + self.grid_rowconfigure(4, weight=1) + self.description_area.text.focus_set() + self.prepare() + + def tags_edit(self): + """Open tags editor window.""" + TagsEditWindow(self) + self.tags_update() + self.grab_set() + self.lift() + self.focus_set() + + def tags_update(self): + """Tags list placing.""" + # Tags list. Tags state are saved to database: + self.tags = Tagslist(self.db.tags_dict(self.task["id"]), self, + orientation='horizontal', width=300, height=30) + self.tags.grid(row=5, column=1, columnspan=3, pady=5, padx=5, + sticky='we') + + def update_task(self): + """Update task in database.""" + task_data = self.description_area.get().rstrip() + self.db.update_task(self.task["id"], field='description', + value=task_data) + # Renew tags list for the task: + existing_tags = [x[0] for x in + self.db.find_by_clause('tasks_tags', 'task_id', + self.task["id"], 'tag_id')] + for item in self.tags.states_list: + if item[1][0].get() == 1: + if item[0] not in existing_tags: + self.db.insert('tasks_tags', ('task_id', 'tag_id'), + (self.task["id"], item[0])) + else: + self.db.delete(table="tasks_tags", task_id=self.task["id"], + tag_id=item[0]) + # Reporting to parent window that task has been changed: + if self.change_var: + self.change_var.set(1) + self.destroy() + + +class TagsEditWindow(Window): + """Checkbuttons editing window.""" + + def __init__(self, parent=None, **options): + super().__init__(master=parent, **options) + self.parent = parent + self.addentry() + self.tags_update() + self.close_button = elements.TaskButton(self, text='Close', + command=self.destroy) + self.delete_button = elements.TaskButton(self, text='Delete', + command=self.delete) + self.maxsize(width=500, height=500) + self.window_elements_config() + self.prepare() + + def window_elements_config(self): + """Window additional parameters configuration.""" + self.title("Tags editor") + self.minsize(width=300, height=300) + self.close_button.grid(row=2, column=2, pady=5, padx=5, sticky='e') + self.delete_button.grid(row=2, column=0, pady=5, padx=5, sticky='w') + + def addentry(self): + """New element addition field""" + self.addentry_label = elements.SimpleLabel(self, text="Add tag:") + self.addentry_label.grid(row=0, column=0, pady=5, padx=5, sticky='w') + elements.TaskButton(self, text='Add', command=self.add).grid( + row=0, column=2, pady=5, padx=5, sticky='e') + self.addfield = elements.SimpleEntry(self, width=20) + self.addfield.grid(row=0, column=1, sticky='ew') + self.addfield.focus_set() + self.addfield.bind('', lambda event: self.add()) + + def tags_update(self): + """Tags list recreation.""" + if hasattr(self, 'tags'): + self.tags.destroy() + self.tags_get() + self.tags.grid(row=1, column=0, columnspan=3, sticky='news') + self.grid_rowconfigure(1, weight=1) + self.grid_columnconfigure(1, weight=1) + + def add(self): + """Insert new element into database.""" + tagname = self.addfield.get() + if tagname: + try: + self.add_record(tagname) + except core.DbErrors: + self.db.reconnect() + else: + self.tags_update() + + def delete(self): + """Remove selected elements from database.""" + dellist = [] + for item in self.tags.states_list: + if item[1][0].get() == 1: + dellist.append(item[0]) + if dellist: + answer = askyesno("Really delete?", + "Are you sure you want to delete selected items?", + parent=self) + if answer: + self.del_record(dellist) + self.tags_update() + + def tags_get(self): + self.tags = Tagslist(self.db.simple_tagslist(), self, width=300, + height=300) + + def add_record(self, tagname): + self.db.insert('tags', ('id', 'name'), (None, tagname)) + + def del_record(self, dellist): + self.db.delete(id=dellist, table='tags') + self.db.delete(tag_id=dellist, table='tasks_tags') + + +class TimestampsTable(Table): + + def __init__(self, columns, parent=None, **options): + super().__init__(columns, parent=parent, **options) + self.table.column('stamp', width=200, anchor='w') + self.table.column('since', width=150, anchor='w') + self.table.column('comment', width=450, anchor='w') + self.table.column('real', width=250, anchor='w') + + def sort_table_contents(self, col, reverse): + """Sorting by click on column header.""" + if col == "stamp": + shortlist = self._sort(0, reverse) + elif col == "real": + shortlist = self._sort(1, reverse) + elif col == "since": + shortlist = self._sort(2, reverse) + else: + return + shortlist.sort(key=lambda x: x[0], reverse=reverse) + for index, value in enumerate(shortlist): + self.table.move(value[1], '', index) + self.table.heading( + col, command=lambda: self.sort_table_contents(col, not reverse)) + + def update_timestamps_list(self, data): + """Refill table contents.""" + self.update_data(data) + for t in data: + t[0] = core.time_format(int(t[0])) + t[1] = core.table_date_format(t[1]) + t[2] = core.time_format(int(t[2])) + self.insert_rows(data) + + +class TimestampsWindow(Window): + """Window with timestamps for selected task.""" + + def __init__(self, taskid, task_time, parent=None, **options): + super().__init__(master=parent, **options) + self.task_id = taskid + self.task_time = task_time + self.title("Timestamps: {}".format( + self.db.find_by_clause('tasks', 'id', self.task_id, 'name')[0][0])) + column_names = OrderedDict({"stamp": "Timestamp", + "real": "Date and time", + "since": "Time spent since", + "comment": "Comment"}) + self.stamps_frame = TimestampsTable(column_names, parent=self) + self.stamps_frame.grid(row=0, column=0, columnspan=2, sticky='news') + elements.TaskButton(self, text="Select all", + command=self.stamps_frame.select_all).grid( + row=1, column=0, + pady=5, padx=5, + sticky='w') + elements.TaskButton(self, text="Clear selection", textwidth=12, + command=self.stamps_frame.clear_all).grid( + row=1, column=1, + pady=5, padx=5, + sticky='e') + tk.Frame(self, height=40).grid(row=2) + self.update_table() + elements.TaskButton( + self, text="Delete...", command=self.delete).grid( + row=3, column=0, pady=5, padx=5, sticky='w') + elements.TaskButton(self, text="Close", command=self.destroy).grid( + row=3, column=1, pady=5, padx=5, sticky='e') + self.grid_columnconfigure(1, weight=1, minsize=500) + self.grid_rowconfigure(0, weight=1, minsize=300) + self.minsize(width=710, height=500) + self.prepare() + + def update_table(self): + db_contents = self.db.find_by_clause("timestamps", "task_id", + self.task_id, "*") + tlist = [{"stamp": timestamp[0], "datetime": timestamp[3], + "comment": timestamp[4], + "spent_since": self.task_time - timestamp[0]} + for timestamp in db_contents] + self.stamps_frame.update_timestamps_list([[f["stamp"], f["datetime"], + f["spent_since"], + f["comment"]] for f in tlist]) + self.sdict = {} + for n, task_id in enumerate(self.stamps_frame.table.get_children()): + self.sdict[task_id] = tlist[n] + + def delete(self): + """Deletes selected timestamps.""" + ids = self.stamps_frame.table.selection() + dates = [self.sdict[x]["datetime"] for x in ids] + if ids: + answer = askyesno("Warning", + "Are you sure you want to delete " + "selected timestamps?", + parent=self) + if answer: + for x in dates: + self.db.delete(table="timestamps", datetime=x, + task_id=self.task_id) + + self.stamps_frame.table.delete(*ids) + for item in ids: + self.sdict.pop(item) + + +class HelpWindow(Window): + """Help window.""" + + def __init__(self, parent=None, text='', **options): + super().__init__(master=parent, **options) + self.title("Help") + main_frame = tk.Frame(self) + self.help_area = Description(main_frame, fontsize=elements.FONTSIZE + 2) + self.help_area.insert(text) + self.help_area.config(state='disabled') + self.help_area.grid(row=0, column=0, sticky='news') + main_frame.grid(row=0, column=0, sticky='news', padx=5, pady=5) + main_frame.grid_rowconfigure(0, weight=1) + main_frame.grid_columnconfigure(0, weight=1) + elements.TaskButton(self, text='OK', command=self.destroy).grid( + row=1, column=0, sticky='e', pady=5, padx=5) + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + self.bind("", lambda e: self.destroy()) + self.prepare() + + +class Tagslist(elements.ScrolledCanvas): + """Tags list. Accepts tagslist: [[tag_id, [state, 'tagname']]], + can be 0 or 1.""" + + def __init__(self, tagslist, parent=None, orientation="vertical", + **options): + super().__init__(parent=parent, orientation=orientation, **options) + self.states_list = tagslist + for item in self.states_list: + # Saving tag state: + state = item[1][0] + # Inserting dynamic variable instead of the state: + item[1][0] = tk.IntVar() + # Connecting new checkbox with this dynamic variable: + cb = elements.SimpleCheckbutton(self.content_frame, text=( + item[1][1] + ' ' * 3 if orientation == "horizontal" + else item[1][1]), variable=item[1][0]) + cb.pack(side=('left' if orientation == "horizontal" else 'bottom'), + anchor='w') + # Setting dynamic variable value to previously saved state: + item[1][0].set(state) + interface_items = [self.canvbox, *get_all_widget_children(self.content_frame, [])] + for item in interface_items: + item.bind("", self.mouse_scroll) # for Windows/OS X + item.bind("", self.mouse_scroll) # for Linux + item.bind("", self.mouse_scroll) + + +class FilterWindow(Window): + """Filters window.""" + + def __init__(self, parent=None, variable=None, **options): + super().__init__(master=parent, **options) + self.title("Filter") + # IntVar instance: used to set 1 if some changes were made. + # For optimization. + self.changed_var = variable + # Operating mode of the filter: "AND", "OR". + self.operating_mode_var = tk.StringVar() + # Lists of stored filter parameters: + stored_dates = self.db.find_by_clause( + 'options', 'name', 'filter_dates', 'value')[0][0].split(',') + stored_tags = str(self.db.find_by_clause( + 'options', 'name', 'filter_tags', 'value')[0][0]).split(',') + if stored_tags[0]: # stored_tags[0] is string. + stored_tags = list(map(int, stored_tags)) + # Dates list: + dates = self.db.simple_dateslist() + # Tags list: + tags = self.db.simple_tagslist() + # Checking checkboxes according to their values loaded from database: + for tag in tags: + if tag[0] in stored_tags: + tag[1][0] = 1 + elements.SimpleLabel(self, text="Dates").grid(row=0, column=0, + sticky='n') + elements.SimpleLabel(self, text="Tags").grid(row=0, column=1, + sticky='n') + self.dates_list = Tagslist( + [[x, [1 if x in stored_dates else 0, x]] for x in dates], self, + width=200, height=300) + self.tags_list = Tagslist(tags, self, width=200, height=300) + self.dates_list.grid(row=1, column=0, pady=5, padx=5, sticky='news') + self.tags_list.grid(row=1, column=1, pady=5, padx=5, sticky='news') + elements.TaskButton(self, text="Select dates...", textwidth=15, + command=self.select_dates).grid(row=2, column=0, + pady=7, padx=5, + sticky='n') + elements.TaskButton(self, text="Clear", command=self.clear_tags).grid( + row=2, column=1, pady=7, padx=5, sticky='n') + elements.TaskButton(self, text="Clear", command=self.clear_dates).grid( + row=3, column=0, pady=7, padx=5, sticky='n') + tk.Frame(self, height=20).grid(row=5, column=0, columnspan=2, + sticky='news') + elements.SimpleLabel(self, text="Filter operating mode:").grid( + row=5, columnspan=2, pady=5) + check_frame = tk.Frame(self) + check_frame.grid(row=7, columnspan=2, pady=5) + elements.SimpleRadiobutton(check_frame, text="AND", + variable=self.operating_mode_var, + value="AND").grid(row=0, column=0, + sticky='e') + elements.SimpleRadiobutton(check_frame, text="OR", + variable=self.operating_mode_var, + value="OR").grid(row=0, column=1, + sticky='w') + self.operating_mode_var.set( + self.db.find_by_clause(table="options", field="name", + value="filter_operating_mode", + searchfield="value")[0][0]) + tk.Frame(self, height=20).grid(row=8, column=0, columnspan=2, + sticky='news') + elements.TaskButton(self, text="Cancel", command=self.destroy).grid( + row=9, column=1, pady=5, padx=5, sticky='e') + elements.TaskButton(self, text='Ok', command=self.apply_filter).grid( + row=9, column=0, pady=5, padx=5, sticky='w') + self.bind("", lambda e: self.apply_filter()) + self.minsize(height=350, width=350) + self.maxsize(width=750, height=600) + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=5) + self.grid_rowconfigure(1, weight=1) + self.prepare() + + def clear_dates(self): + for x in self.dates_list.states_list: + x[1][0].set(0) + + def clear_tags(self): + for x in self.tags_list.states_list: + x[1][0].set(0) + + def select_dates(self): + """Pops up window where user can select dates interval.""" + start_date = tk.StringVar(self) + end_date = tk.StringVar(self) + correct = tk.DoubleVar(self) + CalendarWindow(self, correct, startvar=start_date, endvar=end_date, + startdate=self.dates_list.states_list[-1][0], + enddate=self.dates_list.states_list[0][0]) + if correct.get(): + for date in self.dates_list.states_list: + date[1][0].set(0) + if core.str_to_date(start_date.get()) <= core.str_to_date( + date[0]) <= core.str_to_date(end_date.get()): + date[1][0].set(1) + + def apply_filter(self): + """Create database script based on checkboxes values.""" + dates = list(reversed( + [x[0] for x in self.dates_list.states_list if x[1][0].get() == 1])) + tags = list(reversed( + [x[0] for x in self.tags_list.states_list if x[1][0].get() == 1])) + if not dates and not tags: + script = None + self.operating_mode_var.set("AND") + else: + script = core.prepare_filter_query(dates, tags, + self.operating_mode_var.get()) + GLOBAL_OPTIONS["filter_dict"] = { + 'operating_mode': self.operating_mode_var.get(), + 'script': script, + 'tags': tags, + 'dates': dates + } + # Reporting to parent window that filter values have been changed: + if self.changed_var: + self.changed_var.set(1) + self.destroy() + + +class CalendarWindow(Window): + def __init__(self, parent=None, correct_data=None, startvar=None, + endvar=None, startdate=None, enddate=None, **options): + super().__init__(master=parent, **options) + self.title("Select dates") + self.correct_data = correct_data + self.start_var = startvar + self.end_var = endvar + self.start_date_entry = sel_cal.Datepicker( + self, datevar=self.start_var, + current_month=core.str_to_date(startdate).month, + current_year=core.str_to_date(startdate).year) + self.end_date_entry = sel_cal.Datepicker( + self, datevar=self.end_var, + current_month=core.str_to_date(enddate).month, + current_year=core.str_to_date(enddate).year) + elements.SimpleLabel(self, text="Enter first date:").grid(row=0, + column=0, + pady=3, + padx=5, + sticky='w') + self.start_date_entry.grid(row=1, column=0, padx=5, pady=3, sticky='w') + elements.SimpleLabel(self, text="Enter last date:").grid(row=2, + column=0, + pady=5, + padx=5, + sticky='w') + self.end_date_entry.grid(row=3, column=0, padx=5, pady=3, sticky='w') + tk.Frame(self, height=15, width=10).grid(row=4, column=0, columnspan=2) + elements.TaskButton(self, text='OK', command=self.close).grid( + row=5, column=0, padx=5, pady=5, sticky='w') + elements.TaskButton(self, text='Cancel', command=self.destroy).grid( + row=5, column=1, padx=5, pady=5, sticky='e') + self.bind("", lambda e: self.close()) + self.minsize(height=350, width=450) + self.maxsize(width=600, height=500) + self.grid_columnconfigure(0, weight=1) + self.grid_columnconfigure(1, weight=1) + self.grid_rowconfigure(4, weight=1) + self.prepare() + + def close(self): + try: + core.str_to_date(self.start_var.get()) + core.str_to_date(self.end_var.get()) + except ValueError: + self.correct_data.set(False) + else: + self.correct_data.set(True) + finally: + super().destroy() + + def destroy(self): + self.correct_data.set(False) + super().destroy() + + +class RightclickMenu(tk.Menu): + """Popup menu. By default has one menuitem - "copy".""" + + def __init__(self, parent=None, copy_item=True, paste_item=False, + **options): + super().__init__(master=parent, tearoff=0, **options) + if copy_item: + self.add_command(label="Copy", command=copy_to_clipboard) + if paste_item: + self.add_command(label="Paste", command=paste_from_clipboard) + + def context_menu_show(self, event): + """Function links context menu with current selected widget + and pops menu up.""" + self.tk_popup(event.x_root, event.y_root) + GLOBAL_OPTIONS["selected_widget"] = event.widget + + +class MainFrame(elements.ScrolledCanvas): + """Container for all task frames.""" + + def __init__(self, parent): + super().__init__(parent=parent, bd=2) + self.frames_count = 0 + self.rows_counter = 0 + self.frames = [] + self.fill() + + def clear(self): + """Remove all task frames except with opened tasks.""" + for w in self.content_frame.winfo_children(): + if self.frames_count == GLOBAL_OPTIONS['timers_count'] \ + or self.frames_count == len(GLOBAL_OPTIONS["tasks"]): + break + if hasattr(w, 'task'): + if w.task is None: + self.frames_count -= 1 + w.destroy() + + def clear_all(self): + """Clear all task frames.""" + answer = askyesno("Really clear?", + "Are you sure you want to close all task frames?") + if answer: + for w in self.content_frame.winfo_children(): + if hasattr(w, 'task'): + w.clear() + self.fill() + + def frames_timer_indicator_update(self): + """Explicitly reload timer in every task frame.""" + for frame in self.frames: + if not frame.running: + frame.configure_indicator() + + def fill(self): + """Create contents of the main frame.""" + if self.frames_count < GLOBAL_OPTIONS['timers_count']: + row_count = range( + GLOBAL_OPTIONS['timers_count'] - self.frames_count) + for _ in row_count: + task = TaskFrame(parent=self.content_frame) + task.grid(row=self.rows_counter, pady=5, padx=5, ipady=3, + sticky='ew') + if GLOBAL_OPTIONS["preserved_tasks_list"]: + task_id = GLOBAL_OPTIONS["preserved_tasks_list"].pop(0) + task.get_restored_task_name(task_id) + self.frames.append(task) + self.rows_counter += 1 + self.frames_count += len(row_count) + self.content_frame.update() + self.canvbox.config(width=self.content_frame.winfo_width()) + elif len(GLOBAL_OPTIONS["tasks"]) < self.frames_count > \ + GLOBAL_OPTIONS['timers_count']: + self.clear() + self.content_frame.config(bg='#cfcfcf') + interface_items = [self.content_frame, *get_all_widget_children(self.content_frame, [])] + for item in interface_items: + item.bind("", self.mouse_scroll) # for Windows/OS X + item.bind("", self.mouse_scroll) # for Linux + item.bind("", self.mouse_scroll) + + def change_interface(self, interface): + """Change interface type. Accepts keywords 'normal' and 'small'.""" + for widget in self.content_frame.winfo_children(): + with suppress(TclError): + if interface == 'normal': + widget.normal_interface() + elif interface == 'small': + widget.small_interface() + + def pause_all(self): + for frame in self.frames: + if frame.running: + frame.timer_stop(paused=True) + + def resume_all(self): + for frame in self.frames: + if frame.paused: + frame.timer_start(stop_all=False) + + def stop_all(self): + for frame in self.frames: + frame.timer_stop() + + +class MainMenu(tk.Menu): + """Main window menu.""" + + def __init__(self, parent=None, **options): + super().__init__(master=parent, **options) + file = tk.Menu(self, tearoff=0) + file.add_command(label="Options...", command=self.options_window, + underline=0) + file.add_separator() + file.add_command(label="Exit", command=self.exit, underline=1) + elements.big_font(file, elements.FONTSIZE + 1) + self.add_cascade(label="Main menu", menu=file, underline=0) + helpmenu = tk.Menu(self, tearoff=0) + helpmenu.add_command(label="Help...", + command=lambda: helpwindow(parent=ROOT_WINDOW, + text=core.HELP_TEXT)) + helpmenu.add_command(label="About...", command=self.aboutwindow) + elements.big_font(helpmenu, elements.FONTSIZE + 1) + self.add_cascade(label="Help", menu=helpmenu) + elements.big_font(self, elements.FONTSIZE + 1) + + def options_window(self): + """Open options window.""" + self.db = core.Db() + # number of main window frames: + timers_count_var = tk.IntVar(value=GLOBAL_OPTIONS['timers_count']) + # 'always on top' option: + ontop = tk.IntVar(value=GLOBAL_OPTIONS['always_on_top']) + # 'compact interface' option + compact = GLOBAL_OPTIONS['compact_interface'] + compact_iface = tk.IntVar(value=compact) + # 'save tasks on exit' option: + save = tk.IntVar(value=GLOBAL_OPTIONS['preserve_tasks']) + # 'show current day in timers' option: + show_today_var = tk.IntVar(value=GLOBAL_OPTIONS['show_today']) + toggle = GLOBAL_OPTIONS['toggle_tasks'] + toggler_var = tk.IntVar(value=toggle) + params = {} + accept_var = tk.BooleanVar() + Options(ROOT_WINDOW, accept_var, timers_count_var, ontop, + compact_iface, save, show_today_var, toggler_var) + if accept_var.get(): + try: + count = timers_count_var.get() + except tk.TclError: + pass + else: + if count < 1: + count = 1 + elif count > GLOBAL_OPTIONS["MAX_TASKS"]: + count = GLOBAL_OPTIONS["MAX_TASKS"] + params['timers_count'] = count + # apply value of 'always on top' option: + params['always_on_top'] = ontop.get() + ROOT_WINDOW.wm_attributes("-topmost", params['always_on_top']) + # apply value of 'compact interface' option: + params['compact_interface'] = compact_iface.get() + if compact != compact_iface.get(): + if compact_iface.get() == 0: + ROOT_WINDOW.full_interface() + elif compact_iface.get() == 1: + ROOT_WINDOW.small_interface() + # apply value of 'save tasks on exit' option: + params['preserve_tasks'] = save.get() + if not params['preserve_tasks']: + self.db.update_preserved_tasks('') + # apply value of 'show current day in timers' option: + params['show_today'] = show_today_var.get() + # apply value of 'Allow run only one task at a time' option: + params['toggle_tasks'] = toggler_var.get() + # save all parameters to DB: + self.change_parameter(params) + # redraw taskframes if needed: + ROOT_WINDOW.taskframes.fill() + ROOT_WINDOW.taskframes.frames_timer_indicator_update() + # Stop all tasks if exclusive run method has been enabled: + if params['toggle_tasks'] and params['toggle_tasks'] != toggle: + if len([x for x in GLOBAL_OPTIONS["tasks"].values() if x]) != 1: + ROOT_WINDOW.stop_all() + paused = get_paused_taskframes() + if len(paused) > 1: + ROOT_WINDOW.change_paused_state() + for x in paused: + x.paused = False + ROOT_WINDOW.lift() + + def change_parameter(self, paramdict): + """Change option in the database.""" + for key, value in paramdict.items(): + self.db.update(table='options', field='value', value=value, + field_id=key, updfield='name') + GLOBAL_OPTIONS[key] = value + self.db.con.close() + + def aboutwindow(self): + showinfo("About %s" % core.TITLE, + core.ABOUT_MESSAGE.format( + GLOBAL_OPTIONS['version'], + core.CREATOR_NAME, + datetime.datetime.strftime(datetime.datetime.now(), "%Y"))) + + def exit(self): + ROOT_WINDOW.destroy() + + +class Options(Window): + """Options window which can be opened from main menu.""" + + def __init__(self, parent, is_applied, counter, on_top, compact, preserve, + show_today, toggler, **options): + super().__init__(master=parent, width=300, height=200, **options) + self.is_applied = is_applied + self.title("Options") + self.resizable(height=0, width=0) + self.counter = counter + elements.SimpleLabel(self, text="Task frames in main window: ").grid( + row=0, column=0, sticky='w') + counter_frame = tk.Frame(self) + fontsize = elements.FONTSIZE + elements.CanvasButton(counter_frame, text='<', command=self.decrease, + fontsize=fontsize, height=fontsize * 3).grid( + row=0, column=0) + elements.SimpleEntry(counter_frame, width=3, textvariable=counter, + justify='center').grid(row=0, column=1, + sticky='e') + elements.CanvasButton(counter_frame, text='>', command=self.increase, + fontsize=fontsize, height=fontsize * 3).grid( + row=0, column=2) + counter_frame.grid(row=0, column=1) + tk.Frame(self, height=20).grid(row=1) + elements.SimpleLabel(self, text="Always on top: ").grid(row=2, + column=0, + sticky='w', + padx=5) + elements.SimpleCheckbutton(self, variable=on_top).grid(row=2, column=1, + sticky='w', + padx=5) + elements.SimpleLabel(self, text="Compact interface: ").grid(row=3, + column=0, + sticky='w', + padx=5) + elements.SimpleCheckbutton(self, variable=compact).grid(row=3, + column=1, + sticky='w', + padx=5) + elements.SimpleLabel(self, text="Save tasks on exit: ").grid( + row=4, column=0, sticky='w', padx=5) + elements.SimpleCheckbutton(self, variable=preserve).grid(row=4, + column=1, + sticky='w', + padx=5) + elements.SimpleLabel(self, + text="Show time for current day only " + "in timer's window: ").grid( + row=5, column=0, sticky='w', padx=5) + elements.SimpleCheckbutton(self, variable=show_today).grid(row=5, + column=1, + sticky='w', + padx=5) + elements.SimpleLabel(self, + text="Allow to run only " + "one task at a time: ").grid( + row=6, column=0, sticky='w', padx=5) + elements.SimpleCheckbutton(self, variable=toggler).grid(row=6, + column=1, + sticky='w', + padx=5) + tk.Frame(self, height=20).grid(row=7) + elements.TaskButton(self, text='OK', command=self.apply).grid( + row=8, column=0, sticky='w', padx=5, pady=5) + elements.TaskButton(self, text='Cancel', command=self.destroy).grid( + row=8, column=1, sticky='e', padx=5, pady=5) + self.bind("", lambda e: self.apply()) + self.prepare() + + def apply(self): + self.is_applied.set(True) + self.destroy() + + def increase(self): + if self.counter.get() < GLOBAL_OPTIONS["MAX_TASKS"]: + self.counter.set(self.counter.get() + 1) + + def decrease(self): + if self.counter.get() > 1: + self.counter.set(self.counter.get() - 1) + + +class ExportWindow(Window): + """Export dialogue window.""" + + def __init__(self, parent, data, **options): + super().__init__(master=parent, **options) + self.title("Export parameters") + self.task_ids = [x["id"] for x in data.values()] + self.operating_mode_var = tk.IntVar(self) + elements.SimpleLabel(self, text="Export mode", fontsize=elements.FONTSIZE + 1).grid( + row=0, column=0, columnspan=2, sticky='ns', pady=5) + elements.SimpleRadiobutton(self, text="Task-based", + variable=self.operating_mode_var, + value=0).grid(row=1, column=0) + elements.SimpleRadiobutton(self, text="Date-based", + variable=self.operating_mode_var, + value=1).grid(row=1, column=1) + tk.Frame(self, height=15).grid(row=2, column=0) + elements.TaskButton(self, text="Export", command=self.get_data).grid( + row=3, column=0, padx=5, pady=5, sticky='ws') + elements.TaskButton(self, text="Cancel", command=self.destroy).grid( + row=3, column=1, padx=5, pady=5, sticky='es') + self.minsize(height=150, width=250) + self.maxsize(width=450, height=300) + self.grid_columnconfigure('all', weight=1) + self.grid_rowconfigure('all', weight=1) + self.prepare() + + def get_data(self): + """Take from the database information to be exported and prepare it. + All items should be strings.""" + if self.operating_mode_var.get() == 0: + prepared_data = self.db.tasks_to_export(self.task_ids) + else: + prepared_data = self.db.dates_to_export(self.task_ids) + self.export('\n'.join(prepared_data)) + + def export(self, data): + while True: + filename = asksaveasfilename(parent=self, defaultextension=".csv", + filetypes=[("All files", "*.*"), ( + "Comma-separated texts", "*.csv")]) + if filename: + try: + core.write_to_disk(filename, data) + except PermissionError: + showinfo("Unable to save file", + "No permission to save file here!" + "Please select another location.") + else: + break + else: + break + self.destroy() + + +class MainWindow(tk.Tk): + def __init__(self, **options): + super().__init__(**options) + # Default widget colour: + GLOBAL_OPTIONS["colour"] = self.cget('bg') + self.title(core.TITLE) + self.minsize(height=75, width=0) + self.resizable(width=0, height=1) + main_menu = MainMenu(self) # Create main menu. + self.config(menu=main_menu) + self.taskframes = MainFrame(self) # Main window content. + self.taskframes.grid(row=0, columnspan=5) + self.bind("", self.taskframes.reconf_canvas) + self.paused = False + if not GLOBAL_OPTIONS["compact_interface"]: + self.full_interface(True) + self.grid_rowconfigure(0, weight=1) + # Make main window always appear in good position + # and with adequate size: + self.update() + if self.winfo_height() < self.winfo_screenheight() - 250: + window_height = self.winfo_height() + else: + window_height = self.winfo_screenheight() - 250 + self.geometry('%dx%d+100+50' % (self.winfo_width(), window_height)) + if GLOBAL_OPTIONS['always_on_top']: + self.wm_attributes("-topmost", 1) + self.bind("", self.hotkeys) + + def hotkeys(self, event): + """Execute corresponding actions for hotkeys.""" + if event.keysym in ('Cyrillic_yeru', 'Cyrillic_YERU', 's', 'S'): + self.stop_all() + elif event.keysym in ('Cyrillic_es', 'Cyrillic_ES', 'c', 'C'): + self.taskframes.clear_all() + elif event.keysym in ( + 'Cyrillic_shorti', 'Cyrillic_SHORTI', 'q', 'Q', 'Escape'): + self.destroy() + elif event.keysym in ('Cyrillic_ZE', 'Cyrillic_ze', 'p', 'P'): + self.pause_all() + + def full_interface(self, firstrun=False): + """Create elements which are displayed in full interface mode.""" + self.add_frame = tk.Frame(self, height=35) + self.add_frame.grid(row=1, columnspan=5) + self.stop_button = elements.TaskButton(self, text="Stop all", + command=self.stop_all) + self.stop_button.grid(row=2, column=2, sticky='sn', pady=5, padx=5) + self.clear_button = elements.TaskButton( + self, text="Clear all", + command=self.taskframes.clear_all) + self.clear_button.grid(row=2, column=0, sticky='wsn', pady=5, + padx=5) + self.pause_all_var = tk.StringVar(value="Resume all" if self.paused + else "Pause all") + self.pause_button = elements.TaskButton(self, + variable=self.pause_all_var, + command=self.pause_all, + textwidth=10) + self.pause_button.grid(row=2, column=3, sticky='snw', pady=5, + padx=5) + self.add_quit_button = elements.TaskButton(self, text="Quit", + command=self.destroy) + self.add_quit_button.grid(row=2, column=4, sticky='sne', pady=5, + padx=5) + if not firstrun: + self.taskframes.change_interface('normal') + + def small_interface(self): + """Destroy all additional interface elements.""" + for widget in (self.add_frame, self.stop_button, + self.clear_button, self.add_quit_button, + self.pause_button): + widget.destroy() + self.taskframes.change_interface('small') + + def change_paused_state(self, paused=False): + self.paused = paused + if not GLOBAL_OPTIONS["compact_interface"]: + if paused: + title = "Resume all" + else: + title = "Pause all" + self.pause_all_var.set(title) + + def pause_all(self): + if self.paused: + self.taskframes.resume_all() + self.change_paused_state() + else: + self.taskframes.pause_all() + self.change_paused_state(True) + + def stop_all(self): + """Stop all running timers.""" + self.taskframes.stop_all() + self.paused = False + self.change_paused_state() + + def destroy(self): + answer = askyesno("Quit confirmation", "Do you really want to quit?") + if answer: + db = core.Db() + if GLOBAL_OPTIONS["preserve_tasks"]: + tasks = GLOBAL_OPTIONS["tasks"] + if int(GLOBAL_OPTIONS['timers_count']) < len( + GLOBAL_OPTIONS["tasks"]): + db.update(table='options', field='value', + value=len(GLOBAL_OPTIONS["tasks"]), + field_id='timers_count', updfield='name') + else: + tasks = '' + db.update_preserved_tasks(tasks) + db.con.close() + super().destroy() + + +def get_all_widget_children(widget, children_list): + children = widget.winfo_children() + for child in children: + children_list.append(child) + get_all_widget_children(child, children_list) + return children_list + + +def get_paused_taskframes(): + res = [] + for widget in ROOT_WINDOW.taskframes.content_frame.winfo_children(): + if hasattr(widget, "paused"): + if widget.paused: + res.append(widget) + return res + + +def helpwindow(parent=None, text=None): + """Show simple help window with given text.""" + HelpWindow(parent, text) + + +def copy_to_clipboard(): + """Copy widget text to clipboard.""" + GLOBAL_OPTIONS["selected_widget"].clipboard_clear() + if isinstance(GLOBAL_OPTIONS["selected_widget"], tk.Text): + try: + GLOBAL_OPTIONS["selected_widget"].clipboard_append( + GLOBAL_OPTIONS["selected_widget"].selection_get()) + except tk.TclError: + GLOBAL_OPTIONS["selected_widget"].clipboard_append( + GLOBAL_OPTIONS["selected_widget"].get(1.0, 'end')) + else: + GLOBAL_OPTIONS["selected_widget"].clipboard_append( + GLOBAL_OPTIONS["selected_widget"].cget("text")) + + +def paste_from_clipboard(): + """Paste text from clipboard.""" + if isinstance(GLOBAL_OPTIONS["selected_widget"], tk.Text): + GLOBAL_OPTIONS["selected_widget"].insert(tk.INSERT, GLOBAL_OPTIONS[ + "selected_widget"].clipboard_get()) + elif isinstance(GLOBAL_OPTIONS["selected_widget"], tk.Entry): + GLOBAL_OPTIONS["selected_widget"].insert(0, GLOBAL_OPTIONS[ + "selected_widget"].clipboard_get()) + + +def get_options(): + """Get program preferences from database.""" + db = core.Db() + return {x[0]: x[1] for x in db.find_all(table='options')} + + +if __name__ == "__main__": + # Maximum number of task frames: + MAX_TASKS = 10 + # Interval between timer renewal: + TIMER_INTERVAL = 250 + # Interval between saving time to database: + SAVE_INTERVAL = 10000 # ms + # Check if tasks database actually exists: + core.check_database() + # Create options dictionary: + GLOBAL_OPTIONS = get_options() + # Global tasks ids set. Used for preserve duplicates: + if GLOBAL_OPTIONS["tasks"]: + GLOBAL_OPTIONS["tasks"] = dict.fromkeys( + map(int, str(GLOBAL_OPTIONS["tasks"]).split(",")), False) + else: + GLOBAL_OPTIONS["tasks"] = dict() + # List of preserved tasks which are not open: + GLOBAL_OPTIONS["preserved_tasks_list"] = list(GLOBAL_OPTIONS["tasks"]) + # Widget which is currently connected to context menu: + GLOBAL_OPTIONS["selected_widget"] = None + GLOBAL_OPTIONS.update({"MAX_TASKS": MAX_TASKS, + "TIMER_INTERVAL": TIMER_INTERVAL, + "SAVE_INTERVAL": SAVE_INTERVAL}) + # Main window: + ROOT_WINDOW = MainWindow() + ROOT_WINDOW.mainloop() \ No newline at end of file diff --git a/tasker.pyw b/tasker.pyw deleted file mode 100755 index 453a9b4..0000000 --- a/tasker.pyw +++ /dev/null @@ -1,1562 +0,0 @@ -#!/usr/bin/env python3 - -import time -import datetime -import copy - -try: - import tkinter as tk -except ModuleNotFoundError: - import sys - sys.exit("Unable to start GUI. Please install Tk for Python: https://docs.python.org/3/library/tkinter.html.") -from tkinter.filedialog import asksaveasfilename -from tkinter.messagebox import askyesno, showinfo -from tkinter import ttk -from tkinter import TclError - -import elements -import core -import sel_cal - - -class Window(tk.Toplevel): - """Universal class for dialogue windows creation.""" - def __init__(self, master=None, **options): - super().__init__(master=master, **options) - self.db = core.Db() - self.master = master - self.bind("", lambda e: self.destroy()) - - def prepare(self): - self.grab_set() - self.on_top_wait() - self.place_window(self.master) - self.wait_window() - - def on_top_wait(self): - """Allows window to be on the top of others when 'always on top' is enabled.""" - ontop = GLOBAL_OPTIONS['always_on_top'] - if ontop == '1': - self.wm_attributes("-topmost", 1) - - def place_window(self, parent): - """Place widget on top of parent.""" - if parent: - stored_xpos = parent.winfo_rootx() - self.geometry('+%d+%d' % (stored_xpos, parent.winfo_rooty())) - self.withdraw() # temporary hide window. - self.update_idletasks() - # Check if window will appear inside screen borders and move it if not: - if self.winfo_rootx() + self.winfo_width() > self.winfo_screenwidth(): - stored_xpos = (self.winfo_screenwidth() - self.winfo_width() - 50) - self.geometry('+%d+%d' % (stored_xpos, parent.winfo_rooty())) - if self.winfo_rooty() + self.winfo_height() > self.winfo_screenheight(): - self.geometry('+%d+%d' % (stored_xpos, (self.winfo_screenheight() - self.winfo_height() - 150))) - self.deiconify() # restore hidden window. - - def destroy(self): - self.db.con.close() - if self.master: - self.master.focus_set() - self.master.lift() - super().destroy() - - -class TaskLabel(elements.SimpleLabel): - """Simple sunken text label.""" - def __init__(self, parent, anchor='center', **kwargs): - super().__init__(master=parent, relief='sunken', anchor=anchor, **kwargs) - context_menu = RightclickMenu() - self.bind("", context_menu.context_menu_show) - - -class Description(tk.Frame): - """Description frame - Text frame with scroll.""" - def __init__(self, parent=None, copy_menu=True, paste_menu=False, state='normal', **options): - super().__init__(master=parent) - self.text = elements.SimpleText(self, bg=GLOBAL_OPTIONS["colour"], state=state, wrap='word', bd=2, **options) - scroller = tk.Scrollbar(self) - scroller.config(command=self.text.yview) - self.text.config(yscrollcommand=scroller.set) - scroller.grid(row=0, column=1, sticky='ns') - self.text.grid(row=0, column=0, sticky='news') - self.grid_columnconfigure(0, weight=1) - self.grid_rowconfigure('all', weight=1) - # Context menu for copying contents: - self.context_menu = RightclickMenu(copy_item=copy_menu, paste_item=paste_menu) - self.text.bind("", self.context_menu.context_menu_show) - - def config(self, cnf=None, **kw): - """Text configuration method.""" - self.text.config(cnf=cnf, **kw) - - def insert(self, text): - self.text.insert('end', text) - - def get(self): - return self.text.get(1.0, 'end') - - def update_text(self, text): - """Refill text field.""" - self.config(state='normal') - self.text.delete(1.0, 'end') - if text is not None: - self.text.insert(1.0, text) - self.config(state='disabled') - - -class TaskFrame(tk.Frame): - """Task frame on application's main screen.""" - def __init__(self, parent=None): - super().__init__(master=parent, relief='raised', bd=2) - self.db = core.Db() - self.create_content() - self.bind("", lambda e: GLOBAL_OPTIONS["selected_widget"]) - - def create_content(self): - """Creates all window elements.""" - self.startstopvar = tk.StringVar() # Text on "Start" button. - self.startstopvar.set("Start") - self.task = None # Fake name of running task (which actually is not selected yet). - self.task_id = None - self.description = None - if GLOBAL_OPTIONS["compact_interface"] == "0": - self.normal_interface() - # Task name field: - self.tasklabel = TaskLabel(self, width=50, anchor='w') - elements.big_font(self.tasklabel, size=14) - self.tasklabel.grid(row=1, column=0, columnspan=5, padx=5, pady=5, sticky='w') - self.openbutton = elements.TaskButton(self, text="Task...", command=self.name_dialogue) - self.openbutton.grid(row=1, column=5, padx=5, pady=5, sticky='e') - self.startbutton = elements.CanvasButton(self, state='disabled', fontsize=14, command=self.startstopbutton, - variable=self.startstopvar, image='resource/start_disabled.png' if tk.TkVersion >= 8.6 - else 'resource/start_disabled.pgm', opacity='left') - self.startbutton.grid(row=3, column=0, sticky='wsn', padx=5) - # Counter frame: - self.timer_window = TaskLabel(self, width=10, state='disabled') - elements.big_font(self.timer_window, size=20) - self.timer_window.grid(row=3, column=1, pady=5) - self.add_timestamp_button = elements.CanvasButton(self, text='Add\ntimestamp', state='disabled', command=self.add_timestamp) - self.add_timestamp_button.grid(row=3, sticky='sn', column=2, padx=5) - self.timestamps_window_button = elements.CanvasButton(self, text='View\ntimestamps...', state='disabled', - command=self.timestamps_window) - self.timestamps_window_button.grid(row=3, column=3, sticky='wsn', padx=5) - self.properties = elements.TaskButton(self, text="Properties...", textwidth=9, state='disabled', - command=self.properties_window) - self.properties.grid(row=3, column=4, sticky='e', padx=5) - # Clear frame button: - self.clearbutton = elements.TaskButton(self, text='Clear', state='disabled', textwidth=7, command=self.clear) - self.clearbutton.grid(row=3, column=5, sticky='e', padx=5) - self.running_time = 0 # Current value of the counter. - self.running = False - self.timestamp = 0 - - def normal_interface(self): - """Creates elements which are visible only in full interface mode.""" - # 'Task name' text: - self.l1 = tk.Label(self, text='Task name:') - elements.big_font(self.l1, size=12) - self.l1.grid(row=0, column=1, columnspan=3) - # Task description field: - self.description = Description(self, width=60, height=3) - self.description.grid(row=2, column=0, columnspan=6, padx=5, pady=6, sticky='we') - if self.task: - self.description.update_text(self.task[3]) - - def small_interface(self): - """Destroy some interface elements when switching to 'compact' mode.""" - for widget in self.l1, self.description: - widget.destroy() - self.description = None - - def timestamps_window(self): - """Timestamps window opening.""" - TimestampsWindow(self.task_id, self.running_time, run) - - def add_timestamp(self): - """Adding timestamp to database.""" - self.db.insert('timestamps', ('task_id', 'timestamp'), (self.task_id, self.running_time)) - showinfo("Timestamp added", "Timestamp added.") - - def startstopbutton(self): - """Changes "Start/Stop" button state. """ - if self.running: - self.timer_stop() - else: - self.timer_start() - - def properties_window(self): - """Task properties window.""" - edited = tk.IntVar() - TaskEditWindow(self.task[0], parent=run, variable=edited) - if edited.get() == 1: - self.update_description() - - def clear(self): - """Recreation of frame contents.""" - self.timer_stop() - for w in self.winfo_children(): - w.destroy() - GLOBAL_OPTIONS["tasks"].pop(self.task[0]) - self.create_content() - - def name_dialogue(self): - """Task selection window.""" - var = tk.IntVar() - TaskSelectionWindow(run, taskvar=var) - if var.get(): - self.get_task_name(var.get()) - - def get_task_name(self, task_id): - """Getting selected task's name.""" - # Checking if task is already open in another frame: - if task_id not in GLOBAL_OPTIONS["tasks"]: - # Checking if there is open task in this frame: - if self.task: - # Stopping current timer and saving its state: - self.timer_stop() - # If there is open task, we remove it from running tasks set: - GLOBAL_OPTIONS["tasks"].pop(self.task[0]) - self.get_restored_task_name(task_id) - else: - # If selected task is already opened in another frame: - if self.task_id != task_id: - showinfo("Task exists", "Task is already opened.") - - def get_restored_task_name(self, taskid): - self.task_id = taskid - # Preparing new task: - self.prepare_task(self.db.select_task(self.task_id)) # Task parameters from database - - def prepare_task(self, task): - """Prepares frame elements to work with.""" - # Adding task id to set of running tasks: - GLOBAL_OPTIONS["tasks"][task[0]] = False - self.task = task - self.current_date = core.date_format(datetime.datetime.now()) - # Set current time, just for this day: - if self.task[-1] is None: - self.date_exists = False - self.task[-1] = 0 - else: - self.date_exists = True - # Taking current counter value from database: - self.set_current_time() - self.timer_window.config(text=core.time_format(self.running_time)) - self.tasklabel.config(text=self.task[1]) - self.startbutton.config(state='normal') - self.startbutton.config(image='resource/start_normal.png' - if tk.TkVersion >= 8.6 else 'resource/start_normal.pgm') - self.properties.config(state='normal') - self.clearbutton.config(state='normal') - self.timer_window.config(state='normal') - self.add_timestamp_button.config(state='normal') - self.timestamps_window_button.config(state='normal') - if self.description: - self.description.update_text(self.task[3]) - - def set_current_time(self): - """Set current_time depending on time displaying options value.""" - if int(GLOBAL_OPTIONS["show_today"]): - self.running_time = self.task[5] - else: - self.running_time = self.task[2] - - def reload_timer(self): - """Used for task data reloading without explicitly redraw anything but timer.""" - self.timer_stop() - self.task = self.db.select_task(self.task_id) - self.set_current_time() - self.timer_start() - - def check_date(self): - """Used to check if date has been changed since last timer value save.""" - current_date = core.date_format(datetime.datetime.now()) - if current_date != self.current_date: - self.current_date = current_date - self.date_exists = False - self.running_today_time = self.running_today_time - self.timestamp - self.start_today_time = time.time() - self.running_today_time - self.task_update() - - def task_update(self): - """Updates time in the database.""" - if not self.date_exists: - self.db.insert("activity", ("date", "task_id", "spent_time"), - (self.current_date, self.task[0], self.running_today_time)) - self.date_exists = True - # self.reload_timer() - else: - self.db.update_task(self.task[0], value=self.running_today_time) - self.timestamp = self.running_today_time - - def timer_update(self, counter=0): - """Renewal of the counter.""" - interval = 250 # Time interval in milliseconds before next iteration of recursion. - self.running_time = time.time() - self.start_time - self.running_today_time = time.time() - self.start_today_time - self.timer_window.config(text=core.time_format(self.running_time if self.running_time < 86400 - else self.running_today_time)) - # Checking if "Stop all" button is pressed: - if not GLOBAL_OPTIONS["stopall"] and GLOBAL_OPTIONS["tasks"][self.task_id]: - # Every n seconds counter value is saved in database: - if counter >= GLOBAL_OPTIONS["SAVE_INTERVAL"]: - self.check_date() - counter = 0 - else: - counter += interval - # self.timer variable becomes ID created by after(): - self.timer = self.timer_window.after(interval, self.timer_update, counter) - else: - self.timer_stop() - - def timer_start(self): - """Counter start.""" - if not self.running: - GLOBAL_OPTIONS["stopall"] = False - if int(GLOBAL_OPTIONS["toggle_tasks"]): - for key in GLOBAL_OPTIONS["tasks"]: - GLOBAL_OPTIONS["tasks"][key] = False - GLOBAL_OPTIONS["tasks"][self.task_id] = True - # Setting current counter value: - self.start_time = time.time() - self.running_time - # This value is used to add record to database: - self.start_today_time = time.time() - self.task[-1] - self.timer_update() - self.running = True - self.startbutton.config(image='resource/stop.png' if tk.TkVersion >= 8.6 else 'resource/stop.pgm') - self.startstopvar.set("Stop") - - def timer_stop(self): - """Stop counter and save its value to database.""" - if self.running: - # after_cancel() stops execution of callback with given ID. - self.timer_window.after_cancel(self.timer) - self.running_time = time.time() - self.start_time - self.running_today_time = time.time() - self.start_today_time - self.running = False - GLOBAL_OPTIONS["tasks"][self.task_id] = False - # Writing value into database: - self.check_date() - self.task[2] = self.running_time - self.task[5] = self.running_today_time - self.update_description() - self.startbutton.config(image='resource/start_normal.png' if tk.TkVersion >= 8.6 else 'resource/start_normal.pgm') - self.startstopvar.set("Start") - - def update_description(self): - """Update text in "Description" field.""" - self.task[3] = self.db.find_by_clause("tasks", "id", self.task[0], "description")[0][0] - if self.description: - self.description.update_text(self.task[3]) - - def destroy(self): - """Closes frame and writes counter value into database.""" - self.timer_stop() - if self.task: - GLOBAL_OPTIONS["tasks"].pop(self.task[0]) - self.db.con.close() - tk.Frame.destroy(self) - - -class TaskList(tk.Frame): - """Scrollable tasks table.""" - def __init__(self, columns, parent=None, **options): - super().__init__(master=parent, **options) - self.taskslist = ttk.Treeview(self) # A table. - style = ttk.Style() - style.configure(".", font=('Helvetica', 11)) - style.configure("Treeview.Heading", font=('Helvetica', 11)) - scroller = tk.Scrollbar(self) - scroller.config(command=self.taskslist.yview) - self.taskslist.config(yscrollcommand=scroller.set) - scroller.pack(side='right', fill='y') - self.taskslist.pack(fill='both', expand=1) - # Creating and naming columns: - self.taskslist.config(columns=tuple([col[0] for col in columns])) - for index, col in enumerate(columns): - # Configuring columns with given ids: - self.taskslist.column(columns[index][0], width=100, minwidth=100, anchor='center') - # Configuring headers of columns with given ids: - self.taskslist.heading(columns[index][0], text=columns[index][1], command=lambda c=columns[index][0]: - self.sortlist(c, True)) - self.taskslist.column('#0', anchor='w', width=70, minwidth=50, stretch=0) - self.taskslist.column('taskname', width=600, anchor='w') - - def sortlist(self, col, reverse): - """Sorting by click on column header.""" - if col == "time": - shortlist = self._sort(1, reverse) - elif col == "date": - shortlist = self._sort(2, reverse) - else: - shortlist = self._sort(0, reverse) - shortlist.sort(key=lambda x: x[0], reverse=reverse) - for index, value in enumerate(shortlist): - self.taskslist.move(value[1], '', index) - self.taskslist.heading(col, command=lambda: self.sortlist(col, not reverse)) - - def _sort(self, position, reverse): - l = [] - for index, task in enumerate(self.taskslist.get_children()): - l.append((self.tasks[index][position], task)) - # Sort tasks list by corresponding field to match current sorting: - self.tasks.sort(key=lambda x: x[position], reverse=reverse) - return l - - def insert_tasks(self, tasks): - """Insert rows in the table. Row contents are tuples given in values=.""" - for i, v in enumerate(tasks): # item, number, value: - self.taskslist.insert('', i, text="#%d" % (i + 1), values=v) - - def update_list(self, tasks): - """Refill table contents.""" - for item in self.taskslist.get_children(): - self.taskslist.delete(item) - self.tasks = copy.deepcopy(tasks) - for t in tasks: - t[1] = core.time_format(t[1]) - self.insert_tasks(tasks) - - def focus_(self, item): - """Focuses on the row with provided id.""" - self.taskslist.see(item) - self.taskslist.selection_set(item) - self.taskslist.focus_set() - self.taskslist.focus(item) - - -class TaskSelectionWindow(Window): - """Task selection and creation window.""" - def __init__(self, parent=None, taskvar=None, **options): - super().__init__(master=parent, **options) - # Variable which will contain selected task id: - if taskvar: - self.taskidvar = taskvar - # Basic script for retrieving tasks from database: - self.main_script = 'SELECT id, name, total_time, description, creation_date FROM tasks JOIN (SELECT task_id, ' \ - 'sum(spent_time) AS total_time FROM activity GROUP BY task_id) AS act ON act.task_id=tasks.id' - self.title("Task selection") - self.minsize(width=500, height=350) - elements.SimpleLabel(self, text="New task:").grid(row=0, column=0, sticky='w', pady=5, padx=5) - # New task entry field: - self.addentry = elements.SimpleEntry(self, width=50) - self.addentry.grid(row=0, column=1, columnspan=3, sticky='we') - # Enter adds new task: - self.addentry.bind('', lambda event: self.add_new_task()) - self.addentry.focus_set() - # Context menu with 'Paste' option: - addentry_context_menu = RightclickMenu(paste_item=1, copy_item=0) - self.addentry.bind("", addentry_context_menu.context_menu_show) - # "Add task" button: - self.addbutton = elements.TaskButton(self, text="Add task", command=self.add_new_task, takefocus=False) - self.addbutton.grid(row=0, column=4, sticky='e', padx=6, pady=5) - # Entry for typing search requests: - self.searchentry = elements.SimpleEntry(self, width=25) - self.searchentry.grid(row=1, column=1, columnspan=2, sticky='we', padx=5, pady=5) - searchentry_context_menu = RightclickMenu(paste_item=1, copy_item=0) - self.searchentry.bind("", searchentry_context_menu.context_menu_show) - # Case sensitive checkbutton: - self.ignore_case = tk.IntVar(self, value=1) - elements.SimpleCheckbutton(self, text="Ignore case", takefocus=False, variable=self.ignore_case).grid(row=1, column=0, - padx=6, pady=5, sticky='w') - # Search button: - elements.CanvasButton(self, takefocus=False, text='Search', image='resource/magnifier.png' if tk.TkVersion >= 8.6 else - 'resource/magnifier.pgm', command=self.locate_task).grid(row=1, column=3, sticky='w', padx=5, pady=5) - # Refresh button: - elements.TaskButton(self, takefocus=False, image='resource/refresh.png' if tk.TkVersion >= 8.6 else 'resource/refresh.pgm', - command=self.update_list).grid(row=1, column=4, sticky='e', padx=5, pady=5) - # Naming of columns in tasks list: - columnnames = [('taskname', 'Task name'), ('time', 'Spent time'), ('date', 'Creation date')] - # Scrollable tasks table: - self.listframe = TaskList(columnnames, self, takefocus=True) - self.listframe.grid(row=2, column=0, columnspan=5, pady=10, sticky='news') - elements.SimpleLabel(self, text="Summary time:").grid(row=3, column=0, pady=5, padx=5, sticky='w') - # Summarized time of all tasks in the table: - self.fulltime_frame = TaskLabel(self, width=13, anchor='center') - self.fulltime_frame.grid(row=3, column=1, padx=6, pady=5, sticky='e') - # Selected task description: - self.description = Description(self, height=4) - self.description.grid(row=3, column=2, rowspan=2, pady=5, padx=5, sticky='news') - # "Select all" button: - selbutton = elements.TaskButton(self, text="Select all", command=self.select_all) - selbutton.grid(row=4, column=0, sticky='w', padx=5, pady=5) - # "Clear all" button: - clearbutton = elements.TaskButton(self, text="Clear all", command=self.clear_all) - clearbutton.grid(row=4, column=1, sticky='e', padx=5, pady=5) - # Task properties button: - self.editbutton = elements.TaskButton(self, text="Properties...", textwidth=10, command=self.edit) - self.editbutton.grid(row=3, column=3, sticky='w', padx=5, pady=5) - # Remove task button: - self.delbutton = elements.TaskButton(self, text="Remove...", textwidth=10, command=self.delete) - self.delbutton.grid(row=4, column=3, sticky='w', padx=5, pady=5) - # Export button: - self.exportbutton = elements.TaskButton(self, text="Export...", command=self.export) - self.exportbutton.grid(row=4, column=4, padx=5, pady=5, sticky='e') - # Filter button: - self.filterbutton = elements.TaskButton(self, text="Filter...", command=self.filterwindow) - self.filterbutton.grid(row=3, column=4, padx=5, pady=5, sticky='e') - # Filter button context menu: - filter_context_menu = RightclickMenu(copy_item=False) - filter_context_menu.add_command(label='Clear filter', command=self.apply_filter) - self.filterbutton.bind("", filter_context_menu.context_menu_show) - tk.Frame(self, height=40).grid(row=5, columnspan=5, sticky='news') - self.grid_columnconfigure(2, weight=1, minsize=50) - self.grid_rowconfigure(2, weight=1, minsize=50) - self.update_list() # Fill table contents. - self.current_task = '' # Current selected task. - self.listframe.taskslist.bind("", self.descr_down) - self.listframe.taskslist.bind("", self.descr_up) - self.listframe.taskslist.bind("", self.descr_click) - self.listframe.bind("", lambda e: self.focus_first_item(forced=False)) - # Need to avoid masquerading of default ttk.Treeview action on Shift+click and Control+click: - self.modifier_pressed = False - self.listframe.taskslist.bind("", lambda e: self.shift_control_pressed()) - self.listframe.taskslist.bind("", lambda e: self.shift_control_pressed()) - self.listframe.taskslist.bind("", lambda e: self.shift_control_pressed()) - self.listframe.taskslist.bind("", lambda e: self.shift_control_pressed()) - self.listframe.taskslist.bind("", lambda e: self.shift_control_released()) - self.listframe.taskslist.bind("", lambda e: self.shift_control_released()) - self.listframe.taskslist.bind("", lambda e: self.shift_control_released()) - self.listframe.taskslist.bind("", lambda e: self.shift_control_released()) - self.searchentry.bind("", lambda e: self.locate_task()) - self.bind("", lambda e: self.update_list()) - elements.TaskButton(self, text="Open", command=self.get_task).grid(row=6, column=0, padx=5, pady=5, sticky='w') - elements.TaskButton(self, text="Cancel", command=self.destroy).grid(row=6, column=4, padx=5, pady=5, sticky='e') - self.listframe.taskslist.bind("", self.get_task_id) - self.listframe.taskslist.bind("", self.get_task_id) - self.prepare() - - def check_row(self, event): - """Check if mouse click is over the row, not another taskslist element.""" - if (event.type == '4' and len(self.listframe.taskslist.identify_row(event.y)) > 0) or (event.type == '2'): - return True - - def get_task(self): - """Get selected task id from database and close window.""" - # List of selected tasks item id's: - tasks = self.listframe.taskslist.selection() - if tasks: - self.taskidvar.set(self.tdict[tasks[0]][0]) - self.destroy() - - def get_task_id(self, event): - """For clicking on buttons and items.""" - if self.check_row(event): - self.get_task() - - def shift_control_pressed(self): - self.modifier_pressed = True - - def shift_control_released(self): - self.modifier_pressed = False - - def focus_first_item(self, forced=True): - """Selects first item in the table if no items selected.""" - if self.listframe.taskslist.get_children(): - item = self.listframe.taskslist.get_children()[0] - else: - return - if forced: - self.listframe.focus_(item) - self.update_descr(item) - else: - if not self.listframe.taskslist.selection(): - self.listframe.focus_(item) - self.update_descr(item) - else: - self.listframe.taskslist.focus_set() - - def locate_task(self): - """Search task by keywords.""" - searchword = self.searchentry.get() - if searchword: - self.clear_all() - task_items = [] - if self.ignore_case.get(): - for key in self.tdict: - if searchword.lower() in self.tdict[key][1].lower(): - task_items.append(key) - elif self.tdict[key][3]: # Need to be sure that there is non-empty description. - if searchword.lower() in self.tdict[key][3].lower(): - task_items.append(key) - else: - for key in self.tdict: - if searchword in self.tdict[key][1]: - task_items.append(key) - elif self.tdict[key][3]: - if searchword in self.tdict[key][3]: - task_items.append(key) - if task_items: - for item in task_items: - self.listframe.taskslist.selection_add(item) - item = self.listframe.taskslist.selection()[0] - self.listframe.taskslist.see(item) - self.listframe.taskslist.focus_set() - self.listframe.taskslist.focus(item) - self.update_descr(item) - else: - showinfo("No results", "No tasks found.\nMaybe need to change filter settings?") - - def export(self): - """Export all tasks from the table into the file.""" - ExportWindow(self, self.tdict) - - def add_new_task(self): - """Adds new task into the database.""" - task_name = self.addentry.get() - if task_name: - for x in ('"', "'", "`"): - task_name = task_name.replace(x, '') - try: - self.db.insert_task(task_name) - except core.DbErrors: - self.db.reconnect() - for item in self.tdict: - if self.tdict[item][1] == task_name: - self.listframe.focus_(item) - self.update_descr(item) - break - else: - showinfo("Task exists", "Task already exists. Change filter configuration to see it.") - else: - self.update_list() - # If created task appears in the table, highlighting it: - for item in self.tdict: - if self.tdict[item][1] == task_name: - self.listframe.focus_(item) - break - else: - showinfo("Task created", "Task successfully created. Change filter configuration to see it.") - - def filter_query(self): - return self.db.find_by_clause('options', 'name', 'filter', 'value')[0][0] - - def update_list(self): - """Updating table contents using database query.""" - # Restoring filter value: - query = self.filter_query() - if query: - self.filterbutton.config(bg='lightblue') - self.db.exec_script(query) - else: - self.filterbutton.config(bg=GLOBAL_OPTIONS["colour"]) - self.db.exec_script(self.main_script) - tlist = self.db.cur.fetchall() - self.listframe.update_list([[f[1], f[2], f[4]] for f in tlist]) - # Dictionary with row ids and tasks info: - self.tdict = {} - i = 0 - for task_id in self.listframe.taskslist.get_children(): - self.tdict[task_id] = tlist[i] - i += 1 - self.update_descr(None) - self.update_fulltime() - - def update_fulltime(self): - """Updates value in "fulltime" frame.""" - self.fulltime = core.time_format(sum([self.tdict[x][2] for x in self.tdict])) - self.fulltime_frame.config(text=self.fulltime) - - def descr_click(self, event): - """Updates description for the task with item id of the row selected by click.""" - pos = self.listframe.taskslist.identify_row(event.y) - if pos and pos != '#0' and not self.modifier_pressed: - self.listframe.focus_(pos) - self.update_descr(self.listframe.taskslist.focus()) - - def descr_up(self, event): - """Updates description for the item id which is BEFORE selected.""" - item = self.listframe.taskslist.focus() - prev_item = self.listframe.taskslist.prev(item) - if prev_item == '': - self.update_descr(item) - else: - self.update_descr(prev_item) - - def descr_down(self, event): - """Updates description for the item id which is AFTER selected.""" - item = self.listframe.taskslist.focus() - next_item = self.listframe.taskslist.next(item) - if next_item == '': - self.update_descr(item) - else: - self.update_descr(next_item) - - def update_descr(self, item): - """Filling task description frame.""" - if item is None: - self.description.update_text('') - elif item != '': - self.description.update_text(self.tdict[item][3]) - - def select_all(self): - self.listframe.taskslist.selection_set(self.listframe.taskslist.get_children()) - - def clear_all(self): - self.listframe.taskslist.selection_remove(self.listframe.taskslist.get_children()) - - def delete(self): - """Remove selected tasks from the database and the table.""" - ids = [self.tdict[x][0] for x in self.listframe.taskslist.selection() if self.tdict[x][0] - not in GLOBAL_OPTIONS["tasks"]] - items = [x for x in self.listframe.taskslist.selection() if self.tdict[x][0] in ids] - if ids: - answer = askyesno("Warning", "Are you sure you want to delete selected tasks?", parent=self) - if answer: - self.db.delete_tasks(tuple(ids)) - self.listframe.taskslist.delete(*items) - for item in items: - self.tdict.pop(item) - self.update_descr(None) - self.update_fulltime() - - def edit(self): - """Show task edit window.""" - item = self.listframe.taskslist.focus() - try: - id_name = (self.tdict[item][0], self.tdict[item][1]) # Tuple: (selected_task_id, selected_task_name) - except KeyError: - pass - else: - task_changed = tk.IntVar() - TaskEditWindow(id_name[0], self, variable=task_changed) - if task_changed.get() == 1: - # Reload task information from database: - new_task_info = self.db.select_task(id_name[0]) - # Update description: - self.tdict[item] = new_task_info - self.update_descr(item) - # Update data in a table: - self.listframe.taskslist.item(item, values=(new_task_info[1], core.time_format(new_task_info[2]), - new_task_info[4])) - self.update_fulltime() - self.raise_window() - - def filterwindow(self): - """Open filters window.""" - filter_changed = tk.IntVar() - FilterWindow(self, variable=filter_changed) - # Update tasks list only if filter parameters have been changed: - if filter_changed.get() == 1: - self.apply_filter(GLOBAL_OPTIONS["filter_dict"]['operating_mode'], GLOBAL_OPTIONS["filter_dict"]['script'], - GLOBAL_OPTIONS["filter_dict"]['tags'], GLOBAL_OPTIONS["filter_dict"]['dates']) - self.raise_window() - - def apply_filter(self, operating_mode='AND', script=None, tags='', dates=''): - """Record filter parameters to database and apply it.""" - update = self.filter_query() - self.db.update('filter_operating_mode', field='value', value=operating_mode, table='options', updfiled='name') - self.db.update('filter', field='value', value=script, table='options', updfiled='name') - self.db.update('filter_tags', field='value', value=','.join([str(x) for x in tags]), table='options', updfiled='name') - self.db.update('filter_dates', field='value', value=','.join(dates), table='options', updfiled='name') - if update != self.filter_query(): - self.update_list() - - def raise_window(self): - self.grab_set() - self.lift() - - -class TaskEditWindow(Window): - """Task properties window.""" - def __init__(self, taskid, parent=None, variable=None, **options): - super().__init__(master=parent, **options) - # Connected with external IntVar. Needed to avoid unnecessary operations in parent window: - self.change = variable - # Task information from database: - self.task = self.db.select_task(taskid) - # List of dates connected with this task: - dates = [x[0] + " - " + core.time_format(x[1]) for x in self.db.find_by_clause('activity', 'task_id', '%s' % - taskid, 'date, spent_time', 'date')] - self.title("Task properties: {}".format(self.db.find_by_clause('tasks', 'id', taskid, 'name')[0][0])) - self.minsize(width=400, height=300) - elements.SimpleLabel(self, text="Task name:", fontsize=10).grid(row=0, column=0, pady=5, padx=5, sticky='w') - # Frame containing task name: - TaskLabel(self, width=60, height=1, bg=GLOBAL_OPTIONS["colour"], text=self.task[1], - anchor='w').grid(row=1, columnspan=5, sticky='ew', padx=6) - tk.Frame(self, height=30).grid(row=2) - elements.SimpleLabel(self, text="Description:", fontsize=10).grid(row=3, column=0, pady=5, padx=5, sticky='w') - # Task description frame. Editable: - self.description = Description(self, paste_menu=True, width=60, height=6) - self.description.config(state='normal', bg='white') - if self.task[3]: - self.description.insert(self.task[3]) - self.description.grid(row=4, columnspan=5, sticky='ewns', padx=5) - # - elements.SimpleLabel(self, text='Tags:').grid(row=5, column=0, pady=5, padx=5, sticky='nw') - # Place tags list: - self.tags_update() - elements.TaskButton(self, text='Edit tags', textwidth=10, command=self.tags_edit).grid(row=5, column=4, padx=5, pady=5, sticky='e') - elements.SimpleLabel(self, text='Time spent:').grid(row=6, column=0, padx=5, pady=5, sticky='w') - # Frame containing time: - TaskLabel(self, width=11, text='{}'.format(core.time_format(self.task[2]))).grid(row=6, column=1, - pady=5, padx=5, sticky='w') - elements.SimpleLabel(self, text='Dates:').grid(row=6, column=2, sticky='w') - # Frame containing list of dates connected with current task: - datlist = Description(self, height=3, width=30) - datlist.update_text('\n'.join(dates)) - datlist.grid(row=6, column=3, rowspan=3, columnspan=2, sticky='ew', padx=5, pady=5) - # - tk.Frame(self, height=40).grid(row=9) - elements.TaskButton(self, text='Ok', command=self.update_task).grid(row=10, column=0, sticky='sw', padx=5, pady=5) - elements.TaskButton(self, text='Cancel', command=self.destroy).grid(row=10, column=4, sticky='se', padx=5, pady=5) - self.grid_columnconfigure(1, weight=1) - self.grid_columnconfigure(3, weight=10) - self.grid_rowconfigure(4, weight=1) - self.description.text.focus_set() - self.prepare() - - def tags_edit(self): - """Open tags editor window.""" - TagsEditWindow(self) - self.tags_update() - self.grab_set() - self.lift() - self.focus_set() - - def tags_update(self): - """Tags list placing.""" - # Tags list. Tags state are saved to database: - self.tags = Tagslist(self.db.tags_dict(self.task[0]), self, orientation='horizontal', width=300, height=30) - self.tags.grid(row=5, column=1, columnspan=3, pady=5, padx=5, sticky='we') - - def update_task(self): - """Update task in database.""" - taskdata = self.description.get().rstrip() - self.db.update_task(self.task[0], field='description', value=taskdata) - # Renew tags list for the task: - existing_tags = [x[0] for x in self.db.find_by_clause('tasks_tags', 'task_id', self.task[0], 'tag_id')] - for item in self.tags.states_list: - if item[1][0].get() == 1: - if item[0] not in existing_tags: - self.db.insert('tasks_tags', ('task_id', 'tag_id'), (self.task[0], item[0])) - else: - self.db.delete(table="tasks_tags", task_id=self.task[0], tag_id=item[0]) - # Reporting to parent window that task has been changed: - if self.change: - self.change.set(1) - self.destroy() - - -class TagsEditWindow(Window): - """Checkbuttons editing window..""" - def __init__(self, parent=None, **options): - super().__init__(master=parent, **options) - self.parent = parent - self.addentry() - self.tags_update() - self.closebutton = elements.TaskButton(self, text='Close', command=self.destroy) - self.deletebutton = elements.TaskButton(self, text='Delete', command=self.delete) - self.maxsize(width=500, height=500) - self.window_elements_config() - self.prepare() - - def window_elements_config(self): - """Window additional parameters configuration.""" - self.title("Tags editor") - self.minsize(width=300, height=300) - self.closebutton.grid(row=2, column=2, pady=5, padx=5, sticky='e') - self.deletebutton.grid(row=2, column=0, pady=5, padx=5, sticky='w') - - def addentry(self): - """New element addition field""" - self.addentry_label = elements.SimpleLabel(self, text="Add tag:") - self.addentry_label.grid(row=0, column=0, pady=5, padx=5, sticky='w') - elements.TaskButton(self, text='Add', command=self.add).grid(row=0, column=2, pady=5, padx=5, sticky='e') - self.addfield = elements.SimpleEntry(self, width=20) - self.addfield.grid(row=0, column=1, sticky='ew') - self.addfield.focus_set() - self.addfield.bind('', lambda event: self.add()) - - def tags_update(self): - """Tags list recreation.""" - if hasattr(self, 'tags'): - self.tags.destroy() - self.tags_get() - self.tags.grid(row=1, column=0, columnspan=3, sticky='news') - self.grid_rowconfigure(1, weight=1) - self.grid_columnconfigure(1, weight=1) - - def add(self): - """Insert new element into database.""" - tagname = self.addfield.get() - if tagname: - try: - self.add_record(tagname) - except core.DbErrors: - self.db.reconnect() - else: - self.tags_update() - - def delete(self): - """Remove selected elements from database.""" - dellist = [] - for item in self.tags.states_list: - if item[1][0].get() == 1: - dellist.append(item[0]) - if dellist: - answer = askyesno("Really delete?", "Are you sure you want to delete selected items?", parent=self) - if answer: - self.del_record(dellist) - self.tags_update() - - def tags_get(self): - self.tags = Tagslist(self.db.simple_tagslist(), self, width=300, height=300) - - def add_record(self, tagname): - self.db.insert('tags', ('id', 'name'), (None, tagname)) - - def del_record(self, dellist): - self.db.delete(id=dellist, table='tags') - self.db.delete(tag_id=dellist, table='tasks_tags') - - -class TimestampsWindow(TagsEditWindow): - """Window with timestamps for selected task.""" - def __init__(self, taskid, current_task_time, parent=None, **options): - self.taskid = taskid - self.current_time = current_task_time - super().__init__(parent=parent, **options) - - def select_all(self): - for item in self.tags.states_list: - item[1][0].set(1) - - def clear_all(self): - for item in self.tags.states_list: - item[1][0].set(0) - - def window_elements_config(self): - """Configure some window parameters.""" - self.title("Timestamps: {}".format(self.db.find_by_clause('tasks', 'id', self.taskid, 'name')[0][0])) - self.minsize(width=400, height=300) - elements.TaskButton(self, text="Select all", command=self.select_all).grid(row=2, column=0, pady=5, padx=5, sticky='w') - elements.TaskButton(self, text="Clear all", command=self.clear_all).grid(row=2, column=2, pady=5, padx=5, sticky='e') - tk.Frame(self, height=40).grid(row=3) - self.closebutton.grid(row=4, column=2, pady=5, padx=5, sticky='w') - self.deletebutton.grid(row=4, column=0, pady=5, padx=5, sticky='e') - - def addentry(self): - """Empty method just for suppressing unnecessary element creation.""" - pass - - def tags_get(self): - """Creates timestamps list.""" - self.tags = Tagslist(self.db.timestamps(self.taskid, self.current_time), self, width=400, height=300) - - def del_record(self, dellist): - """Deletes selected timestamps.""" - for x in dellist: - self.db.delete(table="timestamps", timestamp=x, task_id=self.taskid) - - -class HelpWindow(Window): - """Help window.""" - def __init__(self, parent=None, text='', **options): - super().__init__(master=parent, **options) - self.title("Help") - main_frame = tk.Frame(self) - self.helptext = Description(main_frame, fontsize=13) - self.helptext.insert(text) - self.helptext.config(state='disabled') - self.helptext.grid(row=0, column=0, sticky='news') - main_frame.grid(row=0, column=0, sticky='news', padx=5, pady=5) - main_frame.grid_rowconfigure(0, weight=1) - main_frame.grid_columnconfigure(0, weight=1) - elements.TaskButton(self, text='OK', command=self.destroy).grid(row=1, column=0, sticky='e', pady=5, padx=5) - self.grid_columnconfigure(0, weight=1) - self.grid_rowconfigure(0, weight=1) - self.bind("", lambda e: self.destroy()) - self.prepare() - - -class Tagslist(elements.ScrolledCanvas): - """Tags list. Accepts tagslist: [[tag_id, [state, 'tagname']]], can be 0 or 1.""" - def __init__(self, tagslist, parent=None, orientation="vertical", **options): - super().__init__(parent=parent, orientation=orientation, **options) - self.states_list = tagslist - for item in self.states_list: - # Saving tag state: - state = item[1][0] - # Inserting dynamic variable instead of the state: - item[1][0] = tk.IntVar() - # Connecting new checkbox with this dynamic variable: - cb = elements.SimpleCheckbutton(self.content_frame, text=(item[1][1] + ' ' * 3 if orientation == "horizontal" - else item[1][1]), variable=item[1][0]) - cb.pack(side=('left' if orientation == "horizontal" else 'bottom'), anchor='w') - # Setting dynamic variable value to previously saved state: - item[1][0].set(state) - - -class FilterWindow(Window): - """Filters window.""" - def __init__(self, parent=None, variable=None, **options): - super().__init__(master=parent, **options) - self.title("Filter") - self.changed = variable # IntVar instance: used to set 1 if some changes were made. For optimization. - self.operating_mode = tk.StringVar() # Operating mode of the filter: "AND", "OR". - # Lists of stored filter parameters: - stored_dates = self.db.find_by_clause('options', 'name', 'filter_dates', 'value')[0][0].split(',') - stored_tags = self.db.find_by_clause('options', 'name', 'filter_tags', 'value')[0][0].split(',') - if stored_tags[0]: # stored_tags[0] is string. - stored_tags = list(map(int, stored_tags)) - # Dates list: - dates = self.db.simple_dateslist() - # Tags list: - tags = self.db.simple_tagslist() - # Checking checkboxes according to their values loaded from database: - for tag in tags: - if tag[0] in stored_tags: - tag[1][0] = 1 - elements.SimpleLabel(self, text="Dates").grid(row=0, column=0, sticky='n') - elements.SimpleLabel(self, text="Tags").grid(row=0, column=1, sticky='n') - self.dateslist = Tagslist([[x, [1 if x in stored_dates else 0, x]] for x in dates], self, width=200, height=300) - self.tagslist = Tagslist(tags, self, width=200, height=300) - self.dateslist.grid(row=1, column=0, pady=5, padx=5, sticky='news') - self.tagslist.grid(row=1, column=1, pady=5, padx=5, sticky='news') - elements.TaskButton(self, text="Select dates...", textwidth=15, command=self.select_dates).grid(row=2, column=0, pady=7, padx=5, sticky='n') - elements.TaskButton(self, text="Clear", command=self.clear_tags).grid(row=2, column=1, pady=7, padx=5, sticky='n') - elements.TaskButton(self, text="Clear", command=self.clear_dates).grid(row=3, column=0, pady=7, padx=5, sticky='n') - tk.Frame(self, height=20).grid(row=5, column=0, columnspan=2, sticky='news') - elements.SimpleLabel(self, text="Filter operating mode:").grid(row=5, columnspan=2, pady=5) - checkframe = tk.Frame(self) - checkframe.grid(row=7, columnspan=2, pady=5) - elements.SimpleRadiobutton(checkframe, text="AND", variable=self.operating_mode, value="AND").grid(row=0, column=0, sticky='e') - elements.SimpleRadiobutton(checkframe, text="OR", variable=self.operating_mode, value="OR").grid(row=0, column=1, sticky='w') - self.operating_mode.set(self.db.find_by_clause(table="options", field="name", - value="filter_operating_mode", searchfield="value")[0][0]) - tk.Frame(self, height=20).grid(row=8, column=0, columnspan=2, sticky='news') - elements.TaskButton(self, text="Cancel", command=self.destroy).grid(row=9, column=1, pady=5, padx=5, sticky='e') - elements.TaskButton(self, text='Ok', command=self.apply_filter).grid(row=9, column=0, pady=5, padx=5, sticky='w') - self.bind("", lambda e: self.apply_filter()) - self.minsize(height=350, width=350) - self.maxsize(width=750, height=600) - self.grid_columnconfigure(0, weight=1) - self.grid_columnconfigure(1, weight=5) - self.grid_rowconfigure(1, weight=1) - self.prepare() - - def clear_dates(self): - for x in self.dateslist.states_list: - x[1][0].set(0) - - def clear_tags(self): - for x in self.tagslist.states_list: - x[1][0].set(0) - - def select_dates(self): - """Pops up window where user can select dates interval.""" - start_date = tk.StringVar(self) - end_date = tk.StringVar(self) - correct = tk.DoubleVar(self) - CalendarWindow(self, correct, startvar=start_date, endvar=end_date, - startdate=self.dateslist.states_list[-1][0], - enddate=self.dateslist.states_list[0][0]) - if correct.get(): - for date in self.dateslist.states_list: - date[1][0].set(0) - if core.str_to_date(start_date.get()) <= core.str_to_date(date[0]) <= core.str_to_date(end_date.get()): - date[1][0].set(1) - - def apply_filter(self): - """Create database script based on checkboxes values.""" - dates = list(reversed([x[0] for x in self.dateslist.states_list if x[1][0].get() == 1])) - tags = list(reversed([x[0] for x in self.tagslist.states_list if x[1][0].get() == 1])) - if not dates and not tags: - script = None - self.operating_mode.set("AND") - else: - if self.operating_mode.get() == "OR": - script = 'SELECT id, name, total_time, description, creation_date FROM tasks JOIN activity ON '\ - 'activity.task_id=tasks.id JOIN tasks_tags ON tasks_tags.task_id=tasks.id '\ - 'JOIN (SELECT task_id, sum(spent_time) AS total_time FROM activity GROUP BY task_id) AS act ' \ - 'ON act.task_id=tasks.id WHERE date IN {1} OR tag_id IN {0} ' \ - 'GROUP BY act.task_id'.format("('%s')" % tags[0] if len(tags) == 1 else tuple(tags), - "('%s')" % dates[0] if len(dates) == 1 else tuple(dates)) - else: - if dates and tags: - script = 'SELECT DISTINCT id, name, total_time, description, creation_date FROM tasks JOIN (SELECT ' \ - 'task_id, sum(spent_time) AS total_time FROM activity WHERE activity.date IN {0} GROUP BY ' \ - 'task_id) AS act ON act.task_id=tasks.id JOIN (SELECT tt.task_id FROM tasks_tags AS tt WHERE '\ - 'tt.tag_id IN {1} GROUP BY tt.task_id HAVING COUNT(DISTINCT tt.tag_id)={3}) AS x ON ' \ - 'x.task_id=tasks.id JOIN (SELECT act.task_id FROM activity AS act WHERE act.date IN {0} ' \ - 'GROUP BY act.task_id HAVING COUNT(DISTINCT act.date)={2}) AS y ON ' \ - 'y.task_id=tasks.id'.format("('%s')" % dates[0] if len(dates) == 1 else tuple(dates), - "('%s')" % tags[0] if len(tags) == 1 else tuple(tags), - len(dates), len(tags)) - elif not dates: - script = 'SELECT DISTINCT id, name, total_time, description, creation_date FROM tasks JOIN (SELECT ' \ - 'task_id, sum(spent_time) AS total_time FROM activity GROUP BY ' \ - 'task_id) AS act ON act.task_id=tasks.id JOIN (SELECT tt.task_id FROM tasks_tags AS tt WHERE ' \ - 'tt.tag_id IN {0} GROUP BY tt.task_id HAVING COUNT(DISTINCT tt.tag_id)={1}) AS x ON ' \ - 'x.task_id=tasks.id'.format(tuple(tags) if len(tags) > 1 else "(%s)" % tags[0], len(tags)) - elif not tags: - script = 'SELECT DISTINCT id, name, total_time, description, creation_date FROM tasks JOIN (SELECT ' \ - 'task_id, sum(spent_time) AS total_time FROM activity WHERE activity.date IN {0} GROUP BY ' \ - 'task_id) AS act ON act.task_id=tasks.id JOIN (SELECT act.task_id FROM activity AS act ' \ - 'WHERE act.date IN {0} ' \ - 'GROUP BY act.task_id HAVING COUNT(DISTINCT act.date)={1}) AS y ON ' \ - 'y.task_id=tasks.id'.format(tuple(dates) if len(dates) > 1 else "('%s')" % dates[0], - len(dates)) - GLOBAL_OPTIONS["filter_dict"] = { - 'operating_mode': self.operating_mode.get(), - 'script': script, - 'tags': tags, - 'dates': dates - } - # Reporting to parent window that filter values have been changed: - if self.changed: - self.changed.set(1) - self.destroy() - - -class CalendarWindow(Window): - def __init__(self, parent=None, correct_data=None, startvar=None, endvar=None, startdate=None, enddate=None, **options): - super().__init__(master=parent, **options) - self.title("Select dates") - self.correct_data = correct_data - self.start = startvar - self.end = endvar - self.start_date_entry = sel_cal.Datepicker(self, datevar=self.start, - current_month=core.str_to_date(startdate).month, - current_year=core.str_to_date(startdate).year) - self.end_date_entry = sel_cal.Datepicker(self, datevar=self.end, - current_month=core.str_to_date(enddate).month, - current_year=core.str_to_date(enddate).year) - elements.SimpleLabel(self, text="Enter first date:").grid(row=0, column=0, pady=3, padx=5, sticky='w') - self.start_date_entry.grid(row=1, column=0, padx=5, pady=3, sticky='w') - elements.SimpleLabel(self, text="Enter last date:").grid(row=2, column=0, pady=5, padx=5, sticky='w') - self.end_date_entry.grid(row=3, column=0, padx=5, pady=3, sticky='w') - tk.Frame(self, height=15, width=10).grid(row=4, column=0, columnspan=2) - elements.TaskButton(self, text='OK', command=self.close).grid(row=5, column=0, padx=5, pady=5, sticky='w') - elements.TaskButton(self, text='Cancel', command=self.destroy).grid(row=5, column=1, padx=5, pady=5, sticky='e') - self.bind("", lambda e: self.close()) - self.minsize(height=350, width=450) - self.maxsize(width=600, height=500) - self.grid_columnconfigure(0, weight=1) - self.grid_columnconfigure(1, weight=1) - self.grid_rowconfigure(4, weight=1) - self.prepare() - - def close(self): - try: - core.str_to_date(self.start.get()) - core.str_to_date(self.end.get()) - except ValueError: - self.correct_data.set(False) - else: - self.correct_data.set(True) - finally: - super().destroy() - - def destroy(self): - self.correct_data.set(False) - super().destroy() - - -class RightclickMenu(tk.Menu): - """Popup menu. By default has one menuitem - "copy".""" - def __init__(self, parent=None, copy_item=True, paste_item=False, **options): - super().__init__(master=parent, tearoff=0, **options) - if copy_item: - self.add_command(label="Copy", command=copy_to_clipboard) - if paste_item: - self.add_command(label="Paste", command=paste_from_clipboard) - - def context_menu_show(self, event): - """Function links context menu with current selected widget and pops menu up.""" - self.tk_popup(event.x_root, event.y_root) - GLOBAL_OPTIONS["selected_widget"] = event.widget - - -class MainFrame(elements.ScrolledCanvas): - """Container for all task frames.""" - def __init__(self, parent): - super().__init__(parent=parent, bd=2) - self.frames_count = 0 - self.rows_counter = 0 - self.fill() - - def clear(self): - """Clear all task frames except with opened tasks.""" - for w in self.content_frame.winfo_children(): - if self.frames_count == int(GLOBAL_OPTIONS['timers_count']) or self.frames_count == len(GLOBAL_OPTIONS["tasks"]): - break - if hasattr(w, 'task'): - if w.task is None: - self.frames_count -= 1 - w.destroy() - - def clear_all(self): - """Clear all task frames.""" - answer = askyesno("Really clear?", "Are you sure you want to close all task frames?") - if answer: - for w in self.content_frame.winfo_children(): - self.frames_count -= 1 - w.destroy() - self.fill() - - def frames_refill(self): - """Reload data in every task frame with data.""" - for w in self.content_frame.winfo_children(): - if hasattr(w, 'task'): - if w.task: - state = w.running - w.timer_stop() - w.prepare_task(w.db.select_task(w.task_id)) - if state: - w.timer_start() - - def fill(self): - """Create contents of the main frame.""" - if self.frames_count < int(GLOBAL_OPTIONS['timers_count']): - row_count = range(int(GLOBAL_OPTIONS['timers_count']) - self.frames_count) - for row_number in row_count: - task = TaskFrame(parent=self.content_frame) - task.grid(row=self.rows_counter, pady=5, padx=5, ipady=3, sticky='ew') - if GLOBAL_OPTIONS["preserved_tasks_list"]: - task_id = GLOBAL_OPTIONS["preserved_tasks_list"].pop(0) - task.get_restored_task_name(task_id) - self.rows_counter += 1 - self.frames_count += len(row_count) - self.content_frame.update() - self.canvbox.config(width=self.content_frame.winfo_width()) - elif len(GLOBAL_OPTIONS["tasks"]) < self.frames_count > int(GLOBAL_OPTIONS['timers_count']): - self.clear() - self.content_frame.config(bg='#cfcfcf') - - def change_interface(self, interface): - """Change interface type. Accepts keywords 'normal' and 'small'.""" - for widget in self.content_frame.winfo_children(): - try: - if interface == 'normal': - widget.normal_interface() - elif interface == 'small': - widget.small_interface() - except TclError: - pass - - -class MainMenu(tk.Menu): - """Main window menu.""" - def __init__(self, parent=None, **options): - super().__init__(master=parent, **options) - file = tk.Menu(self, tearoff=0) - file.add_command(label="Options...", command=self.options_window, underline=0) - file.add_separator() - file.add_command(label="Exit", command=self.exit, underline=1) - elements.big_font(file, 10) - self.add_cascade(label="Main menu", menu=file, underline=0) - helpmenu = tk.Menu(self, tearoff=0) - helpmenu.add_command(label="Help...", command=lambda: helpwindow(parent=run, text=core.HELP_TEXT)) - helpmenu.add_command(label="About...", command=self.aboutwindow) - elements.big_font(helpmenu, 10) - self.add_cascade(label="Help", menu=helpmenu) - elements.big_font(self, 10) - - def options_window(self): - """Open options window.""" - # number of main window frames: - var = tk.IntVar(value=int(GLOBAL_OPTIONS['timers_count'])) - # 'always on top' option: - ontop = tk.IntVar(value=int(GLOBAL_OPTIONS['always_on_top'])) - # 'compact interface' option - compact = int(GLOBAL_OPTIONS['compact_interface']) - compact_iface = tk.IntVar(value=compact) - # 'save tasks on exit' option: - save = tk.IntVar(value=int(GLOBAL_OPTIONS['preserve_tasks'])) - # 'show current day in timers' option: - show_today = tk.IntVar(value=int(GLOBAL_OPTIONS['show_today'])) - toggle = int(GLOBAL_OPTIONS['toggle_tasks']) - toggler = tk.IntVar(value=toggle) - params = {} - accept = tk.BooleanVar() - Options(run, accept, var, ontop, compact_iface, save, show_today, toggler) - if accept.get(): - try: - count = var.get() - except tk.TclError: - pass - else: - if count < 1: - count = 1 - elif count > GLOBAL_OPTIONS["MAX_TASKS"]: - count = GLOBAL_OPTIONS["MAX_TASKS"] - params['timers_count'] = count - # apply value of 'always on top' option: - params['always_on_top'] = ontop.get() - run.wm_attributes("-topmost", ontop.get()) - # apply value of 'compact interface' option: - params['compact_interface'] = compact_iface.get() - if compact != compact_iface.get(): - if compact_iface.get() == 0: - run.full_interface() - elif compact_iface.get() == 1: - run.small_interface() - # apply value of 'save tasks on exit' option: - params['preserve_tasks'] = save.get() - # apply value of 'show current day in timers' option: - params['show_today'] = show_today.get() - # apply value of 'Allow run only one task at a time' option: - params['toggle_tasks'] = toggler.get() - # save all parameters to DB: - self.change_parameter(params) - # redraw taskframes if needed: - run.taskframes.fill() - run.taskframes.frames_refill() - # Stop all tasks if exclusive run method has been enabled: - if int(GLOBAL_OPTIONS["toggle_tasks"]) and int(GLOBAL_OPTIONS["toggle_tasks"]) != toggle: - GLOBAL_OPTIONS["stopall"] = True - run.lift() - - def change_parameter(self, paramdict): - """Change option in the database.""" - db = core.Db() - for parameter_name in paramdict: - par = str(paramdict[parameter_name]) - db.update(table='options', field='value', value=par, - field_id=parameter_name, updfiled='name') - GLOBAL_OPTIONS[parameter_name] = par - db.con.close() - - def aboutwindow(self): - showinfo("About Tasker", "Tasker {0}.\nCopyright (c) Alexey Kallistov, {1}".format( - GLOBAL_OPTIONS['version'], datetime.datetime.strftime(datetime.datetime.now(), "%Y"))) - - def exit(self): - run.destroy() - - -class Options(Window): - """Options window which can be opened from main menu.""" - def __init__(self, parent, is_applied, counter, on_top, compact, preserve, show_today, toggler, **options): - super().__init__(master=parent, width=300, height=200, **options) - self.is_applied = is_applied - self.title("Options") - self.resizable(height=0, width=0) - self.counter = counter - elements.SimpleLabel(self, text="Task frames in main window: ").grid(row=0, column=0, sticky='w') - counterframe = tk.Frame(self) - fontsize = 9 - elements.CanvasButton(counterframe, text='<', command=self.decrease, fontsize=fontsize, height=fontsize * 3).grid(row=0, column=0) - elements.SimpleEntry(counterframe, width=3, textvariable=counter, justify='center').grid(row=0, column=1, sticky='e') - elements.CanvasButton(counterframe, text='>', command=self.increase, fontsize=fontsize, height=fontsize * 3).grid(row=0, column=2) - counterframe.grid(row=0, column=1) - tk.Frame(self, height=20).grid(row=1) - elements.SimpleLabel(self, text="Always on top: ").grid(row=2, column=0, sticky='w', padx=5) - elements.SimpleCheckbutton(self, variable=on_top).grid(row=2, column=1, sticky='w', padx=5) - elements.SimpleLabel(self, text="Compact interface: ").grid(row=3, column=0, sticky='w', padx=5) - elements.SimpleCheckbutton(self, variable=compact).grid(row=3, column=1, sticky='w', padx=5) - elements.SimpleLabel(self, text="Save tasks on exit: ").grid(row=4, column=0, sticky='w', padx=5) - elements.SimpleCheckbutton(self, variable=preserve).grid(row=4, column=1, sticky='w', padx=5) - elements.SimpleLabel(self, text="Show time for current day only in timer's window: ").grid(row=5, column=0, sticky='w', padx=5) - elements.SimpleCheckbutton(self, variable=show_today).grid(row=5, column=1, sticky='w', padx=5) - elements.SimpleLabel(self, text="Allow to run only one task at a time: ").grid(row=6, column=0, sticky='w', padx=5) - elements.SimpleCheckbutton(self, variable=toggler).grid(row=6, column=1, sticky='w', padx=5) - tk.Frame(self, height=20).grid(row=7) - elements.TaskButton(self, text='OK', command=self.apply).grid(row=8, column=0, sticky='w', padx=5, pady=5) - elements.TaskButton(self, text='Cancel', command=self.destroy).grid(row=8, column=1, sticky='e', padx=5, pady=5) - self.bind("", lambda e: self.apply()) - self.prepare() - - def apply(self): - self.is_applied.set(True) - self.destroy() - - def increase(self): - if self.counter.get() < GLOBAL_OPTIONS["MAX_TASKS"]: - self.counter.set(self.counter.get() + 1) - - def decrease(self): - if self.counter.get() > 1: - self.counter.set(self.counter.get() - 1) - - -class ExportWindow(Window): - """Export dialogue window.""" - def __init__(self, parent, data, **options): - super().__init__(master=parent, **options) - self.title("Export parameters") - self.task_ids = [x[0] for x in data.values()] - self.operating_mode = tk.IntVar(self) - elements.SimpleLabel(self, text="Export mode", fontsize=10).grid(row=0, column=0, columnspan=2, sticky='ns', pady=5) - elements.SimpleRadiobutton(self, text="Task-based", variable=self.operating_mode, value=0).grid(row=1, column=0) - elements.SimpleRadiobutton(self, text="Date-based", variable=self.operating_mode, value=1).grid(row=1, column=1) - tk.Frame(self, height=15).grid(row=2, column=0) - elements.TaskButton(self, text="Export", command=self.get_data).grid(row=3, column=0, padx=5, pady=5, sticky='ws') - elements.TaskButton(self, text="Cancel", command=self.destroy).grid(row=3, column=1, padx=5, pady=5, sticky='es') - self.minsize(height=150, width=250) - self.maxsize(width=450, height=300) - self.grid_columnconfigure('all', weight=1) - self.grid_rowconfigure('all', weight=1) - self.prepare() - - def get_data(self): - """Take from the database information to be exported and prepare it. All items should be strings.""" - if self.operating_mode.get() == 0: - export_data = self.db.tasks_to_export(self.task_ids) - prepared_data = ['Task,Description,Dates,Time,Summarized working time'] - # Don't try to understand this 'for' loop below if you want to save your mind! - for key in export_data: - temp_list = [key, export_data[key][0], export_data[key][1][0][0], export_data[key][1][0][1], - export_data[key][2]] - prepared_data.append(','.join(temp_list)) - if len(export_data[key][1]) > 1: - for i in range(1, len(export_data[key][1])): - prepared_data.append(','.join(['', '', export_data[key][1][i][0], export_data[key][1][i][1], ''])) - i += 1 - else: - export_data = self.db.dates_to_export(self.task_ids) - prepared_data = ['Date,Tasks,Descriptions,Time,Summarized working time'] - for key in export_data: - temp_list = [key, export_data[key][0][0][0], export_data[key][0][0][1], export_data[key][0][0][2], - export_data[key][1]] - prepared_data.append(','.join(temp_list)) - if len(export_data[key][0]) > 1: - for i in range(1, len(export_data[key][0])): - prepared_data.append(','.join(['', export_data[key][0][i][0], export_data[key][0][i][1], - export_data[key][0][i][2], ''])) - i += 1 - self.export('\n'.join(prepared_data)) - - def export(self, data): - filename = asksaveasfilename(parent=self, defaultextension=".csv", - filetypes=[("All files", "*.*"), ("Comma-separated texts", "*.csv")]) - if filename: - core.write_to_disk(filename, data) - self.destroy() - - -class MainWindow(tk.Tk): - def __init__(self, **options): - super().__init__(**options) - # Default widget colour: - GLOBAL_OPTIONS["colour"] = self.cget('bg') - self.title("Tasker") - self.minsize(height=75, width=0) - self.resizable(width=0, height=1) - main_menu = MainMenu(self) # Create main menu. - self.config(menu=main_menu) - self.taskframes = MainFrame(self) # Main window content. - self.taskframes.grid(row=0, columnspan=5) - self.bind("", self.taskframes.reconf_canvas) - if GLOBAL_OPTIONS["compact_interface"] == "0": - self.full_interface(True) - self.grid_rowconfigure(0, weight=1) - # Make main window always appear in good position and with adequate size: - self.update() - if self.winfo_height() < self.winfo_screenheight() - 250: - window_height = self.winfo_height() - else: - window_height = self.winfo_screenheight() - 250 - self.geometry('%dx%d+100+50' % (self.winfo_width(), window_height)) - if GLOBAL_OPTIONS['always_on_top'] == '1': - self.wm_attributes("-topmost", 1) - self.bind("", self.hotkeys) - - def hotkeys(self, event): - """Execute corresponding actions for hotkeys.""" - if event.keysym in ('Cyrillic_yeru', 'Cyrillic_YERU', 's', 'S'): - self.stopall() - elif event.keysym in ('Cyrillic_es', 'Cyrillic_ES', 'c', 'C'): - self.taskframes.clear_all() - elif event.keysym in ('Cyrillic_shorti', 'Cyrillic_SHORTI', 'q', 'Q', 'Escape'): - self.destroy() - - def full_interface(self, firstrun=False): - """Create elements which are displayed in full interface mode.""" - self.add_frame = tk.Frame(self, height=35) - self.add_frame.grid(row=1, columnspan=5) - self.add_stop_button = elements.TaskButton(self, text="Stop all", command=self.stopall) - self.add_stop_button.grid(row=2, column=2, sticky='sn', pady=5, padx=5) - self.add_clear_button = elements.TaskButton(self, text="Clear all", command=self.taskframes.clear_all) - self.add_clear_button.grid(row=2, column=0, sticky='wsn', pady=5, padx=5) - self.add_quit_button = elements.TaskButton(self, text="Quit", command=self.destroy) - self.add_quit_button.grid(row=2, column=4, sticky='sne', pady=5, padx=5) - if not firstrun: - self.taskframes.change_interface('normal') - - def small_interface(self): - """Destroy all additional interface elements.""" - for widget in self.add_frame, self.add_stop_button, self.add_clear_button, self.add_quit_button: - widget.destroy() - self.taskframes.change_interface('small') - - def stopall(self): - """Stop all running timers.""" - GLOBAL_OPTIONS["stopall"] = True - - def destroy(self): - answer = askyesno("Quit confirmation", "Do you really want to quit?") - if answer: - db = core.Db() - if GLOBAL_OPTIONS["preserve_tasks"] == "1": - tasks = ','.join([str(x) for x in GLOBAL_OPTIONS["tasks"]]) - if int(GLOBAL_OPTIONS['timers_count']) < len(GLOBAL_OPTIONS["tasks"]): - db.update(table='options', field='value', value=len(GLOBAL_OPTIONS["tasks"]), - field_id='timers_count', updfiled='name') - else: - tasks = '' - db.update(table='options', field='value', value=tasks, - field_id='tasks', updfiled='name') - db.con.close() - super().destroy() - - -def helpwindow(parent=None, text=None): - """Show simple help window with given text.""" - HelpWindow(parent, text) - - -def copy_to_clipboard(): - """Copy widget text to clipboard.""" - GLOBAL_OPTIONS["selected_widget"].clipboard_clear() - if isinstance(GLOBAL_OPTIONS["selected_widget"], tk.Text): - try: - GLOBAL_OPTIONS["selected_widget"].clipboard_append(GLOBAL_OPTIONS["selected_widget"].selection_get()) - except tk.TclError: - GLOBAL_OPTIONS["selected_widget"].clipboard_append(GLOBAL_OPTIONS["selected_widget"].get(1.0, 'end')) - else: - GLOBAL_OPTIONS["selected_widget"].clipboard_append(GLOBAL_OPTIONS["selected_widget"].cget("text")) - - -def paste_from_clipboard(): - """Paste text from clipboard.""" - if isinstance(GLOBAL_OPTIONS["selected_widget"], tk.Text): - GLOBAL_OPTIONS["selected_widget"].insert(tk.INSERT, GLOBAL_OPTIONS["selected_widget"].clipboard_get()) - elif isinstance(GLOBAL_OPTIONS["selected_widget"], tk.Entry): - GLOBAL_OPTIONS["selected_widget"].insert(0, GLOBAL_OPTIONS["selected_widget"].clipboard_get()) - - -def get_options(): - """Get program preferences from database.""" - db = core.Db() - return {x[0]: x[1] for x in db.find_all(table='options')} - - -if __name__ == "__main__": - # Maximum number of task frames: - MAX_TASKS = 10 - # Interval between saving time to database: - SAVE_INTERVAL = 10000 # ms - # Check if tasks database actually exists: - core.check_database() - # Create options dictionary: - GLOBAL_OPTIONS = get_options() - # Global tasks ids set. Used for preserve duplicates: - if GLOBAL_OPTIONS["tasks"]: - GLOBAL_OPTIONS["tasks"] = dict.fromkeys([int(x) for x in GLOBAL_OPTIONS["tasks"].split(",")], False) - else: - GLOBAL_OPTIONS["tasks"] = dict() - # List of preserved tasks which are not open: - GLOBAL_OPTIONS["preserved_tasks_list"] = list(GLOBAL_OPTIONS["tasks"]) - # If True, all running timers will be stopped: - GLOBAL_OPTIONS["stopall"] = False - # Widget which is currently connected to context menu: - GLOBAL_OPTIONS["selected_widget"] = None - GLOBAL_OPTIONS.update({"MAX_TASKS": MAX_TASKS, "SAVE_INTERVAL": SAVE_INTERVAL}) - - # Main window: - run = MainWindow() - run.mainloop()