labnotes 0.0.62__tar.gz → 0.0.63__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.
Files changed (111) hide show
  1. {labnotes-0.0.62 → labnotes-0.0.63}/PKG-INFO +2 -2
  2. {labnotes-0.0.62 → labnotes-0.0.63}/README.md +1 -1
  3. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/context.md +1 -1
  4. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/create_app.py +15 -0
  5. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/experiments.py +1 -7
  6. labnotes-0.0.63/src/labnotes/save_conflict_copy.py +16 -0
  7. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/static/note-editor.js +36 -8
  8. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/templates/experiment.html +1 -1
  9. {labnotes-0.0.62 → labnotes-0.0.63}/.gitignore +0 -0
  10. {labnotes-0.0.62 → labnotes-0.0.63}/CHANGELOG.md +0 -0
  11. {labnotes-0.0.62 → labnotes-0.0.63}/MIGRATIONS.md +0 -0
  12. {labnotes-0.0.62 → labnotes-0.0.63}/pyproject.toml +0 -0
  13. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/__init__.py +0 -0
  14. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/asset_url.py +0 -0
  15. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/cell_progress.py +0 -0
  16. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/check_counts.py +0 -0
  17. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/cli.py +0 -0
  18. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/context_guide.py +0 -0
  19. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/count_runs.py +0 -0
  20. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/create_experiment.py +0 -0
  21. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/create_note.py +0 -0
  22. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/data_html.py +0 -0
  23. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/default_config_text.py +0 -0
  24. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/default_counts.py +0 -0
  25. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/drain_watch.py +0 -0
  26. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/experiment_notes.py +0 -0
  27. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/experiment_progress.py +0 -0
  28. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/experiment_runs.py +0 -0
  29. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/file_changes.py +0 -0
  30. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/format_duration.py +0 -0
  31. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/format_value.py +0 -0
  32. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/group_runs.py +0 -0
  33. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/init.py +0 -0
  34. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/invocation_started.py +0 -0
  35. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/is_positive_int.py +0 -0
  36. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/list_experiments.py +0 -0
  37. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/list_runs.py +0 -0
  38. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/load_config.py +0 -0
  39. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/load_experiment.py +0 -0
  40. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/models.py +0 -0
  41. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/modes.py +0 -0
  42. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/nests_runs.py +0 -0
  43. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/notebook_kernel.py +0 -0
  44. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/output_html.py +0 -0
  45. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/parse_run_view.py +0 -0
  46. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/print_rows.py +0 -0
  47. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/python_blocks.py +0 -0
  48. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/read_config.py +0 -0
  49. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/read_path.py +0 -0
  50. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/render_markdown.py +0 -0
  51. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/__init__.py +0 -0
  52. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/describe_event.py +0 -0
  53. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/execute_run.py +0 -0
  54. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/hold_folder.py +0 -0
  55. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/new_param_run_id.py +0 -0
  56. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/new_run_id.py +0 -0
  57. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  58. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/param_args.py +0 -0
  59. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/param_grid.py +0 -0
  60. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/param_run_name.py +0 -0
  61. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/plan_runs.py +0 -0
  62. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/read_experiment_config.py +0 -0
  63. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/run_experiment.py +0 -0
  64. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/run_folder.py +0 -0
  65. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/run_headless.py +0 -0
  66. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/runs_parent.py +0 -0
  67. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/shell_command.py +0 -0
  68. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/start_experiment.py +0 -0
  69. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/stop_then_kill.py +0 -0
  70. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/wait_for_group.py +0 -0
  71. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/runner/write_run_json.py +0 -0
  72. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/save_note.py +0 -0
  73. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/saved_outputs.py +0 -0
  74. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/serve_app.py +0 -0
  75. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/server.py +0 -0
  76. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/sort_href.py +0 -0
  77. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/sql_quote.py +0 -0
  78. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/static/experiment.js +0 -0
  79. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/static/labnotes.css +0 -0
  80. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/static/new-note.js +0 -0
  81. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/static/note-edit.js +0 -0
  82. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/static/row-link.js +0 -0
  83. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/static/run-cell.js +0 -0
  84. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/static/view-toggle.js +0 -0
  85. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/summarize_config.py +0 -0
  86. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/__init__.py +0 -0
  87. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/experiment_rows.py +0 -0
  88. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/file_prefix.py +0 -0
  89. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/iso_time.py +0 -0
  90. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/list_experiment_runs.py +0 -0
  91. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/open_tables.py +0 -0
  92. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/path_glob.py +0 -0
  93. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/path_pattern.py +0 -0
  94. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/read_experiment.py +0 -0
  95. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/read_file.py +0 -0
  96. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/read_run.py +0 -0
  97. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tables/summarize_experiments.py +0 -0
  98. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/templates/base.html +0 -0
  99. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/templates/editor_imports.html +0 -0
  100. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/templates/index.html +0 -0
  101. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/templates/macros.html +0 -0
  102. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tui/__init__.py +0 -0
  103. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tui/dim.py +0 -0
  104. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tui/format_elapsed.py +0 -0
  105. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tui/run_app.py +0 -0
  106. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tui/run_tui.py +0 -0
  107. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/tui/runs_table.py +0 -0
  108. {labnotes-0.0.62 → labnotes-0.0.63}/src/labnotes/wait_for_note.py +0 -0
  109. {labnotes-0.0.62 → labnotes-0.0.63}/testing-conventions.toml +0 -0
  110. {labnotes-0.0.62 → labnotes-0.0.63}/tests/integration/conftest.py +0 -0
  111. {labnotes-0.0.62 → labnotes-0.0.63}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.62
