#!/usr/bin/env python3 """Convert some funky reminders JSON data to todo.txt.""" from __future__ import annotations import datetime import json import sys from dataclasses import dataclass @dataclass(frozen=True) class TodoEntry: index: int title: str | None notes: str | None completed: bool priority: int completion_date: datetime.datetime | None creation_date: datetime.datetime | None due_date: datetime.datetime | None url: str | None @staticmethod def from_str(string: str) -> TodoEntry: words = string.split() # Strip from left to find completion if words[0] == "x": completed = True del words[0] else: completed = False # Strip from left to find priority if len(words[0]) == 3 and words[0].startswith("(") and words[0].endswith(")"): if words[0] == "(A)": priority = 9 elif words[0] == "(B)": priority = 5 else: priority = 1 else: priority = 0 # Strip from left to find dates try: creation_date = datetime.date.fromisoformat(words[0]) except ValueError: creation_date = None completion_date = None else: creation_date = datetime.datetime.combine( creation_date, datetime.time.min, datetime.timezone.utc, ) del words[0] try: completion_date = datetime.date.fromisoformat(words[0]) except ValueError: completion_date = None else: completion_date = datetime.datetime.combine( completion_date, datetime.time.min, datetime.timezone.utc, ) del words[0] # Strip from right to find meta meta = {} while words and ":" in words[-1][1:-1]: k, v = words.pop(-1).split(":", maxsplit=1) if k == "due": v = datetime.date.fromisoformat(v) v = datetime.datetime.combine( v, datetime.time.min, datetime.timezone.utc, ) if k == "index": v = int(v) if k in meta: raise ValueError(f"duplicate meta tag {k}") meta[k] = v # Strip from right to find context and project tags = [] while words and (words[-1].startswith("+") or words[-1].startswith("@")): tags.append("#" + words.pop(-1)[1:]) # Split the rest into title :: notes rest = " ".join(words) title, *rest = rest.split(" :: ", maxsplit=1) if rest: notes = rest[0] else: notes = None if "https" in meta: url = "https:" + meta.pop("https") elif "http" in meta: url = "http:" + meta.pop("http") else: url = None entry = TodoEntry( index=meta.pop("index", -1), title=title, notes=notes, completed=completed, priority=priority, creation_date=creation_date, completion_date=completion_date, due_date=meta.pop("due", None), url=url, ) if meta: raise ValueError(f"Unconsumed meta: {meta}") return entry def __str__(self) -> str: bits = [] # Completion if self.completed: bits.append("x") # Reduce priority to 9->high->A, 5->medium->B, 1->low->C if self.priority > 6: bits.append("(A)") elif self.priority > 3: bits.append("(B)") elif self.priority > 0: bits.append("(C)") # Creation date required so default if absent if self.creation_date is None: bits.append("1970-01-01") else: bits.append(roundd(self.creation_date).isoformat()) if self.completion_date is not None: bits.append(roundd(self.completion_date).isoformat()) # Extract tags from title or notes tags = "" if self.notes: notes, tags = extract_tags(self.notes) notes = notes.strip() else: notes = self.notes if self.title and not tags: title, tags = extract_tags(self.title) title = title.strip() elif self.title: title = self.title.strip() else: title = self.title if title and notes: bits.append(f"{title} :: {notes}") elif title: bits.append(title) elif notes: bits.append(notes) bits.append(tags) if self.due_date is not None: bits.append("due:" + roundd((self.due_date)).isoformat()) bits.append("index:" + str(self.index)) return " ".join(bits).replace("\n", " ") def extract_tags(string: str) -> tuple[str, str]: words = string.split() tags = [] while words and words[-1].startswith("#"): tags.append("@" + words.pop(-1)[1:]) return " ".join(words), " ".join(sorted(tags, key=str.lower)) def roundd(dt: datetime.datetime) -> datetime.date: # Exist in more than one TZ, round to closest date dates = [ datetime.datetime.combine( dt.date() + delta, datetime.time.min, datetime.timezone.utc, ) for delta in [ datetime.timedelta(days=-1), datetime.timedelta(days=0), datetime.timedelta(days=+1), ] ] min_date = datetime.date.min min_delta = datetime.timedelta(days=100) for date in dates: delta = abs(date - dt) if delta < min_delta: min_date = date.date() min_delta = delta return min_date def _maybe_date(value: str | None) -> datetime.datetime | None: if value is not None: if value.endswith("Z"): value = value[:-1] dt = datetime.datetime.fromisoformat(value) if dt.tzinfo is None: dt = dt.replace(tzinfo=datetime.timezone.utc) return dt return None def main(): data = sys.stdin.read() try: json_data = json.loads(data) except ValueError: entries = [TodoEntry.from_str(line.strip()) for line in data.splitlines()] else: entries = [ TodoEntry( index=n, title=row.pop("title", None), notes=row.pop("notes", None), completed=row.pop("completed"), priority=row.pop("priority"), completion_date=_maybe_date(row.pop("completionDate", None)), creation_date=_maybe_date(row.pop("creationDate", None)), due_date=_maybe_date(row.pop("dueDate", None)), url=row.pop("url", None), ) for n, row in enumerate(json_data["reminders"]) ] if any(x.keys() - {'uid'} for x in json_data["reminders"]): print(f"Left over data:\n{json_data['reminders']}") print("\n".join(map(str, entries))) if __name__ == "__main__": main()