diff --git a/GTG/core/dbus/__init__.py b/GTG/core/dbus/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/GTG/core/dbus/dbus.py b/GTG/core/dbus/dbus.py new file mode 100644 index 0000000000..9832178c1f --- /dev/null +++ b/GTG/core/dbus/dbus.py @@ -0,0 +1,176 @@ +from gi.repository import GLib, Gio +import logging +import os +import traceback + + +log = logging.getLogger(__name__) + + +def _get_installed_xml_from_interface_name(interface_name: str): + """Return an DBus interface XML from system installed folders""" + raw_paths = GLib.get_system_data_dirs() + raw_paths.append(GLib.get_user_data_dir()) + for raw_path in raw_paths: + full_path = os.path.join(raw_path, 'dbus-1', 'interfaces', + interface_name + ".xml") + try: + with open(full_path, 'rt') as f: + log.debug(f"Found file {full_path}") + return f.read() + except OSError: + pass + return None + + +def _get_internal_xml_from_interface_name(interface_name: str): + """Return an DBus interface XML from internal data folders""" + log.debug("TODO: Implement this later") + pass + + +def get_xml_from_interface_name(interface_name: str, + use_internal: bool = True, + use_system: bool = True): + """ + Return an DBus interface XML from either provided by the system (distro) + or internal. + """ + if use_internal: + xml = _get_internal_xml_from_interface_name(interface_name) + if xml is not None: + return xml + if use_system: + xml = _get_installed_xml_from_interface_name(interface_name) + if xml is not None: + return xml + return None + + +class DBusReturnError(Exception): + """ + An error that an DBus interface implementation can throw to indicate + that it should return with that error. + """ + def __init__(self, name, message): + super().__init__(message) + self.name = str(name) + self.message = str(message) + + +class DBusInterfaceImplService(): + INTERFACE_NAME = None + NODE_INFO = None + INTERFACE_INFO = None + _object_id = None + _dbus_connection = None + + def __init__(self, wrapped_object=None): + if wrapped_object is None: + wrapped_object = self + self._wrapped_object = wrapped_object + + def __get_interface_info_from_node_info(self): + for interface in self.NODE_INFO.interfaces: + if interface.name == self.INTERFACE_NAME: + return interface + return None + + def __get_node_info(self): + xml = get_xml_from_interface_name(self.INTERFACE_NAME) + if xml is not None: + return Gio.DBusNodeInfo.new_for_xml(xml) + return None + + def _get_info(self): + if self.NODE_INFO is None and self.INTERFACE_NAME is not None: + self.NODE_INFO = self.__get_node_info() + log.debug(f"Got node info for %r: %r", + self.INTERFACE_NAME, self.NODE_INFO) + if self.NODE_INFO is not None and self.INTERFACE_INFO is None: + self.INTERFACE_INFO = self.__get_interface_info_from_node_info() + log.debug(f"Got interface info for %r: %r", + self.INTERFACE_NAME, self.INTERFACE_INFO) + + def dbus_register(self, dbus_connection, object_path): + """ + Register this implementation on the specified connection and object. + """ + self._get_info() + self._object_id = dbus_connection.register_object( + object_path, + self.INTERFACE_INFO, + self._handle_method_call, + self._handle_get_property, self._handle_set_property) + self._dbus_connection = dbus_connection + return self._object_id + + def dbus_unregister(self): + """Unregister this implementation.""" + if self._dbus_connection is not None and self._object_id is not None: + log.debug("Unregister %r on %r via %r", + self.INTERFACE_NAME, + self._dbus_connection, + self._object_id) + ret = self._dbus_connection.unregister_object(self._object_id) + self._dbus_connection = None + self._object_id = None + if not ret: + log.warn("Unregister for %r failed!", self.INTERFACE_NAME) + return ret + else: + log.warn("Trying to unregister not-registered %r", + self.INTERFACE_NAME) + return False + + def _handle_method_call(self, connection, + sender, object_path, + interface_name, method_name, + parameters, invocation): + dbg = "%s.%s%s on %s by %s" + dbg_args = (interface_name, method_name, parameters, + object_path, sender) + log.debug(f"Called {dbg}", *dbg_args) + + try: + method = self._wrapped_object.__getattribute__(method_name) + except AttributeError as e: + log.debug(f"{dbg} → Internal python exception: %s", *dbg_args, e) + invocation.return_dbus_error("python." + type(e).__name__, str(e)) + return + + try: + ret = method(*parameters.unpack()) + except GLib.Error as e: + log.debug(f"{dbg} → GLib Error: %s", *dbg_args, e) + invocation.return_gerror(e) + except DBusReturnError as e: + log.debug(f"{dbg} → Custom Error: %s: %s", + *dbg_args, e.name, e.message) + invocation.return_dbus_error(e.name, e.message) + except Exception as e: + log.warn(f"{dbg} → Python exception: %s", *dbg_args, e) + traceback.print_exc() + invocation.return_dbus_error("python." + type(e).__name__, str(e)) + else: + if type(ret) != GLib.Variant: + if len(invocation.get_method_info().out_args) == 1: + ret = (ret,) # Make one-value return work as expected + ret = GLib.Variant(self.__get_return_variant(invocation), ret) + log.debug(f"{dbg} → Returning %r", *dbg_args, ret) + invocation.return_value(ret) + + def _handle_get_property(self, *args): + """Get a DBus-available property""" + log.debug(f"TODO: Handle get property: {args}") # Not used currently + return None + + def _handle_set_property(self, *args): + """Set a DBus-available property""" + log.debug(f"TODO: Handle set property: {args}") # Not used currently + return None + + def __get_return_variant(self, invocation): + """Get Variant Type string that should be returned for a DBus method""" + info = invocation.get_method_info() + return '(' + "".join([arg.signature for arg in info.out_args]) + ')' diff --git a/GTG/core/dbus/tasks.py b/GTG/core/dbus/tasks.py new file mode 100644 index 0000000000..d347bd743d --- /dev/null +++ b/GTG/core/dbus/tasks.py @@ -0,0 +1,355 @@ + +# Implementation of the org.gnome.GTG.Tasks interface + +import datetime +import logging +import enum +from gi.repository import GLib +from GTG.core.search import parse_search_query, InvalidQuery +from GTG.core.dates import Date +from GTG.core.task import Task +from .dbus import DBusInterfaceImplService, DBusReturnError + + +__all__ = ('DBusImplTasks', 'InvalidTaskDict', 'TaskDict', 'TaskStatus') +log = logging.getLogger(__name__) + + +class TaskStatus(enum.IntEnum): + """Task status for the DBus-Interface.""" + + unknown = 0 + """Unknown status, normally you shouldn't use and see this.""" + active = 1 + """Task is still active (and is open).""" + dismissed = 2 + """Task has been dismissed (and is closed).""" + done = 3 + """Task has been completed (and is closed).""" + + @classmethod + def status_to_enum(cls, status: str): + _status_to_enum_map = { + Task.STA_ACTIVE: cls.active, + Task.STA_DISMISSED: cls.dismissed, + Task.STA_DONE: cls.done, + } + return _status_to_enum_map.get(status, cls.unknown) + + @classmethod + def enum_to_status(cls, e): + _enum_to_status_map = { + cls.active: Task.STA_ACTIVE, + cls.dismissed: Task.STA_DISMISSED, + cls.done: Task.STA_DONE, + } + return _enum_to_status_map.get(e, '') + + +class TaskDict(dict): + """Helper class for managing a task dictionary.""" + + @classmethod + def from_task(cls, task: Task): + """ + Converts the specified task to an task dictionary. + """ + d = TaskDict() + d["id"] = task.get_id() + d["status"] = TaskStatus.status_to_enum(task.get_status()) + d["title"] = task.get_title() + d["duedate"] = _date_to_string(task.get_due_date()) + d["startdate"] = _date_to_string(task.get_start_date()) + d["donedate"] = _date_to_string(task.get_closed_date()) + d["tags"] = task.get_tags_name() + d["text"] = task.get_text() + d["children"] = task.get_children() + d["parents"] = task.get_parents() + return d + + _to_variant_type = { + "id": 's', + "status": 'i', + "title": 's', + "duedate": 's', + "startdate": 's', + "donedate": 's', + "tags": 'as', + "text": 's', + "children": 'as', + "parents": 'as', + } + + def to_variant(self) -> dict: + """ + Convert an task dict to a variant dict to be submitted over DBus. + """ + d = dict(self) + for name, vtype in self._to_variant_type.items(): + if name in d: + d[name] = GLib.Variant(vtype, d[name]) + return d + # return GLib.Variant("a{sv}", d) breaks GLib.Variant('aa{sv}', [dv]) + + +def _task_to_variant(task: Task) -> dict: + """ + Convert an task object to a variant dict to be submitted over DBus. + """ + return TaskDict.from_task(task).to_variant() + + +def _variant_to_task_dict(task_variant) -> TaskDict: + """ + Convert an variant dict to a task dict. + """ + return TaskDict(task_variant.unpack()) + + +def _date_to_string(date: Date): + """ + Convert a gtg date to either an english fuzzy string or to ISO format. + """ + if date == Date.now(): + return "now" + if date == Date.no_date(): + return "" + if date == Date.soon(): + return "soon" + if date == Date.someday(): + return "someday" + return date.isoformat() # Uses the wrapped python date object + + +_string_to_date_map = { + "": Date.no_date(), + "now": Date.now(), + "soon": Date.soon(), + "someday": Date.someday(), +} + + +def _string_to_date(sdate: str) -> Date: + """ + Convert string to the correct a gtg date without localization. + """ + try: + return _string_to_date_map[sdate] + except KeyError: + try: + return Date(datetime.date.fromisoformat(sdate)) + except ValueError: + raise DBusReturnError("gtg.InvalidDateFormat", + f"Invalid date format '{sdate}'") + + +class InvalidTaskDict(DBusReturnError): + """ + Return DBus Error about an invalid type for a task dictionary. + """ + def __init__(self, what, got, expected): + got = got.__name__ + expected = expected.__name__ + super().__init__("gtg.InvalidTaskDict", + f"{what} is a {got}, but expected {expected}") + + +class DBusImplTasks(DBusInterfaceImplService): + INTERFACE_NAME = 'org.gnome.GTG.Tasks' + + def __init__(self, req): + super().__init__() + self.req = req + + tree = req.get_main_view() + # TODO: Register signals + # tree.register_cllbck('node-added', lambda tid, _: + # self.TaskAdded(tid)) + # tree.register_cllbck('node-modified', lambda tid, _: + # self.TaskModified(tid)) + # tree.register_cllbck('node-deleted', lambda tid, _: + # self.TaskDeleted(tid)) + + def _get_task(self, tid): + """ + Return task from a tid, otherwise return an not-found DBus error. + """ + task = self.req.get_task(tid) + if task is None: + raise DBusReturnError("gtg.TaskNotFound", + f"Task not found by id '{tid}'") + return task + + def GetTasks(self, tids: list) -> list: + log.debug(f"Doing GetTasks({tids})") + return [_task_to_variant(self._get_task(tid)) for tid in tids] + + def GetActiveTaskIds(self) -> list: + log.debug(f"Doing GetActiveTaskIds()") + return self.GetTaskIdsFiltered(['active', 'workable']) + + def GetActiveTasks(self) -> list: + log.debug(f"Doing GetActiveTasks()") + return self.GetTasks(self.GetActiveTaskIds()) + + def GetTaskIdsFiltered(self, filters: list) -> list: + log.debug(f"Doing GetTasksFiltered({filters})") + tree = self.req.get_tasks_tree().get_basetree() + view = tree.get_viewtree(name=None) # Anonymous viewtree + last_index = len(filters) - 1 + for i, filter in enumerate(filters): + is_last = i == last_index + if filter[0] == '!': + view.apply_filter(filter[1:], parameters={'negate': 1}, + refresh=is_last) + else: + view.apply_filter(filter, refresh=is_last) + return list(view.get_all_nodes()) + + def GetTasksFiltered(self, filters: list) -> list: + log.debug(f"Doing GetTasksFiltered({filters})") + return self.GetTasks(self.GetTaskIdsFiltered(filters)) + + def SearchTaskIds(self, query: str) -> list: + log.debug(f"Doing SearchTaskIds({query})") + tree = self.req.get_tasks_tree().get_basetree() + view = tree.get_viewtree() + try: + search = parse_search_query(query) + view.apply_filter('search', parameters=search) + tasks = view.get_all_nodes() + if tasks: + return tasks + except InvalidQuery as e: + raise DBusReturnError("gtg.InvalidQuery", + f"Invalid Query '{query}': {e}") + return [] + + def SearchTasks(self, query: str) -> list: + log.debug(f"Doing SearchTasks({query})") + return self.GetTasks(self.SearchTaskIds(query)) + + def HasTasks(self, tids: list) -> dict: + log.debug(f"Doing HasTasks({tids})") + return {tid: self.req.has_task(tid) for tid in tids} + + def DeleteTasks(self, tids: list) -> dict: + log.debug(f"Doing DeleteTasks({tids})") + d = {} + for tid in tids: + d[tid] = self.req.has_task(tid) + if d[tid]: # Task exists, so let's delete it + self.req.delete_task(tid) + return d + + def NewTasks(self, tasks: list) -> list: + log.debug(f"Doing NewTasks({tasks})") + r = [] + for new_task_dict in tasks: + self._verify_task_dict(new_task_dict) + new_task = self.req.new_task() + self._modify_task(new_task, new_task_dict) + r.append(_task_to_variant(new_task)) + return r + + def ModifyTasks(self, patches: list) -> list: + log.debug(f"Doing ModifyTasks({patches})") + r = [] + for patch in patches: + if "id" not in patch: + raise DBusReturnError("gtg.MissingTaskId", + "No 'id' in task dict") + self._verify_task_dict(patch) + task = self._get_task(patch["id"]) + patched_task = self._modify_task(task, patch) + r.append(_task_to_variant(patched_task)) + return r + + def _modify_task(self, task: Task, patch: dict) -> Task: + """Modify a single task and return it""" + if "title" in patch: + task.set_title(patch["title"]) + if "text" in patch: + task.set_text(patch["text"]) + if "duedate" in patch: + task.set_due_date(_string_to_date(patch["duedate"])) + if "startdate" in patch: + task.set_start_date(_string_to_date(patch["startdate"])) + if "status" in patch: + task.set_status(TaskStatus.enum_to_status(patch["status"])) + if "donedate" in patch: + old_status = task.get_status() + donedate = _string_to_date(patch["donedate"]) + task.set_status(old_status, donedate=donedate) + if "tags" in patch: + old_tags = set(task.get_tags_name()) + new_tags = set(patch["tags"]) + common_tags = old_tags & new_tags + for removed_tag in old_tags - common_tags: + task.remove_tag(removed_tag) + for added_tag in new_tags - common_tags: + task.add_tag(added_tag) + if "children" in patch: + log.debug("TODO: Implement patching subtask list") # TODO + if "parents" in patch: + log.debug("TODO: Implement patching parents") # TODO + return task + + def _verify_task_dict_types(self, td: dict): + """ + Check if an task dict has the correct types. + """ + # Reusing TaskDict._to_variant_type because it already contains the + # types it uses for serializing + for key, vartype in TaskDict._to_variant_type.items(): + if key not in td: + continue + + if vartype == 'i': + expected_type = int + elif vartype == 's': + expected_type = str + elif vartype[0] == 'a': + expected_type = list + else: + raise RuntimeError("Unknown vartype") + + if type(td[key]) is not expected_type: + raise InvalidTaskDict(f"'{key}'", type(td[key]), expected_type) + + if vartype[0] != 'a': + continue + + if vartype[1] == 's': + expected_type = str + else: + log.debug("Unknown vartype, can't verify children: %r", + vartype) + continue + for elem in td[key]: + if type(elem) is not expected_type: + raise InvalidTaskDict(f"'{key}' element", + type(elem), expected_type) + + def _verify_task_dict(self, td: dict): + """ + Verify the types and whenever the values are valid for a task dict. + """ + self._verify_task_dict_types(td) + if "startdate" in td: + date = _string_to_date(td["startdate"]) + if date.is_fuzzy() and date != Date.no_date(): + raise DBusReturnError("gtg.InvalidStartDate", + f"Invalid start date '{td['startdate']}'") + + if "donedate" in td: # TODO: What about status? + date = _string_to_date(td["donedate"]) + if date.is_fuzzy() and date != Date.no_date(): + raise DBusReturnError("gtg.InvalidDoneDate", + f"Invalid done date '{td['donedate']}'") + + if "status" in td: + status = TaskStatus.enum_to_status(td["status"]) + if status == '': + raise DBusReturnError("gtg.InvalidStatus", + f"Invalid status {td['status']}") diff --git a/GTG/core/meson.build b/GTG/core/meson.build index 3552e9e6f5..912159c023 100644 --- a/GTG/core/meson.build +++ b/GTG/core/meson.build @@ -52,5 +52,12 @@ gtg_core_plugin_sources = [ 'plugins/engine.py', ] +gtg_core_dbus_sources = [ + 'dbus/__init__.py', + 'dbus/dbus.py', + 'dbus/tasks.py', +] + python3.install_sources(gtg_core_sources, subdir: 'GTG' / 'core', pure: true) python3.install_sources(gtg_core_plugin_sources, subdir: 'GTG' / 'core' / 'plugins', pure: true) +python3.install_sources(gtg_core_dbus_sources, subdir: 'GTG' / 'core' / 'dbus', pure: true) diff --git a/GTG/gtk/application.py b/GTG/gtk/application.py index 9f4c435ae7..048644afa9 100644 --- a/GTG/gtk/application.py +++ b/GTG/gtk/application.py @@ -40,6 +40,7 @@ from GTG.gtk.backends import BackendsDialog from GTG.gtk.browser.tag_editor import TagEditor from GTG.core.timer import Timer +from GTG.core.dbus.tasks import DBusImplTasks log = logging.getLogger(__name__) @@ -117,6 +118,10 @@ def do_startup(self): self.init_style() + self.dbus_tasks = DBusImplTasks(self.req) + self.dbus_tasks.dbus_register(self.get_dbus_connection(), + self.get_dbus_object_path()) + def do_activate(self): """Callback when launched from the desktop.""" @@ -564,6 +569,9 @@ def do_shutdown(self): # Save data and shutdown datastore backends self.req.save_datastore(quit=True) + if self.dbus_tasks: + self.dbus_tasks.dbus_unregister() + Gtk.Application.do_shutdown(self) # -------------------------------------------------------------------------- diff --git a/data/dbus/meson.build b/data/dbus/meson.build new file mode 100644 index 0000000000..b45999c18a --- /dev/null +++ b/data/dbus/meson.build @@ -0,0 +1,8 @@ +configure_file( + input: rdnn_name + '.Tasks.xml', + output: rdnn_name + '.Tasks.xml', + copy: true, + install: true, + install_dir: dbusinterfacesdir +) + diff --git a/data/dbus/org.gnome.GTG.Tasks.xml b/data/dbus/org.gnome.GTG.Tasks.xml new file mode 100644 index 0000000000..d6d3ce1eb7 --- /dev/null +++ b/data/dbus/org.gnome.GTG.Tasks.xml @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/data/meson.build b/data/meson.build index b93f412abb..ce27329592 100644 --- a/data/meson.build +++ b/data/meson.build @@ -34,3 +34,4 @@ configure_file( ) subdir('icons') +subdir('dbus') diff --git a/meson.build b/meson.build index 4035f5c436..66b42a3806 100644 --- a/meson.build +++ b/meson.build @@ -20,6 +20,7 @@ datadir = prefix / get_option('datadir') appdatadir = datadir / 'metainfo' desktopdir = datadir / 'applications' dbusservicedir = datadir / 'dbus-1' / 'services' +dbusinterfacesdir = datadir / 'dbus-1' / 'interfaces' icondir = datadir / 'icons' pythondir = python3.get_path('purelib') rdnn_name = 'org.gnome.GTG'