@silverbulletmd/silverbullet 2.10.0 → 2.12.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 (105) hide show
  1. package/README.md +6 -6
  2. package/client/asset_bundle/bundle.ts +0 -1
  3. package/client/config.ts +343 -0
  4. package/client/data/kv_primitives.ts +2 -2
  5. package/client/markdown_parser/constants.ts +5 -1
  6. package/client/plugos/hooks/command.ts +8 -6
  7. package/client/plugos/hooks/event.ts +60 -24
  8. package/client/plugos/hooks/mq.ts +0 -3
  9. package/client/plugos/hooks/slash_command.ts +3 -20
  10. package/client/plugos/hooks/syscall.ts +0 -4
  11. package/client/plugos/manifest_cache.ts +2 -34
  12. package/client/plugos/plug.ts +0 -3
  13. package/client/plugos/plug_compile.ts +3 -13
  14. package/client/plugos/sandboxes/sandbox.ts +2 -2
  15. package/client/plugos/sandboxes/worker_sandbox.ts +19 -9
  16. package/client/plugos/syscalls/capture.ts +104 -0
  17. package/client/plugos/syscalls/editor.ts +197 -56
  18. package/client/plugos/syscalls/event.ts +12 -9
  19. package/client/plugos/syscalls/fetch.ts +0 -4
  20. package/client/plugos/syscalls/icon.ts +46 -0
  21. package/client/plugos/syscalls/index.ts +33 -13
  22. package/client/plugos/syscalls/jsonschema.ts +0 -1
  23. package/client/plugos/syscalls/mq.ts +0 -1
  24. package/client/plugos/syscalls/navigator.ts +138 -0
  25. package/client/plugos/syscalls/search.ts +44 -0
  26. package/client/plugos/syscalls/shell.ts +0 -1
  27. package/client/plugos/syscalls/space.ts +250 -7
  28. package/client/plugos/syscalls/sync.ts +70 -4
  29. package/client/plugos/syscalls/system.ts +39 -0
  30. package/client/plugos/system.ts +9 -7
  31. package/client/plugos/types.ts +6 -6
  32. package/client/plugos/worker_runtime.ts +0 -2
  33. package/client/space_lua/aggregates.ts +6 -22
  34. package/client/space_lua/ast.ts +0 -5
  35. package/client/space_lua/ast_narrow.ts +0 -5
  36. package/client/space_lua/budget.ts +113 -0
  37. package/client/space_lua/budget_ui.ts +49 -0
  38. package/client/space_lua/eval.ts +113 -88
  39. package/client/space_lua/labels.ts +0 -4
  40. package/client/space_lua/numeric.ts +2 -7
  41. package/client/space_lua/parse.ts +0 -17
  42. package/client/space_lua/quarantine.ts +127 -0
  43. package/client/space_lua/query_collection.ts +18 -51
  44. package/client/space_lua/query_env.ts +0 -2
  45. package/client/space_lua/render_lua_markdown.ts +2 -7
  46. package/client/space_lua/render_widget.ts +18 -1
  47. package/client/space_lua/runtime.ts +64 -77
  48. package/client/space_lua/stdlib/format.ts +0 -12
  49. package/client/space_lua/stdlib/js.ts +0 -2
  50. package/client/space_lua/stdlib/load.ts +0 -1
  51. package/client/space_lua/stdlib/math.ts +0 -7
  52. package/client/space_lua/stdlib/net.ts +0 -4
  53. package/client/space_lua/stdlib/os.ts +0 -10
  54. package/client/space_lua/stdlib/pattern.ts +18 -23
  55. package/client/space_lua/stdlib/space_lua.ts +1 -2
  56. package/client/space_lua/stdlib/string_pack.ts +4 -7
  57. package/client/space_lua/stdlib/table.ts +0 -5
  58. package/client/space_lua/stdlib.ts +18 -31
  59. package/client/space_lua/tonumber.ts +0 -9
  60. package/dist/plug-compile.js +3 -4
  61. package/package.json +52 -41
  62. package/plug-api/lib/async.ts +1 -6
  63. package/plug-api/lib/collation.ts +22 -0
  64. package/plug-api/lib/crypto.ts +16 -15
  65. package/plug-api/lib/dates.ts +33 -0
  66. package/plug-api/lib/fuzzy.ts +282 -0
  67. package/plug-api/lib/json.ts +0 -6
  68. package/plug-api/lib/limited_map.ts +0 -2
  69. package/plug-api/lib/link_write.ts +27 -0
  70. package/plug-api/lib/ref.ts +28 -5
  71. package/plug-api/lib/resolve.ts +1 -9
  72. package/plug-api/lib/resolve_path.ts +290 -0
  73. package/plug-api/lib/shortcut.ts +15 -0
  74. package/plug-api/lib/tags.ts +0 -3
  75. package/plug-api/lib/transclusion.ts +28 -1
  76. package/plug-api/lib/tree.ts +0 -5
  77. package/plug-api/lib/yaml.ts +5 -44
  78. package/plug-api/syscall.ts +1 -5
  79. package/plug-api/syscalls/config.ts +2 -2
  80. package/plug-api/syscalls/editor.ts +75 -213
  81. package/plug-api/syscalls/icon.ts +18 -0
  82. package/plug-api/syscalls/index.ts +22 -4
  83. package/plug-api/syscalls/search.ts +11 -0
  84. package/plug-api/syscalls/space.ts +122 -5
  85. package/plug-api/syscalls/sync.ts +31 -2
  86. package/plug-api/syscalls/system.ts +46 -0
  87. package/plug-api/syscalls.ts +2 -0
  88. package/plug-api/system_mock.ts +13 -0
  89. package/plug-api/types/client.ts +20 -4
  90. package/plug-api/types/datastore.ts +11 -2
  91. package/plug-api/types/index.ts +10 -0
  92. package/plug-api/types/manifest.ts +7 -0
  93. package/plug-api/types/profile.ts +13 -0
  94. package/plug-api/types/revisions.ts +67 -0
  95. package/plug-api/ui/description.ts +64 -0
  96. package/plug-api/ui/hover.ts +69 -0
  97. package/plug-api/ui/index.ts +58 -2
  98. package/plug-api/ui/scroll.ts +30 -0
  99. package/plug-api/ui/tree_model.ts +285 -0
  100. package/plug-api/ui/tree_types.ts +67 -0
  101. package/plug-api/ui/use_fit_collapse.ts +67 -0
  102. package/plugs/builtin_plugs.ts +0 -1
  103. package/plugs/index/types.ts +9 -0
  104. package/plug-api/lib/memory_cache.ts +0 -21
  105. /package/plug-api/{ui → lib}/panel_styles.ts +0 -0
