@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.
package/README.md CHANGED
@@ -75,11 +75,15 @@ If this checkout's `package.json` is ahead of the published version, publish the
75
75
  - `getActiveTrashEntries(entries, now)`
76
76
  - `RTL_TRASH_RETENTION_MS`
77
77
 
78
- The package exports four intentional entrypoints. The root (`@recordtimelabel/core`)
78
+ The package exports five intentional entrypoints. The root (`@recordtimelabel/core`)
79
79
  keeps the complete backwards-compatible surface, `/protocol` contains only shared
80
80
  acknowledgement protocol helpers, `/firestore-v2` contains platform-neutral document
81
- and operation planners, and `/compat` contains the legacy `createSyncEngine` and
82
- `createRecordTimeLabelController` APIs.
81
+ and operation planners, `/domain` contains group identity, Twitch VOD matching,
82
+ legacy import, and channel-folder planning, and `/compat` contains the legacy
83
+ `createSyncEngine` and `createRecordTimeLabelController` APIs.
84
+
85
+ `RECORD_TIMELABEL_DOMAIN_CAPABILITIES` is a local release contract. It is not a
86
+ gateway protocol capability.
83
87
 
84
88
  ### Durable sync engine
85
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.7",
3
+ "version": "0.5.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,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
+ };