3
+ Version: 0.0.63
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>
@@ -101,7 +101,7 @@ To exclude a run, add `"excluded": true` and an optional `"reason"` to its
101
101
  replaces it; both show as columns in `runs`.
102
102
 
103
103
  In a terminal, `run` shows a live table of runs (status and time) and stays
104
- open when they finish; `q` quits. Elsewhere, or with `--no-tui`, it prints a
104
+ open when they finish; `q` quits. Elsewhere, it prints a
105
105
  line per event instead.
106
106
 
107
107
  - `--concurrency N` runs N at a time (default 1).
@@ -80,7 +80,7 @@ To exclude a run, add `"excluded": true` and an optional `"reason"` to its
80
80
  replaces it; both show as columns in `runs`.
81
81
 
82
82
  In a terminal, `run` shows a live table of runs (status and time) and stays
83
- open when they finish; `q` quits. Elsewhere, or with `--no-tui`, it prints a
83
+ open when they finish; `q` quits. Elsewhere, it prints a
84
84
  line per event instead.
85
85
 
86
86
  - `--concurrency N` runs N at a time (default 1).
@@ -105,7 +105,7 @@ running run under the cursor; `x` again kills it. `+` and
105
105
  `-` change how many run at once; lowering it lets running runs
106
106
  finish. Enter on a run follows its `stdout.log` and `stderr.log` together, as
107
107
  they're written, and Escape returns to the table. An `adhoc` experiment that
108
- plans a single run opens straight on its output. `q` quits. Elsewhere, or with `--no-tui`, it prints a
108
+ plans a single run opens straight on its output. `q` quits. Elsewhere, it prints a
109
109
  line per event instead.
110
110
 
111
111
  - `--concurrency N` runs N at a time to start with. Without it, `concurrency`
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  from collections.abc import AsyncIterator
3
3
  from contextlib import asynccontextmanager
4
+ from datetime import datetime
4
5
  from pathlib import Path
5
6
 
6
7
  from starlette.applications import Starlette
@@ -29,6 +30,7 @@ from labnotes.load_experiment import load_experiment
29
30
  from labnotes.models import LabnotesConfig
30
31
  from labnotes.notebook_kernel import NotebookKernel
31
32
  from labnotes.python_blocks import python_blocks
33
+ from labnotes.save_conflict_copy import save_conflict_copy
32
34
  from labnotes.save_note import save_note
