@recordtimelabel/core 0.4.7 → 0.6.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 +14 -5
- package/package.json +3 -2
- package/src/changefeed.js +195 -0
- package/src/domain/channel-folder.js +377 -0
- package/src/domain/group-identity.js +456 -0
- package/src/domain/hash.js +147 -0
- package/src/domain/legacy.js +359 -0
- package/src/domain/shared.js +132 -0
- package/src/domain/time.js +43 -0
- package/src/domain/twitch-vod.js +303 -0
- package/src/domain.js +45 -0
- package/src/firestore-v2.js +5 -0
- package/src/index.js +264 -1
- package/src/snapshot-root.js +92 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import {fingerprintCanonicalJson} from './hash.js';
|
|
2
|
+
import {buildRecordTimeLabelGroupIndex} from './group-identity.js';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_UNKNOWN_TITLE,
|
|
5
|
+
collectNonVirtualRecords,
|
|
6
|
+
extractTwitchVodIdFromUrl,
|
|
7
|
+
getFirstMatchingUrlValue,
|
|
8
|
+
getRecordUrls,
|
|
9
|
+
normalizeId,
|
|
10
|
+
normalizeRecordPlatform,
|
|
11
|
+
normalizeText,
|
|
12
|
+
parseElapsedSeconds
|
|
13
|
+
} from './shared.js';
|
|
14
|
+
import {parseUtcMillis} from './time.js';
|
|
15
|
+
|
|
16
|
+
const TITLE_BRACKET_PATTERN = /\[[^\]]*\]|\([^)]*\)|\{[^}]*\}/g;
|
|
17
|
+
const NON_WORD_PATTERN = /[^\p{L}\p{N}\s]/gu;
|
|
18
|
+
const MINUTE_MS = 60 * 1000;
|
|
19
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
20
|
+
const RECORD_AFTER_START_GRACE_MS = 10 * MINUTE_MS;
|
|
21
|
+
const STREAM_START_GRACE_MS = 45 * MINUTE_MS;
|
|
22
|
+
const ELAPSED_START_TOLERANCE_MS = 45 * MINUTE_MS;
|
|
23
|
+
const MAX_REASONABLE_STREAM_MS = 48 * HOUR_MS;
|
|
24
|
+
|
|
25
|
+
const getVodStartMs = (vod = {}) => parseUtcMillis(vod.publishedAt || vod.createdAt);
|
|
26
|
+
|
|
27
|
+
const getNextVodStartMs = (value) => (
|
|
28
|
+
typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
29
|
+
? value
|
|
30
|
+
: parseUtcMillis(value)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const normalizeVodTitleForMatch = (value) => String(value || '')
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.normalize('NFKC')
|
|
36
|
+
.replace(TITLE_BRACKET_PATTERN, ' ')
|
|
37
|
+
.replace(NON_WORD_PATTERN, ' ')
|
|
38
|
+
.replace(/\s+/g, ' ')
|
|
39
|
+
.trim();
|
|
40
|
+
|
|
41
|
+
export const calculateVodTitleSimilarity = (expectedTitle, candidateTitle) => {
|
|
42
|
+
const expected = normalizeVodTitleForMatch(expectedTitle);
|
|
43
|
+
const candidate = normalizeVodTitleForMatch(candidateTitle);
|
|
44
|
+
if (!expected || !candidate) return 0;
|
|
45
|
+
if (expected === candidate) return 1;
|
|
46
|
+
if (expected.includes(candidate) || candidate.includes(expected)) return 0.9;
|
|
47
|
+
|
|
48
|
+
const expectedTokens = new Set(expected.split(' ').filter(Boolean));
|
|
49
|
+
const candidateTokens = new Set(candidate.split(' ').filter(Boolean));
|
|
50
|
+
if (expectedTokens.size === 0 || candidateTokens.size === 0) return 0;
|
|
51
|
+
|
|
52
|
+
let intersection = 0;
|
|
53
|
+
expectedTokens.forEach((token) => {
|
|
54
|
+
if (candidateTokens.has(token)) intersection += 1;
|
|
55
|
+
});
|
|
56
|
+
const union = new Set([...expectedTokens, ...candidateTokens]).size;
|
|
57
|
+
return union > 0 ? intersection / union : 0;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const scoreTwitchVodCandidate = (vod = {}, context = {}) => {
|
|
61
|
+
const recordMs = parseUtcMillis(context.recordTimestamp);
|
|
62
|
+
const streamStartMs = parseUtcMillis(context.streamCreatedAt || context.streamStartedAt);
|
|
63
|
+
const elapsedSeconds = parseElapsedSeconds(context.recordLiveTimeSeconds);
|
|
64
|
+
const targetSeconds = parseElapsedSeconds(context.recordTargetTimeSeconds) ?? elapsedSeconds;
|
|
65
|
+
const vodStartMs = getVodStartMs(vod);
|
|
66
|
+
const nextVodStartMs = getNextVodStartMs(context.nextVodStartMs || context.nextVodStartAt);
|
|
67
|
+
|
|
68
|
+
if (!vod?.id || vodStartMs === null) return null;
|
|
69
|
+
|
|
70
|
+
const expectedStartMs = recordMs !== null && elapsedSeconds !== null
|
|
71
|
+
? recordMs - (elapsedSeconds * 1000)
|
|
72
|
+
: null;
|
|
73
|
+
const lengthSeconds = parseElapsedSeconds(vod.lengthSeconds);
|
|
74
|
+
const hasStableAnchor = Boolean(streamStartMs !== null || expectedStartMs !== null);
|
|
75
|
+
const matchedBy = [];
|
|
76
|
+
let score = 0;
|
|
77
|
+
const streamAnchorMs = expectedStartMs !== null ? expectedStartMs : streamStartMs;
|
|
78
|
+
const targetAbsoluteMs = targetSeconds !== null && streamAnchorMs !== null
|
|
79
|
+
? streamAnchorMs + (targetSeconds * 1000)
|
|
80
|
+
: recordMs;
|
|
81
|
+
const lengthEndMs = lengthSeconds !== null ? vodStartMs + (lengthSeconds * 1000) : null;
|
|
82
|
+
const inferredEndMs = nextVodStartMs !== null &&
|
|
83
|
+
nextVodStartMs > vodStartMs &&
|
|
84
|
+
nextVodStartMs - vodStartMs <= MAX_REASONABLE_STREAM_MS
|
|
85
|
+
? nextVodStartMs
|
|
86
|
+
: null;
|
|
87
|
+
const vodEndMs = lengthEndMs || inferredEndMs;
|
|
88
|
+
|
|
89
|
+
if (targetAbsoluteMs !== null) {
|
|
90
|
+
if (targetAbsoluteMs < vodStartMs - RECORD_AFTER_START_GRACE_MS) return null;
|
|
91
|
+
if (vodEndMs !== null && targetAbsoluteMs >= vodEndMs + RECORD_AFTER_START_GRACE_MS) return null;
|
|
92
|
+
if (vodEndMs !== null) {
|
|
93
|
+
score += 80;
|
|
94
|
+
matchedBy.push('target-window');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (expectedStartMs !== null) {
|
|
99
|
+
const startDelta = Math.abs(vodStartMs - expectedStartMs);
|
|
100
|
+
if (startDelta <= ELAPSED_START_TOLERANCE_MS) {
|
|
101
|
+
const closeness = 1 - (startDelta / ELAPSED_START_TOLERANCE_MS);
|
|
102
|
+
score += 85 + (closeness * 15);
|
|
103
|
+
matchedBy.push('elapsed-start');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (streamStartMs !== null && recordMs !== null) {
|
|
108
|
+
const startsInsideStreamWindow =
|
|
109
|
+
vodStartMs >= streamStartMs - STREAM_START_GRACE_MS &&
|
|
110
|
+
vodStartMs <= recordMs + RECORD_AFTER_START_GRACE_MS;
|
|
111
|
+
if (startsInsideStreamWindow) {
|
|
112
|
+
const streamSpan = Math.max(recordMs - streamStartMs, 1);
|
|
113
|
+
const positionPenalty = Math.min(Math.max(vodStartMs - streamStartMs, 0) / streamSpan, 1) * 15;
|
|
114
|
+
score += 70 - positionPenalty;
|
|
115
|
+
matchedBy.push('stream-window');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (recordMs !== null && vodEndMs !== null) {
|
|
120
|
+
const containsRecord =
|
|
121
|
+
recordMs >= vodStartMs - RECORD_AFTER_START_GRACE_MS &&
|
|
122
|
+
recordMs <= vodEndMs + RECORD_AFTER_START_GRACE_MS;
|
|
123
|
+
if (containsRecord) {
|
|
124
|
+
score += 60;
|
|
125
|
+
matchedBy.push('duration-window');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!hasStableAnchor && recordMs !== null) {
|
|
130
|
+
const recordOffsetMs = recordMs - vodStartMs;
|
|
131
|
+
if (recordOffsetMs >= -RECORD_AFTER_START_GRACE_MS && recordOffsetMs <= MAX_REASONABLE_STREAM_MS) {
|
|
132
|
+
const closeness = 1 - Math.min(Math.max(recordOffsetMs, 0) / MAX_REASONABLE_STREAM_MS, 1);
|
|
133
|
+
score += 30 + (closeness * 20);
|
|
134
|
+
matchedBy.push('legacy-time-window');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (matchedBy.length === 0) return null;
|
|
139
|
+
|
|
140
|
+
const titleSimilarity = calculateVodTitleSimilarity(context.expectedTitle, vod.title || '');
|
|
141
|
+
score += titleSimilarity * 10;
|
|
142
|
+
if (score < (hasStableAnchor ? 55 : 25)) return null;
|
|
143
|
+
|
|
144
|
+
const vodTimeOffsetSeconds = targetSeconds !== null && matchedBy.includes('elapsed-start')
|
|
145
|
+
? targetSeconds
|
|
146
|
+
: targetSeconds !== null && streamAnchorMs !== null
|
|
147
|
+
? Math.max(0, Math.floor(targetSeconds - ((vodStartMs - streamAnchorMs) / 1000)))
|
|
148
|
+
: (recordMs !== null
|
|
149
|
+
? Math.max(0, Math.floor((recordMs - vodStartMs) / 1000))
|
|
150
|
+
: targetSeconds);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
vod,
|
|
154
|
+
score,
|
|
155
|
+
matchedBy,
|
|
156
|
+
titleSimilarity,
|
|
157
|
+
vodStartAt: new Date(vodStartMs).toISOString(),
|
|
158
|
+
vodTimeOffsetSeconds
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
export const selectBestMatchingTwitchVod = (items = [], context = {}) => {
|
|
163
|
+
const normalizedItems = (Array.isArray(items) ? items : [])
|
|
164
|
+
.map((item) => item?.node || item)
|
|
165
|
+
.map((vod) => ({vod, startMs: getVodStartMs(vod)}))
|
|
166
|
+
.filter((entry) => entry.vod?.id && entry.startMs !== null)
|
|
167
|
+
.sort((a, b) => a.startMs - b.startMs);
|
|
168
|
+
|
|
169
|
+
return normalizedItems
|
|
170
|
+
.map((entry, index) => scoreTwitchVodCandidate(entry.vod, {
|
|
171
|
+
...context,
|
|
172
|
+
nextVodStartMs: normalizedItems[index + 1]?.startMs ?? null
|
|
173
|
+
}))
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.sort((a, b) => b.score - a.score)[0] || null;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const valuesEqual = (left, right) => {
|
|
179
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
180
|
+
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false;
|
|
181
|
+
return left.every((value, index) => valuesEqual(value, right[index]));
|
|
182
|
+
}
|
|
183
|
+
return left === right;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const recordHasConflictingIdentity = (record, source) => {
|
|
187
|
+
const recordStreamId = normalizeText(record?.streamId);
|
|
188
|
+
const sourceStreamId = normalizeText(source?.streamId);
|
|
189
|
+
if (recordStreamId && sourceStreamId && recordStreamId !== sourceStreamId) return true;
|
|
190
|
+
const recordVodId = normalizeText(record?.vodId) || getFirstMatchingUrlValue(record, extractTwitchVodIdFromUrl);
|
|
191
|
+
const sourceVodId = normalizeText(source?.vodId) || getFirstMatchingUrlValue(source, extractTwitchVodIdFromUrl);
|
|
192
|
+
if (recordVodId && sourceVodId && recordVodId !== sourceVodId) return true;
|
|
193
|
+
return false;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
export const buildRecordTimeLabelVodPatchOperationId = (recordId, patch = {}) => (
|
|
197
|
+
`record.update:${normalizeId(recordId)}:vod-patch:${fingerprintCanonicalJson(patch || {})}`
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
export const buildKnownTwitchVodRecordPatches = ({
|
|
201
|
+
records = [],
|
|
202
|
+
groupIndex = null,
|
|
203
|
+
fallbackPlatform,
|
|
204
|
+
unknownTitle = DEFAULT_UNKNOWN_TITLE
|
|
205
|
+
} = {}) => {
|
|
206
|
+
const safeRecords = collectNonVirtualRecords(records);
|
|
207
|
+
const index = groupIndex || buildRecordTimeLabelGroupIndex({
|
|
208
|
+
records: safeRecords,
|
|
209
|
+
fallbackPlatform,
|
|
210
|
+
unknownTitle
|
|
211
|
+
});
|
|
212
|
+
const conflictGroupIds = new Set(index.diagnostics?.conflictGroupIds || []);
|
|
213
|
+
const sourceByGroupId = {};
|
|
214
|
+
|
|
215
|
+
safeRecords.forEach((record) => {
|
|
216
|
+
if (normalizeRecordPlatform(record, fallbackPlatform) !== 'twitch' || record?.hasVod !== true) return;
|
|
217
|
+
const recordId = normalizeId(record?.id);
|
|
218
|
+
const groupId = index.groupIdByRecordId[recordId];
|
|
219
|
+
if (!groupId || conflictGroupIds.has(groupId)) return;
|
|
220
|
+
const vodId = normalizeText(record?.vodId) || getFirstMatchingUrlValue(record, extractTwitchVodIdFromUrl);
|
|
221
|
+
if (!vodId) return;
|
|
222
|
+
const current = sourceByGroupId[groupId];
|
|
223
|
+
const currentScore = current
|
|
224
|
+
? Number(Boolean(current.vodStartAt || current.vodPublishedAt)) +
|
|
225
|
+
Number(Number.isFinite(Number(current.vodTimeOffsetSeconds)))
|
|
226
|
+
: -1;
|
|
227
|
+
const nextScore = Number(Boolean(record.vodStartAt || record.vodPublishedAt)) +
|
|
228
|
+
Number(Number.isFinite(Number(record.vodTimeOffsetSeconds)));
|
|
229
|
+
if (!current || nextScore > currentScore) sourceByGroupId[groupId] = record;
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const patches = [];
|
|
233
|
+
safeRecords.forEach((record) => {
|
|
234
|
+
const recordId = normalizeId(record?.id);
|
|
235
|
+
if (!recordId || normalizeRecordPlatform(record, fallbackPlatform) !== 'twitch' || record.hasVod === true) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const groupId = index.groupIdByRecordId[recordId];
|
|
239
|
+
if (!groupId || conflictGroupIds.has(groupId)) return;
|
|
240
|
+
const source = sourceByGroupId[groupId];
|
|
241
|
+
if (!source || recordHasConflictingIdentity(record, source)) return;
|
|
242
|
+
const vodId = normalizeText(source.vodId) || getFirstMatchingUrlValue(source, extractTwitchVodIdFromUrl);
|
|
243
|
+
if (!vodId) return;
|
|
244
|
+
|
|
245
|
+
const vodStartAt = source.vodStartAt || source.vodPublishedAt || null;
|
|
246
|
+
const vodStartMs = parseUtcMillis(vodStartAt);
|
|
247
|
+
const recordCreatedAtMs = parseUtcMillis(record.createdAt);
|
|
248
|
+
const configuredOffsetSeconds = Number.isFinite(Number(record.timeOffsetSeconds))
|
|
249
|
+
? Math.max(0, Number(record.timeOffsetSeconds))
|
|
250
|
+
: 0;
|
|
251
|
+
const timestampOffsetSeconds = vodStartMs !== null && recordCreatedAtMs !== null &&
|
|
252
|
+
recordCreatedAtMs >= vodStartMs
|
|
253
|
+
? Math.max(0, Math.floor((recordCreatedAtMs - vodStartMs) / 1000) - configuredOffsetSeconds)
|
|
254
|
+
: null;
|
|
255
|
+
const fallbackOffsetSeconds = parseElapsedSeconds(record.liveTime || record.originalLiveTime);
|
|
256
|
+
const vodTimeOffsetSeconds = timestampOffsetSeconds ?? fallbackOffsetSeconds;
|
|
257
|
+
const sourceVideoUrl = getRecordUrls(source).find((url) => extractTwitchVodIdFromUrl(url) === vodId) ||
|
|
258
|
+
`https://www.twitch.tv/videos/${vodId}`;
|
|
259
|
+
const vodMatchedBy = Array.from(new Set([
|
|
260
|
+
...(Array.isArray(source.vodMatchedBy) ? source.vodMatchedBy : []),
|
|
261
|
+
'sibling-record'
|
|
262
|
+
]));
|
|
263
|
+
const candidate = {
|
|
264
|
+
hasVod: true,
|
|
265
|
+
videoUrl: sourceVideoUrl,
|
|
266
|
+
vodId,
|
|
267
|
+
vodPublishedAt: source.vodPublishedAt || vodStartAt,
|
|
268
|
+
vodStartAt,
|
|
269
|
+
vodTitle: source.vodTitle || '',
|
|
270
|
+
vodLookupVersion: 2,
|
|
271
|
+
vodMatchedBy
|
|
272
|
+
};
|
|
273
|
+
if (Number.isFinite(vodTimeOffsetSeconds)) candidate.vodTimeOffsetSeconds = vodTimeOffsetSeconds;
|
|
274
|
+
if (Number.isFinite(Number(source.vodMatchConfidence))) {
|
|
275
|
+
candidate.vodMatchConfidence = Number(source.vodMatchConfidence);
|
|
276
|
+
}
|
|
277
|
+
if (source.downloadUrl) {
|
|
278
|
+
candidate.downloadUrl = source.downloadUrl;
|
|
279
|
+
candidate.isDirectM3u8 = source.isDirectM3u8 || false;
|
|
280
|
+
candidate.m3u8Type = source.m3u8Type || null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const patch = {};
|
|
284
|
+
Object.entries(candidate).forEach(([field, value]) => {
|
|
285
|
+
if (!valuesEqual(record[field], value)) patch[field] = value;
|
|
286
|
+
});
|
|
287
|
+
if (Object.keys(patch).length === 0) return;
|
|
288
|
+
patches.push({
|
|
289
|
+
recordId,
|
|
290
|
+
patch,
|
|
291
|
+
operationId: buildRecordTimeLabelVodPatchOperationId(recordId, patch)
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
patches,
|
|
297
|
+
diagnostics: {
|
|
298
|
+
sourceGroupCount: Object.keys(sourceByGroupId).length,
|
|
299
|
+
patchCount: patches.length,
|
|
300
|
+
skippedConflictGroupCount: conflictGroupIds.size
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
};
|
package/src/domain.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const RECORD_TIMELABEL_DOMAIN_CAPABILITIES = Object.freeze([
|
|
2
|
+
'group-identity-v1',
|
|
3
|
+
'twitch-vod-matching-v1',
|
|
4
|
+
'legacy-import-v1',
|
|
5
|
+
'channel-folder-planning-v1'
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
RECORD_GROUP_IDENTITY_VERSION,
|
|
10
|
+
buildLegacyRecordTimeLabelGroupId,
|
|
11
|
+
buildRecordTimeLabelGroupIndex,
|
|
12
|
+
buildRecordTimeLabelGroupMetadata,
|
|
13
|
+
buildRecordTimeLabelGroupReorderOperationId,
|
|
14
|
+
buildRecordTimeLabelStandaloneGroupId,
|
|
15
|
+
canonicalizeRecordTimeLabelGroupView
|
|
16
|
+
} from './domain/group-identity.js';
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
extractTwitchVodIdFromUrl,
|
|
20
|
+
extractYouTubeVideoIdFromUrl
|
|
21
|
+
} from './domain/shared.js';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
buildKnownTwitchVodRecordPatches,
|
|
25
|
+
buildRecordTimeLabelVodPatchOperationId,
|
|
26
|
+
calculateVodTitleSimilarity,
|
|
27
|
+
normalizeVodTitleForMatch,
|
|
28
|
+
scoreTwitchVodCandidate,
|
|
29
|
+
selectBestMatchingTwitchVod
|
|
30
|
+
} from './domain/twitch-vod.js';
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
normalizeLegacyRecordTimeLabelImport,
|
|
34
|
+
remapLegacyRecordTimeLabelFolderAliases
|
|
35
|
+
} from './domain/legacy.js';
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
buildRecordTimeLabelChannelFolderPlan,
|
|
39
|
+
collectRecordTimeLabelChannelCandidates,
|
|
40
|
+
extractChannelHandleFromUrl,
|
|
41
|
+
findBestRecordTimeLabelChannelFolder,
|
|
42
|
+
getChannelMatchKey,
|
|
43
|
+
isPendingChannelFolderId,
|
|
44
|
+
isValidChannelName
|
|
45
|
+
} from './domain/channel-folder.js';
|
package/src/firestore-v2.js
CHANGED
|
@@ -21,6 +21,11 @@ export {
|
|
|
21
21
|
buildRecordTimeLabelRequestId,
|
|
22
22
|
buildOperationsFromSnapshotDiff,
|
|
23
23
|
buildStateFromFirestoreV2Documents,
|
|
24
|
+
buildFirestoreV2SnapshotRoot,
|
|
25
|
+
applyRecordTimeLabelSnapshot,
|
|
26
|
+
composeRecordTimeLabelHydratedState,
|
|
27
|
+
applyFirestoreV2ResolvedChangeBatch,
|
|
28
|
+
FIRESTORE_V2_BOOTSTRAP_REASONS,
|
|
24
29
|
estimateFirestoreV2WriteUnits,
|
|
25
30
|
createRecordTimeLabelTransportFailureResults,
|
|
26
31
|
extendFirestoreV2OperationReadPlanWithRecords,
|
package/src/index.js
CHANGED
|
@@ -31,7 +31,85 @@ const REQUIRED_FOLDERS = [
|
|
|
31
31
|
{ id: DEFAULT_FOLDER_ID, name: 'Uncategorized' }
|
|
32
32
|
];
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
import {
|
|
35
|
+
buildFirestoreV2SnapshotRoot,
|
|
36
|
+
isActiveLifecycleTombstone
|
|
37
|
+
} from './snapshot-root.js';
|
|
38
|
+
import {
|
|
39
|
+
applyFirestoreV2ResolvedChangeBatch,
|
|
40
|
+
FIRESTORE_V2_BOOTSTRAP_REASONS
|
|
41
|
+
} from './changefeed.js';
|
|
42
|
+
|
|
43
|
+
export {
|
|
44
|
+
buildFirestoreV2SnapshotRoot,
|
|
45
|
+
compareLifecycleTombstones,
|
|
46
|
+
isActiveLifecycleTombstone,
|
|
47
|
+
mergeRootLifecycleTombstone,
|
|
48
|
+
mergeUniqueSnapshotIds,
|
|
49
|
+
stripSnapshotBulkStatus
|
|
50
|
+
} from './snapshot-root.js';
|
|
51
|
+
export {
|
|
52
|
+
applyFirestoreV2ResolvedChangeBatch,
|
|
53
|
+
FIRESTORE_V2_BOOTSTRAP_REASONS
|
|
54
|
+
} from './changefeed.js';
|
|
55
|
+
|
|
56
|
+
import {
|
|
57
|
+
RECORD_GROUP_IDENTITY_VERSION,
|
|
58
|
+
RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
|
|
59
|
+
buildKnownTwitchVodRecordPatches,
|
|
60
|
+
buildLegacyRecordTimeLabelGroupId,
|
|
61
|
+
buildRecordTimeLabelChannelFolderPlan,
|
|
62
|
+
buildRecordTimeLabelGroupIndex,
|
|
63
|
+
buildRecordTimeLabelGroupMetadata,
|
|
64
|
+
buildRecordTimeLabelGroupReorderOperationId,
|
|
65
|
+
buildRecordTimeLabelStandaloneGroupId,
|
|
66
|
+
buildRecordTimeLabelVodPatchOperationId,
|
|
67
|
+
calculateVodTitleSimilarity,
|
|
68
|
+
canonicalizeRecordTimeLabelGroupView,
|
|
69
|
+
collectRecordTimeLabelChannelCandidates,
|
|
70
|
+
extractChannelHandleFromUrl,
|
|
71
|
+
extractTwitchVodIdFromUrl,
|
|
72
|
+
extractYouTubeVideoIdFromUrl,
|
|
73
|
+
findBestRecordTimeLabelChannelFolder,
|
|
74
|
+
getChannelMatchKey,
|
|
75
|
+
isPendingChannelFolderId,
|
|
76
|
+
isValidChannelName,
|
|
77
|
+
normalizeLegacyRecordTimeLabelImport,
|
|
78
|
+
normalizeVodTitleForMatch,
|
|
79
|
+
remapLegacyRecordTimeLabelFolderAliases,
|
|
80
|
+
scoreTwitchVodCandidate,
|
|
81
|
+
selectBestMatchingTwitchVod
|
|
82
|
+
} from './domain.js';
|
|
83
|
+
|
|
84
|
+
export {
|
|
85
|
+
RECORD_GROUP_IDENTITY_VERSION,
|
|
86
|
+
RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
|
|
87
|
+
buildKnownTwitchVodRecordPatches,
|
|
88
|
+
buildLegacyRecordTimeLabelGroupId,
|
|
89
|
+
buildRecordTimeLabelChannelFolderPlan,
|
|
90
|
+
buildRecordTimeLabelGroupIndex,
|
|
91
|
+
buildRecordTimeLabelGroupMetadata,
|
|
92
|
+
buildRecordTimeLabelGroupReorderOperationId,
|
|
93
|
+
buildRecordTimeLabelStandaloneGroupId,
|
|
94
|
+
buildRecordTimeLabelVodPatchOperationId,
|
|
95
|
+
calculateVodTitleSimilarity,
|
|
96
|
+
canonicalizeRecordTimeLabelGroupView,
|
|
97
|
+
collectRecordTimeLabelChannelCandidates,
|
|
98
|
+
extractChannelHandleFromUrl,
|
|
99
|
+
extractTwitchVodIdFromUrl,
|
|
100
|
+
extractYouTubeVideoIdFromUrl,
|
|
101
|
+
findBestRecordTimeLabelChannelFolder,
|
|
102
|
+
getChannelMatchKey,
|
|
103
|
+
isPendingChannelFolderId,
|
|
104
|
+
isValidChannelName,
|
|
105
|
+
normalizeLegacyRecordTimeLabelImport,
|
|
106
|
+
normalizeVodTitleForMatch,
|
|
107
|
+
remapLegacyRecordTimeLabelFolderAliases,
|
|
108
|
+
scoreTwitchVodCandidate,
|
|
109
|
+
selectBestMatchingTwitchVod
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const RECORD_TIMELABEL_CORE_VERSION = '0.6.0';
|
|
35
113
|
export const RTL_SYNC_PROTOCOL_VERSION = 2;
|
|
36
114
|
export const RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES = Object.freeze([
|
|
37
115
|
'fifo-retry-fence',
|
|
@@ -2028,6 +2106,184 @@ export const buildStateFromFirestoreV2Documents = (documents = {}, options = {})
|
|
|
2028
2106
|
return { state, pendingOps };
|
|
2029
2107
|
};
|
|
2030
2108
|
|
|
2109
|
+
const collectActiveTombstoneIds = (state = {}, kind) => {
|
|
2110
|
+
const rootField = kind === 'records' ? 'deletedRecordTombstones' : 'deletedFolderTombstones';
|
|
2111
|
+
const blocked = new Set();
|
|
2112
|
+
Object.entries(state?.[rootField] || {}).forEach(([id, tombstone]) => {
|
|
2113
|
+
if (isActiveLifecycleTombstone(tombstone)) blocked.add(id);
|
|
2114
|
+
});
|
|
2115
|
+
Object.entries(state?.lifecycleTombstones || {}).forEach(([id, tombstone]) => {
|
|
2116
|
+
if (!isActiveLifecycleTombstone(tombstone)) return;
|
|
2117
|
+
const entityId = String(tombstone.entityId || id.split(':').slice(1).join(':') || '').trim();
|
|
2118
|
+
const tombstoneKind = String(tombstone.kind || id.split(':')[0] || '').trim();
|
|
2119
|
+
if (kind === 'records' && tombstoneKind === 'record' && entityId) blocked.add(entityId);
|
|
2120
|
+
if (kind === 'folders' && tombstoneKind === 'folder' && entityId) blocked.add(entityId);
|
|
2121
|
+
});
|
|
2122
|
+
return blocked;
|
|
2123
|
+
};
|
|
2124
|
+
|
|
2125
|
+
const mergeLifecycleTombstoneDocuments = (current = {}, incoming = {}) => {
|
|
2126
|
+
const merged = {...(current || {})};
|
|
2127
|
+
Object.entries(incoming || {}).forEach(([id, tombstone]) => {
|
|
2128
|
+
const existing = merged[id];
|
|
2129
|
+
if (!existing) {
|
|
2130
|
+
merged[id] = tombstone;
|
|
2131
|
+
return;
|
|
2132
|
+
}
|
|
2133
|
+
const currentGeneration = Number(existing.lifecycleGeneration || 0);
|
|
2134
|
+
const nextGeneration = Number(tombstone?.lifecycleGeneration || 0);
|
|
2135
|
+
const currentDeletedAt = Number(existing.deletedAt || 0);
|
|
2136
|
+
const nextDeletedAt = Number(tombstone?.deletedAt || 0);
|
|
2137
|
+
if (nextGeneration > currentGeneration ||
|
|
2138
|
+
(nextGeneration === currentGeneration && nextDeletedAt >= currentDeletedAt)) {
|
|
2139
|
+
merged[id] = tombstone;
|
|
2140
|
+
}
|
|
2141
|
+
});
|
|
2142
|
+
return merged;
|
|
2143
|
+
};
|
|
2144
|
+
|
|
2145
|
+
const collectDocumentIdChanges = (currentDocs = {}, nextDocs = {}) => {
|
|
2146
|
+
const currentIds = new Set(Object.keys(currentDocs || {}));
|
|
2147
|
+
const nextIds = new Set(Object.keys(nextDocs || {}));
|
|
2148
|
+
return {
|
|
2149
|
+
upsertIds: [...nextIds].filter((id) => (
|
|
2150
|
+
JSON.stringify(currentDocs?.[id] || null) !== JSON.stringify(nextDocs?.[id] || null)
|
|
2151
|
+
)).sort(),
|
|
2152
|
+
deleteIds: [...currentIds].filter((id) => !nextIds.has(id)).sort()
|
|
2153
|
+
};
|
|
2154
|
+
};
|
|
2155
|
+
|
|
2156
|
+
export const applyRecordTimeLabelSnapshot = (
|
|
2157
|
+
currentState = {},
|
|
2158
|
+
incomingState = {},
|
|
2159
|
+
mode = 'snapshot.replace'
|
|
2160
|
+
) => {
|
|
2161
|
+
const snapshotMode = mode === 'snapshot.merge' ? 'snapshot.merge' : 'snapshot.replace';
|
|
2162
|
+
const isMerge = snapshotMode === 'snapshot.merge';
|
|
2163
|
+
const current = normalizeState(currentState || {});
|
|
2164
|
+
const incoming = normalizeState(incomingState || {});
|
|
2165
|
+
const currentDocs = buildFirestoreV2DocumentsFromState(current);
|
|
2166
|
+
const incomingDocs = buildFirestoreV2DocumentsFromState(incoming);
|
|
2167
|
+
const blockedRecordIds = collectActiveTombstoneIds(current, 'records');
|
|
2168
|
+
const blockedFolderIds = collectActiveTombstoneIds(current, 'folders');
|
|
2169
|
+
|
|
2170
|
+
const mergedRecordDocs = {...(currentDocs.records || {})};
|
|
2171
|
+
Object.entries(incomingDocs.records || {}).forEach(([id, data]) => {
|
|
2172
|
+
if (blockedRecordIds.has(id)) return;
|
|
2173
|
+
mergedRecordDocs[id] = data;
|
|
2174
|
+
});
|
|
2175
|
+
const recordDocs = isMerge
|
|
2176
|
+
? mergedRecordDocs
|
|
2177
|
+
: Object.fromEntries(Object.entries(mergedRecordDocs).filter(([id]) => (
|
|
2178
|
+
incomingDocs.records?.[id] && !blockedRecordIds.has(id)
|
|
2179
|
+
)));
|
|
2180
|
+
|
|
2181
|
+
const mergedFolderDocs = {...(currentDocs.folders || {})};
|
|
2182
|
+
Object.entries(incomingDocs.folders || {}).forEach(([id, data]) => {
|
|
2183
|
+
if (blockedFolderIds.has(id)) return;
|
|
2184
|
+
mergedFolderDocs[id] = data;
|
|
2185
|
+
});
|
|
2186
|
+
const folderDocs = isMerge
|
|
2187
|
+
? mergedFolderDocs
|
|
2188
|
+
: Object.fromEntries(Object.entries(mergedFolderDocs).filter(([id]) => (
|
|
2189
|
+
incomingDocs.folders?.[id] && !blockedFolderIds.has(id)
|
|
2190
|
+
)));
|
|
2191
|
+
|
|
2192
|
+
const nextRoot = buildFirestoreV2SnapshotRoot({
|
|
2193
|
+
currentRoot: currentDocs.root || {},
|
|
2194
|
+
incomingRoot: incomingDocs.root || {},
|
|
2195
|
+
mode: snapshotMode
|
|
2196
|
+
});
|
|
2197
|
+
const {state} = buildStateFromFirestoreV2Documents({
|
|
2198
|
+
root: {id: 'main', ...nextRoot},
|
|
2199
|
+
records: recordDocs,
|
|
2200
|
+
folders: folderDocs,
|
|
2201
|
+
trash: {...(currentDocs.trash || {})},
|
|
2202
|
+
ops: {},
|
|
2203
|
+
lifecycleTombstones: mergeLifecycleTombstoneDocuments(
|
|
2204
|
+
currentDocs.lifecycleTombstones,
|
|
2205
|
+
incomingDocs.lifecycleTombstones
|
|
2206
|
+
)
|
|
2207
|
+
});
|
|
2208
|
+
const nextDocs = buildFirestoreV2DocumentsFromState(state);
|
|
2209
|
+
const recordChanges = collectDocumentIdChanges(currentDocs.records, nextDocs.records);
|
|
2210
|
+
const folderChanges = collectDocumentIdChanges(currentDocs.folders, nextDocs.folders);
|
|
2211
|
+
return {
|
|
2212
|
+
state: {...state, pendingOps: []},
|
|
2213
|
+
documentChanges: {
|
|
2214
|
+
records: recordChanges,
|
|
2215
|
+
folders: folderChanges,
|
|
2216
|
+
trash: {upsertIds: [], deleteIds: []},
|
|
2217
|
+
root: true
|
|
2218
|
+
},
|
|
2219
|
+
diagnostics: {
|
|
2220
|
+
mode: snapshotMode,
|
|
2221
|
+
blockedRecordCount: blockedRecordIds.size,
|
|
2222
|
+
blockedFolderCount: blockedFolderIds.size,
|
|
2223
|
+
recordUpsertCount: recordChanges.upsertIds.length,
|
|
2224
|
+
recordDeleteCount: recordChanges.deleteIds.length,
|
|
2225
|
+
folderUpsertCount: folderChanges.upsertIds.length,
|
|
2226
|
+
folderDeleteCount: folderChanges.deleteIds.length,
|
|
2227
|
+
trashPreserved: true
|
|
2228
|
+
}
|
|
2229
|
+
};
|
|
2230
|
+
};
|
|
2231
|
+
|
|
2232
|
+
export const composeRecordTimeLabelHydratedState = ({
|
|
2233
|
+
remote = {},
|
|
2234
|
+
pendingOps = [],
|
|
2235
|
+
importJobs = [],
|
|
2236
|
+
localNavigation = null
|
|
2237
|
+
} = {}) => {
|
|
2238
|
+
const jobs = (Array.isArray(importJobs) ? importJobs : [])
|
|
2239
|
+
.filter((job) => job && typeof job === 'object')
|
|
2240
|
+
.sort((left, right) => Number(left?.createdAt || 0) - Number(right?.createdAt || 0));
|
|
2241
|
+
const coveredOperationIds = new Set();
|
|
2242
|
+
let state = normalizeState(remote || {});
|
|
2243
|
+
jobs.forEach((job) => {
|
|
2244
|
+
state = applyRecordTimeLabelSnapshot(
|
|
2245
|
+
state,
|
|
2246
|
+
job.state || {},
|
|
2247
|
+
job.mode === 'snapshot.merge' ? 'snapshot.merge' : 'snapshot.replace'
|
|
2248
|
+
).state;
|
|
2249
|
+
(Array.isArray(job.includedOperationIds) ? job.includedOperationIds : []).forEach((id) => {
|
|
2250
|
+
if (id) coveredOperationIds.add(id);
|
|
2251
|
+
});
|
|
2252
|
+
});
|
|
2253
|
+
(Array.isArray(pendingOps) ? pendingOps : [])
|
|
2254
|
+
.filter((operation) => operation?.id && !coveredOperationIds.has(operation.id))
|
|
2255
|
+
.forEach((operation) => {
|
|
2256
|
+
state = applyRecordTimeLabelOperation(state, operation);
|
|
2257
|
+
});
|
|
2258
|
+
|
|
2259
|
+
const navigation = localNavigation && typeof localNavigation === 'object' ? localNavigation : {};
|
|
2260
|
+
const nextSettings = {...(state.settings || {})};
|
|
2261
|
+
const lastActiveFolderId = navigation.lastActiveFolderId;
|
|
2262
|
+
if (lastActiveFolderId) {
|
|
2263
|
+
const folderExists = (Array.isArray(state.folders) ? state.folders : [])
|
|
2264
|
+
.some((folder) => folder?.id === lastActiveFolderId);
|
|
2265
|
+
if (folderExists) nextSettings.lastActiveFolderId = lastActiveFolderId;
|
|
2266
|
+
else delete nextSettings.lastActiveFolderId;
|
|
2267
|
+
} else {
|
|
2268
|
+
delete nextSettings.lastActiveFolderId;
|
|
2269
|
+
}
|
|
2270
|
+
const localExpandedGroups = Array.isArray(navigation.expandedGroups) ? navigation.expandedGroups : [];
|
|
2271
|
+
const expandedGroups = Array.from(new Set([
|
|
2272
|
+
...(Array.isArray(state.expandedGroups) ? state.expandedGroups : []),
|
|
2273
|
+
...localExpandedGroups
|
|
2274
|
+
]));
|
|
2275
|
+
const remoteRevision = Number(remote?.revision);
|
|
2276
|
+
return {
|
|
2277
|
+
state: {
|
|
2278
|
+
...state,
|
|
2279
|
+
settings: nextSettings,
|
|
2280
|
+
expandedGroups,
|
|
2281
|
+
...(Number.isFinite(remoteRevision) ? {revision: remoteRevision} : {})
|
|
2282
|
+
},
|
|
2283
|
+
coveredOperationIds: Array.from(coveredOperationIds)
|
|
2284
|
+
};
|
|
2285
|
+
};
|
|
2286
|
+
|
|
2031
2287
|
const mergeOrderArrays = (normalizer, ...orders) => {
|
|
2032
2288
|
const seen = new Set();
|
|
2033
2289
|
const result = [];
|
|
@@ -5289,6 +5545,8 @@ export const buildOperationsFromSnapshotDiff = ({
|
|
|
5289
5545
|
|
|
5290
5546
|
export default {
|
|
5291
5547
|
RECORD_TIMELABEL_CORE_VERSION,
|
|
5548
|
+
RECORD_GROUP_IDENTITY_VERSION,
|
|
5549
|
+
RECORD_TIMELABEL_DOMAIN_CAPABILITIES,
|
|
5292
5550
|
RECORD_TIMELABEL_DURABLE_ENGINE_CAPABILITIES,
|
|
5293
5551
|
RECORD_TIMELABEL_CAPABILITY_LIFECYCLE_GENERATION_FENCE,
|
|
5294
5552
|
RECORD_TIMELABEL_CAPABILITY_OPERATION_CONFLICT_QUARANTINE,
|
|
@@ -5330,6 +5588,11 @@ export default {
|
|
|
5330
5588
|
buildFirestoreV2DocumentsFromState,
|
|
5331
5589
|
buildFirestoreV2DocumentChangeSet,
|
|
5332
5590
|
buildStateFromFirestoreV2Documents,
|
|
5591
|
+
buildFirestoreV2SnapshotRoot,
|
|
5592
|
+
applyRecordTimeLabelSnapshot,
|
|
5593
|
+
composeRecordTimeLabelHydratedState,
|
|
5594
|
+
applyFirestoreV2ResolvedChangeBatch,
|
|
5595
|
+
FIRESTORE_V2_BOOTSTRAP_REASONS,
|
|
5333
5596
|
validateRecordTimeLabelOperationBatch,
|
|
5334
5597
|
buildFirestoreV2OperationReadPlan,
|
|
5335
5598
|
extendFirestoreV2OperationReadPlanWithRecords,
|