@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silverbulletmd/silverbullet",
3
- "version": "2.10.0",
3
+ "version": "2.12.0",
4
4
  "type": "module",
5
5
  "description": "A self-hosted, web-based note taking app",
6
6
  "publishConfig": {
@@ -15,20 +15,26 @@
15
15
  "./lib/tree": "./plug-api/lib/tree.ts",
16
16
  "./lib/ref": "./plug-api/lib/ref.ts",
17
17
  "./lib/resolve": "./plug-api/lib/resolve.ts",
18
+ "./lib/resolve_path": "./plug-api/lib/resolve_path.ts",
19
+ "./lib/link_write": "./plug-api/lib/link_write.ts",
18
20
  "./lib/dates": "./plug-api/lib/dates.ts",
21
+ "./lib/fuzzy": "./plug-api/lib/fuzzy.ts",
19
22
  "./lib/async": "./plug-api/lib/async.ts",
20
23
  "./lib/crypto": "./plug-api/lib/crypto.ts",
21
24
  "./lib/limited_map": "./plug-api/lib/limited_map.ts",
22
25
  "./lib/tags": "./plug-api/lib/tags.ts",
26
+ "./lib/collation": "./plug-api/lib/collation.ts",
23
27
  "./lib/transclusion": "./plug-api/lib/transclusion.ts",
24
28
  "./lib/native_fetch": "./plug-api/lib/native_fetch.ts",
25
29
  "./lib/query_expression": "./plug-api/lib/query_expression.ts",
30
+ "./lib/panel_styles": "./plug-api/lib/panel_styles.ts",
26
31
  "./type/client": "./plug-api/types/client.ts",
27
32
  "./type/config": "./plug-api/types/config.ts",
28
33
  "./type/manifest": "./plug-api/types/manifest.ts",
29
34
  "./type/namespace": "./plug-api/types/namespace.ts",
30
35
  "./type/datastore": "./plug-api/types/datastore.ts",
31
36
  "./type/event": "./plug-api/types/event.ts",
37
+ "./type/revisions": "./plug-api/types/revisions.ts",
32
38
  "./type/index": "./plug-api/types/index.ts"
33
39
  },
34
40
  "bin": {
@@ -41,6 +47,7 @@
41
47
  "client/plugos/**/*.ts",
42
48
  "!client/plugos/**/*.test.ts",
43
49
  "client/asset_bundle/bundle.ts",
50
+ "client/config.ts",
44
51
  "client/data/kv_primitives.ts",
45
52
  "client/data/datastore.ts",
46
53
  "client/space_lua/**/*.ts",
@@ -48,6 +55,7 @@
48
55
  "!client/space_lua/**/*.bench.ts",
49
56
  "client/markdown_parser/constants.ts",
50
57
  "plugs/builtin_plugs.ts",
58
+ "plugs/index/types.ts",
51
59
  "dist/plug-compile.js"
52
60
  ],
53
61
  "scripts": {
@@ -55,9 +63,10 @@
55
63
  "build:plugs": "tsx build/build_plugs.ts",
56
64
  "build:client": "tsx build/build_client.ts",
57
65
  "build:plug-compile": "tsx build/build_plug_compile.ts",
66
+ "prebuild:client": "node build/patch_codemirror_view.mjs",
58
67
  "prepublishOnly": "npm run build:plug-compile",
59
68
  "test": "vitest run",
60
- "check": "tsc --noEmit",
69
+ "check": "tsc --noEmit && tsc -p tsconfig.e2e.json",
61
70
  "lint": "biome lint .",
62
71
  "fmt": "biome format --write .",
63
72
  "fmt:check": "biome format .",
@@ -71,62 +80,64 @@
71
80
  },
