From fac8dc1fc7574c0f9afb3f8829aa12c8855e1e0c Mon Sep 17 00:00:00 2001 From: saminur Date: Thu, 15 Jan 2026 00:14:34 -0500 Subject: [PATCH 1/9] added the tab sepecific code in extension --- extension/writing-process/src/background.js | 4 +++- extension/writing-process/src/writing_common.js | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/extension/writing-process/src/background.js b/extension/writing-process/src/background.js index d5544d1a7..175120e7e 100644 --- a/extension/writing-process/src/background.js +++ b/extension/writing-process/src/background.js @@ -4,7 +4,7 @@ Background script. This works across all of Google Chrome. import { CONFIG } from "./service_worker_config.js"; import { googledocs_id_from_url } from './writing_common'; - +import { tab_id_from_url } from './writing_common'; import * as loEvent from 'lo_event/lo_event/lo_event.js'; import * as loEventDebug from 'lo_event/lo_event/debugLog.js'; import { websocketLogger } from 'lo_event/lo_event/websocketLogger.js'; @@ -203,6 +203,7 @@ chrome.webRequest.onBeforeRequest.addListener( versus GMT. */ event = { 'doc_id': googledocs_id_from_url(request.url), + 'tab_id': tab_id_from_url(request.url), 'url': request.url, 'bundles': JSON.parse(formdata.bundles), 'rev': formdata.rev, @@ -216,6 +217,7 @@ chrome.webRequest.onBeforeRequest.addListener( */ event = { 'doc_id': googledocs_id_from_url(request.url), + 'tab_id': tab_id_from_url(request.url), 'url': request.url, 'formdata': formdata, 'rev': formdata.rev, diff --git a/extension/writing-process/src/writing_common.js b/extension/writing-process/src/writing_common.js index 6242b3ab6..3f807ee3e 100644 --- a/extension/writing-process/src/writing_common.js +++ b/extension/writing-process/src/writing_common.js @@ -78,6 +78,21 @@ export function googledocs_id_from_url(url) { return null; } +export function tab_id_from_url(url) { + /* + Given a URL like: + https://docs.google.com/document/d//edit?tab=t.95yb7msfl8ul + extract the associated tab ID: + t.95yb7msfl8ul + Return null if not a valid URL or tab param. + */ + var match = url.match(/[?&]tab=([^&]+)/i); + if (match) { + return match[1]; + } + return null; +} + var writing_lasthash = ""; function unique_id() { /* From 372a589d4709d5e0b066c9f97d5a0c4df06f987f Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Thu, 15 Jan 2026 16:34:31 -0500 Subject: [PATCH 2/9] added tab_id to google doc extension --- VERSION | 2 +- extension/writing-process/src/background.js | 7 +++---- extension/writing-process/src/writing.js | 15 ++++++++++++++- extension/writing-process/src/writing_common.js | 2 +- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index fc5d63b11..976f02544 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.01.26T17.51.31.713Z.b83cda8e.berickson.20260126.blacklist.by.domain +0.1.0+2026.01.15T21.34.31.229Z.06c1a0bd.berickson.20260113.extension.tab.ids diff --git a/extension/writing-process/src/background.js b/extension/writing-process/src/background.js index 175120e7e..058232fb2 100644 --- a/extension/writing-process/src/background.js +++ b/extension/writing-process/src/background.js @@ -3,8 +3,7 @@ Background script. This works across all of Google Chrome. */ import { CONFIG } from "./service_worker_config.js"; -import { googledocs_id_from_url } from './writing_common'; -import { tab_id_from_url } from './writing_common'; +import { googledocs_id_from_url, googledocs_tab_id_from_url } from './writing_common'; import * as loEvent from 'lo_event/lo_event/lo_event.js'; import * as loEventDebug from 'lo_event/lo_event/debugLog.js'; import { websocketLogger } from 'lo_event/lo_event/websocketLogger.js'; @@ -203,7 +202,7 @@ chrome.webRequest.onBeforeRequest.addListener( versus GMT. */ event = { 'doc_id': googledocs_id_from_url(request.url), - 'tab_id': tab_id_from_url(request.url), + 'tab_id': googledocs_tab_id_from_url(request.url), 'url': request.url, 'bundles': JSON.parse(formdata.bundles), 'rev': formdata.rev, @@ -217,7 +216,7 @@ chrome.webRequest.onBeforeRequest.addListener( */ event = { 'doc_id': googledocs_id_from_url(request.url), - 'tab_id': tab_id_from_url(request.url), + 'tab_id': googledocs_tab_id_from_url(request.url), 'url': request.url, 'formdata': formdata, 'rev': formdata.rev, diff --git a/extension/writing-process/src/writing.js b/extension/writing-process/src/writing.js index 1e63f9df1..084affc5e 100644 --- a/extension/writing-process/src/writing.js +++ b/extension/writing-process/src/writing.js @@ -5,7 +5,7 @@ /* For debugging purposes: we know the extension is active */ // document.body.style.border = "1px solid blue"; -import { googledocs_id_from_url, treeget } from './writing_common'; +import { googledocs_id_from_url, googledocs_tab_id_from_url, treeget } from './writing_common'; /* General Utility Functions */ @@ -49,6 +49,7 @@ function log_event(event_type, event) { "type": "http://schema.learning-observer.org/writing-observer/", "title": google_docs_title(), "id": doc_id(), + "tab_id": tab_id(), "url": window.location.href, }; @@ -78,6 +79,18 @@ function doc_id() { } } +function tab_id() { + /* + Extract the Google document's current Tab ID from the window + */ + try { + return googledocs_tab_id_from_url(window.location.href); + } catch(error) { + log_error("Couldn't read document's tab id"); + return null; + } +} + function this_is_a_google_doc() { /* diff --git a/extension/writing-process/src/writing_common.js b/extension/writing-process/src/writing_common.js index 3f807ee3e..2b14350fd 100644 --- a/extension/writing-process/src/writing_common.js +++ b/extension/writing-process/src/writing_common.js @@ -78,7 +78,7 @@ export function googledocs_id_from_url(url) { return null; } -export function tab_id_from_url(url) { +export function googledocs_tab_id_from_url(url) { /* Given a URL like: https://docs.google.com/document/d//edit?tab=t.95yb7msfl8ul From e314925f823e7f7b162c0e01f1e3f9d1dfbae5af Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Fri, 16 Jan 2026 09:07:13 -0500 Subject: [PATCH 3/9] updated regex for tab ids --- VERSION | 2 +- extension/writing-process/src/writing_common.js | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index 976f02544..2651a9d25 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.01.15T21.34.31.229Z.06c1a0bd.berickson.20260113.extension.tab.ids +0.1.0+2026.01.16T14.07.13.703Z.c43d1da8.berickson.20260113.extension.tab.ids diff --git a/extension/writing-process/src/writing_common.js b/extension/writing-process/src/writing_common.js index 2b14350fd..12fc81072 100644 --- a/extension/writing-process/src/writing_common.js +++ b/extension/writing-process/src/writing_common.js @@ -82,11 +82,20 @@ export function googledocs_tab_id_from_url(url) { /* Given a URL like: https://docs.google.com/document/d//edit?tab=t.95yb7msfl8ul + https://docs.google.com/document/d//edit?tab=t.95yb7msfl8ul#heading=h.abc123 extract the associated tab ID: t.95yb7msfl8ul - Return null if not a valid URL or tab param. + Return null if not a valid Google Docs URL or tab param. + + Regex explanation: + 1. `/.*:\/\/` - match any protocol (http/https) followed by :// + 2. `docs\.google\.com\/document\/` - match google docs domain + 3. `.*` - match any characters until we find the tab param + 4. `[?&]tab=` - match tab parameter in query string + 5. `([^&#]+)` - capture tab value, stopping at & (next param) or # (hash fragment) + 6. `/i` - case insensitive */ - var match = url.match(/[?&]tab=([^&]+)/i); + var match = url.match(/.*:\/\/docs\.google\.com\/document\/.*[?&]tab=([^&#]+)/i); if (match) { return match[1]; } From ed6dc89868910687bf5288fac4d7d2f1ef12b26a Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Tue, 27 Jan 2026 15:19:39 -0500 Subject: [PATCH 4/9] added nm command to reconstruct --- VERSION | 2 +- modules/writing_observer/VERSION | 2 +- .../writing_observer/reconstruct_doc.py | 58 +++++++++++++++++-- 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/VERSION b/VERSION index 2651a9d25..6faacdd13 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.01.16T14.07.13.703Z.c43d1da8.berickson.20260113.extension.tab.ids +0.1.0+2026.01.27T20.19.39.707Z.8a7eda49.berickson.20260113.extension.tab.ids diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index 32e666dbe..6faacdd13 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.01.13T18.33.25.519Z.0984e08f.berickson.20260113.abstract.time.on.task.reducers +0.1.0+2026.01.27T20.19.39.707Z.8a7eda49.berickson.20260113.extension.tab.ids diff --git a/modules/writing_observer/writing_observer/reconstruct_doc.py b/modules/writing_observer/writing_observer/reconstruct_doc.py index d060c083d..cef0ea145 100644 --- a/modules/writing_observer/writing_observer/reconstruct_doc.py +++ b/modules/writing_observer/writing_observer/reconstruct_doc.py @@ -40,6 +40,7 @@ def __new__(cls): new_object._text = "" new_object._position = 0 new_object._edit_metadata = {} + new_object._tabs = {} new_object.fix_validity() return new_object @@ -100,6 +101,14 @@ def from_json(json_rep): new_object._text = json_rep.get('text', '') new_object._position = json_rep.get('position', 0) new_object._edit_metadata = json_rep.get('edit_metadata', {}) + + if 'tabs' in json_rep and json_rep['tabs']: + new_object._tabs = {} + for tab_id, tab_data in json_rep['tabs'].items(): + new_object._tabs[tab_id] = google_text.from_json(tab_data) + else: + new_object._tabs = {} + new_object.fix_validity() return new_object @@ -155,11 +164,14 @@ def json(self): ''' This serializes to JSON. ''' - return { + result = { 'text': self._text, 'position': self._position, 'edit_metadata': self._edit_metadata } + if self._tabs: + result['tabs'] = {tab_id: tab.json for tab_id, tab in self._tabs.items()} + return result def get_parsed_text(self): @@ -169,6 +181,15 @@ def get_parsed_text(self): return self._text.replace(PLACEHOLDER, "") +def dispatch_command(doc, cmd): + if cmd['ty'] in dispatch: + doc = dispatch[cmd['ty']](doc, **cmd) + else: + print("Unrecogized Google Docs command: " + repr(cmd['ty'])) + # TODO: Log issue and fix it! + return doc + + def command_list(doc, commands): ''' This will process a list of commands. It is helpful either when @@ -176,11 +197,7 @@ def command_list(doc, commands): new `save` requests. ''' for item in commands: - if item['ty'] in dispatch: - doc = dispatch[item['ty']](doc, **item) - else: - print("Unrecogized Google Docs command: " + repr(item['ty'])) - # TODO: Log issue and fix it! + doc = dispatch_command(doc, item) return doc @@ -301,6 +318,34 @@ def null(doc, **kwargs): return doc +def nm(doc, nmc, nmr, **kwargs): + ''' + Handle named commands for tabs (sub-documents). + + * `nmc` is the command to execute + * `nmr` is the name/reference list, which contains the target tab ID + ''' + # Find the target tab from the nmr list + target_tab = None + for item in reversed(nmr or []): + if isinstance(item, str) and item.startswith("t."): + target_tab = item + break + + if target_tab is None: + # No tab specified, apply to main document + doc = dispatch_command(doc, nmc) + else: + # Ensure the tab exists + if target_tab not in doc._tabs: + doc._tabs[target_tab] = google_text() + + # Apply the command to the sub-document + doc._tabs[target_tab] = dispatch_command(doc._tabs[target_tab], nmc) + + return doc + + # This dictionary maps the `ty` parameter to the function which # handles data of that type. @@ -328,6 +373,7 @@ def null(doc, **kwargs): 'mefd': null, # suggestion 'mlti': multi, 'msfd': null, # suggestion + 'nm': nm, # named command for tabs 'null': null, 'ord': null, 'ras': null, # suggestion. Autospell? From e1d6f4fe9accf1d912eb0fa4c0083170c9ccc04b Mon Sep 17 00:00:00 2001 From: saminur Date: Fri, 30 Jan 2026 17:02:29 -0500 Subject: [PATCH 5/9] code for tab_list reducer --- .../writing_observer/module.py | 8 ++- .../writing_observer/writing_analysis.py | 55 ++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py index 009262cef..4d155f41e 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -286,6 +286,12 @@ 'function': writing_observer.writing_analysis.document_list, 'default': {'docs': []} }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_scope, + 'function': writing_observer.writing_analysis.tab_list_reducer, + 'default': {'tabs': {}} + }, { 'context': "org.mitros.writing_analytics", 'scope': writing_observer.writing_analysis.student_scope, @@ -360,4 +366,4 @@ 'name': 'NLP Options', 'suburl': 'nlp-options', 'static_json': INDICATOR_JSONS -}] +}] \ No newline at end of file diff --git a/modules/writing_observer/writing_observer/writing_analysis.py b/modules/writing_observer/writing_observer/writing_analysis.py index 4d1a836e2..ba3a21ece 100644 --- a/modules/writing_observer/writing_observer/writing_analysis.py +++ b/modules/writing_observer/writing_observer/writing_analysis.py @@ -262,6 +262,59 @@ async def document_list(event, internal_state): return False, False +def _extract_tab_title(client): + mouseclick = client.get("mouseclick", {}) + class_name = mouseclick.get("target.className", "") or "" + if "chapter-label-content" in class_name: + title = mouseclick.get("target.innerText") + if title: + return title + return None + + +def _extract_tab_id(event): + client = event.get("client", {}) or {} + tab_id = client.get("tab_id") or event.get("tab_id") + if tab_id: + return tab_id + url = client.get("url") or client.get("object", {}).get("url") or event.get("url") + if not url: + return None + match = re.search(r"tab=([^&#]+)", url) + return match.group(1) if match else None + + +@kvs_pipeline(scope=gdoc_scope, null_state={"tabs": {}}) +async def tab_list_reducer(event, internal_state): + ''' + Track per-document tab metadata (tab_id, title, last_accessed) per student. + ''' + if internal_state is None: + internal_state = {"tabs": {}} + + client = event.get("client", {}) or {} + tab_id = _extract_tab_id(event) + if not tab_id: + return False, False + + tabs = internal_state.get("tabs") or {} + entry = tabs.get(tab_id, {}) + + title = _extract_tab_title(client) or entry.get("title") + server_time = event.get("server", {}).get("time") + if server_time is None: + server_time = client.get("timestamp") or client.get("metadata", {}).get("ts") + + tabs[tab_id] = { + "tab_id": tab_id, + "title": title, + "last_accessed": server_time, + } + + internal_state["tabs"] = tabs + return internal_state, internal_state + + @kvs_pipeline(scope=student_scope) async def last_document(event, internal_state): ''' @@ -352,4 +405,4 @@ def document_link_to_doc_id(event): event['doc_id'] = doc_id return event -learning_observer.adapters.adapter.add_common_migrator(document_link_to_doc_id, __file__) +learning_observer.adapters.adapter.add_common_migrator(document_link_to_doc_id, __file__) \ No newline at end of file From dc98ec5333feb9badabaa5c4c80121ee8d39fe26 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 2 Feb 2026 08:46:14 -0500 Subject: [PATCH 6/9] added tab scoped time on task --- VERSION | 2 +- modules/writing_observer/VERSION | 2 +- modules/writing_observer/writing_observer/module.py | 8 +++++++- .../writing_observer/writing_observer/writing_analysis.py | 7 ++++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index 6faacdd13..efe2f5a8d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.01.27T20.19.39.707Z.8a7eda49.berickson.20260113.extension.tab.ids +0.1.0+2026.02.02T13.46.14.135Z.6ff5f938.berickson.20260113.extension.tab.ids diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index 6faacdd13..efe2f5a8d 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.01.27T20.19.39.707Z.8a7eda49.berickson.20260113.extension.tab.ids +0.1.0+2026.02.02T13.46.14.135Z.6ff5f938.berickson.20260113.extension.tab.ids diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py index 4d155f41e..6fca2b494 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -261,7 +261,13 @@ { 'context': "org.mitros.writing_analytics", 'scope': writing_observer.writing_analysis.gdoc_scope, - 'function': writing_observer.writing_analysis.time_on_task, + 'function': writing_observer.writing_analysis.gdoc_scope_time_on_task, + 'default': {'saved_ts': 0} + }, + { + 'context': "org.mitros.writing_analytics", + 'scope': writing_observer.writing_analysis.gdoc_tab_scope, + 'function': writing_observer.writing_analysis.gdoc_tab_scope_time_on_task, 'default': {'saved_ts': 0} }, { diff --git a/modules/writing_observer/writing_observer/writing_analysis.py b/modules/writing_observer/writing_observer/writing_analysis.py index ba3a21ece..d114b1a66 100644 --- a/modules/writing_observer/writing_observer/writing_analysis.py +++ b/modules/writing_observer/writing_observer/writing_analysis.py @@ -64,6 +64,8 @@ else: gdoc_scope = student_scope # HACK for backwards-compatibility +gdoc_tab_scope = Scope([KeyField.STUDENT, EventField('doc_id'), EventField('tab_id')]) + @learning_observer.communication_protocol.integration.publish_function('writing_observer.activity_map') def determine_activity_status(last_ts): @@ -71,7 +73,6 @@ def determine_activity_status(last_ts): return {'status': status} -@kvs_pipeline(scope=gdoc_scope) async def time_on_task(event, internal_state): ''' This adds up time intervals between successive timestamps. If the interval @@ -87,6 +88,10 @@ async def time_on_task(event, internal_state): return internal_state, internal_state +gdoc_scope_time_on_task = kvs_pipeline(scope=gdoc_scope)(time_on_task) +gdoc_tab_scope_time_on_task = kvs_pipeline(scope=gdoc_tab_scope)(time_on_task) + + @kvs_pipeline(scope=gdoc_scope) async def binned_time_on_task(event, internal_state): ''' From 9c4ef3bcb2a702c8915a290188a4d7a3cc39f2e1 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 2 Feb 2026 10:25:39 -0500 Subject: [PATCH 7/9] strengthened tab list reducer --- VERSION | 2 +- modules/writing_observer/VERSION | 2 +- .../writing_observer/module.py | 2 +- .../writing_observer/reconstruct_doc.py | 3 + .../writing_observer/writing_analysis.py | 159 ++++++++++++++---- 5 files changed, 135 insertions(+), 33 deletions(-) diff --git a/VERSION b/VERSION index efe2f5a8d..11db90f73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.02.02T13.46.14.135Z.6ff5f938.berickson.20260113.extension.tab.ids +0.1.0+2026.02.02T15.25.39.358Z.dc98ec53.berickson.20260113.extension.tab.ids diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index efe2f5a8d..11db90f73 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.02.02T13.46.14.135Z.6ff5f938.berickson.20260113.extension.tab.ids +0.1.0+2026.02.02T15.25.39.358Z.dc98ec53.berickson.20260113.extension.tab.ids diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py index 6fca2b494..832e3447d 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -295,7 +295,7 @@ { 'context': "org.mitros.writing_analytics", 'scope': writing_observer.writing_analysis.gdoc_scope, - 'function': writing_observer.writing_analysis.tab_list_reducer, + 'function': writing_observer.writing_analysis.tab_list, 'default': {'tabs': {}} }, { diff --git a/modules/writing_observer/writing_observer/reconstruct_doc.py b/modules/writing_observer/writing_observer/reconstruct_doc.py index cef0ea145..39cb57328 100644 --- a/modules/writing_observer/writing_observer/reconstruct_doc.py +++ b/modules/writing_observer/writing_observer/reconstruct_doc.py @@ -357,6 +357,7 @@ def nm(doc, nmc, nmr, **kwargs): # these can't be handled like plain 'is' or 'ds' because the include different fields # (e.g., 'sugid', presumably, suggestion id.) dispatch = { + 'ac': null, # new tab title 'ae': null, 'ase': null, # suggestion 'ast': null, # suggestion. Image? @@ -371,6 +372,7 @@ def nm(doc, nmc, nmr, **kwargs): 'is': insert, 'iss': null, # suggested insertion 'mefd': null, # suggestion + 'mkch': null, # name of the first tab 'mlti': multi, 'msfd': null, # suggestion 'nm': nm, # named command for tabs @@ -390,6 +392,7 @@ def nm(doc, nmc, nmr, **kwargs): 'sl': null, 'ste': null, # suggestion 'sue': null, # suggestion + 'ucp': null, # updated tab title 'uefd': null, # suggestion 'use': null, # suggestion 'umv': null, diff --git a/modules/writing_observer/writing_observer/writing_analysis.py b/modules/writing_observer/writing_observer/writing_analysis.py index d114b1a66..6ef1fd4df 100644 --- a/modules/writing_observer/writing_observer/writing_analysis.py +++ b/modules/writing_observer/writing_observer/writing_analysis.py @@ -267,14 +267,104 @@ async def document_list(event, internal_state): return False, False -def _extract_tab_title(client): - mouseclick = client.get("mouseclick", {}) - class_name = mouseclick.get("target.className", "") or "" - if "chapter-label-content" in class_name: - title = mouseclick.get("target.innerText") - if title: - return title - return None +def _iter_commands_from_client(client): + """Yield command dicts from either bundles (google_docs_save) or history (document_history).""" + event_type = client.get("event") + + if event_type == "google_docs_save": + for bundle in client.get("bundles") or []: + for command in bundle.get("commands") or []: + if isinstance(command, dict): + yield command + + elif event_type == "document_history": + history = client.get("history") or {} + changelog = history.get("changelog") or [] + # Each changelog item is expected to be like: [, ...] + for item in changelog: + if isinstance(item, (list, tuple)) and item and isinstance(item[0], dict): + yield item[0] + + +def _iter_leaf_commands(client): + for cmd in _iter_commands_from_client(client): + if not isinstance(cmd, dict): + continue + + if cmd.get("ty") == "mlti": + for sub in cmd.get("mts") or []: + if isinstance(sub, dict): + yield sub + else: + yield cmd + + +def _get_event_time(event, client): + """Resolve the timestamp once per event, with fallback.""" + server_time = (event.get("server") or {}).get("time") + if server_time is not None: + return server_time + return client.get("timestamp") or (client.get("metadata") or {}).get("ts") + + +def extract_from_ucp(command): + if command.get("ty") != "ucp": + return None, None + d = command.get("d") + try: + return d[0], d[1][1][1] + except (TypeError, IndexError, KeyError): + return None, None + + +def extract_from_mkch(command): + if command.get("ty") != "mkch": + return None, None + + d = command.get("d") + try: + return 't.0', d[0][1] + except (TypeError, IndexError, KeyError, AttributeError): + return None, None + + +def extract_from_ac(command): + if command.get("ty") != "ac": + return None, None + + d = command.get("d") + try: + return d[0], d[1][1] + except (TypeError, IndexError, KeyError, AttributeError): + return None, None + + +TITLE_EXTRACTORS = { + "ucp": extract_from_ucp, + "mkch": extract_from_mkch, + "ac": extract_from_ac, +} + + +def _extract_all_tab_titles(client): + """ + Extract all (tab_id, title) pairs from leaf commands (including those inside mlti). + """ + event_type = client.get("event") + if event_type not in ("google_docs_save", "document_history"): + return [] + + out = [] + for cmd in _iter_leaf_commands(client): + ty = cmd.get("ty") + extractor = TITLE_EXTRACTORS.get(ty) + if not extractor: + continue + tab_id, title = extractor(cmd) + if tab_id is None: + continue + out.append((tab_id, title)) + return out def _extract_tab_id(event): @@ -290,31 +380,40 @@ def _extract_tab_id(event): @kvs_pipeline(scope=gdoc_scope, null_state={"tabs": {}}) -async def tab_list_reducer(event, internal_state): - ''' +async def tab_list(event, internal_state): + """ Track per-document tab metadata (tab_id, title, last_accessed) per student. - ''' - if internal_state is None: - internal_state = {"tabs": {}} - - client = event.get("client", {}) or {} - tab_id = _extract_tab_id(event) - if not tab_id: - return False, False + Rules: + - If client.tab_id exists AND is already in state: ONLY update last_accessed for that tab. + - Still add new tabs discovered in commands (and set last_accessed for those new tabs). + - For existing tabs discovered in commands: update title if present, but do NOT touch last_accessed + unless it's the active existing tab (handled first). + """ + internal_state = internal_state or {"tabs": {}} tabs = internal_state.get("tabs") or {} - entry = tabs.get(tab_id, {}) - - title = _extract_tab_title(client) or entry.get("title") - server_time = event.get("server", {}).get("time") - if server_time is None: - server_time = client.get("timestamp") or client.get("metadata", {}).get("ts") - - tabs[tab_id] = { - "tab_id": tab_id, - "title": title, - "last_accessed": server_time, - } + + client = event.get("client") or {} + server_time = _get_event_time(event, client) + + active_tab_id = _extract_tab_id(event) + + # 1) Only bump last_accessed for the active tab IF it already exists in state + if active_tab_id is not None and active_tab_id in tabs: + tabs[active_tab_id]["last_accessed"] = server_time + + # 2) Add/update titles for all extracted tabs + for tab_id, title in _extract_all_tab_titles(client): + if tab_id not in tabs: + # New tab: initialize and set last_accessed now + tabs[tab_id] = { + "title": title, + "last_accessed": server_time, + } + else: + # Existing tab: update title if we learned one; do not update last_accessed here + if title is not None: + tabs[tab_id]["title"] = title internal_state["tabs"] = tabs return internal_state, internal_state From c0f2a8f0aa4d234fa55e61e779c389a41abf92b4 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 2 Feb 2026 15:44:30 -0500 Subject: [PATCH 8/9] added back unnecessary line removals --- VERSION | 2 +- modules/writing_observer/VERSION | 2 +- modules/writing_observer/writing_observer/module.py | 2 +- modules/writing_observer/writing_observer/writing_analysis.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index 11db90f73..0363e31dc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.02.02T15.25.39.358Z.dc98ec53.berickson.20260113.extension.tab.ids +0.1.0+2026.02.02T20.44.30.851Z.9c4ef3bc.berickson.20260113.extension.tab.ids diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index 11db90f73..0363e31dc 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.02.02T15.25.39.358Z.dc98ec53.berickson.20260113.extension.tab.ids +0.1.0+2026.02.02T20.44.30.851Z.9c4ef3bc.berickson.20260113.extension.tab.ids diff --git a/modules/writing_observer/writing_observer/module.py b/modules/writing_observer/writing_observer/module.py index 832e3447d..b23df8323 100644 --- a/modules/writing_observer/writing_observer/module.py +++ b/modules/writing_observer/writing_observer/module.py @@ -372,4 +372,4 @@ 'name': 'NLP Options', 'suburl': 'nlp-options', 'static_json': INDICATOR_JSONS -}] \ No newline at end of file +}] diff --git a/modules/writing_observer/writing_observer/writing_analysis.py b/modules/writing_observer/writing_observer/writing_analysis.py index 6ef1fd4df..ae24bdb53 100644 --- a/modules/writing_observer/writing_observer/writing_analysis.py +++ b/modules/writing_observer/writing_observer/writing_analysis.py @@ -509,4 +509,4 @@ def document_link_to_doc_id(event): event['doc_id'] = doc_id return event -learning_observer.adapters.adapter.add_common_migrator(document_link_to_doc_id, __file__) \ No newline at end of file +learning_observer.adapters.adapter.add_common_migrator(document_link_to_doc_id, __file__) From c2b001254dbc354d0e946f84b11abb75eb78f166 Mon Sep 17 00:00:00 2001 From: Bradley Erickson Date: Mon, 2 Feb 2026 15:54:51 -0500 Subject: [PATCH 9/9] added back in tab_id to nested structure --- VERSION | 2 +- modules/writing_observer/VERSION | 2 +- modules/writing_observer/writing_observer/writing_analysis.py | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/VERSION b/VERSION index 0363e31dc..0d5696b3a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0+2026.02.02T20.44.30.851Z.9c4ef3bc.berickson.20260113.extension.tab.ids +0.1.0+2026.02.02T20.54.51.168Z.c0f2a8f0.berickson.20260113.extension.tab.ids diff --git a/modules/writing_observer/VERSION b/modules/writing_observer/VERSION index 0363e31dc..0d5696b3a 100644 --- a/modules/writing_observer/VERSION +++ b/modules/writing_observer/VERSION @@ -1 +1 @@ -0.1.0+2026.02.02T20.44.30.851Z.9c4ef3bc.berickson.20260113.extension.tab.ids +0.1.0+2026.02.02T20.54.51.168Z.c0f2a8f0.berickson.20260113.extension.tab.ids diff --git a/modules/writing_observer/writing_observer/writing_analysis.py b/modules/writing_observer/writing_observer/writing_analysis.py index ae24bdb53..85dc87425 100644 --- a/modules/writing_observer/writing_observer/writing_analysis.py +++ b/modules/writing_observer/writing_observer/writing_analysis.py @@ -407,6 +407,7 @@ async def tab_list(event, internal_state): if tab_id not in tabs: # New tab: initialize and set last_accessed now tabs[tab_id] = { + "tab_id": tab_id, "title": title, "last_accessed": server_time, }