From 899b212cbafbc2beadb28f9816cd417b835c8cdd Mon Sep 17 00:00:00 2001 From: Ferdinand Urban Date: Tue, 29 Sep 2026 09:25:06 +0000 Subject: [PATCH] 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) --- src/lib/whatsapp.js | 209 ++++++++++++++++++++++++++++++++++++++++++ test/whatsapp.test.js | 153 +++++++++++++++++++++++++++++++ 2 files changed, 362 insertions(+) create mode 100644 src/lib/whatsapp.js create mode 100644 test/whatsapp.test.js diff --git a/src/lib/whatsapp.js b/src/lib/whatsapp.js new file mode 100644 index 0000000..3b9064d --- /dev/null +++ b/src/lib/whatsapp.js @@ -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; + +// "" bzw. "" +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) }; +} diff --git a/test/whatsapp.test.js b/test/whatsapp.test.js new file mode 100644 index 0000000..5aa238b --- /dev/null +++ b/test/whatsapp.test.js @@ -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', + '[21.09.26, 10:16:00] Ben Muster: Stark!', + 'Zweite Zeile der Nachricht', + '\u200e[22.09.26, 08:00:00] ~\u202fCarla: \u200e', + // Außer der Reihe: im Chat später, zeitlich aber früher + '\u200e[21.09.26, 07:30:00] Ben Muster: Frühaufsteher \u200e', + '\u200e[22.09.26, 08:00:05] ~\u202fCarla: \u200e', + '[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', + '\u200e[28.09.26, 12:00:00] Anna Beispiel: Nächste Woche \u200e', +].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 ', + 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', + '\u200e[23.09.26, 10:03:53] Tom: Autostart \u200e', + ].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: ', + ].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' }); +});