@needle-tools/addressing 0.3.2 → 0.3.3
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 +70 -70
- package/dist/address.js +7 -2
- package/dist/anchor.d.ts +12 -0
- package/dist/anchor.js +11 -8
- package/dist/gltf.js +6 -4
- package/dist/id.d.ts +30 -11
- package/dist/id.js +39 -22
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/resolve.d.ts +62 -6
- package/dist/resolve.js +141 -21
- package/package.json +1 -1
- package/src/address.ts +111 -106
- package/src/anchor.ts +71 -68
- package/src/gltf.ts +91 -89
- package/src/hash.ts +58 -58
- package/src/id.ts +61 -45
- package/src/index.ts +34 -29
- package/src/resolve.ts +315 -172
- package/src/uuid.ts +48 -48
package/src/resolve.ts
CHANGED
|
@@ -1,172 +1,315 @@
|
|
|
1
|
-
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
-
import { collectionKeyForType, type GraphNode, type NeedleAddress } from "./address.js";
|
|
3
|
-
import { NEEDLE_ID_KEY, STAMPED_COLLECTIONS, type GltfJson } from "./gltf.js";
|
|
4
|
-
|
|
5
|
-
export type GltfResolveResult = {
|
|
6
|
-
type: string;
|
|
7
|
-
index: number;
|
|
8
|
-
element: Record<string, unknown>;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Resolve an address inside glTF JSON.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
export
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
1
|
+
import { parseAnchorValue } from "./anchor.js";
|
|
2
|
+
import { collectionKeyForType, type GraphNode, type NeedleAddress } from "./address.js";
|
|
3
|
+
import { NEEDLE_ID_KEY, STAMPED_COLLECTIONS, type GltfJson } from "./gltf.js";
|
|
4
|
+
|
|
5
|
+
export type GltfResolveResult = {
|
|
6
|
+
type: string;
|
|
7
|
+
index: number;
|
|
8
|
+
element: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve an address inside glTF JSON. Same ladder as the live graph —
|
|
13
|
+
* **anchor → id → namePath** (nodes) — with the anchor winning only when it matches
|
|
14
|
+
* exactly one element. Returns null when nothing matches exactly (reconciliation is a
|
|
15
|
+
* separate, explicit step — resolution never guesses).
|
|
16
|
+
*/
|
|
17
|
+
export function resolveInGltf(address: NeedleAddress, json: GltfJson, options?: ResolveOptions): GltfResolveResult | null {
|
|
18
|
+
const report = options?.report;
|
|
19
|
+
const searched: Array<readonly [string, string]> = address.type
|
|
20
|
+
? [[address.type, collectionKeyForType(address.type)]]
|
|
21
|
+
: [...STAMPED_COLLECTIONS];
|
|
22
|
+
|
|
23
|
+
let byAnchor: GltfResolveResult | null = null;
|
|
24
|
+
if (address.anchor) {
|
|
25
|
+
const matches: GltfResolveResult[] = [];
|
|
26
|
+
for (const [type, key] of searched) {
|
|
27
|
+
for (const found of collectInJsonCollection(json, key, el => parseAnchorValue(el.name as string | undefined) === address.anchor)) {
|
|
28
|
+
matches.push({ type, index: found.index, element: found.element });
|
|
29
|
+
if (matches.length > 1 && !report) break;
|
|
30
|
+
}
|
|
31
|
+
if (matches.length > 1 && !report) break;
|
|
32
|
+
}
|
|
33
|
+
if (matches.length === 1) byAnchor = matches[0];
|
|
34
|
+
else if (matches.length > 1) report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
|
|
35
|
+
}
|
|
36
|
+
if (byAnchor && !report) return byAnchor;
|
|
37
|
+
|
|
38
|
+
let byId: GltfResolveResult | null = null;
|
|
39
|
+
if (address.id) {
|
|
40
|
+
for (const [type, key] of searched) {
|
|
41
|
+
const found = findInCollection(json, key, el => el.extras?.[NEEDLE_ID_KEY] === address.id);
|
|
42
|
+
if (found) { byId = { type, index: found.index, element: found.element }; break; }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (byAnchor) {
|
|
47
|
+
if (byId && (byId.type !== byAnchor.type || byId.index !== byAnchor.index)) {
|
|
48
|
+
report?.({ kind: "conflict", preferred: "anchor", other: "id" });
|
|
49
|
+
}
|
|
50
|
+
return byAnchor;
|
|
51
|
+
}
|
|
52
|
+
if (byId) return byId;
|
|
53
|
+
|
|
54
|
+
if (address.namePath?.length && (!address.type || address.type === "node")) {
|
|
55
|
+
const index = walkGltfNamePath(json, address.namePath);
|
|
56
|
+
if (index !== null) {
|
|
57
|
+
const nodes = json["nodes"] as Array<Record<string, unknown>>;
|
|
58
|
+
return { type: "node", index, element: nodes[index] };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** which rung of the ladder produced (or could have produced) a match */
|
|
65
|
+
export type ResolutionRung = "id" | "anchor" | "namePath" | "name";
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Something a caller may want to show a human. Never an error — resolution still returns
|
|
69
|
+
* its best answer; these say the answer was not the only defensible one.
|
|
70
|
+
*/
|
|
71
|
+
export type ResolutionIssue =
|
|
72
|
+
/** an anchor matched several elements, so it could not outrank an exact key */
|
|
73
|
+
| { kind: "ambiguous-anchor"; anchor: string; count: number }
|
|
74
|
+
/** a name matched several elements in a flat collection */
|
|
75
|
+
| { kind: "ambiguous-name"; name: string; count: number }
|
|
76
|
+
/** two rungs resolved to DIFFERENT elements — the content changed underneath */
|
|
77
|
+
| { kind: "conflict"; preferred: ResolutionRung; other: ResolutionRung };
|
|
78
|
+
|
|
79
|
+
export type ResolveOptions = {
|
|
80
|
+
/**
|
|
81
|
+
* Collect ambiguity/conflict evidence. OPT-IN because it costs extra traversals:
|
|
82
|
+
* detecting that two rungs disagree means evaluating both instead of stopping at the
|
|
83
|
+
* first hit, and resolution already walks from the root per address. The runtime
|
|
84
|
+
* short-circuits and pays nothing; the editor asks for evidence so it can surface
|
|
85
|
+
* drift to the user.
|
|
86
|
+
*/
|
|
87
|
+
report?: (issue: ResolutionIssue) => void;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve an address inside a live scene graph (duck-typed — any three.js).
|
|
92
|
+
*
|
|
93
|
+
* Ladder: **anchor → id → namePath**. The anchor comes first because it is a deliberate,
|
|
94
|
+
* human-authored declaration, while an id may be a derived value whose basis shifted when
|
|
95
|
+
* the content changed. It only wins when it resolves UNIQUELY: anchors are labels and may
|
|
96
|
+
* legally repeat, and an ambiguous label must never outrank an exact key.
|
|
97
|
+
*/
|
|
98
|
+
export function resolveInGraph(address: NeedleAddress, root: GraphNode, options?: ResolveOptions): GraphNode | null {
|
|
99
|
+
const report = options?.report;
|
|
100
|
+
|
|
101
|
+
let byAnchor: GraphNode | null = null;
|
|
102
|
+
if (address.anchor) {
|
|
103
|
+
// without a reporter, stop at two — "exactly one" vs "more than one" is all the
|
|
104
|
+
// ladder needs, and the count only matters for the message
|
|
105
|
+
const matches = collectInGraph(root, n => parseAnchorValue(n.name) === address.anchor, report ? Infinity : 2);
|
|
106
|
+
if (matches.length === 1) byAnchor = matches[0];
|
|
107
|
+
else if (matches.length > 1) report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
|
|
108
|
+
}
|
|
109
|
+
// fast path: nothing left to learn unless the caller wants the disagreement reported
|
|
110
|
+
if (byAnchor && !report) return byAnchor;
|
|
111
|
+
|
|
112
|
+
let byId: GraphNode | null = null;
|
|
113
|
+
if (address.id) byId = findInGraph(root, n => n.userData?.[NEEDLE_ID_KEY] === address.id);
|
|
114
|
+
|
|
115
|
+
if (byAnchor) {
|
|
116
|
+
if (byId && byId !== byAnchor) report?.({ kind: "conflict", preferred: "anchor", other: "id" });
|
|
117
|
+
return byAnchor;
|
|
118
|
+
}
|
|
119
|
+
if (byId) return byId;
|
|
120
|
+
|
|
121
|
+
if (address.namePath?.length) {
|
|
122
|
+
let current: GraphNode | null = root;
|
|
123
|
+
for (const segment of address.namePath) {
|
|
124
|
+
current = current.children?.find(c => (c.name ?? "") === segment) ?? null;
|
|
125
|
+
if (!current) break;
|
|
126
|
+
}
|
|
127
|
+
if (current && current !== root) return current;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** duck-typed view of an element that is not a graph node (a material, texture, …) */
|
|
133
|
+
export type AddressableElement = {
|
|
134
|
+
name?: string;
|
|
135
|
+
userData?: Record<string, unknown>;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve an address against a FLAT collection of live elements — materials, textures and
|
|
140
|
+
* anything else that carries a name and `userData` but is not part of the scene graph.
|
|
141
|
+
*
|
|
142
|
+
* Same ladder as the graph, minus `namePath` (there is no hierarchy to walk) plus `name`
|
|
143
|
+
* as the final rung, since a flat collection has no positional fallback. This exists so
|
|
144
|
+
* every element type resolves through ONE implementation: materials previously had their
|
|
145
|
+
* own ladders, could not be anchored at all, and disagreed with each other about whether a
|
|
146
|
+
* name match had to be unique.
|
|
147
|
+
*/
|
|
148
|
+
export function resolveInCollection<T extends AddressableElement>(address: NeedleAddress, elements: readonly T[], options?: ResolveOptions): T | null {
|
|
149
|
+
const report = options?.report;
|
|
150
|
+
|
|
151
|
+
let byAnchor: T | null = null;
|
|
152
|
+
if (address.anchor) {
|
|
153
|
+
const matches = elements.filter(e => parseAnchorValue(e.name) === address.anchor);
|
|
154
|
+
if (matches.length === 1) byAnchor = matches[0];
|
|
155
|
+
else if (matches.length > 1) report?.({ kind: "ambiguous-anchor", anchor: address.anchor, count: matches.length });
|
|
156
|
+
}
|
|
157
|
+
if (byAnchor && !report) return byAnchor;
|
|
158
|
+
|
|
159
|
+
const byId = address.id ? elements.find(e => e.userData?.[NEEDLE_ID_KEY] === address.id) ?? null : null;
|
|
160
|
+
|
|
161
|
+
if (byAnchor) {
|
|
162
|
+
if (byId && byId !== byAnchor) report?.({ kind: "conflict", preferred: "anchor", other: "id" });
|
|
163
|
+
return byAnchor;
|
|
164
|
+
}
|
|
165
|
+
if (byId) return byId;
|
|
166
|
+
|
|
167
|
+
if (address.name) {
|
|
168
|
+
const named = elements.filter(e => e.name === address.name);
|
|
169
|
+
if (named.length === 1) return named[0];
|
|
170
|
+
if (named.length > 1) report?.({ kind: "ambiguous-name", name: address.name, count: named.length });
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type ReconcileCandidate = {
|
|
176
|
+
object: GraphNode;
|
|
177
|
+
/** 0..1 — heuristic, for ranking and thresholds; 1.0 is reserved for exact resolution */
|
|
178
|
+
confidence: number;
|
|
179
|
+
reasons: string[];
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* When exact resolution fails (the asset changed), propose candidates instead of
|
|
184
|
+
* guessing: nodes matching the address's name, ranked by how much of the recorded
|
|
185
|
+
* namePath still matches. Callers decide thresholds and whether to re-anchor.
|
|
186
|
+
*/
|
|
187
|
+
export function reconcileInGraph(address: NeedleAddress, root: GraphNode): ReconcileCandidate[] {
|
|
188
|
+
if (!address.name && !address.namePath?.length) return [];
|
|
189
|
+
const targetName = address.name ?? address.namePath![address.namePath!.length - 1];
|
|
190
|
+
|
|
191
|
+
const candidates: ReconcileCandidate[] = [];
|
|
192
|
+
collectDescendants(root, (node, namePath) => {
|
|
193
|
+
if ((node.name ?? "") !== targetName) return;
|
|
194
|
+
const reasons = [`name matches "${targetName}"`];
|
|
195
|
+
let confidence = 0.5;
|
|
196
|
+
if (address.namePath?.length) {
|
|
197
|
+
const similarity = pathSuffixSimilarity(address.namePath, namePath);
|
|
198
|
+
confidence += 0.4 * similarity;
|
|
199
|
+
if (similarity > 0) reasons.push(`path suffix similarity ${(similarity * 100).toFixed(0)}%`);
|
|
200
|
+
}
|
|
201
|
+
candidates.push({ object: node, confidence, reasons });
|
|
202
|
+
});
|
|
203
|
+
if (candidates.length === 1) {
|
|
204
|
+
candidates[0].confidence = Math.min(0.95, candidates[0].confidence + 0.1);
|
|
205
|
+
candidates[0].reasons.push("only candidate with this name");
|
|
206
|
+
}
|
|
207
|
+
return candidates.sort((a, b) => b.confidence - a.confidence);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** every element of one glTF collection matching `predicate` */
|
|
211
|
+
function collectInJsonCollection(json: GltfJson, key: string, predicate: (el: Record<string, any>) => boolean): Array<{ index: number; element: Record<string, unknown> }> {
|
|
212
|
+
const collection = json[key];
|
|
213
|
+
if (!Array.isArray(collection)) return [];
|
|
214
|
+
const found: Array<{ index: number; element: Record<string, unknown> }> = [];
|
|
215
|
+
for (let i = 0; i < collection.length; i++) {
|
|
216
|
+
const element = collection[i];
|
|
217
|
+
if (element != null && typeof element === "object" && predicate(element)) {
|
|
218
|
+
found.push({ index: i, element });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return found;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function findInCollection(json: GltfJson, key: string, predicate: (el: Record<string, any>) => boolean): { index: number; element: Record<string, unknown> } | null {
|
|
225
|
+
const collection = json[key];
|
|
226
|
+
if (!Array.isArray(collection)) return null;
|
|
227
|
+
for (let i = 0; i < collection.length; i++) {
|
|
228
|
+
const element = collection[i];
|
|
229
|
+
if (element != null && typeof element === "object" && predicate(element)) {
|
|
230
|
+
return { index: i, element };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function walkGltfNamePath(json: GltfJson, namePath: string[]): number | null {
|
|
237
|
+
const nodes = json["nodes"];
|
|
238
|
+
const scenes = json["scenes"];
|
|
239
|
+
if (!Array.isArray(nodes) || !Array.isArray(scenes)) return null;
|
|
240
|
+
// roots: the union of all scenes' root nodes
|
|
241
|
+
let candidates: number[] = scenes.flatMap(s => (Array.isArray(s?.nodes) ? s.nodes : []));
|
|
242
|
+
let match: number | null = null;
|
|
243
|
+
for (const segment of namePath) {
|
|
244
|
+
match = candidates.find(i => ((nodes[i]?.name ?? "") === segment)) ?? null;
|
|
245
|
+
if (match === null) return null;
|
|
246
|
+
const children = nodes[match]?.children;
|
|
247
|
+
candidates = Array.isArray(children) ? children : [];
|
|
248
|
+
}
|
|
249
|
+
return match;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** every node matching `predicate`, stopping once `limit` are found (cycle-safe) */
|
|
253
|
+
function collectInGraph(root: GraphNode, predicate: (node: GraphNode) => boolean, limit: number): GraphNode[] {
|
|
254
|
+
const found: GraphNode[] = [];
|
|
255
|
+
const visited = new Set<GraphNode>();
|
|
256
|
+
const walk = (node: GraphNode) => {
|
|
257
|
+
if (found.length >= limit || visited.has(node)) return;
|
|
258
|
+
visited.add(node);
|
|
259
|
+
if (predicate(node)) {
|
|
260
|
+
found.push(node);
|
|
261
|
+
if (found.length >= limit) return;
|
|
262
|
+
}
|
|
263
|
+
if (!node.children) return;
|
|
264
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
265
|
+
const child = node.children[i];
|
|
266
|
+
if (child != null) walk(child);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
walk(root);
|
|
270
|
+
return found;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function findInGraph(root: GraphNode, predicate: (node: GraphNode) => boolean, visited = new Set<GraphNode>()): GraphNode | null {
|
|
274
|
+
if (visited.has(root)) return null;
|
|
275
|
+
visited.add(root);
|
|
276
|
+
if (predicate(root)) return root;
|
|
277
|
+
if (!root.children) return null;
|
|
278
|
+
for (let i = 0; i < root.children.length; i++) {
|
|
279
|
+
const child = root.children[i];
|
|
280
|
+
if (child == null) continue;
|
|
281
|
+
const found = findInGraph(child, predicate, visited);
|
|
282
|
+
if (found) return found;
|
|
283
|
+
}
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** visit every descendant of root with its root-exclusive name path (including own name) */
|
|
288
|
+
function collectDescendants(root: GraphNode, visit: (node: GraphNode, namePath: string[]) => void): void {
|
|
289
|
+
const visited = new Set<GraphNode>();
|
|
290
|
+
const walk = (node: GraphNode, namePath: string[]) => {
|
|
291
|
+
if (visited.has(node)) return;
|
|
292
|
+
visited.add(node);
|
|
293
|
+
if (node !== root) visit(node, namePath);
|
|
294
|
+
if (!node.children) return;
|
|
295
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
296
|
+
const child = node.children[i];
|
|
297
|
+
if (child == null) continue;
|
|
298
|
+
walk(child, [...namePath, child.name ?? ""]);
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
walk(root, []);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** how much of the expected path's tail matches the actual path's tail (0..1) */
|
|
305
|
+
function pathSuffixSimilarity(expected: string[], actual: string[]): number {
|
|
306
|
+
let shared = 0;
|
|
307
|
+
while (
|
|
308
|
+
shared < expected.length &&
|
|
309
|
+
shared < actual.length &&
|
|
310
|
+
expected[expected.length - 1 - shared] === actual[actual.length - 1 - shared]
|
|
311
|
+
) {
|
|
312
|
+
shared++;
|
|
313
|
+
}
|
|
314
|
+
return shared / Math.max(expected.length, actual.length);
|
|
315
|
+
}
|
package/src/uuid.ts
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
1
|
-
import { sha1, toHex } from "./hash.js";
|
|
2
|
-
|
|
3
|
-
/** RFC 4122 URL namespace — the namespace needle-cloud's `ensureUUID` uses (`uuidv5.URL`). */
|
|
4
|
-
export const UUID_NAMESPACE_URL = "6ba7b811-9dad-11d1-80b4-00c04fd430c8";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* RFC 4122 version-5 (SHA-1, name-based) UUID. Byte-identical to the `uuid` npm
|
|
8
|
-
* package's `v5(name, namespace)`.
|
|
9
|
-
*/
|
|
10
|
-
export function uuidv5(name: string, namespace: string = UUID_NAMESPACE_URL): string {
|
|
11
|
-
const ns = parseUuidBytes(namespace);
|
|
12
|
-
const nameBytes = new TextEncoder().encode(name);
|
|
13
|
-
const input = new Uint8Array(ns.length + nameBytes.length);
|
|
14
|
-
input.set(ns);
|
|
15
|
-
input.set(nameBytes, ns.length);
|
|
16
|
-
const hash = sha1(input).slice(0, 16);
|
|
17
|
-
hash[6] = (hash[6] & 0x0f) | 0x50; // version 5
|
|
18
|
-
hash[8] = (hash[8] & 0x3f) | 0x80; // RFC 4122 variant
|
|
19
|
-
return formatUuid(hash);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Random UUID for content that has no source file to be deterministic against
|
|
24
|
-
* (scratch content created in an editor). Uses the platform's crypto when available.
|
|
25
|
-
*/
|
|
26
|
-
export function randomUuid(): string {
|
|
27
|
-
const cryptoObj = globalThis.crypto;
|
|
28
|
-
if (cryptoObj?.randomUUID) return cryptoObj.randomUUID();
|
|
29
|
-
const bytes = new Uint8Array(16);
|
|
30
|
-
if (cryptoObj?.getRandomValues) cryptoObj.getRandomValues(bytes);
|
|
31
|
-
else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
32
|
-
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
33
|
-
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
34
|
-
return formatUuid(bytes);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function parseUuidBytes(uuid: string): Uint8Array {
|
|
38
|
-
const hex = uuid.replace(/-/g, "");
|
|
39
|
-
if (hex.length !== 32) throw new Error(`Invalid UUID: ${uuid}`);
|
|
40
|
-
const bytes = new Uint8Array(16);
|
|
41
|
-
for (let i = 0; i < 16; i++) bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
|
42
|
-
return bytes;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function formatUuid(bytes: Uint8Array): string {
|
|
46
|
-
const hex = toHex(bytes);
|
|
47
|
-
return `${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}`;
|
|
48
|
-
}
|
|
1
|
+
import { sha1, toHex } from "./hash.js";
|
|
2
|
+
|
|
3
|
+
/** RFC 4122 URL namespace — the namespace needle-cloud's `ensureUUID` uses (`uuidv5.URL`). */
|
|
4
|
+
export const UUID_NAMESPACE_URL = "6ba7b811-9dad-11d1-80b4-00c04fd430c8";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* RFC 4122 version-5 (SHA-1, name-based) UUID. Byte-identical to the `uuid` npm
|
|
8
|
+
* package's `v5(name, namespace)`.
|
|
9
|
+
*/
|
|
10
|
+
export function uuidv5(name: string, namespace: string = UUID_NAMESPACE_URL): string {
|
|
11
|
+
const ns = parseUuidBytes(namespace);
|
|
12
|
+
const nameBytes = new TextEncoder().encode(name);
|
|
13
|
+
const input = new Uint8Array(ns.length + nameBytes.length);
|
|
14
|
+
input.set(ns);
|
|
15
|
+
input.set(nameBytes, ns.length);
|
|
16
|
+
const hash = sha1(input).slice(0, 16);
|
|
17
|
+
hash[6] = (hash[6] & 0x0f) | 0x50; // version 5
|
|
18
|
+
hash[8] = (hash[8] & 0x3f) | 0x80; // RFC 4122 variant
|
|
19
|
+
return formatUuid(hash);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Random UUID for content that has no source file to be deterministic against
|
|
24
|
+
* (scratch content created in an editor). Uses the platform's crypto when available.
|
|
25
|
+
*/
|
|
26
|
+
export function randomUuid(): string {
|
|
27
|
+
const cryptoObj = globalThis.crypto;
|
|
28
|
+
if (cryptoObj?.randomUUID) return cryptoObj.randomUUID();
|
|
29
|
+
const bytes = new Uint8Array(16);
|
|
30
|
+
if (cryptoObj?.getRandomValues) cryptoObj.getRandomValues(bytes);
|
|
31
|
+
else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
32
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
33
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
34
|
+
return formatUuid(bytes);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseUuidBytes(uuid: string): Uint8Array {
|
|
38
|
+
const hex = uuid.replace(/-/g, "");
|
|
39
|
+
if (hex.length !== 32) throw new Error(`Invalid UUID: ${uuid}`);
|
|
40
|
+
const bytes = new Uint8Array(16);
|
|
41
|
+
for (let i = 0; i < 16; i++) bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
|
42
|
+
return bytes;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatUuid(bytes: Uint8Array): string {
|
|
46
|
+
const hex = toHex(bytes);
|
|
47
|
+
return `${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}`;
|
|
48
|
+
}
|