labnotes 0.0.54__tar.gz → 0.0.56__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.54 → labnotes-0.0.56}/PKG-INFO +1 -1
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/create_app.py +26 -4
- labnotes-0.0.56/src/labnotes/file_changes.py +11 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/list_runs.py +5 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/load_experiment.py +11 -3
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/models.py +6 -1
- labnotes-0.0.56/src/labnotes/parse_run_view.py +39 -0
- labnotes-0.0.56/src/labnotes/sort_href.py +16 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/static/experiment.js +12 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/static/labnotes.css +33 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/static/note-edit.js +10 -2
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/static/note-editor.js +58 -23
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/templates/editor_imports.html +1 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/templates/experiment.html +6 -3
- labnotes-0.0.54/src/labnotes/parse_run_view.py +0 -20
- labnotes-0.0.54/src/labnotes/sort_href.py +0 -13
- {labnotes-0.0.54 → labnotes-0.0.56}/.gitignore +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/CHANGELOG.md +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/MIGRATIONS.md +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/README.md +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/pyproject.toml +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/__init__.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/asset_url.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/cell_progress.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/check_counts.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/cli.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/context.md +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/context_guide.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/count_runs.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/create_experiment.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/data_html.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/default_config_text.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/default_counts.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/drain_watch.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/experiment_notes.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/experiment_progress.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/experiment_runs.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/experiments.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/format_duration.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/format_value.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/init.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/is_positive_int.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/list_experiments.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/load_config.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/modes.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/notebook_kernel.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/output_html.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/print_rows.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/python_blocks.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/read_config.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/read_path.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/render_markdown.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/__init__.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/describe_event.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/execute_run.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/hold_folder.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/new_run_id.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/new_stamped_run_id.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/param_args.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/param_grid.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/plan_runs.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/read_experiment_config.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/run_experiment.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/run_folder.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/run_headless.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/runs_parent.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/shell_command.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/start_experiment.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/stop_then_kill.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/wait_for_group.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/runner/write_run_json.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/save_note.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/saved_outputs.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/serve_app.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/server.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/sql_quote.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/static/row-link.js +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/static/run-cell.js +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/static/view-toggle.js +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/stop_idle_kernels.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/summarize_config.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/__init__.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/experiment_rows.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/file_prefix.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/iso_time.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/list_experiment_runs.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/open_tables.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/path_glob.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/path_pattern.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/read_experiment.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/read_file.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/read_run.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tables/summarize_experiments.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/templates/base.html +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/templates/index.html +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/templates/macros.html +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tui/__init__.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tui/dim.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tui/format_elapsed.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tui/run_app.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tui/run_tui.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/src/labnotes/tui/runs_table.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/testing-conventions.toml +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/tests/integration/conftest.py +0 -0
- {labnotes-0.0.54 → labnotes-0.0.56}/uv.lock +0 -0
|
@@ -6,7 +6,13 @@ from pathlib import Path
|
|
|
6
6
|
from starlette.applications import Starlette
|
|
7
7
|
from starlette.exceptions import HTTPException
|
|
8
8
|
from starlette.requests import Request
|
|
9
|
-
from starlette.responses import
|
|
9
|
+
from starlette.responses import (
|
|
10
|
+
HTMLResponse,
|
|
11
|
+
JSONResponse,
|
|
12
|
+
PlainTextResponse,
|
|
13
|
+
Response,
|
|
14
|
+
StreamingResponse,
|
|
15
|
+
)
|
|
10
16
|
from starlette.routing import Mount, Route
|
|
11
17
|
from starlette.staticfiles import StaticFiles
|
|
12
18
|
from starlette.templating import Jinja2Templates
|
|
@@ -14,6 +20,7 @@ from starlette.templating import Jinja2Templates
|
|
|
14
20
|
from labnotes.asset_url import asset_url
|
|
15
21
|
from labnotes.drain_watch import drain_watch
|
|
16
22
|
from labnotes.experiment_notes import experiment_notes
|
|
23
|
+
from labnotes.file_changes import file_changes
|
|
17
24
|
from labnotes.format_duration import format_duration
|
|
18
25
|
from labnotes.format_value import format_value
|
|
19
26
|
from labnotes.list_experiments import list_experiments
|
|
@@ -74,9 +81,7 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
|
|
|
74
81
|
request, "experiment.html", {"experiment": loaded}
|
|
75
82
|
)
|
|
76
83
|
|
|
77
|
-
async def
|
|
78
|
-
if request.headers.get("content-type") != "application/json":
|
|
79
|
-
raise HTTPException(415, "Send JSON")
|
|
84
|
+
async def read_note(request: Request) -> dict[str, str]:
|
|
80
85
|
name = request.path_params["name"]
|
|
81
86
|
note = request.path_params["note"]
|
|
82
87
|
found = await experiment_notes(
|
|
@@ -87,6 +92,21 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
|
|
|
87
92
|
[note] = found
|
|
88
93
|
return note
|
|
89
94
|
|
|
95
|
+
async def find_note(request: Request) -> dict[str, str]:
|
|
96
|
+
if request.headers.get("content-type") != "application/json":
|
|
97
|
+
raise HTTPException(415, "Send JSON")
|
|
98
|
+
return await read_note(request)
|
|
99
|
+
|
|
100
|
+
async def get_note(request: Request) -> Response:
|
|
101
|
+
note = await read_note(request)
|
|
102
|
+
return PlainTextResponse(note["content"], media_type="text/markdown")
|
|
103
|
+
|
|
104
|
+
async def changes(request: Request) -> Response:
|
|
105
|
+
return StreamingResponse(
|
|
106
|
+
file_changes(request.app.state.tables, request.path_params["name"]),
|
|
107
|
+
media_type="text/event-stream",
|
|
108
|
+
)
|
|
109
|
+
|
|
90
110
|
async def put_note(request: Request) -> Response:
|
|
91
111
|
note = await find_note(request)
|
|
92
112
|
body = await request.json()
|
|
@@ -119,6 +139,8 @@ def create_app(config: LabnotesConfig, kernel_idle_minutes: float) -> Starlette:
|
|
|
119
139
|
routes=[
|
|
120
140
|
Route("/", index),
|
|
121
141
|
Route("/experiments/{name}", experiment),
|
|
142
|
+
Route("/experiments/{name}/changes", changes),
|
|
143
|
+
Route("/experiments/{name}/notes/{note:path}", get_note),
|
|
122
144
|
Route("/experiments/{name}/notes/{note:path}", put_note, methods=["PUT"]),
|
|
123
145
|
Route(
|
|
124
146
|
"/experiments/{name}/notes/{note:path}/run",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import AsyncIterator
|
|
3
|
+
|
|
4
|
+
from dirsql import DirSQL
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def file_changes(tables: DirSQL, experiment: str) -> AsyncIterator[str]:
|
|
8
|
+
async for event in tables.watch():
|
|
9
|
+
row = event.row or event.old_row
|
|
10
|
+
if event.table == "files" and row and row["experiment"] == experiment:
|
|
11
|
+
yield f"data: {json.dumps(row['filename'])}\n\n"
|
|
@@ -16,6 +16,11 @@ async def list_runs(
|
|
|
16
16
|
f" lower({quote_string(value)})) > 0"
|
|
17
17
|
for column, value in view.filters.items()
|
|
18
18
|
]
|
|
19
|
+
where += [
|
|
20
|
+
f"params -> {quote_string(f'$.{quote_identifier(column)}')}"
|
|
21
|
+
f" IN ({', '.join(quote_string(value) for value in picked.values())})"
|
|
22
|
+
for column, picked in view.picked.items()
|
|
23
|
+
]
|
|
19
24
|
filtered = f" WHERE {' AND '.join(where)}" if where else ""
|
|
20
25
|
direction = "DESC" if view.descending else "ASC"
|
|
21
26
|
if view.sort == "status":
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import json
|
|
2
|
-
from collections.abc import Callable,
|
|
2
|
+
from collections.abc import Callable, Sequence
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
from typing import cast
|
|
5
5
|
|
|
@@ -9,7 +9,9 @@ from labnotes.check_counts import check_counts
|
|
|
9
9
|
from labnotes.count_runs import count_runs
|
|
10
10
|
from labnotes.experiment_notes import experiment_notes
|
|
11
11
|
from labnotes.experiment_runs import experiment_runs
|
|
12
|
+
from labnotes.format_value import format_value
|
|
12
13
|
from labnotes.list_runs import list_runs
|
|
14
|
+
from labnotes.models import QueryParams
|
|
13
15
|
from labnotes.parse_run_view import parse_run_view
|
|
14
16
|
from labnotes.python_blocks import python_blocks
|
|
15
17
|
from labnotes.render_markdown import render_markdown
|
|
@@ -27,7 +29,7 @@ async def load_experiment(
|
|
|
27
29
|
root: Path,
|
|
28
30
|
mode: str,
|
|
29
31
|
name: str,
|
|
30
|
-
params:
|
|
32
|
+
params: QueryParams,
|
|
31
33
|
outputs_for: Callable[[str, str], Sequence[str]] = _no_outputs,
|
|
32
34
|
) -> dict[str, object] | None:
|
|
33
35
|
found = await tables.query(
|
|
@@ -43,7 +45,12 @@ async def load_experiment(
|
|
|
43
45
|
source = experiment_runs(name, list(varies))
|
|
44
46
|
summary = await check_counts(tables, source, summary)
|
|
45
47
|
shown = ["id", *(key for key in varies if key != "id"), "started", "duration"]
|
|
46
|
-
|
|
48
|
+
choices = {
|
|
49
|
+
key: {format_value(value): json.dumps(value) for value in values}
|
|
50
|
+
for key, values in varies.items()
|
|
51
|
+
if key != "id"
|
|
52
|
+
}
|
|
53
|
+
view = parse_run_view(params, shown, choices)
|
|
47
54
|
[totals] = await count_runs(tables, source, str(summary["completed_run"]))
|
|
48
55
|
notes = await experiment_notes(tables, root, name)
|
|
49
56
|
return {
|
|
@@ -69,6 +76,7 @@ async def load_experiment(
|
|
|
69
76
|
),
|
|
70
77
|
"run_columns": shown,
|
|
71
78
|
"run_view": view,
|
|
79
|
+
"run_choices": choices,
|
|
72
80
|
"sort_hrefs": {
|
|
73
81
|
column: sort_href(view, column) for column in ["status", *shown]
|
|
74
82
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from collections.abc import Callable, Coroutine
|
|
2
2
|
from dataclasses import dataclass, field
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Any
|
|
4
|
+
from typing import Any, Protocol
|
|
5
5
|
|
|
6
6
|
from labnotes.default_counts import DEFAULT_COUNTS
|
|
7
7
|
from labnotes.modes import DEFAULT_MODE
|
|
@@ -24,12 +24,17 @@ Listen = Callable[[Controls], None]
|
|
|
24
24
|
Start = Callable[[Report, Listen], Coroutine[Any, Any, list[dict[str, object]]]]
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
class QueryParams(Protocol):
|
|
28
|
+
def getlist(self, key: str) -> list[str]: ...
|
|
29
|
+
|
|
30
|
+
|
|
27
31
|
@dataclass(frozen=True)
|
|
28
32
|
class RunView:
|
|
29
33
|
sort: str | None = None
|
|
30
34
|
descending: bool = False
|
|
31
35
|
status: str | None = None
|
|
32
36
|
filters: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
picked: dict[str, dict[str, str]] = field(default_factory=dict)
|
|
33
38
|
|
|
34
39
|
|
|
35
40
|
@dataclass(frozen=True)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
|
|
3
|
+
from labnotes.models import QueryParams, RunView
|
|
4
|
+
|
|
5
|
+
STATUSES = {"complete", "incomplete"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def parse_run_view(
|
|
9
|
+
params: QueryParams,
|
|
10
|
+
columns: list[str],
|
|
11
|
+
choices: Mapping[str, Mapping[str, str]],
|
|
12
|
+
) -> RunView:
|
|
13
|
+
def last(key: str) -> str | None:
|
|
14
|
+
values = params.getlist(key)
|
|
15
|
+
return values[-1] if values else None
|
|
16
|
+
|
|
17
|
+
sort = last("sort")
|
|
18
|
+
status = last("status")
|
|
19
|
+
return RunView(
|
|
20
|
+
sort=sort if sort in columns or sort == "status" else None,
|
|
21
|
+
descending=last("order") == "desc",
|
|
22
|
+
status=status if status in STATUSES else None,
|
|
23
|
+
filters={
|
|
24
|
+
column: value
|
|
25
|
+
for column in columns
|
|
26
|
+
if column not in choices and (value := (last(f"f.{column}") or "").strip())
|
|
27
|
+
},
|
|
28
|
+
picked={
|
|
29
|
+
column: picked
|
|
30
|
+
for column, values in choices.items()
|
|
31
|
+
if (
|
|
32
|
+
picked := {
|
|
33
|
+
label: values[label]
|
|
34
|
+
for label in params.getlist(f"f.{column}")
|
|
35
|
+
if label in values
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
},
|
|
39
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from urllib.parse import urlencode
|
|
2
|
+
|
|
3
|
+
from labnotes.models import RunView
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def sort_href(view: RunView, column: str) -> str:
|
|
7
|
+
params = [("sort", column)]
|
|
8
|
+
if view.sort == column and not view.descending:
|
|
9
|
+
params.append(("order", "desc"))
|
|
10
|
+
if view.status:
|
|
11
|
+
params.append(("status", view.status))
|
|
12
|
+
params += [(f"f.{name}", value) for name, value in view.filters.items()]
|
|
13
|
+
params += [
|
|
14
|
+
(f"f.{name}", label) for name, picked in view.picked.items() for label in picked
|
|
15
|
+
]
|
|
16
|
+
return f"?{urlencode(params)}#runs"
|
|
@@ -46,6 +46,10 @@ if (filters) {
|
|
|
46
46
|
for (const part of ["thead tr:first-child", "tbody"]) {
|
|
47
47
|
runs.querySelector(part).replaceWith(fresh.querySelector(part));
|
|
48
48
|
}
|
|
49
|
+
const summaries = fresh.querySelectorAll(".pick summary");
|
|
50
|
+
runs.querySelectorAll(".pick summary").forEach((summary, index) => {
|
|
51
|
+
summary.textContent = summaries[index].textContent;
|
|
52
|
+
});
|
|
49
53
|
history.replaceState(null, "", url);
|
|
50
54
|
};
|
|
51
55
|
|
|
@@ -58,4 +62,12 @@ if (filters) {
|
|
|
58
62
|
clearTimeout(pending);
|
|
59
63
|
pending = setTimeout(refresh, 250);
|
|
60
64
|
});
|
|
65
|
+
document.addEventListener("change", (event) => {
|
|
66
|
+
if (event.target.form === filters && event.target.type === "checkbox") refresh();
|
|
67
|
+
});
|
|
68
|
+
document.addEventListener("click", (event) => {
|
|
69
|
+
for (const pick of runs.querySelectorAll(".pick[open]")) {
|
|
70
|
+
if (!pick.contains(event.target)) pick.open = false;
|
|
71
|
+
}
|
|
72
|
+
});
|
|
61
73
|
}
|
|
@@ -276,6 +276,39 @@ h3 {
|
|
|
276
276
|
border-radius: 5px;
|
|
277
277
|
}
|
|
278
278
|
.runs-table .filters select { padding: 0.1rem 0; }
|
|
279
|
+
.runs-table .pick { position: relative; }
|
|
280
|
+
.runs-table .pick summary {
|
|
281
|
+
list-style: none;
|
|
282
|
+
cursor: pointer;
|
|
283
|
+
font-size: 0.82rem;
|
|
284
|
+
padding: 0.1rem 0.4rem;
|
|
285
|
+
color: var(--ink);
|
|
286
|
+
background: var(--surface);
|
|
287
|
+
border: 1px solid var(--rule);
|
|
288
|
+
border-radius: 5px;
|
|
289
|
+
white-space: nowrap;
|
|
290
|
+
overflow: hidden;
|
|
291
|
+
text-overflow: ellipsis;
|
|
292
|
+
}
|
|
293
|
+
.runs-table .pick summary::-webkit-details-marker { display: none; }
|
|
294
|
+
.runs-table .pick[open] summary, .runs-table .pick summary:focus-visible { outline: none; border-color: var(--accent); }
|
|
295
|
+
.runs-table .pick .options {
|
|
296
|
+
position: absolute;
|
|
297
|
+
z-index: 1;
|
|
298
|
+
top: calc(100% + 0.25rem);
|
|
299
|
+
left: 0;
|
|
300
|
+
min-width: 100%;
|
|
301
|
+
max-height: 16rem;
|
|
302
|
+
overflow: auto;
|
|
303
|
+
padding: 0.3rem 0;
|
|
304
|
+
background: var(--surface);
|
|
305
|
+
border: 1px solid var(--rule);
|
|
306
|
+
border-radius: 5px;
|
|
307
|
+
box-shadow: 0 4px 12px rgb(0 0 0 / 0.08);
|
|
308
|
+
}
|
|
309
|
+
.runs-table .pick label { display: flex; gap: 0.45rem; align-items: center; padding: 0.2rem 0.6rem; font-size: 0.82rem; white-space: nowrap; cursor: pointer; }
|
|
310
|
+
.runs-table .pick label:hover { background: var(--paper); }
|
|
311
|
+
.runs-table .filters .pick input { width: auto; }
|
|
279
312
|
.runs-table .filters input:focus, .runs-table .filters select:focus { outline: none; border-color: var(--accent); }
|
|
280
313
|
.runs-table tbody tr:last-child td { border-bottom: 0; }
|
|
281
314
|
.run-row { cursor: pointer; }
|
|
@@ -4,6 +4,8 @@ if (note) {
|
|
|
4
4
|
const prose = note.querySelector(".prose");
|
|
5
5
|
const status = note.querySelector(".edit-status");
|
|
6
6
|
const editor = import(note.dataset.editor);
|
|
7
|
+
let editing = null;
|
|
8
|
+
|
|
7
9
|
editor.then(
|
|
8
10
|
() => prose.classList.add("editable"),
|
|
9
11
|
() => {
|
|
@@ -15,9 +17,15 @@ if (note) {
|
|
|
15
17
|
const open = async (event) => {
|
|
16
18
|
if (event.target.closest("a, button, output") || getSelection().toString()) return;
|
|
17
19
|
const { edit } = await editor;
|
|
18
|
-
if (
|
|
20
|
+
if (editing) return;
|
|
19
21
|
prose.removeEventListener("click", open);
|
|
20
|
-
edit(note, prose, status);
|
|
22
|
+
editing = edit(note, prose, status);
|
|
21
23
|
};
|
|
22
24
|
prose.addEventListener("click", open);
|
|
25
|
+
|
|
26
|
+
new EventSource(note.dataset.changesUrl).addEventListener("message", (event) => {
|
|
27
|
+
if (JSON.parse(event.data) !== note.dataset.note) return;
|
|
28
|
+
if (editing) editing.pull();
|
|
29
|
+
else location.reload();
|
|
30
|
+
});
|
|
23
31
|
}
|
|
@@ -12,6 +12,7 @@ import { EditorState, RangeSetBuilder } from "@codemirror/state";
|
|
|
12
12
|
import { Decoration, EditorView, ViewPlugin, keymap } from "@codemirror/view";
|
|
13
13
|
import { tags } from "@lezer/highlight";
|
|
14
14
|
import { GFM, parseCode, parser } from "@lezer/markdown";
|
|
15
|
+
import { DIFF_DELETE, DIFF_EQUAL, diff_match_patch } from "diff-match-patch";
|
|
15
16
|
|
|
16
17
|
// Built from @lezer/markdown rather than @codemirror/lang-markdown, which
|
|
17
18
|
// imports HTML, CSS and JavaScript support that notes never use.
|
|
@@ -102,9 +103,27 @@ function show(status, message) {
|
|
|
102
103
|
status.hidden = false;
|
|
103
104
|
}
|
|
104
105
|
|
|
106
|
+
const dmp = new diff_match_patch();
|
|
107
|
+
|
|
108
|
+
// Three-way: what changed on disk since base, applied onto the editor's text.
|
|
109
|
+
const merge = (base, ours, theirs) => dmp.patch_apply(dmp.patch_make(base, theirs), ours)[0];
|
|
110
|
+
|
|
111
|
+
// Minimal changes, so the cursor and undo history survive.
|
|
112
|
+
function setText(view, text) {
|
|
113
|
+
const changes = [];
|
|
114
|
+
let at = 0;
|
|
115
|
+
for (const [op, chunk] of dmp.diff_main(view.state.doc.toString(), text)) {
|
|
116
|
+
if (op === DIFF_EQUAL) at += chunk.length;
|
|
117
|
+
else if (op === DIFF_DELETE) {
|
|
118
|
+
changes.push({ from: at, to: at + chunk.length });
|
|
119
|
+
at += chunk.length;
|
|
120
|
+
} else changes.push({ from: at, insert: chunk });
|
|
121
|
+
}
|
|
122
|
+
if (changes.length) view.dispatch({ changes });
|
|
123
|
+
}
|
|
124
|
+
|
|
105
125
|
export function edit(note, prose, status) {
|
|
106
126
|
let base = JSON.parse(note.querySelector(".note-source").textContent);
|
|
107
|
-
let stale = false;
|
|
108
127
|
let timer;
|
|
109
128
|
let queue = Promise.resolve();
|
|
110
129
|
|
|
@@ -112,31 +131,46 @@ export function edit(note, prose, status) {
|
|
|
112
131
|
host.className = "editor";
|
|
113
132
|
prose.replaceWith(host);
|
|
114
133
|
|
|
115
|
-
const
|
|
116
|
-
(queue = queue
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
const run = (task) =>
|
|
135
|
+
(queue = queue.then(task).catch((error) => show(status, `Not saved: ${error.message}`)));
|
|
136
|
+
|
|
137
|
+
const absorb = (theirs) => {
|
|
138
|
+
setText(view, merge(base, view.state.doc.toString(), theirs));
|
|
139
|
+
base = theirs;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const push = async () => {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
for (;;) {
|
|
145
|
+
const content = view.state.doc.toString();
|
|
146
|
+
if (content === base) return;
|
|
147
|
+
const response = await fetch(note.dataset.saveUrl, {
|
|
148
|
+
method: "PUT",
|
|
149
|
+
headers: { "Content-Type": "application/json" },
|
|
150
|
+
body: JSON.stringify({ base, content }),
|
|
151
|
+
});
|
|
152
|
+
if (response.status !== 409) {
|
|
153
|
+
if (response.ok) base = content;
|
|
154
|
+
else show(status, `Not saved: ${response.status} ${response.statusText}`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
absorb((await response.json()).content);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const pull = async () => {
|
|
162
|
+
const response = await fetch(note.dataset.saveUrl, { cache: "no-store" });
|
|
163
|
+
if (!response.ok) return show(status, `Not synced: ${response.status} ${response.statusText}`);
|
|
164
|
+
const theirs = await response.text();
|
|
165
|
+
if (theirs === base) return;
|
|
166
|
+
absorb(theirs);
|
|
167
|
+
await push();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const save = () => run(push);
|
|
136
171
|
|
|
137
172
|
const close = async () => {
|
|
138
173
|
await save();
|
|
139
|
-
if (stale) return;
|
|
140
174
|
document.removeEventListener("mousedown", outside);
|
|
141
175
|
location.reload();
|
|
142
176
|
};
|
|
@@ -166,4 +200,5 @@ export function edit(note, prose, status) {
|
|
|
166
200
|
}),
|
|
167
201
|
});
|
|
168
202
|
view.focus();
|
|
203
|
+
return { pull: () => run(pull) };
|
|
169
204
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"@lezer/python": "https://esm.sh/*@lezer/python@1.1.19",
|
|
17
17
|
"@marijn/find-cluster-break": "https://esm.sh/*@marijn/find-cluster-break@1.0.4",
|
|
18
18
|
"crelt": "https://esm.sh/*crelt@1.0.7",
|
|
19
|
+
"diff-match-patch": "https://esm.sh/*diff-match-patch@1.0.5",
|
|
19
20
|
"style-mod": "https://esm.sh/*style-mod@4.1.4",
|
|
20
21
|
"w3c-keyname": "https://esm.sh/*w3c-keyname@2.2.8"
|
|
21
22
|
}
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<nav class="tabs sub">
|
|
38
38
|
{% for each in e.notes %}<a href="?tab=notebooks&note={{ each.name | urlencode }}"{% if each.name == note.name %} aria-current="page"{% endif %}>{{ each.name }}</a>{% endfor %}
|
|
39
39
|
</nav>
|
|
40
|
-
<article class="note" data-run-url="{{ url_for('run_block', name=e.name, note=note.name) }}" data-restart-url="{{ url_for('restart_kernel', name=e.name, note=note.name) }}" data-save-url="{{ url_for('put_note', name=e.name, note=note.name) }}" data-editor="{{ asset('note-editor.js') }}">
|
|
40
|
+
<article class="note" data-run-url="{{ url_for('run_block', name=e.name, note=note.name) }}" data-restart-url="{{ url_for('restart_kernel', name=e.name, note=note.name) }}" data-save-url="{{ url_for('put_note', name=e.name, note=note.name) }}" data-editor="{{ asset('note-editor.js') }}" data-changes-url="{{ url_for('changes', name=e.name) }}" data-note="{{ note.name }}">
|
|
41
41
|
{% if note.runnable %}<header class="note-name"><button type="button" class="restart" title="Stop this note's Python kernel. The next Run starts a fresh one.">Restart</button></header>{% endif %}
|
|
42
42
|
<p class="edit-status" role="status" hidden></p>
|
|
43
43
|
<div class="prose">{{ note.html | safe }}</div>
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
{% else %}
|
|
53
53
|
<section id="runs">
|
|
54
54
|
{% set view = e.run_view %}
|
|
55
|
-
{% set narrowed = view.status or view.filters %}
|
|
55
|
+
{% set narrowed = view.status or view.filters or view.picked %}
|
|
56
56
|
{% if narrowed %}<p class="narrowed"><span class="num">{{ e.runs_list | length }}</span> of <span class="num">{{ e.runs }}</span> <a class="clear" href="{{ '?sort=' ~ (view.sort | urlencode) ~ ('&order=desc' if view.descending else '') if view.sort else '?' }}#runs">Clear filters</a></p>{% endif %}
|
|
57
57
|
{% if e.runs %}
|
|
58
58
|
<form id="run-filters" method="get" action="#runs">
|
|
@@ -75,7 +75,10 @@
|
|
|
75
75
|
<option value="complete"{{ ' selected' if view.status == 'complete' }}>✓</option>
|
|
76
76
|
<option value="incomplete"{{ ' selected' if view.status == 'incomplete' }}>✕</option>
|
|
77
77
|
</select></td>
|
|
78
|
-
{% for column in e.run_columns %}<td
|
|
78
|
+
{% for column in e.run_columns %}<td>{% if column in e.run_choices %}{% set picked = view.picked.get(column, {}) %}<details class="pick">
|
|
79
|
+
<summary aria-label="Filter {{ column }}">{{ picked | join(', ') if picked else 'all' }}</summary>
|
|
80
|
+
<div class="options">{% for label in e.run_choices[column] %}<label><input form="run-filters" type="checkbox" name="f.{{ column }}" value="{{ label }}"{{ ' checked' if label in picked }}> {{ label }}</label>{% endfor %}</div>
|
|
81
|
+
</details>{% else %}<input form="run-filters" type="search" name="f.{{ column }}" value="{{ view.filters.get(column, '') }}" placeholder="filter" aria-label="Filter {{ column }}">{% endif %}</td>{% endfor %}
|
|
79
82
|
</tr>
|
|
80
83
|
</thead>
|
|
81
84
|
<tbody>
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
from collections.abc import Mapping
|
|
2
|
-
|
|
3
|
-
from labnotes.models import RunView
|
|
4
|
-
|
|
5
|
-
STATUSES = {"complete", "incomplete"}
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def parse_run_view(params: Mapping[str, str], columns: list[str]) -> RunView:
|
|
9
|
-
sort = params.get("sort")
|
|
10
|
-
status = params.get("status")
|
|
11
|
-
return RunView(
|
|
12
|
-
sort=sort if sort in columns or sort == "status" else None,
|
|
13
|
-
descending=params.get("order") == "desc",
|
|
14
|
-
status=status if status in STATUSES else None,
|
|
15
|
-
filters={
|
|
16
|
-
column: value
|
|
17
|
-
for column in columns
|
|
18
|
-
if (value := params.get(f"f.{column}", "").strip())
|
|
19
|
-
},
|
|
20
|
-
)
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
from urllib.parse import urlencode
|
|
2
|
-
|
|
3
|
-
from labnotes.models import RunView
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def sort_href(view: RunView, column: str) -> str:
|
|
7
|
-
params = {"sort": column}
|
|
8
|
-
if view.sort == column and not view.descending:
|
|
9
|
-
params["order"] = "desc"
|
|
10
|
-
if view.status:
|
|
11
|
-
params["status"] = view.status
|
|
12
|
-
params.update({f"f.{name}": value for name, value in view.filters.items()})
|
|
13
|
-
return f"?{urlencode(params)}#runs"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|