WhatsApp-Chatexport einlesen und Videos chronologisch ordnen

Erkennt iPhone- und Android-Exporte auf Deutsch und Englisch, ordnet
Videos nach Zeitstempel statt nach Chatreihenfolge, meldet fehlende
Dateien, markiert doppelt gepostete Videos und filtert auf Montag bis
Freitag der Vorwoche.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ferdi2goandClaude Opus 5.5 committed 2026-09-29 09:25:22 +00:00
1 parent f7d249f28b
commit 899b212cba
2 files changed
+362

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
// Liest WhatsApp-Chatexporte (iPhone und Android, deutsch und englisch) und
// ordnet die Videos des Exports chronologisch. Läuft im Browser und in Node.
// Unsichtbare Richtungszeichen, die WhatsApp vor Zeilen und Namen setzt.
const INVISIBLE = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
const ODD_SPACES = /[\u00a0\u202f]/g;
// iPhone: "[24.09.26, 16:36:19] Name: Text"
const IOS_HEADER =
/^\[(\d{1,2})([./])(\d{1,2})\2(\d{2,4}),? (\d{1,2}):(\d{2})(?::(\d{2}))?(?: ?([AP]M))?\] (.*)$/i;
// Android: "24.09.26, 16:36 - Name: Text"
const ANDROID_HEADER =
/^(\d{1,2})([./])(\d{1,2})\2(\d{2,4}),? (\d{1,2}):(\d{2})(?::(\d{2}))?(?: ?([AP]M))? - (.*)$/i;
// "<Anhang: 00001338-VIDEO-2026-09-24-16-36-19.mp4>" bzw. "<attached: …>"
const TAGGED_ATTACHMENT = /<[^<>:]{1,40}:\s*([^<>]+?\.[A-Za-z0-9]{2,5})>/g;
// "VID-20260924-WA0012.mp4 (Datei angehängt)" bzw. "(file attached)"
const ANDROID_ATTACHMENT = /([^\s<>]+\.[A-Za-z0-9]{2,5}) \((?:Datei angehängt|file attached)\)/g;
const VIDEO_EXTENSION = /\.(mp4|mov|m4v|3gp|avi|mkv|webm)$/i;
const pad = (value) => String(value).padStart(2, '0');
function cleanLine(line) {
return line.replace(INVISIBLE, '').replace(ODD_SPACES, ' ');
}
export function isVideoFile(name) {
return VIDEO_EXTENSION.test(name);
}
export function baseName(path) {
return path.split(/[\\/]/).pop();
}
// Bei Datumsangaben mit Schrägstrich ist die Reihenfolge nicht eindeutig
// (US: M/D, sonst D/M). Ein Tag über 12 irgendwo im Chat entscheidet.
function detectDayFirst(lines) {
for (const line of lines) {
const match = line.match(IOS_HEADER) || line.match(ANDROID_HEADER);
if (match && match[2] === '/' && Number(match[1]) > 12) return true;
}
return false;
}
function toTimestamp(match, dayFirst) {
const [, first, separator, second, rawYear, rawHour, minute, second_ = '0', meridiem] = match;
let day = Number(first);
let month = Number(second);
if (separator === '/' && !dayFirst) [day, month] = [month, day];
let year = Number(rawYear);
if (year < 100) year += 2000;
let hour = Number(rawHour);
if (meridiem) {
const pm = meridiem.toUpperCase() === 'PM';
if (hour === 12) hour = pm ? 12 : 0;
else if (pm) hour += 12;
}
if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23) return null;
return `${year}-${pad(month)}-${pad(day)}T${pad(hour)}:${minute}:${pad(second_)}`;
}
function splitSender(rest) {
const index = rest.indexOf(': ');
if (index > 0) return { sender: rest.slice(0, index).trim(), text: rest.slice(index + 2) };
if (rest.endsWith(':')) return { sender: rest.slice(0, -1).trim(), text: '' };
// Systemmeldung ohne Absender, z. B. "Max hat die Gruppe verlassen"
return { sender: null, text: rest };
}
export function findAttachments(text) {
const names = [];
for (const pattern of [TAGGED_ATTACHMENT, ANDROID_ATTACHMENT]) {
for (const match of text.matchAll(pattern)) names.push(match[1].trim());
}
return names;
}
function captionOf(text) {
return text.replace(TAGGED_ATTACHMENT, '').replace(ANDROID_ATTACHMENT, '').trim();
}
export function parseChat(rawText) {
const lines = rawText.replace(/\r\n?/g, '\n').split('\n').map(cleanLine);
const dayFirst = detectDayFirst(lines);
const messages = [];
for (const line of lines) {
const match = line.match(IOS_HEADER) || line.match(ANDROID_HEADER);
const timestamp = match && toTimestamp(match, dayFirst);
if (timestamp) {
messages.push({ index: messages.length, timestamp, ...splitSender(match[9]) });
} else if (messages.length > 0) {
messages[messages.length - 1].text += `\n${line}`;
}
}
return messages.map((message) => ({
...message,
text: message.text.trim(),
attachments: findAttachments(message.text),
}));
}
// Fallback für Videos ohne Chatzeile: der Dateiname trägt den Zeitpunkt.
export function timestampFromFileName(name) {
const ios = name.match(/(\d{4})-(\d{2})-(\d{2})-(\d{2})-(\d{2})-(\d{2})\.\w+$/);
if (ios) return `${ios[1]}-${ios[2]}-${ios[3]}T${ios[4]}:${ios[5]}:${ios[6]}`;
const android = name.match(/-(\d{4})(\d{2})(\d{2})-WA\d+/);
if (android) return `${android[1]}-${android[2]}-${android[3]}T00:00:00`;
return null;
}
function compareClips(a, b) {
return (
a.timestamp.localeCompare(b.timestamp) ||
a.order - b.order ||
a.file.localeCompare(b.file)
);
}
// Doppelt gepostete Videos sind bytegleich; die Dateigröße genügt als Merkmal.
function markDuplicates(clips) {
const firstBySize = new Map();
for (const clip of clips) {
if (!clip.size) continue;
const first = firstBySize.get(clip.size);
if (first) clip.duplicateOf = first.file;
else firstBySize.set(clip.size, clip);
}
}
// files: [{ name, size }] aus dem Export (Pfade erlaubt).
// Liefert die Videos chronologisch, dazu im Chat genannte, aber fehlende Dateien.
export function collectClips(messages, files) {
const videos = new Map();
for (const file of files) {
if (isVideoFile(file.name)) videos.set(baseName(file.name), file);
}
const clips = [];
const missing = [];
const used = new Set();
for (const message of messages) {
for (const attachment of message.attachments) {
if (!isVideoFile(attachment)) continue;
const name = baseName(attachment);
const file = videos.get(name);
if (!file) {
missing.push({ file: name, timestamp: message.timestamp, sender: message.sender });
continue;
}
if (used.has(name)) continue;
used.add(name);
clips.push({
file: name,
path: file.name,
size: file.size ?? 0,
timestamp: message.timestamp,
sender: message.sender,
caption: captionOf(message.text),
order: message.index,
});
}
}
for (const [name, file] of videos) {
if (used.has(name)) continue;
const timestamp = timestampFromFileName(name);
if (!timestamp) continue;
clips.push({
file: name,
path: file.name,
size: file.size ?? 0,
timestamp,
sender: null,
caption: '',
order: Number.MAX_SAFE_INTEGER,
});
}
clips.sort(compareClips);
markDuplicates(clips);
return { clips, missing };
}
// from/to als "JJJJ-MM-TT", beide Tage eingeschlossen.
export function clipsInRange(clips, from, to) {
return clips.filter((clip) => {
const day = clip.timestamp.slice(0, 10);
return (!from || day >= from) && (!to || day <= to);
});
}
export function toDateString(date) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
// Montag bis Freitag der Kalenderwoche vor der von `today`.
export function previousWorkWeek(today = new Date()) {
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const daysSinceMonday = (date.getDay() + 6) % 7;
const monday = new Date(date);
monday.setDate(date.getDate() - daysSinceMonday - 7);
const friday = new Date(monday);
friday.setDate(monday.getDate() + 4);
return { from: toDateString(monday), to: toDateString(friday) };
}