33
35
  from labnotes.saved_outputs import saved_outputs
34
36
  from labnotes.tables.open_tables import open_tables
@@ -127,6 +129,14 @@ def create_app(config: LabnotesConfig) -> Starlette:
127
129
  return JSONResponse({"content": current}, status_code=409)
128
130
  return Response(status_code=204)
129
131
 
132
+ async def post_conflict(request: Request) -> Response:
133
+ note = await find_note(request)
134
+ content = (await request.json()).get("content")
135
+ if not isinstance(content, str):
136
+ raise HTTPException(400, "Send content as a string")
137
+ copy = save_conflict_copy(Path(note["path"]), content, datetime.now())
138
+ return JSONResponse({"name": copy.name}, status_code=201)
139
+
130
140
  async def run_block(request: Request) -> Response:
131
141
  blocks = python_blocks((await find_note(request))["content"])
132
142
  key = (request.path_params["name"], request.path_params["note"])
@@ -152,6 +162,11 @@ def create_app(config: LabnotesConfig) -> Starlette:
152
162
  Route("/experiments/{name}/notes", post_note, methods=["POST"]),
153
163
  Route("/experiments/{name}/notes/{note:path}", get_note),
154
164
  Route("/experiments/{name}/notes/{note:path}", put_note, methods=["PUT"]),
165
+ Route(
166
+ "/experiments/{name}/notes/{note:path}/conflict",
167
+ post_conflict,
168
+ methods=["POST"],
169
+ ),
155
170
  Route(
156
171
  "/experiments/{name}/notes/{note:path}/run",
157
172
  run_block,
@@ -39,11 +39,6 @@ from labnotes.tui.run_tui import run_tui
39
39
  type=click.Path(exists=True, dir_okay=False, path_type=Path),
40
40
  help="With run: read this file instead of the experiment's experiment.toml.",
41
41
  )
42
- @click.option(
43
- "--no-tui",
44
- is_flag=True,
45
- help="With run: print a line per event instead of the live view.",
46
- )
47
42
  @click.pass_context
