mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-08 16:02:20 +02:00
refactor(email): split the email library into seven modules
`emailLibrary/index.js` goes from 11,375 lines to 6,187. What came out:
- `settingsPage.js` (879) — the Email Settings view, its form and controls,
away/auto-reply including the calendar-event sync, and the display
preferences. The inline-image preference lives here rather than with the
renderer because the settings form owns writing it.
- `unsubscribe.js` (1,224) — the bulk-unsubscribe review flow. One exported
entry point, its own localStorage keys, its own agent-tool-output listeners.
- `reader.js` (676) — opening an email as a docked tab or a floating window,
plus the AI summary panel both of them share.
- `menus.js` (855) — the reader More menu, the card kebab menu, the bulk
Actions menu and `_bulkAction`.
- `bodyRender.js` (871) — plain and threaded body rendering, inline MIME
images, quote folding.
- `attachments.js` (491) — attachment chips and the deferred load for messages
whose attachment list was not in the list response.
- `aiReply.js` (449) — AI-reply entry points, the per-message context draft,
the translate and remind submenus.
The extracted modules import back from `index.js`, so the graph has cycles.
That is safe for hoisted function declarations and unsafe for a value read
during evaluation, so nothing crosses a module boundary except functions:
`API_BASE` is re-declared per module, the way `emailInbox.js` and
`emailShared.js` already do it, and `_autoReplyRefreshSeq` moves into
`state.js` because the settings page and the unread-badge refresh both write
it and an imported binding is read-only. The module-graph test enters the
package at each module in turn, which is the order that would expose a
dead-zone read.
Two test helpers grew while doing this. `js_function_source` replaces three
marker-pair slices ("from this signature down to that comment") whose end
marker had moved into another module — the slice ran past the function and
kept passing against the wrong text. Its first implementation balanced braces
by walking characters and ended `_toggleCardPreview` 18 lines early, because
the apostrophe in `// that's a scroll, not a nav` opened a string that ate the
braces after it. It now keys on the invariant the file actually holds: a
top-level declaration starts at column 0, so its closing brace is the next
lone `}` at column 0.
This commit is contained in:
@@ -16,6 +16,7 @@ not about the package, so the concatenation order only has to be stable.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
_STATIC_JS = Path(__file__).resolve().parents[2] / "static" / "js"
|
||||
@@ -48,3 +49,35 @@ def email_library_source(include_wrapper: bool = False) -> str:
|
||||
return "\n".join(
|
||||
p.read_text(encoding="utf-8") for p in email_library_paths(include_wrapper)
|
||||
)
|
||||
|
||||
|
||||
def js_function_source(name: str, source: str | None = None) -> str:
|
||||
"""One top-level JS function, from its signature to its closing brace.
|
||||
|
||||
Two things this does not do, on purpose.
|
||||
|
||||
It does not slice between a signature and a marker further down ("from
|
||||
``_toggleCardPreview`` to the ``Wrap a probable signature`` comment"). That
|
||||
is what a split breaks: the marker ends up in another module, the slice runs
|
||||
past the end of the function without failing, and the assertions keep
|
||||
passing against the wrong text.
|
||||
|
||||
It does not balance braces by walking characters either. The obvious version
|
||||
of that walker treats the apostrophe in a ``// that's a scroll`` comment as
|
||||
an open quote and swallows every brace until the next one, which ends the
|
||||
function early — silently, again.
|
||||
|
||||
Instead it uses the invariant the file actually holds: a top-level
|
||||
declaration starts at column 0, so its closing brace is the next lone ``}``
|
||||
at column 0.
|
||||
"""
|
||||
text = email_library_source() if source is None else source
|
||||
signature = re.compile(
|
||||
r"^(?:export\s+)?(?:async\s+)?function\s+" + re.escape(name) + r"\s*\(",
|
||||
re.M,
|
||||
)
|
||||
match = signature.search(text)
|
||||
assert match, f"no top-level declaration of {name}"
|
||||
closing = re.compile(r"^\}", re.M).search(text, match.end())
|
||||
assert closing, f"unterminated function {name}"
|
||||
return text[match.start():closing.end()]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from pathlib import Path
|
||||
from tests.helpers.js_modules import email_library_source
|
||||
from tests.helpers.js_modules import email_library_source, js_function_source
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
@@ -9,20 +9,11 @@ _EMAIL_FIXTURE_HELPER = _REPO / "scripts" / "ody_eval_email_fixture.py"
|
||||
|
||||
|
||||
def _bulk_action_source() -> str:
|
||||
text = email_library_source()
|
||||
start = text.index("async function _bulkAction(action)")
|
||||
end = text.index("\n}\n\n// _extractName", start) + 3
|
||||
return text[start:end]
|
||||
return js_function_source("_bulkAction")
|
||||
|
||||
|
||||
def _function_source(name: str) -> str:
|
||||
text = email_library_source()
|
||||
start = text.index(f"function {name}")
|
||||
next_function = text.find("\nfunction ", start + 1)
|
||||
next_async = text.find("\nasync function ", start + 1)
|
||||
candidates = [idx for idx in (next_function, next_async) if idx != -1]
|
||||
end = min(candidates) if candidates else len(text)
|
||||
return text[start:end]
|
||||
return js_function_source(name)
|
||||
|
||||
|
||||
def test_email_bulk_read_unread_calls_provider_write_routes():
|
||||
|
||||
@@ -31,6 +31,7 @@ from pathlib import Path
|
||||
|
||||
from tests.helpers.js_modules import (
|
||||
EMAIL_LIBRARY_ENTRY,
|
||||
EMAIL_LIBRARY_PACKAGE,
|
||||
EMAIL_LIBRARY_WRAPPER,
|
||||
email_library_paths,
|
||||
)
|
||||
@@ -61,22 +62,71 @@ def _listed_exports(path: Path) -> set[str]:
|
||||
return names
|
||||
|
||||
|
||||
def test_wrapper_re_exports_the_entry_module_surface_exactly():
|
||||
"""The old path must expose the same names as the package entry module.
|
||||
# The email library's public surface. The entry module exports more than this —
|
||||
# siblings in the package import helpers back out of it — so the wrapper is what
|
||||
# declares which names are API and which are package-internal.
|
||||
#
|
||||
# Written out rather than derived because three of the five callers reach these
|
||||
# through a dynamic import and a property read (`mod.openEmailLibrary` in
|
||||
# chatStream.js and chatRenderer.js, `mod.refreshEmailLibrary` and
|
||||
# `mod.openEmailLibrary` in document.js, `mod.mountEmailSettings` in
|
||||
# settings.js), which no import scan can see. Only emailInbox.js imports names
|
||||
# statically, and `test_wrapper_exposes_every_statically_imported_name` covers
|
||||
# that half exactly.
|
||||
_PUBLIC_SURFACE = {
|
||||
"closeEmailLibrary",
|
||||
"initEmailLibrary",
|
||||
"isOpen",
|
||||
"mountEmailSettings",
|
||||
"openEmailLibrary",
|
||||
"openEmailLibrarySettings",
|
||||
"prewarmEmailLibrary",
|
||||
"prewarmUnreadEmails",
|
||||
"refreshEmailLibrary",
|
||||
}
|
||||
|
||||
Not a subset and not a superset: a missing name breaks a caller silently,
|
||||
and a name the entry module no longer exports is a load-time error.
|
||||
"""
|
||||
_STATIC_JS = ROOT / "static" / "js"
|
||||
_WRAPPER_IMPORT = re.compile(
|
||||
r"import\s*\{([^}]*)\}\s*from\s*'\./emailLibrary\.js(?:\?[^']*)?'", re.S
|
||||
)
|
||||
|
||||
|
||||
def test_wrapper_declares_the_public_surface():
|
||||
assert _listed_exports(EMAIL_LIBRARY_WRAPPER) == _PUBLIC_SURFACE
|
||||
|
||||
|
||||
def test_wrapper_re_exports_only_names_the_entry_module_has():
|
||||
"""A name in the wrapper that the entry module does not export is a
|
||||
SyntaxError at load time, and it takes the whole email panel with it."""
|
||||
entry = _declared_exports(EMAIL_LIBRARY_ENTRY) | _listed_exports(EMAIL_LIBRARY_ENTRY)
|
||||
wrapper = _listed_exports(EMAIL_LIBRARY_WRAPPER)
|
||||
assert wrapper, f"{EMAIL_LIBRARY_WRAPPER} re-exports nothing"
|
||||
assert wrapper == entry, (
|
||||
"static/js/emailLibrary.js and static/js/emailLibrary/index.js disagree "
|
||||
f"on the public surface; only in the wrapper: {sorted(wrapper - entry)}; "
|
||||
f"only in the entry module: {sorted(entry - wrapper)}"
|
||||
assert wrapper <= entry, (
|
||||
"static/js/emailLibrary.js re-exports names static/js/emailLibrary/"
|
||||
f"index.js does not export: {sorted(wrapper - entry)}"
|
||||
)
|
||||
|
||||
|
||||
def test_wrapper_exposes_every_statically_imported_name():
|
||||
"""Whatever a module outside the package imports by name must be there."""
|
||||
wrapper = _listed_exports(EMAIL_LIBRARY_WRAPPER)
|
||||
checked = 0
|
||||
for path in sorted(_STATIC_JS.rglob("*.js")):
|
||||
if path == EMAIL_LIBRARY_WRAPPER or EMAIL_LIBRARY_PACKAGE in path.parents:
|
||||
continue
|
||||
for block in _WRAPPER_IMPORT.findall(path.read_text(encoding="utf-8")):
|
||||
for raw in block.split(","):
|
||||
name = raw.strip().split(" as ")[0].strip()
|
||||
if not name:
|
||||
continue
|
||||
checked += 1
|
||||
assert name in wrapper, (
|
||||
f"{path.relative_to(ROOT)} imports {name} from "
|
||||
"static/js/emailLibrary.js, which does not export it"
|
||||
)
|
||||
assert checked, "no module imports names from static/js/emailLibrary.js"
|
||||
|
||||
|
||||
def test_every_package_module_is_precached():
|
||||
sw = _SW.read_text(encoding="utf-8")
|
||||
for path in email_library_paths():
|
||||
|
||||
@@ -4,7 +4,7 @@ import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
from tests.helpers.js_modules import email_library_source
|
||||
from tests.helpers.js_modules import email_library_source, js_function_source
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
@@ -14,65 +14,7 @@ def _source() -> str:
|
||||
|
||||
|
||||
def _function_source(name: str) -> str:
|
||||
"""Return one top-level JS function using balanced braces."""
|
||||
text = _source()
|
||||
markers = (f"function {name}", f"async function {name}", f"export function {name}", f"export async function {name}")
|
||||
starts = [text.find(marker) for marker in markers]
|
||||
starts = [start for start in starts if start >= 0]
|
||||
assert starts, f"missing function {name}"
|
||||
start = min(starts)
|
||||
paren = text.index("(", start)
|
||||
paren_depth = 0
|
||||
quote = None
|
||||
escaped = False
|
||||
for index in range(paren, len(text)):
|
||||
char = text[index]
|
||||
if quote:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == quote:
|
||||
quote = None
|
||||
continue
|
||||
if char in ("'", '"', "`"):
|
||||
quote = char
|
||||
elif char == "(":
|
||||
paren_depth += 1
|
||||
elif char == ")":
|
||||
paren_depth -= 1
|
||||
if paren_depth == 0:
|
||||
brace = text.index("{", index)
|
||||
break
|
||||
else:
|
||||
raise AssertionError(f"unterminated signature {name}")
|
||||
depth = 0
|
||||
quote = None
|
||||
escaped = False
|
||||
template_depth = 0
|
||||
for index in range(brace, len(text)):
|
||||
char = text[index]
|
||||
if quote:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif char == "\\":
|
||||
escaped = True
|
||||
elif char == quote and template_depth == 0:
|
||||
quote = None
|
||||
elif quote == "`" and char == "$" and index + 1 < len(text) and text[index + 1] == "{":
|
||||
template_depth += 1
|
||||
elif quote == "`" and char == "}" and template_depth:
|
||||
template_depth -= 1
|
||||
continue
|
||||
if char in ("'", '"', "`"):
|
||||
quote = char
|
||||
elif char == "{":
|
||||
depth += 1
|
||||
elif char == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[start:index + 1]
|
||||
raise AssertionError(f"unterminated function {name}")
|
||||
return js_function_source(name, _source())
|
||||
|
||||
|
||||
def _run_scheduler_scenario(scenario: str):
|
||||
|
||||
@@ -6,7 +6,7 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from tests.helpers.js_modules import email_library_source
|
||||
from tests.helpers.js_modules import email_library_source, js_function_source
|
||||
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
@@ -22,7 +22,7 @@ def _extract_between(source: str, signature: str, next_marker: str) -> str:
|
||||
|
||||
def test_library_unread_preview_has_one_authoritative_request_and_rollback():
|
||||
source = email_library_source()
|
||||
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
|
||||
function = js_function_source("_toggleCardPreview", source)
|
||||
|
||||
assert function.count("/api/email/read/") == 1
|
||||
assert "/api/email/mark-read/" not in function
|
||||
@@ -39,7 +39,7 @@ def test_library_unread_preview_has_one_authoritative_request_and_rollback():
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_library_authoritative_success_defeats_newer_rollback_in_either_order():
|
||||
source = email_library_source()
|
||||
function = _extract_between(source, "async function _toggleCardPreview", "\n/**\n * Wrap a probable signature block")
|
||||
function = js_function_source("_toggleCardPreview", source)
|
||||
settlements = _extract_between(
|
||||
function,
|
||||
" const restoreUnreadState = () => {",
|
||||
|
||||
Reference in New Issue
Block a user