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)