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()