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) };
}
+153
View File
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
clipsInRange,
collectClips,
parseChat,
previousWorkWeek,
timestampFromFileName,
} from '../src/lib/whatsapp.js';
// Ausgedachte Zeilen im Format eines deutschen iPhone-Exports mit Medien,
// samt der unsichtbaren Richtungszeichen (\u200e), die WhatsApp einfügt.
const IOS_CHAT = [
'[21.09.26, 09:00:00] Anna Beispiel: Guten Morgen',
'\u200e[21.09.26, 10:15:30] Anna Beispiel: Ordnung am Platz \u200e<Anhang: 00000101-VIDEO-2026-09-21-10-15-30.mp4>',
'[21.09.26, 10:16:00] Ben Muster: Stark!',
'Zweite Zeile der Nachricht',
'\u200e[22.09.26, 08:00:00] ~\u202fCarla: \u200e<Anhang: 00000102-VIDEO-2026-09-22-08-00-00.mp4>',
// Außer der Reihe: im Chat später, zeitlich aber früher
'\u200e[21.09.26, 07:30:00] Ben Muster: Frühaufsteher \u200e<Anhang: 00000103-VIDEO-2026-09-21-07-30-00.mp4>',
'\u200e[22.09.26, 08:00:05] ~\u202fCarla: \u200e<Anhang: 00000104-PHOTO-2026-09-22-08-00-05.jpg>',
'[22.09.26, 09:00:00] Ben Muster: \u200eDiese Nachricht wurde gelöscht.',
'\u200e[23.09.26, 11:00:00] Anna Beispiel: Fehlt im Export \u200e<Anhang: 00000105-VIDEO-2026-09-23-11-00-00.mp4>',
'\u200e[28.09.26, 12:00:00] Anna Beispiel: Nächste Woche \u200e<Anhang: 00000106-VIDEO-2026-09-28-12-00-00.mp4>',
].join('\r\n');
const IOS_FILES = [
{ name: '_chat.txt', size: 1000 },
{ name: 'WhatsApp Chat/00000101-VIDEO-2026-09-21-10-15-30.mp4', size: 5000 },
{ name: 'WhatsApp Chat/00000102-VIDEO-2026-09-22-08-00-00.mp4', size: 7000 },
{ name: 'WhatsApp Chat/00000103-VIDEO-2026-09-21-07-30-00.mp4', size: 6000 },
{ name: 'WhatsApp Chat/00000104-PHOTO-2026-09-22-08-00-05.jpg', size: 100 },
{ name: 'WhatsApp Chat/00000106-VIDEO-2026-09-28-12-00-00.mp4', size: 8000 },
// Im Chat nicht erwähnt, Zeitpunkt nur aus dem Dateinamen
{ name: 'WhatsApp Chat/00000107-VIDEO-2026-09-22-15-00-00.mp4', size: 9000 },
];
test('iPhone-Export: Nachrichten, Absender, Zeitstempel und Anhänge', () => {
const messages = parseChat(IOS_CHAT);
assert.equal(messages.length, 9);
assert.deepEqual(messages[1], {
index: 1,
timestamp: '2026-09-21T10:15:30',
sender: 'Anna Beispiel',
text: 'Ordnung am Platz <Anhang: 00000101-VIDEO-2026-09-21-10-15-30.mp4>',
attachments: ['00000101-VIDEO-2026-09-21-10-15-30.mp4'],
});
assert.equal(messages[2].text, 'Stark!\nZweite Zeile der Nachricht');
assert.equal(messages[3].sender, '~ Carla');
assert.deepEqual(messages[6].attachments, []);
});
test('Videos werden nach Zeitstempel sortiert, nicht nach Chatreihenfolge', () => {
const { clips } = collectClips(parseChat(IOS_CHAT), IOS_FILES);
assert.deepEqual(
clips.map((clip) => clip.file),
[
'00000103-VIDEO-2026-09-21-07-30-00.mp4',
'00000101-VIDEO-2026-09-21-10-15-30.mp4',
'00000102-VIDEO-2026-09-22-08-00-00.mp4',
'00000107-VIDEO-2026-09-22-15-00-00.mp4',
'00000106-VIDEO-2026-09-28-12-00-00.mp4',
],
);
});
test('Clips tragen Absender, Beschriftung und Pfad', () => {
const { clips } = collectClips(parseChat(IOS_CHAT), IOS_FILES);
const clip = clips.find((c) => c.file.startsWith('00000101'));
assert.equal(clip.sender, 'Anna Beispiel');
assert.equal(clip.caption, 'Ordnung am Platz');
assert.equal(clip.path, 'WhatsApp Chat/00000101-VIDEO-2026-09-21-10-15-30.mp4');
const orphan = clips.find((c) => c.file.startsWith('00000107'));
assert.equal(orphan.sender, null);
});
test('Im Chat genannte, aber fehlende Videos werden gemeldet', () => {
const { missing } = collectClips(parseChat(IOS_CHAT), IOS_FILES);
assert.deepEqual(missing.map((m) => m.file), ['00000105-VIDEO-2026-09-23-11-00-00.mp4']);
});
test('Bytegleiche Videos werden als Duplikat markiert', () => {
const chat = [
'\u200e[23.09.26, 10:03:49] Tom: Autostart \u200e<Anhang: 00000201-VIDEO-2026-09-23-10-03-49.mp4>',
'\u200e[23.09.26, 10:03:53] Tom: Autostart \u200e<Anhang: 00000202-VIDEO-2026-09-23-10-03-53.mp4>',
].join('\n');
const files = [
{ name: '00000201-VIDEO-2026-09-23-10-03-49.mp4', size: 4242 },
{ name: '00000202-VIDEO-2026-09-23-10-03-53.mp4', size: 4242 },
];
const { clips } = collectClips(parseChat(chat), files);
assert.equal(clips[0].duplicateOf, undefined);
assert.equal(clips[1].duplicateOf, '00000201-VIDEO-2026-09-23-10-03-49.mp4');
});
test('Android-Export (deutsch) mit Systemmeldung', () => {
const chat = [
'21.09.26, 10:15 - Anna: VID-20260921-WA0003.mp4 (Datei angehängt)',
'Ordnung am Platz',
'21.09.26, 10:20 - Ben hat die Gruppe verlassen',
'21.09.26, 11:00 - Ben: Hallo',
].join('\n');
const messages = parseChat(chat);
assert.equal(messages.length, 3);
assert.equal(messages[0].timestamp, '2026-09-21T10:15:00');
assert.deepEqual(messages[0].attachments, ['VID-20260921-WA0003.mp4']);
assert.equal(messages[1].sender, null);
const { clips } = collectClips(messages, [{ name: 'VID-20260921-WA0003.mp4', size: 1 }]);
assert.equal(clips[0].caption, 'Ordnung am Platz');
});
test('Englischer Export mit Monat zuerst und AM/PM', () => {
const messages = parseChat(
[
'9/21/26, 4:36\u202fPM - Anna: VID-20260921-WA0001.mp4 (file attached)',
'[9/22/26, 12:05:00 AM] Ben: <attached: 00000001-VIDEO-2026-09-22-00-05-00.mp4>',
].join('\n'),
);
assert.equal(messages[0].timestamp, '2026-09-21T16:36:00');
assert.equal(messages[1].timestamp, '2026-09-22T00:05:00');
assert.deepEqual(messages[1].attachments, ['00000001-VIDEO-2026-09-22-00-05-00.mp4']);
});
test('Schrägstrich-Datum mit Tag über 12 gilt als Tag zuerst', () => {
const messages = parseChat('24/09/2026, 16:36 - Anna: Hallo\n05/09/2026, 10:00 - Ben: Hi');
assert.equal(messages[0].timestamp, '2026-09-24T16:36:00');
assert.equal(messages[1].timestamp, '2026-09-05T10:00:00');
});
test('Zeitstempel aus Dateinamen', () => {
assert.equal(timestampFromFileName('00001338-VIDEO-2026-09-24-16-36-18.mp4'), '2026-09-24T16:36:18');
assert.equal(timestampFromFileName('VID-20260924-WA0012.mp4'), '2026-09-24T00:00:00');
assert.equal(timestampFromFileName('film.mp4'), null);
});
test('Zeitraumfilter schließt beide Tage ein', () => {
const { clips } = collectClips(parseChat(IOS_CHAT), IOS_FILES);
const week = clipsInRange(clips, '2026-09-21', '2026-09-25');
assert.equal(week.length, 4);
assert.ok(week.every((clip) => clip.timestamp < '2026-09-26'));
});
test('Vergangene Woche ist Montag bis Freitag der Vorwoche', () => {
// Dienstag, 29.09.2026
assert.deepEqual(previousWorkWeek(new Date(2026, 8, 29)), { from: '2026-09-21', to: '2026-09-25' });
// Montag, 28.09.2026
assert.deepEqual(previousWorkWeek(new Date(2026, 8, 28)), { from: '2026-09-21', to: '2026-09-25' });
// Sonntag, 27.09.2026 gehört noch zur Woche ab 21.09.
assert.deepEqual(previousWorkWeek(new Date(2026, 8, 27)), { from: '2026-09-14', to: '2026-09-18' });
// Über den Jahreswechsel
assert.deepEqual(previousWorkWeek(new Date(2027, 0, 5)), { from: '2026-12-28', to: '2027-01-01' });
});