|
- #! /usr/bin/env python3
- # WhatsApp Chat Archiver
-
- import sqlite3
- import os
- import argparse
- import html
- from datetime import datetime, timedelta
- import shutil
-
- # WhatsApp's epoch starts on 2001-01-01 00:00:00 (Core Data timestamp)
- WHATSAPP_EPOCH = datetime(2001, 1, 1)
-
- def convert_whatsapp_timestamp(ts):
- """Converts WhatsApp's Core Data timestamp to a human-readable string."""
- if not ts:
- return ""
- try:
- # Timestamps are seconds since the WhatsApp epoch
- dt = WHATSAPP_EPOCH + timedelta(seconds=ts)
- return dt.strftime('%Y-%m-%d %H:%M:%S')
- except (ValueError, TypeError):
- return "Invalid date"
-
- def get_media_tag_for_gallery(media_path, output_dir, base_path=""):
- """Generates media HTML for gallery pages with proper relative paths."""
- if not media_path:
- return ""
-
- test_path = os.path.join(output_dir, 'Message', media_path)
- if not os.path.exists(test_path):
- return ""
-
- full_media_path = os.path.join(base_path, 'Message', media_path)
- full_media_path = full_media_path.lstrip('./')
- full_media_path = f'../{full_media_path}'
-
- ext = os.path.splitext(media_path)[1].lower()
-
- if ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
- return f'<img src="{full_media_path}" loading="lazy" alt="Image" class="gallery-media">'
- elif ext in ['.mp4', '.mov', '.webm']:
- return f'<video controls src="{full_media_path}" class="gallery-media" loading="lazy"></video>'
- elif ext in ['.mp3', '.ogg', '.opus', '.m4a']:
- return f'<audio controls src="{full_media_path}" loading="lazy"></audio>'
- else:
- return f'<div class="file-media"><a href="{full_media_path}" target="_blank">📎 {os.path.basename(media_path)}</a></div>'
-
-
- def generate_all_media_gallery(db_path, output_dir):
- """Generates HTML pages showing all media files sorted by time with pagination."""
- conn = sqlite3.connect(db_path)
- cursor = conn.cursor()
-
- # Get all media messages with chat and sender information
- query = """
- SELECT
- m.ZMESSAGEDATE,
- mi.ZMEDIALOCALPATH,
- m.ZISFROMME,
- m.ZFROMJID,
- cs.ZPARTNERNAME AS ChatName,
- cs.ZCONTACTJID,
- gm_p.ZPUSHNAME AS GroupMemberName,
- p.ZPUSHNAME AS PushName,
- cs.Z_PK as ChatID,
- gm.ZMEMBERJID AS GroupMemberJID,
- sender_cs.ZPARTNERNAME AS SenderPartnerName,
- gm_fallback_p.ZPUSHNAME AS GroupMemberNameFallback
- FROM
- ZWAMESSAGE m
- LEFT JOIN
- ZWAMEDIAITEM mi ON m.ZMEDIAITEM = mi.Z_PK
- LEFT JOIN
- ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
- LEFT JOIN
- ZWAGROUPMEMBER gm ON gm.Z_PK = m.ZGROUPMEMBER
- LEFT JOIN
- ZWAPROFILEPUSHNAME gm_p ON gm.ZMEMBERJID = gm_p.ZJID
- LEFT JOIN
- ZWAPROFILEPUSHNAME p ON m.ZFROMJID = p.ZJID
- LEFT JOIN
- ZWACHATSESSION sender_cs ON sender_cs.ZCONTACTJID = m.ZFROMJID
- LEFT JOIN
- ZWAGROUPMEMBER gm_fallback ON gm_fallback.ZCHATSESSION = m.ZCHATSESSION AND gm_fallback.ZMEMBERJID = m.ZFROMJID
- LEFT JOIN
- ZWAPROFILEPUSHNAME gm_fallback_p ON gm_fallback.ZMEMBERJID = gm_fallback_p.ZJID
- WHERE
- mi.ZMEDIALOCALPATH IS NOT NULL
- AND cs.ZCONTACTJID NOT LIKE '%@status'
- ORDER BY
- m.ZMESSAGEDATE DESC;
- """
-
- cursor.execute(query)
- all_media_messages = cursor.fetchall()
- conn.close()
-
- # Filter out messages without valid media paths
- valid_media_messages = []
- for msg in all_media_messages:
- if msg[1] and os.path.exists(os.path.join(output_dir, 'Message', msg[1])):
- valid_media_messages.append(msg)
-
- total_media = len(valid_media_messages)
- items_per_page = 120 # Show 120 media items per page
- total_pages = (total_media + items_per_page - 1) // items_per_page
-
- if total_pages == 0:
- total_pages = 1
-
- # Generate each page
- for page_num in range(1, total_pages + 1):
- start_idx = (page_num - 1) * items_per_page
- end_idx = min(start_idx + items_per_page, total_media)
- page_media_messages = valid_media_messages[start_idx:end_idx]
-
- # Create media-gallery subdirectory
- media_gallery_dir = os.path.join(output_dir, "media-gallery")
- os.makedirs(media_gallery_dir, exist_ok=True)
-
- # Determine filename
- if page_num == 1:
- filename = "media-gallery.html"
- else:
- filename = f"media-gallery-page-{page_num}.html"
-
- media_gallery_path = os.path.join(media_gallery_dir, filename)
-
- with open(media_gallery_path, 'w', encoding='utf-8') as f:
- f.write(f"""
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>WhatsApp Media Gallery - Page {page_num}</title>
- <style>
- body {{
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
- background-color: #f4f4f9;
- margin: 0;
- padding: 20px;
- min-height: 100vh;
- }}
- .header {{
- background-color: #128C7E;
- color: white;
- padding: 20px;
- margin: -20px -20px 20px -20px;
- text-align: center;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- }}
- .header h1 {{
- margin: 0;
- font-size: 1.8em;
- }}
- .nav-links {{
- margin-top: 10px;
- }}
- .nav-links a {{
- color: rgba(255,255,255,0.9);
- text-decoration: none;
- margin: 0 10px;
- padding: 5px 10px;
- border-radius: 5px;
- transition: background-color 0.2s;
- }}
- .nav-links a:hover {{
- background-color: rgba(255,255,255,0.1);
- }}
- .container {{
- max-width: 1200px;
- margin: auto;
- background: white;
- padding: 20px;
- border-radius: 12px;
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
- }}
- .page-info {{
- text-align: center;
- margin-bottom: 20px;
- color: #666;
- font-size: 0.9em;
- }}
- .pagination {{
- display: flex;
- justify-content: center;
- align-items: center;
- gap: 10px;
- margin: 20px 0;
- flex-wrap: wrap;
- }}
- .pagination a, .pagination span {{
- padding: 8px 12px;
- border-radius: 6px;
- text-decoration: none;
- font-size: 0.9em;
- min-width: 35px;
- text-align: center;
- transition: all 0.2s;
- }}
- .pagination a {{
- background-color: #f8f9fa;
- color: #128C7E;
- border: 1px solid #dee2e6;
- }}
- .pagination a:hover {{
- background-color: #128C7E;
- color: white;
- }}
- .pagination .current {{
- background-color: #128C7E;
- color: white;
- border: 1px solid #128C7E;
- }}
- .pagination .disabled {{
- background-color: #e9ecef;
- color: #6c757d;
- border: 1px solid #dee2e6;
- cursor: not-allowed;
- }}
- .media-grid {{
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
- gap: 20px;
- margin-top: 20px;
- }}
- .media-item {{
- background: #f8f9fa;
- border-radius: 12px;
- padding: 15px;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- }}
- .media-header {{
- display: flex;
- align-items: center;
- margin-bottom: 10px;
- font-size: 0.9em;
- color: #666;
- }}
- .chat-name {{
- font-weight: 500;
- color: #128C7E;
- }}
- .sender-name {{
- color: #005c4b;
- margin-left: 5px;
- }}
- .timestamp {{
- margin-left: auto;
- font-size: 0.8em;
- color: #999;
- }}
- .gallery-media {{
- width: 100%;
- max-height: 300px;
- object-fit: cover;
- border-radius: 8px;
- }}
- .raw-file-link {{
- display: inline-block;
- margin-top: 8px;
- padding: 4px 8px;
- background: #128C7E;
- color: white;
- text-decoration: none;
- border-radius: 4px;
- font-size: 0.75em;
- transition: background-color 0.2s;
- }}
- .raw-file-link:hover {{
- background: #0d6b5e;
- color: white;
- text-decoration: none;
- }}
- .file-media {{
- padding: 20px;
- text-align: center;
- background: #e9ecef;
- border-radius: 8px;
- border: 2px dashed #adb5bd;
- }}
- .file-media a {{
- text-decoration: none;
- color: #495057;
- font-weight: 500;
- }}
- </style>
- </head>
- <body>
- <div class="header">
- <h1>📷 Media Gallery</h1>
- <div class="nav-links">
- <a href="../whatsapp-chats.html">← Back to Chats</a>
- <a href="../index.html">🏠 Home</a>
- </div>
- </div>
- <div class="container">
- <div class="page-info">
- Showing {start_idx + 1}-{end_idx} of {total_media} media files (Page {page_num} of {total_pages})
- </div>
- """)
-
- # Add pagination controls
- f.write('<div class="pagination">')
-
- # Previous page link
- if page_num > 1:
- prev_filename = "media-gallery.html" if page_num == 2 else f"media-gallery-page-{page_num - 1}.html"
- f.write(f'<a href="{prev_filename}">‹ Previous</a>')
- else:
- f.write('<span class="disabled">‹ Previous</span>')
-
- # Page numbers
- start_page = max(1, page_num - 2)
- end_page = min(total_pages, page_num + 2)
-
- if start_page > 1:
- f.write('<a href="media-gallery.html">1</a>')
- if start_page > 2:
- f.write('<span>...</span>')
-
- for p in range(start_page, end_page + 1):
- if p == page_num:
- f.write(f'<span class="current">{p}</span>')
- else:
- p_filename = "media-gallery.html" if p == 1 else f"media-gallery-page-{p}.html"
- f.write(f'<a href="{p_filename}">{p}</a>')
-
- if end_page < total_pages:
- if end_page < total_pages - 1:
- f.write('<span>...</span>')
- f.write(f'<a href="media-gallery-page-{total_pages}.html">{total_pages}</a>')
-
- # Next page link
- if page_num < total_pages:
- next_filename = f"media-gallery-page-{page_num + 1}.html"
- f.write(f'<a href="{next_filename}">Next ›</a>')
- else:
- f.write('<span class="disabled">Next ›</span>')
-
- f.write('</div>')
-
- # Media grid
- f.write('<div class="media-grid">')
-
- for message_date, media_path, is_from_me, from_jid, chat_name, contact_jid, group_member_name, push_name, chat_id, group_member_jid, sender_partner_name, group_member_name_fallback in page_media_messages:
- if not media_path:
- continue
-
- # Determine sender name
- if is_from_me:
- sender_name = "You"
- else:
- # For group messages, prioritize ZCONTACTNAME from ZWAGROUPMEMBER linked via ZGROUPMEMBER
- sender_name = group_member_name or group_member_name_fallback # Try direct link first, then fallback via ZFROMJID
-
- if not sender_name:
- # Try sender's partner name from their individual chat session
- sender_name = sender_partner_name or push_name
-
- if not sender_name:
- # Check if this is a group chat and ZFROMJID is the group JID (can't determine individual sender)
- if '@g.us' in str(contact_jid or '') and from_jid and '@g.us' in from_jid:
- sender_name = "Group Member" # Generic fallback for unidentifiable group messages
- elif group_member_jid and '@' in group_member_jid:
- phone_number = group_member_jid.split('@')[0]
- sender_name = f"+{phone_number}" if phone_number.isdigit() else group_member_jid
- elif from_jid and '@' in from_jid:
- phone_number = from_jid.split('@')[0]
- sender_name = f"+{phone_number}" if phone_number.isdigit() else from_jid
- else:
- sender_name = "Unknown"
-
- # Generate media HTML
- media_html = get_media_tag_for_gallery(media_path, output_dir)
- if not media_html:
- continue
-
- # Sanitize contact_jid for filename
- if contact_jid:
- safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
- else:
- safe_filename = str(chat_id)
-
- f.write(f"""
- <div class="media-item">
- <div class="media-header">
- <span class="chat-name">{html.escape(str(chat_name or "Unknown Chat"))}</span>
- <span class="sender-name">• {html.escape(str(sender_name))}</span>
- <span class="timestamp">{convert_whatsapp_timestamp(message_date)}</span>
- </div>
- <a href="../chats/{safe_filename}.html" style="text-decoration: none; color: inherit;">
- {media_html}
- </a>
- <a href="../../Message/{media_path}" target="_blank" class="raw-file-link">📁 Open File</a>
- </div>
- """)
-
- f.write('</div>')
-
- # Add pagination controls at bottom
- f.write('<div class="pagination">')
-
- # Previous page link
- if page_num > 1:
- prev_filename = "media-gallery.html" if page_num == 2 else f"media-gallery-page-{page_num - 1}.html"
- f.write(f'<a href="{prev_filename}">‹ Previous</a>')
- else:
- f.write('<span class="disabled">‹ Previous</span>')
-
- # Page numbers (simplified for bottom)
- for p in range(start_page, end_page + 1):
- if p == page_num:
- f.write(f'<span class="current">{p}</span>')
- else:
- p_filename = "media-gallery.html" if p == 1 else f"media-gallery-page-{p}.html"
- f.write(f'<a href="{p_filename}">{p}</a>')
-
- # Next page link
- if page_num < total_pages:
- next_filename = f"media-gallery-page-{page_num + 1}.html"
- f.write(f'<a href="{next_filename}">Next ›</a>')
- else:
- f.write('<span class="disabled">Next ›</span>')
-
- f.write('</div>')
-
- f.write("""
- </div>
- </body>
- </html>
- """)
-
- print(f"Generated {total_pages} media gallery pages with {total_media} total media files")
-
-
- def generate_chat_media_gallery(db_path, output_dir, chat_id, chat_name, contact_jid):
- """Generates an HTML page showing all media files for a specific chat."""
- conn = sqlite3.connect(db_path)
- cursor = conn.cursor()
-
- # Get media messages for this specific chat
- query = """
- SELECT
- m.ZMESSAGEDATE,
- mi.ZMEDIALOCALPATH,
- m.ZISFROMME,
- m.ZFROMJID,
- gm_p.ZPUSHNAME AS GroupMemberName,
- p.ZPUSHNAME AS PushName,
- gm.ZMEMBERJID AS GroupMemberJID,
- sender_cs.ZPARTNERNAME AS SenderPartnerName,
- gm_fallback_p.ZPUSHNAME AS GroupMemberNameFallback
- FROM
- ZWAMESSAGE m
- LEFT JOIN
- ZWAMEDIAITEM mi ON m.ZMEDIAITEM = mi.Z_PK
- LEFT JOIN
- ZWAGROUPMEMBER gm ON gm.Z_PK = m.ZGROUPMEMBER
- LEFT JOIN
- ZWAPROFILEPUSHNAME gm_p ON gm.ZMEMBERJID = gm_p.ZJID
- LEFT JOIN
- ZWAPROFILEPUSHNAME p ON m.ZFROMJID = p.ZJID
- LEFT JOIN
- ZWACHATSESSION sender_cs ON sender_cs.ZCONTACTJID = m.ZFROMJID
- LEFT JOIN
- ZWAGROUPMEMBER gm_fallback ON gm_fallback.ZCHATSESSION = m.ZCHATSESSION AND gm_fallback.ZMEMBERJID = m.ZFROMJID
- LEFT JOIN
- ZWAPROFILEPUSHNAME gm_fallback_p ON gm_fallback.ZMEMBERJID = gm_fallback_p.ZJID
- WHERE
- m.ZCHATSESSION = ?
- AND mi.ZMEDIALOCALPATH IS NOT NULL
- ORDER BY
- m.ZMESSAGEDATE DESC;
- """
-
- cursor.execute(query, (chat_id,))
- media_messages = cursor.fetchall()
- conn.close()
-
- if not media_messages:
- return # No media to display
-
- # Sanitize contact_jid for filename
- if contact_jid:
- safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
- else:
- safe_filename = str(chat_id)
-
- media_dir = os.path.join(output_dir, "media")
- os.makedirs(media_dir, exist_ok=True)
- media_gallery_path = os.path.join(media_dir, f"{safe_filename}.html")
-
- with open(media_gallery_path, 'w', encoding='utf-8') as f:
- f.write(f"""
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Media from {html.escape(str(chat_name))}</title>
- <style>
- body {{
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
- background-color: #f4f4f9;
- margin: 0;
- padding: 20px;
- min-height: 100vh;
- }}
- .header {{
- background-color: #128C7E;
- color: white;
- padding: 20px;
- margin: -20px -20px 20px -20px;
- text-align: center;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- }}
- .header h1 {{
- margin: 0;
- font-size: 1.8em;
- }}
- .nav-links {{
- margin-top: 10px;
- }}
- .nav-links a {{
- color: rgba(255,255,255,0.9);
- text-decoration: none;
- margin: 0 10px;
- padding: 5px 10px;
- border-radius: 5px;
- transition: background-color 0.2s;
- }}
- .nav-links a:hover {{
- background-color: rgba(255,255,255,0.1);
- }}
- .container {{
- max-width: 1200px;
- margin: auto;
- background: white;
- padding: 20px;
- border-radius: 12px;
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
- }}
- .media-grid {{
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
- gap: 15px;
- margin-top: 20px;
- }}
- .media-item {{
- background: #f8f9fa;
- border-radius: 12px;
- padding: 10px;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- transition: transform 0.2s;
- }}
- .media-item:hover {{
- transform: translateY(-2px);
- }}
- .media-header {{
- display: flex;
- align-items: center;
- margin-bottom: 8px;
- font-size: 0.85em;
- color: #666;
- }}
- .sender-name {{
- font-weight: 500;
- color: #005c4b;
- }}
- .timestamp {{
- margin-left: auto;
- font-size: 0.8em;
- color: #999;
- }}
- .gallery-media {{
- width: 100%;
- max-height: 200px;
- object-fit: cover;
- border-radius: 8px;
- cursor: pointer;
- }}
- .raw-file-link {{
- display: inline-block;
- margin-top: 8px;
- padding: 4px 8px;
- background: #128C7E;
- color: white;
- text-decoration: none;
- border-radius: 4px;
- font-size: 0.75em;
- transition: background-color 0.2s;
- }}
- .raw-file-link:hover {{
- background: #0d6b5e;
- color: white;
- text-decoration: none;
- }}
- .file-media {{
- padding: 15px;
- text-align: center;
- background: #e9ecef;
- border-radius: 8px;
- border: 2px dashed #adb5bd;
- }}
- .file-media a {{
- text-decoration: none;
- color: #495057;
- font-weight: 500;
- }}
- </style>
- </head>
- <body>
- <div class="header">
- <h1>📷 Media from {html.escape(str(chat_name))}</h1>
- <div class="nav-links">
- <a href="../chats/{safe_filename}.html">← Back to Chat</a>
- <a href="../media-gallery/media-gallery.html">🖼️ All Media</a>
- <a href="../whatsapp-chats.html">💬 All Chats</a>
- </div>
- </div>
- <div class="container">
- <p>{len(media_messages)} media files in this chat, sorted by date (newest first).</p>
- <div class="media-grid">
- """)
-
- for message_date, media_path, is_from_me, from_jid, group_member_name, push_name, group_member_jid, sender_partner_name, group_member_name_fallback in media_messages:
- if not media_path:
- continue
-
- # Determine sender name
- if is_from_me:
- sender_name = "You"
- else:
- # For group messages, prioritize ZCONTACTNAME from ZWAGROUPMEMBER linked via ZGROUPMEMBER
- sender_name = group_member_name or group_member_name_fallback # Try direct link first, then fallback via ZFROMJID
-
- if not sender_name:
- # Try sender's partner name from their individual chat session
- sender_name = sender_partner_name or push_name
-
- if not sender_name:
- # Check if this is a group chat and ZFROMJID is the group JID (can't determine individual sender)
- if contact_jid and '@g.us' in contact_jid and from_jid and '@g.us' in from_jid:
- sender_name = "Group Member" # Generic fallback for unidentifiable group messages
- elif group_member_jid and '@' in group_member_jid:
- phone_number = group_member_jid.split('@')[0]
- sender_name = f"+{phone_number}" if phone_number.isdigit() else group_member_jid
- elif from_jid and '@' in from_jid:
- phone_number = from_jid.split('@')[0]
- sender_name = f"+{phone_number}" if phone_number.isdigit() else from_jid
- else:
- sender_name = "Unknown"
-
- # Generate media HTML with proper relative path
- media_html = get_media_tag_for_gallery(media_path, output_dir, "../")
- if not media_html:
- continue
-
- f.write(f"""
- <div class="media-item">
- <div class="media-header">
- <span class="sender-name">{html.escape(str(sender_name))}</span>
- <span class="timestamp">{convert_whatsapp_timestamp(message_date)}</span>
- </div>
- {media_html}
- <a href="../Message/{media_path}" target="_blank" class="raw-file-link">📁 Open File</a>
- </div>
- """)
-
- f.write("""
- </div>
- </div>
- </body>
- </html>
- """)
-
- print(f"Generated chat media gallery for: {chat_name}")
-
-
- def get_media_tag(media_path, output_dir):
- """Generates the appropriate HTML tag for a given media file and copies it."""
- if not media_path:
- return ""
-
- # Path in the DB is often relative like 'Media/WhatsApp Images/IMG-...'
- test_path = os.path.join(output_dir, 'Message', media_path)
- full_media_path = ''
- if not os.path.exists(test_path):
- return f'<div class="media-missing">Media not found: {html.escape(test_path)}</div>'
-
- full_media_path = os.path.join('Message', media_path)
- # remove ./ in the beginning if present
- full_media_path = full_media_path.lstrip('./')
-
- ext = os.path.splitext(media_path)[1].lower()
-
- if ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
- return f'<img src="../{full_media_path}" loading="lazy" alt="Image" class="media-item">'
- elif ext in ['.mp4', '.mov', '.webm']:
- return f'<video controls src="../{full_media_path}" class="media-item" loading="lazy"></video>'
- elif ext in ['.mp3', '.ogg', '.opus', '.m4a']:
- return f'<audio controls src="../{full_media_path}" loading="lazy"></audio>'
- else:
- return f'<a href="../{full_media_path}" target="_blank">View Media: {os.path.basename(media_path)}</a>'
-
- def generate_html_chat(db_path, media_path, output_dir, chat_id, chat_name, is_group, contact_jid):
- """Generates an HTML file for a single chat session."""
- conn = sqlite3.connect(db_path)
- cursor = conn.cursor()
-
- # Updated query to fetch more potential name fields and properly resolve group member names
- query = """
- SELECT
- m.ZISFROMME,
- m.ZTEXT,
- m.ZMESSAGEDATE,
- m.ZFROMJID,
- gm_p.ZPUSHNAME AS GroupMemberContactName,
- cs.ZPARTNERNAME AS ChatPartnerName,
- p.ZPUSHNAME AS ProfilePushName,
- mi.ZMEDIALOCALPATH,
- cs.ZCONTACTJID AS ChatJID,
- gm.ZMEMBERJID AS GroupMemberJID,
- sender_cs.ZPARTNERNAME AS SenderPartnerName,
- gm_fallback_p.ZPUSHNAME AS GroupMemberContactNameFallback
- FROM
- ZWAMESSAGE m
- LEFT JOIN
- ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
- LEFT JOIN
- ZWAGROUPMEMBER gm ON gm.Z_PK = m.ZGROUPMEMBER
- LEFT JOIN
- ZWAPROFILEPUSHNAME gm_p ON gm.ZMEMBERJID = gm_p.ZJID
- LEFT JOIN
- ZWAPROFILEPUSHNAME p ON m.ZFROMJID = p.ZJID
- LEFT JOIN
- ZWAMEDIAITEM mi ON m.ZMEDIAITEM = mi.Z_PK
- LEFT JOIN
- ZWACHATSESSION sender_cs ON sender_cs.ZCONTACTJID = m.ZFROMJID
- LEFT JOIN
- ZWAGROUPMEMBER gm_fallback ON gm_fallback.ZCHATSESSION = m.ZCHATSESSION AND gm_fallback.ZMEMBERJID = m.ZFROMJID
- LEFT JOIN
- ZWAPROFILEPUSHNAME gm_fallback_p ON gm_fallback.ZMEMBERJID = gm_fallback_p.ZJID
- WHERE
- m.ZCHATSESSION = ?
- ORDER BY
- m.ZMESSAGEDATE ASC;
- """
-
- cursor.execute(query, (chat_id,))
- messages = cursor.fetchall()
- conn.close()
-
- if not messages:
- print(f"No messages found for chat: {chat_name}")
- return
-
- # Sanitize contact_jid for a unique and safe filename
- if contact_jid:
- safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
- else:
- # Fallback to chat_id if contact_jid is not available
- safe_filename = str(chat_id)
-
- chats_dir = os.path.join(output_dir, "chats")
- os.makedirs(chats_dir, exist_ok=True)
- html_filename = os.path.join(chats_dir, f"{safe_filename}.html")
-
- with open(html_filename, 'w', encoding='utf-8') as f:
- f.write(f"""
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Chat with {html.escape(chat_name)}</title>
- <style>
- body {{
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
- background-color: #e5ddd5;
- margin: 0;
- padding: 20px;
- color: #111b21;
- min-height: 100vh;
- box-sizing: border-box;
- }}
- .chat-container {{
- max-width: 800px;
- margin: auto;
- background-image: url('../current_wallpaper.jpg');
- background-size: auto 100%;
- background-attachment: fixed;
- background-position: center;
- border-radius: 8px;
- box-shadow: 0 1px 1px 0 rgba(0,0,0,0.06), 0 2px 5px 0 rgba(0,0,0,0.06);
- overflow: hidden;
- }}
- .chat-header {{
- background-color: #008069;
- color: white;
- padding: 15px 20px;
- font-size: 1.2em;
- text-align: center;
- }}
- .nav-links {{
- margin-top: 8px;
- font-size: 0.8em;
- }}
- .nav-links a {{
- color: rgba(255,255,255,0.9);
- text-decoration: none;
- margin: 0 8px;
- padding: 3px 8px;
- border-radius: 3px;
- transition: background-color 0.2s;
- }}
- .nav-links a:hover {{
- background-color: rgba(255,255,255,0.1);
- }}
-
-
- .chat-header-id {{
- font-size: 0.7em;
- opacity: 0.8;
- margin-top: 5px;
- }}
-
- .chat-box {{
- padding: 20px;
- display: flex;
- flex-direction: column;
- gap: 12px;
- }}
- .message {{
- padding: 8px 12px;
- border-radius: 18px;
- max-width: 70%;
- word-wrap: break-word;
- position: relative;
- }}
- .message.sent {{
- background-color: #dcf8c6;
- align-self: flex-end;
- border-bottom-right-radius: 4px;
- }}
- .message.received {{
- background-color: #ffffff;
- align-self: flex-start;
- border-bottom-left-radius: 4px;
- }}
- .sender-name {{
- font-weight: bold;
- font-size: 0.9em;
- color: #005c4b;
- margin-bottom: 4px;
- }}
- .timestamp {{
- font-size: 0.75em;
- color: #667781;
- margin-top: 5px;
- text-align: right;
- }}
- .media-item {{
- max-width: 100%;
- border-radius: 8px;
- margin-top: 5px;
- display: block;
- }}
- .media-missing {{
- font-style: italic;
- color: #888;
- background-color: #fcebeb;
- border: 1px solid #f5c6cb;
- padding: 10px;
- border-radius: 8px;
- }}
- </style>
- </head>
- <body>
- <div class="chat-container">
- <div class="chat-header">
- {html.escape(chat_name)}
- <div class="chat-header-id">{contact_jid}</div>
- <div class="nav-links">
- <a href="../whatsapp-chats.html">← Back to Chats</a>
- <a href="../media/{safe_filename}.html">📷 Media</a>
- </div>
- </div>
- <div class="chat-box">
- """)
-
- # Write messages
- for is_from_me, text, timestamp, from_jid, group_member_contact_name, chat_partner_name, profile_push_name, media_local_path, contact_jid, group_member_jid, sender_partner_name, group_member_contact_name_fallback in messages:
- msg_class = "sent" if is_from_me else "received"
-
- f.write(f'<div class="message {msg_class}">')
-
- # Determine and display the sender's name for incoming messages
- if not is_from_me:
- # Prioritize group member contact name for group chats
- if is_group:
- # For group messages, prioritize ZCONTACTNAME from ZWAGROUPMEMBER linked via ZGROUPMEMBER
- sender_name = group_member_contact_name or group_member_contact_name_fallback # Try direct link first, then fallback via ZFROMJID
-
- if not sender_name:
- # Try sender's partner name from their individual chat session
- sender_name = sender_partner_name or profile_push_name
-
- if not sender_name:
- # Check if this is a group chat and ZFROMJID is the group JID (can't determine individual sender)
- if contact_jid and '@g.us' in contact_jid and from_jid and '@g.us' in from_jid:
- sender_name = "Group Member" # Generic fallback for unidentifiable group messages
- elif group_member_jid and '@' in group_member_jid:
- phone_number = group_member_jid.split('@')[0]
- sender_name = f"+{phone_number}" if phone_number.isdigit() else group_member_jid
- elif from_jid and '@' in from_jid:
- phone_number = from_jid.split('@')[0]
- sender_name = f"+{phone_number}" if phone_number.isdigit() else from_jid
- else:
- sender_name = "Unknown"
- else:
- # For individual chats, prefer partner name or push name
- sender_name = chat_partner_name or profile_push_name or from_jid or "Unknown"
-
- f.write(f'<div class="sender-name">{html.escape(str(sender_name))}</div>')
-
- if text:
- # Replace newline characters with <br> tags for proper display
- escaped_text = html.escape(text)
- f.write(f'<div>{escaped_text.replace(chr(10), "<br>")}</div>')
-
- if media_local_path:
- # print("Media path:", media_local_path)
- f.write(get_media_tag(media_local_path, output_dir))
-
- f.write(f'<div class="timestamp">{convert_whatsapp_timestamp(timestamp)}</div>')
- f.write('</div>')
-
- f.write("""
- </div>
- </div>
- </body>
- </html>
- """)
-
- print(f"Successfully generated HTML for: {chat_name}")
-
-
- # Step: iPhone backup manifest.db processing
- def process_iphone_backup(backup_path, output_dir):
- """
- Processes the iPhone backup manifest.db, extracts WhatsApp shared files, and recreates the file structure in output_dir.
- Acts as an archiver to accumulate data across multiple imports without overwriting existing data.
- """
- manifest_db_path = os.path.join(backup_path, 'Manifest.db')
- if not os.path.exists(manifest_db_path):
- print(f"Manifest.db not found in backup path: {manifest_db_path}")
- return
-
- # Connect to manifest.db and extract WhatsApp shared files
- backup_conn = sqlite3.connect(manifest_db_path)
- backup_cursor = backup_conn.cursor()
- backup_cursor.execute("SELECT fileID, domain, relativePath FROM Files WHERE domain = ?", ('AppDomainGroup-group.net.whatsapp.WhatsApp.shared',))
- files = backup_cursor.fetchall()
- print(f"Found {len(files)} WhatsApp shared files in manifest.db.")
- backup_conn.close()
-
- # Count for statistics
- new_files = 0
- updated_files = 0
- skipped_files = 0
- special_db_files = 0
-
- # Check for SQLite database files that need special handling
- db_files_to_merge = [
- 'ChatStorage.sqlite',
- 'CallHistory.sqlite',
- 'DeviceAgents.sqlite',
- 'Labels.sqlite',
- 'Ranking.sqlite',
- 'Sticker.sqlite'
- ]
-
- print('Copying WhatsApp shared files to archive location...')
- # Prepare to recreate file structure
- for fileID, domain, relativePath in files:
- src_file = os.path.join(backup_path, fileID[:2], fileID)
- dest_file = os.path.join(output_dir, relativePath)
- os.makedirs(os.path.dirname(dest_file), exist_ok=True)
-
- if not os.path.exists(src_file):
- # print(f"Source file missing: {src_file}")
- skipped_files += 1
- continue
-
- # Handle SQLite database files specially - merge data instead of overwriting
- file_basename = os.path.basename(dest_file)
- if file_basename in db_files_to_merge and os.path.exists(dest_file):
- special_db_files += 1
- try:
- # For SQLite databases, we need to merge the data
- if file_basename == 'ChatStorage.sqlite':
- merge_chat_database(src_file, dest_file)
- else:
- # For other SQLite databases, make a backup and then replace
- # Future enhancement: implement proper merging for all database types
- backup_file = f"{dest_file}.backup_{datetime.now().strftime('%Y%m%d%H%M%S')}"
- shutil.copy2(dest_file, backup_file)
- print(f"Created backup of {file_basename} as {os.path.basename(backup_file)}")
- shutil.copy2(src_file, dest_file)
- except Exception as e:
- print(f"Error processing database {dest_file}: {e}")
- continue
-
- # For non-database files
- if os.path.exists(dest_file):
- # If file exists, we want to keep the newer one
- # For media files, we always keep them (accumulate data)
- is_media_file = any(relativePath.startswith(prefix) for prefix in ['Media/', 'Message/', 'ProfilePictures/', 'Avatar/'])
-
- if is_media_file:
- # For media files, don't overwrite but create a version with timestamp if different
- if not files_are_identical(src_file, dest_file):
- filename, ext = os.path.splitext(dest_file)
- timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
- new_dest_file = f"{filename}_{timestamp}{ext}"
- try:
- shutil.copy2(src_file, new_dest_file)
- print(f"Saved additional version of media file: {os.path.relpath(new_dest_file, output_dir)}")
- new_files += 1
- except Exception as e:
- print(f"Error copying alternate version {src_file}: {e}")
- skipped_files += 1
- else:
- skipped_files += 1
- else:
- # For non-media files, we'll take the newer one
- try:
- shutil.copy2(src_file, dest_file)
- updated_files += 1
- except Exception as e:
- print(f"Error updating {dest_file}: {e}")
- skipped_files += 1
- else:
- # If file doesn't exist, copy it
- try:
- shutil.copy2(src_file, dest_file)
- new_files += 1
- except Exception as e:
- print(f"Error copying {src_file} to {dest_file}: {e}")
- skipped_files += 1
-
- print(f"\nBackup import summary:")
- print(f"- Added {new_files} new files")
- print(f"- Updated {updated_files} existing files")
- print(f"- Special handling for {special_db_files} database files")
- print(f"- Skipped {skipped_files} files")
-
-
- def files_are_identical(file1, file2):
- """Compare two files to see if they are identical in content."""
- if os.path.getsize(file1) != os.path.getsize(file2):
- return False
-
- # For larger files, just compare a sample to avoid reading entire files into memory
- if os.path.getsize(file1) > 1024*1024: # 1MB threshold
- with open(file1, 'rb') as f1, open(file2, 'rb') as f2:
- # Compare the first and last 4KB of the file
- start1 = f1.read(4096)
- start2 = f2.read(4096)
- if start1 != start2:
- return False
-
- f1.seek(-4096, 2) # 2 is os.SEEK_END
- f2.seek(-4096, 2)
- end1 = f1.read(4096)
- end2 = f2.read(4096)
- return end1 == end2
- else:
- # For smaller files, read entire contents for comparison
- with open(file1, 'rb') as f1, open(file2, 'rb') as f2:
- return f1.read() == f2.read()
-
-
- def merge_chat_database(src_file, dest_file):
- """
- Merge WhatsApp chat databases to combine messages from multiple backups.
- This preserves all existing messages and adds only new ones.
- """
- print(f"Merging chat databases to preserve existing messages...")
-
- # Create a temporary copy for processing
- temp_file = f"{dest_file}.temp"
- shutil.copy2(dest_file, temp_file)
-
- try:
- # Connect to both databases
- src_conn = sqlite3.connect(src_file)
- dest_conn = sqlite3.connect(temp_file)
-
- # Make it safer by enabling foreign keys
- src_conn.execute("PRAGMA foreign_keys = OFF")
- dest_conn.execute("PRAGMA foreign_keys = OFF")
-
- # Get all messages from source
- src_cursor = src_conn.cursor()
- src_cursor.execute("SELECT Z_PK FROM ZWAMESSAGE")
- src_message_ids = {row[0] for row in src_cursor.fetchall()}
-
- # Get all messages from destination to avoid duplicates
- dest_cursor = dest_conn.cursor()
- dest_cursor.execute("SELECT Z_PK FROM ZWAMESSAGE")
- dest_message_ids = {row[0] for row in dest_cursor.fetchall()}
-
- # Find new message IDs that don't exist in the destination
- new_message_ids = src_message_ids - dest_message_ids
-
- if not new_message_ids:
- print("No new messages to import")
- src_conn.close()
- dest_conn.close()
- os.remove(temp_file)
- return
-
- print(f"Found {len(new_message_ids)} new messages to import")
-
- # Tables that need to be merged (simplified for this example)
- tables_to_check = [
- "ZWAMESSAGE", "ZWAMEDIAITEM", "ZWAGROUPMEMBER",
- "ZWACHATSESSION", "ZWAPROFILEPUSHNAME"
- ]
-
- # For each table, copy new records
- for table in tables_to_check:
- # Check if table exists
- src_cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table}'")
- if not src_cursor.fetchone():
- print(f"Table {table} doesn't exist in source database, skipping...")
- continue
-
- # Get column names
- src_cursor.execute(f"PRAGMA table_info({table})")
- columns = [row[1] for row in src_cursor.fetchall()]
- column_str = ", ".join(columns)
-
- # For each message ID, copy related records
- for msg_id in new_message_ids:
- # This is simplified - in reality you'd need more complex logic to follow foreign key relationships
- src_cursor.execute(f"SELECT {column_str} FROM {table} WHERE Z_PK = ?", (msg_id,))
- rows = src_cursor.fetchall()
-
- for row in rows:
- # Skip existing records with same primary key
- dest_cursor.execute(f"SELECT 1 FROM {table} WHERE Z_PK = ?", (row[0],))
- if dest_cursor.fetchone():
- continue
-
- # Insert new record
- placeholders = ", ".join(["?" for _ in row])
- dest_cursor.execute(f"INSERT OR IGNORE INTO {table} ({column_str}) VALUES ({placeholders})", row)
-
- # Commit changes
- dest_conn.commit()
-
- # Close connections
- src_conn.close()
- dest_conn.close()
-
- # Replace destination file with merged file
- os.rename(temp_file, dest_file)
- print(f"Successfully merged chat databases")
-
- except Exception as e:
- print(f"Error merging databases: {e}")
- if os.path.exists(temp_file):
- os.remove(temp_file)
-
-
- def main():
- parser = argparse.ArgumentParser(description="WhatsApp Chat Exporter")
- parser.add_argument("--output-path", default="./", help="Directory to save the archive")
- parser.add_argument("--backup-path", default=None, help="Path to iPhone backup directory (for manifest.db processing)")
- args = parser.parse_args()
-
- if args.backup_path:
- process_iphone_backup(args.backup_path, args.output_path)
- # Use backup paths for archive creation
- db_path = os.path.join(args.output_path, "ChatStorage.sqlite")
- media_path = os.path.join(args.output_path, "Message/")
- else:
- parser.add_argument("db_path", help="Path to the ChatStorage.sqlite file.")
- parser.add_argument("media_path", help="Path to the root 'Media' directory.")
- args = parser.parse_args()
- db_path = args.db_path
- media_path = args.media_path
-
- if not os.path.exists(db_path):
- print(f"Error: Database file not found at '{db_path}'")
- return
-
- if not os.path.exists(media_path):
- print(f"Error: Media directory not found at '{media_path}'")
- return
-
- os.makedirs(args.output_path, exist_ok=True)
-
- conn = sqlite3.connect(db_path)
- cursor = conn.cursor()
-
- # Get all chats, joining with ZWAPROFILEPUSHNAME and using COALESCE to get the best possible name.
- cursor.execute("""
- SELECT
- cs.Z_PK,
- COALESCE(p.ZPUSHNAME, cs.ZPARTNERNAME) AS ChatName,
- cs.ZCONTACTJID,
- cs.ZMESSAGECOUNTER,
- MIN(m.ZMESSAGEDATE) as FirstMessageDate,
- MAX(m.ZMESSAGEDATE) as LastMessageDate,
- COALESCE(gi.ZPICTUREPATH, pic.ZPATH) AS AvatarPath
- FROM
- ZWACHATSESSION cs
- LEFT JOIN
- ZWAPROFILEPUSHNAME p ON cs.ZCONTACTJID = p.ZJID
- LEFT JOIN
- ZWAMESSAGE m ON cs.Z_PK = m.ZCHATSESSION
- LEFT JOIN
- ZWAGROUPINFO gi ON cs.ZGROUPINFO = gi.Z_PK
- LEFT JOIN
- ZWAPROFILEPICTUREITEM pic ON cs.ZCONTACTJID = pic.ZJID
- WHERE
- cs.ZCONTACTJID NOT LIKE '%@status'
- GROUP BY
- cs.Z_PK, ChatName, cs.ZCONTACTJID, cs.ZMESSAGECOUNTER, AvatarPath
- ORDER BY
- LastMessageDate DESC NULLS LAST, ChatName
- """)
- chats = cursor.fetchall()
- conn.close()
-
- print(f"Found {len(chats)} chats to export.")
-
- index_path = os.path.join(args.output_path, "whatsapp-chats.html")
- with open(index_path, 'w', encoding='utf-8') as index_f:
- index_f.write(f"""
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>WhatsApp Chat Export</title>
- <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path fill='%23128C7E' d='M12 2C6.5 2 2 6.5 2 12c0 2 .6 3.9 1.6 5.4L2 22l4.6-1.6c1.5 1 3.4 1.6 5.4 1.6 5.5 0 10-4.5 10-10S17.5 2 12 2z'/></svg>">
- <style>
- body {{
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
- background-color: #f4f4f9;
- margin: 0;
- padding: 20px;
- min-height: 100vh;
- }}
- .header {{
- background-color: #128C7E;
- color: white;
- padding: 20px;
- margin: -20px -20px 20px -20px;
- text-align: center;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- }}
- .header h1 {{
- margin: 0;
- font-size: 1.8em;
- }}
- .export-info {{
- color: rgba(255,255,255,0.9);
- margin-top: 8px;
- font-size: 0.9em;
- }}
- .container {{
- max-width: 700px;
- margin: auto;
- background: white;
- padding: 20px;
- border-radius: 12px;
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
- }}
- ul {{ list-style-type: none; padding: 0; }}
- li {{ margin: 8px 0; }}
- .chat-entry {{
- text-decoration: none;
- color: #0056b3;
- background-color: #fff;
- padding: 12px;
- border-radius: 8px;
- display: flex;
- align-items: center;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- transition: all 0.2s ease-in-out;
- gap: 12px;
- }}
- a.chat-entry:hover {{
- background-color: #e9ecef;
- transform: translateY(-2px);
- box-shadow: 0 4px 8px rgba(0,0,0,0.15);
- }}
- .chat-entry.inactive {{
- color: #999;
- background-color: #f8f9fa;
- cursor: default;
- }}
- .chat-avatar {{
- width: 48px;
- height: 48px;
- border-radius: 50%;
- background-size: cover;
- background-position: center;
- flex-shrink: 0;
- }}
- .chat-avatar.default-individual {{
- background-color: #DFE5E7;
- background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23999"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>');
- }}
- .chat-avatar.default-group {{
- background-color: #DFE5E7;
- background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23999"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>');
- }}
- .chat-info {{
- flex-grow: 1;
- min-width: 0;
- }}
- .message-count {{
- background-color: #128C7E;
- color: white;
- padding: 4px 8px;
- border-radius: 12px;
- font-size: 0.85em;
- min-width: 24px;
- text-align: center;
- }}
- .message-count.zero {{
- background-color: #ddd;
- }}
- .chat-info {{
- display: flex;
- flex-direction: column;
- gap: 4px;
- }}
- .chat-name {{
- font-weight: 500;
- }}
- .date-range {{
- font-size: 0.8em;
- color: #667781;
- }}
- .chat-entry.inactive .date-range {{
- color: #999;
- }}
- </style>
- </head>
- <body>
- <div class="header">
- <h1>WhatsApp Chat Export</h1>
- <div class="export-info">Exported on {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
- <div style="margin-top: 10px;">
- <a href="media-gallery/media-gallery.html" style="color: rgba(255,255,255,0.9); text-decoration: none; padding: 5px 10px; border-radius: 5px; background-color: rgba(255,255,255,0.1);">📷 View All Media</a>
- </div>
- </div>
- <div class="container">
- <ul>
- """)
- for chat_id, chat_name, contact_jid, message_count, first_message_date, last_message_date, avatar_path in chats:
- if not chat_name:
- chat_name = f"Unknown Chat ({contact_jid or chat_id})"
- full_avatar_path = avatar_path if avatar_path and os.path.isabs(avatar_path) else os.path.join(args.output_path, avatar_path) if avatar_path else None
-
- # Find all file paths in args.output_path that start with full_avatar_path
- matching_files = []
- if full_avatar_path:
- for root, dirs, files in os.walk(args.output_path):
- for file in files:
- file_path = os.path.join(root, file)
- if file_path.startswith(full_avatar_path):
- matching_files.append(file_path)
-
- # Use the first matching file if available
- if matching_files:
- avatar_path = os.path.relpath(matching_files[0], args.output_path)
- full_avatar_path = matching_files[0]
-
- # A group chat JID typically ends with '@g.us'
- is_group = contact_jid and '@g.us' in contact_jid
-
- # Sanitize contact_jid for a unique and safe filename
- if contact_jid:
- safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
- else:
- # Fallback to chat_id if contact_jid is not available
- safe_filename = str(chat_id)
-
- # Add default avatar based on chat type
- if avatar_path and os.path.exists(full_avatar_path):
- avatar_html = f'<div class="chat-avatar" style="background-image: url(\'{avatar_path}\');"></div>'
- else:
- avatar_html = f'<div class="chat-avatar default-{"group" if is_group else "individual"}"></div>'
-
- # Format date range
- date_range = ""
- if message_count > 0 and first_message_date and last_message_date:
- first_date = convert_whatsapp_timestamp(first_message_date).split()[0] # Get just the date part
- last_date = convert_whatsapp_timestamp(last_message_date).split()[0]
- if first_date == last_date:
- date_range = first_date
- else:
- date_range = f"{first_date} – {last_date}"
-
- if message_count > 0:
- # Generate chat HTML only for chats with messages
- generate_html_chat(db_path, media_path, args.output_path, chat_id, chat_name, is_group, contact_jid)
-
- # Generate individual chat media gallery
- generate_chat_media_gallery(db_path, args.output_path, chat_id, chat_name, contact_jid)
-
- # Clickable entry with link
- index_f.write(
- f'<li><a class="chat-entry" href="chats/{html.escape(safe_filename)}.html">'
- f'{avatar_html}'
- f'<div class="chat-info">'
- f'<span class="chat-name">{html.escape(str(chat_name))}</span>'
- f'<span class="date-range">{date_range}</span>'
- f'</div>'
- f'<span class="message-count">{message_count:,}</span>'
- f'</a></li>'
- )
- else:
- # Non-clickable entry for empty chats
- index_f.write(
- f'<li><div class="chat-entry inactive">'
- f'{avatar_html}'
- f'<div class="chat-info">'
- f'<span class="chat-name">{html.escape(str(chat_name))}</span>'
- f'<span class="date-range">No messages</span>'
- f'</div>'
- f'<span class="message-count zero">0</span>'
- f'</div></li>'
- )
-
- index_f.write("</ul></div></body></html>")
-
- # Generate the all-media gallery
- generate_all_media_gallery(db_path, args.output_path)
-
- # Create a simple redirect index.html
- redirect_index = os.path.join(args.output_path, "index.html")
- with open(redirect_index, 'w', encoding='utf-8') as f:
- f.write(f"""<!DOCTYPE html>
- <html>
- <head>
- <meta http-equiv="refresh" content="0; url=whatsapp-chats.html">
- <title>Redirecting to WhatsApp Chats...</title>
- </head>
- <body>
- <p>Redirecting to <a href="whatsapp-chats.html">WhatsApp Chats</a>...</p>
- </body>
- </html>""")
-
- print(f"\nExport complete!")
- print(f"View your chats by opening either of these files in your browser:")
- print(f" • {os.path.abspath(index_path)}")
- print(f" • {os.path.abspath(redirect_index)}")
- print(f"\nAdditional features:")
- print(f" • Media Gallery: {os.path.abspath(os.path.join(args.output_path, 'media-gallery', 'media-gallery.html'))}")
- print(f" • Individual chat media galleries available in the media/ folder")
-
-
- if __name__ == "__main__":
- main()
|