@recordtimelabel/core 0.4.7 → 0.5.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.
@@ -0,0 +1,456 @@
1
+ import {fingerprintCanonicalJson} from './hash.js';
2
+ import {
3
+ DEFAULT_UNKNOWN_CHANNEL,
4
+ DEFAULT_UNKNOWN_TITLE,
5
+ GROUP_ID_PREFIX,
6
+ TWITCH_STREAM_ANCHOR_TOLERANCE_MS,
7
+ collectNonVirtualRecords,
8
+ extractTwitchVodIdFromUrl,
9
+ extractYouTubeVideoIdFromUrl,
10
+ getFirstMatchingUrlValue,
11
+ getRecordTitle,
12
+ normalizeFallbackPlatform,
13
+ normalizeId,
14
+ normalizeIdList,
15
+ normalizeRecordPlatform,
16
+ normalizeText,
17
+ parseElapsedSeconds
18
+ } from './shared.js';
19
+ import {parseUtcMillis, roundDownToUtcMinuteIso, toCreatedAtMillis} from './time.js';
20
+
21
+ export const RECORD_GROUP_IDENTITY_VERSION = 1;
22
+
23
+ export const buildRecordTimeLabelStandaloneGroupIdentity = (
24
+ record,
25
+ fallbackPlatform,
26
+ unknownTitle = DEFAULT_UNKNOWN_TITLE
27
+ ) => {
28
+ const platform = normalizeRecordPlatform(record, fallbackPlatform);
29
+
30
+ if (platform === 'youtube') {
31
+ const videoId = normalizeText(record?.videoId) || getFirstMatchingUrlValue(record, extractYouTubeVideoIdFromUrl);
32
+ if (videoId) return `youtube:video:${videoId}`;
33
+ }
34
+
35
+ if (platform === 'twitch') {
36
+ const streamId = normalizeText(record?.streamId);
37
+ const streamCreatedAt = normalizeText(record?.streamCreatedAt || record?.streamStartedAt);
38
+ const channelHandle = normalizeText(
39
+ record?.channelHandle || record?.channelUsername || record?.username
40
+ ).toLowerCase();
41
+ const hasLiveIdentity = record?.isLiveStream === true || !!streamId || !!streamCreatedAt;
42
+
43
+ if (hasLiveIdentity) {
44
+ if (streamId) return `twitch:stream:${streamId}`;
45
+ if (channelHandle && streamCreatedAt) {
46
+ return `twitch:stream-start:${channelHandle}:${streamCreatedAt}`;
47
+ }
48
+ }
49
+
50
+ const vodId = normalizeText(record?.vodId) || getFirstMatchingUrlValue(record, extractTwitchVodIdFromUrl);
51
+ if (vodId) return `twitch:vod:${vodId}`;
52
+ }
53
+
54
+ return `legacy:${platform}:${getRecordTitle(record, unknownTitle)}`;
55
+ };
56
+
57
+ export const buildRecordTimeLabelStandaloneGroupId = (
58
+ record,
59
+ fallbackPlatform,
60
+ unknownTitle = DEFAULT_UNKNOWN_TITLE
61
+ ) => `${GROUP_ID_PREFIX}${buildRecordTimeLabelStandaloneGroupIdentity(record, fallbackPlatform, unknownTitle)}`;
62
+
63
+ export const buildLegacyRecordTimeLabelGroupId = (
64
+ record,
65
+ fallbackPlatform,
66
+ unknownTitle = DEFAULT_UNKNOWN_TITLE
67
+ ) => `${GROUP_ID_PREFIX}${record?.title || unknownTitle}__${record?.platform || normalizeFallbackPlatform(fallbackPlatform)}`;
68
+
69
+ const buildFallbackRecordGroupId = (
70
+ record,
71
+ fallbackPlatform,
72
+ unknownTitle = DEFAULT_UNKNOWN_TITLE
73
+ ) => `${GROUP_ID_PREFIX}legacy:${normalizeRecordPlatform(record, fallbackPlatform)}:${getRecordTitle(record, unknownTitle)}`;
74
+
75
+ const getTwitchLiveIdentityParts = (record = {}, fallbackPlatform) => {
76
+ if (normalizeRecordPlatform(record, fallbackPlatform) !== 'twitch') {
77
+ return {
78
+ streamId: '',
79
+ streamStartKey: '',
80
+ vodId: '',
81
+ channelKey: '',
82
+ streamAnchorMs: null,
83
+ hasExactStreamStart: false
84
+ };
85
+ }
86
+
87
+ const streamId = normalizeText(record?.streamId);
88
+ const streamCreatedAt = normalizeText(record?.streamCreatedAt || record?.streamStartedAt);
89
+ const channelHandle = normalizeText(
90
+ record?.channelHandle || record?.channelUsername || record?.username
91
+ ).toLowerCase();
92
+ const vodId = normalizeText(record?.vodId) || getFirstMatchingUrlValue(record, extractTwitchVodIdFromUrl);
93
+ const explicitAnchorValue = streamCreatedAt || normalizeText(record?.vodStartAt || record?.vodPublishedAt);
94
+ const explicitAnchorMs = parseUtcMillis(explicitAnchorValue);
95
+ const createdAtMs = parseUtcMillis(record?.createdAt);
96
+ const elapsedSeconds = parseElapsedSeconds(record?.originalLiveTime || record?.liveTime);
97
+ const currentTimeSeconds = parseElapsedSeconds(record?.currentTime);
98
+ const inferredElapsed = elapsedSeconds === null ? currentTimeSeconds : elapsedSeconds;
99
+ const inferredAnchorMs = createdAtMs !== null && inferredElapsed !== null
100
+ ? createdAtMs - (inferredElapsed * 1000)
101
+ : null;
102
+
103
+ return {
104
+ streamId,
105
+ streamStartKey: channelHandle && streamCreatedAt
106
+ ? `twitch:stream-start:${channelHandle}:${streamCreatedAt}`
107
+ : '',
108
+ vodId,
109
+ channelKey: channelHandle || normalizeText(record?.streamerName || record?.channelName).toLowerCase(),
110
+ streamAnchorMs: explicitAnchorMs !== null
111
+ ? explicitAnchorMs
112
+ : inferredAnchorMs,
113
+ hasExactStreamStart: Boolean(streamCreatedAt && parseUtcMillis(streamCreatedAt) !== null)
114
+ };
115
+ };
116
+
117
+ const addAliasTarget = (aliasTargetsByGroupId, alias, canonicalId) => {
118
+ const normalizedAlias = normalizeId(alias);
119
+ const normalizedCanonicalId = normalizeId(canonicalId);
120
+ if (!normalizedAlias || !normalizedCanonicalId) return;
121
+ const existing = aliasTargetsByGroupId[normalizedAlias] || [];
122
+ if (existing.includes(normalizedCanonicalId)) return;
123
+ aliasTargetsByGroupId[normalizedAlias] = [...existing, normalizedCanonicalId];
124
+ };
125
+
126
+ const sortGroupIdsByLatestCreatedAt = (groupIds = [], groupsById = {}) => (
127
+ [...groupIds].sort((left, right) => {
128
+ const delta = (groupsById[right]?.latestCreatedAt || 0) - (groupsById[left]?.latestCreatedAt || 0);
129
+ if (delta !== 0) return delta;
130
+ return String(left).localeCompare(String(right));
131
+ })
132
+ );
133
+
134
+ export const buildRecordTimeLabelGroupIndex = ({
135
+ records = [],
136
+ fallbackPlatform,
137
+ unknownTitle = DEFAULT_UNKNOWN_TITLE
138
+ } = {}) => {
139
+ const platform = normalizeFallbackPlatform(fallbackPlatform);
140
+ const safeRecords = collectNonVirtualRecords(records);
141
+ const seenIds = new Set();
142
+ const indexedRecords = [];
143
+ let skippedMissingIdCount = 0;
144
+ let skippedDuplicateIdCount = 0;
145
+
146
+ safeRecords.forEach((record) => {
147
+ const recordId = normalizeId(record?.id || record?.recordId);
148
+ if (!recordId) {
149
+ skippedMissingIdCount += 1;
150
+ return;
151
+ }
152
+ if (seenIds.has(recordId)) {
153
+ skippedDuplicateIdCount += 1;
154
+ return;
155
+ }
156
+ seenIds.add(recordId);
157
+ indexedRecords.push({...record, id: recordId});
158
+ });
159
+
160
+ const identityParts = indexedRecords.map((record) => getTwitchLiveIdentityParts(record, platform));
161
+ const parents = indexedRecords.map((_, index) => index);
162
+ const clusterStreamIds = identityParts.map((parts) => new Set(parts.streamId ? [parts.streamId] : []));
163
+ const clusterVodIds = identityParts.map((parts) => new Set(parts.vodId ? [parts.vodId] : []));
164
+ let conflictRefusalCount = 0;
165
+
166
+ const find = (index) => {
167
+ let root = index;
168
+ while (parents[root] !== root) root = parents[root];
169
+ while (parents[index] !== index) {
170
+ const next = parents[index];
171
+ parents[index] = root;
172
+ index = next;
173
+ }
174
+ return root;
175
+ };
176
+
177
+ const union = (left, right) => {
178
+ const leftRoot = find(left);
179
+ const rightRoot = find(right);
180
+ if (leftRoot === rightRoot) return true;
181
+ const mergedStreamIds = new Set([...clusterStreamIds[leftRoot], ...clusterStreamIds[rightRoot]]);
182
+ const mergedVodIds = new Set([...clusterVodIds[leftRoot], ...clusterVodIds[rightRoot]]);
183
+ if (mergedStreamIds.size > 1 || (mergedVodIds.size > 1 && mergedStreamIds.size === 0)) {
184
+ conflictRefusalCount += 1;
185
+ return false;
186
+ }
187
+ parents[rightRoot] = leftRoot;
188
+ clusterStreamIds[leftRoot] = mergedStreamIds;
189
+ clusterVodIds[leftRoot] = mergedVodIds;
190
+ return true;
191
+ };
192
+
193
+ const canShareTwitchStream = (left, right) => {
194
+ if (left.streamId && right.streamId && left.streamId !== right.streamId) return false;
195
+ if (left.vodId && right.vodId && left.vodId !== right.vodId) {
196
+ return Boolean(left.streamId && left.streamId === right.streamId);
197
+ }
198
+ return true;
199
+ };
200
+
201
+ const unionByExactIdentity = (getKey, canUnion = () => true) => {
202
+ const firstIndexByKey = new Map();
203
+ identityParts.forEach((parts, index) => {
204
+ const key = getKey(parts);
205
+ if (!key) return;
206
+ if (firstIndexByKey.has(key)) {
207
+ const firstIndex = firstIndexByKey.get(key);
208
+ if (canUnion(parts, identityParts[firstIndex])) union(index, firstIndex);
209
+ } else firstIndexByKey.set(key, index);
210
+ });
211
+ };
212
+
213
+ unionByExactIdentity((parts) => parts.streamId && `stream:${parts.streamId}`);
214
+ unionByExactIdentity(
215
+ (parts) => parts.streamStartKey && `start:${parts.streamStartKey}`,
216
+ canShareTwitchStream
217
+ );
218
+ unionByExactIdentity((parts) => parts.vodId && `vod:${parts.vodId}`, canShareTwitchStream);
219
+
220
+ const anchoredIndexesByChannel = new Map();
221
+ identityParts.forEach((parts, index) => {
222
+ if (!parts.channelKey || !Number.isFinite(parts.streamAnchorMs)) return;
223
+ const indexes = anchoredIndexesByChannel.get(parts.channelKey) || [];
224
+ indexes.push(index);
225
+ anchoredIndexesByChannel.set(parts.channelKey, indexes);
226
+ });
227
+ anchoredIndexesByChannel.forEach((indexes) => {
228
+ indexes.sort((left, right) => (
229
+ identityParts[left].streamAnchorMs - identityParts[right].streamAnchorMs
230
+ ));
231
+ for (let index = 1; index < indexes.length; index += 1) {
232
+ const previous = indexes[index - 1];
233
+ const current = indexes[index];
234
+ if (
235
+ canShareTwitchStream(identityParts[current], identityParts[previous]) &&
236
+ identityParts[current].streamAnchorMs - identityParts[previous].streamAnchorMs <=
237
+ TWITCH_STREAM_ANCHOR_TOLERANCE_MS
238
+ ) {
239
+ union(previous, current);
240
+ }
241
+ }
242
+ });
243
+
244
+ const clusterIndexes = new Map();
245
+ indexedRecords.forEach((_, index) => {
246
+ const root = find(index);
247
+ const indexes = clusterIndexes.get(root) || [];
248
+ indexes.push(index);
249
+ clusterIndexes.set(root, indexes);
250
+ });
251
+
252
+ const canonicalGroupIdByRoot = new Map();
253
+ const conflictGroupIds = [];
254
+ clusterIndexes.forEach((indexes, root) => {
255
+ const parts = indexes.map((index) => identityParts[index]);
256
+ const streamIds = [...new Set(parts.map((entry) => entry.streamId).filter(Boolean))];
257
+ const vodIds = [...new Set(parts.map((entry) => entry.vodId).filter(Boolean))];
258
+ if (streamIds.length > 1 || (vodIds.length > 1 && streamIds.length === 0)) {
259
+ conflictGroupIds.push(root);
260
+ }
261
+ if (indexes.length < 2) return;
262
+ const streamId = streamIds.slice().sort()[0];
263
+ const vodId = vodIds.slice().sort()[0];
264
+ const streamStartKey = parts.map((entry) => entry.streamStartKey).filter(Boolean).sort()[0];
265
+ if (streamId) {
266
+ canonicalGroupIdByRoot.set(root, `${GROUP_ID_PREFIX}twitch:stream:${streamId}`);
267
+ } else if (vodId) {
268
+ canonicalGroupIdByRoot.set(root, `${GROUP_ID_PREFIX}twitch:vod:${vodId}`);
269
+ } else if (streamStartKey) {
270
+ canonicalGroupIdByRoot.set(root, `${GROUP_ID_PREFIX}${streamStartKey}`);
271
+ } else {
272
+ const anchoredPart = parts
273
+ .filter((entry) => entry.channelKey && Number.isFinite(entry.streamAnchorMs))
274
+ .sort((left, right) => left.streamAnchorMs - right.streamAnchorMs)[0];
275
+ if (anchoredPart) {
276
+ const roundedAnchor = roundDownToUtcMinuteIso(anchoredPart.streamAnchorMs);
277
+ canonicalGroupIdByRoot.set(
278
+ root,
279
+ `${GROUP_ID_PREFIX}twitch:stream-inferred:${encodeURIComponent(anchoredPart.channelKey)}:${roundedAnchor}`
280
+ );
281
+ }
282
+ }
283
+ });
284
+
285
+ const groupIdByRecordId = {};
286
+ const groupsById = {};
287
+ const aliasTargetsByGroupId = {};
288
+ const conflictCanonicalIds = new Set();
289
+
290
+ indexedRecords.forEach((record, index) => {
291
+ const root = find(index);
292
+ const groupId = canonicalGroupIdByRoot.get(root) ||
293
+ buildRecordTimeLabelStandaloneGroupId(record, platform, unknownTitle);
294
+ groupIdByRecordId[record.id] = groupId;
295
+ const createdAt = toCreatedAtMillis(record);
296
+ const existing = groupsById[groupId];
297
+ groupsById[groupId] = {
298
+ id: groupId,
299
+ latestCreatedAt: Math.max(existing?.latestCreatedAt || 0, createdAt),
300
+ recordIds: [...(existing?.recordIds || []), record.id]
301
+ };
302
+ if (conflictGroupIds.includes(root)) conflictCanonicalIds.add(groupId);
303
+
304
+ addAliasTarget(aliasTargetsByGroupId, groupId, groupId);
305
+ addAliasTarget(
306
+ aliasTargetsByGroupId,
307
+ buildRecordTimeLabelStandaloneGroupId(record, platform, unknownTitle),
308
+ groupId
309
+ );
310
+ addAliasTarget(aliasTargetsByGroupId, buildLegacyRecordTimeLabelGroupId(record, platform, unknownTitle), groupId);
311
+ addAliasTarget(aliasTargetsByGroupId, buildFallbackRecordGroupId(record, platform, unknownTitle), groupId);
312
+ });
313
+
314
+ const groups = Object.values(groupsById).sort((left, right) => {
315
+ const delta = (right.latestCreatedAt || 0) - (left.latestCreatedAt || 0);
316
+ if (delta !== 0) return delta;
317
+ return left.id.localeCompare(right.id);
318
+ });
319
+
320
+ return {
321
+ groupIdByRecordId,
322
+ groups,
323
+ aliasTargetsByGroupId,
324
+ diagnostics: {
325
+ identityVersion: RECORD_GROUP_IDENTITY_VERSION,
326
+ fallbackPlatform: platform,
327
+ recordCount: indexedRecords.length,
328
+ groupCount: groups.length,
329
+ skippedMissingIdCount,
330
+ skippedDuplicateIdCount,
331
+ conflictRefusalCount,
332
+ conflictGroupIds: [...conflictCanonicalIds].sort()
333
+ }
334
+ };
335
+ };
336
+
337
+ const migrateGroupIdList = (groupIds = [], aliasTargetsByGroupId = {}, groupsById = {}) => {
338
+ const seen = new Set();
339
+ const result = [];
340
+ normalizeIdList(groupIds).forEach((groupId) => {
341
+ const mappedGroupIds = aliasTargetsByGroupId[groupId] || [groupId];
342
+ const orderedGroupIds = mappedGroupIds.length > 1
343
+ ? sortGroupIdsByLatestCreatedAt(mappedGroupIds, groupsById)
344
+ : mappedGroupIds;
345
+ orderedGroupIds.forEach((mappedGroupId) => {
346
+ if (!mappedGroupId || seen.has(mappedGroupId)) return;
347
+ seen.add(mappedGroupId);
348
+ result.push(mappedGroupId);
349
+ });
350
+ });
351
+ return result;
352
+ };
353
+
354
+ const groupsByIdFromIndex = (groupIndex) => {
355
+ const groupsById = {};
356
+ (groupIndex?.groups || []).forEach((group) => {
357
+ if (group?.id) groupsById[group.id] = group;
358
+ });
359
+ return groupsById;
360
+ };
361
+
362
+ export const canonicalizeRecordTimeLabelGroupView = ({
363
+ records = [],
364
+ groupIndex = null,
365
+ groupOrder = [],
366
+ expandedGroups = [],
367
+ fallbackPlatform,
368
+ unknownTitle = DEFAULT_UNKNOWN_TITLE
369
+ } = {}) => {
370
+ const index = groupIndex || buildRecordTimeLabelGroupIndex({
371
+ records,
372
+ fallbackPlatform,
373
+ unknownTitle
374
+ });
375
+ const groupsById = groupsByIdFromIndex(index);
376
+ const visibleGroupIds = (index.groups || []).map((group) => group.id);
377
+ const migratedCurrentOrder = migrateGroupIdList(
378
+ groupOrder,
379
+ index.aliasTargetsByGroupId,
380
+ groupsById
381
+ );
382
+ const migratedExpandedGroups = migrateGroupIdList(
383
+ expandedGroups,
384
+ index.aliasTargetsByGroupId,
385
+ groupsById
386
+ );
387
+
388
+ let nextGroupOrder = [];
389
+ if (visibleGroupIds.length === 0) {
390
+ nextGroupOrder = migratedCurrentOrder.filter((groupId) => groupId.startsWith(GROUP_ID_PREFIX));
391
+ } else if (migratedCurrentOrder.length === 0) {
392
+ nextGroupOrder = sortGroupIdsByLatestCreatedAt(visibleGroupIds, groupsById);
393
+ } else {
394
+ const orderedVisibleGroups = migratedCurrentOrder.filter((groupId) => groupsById[groupId]);
395
+ const orderedVisibleSet = new Set(orderedVisibleGroups);
396
+ const newVisibleGroups = sortGroupIdsByLatestCreatedAt(
397
+ visibleGroupIds.filter((groupId) => !orderedVisibleSet.has(groupId)),
398
+ groupsById
399
+ );
400
+ const visibleSet = new Set([...newVisibleGroups, ...orderedVisibleGroups]);
401
+ const hiddenOrder = migratedCurrentOrder.filter((groupId) => (
402
+ groupId.startsWith(GROUP_ID_PREFIX) && !visibleSet.has(groupId)
403
+ ));
404
+ nextGroupOrder = [...newVisibleGroups, ...orderedVisibleGroups, ...hiddenOrder];
405
+ }
406
+
407
+ const previousGroupOrder = normalizeIdList(groupOrder);
408
+ const previousExpanded = normalizeIdList(expandedGroups);
409
+ const changed = previousGroupOrder.join('\u0000') !== nextGroupOrder.join('\u0000') ||
410
+ previousExpanded.join('\u0000') !== migratedExpandedGroups.join('\u0000');
411
+
412
+ return {
413
+ groupOrder: nextGroupOrder,
414
+ expandedGroups: migratedExpandedGroups,
415
+ changed,
416
+ diagnostics: {
417
+ identityVersion: RECORD_GROUP_IDENTITY_VERSION,
418
+ migratedAliasCount: previousGroupOrder.filter((groupId) => (
419
+ Boolean(index.aliasTargetsByGroupId[groupId]) &&
420
+ index.aliasTargetsByGroupId[groupId].join('\u0000') !== groupId
421
+ )).length,
422
+ newVisibleCount: nextGroupOrder.filter((groupId) => groupsById[groupId] && !previousGroupOrder.includes(groupId)).length,
423
+ retainedHiddenCount: nextGroupOrder.filter((groupId) => !groupsById[groupId]).length,
424
+ changed
425
+ }
426
+ };
427
+ };
428
+
429
+ export const buildRecordTimeLabelGroupMetadata = ({
430
+ records = [],
431
+ fallbackPlatform,
432
+ unknownTitle = DEFAULT_UNKNOWN_TITLE,
433
+ unknownChannel = DEFAULT_UNKNOWN_CHANNEL
434
+ } = {}) => {
435
+ const platform = normalizeFallbackPlatform(fallbackPlatform);
436
+ let latestRecord = null;
437
+ let latestCreatedAt = Number.NEGATIVE_INFINITY;
438
+ collectNonVirtualRecords(records).forEach((record) => {
439
+ const createdAt = toCreatedAtMillis(record);
440
+ if (!latestRecord || createdAt >= latestCreatedAt) {
441
+ latestRecord = record;
442
+ latestCreatedAt = createdAt;
443
+ }
444
+ });
445
+ const safeRecord = latestRecord || {};
446
+ return {
447
+ title: getRecordTitle(safeRecord, unknownTitle),
448
+ streamerName: normalizeText(safeRecord.streamerName || safeRecord.channelName) || unknownChannel,
449
+ avatarUrl: safeRecord.avatarUrl || null,
450
+ platform: normalizeRecordPlatform(safeRecord, platform)
451
+ };
452
+ };
453
+
454
+ export const buildRecordTimeLabelGroupReorderOperationId = (previousOrder = [], nextOrder = []) => (
455
+ `group.reorder:canonical:${fingerprintCanonicalJson(normalizeIdList(previousOrder))}:${fingerprintCanonicalJson(normalizeIdList(nextOrder))}`
456
+ );
@@ -0,0 +1,147 @@
1
+ const K = new Uint32Array([
2
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
3
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
4
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
5
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
6
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
7
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
8
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
9
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
10
+ ]);
11
+
12
+ const rotr = (value, bits) => (value >>> bits) | (value << (32 - bits));
13
+
14
+ const toUtf8Bytes = (value) => {
15
+ const text = String(value);
16
+ if (typeof TextEncoder === 'function') {
17
+ return new TextEncoder().encode(text);
18
+ }
19
+ const bytes = [];
20
+ for (let index = 0; index < text.length; index += 1) {
21
+ let code = text.charCodeAt(index);
22
+ if (code >= 0xd800 && code <= 0xdbff && index + 1 < text.length) {
23
+ const next = text.charCodeAt(index + 1);
24
+ if (next >= 0xdc00 && next <= 0xdfff) {
25
+ code = ((code - 0xd800) << 10) + (next - 0xdc00) + 0x10000;
26
+ index += 1;
27
+ }
28
+ }
29
+ if (code < 0x80) bytes.push(code);
30
+ else if (code < 0x800) bytes.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
31
+ else if (code < 0x10000) {
32
+ bytes.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
33
+ } else {
34
+ bytes.push(
35
+ 0xf0 | (code >> 18),
36
+ 0x80 | ((code >> 12) & 0x3f),
37
+ 0x80 | ((code >> 6) & 0x3f),
38
+ 0x80 | (code & 0x3f)
39
+ );
40
+ }
41
+ }
42
+ return Uint8Array.from(bytes);
43
+ };
44
+
45
+ export const sha256Hex = (value) => {
46
+ const message = toUtf8Bytes(value);
47
+ const bitLength = message.length * 8;
48
+ const paddedLength = (((message.length + 9 + 63) >> 6) << 6);
49
+ const bytes = new Uint8Array(paddedLength);
50
+ bytes.set(message);
51
+ bytes[message.length] = 0x80;
52
+ const view = new DataView(bytes.buffer);
53
+ view.setUint32(paddedLength - 4, bitLength >>> 0);
54
+
55
+ let h0 = 0x6a09e667;
56
+ let h1 = 0xbb67ae85;
57
+ let h2 = 0x3c6ef372;
58
+ let h3 = 0xa54ff53a;
59
+ let h4 = 0x510e527f;
60
+ let h5 = 0x9b05688c;
61
+ let h6 = 0x1f83d9ab;
62
+ let h7 = 0x5be0cd19;
63
+ const w = new Uint32Array(64);
64
+
65
+ for (let offset = 0; offset < paddedLength; offset += 64) {
66
+ for (let index = 0; index < 16; index += 1) {
67
+ w[index] = view.getUint32(offset + (index * 4));
68
+ }
69
+ for (let index = 16; index < 64; index += 1) {
70
+ const s0 = rotr(w[index - 15], 7) ^ rotr(w[index - 15], 18) ^ (w[index - 15] >>> 3);
71
+ const s1 = rotr(w[index - 2], 17) ^ rotr(w[index - 2], 19) ^ (w[index - 2] >>> 10);
72
+ w[index] = (w[index - 16] + s0 + w[index - 7] + s1) >>> 0;
73
+ }
74
+
75
+ let a = h0;
76
+ let b = h1;
77
+ let c = h2;
78
+ let d = h3;
79
+ let e = h4;
80
+ let f = h5;
81
+ let g = h6;
82
+ let h = h7;
83
+ for (let index = 0; index < 64; index += 1) {
84
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
85
+ const ch = (e & f) ^ (~e & g);
86
+ const temp1 = (h + S1 + ch + K[index] + w[index]) >>> 0;
87
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
88
+ const maj = (a & b) ^ (a & c) ^ (b & c);
89
+ const temp2 = (S0 + maj) >>> 0;
90
+ h = g;
91
+ g = f;
92
+ f = e;
93
+ e = (d + temp1) >>> 0;
94
+ d = c;
95
+ c = b;
96
+ b = a;
97
+ a = (temp1 + temp2) >>> 0;
98
+ }
99
+ h0 = (h0 + a) >>> 0;
100
+ h1 = (h1 + b) >>> 0;
101
+ h2 = (h2 + c) >>> 0;
102
+ h3 = (h3 + d) >>> 0;
103
+ h4 = (h4 + e) >>> 0;
104
+ h5 = (h5 + f) >>> 0;
105
+ h6 = (h6 + g) >>> 0;
106
+ h7 = (h7 + h) >>> 0;
107
+ }
108
+
109
+ return [h0, h1, h2, h3, h4, h5, h6, h7]
110
+ .map((word) => word.toString(16).padStart(8, '0'))
111
+ .join('');
112
+ };
113
+
114
+ export const canonicalizeJson = (value) => {
115
+ if (value === null) return 'null';
116
+ const type = typeof value;
117
+ if (type === 'number') {
118
+ if (!Number.isFinite(value)) return 'null';
119
+ return JSON.stringify(value);
120
+ }
121
+ if (type === 'boolean') return value ? 'true' : 'false';
122
+ if (type === 'string') return JSON.stringify(value);
123
+ if (type !== 'object') return undefined;
124
+
125
+ if (Array.isArray(value)) {
126
+ const items = value.map((entry) => {
127
+ const serialized = canonicalizeJson(entry);
128
+ return serialized === undefined ? 'null' : serialized;
129
+ });
130
+ return `[${items.join(',')}]`;
131
+ }
132
+
133
+ const keys = Object.keys(value).filter((key) => value[key] !== undefined).sort();
134
+ const fields = [];
135
+ keys.forEach((key) => {
136
+ const serialized = canonicalizeJson(value[key]);
137
+ if (serialized === undefined) return;
138
+ fields.push(`${JSON.stringify(key)}:${serialized}`);
139
+ });
140
+ return `{${fields.join(',')}}`;
141
+ };
142
+
143
+ export const digestCanonicalJson = (value) => sha256Hex(canonicalizeJson(value));
144
+
145
+ export const fingerprintCanonicalJson = (value, length = 16) => (
146
+ digestCanonicalJson(value).slice(0, length)
147
+ );