@shivaedev/work-board 0.4.0 → 0.5.0

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 (119) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +27 -16
  3. package/assets/native.js +36 -0
  4. package/dist/board.d.ts +1 -1
  5. package/dist/board.d.ts.map +1 -1
  6. package/dist/board.js +38 -8
  7. package/dist/board.js.map +1 -1
  8. package/dist/browser/client.d.ts +88 -0
  9. package/dist/browser/client.d.ts.map +1 -0
  10. package/dist/browser/client.js +53 -0
  11. package/dist/browser/client.js.map +1 -0
  12. package/dist/browser/session.d.ts +89 -0
  13. package/dist/browser/session.d.ts.map +1 -0
  14. package/dist/browser/session.js +20 -0
  15. package/dist/browser/session.js.map +1 -0
  16. package/dist/cli.js +1 -1
  17. package/dist/files/changes.d.ts +4 -1
  18. package/dist/files/changes.d.ts.map +1 -1
  19. package/dist/files/changes.js +7 -3
  20. package/dist/files/changes.js.map +1 -1
  21. package/dist/files/list.d.ts +1 -1
  22. package/dist/files/list.d.ts.map +1 -1
  23. package/dist/files/list.js +5 -3
  24. package/dist/files/list.js.map +1 -1
  25. package/dist/history/schema.js +1 -1
  26. package/dist/history/schema.js.map +1 -1
  27. package/dist/history/script.d.ts.map +1 -1
  28. package/dist/history/script.js +28 -13
  29. package/dist/history/script.js.map +1 -1
  30. package/dist/http/assets.d.ts +2 -0
  31. package/dist/http/assets.d.ts.map +1 -1
  32. package/dist/http/assets.js +10 -0
  33. package/dist/http/assets.js.map +1 -1
  34. package/dist/http/history.d.ts.map +1 -1
  35. package/dist/http/history.js +1 -13
  36. package/dist/http/history.js.map +1 -1
  37. package/dist/http/sameOrigin.d.ts +3 -0
  38. package/dist/http/sameOrigin.d.ts.map +1 -0
  39. package/dist/http/sameOrigin.js +14 -0
  40. package/dist/http/sameOrigin.js.map +1 -0
  41. package/dist/page/ageScript.d.ts +2 -0
  42. package/dist/page/ageScript.d.ts.map +1 -0
  43. package/dist/page/ageScript.js +23 -0
  44. package/dist/page/ageScript.js.map +1 -0
  45. package/dist/page/client.d.ts +1 -1
  46. package/dist/page/client.d.ts.map +1 -1
  47. package/dist/page/client.js +73 -74
  48. package/dist/page/client.js.map +1 -1
  49. package/dist/page/navigationScript.d.ts.map +1 -1
  50. package/dist/page/navigationScript.js +7 -5
  51. package/dist/page/navigationScript.js.map +1 -1
  52. package/dist/page/searchScript.d.ts.map +1 -1
  53. package/dist/page/searchScript.js +6 -4
  54. package/dist/page/searchScript.js.map +1 -1
  55. package/dist/page/shell.js +1 -1
  56. package/dist/page/shell.js.map +1 -1
  57. package/dist/page/stateScript.js +1 -1
  58. package/dist/page/style.d.ts.map +1 -1
  59. package/dist/page/style.js +1 -0
  60. package/dist/page/style.js.map +1 -1
  61. package/dist/page/swap.d.ts +1 -1
  62. package/dist/page/swap.d.ts.map +1 -1
  63. package/dist/page/swap.js +16 -5
  64. package/dist/page/swap.js.map +1 -1
  65. package/dist/path/isMarkdown.d.ts +2 -0
  66. package/dist/path/isMarkdown.d.ts.map +1 -0
  67. package/dist/path/isMarkdown.js +7 -0
  68. package/dist/path/isMarkdown.js.map +1 -0
  69. package/dist/rpc/affected.d.ts +4 -0
  70. package/dist/rpc/affected.d.ts.map +1 -0
  71. package/dist/rpc/affected.js +75 -0
  72. package/dist/rpc/affected.js.map +1 -0
  73. package/dist/rpc/contract.d.ts +134 -0
  74. package/dist/rpc/contract.d.ts.map +1 -0
  75. package/dist/rpc/contract.js +59 -0
  76. package/dist/rpc/contract.js.map +1 -0
  77. package/dist/rpc/hints.d.ts +17 -0
  78. package/dist/rpc/hints.d.ts.map +1 -0
  79. package/dist/rpc/hints.js +28 -0
  80. package/dist/rpc/hints.js.map +1 -0
  81. package/dist/rpc/keys.d.ts +7 -0
  82. package/dist/rpc/keys.d.ts.map +1 -0
  83. package/dist/rpc/keys.js +9 -0
  84. package/dist/rpc/keys.js.map +1 -0
  85. package/dist/rpc/pageKeys.d.ts +3 -0
  86. package/dist/rpc/pageKeys.d.ts.map +1 -0
  87. package/dist/rpc/pageKeys.js +14 -0
  88. package/dist/rpc/pageKeys.js.map +1 -0
  89. package/dist/rpc/server.d.ts +12 -0
  90. package/dist/rpc/server.d.ts.map +1 -0
  91. package/dist/rpc/server.js +32 -0
  92. package/dist/rpc/server.js.map +1 -0
  93. package/package.json +9 -4
  94. package/src/board.ts +44 -9
  95. package/src/browser/client.ts +59 -0
  96. package/src/browser/session.ts +26 -0
  97. package/src/cli.ts +1 -1
  98. package/src/files/changes.ts +14 -4
  99. package/src/files/list.ts +5 -4
  100. package/src/history/schema.ts +1 -1
  101. package/src/history/script.ts +28 -13
  102. package/src/http/assets.ts +11 -0
  103. package/src/http/history.ts +1 -12
  104. package/src/http/sameOrigin.ts +14 -0
  105. package/src/page/ageScript.ts +22 -0
  106. package/src/page/client.ts +73 -74
  107. package/src/page/navigationScript.ts +7 -5
  108. package/src/page/searchScript.ts +6 -4
  109. package/src/page/shell.ts +1 -1
  110. package/src/page/stateScript.ts +1 -1
  111. package/src/page/style.ts +1 -0
  112. package/src/page/swap.ts +16 -5
  113. package/src/path/isMarkdown.ts +6 -0
  114. package/src/rpc/affected.ts +77 -0
  115. package/src/rpc/contract.ts +62 -0
  116. package/src/rpc/hints.ts +34 -0
  117. package/src/rpc/keys.ts +9 -0
  118. package/src/rpc/pageKeys.ts +14 -0
  119. package/src/rpc/server.ts +55 -0
