diff --git a/README.md b/README.md
index 77f4e5f..8aef810 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,11 @@ At the moment the list of supported synchronizations is the following:
Taskwarrior ⬄ Generic Caldav server |
tw-caldav-sync |
+
+ | README |
+ Obsidian Markdown Taks ⬄ Google Tasks |
+ md-gtasks-sync |
+
| README |
Local Files ⬄ Google Keep Notes |
@@ -510,6 +515,48 @@ Options:
+
+
+
+ md_gtasks_sync --help
+
+```
+Usage: md_gtasks_sync [OPTIONS]
+
+ Synchronize lists from your Google Tasks with Obsidian Tasks Markdown file.
+
+ The list of MD tasks can be based on a Markdown file path
+ while the list in GTasks should be provided by their name. if it doesn't
+ exist it will be created.
+
+Options:
+ -l, --gtasks-list TEXT Name of the Google Tasks list to synchronize
+ (will be created if not there)
+ --google-secret FILE Override the client secret used for the
+ communication with the Google APIs
+ --oauth-port INTEGER Port to use for OAuth Authentication with
+ Google Applications
+ -m, --markdown-file TEXT Name of the Markdown file including tasks
+ list to synchronize
+ --list-combinations List the available named TW<->Google Tasks
+ combinations
+ --list-resolution-strategies List all the available resolution strategies
+ and exit
+ -r, --resolution-strategy [MostRecentRS|LeastRecentRS|AlwaysFirstRS|AlwaysSecondRS]
+ Resolution strategy to use during conflicts
+ -b, --combination TEXT Name of an already saved TW<->Google Tasks
+ combination
+ -s, --save-as TEXT Save the given TW<->Google Tasks filters
+ combination using a specified custom name.
+ --prefer-scheduled-date Prefer using the "scheduled" date field
+ instead of the "due" date if the former is
+ available
+ -v, --verbose
+ --version Show the version and exit.
+ --help Show this message and exit.
+```
+
+
## Mechanics / Automatic synchronization
diff --git a/docs/readme-md-gtasks.md b/docs/readme-md-gtasks.md
new file mode 100644
index 0000000..1399e1c
--- /dev/null
+++ b/docs/readme-md-gtasks.md
@@ -0,0 +1,101 @@
+# [Markdown Obsidian Tasks](https://publish.obsidian.md/tasks/Introduction) ⬄ [Google Tasks](https://support.google.com/tasks/answer/7675772)
+
+
+
+## Description
+
+Given all tasks in your Google Task task list and a Markdown file with
+Obsidian tasks, synchronise all the addition /
+modification / deletion events between them.
+
+## Motivation
+
+While Obsidian Tasks is good for taking notes, tracking tasks across projects,
+keeping track of project goals etc., lacks the portability, simplicity and
+minimalistic design of Google Tasks. The latter also has the following
+advantages:
+
+- Automatic sync across all your devices
+- Comfortable addition/modification of events using voice commands
+- Actual reminding of events with a variety of mechanisms
+
+## Usage Examples
+
+Run the `md_gtasks_sync` to synchronise the Google Tasks list of your choice with
+the selected Markdown file. Run with `--help` for the list of options.
+
+```sh
+# Sync the +remindme Taskwarrior tag with the Google Tasks list named "TW Reminders"
+
+md_gtasks_sync --help
+md_gtasks_sync -m tasks.md -l "MD Tasks"
+```
+
+## Installation
+
+### Package Installation
+
+Install the `syncall` package from PyPI, enabling the `google` and `md`
+extras:
+
+```sh
+pip3 install syncall[google,tw]
+```
+
+## Notes re this synchronization
+
+- Currently subtasks of a Google Tasks item are treated as completely
+ independent of the parent task when converted to Markdown
+- It's not possible to get the time part of the "due" field of a task using the
+ Google Tasks API. Due to this restriction we currently do currently do sync
+ the date part (without the time) from Google Tasks to Markdown, but in
+ order not to remove the time part when doing the inverse synchronization, we
+ don't sync the date at all from Markdown to Google Tasks. More
+ information in [this ticket](https://issuetracker.google.com/u/1/issues/128979662)
+
+
+Overriding Google Tasks API key (not required)
+
+**This step isn't since the Google Console app of this project is now verified.**
+
+At the moment the Google Console app that makes use of the Google Tasks API is
+still in Testing mode and awaiting approval from Google. This means that if it
+raches more than 100 users, the integration may stop working for you. In that
+case in order to use this integration you will have to register for your own
+developer account with the Google Tasks API with the following steps:
+
+Firstly, remove the `~/.gtasks_credentials.pickle` file on your system since
+that will be reused if found by the app.
+
+For creating your own Google Cloud Developer App:
+
+- Go to the [Google Cloud developer console](https://console.cloud.google.com/)
+- Make a new project
+- From the sidebar go to `API & Services` and once there click the `ENABLE APIS AND SERVICES` button
+- Look for and Enable the `Tasks API`
+
+Your newly created app now has access to the Tasks API. We now have to create
+and download the credentials:
+
+- Again, from the sidebar under `API And Services` click `Credentials`
+- In the Google Tasks API screen, click the `CREATE CREDENTIALS` button.
+- Select the `User data` radio button (not the `Application data`).
+- Fill in the `OAuth Consent Screen` information (shouldn't affect the process)
+- Allow the said credentials to access the following scopes:
+ - `Create, edit, organize, and delete all your tasks`
+ - `View your tasks`
+- Create a new `OAuth Client ID`. Set the type to `Desktop App` (app name is not
+ important).
+- Finally download the credentials in JSON form by clicking the download button
+ as shown below. This is the file you need to point to when running
+ `tw_gtasks_sync`.
+
+ 
+
+To specify your custom credentials JSON file use the `--google-secret` flag as follows:
+
+```sh
+md_gtasks_sync -l "" -m tasks.md --google-secret ""
+```
+
+
diff --git a/misc/meme-md-gtasks.png b/misc/meme-md-gtasks.png
new file mode 100644
index 0000000..26b9217
Binary files /dev/null and b/misc/meme-md-gtasks.png differ
diff --git a/pyproject.toml b/pyproject.toml
index 1c35133..b9736f4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,6 +49,8 @@ tw_notion_sync = "syncall.scripts.tw_notion_sync:main"
fs_gkeep_sync = "syncall.scripts.fs_gkeep_sync:main"
tw_caldav_sync = "syncall.scripts.tw_caldav_sync:main"
tw_gtasks_sync = "syncall.scripts.tw_gtasks_sync:main"
+md_gtasks_sync = "syncall.scripts.md_gtasks_sync:main"
+
# end-user dependencies --------------------------------------------------------
[tool.poetry.dependencies]
diff --git a/syncall/cli.py b/syncall/cli.py
index 50c89df..10d74f4 100644
--- a/syncall/cli.py
+++ b/syncall/cli.py
@@ -357,6 +357,30 @@ def opt_gtasks_list():
)
+# markdown file -------------------------------------------------------------------------------
+def opts_markdown():
+ def decorator(f):
+ for d in reversed(
+ [
+ _opt_md_file,
+ _opt_prefer_scheduled_date,
+ ],
+ ):
+ f = d()(f)
+ return f
+
+ return decorator
+
+
+def _opt_md_file():
+ return click.option(
+ "-m",
+ "--markdown-file",
+ type=str,
+ help="Name of the Markdown file including tasks list to synchronize",
+ )
+
+
# google-related options ----------------------------------------------------------------------
def opt_google_secret_override():
return click.option(
@@ -459,6 +483,14 @@ def opt_filename_extension():
default=".md",
)
+def opt_filename_path():
+ return click.option(
+ "--path",
+ "--filename-path",
+ "filename_path",
+ type=str,
+ help="Use this file path for locally saved data",
+ )
# general options -----------------------------------------------------------------------------
def opts_miscellaneous(side_A_name: str, side_B_name: str):
diff --git a/syncall/filesystem/markdown_task_item.py b/syncall/filesystem/markdown_task_item.py
new file mode 100644
index 0000000..61259b4
--- /dev/null
+++ b/syncall/filesystem/markdown_task_item.py
@@ -0,0 +1,114 @@
+import datetime
+import re
+import uuid
+
+from typing import Optional
+
+from item_synchronizer.types import ID
+from syncall.concrete_item import ConcreteItem, ItemKey, KeyType
+from syncall.filesystem.filesystem_file import FilesystemFile
+
+MD_TASK_CHECKBOX_RE = r"\[[ xX]\]"
+MD_TASK_LINE_START_RE = r"-\s*" + MD_TASK_CHECKBOX_RE
+MD_TASK_SCHEDULED_EMOJI = "⏳"
+MD_TASK_DUE_EMOJI = "📅"
+MD_TASK_DONE_EMOJI = "✅"
+
+MD_TASK_DATE_RE = r"(?<={EMOJI} )\d{4}-\d{2}-\d{2}"
+
+MD_TASK_SCHEDULED_RE = MD_TASK_DATE_RE.replace('{EMOJI}', MD_TASK_SCHEDULED_EMOJI)
+MD_TASK_DUE_RE = MD_TASK_DATE_RE.replace('{EMOJI}', MD_TASK_DUE_EMOJI)
+MD_TASK_DONE_RE = MD_TASK_DATE_RE.replace('{EMOJI}', MD_TASK_DONE_EMOJI)
+
+class MarkdownTaskItem(ConcreteItem):
+ """A task line inside a Markdown file."""
+
+ def __init__(self, is_checked: bool = False, title: str = ""):
+ super().__init__(
+ keys=(
+ ItemKey("is_checked", KeyType.String),
+ ItemKey("title", KeyType.String),
+ ItemKey("last_modified_date", KeyType.Date),
+ )
+ )
+
+ self._persistent_id = None
+ self.last_modified_date = None
+ self.scheduled_date = None
+ self.due_date = None
+ self.done_date = None
+ self.deleted = False
+ self.is_checked = is_checked
+ self.title = title
+
+ @classmethod
+ def from_raw_item(cls, markdown_raw_item: str) -> "MarkdownTaskItem":
+ """Create a MarkdownTaskItem given the raw item at hand."""
+
+ result = cls(
+ is_checked=markdown_raw_item["is_checked"],
+ title=markdown_raw_item["title"]
+ )
+ return result
+
+ @classmethod
+ def from_markdown(cls, markdown_text: str, markdown_file: FilesystemFile) -> "MarkdownTaskItem":
+ """Create a MarkdownTaskItem given the line of text."""
+
+ markdown_task = re.match(MD_TASK_LINE_START_RE, markdown_text)
+
+ if markdown_task is None:
+ return None
+
+ checkbox_found = re.search(MD_TASK_CHECKBOX_RE, markdown_text)
+ is_checked = 'X' in checkbox_found.group(0).upper()
+
+ 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)
+ title = re.split(md_task_split_re, markdown_text)[2].strip()
+
+ result = cls(
+ is_checked=is_checked,
+ title=title
+ )
+ result.last_modified_date = markdown_file.last_modified_date
+
+ due_date = re.search(MD_TASK_DUE_RE, markdown_text)
+ scheduled_date = re.search(MD_TASK_SCHEDULED_RE, markdown_text)
+ done_date = re.search(MD_TASK_DONE_RE, markdown_text)
+
+ if due_date:
+ result.due_date = datetime.datetime.fromisoformat(due_date.group(0))
+
+ if scheduled_date:
+ result.scheduled_date = datetime.datetime.fromisoformat(scheduled_date.group(0))
+
+ if done_date:
+ result.done_date = datetime.datetime.fromisoformat(done_date.group(0))
+
+ return result
+
+ def __str__(self):
+ result = '- [{}] {}'.format(
+ 'X' if self.is_checked else ' ',
+ self.title)
+
+ if self.scheduled_date:
+ result += " " + MD_TASK_SCHEDULED_EMOJI + " " + self.scheduled_date.date().isoformat()
+
+ if self.due_date:
+ result += " " + MD_TASK_DUE_EMOJI + " " + self.due_date.date().isoformat()
+
+ if self.done_date:
+ result += " " + MD_TASK_DONE_EMOJI + " " + self.done_date.date().isoformat()
+
+ return result
+
+ def _id(self) -> ID:
+ return uuid.uuid5(uuid.NAMESPACE_OID, self.title)
+
+ @property
+ def id(self) -> Optional[ID]:
+ return self._persistent_id or self._id()
+
+ def delete(self) -> None:
+ self.deleted = True
diff --git a/syncall/filesystem/markdown_tasks_side.py b/syncall/filesystem/markdown_tasks_side.py
new file mode 100644
index 0000000..eacf54a
--- /dev/null
+++ b/syncall/filesystem/markdown_tasks_side.py
@@ -0,0 +1,173 @@
+import datetime
+import pickle
+import re
+
+from pathlib import Path
+from typing import MutableMapping, Optional, Sequence, cast
+
+from item_synchronizer.types import ID
+from loguru import logger
+
+from syncall.concrete_item import ConcreteItem
+from syncall.filesystem.filesystem_file import FilesystemFile
+from syncall.filesystem.markdown_task_item import MarkdownTaskItem
+from syncall.sync_side import SyncSide
+
+
+class MarkdownTasksSide(SyncSide):
+ """Integration for managing files in a local filesystem.
+
+ - Embed the UUID as an extended attribute of each file.
+ """
+
+ @classmethod
+ def id_key(cls) -> str:
+ return "id"
+
+ @classmethod
+ def summary_key(cls) -> str:
+ return "title"
+
+ @classmethod
+ def last_modification_key(cls) -> str:
+ return "last_modified_date"
+
+ def __init__(self, markdown_file: Path) -> None:
+ super().__init__(name="Fs", fullname="Filesystem")
+ self._filename_path = markdown_file
+ self._filesystem_file = FilesystemFile(path=markdown_file)
+ self._filesystem_ids_path = Path(f".{markdown_file}.ids")
+
+ self._ids_map = {}
+ if self._filesystem_ids_path.is_file():
+ with self._filesystem_ids_path.open("rb") as f:
+ self._ids_map = pickle.load(f)
+
+ all_items = self.get_all_items(include_non_tasks=True)
+
+ # dict with items. Ignore lines with no tasks
+ self._items_cache: dict[str, dict] = {
+ str(item.id): item for item in all_items if item
+ }
+
+ # Array with item ids in the same order found in the .md file
+ # It will have None in positions with no Markdown tasks
+ self._items_order = [ str(item.id) if item else None for item in all_items ]
+
+ def start(self):
+ pass
+
+ def finish(self):
+ contents = ""
+ # add existing file lines as they are if they are not tasks
+ # or change them for the tasks in text format when appropriate
+ for item_id, line in zip(self._items_order, self._filesystem_file.contents.splitlines()):
+ if item_id:
+ try:
+ line_content = str(self.get_item(item_id))
+ except KeyError:
+ continue
+ else:
+ line_content = line
+
+ contents += line_content + "\n"
+
+ # so far we've inserted older tasks. add newly synced ones
+ new_ids = [ item_id for item_id in self._items_cache.keys() if item_id not in self._items_order ]
+ for item_id in new_ids:
+ line_content = str(self.get_item(item_id))
+ contents += line_content + "\n"
+
+ self._filesystem_file.contents = contents
+ self._filesystem_file.flush()
+
+ # delete id mappings if the item no longer exist
+ existing_ids = [ str(item._id()) for item in self._items_cache.values() ]
+ self._ids_map = {new_id: persistent_id for new_id, persistent_id in self._ids_map.items() if new_id in existing_ids}
+
+ with self._filesystem_ids_path.open("wb") as f:
+ pickle.dump(self._ids_map, f)
+
+ def get_persistent_id(self, id):
+ # Markdown doesnt keep a stable id as it's just a text format
+ # We record ids in a pickle file if they change
+ # so the map in Syncronizer works as expected
+ # this would be the first id ever set for an item
+ try:
+ return self._ids_map[str(id)]
+ except KeyError:
+ return id
+
+ def get_all_items(self, **kargs) -> Sequence[FilesystemFile]:
+ """Read all items again from storage."""
+ """The array will have None in lines with no tasks"""
+ result = []
+ found_tasks = 0
+ for line in self._filesystem_file.contents.splitlines():
+ item = MarkdownTaskItem.from_markdown(line, self._filesystem_file)
+ if item:
+ found_tasks += 1
+ item_id = item._id()
+ persistent_id = self.get_persistent_id(item_id)
+ if persistent_id != item_id:
+ item._persistent_id = persistent_id
+ if item or kargs.get('include_non_tasks'):
+ result.append(item)
+
+ logger.opt(lazy=True).debug(
+ f"Found {found_tasks} matching tasks inside {self._filename_path}"
+ )
+ return result
+
+ def get_item(self, item_id: ID) -> Optional[MarkdownTaskItem]:
+ item = self._items_cache.get(item_id)
+ return item
+
+ def delete_single_item(self, item_id: ID):
+ try:
+ del self._items_cache[item_id]
+ except Keyerror:
+ logger.warning(f"Requested to delete item {item_id} but item cannot be found.")
+ return
+
+ def update_item(self, item_id: ID, **changes):
+ item = self.get_item(item_id)
+ if item is None:
+ logger.warning(f"Requested to update item {item_id} but item cannot be found.")
+ return
+
+ if not {"title", "is_checked"}.issubset(changes):
+ logger.warning(f"Invalid changes provided to Filesystem Side -> {changes}")
+ return
+
+ if item.title != changes["title"]:
+ item.title = changes["title"]
+ logger.warning(f"The item {item_id} has changed its id to {item._id()}")
+ self._ids_map[str(item._id())] = item_id
+
+ item.is_checked = changes["is_checked"]
+
+ def add_item(self, item: MarkdownTaskItem) -> FilesystemFile:
+ item = MarkdownTaskItem.from_raw_item(item)
+ self._items_cache[item.id] = item
+ return item
+
+ @classmethod
+ def items_are_identical(
+ cls, item1: ConcreteItem, item2: ConcreteItem, ignore_keys: Sequence[str] = []
+ ) -> bool:
+ # item1 = item1.copy()
+ # item2 = item2.copy()
+
+ keys = [
+ k
+ for k in [
+ "id",
+ "title",
+ "is_checked",
+ "due_date",
+ "done_date",
+ ]
+ if k not in ignore_keys
+ ]
+ return SyncSide._items_are_identical(item1, item2, keys)
diff --git a/syncall/scripts/md_gtasks_sync.py b/syncall/scripts/md_gtasks_sync.py
new file mode 100644
index 0000000..6dd4048
--- /dev/null
+++ b/syncall/scripts/md_gtasks_sync.py
@@ -0,0 +1,184 @@
+from typing import List
+
+import click
+from bubop import (
+ check_optional_mutually_exclusive,
+ check_required_mutually_exclusive,
+ format_dict,
+ logger,
+ loguru_tqdm_sink,
+)
+
+from syncall.app_utils import confirm_before_proceeding, inform_about_app_extras
+
+try:
+ from syncall.google.gtasks_side import GTasksSide
+ from syncall.filesystem.markdown_tasks_side import MarkdownTasksSide
+except ImportError:
+ inform_about_app_extras(["google", "fs"])
+
+from syncall.aggregator import Aggregator
+from syncall.app_utils import (
+ app_log_to_syslog,
+ cache_or_reuse_cached_combination,
+ error_and_exit,
+ fetch_app_configuration,
+ get_resolution_strategy,
+ register_teardown_handler,
+)
+from syncall.cli import (
+ opt_google_oauth_port,
+ opt_google_secret_override,
+ opt_gtasks_list,
+ opts_markdown,
+ opts_miscellaneous,
+)
+from syncall.tw_gtasks_utils import convert_gtask_to_md, convert_md_to_gtask
+
+
+@click.command()
+@opt_gtasks_list()
+@opt_google_secret_override()
+@opt_google_oauth_port()
+@opts_markdown()
+@opts_miscellaneous(side_A_name="Obsidian", side_B_name="Google Tasks")
+def main(
+ gtasks_list: str,
+ google_secret: str,
+ oauth_port: int,
+ markdown_file: str,
+ prefer_scheduled_date: bool,
+ resolution_strategy: str,
+ verbose: int,
+ combination_name: str,
+ custom_combination_savename: str,
+ pdb_on_error: bool,
+ confirm: bool,
+):
+ """Synchronize lists from your Google Tasks with Obsidian Tasks Markdown file.
+
+ The list of MD tasks can be based on a Markdown file path
+ while the list in GTasks should be provided by their name. if it doesn't
+ exist it will be created.
+ """
+ # setup logger ----------------------------------------------------------------------------
+ loguru_tqdm_sink(verbosity=verbose)
+ app_log_to_syslog()
+ logger.debug("Initialising...")
+ inform_about_config = False
+
+ # cli validation --------------------------------------------------------------------------
+ check_optional_mutually_exclusive(combination_name, custom_combination_savename)
+
+ combination_of_file_and_gtasks_list = any(
+ [
+ markdown_file,
+ gtasks_list,
+ ]
+ )
+ check_optional_mutually_exclusive(
+ combination_name, combination_of_file_and_gtasks_list
+ )
+
+ # existing combination name is provided ---------------------------------------------------
+ if combination_name is not None:
+ app_config = fetch_app_configuration(
+ side_A_name="Obsidian", side_B_name="Google Tasks", combination=combination_name
+ )
+ markdown_file = app_config["markdown_file"]
+ gtasks_list = app_config["gtasks_list"]
+
+ # combination manually specified ----------------------------------------------------------
+ else:
+ inform_about_config = True
+ combination_name = cache_or_reuse_cached_combination(
+ config_args={
+ "gtasks_list": gtasks_list,
+ "markdown_file": markdown_file,
+ },
+ config_fname="md_gtasks_configs",
+ custom_combination_savename=custom_combination_savename,
+ )
+
+ # more checks -----------------------------------------------------------------------------
+ if gtasks_list is None:
+ error_and_exit(
+ "You have to provide the name of a Google Tasks list to synchronize events"
+ " to/from. You can do so either via CLI arguments or by specifying an existing"
+ " saved combination"
+ )
+
+ # announce configuration ------------------------------------------------------------------
+ logger.info(
+ format_dict(
+ header="Configuration",
+ items={
+ "Markdown Filename Path": markdown_file,
+ "Google Tasks": gtasks_list,
+ "Prefer scheduled dates": prefer_scheduled_date,
+ },
+ prefix="\n\n",
+ suffix="\n",
+ )
+ )
+ if confirm:
+ confirm_before_proceeding()
+
+ # initialize sides ------------------------------------------------------------------------
+ md_side = MarkdownTasksSide(
+ markdown_file=markdown_file
+ )
+
+ gtasks_side = GTasksSide(
+ task_list_title=gtasks_list, oauth_port=oauth_port, client_secret=google_secret
+ )
+
+ # teardown function and exception handling ------------------------------------------------
+ register_teardown_handler(
+ pdb_on_error=pdb_on_error,
+ inform_about_config=inform_about_config,
+ combination_name=combination_name,
+ verbose=verbose,
+ )
+
+ # take extra arguments into account -------------------------------------------------------
+ def convert_B_to_A(*args, **kargs):
+ return convert_md_to_gtask(
+ *args,
+ **kargs,
+ set_scheduled_date=prefer_scheduled_date,
+ )
+
+ convert_B_to_A.__doc__ = convert_md_to_gtask.__doc__
+
+ def convert_A_to_B(*args, **kargs):
+ return convert_gtask_to_md(
+ *args,
+ **kargs,
+ set_scheduled_date=prefer_scheduled_date,
+ )
+
+ convert_A_to_B.__doc__ = convert_gtask_to_md.__doc__
+
+ # sync ------------------------------------------------------------------------------------
+ with Aggregator(
+ side_A=gtasks_side,
+ side_B=md_side,
+ converter_B_to_A=convert_B_to_A,
+ converter_A_to_B=convert_A_to_B,
+ resolution_strategy=get_resolution_strategy(
+ resolution_strategy, side_A_type=type(gtasks_side), side_B_type=type(md_side)
+ ),
+ config_fname=combination_name,
+ ignore_keys=(
+ ("last_modified_date"),
+ (),
+ ),
+ ) as aggregator:
+ aggregator.sync()
+
+ return 0
+
+
+if __name__ == "__main__":
+ main()
diff --git a/syncall/tw_gtasks_utils.py b/syncall/tw_gtasks_utils.py
index f680283..f34d226 100644
--- a/syncall/tw_gtasks_utils.py
+++ b/syncall/tw_gtasks_utils.py
@@ -2,6 +2,7 @@
from item_synchronizer.types import Item
from syncall.google.common import parse_google_datetime
+from syncall.filesystem.markdown_task_item import MarkdownTaskItem
from syncall.google.gtasks_side import GTasksSide
from syncall.tw_utils import extract_tw_fields_from_string, get_tw_annotations_as_str
from syncall.types import GTasksItem
@@ -33,6 +34,36 @@ def convert_tw_to_gtask(
return gtasks_item
+def convert_md_to_gtask(
+ md_item: Item,
+ set_scheduled_date: bool = False,
+) -> Item:
+ """MD -> GTasks conversion."""
+ assert all(
+ i in md_item.keys() for i in ("title", "is_checked")
+ ), "Missing keys in md_item"
+
+ gtasks_item = {}
+
+ # title
+ gtasks_item["title"] = md_item["title"]
+
+ # status
+ gtasks_item["status"] = "completed" if md_item["is_checked"] else "needsAction"
+
+ # dates
+ if md_item.last_modified_date:
+ gtasks_item["updated"] = format_datetime_tz(parse_google_datetime(md_item.last_modified_date))
+
+ due_date = md_item.scheduled_date if set_scheduled_date else md_item.due_date
+ if md_item.due_date:
+ gtasks_item["due"] = format_datetime_tz(parse_google_datetime(due_date))
+
+ if md_item.done_date:
+ gtasks_item["completed"] = format_datetime_tz(parse_google_datetime(md_item.done_date))
+
+ return gtasks_item
+
def convert_gtask_to_tw(
gtasks_item: GTasksItem,
@@ -94,3 +125,42 @@ def convert_gtask_to_tw(
tw_item["modified"] = parse_google_datetime(gtasks_item["updated"])
return tw_item
+
+
+def convert_gtask_to_md(
+ gtasks_item: GTasksItem,
+ set_scheduled_date: bool = False,
+) -> Item:
+ """GTasks -> MD Converter.
+
+ If set_scheduled_date, then it will set the "scheduled" date of the produced TW task
+ instead of the "due" date
+ """
+ status_gtask = gtasks_item["status"]
+
+ # status
+ is_checked = status_gtask == "completed"
+
+ # Description
+ title = gtasks_item["title"]
+
+ md_item: MarkdownTaskItem = MarkdownTaskItem(is_checked, title)
+
+ # due/scheduled date
+ due_date = GTasksSide.get_task_due_time(gtasks_item)
+ if due_date is not None:
+ if set_scheduled_date:
+ md_item.scheduled_date = due_date.replace(tzinfo=None)
+ else:
+ md_item.due_date = due_date.replace(tzinfo=None)
+
+ # end date
+ end_date = GTasksSide.get_task_completed_time(gtasks_item)
+ if end_date is not None:
+ md_item.done_date = end_date.replace(tzinfo=None)
+
+ # update time
+ if "updated" in gtasks_item.keys():
+ md_item.last_modified_date = parse_google_datetime(gtasks_item["updated"]).replace(tzinfo=None)
+
+ return md_item