@recordtimelabel/core 0.4.6 → 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
 
@@ -131,8 +135,16 @@ content is quarantined as an ID conflict. Rejected entries are terminal diagnost
131
135
  API; an intentional retry currently requires a new operation ID.
132
136
 
133
137
  The engine exposes `init()`, `dispatch(operations)`, `sync(reason)`,
134
- `syncUntilIdle(reason, options)`, `waitForIdle()`, `getSnapshot()`, `subscribe(listener)`,
135
- and `destroy()`. `sync()` preserves the one-FIFO-batch boundary. `syncUntilIdle()` drains
138
+ `syncUntilIdle(reason, options)`, `waitForIdle()`, `waitForRemoteReady()`, `getSnapshot()`,
139
+ `subscribe(listener)`, and `destroy()`. A cloud `subscribe(listener, context)` disposer may
140
+ carry a `.ready` promise. After `init()` settles, hosts can await `waitForRemoteReady()` to
141
+ prove that the current subscription's initial root observation and any bounded catch-up have
142
+ settled; this wait rejects when the listener fails before readiness and fails closed across a
143
+ session generation change or destroy. `init()` deliberately does not await this promise because
144
+ the initial listener callback may itself enter the engine queue. Anonymous/no-subscription use
145
+ is immediately ready.
146
+
147
+ `sync()` preserves the one-FIFO-batch boundary. `syncUntilIdle()` drains
136
148
  subsequent batches up to `RTL_MAX_SYNC_DRAIN_ROUNDS`; it stops on retry/deferred/protocol
137
149
  conditions and reports `syncDrainLimitReached` as a retryable failure instead of silently
138
150
  reporting success. Hosts may provide `beforeRound({round, pendingCount, snapshot})` to block
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@recordtimelabel/core",
3
- "version": "0.4.6",
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
+ };