@@ -14,6 +14,7 @@ import type {
14
14
  SlashCompletionOption,
15
15
  SlashCompletions,
16
16
  } from "@silverbulletmd/silverbullet/type/client";
17
+ import { matchesNodeContexts } from "../../lib/node_contexts.ts";
17
18
 
18
19
  const slashCommandRegexp = /([^\w:]|^)\/[\w#-]*/;
19
20
 
@@ -46,7 +47,6 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
46
47
  });
47
48
  }
48
49
  }
49
- // Iterate over script defined slash commands
50
50
  for (const command of Object.values(
51
51
  this.client.config.get<Record<string, SlashCommand>>("slashCommands", {}),
52
52
  )) {
@@ -70,28 +70,15 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
70
70
  // the user is referring to preceding text, not what comes after.
71
71
  const currentNode = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
72
72
  if (
73
- currentNode.type.name === "CommentBlock" ||
73
+ currentNode.type.name === "CommentMarkerBlock" ||
74
74
  currentNode.type.name === "Link"
75
75
  ) {
76
76
  return null;
77
77
  }
78
78
 
79
- // Check if the slash command is available in the current context
80
79
  const parentNodes = this.client.extractParentNodes(ctx.state, currentNode);
81
80
  for (const def of this.slashCommands) {
82
- if (
83
- def.onlyContexts &&
84
- !def.onlyContexts.some((context) =>
85
- parentNodes.some((node) => node.startsWith(context)),
86
- )
87
- ) {
88
- continue;
89
- }
90
- if (
91
- def.exceptContexts?.some((context) =>
92
- parentNodes.some((node) => node.startsWith(context)),
93
- )
94
- ) {
81
+ if (!matchesNodeContexts(def, parentNodes)) {
95
82
  continue;
96
83
  }
97
84
  options.push({
@@ -99,7 +86,6 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
99
86
  detail: def.description,
100
87
  boost: def.priority,
101
88
  apply: () => {
102
- // Delete slash command part
103
89
  this.client.editorView.dispatch({
104
90
  changes: {
105
91
  from: prefix!.from + prefixText.indexOf("/"),
@@ -107,7 +93,6 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
107
93
  insert: "",
108
94
  },
109
95
  });
110
- // Replace with whatever the completion is
111
96
  safeRun(async () => {
112
97
  await def.run!();
113
98
  this.client.focus();
@@ -126,7 +111,6 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
126
111
  detail: slashCompletion.detail,
127
112
  boost: slashCompletion.order && -slashCompletion.order,
128
113
  apply: () => {
129
- // Delete slash command part
130
114
  this.client.editorView.dispatch({
131
115
  changes: {
132
116
  from: prefix!.from + prefixText.indexOf("/"),
@@ -134,7 +118,6 @@ export class SlashCommandHook implements Hook<SlashCommandHookT> {
134
118
  insert: "",
135
119
  },
136
120
  });
137
- // Replace with whatever the completion is
138
121
  safeRun(async () => {
139
122
  await this.client.clientSystem.system.invokeFunction(
140
123
  slashCompletion.invoke,
@@ -13,7 +13,6 @@ export class SyscallHook implements Hook<SyscallHookT> {
13
13
  }
14
14
 
15
15
  registerSyscalls(system: System<SyscallHookT>) {
16
- // Register syscalls from all loaded plugs
17
16
  for (const plug of system.loadedPlugs.values()) {
18
17
  const syscalls: SysCallMapping = {};
19
18
 
@@ -30,7 +29,6 @@ export class SyscallHook implements Hook<SyscallHookT> {
30
29
  ? syscallDefinition
31
30
  : syscallDefinition.name;
32
31
 
33
- // Add the syscall to our mapping
34
32
  const callback = (ctx: SyscallContext, ...args: any[]) =>
35
33
  system.syscall(ctx, "system.invokeFunction", [
36
34
  `${plug.manifest!.name}.${name}`,
@@ -46,7 +44,6 @@ export class SyscallHook implements Hook<SyscallHookT> {
46
44
  };
47
45
  }
48
46
 
49
- // Register the syscalls with no required permissions
50
47
  system.registerSyscalls([], syscalls);
51
48
  }
52
49
  }
@@ -68,7 +65,6 @@ export class SyscallHook implements Hook<SyscallHookT> {
68
65
  continue;
69
66
  }
70
67
 
71
- // Validate syscall name format (should be namespaced)
72
68
  if (!syscallName.includes(".")) {
73
69
  errors.push(
74
70
  `Function ${name} has invalid syscall name "${syscallName}" - must be in format "namespace.name"`,
@@ -2,13 +2,13 @@ import type { KvPrimitives } from "../data/kv_primitives.ts";
2
2
  import type { Plug } from "./plug.ts";
3
3
  import type { Manifest } from "./types.ts";
4
4
 
5
- export interface ManifestCache<T> {
5
+ export type ManifestCache<T> = {
6
6
  getManifest(
7
7
  plug: Plug<T>,
8
8
  cacheKey: string,
9
9
  cacheHash: number,
10
10
  ): Promise<Manifest<T>>;
11
- }
11
+ };
12
12
 
13
13
  export class KVPrimitivesManifestCache<T> implements ManifestCache<T> {
14
14
  constructor(
@@ -23,7 +23,6 @@ export class KVPrimitivesManifestCache<T> implements ManifestCache<T> {
23
23
  ): Promise<Manifest<T>> {
24
24
  const [cached] = await this.kv.batchGet([[this.manifestPrefix, cacheKey]]);
25
25
  if (cached && cached.hash === cacheHash) {
26
- // console.log("Using KV cached manifest for", plug.name);
27
26
  return cached.manifest;
28
27
  }
29
28
  await plug.sandbox.init();
@@ -41,34 +40,3 @@ export class KVPrimitivesManifestCache<T> implements ManifestCache<T> {
41
40
  return manifest;
42
41
  }
43
42
  }
44
-
45
- export class InMemoryManifestCache<T> implements ManifestCache<T> {
46
- private cache = new Map<
47
- string,
48
- {
49
- manifest: Manifest<T>;
50
- hash: number;
51
- }
52
- >();
53
-
54
- async getManifest(
55
- plug: Plug<T>,
56
- cacheKey: string,
57
- cacheHash: number,
58
- ): Promise<Manifest<T>> {
59
- const cached = this.cache.get(cacheKey);
60
- if (cached && cached.hash === cacheHash) {
61
- // console.log("Using memory cached manifest for", plug.name);
62
- return cached.manifest;
63
- }
64
- await plug.sandbox.init();
65
- const manifest = plug.sandbox.manifest!;
66
-
67
- // Deliverately removing the assets from the manifest to preserve space, will be re-added upon load of actual worker
68
- this.cache.set(cacheKey, {
69
- manifest: { ...manifest, assets: undefined },
70
- hash: cacheHash,
71
- });
72
- return manifest;
73
- }
74
- }
@@ -31,7 +31,6 @@ export class Plug<HookT> {
31
31
  const plug = new Plug(system, sandboxFactory);
32
32
  plug.path = path;
33
33
 
34
- // Retrieve the manifest, which may either come from a cache or be loaded from the worker
35
34
  plug.manifest = await system.options.manifestCache!.getManifest(
36
35
  plug,
37
36
  path,
@@ -62,14 +61,12 @@ export class Plug<HookT> {
62
61
 
63
62
  // Invoke a function
64
63
  async invoke(name: string, args: any[]): Promise<any> {
65
- // Before we access the manifest
66
64
  const funDef = this.manifest!.functions[name];
67
65
  if (!funDef) {
68
66
  throw new Error(`Function ${name} not found in manifest`);
69
67
  }
70
68
  const sandbox = this.sandbox!;
71
69
  if (funDef.redirect) {
72
- // Function redirect, look up
73
70
  let plug: Plug<HookT> | undefined = this;
74
71
  if (funDef.redirect.indexOf(".") !== -1) {
75
72
  const [plugName, functionName] = funDef.redirect.split(".");
@@ -24,7 +24,6 @@ const workerRuntimePlugin: esbuild.Plugin = {
24
24
  name: "worker-runtime",
25
25
  setup(build) {
26
26
  if (typeof __EMBEDDED_WORKER_RUNTIME_JS__ !== "undefined") {
27
- // Bundled CLI: serve pre-bundled JS from the embedded constant
28
27
  build.onResolve({ filter: /^worker-runtime$/ }, () => ({
29
28
  path: "worker-runtime",
30
29
  namespace: "worker-runtime",
@@ -34,7 +33,6 @@ const workerRuntimePlugin: esbuild.Plugin = {
34
33
  loader: "js",
35
34
  }));
36
35
  } else {
37
- // From source: point directly at the .ts file, esbuild handles it
38
36
  build.onResolve({ filter: /^worker-runtime$/ }, () => ({
39
37
  path: path.join(import.meta.dirname, "worker_runtime.ts"),
40
38
  }));
@@ -70,14 +68,12 @@ export async function compileManifest(
70
68
  delete manifest.build;
71
69
  }
72
70
 
73
- // Assets
74
71
  const assetsBundle = await bundleAssets(
75
72
  path.resolve(rootPath),
76
73
  (manifest.assets as string[]) || [],
77
74
  );
78
75
  manifest.assets = assetsBundle.toJSON();
79
76
 
80
- // Normalize the edge case of a plug with no functions
81
77
  if (!manifest.functions) {
82
78
  manifest.functions = {};
83
79
  }
@@ -92,13 +88,11 @@ ${Object.entries(manifest.functions)
92
88
  return "";
93
89
  }
94
90
  let [filePath, jsFunctionName] = def.path.split(":");
95
- // Resolve path
96
91
  filePath = path.join(rootPath, filePath);
97
92
 
98
- return `import {${jsFunctionName} as ${funcName}} from "${
99
- // Replacing \ with / for Windows
100
- path.resolve(filePath).replaceAll("\\", "\\\\")
101
- }";\n`;
93
+ return `import {${jsFunctionName} as ${funcName}} from "${path
94
+ .resolve(filePath)
95
+ .replaceAll("\\", "\\\\")}";\n`;
102
96
  })
103
97
  .join("")}
104
98
 
@@ -122,8 +116,6 @@ export const plug = {manifest, functionMapping};
122
116
  setupMessageListener(functionMapping, manifest, self.postMessage);
123
117
  `;
124
118
 
125
- // console.log("Code:", jsFile);
126
-
127
119
  const tempDir = await mkdtemp(path.join(tmpdir(), "plug-compile-"));
128
120
  const inFile = path.join(tempDir, "input.js");
129
121
  const outFile = `${destPath}/${manifest.name}.plug.js`;
@@ -152,7 +144,6 @@ setupMessageListener(functionMapping, manifest, self.postMessage);
152
144
  jsCode = patchBundledJS(jsCode);
153
145
  await writeFile(outFile, jsCode, "utf-8");
154
146
 
155
- // Clean up temp directory
156
147
  await rm(tempDir, { recursive: true, force: true });
157
148
 
158
149
  console.log(`Plug ${manifest.name} written to ${outFile}.`);
@@ -175,7 +166,6 @@ export async function compileManifests(
175
166
  building = true;
176
167
  await mkdir(dist, { recursive: true });
177
168
  const startTime = Date.now();
178
- // Build all plugs in parallel
179
169
  await Promise.all(
180
170
  manifestFiles.map(async (plugManifestPath) => {
181
171
  const manifestPath = plugManifestPath as string;
@@ -3,7 +3,7 @@ import type { Manifest } from "../types.ts";
3
3
 
4
4
  export type SandboxFactory<HookT> = (plug: Plug<HookT>) => Sandbox<HookT>;
5
5
 
6
- export interface Sandbox<HookT> {
6
+ export type Sandbox<HookT> = {
7
7
  manifest?: Manifest<HookT>;
8
8
 
9
9
  init(): Promise<void>;
@@ -11,4 +11,4 @@ export interface Sandbox<HookT> {
11
11
  invoke(name: string, args: any[]): Promise<any>;
12
12
 
13
13
  stop(): void;
14
- }
14
+ };
@@ -7,7 +7,6 @@ import { race, timeout } from "@silverbulletmd/silverbullet/lib/async";
7
7
  import { fsEndpoint } from "../../spaces/constants.ts";
8
8
 
9
9
  /**
10
- * Represents a "safe" execution environment for plug code
11
10
  * Effectively this wraps a web worker, the reason to have this split from Plugs is to allow plugs to manage multiple sandboxes, e.g. for performance in the future
12
11
  */
13
12
  export class WorkerSandbox<HookT> implements Sandbox<HookT> {
@@ -25,7 +24,6 @@ export class WorkerSandbox<HookT> implements Sandbox<HookT> {
25
24
  private workerOptions = {},
26
25
  ) {}
27
26
 
28
- /** Factory producing a WorkerSandbox for a plug file at a local space path. */
29
27
  static forPath<HookT>(path: string): SandboxFactory<HookT> {
30
28
  return (plug) =>
31
29
  new WorkerSandbox(
@@ -40,7 +38,6 @@ export class WorkerSandbox<HookT> implements Sandbox<HookT> {
40
38
  init(): Promise<void> {
41
39
  console.log("Booting up worker from", this.workerUrl.pathname);
42
40
  if (this.worker) {
43
- // Race condition
44
41
  console.warn("Double init of sandbox, ignoring");
45
42
  return Promise.resolve();
46
43
  }
@@ -54,14 +51,12 @@ export class WorkerSandbox<HookT> implements Sandbox<HookT> {
54
51
  timeout(5000).catch((_) =>
55
52
  Promise.reject(new Error("Plug timed out during creation")),
56
53
  ),
57
- new Promise((resolve) => {
54
+ new Promise<void>((resolve) => {
58
55
  this.worker!.onmessage = (ev) => {
59
56
  if (ev.data.type === "manifest") {
60
57
  this.manifest = ev.data.manifest;
61
- // Set manifest in the plug
62
58
  this.plug.manifest = this.manifest!;
63
59
 
64
- // Set assets in the plug
65
60
  this.plug.assets = new AssetBundle(
66
61
  this.manifest?.assets ? (this.manifest.assets as AssetJson) : {},
67
62
  );
@@ -72,7 +67,15 @@ export class WorkerSandbox<HookT> implements Sandbox<HookT> {
72
67
  void this.onMessage(ev.data);
73
68
  };
74
69
  }),
75
- ]);
70
+ ]).catch((e) => {
71
+ // A worker that failed to come up must not linger: its script fetch may
72
+ // still be in flight (e.g. on a slow link), and a late boot would
73
+ // resolve into a sandbox the system already gave up on. Terminating it
74
+ // also lets a retry start from a clean slate.
75
+ this.worker?.terminate();
76
+ this.worker = undefined;
77
+ throw e;
78
+ });
76
79
  }
77
80
 
78
81
  async onMessage(data: ControllerMessage) {
@@ -92,7 +95,6 @@ export class WorkerSandbox<HookT> implements Sandbox<HookT> {
92
95
  result: result,
93
96
  } as WorkerMessage);
94
97
  } catch (e: any) {
95
- // console.error("Syscall fail", e);
96
98
  this.worker &&
97
99
  this.worker!.postMessage({
98
100
  type: "sysr",
@@ -118,7 +120,6 @@ export class WorkerSandbox<HookT> implements Sandbox<HookT> {
118
120
 
119
121
  async invoke(name: string, args: any[]): Promise<any> {
120
122
  if (!this.worker) {
121
- // Lazy initialization
122
123
  await this.init();
123
124
  }
124
125
  this.reqId++;
@@ -138,5 +139,14 @@ export class WorkerSandbox<HookT> implements Sandbox<HookT> {
138
139
  this.worker.terminate();
139
140
  this.worker = undefined;
140
141
  }
142
+ // A terminated worker will never post the `invr` that would otherwise
143
+ // settle these.
144
+ if (this.outstandingInvocations.size > 0) {
145
+ const error = new Error("Plug sandbox stopped");
146
+ for (const { reject } of this.outstandingInvocations.values()) {
147
+ reject(error);
148
+ }
149
+ this.outstandingInvocations.clear();
150
+ }
141
151
  }
142
152
  }
@@ -0,0 +1,104 @@
1
+ import {
2
+ maximumDocumentSize,
3
+ notFoundError,
4
+ } from "@silverbulletmd/silverbullet/constants";
5
+ import { isValidPath } from "@silverbulletmd/silverbullet/lib/ref";
6
+ import type { CaptureInvocationContext } from "../../capture/invocation.ts";
7
+ import type { Client } from "../../client.ts";
8
+ import type { SysCallMapping } from "../system.ts";
9
+
10
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
11
+ return a.length === b.length && a.every((byte, index) => byte === b[index]);
12
+ }
13
+
14
+ export function captureSyscalls(
15
+ context: () => CaptureInvocationContext | undefined,
16
+ client: Client,
17
+ ): SysCallMapping {
18
+ const active = (): CaptureInvocationContext => {
19
+ const current = context();
20
+ if (!current) throw new Error("No active capture action");
21
+ return current;
22
+ };
23
+
24
+ return {
25
+ "capture.readFile": {
26
+ callback: async (_ctx, handle: string): Promise<Uint8Array> =>
27
+ new Uint8Array(await (await active().readFile(handle)).arrayBuffer()),
28
+ description:
29
+ "Reads a staged file belonging to the current capture action.",
30
+ parameters: [
31
+ {
32
+ name: "handle",
33
+ type: "string",
34
+ description: "File handle from the capture.",
35
+ },
36
+ ],
37
+ returns: [{ type: "byteArray", description: "File bytes." }],
38
+ examples: [
39
+ { code: "local bytes = capture.readFile(data.files[1].handle)" },
40
+ ],
41
+ },
42
+ "capture.saveFile": {
43
+ callback: async (
44
+ _ctx,
45
+ handle: string,
46
+ path: string,
47
+ ): Promise<boolean> => {
48
+ if (!isValidPath(path)) throw new Error("Invalid capture file path");
49
+ const blob = await active().readFile(handle);
50
+ const limit = client.config.get<number>(
51
+ "maximumDocumentSize",
52
+ maximumDocumentSize,
53
+ );
54
+ if (typeof limit !== "number" || blob.size > limit * 1024 * 1024) {
55
+ throw new Error("Shared file is too large");
56
+ }
57
+ const bytes = new Uint8Array(await blob.arrayBuffer());
58
+ let existing = false;
59
+ try {
60
+ await client.space.getDocumentMeta(path);
61
+ existing = true;
62
+ } catch (error) {
63
+ if (
64
+ !(error instanceof Error && error.message === notFoundError.message)
65
+ )
66
+ throw error;
67
+ }
68
+ if (existing) {
69
+ const saved = await client.space.readDocument(path);
70
+ if (!sameBytes(saved.data, bytes))
71
+ throw new Error(`Shared file conflict: ${path}`);
72
+ return false;
73
+ }
74
+ await client.space.writeDocument(path, bytes);
75
+ const saved = await client.space.readDocument(path);
76
+ if (!sameBytes(saved.data, bytes))
77
+ throw new Error(`Shared file verification failed: ${path}`);
78
+ return true;
79
+ },
80
+ description:
81
+ "Saves a staged file without overwriting different bytes. Returns true when newly created and false when identical content already exists.",
82
+ parameters: [
83
+ {
84
+ name: "handle",
85
+ type: "string",
86
+ description: "File handle from the capture.",
87
+ },
88
+ { name: "path", type: "string", description: "New document path." },
89
+ ],
90
+ returns: [
91
+ {
92
+ type: "boolean",
93
+ description:
94
+ "true when the file was created, false when identical bytes already exist.",
95
+ },
96
+ ],
97
+ examples: [
98
+ {
99
+ code: 'capture.saveFile(data.files[1].handle, "Inbox/" .. data.files[1].name)',
100
+ },
101
+ ],
102
+ },
103
+ };
104
+ }