diff --git a/gonotego/settings-server/src/App.tsx b/gonotego/settings-server/src/App.tsx index 3ce96207..68e782b7 100644 --- a/gonotego/settings-server/src/App.tsx +++ b/gonotego/settings-server/src/App.tsx @@ -20,7 +20,8 @@ const SettingsUI = () => { { display: 'Notion', value: 'notion' }, { display: 'Slack', value: 'slack' }, { display: 'Twitter', value: 'twitter' }, - { display: 'Email', value: 'email' } + { display: 'Email', value: 'email' }, + { display: 'Google Docs', value: 'googledocs' } ]; const BLOB_STORAGE_SYSTEMS = [ @@ -54,6 +55,10 @@ const SettingsUI = () => { EMAIL_USER: '', EMAIL_PASSWORD: '', EMAIL_SERVER: '', + GOOGLE_DOCS_CREDENTIALS: '', + GOOGLE_DOCS_SHARE_EMAIL: '', + GOOGLE_DOCS_FOLDER_ID: '', + GOOGLE_DOCS_TITLE_FORMAT: '', DROPBOX_ACCESS_TOKEN: '', OPENAI_API_KEY: '', WIFI_NETWORKS: [], @@ -727,6 +732,13 @@ const SettingsUI = () => { { key: 'NOTION_DATABASE_ID', label: 'Database ID' }, ], shouldShowSection('notion'))} + {renderSettingGroup('Google Docs', 'Google Docs integration settings (one doc per month)', [ + { key: 'GOOGLE_DOCS_CREDENTIALS', label: 'Service Account JSON Path', tip: 'Path on the device to the service-account credentials file' }, + { key: 'GOOGLE_DOCS_SHARE_EMAIL', label: 'Share With Email', tip: 'Each monthly doc is shared with this address as an editor' }, + { key: 'GOOGLE_DOCS_FOLDER_ID', label: 'Drive Folder ID', tip: 'Optional: Drive folder to create the monthly docs in' }, + { key: 'GOOGLE_DOCS_TITLE_FORMAT', label: 'Doc Title Format', tip: "Optional strftime for the monthly doc title, default '%B %Y'" }, + ], shouldShowSection('googledocs'))} + {renderSettingGroup('Slack', 'Slack integration settings', [ { key: 'SLACK_API_TOKEN', label: 'API Token', type: 'password', tip: 'Bot token starting with xoxb-' }, { key: 'SLACK_CHANNEL', label: 'Channel Name', tip: 'Channel name without the # symbol' }, diff --git a/gonotego/settings/secure_settings_template.py b/gonotego/settings/secure_settings_template.py index 733fcf5f..5fadd7d6 100644 --- a/gonotego/settings/secure_settings_template.py +++ b/gonotego/settings/secure_settings_template.py @@ -33,6 +33,11 @@ EMAIL_PASSWORD = '' EMAIL_SERVER = '' +GOOGLE_DOCS_CREDENTIALS = '' +GOOGLE_DOCS_SHARE_EMAIL = '' +GOOGLE_DOCS_FOLDER_ID = '' +GOOGLE_DOCS_TITLE_FORMAT = '' + DROPBOX_ACCESS_TOKEN = '' OPENAI_API_KEY = '' diff --git a/gonotego/uploader/googledocs/googledocs_api.py b/gonotego/uploader/googledocs/googledocs_api.py new file mode 100644 index 00000000..7c47e12a --- /dev/null +++ b/gonotego/uploader/googledocs/googledocs_api.py @@ -0,0 +1,133 @@ +"""Client for the Google Docs and Drive REST APIs used by the Google Docs uploader. + +Auth can be either of two credential kinds, both pointed to by +GOOGLE_DOCS_CREDENTIALS (falling back to the GOOGLE_APPLICATION_CREDENTIALS env +var, then /home/pi/secrets/google_credentials.json): + + - A **service account** key (JSON with "type": "service_account"). Docs are + created and owned by the service account, then shared with GOOGLE_DOCS_SHARE_EMAIL. + - A **user OAuth token** (an "authorized user" JSON with a refresh_token, + produced by scripts/authorize_googledocs.py). Docs are created in and owned + by that Google user's own Drive. Use this to write into someone's own Drive, + since a service account cannot own files in a consumer Gmail Drive. + +The uploader keeps one Google Doc per month. This client finds that doc by name +or creates it, optionally placing it in a Drive folder, then appends content. + +Scopes: documents (edit docs) and drive.file (create/find/share only the files +this app makes). drive.file is deliberately narrow: only docs this app created +are visible to it, not the rest of anyone's Drive. +""" +import json +import os + +SCOPES = [ + 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/drive.file', +] +DEFAULT_CREDENTIALS_PATH = '/home/pi/secrets/google_credentials.json' +DRIVE_FILES_URL = 'https://www.googleapis.com/drive/v3/files' +DOCS_URL = 'https://docs.googleapis.com/v1/documents' +DOC_MIME = 'application/vnd.google-apps.document' + + +class GoogleDocsError(Exception): + """A Google Docs or Drive API request failed or could not be made.""" + + +def default_credentials_path(): + return os.environ.get('GOOGLE_APPLICATION_CREDENTIALS') or DEFAULT_CREDENTIALS_PATH + + +def load_credentials(path): + """Loads Google credentials from a service-account key or a user OAuth token. + + Detects the kind by the JSON contents: a service-account key has + "type": "service_account"; anything else is treated as an authorized-user + token (with a refresh_token) that authenticates as a specific Google user so + the docs live in that user's own Drive. + """ + with open(path) as f: + info = json.load(f) + if info.get('type') == 'service_account': + from google.oauth2 import service_account + return service_account.Credentials.from_service_account_info(info, scopes=SCOPES) + from google.oauth2.credentials import Credentials + return Credentials.from_authorized_user_info(info, scopes=SCOPES) + + +def _escape_query_value(value): + """Escapes a value for use inside a Drive query string literal.""" + return value.replace('\\', '\\\\').replace("'", "\\'") + + +class GoogleDocsClient: + """A minimal client for the Drive files endpoints and the Docs endpoints.""" + + def __init__(self, session=None, credentials_path=None, timeout=60): + self._session = session + self._credentials_path = credentials_path + self._timeout = timeout + + def session(self): + """Returns an authorized requests session, building it from the SA creds once.""" + if self._session is None: + from google.auth.transport.requests import AuthorizedSession + path = self._credentials_path or default_credentials_path() + self._session = AuthorizedSession(load_credentials(path)) + return self._session + + def _request(self, method, url, **kwargs): + kwargs.setdefault('timeout', self._timeout) + response = self.session().request(method, url, **kwargs) + if response.status_code // 100 != 2: + raise GoogleDocsError( + f'{method} {url} -> HTTP {response.status_code}: {response.text[:400]}') + if response.content: + return response.json() + return {} + + def find_doc(self, name): + """Returns the id of a Google Doc with exactly this name, or None.""" + query = (f"name = '{_escape_query_value(name)}' and " + f"mimeType = '{DOC_MIME}' and trashed = false") + data = self._request( + 'GET', DRIVE_FILES_URL, + params={'q': query, 'fields': 'files(id,name)', 'pageSize': 1}) + files = data.get('files', []) + return files[0]['id'] if files else None + + def create_doc(self, name, folder_id=None, share_email=None): + """Creates a Google Doc, optionally in a folder and shared with an email.""" + metadata = {'name': name, 'mimeType': DOC_MIME} + if folder_id: + metadata['parents'] = [folder_id] + data = self._request( + 'POST', DRIVE_FILES_URL, params={'fields': 'id'}, json=metadata) + doc_id = data['id'] + if share_email: + self.share(doc_id, share_email) + return doc_id + + def share(self, doc_id, email, role='writer'): + """Grants a user access to a doc without sending a notification email.""" + self._request( + 'POST', f'{DRIVE_FILES_URL}/{doc_id}/permissions', + params={'sendNotificationEmail': 'false'}, + json={'type': 'user', 'role': role, 'emailAddress': email}) + + def get_or_create_month_doc(self, name, folder_id=None, share_email=None): + """Returns the id of the doc named `name`, creating and sharing it if absent.""" + return self.find_doc(name) or self.create_doc( + name, folder_id=folder_id, share_email=share_email) + + def get_document(self, doc_id): + """Fetches the document structure (used to find the append point and last day).""" + return self._request('GET', f'{DOCS_URL}/{doc_id}') + + def batch_update(self, doc_id, requests): + """Applies a list of Docs API requests in one batch.""" + if not requests: + return {} + return self._request( + 'POST', f'{DOCS_URL}/{doc_id}:batchUpdate', json={'requests': requests}) diff --git a/gonotego/uploader/googledocs/googledocs_uploader.py b/gonotego/uploader/googledocs/googledocs_uploader.py new file mode 100644 index 00000000..a4f6b483 --- /dev/null +++ b/gonotego/uploader/googledocs/googledocs_uploader.py @@ -0,0 +1,257 @@ +"""Uploads notes to Google Docs, one document per month. + +Layout inside each month's doc: + + September 2026 <- the document's title (one per month) + + Monday, September 8th, 2026 <- day heading (Heading 1) + 6:30 AM <- session timestamp (Heading 2) + - a note <- bulleted, nested by indent level + - another note + - an indented note + 7:14 AM + - a later session's note + +A new day heading is written when the day changes, and a new timestamp heading +when a session starts (after an END_SESSION, inactivity, or a restart). Notes are +a bulleted list nested by the tab/indent level, matching how they were typed. + +Auth is a Google service account (see googledocs_api). Settings: + GOOGLE_DOCS_CREDENTIALS: path to the service-account JSON (optional; defaults + to GOOGLE_APPLICATION_CREDENTIALS or /home/pi/secrets/google_credentials.json). + GOOGLE_DOCS_SHARE_EMAIL: email to share each new month's doc with (e.g. yours). + GOOGLE_DOCS_FOLDER_ID: Drive folder id to create the month docs in (optional). + GOOGLE_DOCS_TITLE_FORMAT: strftime for the month doc title (default '%B %Y'). +""" +from datetime import datetime + +from gonotego.common import events +from gonotego.settings import settings +from gonotego.uploader.googledocs import googledocs_api + +DAY_STYLE = 'HEADING_1' +SESSION_STYLE = 'HEADING_2' +BULLET_PRESET = 'BULLET_DISC_CIRCLE_SQUARE' +DEFAULT_TITLE_FORMAT = '%B %Y' + + +def clip(x, a, b): + return max(a, min(x, b)) + + +def ordinal_suffix(day): + if 3 < day < 21: + return 'th' + return {1: 'st', 2: 'nd', 3: 'rd'}.get(day % 10, 'th') + + +def day_title(dt): + """e.g. 'Monday, September 8th, 2026'.""" + return f'{dt.strftime("%A, %B")} {dt.day}{ordinal_suffix(dt.day)}, {dt.year}' + + +def session_title(dt): + """e.g. '6:30 AM' (no leading zero on the hour).""" + return f'{dt.hour % 12 or 12}:{dt.minute:02d} {"AM" if dt.hour < 12 else "PM"}' + + +def month_title(dt): + fmt = _setting('GOOGLE_DOCS_TITLE_FORMAT') or DEFAULT_TITLE_FORMAT + return dt.strftime(fmt) + + +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() + + +def _setting(key): + """Returns a configured setting value, or None if unset or a ''.""" + value = settings.get(key, None) + if not value: + return None + if isinstance(value, str) and value.startswith('<') and value.endswith('>'): + return None + return value + + +def doc_end_index(document): + """The index just past the document body, where new content is appended.""" + content = document.get('body', {}).get('content', []) + return content[-1]['endIndex'] if content else 1 + + +def _paragraph_text(element): + runs = element.get('paragraph', {}).get('elements', []) + return ''.join(run.get('textRun', {}).get('content', '') for run in runs) + + +def last_day_heading(document): + """The text of the last Heading-1 (day) paragraph in the doc, or None.""" + last = None + for element in document.get('body', {}).get('content', []): + paragraph = element.get('paragraph') + if paragraph and paragraph.get('paragraphStyle', {}).get('namedStyleType') == DAY_STYLE: + last = _paragraph_text(element).strip() + return last + + +class Uploader: + + def __init__(self, client=None): + self._client = client + self.indent_level = 0 + self.last_indent_level = -1 + self.session_started = False + self.current_day = None + self._month_doc = None # (month_title, doc_id) + + def get_client(self): + if self._client is None: + self._client = googledocs_api.GoogleDocsClient( + credentials_path=_setting('GOOGLE_DOCS_CREDENTIALS')) + return self._client + + def month_doc_id(self, dt): + """Finds or creates the doc for dt's month, caching within the month.""" + title = month_title(dt) + if self._month_doc is None or self._month_doc[0] != title: + doc_id = self.get_client().get_or_create_month_doc( + title, + folder_id=_setting('GOOGLE_DOCS_FOLDER_ID'), + share_email=_setting('GOOGLE_DOCS_SHARE_EMAIL')) + self._month_doc = (title, doc_id) + self.current_day = None # new/unknown doc; re-learn the last day from it + return self._month_doc[1] + + def upload(self, note_events): + """Appends the note events to the current month's doc. Returns True on success.""" + try: + self._upload(note_events) + except googledocs_api.GoogleDocsError as e: + print(f'Google Docs upload failed: {e}') + return False + except Exception as e: # noqa: BLE001 - keep notes queued on any failure + print(f'Google Docs upload error: {e!r}') + return False + return True + + def _upload(self, note_events): + submits = [e for e in note_events if e.action == events.SUBMIT and e.text.strip()] + if not submits: + self._apply_structural(note_events) + return + + client = self.get_client() + doc_id = self.month_doc_id(note_datetime(submits[0])) + document = client.get_document(doc_id) + insert_index = max(1, doc_end_index(document) - 1) + if self.current_day is None: + self.current_day = last_day_heading(document) + + parts = [] + paragraphs = [] # (start_offset, end_offset, kind) + pos = 0 + + def emit(text, kind): + nonlocal pos + start = pos + parts.append(text) + pos += len(text) + paragraphs.append((start, pos, kind)) + + for note_event in note_events: + action = note_event.action + if action == events.INDENT: + self.indent_level = clip(self.indent_level + 1, 0, self.last_indent_level + 1) + elif action == events.UNINDENT: + self.indent_level = clip(self.indent_level - 1, 0, self.last_indent_level + 1) + elif action == events.CLEAR_EMPTY: + self.indent_level = 0 + elif action == events.ENTER_EMPTY: + self.indent_level = clip(self.indent_level - 1, 0, self.last_indent_level + 1) + elif action == events.END_SESSION: + self.end_session() + elif action == events.SUBMIT: + text = note_event.text.strip() + if not text: + continue + dt = note_datetime(note_event) + day = day_title(dt) + if day != self.current_day: + emit(day + '\n', 'day') + self.current_day = day + self.session_started = False + if not self.session_started: + emit(session_title(dt) + '\n', 'session') + self.session_started = True + emit('\t' * self.indent_level + text + '\n', 'note') + self.last_indent_level = self.indent_level + + if not paragraphs: + return + requests = self._build_requests(insert_index, ''.join(parts), paragraphs) + client.batch_update(doc_id, requests) + + def _build_requests(self, insert_index, blob, paragraphs): + """One insert of the whole blob, then paragraph styles and bullets. + + Style/bullet requests are ordered high-index-to-low so that bullet creation + (which consumes leading tabs and shifts following indices) never invalidates + an earlier request's range within the same batch. + """ + requests = [{'insertText': { + 'location': {'index': insert_index}, 'text': blob}}] + styling = [] # (start_index, request) + i = 0 + n = len(paragraphs) + while i < n: + start, end, kind = paragraphs[i] + if kind in ('day', 'session'): + style = DAY_STYLE if kind == 'day' else SESSION_STYLE + styling.append((insert_index + start, {'updateParagraphStyle': { + 'range': {'startIndex': insert_index + start, 'endIndex': insert_index + end}, + 'paragraphStyle': {'namedStyleType': style}, + 'fields': 'namedStyleType'}})) + i += 1 + else: # a maximal run of note paragraphs becomes one bulleted list + group_start = start + j = i + while j < n and paragraphs[j][2] == 'note': + j += 1 + group_end = paragraphs[j - 1][1] + styling.append((insert_index + group_start, {'createParagraphBullets': { + 'range': {'startIndex': insert_index + group_start, + 'endIndex': insert_index + group_end}, + 'bulletPreset': BULLET_PRESET}})) + i = j + styling.sort(key=lambda item: item[0], reverse=True) + requests.extend(request for _, request in styling) + return requests + + def _apply_structural(self, note_events): + """Updates indent/session state for a batch that has no writable notes.""" + for note_event in note_events: + action = note_event.action + if action == events.INDENT: + self.indent_level = clip(self.indent_level + 1, 0, self.last_indent_level + 1) + elif action == events.UNINDENT: + self.indent_level = clip(self.indent_level - 1, 0, self.last_indent_level + 1) + elif action == events.CLEAR_EMPTY: + self.indent_level = 0 + elif action == events.ENTER_EMPTY: + self.indent_level = clip(self.indent_level - 1, 0, self.last_indent_level + 1) + elif action == events.END_SESSION: + self.end_session() + + def handle_inactivity(self): + self.end_session() + + def handle_disconnect(self): + self.end_session() + + def end_session(self): + self.session_started = False + self.indent_level = 0 + self.last_indent_level = -1 diff --git a/gonotego/uploader/googledocs/test_googledocs_api.py b/gonotego/uploader/googledocs/test_googledocs_api.py new file mode 100644 index 00000000..73b80140 --- /dev/null +++ b/gonotego/uploader/googledocs/test_googledocs_api.py @@ -0,0 +1,149 @@ +import json + +import pytest + +from gonotego.uploader.googledocs import googledocs_api as api + + +class FakeResponse: + + def __init__(self, status_code=200, json_data=None, text=''): + self.status_code = status_code + self._json = json_data if json_data is not None else {} + self.text = text + self.content = b'x' if (json_data is not None or text) else b'' + + def json(self): + return self._json + + +class FakeSession: + + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def request(self, method, url, **kwargs): + self.calls.append({'method': method, 'url': url, **kwargs}) + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + + +def make_client(responses): + session = FakeSession(responses) + return api.GoogleDocsClient(session=session), session + + +def test_find_doc_builds_query_and_returns_id(): + client, session = make_client([FakeResponse(json_data={'files': [{'id': 'doc1'}]})]) + assert client.find_doc('September 2026') == 'doc1' + call = session.calls[0] + assert call['method'] == 'GET' and call['url'] == api.DRIVE_FILES_URL + q = call['params']['q'] + assert "name = 'September 2026'" in q + assert f"mimeType = '{api.DOC_MIME}'" in q + assert 'trashed = false' in q + + +def test_find_doc_returns_none_when_absent(): + client, _ = make_client([FakeResponse(json_data={'files': []})]) + assert client.find_doc('Nope') is None + + +def test_find_doc_escapes_apostrophe(): + client, session = make_client([FakeResponse(json_data={'files': []})]) + client.find_doc("Andrea's Notes") + assert "name = 'Andrea\\'s Notes'" in session.calls[0]['params']['q'] + + +def test_create_doc_without_share(): + client, session = make_client([FakeResponse(json_data={'id': 'newdoc'})]) + assert client.create_doc('September 2026') == 'newdoc' + call = session.calls[0] + assert call['method'] == 'POST' and call['url'] == api.DRIVE_FILES_URL + assert call['json'] == {'name': 'September 2026', 'mimeType': api.DOC_MIME} + + +def test_create_doc_with_folder_and_share(): + client, session = make_client([ + FakeResponse(json_data={'id': 'newdoc'}), # create + FakeResponse(json_data={'id': 'perm1'}), # share + ]) + assert client.create_doc('Sept', folder_id='folder1', share_email='a@b.com') == 'newdoc' + create, share = session.calls + assert create['json']['parents'] == ['folder1'] + assert share['url'] == f'{api.DRIVE_FILES_URL}/newdoc/permissions' + assert share['json'] == {'type': 'user', 'role': 'writer', 'emailAddress': 'a@b.com'} + assert share['params']['sendNotificationEmail'] == 'false' + + +def test_get_or_create_uses_existing(): + client, session = make_client([FakeResponse(json_data={'files': [{'id': 'existing'}]})]) + assert client.get_or_create_month_doc('Sept', share_email='a@b.com') == 'existing' + assert len(session.calls) == 1 # only the find; no create, no share + + +def test_get_or_create_creates_when_missing(): + client, session = make_client([ + FakeResponse(json_data={'files': []}), # find -> none + FakeResponse(json_data={'id': 'created'}), # create + FakeResponse(json_data={'id': 'perm'}), # share + ]) + assert client.get_or_create_month_doc('Sept', share_email='a@b.com') == 'created' + assert [c['method'] for c in session.calls] == ['GET', 'POST', 'POST'] + + +def test_batch_update_posts_requests(): + client, session = make_client([FakeResponse(json_data={'documentId': 'd'})]) + reqs = [{'insertText': {'location': {'index': 1}, 'text': 'hi'}}] + client.batch_update('doc1', reqs) + call = session.calls[0] + assert call['url'] == f'{api.DOCS_URL}/doc1:batchUpdate' + assert call['json'] == {'requests': reqs} + + +def test_batch_update_noop_on_empty(): + client, session = make_client([]) + assert client.batch_update('doc1', []) == {} + assert session.calls == [] + + +def test_error_raised_on_non_2xx(): + client, _ = make_client([FakeResponse(status_code=403, text='forbidden')]) + with pytest.raises(api.GoogleDocsError, match='403'): + client.get_document('doc1') + + +def test_load_credentials_routes_service_account(tmp_path, monkeypatch): + from google.oauth2 import service_account + monkeypatch.setattr( + service_account.Credentials, 'from_service_account_info', + classmethod(lambda cls, info, scopes: ('service_account', info, scopes))) + path = tmp_path / 'sa.json' + path.write_text(json.dumps({'type': 'service_account', 'client_email': 'x@y.com'})) + kind, info, scopes = api.load_credentials(str(path)) + assert kind == 'service_account' + assert info['client_email'] == 'x@y.com' + assert scopes == api.SCOPES + + +def test_load_credentials_routes_authorized_user(tmp_path, monkeypatch): + from google.oauth2 import credentials + monkeypatch.setattr( + credentials.Credentials, 'from_authorized_user_info', + classmethod(lambda cls, info, scopes: ('authorized_user', info, scopes))) + path = tmp_path / 'token.json' + path.write_text(json.dumps({'refresh_token': 'r', 'client_id': 'c', 'client_secret': 's'})) + kind, info, scopes = api.load_credentials(str(path)) + assert kind == 'authorized_user' + assert info['refresh_token'] == 'r' + assert scopes == api.SCOPES + + +def test_default_credentials_path_prefers_env(monkeypatch): + monkeypatch.setenv('GOOGLE_APPLICATION_CREDENTIALS', '/tmp/creds.json') + assert api.default_credentials_path() == '/tmp/creds.json' + monkeypatch.delenv('GOOGLE_APPLICATION_CREDENTIALS', raising=False) + assert api.default_credentials_path() == api.DEFAULT_CREDENTIALS_PATH diff --git a/gonotego/uploader/googledocs/test_googledocs_uploader.py b/gonotego/uploader/googledocs/test_googledocs_uploader.py new file mode 100644 index 00000000..c1d7758b --- /dev/null +++ b/gonotego/uploader/googledocs/test_googledocs_uploader.py @@ -0,0 +1,237 @@ +from datetime import datetime + +import pytest + +from gonotego.common import events +from gonotego.uploader.googledocs import googledocs_api +from gonotego.uploader.googledocs import googledocs_uploader as gd + + +@pytest.fixture(autouse=True) +def stub_settings(monkeypatch): + """Keep the uploader off Redis: settings come from this dict (default None).""" + store = {} + monkeypatch.setattr(gd.settings, 'get', lambda key, default=None: store.get(key, default)) + return store + + +class FakeClient: + + def __init__(self): + self.docs = {} # title -> doc_id + self.created = [] # (title, folder_id, share_email) + self.doc_state = {} # doc_id -> {'end_index', 'last_day'} + self.batches = [] # (doc_id, requests) + self.get_or_create_calls = 0 + self.fail = False + + def get_or_create_month_doc(self, name, folder_id=None, share_email=None): + self.get_or_create_calls += 1 + if name not in self.docs: + doc_id = f'doc:{name}' + self.docs[name] = doc_id + self.created.append((name, folder_id, share_email)) + self.doc_state[doc_id] = {'end_index': 2, 'last_day': None} + return self.docs[name] + + def set_existing_day(self, doc_id, day_text): + self.doc_state.setdefault(doc_id, {'end_index': 2, 'last_day': None}) + self.doc_state[doc_id]['last_day'] = day_text + + def get_document(self, doc_id): + state = self.doc_state.setdefault(doc_id, {'end_index': 2, 'last_day': None}) + content = [{'endIndex': 1}] + if state['last_day']: + content.append({ + 'endIndex': 1 + len(state['last_day']) + 1, + 'paragraph': { + 'paragraphStyle': {'namedStyleType': gd.DAY_STYLE}, + 'elements': [{'textRun': {'content': state['last_day'] + '\n'}}]}}) + content.append({'endIndex': state['end_index'], + 'paragraph': {'elements': [{'textRun': {'content': ''}}]}}) + return {'body': {'content': content}} + + def batch_update(self, doc_id, requests): + if self.fail: + raise googledocs_api.GoogleDocsError('boom') + self.batches.append((doc_id, requests)) + inserted = sum(len(r['insertText']['text']) for r in requests if 'insertText' in r) + self.doc_state[doc_id]['end_index'] += inserted + return {} + + +def note(action, text='', timestamp=None): + ts = timestamp if timestamp is not None else datetime(2026, 9, 8, 6, 30).timestamp() + return events.NoteEvent(text=text, action=action, audio_filepath='', timestamp=ts) + + +def insert_text(requests): + return requests[0]['insertText']['text'] + + +def request_kinds(requests): + kinds = [] + for r in requests: + if 'insertText' in r: + kinds.append('insert') + elif 'createParagraphBullets' in r: + kinds.append('bullets') + elif 'updateParagraphStyle' in r: + kinds.append(r['updateParagraphStyle']['paragraphStyle']['namedStyleType']) + return kinds + + +def test_single_session_layout(): + client = FakeClient() + up = gd.Uploader(client=client) + ts = datetime(2026, 9, 8, 6, 30).timestamp() + ok = up.upload([note(events.SUBMIT, 'first', ts), note(events.SUBMIT, 'second', ts)]) + + assert ok is True + assert client.created == [('September 2026', None, None)] + doc_id, requests = client.batches[0] + assert doc_id == 'doc:September 2026' + day = gd.day_title(datetime(2026, 9, 8, 6, 30)) + session = gd.session_title(datetime(2026, 9, 8, 6, 30)) + assert insert_text(requests) == f'{day}\n{session}\nfirst\nsecond\n' + # insert first, then styling high-index-to-low: notes bullets, session H2, day H1. + assert request_kinds(requests) == ['insert', 'bullets', gd.SESSION_STYLE, gd.DAY_STYLE] + + +def test_session_and_day_headings_get_styles_over_correct_ranges(): + client = FakeClient() + up = gd.Uploader(client=client) + up.upload([note(events.SUBMIT, 'hello')]) + _, requests = client.batches[0] + day = gd.day_title(datetime(2026, 9, 8, 6, 30)) + session = gd.session_title(datetime(2026, 9, 8, 6, 30)) + insert_index = 1 # end_index(2) - 1 + by_type = {request_kinds([r])[0]: r for r in requests[1:]} + day_range = by_type[gd.DAY_STYLE]['updateParagraphStyle']['range'] + assert day_range == {'startIndex': insert_index, 'endIndex': insert_index + len(day) + 1} + session_start = insert_index + len(day) + 1 + session_range = by_type[gd.SESSION_STYLE]['updateParagraphStyle']['range'] + assert session_range == {'startIndex': session_start, + 'endIndex': session_start + len(session) + 1} + bullets_range = by_type['bullets']['createParagraphBullets']['range'] + assert bullets_range['startIndex'] == session_range['endIndex'] + + +def test_indentation_uses_tabs_and_one_bullet_group(): + client = FakeClient() + up = gd.Uploader(client=client) + up.upload([ + note(events.SUBMIT, 'a'), + note(events.INDENT), + note(events.SUBMIT, 'a1'), + note(events.INDENT), + note(events.SUBMIT, 'a1x'), + note(events.UNINDENT), + note(events.SUBMIT, 'a2'), + ]) + _, requests = client.batches[0] + text = insert_text(requests) + assert '\na\n' in text + assert '\n\ta1\n' in text + assert '\n\t\ta1x\n' in text + assert '\n\ta2\n' in text + # A single contiguous run of notes -> exactly one bullets request. + assert request_kinds(requests).count('bullets') == 1 + + +def test_new_session_same_day_no_duplicate_day_heading(): + client = FakeClient() + up = gd.Uploader(client=client) + t1 = datetime(2026, 9, 8, 6, 30).timestamp() + t2 = datetime(2026, 9, 8, 7, 14).timestamp() + up.upload([note(events.SUBMIT, 'one', t1), note(events.END_SESSION, timestamp=t1), + note(events.SUBMIT, 'two', t2)]) + _, requests = client.batches[0] + kinds = request_kinds(requests) + assert kinds.count(gd.DAY_STYLE) == 1 # one day heading + assert kinds.count(gd.SESSION_STYLE) == 2 # two session timestamps + text = insert_text(requests) + assert gd.session_title(datetime(2026, 9, 8, 6, 30)) in text + assert gd.session_title(datetime(2026, 9, 8, 7, 14)) in text + + +def test_day_rollover_adds_new_day_heading(): + client = FakeClient() + up = gd.Uploader(client=client) + t1 = datetime(2026, 9, 8, 23, 55).timestamp() + t2 = datetime(2026, 9, 9, 0, 5).timestamp() + up.upload([note(events.SUBMIT, 'late', t1), note(events.SUBMIT, 'early', t2)]) + _, requests = client.batches[0] + assert request_kinds(requests).count(gd.DAY_STYLE) == 2 + + +def test_existing_day_heading_in_doc_is_not_repeated(): + client = FakeClient() + up = gd.Uploader(client=client) + today = gd.day_title(datetime(2026, 9, 8, 6, 30)) + # Pre-create the month doc and mark today's heading already present. + doc_id = client.get_or_create_month_doc('September 2026') + client.set_existing_day(doc_id, today) + up.upload([note(events.SUBMIT, 'x')]) + _, requests = client.batches[0] + assert gd.DAY_STYLE not in request_kinds(requests) # no new day heading + assert gd.SESSION_STYLE in request_kinds(requests) # but a session heading + + +def test_month_doc_cached_across_uploads(): + client = FakeClient() + up = gd.Uploader(client=client) + up.upload([note(events.SUBMIT, 'one')]) + up.upload([note(events.SUBMIT, 'two')]) + assert client.get_or_create_calls == 1 # same month -> resolved once + assert len(client.batches) == 2 + + +def test_folder_and_share_settings_passed(stub_settings): + stub_settings['GOOGLE_DOCS_FOLDER_ID'] = 'folderX' + stub_settings['GOOGLE_DOCS_SHARE_EMAIL'] = 'andrea@example.com' + client = FakeClient() + gd.Uploader(client=client).upload([note(events.SUBMIT, 'x')]) + assert client.created == [('September 2026', 'folderX', 'andrea@example.com')] + + +def test_placeholder_settings_treated_as_unset(stub_settings): + stub_settings['GOOGLE_DOCS_SHARE_EMAIL'] = '' + client = FakeClient() + gd.Uploader(client=client).upload([note(events.SUBMIT, 'x')]) + assert client.created == [('September 2026', None, None)] + + +def test_custom_title_format(stub_settings): + stub_settings['GOOGLE_DOCS_TITLE_FORMAT'] = 'GNG %Y-%m' + client = FakeClient() + gd.Uploader(client=client).upload([note(events.SUBMIT, 'x')]) + assert client.created[0][0] == 'GNG 2026-09' + + +def test_empty_submits_skipped(): + client = FakeClient() + up = gd.Uploader(client=client) + assert up.upload([note(events.SUBMIT, ' ')]) is True + assert client.batches == [] # nothing to write, no doc touched + + +def test_upload_returns_false_on_api_error(): + client = FakeClient() + client.fail = True + assert gd.Uploader(client=client).upload([note(events.SUBMIT, 'x')]) is False + + +def test_session_title_formatting(): + assert gd.session_title(datetime(2026, 9, 8, 6, 30)) == '6:30 AM' + assert gd.session_title(datetime(2026, 9, 8, 0, 5)) == '12:05 AM' + assert gd.session_title(datetime(2026, 9, 8, 13, 9)) == '1:09 PM' + assert gd.session_title(datetime(2026, 9, 8, 12, 0)) == '12:00 PM' + + +@pytest.mark.parametrize('day, expected_suffix', [ + (1, 'st'), (2, 'nd'), (3, 'rd'), (4, 'th'), (11, 'th'), (12, 'th'), + (13, 'th'), (21, 'st'), (22, 'nd'), (23, 'rd'), (31, 'st')]) +def test_day_title_ordinals(day, expected_suffix): + title = gd.day_title(datetime(2026, 1, day, 9, 0)) + assert title.endswith(f'{day}{expected_suffix}, 2026') diff --git a/gonotego/uploader/runner.py b/gonotego/uploader/runner.py index f1ceb722..0134a949 100644 --- a/gonotego/uploader/runner.py +++ b/gonotego/uploader/runner.py @@ -8,6 +8,7 @@ from gonotego.common import status from gonotego.settings import settings from gonotego.uploader.email import email_uploader +from gonotego.uploader.googledocs import googledocs_uploader from gonotego.uploader.ideaflow import ideaflow_uploader from gonotego.uploader.remnote import remnote_uploader from gonotego.uploader.roam import roam_api_uploader @@ -46,6 +47,8 @@ def is_unconfigured(note_taking_system): def make_uploader(note_taking_system): if note_taking_system == 'email': return email_uploader.Uploader() + elif note_taking_system == 'googledocs': + return googledocs_uploader.Uploader() elif note_taking_system == 'ideaflow': return ideaflow_uploader.Uploader() elif note_taking_system == 'remnote': diff --git a/pyproject.toml b/pyproject.toml index bbdf9a59..f992a85d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ 'pydantic==2.9.2', # Pin to specific working version 'pydantic_core==2.23.4', # Pin to specific working version 'python-dateutil<=2.9.0.post0', + 'google-auth<=2.35.0', 'redis<=7.0.1', 'requests<=2.32.5', # selenium 4.0 breaks with arm geckodriver. diff --git a/scripts/authorize_googledocs.py b/scripts/authorize_googledocs.py new file mode 100644 index 00000000..2091dabc --- /dev/null +++ b/scripts/authorize_googledocs.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""One-time authorization for the Google Docs uploader. + +Run this on a machine with a web browser. Whoever signs in during the consent +screen is the Google user the notes will be written as, and the monthly docs +will live in that user's own Drive, owned by them. + +Setup: + 1. In Google Cloud, enable the Google Docs API and Google Drive API, and + create an OAuth 2.0 Client ID of type "Desktop app"; download its JSON. + 2. On the OAuth consent screen (External / Testing), add the Google account + that will sign in here as a Test user. + 3. pip install google-auth-oauthlib + +Usage: + python authorize_googledocs.py CLIENT_SECRET.json OUTPUT_TOKEN.json + +Then copy OUTPUT_TOKEN.json to the device and set GOOGLE_DOCS_CREDENTIALS to its +path (and NOTE_TAKING_SYSTEM=googledocs). +""" +import sys + +# Must match the scopes the uploader requests (see googledocs_api.SCOPES). +SCOPES = [ + 'https://www.googleapis.com/auth/documents', + 'https://www.googleapis.com/auth/drive.file', +] + + +def main(): + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + client_secret = sys.argv[1] + out = sys.argv[2] if len(sys.argv) > 2 else 'gdocs-token.json' + + from google_auth_oauthlib.flow import InstalledAppFlow + flow = InstalledAppFlow.from_client_secrets_file(client_secret, SCOPES) + # access_type=offline + prompt=consent guarantees a refresh_token comes back. + credentials = flow.run_local_server( + port=0, access_type='offline', prompt='consent') + with open(out, 'w') as f: + f.write(credentials.to_json()) + print(f'\nWrote {out}. Signed in as the account you chose in the browser.') + print('Copy it to the device and set GOOGLE_DOCS_CREDENTIALS to its path.') + + +if __name__ == '__main__': + main()