transcript-viewer 0.6.0__tar.gz → 0.6.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- transcript_viewer-0.6.0/README.md → transcript_viewer-0.6.1/PKG-INFO +27 -3
- transcript_viewer-0.6.0/PKG-INFO → transcript_viewer-0.6.1/README.md +14 -16
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/pyproject.toml +1 -1
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/src/transcript_viewer/library.py +35 -3
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/src/transcript_viewer/page.html +96 -4
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/src/transcript_viewer/viewer.py +33 -4
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/page.test.js +56 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_readme.py +24 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_viewer.py +34 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/uv.lock +1 -1
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/.coverage +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/.github/workflows/publish.yml +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/.github/workflows/test.yml +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/.gitignore +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/src/transcript_viewer/__init__.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/src/transcript_viewer/ai.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/src/transcript_viewer/cli.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/src/transcript_viewer/config.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/src/transcript_viewer/corpus.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/src/transcript_viewer/fetch.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/src/transcript_viewer/store.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_ai.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_cli.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_config.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_corpus.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_fetch.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_library.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_page.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_store.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_stream.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_style.py +0 -0
- {transcript_viewer-0.6.0 → transcript_viewer-0.6.1}/tests/test_tidiness.py +0 -0
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: transcript-viewer
|
|
3
|
+
Version: 0.6.1
|
|
4
|
+
Summary: Browse agent transcripts in a local, dependency-free web viewer.
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: atif-make>=0.5.0
|
|
8
|
+
Provides-Extra: ai
|
|
9
|
+
Requires-Dist: anthropic>=0.40; extra == 'ai'
|
|
10
|
+
Provides-Extra: parquet
|
|
11
|
+
Requires-Dist: atif-make[parquet]>=0.5.0; extra == 'parquet'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
1
14
|
# transcript-viewer
|
|
2
15
|
|
|
3
16
|
Browse agent transcripts in a local web viewer — what Claude Code, Codex and
|
|
@@ -13,9 +26,15 @@ anything to change in the viewer.
|
|
|
13
26
|
## Install
|
|
14
27
|
|
|
15
28
|
```sh
|
|
16
|
-
uv tool install transcript-viewer
|
|
29
|
+
uv tool install transcript-viewer # pulls atif-make automatically
|
|
30
|
+
uv tool install "transcript-viewer[parquet]" # + datasets published as Parquet
|
|
31
|
+
uv tool install "transcript-viewer[ai,parquet]" # + the optional Claude features
|
|
17
32
|
```
|
|
18
33
|
|
|
34
|
+
The extras are only needed for what they name: `parquet` for a dataset that
|
|
35
|
+
ships as Parquet rather than JSON, `ai` for the summarise and ask features. The
|
|
36
|
+
viewer works without either.
|
|
37
|
+
|
|
19
38
|
```sh
|
|
20
39
|
transcript-viewer # the library, empty on a first run
|
|
21
40
|
transcript-viewer path/to/session.jsonl # one log
|
|
@@ -395,7 +414,11 @@ The page is a file rather than a string inside `viewer.py`, which is where it
|
|
|
395
414
|
used to live. Two thirds of that module was CSS and JavaScript typed as though
|
|
396
415
|
it were Python — a template expression once shipped inside static HTML and
|
|
397
416
|
rendered its own source, which is harder to miss in a file that knows what it
|
|
398
|
-
is. It is read
|
|
417
|
+
is. It is re-read whenever it changes, so editing the interface and reloading
|
|
418
|
+
shows the edit — twice it did not, and read as an edit that had failed. The
|
|
419
|
+
cost is a stat per page load, and none on what the page then calls; for an
|
|
420
|
+
installed copy the file never changes and the stat always says so. The wheel
|
|
421
|
+
carries it.
|
|
399
422
|
|
|
400
423
|
## Tests
|
|
401
424
|
|
|
@@ -411,7 +434,8 @@ uv run --with-editable ../atif-make pytest
|
|
|
411
434
|
```
|
|
412
435
|
|
|
413
436
|
Add `--extra ai` to either command to exercise the AI paths against a real SDK;
|
|
414
|
-
the tests stub the model call, so this never contacts the API.
|
|
437
|
+
the tests stub the model call, so this never contacts the API. `--extra parquet`
|
|
438
|
+
does the same for the Parquet path, which is otherwise skipped.
|
|
415
439
|
|
|
416
440
|
The suite starts a real server on an ephemeral port and exercises the endpoints,
|
|
417
441
|
including that it binds loopback only. It is isolated from your own library,
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.5
|
|
2
|
-
Name: transcript-viewer
|
|
3
|
-
Version: 0.6.0
|
|
4
|
-
Summary: Browse agent transcripts in a local, dependency-free web viewer.
|
|
5
|
-
License: MIT
|
|
6
|
-
Requires-Python: >=3.12
|
|
7
|
-
Requires-Dist: atif-make>=0.5.0
|
|
8
|
-
Provides-Extra: ai
|
|
9
|
-
Requires-Dist: anthropic>=0.40; extra == 'ai'
|
|
10
|
-
Provides-Extra: parquet
|
|
11
|
-
Requires-Dist: atif-make[parquet]>=0.5.0; extra == 'parquet'
|
|
12
|
-
Description-Content-Type: text/markdown
|
|
13
|
-
|
|
14
1
|
# transcript-viewer
|
|
15
2
|
|
|
16
3
|
Browse agent transcripts in a local web viewer — what Claude Code, Codex and
|
|
@@ -26,9 +13,15 @@ anything to change in the viewer.
|
|
|
26
13
|
## Install
|
|
27
14
|
|
|
28
15
|
```sh
|
|
29
|
-
uv tool install transcript-viewer
|
|
16
|
+
uv tool install transcript-viewer # pulls atif-make automatically
|
|
17
|
+
uv tool install "transcript-viewer[parquet]" # + datasets published as Parquet
|
|
18
|
+
uv tool install "transcript-viewer[ai,parquet]" # + the optional Claude features
|
|
30
19
|
```
|
|
31
20
|
|
|
21
|
+
The extras are only needed for what they name: `parquet` for a dataset that
|
|
22
|
+
ships as Parquet rather than JSON, `ai` for the summarise and ask features. The
|
|
23
|
+
viewer works without either.
|
|
24
|
+
|
|
32
25
|
```sh
|
|
33
26
|
transcript-viewer # the library, empty on a first run
|
|
34
27
|
transcript-viewer path/to/session.jsonl # one log
|
|
@@ -408,7 +401,11 @@ The page is a file rather than a string inside `viewer.py`, which is where it
|
|
|
408
401
|
used to live. Two thirds of that module was CSS and JavaScript typed as though
|
|
409
402
|
it were Python — a template expression once shipped inside static HTML and
|
|
410
403
|
rendered its own source, which is harder to miss in a file that knows what it
|
|
411
|
-
is. It is read
|
|
404
|
+
is. It is re-read whenever it changes, so editing the interface and reloading
|
|
405
|
+
shows the edit — twice it did not, and read as an edit that had failed. The
|
|
406
|
+
cost is a stat per page load, and none on what the page then calls; for an
|
|
407
|
+
installed copy the file never changes and the stat always says so. The wheel
|
|
408
|
+
carries it.
|
|
412
409
|
|
|
413
410
|
## Tests
|
|
414
411
|
|
|
@@ -424,7 +421,8 @@ uv run --with-editable ../atif-make pytest
|
|
|
424
421
|
```
|
|
425
422
|
|
|
426
423
|
Add `--extra ai` to either command to exercise the AI paths against a real SDK;
|
|
427
|
-
the tests stub the model call, so this never contacts the API.
|
|
424
|
+
the tests stub the model call, so this never contacts the API. `--extra parquet`
|
|
425
|
+
does the same for the Parquet path, which is otherwise skipped.
|
|
428
426
|
|
|
429
427
|
The suite starts a real server on an ephemeral port and exercises the endpoints,
|
|
430
428
|
including that it binds loopback only. It is isolated from your own library,
|
|
@@ -51,17 +51,49 @@ def _now() -> str:
|
|
|
51
51
|
return datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z")
|
|
52
52
|
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
# What was parsed, and the state of the file it came from. The library is read
|
|
55
|
+
# far more often than it is written — a page load asks about every session in
|
|
56
|
+
# it — and it grows with the corpus, so parsing a megabyte of JSON per question
|
|
57
|
+
# is the difference between a page appearing and a page taking four seconds.
|
|
58
|
+
_parsed: tuple[int, int, dict[str, dict]] | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read(path: Path) -> dict[str, dict]:
|
|
62
|
+
data = store.read_json(path)
|
|
57
63
|
entries = data.get("entries")
|
|
58
64
|
if not isinstance(entries, dict):
|
|
59
65
|
return {}
|
|
60
66
|
return {k: v for k, v in entries.items() if isinstance(v, dict)}
|
|
61
67
|
|
|
62
68
|
|
|
69
|
+
def load(path: Path | None = None) -> dict[str, dict]:
|
|
70
|
+
"""Every annotation, by key. A missing or damaged file reads as empty.
|
|
71
|
+
|
|
72
|
+
The real library is cached against its own timestamp and size, so a change
|
|
73
|
+
made by anything — this process or another — is picked up on the next read.
|
|
74
|
+
A caller gets its own outer dict, since `remove` pops from what it is given
|
|
75
|
+
and callers should not be able to edit the cache by accident.
|
|
76
|
+
"""
|
|
77
|
+
global _parsed
|
|
78
|
+
path = path or LIBRARY_PATH
|
|
79
|
+
if path != LIBRARY_PATH:
|
|
80
|
+
return _read(path)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
info = path.stat()
|
|
84
|
+
stamp = (info.st_mtime_ns, info.st_size)
|
|
85
|
+
except OSError:
|
|
86
|
+
return _read(path)
|
|
87
|
+
|
|
88
|
+
if _parsed is None or (_parsed[0], _parsed[1]) != stamp:
|
|
89
|
+
_parsed = (stamp[0], stamp[1], _read(path))
|
|
90
|
+
return dict(_parsed[2])
|
|
91
|
+
|
|
92
|
+
|
|
63
93
|
def save(entries: dict[str, dict], path: Path | None = None) -> None:
|
|
64
94
|
"""Write the whole library atomically."""
|
|
95
|
+
global _parsed
|
|
96
|
+
_parsed = None # the next read re-stamps against the file just written
|
|
65
97
|
store.write_json(path or LIBRARY_PATH, {"version": VERSION, "entries": entries})
|
|
66
98
|
|
|
67
99
|
|
|
@@ -430,8 +430,33 @@ pre.json{line-height:1.45}
|
|
|
430
430
|
.more button{border:1px solid var(--line);background:var(--panel);color:var(--dim);border-radius:999px;
|
|
431
431
|
padding:8px 18px;font:inherit;font-size:12.5px;cursor:pointer}
|
|
432
432
|
.more button:hover{background:var(--sunk);color:var(--ink)}
|
|
433
|
+
|
|
434
|
+
/* Shown only if opening takes long enough to notice — a library of a few
|
|
435
|
+
thousand sessions is a couple of megabytes, and a blank window while that
|
|
436
|
+
arrives reads as broken rather than busy. */
|
|
437
|
+
#boot{position:fixed;inset:0;z-index:60;display:flex;flex-direction:column;
|
|
438
|
+
align-items:center;justify-content:center;gap:14px;background:var(--bg)}
|
|
439
|
+
#boot[hidden]{display:none}
|
|
440
|
+
#bootbar{width:min(320px,52vw);height:3px;border-radius:3px;background:var(--line);
|
|
441
|
+
overflow:hidden}
|
|
442
|
+
#bootbar i{display:block;height:100%;width:0;border-radius:3px;background:var(--accent);
|
|
443
|
+
transition:width .18s ease-out}
|
|
444
|
+
/* Nothing to measure yet: sweep rather than sit at zero, which reads as stuck. */
|
|
445
|
+
#bootbar.waiting i{width:35%;animation:sweep 1.1s ease-in-out infinite}
|
|
446
|
+
@keyframes sweep{0%{transform:translateX(-100%)}100%{transform:translateX(320%)}}
|
|
447
|
+
#boottext{margin:0;font:12px/1.5 var(--font-mono);color:var(--muted);
|
|
448
|
+
letter-spacing:.02em;text-align:center;padding:0 20px}
|
|
449
|
+
@media (prefers-reduced-motion:reduce){
|
|
450
|
+
#bootbar i{transition:none}
|
|
451
|
+
#bootbar.waiting i{animation:none;width:100%;opacity:.5}
|
|
452
|
+
}
|
|
433
453
|
</style>
|
|
434
454
|
|
|
455
|
+
<div id="boot" hidden>
|
|
456
|
+
<div id="bootbar"><i></i></div>
|
|
457
|
+
<p id="boottext">Opening your library…</p>
|
|
458
|
+
</div>
|
|
459
|
+
|
|
435
460
|
<header id="top">
|
|
436
461
|
<span class="mark">Transcript Viewer</span>
|
|
437
462
|
<div id="crumb"></div>
|
|
@@ -932,10 +957,77 @@ addEventListener("keydown",e=>{
|
|
|
932
957
|
if(e.key==="\\"&&e.target.tagName!=="INPUT"){e.preventDefault();toggleSide()}
|
|
933
958
|
});
|
|
934
959
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
}
|
|
960
|
+
/* Opening the library has three parts worth naming: waiting for the server to
|
|
961
|
+
answer, reading the answer, and drawing it. Held back briefly so a fast open
|
|
962
|
+
does not flash a bar at you. */
|
|
963
|
+
const boot={el:null,bar:null,text:null,timer:0};
|
|
964
|
+
|
|
965
|
+
function bootShow(){
|
|
966
|
+
boot.el=document.getElementById("boot");
|
|
967
|
+
boot.bar=document.getElementById("bootbar");
|
|
968
|
+
boot.text=document.getElementById("boottext");
|
|
969
|
+
if(!boot.el)return;
|
|
970
|
+
boot.timer=setTimeout(()=>{if(boot.el.hidden)boot.el.hidden=false},160);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function bootSay(what,done,total){
|
|
974
|
+
if(boot.text)boot.text.textContent=what;
|
|
975
|
+
if(!boot.bar)return;
|
|
976
|
+
if(total>0){
|
|
977
|
+
boot.bar.classList.remove("waiting");
|
|
978
|
+
boot.bar.firstElementChild.style.width=Math.min(100,done/total*100)+"%";
|
|
979
|
+
}else{
|
|
980
|
+
boot.bar.classList.add("waiting");
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function bootDone(){
|
|
985
|
+
clearTimeout(boot.timer);
|
|
986
|
+
if(boot.el)boot.el.hidden=true;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/* Read the response as it arrives, so the bar tracks real bytes rather than
|
|
990
|
+
guessing at them. Without a length to measure against it sweeps instead of
|
|
991
|
+
claiming progress it cannot know. */
|
|
992
|
+
async function readIndex(){
|
|
993
|
+
const response=await fetch("/api/index");
|
|
994
|
+
if(!response.ok)throw new Error("the server answered "+response.status);
|
|
995
|
+
const total=Number(response.headers.get("Content-Length")||0);
|
|
996
|
+
if(!response.body||!total)return response.json();
|
|
997
|
+
|
|
998
|
+
const reader=response.body.getReader();
|
|
999
|
+
const chunks=[];let done=0;
|
|
1000
|
+
for(;;){
|
|
1001
|
+
const piece=await reader.read();
|
|
1002
|
+
if(piece.done)break;
|
|
1003
|
+
chunks.push(piece.value);done+=piece.value.length;
|
|
1004
|
+
bootSay("Reading your library… "+bytes(done)+" of "+bytes(total),done,total);
|
|
1005
|
+
}
|
|
1006
|
+
const joined=new Uint8Array(done);let at=0;
|
|
1007
|
+
for(const chunk of chunks){joined.set(chunk,at);at+=chunk.length}
|
|
1008
|
+
bootSay("Making sense of it…",0,0);
|
|
1009
|
+
return JSON.parse(new TextDecoder().decode(joined));
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
async function openLibrary(){
|
|
1013
|
+
bootShow();
|
|
1014
|
+
bootSay("Looking for your sessions…",0,0);
|
|
1015
|
+
try{
|
|
1016
|
+
const d=await readIndex();
|
|
1017
|
+
INDEX=d.sessions||[];GROUPS=d.groups||[];TAGS=d.tags||[];AI=d.ai||{};DOWNLOADS=d.downloads||DOWNLOADS;
|
|
1018
|
+
bootSay("Drawing "+INDEX.length+" sessions…",0,0);
|
|
1019
|
+
// Let that line paint before the work that blocks the thread.
|
|
1020
|
+
await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));
|
|
1021
|
+
count.textContent=INDEX.length+" sessions";drawList();showLibrary();
|
|
1022
|
+
}catch(err){
|
|
1023
|
+
// Leave it on screen: a blank window would say nothing at all.
|
|
1024
|
+
bootSay("Could not open your library — "+((err&&err.message)||err),0,0);
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
bootDone();
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
openLibrary();
|
|
939
1031
|
q.oninput=drawList;
|
|
940
1032
|
|
|
941
1033
|
/* Diwan's collectionTree builds a forest carrying depth, then flattens it
|
|
@@ -35,7 +35,33 @@ from . import ai, config, fetch, library
|
|
|
35
35
|
# The page is a real .html file rather than a string in here: it is 2,232 lines
|
|
36
36
|
# of CSS and JavaScript, which is not Python and should not be typed as though
|
|
37
37
|
# it were. Read once at import, and served from memory.
|
|
38
|
-
|
|
38
|
+
PAGE_PATH = Path(__file__).parent / "page.html"
|
|
39
|
+
|
|
40
|
+
# What was read, and the state of the file it was read from.
|
|
41
|
+
_page: tuple[float, int, str] | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def page() -> str:
|
|
45
|
+
"""The interface, re-read when the file behind it changes.
|
|
46
|
+
|
|
47
|
+
The whole interface is that one file, so editing it and seeing nothing
|
|
48
|
+
change until the server is restarted reads as the edit not having worked.
|
|
49
|
+
Twice now it has. The cost is one stat per page load — not per request, and
|
|
50
|
+
not on anything the page then calls — and for an installed copy, where the
|
|
51
|
+
file never changes, it is a stat that always says the same thing.
|
|
52
|
+
"""
|
|
53
|
+
global _page
|
|
54
|
+
try:
|
|
55
|
+
info = PAGE_PATH.stat()
|
|
56
|
+
stamp = (info.st_mtime, info.st_size)
|
|
57
|
+
except OSError:
|
|
58
|
+
# If it cannot be stat'd, what was already read is better than nothing.
|
|
59
|
+
if _page is not None:
|
|
60
|
+
return _page[2]
|
|
61
|
+
raise
|
|
62
|
+
if _page is None or (_page[0], _page[1]) != stamp:
|
|
63
|
+
_page = (stamp[0], stamp[1], PAGE_PATH.read_text(encoding="utf-8"))
|
|
64
|
+
return _page[2]
|
|
39
65
|
|
|
40
66
|
|
|
41
67
|
def _point_images_at_server(trajectory: Trajectory, index: str) -> None:
|
|
@@ -305,7 +331,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
305
331
|
return {
|
|
306
332
|
e.key
|
|
307
333
|
for e in self.entries
|
|
308
|
-
if (at := _group(e, library.get(e.key).get("source", "")))
|
|
334
|
+
if (at := _group(e, library.load().get(e.key, {}).get("source", "")))
|
|
309
335
|
and (at == name or at.startswith(f"{name}/"))
|
|
310
336
|
}
|
|
311
337
|
|
|
@@ -828,10 +854,13 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
828
854
|
url = urlparse(self.path)
|
|
829
855
|
|
|
830
856
|
if url.path == "/":
|
|
831
|
-
self._send(
|
|
857
|
+
self._send(page().encode(), "text/html; charset=utf-8")
|
|
832
858
|
return
|
|
833
859
|
|
|
834
860
|
if url.path == "/api/index":
|
|
861
|
+
# Read once. Asking per session made a page load parse the whole
|
|
862
|
+
# library as many times as there were sessions in it.
|
|
863
|
+
records = library.load()
|
|
835
864
|
rows = [
|
|
836
865
|
{
|
|
837
866
|
"key": e.key,
|
|
@@ -845,7 +874,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
845
874
|
"size_bytes": e.size_bytes,
|
|
846
875
|
"subagents": e.subagents,
|
|
847
876
|
"session_title": e.session_title,
|
|
848
|
-
"group": _group(e,
|
|
877
|
+
"group": _group(e, records.get(e.key, {}).get("source", "")),
|
|
849
878
|
}
|
|
850
879
|
for e in self.entries
|
|
851
880
|
# A downloaded folder the reader deleted, or a drive not mounted
|
|
@@ -1424,3 +1424,59 @@ test("everything reads as All sessions", () => {
|
|
|
1424
1424
|
console.log(`\n${tests.length - failed} passed, ${failed} failed`);
|
|
1425
1425
|
process.exit(failed ? 1 : 0);
|
|
1426
1426
|
})();
|
|
1427
|
+
|
|
1428
|
+
test("opening the library reports each stage it goes through", async () => {
|
|
1429
|
+
const said = [];
|
|
1430
|
+
const el = { hidden: true };
|
|
1431
|
+
const bar = {
|
|
1432
|
+
classList: { add() {}, remove() {} },
|
|
1433
|
+
firstElementChild: { style: {} },
|
|
1434
|
+
};
|
|
1435
|
+
const text = { textContent: "" };
|
|
1436
|
+
globalThis.__boot = { boot: el, bootbar: bar, boottext: text, said };
|
|
1437
|
+
|
|
1438
|
+
const seen = run(`
|
|
1439
|
+
document.getElementById = id => globalThis.__boot[id] || {textContent:"",style:{},classList:{add(){},remove(){}}};
|
|
1440
|
+
globalThis.requestAnimationFrame = fn => fn();
|
|
1441
|
+
globalThis.fetch = async () => ({
|
|
1442
|
+
ok: true,
|
|
1443
|
+
headers: { get: () => "0" },
|
|
1444
|
+
body: null,
|
|
1445
|
+
json: async () => ({sessions: [], groups: [], tags: [], ai: {}}),
|
|
1446
|
+
});
|
|
1447
|
+
const stages = [];
|
|
1448
|
+
const realSay = bootSay;
|
|
1449
|
+
bootSay = (what) => { stages.push(what); realSay(what, 0, 0); };
|
|
1450
|
+
return openLibrary().then(() => stages);
|
|
1451
|
+
`);
|
|
1452
|
+
|
|
1453
|
+
return seen.then((stages) => {
|
|
1454
|
+
assert.ok(
|
|
1455
|
+
stages.some((s) => /Looking for your sessions/.test(s)),
|
|
1456
|
+
"it should say it is looking before it has anything",
|
|
1457
|
+
);
|
|
1458
|
+
assert.ok(
|
|
1459
|
+
stages.some((s) => /Drawing/.test(s)),
|
|
1460
|
+
"it should say it is drawing once it has the data",
|
|
1461
|
+
);
|
|
1462
|
+
});
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
test("a failure to open leaves the reason on screen", async () => {
|
|
1466
|
+
const text = { textContent: "" };
|
|
1467
|
+
const el = { hidden: false };
|
|
1468
|
+
globalThis.__boot = {
|
|
1469
|
+
boot: el,
|
|
1470
|
+
bootbar: { classList: { add() {}, remove() {} }, firstElementChild: { style: {} } },
|
|
1471
|
+
boottext: text,
|
|
1472
|
+
};
|
|
1473
|
+
|
|
1474
|
+
await run(`
|
|
1475
|
+
document.getElementById = id => globalThis.__boot[id] || {textContent:"",style:{},classList:{add(){},remove(){}}};
|
|
1476
|
+
globalThis.fetch = async () => ({ ok: false, status: 500 });
|
|
1477
|
+
return openLibrary();
|
|
1478
|
+
`);
|
|
1479
|
+
|
|
1480
|
+
assert.match(text.textContent, /Could not open your library/);
|
|
1481
|
+
assert.strictEqual(el.hidden, false, "a blank window would say nothing at all");
|
|
1482
|
+
});
|
|
@@ -41,6 +41,8 @@ CLAIMS = [
|
|
|
41
41
|
("quadratic", "HISTORY_TURNS", "src/transcript_viewer/ai.py"),
|
|
42
42
|
("links into the transcript", "function jumpToStep", "src/transcript_viewer/page.html"),
|
|
43
43
|
("--extra ai", "ai = [", "pyproject.toml"),
|
|
44
|
+
("re-read whenever it changes", "def page()", "src/transcript_viewer/viewer.py"),
|
|
45
|
+
("stat per page load", "PAGE_PATH.stat()", "src/transcript_viewer/viewer.py"),
|
|
44
46
|
]
|
|
45
47
|
|
|
46
48
|
|
|
@@ -162,3 +164,25 @@ def test_every_module_appears_in_the_map():
|
|
|
162
164
|
if path.name.startswith("_") or path.suffix not in {".py", ".html"}:
|
|
163
165
|
continue
|
|
164
166
|
assert f" {path.name}" in readme, f"{path.name} is missing from the module map"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_every_extra_offered_in_the_readme_exists():
|
|
170
|
+
"""An install command that names an extra nobody defined simply fails."""
|
|
171
|
+
project = Path("pyproject.toml").read_text()
|
|
172
|
+
defined = set(re.findall(r"^([a-z][a-z0-9-]*) = \[", project, re.MULTILINE))
|
|
173
|
+
readme = Path("README.md").read_text()
|
|
174
|
+
offered = set()
|
|
175
|
+
for group in re.findall(r'transcript-viewer\[([a-z,]+)\]', readme):
|
|
176
|
+
offered.update(group.split(","))
|
|
177
|
+
missing = offered - defined
|
|
178
|
+
assert not missing, f"the README offers extras that do not exist: {missing}"
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_every_extra_that_exists_is_mentioned():
|
|
182
|
+
"""An extra nobody is told about may as well not be there."""
|
|
183
|
+
project = Path("pyproject.toml").read_text()
|
|
184
|
+
block = project.split("[project.optional-dependencies]")[1].split("\n[")[0]
|
|
185
|
+
defined = set(re.findall(r"^([a-z][a-z0-9-]*) = \[", block, re.MULTILINE))
|
|
186
|
+
readme = Path("README.md").read_text()
|
|
187
|
+
unmentioned = {name for name in defined if name not in readme}
|
|
188
|
+
assert not unmentioned, f"extras the README never mentions: {unmentioned}"
|
|
@@ -911,3 +911,37 @@ def test_clearing_an_empty_library_is_harmless(server):
|
|
|
911
911
|
_delete(server + "/api/library?all=1")
|
|
912
912
|
status, payload = _delete(server + "/api/library?all=1")
|
|
913
913
|
assert status == 200 and payload["removed"] == 0
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def test_the_page_is_re_read_when_it_changes(tmp_path, monkeypatch):
|
|
917
|
+
"""Editing the interface and seeing nothing change reads as a broken edit."""
|
|
918
|
+
from transcript_viewer import viewer
|
|
919
|
+
|
|
920
|
+
page_file = tmp_path / "page.html"
|
|
921
|
+
page_file.write_text("<p>first</p>")
|
|
922
|
+
monkeypatch.setattr(viewer, "PAGE_PATH", page_file)
|
|
923
|
+
monkeypatch.setattr(viewer, "_page", None)
|
|
924
|
+
|
|
925
|
+
assert viewer.page() == "<p>first</p>"
|
|
926
|
+
|
|
927
|
+
# A same-size rewrite is the hard case: only the timestamp separates them.
|
|
928
|
+
page_file.write_text("<p>secnd</p>")
|
|
929
|
+
import os
|
|
930
|
+
stamp = page_file.stat().st_mtime + 10
|
|
931
|
+
os.utime(page_file, (stamp, stamp))
|
|
932
|
+
|
|
933
|
+
assert viewer.page() == "<p>secnd</p>"
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def test_an_unreadable_page_serves_what_was_already_read(tmp_path, monkeypatch):
|
|
937
|
+
"""A file being written to should not take the interface down mid-save."""
|
|
938
|
+
from transcript_viewer import viewer
|
|
939
|
+
|
|
940
|
+
page_file = tmp_path / "page.html"
|
|
941
|
+
page_file.write_text("<p>kept</p>")
|
|
942
|
+
monkeypatch.setattr(viewer, "PAGE_PATH", page_file)
|
|
943
|
+
monkeypatch.setattr(viewer, "_page", None)
|
|
944
|
+
assert viewer.page() == "<p>kept</p>"
|
|
945
|
+
|
|
946
|
+
page_file.unlink()
|
|
947
|
+
assert viewer.page() == "<p>kept</p>"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|