'
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"""
WhatsApp Media Gallery - Page {page_num}
Showing {start_idx + 1}-{end_idx} of {total_media} media files (Page {page_num} of {total_pages})
""")
# Add pagination controls
f.write('
')
# 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'‹ Previous')
else:
f.write('‹ Previous')
# Page numbers
start_page = max(1, page_num - 2)
end_page = min(total_pages, page_num + 2)
if start_page > 1:
f.write('1')
if start_page > 2:
f.write('...')
for p in range(start_page, end_page + 1):
if p == page_num:
f.write(f'{p}')
else:
p_filename = "media-gallery.html" if p == 1 else f"media-gallery-page-{p}.html"
f.write(f'{p}')
if end_page < total_pages:
if end_page < total_pages - 1:
f.write('...')
f.write(f'{total_pages}')
# Next page link
if page_num < total_pages:
next_filename = f"media-gallery-page-{page_num + 1}.html"
f.write(f'Next ›')
else:
f.write('Next ›')
f.write('
')
# Media grid
f.write('
')
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"""
{html.escape(str(chat_name or "Unknown Chat"))}• {html.escape(str(sender_name))}{convert_whatsapp_timestamp(message_date)}
')
# 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'‹ Previous')
else:
f.write('‹ Previous')
# Page numbers (simplified for bottom)
for p in range(start_page, end_page + 1):
if p == page_num:
f.write(f'{p}')
else:
p_filename = "media-gallery.html" if p == 1 else f"media-gallery-page-{p}.html"
f.write(f'{p}')
# Next page link
if page_num < total_pages:
next_filename = f"media-gallery-page-{page_num + 1}.html"
f.write(f'Next ›')
else:
f.write('Next ›')
f.write('
')
f.write("""
""")
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"""
Media from {html.escape(str(chat_name))}
{len(media_messages)} media files in this chat, sorted by date (newest first).
""")
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"""
""")
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'
Media not found: {html.escape(test_path)}
'
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''
elif ext in ['.mp4', '.mov', '.webm']:
return f''
elif ext in ['.mp3', '.ogg', '.opus', '.m4a']:
return f''
else:
return f'View Media: {os.path.basename(media_path)}'
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"""
Chat with {html.escape(chat_name)}
""")
# 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'
')
# 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'
{html.escape(str(sender_name))}
')
if text:
# Replace newline characters with tags for proper display
escaped_text = html.escape(text)
f.write(f'
{escaped_text.replace(chr(10), " ")}
')
if media_local_path:
# print("Media path:", media_local_path)
f.write(get_media_tag(media_local_path, output_dir))
f.write(f'
{convert_whatsapp_timestamp(timestamp)}
')
f.write('
')
f.write("""
""")
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"""
WhatsApp Chat Export
WhatsApp Chat Export
Exported on {datetime.now().strftime('%Y-%m-%d %H:%M')}
""")
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''
else:
avatar_html = f''
# 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'
""")
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()