From 575de57fb6308a968a84fcc1f1cb9a4d255ba0cd Mon Sep 17 00:00:00 2001 From: Dima Gerasimov Date: Fri, 17 Apr 2020 20:06:50 +0100 Subject: [PATCH 1/5] Initial data provider for roam research --- my/roamresearch.py | 110 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 my/roamresearch.py diff --git a/my/roamresearch.py b/my/roamresearch.py new file mode 100644 index 0000000..d450e77 --- /dev/null +++ b/my/roamresearch.py @@ -0,0 +1,110 @@ +""" +[[Roam][https://roamresearch.com]] data +""" +from datetime import datetime +from pathlib import Path +from itertools import chain +from typing import NamedTuple, Iterator, List, Optional + +import pytz + +from .common import get_files, LazyLogger, Json + +from my.config import roamresearch as config + +logger = LazyLogger(__name__) + + +def last() -> Path: + return max(get_files(config.export_path, '*.json')) + + +class Keys: + CREATED = 'create-time' + EDITED = 'edit-time' + EDIT_EMAIL = 'edit-email' + STRING = 'string' + CHILDREN = 'children' + TITLE = 'title' + + +class Node(NamedTuple): + raw: Json + + # TODO not sure if UTC + @property + def created(self) -> datetime: + ct = self.raw.get(Keys.CREATED) + if ct is None: + # e.g. daily notes don't have create time for some reason??? + return self.edited + else: + return datetime.fromtimestamp(ct / 1000, tz=pytz.utc) + + @property + def edited(self) -> datetime: + rt = self.raw[Keys.EDITED] + return datetime.fromtimestamp(rt / 1000, tz=pytz.utc) + + @property + def title(self) -> Optional[str]: + return self.raw.get(Keys.TITLE) + + @property + def body(self) -> Optional[str]: + return self.raw.get(Keys.STRING) + + @property + def children(self) -> List['Node']: + ch = self.raw.get(Keys.CHILDREN, []) + return list(map(Node, ch)) + + def _render(self) -> Iterator[str]: + ss = f'[{self.created:%Y-%m-%d %H:%M}] {self.title or " "}' + body = self.body + sc = chain.from_iterable(c._render() for c in self.children) + + yield ss + if body is not None: + yield body + for c in sc: + yield '| ' + c + + def render(self) -> str: + return '\n'.join(self._render()) + + def __repr__(self): + return f'Node(created={self.created}, title={self.title}, body={self.body})' + + @staticmethod + def make(raw: Json) -> Iterator['Node']: + is_empty = set(raw.keys()) == {Keys.EDITED, Keys.EDIT_EMAIL, Keys.TITLE} + # not sure about that... but daily notes end up like that + if is_empty: + # todo log? + return + yield Node(raw) + + +class Roam: + def __init__(self, json: List[Json]) -> None: + self.nodes: List[Node] = [] + # TODO make it lazy? + for j in json: + self.nodes.extend(Node.make(j)) + + +def roam() -> Roam: + import json + raw = json.loads(last().read_text()) + roam = Roam(raw) + return roam + + +def print_all_notes(): + # just a demo method + # TODO demonstrate dumping as org-mode?? + for n in roam().nodes: + print(n.render()) + +# TODO could generate org-mode mirror in a single file for a demo? From 72d5616898000d10ae7aa3823d630ee62d8dca47 Mon Sep 17 00:00:00 2001 From: Dima Gerasimov Date: Fri, 17 Apr 2020 20:32:56 +0100 Subject: [PATCH 2/5] add support for permalinks and guess created time for daily notes --- my/roamresearch.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/my/roamresearch.py b/my/roamresearch.py index d450e77..6fb314e 100644 --- a/my/roamresearch.py +++ b/my/roamresearch.py @@ -4,6 +4,7 @@ from datetime import datetime from pathlib import Path from itertools import chain +import re from typing import NamedTuple, Iterator, List, Optional import pytz @@ -26,6 +27,7 @@ class Keys: STRING = 'string' CHILDREN = 'children' TITLE = 'title' + UID = 'uid' class Node(NamedTuple): @@ -35,11 +37,19 @@ class Node(NamedTuple): @property def created(self) -> datetime: ct = self.raw.get(Keys.CREATED) - if ct is None: - # e.g. daily notes don't have create time for some reason??? - return self.edited - else: + if ct is not None: return datetime.fromtimestamp(ct / 1000, tz=pytz.utc) + # ugh. daily notes don't have create time for some reason??? + + title = self.title + assert title is not None + # the format is 'February 8th, 2020'. Fucking hell. + m = re.fullmatch(r'(\w+) (\d+)\w+, (\d+)', title) + assert m is not None + # strip off 'th'/'rd' crap + dts = m.group(1) + ' ' + m.group(2) + ' ' + m.group(3) + dt = datetime.strptime(dts, '%B %d %Y') + return pytz.utc.localize(dt) @property def edited(self) -> datetime: @@ -59,6 +69,22 @@ class Node(NamedTuple): ch = self.raw.get(Keys.CHILDREN, []) return list(map(Node, ch)) + @property + def permalink(self) -> str: + username = config.username # sadly, Roam research doesn't provide 3 + return f'https://roamresearch.com/#/app/{username}/page/{self.uid}' + + @property + def uid(self) -> str: + u = self.raw.get(Keys.UID) + if u is not None: + return u + # ugh. so None apparently means "Daily note" + + # yes, it is using US date format... + return self.created.strftime('%m-%d-%Y') + + def _render(self) -> Iterator[str]: ss = f'[{self.created:%Y-%m-%d %H:%M}] {self.title or " "}' body = self.body @@ -67,6 +93,7 @@ class Node(NamedTuple): yield ss if body is not None: yield body + yield self.permalink for c in sc: yield '| ' + c From 7b9266b25db638fe7b1fb8033b4752d59e2ee7c6 Mon Sep 17 00:00:00 2001 From: Dima Gerasimov Date: Fri, 17 Apr 2020 21:39:28 +0100 Subject: [PATCH 3/5] created date: add fallback for missing/unexpected title format --- my/roamresearch.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/my/roamresearch.py b/my/roamresearch.py index 6fb314e..36bc881 100644 --- a/my/roamresearch.py +++ b/my/roamresearch.py @@ -42,10 +42,12 @@ class Node(NamedTuple): # ugh. daily notes don't have create time for some reason??? title = self.title - assert title is not None + if title is None: + return self.edited # fallback TODO log? # the format is 'February 8th, 2020'. Fucking hell. m = re.fullmatch(r'(\w+) (\d+)\w+, (\d+)', title) - assert m is not None + if m is None: + return self.edited # fallback TODO log? # strip off 'th'/'rd' crap dts = m.group(1) + ' ' + m.group(2) + ' ' + m.group(3) dt = datetime.strptime(dts, '%B %d %Y') From d0fd6f822a69a14cb0596996d880b28f780b02d9 Mon Sep 17 00:00:00 2001 From: Dima Gerasimov Date: Sun, 19 Apr 2020 00:55:18 +0100 Subject: [PATCH 4/5] split out path from permalink --- my/roamresearch.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/my/roamresearch.py b/my/roamresearch.py index 36bc881..3acd336 100644 --- a/my/roamresearch.py +++ b/my/roamresearch.py @@ -68,13 +68,18 @@ class Node(NamedTuple): @property def children(self) -> List['Node']: + # TODO def. cache.. ch = self.raw.get(Keys.CHILDREN, []) return list(map(Node, ch)) + @property + def path(self) -> str: + username = config.username # sadly, Roam research export doesn't provide it + return f'{username}/page/{self.uid}' + @property def permalink(self) -> str: - username = config.username # sadly, Roam research doesn't provide 3 - return f'https://roamresearch.com/#/app/{username}/page/{self.uid}' + return f'https://roamresearch.com/#/app/{self.path}' @property def uid(self) -> str: From 39860862aea41be23dfc388c13994803bc98f9c5 Mon Sep 17 00:00:00 2001 From: Dima Gerasimov Date: Sun, 19 Apr 2020 17:46:14 +0100 Subject: [PATCH 5/5] rename nodes -> notes --- my/roamresearch.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/my/roamresearch.py b/my/roamresearch.py index 3acd336..ea668bb 100644 --- a/my/roamresearch.py +++ b/my/roamresearch.py @@ -68,7 +68,7 @@ class Node(NamedTuple): @property def children(self) -> List['Node']: - # TODO def. cache.. + # TODO cache? needs a key argument (because of Json) ch = self.raw.get(Keys.CHILDREN, []) return list(map(Node, ch)) @@ -121,11 +121,12 @@ class Node(NamedTuple): class Roam: - def __init__(self, json: List[Json]) -> None: - self.nodes: List[Node] = [] - # TODO make it lazy? - for j in json: - self.nodes.extend(Node.make(j)) + def __init__(self, raw: List[Json]) -> None: + self.raw = raw + + @property + def notes(self) -> List[Node]: + return list(chain.from_iterable(map(Node.make, self.raw))) def roam() -> Roam: @@ -138,7 +139,7 @@ def roam() -> Roam: def print_all_notes(): # just a demo method # TODO demonstrate dumping as org-mode?? - for n in roam().nodes: + for n in roam().notes: print(n.render()) # TODO could generate org-mode mirror in a single file for a demo?