48
43
  def experiments(
49
44
  ctx: click.Context,
@@ -52,7 +47,6 @@ def experiments(
52
47
  output_format: str,
53
48
  concurrency: int | None,
54
49
  experiment_config: Path | None,
55
- no_tui: bool,
56
50
  ) -> None:
57
51
  config = load_config(ctx)
58
52
  if config is None:
@@ -68,7 +62,7 @@ def experiments(
68
62
  click.echo(f"Created {path / 'experiment.toml'}")
69
63
  return
70
64
  case str(), "run":
71
- live = sys.stdout.isatty() and sys.stderr.isatty() and not no_tui
65
+ live = sys.stdout.isatty() and sys.stderr.isatty()
72
66
  drive = run_tui if live else lambda start, title: run_headless(start)
73
67
  try:
74
68
  results = start_experiment(
@@ -0,0 +1,16 @@
1
+ from datetime import datetime
2
+ from itertools import chain, count
3
+ from pathlib import Path
4
+
5
+
6
+ def save_conflict_copy(path: Path, content: str, now: datetime) -> Path:
7
+ stamp = f"Conflicted copy {now:%Y%m%d%H%M}"
8
+ tags = chain([stamp], (f"{stamp} {n}" for n in count(2)))
9
+ while True:
10
+ copy = path.with_name(f"{path.stem} ({next(tags)}){path.suffix}")
11
+ try:
12
+ with copy.open("x", encoding="utf-8") as file:
13
+ file.write(content)
14
+ return copy
15
+ except FileExistsError:
16
+ pass
@@ -105,9 +105,6 @@ function show(status, message) {
105
105
 
106
106
  const dmp = new diff_match_patch();
107
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
108
  // Minimal changes, so the cursor and undo history survive.
112
109
  function setText(view, text) {
113
110
  const changes = [];
@@ -125,22 +122,52 @@ function setText(view, text) {
125
122
  export function edit(note, prose, status) {
126
123
  let base = JSON.parse(note.querySelector(".note-source").textContent);
127
124
  let timer;
125
+ let deadline;
128
126
  let queue = Promise.resolve();
129
127
 
130
128
  const host = document.createElement("div");
131
129
  host.className = "editor";
132
130
  prose.replaceWith(host);
133
131
 
132
+ const notice = (message) => {
133
+ show(status, message);
134
+ setTimeout(() => {
135
+ if (status.textContent === message) status.hidden = true;
136
+ }, 4000);
137
+ };
138
+
134
139
  const run = (task) =>
135
140
  (queue = queue.then(task).catch((error) => show(status, `Not saved: ${error.message}`)));
136
141
 
137
- const absorb = (theirs) => {
138
- setText(view, merge(base, view.state.doc.toString(), theirs));
142
+ // Three-way: what changed on disk since base, applied onto the editor's text.
143
+ // A patch that will not apply leaves the editor alone and keeps the disk
144
+ // version in a sibling file, as Obsidian Sync does.
145
+ const absorb = async (theirs) => {
146
+ const ours = view.state.doc.toString();
147
+ const [merged, applied] = dmp.patch_apply(dmp.patch_make(base, theirs), ours);
148
+ const name = note.dataset.note;
149
+ if (applied.every(Boolean)) {
150
+ setText(view, merged);
151
+ if (ours !== base) notice(`${name} was modified externally, merging changes automatically`);
152
+ } else {
153
+ const response = await fetch(note.dataset.conflictUrl, {
154
+ method: "POST",
155
+ headers: { "Content-Type": "application/json" },
156
+ body: JSON.stringify({ content: theirs }),
157
+ });
158
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
159
+ const copy = (await response.json()).name;
160
+ show(
161
+ status,
162
+ `${name} was modified externally and could not be merged. The outside version is saved as ${copy}.`,
163
+ );
164
+ }
139
165
  base = theirs;
140
166
  };
141
167
 
142
168
  const push = async () => {
143
169
  clearTimeout(timer);
170
+ deadline = undefined;
144
171
  for (;;) {
145
172
  const content = view.state.doc.toString();
146
173
  if (content === base) return;
@@ -154,7 +181,7 @@ export function edit(note, prose, status) {
154
181
  else show(status, `Not saved: ${response.status} ${response.statusText}`);
155
182
  return;
156
183
  }
157
- absorb((await response.json()).content);
184
+ await absorb((await response.json()).content);
158
185
  }
159
186
  };
160
187
 
@@ -163,7 +190,7 @@ export function edit(note, prose, status) {
163
190
  if (!response.ok) return show(status, `Not synced: ${response.status} ${response.statusText}`);
164
191
  const theirs = await response.text();
165
192
  if (theirs === base) return;
166
- absorb(theirs);
193
+ await absorb(theirs);
167
194
  await push();
168
195
  };
169
196
 
@@ -194,7 +221,8 @@ export function edit(note, prose, status) {
194
221
  EditorView.updateListener.of((update) => {
195
222
  if (!update.docChanged) return;
196
223
  clearTimeout(timer);
197
- timer = setTimeout(save, 500);
224
+ deadline ??= Date.now() + 2000;
225
+ timer = setTimeout(save, Math.min(500, deadline - Date.now()));
198
226
  }),
199
227
  ],
200
228
  }),
@@ -37,7 +37,7 @@
37
37
  <nav class="tabs sub">
38
38
  {% for each in e.notes %}<a href="?tab=notebooks&amp;note={{ each.name | urlencode }}"{% if each.name == note.name %} aria-current="page"{% endif %}>{{ each.name }}</a>{% endfor %}<button type="button" class="new-note" data-url="{{ url_for('post_note', name=e.name) }}" aria-label="New note" title="New note">+</button>
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') }}" data-changes-url="{{ url_for('changes', name=e.name) }}" data-note="{{ note.name }}">
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-conflict-url="{{ url_for('post_conflict', 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>
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes