labnotes 0.0.7__tar.gz → 0.0.9__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.
- {labnotes-0.0.7 → labnotes-0.0.9}/PKG-INFO +3 -1
- {labnotes-0.0.7 → labnotes-0.0.9}/pyproject.toml +2 -0
- labnotes-0.0.9/src/labnotes/asset_url.py +19 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/create_app.py +35 -2
- labnotes-0.0.9/src/labnotes/data_html.py +12 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/load_experiment.py +17 -3
- labnotes-0.0.9/src/labnotes/notebook_kernel.py +59 -0
- labnotes-0.0.9/src/labnotes/output_html.py +25 -0
- labnotes-0.0.9/src/labnotes/python_blocks.py +12 -0
- labnotes-0.0.9/src/labnotes/render_markdown.py +40 -0
- labnotes-0.0.9/src/labnotes/saved_outputs.py +8 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/static/labnotes.css +25 -0
- labnotes-0.0.9/src/labnotes/static/run-cell.js +23 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/templates/base.html +1 -1
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/templates/experiment.html +2 -1
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/templates/index.html +2 -2
- {labnotes-0.0.7 → labnotes-0.0.9}/uv.lock +511 -0
- labnotes-0.0.7/src/labnotes/render_markdown.py +0 -9
- {labnotes-0.0.7 → labnotes-0.0.9}/.gitignore +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/CHANGELOG.md +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/MIGRATIONS.md +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/README.md +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/__init__.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/cell_progress.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/cli.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/dump_experiments.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/format_value.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/list_experiments.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/list_runs.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/models.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/open_experiments.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/open_runs.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/parse_run_view.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/pivot_cells.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/read_design.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/read_note.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/run_columns.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/serve_app.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/sort_href.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/study_progress.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/summarize_design.py +0 -0
- {labnotes-0.0.7 → labnotes-0.0.9}/src/labnotes/templates/macros.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: labnotes
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.9
|
|
4
4
|
Summary: Browse experiment designs, notes, and run data from a folder.
|
|
5
5
|
Project-URL: Repository, https://github.com/thekevinscott/labnotes
|
|
6
6
|
Author-email: Kevin Scott <me@thekevinscott.com>
|
|
@@ -8,7 +8,9 @@ License-Expression: MIT
|
|
|
8
8
|
Requires-Python: >=3.12
|
|
9
9
|
Requires-Dist: click>=8.5.0
|
|
10
10
|
Requires-Dist: dirsql>=0.4.110
|
|
11
|
+
Requires-Dist: ipykernel>=7.4.0
|
|
11
12
|
Requires-Dist: jinja2>=3.1.6
|
|
13
|
+
Requires-Dist: jupyter-client>=8.10.0
|
|
12
14
|
Requires-Dist: markdown-it-py>=4.2.0
|
|
13
15
|
Requires-Dist: pyyaml>=6.0.3
|
|
14
16
|
Requires-Dist: starlette>=1.8.0
|
|
@@ -13,7 +13,9 @@ authors = [{ name = "Kevin Scott", email = "me@thekevinscott.com" }]
|
|
|
13
13
|
dependencies = [
|
|
14
14
|
"click>=8.5.0",
|
|
15
15
|
"dirsql>=0.4.110",
|
|
16
|
+
"ipykernel>=7.4.0",
|
|
16
17
|
"jinja2>=3.1.6",
|
|
18
|
+
"jupyter-client>=8.10.0",
|
|
17
19
|
"markdown-it-py>=4.2.0",
|
|
18
20
|
"pyyaml>=6.0.3",
|
|
19
21
|
"starlette>=1.8.0",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
from functools import cache
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from jinja2 import pass_context
|
|
7
|
+
|
|
8
|
+
STATIC = Path(__file__).parent / "static"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@cache
|
|
12
|
+
def _digest(path: str) -> str:
|
|
13
|
+
return hashlib.sha256((STATIC / path).read_bytes()).hexdigest()[:12]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pass_context
|
|
17
|
+
def asset_url(context: Any, path: str) -> str:
|
|
18
|
+
# Browsers (Safari especially) hold onto static files across releases.
|
|
19
|
+
return f"{context['request'].url_for('static', path=path)}?v={_digest(path)}"
|
|
@@ -7,25 +7,33 @@ from typing import cast
|
|
|
7
7
|
from starlette.applications import Starlette
|
|
8
8
|
from starlette.exceptions import HTTPException
|
|
9
9
|
from starlette.requests import Request
|
|
10
|
-
from starlette.responses import Response
|
|
10
|
+
from starlette.responses import HTMLResponse, Response
|
|
11
11
|
from starlette.routing import Mount, Route
|
|
12
12
|
from starlette.staticfiles import StaticFiles
|
|
13
13
|
from starlette.templating import Jinja2Templates
|
|
14
14
|
|
|
15
|
+
from labnotes.asset_url import asset_url
|
|
15
16
|
from labnotes.drain_watch import drain_watch
|
|
16
17
|
from labnotes.format_value import format_value
|
|
17
18
|
from labnotes.list_experiments import list_experiments
|
|
18
19
|
from labnotes.load_experiment import load_experiment
|
|
20
|
+
from labnotes.notebook_kernel import NotebookKernel
|
|
19
21
|
from labnotes.open_experiments import open_experiments
|
|
20
22
|
from labnotes.open_runs import open_runs
|
|
21
23
|
from labnotes.pivot_cells import pivot_cells
|
|
24
|
+
from labnotes.python_blocks import python_blocks
|
|
25
|
+
from labnotes.saved_outputs import saved_outputs
|
|
26
|
+
from labnotes.sql_quote import quote_string
|
|
22
27
|
|
|
23
28
|
HERE = Path(__file__).parent
|
|
24
29
|
templates = Jinja2Templates(directory=HERE / "templates")
|
|
25
30
|
templates.env.filters["value"] = format_value
|
|
31
|
+
templates.env.globals["asset"] = asset_url
|
|
26
32
|
|
|
27
33
|
|
|
28
34
|
def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
|
|
35
|
+
kernels: dict[tuple[str, str], NotebookKernel] = {}
|
|
36
|
+
|
|
29
37
|
@asynccontextmanager
|
|
30
38
|
async def lifespan(app: Starlette) -> AsyncIterator[None]:
|
|
31
39
|
app.state.experiments = open_experiments(experiments_folder)
|
|
@@ -39,6 +47,8 @@ def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
|
|
|
39
47
|
yield
|
|
40
48
|
for watcher in watchers:
|
|
41
49
|
watcher.cancel()
|
|
50
|
+
for kernel in kernels.values():
|
|
51
|
+
await kernel.stop()
|
|
42
52
|
|
|
43
53
|
async def index(request: Request) -> Response:
|
|
44
54
|
state = request.app.state
|
|
@@ -51,11 +61,13 @@ def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
|
|
|
51
61
|
|
|
52
62
|
async def experiment(request: Request) -> Response:
|
|
53
63
|
state = request.app.state
|
|
64
|
+
name = request.path_params["name"]
|
|
54
65
|
loaded = await load_experiment(
|
|
55
66
|
state.experiments,
|
|
56
67
|
state.runs,
|
|
57
|
-
|
|
68
|
+
name,
|
|
58
69
|
request.query_params,
|
|
70
|
+
lambda note, content: saved_outputs(kernels.get((name, note)), content),
|
|
59
71
|
)
|
|
60
72
|
if loaded is None:
|
|
61
73
|
raise HTTPException(404, "No such experiment")
|
|
@@ -67,10 +79,31 @@ def create_app(experiments_folder: Path, data_folder: Path) -> Starlette:
|
|
|
67
79
|
{"experiment": loaded, "pivot": pivot_cells(varies, cells)},
|
|
68
80
|
)
|
|
69
81
|
|
|
82
|
+
async def run_block(request: Request) -> Response:
|
|
83
|
+
if request.headers.get("content-type") != "application/json":
|
|
84
|
+
raise HTTPException(415, "Send JSON")
|
|
85
|
+
name = request.path_params["name"]
|
|
86
|
+
note = request.path_params["note"]
|
|
87
|
+
found = await request.app.state.experiments.query(
|
|
88
|
+
"SELECT content FROM notes"
|
|
89
|
+
f" WHERE experiment = {quote_string(name)} AND name = {quote_string(note)}"
|
|
90
|
+
)
|
|
91
|
+
if not found:
|
|
92
|
+
raise HTTPException(404, "No such note")
|
|
93
|
+
[row] = found
|
|
94
|
+
blocks = python_blocks(str(row["content"]))
|
|
95
|
+
block = (await request.json()).get("block")
|
|
96
|
+
if not isinstance(block, int) or not 0 <= block < len(blocks):
|
|
97
|
+
raise HTTPException(400, "No such block")
|
|
98
|
+
if (name, note) not in kernels:
|
|
99
|
+
kernels[(name, note)] = NotebookKernel(data_folder)
|
|
100
|
+
return HTMLResponse(await kernels[(name, note)].run(blocks[block]))
|
|
101
|
+
|
|
70
102
|
return Starlette(
|
|
71
103
|
routes=[
|
|
72
104
|
Route("/", index),
|
|
73
105
|
Route("/experiments/{name}", experiment),
|
|
106
|
+
Route("/experiments/{name}/notes/{note}/run", run_block, methods=["POST"]),
|
|
74
107
|
Mount("/static", StaticFiles(directory=HERE / "static"), name="static"),
|
|
75
108
|
],
|
|
76
109
|
lifespan=lifespan,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from html import escape
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def data_html(data: Mapping[str, str]) -> str:
|
|
6
|
+
# Rich output is trusted: it comes from code in the user's own notes.
|
|
7
|
+
for mime in ("text/html", "image/svg+xml"):
|
|
8
|
+
if mime in data:
|
|
9
|
+
return data[mime]
|
|
10
|
+
if "image/png" in data:
|
|
11
|
+
return f'<img alt="" src="data:image/png;base64,{data["image/png"]}">'
|
|
12
|
+
return f"<pre>{escape(data.get('text/plain', ''))}</pre>"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
-
from collections.abc import Mapping
|
|
2
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
3
3
|
from typing import cast
|
|
4
4
|
|
|
5
5
|
from dirsql import DirSQL
|
|
@@ -14,8 +14,16 @@ from labnotes.study_progress import study_progress
|
|
|
14
14
|
from labnotes.summarize_design import summarize_design
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _no_outputs(note: str, content: str) -> Sequence[str]:
|
|
18
|
+
return ()
|
|
19
|
+
|
|
20
|
+
|
|
17
21
|
async def load_experiment(
|
|
18
|
-
experiments: DirSQL,
|
|
22
|
+
experiments: DirSQL,
|
|
23
|
+
runs: DirSQL,
|
|
24
|
+
name: str,
|
|
25
|
+
params: Mapping[str, str],
|
|
26
|
+
outputs_for: Callable[[str, str], Sequence[str]] = _no_outputs,
|
|
19
27
|
) -> dict[str, object] | None:
|
|
20
28
|
found = await experiments.query(
|
|
21
29
|
f"SELECT name, study, design FROM experiments WHERE name = {quote_string(name)}"
|
|
@@ -45,7 +53,13 @@ async def load_experiment(
|
|
|
45
53
|
**summary,
|
|
46
54
|
**progress,
|
|
47
55
|
"notes": [
|
|
48
|
-
{
|
|
56
|
+
{
|
|
57
|
+
"name": note["name"],
|
|
58
|
+
"html": render_markdown(
|
|
59
|
+
str(note["content"]),
|
|
60
|
+
outputs_for(str(note["name"]), str(note["content"])),
|
|
61
|
+
),
|
|
62
|
+
}
|
|
49
63
|
for note in notes
|
|
50
64
|
],
|
|
51
65
|
"runs_list": listed,
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from jupyter_client.asynchronous.client import AsyncKernelClient
|
|
6
|
+
from jupyter_client.manager import AsyncKernelManager
|
|
7
|
+
|
|
8
|
+
from labnotes.output_html import output_html
|
|
9
|
+
|
|
10
|
+
QUIET = {"status", "execute_input"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def block_key(code: str) -> str:
|
|
14
|
+
return hashlib.sha256(code.encode()).hexdigest()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NotebookKernel:
|
|
18
|
+
"""One live Python kernel per notes file; blocks run only when asked."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, cwd: Path) -> None:
|
|
21
|
+
self.cwd = cwd
|
|
22
|
+
self.outputs: dict[str, str] = {}
|
|
23
|
+
self._lock = asyncio.Lock()
|
|
24
|
+
self._manager: AsyncKernelManager | None = None
|
|
25
|
+
self._client: AsyncKernelClient | None = None
|
|
26
|
+
|
|
27
|
+
async def _start(self) -> None:
|
|
28
|
+
self._manager = AsyncKernelManager(kernel_name="python3")
|
|
29
|
+
await self._manager.start_kernel(cwd=str(self.cwd))
|
|
30
|
+
self._client = self._manager.client()
|
|
31
|
+
self._client.start_channels()
|
|
32
|
+
await self._client.wait_for_ready(timeout=60)
|
|
33
|
+
|
|
34
|
+
async def run(self, code: str) -> str:
|
|
35
|
+
async with self._lock:
|
|
36
|
+
if self._client is None:
|
|
37
|
+
await self._start()
|
|
38
|
+
assert self._client is not None
|
|
39
|
+
messages: list[dict[str, object]] = []
|
|
40
|
+
await self._client.execute_interactive(
|
|
41
|
+
code,
|
|
42
|
+
output_hook=lambda message: messages.append(message),
|
|
43
|
+
allow_stdin=False,
|
|
44
|
+
)
|
|
45
|
+
html = output_html(
|
|
46
|
+
[
|
|
47
|
+
{"msg_type": message["msg_type"], "content": message["content"]}
|
|
48
|
+
for message in messages
|
|
49
|
+
if message["msg_type"] not in QUIET
|
|
50
|
+
]
|
|
51
|
+
)
|
|
52
|
+
self.outputs[block_key(code)] = html
|
|
53
|
+
return html
|
|
54
|
+
|
|
55
|
+
async def stop(self) -> None:
|
|
56
|
+
if self._client is not None:
|
|
57
|
+
self._client.stop_channels()
|
|
58
|
+
if self._manager is not None:
|
|
59
|
+
await self._manager.shutdown_kernel(now=True)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections.abc import Mapping, Sequence
|
|
3
|
+
from html import escape
|
|
4
|
+
|
|
5
|
+
from labnotes.data_html import data_html
|
|
6
|
+
|
|
7
|
+
ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def output_html(outputs: Sequence[Mapping[str, object]]) -> str:
|
|
11
|
+
parts = []
|
|
12
|
+
for output in outputs:
|
|
13
|
+
kind = output["msg_type"]
|
|
14
|
+
content = output["content"]
|
|
15
|
+
assert isinstance(content, Mapping)
|
|
16
|
+
if kind == "stream":
|
|
17
|
+
parts.append(
|
|
18
|
+
f'<pre class="{escape(content["name"])}">{escape(content["text"])}</pre>'
|
|
19
|
+
)
|
|
20
|
+
elif kind in ("execute_result", "display_data"):
|
|
21
|
+
parts.append(data_html(content["data"]))
|
|
22
|
+
elif kind == "error":
|
|
23
|
+
trace = ANSI.sub("", "\n".join(content["traceback"]))
|
|
24
|
+
parts.append(f'<pre class="error">{escape(trace)}</pre>')
|
|
25
|
+
return "".join(parts)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from markdown_it import MarkdownIt
|
|
2
|
+
from markdown_it.token import Token
|
|
3
|
+
|
|
4
|
+
_parser = MarkdownIt("commonmark")
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def is_python(token: Token) -> bool:
|
|
8
|
+
return token.type == "fence" and token.info.split()[:1] == ["python"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def python_blocks(text: str) -> list[str]:
|
|
12
|
+
return [token.content for token in _parser.parse(text) if is_python(token)]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from collections.abc import MutableMapping, Sequence
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from markdown_it import MarkdownIt
|
|
5
|
+
from markdown_it.renderer import RendererHTML
|
|
6
|
+
from markdown_it.token import Token
|
|
7
|
+
|
|
8
|
+
from labnotes.python_blocks import is_python
|
|
9
|
+
|
|
10
|
+
_markdown = MarkdownIt("commonmark", {"html": False, "typographer": True}).enable(
|
|
11
|
+
["table", "strikethrough", "replacements", "smartquotes"]
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _fence(
|
|
16
|
+
self: RendererHTML,
|
|
17
|
+
tokens: Sequence[Token],
|
|
18
|
+
idx: int,
|
|
19
|
+
options: Any,
|
|
20
|
+
env: MutableMapping[str, Any],
|
|
21
|
+
) -> str:
|
|
22
|
+
code = RendererHTML.fence(self, tokens, idx, options, env)
|
|
23
|
+
if not is_python(tokens[idx]):
|
|
24
|
+
return code
|
|
25
|
+
block = env["blocks"]
|
|
26
|
+
env["blocks"] = block + 1
|
|
27
|
+
outputs = env["outputs"]
|
|
28
|
+
shown = outputs[block] if block < len(outputs) else ""
|
|
29
|
+
return (
|
|
30
|
+
f'<div class="cell" data-block="{block}">{code}'
|
|
31
|
+
'<button type="button" class="run">Run</button>'
|
|
32
|
+
f'<output class="cell-output">{shown}</output></div>\n'
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_markdown.add_render_rule("fence", _fence)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_markdown(text: str, outputs: Sequence[str] = ()) -> str:
|
|
40
|
+
return _markdown.render(text, {"blocks": 0, "outputs": outputs})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
from labnotes.notebook_kernel import NotebookKernel, block_key
|
|
2
|
+
from labnotes.python_blocks import python_blocks
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def saved_outputs(kernel: NotebookKernel | None, content: str) -> list[str]:
|
|
6
|
+
if kernel is None:
|
|
7
|
+
return []
|
|
8
|
+
return [kernel.outputs.get(block_key(code), "") for code in python_blocks(content)]
|
|
@@ -406,3 +406,28 @@ h2 .clear { font-family: var(--sans); font-size: 0.82rem; margin-left: 0.8rem; c
|
|
|
406
406
|
.ledger .design { display: none; }
|
|
407
407
|
.progress-col { width: auto; }
|
|
408
408
|
}
|
|
409
|
+
|
|
410
|
+
/* Runnable python blocks in notes */
|
|
411
|
+
.cell { position: relative; margin: 1em 0; }
|
|
412
|
+
.cell pre { margin: 0; }
|
|
413
|
+
.cell .run {
|
|
414
|
+
position: absolute;
|
|
415
|
+
top: 0.5rem;
|
|
416
|
+
right: 0.5rem;
|
|
417
|
+
font: inherit;
|
|
418
|
+
font-family: var(--sans);
|
|
419
|
+
font-size: 0.75rem;
|
|
420
|
+
padding: 0.15rem 0.6rem;
|
|
421
|
+
border: 1px solid var(--rule-strong);
|
|
422
|
+
border-radius: 5px;
|
|
423
|
+
background: var(--surface);
|
|
424
|
+
color: var(--ink-2);
|
|
425
|
+
cursor: pointer;
|
|
426
|
+
}
|
|
427
|
+
.cell .run:hover { color: var(--accent); border-color: var(--accent); }
|
|
428
|
+
.cell .run:disabled { cursor: progress; opacity: 0.6; }
|
|
429
|
+
.cell-output { display: block; }
|
|
430
|
+
.cell-output:not(:empty) { margin-top: 0.4rem; padding-left: 0.9rem; border-left: 2px solid var(--accent-soft); overflow-x: auto; }
|
|
431
|
+
.cell-output pre { background: none; border: 0; padding: 0.3rem 0; }
|
|
432
|
+
.cell-output .stderr, .cell-output .error { color: var(--bad); }
|
|
433
|
+
.cell-output img, .cell-output svg { max-width: 100%; height: auto; }
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
for (const note of document.querySelectorAll(".note[data-run-url]")) {
|
|
2
|
+
for (const cell of note.querySelectorAll(".cell")) {
|
|
3
|
+
const button = cell.querySelector(".run");
|
|
4
|
+
const output = cell.querySelector(".cell-output");
|
|
5
|
+
button.addEventListener("click", async () => {
|
|
6
|
+
button.disabled = true;
|
|
7
|
+
button.textContent = "Running";
|
|
8
|
+
try {
|
|
9
|
+
const response = await fetch(note.dataset.runUrl, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: { "Content-Type": "application/json" },
|
|
12
|
+
body: JSON.stringify({ block: Number(cell.dataset.block) }),
|
|
13
|
+
});
|
|
14
|
+
output.innerHTML = response.ok
|
|
15
|
+
? await response.text()
|
|
16
|
+
: `<pre class="error">${response.status} ${response.statusText}</pre>`;
|
|
17
|
+
} finally {
|
|
18
|
+
button.disabled = false;
|
|
19
|
+
button.textContent = "Run";
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<meta name="color-scheme" content="light dark">
|
|
7
7
|
<title>{% block title %}labnotes{% endblock %}</title>
|
|
8
|
-
<link rel="stylesheet" href="{{
|
|
8
|
+
<link rel="stylesheet" href="{{ asset('labnotes.css') }}">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<header class="masthead">
|
|
@@ -111,12 +111,13 @@
|
|
|
111
111
|
<section id="notes" class="block">
|
|
112
112
|
<h2>Notes</h2>
|
|
113
113
|
{% for note in e.notes %}
|
|
114
|
-
<article class="note">
|
|
114
|
+
<article class="note" data-run-url="{{ url_for('run_block', name=e.name, note=note.name) }}">
|
|
115
115
|
<header class="note-name">{{ note.name }}</header>
|
|
116
116
|
<div class="prose">{{ note.html | safe }}</div>
|
|
117
117
|
</article>
|
|
118
118
|
{% endfor %}
|
|
119
119
|
</section>
|
|
120
|
+
<script src="{{ asset('run-cell.js') }}" defer></script>
|
|
120
121
|
{% endif %}
|
|
121
122
|
|
|
122
123
|
{% if e.study %}
|
|
@@ -49,8 +49,8 @@
|
|
|
49
49
|
{% endfor %}
|
|
50
50
|
</tbody>
|
|
51
51
|
</table>
|
|
52
|
-
<script src="{{
|
|
53
|
-
<script src="{{
|
|
52
|
+
<script src="{{ asset('view-toggle.js') }}" defer></script>
|
|
53
|
+
<script src="{{ asset('row-link.js') }}" defer></script>
|
|
54
54
|
{% else %}
|
|
55
55
|
<p class="empty">No experiments found. Each experiment is a folder holding a <code>design.yaml</code> and its markdown notes.</p>
|
|
56
56
|
{% endif %}
|