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 00000000..c06a16f6 Binary files /dev/null and b/bugwarrior/docs/services/pictures/logseq_token.png differ 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)