Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

773 Zeilen
30 KiB

  1. #! /usr/bin/env python3
  2. # WhatsApp Chat Archiver
  3. import sqlite3
  4. import os
  5. import argparse
  6. import html
  7. from datetime import datetime, timedelta
  8. import shutil
  9. # WhatsApp's epoch starts on 2001-01-01 00:00:00 (Core Data timestamp)
  10. WHATSAPP_EPOCH = datetime(2001, 1, 1)
  11. def convert_whatsapp_timestamp(ts):
  12. """Converts WhatsApp's Core Data timestamp to a human-readable string."""
  13. if not ts:
  14. return ""
  15. try:
  16. # Timestamps are seconds since the WhatsApp epoch
  17. dt = WHATSAPP_EPOCH + timedelta(seconds=ts)
  18. return dt.strftime('%Y-%m-%d %H:%M:%S')
  19. except (ValueError, TypeError):
  20. return "Invalid date"
  21. def get_media_tag(media_path, output_dir):
  22. """Generates the appropriate HTML tag for a given media file and copies it."""
  23. if not media_path:
  24. return ""
  25. # Path in the DB is often relative like 'Media/WhatsApp Images/IMG-...'
  26. test_path = os.path.join(output_dir, 'Message', media_path)
  27. full_media_path = ''
  28. if not os.path.exists(test_path):
  29. return f'<div class="media-missing">Media not found: {html.escape(test_path)}</div>'
  30. full_media_path = os.path.join('Message', media_path)
  31. # remove ./ in the beginning if present
  32. full_media_path = full_media_path.lstrip('./')
  33. ext = os.path.splitext(media_path)[1].lower()
  34. if ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
  35. return f'<img src="../{full_media_path}" loading="lazy" alt="Image" class="media-item">'
  36. elif ext in ['.mp4', '.mov', '.webm']:
  37. return f'<video controls src="../{full_media_path}" class="media-item" loading="lazy"></video>'
  38. elif ext in ['.mp3', '.ogg', '.opus', '.m4a']:
  39. return f'<audio controls src="../{full_media_path}" loading="lazy"></audio>'
  40. else:
  41. return f'<a href="../{full_media_path}" target="_blank">View Media: {os.path.basename(media_path)}</a>'
  42. def generate_html_chat(db_path, media_path, output_dir, chat_id, chat_name, is_group, contact_jid):
  43. """Generates an HTML file for a single chat session."""
  44. conn = sqlite3.connect(db_path)
  45. cursor = conn.cursor()
  46. # Updated query to fetch more potential name fields (like ZFIRSTNAME) to find the best one.
  47. query = """
  48. SELECT
  49. m.ZISFROMME,
  50. m.ZTEXT,
  51. m.ZMESSAGEDATE,
  52. m.ZFROMJID,
  53. g.ZCONTACTNAME AS GroupMemberContactName,
  54. cs.ZPARTNERNAME AS ChatPartnerName,
  55. p.ZPUSHNAME AS ProfilePushName,
  56. mi.ZMEDIALOCALPATH,
  57. cs.ZCONTACTJID AS ChatJID
  58. FROM
  59. ZWAMESSAGE m
  60. LEFT JOIN
  61. ZWAGROUPMEMBER g ON m.ZGROUPMEMBER = g.Z_PK
  62. LEFT JOIN
  63. ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
  64. LEFT JOIN
  65. ZWAPROFILEPUSHNAME p ON m.ZFROMJID = p.ZJID
  66. LEFT JOIN
  67. ZWAMEDIAITEM mi ON m.ZMEDIAITEM = mi.Z_PK
  68. WHERE
  69. m.ZCHATSESSION = ?
  70. ORDER BY
  71. m.ZMESSAGEDATE ASC;
  72. """
  73. cursor.execute(query, (chat_id,))
  74. messages = cursor.fetchall()
  75. conn.close()
  76. if not messages:
  77. print(f"No messages found for chat: {chat_name}")
  78. return
  79. # Sanitize contact_jid for a unique and safe filename
  80. if contact_jid:
  81. safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
  82. else:
  83. # Fallback to chat_id if contact_jid is not available
  84. safe_filename = str(chat_id)
  85. chats_dir = os.path.join(output_dir, "chats")
  86. os.makedirs(chats_dir, exist_ok=True)
  87. html_filename = os.path.join(chats_dir, f"{safe_filename}.html")
  88. with open(html_filename, 'w', encoding='utf-8') as f:
  89. f.write(f"""
  90. <!DOCTYPE html>
  91. <html lang="en">
  92. <head>
  93. <meta charset="UTF-8">
  94. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  95. <title>Chat with {html.escape(chat_name)}</title>
  96. <style>
  97. body {{
  98. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  99. background-color: #e5ddd5;
  100. margin: 0;
  101. padding: 20px;
  102. color: #111b21;
  103. min-height: 100vh;
  104. box-sizing: border-box;
  105. }}
  106. .chat-container {{
  107. max-width: 800px;
  108. margin: auto;
  109. background-image: url('../current_wallpaper.jpg');
  110. background-size: auto 100%;
  111. background-attachment: fixed;
  112. background-position: center;
  113. border-radius: 8px;
  114. box-shadow: 0 1px 1px 0 rgba(0,0,0,0.06), 0 2px 5px 0 rgba(0,0,0,0.06);
  115. overflow: hidden;
  116. }}
  117. .chat-header {{
  118. background-color: #008069;
  119. color: white;
  120. padding: 15px 20px;
  121. font-size: 1.2em;
  122. text-align: center;
  123. }}
  124. .chat-header-id {{
  125. font-size: 0.7em;
  126. opacity: 0.8;
  127. margin-top: 5px;
  128. }}
  129. .chat-box {{
  130. padding: 20px;
  131. display: flex;
  132. flex-direction: column;
  133. gap: 12px;
  134. }}
  135. .message {{
  136. padding: 8px 12px;
  137. border-radius: 18px;
  138. max-width: 70%;
  139. word-wrap: break-word;
  140. position: relative;
  141. }}
  142. .message.sent {{
  143. background-color: #dcf8c6;
  144. align-self: flex-end;
  145. border-bottom-right-radius: 4px;
  146. }}
  147. .message.received {{
  148. background-color: #ffffff;
  149. align-self: flex-start;
  150. border-bottom-left-radius: 4px;
  151. }}
  152. .sender-name {{
  153. font-weight: bold;
  154. font-size: 0.9em;
  155. color: #005c4b;
  156. margin-bottom: 4px;
  157. }}
  158. .timestamp {{
  159. font-size: 0.75em;
  160. color: #667781;
  161. margin-top: 5px;
  162. text-align: right;
  163. }}
  164. .media-item {{
  165. max-width: 100%;
  166. border-radius: 8px;
  167. margin-top: 5px;
  168. display: block;
  169. }}
  170. .media-missing {{
  171. font-style: italic;
  172. color: #888;
  173. background-color: #fcebeb;
  174. border: 1px solid #f5c6cb;
  175. padding: 10px;
  176. border-radius: 8px;
  177. }}
  178. </style>
  179. </head>
  180. <body>
  181. <div class="chat-container">
  182. <div class="chat-header">
  183. {html.escape(chat_name)}
  184. <div class="chat-header-id">{contact_jid}</div>
  185. </div>
  186. <div class="chat-box">
  187. """)
  188. # Write messages
  189. for is_from_me, text, timestamp, from_jid, group_member_contact_name, chat_partner_name, profile_push_name, media_local_path, contact_jid in messages:
  190. msg_class = "sent" if is_from_me else "received"
  191. f.write(f'<div class="message {msg_class}">')
  192. # Determine and display the sender's name for incoming messages
  193. if not is_from_me:
  194. # Prioritize group member contact name for group chats
  195. if is_group:
  196. # Try names in order of preference, avoiding encoded-looking strings
  197. potential_names = [
  198. group_member_contact_name,
  199. profile_push_name,
  200. from_jid,
  201. chat_partner_name,
  202. ]
  203. # Filter out None values and strings that look like they're encoded
  204. valid_names = [name for name in potential_names if name and not (
  205. name.startswith('CK') and any(c.isupper() for c in name[2:]) and '=' in name
  206. )]
  207. sender_name = next((name for name in valid_names), "Unknown")
  208. else:
  209. # For individual chats, prefer partner name or push name
  210. sender_name = chat_partner_name or profile_push_name or from_jid or "Unknown"
  211. f.write(f'<div class="sender-name">{html.escape(str(sender_name))}</div>')
  212. if text:
  213. # Replace newline characters with <br> tags for proper display
  214. escaped_text = html.escape(text)
  215. f.write(f'<div>{escaped_text.replace(chr(10), "<br>")}</div>')
  216. if media_local_path:
  217. # print("Media path:", media_local_path)
  218. f.write(get_media_tag(media_local_path, output_dir))
  219. f.write(f'<div class="timestamp">{convert_whatsapp_timestamp(timestamp)}</div>')
  220. f.write('</div>')
  221. f.write("""
  222. </div>
  223. </div>
  224. </body>
  225. </html>
  226. """)
  227. print(f"Successfully generated HTML for: {chat_name}")
  228. # Step: iPhone backup manifest.db processing
  229. def process_iphone_backup(backup_path, output_dir):
  230. """
  231. Processes the iPhone backup manifest.db, extracts WhatsApp shared files, and recreates the file structure in output_dir.
  232. Acts as an archiver to accumulate data across multiple imports without overwriting existing data.
  233. """
  234. manifest_db_path = os.path.join(backup_path, 'Manifest.db')
  235. if not os.path.exists(manifest_db_path):
  236. print(f"Manifest.db not found in backup path: {manifest_db_path}")
  237. return
  238. # Connect to manifest.db and extract WhatsApp shared files
  239. backup_conn = sqlite3.connect(manifest_db_path)
  240. backup_cursor = backup_conn.cursor()
  241. backup_cursor.execute("SELECT fileID, domain, relativePath FROM Files WHERE domain = ?", ('AppDomainGroup-group.net.whatsapp.WhatsApp.shared',))
  242. files = backup_cursor.fetchall()
  243. print(f"Found {len(files)} WhatsApp shared files in manifest.db.")
  244. backup_conn.close()
  245. # Count for statistics
  246. new_files = 0
  247. updated_files = 0
  248. skipped_files = 0
  249. special_db_files = 0
  250. # Check for SQLite database files that need special handling
  251. db_files_to_merge = [
  252. 'ChatStorage.sqlite',
  253. 'CallHistory.sqlite',
  254. 'DeviceAgents.sqlite',
  255. 'Labels.sqlite',
  256. 'Ranking.sqlite',
  257. 'Sticker.sqlite'
  258. ]
  259. # Prepare to recreate file structure
  260. for fileID, domain, relativePath in files:
  261. src_file = os.path.join(backup_path, fileID[:2], fileID)
  262. dest_file = os.path.join(output_dir, relativePath)
  263. os.makedirs(os.path.dirname(dest_file), exist_ok=True)
  264. if not os.path.exists(src_file):
  265. print(f"Source file missing: {src_file}")
  266. skipped_files += 1
  267. continue
  268. # Handle SQLite database files specially - merge data instead of overwriting
  269. file_basename = os.path.basename(dest_file)
  270. if file_basename in db_files_to_merge and os.path.exists(dest_file):
  271. special_db_files += 1
  272. try:
  273. # For SQLite databases, we need to merge the data
  274. if file_basename == 'ChatStorage.sqlite':
  275. merge_chat_database(src_file, dest_file)
  276. else:
  277. # For other SQLite databases, make a backup and then replace
  278. # Future enhancement: implement proper merging for all database types
  279. backup_file = f"{dest_file}.backup_{datetime.now().strftime('%Y%m%d%H%M%S')}"
  280. shutil.copy2(dest_file, backup_file)
  281. print(f"Created backup of {file_basename} as {os.path.basename(backup_file)}")
  282. shutil.copy2(src_file, dest_file)
  283. except Exception as e:
  284. print(f"Error processing database {dest_file}: {e}")
  285. continue
  286. # For non-database files
  287. if os.path.exists(dest_file):
  288. # If file exists, we want to keep the newer one
  289. # For media files, we always keep them (accumulate data)
  290. is_media_file = any(relativePath.startswith(prefix) for prefix in ['Media/', 'Message/', 'ProfilePictures/', 'Avatar/'])
  291. if is_media_file:
  292. # For media files, don't overwrite but create a version with timestamp if different
  293. if not files_are_identical(src_file, dest_file):
  294. filename, ext = os.path.splitext(dest_file)
  295. timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
  296. new_dest_file = f"{filename}_{timestamp}{ext}"
  297. try:
  298. shutil.copy2(src_file, new_dest_file)
  299. print(f"Saved additional version of media file: {os.path.relpath(new_dest_file, output_dir)}")
  300. new_files += 1
  301. except Exception as e:
  302. print(f"Error copying alternate version {src_file}: {e}")
  303. skipped_files += 1
  304. else:
  305. skipped_files += 1
  306. else:
  307. # For non-media files, we'll take the newer one
  308. try:
  309. shutil.copy2(src_file, dest_file)
  310. updated_files += 1
  311. except Exception as e:
  312. print(f"Error updating {dest_file}: {e}")
  313. skipped_files += 1
  314. else:
  315. # If file doesn't exist, copy it
  316. try:
  317. shutil.copy2(src_file, dest_file)
  318. new_files += 1
  319. except Exception as e:
  320. print(f"Error copying {src_file} to {dest_file}: {e}")
  321. skipped_files += 1
  322. print(f"\nBackup import summary:")
  323. print(f"- Added {new_files} new files")
  324. print(f"- Updated {updated_files} existing files")
  325. print(f"- Special handling for {special_db_files} database files")
  326. print(f"- Skipped {skipped_files} files")
  327. def files_are_identical(file1, file2):
  328. """Compare two files to see if they are identical in content."""
  329. if os.path.getsize(file1) != os.path.getsize(file2):
  330. return False
  331. # For larger files, just compare a sample to avoid reading entire files into memory
  332. if os.path.getsize(file1) > 1024*1024: # 1MB threshold
  333. with open(file1, 'rb') as f1, open(file2, 'rb') as f2:
  334. # Compare the first and last 4KB of the file
  335. start1 = f1.read(4096)
  336. start2 = f2.read(4096)
  337. if start1 != start2:
  338. return False
  339. f1.seek(-4096, 2) # 2 is os.SEEK_END
  340. f2.seek(-4096, 2)
  341. end1 = f1.read(4096)
  342. end2 = f2.read(4096)
  343. return end1 == end2
  344. else:
  345. # For smaller files, read entire contents for comparison
  346. with open(file1, 'rb') as f1, open(file2, 'rb') as f2:
  347. return f1.read() == f2.read()
  348. def merge_chat_database(src_file, dest_file):
  349. """
  350. Merge WhatsApp chat databases to combine messages from multiple backups.
  351. This preserves all existing messages and adds only new ones.
  352. """
  353. print(f"Merging chat databases to preserve existing messages...")
  354. # Create a temporary copy for processing
  355. temp_file = f"{dest_file}.temp"
  356. shutil.copy2(dest_file, temp_file)
  357. try:
  358. # Connect to both databases
  359. src_conn = sqlite3.connect(src_file)
  360. dest_conn = sqlite3.connect(temp_file)
  361. # Make it safer by enabling foreign keys
  362. src_conn.execute("PRAGMA foreign_keys = OFF")
  363. dest_conn.execute("PRAGMA foreign_keys = OFF")
  364. # Get all messages from source
  365. src_cursor = src_conn.cursor()
  366. src_cursor.execute("SELECT Z_PK FROM ZWAMESSAGE")
  367. src_message_ids = {row[0] for row in src_cursor.fetchall()}
  368. # Get all messages from destination to avoid duplicates
  369. dest_cursor = dest_conn.cursor()
  370. dest_cursor.execute("SELECT Z_PK FROM ZWAMESSAGE")
  371. dest_message_ids = {row[0] for row in dest_cursor.fetchall()}
  372. # Find new message IDs that don't exist in the destination
  373. new_message_ids = src_message_ids - dest_message_ids
  374. if not new_message_ids:
  375. print("No new messages to import")
  376. src_conn.close()
  377. dest_conn.close()
  378. os.remove(temp_file)
  379. return
  380. print(f"Found {len(new_message_ids)} new messages to import")
  381. # Tables that need to be merged (simplified for this example)
  382. tables_to_check = [
  383. "ZWAMESSAGE", "ZWAMEDIAITEM", "ZWAGROUPMEMBER",
  384. "ZWACHATSESSION", "ZWAPROFILEPUSHNAME"
  385. ]
  386. # For each table, copy new records
  387. for table in tables_to_check:
  388. # Check if table exists
  389. src_cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table}'")
  390. if not src_cursor.fetchone():
  391. print(f"Table {table} doesn't exist in source database, skipping...")
  392. continue
  393. # Get column names
  394. src_cursor.execute(f"PRAGMA table_info({table})")
  395. columns = [row[1] for row in src_cursor.fetchall()]
  396. column_str = ", ".join(columns)
  397. # For each message ID, copy related records
  398. for msg_id in new_message_ids:
  399. # This is simplified - in reality you'd need more complex logic to follow foreign key relationships
  400. src_cursor.execute(f"SELECT {column_str} FROM {table} WHERE Z_PK = ?", (msg_id,))
  401. rows = src_cursor.fetchall()
  402. for row in rows:
  403. # Skip existing records with same primary key
  404. dest_cursor.execute(f"SELECT 1 FROM {table} WHERE Z_PK = ?", (row[0],))
  405. if dest_cursor.fetchone():
  406. continue
  407. # Insert new record
  408. placeholders = ", ".join(["?" for _ in row])
  409. dest_cursor.execute(f"INSERT OR IGNORE INTO {table} ({column_str}) VALUES ({placeholders})", row)
  410. # Commit changes
  411. dest_conn.commit()
  412. # Close connections
  413. src_conn.close()
  414. dest_conn.close()
  415. # Replace destination file with merged file
  416. os.rename(temp_file, dest_file)
  417. print(f"Successfully merged chat databases")
  418. except Exception as e:
  419. print(f"Error merging databases: {e}")
  420. if os.path.exists(temp_file):
  421. os.remove(temp_file)
  422. def main():
  423. parser = argparse.ArgumentParser(description="WhatsApp Chat Exporter")
  424. parser.add_argument("--output", default="_html_export", help="Directory to save the HTML files.")
  425. parser.add_argument("--backup-path", default=None, help="Path to iPhone backup directory (for manifest.db processing)")
  426. args = parser.parse_args()
  427. if args.backup_path:
  428. process_iphone_backup(args.backup_path, args.output)
  429. # Use backup paths for archive creation
  430. db_path = os.path.join(args.output, "ChatStorage.sqlite")
  431. media_path = os.path.join(args.output, "Message/")
  432. else:
  433. parser.add_argument("db_path", help="Path to the ChatStorage.sqlite file.")
  434. parser.add_argument("media_path", help="Path to the root 'Media' directory.")
  435. args = parser.parse_args()
  436. db_path = args.db_path
  437. media_path = args.media_path
  438. if not os.path.exists(db_path):
  439. print(f"Error: Database file not found at '{db_path}'")
  440. return
  441. if not os.path.exists(media_path):
  442. print(f"Error: Media directory not found at '{media_path}'")
  443. return
  444. os.makedirs(args.output, exist_ok=True)
  445. conn = sqlite3.connect(db_path)
  446. cursor = conn.cursor()
  447. # Get all chats, joining with ZWAPROFILEPUSHNAME and using COALESCE to get the best possible name.
  448. cursor.execute("""
  449. SELECT
  450. cs.Z_PK,
  451. COALESCE(p.ZPUSHNAME, cs.ZPARTNERNAME) AS ChatName,
  452. cs.ZCONTACTJID,
  453. cs.ZMESSAGECOUNTER,
  454. MIN(m.ZMESSAGEDATE) as FirstMessageDate,
  455. MAX(m.ZMESSAGEDATE) as LastMessageDate,
  456. COALESCE(gi.ZPICTUREPATH, pic.ZPATH) AS AvatarPath
  457. FROM
  458. ZWACHATSESSION cs
  459. LEFT JOIN
  460. ZWAPROFILEPUSHNAME p ON cs.ZCONTACTJID = p.ZJID
  461. LEFT JOIN
  462. ZWAMESSAGE m ON cs.Z_PK = m.ZCHATSESSION
  463. LEFT JOIN
  464. ZWAGROUPINFO gi ON cs.ZGROUPINFO = gi.Z_PK
  465. LEFT JOIN
  466. ZWAPROFILEPICTUREITEM pic ON cs.ZCONTACTJID = pic.ZJID
  467. WHERE
  468. cs.ZCONTACTJID NOT LIKE '%@status'
  469. GROUP BY
  470. cs.Z_PK, ChatName, cs.ZCONTACTJID, cs.ZMESSAGECOUNTER, AvatarPath
  471. ORDER BY
  472. LastMessageDate DESC NULLS LAST, ChatName
  473. """)
  474. chats = cursor.fetchall()
  475. conn.close()
  476. print(f"Found {len(chats)} chats to export.")
  477. index_path = os.path.join(args.output, "whatsapp-chats.html")
  478. with open(index_path, 'w', encoding='utf-8') as index_f:
  479. index_f.write(f"""
  480. <!DOCTYPE html>
  481. <html lang="en">
  482. <head>
  483. <meta charset="UTF-8">
  484. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  485. <title>WhatsApp Chat Export</title>
  486. <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>">
  487. <style>
  488. body {{
  489. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  490. background-color: #f4f4f9;
  491. margin: 0;
  492. padding: 20px;
  493. min-height: 100vh;
  494. }}
  495. .header {{
  496. background-color: #128C7E;
  497. color: white;
  498. padding: 20px;
  499. margin: -20px -20px 20px -20px;
  500. text-align: center;
  501. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  502. }}
  503. .header h1 {{
  504. margin: 0;
  505. font-size: 1.8em;
  506. }}
  507. .export-info {{
  508. color: rgba(255,255,255,0.9);
  509. margin-top: 8px;
  510. font-size: 0.9em;
  511. }}
  512. .container {{
  513. max-width: 700px;
  514. margin: auto;
  515. background: white;
  516. padding: 20px;
  517. border-radius: 12px;
  518. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  519. }}
  520. ul {{ list-style-type: none; padding: 0; }}
  521. li {{ margin: 8px 0; }}
  522. .chat-entry {{
  523. text-decoration: none;
  524. color: #0056b3;
  525. background-color: #fff;
  526. padding: 12px;
  527. border-radius: 8px;
  528. display: flex;
  529. align-items: center;
  530. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  531. transition: all 0.2s ease-in-out;
  532. gap: 12px;
  533. }}
  534. a.chat-entry:hover {{
  535. background-color: #e9ecef;
  536. transform: translateY(-2px);
  537. box-shadow: 0 4px 8px rgba(0,0,0,0.15);
  538. }}
  539. .chat-entry.inactive {{
  540. color: #999;
  541. background-color: #f8f9fa;
  542. cursor: default;
  543. }}
  544. .chat-avatar {{
  545. width: 48px;
  546. height: 48px;
  547. border-radius: 50%;
  548. background-size: cover;
  549. background-position: center;
  550. flex-shrink: 0;
  551. }}
  552. .chat-avatar.default-individual {{
  553. background-color: #DFE5E7;
  554. 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>');
  555. }}
  556. .chat-avatar.default-group {{
  557. background-color: #DFE5E7;
  558. 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>');
  559. }}
  560. .chat-info {{
  561. flex-grow: 1;
  562. min-width: 0;
  563. }}
  564. .message-count {{
  565. background-color: #128C7E;
  566. color: white;
  567. padding: 4px 8px;
  568. border-radius: 12px;
  569. font-size: 0.85em;
  570. min-width: 24px;
  571. text-align: center;
  572. }}
  573. .message-count.zero {{
  574. background-color: #ddd;
  575. }}
  576. .chat-info {{
  577. display: flex;
  578. flex-direction: column;
  579. gap: 4px;
  580. }}
  581. .chat-name {{
  582. font-weight: 500;
  583. }}
  584. .date-range {{
  585. font-size: 0.8em;
  586. color: #667781;
  587. }}
  588. .chat-entry.inactive .date-range {{
  589. color: #999;
  590. }}
  591. </style>
  592. </head>
  593. <body>
  594. <div class="header">
  595. <h1>WhatsApp Chat Export</h1>
  596. <div class="export-info">Exported on {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
  597. </div>
  598. <div class="container">
  599. <ul>
  600. """)
  601. for chat_id, chat_name, contact_jid, message_count, first_message_date, last_message_date, avatar_path in chats:
  602. if not chat_name:
  603. chat_name = f"Unknown Chat ({contact_jid or chat_id})"
  604. full_avatar_path = avatar_path if avatar_path and os.path.isabs(avatar_path) else os.path.join(args.output, avatar_path) if avatar_path else None
  605. # Find all file paths in args.output that start with full_avatar_path
  606. matching_files = []
  607. if full_avatar_path:
  608. for root, dirs, files in os.walk(args.output):
  609. for file in files:
  610. file_path = os.path.join(root, file)
  611. if file_path.startswith(full_avatar_path):
  612. matching_files.append(file_path)
  613. # Use the first matching file if available
  614. if matching_files:
  615. avatar_path = os.path.relpath(matching_files[0], args.output)
  616. full_avatar_path = matching_files[0]
  617. # A group chat JID typically ends with '@g.us'
  618. is_group = contact_jid and '@g.us' in contact_jid
  619. # Sanitize contact_jid for a unique and safe filename
  620. if contact_jid:
  621. safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
  622. else:
  623. # Fallback to chat_id if contact_jid is not available
  624. safe_filename = str(chat_id)
  625. # Add default avatar based on chat type
  626. if avatar_path and os.path.exists(full_avatar_path):
  627. avatar_html = f'<div class="chat-avatar" style="background-image: url(\'{avatar_path}\');"></div>'
  628. else:
  629. avatar_html = f'<div class="chat-avatar default-{"group" if is_group else "individual"}"></div>'
  630. # Format date range
  631. date_range = ""
  632. if message_count > 0 and first_message_date and last_message_date:
  633. first_date = convert_whatsapp_timestamp(first_message_date).split()[0] # Get just the date part
  634. last_date = convert_whatsapp_timestamp(last_message_date).split()[0]
  635. if first_date == last_date:
  636. date_range = first_date
  637. else:
  638. date_range = f"{first_date} – {last_date}"
  639. if message_count > 0:
  640. # Generate chat HTML only for chats with messages
  641. generate_html_chat(db_path, media_path, args.output, chat_id, chat_name, is_group, contact_jid)
  642. # Clickable entry with link
  643. index_f.write(
  644. f'<li><a class="chat-entry" href="chats/{html.escape(safe_filename)}.html">'
  645. f'{avatar_html}'
  646. f'<div class="chat-info">'
  647. f'<span class="chat-name">{html.escape(str(chat_name))}</span>'
  648. f'<span class="date-range">{date_range}</span>'
  649. f'</div>'
  650. f'<span class="message-count">{message_count:,}</span>'
  651. f'</a></li>'
  652. )
  653. else:
  654. # Non-clickable entry for empty chats
  655. index_f.write(
  656. f'<li><div class="chat-entry inactive">'
  657. f'{avatar_html}'
  658. f'<div class="chat-info">'
  659. f'<span class="chat-name">{html.escape(str(chat_name))}</span>'
  660. f'<span class="date-range">No messages</span>'
  661. f'</div>'
  662. f'<span class="message-count zero">0</span>'
  663. f'</div></li>'
  664. )
  665. index_f.write("</ul></div></body></html>")
  666. # Create a simple redirect index.html
  667. redirect_index = os.path.join(args.output, "index.html")
  668. with open(redirect_index, 'w', encoding='utf-8') as f:
  669. f.write(f"""<!DOCTYPE html>
  670. <html>
  671. <head>
  672. <meta http-equiv="refresh" content="0; url=whatsapp-chats.html">
  673. <title>Redirecting to WhatsApp Chats...</title>
  674. </head>
  675. <body>
  676. <p>Redirecting to <a href="whatsapp-chats.html">WhatsApp Chats</a>...</p>
  677. </body>
  678. </html>""")
  679. print(f"\nExport complete!")
  680. print(f"View your chats by opening either of these files in your browser:")
  681. print(f" • {os.path.abspath(index_path)}")
  682. print(f" • {os.path.abspath(redirect_index)}")
  683. if __name__ == "__main__":
  684. main()