pi-weave 0.3.0 → 0.3.2
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.
- package/README.md +40 -16
- package/package.json +2 -2
- package/src/core/frontmatter.ts +3 -3
- package/src/core/graph/model.ts +2 -2
- package/src/core/graph/wikilinks.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/pi/viewer/tui/surface/detail.ts +1 -1
- package/src/web/client/dist/app.js +239 -243
- package/src/web/client/graph/ForceTuner.tsx +14 -98
- package/src/web/client/graph/Graph.tsx +29 -74
- package/src/web/client/graph/column.model.ts +22 -15
- package/src/web/client/graph/graph.model.ts +31 -63
- package/src/web/client/graph/groups.ts +9 -7
- package/src/web/client/graph/positions.ts +3 -3
- package/src/web/client/graph/renderer.dom.ts +2 -2
- package/src/web/client/graph/renderer.ts +14 -5
- package/src/web/client/graph/tuner.model.ts +6 -180
- package/src/web/client/main.tsx +1 -4
- package/src/web/client/note/Note.tsx +77 -92
- package/src/web/client/note/drafts.ts +172 -0
- package/src/web/client/note/note.model.ts +2 -34
- package/src/web/client/search/SearchPalette.tsx +9 -8
- package/src/web/client/shell/ContextRail.tsx +8 -9
- package/src/web/client/shell/Settings.tsx +71 -0
- package/src/web/client/shell/Shell.tsx +409 -255
- package/src/web/client/shell/StatusBar.tsx +2 -6
- package/src/web/client/shell/focus.model.ts +2 -2
- package/src/web/client/shell/keys.model.ts +40 -39
- package/src/web/client/shell/keys.ts +1 -0
- package/src/web/client/shell/shell.model.ts +32 -128
- package/src/web/client/shell/theme.model.ts +27 -97
- package/src/web/client/shell/theme.ts +32 -238
- package/src/web/client/shell/workspace.css.ts +140 -0
- package/src/web/client/tree/Tree.tsx +43 -14
- package/src/web/client/tree/tree.model.ts +19 -0
- package/src/web/client/workspace.ts +19 -1
- package/src/web/server/backup.ts +59 -0
- package/src/web/server/routes.ts +45 -3
- package/src/web/server/server.ts +5 -0
- package/src/web/server/workspace-state.ts +121 -0
- package/src/web/shared/layout.ts +5 -6
- package/src/web/shared/preferences.ts +76 -0
- package/src/web/shared/themes.ts +92 -0
- package/src/web/shared/workspace.ts +310 -0
- package/src/web/client/selection.storage.ts +0 -69
- package/src/web/client/shell/Columns.tsx +0 -139
- package/src/web/client/shell/Divider.tsx +0 -15
- package/src/web/client/shell/Header.tsx +0 -119
- package/src/web/client/shell/drag.model.ts +0 -35
- package/src/web/client/shell/layout.model.ts +0 -132
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** The vault and repository tree column. */
|
|
2
2
|
|
|
3
|
+
import { NOTE_DRAG_TYPE } from "../shell/shell.model";
|
|
3
4
|
import { useLayoutEffect, useRef, useState } from "preact/hooks";
|
|
4
5
|
import { createFolder, deleteFolder, deleteNote, moveNote, renameFolder, renameNote } from "../api";
|
|
5
6
|
import { fetchJson } from "../api.dom";
|
|
@@ -20,7 +21,6 @@ import {
|
|
|
20
21
|
deleteNeedsConfirmation,
|
|
21
22
|
depthVar,
|
|
22
23
|
dropFolder,
|
|
23
|
-
expand,
|
|
24
24
|
initialTreeView,
|
|
25
25
|
newFolderParent,
|
|
26
26
|
newFolderPath,
|
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
rowCountLabel,
|
|
34
34
|
rowViews,
|
|
35
35
|
rowsFor,
|
|
36
|
+
revealFolder,
|
|
37
|
+
affectedNoteSlugs,
|
|
36
38
|
setQuery,
|
|
37
39
|
toggleExpanded,
|
|
38
40
|
toggleInternals,
|
|
@@ -44,12 +46,12 @@ export interface TreeProps {
|
|
|
44
46
|
graph: GraphPayload | null;
|
|
45
47
|
selectedId: string | null;
|
|
46
48
|
recentIds: ReadonlySet<string>;
|
|
47
|
-
onSelect: (id: string) => void;
|
|
49
|
+
onSelect: (id: string, newTab?: boolean, keepSidebar?: boolean) => void;
|
|
48
50
|
/**
|
|
49
51
|
* Ask before a mutation that would invalidate an open draft. `false` means
|
|
50
52
|
* keep editing: the mutation must not run.
|
|
51
53
|
*/
|
|
52
|
-
onMutate: () => boolean;
|
|
54
|
+
onMutate: (slugs: readonly string[]) => boolean;
|
|
53
55
|
onRefresh: () => void;
|
|
54
56
|
now: number;
|
|
55
57
|
}
|
|
@@ -74,7 +76,7 @@ export function Icon({ name, class: className }: { name: IconName; class?: strin
|
|
|
74
76
|
);
|
|
75
77
|
}
|
|
76
78
|
|
|
77
|
-
function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: () => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
|
|
79
|
+
function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMenu, onDrop }: { view: ReturnType<typeof rowViews>[number]; recentIds: ReadonlySet<string>; edit: string | null; onEdit: (value: string) => void; onRename: (value: string | null) => void; onSelect: (newTab: boolean) => void; onToggle: () => void; onMenu: (x: number, y: number) => void; onDrop: (id: string) => void }) {
|
|
78
80
|
const folder = dropFolder(view.id);
|
|
79
81
|
return (
|
|
80
82
|
<li
|
|
@@ -88,10 +90,10 @@ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMe
|
|
|
88
90
|
aria-selected={view.selected}
|
|
89
91
|
aria-expanded={view.hasKids ? view.expanded : undefined}
|
|
90
92
|
style={depthVar(view.depth)}
|
|
91
|
-
onClick={onSelect}
|
|
93
|
+
onClick={(event) => onSelect(event.metaKey || event.ctrlKey)}
|
|
92
94
|
draggable={view.id.startsWith("note:")}
|
|
93
95
|
aria-label={view.id === DRAFT_FOLDER_ID ? "New folder" : undefined}
|
|
94
|
-
onDragStart={(event) => event.dataTransfer?.setData("text/plain", view.id)}
|
|
96
|
+
onDragStart={(event) => { event.dataTransfer?.setData("text/plain", view.id); event.dataTransfer?.setData(NOTE_DRAG_TYPE, view.id); if (event.dataTransfer) event.dataTransfer.effectAllowed = "copyMove"; }}
|
|
95
97
|
onDragOver={folder === undefined ? undefined : (event) => event.preventDefault()}
|
|
96
98
|
onDrop={folder === undefined ? undefined : (event) => { event.preventDefault(); onDrop(event.dataTransfer?.getData("text/plain") ?? ""); }}
|
|
97
99
|
onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); onMenu(event.clientX, event.clientY); }}
|
|
@@ -161,6 +163,14 @@ export function Tree(props: TreeProps) {
|
|
|
161
163
|
* what makes a mis-click cheap.
|
|
162
164
|
*/
|
|
163
165
|
const [menu, setMenu] = useState<{ id: string; label: string; x: number; y: number; armed?: boolean } | null>(null);
|
|
166
|
+
const root = useRef<HTMLDivElement | null>(null);
|
|
167
|
+
useLayoutEffect(() => {
|
|
168
|
+
if (menu !== null) root.current?.querySelector<HTMLButtonElement>(".weave-menu button")?.focus();
|
|
169
|
+
}, [menu?.id]);
|
|
170
|
+
const closeMenu = (): void => {
|
|
171
|
+
setMenu(null);
|
|
172
|
+
root.current?.querySelector<HTMLButtonElement>(".weave-tree-actions")?.focus();
|
|
173
|
+
};
|
|
164
174
|
const [editing, setEditing] = useState<{ id: string; label: string; value: string } | null>(null);
|
|
165
175
|
/**
|
|
166
176
|
* The parent path a pending "New folder" will be created under, or `null`.
|
|
@@ -190,15 +200,16 @@ export function Tree(props: TreeProps) {
|
|
|
190
200
|
// gesture `rename` already uses, and it shows *where* the folder will
|
|
191
201
|
// land, which a prompt cannot.
|
|
192
202
|
const parent = newFolderParent(target);
|
|
193
|
-
|
|
203
|
+
setState((current) => revealFolder(current, parent));
|
|
194
204
|
setDraft(parent);
|
|
195
205
|
setEditing({ id: DRAFT_FOLDER_ID, label: "", value: "" });
|
|
196
206
|
} else if (action === "rename") {
|
|
207
|
+
setState((current) => revealFolder(current, target.path.split("/").slice(0, -1).join("/")));
|
|
197
208
|
setEditing({ id: menu.id, label: menu.label, value: menu.label });
|
|
198
209
|
} else if (target.type !== "vault") {
|
|
199
210
|
// Before the request: asking after a delete is asking about a file that
|
|
200
211
|
// is already gone, and "keep editing" would strand the draft on a 404.
|
|
201
|
-
if (
|
|
212
|
+
if (!props.onMutate(affectedNoteSlugs(props.graph, target))) return;
|
|
202
213
|
await run(target.type === "note" ? deleteNote(fetchJson, target.path) : deleteFolder(fetchJson, target.path), deletesSelection(props.selectedId, target) ? "vault" : undefined);
|
|
203
214
|
}
|
|
204
215
|
};
|
|
@@ -247,24 +258,32 @@ export function Tree(props: TreeProps) {
|
|
|
247
258
|
// is the least useful moment to ask. Renaming back is one more rename.
|
|
248
259
|
// An open draft is a different question; `onMutate` asks that one.
|
|
249
260
|
if (target !== null && name && name !== current.label) {
|
|
250
|
-
if (
|
|
261
|
+
if (!props.onMutate(affectedNoteSlugs(props.graph, target))) return;
|
|
251
262
|
void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name));
|
|
252
263
|
}
|
|
253
264
|
};
|
|
254
265
|
|
|
255
266
|
return (
|
|
256
|
-
<div class="weave-tree" onKeyDown={(event) => {
|
|
267
|
+
<div class="weave-tree" ref={root} onKeyDown={(event) => {
|
|
268
|
+
if (menu !== null && event.key === "Escape") {
|
|
269
|
+
event.preventDefault(); event.stopPropagation(); closeMenu(); return;
|
|
270
|
+
}
|
|
257
271
|
const target = event.target as KeyTarget;
|
|
258
272
|
const next = treeKey(rows, state, props.selectedId, event.key, isTextEntry(target?.tagName ?? null, target?.isContentEditable === true));
|
|
259
273
|
if (!next.handled) return;
|
|
260
274
|
event.preventDefault();
|
|
261
275
|
setState(next.state);
|
|
262
|
-
if (next.selectedId !== null) props.onSelect(next.selectedId);
|
|
276
|
+
if (next.selectedId !== null) props.onSelect(next.selectedId, false, true);
|
|
263
277
|
}}>
|
|
264
278
|
<div class="weave-tree-controls">
|
|
265
279
|
<input type="search" class="weave-filter" value={state.query} placeholder={FILTER_PLACEHOLDER} aria-label={FILTER_LABEL} title={FILTER_HINT} onInput={(event) => setState(setQuery(state, event.currentTarget.value))} />
|
|
266
280
|
<button type="button" class="weave-chip" title={provenanceHint(state.provFilter)} onClick={() => setState(cycleProvenance(state))}>◧ {provenanceLabel(state.provFilter)}</button>
|
|
267
281
|
<button type="button" class="weave-chip" title={internalsHint(state.showInternals)} onClick={() => setState(toggleInternals(state))}>◧ {internalsLabel(state.showInternals)}</button>
|
|
282
|
+
<button type="button" class="weave-chip weave-tree-actions" aria-label="File actions" title="File actions for the selected item" onClick={(event) => {
|
|
283
|
+
const id = props.selectedId !== null && mutableTreeRow(props.selectedId) !== null ? props.selectedId : "vault";
|
|
284
|
+
const at = event.currentTarget.getBoundingClientRect();
|
|
285
|
+
setMenu({ id, label: props.graph?.model.nodes.find((node) => node.id === id)?.label ?? "Vault", x: at.left, y: at.bottom });
|
|
286
|
+
}}>···</button>
|
|
268
287
|
</div>
|
|
269
288
|
{empty === null ? (
|
|
270
289
|
<ul
|
|
@@ -278,7 +297,7 @@ export function Tree(props: TreeProps) {
|
|
|
278
297
|
setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
|
|
279
298
|
}}
|
|
280
299
|
>
|
|
281
|
-
{rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={() => { props.onSelect(view.id); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) {
|
|
300
|
+
{rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
|
|
282
301
|
</ul>
|
|
283
302
|
) : <p class="weave-tree-empty">{empty}</p>}
|
|
284
303
|
<p class="weave-tree-count">{rowCountLabel(rows)}</p>
|
|
@@ -286,8 +305,18 @@ export function Tree(props: TreeProps) {
|
|
|
286
305
|
const target = mutableTreeRow(menu.id)!;
|
|
287
306
|
return (
|
|
288
307
|
<>
|
|
289
|
-
<button type="button" class="weave-menu-backdrop" aria-label="Close context menu" onClick={
|
|
290
|
-
<div class="weave-menu" role="menu" style={{ left: `${menu.x}px`, top: `${menu.y}px` }}
|
|
308
|
+
<button type="button" class="weave-menu-backdrop" aria-label="Close context menu" onClick={closeMenu} />
|
|
309
|
+
<div class="weave-menu" role="menu" style={{ left: `${menu.x}px`, top: `${menu.y}px` }} onKeyDown={(event) => {
|
|
310
|
+
if (event.key === "Escape") { event.preventDefault(); closeMenu(); }
|
|
311
|
+
else if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
|
312
|
+
event.preventDefault();
|
|
313
|
+
const items = [...event.currentTarget.querySelectorAll<HTMLButtonElement>("button")];
|
|
314
|
+
const index = items.indexOf(document.activeElement as HTMLButtonElement);
|
|
315
|
+
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
|
|
316
|
+
items[next]?.focus();
|
|
317
|
+
}
|
|
318
|
+
event.stopPropagation();
|
|
319
|
+
}}>
|
|
291
320
|
<button type="button" role="menuitem" onClick={() => void act("newFolder")}>New folder…</button>
|
|
292
321
|
{target.type !== "vault" ? (
|
|
293
322
|
<>
|
|
@@ -74,6 +74,17 @@ export function initialTreeView(roots: readonly string[] = ["vault", "repository
|
|
|
74
74
|
return { expanded: new Set(roots), showInternals: false, provFilter: null, query: "" };
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** Make an inline file operation visible even when its destination was filtered or collapsed. */
|
|
78
|
+
export function revealFolder(state: TreeViewState, path: string): TreeViewState {
|
|
79
|
+
const expanded = new Set(state.expanded).add("vault");
|
|
80
|
+
let parent = "";
|
|
81
|
+
for (const part of path ? path.split("/") : []) {
|
|
82
|
+
parent = parent ? `${parent}/${part}` : part;
|
|
83
|
+
expanded.add(`vfolder:${parent}`);
|
|
84
|
+
}
|
|
85
|
+
return { ...state, expanded, query: "", provFilter: null };
|
|
86
|
+
}
|
|
87
|
+
|
|
77
88
|
// --- reassembling the wire payload -------------------------------------------------
|
|
78
89
|
|
|
79
90
|
/**
|
|
@@ -224,6 +235,14 @@ export function mutableTreeRow(id: string): { type: "note" | "folder" | "vault";
|
|
|
224
235
|
return null;
|
|
225
236
|
}
|
|
226
237
|
|
|
238
|
+
/** Note slugs a note or folder mutation can invalidate. */
|
|
239
|
+
export function affectedNoteSlugs(graph: GraphPayload | null, target: { type: "note" | "folder" | "vault"; path: string }): string[] {
|
|
240
|
+
if (target.type === "note") return [target.path];
|
|
241
|
+
if (target.type !== "folder" || graph === null) return [];
|
|
242
|
+
const prefix = `note:${target.path}/`;
|
|
243
|
+
return graph.model.nodes.filter((node) => node.kind === "note" && node.id.startsWith(prefix)).map((node) => node.id.slice(5));
|
|
244
|
+
}
|
|
245
|
+
|
|
227
246
|
export function deletesSelection(selectedId: string | null, target: { type: "note" | "folder" | "vault"; path: string }): boolean {
|
|
228
247
|
if (selectedId === null) return false;
|
|
229
248
|
if (target.type === "note") return selectedId === `note:${target.path}`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Fetching and polling for the browser workspace. */
|
|
2
2
|
|
|
3
|
-
import type { GraphPayload } from "../shared/wire";
|
|
3
|
+
import type { GraphPayload, NotePayload } from "../shared/wire";
|
|
4
4
|
import type { ApiResult, FetchLike } from "./api";
|
|
5
5
|
import { fetchGraph, fetchNote } from "./api";
|
|
6
6
|
import type { WorkspaceState } from "./state";
|
|
@@ -115,3 +115,21 @@ export function startWorkspace(opts: WorkspaceOptions): WorkspaceHandle {
|
|
|
115
115
|
},
|
|
116
116
|
};
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
/** Per-document loading: retry failures, and drop completions after a tab leaves. */
|
|
120
|
+
export function watchNote(fetch: FetchLike, slug: string, notify: (result: ApiResult<NotePayload>) => void,
|
|
121
|
+
defer: (fn: () => void, ms: number) => () => void = (fn, ms) => {
|
|
122
|
+
const timer = setTimeout(fn, ms); return () => clearTimeout(timer);
|
|
123
|
+
}): () => void {
|
|
124
|
+
let stopped = false;
|
|
125
|
+
let cancel: (() => void) | null = null;
|
|
126
|
+
const load = async (): Promise<void> => {
|
|
127
|
+
if (stopped) return;
|
|
128
|
+
const result = await fetchNote(fetch, slug);
|
|
129
|
+
if (stopped) return;
|
|
130
|
+
notify(result);
|
|
131
|
+
if (!result.ok) cancel = defer(() => void load(), POLL_MS);
|
|
132
|
+
};
|
|
133
|
+
void load();
|
|
134
|
+
return () => { stopped = true; cancel?.(); };
|
|
135
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Create a complete archive before offering a download; originals are read only. */
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { mkdtemp, open, readdir, realpath, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { Zip, ZipDeflate } from "fflate";
|
|
7
|
+
|
|
8
|
+
export async function createVaultBackup(vaultRoot: string): Promise<{ path: string; dispose: () => Promise<void> }> {
|
|
9
|
+
const directory = await mkdtemp(join(tmpdir(), "pi-weave-backup-"));
|
|
10
|
+
const path = join(directory, "vault.zip");
|
|
11
|
+
const dispose = (): Promise<void> => rm(directory, { recursive: true, force: true });
|
|
12
|
+
try {
|
|
13
|
+
const output = await open(path, "w");
|
|
14
|
+
try {
|
|
15
|
+
let pending = Promise.resolve();
|
|
16
|
+
let failure: Error | null = null;
|
|
17
|
+
let entries = 0;
|
|
18
|
+
const zip = new Zip((error, chunk) => {
|
|
19
|
+
if (error) { failure = error; return; }
|
|
20
|
+
pending = pending.then(() => output.writeFile(chunk));
|
|
21
|
+
});
|
|
22
|
+
const flush = async (): Promise<void> => { await pending; if (failure) throw failure; };
|
|
23
|
+
const walk = async (directory: string, prefix: string, ancestors: ReadonlySet<string>): Promise<void> => {
|
|
24
|
+
const resolved = await realpath(directory);
|
|
25
|
+
if (ancestors.has(resolved)) throw new Error(`Symbolic-link cycle in vault: ${prefix}`);
|
|
26
|
+
const next = new Set([...ancestors, resolved]);
|
|
27
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
28
|
+
// ZIP readers on Windows treat backslashes as path separators.
|
|
29
|
+
if (entry.name.includes("\\")) throw new Error(`Unsupported backup filename: ${entry.name}`);
|
|
30
|
+
const source = join(directory, entry.name);
|
|
31
|
+
const info = await stat(source);
|
|
32
|
+
const name = prefix + entry.name + (info.isDirectory() ? "/" : "");
|
|
33
|
+
if (!info.isDirectory() && !info.isFile()) throw new Error(`Unsupported vault entry: ${name}`);
|
|
34
|
+
if (++entries > 65535 || info.isFile() && info.size > 0xffffffff) throw new Error("Vault exceeds ZIP format limits");
|
|
35
|
+
const file = new ZipDeflate(name, { level: 1 });
|
|
36
|
+
file.mtime = new Date(Math.max(new Date(1980, 0, 1).getTime(), Math.min(new Date(2099, 11, 31).getTime(), info.mtime.getTime())));
|
|
37
|
+
zip.add(file);
|
|
38
|
+
await flush();
|
|
39
|
+
if (info.isDirectory()) {
|
|
40
|
+
file.push(new Uint8Array(), true);
|
|
41
|
+
await flush();
|
|
42
|
+
await walk(source, name, next);
|
|
43
|
+
} else {
|
|
44
|
+
for await (const chunk of createReadStream(source)) {
|
|
45
|
+
file.push(chunk as Uint8Array);
|
|
46
|
+
await flush();
|
|
47
|
+
}
|
|
48
|
+
file.push(new Uint8Array(), true);
|
|
49
|
+
await flush();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
await walk(vaultRoot, "", new Set());
|
|
54
|
+
zip.end();
|
|
55
|
+
await flush();
|
|
56
|
+
} finally { await output.close(); }
|
|
57
|
+
return { path, dispose };
|
|
58
|
+
} catch (error) { await dispose(); throw error; }
|
|
59
|
+
}
|
package/src/web/server/routes.ts
CHANGED
|
@@ -43,7 +43,10 @@
|
|
|
43
43
|
* `resolveNotePath` (via `getNote`) rejects unsafe slugs.
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
-
import { createHash } from "node:crypto";
|
|
46
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
47
|
+
import { createReadStream } from "node:fs";
|
|
48
|
+
import { pipeline } from "node:stream/promises";
|
|
49
|
+
import { createVaultBackup } from "./backup";
|
|
47
50
|
import { readFile } from "node:fs/promises";
|
|
48
51
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
49
52
|
import type { WorkspaceSnapshot } from "../../core/cache/workspace";
|
|
@@ -65,6 +68,8 @@ import type {
|
|
|
65
68
|
} from "../shared/wire";
|
|
66
69
|
import { WIRE_MODEL_OMITTED_KEYS } from "../shared/wire";
|
|
67
70
|
import { renderPage } from "./page";
|
|
71
|
+
import { isWorkspaceViewId, type WorkspaceStateStore } from "./workspace-state";
|
|
72
|
+
import { parseWorkspaceLayout } from "../shared/workspace";
|
|
68
73
|
import type { RequestFacts, SecurityPolicy } from "./security";
|
|
69
74
|
import { requestFacts } from "./security";
|
|
70
75
|
|
|
@@ -78,6 +83,7 @@ export interface RouteDeps {
|
|
|
78
83
|
bundlePath: string;
|
|
79
84
|
/** Test seam for `POST /api/open`; defaults to the real editor shell-out. */
|
|
80
85
|
openNote?: ((slug: string) => Promise<boolean>) | undefined;
|
|
86
|
+
workspaceState: WorkspaceStateStore;
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
const JSON_TYPE = "application/json; charset=utf-8";
|
|
@@ -160,8 +166,9 @@ export function parseTarget(url: string): Target {
|
|
|
160
166
|
* the caller has exactly one failure branch.
|
|
161
167
|
*/
|
|
162
168
|
export const MAX_BODY_BYTES = 64 * 1024;
|
|
169
|
+
const MAX_WORKSPACE_STATE_BODY_BYTES = 256 * 1024;
|
|
163
170
|
|
|
164
|
-
export async function readJsonBody(req: IncomingMessage): Promise<unknown | null> {
|
|
171
|
+
export async function readJsonBody(req: IncomingMessage, maxBytes = MAX_BODY_BYTES): Promise<unknown | null> {
|
|
165
172
|
const chunks: Buffer[] = [];
|
|
166
173
|
let size = 0;
|
|
167
174
|
// `IncomingMessage` yields Buffers unless `setEncoding` was called, and we
|
|
@@ -169,7 +176,7 @@ export async function readJsonBody(req: IncomingMessage): Promise<unknown | null
|
|
|
169
176
|
// pretending to be defensive.
|
|
170
177
|
for await (const chunk of req as AsyncIterable<Buffer>) {
|
|
171
178
|
size += chunk.length;
|
|
172
|
-
if (size >
|
|
179
|
+
if (size > maxBytes) return null;
|
|
173
180
|
chunks.push(chunk);
|
|
174
181
|
}
|
|
175
182
|
if (chunks.length === 0) return null;
|
|
@@ -375,6 +382,18 @@ async function route(
|
|
|
375
382
|
if (method === "GET" && path === "/") return sendShell(deps, res);
|
|
376
383
|
if (method === "GET" && path === "/app.js") return sendBundle(deps, res);
|
|
377
384
|
if (method === "GET" && path === "/api/graph") return sendGraph(deps, req, res);
|
|
385
|
+
if (method === "GET" && path === "/api/backup") {
|
|
386
|
+
const backup = await createVaultBackup(deps.vaultRoot);
|
|
387
|
+
try {
|
|
388
|
+
res.writeHead(200, { ...baseHeaders(), "content-type": "application/zip", "content-disposition": `attachment; filename="weave-vault-${new Date().toISOString().slice(0, 10)}.zip"`, "cache-control": "no-store" });
|
|
389
|
+
await pipeline(createReadStream(backup.path), res);
|
|
390
|
+
} finally { await backup.dispose(); }
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (method === "GET" && path === "/api/workspace-state") {
|
|
394
|
+
return sendJson(res, 200, { viewId: randomUUID(), layout: await deps.workspaceState.readLatest(), info: { vaultRoot: deps.vaultRoot, version: (JSON.parse(await readFile(new URL("../../../package.json", import.meta.url), "utf8")) as { version: string }).version } }, { "cache-control": "no-store" });
|
|
395
|
+
}
|
|
396
|
+
if (method === "POST" && path === "/api/workspace-state") return saveWorkspaceState(deps, req, res);
|
|
378
397
|
if (path.startsWith("/api/folder/")) {
|
|
379
398
|
const handled = await routeFolder(deps, method, path.slice("/api/folder/".length), req, res);
|
|
380
399
|
if (handled) return;
|
|
@@ -396,6 +415,29 @@ async function route(
|
|
|
396
415
|
|
|
397
416
|
// --- handlers ----------------------------------------------------------------
|
|
398
417
|
|
|
418
|
+
async function saveWorkspaceState(deps: RouteDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
419
|
+
const body = await readJsonBody(req, MAX_WORKSPACE_STATE_BODY_BYTES);
|
|
420
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
421
|
+
sendJson(res, 400, { error: "Expected a workspace state object" });
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const record = body as Record<string, unknown>;
|
|
425
|
+
if (
|
|
426
|
+
Object.keys(record).length !== 2 || !Object.hasOwn(record, "viewId") || !Object.hasOwn(record, "layout") ||
|
|
427
|
+
!isWorkspaceViewId(record.viewId)
|
|
428
|
+
) {
|
|
429
|
+
sendJson(res, 400, { error: "Expected a UUID viewId and a valid layout" });
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const layout = parseWorkspaceLayout(record.layout);
|
|
433
|
+
if (layout === null) {
|
|
434
|
+
sendJson(res, 400, { error: "Invalid workspace layout" });
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
await deps.workspaceState.write(record.viewId, layout);
|
|
438
|
+
sendJson(res, 200, { ok: true }, { "cache-control": "no-store" });
|
|
439
|
+
}
|
|
440
|
+
|
|
399
441
|
function sendShell(deps: RouteDeps, res: ServerResponse): void {
|
|
400
442
|
const page = renderPage({
|
|
401
443
|
bootstrap: { cwd: deps.cwd },
|
package/src/web/server/server.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { WorkspaceCache } from "../../core/cache/workspace";
|
|
|
19
19
|
import { resolveVaultRoot } from "../../core/paths";
|
|
20
20
|
import { handleRequest, type RouteDeps } from "./routes";
|
|
21
21
|
import { createSecurityPolicy, type SecurityPolicy } from "./security";
|
|
22
|
+
import { createWorkspaceStateStore, defaultWorkspaceStateDir } from "./workspace-state";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* The committed bundle, resolved relative to this module rather than to
|
|
@@ -41,6 +42,8 @@ export interface StartWorkspaceServerOptions {
|
|
|
41
42
|
cookieName?: string | undefined;
|
|
42
43
|
/** Absolute path to the client bundle. Defaults to the committed one. */
|
|
43
44
|
bundlePath?: string | undefined;
|
|
45
|
+
/** Presentation snapshot directory. Defaults to the user's pi-weave config directory. */
|
|
46
|
+
presentationStateDir?: string | undefined;
|
|
44
47
|
/** Test seam for `POST /api/open`. */
|
|
45
48
|
openNote?: ((slug: string) => Promise<boolean>) | undefined;
|
|
46
49
|
}
|
|
@@ -61,6 +64,7 @@ export interface WorkspaceServer {
|
|
|
61
64
|
export async function startWorkspaceServer(opts: StartWorkspaceServerOptions): Promise<WorkspaceServer> {
|
|
62
65
|
const vaultRoot = opts.vaultRoot ?? resolveVaultRoot();
|
|
63
66
|
const cache = opts.cache ?? new WorkspaceCache({ cwd: opts.cwd, vaultRoot });
|
|
67
|
+
const workspaceState = await createWorkspaceStateStore(opts.cwd, vaultRoot, opts.presentationStateDir ?? defaultWorkspaceStateDir());
|
|
64
68
|
// `deps` is assembled before `listen` because the request handler closes
|
|
65
69
|
// over it, but the security policy needs the bound port — so the policy
|
|
66
70
|
// slot is filled after binding and the handler reads it through the
|
|
@@ -104,6 +108,7 @@ export async function startWorkspaceServer(opts: StartWorkspaceServerOptions): P
|
|
|
104
108
|
security,
|
|
105
109
|
bundlePath: opts.bundlePath ?? defaultBundlePath(),
|
|
106
110
|
...(opts.openNote !== undefined ? { openNote: opts.openNote } : {}),
|
|
111
|
+
workspaceState,
|
|
107
112
|
};
|
|
108
113
|
|
|
109
114
|
return {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { findGitRoot } from "../../core/git";
|
|
6
|
+
import { withMutationQueue } from "../../core/mutex";
|
|
7
|
+
import { parseWorkspaceLayout, type WorkspaceLayout } from "../shared/workspace";
|
|
8
|
+
|
|
9
|
+
const MAX_SNAPSHOTS = 8;
|
|
10
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
11
|
+
|
|
12
|
+
export function isWorkspaceViewId(value: unknown): value is string {
|
|
13
|
+
return typeof value === "string" && UUID.test(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Snapshot {
|
|
17
|
+
viewId: string;
|
|
18
|
+
savedAt: number;
|
|
19
|
+
layout: WorkspaceLayout;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SnapshotFile {
|
|
23
|
+
version: 1;
|
|
24
|
+
viewId: string;
|
|
25
|
+
layout: WorkspaceLayout;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkspaceStateStore {
|
|
29
|
+
readLatest(): Promise<WorkspaceLayout | null>;
|
|
30
|
+
write(viewId: string, layout: WorkspaceLayout): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function defaultWorkspaceStateDir(): string {
|
|
34
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "pi-weave", "presentation");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createWorkspaceStateStore(
|
|
38
|
+
cwd: string,
|
|
39
|
+
vaultRoot: string,
|
|
40
|
+
stateDir = defaultWorkspaceStateDir(),
|
|
41
|
+
): WorkspaceStateStore {
|
|
42
|
+
let directoryPromise: Promise<string> | null = null;
|
|
43
|
+
const directory = (): Promise<string> => {
|
|
44
|
+
directoryPromise ??= (async () => {
|
|
45
|
+
const [vault, repo] = await Promise.all([canonical(vaultRoot), findGitRoot(cwd)]);
|
|
46
|
+
const workspaceRoot = await canonical(repo ?? cwd);
|
|
47
|
+
const id = createHash("sha256").update(JSON.stringify([vault, workspaceRoot])).digest("hex");
|
|
48
|
+
return join(stateDir, id);
|
|
49
|
+
})();
|
|
50
|
+
return directoryPromise;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async readLatest() {
|
|
55
|
+
return (await readSnapshots(await directory()))[0]?.layout ?? null;
|
|
56
|
+
},
|
|
57
|
+
async write(viewId, layout) {
|
|
58
|
+
if (!isWorkspaceViewId(viewId)) throw new Error("viewId must be a UUID");
|
|
59
|
+
const dir = await directory();
|
|
60
|
+
const file = join(dir, `${viewId}.json`);
|
|
61
|
+
await withMutationQueue(file, async () => {
|
|
62
|
+
await writeSnapshot(dir, file, { version: 1, viewId, layout });
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function canonical(path: string): Promise<string> {
|
|
69
|
+
const absolute = resolve(path);
|
|
70
|
+
try {
|
|
71
|
+
return await fs.realpath(absolute);
|
|
72
|
+
} catch {
|
|
73
|
+
return absolute;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readSnapshots(directory: string): Promise<Snapshot[]> {
|
|
78
|
+
let names: string[];
|
|
79
|
+
try {
|
|
80
|
+
names = await fs.readdir(directory);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
const snapshots = await Promise.all(names.filter((name) => UUID.test(name.slice(0, -5)) && name.endsWith(".json")).map(async (name) => {
|
|
86
|
+
const file = join(directory, name);
|
|
87
|
+
try {
|
|
88
|
+
const [text, info] = await Promise.all([fs.readFile(file, "utf8"), fs.stat(file)]);
|
|
89
|
+
const raw = JSON.parse(text) as SnapshotFile;
|
|
90
|
+
const layout = raw.version === 1 && raw.viewId === name.slice(0, -5) ? parseWorkspaceLayout(raw.layout) : null;
|
|
91
|
+
return layout ? { viewId: raw.viewId, savedAt: info.mtimeMs, layout } : null;
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}));
|
|
96
|
+
return snapshots.filter((item): item is Snapshot => item !== null).sort((a, b) => b.savedAt - a.savedAt).slice(0, MAX_SNAPSHOTS);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function writeSnapshot(directory: string, file: string, state: SnapshotFile): Promise<void> {
|
|
100
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
101
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
102
|
+
try {
|
|
103
|
+
await fs.writeFile(temporary, JSON.stringify(state), { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
104
|
+
await fs.rename(temporary, file);
|
|
105
|
+
const entries = await Promise.all((await fs.readdir(directory)).filter((name) => name.endsWith(".json")).map(async (name) => {
|
|
106
|
+
const path = join(directory, name);
|
|
107
|
+
try {
|
|
108
|
+
return { path, mtime: (await fs.stat(path)).mtimeMs };
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
}));
|
|
114
|
+
const existing = entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
|
|
115
|
+
existing.sort((a, b) => b.mtime - a.mtime);
|
|
116
|
+
await Promise.all(existing.slice(MAX_SNAPSHOTS).map(({ path }) => fs.rm(path, { force: true })));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
await fs.rm(temporary, { force: true });
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/web/shared/layout.ts
CHANGED
|
@@ -41,14 +41,13 @@ const ALPHA_MIN = 0.001;
|
|
|
41
41
|
* Mutable because picking these numbers is an act of taste, not of
|
|
42
42
|
* derivation: the difference between "one hairball" and "legible groups" is a
|
|
43
43
|
* ratio between containment cohesion, charge and centre gravity that is far
|
|
44
|
-
* easier to *see* than to reason about. The
|
|
45
|
-
* `client/graph/tuner.model.ts` and docs/weave-workspace.md §15.7)
|
|
46
|
-
* and re-
|
|
44
|
+
* easier to *see* than to reason about. The settings sliders (see
|
|
45
|
+
* `client/graph/tuner.model.ts` and docs/weave-workspace.md §15.7) update this record
|
|
46
|
+
* and re-run the layout live, so a human can find the values by eye and they then
|
|
47
47
|
* get frozen back into {@link FORCE_DEFAULTS}.
|
|
48
48
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* the one writer, and it is unreachable without the query flag.
|
|
49
|
+
* `computeLayout(model, { seed })` stays deterministic for a given set of forces.
|
|
50
|
+
* Settings are the production writer of this record.
|
|
52
51
|
*
|
|
53
52
|
* ponytail: module-level mutable state, fine at one graph per page — thread it
|
|
54
53
|
* as a `LayoutOptions` field if a second concurrent consumer ever appears.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Validated preferences, stored alongside the workspace's existing snapshots. */
|
|
2
|
+
import { FORCE_DEFAULTS } from "./layout";
|
|
3
|
+
import type { ForceConstants } from "./layout";
|
|
4
|
+
import { ACCENTS, THEMES, isThemeChoice } from "./themes";
|
|
5
|
+
import type { AccentChoice, PaletteChoice } from "./themes";
|
|
6
|
+
|
|
7
|
+
/** One slider: which constant it drives, and the range it may drive it over. */
|
|
8
|
+
export interface SliderSpec {
|
|
9
|
+
readonly key: keyof ForceConstants;
|
|
10
|
+
readonly label: string;
|
|
11
|
+
/** One line under the label saying what moving it does. */
|
|
12
|
+
readonly hint: string;
|
|
13
|
+
readonly min: number;
|
|
14
|
+
readonly max: number;
|
|
15
|
+
readonly step: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Graph forces, ordered from group cohesion to repulsion and centering. */
|
|
19
|
+
export const FORCE_SLIDERS: readonly SliderSpec[] = [
|
|
20
|
+
{ key: "containsStrength", label: "Group cohesion", hint: "how hard a parent holds its children — group cohesion", min: 0, max: 1, step: 0.01 },
|
|
21
|
+
{ key: "containsRest", label: "Group radius", hint: "how far children sit from their parent — rosette radius", min: 10, max: 300, step: 5 },
|
|
22
|
+
{ key: "relationStrength", label: "Link strength", hint: "pull of links-to / mentions across groups", min: 0, max: 1, step: 0.01 },
|
|
23
|
+
{ key: "relationDistance", label: "Link distance", hint: "how long those cross-group springs are", min: 20, max: 600, step: 10 },
|
|
24
|
+
{ key: "charge", label: "Repulsion", hint: "node-to-node repulsion — more negative pushes harder", min: -1200, max: 0, step: 10 },
|
|
25
|
+
{ key: "chargeMax", label: "Repulsion range", hint: "distance past which repulsion stops — caps overall spread", min: 100, max: 4000, step: 50 },
|
|
26
|
+
{ key: "center", label: "Center gravity", hint: "pull toward the origin — too much makes one blob", min: 0, max: 0.3, step: 0.005 },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
export interface Preferences {
|
|
31
|
+
readonly lightTheme: PaletteChoice;
|
|
32
|
+
readonly darkTheme: PaletteChoice;
|
|
33
|
+
readonly accent: AccentChoice;
|
|
34
|
+
readonly fontSize: number;
|
|
35
|
+
readonly readable: boolean;
|
|
36
|
+
readonly spellcheck: boolean;
|
|
37
|
+
readonly defaultEdit: boolean;
|
|
38
|
+
readonly startup: "restore" | "empty";
|
|
39
|
+
readonly focusNewTabs: boolean;
|
|
40
|
+
readonly groupColors: boolean;
|
|
41
|
+
readonly forces: ForceConstants;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_PREFERENCES: Preferences = {
|
|
45
|
+
lightTheme: "light", darkTheme: "dark", accent: "theme", fontSize: 14, readable: true, spellcheck: true, defaultEdit: false,
|
|
46
|
+
startup: "restore", focusNewTabs: true, groupColors: true, forces: { ...FORCE_DEFAULTS },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parsePreferences(input: unknown): Preferences | null {
|
|
54
|
+
if (!record(input)) return null;
|
|
55
|
+
const legacy = !Object.hasOwn(input, "lightTheme") && !Object.hasOwn(input, "darkTheme");
|
|
56
|
+
const value = legacy ? { ...input, lightTheme: "light", darkTheme: "dark" } : input;
|
|
57
|
+
if (Object.keys(value).length !== Object.keys(DEFAULT_PREFERENCES).length) return null;
|
|
58
|
+
for (const scheme of ["light", "dark"] as const) {
|
|
59
|
+
const theme = value[`${scheme}Theme`];
|
|
60
|
+
if (!isThemeChoice(theme) || theme === "system" || THEMES[theme].scheme !== scheme) return null;
|
|
61
|
+
}
|
|
62
|
+
if (value["accent"] !== "theme" && (typeof value["accent"] !== "string" || !Object.hasOwn(ACCENTS, value["accent"]))) return null;
|
|
63
|
+
if (typeof value["fontSize"] !== "number" || !Number.isInteger(value["fontSize"]) || value["fontSize"] < 12 || value["fontSize"] > 22) return null;
|
|
64
|
+
for (const key of ["readable", "spellcheck", "defaultEdit", "focusNewTabs", "groupColors"] as const) {
|
|
65
|
+
if (typeof value[key] !== "boolean") return null;
|
|
66
|
+
}
|
|
67
|
+
if (value["startup"] !== "restore" && value["startup"] !== "empty") return null;
|
|
68
|
+
const forces = value["forces"];
|
|
69
|
+
if (!record(forces) || Object.keys(forces).length !== FORCE_SLIDERS.length) return null;
|
|
70
|
+
for (const { key, min, max } of FORCE_SLIDERS) {
|
|
71
|
+
const force = forces[key];
|
|
72
|
+
if (typeof force !== "number" || !Number.isFinite(force) || force < min || force > max) return null;
|
|
73
|
+
}
|
|
74
|
+
return value as unknown as Preferences;
|
|
75
|
+
}
|
|
76
|
+
|