chat-export 0.9.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
main.py
ADDED
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import difflib
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import time
|
|
6
|
+
import traceback
|
|
7
|
+
import zipfile
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Optional
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import webbrowser
|
|
14
|
+
from pathlib import Path, PureWindowsPath, PurePosixPath
|
|
15
|
+
# Attempt to import PyObjC modules for macOS file dialog support
|
|
16
|
+
# for this to work, you need to pip install PyObjC
|
|
17
|
+
if sys.platform == 'darwin':
|
|
18
|
+
try:
|
|
19
|
+
from AppKit import NSOpenPanel, NSApplication, NSApp
|
|
20
|
+
import objc
|
|
21
|
+
pyobjc_available = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
pyobjc_available = False
|
|
24
|
+
else:
|
|
25
|
+
pyobjc_available = False
|
|
26
|
+
|
|
27
|
+
# Attempt to import pywin32 modules for Windows file dialog support
|
|
28
|
+
if sys.platform == 'win32':
|
|
29
|
+
try:
|
|
30
|
+
import win32ui
|
|
31
|
+
import win32con
|
|
32
|
+
pywin32_available = True
|
|
33
|
+
except ImportError:
|
|
34
|
+
pywin32_available = False
|
|
35
|
+
else:
|
|
36
|
+
pywin32_available = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class DateRange:
|
|
40
|
+
def __init__(self, from_date=None, until_date=None):
|
|
41
|
+
self.from_date = from_date
|
|
42
|
+
self.until_date = until_date
|
|
43
|
+
|
|
44
|
+
if self.from_date and self.until_date and self.from_date > self.until_date:
|
|
45
|
+
raise ValueError("'From' date must be before 'until' date")
|
|
46
|
+
|
|
47
|
+
def contains(self, msg_date):
|
|
48
|
+
"""Check if a date falls within this range."""
|
|
49
|
+
if self.from_date and msg_date < self.from_date:
|
|
50
|
+
return False
|
|
51
|
+
if self.until_date and msg_date > self.until_date:
|
|
52
|
+
return False
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
def is_filtered(self):
|
|
56
|
+
"""Return True if any filtering is applied."""
|
|
57
|
+
return self.from_date is not None or self.until_date is not None
|
|
58
|
+
|
|
59
|
+
def format_range(self, date_format):
|
|
60
|
+
"""Return a formatted string representation of the range."""
|
|
61
|
+
if not self.is_filtered():
|
|
62
|
+
return None
|
|
63
|
+
from_str = self.from_date.strftime(date_format) if self.from_date else 'start'
|
|
64
|
+
until_str = self.until_date.strftime(date_format) if self.until_date else 'end'
|
|
65
|
+
return f"Filtered: {from_str} to {until_str}"
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def parse_date_input(date_str, date_formats):
|
|
69
|
+
"""Parse date string in various formats."""
|
|
70
|
+
if not date_str:
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
for fmt in date_formats:
|
|
74
|
+
try:
|
|
75
|
+
return datetime.strptime(date_str, fmt).date()
|
|
76
|
+
except ValueError:
|
|
77
|
+
continue
|
|
78
|
+
raise ValueError("Invalid date format. Please use DD.MM.YYYY, MM/DD/YYYY, DD.MM.YY, or MM/DD/YY")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Chat:
|
|
83
|
+
"""Container for chat metadata and messages."""
|
|
84
|
+
# Chat-level metadata
|
|
85
|
+
name: str
|
|
86
|
+
is_ios: bool
|
|
87
|
+
has_media: bool
|
|
88
|
+
attachments_in_zip: frozenset
|
|
89
|
+
message_date_format: str
|
|
90
|
+
newline_marker: str
|
|
91
|
+
|
|
92
|
+
# Chat data
|
|
93
|
+
messages: list = field(default_factory=list)
|
|
94
|
+
senders: list = field(default_factory=list)
|
|
95
|
+
date_range: Optional['DateRange'] = None
|
|
96
|
+
sender_color_map: dict = field(default_factory=dict)
|
|
97
|
+
own_name: str = ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Message:
|
|
102
|
+
"""Individual message with computed properties using Chat context."""
|
|
103
|
+
timestamp: str
|
|
104
|
+
sender: str
|
|
105
|
+
content: str
|
|
106
|
+
|
|
107
|
+
# Computed fields (calculated with Chat context)
|
|
108
|
+
attachment_name: Optional[str] = None
|
|
109
|
+
cleaned_content: str = ""
|
|
110
|
+
parsed_date: Optional[datetime] = None
|
|
111
|
+
formatted_timestamp: str = ""
|
|
112
|
+
has_attachment: bool = False
|
|
113
|
+
|
|
114
|
+
@classmethod
|
|
115
|
+
def create_with_context(cls, timestamp: str, sender: str, content: str, chat: 'Chat') -> 'Message':
|
|
116
|
+
"""Create a Message with computed properties using Chat context."""
|
|
117
|
+
# Compute attachment name
|
|
118
|
+
attachment_name = cls._extract_attachment_name(content, chat)
|
|
119
|
+
has_attachment = attachment_name is not None
|
|
120
|
+
|
|
121
|
+
# Compute cleaned content
|
|
122
|
+
cleaned_content = cls._clean_message_content(content, chat, attachment_name)
|
|
123
|
+
|
|
124
|
+
# Compute parsed date
|
|
125
|
+
parsed_date = cls._parse_message_date(timestamp, chat.message_date_format)
|
|
126
|
+
|
|
127
|
+
# Compute formatted timestamp
|
|
128
|
+
formatted_timestamp = cls._re_render_with_day_of_week(timestamp, parsed_date)
|
|
129
|
+
|
|
130
|
+
return cls(
|
|
131
|
+
timestamp=timestamp,
|
|
132
|
+
sender=sender,
|
|
133
|
+
content=content,
|
|
134
|
+
attachment_name=attachment_name,
|
|
135
|
+
cleaned_content=cleaned_content,
|
|
136
|
+
parsed_date=parsed_date,
|
|
137
|
+
formatted_timestamp=formatted_timestamp,
|
|
138
|
+
has_attachment=has_attachment
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Attachment patterns as class constants
|
|
142
|
+
ATTACHMENT_PATTERN_ANDROID = r'(.+?\.[a-zA-Z0-9]{0,4}) \(.{1,20} .{1,20}\)'
|
|
143
|
+
ATTACHMENT_PATTERN_IOS = r'<\w{2,20}:\s*([^>]+)>'
|
|
144
|
+
|
|
145
|
+
@staticmethod
|
|
146
|
+
def _extract_attachment_name(content: str, chat: 'Chat') -> Optional[str]:
|
|
147
|
+
"""Extract attachment filename from message content using various patterns."""
|
|
148
|
+
if chat.is_ios and '<' in content:
|
|
149
|
+
match = re.search(Message.ATTACHMENT_PATTERN_IOS, content)
|
|
150
|
+
if match:
|
|
151
|
+
result = match.group(1) if match.groups() else match.group(0)
|
|
152
|
+
if result in chat.attachments_in_zip:
|
|
153
|
+
return result
|
|
154
|
+
elif not chat.is_ios and '(' in content:
|
|
155
|
+
match = re.search(Message.ATTACHMENT_PATTERN_ANDROID, content)
|
|
156
|
+
if match:
|
|
157
|
+
result = match.group(1) if match.groups() else match.group(0)
|
|
158
|
+
if result in chat.attachments_in_zip:
|
|
159
|
+
return result
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _clean_message_content(content: str, chat: 'Chat', attachment_name: Optional[str]) -> str:
|
|
164
|
+
"""Remove attachment markers from message content."""
|
|
165
|
+
cleaned_content = content
|
|
166
|
+
|
|
167
|
+
# Remove attachment patterns if media present and attachment found
|
|
168
|
+
if chat.has_media and chat.is_ios and '<' in content and attachment_name is not None:
|
|
169
|
+
cleaned_content = re.sub(Message.ATTACHMENT_PATTERN_IOS, '', cleaned_content)
|
|
170
|
+
elif chat.has_media and not chat.is_ios and '(' in content and attachment_name is not None:
|
|
171
|
+
cleaned_content = re.sub(Message.ATTACHMENT_PATTERN_ANDROID, '', cleaned_content)
|
|
172
|
+
|
|
173
|
+
if cleaned_content != content:
|
|
174
|
+
# Clean up any remaining parentheses and extra whitespace
|
|
175
|
+
cleaned_content = re.sub(r'\s*\([^)]+\)\s*$', '', cleaned_content)
|
|
176
|
+
|
|
177
|
+
# Make '<medien ausgeschlossen>' visible in html
|
|
178
|
+
cleaned_content = cleaned_content.replace('<', '[').replace('>', ']')
|
|
179
|
+
cleaned_content = Message._wrap_urls_with_anchor_tags(cleaned_content)
|
|
180
|
+
cleaned_content = cleaned_content.replace(chat.newline_marker, '<br>')
|
|
181
|
+
|
|
182
|
+
# Handle call attempts (old exports contained "null", newer contain empty messages "")
|
|
183
|
+
# we don't have any details on calls. Whether if they were video or audio.
|
|
184
|
+
# Nor if they where attempts or established
|
|
185
|
+
# Nor if they were incoming or outgoing.
|
|
186
|
+
# Just a string "null"
|
|
187
|
+
if cleaned_content == "null" or (cleaned_content == "" and attachment_name is None):
|
|
188
|
+
cleaned_content = "[call (attempt)]"
|
|
189
|
+
|
|
190
|
+
return cleaned_content.strip()
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _parse_message_date(timestamp: str, message_date_format: str) -> Optional[datetime]:
|
|
194
|
+
"""Parse the date from a message timestamp."""
|
|
195
|
+
try:
|
|
196
|
+
# Remove time part and any AM/PM indicator
|
|
197
|
+
date_str = re.split(', | ', timestamp.replace('[',''))[0]
|
|
198
|
+
return datetime.strptime(date_str, message_date_format).date()
|
|
199
|
+
except (ValueError, AttributeError):
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _re_render_with_day_of_week(timestamp: str, parsed_date: Optional[datetime]) -> str:
|
|
204
|
+
"""Parse the date string and re-render it including the day of week."""
|
|
205
|
+
try:
|
|
206
|
+
if parsed_date:
|
|
207
|
+
day_of_week = parsed_date.strftime('%a')
|
|
208
|
+
return f"{day_of_week}, {timestamp}"
|
|
209
|
+
return timestamp
|
|
210
|
+
except (ValueError, AttributeError):
|
|
211
|
+
return timestamp
|
|
212
|
+
|
|
213
|
+
@staticmethod
|
|
214
|
+
def _wrap_urls_with_anchor_tags(text: str) -> str:
|
|
215
|
+
"""Wrap URLs in anchor tags."""
|
|
216
|
+
url_pattern = re.compile(r'(https?://[^\s]+)')
|
|
217
|
+
return url_pattern.sub(r'<a href="\1" target="_blank">\1</a>', text)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Renderer:
|
|
221
|
+
"""Base renderer class for message rendering."""
|
|
222
|
+
|
|
223
|
+
def __init__(self, output_dir=None):
|
|
224
|
+
self.output_dir = output_dir
|
|
225
|
+
|
|
226
|
+
def render(self, chat):
|
|
227
|
+
"""Render a Chat object. To be implemented by subclasses."""
|
|
228
|
+
raise NotImplementedError("Subclasses must implement render method")
|
|
229
|
+
|
|
230
|
+
def get_generated_files(self) -> list[Path]:
|
|
231
|
+
"""Get the generated files."""
|
|
232
|
+
raise NotImplementedError("Subclasses must implement get_generated_files method")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def macos_file_picker():
|
|
236
|
+
"""Present a native macOS file dialog to select a file.
|
|
237
|
+
pip install pyobjc-framework-Cocoa
|
|
238
|
+
for this to work
|
|
239
|
+
"""
|
|
240
|
+
# Initialize NSApplication if it hasn't been
|
|
241
|
+
app = NSApplication.sharedApplication()
|
|
242
|
+
app.setActivationPolicy_(1) # NSApplicationActivationPolicyRegular
|
|
243
|
+
|
|
244
|
+
# Create and configure the panel first
|
|
245
|
+
panel = NSOpenPanel.alloc().init()
|
|
246
|
+
panel.setCanChooseFiles_(True)
|
|
247
|
+
panel.setCanChooseDirectories_(False)
|
|
248
|
+
panel.setAllowsMultipleSelection_(False)
|
|
249
|
+
panel.setTitle_("Select WhatsApp Chat Export ZIP File")
|
|
250
|
+
panel.setPrompt_("Open")
|
|
251
|
+
|
|
252
|
+
# Set file type filter to only show .zip files
|
|
253
|
+
panel.setAllowedFileTypes_(["zip"])
|
|
254
|
+
|
|
255
|
+
# Bring app and panel to front
|
|
256
|
+
app.activateIgnoringOtherApps_(True)
|
|
257
|
+
|
|
258
|
+
# Run the panel
|
|
259
|
+
response = panel.runModal()
|
|
260
|
+
|
|
261
|
+
# Clean up
|
|
262
|
+
app.setActivationPolicy_(0) # NSApplicationActivationPolicyRegular
|
|
263
|
+
|
|
264
|
+
if response == 1: # NSModalResponseOK
|
|
265
|
+
urls = panel.URLs()
|
|
266
|
+
if urls and len(urls):
|
|
267
|
+
return str(urls[0].path())
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
def windows_file_picker():
|
|
271
|
+
"""Use the native Windows file picker with pywin32.
|
|
272
|
+
pip install pywin32
|
|
273
|
+
for this to work.
|
|
274
|
+
"""
|
|
275
|
+
# Define file filter format: "Description|*.extension|"
|
|
276
|
+
file_filter = "ZIP Files (*.zip)|*.zip|All Files (*.*)|*.*|"
|
|
277
|
+
|
|
278
|
+
dlg = win32ui.CreateFileDialog(1, None, None, 0, file_filter) # Open dialog (1)
|
|
279
|
+
dlg.SetOFNTitle("Select WhatsApp Chat Export ZIP File")
|
|
280
|
+
dlg.SetOFNInitialDir(os.path.expanduser("~")) # Start in user's home directory
|
|
281
|
+
|
|
282
|
+
if dlg.DoModal() == 1: # If the user selects a file
|
|
283
|
+
return dlg.GetPathName()
|
|
284
|
+
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
version = "0.9.2"
|
|
290
|
+
|
|
291
|
+
donate_link = "https://donate.stripe.com/3csfZLaIj5JE6dO4gg"
|
|
292
|
+
|
|
293
|
+
def parse_path(path_str: str) -> Path:
|
|
294
|
+
"""
|
|
295
|
+
Parse a path string safely, handling both Windows and Unix styles.
|
|
296
|
+
Returns a pathlib.Path object normalized to the current OS.
|
|
297
|
+
"""
|
|
298
|
+
# Strip quotes from the path string if present
|
|
299
|
+
path_str = path_str.strip('"\'')
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# Decide whether it's Windows or Unix style by looking for a drive letter
|
|
303
|
+
if ":" in path_str[:3] or "\\" in path_str:
|
|
304
|
+
# Looks like Windows
|
|
305
|
+
pure = PureWindowsPath(path_str)
|
|
306
|
+
else:
|
|
307
|
+
# Looks like Unix/Posix
|
|
308
|
+
pure = PurePosixPath(path_str)
|
|
309
|
+
|
|
310
|
+
# Convert into an OS-specific Path (resolves separators automatically)
|
|
311
|
+
return Path(pure)
|
|
312
|
+
|
|
313
|
+
def parse_arguments():
|
|
314
|
+
"""Parse command line arguments for both interactive and non-interactive modes."""
|
|
315
|
+
parser = argparse.ArgumentParser(
|
|
316
|
+
description='Convert WhatsApp chat exports to HTML format',
|
|
317
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
318
|
+
epilog='''
|
|
319
|
+
Examples:
|
|
320
|
+
# Interactive mode (default)
|
|
321
|
+
python main.py
|
|
322
|
+
|
|
323
|
+
# Non-interactive mode
|
|
324
|
+
python main.py -n -z "chat.zip" -p "John Doe"
|
|
325
|
+
|
|
326
|
+
# With date filtering
|
|
327
|
+
python main.py -n -z "chat.zip" -p "John Doe" --from-date "01.01.2024" --until-date "31.12.2024"
|
|
328
|
+
|
|
329
|
+
# With custom output directory (creates /tmp/chat/ instead of ./chat/)
|
|
330
|
+
python main.py -n -z "chat.zip" -p "John Doe" -o "/tmp"
|
|
331
|
+
'''
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
parser.add_argument('-n', '--non-interactive',
|
|
335
|
+
action='store_true',
|
|
336
|
+
help='Run in non-interactive mode (requires -z and -p)')
|
|
337
|
+
|
|
338
|
+
parser.add_argument('-z', '--zip-file',
|
|
339
|
+
type=str,
|
|
340
|
+
help='Path to WhatsApp chat export ZIP file (required in non-interactive mode)')
|
|
341
|
+
|
|
342
|
+
parser.add_argument('-p', '--participant',
|
|
343
|
+
type=str,
|
|
344
|
+
help='Name of the participant that represents yourself (required in non-interactive mode)')
|
|
345
|
+
|
|
346
|
+
parser.add_argument('--from-date',
|
|
347
|
+
type=str,
|
|
348
|
+
help='Start date for filtering messages (optional, formats: DD.MM.YYYY, MM/DD/YYYY, DD.MM.YY, MM/DD/YY)')
|
|
349
|
+
|
|
350
|
+
parser.add_argument('--until-date',
|
|
351
|
+
type=str,
|
|
352
|
+
help='End date for filtering messages (optional, formats: DD.MM.YYYY, MM/DD/YYYY, DD.MM.YY, MM/DD/YY)')
|
|
353
|
+
|
|
354
|
+
parser.add_argument('-o', '--output-dir',
|
|
355
|
+
type=str,
|
|
356
|
+
help='Base directory where the ZIP-derived output folder will be created (default: current working directory)')
|
|
357
|
+
|
|
358
|
+
args = parser.parse_args()
|
|
359
|
+
|
|
360
|
+
# Validate non-interactive mode requirements
|
|
361
|
+
if args.non_interactive:
|
|
362
|
+
if not args.zip_file:
|
|
363
|
+
parser.error("Non-interactive mode requires --zip-file (-z)")
|
|
364
|
+
if not args.participant:
|
|
365
|
+
parser.error("Non-interactive mode requires --participant (-p)")
|
|
366
|
+
|
|
367
|
+
return args
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
class MessageParser:
|
|
371
|
+
"""Handles parsing of WhatsApp chat content into Message objects."""
|
|
372
|
+
|
|
373
|
+
def __init__(self, is_ios=False, has_media=False, attachments_in_zip=None):
|
|
374
|
+
self.is_ios = is_ios
|
|
375
|
+
self.has_media = has_media
|
|
376
|
+
self.attachments_in_zip = attachments_in_zip or set()
|
|
377
|
+
|
|
378
|
+
# Chat patterns for different platforms
|
|
379
|
+
self.chat_patterns = {
|
|
380
|
+
'ios': re.compile(r'\[(\d{1,4}.\d{1,2}.\d{2,4}, \d{1,2}:\d{2}(?::\d{2})?(?:\s*[AaPp][Mm])?)\] (.*?): (.*)'),
|
|
381
|
+
'android': re.compile(r'(\d{1,4}.\d{1,2}.\d{2,4},? \d{1,2}:\d{2}(?::\d{2})?(?:\s*[AaPp][Mm])?) - (.*?): (.*)')
|
|
382
|
+
}
|
|
383
|
+
self.whatsapp_patterns = {
|
|
384
|
+
'ios': re.compile(r'\[(\d{1,4}.\d{1,2}.\d{2,4}, \d{1,2}:\d{2}(?::\d{2})?(?:\s*[AaPp][Mm])?)\] (.*)'),
|
|
385
|
+
'android': re.compile(r'(\d{1,4}.\d{1,2}.\d{2,4},? \d{1,2}:\d{2}(?::\d{2})?(?:\s*[AaPp][Mm])?) - (.*)')
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
self.attachment_pattern_android = r'(.+?\.[a-zA-Z0-9]{0,4}) \(.{1,20} .{1,20}\)'
|
|
389
|
+
self.attachment_pattern_ios = r'<\w{2,20}:\s*([^>]+)>'
|
|
390
|
+
self.newline_marker = ' $NEWLINE$ '
|
|
391
|
+
self.message_date_format = "%d.%m.%y"
|
|
392
|
+
|
|
393
|
+
self.date_formats = [
|
|
394
|
+
"%d.%m.%Y", # German format: DD.MM.YYYY
|
|
395
|
+
"%m/%d/%Y", # US format: MM/DD/YYYY
|
|
396
|
+
"%d.%m.%y", # German format: DD.MM.YY
|
|
397
|
+
"%m/%d/%y" # US format: MM/DD/YY
|
|
398
|
+
]
|
|
399
|
+
|
|
400
|
+
def get_date_format(self, chat_content):
|
|
401
|
+
"""Determine the date format used in the chat."""
|
|
402
|
+
chat_content = chat_content.replace('','')
|
|
403
|
+
first_line = None
|
|
404
|
+
pattern = self.chat_patterns['ios'] if self.is_ios else self.chat_patterns['android']
|
|
405
|
+
for line in chat_content.split('\n'):
|
|
406
|
+
if pattern.match(line):
|
|
407
|
+
first_line = line
|
|
408
|
+
break
|
|
409
|
+
|
|
410
|
+
if first_line is None:
|
|
411
|
+
first_line_content = chat_content.split('\n')[0]
|
|
412
|
+
raise ValueError(f"Could not determine the date format of the chat: {first_line_content}")
|
|
413
|
+
|
|
414
|
+
first_line_date = first_line.split(',')[0].replace('[', '')
|
|
415
|
+
# find first non-digit in the date string
|
|
416
|
+
for char in first_line_date:
|
|
417
|
+
if not char.isdigit():
|
|
418
|
+
deliminator = char
|
|
419
|
+
break
|
|
420
|
+
|
|
421
|
+
# year might be in position 0 or 2, i.e. 2018-12-22 vs 22.12.18 vs 22.12.2018
|
|
422
|
+
if len(first_line_date.split(deliminator)[0]) == 4:
|
|
423
|
+
return f'%Y{deliminator}%m{deliminator}%d'
|
|
424
|
+
# year is in position 2
|
|
425
|
+
# check if year is 2 or 4 digits
|
|
426
|
+
year_pattern = '%y' if len(first_line_date.split(deliminator)[2]) == 2 else '%Y'
|
|
427
|
+
# need to find out if month or day comes first.
|
|
428
|
+
day_before_month = True
|
|
429
|
+
for line in chat_content.split('\n'):
|
|
430
|
+
if not pattern.match(line):
|
|
431
|
+
continue
|
|
432
|
+
date_str = re.split(', | ', line.replace('[',''))[0]
|
|
433
|
+
first, second, _ = date_str.split(deliminator)
|
|
434
|
+
# convert to int
|
|
435
|
+
first = int(first)
|
|
436
|
+
second = int(second)
|
|
437
|
+
if first > 12:
|
|
438
|
+
day_before_month = True
|
|
439
|
+
break
|
|
440
|
+
if second > 12:
|
|
441
|
+
day_before_month = False
|
|
442
|
+
break
|
|
443
|
+
|
|
444
|
+
if day_before_month:
|
|
445
|
+
return f'%d{deliminator}%m{deliminator}{year_pattern}'
|
|
446
|
+
else:
|
|
447
|
+
return f'%m{deliminator}%d{deliminator}{year_pattern}'
|
|
448
|
+
|
|
449
|
+
def _parse_timestamp_date(self, timestamp):
|
|
450
|
+
"""Helper method to parse timestamp for date filtering during message processing."""
|
|
451
|
+
try:
|
|
452
|
+
# Remove time part and any AM/PM indicator
|
|
453
|
+
date_str = re.split(', | ', timestamp.replace('[',''))[0]
|
|
454
|
+
return datetime.strptime(date_str, self.message_date_format).date()
|
|
455
|
+
except (ValueError, AttributeError):
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
def get_senders(self, chat_content):
|
|
459
|
+
"""Extract all unique senders from chat content."""
|
|
460
|
+
senders = set()
|
|
461
|
+
pattern = self.chat_patterns['ios'] if self.is_ios else self.chat_patterns['android']
|
|
462
|
+
for line in chat_content.split('\n'):
|
|
463
|
+
match = pattern.match(line)
|
|
464
|
+
if match:
|
|
465
|
+
sender = match.group(2)
|
|
466
|
+
senders.add(sender)
|
|
467
|
+
return sorted(list(senders))
|
|
468
|
+
|
|
469
|
+
def _generate_color_map(self, senders, own_name):
|
|
470
|
+
"""Generate color mapping for senders."""
|
|
471
|
+
sender_colors = {
|
|
472
|
+
'own': '#d9fdd3', # WhatsApp green for own messages
|
|
473
|
+
'default': '#ffffff', # White for the second sender
|
|
474
|
+
'whatsapp': '#20c063',
|
|
475
|
+
# Additional colors for other senders
|
|
476
|
+
'others': [
|
|
477
|
+
'#f0e6ff', # Light purple
|
|
478
|
+
'#fff3e6', # Light orange
|
|
479
|
+
'#e6fff0', # Light mint
|
|
480
|
+
'#ffe6e6', # Light pink
|
|
481
|
+
'#e6f3ff', # Light blue
|
|
482
|
+
'#fff0f0', # Lighter pink
|
|
483
|
+
'#e6ffe6', # Lighter mint
|
|
484
|
+
'#f2e6ff', # Lighter purple
|
|
485
|
+
'#fff5e6', # Peach
|
|
486
|
+
'#e6ffff', # Light cyan
|
|
487
|
+
'#ffe6f0', # Rose
|
|
488
|
+
'#f0ffe6', # Light lime
|
|
489
|
+
'#e6e6ff', # Lavender
|
|
490
|
+
'#ffe6cc', # Light apricot
|
|
491
|
+
'#e6fff9' # Light turquoise
|
|
492
|
+
]
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
color_map = {}
|
|
496
|
+
|
|
497
|
+
# Remove own name from senders list for color assignment
|
|
498
|
+
other_senders = [s for s in senders if s != own_name]
|
|
499
|
+
|
|
500
|
+
# Assign colors to senders
|
|
501
|
+
color_map[own_name] = sender_colors['own']
|
|
502
|
+
|
|
503
|
+
# assign whatsapp color
|
|
504
|
+
color_map['WhatsApp'] = sender_colors['whatsapp']
|
|
505
|
+
|
|
506
|
+
# Assign white to the first other sender
|
|
507
|
+
if other_senders:
|
|
508
|
+
color_map[other_senders[0]] = sender_colors['default']
|
|
509
|
+
|
|
510
|
+
# Assign different colors to remaining senders
|
|
511
|
+
for i, sender in enumerate(other_senders[1:]):
|
|
512
|
+
color_index = i % len(sender_colors['others'])
|
|
513
|
+
color_map[sender] = sender_colors['others'][color_index]
|
|
514
|
+
|
|
515
|
+
return color_map
|
|
516
|
+
|
|
517
|
+
def parse_messages(self, chat_content, chat_name="", date_range=None, own_name=""):
|
|
518
|
+
"""Parse chat content into a Chat object."""
|
|
519
|
+
# Set the message date format
|
|
520
|
+
self.message_date_format = self.get_date_format(chat_content)
|
|
521
|
+
|
|
522
|
+
# Create Chat object with metadata
|
|
523
|
+
chat = Chat(
|
|
524
|
+
name=chat_name,
|
|
525
|
+
is_ios=self.is_ios,
|
|
526
|
+
has_media=self.has_media,
|
|
527
|
+
attachments_in_zip=frozenset(self.attachments_in_zip),
|
|
528
|
+
message_date_format=self.message_date_format,
|
|
529
|
+
newline_marker=self.newline_marker,
|
|
530
|
+
messages=[],
|
|
531
|
+
senders=self.get_senders(chat_content),
|
|
532
|
+
date_range=date_range,
|
|
533
|
+
sender_color_map={},
|
|
534
|
+
own_name=own_name
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
# Preprocess the chat content to handle multi-line messages
|
|
538
|
+
processed_content = []
|
|
539
|
+
current_line = []
|
|
540
|
+
filtered_count = 0
|
|
541
|
+
total_count = 0
|
|
542
|
+
|
|
543
|
+
for line in chat_content.split('\n'):
|
|
544
|
+
# remove the Left-to-right_marks
|
|
545
|
+
line = line.replace('','')
|
|
546
|
+
pattern = self.chat_patterns['ios'] if self.is_ios else self.chat_patterns['android']
|
|
547
|
+
wapattern = self.whatsapp_patterns['ios'] if self.is_ios else self.whatsapp_patterns['android']
|
|
548
|
+
match = pattern.match(line)
|
|
549
|
+
wamatch = wapattern.match(line)
|
|
550
|
+
|
|
551
|
+
if match or wamatch:
|
|
552
|
+
total_count += 1
|
|
553
|
+
if current_line:
|
|
554
|
+
processed_content.append(''.join(current_line))
|
|
555
|
+
|
|
556
|
+
# Only add messages within date range
|
|
557
|
+
if match:
|
|
558
|
+
timestamp = match.group(1)
|
|
559
|
+
if date_range and not date_range.contains(self._parse_timestamp_date(timestamp)):
|
|
560
|
+
current_line = []
|
|
561
|
+
continue
|
|
562
|
+
else:
|
|
563
|
+
timestamp = wamatch.group(1)
|
|
564
|
+
if date_range and not date_range.contains(self._parse_timestamp_date(timestamp)):
|
|
565
|
+
current_line = []
|
|
566
|
+
continue
|
|
567
|
+
|
|
568
|
+
filtered_count += 1
|
|
569
|
+
current_line = [line]
|
|
570
|
+
else:
|
|
571
|
+
if current_line:
|
|
572
|
+
current_line.append(self.newline_marker + line)
|
|
573
|
+
|
|
574
|
+
# Don't forget to add the last message
|
|
575
|
+
if current_line:
|
|
576
|
+
processed_content.append(''.join(current_line))
|
|
577
|
+
|
|
578
|
+
# Parse each processed line into Message objects
|
|
579
|
+
messages = []
|
|
580
|
+
for line in processed_content:
|
|
581
|
+
pattern = self.chat_patterns['ios'] if self.is_ios else self.chat_patterns['android']
|
|
582
|
+
wapattern = self.whatsapp_patterns['ios'] if self.is_ios else self.whatsapp_patterns['android']
|
|
583
|
+
match = pattern.match(line)
|
|
584
|
+
wamatch = wapattern.match(line)
|
|
585
|
+
|
|
586
|
+
if match:
|
|
587
|
+
timestamp, sender, content = match.groups()
|
|
588
|
+
messages.append(Message.create_with_context(
|
|
589
|
+
timestamp=timestamp,
|
|
590
|
+
sender=sender,
|
|
591
|
+
content=content,
|
|
592
|
+
chat=chat
|
|
593
|
+
))
|
|
594
|
+
elif wamatch:
|
|
595
|
+
timestamp, content = wamatch.groups()
|
|
596
|
+
messages.append(Message.create_with_context(
|
|
597
|
+
timestamp=timestamp,
|
|
598
|
+
sender="WhatsApp",
|
|
599
|
+
content=content,
|
|
600
|
+
chat=chat
|
|
601
|
+
))
|
|
602
|
+
|
|
603
|
+
# Update chat with messages and color map
|
|
604
|
+
chat.messages.extend(messages)
|
|
605
|
+
chat.sender_color_map.update(self._generate_color_map(chat.senders, own_name))
|
|
606
|
+
|
|
607
|
+
return chat, filtered_count, total_count
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class HTMLRenderer(Renderer):
|
|
611
|
+
"""Renders messages to HTML format."""
|
|
612
|
+
|
|
613
|
+
def __init__(self, output_dir, has_media=False):
|
|
614
|
+
super().__init__(output_dir)
|
|
615
|
+
self.has_media = has_media
|
|
616
|
+
self.html_filename = 'chat.html'
|
|
617
|
+
self.html_filename_media_linked = 'chat_media_linked.html'
|
|
618
|
+
self.attachments_to_extract = set()
|
|
619
|
+
|
|
620
|
+
def get_generated_files(self) -> list[Path]:
|
|
621
|
+
"""Get the generated files."""
|
|
622
|
+
return [Path(self.output_dir, self.html_filename), Path(self.output_dir, self.html_filename_media_linked)]
|
|
623
|
+
|
|
624
|
+
def get_css_styles(self):
|
|
625
|
+
"""Return the CSS styles for the HTML output."""
|
|
626
|
+
return """body {
|
|
627
|
+
font-family: Arial, sans-serif;
|
|
628
|
+
max-width: 900px;
|
|
629
|
+
margin: 0 auto;
|
|
630
|
+
padding: 20px;
|
|
631
|
+
background-color: #e5ddd5;
|
|
632
|
+
}
|
|
633
|
+
.message {
|
|
634
|
+
margin: 10px 0;
|
|
635
|
+
padding: 10px;
|
|
636
|
+
border-radius: 7.5px;
|
|
637
|
+
max-width: 65%;
|
|
638
|
+
position: relative;
|
|
639
|
+
clear: both;
|
|
640
|
+
}
|
|
641
|
+
.message.sent {
|
|
642
|
+
float: right;
|
|
643
|
+
margin-left: 35%;
|
|
644
|
+
}
|
|
645
|
+
.message.received {
|
|
646
|
+
float: left;
|
|
647
|
+
margin-right: 35%;
|
|
648
|
+
}
|
|
649
|
+
.message.whatsapp {
|
|
650
|
+
max-width: 100%
|
|
651
|
+
}
|
|
652
|
+
.media {
|
|
653
|
+
max-width: 100%;
|
|
654
|
+
border-radius: 5px;
|
|
655
|
+
margin: 5px 0;
|
|
656
|
+
}
|
|
657
|
+
.timestamp {
|
|
658
|
+
color: #667781;
|
|
659
|
+
font-size: 0.75em;
|
|
660
|
+
float: right;
|
|
661
|
+
margin-left: 10px;
|
|
662
|
+
margin-top: 5px;
|
|
663
|
+
}
|
|
664
|
+
.sender {
|
|
665
|
+
color: #1f7aad;
|
|
666
|
+
font-size: 0.85em;
|
|
667
|
+
font-weight: bold;
|
|
668
|
+
display: block;
|
|
669
|
+
margin-bottom: 5px;
|
|
670
|
+
}
|
|
671
|
+
.content {
|
|
672
|
+
word-wrap: break-word;
|
|
673
|
+
}
|
|
674
|
+
.clearfix::after {
|
|
675
|
+
content: "";
|
|
676
|
+
clear: both;
|
|
677
|
+
display: table;
|
|
678
|
+
}
|
|
679
|
+
a {
|
|
680
|
+
color: #039be5;
|
|
681
|
+
text-decoration: none;
|
|
682
|
+
}
|
|
683
|
+
a:hover {
|
|
684
|
+
text-decoration: underline;
|
|
685
|
+
}
|
|
686
|
+
@media print {
|
|
687
|
+
body {
|
|
688
|
+
background-color: #ffffff;
|
|
689
|
+
}
|
|
690
|
+
}"""
|
|
691
|
+
|
|
692
|
+
def get_html_header(self):
|
|
693
|
+
"""Generate the HTML header."""
|
|
694
|
+
return f"""<!DOCTYPE html>
|
|
695
|
+
<html>
|
|
696
|
+
<head>
|
|
697
|
+
<meta charset="utf-8">
|
|
698
|
+
<title>{self.chat.name}</title>
|
|
699
|
+
<style>
|
|
700
|
+
{self.get_css_styles()}
|
|
701
|
+
</style>
|
|
702
|
+
</head>
|
|
703
|
+
<body>
|
|
704
|
+
<div class="chat-container">
|
|
705
|
+
<h1>{self.chat.name}</h1>"""
|
|
706
|
+
|
|
707
|
+
def get_html_footer(self):
|
|
708
|
+
"""Generate the HTML footer."""
|
|
709
|
+
return """
|
|
710
|
+
</div>
|
|
711
|
+
</body>
|
|
712
|
+
</html>"""
|
|
713
|
+
|
|
714
|
+
def render_media_element(self, attachment_name, is_media_linked=False):
|
|
715
|
+
"""Render a media element based on its file extension."""
|
|
716
|
+
media_path = f"./media/{attachment_name}"
|
|
717
|
+
|
|
718
|
+
if is_media_linked:
|
|
719
|
+
# Always show as link in media-linked version
|
|
720
|
+
return f'<a href="{media_path}">📎 {attachment_name}</a><br>'
|
|
721
|
+
|
|
722
|
+
# Render media inline in main version
|
|
723
|
+
ext = attachment_name.lower()
|
|
724
|
+
if ext.endswith(('.jpg', '.jpeg', '.png', '.webp', '.gif')):
|
|
725
|
+
return f'<img class="media" src="{media_path}"><br>'
|
|
726
|
+
elif ext.endswith('.mp4'):
|
|
727
|
+
return f'<video class="media" controls><source src="{media_path}" type="video/mp4"></video><br>'
|
|
728
|
+
elif ext.endswith('.opus'):
|
|
729
|
+
return f'<audio class="media" controls><source src="{media_path}" type="audio/ogg"></audio><br>'
|
|
730
|
+
elif ext.endswith('.wav'):
|
|
731
|
+
return f'<audio class="media" controls><source src="{media_path}" type="audio/wav"></source></audio><br>'
|
|
732
|
+
elif ext.endswith('.mp3'):
|
|
733
|
+
return f'<audio class="media" controls><source src="{media_path}" type="audio/mpeg"></audio><br>'
|
|
734
|
+
elif ext.endswith('.m4a'):
|
|
735
|
+
return f'<audio class="media" controls><source src="{media_path}" type="audio/mp4"></audio><br>'
|
|
736
|
+
else:
|
|
737
|
+
return f'<a href="{media_path}">📎 {attachment_name}</a><br>'
|
|
738
|
+
|
|
739
|
+
def render_message(self, message, sender_color_map, own_name, main_f, media_f):
|
|
740
|
+
"""Render a single message to both file handles."""
|
|
741
|
+
# Determine message alignment and background color
|
|
742
|
+
is_own_message = message.sender == own_name
|
|
743
|
+
is_wa_message = message.sender == "WhatsApp"
|
|
744
|
+
message_class = "sent" if is_own_message else "received"
|
|
745
|
+
if is_wa_message:
|
|
746
|
+
message_class = 'whatsapp'
|
|
747
|
+
bg_color = sender_color_map.get(message.sender, '#ffffff')
|
|
748
|
+
|
|
749
|
+
# Common message structure
|
|
750
|
+
message_start = f'\n<div class="message {message_class} clearfix" style="background-color: {bg_color};">'
|
|
751
|
+
sender_div = f'<div class="sender">{message.sender}</div>'
|
|
752
|
+
content_start = '<div class="content">'
|
|
753
|
+
content_end = '</div>'
|
|
754
|
+
timestamp_span = f'<span class="timestamp">{message.formatted_timestamp}</span>'
|
|
755
|
+
message_end = '</div>'
|
|
756
|
+
|
|
757
|
+
# Write message start to both files
|
|
758
|
+
main_f.write(message_start)
|
|
759
|
+
media_f.write(message_start)
|
|
760
|
+
|
|
761
|
+
# Write sender to both files
|
|
762
|
+
main_f.write(sender_div)
|
|
763
|
+
media_f.write(sender_div)
|
|
764
|
+
|
|
765
|
+
# Write content start to both files
|
|
766
|
+
main_f.write(content_start)
|
|
767
|
+
media_f.write(content_start)
|
|
768
|
+
|
|
769
|
+
# Check if the message contains media
|
|
770
|
+
if message.has_attachment:
|
|
771
|
+
attachment_name = message.attachment_name
|
|
772
|
+
self.attachments_to_extract.add(attachment_name)
|
|
773
|
+
|
|
774
|
+
# Render media differently for each file
|
|
775
|
+
main_f.write(self.render_media_element(attachment_name, is_media_linked=False))
|
|
776
|
+
media_f.write(self.render_media_element(attachment_name, is_media_linked=True))
|
|
777
|
+
|
|
778
|
+
# Add the message content to both files
|
|
779
|
+
cleaned_content = message.cleaned_content
|
|
780
|
+
if cleaned_content:
|
|
781
|
+
main_f.write(f'{cleaned_content}')
|
|
782
|
+
media_f.write(f'{cleaned_content}')
|
|
783
|
+
|
|
784
|
+
# Write content end, timestamp, and message end to both files
|
|
785
|
+
main_f.write(content_end)
|
|
786
|
+
media_f.write(content_end)
|
|
787
|
+
main_f.write(timestamp_span)
|
|
788
|
+
media_f.write(timestamp_span)
|
|
789
|
+
main_f.write(message_end)
|
|
790
|
+
media_f.write(message_end)
|
|
791
|
+
|
|
792
|
+
def render(self, chat):
|
|
793
|
+
"""Render chat to HTML files."""
|
|
794
|
+
print("Writing HTML files...")
|
|
795
|
+
|
|
796
|
+
self.chat = chat
|
|
797
|
+
# Prepare file paths
|
|
798
|
+
main_html_path = os.path.join(self.output_dir, self.html_filename)
|
|
799
|
+
media_linked_html_path = os.path.join(self.output_dir, self.html_filename_media_linked)
|
|
800
|
+
|
|
801
|
+
# Open both files for writing
|
|
802
|
+
with open(main_html_path, 'w', encoding='utf-8') as main_f, \
|
|
803
|
+
open(media_linked_html_path, 'w', encoding='utf-8') as media_f:
|
|
804
|
+
|
|
805
|
+
# Write header to both files
|
|
806
|
+
header = self.get_html_header()
|
|
807
|
+
main_f.write(header)
|
|
808
|
+
media_f.write(header)
|
|
809
|
+
|
|
810
|
+
# Write date range and attribution to both files
|
|
811
|
+
if chat.date_range and chat.date_range.is_filtered():
|
|
812
|
+
date_range_str = chat.date_range.format_range(chat.message_date_format)
|
|
813
|
+
if date_range_str:
|
|
814
|
+
date_html = f'<p style="color: #667781;">{date_range_str}</p>'
|
|
815
|
+
main_f.write(date_html)
|
|
816
|
+
media_f.write(date_html)
|
|
817
|
+
|
|
818
|
+
attribution = '<p style="color: #667781;">This rendering has been created with the free offline tool `chat-export` from https://chat-export.click </p>'
|
|
819
|
+
main_f.write(attribution)
|
|
820
|
+
media_f.write(attribution)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
message_count = 0
|
|
824
|
+
|
|
825
|
+
for message in chat.messages:
|
|
826
|
+
self.render_message(message, chat.sender_color_map, chat.own_name, main_f, media_f)
|
|
827
|
+
message_count += 1
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# Write footer to both files
|
|
832
|
+
footer = self.get_html_footer()
|
|
833
|
+
main_f.write(footer)
|
|
834
|
+
media_f.write(footer)
|
|
835
|
+
|
|
836
|
+
return self.attachments_to_extract
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
class ChatExport:
|
|
840
|
+
def __init__(self, zip_path, from_date=None, until_date=None, participant_name=None, base_output_dir=None):
|
|
841
|
+
# Validate zip file existence
|
|
842
|
+
if not os.path.exists(zip_path):
|
|
843
|
+
raise FileNotFoundError(f"Could not find the file: {zip_path}\nPlease check if the file path is correct.")
|
|
844
|
+
|
|
845
|
+
if not zip_path.lower().endswith('.zip'):
|
|
846
|
+
raise ValueError(f"The file {zip_path} is not a zip file.\nPlease provide a valid WhatsApp chat export zip file.")
|
|
847
|
+
|
|
848
|
+
self.zip_path = zip_path
|
|
849
|
+
|
|
850
|
+
# Pre-set values for non-interactive mode
|
|
851
|
+
self.from_date = from_date
|
|
852
|
+
self.until_date = until_date
|
|
853
|
+
self.participant_name = participant_name
|
|
854
|
+
|
|
855
|
+
# Set up output directory: base_output_dir/zip_filename or just zip_filename
|
|
856
|
+
zip_stem = Path(zip_path).stem
|
|
857
|
+
if base_output_dir:
|
|
858
|
+
# Normalize the base output directory path (handle Windows paths, quotes, etc.)
|
|
859
|
+
normalized_base_dir = parse_path(base_output_dir)
|
|
860
|
+
self.output_dir = os.path.join(normalized_base_dir, zip_stem)
|
|
861
|
+
else:
|
|
862
|
+
self.output_dir = zip_stem
|
|
863
|
+
self.media_dir = os.path.join(self.output_dir, "media")
|
|
864
|
+
|
|
865
|
+
self.own_name = participant_name
|
|
866
|
+
self.attachments_in_zip = set()
|
|
867
|
+
self.sender_colors = {
|
|
868
|
+
'own': '#d9fdd3', # WhatsApp green for own messages
|
|
869
|
+
'default': '#ffffff', # White for the second sender
|
|
870
|
+
'whatsapp': '#20c063',
|
|
871
|
+
# Additional colors for other senders
|
|
872
|
+
'others': [
|
|
873
|
+
'#f0e6ff', # Light purple
|
|
874
|
+
'#fff3e6', # Light orange
|
|
875
|
+
'#e6fff0', # Light mint
|
|
876
|
+
'#ffe6e6', # Light pink
|
|
877
|
+
'#e6f3ff', # Light blue
|
|
878
|
+
'#fff0f0', # Lighter pink
|
|
879
|
+
'#e6ffe6', # Lighter mint
|
|
880
|
+
'#f2e6ff', # Lighter purple
|
|
881
|
+
'#fff5e6', # Peach
|
|
882
|
+
'#e6ffff', # Light cyan
|
|
883
|
+
'#ffe6f0', # Rose
|
|
884
|
+
'#f0ffe6', # Light lime
|
|
885
|
+
'#e6e6ff', # Lavender
|
|
886
|
+
'#ffe6cc', # Light apricot
|
|
887
|
+
'#e6fff9' # Light turquoise
|
|
888
|
+
]
|
|
889
|
+
}
|
|
890
|
+
self.has_media = False
|
|
891
|
+
self.is_ios = False
|
|
892
|
+
|
|
893
|
+
self.date_formats = [
|
|
894
|
+
"%d.%m.%Y", # German format: DD.MM.YYYY
|
|
895
|
+
"%m/%d/%Y", # US format: MM/DD/YYYY
|
|
896
|
+
"%d.%m.%y", # German format: DD.MM.YY
|
|
897
|
+
"%m/%d/%y" # US format: MM/DD/YY
|
|
898
|
+
]
|
|
899
|
+
|
|
900
|
+
# Initialize modular components (will be set up later after platform detection)
|
|
901
|
+
self.parser = None
|
|
902
|
+
self.renderer = None
|
|
903
|
+
|
|
904
|
+
def validate_participant(self, participant_name, senders):
|
|
905
|
+
"""Validate that the specified participant exists in the chat.
|
|
906
|
+
If not found, display all participants and raise an error."""
|
|
907
|
+
if participant_name not in senders:
|
|
908
|
+
print(f"\nError: Participant '{participant_name}' not found in the chat.")
|
|
909
|
+
print("\nFound the following participants in the chat:")
|
|
910
|
+
for i, sender in enumerate(senders, 1):
|
|
911
|
+
print(f"{i}. {sender}")
|
|
912
|
+
print("\nPlease use one of the names listed above exactly as shown.")
|
|
913
|
+
raise ValueError(f"Participant '{participant_name}' not found in chat")
|
|
914
|
+
return True
|
|
915
|
+
|
|
916
|
+
def parse_date_input(self, date_str):
|
|
917
|
+
"""Parse date string in either US or German format."""
|
|
918
|
+
return DateRange.parse_date_input(date_str, self.date_formats)
|
|
919
|
+
|
|
920
|
+
def setup_modular_components(self):
|
|
921
|
+
"""Initialize the MessageParser and HTMLRenderer components."""
|
|
922
|
+
# Setup parser
|
|
923
|
+
self.parser = MessageParser(
|
|
924
|
+
is_ios=self.is_ios,
|
|
925
|
+
has_media=self.has_media,
|
|
926
|
+
attachments_in_zip=self.attachments_in_zip
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
# Setup renderer
|
|
931
|
+
self.renderer = HTMLRenderer(
|
|
932
|
+
output_dir=self.output_dir,
|
|
933
|
+
has_media=self.has_media
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
@staticmethod
|
|
937
|
+
def most_similar(target: str, candidates: list[str]) -> str:
|
|
938
|
+
"""Return the string from candidates most similar to target."""
|
|
939
|
+
return max(candidates, key=lambda c: difflib.SequenceMatcher(None, target, c).ratio())
|
|
940
|
+
|
|
941
|
+
def process_chat(self):
|
|
942
|
+
# Ask for optional date range
|
|
943
|
+
print("\nOptional: Enter date range to filter messages")
|
|
944
|
+
print("Supported formats: MM/DD/YYYY, DD.MM.YYYY, MM/DD/YY, DD.MM.YY")
|
|
945
|
+
print("Leave empty to skip")
|
|
946
|
+
while True:
|
|
947
|
+
try:
|
|
948
|
+
from_date_str = input("From date (optional): ").strip()
|
|
949
|
+
self.from_date = self.parse_date_input(from_date_str)
|
|
950
|
+
break
|
|
951
|
+
except ValueError as e:
|
|
952
|
+
print(f"Error: {e}")
|
|
953
|
+
if input("Try again? [Y/n]: ").lower() == 'n':
|
|
954
|
+
break
|
|
955
|
+
|
|
956
|
+
while True:
|
|
957
|
+
try:
|
|
958
|
+
until_date_str = input("Until date (optional): ").strip()
|
|
959
|
+
self.until_date = self.parse_date_input(until_date_str)
|
|
960
|
+
if self.from_date and self.until_date and self.from_date > self.until_date:
|
|
961
|
+
raise ValueError("'From' date must be before 'until' date")
|
|
962
|
+
break
|
|
963
|
+
except ValueError as e:
|
|
964
|
+
print(f"Error: {e}")
|
|
965
|
+
if input("Try again? [Y/n]: ").lower() == 'n':
|
|
966
|
+
break
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
# Get the base name of the zip file without extension
|
|
970
|
+
zip_base_name = Path(self.zip_path).stem
|
|
971
|
+
|
|
972
|
+
if os.path.exists(self.output_dir):
|
|
973
|
+
print(f"Cleaning existing directory: {self.output_dir}")
|
|
974
|
+
shutil.rmtree(self.output_dir)
|
|
975
|
+
|
|
976
|
+
# Create fresh output directories
|
|
977
|
+
os.makedirs(self.output_dir)
|
|
978
|
+
os.makedirs(self.media_dir)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
with zipfile.ZipFile(self.zip_path, 'r') as zip_ref:
|
|
983
|
+
chat_file_candidates = [f for f in zip_ref.namelist() if f.lower().endswith('.txt')]
|
|
984
|
+
if '_chat.txt' in chat_file_candidates:
|
|
985
|
+
self.is_ios = True
|
|
986
|
+
chat_file = '_chat.txt'
|
|
987
|
+
else:
|
|
988
|
+
self.is_ios = False
|
|
989
|
+
chat_file = self.most_similar(f"{zip_base_name}.txt", chat_file_candidates)
|
|
990
|
+
|
|
991
|
+
# Extract media files
|
|
992
|
+
for file in zip_ref.namelist():
|
|
993
|
+
if file != chat_file:
|
|
994
|
+
self.attachments_in_zip.add(file)
|
|
995
|
+
self.has_media = True
|
|
996
|
+
|
|
997
|
+
# If still not found, raise error
|
|
998
|
+
if chat_file not in zip_ref.namelist():
|
|
999
|
+
raise FileNotFoundError(f"The chat file '{chat_file}' does not exist in the ZIP archive. Not a valid WhatsApp export zip.")
|
|
1000
|
+
|
|
1001
|
+
with zip_ref.open(chat_file) as f:
|
|
1002
|
+
chat_content = f.read().decode('utf-8')
|
|
1003
|
+
if self.has_media:
|
|
1004
|
+
print(f"ZIP file is an {'iOS' if self.is_ios else 'Android'} export with media/attachments, '{chat_file}' is the chat text file.")
|
|
1005
|
+
else:
|
|
1006
|
+
print(f"ZIP file is an {'iOS' if self.is_ios else 'Android'} export without media/attachments, '{chat_file}' is the chat text file.")
|
|
1007
|
+
shutil.rmtree(self.media_dir)
|
|
1008
|
+
# Setup modular components now that we know the platform and media status
|
|
1009
|
+
self.setup_modular_components()
|
|
1010
|
+
|
|
1011
|
+
# Create date range for filtering
|
|
1012
|
+
date_range = DateRange(self.from_date, self.until_date) if (self.from_date or self.until_date) else None
|
|
1013
|
+
|
|
1014
|
+
# Get list of senders first to let user choose their name
|
|
1015
|
+
senders = self.parser.get_senders(chat_content)
|
|
1016
|
+
print("\nFound the following participants in the chat:")
|
|
1017
|
+
for i, sender in enumerate(senders, 1):
|
|
1018
|
+
print(f"{i}. {sender}")
|
|
1019
|
+
|
|
1020
|
+
while True:
|
|
1021
|
+
try:
|
|
1022
|
+
choice = int(input("\nEnter the number corresponding to your name: ")) - 1
|
|
1023
|
+
if 0 <= choice < len(senders):
|
|
1024
|
+
self.own_name = senders[choice]
|
|
1025
|
+
break
|
|
1026
|
+
else:
|
|
1027
|
+
print("Invalid choice. Please try again.")
|
|
1028
|
+
except ValueError:
|
|
1029
|
+
print("Please enter a valid number.")
|
|
1030
|
+
|
|
1031
|
+
processing_start_time = time.time()
|
|
1032
|
+
|
|
1033
|
+
# Parse messages using the new MessageParser - now returns a Chat object
|
|
1034
|
+
chat, filtered_count, total_count = self.parser.parse_messages(
|
|
1035
|
+
chat_content,
|
|
1036
|
+
chat_name=os.path.basename(self.zip_path),
|
|
1037
|
+
date_range=date_range,
|
|
1038
|
+
own_name=self.own_name
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
if date_range and date_range.is_filtered():
|
|
1042
|
+
print(f"\n{filtered_count} of {total_count} messages match date range filter.")
|
|
1043
|
+
if filtered_count == 0:
|
|
1044
|
+
raise ValueError("No messages found in the specified date range. Aborting.")
|
|
1045
|
+
print(f"Exporting {len(chat.messages)} messages.")
|
|
1046
|
+
|
|
1047
|
+
# Render messages using the new HTMLRenderer
|
|
1048
|
+
attachments_to_extract = self.renderer.render(chat)
|
|
1049
|
+
|
|
1050
|
+
if self.has_media:
|
|
1051
|
+
print("Extracting attachments/media...")
|
|
1052
|
+
# extract attachments of rendered messages
|
|
1053
|
+
with zipfile.ZipFile(self.zip_path, 'r') as zip_ref:
|
|
1054
|
+
# Extract media files
|
|
1055
|
+
for file in zip_ref.namelist():
|
|
1056
|
+
if file in attachments_to_extract:
|
|
1057
|
+
zip_ref.extract(file, self.media_dir)
|
|
1058
|
+
processing_end_time = time.time()
|
|
1059
|
+
print(f"Processing took {processing_end_time - processing_start_time:.3f} seconds")
|
|
1060
|
+
|
|
1061
|
+
def process_chat_non_interactive(self):
|
|
1062
|
+
"""Process chat in non-interactive mode using pre-set parameters."""
|
|
1063
|
+
# Validate date parameters if provided
|
|
1064
|
+
if self.from_date:
|
|
1065
|
+
try:
|
|
1066
|
+
self.from_date = self.parse_date_input(self.from_date)
|
|
1067
|
+
except ValueError as e:
|
|
1068
|
+
raise ValueError(f"Invalid from-date format: {e}")
|
|
1069
|
+
|
|
1070
|
+
if self.until_date:
|
|
1071
|
+
try:
|
|
1072
|
+
self.until_date = self.parse_date_input(self.until_date)
|
|
1073
|
+
except ValueError as e:
|
|
1074
|
+
raise ValueError(f"Invalid until-date format: {e}")
|
|
1075
|
+
|
|
1076
|
+
if self.from_date and self.until_date and self.from_date > self.until_date:
|
|
1077
|
+
raise ValueError("'From' date must be before 'until' date")
|
|
1078
|
+
|
|
1079
|
+
processing_start_time = time.time()
|
|
1080
|
+
|
|
1081
|
+
# Get the base name of the zip file without extension
|
|
1082
|
+
zip_base_name = Path(self.zip_path).stem
|
|
1083
|
+
|
|
1084
|
+
if os.path.exists(self.output_dir):
|
|
1085
|
+
print(f"Cleaning existing directory: {self.output_dir}")
|
|
1086
|
+
shutil.rmtree(self.output_dir)
|
|
1087
|
+
|
|
1088
|
+
# Create fresh output directories
|
|
1089
|
+
os.makedirs(self.output_dir)
|
|
1090
|
+
os.makedirs(self.media_dir)
|
|
1091
|
+
|
|
1092
|
+
# Validate that it's a proper ZIP file
|
|
1093
|
+
try:
|
|
1094
|
+
with zipfile.ZipFile(self.zip_path, 'r') as zip_ref:
|
|
1095
|
+
chat_file_candidates = [f for f in zip_ref.namelist() if f.lower().endswith('.txt')]
|
|
1096
|
+
if '_chat.txt' in chat_file_candidates:
|
|
1097
|
+
self.is_ios = True
|
|
1098
|
+
chat_file = '_chat.txt'
|
|
1099
|
+
else:
|
|
1100
|
+
self.is_ios = False
|
|
1101
|
+
chat_file = self.most_similar(f"{zip_base_name}.txt", chat_file_candidates)
|
|
1102
|
+
|
|
1103
|
+
# Extract media files
|
|
1104
|
+
for file in zip_ref.namelist():
|
|
1105
|
+
if file != chat_file:
|
|
1106
|
+
self.attachments_in_zip.add(file)
|
|
1107
|
+
self.has_media = True
|
|
1108
|
+
|
|
1109
|
+
# If still not found, raise error
|
|
1110
|
+
if chat_file not in zip_ref.namelist():
|
|
1111
|
+
raise FileNotFoundError(f"The chat file '{chat_file}' does not exist in the ZIP archive. Not a valid WhatsApp export zip.")
|
|
1112
|
+
|
|
1113
|
+
with zip_ref.open(chat_file) as f:
|
|
1114
|
+
chat_content = f.read().decode('utf-8')
|
|
1115
|
+
|
|
1116
|
+
except zipfile.BadZipFile:
|
|
1117
|
+
raise ValueError(f"The file {self.zip_path} is not a valid ZIP file.")
|
|
1118
|
+
|
|
1119
|
+
if self.has_media:
|
|
1120
|
+
print(f"ZIP file is an {'iOS' if self.is_ios else 'Android'} export with media/attachments, '{chat_file}' is the chat text file.")
|
|
1121
|
+
else:
|
|
1122
|
+
print(f"ZIP file is an {'iOS' if self.is_ios else 'Android'} export without media/attachments, '{chat_file}' is the chat text file.")
|
|
1123
|
+
shutil.rmtree(self.media_dir)
|
|
1124
|
+
|
|
1125
|
+
# Setup modular components now that we know the platform and media status
|
|
1126
|
+
self.setup_modular_components()
|
|
1127
|
+
|
|
1128
|
+
# Create date range for filtering
|
|
1129
|
+
date_range = DateRange(self.from_date, self.until_date) if (self.from_date or self.until_date) else None
|
|
1130
|
+
print(f"from date: {self.from_date}, until date: {self.until_date}")
|
|
1131
|
+
|
|
1132
|
+
# Get list of senders and validate the provided participant
|
|
1133
|
+
senders = self.parser.get_senders(chat_content)
|
|
1134
|
+
self.validate_participant(self.own_name, senders)
|
|
1135
|
+
|
|
1136
|
+
# Parse messages using the new MessageParser - now returns a Chat object
|
|
1137
|
+
chat, filtered_count, total_count = self.parser.parse_messages(
|
|
1138
|
+
chat_content,
|
|
1139
|
+
chat_name=os.path.basename(self.zip_path),
|
|
1140
|
+
date_range=date_range,
|
|
1141
|
+
own_name=self.own_name
|
|
1142
|
+
)
|
|
1143
|
+
|
|
1144
|
+
if date_range and date_range.is_filtered():
|
|
1145
|
+
print(f"\n{filtered_count} of {total_count} messages match date range filter.")
|
|
1146
|
+
if filtered_count == 0:
|
|
1147
|
+
raise ValueError("No messages found in the specified date range. Aborting.")
|
|
1148
|
+
print(f"Exporting {len(chat.messages)} messages.")
|
|
1149
|
+
|
|
1150
|
+
# Render messages using the new HTMLRenderer
|
|
1151
|
+
attachments_to_extract = self.renderer.render(chat)
|
|
1152
|
+
|
|
1153
|
+
if self.has_media:
|
|
1154
|
+
print("Extracting attachments/media...")
|
|
1155
|
+
# extract attachments of rendered messages
|
|
1156
|
+
with zipfile.ZipFile(self.zip_path, 'r') as zip_ref:
|
|
1157
|
+
# Extract media files
|
|
1158
|
+
for file in zip_ref.namelist():
|
|
1159
|
+
if file in attachments_to_extract:
|
|
1160
|
+
zip_ref.extract(file, self.media_dir)
|
|
1161
|
+
processing_end_time = time.time()
|
|
1162
|
+
print(f"Processing took {processing_end_time - processing_start_time:.3f} seconds")
|
|
1163
|
+
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def check_tkinter_availability():
|
|
1167
|
+
"""Check if tkinter is available and working on the system."""
|
|
1168
|
+
try:
|
|
1169
|
+
import tkinter as tk
|
|
1170
|
+
root = tk.Tk()
|
|
1171
|
+
root.destroy()
|
|
1172
|
+
return True
|
|
1173
|
+
except Exception as e:
|
|
1174
|
+
print("Tkinter is not available on your system. Using prompt input instead of file picker dialog.")
|
|
1175
|
+
return False
|
|
1176
|
+
|
|
1177
|
+
def browse_zip_file():
|
|
1178
|
+
if sys.platform == 'darwin' and pyobjc_available:
|
|
1179
|
+
result = macos_file_picker()
|
|
1180
|
+
return result
|
|
1181
|
+
elif sys.platform == 'win32' and pywin32_available:
|
|
1182
|
+
result = windows_file_picker()
|
|
1183
|
+
return result
|
|
1184
|
+
|
|
1185
|
+
# Check tkinter availability first
|
|
1186
|
+
if not check_tkinter_availability():
|
|
1187
|
+
# Fallback to command line input
|
|
1188
|
+
file_path = input("Please enter the path to your WhatsApp chat export ZIP file: ").strip()
|
|
1189
|
+
return file_path if file_path else None
|
|
1190
|
+
|
|
1191
|
+
import tkinter as tk
|
|
1192
|
+
from tkinter import filedialog
|
|
1193
|
+
# Initialize Tkinter root window and hide it
|
|
1194
|
+
root = tk.Tk()
|
|
1195
|
+
root.withdraw() # Hide the main window
|
|
1196
|
+
|
|
1197
|
+
# Open file dialog and set file type filter to .zip files
|
|
1198
|
+
zip_file_path = filedialog.askopenfilename(
|
|
1199
|
+
title="Select a WhatsApp Chat Export ZIP file",
|
|
1200
|
+
filetypes=[("ZIP files", "*.zip")]
|
|
1201
|
+
)
|
|
1202
|
+
|
|
1203
|
+
# Return the selected file path
|
|
1204
|
+
return zip_file_path
|
|
1205
|
+
|
|
1206
|
+
def open_html_file_in_browser(html_file: Path):
|
|
1207
|
+
"""Opens the specified HTML file in the default web browser."""
|
|
1208
|
+
# Get the absolute path of the file
|
|
1209
|
+
file_path = Path(os.path.abspath(html_file))
|
|
1210
|
+
# Open the file in the default web browser
|
|
1211
|
+
# file:///
|
|
1212
|
+
webbrowser.open(f"file://{file_path.as_posix()}")
|
|
1213
|
+
|
|
1214
|
+
def main():
|
|
1215
|
+
args = parse_arguments()
|
|
1216
|
+
if args.non_interactive:
|
|
1217
|
+
# Non-interactive mode
|
|
1218
|
+
print(f"chat-export v{version} - Non-interactive mode")
|
|
1219
|
+
print("----------------------------------------")
|
|
1220
|
+
success = False
|
|
1221
|
+
try:
|
|
1222
|
+
print(f"Processing file: {args.zip_file}...")
|
|
1223
|
+
|
|
1224
|
+
# Parse dates if provided
|
|
1225
|
+
from_date = args.from_date if args.from_date else None
|
|
1226
|
+
until_date = args.until_date if args.until_date else None
|
|
1227
|
+
|
|
1228
|
+
chat_export = ChatExport(args.zip_file, from_date, until_date, args.participant, args.output_dir)
|
|
1229
|
+
chat_export.process_chat_non_interactive()
|
|
1230
|
+
print(f'Written: {", ".join([str(p.absolute()) for p in chat_export.renderer.get_generated_files()])}')
|
|
1231
|
+
print("Done.")
|
|
1232
|
+
success = True
|
|
1233
|
+
|
|
1234
|
+
except FileNotFoundError as e:
|
|
1235
|
+
print(f"Error: {e}")
|
|
1236
|
+
sys.exit(1)
|
|
1237
|
+
except ValueError as e:
|
|
1238
|
+
print(f"Error: {e}")
|
|
1239
|
+
sys.exit(1)
|
|
1240
|
+
except Exception as e:
|
|
1241
|
+
print(f"An unexpected error occurred: {e}")
|
|
1242
|
+
print(traceback.format_exc())
|
|
1243
|
+
sys.exit(1)
|
|
1244
|
+
|
|
1245
|
+
else:
|
|
1246
|
+
# Interactive mode (original behavior)
|
|
1247
|
+
print(f"Welcome to chat-export v{version}")
|
|
1248
|
+
print("----------------------------------------")
|
|
1249
|
+
print("Select the WhatsApp chat export ZIP file you want to convert to HTML.")
|
|
1250
|
+
success = False
|
|
1251
|
+
try:
|
|
1252
|
+
selected_zip_file = browse_zip_file()
|
|
1253
|
+
if not selected_zip_file:
|
|
1254
|
+
raise FileNotFoundError("No file selected.")
|
|
1255
|
+
print(f"Processing selected file: {selected_zip_file}...")
|
|
1256
|
+
chat_export = ChatExport(selected_zip_file, base_output_dir=args.output_dir)
|
|
1257
|
+
chat_export.process_chat()
|
|
1258
|
+
print(f'Written: {", ".join([str(p.absolute()) for p in chat_export.renderer.get_generated_files()])}')
|
|
1259
|
+
print("Done.")
|
|
1260
|
+
open_in_browser = input("Would you like to open them in the browser? [Y/n]: ").strip().lower()
|
|
1261
|
+
if open_in_browser != 'n':
|
|
1262
|
+
for file in reversed(chat_export.renderer.get_generated_files()):
|
|
1263
|
+
open_html_file_in_browser(file.absolute())
|
|
1264
|
+
success = True
|
|
1265
|
+
|
|
1266
|
+
except FileNotFoundError as e:
|
|
1267
|
+
print(f"Error: {e}")
|
|
1268
|
+
except ValueError as e:
|
|
1269
|
+
print(f"Error: {e}")
|
|
1270
|
+
except Exception as e:
|
|
1271
|
+
print(f"An unexpected error occurred: {e}")
|
|
1272
|
+
print(traceback.format_exc())
|
|
1273
|
+
|
|
1274
|
+
if success and input("Do you like the tool and want to buy me a coffee? [y/N]: ").strip().lower() == 'y':
|
|
1275
|
+
webbrowser.open(donate_link)
|
|
1276
|
+
if not success:
|
|
1277
|
+
print("Press enter to exit")
|
|
1278
|
+
input()
|
|
1279
|
+
|
|
1280
|
+
if __name__ == "__main__":
|
|
1281
|
+
main()
|