Skip to content

Commit 943ccbf

Browse files
jvalenzuelajmriego
authored andcommitted
obsidian: Initial implementation of synchronization to Obsidian Tasks
1 parent 7b61977 commit 943ccbf

9 files changed

Lines changed: 724 additions & 1 deletion

File tree

README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ At the moment the list of supported synchronizations is the following:
7979
<td> <a href="https://taskwarrior.org/">Taskwarrior</a> ⬄ Generic <a href="https://en.wikipedia.org/wiki/CalDAV">Caldav </a> server</td>
8080
<td><tt>tw-caldav-sync</tt></td>
8181
</tr>
82+
<tr>
83+
<td><a href="https://github.com/bergercookie/syncall/blob/master/docs/readme-md-gtasks.md">README</a></td>
84+
<td> <a href="https://publish.obsidian.md/tasks/Introduction">Obsidian Markdown Taks</a> ⬄ <a href="https://support.google.com/tasks/answer/7675772">Google Tasks</a></td>
85+
<td><tt>md-gtasks-sync</tt></td>
86+
</tr>
8287
<tr>
8388
<td><a href="https://github.com/bergercookie/syncall/blob/master/docs/readme-fs-gkeep.md">README</a></td>
8489
<td> Local Files ⬄ <a href="https://www.google.com/keep/">Google Keep Notes</a></td>
@@ -510,6 +515,48 @@ Options:
510515
</details>
511516

512517
<!-- END sniff-and-replace -->
518+
<!-- START sniff-and-replace md_gtasks_sync --help START -->
519+
520+
<details>
521+
<summary><tt>md_gtasks_sync --help</tt></summary>
522+
523+
```
524+
Usage: md_gtasks_sync [OPTIONS]
525+
526+
Synchronize lists from your Google Tasks with Obsidian Tasks Markdown file.
527+
528+
The list of MD tasks can be based on a Markdown file path
529+
while the list in GTasks should be provided by their name. if it doesn't
530+
exist it will be created.
531+
532+
Options:
533+
-l, --gtasks-list TEXT Name of the Google Tasks list to synchronize
534+
(will be created if not there)
535+
--google-secret FILE Override the client secret used for the
536+
communication with the Google APIs
537+
--oauth-port INTEGER Port to use for OAuth Authentication with
538+
Google Applications
539+
-m, --markdown-file TEXT Name of the Markdown file including tasks
540+
list to synchronize
541+
--list-combinations List the available named TW<->Google Tasks
542+
combinations
543+
--list-resolution-strategies List all the available resolution strategies
544+
and exit
545+
-r, --resolution-strategy [MostRecentRS|LeastRecentRS|AlwaysFirstRS|AlwaysSecondRS]
546+
Resolution strategy to use during conflicts
547+
-b, --combination TEXT Name of an already saved TW<->Google Tasks
548+
combination
549+
-s, --save-as TEXT Save the given TW<->Google Tasks filters
550+
combination using a specified custom name.
551+
--prefer-scheduled-date Prefer using the "scheduled" date field
552+
instead of the "due" date if the former is
553+
available
554+
-v, --verbose
555+
--version Show the version and exit.
556+
--help Show this message and exit.
557+
```
558+
559+
</details>
513560

514561
## Mechanics / Automatic synchronization
515562