@@ -3,98 +3,97 @@ import "/_board/saved-views.js";
3
3
  import "/_board/history.js";
4
4
  import "/_board/preferences.js";
5
5
  import "/_board/search.js";
6
+ import { tick } from "/_board/ages.js";
6
7
  import { renderDiagrams } from "/_board/diagrams.js";
7
8
  import { applyPage } from "/_board/page-state.js";
8
- import { pageVersion } from "/_board/navigation.js";
9
- import { remember } from "/_board/swap.js";
9
+ import "/_board/navigation.js";
10
+ import { remember, swap } from "/_board/swap.js";
11
+ import { refreshLibrary } from "/_board/library.js";
12
+ import { session } from "/_board/native.js";
10
13
 
11
- const since = (modified) => {
12
- const seconds = Math.max(0, Math.round((Date.now() - modified) / 1000));
13
- if (seconds < 60) return seconds + "s";
14
- if (seconds < 3600) return Math.round(seconds / 60) + "m";
15
- if (seconds < 172800) return Math.round(seconds / 3600) + "h";
16
- return Math.round(seconds / 86400) + "d";
17
- };
18
-
19
- const tick = () => {
20
- for (const age of document.querySelectorAll("[data-modified]")) {
21
- const ago = since(Number(age.dataset.modified));
22
- age.textContent = age.classList.contains("short") ? ago : "updated " + ago + " ago";
23
- }
24
- };
25
14
 
26
15
  remember(document.getElementById("files"));
27
16
  remember(document.getElementById("doc"));
28
17
 
18
+ let native = session(window);
29
19
  const live = document.getElementById("live");
30
- let connected = false;
31
- let dropped = true;
32
- let failure = "";
33
-
20
+ const toggle = document.getElementById("updates-toggle");
21
+ let pageFailure = "";
22
+ let navFailure = "";
23
+ let pageWaiting = true;
24
+ let watching = false;
25
+ let navWaiting = true;
26
+ let active = "";
27
+ let releasePage;
34
28
  const show = () => {
35
- const state = failure !== "" ? "stale" : connected ? "live" : "down";
36
- live.dataset.state = state;
37
- live.textContent = failure !== "" ? failure : connected ? "live" : "reconnecting";
29
+ const state = native.registry.get(native.updates.status);
30
+ const paused = native.registry.get(native.updates.paused);
31
+ const failure = pageFailure || navFailure;
32
+ live.dataset.state = paused ? "paused" : failure ? "stale" : !watching || state.connection !== "live" ? "down" : pageWaiting || navWaiting ? "refreshing" : "live";
33
+ live.textContent = paused ? "updates paused · " + state.pending + (state.pending === 256 ? "+" : "") + " pending updates" : failure || (!watching || state.connection !== "live" ? "reconnecting" : pageWaiting || navWaiting ? "refreshing" : "live");
34
+ toggle.textContent = paused ? "Resume updates" : "Pause updates";
35
+ toggle.setAttribute("aria-pressed", String(paused));
36
+ live.title = "Connection: " + state.connection + "; last observed source watcher: " + (watching ? "watching" : "unavailable") + "; " + (paused ? "automatic reads paused" : pageWaiting || navWaiting ? "reads pending" : failure ? "showing retained source" : "reads settled");
38
37
  };
