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 TaksGoogle 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) + +![logo](../misc/meme-md-gtasks.png) + +## 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`. + + ![download-btn](../misc/gcal-json-btn.png) + +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