From 7eb789c651c3c6584394150d478e8e3d94f90a7f Mon Sep 17 00:00:00 2001 From: Stephen Cross Date: Fri, 16 Aug 2024 20:53:51 -0400 Subject: [PATCH] New service: Logseq (#1052) * logseq: WIP new service * bracket replacement config options * inline_links option, handle waiting tasks * fix doc format issues * Fix line length * README.rst updates from code review Batch suggested updates Co-authored-by: ryneeverett * Apply suggestions from code review Batch suggested changes Co-authored-by: ryneeverett * Address more code review comments * Fix broken test * Add note about taskw character escaping issue * Improved tag support for #[[mutli word]] style tags * Address remaining review comments --------- Co-authored-by: ryneeverett --- bugwarrior/README.rst | 2 + bugwarrior/docs/services/logseq.rst | 183 +++++++++++ .../docs/services/pictures/logseq_token.png | Bin 0 -> 28611 bytes bugwarrior/services/logseq.py | 309 ++++++++++++++++++ setup.py | 1 + tests/test_logseq.py | 100 ++++++ 6 files changed, 595 insertions(+) create mode 100644 bugwarrior/docs/services/logseq.rst create mode 100644 bugwarrior/docs/services/pictures/logseq_token.png create mode 100644 bugwarrior/services/logseq.py create mode 100644 tests/test_logseq.py diff --git a/bugwarrior/README.rst b/bugwarrior/README.rst index 55be47bb..d255b2f2 100644 --- a/bugwarrior/README.rst +++ b/bugwarrior/README.rst @@ -22,6 +22,7 @@ It currently supports the following remote resources: - `Gmail `_ - `Jira `_ - `Kanboard `_ +- `Logseq `_ - `Nextcloud Deck `_ - `Pagure `_ - `Phabricator `_ @@ -74,3 +75,4 @@ Contributors - Andrew Demas (contributed support for PivotalTracker) - Florian Preinstorfer (contributed support for Kanboard) - Lena Brüder (contributed support for Nextcloud Deck) +- Stephen Cross (contributed support for Logseq) diff --git a/bugwarrior/docs/services/logseq.rst b/bugwarrior/docs/services/logseq.rst new file mode 100644 index 00000000..56955717 --- /dev/null +++ b/bugwarrior/docs/services/logseq.rst @@ -0,0 +1,183 @@ +Logseq +====== + +You can import `tasks `_ from `Logseq `_ using the ``logseq`` service name. + + +Additional Requirements +----------------------- + +To use bugwarrior to pull tickets from Logseq you need to enable the Logseq HTTP APIs server. +In Logseq go to **Settings** > **Features** and toggle the **HTTP APIs server** option. + +Next select the **API** option in the top menu to configure authorization token, e.g + +.. image:: pictures/logseq_token.png + + +Example Service +--------------- + +Here's an example of a Logseq target: + +.. config:: + + [my_logseq_graph] + service = logseq + logseq.token = mybugwarrioraccesstoken + +The above example is the minimum required to import issues from Logseq. +You can also feel free to use any of the configuration options described in +:ref:`common_configuration_options` or described in `Service Features`_ below. + +Service Features +---------------- + +Host and port ++++++++++++++ + +By default the service connects to Logseq on your local machine at `localhost:12315`. If you have +Logseq on another host or using a different port you can change the setting using: + +.. config:: + :fragment: logseq + + logseq.host = anotherhost.home.lan + logseq.port = 12315 + + +Authorization Token ++++++++++++++++++++ + +The authorization token is used to authenticate with Logseq. This value is required and must match +the one of the authorization tokens set in Logseq HTTP APIs server settings. + +.. config:: + :fragment: logseq + + logseq.token = mybugwarrioraccesstoken + + +Task filters +++++++++++++ + +You can configure the service to import tasks in different states. +By default the service will import all tasks in an active tasks states + + DOING, TODO, NOW, LATER, IN-PROGRESS, WAIT, WAITING + +You can override this filter by setting the ``task_state`` option to a +comma separated list of required task states. + +.. config:: + :fragment: logseq + + logseq.task_state = DOING, NOW, IN-PROGRESS + +Task state and data/time mappings ++++++++++++++++++++++++++++++++++ + +``DOING``, ``TODO``, ``NOW``, ``LATER``, and ``IN-PROGRESS`` are mapped to the default ``pending`` state. +The Logseq task ``SCHEDULED:`` and ``DEADLINE:`` fields are mapped to the ``scheduled`` and +``due`` date fields. + +``WAITING`` and ``WAIT`` are dynamically mapped to either ``pending`` or ``waiting`` states based on +the ``wait`` date. The ``SCHEDULED:`` date or ``DEADLINE`` date is used to set the ``wait`` date on the +task. If no scheduled or deadline date is available then the wait date is set to ``someday`` +(see ``Date and Time Synonyms ``_). +Future dated waiting tasks can be listed using ``task waiting`` + +``DONE`` is mapped to the ``completed`` state. + +``CANCELED`` and ``CANCELLED`` are mapped to the ``deleted`` state. + +Priority mapping +++++++++++++++++ + +Logseq task priorities ``A``, ``B``, and ``C`` are mapped to the taskwarrior priorities +``H``, ``M``, and ``L`` respectively. + +Character replacement ++++++++++++++++++++++ + +This capability is in part to workaround ``ralphbean/taskw#172 ``_ +which causes the ``[`` and ``]`` characters commonly used in Logseq to be over escaped as ``&open;`` and ``&close;`` +when they are synced using bugwarrior. + +To avoid display issues ``[[`` and ``]]`` are replaced by ``【`` and ``】`` for page links, and single +``[`` and ``]`` are replaced by ``〈`` and ``〉``. + +You can override this default behaviour to use alternative custom characters by setting the ``char_*`` options. + +.. config:: + :fragment: logseq + + logseq.char_open_link = 〖 + logseq.char_close_link = 〗 + logseq.char_open_bracket = ( + logseq.char_close_bracket = ) + +Logseq URI links +++++++++++++++++ + +A ``logseq://`` URI is generated for each task to enable easy navigation directly to the specific task in +the Logseq application. + +By default bugwarrior incorporates the links into task description. To disable this behaviour either +modify the ``inline_links`` option in the main section to affect all services, or to modify for the logseg sevice only you can +set it in your Logseq section. + +.. config:: + :fragment: logseq + + logseq.inline_links = False + +Unlike regular ``http://`` links, most terminals do not make application specific URIs clickable. +A simple way to quickly open a a task in Logseq from the command line is to add a helper function to your +shell that extacts the Logseq URI and opens it using the system specific launcher. For example, to open the +Logseq URI in MacOS add the following to your ``~/..zshrc`` + +.. code-block:: bash + + # open a specific taskwarrior task in Logseq + function taskopen() { + open $(task $1 | grep "Logseq URI" | sed -r 's/^Logseq URI//') + } + +From the command line you can open a specific task using taskwarior task id, e.g. ``taskopen 1234``. + +Tags +++++ + +LogSeq tasks with ``#tag`` style tag entries in the description are added to the Taskwarrior tags. +Multi and single word tags using the Logseq ``#[[Tag]]`` or ``#[[Multi Word]]`` format are +condenced to a ``#Tag`` and ``#MultiWord`` style before adding the Taskwarrior tags. The format of +the tag content in task desciption is unchanged. + + +Troubleshooting +--------------- + +Logseq graph re-index ++++++++++++++++++++++ + +If you re-index your Logseq graph all task ids and uuids are changed. The next time +you run bugwarrior all existing taskwarrior tasks will be closed and new ones will +be created. + +Logseq API connection issues +++++++++++++++++++++++++++++ + +If you get the following error when running bugwarrior: + + CRITICAL:bugwarrior.services.logseq:Unable to connect to Logseq HTTP APIs server. HTTPConnectionPool(host='localhost', port=12315): Max retries exceeded with url: /api (Caused by NewConnectionError(': Failed to establish a new connection: [Errno 61] Connection refused')) + +- Check that the LogSeq application is running +- Check that the HTTP APIs server is started +- Check that authorization token is set in the API server settings and matches the + ``token``. + +Provided UDA Fields +------------------- + +.. udas:: bugwarrior.services.logseq.LogseqIssue diff --git a/bugwarrior/docs/services/pictures/logseq_token.png b/bugwarrior/docs/services/pictures/logseq_token.png new file mode 100644 index 0000000000000000000000000000000000000000..c06a16f6fcd5849aadbef7e6527e2a7b95768708 GIT binary patch literal 28611 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 CioeDH literal 0 HcmV?d00001 diff --git a/bugwarrior/services/logseq.py b/bugwarrior/services/logseq.py new file mode 100644 index 00000000..03d02ce0 --- /dev/null +++ b/bugwarrior/services/logseq.py @@ -0,0 +1,309 @@ +import logging + +import requests +import typing_extensions + +import re +from datetime import datetime + +from bugwarrior import config +from bugwarrior.services import IssueService, Issue, ServiceClient + +log = logging.getLogger(__name__) + + +class LogseqConfig(config.ServiceConfig): + service: typing_extensions.Literal["logseq"] + host: str = "localhost" + port: int = 12315 + token: str + task_state: config.ConfigList = [ + "DOING", "TODO", "NOW", "LATER", "IN-PROGRESS", "WAIT", "WAITING" + # states DONE and CANCELED/CANCELLED are skipped by default + ] + char_open_link: str = "【" + char_close_link: str = "】" + char_open_bracket: str = "〈" + char_close_bracket: str = "〉" + inline_links: bool = True + + +class LogseqClient(ServiceClient): + def __init__(self, host, port, token, filter): + self.host = host + self.port = port + self.token = token + self.filter = filter + + self.headers = { + "Authorization": "Bearer " + self.token, + "content-type": "application/json; charset=utf-8", + } + + def _datascript_query(self, query): + try: + response = requests.post( + f"http://{self.host}:{self.port}/api", + headers=self.headers, + json={"method": "logseq.DB.datascriptQuery", "args": [query]}, + ) + return self.json_response(response) + except requests.exceptions.ConnectionError as ce: + log.fatal("Unable to connect to Logseq HTTP APIs server. %s", ce) + exit(1) + + def _get_current_graph(self): + try: + response = requests.post( + f"http://{self.host}:{self.port}/api", + headers=self.headers, + json={"method": "logseq.getCurrentGraph", "args": []}, + ) + return self.json_response(response) + except requests.exceptions.ConnectionError as ce: + log.fatal("Unable to connect to Logseq HTTP APIs server. %s", ce) + exit(1) + + def get_graph_name(self): + graph = self._get_current_graph() + return graph["name"] if graph else None + + def get_issues(self): + query = f""" + [:find (pull ?b [*]) + :where [?b :block/marker ?marker] + [(contains? #{{{self.filter}}} ?marker)] + ] + """ + result = self._datascript_query(query) + if "error" in result: + log.fatal("Error querying Logseq: %s using query %s", result["error"], query) + exit(1) + return result + + +class LogseqIssue(Issue): + ID = "logseqid" + UUID = "logsequuid" + STATE = "logseqstate" + TITLE = "logseqtitle" + DONE = "logseqdone" + URI = "logsequri" + + # Local 2038-01-18, with time 00:00:00. + # A date far away, with semantically meaningful to GTD users. + # see https://taskwarrior.org/docs/dates/ + SOMEDAY = datetime(2038, 1, 18) + + UDAS = { + ID: { + "type": "string", + "label": "Logseq ID", + }, + UUID: { + "type": "string", + "label": "Logseq UUID", + }, + STATE: { + "type": "string", + "label": "Logseq State", + }, + TITLE: { + "type": "string", + "label": "Logseq Title", + }, + DONE: { + "type": "date", + "label": "Logseq Done", + }, + URI: { + "type": "string", + "label": "Logseq URI", + }, + } + + UNIQUE_KEY = (ID, UUID) + + # map A B C priority to H M L + PRIORITY_MAP = { + "A": "H", + "B": "M", + "C": "L", + } + + # `pending` is the defuault state. Taskwarrior will dynamcily change task to `waiting` + # state if wait date is set to a future date. + STATE_MAP = { + "IN-PROGRESS": "pending", + "DOING": "pending", + "TODO": "pending", + "NOW": "pending", + "LATER": "pending", + "WAIT": "pending", + "WAITING": "pending", + "DONE": "completed", + "CANCELED": "deleted", + "CANCELLED": "deleted", + } + + # replace characters that cause escaping issues like [] and " + # this is a workaround for https://github.com/ralphbean/taskw/issues/172 + def _unescape_content(self, content): + return ( + content.replace('"', "'") # prevent &dquote; in task details + .replace("[[", self.config.char_open_link) # alternate brackets for linked items + .replace("]]", self.config.char_close_link) + .replace("[", self.config.char_open_bracket) # prevent &open; and &close; + .replace("]", self.config.char_close_bracket) + ) + + # remove brackets and spaces to compress display format of mutli work tags + # e.g from #[[Multi Word]] to #MultiWord + def _compress_tag_format(self, tag): + return ( + tag.replace(self.config.char_open_link, "") + .replace(" ", "") + .replace(self.config.char_close_link, "") + ) + + # get an optimized and formatted title + def get_formatted_title(self): + # use first line only and remove priority + first_line = ( + self.record["content"] + .split("\n")[0] # only use first line + .replace("[#A] ", "") # remove priority markers + .replace("[#B] ", "") + .replace("[#C] ", "") + ) + return self._unescape_content(first_line) + + # get a list of tags from the task content + def get_tags_from_content(self): + # this includes #tagname, but ignores tags that are in the #[[tag name]] format + tags = re.findall( + r"(#[^" + self.config.char_open_link + r"^\s]+)", + self.get_formatted_title() + ) + # and this adds the #[[multi word]] formatted tags + tags.extend(re.findall( + r"(#[" + self.config.char_open_link + r"].*[" + self.config.char_close_link + r"])", + self.get_formatted_title() + )) + # compress format to single words + tags = [self._compress_tag_format(t) for t in tags] + return tags + + # get a list of annotations from the content + def get_annotations_from_content(self): + annotations = [] + scheduled_date = None + deadline_date = None + for line in self.record["content"].split("\n"): + # handle special annotations + if line.startswith("SCHEDULED: "): + scheduled_date = self.get_scheduled_date(line) + elif line.startswith("DEADLINE: "): + deadline_date = self.get_scheduled_date(line) + else: + annotations.append(self._unescape_content(line)) + annotations.pop(0) # remove first line + return annotations, scheduled_date, deadline_date + + def get_url(self): + return f'logseq://graph/{self.extra["graph"]}?block-id={self.record["uuid"]}' + + def get_logseq_state(self): + return self.record["marker"] + + def get_scheduled_date(self, scheduled): + # format is + # e.g. <2024-06-20 Thu 10:55 .+1d> + date_split = ( + scheduled.replace("DEADLINE: <", "") + .replace("SCHEDULED: <", "") + .replace(">", "") + .split(" ") + ) + if len(date_split) == 2: # + date = date_split[0] + date_format = "%Y-%m-%d" + elif len(date_split) == 3 and (date_split[2][0] in ("+", ".")): # + date = date_split[0] + date_format = "%Y-%m-%d" + elif len(date_split) == 3: # + date = date_split[0] + " " + date_split[2] + date_format = "%Y-%m-%d %H:%M" + elif len(date_split) == 4: # + date = date_split[0] + " " + date_split[2] + date_format = "%Y-%m-%d %H:%M" + else: + log.warning(f"Could not determine date format from {scheduled}") + return None + + try: + return datetime.strptime(date, date_format) + except ValueError: + log.warning(f"Could not parse date {date} from {scheduled}") + return None + + def _is_waiting(self): + return self.get_logseq_state() in ["WAIT", "WAITING"] + + def to_taskwarrior(self): + annotations, scheduled_date, deadline_date = self.get_annotations_from_content() + wait_date = min([d for d in [scheduled_date, deadline_date, self.SOMEDAY] if d is not None]) + return { + "project": self.extra["graph"], + "priority": ( + self.PRIORITY_MAP[self.record["priority"]] + if "priority" in self.record + else None + ), + "annotations": annotations, + "tags": self.get_tags_from_content(), + "due": deadline_date, + "scheduled": scheduled_date, + "wait": wait_date if self._is_waiting() else None, + "status": self.STATE_MAP[self.get_logseq_state()], + self.ID: self.record["id"], + self.UUID: self.record["uuid"], + self.STATE: self.record["marker"], + self.TITLE: self.get_formatted_title(), + self.URI: self.get_url(), + } + + def get_default_description(self): + return self.build_default_description( + title=self.get_formatted_title(), + url=self.get_url() if self.config.inline_links else '', + number=self.record["id"], + cls="issue", + ) + + +class LogseqService(IssueService): + ISSUE_CLASS = LogseqIssue + CONFIG_SCHEMA = LogseqConfig + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + filter = '"' + '" "'.join(self.config.task_state) + '"' + self.client = LogseqClient( + host=self.config.host, + port=self.config.port, + token=self.config.token, + filter=filter, + ) + + def get_owner(self, issue): + # Issue assignment hasn't been implemented yet. + raise NotImplementedError( + "This service has not implemented support for 'only_if_assigned'." + ) + + def issues(self): + graph_name = self.client.get_graph_name() + for issue in self.client.get_issues(): + extra = {"graph": graph_name} + yield self.get_issue_for_record(issue[0], extra) diff --git a/setup.py b/setup.py index 364b0b2a..9b9e1b5b 100644 --- a/setup.py +++ b/setup.py @@ -107,6 +107,7 @@ azuredevops=bugwarrior.services.azuredevops:AzureDevopsService gitbug=bugwarrior.services.gitbug:GitBugService deck=bugwarrior.services.deck:NextcloudDeckService + logseq=bugwarrior.services.logseq:LogseqService [ini2toml.processing] bugwarrior = bugwarrior.config.ini2toml_plugin:activate """, diff --git a/tests/test_logseq.py b/tests/test_logseq.py new file mode 100644 index 00000000..96417732 --- /dev/null +++ b/tests/test_logseq.py @@ -0,0 +1,100 @@ +from unittest import mock + +from .base import AbstractServiceTest, ServiceTest +from bugwarrior.services.logseq import LogseqService, LogseqClient + + +class TestLogseqIssue(AbstractServiceTest, ServiceTest): + SERVICE_CONFIG = { + "service": "logseq", + "host": "localhost", + "port": 12315, + "token": "TESTTOKEN", + } + + test_record = { + "properties": {"duration": '{"TODO":[0,1699562197346]}'}, + "priority": "C", + "properties-order": ["duration"], + "parent": {"id": 7083}, + "id": 7146, + "uuid": "66699a83-3ee0-4edc-81c6-a24c9b80bec6", + "path-refs": [ + {"id": 4}, + {"id": 10}, + {"id": 555}, + {"id": 559}, + {"id": 568}, + {"id": 1777}, + {"id": 7070}, + ], + "content": "DOING [#A] Do something", + "properties-text-values": {"duration": '{"TODO":[0,1699562197346]}'}, + "marker": "DOING", + "page": {"id": 7070}, + "left": {"id": 7109}, + "format": "markdown", + "refs": [{"id": 4}, {"id": 10}, {"id": 555}, {"id": 568}], + } + + test_extra = { + "graph": "Test", + } + + def setUp(self): + super().setUp() + + self.service = self.get_mock_service(LogseqService) + self.service.client = mock.MagicMock(spec=LogseqClient) + self.service.client.get_issues = mock.MagicMock( + return_value=[self.test_record, self.test_extra] + ) + + def test_to_taskwarrior(self): + issue = self.service.get_issue_for_record(self.test_record, self.test_extra) + + expected = { + "annotations": [], + "due": None, + "scheduled": None, + "wait": None, + "status": "pending", + "priority": "L", + "project": self.test_extra["graph"], + "tags": [], + issue.ID: int(self.test_record["id"]), + issue.UUID: self.test_record["uuid"], + issue.STATE: self.test_record["marker"], + issue.TITLE: "DOING Do something", + issue.URI: "logseq://graph/Test?block-id=66699a83-3ee0-4edc-81c6-a24c9b80bec6", + } + + actual = issue.to_taskwarrior() + + self.assertEqual(actual, expected) + + def test_issues(self): + self.service.client.get_graph_name.return_value = self.test_extra["graph"] + self.service.client.get_issues.return_value = [[self.test_record]] + issue = next(self.service.issues()) + + expected = { + "annotations": [], + "description": f"(bw)Is#{self.test_record['id']}" + + " - DOING Do something" + + " .. logseq://graph/Test?block-id=66699a83-3ee0-4edc-81c6-a24c9b80bec6", + "due": None, + "scheduled": None, + "wait": None, + "status": "pending", + "priority": "L", + "project": self.test_extra["graph"], + "tags": [], + issue.ID: int(self.test_record["id"]), + issue.UUID: self.test_record["uuid"], + issue.STATE: self.test_record["marker"], + issue.TITLE: "DOING Do something", + issue.URI: "logseq://graph/Test?block-id=66699a83-3ee0-4edc-81c6-a24c9b80bec6", + } + + self.assertEqual(issue.get_taskwarrior_record(), expected)