diff --git a/examples/parser-tests/README.md b/examples/parser-tests/README.md index 8d6661e..41328e2 100644 --- a/examples/parser-tests/README.md +++ b/examples/parser-tests/README.md @@ -40,7 +40,43 @@ This directory contains WhatsApp chat export examples in various formats for tes - Japanese media indicators: `<メディアなし>`, `.jpg (ファイル添付)` - Japanese characters in usernames and messages -### 5. `dash-format-test.txt` +### 5. `asian-ampm-format-test.txt` +- **Date Format**: `YYYY/MM/DD, HH:MM am/pm` +- **Example**: `2024/12/01, 10:04 pm` +- **Locale**: Year-first formats with 12-hour clock +- **Features Tested**: + - Asian year-first date format with AM/PM + - Lowercase AM/PM (`am`, `pm`) + - Real-world export style from issue reports + +### 6. `european-ampm-format-test.txt` +- **Date Format**: `DD/MM/YYYY, H:MM AM/PM` +- **Example**: `23/06/2018, 1:55 p.m.` +- **Locale**: Day-first formats with 12-hour clock +- **Features Tested**: + - Day-first date with AM/PM + - AM/PM variants: `p.m.`, `p. m.`, uppercase/lowercase + - Compatibility with parser normalization of AM/PM tokens + +### 7. `dot-time-klo-format-test.txt` +- **Date Format**: `DD.MM.YYYY klo HH.MM.SS` and `DD.MM.YY HH.MM.SS:` +- **Example**: `13.6.2018 klo 21.25.15` +- **Locale**: Finnish and dot-time variants +- **Features Tested**: + - Finnish marker `klo` + - Time using dot separator (`HH.MM` and `HH.MM.SS`) + - Separator after timestamp using `:` + +### 8. `bracketed-year-first-test.txt` +- **Date Format**: `[YYYY/MM/DD, HH:MM:SS]` and `[YYYY/MM/DD H:MM:SS AM/PM]` +- **Example**: `[2018/06/13, 21:25:15]` +- **Locale**: Year-first bracketed variants +- **Features Tested**: + - Bracketed year-first format + - 24-hour and 12-hour variants + - AM/PM normalization inside brackets + +### 9. `dash-format-test.txt` - **Date Format**: `DD-MM-YYYY, HH:MM` - **Example**: `10-12-2024, 14:30` - **Locale**: Alternative European format @@ -76,25 +112,47 @@ The parser supports the following date patterns: - Example: `2024/12/10, 14:30` - Year-first format (Japan, China, Korea) -7. **Bracketed Format**: `[DD/MM/YY, HH:MM:SS]` +7. **Asian Format (12h)**: `YYYY/MM/DD, HH:MM AM/PM` + - Example: `2024/12/01, 10:04 pm` + - Year-first format with 12-hour clock + - Supports uppercase and lowercase AM/PM + +8. **European/Brazilian Format (12h)**: `DD/MM/YY, H:MM AM/PM` + - Example: `23/06/2018, 1:55 p.m.` + - Day-first format with 12-hour clock + - Supports `AM`, `PM`, `a.m.`, `p.m.`, and `p. m.` variants + +9. **Bracketed Format**: `[DD/MM/YY, HH:MM:SS]` - Example: `[10/12/24, 14:30:45]` - Some older WhatsApp versions -8. **iOS Bracketed Format (12h)**: `[DD/MM/YYYY, HH:MM:SS AM/PM]` +10. **iOS Bracketed Format (12h)**: `[DD/MM/YYYY, HH:MM:SS AM/PM]` - Example: `[13/11/2025, 12:25:55 PM]` - iOS WhatsApp exports - Uses 12-hour format with AM/PM - May contain Unicode whitespace (U+202F) before AM/PM - File typically named `_chat.txt` (with underscore prefix) +11. **Bracketed Year-First Format**: `[YYYY/MM/DD, HH:MM:SS]` + - Example: `[2018/06/13, 21:25:15]` + - Supports both 24-hour and AM/PM variants + +12. **Dot-Time + Finnish `klo`**: `DD.MM.YYYY klo HH.MM.SS` + - Example: `13.6.2018 klo 21.25.15` + - Dot-separated time normalized for parsing + ## Test Files ### 1. `german-format-test.txt` (see above) ### 2. `spanish-format-test.txt` (see above) ### 3. `french-format-test.txt` (see above) ### 4. `asian-format-test.txt` (see above) -### 5. `dash-format-test.txt` (see above) -### 6. `ios-format-test.txt` +### 5. `asian-ampm-format-test.txt` (see above) +### 6. `european-ampm-format-test.txt` (see above) +### 7. `dot-time-klo-format-test.txt` (see above) +### 8. `bracketed-year-first-test.txt` (see above) +### 9. `dash-format-test.txt` (see above) +### 10. `ios-format-test.txt` - **Date Format**: `[DD/MM/YYYY, HH:MM:SS AM/PM]` - **Example**: `[13/11/2025, 12:25:55 PM]` - **Locale**: iOS (all locales) diff --git a/examples/parser-tests/asian-ampm-format-test.txt b/examples/parser-tests/asian-ampm-format-test.txt new file mode 100644 index 0000000..0b281a2 --- /dev/null +++ b/examples/parser-tests/asian-ampm-format-test.txt @@ -0,0 +1,5 @@ +2024/12/01, 10:04 pm - Alice: Messages and calls are end-to-end encrypted. Only people in this chat can read, listen to, or share them. Learn more. +2024/12/01, 10:05 pm - Alice: Hello there +2024/12/01, 10:06 pm - Bob: Hi! +2024/12/01, 10:07 pm - Alice: +2024/12/01, 10:08 pm - Bob: Nice diff --git a/examples/parser-tests/bracketed-year-first-test.txt b/examples/parser-tests/bracketed-year-first-test.txt new file mode 100644 index 0000000..bc98604 --- /dev/null +++ b/examples/parser-tests/bracketed-year-first-test.txt @@ -0,0 +1,3 @@ +[2018/06/13, 21:25:15] Ana: mensagem com colchetes +[2018/06/13 1:55:00 p. m.] Bruno: ampm com espaços +[2018/06/13, 01:56:00 AM] Ana: uppercase também funciona diff --git a/examples/parser-tests/dot-time-klo-format-test.txt b/examples/parser-tests/dot-time-klo-format-test.txt new file mode 100644 index 0000000..e51b35d --- /dev/null +++ b/examples/parser-tests/dot-time-klo-format-test.txt @@ -0,0 +1,3 @@ +13.6.2018 klo 21.25.15 - Mika: tämä on testi +13.06.18 21.25.15: Laura: tämä on toinen testi +13.06.2018, 21.25 - Mika: ilman sekunteja diff --git a/examples/parser-tests/european-ampm-format-test.txt b/examples/parser-tests/european-ampm-format-test.txt new file mode 100644 index 0000000..54ccc21 --- /dev/null +++ b/examples/parser-tests/european-ampm-format-test.txt @@ -0,0 +1,4 @@ +23/06/2018, 1:55 p.m. - Loris: one +23/06/2018, 1:56 p. m. - Luke: two +23/06/2018, 1:57 PM - Loris: three +23/06/2018, 1:58 am - Luke: four diff --git a/package-lock.json b/package-lock.json index 4c80228..a1c325f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -709,6 +709,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -2168,7 +2169,6 @@ "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -3435,7 +3435,6 @@ "integrity": "sha512-Vp3zX/qlwerQmHMP6x0Ry1oY7eKKRcOWGc2P59srOp4zcqyn+etJyQpELgOi4+ZSUgteX8Y387NuwruLgGXLUQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", @@ -3475,7 +3474,6 @@ "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "debug": "^4.4.1", @@ -3962,7 +3960,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4013,7 +4010,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5249,7 +5245,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -5824,6 +5821,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -5844,6 +5842,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -5859,6 +5858,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -5869,6 +5869,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -7668,7 +7669,6 @@ "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.0.0" } @@ -8235,7 +8235,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -10779,7 +10778,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -11527,6 +11525,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -11544,6 +11543,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -12039,7 +12039,6 @@ "integrity": "sha512-6qGjWccl5yoyugHt3jTgztJ9Y0JVzyH8/Voc/D8PlLat9pwxQYXz7W1Dpnq5h0/G5GCYGUaDSlYcyk3AMh5A6g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.1", "@semantic-release/error": "^4.0.0", @@ -13198,7 +13197,6 @@ "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -13262,21 +13260,6 @@ } } }, - "node_modules/svelte-check/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -13345,6 +13328,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -13395,6 +13379,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -13409,6 +13394,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -13578,7 +13564,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13701,7 +13686,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -13945,7 +13929,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14039,7 +14022,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/src/lib/components/MediaGallery.svelte b/src/lib/components/MediaGallery.svelte index 5260dd6..0fd5f8f 100644 --- a/src/lib/components/MediaGallery.svelte +++ b/src/lib/components/MediaGallery.svelte @@ -696,13 +696,13 @@ onDestroy(() => { - + - +
{#each filteredMediaParticipants as participant (participant)} { {/if} · {#if lightboxItem.messageTimestamp} - {new Date(lightboxItem.messageTimestamp).toLocaleString()} + {new Date(lightboxItem.messageTimestamp).toLocaleString(getLocale())} {:else} {m.media_gallery_unknown_date()} {/if} diff --git a/src/lib/parser/chat-parser.ts b/src/lib/parser/chat-parser.ts index 97683e6..54d5291 100644 --- a/src/lib/parser/chat-parser.ts +++ b/src/lib/parser/chat-parser.ts @@ -189,6 +189,23 @@ const DATE_PATTERNS = [ ); }, }, + // DD/MM/YY, HH:MM AM/PM - European/Brazilian format (12h) + { + regex: + /^(\d{1,2})\/(\d{1,2})\/(\d{2,4}),?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?[\s\u202F\u00A0]*([AP]M)\s*-\s*/i, + parse: (match: RegExpMatchArray) => { + const [, day, month, year, hours, minutes, seconds, ampm] = match; + return parseDateTime( + parseInt(day, 10), + parseInt(month, 10), + normalizeYear(parseInt(year, 10)), + parseInt(hours, 10), + parseInt(minutes, 10), + seconds ? parseInt(seconds, 10) : 0, + ampm, + ); + }, + }, // DD/MM/YY, HH:MM - European/Brazilian format (24h) - No AM/PM means it's not US format { regex: @@ -253,6 +270,39 @@ const DATE_PATTERNS = [ ); }, }, + // DD.MM.YY HH:MM(:SS): - Dot date format with colon separator after time + { + regex: + /^(\d{1,2})\.(\d{1,2})\.(\d{2,4}),?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*:\s*/, + parse: (match: RegExpMatchArray) => { + const [, day, month, year, hours, minutes, seconds] = match; + return parseDateTime( + parseInt(day, 10), + parseInt(month, 10), + normalizeYear(parseInt(year, 10)), + parseInt(hours, 10), + parseInt(minutes, 10), + seconds ? parseInt(seconds, 10) : 0, + ); + }, + }, + // YYYY/MM/DD, HH:MM AM/PM - Asian format with 12-hour time + { + regex: + /^(\d{4})\/(\d{1,2})\/(\d{1,2}),?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?[\s\u202F\u00A0]*([AP]M)\s*-\s*/i, + parse: (match: RegExpMatchArray) => { + const [, year, month, day, hours, minutes, seconds, ampm] = match; + return parseDateTime( + parseInt(day, 10), + parseInt(month, 10), + parseInt(year, 10), + parseInt(hours, 10), + parseInt(minutes, 10), + seconds ? parseInt(seconds, 10) : 0, + ampm, + ); + }, + }, // YYYY/MM/DD, HH:MM - Asian format { regex: @@ -269,6 +319,39 @@ const DATE_PATTERNS = [ ); }, }, + // [YYYY/MM/DD, HH:MM:SS AM/PM] - Bracketed year-first format with 12-hour time + { + regex: + /^\[(\d{4})\/(\d{1,2})\/(\d{1,2}),?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?[\s\u202F\u00A0]*([AP]M)\]\s*/i, + parse: (match: RegExpMatchArray) => { + const [, year, month, day, hours, minutes, seconds, ampm] = match; + return parseDateTime( + parseInt(day, 10), + parseInt(month, 10), + parseInt(year, 10), + parseInt(hours, 10), + parseInt(minutes, 10), + seconds ? parseInt(seconds, 10) : 0, + ampm, + ); + }, + }, + // [YYYY/MM/DD, HH:MM:SS] - Bracketed year-first format (24-hour) + { + regex: + /^\[(\d{4})\/(\d{1,2})\/(\d{1,2}),?\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\]\s*/, + parse: (match: RegExpMatchArray) => { + const [, year, month, day, hours, minutes, seconds] = match; + return parseDateTime( + parseInt(day, 10), + parseInt(month, 10), + parseInt(year, 10), + parseInt(hours, 10), + parseInt(minutes, 10), + seconds ? parseInt(seconds, 10) : 0, + ); + }, + }, // [DD/MM/YYYY, HH:MM:SS AM/PM] - iOS bracketed format with 12-hour time // Note: May contain Unicode whitespace characters (U+202F, U+00A0) before AM/PM // MUST be checked before non-AM/PM bracketed format @@ -306,6 +389,98 @@ const DATE_PATTERNS = [ }, ]; +type DatePattern = (typeof DATE_PATTERNS)[number]; + +const US_AMPM_PATTERN_INDEX = 0; +const DMY_AMPM_PATTERN_INDEX = 1; + +function normalizeLineForParsing(line: string): string { + return line + .replace(/^[\u200E\u200F]+/, '') + .replace(/[\u202F\u00A0]/g, ' ') + .replace(/\s+klo\s+/gi, ' ') + .replace( + /(\s\d{1,2})\.(\d{2})(?:\.(\d{2}))?(?=\s*(?:-|\]|:))/g, + (_, h, m, s) => `${h}:${m}${s ? `:${s}` : ''}`, + ) + .replace( + /\b([ap])\s*\.?\s*m\.?\b/gi, + (_, period: string) => `${period.toUpperCase()}M`, + ); +} + +function detectDatePatternOrder(lines: string[]): DatePattern[] { + const scores = new Array(DATE_PATTERNS.length).fill(0); + const sampleLines = lines.slice(0, 200); + let dayFirstSlashAmPmEvidence = 0; + let monthFirstSlashAmPmEvidence = 0; + + for (const line of sampleLines) { + if (!line.trim()) { + continue; + } + + const normalizedLine = normalizeLineForParsing(line); + + const slashAmPmMatch = normalizedLine.match( + /^(\d{1,2})\/(\d{1,2})\/(\d{2,4}),?\s+\d{1,2}:\d{2}(?::\d{2})?\s*[AP]M\s*-\s*/i, + ); + + if (slashAmPmMatch) { + const first = parseInt(slashAmPmMatch[1], 10); + const second = parseInt(slashAmPmMatch[2], 10); + + if (first > 12 && second <= 12) { + dayFirstSlashAmPmEvidence++; + } else if (second > 12 && first <= 12) { + monthFirstSlashAmPmEvidence++; + } + } + + for (let index = 0; index < DATE_PATTERNS.length; index++) { + const pattern = DATE_PATTERNS[index]; + if (pattern.regex.test(normalizedLine)) { + scores[index]++; + } + } + } + + let bestIndex = -1; + let bestScore = 0; + const tiedBestIndices: number[] = []; + + for (let index = 0; index < scores.length; index++) { + if (scores[index] > bestScore) { + bestScore = scores[index]; + bestIndex = index; + tiedBestIndices.length = 0; + tiedBestIndices.push(index); + } else if (scores[index] === bestScore && bestScore > 0) { + tiedBestIndices.push(index); + } + } + + if ( + tiedBestIndices.includes(US_AMPM_PATTERN_INDEX) && + tiedBestIndices.includes(DMY_AMPM_PATTERN_INDEX) + ) { + if (dayFirstSlashAmPmEvidence > monthFirstSlashAmPmEvidence) { + bestIndex = DMY_AMPM_PATTERN_INDEX; + } else if (monthFirstSlashAmPmEvidence > dayFirstSlashAmPmEvidence) { + bestIndex = US_AMPM_PATTERN_INDEX; + } + } + + if (bestIndex === -1 || bestScore === 0) { + return DATE_PATTERNS; + } + + return [ + DATE_PATTERNS[bestIndex], + ...DATE_PATTERNS.filter((_, index) => index !== bestIndex), + ]; +} + function normalizeYear(year: number): number { if (year < 100) { return year + (year > 50 ? 1900 : 2000); @@ -412,17 +587,75 @@ function isSystemMessage(content: string): boolean { ); } -function parseLine(line: string): { +/** + * Compute the position in `originalLine` that corresponds to the end of the + * timestamp prefix whose normalized form has length `normalizedMatchLength`. + * + * `normalizeLineForParsing` makes five transforms; three of them change length: + * 1. Strips leading U+200E / U+200F directional marks (shortens) + * 2. Collapses `\s+klo\s+` → single space (shortens) + * 3. Rewrites `a.m.`/`p.m.` variants → `AM`/`PM` (shortens) + * The remaining two replace characters 1-for-1 (U+202F/U+00A0 → space; dot + * time separators → colon), so they do not shift positions. + * This function walks both strings in parallel to account for the differences. + */ +function getOriginalRemainderOffset( + originalLine: string, + normalizedMatchLength: number, +): number { + // Step 1: skip leading directional marks that normalization strips + const leadingMarks = originalLine.match(/^[\u200E\u200F]+/); + let origPos = leadingMarks ? leadingMarks[0].length : 0; + let normPos = 0; + + while (normPos < normalizedMatchLength && origPos < originalLine.length) { + const remaining = originalLine.substring(origPos); + + // Step 3: \s+klo\s+ → single space in normalized + const kloMatch = remaining.match(/^\s+klo\s+/i); + if (kloMatch) { + normPos += 1; // one space in normalized + origPos += kloMatch[0].length; + continue; + } + + // Step 5: a.m./p.m. variants → AM/PM in normalized (2 chars) + const ampmMatch = remaining.match(/^([ap])\s*\.?\s*m\.?\b/i); + if (ampmMatch) { + normPos += 2; + origPos += ampmMatch[0].length; + continue; + } + + // Steps 2 and 4 are 1-for-1 character replacements + normPos++; + origPos++; + } + + return origPos; +} + +function parseLine( + line: string, + datePatterns: DatePattern[] = DATE_PATTERNS, +): { timestamp: Date; sender: string; content: string; pattern: RegExp; } | null { - for (const { regex, parse } of DATE_PATTERNS) { - const match = line.match(regex); + const normalizedLine = normalizeLineForParsing(line); + + for (const { regex, parse } of datePatterns) { + const match = normalizedLine.match(regex); if (match) { const timestamp = parse(match); - const remainder = line.substring(match[0].length); + + // Derive remainder from the original line so that sender/content + // preserve the exact exported text (and ID hashes stay stable). + // Normalization is used only for timestamp detection and parsing. + const splitOffset = getOriginalRemainderOffset(line, match[0].length); + const remainder = line.substring(splitOffset); // Try to extract sender and content // Format is usually: "Sender Name: message content" @@ -492,6 +725,7 @@ export function parseChat( filename: string = 'WhatsApp Chat', ): ParsedChat { const lines = content.split(/\r?\n/); + const datePatterns = detectDatePatternOrder(lines); const messages: ChatMessage[] = []; const participantsSet = new Set(); const usedIds = new Set(); // Track used IDs to handle collisions @@ -516,7 +750,7 @@ export function parseChat( for (const line of lines) { if (!line.trim()) continue; - const parsed = parseLine(line); + const parsed = parseLine(line, datePatterns); if (parsed) { // This is a new message - push previous one first