citegraph 0.2.0__py3-none-any.whl

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.
citegraph/webui.py ADDED
@@ -0,0 +1,624 @@
1
+ """Read-only live-monitoring web UI for a citegraph out directory.
2
+
3
+ ``citegraph ui --out ./out`` serves a single-page dashboard on
4
+ ``127.0.0.1`` that polls the out directory every couple of seconds and
5
+ re-renders as artifacts appear, so a running pipeline can be watched
6
+ live. It is the MVP slice of the researcher-facing application proposed
7
+ in ``docs/UI_ARCHITECTURE.md``: strictly read-only (it never writes into
8
+ ``out_dir``), stdlib-only, and built on the same data contract as
9
+ ``citegraph report`` (:func:`citegraph.html_report.collect_report_data`).
10
+
11
+ Endpoints:
12
+
13
+ - ``GET /`` — the page (inline CSS/JS, no external requests).
14
+ - ``GET /api/data`` — the report data dict (without markdown previews)
15
+ plus ``recent_files``, the most recently modified files under
16
+ ``out_dir``. Valid even when ``out_dir`` does not exist yet.
17
+ - ``GET /api/markdown?stem=<stem>`` — the first ~2,500 chars of one
18
+ converted markdown file, fetched lazily when a paper row is expanded.
19
+ Only bare stems resolving inside ``out_dir/markdown`` are served.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import threading
26
+ from datetime import UTC, datetime
27
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
28
+ from pathlib import Path
29
+ from urllib.parse import parse_qs, urlparse
30
+
31
+ from citegraph.html_report import _MARKDOWN_PREVIEW_CHARS, collect_report_data
32
+ from citegraph.io import OutLayout
33
+
34
+ __all__ = ["create_server"]
35
+
36
+ _RECENT_FILES_N = 15
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Data layer
41
+ # ---------------------------------------------------------------------------
42
+ def _scan_files(out_dir: Path) -> list[tuple[float, int, Path]]:
43
+ """(mtime, size, path) for every regular file under ``out_dir``."""
44
+ if not out_dir.is_dir():
45
+ return []
46
+ entries: list[tuple[float, int, Path]] = []
47
+ for path in out_dir.rglob("*"):
48
+ try:
49
+ if not path.is_file():
50
+ continue
51
+ stat = path.stat()
52
+ except OSError: # file vanished mid-scan — a live run is writing
53
+ continue
54
+ entries.append((stat.st_mtime, stat.st_size, path))
55
+ return entries
56
+
57
+
58
+ def _recent_files(entries: list[tuple[float, int, Path]], out_dir: Path) -> list[dict]:
59
+ newest = sorted(entries, key=lambda e: e[0], reverse=True)[:_RECENT_FILES_N]
60
+ return [
61
+ {
62
+ "path": str(path.relative_to(out_dir)),
63
+ "size": size,
64
+ "mtime": datetime.fromtimestamp(mtime, UTC).isoformat(timespec="seconds"),
65
+ }
66
+ for mtime, size, path in newest
67
+ ]
68
+
69
+
70
+ def _build_payload_text(out_dir: Path) -> tuple[str, tuple]:
71
+ """Serialized ``/api/data`` payload plus the directory fingerprint it reflects."""
72
+ entries = _scan_files(out_dir)
73
+ fingerprint = (len(entries), max((e[0] for e in entries), default=0.0))
74
+ data = collect_report_data(out_dir, include_markdown_previews=False)
75
+ data["generated_at"] = datetime.now(UTC).isoformat(timespec="seconds")
76
+ data["recent_files"] = _recent_files(entries, out_dir)
77
+ return json.dumps(data), fingerprint
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # HTTP server
82
+ # ---------------------------------------------------------------------------
83
+ class _UIServer(ThreadingHTTPServer):
84
+ daemon_threads = True
85
+
86
+ def __init__(self, address: tuple[str, int], out_dir: Path, verbose: bool) -> None:
87
+ super().__init__(address, _Handler)
88
+ self.out_dir = Path(out_dir)
89
+ self.verbose = verbose
90
+ # /api/data is recollected only when the directory fingerprint
91
+ # (file count + newest mtime) changes; polls in between reuse the
92
+ # cached text, which also lets the client skip re-rendering.
93
+ self._payload_lock = threading.Lock()
94
+ self._payload_fingerprint: tuple | None = None
95
+ self._payload_text = ""
96
+
97
+ def payload_text(self) -> str:
98
+ with self._payload_lock:
99
+ entries = _scan_files(self.out_dir)
100
+ fingerprint = (len(entries), max((e[0] for e in entries), default=0.0))
101
+ if fingerprint != self._payload_fingerprint:
102
+ self._payload_text, self._payload_fingerprint = _build_payload_text(
103
+ self.out_dir
104
+ )
105
+ return self._payload_text
106
+
107
+
108
+ class _Handler(BaseHTTPRequestHandler):
109
+ server: _UIServer
110
+ server_version = "citegraph-ui"
111
+
112
+ def log_message(self, format: str, *args: object) -> None: # noqa: A002 - stdlib signature
113
+ if self.server.verbose:
114
+ super().log_message(format, *args)
115
+
116
+ def _send(self, code: int, content_type: str, body: bytes) -> None:
117
+ self.send_response(code)
118
+ self.send_header("Content-Type", content_type)
119
+ self.send_header("Content-Length", str(len(body)))
120
+ self.send_header("Cache-Control", "no-store")
121
+ self.end_headers()
122
+ self.wfile.write(body)
123
+
124
+ def _send_text(self, code: int, text: str) -> None:
125
+ self._send(code, "text/plain; charset=utf-8", text.encode("utf-8"))
126
+
127
+ def do_GET(self) -> None: # noqa: N802 - stdlib naming
128
+ parsed = urlparse(self.path)
129
+ if parsed.path == "/":
130
+ self._send(200, "text/html; charset=utf-8", _PAGE.encode("utf-8"))
131
+ elif parsed.path == "/api/data":
132
+ self._send(
133
+ 200,
134
+ "application/json; charset=utf-8",
135
+ self.server.payload_text().encode("utf-8"),
136
+ )
137
+ elif parsed.path == "/api/markdown":
138
+ self._handle_markdown(parsed.query)
139
+ else:
140
+ self._send_text(404, "not found")
141
+
142
+ def _handle_markdown(self, query: str) -> None:
143
+ stem = (parse_qs(query).get("stem") or [""])[0]
144
+ if not stem or "/" in stem or "\\" in stem or ".." in stem:
145
+ self._send_text(400, "invalid stem")
146
+ return
147
+ markdown_dir = OutLayout(self.server.out_dir).markdown_dir.resolve()
148
+ candidate = (markdown_dir / f"{stem}.md").resolve()
149
+ if not candidate.is_relative_to(markdown_dir):
150
+ self._send_text(400, "invalid stem")
151
+ return
152
+ if not candidate.is_file():
153
+ self._send_text(404, "unknown stem")
154
+ return
155
+ try:
156
+ text = candidate.read_text(encoding="utf-8", errors="replace")
157
+ except OSError:
158
+ self._send_text(404, "unreadable markdown")
159
+ return
160
+ self._send_text(200, text[:_MARKDOWN_PREVIEW_CHARS])
161
+
162
+
163
+ def create_server(out_dir: Path, *, port: int = 8765, verbose: bool = False) -> _UIServer:
164
+ """Bind the live-monitor server on ``127.0.0.1:port`` (0 = any free port).
165
+
166
+ The caller drives it: ``server.serve_forever()`` /
167
+ ``server.shutdown()`` / ``server.server_close()``. The server never
168
+ writes into ``out_dir`` and works before the directory exists.
169
+ """
170
+ return _UIServer(("127.0.0.1", port), Path(out_dir), verbose)
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Frontend (static; all state comes from /api/data)
175
+ # ---------------------------------------------------------------------------
176
+ _PAGE = """<!doctype html>
177
+ <html lang="en"><head><meta charset="utf-8">
178
+ <meta name="viewport" content="width=device-width, initial-scale=1">
179
+ <title>citegraph — live monitor</title>
180
+ <style>
181
+ :root {
182
+ --paper: #f6f7f4; --surface: #fdfdfb; --line: #dde2db;
183
+ --ink: #1b2420; --ink-soft: #55605a;
184
+ --accent: #1f6a4e; --accent-soft: #e3eee8;
185
+ --brass: #a07a2f; --warn: #b4552d; --warn-soft: #f6e9e2;
186
+ --code-bg: #eef1ec;
187
+ }
188
+ @media (prefers-color-scheme: dark) {
189
+ :root {
190
+ --paper: #131a17; --surface: #1a231f; --line: #2c3833;
191
+ --ink: #e6eae4; --ink-soft: #9aa8a0;
192
+ --accent: #58b78e; --accent-soft: #1e3a2f;
193
+ --brass: #c9a45c; --warn: #d98a63; --warn-soft: #3a2820;
194
+ --code-bg: #101613;
195
+ }
196
+ }
197
+ * { box-sizing: border-box; }
198
+ body {
199
+ margin: 0; background: var(--paper); color: var(--ink);
200
+ font-family: system-ui, sans-serif; font-size: 15px; line-height: 1.5;
201
+ }
202
+ h1, h2, h3 { font-family: "Iowan Old Style", Georgia, serif; line-height: 1.2; }
203
+ h1 { font-size: 1.6rem; margin: 0; }
204
+ h2 { font-size: 1.25rem; margin: 0 0 0.75rem; }
205
+ code, .mono, pre { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.86em; }
206
+ code { background: var(--code-bg); padding: 0.1em 0.35em; border-radius: 4px; }
207
+ main { max-width: 1180px; margin: 0 auto; padding: 1rem 1.25rem 4rem; }
208
+ section {
209
+ background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
210
+ padding: 1.1rem 1.25rem 1.35rem; margin-top: 1.25rem;
211
+ }
212
+ .eyebrow {
213
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
214
+ text-transform: uppercase; letter-spacing: 0.14em; font-size: 0.68rem;
215
+ color: var(--brass); margin: 0 0 0.35rem;
216
+ }
217
+ .soft { color: var(--ink-soft); }
218
+ header.page { padding: 1.4rem 0 0; }
219
+ .liveline {
220
+ display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
221
+ color: var(--ink-soft); font-size: 0.85rem; margin-top: 0.35rem;
222
+ }
223
+ .dot {
224
+ width: 9px; height: 9px; border-radius: 50%; background: var(--accent);
225
+ display: inline-block; animation: pulse 2s ease-in-out infinite;
226
+ }
227
+ @keyframes pulse { 50% { opacity: 0.3; } }
228
+ @media (prefers-reduced-motion: reduce) { .dot { animation: none; } }
229
+ body.stale .dot { background: var(--warn); animation: none; }
230
+ body.paused .dot { background: var(--ink-soft); animation: none; }
231
+ #pause {
232
+ font: inherit; font-size: 0.8rem; color: var(--ink); background: var(--surface);
233
+ border: 1px solid var(--line); border-radius: 6px; padding: 0.15rem 0.6rem; cursor: pointer;
234
+ }
235
+ #pause:hover { border-color: var(--accent); color: var(--accent); }
236
+ #stale-note { color: var(--warn); display: none; }
237
+ body.stale #stale-note { display: inline; }
238
+ .pill {
239
+ display: inline-block; padding: 0.05em 0.55em; border-radius: 999px;
240
+ font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.72rem;
241
+ white-space: nowrap; vertical-align: middle;
242
+ }
243
+ .pill-ok { background: var(--accent-soft); color: var(--accent); }
244
+ .pill-warn { background: var(--warn-soft); color: var(--warn); }
245
+ .pill-muted { background: var(--line); color: var(--ink-soft); }
246
+ .stage-strip {
247
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
248
+ gap: 0.6rem; margin-top: 0.9rem;
249
+ }
250
+ .stage { border: 1px solid var(--line); border-radius: 8px; padding: 0.6rem 0.7rem;
251
+ background: var(--surface); }
252
+ .stage-done { border-color: var(--accent); }
253
+ .stage-name {
254
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
255
+ text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.68rem;
256
+ color: var(--ink-soft); margin-bottom: 0.25rem;
257
+ }
258
+ .stage-detail { font-size: 0.8rem; color: var(--ink-soft); margin-top: 0.3rem; }
259
+ .stage-hint { margin-top: 0.3rem; font-size: 0.72rem; overflow-x: auto; }
260
+ .problem-cards {
261
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 0.6rem;
262
+ }
263
+ .problem-card {
264
+ display: flex; align-items: baseline; gap: 0.5rem;
265
+ border: 1px solid var(--warn); border-radius: 8px; padding: 0.55rem 0.7rem;
266
+ background: var(--warn-soft); color: var(--ink);
267
+ }
268
+ .problem-count {
269
+ font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 1.15rem;
270
+ font-variant-numeric: tabular-nums; color: var(--warn); font-weight: 600;
271
+ }
272
+ .problem-label { font-size: 0.82rem; }
273
+ .all-clear { color: var(--accent); margin: 0; }
274
+ .placeholder { color: var(--ink-soft); margin: 0; }
275
+ .controls { display: flex; gap: 1rem; align-items: center; margin-bottom: 0.7rem; flex-wrap: wrap; }
276
+ .controls input[type="search"] {
277
+ background: var(--paper); color: var(--ink); border: 1px solid var(--line);
278
+ border-radius: 6px; padding: 0.35rem 0.6rem; min-width: 240px; font: inherit;
279
+ }
280
+ .controls label { font-size: 0.85rem; color: var(--ink-soft); }
281
+ .table-scroll { overflow-x: auto; }
282
+ .paper-table { min-width: 900px; border-top: 1px solid var(--line); }
283
+ .paper-row { border-bottom: 1px solid var(--line); }
284
+ .paper-row summary, .paper-head {
285
+ display: grid; grid-template-columns: 2.2fr 1.2fr 2.6fr 1.1fr 1.6fr;
286
+ gap: 0.7rem; padding: 0.45rem 0.3rem; align-items: baseline; cursor: pointer;
287
+ list-style: none;
288
+ }
289
+ .paper-head { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.1em;
290
+ color: var(--ink-soft); font-family: ui-monospace, "SF Mono", Menlo, monospace;
291
+ cursor: default; }
292
+ .paper-row summary::-webkit-details-marker { display: none; }
293
+ .paper-row summary:hover { background: var(--code-bg); }
294
+ .col { overflow: hidden; text-overflow: ellipsis; }
295
+ .col .mono { font-variant-numeric: tabular-nums; }
296
+ .col-notes { font-size: 0.8rem; color: var(--warn); }
297
+ .cell-title { font-size: 0.88rem; }
298
+ .paper-body { padding: 0.5rem 0.75rem 1rem; background: var(--code-bg); border-radius: 6px;
299
+ margin: 0 0.3rem 0.7rem; }
300
+ .kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.15rem 1rem; margin: 0.5rem 0; }
301
+ .kv dt { color: var(--ink-soft); font-size: 0.8rem; }
302
+ .kv dd { margin: 0; }
303
+ .md-preview {
304
+ max-height: 260px; overflow: auto; background: var(--paper);
305
+ border: 1px solid var(--line); border-radius: 6px; padding: 0.6rem;
306
+ white-space: pre-wrap; word-break: break-word; margin: 0.25rem 0;
307
+ }
308
+ table { border-collapse: collapse; width: 100%; font-size: 0.88rem; }
309
+ th, td { text-align: left; padding: 0.35rem 0.6rem; border-bottom: 1px solid var(--line);
310
+ vertical-align: top; }
311
+ th { font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.1em;
312
+ color: var(--ink-soft); font-family: ui-monospace, "SF Mono", Menlo, monospace; }
313
+ td.num { font-variant-numeric: tabular-nums; text-align: right;
314
+ font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 0.82rem; }
315
+ footer { margin-top: 2rem; color: var(--ink-soft); font-size: 0.8rem; }
316
+ </style></head><body>
317
+ <main>
318
+ <header class="page">
319
+ <p class="eyebrow">citegraph · live monitor</p>
320
+ <h1 id="title">out</h1>
321
+ <div class="liveline">
322
+ <span class="dot" id="dot"></span>
323
+ <span class="mono" id="outdir"></span>
324
+ <span id="ago">connecting…</span>
325
+ <span id="stale-note">server stopped? retrying…</span>
326
+ <button id="pause" type="button">pause</button>
327
+ </div>
328
+ <div class="stage-strip" id="stages"></div>
329
+ </header>
330
+ <section>
331
+ <p class="eyebrow">summary</p>
332
+ <h2>Problems</h2>
333
+ <div id="problems"></div>
334
+ </section>
335
+ <section>
336
+ <p class="eyebrow">activity</p>
337
+ <h2>Recent files</h2>
338
+ <div id="recent" class="table-scroll"></div>
339
+ </section>
340
+ <section>
341
+ <p class="eyebrow">per-paper</p>
342
+ <h2>Papers</h2>
343
+ <div class="controls">
344
+ <input id="filter-text" type="search" placeholder="filter by stem or title…">
345
+ <label><input id="filter-problems" type="checkbox"> problems only</label>
346
+ <span id="paper-count" class="soft"></span>
347
+ </div>
348
+ <div id="papers"></div>
349
+ </section>
350
+ <footer>read-only MVP — see <code>docs/UI_ARCHITECTURE.md</code> for the full
351
+ application plan.</footer>
352
+ </main>
353
+ <script>
354
+ (function () {
355
+ 'use strict';
356
+ var POLL_MS = 2000;
357
+ var paused = false;
358
+ var lastText = null;
359
+ var lastFetchedAt = null;
360
+ var openStems = {}; // stem -> true while its row is expanded
361
+ var mdCache = {}; // stem -> fetched markdown preview (or pending marker)
362
+
363
+ var PILL = {
364
+ ok: 'ok', done: 'ok',
365
+ zero: 'warn', failed: 'warn', corrupt: 'warn', error: 'warn',
366
+ 'image-only': 'warn', missing: 'warn', partial: 'warn',
367
+ pending: 'muted', skipped: 'muted', not_run: 'muted', optional_not_run: 'muted'
368
+ };
369
+
370
+ function el(tag, cls, text) {
371
+ var node = document.createElement(tag);
372
+ if (cls) { node.className = cls; }
373
+ if (text !== undefined && text !== null) { node.textContent = String(text); }
374
+ return node;
375
+ }
376
+ function pill(label, status) {
377
+ return el('span', 'pill pill-' + (PILL[status] || 'muted'), label);
378
+ }
379
+ function yearStr(y) { return y ? String(y) : '?'; }
380
+
381
+ function renderStages(stages) {
382
+ var strip = document.getElementById('stages');
383
+ strip.textContent = '';
384
+ stages.forEach(function (s) {
385
+ var cell = el('div', 'stage' + (s.status === 'done' ? ' stage-done' : ''));
386
+ cell.appendChild(el('div', 'stage-name', s.label));
387
+ var mark = { done: '\\u2713 done', partial: 'partial', not_run: 'not run' }[s.status]
388
+ || 'optional';
389
+ cell.appendChild(pill(mark, s.status));
390
+ cell.appendChild(el('div', 'stage-detail', s.detail));
391
+ if (s.hint) {
392
+ var hint = el('div', 'stage-hint');
393
+ hint.appendChild(el('code', null, s.hint));
394
+ cell.appendChild(hint);
395
+ }
396
+ strip.appendChild(cell);
397
+ });
398
+ }
399
+
400
+ function renderProblems(problems) {
401
+ var box = document.getElementById('problems');
402
+ box.textContent = '';
403
+ var cards = (problems && problems.cards) || [];
404
+ if (!cards.length) {
405
+ box.appendChild(el('p', 'all-clear',
406
+ 'No problems detected at the stages run so far.'));
407
+ return;
408
+ }
409
+ var grid = el('div', 'problem-cards');
410
+ cards.forEach(function (c) {
411
+ var card = el('div', 'problem-card');
412
+ card.appendChild(el('span', 'problem-count', c.count));
413
+ card.appendChild(el('span', 'problem-label', c.label));
414
+ grid.appendChild(card);
415
+ });
416
+ box.appendChild(grid);
417
+ }
418
+
419
+ function renderRecent(files) {
420
+ var box = document.getElementById('recent');
421
+ box.textContent = '';
422
+ if (!files || !files.length) {
423
+ box.appendChild(el('p', 'placeholder', 'No files yet.'));
424
+ return;
425
+ }
426
+ var table = el('table');
427
+ var thead = el('thead'); var hr = el('tr');
428
+ ['file', 'size', 'modified (UTC)'].forEach(function (h) {
429
+ hr.appendChild(el('th', null, h));
430
+ });
431
+ thead.appendChild(hr); table.appendChild(thead);
432
+ var tbody = el('tbody');
433
+ files.forEach(function (f) {
434
+ var tr = el('tr');
435
+ var pathCell = el('td'); pathCell.appendChild(el('span', 'mono', f.path));
436
+ tr.appendChild(pathCell);
437
+ tr.appendChild(el('td', 'num', f.size.toLocaleString()));
438
+ tr.appendChild(el('td', 'num', f.mtime.replace('T', ' ').replace('+00:00', '')));
439
+ tbody.appendChild(tr);
440
+ });
441
+ table.appendChild(tbody);
442
+ box.appendChild(table);
443
+ }
444
+
445
+ function loadMarkdown(stem, pre) {
446
+ if (mdCache[stem] !== undefined) { pre.textContent = mdCache[stem]; return; }
447
+ mdCache[stem] = 'loading\\u2026';
448
+ fetch('/api/markdown?stem=' + encodeURIComponent(stem))
449
+ .then(function (r) { return r.ok ? r.text() : '(no markdown preview)'; })
450
+ .catch(function () { return '(no markdown preview)'; })
451
+ .then(function (text) {
452
+ mdCache[stem] = text;
453
+ pre.textContent = text;
454
+ });
455
+ pre.textContent = mdCache[stem];
456
+ }
457
+
458
+ function paperBody(p) {
459
+ var body = el('div', 'paper-body');
460
+ if (p.metadata.status === 'ok') {
461
+ var kv = el('dl', 'kv');
462
+ [['Title', p.metadata.title], ['Authors', p.metadata.authors],
463
+ ['Journal', p.metadata.journal], ['Year', yearStr(p.metadata.year)]]
464
+ .forEach(function (pair) {
465
+ kv.appendChild(el('dt', null, pair[0]));
466
+ kv.appendChild(el('dd', null, pair[1]));
467
+ });
468
+ body.appendChild(kv);
469
+ }
470
+ if (p.refs_preview && p.refs_preview.length) {
471
+ body.appendChild(el('h3', null,
472
+ 'Extracted references (first ' + p.refs_preview.length + ')'));
473
+ var ol = el('ol');
474
+ p.refs_preview.forEach(function (r) {
475
+ var li = el('li', null, r.title + ' ');
476
+ li.appendChild(el('span', 'soft',
477
+ '\\u2014 ' + r.authors + ' (' + yearStr(r.year) + ')'));
478
+ ol.appendChild(li);
479
+ });
480
+ if (p.refs_more) { ol.appendChild(el('li', 'soft', '\\u2026and ' + p.refs_more + ' more')); }
481
+ body.appendChild(ol);
482
+ }
483
+ if (p.markdown.status === 'ok' || p.markdown.status === 'image-only') {
484
+ body.appendChild(el('h3', null, 'Markdown preview'));
485
+ var pre = el('pre', 'md-preview', '');
486
+ body.appendChild(pre);
487
+ loadMarkdown(p.stem, pre);
488
+ }
489
+ if (!body.childNodes.length) {
490
+ body.appendChild(el('p', 'soft', 'Nothing cached for this paper yet.'));
491
+ }
492
+ return body;
493
+ }
494
+
495
+ function paperRow(p) {
496
+ var details = el('details', 'paper-row');
497
+ details.dataset.stem = p.stem;
498
+ details.dataset.title = p.metadata.title || '';
499
+ details.dataset.problem = p.problem ? '1' : '0';
500
+ var summary = el('summary');
501
+ var stemCol = el('span', 'col mono', p.stem);
502
+ var mdCol = el('span', 'col');
503
+ if (p.markdown.chars !== null && p.markdown.chars !== undefined) {
504
+ mdCol.appendChild(el('span', 'mono', p.markdown.chars.toLocaleString() + ' '));
505
+ }
506
+ mdCol.appendChild(pill(p.markdown.status, p.markdown.status));
507
+ var metaCol = el('span', 'col');
508
+ metaCol.appendChild(pill(p.metadata.status, p.metadata.status));
509
+ if (p.metadata.status === 'ok') {
510
+ var t = el('span', 'cell-title', ' ' + p.metadata.title + ' ');
511
+ t.appendChild(el('span', 'soft', '(' + yearStr(p.metadata.year) + ')'));
512
+ metaCol.appendChild(t);
513
+ }
514
+ var refsCol = el('span', 'col');
515
+ if (p.references.count !== null && p.references.count !== undefined) {
516
+ refsCol.appendChild(el('span', 'mono', p.references.count + ' '));
517
+ }
518
+ refsCol.appendChild(pill(p.references.status, p.references.status));
519
+ var notesCol = el('span', 'col col-notes', (p.notes || []).join('; '));
520
+ [stemCol, mdCol, metaCol, refsCol, notesCol].forEach(function (c) {
521
+ summary.appendChild(c);
522
+ });
523
+ details.appendChild(summary);
524
+ if (openStems[p.stem]) {
525
+ details.open = true;
526
+ details.appendChild(paperBody(p));
527
+ }
528
+ details.addEventListener('toggle', function () {
529
+ openStems[p.stem] = details.open;
530
+ if (details.open && details.childNodes.length === 1) {
531
+ details.appendChild(paperBody(p));
532
+ }
533
+ });
534
+ return details;
535
+ }
536
+
537
+ function renderPapers(papers) {
538
+ var box = document.getElementById('papers');
539
+ box.textContent = '';
540
+ if (!papers.length) {
541
+ box.appendChild(el('p', 'placeholder',
542
+ 'Nothing processed yet \\u2014 waiting for citegraph convert output\\u2026'));
543
+ applyFilter();
544
+ return;
545
+ }
546
+ var scroll = el('div', 'table-scroll');
547
+ var table = el('div', 'paper-table');
548
+ var head = el('div', 'paper-row paper-head');
549
+ ['stem', 'conversion', 'metadata', 'references', 'notes'].forEach(function (h) {
550
+ head.appendChild(el('span', 'col', h));
551
+ });
552
+ table.appendChild(head);
553
+ papers.forEach(function (p) { table.appendChild(paperRow(p)); });
554
+ scroll.appendChild(table);
555
+ box.appendChild(scroll);
556
+ applyFilter();
557
+ }
558
+
559
+ var q = document.getElementById('filter-text');
560
+ var cb = document.getElementById('filter-problems');
561
+ function applyFilter() {
562
+ var needle = q.value.trim().toLowerCase();
563
+ var rows = document.querySelectorAll('details.paper-row');
564
+ var shown = 0;
565
+ rows.forEach(function (row) {
566
+ var hay = (row.dataset.stem + ' ' + row.dataset.title).toLowerCase();
567
+ var ok = (!needle || hay.indexOf(needle) !== -1) &&
568
+ (!cb.checked || row.dataset.problem === '1');
569
+ row.hidden = !ok;
570
+ if (ok) { shown += 1; }
571
+ });
572
+ document.getElementById('paper-count').textContent =
573
+ 'showing ' + shown + ' of ' + rows.length;
574
+ }
575
+ q.addEventListener('input', applyFilter);
576
+ cb.addEventListener('change', applyFilter);
577
+
578
+ function render(data) {
579
+ document.getElementById('title').textContent =
580
+ data.out_dir.split('/').filter(Boolean).pop() || 'out';
581
+ document.getElementById('outdir').textContent = data.out_dir;
582
+ renderStages(data.stages || []);
583
+ renderProblems(data.problems);
584
+ renderRecent(data.recent_files);
585
+ renderPapers(data.papers || []);
586
+ }
587
+
588
+ function tick() {
589
+ if (paused) { return; }
590
+ fetch('/api/data')
591
+ .then(function (r) { return r.text(); })
592
+ .then(function (text) {
593
+ document.body.classList.remove('stale');
594
+ lastFetchedAt = Date.now();
595
+ if (text !== lastText) {
596
+ lastText = text;
597
+ render(JSON.parse(text));
598
+ }
599
+ })
600
+ .catch(function () { document.body.classList.add('stale'); });
601
+ }
602
+
603
+ function updateAgo() {
604
+ var ago = document.getElementById('ago');
605
+ if (paused) { ago.textContent = 'paused'; return; }
606
+ if (lastFetchedAt === null) { return; }
607
+ ago.textContent = 'updated ' + Math.round((Date.now() - lastFetchedAt) / 1000) + 's ago';
608
+ }
609
+
610
+ document.getElementById('pause').addEventListener('click', function () {
611
+ paused = !paused;
612
+ document.body.classList.toggle('paused', paused);
613
+ this.textContent = paused ? 'resume' : 'pause';
614
+ updateAgo();
615
+ if (!paused) { tick(); }
616
+ });
617
+
618
+ setInterval(tick, POLL_MS);
619
+ setInterval(updateAgo, 1000);
620
+ tick();
621
+ })();
622
+ </script>
623
+ </body></html>
624
+ """