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/__init__.py +44 -0
- citegraph/_progress.py +74 -0
- citegraph/annotations.py +183 -0
- citegraph/author_export.py +294 -0
- citegraph/author_overrides.py +143 -0
- citegraph/authors.py +1997 -0
- citegraph/cli.py +926 -0
- citegraph/cocitation_stats.py +370 -0
- citegraph/config.py +73 -0
- citegraph/cost_estimation.py +259 -0
- citegraph/dedup.py +498 -0
- citegraph/enrich.py +972 -0
- citegraph/extract_metadata.py +81 -0
- citegraph/extract_references.py +376 -0
- citegraph/graph.py +660 -0
- citegraph/html_report.py +1787 -0
- citegraph/identity.py +199 -0
- citegraph/ids.py +106 -0
- citegraph/io.py +566 -0
- citegraph/jel.py +167 -0
- citegraph/journals.py +182 -0
- citegraph/llm.py +186 -0
- citegraph/pdf_to_markdown.py +394 -0
- citegraph/pipeline.py +997 -0
- citegraph/plotting.py +1214 -0
- citegraph/reports.py +214 -0
- citegraph/schemas.py +89 -0
- citegraph/webui.py +624 -0
- citegraph-0.2.0.dist-info/METADATA +870 -0
- citegraph-0.2.0.dist-info/RECORD +33 -0
- citegraph-0.2.0.dist-info/WHEEL +4 -0
- citegraph-0.2.0.dist-info/entry_points.txt +2 -0
- citegraph-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
"""
|