@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.
@@ -0,0 +1,359 @@
1
+ import {digestCanonicalJson} from './hash.js';
2
+ import {
3
+ SYSTEM_FOLDER_IDS,
4
+ VIRTUAL_FOLDER_IDS,
5
+ normalizeId,
6
+ normalizeIdList,
7
+ normalizeText,
8
+ toArray
9
+ } from './shared.js';
10
+
11
+ const EXCLUDED_LEGACY_RECORD_FIELDS = new Set([
12
+ 'id',
13
+ 'recordId',
14
+ 'folderId',
15
+ 'sortIndex',
16
+ 'pendingSync',
17
+ 'syncAttempts',
18
+ 'pending',
19
+ 'lastSyncError',
20
+ 'syncStatus',
21
+ 'clientId',
22
+ 'ownerUid',
23
+ 'workspaceEpoch',
24
+ 'createdLocally',
25
+ 'importedAt',
26
+ 'lastSyncedAt',
27
+ 'syncError'
28
+ ]);
29
+
30
+ const FOLDER_REFERENCE_FIELDS = [
31
+ 'folderId',
32
+ 'originalFolderId',
33
+ 'sourceFolderId',
34
+ 'targetFolderId'
35
+ ];
36
+
37
+ const OPERATION_TYPES = Object.freeze({
38
+ FOLDER_CREATE: 'folder.create',
39
+ FOLDER_UPDATE: 'folder.update',
40
+ FOLDER_DELETE: 'folder.delete',
41
+ FOLDER_REORDER: 'folder.reorder',
42
+ RECORD_CREATE: 'record.create',
43
+ RECORD_MOVE: 'record.move',
44
+ RECORD_REORDER: 'record.reorder',
45
+ SETTINGS_UPDATE: 'settings.update'
46
+ });
47
+
48
+ const isPlainObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
49
+
50
+ const isSerializableDomainValue = (value) => {
51
+ const type = typeof value;
52
+ if (value === null) return true;
53
+ if (type === 'string' || type === 'boolean') return true;
54
+ if (type === 'number') return Number.isFinite(value);
55
+ if (Array.isArray(value)) return value.every(isSerializableDomainValue);
56
+ if (isPlainObject(value)) return Object.values(value).every(isSerializableDomainValue);
57
+ return false;
58
+ };
59
+
60
+ const collectDomainRecordFields = (record = {}) => {
61
+ const fields = {};
62
+ Object.keys(record || {}).sort().forEach((key) => {
63
+ if (EXCLUDED_LEGACY_RECORD_FIELDS.has(key)) return;
64
+ if (key.startsWith('_') || key.startsWith('$')) return;
65
+ const value = record[key];
66
+ if (value === undefined || !isSerializableDomainValue(value)) return;
67
+ fields[key] = value;
68
+ });
69
+ return fields;
70
+ };
71
+
72
+ const resolveImportFolderId = (folderId, record = {}) => {
73
+ const candidates = [folderId, record.folderId, record.originalFolderId];
74
+ const match = candidates.map(normalizeId).find((id) => id && !VIRTUAL_FOLDER_IDS.has(id));
75
+ return match || 'uncategorized';
76
+ };
77
+
78
+ const isLegacyNameIdFolder = (folder = {}) => {
79
+ const id = normalizeId(folder?.id);
80
+ const name = normalizeText(folder?.name);
81
+ return Boolean(id) && !SYSTEM_FOLDER_IDS.has(id) && id === name;
82
+ };
83
+
84
+ const normalizeFolderName = (value) => normalizeText(value).toLowerCase();
85
+
86
+ const buildLegacyFolderAliasMap = (folders = [], canonicalFolders = []) => {
87
+ const canonicalIdsByName = {};
88
+ toArray(canonicalFolders).forEach((folder) => {
89
+ const id = normalizeId(folder?.id);
90
+ const name = normalizeFolderName(folder?.name);
91
+ if (!id || !name || SYSTEM_FOLDER_IDS.has(id)) return;
92
+ const ids = canonicalIdsByName[name] || [];
93
+ if (!ids.includes(id)) ids.push(id);
94
+ canonicalIdsByName[name] = ids;
95
+ });
96
+
97
+ const aliases = {};
98
+ const diagnostics = {
99
+ remappedFolderCount: 0,
100
+ ambiguousFolderCount: 0,
101
+ unmatchedLegacyFolderCount: 0
102
+ };
103
+ toArray(folders).forEach((folder) => {
104
+ if (!isLegacyNameIdFolder(folder)) return;
105
+ const folderId = normalizeId(folder.id);
106
+ const candidates = canonicalIdsByName[normalizeFolderName(folder.name)] || [];
107
+ if (candidates.includes(folderId)) return;
108
+ if (candidates.length === 1) {
109
+ aliases[folderId] = candidates[0];
110
+ diagnostics.remappedFolderCount += 1;
111
+ return;
112
+ }
113
+ if (candidates.length === 0) diagnostics.unmatchedLegacyFolderCount += 1;
114
+ else diagnostics.ambiguousFolderCount += 1;
115
+ });
116
+ return {aliases, diagnostics};
117
+ };
118
+
119
+ const remapFolderId = (value, aliases = {}) => aliases[value] || value;
120
+
121
+ const remapUniqueIds = (items = [], aliases = {}) => {
122
+ const seen = new Set();
123
+ return toArray(items).reduce((result, item) => {
124
+ const nextId = remapFolderId(item, aliases);
125
+ if (!nextId || seen.has(nextId)) return result;
126
+ seen.add(nextId);
127
+ result.push(nextId);
128
+ return result;
129
+ }, []);
130
+ };
131
+
132
+ const remapObjectFolderFields = (value, aliases = {}, fields = []) => {
133
+ if (!isPlainObject(value)) return value;
134
+ let result = value;
135
+ fields.forEach((field) => {
136
+ if (!Object.hasOwn(aliases, value[field])) return;
137
+ if (result === value) result = {...value};
138
+ result[field] = aliases[value[field]];
139
+ });
140
+ return result;
141
+ };
142
+
143
+ const remapRecordFolderFields = (record, aliases = {}) => (
144
+ remapObjectFolderFields(record, aliases, FOLDER_REFERENCE_FIELDS)
145
+ );
146
+
147
+ const remapRecordListFolderFields = (records, aliases = {}) => {
148
+ if (!Array.isArray(records)) return records;
149
+ let changed = false;
150
+ const remapped = records.map((record) => {
151
+ const nextRecord = remapRecordFolderFields(record, aliases);
152
+ if (nextRecord !== record) changed = true;
153
+ return nextRecord;
154
+ });
155
+ return changed ? remapped : records;
156
+ };
157
+
158
+ const remapPendingOperation = (operation, aliases = {}) => {
159
+ const payload = operation?.payload;
160
+ if (!isPlainObject(payload)) return operation;
161
+ let nextPayload = remapObjectFolderFields(payload, aliases, FOLDER_REFERENCE_FIELDS);
162
+ const setPayloadField = (field, value) => {
163
+ if (value === payload[field]) return;
164
+ if (nextPayload === payload) nextPayload = {...payload};
165
+ nextPayload[field] = value;
166
+ };
167
+
168
+ if ([
169
+ OPERATION_TYPES.FOLDER_CREATE,
170
+ OPERATION_TYPES.FOLDER_UPDATE,
171
+ OPERATION_TYPES.FOLDER_DELETE
172
+ ].includes(operation?.type)) {
173
+ setPayloadField('id', remapFolderId(payload.id, aliases));
174
+ }
175
+
176
+ setPayloadField('folder', remapObjectFolderFields(payload.folder, aliases, ['id']));
177
+ setPayloadField('record', remapRecordFolderFields(payload.record, aliases));
178
+ setPayloadField('records', remapRecordListFolderFields(payload.records, aliases));
179
+
180
+ if (operation?.type === OPERATION_TYPES.FOLDER_REORDER) {
181
+ setPayloadField('folderOrder', remapUniqueIds(payload.folderOrder, aliases));
182
+ setPayloadField('order', remapUniqueIds(payload.order, aliases));
183
+ }
184
+
185
+ if (operation?.type === OPERATION_TYPES.SETTINGS_UPDATE) {
186
+ setPayloadField('patch', remapObjectFolderFields(payload.patch, aliases, ['lastActiveFolderId']));
187
+ setPayloadField('settings', remapObjectFolderFields(payload.settings, aliases, ['lastActiveFolderId']));
188
+ }
189
+
190
+ return nextPayload === payload ? operation : {...operation, payload: nextPayload};
191
+ };
192
+
193
+ export const normalizeLegacyRecordTimeLabelImport = (input = {}) => {
194
+ const sourceRecords = input?.records && typeof input.records === 'object' ? input.records : {};
195
+ const generatedIds = [];
196
+ const occurrenceByDigest = {};
197
+ const nextRecords = {};
198
+ let assignedCount = 0;
199
+ let preservedCount = 0;
200
+
201
+ const visit = (folderId, record) => {
202
+ if (!record || typeof record !== 'object') return;
203
+ const resolvedFolderId = resolveImportFolderId(folderId, record);
204
+ if (!nextRecords[resolvedFolderId]) nextRecords[resolvedFolderId] = [];
205
+ const existingId = normalizeId(record.id || record.recordId);
206
+ if (existingId) {
207
+ preservedCount += 1;
208
+ nextRecords[resolvedFolderId].push({...record, id: existingId});
209
+ return;
210
+ }
211
+ const digest = digestCanonicalJson({
212
+ folderId: resolvedFolderId,
213
+ record: collectDomainRecordFields(record)
214
+ });
215
+ occurrenceByDigest[digest] = (occurrenceByDigest[digest] || 0) + 1;
216
+ const occurrence = occurrenceByDigest[digest];
217
+ const id = occurrence === 1
218
+ ? `legacy-record:${digest}`
219
+ : `legacy-record:${digest}:${occurrence}`;
220
+ assignedCount += 1;
221
+ generatedIds.push({folderId: resolvedFolderId, id, occurrence});
222
+ nextRecords[resolvedFolderId].push({...record, id});
223
+ };
224
+
225
+ if (Array.isArray(sourceRecords)) {
226
+ sourceRecords.forEach((record) => visit(record?.folderId, record));
227
+ } else {
228
+ Object.keys(sourceRecords).sort().forEach((folderId) => {
229
+ toArray(sourceRecords[folderId]).forEach((record) => visit(folderId, record));
230
+ });
231
+ }
232
+
233
+ return {
234
+ state: {
235
+ ...input,
236
+ records: nextRecords
237
+ },
238
+ generatedIds,
239
+ diagnostics: {
240
+ assignedCount,
241
+ preservedCount,
242
+ duplicateDigestCount: Object.values(occurrenceByDigest).filter((count) => count > 1).length
243
+ }
244
+ };
245
+ };
246
+
247
+ export const remapLegacyRecordTimeLabelFolderAliases = ({
248
+ state = {},
249
+ canonicalFolders = null,
250
+ pendingOperations = []
251
+ } = {}) => {
252
+ const folders = toArray(state?.folders);
253
+ const canonical = canonicalFolders === null ? folders : toArray(canonicalFolders);
254
+ const {aliases, diagnostics} = buildLegacyFolderAliasMap(folders, canonical);
255
+ const aliasCount = Object.keys(aliases).length;
256
+ if (aliasCount === 0) {
257
+ return {
258
+ state,
259
+ pendingOperations: Array.isArray(pendingOperations) ? pendingOperations : [],
260
+ diagnostics: {
261
+ ...diagnostics,
262
+ remappedReferenceCount: 0
263
+ }
264
+ };
265
+ }
266
+
267
+ const nextFolders = folders.filter((folder) => !aliases[folder?.id]);
268
+ const folderIds = new Set(nextFolders.map((folder) => folder?.id).filter(Boolean));
269
+ const requiredCanonicalIds = new Set(Object.values(aliases));
270
+ canonical.forEach((folder) => {
271
+ if (!requiredCanonicalIds.has(folder?.id) || folderIds.has(folder.id)) return;
272
+ nextFolders.push(folder);
273
+ folderIds.add(folder.id);
274
+ });
275
+
276
+ const records = {};
277
+ const recordIdsByFolder = {};
278
+ Object.entries(state?.records || {}).forEach(([folderId, folderRecords]) => {
279
+ const targetFolderId = aliases[folderId] || folderId;
280
+ if (!records[targetFolderId]) records[targetFolderId] = [];
281
+ if (!recordIdsByFolder[targetFolderId]) recordIdsByFolder[targetFolderId] = new Set();
282
+ toArray(folderRecords).forEach((record) => {
283
+ const recordId = record?.id;
284
+ if (recordId && recordIdsByFolder[targetFolderId].has(recordId)) return;
285
+ if (recordId) recordIdsByFolder[targetFolderId].add(recordId);
286
+ records[targetFolderId].push(remapRecordFolderFields(record, aliases));
287
+ });
288
+ });
289
+
290
+ const settings = isPlainObject(state?.settings)
291
+ ? {
292
+ ...state.settings,
293
+ ...(aliases[state.settings.lastActiveFolderId]
294
+ ? {lastActiveFolderId: aliases[state.settings.lastActiveFolderId]}
295
+ : {})
296
+ }
297
+ : state?.settings;
298
+
299
+ const trashEntries = Object.fromEntries(
300
+ Object.entries(state?.trashEntries || {}).map(([entryId, entry]) => {
301
+ if (!isPlainObject(entry)) return [entryId, entry];
302
+ let nextEntry = remapObjectFolderFields(entry, aliases, ['originalFolderId']);
303
+ const payload = entry.payload;
304
+ if (isPlainObject(payload)) {
305
+ const nextRecord = remapRecordFolderFields(payload.record, aliases);
306
+ const nextRecords = remapRecordListFolderFields(payload.records, aliases);
307
+ if (nextRecord !== payload.record || nextRecords !== payload.records) {
308
+ nextEntry = {
309
+ ...nextEntry,
310
+ payload: {
311
+ ...payload,
312
+ ...(nextRecord !== payload.record ? {record: nextRecord} : {}),
313
+ ...(nextRecords !== payload.records ? {records: nextRecords} : {})
314
+ }
315
+ };
316
+ }
317
+ }
318
+ return [entryId, nextEntry];
319
+ })
320
+ );
321
+
322
+ const deletedRecordTombstones = Object.fromEntries(
323
+ Object.entries(state?.deletedRecordTombstones || {}).map(([recordId, tombstone]) => [
324
+ recordId,
325
+ remapObjectFolderFields(tombstone, aliases, ['folderId', 'originalFolderId'])
326
+ ])
327
+ );
328
+
329
+ const nextPendingOperations = (Array.isArray(pendingOperations) ? pendingOperations : []).reduce((result, operation) => {
330
+ const folderId = operation?.payload?.folder?.id || operation?.payload?.folderId;
331
+ if (operation?.type === OPERATION_TYPES.FOLDER_CREATE && aliases[folderId]) {
332
+ return result;
333
+ }
334
+ result.push(remapPendingOperation(operation, aliases));
335
+ return result;
336
+ }, []);
337
+
338
+ return {
339
+ state: {
340
+ ...state,
341
+ folders: nextFolders,
342
+ records,
343
+ folderOrder: remapUniqueIds(state?.folderOrder, aliases),
344
+ trashEntries,
345
+ deletedRecordTombstones,
346
+ deletedFolderTombstones: state?.deletedFolderTombstones,
347
+ lifecycleTombstones: state?.lifecycleTombstones,
348
+ ...(aliases[state?.lastActiveFolderId]
349
+ ? {lastActiveFolderId: aliases[state.lastActiveFolderId]}
350
+ : {}),
351
+ ...(settings ? {settings} : {})
352
+ },
353
+ pendingOperations: nextPendingOperations,
354
+ diagnostics: {
355
+ ...diagnostics,
356
+ remappedReferenceCount: aliasCount
357
+ }
358
+ };
359
+ };
@@ -0,0 +1,132 @@
1
+ export const GROUP_ID_PREFIX = 'group-';
2
+ export const DEFAULT_UNKNOWN_TITLE = '未知直播標題';
3
+ export const DEFAULT_UNKNOWN_CHANNEL = '未知頻道';
4
+ export const VIRTUAL_FOLDER_IDS = new Set(['all']);
5
+ export const SYSTEM_FOLDER_IDS = new Set(['all', 'uncategorized']);
6
+ export const FALLBACK_PLATFORMS = new Set(['twitch', 'youtube', 'unknown']);
7
+ export const TWITCH_STREAM_ANCHOR_TOLERANCE_MS = 5 * 60 * 1000;
8
+
9
+ export const normalizeText = (value) => {
10
+ if (typeof value === 'string' || typeof value === 'number') {
11
+ return String(value).trim();
12
+ }
13
+ return '';
14
+ };
15
+
16
+ export const normalizeId = (value) => {
17
+ const text = normalizeText(value);
18
+ return text || '';
19
+ };
20
+
21
+ export const normalizeFallbackPlatform = (value) => {
22
+ const text = normalizeText(value).toLowerCase();
23
+ if (FALLBACK_PLATFORMS.has(text)) return text;
24
+ return 'unknown';
25
+ };
26
+
27
+ export const normalizeRecordPlatform = (record, fallbackPlatform) => {
28
+ const explicit = normalizeText(record?.platform).toLowerCase();
29
+ if (explicit === 'twitch' || explicit === 'youtube') return explicit;
30
+ if (explicit.includes('youtube') || explicit === 'yt') return 'youtube';
31
+ if (explicit.includes('twitch')) return 'twitch';
32
+ return normalizeFallbackPlatform(fallbackPlatform);
33
+ };
34
+
35
+ export const toArray = (value) => (Array.isArray(value) ? value : []);
36
+
37
+ export const normalizeIdList = (ids = []) => {
38
+ const seen = new Set();
39
+ const result = [];
40
+ toArray(ids).forEach((id) => {
41
+ const normalized = normalizeId(id);
42
+ if (!normalized || seen.has(normalized)) return;
43
+ seen.add(normalized);
44
+ result.push(normalized);
45
+ });
46
+ return result;
47
+ };
48
+
49
+ export const getRecordUrls = (record = {}) => ([
50
+ record.videoUrl,
51
+ record.originalVideoUrl,
52
+ record.url,
53
+ record.sourceUrl,
54
+ record.jumpUrl
55
+ ].map(normalizeText).filter(Boolean));
56
+
57
+ export const getFirstMatchingUrlValue = (record, extractor) => {
58
+ for (const url of getRecordUrls(record)) {
59
+ const value = extractor(url);
60
+ if (value) return value;
61
+ }
62
+ return '';
63
+ };
64
+
65
+ export const collectNonVirtualRecords = (records) => {
66
+ if (Array.isArray(records)) {
67
+ return records.filter((record) => record && typeof record === 'object');
68
+ }
69
+ if (!records || typeof records !== 'object') return [];
70
+ const collected = [];
71
+ Object.entries(records).forEach(([folderId, folderRecords]) => {
72
+ if (VIRTUAL_FOLDER_IDS.has(folderId)) return;
73
+ toArray(folderRecords).forEach((record) => {
74
+ if (record && typeof record === 'object') collected.push(record);
75
+ });
76
+ });
77
+ return collected;
78
+ };
79
+
80
+ export const extractYouTubeVideoIdFromUrl = (url) => {
81
+ const value = normalizeText(url);
82
+ if (!value) return '';
83
+
84
+ const shortMatch = value.match(/youtu\.be\/([^?&#/]+)/i);
85
+ if (shortMatch?.[1]) return shortMatch[1];
86
+
87
+ try {
88
+ const parsed = new URL(value);
89
+ if (parsed.hostname.includes('youtube.com')) {
90
+ const watchVideoId = normalizeText(parsed.searchParams.get('v'));
91
+ if (watchVideoId) return watchVideoId;
92
+ const parts = parsed.pathname.split('/').filter(Boolean);
93
+ if (['embed', 'shorts', 'live'].includes(parts[0])) {
94
+ return normalizeText(parts[1]);
95
+ }
96
+ }
97
+ } catch (_) {}
98
+
99
+ return '';
100
+ };
101
+
102
+ export const extractTwitchVodIdFromUrl = (url) => {
103
+ const value = normalizeText(url);
104
+ if (!value) return '';
105
+ const match = value.match(/twitch\.tv\/videos\/(\d+)/i) || value.match(/\/videos\/(\d+)/i);
106
+ return match?.[1] || '';
107
+ };
108
+
109
+ export const getRecordTitle = (record, unknownTitle = DEFAULT_UNKNOWN_TITLE) => (
110
+ normalizeText(record?.title) || unknownTitle
111
+ );
112
+
113
+ export const parseElapsedSeconds = (value) => {
114
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
115
+ return value;
116
+ }
117
+ const text = normalizeText(value);
118
+ if (!text) return null;
119
+ if (/^\d+(?:\.\d+)?$/.test(text)) {
120
+ const seconds = Number(text);
121
+ return Number.isFinite(seconds) && seconds >= 0 ? seconds : null;
122
+ }
123
+ const parts = text.split(':');
124
+ if (parts.length !== 2 && parts.length !== 3) return null;
125
+ if (!parts.every((part) => /^\d{1,2}$/.test(part))) return null;
126
+ const numbers = parts.map(Number);
127
+ if (!numbers.every(Number.isFinite)) return null;
128
+ if (parts.length === 3) {
129
+ return (numbers[0] * 3600) + (numbers[1] * 60) + numbers[2];
130
+ }
131
+ return (numbers[0] * 60) + numbers[1];
132
+ };
@@ -0,0 +1,43 @@
1
+ const ISO_DATE_PATTERN = /^(\d{4}-\d{2}-\d{2})(?:[T\s](\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?)(Z|[+-]\d{2}:?\d{2})?)?$/;
2
+
3
+ const normalizeIsoCandidate = (value) => {
4
+ const match = value.match(ISO_DATE_PATTERN);
5
+ if (!match) return '';
6
+ const date = match[1];
7
+ const time = match[2] || '00:00:00.000';
8
+ const offset = match[3] || 'Z';
9
+ const normalizedTime = time.length === 5 ? `${time}:00.000` : time.length === 8 ? `${time}.000` : time;
10
+ const normalizedOffset = offset === 'Z'
11
+ ? 'Z'
12
+ : offset.includes(':')
13
+ ? offset
14
+ : `${offset.slice(0, 3)}:${offset.slice(3)}`;
15
+ return `${date}T${normalizedTime}${normalizedOffset}`;
16
+ };
17
+
18
+ export const parseUtcMillis = (value) => {
19
+ if (typeof value === 'number') {
20
+ return Number.isFinite(value) ? value : null;
21
+ }
22
+ if (typeof value !== 'string') return null;
23
+ const text = value.trim();
24
+ if (!text) return null;
25
+ if (/^-?\d+$/.test(text)) {
26
+ const numeric = Number(text);
27
+ return Number.isFinite(numeric) ? numeric : null;
28
+ }
29
+ const iso = normalizeIsoCandidate(text);
30
+ if (!iso) return null;
31
+ const millis = Date.parse(iso);
32
+ return Number.isFinite(millis) ? millis : null;
33
+ };
34
+
35
+ export const toCreatedAtMillis = (record) => {
36
+ const parsed = parseUtcMillis(record?.createdAt);
37
+ return parsed === null ? 0 : parsed;
38
+ };
39
+
40
+ export const roundDownToUtcMinuteIso = (millis) => {
41
+ if (!Number.isFinite(millis)) return '';
42
+ return new Date(Math.round(millis / 60000) * 60000).toISOString();
43
+ };