72
81
  "dependencies": {
73
82
  "@cfworker/json-schema": "^4.1.1",
74
- "@codemirror/autocomplete": "6.20.1",
75
- "@codemirror/commands": "6.10.3",
83
+ "@codemirror/autocomplete": "6.20.3",
84
+ "@codemirror/commands": "6.11.1",
76
85
  "@codemirror/lang-css": "6.3.1",
77
- "@codemirror/lang-html": "6.4.11",
86
+ "@codemirror/lang-html": "6.4.12",
78
87
  "@codemirror/lang-javascript": "6.2.5",
79
- "@codemirror/lang-markdown": "6.5.0",
80
- "@codemirror/language": "6.12.3",
81
- "@codemirror/legacy-modes": "6.5.2",
82
- "@codemirror/lint": "6.9.5",
83
- "@codemirror/search": "6.6.0",
84
- "@codemirror/state": "6.6.0",
85
- "@codemirror/view": "6.41.0",
86
- "@joplin/turndown-plugin-gfm": "1.0.64",
87
- "@lezer/common": "1.5.1",
88
- "@lezer/css": "1.3.3",
89
- "@lezer/highlight": "1.2.3",
88
+ "@codemirror/lang-markdown": "6.5.2",
89
+ "@codemirror/language": "6.12.4",
90
+ "@codemirror/legacy-modes": "6.5.4",
91
+ "@codemirror/lint": "6.9.7",
92
+ "@codemirror/search": "6.7.2",
93
+ "@codemirror/state": "6.7.6",
94
+ "@codemirror/view": "6.43.13",
95
+ "@joplin/turndown-plugin-gfm": "1.0.68",
96
+ "@lezer/common": "1.5.2",
97
+ "@lezer/css": "1.3.8",
98
+ "@lezer/highlight": "1.2.4",
90
99
  "@lezer/html": "1.3.13",
91
- "@lezer/javascript": "1.5.4",
92
- "@lezer/lr": "1.4.8",
93
- "@lezer/markdown": "1.6.3",
100
+ "@lezer/javascript": "1.5.5",
101
+ "@lezer/lr": "1.4.10",
102
+ "@lezer/markdown": "1.7.2",
94
103
  "@msgpack/msgpack": "3.1.3",
104
+ "@noble/hashes": "2.4.0",
95
105
  "@replit/codemirror-lang-nix": "6.0.1",
96
- "@replit/codemirror-vim": "6.3.0",
97
- "commander": "^14.0.3",
98
- "crelt": "1.0.6",
106
+ "@replit/codemirror-vim": "6.4.0",
107
+ "@types/js-yaml": "^4.0.9",
108
+ "commander": "^15.0.0",
109
+ "crelt": "1.0.7",
99
110
  "d3-force": "^3.0.0",
100
- "esbuild": "^0.27.3",
111
+ "esbuild": "^0.28.2",
101
112
  "fast-diff": "1.3.0",
102
- "force-graph": "^1.49.0",
103
113
  "fast-glob": "^3.3.3",
114
+ "force-graph": "^1.51.4",
104
115
  "gitignore-parser": "0.0.2",
105
116
  "idb": "8.0.3",
106
- "js-yaml": "^4.1.1",
117
+ "js-yaml": "^4.3.2",
107
118
  "mime": "4.1.0",
108
- "preact": "10.28.2",
109
- "preact-feather": "4.2.1",
110
- "react-icons": "5.5.0",
111
- "sass": "^1.97.3",
112
- "style-mod": "4.1.3",
113
- "turndown": "7.2.2"
119
+ "preact": "10.29.8",
120
+ "preact-feather": "4.3.0",
121
+ "react-icons": "5.7.0",
122
+ "sass": "^1.105.0",
123
+ "style-mod": "4.1.4",
124
+ "turndown": "7.2.4"
114
125
  },
115
126
  "devDependencies": {
116
- "@biomejs/biome": "2.4.6",
117
- "@playwright/test": "^1.58.2",
118
- "@preact/preset-vite": "^2.10.3",
127
+ "@biomejs/biome": "2.5.14",
128
+ "@playwright/test": "1.58.2",
129
+ "@preact/preset-vite": "^2.10.6",
119
130
  "@types/d3-force": "^3.0.10",
120
131
  "@types/gitignore-parser": "^0.0.3",
121
- "@types/js-yaml": "^4.0.9",
122
- "@types/node": "^22.0.0",
132
+ "@types/node": "^26.6.2",
123
133
  "@types/turndown": "^5.0.6",
124
- "fake-indexeddb": "6.0.1",
134
+ "@typescript/native": "npm:typescript@^7.0.2",
135
+ "fake-indexeddb": "6.2.5",
125
136
  "preact-render-to-string": "^6.7.0",
126
- "tsx": "^4.19.0",
127
- "typescript": "^5.9.3",
128
- "vite": "^7.3.1",
129
- "vitest": "^4.0.18"
137
+ "tsx": "^4.23.15",
138
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
139
+ "vite": "^8.3.1",
140
+ "vitest": "^4.1.11"
130
141
  },
