@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.
- package/README.md +6 -6
- package/client/asset_bundle/bundle.ts +0 -1
- package/client/config.ts +343 -0
- package/client/data/kv_primitives.ts +2 -2
- package/client/markdown_parser/constants.ts +5 -1
- package/client/plugos/hooks/command.ts +8 -6
- package/client/plugos/hooks/event.ts +60 -24
- package/client/plugos/hooks/mq.ts +0 -3
- package/client/plugos/hooks/slash_command.ts +3 -20
- package/client/plugos/hooks/syscall.ts +0 -4
- package/client/plugos/manifest_cache.ts +2 -34
- package/client/plugos/plug.ts +0 -3
- package/client/plugos/plug_compile.ts +3 -13
- package/client/plugos/sandboxes/sandbox.ts +2 -2
- package/client/plugos/sandboxes/worker_sandbox.ts +19 -9
- package/client/plugos/syscalls/capture.ts +104 -0
- package/client/plugos/syscalls/editor.ts +197 -56
- package/client/plugos/syscalls/event.ts +12 -9
- package/client/plugos/syscalls/fetch.ts +0 -4
- package/client/plugos/syscalls/icon.ts +46 -0
- package/client/plugos/syscalls/index.ts +33 -13
- package/client/plugos/syscalls/jsonschema.ts +0 -1
- package/client/plugos/syscalls/mq.ts +0 -1
- package/client/plugos/syscalls/navigator.ts +138 -0
- package/client/plugos/syscalls/search.ts +44 -0
- package/client/plugos/syscalls/shell.ts +0 -1
- package/client/plugos/syscalls/space.ts +250 -7
- package/client/plugos/syscalls/sync.ts +70 -4
- package/client/plugos/syscalls/system.ts +39 -0
- package/client/plugos/system.ts +9 -7
- package/client/plugos/types.ts +6 -6
- package/client/plugos/worker_runtime.ts +0 -2
- package/client/space_lua/aggregates.ts +6 -22
- package/client/space_lua/ast.ts +0 -5
- package/client/space_lua/ast_narrow.ts +0 -5
- package/client/space_lua/budget.ts +113 -0
- package/client/space_lua/budget_ui.ts +49 -0
- package/client/space_lua/eval.ts +113 -88
- package/client/space_lua/labels.ts +0 -4
- package/client/space_lua/numeric.ts +2 -7
- package/client/space_lua/parse.ts +0 -17
- package/client/space_lua/quarantine.ts +127 -0
- package/client/space_lua/query_collection.ts +18 -51
- package/client/space_lua/query_env.ts +0 -2
- package/client/space_lua/render_lua_markdown.ts +2 -7
- package/client/space_lua/render_widget.ts +18 -1
- package/client/space_lua/runtime.ts +64 -77
- package/client/space_lua/stdlib/format.ts +0 -12
- package/client/space_lua/stdlib/js.ts +0 -2
- package/client/space_lua/stdlib/load.ts +0 -1
- package/client/space_lua/stdlib/math.ts +0 -7
- package/client/space_lua/stdlib/net.ts +0 -4
- package/client/space_lua/stdlib/os.ts +0 -10
- package/client/space_lua/stdlib/pattern.ts +18 -23
- package/client/space_lua/stdlib/space_lua.ts +1 -2
- package/client/space_lua/stdlib/string_pack.ts +4 -7
- package/client/space_lua/stdlib/table.ts +0 -5
- package/client/space_lua/stdlib.ts +18 -31
- package/client/space_lua/tonumber.ts +0 -9
- package/dist/plug-compile.js +3 -4
- package/package.json +52 -41
- package/plug-api/lib/async.ts +1 -6
- package/plug-api/lib/collation.ts +22 -0
- package/plug-api/lib/crypto.ts +16 -15
- package/plug-api/lib/dates.ts +33 -0
- package/plug-api/lib/fuzzy.ts +282 -0
- package/plug-api/lib/json.ts +0 -6
- package/plug-api/lib/limited_map.ts +0 -2
- package/plug-api/lib/link_write.ts +27 -0
- package/plug-api/lib/ref.ts +28 -5
- package/plug-api/lib/resolve.ts +1 -9
- package/plug-api/lib/resolve_path.ts +290 -0
- package/plug-api/lib/shortcut.ts +15 -0
- package/plug-api/lib/tags.ts +0 -3
- package/plug-api/lib/transclusion.ts +28 -1
- package/plug-api/lib/tree.ts +0 -5
- package/plug-api/lib/yaml.ts +5 -44
- package/plug-api/syscall.ts +1 -5
- package/plug-api/syscalls/config.ts +2 -2
- package/plug-api/syscalls/editor.ts +75 -213
- package/plug-api/syscalls/icon.ts +18 -0
- package/plug-api/syscalls/index.ts +22 -4
- package/plug-api/syscalls/search.ts +11 -0
- package/plug-api/syscalls/space.ts +122 -5
- package/plug-api/syscalls/sync.ts +31 -2
- package/plug-api/syscalls/system.ts +46 -0
- package/plug-api/syscalls.ts +2 -0
- package/plug-api/system_mock.ts +13 -0
- package/plug-api/types/client.ts +20 -4
- package/plug-api/types/datastore.ts +11 -2
- package/plug-api/types/index.ts +10 -0
- package/plug-api/types/manifest.ts +7 -0
- package/plug-api/types/profile.ts +13 -0
- package/plug-api/types/revisions.ts +67 -0
- package/plug-api/ui/description.ts +64 -0
- package/plug-api/ui/hover.ts +69 -0
- package/plug-api/ui/index.ts +58 -2
- package/plug-api/ui/scroll.ts +30 -0
- package/plug-api/ui/tree_model.ts +285 -0
- package/plug-api/ui/tree_types.ts +67 -0
- package/plug-api/ui/use_fit_collapse.ts +67 -0
- package/plugs/builtin_plugs.ts +0 -1
- package/plugs/index/types.ts +9 -0
- package/plug-api/lib/memory_cache.ts +0 -21
- /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.
|
|
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.
|
|
75
|
-
"@codemirror/commands": "6.
|
|
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.
|
|
86
|
+
"@codemirror/lang-html": "6.4.12",
|
|
78
87
|
"@codemirror/lang-javascript": "6.2.5",
|
|
79
|
-
"@codemirror/lang-markdown": "6.5.
|
|
80
|
-
"@codemirror/language": "6.12.
|
|
81
|
-
"@codemirror/legacy-modes": "6.5.
|
|
82
|
-
"@codemirror/lint": "6.9.
|
|
83
|
-
"@codemirror/search": "6.
|
|
84
|
-
"@codemirror/state": "6.6
|
|
85
|
-
"@codemirror/view": "6.
|
|
86
|
-
"@joplin/turndown-plugin-gfm": "1.0.
|
|
87
|
-
"@lezer/common": "1.5.
|
|
88
|
-
"@lezer/css": "1.3.
|
|
89
|
-
"@lezer/highlight": "1.2.
|
|
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.
|
|
92
|
-
"@lezer/lr": "1.4.
|
|
93
|
-
"@lezer/markdown": "1.
|
|
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.
|
|
97
|
-
"
|
|
98
|
-
"
|
|
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.
|
|
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.
|
|
117
|
+
"js-yaml": "^4.3.2",
|
|
107
118
|
"mime": "4.1.0",
|
|
108
|
-
"preact": "10.
|
|
109
|
-
"preact-feather": "4.
|
|
110
|
-
"react-icons": "5.
|
|
111
|
-
"sass": "^1.
|
|
112
|
-
"style-mod": "4.1.
|
|
113
|
-
"turndown": "7.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.
|
|
117
|
-
"@playwright/test": "
|
|
118
|
-
"@preact/preset-vite": "^2.10.
|
|
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/
|
|
122
|
-
"@types/node": "^22.0.0",
|
|
132
|
+
"@types/node": "^26.6.2",
|
|
123
133
|
"@types/turndown": "^5.0.6",
|
|
124
|
-
"
|
|
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.
|
|
127
|
-
"typescript": "
|
|
128
|
-
"vite": "^
|
|
129
|
-
"vitest": "^4.
|
|
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",
|
package/plug-api/lib/async.ts
CHANGED
|
@@ -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();
|
|
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
|
+
}
|
package/plug-api/lib/crypto.ts
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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,
|
package/plug-api/lib/dates.ts
CHANGED
|
@@ -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
|
+
}
|
package/plug-api/lib/json.ts
CHANGED
|
@@ -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
|
}
|