docs/readme-md-gtasks.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
# [Markdown Obsidian Tasks](https://publish.obsidian.md/tasks/Introduction)[Google Tasks](https://support.google.com/tasks/answer/7675772)
2+
3+
![logo](../misc/meme-md-gtasks.png)
4+
5+
## Description
6+
7+
Given all tasks in your Google Task task list and a Markdown file with
8+
Obsidian tasks, synchronise all the addition /
9+
modification / deletion events between them.
10+
11+
## Motivation
12+
13+
While Obsidian Tasks is good for taking notes, tracking tasks across projects,
14+
keeping track of project goals etc., lacks the portability, simplicity and
15+
minimalistic design of Google Tasks. The latter also has the following
16+
advantages:
17+
18+
- Automatic sync across all your devices
19+
- Comfortable addition/modification of events using voice commands
20+
- Actual reminding of events with a variety of mechanisms
21+
22+
## Usage Examples
23+
24+
Run the `md_gtasks_sync` to synchronise the Google Tasks list of your choice with
25+
the selected Markdown file. Run with `--help` for the list of options.
26+
27+
```sh
28+
# Sync the +remindme Taskwarrior tag with the Google Tasks list named "TW Reminders"
29+
30+
md_gtasks_sync --help
31+
md_gtasks_sync -m tasks.md -l "MD Tasks"
32+
```
33+
34+
## Installation
35+
36+
### Package Installation
37+
38+
Install the `syncall` package from PyPI, enabling the `google` and `md`
39+
extras:
40+
41+
```sh
42+
pip3 install syncall[google,tw]
43+
```
44+
45+
## Notes re this synchronization
46+
47+
- Currently subtasks of a Google Tasks item are treated as completely
48+
independent of the parent task when converted to Markdown
49+
- It's not possible to get the time part of the "due" field of a task using the
50+
Google Tasks API. Due to this restriction we currently do currently do sync
51+
the date part (without the time) from Google Tasks to Markdown, but in
52+
order not to remove the time part when doing the inverse synchronization, we
53+
don't sync the date at all from Markdown to Google Tasks. More
54+
information in [this ticket](https://issuetracker.google.com/u/1/issues/128979662)
55+
56+
<details>
57+
<summary>Overriding Google Tasks API key (not required)</summary>
58+
59+
**This step isn't since the Google Console app of this project is now verified.**
60+
61+
At the moment the Google Console app that makes use of the Google Tasks API is
62+
still in Testing mode and awaiting approval from Google. This means that if it
63+
raches more than 100 users, the integration may stop working for you. In that
64+
case in order to use this integration you will have to register for your own
65+
developer account with the Google Tasks API with the following steps:
66+
67+
Firstly, remove the `~/.gtasks_credentials.pickle` file on your system since
68+
that will be reused if found by the app.
69+
70+
For creating your own Google Cloud Developer App:
71+
72+
- Go to the [Google Cloud developer console](https://console.cloud.google.com/)
73+
- Make a new project
74+
- From the sidebar go to `API & Services` and once there click the `ENABLE APIS AND SERVICES` button
75+
- Look for and Enable the `Tasks API`
76+
77+
Your newly created app now has access to the Tasks API. We now have to create
78+
and download the credentials:
79+
80+
- Again, from the sidebar under `API And Services` click `Credentials`
81+
- In the Google Tasks API screen, click the `CREATE CREDENTIALS` button.
82+
- Select the `User data` radio button (not the `Application data`).
83+
- Fill in the `OAuth Consent Screen` information (shouldn't affect the process)
84+
- Allow the said credentials to access the following scopes:
85+
- `Create, edit, organize, and delete all your tasks`
86+
- `View your tasks`
87+
- Create a new `OAuth Client ID`. Set the type to `Desktop App` (app name is not
88+
important).
89+
- Finally download the credentials in JSON form by clicking the download button
90+
as shown below. This is the file you need to point to when running
91+
`tw_gtasks_sync`.
92+
93+
![download-btn](../misc/gcal-json-btn.png)
94+
95+
To specify your custom credentials JSON file use the `--google-secret` flag as follows:
96+
97+
```sh
98+
md_gtasks_sync -l "<list-name>" -m tasks.md --google-secret "<path/to/downloaded/json/file>"
99+
```
100+
101+
</details>

misc/meme-md-gtasks.png

104 KB
Loading

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ tw_notion_sync = "syncall.scripts.tw_notion_sync:main"
4949
fs_gkeep_sync = "syncall.scripts.fs_gkeep_sync:main"
5050
tw_caldav_sync = "syncall.scripts.tw_caldav_sync:main"
5151
tw_gtasks_sync = "syncall.scripts.tw_gtasks_sync:main"
52+
md_gtasks_sync = "syncall.scripts.md_gtasks_sync:main"
53+
5254

5355
# end-user dependencies --------------------------------------------------------
5456
[tool.poetry.dependencies]
@@ -89,7 +91,7 @@ tw = ["taskw-ng", "xdg"]
8991
fs = ["xattr"]
9092

9193
# dev dependencies -------------------------------------------------------------
92-
[tool.poetry.dev-dependencies]
94+
[tool.poetry.group.dev.dependencies]
9395
black = { version = "22.3.0", allow-prereleases = true }
9496
identify = "^2.6.0"
9597
isort = "^5.13.2"

syncall/cli.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,30 @@ def opt_gtasks_list():
357357
)
358358

359359

360+
# markdown file -------------------------------------------------------------------------------
361+
def opts_markdown():
362+
def decorator(f):
363+
for d in reversed(
364+
[
365+
_opt_md_file,
366+
_opt_prefer_scheduled_date,
367+
],
368+
):
369+
f = d()(f)
370+
return f
371+
372+
return decorator
373+
374+
375+
def _opt_md_file():
376+
return click.option(
377+
"-m",
378+
"--markdown-file",
379+
type=str,
380+
help="Name of the Markdown file including tasks list to synchronize",
381+
)
382+
383+
360384
# google-related options ----------------------------------------------------------------------
361385
def opt_google_secret_override():
362386
return click.option(
@@ -459,6 +483,14 @@ def opt_filename_extension():
459483
default=".md",
460484
)
461485

486+
def opt_filename_path():
487+
return click.option(
488+
"--path",
489+
"--filename-path",
490+
"filename_path",
491+
type=str,
492+
help="Use this file path for locally saved data",
493+
)
462494

463495
# general options -----------------------------------------------------------------------------
464496
def opts_miscellaneous(side_A_name: str, side_B_name: str):
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import datetime
2+
import re
3+
import uuid
4+
5+
from typing import Optional
6+
7+
from item_synchronizer.types import ID
8+
from syncall.concrete_item import ConcreteItem, ItemKey, KeyType
9+
from syncall.filesystem.filesystem_file import FilesystemFile
10+
11+
MD_TASK_CHECKBOX_RE = r"\[[ xX]\]"
12+
MD_TASK_LINE_START_RE = r"-\s*" + MD_TASK_CHECKBOX_RE
13+
MD_TASK_SCHEDULED_EMOJI = "⏳"
14+
MD_TASK_DUE_EMOJI = "📅"
15+
MD_TASK_DONE_EMOJI = "✅"
16+
17+
MD_TASK_DATE_RE = r"(?<={EMOJI} )\d{4}-\d{2}-\d{2}"
18+
19+
MD_TASK_SCHEDULED_RE = MD_TASK_DATE_RE.replace('{EMOJI}', MD_TASK_SCHEDULED_EMOJI)
20+
MD_TASK_DUE_RE = MD_TASK_DATE_RE.replace('{EMOJI}', MD_TASK_DUE_EMOJI)
21+
MD_TASK_DONE_RE = MD_TASK_DATE_RE.replace('{EMOJI}', MD_TASK_DONE_EMOJI)
22+
23+
class MarkdownTaskItem(ConcreteItem):
24+
"""A task line inside a Markdown file."""
25+
26+
def __init__(self, is_checked: bool = False, title: str = ""):
27+
super().__init__(
28+
keys=(
29+
ItemKey("is_checked", KeyType.String),
30+
ItemKey("title", KeyType.String),
31+
ItemKey("last_modified_date", KeyType.Date),
32+
)
33+
)
34+
35+
self._persistent_id = None
36+
self.last_modified_date = None
37+
self.scheduled_date = None
38+
self.due_date = None
39+
self.done_date = None
40+
self.deleted = False
41+
self.is_checked = is_checked
42+
self.title = title
43+
44+
@classmethod
45+
def from_raw_item(cls, markdown_raw_item: str) -> "MarkdownTaskItem":
46+
"""Create a MarkdownTaskItem given the raw item at hand."""
47+
48+
result = cls(
49+
is_checked=markdown_raw_item["is_checked"],
50+
title=markdown_raw_item["title"]
51+
)
52+
return result
53+
54+
@classmethod
55+
def from_markdown(cls, markdown_text: str, markdown_file: FilesystemFile) -> "MarkdownTaskItem":
56+
"""Create a MarkdownTaskItem given the line of text."""
57+
58+
markdown_task = re.match(MD_TASK_LINE_START_RE, markdown_text)
59+
60+
if markdown_task is None:
61+
return None
62+
63+
checkbox_found = re.search(MD_TASK_CHECKBOX_RE, markdown_text)
64+
is_checked = 'X' in checkbox_found.group(0).upper()
65+
66+
md_task_split_re = "(\\s*{}\\s*|\\s*{}\\s*|\\s*{}\\s*|\\s*{}\\s*)".format(MD_TASK_CHECKBOX_RE, MD_TASK_SCHEDULED_EMOJI, MD_TASK_DUE_EMOJI, MD_TASK_DONE_EMOJI)
67+
title = re.split(md_task_split_re, markdown_text)[2].strip()
68+
69+
result = cls(
70+
is_checked=is_checked,
71+
title=title
72+
)
73+
result.last_modified_date = markdown_file.last_modified_date
74+
75+
due_date = re.search(MD_TASK_DUE_RE, markdown_text)
76+
scheduled_date = re.search(MD_TASK_SCHEDULED_RE, markdown_text)
77+
done_date = re.search(MD_TASK_DONE_RE, markdown_text)
78+
79+
if due_date:
80+
result.due_date = datetime.datetime.fromisoformat(due_date.group(0))
81+
82+
if scheduled_date:
83+
result.scheduled_date = datetime.datetime.fromisoformat(scheduled_date.group(0))
84+
85+
if done_date:
86+
result.done_date = datetime.datetime.fromisoformat(done_date.group(0))
87+
88+
return result
89+
90+
def __str__(self):
91+
result = '- [{}] {}'.format(
92+
'X' if self.is_checked else ' ',
93+
self.title)
94+
95+
if self.scheduled_date:
96+
result += " " + MD_TASK_SCHEDULED_EMOJI + " " + self.scheduled_date.date().isoformat()
97+
98+
if self.due_date:
99+
result += " " + MD_TASK_DUE_EMOJI + " " + self.due_date.date().isoformat()
100+
101+
if self.done_date:
102+
result += " " + MD_TASK_DONE_EMOJI + " " + self.done_date.date().isoformat()
103+
104+
return result
105+
106+
def _id(self) -> ID:
107+
return uuid.uuid5(uuid.NAMESPACE_OID, self.title)
108+
109+
@property
110+
def id(self) -> Optional[ID]:
111+
return self._persistent_id or self._id()
112+
113+
def delete(self) -> None:
114+
self.deleted = True

0 commit comments

Comments
 (0)