131
142
  "engines": {
132
143
  "node": ">=24.13.0",
@@ -11,7 +11,6 @@ export function throttle(
11
11
  }, limit);
12
12
  }
13
13
  };
14
- // Immediately execute any pending call and cancel the timer
15
14
  throttled.flush = () => {
16
15
  if (timer) {
17
16
  clearTimeout(timer);
@@ -91,7 +90,7 @@ export class PromiseQueue {
91
90
  reject(error);
92
91
  }
93
92
 
94
- void this.process(); // Continue processing the next promise in the queue
93
+ void this.process();
95
94
  }
96
95
  }
97
96
 
@@ -108,17 +107,13 @@ export async function batchRequests<I, O>(
108
107
  batchSize: number,
109
108
  ): Promise<O[]> {
110
109
  const results: O[] = [];
111
- // Split values into batches of batchSize
112
110
  const batches: I[][] = [];
113
111
  for (let i = 0; i < values.length; i += batchSize) {
114
112
  batches.push(values.slice(i, i + batchSize));
115
113
  }
116
- // Run fn on them in parallel
117
114
  const batchResults = await Promise.all(batches.map(fn));
118
- // Flatten the results
119
115
  for (const batchResult of batchResults) {
120
116
  if (Array.isArray(batchResult)) {
121
- // If fn returns an array, collect them
122
117
  results.push(...batchResult);
123
118
  }
124
119
  }
@@ -0,0 +1,22 @@
1
+ import type { QueryCollationConfig } from "../types/config.ts";
2
+
3
+ /**
4
+ * Compares two values the same way the query engine's `order by` does:
5
+ * codepoint order by default, or `Intl.Collator` order when the space's
6
+ * `queryCollation` config turns it on and both sides are strings.
7
+ */
8
+ export function compareCollated(
9
+ // deno-lint-ignore no-explicit-any
10
+ a: any,
11
+ // deno-lint-ignore no-explicit-any
12
+ b: any,
13
+ collation: QueryCollationConfig | undefined,
14
+ collator: Intl.Collator,
15
+ ): number {
16
+ if (collation?.enabled && typeof a === "string" && typeof b === "string") {
17
+ return collator.compare(a, b);
18
+ }
19
+ if (a < b) return -1;
20
+ if (a > b) return 1;
21
+ return 0;
22
+ }
@@ -1,3 +1,5 @@
1
+ import { sha256 } from "@noble/hashes/sha2.js";
2
+
1
3
  export function base64Decode(s: string): Uint8Array {
2
4
  const binString = atob(s);
3
5
  const len = binString.length;
@@ -32,31 +34,33 @@ export function base64DecodeDataUrl(dataUrl: string): Uint8Array {
32
34
  return base64Decode(b64Encoded);
33
35
  }
34
36
 
35
- /**
36
- * Perform sha256 hash using the browser's crypto APIs
37
- * Note: this will only work over HTTPS
38
- * @param message
39
- */
40
37
  export async function hashSHA256(
41
38
  message: string | Uint8Array,
42
39
  ): Promise<string> {
43
- // Transform the string into an ArrayBuffer
44
40
  const encoder = new TextEncoder();
45
41
  const data: Uint8Array =
46
42
  typeof message === "string" ? encoder.encode(message) : message;
47
43
 
48
- // Generate the hash
49
- const hashBuffer = await globalThis.crypto.subtle.digest(
50
- "SHA-256",
51
- data as BufferSource,
52
- );
44
+ const hashBuffer = globalThis.crypto.subtle
45
+ ? await globalThis.crypto.subtle.digest("SHA-256", data as BufferSource)
46
+ : sha256(data);
53
47
 
54
- // Transform the hash into a hex string
55
48
  return Array.from(new Uint8Array(hashBuffer))
56
49
  .map((b) => b.toString(16).padStart(2, "0"))
57
50
  .join("");
58
51
  }
59
52
 
53
+ export function randomUUID(): string {
54
+ if (globalThis.crypto.randomUUID) return globalThis.crypto.randomUUID();
55
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
56
+ bytes[6] = (bytes[6] & 0x0f) | 0x40;
57
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
58
+ const hex = Array.from(bytes, (byte) =>
59
+ byte.toString(16).padStart(2, "0"),
60
+ ).join("");
61
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
62
+ }
63
+
60
64
  /**
61
65
  * To avoid database clashes based on space folder path name, base URLs and encryption keys we derive
62
66
  * a database name from a hash of all these combined together
@@ -117,7 +121,6 @@ export async function encryptAesGcm(
117
121
  );
118
122
  const encrypted = new Uint8Array(encryptedBuffer);
119
123
 
120
- // Prepend IV to ciphertext
121
124
  const result = new Uint8Array(iv.length + encrypted.length);
122
125
  result.set(iv, 0);
123
126
  result.set(encrypted, iv.length);
@@ -143,10 +146,8 @@ export async function deriveCTRKeyFromPassword(
143
146
  password: string,
144
147
  salt: Uint8Array,
145
148
  ): Promise<CryptoKey> {
146
- // Encode password to ArrayBuffer
147
149
  const passwordBytes = new TextEncoder().encode(password);
148
150
 
149
- // Import password as a CryptoKey
150
151
  const baseKey = await globalThis.crypto.subtle.importKey(
151
152
  "raw",
152
153
  passwordBytes,
@@ -2,6 +2,39 @@ export function niceDate(d: Date): string {
2
2
  return localDateString(d).split("T")[0];
3
3
  }
4
4
 
5
+ /** Largest-unit-first, so the cascade below divides down to the right one. */
6
+ const RELATIVE_DIVISIONS: {
7
+ amount: number;
8
+ unit: Intl.RelativeTimeFormatUnit;
9
+ }[] = [
10
+ { amount: 60, unit: "second" },
11
+ { amount: 60, unit: "minute" },
12
+ { amount: 24, unit: "hour" },
13
+ { amount: 7, unit: "day" },
14
+ { amount: 4.34524, unit: "week" },
15
+ { amount: 12, unit: "month" },
16
+ ];
17
+
18
+ /**
19
+ * "10 minutes ago", "yesterday", "3 months ago". `numeric: "auto"` is what
20
+ * turns the -1 cases into words rather than "1 day ago".
21
+ */
22
+ export function relativeTime(
23
+ timestamp: number,
24
+ locale?: string,
25
+ now: number = Date.now(),
26
+ ): string {
27
+ const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
28
+ let duration = (timestamp - now) / 1000;
29
+ for (const { amount, unit } of RELATIVE_DIVISIONS) {
30
+ if (Math.abs(duration) < amount) {
31
+ return formatter.format(Math.round(duration), unit);
32
+ }
33
+ duration /= amount;
34
+ }
35
+ return formatter.format(Math.round(duration), "year");
36
+ }
37
+
5
38
  export function localDateString(d: Date): string {
6
39
  return (
7
40
  d.getFullYear() +
@@ -0,0 +1,282 @@
1
+ function normalize(s: string): string {
2
+ return s
3
+ .normalize("NFD")
4
+ .replace(/\p{Diacritic}/gu, "")
5
+ .toLowerCase();
6
+ }
7
+
8
+ const WORD_BOUNDARY_CHARS = new Set(["/", "-", "_", " ", ".", ":"]);
9
+
10
+ function isWordBoundaryAt(
11
+ original: string,
12
+ lowered: string,
13
+ i: number,
14
+ ): boolean {
15
+ if (i === 0) return true;
16
+ const prev = lowered[i - 1];
17
+ if (WORD_BOUNDARY_CHARS.has(prev)) return true;
18
+ // CamelCase boundary: previous original char is lowercase, current is uppercase
19
+ const origPrev = original[i - 1];
20
+ const origCur = original[i];
21
+ if (
22
+ origPrev &&
23
+ origCur &&
24
+ origPrev === origPrev.toLowerCase() &&
25
+ origPrev !== origPrev.toUpperCase() &&
26
+ origCur === origCur.toUpperCase() &&
27
+ origCur !== origCur.toLowerCase()
28
+ ) {
29
+ return true;
30
+ }
31
+ return false;
32
+ }
33
+
34
+ function bestSubstringEditDistance(token: string, candidate: string): number {
35
+ const tl = token.length;
36
+ const cl = candidate.length;
37
+ let prevPrev = new Array(cl + 1).fill(0);
38
+ let prev = new Array(cl + 1).fill(0);
39
+ let curr = new Array(cl + 1);
40
+
41
+ for (let i = 1; i <= tl; i++) {
42
+ curr[0] = i;
43
+ for (let j = 1; j <= cl; j++) {
44
+ const cost = token[i - 1] === candidate[j - 1] ? 0 : 1;
45
+ let v = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
46
+ if (
47
+ i > 1 &&
48
+ j > 1 &&
49
+ token[i - 1] === candidate[j - 2] &&
50
+ token[i - 2] === candidate[j - 1]
51
+ ) {
52
+ v = Math.min(v, prevPrev[j - 2] + 1);
53
+ }
54
+ curr[j] = v;
55
+ }
56
+ const tmp = prevPrev;
57
+ prevPrev = prev;
58
+ prev = curr;
59
+ curr = tmp;
60
+ }
61
+
62
+ let best = prev[0];
63
+ for (let j = 1; j <= cl; j++) {
64
+ if (prev[j] < best) best = prev[j];
65
+ }
66
+ return best;
67
+ }
68
+
69
+ export function typoScore(token: string, candidate: string): number {
70
+ if (token.length < 4) return 0;
71
+ const max = Math.min(2, Math.floor(token.length / 4));
72
+ if (max < 1) return 0;
73
+
74
+ const bestDist = bestSubstringEditDistance(token, candidate);
75
+ if (bestDist > max) return 0;
76
+ return 0.25 - 0.05 * bestDist; // 0.25 / 0.20 / 0.15
77
+ }
78
+
79
+ function subsequenceScore(
80
+ token: string,
81
+ candidate: string,
82
+ candidateOriginal: string,
83
+ ): number {
84
+ // Greedy left-to-right match; track contiguous runs and word-boundary hits.
85
+ let ti = 0;
86
+ let firstMatch = -1;
87
+ let lastMatch = -1;
88
+ let contiguousRunSum = 0;
89
+ let currentRun = 0;
90
+ let boundaryHits = 0;
91
+ let prevMatchCi = -2;
92
+
93
+ for (let ci = 0; ci < candidate.length && ti < token.length; ci++) {
94
+ if (candidate[ci] === token[ti]) {
95
+ if (firstMatch < 0) firstMatch = ci;
96
+ lastMatch = ci;
97
+ if (isWordBoundaryAt(candidateOriginal, candidate, ci)) boundaryHits++;
98
+ if (ci === prevMatchCi + 1) {
99
+ currentRun++;
100
+ } else {
101
+ contiguousRunSum += currentRun * currentRun;
102
+ currentRun = 1;
103
+ }
104
+ prevMatchCi = ci;
105
+ ti++;
106
+ }
107
+ }
108
+ contiguousRunSum += currentRun * currentRun;
109
+
110
+ if (ti < token.length) return 0; // not a subsequence at all
111
+
112
+ // Normalize: token length over span (penalizes wide spans)
113
+ const span = lastMatch - firstMatch + 1;
114
+ const density = token.length / span; // in (0, 1]
115
+ const boundaryBonus = boundaryHits / token.length; // in [0, 1]
116
+ const contiguityBonus = contiguousRunSum / (token.length * token.length); // in (0, 1]
117
+
118
+ // Weighted combination scaled into [0.30, 0.65]
119
+ const raw = 0.5 * density + 0.3 * contiguityBonus + 0.2 * boundaryBonus;
120
+ return 0.3 + raw * 0.35;
121
+ }
122
+
123
+ export function scoreToken(token: string, candidate: string): number {
124
+ if (!token) return 0;
125
+ const t = normalize(token);
126
+ const cOrig = candidate;
127
+ const c = normalize(candidate);
128
+
129
+ // Tier 1: exact equality
130
+ if (c === t) return 1.0;
131
+
132
+ // Tier 2: prefix
133
+ if (c.startsWith(t)) {
134
+ return isWordBoundaryAt(cOrig, c, 0) ? 0.95 : 0.9;
135
+ }
136
+
137
+ // Tier 3: substring. Search for the best occurrence — a word-boundary
138
+ // match wins even if it appears later than a non-boundary match.
139
+ {
140
+ let bestIdx = -1;
141
+ let bestBoundary = false;
142
+ let idx = c.indexOf(t);
143
+ while (idx >= 0) {
144
+ const boundary = isWordBoundaryAt(cOrig, c, idx);
145
+ if (bestIdx < 0 || (boundary && !bestBoundary)) {
146
+ bestIdx = idx;
147
+ bestBoundary = boundary;
148
+ if (boundary) break; // can't get better
149
+ }
150
+ idx = c.indexOf(t, idx + 1);
151
+ }
152
+ if (bestIdx >= 0) {
153
+ // For very short tokens (<=2 chars), only count substring matches at
154
+ // word boundaries — otherwise short tokens generate too much noise.
155
+ if (t.length > 2 || bestBoundary) {
156
+ return bestBoundary ? 0.8 : 0.75;
157
+ }
158
+ // fall through for short non-boundary substring matches
159
+ }
160
+ }
161
+
162
+ // Tier 4: subsequence (short tokens skip this tier; substring at boundary
163
+ // is the only way for them to match)
164
+ if (t.length > 2) {
165
+ const subseq = subsequenceScore(t, c, cOrig);
166
+ if (subseq > 0) return subseq;
167
+ }
168
+
169
+ // Tier 5: bounded typo
170
+ const typo = typoScore(t, c);
171
+ if (typo > 0) return typo;
172
+
173
+ return 0;
174
+ }
175
+
176
+ export type RankField = { weight: number; segments?: boolean };
177
+ export type RankOptions<T> = {
178
+ fields?: Record<string, number | RankField>;
179
+ orderId?: (obj: T) => number;
180
+ };
181
+
182
+ const DEFAULT_FIELDS: Record<string, number | RankField> = {
183
+ name: { weight: 1.0, segments: true },
184
+ displayName: 0.9,
185
+ aliases: 0.85,
186
+ };
187
+
188
+ /**
189
+ * Whether `phrase` equals one of `obj`'s ranked fields verbatim (normalized).
190
+ * Per-token scoring (below) never penalizes a candidate for carrying *extra*
191
+ * words the phrase didn't ask for -- "Foo" and "Foo Bar" score identically
192
+ * against the phrase "Foo", since each of the phrase's tokens matches
193
+ * somewhere in either candidate just as well. That leaves same-score ties
194
+ * exactly where a user's full, exact phrase should still win outright, which
195
+ * is what this tie-break (see `rank`'s sort) is for.
196
+ */
197
+ function hasExactFieldMatch<T extends Record<string, any>>(
198
+ obj: T,
199
+ normalizedPhrase: string,
200
+ fields: Record<string, number | RankField>,
201
+ ): boolean {
202
+ for (const fieldName of Object.keys(fields)) {
203
+ const raw = obj[fieldName];
204
+ const values: unknown[] = Array.isArray(raw)
205
+ ? raw
206
+ : raw != null
207
+ ? [raw]
208
+ : [];
209
+ for (const value of values) {
210
+ if (normalize(String(value)) === normalizedPhrase) return true;
211
+ }
212
+ }
213
+ return false;
214
+ }
215
+
216
+ export function rank<T extends Record<string, any>>(
217
+ objects: T[],
218
+ phrase: string,
219
+ options: RankOptions<T> = {},
220
+ ): (T & { score: number })[] {
221
+ const fields = options.fields ?? DEFAULT_FIELDS;
222
+ const orderId = options.orderId ?? (() => 0);
223
+ const normalizedPhrase = normalize(phrase);
224
+ const tokens = normalizedPhrase.split(/\s+/).filter((t) => t.length > 0);
225
+ if (tokens.length === 0) {
226
+ return [...objects]
227
+ .sort(
228
+ (a, b) =>
229
+ orderId(a) - orderId(b) ||
230
+ String(a.name ?? "").localeCompare(String(b.name ?? "")),
231
+ )
232
+ .map((o) => ({ ...o, score: 1 }));
233
+ }
234
+ const results: (T & { score: number })[] = [];
235
+ for (const obj of objects) {
236
+ let product = 1;
237
+ let excluded = false;
238
+ for (const token of tokens) {
239
+ let best = 0;
240
+ for (const [fieldName, fieldSpec] of Object.entries(fields)) {
241
+ const spec: RankField =
242
+ typeof fieldSpec === "number" ? { weight: fieldSpec } : fieldSpec;
243
+ const raw = obj[fieldName];
244
+ const values: string[] = Array.isArray(raw)
245
+ ? raw.map(String)
246
+ : raw != null
247
+ ? [String(raw)]
248
+ : [];
249
+ for (const value of values) {
250
+ if (spec.segments && value.includes("/")) {
251
+ const last = value.slice(value.lastIndexOf("/") + 1);
252
+ best = Math.max(
253
+ best,
254
+ scoreToken(token, last) * spec.weight,
255
+ scoreToken(token, value) * spec.weight * 0.85,
256
+ );
257
+ } else {
258
+ best = Math.max(best, scoreToken(token, value) * spec.weight);
259
+ }
260
+ }
261
+ }
262
+ if (best === 0) {
263
+ excluded = true;
264
+ break;
265
+ }
266
+ product *= best;
267
+ }
268
+ if (!excluded) {
269
+ results.push({ ...obj, score: product ** (1 / tokens.length) });
270
+ }
271
+ }
272
+ return results.sort((a, b) => {
273
+ if (b.score !== a.score) return b.score - a.score;
274
+ const aExact = hasExactFieldMatch(a, normalizedPhrase, fields);
275
+ const bExact = hasExactFieldMatch(b, normalizedPhrase, fields);
276
+ if (aExact !== bExact) return aExact ? -1 : 1;
277
+ return (
278
+ orderId(a) - orderId(b) ||
279
+ String(a.name ?? "").localeCompare(String(b.name ?? ""))
280
+ );
281
+ });
282
+ }
@@ -50,7 +50,6 @@ export function deepEqual(a: any, b: any): boolean {
50
50
  * @param d the date to convert
51
51
  */
52
52
  export function cleanStringDate(d: Date): string {
53
- // If no significant time, return a date string only
54
53
  if (
55
54
  d.getUTCHours() === 0 &&
56
55
  d.getUTCMinutes() === 0 &&
@@ -85,7 +84,6 @@ export function cleanupJSON(a: any): any {
85
84
  if (Array.isArray(a)) {
86
85
  return a.map(cleanupJSON);
87
86
  }
88
- // If a is a date, convert to a string
89
87
  if (a instanceof Date) {
90
88
  return cleanStringDate(a);
91
89
  }
@@ -106,17 +104,14 @@ export function cleanupJSON(a: any): any {
106
104
  }
107
105
 
108
106
  export function deepClone<T>(obj: T, ignoreKeys: string[] = []): T {
109
- // Handle null, undefined, or primitive types (string, number, boolean, symbol, bigint)
110
107
  if (obj === null || typeof obj !== "object") {
111
108
  return obj;
112
109
  }
113
110
 
114
- // Handle Date
115
111
  if (obj instanceof Date) {
116
112
  return new Date(obj.getTime()) as any;
117
113
  }
118
114
 
119
- // Handle Array
120
115
  if (Array.isArray(obj)) {
121
116
  const arrClone: any[] = [];
122
117
  for (let i = 0; i < obj.length; i++) {
@@ -125,7 +120,6 @@ export function deepClone<T>(obj: T, ignoreKeys: string[] = []): T {
125
120
  return arrClone as any;
126
121
  }
127
122
 
128
- // Handle Object
129
123
  if (obj instanceof Object) {
130
124
  const objClone: { [key: string]: any } = {};
131
125
  for (const key in obj) {
@@ -31,7 +31,6 @@ export class LimitedMap<V> {
31
31
  }, ttl);
32
32
  }
33
33
  if (this.map.size >= this.maxSize) {
34
- // Remove the oldest key before adding a new one
35
34
  const oldestKey = this.getOldestKey();
36
35
  this.map.delete(oldestKey!);
37
36
  }
@@ -41,7 +40,6 @@ export class LimitedMap<V> {
41
40
  get(key: string): V | undefined {
42
41
  const entry = this.map.get(key);
43
42
  if (entry) {
44
- // Update the last accessed timestamp
45
43
  entry.la = Date.now();
46
44
  return entry.value;
47
45
  }