39
-
40
- const load = async () => {
41
- const version = pageVersion();
38
+ const watchPage = () => {
42
39
  const path = location.pathname + location.search;
43
- try {
44
- const response = await fetch(path, { cache: "no-store" });
45
- const next = new DOMParser().parseFromString(await response.text(), "text/html");
46
- const files = next.getElementById("files");
47
- const doc = next.getElementById("doc");
48
- if (version !== pageVersion()) return;
49
- if (files === null || doc === null) {
50
- failure = "refresh failed (" + response.status + ")";
51
- return;
40
+ if (path === active) return;
41
+ active = path;
42
+ releasePage?.();
43
+ pageFailure = "";
44
+ const atom = native.api.page.query({ url: path });
45
+ releasePage = native.registry.subscribe(atom, (value) => {
46
+ pageWaiting = value.waiting || value._tag === "Initial";
47
+ if (value._tag === "Failure") pageFailure = "refresh failed";
48
+ if (value._tag === "Success" && !value.waiting && active === location.pathname + location.search) {
49
+ const next = new DOMParser().parseFromString(value.value.html, "text/html");
50
+ pageFailure = applyPage(next, true) ? "" : "refresh failed";
51
+ tick();
52
52
  }
53
- failure = "";
54
- applyPage(next, true);
55
- tick();
56
- } catch {
57
- if (version !== pageVersion()) return;
58
- failure = "refresh failed";
59
- }
53
+ show();
54
+ }, { immediate: true });
60
55
  };
61
-
62
- let loading = false;
63
- let dirty = false;
64
- const refresh = async () => {
65
- if (loading) {
66
- dirty = true;
67
- return;
56
+ const observe = () => {
57
+ native.registry.subscribe(native.api.navigation.query(), (value) => {
58
+ navWaiting = value.waiting || value._tag === "Initial";
59
+ if (value._tag === "Failure") navFailure = "navigation refresh failed";
60
+ if (value._tag === "Success" && !value.waiting) {
61
+ navFailure = "";
62
+ const files = document.getElementById("files");
63
+ const next = files.cloneNode(false);
64
+ next.innerHTML = value.value;
65
+ const current = document.getElementById("doc").dataset.file;
66
+ for (const link of next.querySelectorAll("a")) if (decodeURIComponent(new URL(link.href, location.href).pathname.slice(1)) === current) link.setAttribute("aria-current", "page");
67
+ swap(files, next);
68
+ refreshLibrary();
69
+ tick();
70
+ document.dispatchEvent(new Event("board-index-change"));
68
71
  }
69
- loading = true;
70
- do {
71
- dirty = false;
72
- await load();
73
- } while (dirty);
74
- loading = false;
75
- show();
76
- };
77
-
78
- const events = new EventSource("/events");
79
- events.addEventListener("ready", () => {
80
- connected = true;
81
- document.dispatchEvent(new Event("board-index-change"));
82
72
  show();
83
- if (dropped) refresh();
84
- dropped = false;
85
- });
86
- const down = () => {
87
- connected = false;
88
- dropped = true;
73
+ }, { immediate: true });
74
+ native.registry.subscribe(native.api.watcher.query(), (value) => {
75
+ watching = value._tag === "Success" && value.value.watching;
89
76
  show();
77
+ }, { immediate: true });
78
+ native.registry.subscribe(native.updates.status, show);
79
+ native.registry.subscribe(native.updates.paused, (paused) => { show(); if (!paused) document.dispatchEvent(new Event("board-updates-resumed")); });
90
80
  };
91
- events.addEventListener("change", () => { refresh(); document.dispatchEvent(new Event("board-index-change")); });
92
- events.addEventListener("down", down);
93
- events.addEventListener("error", down);
81
+ observe();
82
+ toggle.disabled = false;
83
+ toggle.addEventListener("click", () => native.registry.set(native.updates.paused, !native.registry.get(native.updates.paused)));
84
+ document.addEventListener("board-page", watchPage);
85
+ watchPage();
86
+ window.addEventListener("pagehide", () => { releasePage?.(); active = ""; });
87
+ window.addEventListener("pageshow", (event) => {
88
+ if (!event.persisted) return;
89
+ native = session(window);
90
+ pageWaiting = navWaiting = true;
91
+ watching = false;
92
+ pageFailure = navFailure = "";
93
+ observe();
94
+ watchPage();
95
+ });
94
96
 
95
- document.addEventListener("board-page", tick);
96
- tick();
97
- setInterval(tick, 5000);
98
97
  renderDiagrams(document);
99
98
  `;
100
99
  export const preferences = `
@@ -3,6 +3,7 @@ export function navigationScript(): string {
3
3
  import { applyPage, capturePage } from "/_board/page-state.js";
4
4
  import { reportMissingPassage, restoreReading, scrollToPassage } from "/_board/reading-state.js";
5
5
  import { refreshLibrary } from "/_board/library.js";
6
+ import { session } from "/_board/native.js";
6
7
 
7
8
  const cache = new Map();
8
9
  const readingKey = "work-board:reading:" + document.documentElement.dataset.workspace;
@@ -65,20 +66,21 @@ const navigate = async (target, entry) => {
65
66
  restoreReading(cached.reading);
66
67
  } else status.textContent = "Opening document…";
67
68
  try {
68
- const response = await fetch(target.pathname + target.search, { cache: "no-store", signal: controller.signal });
69
- const page = new DOMParser().parseFromString(await response.text(), "text/html");
69
+ const native = session(window);
70
+ const response = await native.read(native.api.page.query({ url: target.pathname + target.search }), controller.signal);
71
+ const page = new DOMParser().parseFromString(response.html, "text/html");
70
72
  if (mine !== version) return;
71
73
  if (!applyPage(page, Boolean(cached))) throw new Error("Invalid page");
72
74
  displayedId = id;
73
- status.textContent = response.ok ? "" : response.status === 404 ? "This document is missing or was renamed." : "This document could not be opened.";
74
- if (response.ok) reportMissingPassage();
75
+ status.textContent = response.status < 400 ? "" : response.status === 404 ? "This document is missing or was renamed." : "This document could not be opened.";
76
+ if (response.status < 400) reportMissingPassage();
75
77
  if (!cached) {
76
78
  document.getElementById("doc").focus({ preventScroll: true });
77
79
  if (savedReading[id] ?? entry?.reading) restoreReading(savedReading[id] ?? entry.reading);
78
80
  else scrollToPassage(target.hash);
79
81
  }
80
82
  } catch (error) {
81
- if (mine !== version || error.name === "AbortError") return;
83
+ if (mine !== version || controller.signal.aborted) return;
82
84
  status.textContent = "Could not open this document. Retry the link or reload the page.";
83
85
  }
84
86
  };
@@ -1,6 +1,7 @@
1
1
  export function searchScript(): string {
2
2
  return `
3
3
  import { activate, matchingCommands, move, showResults } from "/_board/search-results.js";
4
+ import { session } from "/_board/native.js";
4
5
  const dialog = document.getElementById("search-dialog");
5
6
  const input = document.getElementById("search-query");
6
7
  const status = document.getElementById("search-status");
@@ -25,14 +26,15 @@ const search = async () => {
25
26
  status.textContent = "Searching…";
26
27
  controller = new AbortController();
27
28
  try {
28
- const response = await fetch("/_board/search?q=" + encodeURIComponent(query), { cache: "no-store", signal: controller.signal });
29
- if (!response.ok) throw new Error("Search unavailable");
30
- const body = await response.json();
29
+ const native = session(window);
30
+ const atom = native.api.search.query({ query });
31
+ native.registry.refresh(atom);
32
+ const body = await native.read(atom, controller.signal);
31
33
  if (mine !== version || !dialog.open) return;
32
34
  showResults([...commands, ...body.results]);
33
35
  status.textContent = (body.total ? (body.total > body.results.length ? "Showing " + body.results.length + " of " : "") + body.total + " matches." : "No document matches.") + (body.unavailable.length ? " Could not read " + body.unavailable.length + " files; results are incomplete." : "");
34
36
  } catch (error) {
35
- if (mine !== version || error.name === "AbortError") return;
37
+ if (mine !== version || controller.signal.aborted) return;
36
38
  status.textContent = "Search could not be completed. Press Search to retry.";
37
39
  }
38
40
  };
package/src/page/shell.ts CHANGED
@@ -33,7 +33,7 @@ export const shell = (
33
33
  '<div class="preferences" role="group" aria-label="Reading preferences">',
34
34
  '<div class="preference"><label for="theme">Theme</label><select id="theme"><option value="system">System</option><option value="light">Light</option><option value="dark">Dark</option></select></div>',
35
35
  '<div class="preference"><label for="density">Density</label><select id="density"><option value="comfortable">Comfortable</option><option value="compact">Compact</option></select></div>',
36
- '<span id="preference-status" role="status"></span></div>',
36
+ '<button id="updates-toggle" type="button" aria-pressed="false" disabled>Pause updates</button><span id="preference-status" role="status"></span></div>',
37
37
  `<main${view ? ` data-view="${typeof view === "string" ? view : "work"}"` : ""}${identity ? ` data-identity="${escapeHtml(identity)}"` : ""} id="doc" data-file="${escapeHtml(title)}" data-url="${escapeHtml(fileUrl(title))}" class="${board ? "board-view" : "document-view"}" tabindex="-1">${main}</main>`,
38
38
  `</div></div>${searchDialog}</body></html>`,
39
39
  ].join("");
@@ -22,7 +22,7 @@ export const applyPage = (page, preserve = false) => {
22
22
  const content = current.querySelector("#changes-content");
23
23
  if (content) incoming.querySelector("#changes-content")?.replaceWith(content.cloneNode(true));
24
24
  }
25
- swap(current, incoming);
25
+ swap(current, incoming, true);
26
26
  } else {
27
27
  current.replaceWith(document.importNode(incoming, true));
28
28
  remember(document.getElementById("doc"));
package/src/page/style.ts CHANGED
@@ -48,6 +48,7 @@ footer { margin-top: 2.5em; padding-top: 1em; border-top: 1px solid var(--border
48
48
  `;
49
49
 
50
50
  const diagrams = `
51
+ [data-live-change] { outline: 2px solid var(--border); outline-offset: 3px; border-radius: 3px; }
51
52
  figure.diagram { margin-left: 0; margin-right: 0; }
52
53
  figure.diagram[data-state="pending"] { min-height: 12rem; background: var(--surface); border-radius: 6px; }
53
54
  figure.diagram .diagram-source { display: none; }
package/src/page/swap.ts CHANGED
@@ -4,6 +4,12 @@ const containers = new Set(["DIV", "SECTION", "ARTICLE", "HEADER", "FOOTER", "MA
4
4
  const structural = (element) => [...element.childNodes].every((node) => node.nodeType === 1 || node.textContent.trim() === "");
5
5
 
6
6
  const pristine = new WeakMap();
7
+ const flash = (element) => {
8
+ if (element.matches("[data-modified]")) return;
9
+ if (!element.matches("p, li, pre, blockquote, h1, h2, h3, h4, h5, h6, .item, .notes, .metadata-panel")) return;
10
+ element.setAttribute("data-live-change", "");
11
+ setTimeout(() => element.removeAttribute("data-live-change"), 2500);
12
+ };
7
13
 
8
14
  export const remember = (element) => {
9
15
  pristine.set(element, element.outerHTML);
@@ -13,6 +19,7 @@ export const remember = (element) => {
13
19
  const normalized = (element) => {
14
20
  const copy = element.cloneNode(true);
15
21
  for (const node of [copy, ...copy.querySelectorAll("*")]) {
22
+ node.removeAttribute("data-live-change");
16
23
  if (node.tagName === "DETAILS") node.removeAttribute("open");
17
24
  if (node.matches("figure.diagram")) {
18
25
  node.querySelector(".diagram-svg")?.remove();
@@ -29,8 +36,10 @@ const signature = (element) => {
29
36
  return pristine.get(element);
30
37
  };
31
38
 
39
+ const normalizedShell = (element) => { const copy = element.cloneNode(false); copy.removeAttribute("data-live-change"); return copy.outerHTML; };
40
+
32
41
  const sameShell = (old, next) =>
33
- old.tagName === next.tagName && containers.has(old.tagName) && old.cloneNode(false).outerHTML === next.cloneNode(false).outerHTML;
42
+ old.tagName === next.tagName && containers.has(old.tagName) && normalizedShell(old) === normalizedShell(next);
34
43
 
35
44
  const matches = (a, b) => {
36
45
  let start = 0;
@@ -55,12 +64,13 @@ const matches = (a, b) => {
55
64
  return [...pairs, [a.length, b.length]];
56
65
  };
57
66
 
58
- const patch = (current, incoming) => {
67
+ const patch = (current, incoming, highlight) => {
59
68
  const selected = current.tagName === "SELECT" ? current.value : null;
60
69
  const fallback = incoming.tagName === "SELECT" ? incoming.value : null;
61
70
  if (!structural(current) || !structural(incoming)) {
62
71
  current.replaceChildren(...incoming.childNodes);
63
72
  for (const child of current.children) remember(child);
73
+ if (highlight) flash(current);
64
74
  return;
65
75
  }
66
76
  const olds = [...current.children];
@@ -68,6 +78,7 @@ const patch = (current, incoming) => {
68
78
  const signatures = news.map((next) => next.outerHTML);
69
79
  const arrive = (index) => {
70
80
  remember(news[index]);
81
+ if (highlight) flash(news[index]);
71
82
  return news[index];
72
83
  };
73
84
  let i = 0;
@@ -76,7 +87,7 @@ const patch = (current, incoming) => {
76
87
  const anchor = olds[kept] ?? null;
77
88
  for (; i < kept && j < arrived; i++, j++) {
78
89
  if (sameShell(olds[i], news[j])) {
79
- patch(olds[i], news[j]);
90
+ patch(olds[i], news[j], highlight);
80
91
  pristine.set(olds[i], signatures[j]);
81
92
  } else olds[i].replaceWith(arrive(j));
82
93
  }
@@ -110,12 +121,12 @@ const carryDrawings = (drawings, figures) => {
110
121
  });
111
122
  };
112
123
 
113
- export const swap = (current, incoming) => {
124
+ export const swap = (current, incoming, highlight = false) => {
114
125
  const before = keysOf(current);
115
126
  const open = new Map(before.map(([key, details]) => [key, details.open]));
116
127
  const existing = new Set(before.map(([, details]) => details));
117
128
  const drawings = [...current.querySelectorAll("figure.diagram")].map((figure) => figure.querySelector(".diagram-svg"));
118
- patch(current, incoming);
129
+ patch(current, incoming, highlight);
119
130
  carryDrawings(drawings, [...current.querySelectorAll("figure.diagram")]);
120
131
  for (const [key, details] of keysOf(current)) {
121
132
  const was = open.get(key);
@@ -0,0 +1,6 @@
1
+ function skipped(segment: string): boolean {
2
+ return segment.startsWith(".") || segment === "node_modules";
3
+ }
4
+ export function isMarkdown(path: string): boolean {
5
+ return path.endsWith(".md") && !path.split("/").some(skipped);
6
+ }
@@ -0,0 +1,77 @@
1
+ import type { Key } from "@shivaedev/effect-contract/keys.ts";
2
+ import { backlinksOf } from "#metadata/backlinks.ts";
3
+ import type { MetadataModel } from "#metadata/model.ts";
4
+ import { documents, identities, index, navigation } from "./keys.ts";
5
+
6
+ function uncertain(previous: MetadataModel, current: MetadataModel, paths: readonly string[]) {
7
+ if (previous.unavailable.length > 0 || current.unavailable.length > 0) {
8
+ return true;
9
+ }
10
+ const before = new Map(previous.documents.map((doc) => [doc.file, doc.parsed]));
11
+ const after = new Map(current.documents.map((doc) => [doc.file, doc.parsed]));
12
+ const known = new Set([...before.keys(), ...after.keys()]);
13
+ if (paths.some((path) => !known.has(path))) {
14
+ return true;
15
+ }
16
+ const announced = new Set(paths);
17
+ return [...known].some((file) => {
18
+ const old = before.get(file);
19
+ const next = after.get(file);
20
+ return !announced.has(file) && (old?.body !== next?.body || old?.raw !== next?.raw || old?.bodyLine !== next?.bodyLine);
21
+ });
22
+ }
23
+ function connectIdentities(model: MetadataModel, connect: (from: string, to: string) => void) {
24
+ for (const duplicates of model.ids.values()) {
25
+ const first = duplicates[0];
26
+ if (first === undefined) {
27
+ continue;
28
+ }
29
+ for (const duplicate of duplicates.slice(1)) {
30
+ connect(first.file, duplicate.file);
31
+ connect(duplicate.file, first.file);
32
+ }
33
+ }
34
+ }
35
+ function linkedFiles(models: readonly MetadataModel[], paths: readonly string[]) {
36
+ const edges = new Map<string, Set<string>>();
37
+ function connect(from: string, to: string) {
38
+ const targets = edges.get(from) ?? new Set();
39
+ targets.add(to);
40
+ edges.set(from, targets);
41
+ }
42
+ for (const model of models) {
43
+ connectIdentities(model, connect);
44
+ for (const document of model.documents) {
45
+ for (const link of backlinksOf(document.file, model)) {
46
+ connect(document.file, link.file);
47
+ connect(link.file, document.file);
48
+ }
49
+ }
50
+ }
51
+ const files = new Set(paths);
52
+ for (const file of files) {
53
+ for (const linked of edges.get(file) ?? []) {
54
+ files.add(linked);
55
+ }
56
+ }
57
+ return files;
58
+ }
59
+ export function affected(previous: MetadataModel, current: MetadataModel, paths: readonly string[]): readonly Key[] | undefined {
60
+ if (uncertain(previous, current, paths)) {
61
+ return undefined;
62
+ }
63
+ const models = [previous, current];
64
+ const files = linkedFiles(models, paths);
65
+ const keys: Key[] = [index.list, navigation.list, ...[...files].map((file) => documents.item(file))];
66
+ for (const model of models) {
67
+ if (model.rootFile && files.has(model.rootFile)) {
68
+ keys.push(documents.item(""));
69
+ }
70
+ for (const document of model.documents) {
71
+ if (files.has(document.file) && document.parsed.fields.id) {
72
+ keys.push(identities.item(document.parsed.fields.id));
73
+ }
74
+ }
75
+ }
76
+ return keys;
77
+ }
@@ -0,0 +1,62 @@
1
+ import { Schema } from "effect";
2
+ import { Rpc } from "effect/unstable/rpc";
3
+ import { contract } from "@shivaedev/effect-contract/contract.ts";
4
+ import { LiveHint } from "@shivaedev/effect-contract/live.ts";
5
+ import { query } from "@shivaedev/effect-contract/operation.ts";
6
+ import { HistoryInput, HistoryOutput } from "#history/schema.ts";
7
+ import { index, navigation, watcher, workspace } from "./keys.ts";
8
+ import { pageKeys } from "./pageKeys.ts";
9
+
10
+ export class ReadFailed extends Schema.TaggedError<ReadFailed>()("ReadFailed", { operation: Schema.String, status: Schema.Number }) {}
11
+ const PageUrl = Schema.String.check(
12
+ Schema.isMaxLength(8192),
13
+ Schema.makeFilter((value) => {
14
+ if (!value.startsWith("/") || value.startsWith("//")) {
15
+ return "Expected a local reading URL";
16
+ }
17
+ try {
18
+ const path = new URL(value, "http://127.0.0.1").pathname;
19
+ return (
20
+ path === "/"
21
+ || ["/_board/work", "/_board/overview", "/_board/changes"].includes(path)
22
+ || /^\/_board\/item\/[^/]+\/$/u.exec(path) !== null
23
+ || /\.md$/iu.exec(path) !== null
24
+ || "Expected a local reading URL"
25
+ );
26
+ } catch {
27
+ return "Expected a local reading URL";
28
+ }
29
+ }),
30
+ );
31
+ const Page = Schema.Struct({ html: Schema.String, status: Schema.Number });
32
+ const Entry = Schema.Struct({
33
+ file: Schema.String,
34
+ href: Schema.String,
35
+ kind: Schema.Literals(["document", "heading", "passage"]),
36
+ line: Schema.optional(Schema.Number),
37
+ snippet: Schema.String,
38
+ text: Schema.String,
39
+ title: Schema.String,
40
+ });
41
+ const ReadPage = query("page", { payload: { url: PageUrl }, reads: ({ url }) => pageKeys(url), rejections: { ReadFailed }, success: Page });
42
+ const ReadNavigation = query("navigation", { reads: () => [workspace.list, navigation.list], rejections: { ReadFailed }, success: Schema.String });
43
+ const ReadSearch = query("search", {
44
+ payload: { query: Schema.String.check(Schema.isMaxLength(8192)) },
45
+ reads: () => [workspace.list, index.list],
46
+ rejections: { ReadFailed },
47
+ success: Schema.Struct({ results: Schema.Array(Entry), total: Schema.Number, unavailable: Schema.Array(Schema.String) }),
48
+ });
49
+ const ReadHistory = query("history", {
50
+ payload: HistoryInput,
51
+ reads: () => [workspace.list, index.list],
52
+ rejections: { ReadFailed },
53
+ success: HistoryOutput,
54
+ });
55
+ const ReadWatcher = query("watcher", {
56
+ reads: () => [workspace.list, watcher.list],
57
+ rejections: { ReadFailed },
58
+ success: Schema.Struct({ watching: Schema.Boolean }),
59
+ });
60
+ export const workContract = contract("work-board", { queries: [ReadPage, ReadNavigation, ReadSearch, ReadHistory, ReadWatcher] });
61
+ export const Subscribe = Rpc.make("work-board.subscribe", { error: ReadFailed, stream: true, success: LiveHint });
62
+ export const workRpcs = workContract.add(Subscribe);
@@ -0,0 +1,34 @@
1
+ import { Effect, PubSub, Stream } from "effect";
2
+ import type { LiveHint } from "@shivaedev/effect-contract/live.ts";
3
+ import type { Change, Changes } from "#files/changes.ts";
4
+ import type { Snapshot } from "#search/snapshot.ts";
5
+ import { affected } from "./affected.ts";
6
+
7
+ export function hints(changes: Changes, index: Effect.Effect<Snapshot, unknown>) {
8
+ return Stream.unwrap(
9
+ Effect.gen(function* () {
10
+ const subscription = yield* PubSub.subscribe(changes.events);
11
+ let sequence = changes.sequence ? yield* changes.sequence : undefined;
12
+ let previous = yield* Effect.option(index);
13
+ function nextHint(change: Change) {
14
+ return Effect.gen(function* () {
15
+ const current = yield* Effect.option(index);
16
+ const contiguous = sequence !== undefined && change.sequence === sequence + 1;
17
+ sequence = change.sequence;
18
+ let hint: LiveHint = { _tag: "Resync" };
19
+ if (contiguous && change._tag === "Changed" && previous._tag === "Some" && current._tag === "Some") {
20
+ const keys = affected(previous.value.model, current.value.model, change.paths);
21
+ if (keys !== undefined) {
22
+ hint = { _tag: "Changed", keys };
23
+ }
24
+ }
25
+ previous = current;
26
+ return hint;
27
+ });
28
+ }
29
+
30
+ const changesStream = Stream.fromSubscription(subscription).pipe(Stream.mapEffect(nextHint));
31
+ return Stream.concat(Stream.succeed<LiveHint>({ _tag: "Resync" }), changesStream);
32
+ }),
33
+ );
34
+ }
@@ -0,0 +1,9 @@
1
+ import { Schema } from "effect";
2
+ import { collection } from "@shivaedev/effect-contract/keys.ts";
3
+
4
+ export const workspace = collection("work-board/workspace", Schema.String);
5
+ export const documents = collection("work-board/documents", Schema.String);
6
+ export const identities = collection("work-board/identities", Schema.String);
7
+ export const navigation = collection("work-board/navigation", Schema.String);
8
+ export const index = collection("work-board/index", Schema.String);
9
+ export const watcher = collection("work-board/watcher", Schema.String);
@@ -0,0 +1,14 @@
1
+ import type { Key } from "@shivaedev/effect-contract/keys.ts";
2
+ import { documents, identities, index, workspace } from "./keys.ts";
3
+
4
+ export function pageKeys(url: string): readonly Key[] {
5
+ const path = new URL(url, "http://127.0.0.1").pathname;
6
+ const item = /^\/_board\/item\/(?<id>[^/]+)\/$/u.exec(path)?.groups?.id;
7
+ let key: Key = documents.item(decodeURIComponent(path.slice(1)));
8
+ if (item) {
9
+ key = identities.item(decodeURIComponent(item));
10
+ } else if (path.startsWith("/_board/")) {
11
+ key = index.list;
12
+ }
13
+ return [workspace.list, key];
14
+ }
@@ -0,0 +1,55 @@
1
+ import { Clock, Effect, Layer, Ref } from "effect";
2
+ import { HttpIncomingMessage, HttpServerResponse } from "effect/unstable/http";
3
+ import { RpcSerialization, RpcServer } from "effect/unstable/rpc";
4
+ import type { Changes } from "#files/changes.ts";
5
+ import { MAX_BYTES } from "#history/limits.ts";
6
+ import { historyResponse } from "#history/response.ts";
7
+ import { HEADERS } from "#http/respond.ts";
8
+ import { navHtml } from "#page/nav.ts";
9
+ import { searchMatch } from "#search/match.ts";
10
+ import type { Snapshot } from "#search/snapshot.ts";
11
+ import { ReadFailed, workRpcs } from "./contract.ts";
12
+ import { hints } from "./hints.ts";
13
+
14
+ export interface ServerOptions {
15
+ readonly changes: Changes;
16
+ readonly home: string | undefined;
17
+ readonly index: Effect.Effect<Snapshot, unknown>;
18
+ readonly page: (url: string) => Effect.Effect<HttpServerResponse.HttpServerResponse, unknown>;
19
+ }
20
+
21
+ export const server = Effect.fn("WorkBoard.server")(function* ({ changes, home, index, page }: ServerOptions) {
22
+ const handlers = workRpcs.toLayer({
23
+ "work-board.history": ({ action, baseline }) =>
24
+ Effect.gen(function* () {
25
+ const now = yield* Clock.currentTimeMillis;
26
+ const snapshot = action === "check" ? undefined : yield* index;
27
+ return historyResponse(baseline, now, snapshot);
28
+ }).pipe(Effect.catch(() => Effect.fail(new ReadFailed({ operation: "history", status: 500 })))),
29
+ "work-board.navigation": () =>
30
+ Effect.map(changes.files, (files) => navHtml(files, "", home)).pipe(
31
+ Effect.catch(() => Effect.fail(new ReadFailed({ operation: "navigation", status: 500 }))),
32
+ ),
33
+ "work-board.page": ({ url }) =>
34
+ page(url).pipe(
35
+ Effect.flatMap((response) => {
36
+ if (response.status >= 500 || response.body._tag !== "Uint8Array") {
37
+ return Effect.fail(new ReadFailed({ operation: "page", status: response.status }));
38
+ }
39
+ return Effect.succeed({ html: new TextDecoder().decode(response.body.body), status: response.status });
40
+ }),
41
+ Effect.catch((error) => Effect.fail(error instanceof ReadFailed ? error : new ReadFailed({ operation: "page", status: 500 }))),
42
+ ),
43
+ "work-board.search": ({ query }) =>
44
+ Effect.map(index, (snapshot) => ({ ...searchMatch(snapshot.entries, query), unavailable: snapshot.unavailable })).pipe(
45
+ Effect.catch(() => Effect.fail(new ReadFailed({ operation: "search", status: 500 }))),
46
+ ),
47
+ "work-board.subscribe": () => hints(changes, index),
48
+ "work-board.watcher": () => Effect.map(Ref.get(changes.watching), (watching) => ({ watching })),
49
+ });
50
+ const handler = yield* RpcServer.toHttpEffect(workRpcs).pipe(Effect.provide(Layer.merge(handlers, RpcSerialization.layerNdjson)));
51
+ return handler.pipe(
52
+ Effect.provideService(HttpIncomingMessage.MaxBodySize, BigInt(4 * MAX_BYTES + 4096)),
53
+ Effect.map((response) => HttpServerResponse.setHeaders(response, HEADERS)),
54
+ );
55
+ });