From 27debd96e97c5beb70de945037f231cad624548f Mon Sep 17 00:00:00 2001 From: David Bieber Date: Sat, 5 Sep 2026 07:40:14 -0400 Subject: [PATCH 1/2] Upload to Roam via the backend API instead of a headless browser Roam's web app now sits behind a Vercel bot-verification challenge that the headless Firefox on the Pi fails: sign-in succeeds, but the graph never finishes loading (or renders "Failed to verify your browser"), so the browser uploader crashes on every batch. On devices still running the pre-#114 code it also silently committed notes it never inserted (block uid None). This adds roam_backend_api.py, a small client for Roam's backend API (q and write endpoints, peer redirect handling, retries), and roam_api_uploader.py, which builds the same Daily Notes > [[Go Note Go Notes]]: > session > notes structure as helper.js without a browser. The runner uses it whenever ROAM_API_TOKEN is configured and otherwise falls back to the browser uploader. Also: - settings.get() accepts a default for optional settings. - ROAM_API_TOKEN added to the settings template, masked in the settings server, and exposed in the settings UI. - requests is now a declared dependency. - conftest.py stubs the per-device secure_settings module so tests that import settings can run in CI. Co-Authored-By: Claude Fable 5.1 --- conftest.py | 17 ++ gonotego/settings-server/src/App.tsx | 6 +- gonotego/settings/secure_settings_template.py | 1 + gonotego/settings/server.py | 1 + gonotego/settings/settings.py | 13 +- gonotego/uploader/roam/roam_api_uploader.py | 118 +++++++++++ gonotego/uploader/roam/roam_backend_api.py | 196 ++++++++++++++++++ .../uploader/roam/test_roam_api_uploader.py | 140 +++++++++++++ .../uploader/roam/test_roam_backend_api.py | 154 ++++++++++++++ gonotego/uploader/runner.py | 13 ++ pyproject.toml | 1 + 11 files changed, 656 insertions(+), 4 deletions(-) create mode 100644 conftest.py create mode 100644 gonotego/uploader/roam/roam_api_uploader.py create mode 100644 gonotego/uploader/roam/roam_backend_api.py create mode 100644 gonotego/uploader/roam/test_roam_api_uploader.py create mode 100644 gonotego/uploader/roam/test_roam_backend_api.py diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..42038148 --- /dev/null +++ b/conftest.py @@ -0,0 +1,17 @@ +"""Shared pytest configuration. + +secure_settings.py holds per-device secrets and is not checked in. Modules +that import gonotego.settings.settings need it to exist, so when it is absent +the tests get an empty stand-in module instead. +""" +import importlib +import sys +import types + +try: + importlib.import_module('gonotego.settings.secure_settings') +except ImportError: + stub = types.ModuleType('gonotego.settings.secure_settings') + sys.modules['gonotego.settings.secure_settings'] = stub + import gonotego.settings + gonotego.settings.secure_settings = stub diff --git a/gonotego/settings-server/src/App.tsx b/gonotego/settings-server/src/App.tsx index ec4f513c..3ce96207 100644 --- a/gonotego/settings-server/src/App.tsx +++ b/gonotego/settings-server/src/App.tsx @@ -32,6 +32,7 @@ const SettingsUI = () => { NOTE_TAKING_SYSTEM: '', BLOB_STORAGE_SYSTEM: '', ROAM_GRAPH: '', + ROAM_API_TOKEN: '', ROAM_USER: '', ROAM_PASSWORD: '', REMNOTE_USER_ID: '', @@ -701,8 +702,9 @@ const SettingsUI = () => { {/* Conditional Settings based on Note Taking System */} {renderSettingGroup('Roam Research', 'Roam Research integration settings', [ { key: 'ROAM_GRAPH', label: 'Graph Name' }, - { key: 'ROAM_USER', label: 'Username' }, - { key: 'ROAM_PASSWORD', label: 'Password', type: 'password' }, + { key: 'ROAM_API_TOKEN', label: 'API Token (recommended; Roam Settings > Graph > API tokens)', type: 'password' }, + { key: 'ROAM_USER', label: 'Username (browser fallback)' }, + { key: 'ROAM_PASSWORD', label: 'Password (browser fallback)', type: 'password' }, ], shouldShowSection('roam'))} {renderSettingGroup('RemNote', 'RemNote integration settings', [ diff --git a/gonotego/settings/secure_settings_template.py b/gonotego/settings/secure_settings_template.py index 986ea919..733fcf5f 100644 --- a/gonotego/settings/secure_settings_template.py +++ b/gonotego/settings/secure_settings_template.py @@ -3,6 +3,7 @@ BLOB_STORAGE_SYSTEM = '' ROAM_GRAPH = '' +ROAM_API_TOKEN = '' ROAM_USER = '' ROAM_PASSWORD = '' diff --git a/gonotego/settings/server.py b/gonotego/settings/server.py index 3c1affb0..f068e7eb 100644 --- a/gonotego/settings/server.py +++ b/gonotego/settings/server.py @@ -22,6 +22,7 @@ # Sensitive keys that should be masked SENSITIVE_KEYS = [ + 'ROAM_API_TOKEN', 'ROAM_PASSWORD', 'REMNOTE_API_KEY', 'IDEAFLOW_PASSWORD', diff --git a/gonotego/settings/settings.py b/gonotego/settings/settings.py index 2b120cc5..42383a6f 100644 --- a/gonotego/settings/settings.py +++ b/gonotego/settings/settings.py @@ -17,12 +17,21 @@ def get_redis_key(key): return f'{SETTINGS_KEY}:{key}' -def get(key): +_MISSING = object() + + +def get(key, default=_MISSING): + """Returns a setting, preferring the value set on the device over secure_settings. + + Raises AttributeError for an unknown key unless a default is given. + """ r = interprocess.get_redis_client() value_bytes = r.get(get_redis_key(key)) if value_bytes is None: # If the setting isn't set in redis, fall back to the value from secure_settings. - return getattr(secure_settings, key) + if default is _MISSING: + return getattr(secure_settings, key) + return getattr(secure_settings, key, default) value_repr = value_bytes.decode('utf-8') value = ast.literal_eval(value_repr) return value diff --git a/gonotego/uploader/roam/roam_api_uploader.py b/gonotego/uploader/roam/roam_api_uploader.py new file mode 100644 index 00000000..96587fea --- /dev/null +++ b/gonotego/uploader/roam/roam_api_uploader.py @@ -0,0 +1,118 @@ +"""Uploads notes to Roam Research through the Roam backend API. + +Produces the same structure as the browser-based uploader (roam_uploader.py): + + Daily Notes page for the note's date + - [[Go Note Go Notes]]: + - 06:30 AM (one block per writing session) + - a note + - another note + - an indented note + +Unlike the browser uploader this needs no Firefox and no Roam password, and it +is unaffected by the bot-verification challenge in front of Roam's web app. +It is used whenever ROAM_API_TOKEN is set. +""" +from datetime import datetime +import os + +from gonotego.common import events +from gonotego.settings import settings +from gonotego.uploader.blob import blob_uploader +from gonotego.uploader.roam import roam_backend_api + +SECTION_TITLE = '[[Go Note Go Notes]]:' +UNVERIFIED_TAG = '#[[unverified transcription]]' + + +def note_datetime(note_event): + """The note's effective time (clock + alleged-time offset), or now if unknown.""" + timestamp = note_event.effective_timestamp if note_event is not None else None + return datetime.fromtimestamp(timestamp) if timestamp else datetime.now() + + +class Uploader: + + def __init__(self, client=None): + self._client = client + self.session_uid = None + self.last_note_uid = None + self.stack = [] + + def get_client(self): + if self._client is None: + self._client = roam_backend_api.RoamBackendClient( + token=settings.get('ROAM_API_TOKEN'), + graph=settings.get('ROAM_GRAPH')) + return self._client + + def new_session(self, note_event): + """Creates the block that this writing session's notes are nested under.""" + client = self.get_client() + dt = note_datetime(note_event) + title = roam_backend_api.daily_note_title(dt) + page_uid = client.get_or_create_page(title, uid=roam_backend_api.daily_note_uid(dt)) + section_uid = client.get_or_create_block_on_page(page_uid, SECTION_TITLE) + self.session_uid = client.create_block(section_uid, dt.strftime('%H:%M %p')) + print(f'Started session (({self.session_uid})) on "{title}"') + + def upload(self, note_events): + """Uploads the note events. Returns True on success, False if a request failed.""" + try: + self._upload(note_events) + except roam_backend_api.RoamAPIError as e: + print(f'Roam API upload failed: {e}') + return False + return True + + def _upload(self, note_events): + client = self.get_client() + blob_client = None + for note_event in note_events: + if note_event.action == events.INDENT: + # When you press tab, that adds your most-recent note to a stack. + if self.last_note_uid and self.last_note_uid not in self.stack: + self.stack.append(self.last_note_uid) + elif note_event.action == events.UNINDENT: + # When you shift-tab, that pops from the stack. + if self.stack: + self.stack.pop() + elif note_event.action == events.CLEAR_EMPTY: + # When you shift-delete from an empty note, that clears the stack. + self.stack = [] + elif note_event.action == events.ENTER_EMPTY: + # When you submit from an empty note, that pops from the stack. + if self.stack: + self.stack.pop() + elif note_event.action == events.END_SESSION: + self.end_session() + elif note_event.action == events.SUBMIT: + if self.session_uid is None: + self.new_session(note_event) + text = note_event.text.strip() + has_audio = bool(note_event.audio_filepath) and os.path.exists(note_event.audio_filepath) + if has_audio: + text = f'{text} {UNVERIFIED_TAG}' + parent_uid = self.stack[-1] if self.stack else self.session_uid + block_uid = client.create_block(parent_uid, text) + self.last_note_uid = block_uid + print(f'Inserted: "{text}" at block (({block_uid}))') + if has_audio: + if blob_client is None: + blob_client = blob_uploader.make_client() + embed_url = blob_uploader.upload_blob(note_event.audio_filepath, blob_client) + if embed_url: + embed_text = '{{audio: ' + embed_url + '}}' + print(f'Audio embed: {embed_text}') + client.create_block(block_uid, embed_text) + + def handle_inactivity(self): + self.end_session() + + def handle_disconnect(self): + self.end_session() + + def end_session(self): + self.session_uid = None + self.last_note_uid = None + self.stack = [] diff --git a/gonotego/uploader/roam/roam_backend_api.py b/gonotego/uploader/roam/roam_backend_api.py new file mode 100644 index 00000000..ff72f496 --- /dev/null +++ b/gonotego/uploader/roam/roam_backend_api.py @@ -0,0 +1,196 @@ +"""Client for the Roam Research backend API. + +Talks to Roam over HTTPS instead of driving Roam in a browser. Roam's web app +sits behind a bot-verification challenge that headless browsers on the Pi fail, +so the browser-based uploader can no longer reach the graph; the backend API +has no such check. + +Create a token in Roam under Settings > Graph > API tokens (edit access), then +run ":set ROAM_API_TOKEN " on Go Note Go or add it to secure_settings. + +Reference: https://github.com/Roam-Research/backend-sdks +Docs: https://roamresearch.com/#/app/developer-documentation/page/bmYYKQ4vf +""" +import random +import re +import string +import time + +import requests + +BASE_URL = 'https://api.roamresearch.com' +UID_ALPHABET = string.ascii_letters + string.digits + '-_' +UID_LENGTH = 9 +PEER_RE = re.compile(r'https://(peer-\d+)[^:/]*:(\d+)') +MAX_REDIRECTS = 5 +RETRY_DELAY_SECONDS = 3 + +PAGE_UID_QUERY = """ +[:find ?uid + :in $ ?title + :where + [?page :node/title ?title] + [?page :block/uid ?uid]] +""" + +BLOCK_ON_PAGE_QUERY = """ +[:find ?uid + :in $ ?page-uid ?string + :where + [?page :block/uid ?page-uid] + [?block :block/page ?page] + [?block :block/string ?string] + [?block :block/uid ?uid]] +""" + + +class RoamAPIError(Exception): + """The Roam backend API rejected a request or could not be reached.""" + + +class RoamNotReadyError(RoamAPIError): + """The graph is still starting up on Roam's side; the request can be retried.""" + + +def generate_uid(): + """Returns a new random block uid in Roam's 9-character format.""" + return ''.join(random.choice(UID_ALPHABET) for _ in range(UID_LENGTH)) + + +def ordinal_suffix(day): + """Returns 'st', 'nd', 'rd', or 'th' for a day of the month.""" + if 3 < day < 21: + return 'th' + return {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') + + +def daily_note_title(dt): + """Returns the title Roam uses for the Daily Notes page of a date, e.g. 'September 5th, 2026'.""" + return f'{dt.strftime("%B")} {dt.day}{ordinal_suffix(dt.day)}, {dt.year}' + + +def daily_note_uid(dt): + """Returns the uid Roam uses for the Daily Notes page of a date, e.g. '09-05-2026'.""" + return dt.strftime('%m-%d-%Y') + + +def normalize_graph_name(graph): + """Strips the 'app/' prefix the browser uploader accepts in ROAM_GRAPH.""" + if graph.startswith('app/'): + return graph[len('app/'):] + return graph + + +class RoamBackendClient: + """A minimal client for Roam's q and write endpoints.""" + + def __init__(self, token, graph, session=None, timeout=60, retries=3): + if not token: + raise ValueError('A Roam API token is required.') + self._token = token + self.graph = normalize_graph_name(graph) + self._session = session or requests.Session() + self._timeout = timeout + self._retries = retries + # Roam redirects the first request to a graph-specific peer; we remember it. + self._base_url = BASE_URL + + def _headers(self): + return { + 'Content-Type': 'application/json; charset=utf-8', + 'Accept': 'application/json', + 'Authorization': f'Bearer {self._token}', + 'x-authorization': f'Bearer {self._token}', + } + + def _call_once(self, endpoint, body): + for _ in range(MAX_REDIRECTS): + url = f'{self._base_url}/api/graph/{self.graph}/{endpoint}' + response = self._session.post( + url, headers=self._headers(), json=body, + allow_redirects=False, timeout=self._timeout) + if response.is_redirect or response.is_permanent_redirect: + location = response.headers.get('Location', '') + match = PEER_RE.search(location) + if not match: + raise RoamAPIError(f'Unexpected redirect from Roam API: {location!r}') + peer, port = match.groups() + self._base_url = f'https://{peer}.api.roamresearch.com:{port}' + continue + break + else: + raise RoamAPIError('Too many redirects from the Roam API.') + + if response.status_code == 401: + raise RoamAPIError('Roam API token is invalid or lacks permission for this graph.') + if response.status_code == 503: + raise RoamNotReadyError('Roam graph is not ready yet; retry in a few seconds.') + if not response.ok: + raise RoamAPIError(f'Roam API error (HTTP {response.status_code}): {response.text[:500]}') + return response + + def call(self, endpoint, body): + """POSTs body to the graph's endpoint ('q', 'pull', or 'write') and returns the response. + + Retries transient failures (connection problems, graph not ready) a few times. + Raises RoamAPIError if the request ultimately fails. + """ + attempt = 0 + while True: + try: + return self._call_once(endpoint, body) + except (requests.RequestException, RoamNotReadyError) as e: + attempt += 1 + if attempt > self._retries: + if isinstance(e, RoamAPIError): + raise + raise RoamAPIError(f'Could not reach the Roam API: {e!r}') from e + print(f'Roam API request failed ({e!r}); retrying in {RETRY_DELAY_SECONDS}s.') + time.sleep(RETRY_DELAY_SECONDS) + + def q(self, query, args=None): + """Runs a datalog query and returns its result rows.""" + body = {'query': query} + if args is not None: + body['args'] = list(args) + return self.call('q', body).json()['result'] + + def write(self, body): + return self.call('write', body) + + def get_page_uid(self, title): + """Returns the uid of the page with this title, or None if it doesn't exist.""" + results = self.q(PAGE_UID_QUERY, [title]) + return results[0][0] if results else None + + def create_page(self, title, uid=None): + """Creates a page and returns its uid.""" + page = {'title': title} + if uid: + page['uid'] = uid + self.write({'action': 'create-page', 'page': page}) + return uid or self.get_page_uid(title) + + def get_or_create_page(self, title, uid=None): + return self.get_page_uid(title) or self.create_page(title, uid=uid) + + def get_block_on_page_uid(self, page_uid, text): + """Returns the uid of a block anywhere on the page with exactly this text, or None.""" + results = self.q(BLOCK_ON_PAGE_QUERY, [page_uid, text]) + return results[0][0] if results else None + + def create_block(self, parent_uid, text, order='last', uid=None): + """Creates a child block under parent_uid and returns the new block's uid. + + order is an integer position or 'last'. + """ + uid = uid or generate_uid() + self.write({ + 'action': 'create-block', + 'location': {'parent-uid': parent_uid, 'order': order}, + 'block': {'string': text, 'uid': uid}, + }) + return uid + + def get_or_create_block_on_page(self, page_uid, text, order='last'): + return self.get_block_on_page_uid(page_uid, text) or self.create_block(page_uid, text, order=order) diff --git a/gonotego/uploader/roam/test_roam_api_uploader.py b/gonotego/uploader/roam/test_roam_api_uploader.py new file mode 100644 index 00000000..fa5b664b --- /dev/null +++ b/gonotego/uploader/roam/test_roam_api_uploader.py @@ -0,0 +1,140 @@ +from datetime import datetime + +from gonotego.common import events +from gonotego.uploader.blob import blob_uploader +from gonotego.uploader.roam import roam_api_uploader +from gonotego.uploader.roam import roam_backend_api + + +class FakeClient: + """Records the tree of pages and blocks the uploader asks for.""" + + def __init__(self): + self.pages = {} # title -> uid + self.page_uids = {} # title -> uid passed at creation + self.blocks = [] # (parent_uid, text, uid) + self.blocks_on_page = {} # (page_uid, text) -> uid + self.count = 0 + self.fail = False + + def get_or_create_page(self, title, uid=None): + if title not in self.pages: + self.pages[title] = uid or f'page{len(self.pages)}' + self.page_uids[title] = uid + return self.pages[title] + + def get_or_create_block_on_page(self, page_uid, text, order='last'): + key = (page_uid, text) + if key not in self.blocks_on_page: + self.blocks_on_page[key] = self.create_block(page_uid, text, order=order) + return self.blocks_on_page[key] + + def create_block(self, parent_uid, text, order='last', uid=None): + if self.fail: + raise roam_backend_api.RoamAPIError('boom') + self.count += 1 + uid = uid or f'b{self.count}' + self.blocks.append((parent_uid, text, uid)) + return uid + + def children(self, parent_uid): + return [text for parent, text, _ in self.blocks if parent == parent_uid] + + def uid_of(self, text): + return next(uid for _, t, uid in self.blocks if t == text) + + +TS = datetime(2026, 9, 5, 6, 30).timestamp() + + +def note(action, text='', audio_filepath='', timestamp=TS): + return events.NoteEvent(text=text, action=action, audio_filepath=audio_filepath, timestamp=timestamp) + + +def test_upload_builds_daily_note_structure(): + client = FakeClient() + uploader = roam_api_uploader.Uploader(client=client) + + ok = uploader.upload([ + note(events.SUBMIT, 'first'), + note(events.SUBMIT, 'second'), + note(events.INDENT), + note(events.SUBMIT, 'nested under second'), + note(events.UNINDENT), + note(events.SUBMIT, 'third'), + note(events.END_SESSION), + note(events.SUBMIT, 'new session note', timestamp=datetime(2026, 9, 5, 7, 14).timestamp()), + ]) + + assert ok is True + assert client.pages == {'September 5th, 2026': '09-05-2026'} + assert client.page_uids['September 5th, 2026'] == '09-05-2026' + assert client.children('09-05-2026') == ['[[Go Note Go Notes]]:'] + section_uid = client.uid_of('[[Go Note Go Notes]]:') + assert client.children(section_uid) == ['06:30 AM', '07:14 AM'] + first_session = client.uid_of('06:30 AM') + assert client.children(first_session) == ['first', 'second', 'third'] + assert client.children(client.uid_of('second')) == ['nested under second'] + assert client.children(client.uid_of('07:14 AM')) == ['new session note'] + + +def test_enter_empty_pops_stack_and_clear_empty_clears_it(): + client = FakeClient() + uploader = roam_api_uploader.Uploader(client=client) + uploader.upload([ + note(events.SUBMIT, 'a'), + note(events.INDENT), + note(events.SUBMIT, 'a1'), + note(events.INDENT), + note(events.SUBMIT, 'a1x'), + note(events.ENTER_EMPTY), + note(events.SUBMIT, 'a2'), + note(events.CLEAR_EMPTY), + note(events.SUBMIT, 'b'), + ]) + session = client.uid_of('06:30 AM') + assert client.children(session) == ['a', 'b'] + assert client.children(client.uid_of('a')) == ['a1', 'a2'] + assert client.children(client.uid_of('a1')) == ['a1x'] + + +def test_session_persists_across_uploads_until_ended(): + client = FakeClient() + uploader = roam_api_uploader.Uploader(client=client) + assert uploader.upload([note(events.SUBMIT, 'one')]) + assert uploader.upload([note(events.SUBMIT, 'two')]) + uploader.handle_inactivity() + assert uploader.upload([note(events.SUBMIT, 'three')]) + section_uid = client.uid_of('[[Go Note Go Notes]]:') + assert len(client.children(section_uid)) == 2 + assert client.children(client.uid_of('one')) == [] + assert client.children(client.blocks[1][2]) == ['one', 'two'] + + +def test_api_error_returns_false_so_notes_stay_queued(): + client = FakeClient() + client.fail = True + uploader = roam_api_uploader.Uploader(client=client) + assert uploader.upload([note(events.SUBMIT, 'one')]) is False + + +def test_audio_notes_get_tag_and_embed(monkeypatch, tmp_path): + audio = tmp_path / 'clip.wav' + audio.write_bytes(b'RIFF') + monkeypatch.setattr(blob_uploader, 'make_client', lambda: object()) + monkeypatch.setattr(blob_uploader, 'upload_blob', lambda filepath, client: 'https://dl.example.com/clip.wav') + client = FakeClient() + uploader = roam_api_uploader.Uploader(client=client) + + assert uploader.upload([note(events.SUBMIT, 'spoken note', audio_filepath=str(audio))]) + + session = client.uid_of('06:30 AM') + assert client.children(session) == ['spoken note #[[unverified transcription]]'] + note_uid = client.uid_of('spoken note #[[unverified transcription]]') + assert client.children(note_uid) == ['{{audio: https://dl.example.com/clip.wav}}'] + + +def test_note_datetime_uses_effective_timestamp(): + event = note(events.SUBMIT, 'x') + event.offset = 3600.0 + assert roam_api_uploader.note_datetime(event) == datetime(2026, 9, 5, 7, 30) diff --git a/gonotego/uploader/roam/test_roam_backend_api.py b/gonotego/uploader/roam/test_roam_backend_api.py new file mode 100644 index 00000000..53c7b489 --- /dev/null +++ b/gonotego/uploader/roam/test_roam_backend_api.py @@ -0,0 +1,154 @@ +from datetime import datetime + +import pytest +import requests + +from gonotego.uploader.roam import roam_backend_api as api + + +class FakeResponse: + + def __init__(self, status_code=200, json_data=None, headers=None, text=''): + self.status_code = status_code + self._json = json_data if json_data is not None else {} + self.headers = headers or {} + self.text = text + has_location = 'Location' in self.headers + self.is_redirect = has_location and status_code in (301, 302, 303, 307, 308) + self.is_permanent_redirect = has_location and status_code in (301, 308) + self.ok = status_code < 400 + + def json(self): + return self._json + + +class FakeSession: + + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def post(self, url, headers=None, json=None, allow_redirects=True, timeout=None): + self.calls.append({'url': url, 'headers': headers, 'json': json, 'allow_redirects': allow_redirects}) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +REDIRECT = FakeResponse(308, headers={'Location': 'https://peer-24.api.roamresearch.com:3001/api/graph/g/q'}) + + +def make_client(responses, **kwargs): + session = FakeSession(responses) + client = api.RoamBackendClient(token='tok', graph='g', session=session, **kwargs) + return client, session + + +def test_follows_peer_redirect_and_remembers_peer(): + client, session = make_client([REDIRECT, FakeResponse(json_data={'result': [['abc']]}), FakeResponse(json_data={'result': []})]) + + assert client.q('[:find ?x]', ['arg']) == [['abc']] + assert client.q('[:find ?x]') == [] + + assert session.calls[0]['url'] == 'https://api.roamresearch.com/api/graph/g/q' + assert session.calls[1]['url'] == 'https://peer-24.api.roamresearch.com:3001/api/graph/g/q' + assert session.calls[2]['url'] == 'https://peer-24.api.roamresearch.com:3001/api/graph/g/q' + assert session.calls[0]['json'] == {'query': '[:find ?x]', 'args': ['arg']} + assert session.calls[2]['json'] == {'query': '[:find ?x]'} + for call in session.calls: + assert call['allow_redirects'] is False + assert call['headers']['Authorization'] == 'Bearer tok' + assert call['headers']['x-authorization'] == 'Bearer tok' + + +def test_unexpected_redirect_raises(): + client, _ = make_client([FakeResponse(308, headers={'Location': 'https://elsewhere.example.com/'})]) + with pytest.raises(api.RoamAPIError): + client.q('[:find ?x]') + + +def test_invalid_token_raises(): + client, _ = make_client([FakeResponse(401)]) + with pytest.raises(api.RoamAPIError, match='token'): + client.q('[:find ?x]') + + +def test_bad_request_includes_server_message(): + client, _ = make_client([FakeResponse(400, text='{"message":"nope"}')]) + with pytest.raises(api.RoamAPIError, match='nope'): + client.write({'action': 'create-block'}) + + +def test_not_ready_is_retried(monkeypatch): + monkeypatch.setattr(api.time, 'sleep', lambda seconds: None) + client, session = make_client([FakeResponse(503), FakeResponse(json_data={'result': [['uid']]})], retries=1) + assert client.q('[:find ?x]') == [['uid']] + assert len(session.calls) == 2 + + +def test_connection_error_is_wrapped(monkeypatch): + monkeypatch.setattr(api.time, 'sleep', lambda seconds: None) + client, _ = make_client([requests.ConnectionError('down'), requests.ConnectionError('down')], retries=1) + with pytest.raises(api.RoamAPIError, match='Could not reach'): + client.q('[:find ?x]') + + +def test_get_page_uid(): + client, _ = make_client([FakeResponse(json_data={'result': [['09-05-2026']]}), FakeResponse(json_data={'result': []})]) + assert client.get_page_uid('September 5th, 2026') == '09-05-2026' + assert client.get_page_uid('Missing') is None + + +def test_get_or_create_page_creates_with_uid(): + client, session = make_client([FakeResponse(json_data={'result': []}), FakeResponse()]) + assert client.get_or_create_page('September 5th, 2026', uid='09-05-2026') == '09-05-2026' + assert session.calls[1]['url'].endswith('/api/graph/g/write') + assert session.calls[1]['json'] == {'action': 'create-page', 'page': {'title': 'September 5th, 2026', 'uid': '09-05-2026'}} + + +def test_create_block_generates_uid_and_appends_last(): + client, session = make_client([FakeResponse()]) + uid = client.create_block('parent', 'hello') + assert len(uid) == api.UID_LENGTH + assert set(uid) <= set(api.UID_ALPHABET) + assert session.calls[0]['json'] == { + 'action': 'create-block', + 'location': {'parent-uid': 'parent', 'order': 'last'}, + 'block': {'string': 'hello', 'uid': uid}, + } + + +def test_get_or_create_block_on_page_reuses_existing(): + client, session = make_client([FakeResponse(json_data={'result': [['existing']]})]) + assert client.get_or_create_block_on_page('page', '[[Go Note Go Notes]]:') == 'existing' + assert len(session.calls) == 1 + assert session.calls[0]['json']['args'] == ['page', '[[Go Note Go Notes]]:'] + + +def test_requires_token(): + with pytest.raises(ValueError): + api.RoamBackendClient(token='', graph='g') + + +@pytest.mark.parametrize('day, expected', [ + (1, 'September 1st, 2026'), (2, 'September 2nd, 2026'), (3, 'September 3rd, 2026'), + (4, 'September 4th, 2026'), (11, 'September 11th, 2026'), (12, 'September 12th, 2026'), + (13, 'September 13th, 2026'), (21, 'September 21st, 2026'), (22, 'September 22nd, 2026'), + (23, 'September 23rd, 2026'), (30, 'September 30th, 2026'), +]) +def test_daily_note_title(day, expected): + assert api.daily_note_title(datetime(2026, 9, day, 6, 30)) == expected + + +def test_daily_note_title_31st(): + assert api.daily_note_title(datetime(2026, 1, 31)) == 'January 31st, 2026' + + +def test_daily_note_uid(): + assert api.daily_note_uid(datetime(2026, 9, 5)) == '09-05-2026' + + +def test_normalize_graph_name(): + assert api.normalize_graph_name('app/playground') == 'playground' + assert api.normalize_graph_name('playground') == 'playground' diff --git a/gonotego/uploader/runner.py b/gonotego/uploader/runner.py index c34dc390..68c25172 100644 --- a/gonotego/uploader/runner.py +++ b/gonotego/uploader/runner.py @@ -10,6 +10,7 @@ from gonotego.uploader.email import email_uploader from gonotego.uploader.ideaflow import ideaflow_uploader from gonotego.uploader.remnote import remnote_uploader +from gonotego.uploader.roam import roam_api_uploader from gonotego.uploader.roam import roam_uploader from gonotego.uploader.mem import mem_uploader from gonotego.uploader.notion import notion_uploader @@ -25,6 +26,13 @@ def print_configuration_help(): print("Example: ':set NOTE_TAKING_SYSTEM roam'") +def is_configured(value): + """True if a setting has a real value rather than being empty or a ''.""" + if not value: + return False + return not (value.startswith('<') and value.endswith('>')) + + def is_unconfigured(note_taking_system): """Check if the note taking system is unconfigured.""" return note_taking_system == '' or note_taking_system == '' @@ -38,6 +46,11 @@ def make_uploader(note_taking_system): elif note_taking_system == 'remnote': return remnote_uploader.Uploader() elif note_taking_system == 'roam': + if is_configured(settings.get('ROAM_API_TOKEN', None)): + return roam_api_uploader.Uploader() + print('ROAM_API_TOKEN is not set; using the browser-based Roam uploader. ' + 'Create a token in Roam (Settings > Graph > API tokens) and run ' + "':set ROAM_API_TOKEN ' to upload without a browser.") return roam_uploader.Uploader() elif note_taking_system == 'mem': return mem_uploader.Uploader() diff --git a/pyproject.toml b/pyproject.toml index 1ea6db5d..64ec8c69 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ 'pydantic_core==2.23.4', # Pin to specific working version 'python-dateutil<=2.9.0.post0', 'redis<=7.0.1', + 'requests<=2.32.5', # selenium 4.0 breaks with arm geckodriver. 'selenium==3.141.0', 'setuptools-rust<=1.12.0', From b2f5aa40f61d61f15d2ed10c683023a95e515026 Mon Sep 17 00:00:00 2001 From: David Bieber Date: Sat, 5 Sep 2026 07:42:59 -0400 Subject: [PATCH 2/2] Switch Roam uploader implementation live when ROAM_API_TOKEN changes Lets ':set ROAM_API_TOKEN ' on a running device take effect without a restart, and covers is_configured with a test. Co-Authored-By: Claude Fable 5.1 --- gonotego/uploader/runner.py | 13 ++++++++++++- gonotego/uploader/test_runner.py | 8 ++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 gonotego/uploader/test_runner.py diff --git a/gonotego/uploader/runner.py b/gonotego/uploader/runner.py index 68c25172..f1ceb722 100644 --- a/gonotego/uploader/runner.py +++ b/gonotego/uploader/runner.py @@ -33,6 +33,11 @@ def is_configured(value): return not (value.startswith('<') and value.endswith('>')) +def roam_api_configured(): + """True if a Roam API token is set, so Roam uploads can skip the browser.""" + return is_configured(settings.get('ROAM_API_TOKEN', None)) + + def is_unconfigured(note_taking_system): """Check if the note taking system is unconfigured.""" return note_taking_system == '' or note_taking_system == '' @@ -46,7 +51,7 @@ def make_uploader(note_taking_system): elif note_taking_system == 'remnote': return remnote_uploader.Uploader() elif note_taking_system == 'roam': - if is_configured(settings.get('ROAM_API_TOKEN', None)): + if roam_api_configured(): return roam_api_uploader.Uploader() print('ROAM_API_TOKEN is not set; using the browser-based Roam uploader. ' 'Create a token in Roam (Settings > Graph > API tokens) and run ' @@ -107,6 +112,12 @@ def main(): uploader = make_uploader(note_taking_system) + # Switch between the Roam API and browser uploaders if ROAM_API_TOKEN was + # set or cleared while running (e.g. via ':set ROAM_API_TOKEN '). + if note_taking_system == 'roam' and roam_api_configured() != isinstance(uploader, roam_api_uploader.Uploader): + uploader.handle_disconnect() + uploader = make_uploader(note_taking_system) + note_event_bytes_list = [] note_events = [] while note_events_queue.size() > 0: diff --git a/gonotego/uploader/test_runner.py b/gonotego/uploader/test_runner.py new file mode 100644 index 00000000..d47d504d --- /dev/null +++ b/gonotego/uploader/test_runner.py @@ -0,0 +1,8 @@ +from gonotego.uploader import runner + + +def test_is_configured(): + assert runner.is_configured('roam-graph-token-abc') + assert not runner.is_configured('') + assert not runner.is_configured(None) + assert not runner.is_configured('')