@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 CHANGED
@@ -17,10 +17,10 @@ During local development an app can consume a sibling checkout with:
17
17
  "@recordtimelabel/core": "file:../recordtimelabel-core"
18
18
  ```
19
19
 
20
- For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The current strict durable transport contract is prepared in package version `0.4.6` (publish it before updating consumers):
20
+ For release builds, consume a fixed npm package, git tag, or private registry version so builds do not depend on a sibling folder path. The current published artifact is `0.6.0`:
21
21
 
22
22
  ```json
23
- "@recordtimelabel/core": "0.4.6"
23
+ "@recordtimelabel/core": "0.6.0"
24
24
  ```
25
25
 
26
26
  If this checkout's `package.json` is ahead of the published version, publish the new package before updating consumers to that version.
@@ -74,12 +74,21 @@ If this checkout's `package.json` is ahead of the published version, publish the
74
74
  - `OPERATION_TYPES`
75
75
  - `getActiveTrashEntries(entries, now)`
76
76
  - `RTL_TRASH_RETENTION_MS`
77
+ - `buildFirestoreV2SnapshotRoot({ currentRoot, incomingRoot, mode })`
78
+ - `applyRecordTimeLabelSnapshot(currentState, incomingState, mode)`
79
+ - `composeRecordTimeLabelHydratedState({ remote, pendingOps, importJobs, localNavigation })`
80
+ - `applyFirestoreV2ResolvedChangeBatch({ cache, changes, resolvedDocuments, targetRevision })`
81
+ - `FIRESTORE_V2_BOOTSTRAP_REASONS`
77
82
 
78
- The package exports four intentional entrypoints. The root (`@recordtimelabel/core`)
83
+ The package exports five intentional entrypoints. The root (`@recordtimelabel/core`)
79
84
  keeps the complete backwards-compatible surface, `/protocol` contains only shared
80
85
  acknowledgement protocol helpers, `/firestore-v2` contains platform-neutral document
81
- and operation planners, and `/compat` contains the legacy `createSyncEngine` and
82
- `createRecordTimeLabelController` APIs.
86
+ and operation planners, `/domain` contains group identity, Twitch VOD matching,
87
+ legacy import, and channel-folder planning, and `/compat` contains the legacy
88
+ `createSyncEngine` and `createRecordTimeLabelController` APIs.
89
+
90
+ `RECORD_TIMELABEL_DOMAIN_CAPABILITIES` is a local release contract. It is not a
91
+ gateway protocol capability.
83
92
 
84
93
  ### Durable sync engine
85
94
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.7",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "Shared RecordTimeLabel data model, merge logic, operations, and sync engine.",
6
6
  "main": "./src/index.js",
@@ -8,7 +8,8 @@
8
8
  ".": "./src/index.js",
9
9
  "./protocol": "./src/protocol.js",
10
10
  "./firestore-v2": "./src/firestore-v2.js",
11
- "./compat": "./src/compat.js"
11
+ "./compat": "./src/compat.js",
12
+ "./domain": "./src/domain.js"
12
13
  },
13
14
  "scripts": {
14
15
  "test": "node --test tests/*.test.mjs"
@@ -0,0 +1,195 @@
1
+ export const FIRESTORE_V2_BOOTSTRAP_REASONS = Object.freeze({
2
+ REVISION_GAP: 'revision-gap',
3
+ REVISION_DUPLICATE: 'revision-duplicate',
4
+ REVISION_OVERSHOOT: 'revision-overshoot',
5
+ CACHE_AHEAD: 'cache-ahead',
6
+ CHANGED_DOCUMENT_MISSING: 'changed-document-missing',
7
+ ROOT_DOCUMENT_MISSING: 'root-document-missing',
8
+ INVALID_CHANGE_CONFLICT: 'invalid-change-conflict'
9
+ });
10
+
11
+ const toId = (value) => String(value || '').trim();
12
+ const toIdList = (value) => (Array.isArray(value) ? value : []).map(toId).filter(Boolean);
13
+
14
+ const cloneDocuments = (documents = {}) => ({
15
+ root: documents.root ? {...documents.root} : documents.root,
16
+ records: {...(documents.records || {})},
17
+ folders: {...(documents.folders || {})},
18
+ trash: {...(documents.trash || {})},
19
+ lifecycleTombstones: {...(documents.lifecycleTombstones || {})},
20
+ ops: {...(documents.ops || {})}
21
+ });
22
+
23
+ const fail = (reason, inputCache) => ({
24
+ ok: false,
25
+ bootstrapRequired: true,
26
+ reason,
27
+ cache: {
28
+ revision: Number(inputCache?.revision || 0),
29
+ documents: cloneDocuments(inputCache?.documents || {})
30
+ },
31
+ diagnostics: {reason, appliedCount: 0}
32
+ });
33
+
34
+ const hasConflict = (changedIds, deletedIds) => {
35
+ const deleted = new Set(deletedIds);
36
+ return changedIds.some((id) => deleted.has(id));
37
+ };
38
+
39
+ const resolvedDocument = (resolvedDocuments, kind, id) => {
40
+ const bucket = resolvedDocuments?.[kind];
41
+ if (!bucket || !Object.prototype.hasOwnProperty.call(bucket, id)) return undefined;
42
+ return bucket[id];
43
+ };
44
+
45
+ export const applyFirestoreV2ResolvedChangeBatch = ({
46
+ cache = {},
47
+ changes = [],
48
+ resolvedDocuments = {},
49
+ targetRevision = null
50
+ } = {}) => {
51
+ const inputRevision = Number(cache?.revision || 0);
52
+ const inputDocuments = cache?.documents || {};
53
+ const inputCache = {revision: inputRevision, documents: inputDocuments};
54
+ const expectedTarget = targetRevision == null ? null : Number(targetRevision);
55
+ if (!Number.isFinite(inputRevision) || inputRevision < 0) {
56
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP, inputCache);
57
+ }
58
+
59
+ const batch = Array.isArray(changes) ? changes : [];
60
+ if (batch.length === 0) {
61
+ if (expectedTarget != null && Number.isFinite(expectedTarget) && expectedTarget < inputRevision) {
62
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CACHE_AHEAD, inputCache);
63
+ }
64
+ return {
65
+ ok: true,
66
+ bootstrapRequired: false,
67
+ reason: null,
68
+ cache: {revision: inputRevision, documents: cloneDocuments(inputDocuments)},
69
+ diagnostics: {reason: null, appliedCount: 0}
70
+ };
71
+ }
72
+
73
+ const firstRevision = Number(batch[0]?.revision || 0);
74
+ if (expectedTarget != null && Number.isFinite(expectedTarget) && inputRevision > expectedTarget) {
75
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CACHE_AHEAD, inputCache);
76
+ }
77
+ if (firstRevision <= inputRevision) {
78
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_DUPLICATE, inputCache);
79
+ }
80
+ if (expectedTarget != null && Number.isFinite(expectedTarget) && firstRevision > expectedTarget) {
81
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_OVERSHOOT, inputCache);
82
+ }
83
+ if (firstRevision > inputRevision + 1) {
84
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP, inputCache);
85
+ }
86
+
87
+ const candidate = {
88
+ revision: inputRevision,
89
+ documents: cloneDocuments(inputDocuments)
90
+ };
91
+ let rootChanged = false;
92
+
93
+ for (let index = 0; index < batch.length; index += 1) {
94
+ const change = batch[index] || {};
95
+ const revision = Number(change.revision || 0);
96
+ if (revision !== candidate.revision + 1) {
97
+ const reason = revision <= candidate.revision
98
+ ? FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_DUPLICATE
99
+ : FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_GAP;
100
+ return fail(reason, inputCache);
101
+ }
102
+ if (expectedTarget != null && Number.isFinite(expectedTarget) && revision > expectedTarget) {
103
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.REVISION_OVERSHOOT, inputCache);
104
+ }
105
+
106
+ const changedRecords = toIdList(change.changedRecordIds);
107
+ const deletedRecords = toIdList(change.deletedRecordIds);
108
+ const changedFolders = toIdList(change.changedFolderIds);
109
+ const deletedFolders = toIdList(change.deletedFolderIds);
110
+ const changedTrash = toIdList(change.changedTrashEntryIds);
111
+ const deletedTrash = toIdList(change.deletedTrashEntryIds);
112
+ const lifecycleUpserts = Array.isArray(change.lifecycleTombstoneUpserts)
113
+ ? change.lifecycleTombstoneUpserts
114
+ : [];
115
+ const lifecycleDeletes = toIdList(change.deletedLifecycleTombstoneIds);
116
+ const lifecycleUpsertIds = lifecycleUpserts.map((entry) => toId(entry?.id)).filter(Boolean);
117
+
118
+ if (
119
+ hasConflict(changedRecords, deletedRecords) ||
120
+ hasConflict(changedFolders, deletedFolders) ||
121
+ hasConflict(changedTrash, deletedTrash) ||
122
+ hasConflict(lifecycleUpsertIds, lifecycleDeletes)
123
+ ) {
124
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.INVALID_CHANGE_CONFLICT, inputCache);
125
+ }
126
+
127
+ const missingChanged = [
128
+ ...changedRecords.map((id) => ['records', id]),
129
+ ...changedFolders.map((id) => ['folders', id]),
130
+ ...changedTrash.map((id) => ['trash', id])
131
+ ].some(([kind, id]) => {
132
+ const document = resolvedDocument(resolvedDocuments, kind, id);
133
+ return document == null;
134
+ });
135
+ if (missingChanged) {
136
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.CHANGED_DOCUMENT_MISSING, inputCache);
137
+ }
138
+
139
+ if (change.rootChanged === true) {
140
+ const rootDocument = resolvedDocuments.root;
141
+ const rootRevision = Number(rootDocument?.revision || 0);
142
+ if (!rootDocument || !Number.isFinite(rootRevision) || rootRevision < revision) {
143
+ return fail(FIRESTORE_V2_BOOTSTRAP_REASONS.ROOT_DOCUMENT_MISSING, inputCache);
144
+ }
145
+ rootChanged = true;
146
+ }
147
+
148
+ changedRecords.forEach((id) => {
149
+ const document = resolvedDocument(resolvedDocuments, 'records', id);
150
+ candidate.documents.records[id] = {id, ...document};
151
+ });
152
+ changedFolders.forEach((id) => {
153
+ const document = resolvedDocument(resolvedDocuments, 'folders', id);
154
+ candidate.documents.folders[id] = {id, ...document};
155
+ });
156
+ changedTrash.forEach((id) => {
157
+ const document = resolvedDocument(resolvedDocuments, 'trash', id);
158
+ candidate.documents.trash[id] = {id, ...document};
159
+ });
160
+ deletedRecords.forEach((id) => delete candidate.documents.records[id]);
161
+ deletedFolders.forEach((id) => delete candidate.documents.folders[id]);
162
+ deletedTrash.forEach((id) => delete candidate.documents.trash[id]);
163
+ lifecycleUpserts.forEach((entry) => {
164
+ const id = toId(entry?.id);
165
+ if (id) candidate.documents.lifecycleTombstones[id] = {...entry, id};
166
+ });
167
+ lifecycleDeletes.forEach((id) => {
168
+ delete candidate.documents.lifecycleTombstones[id];
169
+ });
170
+ candidate.revision = revision;
171
+ }
172
+
173
+ if (rootChanged) {
174
+ const rootDocument = resolvedDocuments.root;
175
+ candidate.documents.root = {id: 'main', ...rootDocument};
176
+ } else if (candidate.documents.root) {
177
+ candidate.documents.root = {
178
+ ...candidate.documents.root,
179
+ id: 'main',
180
+ revision: candidate.revision
181
+ };
182
+ }
183
+
184
+ return {
185
+ ok: true,
186
+ bootstrapRequired: false,
187
+ reason: null,
188
+ cache: candidate,
189
+ diagnostics: {
190
+ reason: null,
191
+ appliedCount: batch.length,
192
+ revision: candidate.revision
193
+ }
194
+ };
195
+ };
@@ -0,0 +1,377 @@
1
+ import {
2
+ SYSTEM_FOLDER_IDS,
3
+ normalizeId,
4
+ normalizeText,
5
+ toArray
6
+ } from './shared.js';
7
+
8
+ const FUZZY_MATCH_THRESHOLD = 0.88;
9
+ const INVALID_CHANNEL_NAMES = new Set([
10
+ '',
11
+ 'n/a',
12
+ 'na',
13
+ 'unknown',
14
+ 'unknown channel',
15
+ 'unknown streamer',
16
+ 'fallback',
17
+ '未知頻道',
18
+ '未知频道'
19
+ ]);
20
+ const RESERVED_TWITCH_PATHS = new Set([
21
+ 'about', 'clips', 'directory', 'downloads', 'followers', 'following', 'jobs',
22
+ 'p', 'profile', 'schedule', 'settings', 'store', 'subscriptions', 'videos'
23
+ ]);
24
+ const CHANNEL_STOP_WORDS = new Set([
25
+ 'ch', 'channel', 'channels', 'official', 'stream', 'streamer', 'streams',
26
+ 'twitch', 'video', 'videos', 'vod', 'vods', 'youtube', 'yt'
27
+ ]);
28
+ const EVIDENCE_SOURCE_RANK = {
29
+ folderName: 0,
30
+ folderAlias: 1,
31
+ folderAliasName: 1,
32
+ folderAliasHandle: 1,
33
+ folderAliasUrl: 1,
34
+ record: 2
35
+ };
36
+
37
+ const toText = (value) => (typeof value === 'string' ? value.trim() : '');
38
+
39
+ export const normalizeChannelPlatform = (value) => {
40
+ const platform = toText(value).toLowerCase();
41
+ if (platform.includes('youtube') || platform === 'yt') return 'youtube';
42
+ if (platform.includes('twitch')) return 'twitch';
43
+ return platform || '';
44
+ };
45
+
46
+ export const isValidChannelName = (value) => {
47
+ const text = toText(value);
48
+ if (!text) return false;
49
+ const normalized = text.normalize('NFKC').toLowerCase();
50
+ if (INVALID_CHANNEL_NAMES.has(normalized)) return false;
51
+ return !/unknown|fallback/i.test(normalized);
52
+ };
53
+
54
+ const splitChannelTokens = (value, {removeStopWords = true} = {}) => {
55
+ const normalized = toText(value)
56
+ .normalize('NFKC')
57
+ .replace(/^@+/, '')
58
+ .replace(/['"`]/g, '')
59
+ .replace(/[()[\]{}]/g, ' ')
60
+ .toLowerCase();
61
+ return normalized
62
+ .split(/[^\p{L}\p{N}]+/u)
63
+ .map((token) => token.trim())
64
+ .filter((token) => token && (!removeStopWords || !CHANNEL_STOP_WORDS.has(token)));
65
+ };
66
+
67
+ export const getChannelMatchKey = (value) => splitChannelTokens(value).join('');
68
+
69
+ export const getStrictChannelMatchKey = (value) => (
70
+ splitChannelTokens(value, {removeStopWords: false}).join('')
71
+ );
72
+
73
+ const getCandidateKey = (value) => {
74
+ const key = getChannelMatchKey(value);
75
+ if (key) return key;
76
+ return toText(value)
77
+ .normalize('NFKC')
78
+ .replace(/^@+/, '')
79
+ .replace(/[^\p{L}\p{N}]+/gu, '')
80
+ .toLowerCase();
81
+ };
82
+
83
+ const evidenceSourceRank = (source) => {
84
+ const text = String(source || '');
85
+ if (Object.hasOwn(EVIDENCE_SOURCE_RANK, text)) return EVIDENCE_SOURCE_RANK[text];
86
+ if (text.startsWith('record:')) return EVIDENCE_SOURCE_RANK.record;
87
+ return 3;
88
+ };
89
+
90
+ const addCandidate = (candidates, value, source) => {
91
+ if (!isValidChannelName(value)) return;
92
+ const text = toText(value);
93
+ const key = getCandidateKey(text);
94
+ if (!key) return;
95
+ const dedupeKey = `${source}:${key}`;
96
+ if (candidates.some((candidate) => candidate.dedupeKey === dedupeKey)) return;
97
+ candidates.push({
98
+ value: text,
99
+ key,
100
+ strictKey: getStrictChannelMatchKey(text),
101
+ source,
102
+ dedupeKey
103
+ });
104
+ };
105
+
106
+ const parseUrl = (value) => {
107
+ const text = toText(value);
108
+ if (!text) return null;
109
+ try {
110
+ return new URL(text);
111
+ } catch (_) {
112
+ try {
113
+ return new URL(`https://${text}`);
114
+ } catch (__) {
115
+ return null;
116
+ }
117
+ }
118
+ };
119
+
120
+ export const extractChannelHandleFromUrl = (value) => {
121
+ const url = parseUrl(value);
122
+ if (!url) return '';
123
+ const host = url.hostname.replace(/^www\./, '').toLowerCase();
124
+ const parts = url.pathname.split('/').filter(Boolean);
125
+ if (parts.length === 0) return '';
126
+ if (host.endsWith('twitch.tv')) {
127
+ const first = parts[0].toLowerCase();
128
+ if (RESERVED_TWITCH_PATHS.has(first)) return '';
129
+ return parts[0];
130
+ }
131
+ if (host.endsWith('youtube.com')) {
132
+ if (parts[0]?.startsWith('@')) return parts[0];
133
+ if (['c', 'user'].includes(parts[0]) && parts[1]) return parts[1];
134
+ return '';
135
+ }
136
+ return '';
137
+ };
138
+
139
+ export const collectRecordTimeLabelChannelCandidates = (record = {}) => {
140
+ const candidates = [];
141
+ addCandidate(candidates, record.streamerName, 'streamerName');
142
+ addCandidate(candidates, record.channelName, 'channelName');
143
+ addCandidate(candidates, record.displayName, 'displayName');
144
+ addCandidate(candidates, record.channelHandle, 'channelHandle');
145
+ addCandidate(candidates, record.channelUsername, 'channelUsername');
146
+ addCandidate(candidates, record.username, 'username');
147
+ addCandidate(candidates, record.userName, 'userName');
148
+ [
149
+ record.channelUrl,
150
+ record.channelBaseUrl,
151
+ record.videoUrl,
152
+ record.originalVideoUrl,
153
+ record.jumpUrl
154
+ ].forEach((url) => {
155
+ addCandidate(candidates, extractChannelHandleFromUrl(url), 'urlHandle');
156
+ });
157
+ return candidates.map(({dedupeKey: _dedupeKey, ...candidate}) => candidate);
158
+ };
159
+
160
+ const collectFolderChannelCandidates = (folder = {}, folderRecords = []) => {
161
+ const candidates = [];
162
+ addCandidate(candidates, folder.name, 'folderName');
163
+ if (Array.isArray(folder.aliases)) {
164
+ folder.aliases.forEach((alias) => {
165
+ if (typeof alias === 'string') addCandidate(candidates, alias, 'folderAlias');
166
+ else if (alias && typeof alias === 'object') {
167
+ addCandidate(candidates, alias.name, 'folderAliasName');
168
+ addCandidate(candidates, alias.handle, 'folderAliasHandle');
169
+ addCandidate(candidates, alias.url, 'folderAliasUrl');
170
+ }
171
+ });
172
+ }
173
+ folderRecords.forEach((record) => {
174
+ collectRecordTimeLabelChannelCandidates(record).forEach((candidate) => {
175
+ addCandidate(candidates, candidate.value, `record:${candidate.source}`);
176
+ });
177
+ });
178
+ return candidates;
179
+ };
180
+
181
+ const collectFolderPlatforms = (folderRecords = []) => {
182
+ const platforms = new Set();
183
+ folderRecords.forEach((record) => {
184
+ const platform = normalizeChannelPlatform(record?.platform);
185
+ if (platform) platforms.add(platform);
186
+ });
187
+ return platforms;
188
+ };
189
+
190
+ const jaroWinkler = (first, second) => {
191
+ if (first === second) return 1;
192
+ if (!first || !second) return 0;
193
+ const firstLength = first.length;
194
+ const secondLength = second.length;
195
+ const matchDistance = Math.floor(Math.max(firstLength, secondLength) / 2) - 1;
196
+ const firstMatches = new Array(firstLength).fill(false);
197
+ const secondMatches = new Array(secondLength).fill(false);
198
+ let matches = 0;
199
+ for (let i = 0; i < firstLength; i += 1) {
200
+ const start = Math.max(0, i - matchDistance);
201
+ const end = Math.min(i + matchDistance + 1, secondLength);
202
+ for (let j = start; j < end; j += 1) {
203
+ if (secondMatches[j] || first[i] !== second[j]) continue;
204
+ firstMatches[i] = true;
205
+ secondMatches[j] = true;
206
+ matches += 1;
207
+ break;
208
+ }
209
+ }
210
+ if (matches === 0) return 0;
211
+ let transpositions = 0;
212
+ let secondIndex = 0;
213
+ for (let i = 0; i < firstLength; i += 1) {
214
+ if (!firstMatches[i]) continue;
215
+ while (!secondMatches[secondIndex]) secondIndex += 1;
216
+ if (first[i] !== second[secondIndex]) transpositions += 1;
217
+ secondIndex += 1;
218
+ }
219
+ const jaro = (
220
+ (matches / firstLength) +
221
+ (matches / secondLength) +
222
+ ((matches - (transpositions / 2)) / matches)
223
+ ) / 3;
224
+ let prefix = 0;
225
+ const maxPrefix = Math.min(4, firstLength, secondLength);
226
+ while (prefix < maxPrefix && first[prefix] === second[prefix]) prefix += 1;
227
+ return jaro + (prefix * 0.1 * (1 - jaro));
228
+ };
229
+
230
+ const compareCandidates = (incoming, target) => {
231
+ if (!incoming?.key || !target?.key) return {score: 0, reason: 'empty'};
232
+ if (incoming.strictKey && incoming.strictKey === target.strictKey) {
233
+ return {score: 1, reason: 'exact-key'};
234
+ }
235
+ if (incoming.key === target.key) return {score: 0.94, reason: 'normalized-key'};
236
+ const shorter = incoming.key.length <= target.key.length ? incoming.key : target.key;
237
+ const longer = incoming.key.length > target.key.length ? incoming.key : target.key;
238
+ if (shorter.length >= 6 && longer.includes(shorter) && (shorter.length / longer.length) >= 0.65) {
239
+ return {score: 0.93, reason: 'contained-key'};
240
+ }
241
+ return {score: jaroWinkler(incoming.key, target.key), reason: 'jaro-winkler'};
242
+ };
243
+
244
+ const folderAllowsFuzzyMatch = (incomingPlatform, folderPlatforms) => {
245
+ if (!incomingPlatform || folderPlatforms.size === 0) return false;
246
+ return [...folderPlatforms].some((platform) => platform !== incomingPlatform);
247
+ };
248
+
249
+ const isBetterMatch = (candidate, current, folderOrderIndex) => {
250
+ if (!current) return true;
251
+ if (candidate.score !== current.score) return candidate.score > current.score;
252
+ const candidateEvidence = evidenceSourceRank(candidate.evidenceSource);
253
+ const currentEvidence = evidenceSourceRank(current.evidenceSource);
254
+ if (candidateEvidence !== currentEvidence) return candidateEvidence < currentEvidence;
255
+ const candidateOrder = folderOrderIndex.get(candidate.folderId) ?? Number.POSITIVE_INFINITY;
256
+ const currentOrder = folderOrderIndex.get(current.folderId) ?? Number.POSITIVE_INFINITY;
257
+ if (candidateOrder !== currentOrder) return candidateOrder < currentOrder;
258
+ return String(candidate.folderId).localeCompare(String(current.folderId)) < 0;
259
+ };
260
+
261
+ export const findBestRecordTimeLabelChannelFolder = ({
262
+ record,
263
+ folders = [],
264
+ recordsByFolder = {},
265
+ folderOrder = [],
266
+ excludedFolderIds = SYSTEM_FOLDER_IDS,
267
+ fuzzyThreshold = FUZZY_MATCH_THRESHOLD
268
+ } = {}) => {
269
+ const incomingCandidates = collectRecordTimeLabelChannelCandidates(record);
270
+ if (incomingCandidates.length === 0) return null;
271
+
272
+ const incomingPlatform = normalizeChannelPlatform(record?.platform);
273
+ const excludedIds = excludedFolderIds instanceof Set
274
+ ? excludedFolderIds
275
+ : new Set(excludedFolderIds || []);
276
+ const folderOrderIndex = new Map(
277
+ (Array.isArray(folderOrder) ? folderOrder : []).map((folderId, index) => [folderId, index])
278
+ );
279
+ (folders || []).forEach((folder, index) => {
280
+ if (folder?.id && !folderOrderIndex.has(folder.id)) folderOrderIndex.set(folder.id, 100000 + index);
281
+ });
282
+
283
+ let bestMatch = null;
284
+ (folders || []).forEach((folder) => {
285
+ if (!folder?.id || excludedIds.has(folder.id) || SYSTEM_FOLDER_IDS.has(folder.id)) return;
286
+ const folderRecords = Array.isArray(recordsByFolder?.[folder.id]) ? recordsByFolder[folder.id] : [];
287
+ const folderPlatforms = collectFolderPlatforms(folderRecords);
288
+ const allowFuzzy = folderAllowsFuzzyMatch(incomingPlatform, folderPlatforms);
289
+ const targetCandidates = collectFolderChannelCandidates(folder, folderRecords);
290
+
291
+ incomingCandidates.forEach((incomingCandidate) => {
292
+ targetCandidates.forEach((targetCandidate) => {
293
+ const comparison = compareCandidates(incomingCandidate, targetCandidate);
294
+ const isExact = comparison.score === 1;
295
+ const isAcceptedFuzzy = allowFuzzy && comparison.score >= fuzzyThreshold;
296
+ if (!isExact && !isAcceptedFuzzy) return;
297
+ const match = {
298
+ folderId: folder.id,
299
+ folder: {id: folder.id, name: folder.name || ''},
300
+ score: comparison.score,
301
+ reason: comparison.reason,
302
+ evidenceSource: targetCandidate.source,
303
+ incomingCandidate,
304
+ targetCandidate: {
305
+ value: targetCandidate.value,
306
+ key: targetCandidate.key,
307
+ strictKey: targetCandidate.strictKey,
308
+ source: targetCandidate.source
309
+ },
310
+ crossPlatform: allowFuzzy,
311
+ folderPlatforms: [...folderPlatforms]
312
+ };
313
+ if (isBetterMatch(match, bestMatch, folderOrderIndex)) bestMatch = match;
314
+ });
315
+ });
316
+ });
317
+
318
+ return bestMatch;
319
+ };
320
+
321
+ export const isPendingChannelFolderId = (folderId) => (
322
+ typeof folderId === 'string' && folderId.startsWith('pending-channel-folder-')
323
+ );
324
+
325
+ export const buildRecordTimeLabelChannelFolderPlan = ({
326
+ channelGroups = {},
327
+ folders = [],
328
+ recordsByFolder = {},
329
+ folderOrder = []
330
+ } = {}) => {
331
+ const foldersToCreate = [];
332
+ const channelFolderAssignments = {};
333
+ const plannedFolders = [...(Array.isArray(folders) ? folders : [])];
334
+ const plannedRecords = {...recordsByFolder};
335
+
336
+ Object.entries(channelGroups || {}).forEach(([channelName, entries = []]) => {
337
+ const representativeRecord = {
338
+ ...(entries[0]?.record || {}),
339
+ streamerName: channelName
340
+ };
341
+ const sourceFolderIds = new Set(entries.map((entry) => entry.currentFolderId).filter(Boolean));
342
+ const exactNameFolder = plannedFolders.find((folder) => (
343
+ folder?.id &&
344
+ !SYSTEM_FOLDER_IDS.has(folder.id) &&
345
+ normalizeText(folder.name).toLowerCase() === normalizeText(channelName).toLowerCase()
346
+ ));
347
+ const matchedFolder = exactNameFolder || findBestRecordTimeLabelChannelFolder({
348
+ record: representativeRecord,
349
+ folders: plannedFolders.filter((folder) => !sourceFolderIds.has(folder?.id)),
350
+ recordsByFolder: plannedRecords,
351
+ folderOrder
352
+ })?.folder;
353
+
354
+ if (matchedFolder) {
355
+ channelFolderAssignments[channelName] = isPendingChannelFolderId(matchedFolder.id)
356
+ ? {tempId: matchedFolder.id}
357
+ : {folderId: matchedFolder.id};
358
+ plannedRecords[matchedFolder.id] = [
359
+ ...(plannedRecords[matchedFolder.id] || []),
360
+ ...entries.map((entry) => entry.record)
361
+ ];
362
+ return;
363
+ }
364
+
365
+ const tempId = `pending-channel-folder-${foldersToCreate.length}`;
366
+ foldersToCreate.push({channelName, tempId});
367
+ channelFolderAssignments[channelName] = {tempId};
368
+ plannedFolders.push({id: tempId, name: channelName});
369
+ plannedRecords[tempId] = entries.map((entry) => entry.record);
370
+ });
371
+
372
+ return {
373
+ foldersToCreate,
374
+ channelFolderAssignments,
375
+ plannedRecords
376
+ };
377
+ };