Files
Haven/src/importDiscord.js
T

307 lines
10 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── Discord Import Parser ─────────────────────────────────
// Supports:
// 1. DiscordChatExporter JSON (single channel)
// 2. DiscordChatExporter ZIP (multi-channel)
// 3. Discord official data package ZIP (Settings → Privacy → Request Data)
//
// Returns: { format, serverName, channels: [{ name, topic, category, messageCount, messages }] }
const fs = require('fs');
const path = require('path');
const { unescapeDiscord } = require('./discordText');
// ── Public entry point ──────────────────────────────────────
function parseDiscordExport(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.json') return parseSingleJson(filePath);
if (ext === '.zip') return parseZip(filePath);
throw new Error('Unsupported file type. Upload a .json or .zip file.');
}
// ── Single JSON (DiscordChatExporter) ───────────────────────
function parseSingleJson(filePath) {
const raw = fs.readFileSync(filePath, 'utf-8');
let data;
try { data = JSON.parse(raw); }
catch { throw new Error('File is not valid JSON.'); }
// Standard DCE format: { guild, channel, messages }
if (data.guild && data.channel && Array.isArray(data.messages)) {
return buildDCEResult(data);
}
// Array of exports (rare but possible)
if (Array.isArray(data)) {
const channels = [];
let serverName = 'Unknown Server';
for (const item of data) {
if (item.guild && item.channel && Array.isArray(item.messages)) {
const r = buildDCEResult(item);
serverName = r.serverName;
channels.push(...r.channels);
}
}
if (channels.length > 0) return { format: 'DiscordChatExporter', serverName, channels };
}
throw new Error('Unrecognized JSON format. Expected a DiscordChatExporter export.');
}
// ── ZIP file (detect DCE vs official package) ───────────────
function parseZip(filePath) {
const AdmZip = require('adm-zip');
let zip;
try { zip = new AdmZip(filePath); }
catch { throw new Error('Could not open ZIP file. It may be corrupt.'); }
const entries = zip.getEntries();
const jsonFiles = entries.filter(e => !e.isDirectory && e.entryName.endsWith('.json')
&& !e.entryName.startsWith('__MACOSX'));
const csvEntries = entries.filter(e => !e.isDirectory &&
/messages[/\\]c?\d+[/\\]messages\.csv$/i.test(e.entryName));
// DiscordChatExporter ZIPs contain per-channel .json files
if (jsonFiles.length > 0 && csvEntries.length === 0) {
return parseDCEZip(zip, jsonFiles);
}
// Discord official data package has messages/cXXXX/messages.csv
if (csvEntries.length > 0) {
return parseOfficialZip(zip, entries);
}
throw new Error('Could not detect export format inside ZIP. Expected DiscordChatExporter JSON files or a Discord data package.');
}
// ── DCE ZIP (multiple .json files) ──────────────────────────
function parseDCEZip(zip, jsonFiles) {
const channels = [];
let serverName = 'Unknown Server';
for (const entry of jsonFiles) {
try {
const raw = zip.readAsText(entry);
const data = JSON.parse(raw);
if (data.guild && data.channel && Array.isArray(data.messages)) {
const r = buildDCEResult(data);
serverName = r.serverName;
channels.push(...r.channels);
}
} catch (err) {
// Untrusted file: skip it, but say so, or the channel just goes missing from the import.
console.warn('[import] Skipped unreadable file in export:', entry.entryName, err.message);
}
}
if (channels.length === 0) {
throw new Error('No valid DiscordChatExporter channels found in the ZIP.');
}
return { format: 'DiscordChatExporter', serverName, channels };
}
// ── Discord official data package ───────────────────────────
function parseOfficialZip(zip, entries) {
let serverName = 'Discord Import';
// Try to grab the server name from servers/*/guild.json
const guildJsons = entries.filter(e =>
/servers[/\\]\d+[/\\]guild\.json$/i.test(e.entryName) && !e.isDirectory);
if (guildJsons.length > 0) {
try {
const g = JSON.parse(zip.readAsText(guildJsons[0]));
if (g.name) serverName = g.name;
} catch { /* unreadable guild.json: keep the default server name */ }
}
// Build channel map from channel.json files
const channelJsons = entries.filter(e =>
/messages[/\\]c?\d+[/\\]channel\.json$/i.test(e.entryName) && !e.isDirectory);
const csvEntries = entries.filter(e =>
/messages[/\\]c?\d+[/\\]messages\.csv$/i.test(e.entryName) && !e.isDirectory);
const channelMap = {};
for (const entry of channelJsons) {
try {
const dir = path.posix.dirname(entry.entryName.replace(/\\/g, '/'));
const info = JSON.parse(zip.readAsText(entry));
channelMap[dir] = {
discordId: info.id || dir,
name: info.name || 'unknown-channel',
topic: '',
category: null,
messages: [],
messageCount: 0
};
if (info.guild?.name) serverName = info.guild.name;
} catch (err) {
console.warn('[import] Skipped unreadable channel file in export:', entry.entryName, err.message);
}
}
// Parse CSV files
for (const entry of csvEntries) {
const dir = path.posix.dirname(entry.entryName.replace(/\\/g, '/'));
const ch = channelMap[dir];
if (!ch) continue;
try {
const csv = zip.readAsText(entry);
const msgs = parseCSV(csv);
ch.messages = msgs;
ch.messageCount = msgs.length;
} catch (err) {
console.warn('[import] Skipped unreadable messages file in export:', entry.entryName, err.message);
}
}
const channels = Object.values(channelMap).filter(c => c.messageCount > 0);
if (channels.length === 0) {
throw new Error('No channels with messages found in the data package.');
}
return { format: 'Discord Data Package', serverName, channels };
}
// ── Build a result from a single DCE JSON object ────────────
function buildDCEResult(data) {
const messages = [];
for (const msg of data.messages) {
// Only import regular messages and replies
const type = msg.type || 'Default';
if (type !== 'Default' && type !== 'Reply') continue;
let content = unescapeDiscord(msg.content || '');
// Append attachment links
if (Array.isArray(msg.attachments)) {
for (const a of msg.attachments) {
const name = a.fileName || a.filename || 'file';
const url = a.url || '';
content += `\n📎 ${url ? `[${name}](${url})` : name}`;
}
}
content = content.trim();
if (!content) continue;
messages.push({
discordId: msg.id,
author: msg.author?.nickname || msg.author?.name || 'Unknown',
authorId: msg.author?.id || null,
authorAvatar: msg.author?.avatarUrl || null,
isBot: msg.author?.isBot || false,
content,
timestamp: msg.timestamp,
isPinned: msg.isPinned || false,
reactions: (msg.reactions || []).map(r => ({
emoji: importedReactionEmoji(r.emoji),
count: r.count || 1
})).filter(r => isReactionEmoji(r.emoji)),
replyTo: msg.reference?.messageId || null
});
}
return {
format: 'DiscordChatExporter',
serverName: data.guild?.name || 'Unknown Server',
channels: [{
discordId: data.channel?.id || null,
name: data.channel?.name || 'unknown',
topic: data.channel?.topic || '',
category: data.channel?.category || null,
messageCount: messages.length,
messages
}]
};
}
// ── CSV parser for official data package ────────────────────
// Format: ID,Timestamp,Contents,Attachments
function parseCSV(csvText) {
const lines = csvText.split('\n');
if (lines.length < 2) return [];
const messages = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
const fields = splitCSVLine(line);
if (fields.length < 3) continue;
const [id, timestamp, contents, attachments] = fields;
let content = contents || '';
// Append any attachment URLs
if (attachments) {
const urls = attachments.split(' ').filter(u => u.startsWith('http'));
for (const url of urls) {
const name = url.split('/').pop()?.split('?')[0] || 'file';
content += `\n📎 [${name}](${url})`;
}
}
content = content.trim();
if (!content) continue;
messages.push({
discordId: id,
author: 'You', // Official package only has your own messages
authorId: null,
isBot: false,
content,
timestamp,
isPinned: false,
reactions: [],
replyTo: null
});
}
return messages;
}
// ── RFC 4180 CSV line splitter ──────────────────────────────
function splitCSVLine(line) {
const fields = [];
let cur = '';
let inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (inQ) {
if (ch === '"') {
if (i + 1 < line.length && line[i + 1] === '"') { cur += '"'; i++; }
else inQ = false;
} else {
cur += ch;
}
} else {
if (ch === '"') inQ = true;
else if (ch === ',') { fields.push(cur); cur = ''; }
else cur += ch;
}
}
fields.push(cur);
return fields;
}
// Same rule the live add-reaction handler applies: a Unicode emoji or a
// :custom_name:, at most 32 characters. Anything else is dropped on import.
const REACTION_EMOJI_RE = /^[\p{Emoji}\p{Emoji_Component}️‍]+$/u;
const CUSTOM_REACTION_RE = /^:[a-zA-Z0-9_-]{1,30}:$/;
function isReactionEmoji(emoji) {
return typeof emoji === 'string' && emoji.length >= 1 && emoji.length <= 32
&& (REACTION_EMOJI_RE.test(emoji) || CUSTOM_REACTION_RE.test(emoji));
}
// Discord exports name a server's own emoji bare ("pepe"). Haven writes those
// as :pepe:, so they survive the check above instead of being dropped.
function importedReactionEmoji(raw) {
const name = typeof raw === 'string' ? raw : (raw && typeof raw.name === 'string' ? raw.name : '');
if (/^[a-zA-Z0-9_-]{1,30}$/.test(name) && !REACTION_EMOJI_RE.test(name)) return `:${name}:`;
return name || '\u2753';
}
module.exports = { parseDiscordExport, isReactionEmoji, importedReactionEmoji };