Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.

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