diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index f418a595fa..8259fbedc9 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -9,7 +9,7 @@ on: branches: [ master ] jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v2 - name: Set up Python 3.10 @@ -20,10 +20,9 @@ jobs: run: | python -m pip install --upgrade pip sudo apt-get update - sudo apt install -y libgirepository1.0-dev gir1.2-gtk-3.0 libgtksourceview-4-dev + sudo apt install -y libgirepository1.0-dev gir1.2-gtk-4.0 libgtksourceview-5-dev pip install --user -e git+https://github.com/getting-things-gnome/liblarch.git#egg=liblarch pip install --user pytest pycairo PyGObject caldav mock lxml - name: Run unit tests with Pytest run: | ./run-tests - diff --git a/GTG/backends/__init__.py b/GTG/backends/__init__.py index d7b740e78f..fe84cb3adb 100644 --- a/GTG/backends/__init__.py +++ b/GTG/backends/__init__.py @@ -170,13 +170,4 @@ def get_saved_backends_list(self): backend_data['backend'] = module.Backend(backend_data) backends.append(backend_data) - # If no backend available, we create a new using localfile. Dic - # will be filled in by the backend - if not backends: - dic = BackendFactory().get_new_backend_dict( - "backend_localfile") - - dic["first_run"] = True - backends.append(dic) - return backends diff --git a/GTG/backends/backend_caldav.py b/GTG/backends/backend_caldav.py index b199eeb833..d3edecf4ae 100644 --- a/GTG/backends/backend_caldav.py +++ b/GTG/backends/backend_caldav.py @@ -34,7 +34,7 @@ from GTG.backends.periodic_import_backend import PeriodicImportBackend from GTG.core.dates import LOCAL_TIMEZONE, Accuracy, Date from GTG.core.interruptible import interruptible -from GTG.core.task import DisabledSyncCtx, Task +from GTG.core.tasks import Task, Status from vobject import iCalendar logger = logging.getLogger(__name__) @@ -107,7 +107,7 @@ def initialize(self) -> None: @interruptible def do_periodic_import(self) -> None: - with self.datastore.get_backend_mutex(): + with self.datastore.mutex: self._do_periodic_import() @interruptible @@ -115,8 +115,8 @@ def set_task(self, task: Task) -> None: if self._parameters["is-first-run"] or not self._cache.initialized: logger.warning("not loaded yet, ignoring set_task") return - with self.datastore.get_backend_mutex(): - return self._set_task(task) + with self.datastore.mutex: + self.datastore.tasks.add(task) @interruptible def remove_task(self, tid: str) -> None: @@ -126,8 +126,8 @@ def remove_task(self, tid: str) -> None: if not tid: logger.warning("no task id passed to remove_task call, ignoring") return - with self.datastore.get_backend_mutex(): - return self._remove_task(tid) + with self.datastore.mutex: + return self.datastore.tasks.remove(tid) # # real main methods @@ -151,10 +151,9 @@ def _do_periodic_import(self) -> None: self._cache.initialized = True def _set_task(self, task: Task) -> None: - logger.debug('set_task todo for %r', task.get_uuid()) - with DisabledSyncCtx(task, sync_on_exit=False): - seq_value = SEQUENCE.get_gtg(task, self.namespace) - SEQUENCE.write_gtg(task, seq_value + 1, self.namespace) + logger.debug('set_task todo for %r', task.id) + seq_value = SEQUENCE.get_gtg(task, self.namespace) + SEQUENCE.write_gtg(task, seq_value + 1, self.namespace) todo, calendar = self._get_todo_and_calendar(task) if not calendar: logger.info("%r has no calendar to be synced with", task) @@ -259,7 +258,7 @@ def _clean_task_missing_from_backend(self, uid: str, if do_delete: # the task was missing for a good reason counts['deleted'] += 1 self._cache.del_todo(uid) - self.datastore.request_task_deletion(uid) + self.datastore.tasks.remove(uid) @staticmethod def _denorm_children_on_vtodos(todos: list): @@ -298,12 +297,12 @@ def _import_calendar_todos(self, calendar: iCalendar, uid = UID_FIELD.get_dav(todo) self._cache.set_todo(todo, uid) # Updating and creating task according to todos - task = self.datastore.get_task(uid) + task = self.datastore.tasks.lookup[uid] if not task: # not found, creating it - task = self.datastore.task_factory(uid) - with DisabledSyncCtx(task): - Translator.fill_task(todo, task, self.namespace) - self.datastore.push_task(task) + task = Task() + task.id = uid + Translator.fill_task(todo, task, self.namespace, self.datastore) + self.datastore.tasks.add(task) counts['created'] += 1 else: result = self._update_task(task, todo) @@ -313,14 +312,13 @@ def _import_calendar_todos(self, calendar: iCalendar, logger.warning("Shouldn't be diff for %r", uid) def _update_task(self, task: Task, todo: iCalendar, force: bool = False): - with DisabledSyncCtx(task): - if not force: - task_seq = SEQUENCE.get_gtg(task, self.namespace) - todo_seq = SEQUENCE.get_dav(todo) - if task_seq >= todo_seq: - return 'unchanged' - Translator.fill_task(todo, task, self.namespace) - return 'updated' + if not force: + task_seq = SEQUENCE.get_gtg(task, self.namespace) + todo_seq = SEQUENCE.get_dav(todo) + if task_seq >= todo_seq: + return 'unchanged' + Translator.fill_task(todo, task, self.namespace, self.datastore) + return 'updated' def __sort_todos(self, todos: list, max_depth: int = 500): """For a given list of todos, will return first the one without parent @@ -336,7 +334,7 @@ def __sort_todos(self, todos: list, max_depth: int = 500): parents = PARENT_FIELD.get_dav(todo) if (not parents # no parent mean no relationship on build or parents[0] in known_todos # already known parent - or self.datastore.get_task(uid)): # already known uid + or self.datastore.tasks.lookup[uid]): # already known uid yield todo known_todos.add(uid) if loop >= MAX_CALENDAR_DEPTH: @@ -345,8 +343,7 @@ def __sort_todos(self, todos: list, max_depth: int = 500): def _get_calendar_tasks(self, calendar: iCalendar): """Getting all tasks that has the calendar tag""" - for uid in self.datastore.get_all_tasks(): - task = self.datastore.get_task(uid) + for task in self.datastore.tasks.data: if CATEGORIES.has_calendar_tag(task, calendar): yield uid, task @@ -555,11 +552,11 @@ def _get_dt_for_dav_writing(value): class Status(Field): - DEFAULT_STATUS = (Task.STA_ACTIVE, 'NEEDS-ACTION') - _status_mapping = ((Task.STA_ACTIVE, 'NEEDS-ACTION'), - (Task.STA_ACTIVE, 'IN-PROCESS'), - (Task.STA_DISMISSED, 'CANCELLED'), - (Task.STA_DONE, 'COMPLETED')) + DEFAULT_STATUS = (Status.ACTIVE, 'NEEDS-ACTION') + _status_mapping = ((Status.ACTIVE, 'NEEDS-ACTION'), + (Status.ACTIVE, 'IN-PROCESS'), + (Status.DISMISSED, 'CANCELLED'), + (Status.DONE, 'COMPLETED')) def _translate(self, gtg_value=None, dav_value=None): for gtg_status, dav_status in self._status_mapping: @@ -574,9 +571,9 @@ def write_dav(self, vtodo: iCalendar, value): def get_gtg(self, task: Task, namespace: str = None) -> str: active, done = 0, 0 for subtask in self._browse_subtasks(task): - if subtask.get_status() == Task.STA_ACTIVE: + if subtask.is_active: active += 1 - elif subtask.get_status() == Task.STA_DONE: + elif subtask.status == Status.DONE: done += 1 if active and done: return 'IN-PROCESS' @@ -599,9 +596,9 @@ class PercentComplete(Field): def get_gtg(self, task: Task, namespace: str = None) -> str: total_cnt, done_cnt = 0, 0 for subtask in self._browse_subtasks(task): - if subtask.get_status() != Task.STA_DISMISSED: + if subtask.status != Status.DISMISSED: total_cnt += 1 - if subtask.get_status() == Task.STA_DONE: + if subtask.status == Status.DONE: done_cnt += 1 if total_cnt: return str(int(100 * done_cnt / total_cnt)) @@ -645,7 +642,7 @@ def get_calendar_tag(self, calendar: iCalendar) -> str: return self.to_tag(calendar.name, DAV_TAG_PREFIX) def has_calendar_tag(self, task: Task, calendar: iCalendar) -> bool: - return self.get_calendar_tag(calendar) in task.get_tags_name() + return self.get_calendar_tag(calendar) in [t.name for t in task.tags] class AttributeField(Field): @@ -758,10 +755,10 @@ def _extract_plain_text(self, task: Task) -> str: subtask = task.req.get_task(line[2:-2].strip()) if not subtask: continue - if subtask.get_status() == Task.STA_DONE: - result += f"[x] {subtask.get_title()}\n" + if subtask.status == Status.DONE: + result += f"[x] {subtask.title}\n" else: - result += f"[ ] {subtask.get_title()}\n" + result += f"[ ] {subtask.title}\n" else: result += line.strip() + '\n' return result.strip() @@ -849,10 +846,9 @@ def __repr__(self): class OrderField(Field): def get_gtg(self, task: Task, namespace: str = None): - parents = task.get_parents() - if not parents or not parents[0]: + parent = task.parent + if not parent: return - parent = task.req.get_task(parents[0]) uid = UID_FIELD.get_gtg(task, namespace) return parent.get_child_index(uid) @@ -866,7 +862,7 @@ class Recurrence(Field): DAV_DAYS = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] def get_gtg(self, task: Task, namespace: str = None) -> tuple: - return task.get_recurring(), task.get_recurring_term() + return task._is_recurring, task.recurring_term def get_dav(self, todo=None, vtodo=None) -> tuple: if todo: @@ -975,16 +971,16 @@ def fill_vtodo(cls, task: Task, calendar_name: str, namespace: str, return vcal @classmethod - def fill_task(cls, todo: iCalendar, task: Task, namespace: str): + def fill_task(cls, todo: iCalendar, task: Task, namespace: str, datastore): nmspc = {'namespace': namespace} - with DisabledSyncCtx(task): - for field in cls.fields: - field.set_gtg(todo, task, **nmspc) - task.set_attribute("url", str(todo.url), **nmspc) - task.set_attribute("calendar_url", str(todo.parent.url), **nmspc) - task.set_attribute("calendar_name", todo.parent.name, **nmspc) - if not CATEGORIES.has_calendar_tag(task, todo.parent): - task.add_tag(CATEGORIES.get_calendar_tag(todo.parent)) + for field in cls.fields: + field.set_gtg(todo, task, **nmspc) + task.set_attribute("url", str(todo.url), **nmspc) + task.set_attribute("calendar_url", str(todo.parent.url), **nmspc) + task.set_attribute("calendar_name", todo.parent.name, **nmspc) + if not CATEGORIES.has_calendar_tag(task, todo.parent): + tag = datastore.tags.new(CATEGORIES.get_calendar_tag(todo.parent)) + task.add_tag(tag) return task @classmethod diff --git a/GTG/backends/backend_localfile.py b/GTG/backends/backend_localfile.py deleted file mode 100644 index 6ff41b1eca..0000000000 --- a/GTG/backends/backend_localfile.py +++ /dev/null @@ -1,350 +0,0 @@ -# ----------------------------------------------------------------------------- -# Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program. If not, see . -# ----------------------------------------------------------------------------- - -""" -Localfile is a read/write backend that will store your tasks in an XML file -This file will be in your $XDG_DATA_DIR/gtg folder. - -This backend contains comments that are meant as a reference, in case someone -wants to write a backend. -""" - -import os -import logging - -from GTG.backends.backend_signals import BackendSignals -from GTG.backends.generic_backend import GenericBackend -from GTG.core.dirs import DATA_DIR -from gettext import gettext as _ -from GTG.core import xml -from GTG.core import firstrun_tasks -from GTG.core import versioning -from GTG.core.tag import SEARCH_TAG_PREFIX - -from typing import Dict -from lxml import etree as et - -log = logging.getLogger(__name__) - - -class Backend(GenericBackend): - """ - Localfile backend, which stores your tasks in a XML file in the standard - XDG_DATA_DIR/gtg folder (the path is configurable). - An instance of this class is used as the default backend for GTG. - This backend loads all the tasks stored in the localfile after it's enabled - and from that point on just writes the changes to the file: it does not - listen for eventual file changes - """ - - # General description of the backend: these are used to show a description - # of the backend to the user when s/he is considering adding it. - # BACKEND_NAME is the name of the backend used internally (it must be - # unique). - # Please note that BACKEND_NAME and BACKEND_ICON_NAME should *not* be - # translated. - _general_description = { - GenericBackend.BACKEND_NAME: 'backend_localfile', - GenericBackend.BACKEND_ICON: 'folder', - GenericBackend.BACKEND_HUMAN_NAME: _('Local File'), - GenericBackend.BACKEND_AUTHORS: ['Lionel Dricot', - 'Luca Invernizzi'], - GenericBackend.BACKEND_TYPE: GenericBackend.TYPE_READWRITE, - GenericBackend.BACKEND_DESCRIPTION: - _(('Your tasks are saved in a text file (XML format). ' - ' This is the most basic and the default way ' - 'for GTG to save your tasks.')), - } - - # These are the parameters to configure a new backend of this type. A - # parameter has a name, a type and a default value. - # Here, we define a parameter "path", which is a string, and has a default - # value as a random file in the default path - _static_parameters = { - "path": { - GenericBackend.PARAM_TYPE: GenericBackend.TYPE_STRING, - GenericBackend.PARAM_DEFAULT_VALUE: - 'gtg_data.xml'}} - - def __init__(self, parameters: Dict): - """ - Instantiates a new backend. - - @param parameters: A dictionary of parameters, generated from - _static_parameters. A few parameters are added to those, the list of - these is in the "DefaultBackend" class, look for the KEY_* constants. - - The backend should take care if one expected value is None or - does not exist in the dictionary. - """ - super().__init__(parameters) - - if self.KEY_DEFAULT_BACKEND not in parameters: - parameters[self.KEY_DEFAULT_BACKEND] = True - - def get_path(self) -> str: - """Return the current path to XML - - Path can be relative to projects.xml - """ - path = self._parameters['path'] - - # This is local path, convert it to absolute path - if os.sep not in path: - path = os.path.join(DATA_DIR, path) - - return os.path.abspath(path) - - - def initialize(self): - """ This is called when a backend is enabled """ - - super(Backend, self).initialize() - filepath = self.get_path() - - if not os.path.isfile(filepath): - root = firstrun_tasks.generate() - xml.create_dirs(self.get_path()) - xml.save_file(self.get_path(), root) - - self.data_tree = xml.open_file(filepath, 'gtgData') - self.task_tree = self.data_tree.find('tasklist') - self.tag_tree = self.data_tree.find('taglist') - self.search_tree = self.data_tree.find('searchlist') - - self.datastore.load_tag_tree(self.tag_tree) - self.datastore.load_search_tree(self.search_tree) - - # Make safety daily backup after loading - xml.save_file(self.get_path(), self.data_tree) - xml.write_backups(self.get_path()) - - - def this_is_the_first_run(self, _) -> None: - """ Called upon the very first GTG startup. - - This function is needed only in this backend, because it can be used - as default one. The xml parameter is an object containing GTG default - tasks. It will be saved to a file, and the backend will be set as - default. - - @param xml: an xml object containing the default tasks. - """ - - filepath = self.get_path() - self.do_first_run_versioning(filepath) - - - self._parameters[self.KEY_DEFAULT_BACKEND] = True - - # Load the newly created file - self.data_tree = xml.open_file(self.get_path(), 'gtgData') - self.task_tree = self.data_tree.find('tasklist') - self.tag_tree = self.data_tree.find('taglist') - xml.backup_used = None - - - def do_first_run_versioning(self, filepath: str) -> None: - """If there is an old file around needing versioning, convert it, then rename the old file.""" - old_path = self.find_old_path(DATA_DIR) - if old_path is not None: - log.warning('Found old file: %r. Running versioning code.', old_path) - tree = versioning.convert(old_path, self.datastore) - xml.save_file(filepath, tree) - os.rename(old_path, old_path + '.imported') - else: - root = firstrun_tasks.generate() - xml.create_dirs(self.get_path()) - xml.save_file(self.get_path(), root) - - - def find_old_path(self, datadir: str) -> str: - """Reliably find the old data files.""" - # used by which version? - path = os.path.join(datadir, 'gtg_tasks.xml') - if os.path.isfile(path): - return path - # used by (at least) 0.3.1-4 - path = os.path.join(datadir, 'projects.xml') - if os.path.isfile(path): - return self.find_old_uuid_path(path) - return None - - - def find_old_uuid_path(self, path: str) -> str: - """Find the first backend entry with module='backend_localfile' and return its path.""" - old_tree = xml.open_file(path, 'config') - for backend in old_tree.findall('backend'): - module = backend.get('module') - if module == 'backend_localfile': - uuid_path = backend.get('path') - if os.path.isfile(uuid_path): - return uuid_path - return None - - - def start_get_tasks(self) -> None: - """ This function starts submitting the tasks from the XML file into - GTG core. It's run as a separate thread. - - @return: start_get_tasks() might not return or finish - """ - - for element in self.task_tree.iter('task'): - tid = element.get('id') - task = self.datastore.task_factory(tid) - - if task: - task = xml.task_from_element(task, element) - self.datastore.push_task(task) - - - def set_task(self, task) -> None: - """ - This function is called from GTG core whenever a task should be - saved, either because it's a new one or it has been modified. - This function will look into the loaded XML object if the task is - present, and if it's not, it will create it. Then, it will save the - task data in the XML object. - - @param task: the task object to save - """ - - tid = task.get_id() - element = xml.task_to_element(task) - existing = self.task_tree.findall(f"task[@id='{tid}']") - - if existing and element != existing[0]: - existing[0].getparent().replace(existing[0], element) - - else: - self.task_tree.append(element) - - # Write the xml - xml.save_file(self.get_path(), self.data_tree) - - def remove_task(self, tid: str) -> None: - """ This function is called from GTG core whenever a task must be - removed from the backend. Note that the task could be not present here. - - @param tid: the id of the task to delete - """ - - element = self.task_tree.findall(f'task[@id="{tid}"]') - - if element: - element[0].getparent().remove(element[0]) - xml.save_file(self.get_path(), self.data_tree) - - def save_tags(self, tagnames, tagstore) -> None: - """Save changes to tags and saved searches.""" - - already_saved = [] - self.search_tree.clear() - self.tag_tree.clear() - - for tagname in tagnames: - if tagname in already_saved: - continue - - tag = tagstore.get_node(tagname) - - attributes = tag.get_all_attributes(butname=True, withparent=True) - if "special" in attributes: - continue - - if tag.is_search_tag(): - root = self.search_tree - tag_type = 'savedSearch' - else: - root = self.tag_tree - tag_type = 'tag' - - tid = str(tag.tid) - element = root.findall(f'{tag_type}[@id="{tid}"]') - - if len(element) == 0: - element = et.SubElement(self.task_tree, tag_type) - root.append(element) - else: - element = element[0] - - # Don't save the @ in the name - element.set('id', tid) - element.set('name', tag.get_friendly_name()) - - # Remove these and don't re-add them if not needed - element.attrib.pop('icon', None) - element.attrib.pop('color', None) - element.attrib.pop('parent', None) - - for attr in attributes: - # skip labels for search tags - if tag.is_search_tag() and attr == 'label': - continue - - value = tag.get_attribute(attr) - - if value: - if attr == 'color': - value = value[1:] - element.set(attr, value) - - already_saved.append(tagname) - - xml.save_file(self.get_path(), self.data_tree) - - def used_backup(self): - """ This functions return a boolean value telling if backup files - were used when instantiating Backend class. - """ - return xml.backup_used is not None - - def backup_file_info(self): - """This functions returns status of the attempt to recover - gtg_tasks.xml - """ - - back = xml.backup_used - - if not back: - return - - elif back['filepath']: - return f"Recovered from backup made on: {back['time']}" - - else: - return 'No backups found. Created a new file' - - - def notify_user_about_backup(self) -> None: - """ This function causes the inforbar to show up with the message - about file recovery. - """ - message = _( - 'Oops, something unexpected happened! ' - 'GTG tried to recover your tasks from backups. \n' - ) + self.backup_file_info() - - BackendSignals().interaction_requested( - self.get_id(), message, - BackendSignals().INTERACTION_INFORM, 'on_continue_clicked') - - def on_continue_clicked(self, *args) -> None: - """ Callback when the user clicks continue in the infobar.""" - pass diff --git a/GTG/backends/generic_backend.py b/GTG/backends/generic_backend.py index c48b125d54..f074c60799 100644 --- a/GTG/backends/generic_backend.py +++ b/GTG/backends/generic_backend.py @@ -30,7 +30,6 @@ import logging from GTG.backends.backend_signals import BackendSignals -from GTG.core.tag import ALLTASKS_TAG from GTG.core.dirs import SYNC_DATA_DIR from GTG.core.interruptible import _cancellation_point from GTG.core.keyring import Keyring @@ -38,6 +37,8 @@ log = logging.getLogger(__name__) PICKLE_BACKUP_NBR = 2 +ALLTASKS_TAG = 'gtg-tags-all' + class GenericBackend(): """ diff --git a/GTG/backends/meson.build b/GTG/backends/meson.build index 4ede3fc71e..02908f4e24 100644 --- a/GTG/backends/meson.build +++ b/GTG/backends/meson.build @@ -1,6 +1,5 @@ gtg_backend_sources = [ '__init__.py', - 'backend_localfile.py', 'backend_caldav.py', 'backend_signals.py', 'generic_backend.py', diff --git a/GTG/core/base_store.py b/GTG/core/base_store.py index ed13638b10..0d4b23a105 100644 --- a/GTG/core/base_store.py +++ b/GTG/core/base_store.py @@ -105,10 +105,16 @@ def remove(self, item_id: UUID) -> None: """Remove an existing item from the store.""" item = self.lookup[item_id] - parent = item.parent + + try: + parent = item.parent + + for child in item.children: + del self.lookup[child.id] + + except AttributeError: + parent = None - for child in item.children: - del self.lookup[child.id] if parent: parent.children.remove(item) diff --git a/GTG/core/clipboard.py b/GTG/core/clipboard.py index 01b0c81ba7..2eefb09f9d 100644 --- a/GTG/core/clipboard.py +++ b/GTG/core/clipboard.py @@ -24,10 +24,10 @@ class TaskClipboard(): - def __init__(self, req): + def __init__(self, ds): self.description = None self.content = [] - self.req = req + self.ds = ds """"take two Gtk.TextIter as parameter and copy the """ @@ -56,9 +56,7 @@ def copy(self, start, stop, bullet=None): if hasattr(ta, 'is_subtask'): is_subtask = True tid = ta.child - tas = self.req.get_task(tid) - tas.set_to_keep() - tas.sync() + tas = self.ds.tasks.lookup[tid] self.content.append(['subtask', tid]) if not is_subtask: if end_line.get_line() < stop.get_line(): diff --git a/GTG/core/config.py b/GTG/core/config.py index 9d3138e2c6..075834086b 100644 --- a/GTG/core/config.py +++ b/GTG/core/config.py @@ -43,6 +43,7 @@ 'tasklist_sort_column': 5, 'tasklist_sort_order': 1, "font_name": "", + "font_size": 0, 'hour': "00", 'min': "00", 'autoclean': True, diff --git a/GTG/core/datastore.py b/GTG/core/datastore.py index eb4a7d3643..df88580e41 100644 --- a/GTG/core/datastore.py +++ b/GTG/core/datastore.py @@ -1,6 +1,6 @@ # ----------------------------------------------------------------------------- # Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau +# Copyright (c) The GTG Team # # This program is free software: you can redistribute it and/or modify it under # the terms of the GNU General Public License as published by the Free Software @@ -15,380 +15,461 @@ # You should have received a copy of the GNU General Public License along with # this program. If not, see . # ----------------------------------------------------------------------------- -""" -Contains the Datastore object, which is the manager of all the active backends -(both enabled and disabled ones) -""" -from collections import deque +"""The datastore ties together all the basic type stores and backends.""" + +import os import threading import logging -import uuid - +import shutil +from datetime import datetime, timedelta +from time import time +import random +import string + +from GTG.core.tasks import TaskStore, Filter +from GTG.core.tags import TagStore, Tag +from GTG.core.saved_searches import SavedSearchStore +from GTG.core import firstrun_tasks +from GTG.core.dates import Date from GTG.backends.backend_signals import BackendSignals from GTG.backends.generic_backend import GenericBackend -from GTG.core.config import CoreConfig -from GTG.core import requester -from GTG.core.search import parse_search_query, search_filter, InvalidQuery -from GTG.core.tag import Tag, SEARCH_TAG, SEARCH_TAG_PREFIX -from GTG.core.task import Task -from GTG.core.treefactory import TreeFactory -from GTG.core.borg import Borg +import GTG.core.info as info + +from lxml import etree as et + +from typing import Optional log = logging.getLogger(__name__) -TAG_XMLROOT = "tagstore" -class DataStore(): - """ - A wrapper around all backends that is responsible for keeping the backend - instances. It can enable, disable, register and destroy backends, and acts - as interface between the backends and GTG core. - You should not interface yourself directly with the DataStore: use the - Requester instead (which also sends signals as you issue commands). - """ +class Datastore: - def __init__(self, global_conf=CoreConfig()): - """ - Initializes a DataStore object - """ - # dictionary {backend_name_string: Backend instance} + #: Amount of backups to keep + BACKUPS_NUMBER = 7 + + + def __init__(self) -> None: + self.tasks = TaskStore() + self.tags = TagStore() + self.saved_searches = SavedSearchStore() + self.xml_tree = None + + self._mutex = threading.Lock() self.backends = {} - self.treefactory = TreeFactory() - self._tasks = self.treefactory.get_tasks_tree() - self.requester = requester.Requester(self, global_conf) - self.tagfile_loaded = False - self._tagstore = self.treefactory.get_tags_tree(self.requester) self._backend_signals = BackendSignals() - self.conf = global_conf - self.tag_idmap = {} + + # When a backup has to be used, this will be filled with + # info on the backup used + self.backup_info = {} # Flag when turned to true, all pending operation should be # completed and then GTG should quit self.please_quit = False - # The default backend must be loaded first. This flag turns to True - # when the default backend loading has finished. - self.is_default_backend_loaded = False - self._backend_signals.connect('default-backend-loaded', - self._activate_non_default_backends) - self._backend_mutex = threading.Lock() + # Count of tasks for each pane and each tag + self.task_count = { + 'open': {'all': 0, 'untagged': 0}, + 'actionable': {'all': 0, 'untagged': 0}, + 'closed': {'all': 0, 'untagged': 0}, + } - # Accessor to embedded objects in DataStore ############################## - def get_tagstore(self): - """ - Return the Tagstore associated with this DataStore + self.data_path = None + self._activate_non_default_backends() - @return GTG.core.tagstore.TagStore: the tagstore object - """ - return self._tagstore - def get_requester(self): - """ - Return the Requester associate with this DataStore + @property + def mutex(self) -> threading.Lock: + return self._mutex - @returns GTG.core.requester.Requester: the requester associated with - this datastore - """ - return self.requester - def get_tasks_tree(self): - """ - Return the Tree with all the tasks contained in this Datastore + def load_data(self, data: et.Element) -> None: + """Load data from an lxml element object.""" - @returns GTG.core.tree.Tree: a task tree (the main one) - """ - return self._tasks + self.saved_searches.from_xml(data.find('searchlist')) + self.tags.from_xml(data.find('taglist')) + self.tasks.from_xml(data.find('tasklist'), self.tags) - # Tags functions ########################################################## - def _add_new_tag(self, name, tag, filter_func, parameters, parent_id=None): - """ Add tag into a tree """ - if self._tagstore.has_node(name): - raise IndexError(f'tag {name} was already in the datastore') + self.refresh_task_count() - self._tasks.add_filter(name, filter_func, parameters=parameters) - self._tagstore.add_node(tag, parent_id=parent_id) - tag.set_save_callback(self.save) - def new_tag(self, name, attributes={}, tid=None): - """ - Create a new tag + def load_file(self, path: str) -> None: + """Load data from a file.""" - @returns GTG.core.tag.Tag: the new tag - """ - parameters = {'tag': name} - tag = Tag(name, req=self.requester, attributes=attributes, tid=tid) - self._add_new_tag(name, tag, self.treefactory.tag_filter, parameters) - return tag + bench_start = 0 - def new_search_tag(self, name, query, attributes={}, tid=None, save=True): - """ - Create a new search tag + if log.isEnabledFor(logging.DEBUG): + bench_start = time() + + parser = et.XMLParser(remove_blank_text=True, strip_cdata=False) + + with open(path, 'rb') as stream: + self.xml_tree = et.parse(stream, parser=parser) + self.load_data(self.xml_tree) + + if log.isEnabledFor(logging.DEBUG): + log.debug('Processed file %s in %.2fms', + path, (time() - bench_start) * 1000) + + # Store path, so we can call save() on it + self.data_path = path + + + def generate_xml(self) -> et.ElementTree: + """Generate lxml element object with all data.""" + + root = et.Element('gtgData') + root.set('appVersion', info.VERSION) + root.set('xmlVersion', '2') + + root.append(self.tags.to_xml()) + root.append(self.saved_searches.to_xml()) + root.append(self.tasks.to_xml()) + + return et.ElementTree(root) + + + def write_file(self, path: str) -> None: + """Generate and write xml file.""" + + tree = self.generate_xml() + + with open(path, 'wb') as stream: + tree.write(stream, xml_declaration=True, pretty_print=True, + encoding='UTF-8') + + + def save(self, path: Optional[str] = None) -> None: + """Write GTG data file.""" + + path = path or self.data_path + temp_file = path + '__' + bench_start = 0 - @returns GTG.core.tag.Tag: the new search tag/None for a invalid query - """ try: - parameters = parse_search_query(query) - except InvalidQuery as error: - log.warning("Problem with parsing query %r (skipping): %s", query, error.message) - return None + os.rename(path, temp_file) + except IOError: + log.error('Error renaming temp file %r', temp_file) - # Create own copy of attributes and add special attributes label, query - init_attr = dict(attributes) - init_attr["label"] = name - init_attr["query"] = query + if log.isEnabledFor(logging.DEBUG): + bench_start = time() - name = SEARCH_TAG_PREFIX + name - tag = Tag(name, req=self.requester, attributes=init_attr, tid=tid) - self._add_new_tag(name, tag, search_filter, parameters, - parent_id=SEARCH_TAG) + base_dir = os.path.dirname(path) - if save: - self.save_tagtree() + try: + os.makedirs(base_dir, exist_ok=True) + except IOError as error: + log.error("Error while creating directories: %r", error) - return tag + try: + self.write_file(path) + except (IOError, FileNotFoundError): + log.error('Could not write XML file at %r', path) + return - def remove_tag(self, name): - """ Removes a tag from the tagtree """ - if self._tagstore.has_node(name): - self._tagstore.del_node(name) - self.save_tagtree() - else: - raise IndexError(f"There is no tag {name}") - def rename_tag(self, oldname, newname): - """ Give a tag a new name + if log.isEnabledFor(logging.DEBUG): + log.debug('Saved file %s in %.2fms', + path, (time() - bench_start) * 1000) + + try: + os.remove(temp_file) + except FileNotFoundError: + pass - This function is quite high-level method. Right now, - only renaming search bookmarks are implemented by removing - the old one and creating almost identical one with the new name. + self.write_backups(path) - NOTE: Implementation for regular tasks must be much more robust. - You have to replace all occurences of tag name in tasks descriptions, - their parameters and backend settings (synchronize only certain tags). - Have a fun with implementing it! - """ + def print_info(self) -> None: + """Print statistics and information on this datastore.""" - tag = self.get_tag(oldname) + tasks = self.tasks.count() + initialized = 'Initialized' if tasks > 0 else 'Empty' - if not tag.is_search_tag(): - for task_id in tag.get_related_tasks(): + print(f'Datastore [{initialized}]') + print(f'- Tags: {self.tags.count()}') + print(f'- Saved Searches: {self.saved_searches.count()}') + print(f'- Tasks: {self.tasks.count()}') - # Store old tag attributes - color = tag.get_attribute("color") - icon = tag.get_attribute("icon") - tid = tag.tid - my_task = self.get_task(task_id) - my_task.rename_tag(oldname, newname) + def refresh_task_for_tag(self, tag: Tag) -> None: + """Refresh task counts for a tag.""" - # Restore attributes on tag - new_tag = self.get_tag(newname) - new_tag.tid = tid + try: + tag.task_count_open = self.task_count['open'][tag.name] + except KeyError: + tag.task_count_open = 0 - if color: - new_tag.set_attribute("color", color) + try: + tag.task_count_closed = self.task_count['closed'][tag.name] + except KeyError: + tag.task_count_closed = 0 - if icon: - new_tag.set_attribute("icon", icon) + try: + tag.task_count_actionable = self.task_count['actionable'][tag.name] + except KeyError: + tag.task_count_actionable = 0 - self.remove_tag(oldname) - self.save_tagtree() - return None + def refresh_task_count(self) -> None: + """Refresh task count dictionary.""" - query = tag.get_attribute("query") - self.remove_tag(oldname) + def count_tasks(count: dict, tasklist: list): + for task in tasklist: + count['all'] += 1 - # Make sure the name is unique - if newname.startswith('!'): - newname = '_' + newname + if not task.tags: + count['untagged'] += 1 - label, num = newname, 1 - while self._tagstore.has_node(SEARCH_TAG_PREFIX + label): - num += 1 - label = newname + " " + str(num) + for tag in task.tags: + val = count.get(tag.name, 0) + count[tag.name] = val + 1 - self.new_search_tag(label, query, {}, tag.tid) + if task.children: + count_tasks(count, task.children) - def get_tag(self, tagname): - """ - Returns tag object + # Reset task counts + self.task_count = { + 'open': {'all': 0, 'untagged': 0}, + 'actionable': {'all': 0, 'untagged': 0}, + 'closed': {'all': 0, 'untagged': 0}, + } - @return GTG.core.tag.Tag - """ - if self._tagstore.has_node(tagname): - return self._tagstore.get_node(tagname) - else: - return None + count_tasks(self.task_count['open'], + self.tasks.filter(Filter.ACTIVE)) + + count_tasks(self.task_count['closed'], + self.tasks.filter(Filter.CLOSED)) + + count_tasks(self.task_count['actionable'], + self.tasks.filter(Filter.ACTIONABLE)) - def load_tag_tree(self, tag_tree): - """ - Loads the tag tree from a xml file - """ - for element in tag_tree.iter('tag'): - tid = element.get('id') - name = element.get('name') - color = element.get('color') - icon = element.get('icon') - parent = element.get('parent') - nonactionable = element.get('nonactionable') + def notify_tag_change(self, tag) -> None: + """Notify tasks that this tag has changed.""" + + for task in self.tasks.lookup.values(): + if tag in task.tags: + task.notify('icons') + task.notify('row_css') + task.notify('tag_colors') + task.notify('show_tag_colors') - tag_attrs = {} - if color: - tag_attrs['color'] = '#' + color + def first_run(self, path: str) -> et.Element: + """Write initial data file.""" + + self.xml_tree = firstrun_tasks.generate() + self.load_data(self.xml_tree) + self.save(path) + + + def do_first_run_versioning(self, filepath: str) -> None: + """If there is an old file around needing versioning, convert it, then rename the old file.""" + + old_path = self.find_old_path(DATA_DIR) + + if old_path is not None: + log.warning('Found old file: %r. Running versioning code.', old_path) + tree = versioning.convert(old_path, self) + self.load_data(tree) + self.save(filepath) + os.rename(old_path, old_path + '.imported') + + else: + self.first_run(self.data_path) - if icon: - tag_attrs['icon'] = icon - if nonactionable: - tag_attrs['nonactionable'] = nonactionable + def find_old_path(self, datadir: str) -> None | str: + """Reliably find the old data files.""" - tag = self.new_tag(name, tag_attrs, tid) + # used by which version? + path = os.path.join(datadir, 'gtg_tasks.xml') + + if os.path.isfile(path): + return path + + # used by (at least) 0.3.1-4 + path = os.path.join(datadir, 'projects.xml') + + if os.path.isfile(path): + return self.find_old_uuid_path(path) - if parent: - tag.set_parent(parent) + return None - # Add to idmap for quick lookup based on ID - self.tag_idmap[tid] = tag - self.tagfile_loaded = True + def find_old_uuid_path(self, path: str) -> None | str: + """Find the first backend entry with module='backend_localfile' and return its path.""" + with open(path, 'r') as stream: + xml_tree = et.parse(stream) + + for backend in xml_tree.findall('backend'): + module = backend.get('module') + if module == 'backend_localfile': + uuid_path = backend.get('path') + if os.path.isfile(uuid_path): - def load_search_tree(self, search_tree): - """Load saved searches tree.""" + return uuid_path - for element in search_tree.iter('savedSearch'): - tid = element.get('id') - name = element.get('name') - color = element.get('color') - icon = element.get('icon') - query = element.get('query') + return None - tag_attrs = {} - if color: - tag_attrs['color'] = color + @staticmethod + def get_backup_path(path: str, i: int = None) -> str: + """Get path of backups which are backup/ directory.""" - if icon: - tag_attrs['icon'] = icon + dirname, filename = os.path.split(path) + backup_file = f"{filename}.bak.{i}" if i else filename - self.new_search_tag(name, query, tag_attrs, tid, False) + return os.path.join(dirname, 'backup', backup_file) - def get_tag_by_id(self, tid): - """Get a tag by its ID""" + def write_backups(self, path: str) -> None: + backup_name = self.get_backup_path(path) + backup_dir = os.path.dirname(backup_name) + # Make sure backup dir exists try: - return self.tag_idmap[tid] - except KeyError: + os.makedirs(backup_dir, exist_ok=True) + + except IOError: + log.error('Backup dir %r cannot be created!', backup_dir) return - def save_tagtree(self): - """ Saves the tag tree to an XML file """ + # Cycle backups + for current_backup in range(self.BACKUPS_NUMBER, 0, -1): + older = f"{backup_name}.bak.{current_backup}" + newer = f"{backup_name}.bak.{current_backup - 1}" - if not self.tagfile_loaded: - return + try: + shutil.move(newer, older) + except FileNotFoundError: + pass - tags = self._tagstore.get_main_view().get_all_nodes() + # bak.0 is always a fresh copy of the closed file + # so that it's not touched in case of not opening next time + bak_0 = f"{backup_name}.bak.0" + shutil.copy(path, bak_0) - for backend in self.backends.values(): - if backend.get_name() == 'backend_localfile': - backend.save_tags(tags, self._tagstore) + # Add daily backup + today = datetime.today().strftime('%Y-%m-%d') + daily_backup = f'{backup_name}.{today}.bak' + if not os.path.exists(daily_backup): + shutil.copy(path, daily_backup) - # Tasks functions ######################################################### - def get_all_tasks(self): - """ - Returns list of all keys of active tasks + self.purge_backups(path) - @return a list of strings: a list of task ids - """ - return self._tasks.get_main_view().get_all_nodes() - def has_task(self, tid): - """ - Returns true if the tid is among the active or closed tasks for - this DataStore, False otherwise. + @staticmethod + def purge_backups(path: str, days: int = 30) -> None: + """Remove backups older than X days.""" - @param tid: Task ID to search for - @return bool: True if the task is present - """ - return self._tasks.has_node(tid) + now = time() + day_in_secs = 86_400 + basedir = os.path.dirname(path) - def get_task(self, tid): - """ - Returns the internal task object for the given tid, or None if the - tid is not present in this DataStore. + for filename in os.listdir(basedir): + filename = os.path.join(basedir, filename) + filestamp = os.stat(filename).st_mtime + filecompare = now - (days * day_in_secs) - @param tid: Task ID to retrieve - @returns GTG.core.task.Task or None: whether the Task is present - or not - """ - if self.has_task(tid): - return self._tasks.get_node(tid) - else: - # log.error("requested non-existent task %s", tid) - # This is not an error: it is normal to request a task which - # might not exist yet. - return None + if filestamp < filecompare: + os.remove(filename) - def task_factory(self, tid, newtask=False): - """ - Instantiates the given task id as a Task object. - @param tid: a task id. Must be unique - @param newtask: True if the task has never been seen before - @return Task: a Task instance - """ - return Task(tid, self.requester, newtask) + def find_and_load_file(self, path: str) -> None: + """Find an XML file to open - def new_task(self): - """ - Creates a blank new task in this DataStore. - New task is created in all the backends that collect all tasks (among - them, the default backend). The default backend uses the same task id - in its own internal representation. + If file could not be opened, try: + - file__ + - file.bak.0 + - file.bak.1 + - .... until BACKUP_NUMBER - @return: The task object that was created. - """ - task = self.task_factory(str(uuid.uuid4()), True) - self._tasks.add_node(task) - return task + If file doesn't exist, create a new file.""" - def push_task(self, task): - """ - Adds the given task object to the task tree. In other words, registers - the given task in the GTG task set. - This function is used in mutual exclusion: only a backend at a time is - allowed to push tasks. + files = [ + path, # Main file + path + '__', # Temp file + ] - @param task: A valid task object (a GTG.core.task.Task) - @return bool: True if the task has been accepted - """ + # Add backup files + files += [self.get_backup_path(path, i) + for i in range(self.BACKUPS_NUMBER)] - def adding(task): - self._tasks.add_node(task) - task.set_loaded() - if self.is_default_backend_loaded: - task.sync() - if self.has_task(task.get_id()): - return False - else: - # Thread protection - adding(task) - return True - ########################################################################## - # Backends functions - ########################################################################## + for index, filepath in enumerate(files): + try: + log.debug('Opening file %s', filepath) + self.load_file(filepath) + + timestamp = os.path.getmtime(filepath) + mtime = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') + + # This was a backup. We should inform the user + if index > 0: + self.backup_info = { + 'name': filepath, + 'time': mtime + } + + # We could open a file, let's stop this loop + break + + except FileNotFoundError: + log.debug('File not found: %r. Trying next.', filepath) + continue + + except PermissionError: + log.debug('Not allowed to open: %r. Trying next.', filepath) + continue + + except et.XMLSyntaxError as error: + log.debug('Syntax error in %r. %r. Trying next.', + filepath, error) + continue + + # We couldn't open any file :( + if not self.xml_tree: + try: + # Try making a new empty file and open it + self.first_run(path) + self.data_path = path + + except IOError: + raise SystemError(f'Could not write a file at {path}') + + + def purge(self, max_days: int) -> None: + """Remove closed tasks and unused tags.""" + + log.debug("Deleting old tasks") + + today = Date.today() + for task in self.tasks.data: + if (today - task.date_closed).days > max_days: + self.tasks.remove(task.id) + + log.debug("Deleting unused tags") + + for tag in self.tags.data: + count_open = self.task_count['open'].get(tag.name, 0) + count_closed = self.task_count['closed'].get(tag.name, 0) + customized = tag.color or tag.icon + + if (count_open + count_closed) == 0 and not customized: + self.tags.remove(tag.id) + + + # -------------------------------------------------------------------------- + # BACKENDS + # -------------------------------------------------------------------------- + def get_all_backends(self, disabled=False): - """ - returns list of all registered backends for this DataStore. + """returns list of all registered backends for this DataStore. @param disabled: If disabled is True, attaches also the list of disabled backends @@ -400,6 +481,7 @@ def get_all_backends(self, disabled=False): result.append(backend) return result + def get_backend(self, backend_id): """ Returns a backend given its id. @@ -413,6 +495,7 @@ def get_backend(self, backend_id): else: return None + def register_backend(self, backend_dic): """ Registers a TaskSource as a backend for this DataStore @@ -434,38 +517,38 @@ def register_backend(self, backend_dic): if backend.get_id() in self.backends: log.error("registering already registered backend") return None - # creating the TaskSource which will wrap the backend, - # filtering the tasks that should hit the backend. - source = TaskSource(requester=self.requester, - backend=backend, - datastore=self) + + + backend.register_datastore(self) if first_run: backend.this_is_the_first_run(None) - self.backends[backend.get_id()] = source + self.backends[backend.get_id()] = backend # we notify that a new backend is present self._backend_signals.backend_added(backend.get_id()) # saving the backend in the correct dictionary (backends for # enabled backends, disabled_backends for the disabled ones) # this is useful for retro-compatibility if GenericBackend.KEY_ENABLED not in backend_dic: - source.set_parameter(GenericBackend.KEY_ENABLED, True) + backend.set_parameter(GenericBackend.KEY_ENABLED, True) if GenericBackend.KEY_DEFAULT_BACKEND not in backend_dic: - source.set_parameter(GenericBackend.KEY_DEFAULT_BACKEND, True) + backend.set_parameter(GenericBackend.KEY_DEFAULT_BACKEND, True) # if it's enabled, we initialize it - if source.is_enabled() and \ - (self.is_default_backend_loaded or source.is_default()): - source.initialize(connect_signals=False) + if backend.is_enabled(): + self._backend_startup(backend) + # backend.initialize() # Filling the backend # Doing this at start is more efficient than # after the GUI is launched - source.start_get_tasks() - return source + # backend.start_get_tasks() + + return backend else: log.error("Tried to register a backend without a pid") - def _activate_non_default_backends(self, sender=None): + + def _activate_non_default_backends(self): """ Non-default backends have to wait until the default loads before being activated. This function is called after the first default @@ -473,15 +556,12 @@ def _activate_non_default_backends(self, sender=None): @param sender: not used, just here for signal compatibility """ - if self.is_default_backend_loaded: - log.debug("spurious call") - return - self.is_default_backend_loaded = True for backend in self.backends.values(): - if backend.is_enabled() and not backend.is_default(): + if backend.is_enabled(): self._backend_startup(backend) + def _backend_startup(self, backend): """ Helper function to launch a thread that starts a backend. @@ -504,6 +584,7 @@ def __backend_startup(self, backend): thread.setDaemon(True) thread.start() + def set_backend_enabled(self, backend_id, state): """ The backend corresponding to backend_id is enabled or disabled @@ -527,12 +608,8 @@ def set_backend_enabled(self, backend_id, state): threading.Thread(target=backend.quit, kwargs={'disable': True}).start() elif current_state is False and state is True: - if self.is_default_backend_loaded is True: - self._backend_startup(backend) - else: - # will be activated afterwards - backend.set_parameter(GenericBackend.KEY_ENABLED, - True) + self._backend_startup(backend) + def remove_backend(self, backend_id): """ @@ -554,6 +631,7 @@ def remove_backend(self, backend_id): self._backend_signals.backend_removed(backend.get_id()) del self.backends[backend_id] + def backend_change_attached_tags(self, backend_id, tag_names): """ Changes the tags for which a backend should store a task @@ -565,6 +643,7 @@ def backend_change_attached_tags(self, backend_id, tag_names): backend = self.backends[backend_id] backend.set_attached_tags(tag_names) + def flush_all_tasks(self, backend_id): """ This function will cause all tasks to be checked against the backend @@ -579,312 +658,122 @@ def flush_all_tasks(self, backend_id): def _internal_flush_all_tasks(): backend = self.backends[backend_id] - for task_id in self.get_all_tasks(): + for task in self.tasks.data: if self.please_quit: break - backend.queue_set_task(task_id) + backend.queue_set_task(task.id) t = threading.Thread(target=_internal_flush_all_tasks) t.start() self.backends[backend_id].start_get_tasks() - def save(self, quit=False): - """ - Saves the backends parameters. - @param quit: If quit is true, backends are shut down - """ + # -------------------------------------------------------------------------- + # TESTING AND UTILS + # -------------------------------------------------------------------------- - try: - self.start_get_tasks_thread.join() - except Exception: - pass + def fill_with_samples(self, tasks_count: int) -> None: + """Fill the Datastore with sample data.""" - # we ask all the backends to quit first. - if quit: - # we quit backends in parallel - threads_dic = {} + def random_date(start: datetime = None): + start = start or datetime.now() + end = start + timedelta(days=random.randint(1, 365 * 5)) - for b in self.get_all_backends(): - thread = threading.Thread(target=b.quit) - threads_dic[b.get_id()] = thread - thread.start() + return start + (end - start) - for backend_id, thread in threads_dic.items(): - # after 20 seconds, we give up - thread.join(20) - alive = thread.is_alive() + def random_boolean() -> bool: + return bool(random.getrandbits(1)) - if alive: - log.error("The %s backend stalled while quitting", - backend_id) - # we save the parameters - for b in self.get_all_backends(disabled=True): - config = self.conf.get_backend_config(b.get_name()) + def random_word(length: int) -> str: + letters = string.ascii_lowercase + return ''.join(random.choice(letters) for _ in range(length)) - for key, value in b.get_parameters().items(): - if key in ["backend", "xmlobject"]: - # We don't want parameters, backend, xmlobject: - # we'll create them at next startup - continue + if tasks_count == 0: + return - param_type = b.get_parameter_type(key) - value = b.cast_param_type_to_string(param_type, value) - config.set(str(key), value) - config.save() + tags_count = random.randint(tasks_count // 10, tasks_count) + search_count = random.randint(0, tasks_count // 10) + task_sizes = [random.randint(0, 200) for _ in range(10)] + randint = random.randint + tag_words = [random_word(randint(2, 14)) for _ in range(tags_count)] - # Saving the tagstore - self.save_tagtree() + # Generate saved searches + for _ in range(search_count): + name = random_word(randint(2, 10)) + query = random_word(randint(2, 10)) + self.saved_searches.new(name, query) - def request_task_deletion(self, tid): - """ - This is a proxy function to request a task deletion from a backend + # Generate tags + for tag_name in tag_words: + tag = self.tags.new(tag_name) + tag.actionable = random_boolean() + tag.color = self.tags.generate_color() - @param tid: the tid of the task to remove - """ - self.requester.delete_task(tid) - def get_backend_mutex(self): - """ - Returns the mutex object used by backends to avoid modifying a task - at the same time. + # Parent the tags + for tag in self.tags.data: + if bool(random.getrandbits(1)): + parent = random.choice(self.tags.data) - @returns: threading.Lock - """ - return self._backend_mutex + if tag.id == parent.id: + continue + self.tags.parent(tag.id, parent.id) -class TaskSource(): - """ - Transparent interface between the real backend and the DataStore. - Is in charge of connecting and disconnecting to signals - """ - def __init__(self, requester, backend, datastore): - """ - Instantiates a TaskSource object. + # Generate tasks + for _ in range(tasks_count): + title = '' + content = '' + content_size = random.choice(task_sizes) - @param requester: a Requester - @param backend: the backend being wrapped - @param datastore: a Datastore - """ - self.backend = backend - self.req = requester - self.backend.register_datastore(datastore) - self.tasktree = datastore.get_tasks_tree().get_main_view() - self.to_set = deque() - self.to_remove = deque() - self.please_quit = False - self.task_filter = self.get_task_filter_for_backend() - if log.isEnabledFor(logging.DEBUG): - self.timer_timestep = 5 - else: - self.timer_timestep = 1 - self.add_task_handle = None - self.set_task_handle = None - self.remove_task_handle = None - self.to_set_timer = None - - def start_get_tasks(self): - """ Loads all task from the backend and connects its signals - afterwards. """ - self.backend.start_get_tasks() - self._connect_signals() - if self.backend.is_default(): - BackendSignals().default_backend_loaded() - - def get_task_filter_for_backend(self): - """ - Filter that checks if the task should be stored in this backend. + for _ in range(random.randint(1, 15)): + word = random_word(randint(4, 20)) - @returns function: a function that accepts a task and returns - True/False whether the task should be stored or not - """ + if word in tag_words: + word = '@' + word - def backend_filter(req, task, parameters): - """ - Filter that checks if two tags sets intersect. It is used to check - if a task should be stored inside a backend - @param task: a task object - @param tags_to_match_set: a *set* of tag names - """ - try: - tags_to_match_set = parameters['tags'] - except KeyError: - return [] - all_tasks_tag = req.get_alltag_tag().get_name() - if all_tasks_tag in tags_to_match_set: - return True - task_tags = set(task.get_tags_name()) - return task_tags.intersection(tags_to_match_set) - - attached_tags = self.backend.get_attached_tags() - return lambda task: backend_filter(self.requester, task, - {"tags": set(attached_tags)}) - - def should_task_id_be_stored(self, task_id): - """ - Helper function: Checks if a task should be stored in this backend + title += word + ' ' - @param task_id: a task id - @returns bool: True if the task should be stored - """ - # task = self.req.get_task(task_id) - # FIXME: it will be a lot easier to add, instead, - # a filter to a tree and check that this task is well in the tree -# return self.task_filter(task) - return True + task = self.tasks.new(title) - def queue_set_task(self, tid, path=None): - """ - Updates the task in the DataStore. Actually, it adds the task to a - queue to be updated asynchronously. + for _ in range(random.randint(0, 10)): + tag = self.tags.find(random.choice(tag_words)) + task.add_tag(tag) - @param task: The Task object to be updated. - @param path: its path in TreeView widget => not used there - """ - if self.should_task_id_be_stored(tid): - if tid not in self.to_set and tid not in self.to_remove: - self.to_set.appendleft(tid) - self.__try_launch_setting_thread() - else: - self.queue_remove_task(tid, path) + if random_boolean(): + task.toggle_active() - def launch_setting_thread(self, bypass_please_quit=False): - """ - Operates the threads to set and remove tasks. - Releases the lock when it is done. - - @param bypass_please_quit: if True, the self.please_quit - "quit condition" is ignored. Currently, - it's turned to true after the quit - condition has been issued, to execute - eventual pending operations. - """ - while not self.please_quit or bypass_please_quit: - try: - tid = self.to_set.pop() - except IndexError: - break - # we check that the task is not already marked for deletion - # and that it's still to be stored in this backend - # NOTE: no need to lock, we're reading - if tid not in self.to_remove and \ - self.should_task_id_be_stored(tid) and \ - self.req.has_task(tid): - task = self.req.get_task(tid) - self.backend.queue_set_task(task) - while not self.please_quit or bypass_please_quit: - try: - tid = self.to_remove.pop() - except IndexError: - break - self.backend.queue_remove_task(tid) - # we release the weak lock - self.to_set_timer = None + if random_boolean(): + task.toggle_dismiss() - def queue_remove_task(self, tid, path=None): - """ - Queues task to be removed. + for _ in range(random.randint(0, content_size)): + word = random_word(randint(4, 20)) - @param sender: not used, any value will do - @param tid: The Task ID of the task to be removed - """ - if tid not in self.to_remove: - self.to_remove.appendleft(tid) - self.__try_launch_setting_thread() + if word in tag_words: + word = '@' + word - def __try_launch_setting_thread(self): - """ - Helper function to launch the setting thread, if it's not running - """ - if self.to_set_timer is None and not self.please_quit: - self.to_set_timer = threading.Timer(self.timer_timestep, - self.launch_setting_thread) - self.to_set_timer.setDaemon(True) - self.to_set_timer.start() + content += word + ' ' + content += '\n' if random_boolean() else '' - def initialize(self, connect_signals=True): - """ - Initializes the backend and starts looking for signals. + task.content = content - @param connect_signals: if True, it starts listening for signals - """ - self.backend.initialize() - if connect_signals: - self._connect_signals() + if random_boolean(): + task.date_start = random_date() - def _connect_signals(self): - """ - Helper function to connect signals - """ - if not self.add_task_handle: - self.add_task_handle = self.tasktree.register_cllbck( - 'node-added', self.queue_set_task) - if not self.set_task_handle: - self.set_task_handle = self.tasktree.register_cllbck( - 'node-modified', self.queue_set_task) - if not self.remove_task_handle: - self.remove_task_handle = self.tasktree.register_cllbck( - 'node-deleted', self.queue_remove_task) - - def _disconnect_signals(self): - """ - Helper function to disconnect signals - """ - if self.add_task_handle: - self.tasktree.deregister_cllbck('node-added', - self.set_task_handle) - self.add_task_handle = None - if self.set_task_handle: - self.tasktree.deregister_cllbck('node-modified', - self.set_task_handle) - self.set_task_handle = None - if self.remove_task_handle: - self.tasktree.deregister_cllbck('node-deleted', - self.remove_task_handle) - self.remove_task_handle = None - - def sync(self): - """ - Forces the TaskSource to sync all the pending tasks - """ - try: - self.to_set_timer.cancel() - except Exception: - pass - try: - self.to_set_timer.join(3) - except Exception: - pass - try: - self.start_get_tasks_thread.join(3) - except Exception: - pass - self.launch_setting_thread(bypass_please_quit=True) + if random_boolean(): + task.date_due = Date(random_date()) - def quit(self, disable=False): - """ - Quits the backend and disconnect the signals - @param disable: if True, the backend is disabled. - """ - self._disconnect_signals() - self.please_quit = True - self.sync() - self.backend.quit(disable) + # Parent the tasks + for task in self.tasks.data: + if bool(random.getrandbits(1)): + parent = random.choice(self.tasks.data) - def __getattr__(self, attr): - """ - Delegates all the functions not defined here to the real backend - (standard python function) + if task.id == parent.id: + continue - @param attr: attribute to get - """ - if attr in self.__dict__: - return self.__dict__[attr] - else: - return getattr(self.backend, attr) + self.tasks.parent(task.id, parent.id) diff --git a/GTG/core/datastore2.py b/GTG/core/datastore2.py deleted file mode 100644 index b608f199ab..0000000000 --- a/GTG/core/datastore2.py +++ /dev/null @@ -1,700 +0,0 @@ -# ----------------------------------------------------------------------------- -# Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) The GTG Team -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program. If not, see . -# ----------------------------------------------------------------------------- - -"""The datastore ties together all the basic type stores and backends.""" - -import os -import threading -import logging -import shutil -from datetime import datetime, timedelta -from time import time -import random -import string - -from GTG.core.tasks2 import TaskStore, Filter -from GTG.core.tags2 import TagStore -from GTG.core.saved_searches import SavedSearchStore -from GTG.core import firstrun_tasks -from GTG.core.dates import Date -from GTG.backends.backend_signals import BackendSignals -from GTG.backends.generic_backend import GenericBackend -import GTG.core.info as info - -from lxml import etree as et - -from typing import Optional - - -log = logging.getLogger(__name__) - - -class Datastore2: - - #: Amount of backups to keep - BACKUPS_NUMBER = 7 - - - def __init__(self) -> None: - self.tasks = TaskStore() - self.tags = TagStore() - self.saved_searches = SavedSearchStore() - self.xml_tree = None - - self._mutex = threading.Lock() - self.backends = {} - self._backend_signals = BackendSignals() - - # When a backup has to be used, this will be filled with - # info on the backup used - self.backup_info = {} - - # Flag when turned to true, all pending operation should be - # completed and then GTG should quit - self.please_quit = False - - # Count of tasks for each pane and each tag - self.task_count = { - 'open': {'all': 0, 'untagged': 0}, - 'actionable': {'all': 0, 'untagged': 0}, - 'closed': {'all': 0, 'untagged': 0}, - } - - self.data_path = None - - - @property - def mutex(self) -> threading.Lock: - return self._mutex - - - def load_data(self, data: et.Element) -> None: - """Load data from an lxml element object.""" - - self.saved_searches.from_xml(data.find('searchlist')) - self.tags.from_xml(data.find('taglist')) - self.tasks.from_xml(data.find('tasklist'), self.tags) - - self.refresh_task_count() - - - def load_file(self, path: str) -> None: - """Load data from a file.""" - - bench_start = 0 - - if log.isEnabledFor(logging.DEBUG): - bench_start = time() - - parser = et.XMLParser(remove_blank_text=True, strip_cdata=False) - - with open(path, 'rb') as stream: - self.xml_tree = et.parse(stream, parser=parser) - self.load_data(self.xml_tree) - - if log.isEnabledFor(logging.DEBUG): - log.debug('Processed file %s in %.2fms', - path, (time() - bench_start) * 1000) - - # Store path, so we can call save() on it - self.data_path = path - - - def generate_xml(self) -> et.ElementTree: - """Generate lxml element object with all data.""" - - root = et.Element('gtgData') - root.set('appVersion', info.VERSION) - root.set('xmlVersion', '2') - - root.append(self.tags.to_xml()) - root.append(self.saved_searches.to_xml()) - root.append(self.tasks.to_xml()) - - return et.ElementTree(root) - - - def write_file(self, path: str) -> None: - """Generate and write xml file.""" - - tree = self.generate_xml() - - with open(path, 'wb') as stream: - tree.write(stream, xml_declaration=True, pretty_print=True, - encoding='UTF-8') - - - def save(self, path: Optional[str] = None) -> None: - """Write GTG data file.""" - - path = path or self.data_path - temp_file = path + '__' - bench_start = 0 - - try: - os.rename(path, temp_file) - except IOError: - log.error('Error renaming temp file %r', temp_file) - - if log.isEnabledFor(logging.DEBUG): - bench_start = time() - - base_dir = os.path.dirname(path) - - try: - os.makedirs(base_dir, exist_ok=True) - except IOError as error: - log.error("Error while creating directories: %r", error) - - try: - self.write_file(path) - except (IOError, FileNotFoundError): - log.error('Could not write XML file at %r', path) - return - - - if log.isEnabledFor(logging.DEBUG): - log.debug('Saved file %s in %.2fms', - path, (time() - bench_start) * 1000) - - try: - os.remove(temp_file) - except FileNotFoundError: - pass - - self.write_backups(path) - - - def print_info(self) -> None: - """Print statistics and information on this datastore.""" - - tasks = self.tasks.count() - initialized = 'Initialized' if tasks > 0 else 'Empty' - - print(f'Datastore [{initialized}]') - print(f'- Tags: {self.tags.count()}') - print(f'- Saved Searches: {self.saved_searches.count()}') - print(f'- Tasks: {self.tasks.count()}') - - - def refresh_task_count(self) -> None: - """Refresh task count dictionary.""" - - def count_tasks(count: dict, tasklist: list): - for task in tasklist: - count['all'] += 1 - - if not task.tags: - count['untagged'] += 1 - - for tag in task.tags: - val = count.get(tag.name, 0) - count[tag.name] = val + 1 - - if task.children: - count_tasks(count, task.children) - - # Reset task counts - self.task_count = { - 'open': {'all': 0, 'untagged': 0}, - 'actionable': {'all': 0, 'untagged': 0}, - 'closed': {'all': 0, 'untagged': 0}, - } - - count_tasks(self.task_count['open'], - self.tasks.filter(Filter.ACTIVE)) - - count_tasks(self.task_count['closed'], - self.tasks.filter(Filter.CLOSED)) - - count_tasks(self.task_count['actionable'], - self.tasks.filter(Filter.ACTIONABLE)) - - - def first_run(self, path: str) -> et.Element: - """Write initial data file.""" - - self.xml_tree = firstrun_tasks.generate() - self.load_data(self.xml_tree) - self.save(path) - - - @staticmethod - def get_backup_path(path: str, i: int = None) -> str: - """Get path of backups which are backup/ directory.""" - - dirname, filename = os.path.split(path) - backup_file = f"{filename}.bak.{i}" if i else filename - - return os.path.join(dirname, 'backup', backup_file) - - - def write_backups(self, path: str) -> None: - backup_name = self.get_backup_path(path) - backup_dir = os.path.dirname(backup_name) - - # Make sure backup dir exists - try: - os.makedirs(backup_dir, exist_ok=True) - - except IOError: - log.error('Backup dir %r cannot be created!', backup_dir) - return - - # Cycle backups - for current_backup in range(self.BACKUPS_NUMBER, 0, -1): - older = f"{backup_name}.bak.{current_backup}" - newer = f"{backup_name}.bak.{current_backup - 1}" - - try: - shutil.move(newer, older) - except FileNotFoundError: - pass - - # bak.0 is always a fresh copy of the closed file - # so that it's not touched in case of not opening next time - bak_0 = f"{backup_name}.bak.0" - shutil.copy(path, bak_0) - - # Add daily backup - today = datetime.today().strftime('%Y-%m-%d') - daily_backup = f'{backup_name}.{today}.bak' - - if not os.path.exists(daily_backup): - shutil.copy(path, daily_backup) - - self.purge_backups(path) - - - @staticmethod - def purge_backups(path: str, days: int = 30) -> None: - """Remove backups older than X days.""" - - now = time() - day_in_secs = 86_400 - basedir = os.path.dirname(path) - - for filename in os.listdir(basedir): - filename = os.path.join(basedir, filename) - filestamp = os.stat(filename).st_mtime - filecompare = now - (days * day_in_secs) - - if filestamp < filecompare: - os.remove(filename) - - - def find_and_load_file(self, path: str) -> None: - """Find an XML file to open - - If file could not be opened, try: - - file__ - - file.bak.0 - - file.bak.1 - - .... until BACKUP_NUMBER - - If file doesn't exist, create a new file.""" - - files = [ - path, # Main file - path + '__', # Temp file - ] - - # Add backup files - files += [self.get_backup_path(path, i) - for i in range(self.BACKUPS_NUMBER)] - - - for index, filepath in enumerate(files): - try: - log.debug('Opening file %s', filepath) - self.load_file(filepath) - - timestamp = os.path.getmtime(filepath) - mtime = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') - - # This was a backup. We should inform the user - if index > 0: - self.backup_info = { - 'name': filepath, - 'time': mtime - } - - # We could open a file, let's stop this loop - break - - except FileNotFoundError: - log.debug('File not found: %r. Trying next.', filepath) - continue - - except PermissionError: - log.debug('Not allowed to open: %r. Trying next.', filepath) - continue - - except et.XMLSyntaxError as error: - log.debug('Syntax error in %r. %r. Trying next.', - filepath, error) - continue - - # We couldn't open any file :( - if not self.xml_tree: - try: - # Try making a new empty file and open it - self.first_run(path) - - except IOError: - raise SystemError(f'Could not write a file at {path}') - - - def purge(self, max_days: int) -> None: - """Remove closed tasks and unused tags.""" - - log.debug("Deleting old tasks") - - today = Date.today() - for task in self.tasks.data: - if (today - task.date_closed).days > max_days: - self.tasks.remove(task.id) - - log.debug("Deleting unused tags") - - for tag in self.tags.data: - count_open = self.task_count['open'].get(tag.name, 0) - count_closed = self.task_count['closed'].get(tag.name, 0) - customized = tag.color or tag.icon - - if (count_open + count_closed) == 0 and not customized: - self.tags.remove(tag.id) - - - # -------------------------------------------------------------------------- - # BACKENDS - # -------------------------------------------------------------------------- - - def get_all_backends(self, disabled=False): - """returns list of all registered backends for this DataStore. - - @param disabled: If disabled is True, attaches also the list of - disabled backends - @return list: a list of TaskSource objects - """ - result = [] - for backend in self.backends.values(): - if backend.is_enabled() or disabled: - result.append(backend) - return result - - - def get_backend(self, backend_id): - """ - Returns a backend given its id. - - @param backend_id: a backend id - @returns GTG.core.datastore.TaskSource or None: the requested backend, - or None - """ - if backend_id in self.backends: - return self.backends[backend_id] - else: - return None - - - def register_backend(self, backend_dic): - """ - Registers a TaskSource as a backend for this DataStore - - @param backend_dic: Dictionary object containing all the - parameters to initialize the backend - (filename...). It should also contain the - backend class (under "backend"), and its - unique id (under "pid") - """ - if "backend" in backend_dic: - if "pid" not in backend_dic: - log.error("registering a backend without pid.") - return None - backend = backend_dic["backend"] - first_run = backend_dic["first_run"] - - # Checking that is a new backend - if backend.get_id() in self.backends: - log.error("registering already registered backend") - return None - - if first_run: - backend.this_is_the_first_run(None) - - self.backends[backend.get_id()] = backend - # we notify that a new backend is present - self._backend_signals.backend_added(backend.get_id()) - # saving the backend in the correct dictionary (backends for - # enabled backends, disabled_backends for the disabled ones) - # this is useful for retro-compatibility - if GenericBackend.KEY_ENABLED not in backend_dic: - backend.set_parameter(GenericBackend.KEY_ENABLED, True) - if GenericBackend.KEY_DEFAULT_BACKEND not in backend_dic: - backend.set_parameter(GenericBackend.KEY_DEFAULT_BACKEND, True) - # if it's enabled, we initialize it - if backend.is_enabled() and \ - (self.is_default_backend_loaded or backend.is_default()): - - backend.initialize(connect_signals=False) - # Filling the backend - # Doing this at start is more efficient than - # after the GUI is launched - backend.start_get_tasks() - - return backend - else: - log.error("Tried to register a backend without a pid") - - - def _activate_non_default_backends(self, sender=None): - """ - Non-default backends have to wait until the default loads before - being activated. This function is called after the first default - backend has loaded all its tasks. - - @param sender: not used, just here for signal compatibility - """ - - self.is_default_backend_loaded = True - for backend in self.backends.values(): - if backend.is_enabled() and not backend.is_default(): - self._backend_startup(backend) - - - def _backend_startup(self, backend): - """ - Helper function to launch a thread that starts a backend. - - @param backend: the backend object - """ - - def __backend_startup(self, backend): - """ - Helper function to start a backend - - @param backend: the backend object - """ - backend.initialize() - backend.start_get_tasks() - self.flush_all_tasks(backend.get_id()) - - thread = threading.Thread(target=__backend_startup, - args=(self, backend)) - thread.setDaemon(True) - thread.start() - - - def set_backend_enabled(self, backend_id, state): - """ - The backend corresponding to backend_id is enabled or disabled - according to "state". - Disable: - Quits a backend and disables it (which means it won't be - automatically loaded next time GTG is started) - Enable: - Reloads a disabled backend. Backend must be already known by the - Datastore - - @param backend_id: a backend id - @param state: True to enable, False to disable - """ - if backend_id in self.backends: - backend = self.backends[backend_id] - current_state = backend.is_enabled() - if current_state is True and state is False: - # we disable the backend - # FIXME!!! - threading.Thread(target=backend.quit, - kwargs={'disable': True}).start() - elif current_state is False and state is True: - if self.is_default_backend_loaded is True: - self._backend_startup(backend) - else: - # will be activated afterwards - backend.set_parameter(GenericBackend.KEY_ENABLED, - True) - - - def remove_backend(self, backend_id): - """ - Removes a backend, and forgets it ever existed. - - @param backend_id: a backend id - """ - if backend_id in self.backends: - backend = self.backends[backend_id] - if backend.is_enabled(): - self.set_backend_enabled(backend_id, False) - # FIXME: to keep things simple, backends are not notified that they - # are completely removed (they think they're just - # deactivated). We should add a "purge" call to backend to - # let them know that they're removed, so that they can - # remove all the various files they've created. (invernizzi) - - # we notify that the backend has been deleted - self._backend_signals.backend_removed(backend.get_id()) - del self.backends[backend_id] - - - def backend_change_attached_tags(self, backend_id, tag_names): - """ - Changes the tags for which a backend should store a task - - @param backend_id: a backend_id - @param tag_names: the new set of tags. This should not be a tag object, - just the tag name. - """ - backend = self.backends[backend_id] - backend.set_attached_tags(tag_names) - - - def flush_all_tasks(self, backend_id): - """ - This function will cause all tasks to be checked against the backend - identified with backend_id. If tasks need to be added or removed, it - will be done here. - It has to be run after the creation of a new backend (or an alteration - of its "attached tags"), so that the tasks which are already loaded in - the Tree will be saved in the proper backends - - @param backend_id: a backend id - """ - - def _internal_flush_all_tasks(): - backend = self.backends[backend_id] - for task in self.tasks.data: - if self.please_quit: - break - backend.queue_set_task(task.id) - t = threading.Thread(target=_internal_flush_all_tasks) - t.start() - self.backends[backend_id].start_get_tasks() - - - # -------------------------------------------------------------------------- - # TESTING AND UTILS - # -------------------------------------------------------------------------- - - def fill_with_samples(self, tasks_count: int) -> None: - """Fill the Datastore with sample data.""" - - def random_date(start: datetime = None): - start = start or datetime.now() - end = start + timedelta(days=random.randint(1, 365 * 5)) - - return start + (end - start) - - - def random_boolean() -> bool: - return bool(random.getrandbits(1)) - - - def random_word(length: int) -> str: - letters = string.ascii_lowercase - return ''.join(random.choice(letters) for _ in range(length)) - - - if tasks_count == 0: - return - - - tags_count = random.randint(tasks_count // 10, tasks_count) - search_count = random.randint(0, tasks_count // 10) - task_sizes = [random.randint(0, 200) for _ in range(10)] - randint = random.randint - tag_words = [random_word(randint(2, 14)) for _ in range(tags_count)] - - # Generate saved searches - for _ in range(search_count): - name = random_word(randint(2, 10)) - query = random_word(randint(2, 10)) - self.saved_searches.new(name, query) - - # Generate tags - for tag_name in tag_words: - tag = self.tags.new(tag_name) - tag.actionable = random_boolean() - tag.color = self.tags.generate_color() - - - # Parent the tags - for tag in self.tags.data: - if bool(random.getrandbits(1)): - parent = random.choice(self.tags.data) - - if tag.id == parent.id: - continue - - self.tags.parent(tag.id, parent.id) - - - # Generate tasks - for _ in range(tasks_count): - title = '' - content = '' - content_size = random.choice(task_sizes) - - for _ in range(random.randint(1, 15)): - word = random_word(randint(4, 20)) - - if word in tag_words: - word = '@' + word - - title += word + ' ' - - task = self.tasks.new(title) - - for _ in range(random.randint(0, 10)): - tag = self.tags.find(random.choice(tag_words)) - task.add_tag(tag) - - if random_boolean(): - task.toggle_active() - - if random_boolean(): - task.toggle_dismiss() - - for _ in range(random.randint(0, content_size)): - word = random_word(randint(4, 20)) - - if word in tag_words: - word = '@' + word - - content += word + ' ' - content += '\n' if random_boolean() else '' - - task.content = content - - if random_boolean(): - task.date_start = random_date() - - if random_boolean(): - task.date_due = Date(random_date()) - - - # Parent the tasks - for task in self.tasks.data: - if bool(random.getrandbits(1)): - parent = random.choice(self.tasks.data) - - if task.id == parent.id: - continue - - self.tasks.parent(task.id, parent.id) diff --git a/GTG/core/dates.py b/GTG/core/dates.py index 80e8789bd2..68b81cfcd2 100644 --- a/GTG/core/dates.py +++ b/GTG/core/dates.py @@ -199,7 +199,7 @@ def dt_by_accuracy(self, wanted_accuracy: Accuracy): return self.dt_value if self.accuracy is Accuracy.fuzzy: now = datetime.now() - delta_days = {SOON: 15, SOMEDAY: 365, NODATE: 9999} + delta_days = {SOON: 15, SOMEDAY: 9999, NODATE: 9999} gtg_date = Date(now + timedelta(delta_days[self.dt_value])) if gtg_date.accuracy is wanted_accuracy: return gtg_date.dt_value diff --git a/GTG/core/filters.py b/GTG/core/filters.py new file mode 100644 index 0000000000..4207f4a635 --- /dev/null +++ b/GTG/core/filters.py @@ -0,0 +1,182 @@ +# ----------------------------------------------------------------------------- +# Getting Things GNOME! - a personal organizer for the GNOME desktop +# Copyright (c) - The GTG Team +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, see . +# ----------------------------------------------------------------------------- + +"""Filters for tags and tasks""" + +from gi.repository import Gtk, GObject, Gdk +from GTG.core.tags import Tag +from GTG.core.tasks import Task, Status +from GTG.core import search + + +def unwrap(row, expected_type): + """Find an item in TreeRow widget (sometimes nested).""" + + item = row.get_item() + + while type(item) is not expected_type: + item = item.get_item() + + return item + + +class TagEmptyFilter(Gtk.Filter): + __gtype_name__ = 'TagEmptyFilter' + + def __init__(self, ds, pane): + super(TagEmptyFilter, self).__init__() + self.ds = ds + self.pane = pane + + + def do_match(self, item) -> bool: + tag = unwrap(item, Tag) + + if self.pane == 'open': + return tag.task_count_open > 0 + + elif self.pane == 'closed': + return tag.task_count_closed > 0 + + elif self.pane == 'workview': + return tag.task_count_actionable > 0 and tag.actionable + + else: + return True + + +class TaskPaneFilter(Gtk.Filter): + __gtype_name__ = 'TaskPaneFilter' + + def __init__(self, ds, pane, tags = [], no_tags=False): + super(TaskPaneFilter, self).__init__() + self.ds = ds + self.pane = pane + self.tags = set() + self.no_tags = no_tags + self.expand = False + + + def expand_tags(self) -> set: + """Expand tags to include their children.""" + + def get_children(children: set) -> None: + for child in children: + result.add(child) + + if child.children: + return get_children(child.children) + + + result = set() + + for tag in self.tags: + result.add(tag) + + if tag.children: + get_children(tag.children) + + return result + + + def match_tags(self, task: Task) -> bool: + """Match selected tags to task tags.""" + + all_tags = self.expand_tags() + return len(all_tags.intersection(set(task.tags))) >= len(self.tags) + + + def do_match(self, item) -> bool: + task = unwrap(item, Task) + + if self.pane == 'active': + show = task.status is Status.ACTIVE + elif self.pane == 'workview': + show = task.is_actionable + + if self.expand: + item.set_expanded(True) + self.expand = False + + elif self.pane == 'closed': + show = task.status is not Status.ACTIVE + + if show: + if self.no_tags: + current = not task.tags + return current or any(bool(c.tags) for c in task.children) + elif self.tags: + current = self.match_tags(task) + return current or any(self.match_tags(c) for c in task.children) + else: + return True + else: + return False + + +class SearchTaskFilter(Gtk.Filter): + __gtype_name__ = 'SearchTaskFilter' + + def __init__(self, ds, pane): + super(SearchTaskFilter, self).__init__() + self.ds = ds + self.query = '' + self.checks = None + self.pane = pane + self.expand = False + self.tags = set() + + + def set_query(self, query: str) -> None: + self.query = query + + try: + self.checks = search.parse_search_query(query) + except search.InvalidQuery: + self.checks = None + + + def match_tags(self, task: Task) -> bool: + """Match selected tags to task tags.""" + + return len(self.tags.intersection(set(task.tags))) >= len(self.tags) + + + def do_match(self, item) -> bool: + task = unwrap(item, Task) + + if self.pane == 'active': + show = task.status is Status.ACTIVE + elif self.pane == 'workview': + show = task.is_actionable + if self.expand: + item.set_expanded(True) + self.expand = False + elif self.pane == 'closed': + show = task.status is not Status.ACTIVE + + if show: + if self.tags: + current = self.match_tags(task) + tag_show = current or any(self.match_tags(c) for c in task.children) + + return tag_show and search.search_filter(task, self.checks) + + return search.search_filter(task, self.checks) + else: + return False \ No newline at end of file diff --git a/GTG/core/firstrun_tasks.py b/GTG/core/firstrun_tasks.py index 75538d13b3..42bbaba83e 100644 --- a/GTG/core/firstrun_tasks.py +++ b/GTG/core/firstrun_tasks.py @@ -21,8 +21,7 @@ Tasks should serve as a quick tutorial how GTG works """ from gettext import gettext as _ -from GTG.core.tag import extract_tags_from_text -from GTG.core import xml +from GTG.core.tags import extract_tags_from_text from GTG.core.dates import Date from uuid import uuid4 @@ -68,13 +67,13 @@ "# Creating and editing tasks\n" "\n" "Using GTG is easy: you organize what you have to do by creating new " - "tasks. To do this, simply press the "New Task" (+) button, " + "tasks. To do this, simply press the \"New Task\" (+) button, " "edit the task by describing it, set some parameters, and that's it! " - "Once a task is done, you can close it by pressing the "Mark As " - "Done" button.\n" + "Once a task is done, you can close it by pressing the \"Mark As " + "Done\" button.\n" "\n" "In GTG, a task is automatically saved while you are editing it. No " - "need to press any "Save" button! Try it: add some text to " + "need to press any \"Save\" button! Try it: add some text to " "this task, close the window, and reopen it: your changes are still " "there!\n" "\n" @@ -82,21 +81,21 @@ "\n" "In life, you often get more things done by refining them in " "smaller, more operational tasks. GTG helps to do just this by " - "defining "subtasks". In GTG, those subtasks are " + "defining \"subtasks\". In GTG, those subtasks are " "considered as prerequisites that must be completed before being able " "to close their parent task.\n" "\n" "Therefore, in GTG, a task might host one or several subtasks. Those " "appear as links in the task description, just like the link below. " "To open and edit a subtask, simply click on its link! " - "You can always come back using the "Open Parent" button. " + "You can always come back using the \"Open Parent\" button. " "Try opening the following subtask for example:\n" "{! 262d1410-71aa-4e35-abec-90ef1bab44d3 !}\n" "\n" "# Closing a task\n" "\n" "In GTG, once you are done with a task, you can close it by pushing " - "either the "Mark as Done" or the "Dismiss" " + "either the \"Mark as Done\" or the \"Dismiss\" " "button. Use the first one if the task is done, and the latter if you " "want to close it because it is not relevant anymore.\n" "\n" @@ -106,7 +105,7 @@ "anymore (they were prerequisites, after all).\n" "\n" "Note that the tasks that you have marked as done or dismissed are " - "listed in the "Closed" tasks view mode.\n" + "listed in the \"Closed\" tasks view mode.\n" "\n" "# Learn more about GTG\n" "\n" @@ -122,7 +121,7 @@ "{! 586dd1a7-0772-4d0a-85db-34edfc8ee30c !}\n" "\n" "We also recommend you read our user manual, by pressing F1 " - "or using the "Help" entry in the main window's menu button.\n" + "or using the \"Help\" entry in the main window's menu button.\n" "\n" "We hope you will enjoy using GTG, and thank you for trying it out! " "To learn more about the GTG project and how you can contribute, " @@ -137,16 +136,16 @@ 'added': today, 'modified': today, 'content': _( - "A "Subtask" is something that you need to do first before " + "A \"Subtask\" is something that you need to do first before " "being able to accomplish your task. In GTG, the purpose of subtasks " "is to cut down a task (or project) in smaller, action-oriented subtasks " "that are easier to achieve and to track down.\n\n" "To insert a subtask in the task description (this window, for " - "instance), begin a line with "-", then write the subtask " + "instance), begin a line with \"-\", then write the subtask " "title and press Enter.\n" "\n" - "Try inserting one subtask below. Type "- This is my first " - "subtask!", for instance, and press Enter:\n" + "Try inserting one subtask below. Type \"- This is my first " + "subtask!\", for instance, and press Enter:\n" "\n" "\n" "\n" @@ -169,9 +168,9 @@ 'modified': today, 'content': _( "In GTG, you use tags to sort your tasks. A tag is a simple word that " - "begins with "@".\n" + "begins with \"@\".\n" "\n" - "Try to type a word beginning with "@" here:\n" + "Try to type a word beginning with \"@\" here:\n" "\n" "Once it becomes highlighted, it means it is recognized as a tag, " "and this tag is immediately linked to the task.\n" @@ -202,19 +201,19 @@ 'added': today, 'modified': today, 'content': _( - "If you press the "Actionable" tab, only actionable tasks " + "If you press the \"Actionable\" tab, only actionable tasks " "will be displayed in your list.\n" "\n" "What is an actionable task? It's a task you can do directly, right " "now.\n" "\n" - "It's a task that is already "start-able", i.e. the start " + "It's a task that is already \"start-able\", i.e. the start " "date is already over.\n" "\n" "It's a task that doesn't have open subtasks, i.e. you can do the " "task itself directly, it does not depend on something else.\n" "\n" - "It's a task that has a due date different than "Someday", " + "It's a task that has a due date different than \"Someday\", " "since this kind of date is reserved for things that needs more " "thoughts before being actionable.\n" "\n" @@ -233,7 +232,7 @@ "Actionable view, it might disappear from the view due to the change " "you just made (e.g. adding a tag hidden in the Actionable view, etc.). " "To avoid this, you may prefer to edit your task while " - "in the "Open" tasks view instead.") + "in the \"Open\" tasks view instead.") }, { @@ -245,7 +244,7 @@ 'content': _( "GTG has the ability to add plugins to extend its core functionality." "\n\n" - "You can find the Plugins Manager by clicking on "Plugins" " + "You can find the Plugins Manager by clicking on \"Plugins\" " "in the main window's menu button. We would like to encourage you " "to write your own plugins and contribute them to the GTG project " "so that we can consider them for wider inclusion.") @@ -304,11 +303,11 @@ "if you want to edit your tasks using an online service.\n" "\n" "To use synchronization services, click the menu button in the main window, " - "and select "Synchronization". You will then have the " + "and select \"Synchronization\". You will then have the " "possibility to select among several online or local services " "from/to where you can import or export your tasks.\n" "As of v0.6, only CalDAV is available. You need to have the " - ""caldav" Python package installed if it doesn't appear.") + "\"caldav\" Python package installed if it doesn't appear.") }, { @@ -324,18 +323,18 @@ "Searching for tasks is really easy: in the main window, hit Ctrl+F and " "just type the words you are looking for in the search bar.\n" "\n" - "GTG can store your searches in the sidebar, in the "Saved Searches" - "" section. You can thus always go back to a previous search if you " + "GTG can store your searches in the sidebar, in the \"Saved Searches" + "\" section. You can thus always go back to a previous search if you " "need it. Search results are updated automatically, so you always get " "all the tasks matching your search request.\n" "\n" "GTG's search feature is really powerful and accepts many parameters " "that allow you to search for very specific tasks. For instance, " - "using the search query "@errands !today", you can search " + "using the search query \"@errands !today\", you can search " "for tasks with the @errands tag that must be done today. To learn " "more about the various search parameters you can use, refer to the " "Search Syntax documentation in GTG's user manual, found through the " - ""Help" menu item in the main window's menu button.") + "\"Help\" menu item in the main window's menu button.") }, ] diff --git a/GTG/core/meson.build b/GTG/core/meson.build index f7a1f96a8d..f54a6544c4 100644 --- a/GTG/core/meson.build +++ b/GTG/core/meson.build @@ -26,29 +26,25 @@ gtg_core_sources = [ 'borg.py', 'clipboard.py', 'config.py', - 'datastore.py', 'dates.py', 'dirs.py', 'firstrun_tasks.py', 'interruptible.py', 'keyring.py', 'networkmanager.py', - 'requester.py', 'search.py', - 'tag.py', - 'task.py', - 'xml.py', 'timer.py', - 'treefactory.py', 'twokeydict.py', 'urlregex.py', 'watchdog.py', 'versioning.py', 'base_store.py', - 'tasks2.py', - 'tags2.py', + 'tasks.py', + 'tags.py', 'saved_searches.py', - 'datastore2.py', + 'datastore.py', + 'filters.py', + 'sorters.py', ] gtg_core_plugin_sources = [ diff --git a/GTG/core/plugins/api.py b/GTG/core/plugins/api.py index 3af6dac8da..7fe7f3d095 100644 --- a/GTG/core/plugins/api.py +++ b/GTG/core/plugins/api.py @@ -37,8 +37,7 @@ class PluginAPI(): """ def __init__(self, - requester, - view_manager, + app, taskeditor=None): """ Construct a PluginAPI object. @@ -48,25 +47,29 @@ def __init__(self, @param task_id: The Editor, if we are in one otherwise. """ - self.__requester = requester - self.__view_manager = view_manager + + self.app = app + self.ds = app.ds + self.browser = app.browser + self.selection_changed_callback_listeners = [] + if taskeditor: self.__ui = taskeditor - self.__builder = self.__ui.get_builder() self.__task_id = taskeditor.get_task() else: - self.__ui = self.__view_manager.browser - self.__builder = self.__ui.get_builder() + self.__ui = self.browser self.__task_id = None - self.__view_manager.browser.selection.connect( - "changed", self.__selection_changed) + + for pane in self.browser.panes.values(): + pane.task_selection.connect('selection-changed', self.__selection_changed, pane) + self.taskwidget_id = 0 self.taskwidget_widg = [] - def __selection_changed(self, selection): + def __selection_changed(self, model, position, n_items, user_data=None): for func in self.selection_changed_callback_listeners: - func(selection) + func(user_data.get_selection()) # Accessor methods ============================================================ def is_editor(self): @@ -85,19 +88,7 @@ def get_view_manager(self): """ returns a GTG.gtk.manager.Manager """ - return self.__view_manager - - def get_requester(self): - """ - returns a GTG.core.requester.Requester - """ - return self.__requester - - def get_gtk_builder(self): - """ - Returns the gtk builder for the parent window - """ - return self.__builder + return self.app def get_ui(self): """ @@ -109,21 +100,21 @@ def get_browser(self): """ Returns a Browser """ - return self.__view_manager.browser + return self.browser def get_menu(self): """ Return the menu entry to the menu of the Task Browser or Task Editor. """ - return self.__builder.get_object('main_menu') + return self.__ui.get_menu() def get_header(self): """Return the headerbar of the mainwindow""" - return self.__builder.get_object('browser_headerbar') + return self.__ui.get_headerbar() def get_quickadd_pane(self): """Return the quickadd pane""" - return self.__builder.get_object('quickadd_pane') + return self.__ui.get_quickadd_pane() def get_selected(self): """ @@ -132,7 +123,7 @@ def get_selected(self): if self.is_editor(): return self.__task_id else: - return self.__view_manager.browser.get_selected_tasks() + return self.browser.get_selected_tasks() def set_active_selection_changed_callback(self, func): if func not in self.selection_changed_callback_listeners: @@ -149,9 +140,8 @@ def add_menu_item(self, item): @param item: The Gio.MenuItem that is going to be added. """ - _, _, menu = self.__builder.get_object( - 'editor_menu' if self.is_editor() else 'main_menu' - ).iterate_item_links(0).get_next() + # All menu items are added to the first section + _, _, menu = self.__ui.get_menu().iterate_item_links(0).get_next() menu.append_item(item) def remove_menu_item(self, item): @@ -162,10 +152,8 @@ def remove_menu_item(self, item): # you cannot remove items by identity since there values are simply copied # when adding a new one. A reliable solution is to instead find the first one # with the same label as the given one. - _, _, menu = self.__builder.get_object( - 'editor_menu' if self.is_editor() else 'main_menu' - # all menu items are added to the first section - ).iterate_item_links(0).get_next() + # All menu items are added to the first section + _, _, menu = self.get_menu().iterate_item_links(0).get_next() length = menu.get_n_items() i = 0 @@ -189,11 +177,9 @@ def add_widget_to_taskeditor(self, widget): @param widget: The Gtk.Widget that is going to be added. """ - vbox = self.__builder.get_object('pluginbox') + vbox = self.__ui.get_plugin_box() if vbox: - vbox.add(widget) - vbox.reorder_child(widget, -2) - widget.show_all() + vbox.append(widget) self.taskwidget_id += 1 self.taskwidget_widg.append(widget) return self.taskwidget_id @@ -207,7 +193,7 @@ def remove_widget_from_taskeditor(self, widg_id): """ if self.is_editor() and widg_id: try: - wi = self.__builder.get_object('vbox4') + wi = self.__ui.get_plugin_box() if wi and widg_id in self.taskwidget_widg: wi.remove(self.taskwidget_widg.pop(widg_id)) except Exception: @@ -223,12 +209,12 @@ def set_bgcolor_func(self, func=None): browser = self.get_browser() # set default bgcolor? - if func is None: - func = browser.tv_factory.get_task_bg_color + # if func is None: + # func = browser.tv_factory.get_task_bg_color - for pane in browser.vtree_panes.values(): - pane.set_bg_color(func, 'bg_color') - pane.basetree.get_basetree().refresh_all() + # for pane in browser.vtree_panes.values(): + # pane.set_bg_color(func, 'bg_color') + # pane.basetree.get_basetree().refresh_all() # file saving/loading ======================================================= def load_configuration_object(self, plugin_name, filename, diff --git a/GTG/core/plugins/engine.py b/GTG/core/plugins/engine.py index 15794ade06..500462e26d 100644 --- a/GTG/core/plugins/engine.py +++ b/GTG/core/plugins/engine.py @@ -15,7 +15,9 @@ # You should have received a copy of the GNU General Public License along with # this program. If not, see . # ----------------------------------------------------------------------------- -import imp + +import importlib +import inspect import os import logging from gi.repository import GLib @@ -101,15 +103,17 @@ def _load_module(self, module_paths): """Load the module containing this plugin.""" try: # import the module containing the plugin - f, pathname, desc = imp.find_module(self.module_name, module_paths) - module = imp.load_module(self.module_name, f, pathname, desc) - # find the class object for the actual plugin - for key, item in module.__dict__.items(): - if isinstance(item, type): - self.plugin_class = item - self.class_name = item.__dict__['__module__'].split('.')[1] - break + spec = importlib.machinery.PathFinder().find_spec(self.module_name, module_paths) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + + classes = inspect.getmembers(mod, inspect.isclass) + + self.class_name = classes[0][0] + self.plugin_class = classes[0][1] + except ImportError as e: + print(e) # load_module() failed, probably because of a module dependency if len(self.module_depends) > 0: self._check_module_depends() diff --git a/GTG/core/requester.py b/GTG/core/requester.py deleted file mode 100644 index 4efbc70a2b..0000000000 --- a/GTG/core/requester.py +++ /dev/null @@ -1,278 +0,0 @@ -# ----------------------------------------------------------------------------- -# Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program. If not, see . -# ----------------------------------------------------------------------------- - -""" -A nice general purpose interface for the datastore and tagstore -""" -import logging -from gi.repository import GObject - -from GTG.core.tag import Tag, SEARCH_TAG_PREFIX - -log = logging.getLogger(__name__) - - -class Requester(GObject.GObject): - """ A view on a GTG datastore. - - L{Requester} is a stateless object that simply provides a nice API for - user interfaces to use for datastore operations. - - Multiple L{Requester}s can exist on the same datastore, so they should - never have state of their own. - """ - __gsignals__ = {'status-changed': (GObject.SignalFlags.RUN_FIRST, None, (str, str, str,))} - - def __init__(self, datastore, global_conf): - """Construct a L{Requester}.""" - super().__init__() - self.ds = datastore - self._config = global_conf - self.__basetree = self.ds.get_tasks_tree() - - # Tasks Tree ###################### - # By default, we return the task tree of the main window - def get_tasks_tree(self, name='active', refresh=True): - return self.__basetree.get_viewtree(name=name, refresh=refresh) - - def get_main_view(self): - return self.__basetree.get_main_view() - - def is_displayed(self, task): - return self.__basetree.get_viewtree(name='active').is_displayed(task) - - def get_basetree(self): - return self.__basetree - - def apply_global_filter(self, tree, filtername): - """ - This method also update the viewcount of tags - TODO(jakubbrindza): Evaluate if this is used somewhere before release - """ - tree.apply_filter(filtername) - for t in self.get_all_tags(): - ta = self.get_tag(t) - ta.apply_filter(filtername) - - def unapply_global_filter(self, tree, filtername): - """ - TODO(jakubbrindza): Evaluate if this is used somewhere before release - """ - tree.unapply_filter(filtername) - for t in self.get_all_tags(): - ta = self.get_tag(t) - ta.unapply_filter(filtername) - - # Filters bank ####################### - # List, by name, all available filters - def list_filters(self): - return self.__basetree.list_filters() - - # Add a filter to the filter bank - # Return True if the filter was added - # Return False if the filter_name was already in the bank - def add_filter(self, filter_name, filter_func): - return self.__basetree.add_filter(filter_name, filter_func) - - # Remove a filter from the bank. - # Only custom filters that were added here can be removed - # Return False if the filter was not removed - def remove_filter(self, filter_name): - return self.__basetree.remove_filter(filter_name) - - # Tasks ########################## - def has_task(self, tid): - """Does the task 'tid' exist?""" - return self.ds.has_task(tid) - - def get_task(self, tid): - """Get the task with the given C{tid}. - - If no such task exists, create it and force the tid to be C{tid}. - - @param tid: The task id. - @return: A task. - """ - task = self.ds.get_task(tid) - return task - - # FIXME unused parameter newtask (maybe for compatibility?) - def new_task(self, tags=None, newtask=True): - """Create a new task. - - Note: this modifies the datastore. - - @param pid: The project where the new task will be created. - @param tags: The tags for the new task. If not provided, then the - task will have no tags. Tags must be an iterator type containing - the tags tids - @param newtask: C{True} if this is creating a new task that never - existed, C{False} if importing an existing task from a backend. - @return: A task from the data store - """ - task = self.ds.new_task() - if tags: - for t in tags: - assert(not isinstance(t, Tag)) - task.tag_added(t) - return task - - def delete_task(self, tid, recursive=True): - """Delete the task 'tid' and, by default, delete recursively - all the childrens. - - Note: this modifies the datastore. - - @param tid: The id of the task to be deleted. - """ - # send the signal before actually deleting the task ! - log.debug("deleting task %s", tid) - return self.__basetree.del_node(tid, recursive=recursive) - - def get_task_id(self, task_title): - """ Heuristic which convert task_title to a task_id - - Return a first task which has similar title """ - - task_title = task_title.lower() - tasks = self.get_tasks_tree('active', False).get_all_nodes() - tasktree = self.get_main_view() - for task_id in tasks: - task = tasktree.get_node(task_id) - if task_title == task.get_title().lower(): - return task_id - - return None - - # Tags ########################## - def get_tag_tree(self): - return self.ds.get_tagstore().get_viewtree(name='activetags') - - def new_tag(self, tagname): - """Create a new tag called 'tagname'. - - Note: this modifies the datastore. - - @param tagname: The name of the new tag. - @return: The newly-created tag. - """ - return self.ds.new_tag(tagname) - - def new_search_tag(self, query): - """ - Create a new search tag from search query - - Note: this modifies the datastore. - - @param query: Query will be parsed using search parser - @return: tag_id - """ - # ! at the beginning is reserved keyword for liblarch - if query.startswith('!'): - label = '_' + query - else: - label = query - - # find possible name collisions - name, number = label, 1 - already_search = False - while True: - tag = self.get_tag(SEARCH_TAG_PREFIX + name) - if tag is None: - break - - if tag.is_search_tag() and tag.get_attribute("query") == query: - already_search = True - break - - # this name is used, adding number - number += 1 - name = label + ' ' + str(number) - - if not already_search: - tag = self.ds.new_search_tag(name, query) - - return SEARCH_TAG_PREFIX + name - - def remove_tag(self, name): - """ calls datastore to remove a given tag """ - self.ds.remove_tag(name) - - def rename_tag(self, oldname, newname): - self.ds.rename_tag(oldname, newname) - - def get_tag(self, tagname): - return self.ds.get_tag(tagname) - - def get_used_tags(self): - """Return tags currently used by a task. - - @return: A list of tag names used by a task. - """ - tagstore = self.ds.get_tagstore() - view = tagstore.get_viewtree(name='tag_completion', refresh=False) - tags = view.get_all_nodes() - tags.sort(key=str.lower) - return tags - - def get_all_tags(self): - """ - Gets all tags from all tasks - """ - return self.ds.get_tagstore().get_main_view().get_all_nodes() - - def delete_tag(self, tagname): - my_tag = self.get_tag(tagname) - for task_id in my_tag.get_related_tasks(): - my_task = self.get_task(task_id) - my_task.remove_tag(tagname) - my_task.sync() - - # Backends ####################### - def get_all_backends(self, disabled=False): - return self.ds.get_all_backends(disabled) - - def register_backend(self, dic): - return self.ds.register_backend(dic) - - def flush_all_tasks(self, backend_id): - return self.ds.flush_all_tasks(backend_id) - - def get_backend(self, backend_id): - return self.ds.get_backend(backend_id) - - def set_backend_enabled(self, backend_id, state): - return self.ds.set_backend_enabled(backend_id, state) - - def remove_backend(self, backend_id): - return self.ds.remove_backend(backend_id) - - def backend_change_attached_tags(self, backend_id, tags): - return self.ds.backend_change_attached_tags(backend_id, tags) - - def save_datastore(self, quit=False): - return self.ds.save(quit) - - # Config ############################ - def get_config(self, system): - """ Returns configuration object for subsytem, e.g. browser """ - return self._config.get_subconfig(system) - - def get_task_config(self, task_id): - """ Returns configuration object for task """ - return self._config.get_task_config(task_id) diff --git a/GTG/core/saved_searches.py b/GTG/core/saved_searches.py index d7e6f5f025..fa361eabb0 100644 --- a/GTG/core/saved_searches.py +++ b/GTG/core/saved_searches.py @@ -19,7 +19,7 @@ """Everything related to saved searches.""" -from gi.repository import GObject +from gi.repository import GObject, Gio from uuid import uuid4, UUID from typing import Optional @@ -36,17 +36,49 @@ class SavedSearch(GObject.Object): """A saved search.""" __gtype_name__ = 'gtg_SavedSearch' - __slots__ = ['id', 'name', 'query', 'icon', 'children'] def __init__(self, id: UUID, name: str, query: str) -> None: self.id = id - self.name = name - self.query = query + self._name = name + self._query = query + self._icon = None - self.icon = None - self.children = [] - self.parent = None + super(SavedSearch, self).__init__() + + @GObject.Property(type=str) + def name(self) -> str: + """Read only property.""" + + return self._name + + @name.setter + def set_name(self, value: str) -> None: + self._name = value + + + @GObject.Property(type=str) + def icon(self) -> str: + """Read only property.""" + + return self._icon + + + @icon.setter + def set_icon(self, value: str) -> None: + self._icon = value + + + @GObject.Property(type=str) + def query(self) -> str: + """Read only property.""" + + return self._query + + + @query.setter + def set_query(self, value: str) -> None: + self._query = value def __str__(self) -> str: @@ -76,6 +108,11 @@ class SavedSearchStore(BaseStore): #: Tag to look for in XML XML_TAG = 'savedSearch' + def __init__(self) -> None: + super().__init__() + + self.model = Gio.ListStore.new(SavedSearch) + def __str__(self) -> str: """String representation.""" @@ -109,42 +146,17 @@ def from_xml(self, xml: Element) -> None: log.debug('Added %s', search) - for element in elements: - parent_name = element.get('parent') - - if parent_name and parent_name != 'search': - tid = element.get('id') - - parent = self.find(parent_name) - - if parent: - self.parent(tid, parent.id) - log.debug('Added %s as child of %s', element, parent) - - def to_xml(self) -> Element: """Save searches to an LXML element.""" root = Element('searchlist') - parent_map = {} - - for search in self.data: - for child in search.children: - parent_map[child.id] = search.name - for search in self.lookup.values(): element = SubElement(root, self.XML_TAG) element.set('id', str(search.id)) element.set('name', search.name) element.set('query', search.query) - try: - element.set('parent', str(parent_map[search.id])) - except KeyError: - # Toplevel search - pass - return root @@ -154,10 +166,17 @@ def new(self, name: str, query: str, parent: UUID = None) -> SavedSearch: search_id = uuid4() search = SavedSearch(id=search_id, name=name, query=query) - if parent: - self.add(search, parent) - else: - self.data.append(search) - self.lookup[search_id] = search + self.data.append(search) + self.lookup[search_id] = search + self.model.append(search) return search + + + def add(self, item, parent_id: UUID = None) -> None: + """Add a tag to the tagstore.""" + + super().add(item, parent_id) + self.model.append(item) + + self.emit('added', item) diff --git a/GTG/core/search.py b/GTG/core/search.py index a8876e8809..38c1b1308f 100644 --- a/GTG/core/search.py +++ b/GTG/core/search.py @@ -247,7 +247,7 @@ def search_filter(task, parameters=None): """ Check if task satisfies all search parameters """ if parameters is None or 'q' not in parameters: - return False + return True def check_commands(commands_list): """ Execute search commands @@ -256,24 +256,25 @@ def check_commands(commands_list): def fulltext_search(task, word): """ check if task contains the word """ + word = word.lower() - text = task.get_excerpt(strip_tags=False).lower() - title = task.get_title().lower() + text = task.excerpt.lower() + title = task.title.lower() return word in text or word in title value_checks = { - 'after': lambda t, v: task.get_due_date() > v, - 'before': lambda t, v: task.get_due_date() < v, - 'tag': lambda t, v: v in task.get_tags_name(), + 'after': lambda t, v: task.date_due > v, + 'before': lambda t, v: task.date_due < v, + 'tag': lambda t, v: v in [tag.name for tag in t.tags], 'word': fulltext_search, - 'today': lambda task, v: task.get_due_date() == Date.today(), - 'tomorrow': lambda task, v: task.get_due_date() == Date.tomorrow(), - 'nodate': lambda task, v: task.get_due_date() == Date.no_date(), - 'now': lambda task, v: task.get_due_date() == Date.now(), - 'soon': lambda task, v: task.get_due_date() == Date.soon(), - 'someday': lambda task, v: task.get_due_date() == Date.someday(), - 'notag': lambda task, v: task.get_tags() == [], + 'today': lambda task, v: task.date_due == Date.today(), + 'tomorrow': lambda task, v: task.date_due == Date.tomorrow(), + 'nodate': lambda task, v: task.date_due == Date.no_date(), + 'now': lambda task, v: task.date_due == Date.now(), + 'soon': lambda task, v: task.date_due == Date.soon(), + 'someday': lambda task, v: task.date_due == Date.someday(), + 'notag': lambda task, v: task.tags == [], } for command in commands_list: diff --git a/GTG/core/sorters.py b/GTG/core/sorters.py new file mode 100644 index 0000000000..f2e8fb1178 --- /dev/null +++ b/GTG/core/sorters.py @@ -0,0 +1,186 @@ +# ----------------------------------------------------------------------------- +# Getting Things GNOME! - a personal organizer for the GNOME desktop +# Copyright (c) - The GTG Team +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, see . +# ----------------------------------------------------------------------------- + +"""Sorters for tags and tasks.""" + +from gi.repository import Gtk, GObject, Gdk +from GTG.core.tasks import Task + +def unwrap(row, expected_type): + """Find an item in TreeRow widget (sometimes nested).""" + + item = row + + while type(item) is not expected_type: + item = item.get_item() + + return item + + +class TaskTitleSorter(Gtk.Sorter): + __gtype_name__ = 'TaskTitleSorter' + + def __init__(self): + super(TaskTitleSorter, self).__init__() + + + def do_compare(self, a, b) -> Gtk.Ordering: + + a = unwrap(a, Task) + b = unwrap(b, Task) + + first = a.title[0] + second = b.title[0] + + if first > second: + return Gtk.Ordering.LARGER + elif first < second: + return Gtk.Ordering.SMALLER + else: + return Gtk.Ordering.EQUAL + + +class TaskDueSorter(Gtk.Sorter): + __gtype_name__ = 'DueSorter' + + def __init__(self): + super(TaskDueSorter, self).__init__() + + + def do_compare(self, a, b) -> Gtk.Ordering: + + a = unwrap(a, Task) + b = unwrap(b, Task) + + first = a.date_due + second = b.date_due + + if first > second: + return Gtk.Ordering.LARGER + elif first < second: + return Gtk.Ordering.SMALLER + else: + return Gtk.Ordering.EQUAL + + +class TaskStartSorter(Gtk.Sorter): + __gtype_name__ = 'StartSorter' + + def __init__(self): + super(TaskStartSorter, self).__init__() + + + def do_compare(self, a, b) -> Gtk.Ordering: + + a = unwrap(a, Task) + b = unwrap(b, Task) + + first = a.date_start + second = b.date_start + + if first > second: + return Gtk.Ordering.LARGER + elif first < second: + return Gtk.Ordering.SMALLER + else: + return Gtk.Ordering.EQUAL + + +class TaskModifiedSorter(Gtk.Sorter): + __gtype_name__ = 'ModifiedSorter' + + def __init__(self): + super(TaskModifiedSorter, self).__init__() + + + def do_compare(self, a, b) -> Gtk.Ordering: + + a = unwrap(a, Task) + b = unwrap(b, Task) + + first = a.date_modified + second = b.date_modified + + if first > second: + return Gtk.Ordering.LARGER + elif first < second: + return Gtk.Ordering.SMALLER + else: + return Gtk.Ordering.EQUAL + + +class TaskTagSorter(Gtk.Sorter): + __gtype_name__ = 'TagSorter' + + def __init__(self): + super(TaskTagSorter, self).__init__() + + + def get_first_letter(self, tags) -> str: + """Get first letter of the first tag in a set of tags.""" + + # Fastest way to get the first item + # on a set in Python + for t in tags: + return t.name[0] + + def do_compare(self, a, b) -> Gtk.Ordering: + + a = unwrap(a, Task) + b = unwrap(b, Task) + + if a.tags: + first = self.get_first_letter(a.tags) + else: + first = 'zzzzzzz' + + if b.tags: + second = self.get_first_letter(b.tags) + else: + second = 'zzzzzzz' + + if first > second: + return Gtk.Ordering.LARGER + elif first < second: + return Gtk.Ordering.SMALLER + else: + return Gtk.Ordering.EQUAL + + +class TaskAddedSorter(Gtk.Sorter): + __gtype_name__ = 'AddedSorter' + + def __init__(self): + super(TaskAddedSorter, self).__init__() + + + def do_compare(self, a, b) -> Gtk.Ordering: + + a = unwrap(a, Task) + b = unwrap(b, Task) + + first = a.date_added + second = b.date_added + + if first > second: + return Gtk.Ordering.LARGER + elif first < second: + return Gtk.Ordering.SMALLER + else: + return Gtk.Ordering.EQUAL + diff --git a/GTG/core/tag.py b/GTG/core/tag.py deleted file mode 100644 index 068e5a27e6..0000000000 --- a/GTG/core/tag.py +++ /dev/null @@ -1,287 +0,0 @@ -# ----------------------------------------------------------------------------- -# Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program. If not, see . -# ----------------------------------------------------------------------------- - -""" -tagstore is where the tag objects are handled. Also defines the Tag object. - -Tagstore is to tag as datastore is to task. Of course, the tagstore is -easier. See the end of this file for the Tag object implementation. -""" - -import uuid -import xml.sax.saxutils as saxutils -import re - -from liblarch import TreeNode -from functools import reduce - -# Tags with special meaning -ALLTASKS_TAG = "gtg-tags-all" -NOTAG_TAG = "gtg-tags-none" -SEP_TAG = "gtg-tags-sep" -SEARCH_TAG = "search" - -SEARCH_TAG_PREFIX = "__SAVED_SEARCH_" # For name inside the tagtree - -def extract_tags_from_text(text): - """ Given a string, returns a list of the @tags contained in that """ - - return re.findall(r'(?:^|[\s])(@[\w\/\.\-\:\&]*\w)', text) - - -def parse_tag_list(text): - """ Parse a line of a list of tasks. User can specify if the tag is - positive or not by prepending '!'. - - @param text: string entry from user - @return: list of tupples (tag, is_positive) - """ - - result = [] - for tag in text.split(): - if tag.startswith('!'): - tag = tag[1:] - is_positive = False - else: - is_positive = True - - result.append((tag, is_positive)) - return result - - -class Tag(TreeNode): - """A short name that can be applied to L{Task}s. - - I mean, surely you must know what a tag is by now. Think Gmail, - del.icio.us, Flickr et al. - - A tag is defined by its name, which in most cases is C{@something}. A tag - can also have multiple arbitrary attributes. The only attribute enforced - for tags is C{name}, which always matches L{Tag.get_name()}. - """ - - def __init__(self, name, req, attributes={}, tid=None): - """Construct a tag. - - @param name: The name of the tag. Should be a string, generally - a short one. - @param attributes: Allow having initial set of attributes without - calling _save callback - """ - super().__init__(name) - self._name = saxutils.unescape(str(name)) - self.req = req - self._save = None - self._attributes = {'name': self._name} - for key, value in attributes.items(): - self.set_attribute(key, value) - - self.viewcount = None - - if tid: - self.tid = tid - else: - self.tid = uuid.uuid4() - - def __get_viewcount(self): - if not self.viewcount and self.get_name() != "gtg-tags-sep": - basetree = self.req.get_basetree() - self.viewcount = basetree.get_viewcount(self.get_name(), False) - - sp_id = self.get_attribute("special") - if sp_id == "all": - pass - if sp_id == "notag": - self.viewcount.apply_filter('notag', refresh=False) - # No special means a normal tag - else: - self.viewcount.apply_filter(self.get_name(), refresh=False) - self.viewcount.apply_filter('active') - self.viewcount.register_cllbck(self.modified) - return self.viewcount - - def apply_filter(self, filtername): - if self.viewcount: - self.viewcount.apply_filter(filtername) - - def unapply_filter(self, filtername): - if self.viewcount: - self.viewcount.unapply_filter(filtername) - - # When a task change a tag, we may want to manually update - # To ensure that the task is well counted/uncounted for that tag - def update_task(self, task_id): - vc = self.__get_viewcount() - vc.modify(task_id) - - # overiding some functions to not allow dnd of special tags - def add_parent(self, parent_id): - p = self.req.get_tag(parent_id) - if p and not self.is_special() and not p.is_special(): - TreeNode.add_parent(self, parent_id) - - def add_child(self, child_id): - special_child = self.req.get_tag(child_id).is_special() - if not self.is_special() and not special_child: - TreeNode.add_child(self, child_id) - - def get_name(self): - """Return the internal name of the tag, as saved in the tree.""" - return self.get_attribute("name") - - def get_friendly_name(self): - """Return the name of the tag, but without the internal search tag prefix.""" - if self.is_search_tag(): - return self.get_attribute("name")[len(SEARCH_TAG_PREFIX):] - else: - return self.get_attribute("name") - - def set_save_callback(self, save): - self._save = save - - def set_attribute(self, att_name, att_value): - """Set an arbitrary attribute. - - This will call the C{save_cllbk} callback passed to the constructor. - - @param att_name: The name of the attribute. - @param att_value: The value of the attribute. Will be converted to a - string. - """ - modified = False - if att_name == "name": - raise KeyError( - "The name of tag cannot be set manually") - elif att_name == "parent": - self.add_parent(att_value) - modified = True - else: - # Attributes should all be strings. - val = str(att_value) - self._attributes[att_name] = val - if self._save: - self._save() - modified = True - if modified: - self.modified() - self.notify_related_tasks() - - def get_attribute(self, att_name): - """Get the attribute C{att_name}. - - Returns C{None} if there is no attribute matching C{att_name}. - """ - to_return = None - if att_name == 'parent': - if self.has_parent(): - parents_id = self.get_parents() - if len(parents_id) > 0: - to_return = reduce(lambda a, b: f"{a},{b}", - parents_id) - elif att_name == 'label': - to_return = self._attributes.get(att_name, self.get_id()) - else: - to_return = self._attributes.get(att_name, None) - return to_return - - def del_attribute(self, att_name): - """Deletes the attribute C{att_name}. - """ - if att_name not in self._attributes: - return - elif att_name in ['name', 'parent']: - return - else: - del self._attributes[att_name] - if self._save: - self._save() - self.modified() - self.notify_related_tasks() - - def get_all_attributes(self, butname=False, withparent=False): - """Return a list of all attribute names. - - @param butname: If True, exclude C{name} from the list of attribute - names. - @param withparent: If True, the "parent" attribute is attached - """ - attributes = list(self._attributes.keys()) - if butname: - attributes.remove('name') - if withparent: - parent_id = self.get_attribute("parent") - if parent_id: - attributes.append("parent") - return attributes - - # TASK relation #### - def get_active_tasks_count(self): - count = self.__get_count() - return count - - def get_total_tasks_count(self): - return self.__get_count() - - def __get_count(self, tasktree=None): - """Returns the number of all related tasks""" - # this method purposefully doesn't rely on get_related_tasks() - # which does a similar job, in order to benefit from liblarch - # optimizations - vc = self.__get_viewcount() - if vc: - return vc.get_n_nodes() - else: - return 0 - - def get_related_tasks(self, tasktree=None): - """Returns all related tasks node ids""" - if not tasktree: - tasktree = self.req.get_tasks_tree() - sp_id = self.get_attribute("special") - if sp_id == "all": - toreturn = tasktree.get_nodes(withfilters=['active']) - elif sp_id == "notag": - toreturn = tasktree.get_nodes(withfilters=['notag']) - elif sp_id == "sep": - toreturn = [] - else: - tname = self.get_name() - toreturn = tasktree.get_nodes(withfilters=[tname]) - return toreturn - - def notify_related_tasks(self): - """Notify changes to all related tasks""" - for task_id in self.get_related_tasks(): - my_task = self.req.get_task(task_id) - my_task.modified() - - def is_special(self): - return bool(self.get_attribute('special')) - - def is_search_tag(self): - return SEARCH_TAG in self.get_parents() - - def is_used(self): - return self.get_total_tasks_count() > 0 - - def is_actively_used(self): - return self.is_search_tag() or self.is_special() or\ - self.get_active_tasks_count() > 0 - - def __str__(self): - return "Tag: %s" % self.get_name() diff --git a/GTG/core/tags2.py b/GTG/core/tags.py similarity index 52% rename from GTG/core/tags2.py rename to GTG/core/tags.py index 35334187e3..4b7b64571c 100644 --- a/GTG/core/tags2.py +++ b/GTG/core/tags.py @@ -19,11 +19,12 @@ """Everything related to tags.""" -from gi.repository import GObject +from gi.repository import GObject, Gtk, Gio, Gdk from uuid import uuid4, UUID import logging import random +import re from lxml.etree import Element, SubElement from typing import Any, Dict, Set @@ -33,23 +34,33 @@ log = logging.getLogger(__name__) -class Tag2(GObject.Object): +def extract_tags_from_text(text): + """ Given a string, returns a list of the @tags contained in that """ + + return re.findall(r'(?:^|[\s])(@[\w\/\.\-\:\&]*\w)', text) + + +class Tag(GObject.Object): """A tag that can be applied to a Task.""" __gtype_name__ = 'gtg_Tag' - __slots__ = ['id', 'name', 'icon', 'color', 'actionable', 'children'] - def __init__(self, id: UUID, name: str) -> None: self.id = id - self.name = name + self._name = name - self.icon = None - self.color = None + self._icon = None + self._color = None self.actionable = True self.children = [] self.parent = None + self._task_count_open = 0 + self._task_count_actionable = 0 + self._task_count_closed = 0 + + super(Tag, self).__init__() + def __str__(self) -> str: """String representation.""" @@ -69,6 +80,93 @@ def __eq__(self, other) -> bool: return self.id == other.id + @GObject.Property(type=str) + def name(self) -> str: + """Read only property.""" + + return self._name + + + @name.setter + def set_name(self, value: str) -> None: + self._name = value + + + @GObject.Property(type=str) + def icon(self) -> str: + """Read only property.""" + + return self._icon + + + @icon.setter + def set_icon(self, value: str) -> None: + self._icon = value + self.notify('has-icon') + + + @GObject.Property(type=str) + def color(self) -> str: + """Read only property.""" + + return self._color + + + @color.setter + def set_color(self, value: str) -> None: + self._color = value + self.notify('has-color') + + + @GObject.Property(type=bool, default=False) + def has_color(self) -> bool: + + return self._color and not self._icon + + + @GObject.Property(type=bool, default=False) + def has_icon(self) -> bool: + + return self._icon + + + @GObject.Property(type=int, default=0) + def task_count_open(self) -> int: + + return self._task_count_open + + + @task_count_open.setter + def set_task_count_open(self, value: int) -> None: + self._task_count_open = value + + + @GObject.Property(type=int, default=0) + def task_count_actionable(self) -> int: + + return self._task_count_actionable + + + @task_count_actionable.setter + def set_task_count_actionable(self, value: int) -> None: + self._task_count_actionable = value + + + @GObject.Property(type=int, default=0) + def task_count_closed(self) -> int: + + return self._task_count_closed + + + @task_count_closed.setter + def set_task_count_closed(self, value: int) -> None: + self._task_count_closed = value + + + def __hash__(self): + return id(self) + + class TagStore(BaseStore): """A tree of tags.""" @@ -81,24 +179,42 @@ class TagStore(BaseStore): def __init__(self) -> None: self.used_colors: Set[Color] = set() - self.lookup_names: Dict[str, Tag2] = {} + self.lookup_names: Dict[str, Tag] = {} super().__init__() + self.model = Gio.ListStore.new(Tag) + self.tree_model = Gtk.TreeListModel.new(self.model, False, False, self.model_expand) + + + def model_expand(self, item): + model = Gio.ListStore.new(Tag) + + if type(item) == Gtk.TreeListRow: + item = item.get_item() + + # open the first one + if item.children: + for child in item.children: + model.append(child) + + return Gtk.TreeListModel.new(model, False, False, self.model_expand) + + def __str__(self) -> str: """String representation.""" return f'Tag Store. Holds {len(self.lookup)} tag(s)' - def find(self, name: str) -> Tag2: + def find(self, name: str) -> Tag: """Get a tag by name.""" return self.lookup_names[name] - def new(self, name: str, parent: UUID = None) -> Tag2: + def new(self, name: str, parent: UUID = None) -> Tag: """Create a new tag and add it to the store.""" name = name if not name.startswith('@') else name[1:] @@ -107,14 +223,12 @@ def new(self, name: str, parent: UUID = None) -> Tag2: return self.lookup_names[name] except KeyError: tid = uuid4() - tag = Tag2(id=tid, name=name) + tag = Tag(id=tid, name=name) if parent: self.add(tag, parent) else: - self.data.append(tag) - self.lookup[tid] = tag - self.lookup_names[name] = tag + self.add(tag) self.emit('added', tag) return tag @@ -132,10 +246,23 @@ def from_xml(self, xml: Element) -> None: name = element.get('name') color = element.get('color') icon = element.get('icon') + nonactionable = element.get('nonactionable') or 'False' - tag = Tag2(id=tid, name=name) + if color: + if not color.startswith('#'): + color = '#' + color + + rgb = Gdk.RGBA() + rgb.parse(color) + red = int(rgb.red * 255) + blue = int(rgb.blue * 255) + green = int(rgb.green * 255) + color = '#{:02x}{:02x}{:02x}'.format(red, green, blue) + + tag = Tag(id=tid, name=name) tag.color = color tag.icon = icon + tag.actionable = (nonactionable == 'False') self.add(tag) @@ -149,9 +276,13 @@ def from_xml(self, xml: Element) -> None: tid = element.get('id') try: - parent = self.find(parent_name) - self.parent(tid, parent.id) - log.debug('Added %s as child of %s', tag, parent) + parent_id = self.find(parent_name).id + except KeyError: + parent_id = parent_name + + try: + self.parent(tid, parent_id) + log.debug('Added %s as child of %s', tag, parent_name) except KeyError: pass @@ -178,6 +309,9 @@ def to_xml(self) -> Element: if tag.icon: element.set('icon', tag.icon) + + element.set('nonactionable', str(not tag.actionable)) + try: element.set('parent', str(parent_map[tag.id])) except KeyError: @@ -210,4 +344,24 @@ def add(self, item: Any, parent_id: UUID = None) -> None: super().add(item, parent_id) self.lookup_names[item.name] = item + + if not parent_id: + self.model.append(item) + self.emit('added', item) + + + def parent(self, item_id: UUID, parent_id: UUID) -> None: + + super().parent(item_id, parent_id) + item = self.lookup[item_id] + pos = self.model.find(item) + self.model.remove(pos[1]) + + + + def unparent(self, item_id: UUID, parent_id: UUID) -> None: + + super().unparent(item_id, parent_id) + item = self.lookup[item_id] + self.model.append(item) diff --git a/GTG/core/task.py b/GTG/core/task.py deleted file mode 100644 index 88be9af6a8..0000000000 --- a/GTG/core/task.py +++ /dev/null @@ -1,933 +0,0 @@ -# ----------------------------------------------------------------------------- -# Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program. If not, see . -# ----------------------------------------------------------------------------- - -""" -task.py contains the Task class which represents (guess what) a task -""" -from datetime import datetime, date -import html -import re -import uuid -import logging -import xml.sax.saxutils as saxutils - -from gi.repository import GLib - -from gettext import gettext as _ -from GTG.core.dates import Date -from liblarch import TreeNode - -log = logging.getLogger(__name__) - - -class Task(TreeNode): - """ This class represent a task in GTG. - You should never create a Task directly. Use the datastore.new_task() - function.""" - - STA_ACTIVE = "Active" - STA_DISMISSED = "Dismiss" - STA_DONE = "Done" - DEFAULT_TASK_NAME = None - - def __init__(self, task_id, requester, newtask=False): - super().__init__(task_id) - # the id of this task in the project should be set - # tid is a string ! (we have to choose a type and stick to it) - if not isinstance(task_id, str): - raise ValueError("Wrong type for task_id %r", type(task_id)) - self.tid = task_id - self.set_uuid(task_id) - self.remote_ids = {} - # set to True to disable self.sync() and avoid flooding on task edit - self.sync_disabled = False - self.content = "" - if Task.DEFAULT_TASK_NAME is None: - Task.DEFAULT_TASK_NAME = _("My new task") - self.title = Task.DEFAULT_TASK_NAME - # available status are: Active - Done - Dismiss - Note - self.status = self.STA_ACTIVE - - self.added_date = Date.no_date() - if newtask: - self.added_date = Date(datetime.now()) - - self.closed_date = Date.no_date() - self.due_date = Date.no_date() - self.start_date = Date.no_date() - self.can_be_deleted = newtask - # tags - self.tags = [] - self.req = requester - self.__main_treeview = requester.get_main_view() - # If we don't have a newtask, we will have to load it. - self.loaded = newtask - # Should not be necessary with the new backends -# if self.loaded: -# self.req._task_loaded(self.tid) - self.attributes = {} - self._modified_update() - - # Setting the attributes related to repeating tasks. - self.recurring = False # type: bool - self.recurring_term = None # type: str - self.recurring_updated_date = Date.no_date() - self.inherit_recursion() - - def get_added_date(self): - return self.added_date - - def set_added_date(self, value): - self.added_date = Date(value) - - def is_loaded(self): - return self.loaded - - def set_loaded(self, signal=True): - # avoid doing it multiple times - if not self.loaded: - self.loaded = True - - def set_to_keep(self): - self.can_be_deleted = False - - def is_new(self): - return self.can_be_deleted - - def get_id(self): - return str(self.tid) - - def set_uuid(self, value): - self.uuid = str(value) - - def get_uuid(self): - # NOTE: Transitional if switch, needed to add - # the uuid field to tasks created before - # adding this field to the task description. - if self.uuid == "": - self.set_uuid(uuid.uuid4()) - self.sync() - return str(self.uuid) - - def get_title(self): - return self.title - - def duplicate(self): - """ Duplicates a task with a new ID """ - copy = self.req.ds.new_task() - # Inherit the recurrency - copy.set_recurring(True, self.recurring_term) - nextdate = self.get_next_occurrence() - copy.set_due_date(nextdate) - - copy.set_title(self.title) - copy.content = self.content - copy.tags = self.tags - log.debug("Duppicating task %s as task %s", - self.get_id(), copy.get_id()) - return copy - - def duplicate_recursively(self): - """ - Duplicates recursively all the task itself and its children while keeping the relationship - """ - newtask = self.duplicate() - if self.has_child(): - for c_tid in self.get_children(): - child = self.req.get_task(c_tid) - if child.is_loaded(): - newtask.add_child(child.duplicate_recursively()) - - newtask.sync() - return newtask.tid - - - def set_title(self, title): - """Set the tasks title. Returns True if title was changed.""" - - if title: - title = title.strip('\t\n') - else: - title = '(no title task)' - - # Avoid unnecessary syncing - if title == self.title: - return False - else: - self.title = title - self.sync() - return True - - def toggle_status(self): - - if self.status in [self.STA_DONE, self.STA_DISMISSED]: - self.set_status(self.STA_ACTIVE) - else: - self.set_status(self.STA_DONE) - - def set_status(self, status, donedate=None, propagation=False, init=False): - old_status = self.status - self.can_be_deleted = False - # No need to update children or whatever if the task is not loaded - if status and self.is_loaded(): - # we first modify the status of the children - # If Done, we set the done date - if status in [self.STA_DONE, self.STA_DISMISSED]: - for c in self.get_subtasks(): - if c.get_status() in [self.STA_ACTIVE]: - c.set_status(status, donedate=donedate, propagation=True) - - # If the task is recurring, it must be duplicate with - # another task id and the next occurence of the task - # while preserving child/parent relations. - # For a task to be duplicated, it must satisfy 3 rules. - # 1- It is recurring. - # 2- It has no parent or no recurring parent. - # 3- It was directly marked as done (not by propagation from its parent). - rules = [self.recurring, not propagation] - if all(rules) and not self.is_parent_recurring(): - # duplicate all the children - nexttask_tid = self.duplicate_recursively() - if self.has_parent(): - for p_tid in self.get_parents(): - par = self.req.get_task(p_tid) - if par.is_loaded() and par.get_status() in (self.STA_ACTIVE): - par.add_child(nexttask_tid) - par.sync() - - # If we mark a task as Active and that some parent are not - # Active, we break the parent/child relation - # It has no sense to have an active subtask of a done parent. - # (old_status check is necessary to avoid false positive a start) - elif status in [self.STA_ACTIVE] and\ - old_status in [self.STA_DONE, self.STA_DISMISSED]: - if self.has_parent(): - for p_tid in self.get_parents(): - par = self.req.get_task(p_tid) - if par.is_loaded() and par.get_status() in\ - [self.STA_DONE, self.STA_DISMISSED]: - # we can either break the parent/child relationship - # self.remove_parent(p_tid) - # or restore the parent too - par.set_status(self.STA_ACTIVE) - # We dont mark the children as Active because - # They might be already completed after all - - # then the task itself - if status: - if not init: - GLib.idle_add(self.req.emit, "status-changed", self.tid, self.status, status) - self.status = status - - # Set closing date - if status and status in [self.STA_DONE, self.STA_DISMISSED]: - # to the specified date (if any) - if donedate: - self.closed_date = donedate - # or to today - else: - self.closed_date = Date.today() - self.sync() - - def get_status(self): - return self.status - - def get_modified(self): - return self.last_modified - - def set_modified(self, value): - self.last_modified = Date(value) - - def recursive_sync(self): - """Recursively sync the task and all task children. Defined""" - self.sync() - for sub_id in self.children: - sub = self.req.get_task(sub_id) - sub.recursive_sync() - - # ABOUT RECURRING TASKS - # Like anything related to dates, repeating tasks are subtle and complex - # when creating a new task, the due date is calculated from either the current date or - # the start date, while we get the next occurrence of a task not from the current date but - # from the due date itself. - # - # However when we are retrieving the task from the XML files, we should only set the - # the recurring_term. - - def set_recurring(self, recurring: bool, recurring_term: str = None, newtask=False): - """Sets a task as recurring or not, and its recurring term. - - There are 4 cases to acknowledge when setting a task to recurring: - - if repeating but the term is invalid: it will be set to False. - - if repeating and the term is valid: we set it to True. - - if not repeating and the term is valid: we set the bool attr to True and set the term. - - if not repeating and the term is invalid: we set it to False and keep the previous term. - - Setting a task as recurrent implies that the - children of a recurrent task will be also - set to recurrent and will inherit - their parent's recurring term - - Args: - recurring (bool): True if the task is recurring and False if not. - recurring_term (str, optional): the recurring period of a task (every Monday, day..). - Defaults to None. - newtask (bool, optional): if this is a new task, we must set the due_date. - Defaults to False. - """ - def is_valid_term(): - """ Verify if the term is valid and returns the appropriate Due date. - - Return a tuple of (bool, Date) - """ - if recurring_term is None: - return False, None - - try: - # If a start date is already set, - # we should calculate the next date from that day. - if self.start_date == Date.no_date(): - start_from = Date(datetime.now()) - else: - start_from = self.start_date - - newdate = start_from.parse_from_date(recurring_term, newtask) - return True, newdate - except ValueError: - return False, None - - self.recurring = recurring - # We verifiy if the term passed is valid - valid, newdate = is_valid_term() - - recurring_term = recurring_term if valid else None - - if self.recurring: - if not valid: - self.recurring_term = None - self.recurring = False - else: - self.recurring_term = recurring_term - self.recurring_updated_date = datetime.now() - if newtask: - self.set_due_date(newdate) - else: - if valid: - self.recurring_term = recurring_term - self.recurring_updated_date = datetime.now() - - self.sync() - # setting its children to recurrent - if self.has_child(): - for c_tid in self.get_children(): - child = self.req.get_task(c_tid) - if child.is_loaded() and child.get_status() in (self.STA_ACTIVE): - child.set_recurring(self.recurring, self.recurring_term) - if self.recurring: - child.set_due_date(newdate) - - def toggle_recurring(self): - """ Toggle a task's recurrency ON/OFF. Use this function to toggle, not set_recurring""" - # If there is no recurring_term, We assume it to recur every day. - newtask = False - if self.recurring_term is None: - self.recurring_term = 'day' - newtask = True - - self.set_recurring(not self.recurring, self.recurring_term, newtask) - - def get_recurring(self): - return self.recurring - - def get_recurring_term(self): - return self.recurring_term - - def get_recurring_updated_date(self): - return self.recurring_updated_date - - def set_recurring_updated_date(self, date): - self.recurring_updated_date = Date(date) - - def inherit_recursion(self): - """ Inherits the recurrent state of the parent. - If the task has a recurrent parent, it must be set to recur, itself. - """ - if self.has_parent(): - for p_tid in self.get_parents(): - par = self.req.get_task(p_tid) - if par.get_recurring() and par.is_loaded(): - self.set_recurring(True, par.get_recurring_term()) - self.set_due_date(par.due_date) - else: - self.set_recurring(False) - - - def get_next_occurrence(self): - """Calcutate the next occurrence of a recurring task - - To know which is the correct next occurrence there are two rules: - - if the task was marked as done before or during the open period (before the duedate); - in this case, we need to deal with the issue of tasks that recur on the same date. - example: due_date is 09/09 and done_date is 09/09 - - if the task was marked after the due date, we need to figure out the next occurrence - after the current date(today). - - Raises: - ValueError: if the recurring_term is invalid - - Returns: - Date: the next due date of a task - """ - today = date.today() - if today <= self.due_date: - try: - nextdate = self.due_date.parse_from_date(self.recurring_term, newtask=False) - while nextdate <= self.due_date: - nextdate = nextdate.parse_from_date(self.recurring_term, newtask=False) - return nextdate - except Exception: - raise ValueError(f'Invalid recurring term {self.recurring_term}') - elif today > self.due_date: - try: - next_date = self.due_date.parse_from_date(self.recurring_term, newtask=False) - while next_date < date.today(): - next_date = next_date.parse_from_date(self.recurring_term, newtask=False) - return next_date - except Exception: - raise ValueError(f'Invalid recurring term {self.recurring_term}') - - def is_parent_recurring(self): - if self.has_parent(): - for p_tid in self.get_parents(): - p = self.req.get_task(p_tid) - if p.is_loaded() and p.get_status() in (self.STA_ACTIVE) and p.get_recurring(): - return True - return False - - - # ABOUT DUE DATES - # - # PLEASE READ THIS: although simple in appearance, handling task dates can - # actually be subtle. Take the time to understand this if you plan to work - # on the methods below. - # - # Due date is the date at which a task must be accomplished. Constraints - # exist between a task's due date and its ancestor/children's due dates. - # - # Date constraints - # - # Those are the following: - # - children of a task cannot have a task due date that happens later - # than the task's due date - # - ancestors of a task cannot have a due that happens before the - # task's due date (this is the reverse constraint from the first one) - # - a task's start date cannot happen later than this task's due date - # - # Tasks with undefined or fuzzy due dates - # - # Task with no due date (="undefined" tasks) or tasks with fuzzy start/due - # dates are not subject to constraints. Furthermore, they are - # "transparent". Meaning that they let the constraints coming from their - # children/parents pass through them. So, for instance, a children of - # a task with an undefined or fuzzy task would be constrained by this - # latter task's ancestors. Equally, the an ancestor from the same - # undefined/fuzzy task would be constrained by the children due dates. - # - # Updating a task due date - # - # Whenever a task due date is changed, all ancestor/chldren of this task - # *must* be updated according to the constraining rules. As said above, - # constraints must go through tasks with undefined/fuzzy due dates too! - # - # Undefined/fuzzy task dates are NEVER to be updated. They are not - # sensitive to constraint. If you want to now what constraint there is - # on this task's due date though, you can obtain it by using - # get_due_date_constraint method. - def set_due_date(self, new_duedate): - """Defines the task's due date.""" - - def __get_defined_parent_list(task): - """Recursively fetch a list of parents that have a defined due date - which is not fuzzy""" - parent_list = [] - for par_id in task.parents: - par = self.req.get_task(par_id) - if par.get_due_date().is_fuzzy(): - parent_list += __get_defined_parent_list(par) - else: - parent_list.append(par) - return parent_list - - def __get_defined_child_list(task): - """Recursively fetch a list of children that have a defined - due date which is not fuzzy""" - child_list = [] - for child_id in task.children: - child = self.req.get_task(child_id) - if child.get_due_date().is_fuzzy(): - child_list += __get_defined_child_list(child) - else: - child_list.append(child) - return child_list - - old_due_date = self.due_date - new_duedate_obj = Date(new_duedate) # caching the conversion - self.due_date = new_duedate_obj - # If the new date is fuzzy or undefined, we don't update related tasks - if not new_duedate_obj.is_fuzzy(): - # if some ancestors' due dates happen before the task's new - # due date, we update them (except for fuzzy dates) - for par in __get_defined_parent_list(self): - if par.get_due_date() < new_duedate_obj: - par.set_due_date(new_duedate) - # we must apply the constraints to the defined & non-fuzzy children - # as well - for sub in __get_defined_child_list(self): - sub_duedate = sub.get_due_date() - # if the child's due date happens later than the task's: we - # update it to the task's new due date - if sub_duedate > new_duedate_obj: - sub.set_due_date(new_duedate) - # if the child's start date happens later than - # the task's new due date, we update it - # (except for fuzzy start dates) - sub_startdate = sub.get_start_date() - if not sub_startdate.is_fuzzy() and \ - sub_startdate > new_duedate_obj: - sub.set_start_date(new_duedate) - # If the date changed, we notify the change for the children since the - # constraints might have changed - if old_due_date != new_duedate_obj: - self.recursive_sync() - - def get_due_date(self): - """ Returns the due date, which always respects all constraints """ - return self.due_date - - def get_urgent_date(self): - """ - Returns the most urgent due date among the task and its active subtasks - """ - urgent_date = self.get_due_date() - for subtask in self.get_subtasks(): - if subtask.get_status() == self.STA_ACTIVE: - urgent_date = min(urgent_date, subtask.get_urgent_date()) - return urgent_date - - def get_due_date_constraint(self): - """ Returns the most urgent due date constraint, following - parents' due dates. Return Date.no_date() if no constraint - is applied. """ - # Check out for constraints depending on date definition/fuzziness. - strongest_const_date = self.due_date - if strongest_const_date.is_fuzzy(): - for par_id in self.parents: - par = self.req.get_task(par_id) - par_duedate = par.get_due_date() - # if parent date is undefined or fuzzy, look further up - if par_duedate.is_fuzzy(): - par_duedate = par.get_due_date_constraint() - # if par_duedate is still undefined/fuzzy, all parents' due - # dates are undefined or fuzzy: strongest_const_date is then - # the best choice so far, we don't update it. - if par_duedate.is_fuzzy(): - continue - # par_duedate is not undefined/fuzzy. If strongest_const_date - # is still undefined or fuzzy, parent_duedate is the best - # choice. - if strongest_const_date.is_fuzzy(): - strongest_const_date = par_duedate - continue - # strongest_const_date and par_date are defined and not fuzzy: - # we compare the dates - if par_duedate < strongest_const_date: - strongest_const_date = par_duedate - return strongest_const_date - - # ABOUT START DATE - # - # Start date is the date at which the user has decided to work or consider - # working on this task. - def set_start_date(self, fulldate): - self.start_date = Date(fulldate) - self.sync() - - def get_start_date(self): - return self.start_date - - # ABOUT CLOSED DATE - # - # Closed date is the date at which the task has been closed (done or - # dismissed). Closed date is not constrained and doesn't constrain other - # dates. - def set_closed_date(self, fulldate): - self.closed_date = Date(fulldate) - self.sync() - - def get_closed_date(self): - return self.closed_date - - def get_days_left(self): - return self.get_due_date().days_left() - - def get_days_late(self): - due_date = self.get_due_date() - if due_date == Date.no_date(): - return None - closed_date = self.get_closed_date() - return (closed_date - due_date).days - - def get_text(self): - """ Return the content or empty string in case of None """ - if self.content: - return str(self.content) - else: - return "" - - def get_excerpt(self, lines=0, char=0, strip_tags=False, - strip_subtasks=True): - """ - get_excerpt return the beginning of the content of the task. - If "lines" is provided and different than 0, it return the number X - of line (or the whole content if it contains less lines) - If "char" is provided, it returns the X first chars of content (or the - whole contents if it contains less char) - If both char and lines are provided, the shorter one is returned. - If none of them are provided (or if they are 0), this function is - equivalent to get_text with with all XML stripped down. - Warning: all markup informations are stripped down. Empty lines are - also removed - """ - # defensive programmation to avoid returning None - if self.content: - txt = self.content - - # Prevent issues with & in content - txt = saxutils.escape(txt) - txt = txt.strip() - - if strip_tags: - for tag in self.get_tags_name(): - txt = (txt.replace(f'@{tag}, ', '') - .replace(f'@{tag},', '') - .replace(f'@{tag}', '')) - - if strip_subtasks: - txt = re.sub(r'\{\!.+\!\}', '', txt) - - # Strip blank lines and get desired amount of lines - txt = [line for line in txt.splitlines() if line] - txt = txt[:lines] - txt = '\n'.join(txt) - - # We keep the desired number of char - if char > 0: - txt = txt[:char] - return txt - else: - return "" - - def __strip_content(self, element, strip_subtasks=False): - txt = "" - if element: - for n in element.childNodes: - if n.nodeType == n.ELEMENT_NODE: - if strip_subtasks and n.tagName == 'subtask': - if txt[-2:] == '→ ': - txt = txt[:-2] - else: - txt += self.__strip_content(n, strip_subtasks) - elif n.nodeType == n.TEXT_NODE: - txt += n.nodeValue - return txt - - def set_text(self, texte): - self.can_be_deleted = False - self.content = html.unescape(str(texte)) - - # SUBTASKS ############################################################### - def new_subtask(self): - """Add a newly created subtask to this task. Return the task added as - a subtask - """ - subt = self.req.new_task(newtask=True) - # we use the inherited childrens - self.add_child(subt.get_id()) - return subt - - def add_child(self, tid): - """Add a subtask to this task - - @param child: the added task - """ - log.debug("adding child %s to task %s", tid, self.get_id()) - self.can_be_deleted = False - # the core of the method is in the TreeNode object - TreeNode.add_child(self, tid) - # now we set inherited attributes only if it's a new task - child = self.req.get_task(tid) - if self.is_loaded() and child and child.can_be_deleted: - # If the the child is repeating no need to change the date - if not child.get_recurring(): - child.set_start_date(self.get_start_date()) - child.set_due_date(self.get_due_date()) - for t in self.get_tags(): - child.add_tag(t.get_name()) - - child.inherit_recursion() - - self.sync() - return True - - def remove_child(self, tid): - """Removed a subtask from the task. - - @param tid: the ID of the task to remove - """ - c = self.req.get_task(tid) - c.remove_parent(self.get_id()) - if c.can_be_deleted: - self.req.delete_task(tid) - self.sync() - return True - else: - return False - - # FIXME: remove this function and use liblarch instead. - def get_subtasks(self): - tree = self.get_tree() - return [tree.get_node(node_id) for node_id in self.get_children()] - - def set_parent(self, parent_id): - """Update the task's parent. Refresh due date constraints.""" - TreeNode.set_parent(self, parent_id) - if parent_id is not None: - par = self.req.get_task(parent_id) - par_duedate = par.get_due_date_constraint() - if not par_duedate.is_fuzzy() and \ - not self.due_date.is_fuzzy() and \ - par_duedate < self.due_date: - self.set_due_date(par_duedate) - self.inherit_recursion() - self.recursive_sync() - - def set_attribute(self, att_name, att_value, namespace=""): - """Set an arbitrary attribute. - - @param att_name: The name of the attribute. - @param att_value: The value of the attribute. Will be converted to a - string. - """ - val = str(att_value) - self.attributes[(namespace, att_name)] = val - self.sync() - - def get_attribute(self, att_name, namespace=""): - """Get the attribute C{att_name}. - - Returns C{None} if there is no attribute matching C{att_name}. - """ - return self.attributes.get((namespace, att_name), None) - - def sync(self): - if self.sync_disabled: - return self.is_loaded() - self._modified_update() - if self.is_loaded(): - # This is a liblarch call to the TreeNode ancestor - self.modified() - return True - return False - - def _modified_update(self): - """ - Updates the modified timestamp - """ - self.last_modified = datetime.now() - -# TAG FUNCTIONS ############################################################## - def get_tags_name(self): - # Return a copy of the list of tags. Not the original object. - return list(self.tags) - - # return a copy of the list of tag objects - def get_tags(self): - tags = [] - for tname in self.tags: - tag = self.req.get_tag(tname) - if not tag: - tag = self.req.new_tag(tname) - tags.append(tag) - return tags - - def rename_tag(self, old, new): - eold = saxutils.escape(saxutils.unescape(old)) - enew = saxutils.escape(saxutils.unescape(new)) - self.content = self.content.replace(eold, enew) - oldt = self.req.get_tag(old) - self.remove_tag(old) - oldt.modified() - self.tag_added(new) - self.req.get_tag(new).modified() - self.sync() - - def tag_added_by_id(self, tid): - """Add a tag by its ID""" - - tag = self.req.ds.get_tag_by_id(tid) - - if tag: - self.tag_added(tag.get_name()) - - def tag_added(self, tagname): - """ - Adds a tag. Does not add '@tag' to the contents. See add_tag - """ - if tagname not in self.tags: - self.tags.append(tagname) - if self.is_loaded(): - for child in self.get_subtasks(): - if child.can_be_deleted: - child.tag_added(tagname) - - tag = self.req.get_tag(tagname) - if not tag: - tag = self.req.new_tag(tagname) - tag.modified() - return True - - def add_tag(self, tagname): - "Add a tag to the task and insert '@tag' into the task's content" - - if self.tag_added(tagname): - c = self.content - tagname = html.escape(tagname) - tagname = '@' + tagname if not tagname.startswith('@') else tagname - - if not c: - # don't need a separator if it's the only text - sep = '' - elif c.startswith('@'): - # if content starts with a tag, make a comma-separated list - sep = ', ' - else: - # other text at the beginning, so put the tag on its own line - sep = '\n\n' - - self.content = f'{tagname}{sep}{c}' - # we modify the task internal state, thus we have to call for a - # sync - - self.sync() - - # remove by tagname - def remove_tag(self, tagname): - modified = False - if tagname in self.tags: - self.tags.remove(tagname) - modified = True - for child in self.get_subtasks(): - if child.can_be_deleted: - child.remove_tag(tagname) - self.content = self._strip_tag(self.content, tagname) - if modified: - tag = self.req.get_tag(tagname) - # The ViewCount of the tag still doesn't know that - # the task was removed. We need to update manually - if tag: - tag.update_task(self.get_id()) - tag.modified() - - def _strip_tag(self, text, tagname, newtag=''): - if tagname.startswith('@'): - inline_tag = tagname[1:] - else: - inline_tag = tagname - - return (text - .replace(f'@{tagname}\n\n', newtag) - .replace(f'@{tagname}\n', newtag) - .replace(f'@{tagname}, ', newtag) - .replace(f'@{tagname}', newtag) - .replace(f'{tagname}\n\n', newtag) - .replace(f'{tagname}, ', newtag) - .replace(f'{tagname},', inline_tag) - # don't forget a space a the end - .replace(f'{tagname}', inline_tag)) - - # tag_list is a list of tags names - # return true if at least one of the list is in the task - def has_tags(self, tag_list=None, notag_only=False): - # recursive function to explore the tags and its children - def children_tag(tagname): - toreturn = False - if tagname in self.tags: - toreturn = True - else: - tag = self.req.get_tag(tagname) - if tag: - for tagc_name in tag.get_children(): - if not toreturn: - toreturn = children_tag(tagc_name) - return toreturn - - # We want to see if the task has no tags - toreturn = False - if notag_only: - toreturn = self.tags == [] - # Here, the user ask for the "empty" tag - # And virtually every task has it. - elif tag_list == [] or tag_list is None: - toreturn = True - elif tag_list: - for tagname in tag_list: - if not toreturn: - toreturn = children_tag(tagname) - else: - # Well, if we don't filter on tags or notag, it's true, of course - toreturn = True - return toreturn - - def __str__(self): - return '' % ( - self.title, - self.tid, - self.status, - self.tags, - self.added_date, - self.recurring) - - __repr__ = __str__ - - -class DisabledSyncCtx: - """Context manager disabling GTG.core.task.Task.sync() - and firing one only sync on __exit__""" - - def __init__(self, task: Task, sync_on_exit: bool = True): - self.task = task - self.sync_on_exit = sync_on_exit - - def __enter__(self): - self.task.sync_disabled = True - return self.task - - def __exit__(self, *args, **kwargs): - self.task.sync_disabled = False - if self.sync_on_exit: - self.task.sync() diff --git a/GTG/core/tasks.py b/GTG/core/tasks.py new file mode 100644 index 0000000000..9446b8140d --- /dev/null +++ b/GTG/core/tasks.py @@ -0,0 +1,1033 @@ +# ----------------------------------------------------------------------------- +# Getting Things GNOME! - a personal organizer for the GNOME desktop +# Copyright (c) The GTG Team +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, see . +# ----------------------------------------------------------------------------- + +"""Everything related to tasks.""" + + +from gi.repository import GObject, Gio, Gtk, Gdk +from gettext import gettext as _ + +from uuid import uuid4, UUID +import logging +from typing import Callable, Any, Optional +from enum import Enum +import re +import datetime +from operator import attrgetter + +from lxml.etree import Element, SubElement, CDATA + +from GTG.core.base_store import BaseStore +from GTG.core.tags import Tag, TagStore +from GTG.core.dates import Date + +log = logging.getLogger(__name__) + + +# ------------------------------------------------------------------------------ +# REGEXES +# ------------------------------------------------------------------------------ + +TAG_REGEX = re.compile(r'^\B\@\w+(\-\w+)*\,*') +SUB_REGEX = re.compile(r'\{\!.+\!\}') + + +# ------------------------------------------------------------------------------ +# TASK STATUS +# ------------------------------------------------------------------------------ + +class Status(Enum): + """Status for a task.""" + + ACTIVE = 'Active' + DONE = 'Done' + DISMISSED = 'Dismissed' + + +class Filter(Enum): + """Types of filters.""" + + ACTIVE = 'Active' + ACTIONABLE = 'Actionable' + CLOSED = 'Closed' + STATUS = 'Status' + TAG = 'Tag' + PARENT = 'Parent' + CHILDREN = 'Children' + + +# ------------------------------------------------------------------------------ +# TASK +# ------------------------------------------------------------------------------ + +DEFAULT_TITLE = _('New Task') + + +class Task(GObject.Object): + """A single task.""" + + __gtype_name__ = 'gtg_Task' + + def __init__(self, id: UUID, title: str) -> None: + self.id = id + self.raw_title = title.strip('\t\n') + self.content = '' + self.tags = set() + self.children = [] + self.status = Status.ACTIVE + self.parent = None + + self._date_added = Date.no_date() + self._date_due = Date.no_date() + self._date_start = Date.no_date() + self._date_closed = Date.no_date() + self._date_modified = Date(datetime.datetime.now()) + + self._has_date_due = False + self._has_date_start = False + + self._date_due_str = '' + self._date_start_str = '' + self._is_active = True + + self._is_recurring = False + self.recurring_term = None + self.recurring_updated_date = datetime.datetime.now() + + self.attributes = {} + + self.duplicate_cb = NotImplemented + + super(Task, self).__init__() + + + @GObject.Property(type=bool, default=True) + def is_actionable(self) -> bool: + """Determine if this task is actionable.""" + + actionable_tags = all(t.actionable for t in self.tags) + active_children = all(t.status != Status.ACTIVE for t in self.children) + days_left = self._date_start.days_left() + can_start = True if not days_left else days_left <= 0 + + return (self.status == Status.ACTIVE + and self._date_due != Date.someday() + and actionable_tags + and active_children + and can_start) + + + def toggle_active(self, propagated: bool = False) -> None: + """Toggle between possible statuses.""" + + if self.status is Status.ACTIVE: + status = Status.DONE + + else: + status = Status.ACTIVE + + self.set_status(status, propagated) + + + def toggle_dismiss(self, propagated: bool = False) -> None: + """Set this task to be dismissed.""" + + if self.status is Status.ACTIVE: + status = Status.DISMISSED + + else: + status = Status.ACTIVE + + self.set_status(status, propagated) + + + def set_status(self, status: Status, propagated: bool = False) -> None: + """Set status for task.""" + + if self.status == Status.ACTIVE: + for t in self.tags: + t.task_count_open -= 1 + + if self.is_actionable: + for t in self.tags: + t.task_count_actionable -= 1 + + else: + for t in self.tags: + t.task_count_closed -= 1 + + self.status = status + self.is_active = (status == Status.ACTIVE) + + if status != Status.ACTIVE: + self.date_closed = Date.today() + + # If the task is recurring, it must be duplicate with + # another task id and the next occurence of the task + # while preserving child/parent relations. + # For a task to be duplicated, it must satisfy 3 rules. + # 1- It is recurring. + # 2- It has no parent or no recurring parent. + # 3- It was directly marked as done (not by propagation from its parent). + if (self._is_recurring and not propagated and + not self.is_parent_recurring()): + self.duplicate_cb(self) + + else: + self.date_closed = Date.no_date() + + if self.parent and self.parent.status is not Status.ACTIVE: + self.parent.set_status(status, propagated=True) + + if status == Status.ACTIVE: + for t in self.tags: + t.task_count_open += 1 + + if self.is_actionable: + for t in self.tags: + t.task_count_actionable += 1 + + else: + for t in self.tags: + t.task_count_closed += 1 + + + for child in self.children: + child.set_status(status, propagated=True) + + + @property + def date_due(self) -> Date: + return self._date_due + + + @date_due.setter + def date_due(self, value: Date) -> None: + self._date_due = value + self.has_date_due = bool(value) + + if value: + self.date_due_str = self._date_due.to_readable_string() + else: + self.date_due_str = '' + + for tag in self.tags: + if self.is_actionable: + tag.task_count_actionable += 1 + else: + tag.task_count_actionable -= 1 + + if not value or value.is_fuzzy(): + return + + for child in self.children: + if (child.date_due + and not child.date_due.is_fuzzy() + and child.date_due > value): + + child.date_due = value + + if (self.parent + and self.parent.date_due + and self.parent.date_due.is_fuzzy() + and self.parent.date_due < value): + self.parent.date_due = value + + + @property + def date_added(self) -> Date: + return self._date_added + + + @date_added.setter + def date_added(self, value: Any) -> None: + self._date_added = Date(value) + + + @property + def date_start(self) -> Date: + return self._date_start + + + @date_start.setter + def date_start(self, value: Any) -> None: + if isinstance(value, str): + self._date_start = Date.parse(value) + else: + self._date_start = Date(value) + + self.has_date_start = bool(value) + self.date_start_str = self._date_start.to_readable_string() + + + @property + def date_closed(self) -> Date: + return self._date_closed + + + @date_closed.setter + def date_closed(self, value: Any) -> None: + self._date_closed = Date(value) + + + @property + def date_modified(self) -> Date: + return self._date_modified + + + @date_modified.setter + def date_modified(self, value: Any) -> None: + self._date_modified = Date(value) + + + @GObject.Property(type=str) + def title(self) -> str: + return self.raw_title + + + @title.setter + def title(self, value) -> None: + self.raw_title = value.strip('\t\n') or _('(no title)') + + + @GObject.Property(type=str) + def excerpt(self) -> str: + if not self.content: + return '' + + # Strip tags + txt = TAG_REGEX.sub('', self.content) + + # Strip subtasks + txt = SUB_REGEX.sub('', txt) + + # Strip blank lines and set within char limit + return f'{txt.strip()[:50]}…' + + + def add_tag(self, tag: Tag) -> None: + """Add a tag to this task.""" + + if isinstance(tag, Tag): + if tag not in self.tags: + self.tags.add(tag) + + if self.status == Status.ACTIVE: + tag.task_count_open += 1 + else: + tag.task_count_closed += 1 + + if self.is_actionable: + tag.task_count_actionable += 1 + else: + raise ValueError + + + def remove_tag(self, tag_name: str) -> None: + """Remove a tag from this task.""" + + for t in self.tags.copy(): + if t.name == tag_name: + self.tags.remove(t) + + if self.status == Status.ACTIVE: + t.task_count_open -= 1 + else: + t.task_count_closed -= 1 + + if self.is_actionable: + t.task_count_actionable -= 1 + + self.content = (self.content.replace(f'{tag_name}\n\n', '') + .replace(f'{tag_name},', '') + .replace(f'{tag_name}', '')) + + + def rename_tag(self, old_tag_name: str, new_tag_name: str) -> None: + """Replace a tag's name in the content.""" + + self.content = (self.content.replace(f'@{old_tag_name}', + f'@{new_tag_name}')) + + + @property + def days_left(self) -> Optional[Date]: + return self.date_due.days_left() + + + def update_modified(self) -> None: + """Update the modified property.""" + + self._date_modified = Date(datetime.datetime.now()) + + + def set_recurring(self, recurring: bool, recurring_term: str = None, newtask=False): + """Sets a task as recurring or not, and its recurring term. + + Like anything related to dates, repeating tasks are subtle and complex + when creating a new task, the due date is calculated from either the + current date or the start date, while we get the next occurrence of a + task not from the current date but from the due date itself. + + However when we are retrieving the task from the XML files, we should + only set the the recurring_term. + + There are 4 cases to acknowledge when setting a task to recurring: + - if repeating but the term is invalid: it will be set to False. + - if repeating and the term is valid: we set it to True. + - if not repeating and the term is valid: we set the bool attr to True and set the term. + - if not repeating and the term is invalid: we set it to False and keep the previous term. + + Setting a task as recurrent implies that the children of a recurrent + task will be also set to recurrent and will inherit their parent's + recurring term + + Args: + recurring (bool): True if the task is recurring and False if not. + recurring_term (str, optional): the recurring period of a task (every Monday, day..). + Defaults to None. + newtask (bool, optional): if this is a new task, we must set the due_date. + Defaults to False. + """ + def is_valid_term(): + """ Verify if the term is valid and returns the appropriate Due date. + + Return a tuple of (bool, Date) + """ + if recurring_term is None: + return False, None + + try: + # If a start date is already set, + # we should calculate the next date from that day. + if self.date_start == Date.no_date(): + start_from = Date(datetime.datetime.now()) + else: + start_from = self.date_start + + newdate = start_from.parse_from_date(recurring_term, newtask) + + return True, newdate + + except ValueError: + return False, None + + self._is_recurring = recurring + + # We verifiy if the term passed is valid + valid, newdate = is_valid_term() + recurring_term = recurring_term if valid else None + + if self._is_recurring: + if not valid: + self.recurring_term = None + self._is_recurring = False + else: + self.recurring_term = recurring_term + self.recurring_updated_date = datetime.datetime.now() + + if newtask: + self.date_due = newdate + else: + if valid: + self.recurring_term = recurring_term + self.recurring_updated_date = datetime.datetime.now() + + # setting its children to recurrent + for child in self.children: + if child.status is Status.ACTIVE: + child.set_recurring(self._is_recurring, self.recurring_term) + + if self._is_recurring: + child.date_due = newdate + + self.notify('is_recurring') + + + def toggle_recurring(self): + """ Toggle a task's recurrency ON/OFF. Use this function to toggle, not set_recurring""" + + # If there is no recurring_term, We assume it to recur every day. + newtask = False + + if self.recurring_term is None: + self.recurring_term = 'day' + newtask = True + + self.set_recurring(not self._is_recurring, self.recurring_term, newtask) + + + def inherit_recursion(self): + """ Inherits the recurrent state of the parent. + If the task has a recurrent parent, it must be set to recur, itself. + """ + if self.parent and self.parent._is_recurring: + self.set_recurring(True, self.parent.recurring_term) + self.date_due = self.parent.date_due + else: + self._is_recurring = False + + + def is_parent_recurring(self): + """Determine if the parent task is recurring.""" + + return (self.parent and + self.parent.status == Status.ACTIVE + and self.parent._is_recurring) + + + def get_next_occurrence(self): + """Calcutate the next occurrence of a recurring task + + To know which is the correct next occurrence there are two rules: + - if the task was marked as done before or during the open period (before the duedate); + in this case, we need to deal with the issue of tasks that recur on the same date. + example: due_date is 09/09 and done_date is 09/09 + - if the task was marked after the due date, we need to figure out the next occurrence + after the current date(today). + + Raises: + ValueError: if the recurring_term is invalid + + Returns: + Date: the next due date of a task + """ + + today = datetime.date.today() + + if today <= self.date_due: + try: + nextdate = self.date_due.parse_from_date(self.recurring_term, newtask=False) + + while nextdate <= self.date_due: + nextdate = nextdate.parse_from_date(self.recurring_term, newtask=False) + + return nextdate + + except Exception: + raise ValueError(f'Invalid recurring term {self.recurring_term}') + + elif today > self.date_due: + try: + next_date = self.date_due.parse_from_date(self.recurring_term, newtask=False) + + while next_date < datetime.date.today(): + next_date = next_date.parse_from_date(self.recurring_term, newtask=False) + + return next_date + + except Exception: + raise ValueError(f'Invalid recurring term {self.recurring_term}') + + # ----------------------------------------------------------------------- + # Bind Properties + # + # Since PyGobject doesn't support bind_property_full() yet + # we can't do complex binds. These props below serve as a + # workaround so that we can use them with the regular + # bind_property(). + # ----------------------------------------------------------------------- + + @GObject.Property(type=bool, default=False) + def is_recurring(self) -> bool: + return self._is_recurring + + + @GObject.Property(type=bool, default=False) + def has_date_due(self) -> bool: + return self._has_date_due + + + @has_date_due.setter + def set_has_date_due(self, value) -> None: + self._has_date_due = value + + + @GObject.Property(type=bool, default=False) + def has_date_start(self) -> bool: + return self._has_date_start + + + @has_date_start.setter + def set_has_date_start(self, value) -> None: + self._has_date_start = value + + + @GObject.Property(type=str) + def date_start_str(self) -> str: + return self._date_start_str + + + @date_start_str.setter + def set_date_start_str(self, value) -> None: + self._date_start_str = value + + + @GObject.Property(type=str) + def date_due_str(self) -> str: + return self._date_due_str + + + @date_due_str.setter + def set_date_due_str(self, value) -> None: + self._date_due_str = value + + + @GObject.Property(type=bool, default=True) + def is_active(self) -> bool: + return self._is_active + + + @is_active.setter + def set_is_active(self, value) -> None: + self._is_active = value + + + @GObject.Property(type=bool, default=False) + def has_children(self) -> bool: + return bool(len(self.children)) + + + @GObject.Property(type=str) + def icons(self) -> str: + icons_text = '' + for t in self.tags: + if t.icon: + icons_text += t.icon + + return icons_text + + + @GObject.Property(type=str) + def row_css(self) -> str: + for tag in self.tags: + if tag.color: + color = Gdk.RGBA() + color.parse(tag.color) + color.alpha = 0.1 + return '* { background:' + color.to_string() + '; }' + + + @GObject.Property(type=str) + def tag_colors(self) -> str: + return ','.join(t.color for t in self.tags + if t.color and not t.icon) + + + @GObject.Property(type=bool, default=False) + def show_tag_colors(self) -> str: + return any(t.color and not t.icon for t in self.tags) + + + @property + def tag_names(self) -> list[str]: + return [ t.name for t in self.tags ] + + + def set_attribute(self, att_name, att_value, namespace="") -> None: + """Set an arbitrary attribute.""" + + val = str(att_value) + self.attributes[(namespace, att_name)] = val + + + def get_attribute(self, att_name, namespace="") -> str | None: + """Get an attribute.""" + + return self.attributes.get((namespace, att_name), None) + + + def __str__(self) -> str: + """String representation.""" + + return f'Task: {self.title} ({self.id})' + + + def __repr__(self) -> str: + """String representation.""" + + tags = ', '.join([t.name for t in self.tags]) + return (f'Task "{self.title}" with id "{self.id}".' + f'Status: {self.status}, tags: {tags}') + + + def __eq__(self, other) -> bool: + """Equivalence.""" + + return self.id == other.id + + + def __hash__(self) -> int: + """Hash (used for dicts and sets).""" + + return hash(self.id) + + +# ------------------------------------------------------------------------------ +# STORE +# ------------------------------------------------------------------------------ + +class TaskStore(BaseStore): + """A tree of tasks.""" + + __gtype_name__ = 'gtg_TaskStore' + + #: Tag to look for in XML + XML_TAG = 'task' + + def __init__(self) -> None: + super().__init__() + + self.model = Gio.ListStore.new(Task) + self.tree_model = Gtk.TreeListModel.new(self.model, False, False, self.model_expand) + + + def model_expand(self, item): + model = Gio.ListStore.new(Task) + + if type(item) == Gtk.TreeListRow: + item = item.get_item() + + # open the first one + if item.children: + for child in item.children: + model.append(child) + + return Gtk.TreeListModel.new(model, False, False, self.model_expand) + + + def __str__(self) -> str: + """String representation.""" + + return f'Task Store. Holds {len(self.lookup)} task(s)' + + + def get(self, tid: UUID) -> Task: + """Get a task by name.""" + + return self.lookup[tid] + + + def duplicate_for_recurrent(self, task: Task) -> Task: + """Duplicate a task for the next ocurrence.""" + + new_task = self.new(task.title) + new_task.tags = task.tags + new_task.content = task.content + new_task.date_added = task.date_added + new_task.date_due = task.get_next_occurrence() + + # Only goes through for the first task + if task.parent and task.parent.is_active: + self.parent(new_task.id, task.parent.id) + + for child in task.children: + new_child = self.duplicate_for_recurrent(child) + self.parent(new_child.id, new_task.id) + + log.debug("Duplicated task %s as task %s", task.id, new_task.id) + return new_task + + + def new(self, title: str = None, parent: UUID = None) -> Task: + """Create a new task and add it to the store.""" + + tid = uuid4() + title = title or DEFAULT_TITLE + task = Task(id=tid, title=title) + task.date_added = Date.now() + + if parent: + self.add(task, parent) + else: + self.add(task) + + return task + + + def from_xml(self, xml: Element, tag_store: TagStore) -> None: + """Load up tasks from a lxml object.""" + + elements = list(xml.iter(self.XML_TAG)) + + for element in elements: + tid = element.get('id') + title = element.find('title').text + status = element.get('status') + + task = Task(id=tid, title=title) + + dates = element.find('dates') + + modified = dates.find('modified').text + task.date_modified = Date(datetime.datetime.fromisoformat(modified)) + + added = dates.find('added').text + task.date_added = Date(datetime.datetime.fromisoformat(added)) + + if status == 'Done': + task.status = Status.DONE + elif status == 'Dismissed': + task.status = Status.DISMISSED + + # Dates + try: + closed = Date.parse(dates.find('done').text) + task.date_closed = closed + except AttributeError: + pass + + fuzzy_due_date = Date.parse(dates.findtext('fuzzyDue')) + due_date = Date.parse(dates.findtext('due')) + + if fuzzy_due_date: + task.date_due = fuzzy_due_date + elif due_date: + task.date_due = due_date + + fuzzy_start = dates.findtext('fuzzyStart') + start = dates.findtext('start') + + if fuzzy_start: + task.date_start = Date(fuzzy_start) + elif start: + task.date_start = Date(start) + + taglist = element.find('tags') + + if taglist is not None: + for t in taglist.iter('tag'): + try: + tag = tag_store.get(t.text) + task.add_tag(tag) + except KeyError: + pass + + # Content + content = element.find('content').text or '' + content = content.replace(']]>', ']]>') + task.content = content + + self.add(task) + + log.debug('Added %s', task) + + + # All tasks have been added, now we parent them + for element in elements: + parent_tid = element.get('id') + subtasks = element.find('subtasks') + + for sub in subtasks.findall('sub'): + self.parent(sub.text, parent_tid) + + + def to_xml(self) -> Element: + """Serialize the taskstore into a lxml element.""" + + root = Element('tasklist') + + for task in self.lookup.values(): + element = SubElement(root, self.XML_TAG) + element.set('id', str(task.id)) + element.set('status', task.status.value) + + title = SubElement(element, 'title') + title.text = task.title + + tags = SubElement(element, 'tags') + + for t in task.tags: + tag_tag = SubElement(tags, 'tag') + tag_tag.text = str(t.id) + + dates = SubElement(element, 'dates') + + added_date = SubElement(dates, 'added') + added_date.text = str(task.date_added) + + modified_date = SubElement(dates, 'modified') + modified_date.text = str(task.date_modified) + + if task.status == Status.DONE: + done_date = SubElement(dates, 'done') + done_date.text = str(task.date_closed) + + if task.date_due: + due = SubElement(dates, 'due') + due.text = str(task.date_due) + + if task.date_start: + start = SubElement(dates, 'start') + start.text = str(task.date_start) + + subtasks = SubElement(element, 'subtasks') + + for subtask in task.children: + sub = SubElement(subtasks, 'sub') + sub.text = str(subtask.id) + + content = SubElement(element, 'content') + text = task.content + + # Poor man's encoding. + # CDATA's only poison is this combination of characters. + text = text.replace(']]>', ']]>') + content.text = CDATA(text) + + return root + + def add(self, item: Any, parent_id: UUID = None) -> None: + """Add a task to the taskstore.""" + + super().add(item, parent_id) + + if not parent_id: + self.model.append(item) + + item.duplicate_cb = self.duplicate_for_recurrent + self.notify('task_count_all') + self.notify('task_count_no_tags') + + self.emit('added', item) + + + def remove(self, item_id: UUID) -> None: + """Remove an existing task.""" + + # Remove from UI + item = self.lookup[item_id] + pos = self.model.find(item) + self.model.remove(pos[1]) + + super().remove(item_id) + + self.notify('task_count_all') + self.notify('task_count_no_tags') + + + def parent(self, item_id: UUID, parent_id: UUID) -> None: + + super().parent(item_id, parent_id) + + # Remove from UI + item = self.lookup[item_id] + pos = self.model.find(item) + self.model.remove(pos[1]) + + + def unparent(self, item_id: UUID, parent_id: UUID) -> None: + + super().unparent(item_id, parent_id) + item = self.lookup[item_id] + parent = self.lookup[parent_id] + + self.model.append(item) + parent.notify('has_children') + + + def filter(self, filter_type: Filter, arg = None) -> list: + """Filter tasks according to a filter type.""" + + def filter_tag(tag: str) -> list: + """Filter tasks that only have a specific tag.""" + + output = [] + + for t in self.data: + tags = [_tag for _tag in t.tags] + + # Include the tag's children + for _tag in t.tags: + for child in _tag.children: + tags.append(child) + + if tag in tags: + output.append(t) + + return output + + + if filter_type == Filter.STATUS: + return [t for t in self.data if t.status == arg] + + elif filter_type == Filter.ACTIVE: + return [t for t in self.data if t.status == Status.ACTIVE] + + elif filter_type == Filter.CLOSED: + return [t for t in self.data if t.status != Status.ACTIVE] + + elif filter_type == Filter.ACTIONABLE: + return [t for t in self.data if t.is_actionable] + + elif filter_type == Filter.PARENT: + return [t for t in self.lookup.values() if not t.parent] + + elif filter_type == Filter.CHILDREN: + return [t for t in self.lookup.values() if t.parent] + + elif filter_type == Filter.TAG: + if type(arg) == list: + output = [] + + for t in arg: + if output: + output = list(set(output) & set(filter_tag(t))) + else: + output = filter_tag(t) + + + return output + + else: + return filter_tag(arg) + + + def filter_custom(self, key: str, condition: Callable) -> list: + """Filter tasks according to a function.""" + + return [t for t in self.lookup.values() if condition(getattr(t, key))] + + + def sort(self, tasks: list = None, + key: str = None, reverse: bool = False) -> None: + """Sort a list of tasks in-place.""" + + tasks = tasks or self.data + key = key or 'date_added' + + for t in tasks: + t.children.sort(key=attrgetter(key), reverse=reverse) + + tasks.sort(key=attrgetter(key), reverse=reverse) + + + @GObject.Property(type=str) + def task_count_all(self) -> str: + return str(len(self.lookup.keys())) + + + @GObject.Property(type=str) + def task_count_no_tags(self) -> str: + i = 0 + + for task in self.lookup.values(): + if not task.tags: + i += 1 + + return str(i) diff --git a/GTG/core/tasks2.py b/GTG/core/tasks2.py deleted file mode 100644 index 05e833b198..0000000000 --- a/GTG/core/tasks2.py +++ /dev/null @@ -1,567 +0,0 @@ -# ----------------------------------------------------------------------------- -# Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) The GTG Team -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program. If not, see . -# ----------------------------------------------------------------------------- - -"""Everything related to tasks.""" - - -from gi.repository import GObject -from gettext import gettext as _ - -from uuid import uuid4, UUID -import logging -from typing import Callable, Any, Optional -from enum import Enum -import re -import datetime -from operator import attrgetter - -from lxml.etree import Element, SubElement, CDATA - -from GTG.core.base_store import BaseStore -from GTG.core.tags2 import Tag2, TagStore -from GTG.core.dates import Date - -log = logging.getLogger(__name__) - - -# ------------------------------------------------------------------------------ -# REGEXES -# ------------------------------------------------------------------------------ - -TAG_REGEX = re.compile(r'^\B\@\w+(\-\w+)*\,+') -SUB_REGEX = re.compile(r'\{\!.+\!\}') - - -# ------------------------------------------------------------------------------ -# TASK STATUS -# ------------------------------------------------------------------------------ - -class Status(Enum): - """Status for a task.""" - - ACTIVE = 'Active' - DONE = 'Done' - DISMISSED = 'Dismissed' - - -class Filter(Enum): - """Types of filters.""" - - ACTIVE = 'Active' - ACTIONABLE = 'Actionable' - CLOSED = 'Closed' - STATUS = 'Status' - TAG = 'Tag' - PARENT = 'Parent' - CHILDREN = 'Children' - - -# ------------------------------------------------------------------------------ -# TASK -# ------------------------------------------------------------------------------ - -class Task2(GObject.Object): - """A single task.""" - - __gtype_name__ = 'gtg_Task' - __slots__ = ['id', 'raw_title', 'content', 'tags', - 'children', 'status', 'parent', '_date_added', - '_date_due', '_date_start', '_date_closed', - '_date_modified'] - - - def __init__(self, id: UUID, title: str) -> None: - self.id = id - self.raw_title = title.strip('\t\n') - self.content = '' - self.tags = [] - self.children = [] - self.status = Status.ACTIVE - self.parent = None - - self._date_added = Date.no_date() - self._date_due = Date.no_date() - self._date_start = Date.no_date() - self._date_closed = Date.no_date() - self._date_modified = Date(datetime.datetime.now()) - - - def is_actionable(self) -> bool: - """Determine if this task is actionable.""" - - actionable_tags = all(t.actionable for t in self.tags) - active_children = all(t.status != Status.ACTIVE for t in self.children) - days_left = self._date_start.days_left() - can_start = True if not days_left else days_left <= 0 - - return (self.status == Status.ACTIVE - and self._date_due != Date.someday() - and actionable_tags - and active_children - and can_start) - - - def toggle_active(self, propagate: bool = True) -> None: - """Toggle between possible statuses.""" - - if self.status is Status.ACTIVE: - self.status = Status.DONE - self.date_closed = Date.today() - - else: - self.status = Status.ACTIVE - self.date_closed = Date.no_date() - - if self.parent and self.parent.status is not Status.ACTIVE: - self.parent.toggle_active(propagate=False) - - if propagate: - for child in self.children: - child.toggle_active() - - - def toggle_dismiss(self, propagate: bool = True) -> None: - """Set this task to be dismissed.""" - - if self.status is Status.ACTIVE: - self.status = Status.DISMISSED - self.date_closed = Date.today() - - elif self.status is Status.DISMISSED: - self.status = Status.ACTIVE - self.date_closed = Date.no_date() - - if self.parent and self.parent.status is not Status.ACTIVE: - self.parent.toggle_dismiss(propagate=False) - - if propagate: - for child in self.children: - child.toggle_dismiss() - - - def set_status(self, status: Status) -> None: - """Set status for task.""" - - self.status = status - - for child in self.children: - child.set_status(status) - - - @property - def date_due(self) -> Date: - return self._date_due - - - @date_due.setter - def date_due(self, value: Date) -> None: - self._date_due = value - - if not value or value.is_fuzzy(): - return - - for child in self.children: - if (child.date_due - and not child.date_due.is_fuzzy() - and child.date_due > value): - - child.date_due = value - - if (self.parent - and self.parent.date_due - and self.parent.date_due.is_fuzzy() - and self.parent.date_due < value): - self.parent.date_due = value - - - @property - def date_added(self) -> Date: - return self._date_added - - - @date_added.setter - def date_added(self, value: Any) -> None: - self._date_added = Date(value) - - - @property - def date_start(self) -> Date: - return self._date_start - - - @date_start.setter - def date_start(self, value: Any) -> None: - self._date_start = Date(value) - - - @property - def date_closed(self) -> Date: - return self._date_closed - - - @date_closed.setter - def date_closed(self, value: Any) -> None: - self._date_closed = Date(value) - - - @property - def date_modified(self) -> Date: - return self._date_modified - - - @date_modified.setter - def date_modified(self, value: Any) -> None: - self._date_modified = Date(value) - - - @property - def title(self) -> str: - return self.raw_title - - - @title.setter - def title(self, value) -> None: - self.raw_title = value.strip('\t\n') or _('(no title)') - - - @property - def excerpt(self) -> str: - if not self.content: - return '' - - # Strip tags - txt = TAG_REGEX.sub('', self.content) - - # Strip subtasks - txt = SUB_REGEX.sub('', txt) - - # Strip blank lines and set within char limit - return f'{txt.strip()[:80]}…' - - - def add_tag(self, tag: Tag2) -> None: - """Add a tag to this task.""" - - if isinstance(tag, Tag2): - if tag not in self.tags: - self.tags.append(tag) - else: - raise ValueError - - - def remove_tag(self, tag_name: str) -> None: - """Remove a tag from this task.""" - - for t in self.tags: - if t.name == tag_name: - self.tags.remove(t) - (self.content.replace(f'{tag_name}\n\n', '') - .replace(f'{tag_name},', '') - .replace(f'{tag_name}', '')) - - - @property - def days_left(self) -> Optional[Date]: - return self.date_due.days_left() - - - def update_modified(self) -> None: - """Update the modified property.""" - - self._date_modified = Date(datetime.datetime.now()) - - - def __str__(self) -> str: - """String representation.""" - - return f'Task: {self.title} ({self.id})' - - - def __repr__(self) -> str: - """String representation.""" - - tags = ', '.join([t.name for t in self.tags]) - return (f'Task "{self.title}" with id "{self.id}".' - f'Status: {self.status}, tags: {tags}') - - - def __eq__(self, other) -> bool: - """Equivalence.""" - - return self.id == other.id - - - def __hash__(self) -> int: - """Hash (used for dicts and sets).""" - - return hash(self.id) - - -# ------------------------------------------------------------------------------ -# STORE -# ------------------------------------------------------------------------------ - -class TaskStore(BaseStore): - """A tree of tasks.""" - - __gtype_name__ = 'gtg_TaskStore' - - #: Tag to look for in XML - XML_TAG = 'task' - - def __init__(self) -> None: - super().__init__() - - - def __str__(self) -> str: - """String representation.""" - - return f'Task Store. Holds {len(self.lookup)} task(s)' - - - def get(self, tid: UUID) -> Task2: - """Get a task by name.""" - - return self.lookup[tid] - - - def new(self, title: str = None, parent: UUID = None) -> Task2: - """Create a new task and add it to the store.""" - - tid = uuid4() - title = title or _('New Task') - task = Task2(id=tid, title=title) - task.date_added = Date.now() - - if parent: - self.add(task, parent) - else: - self.data.append(task) - self.lookup[tid] = task - - self.emit('added', task) - return task - - - def from_xml(self, xml: Element, tag_store: TagStore) -> None: - """Load up tasks from a lxml object.""" - - elements = list(xml.iter(self.XML_TAG)) - - for element in elements: - tid = element.get('id') - title = element.find('title').text - status = element.get('status') - - task = Task2(id=tid, title=title) - - dates = element.find('dates') - - modified = dates.find('modified').text - task.date_modified = Date(datetime.datetime.fromisoformat(modified)) - - added = dates.find('added').text - task.date_added = Date(datetime.datetime.fromisoformat(added)) - - if status == 'Done': - task.status = Status.DONE - elif status == 'Dismissed': - task.status = Status.DISMISSED - - # Dates - try: - closed = Date.parse(dates.find('done').text) - task.date_closed = closed - except AttributeError: - pass - - fuzzy_due_date = Date.parse(dates.findtext('fuzzyDue')) - due_date = Date.parse(dates.findtext('due')) - - if fuzzy_due_date: - task._date_due = fuzzy_due_date - elif due_date: - task._date_due = due_date - - fuzzy_start = dates.findtext('fuzzyStart') - start = dates.findtext('start') - - if fuzzy_start: - task.date_start = Date(fuzzy_start) - elif start: - task.date_start = Date(start) - - taglist = element.find('tags') - - if taglist is not None: - for t in taglist.iter('tag'): - try: - tag = tag_store.get(t.text) - task.tags.append(tag) - except KeyError: - pass - - # Content - content = element.find('content').text or '' - content = content.replace(']]>', ']]>') - task.content = content - - self.add(task) - - log.debug('Added %s', task) - - - # All tasks have been added, now we parent them - for element in elements: - parent_tid = element.get('id') - subtasks = element.find('subtasks') - - for sub in subtasks.findall('sub'): - self.parent(sub.text, parent_tid) - - - def to_xml(self) -> Element: - """Serialize the taskstore into a lxml element.""" - - root = Element('tasklist') - - for task in self.lookup.values(): - element = SubElement(root, self.XML_TAG) - element.set('id', str(task.id)) - element.set('status', task.status.value) - - title = SubElement(element, 'title') - title.text = task.title - - tags = SubElement(element, 'tags') - - for t in task.tags: - tag_tag = SubElement(tags, 'tag') - tag_tag.text = str(t.id) - - dates = SubElement(element, 'dates') - - added_date = SubElement(dates, 'added') - added_date.text = str(task.date_added) - - modified_date = SubElement(dates, 'modified') - modified_date.text = str(task.date_modified) - - if task.status == Status.DONE: - done_date = SubElement(dates, 'done') - done_date.text = str(task.date_closed) - - if task.date_due: - due = SubElement(dates, 'due') - due.text = str(task.date_due) - - if task.date_start: - start = SubElement(dates, 'start') - start.text = str(task.date_start) - - subtasks = SubElement(element, 'subtasks') - - for subtask in task.children: - sub = SubElement(subtasks, 'sub') - sub.text = str(subtask.id) - - content = SubElement(element, 'content') - text = task.content - - # Poor man's encoding. - # CDATA's only poison is this combination of characters. - text = text.replace(']]>', ']]>') - content.text = CDATA(text) - - return root - - - def filter(self, filter_type: Filter, arg = None) -> list: - """Filter tasks according to a filter type.""" - - def filter_tag(tag: str) -> list: - """Filter tasks that only have a specific tag.""" - - output = [] - - for t in self.data: - tags = [_tag for _tag in t.tags] - - # Include the tag's children - for _tag in t.tags: - for child in _tag.children: - tags.append(child) - - if tag in tags: - output.append(t) - - return output - - - if filter_type == Filter.STATUS: - return [t for t in self.data if t.status == arg] - - elif filter_type == Filter.ACTIVE: - return [t for t in self.data if t.status == Status.ACTIVE] - - elif filter_type == Filter.CLOSED: - return [t for t in self.data if t.status != Status.ACTIVE] - - elif filter_type == Filter.ACTIONABLE: - return [t for t in self.data if t.is_actionable()] - - elif filter_type == Filter.PARENT: - return [t for t in self.lookup.values() if not t.parent] - - elif filter_type == Filter.CHILDREN: - return [t for t in self.lookup.values() if t.parent] - - elif filter_type == Filter.TAG: - if type(arg) == list: - output = [] - - for t in arg: - if output: - output = list(set(output) & set(filter_tag(t))) - else: - output = filter_tag(t) - - - return output - - else: - return filter_tag(arg) - - - def filter_custom(self, key: str, condition: Callable) -> list: - """Filter tasks according to a function.""" - - return [t for t in self.lookup.values() if condition(getattr(t, key))] - - - def sort(self, tasks: list = None, - key: str = None, reverse: bool = False) -> None: - """Sort a list of tasks in-place.""" - - tasks = tasks or self.data - key = key or 'date_added' - - for t in tasks: - t.children.sort(key=attrgetter(key), reverse=reverse) - - tasks.sort(key=attrgetter(key), reverse=reverse) diff --git a/GTG/core/treefactory.py b/GTG/core/treefactory.py deleted file mode 100644 index 7b7f935a0b..0000000000 --- a/GTG/core/treefactory.py +++ /dev/null @@ -1,234 +0,0 @@ -# ----------------------------------------------------------------------------- -# Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program. If not, see . -# ----------------------------------------------------------------------------- - -from datetime import datetime - -from GTG.core.search import search_filter -from GTG.core import tag -from GTG.core.task import Task -from gettext import gettext as _ -from GTG.core.dates import Date -from liblarch import Tree -from GTG.core.config import CoreConfig - -class TreeFactory(): - - def __init__(self): - # Keep the tree in memory jus in case we have to use it for filters. - self.tasktree = None - self.tagtree = None - - def get_tasks_tree(self): - """This create a liblarch tree suitable for tasks, - including default filters - For tags, filter are dynamically created at Tag insertion. - """ - tasktree = Tree() - f_dic = { - 'workview': [self.workview], - 'active': [self.active], - 'closed': [self.closed, {'flat': True}], - 'notag': [self.notag], - 'workable': [self.is_workable], - 'started': [self.is_started], - 'workdue': [self.workdue], - 'workstarted': [self.workstarted], - 'worktostart': [self.worktostart], - 'worklate': [self.worklate], - 'no_disabled_tag': [self.no_disabled_tag], - } - - for f in f_dic: - filt = f_dic[f] - if len(filt) > 1: - param = filt[1] - else: - param = None - tasktree.add_filter(f, filt[0], param) - self.tasktree = tasktree - return tasktree - - def get_tags_tree(self, req): - """This create a liblarch tree suitable for tags, - including the all_tags_tag and notag_tag. - """ - tagtree = Tree() - - # Build the "all tasks tag" - alltag = tag.Tag(tag.ALLTASKS_TAG, req=req) - alltag.set_attribute("special", "all") - alltag.set_attribute("label", "%s" - % _("All tasks")) - alltag.set_attribute("icon", "emblem-documents-symbolic") - alltag.set_attribute("order", 0) - tagtree.add_node(alltag) - p = {} - self.tasktree.add_filter(tag.ALLTASKS_TAG, - self.alltag, parameters=p) - # Build the "without tag tag" - notag_tag = tag.Tag(tag.NOTAG_TAG, req=req) - notag_tag.set_attribute("special", "notag") - notag_tag.set_attribute("label", "%s" - % _("Tasks with no tags")) - notag_tag.set_attribute("icon", "task-past-due-symbolic") - notag_tag.set_attribute("order", 2) - tagtree.add_node(notag_tag) - p = {} - self.tasktree.add_filter(tag.NOTAG_TAG, - self.notag, parameters=p) - - # Build the search tag - search_tag = tag.Tag(tag.SEARCH_TAG, req=req) - search_tag.set_attribute("special", "search") - search_tag.set_attribute("label", _("Saved searches")) - search_tag.set_attribute("icon", "system-search-symbolic") - search_tag.set_attribute("order", 1) - tagtree.add_node(search_tag) - p = {} - self.tasktree.add_filter(tag.SEARCH_TAG, - search_filter, parameters=p) - - # Build the separator - sep_tag = tag.Tag(tag.SEP_TAG, req=req) - sep_tag.set_attribute("special", "sep") - sep_tag.set_attribute("order", 3) - tagtree.add_node(sep_tag) - - # Filters - tagtree.add_filter('activetag', self.actively_used_tag) - tagtree.add_filter('usedtag', self.used_tag) - - activeview = tagtree.get_viewtree(name='activetags', refresh=False) - activeview.apply_filter('activetag') - - # This view doesn't seem to be used. So it's not useful to build it now -# usedview = tagtree.get_viewtree(name='usedtags',refresh=False) -# usedview.apply_filter('usedtag') - - self.tagtree = tagtree - self.tagtree_loaded = True - return tagtree - - # Tag Filters ########################################## - - # filter to display only tags with active tasks - def actively_used_tag(self, node, parameters=None): - toreturn = node.is_actively_used() - return toreturn - - def used_tag(self, node, parameters=None): - return node.is_used() - - # Task Filters ######################################### - # That one is used to filters tag. Is it built dynamically each times - # a tag is added to the tagstore - def tag_filter(self, node, parameters): - tag = parameters['tag'] - return node.has_tags([tag]) - - def alltag(self, task, parameters=None): - return True - - def notag(self, task, parameters=None): - """ Filter of tasks without tags """ - return task.has_tags(notag_only=True) - - def is_leaf(self, task, parameters=None): - """ Filter of tasks which have no children """ - return not task.has_child() - - def is_workable(self, task, parameters=None): - """ Filter of tasks that can be worked """ - tree = task.get_tree() - for child_id in task.get_children(): - if not tree.has_node(child_id): - continue - - child = tree.get_node(child_id) - if child.get_status() == Task.STA_ACTIVE: - return False - - return True - - def is_started(self, task, parameters=None): - """ Filter for tasks that are already started """ - days_left = task.get_start_date().days_left() - - if days_left is None: - # without startdate - return True - elif days_left == 0: - coreconfig = CoreConfig() - browser_subconfig = coreconfig.get_subconfig('browser') - hour_shift = browser_subconfig.get("hour") - if datetime.now().hour > int(hour_shift): - return True - if datetime.now().hour == int(hour_shift): - minute_shift = browser_subconfig.get("min") - return datetime.now().minute >= int(minute_shift) - else: - return False - else: - return days_left < 0 - - def workview(self, task, parameters=None): - wv = self.active(task) and \ - self.is_started(task) and \ - self.is_workable(task) and \ - self.no_disabled_tag(task) and \ - task.get_due_date() != Date.someday() - return wv - - def workdue(self, task): - """ Filter for tasks due within the next day """ - wv = self.workview(task) and task.get_due_date() and task.get_days_left() < 2 - return wv - - def worklate(self, task): - """ Filter for tasks due within the next day """ - wv = self.workview(task) and task.get_due_date() and task.get_days_late() > 0 - return wv - - def workstarted(self, task): - """ Filter for workable tasks with a start date specified """ - wv = self.workview(task) and \ - task.start_date - return wv - - def worktostart(self, task): - """ Filter for workable tasks without a start date specified """ - wv = self.workview(task) and \ - not task.start_date - return wv - - def active(self, task, parameters=None): - """ Filter of tasks which are active """ - return task.get_status() == Task.STA_ACTIVE - - def closed(self, task, parameters=None): - """ Filter of tasks which are closed """ - ret = task.get_status() in [Task.STA_DISMISSED, Task.STA_DONE] - return ret - - def no_disabled_tag(self, task, parameters=None): - """Filter of task that don't have any disabled/nonactionable tag""" - toreturn = True - for t in task.get_tags(): - if t.get_attribute("nonactionable") == "True": - toreturn = False - return toreturn diff --git a/GTG/core/versioning.py b/GTG/core/versioning.py index a7fdd31f71..e57a7cef81 100644 --- a/GTG/core/versioning.py +++ b/GTG/core/versioning.py @@ -26,7 +26,7 @@ from GTG.core.dates import Date from GTG.core.dirs import DATA_DIR from GTG.core import xml -from GTG.core import datastore +from uuid import uuid4, UUID from datetime import date from typing import Optional, Tuple @@ -39,7 +39,7 @@ tid_cache = {} -def convert(path: str, ds: datastore) -> et.ElementTree: +def convert(path: str) -> et.ElementTree: """Convert old XML into the new format.""" old_tree = xml.open_file(path, 'project') @@ -69,7 +69,7 @@ def convert(path: str, ds: datastore) -> et.ElementTree: tid_cache[tid] = new_tid for task in old_tree.iter('task'): - new_task = convert_task(task, ds) + new_task = convert_task(task) if new_task is not None: tasklist.append(new_task) @@ -137,18 +137,17 @@ def convert_tags(old_tree: et.Element) -> Tuple[et.Element, et.Element]: return taglist, searchlist -def convert_task(task: et.Element, ds: datastore) -> Optional[et.Element]: +def convert_task(task: et.Element) -> Optional[et.Element]: """Convert old task XML into the new format.""" - tid = task.attrib['id'] - real_task = ds.task_factory(tid) - if task is None: return + tid = task.attrib['id'] + # Get the old task properties # TIDs were stored as UUID, but sometimes they were not present - tid = task.get('uuid') or real_task.get_uuid() or tid_cache[tid] + tid = task.get('uuid') or uuid4() or tid_cache[tid] status = task.get('status') title = task.find('title').text content = task.find('content') diff --git a/GTG/core/xml.py b/GTG/core/xml.py deleted file mode 100644 index 15731b3109..0000000000 --- a/GTG/core/xml.py +++ /dev/null @@ -1,353 +0,0 @@ -# ----------------------------------------------------------------------------- -# Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program. If not, see . -# ----------------------------------------------------------------------------- - -import os -import shutil -import logging -from datetime import datetime -from GTG.core.dates import Date - -from lxml import etree - -log = logging.getLogger(__name__) -# Total amount of backups -BACKUPS = 7 - -# Information on whether a backup was used -backup_used = {} - - -def task_from_element(task, element: etree.Element): - """Populate task from XML element.""" - - task.set_title(element.find('title').text) - task.set_uuid(element.get('id')) - task.set_status(element.attrib['status'], init=True) # Done date set later - - # Recurring tasks - recurring = element.find('recurring') - recurring_enabled = recurring.get('enabled') - - recurring_term = recurring.findtext('term') - - if recurring_term: - task.set_recurring(recurring_enabled == 'true', - None if recurring_term == 'None' else recurring_term) - - try: - recurring_updated_date = recurring.find('updated_date').text - if recurring_updated_date: - task.set_recurring_updated_date(Date(recurring_updated_date)) - except AttributeError: - pass - - taglist = element.find('tags') - - - if taglist is not None: - [task.tag_added_by_id(t.text) for t in taglist.iter('tag')] - - # Content - content = element.find('content').text or '' - - content = content.replace(']]>', ']]>') - task.set_text(content) - - # Subtasks - subtasks = element.find('subtasks') - - for sub in subtasks.findall('sub'): - task.add_child(sub.text) - - # Retrieving all dates - dates = element.find('dates') - done_date = None - - # supporting old ways of salvaging fuzzy dates - for key, get_date, set_date in ( - ('fuzzyDue', task.get_due_date, task.set_due_date), - ('fuzzyStart', task.get_start_date, task.set_start_date)): - if not get_date() and dates.find(key) is not None \ - and dates.find(key).text: - set_date(Date(dates.find(key).text)) - - # modified date from file needs to be set as last action to survive - for key, set_date in (('added', task.set_added_date), - ('due', task.set_due_date), - ('done', task.set_closed_date), - ('start', task.set_start_date), - ('modified', task.set_modified),): - value = dates.find(key) - if value is not None and value.text: - set_date(Date(value.text)) - - return task - - -def task_to_element(task) -> etree.Element: - """Serialize task into XML Element.""" - - element = etree.Element('task') - - element.set('id', task.get_id()) - element.set('status', task.get_status()) - element.set('uuid', task.get_uuid()) - element.set('recurring', str(task.get_recurring())) - - tags = etree.SubElement(element, 'tags') - - for t in task.get_tags(): - tag_tag = etree.SubElement(tags, 'tag') - tag_tag.text = str(t.tid) - - title = etree.SubElement(element, 'title') - title.text = task.get_title() - - dates = etree.SubElement(element, 'dates') - - for key, get_date in (('added', task.get_added_date), - ('modified', task.get_modified), - ('done', task.get_closed_date), - ('due', task.get_due_date), - ('start', task.get_start_date)): - value = get_date() - if value: - etree.SubElement(dates, key).text = str(value) - - recurring = etree.SubElement(element, 'recurring') - recurring.set('enabled', str(task.recurring).lower()) - - recurring_term = etree.SubElement(recurring, 'term') - recurring_term.text = str(task.get_recurring_term()) - - recurring_updated_date_elem = etree.SubElement(recurring, 'updated_date') - recurring_updated_date = task.get_recurring_updated_date() - - if recurring_updated_date: - recurring_updated_date_elem.text = str(recurring_updated_date) - else: - recurring_updated_date_elem.text = '9999-12-30' - - subtasks = etree.SubElement(element, 'subtasks') - - for subtask_id in task.get_children(): - sub = etree.SubElement(subtasks, 'sub') - sub.text = subtask_id - - content = etree.SubElement(element, 'content') - text = task.get_text() - - # Poor man's encoding. - # CDATA's only poison is this combination of characters. - text = text.replace(']]>', ']]>') - - content.text = etree.CDATA(text) - - return element - - -def get_file_mtime(filepath: str) -> str: - """Get date from file.""" - - timestamp = os.path.getmtime(filepath) - return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') - - -def get_backup_name(filepath: str, i: int) -> str: - """Get name of backups which are backup/ directory.""" - - dirname, filename = os.path.split(filepath) - backup_file = f"{filename}.bak.{i}" if i else filename - - return os.path.join(dirname, 'backup', backup_file) - - -def get_xml_tree(filepath: str) -> etree.ElementTree: - """Parse XML file at filepath and get tree.""" - - parser = etree.XMLParser(remove_blank_text=True, strip_cdata=False) - - with open(filepath, 'rb') as stream: - tree = etree.parse(stream, parser=parser) - - return tree - - -def open_file(xml_path: str, root_tag: str) -> etree.ElementTree: - """Open an XML file in a robust way - - If file could not be opened, try: - - file__ - - file.bak.0 - - file.bak.1 - - .... until BACKUP_NBR - - If file doesn't exist, create a new file.""" - - global backup_used - - files = [ - xml_path, # Main file - xml_path + '__', # Temp file - ] - - # Add backup files - files += [get_backup_name(xml_path, i) for i in range(BACKUPS)] - - root = None - backup_used = None - - for index, filepath in enumerate(files): - try: - log.debug('Opening file %s', filepath) - root = get_xml_tree(filepath) - - # This was a backup. We should inform the user - if index > 0: - backup_used = { - 'name': filepath, - 'time': get_file_mtime(filepath) - } - - # We could open a file, let's stop this loop - break - - except FileNotFoundError: - log.debug('File not found: %r. Trying next.', filepath) - continue - - except PermissionError: - log.debug('Not allowed to open: %r. Trying next.', filepath) - continue - - except etree.XMLSyntaxError as error: - log.debug('Syntax error in %r. %r. Trying next.', filepath, error) - continue - - if root: - return root - - # We couldn't open any file :( - else: - # Try making a new empty file and open it - try: - - write_empty_file(xml_path, root_tag) - return open_file(xml_path, root_tag) - - except IOError: - raise SystemError(f'Could not write a file at {xml_path}') - - -def write_backups(filepath: str) -> None: - """Make backups for the file at filepath.""" - - current_back = BACKUPS - backup_name = get_backup_name(filepath, None) - backup_dir = os.path.dirname(backup_name) - - # Make sure backup dir exists - try: - os.makedirs(backup_dir, exist_ok=True) - - except IOError: - log.error('Backup dir %r cannot be created!', backup_dir) - return - - # Cycle backups - while current_back > 0: - older = f"{backup_name}.bak.{current_back}" - newer = f"{backup_name}.bak.{current_back - 1}" - - if os.path.exists(newer): - shutil.move(newer, older) - - current_back -= 1 - - # bak.0 is always a fresh copy of the closed file - # so that it's not touched in case of not opening next time - bak_0 = f"{backup_name}.bak.0" - shutil.copy(filepath, bak_0) - - # Add daily backup - today = datetime.today().strftime('%Y-%m-%d') - daily_backup = f'{backup_name}.{today}.bak' - - if not os.path.exists(daily_backup): - shutil.copy(filepath, daily_backup) - - -def write_xml(filepath: str, tree: etree.ElementTree) -> None: - """Write an XML file.""" - - with open(filepath, 'wb') as stream: - tree.write(stream, xml_declaration=True, - pretty_print=True, - encoding='UTF-8') - - -def create_dirs(filepath: str) -> None: - """Create directory tree for filepath.""" - - base_dir = os.path.dirname(filepath) - try: - os.makedirs(base_dir, exist_ok=True) - except IOError as error: - log.error("Error while creating directories: %r", error) - - -def save_file(filepath: str, root: etree.ElementTree) -> None: - """Save an XML file.""" - - temp_file = filepath + '__' - - if os.path.exists(filepath): - os.rename(filepath, temp_file) - - try: - write_xml(filepath, root) - - if os.path.exists(temp_file): - os.remove(temp_file) - - except (IOError, FileNotFoundError): - log.error('Could not write XML file at %r', filepath) - create_dirs(filepath) - - -def write_empty_file(filepath: str, root_tag: str) -> None: - """Write an empty tasks file.""" - - root = etree.Element(root_tag) - save_file(filepath, etree.ElementTree(root)) - - -def skeleton() -> etree.Element: - """Generate root XML tag and basic subtags.""" - - root = etree.Element('gtgData') - # Bump this on each new GTG release, no matter what: - root.set('appVersion', '0.6') - # Bump this when there are known file format changes: - root.set('xmlVersion', '2') - - etree.SubElement(root, 'taglist') - etree.SubElement(root, 'searchlist') - etree.SubElement(root, 'tasklist') - - return root diff --git a/GTG/gtg.in b/GTG/gtg.in index 78ef9739fd..5c23fa486b 100755 --- a/GTG/gtg.in +++ b/GTG/gtg.in @@ -71,9 +71,23 @@ if __name__ == "__main__": pass import gi -gi.require_version('Gdk', '3.0') -gi.require_version('Gtk', '3.0') -gi.require_version('GtkSource', '4') +gi.require_version('Gdk', '4.0') +gi.require_version('Gtk', '4.0') +gi.require_version('GtkSource', '5') + + +# Monkey patch Gtk.CssProvider.load_from_data for backwards compatibility. +# GTK 4.10 introduced an api change that requires a fix in PyGObject: +# https://gitlab.gnome.org/GNOME/pygobject/-/merge_requests/231 +from gi.repository import Gtk +if (Gtk.get_major_version(), Gtk.get_minor_version()) >= (4, 9): + def decorate(original_func): + def compat(self, data: bytes): + original_func(self, data.decode(), -1) + return compat + + Gtk.CssProvider.load_from_data = decorate(Gtk.CssProvider.load_from_data) + from gi.repository import GLib diff --git a/GTG/gtk/application.py b/GTG/gtk/application.py index 13c42e936e..015d60572f 100644 --- a/GTG/gtk/application.py +++ b/GTG/gtk/application.py @@ -27,7 +27,8 @@ from GTG.core.dirs import DATA_DIR -from GTG.core.datastore2 import Datastore2 +from GTG.core.datastore import Datastore +from GTG.core.tasks import Filter from GTG.gtk.browser.delete_task import DeletionUI from GTG.gtk.browser.main_window import MainWindow @@ -36,14 +37,15 @@ from GTG.gtk.preferences import Preferences from GTG.gtk.plugins import PluginsDialog from GTG.core import clipboard +from GTG.core.config import CoreConfig from GTG.core.plugins.engine import PluginEngine from GTG.core.plugins.api import PluginAPI from GTG.backends import BackendFactory -from GTG.core.datastore import DataStore from GTG.core.dirs import CSS_DIR from GTG.core.dates import Date from GTG.gtk.backends import BackendsDialog from GTG.gtk.browser.tag_editor import TagEditor +from GTG.gtk.browser.search_editor import SearchEditor from GTG.core.timer import Timer from GTG.gtk.errorhandler import do_error_dialog @@ -52,12 +54,9 @@ class Application(Gtk.Application): - ds: Datastore2 = Datastore2() + ds: Datastore = Datastore() """Datastore loaded with the default data file""" - # Requester - req = None - # List of opened tasks (task editor windows). Task IDs are keys, # while the editors are their values. open_tasks = {} @@ -115,30 +114,20 @@ def do_startup(self): data_file = os.path.join(DATA_DIR, 'gtg_data.xml') self.ds.find_and_load_file(data_file) - # TODO: Remove this once the new core is stable - self.ds.data_path = os.path.join(DATA_DIR, 'gtg_data2.xml') - - # Register backends - datastore = DataStore() - for backend_dic in BackendFactory().get_saved_backends_list(): - datastore.register_backend(backend_dic) - - # Save the backends directly to be sure projects.xml is written - datastore.save(quit=False) + self.ds.register_backend(backend_dic) - self.req = datastore.get_requester() + self.config_core = CoreConfig() + self.config = self.config_core.get_subconfig('browser') + self.config_plugins = self.config_core.get_subconfig('plugins') - self.config = self.req.get_config("browser") - self.config_plugins = self.req.get_config("plugins") - - self.clipboard = clipboard.TaskClipboard(self.req) + self.clipboard = clipboard.TaskClipboard(self.ds) self.timer = Timer(self.config) self.timer.connect('refresh', self.autoclean) - self.preferences_dialog = Preferences(self.req, self) - self.plugins_dialog = PluginsDialog(self.req) + self.preferences_dialog = Preferences(self) + self.plugins_dialog = PluginsDialog(self.config_plugins) if self.config.get('dark_mode'): self.toggle_darkmode() @@ -161,6 +150,7 @@ def do_activate(self): try: self.init_shared() self.browser.present() + self.browser.restore_editor_windows() log.debug("Application activation finished") except Exception as e: @@ -246,16 +236,16 @@ def init_shared(self): def init_browser(self): # Browser (still hidden) if not self.browser: - self.browser = MainWindow(self.req, self) + self.browser = MainWindow(self) if self.props.application_id == 'org.gnome.GTGDevel': - self.browser.get_style_context().add_class('devel') + self.browser.add_css_class('devel') def init_plugin_engine(self): """Setup the plugin engine.""" self.plugin_engine = PluginEngine() - plugin_api = PluginAPI(self.req, self) + plugin_api = PluginAPI(self) self.plugin_engine.register_api(plugin_api) try: @@ -271,27 +261,26 @@ def init_plugin_engine(self): def init_style(self): """Load the application's CSS file.""" - screen = Gdk.Screen.get_default() + display = Gdk.Display.get_default() provider = Gtk.CssProvider() - add_provider = Gtk.StyleContext.add_provider_for_screen + add_provider = Gtk.StyleContext.add_provider_for_display css_path = os.path.join(CSS_DIR, 'style.css') provider.load_from_path(css_path) - add_provider(screen, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + add_provider(display, provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) def toggle_darkmode(self, state=True): """Use dark mode theme.""" settings = Gtk.Settings.get_default() - prefs_css = self.preferences_dialog.window.get_style_context() settings.set_property("gtk-application-prefer-dark-theme", state) # Toggle dark mode for preferences and editors if state: - prefs_css.add_class('dark') + self.preferences_dialog.add_css_class('dark') text_tags.use_dark_mode() else: - prefs_css.remove_class('dark') + self.preferences_dialog.remove_css_class('dark') text_tags.use_light_mode() def init_actions(self): @@ -314,8 +303,7 @@ def init_actions(self): ('close', self.close_context, ('app.close', ['Escape'])), ('editor.close', self.close_focused_task, ('app.editor.close', ['w'])), ('editor.show_parent', self.open_parent_task, None), - ('editor.delete', self.delete_editor_task, None), - ('editor.open_tags_popup', self.open_tags_popup_in_editor, None), + ('editor.delete', self.delete_editor_task, None) ] for action, callback, accel in action_entries: @@ -328,7 +316,7 @@ def init_actions(self): if accel is not None: self.set_accels_for_action(*accel) - self.plugins_dialog.dialog.insert_action_group('app', self) + self.plugins_dialog.insert_action_group('app', self) # -------------------------------------------------------------------------- # ACTIONS @@ -350,13 +338,8 @@ def new_subtask(self, param, action): def add_parent(self, param, action): """Callback to add a parent to a task""" - try: - if self.browser.have_same_parent(): - self.browser.on_add_parent() + self.browser.on_add_parent() - # When no task has been selected - except IndexError: - return def edit_task(self, param, action): """Callback to edit a task.""" @@ -365,10 +348,13 @@ def edit_task(self, param, action): def mark_as_done(self, param, action): """Callback to mark a task as done.""" + try: self.get_active_editor().change_status() except AttributeError: self.browser.on_mark_as_done() + finally: + self.browser.get_pane().refresh() def dismiss(self, param, action): """Callback to mark a task as done.""" @@ -377,6 +363,8 @@ def dismiss(self, param, action): self.get_active_editor().toggle_dismiss() except AttributeError: self.browser.on_dismiss_task() + finally: + self.browser.get_pane().refresh() def reopen(self, param, action): """Callback to mark task as open.""" @@ -385,6 +373,8 @@ def reopen(self, param, action): self.get_active_editor().reopen() except AttributeError: self.browser.on_reopen_task() + finally: + self.browser.get_pane().refresh() def open_help(self, action, param): """Open help callback.""" @@ -403,7 +393,7 @@ def open_preferences(self, action, param): """Callback to open the preferences dialog.""" self.preferences_dialog.activate() - self.preferences_dialog.window.set_transient_for(self.browser) + self.preferences_dialog.set_transient_for(self.browser) def open_about(self, action, param): """Callback to open the about dialog.""" @@ -414,7 +404,7 @@ def open_plugins_manager(self, action, params): """Callback to open the plugins manager dialog.""" self.plugins_dialog.activate() - self.plugins_dialog.dialog.set_transient_for(self.browser) + self.plugins_dialog.set_transient_for(self.browser) def close_context(self, action, params): @@ -424,7 +414,7 @@ def close_context(self, action, params): search = self.browser.search_entry.is_focus() if editor: - self.close_task(editor.task.get_id()) + self.close_task(editor.task.id) elif search: self.browser.toggle_search(action, params) @@ -434,7 +424,7 @@ def close_focused_task(self, action, params): editor = self.get_active_editor() if editor: - self.close_task(editor.task.get_id()) + self.close_task(editor.task.id) def delete_editor_task(self, action, params): """Callback to delete the task currently open.""" @@ -442,16 +432,10 @@ def delete_editor_task(self, action, params): editor = self.get_active_editor() task = editor.task - if task.is_new(): - self.close_task(task.get_id()) + if editor.is_new(): + self.close_task(task) else: - self.delete_tasks([task.get_id()], editor.window) - - def open_tags_popup_in_editor(self, action, params): - """Callback to open the tags popup in the focused task editor.""" - - editor = self.get_active_editor() - editor.open_tags_popover() + self.delete_tasks([task], editor) def open_parent_task(self, action, params): """Callback to open the parent of the currently open task.""" @@ -470,17 +454,14 @@ def purge_old_tasks(self, widget=None): today = Date.today() max_days = self.config.get('autoclean_days') - closed_tree = self.req.get_tasks_tree(name='inactive') - - closed_tasks = [self.req.get_task(tid) for tid in - closed_tree.get_all_nodes()] - + closed_tasks = self.ds.tasks.filter(Filter.CLOSED) + to_remove = [t for t in closed_tasks - if (today - t.get_closed_date()).days > max_days] + if (today - t.date_closed).days > max_days] + + for t in to_remove: + self.ds.tasks.remove(t.id) - [self.req.delete_task(task.get_id()) - for task in to_remove - if self.req.has_task(task.get_id())] def autoclean(self, timer): """Run Automatic cleanup of old tasks.""" @@ -495,7 +476,7 @@ def autoclean(self, timer): def open_edit_backends(self, sender=None, backend_id=None): """Open the backends dialog.""" - self.backends_dialog = BackendsDialog(self.req) + self.backends_dialog = BackendsDialog(self.ds) self.backends_dialog.dialog.insert_action_group('app', self) self.backends_dialog.activate() @@ -503,24 +484,38 @@ def open_edit_backends(self, sender=None, backend_id=None): if backend_id: self.backends_dialog.show_config_for_backend(backend_id) - def delete_tasks(self, tids, window): + def delete_tasks(self, tasks, window): """Present the delete task confirmation dialog.""" if not self.delete_task_dialog: - self.delete_task_dialog = DeletionUI(self.req, window) + self.delete_task_dialog = DeletionUI(window, self.ds) - tasks_to_delete = self.delete_task_dialog.show(tids) + def on_show_async_callback(tasks_to_delete): + [self.close_task(task.id) for task in tasks_to_delete + if task.id in self.open_tasks] - [self.close_task(task.get_id()) for task in tasks_to_delete - if task.get_id() in self.open_tasks] + self.delete_task_dialog.show_async(tasks, on_show_async_callback) def open_tag_editor(self, tag): """Open Tag editor dialog.""" - self.edit_tag_dialog = TagEditor(self.req, self, tag) + self.edit_tag_dialog = TagEditor(self, tag) self.edit_tag_dialog.set_transient_for(self.browser) self.edit_tag_dialog.insert_action_group('app', self) + + def open_search_editor(self, search): + """Open Saved search editor dialog.""" + + self.edit_search_dialog = SearchEditor(self, search) + self.edit_search_dialog.set_transient_for(self.browser) + self.edit_search_dialog.insert_action_group('app', self) + + def close_search_editor(self): + """Close search editor dialog.""" + + self.edit_search_dialog = None + def close_tag_editor(self): """Close tag editor dialog.""" @@ -548,46 +543,39 @@ def reload_opened_editors(self, task_uid_list=None): else: [task.reload_editor() for task in self.open_tasks] - def open_task(self, uid, new=False): + def open_task(self, task, new=False): """Open the task identified by 'uid'. If a Task editor is already opened for a given task, we present it. Otherwise, we create a new one. """ - - if uid in self.open_tasks: - editor = self.open_tasks[uid] + + if task.id in self.open_tasks: + editor = self.open_tasks[task.id] editor.present() else: - task = self.req.get_task(uid) - editor = None - - if task: - editor = TaskEditor(requester=self.req, app=self, task=task, - thisisnew=new, clipboard=self.clipboard) - - editor.present() - self.open_tasks[uid] = editor + editor = TaskEditor(app=self, task=task) + editor.present() - # Save open tasks to config - open_tasks = self.config.get("opened_tasks") + self.open_tasks[task.id] = editor - if uid not in open_tasks: - open_tasks.append(uid) + # Save open tasks to config + config_open_tasks = self.config.get("opened_tasks") - self.config.set("opened_tasks", open_tasks) + if task.id not in config_open_tasks: + config_open_tasks.append(task.id) - else: - log.error('Task %s could not be found!', uid) + self.config.set("opened_tasks", config_open_tasks) return editor + def get_active_editor(self): """Get focused task editor window.""" for editor in self.open_tasks.values(): - if editor.window.is_active(): + if editor.is_active(): return editor def close_task(self, tid): @@ -657,10 +645,6 @@ def do_shutdown(self): self.save_plugin_settings() self.ds.save() - if self.req is not None: - # Save data and shutdown datastore backends - self.req.save_datastore(quit=True) - Gtk.Application.do_shutdown(self) # -------------------------------------------------------------------------- diff --git a/GTG/gtk/backends/__init__.py b/GTG/gtk/backends/__init__.py index 5b57090e1c..4f5832338f 100644 --- a/GTG/gtk/backends/__init__.py +++ b/GTG/gtk/backends/__init__.py @@ -49,12 +49,11 @@ class BackendsDialog(): - the backend adding view """ - def __init__(self, req): + def __init__(self, ds): """ Initializes the gtk objects and signals. - @param req: a Requester object """ - self.req = req + self.ds = ds # Declare subsequently loaded widget self.dialog = None self.treeview_window = None @@ -71,7 +70,7 @@ def __init__(self, req): self.dialog.set_title(dialog_title.format(name=info.NAME)) self._create_widgets_for_add_panel() self._create_widgets_for_conf_panel() - self._setup_signal_connections(builder) + self._setup_signal_connections() self._create_widgets_for_treeview() ######################################## @@ -79,7 +78,6 @@ def __init__(self, req): ######################################## def activate(self): """Shows this window, refreshing the current view""" - self.dialog.show_all() self.backends_tv.refresh() self.backends_tv.select_backend() self.dialog.present() @@ -92,33 +90,12 @@ def on_close(self, widget, data=None): @param data: same as widget, disregard the content """ self.dialog.hide() - self.req.save_datastore() + self.ds.save() return True ######################################## # HELPER FUNCTIONS ##################### ######################################## - def get_requester(self): - """ - Helper function: returns the requester. - It's used by the "views" displayed by this class (backend editing and - adding views) to access the requester - """ - return self.req - - def get_pixbuf_from_icon_name(self, name, height): - """ - Helper function: returns a pixbuf of an icon given its name in the - loaded icon theme - - @param name: the name of the icon - @param height: the height of the returned pixbuf - @param width: the width of the returned pixbuf - - @returns GdkPixbuf: a pixbuf containing the wanted icon, or None - (if the icon is not present) - """ - return Gtk.IconTheme.get_default().load_icon(name, height, 0) def _show_panel(self, panel_name): """ @@ -139,12 +116,11 @@ def _show_panel(self, panel_name): log.error("panel name unknown %r", panel_name) return # Central pane - # NOTE: self.central_pane is the Gtk.Container in which we load panels + # NOTE: self.central_pane is the Gtk.Viewport in which we load panels if panel_to_remove in self.central_pane: - self.central_pane.remove(panel_to_remove) + self.central_pane.set_child(None) if panel_to_add not in self.central_pane: - self.central_pane.add(panel_to_add) - self.central_pane.show_all() + self.central_pane.set_child(panel_to_add) # Side treeview # disabled if we're adding a new backend try: @@ -176,17 +152,12 @@ def _load_widgets_from_builder(self, builder): for attr, widget in widgets.items(): setattr(self, attr, builder.get_object(widget)) - def _setup_signal_connections(self, builder): + def _setup_signal_connections(self): """ Creates some GTK signals connections - - @param builder: a Gtk.Builder """ - signals = { - 'on_add_button_clicked': self.on_add_button, - 'on_remove_button_clicked': self.on_remove_button, - } - builder.connect_signals(signals) + self.add_button.connect("clicked", self.on_add_button) + self.remove_button.connect("clicked", self.on_remove_button) def _create_widgets_for_treeview(self): """ @@ -194,11 +165,11 @@ def _create_widgets_for_treeview(self): backends the user has added """ self.backends_tv = BackendsTree(self) - self.treeview_window.add(self.backends_tv) + self.treeview_window.set_child(self.backends_tv) def _create_widgets_for_conf_panel(self): """simply creates the panel to configure backends""" - self.config_panel = ConfigurePanel(self) + self.config_panel = ConfigurePanel(self, self.ds) def _create_widgets_for_add_panel(self): """simply creates the panel to add backends""" @@ -217,7 +188,7 @@ def on_backend_selected(self, backend_id): if backend_id: self._show_panel("configuration") self.config_panel.set_backend(backend_id) - backend = self.req.get_backend(backend_id) + backend = self.ds.get_backend(backend_id) self.remove_button.set_sensitive(not backend.is_default()) def on_add_button(self, widget=None, data=None): @@ -242,7 +213,7 @@ def on_backend_added(self, backend_name): backend_dic = BackendFactory().get_new_backend_dict(backend_name) if backend_dic: backend_dic[GenericBackend.KEY_ENABLED] = False - self.req.register_backend(backend_dic) + self.ds.register_backend(backend_dic) # Restore UI self._show_panel("configuration") @@ -263,18 +234,22 @@ def on_remove_button(self, widget=None, data=None): if backend_id is None: # no backend selected return - backend = self.req.get_backend(backend_id) + backend = self.ds.get_backend(backend_id) dialog = Gtk.MessageDialog( - parent=self.dialog, - flags=Gtk.DialogFlags.DESTROY_WITH_PARENT, - type=Gtk.MessageType.QUESTION, + transient_for=self.dialog, + modal=True, + destroy_with_parent=True, + message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO, - message_format=_("Do you really want to remove the '%s' " + text=_("Do you really want to remove the '%s' " "synchronization service?") % backend.get_human_name()) - response = dialog.run() - dialog.destroy() + dialog.connect("response", self.on_remove_response, backend_id) + dialog.present() + + def on_remove_response(self, dialog, response, backend_id): if response == Gtk.ResponseType.YES: # delete the backend and remove it from the lateral treeview - self.req.remove_backend(backend_id) + self.ds.remove_backend(backend_id) self.backends_tv.remove_backend(backend_id) + dialog.destroy() diff --git a/GTG/gtk/backends/addpanel.py b/GTG/gtk/backends/addpanel.py index b6b27320ff..434ce679a8 100644 --- a/GTG/gtk/backends/addpanel.py +++ b/GTG/gtk/backends/addpanel.py @@ -39,6 +39,7 @@ def __init__(self, backends): loaded """ super().__init__(orientation=Gtk.Orientation.VERTICAL) + self.set_spacing(24) self.dialog = backends self._create_widgets() @@ -51,14 +52,17 @@ def _create_widgets(self): top = Gtk.Box() top.set_spacing(6) middle = Gtk.Box() + middle.set_valign(Gtk.Align.CENTER) + middle.set_vexpand(True) bottom = Gtk.Box() + bottom.set_valign(Gtk.Align.END) + bottom.set_vexpand(True) self._fill_top_box(top) self._fill_middle_box(middle) self._fill_bottom_box(bottom) - self.pack_start(top, False, True, 0) - self.pack_start(middle, True, True, 0) - self.pack_start(bottom, True, True, 0) - self.set_border_width(12) + self.append(top) + self.append(middle) + self.append(bottom) def _fill_top_box(self, box): """ @@ -68,14 +72,15 @@ def _fill_top_box(self, box): @param box: the Gtk.Box to fill """ label = Gtk.Label(label=_("Select synchronization service:")) - label.set_alignment(0, 0.5) + label.set_xalign(0) + label.set_yalign(0.5) self.combo_types = BackendsCombo(self.dialog) # FIXME # self.combo_types.get_child().connect( # 'changed', self.on_combo_changed) self.combo_types.connect('changed', self.on_combo_changed) - box.pack_start(label, False, True, 0) - box.pack_start(self.combo_types, False, True, 0) + box.append(label) + box.append(self.combo_types) def _fill_middle_box(self, box): """ @@ -84,34 +89,30 @@ def _fill_middle_box(self, box): @param box: the Gtk.Box to fill """ - self.label_name = Gtk.Label(label="name") - self.label_name.set_alignment(xalign=0.5, yalign=1) + self.label_name = Gtk.Label() self.label_description = Gtk.Label() - self.label_description.set_justify(Gtk.Justification.FILL) - self.label_description.set_line_wrap(True) - self.label_description.set_size_request(300, -1) - self.label_description.set_alignment(xalign=0, yalign=0.5) - self.label_author = Gtk.Label(label="") - self.label_author.set_line_wrap(True) - self.label_author.set_alignment(xalign=0, yalign=0) - self.label_modules = Gtk.Label(label="") - self.label_modules.set_line_wrap(True) - self.label_modules.set_alignment(xalign=0, yalign=0) + self.label_description.set_xalign(0) + self.label_description.set_wrap(True) + self.label_author = Gtk.Label() + self.label_modules = Gtk.Label() self.image_icon = Gtk.Image() - self.image_icon.set_size_request(128, 128) - align_image = Gtk.Alignment.new(1, 0, 0, 0) - align_image.add(self.image_icon) + self.image_icon.set_hexpand(True) + self.image_icon.set_halign(Gtk.Align.END) + self.image_icon.set_pixel_size(128) labels_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - labels_vbox.pack_start(self.label_description, True, True, 10) - labels_vbox.pack_start(self.label_author, True, True, 0) - labels_vbox.pack_start(self.label_modules, True, True, 0) + labels_vbox.set_spacing(6) + labels_vbox.append(self.label_description) + labels_vbox.append(self.label_author) + labels_vbox.append(self.label_modules) low_box = Gtk.Box() - low_box.pack_start(labels_vbox, True, True, 0) - low_box.pack_start(align_image, True, True, 0) + low_box.set_vexpand(True) + low_box.append(labels_vbox) + low_box.append(self.image_icon) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - vbox.pack_start(self.label_name, True, True, 0) - vbox.pack_start(low_box, True, True, 0) - box.pack_start(vbox, True, True, 0) + vbox.set_spacing(12) + vbox.append(self.label_name) + vbox.append(low_box) + box.append(vbox) def _fill_bottom_box(self, box): """ @@ -125,16 +126,11 @@ def _fill_bottom_box(self, box): cancel_button.connect('clicked', self.on_cancel) self.ok_button = Gtk.Button() self.ok_button.set_label(_("OK")) + self.ok_button.set_hexpand(True) + self.ok_button.set_halign(Gtk.Align.END) self.ok_button.connect('clicked', self.on_confirm) - align = Gtk.Alignment.new(0.5, 1, 1, 0) - align.set_padding(0, 10, 0, 0) - buttonbox = Gtk.ButtonBox() - buttonbox.set_layout(Gtk.ButtonBoxStyle.EDGE) - buttonbox.add(cancel_button) - buttonbox.set_child_secondary(cancel_button, False) - buttonbox.add(self.ok_button) - align.add(buttonbox) - box.pack_start(align, True, True, 0) + box.append(cancel_button) + box.append(self.ok_button) def refresh_backends(self): """Populates the combo box containing the available backends""" @@ -182,6 +178,4 @@ def on_combo_changed(self, widget=None): (ngettext("Author", "Authors", len(authors)), reduce(lambda a, b: a + "\n" + " - " + b, authors)) self.label_author.set_markup(author_txt) - pixbuf = self.dialog.get_pixbuf_from_icon_name(backend.Backend.get_icon(), 128) - self.image_icon.set_from_pixbuf(pixbuf) - self.show_all() + self.image_icon.set_from_icon_name(backend.Backend.get_icon()) diff --git a/GTG/gtk/backends/backendscombo.py b/GTG/gtk/backends/backendscombo.py index 9f6360d0cc..2536d162a9 100644 --- a/GTG/gtk/backends/backendscombo.py +++ b/GTG/gtk/backends/backendscombo.py @@ -17,7 +17,6 @@ # ----------------------------------------------------------------------------- from gi.repository import Gtk -from gi.repository import GdkPixbuf from GTG.backends import BackendFactory @@ -45,11 +44,10 @@ def __init__(self, backends): self._liststore_init() self._renderers_init() self.set_size_request(-1, 30) - self.show_all() def _liststore_init(self): """Setup the Gtk.ListStore""" - self.liststore = Gtk.ListStore(str, str, GdkPixbuf.Pixbuf) + self.liststore = Gtk.ListStore(str, str, str) self.set_model(self.liststore) def _renderers_init(self): @@ -61,7 +59,7 @@ def _renderers_init(self): # Icon renderer pixbuf_cell = Gtk.CellRendererPixbuf() self.pack_start(pixbuf_cell, False) - self.add_attribute(pixbuf_cell, "pixbuf", self.COLUMN_ICON) + self.add_attribute(pixbuf_cell, "icon-name", self.COLUMN_ICON) def refresh(self): """ @@ -78,10 +76,9 @@ def refresh(self): # See LP bug #940917 (Izidor) if name == "backend_localfile": continue - pixbuf = self.dialog.get_pixbuf_from_icon_name(module.Backend.get_icon(), 16) self.liststore.append((name, module.Backend.get_human_default_name(), - pixbuf)) + module.Backend.get_icon())) if backend_types: # triggers a "changed" signal, which is used in the AddPanel to # refresh the backend description and icon diff --git a/GTG/gtk/backends/backendstree.py b/GTG/gtk/backends/backendstree.py index 1827cb38d6..6c98cc3825 100644 --- a/GTG/gtk/backends/backendstree.py +++ b/GTG/gtk/backends/backendstree.py @@ -17,12 +17,11 @@ # ----------------------------------------------------------------------------- from gi.repository import Gtk -from gi.repository import GdkPixbuf -from GTG.core.tag import ALLTASKS_TAG from GTG.gtk.colors import get_colored_tags_markup, rgba_to_hex from GTG.backends.backend_signals import BackendSignals +ALLTASKS_TAG = 'gtg-tags-all' class BackendsTree(Gtk.TreeView): """ @@ -33,6 +32,7 @@ class BackendsTree(Gtk.TreeView): COLUMN_ICON = 1 COLUMN_TEXT = 2 # holds the backend "human-readable" name COLUMN_TAGS = 3 + COLUMN_ENABLED = 4 def __init__(self, backendsdialog): """ @@ -43,7 +43,7 @@ def __init__(self, backendsdialog): """ super().__init__() self.dialog = backendsdialog - self.req = backendsdialog.get_requester() + self.ds = backendsdialog.ds self._init_liststore() self._init_renderers() self._init_signals() @@ -57,7 +57,7 @@ def refresh(self): # Sort backends # 1, put default backend on top # 2, sort backends by human name - backends = list(self.req.get_all_backends(disabled=True)) + backends = list(self.ds.get_all_backends(disabled=True)) backends = sorted(backends, key=lambda backend: (not backend.is_default(), backend.get_human_name())) @@ -75,7 +75,7 @@ def on_backend_added(self, sender, backend_id): @param backend_id: the id of the backend to add """ # Add - backend = self.req.get_backend(backend_id) + backend = self.ds.get_backend(backend_id) if not backend: return self.add_backend(backend) @@ -94,10 +94,10 @@ def add_backend(self, backend): if backend: backend_iter = self.liststore.append([ backend.get_id(), - self.dialog.get_pixbuf_from_icon_name(backend.get_icon(), - 16), + backend.get_icon(), backend.get_human_name(), self._get_markup_for_tags(backend.get_attached_tags()), + backend.is_enabled() ]) self.backendid_to_iter[backend.get_id()] = backend_iter @@ -112,19 +112,10 @@ def on_backend_state_changed(self, sender, backend_id): if backend_id in self.backendid_to_iter: b_iter = self.backendid_to_iter[backend_id] b_path = self.liststore.get_path(b_iter) - backend = self.req.get_backend(backend_id) + backend = self.ds.get_backend(backend_id) backend_name = backend.get_human_name() - if backend.is_enabled(): - text = backend_name - else: - # FIXME This snippet is on more than 2 places!!! - # FIXME create a function which takes a widget and - # flag and returns color as #RRGGBB - style_context = self.get_style_context() - color = style_context.get_color(Gtk.StateFlags.INSENSITIVE) - color = rgba_to_hex(color) - text = f"{backend_name}" - self.liststore[b_path][self.COLUMN_TEXT] = text + self.liststore[b_path][self.COLUMN_TEXT] = backend_name + self.liststore[b_path][self.COLUMN_ENABLED] = backend.is_enabled() # Also refresh the tags new_tags = self._get_markup_for_tags(backend.get_attached_tags()) @@ -140,7 +131,7 @@ def _get_markup_for_tags(self, tag_names): if ALLTASKS_TAG in tag_names: tags_txt = "" else: - tags_txt = get_colored_tags_markup(self.req, tag_names) + tags_txt = get_colored_tags_markup(self.ds, tag_names) return "" + tags_txt + "" def remove_backend(self, backend_id): @@ -156,7 +147,7 @@ def remove_backend(self, backend_id): def _init_liststore(self): """Creates the liststore""" - self.liststore = Gtk.ListStore(object, GdkPixbuf.Pixbuf, str, str) + self.liststore = Gtk.ListStore(object, str, str, str, bool) self.set_model(self.liststore) def _init_renderers(self): @@ -166,12 +157,13 @@ def _init_renderers(self): # For the backend icon pixbuf_cell = Gtk.CellRendererPixbuf() tvcolumn_pixbuf = Gtk.TreeViewColumn('Icon', pixbuf_cell) - tvcolumn_pixbuf.add_attribute(pixbuf_cell, 'pixbuf', self.COLUMN_ICON) + tvcolumn_pixbuf.add_attribute(pixbuf_cell, 'icon-name', self.COLUMN_ICON) self.append_column(tvcolumn_pixbuf) # For the backend name text_cell = Gtk.CellRendererText() tvcolumn_text = Gtk.TreeViewColumn('Name', text_cell) tvcolumn_text.add_attribute(text_cell, 'markup', self.COLUMN_TEXT) + tvcolumn_text.add_attribute(text_cell, 'sensitive', self.COLUMN_ENABLED) self.append_column(tvcolumn_text) text_cell.connect('edited', self.cell_edited_callback) text_cell.set_property('editable', True) diff --git a/GTG/gtk/backends/configurepanel.py b/GTG/gtk/backends/configurepanel.py index e6a67b5939..0030fc0bb1 100644 --- a/GTG/gtk/backends/configurepanel.py +++ b/GTG/gtk/backends/configurepanel.py @@ -28,7 +28,7 @@ class ConfigurePanel(Gtk.Box): A vertical Box that lets you configure a backend """ - def __init__(self, backends): + def __init__(self, backends, ds): """ Constructor, creating all the gtk widgets @@ -40,7 +40,7 @@ def __init__(self, backends): self.should_spinner_be_shown = False self.task_deleted_handle = None self.task_added_handle = None - self.req = backends.get_requester() + self.ds = ds self._create_widgets() self._connect_signals() @@ -64,13 +64,12 @@ def _create_widgets(self): middle.set_spacing(6) self._fill_top_box(top) self._fill_middle_box(middle) - self.pack_start(top, False, True, 0) - self.pack_start(middle, False, True, 0) - align = Gtk.Alignment.new(0, 0, 1, 0) - align.set_padding(10, 0, 0, 0) - self.parameters_ui = ParametersUI(self.req) - align.add(self.parameters_ui) - self.pack_start(align, False, True, 0) + self.append(top) + self.append(middle) + self.parameters_ui = ParametersUI() + self.parameters_ui.ds = self.ds + self.parameters_ui.set_margin_top(10) + self.append(self.parameters_ui) def _fill_top_box(self, box): """ Fill header with service's icon, name, and a spinner @@ -81,7 +80,9 @@ def _fill_top_box(self, box): self.image_icon.set_size_request(48, 48) self.human_name_label = Gtk.Label() - self.human_name_label.set_alignment(xalign=0, yalign=0.5) + self.human_name_label.set_hexpand(True) + self.human_name_label.set_xalign(0) + self.human_name_label.set_yalign(0.5) # FIXME in the newer versions of GTK3 there always be Spinner! try: @@ -91,12 +92,12 @@ def _fill_top_box(self, box): self.spinner = Gtk.Box() self.spinner.connect("show", self.on_spinner_show) self.spinner.set_size_request(32, 32) - align_spin = Gtk.Alignment.new(1, 0, 0, 0) - align_spin.add(self.spinner) + self.spinner.set_margin_top(1) - box.pack_start(self.image_icon, False, True, 0) - box.pack_start(self.human_name_label, True, True, 0) - box.pack_start(align_spin, False, True, 0) + box.set_spacing(10) + box.append(self.image_icon) + box.append(self.human_name_label) + box.append(self.spinner) def _fill_middle_box(self, box): """ @@ -105,23 +106,26 @@ def _fill_middle_box(self, box): @param box: the Gtk.Box to fill """ self.sync_status_label = Gtk.Label() - self.sync_status_label.set_alignment(xalign=0.8, yalign=0.5) + self.sync_status_label.set_hexpand(True) + self.sync_status_label.set_xalign(0.8) + self.sync_status_label.set_yalign(0.5) self.sync_button = Gtk.Button() + self.sync_button.set_hexpand(True) self.sync_button.connect("clicked", self.on_sync_button_clicked) - box.pack_start(self.sync_status_label, True, True, 0) - box.pack_start(self.sync_button, True, True, 0) + box.append(self.sync_status_label) + box.append(self.sync_button) def set_backend(self, backend_id): """Changes the backend to configure, refreshing this view. @param backend_id: the id of the backend to configure """ - self.backend = self.dialog.get_requester().get_backend(backend_id) + self.backend = self.dialog.ds.get_backend(backend_id) self.refresh_title() self.refresh_sync_status() self.parameters_ui.refresh(self.backend) - self.image_icon.set_from_pixbuf(self.dialog.get_pixbuf_from_icon_name( - self.backend.get_icon(), 48)) + self.image_icon.set_pixel_size(48) + self.image_icon.set_from_icon_name(self.backend.get_icon()) def refresh_title(self, sender=None, data=None): """ @@ -176,7 +180,7 @@ def on_sync_button_clicked(self, sender): @param sender: not used, here only for signal callback compatibility """ self.parameters_ui.commit_changes() - self.req.set_backend_enabled(self.backend.get_id(), + self.ds.set_backend_enabled(self.backend.get_id(), not self.backend.is_enabled()) def on_sync_started(self, sender, backend_id): @@ -224,7 +228,6 @@ def spinner_set_active(self, active): if active: if isinstance(self.spinner, Gtk.Spinner): self.spinner.start() - self.spinner.show() else: self.spinner.hide() if isinstance(self.spinner, Gtk.Spinner): diff --git a/GTG/gtk/backends/parameters_ui/__init__.py b/GTG/gtk/backends/parameters_ui/__init__.py index 1c28094592..58339113a8 100644 --- a/GTG/gtk/backends/parameters_ui/__init__.py +++ b/GTG/gtk/backends/parameters_ui/__init__.py @@ -46,15 +46,19 @@ class ParametersUI(Gtk.Box): COMMON_WIDTH = 170 - def __init__(self, requester): + def __init__(self): """Constructs the list of the possible widgets. @param requester: a GTG.core.requester.Requester object """ super().__init__(orientation=Gtk.Orientation.VERTICAL) - self.req = requester self.set_spacing(10) + # Keep track of our children, for some reason iterating through + # them the regular way to remove them just doesn't always work. + # And this is the recommended way anyway. + self.displayed_params = [] + # builds a list of widget generators. More precisely, it's a # list of tuples: (backend_parameter_name, widget_generator) self.parameter_widgets = ( @@ -116,7 +120,7 @@ def UI_generator(self, param_type, special_arguments={}): @return function: return a widget generator, not a widget. the widget can be obtained by calling widget_generator(backend) """ - return lambda backend: param_type(req=self.req, + return lambda backend: param_type(ds=self.ds, backend=backend, width=self.COMMON_WIDTH, **special_arguments) @@ -128,9 +132,9 @@ def refresh(self, backend): @param backend: the backend that is being configured """ # remove the old parameters UIs - def _remove_child(self, child, data=None): - self.remove(child) - self.foreach(functools.partial(_remove_child, self), None) + for p_widget in self.displayed_params: + self.remove(p_widget) + self.displayed_params.clear() # add new widgets backend_parameters = backend.get_parameters() if backend_parameters[GenericBackend.KEY_DEFAULT_BACKEND]: @@ -139,15 +143,14 @@ def _remove_child(self, child, data=None): for parameter_name, widget in self.parameter_widgets: if parameter_name in backend_parameters: # FIXME I am not 100% about this change - self.pack_start(widget(backend), True, True, 0) - self.show_all() + p_widget = widget(backend) + self.append(p_widget) + self.displayed_params.append(p_widget) def commit_changes(self): """ Saves all the parameters at their current state (the user may have modified them) """ - - def _commit_changes(child, data=None): - child.commit_changes() - self.foreach(_commit_changes, None) + for p_widget in self.displayed_params: + p_widget.commit_changes() diff --git a/GTG/gtk/backends/parameters_ui/checkbox.py b/GTG/gtk/backends/parameters_ui/checkbox.py index 08f8241c0b..16a315b6b1 100644 --- a/GTG/gtk/backends/parameters_ui/checkbox.py +++ b/GTG/gtk/backends/parameters_ui/checkbox.py @@ -25,11 +25,11 @@ class CheckBoxUI(Gtk.Box): meaning """ - def __init__(self, req, backend, width, text, parameter): + def __init__(self, ds, backend, width, text, parameter): """ Creates the checkbox and the related label. - @param req: a Requester + @param ds: Datastore @param backend: a backend object @param width: the width of the gtk.Label object @param parameter: the backend parameter this checkbox should display @@ -37,7 +37,7 @@ def __init__(self, req, backend, width, text, parameter): """ super().__init__() self.backend = backend - self.req = req + self.ds = ds self.text = text self.parameter = parameter self._populate_gtk(width) @@ -51,7 +51,7 @@ def _populate_gtk(self, width): backend_parameters = self.backend.get_parameters()[self.parameter] self.checkbutton.set_active(backend_parameters) self.checkbutton.connect("toggled", self.on_modified) - self.pack_start(self.checkbutton, False, True, 0) + self.append(self.checkbutton) def commit_changes(self): """Saves the changes to the backend parameter""" @@ -66,4 +66,4 @@ def on_modified(self, sender=None): @param sender: not used, only here for signal compatibility """ if self.backend.is_enabled() and not self.backend.is_default(): - self.req.set_backend_enabled(self.backend.get_id(), False) + self.ds.set_backend_enabled(self.backend.get_id(), False) diff --git a/GTG/gtk/backends/parameters_ui/import_tags.py b/GTG/gtk/backends/parameters_ui/import_tags.py index 9459bcdc9a..5c317a4b16 100644 --- a/GTG/gtk/backends/parameters_ui/import_tags.py +++ b/GTG/gtk/backends/parameters_ui/import_tags.py @@ -19,7 +19,8 @@ from gi.repository import Gtk -from GTG.core.tag import ALLTASKS_TAG + +ALLTASKS_TAG = 'gtg-tags-all' class ImportTagsUI(Gtk.Box): @@ -28,11 +29,11 @@ class ImportTagsUI(Gtk.Box): to let the user change the attached tags (or imported) """ - def __init__(self, req, backend, width, title, anybox_text, somebox_text, + def __init__(self, ds, backend, width, title, anybox_text, somebox_text, parameter_name): """Populates the widgets and refresh the tags to display - @param req: a requester + @param ds: a requester @param backend: the backend to configure @param width: the length of the radio buttons @param title: the text for the label describing what this collection @@ -44,7 +45,7 @@ def __init__(self, req, backend, width, title, anybox_text, somebox_text, """ super().__init__(orientation=Gtk.Orientation.VERTICAL) self.backend = backend - self.req = req + self.ds = ds self.title = title self.anybox_text = anybox_text self.somebox_text = somebox_text @@ -60,25 +61,25 @@ def _populate_gtk(self, width): @param width: the length of the radio buttons """ title_label = Gtk.Label() - title_label.set_alignment(xalign=0, yalign=0) + title_label.set_xalign(0) + title_label.set_yalign(0) title_label.set_markup(f"{self.title}") - self.pack_start(title_label, True, True, 0) - align = Gtk.Alignment.new(0, 0, 1, 0) - align.set_padding(0, 0, 10, 0) - self.pack_start(align, True, True, 0) + self.append(title_label) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - align.add(vbox) - self.all_tags_radio = Gtk.RadioButton(group=None, + self.append(vbox) + self.all_tags_radio = Gtk.CheckButton(group=None, label=self.anybox_text) - vbox.pack_start(self.all_tags_radio, True, True, 0) - self.some_tags_radio = Gtk.RadioButton(group=self.all_tags_radio, + vbox.append(self.all_tags_radio) + self.some_tags_radio = Gtk.CheckButton(group=self.all_tags_radio, label=self.somebox_text) self.some_tags_radio.set_size_request(width=width, height=-1) box = Gtk.Box() - vbox.pack_start(box, True, True, 0) - box.pack_start(self.some_tags_radio, False, True, 0) + box.set_spacing(10) + vbox.append(box) + box.append(self.some_tags_radio) self.tags_entry = Gtk.Entry() - box.pack_start(self.tags_entry, True, True, 0) + self.tags_entry.set_hexpand(True) + box.append(self.tags_entry) def on_changed(self, radio, data=None): """ Signal callback, executed when the user modifies something. @@ -89,7 +90,7 @@ def on_changed(self, radio, data=None): @param data: not used, only here for signal compatibility """ # every change in the config disables the backend - self.req.set_backend_enabled(self.backend.get_id(), False) + self.ds.set_backend_enabled(self.backend.get_id(), False) self._refresh_textbox_state() def commit_changes(self): diff --git a/GTG/gtk/backends/parameters_ui/password.py b/GTG/gtk/backends/parameters_ui/password.py index bf981ec23c..eef1fd2c08 100644 --- a/GTG/gtk/backends/parameters_ui/password.py +++ b/GTG/gtk/backends/parameters_ui/password.py @@ -24,17 +24,17 @@ class PasswordUI(Gtk.Box): """Widget displaying a gtk.Label and a textbox to input a password""" - def __init__(self, req, backend, width): + def __init__(self, ds, backend, width): """Creates the gtk widgets and loads the current password in the text field - @param req: a Requester + @param ds: a Requester @param backend: a backend object @param width: the width of the Gtk.Label object """ super().__init__() self.backend = backend - self.req = req + self.ds = ds self._populate_gtk(width) self._load_password() self._connect_signals() @@ -44,15 +44,15 @@ def _populate_gtk(self, width): @param width: the width of the Gtk.Label object """ + self.set_spacing(10) password_label = Gtk.Label(label=_("Password:")) - password_label.set_alignment(xalign=0, yalign=0) + password_label.set_yalign(0) + password_label.set_xalign(0) password_label.set_size_request(width=width, height=-1) - self.pack_start(password_label, False, True, 0) - align = Gtk.Alignment.new(0, 0.5, 1, 0) - align.set_padding(0, 0, 10, 0) - self.pack_start(align, True, True, 0) + self.append(password_label) self.password_textbox = Gtk.Entry() - align.add(self.password_textbox) + self.password_textbox.set_hexpand(True) + self.append(self.password_textbox) def _load_password(self): """Loads the password from the backend""" @@ -78,4 +78,4 @@ def on_password_modified(self, sender): @param sender: not used, only here for signal compatibility """ if self.backend.is_enabled() and not self.backend.is_default(): - self.req.set_backend_enabled(self.backend.get_id(), False) + self.ds.set_backend_enabled(self.backend.get_id(), False) diff --git a/GTG/gtk/backends/parameters_ui/path.py b/GTG/gtk/backends/parameters_ui/path.py index 81c94aa01f..2db077b469 100644 --- a/GTG/gtk/backends/parameters_ui/path.py +++ b/GTG/gtk/backends/parameters_ui/path.py @@ -28,17 +28,17 @@ class PathUI(Gtk.Box): filesystem explorer to modify that path (also, a label to describe those) """ - def __init__(self, req, backend, width): + def __init__(self, ds, backend, width): """ Creates the textbox, the button and loads the current path. - @param req: a Requester + @param ds: Datastore @param backend: a backend object @param width: the width of the Gtk.Label object """ super().__init__() self.backend = backend - self.req = req + self.ds = ds self._populate_gtk(width) def _populate_gtk(self, width): @@ -47,21 +47,20 @@ def _populate_gtk(self, width): @param width: the width of the Gtk.Label object """ label = Gtk.Label(label=_("Filename:")) - label.set_line_wrap(True) - label.set_alignment(xalign=0, yalign=0.5) + label.set_wrap(True) + label.set_xalign(0) + label.set_yalign(0.5) label.set_size_request(width=width, height=-1) - self.pack_start(label, False, True, 0) - align = Gtk.Alignment.new(0, 0.5, 1, 0) - align.set_padding(0, 0, 10, 0) - self.pack_start(align, True, True, 0) + self.append(label) self.textbox = Gtk.Entry() + self.textbox.set_hexpand(True) self.textbox.set_text(self.backend.get_parameters()['path']) self.textbox.connect('changed', self.on_path_modified) - align.add(self.textbox) + self.append(self.textbox) self.button = Gtk.Button() self.button.set_label("Edit") self.button.connect('clicked', self.on_button_clicked) - self.pack_start(self.button, False, True, 0) + self.append(self.button) def commit_changes(self): """Saves the changes to the backend parameter""" @@ -75,7 +74,7 @@ def on_path_modified(self, sender): @param sender: not used, only here for signal compatibility """ if self.backend.is_enabled() and not self.backend.is_default(): - self.req.set_backend_enabled(self.backend.get_id(), False) + self.ds.set_backend_enabled(self.backend.get_id(), False) def on_button_clicked(self, sender): """Shows the filesystem explorer to choose a new file diff --git a/GTG/gtk/backends/parameters_ui/period.py b/GTG/gtk/backends/parameters_ui/period.py index 4888830564..4f2dbf9d99 100644 --- a/GTG/gtk/backends/parameters_ui/period.py +++ b/GTG/gtk/backends/parameters_ui/period.py @@ -25,18 +25,18 @@ class PeriodUI(Gtk.Box): """A widget to change the frequency of a backend synchronization """ - def __init__(self, req, backend, width): + def __init__(self, ds, backend, width): """ Creates the Gtk.Adjustment and the related label. Loads the current period. - @param req: a Requester + @param ds: Datastore @param backend: a backend object @param width: the width of the Gtk.Label object """ super().__init__() self.backend = backend - self.req = req + self.ds = ds self._populate_gtk(width) self._connect_signals() @@ -45,30 +45,29 @@ def _populate_gtk(self, width): @param width: the width of the Gtk.Label object """ + self.set_spacing(10) period_label = Gtk.Label(label=_("Check for new tasks every")) - period_label.set_alignment(xalign=0, yalign=0.5) - period_label.set_line_wrap(True) + period_label.set_xalign(0) + period_label.set_yalign(0.5) + period_label.set_wrap(True) period_label.set_size_request(width=width, height=-1) - self.pack_start(period_label, False, True, 0) - align = Gtk.Alignment.new(0, 0.5, 1, 0) - align.set_padding(0, 0, 10, 0) - self.pack_start(align, False, True, 0) + self.append(period_label) period = self.backend.get_parameters()['period'] self.adjustment = Gtk.Adjustment(value=period, lower=1, upper=120, - step_incr=1, - page_incr=0, + step_increment=1, + page_increment=0, page_size=0) self.period_spin = Gtk.SpinButton(adjustment=self.adjustment, climb_rate=0.3, digits=0) + self.append(self.period_spin) self.minutes_label = Gtk.Label() self.update_minutes_label() - self.minutes_label.set_alignment(xalign=0, yalign=0.5) - self.pack_start(self.minutes_label, False, True, 0) - align.add(self.period_spin) - self.show_all() + self.minutes_label.set_xalign(0) + self.minutes_label.set_yalign(0.5) + self.append(self.minutes_label) def _connect_signals(self): """Connects the gtk signals""" @@ -87,7 +86,7 @@ def on_spin_changed(self, sender): """ self.update_minutes_label() if self.backend.is_enabled() and not self.backend.is_default(): - self.req.set_backend_enabled(self.backend.get_id(), False) + self.ds.set_backend_enabled(self.backend.get_id(), False) def update_minutes_label(self): adjustment = int(self.adjustment.get_value()) diff --git a/GTG/gtk/backends/parameters_ui/text.py b/GTG/gtk/backends/parameters_ui/text.py index 4b77b1f538..d275e4d715 100644 --- a/GTG/gtk/backends/parameters_ui/text.py +++ b/GTG/gtk/backends/parameters_ui/text.py @@ -23,18 +23,18 @@ class TextUI(Gtk.Box): """A widget to display a simple textbox and a label to describe its content """ - def __init__(self, req, backend, width, description, parameter_name): + def __init__(self, ds, backend, width, description, parameter_name): """ Creates the textbox and the related label. Loads the current content. - @param req: a Requester + @param ds: Datastore @param backend: a backend object @param width: the width of the Gtk.Label object """ super().__init__() self.backend = backend - self.req = req + self.ds = ds self.parameter_name = parameter_name self.description = description self._populate_gtk(width) @@ -44,19 +44,19 @@ def _populate_gtk(self, width): @param width: the width of the Gtk.Label object """ + self.set_spacing(10) label = Gtk.Label(label=f"{self.description}:") - label.set_line_wrap(True) - label.set_alignment(xalign=0, yalign=0.5) + label.set_wrap(True) + label.set_xalign(0) + label.set_yalign(0.5) label.set_size_request(width=width, height=-1) - self.pack_start(label, False, True, 0) - align = Gtk.Alignment.new(0, 0.5, 1, 0) - align.set_padding(0, 0, 10, 0) - self.pack_start(align, True, True, 0) + self.append(label) self.textbox = Gtk.Entry() + self.textbox.set_hexpand(True) backend_parameters = self.backend.get_parameters()[self.parameter_name] self.textbox.set_text(backend_parameters) self.textbox.connect('changed', self.on_text_modified) - align.add(self.textbox) + self.append(self.textbox) def commit_changes(self): """Saves the changes to the backend parameter""" @@ -71,4 +71,4 @@ def on_text_modified(self, sender): @param sender: not used, only here for signal compatibility """ if self.backend.is_enabled() and not self.backend.is_default(): - self.req.set_backend_enabled(self.backend.get_id(), False) + self.ds.set_backend_enabled(self.backend.get_id(), False) diff --git a/GTG/gtk/browser/__init__.py b/GTG/gtk/browser/__init__.py index 0a9c53c209..8c2b3ecc3f 100644 --- a/GTG/gtk/browser/__init__.py +++ b/GTG/gtk/browser/__init__.py @@ -32,3 +32,4 @@ class GnomeConfig(): MENUS_UI_FILE = os.path.join(data, "context_menus.ui") MODIFYTAGS_UI_FILE = os.path.join(data, "modify_tags.ui") TAG_EDITOR_UI_FILE = os.path.join(data, "tag_editor.ui") + SEARCH_EDITOR_UI_FILE = os.path.join(data, "search_editor.ui") diff --git a/GTG/gtk/browser/adaptive_button.py b/GTG/gtk/browser/adaptive_button.py index a39402833f..db6fbac53e 100644 --- a/GTG/gtk/browser/adaptive_button.py +++ b/GTG/gtk/browser/adaptive_button.py @@ -8,7 +8,7 @@ log = logging.getLogger(__name__) -class AdaptiveFittingWidget(Gtk.Container): +class AdaptiveFittingWidget(Gtk.BinLayout): """ This widget chooses the biggest but fitting children widgets and displays that one. This is useful to switch out between text and an icon in an diff --git a/GTG/gtk/browser/backend_infobar.py b/GTG/gtk/browser/backend_infobar.py index 80ce0a6d0f..8d034a0664 100644 --- a/GTG/gtk/browser/backend_infobar.py +++ b/GTG/gtk/browser/backend_infobar.py @@ -71,10 +71,10 @@ def _populate(self): content_box.set_homogeneous(False) self.label = Gtk.Label() self.label.set_hexpand(True) - self.label.set_line_wrap(True) + self.label.set_wrap(True) self.label.set_alignment(0.5, 0.5) self.label.set_justify(Gtk.Justification.FILL) - content_box.add(self.label) + content_box.append(self.label) def _on_error_response(self, widget, event): """ @@ -174,32 +174,31 @@ def _prepare_textual_interaction(self): self.dialog.set_title(title) self.dialog.set_transient_for(self.browser.window) self.dialog.set_destroy_with_parent(True) - self.dialog.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) self.dialog.set_modal(True) # self.dialog.set_size_request(300,170) vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.dialog.add(vbox) + self.dialog.set_child(vbox) description_label = Gtk.Label() description_label.set_justify(Gtk.Justification.FILL) - description_label.set_line_wrap(True) + description_label.set_wrap(True) description_label.set_markup(description) align = Gtk.Alignment.new(0.5, 0.5, 1, 1) align.set_padding(10, 0, 20, 20) align.set_vexpand(True) align.add(description_label) - vbox.add(align) + vbox.append(align) self.text_box = Gtk.Entry() self.text_box.set_size_request(-1, 40) align = Gtk.Alignment.new(0.5, 0.5, 1, 1) align.set_vexpand(True) align.set_padding(20, 20, 20, 20) align.add(self.text_box) - vbox.add(align) + vbox.append(align) button = Gtk.Button() button.set_label(_("OK")) button.connect("clicked", self._on_text_confirmed) button.set_size_request(-1, 40) - vbox.add(button) + vbox.append(button) self.dialog.show_all() self.hide() diff --git a/GTG/gtk/browser/cell_renderer_tags.py b/GTG/gtk/browser/cell_renderer_tags.py deleted file mode 100644 index 3808c10f1b..0000000000 --- a/GTG/gtk/browser/cell_renderer_tags.py +++ /dev/null @@ -1,234 +0,0 @@ -# ----------------------------------------------------------------------------- -# Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program. If not, see . -# ----------------------------------------------------------------------------- - -from gi.repository import GObject, GLib, Gtk, Gdk -from gi.repository import Pango -import gi -import cairo -gi.require_version('PangoCairo', '1.0') -# XXX: disable PEP8 checking on this line to prevent an E402 error due to -# require_version needing to be called before the PangoCairo import -from gi.repository import PangoCairo # noqa - - -class CellRendererTags(Gtk.CellRenderer): - - SYMBOLIC_ICONS = ( - 'emblem-documents-symbolic', - 'task-past-due-symbolic', - 'system-search-symbolic', - ) - - __gproperties__ = { - 'tag_list': (GObject.TYPE_PYOBJECT, - "Tag list", "A list of tags", GObject.ParamFlags.READWRITE), - 'tag': (GObject.TYPE_PYOBJECT, "Tag", - "Tag", GObject.ParamFlags.READWRITE), - } - - # Private methods - def __roundedrec(self, context, x, y, w, h, r=10): - "Draw a rounded rectangle" - # A * BQ - # H C - # * * - # G D - # F * E - - context.move_to(x + r, y) # Move to A - context.line_to(x + w - r, y) # Line to B - - context.curve_to( - x + w, y, - x + w, y, - x + w, y + r - ) # Curve to C - context.line_to(x + w, y + h - r) # Line to D - - context.curve_to( - x + w, y + h, - x + w, y + h, - x + w - r, y + h - ) # Curve to E - context.line_to(x + r, y + h) # Line to F - - context.curve_to( - x, y + h, - x, y + h, - x, y + h - r - ) # Curve to G - context.line_to(x, y + r) # Line to H - - context.curve_to( - x, y, - x, y, - x + r, y - ) # Curve to A - return - - def __count_viewable_tags(self): - - count = 0 - if self.tag_list is not None: - for my_tag in self.tag_list: - my_tag_color = my_tag.get_attribute("color") - my_tag_icon = my_tag.get_attribute("icon") - if my_tag_color or my_tag_icon: - count = count + 1 - elif self.tag is not None: - count = 1 - else: - count = 0 - - return count - - # Class methods - def __init__(self, config): - super().__init__() - self.tag_list = None - self.tag = None - self.xpad = 1 - self.ypad = 1 - self.PADDING = 1 - self.config = config - self._ignore_icon_error_for = set() - - def do_set_property(self, pspec, value): - if pspec.name == "tag-list": - self.tag_list = value - else: - setattr(self, pspec.name, value) - - def do_get_property(self, pspec): - if pspec.name == "tag-list": - return self.tag_list - else: - return getattr(self, pspec.name) - - def do_render(self, cr, widget, background_area, cell_area, flags): - - vw_tags = self.__count_viewable_tags() - count = 0 - - # Select source - if self.tag_list is not None: - tags = self.tag_list - elif self.tag is not None: - tags = [self.tag] - else: - return - - if self.config.get('dark_mode'): - symbolic_color = Gdk.RGBA(0.9, 0.9, 0.9, 1) - else: - symbolic_color = Gdk.RGBA(0, 0, 0, 1) - - # Drawing context - gdkcontext = cr - scale_factor = widget.get_scale_factor() - # Don't blur border on lodpi - if scale_factor == 1: - gdkcontext.set_antialias(cairo.ANTIALIAS_NONE) - - # Coordinates of the origin point - x_align = self.get_property("xalign") - y_align = self.get_property("yalign") - padding = self.PADDING - orig_x = cell_area.x + int( - (cell_area.width - 16 * vw_tags - padding * 2 * (vw_tags - 1)) * x_align) - orig_y = cell_area.y + int( - (cell_area.height - 16) * y_align) - - # We draw the icons & squares - for my_tag in tags: - - my_tag_icon = my_tag.get_attribute("icon") - my_tag_color = my_tag.get_attribute("color") - - rect_x = orig_x + self.PADDING * 2 * count + 16 * count - rect_y = orig_y - - if my_tag_icon: - if my_tag_icon in self.SYMBOLIC_ICONS: - icon_theme = Gtk.IconTheme.get_default() - info = icon_theme.lookup_icon_for_scale(my_tag_icon, 16, - scale_factor, 0) - pixbuf, was_symbolic = info.load_symbolic(symbolic_color) - - surface = Gdk.cairo_surface_create_from_pixbuf( - pixbuf, scale_factor, widget.get_window()) - Gtk.render_icon_surface( - widget.get_style_context(), gdkcontext, surface, - rect_x, rect_y) - - count += 1 - - else: - layout = PangoCairo.create_layout(cr) - layout.set_markup(my_tag_icon, -1) - cr.move_to(rect_x - 2, rect_y - 1) - PangoCairo.show_layout(cr, layout) - count += 1 - - elif my_tag_color: - - # Draw rounded rectangle - my_color = Gdk.RGBA() - my_color.parse(my_tag_color) - Gdk.cairo_set_source_rgba(gdkcontext, my_color) - - self.__roundedrec(gdkcontext, rect_x, rect_y, 16, 16, 8) - gdkcontext.fill() - count += 1 - - # Outer line - Gdk.cairo_set_source_rgba(gdkcontext, Gdk.RGBA(0, 0, 0, 0.20)) - gdkcontext.set_line_width(1.0) - self.__roundedrec(gdkcontext, rect_x, rect_y, 16, 16, 8) - gdkcontext.stroke() - - if self.tag and my_tag: - - my_tag_icon = my_tag.get_attribute("icon") - my_tag_color = my_tag.get_attribute("color") - - if not my_tag_icon and not my_tag_color: - # Draw rounded rectangle - Gdk.cairo_set_source_rgba(gdkcontext, - Gdk.RGBA(0.95, 0.95, 0.95, 1)) - self.__roundedrec(gdkcontext, rect_x, rect_y, 16, 16, 8) - gdkcontext.fill() - - # Outer line - Gdk.cairo_set_source_rgba(gdkcontext, Gdk.RGBA(0, 0, 0, 0.20)) - gdkcontext.set_line_width(1.0) - self.__roundedrec(gdkcontext, rect_x, rect_y, 16, 16, 8) - gdkcontext.stroke() - - def do_get_size(self, widget, cell_area=None): - count = self.__count_viewable_tags() - - if count != 0: - return (self.xpad, self.ypad, - self.xpad * 2 + 16 * count + 2 * count * self.PADDING, - 16 + 2 * self.ypad) - else: - return (self.xpad, self.ypad, self.xpad * 2, self.ypad * 2) - - -GObject.type_register(CellRendererTags) diff --git a/GTG/gtk/browser/delete_tag.py b/GTG/gtk/browser/delete_tag.py index 913e1a9113..871b2f0738 100644 --- a/GTG/gtk/browser/delete_tag.py +++ b/GTG/gtk/browser/delete_tag.py @@ -18,17 +18,16 @@ from gi.repository import Gtk -from GTG.core.tasks2 import Filter +from GTG.core.tasks import Filter from gettext import gettext as _, ngettext -class DeleteTagsDialog(): +class DeleteTagsDialog: MAXIMUM_TAGS_TO_SHOW = 5 - def __init__(self, req, browser): - self.req = req + def __init__(self, browser): self.browser = browser self.tags_todelete = [] @@ -37,20 +36,28 @@ def on_delete_confirm(self): otherwise, we will look which tid is selected""" for tag in self.tags_todelete: - self.req.delete_tag(tag) - - # TODO: New Core - the_tag = self.browser.app.ds.tags.find(tag) - tasks = self.browser.app.ds.tasks.filter(Filter.TAG, the_tag) + tasks = self.browser.app.ds.tasks.filter(Filter.TAG, tag) for t in tasks: - t.remove_tag(tag) + t.remove_tag(tag.name) - self.browser.app.ds.tags.remove(the_tag.id) + self.browser.app.ds.tags.remove(tag.id) + self.browser.app.ds.tasks.notify('task_count_no_tags') self.tags_todelete = [] - def delete_tags(self, tags=None): + def on_response(self, dialog, response, tagslist, callback): + dialog.destroy() + + if response == Gtk.ResponseType.YES: + self.on_delete_confirm() + elif response == Gtk.ResponseType.REJECT: + tagslist = [] + + if callback: + callback(tagslist) + + def show(self, tags=None, callback=None): self.tags_todelete = tags or self.tags_todelete if not self.tags_todelete: @@ -86,28 +93,22 @@ def delete_tags(self, tags=None): if len(tagslist) == 1: # Don't show a bulleted list if there's only one item - titles = "".join(tag for tag in tagslist) + titles = "".join(tag.name for tag in tagslist) else: - titles = "".join("\n• " + tag for tag in tagslist) + titles = "".join("\n• " + tag.name for tag in tagslist) # Build and run dialog dialog = Gtk.MessageDialog(transient_for=self.browser, modal=True) dialog.add_button(cancel_text, Gtk.ResponseType.CANCEL) delete_btn = dialog.add_button(delete_text, Gtk.ResponseType.YES) - delete_btn.get_style_context().add_class("destructive-action") + delete_btn.add_css_class("destructive-action") dialog.props.use_markup = True - dialog.props.text = "" + label_text + "" + # Decrease size of title to workaround not being able to put two texts in GTK4 + dialog.props.text = "" + label_text + "" dialog.props.secondary_text = titles + titles_suffix - response = dialog.run() - dialog.destroy() - - if response == Gtk.ResponseType.YES: - self.on_delete_confirm() - elif response == Gtk.ResponseType.REJECT: - tagslist = [] - - return tagslist + dialog.connect("response", self.on_response, tagslist, callback) + dialog.present() diff --git a/GTG/gtk/browser/delete_task.py b/GTG/gtk/browser/delete_task.py index 5749e77fc1..bd6337b012 100644 --- a/GTG/gtk/browser/delete_task.py +++ b/GTG/gtk/browser/delete_task.py @@ -23,66 +23,51 @@ from GTG.gtk import ViewConfig -class DeletionUI(): +class DeletionUI: MAXIMUM_TIDS_TO_SHOW = 5 - def __init__(self, req, window): - self.req = req - self.tids_todelete = [] - - # Tags which must be updated - self.update_tags = [] + def __init__(self, window, ds): + self.tasks_to_delete = [] self.window = window + self.ds = ds + + + def on_response(self, dialog, response, tasklist, callback): + dialog.destroy() + + if response == Gtk.ResponseType.YES: + self.on_delete_confirm(tasklist) - def on_delete_confirm(self): - """if we pass a tid as a parameter, we delete directly - otherwise, we will look which tid is selected""" + if callback: + callback(tasklist) - for tid in self.tids_todelete: - if self.req.has_task(tid): - self.req.delete_task(tid, recursive=True) - self.tids_todelete = [] + def on_delete_confirm(self, tasklist): - # Update tags - for tagname in self.update_tags: - tag = self.req.get_tag(tagname) - tag.modified() + for task in tasklist: + self.ds.tasks.remove(task.id) - self.update_tags = [] def recursive_list_tasks(self, tasklist, root): """Populate a list of all the subtasks and - their children, recursively. + their children, recursively.""" - Also collect the list of affected tags - which should be refreshed""" if root not in tasklist: tasklist.append(root) - [self.update_tags.append(tagname) - for tagname in root.get_tags_name() - if tagname not in self.update_tags] + [self.recursive_list_tasks(tasklist, t) + for t in root.children + if t not in tasklist] - [self.recursive_list_tasks(tasklist, i) - for i in root.get_subtasks() if i not in tasklist] - - def show(self, tids=None): - self.tids_todelete = tids or self.tids_todelete - - if not self.tids_todelete: - # We must at least have something to delete! - return [] + def show_async(self, tasks_to_delete, callback=None): # Get full task list to delete tasklist = [] - self.update_tags = [] - for tid in self.tids_todelete: - task = self.req.get_task(tid) + for task in tasks_to_delete: self.recursive_list_tasks(tasklist, task) # Prepare Labels @@ -103,7 +88,7 @@ def show(self, tids=None): missing_titles_count = len(tasklist) - self.MAXIMUM_TIDS_TO_SHOW if missing_titles_count >= 2: - tasks = tasklist[: self.MAXIMUM_TIDS_TO_SHOW] + tasks = tasklist[:self.MAXIMUM_TIDS_TO_SHOW] titles_suffix = _("\nAnd {missing_titles_count:d} more tasks") titles_suffix = titles_suffix.format(missing_titles_count=missing_titles_count) else: @@ -112,28 +97,22 @@ def show(self, tids=None): if len(tasklist) == 1: # Don't show a bulleted list if there's only one item - titles = "".join(task.get_title() for task in tasks) + titles = "".join(task.title for task in tasks) else: - titles = "".join("\n• " + task.get_title() for task in tasks) + titles = "".join("\n• " + task.title for task in tasks) # Build and run dialog dialog = Gtk.MessageDialog(transient_for=self.window, modal=True) dialog.add_button(cancel_text, Gtk.ResponseType.CANCEL) delete_btn = dialog.add_button(delete_text, Gtk.ResponseType.YES) - delete_btn.get_style_context().add_class("destructive-action") + delete_btn.add_css_class("destructive-action") dialog.props.use_markup = True - dialog.props.text = "" + label_text + "" + # Decrease size of title to workaround not being able to put two texts in GTK4 + dialog.props.text = "" + label_text + "" dialog.props.secondary_text = titles + titles_suffix - response = dialog.run() - dialog.destroy() - - if response == Gtk.ResponseType.YES: - self.on_delete_confirm() - elif response == Gtk.ResponseType.CANCEL: - tasklist = [] - - return tasklist + dialog.connect("response", self.on_response, tasklist, callback) + dialog.present() diff --git a/GTG/gtk/browser/main_window.py b/GTG/gtk/browser/main_window.py index 470cb2c496..7446255d1f 100644 --- a/GTG/gtk/browser/main_window.py +++ b/GTG/gtk/browser/main_window.py @@ -22,7 +22,7 @@ import datetime import logging import ast -import liblarch_gtk # Just for types +import re from gi.repository import GObject, Gtk, Gdk, Gio, GLib @@ -30,20 +30,18 @@ from GTG.backends.backend_signals import BackendSignals from GTG.core.dirs import ICONS_DIR from GTG.core.search import parse_search_query, InvalidQuery -from GTG.core.tag import SEARCH_TAG -from GTG.core.task import Task from gettext import gettext as _ from GTG.gtk.browser import GnomeConfig from GTG.gtk.browser import quick_add from GTG.gtk.browser.backend_infobar import BackendInfoBar from GTG.gtk.browser.modify_tags import ModifyTagsDialog from GTG.gtk.browser.delete_tag import DeleteTagsDialog -from GTG.gtk.browser.tag_context_menu import TagContextMenu -from GTG.gtk.browser.treeview_factory import TreeviewFactory +from GTG.gtk.browser.sidebar import Sidebar +from GTG.gtk.browser.task_pane import TaskPane from GTG.gtk.editor.calendar import GTGCalendar from GTG.gtk.tag_completion import TagCompletion from GTG.core.dates import Date -from GTG.core.tasks2 import Filter +from GTG.core.tasks import Filter, Status from GTG.gtk.browser.adaptive_button import AdaptiveFittingWidget # Register type log = logging.getLogger(__name__) @@ -55,10 +53,13 @@ PANE_STACK_NAMES_MAP_INVERTED = {v: k for k, v in PANE_STACK_NAMES_MAP.items()} +@Gtk.Template(filename=GnomeConfig.BROWSER_UI_FILE) class MainWindow(Gtk.ApplicationWindow): """ The UI for browsing open and closed tasks, and listing tags in a tree """ + __gtype_name__ = 'MainWindow' + __string_signal__ = (GObject.SignalFlags.RUN_FIRST, None, (str, )) __none_signal__ = (GObject.SignalFlags.RUN_FIRST, None, tuple()) __gsignals__ = {'task-added-via-quick-add': __string_signal__, @@ -67,38 +68,65 @@ class MainWindow(Gtk.ApplicationWindow): 'visibility-toggled': __none_signal__, } - def __init__(self, requester, app): + main_hpanes = Gtk.Template.Child() + open_pane = Gtk.Template.Child() + actionable_pane = Gtk.Template.Child() + closed_pane = Gtk.Template.Child() + tree_stack = Gtk.Template.Child('stack') + + search_entry = Gtk.Template.Child() + searchbar = Gtk.Template.Child() + search_button = Gtk.Template.Child() + + quickadd_entry = Gtk.Template.Child('quickadd_field') + quickadd_pane = Gtk.Template.Child() + + sidebar_vbox = Gtk.Template.Child('sidebar_vbox') + + vbox_toolbars = None + stack_switcher = Gtk.Template.Child() + main_box = Gtk.Template.Child('main_view_box') + + defer_btn = Gtk.Template.Child('defer_task_button') + defer_menu_btn = Gtk.Template.Child() + defer_menu_days_section = Gtk.Template.Child() + + headerbar = Gtk.Template.Child('browser_headerbar') + main_menu_btn = Gtk.Template.Child() + main_menu = Gtk.Template.Child() + help_overlay = Gtk.Template.Child('shortcuts') + about = Gtk.Template.Child('about_dialog') + + def __init__(self, app): super().__init__(application=app) # Object prime variables - self.req = requester self.app = app - self.config = self.req.get_config('browser') + self.config = app.config self.tag_active = False # Timeout handler for search self.search_timeout = None - # Treeviews handlers - self.vtree_panes = {} - self.tv_factory = TreeviewFactory(self.req, self.config) + self.sidebar = Sidebar(app, app.ds, self) + self.sidebar_vbox.append(self.sidebar) + + self.panes = { + 'active': None, + 'workview': None, + 'closed': None, + } + + self.panes['active'] = TaskPane(self, 'active') + self.open_pane.append(self.panes['active']) - # Active Tasks - self.activetree = self.req.get_tasks_tree(name='active', refresh=False) - self.vtree_panes['active'] = \ - self.tv_factory.active_tasks_treeview(self.activetree) + self.panes['workview'] = TaskPane(self, 'workview') + self.actionable_pane.append(self.panes['workview']) - # Workview Tasks - self.workview_tree = \ - self.req.get_tasks_tree(name='workview', refresh=False) - self.vtree_panes['workview'] = \ - self.tv_factory.active_tasks_treeview(self.workview_tree) + self.panes['closed'] = TaskPane(self, 'closed') + self.closed_pane.append(self.panes['closed']) - # Closed Tasks - self.closedtree = \ - self.req.get_tasks_tree(name='closed', refresh=False) - self.vtree_panes['closed'] = \ - self.tv_factory.closed_tasks_treeview(self.closedtree) + self._init_context_menus() # YOU CAN DEFINE YOUR INTERNAL MECHANICS VARIABLES BELOW # Setup GTG icon theme @@ -111,36 +139,23 @@ def __init__(self, requester, app): self.tagtree = None self.tagtreeview = None - # Load window tree - self.builder = Gtk.Builder() - self.builder.add_from_file(GnomeConfig.BROWSER_UI_FILE) - self.builder.add_from_file(GnomeConfig.HELP_OVERLAY_UI_FILE) - - # Define aliases for specific widgets to reuse them easily in the code - self._init_widget_aliases() - self.sidebar.connect('notify::visible', self._on_sidebar_visible) - self.add_action(Gio.PropertyAction.new('sidebar', self.sidebar, 'visible')) - - self.set_titlebar(self.headerbar) - self.set_title('Getting Things GNOME!') - self.add(self.main_box) + self.sidebar_vbox.connect('notify::visible', self._on_sidebar_visible) + self.add_action(Gio.PropertyAction.new('sidebar', self.sidebar_vbox, 'visible')) # Setup help overlay (shortcuts window) self.set_help_overlay(self.help_overlay) # Init non-GtkBuilder widgets self._init_ui_widget() - self._init_context_menus() # Initialize "About" dialog self._init_about_dialog() - # Create our dictionary and connect it + # Connect manual signals self._init_signal_connections() self.restore_state_from_conf() - self.reapply_filter() self._set_defer_days() self.browser_shown = False @@ -159,12 +174,29 @@ def _init_context_menus(self): builder.add_from_file(GnomeConfig.MENUS_UI_FILE) closed_menu_model = builder.get_object('closed_task_menu') - self.closed_menu = Gtk.Menu.new_from_model(closed_menu_model) - self.closed_menu.attach_to_widget(self.main_box) + self.closed_menu = Gtk.PopoverMenu.new_from_model_full( + closed_menu_model, Gtk.PopoverMenuFlags.NESTED + ) + + self.closed_menu.set_has_arrow(False) + self.closed_menu.set_parent(self) + self.closed_menu.set_halign(Gtk.Align.START) + self.closed_menu.set_position(Gtk.PositionType.BOTTOM) open_menu_model = builder.get_object('task_menu') - self.open_menu = Gtk.Menu.new_from_model(open_menu_model) - self.open_menu.attach_to_widget(self.main_box) + self.open_menu = Gtk.PopoverMenu.new_from_model_full( + open_menu_model, Gtk.PopoverMenuFlags.NESTED + ) + + self.open_menu.set_has_arrow(False) + self.open_menu.set_parent(self) + self.open_menu.set_halign(Gtk.Align.START) + self.open_menu.set_position(Gtk.PositionType.BOTTOM) + + sort_menu_model = builder.get_object('sort_menu') + self.sort_menu = Gtk.PopoverMenu.new_from_model(sort_menu_model) + self.panes['active'].sort_btn.set_popover(self.sort_menu) + def _set_actions(self): """Setup actions.""" @@ -210,6 +242,13 @@ def _set_actions(self): ('recurring_month', self.on_set_recurring_every_month, None), ('recurring_year', self.on_set_recurring_every_year, None), ('recurring_toggle', self.on_toggle_recurring, None), + ('sort_by_start', self.on_sort_start, None), + ('sort_by_due', self.on_sort_due, None), + ('sort_by_added', self.on_sort_added, None), + ('sort_by_title', self.on_sort_title, None), + ('sort_by_modified', self.on_sort_modified, None), + ('sort_by_added', self.on_sort_added, None), + ('sort_by_tags', self.on_sort_tags, None), ] for action, callback, accel in action_entries: @@ -228,58 +267,29 @@ def _init_icon_theme(self): sets the deafault theme for icon and its directory """ # TODO(izidor): Add icon dirs on app level - Gtk.IconTheme.get_default().prepend_search_path(ICONS_DIR) - - def _init_widget_aliases(self): - """ - Defines aliases for UI elements found in the GtkBuilder file - """ - - self.taskpopup = self.builder.get_object("task_context_menu") - self.defertopopup = self.builder.get_object("defer_to_context_menu") - self.ctaskpopup = self.builder.get_object("closed_task_context_menu") - self.about = self.builder.get_object("about_dialog") - self.open_pane = self.builder.get_object("open_pane") - self.actionable_pane = self.builder.get_object("actionable_pane") - self.closed_pane = self.builder.get_object("closed_pane") - self.menu_view_workview = self.builder.get_object("view_workview") - self.toggle_workview = self.builder.get_object("workview_toggle") - self.search_entry = self.builder.get_object("search_entry") - self.searchbar = self.builder.get_object("searchbar") - self.search_button = self.builder.get_object("search_button") - self.quickadd_entry = self.builder.get_object("quickadd_field") - self.quickadd_pane = self.builder.get_object("quickadd_pane") - self.sidebar = self.builder.get_object("sidebar_vbox") - self.sidebar_container = self.builder.get_object("sidebar-scroll") - self.sidebar_notebook = self.builder.get_object("sidebar_notebook") - self.main_notebook = self.builder.get_object("main_notebook") - self.accessory_notebook = self.builder.get_object("accessory_notebook") - self.vbox_toolbars = self.builder.get_object("vbox_toolbars") - self.stack_switcher = self.builder.get_object("stack_switcher") - self.headerbar = self.builder.get_object("browser_headerbar") - self.main_box = self.builder.get_object("main_view_box") - self.defer_btn = self.builder.get_object("defer_task_button") - self.defer_menu_btn = self.builder.get_object("defer_menu_btn") - self.help_overlay = self.builder.get_object("shortcuts") - - self.tagpopup = TagContextMenu(self.req, self.app) + Gtk.IconTheme.get_for_display(self.get_display()).add_search_path(ICONS_DIR) def _init_ui_widget(self): """ Sets the main pane with three trees for active tasks, actionable tasks (workview), closed tasks and creates ModifyTagsDialog & Calendar """ # Tasks treeviews - self.open_pane.add(self.vtree_panes['active']) - self.actionable_pane.add(self.vtree_panes['workview']) - self.closed_pane.add(self.vtree_panes['closed']) - - tag_completion = TagCompletion(self.req.get_tag_tree()) - self.modifytags_dialog = ModifyTagsDialog(tag_completion, self.req, self.app) - self.modifytags_dialog.dialog.set_transient_for(self) - self.deletetags_dialog = DeleteTagsDialog(self.req, self) + # self.open_pane.add(self.vtree_panes['active']) + # self.actionable_pane.add(self.vtree_panes['workview']) + # self.closed_pane.add(self.vtree_panes['closed']) + + quickadd_focus_controller = Gtk.EventControllerFocus() + quickadd_focus_controller.connect('enter', self.on_quickadd_focus_in) + quickadd_focus_controller.connect('leave', self.on_quickadd_focus_out) + self.quickadd_entry.add_controller(quickadd_focus_controller) + + tag_completion = TagCompletion(self.app.ds.tags) + self.modifytags_dialog = ModifyTagsDialog(tag_completion, self.app) + self.modifytags_dialog.set_transient_for(self) + + self.deletetags_dialog = DeleteTagsDialog(self) self.calendar = GTGCalendar() self.calendar.set_transient_for(self) - self.calendar.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) self.calendar.connect("date-changed", self.on_date_changed) def _set_defer_days(self, timer=None): @@ -287,7 +297,6 @@ def _set_defer_days(self, timer=None): Set dynamic day labels for the toolbar's task deferring menubutton. """ today = datetime.datetime.today() - dynamic_days_menu_section = self.builder.get_object("defer_menu_dydays_section") # Day 0 is "Today", day 1 is "Tomorrow", # so we don't need to calculate the weekday name for those. @@ -296,44 +305,15 @@ def _set_defer_days(self, timer=None): translated_weekday_combo = _("In {number_of_days} days — {weekday}").format( weekday=weekday_name, number_of_days=i + 2) - action = ''.join(dynamic_days_menu_section.get_item_attribute_value( + action = ''.join(self.defer_menu_days_section.get_item_attribute_value( i, 'action', GLib.VariantType.new('s')).get_string() ) replacement_item = Gio.MenuItem.new(translated_weekday_combo, action) - dynamic_days_menu_section.remove(i) - dynamic_days_menu_section.insert_item(i, replacement_item) - - def init_tags_sidebar(self): - """ - initializes the tagtree (left area with tags and searches) - """ - self.tagtree = self.req.get_tag_tree() - self.tagtreeview = self.tv_factory.tags_treeview(self.tagtree) - self.tagtreeview.get_selection().connect('changed', self.on_select_tag) + self.defer_menu_days_section.remove(i) + self.defer_menu_days_section.insert_item(i, replacement_item) - self.tagtree_gesture_single = Gtk.GestureSingle(widget=self.tagtreeview, button=Gdk.BUTTON_SECONDARY) - self.tagtree_gesture_single.connect('begin', self.on_tag_treeview_click_begin) - self.tagtree_key_controller = Gtk.EventControllerKey(widget=self.tagtreeview) - self.tagtree_key_controller.connect('key-pressed', self.on_tag_treeview_key_press_event) - self.tagtreeview.connect('node-expanded', self.on_tag_expanded) - self.tagtreeview.connect('node-collapsed', self.on_tag_collapsed) - - self.sidebar_container.add(self.tagtreeview) - - for path_t in self.config.get("expanded_tags"): - # the tuple was stored as a string. we have to reconstruct it - path = () - for p in path_t[1:-1].split(","): - p = p.strip(" '") - path += (p, ) - if path[-1] == '': - path = path[:-1] - self.tagtreeview.expand_node(path) - - # expanding search tag does not work automatically, request it - self.expand_search_tag() def _init_about_dialog(self): """ @@ -388,74 +368,89 @@ def _init_signal_connections(self): """ connects signals on UI elements """ - SIGNAL_CONNECTIONS_DIC = { - "on_edit_done_task": self.on_edit_done_task, - "on_add_subtask": self.on_add_subtask, - "on_tagcontext_deactivate": self.on_tagcontext_deactivate, - "on_quickadd_field_activate": self.on_quickadd_activate, - "on_quickadd_field_focus_in": self.on_quickadd_focus_in, - "on_quickadd_field_focus_out": self.on_quickadd_focus_out, - "on_about_delete": self.on_about_close, - "on_about_close": self.on_about_close, - "on_search": self.on_search, - } - self.builder.connect_signals(SIGNAL_CONNECTIONS_DIC) - # When destroying this window, quit GTG - self.connect("destroy", self.quit) + self.connect("close-request", self.quit) # Store window position - self.connect('size-allocate', self.on_size_allocate) - - # Active tasks TreeView - self.vtree_panes['active'].connect('row-activated', self.on_edit_active_task) - self.vtree_panes['active'].connect('cursor-changed', self.on_cursor_changed) - - tsk_treeview_btn_press = self.on_task_treeview_click_begin - self.active_pane_gesture_single = Gtk.GestureSingle(widget=self.vtree_panes['active'], button=Gdk.BUTTON_SECONDARY, - propagation_phase=Gtk.PropagationPhase.CAPTURE) - self.active_pane_gesture_single.connect('begin', tsk_treeview_btn_press) - task_treeview_key_press = self.on_task_treeview_key_press_event - self.active_pane_key_controller = Gtk.EventControllerKey(widget=self.vtree_panes['active']) - self.active_pane_key_controller.connect('key-pressed', task_treeview_key_press) - self.vtree_panes['active'].connect('node-expanded', self.on_task_expanded) - self.vtree_panes['active'].connect('node-collapsed', self.on_task_collapsed) - - # Workview tasks TreeView - self.vtree_panes['workview'].connect('row-activated', self.on_edit_active_task) - self.vtree_panes['workview'].connect('cursor-changed', self.on_cursor_changed) - - tsk_treeview_btn_press = self.on_task_treeview_click_begin - self.workview_pane_gesture_single = Gtk.GestureSingle(widget=self.vtree_panes['workview'], button=Gdk.BUTTON_SECONDARY, - propagation_phase=Gtk.PropagationPhase.CAPTURE) - self.workview_pane_gesture_single.connect('begin', tsk_treeview_btn_press) - task_treeview_key_press = self.on_task_treeview_key_press_event - self.workview_pane_key_controller = Gtk.EventControllerKey(widget=self.vtree_panes['workview']) - self.workview_pane_key_controller.connect('key-pressed', task_treeview_key_press) - self.vtree_panes['workview'].set_col_visible('startdate', False) + self.connect('notify::default-width', self.on_window_resize) + self.connect('notify::default-height', self.on_window_resize) + + # # Active tasks TreeView + # tsk_treeview_btn_press = self.on_task_treeview_click_begin + # active_pane_gesture_single = Gtk.GestureSingle( + # button=Gdk.BUTTON_SECONDARY, propagation_phase=Gtk.PropagationPhase.CAPTURE + # ) + # active_pane_gesture_single.connect('begin', tsk_treeview_btn_press) + # task_treeview_key_press = self.on_task_treeview_key_press_event + # active_pane_key_controller = Gtk.EventControllerKey() + # active_pane_key_controller.connect('key-pressed', task_treeview_key_press) + # self.vtree_panes['active'].add_controller(active_pane_gesture_single) + # self.vtree_panes['active'].add_controller(active_pane_key_controller) + # self.vtree_panes['active'].connect('node-expanded', self.on_task_expanded) + # self.vtree_panes['active'].connect('node-collapsed', self.on_task_collapsed) + + # # Workview tasks TreeView + # tsk_treeview_btn_press = self.on_task_treeview_click_begin + # workview_pane_gesture_single = Gtk.GestureSingle( + # button=Gdk.BUTTON_SECONDARY, propagation_phase=Gtk.PropagationPhase.CAPTURE + # ) + # workview_pane_gesture_single.connect('begin', tsk_treeview_btn_press) + # task_treeview_key_press = self.on_task_treeview_key_press_event + # workview_pane_key_controller = Gtk.EventControllerKey() + # workview_pane_key_controller.connect('key-pressed', task_treeview_key_press) + # self.vtree_panes['workview'].add_controller(workview_pane_gesture_single) + # self.vtree_panes['workview'].add_controller(workview_pane_key_controller) + # self.vtree_panes['workview'].set_col_visible('startdate', False) # Closed tasks Treeview - self.vtree_panes['closed'].connect('row-activated', self.on_edit_done_task) + # self.vtree_panes['closed'].connect('row-activated', self.on_edit_done_task) # I did not want to break the variable and there was no other # option except this name:(Nimit) - clsd_tsk_btn_prs = self.on_closed_task_treeview_click_begin - self.closed_pane_gesture_single = Gtk.GestureSingle(widget=self.vtree_panes['closed'], button=Gdk.BUTTON_SECONDARY, - propagation_phase=Gtk.PropagationPhase.CAPTURE) - self.closed_pane_gesture_single.connect('begin', clsd_tsk_btn_prs) - clsd_tsk_key_prs = self.on_closed_task_treeview_key_press_event - self.closed_pane_key_controller = Gtk.EventControllerKey(widget=self.vtree_panes['closed']) - self.closed_pane_key_controller.connect('key-pressed', clsd_tsk_key_prs) - self.vtree_panes['closed'].connect('cursor-changed', self.on_cursor_changed) + # clsd_tsk_btn_prs = self.on_closed_task_treeview_click_begin + # closed_pane_gesture_single = Gtk.GestureSingle( + # button=Gdk.BUTTON_SECONDARY, propagation_phase=Gtk.PropagationPhase.CAPTURE + # ) + # closed_pane_gesture_single.connect('begin', clsd_tsk_btn_prs) + # clsd_tsk_key_prs = self.on_closed_task_treeview_key_press_event + # closed_pane_key_controller = Gtk.EventControllerKey() + # closed_pane_key_controller.connect('key-pressed', clsd_tsk_key_prs) + # self.vtree_panes['closed'].add_controller(closed_pane_gesture_single) + # self.vtree_panes['closed'].add_controller(closed_pane_key_controller) + # self.vtree_panes['closed'].connect('cursor-changed', self.on_cursor_changed) + # # Closed tasks Treeview + # self.vtree_panes['closed'].connect('row-activated', self.on_edit_done_task) + # # I did not want to break the variable and there was no other + # # option except this name:(Nimit) + # clsd_tsk_btn_prs = self.on_closed_task_treeview_click_begin + # self.closed_pane_gesture_single = Gtk.GestureSingle(widget=self.vtree_panes['closed'], button=Gdk.BUTTON_SECONDARY, + # propagation_phase=Gtk.PropagationPhase.CAPTURE) + # self.closed_pane_gesture_single.connect('begin', clsd_tsk_btn_prs) + # clsd_tsk_key_prs = self.on_closed_task_treeview_key_press_event + # self.closed_pane_key_controller = Gtk.EventControllerKey(widget=self.vtree_panes['closed']) + # self.closed_pane_key_controller.connect('key-pressed', clsd_tsk_key_prs) + # self.vtree_panes['closed'].connect('cursor-changed', self.on_cursor_changed) b_signals = BackendSignals() b_signals.connect(b_signals.BACKEND_FAILED, self.on_backend_failed) b_signals.connect(b_signals.BACKEND_STATE_TOGGLED, self.remove_backend_infobar) b_signals.connect(b_signals.INTERACTION_REQUESTED, self.on_backend_needing_interaction) - self.selection = self.vtree_panes['active'].get_selection() + # self.selection = self.vtree_panes['active'].get_selection() # HELPER FUNCTIONS ########################################################## + def show_popup_at(self, popup, x, y): + rect = Gdk.Rectangle() + rect.x = x + rect.y = y + popup.set_pointing_to(rect) + popup.popup() + + def show_popup_at_tree_cursor(self, popup, treeview): + _, selected_paths = treeview.get_selection().get_selected_rows() + rect = treeview.get_cell_area(selected_paths[0], None) + self.show_popup_at(popup, 0, rect.y+2*rect.height) + def toggle_search(self, action, param): """Callback to toggle search bar.""" @@ -466,31 +461,22 @@ def on_search_toggled(self, widget=None): self.search_button.set_active(False) self.searchbar.set_search_mode(False) self.search_entry.set_text('') - self.get_selected_tree().unapply_filter(SEARCH_TAG) else: self.search_button.set_active(True) self.searchbar.set_search_mode(True) self.search_entry.grab_focus() - def _try_filter_by_query(self, query, refresh: bool = True): - log.debug("Searching for %r", query) - vtree = self.get_selected_tree() - try: - vtree.apply_filter(SEARCH_TAG, parse_search_query(query), - refresh=refresh) - except InvalidQuery as error: - log.debug("Invalid query %r: %r", query, error) - vtree.unapply_filter(SEARCH_TAG) def do_search(self): """Perform the actual search and cancel the timeout.""" - self._try_filter_by_query(self.search_entry.get_text()) + self.get_pane().set_search_query(self.search_entry.get_text()) GLib.source_remove(self.search_timeout) self.search_timeout = None + @Gtk.Template.Callback() def on_search(self, data): """Callback everytime a character is inserted in the search field.""" @@ -505,34 +491,10 @@ def on_search(self, data): def on_save_search(self, action, param): query = self.search_entry.get_text() + name = re.sub(r'!(?=\w)+', '', query) - # Try if this is a new search tag and save it correctly - tag_id = self.req.new_search_tag(query) - - # Apply new search right now - if self.tagtreeview is not None: - self.select_search_tag(tag_id) - else: - self.get_selected_tree().apply_filter(tag_id) - - def select_search_tag(self, tag_id): - tag = self.req.get_tag(tag_id) - """Select new search in tagsidebar and apply it""" - - # Make sure search tag parent is expanded - # (otherwise selection does not work) - self.expand_search_tag() - - # Get iterator for new search tag - model = self.tagtreeview.get_model() - path = self.tagtree.get_paths_for_node(tag.get_id())[0] - tag_iter = model.my_get_iter(path) + self.app.ds.saved_searches.new(name, query) - # Select only it and apply filters on top of that - selection = self.tagtreeview.get_selection() - selection.unselect_all() - selection.select_iter(tag_iter) - self.on_select_tag() def quit(self, widget=None, data=None): self.app.quit() @@ -561,67 +523,69 @@ def restore_collapsed_tasks(self, tasks=None): print(f"Invalid liblarch path {path}") def restore_state_from_conf(self): - # Extract state from configuration dictionary - # if "browser" not in self.config: - # #necessary to have the minimum width of the tag pane - # # inferior to the "first run" width - # self.builder.get_object("hpaned1").set_position(250) - # return - + # NOTE: for the window state to be restored, this must + # be called **before** the window is realized. The size + # of the window cannot manually be changed later. width = self.config.get('width') height = self.config.get('height') if width and height: - self.resize(width, height) + self.set_default_size(width, height) # checks for maximization window - self.connect('notify::is-maximized', self.on_maximize) + self.connect('notify::maximized', self.on_maximize) if self.config.get("maximized"): self.maximize() tag_pane = self.config.get("tag_pane") - self.sidebar.props.visible = tag_pane + self.sidebar_vbox.props.visible = tag_pane sidebar_width = self.config.get("sidebar_width") - self.builder.get_object("main_hpanes").set_position(sidebar_width) - self.builder.get_object("main_hpanes").connect('notify::position', - self.on_sidebar_width) + self.main_hpanes.set_position(sidebar_width) + self.main_hpanes.connect('notify::position', self.on_sidebar_width) # Callbacks for sorting and restoring previous state - model = self.vtree_panes['active'].get_model() - model.connect('sort-column-changed', self.on_sort_column_changed) - sort_column = self.config.get('tasklist_sort_column') - sort_order = self.config.get('tasklist_sort_order') + # model = self.vtree_panes['active'].get_model() + # model.connect('sort-column-changed', self.on_sort_column_changed) + # sort_column = self.config.get('tasklist_sort_column') + # sort_order = self.config.get('tasklist_sort_order') - if sort_column and sort_order: - sort_column, sort_order = int(sort_column), int(sort_order) - model.set_sort_column_id(sort_column, sort_order) + # if sort_column and sort_order: + # sort_column, sort_order = int(sort_column), int(sort_order) + # model.set_sort_column_id(sort_column, sort_order) - self.restore_collapsed_tasks() + # self.restore_collapsed_tasks() - view_name = PANE_STACK_NAMES_MAP_INVERTED.get(self.config.get('view'), - PANE_STACK_NAMES_MAP_INVERTED['active']) - self.stack_switcher.get_stack().set_visible_child_name(view_name) + # view_name = PANE_STACK_NAMES_MAP_INVERTED.get(self.config.get('view'), + # PANE_STACK_NAMES_MAP_INVERTED['active']) + # self.stack_switcher.get_stack().set_visible_child_name(view_name) - def open_task(req, t): - """ Open the task if loaded. Otherwise ask for next iteration """ - if req.has_task(t): - self.app.open_task(t) - return False - else: - return True + # def open_task(ds, tid): + # """ Open the task if loaded. Otherwise ask for next iteration """ + # try: + # task = ds.tasks.lookup[tid] + # self.app.open_task(task) + # return False + # except KeyError: + # return True - for t in self.config.get("opened_tasks"): - GLib.idle_add(open_task, self.req, t) + # for t in self.config.get("opened_tasks"): + # GLib.idle_add(open_task, self.app.ds, t) - def refresh_all_views(self, timer): - collapsed = self.config.get("collapsed_tasks") - for pane in 'active', 'workview', 'closed': - self.req.get_tasks_tree(pane, False).reset_filters(refresh=False) - self.reapply_filter() + def restore_editor_windows(self): + """Restore editor window for tasks.""" + + for tid in self.config.get("opened_tasks"): + try: + task = self.app.ds.tasks.lookup[tid] + self.app.open_task(task) + except KeyError: + log.warning("Could not restore task with id %s", tid) + - self.restore_collapsed_tasks(collapsed) + def refresh_all_views(self, timer): + self.get_pane().refresh() def find_value_in_treestore(self, store, treeiter, value): """Search for value in tree store recursively.""" @@ -653,8 +617,8 @@ def on_sort_column_changed(self, model): self.config.set('tasklist_sort_column', sort_column) self.config.set('tasklist_sort_order', sort_order) - def on_size_allocate(self, widget=None, data=None): - width, height = self.get_size() + def on_window_resize(self, widget=None, gparam=None): + width, height = self.get_default_size() self.config.set('width', width) self.config.set('height', height) @@ -667,7 +631,8 @@ def on_about_clicked(self, widget): """ self.about.show() - def on_about_close(self, widget, response): + @Gtk.Template.Callback() + def on_about_close(self, widget): """ close the about dialog """ @@ -677,12 +642,13 @@ def on_about_close(self, widget, response): def on_cursor_changed(self, widget=None): """Callback when the treeview's cursor changes.""" - if self.has_any_selection(): - self.defer_btn.set_sensitive(True) - self.defer_menu_btn.set_sensitive(True) - else: - self.defer_btn.set_sensitive(False) - self.defer_menu_btn.set_sensitive(False) + ... + # if self.has_any_selection(): + # self.defer_btn.set_sensitive(True) + # self.defer_menu_btn.set_sensitive(True) + # else: + # self.defer_btn.set_sensitive(False) + # self.defer_menu_btn.set_sensitive(False) def on_tagcontext_deactivate(self, menushell): self.reset_cursor() @@ -691,13 +657,13 @@ def _show_main_menu(self, action, param): """ Action callback to show the main menu. """ - main_menu_btn = self.builder.get_object('main_menu_btn') + main_menu_btn = self.main_menu_btn main_menu_btn.props.active = not main_menu_btn.props.active def on_sidebar_toggled(self, action, param): """Toggle tags sidebar via the action.""" - self.sidebar.props.visible = not self.sidebar.props.visible + self.sidebar_vbox.props.visible = not self.sidebar_vbox.props.visible def _on_sidebar_visible(self, obj, param): """Visibility of the sidebar changed.""" @@ -705,18 +671,18 @@ def _on_sidebar_visible(self, obj, param): assert param.name == 'visible' visible = obj.get_property(param.name) self.config.set("tag_pane", visible) - if visible and not self.tagtreeview: - self.init_tags_sidebar() def on_collapse_all_tasks(self, action, param): """Collapse all tasks.""" - self.vtree_panes['active'].collapse_all() + + self.get_pane().emit('collapse-all') def on_expand_all_tasks(self, action, param): """Expand all tasks.""" - self.vtree_panes['active'].expand_all() - def on_task_expanded(self, sender: liblarch_gtk.TreeView, path: str): + self.get_pane().emit('expand-all') + + def on_task_expanded(self, sender, path: str): # For some reason, path is turned from a tuple into a string of a # tuple if type(path) is str: @@ -781,13 +747,13 @@ def focus_quickentry(self, action, param): def focus_sidebar(self, action, param): """Callback to focus the sidebar widget.""" - self.sidebar.props.visible = True + self.sidebar_vbox.props.visible = True self.tagtreeview.grab_focus() - def on_quickadd_focus_in(self, widget, event): + def on_quickadd_focus_in(self, controller): self.toggle_delete_accel(False) - def on_quickadd_focus_out(self, widget, event): + def on_quickadd_focus_out(self, controller): self.toggle_delete_accel(True) def toggle_delete_accel(self, enable_delete_accel): @@ -797,100 +763,50 @@ def toggle_delete_accel(self, enable_delete_accel): accels = ['Delete'] if enable_delete_accel else [] self.app.set_accels_for_action('win.delete_task', accels) - def on_quickadd_activate(self, widget): + @Gtk.Template.Callback() + def on_quickadd_activate(self, widget) -> None: """ Add a new task from quickadd toolbar """ - text = str(self.quickadd_entry.get_text()) - text = text.strip() - if text: - tags = self.get_selected_tags(nospecial=True) - - # We will select quick-added task in browser. - # This has proven to be quite complex and deserves an explanation. - # We register a callback on the sorted treemodel that we're - # displaying, which is a TreeModelSort. When a row gets added, - # we're notified of it. - # We have to verify that that row belongs to the task we should - # select. So, we have to wait for the task to be created, and then - # wait for its tid to show up (invernizzi) - def select_next_added_task_in_browser(treemodelsort, path, iter, self): - # copy() is required because boxed structures are not copied - # when passed in a callback without transfer - # See https://bugzilla.gnome.org/show_bug.cgi?id=722899 - iter = iter.copy() - - def selecter(treemodelsort, path, iter, self): - self.__last_quick_added_tid_event.wait() - treeview = self.vtree_panes['active'] - treemodelsort.disconnect(self.__quick_add_select_handle) - selection = treeview.get_selection() - selection.unselect_all() - # Since we use iter for selection, - # the task selected is bound to be correct - selection.select_iter(iter) - - # It cannot be another thread than the main gtk thread ! - GLib.idle_add(selecter, treemodelsort, path, iter, self) - - data = quick_add.parse(text) - # event that is set when the new task is created - self.__last_quick_added_tid_event = threading.Event() - self.__quick_add_select_handle = \ - self.vtree_panes['active'].get_model().connect( - "row-inserted", select_next_added_task_in_browser, - self) - task = self.req.new_task(newtask=True) - self.__last_quick_added_tid = task.get_id() - self.__last_quick_added_tid_event.set() - - # Combine tags from selection with tags from parsed text - data['tags'].update(tags) - - if data['title'] != '': - task.set_title(data['title']) - task.set_to_keep() - - for tag in data['tags']: - task.add_tag(tag) - - task.set_start_date(data['start']) - task.set_due_date(data['due']) - - if data['recurring']: - task.set_recurring(True, data['recurring'], newtask=True) - - self.quickadd_entry.set_text('') - - # TODO: New Core - new_t = self.app.ds.tasks.new(data['title']) - new_t.date_start = data['start'] - new_t.date_due = data['due'] - new_t.id = task.tid - self.app.ds.tasks.refresh_lookup_cache() - - - for tag in data['tags']: - _tag = self.app.ds.tags.new(tag) - new_t.add_tag(_tag) - - # signal the event for the plugins to catch - GLib.idle_add(self.emit, "task-added-via-quick-add", task.get_id()) - else: - # if no text is selected, we open the currently selected task - nids = self.vtree_panes['active'].get_selected_nodes() - for nid in nids: - self.app.open_task(nid) + + text = self.quickadd_entry.get_text().strip() + + if not text: + tasks = self.get_pane().get_selection() + for t in tasks: + self.app.open_task(t) + + return + + tags = self.sidebar.selected_tags(names_only=True) + data = quick_add.parse(text) + + # Combine tags from selection with tags from parsed text + data['tags'].update(tags) + self.quickadd_entry.set_text('') + + task = self.app.ds.tasks.new(data['title']) + task.date_start = data['start'] + task.date_due = data['due'] + self.app.ds.tasks.refresh_lookup_cache() + + #TODO: Add back recurring + + for tag in data['tags']: + _tag = self.app.ds.tags.new(tag) + task.add_tag(_tag) + + # signal the event for the plugins to catch + GLib.idle_add(self.emit, "task-added-via-quick-add", task.id) + self.get_pane().select_last() + def on_tag_treeview_click_begin(self, gesture, sequence): """ deals with mouse click event on the tag tree """ - event = Gtk.get_current_event() + _, x, y = gesture.get_point(sequence) log.debug("Received button event #%d at %d, %d", - event.button, event.x, event.y) - if event.button.button == 3: - x = int(event.x) - y = int(event.y) - time = event.time + gesture.get_current_button(), x, y) + if gesture.get_current_button() == 3: pthinfo = self.tagtreeview.get_path_at_pos(x, y) if pthinfo is not None: path, col, cellx, celly = pthinfo @@ -916,20 +832,19 @@ def on_tag_treeview_click_begin(self, gesture, sequence): if selected_search is not None: my_tag = self.req.get_tag(selected_search) self.tagpopup.set_tag(my_tag) - self.tagpopup.popup(None, None, None, None, event.button.button, time) + self.show_popup_at(self.tagpopup, x, y) elif len(selected_tags) > 0: # Then we are looking at single, normal tag rather than # the special 'All tags' or 'Tasks without tags'. We only # want to popup the menu for normal tags. my_tag = self.req.get_tag(selected_tags[0]) self.tagpopup.set_tag(my_tag) - self.tagpopup.popup(None, None, None, None, event.button.button, time) + self.show_popup_at(self.tagpopup, x, y) else: self.reset_cursor() def on_tag_treeview_key_press_event(self, controller, keyval, keycode, state): keyname = Gdk.keyval_name(keyval) - event = Gtk.get_current_event() is_shift_f10 = (keyname == "F10" and state & Gdk.ModifierType.SHIFT_MASK) if is_shift_f10 or keyname == "Menu": selected_tags = self.get_selected_tags(nospecial=True) @@ -939,24 +854,26 @@ def on_tag_treeview_key_press_event(self, controller, keyval, keycode, state): # popup menu for searches if selected_search is not None: self.tagpopup.set_tag(selected_search) - self.tagpopup.popup(None, None, None, None, 0, event.time) + self.show_popup_at_tree_cursor(self.tagpopup, self.tagtreeview) elif len(selected_tags) > 0: # Then we are looking at single, normal tag rather than # the special 'All tags' or 'Tasks without tags'. We only # want to popup the menu for normal tags. selected_tag = self.req.get_tag(selected_tags[0]) self.tagpopup.set_tag(selected_tag) - self.tagpopup.popup(None, None, None, None, 0, event.time) + model, titer = self.tagtreeview.get_selection().get_selected() + rect = self.tagtreeview.get_cell_area(model.get_path(titer), None) + self.show_popup_at_tree_cursor(self.tagpopup, self.tagtreeview) else: self.reset_cursor() return True if keyname == "Delete": - self.on_delete_tag_activate(event) + self.on_delete_tag_activate() return True - def on_delete_tag_activate(self, event): - tags = self.get_selected_tags() - self.deletetags_dialog.delete_tags(tags) + def on_delete_tag_activate(self, tags=[]): + tags = tags or self.get_selected_tags() + self.deletetags_dialog.show(tags) def on_delete_tag(self, event): tags = self.get_selected_tags() @@ -977,14 +894,18 @@ def on_delete_tag(self, event): def on_task_treeview_click_begin(self, gesture, sequence): """ Pop up context menu on right mouse click in the main task tree view """ - event = Gtk.get_current_event() treeview = gesture.get_widget() + _, x, y = gesture.get_point(sequence) log.debug("Received button event #%s at %d,%d", - event.button, event.x, event.y) - if event.button.button == 3: - x = int(event.x) - y = int(event.y) - pthinfo = treeview.get_path_at_pos(x, y) + gesture.get_current_button(), x, y) + if gesture.get_current_button() == 3: + # Only when using a filtered treeview (you have selected a specific + # tag in tagtree), for some reason the standard coordinates become + # wrong and you must convert them. + # The original coordinates are still needed to put the popover + # in the correct place + tx, ty = treeview.convert_widget_to_bin_window_coords(x, y) + pthinfo = treeview.get_path_at_pos(tx, ty) if pthinfo is not None: path, col, cellx, celly = pthinfo selection = treeview.get_selection() @@ -997,24 +918,21 @@ def on_task_treeview_click_begin(self, gesture, sequence): self.app.action_enabled_changed('add_parent', True) if not self.have_same_parent(): self.app.action_enabled_changed('add_parent', False) - self.open_menu.popup_at_pointer(event) + self.show_popup_at(self.open_menu, x, y) def on_task_treeview_key_press_event(self, controller, keyval, keycode, state): keyname = Gdk.keyval_name(keyval) - event = Gtk.get_current_event() is_shift_f10 = (keyname == "F10" and state & Gdk.ModifierType.SHIFT_MASK) if is_shift_f10 or keyname == "Menu": - self.open_menu.popup_at_pointer(event) - return True + self.show_popup_at_tree_cursor(self.open_menu, controller.get_widget()) def on_closed_task_treeview_click_begin(self, gesture, sequence): - event = Gtk.get_current_event() treeview = gesture.get_widget() - if event.button.button == 3: - x = int(event.x) - y = int(event.y) - pthinfo = treeview.get_path_at_pos(x, y) + _, x, y = gesture.get_point(sequence) + if gesture.get_current_button() == 3: + tx, ty = treeview.convert_widget_to_bin_window_coords(x, y) + pthinfo = treeview.get_path_at_pos(tx, ty) if pthinfo is not None: path, col, cellx, celly = pthinfo @@ -1026,150 +944,103 @@ def on_closed_task_treeview_click_begin(self, gesture, sequence): treeview.set_cursor(path, col, 0) treeview.grab_focus() - self.closed_menu.popup_at_pointer(event) + self.show_popup_at(self.closed_menu, x, y) def on_closed_task_treeview_key_press_event(self, controller, keyval, keycode, state): keyname = Gdk.keyval_name(keyval) - event = Gtk.get_current_event() is_shift_f10 = (keyname == "F10" and state & Gdk.ModifierType.SHIFT_MASK) if is_shift_f10 or keyname == "Menu": - self.closed_menu.popup_at_pointer(event) - return True + self.show_popup_at_tree_cursor(self.closed_menu, controller.get_widget()) def on_add_task(self, widget=None): - tags = [tag for tag in self.get_selected_tags(nospecial=True)] - - task = self.req.new_task(tags=tags, newtask=True) - uid = task.get_id() - - # TODO: New core new_task = self.app.ds.tasks.new() - new_task.id = uid - for t in tags: - new_task.add_tag(self.app.ds.tags.new(t)) + for tag in self.sidebar.selected_tags(): + new_task.add_tag(tag) + + self.app.open_task(new_task) - self.app.open_task(uid, new=True) def on_add_subtask(self, widget=None): - uid = self.get_selected_task() - if uid: - zetask = self.req.get_task(uid) - tags = [t.get_name() for t in zetask.get_tags()] - task = self.req.new_task(tags=tags, newtask=True) - # task.add_parent(uid) - zetask.add_child(task.get_id()) - # if the parent task is recurring, its child must be also. - task.inherit_recursion() + pane = self.get_pane() - # TODO: New core - t = self.app.ds.tasks.new(parent=uid) - t.tags = self.app.ds.tasks.get(uid).tags + for task in pane.get_selection(): + new_task = self.app.ds.tasks.new(parent=task.id) + new_task.tags = task.tags + self.app.open_task(new_task) + pane.refresh() - self.app.open_task(task.get_id(), new=True) def on_add_parent(self, widget=None): - selected_tasks = self.get_selected_tasks() - first_task = self.req.get_task(selected_tasks[0]) - if len(selected_tasks): - parents = first_task.get_parents() - if parents: - # Switch parents - for p_tid in parents: - par = self.req.get_task(p_tid) - - # TODO: New core - new_p = self.app.ds.tasks.get(p_tid) - - if par.get_status() == Task.STA_ACTIVE: - new_parent = par.new_subtask() - nc_parent = self.app.ds.tasks.new(parent=p_tid) - nc_parent.id = new_parent.tid - - for uid_task in selected_tasks: - # Make sure the task doesn't get deleted - # while switching parents - self.req.get_task(uid_task).set_to_keep() - par.remove_child(uid_task) - new_parent.add_child(uid_task) - - # TODO: New Core - self.app.ds.tasks.refresh_lookup_cache() - self.app.ds.tasks.unparent(uid_task, p_tid) - self.app.ds.tasks.parent(uid_task, nc_parent.id) + selection = self.get_pane().get_selection() + + if not selection: + return + + parent = selection[0].parent - else: - # If the tasks have no parent already, no need to switch parents - new_parent = self.req.new_task(newtask=True) - for uid_task in selected_tasks: - new_parent.add_child(uid_task) - - # TODO: New Core - t = self.app.ds.tasks.new() - t.id = new_parent.tid + # Check all tasks have the same parent + if any(t.parent != parent for t in selection): + return + + if parent: + if parent.status == Status.ACTIVE: + new_parent = self.app.ds.tasks.new(parent=parent.id) + + for task in selection: self.app.ds.tasks.refresh_lookup_cache() - self.app.ds.tasks.parent(uid_task, new_parent.tid) + self.app.ds.tasks.unparent(task.id, parent.id) + self.app.ds.tasks.parent(task.id, new_parent.id) + else: + new_parent = self.app.ds.tasks.new() + + for task in selection: + self.app.ds.tasks.refresh_lookup_cache() + self.app.ds.tasks.parent(task.id, new_parent.id) + + self.app.open_task(new_parent) + self.get_pane().refresh() - self.app.open_task(new_parent.get_id(), new=True) def on_edit_active_task(self, widget=None, row=None, col=None): - tid = self.get_selected_task() - if tid: - self.app.open_task(tid) + for task in self.get_pane().get_selection(): + self.app.open_task(task) def on_edit_done_task(self, widget, row=None, col=None): tid = self.get_selected_task('closed') if tid: self.app.open_task(tid) + def on_delete_tasks(self, widget=None, tid=None): # If we don't have a parameter, then take the selection in the # treeview if not tid: - # tid_to_delete is a [project, task] tuple - tids_todelete = self.get_selected_tasks() - if not tids_todelete: + tasks_todelete = self.get_pane().get_selection() + + if not tasks_todelete: return else: - tids_todelete = [tid] + tasks_todelete = [self.ds.tasks.lookup[tid]] - log.debug("going to delete %r", tids_todelete) - self.app.delete_tasks(tids_todelete, self) + log.debug("going to delete %r", tasks_todelete) + self.app.delete_tasks(tasks_todelete, self) - # TODO: New core core - for tid in tids_todelete: - self.app.ds.tasks.remove(tid) def update_start_date(self, widget, new_start_date): - tasks = [self.req.get_task(uid) - for uid in self.get_selected_tasks() - if uid is not None] - - start_date = Date.parse(new_start_date) - - # FIXME:If the task dialog is displayed, refresh its start_date widget - for task in tasks: - task.set_start_date(start_date) - - # TODO: New core - for uid in self.get_selected_tasks(): - if uid: - t = self.app.ds.tasks.get(uid) - t.date_start = start_date + for task in self.get_pane().get_selection(): + task.date_start = new_start_date def update_start_to_next_day(self, day_number): """Update start date to N days from today.""" - tasks = [self.req.get_task(uid) - for uid in self.get_selected_tasks() - if uid is not None] - next_day = Date.today() + datetime.timedelta(days=day_number) - for task in tasks: - task.set_start_date(next_day) + for task in self.get_pane().get_selection(): + task.date_start = next_day + def on_mark_as_started(self, action, param): self.update_start_date(None, "today") @@ -1222,21 +1093,10 @@ def on_start_clear(self, action, param): self.update_start_date(None, None) def update_due_date(self, widget, new_due_date): - tasks = [self.req.get_task(uid) - for uid in self.get_selected_tasks() - if uid is not None] - due_date = Date.parse(new_due_date) - # FIXME: If the task dialog is displayed, refresh its due_date widget - for task in tasks: - task.set_due_date(due_date) - - # TODO: New core - for uid in self.get_selected_tasks(): - if uid: - t = self.app.ds.tasks.get(uid) - t.date_due = due_date + for task in self.get_pane().get_selection(): + task.date_due = due_date def on_set_due_today(self, action, param): self.update_due_date(None, "today") @@ -1293,18 +1153,11 @@ def on_set_due_for_specific_date(self, action, param): self.calendar.show() def update_recurring(self, recurring, recurring_term): - tasks = [self.req.get_task(uid) - for uid in self.get_selected_tasks() - if uid is not None] - - for task in tasks: + for task in self.get_pane().get_selection(): task.set_recurring(recurring, recurring_term, True) def update_toggle_recurring(self): - tasks = [self.req.get_task(uid) - for uid in self.get_selected_tasks() - if uid is not None] - for task in tasks: + for task in self.get_pane().get_selection(): task.toggle_recurring() def on_set_recurring_every_day(self, action, param): @@ -1339,9 +1192,52 @@ def on_date_changed(self, calendar): def on_modify_tags(self, action, params): """Open modify tags dialog for selected tasks.""" - tasks = self.get_selected_tasks() + tasks = self.get_pane().get_selection() self.modifytags_dialog.modify_tags(tasks) + + def on_sort_start(self, action, params) -> None: + """Callback when changing task sorting.""" + + self.get_pane().set_sorter('Start') + + + def on_sort_due(self, action, params) -> None: + """Callback when changing task sorting.""" + + self.get_pane().set_sorter('Due') + + + def on_sort_added(self, action, params) -> None: + """Callback when changing task sorting.""" + + self.get_pane().set_sorter('Added') + + + def on_sort_title(self, action, params) -> None: + """Callback when changing task sorting.""" + + self.get_pane().set_sorter('Title') + + + def on_sort_modified(self, action, params) -> None: + """Callback when changing task sorting.""" + + self.get_pane().set_sorter('Modified') + + + def on_sort_added(self, action, params) -> None: + """Callback when changing task sorting.""" + + self.get_pane().set_sorter('Added') + + + def on_sort_tags(self, action, params) -> None: + """Callback when changing task sorting.""" + + self.get_pane().set_sorter('Tags') + + def close_all_task_editors(self, task_id): """ Including editors of subtasks """ all_subtasks = [] @@ -1355,103 +1251,20 @@ def trace_subtasks(root): trace_subtasks(self.req.get_task(task_id)) for task in all_subtasks: - self.app.close_task(task.get_id()) + self.app.close_task(task.id) - def on_mark_as_done(self, widget=None): - tasks_uid = [uid for uid in self.get_selected_tasks() - if uid is not None] - if len(tasks_uid) == 0: - return - tasks = [self.req.get_task(uid) for uid in tasks_uid] - tasks_status = [task.get_status() for task in tasks] - for uid, task, status in zip(tasks_uid, tasks, tasks_status): - if status == Task.STA_DONE: - # Marking as undone - task.set_status(Task.STA_ACTIVE) - GObject.idle_add(self.emit, "task-marked-as-not-done", task.get_id()) - # Parents of that task must be updated - not to be shown - # in workview, update children count, etc. - for parent_id in task.get_parents(): - parent = self.req.get_task(parent_id) - parent.modified() - else: - task.set_status(Task.STA_DONE) - self.close_all_task_editors(uid) - GObject.idle_add(self.emit, "task-marked-as-done", task.get_id()) - # TODO: New core - for uid in tasks_uid: - t = self.app.ds.tasks.get(uid) - t.toggle_active() + def on_mark_as_done(self, widget=None): + for task in self.get_pane().get_selection(): + task.toggle_active() def on_dismiss_task(self, widget=None): - tasks_uid = [uid for uid in self.get_selected_tasks() - if uid is not None] - if len(tasks_uid) == 0: - return - tasks = [self.req.get_task(uid) for uid in tasks_uid] - tasks_status = [task.get_status() for task in tasks] - for uid, task, status in zip(tasks_uid, tasks, tasks_status): - if status == Task.STA_DISMISSED: - task.set_status(Task.STA_ACTIVE) - else: - task.set_status(Task.STA_DISMISSED) - self.close_all_task_editors(uid) - - # TODO: New core - for uid in tasks_uid: - t = self.app.ds.tasks.get(uid) - t.toggle_dismiss() - + for task in self.get_pane().get_selection(): + task.toggle_dismiss() def on_reopen_task(self, widget=None): - tasks_uid = [uid for uid in self.get_selected_tasks() - if uid is not None] - tasks = [self.req.get_task(uid) for uid in tasks_uid] - tasks_status = [task.get_status() for task in tasks] - for uid, task, status in zip(tasks_uid, tasks, tasks_status): - if status == Task.STA_DONE: - task.set_status(Task.STA_ACTIVE) - GObject.idle_add(self.emit, "task-marked-as-not-done", task.get_id()) - # Parents of that task must be updated - not to be shown - # in workview, update children count, etc. - for parent_id in task.get_parents(): - parent = self.req.get_task(parent_id) - parent.modified() - elif status == Task.STA_DISMISSED: - task.set_status(Task.STA_ACTIVE) - - # TODO: New core - for uid in tasks_uid: - t = self.app.ds.tasks.get(uid) - t.toggle_active() - - - def reapply_filter(self, current_pane: str = None): - if current_pane is None: - current_pane = self.get_selected_pane() - filters = self.get_selected_tags() - filters.append(current_pane) - vtree = self.req.get_tasks_tree(name=current_pane, refresh=False) - # Re-applying search if some search is specified - search = self.search_entry.get_text() - if search: - filters.append(SEARCH_TAG) - # only resetting filters if the applied filters are different from - # current ones, leaving a chance for liblarch to make the good call on - # whether to refilter or not - if sorted(filters) != sorted(vtree.list_applied_filters()): - vtree.reset_filters(refresh=False) - # Browsing and applying filters. For performance optimization, only - # allowing liblarch to trigger a refresh on last item. This way the - # refresh is never triggered more than once and we let the possibility - # to liblarch not to trigger refresh is filters did not change. - for filter_name in filters: - is_last = filter_name == filters[-1] - if filter_name == SEARCH_TAG: - self._try_filter_by_query(search, refresh=is_last) - else: - vtree.apply_filter(filter_name, refresh=is_last) + for task in self.get_pane().get_selection(): + task.toggle_active() def on_select_tag(self, widget=None, row=None, col=None): """ Callback for tag(s) selection from left sidebar. @@ -1473,11 +1286,40 @@ def on_pane_switch(self, obj, pspec): """ current_pane = self.get_selected_pane() self.config.set('view', current_pane) - self.reapply_filter(current_pane) + + # HACK: We expand all the tasks in the open tab + # so their subtasks "exist" when switching + # to actionable + self.stack_switcher.get_stack().get_first_child().get_first_child().emit('expand-all') + + self.get_pane().set_filter_tags(set(self.sidebar.selected_tags())) + self.sidebar.change_pane(current_pane) + self.get_pane().sort_btn.set_popover(None) + self.get_pane().sort_btn.set_popover(self.sort_menu) + + if search_query := self.search_entry.get_text(): + self.get_pane().set_search_query(search_query) + + self.notify('is_pane_open') + self.notify('is_pane_actionable') + self.notify('is_pane_closed') # PUBLIC METHODS ########################################################### + def get_menu(self): + """Get the primary application menu""" + return self.main_menu + + def get_headerbar(self): + """Get the headerbar for the window""" + return self.headerbar + + def get_quickadd_pane(self): + """Get the quickadd pane""" + return self.quickadd_pane + def have_same_parent(self): """Determine whether the selected tasks have the same parent""" + selected_tasks = self.get_selected_tasks() first_task = self.req.get_task(selected_tasks[0]) parents = first_task.get_parents() @@ -1503,6 +1345,28 @@ def get_selected_pane(self): return PANE_STACK_NAMES_MAP[current] + + def get_pane(self): + """Get the selected pane.""" + + return self.stack_switcher.get_stack().get_visible_child().get_first_child() + + + @GObject.Property(type=bool, default=True) + def is_pane_open(self) -> bool: + return self.get_selected_pane() == 'active' + + + @GObject.Property(type=bool, default=False) + def is_pane_actionable(self) -> bool: + return self.get_selected_pane() == 'workview' + + + @GObject.Property(type=bool, default=False) + def is_pane_closed(self) -> bool: + return self.get_selected_pane() == 'closed' + + def get_selected_tree(self, refresh: bool = False): return self.req.get_tasks_tree(name=self.get_selected_pane(), refresh=refresh) @@ -1620,30 +1484,6 @@ def add_page_to_sidebar_notebook(self, icon, page): """ return self._add_page(self.sidebar_notebook, icon, page) - def add_page_to_main_notebook(self, title, page): - """Adds a new page tab to the top right main panel. The tab - will be added as the last tab. Also causes the tabs to be - shown. - @param title: Short text to use for the tab label - @param page: Gtk.Frame-based panel to be added - """ - return self._add_page(self.main_notebook, Gtk.Label(label=title), page) - - def remove_page_from_sidebar_notebook(self, page): - """Removes a new page tab from the left panel. If this leaves - only one tab in the notebook, the tab selector will be hidden. - @param page: Gtk.Frame-based panel to be removed - """ - return self._remove_page(self.sidebar_notebook, page) - - def remove_page_from_main_notebook(self, page): - """Removes a new page tab from the top right main panel. If - this leaves only one tab in the notebook, the tab selector will - be hidden. - @param page: Gtk.Frame-based panel to be removed - """ - return self._remove_page(self.main_notebook, page) - def hide(self): """ Hides the task browser """ self.browser_shown = False @@ -1673,9 +1513,6 @@ def is_active(self): return self.get_property("is-active") or self.menu.is_visible() - def get_builder(self): - return self.builder - def get_window(self): return self @@ -1738,7 +1575,7 @@ def remove_backend_infobar(self, sender, backend_id): @param backend_id: the id of the backend which Gtk.Infobar should be removed. """ - backend = self.req.get_backend(backend_id) + backend = self.app.ds.get_backend(backend_id) if not backend or (backend and backend.is_enabled()): # remove old infobar related to backend_id, if any if self.vbox_toolbars: @@ -1771,12 +1608,3 @@ def get_selected_search(self): if tag.is_search_tag(): return tags[0] return None - - def expand_search_tag(self): - """ For some unknown reason, search tag is not expanded correctly and - it must be done manually """ - if self.tagtreeview is not None: - model = self.tagtreeview.get_model() - search_iter = model.my_get_iter((SEARCH_TAG, )) - search_path = model.get_path(search_iter) - self.tagtreeview.expand_row(search_path, False) diff --git a/GTG/gtk/browser/modify_tags.py b/GTG/gtk/browser/modify_tags.py index 8af823ce35..fbd6e8a1ba 100644 --- a/GTG/gtk/browser/modify_tags.py +++ b/GTG/gtk/browser/modify_tags.py @@ -21,97 +21,97 @@ from gettext import gettext as _ from GTG.gtk.browser import GnomeConfig -from GTG.core.tag import parse_tag_list +@Gtk.Template(filename=GnomeConfig.MODIFYTAGS_UI_FILE) -class ModifyTagsDialog(): +class ModifyTagsDialog(Gtk.Dialog): """ Dialog for batch adding/removal of tags """ - def __init__(self, tag_completion, req, app): - self.req = req + __gtype_name__ = "ModifyTagsDialog" + + _tag_entry = Gtk.Template.Child() + _apply_to_subtasks_check = Gtk.Template.Child() + + def __init__(self, tag_completion, app): + super().__init__() + self.app = app self.tasks = [] - self._init_dialog() - self.tag_entry.set_completion(tag_completion) + self._tag_entry.set_completion(tag_completion) # Rember values from last time self.last_tag_entry = _("NewTag") self.last_apply_to_subtasks = False - def _init_dialog(self): - """ Init GtkBuilder .ui file """ - builder = Gtk.Builder() - builder.add_from_file(GnomeConfig.MODIFYTAGS_UI_FILE) - builder.connect_signals({ - "on_modifytags_confirm": - self.on_confirm, - "on_modifytags_cancel": - lambda dialog: dialog.hide, - }) - - self.tag_entry = builder.get_object("tag_entry") - self.apply_to_subtasks = builder.get_object("apply_to_subtasks") - self.dialog = builder.get_object("modifytags_dialog") + + def parse_tag_list(self, text): + """ Parse a line of a list of tasks. User can specify if the tag is + positive or not by prepending '!'. + + @param text: string entry from user + @return: list of tupples (tag, is_positive) + """ + + result = [] + for tag in text.split(): + if tag.startswith('!'): + tag = tag[1:] + is_positive = False + else: + is_positive = True + + result.append((tag, is_positive)) + return result + def modify_tags(self, tasks): """ Show and run dialog for selected tasks """ - if len(tasks) == 0: + + if not tasks: return self.tasks = tasks - self.tag_entry.set_text(self.last_tag_entry) - self.tag_entry.grab_focus() - self.apply_to_subtasks.set_active(self.last_apply_to_subtasks) + self._tag_entry.set_text(self.last_tag_entry) + self._tag_entry.grab_focus() + self._apply_to_subtasks_check.set_active(self.last_apply_to_subtasks) - self.dialog.run() - self.dialog.hide() + self.show() - self.tasks = [] + @Gtk.Template.Callback() + def on_response(self, widget, response): + if response == Gtk.ResponseType.APPLY: + self.apply_changes() + + self.hide() - def on_confirm(self, widget): + def apply_changes(self): """ Apply changes """ - tags = parse_tag_list(self.tag_entry.get_text()) - # If the checkbox is checked, find all subtasks - if self.apply_to_subtasks.get_active(): - for task_id in self.tasks: - task = self.req.get_task(task_id) - # FIXME: Python not reinitialize the default value of its - # parameter therefore it must be done manually. This function - # should be refractored # as far it is marked as depricated - for subtask in task.get_subtasks(): - subtask_id = subtask.get_id() - if subtask_id not in self.tasks: - self.tasks.append(subtask_id) - - for task_id in self.tasks: - task = self.req.get_task(task_id) - for tag, is_positive in tags: - if is_positive: - task.add_tag(tag) - else: - task.remove_tag(tag) - task.sync() + tags = self.parse_tag_list(self._tag_entry.get_text()) - # TODO: New Core - for tid in self.tasks: - t = self.app.ds.tasks.get(tid) + # If the checkbox is checked, find all subtasks + if self._apply_to_subtasks_check.get_active(): + for task in self.tasks: + for subtask in task.children: + if subtask not in self.tasks: + self.tasks.append(subtask) + for task in self.tasks: for tag, is_positive in tags: _tag = self.app.ds.tags.new(tag) if is_positive: - t.add_tag(_tag) + task.add_tag(_tag) else: - t.remove_tag(_tag) + task.remove_tag(_tag.name) self.app.ds.save() + self.app.ds.tasks.notify('task_count_no_tags') # Rember the last actions - self.last_tag_entry = self.tag_entry.get_text() - self.last_apply_to_subtasks = self.apply_to_subtasks.get_active() -# ----------------------------------------------------------------------------- + self.last_tag_entry = self._tag_entry.get_text() + self.last_apply_to_subtasks = self._apply_to_subtasks_check.get_active() diff --git a/GTG/gtk/browser/search_editor.py b/GTG/gtk/browser/search_editor.py new file mode 100644 index 0000000000..9327ddc1e1 --- /dev/null +++ b/GTG/gtk/browser/search_editor.py @@ -0,0 +1,242 @@ +# ----------------------------------------------------------------------------- +# Getting Things GNOME! - a personal organizer for the GNOME desktop +# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, see . +# ----------------------------------------------------------------------------- + +""" +This module contains the SearchEditor class which is a window that allows the +user to edit a saved search properties. +""" +from gi.repository import GObject, Gtk, Gdk, GdkPixbuf, GLib + +import logging +import random +from gettext import gettext as _ +from GTG.gtk.browser import GnomeConfig + +log = logging.getLogger(__name__) + + +@Gtk.Template(filename=GnomeConfig.SEARCH_EDITOR_UI_FILE) +class SearchEditor(Gtk.Dialog): + """ + A window to edit certain properties of a saved search. + """ + + __gtype_name__ = 'GTG_SearchEditor' + _emoji_chooser = Gtk.Template.Child('emoji-chooser') + _icon_button = Gtk.Template.Child('icon-button') + _name_entry = Gtk.Template.Child('name-entry') + + def __init__(self, app, search=None): + super().__init__(use_header_bar=1) + + set_icon_shortcut = Gtk.Shortcut.new( + Gtk.ShortcutTrigger.parse_string("I"), + Gtk.CallbackAction.new(self._set_icon)) + + self.add_shortcut(set_icon_shortcut) + + self.app = app + self.search = search + + self.set_transient_for(app.browser) + self._title_format = self.get_title() + self._emoji_chooser.set_parent(self._icon_button) + + self.is_valid = True + self._emoji = None + self.use_icon = False + self._search_name = '' + + self.set_search(search) + self.show() + + + @GObject.Property(type=str, default='') + def search_name(self): + """The (new) name of the search.""" + + return self._search_name + + @search_name.setter + def search_name(self, value: str): + self._search_name = value + self._validate() + + @GObject.Property(type=str, default='') + def search_query(self): + """The (new) name of the search.""" + + return self._search_query + + @search_query.setter + def search_query(self, value: str): + self._search_query = value + self._validate() + + @GObject.Property(type=bool, default=True) + def is_valid(self): + """ + Whenever it is valid to apply the changes (like malformed search name). + """ + + return self._is_valid + + @is_valid.setter + def is_valid(self, value: bool): + self._is_valid = value + + @GObject.Property(type=bool, default=False) + def has_icon(self): + """ + Whenever the search will have an icon. + """ + + return bool(self._emoji) + + def _validate(self): + """ + Validates the current search preferences. + Returns true whenever it passes validation, False otherwise, + and modifies the is_valid property appropriately. + On failure, the widgets are modified accordingly to show the user + why it doesn't accept it. + """ + + valid = True + valid &= self._validate_search_name() + self.is_valid = valid + return valid + + def _validate_search_name(self): + """ + Validates the current search name. + Returns true whenever it passes validation, False otherwise. + On failure, the widgets are modified accordingly to show the user + why it doesn't accept it. + """ + + if self.search_name == '': + self._name_entry.add_css_class("error") + self._name_entry.props.tooltip_text = \ + _("search name can not be empty") + return False + else: + self._name_entry.remove_css_class("error") + self._name_entry.props.tooltip_text = "" + return True + + def set_search(self, search): + """ + Set the search to edit. + Widgets are updated with the information of the search, + the previous information/state is lost. + """ + self.search = search + if search is None: + return + + icon = search.icon + self._set_emoji(self._emoji_chooser, text=icon if icon else '') + + self.search_name = search.name + self.search_query = search.query + self.set_title(self._title_format % self.search_name) + + + def do_destroy(self): + + self.app.close_search_editor() + super().destroy() + + def _cancel(self): + """ + Cancel button has been clicked, closing the editor window without + applying changes. + """ + + self.destroy() + + def _apply(self): + """ + Apply button has been clicked, applying the settings and closing the + editor window. + """ + if self.search is None: + log.warning("Trying to apply but no search set, shouldn't happen") + self._cancel() + return + + if self.has_icon and self._emoji: + self.search.icon = self._emoji + elif self.has_icon: # Should never happen, but just in case + log.warning("Tried to set icon for %r but no icon given", + self.search.name) + self.search.icon = None + else: + self.search.icon = None + + if self.search_name != self.search.name: + log.debug("Renaming %r → %r", self.search.name, self.search_name) + self.search.name = self.search_name + + self.app.ds.saved_searches.model.emit('items-changed', 0, 0, 0) + self.destroy() + + # CALLBACKS ##### + @Gtk.Template.Callback('response') + def _response(self, widget: GObject.Object, response: Gtk.ResponseType): + if response == Gtk.ResponseType.APPLY: + self._apply() + else: + self._cancel() + + + @Gtk.Template.Callback('set_icon') + def _set_icon(self, widget: GObject.Object, shargs: GLib.Variant = None): + """ + Button to set the icon/emoji has been clicked. + """ + self._emoji_chooser.popup() + + @Gtk.Template.Callback('emoji_set') + def _set_emoji(self, widget: GObject.Object, text: str = None): + """ + Callback when an emoji has been inserted. + """ + self._emoji = text if text else None + + if text: + self._emoji = text + self._icon_button.set_label(text) + if label := self._icon_button.get_child(): + label.set_opacity(1) + else: + self._emoji = None + self._icon_button.set_label('🏷️') + if label := self._icon_button.get_child(): + label.set_opacity(0.4) + + self.notify('has-icon') + + @Gtk.Template.Callback('remove_icon') + def _remove_icon(self, widget: GObject.Object): + """ + Callback to remove the icon. + """ + + self._set_emoji(self._emoji_chooser, text='') diff --git a/GTG/gtk/browser/sidebar.py b/GTG/gtk/browser/sidebar.py new file mode 100644 index 0000000000..f23fe08c62 --- /dev/null +++ b/GTG/gtk/browser/sidebar.py @@ -0,0 +1,658 @@ +# ----------------------------------------------------------------------------- +# Getting Things GNOME! - a personal organizer for the GNOME desktop +# Copyright (c) - The GTG Team +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, see . +# ----------------------------------------------------------------------------- + +"""Sidebar widgets.""" + +from gi.repository import Gtk, GObject, Gdk, Gio + +from GTG.core.tags import Tag +from GTG.core.tasks import Task +from GTG.core.filters import TagEmptyFilter +from GTG.core.saved_searches import SavedSearch +from GTG.core.datastore import Datastore +from GTG.gtk.browser.sidebar_context_menu import TagContextMenu, SearchesContextMenu +from GTG.gtk.browser.tag_pill import TagPill + + +class TagBox(Gtk.Box): + """Box subclass to keep a pointer to the tag object""" + + tag = GObject.Property(type=Tag) + + +class SearchBox(Gtk.Box): + """Box subclass to keep a pointer to the search object""" + + search = GObject.Property(type=SavedSearch) + + +def unwrap(row, expected_type): + """Find an item in TreeRow widget (sometimes nested).""" + + item = row.get_item() + + while type(item) is not expected_type: + item = item.get_item() + + return item + + +# Shorthands +BIND_FLAGS = GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE +signal_block = GObject.signal_handler_block + + +class Sidebar(Gtk.ScrolledWindow): + """The sidebar widget""" + + def __init__(self, app, ds: Datastore, browser): + + super(Sidebar, self).__init__() + self.ds = ds + self.app = app + self.browser = browser + + wrap_box = Gtk.Box() + wrap_box.set_orientation(Gtk.Orientation.VERTICAL) + wrap_box.set_vexpand(True) + wrap_box.set_hexpand(True) + + # ------------------------------------------------------------------------------- + # General Filters + # ------------------------------------------------------------------------------- + + self.general_box = Gtk.ListBox() + self.general_box.get_style_context().add_class('navigation-sidebar') + + all_count = str(ds.task_count['open']['all']) + untag_count = str(ds.task_count['open']['untagged']) + + self.all_btn = self.btn_item('emblem-documents-symbolic', 'All Tasks', 'task_count_all') + self.none_btn = self.btn_item('task-past-due-symbolic', 'Tasks with no tags', 'task_count_no_tags') + + self.general_box.append(self.all_btn) + self.general_box.append(self.none_btn) + wrap_box.append(self.general_box) + + separator = Gtk.Separator() + separator.set_sensitive(False) + wrap_box.append(separator) + + self.general_box.select_row(self.general_box.get_row_at_index(0)) + self.box_handle = self.general_box.connect('row-selected', self.on_general_box_selected) + + # ------------------------------------------------------------------------------- + # Saved Searches Section + # ------------------------------------------------------------------------------- + searches_btn_box = Gtk.Box() + searches_button = Gtk.Button() + searches_button_label = Gtk.Label() + searches_button_label.set_markup('Saved Searches') + searches_button_label.set_xalign(0) + searches_button.get_style_context().add_class('flat') + + searches_button.set_margin_top(6) + searches_button.set_margin_start(6) + searches_button.set_margin_end(6) + + searches_icon = Gtk.Image.new_from_icon_name('folder-saved-search-symbolic') + searches_icon.set_margin_end(6) + searches_btn_box.append(searches_icon) + searches_btn_box.append(searches_button_label) + searches_button.set_child(searches_btn_box) + searches_button.connect('clicked', self.on_search_reveal) + + self.searches_selection = Gtk.SingleSelection.new(ds.saved_searches.model) + self.searches_selection.set_can_unselect(True) + self.searches_selection.unselect_item(0) + self.search_handle = self.searches_selection.connect('selection-changed', self.on_search_selected) + + searches_signals = Gtk.SignalListItemFactory() + searches_signals.connect('setup', self.searches_setup_cb) + searches_signals.connect('bind', self.searches_bind_cb) + searches_signals.connect('unbind', self.searches_unbind_cb) + + searches_view = Gtk.ListView.new(self.searches_selection, searches_signals) + searches_view.get_style_context().add_class('navigation-sidebar') + searches_view.set_hexpand(True) + + self.searches_revealer = Gtk.Revealer() + self.searches_revealer.set_child(searches_view) + self.searches_revealer.set_reveal_child(True) + + # ------------------------------------------------------------------------------- + # Tags Section + # ------------------------------------------------------------------------------- + self.tags_filter = TagEmptyFilter(ds, 'open') + self.filtered_tags = Gtk.FilterListModel() + self.filtered_tags.set_model(ds.tags.tree_model) + self.filtered_tags.set_filter(self.tags_filter) + + self.tag_selection = Gtk.MultiSelection.new(self.filtered_tags) + self.tag_handle = self.tag_selection.connect('selection-changed', self.on_tag_selected) + + tags_signals = Gtk.SignalListItemFactory() + tags_signals.connect('setup', self.tags_setup_cb) + tags_signals.connect('bind', self.tags_bind_cb) + tags_signals.connect('unbind', self.tags_unbind_cb) + + view = Gtk.ListView.new(self.tag_selection, tags_signals) + view.get_style_context().add_class('navigation-sidebar') + view.set_vexpand(True) + view.set_hexpand(True) + + view_drop = Gtk.DropTarget.new(Tag, Gdk.DragAction.COPY) + view_drop.connect("drop", self.on_toplevel_tag_drop) + view.add_controller(view_drop) + + self.revealer = Gtk.Revealer() + self.revealer.set_child(view) + self.revealer.set_reveal_child(True) + + btn_box = Gtk.Box() + button = Gtk.Button() + button_label = Gtk.Label() + button_label.set_markup('Tags') + button_label.set_xalign(0) + button.get_style_context().add_class('flat') + + button.set_margin_top(6) + button.set_margin_start(6) + button.set_margin_end(6) + + tags_icon = Gtk.Image.new_from_icon_name('view-list-symbolic') + tags_icon.set_margin_end(6) + btn_box.append(tags_icon) + btn_box.append(button_label) + button.set_child(btn_box) + button.connect('clicked', self.on_tag_reveal) + + self.expanders = set() + + # ------------------------------------------------------------------------------- + # Bring everything together + # ------------------------------------------------------------------------------- + + wrap_box.append(searches_button) + wrap_box.append(self.searches_revealer) + wrap_box.append(button) + wrap_box.append(self.revealer) + self.set_child(wrap_box) + + + def refresh_tags(self) -> None: + """Refresh tags list.""" + + self.filtered_tags.items_changed(0, 0, 0) + self.tags_filter.changed(Gtk.FilterChange.DIFFERENT) + + + def change_pane(self, pane: str) -> None: + """Change pane for the tag list.""" + + self.tags_filter.pane = pane + self.tags_filter.changed(Gtk.FilterChange.DIFFERENT) + + + def on_tag_RMB_click(self, gesture, sequence) -> None: + """Callback when right-clicking on a tag.""" + + menu = TagContextMenu(self.ds, self.app, gesture.get_widget().tag) + menu.set_parent(gesture.get_widget()) + menu.set_halign(Gtk.Align.START) + menu.set_position(Gtk.PositionType.BOTTOM) + + point = gesture.get_point(sequence) + rect = Gdk.Rectangle() + rect.x = point.x + rect.y = point.y + menu.set_pointing_to(rect) + menu.popup() + + + def on_searches_RMB_click(self, gesture, sequence) -> None: + """Callback when right-clicking on a search.""" + + menu = SearchesContextMenu(self.ds, self.app, gesture.get_widget().search) + menu.set_parent(gesture.get_widget()) + menu.set_halign(Gtk.Align.START) + menu.set_position(Gtk.PositionType.BOTTOM) + + point = gesture.get_point(sequence) + rect = Gdk.Rectangle() + rect.x = point.x + rect.y = point.y + menu.set_pointing_to(rect) + menu.popup() + + + def btn_item(self, icon_name:str, text: str, count_prop: str) -> Gtk.Box: + """Generate a button for the main listbox.""" + + box = Gtk.Box() + + icon = Gtk.Image.new_from_icon_name(icon_name) + icon.set_margin_end(6) + box.append(icon) + + label = Gtk.Label() + label.set_halign(Gtk.Align.START) + label.set_hexpand(True) + label.set_text(text) + box.append(label) + + count_label = Gtk.Label() + count_label.set_halign(Gtk.Align.START) + count_label.add_css_class('dim-label') + box.append(count_label) + + self.ds.tasks.bind_property(count_prop, count_label, 'label', BIND_FLAGS) + + return box + + + def tags_setup_cb(self, factory, listitem, user_data=None) -> None: + """Setup for a row in the tags listview""" + + box = TagBox() + label = Gtk.Label() + expander = Gtk.TreeExpander() + icon = Gtk.Label() + color = TagPill() + open_count_label = Gtk.Label() + actionable_count_label = Gtk.Label() + closed_count_label = Gtk.Label() + + expander.set_margin_end(6) + expander.add_css_class('arrow-only-expander') + icon.set_margin_end(6) + color.set_margin_end(6) + color.set_size_request(16, 16) + + color.set_valign(Gtk.Align.CENTER) + color.set_halign(Gtk.Align.CENTER) + color.set_vexpand(False) + label.set_halign(Gtk.Align.START) + label.set_hexpand(True) + + open_count_label.set_halign(Gtk.Align.START) + open_count_label.add_css_class('dim-label') + open_count_label.set_text('0') + + actionable_count_label.set_halign(Gtk.Align.START) + actionable_count_label.add_css_class('dim-label') + actionable_count_label.set_text('0') + + closed_count_label.set_halign(Gtk.Align.START) + closed_count_label.add_css_class('dim-label') + closed_count_label.set_text('0') + + + # Drag ... + source = Gtk.DragSource() + source.connect('prepare', self.prepare) + source.connect('drag-begin', self.drag_begin) + source.connect('drag-end', self.drag_end) + box.add_controller(source) + + # ... and drop + drop = Gtk.DropTarget.new(Tag, Gdk.DragAction.COPY) + drop.connect('drop', self.drag_drop) + drop.connect('enter', self.drop_enter) + box.add_controller(drop) + + task_drop = Gtk.DropTarget.new(Task, Gdk.DragAction.COPY) + task_drop.connect('drop', self.task_drag_drop) + task_drop.connect('enter', self.drop_enter) + box.add_controller(task_drop) + + multi_task_drop = Gtk.DropTarget.new(Gio.ListModel, Gdk.DragAction.COPY) + multi_task_drop.connect('drop', self.multi_task_drag_drop) + multi_task_drop.connect('enter', self.drop_enter) + box.add_controller(multi_task_drop) + + + box.append(expander) + box.append(color) + box.append(icon) + box.append(label) + box.append(open_count_label) + box.append(actionable_count_label) + box.append(closed_count_label) + listitem.set_child(box) + + # Right click event controller + tags_RMB_controller = Gtk.GestureSingle(button=Gdk.BUTTON_SECONDARY) + tags_RMB_controller.connect('begin', self.on_tag_RMB_click) + box.add_controller(tags_RMB_controller) + + self.expanders.add(expander) + + + def tags_bind_cb(self, signallistitem, listitem, user_data=None) -> None: + """Bind properties for a specific row in the tags listview""" + + box = listitem.get_child() + expander = box.get_first_child() + color = expander.get_next_sibling() + icon = color.get_next_sibling() + label = icon.get_next_sibling() + + open_count_label = label.get_next_sibling() + actionable_count_label = open_count_label.get_next_sibling() + closed_count_label = actionable_count_label.get_next_sibling() + + item = unwrap(listitem, Tag) + + box.props.tag = item + expander.set_list_row(listitem.get_item()) + + listitem.bindings = [ + item.bind_property('name', label, 'label', BIND_FLAGS), + item.bind_property('icon', icon, 'label', BIND_FLAGS), + item.bind_property('color', color, 'color_list', BIND_FLAGS), + + item.bind_property('has_icon', color, 'visible', BIND_FLAGS, lambda b, v: not v), + item.bind_property('has_icon', icon, 'visible', BIND_FLAGS), + + item.bind_property('task_count_open', open_count_label, 'label', BIND_FLAGS), + item.bind_property('task_count_actionable', actionable_count_label, 'label', BIND_FLAGS), + item.bind_property('task_count_closed', closed_count_label, 'label', BIND_FLAGS), + ] + + self.browser.bind_property('is_pane_open', open_count_label, 'visible', BIND_FLAGS) + self.browser.bind_property('is_pane_actionable', actionable_count_label, 'visible', BIND_FLAGS) + self.browser.bind_property('is_pane_closed', closed_count_label, 'visible', BIND_FLAGS) + + + if item.parent: + parent = item + depth = 0 + + while parent.parent: + depth += 1 + parent = parent.parent + + box.set_margin_start((18 * depth) + 16) + else: + box.set_margin_start(18) + + if not item.children: + expander.set_visible(False) + else: + expander.set_visible(True) + + + def tags_unbind_cb(self, signallistitem, listitem, user_data=None) -> None: + """Clean up bindings made in tags_bind_cb""" + for binding in listitem.bindings: + binding.unbind() + + + def unselect_tags(self) -> None: + """Clear tags selection""" + + with signal_block(self.tag_selection, self.tag_handle): + self.tag_selection.unselect_all() + + + def unselect_searches(self) -> None: + """Clear selection for saved searches""" + + with signal_block(self.searches_selection, self.search_handle): + search_id = self.searches_selection.get_selected() + self.searches_selection.unselect_item(search_id) + + + def unselect_general_box(self) -> None: + """Clear selection for saved searches""" + + with signal_block(self.general_box, self.box_handle): + self.general_box.unselect_all() + + + def on_general_box_selected(self, listbox, user_data=None): + """Callback when clicking on a row in the general listbox""" + + self.unselect_tags() + self.unselect_searches() + index = listbox.get_selected_row().get_index() + self.app.browser.get_pane().emit('expand-all') + + if index == 0: + self.app.browser.get_pane().set_filter_tags() + elif index == 1: + self.app.browser.get_pane().set_filter_notags() + + + def on_search_selected(self, model, position, user_data=None): + """Callback when selecting a saved search""" + + self.unselect_tags() + self.unselect_general_box() + + item = model.get_item(position) + self.app.browser.get_pane().emit('expand-all') + self.app.browser.get_pane().set_search_query(item.query) + + + def selected_tags(self, names_only: bool = False) -> list: + """Get a list of selected tags""" + + selection = self.tag_selection.get_selection() + result, iterator, _ = Gtk.BitsetIter.init_first(selection) + selected = [] + + while iterator.is_valid(): + val = iterator.get_value() + item = unwrap(self.tag_selection.get_item(val), Tag) + selected.append(item.name if names_only else item) + iterator.next() + + return selected + + + def on_tag_selected(self, model, position, n_items, user_data=None): + """Callback when selecting one or more tags""" + + self.unselect_general_box() + self.unselect_searches() + + self.app.browser.get_pane().emit('expand-all') + self.app.browser.get_pane().set_filter_tags(set(self.selected_tags())) + + + def on_tag_reveal(self, event) -> None: + """Callback for clicking on the tags title button (revealer).""" + + self.revealer.set_reveal_child(not self.revealer.get_reveal_child()) + + + def on_search_reveal(self, event) -> None: + """Callback for clicking on the search title button (revealer).""" + + self.searches_revealer.set_reveal_child(not self.searches_revealer.get_reveal_child()) + + + def searches_setup_cb(self, factory, listitem, user_data=None) -> None: + """Setup for a row in the saved searches listview""" + + box = SearchBox() + label = Gtk.Label() + icon = Gtk.Label() + + icon.set_margin_end(6) + label.set_halign(Gtk.Align.START) + label.set_hexpand(True) + + box.set_margin_start(18) + + box.append(icon) + box.append(label) + listitem.set_child(box) + + searches_RMB_controller = Gtk.GestureSingle(button=Gdk.BUTTON_SECONDARY) + searches_RMB_controller.connect('begin', self.on_searches_RMB_click) + box.add_controller(searches_RMB_controller) + + + def searches_bind_cb(self, signallistitem, listitem, user_data=None) -> None: + """Bind properties for a specific row in the searches listview""" + + icon = listitem.get_child().get_first_child() + label = icon.get_next_sibling() + box = listitem.get_child() + + item = unwrap(listitem, SavedSearch) + box.search = item + + listitem.bindings = [ + item.bind_property('name', label, 'label', BIND_FLAGS), + item.bind_property('icon', icon, 'label', BIND_FLAGS), + ] + + def searches_unbind_cb(self, signallistitem, listitem, user_data=None) -> None: + """Clean up bindings made in searches_bind_cb""" + for binding in listitem.bindings: + binding.unbind() + listitem.bindings.clear() + + + # ------------------------------------------------------------------------------------------- + # Drag and drop + # ------------------------------------------------------------------------------------------- + + def prepare(self, source, x, y): + """Callback to prepare for the DnD operation""" + + # Get content from source + data = source.get_widget().props.tag + + # Set it as content provider + content = Gdk.ContentProvider.new_for_value(data) + + return content + + + def drag_begin(self, source, drag): + """Callback when DnD beings""" + + source.get_widget().set_opacity(0.25) + icon = Gtk.DragIcon.get_for_drag(drag) + frame = Gtk.Frame() + picture = Gtk.Picture.new_for_paintable( + Gtk.WidgetPaintable.new(source.get_widget())) + + frame.set_child(picture) + icon.set_child(frame) + + + def drag_end(self, source, drag, unused): + """Callback when DnD ends""" + + if source.get_widget(): + source.get_widget().set_opacity(1) + + + def check_parent(self, value, target) -> bool: + """Check to parenting a parent to its own children""" + + item = target + while item.parent: + if item.parent == value: + return False + + item = item.parent + + return True + + + def drag_drop(self, target, value, x, y): + """Callback when dropping onto a target""" + + dropped = target.get_widget().props.tag + + if not self.check_parent(value, dropped): + return + + if value.parent: + self.ds.tags.unparent(value.id, value.parent.id) + + self.ds.tags.parent(value.id, dropped.id) + self.ds.tags.tree_model.emit('items-changed', 0, 0, 0) + + + def drop_enter(self, target, x, y, user_data=None): + """Callback when the mouse hovers over the drop target.""" + + expander = target.get_widget().get_first_child() + + if target.get_widget().tag.children: + expander.activate_action('listitem.expand') + + # There's a funny bug in here. If the expansion of the row + # makes the window larger, Gtk won't recognize the new drop areas + # and will think you're dragging outside the window. + + return Gdk.DragAction.COPY + + + def task_drag_drop(self, target, task, x, y): + """Callback when dropping onto a target""" + + tag = target.get_widget().props.tag + task.add_tag(tag) + self.notify_task(task) + + self.ds.tasks.notify('task_count_no_tags') + + + def multi_task_drag_drop(self, target, tasklist, x, y): + """Callback when dropping onto a target""" + + for task in list(tasklist): + tag = target.get_widget().props.tag + task.add_tag(tag) + self.notify_task(task) + + self.ds.tasks.notify('task_count_no_tags') + + + def notify_task(self, task: Task) -> None: + """Notify that tasks props have changed.""" + + task.notify('row_css') + task.notify('icons') + task.notify('tag_colors') + + + def on_toplevel_tag_drop(self, drop_target, tag, x, y): + if tag.parent: + self.ds.tags.unparent(tag.id, tag.parent.id) + + try: + for expander in self.expanders: + expander.activate_action('listitem.toggle-expand') + expander.activate_action('listitem.toggle-expand') + except RuntimeError: + pass + + self.ds.tags.tree_model.emit('items-changed', 0, 0, 0) + return True + else: + return False diff --git a/GTG/gtk/browser/sidebar_context_menu.py b/GTG/gtk/browser/sidebar_context_menu.py new file mode 100644 index 0000000000..997e8e9dee --- /dev/null +++ b/GTG/gtk/browser/sidebar_context_menu.py @@ -0,0 +1,119 @@ +# ----------------------------------------------------------------------------- +# Getting Things GNOME! - a personal organizer for the GNOME desktop +# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, see . +# ----------------------------------------------------------------------------- + +""" +tag_context_menu: +Implements a context (pop-up) menu for the tag item in the sidebar. +Right now it is just a void shell It is supposed to become a more generic +sidebar context for all kind of item displayed there. +Also, it is supposed to handle more complex menus (with non-std widgets, +like a color picker) +""" + +from gi.repository import Gtk, Gio + +from gettext import gettext as _ +from GTG.gtk.colors import generate_tag_color, color_add, color_remove +from GTG.gtk.browser import GnomeConfig + + +class TagContextMenu(Gtk.PopoverMenu): + """Context menu fo the tag i the sidebar""" + + def __init__(self, ds, app, tag): + super().__init__(has_arrow=False) + self.tag = tag + self.app = app + self.ds = ds + + actions = [ + ("edit_tag", self.on_mi_cc_activate), + ("generate_tag_color", self.on_mi_ctag_activate), + ("delete_tag", lambda w, a, p : self.app.browser.on_delete_tag_activate([self.tag])) + ] + + for action_disc in actions: + self.install_action( + ".".join(["tags_popup", action_disc[0]]), None, action_disc[1]) + + # Build up the menu + self.build_menu() + + + def build_menu(self): + """Build up the widget""" + if self.tag: + menu_builder = Gtk.Builder() + menu_builder.add_from_file(GnomeConfig.MENUS_UI_FILE) + menu_model = menu_builder.get_object("tag_context_menu") + self.mi_del_tag = Gio.MenuItem.new(_("Delete"), "tags_popup.delete_tag") + menu_model.append_item(self.mi_del_tag) + + self.set_menu_model(menu_model) + + + # CALLBACKS ############################################################### + def on_mi_cc_activate(self, widget, action_name, param): + """Callback: show the tag editor upon request""" + + self.app.open_tag_editor(self.tag) + + + def on_mi_ctag_activate(self, widget, action_name, param): + + self.tag.color = self.ds.tags.generate_color() + self.ds.notify_tag_change(self.tag) + + +class SearchesContextMenu(Gtk.PopoverMenu): + """Context menu fo the tag i the sidebar""" + + def __init__(self, ds, app, search): + super().__init__(has_arrow=False) + self.search = search + self.app = app + self.ds = ds + + actions = [ + ("edit_search", lambda w, a, p: app.open_search_editor(search)), + ("delete_search", lambda w, a, p : ds.saved_searches.remove(self.search.id)) + ] + + for action_disc in actions: + self.install_action( + ".".join(["search_popup", action_disc[0]]), None, action_disc[1]) + + # Build up the menu + self.build_menu() + + + def build_menu(self): + """Build up the widget""" + + menu_builder = Gtk.Builder() + menu_builder.add_from_file(GnomeConfig.MENUS_UI_FILE) + menu_model = menu_builder.get_object("search_context_menu") + + self.set_menu_model(menu_model) + + + # CALLBACKS ############################################################### + def on_mi_cc_activate(self, widget, action_name, param): + """Callback: show the tag editor upon request""" + + # self.app.open_tag_editor(self.tag) \ No newline at end of file diff --git a/GTG/gtk/browser/tag_context_menu.py b/GTG/gtk/browser/tag_context_menu.py deleted file mode 100644 index 47d6084d4f..0000000000 --- a/GTG/gtk/browser/tag_context_menu.py +++ /dev/null @@ -1,99 +0,0 @@ -# ----------------------------------------------------------------------------- -# Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program. If not, see . -# ----------------------------------------------------------------------------- - -""" -tag_context_menu: -Implements a context (pop-up) menu for the tag item in the sidebar. -Right now it is just a void shell It is supposed to become a more generic -sidebar context for all kind of item displayed there. -Also, it is supposed to handle more complex menus (with non-std widgets, -like a color picker) -""" - -from gi.repository import Gtk - -from gettext import gettext as _ -from GTG.gtk.colors import generate_tag_color, color_add, color_remove - - -class TagContextMenu(Gtk.Menu): - """Context menu fo the tag i the sidebar""" - - def __init__(self, req, app, tag=None): - super().__init__() - self.req = req - self.app = app - self.tag = tag - # Build up the menu - self.set_tag(tag) - self.__build_menu() - - def __build_menu(self): - """Build up the widget""" - # Reset the widget - for i in self: - self.remove(i) - i.destroy() - if self.tag is not None: - # Color chooser FIXME: SHOULD BECOME A COLOR PICKER - self.mi_cc = Gtk.MenuItem() - self.mi_cc.set_label(_("Edit...")) - self.append(self.mi_cc) - self.mi_cc.connect('activate', self.on_mi_cc_activate) - - self.mi_ctag = Gtk.MenuItem() - self.mi_ctag.set_label(_("Generate Color")) - self.append(self.mi_ctag) - self.mi_ctag.connect('activate', self.on_mi_ctag_activate) - - if self.tag.is_search_tag(): - self.mi_del = Gtk.MenuItem() - self.mi_del.set_label(_("Delete")) - self.append(self.mi_del) - self.mi_del.connect('activate', self.on_mi_del_activate) - else: - self.mi_del_tag = Gtk.MenuItem() - self.mi_del_tag.set_label(_("Delete")) - self.append(self.mi_del_tag) - self.mi_del_tag.connect('activate', self.app.browser.on_delete_tag_activate) - - # Make it visible - self.show_all() - - # PUBLIC API ############################################################## - def set_tag(self, tag): - """Update the context menu items using the tag attributes.""" - self.tag = tag - self.__build_menu() - - # CALLBACKS ############################################################### - def on_mi_cc_activate(self, widget): - """Callback: show the tag editor upon request""" - self.app.open_tag_editor(self.tag) - - def on_mi_ctag_activate(self, widget): - random_color = generate_tag_color() - present_color = self.tag.get_attribute('color') - if present_color is not None: - color_remove(present_color) - self.tag.set_attribute('color', random_color) - color_add(random_color) - - def on_mi_del_activate(self, widget): - """ delete a selected search """ - self.req.remove_tag(self.tag.get_name()) diff --git a/GTG/gtk/browser/tag_editor.py b/GTG/gtk/browser/tag_editor.py index 30bf78d26f..03307416c7 100644 --- a/GTG/gtk/browser/tag_editor.py +++ b/GTG/gtk/browser/tag_editor.py @@ -20,7 +20,7 @@ This module contains the TagEditor class which is a window that allows the user to edit a tag properties. """ -from gi.repository import GObject, Gtk, Gdk, GdkPixbuf +from gi.repository import GObject, Gtk, Gdk, GdkPixbuf, GLib import logging import random @@ -32,29 +32,35 @@ @Gtk.Template(filename=GnomeConfig.TAG_EDITOR_UI_FILE) -class TagEditor(Gtk.Window): +class TagEditor(Gtk.Dialog): """ A window to edit certain properties of an tag. """ __gtype_name__ = 'GTG_TagEditor' - _emoji_entry = Gtk.Template.Child('emoji-entry') + _emoji_chooser = Gtk.Template.Child('emoji-chooser') _icon_button = Gtk.Template.Child('icon-button') _name_entry = Gtk.Template.Child('name-entry') - def __init__(self, req, app, tag=None): - super().__init__() + def __init__(self, app, tag=None): + super().__init__(use_header_bar=1) + + set_icon_shortcut = Gtk.Shortcut.new( + Gtk.ShortcutTrigger.parse_string("I"), + Gtk.CallbackAction.new(self._set_icon)) + + self.add_shortcut(set_icon_shortcut) - self.req = req self.app = app self.tag = tag + self.set_transient_for(app.browser) self._title_format = self.get_title() + self._emoji_chooser.set_parent(self._icon_button) - self._emoji_entry_changed_id = self._emoji_entry.connect( - 'changed', self._set_emoji) - - self.tag_rgba = RGBA(1.0, 1.0, 1.0, 1.0) + self.tag_rgba = Gdk.RGBA() + (self.tag_rgba.red, self.tag_rgba.green, + self.tag_rgba.blue, self.tag_rgba.alpha) = 1.0, 1.0, 1.0, 1.0 self.tag_name = '' self.tag_is_actionable = True self.is_valid = True @@ -62,29 +68,34 @@ def __init__(self, req, app, tag=None): self.use_icon = False self.set_tag(tag) - self.show_all() + self.show() @GObject.Property(type=bool, default=False) def has_color(self): """Whenever the tag has a color.""" + return self._has_color @has_color.setter def has_color(self, value: bool): + self._has_color = value @GObject.Property(type=Gdk.RGBA) def tag_rgba(self): """The color of the tag. Alpha is ignored.""" + return self._tag_rgba @tag_rgba.setter def tag_rgba(self, value: Gdk.RGBA): + self._tag_rgba = value @GObject.Property(type=str, default='') def tag_name(self): """The (new) name of the tag.""" + return self._tag_name @tag_name.setter @@ -97,6 +108,7 @@ def tag_is_actionable(self): """ Whenever the tag should show up in the actionable tab. """ + return self._tag_is_actionable @tag_is_actionable.setter @@ -108,6 +120,7 @@ def is_valid(self): """ Whenever it is valid to apply the changes (like malformed tag name). """ + return self._is_valid @is_valid.setter @@ -119,17 +132,8 @@ def has_icon(self): """ Whenever the tag will have an icon. """ - return bool(self._emoji) - def _reset_emoji_entry(self): - """ - The emoji entry should stay clear in order to function properly. - When something is being inserted, then it should be cleared after - either starting editing a new tag, selected one, or otherwise changed. - """ - with GObject.signal_handler_block(self._emoji_entry, - self._emoji_entry_changed_id): - self._emoji_entry.set_text('') + return bool(self._emoji) def _validate(self): """ @@ -139,6 +143,7 @@ def _validate(self): On failure, the widgets are modified accordingly to show the user why it doesn't accept it. """ + valid = True valid &= self._validate_tag_name() self.is_valid = valid @@ -151,19 +156,17 @@ def _validate_tag_name(self): On failure, the widgets are modified accordingly to show the user why it doesn't accept it. """ - # TODO: Possibly add more restrictions. + if self.tag_name == '': - self._name_entry.set_icon_from_icon_name( - Gtk.EntryIconPosition.SECONDARY, Gtk.STOCK_DIALOG_ERROR) - self._name_entry.props.secondary_icon_tooltip_text = \ + self._name_entry.add_css_class("error") + self._name_entry.props.tooltip_text = \ _("Tag name can not be empty") return False else: - self._name_entry.set_icon_from_icon_name( - Gtk.EntryIconPosition.SECONDARY, None) + self._name_entry.remove_css_class("error") + self._name_entry.props.tooltip_text = "" return True - # PUBLIC API ##### def set_tag(self, tag): """ Set the tag to edit. @@ -174,73 +177,91 @@ def set_tag(self, tag): if tag is None: return - icon = tag.get_attribute('icon') - self._set_emoji(self._emoji_entry, text=icon if icon else '') + icon = tag.icon + self._set_emoji(self._emoji_chooser, text=icon if icon else '') - self.tag_name = tag.get_friendly_name() + self.tag_name = tag.name self.set_title(self._title_format % ('@' + self.tag_name,)) - rgba = RGBA(1.0, 1.0, 1.0, 1.0) - if color := tag.get_attribute('color'): + rgba = Gdk.RGBA() + rgba.red, rgba.green, rgba.blue, rgba.alpha = 1.0, 1.0, 1.0, 1.0 + + if color := tag.color: + color = '#' + color if not color.startswith('#') else color if not rgba.parse(color): log.warning("Failed to parse tag color for %r: %r", - tag.get_name(), color) + tag.name, color) + self.has_color = bool(color) self.tag_rgba = rgba - self.tag_is_actionable = \ - self.tag.get_attribute("nonactionable") != "True" + self.tag_is_actionable = self.tag.actionable def do_destroy(self): + self.app.close_tag_editor() super().destroy() - # CALLBACKS ##### - @Gtk.Template.Callback('cancel') - def _cancel(self, widget: GObject.Object): + def _cancel(self): """ Cancel button has been clicked, closing the editor window without applying changes. """ + self.destroy() - @Gtk.Template.Callback('apply') - def _apply(self, widget: GObject.Object): + def _apply(self): """ Apply button has been clicked, applying the settings and closing the editor window. """ if self.tag is None: log.warning("Trying to apply but no tag set, shouldn't happen") - self._cancel(widget) + self._cancel() return if self.has_icon and self._emoji: - self.tag.set_attribute('icon', self._emoji) + self.tag.icon = self._emoji elif self.has_icon: # Should never happen, but just in case log.warning("Tried to set icon for %r but no icon given", - self.tag.get_name()) - self.tag.del_attribute('icon') + self.tag.name) + self.tag.icon = None else: - self.tag.del_attribute('icon') + self.tag.icon = None if self.has_color: color = rgb_to_hex(self.tag_rgba) color_add(color) - self.tag.set_attribute('color', color) + self.tag.color = color else: - if tag_color := self.tag.get_attribute('color'): + if tag_color := self.tag.color: color_remove(tag_color) - self.tag.del_attribute('color') - self.tag.set_attribute('nonactionable', str(not self.tag_is_actionable)) + self.tag.color = None + + self.tag.actionable = self.tag_is_actionable - if self.tag_name != self.tag.get_friendly_name(): - log.debug("Renaming %r → %r", self.tag.get_name(), self.tag_name) - self.req.rename_tag(self.tag.get_name(), self.tag_name) - self.tag = self.req.get_tag(self.tag_name) + if self.tag_name != self.tag.name: + log.debug("Renaming %r → %r", self.tag.name, self.tag_name) + for t in self.app.ds.tasks.lookup.values(): + t.rename_tag(self.tag.name, self.tag_name) + + self.tag.name = self.tag_name + + self.app.ds.refresh_task_count() + self.app.ds.refresh_task_for_tag(self.tag) + self.app.ds.notify_tag_change(self.tag) + self.app.browser.sidebar.refresh_tags() self.destroy() + # CALLBACKS ##### + @Gtk.Template.Callback('response') + def _response(self, widget: GObject.Object, response: Gtk.ResponseType): + if response == Gtk.ResponseType.APPLY: + self._apply() + else: + self._cancel() + @Gtk.Template.Callback('random_color') def _random_color(self, widget: GObject.Object): """ @@ -248,38 +269,34 @@ def _random_color(self, widget: GObject.Object): with an random color. """ self.has_color = True - self.tag_rgba = random_color() - + color = self.app.ds.tags.generate_color() + c = Gdk.RGBA() + c.red, c.green, c.blue, c.alpha = 1.0, 1.0, 1.0, 1.0 + c.parse('#' + color) + self.tag_rgba = c + @Gtk.Template.Callback('activate_color') def _activate_color(self, widget: GObject.Object): """ Enable using the selected color because a color has been selected. """ + self.has_color = True @Gtk.Template.Callback('set_icon') - def _set_icon(self, widget: GObject.Object): + def _set_icon(self, widget: GObject.Object, shargs: GLib.Variant = None): """ Button to set the icon/emoji has been clicked. """ - self._reset_emoji_entry() - # Resize to make the emoji picker fit (can't go outside of the - # window for some reason, at least in GTK3) - w, h = self.get_size() - self.resize(max(w, 550), max(h, 300)) - self._emoji_entry.do_insert_emoji(self._emoji_entry) + self._emoji_chooser.popup() + @Gtk.Template.Callback('emoji_set') def _set_emoji(self, widget: GObject.Object, text: str = None): """ Callback when an emoji has been inserted. - This is part of the emoji insertion hack. - The text parameter can be used to override the emoji to use, used - for initialization. """ - if text is None: - text = self._emoji_entry.get_text() - self._emoji = text if text else None + if text: self._emoji = text self._icon_button.set_label(text) @@ -290,21 +307,24 @@ def _set_emoji(self, widget: GObject.Object, text: str = None): self._icon_button.set_label('🏷️') if label := self._icon_button.get_child(): label.set_opacity(0.4) - self.notify('has-icon') - self._reset_emoji_entry() + self.notify('has-icon') @Gtk.Template.Callback('remove_icon') def _remove_icon(self, widget: GObject.Object): """ Callback to remove the icon. """ - self._set_emoji(self._emoji_entry, text='') + + self._set_emoji(self._emoji_chooser, text='') @Gtk.Template.Callback('remove_color') def _remove_color(self, widget: GObject.Object): """ Callback to remove the color. """ - self.tag_rgba = RGBA(1.0, 1.0, 1.0, 1.0) + + c = Gdk.RGBA() + c.red, c.green, c.blue, c.alpha = 1.0, 1.0, 1.0, 1.0 + self.tag_rgba = c self.has_color = False diff --git a/GTG/gtk/browser/tag_pill.py b/GTG/gtk/browser/tag_pill.py new file mode 100644 index 0000000000..d2bf752240 --- /dev/null +++ b/GTG/gtk/browser/tag_pill.py @@ -0,0 +1,124 @@ +# ----------------------------------------------------------------------------- +# Getting Things GNOME! - a personal organizer for the GNOME desktop +# Copyright (c) - The GTG Team +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, see . +# ----------------------------------------------------------------------------- + +"""Tag colors widget""" + +from gi.repository import Gtk, Gdk, GObject + + +class TagPill(Gtk.DrawingArea): + """Color pill widget for tags.""" + + __gtype_name__ = 'TagPill' + + default_color = '#666666' + + def __init__(self, radius: int = 5): + + super(TagPill, self).__init__() + self.colors = [Gdk.RGBA()] + self.colors[0].parse(self.default_color) + + self.colors_str = '' + self.radius = radius + self.set_draw_func(self.do_draw_function) + + + @GObject.Property(type=str) + def color_list(self) -> str: + return self.colors_str + + + @color_list.setter + def set_colors(self, value) -> None: + + try: + self.colors = [] + + for color in value.split(','): + rgba = Gdk.RGBA() + valid = rgba.parse(color) + + if valid: + self.colors.append(rgba) + else: + self.colors = [Gdk.RGBA()] + self.colors[0].parse(self.default_color) + + except AttributeError: + self.colors = [Gdk.RGBA()] + self.colors[0].parse(self.default_color) + + self.set_size_request((16 + 6) * len(self.colors), 16) + self.queue_draw() + + + def draw_rect(self, context, x: int, w: int, h: int, + color: Gdk.RGBA = None) -> None: + """Draw a single color rectangle.""" + + y = 0 # No change in Y axis + r = self.radius + + if color: + context.set_source_rgba(color.red, color.green, color.blue) + + # A * BQ + # H C + # * * + # G D + # F * E + + context.move_to(x + r, y) # Move to A + context.line_to(x + w - r, y) # Line to B + + context.curve_to( + x + w, y, + x + w, y, + x + w, y + r + ) # Curve to C + context.line_to(x + w, y + h - r) # Line to D + + context.curve_to( + x + w, y + h, + x + w, y + h, + x + w - r, y + h + ) # Curve to E + context.line_to(x + r, y + h) # Line to F + + context.curve_to( + x, y + h, + x, y + h, + x, y + h - r + ) # Curve to G + context.line_to(x, y + r) # Line to H + + context.curve_to( + x, y, + x, y, + x + r, y + ) # Curve to A + + + def do_draw_function(self, area, context, w, h, user_data=None): + """Drawing callback.""" + + for i, color in enumerate(self.colors): + x = i * (16 + 6) + self.draw_rect(context, x, 16, h, color) + context.fill() diff --git a/GTG/gtk/browser/task_pane.py b/GTG/gtk/browser/task_pane.py new file mode 100644 index 0000000000..25d436b09d --- /dev/null +++ b/GTG/gtk/browser/task_pane.py @@ -0,0 +1,695 @@ +# ----------------------------------------------------------------------------- +# Getting Things GNOME! - a personal organizer for the GNOME desktop +# Copyright (c) - The GTG Team +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# this program. If not, see . +# ----------------------------------------------------------------------------- + +"""Task pane and list.""" + +from gi.repository import Gtk, GObject, Gdk, Gio, Pango +from GTG.core.tasks import Task, Status +from GTG.core.filters import TaskPaneFilter, SearchTaskFilter +from GTG.core.sorters import * +from GTG.gtk.browser.tag_pill import TagPill +from gettext import gettext as _ + + +BIND_FLAGS = GObject.BindingFlags.DEFAULT | GObject.BindingFlags.SYNC_CREATE + +class TaskBox(Gtk.Box): + """Box subclass to keep a pointer to the tag object""" + + task = GObject.Property(type=Task) + + def __init__(self, config, is_actionable=False): + self.config = config + super(TaskBox, self).__init__() + + self.expander = Gtk.TreeExpander() + self.expander.set_margin_end(6) + self.expander.add_css_class('arrow-only-expander') + + self.check = Gtk.CheckButton() + self.check.set_margin_end(6) + + self.append(self.expander) + self.append(self.check) + + self.is_actionable = is_actionable + + + @GObject.Property(type=bool, default=True) + def has_children(self) -> None: + return + + + @has_children.setter + def set_has_children(self, value) -> bool: + + if self.is_actionable: + value = False + + self.expander.set_visible(value) + + if value: + widget = self.expander + else: + widget = self.check + + check_width = 21 + margin = 6 + + indent = margin if value else (check_width + margin) + + if self.task.parent and not self.is_actionable: + parent = self.task + depth = 0 + + while parent.parent: + depth += 1 + parent = parent.parent + + widget.set_margin_start(indent + (21 * depth)) + else: + widget.set_margin_start(indent) + + + @GObject.Property(type=bool, default=True) + def is_active(self) -> None: + return + + + @is_active.setter + def set_is_active(self, value) -> bool: + if value: + self.remove_css_class('closed-task') + else: + self.add_css_class('closed-task') + + + @GObject.Property(type=str) + def row_css(self) -> None: + return + + + @row_css.setter + def set_row_css(self, value) -> None: + show = self.config.get('bg_color_enable') + context = self.get_style_context() + + if not value or not show: + try: + context.remove_provider(self.provider) + return + except AttributeError: + return + + val = str.encode(value) + + self.provider = Gtk.CssProvider() + self.provider.load_from_data(val) + context.add_provider(self.provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) + + +def unwrap(row, expected_type): + """Find an item in TreeRow widget (sometimes nested).""" + + item = row.get_item() + + while type(item) is not expected_type: + item = item.get_item() + + return item + + +class TaskPane(Gtk.ScrolledWindow): + """The task pane widget""" + + def __init__(self, browser, pane): + + super(TaskPane, self).__init__() + self.ds = browser.app.ds + self.app = browser.app + self.browser = browser + self.pane = pane + self.searching = False + + self.set_vexpand(True) + self.set_hexpand(True) + + # ------------------------------------------------------------------------------- + # Title + # ------------------------------------------------------------------------------- + title_box = Gtk.Box() + title_box.set_valign(Gtk.Align.START) + + title_box.set_margin_top(32) + title_box.set_margin_bottom(32) + title_box.set_margin_start(24) + title_box.set_margin_end(24) + + self.title = Gtk.Label() + self.title.set_halign(Gtk.Align.START) + self.title.set_hexpand(True) + self.title.add_css_class('title-1') + title_box.append(self.title) + + self.sort_btn = Gtk.MenuButton() + self.sort_btn.set_icon_name('view-more-symbolic') + self.sort_btn.add_css_class('flat') + + title_box.append(self.sort_btn) + + + # ------------------------------------------------------------------------------- + # Task List + # ------------------------------------------------------------------------------- + + self.search_filter = SearchTaskFilter(self.ds, pane) + self.task_filter = TaskPaneFilter(self.app.ds, pane) + + self.filtered = Gtk.FilterListModel() + self.filtered.set_model(self.app.ds.tasks.tree_model) + self.filtered.set_filter(self.task_filter) + + self.sort_model = Gtk.TreeListRowSorter() + + self.main_sorter = Gtk.SortListModel() + self.main_sorter.set_model(self.filtered) + self.main_sorter.set_sorter(self.sort_model) + + self.task_selection = Gtk.MultiSelection.new(self.main_sorter) + + tasks_signals = Gtk.SignalListItemFactory() + tasks_signals.connect('setup', self.task_setup_cb) + tasks_signals.connect('bind', self.task_bind_cb) + tasks_signals.connect('unbind', self.task_unbind_cb) + + view = Gtk.ListView.new(self.task_selection, tasks_signals) + view.set_show_separators(True) + view.add_css_class('task-list') + + view_drop = Gtk.DropTarget.new(Task, Gdk.DragAction.COPY) + view_drop.connect("drop", self.on_toplevel_tag_drop) + view.add_controller(view_drop) + + key_controller = Gtk.EventControllerKey() + key_controller.connect('key-released', self.on_key_released) + view.add_controller(key_controller) + view.connect('activate', self.on_listview_activated) + + self.set_child(view) + self.set_title() + + + @GObject.Signal(name='expand-all') + def expand_all(self, *_): + """Emit this signal to expand all TreeRowExpanders""" + + + @GObject.Signal(name='collapse-all') + def collapse_all(self, *_): + """Emit this signal to collapse all TreeRowExpanders""" + + + def set_title(self) -> None: + """Change pane title.""" + + if not self.task_filter.tags: + if self.pane == 'active': + self.title.set_text(_('All Open Tasks')) + if self.pane == 'workview': + self.title.set_text(_('Actionable Tasks')) + if self.pane == 'closed': + self.title.set_text(_('All Closed Tasks')) + + else: + tags = ', '.join('@' + t.name for t in self.task_filter.tags) + + if self.pane == 'active': + self.title.set_text(_('{0} (Open)'.format(tags))) + if self.pane == 'workview': + self.title.set_text(_('{0} (Actionable)'.format(tags))) + if self.pane == 'closed': + self.title.set_text(_('{0} (Closed)'.format(tags))) + + + def set_search_query(self, query) -> None: + """Change tasks filter.""" + + self.filtered.set_filter(self.search_filter) + self.search_filter.set_query(query) + self.search_filter.pane = self.pane + self.search_filter.changed(Gtk.FilterChange.DIFFERENT) + self.searching = True + + + def set_filter_pane(self, pane) -> None: + """Change tasks filter.""" + + if self.searching: + self.searching = False + self.filtered.set_filter(self.task_filter) + + self.pane = pane + self.task_filter.pane = pane + self.task_filter.expand = True + self.task_filter.changed(Gtk.FilterChange.DIFFERENT) + self.set_title() + + + def set_filter_tags(self, tags=[]) -> None: + """Change tasks filter.""" + + if self.searching: + self.search_filter.tags = tags + self.search_filter.changed(Gtk.FilterChange.DIFFERENT) + else: + self.task_filter.tags = tags + self.task_filter.no_tags = False + self.task_filter.changed(Gtk.FilterChange.DIFFERENT) + + self.set_title() + + def set_filter_notags(self, tags=[]) -> None: + """Change tasks filter.""" + + if self.searching: + self.search_filter.tags = [] + self.search_filter.changed(Gtk.FilterChange.DIFFERENT) + else: + self.task_filter.tags = [] + self.task_filter.no_tags = True + self.task_filter.changed(Gtk.FilterChange.DIFFERENT) + + self.set_title() + + + def refresh(self): + """Refresh the task filter""" + + self.task_filter.changed(Gtk.FilterChange.DIFFERENT) + self.main_sorter.items_changed(0,0,0) + + + def set_sorter(self, method=None) -> None: + """Change tasks filter.""" + + sorter = None + + if method == 'Start': + sorter = TaskStartSorter() + if method == 'Due': + sorter = TaskDueSorter() + if method == 'Modified': + sorter = TaskModifiedSorter() + elif method == 'Added': + sorter = TaskAddedSorter() + elif method == 'Tags': + sorter = TaskTagSorter() + elif method == 'Title': + sorter = TaskTitleSorter() + + self.sort_model.set_sorter(sorter) + + + def on_listview_activated(self, listview, position, user_data = None): + """Callback when double clicking on a row.""" + + self.app.browser.on_edit_active_task() + + + def on_key_released(self, controller, keyval, keycode, state): + """Callback when a key is released. """ + + is_enter = keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter) + is_left = keyval == Gdk.KEY_Left + is_right = keyval == Gdk.KEY_Right + + if is_enter: + self.app.browser.on_edit_active_task() + elif is_left: + self.expand_selected(False) + elif is_right: + self.expand_selected(True) + + + def select_last(self) -> None: + """Select last position in the task list.""" + + position = self.app.ds.tasks.tree_model.get_n_items() + self.task_selection.select_item(position - 1, True) + + + def select_task(self, task: Task) -> None: + """Select a task in the list.""" + + position = None + + for i in range(self.main_sorter.get_n_items()): + item = unwrap(self.main_sorter.get_item(i), Task) + + if item == task: + position = i + break + + if position: + self.task_selection.select_item(position, True) + + + def get_selection(self, indices: bool = False) -> list: + """Get the currently selected tasks.""" + + selection = self.task_selection.get_selection() + result, iterator, _ = Gtk.BitsetIter.init_first(selection) + selected = [] + + while iterator.is_valid(): + val = iterator.get_value() + + if indices: + selected.append(val) + else: + selected.append(unwrap(self.task_selection.get_item(val), Task)) + + iterator.next() + + return selected + + + def expand_selected(self, expand) -> None: + """Get the box widgets of the selected tasks.""" + + selection = self.task_selection.get_selection() + result, iterator, _ = Gtk.BitsetIter.init_first(selection) + + while iterator.is_valid(): + val = iterator.get_value() + row = self.task_selection.get_item(val) + row.set_expanded(expand) + + iterator.next() + + + def get_selected_number(self) -> int: + """Get number of items currently selected.""" + + selection = self.task_selection.get_selection() + return selection.get_size() + + + def on_checkbox_toggled(self, button, task=None): + """Callback when clicking a checkbox.""" + + if task.status == Status.DISMISSED: + task.toggle_dismiss() + else: + task.toggle_active() + + task.notify('is_active') + self.task_filter.changed(Gtk.FilterChange.DIFFERENT) + + + def task_setup_cb(self, factory, listitem, user_data=None): + """Setup widgets for rows""" + + box = TaskBox(self.app.config, self.pane == 'workview') + label = Gtk.Label() + separator = Gtk.Separator() + icons = Gtk.Label() + color = TagPill() + due = Gtk.Label() + due_icon = Gtk.Image.new_from_icon_name('alarm-symbolic') + start = Gtk.Label() + start_icon = Gtk.Image.new_from_icon_name('media-playback-start-symbolic') + recurring_icon = Gtk.Label() + + color.set_size_request(16, 16) + + color.set_vexpand(False) + color.set_valign(Gtk.Align.CENTER) + + separator.set_margin_end(12) + + def on_notify_visibility(obj, gparamstring): + val = ((recurring_icon.is_visible() + or due_icon.is_visible() + or start_icon.is_visible()) + and + (color.is_visible() or icons.is_visible())) + separator.set_visible(val) + + for widget in (recurring_icon, due_icon, start_icon, color, icons): + widget.connect("notify::visible", on_notify_visibility) + + icons.set_margin_end(6) + + label.set_hexpand(True) + label.set_ellipsize(Pango.EllipsizeMode.END) + label.set_margin_end(12) + label.set_xalign(0) + + recurring_icon.set_margin_end(16) + recurring_icon.set_label('\u2B6E') + + due_icon.set_margin_end(6) + due.set_margin_end(24) + + start_icon.set_margin_end(6) + start.set_margin_end(12) + + # DnD stuff + source = Gtk.DragSource() + source.connect('prepare', self.drag_prepare) + source.connect('drag-begin', self.drag_begin) + source.connect('drag-end', self.drag_end) + box.add_controller(source) + + # Set drop for DnD + drop = Gtk.DropTarget.new(Task, Gdk.DragAction.COPY) + drop.connect('drop', self.drag_drop) + drop.connect('enter', self.drop_enter) + + box.add_controller(drop) + + task_RMB_controller = Gtk.GestureSingle(button=Gdk.BUTTON_SECONDARY) + task_RMB_controller.connect('end', self.on_task_RMB_click) + box.add_controller(task_RMB_controller) + + self.connect('expand-all', lambda s: box.expander.activate_action('listitem.expand')) + self.connect('collapse-all', lambda s: box.expander.activate_action('listitem.collapse')) + + box.append(label) + box.append(recurring_icon) + box.append(due_icon) + box.append(due) + box.append(start_icon) + box.append(start) + box.append(separator) + box.append(color) + box.append(icons) + listitem.set_child(box) + + + def task_bind_cb(self, factory, listitem, user_data=None): + """Bind values to the widgets in setup_cb""" + + box = listitem.get_child() + expander = box.get_first_child() + check = expander.get_next_sibling() + label = check.get_next_sibling() + recurring_icon = label.get_next_sibling() + due_icon = recurring_icon.get_next_sibling() + due = due_icon.get_next_sibling() + start_icon = due.get_next_sibling() + start = start_icon.get_next_sibling() + separator = start.get_next_sibling() + color = separator.get_next_sibling() + icons = color.get_next_sibling() + + item = unwrap(listitem, Task) + + box.props.task = item + box.expander.set_list_row(listitem.get_item()) + + def not_empty(binding, value, user_data=None): + return len(value) > 0 + + def show_start(binding, value, user_data=None): + return value and self.pane == 'active' + + + listitem.bindings = [ + item.bind_property('has_children', box, 'has_children', BIND_FLAGS), + + item.bind_property('title', label, 'label', BIND_FLAGS), + item.bind_property('excerpt', box, 'tooltip-text', BIND_FLAGS), + + item.bind_property('is_recurring', recurring_icon, 'visible', BIND_FLAGS), + + item.bind_property('has_date_due', due_icon, 'visible', BIND_FLAGS), + item.bind_property('has_date_start', start_icon, 'visible', BIND_FLAGS, show_start), + + item.bind_property('date_due_str', due, 'label', BIND_FLAGS), + item.bind_property('date_start_str', start, 'label', BIND_FLAGS), + item.bind_property('date_start_str', start, 'visible', BIND_FLAGS, show_start), + + item.bind_property('is_active', box, 'is_active', BIND_FLAGS), + item.bind_property('icons', icons, 'label', BIND_FLAGS), + item.bind_property('icons', icons, 'visible', BIND_FLAGS, not_empty), + item.bind_property('row_css', box, 'row_css', BIND_FLAGS), + + item.bind_property('tag_colors', color, 'color_list', BIND_FLAGS), + item.bind_property('show_tag_colors', color, 'visible', BIND_FLAGS), + ] + + box.check.set_active(item.status == Status.DONE) + box.check.connect('toggled', self.on_checkbox_toggled, item) + + + def task_unbind_cb(self, factory, listitem, user_data=None): + """Clean up bindings made in task_bind_cb""" + for binding in listitem.bindings: + binding.unbind() + listitem.bindings.clear() + box = listitem.get_child() + box.check.disconnect_by_func(self.on_checkbox_toggled) + + + def drag_prepare(self, source, x, y): + """Callback to prepare for the DnD operation""" + + selection = self.get_selection() + + if len(selection) > 1: + data = Gio.ListStore() + data.splice(0, 0, selection) + + content = Gdk.ContentProvider.new_for_value(GObject.Value(Gio.ListModel, data)) + return content + + else: + # Get content from source + data = source.get_widget().props.task + + # Set it as content provider + content = Gdk.ContentProvider.new_for_value(data) + return content + + + def drag_begin(self, source, drag): + """Callback when DnD beings""" + + source.get_widget().set_opacity(0.25) + icon = Gtk.DragIcon.get_for_drag(drag) + frame = Gtk.Frame() + picture = Gtk.Picture.new_for_paintable( + Gtk.WidgetPaintable.new(source.get_widget())) + + frame.set_child(picture) + icon.set_child(frame) + + + def drag_end(self, source, drag, unused): + """Callback when DnD ends""" + + if source.get_widget(): + source.get_widget().set_opacity(1) + + + def drop_enter(self, target, x, y, user_data=None): + """Callback when the mouse hovers over the drop target.""" + + expander = target.get_widget().get_first_child() + + if target.get_widget().task.children: + expander.activate_action('listitem.expand') + + # There's a funny bug in here. If the expansion of the row + # makes the window larger, Gtk won't recognize the new drop areas + # and will think you're dragging outside the window. + + return Gdk.DragAction.COPY + + + def check_parent(self, value, target) -> bool: + """Check to parenting a parent to its own children""" + + item = target + while item.parent: + if item.parent == value: + return False + + item = item.parent + + return True + + + def drag_drop(self, target, task, x, y): + """Callback when dropping onto a target""" + + dropped = target.get_widget().props.task + + if not self.check_parent(task, dropped): + return + + if task.parent: + self.ds.tasks.unparent(task.id, task.parent.id) + + self.ds.tasks.parent(task.id, dropped.id) + self.refresh() + self.emit('collapse-all') + self.emit('expand-all') + + + def on_task_RMB_click(self, gesture, sequence) -> None: + """Callback when right-clicking on an open task.""" + + widget = gesture.get_widget() + task = widget.task + + if self.get_selected_number() <= 1: + self.select_task(task) + + if task.status == Status.ACTIVE: + menu = self.browser.open_menu + else: + menu = self.browser.closed_menu + + point = gesture.get_point(sequence) + x, y = widget.translate_coordinates(self.browser, point.x, point.y) + + rect = Gdk.Rectangle() + rect.x = x + rect.y = y + + menu.set_pointing_to(rect) + menu.popup() + + + def on_toplevel_tag_drop(self, drop_target, task, x, y): + if task.parent: + self.ds.tasks.unparent(task.id, task.parent.id) + self.ds.tasks.tree_model.emit('items-changed', 0, 0, 0) + self.refresh() + + # Not pretty, but needed to force the update of + # the parent task and it's remaining children + self.emit('collapse-all') + self.emit('expand-all') + + return True + else: + return False diff --git a/GTG/gtk/browser/treeview_factory.py b/GTG/gtk/browser/treeview_factory.py deleted file mode 100644 index f22487c113..0000000000 --- a/GTG/gtk/browser/treeview_factory.py +++ /dev/null @@ -1,469 +0,0 @@ -# ----------------------------------------------------------------------------- -# Getting Things GNOME! - a personal organizer for the GNOME desktop -# Copyright (c) 2008-2013 - Lionel Dricot & Bertrand Rousseau -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU General Public License as published by the Free Software -# Foundation, either version 3 of the License, or (at your option) any later -# version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -# details. -# -# You should have received a copy of the GNU General Public License along with -# this program. If not, see . -# ----------------------------------------------------------------------------- - -import locale -from datetime import datetime -import xml.sax.saxutils as saxutils - -from gi.repository import GObject, Gtk, Pango - -from GTG.core.search import parse_search_query, search_filter -from GTG.core.tag import SEARCH_TAG -from GTG.core.task import Task -from gettext import gettext as _ -from GTG.gtk import colors -from GTG.gtk.browser.cell_renderer_tags import CellRendererTags -from GTG.core.dates import Date -from liblarch_gtk import TreeView - - -class TreeviewFactory(): - - def __init__(self, requester, config): - self.req = requester - self.mainview = self.req.get_tasks_tree() - self.config = config - - # Initial unactive color - # This is a crude hack. As we don't have a reference to the - # treeview to retrieve the style, we save that color when we - # build the treeview. - self.unactive_color = "#888a85" - - # List of keys for connecting/disconnecting Tag tree - self.tag_cllbcks = [] - - # Cache tags treeview for on_rename_tag callback - self.tags_view = None - - ############################# - # Functions for tasks columns - ################################ - def _has_hidden_subtask(self, task): - # not recursive - display_count = self.mainview.node_n_children(task.get_id()) - real_count = 0 - if task.has_child(): - for tid in task.get_children(): - sub_task = self.req.get_task(tid) - if sub_task and sub_task.get_status() == Task.STA_ACTIVE: - real_count = real_count + 1 - return display_count < real_count - - def get_task_bg_color(self, node, default_color): - if self.config.get('bg_color_enable'): - return colors.background_color(node.get_tags(), default_color, 0.5) - else: - return None - - def get_task_tags_column_contents(self, node): - """Returns an ordered list of tags of a task""" - tags = node.get_tags() - - search_parent = self.req.get_tag(SEARCH_TAG) - for search_tag in search_parent.get_children(): - tag = self.req.get_tag(search_tag) - match = search_filter( - node, - parse_search_query(tag.get_attribute('query')), - ) - if match and search_tag not in tags: - tags.append(tag) - - tags.sort(key=lambda x: x.get_name()) - return tags - - def get_task_title_column_string(self, node): - return saxutils.escape(node.get_title()) - - def get_task_label_column_string(self, node): - str_format = "%s" - - # We add the indicator when task is repeating - INDICATOR = "\u21BB " - if node.get_recurring(): - str_format = INDICATOR + str_format - - if node.get_status() == Task.STA_ACTIVE: - # we mark in bold tasks which are due today or as Now - days_left = node.get_days_left() - if days_left is not None and days_left <= 0: - str_format = f"{str_format}" - if self._has_hidden_subtask(node): - str_format = f"{str_format}" - - title = str_format % saxutils.escape(node.get_title()) - if node.get_status() == Task.STA_ACTIVE: - count = self.mainview.node_n_children(node.get_id(), recursive=True) - if count != 0: - title += f" ({count})" - elif node.get_status() == Task.STA_DISMISSED: - title = f"{title}" - - if self.config.get("contents_preview_enable"): - excerpt = saxutils.escape(node.get_excerpt(lines=1, - strip_tags=True, - strip_subtasks=True)) - title += " %s" \ - % (self.unactive_color, excerpt) - return title - - def get_task_startdate_column_string(self, node): - start_date = node.get_start_date() - if start_date: - return _(start_date.to_readable_string()) - else: - # Do not parse with gettext then, or you'll get undefined behavior. - return "" - - def get_task_duedate_column_string(self, node): - # For tasks with no due dates, we use the most constraining due date. - if node.get_due_date() == Date.no_date(): - # This particular call must NOT use the gettext "_" function, - # as you will get some very weird erratic behavior: - # strings showing up and changing on the fly when the mouse hovers, - # whereas no strings should even be shown at all. - return node.get_due_date_constraint().to_readable_string() - else: - # Other tasks show their due date (which *can* be fuzzy) - return _(node.get_due_date().to_readable_string()) - - def get_task_closeddate_column_string(self, node): - closed_date = node.get_closed_date() - if closed_date: - return _(closed_date.to_readable_string()) - else: - # Do not parse with gettext then, or you'll get undefined behavior. - return "" - - def sort_by_startdate(self, task1, task2, order): - t1 = task1.get_start_date() - t2 = task2.get_start_date() - return self.__date_comp_continue(task1, task2, order, t1, t2) - - def sort_by_duedate(self, task1, task2, order): - t1 = task1.get_urgent_date() - t2 = task2.get_urgent_date() - if t1 == Date.no_date(): - t1 = task1.get_due_date_constraint() - if t2 == Date.no_date(): - t2 = task2.get_due_date_constraint() - return self.__date_comp_continue(task1, task2, order, t1, t2) - - def sort_by_closeddate(self, task1, task2, order): - t1 = task1.get_closed_date() - t2 = task2.get_closed_date() - - # Convert both times to datetimes (accurate comparison) - if isinstance(t1, Date): - d = t1.date() - t1 = datetime(year=d.year, month=d.month, day=d.day) - if isinstance(t2, Date): - d = t2.date() - t2 = datetime(year=d.year, month=d.month, day=d.day) - return self.__date_comp_continue(task1, task2, order, t1, t2) - - def sort_by_title(self, task1, task2, order): - # Strip "@" and convert everything to lowercase to allow fair comparisons; - # otherwise, Capitalized Tasks get sorted after their lowercase equivalents, - # and tasks starting with a tag would get sorted before everything else. - t1 = task1.get_title().replace("@", "").lower() - t2 = task2.get_title().replace("@", "").lower() - return (t1 > t2) - (t1 < t2) - - def __date_comp_continue(self, task1, task2, order, t1, t2): - sort = (t2 > t1) - (t2 < t1) - - if sort != 0: # Ingore order, since this will be done automatically - - return sort - - # Dates are equal - # Group tasks with the same tag together for visual cleanness - t1_tags = task1.get_tags_name() - t1_tags.sort() - t2_tags = task2.get_tags_name() - t2_tags.sort() - sort = (t1_tags > t2_tags) - (t1_tags < t2_tags) - - if sort == 0: # Even tags are equal - # Break ties by sorting by title - sort = locale.strcoll(task1.get_title(), task2.get_title()) - - if order != Gtk.SortType.ASCENDING: - return -sort - return sort - - ############################# - # Functions for tags columns - ############################# - def get_tag_name(self, node): - label = node.get_attribute("label") - if label.startswith('@'): - label = label[1:] - - if node.get_attribute("nonactionable") == "True": - return f"{label}" - elif node.get_id() == 'search' and not node.get_children(): - return f"{label}" - else: - return label - - def get_tag_count(self, node): - if node.get_id() == 'search': - return "" - else: - toreturn = node.get_active_tasks_count() - return f"{toreturn}" - - def is_tag_separator_filter(self, tag): - return tag.get_attribute('special') == 'sep' - - def tag_sorting(self, t1, t2, order): - t1_sp = t1.get_attribute("special") - t2_sp = t2.get_attribute("special") - t1_name = locale.strxfrm(t1.get_name()) - t2_name = locale.strxfrm(t2.get_name()) - if not t1_sp and not t2_sp: - return (t1_name > t2_name) - (t1_name < t2_name) - elif not t1_sp and t2_sp: - return 1 - elif t1_sp and not t2_sp: - return -1 - else: - t1_order = t1.get_attribute("order") - t2_order = t2.get_attribute("order") - return (t1_order > t2_order) - (t1_order < t2_order) - - def on_tag_task_dnd(self, source, target): - task = self.req.get_task(source) - if target.startswith('@'): - task.add_tag(target) - elif target == 'gtg-tags-none': - for t in task.get_tags_name(): - task.remove_tag(t) - task.modified() - - ############################################ - # The Factory ############################## - ############################################ - def tags_treeview(self, tree): - desc = {} - - # Tag id - col_name = 'tag_id' - col = {} - col['renderer'] = ['markup', Gtk.CellRendererText()] - col['value'] = [str, lambda node: node.get_id()] - col['visible'] = False - col['order'] = 0 - col['sorting_func'] = self.tag_sorting - desc[col_name] = col - - # Tags color - col_name = 'color' - col = {} - render_tags = CellRendererTags(self.config) - render_tags.set_property('ypad', 5) - col['title'] = _("Tags") - col['renderer'] = ['tag', render_tags] - col['value'] = [GObject.TYPE_PYOBJECT, lambda node: node] - col['expandable'] = False - col['resizable'] = False - col['order'] = 1 - desc[col_name] = col - - # Tag names - col_name = 'tagname' - col = {} - render_text = Gtk.CellRendererText() - render_text.set_property('ypad', 5) - col['renderer'] = ['markup', render_text] - col['value'] = [str, self.get_tag_name] - col['expandable'] = True - col['new_column'] = False - col['order'] = 2 - desc[col_name] = col - - # Tag count - col_name = 'tagcount' - col = {} - render_text = Gtk.CellRendererText() - render_text.set_property('xpad', 17) - render_text.set_property('ypad', 5) - render_text.set_property('xalign', 1) - col['renderer'] = ['markup', render_text] - col['value'] = [str, self.get_tag_count] - col['expandable'] = False - col['new_column'] = False - col['order'] = 3 - desc[col_name] = col - - return self.build_tag_treeview(tree, desc) - - def active_tasks_treeview(self, tree): - # Build the title/label/tags columns - # Translators: Column name, containing the task titles - desc = self.common_desc_for_tasks(tree, _("Tasks")) - - # "startdate" column - col_name = 'startdate' - col = {} - # Translators: Column name, containing the start date - col['title'] = _("Start Date") - col['expandable'] = False - col['resizable'] = False - col['value'] = [str, self.get_task_startdate_column_string] - col['order'] = 3 - col['sorting_func'] = self.sort_by_startdate - desc[col_name] = col - - # 'duedate' column - col_name = 'duedate' - col = {} - # Translators: Column name, containing the due date - col['title'] = _("Due") - col['expandable'] = False - col['resizable'] = False - col['value'] = [str, self.get_task_duedate_column_string] - col['order'] = 4 - col['sorting_func'] = self.sort_by_duedate - desc[col_name] = col - - # Returning the treeview - treeview = self.build_task_treeview(tree, desc) - treeview.set_sort_column('duedate') - return treeview - - def closed_tasks_treeview(self, tree): - # Build the title/label/tags columns - # Translators: Column name, containing the task titles - desc = self.common_desc_for_tasks(tree, _("Closed Tasks")) - - # "startdate" column - col_name = 'closeddate' - col = {} - # Translators: Column name, containing the closed date - col['title'] = _("Closed Date") - col['expandable'] = False - col['resizable'] = False - col['value'] = [str, self.get_task_closeddate_column_string] - col['order'] = 3 - col['sorting_func'] = self.sort_by_closeddate - desc[col_name] = col - - # Returning the treeview - treeview = self.build_task_treeview(tree, desc) - treeview.set_sort_column('closeddate') - return treeview - - # This build the first tag/title columns, common - # to both active and closed tasks treeview - def common_desc_for_tasks(self, tree, title_label): - desc = {} - - # invisible 'task_id' column - col_name = 'task_id' - col = {} - col['renderer'] = ['markup', Gtk.CellRendererText()] - col['value'] = [str, lambda node: node.get_id()] - col['visible'] = False - col['order'] = 0 - desc[col_name] = col - - # invisible 'bg_color' column - col_name = 'bg_color' - col = {} - col['value'] = [str, lambda node: None] - col['visible'] = False - desc[col_name] = col - - # invisible 'title' column - col_name = 'title' - col = {} - render_text = Gtk.CellRendererText() - render_text.set_property("ellipsize", Pango.EllipsizeMode.END) - col['renderer'] = ['markup', render_text] - col['value'] = [str, self.get_task_title_column_string] - col['visible'] = False - col['order'] = 0 - col['sorting_func'] = self.sort_by_title - desc[col_name] = col - - # "tags" column (no title) - col_name = 'tags' - col = {} - render_tags = CellRendererTags(self.config) - render_tags.set_property('xalign', 0.0) - col['renderer'] = ['tag_list', render_tags] - col['value'] = [GObject.TYPE_PYOBJECT, self.get_task_tags_column_contents] - col['expandable'] = False - col['resizable'] = False - col['order'] = 1 - desc[col_name] = col - - # "label" column - col_name = 'label' - col = {} - col['title'] = title_label - render_text = Gtk.CellRendererText() - render_text.set_property("ellipsize", Pango.EllipsizeMode.END) - col['renderer'] = ['markup', render_text] - col['value'] = [str, self.get_task_label_column_string] - col['expandable'] = True - col['resizable'] = True - col['sorting'] = 'title' - col['order'] = 2 - desc[col_name] = col - return desc - - def build_task_treeview(self, tree, desc): - treeview = TreeView(tree, desc) - # Now that the treeview is done, we can polish - treeview.set_main_search_column('label') - treeview.set_expander_column('label') - treeview.set_dnd_name('gtg/task-iter-str') - # Background colors - treeview.set_bg_color(self.get_task_bg_color, 'bg_color') - # Global treeview properties - treeview.set_property("enable-tree-lines", False) - treeview.set_rules_hint(False) - treeview.set_multiple_selection(True) - # Updating the unactive color (same for everyone) - color = treeview.get_style_context().get_color(Gtk.StateFlags.INSENSITIVE) - # Convert color into #RRRGGGBBB - self.unactive_color = color.to_color().to_string() - return treeview - - def build_tag_treeview(self, tree, desc): - treeview = TreeView(tree, desc) - # Global treeview properties - treeview.set_property("enable-tree-lines", False) - treeview.set_rules_hint(False) - treeview.set_row_separator_func(self.is_tag_separator_filter) - treeview.set_headers_visible(False) - treeview.set_dnd_name('gtg/tag-iter-str') - treeview.set_dnd_external('gtg/task-iter-str', self.on_tag_task_dnd) - # Updating the unactive color (same for everyone) - color = treeview.get_style_context().get_color(Gtk.StateFlags.INSENSITIVE) - # Convert color into #RRRGGGBBB - self.unactive_color = color.to_color().to_string() - - treeview.set_sort_column('tag_id') - self.tags_view = treeview - return treeview diff --git a/GTG/gtk/colors.py b/GTG/gtk/colors.py index f1462ac907..10eec558a7 100644 --- a/GTG/gtk/colors.py +++ b/GTG/gtk/colors.py @@ -109,8 +109,7 @@ def background_color(tags, bgcolor=None, galpha_scale=1, use_alpha=True): gcolor = RGBA(red, green, blue) gcolor.alpha = (1.0 - abs(brightness - target_brightness)) * galpha_scale - # TODO: Remove GTK3 check on gtk4 port - if not use_alpha or Gtk.get_major_version() == 3: + if not use_alpha: gcolor.red = (1 - gcolor.alpha) * gcolor.red + gcolor.alpha * bgcolor.red gcolor.green = (1 - gcolor.alpha) * gcolor.green + gcolor.alpha * bgcolor.green gcolor.blue = (1 - gcolor.alpha) * gcolor.blue + gcolor.alpha * bgcolor.blue @@ -120,13 +119,13 @@ def background_color(tags, bgcolor=None, galpha_scale=1, use_alpha=True): return my_color -def get_colored_tag_markup(req, tag_name, html=False): +def get_colored_tag_markup(ds, tag_name, html=False): """ Given a tag name, returns a string containing the markup to color the tag name if html, returns a string insertable in html """ - tag = req.get_tag(tag_name) + tag = ds.tags.find(tag_name) if tag is None: # no task loaded with that tag, color cannot be taken return tag_name @@ -142,11 +141,11 @@ def get_colored_tag_markup(req, tag_name, html=False): return tag_name -def get_colored_tags_markup(req, tag_names): +def get_colored_tags_markup(ds, tag_names): """ Calls get_colored_tag_markup for each tag_name in tag_names """ - tag_markups = [get_colored_tag_markup(req, t) for t in tag_names] + tag_markups = [get_colored_tag_markup(ds, t) for t in tag_names] tags_txt = "" if tag_markups: # reduce crashes if applied to an empty list @@ -167,6 +166,21 @@ def generate_tag_color(): return my_color +def grgba_to_hex(rgba, ignore_alpha=True): + """ + Simply convert a Gdk.RGBA to a #ffffff style color representation, + Gdk.Color used to have this built in, however Gdk.RGBA.to_string + gives it in rgb(255, 255, 255) which can't be used in certain + cases. + """ + colors = [int(rgba.red * 255), int(rgba.green * 255), int(rgba.blue * 255)] + format_string = '#%02x%02x%02x' + if not ignore_alpha: + colors.append(int(rgba.alpha * 255)) + format_string += '%02x' + return format_string % tuple(colors) + + def color_add(present_color): if present_color not in used_color: diff --git a/GTG/gtk/data/backends.ui b/GTG/gtk/data/backends.ui index 242624a447..35743ff5be 100644 --- a/GTG/gtk/data/backends.ui +++ b/GTG/gtk/data/backends.ui @@ -9,151 +9,76 @@ 10 - False - mouse - - - - - True - False - 12 - vertical - 18 + - - True - False - 10 + + vertical + + + True + 210 + 400 + + + + + + + + - - True - False - vertical + + False - - 210 - 400 - True - False - - - + + _Add + True + list-add-symbolic - - True - True - 0 - - - True - False - icons - False - 1 - - - True - False - _Add - True - list-add-symbolic - - - - False - True - - - - - True - False - _Remove - True - list-remove-symbolic - - - - False - True - - - + + _Remove + True + list-remove-symbolic - - False - True - 1 - - - - True - True - 0 - - - - - 480 - True - True - adjustment1 - never - - True - False - - - + + True + end + Help + True + app.open_help + - - True - True - 1 - - - True - True - 0 - - - True - False - start + + + + + True + 480 + adjustment1 - - Help - True - True - True - app.open_help + + + + + - - False - False - 0 - - - False - True - 1 - diff --git a/GTG/gtk/data/calendar.ui b/GTG/gtk/data/calendar.ui index 1993333906..23f07571ae 100644 --- a/GTG/gtk/data/calendar.ui +++ b/GTG/gtk/data/calendar.ui @@ -1,60 +1,39 @@ - - + - False - GDK_STRUCTURE_MASK | GDK_PROXIMITY_OUT_MASK False True True - True - True - - - - + - True - False 12 12 12 12 vertical - - True - True - + - True - False 8 True Now - True - True True Soon - True - True True Someday - True - True True @@ -66,13 +45,11 @@ Clear - True - True True 8 - + diff --git a/GTG/gtk/data/context_menus.ui b/GTG/gtk/data/context_menus.ui index d415627df0..ff872790b8 100644 --- a/GTG/gtk/data/context_menus.ui +++ b/GTG/gtk/data/context_menus.ui @@ -1,6 +1,6 @@ - +
@@ -230,4 +230,49 @@
+ + + Edit... + tags_popup.edit_tag + + + Generate Color + tags_popup.generate_tag_color + + + + + + Edit... + search_popup.edit_search + + + Delete + search_popup.delete_search + + + + + + + Sort by Start Date + win.sort_by_start + + + + Sort by Due Date + win.sort_by_due + + + + Sort by Added Date + win.sort_by_added + + + + Sort by Modified Date + win.sort_by_modified + + +
diff --git a/GTG/gtk/data/general_preferences.ui b/GTG/gtk/data/general_preferences.ui index 9c1b519ac9..a8e0be1212 100644 --- a/GTG/gtk/data/general_preferences.ui +++ b/GTG/gtk/data/general_preferences.ui @@ -1,33 +1,23 @@ - - + 999 30 1 10 - - True - False + diff --git a/GTG/gtk/data/modify_tags.ui b/GTG/gtk/data/modify_tags.ui index 6acaebc9b3..d861d71343 100644 --- a/GTG/gtk/data/modify_tags.ui +++ b/GTG/gtk/data/modify_tags.ui @@ -1,67 +1,26 @@ - - - False - 12 + + diff --git a/GTG/gtk/data/plugins.ui b/GTG/gtk/data/plugins.ui index 6ef8628fc3..9e76b09dff 100644 --- a/GTG/gtk/data/plugins.ui +++ b/GTG/gtk/data/plugins.ui @@ -1,120 +1,46 @@ - - - - False - 5 - mouse - dialog - system-run-symbolic - - - - - - - - - True - False - vertical - 2 - - - True - False - end - - - - - True - False - 0 - none - - - True - False - 12 - True - - - - - True - False - <b>Dependencies</b> - True - - - - - - - - - False - 10 - center-on-parent - 750 + + + + + + + + diff --git a/GTG/gtk/data/style.css b/GTG/gtk/data/style.css index ca9a681902..951c688ba3 100644 --- a/GTG/gtk/data/style.css +++ b/GTG/gtk/data/style.css @@ -2,10 +2,27 @@ * MAIN WINDOW * -------------------------------------------------------------------------------- */ -.menu-disclose { +.menu-disclose button { min-width: 16px; } +.task-list { + border-top: 1px solid rgb(213, 208, 204); +} + +/* + We have to get move the padding into the boxes + so the background color of the rows reaches all + the way to the edges +*/ +.task-list row { + padding: 0; +} + +.task-list row > box { + padding: 15px 16px; +} + /* -------------------------------------------------------------------------------- * ERROR HANDLER * -------------------------------------------------------------------------------- @@ -36,10 +53,14 @@ border-bottom-color: alpha(rgb(0, 0, 0), 0.2); } -.recurring-active { +.recurring-active image { color: rgb(52, 130, 224); } +.backends-cont { + padding: 12px; +} + /* -------------------------------------------------------------------------------- * TEXT EDITOR * -------------------------------------------------------------------------------- @@ -62,3 +83,25 @@ textview check { .icon { font-size: 36px; } + +.hide { opacity: 0.2; } + +.color-pill { + background: #999; + padding: 0; + min-height: 16px; + min-width: 16px; + border: none; +} + +.arrow-only-expander { + border-spacing: 0; +} + +.arrow-only-expander indent { + -gtk-icon-size: 0px; +} + +.closed-task { + opacity: 0.5; +} \ No newline at end of file diff --git a/GTG/gtk/data/tag_editor.ui b/GTG/gtk/data/tag_editor.ui index 3076361973..b35063f71a 100644 --- a/GTG/gtk/data/tag_editor.ui +++ b/GTG/gtk/data/tag_editor.ui @@ -1,238 +1,160 @@ - - -