您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

1479 行
54 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_for_gallery(media_path, output_dir, base_path=""):
  22. """Generates media HTML for gallery pages with proper relative paths."""
  23. if not media_path:
  24. return ""
  25. test_path = os.path.join(output_dir, 'Message', media_path)
  26. if not os.path.exists(test_path):
  27. return ""
  28. full_media_path = os.path.join(base_path, 'Message', media_path)
  29. full_media_path = full_media_path.lstrip('./')
  30. full_media_path = f'../{full_media_path}'
  31. ext = os.path.splitext(media_path)[1].lower()
  32. if ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
  33. return f'<img src="{full_media_path}" loading="lazy" alt="Image" class="gallery-media">'
  34. elif ext in ['.mp4', '.mov', '.webm']:
  35. return f'<video controls src="{full_media_path}" class="gallery-media" loading="lazy"></video>'
  36. elif ext in ['.mp3', '.ogg', '.opus', '.m4a']:
  37. return f'<audio controls src="{full_media_path}" loading="lazy"></audio>'
  38. else:
  39. return f'<div class="file-media"><a href="{full_media_path}" target="_blank">📎 {os.path.basename(media_path)}</a></div>'
  40. def generate_all_media_gallery(db_path, output_dir):
  41. """Generates HTML pages showing all media files sorted by time with pagination."""
  42. conn = sqlite3.connect(db_path)
  43. cursor = conn.cursor()
  44. # Get all media messages with chat and sender information
  45. query = """
  46. SELECT
  47. m.ZMESSAGEDATE,
  48. mi.ZMEDIALOCALPATH,
  49. m.ZISFROMME,
  50. m.ZFROMJID,
  51. cs.ZPARTNERNAME AS ChatName,
  52. cs.ZCONTACTJID,
  53. gm_p.ZPUSHNAME AS GroupMemberName,
  54. p.ZPUSHNAME AS PushName,
  55. cs.Z_PK as ChatID,
  56. gm.ZMEMBERJID AS GroupMemberJID,
  57. sender_cs.ZPARTNERNAME AS SenderPartnerName,
  58. gm_fallback_p.ZPUSHNAME AS GroupMemberNameFallback
  59. FROM
  60. ZWAMESSAGE m
  61. LEFT JOIN
  62. ZWAMEDIAITEM mi ON m.ZMEDIAITEM = mi.Z_PK
  63. LEFT JOIN
  64. ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
  65. LEFT JOIN
  66. ZWAGROUPMEMBER gm ON gm.Z_PK = m.ZGROUPMEMBER
  67. LEFT JOIN
  68. ZWAPROFILEPUSHNAME gm_p ON gm.ZMEMBERJID = gm_p.ZJID
  69. LEFT JOIN
  70. ZWAPROFILEPUSHNAME p ON m.ZFROMJID = p.ZJID
  71. LEFT JOIN
  72. ZWACHATSESSION sender_cs ON sender_cs.ZCONTACTJID = m.ZFROMJID
  73. LEFT JOIN
  74. ZWAGROUPMEMBER gm_fallback ON gm_fallback.ZCHATSESSION = m.ZCHATSESSION AND gm_fallback.ZMEMBERJID = m.ZFROMJID
  75. LEFT JOIN
  76. ZWAPROFILEPUSHNAME gm_fallback_p ON gm_fallback.ZMEMBERJID = gm_fallback_p.ZJID
  77. WHERE
  78. mi.ZMEDIALOCALPATH IS NOT NULL
  79. AND cs.ZCONTACTJID NOT LIKE '%@status'
  80. ORDER BY
  81. m.ZMESSAGEDATE DESC;
  82. """
  83. cursor.execute(query)
  84. all_media_messages = cursor.fetchall()
  85. conn.close()
  86. # Filter out messages without valid media paths
  87. valid_media_messages = []
  88. for msg in all_media_messages:
  89. if msg[1] and os.path.exists(os.path.join(output_dir, 'Message', msg[1])):
  90. valid_media_messages.append(msg)
  91. total_media = len(valid_media_messages)
  92. items_per_page = 120 # Show 120 media items per page
  93. total_pages = (total_media + items_per_page - 1) // items_per_page
  94. if total_pages == 0:
  95. total_pages = 1
  96. # Generate each page
  97. for page_num in range(1, total_pages + 1):
  98. start_idx = (page_num - 1) * items_per_page
  99. end_idx = min(start_idx + items_per_page, total_media)
  100. page_media_messages = valid_media_messages[start_idx:end_idx]
  101. # Create media-gallery subdirectory
  102. media_gallery_dir = os.path.join(output_dir, "media-gallery")
  103. os.makedirs(media_gallery_dir, exist_ok=True)
  104. # Determine filename
  105. if page_num == 1:
  106. filename = "media-gallery.html"
  107. else:
  108. filename = f"media-gallery-page-{page_num}.html"
  109. media_gallery_path = os.path.join(media_gallery_dir, filename)
  110. with open(media_gallery_path, 'w', encoding='utf-8') as f:
  111. f.write(f"""
  112. <!DOCTYPE html>
  113. <html lang="en">
  114. <head>
  115. <meta charset="UTF-8">
  116. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  117. <title>WhatsApp Media Gallery - Page {page_num}</title>
  118. <style>
  119. body {{
  120. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  121. background-color: #f4f4f9;
  122. margin: 0;
  123. padding: 20px;
  124. min-height: 100vh;
  125. }}
  126. .header {{
  127. background-color: #128C7E;
  128. color: white;
  129. padding: 20px;
  130. margin: -20px -20px 20px -20px;
  131. text-align: center;
  132. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  133. }}
  134. .header h1 {{
  135. margin: 0;
  136. font-size: 1.8em;
  137. }}
  138. .nav-links {{
  139. margin-top: 10px;
  140. }}
  141. .nav-links a {{
  142. color: rgba(255,255,255,0.9);
  143. text-decoration: none;
  144. margin: 0 10px;
  145. padding: 5px 10px;
  146. border-radius: 5px;
  147. transition: background-color 0.2s;
  148. }}
  149. .nav-links a:hover {{
  150. background-color: rgba(255,255,255,0.1);
  151. }}
  152. .container {{
  153. max-width: 1200px;
  154. margin: auto;
  155. background: white;
  156. padding: 20px;
  157. border-radius: 12px;
  158. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  159. }}
  160. .page-info {{
  161. text-align: center;
  162. margin-bottom: 20px;
  163. color: #666;
  164. font-size: 0.9em;
  165. }}
  166. .pagination {{
  167. display: flex;
  168. justify-content: center;
  169. align-items: center;
  170. gap: 10px;
  171. margin: 20px 0;
  172. flex-wrap: wrap;
  173. }}
  174. .pagination a, .pagination span {{
  175. padding: 8px 12px;
  176. border-radius: 6px;
  177. text-decoration: none;
  178. font-size: 0.9em;
  179. min-width: 35px;
  180. text-align: center;
  181. transition: all 0.2s;
  182. }}
  183. .pagination a {{
  184. background-color: #f8f9fa;
  185. color: #128C7E;
  186. border: 1px solid #dee2e6;
  187. }}
  188. .pagination a:hover {{
  189. background-color: #128C7E;
  190. color: white;
  191. }}
  192. .pagination .current {{
  193. background-color: #128C7E;
  194. color: white;
  195. border: 1px solid #128C7E;
  196. }}
  197. .pagination .disabled {{
  198. background-color: #e9ecef;
  199. color: #6c757d;
  200. border: 1px solid #dee2e6;
  201. cursor: not-allowed;
  202. }}
  203. .media-grid {{
  204. display: grid;
  205. grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  206. gap: 20px;
  207. margin-top: 20px;
  208. }}
  209. .media-item {{
  210. background: #f8f9fa;
  211. border-radius: 12px;
  212. padding: 15px;
  213. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  214. }}
  215. .media-header {{
  216. display: flex;
  217. align-items: center;
  218. margin-bottom: 10px;
  219. font-size: 0.9em;
  220. color: #666;
  221. }}
  222. .chat-name {{
  223. font-weight: 500;
  224. color: #128C7E;
  225. }}
  226. .sender-name {{
  227. color: #005c4b;
  228. margin-left: 5px;
  229. }}
  230. .timestamp {{
  231. margin-left: auto;
  232. font-size: 0.8em;
  233. color: #999;
  234. }}
  235. .gallery-media {{
  236. width: 100%;
  237. max-height: 300px;
  238. object-fit: cover;
  239. border-radius: 8px;
  240. }}
  241. .raw-file-link {{
  242. display: inline-block;
  243. margin-top: 8px;
  244. padding: 4px 8px;
  245. background: #128C7E;
  246. color: white;
  247. text-decoration: none;
  248. border-radius: 4px;
  249. font-size: 0.75em;
  250. transition: background-color 0.2s;
  251. }}
  252. .raw-file-link:hover {{
  253. background: #0d6b5e;
  254. color: white;
  255. text-decoration: none;
  256. }}
  257. .file-media {{
  258. padding: 20px;
  259. text-align: center;
  260. background: #e9ecef;
  261. border-radius: 8px;
  262. border: 2px dashed #adb5bd;
  263. }}
  264. .file-media a {{
  265. text-decoration: none;
  266. color: #495057;
  267. font-weight: 500;
  268. }}
  269. </style>
  270. </head>
  271. <body>
  272. <div class="header">
  273. <h1>📷 Media Gallery</h1>
  274. <div class="nav-links">
  275. <a href="../whatsapp-chats.html">← Back to Chats</a>
  276. <a href="../index.html">🏠 Home</a>
  277. </div>
  278. </div>
  279. <div class="container">
  280. <div class="page-info">
  281. Showing {start_idx + 1}-{end_idx} of {total_media} media files (Page {page_num} of {total_pages})
  282. </div>
  283. """)
  284. # Add pagination controls
  285. f.write('<div class="pagination">')
  286. # Previous page link
  287. if page_num > 1:
  288. prev_filename = "media-gallery.html" if page_num == 2 else f"media-gallery-page-{page_num - 1}.html"
  289. f.write(f'<a href="{prev_filename}">‹ Previous</a>')
  290. else:
  291. f.write('<span class="disabled">‹ Previous</span>')
  292. # Page numbers
  293. start_page = max(1, page_num - 2)
  294. end_page = min(total_pages, page_num + 2)
  295. if start_page > 1:
  296. f.write('<a href="media-gallery.html">1</a>')
  297. if start_page > 2:
  298. f.write('<span>...</span>')
  299. for p in range(start_page, end_page + 1):
  300. if p == page_num:
  301. f.write(f'<span class="current">{p}</span>')
  302. else:
  303. p_filename = "media-gallery.html" if p == 1 else f"media-gallery-page-{p}.html"
  304. f.write(f'<a href="{p_filename}">{p}</a>')
  305. if end_page < total_pages:
  306. if end_page < total_pages - 1:
  307. f.write('<span>...</span>')
  308. f.write(f'<a href="media-gallery-page-{total_pages}.html">{total_pages}</a>')
  309. # Next page link
  310. if page_num < total_pages:
  311. next_filename = f"media-gallery-page-{page_num + 1}.html"
  312. f.write(f'<a href="{next_filename}">Next ›</a>')
  313. else:
  314. f.write('<span class="disabled">Next ›</span>')
  315. f.write('</div>')
  316. # Media grid
  317. f.write('<div class="media-grid">')
  318. 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:
  319. if not media_path:
  320. continue
  321. # Determine sender name
  322. if is_from_me:
  323. sender_name = "You"
  324. else:
  325. # For group messages, prioritize ZCONTACTNAME from ZWAGROUPMEMBER linked via ZGROUPMEMBER
  326. sender_name = group_member_name or group_member_name_fallback # Try direct link first, then fallback via ZFROMJID
  327. if not sender_name:
  328. # Try sender's partner name from their individual chat session
  329. sender_name = sender_partner_name or push_name
  330. if not sender_name:
  331. # Check if this is a group chat and ZFROMJID is the group JID (can't determine individual sender)
  332. if '@g.us' in str(contact_jid or '') and from_jid and '@g.us' in from_jid:
  333. sender_name = "Group Member" # Generic fallback for unidentifiable group messages
  334. elif group_member_jid and '@' in group_member_jid:
  335. phone_number = group_member_jid.split('@')[0]
  336. sender_name = f"+{phone_number}" if phone_number.isdigit() else group_member_jid
  337. elif from_jid and '@' in from_jid:
  338. phone_number = from_jid.split('@')[0]
  339. sender_name = f"+{phone_number}" if phone_number.isdigit() else from_jid
  340. else:
  341. sender_name = "Unknown"
  342. # Generate media HTML
  343. media_html = get_media_tag_for_gallery(media_path, output_dir)
  344. if not media_html:
  345. continue
  346. # Sanitize contact_jid for filename
  347. if contact_jid:
  348. safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
  349. else:
  350. safe_filename = str(chat_id)
  351. f.write(f"""
  352. <div class="media-item">
  353. <div class="media-header">
  354. <span class="chat-name">{html.escape(str(chat_name or "Unknown Chat"))}</span>
  355. <span class="sender-name">• {html.escape(str(sender_name))}</span>
  356. <span class="timestamp">{convert_whatsapp_timestamp(message_date)}</span>
  357. </div>
  358. <a href="../chats/{safe_filename}.html" style="text-decoration: none; color: inherit;">
  359. {media_html}
  360. </a>
  361. <a href="../../Message/{media_path}" target="_blank" class="raw-file-link">📁 Open File</a>
  362. </div>
  363. """)
  364. f.write('</div>')
  365. # Add pagination controls at bottom
  366. f.write('<div class="pagination">')
  367. # Previous page link
  368. if page_num > 1:
  369. prev_filename = "media-gallery.html" if page_num == 2 else f"media-gallery-page-{page_num - 1}.html"
  370. f.write(f'<a href="{prev_filename}">‹ Previous</a>')
  371. else:
  372. f.write('<span class="disabled">‹ Previous</span>')
  373. # Page numbers (simplified for bottom)
  374. for p in range(start_page, end_page + 1):
  375. if p == page_num:
  376. f.write(f'<span class="current">{p}</span>')
  377. else:
  378. p_filename = "media-gallery.html" if p == 1 else f"media-gallery-page-{p}.html"
  379. f.write(f'<a href="{p_filename}">{p}</a>')
  380. # Next page link
  381. if page_num < total_pages:
  382. next_filename = f"media-gallery-page-{page_num + 1}.html"
  383. f.write(f'<a href="{next_filename}">Next ›</a>')
  384. else:
  385. f.write('<span class="disabled">Next ›</span>')
  386. f.write('</div>')
  387. f.write("""
  388. </div>
  389. </body>
  390. </html>
  391. """)
  392. print(f"Generated {total_pages} media gallery pages with {total_media} total media files")
  393. def generate_chat_media_gallery(db_path, output_dir, chat_id, chat_name, contact_jid):
  394. """Generates an HTML page showing all media files for a specific chat."""
  395. conn = sqlite3.connect(db_path)
  396. cursor = conn.cursor()
  397. # Get media messages for this specific chat
  398. query = """
  399. SELECT
  400. m.ZMESSAGEDATE,
  401. mi.ZMEDIALOCALPATH,
  402. m.ZISFROMME,
  403. m.ZFROMJID,
  404. gm_p.ZPUSHNAME AS GroupMemberName,
  405. p.ZPUSHNAME AS PushName,
  406. gm.ZMEMBERJID AS GroupMemberJID,
  407. sender_cs.ZPARTNERNAME AS SenderPartnerName,
  408. gm_fallback_p.ZPUSHNAME AS GroupMemberNameFallback
  409. FROM
  410. ZWAMESSAGE m
  411. LEFT JOIN
  412. ZWAMEDIAITEM mi ON m.ZMEDIAITEM = mi.Z_PK
  413. LEFT JOIN
  414. ZWAGROUPMEMBER gm ON gm.Z_PK = m.ZGROUPMEMBER
  415. LEFT JOIN
  416. ZWAPROFILEPUSHNAME gm_p ON gm.ZMEMBERJID = gm_p.ZJID
  417. LEFT JOIN
  418. ZWAPROFILEPUSHNAME p ON m.ZFROMJID = p.ZJID
  419. LEFT JOIN
  420. ZWACHATSESSION sender_cs ON sender_cs.ZCONTACTJID = m.ZFROMJID
  421. LEFT JOIN
  422. ZWAGROUPMEMBER gm_fallback ON gm_fallback.ZCHATSESSION = m.ZCHATSESSION AND gm_fallback.ZMEMBERJID = m.ZFROMJID
  423. LEFT JOIN
  424. ZWAPROFILEPUSHNAME gm_fallback_p ON gm_fallback.ZMEMBERJID = gm_fallback_p.ZJID
  425. WHERE
  426. m.ZCHATSESSION = ?
  427. AND mi.ZMEDIALOCALPATH IS NOT NULL
  428. ORDER BY
  429. m.ZMESSAGEDATE DESC;
  430. """
  431. cursor.execute(query, (chat_id,))
  432. media_messages = cursor.fetchall()
  433. conn.close()
  434. if not media_messages:
  435. return # No media to display
  436. # Sanitize contact_jid for filename
  437. if contact_jid:
  438. safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
  439. else:
  440. safe_filename = str(chat_id)
  441. media_dir = os.path.join(output_dir, "media")
  442. os.makedirs(media_dir, exist_ok=True)
  443. media_gallery_path = os.path.join(media_dir, f"{safe_filename}.html")
  444. with open(media_gallery_path, 'w', encoding='utf-8') as f:
  445. f.write(f"""
  446. <!DOCTYPE html>
  447. <html lang="en">
  448. <head>
  449. <meta charset="UTF-8">
  450. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  451. <title>Media from {html.escape(str(chat_name))}</title>
  452. <style>
  453. body {{
  454. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  455. background-color: #f4f4f9;
  456. margin: 0;
  457. padding: 20px;
  458. min-height: 100vh;
  459. }}
  460. .header {{
  461. background-color: #128C7E;
  462. color: white;
  463. padding: 20px;
  464. margin: -20px -20px 20px -20px;
  465. text-align: center;
  466. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  467. }}
  468. .header h1 {{
  469. margin: 0;
  470. font-size: 1.8em;
  471. }}
  472. .nav-links {{
  473. margin-top: 10px;
  474. }}
  475. .nav-links a {{
  476. color: rgba(255,255,255,0.9);
  477. text-decoration: none;
  478. margin: 0 10px;
  479. padding: 5px 10px;
  480. border-radius: 5px;
  481. transition: background-color 0.2s;
  482. }}
  483. .nav-links a:hover {{
  484. background-color: rgba(255,255,255,0.1);
  485. }}
  486. .container {{
  487. max-width: 1200px;
  488. margin: auto;
  489. background: white;
  490. padding: 20px;
  491. border-radius: 12px;
  492. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  493. }}
  494. .media-grid {{
  495. display: grid;
  496. grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  497. gap: 15px;
  498. margin-top: 20px;
  499. }}
  500. .media-item {{
  501. background: #f8f9fa;
  502. border-radius: 12px;
  503. padding: 10px;
  504. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  505. transition: transform 0.2s;
  506. }}
  507. .media-item:hover {{
  508. transform: translateY(-2px);
  509. }}
  510. .media-header {{
  511. display: flex;
  512. align-items: center;
  513. margin-bottom: 8px;
  514. font-size: 0.85em;
  515. color: #666;
  516. }}
  517. .sender-name {{
  518. font-weight: 500;
  519. color: #005c4b;
  520. }}
  521. .timestamp {{
  522. margin-left: auto;
  523. font-size: 0.8em;
  524. color: #999;
  525. }}
  526. .gallery-media {{
  527. width: 100%;
  528. max-height: 200px;
  529. object-fit: cover;
  530. border-radius: 8px;
  531. cursor: pointer;
  532. }}
  533. .raw-file-link {{
  534. display: inline-block;
  535. margin-top: 8px;
  536. padding: 4px 8px;
  537. background: #128C7E;
  538. color: white;
  539. text-decoration: none;
  540. border-radius: 4px;
  541. font-size: 0.75em;
  542. transition: background-color 0.2s;
  543. }}
  544. .raw-file-link:hover {{
  545. background: #0d6b5e;
  546. color: white;
  547. text-decoration: none;
  548. }}
  549. .file-media {{
  550. padding: 15px;
  551. text-align: center;
  552. background: #e9ecef;
  553. border-radius: 8px;
  554. border: 2px dashed #adb5bd;
  555. }}
  556. .file-media a {{
  557. text-decoration: none;
  558. color: #495057;
  559. font-weight: 500;
  560. }}
  561. </style>
  562. </head>
  563. <body>
  564. <div class="header">
  565. <h1>📷 Media from {html.escape(str(chat_name))}</h1>
  566. <div class="nav-links">
  567. <a href="../chats/{safe_filename}.html">← Back to Chat</a>
  568. <a href="../media-gallery/media-gallery.html">🖼️ All Media</a>
  569. <a href="../whatsapp-chats.html">💬 All Chats</a>
  570. </div>
  571. </div>
  572. <div class="container">
  573. <p>{len(media_messages)} media files in this chat, sorted by date (newest first).</p>
  574. <div class="media-grid">
  575. """)
  576. 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:
  577. if not media_path:
  578. continue
  579. # Determine sender name
  580. if is_from_me:
  581. sender_name = "You"
  582. else:
  583. # For group messages, prioritize ZCONTACTNAME from ZWAGROUPMEMBER linked via ZGROUPMEMBER
  584. sender_name = group_member_name or group_member_name_fallback # Try direct link first, then fallback via ZFROMJID
  585. if not sender_name:
  586. # Try sender's partner name from their individual chat session
  587. sender_name = sender_partner_name or push_name
  588. if not sender_name:
  589. # Check if this is a group chat and ZFROMJID is the group JID (can't determine individual sender)
  590. if contact_jid and '@g.us' in contact_jid and from_jid and '@g.us' in from_jid:
  591. sender_name = "Group Member" # Generic fallback for unidentifiable group messages
  592. elif group_member_jid and '@' in group_member_jid:
  593. phone_number = group_member_jid.split('@')[0]
  594. sender_name = f"+{phone_number}" if phone_number.isdigit() else group_member_jid
  595. elif from_jid and '@' in from_jid:
  596. phone_number = from_jid.split('@')[0]
  597. sender_name = f"+{phone_number}" if phone_number.isdigit() else from_jid
  598. else:
  599. sender_name = "Unknown"
  600. # Generate media HTML with proper relative path
  601. media_html = get_media_tag_for_gallery(media_path, output_dir, "../")
  602. if not media_html:
  603. continue
  604. f.write(f"""
  605. <div class="media-item">
  606. <div class="media-header">
  607. <span class="sender-name">{html.escape(str(sender_name))}</span>
  608. <span class="timestamp">{convert_whatsapp_timestamp(message_date)}</span>
  609. </div>
  610. {media_html}
  611. <a href="../Message/{media_path}" target="_blank" class="raw-file-link">📁 Open File</a>
  612. </div>
  613. """)
  614. f.write("""
  615. </div>
  616. </div>
  617. </body>
  618. </html>
  619. """)
  620. print(f"Generated chat media gallery for: {chat_name}")
  621. def get_media_tag(media_path, output_dir):
  622. """Generates the appropriate HTML tag for a given media file and copies it."""
  623. if not media_path:
  624. return ""
  625. # Path in the DB is often relative like 'Media/WhatsApp Images/IMG-...'
  626. test_path = os.path.join(output_dir, 'Message', media_path)
  627. full_media_path = ''
  628. if not os.path.exists(test_path):
  629. return f'<div class="media-missing">Media not found: {html.escape(test_path)}</div>'
  630. full_media_path = os.path.join('Message', media_path)
  631. # remove ./ in the beginning if present
  632. full_media_path = full_media_path.lstrip('./')
  633. ext = os.path.splitext(media_path)[1].lower()
  634. if ext in ['.jpg', '.jpeg', '.png', '.gif', '.webp']:
  635. return f'<img src="../{full_media_path}" loading="lazy" alt="Image" class="media-item">'
  636. elif ext in ['.mp4', '.mov', '.webm']:
  637. return f'<video controls src="../{full_media_path}" class="media-item" loading="lazy"></video>'
  638. elif ext in ['.mp3', '.ogg', '.opus', '.m4a']:
  639. return f'<audio controls src="../{full_media_path}" loading="lazy"></audio>'
  640. else:
  641. return f'<a href="../{full_media_path}" target="_blank">View Media: {os.path.basename(media_path)}</a>'
  642. def generate_html_chat(db_path, media_path, output_dir, chat_id, chat_name, is_group, contact_jid):
  643. """Generates an HTML file for a single chat session."""
  644. conn = sqlite3.connect(db_path)
  645. cursor = conn.cursor()
  646. # Updated query to fetch more potential name fields and properly resolve group member names
  647. query = """
  648. SELECT
  649. m.ZISFROMME,
  650. m.ZTEXT,
  651. m.ZMESSAGEDATE,
  652. m.ZFROMJID,
  653. gm_p.ZPUSHNAME AS GroupMemberContactName,
  654. cs.ZPARTNERNAME AS ChatPartnerName,
  655. p.ZPUSHNAME AS ProfilePushName,
  656. mi.ZMEDIALOCALPATH,
  657. cs.ZCONTACTJID AS ChatJID,
  658. gm.ZMEMBERJID AS GroupMemberJID,
  659. sender_cs.ZPARTNERNAME AS SenderPartnerName,
  660. gm_fallback_p.ZPUSHNAME AS GroupMemberContactNameFallback
  661. FROM
  662. ZWAMESSAGE m
  663. LEFT JOIN
  664. ZWACHATSESSION cs ON m.ZCHATSESSION = cs.Z_PK
  665. LEFT JOIN
  666. ZWAGROUPMEMBER gm ON gm.Z_PK = m.ZGROUPMEMBER
  667. LEFT JOIN
  668. ZWAPROFILEPUSHNAME gm_p ON gm.ZMEMBERJID = gm_p.ZJID
  669. LEFT JOIN
  670. ZWAPROFILEPUSHNAME p ON m.ZFROMJID = p.ZJID
  671. LEFT JOIN
  672. ZWAMEDIAITEM mi ON m.ZMEDIAITEM = mi.Z_PK
  673. LEFT JOIN
  674. ZWACHATSESSION sender_cs ON sender_cs.ZCONTACTJID = m.ZFROMJID
  675. LEFT JOIN
  676. ZWAGROUPMEMBER gm_fallback ON gm_fallback.ZCHATSESSION = m.ZCHATSESSION AND gm_fallback.ZMEMBERJID = m.ZFROMJID
  677. LEFT JOIN
  678. ZWAPROFILEPUSHNAME gm_fallback_p ON gm_fallback.ZMEMBERJID = gm_fallback_p.ZJID
  679. WHERE
  680. m.ZCHATSESSION = ?
  681. ORDER BY
  682. m.ZMESSAGEDATE ASC;
  683. """
  684. cursor.execute(query, (chat_id,))
  685. messages = cursor.fetchall()
  686. conn.close()
  687. if not messages:
  688. print(f"No messages found for chat: {chat_name}")
  689. return
  690. # Sanitize contact_jid for a unique and safe filename
  691. if contact_jid:
  692. safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
  693. else:
  694. # Fallback to chat_id if contact_jid is not available
  695. safe_filename = str(chat_id)
  696. chats_dir = os.path.join(output_dir, "chats")
  697. os.makedirs(chats_dir, exist_ok=True)
  698. html_filename = os.path.join(chats_dir, f"{safe_filename}.html")
  699. with open(html_filename, 'w', encoding='utf-8') as f:
  700. f.write(f"""
  701. <!DOCTYPE html>
  702. <html lang="en">
  703. <head>
  704. <meta charset="UTF-8">
  705. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  706. <title>Chat with {html.escape(chat_name)}</title>
  707. <style>
  708. body {{
  709. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  710. background-color: #e5ddd5;
  711. margin: 0;
  712. padding: 20px;
  713. color: #111b21;
  714. min-height: 100vh;
  715. box-sizing: border-box;
  716. }}
  717. .chat-container {{
  718. max-width: 800px;
  719. margin: auto;
  720. background-image: url('../current_wallpaper.jpg');
  721. background-size: auto 100%;
  722. background-attachment: fixed;
  723. background-position: center;
  724. border-radius: 8px;
  725. box-shadow: 0 1px 1px 0 rgba(0,0,0,0.06), 0 2px 5px 0 rgba(0,0,0,0.06);
  726. overflow: hidden;
  727. }}
  728. .chat-header {{
  729. background-color: #008069;
  730. color: white;
  731. padding: 15px 20px;
  732. font-size: 1.2em;
  733. text-align: center;
  734. }}
  735. .nav-links {{
  736. margin-top: 8px;
  737. font-size: 0.8em;
  738. }}
  739. .nav-links a {{
  740. color: rgba(255,255,255,0.9);
  741. text-decoration: none;
  742. margin: 0 8px;
  743. padding: 3px 8px;
  744. border-radius: 3px;
  745. transition: background-color 0.2s;
  746. }}
  747. .nav-links a:hover {{
  748. background-color: rgba(255,255,255,0.1);
  749. }}
  750. .chat-header-id {{
  751. font-size: 0.7em;
  752. opacity: 0.8;
  753. margin-top: 5px;
  754. }}
  755. .chat-box {{
  756. padding: 20px;
  757. display: flex;
  758. flex-direction: column;
  759. gap: 12px;
  760. }}
  761. .message {{
  762. padding: 8px 12px;
  763. border-radius: 18px;
  764. max-width: 70%;
  765. word-wrap: break-word;
  766. position: relative;
  767. }}
  768. .message.sent {{
  769. background-color: #dcf8c6;
  770. align-self: flex-end;
  771. border-bottom-right-radius: 4px;
  772. }}
  773. .message.received {{
  774. background-color: #ffffff;
  775. align-self: flex-start;
  776. border-bottom-left-radius: 4px;
  777. }}
  778. .sender-name {{
  779. font-weight: bold;
  780. font-size: 0.9em;
  781. color: #005c4b;
  782. margin-bottom: 4px;
  783. }}
  784. .timestamp {{
  785. font-size: 0.75em;
  786. color: #667781;
  787. margin-top: 5px;
  788. text-align: right;
  789. }}
  790. .media-item {{
  791. max-width: 100%;
  792. border-radius: 8px;
  793. margin-top: 5px;
  794. display: block;
  795. }}
  796. .media-missing {{
  797. font-style: italic;
  798. color: #888;
  799. background-color: #fcebeb;
  800. border: 1px solid #f5c6cb;
  801. padding: 10px;
  802. border-radius: 8px;
  803. }}
  804. </style>
  805. </head>
  806. <body>
  807. <div class="chat-container">
  808. <div class="chat-header">
  809. {html.escape(chat_name)}
  810. <div class="chat-header-id">{contact_jid}</div>
  811. <div class="nav-links">
  812. <a href="../whatsapp-chats.html">← Back to Chats</a>
  813. <a href="../media/{safe_filename}.html">📷 Media</a>
  814. </div>
  815. </div>
  816. <div class="chat-box">
  817. """)
  818. # Write messages
  819. 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:
  820. msg_class = "sent" if is_from_me else "received"
  821. f.write(f'<div class="message {msg_class}">')
  822. # Determine and display the sender's name for incoming messages
  823. if not is_from_me:
  824. # Prioritize group member contact name for group chats
  825. if is_group:
  826. # For group messages, prioritize ZCONTACTNAME from ZWAGROUPMEMBER linked via ZGROUPMEMBER
  827. sender_name = group_member_contact_name or group_member_contact_name_fallback # Try direct link first, then fallback via ZFROMJID
  828. if not sender_name:
  829. # Try sender's partner name from their individual chat session
  830. sender_name = sender_partner_name or profile_push_name
  831. if not sender_name:
  832. # Check if this is a group chat and ZFROMJID is the group JID (can't determine individual sender)
  833. if contact_jid and '@g.us' in contact_jid and from_jid and '@g.us' in from_jid:
  834. sender_name = "Group Member" # Generic fallback for unidentifiable group messages
  835. elif group_member_jid and '@' in group_member_jid:
  836. phone_number = group_member_jid.split('@')[0]
  837. sender_name = f"+{phone_number}" if phone_number.isdigit() else group_member_jid
  838. elif from_jid and '@' in from_jid:
  839. phone_number = from_jid.split('@')[0]
  840. sender_name = f"+{phone_number}" if phone_number.isdigit() else from_jid
  841. else:
  842. sender_name = "Unknown"
  843. else:
  844. # For individual chats, prefer partner name or push name
  845. sender_name = chat_partner_name or profile_push_name or from_jid or "Unknown"
  846. f.write(f'<div class="sender-name">{html.escape(str(sender_name))}</div>')
  847. if text:
  848. # Replace newline characters with <br> tags for proper display
  849. escaped_text = html.escape(text)
  850. f.write(f'<div>{escaped_text.replace(chr(10), "<br>")}</div>')
  851. if media_local_path:
  852. # print("Media path:", media_local_path)
  853. f.write(get_media_tag(media_local_path, output_dir))
  854. f.write(f'<div class="timestamp">{convert_whatsapp_timestamp(timestamp)}</div>')
  855. f.write('</div>')
  856. f.write("""
  857. </div>
  858. </div>
  859. </body>
  860. </html>
  861. """)
  862. print(f"Successfully generated HTML for: {chat_name}")
  863. # Step: iPhone backup manifest.db processing
  864. def process_iphone_backup(backup_path, output_dir):
  865. """
  866. Processes the iPhone backup manifest.db, extracts WhatsApp shared files, and recreates the file structure in output_dir.
  867. Acts as an archiver to accumulate data across multiple imports without overwriting existing data.
  868. """
  869. manifest_db_path = os.path.join(backup_path, 'Manifest.db')
  870. if not os.path.exists(manifest_db_path):
  871. print(f"Manifest.db not found in backup path: {manifest_db_path}")
  872. return
  873. # Connect to manifest.db and extract WhatsApp shared files
  874. backup_conn = sqlite3.connect(manifest_db_path)
  875. backup_cursor = backup_conn.cursor()
  876. backup_cursor.execute("SELECT fileID, domain, relativePath FROM Files WHERE domain = ?", ('AppDomainGroup-group.net.whatsapp.WhatsApp.shared',))
  877. files = backup_cursor.fetchall()
  878. print(f"Found {len(files)} WhatsApp shared files in manifest.db.")
  879. backup_conn.close()
  880. # Count for statistics
  881. new_files = 0
  882. updated_files = 0
  883. skipped_files = 0
  884. special_db_files = 0
  885. # Check for SQLite database files that need special handling
  886. db_files_to_merge = [
  887. 'ChatStorage.sqlite',
  888. 'CallHistory.sqlite',
  889. 'DeviceAgents.sqlite',
  890. 'Labels.sqlite',
  891. 'Ranking.sqlite',
  892. 'Sticker.sqlite'
  893. ]
  894. print('Copying WhatsApp shared files to archive location...')
  895. # Prepare to recreate file structure
  896. for fileID, domain, relativePath in files:
  897. src_file = os.path.join(backup_path, fileID[:2], fileID)
  898. dest_file = os.path.join(output_dir, relativePath)
  899. os.makedirs(os.path.dirname(dest_file), exist_ok=True)
  900. if not os.path.exists(src_file):
  901. # print(f"Source file missing: {src_file}")
  902. skipped_files += 1
  903. continue
  904. # Handle SQLite database files specially - merge data instead of overwriting
  905. file_basename = os.path.basename(dest_file)
  906. if file_basename in db_files_to_merge and os.path.exists(dest_file):
  907. special_db_files += 1
  908. try:
  909. # For SQLite databases, we need to merge the data
  910. if file_basename == 'ChatStorage.sqlite':
  911. merge_chat_database(src_file, dest_file)
  912. else:
  913. # For other SQLite databases, make a backup and then replace
  914. # Future enhancement: implement proper merging for all database types
  915. backup_file = f"{dest_file}.backup_{datetime.now().strftime('%Y%m%d%H%M%S')}"
  916. shutil.copy2(dest_file, backup_file)
  917. print(f"Created backup of {file_basename} as {os.path.basename(backup_file)}")
  918. shutil.copy2(src_file, dest_file)
  919. except Exception as e:
  920. print(f"Error processing database {dest_file}: {e}")
  921. continue
  922. # For non-database files
  923. if os.path.exists(dest_file):
  924. # If file exists, we want to keep the newer one
  925. # For media files, we always keep them (accumulate data)
  926. is_media_file = any(relativePath.startswith(prefix) for prefix in ['Media/', 'Message/', 'ProfilePictures/', 'Avatar/'])
  927. if is_media_file:
  928. # For media files, don't overwrite but create a version with timestamp if different
  929. if not files_are_identical(src_file, dest_file):
  930. filename, ext = os.path.splitext(dest_file)
  931. timestamp = datetime.now().strftime('%Y%m%d%H%M%S')
  932. new_dest_file = f"{filename}_{timestamp}{ext}"
  933. try:
  934. shutil.copy2(src_file, new_dest_file)
  935. print(f"Saved additional version of media file: {os.path.relpath(new_dest_file, output_dir)}")
  936. new_files += 1
  937. except Exception as e:
  938. print(f"Error copying alternate version {src_file}: {e}")
  939. skipped_files += 1
  940. else:
  941. skipped_files += 1
  942. else:
  943. # For non-media files, we'll take the newer one
  944. try:
  945. shutil.copy2(src_file, dest_file)
  946. updated_files += 1
  947. except Exception as e:
  948. print(f"Error updating {dest_file}: {e}")
  949. skipped_files += 1
  950. else:
  951. # If file doesn't exist, copy it
  952. try:
  953. shutil.copy2(src_file, dest_file)
  954. new_files += 1
  955. except Exception as e:
  956. print(f"Error copying {src_file} to {dest_file}: {e}")
  957. skipped_files += 1
  958. print(f"\nBackup import summary:")
  959. print(f"- Added {new_files} new files")
  960. print(f"- Updated {updated_files} existing files")
  961. print(f"- Special handling for {special_db_files} database files")
  962. print(f"- Skipped {skipped_files} files")
  963. def files_are_identical(file1, file2):
  964. """Compare two files to see if they are identical in content."""
  965. if os.path.getsize(file1) != os.path.getsize(file2):
  966. return False
  967. # For larger files, just compare a sample to avoid reading entire files into memory
  968. if os.path.getsize(file1) > 1024*1024: # 1MB threshold
  969. with open(file1, 'rb') as f1, open(file2, 'rb') as f2:
  970. # Compare the first and last 4KB of the file
  971. start1 = f1.read(4096)
  972. start2 = f2.read(4096)
  973. if start1 != start2:
  974. return False
  975. f1.seek(-4096, 2) # 2 is os.SEEK_END
  976. f2.seek(-4096, 2)
  977. end1 = f1.read(4096)
  978. end2 = f2.read(4096)
  979. return end1 == end2
  980. else:
  981. # For smaller files, read entire contents for comparison
  982. with open(file1, 'rb') as f1, open(file2, 'rb') as f2:
  983. return f1.read() == f2.read()
  984. def merge_chat_database(src_file, dest_file):
  985. """
  986. Merge WhatsApp chat databases to combine messages from multiple backups.
  987. This preserves all existing messages and adds only new ones.
  988. """
  989. print(f"Merging chat databases to preserve existing messages...")
  990. # Create a temporary copy for processing
  991. temp_file = f"{dest_file}.temp"
  992. shutil.copy2(dest_file, temp_file)
  993. try:
  994. # Connect to both databases
  995. src_conn = sqlite3.connect(src_file)
  996. dest_conn = sqlite3.connect(temp_file)
  997. # Make it safer by enabling foreign keys
  998. src_conn.execute("PRAGMA foreign_keys = OFF")
  999. dest_conn.execute("PRAGMA foreign_keys = OFF")
  1000. # Get all messages from source
  1001. src_cursor = src_conn.cursor()
  1002. src_cursor.execute("SELECT Z_PK FROM ZWAMESSAGE")
  1003. src_message_ids = {row[0] for row in src_cursor.fetchall()}
  1004. # Get all messages from destination to avoid duplicates
  1005. dest_cursor = dest_conn.cursor()
  1006. dest_cursor.execute("SELECT Z_PK FROM ZWAMESSAGE")
  1007. dest_message_ids = {row[0] for row in dest_cursor.fetchall()}
  1008. # Find new message IDs that don't exist in the destination
  1009. new_message_ids = src_message_ids - dest_message_ids
  1010. if not new_message_ids:
  1011. print("No new messages to import")
  1012. src_conn.close()
  1013. dest_conn.close()
  1014. os.remove(temp_file)
  1015. return
  1016. print(f"Found {len(new_message_ids)} new messages to import")
  1017. # Tables that need to be merged (simplified for this example)
  1018. tables_to_check = [
  1019. "ZWAMESSAGE", "ZWAMEDIAITEM", "ZWAGROUPMEMBER",
  1020. "ZWACHATSESSION", "ZWAPROFILEPUSHNAME"
  1021. ]
  1022. # For each table, copy new records
  1023. for table in tables_to_check:
  1024. # Check if table exists
  1025. src_cursor.execute(f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table}'")
  1026. if not src_cursor.fetchone():
  1027. print(f"Table {table} doesn't exist in source database, skipping...")
  1028. continue
  1029. # Get column names
  1030. src_cursor.execute(f"PRAGMA table_info({table})")
  1031. columns = [row[1] for row in src_cursor.fetchall()]
  1032. column_str = ", ".join(columns)
  1033. # For each message ID, copy related records
  1034. for msg_id in new_message_ids:
  1035. # This is simplified - in reality you'd need more complex logic to follow foreign key relationships
  1036. src_cursor.execute(f"SELECT {column_str} FROM {table} WHERE Z_PK = ?", (msg_id,))
  1037. rows = src_cursor.fetchall()
  1038. for row in rows:
  1039. # Skip existing records with same primary key
  1040. dest_cursor.execute(f"SELECT 1 FROM {table} WHERE Z_PK = ?", (row[0],))
  1041. if dest_cursor.fetchone():
  1042. continue
  1043. # Insert new record
  1044. placeholders = ", ".join(["?" for _ in row])
  1045. dest_cursor.execute(f"INSERT OR IGNORE INTO {table} ({column_str}) VALUES ({placeholders})", row)
  1046. # Commit changes
  1047. dest_conn.commit()
  1048. # Close connections
  1049. src_conn.close()
  1050. dest_conn.close()
  1051. # Replace destination file with merged file
  1052. os.rename(temp_file, dest_file)
  1053. print(f"Successfully merged chat databases")
  1054. except Exception as e:
  1055. print(f"Error merging databases: {e}")
  1056. if os.path.exists(temp_file):
  1057. os.remove(temp_file)
  1058. def main():
  1059. parser = argparse.ArgumentParser(description="WhatsApp Chat Exporter")
  1060. parser.add_argument("--output-path", default="./", help="Directory to save the archive")
  1061. parser.add_argument("--backup-path", default=None, help="Path to iPhone backup directory (for manifest.db processing)")
  1062. args = parser.parse_args()
  1063. if args.backup_path:
  1064. process_iphone_backup(args.backup_path, args.output_path)
  1065. # Use backup paths for archive creation
  1066. db_path = os.path.join(args.output_path, "ChatStorage.sqlite")
  1067. media_path = os.path.join(args.output_path, "Message/")
  1068. else:
  1069. parser.add_argument("db_path", help="Path to the ChatStorage.sqlite file.")
  1070. parser.add_argument("media_path", help="Path to the root 'Media' directory.")
  1071. args = parser.parse_args()
  1072. db_path = args.db_path
  1073. media_path = args.media_path
  1074. if not os.path.exists(db_path):
  1075. print(f"Error: Database file not found at '{db_path}'")
  1076. return
  1077. if not os.path.exists(media_path):
  1078. print(f"Error: Media directory not found at '{media_path}'")
  1079. return
  1080. os.makedirs(args.output_path, exist_ok=True)
  1081. conn = sqlite3.connect(db_path)
  1082. cursor = conn.cursor()
  1083. # Get all chats, joining with ZWAPROFILEPUSHNAME and using COALESCE to get the best possible name.
  1084. cursor.execute("""
  1085. SELECT
  1086. cs.Z_PK,
  1087. COALESCE(p.ZPUSHNAME, cs.ZPARTNERNAME) AS ChatName,
  1088. cs.ZCONTACTJID,
  1089. cs.ZMESSAGECOUNTER,
  1090. MIN(m.ZMESSAGEDATE) as FirstMessageDate,
  1091. MAX(m.ZMESSAGEDATE) as LastMessageDate,
  1092. COALESCE(gi.ZPICTUREPATH, pic.ZPATH) AS AvatarPath
  1093. FROM
  1094. ZWACHATSESSION cs
  1095. LEFT JOIN
  1096. ZWAPROFILEPUSHNAME p ON cs.ZCONTACTJID = p.ZJID
  1097. LEFT JOIN
  1098. ZWAMESSAGE m ON cs.Z_PK = m.ZCHATSESSION
  1099. LEFT JOIN
  1100. ZWAGROUPINFO gi ON cs.ZGROUPINFO = gi.Z_PK
  1101. LEFT JOIN
  1102. ZWAPROFILEPICTUREITEM pic ON cs.ZCONTACTJID = pic.ZJID
  1103. WHERE
  1104. cs.ZCONTACTJID NOT LIKE '%@status'
  1105. GROUP BY
  1106. cs.Z_PK, ChatName, cs.ZCONTACTJID, cs.ZMESSAGECOUNTER, AvatarPath
  1107. ORDER BY
  1108. LastMessageDate DESC NULLS LAST, ChatName
  1109. """)
  1110. chats = cursor.fetchall()
  1111. conn.close()
  1112. print(f"Found {len(chats)} chats to export.")
  1113. index_path = os.path.join(args.output_path, "whatsapp-chats.html")
  1114. with open(index_path, 'w', encoding='utf-8') as index_f:
  1115. index_f.write(f"""
  1116. <!DOCTYPE html>
  1117. <html lang="en">
  1118. <head>
  1119. <meta charset="UTF-8">
  1120. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  1121. <title>WhatsApp Chat Export</title>
  1122. <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>">
  1123. <style>
  1124. body {{
  1125. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  1126. background-color: #f4f4f9;
  1127. margin: 0;
  1128. padding: 20px;
  1129. min-height: 100vh;
  1130. }}
  1131. .header {{
  1132. background-color: #128C7E;
  1133. color: white;
  1134. padding: 20px;
  1135. margin: -20px -20px 20px -20px;
  1136. text-align: center;
  1137. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  1138. }}
  1139. .header h1 {{
  1140. margin: 0;
  1141. font-size: 1.8em;
  1142. }}
  1143. .export-info {{
  1144. color: rgba(255,255,255,0.9);
  1145. margin-top: 8px;
  1146. font-size: 0.9em;
  1147. }}
  1148. .container {{
  1149. max-width: 700px;
  1150. margin: auto;
  1151. background: white;
  1152. padding: 20px;
  1153. border-radius: 12px;
  1154. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  1155. }}
  1156. ul {{ list-style-type: none; padding: 0; }}
  1157. li {{ margin: 8px 0; }}
  1158. .chat-entry {{
  1159. text-decoration: none;
  1160. color: #0056b3;
  1161. background-color: #fff;
  1162. padding: 12px;
  1163. border-radius: 8px;
  1164. display: flex;
  1165. align-items: center;
  1166. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  1167. transition: all 0.2s ease-in-out;
  1168. gap: 12px;
  1169. }}
  1170. a.chat-entry:hover {{
  1171. background-color: #e9ecef;
  1172. transform: translateY(-2px);
  1173. box-shadow: 0 4px 8px rgba(0,0,0,0.15);
  1174. }}
  1175. .chat-entry.inactive {{
  1176. color: #999;
  1177. background-color: #f8f9fa;
  1178. cursor: default;
  1179. }}
  1180. .chat-avatar {{
  1181. width: 48px;
  1182. height: 48px;
  1183. border-radius: 50%;
  1184. background-size: cover;
  1185. background-position: center;
  1186. flex-shrink: 0;
  1187. }}
  1188. .chat-avatar.default-individual {{
  1189. background-color: #DFE5E7;
  1190. 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>');
  1191. }}
  1192. .chat-avatar.default-group {{
  1193. background-color: #DFE5E7;
  1194. 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>');
  1195. }}
  1196. .chat-info {{
  1197. flex-grow: 1;
  1198. min-width: 0;
  1199. }}
  1200. .message-count {{
  1201. background-color: #128C7E;
  1202. color: white;
  1203. padding: 4px 8px;
  1204. border-radius: 12px;
  1205. font-size: 0.85em;
  1206. min-width: 24px;
  1207. text-align: center;
  1208. }}
  1209. .message-count.zero {{
  1210. background-color: #ddd;
  1211. }}
  1212. .chat-info {{
  1213. display: flex;
  1214. flex-direction: column;
  1215. gap: 4px;
  1216. }}
  1217. .chat-name {{
  1218. font-weight: 500;
  1219. }}
  1220. .date-range {{
  1221. font-size: 0.8em;
  1222. color: #667781;
  1223. }}
  1224. .chat-entry.inactive .date-range {{
  1225. color: #999;
  1226. }}
  1227. </style>
  1228. </head>
  1229. <body>
  1230. <div class="header">
  1231. <h1>WhatsApp Chat Export</h1>
  1232. <div class="export-info">Exported on {datetime.now().strftime('%Y-%m-%d %H:%M')}</div>
  1233. <div style="margin-top: 10px;">
  1234. <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>
  1235. </div>
  1236. </div>
  1237. <div class="container">
  1238. <ul>
  1239. """)
  1240. for chat_id, chat_name, contact_jid, message_count, first_message_date, last_message_date, avatar_path in chats:
  1241. if not chat_name:
  1242. chat_name = f"Unknown Chat ({contact_jid or chat_id})"
  1243. 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
  1244. # Find all file paths in args.output_path that start with full_avatar_path
  1245. matching_files = []
  1246. if full_avatar_path:
  1247. for root, dirs, files in os.walk(args.output_path):
  1248. for file in files:
  1249. file_path = os.path.join(root, file)
  1250. if file_path.startswith(full_avatar_path):
  1251. matching_files.append(file_path)
  1252. # Use the first matching file if available
  1253. if matching_files:
  1254. avatar_path = os.path.relpath(matching_files[0], args.output_path)
  1255. full_avatar_path = matching_files[0]
  1256. # A group chat JID typically ends with '@g.us'
  1257. is_group = contact_jid and '@g.us' in contact_jid
  1258. # Sanitize contact_jid for a unique and safe filename
  1259. if contact_jid:
  1260. safe_filename = "".join(c if c.isalnum() else "_" for c in contact_jid)
  1261. else:
  1262. # Fallback to chat_id if contact_jid is not available
  1263. safe_filename = str(chat_id)
  1264. # Add default avatar based on chat type
  1265. if avatar_path and os.path.exists(full_avatar_path):
  1266. avatar_html = f'<div class="chat-avatar" style="background-image: url(\'{avatar_path}\');"></div>'
  1267. else:
  1268. avatar_html = f'<div class="chat-avatar default-{"group" if is_group else "individual"}"></div>'
  1269. # Format date range
  1270. date_range = ""
  1271. if message_count > 0 and first_message_date and last_message_date:
  1272. first_date = convert_whatsapp_timestamp(first_message_date).split()[0] # Get just the date part
  1273. last_date = convert_whatsapp_timestamp(last_message_date).split()[0]
  1274. if first_date == last_date:
  1275. date_range = first_date
  1276. else:
  1277. date_range = f"{first_date} – {last_date}"
  1278. if message_count > 0:
  1279. # Generate chat HTML only for chats with messages
  1280. generate_html_chat(db_path, media_path, args.output_path, chat_id, chat_name, is_group, contact_jid)
  1281. # Generate individual chat media gallery
  1282. generate_chat_media_gallery(db_path, args.output_path, chat_id, chat_name, contact_jid)
  1283. # Clickable entry with link
  1284. index_f.write(
  1285. f'<li><a class="chat-entry" href="chats/{html.escape(safe_filename)}.html">'
  1286. f'{avatar_html}'
  1287. f'<div class="chat-info">'
  1288. f'<span class="chat-name">{html.escape(str(chat_name))}</span>'
  1289. f'<span class="date-range">{date_range}</span>'
  1290. f'</div>'
  1291. f'<span class="message-count">{message_count:,}</span>'
  1292. f'</a></li>'
  1293. )
  1294. else:
  1295. # Non-clickable entry for empty chats
  1296. index_f.write(
  1297. f'<li><div class="chat-entry inactive">'
  1298. f'{avatar_html}'
  1299. f'<div class="chat-info">'
  1300. f'<span class="chat-name">{html.escape(str(chat_name))}</span>'
  1301. f'<span class="date-range">No messages</span>'
  1302. f'</div>'
  1303. f'<span class="message-count zero">0</span>'
  1304. f'</div></li>'
  1305. )
  1306. index_f.write("</ul></div></body></html>")
  1307. # Generate the all-media gallery
  1308. generate_all_media_gallery(db_path, args.output_path)
  1309. # Create a simple redirect index.html
  1310. redirect_index = os.path.join(args.output_path, "index.html")
  1311. with open(redirect_index, 'w', encoding='utf-8') as f:
  1312. f.write(f"""<!DOCTYPE html>
  1313. <html>
  1314. <head>
  1315. <meta http-equiv="refresh" content="0; url=whatsapp-chats.html">
  1316. <title>Redirecting to WhatsApp Chats...</title>
  1317. </head>
  1318. <body>
  1319. <p>Redirecting to <a href="whatsapp-chats.html">WhatsApp Chats</a>...</p>
  1320. </body>
  1321. </html>""")
  1322. print(f"\nExport complete!")
  1323. print(f"View your chats by opening either of these files in your browser:")
  1324. print(f" • {os.path.abspath(index_path)}")
  1325. print(f" • {os.path.abspath(redirect_index)}")
  1326. print(f"\nAdditional features:")
  1327. print(f" • Media Gallery: {os.path.abspath(os.path.join(args.output_path, 'media-gallery', 'media-gallery.html'))}")
  1328. print(f" • Individual chat media galleries available in the media/ folder")
  1329. if __name__ == "__main__":
  1330. main()