meta-sovereign 0.49.4 → 0.49.5

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,99 @@
1
+ /**
2
+ * What an archive importer gets besides the parsed file (R-Z10): the
3
+ * other files of the same archive (to find a message's photo, video…),
4
+ * a sink that copies them into `<store>/media/<network>/<sha256>.<ext>`
5
+ * (owner-only, content-addressed so a second import writes nothing new),
6
+ * and per-import counters so ids stay unique across `message_N.json`.
7
+ */
8
+
9
+ import { baseName } from './text.js';
10
+
11
+ // Errors that mean "the disk cannot take this" (R-Z14): never swallowed.
12
+ const FATAL = new Set(['ENOSPC', 'EDQUOT', 'EROFS', 'EIO', 'EACCES', 'EPERM']);
13
+
14
+ export const isFatalWrite = (error) =>
15
+ FATAL.has(error?.code) ||
16
+ error?.code === 'STORE_WRITE_FAILED' ||
17
+ error?.code === 'STORE_FULL';
18
+
19
+ /**
20
+ * @param {object} options
21
+ * @param {string} options.network folder name under media/
22
+ * @param {Array} [options.entries] files of the archive (zip entries)
23
+ * @param {Function|null} [options.save] `({entry, folder}) => Promise<{path, sha256, size}>`
24
+ * copies an archive file into media/ (Node only, ./media-store.js); null = not copied
25
+ */
26
+ export const createArchiveContext = ({
27
+ network,
28
+ entries = [],
29
+ save = null,
30
+ }) => {
31
+ const byName = new Map(entries.map((entry) => [entry.name, entry]));
32
+ const saved = new Map();
33
+ const counters = new Map();
34
+ const warnings = [];
35
+ let baseIndex = null;
36
+ const byBase = () => {
37
+ if (!baseIndex) {
38
+ baseIndex = new Map();
39
+ for (const entry of entries) {
40
+ const base = baseName(entry.name);
41
+ const list = baseIndex.get(base);
42
+ if (list) {
43
+ list.push(entry);
44
+ } else {
45
+ baseIndex.set(base, [entry]);
46
+ }
47
+ }
48
+ }
49
+ return baseIndex;
50
+ };
51
+ const find = (uri) => {
52
+ const clean = String(uri).replace(/^\.?\//, '');
53
+ if (byName.has(clean)) {
54
+ return byName.get(clean);
55
+ }
56
+ // The archive may sit in a top-level folder (`facebook-name-2026/`).
57
+ const suffix = `/${clean}`;
58
+ return (
59
+ (byBase().get(baseName(clean)) ?? []).find((entry) =>
60
+ entry.name.endsWith(suffix)
61
+ ) ?? null
62
+ );
63
+ };
64
+ return {
65
+ network,
66
+ warnings,
67
+ // Facts one file tells the next (X's account.js names "me").
68
+ state: {},
69
+ /** 0, 1, 2… for the n-th time `key` is seen in this import. */
70
+ ordinal(key) {
71
+ const n = counters.get(key) ?? 0;
72
+ counters.set(key, n + 1);
73
+ return n;
74
+ },
75
+ /** The archive file `uri` names, copied into media/; null if absent. */
76
+ async media(uri, folder = network) {
77
+ const entry = uri ? find(uri) : null;
78
+ if (!entry || !save) {
79
+ return null;
80
+ }
81
+ if (!saved.has(entry.name)) {
82
+ saved.set(entry.name, copyEntry({ entry, save, folder, warnings }));
83
+ }
84
+ return saved.get(entry.name);
85
+ },
86
+ };
87
+ };
88
+
89
+ const copyEntry = async ({ entry, save, folder, warnings }) => {
90
+ try {
91
+ return await save({ entry, folder });
92
+ } catch (error) {
93
+ if (isFatalWrite(error)) {
94
+ throw error;
95
+ }
96
+ warnings.push(`${entry.name}: ${error.message}`);
97
+ return null;
98
+ }
99
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Re-importing an archive that an older build imported (R-Z10).
3
+ *
4
+ * Before R-Z10 a Facebook message was `msg:facebook:<ms>` and a LinkedIn
5
+ * one `msg:linkedin:<conversation>-<date>`. A link built from an archive
6
+ * row carries (under a symbol, so it is never stored) the old id it had
7
+ * and what that old link must look like (sender, chat). The importer
8
+ * skips a row whose old link is already in the store, under the old id or
9
+ * under the id migration 0005 moved it to (`alias:<old id>`), so the
10
+ * import does not double the history.
11
+ */
12
+
13
+ import { fixMojibake } from './text.js';
14
+
15
+ export const LEGACY = Symbol('legacyArchiveId');
16
+
17
+ /** Mark `link` as the new form of the old `{ id, sender, chat }`. */
18
+ export const withLegacy = (link, legacy) => ({ ...link, [LEGACY]: legacy });
19
+
20
+ const lastPart = (chat) =>
21
+ String(chat ?? '')
22
+ .split('/')
23
+ .filter(Boolean)
24
+ .at(-1) ?? '';
25
+
26
+ // An old Facebook chat was the whole `thread_path`; the new one its folder.
27
+ const sameChat = (stored, wanted) =>
28
+ stored === wanted || lastPart(stored) === lastPart(wanted);
29
+
30
+ const sameSender = (stored, wanted) =>
31
+ [stored?.sender, stored?.senderName].some(
32
+ (name) => fixMojibake(name) === wanted
33
+ );
34
+
35
+ // The link the old id stands for, now or after migration; null if none.
36
+ const storedLegacy = async (store, id) => {
37
+ if (typeof store.get !== 'function') {
38
+ return null;
39
+ }
40
+ const alias = await store.get(`alias:${id}`);
41
+ return (alias?.to ? await store.get(alias.to) : null) ?? store.get(id);
42
+ };
43
+
44
+ /**
45
+ * True when `link`'s old-scheme twin is already stored. `claimed` makes
46
+ * one old link account for one row (old ids collided, so a second row
47
+ * with the same old id is a different message and is written).
48
+ */
49
+ export const alreadyImported = async (store, link, claimed) => {
50
+ const legacy = link[LEGACY];
51
+ if (!legacy || claimed.has(legacy.id)) {
52
+ return false;
53
+ }
54
+ const there = await storedLegacy(store, legacy.id);
55
+ if (
56
+ there &&
57
+ sameChat(there.chat, legacy.chat) &&
58
+ sameSender(there, legacy.sender)
59
+ ) {
60
+ claimed.add(legacy.id);
61
+ return true;
62
+ }
63
+ return false;
64
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Copies archive files into `<store>/media/<network>/<sha256><ext>`
3
+ * (R-Z10): owner-only, content-addressed (a second import writes
4
+ * nothing new), streamed through a hash so large videos never sit in
5
+ * memory. Node only; the page never loads this.
6
+ */
7
+
8
+ import { createHash, randomBytes } from 'node:crypto';
9
+ import { createWriteStream, promises as fs } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { pipeline } from 'node:stream/promises';
12
+
13
+ const extensionOf = (name) => {
14
+ const ext = path.extname(String(name ?? '')).toLowerCase();
15
+ return /^\.[a-z0-9]{1,8}$/.test(ext) ? ext : '';
16
+ };
17
+
18
+ /** Copy a byte stream to `<mediaDir>/<network>/<sha256><ext>` (0600). */
19
+ export const saveMedia = async ({ mediaDir, network, stream, name }) => {
20
+ const dir = path.join(mediaDir, network);
21
+ // Private from the first byte: media/ and media/<network>/ are 0700.
22
+ for (const folder of [mediaDir, dir]) {
23
+ await fs.mkdir(folder, { recursive: true, mode: 0o700 });
24
+ }
25
+ const temp = path.join(dir, `.tmp-${randomBytes(6).toString('hex')}`);
26
+ const hash = createHash('sha256');
27
+ let size = 0;
28
+ try {
29
+ await pipeline(
30
+ stream,
31
+ async function* (source) {
32
+ for await (const chunk of source) {
33
+ hash.update(chunk);
34
+ size += chunk.length;
35
+ yield chunk;
36
+ }
37
+ },
38
+ createWriteStream(temp, { mode: 0o600 })
39
+ );
40
+ const sha256 = hash.digest('hex');
41
+ const file = `${sha256}${extensionOf(name)}`;
42
+ await fs.rename(temp, path.join(dir, file));
43
+ await fs.chmod(path.join(dir, file), 0o600);
44
+ return { path: `media/${network}/${file}`, sha256, size };
45
+ } catch (error) {
46
+ await fs.rm(temp, { force: true }).catch(() => {});
47
+ throw error;
48
+ }
49
+ };
50
+
51
+ /** A `save` function for createArchiveContext, writing under `mediaDir`. */
52
+ export const mediaSaver =
53
+ (mediaDir) =>
54
+ async ({ entry, folder }) =>
55
+ saveMedia({
56
+ mediaDir,
57
+ network: folder,
58
+ stream: await entry.stream(),
59
+ name: entry.name,
60
+ });
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Facebook / Messenger and Instagram "Download your information" threads
3
+ * (R-Z10): `messages/inbox/<thread>/message_N.json` (and `.html`) turned
4
+ * into message links.
5
+ *
6
+ * Ids are `<thread folder>:<timestamp ms>:<sender digest>:<n>` where n
7
+ * counts messages of the same sender in the same millisecond, so two
8
+ * messages never collide and importing the same archive again lands on
9
+ * the same ids (no duplicates). The thread id is the folder name.
10
+ * Learned from Meta's documented export layout, never from real data.
11
+ */
12
+
13
+ import { buildMessageLink } from '../link.js';
14
+ import { withLegacy } from './legacy.js';
15
+ import {
16
+ fixDeep,
17
+ baseName,
18
+ extName,
19
+ fixMojibake,
20
+ findAll,
21
+ hasClass,
22
+ isoOf,
23
+ parseHtml,
24
+ shortHash,
25
+ textOf,
26
+ } from './text.js';
27
+
28
+ const MIME = {
29
+ '.jpg': 'image/jpeg',
30
+ '.jpeg': 'image/jpeg',
31
+ '.png': 'image/png',
32
+ '.gif': 'image/gif',
33
+ '.webp': 'image/webp',
34
+ '.heic': 'image/heic',
35
+ '.mp4': 'video/mp4',
36
+ '.mov': 'video/quicktime',
37
+ '.webm': 'video/webm',
38
+ '.mp3': 'audio/mpeg',
39
+ '.m4a': 'audio/mp4',
40
+ '.aac': 'audio/aac',
41
+ '.ogg': 'audio/ogg',
42
+ '.wav': 'audio/wav',
43
+ '.pdf': 'application/pdf',
44
+ };
45
+
46
+ // JSON key → attachment kind (a list of items, except `sticker`).
47
+ const ATTACHMENT_KEYS = [
48
+ ['photos', 'photo'],
49
+ ['videos', 'video'],
50
+ ['audio_files', 'audio'],
51
+ ['files', 'file'],
52
+ ['gifs', 'gif'],
53
+ ['sticker', 'sticker'],
54
+ ];
55
+
56
+ const attachmentOf = async (ctx, network, kind, item) => {
57
+ const uri = typeof item === 'string' ? item : item?.uri;
58
+ if (!uri) {
59
+ return null;
60
+ }
61
+ const name = baseName(uri);
62
+ const mime = MIME[extName(name)];
63
+ const copy = await ctx.media(uri, network);
64
+ return {
65
+ kind,
66
+ name,
67
+ ...(mime ? { mime } : {}),
68
+ ...(copy ? { path: copy.path, sha256: copy.sha256, size: copy.size } : {}),
69
+ };
70
+ };
71
+
72
+ const attachmentsOf = async (ctx, network, message) => {
73
+ const out = [];
74
+ for (const [key, kind] of ATTACHMENT_KEYS) {
75
+ const value = message[key];
76
+ for (const item of Array.isArray(value) ? value : value ? [value] : []) {
77
+ const att = await attachmentOf(ctx, network, kind, item);
78
+ if (att) {
79
+ out.push(att);
80
+ }
81
+ }
82
+ }
83
+ if (message.share?.link) {
84
+ out.push({
85
+ kind: 'link',
86
+ url: message.share.link,
87
+ caption: message.share.share_text ?? null,
88
+ });
89
+ }
90
+ return out;
91
+ };
92
+
93
+ // [{reaction, actor}] → [{emoji, count, by}] (one row per emoji).
94
+ const reactionsOf = (list) => {
95
+ const byEmoji = new Map();
96
+ for (const r of Array.isArray(list) ? list : []) {
97
+ if (r?.reaction) {
98
+ const row = byEmoji.get(r.reaction) ?? {
99
+ emoji: r.reaction,
100
+ count: 0,
101
+ by: [],
102
+ };
103
+ row.count += 1;
104
+ if (r.actor) {
105
+ row.by.push(r.actor);
106
+ }
107
+ byEmoji.set(r.reaction, row);
108
+ }
109
+ }
110
+ return [...byEmoji.values()];
111
+ };
112
+
113
+ /** The thread id: the folder holding the message files. */
114
+ export const threadIdOf = (entryName, obj) => {
115
+ const parts = String(entryName ?? '')
116
+ .split(/[\\/]/)
117
+ .filter(Boolean);
118
+ const folder = parts.length >= 2 ? parts.at(-2) : null;
119
+ const fromPath = String(obj?.thread_path ?? '')
120
+ .split('/')
121
+ .filter(Boolean)
122
+ .at(-1);
123
+ return folder || fromPath || obj?.title || 'unknown';
124
+ };
125
+
126
+ const chatTypeOf = (participants) => {
127
+ if (participants.length === 0) {
128
+ return null;
129
+ }
130
+ return participants.length > 2 ? 'group' : 'private';
131
+ };
132
+
133
+ const linkOf = async ({ network, ctx, thread, header, message }) => {
134
+ const sender = message.sender_name ?? 'unknown';
135
+ const ts = message.timestamp_ms ?? message.timestamp ?? 'nots';
136
+ const n = ctx.ordinal(`${thread}|${ts}|${sender}`);
137
+ const link = buildMessageLink({
138
+ source: network,
139
+ externalId: `${thread}:${ts}:${shortHash(sender)}:${n}`,
140
+ sender,
141
+ chat: thread,
142
+ body: typeof message.content === 'string' ? message.content : '',
143
+ timestamp: isoOf(message.timestamp_ms ?? message.timestamp),
144
+ senderName: sender,
145
+ chatName: header.title,
146
+ chatType: chatTypeOf(header.participants),
147
+ participants: header.participants,
148
+ attachments: await attachmentsOf(ctx, network, message),
149
+ reactions: reactionsOf(message.reactions),
150
+ deleted: message.is_unsent === true ? true : null,
151
+ });
152
+ // What an older build called it: `<ms>` or `<sender>-<seconds>`.
153
+ const old =
154
+ message.timestamp_ms ?? `${message.sender_name}-${message.timestamp}`;
155
+ return withLegacy(link, { id: `msg:facebook:${old}`, sender, chat: thread });
156
+ };
157
+
158
+ const headerOf = (obj) => ({
159
+ title: obj.title ?? null,
160
+ participants: (obj.participants ?? [])
161
+ .map((p) => (typeof p === 'string' ? p : p?.name))
162
+ .filter(Boolean),
163
+ });
164
+
165
+ /** One `message_N.json` (object or text) → links. */
166
+ export const metaJsonToLinks = async ({ network, archive, ctx, entryName }) => {
167
+ const obj = fixDeep(
168
+ typeof archive === 'string' ? JSON.parse(archive) : archive
169
+ );
170
+ const thread = threadIdOf(entryName, obj);
171
+ const header = headerOf(obj);
172
+ const links = [];
173
+ for (const message of obj.messages ?? []) {
174
+ links.push(await linkOf({ network, ctx, thread, header, message }));
175
+ }
176
+ return links;
177
+ };
178
+
179
+ // ---- HTML export ---------------------------------------------------------
180
+
181
+ const MONTHS = 'jan feb mar apr may jun jul aug sep oct nov dec'.split(' ');
182
+ const DATE =
183
+ /([A-Za-z]{3,9})\.? (\d{1,2}),? (\d{4}),? (\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?/i;
184
+
185
+ // "Jan 01, 2024 10:00:00 am" (English exports; the export does not say
186
+ // its zone, so it is read in this machine's time zone). Other languages fall through to Date.parse.
187
+ export const htmlDateToMs = (text) => {
188
+ const m = DATE.exec(text ?? '');
189
+ const month = m ? MONTHS.indexOf(m[1].slice(0, 3).toLowerCase()) : -1;
190
+ if (!m || month < 0) {
191
+ const parsed = Date.parse(text ?? '');
192
+ return Number.isNaN(parsed) ? null : parsed;
193
+ }
194
+ let hour = Number(m[4]) % (m[7] ? 12 : 24);
195
+ if (m[7]?.toLowerCase() === 'pm') {
196
+ hour += 12;
197
+ }
198
+ return new Date(
199
+ Number(m[3]),
200
+ month,
201
+ Number(m[2]),
202
+ hour,
203
+ Number(m[5]),
204
+ Number(m[6] ?? 0)
205
+ ).getTime();
206
+ };
207
+
208
+ const MESSAGE = ['_a6-g', '_3-95'];
209
+ const SENDER = ['_a6-h', '_2lek'];
210
+ const BODY = ['_a6-p', '_2let'];
211
+ const WHEN = ['_a6-o', '_2lem'];
212
+ const REACTIONS = ['_a6-q', '_tqp'];
213
+
214
+ const REACTION = /^(\P{L}+?)\s*(\p{L}.*)$/su;
215
+
216
+ const firstOf = (node, names) =>
217
+ findAll(node, (n) => hasClass(n, names))[0] ?? null;
218
+
219
+ const htmlAttachments = async (ctx, network, body) => {
220
+ const out = [];
221
+ const media = findAll(body, (n) =>
222
+ ['img', 'video', 'audio', 'source', 'a'].includes(n.tag)
223
+ );
224
+ const kinds = {
225
+ img: 'photo',
226
+ video: 'video',
227
+ audio: 'audio',
228
+ source: 'video',
229
+ a: 'file',
230
+ };
231
+ for (const node of media) {
232
+ const uri = node.attrs.src ?? node.attrs.href;
233
+ if (uri && !/^[a-z]+:/i.test(uri)) {
234
+ const att = await attachmentOf(ctx, network, kinds[node.tag], uri);
235
+ if (att) {
236
+ out.push(att);
237
+ }
238
+ }
239
+ }
240
+ return out;
241
+ };
242
+
243
+ const htmlMessage = async (ctx, network, node) => {
244
+ const body = firstOf(node, BODY);
245
+ const reactions = firstOf(body ?? node, REACTIONS);
246
+ const likes = findAll(reactions, (n) => n.tag === 'li').map((li) => {
247
+ const m = REACTION.exec(textOf(li).trim());
248
+ return m ? { reaction: m[1].trim(), actor: m[2].trim() } : null;
249
+ });
250
+ const content = body
251
+ ? textOf(body, (n) => hasClass(n, REACTIONS))
252
+ .replace(/[ \t]*\n[ \t]*/g, '\n')
253
+ .trim()
254
+ : '';
255
+ return {
256
+ sender_name: textOf(firstOf(node, SENDER) ?? '').trim(),
257
+ timestamp_ms: htmlDateToMs(textOf(firstOf(node, WHEN) ?? '').trim()),
258
+ content,
259
+ reactions: likes.filter(Boolean),
260
+ _attachments: body ? await htmlAttachments(ctx, network, body) : [],
261
+ };
262
+ };
263
+
264
+ /** One `message_N.html` → links (English dates; see htmlDateToMs). */
265
+ export const metaHtmlToLinks = async ({ network, html, ctx, entryName }) => {
266
+ const tree = parseHtml(html);
267
+ const title = textOf(findAll(tree, (n) => n.tag === 'title')[0] ?? '').trim();
268
+ const thread = threadIdOf(entryName, { title });
269
+ const header = { title: fixMojibake(title) || null, participants: [] };
270
+ const links = [];
271
+ for (const node of findAll(tree, (n) => hasClass(n, MESSAGE))) {
272
+ const message = await htmlMessage(ctx, network, node);
273
+ const link = await linkOf({ network, ctx, thread, header, message });
274
+ const attachments = message._attachments;
275
+ links.push(attachments.length ? { ...link, attachments } : link);
276
+ }
277
+ return links;
278
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Text repairs shared by the data-archive importers (R-Z10).
3
+ */
4
+
5
+ const FFFD = '�';
6
+
7
+ /**
8
+ * Facebook / Instagram JSON exports write UTF-8 bytes as if each byte
9
+ * were a latin-1 character ("Ð\u0098" for "И"). Decode only when the
10
+ * text is all single-byte characters AND the bytes are valid UTF-8 that
11
+ * encodes back to the same bytes, so honest latin-1 text is never damaged.
12
+ */
13
+ export const fixMojibake = (value) => {
14
+ if (typeof value !== 'string' || !/[\u0080-\u00ff]/.test(value)) {
15
+ return value;
16
+ }
17
+ if ([...value].some((c) => c.codePointAt(0) > 0xff)) {
18
+ return value;
19
+ }
20
+ const bytes = Uint8Array.from(value, (c) => c.charCodeAt(0));
21
+ let decoded;
22
+ try {
23
+ decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
24
+ } catch {
25
+ return value;
26
+ }
27
+ const again = new TextEncoder().encode(decoded);
28
+ const same =
29
+ again.length === bytes.length && again.every((b, i) => b === bytes[i]);
30
+ return same && !decoded.includes(FFFD) ? decoded : value;
31
+ };
32
+
33
+ /** `fixMojibake` over every string inside a parsed JSON value. */
34
+ export const fixDeep = (value) => {
35
+ if (typeof value === 'string') {
36
+ return fixMojibake(value);
37
+ }
38
+ if (Array.isArray(value)) {
39
+ return value.map(fixDeep);
40
+ }
41
+ if (value && typeof value === 'object') {
42
+ return Object.fromEntries(
43
+ Object.entries(value).map(([key, item]) => [key, fixDeep(item)])
44
+ );
45
+ }
46
+ return value;
47
+ };
48
+
49
+ /**
50
+ * A short stable hex digest (cyrb53, 53 bits), for parts of ids that may
51
+ * hold odd text. Pure JS so the module also loads in the page.
52
+ */
53
+ export const shortHash = (text) => {
54
+ let h1 = 0xdeadbeef;
55
+ let h2 = 0x41c6ce57;
56
+ for (const ch of String(text)) {
57
+ const code = ch.codePointAt(0);
58
+ h1 = Math.imul(h1 ^ code, 2654435761);
59
+ h2 = Math.imul(h2 ^ code, 1597334677);
60
+ }
61
+ h1 =
62
+ Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
63
+ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
64
+ h2 =
65
+ Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
66
+ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
67
+ return (4294967296 * (2097151 & h2) + (h1 >>> 0))
68
+ .toString(16)
69
+ .padStart(14, '0');
70
+ };
71
+
72
+ /** The last path segment ("a/b/c.jpg" -> "c.jpg"), "/" or "\\" separated. */
73
+ export const baseName = (p) =>
74
+ String(p).split(/[\\/]/).filter(Boolean).at(-1) ?? '';
75
+
76
+ /** The extension with its dot, lower case ("c.JPG" -> ".jpg"), or "". */
77
+ export const extName = (p) => {
78
+ const m = /\.[^./\\]+$/.exec(baseName(p));
79
+ return m ? m[0].toLowerCase() : '';
80
+ };
81
+
82
+ /** ISO text for an epoch (ms) or a date string; null when unreadable. */
83
+ export const isoOf = (value) => {
84
+ if (value === null || value === undefined || value === '') {
85
+ return null;
86
+ }
87
+ const numeric = typeof value === 'string' && /^\d+$/.test(value);
88
+ const date = new Date(numeric ? Number(value) : value);
89
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
90
+ };
91
+
92
+ // "2026-01-01 10:00:00 UTC" is UTC; a time that names no zone is read in
93
+ // this machine's time zone (like WhatsApp and VK exports); other shapes
94
+ // fall back to Date.parse.
95
+ const STAMP =
96
+ /^(\d{4})-(\d\d)-(\d\d)[ T](\d\d):(\d\d)(?::(\d\d))?\s*(UTC|GMT|Z)?$/i;
97
+
98
+ /** ISO text for a LinkedIn export date; null when unreadable. */
99
+ export const linkedinDate = (text) => {
100
+ const m = STAMP.exec(String(text ?? '').trim());
101
+ if (m) {
102
+ const parts = [+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] ?? 0)];
103
+ return (
104
+ m[7] ? new Date(Date.UTC(...parts)) : new Date(...parts)
105
+ ).toISOString();
106
+ }
107
+ return isoOf(String(text ?? '').trim());
108
+ };
109
+
110
+ const ENTITIES = {
111
+ amp: '&',
112
+ lt: '<',
113
+ gt: '>',
114
+ quot: '"',
115
+ apos: "'",
116
+ nbsp: ' ',
117
+ };
118
+
119
+ export const decodeEntities = (text) =>
120
+ text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code) => {
121
+ if (code[0] === '#') {
122
+ const point =
123
+ code[1].toLowerCase() === 'x'
124
+ ? parseInt(code.slice(2), 16)
125
+ : parseInt(code.slice(1), 10);
126
+ return Number.isFinite(point) && point <= 0x10ffff
127
+ ? String.fromCodePoint(point)
128
+ : whole;
129
+ }
130
+ return ENTITIES[code.toLowerCase()] ?? whole;
131
+ });
132
+
133
+ const VOID = new Set(['br', 'img', 'hr', 'input', 'meta', 'link']);
134
+ const TOKEN =
135
+ /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>|([^<]+)/g;
136
+ const ATTRIBUTE = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
137
+
138
+ const attributesOf = (source) => {
139
+ const attrs = {};
140
+ for (const a of source.matchAll(ATTRIBUTE)) {
141
+ attrs[a[1].toLowerCase()] = decodeEntities(a[2] ?? a[3]);
142
+ }
143
+ return attrs;
144
+ };
145
+
146
+ /**
147
+ * A tiny HTML reader for the data exports (no dependency): a tree of
148
+ * `{tag, attrs, children}` and text strings. Tolerant: stray end tags
149
+ * are ignored, unclosed tags are closed at the end.
150
+ */
151
+ export const parseHtml = (html) => {
152
+ const root = { tag: 'root', attrs: {}, children: [] };
153
+ const stack = [root];
154
+ for (const m of html.matchAll(TOKEN)) {
155
+ const top = stack.at(-1);
156
+ if (m[4] !== undefined) {
157
+ top.children.push(decodeEntities(m[4]));
158
+ } else if (m[2] && m[1]) {
159
+ const at = stack.map((n) => n.tag).lastIndexOf(m[2].toLowerCase());
160
+ if (at > 0) {
161
+ stack.length = at;
162
+ }
163
+ } else if (m[2]) {
164
+ const node = {
165
+ tag: m[2].toLowerCase(),
166
+ attrs: attributesOf(m[3]),
167
+ children: [],
168
+ };
169
+ top.children.push(node);
170
+ if (!VOID.has(node.tag) && !m[3].trimEnd().endsWith('/')) {
171
+ stack.push(node);
172
+ }
173
+ }
174
+ }
175
+ return root;
176
+ };
177
+
178
+ export const hasClass = (node, names) => {
179
+ const classes = (node?.attrs?.class ?? '').split(/\s+/);
180
+ return names.some((name) => classes.includes(name));
181
+ };
182
+
183
+ /** Every element under `node` (depth-first) accepted by `test`. */
184
+ export const findAll = (node, test, out = []) => {
185
+ for (const child of node?.children ?? []) {
186
+ if (typeof child !== 'string') {
187
+ if (test(child)) {
188
+ out.push(child);
189
+ }
190
+ findAll(child, test, out);
191
+ }
192
+ }
193
+ return out;
194
+ };
195
+
196
+ /** The text of a node; `skip(node)` leaves a subtree out. */
197
+ export const textOf = (node, skip = null) => {
198
+ if (typeof node === 'string') {
199
+ return node;
200
+ }
201
+ if (skip?.(node)) {
202
+ return '';
203
+ }
204
+ return node.tag === 'br'
205
+ ? '\n'
206
+ : node.children.map((child) => textOf(child, skip)).join('');
207
+ };