mirror of
https://github.com/KnugiHK/WhatsApp-Chat-Exporter.git
synced 2026-05-30 12:58:53 +00:00
Merge branch 'dev'
This commit is contained in:
39
.github/workflows/ci.yml
vendored
Normal file
39
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: Run Pytest on Dev Branch Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
python-version: ["3.13", "3.14"]
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
python-version: "3.10"
|
||||||
|
- os: ubuntu-latest
|
||||||
|
python-version: "3.11"
|
||||||
|
- os: ubuntu-latest
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install .[all] pytest nuitka
|
||||||
|
|
||||||
|
- name: Run pytest
|
||||||
|
run: pytest
|
||||||
30
.github/workflows/compile-binary.yml
vendored
30
.github/workflows/compile-binary.yml
vendored
@@ -7,6 +7,9 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
linux:
|
linux:
|
||||||
@@ -20,7 +23,7 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pycryptodome vobject javaobj-py3 ordered-set zstandard nuitka==2.8.9
|
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka==2.8.9
|
||||||
pip install .
|
pip install .
|
||||||
- name: Build binary with Nuitka
|
- name: Build binary with Nuitka
|
||||||
run: |
|
run: |
|
||||||
@@ -28,6 +31,10 @@ jobs:
|
|||||||
--include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html \
|
--include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html \
|
||||||
--assume-yes-for-downloads Whatsapp_Chat_Exporter --output-filename=wtsexporter_linux_x64
|
--assume-yes-for-downloads Whatsapp_Chat_Exporter --output-filename=wtsexporter_linux_x64
|
||||||
sha256sum wtsexporter_linux_x64
|
sha256sum wtsexporter_linux_x64
|
||||||
|
- name: Generate artifact attestation
|
||||||
|
uses: actions/attest-build-provenance@v3
|
||||||
|
with:
|
||||||
|
subject-path: ./wtsexporter_linux_x64
|
||||||
- uses: actions/upload-artifact@v6
|
- uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: binary-linux
|
name: binary-linux
|
||||||
@@ -45,13 +52,17 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pycryptodome vobject javaobj-py3 ordered-set zstandard nuitka==2.8.9
|
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka==2.8.9
|
||||||
pip install .
|
pip install .
|
||||||
- name: Build binary with Nuitka
|
- name: Build binary with Nuitka
|
||||||
run: |
|
run: |
|
||||||
python -m nuitka --onefile --include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html --assume-yes-for-downloads Whatsapp_Chat_Exporter --output-filename=wtsexporter
|
python -m nuitka --onefile --include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html --assume-yes-for-downloads Whatsapp_Chat_Exporter --output-filename=wtsexporter
|
||||||
copy wtsexporter.exe wtsexporter_x64.exe
|
Rename-Item -Path "wtsexporter.exe" -NewName "wtsexporter_x64.exe"
|
||||||
Get-FileHash wtsexporter_x64.exe
|
Get-FileHash wtsexporter_x64.exe
|
||||||
|
- name: Generate artifact attestation
|
||||||
|
uses: actions/attest-build-provenance@v3
|
||||||
|
with:
|
||||||
|
subject-path: .\wtsexporter_x64.exe
|
||||||
- uses: actions/upload-artifact@v6
|
- uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: binary-windows
|
name: binary-windows
|
||||||
@@ -69,16 +80,21 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pycryptodome vobject javaobj-py3 ordered-set zstandard nuitka==2.8.9
|
pip install pycryptodome javaobj-py3 ordered-set zstandard nuitka==2.8.9
|
||||||
pip install .
|
pip install .
|
||||||
- name: Build binary with Nuitka
|
- name: Build binary with Nuitka
|
||||||
run: |
|
run: |
|
||||||
python -m nuitka --onefile \
|
python -m nuitka --onefile \
|
||||||
--include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html \
|
--include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html \
|
||||||
--assume-yes-for-downloads Whatsapp_Chat_Exporter --output-filename=wtsexporter_macos_x64
|
--assume-yes-for-downloads Whatsapp_Chat_Exporter --output-filename=wtsexporter_macos_arm64
|
||||||
shasum -a 256 wtsexporter_macos_x64
|
shasum -a 256 wtsexporter_macos_arm64
|
||||||
|
- name: Generate artifact attestation
|
||||||
|
uses: actions/attest-build-provenance@v3
|
||||||
|
with:
|
||||||
|
subject-path: ./wtsexporter_macos_arm64
|
||||||
- uses: actions/upload-artifact@v6
|
- uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
name: binary-macos
|
name: binary-macos
|
||||||
path: |
|
path: |
|
||||||
./wtsexporter_macos_x64
|
./wtsexporter_macos_arm64
|
||||||
|
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -138,7 +138,9 @@ __main__
|
|||||||
|
|
||||||
# Dev time intermidiates & temp files
|
# Dev time intermidiates & temp files
|
||||||
result/
|
result/
|
||||||
|
output/
|
||||||
WhatsApp/
|
WhatsApp/
|
||||||
|
AppDomainGroup-group.net.whatsapp.WhatsApp.shared/
|
||||||
/*.db
|
/*.db
|
||||||
/*.db-*
|
/*.db-*
|
||||||
/myout
|
/myout
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
The Whatsapp Chat Exporter is licensed under the MIT license. For more information,
|
|
||||||
refer to https://github.com/KnugiHK/WhatsApp-Chat-Exporter/wiki/Open-Source-Licenses.
|
|
||||||
|
|
||||||
------
|
|
||||||
|
|
||||||
Copyright (c) Django Software Foundation and individual contributors.
|
|
||||||
All rights reserved.
|
|
||||||
|
|
||||||
Redistribution and use in source and binary forms, with or without modification,
|
|
||||||
are permitted provided that the following conditions are met:
|
|
||||||
|
|
||||||
1. Redistributions of source code must retain the above copyright notice,
|
|
||||||
this list of conditions and the following disclaimer.
|
|
||||||
|
|
||||||
2. Redistributions in binary form must reproduce the above copyright
|
|
||||||
notice, this list of conditions and the following disclaimer in the
|
|
||||||
documentation and/or other materials provided with the distribution.
|
|
||||||
|
|
||||||
3. Neither the name of Django nor the names of its contributors may be used
|
|
||||||
to endorse or promote products derived from this software without
|
|
||||||
specific prior written permission.
|
|
||||||
|
|
||||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
|
||||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
||||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
||||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
|
||||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
||||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
||||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
|
||||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
||||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
||||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
||||||
25
README.md
25
README.md
@@ -136,12 +136,10 @@ wtsexporter -i -b ~/Library/Application\ Support/MobileSync/Backup/[device id]
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Results
|
## Results
|
||||||
After extracting, you will get these:
|
After extracting, you will get this:
|
||||||
#### Private Message
|
|
||||||

|

|
||||||
|
|
||||||
#### Group Message
|
|
||||||

|
|
||||||
|
|
||||||
## More options
|
## More options
|
||||||
Invoke the wtsexporter with --help option will show you all options available.
|
Invoke the wtsexporter with --help option will show you all options available.
|
||||||
@@ -233,6 +231,19 @@ Contact Enrichment:
|
|||||||
Use with --enrich-from-vcards. When numbers in the vcf file does not have a country code, this
|
Use with --enrich-from-vcards. When numbers in the vcf file does not have a country code, this
|
||||||
will be used. 1 is for US, 66 for Thailand etc. Most likely use the number of your own country
|
will be used. 1 is for US, 66 for Thailand etc. Most likely use the number of your own country
|
||||||
|
|
||||||
|
Incremental Merging:
|
||||||
|
--incremental-merge Performs an incremental merge of two exports. Requires setting both --source-
|
||||||
|
dir and --target-dir. The chats (JSON files only) and media from the source
|
||||||
|
directory will be merged into the target directory. No chat messages or media
|
||||||
|
will be deleted from the target directory; only new chat messages and media
|
||||||
|
will be added to it. This enables chat messages and media to be deleted from
|
||||||
|
the device to free up space, while ensuring they are preserved in the exported
|
||||||
|
backups.
|
||||||
|
--source-dir SOURCE_DIR
|
||||||
|
Sets the source directory. Used for performing incremental merges.
|
||||||
|
--target-dir TARGET_DIR
|
||||||
|
Sets the target directory. Used for performing incremental merges.
|
||||||
|
|
||||||
Miscellaneous:
|
Miscellaneous:
|
||||||
-s, --showkey Show the HEX key used to decrypt the database
|
-s, --showkey Show the HEX key used to decrypt the database
|
||||||
--check-update Check for updates (require Internet access)
|
--check-update Check for updates (require Internet access)
|
||||||
@@ -243,10 +254,14 @@ Miscellaneous:
|
|||||||
--max-bruteforce-worker MAX_BRUTEFORCE_WORKER
|
--max-bruteforce-worker MAX_BRUTEFORCE_WORKER
|
||||||
Specify the maximum number of worker for bruteforce decryption.
|
Specify the maximum number of worker for bruteforce decryption.
|
||||||
|
|
||||||
WhatsApp Chat Exporter: 0.12.1 Licensed with MIT. See https://wts.knugi.dev/docs?dest=osl for all open source
|
WhatsApp Chat Exporter: 0.13.0rc1 Licensed with MIT. See https://wts.knugi.dev/docs?dest=osl for all open source
|
||||||
licenses.
|
licenses.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
# Python Support Policy
|
||||||
|
|
||||||
|
This project officially supports all non-EOL (End-of-Life) versions of Python. Once a Python version reaches EOL, it is dropped in the next release. See [Python's EOL Schedule](https://devguide.python.org/versions/).
|
||||||
|
|
||||||
# Legal Stuff & Disclaimer
|
# Legal Stuff & Disclaimer
|
||||||
|
|
||||||
This is a MIT licensed project.
|
This is a MIT licensed project.
|
||||||
|
|||||||
@@ -7,27 +7,43 @@ import shutil
|
|||||||
import json
|
import json
|
||||||
import string
|
import string
|
||||||
import glob
|
import glob
|
||||||
|
import logging
|
||||||
import importlib.metadata
|
import importlib.metadata
|
||||||
from Whatsapp_Chat_Exporter import android_crypt, exported_handler, android_handler
|
from Whatsapp_Chat_Exporter import android_crypt, exported_handler, android_handler
|
||||||
from Whatsapp_Chat_Exporter import ios_handler, ios_media_handler
|
from Whatsapp_Chat_Exporter import ios_handler, ios_media_handler
|
||||||
from Whatsapp_Chat_Exporter.data_model import ChatCollection, ChatStore
|
from Whatsapp_Chat_Exporter.data_model import ChatCollection, ChatStore
|
||||||
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, Crypt, check_update, DbType
|
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, CLEAR_LINE, Crypt, check_update
|
||||||
from Whatsapp_Chat_Exporter.utility import readable_to_bytes, sanitize_filename
|
from Whatsapp_Chat_Exporter.utility import readable_to_bytes, safe_name, bytes_to_readable
|
||||||
from Whatsapp_Chat_Exporter.utility import import_from_json, bytes_to_readable
|
from Whatsapp_Chat_Exporter.utility import import_from_json, incremental_merge, DbType
|
||||||
|
from Whatsapp_Chat_Exporter.utility import telegram_json_format
|
||||||
from argparse import ArgumentParser, SUPPRESS
|
from argparse import ArgumentParser, SUPPRESS
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
from sys import exit
|
from sys import exit
|
||||||
from typing import Tuple, Optional, List, Dict, Any, Union
|
from typing import Optional, List, Dict
|
||||||
|
from Whatsapp_Chat_Exporter.vcards_contacts import ContactsFromVCards
|
||||||
|
|
||||||
# Try to import vobject for contacts processing
|
|
||||||
try:
|
logger = logging.getLogger(__name__)
|
||||||
import vobject
|
__version__ = importlib.metadata.version("whatsapp_chat_exporter")
|
||||||
except ModuleNotFoundError:
|
WTSEXPORTER_BANNER = f"""========================================================================================================
|
||||||
vcards_deps_installed = False
|
██╗ ██╗██╗ ██╗ █████╗ ████████╗███████╗ █████╗ ██████╗ ██████╗
|
||||||
else:
|
██║ ██║██║ ██║██╔══██╗╚══██╔══╝██╔════╝██╔══██╗██╔══██╗██╔══██╗
|
||||||
from Whatsapp_Chat_Exporter.vcards_contacts import ContactsFromVCards
|
██║ █╗ ██║███████║███████║ ██║ ███████╗███████║██████╔╝██████╔╝
|
||||||
vcards_deps_installed = True
|
██║███╗██║██╔══██║██╔══██║ ██║ ╚════██║██╔══██║██╔═══╝ ██╔═══╝
|
||||||
|
╚███╔███╔╝██║ ██║██║ ██║ ██║ ███████║██║ ██║██║ ██║
|
||||||
|
╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝
|
||||||
|
|
||||||
|
██████╗██╗ ██╗ █████╗ ████████╗ ███████╗██╗ ██╗██████╗ ██████╗ ██████╗ ████████╗███████╗██████╗
|
||||||
|
██╔════╝██║ ██║██╔══██╗╚══██╔══╝ ██╔════╝╚██╗██╔╝██╔══██╗██╔═══██╗██╔══██╗╚══██╔══╝██╔════╝██╔══██╗
|
||||||
|
██║ ███████║███████║ ██║ █████╗ ╚███╔╝ ██████╔╝██║ ██║██████╔╝ ██║ █████╗ ██████╔╝
|
||||||
|
██║ ██╔══██║██╔══██║ ██║ ██╔══╝ ██╔██╗ ██╔═══╝ ██║ ██║██╔══██╗ ██║ ██╔══╝ ██╔══██╗
|
||||||
|
╚██████╗██║ ██║██║ ██║ ██║ ███████╗██╔╝ ██╗██║ ╚██████╔╝██║ ██║ ██║ ███████╗██║ ██║
|
||||||
|
╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
|
||||||
|
|
||||||
|
WhatsApp Chat Exporter: A customizable Android and iOS/iPadOS WhatsApp database parser
|
||||||
|
Version: {__version__}
|
||||||
|
========================================================================================================"""
|
||||||
|
|
||||||
|
|
||||||
def setup_argument_parser() -> ArgumentParser:
|
def setup_argument_parser() -> ArgumentParser:
|
||||||
@@ -36,10 +52,15 @@ def setup_argument_parser() -> ArgumentParser:
|
|||||||
description='A customizable Android and iOS/iPadOS WhatsApp database parser that '
|
description='A customizable Android and iOS/iPadOS WhatsApp database parser that '
|
||||||
'will give you the history of your WhatsApp conversations in HTML '
|
'will give you the history of your WhatsApp conversations in HTML '
|
||||||
'and JSON. Android Backup Crypt12, Crypt14 and Crypt15 supported.',
|
'and JSON. Android Backup Crypt12, Crypt14 and Crypt15 supported.',
|
||||||
epilog=f'WhatsApp Chat Exporter: {importlib.metadata.version("whatsapp_chat_exporter")} Licensed with MIT. See '
|
epilog=f'WhatsApp Chat Exporter: {__version__} Licensed with MIT. See '
|
||||||
'https://wts.knugi.dev/docs?dest=osl for all open source licenses.'
|
'https://wts.knugi.dev/docs?dest=osl for all open source licenses.'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# General options
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug", dest="debug", default=False, action='store_true',
|
||||||
|
help="Enable debug mode"
|
||||||
|
)
|
||||||
# Device type arguments
|
# Device type arguments
|
||||||
device_group = parser.add_argument_group('Device Type')
|
device_group = parser.add_argument_group('Device Type')
|
||||||
device_group.add_argument(
|
device_group.add_argument(
|
||||||
@@ -106,9 +127,13 @@ def setup_argument_parser() -> ArgumentParser:
|
|||||||
help="Do not output html files"
|
help="Do not output html files"
|
||||||
)
|
)
|
||||||
output_group.add_argument(
|
output_group.add_argument(
|
||||||
"--size", "--output-size", "--split", dest="size", nargs='?', const=0, default=None,
|
"--size", "--output-size", "--split", dest="size", nargs='?', const="0", default=None,
|
||||||
help="Maximum (rough) size of a single output file in bytes, 0 for auto"
|
help="Maximum (rough) size of a single output file in bytes, 0 for auto"
|
||||||
)
|
)
|
||||||
|
output_group.add_argument(
|
||||||
|
"--no-reply", dest="no_reply_ios", default=False, action='store_true',
|
||||||
|
help="Do not process replies (iOS only) (default: handle replies)"
|
||||||
|
)
|
||||||
|
|
||||||
# JSON formatting options
|
# JSON formatting options
|
||||||
json_group = parser.add_argument_group('JSON Options')
|
json_group = parser.add_argument_group('JSON Options')
|
||||||
@@ -120,6 +145,10 @@ def setup_argument_parser() -> ArgumentParser:
|
|||||||
'--pretty-print-json', dest='pretty_print_json', default=None, nargs='?', const=2, type=int,
|
'--pretty-print-json', dest='pretty_print_json', default=None, nargs='?', const=2, type=int,
|
||||||
help="Pretty print the output JSON."
|
help="Pretty print the output JSON."
|
||||||
)
|
)
|
||||||
|
json_group.add_argument(
|
||||||
|
"--tg", "--telegram", dest="telegram", default=False, action='store_true',
|
||||||
|
help="Output the JSON in a format compatible with Telegram export (implies json-per-chat)"
|
||||||
|
)
|
||||||
json_group.add_argument(
|
json_group.add_argument(
|
||||||
"--per-chat", dest="json_per_chat", default=False, action='store_true',
|
"--per-chat", dest="json_per_chat", default=False, action='store_true',
|
||||||
help="Output the JSON file per chat"
|
help="Output the JSON file per chat"
|
||||||
@@ -148,8 +177,8 @@ def setup_argument_parser() -> ArgumentParser:
|
|||||||
help="Do not render avatar in HTML output"
|
help="Do not render avatar in HTML output"
|
||||||
)
|
)
|
||||||
html_group.add_argument(
|
html_group.add_argument(
|
||||||
"--experimental-new-theme", dest="whatsapp_theme", default=False, action='store_true',
|
"--old-theme", dest="telegram_theme", default=False, action='store_true',
|
||||||
help="Use the newly designed WhatsApp-alike theme"
|
help="Use the old Telegram-alike theme"
|
||||||
)
|
)
|
||||||
html_group.add_argument(
|
html_group.add_argument(
|
||||||
"--headline", dest="headline", default="Chat history with ??",
|
"--headline", dest="headline", default="Chat history with ??",
|
||||||
@@ -207,6 +236,33 @@ def setup_argument_parser() -> ArgumentParser:
|
|||||||
help="Use with --enrich-from-vcards. When numbers in the vcf file does not have a country code, this will be used. 1 is for US, 66 for Thailand etc. Most likely use the number of your own country"
|
help="Use with --enrich-from-vcards. When numbers in the vcf file does not have a country code, this will be used. 1 is for US, 66 for Thailand etc. Most likely use the number of your own country"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Incremental merging
|
||||||
|
inc_merging_group = parser.add_argument_group('Incremental Merging')
|
||||||
|
inc_merging_group.add_argument(
|
||||||
|
"--incremental-merge",
|
||||||
|
dest="incremental_merge",
|
||||||
|
default=False,
|
||||||
|
action='store_true',
|
||||||
|
help=("Performs an incremental merge of two exports. "
|
||||||
|
"Requires setting both --source-dir and --target-dir. "
|
||||||
|
"The chats (JSON files only) and media from the source directory will be merged into the target directory. "
|
||||||
|
"No chat messages or media will be deleted from the target directory; only new chat messages and media will be added to it. "
|
||||||
|
"This enables chat messages and media to be deleted from the device to free up space, while ensuring they are preserved in the exported backups."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inc_merging_group.add_argument(
|
||||||
|
"--source-dir",
|
||||||
|
dest="source_dir",
|
||||||
|
default=None,
|
||||||
|
help="Sets the source directory. Used for performing incremental merges."
|
||||||
|
)
|
||||||
|
inc_merging_group.add_argument(
|
||||||
|
"--target-dir",
|
||||||
|
dest="target_dir",
|
||||||
|
default=None,
|
||||||
|
help="Sets the target directory. Used for performing incremental merges."
|
||||||
|
)
|
||||||
|
|
||||||
# Miscellaneous
|
# Miscellaneous
|
||||||
misc_group = parser.add_argument_group('Miscellaneous')
|
misc_group = parser.add_argument_group('Miscellaneous')
|
||||||
misc_group.add_argument(
|
misc_group.add_argument(
|
||||||
@@ -233,6 +289,10 @@ def setup_argument_parser() -> ArgumentParser:
|
|||||||
"--max-bruteforce-worker", dest="max_bruteforce_worker", default=10, type=int,
|
"--max-bruteforce-worker", dest="max_bruteforce_worker", default=10, type=int,
|
||||||
help="Specify the maximum number of worker for bruteforce decryption."
|
help="Specify the maximum number of worker for bruteforce decryption."
|
||||||
)
|
)
|
||||||
|
misc_group.add_argument(
|
||||||
|
"--no-banner", dest="no_banner", default=False, action='store_true',
|
||||||
|
help="Do not show the banner"
|
||||||
|
)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
@@ -245,11 +305,16 @@ def validate_args(parser: ArgumentParser, args) -> None:
|
|||||||
if not args.android and not args.ios and not args.exported and not args.import_json:
|
if not args.android and not args.ios and not args.exported and not args.import_json:
|
||||||
parser.error("You must define the device type.")
|
parser.error("You must define the device type.")
|
||||||
if args.no_html and not args.json and not args.text_format:
|
if args.no_html and not args.json and not args.text_format:
|
||||||
parser.error("You must either specify a JSON output file, text file output directory or enable HTML output.")
|
parser.error(
|
||||||
|
"You must either specify a JSON output file, text file output directory or enable HTML output.")
|
||||||
if args.import_json and (args.android or args.ios or args.exported or args.no_html):
|
if args.import_json and (args.android or args.ios or args.exported or args.no_html):
|
||||||
parser.error("You can only use --import with -j and without --no-html, -a, -i, -e.")
|
parser.error(
|
||||||
|
"You can only use --import with -j and without --no-html, -a, -i, -e.")
|
||||||
elif args.import_json and not os.path.isfile(args.json):
|
elif args.import_json and not os.path.isfile(args.json):
|
||||||
parser.error("JSON file not found.")
|
parser.error("JSON file not found.")
|
||||||
|
if args.incremental_merge and (args.source_dir is None or args.target_dir is None):
|
||||||
|
parser.error(
|
||||||
|
"You must specify both --source-dir and --target-dir for incremental merge.")
|
||||||
if args.android and args.business:
|
if args.android and args.business:
|
||||||
parser.error("WhatsApp Business is only available on iOS for now.")
|
parser.error("WhatsApp Business is only available on iOS for now.")
|
||||||
if "??" not in args.headline:
|
if "??" not in args.headline:
|
||||||
@@ -260,18 +325,22 @@ def validate_args(parser: ArgumentParser, args) -> None:
|
|||||||
(args.json.endswith(".json") and os.path.isfile(args.json)) or
|
(args.json.endswith(".json") and os.path.isfile(args.json)) or
|
||||||
(not args.json.endswith(".json") and os.path.isfile(args.json))
|
(not args.json.endswith(".json") and os.path.isfile(args.json))
|
||||||
):
|
):
|
||||||
parser.error("When --per-chat is enabled, the destination of --json must be a directory.")
|
parser.error(
|
||||||
|
"When --per-chat is enabled, the destination of --json must be a directory.")
|
||||||
|
|
||||||
# vCards validation
|
# vCards validation
|
||||||
if args.enrich_from_vcards is not None and args.default_country_code is None:
|
if args.enrich_from_vcards is not None and args.default_country_code is None:
|
||||||
parser.error("When --enrich-from-vcards is provided, you must also set --default-country-code")
|
parser.error(
|
||||||
|
"When --enrich-from-vcards is provided, you must also set --default-country-code")
|
||||||
|
|
||||||
# Size validation
|
# Size validation and conversion
|
||||||
if args.size is not None and not isinstance(args.size, int) and not args.size.isnumeric():
|
if args.size is not None:
|
||||||
try:
|
try:
|
||||||
args.size = readable_to_bytes(args.size)
|
args.size = readable_to_bytes(args.size)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
parser.error("The value for --split must be ended in pure bytes or with a proper unit (e.g., 1048576 or 1MB)")
|
parser.error(
|
||||||
|
"The value for --split must be pure bytes or use a proper unit (e.g., 1048576 or 1MB)"
|
||||||
|
)
|
||||||
|
|
||||||
# Date filter validation and processing
|
# Date filter validation and processing
|
||||||
if args.filter_date is not None:
|
if args.filter_date is not None:
|
||||||
@@ -282,12 +351,13 @@ def validate_args(parser: ArgumentParser, args) -> None:
|
|||||||
args.key = getpass("Enter your encryption key: ")
|
args.key = getpass("Enter your encryption key: ")
|
||||||
|
|
||||||
# Theme validation
|
# Theme validation
|
||||||
if args.whatsapp_theme:
|
if args.telegram_theme:
|
||||||
args.template = "whatsapp_new.html"
|
args.template = "whatsapp_old.html"
|
||||||
|
|
||||||
# Chat filter validation
|
# Chat filter validation
|
||||||
if args.filter_chat_include is not None and args.filter_chat_exclude is not None:
|
if args.filter_chat_include is not None and args.filter_chat_exclude is not None:
|
||||||
parser.error("Chat inclusion and exclusion filters cannot be used together.")
|
parser.error(
|
||||||
|
"Chat inclusion and exclusion filters cannot be used together.")
|
||||||
|
|
||||||
validate_chat_filters(parser, args.filter_chat_include)
|
validate_chat_filters(parser, args.filter_chat_include)
|
||||||
validate_chat_filters(parser, args.filter_chat_exclude)
|
validate_chat_filters(parser, args.filter_chat_exclude)
|
||||||
@@ -298,20 +368,23 @@ def validate_chat_filters(parser: ArgumentParser, chat_filter: Optional[List[str
|
|||||||
if chat_filter is not None:
|
if chat_filter is not None:
|
||||||
for chat in chat_filter:
|
for chat in chat_filter:
|
||||||
if not chat.isnumeric():
|
if not chat.isnumeric():
|
||||||
parser.error("Enter a phone number in the chat filter. See https://wts.knugi.dev/docs?dest=chat")
|
parser.error(
|
||||||
|
"Enter a phone number in the chat filter. See https://wts.knugi.dev/docs?dest=chat")
|
||||||
|
|
||||||
|
|
||||||
def process_date_filter(parser: ArgumentParser, args) -> None:
|
def process_date_filter(parser: ArgumentParser, args) -> None:
|
||||||
"""Process and validate date filter arguments."""
|
"""Process and validate date filter arguments."""
|
||||||
if " - " in args.filter_date:
|
if " - " in args.filter_date:
|
||||||
start, end = args.filter_date.split(" - ")
|
start, end = args.filter_date.split(" - ")
|
||||||
start = int(datetime.strptime(start, args.filter_date_format).timestamp())
|
start = int(datetime.strptime(
|
||||||
|
start, args.filter_date_format).timestamp())
|
||||||
end = int(datetime.strptime(end, args.filter_date_format).timestamp())
|
end = int(datetime.strptime(end, args.filter_date_format).timestamp())
|
||||||
|
|
||||||
if start < 1009843200 or end < 1009843200:
|
if start < 1009843200 or end < 1009843200:
|
||||||
parser.error("WhatsApp was first released in 2009...")
|
parser.error("WhatsApp was first released in 2009...")
|
||||||
if start > end:
|
if start > end:
|
||||||
parser.error("The start date cannot be a moment after the end date.")
|
parser.error(
|
||||||
|
"The start date cannot be a moment after the end date.")
|
||||||
|
|
||||||
if args.android:
|
if args.android:
|
||||||
args.filter_date = f"BETWEEN {start}000 AND {end}000"
|
args.filter_date = f"BETWEEN {start}000 AND {end}000"
|
||||||
@@ -324,9 +397,11 @@ def process_date_filter(parser: ArgumentParser, args) -> None:
|
|||||||
def process_single_date_filter(parser: ArgumentParser, args) -> None:
|
def process_single_date_filter(parser: ArgumentParser, args) -> None:
|
||||||
"""Process single date comparison filters."""
|
"""Process single date comparison filters."""
|
||||||
if len(args.filter_date) < 3:
|
if len(args.filter_date) < 3:
|
||||||
parser.error("Unsupported date format. See https://wts.knugi.dev/docs?dest=date")
|
parser.error(
|
||||||
|
"Unsupported date format. See https://wts.knugi.dev/docs?dest=date")
|
||||||
|
|
||||||
_timestamp = int(datetime.strptime(args.filter_date[2:], args.filter_date_format).timestamp())
|
_timestamp = int(datetime.strptime(
|
||||||
|
args.filter_date[2:], args.filter_date_format).timestamp())
|
||||||
|
|
||||||
if _timestamp < 1009843200:
|
if _timestamp < 1009843200:
|
||||||
parser.error("WhatsApp was first released in 2009...")
|
parser.error("WhatsApp was first released in 2009...")
|
||||||
@@ -342,21 +417,16 @@ def process_single_date_filter(parser: ArgumentParser, args) -> None:
|
|||||||
elif args.ios:
|
elif args.ios:
|
||||||
args.filter_date = f"<= {_timestamp - APPLE_TIME}"
|
args.filter_date = f"<= {_timestamp - APPLE_TIME}"
|
||||||
else:
|
else:
|
||||||
parser.error("Unsupported date format. See https://wts.knugi.dev/docs?dest=date")
|
parser.error(
|
||||||
|
"Unsupported date format. See https://wts.knugi.dev/docs?dest=date")
|
||||||
|
|
||||||
|
|
||||||
def setup_contact_store(args) -> Optional['ContactsFromVCards']:
|
def setup_contact_store(args) -> Optional['ContactsFromVCards']:
|
||||||
"""Set up and return a contact store if needed."""
|
"""Set up and return a contact store if needed."""
|
||||||
if args.enrich_from_vcards is not None:
|
if args.enrich_from_vcards is not None:
|
||||||
if not vcards_deps_installed:
|
|
||||||
print(
|
|
||||||
"You don't have the dependency to enrich contacts with vCard.\n"
|
|
||||||
"Read more on how to deal with enriching contacts:\n"
|
|
||||||
"https://github.com/KnugiHK/Whatsapp-Chat-Exporter/blob/main/README.md#usage"
|
|
||||||
)
|
|
||||||
exit(1)
|
|
||||||
contact_store = ContactsFromVCards()
|
contact_store = ContactsFromVCards()
|
||||||
contact_store.load_vcf_file(args.enrich_from_vcards, args.default_country_code)
|
contact_store.load_vcf_file(
|
||||||
|
args.enrich_from_vcards, args.default_country_code)
|
||||||
return contact_store
|
return contact_store
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -364,10 +434,10 @@ def setup_contact_store(args) -> Optional['ContactsFromVCards']:
|
|||||||
def decrypt_android_backup(args) -> int:
|
def decrypt_android_backup(args) -> int:
|
||||||
"""Decrypt Android backup files and return error code."""
|
"""Decrypt Android backup files and return error code."""
|
||||||
if args.key is None or args.backup is None:
|
if args.key is None or args.backup is None:
|
||||||
print("You must specify the backup file with -b and a key with -k")
|
logger.error(f"You must specify the backup file with -b and a key with -k{CLEAR_LINE}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
print("Decryption key specified, decrypting WhatsApp backup...")
|
logger.info(f"Decryption key specified, decrypting WhatsApp backup...{CLEAR_LINE}")
|
||||||
|
|
||||||
# Determine crypt type
|
# Determine crypt type
|
||||||
if "crypt12" in args.backup:
|
if "crypt12" in args.backup:
|
||||||
@@ -377,7 +447,8 @@ def decrypt_android_backup(args) -> int:
|
|||||||
elif "crypt15" in args.backup:
|
elif "crypt15" in args.backup:
|
||||||
crypt = Crypt.CRYPT15
|
crypt = Crypt.CRYPT15
|
||||||
else:
|
else:
|
||||||
print("Unknown backup format. The backup file must be crypt12, crypt14 or crypt15.")
|
logger.error(
|
||||||
|
f"Unknown backup format. The backup file must be crypt12, crypt14 or crypt15.{CLEAR_LINE}")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Get key
|
# Get key
|
||||||
@@ -429,19 +500,19 @@ def decrypt_android_backup(args) -> int:
|
|||||||
def handle_decrypt_error(error: int) -> None:
|
def handle_decrypt_error(error: int) -> None:
|
||||||
"""Handle decryption errors with appropriate messages."""
|
"""Handle decryption errors with appropriate messages."""
|
||||||
if error == 1:
|
if error == 1:
|
||||||
print("Dependencies of decrypt_backup and/or extract_encrypted_key"
|
logger.error("Dependencies of decrypt_backup and/or extract_encrypted_key"
|
||||||
" are not present. For details, see README.md.")
|
" are not present. For details, see README.md.\n")
|
||||||
exit(3)
|
exit(3)
|
||||||
elif error == 2:
|
elif error == 2:
|
||||||
print("Failed when decompressing the decrypted backup. "
|
logger.error("Failed when decompressing the decrypted backup. "
|
||||||
"Possibly incorrect offsets used in decryption.")
|
"Possibly incorrect offsets used in decryption.\n")
|
||||||
exit(4)
|
exit(4)
|
||||||
else:
|
else:
|
||||||
print("Unknown error occurred.", error)
|
logger.error("Unknown error occurred.\n")
|
||||||
exit(5)
|
exit(5)
|
||||||
|
|
||||||
|
|
||||||
def process_contacts(args, data: ChatCollection, contact_store=None) -> None:
|
def process_contacts(args, data: ChatCollection) -> None:
|
||||||
"""Process contacts from the database."""
|
"""Process contacts from the database."""
|
||||||
contact_db = args.wa if args.wa else "wa.db" if args.android else "ContactsV2.sqlite"
|
contact_db = args.wa if args.wa else "wa.db" if args.android else "ContactsV2.sqlite"
|
||||||
|
|
||||||
@@ -459,9 +530,9 @@ def process_messages(args, data: ChatCollection) -> None:
|
|||||||
msg_db = args.db if args.db else "msgstore.db" if args.android else args.identifiers.MESSAGE
|
msg_db = args.db if args.db else "msgstore.db" if args.android else args.identifiers.MESSAGE
|
||||||
|
|
||||||
if not os.path.isfile(msg_db):
|
if not os.path.isfile(msg_db):
|
||||||
print(
|
logger.error(
|
||||||
"The message database does not exist. You may specify the path "
|
"The message database does not exist. You may specify the path "
|
||||||
"to database file with option -d or check your provided path."
|
"to database file with option -d or check your provided path.\n"
|
||||||
)
|
)
|
||||||
exit(6)
|
exit(6)
|
||||||
|
|
||||||
@@ -477,8 +548,8 @@ def process_messages(args, data: ChatCollection) -> None:
|
|||||||
message_handler = ios_handler
|
message_handler = ios_handler
|
||||||
|
|
||||||
message_handler.messages(
|
message_handler.messages(
|
||||||
db, data, args.media, args.timezone_offset,
|
db, data, args.media, args.timezone_offset, args.filter_date,
|
||||||
args.filter_date, filter_chat, args.filter_empty
|
filter_chat, args.filter_empty, args.no_reply_ios
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process media
|
# Process media
|
||||||
@@ -513,28 +584,27 @@ def handle_media_directory(args) -> None:
|
|||||||
media_path = os.path.join(args.output, args.media)
|
media_path = os.path.join(args.output, args.media)
|
||||||
|
|
||||||
if os.path.isdir(media_path):
|
if os.path.isdir(media_path):
|
||||||
print("\nWhatsApp directory already exists in output directory. Skipping...", end="\n")
|
logger.info(
|
||||||
|
f"WhatsApp directory already exists in output directory. Skipping...{CLEAR_LINE}")
|
||||||
else:
|
else:
|
||||||
if args.move_media:
|
if args.move_media:
|
||||||
try:
|
try:
|
||||||
print("\nMoving media directory...", end="\n")
|
logger.info(f"Moving media directory...\r")
|
||||||
shutil.move(args.media, f"{args.output}/")
|
shutil.move(args.media, f"{args.output}/")
|
||||||
|
logger.info(f"Media directory has been moved to the output directory{CLEAR_LINE}")
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
print("\nCannot remove original WhatsApp directory. "
|
logger.warning("Cannot remove original WhatsApp directory. "
|
||||||
"Perhaps the directory is opened?", end="\n")
|
"Perhaps the directory is opened?\n")
|
||||||
else:
|
else:
|
||||||
print("\nCopying media directory...", end="\n")
|
logger.info(f"Copying media directory...\r")
|
||||||
shutil.copytree(args.media, media_path)
|
shutil.copytree(args.media, media_path)
|
||||||
|
logger.info(f"Media directory has been copied to the output directory{CLEAR_LINE}")
|
||||||
|
|
||||||
|
|
||||||
def create_output_files(args, data: ChatCollection, contact_store=None) -> None:
|
def create_output_files(args, data: ChatCollection) -> None:
|
||||||
"""Create output files in the specified formats."""
|
"""Create output files in the specified formats."""
|
||||||
# Create HTML files if requested
|
# Create HTML files if requested
|
||||||
if not args.no_html:
|
if not args.no_html:
|
||||||
# Enrich from vcards if available
|
|
||||||
if contact_store and not contact_store.is_empty():
|
|
||||||
contact_store.enrich_from_vcards(data)
|
|
||||||
|
|
||||||
android_handler.create_html(
|
android_handler.create_html(
|
||||||
data,
|
data,
|
||||||
args.output,
|
args.output,
|
||||||
@@ -543,32 +613,29 @@ def create_output_files(args, data: ChatCollection, contact_store=None) -> None:
|
|||||||
args.offline,
|
args.offline,
|
||||||
args.size,
|
args.size,
|
||||||
args.no_avatar,
|
args.no_avatar,
|
||||||
args.whatsapp_theme,
|
args.telegram_theme,
|
||||||
args.headline
|
args.headline
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create text files if requested
|
# Create text files if requested
|
||||||
if args.text_format:
|
if args.text_format:
|
||||||
print("Writing text file...")
|
logger.info(f"Writing text file...{CLEAR_LINE}")
|
||||||
android_handler.create_txt(data, args.text_format)
|
android_handler.create_txt(data, args.text_format)
|
||||||
|
|
||||||
# Create JSON files if requested
|
# Create JSON files if requested
|
||||||
if args.json and not args.import_json:
|
if args.json and not args.import_json:
|
||||||
export_json(args, data, contact_store)
|
export_json(args, data)
|
||||||
|
|
||||||
|
|
||||||
def export_json(args, data: ChatCollection, contact_store=None) -> None:
|
def export_json(args, data: ChatCollection) -> None:
|
||||||
"""Export data to JSON format."""
|
"""Export data to JSON format."""
|
||||||
# Enrich from vcards if available
|
# TODO: remove all non-target chats from data if filtering is applied?
|
||||||
if contact_store and not contact_store.is_empty():
|
|
||||||
contact_store.enrich_from_vcards(data)
|
|
||||||
|
|
||||||
# Convert ChatStore objects to JSON
|
# Convert ChatStore objects to JSON
|
||||||
if isinstance(data.get(next(iter(data), None)), ChatStore):
|
if isinstance(data.get(next(iter(data), None)), ChatStore):
|
||||||
data = {jik: chat.to_json() for jik, chat in data.items()}
|
data = {jik: chat.to_json() for jik, chat in data.items()}
|
||||||
|
|
||||||
# Export as a single file or per chat
|
# Export as a single file or per chat
|
||||||
if not args.json_per_chat:
|
if not args.json_per_chat and not args.telegram:
|
||||||
export_single_json(args, data)
|
export_single_json(args, data)
|
||||||
else:
|
else:
|
||||||
export_multiple_json(args, data)
|
export_multiple_json(args, data)
|
||||||
@@ -582,8 +649,9 @@ def export_single_json(args, data: Dict) -> None:
|
|||||||
ensure_ascii=not args.avoid_encoding_json,
|
ensure_ascii=not args.avoid_encoding_json,
|
||||||
indent=args.pretty_print_json
|
indent=args.pretty_print_json
|
||||||
)
|
)
|
||||||
print(f"\nWriting JSON file...({bytes_to_readable(len(json_data))})")
|
logger.info(f"Writing JSON file...\r")
|
||||||
f.write(json_data)
|
f.write(json_data)
|
||||||
|
logger.info(f"JSON file saved...({bytes_to_readable(len(json_data))}){CLEAR_LINE}")
|
||||||
|
|
||||||
|
|
||||||
def export_multiple_json(args, data: Dict) -> None:
|
def export_multiple_json(args, data: Dict) -> None:
|
||||||
@@ -603,15 +671,18 @@ def export_multiple_json(args, data: Dict) -> None:
|
|||||||
else:
|
else:
|
||||||
contact = jik.replace('+', '')
|
contact = jik.replace('+', '')
|
||||||
|
|
||||||
with open(f"{json_path}/{sanitize_filename(contact)}.json", "w") as f:
|
if args.telegram:
|
||||||
|
messages = telegram_json_format(jik, data[jik], args.timezone_offset)
|
||||||
|
else:
|
||||||
|
messages = {jik: data[jik]}
|
||||||
|
with open(f"{json_path}/{safe_name(contact)}.json", "w") as f:
|
||||||
file_content = json.dumps(
|
file_content = json.dumps(
|
||||||
{jik: data[jik]},
|
messages,
|
||||||
ensure_ascii=not args.avoid_encoding_json,
|
ensure_ascii=not args.avoid_encoding_json,
|
||||||
indent=args.pretty_print_json
|
indent=args.pretty_print_json
|
||||||
)
|
)
|
||||||
f.write(file_content)
|
f.write(file_content)
|
||||||
print(f"Writing JSON file...({index + 1}/{total})", end="\r")
|
logger.info(f"Writing JSON file...({index + 1}/{total})\r")
|
||||||
print()
|
|
||||||
|
|
||||||
|
|
||||||
def process_exported_chat(args, data: ChatCollection) -> None:
|
def process_exported_chat(args, data: ChatCollection) -> None:
|
||||||
@@ -627,7 +698,7 @@ def process_exported_chat(args, data: ChatCollection) -> None:
|
|||||||
args.offline,
|
args.offline,
|
||||||
args.size,
|
args.size,
|
||||||
args.no_avatar,
|
args.no_avatar,
|
||||||
args.whatsapp_theme,
|
args.telegram_theme,
|
||||||
args.headline
|
args.headline
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -636,6 +707,20 @@ def process_exported_chat(args, data: ChatCollection) -> None:
|
|||||||
shutil.copy(file, args.output)
|
shutil.copy(file, args.output)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level):
|
||||||
|
log_handler_stdout = logging.StreamHandler()
|
||||||
|
log_handler_stdout.terminator = ""
|
||||||
|
handlers = [log_handler_stdout]
|
||||||
|
if level == logging.DEBUG:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
handlers.append(logging.FileHandler(f"wtsexpoter-debug-{timestamp}.log", mode="w"))
|
||||||
|
logging.basicConfig(
|
||||||
|
level=level,
|
||||||
|
format="[%(levelname)s] %(message)s",
|
||||||
|
handlers=handlers
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main function to run the WhatsApp Chat Exporter."""
|
"""Main function to run the WhatsApp Chat Exporter."""
|
||||||
# Set up and parse arguments
|
# Set up and parse arguments
|
||||||
@@ -649,6 +734,16 @@ def main():
|
|||||||
# Validate arguments
|
# Validate arguments
|
||||||
validate_args(parser, args)
|
validate_args(parser, args)
|
||||||
|
|
||||||
|
# Print banner if not suppressed
|
||||||
|
if not args.no_banner:
|
||||||
|
print(WTSEXPORTER_BANNER)
|
||||||
|
|
||||||
|
if args.debug:
|
||||||
|
setup_logging(logging.DEBUG)
|
||||||
|
logger.debug("Debug mode enabled.\n")
|
||||||
|
else:
|
||||||
|
setup_logging(logging.INFO)
|
||||||
|
|
||||||
# Create output directory if it doesn't exist
|
# Create output directory if it doesn't exist
|
||||||
os.makedirs(args.output, exist_ok=True)
|
os.makedirs(args.output, exist_ok=True)
|
||||||
|
|
||||||
@@ -669,7 +764,7 @@ def main():
|
|||||||
args.offline,
|
args.offline,
|
||||||
args.size,
|
args.size,
|
||||||
args.no_avatar,
|
args.no_avatar,
|
||||||
args.whatsapp_theme,
|
args.telegram_theme,
|
||||||
args.headline
|
args.headline
|
||||||
)
|
)
|
||||||
elif args.exported:
|
elif args.exported:
|
||||||
@@ -708,9 +803,11 @@ def main():
|
|||||||
# Extract media from backup if needed
|
# Extract media from backup if needed
|
||||||
if args.backup is not None:
|
if args.backup is not None:
|
||||||
if not os.path.isdir(args.media):
|
if not os.path.isdir(args.media):
|
||||||
ios_media_handler.extract_media(args.backup, identifiers, args.decrypt_chunk_size)
|
ios_media_handler.extract_media(
|
||||||
|
args.backup, identifiers, args.decrypt_chunk_size)
|
||||||
else:
|
else:
|
||||||
print("WhatsApp directory already exists, skipping WhatsApp file extraction.")
|
logger.info(
|
||||||
|
f"WhatsApp directory already exists, skipping WhatsApp file extraction.{CLEAR_LINE}")
|
||||||
|
|
||||||
# Set default DB paths if not provided
|
# Set default DB paths if not provided
|
||||||
if args.db is None:
|
if args.db is None:
|
||||||
@@ -718,19 +815,34 @@ def main():
|
|||||||
if args.wa is None:
|
if args.wa is None:
|
||||||
args.wa = "ContactsV2.sqlite"
|
args.wa = "ContactsV2.sqlite"
|
||||||
|
|
||||||
|
if args.incremental_merge:
|
||||||
|
incremental_merge(
|
||||||
|
args.source_dir,
|
||||||
|
args.target_dir,
|
||||||
|
args.media,
|
||||||
|
args.pretty_print_json,
|
||||||
|
args.avoid_encoding_json
|
||||||
|
)
|
||||||
|
logger.info(f"Incremental merge completed successfully.{CLEAR_LINE}")
|
||||||
|
else:
|
||||||
# Process contacts
|
# Process contacts
|
||||||
process_contacts(args, data, contact_store)
|
process_contacts(args, data)
|
||||||
|
|
||||||
|
# Enrich contacts from vCards if needed
|
||||||
|
if args.android and contact_store and not contact_store.is_empty():
|
||||||
|
contact_store.enrich_from_vcards(data)
|
||||||
|
|
||||||
# Process messages, media, and calls
|
# Process messages, media, and calls
|
||||||
process_messages(args, data)
|
process_messages(args, data)
|
||||||
|
|
||||||
# Create output files
|
# Create output files
|
||||||
create_output_files(args, data, contact_store)
|
create_output_files(args, data)
|
||||||
|
|
||||||
# Handle media directory
|
# Handle media directory
|
||||||
handle_media_directory(args)
|
handle_media_directory(args)
|
||||||
|
|
||||||
print("Everything is done!")
|
logger.info("Everything is done!")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import time
|
||||||
import hmac
|
import hmac
|
||||||
import io
|
import io
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
import zlib
|
import zlib
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
from typing import Tuple, Union
|
from typing import Tuple, Union
|
||||||
from hashlib import sha256
|
from hashlib import sha256
|
||||||
from sys import exit
|
from sys import exit
|
||||||
from Whatsapp_Chat_Exporter.utility import CRYPT14_OFFSETS, Crypt, DbType
|
from Whatsapp_Chat_Exporter.utility import CLEAR_LINE, CRYPT14_OFFSETS, Crypt, DbType
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import zlib
|
import zlib
|
||||||
@@ -23,6 +26,9 @@ else:
|
|||||||
support_crypt15 = True
|
support_crypt15 = True
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class DecryptionError(Exception):
|
class DecryptionError(Exception):
|
||||||
"""Base class for decryption-related exceptions."""
|
"""Base class for decryption-related exceptions."""
|
||||||
pass
|
pass
|
||||||
@@ -115,6 +121,7 @@ def _decrypt_database(db_ciphertext: bytes, main_key: bytes, iv: bytes) -> bytes
|
|||||||
)
|
)
|
||||||
return db
|
return db
|
||||||
|
|
||||||
|
|
||||||
def _decrypt_crypt14(database: bytes, main_key: bytes, max_worker: int = 10) -> bytes:
|
def _decrypt_crypt14(database: bytes, main_key: bytes, max_worker: int = 10) -> bytes:
|
||||||
"""Decrypt a crypt14 database using multithreading for brute-force offset detection.
|
"""Decrypt a crypt14 database using multithreading for brute-force offset detection.
|
||||||
|
|
||||||
@@ -138,11 +145,28 @@ def _decrypt_crypt14(database: bytes, main_key: bytes, max_worker: int = 10) ->
|
|||||||
iv = database[offsets["iv"]:offsets["iv"] + 16]
|
iv = database[offsets["iv"]:offsets["iv"] + 16]
|
||||||
db_ciphertext = database[offsets["db"]:]
|
db_ciphertext = database[offsets["db"]:]
|
||||||
try:
|
try:
|
||||||
return _decrypt_database(db_ciphertext, main_key, iv)
|
decrypted_db = _decrypt_database(db_ciphertext, main_key, iv)
|
||||||
except (zlib.error, ValueError):
|
except (zlib.error, ValueError):
|
||||||
pass # Try next offset
|
pass # Try next offset
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"Decryption successful with known offsets: IV {offsets['iv']}, DB {offsets['db']}{CLEAR_LINE}"
|
||||||
|
)
|
||||||
|
return decrypted_db # Successful decryption
|
||||||
|
|
||||||
print("Common offsets failed. Initiating brute-force with multithreading...")
|
def animate_message(stop_event):
|
||||||
|
base_msg = "Common offsets failed. Initiating brute-force with multithreading"
|
||||||
|
dots = ["", ".", "..", "..."]
|
||||||
|
i = 0
|
||||||
|
while not stop_event.is_set():
|
||||||
|
logger.info(f"{base_msg}{dots[i % len(dots)]}\x1b[K\r")
|
||||||
|
time.sleep(0.3)
|
||||||
|
i += 1
|
||||||
|
logger.info(f"Common offsets failed but brute-forcing the offset works!{CLEAR_LINE}")
|
||||||
|
|
||||||
|
stop_event = threading.Event()
|
||||||
|
anim_thread = threading.Thread(target=animate_message, args=(stop_event,))
|
||||||
|
anim_thread.start()
|
||||||
|
|
||||||
# Convert brute force generator into a list for parallel processing
|
# Convert brute force generator into a list for parallel processing
|
||||||
offset_combinations = list(brute_force_offset())
|
offset_combinations = list(brute_force_offset())
|
||||||
@@ -152,22 +176,27 @@ def _decrypt_crypt14(database: bytes, main_key: bytes, max_worker: int = 10) ->
|
|||||||
start_iv, end_iv, start_db = offset_tuple
|
start_iv, end_iv, start_db = offset_tuple
|
||||||
iv = database[start_iv:end_iv]
|
iv = database[start_iv:end_iv]
|
||||||
db_ciphertext = database[start_db:]
|
db_ciphertext = database[start_db:]
|
||||||
|
logger.debug(""f"Trying offsets: IV {start_iv}-{end_iv}, DB {start_db}{CLEAR_LINE}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
db = _decrypt_database(db_ciphertext, main_key, iv)
|
db = _decrypt_database(db_ciphertext, main_key, iv)
|
||||||
print(
|
except (zlib.error, ValueError):
|
||||||
|
return None # Decryption failed, move to next
|
||||||
|
else:
|
||||||
|
stop_event.set()
|
||||||
|
anim_thread.join()
|
||||||
|
logger.info(
|
||||||
f"The offsets of your IV and database are {start_iv} and "
|
f"The offsets of your IV and database are {start_iv} and "
|
||||||
f"{start_db}, respectively. To include your offsets in the "
|
f"{start_db}, respectively. To include your offsets in the "
|
||||||
"program, please report it by creating an issue on GitHub: "
|
"program, please report it by creating an issue on GitHub: "
|
||||||
"https://github.com/KnugiHK/Whatsapp-Chat-Exporter/discussions/47"
|
"https://github.com/KnugiHK/Whatsapp-Chat-Exporter/discussions/47"
|
||||||
"\nShutting down other threads..."
|
f"\nShutting down other threads...{CLEAR_LINE}"
|
||||||
)
|
)
|
||||||
return db
|
return db
|
||||||
except (zlib.error, ValueError):
|
|
||||||
return None # Decryption failed, move to next
|
|
||||||
|
|
||||||
with concurrent.futures.ThreadPoolExecutor(max_worker) as executor:
|
with concurrent.futures.ThreadPoolExecutor(max_worker) as executor:
|
||||||
future_to_offset = {executor.submit(attempt_decrypt, offset): offset for offset in offset_combinations}
|
future_to_offset = {executor.submit(attempt_decrypt, offset)
|
||||||
|
: offset for offset in offset_combinations}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for future in concurrent.futures.as_completed(future_to_offset):
|
for future in concurrent.futures.as_completed(future_to_offset):
|
||||||
@@ -178,14 +207,18 @@ def _decrypt_crypt14(database: bytes, main_key: bytes, max_worker: int = 10) ->
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\nBrute force interrupted by user (Ctrl+C). Exiting gracefully...")
|
stop_event.set()
|
||||||
|
anim_thread.join()
|
||||||
|
logger.info(f"Brute force interrupted by user (Ctrl+C). Shutting down gracefully...{CLEAR_LINE}")
|
||||||
executor.shutdown(wait=False, cancel_futures=True)
|
executor.shutdown(wait=False, cancel_futures=True)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
finally:
|
||||||
|
stop_event.set()
|
||||||
|
anim_thread.join()
|
||||||
|
|
||||||
raise OffsetNotFoundError("Could not find the correct offsets for decryption.")
|
raise OffsetNotFoundError("Could not find the correct offsets for decryption.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _decrypt_crypt12(database: bytes, main_key: bytes) -> bytes:
|
def _decrypt_crypt12(database: bytes, main_key: bytes) -> bytes:
|
||||||
"""Decrypt a crypt12 database.
|
"""Decrypt a crypt12 database.
|
||||||
|
|
||||||
@@ -287,7 +320,7 @@ def decrypt_backup(
|
|||||||
if crypt is not Crypt.CRYPT15 and len(key) != 158:
|
if crypt is not Crypt.CRYPT15 and len(key) != 158:
|
||||||
raise InvalidKeyError("The key file must be 158 bytes")
|
raise InvalidKeyError("The key file must be 158 bytes")
|
||||||
|
|
||||||
#signature check, this is check is used in crypt 12 and 14
|
# signature check, this is check is used in crypt 12 and 14
|
||||||
if crypt != Crypt.CRYPT15:
|
if crypt != Crypt.CRYPT15:
|
||||||
t1 = key[30:62]
|
t1 = key[30:62]
|
||||||
|
|
||||||
@@ -297,7 +330,6 @@ def decrypt_backup(
|
|||||||
if t1 != database[3:35] and crypt == Crypt.CRYPT12:
|
if t1 != database[3:35] and crypt == Crypt.CRYPT12:
|
||||||
raise ValueError("The signature of key file and backup file mismatch")
|
raise ValueError("The signature of key file and backup file mismatch")
|
||||||
|
|
||||||
|
|
||||||
if crypt == Crypt.CRYPT15:
|
if crypt == Crypt.CRYPT15:
|
||||||
if keyfile_stream:
|
if keyfile_stream:
|
||||||
main_key, hex_key = _extract_enc_key(key)
|
main_key, hex_key = _extract_enc_key(key)
|
||||||
@@ -305,7 +337,7 @@ def decrypt_backup(
|
|||||||
main_key, hex_key = _derive_main_enc_key(key)
|
main_key, hex_key = _derive_main_enc_key(key)
|
||||||
if show_crypt15:
|
if show_crypt15:
|
||||||
hex_key_str = ' '.join([hex_key.hex()[c:c+4] for c in range(0, len(hex_key.hex()), 4)])
|
hex_key_str = ' '.join([hex_key.hex()[c:c+4] for c in range(0, len(hex_key.hex()), 4)])
|
||||||
print(f"The HEX key of the crypt15 backup is: {hex_key_str}")
|
logger.info(f"The HEX key of the crypt15 backup is: {hex_key_str}{CLEAR_LINE}")
|
||||||
else:
|
else:
|
||||||
main_key = key[126:]
|
main_key = key[126:]
|
||||||
|
|
||||||
@@ -321,7 +353,6 @@ def decrypt_backup(
|
|||||||
except (InvalidFileFormatError, OffsetNotFoundError, ValueError) as e:
|
except (InvalidFileFormatError, OffsetNotFoundError, ValueError) as e:
|
||||||
raise DecryptionError(f"Decryption failed: {e}") from e
|
raise DecryptionError(f"Decryption failed: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
if not dry_run:
|
if not dry_run:
|
||||||
with open(output, "wb") as f:
|
with open(output, "wb") as f:
|
||||||
f.write(db)
|
f.write(db)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
@@ -9,10 +10,13 @@ from markupsafe import escape as htmle
|
|||||||
from base64 import b64decode, b64encode
|
from base64 import b64decode, b64encode
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
||||||
from Whatsapp_Chat_Exporter.utility import CURRENT_TZ_OFFSET, MAX_SIZE, ROW_SIZE, JidType, Device
|
from Whatsapp_Chat_Exporter.utility import CLEAR_LINE, CURRENT_TZ_OFFSET, MAX_SIZE, ROW_SIZE, JidType, Device
|
||||||
from Whatsapp_Chat_Exporter.utility import rendering, get_file_name, setup_template, get_cond_for_empty
|
from Whatsapp_Chat_Exporter.utility import rendering, get_file_name, setup_template, get_cond_for_empty
|
||||||
from Whatsapp_Chat_Exporter.utility import get_status_location, convert_time_unit, determine_metadata
|
from Whatsapp_Chat_Exporter.utility import get_status_location, convert_time_unit, determine_metadata
|
||||||
from Whatsapp_Chat_Exporter.utility import get_chat_condition, slugify, bytes_to_readable
|
from Whatsapp_Chat_Exporter.utility import get_chat_condition, safe_name, bytes_to_readable
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def contacts(db, data, enrich_from_vcards):
|
def contacts(db, data, enrich_from_vcards):
|
||||||
@@ -33,12 +37,14 @@ def contacts(db, data, enrich_from_vcards):
|
|||||||
|
|
||||||
if total_row_number == 0:
|
if total_row_number == 0:
|
||||||
if enrich_from_vcards is not None:
|
if enrich_from_vcards is not None:
|
||||||
print("No contacts profiles found in the default database, contacts will be imported from the specified vCard file.")
|
logger.info(
|
||||||
|
"No contacts profiles found in the default database, contacts will be imported from the specified vCard file.")
|
||||||
else:
|
else:
|
||||||
print("No contacts profiles found in the default database, consider using --enrich-from-vcards for adopting names from exported contacts from Google")
|
logger.warning(
|
||||||
|
"No contacts profiles found in the default database, consider using --enrich-from-vcards for adopting names from exported contacts from Google")
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
print(f"Processing contacts...({total_row_number})")
|
logger.info(f"Processed {total_row_number} contacts\n")
|
||||||
|
|
||||||
c.execute("SELECT jid, COALESCE(display_name, wa_name) as display_name, status FROM wa_contacts;")
|
c.execute("SELECT jid, COALESCE(display_name, wa_name) as display_name, status FROM wa_contacts;")
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
@@ -51,7 +57,7 @@ def contacts(db, data, enrich_from_vcards):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat, filter_empty):
|
def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat, filter_empty, no_reply):
|
||||||
"""
|
"""
|
||||||
Process WhatsApp messages from the database.
|
Process WhatsApp messages from the database.
|
||||||
|
|
||||||
@@ -66,7 +72,7 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
|||||||
"""
|
"""
|
||||||
c = db.cursor()
|
c = db.cursor()
|
||||||
total_row_number = _get_message_count(c, filter_empty, filter_date, filter_chat)
|
total_row_number = _get_message_count(c, filter_empty, filter_date, filter_chat)
|
||||||
print(f"Processing messages...(0/{total_row_number})", end="\r")
|
logger.info(f"Processing messages...(0/{total_row_number})\r")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content_cursor = _get_messages_cursor_legacy(c, filter_empty, filter_date, filter_chat)
|
content_cursor = _get_messages_cursor_legacy(c, filter_empty, filter_date, filter_chat)
|
||||||
@@ -87,12 +93,12 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
|||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
if i % 1000 == 0:
|
if i % 1000 == 0:
|
||||||
print(f"Processing messages...({i}/{total_row_number})", end="\r")
|
logger.info(f"Processing messages...({i}/{total_row_number})\r")
|
||||||
|
|
||||||
# Fetch the next row safely
|
# Fetch the next row safely
|
||||||
content = _fetch_row_safely(content_cursor)
|
content = _fetch_row_safely(content_cursor)
|
||||||
|
|
||||||
print(f"Processing messages...({total_row_number}/{total_row_number})", end="\r")
|
logger.info(f"Processed {total_row_number} messages{CLEAR_LINE}")
|
||||||
|
|
||||||
|
|
||||||
# Helper functions for message processing
|
# Helper functions for message processing
|
||||||
@@ -102,8 +108,10 @@ def _get_message_count(cursor, filter_empty, filter_date, filter_chat):
|
|||||||
try:
|
try:
|
||||||
empty_filter = get_cond_for_empty(filter_empty, "messages.key_remote_jid", "messages.needs_push")
|
empty_filter = get_cond_for_empty(filter_empty, "messages.key_remote_jid", "messages.needs_push")
|
||||||
date_filter = f'AND timestamp {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND timestamp {filter_date}' if filter_date is not None else ''
|
||||||
include_filter = get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "messages.remote_resource"], "jid", "android")
|
include_filter = get_chat_condition(
|
||||||
exclude_filter = get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "messages.remote_resource"], "jid", "android")
|
filter_chat[0], True, ["messages.key_remote_jid", "messages.remote_resource"], "jid", "android")
|
||||||
|
exclude_filter = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["messages.key_remote_jid", "messages.remote_resource"], "jid", "android")
|
||||||
|
|
||||||
cursor.execute(f"""SELECT count()
|
cursor.execute(f"""SELECT count()
|
||||||
FROM messages
|
FROM messages
|
||||||
@@ -119,8 +127,10 @@ def _get_message_count(cursor, filter_empty, filter_date, filter_chat):
|
|||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
empty_filter = get_cond_for_empty(filter_empty, "jid.raw_string", "broadcast")
|
empty_filter = get_cond_for_empty(filter_empty, "jid.raw_string", "broadcast")
|
||||||
date_filter = f'AND timestamp {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND timestamp {filter_date}' if filter_date is not None else ''
|
||||||
include_filter = get_chat_condition(filter_chat[0], True, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")
|
include_filter = get_chat_condition(
|
||||||
exclude_filter = get_chat_condition(filter_chat[1], False, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")
|
filter_chat[0], True, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")
|
||||||
|
exclude_filter = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")
|
||||||
|
|
||||||
cursor.execute(f"""SELECT count()
|
cursor.execute(f"""SELECT count()
|
||||||
FROM message
|
FROM message
|
||||||
@@ -142,8 +152,10 @@ def _get_messages_cursor_legacy(cursor, filter_empty, filter_date, filter_chat):
|
|||||||
"""Get cursor for legacy database schema."""
|
"""Get cursor for legacy database schema."""
|
||||||
empty_filter = get_cond_for_empty(filter_empty, "messages.key_remote_jid", "messages.needs_push")
|
empty_filter = get_cond_for_empty(filter_empty, "messages.key_remote_jid", "messages.needs_push")
|
||||||
date_filter = f'AND messages.timestamp {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND messages.timestamp {filter_date}' if filter_date is not None else ''
|
||||||
include_filter = get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "messages.remote_resource"], "jid_global", "android")
|
include_filter = get_chat_condition(
|
||||||
exclude_filter = get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "messages.remote_resource"], "jid_global", "android")
|
filter_chat[0], True, ["messages.key_remote_jid", "messages.remote_resource"], "jid_global", "android")
|
||||||
|
exclude_filter = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["messages.key_remote_jid", "messages.remote_resource"], "jid_global", "android")
|
||||||
|
|
||||||
cursor.execute(f"""SELECT messages.key_remote_jid,
|
cursor.execute(f"""SELECT messages.key_remote_jid,
|
||||||
messages._id,
|
messages._id,
|
||||||
@@ -205,8 +217,10 @@ def _get_messages_cursor_new(cursor, filter_empty, filter_date, filter_chat):
|
|||||||
"""Get cursor for new database schema."""
|
"""Get cursor for new database schema."""
|
||||||
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")
|
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")
|
||||||
date_filter = f'AND message.timestamp {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND message.timestamp {filter_date}' if filter_date is not None else ''
|
||||||
include_filter = get_chat_condition(filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid_global", "android")
|
include_filter = get_chat_condition(
|
||||||
exclude_filter = get_chat_condition(filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid_global", "android")
|
filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid_global", "android")
|
||||||
|
exclude_filter = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid_global", "android")
|
||||||
|
|
||||||
cursor.execute(f"""SELECT jid_global.raw_string as key_remote_jid,
|
cursor.execute(f"""SELECT jid_global.raw_string as key_remote_jid,
|
||||||
message._id,
|
message._id,
|
||||||
@@ -290,11 +304,10 @@ def _process_single_message(data, content, table_message, timezone_offset):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Get or create the chat
|
# Get or create the chat
|
||||||
if not data.get_chat(content["key_remote_jid"]):
|
|
||||||
current_chat = data.add_chat(content["key_remote_jid"], ChatStore(Device.ANDROID, content["chat_subject"]))
|
|
||||||
else:
|
|
||||||
current_chat = data.get_chat(content["key_remote_jid"])
|
current_chat = data.get_chat(content["key_remote_jid"])
|
||||||
|
if current_chat is None:
|
||||||
|
current_chat = data.add_chat(content["key_remote_jid"], ChatStore(
|
||||||
|
Device.ANDROID, content["chat_subject"]))
|
||||||
# Determine sender_jid_row_id
|
# Determine sender_jid_row_id
|
||||||
if "sender_jid_row_id" in content:
|
if "sender_jid_row_id" in content:
|
||||||
sender_jid_row_id = content["sender_jid_row_id"]
|
sender_jid_row_id = content["sender_jid_row_id"]
|
||||||
@@ -482,7 +495,7 @@ def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separa
|
|||||||
"""
|
"""
|
||||||
c = db.cursor()
|
c = db.cursor()
|
||||||
total_row_number = _get_media_count(c, filter_empty, filter_date, filter_chat)
|
total_row_number = _get_media_count(c, filter_empty, filter_date, filter_chat)
|
||||||
print(f"\nProcessing media...(0/{total_row_number})", end="\r")
|
logger.info(f"Processing media...(0/{total_row_number})\r")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content_cursor = _get_media_cursor_legacy(c, filter_empty, filter_date, filter_chat)
|
content_cursor = _get_media_cursor_legacy(c, filter_empty, filter_date, filter_chat)
|
||||||
@@ -501,11 +514,11 @@ def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separa
|
|||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
if i % 100 == 0:
|
if i % 100 == 0:
|
||||||
print(f"Processing media...({i}/{total_row_number})", end="\r")
|
logger.info(f"Processing media...({i}/{total_row_number})\r")
|
||||||
|
|
||||||
content = content_cursor.fetchone()
|
content = content_cursor.fetchone()
|
||||||
|
|
||||||
print(f"Processing media...({total_row_number}/{total_row_number})", end="\r")
|
logger.info(f"Processed {total_row_number} media{CLEAR_LINE}")
|
||||||
|
|
||||||
|
|
||||||
# Helper functions for media processing
|
# Helper functions for media processing
|
||||||
@@ -515,8 +528,10 @@ def _get_media_count(cursor, filter_empty, filter_date, filter_chat):
|
|||||||
try:
|
try:
|
||||||
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "messages.needs_push")
|
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "messages.needs_push")
|
||||||
date_filter = f'AND messages.timestamp {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND messages.timestamp {filter_date}' if filter_date is not None else ''
|
||||||
include_filter = get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
include_filter = get_chat_condition(
|
||||||
exclude_filter = get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
||||||
|
exclude_filter = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
||||||
|
|
||||||
cursor.execute(f"""SELECT count()
|
cursor.execute(f"""SELECT count()
|
||||||
FROM message_media
|
FROM message_media
|
||||||
@@ -534,8 +549,10 @@ def _get_media_count(cursor, filter_empty, filter_date, filter_chat):
|
|||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
empty_filter = get_cond_for_empty(filter_empty, "jid.raw_string", "broadcast")
|
empty_filter = get_cond_for_empty(filter_empty, "jid.raw_string", "broadcast")
|
||||||
date_filter = f'AND message.timestamp {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND message.timestamp {filter_date}' if filter_date is not None else ''
|
||||||
include_filter = get_chat_condition(filter_chat[0], True, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")
|
include_filter = get_chat_condition(
|
||||||
exclude_filter = get_chat_condition(filter_chat[1], False, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")
|
filter_chat[0], True, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")
|
||||||
|
exclude_filter = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["jid.raw_string", "jid_group.raw_string"], "jid", "android")
|
||||||
|
|
||||||
cursor.execute(f"""SELECT count()
|
cursor.execute(f"""SELECT count()
|
||||||
FROM message_media
|
FROM message_media
|
||||||
@@ -557,10 +574,12 @@ def _get_media_count(cursor, filter_empty, filter_date, filter_chat):
|
|||||||
|
|
||||||
def _get_media_cursor_legacy(cursor, filter_empty, filter_date, filter_chat):
|
def _get_media_cursor_legacy(cursor, filter_empty, filter_date, filter_chat):
|
||||||
"""Get cursor for legacy media database schema."""
|
"""Get cursor for legacy media database schema."""
|
||||||
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")
|
empty_filter = get_cond_for_empty(filter_empty, "messages.key_remote_jid", "messages.needs_push")
|
||||||
date_filter = f'AND messages.timestamp {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND messages.timestamp {filter_date}' if filter_date is not None else ''
|
||||||
include_filter = get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
include_filter = get_chat_condition(
|
||||||
exclude_filter = get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
||||||
|
exclude_filter = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
||||||
|
|
||||||
cursor.execute(f"""SELECT messages.key_remote_jid,
|
cursor.execute(f"""SELECT messages.key_remote_jid,
|
||||||
message_row_id,
|
message_row_id,
|
||||||
@@ -592,8 +611,10 @@ def _get_media_cursor_new(cursor, filter_empty, filter_date, filter_chat):
|
|||||||
"""Get cursor for new media database schema."""
|
"""Get cursor for new media database schema."""
|
||||||
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")
|
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")
|
||||||
date_filter = f'AND message.timestamp {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND message.timestamp {filter_date}' if filter_date is not None else ''
|
||||||
include_filter = get_chat_condition(filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")
|
include_filter = get_chat_condition(
|
||||||
exclude_filter = get_chat_condition(filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")
|
filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")
|
||||||
|
exclude_filter = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")
|
||||||
|
|
||||||
cursor.execute(f"""SELECT jid.raw_string as key_remote_jid,
|
cursor.execute(f"""SELECT jid.raw_string as key_remote_jid,
|
||||||
message_row_id,
|
message_row_id,
|
||||||
@@ -645,8 +666,8 @@ def _process_single_media(data, content, media_folder, mime, separate_media):
|
|||||||
|
|
||||||
# Copy media to separate folder if needed
|
# Copy media to separate folder if needed
|
||||||
if separate_media:
|
if separate_media:
|
||||||
chat_display_name = slugify(current_chat.name or message.sender
|
chat_display_name = safe_name(current_chat.name or message.sender
|
||||||
or content["key_remote_jid"].split('@')[0], True)
|
or content["key_remote_jid"].split('@')[0])
|
||||||
current_filename = file_path.split("/")[-1]
|
current_filename = file_path.split("/")[-1]
|
||||||
new_folder = os.path.join(media_folder, "separated", chat_display_name)
|
new_folder = os.path.join(media_folder, "separated", chat_display_name)
|
||||||
Path(new_folder).mkdir(parents=True, exist_ok=True)
|
Path(new_folder).mkdir(parents=True, exist_ok=True)
|
||||||
@@ -676,7 +697,7 @@ def vcard(db, data, media_folder, filter_date, filter_chat, filter_empty):
|
|||||||
rows = _execute_vcard_query_legacy(c, filter_date, filter_chat, filter_empty)
|
rows = _execute_vcard_query_legacy(c, filter_date, filter_chat, filter_empty)
|
||||||
|
|
||||||
total_row_number = len(rows)
|
total_row_number = len(rows)
|
||||||
print(f"\nProcessing vCards...(0/{total_row_number})", end="\r")
|
logger.info(f"Processing vCards...(0/{total_row_number})\r")
|
||||||
|
|
||||||
# Create vCards directory if it doesn't exist
|
# Create vCards directory if it doesn't exist
|
||||||
path = os.path.join(media_folder, "vCards")
|
path = os.path.join(media_folder, "vCards")
|
||||||
@@ -684,15 +705,18 @@ def vcard(db, data, media_folder, filter_date, filter_chat, filter_empty):
|
|||||||
|
|
||||||
for index, row in enumerate(rows):
|
for index, row in enumerate(rows):
|
||||||
_process_vcard_row(row, path, data)
|
_process_vcard_row(row, path, data)
|
||||||
print(f"Processing vCards...({index + 1}/{total_row_number})", end="\r")
|
logger.info(f"Processing vCards...({index + 1}/{total_row_number})\r")
|
||||||
|
logger.info(f"Processed {total_row_number} vCards{CLEAR_LINE}")
|
||||||
|
|
||||||
|
|
||||||
def _execute_vcard_query_modern(c, filter_date, filter_chat, filter_empty):
|
def _execute_vcard_query_modern(c, filter_date, filter_chat, filter_empty):
|
||||||
"""Execute vCard query for modern WhatsApp database schema."""
|
"""Execute vCard query for modern WhatsApp database schema."""
|
||||||
|
|
||||||
# Build the filter conditions
|
# Build the filter conditions
|
||||||
chat_filter_include = get_chat_condition(filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
chat_filter_include = get_chat_condition(
|
||||||
chat_filter_exclude = get_chat_condition(filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
filter_chat[0], True, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
||||||
|
chat_filter_exclude = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["messages.key_remote_jid", "remote_resource"], "jid", "android")
|
||||||
date_filter = f'AND messages.timestamp {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND messages.timestamp {filter_date}' if filter_date is not None else ''
|
||||||
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "messages.needs_push")
|
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "messages.needs_push")
|
||||||
|
|
||||||
@@ -721,8 +745,10 @@ def _execute_vcard_query_legacy(c, filter_date, filter_chat, filter_empty):
|
|||||||
"""Execute vCard query for legacy WhatsApp database schema."""
|
"""Execute vCard query for legacy WhatsApp database schema."""
|
||||||
|
|
||||||
# Build the filter conditions
|
# Build the filter conditions
|
||||||
chat_filter_include = get_chat_condition(filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")
|
chat_filter_include = get_chat_condition(
|
||||||
chat_filter_exclude = get_chat_condition(filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")
|
filter_chat[0], True, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")
|
||||||
|
chat_filter_exclude = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["key_remote_jid", "jid_group.raw_string"], "jid", "android")
|
||||||
date_filter = f'AND message.timestamp {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND message.timestamp {filter_date}' if filter_date is not None else ''
|
||||||
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")
|
empty_filter = get_cond_for_empty(filter_empty, "key_remote_jid", "broadcast")
|
||||||
|
|
||||||
@@ -777,7 +803,7 @@ def calls(db, data, timezone_offset, filter_chat):
|
|||||||
if total_row_number == 0:
|
if total_row_number == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"\nProcessing calls...({total_row_number})", end="\r")
|
logger.info(f"Processing calls...({total_row_number})\r")
|
||||||
|
|
||||||
# Fetch call data
|
# Fetch call data
|
||||||
calls_data = _fetch_calls_data(c, filter_chat)
|
calls_data = _fetch_calls_data(c, filter_chat)
|
||||||
@@ -793,6 +819,7 @@ def calls(db, data, timezone_offset, filter_chat):
|
|||||||
|
|
||||||
# Add the calls chat to the data
|
# Add the calls chat to the data
|
||||||
data.add_chat("000000000000000", chat)
|
data.add_chat("000000000000000", chat)
|
||||||
|
logger.info(f"Processed {total_row_number} calls{CLEAR_LINE}")
|
||||||
|
|
||||||
|
|
||||||
def _get_calls_count(c, filter_chat):
|
def _get_calls_count(c, filter_chat):
|
||||||
@@ -912,12 +939,12 @@ def create_html(
|
|||||||
no_avatar=False,
|
no_avatar=False,
|
||||||
experimental=False,
|
experimental=False,
|
||||||
headline=None
|
headline=None
|
||||||
):
|
):
|
||||||
"""Generate HTML chat files from data."""
|
"""Generate HTML chat files from data."""
|
||||||
template = setup_template(template, no_avatar, experimental)
|
template = setup_template(template, no_avatar, experimental)
|
||||||
|
|
||||||
total_row_number = len(data)
|
total_row_number = len(data)
|
||||||
print(f"\nGenerating chats...(0/{total_row_number})", end="\r")
|
logger.info(f"Generating chats...(0/{total_row_number})\r")
|
||||||
|
|
||||||
# Create output directory if it doesn't exist
|
# Create output directory if it doesn't exist
|
||||||
if not os.path.isdir(output_folder):
|
if not os.path.isdir(output_folder):
|
||||||
@@ -958,9 +985,9 @@ def create_html(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if current % 10 == 0:
|
if current % 10 == 0:
|
||||||
print(f"Generating chats...({current}/{total_row_number})", end="\r")
|
logger.info(f"Generating chats...({current}/{total_row_number})\r")
|
||||||
|
|
||||||
print(f"Generating chats...({total_row_number}/{total_row_number})", end="\r")
|
logger.info(f"Generated {total_row_number} chats{CLEAR_LINE}")
|
||||||
|
|
||||||
|
|
||||||
def _generate_single_chat(current_chat, safe_file_name, name, contact, output_folder, template, w3css, headline):
|
def _generate_single_chat(current_chat, safe_file_name, name, contact, output_folder, template, w3css, headline):
|
||||||
|
|||||||
@@ -24,38 +24,6 @@ import struct
|
|||||||
import codecs
|
import codecs
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
class BPListWriter(object):
|
|
||||||
def __init__(self, objects):
|
|
||||||
self.bplist = ""
|
|
||||||
self.objects = objects
|
|
||||||
|
|
||||||
def binary(self):
|
|
||||||
'''binary -> string
|
|
||||||
|
|
||||||
Generates bplist
|
|
||||||
'''
|
|
||||||
self.data = 'bplist00'
|
|
||||||
|
|
||||||
# TODO: flatten objects and count max length size
|
|
||||||
|
|
||||||
# TODO: write objects and save offsets
|
|
||||||
|
|
||||||
# TODO: write offsets
|
|
||||||
|
|
||||||
# TODO: write metadata
|
|
||||||
|
|
||||||
return self.data
|
|
||||||
|
|
||||||
def write(self, filename):
|
|
||||||
'''
|
|
||||||
|
|
||||||
Writes bplist to file
|
|
||||||
'''
|
|
||||||
if self.bplist != "":
|
|
||||||
pass
|
|
||||||
# TODO: save self.bplist to file
|
|
||||||
else:
|
|
||||||
raise Exception('BPlist not yet generated')
|
|
||||||
|
|
||||||
class BPListReader(object):
|
class BPListReader(object):
|
||||||
def __init__(self, s):
|
def __init__(self, s):
|
||||||
@@ -135,7 +103,7 @@ class BPListReader(object):
|
|||||||
|
|
||||||
def __unpackDate(self, offset):
|
def __unpackDate(self, offset):
|
||||||
td = int(struct.unpack(">d", self.data[offset+1:offset+9])[0])
|
td = int(struct.unpack(">d", self.data[offset+1:offset+9])[0])
|
||||||
return datetime(year=2001,month=1,day=1) + timedelta(seconds=td)
|
return datetime(year=2001, month=1, day=1) + timedelta(seconds=td)
|
||||||
|
|
||||||
def __unpackItem(self, offset):
|
def __unpackItem(self, offset):
|
||||||
'''__unpackItem(offset)
|
'''__unpackItem(offset)
|
||||||
@@ -154,44 +122,53 @@ class BPListReader(object):
|
|||||||
elif obj_info == 0x0F: # fill 0000 1111 // fill byte
|
elif obj_info == 0x0F: # fill 0000 1111 // fill byte
|
||||||
raise Exception("0x0F Not Implemented") # this is really pad byte, FIXME
|
raise Exception("0x0F Not Implemented") # this is really pad byte, FIXME
|
||||||
else:
|
else:
|
||||||
raise Exception('unpack item type '+str(obj_header)+' at '+str(offset)+ 'failed')
|
raise Exception('unpack item type '+str(obj_header)+' at '+str(offset) + 'failed')
|
||||||
elif obj_type == 0x10: # int 0001 nnnn ... // # of bytes is 2^nnnn, big-endian bytes
|
elif obj_type == 0x10: # int 0001 nnnn ... // # of bytes is 2^nnnn, big-endian bytes
|
||||||
return self.__unpackInt(offset)
|
return self.__unpackInt(offset)
|
||||||
elif obj_type == 0x20: # real 0010 nnnn ... // # of bytes is 2^nnnn, big-endian bytes
|
elif obj_type == 0x20: # real 0010 nnnn ... // # of bytes is 2^nnnn, big-endian bytes
|
||||||
return self.__unpackFloat(offset)
|
return self.__unpackFloat(offset)
|
||||||
elif obj_type == 0x30: # date 0011 0011 ... // 8 byte float follows, big-endian bytes
|
elif obj_type == 0x30: # date 0011 0011 ... // 8 byte float follows, big-endian bytes
|
||||||
return self.__unpackDate(offset)
|
return self.__unpackDate(offset)
|
||||||
elif obj_type == 0x40: # data 0100 nnnn [int] ... // nnnn is number of bytes unless 1111 then int count follows, followed by bytes
|
# data 0100 nnnn [int] ... // nnnn is number of bytes unless 1111 then int count follows, followed by bytes
|
||||||
|
elif obj_type == 0x40:
|
||||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||||
return self.data[objref:objref+obj_count] # XXX: we return data as str
|
return self.data[objref:objref+obj_count] # XXX: we return data as str
|
||||||
elif obj_type == 0x50: # string 0101 nnnn [int] ... // ASCII string, nnnn is # of chars, else 1111 then int count, then bytes
|
# string 0101 nnnn [int] ... // ASCII string, nnnn is # of chars, else 1111 then int count, then bytes
|
||||||
|
elif obj_type == 0x50:
|
||||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||||
return self.data[objref:objref+obj_count]
|
return self.data[objref:objref+obj_count]
|
||||||
elif obj_type == 0x60: # string 0110 nnnn [int] ... // Unicode string, nnnn is # of chars, else 1111 then int count, then big-endian 2-byte uint16_t
|
# string 0110 nnnn [int] ... // Unicode string, nnnn is # of chars, else 1111 then int count, then big-endian 2-byte uint16_t
|
||||||
|
elif obj_type == 0x60:
|
||||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||||
return self.data[objref:objref+obj_count*2].decode('utf-16be')
|
return self.data[objref:objref+obj_count*2].decode('utf-16be')
|
||||||
elif obj_type == 0x80: # uid 1000 nnnn ... // nnnn+1 is # of bytes
|
elif obj_type == 0x80: # uid 1000 nnnn ... // nnnn+1 is # of bytes
|
||||||
# FIXME: Accept as a string for now
|
# FIXME: Accept as a string for now
|
||||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||||
return self.data[objref:objref+obj_count]
|
return self.data[objref:objref+obj_count]
|
||||||
elif obj_type == 0xA0: # array 1010 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows
|
# array 1010 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows
|
||||||
|
elif obj_type == 0xA0:
|
||||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||||
arr = []
|
arr = []
|
||||||
for i in range(obj_count):
|
for i in range(obj_count):
|
||||||
arr.append(self.__unpackIntStruct(self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
|
arr.append(self.__unpackIntStruct(
|
||||||
|
self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
|
||||||
return arr
|
return arr
|
||||||
elif obj_type == 0xC0: # set 1100 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows
|
# set 1100 nnnn [int] objref* // nnnn is count, unless '1111', then int count follows
|
||||||
|
elif obj_type == 0xC0:
|
||||||
# XXX: not serializable via apple implementation
|
# XXX: not serializable via apple implementation
|
||||||
raise Exception("0xC0 Not Implemented") # FIXME: implement
|
raise Exception("0xC0 Not Implemented") # FIXME: implement
|
||||||
elif obj_type == 0xD0: # dict 1101 nnnn [int] keyref* objref* // nnnn is count, unless '1111', then int count follows
|
# dict 1101 nnnn [int] keyref* objref* // nnnn is count, unless '1111', then int count follows
|
||||||
|
elif obj_type == 0xD0:
|
||||||
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
obj_count, objref = self.__resolveIntSize(obj_info, offset)
|
||||||
keys = []
|
keys = []
|
||||||
for i in range(obj_count):
|
for i in range(obj_count):
|
||||||
keys.append(self.__unpackIntStruct(self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
|
keys.append(self.__unpackIntStruct(
|
||||||
|
self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
|
||||||
values = []
|
values = []
|
||||||
objref += obj_count*self.object_ref_size
|
objref += obj_count*self.object_ref_size
|
||||||
for i in range(obj_count):
|
for i in range(obj_count):
|
||||||
values.append(self.__unpackIntStruct(self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
|
values.append(self.__unpackIntStruct(
|
||||||
|
self.object_ref_size, self.data[objref+i*self.object_ref_size:objref+i*self.object_ref_size+self.object_ref_size]))
|
||||||
dic = {}
|
dic = {}
|
||||||
for i in range(obj_count):
|
for i in range(obj_count):
|
||||||
dic[keys[i]] = values[i]
|
dic[keys[i]] = values[i]
|
||||||
@@ -212,7 +189,7 @@ class BPListReader(object):
|
|||||||
return newArr
|
return newArr
|
||||||
if type(obj) == dict:
|
if type(obj) == dict:
|
||||||
newDic = {}
|
newDic = {}
|
||||||
for k,v in obj.items():
|
for k, v in obj.items():
|
||||||
key_resolved = self.__resolveObject(k)
|
key_resolved = self.__resolveObject(k)
|
||||||
if isinstance(key_resolved, str):
|
if isinstance(key_resolved, str):
|
||||||
rk = key_resolved
|
rk = key_resolved
|
||||||
@@ -232,8 +209,9 @@ class BPListReader(object):
|
|||||||
raise Exception('Bad magic')
|
raise Exception('Bad magic')
|
||||||
|
|
||||||
# read trailer
|
# read trailer
|
||||||
self.offset_size, self.object_ref_size, self.number_of_objects, self.top_object, self.table_offset = struct.unpack('!6xBB4xI4xI4xI', self.data[-32:])
|
self.offset_size, self.object_ref_size, self.number_of_objects, self.top_object, self.table_offset = struct.unpack(
|
||||||
#print "** plist offset_size:",self.offset_size,"objref_size:",self.object_ref_size,"num_objs:",self.number_of_objects,"top:",self.top_object,"table_ofs:",self.table_offset
|
'!6xBB4xI4xI4xI', self.data[-32:])
|
||||||
|
# print "** plist offset_size:",self.offset_size,"objref_size:",self.object_ref_size,"num_objs:",self.number_of_objects,"top:",self.top_object,"table_ofs:",self.table_offset
|
||||||
|
|
||||||
# read offset table
|
# read offset table
|
||||||
self.offset_table = self.data[self.table_offset:-32]
|
self.offset_table = self.data[self.table_offset:-32]
|
||||||
@@ -243,19 +221,19 @@ class BPListReader(object):
|
|||||||
offset_entry = ot[:self.offset_size]
|
offset_entry = ot[:self.offset_size]
|
||||||
ot = ot[self.offset_size:]
|
ot = ot[self.offset_size:]
|
||||||
self.offsets.append(self.__unpackIntStruct(self.offset_size, offset_entry))
|
self.offsets.append(self.__unpackIntStruct(self.offset_size, offset_entry))
|
||||||
#print "** plist offsets:",self.offsets
|
# print "** plist offsets:",self.offsets
|
||||||
|
|
||||||
# read object table
|
# read object table
|
||||||
self.objects = []
|
self.objects = []
|
||||||
k = 0
|
k = 0
|
||||||
for i in self.offsets:
|
for i in self.offsets:
|
||||||
obj = self.__unpackItem(i)
|
obj = self.__unpackItem(i)
|
||||||
#print "** plist unpacked",k,type(obj),obj,"at",i
|
# print "** plist unpacked",k,type(obj),obj,"at",i
|
||||||
k += 1
|
k += 1
|
||||||
self.objects.append(obj)
|
self.objects.append(obj)
|
||||||
|
|
||||||
# rebuild object tree
|
# rebuild object tree
|
||||||
#for i in range(len(self.objects)):
|
# for i in range(len(self.objects)):
|
||||||
# self.__resolveObject(i)
|
# self.__resolveObject(i)
|
||||||
|
|
||||||
# return root object
|
# return root object
|
||||||
@@ -265,28 +243,3 @@ class BPListReader(object):
|
|||||||
def plistWithString(cls, s):
|
def plistWithString(cls, s):
|
||||||
parser = cls(s)
|
parser = cls(s)
|
||||||
return parser.parse()
|
return parser.parse()
|
||||||
|
|
||||||
# helpers for testing
|
|
||||||
def plist(obj):
|
|
||||||
from Foundation import NSPropertyListSerialization, NSPropertyListBinaryFormat_v1_0
|
|
||||||
b = NSPropertyListSerialization.dataWithPropertyList_format_options_error_(obj, NSPropertyListBinaryFormat_v1_0, 0, None)
|
|
||||||
return str(b.bytes())
|
|
||||||
|
|
||||||
def unplist(s):
|
|
||||||
from Foundation import NSData, NSPropertyListSerialization
|
|
||||||
d = NSData.dataWithBytes_length_(s, len(s))
|
|
||||||
return NSPropertyListSerialization.propertyListWithData_options_format_error_(d, 0, None, None)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import json
|
|
||||||
file_path = sys.argv[1]
|
|
||||||
|
|
||||||
with open(file_path, "rb") as fp:
|
|
||||||
data = fp.read()
|
|
||||||
|
|
||||||
out = BPListReader(data).parse()
|
|
||||||
|
|
||||||
with open(file_path + ".json", "w") as fp:
|
|
||||||
json.dump(out, indent=4)
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ class Timing:
|
|||||||
"""
|
"""
|
||||||
Handles timestamp formatting with timezone support.
|
Handles timestamp formatting with timezone support.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, timezone_offset: Optional[int]) -> None:
|
def __init__(self, timezone_offset: Optional[int]) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize Timing object.
|
Initialize Timing object.
|
||||||
@@ -27,7 +28,7 @@ class Timing:
|
|||||||
Returns:
|
Returns:
|
||||||
Optional[str]: Formatted timestamp string, or None if timestamp is None
|
Optional[str]: Formatted timestamp string, or None if timestamp is None
|
||||||
"""
|
"""
|
||||||
if timestamp:
|
if timestamp is not None:
|
||||||
timestamp = timestamp / 1000 if timestamp > 9999999999 else timestamp
|
timestamp = timestamp / 1000 if timestamp > 9999999999 else timestamp
|
||||||
return datetime.fromtimestamp(timestamp, TimeZone(self.timezone_offset)).strftime(format)
|
return datetime.fromtimestamp(timestamp, TimeZone(self.timezone_offset)).strftime(format)
|
||||||
return None
|
return None
|
||||||
@@ -37,6 +38,7 @@ class TimeZone(tzinfo):
|
|||||||
"""
|
"""
|
||||||
Custom timezone class with fixed offset.
|
Custom timezone class with fixed offset.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, offset: int) -> None:
|
def __init__(self, offset: int) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize TimeZone object.
|
Initialize TimeZone object.
|
||||||
@@ -151,6 +153,7 @@ class ChatStore:
|
|||||||
"""
|
"""
|
||||||
Stores chat information and messages.
|
Stores chat information and messages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, type: str, name: Optional[str] = None, media: Optional[str] = None) -> None:
|
def __init__(self, type: str, name: Optional[str] = None, media: Optional[str] = None) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize ChatStore object.
|
Initialize ChatStore object.
|
||||||
@@ -204,15 +207,25 @@ class ChatStore:
|
|||||||
|
|
||||||
def to_json(self) -> Dict[str, Any]:
|
def to_json(self) -> Dict[str, Any]:
|
||||||
"""Convert chat store to JSON-serializable dict."""
|
"""Convert chat store to JSON-serializable dict."""
|
||||||
return {
|
json_dict = {
|
||||||
'name': self.name,
|
key: value
|
||||||
'type': self.type,
|
for key, value in self.__dict__.items()
|
||||||
'my_avatar': self.my_avatar,
|
if key != '_messages'
|
||||||
'their_avatar': self.their_avatar,
|
|
||||||
'their_avatar_thumb': self.their_avatar_thumb,
|
|
||||||
'status': self.status,
|
|
||||||
'messages': {id: msg.to_json() for id, msg in self._messages.items()}
|
|
||||||
}
|
}
|
||||||
|
json_dict['messages'] = {id: msg.to_json() for id, msg in self._messages.items()}
|
||||||
|
return json_dict
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data: Dict) -> 'ChatStore':
|
||||||
|
"""Create a chat store from JSON data."""
|
||||||
|
chat = cls(data.get("type"), data.get("name"))
|
||||||
|
for key, value in data.items():
|
||||||
|
if hasattr(chat, key) and key not in ("messages", "type", "name"):
|
||||||
|
setattr(chat, key, value)
|
||||||
|
for id, msg_data in data.get("messages", {}).items():
|
||||||
|
message = Message.from_json(msg_data)
|
||||||
|
chat.add_message(id, message)
|
||||||
|
return chat
|
||||||
|
|
||||||
def get_last_message(self) -> 'Message':
|
def get_last_message(self) -> 'Message':
|
||||||
"""Get the most recent message in the chat."""
|
"""Get the most recent message in the chat."""
|
||||||
@@ -230,20 +243,42 @@ class ChatStore:
|
|||||||
"""Get all message keys in the chat."""
|
"""Get all message keys in the chat."""
|
||||||
return self._messages.keys()
|
return self._messages.keys()
|
||||||
|
|
||||||
|
def merge_with(self, other: 'ChatStore'):
|
||||||
|
"""Merge another ChatStore into this one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
other (ChatStore): The ChatStore to merge with
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not isinstance(other, ChatStore):
|
||||||
|
raise TypeError("Can only merge with another ChatStore object")
|
||||||
|
|
||||||
|
# Update fields if they are not None in the other ChatStore
|
||||||
|
self.name = other.name or self.name
|
||||||
|
self.type = other.type or self.type
|
||||||
|
self.my_avatar = other.my_avatar or self.my_avatar
|
||||||
|
self.their_avatar = other.their_avatar or self.their_avatar
|
||||||
|
self.their_avatar_thumb = other.their_avatar_thumb or self.their_avatar_thumb
|
||||||
|
self.status = other.status or self.status
|
||||||
|
|
||||||
|
# Merge messages
|
||||||
|
self._messages.update(other._messages)
|
||||||
|
|
||||||
|
|
||||||
class Message:
|
class Message:
|
||||||
"""
|
"""
|
||||||
Represents a single message in a chat.
|
Represents a single message in a chat.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
from_me: Union[bool, int],
|
from_me: Union[bool, int],
|
||||||
timestamp: int,
|
timestamp: int,
|
||||||
time: Union[int, float, str],
|
time: Union[int, float, str],
|
||||||
key_id: int,
|
key_id: Union[int, str],
|
||||||
received_timestamp: int,
|
received_timestamp: int = None,
|
||||||
read_timestamp: int,
|
read_timestamp: int = None,
|
||||||
timezone_offset: int = 0,
|
timezone_offset: int = 0,
|
||||||
message_type: Optional[int] = None
|
message_type: Optional[int] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -255,8 +290,8 @@ class Message:
|
|||||||
timestamp (int): Message timestamp
|
timestamp (int): Message timestamp
|
||||||
time (Union[int, float, str]): Message time
|
time (Union[int, float, str]): Message time
|
||||||
key_id (int): Message unique identifier
|
key_id (int): Message unique identifier
|
||||||
received_timestamp (int): When message was received
|
received_timestamp (int, optional): When message was received. Defaults to None
|
||||||
read_timestamp (int): When message was read
|
read_timestamp (int, optional): When message was read. Defaults to None
|
||||||
timezone_offset (int, optional): Hours offset from UTC. Defaults to 0
|
timezone_offset (int, optional): Hours offset from UTC. Defaults to 0
|
||||||
message_type (Optional[int], optional): Type of message. Defaults to None
|
message_type (Optional[int], optional): Type of message. Defaults to None
|
||||||
|
|
||||||
@@ -281,9 +316,21 @@ class Message:
|
|||||||
self.sender = None
|
self.sender = None
|
||||||
self.safe = False
|
self.safe = False
|
||||||
self.mime = None
|
self.mime = None
|
||||||
self.message_type = message_type,
|
self.message_type = message_type
|
||||||
self.received_timestamp = timing.format_timestamp(received_timestamp, "%Y/%m/%d %H:%M")
|
if isinstance(received_timestamp, (int, float)):
|
||||||
self.read_timestamp = timing.format_timestamp(read_timestamp, "%Y/%m/%d %H:%M")
|
self.received_timestamp = timing.format_timestamp(
|
||||||
|
received_timestamp, "%Y/%m/%d %H:%M")
|
||||||
|
elif isinstance(received_timestamp, str):
|
||||||
|
self.received_timestamp = received_timestamp
|
||||||
|
else:
|
||||||
|
self.received_timestamp = None
|
||||||
|
if isinstance(read_timestamp, (int, float)):
|
||||||
|
self.read_timestamp = timing.format_timestamp(
|
||||||
|
read_timestamp, "%Y/%m/%d %H:%M")
|
||||||
|
elif isinstance(read_timestamp, str):
|
||||||
|
self.read_timestamp = read_timestamp
|
||||||
|
else:
|
||||||
|
self.read_timestamp = None
|
||||||
|
|
||||||
# Extra attributes
|
# Extra attributes
|
||||||
self.reply = None
|
self.reply = None
|
||||||
@@ -295,19 +342,24 @@ class Message:
|
|||||||
def to_json(self) -> Dict[str, Any]:
|
def to_json(self) -> Dict[str, Any]:
|
||||||
"""Convert message to JSON-serializable dict."""
|
"""Convert message to JSON-serializable dict."""
|
||||||
return {
|
return {
|
||||||
'from_me': self.from_me,
|
key: value
|
||||||
'timestamp': self.timestamp,
|
for key, value in self.__dict__.items()
|
||||||
'time': self.time,
|
|
||||||
'media': self.media,
|
|
||||||
'key_id': self.key_id,
|
|
||||||
'meta': self.meta,
|
|
||||||
'data': self.data,
|
|
||||||
'sender': self.sender,
|
|
||||||
'safe': self.safe,
|
|
||||||
'mime': self.mime,
|
|
||||||
'reply': self.reply,
|
|
||||||
'quoted_data': self.quoted_data,
|
|
||||||
'caption': self.caption,
|
|
||||||
'thumb': self.thumb,
|
|
||||||
'sticker': self.sticker
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_json(cls, data: Dict) -> 'Message':
|
||||||
|
message = cls(
|
||||||
|
from_me=data["from_me"],
|
||||||
|
timestamp=data["timestamp"],
|
||||||
|
time=data["time"],
|
||||||
|
key_id=data["key_id"],
|
||||||
|
message_type=data.get("message_type"),
|
||||||
|
received_timestamp=data.get("received_timestamp"),
|
||||||
|
read_timestamp=data.get("read_timestamp")
|
||||||
|
)
|
||||||
|
added = ("from_me", "timestamp", "time", "key_id", "message_type",
|
||||||
|
"received_timestamp", "read_timestamp")
|
||||||
|
for key, value in data.items():
|
||||||
|
if hasattr(message, key) and key not in added:
|
||||||
|
setattr(message, key, value)
|
||||||
|
return message
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from mimetypes import MimeTypes
|
from mimetypes import MimeTypes
|
||||||
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
||||||
from Whatsapp_Chat_Exporter.utility import Device
|
from Whatsapp_Chat_Exporter.utility import CLEAR_LINE, Device
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def messages(path, data, assume_first_as_me=False):
|
def messages(path, data, assume_first_as_me=False):
|
||||||
@@ -38,9 +42,9 @@ def messages(path, data, assume_first_as_me=False):
|
|||||||
|
|
||||||
# Show progress
|
# Show progress
|
||||||
if index % 1000 == 0:
|
if index % 1000 == 0:
|
||||||
print(f"Processing messages & media...({index}/{total_row_number})", end="\r")
|
logger.info(f"Processing messages & media...({index}/{total_row_number})\r")
|
||||||
|
|
||||||
print(f"Processing messages & media...({total_row_number}/{total_row_number})")
|
logger.info(f"Processed {total_row_number} messages & media{CLEAR_LINE}")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
from glob import glob
|
from glob import glob
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from mimetypes import MimeTypes
|
from mimetypes import MimeTypes
|
||||||
from markupsafe import escape as htmle
|
from markupsafe import escape as htmle
|
||||||
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
||||||
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, CURRENT_TZ_OFFSET, get_chat_condition
|
from Whatsapp_Chat_Exporter.utility import APPLE_TIME, CLEAR_LINE, CURRENT_TZ_OFFSET, get_chat_condition
|
||||||
from Whatsapp_Chat_Exporter.utility import bytes_to_readable, convert_time_unit, slugify, Device
|
from Whatsapp_Chat_Exporter.utility import bytes_to_readable, convert_time_unit, safe_name, Device
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def contacts(db, data):
|
def contacts(db, data):
|
||||||
@@ -16,7 +20,7 @@ def contacts(db, data):
|
|||||||
c = db.cursor()
|
c = db.cursor()
|
||||||
c.execute("""SELECT count() FROM ZWAADDRESSBOOKCONTACT WHERE ZABOUTTEXT IS NOT NULL""")
|
c.execute("""SELECT count() FROM ZWAADDRESSBOOKCONTACT WHERE ZABOUTTEXT IS NOT NULL""")
|
||||||
total_row_number = c.fetchone()[0]
|
total_row_number = c.fetchone()[0]
|
||||||
print(f"Pre-processing contacts...({total_row_number})")
|
logger.info(f"Pre-processing contacts...({total_row_number})\r")
|
||||||
|
|
||||||
c.execute("""SELECT ZWHATSAPPID, ZABOUTTEXT FROM ZWAADDRESSBOOKCONTACT WHERE ZABOUTTEXT IS NOT NULL""")
|
c.execute("""SELECT ZWHATSAPPID, ZABOUTTEXT FROM ZWAADDRESSBOOKCONTACT WHERE ZABOUTTEXT IS NOT NULL""")
|
||||||
content = c.fetchone()
|
content = c.fetchone()
|
||||||
@@ -29,6 +33,7 @@ def contacts(db, data):
|
|||||||
current_chat.status = content["ZABOUTTEXT"]
|
current_chat.status = content["ZABOUTTEXT"]
|
||||||
data.add_chat(zwhatsapp_id, current_chat)
|
data.add_chat(zwhatsapp_id, current_chat)
|
||||||
content = c.fetchone()
|
content = c.fetchone()
|
||||||
|
logger.info(f"Pre-processed {total_row_number} contacts{CLEAR_LINE}")
|
||||||
|
|
||||||
|
|
||||||
def process_contact_avatars(current_chat, media_folder, contact_id):
|
def process_contact_avatars(current_chat, media_folder, contact_id):
|
||||||
@@ -55,14 +60,16 @@ def get_contact_name(content):
|
|||||||
return content["ZPUSHNAME"]
|
return content["ZPUSHNAME"]
|
||||||
|
|
||||||
|
|
||||||
def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat, filter_empty):
|
def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat, filter_empty, no_reply):
|
||||||
"""Process WhatsApp messages and contacts from the database."""
|
"""Process WhatsApp messages and contacts from the database."""
|
||||||
c = db.cursor()
|
c = db.cursor()
|
||||||
cursor2 = db.cursor()
|
cursor2 = db.cursor()
|
||||||
|
|
||||||
# Build the chat filter conditions
|
# Build the chat filter conditions
|
||||||
chat_filter_include = get_chat_condition(filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")
|
chat_filter_include = get_chat_condition(
|
||||||
chat_filter_exclude = get_chat_condition(filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")
|
filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")
|
||||||
|
chat_filter_exclude = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")
|
||||||
date_filter = f'AND ZMESSAGEDATE {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND ZMESSAGEDATE {filter_date}' if filter_date is not None else ''
|
||||||
|
|
||||||
# Process contacts first
|
# Process contacts first
|
||||||
@@ -85,7 +92,7 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
|||||||
"""
|
"""
|
||||||
c.execute(contact_query)
|
c.execute(contact_query)
|
||||||
total_row_number = c.fetchone()[0]
|
total_row_number = c.fetchone()[0]
|
||||||
print(f"Processing contacts...({total_row_number})")
|
logger.info(f"Processing contacts...({total_row_number})\r")
|
||||||
|
|
||||||
# Get distinct contacts
|
# Get distinct contacts
|
||||||
contacts_query = f"""
|
contacts_query = f"""
|
||||||
@@ -124,6 +131,8 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
|||||||
process_contact_avatars(current_chat, media_folder, contact_id)
|
process_contact_avatars(current_chat, media_folder, contact_id)
|
||||||
content = c.fetchone()
|
content = c.fetchone()
|
||||||
|
|
||||||
|
logger.info(f"Processed {total_row_number} contacts{CLEAR_LINE}")
|
||||||
|
|
||||||
# Get message count
|
# Get message count
|
||||||
message_count_query = f"""
|
message_count_query = f"""
|
||||||
SELECT count()
|
SELECT count()
|
||||||
@@ -139,7 +148,7 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
|||||||
"""
|
"""
|
||||||
c.execute(message_count_query)
|
c.execute(message_count_query)
|
||||||
total_row_number = c.fetchone()[0]
|
total_row_number = c.fetchone()[0]
|
||||||
print(f"Processing messages...(0/{total_row_number})", end="\r")
|
logger.info(f"Processing messages...(0/{total_row_number})\r")
|
||||||
|
|
||||||
# Fetch messages
|
# Fetch messages
|
||||||
messages_query = f"""
|
messages_query = f"""
|
||||||
@@ -198,7 +207,7 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Process message data
|
# Process message data
|
||||||
invalid = process_message_data(message, content, is_group_message, data, cursor2)
|
invalid = process_message_data(message, content, is_group_message, data, cursor2, no_reply)
|
||||||
|
|
||||||
# Add valid messages to chat
|
# Add valid messages to chat
|
||||||
if not invalid:
|
if not invalid:
|
||||||
@@ -207,13 +216,12 @@ def messages(db, data, media_folder, timezone_offset, filter_date, filter_chat,
|
|||||||
# Update progress
|
# Update progress
|
||||||
i += 1
|
i += 1
|
||||||
if i % 1000 == 0:
|
if i % 1000 == 0:
|
||||||
print(f"Processing messages...({i}/{total_row_number})", end="\r")
|
logger.info(f"Processing messages...({i}/{total_row_number})\r")
|
||||||
content = c.fetchone()
|
content = c.fetchone()
|
||||||
|
logger.info(f"Processed {total_row_number} messages{CLEAR_LINE}")
|
||||||
print(f"Processing messages...({total_row_number}/{total_row_number})", end="\r")
|
|
||||||
|
|
||||||
|
|
||||||
def process_message_data(message, content, is_group_message, data, cursor2):
|
def process_message_data(message, content, is_group_message, data, cursor2, no_reply):
|
||||||
"""Process and set message data from content row."""
|
"""Process and set message data from content row."""
|
||||||
# Handle group sender info
|
# Handle group sender info
|
||||||
if is_group_message and content["ZISFROMME"] == 0:
|
if is_group_message and content["ZISFROMME"] == 0:
|
||||||
@@ -236,7 +244,7 @@ def process_message_data(message, content, is_group_message, data, cursor2):
|
|||||||
return process_metadata_message(message, content, is_group_message)
|
return process_metadata_message(message, content, is_group_message)
|
||||||
|
|
||||||
# Handle quoted replies
|
# Handle quoted replies
|
||||||
if content["ZMETADATA"] is not None and content["ZMETADATA"].startswith(b"\x2a\x14") and False:
|
if content["ZMETADATA"] is not None and content["ZMETADATA"].startswith(b"\x2a\x14") and not no_reply:
|
||||||
quoted = content["ZMETADATA"][2:19]
|
quoted = content["ZMETADATA"][2:19]
|
||||||
message.reply = quoted.decode()
|
message.reply = quoted.decode()
|
||||||
cursor2.execute(f"""SELECT ZTEXT
|
cursor2.execute(f"""SELECT ZTEXT
|
||||||
@@ -308,8 +316,10 @@ def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separa
|
|||||||
c = db.cursor()
|
c = db.cursor()
|
||||||
|
|
||||||
# Build filter conditions
|
# Build filter conditions
|
||||||
chat_filter_include = get_chat_condition(filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID","ZMEMBERJID"], "ZGROUPINFO", "ios")
|
chat_filter_include = get_chat_condition(
|
||||||
chat_filter_exclude = get_chat_condition(filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")
|
filter_chat[0], True, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")
|
||||||
|
chat_filter_exclude = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["ZWACHATSESSION.ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")
|
||||||
date_filter = f'AND ZMESSAGEDATE {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND ZMESSAGEDATE {filter_date}' if filter_date is not None else ''
|
||||||
|
|
||||||
# Get media count
|
# Get media count
|
||||||
@@ -329,7 +339,7 @@ def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separa
|
|||||||
"""
|
"""
|
||||||
c.execute(media_count_query)
|
c.execute(media_count_query)
|
||||||
total_row_number = c.fetchone()[0]
|
total_row_number = c.fetchone()[0]
|
||||||
print(f"\nProcessing media...(0/{total_row_number})", end="\r")
|
logger.info(f"Processing media...(0/{total_row_number})\r")
|
||||||
|
|
||||||
# Fetch media items
|
# Fetch media items
|
||||||
media_query = f"""
|
media_query = f"""
|
||||||
@@ -365,10 +375,9 @@ def media(db, data, media_folder, filter_date, filter_chat, filter_empty, separa
|
|||||||
# Update progress
|
# Update progress
|
||||||
i += 1
|
i += 1
|
||||||
if i % 100 == 0:
|
if i % 100 == 0:
|
||||||
print(f"Processing media...({i}/{total_row_number})", end="\r")
|
logger.info(f"Processing media...({i}/{total_row_number})\r")
|
||||||
content = c.fetchone()
|
content = c.fetchone()
|
||||||
|
logger.info(f"Processed {total_row_number} media{CLEAR_LINE}")
|
||||||
print(f"Processing media...({total_row_number}/{total_row_number})", end="\r")
|
|
||||||
|
|
||||||
|
|
||||||
def process_media_item(content, data, media_folder, mime, separate_media):
|
def process_media_item(content, data, media_folder, mime, separate_media):
|
||||||
@@ -393,7 +402,8 @@ def process_media_item(content, data, media_folder, mime, separate_media):
|
|||||||
|
|
||||||
# Handle separate media option
|
# Handle separate media option
|
||||||
if separate_media:
|
if separate_media:
|
||||||
chat_display_name = slugify(current_chat.name or message.sender or content["ZCONTACTJID"].split('@')[0], True)
|
chat_display_name = safe_name(
|
||||||
|
current_chat.name or message.sender or content["ZCONTACTJID"].split('@')[0])
|
||||||
current_filename = file_path.split("/")[-1]
|
current_filename = file_path.split("/")[-1]
|
||||||
new_folder = os.path.join(media_folder, "separated", chat_display_name)
|
new_folder = os.path.join(media_folder, "separated", chat_display_name)
|
||||||
Path(new_folder).mkdir(parents=True, exist_ok=True)
|
Path(new_folder).mkdir(parents=True, exist_ok=True)
|
||||||
@@ -416,8 +426,10 @@ def vcard(db, data, media_folder, filter_date, filter_chat, filter_empty):
|
|||||||
c = db.cursor()
|
c = db.cursor()
|
||||||
|
|
||||||
# Build filter conditions
|
# Build filter conditions
|
||||||
chat_filter_include = get_chat_condition(filter_chat[0], True, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")
|
chat_filter_include = get_chat_condition(
|
||||||
chat_filter_exclude = get_chat_condition(filter_chat[1], False, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")
|
filter_chat[0], True, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")
|
||||||
|
chat_filter_exclude = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["ZCONTACTJID", "ZMEMBERJID"], "ZGROUPINFO", "ios")
|
||||||
date_filter = f'AND ZWAMESSAGE.ZMESSAGEDATE {filter_date}' if filter_date is not None else ''
|
date_filter = f'AND ZWAMESSAGE.ZMESSAGEDATE {filter_date}' if filter_date is not None else ''
|
||||||
|
|
||||||
# Fetch vCard mentions
|
# Fetch vCard mentions
|
||||||
@@ -444,7 +456,7 @@ def vcard(db, data, media_folder, filter_date, filter_chat, filter_empty):
|
|||||||
c.execute(vcard_query)
|
c.execute(vcard_query)
|
||||||
contents = c.fetchall()
|
contents = c.fetchall()
|
||||||
total_row_number = len(contents)
|
total_row_number = len(contents)
|
||||||
print(f"\nProcessing vCards...(0/{total_row_number})", end="\r")
|
logger.info(f"Processing vCards...(0/{total_row_number})\r")
|
||||||
|
|
||||||
# Create vCards directory
|
# Create vCards directory
|
||||||
path = f'{media_folder}/Message/vCards'
|
path = f'{media_folder}/Message/vCards'
|
||||||
@@ -453,7 +465,8 @@ def vcard(db, data, media_folder, filter_date, filter_chat, filter_empty):
|
|||||||
# Process each vCard
|
# Process each vCard
|
||||||
for index, content in enumerate(contents):
|
for index, content in enumerate(contents):
|
||||||
process_vcard_item(content, path, data)
|
process_vcard_item(content, path, data)
|
||||||
print(f"Processing vCards...({index + 1}/{total_row_number})", end="\r")
|
logger.info(f"Processing vCards...({index + 1}/{total_row_number})\r")
|
||||||
|
logger.info(f"Processed {total_row_number} vCards{CLEAR_LINE}")
|
||||||
|
|
||||||
|
|
||||||
def process_vcard_item(content, path, data):
|
def process_vcard_item(content, path, data):
|
||||||
@@ -479,7 +492,8 @@ def process_vcard_item(content, path, data):
|
|||||||
|
|
||||||
# Create vCard summary and update message
|
# Create vCard summary and update message
|
||||||
vcard_summary = "This media include the following vCard file(s):<br>"
|
vcard_summary = "This media include the following vCard file(s):<br>"
|
||||||
vcard_summary += " | ".join([f'<a href="{htmle(fp)}">{htmle(name)}</a>' for name, fp in zip(vcard_names, file_paths)])
|
vcard_summary += " | ".join([f'<a href="{htmle(fp)}">{htmle(name)}</a>' for name,
|
||||||
|
fp in zip(vcard_names, file_paths)])
|
||||||
|
|
||||||
message = data.get_chat(content["ZCONTACTJID"]).get_message(content["ZMESSAGE"])
|
message = data.get_chat(content["ZCONTACTJID"]).get_message(content["ZMESSAGE"])
|
||||||
message.data = vcard_summary
|
message.data = vcard_summary
|
||||||
@@ -494,8 +508,10 @@ def calls(db, data, timezone_offset, filter_chat):
|
|||||||
c = db.cursor()
|
c = db.cursor()
|
||||||
|
|
||||||
# Build filter conditions
|
# Build filter conditions
|
||||||
chat_filter_include = get_chat_condition(filter_chat[0], True, ["ZGROUPCALLCREATORUSERJIDSTRING"], None, "ios")
|
chat_filter_include = get_chat_condition(
|
||||||
chat_filter_exclude = get_chat_condition(filter_chat[1], False, ["ZGROUPCALLCREATORUSERJIDSTRING"], None, "ios")
|
filter_chat[0], True, ["ZGROUPCALLCREATORUSERJIDSTRING"], None, "ios")
|
||||||
|
chat_filter_exclude = get_chat_condition(
|
||||||
|
filter_chat[1], False, ["ZGROUPCALLCREATORUSERJIDSTRING"], None, "ios")
|
||||||
|
|
||||||
# Get call count
|
# Get call count
|
||||||
call_count_query = f"""
|
call_count_query = f"""
|
||||||
@@ -510,7 +526,7 @@ def calls(db, data, timezone_offset, filter_chat):
|
|||||||
if total_row_number == 0:
|
if total_row_number == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
print(f"\nProcessing calls...({total_row_number})", end="\r")
|
logger.info(f"Processed {total_row_number} calls{CLEAR_LINE}\n")
|
||||||
|
|
||||||
# Fetch call records
|
# Fetch call records
|
||||||
calls_query = f"""
|
calls_query = f"""
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
|
import logging
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
import getpass
|
import getpass
|
||||||
from sys import exit
|
from sys import exit, platform as osname
|
||||||
from Whatsapp_Chat_Exporter.utility import WhatsAppIdentifier
|
from Whatsapp_Chat_Exporter.utility import CLEAR_LINE, WhatsAppIdentifier
|
||||||
from Whatsapp_Chat_Exporter.bplist import BPListReader
|
from Whatsapp_Chat_Exporter.bplist import BPListReader
|
||||||
try:
|
try:
|
||||||
from iphone_backup_decrypt import EncryptedBackup, RelativePath
|
from iphone_backup_decrypt import EncryptedBackup, RelativePath
|
||||||
@@ -15,6 +16,9 @@ else:
|
|||||||
support_encrypted = True
|
support_encrypted = True
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BackupExtractor:
|
class BackupExtractor:
|
||||||
"""
|
"""
|
||||||
A class to handle the extraction of WhatsApp data from iOS backups,
|
A class to handle the extraction of WhatsApp data from iOS backups,
|
||||||
@@ -42,6 +46,7 @@ class BackupExtractor:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if encrypted, False otherwise.
|
bool: True if encrypted, False otherwise.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
with sqlite3.connect(os.path.join(self.base_dir, "Manifest.db")) as db:
|
with sqlite3.connect(os.path.join(self.base_dir, "Manifest.db")) as db:
|
||||||
c = db.cursor()
|
c = db.cursor()
|
||||||
try:
|
try:
|
||||||
@@ -51,18 +56,28 @@ class BackupExtractor:
|
|||||||
return True
|
return True
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
except sqlite3.DatabaseError as e:
|
||||||
|
if str(e) == "authorization denied" and osname == "darwin":
|
||||||
|
logger.error(
|
||||||
|
"You don't have permission to access the backup database. Please"
|
||||||
|
"check your permissions or try moving the backup to somewhere else."
|
||||||
|
)
|
||||||
|
exit(8)
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
def _extract_encrypted_backup(self):
|
def _extract_encrypted_backup(self):
|
||||||
"""
|
"""
|
||||||
Handles the extraction of data from an encrypted iOS backup.
|
Handles the extraction of data from an encrypted iOS backup.
|
||||||
"""
|
"""
|
||||||
if not support_encrypted:
|
if not support_encrypted:
|
||||||
print("You don't have the dependencies to handle encrypted backup.")
|
logger.error("You don't have the dependencies to handle encrypted backup."
|
||||||
print("Read more on how to deal with encrypted backup:")
|
"Read more on how to deal with encrypted backup:"
|
||||||
print("https://github.com/KnugiHK/Whatsapp-Chat-Exporter/blob/main/README.md#usage")
|
"https://github.com/KnugiHK/Whatsapp-Chat-Exporter/blob/main/README.md#usage"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
print("Encryption detected on the backup!")
|
logger.info(f"Encryption detected on the backup!{CLEAR_LINE}")
|
||||||
password = getpass.getpass("Enter the password for the backup:")
|
password = getpass.getpass("Enter the password for the backup:")
|
||||||
self._decrypt_backup(password)
|
self._decrypt_backup(password)
|
||||||
self._extract_decrypted_files()
|
self._extract_decrypted_files()
|
||||||
@@ -74,7 +89,7 @@ class BackupExtractor:
|
|||||||
Args:
|
Args:
|
||||||
password (str): The password for the encrypted backup.
|
password (str): The password for the encrypted backup.
|
||||||
"""
|
"""
|
||||||
print("Trying to decrypt the iOS backup...", end="")
|
logger.info(f"Trying to decrypt the iOS backup...{CLEAR_LINE}")
|
||||||
self.backup = EncryptedBackup(
|
self.backup = EncryptedBackup(
|
||||||
backup_directory=self.base_dir,
|
backup_directory=self.base_dir,
|
||||||
passphrase=password,
|
passphrase=password,
|
||||||
@@ -82,7 +97,8 @@ class BackupExtractor:
|
|||||||
check_same_thread=False,
|
check_same_thread=False,
|
||||||
decrypt_chunk_size=self.decrypt_chunk_size,
|
decrypt_chunk_size=self.decrypt_chunk_size,
|
||||||
)
|
)
|
||||||
print("Done\nDecrypting WhatsApp database...", end="")
|
logger.info(f"iOS backup decrypted successfully{CLEAR_LINE}")
|
||||||
|
logger.info("Decrypting WhatsApp database...\r")
|
||||||
try:
|
try:
|
||||||
self.backup.extract_file(
|
self.backup.extract_file(
|
||||||
relative_path=RelativePath.WHATSAPP_MESSAGES,
|
relative_path=RelativePath.WHATSAPP_MESSAGES,
|
||||||
@@ -100,23 +116,23 @@ class BackupExtractor:
|
|||||||
output_filename=self.identifiers.CALL,
|
output_filename=self.identifiers.CALL,
|
||||||
)
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print("Failed to decrypt backup: incorrect password?")
|
logger.error("Failed to decrypt backup: incorrect password?")
|
||||||
exit(7)
|
exit(7)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print(
|
logger.error(
|
||||||
"Essential WhatsApp files are missing from the iOS backup. "
|
"Essential WhatsApp files are missing from the iOS backup. "
|
||||||
"Perhapse you enabled end-to-end encryption for the backup? "
|
"Perhapse you enabled end-to-end encryption for the backup? "
|
||||||
"See https://wts.knugi.dev/docs.html?dest=iose2e"
|
"See https://wts.knugi.dev/docs.html?dest=iose2e"
|
||||||
)
|
)
|
||||||
exit(6)
|
exit(6)
|
||||||
else:
|
else:
|
||||||
print("Done")
|
logger.info(f"WhatsApp database decrypted successfully{CLEAR_LINE}")
|
||||||
|
|
||||||
def _extract_decrypted_files(self):
|
def _extract_decrypted_files(self):
|
||||||
"""Extract all WhatsApp files after decryption"""
|
"""Extract all WhatsApp files after decryption"""
|
||||||
def extract_progress_handler(file_id, domain, relative_path, n, total_files):
|
def extract_progress_handler(file_id, domain, relative_path, n, total_files):
|
||||||
if n % 100 == 0:
|
if n % 100 == 0:
|
||||||
print(f"Decrypting and extracting files...({n}/{total_files})", end="\r")
|
logger.info(f"Decrypting and extracting files...({n}/{total_files})\r")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.backup.extract_files(
|
self.backup.extract_files(
|
||||||
@@ -125,7 +141,7 @@ class BackupExtractor:
|
|||||||
preserve_folders=True,
|
preserve_folders=True,
|
||||||
filter_callback=extract_progress_handler
|
filter_callback=extract_progress_handler
|
||||||
)
|
)
|
||||||
print(f"All required files are decrypted and extracted. ", end="\n")
|
logger.info(f"All required files are decrypted and extracted.{CLEAR_LINE}")
|
||||||
|
|
||||||
def _extract_unencrypted_backup(self):
|
def _extract_unencrypted_backup(self):
|
||||||
"""
|
"""
|
||||||
@@ -144,10 +160,10 @@ class BackupExtractor:
|
|||||||
|
|
||||||
if not os.path.isfile(wts_db_path):
|
if not os.path.isfile(wts_db_path):
|
||||||
if self.identifiers is WhatsAppIdentifier:
|
if self.identifiers is WhatsAppIdentifier:
|
||||||
print("WhatsApp database not found.")
|
logger.error("WhatsApp database not found.")
|
||||||
else:
|
else:
|
||||||
print("WhatsApp Business database not found.")
|
logger.error("WhatsApp Business database not found.")
|
||||||
print(
|
logger.error(
|
||||||
"Essential WhatsApp files are missing from the iOS backup. "
|
"Essential WhatsApp files are missing from the iOS backup. "
|
||||||
"Perhapse you enabled end-to-end encryption for the backup? "
|
"Perhapse you enabled end-to-end encryption for the backup? "
|
||||||
"See https://wts.knugi.dev/docs.html?dest=iose2e"
|
"See https://wts.knugi.dev/docs.html?dest=iose2e"
|
||||||
@@ -157,12 +173,12 @@ class BackupExtractor:
|
|||||||
shutil.copyfile(wts_db_path, self.identifiers.MESSAGE)
|
shutil.copyfile(wts_db_path, self.identifiers.MESSAGE)
|
||||||
|
|
||||||
if not os.path.isfile(contact_db_path):
|
if not os.path.isfile(contact_db_path):
|
||||||
print("Contact database not found. Skipping...")
|
logger.warning(f"Contact database not found. Skipping...{CLEAR_LINE}")
|
||||||
else:
|
else:
|
||||||
shutil.copyfile(contact_db_path, self.identifiers.CONTACT)
|
shutil.copyfile(contact_db_path, self.identifiers.CONTACT)
|
||||||
|
|
||||||
if not os.path.isfile(call_db_path):
|
if not os.path.isfile(call_db_path):
|
||||||
print("Call database not found. Skipping...")
|
logger.warning(f"Call database not found. Skipping...{CLEAR_LINE}")
|
||||||
else:
|
else:
|
||||||
shutil.copyfile(call_db_path, self.identifiers.CALL)
|
shutil.copyfile(call_db_path, self.identifiers.CALL)
|
||||||
|
|
||||||
@@ -176,7 +192,7 @@ class BackupExtractor:
|
|||||||
c = manifest.cursor()
|
c = manifest.cursor()
|
||||||
c.execute(f"SELECT count() FROM Files WHERE domain = '{_wts_id}'")
|
c.execute(f"SELECT count() FROM Files WHERE domain = '{_wts_id}'")
|
||||||
total_row_number = c.fetchone()[0]
|
total_row_number = c.fetchone()[0]
|
||||||
print(f"Extracting WhatsApp files...(0/{total_row_number})", end="\r")
|
logger.info(f"Extracting WhatsApp files...(0/{total_row_number})\r")
|
||||||
c.execute(
|
c.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT fileID, relativePath, flags, file AS metadata,
|
SELECT fileID, relativePath, flags, file AS metadata,
|
||||||
@@ -213,9 +229,9 @@ class BackupExtractor:
|
|||||||
os.utime(destination, (modification, modification))
|
os.utime(destination, (modification, modification))
|
||||||
|
|
||||||
if row["_index"] % 100 == 0:
|
if row["_index"] % 100 == 0:
|
||||||
print(f"Extracting WhatsApp files...({row['_index']}/{total_row_number})", end="\r")
|
logger.info(f"Extracting WhatsApp files...({row['_index']}/{total_row_number})\r")
|
||||||
row = c.fetchone()
|
row = c.fetchone()
|
||||||
print(f"Extracting WhatsApp files...({total_row_number}/{total_row_number})", end="\n")
|
logger.info(f"Extracted WhatsApp files...({total_row_number}){CLEAR_LINE}")
|
||||||
|
|
||||||
|
|
||||||
def extract_media(base_dir, identifiers, decrypt_chunk_size):
|
def extract_media(base_dir, identifiers, decrypt_chunk_size):
|
||||||
@@ -229,4 +245,3 @@ def extract_media(base_dir, identifiers, decrypt_chunk_size):
|
|||||||
"""
|
"""
|
||||||
extractor = BackupExtractor(base_dir, identifiers, decrypt_chunk_size)
|
extractor = BackupExtractor(base_dir, identifiers, decrypt_chunk_size)
|
||||||
extractor.extract()
|
extractor.extract()
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,26 @@
|
|||||||
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import jinja2
|
import jinja2
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import re
|
import re
|
||||||
|
import string
|
||||||
import math
|
import math
|
||||||
|
import shutil
|
||||||
from bleach import clean as sanitize
|
from bleach import clean as sanitize
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from Whatsapp_Chat_Exporter.data_model import ChatStore
|
from Whatsapp_Chat_Exporter.data_model import ChatCollection, ChatStore, Timing
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple, Union
|
||||||
try:
|
try:
|
||||||
from enum import StrEnum, IntEnum
|
from enum import StrEnum, IntEnum
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# < Python 3.11
|
# < Python 3.11
|
||||||
# This should be removed when the support for Python 3.10 ends.
|
# This should be removed when the support for Python 3.10 ends. (31 Oct 2026)
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
class StrEnum(str, Enum):
|
class StrEnum(str, Enum):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -26,6 +30,9 @@ except ImportError:
|
|||||||
MAX_SIZE = 4 * 1024 * 1024 # Default 4MB
|
MAX_SIZE = 4 * 1024 * 1024 # Default 4MB
|
||||||
ROW_SIZE = 0x3D0
|
ROW_SIZE = 0x3D0
|
||||||
CURRENT_TZ_OFFSET = datetime.now().astimezone().utcoffset().seconds / 3600
|
CURRENT_TZ_OFFSET = datetime.now().astimezone().utcoffset().seconds / 3600
|
||||||
|
CLEAR_LINE = "\x1b[K\n"
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def convert_time_unit(time_second: int) -> str:
|
def convert_time_unit(time_second: int) -> str:
|
||||||
@@ -37,23 +44,31 @@ def convert_time_unit(time_second: int) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
str: A human-readable string representing the time duration.
|
str: A human-readable string representing the time duration.
|
||||||
"""
|
"""
|
||||||
time = str(timedelta(seconds=time_second))
|
|
||||||
if "day" not in time:
|
|
||||||
if time_second < 1:
|
if time_second < 1:
|
||||||
time = "less than a second"
|
return "less than a second"
|
||||||
elif time_second == 1:
|
elif time_second == 1:
|
||||||
time = "a second"
|
return "a second"
|
||||||
elif time_second < 60:
|
|
||||||
time = time[5:][1 if time_second < 10 else 0:] + " seconds"
|
delta = timedelta(seconds=time_second)
|
||||||
elif time_second == 60:
|
parts = []
|
||||||
time = "a minute"
|
|
||||||
elif time_second < 3600:
|
days = delta.days
|
||||||
time = time[2:] + " minutes"
|
if days > 0:
|
||||||
elif time_second == 3600:
|
parts.append(f"{days} day{'s' if days > 1 else ''}")
|
||||||
time = "an hour"
|
|
||||||
else:
|
hours = delta.seconds // 3600
|
||||||
time += " hour"
|
if hours > 0:
|
||||||
return time
|
parts.append(f"{hours} hour{'s' if hours > 1 else ''}")
|
||||||
|
|
||||||
|
minutes = (delta.seconds % 3600) // 60
|
||||||
|
if minutes > 0:
|
||||||
|
parts.append(f"{minutes} minute{'s' if minutes > 1 else ''}")
|
||||||
|
|
||||||
|
seconds = delta.seconds % 60
|
||||||
|
if seconds > 0:
|
||||||
|
parts.append(f"{seconds} second{'s' if seconds > 1 else ''}")
|
||||||
|
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def bytes_to_readable(size_bytes: int) -> str:
|
def bytes_to_readable(size_bytes: int) -> str:
|
||||||
@@ -70,8 +85,8 @@ def bytes_to_readable(size_bytes: int) -> str:
|
|||||||
Returns:
|
Returns:
|
||||||
A human-readable string representing the file size.
|
A human-readable string representing the file size.
|
||||||
"""
|
"""
|
||||||
if size_bytes == 0:
|
if size_bytes < 1024:
|
||||||
return "0B"
|
return f"{size_bytes} B"
|
||||||
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
||||||
i = int(math.floor(math.log(size_bytes, 1024)))
|
i = int(math.floor(math.log(size_bytes, 1024)))
|
||||||
p = math.pow(1024, i)
|
p = math.pow(1024, i)
|
||||||
@@ -103,10 +118,15 @@ def readable_to_bytes(size_str: str) -> int:
|
|||||||
'YB': 1024**8
|
'YB': 1024**8
|
||||||
}
|
}
|
||||||
size_str = size_str.upper().strip()
|
size_str = size_str.upper().strip()
|
||||||
number, unit = size_str[:-2].strip(), size_str[-2:].strip()
|
if size_str.isnumeric():
|
||||||
if unit not in SIZE_UNITS or not number.isnumeric():
|
# If the string is purely numeric, assume it's in bytes
|
||||||
raise ValueError("Invalid input for size_str. Example: 1024GB")
|
return int(size_str)
|
||||||
return int(number) * SIZE_UNITS[unit]
|
match = re.fullmatch(r'^(\d+(\.\d*)?)\s*([KMGTPEZY]?B)?$', size_str)
|
||||||
|
if not match:
|
||||||
|
raise ValueError("Invalid size format for size_str. Expected format like '10MB', '1024GB', or '512'.")
|
||||||
|
unit = ''.join(filter(str.isalpha, size_str)).strip()
|
||||||
|
number = ''.join(c for c in size_str if c.isdigit() or c == '.').strip()
|
||||||
|
return int(float(number) * SIZE_UNITS[unit])
|
||||||
|
|
||||||
|
|
||||||
def sanitize_except(html: str) -> Markup:
|
def sanitize_except(html: str) -> Markup:
|
||||||
@@ -149,26 +169,29 @@ def check_update():
|
|||||||
try:
|
try:
|
||||||
raw = urllib.request.urlopen(PACKAGE_JSON)
|
raw = urllib.request.urlopen(PACKAGE_JSON)
|
||||||
except Exception:
|
except Exception:
|
||||||
print("Failed to check for updates.")
|
logger.error("Failed to check for updates.")
|
||||||
return 1
|
return 1
|
||||||
else:
|
else:
|
||||||
with raw:
|
with raw:
|
||||||
package_info = json.load(raw)
|
package_info = json.load(raw)
|
||||||
latest_version = tuple(map(int, package_info["info"]["version"].split(".")))
|
latest_version = tuple(
|
||||||
|
map(int, package_info["info"]["version"].split(".")))
|
||||||
__version__ = importlib.metadata.version("whatsapp_chat_exporter")
|
__version__ = importlib.metadata.version("whatsapp_chat_exporter")
|
||||||
current_version = tuple(map(int, __version__.split(".")))
|
current_version = tuple(map(int, __version__.split(".")))
|
||||||
if current_version < latest_version:
|
if current_version < latest_version:
|
||||||
print("===============Update===============")
|
logger.info(
|
||||||
print("A newer version of WhatsApp Chat Exporter is available.")
|
"===============Update===============\n"
|
||||||
print("Current version: " + __version__)
|
"A newer version of WhatsApp Chat Exporter is available.\n"
|
||||||
print("Latest version: " + package_info["info"]["version"])
|
f"Current version: {__version__}\n"
|
||||||
|
f"Latest version: {package_info['info']['version']}\n"
|
||||||
|
)
|
||||||
if platform == "win32":
|
if platform == "win32":
|
||||||
print("Update with: pip install --upgrade whatsapp-chat-exporter")
|
logger.info("Update with: pip install --upgrade whatsapp-chat-exporter\n")
|
||||||
else:
|
else:
|
||||||
print("Update with: pip3 install --upgrade whatsapp-chat-exporter")
|
logger.info("Update with: pip3 install --upgrade whatsapp-chat-exporter\n")
|
||||||
print("====================================")
|
logger.info("====================================\n")
|
||||||
else:
|
else:
|
||||||
print("You are using the latest version of WhatsApp Chat Exporter.")
|
logger.info("You are using the latest version of WhatsApp Chat Exporter.\n")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -183,7 +206,7 @@ def rendering(
|
|||||||
headline,
|
headline,
|
||||||
next=False,
|
next=False,
|
||||||
previous=False
|
previous=False
|
||||||
):
|
):
|
||||||
if chat.their_avatar_thumb is None and chat.their_avatar is not None:
|
if chat.their_avatar_thumb is None and chat.their_avatar is not None:
|
||||||
their_avatar_thumb = chat.their_avatar
|
their_avatar_thumb = chat.their_avatar
|
||||||
else:
|
else:
|
||||||
@@ -215,59 +238,104 @@ class Device(StrEnum):
|
|||||||
EXPORTED = "exported"
|
EXPORTED = "exported"
|
||||||
|
|
||||||
|
|
||||||
def import_from_json(json_file: str, data: Dict[str, ChatStore]):
|
def import_from_json(json_file: str, data: ChatCollection):
|
||||||
"""Imports chat data from a JSON file into the data dictionary.
|
"""Imports chat data from a JSON file into the data dictionary.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
json_file: The path to the JSON file.
|
json_file: The path to the JSON file.
|
||||||
data: The dictionary to store the imported chat data.
|
data: The dictionary to store the imported chat data.
|
||||||
"""
|
"""
|
||||||
from Whatsapp_Chat_Exporter.data_model import ChatStore, Message
|
|
||||||
with open(json_file, "r") as f:
|
with open(json_file, "r") as f:
|
||||||
temp_data = json.loads(f.read())
|
temp_data = json.loads(f.read())
|
||||||
total_row_number = len(tuple(temp_data.keys()))
|
total_row_number = len(tuple(temp_data.keys()))
|
||||||
print(f"Importing chats from JSON...(0/{total_row_number})", end="\r")
|
logger.info(f"Importing chats from JSON...(0/{total_row_number})\r")
|
||||||
for index, (jid, chat_data) in enumerate(temp_data.items()):
|
for index, (jid, chat_data) in enumerate(temp_data.items()):
|
||||||
chat = ChatStore(chat_data.get("type"), chat_data.get("name"))
|
chat = ChatStore.from_json(chat_data)
|
||||||
chat.my_avatar = chat_data.get("my_avatar")
|
data.add_chat(jid, chat)
|
||||||
chat.their_avatar = chat_data.get("their_avatar")
|
logger.info(
|
||||||
chat.their_avatar_thumb = chat_data.get("their_avatar_thumb")
|
f"Importing chats from JSON...({index + 1}/{total_row_number})\r")
|
||||||
chat.status = chat_data.get("status")
|
logger.info(f"Imported {total_row_number} chats from JSON{CLEAR_LINE}")
|
||||||
for id, msg in chat_data.get("messages").items():
|
|
||||||
message = Message(
|
|
||||||
from_me=msg["from_me"],
|
|
||||||
timestamp=msg["timestamp"],
|
|
||||||
time=msg["time"],
|
|
||||||
key_id=msg["key_id"],
|
|
||||||
received_timestamp=msg.get("received_timestamp"),
|
|
||||||
read_timestamp=msg.get("read_timestamp")
|
|
||||||
)
|
|
||||||
message.media = msg.get("media")
|
|
||||||
message.meta = msg.get("meta")
|
|
||||||
message.data = msg.get("data")
|
|
||||||
message.sender = msg.get("sender")
|
|
||||||
message.safe = msg.get("safe")
|
|
||||||
message.mime = msg.get("mime")
|
|
||||||
message.reply = msg.get("reply")
|
|
||||||
message.quoted_data = msg.get("quoted_data")
|
|
||||||
message.caption = msg.get("caption")
|
|
||||||
message.thumb = msg.get("thumb")
|
|
||||||
message.sticker = msg.get("sticker")
|
|
||||||
chat.add_message(id, message)
|
|
||||||
data[jid] = chat
|
|
||||||
print(f"Importing chats from JSON...({index + 1}/{total_row_number})", end="\r")
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_filename(file_name: str) -> str:
|
def incremental_merge(source_dir: str, target_dir: str, media_dir: str, pretty_print_json: int, avoid_encoding_json: bool):
|
||||||
"""Sanitizes a filename by removing invalid and unsafe characters.
|
"""Merges JSON files from the source directory into the target directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_name: The filename to sanitize.
|
source_dir (str): The path to the source directory containing JSON files.
|
||||||
|
target_dir (str): The path to the target directory to merge into.
|
||||||
Returns:
|
media_dir (str): The path to the media directory.
|
||||||
The sanitized filename.
|
|
||||||
"""
|
"""
|
||||||
return "".join(x for x in file_name if x.isalnum() or x in "- ")
|
json_files = [f for f in os.listdir(source_dir) if f.endswith('.json')]
|
||||||
|
if not json_files:
|
||||||
|
logger.error("No JSON files found in the source directory.")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("JSON files found:", json_files)
|
||||||
|
|
||||||
|
for json_file in json_files:
|
||||||
|
source_path = os.path.join(source_dir, json_file)
|
||||||
|
target_path = os.path.join(target_dir, json_file)
|
||||||
|
|
||||||
|
if not os.path.exists(target_path):
|
||||||
|
logger.info(f"Copying '{json_file}' to target directory...")
|
||||||
|
os.makedirs(target_dir, exist_ok=True)
|
||||||
|
shutil.copy2(source_path, target_path)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Merging '{json_file}' with existing file in target directory...")
|
||||||
|
with open(source_path, 'r') as src_file, open(target_path, 'r') as tgt_file:
|
||||||
|
source_data = json.load(src_file)
|
||||||
|
target_data = json.load(tgt_file)
|
||||||
|
|
||||||
|
# Parse JSON into ChatStore objects using from_json()
|
||||||
|
source_chats = {jid: ChatStore.from_json(
|
||||||
|
chat) for jid, chat in source_data.items()}
|
||||||
|
target_chats = {jid: ChatStore.from_json(
|
||||||
|
chat) for jid, chat in target_data.items()}
|
||||||
|
|
||||||
|
# Merge chats using merge_with()
|
||||||
|
for jid, chat in source_chats.items():
|
||||||
|
if jid in target_chats:
|
||||||
|
target_chats[jid].merge_with(chat)
|
||||||
|
else:
|
||||||
|
target_chats[jid] = chat
|
||||||
|
|
||||||
|
# Serialize merged data
|
||||||
|
merged_data = {jid: chat.to_json()
|
||||||
|
for jid, chat in target_chats.items()}
|
||||||
|
|
||||||
|
# Check if the merged data differs from the original target data
|
||||||
|
if json.dumps(merged_data, sort_keys=True) != json.dumps(target_data, sort_keys=True):
|
||||||
|
logger.info(
|
||||||
|
f"Changes detected in '{json_file}', updating target file...")
|
||||||
|
with open(target_path, 'w') as merged_file:
|
||||||
|
json.dump(
|
||||||
|
merged_data,
|
||||||
|
merged_file,
|
||||||
|
indent=pretty_print_json,
|
||||||
|
ensure_ascii=not avoid_encoding_json,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"No changes detected in '{json_file}', skipping update.")
|
||||||
|
|
||||||
|
# Merge media directories
|
||||||
|
source_media_path = os.path.join(source_dir, media_dir)
|
||||||
|
target_media_path = os.path.join(target_dir, media_dir)
|
||||||
|
logger.info(
|
||||||
|
f"Merging media directories. Source: {source_media_path}, target: {target_media_path}")
|
||||||
|
if os.path.exists(source_media_path):
|
||||||
|
for root, _, files in os.walk(source_media_path):
|
||||||
|
relative_path = os.path.relpath(root, source_media_path)
|
||||||
|
target_root = os.path.join(target_media_path, relative_path)
|
||||||
|
os.makedirs(target_root, exist_ok=True)
|
||||||
|
for file in files:
|
||||||
|
source_file = os.path.join(root, file)
|
||||||
|
target_file = os.path.join(target_root, file)
|
||||||
|
# we only copy if the file doesn't exist in the target or if the source is newer
|
||||||
|
if not os.path.exists(target_file) or os.path.getmtime(source_file) > os.path.getmtime(target_file):
|
||||||
|
logger.info(f"Copying '{source_file}' to '{target_file}'...")
|
||||||
|
shutil.copy2(source_file, target_file)
|
||||||
|
|
||||||
|
|
||||||
def get_file_name(contact: str, chat: ChatStore) -> Tuple[str, str]:
|
def get_file_name(contact: str, chat: ChatStore) -> Tuple[str, str]:
|
||||||
@@ -299,7 +367,7 @@ def get_file_name(contact: str, chat: ChatStore) -> Tuple[str, str]:
|
|||||||
else:
|
else:
|
||||||
name = phone_number
|
name = phone_number
|
||||||
|
|
||||||
return sanitize_filename(file_name), name
|
return safe_name(file_name), name
|
||||||
|
|
||||||
|
|
||||||
def get_cond_for_empty(enable: bool, jid_field: str, broadcast_field: str) -> str:
|
def get_cond_for_empty(enable: bool, jid_field: str, broadcast_field: str) -> str:
|
||||||
@@ -335,23 +403,29 @@ def get_chat_condition(filter: Optional[List[str]], include: bool, columns: List
|
|||||||
if filter is not None:
|
if filter is not None:
|
||||||
conditions = []
|
conditions = []
|
||||||
if len(columns) < 2 and jid is not None:
|
if len(columns) < 2 and jid is not None:
|
||||||
raise ValueError("There must be at least two elements in argument columns if jid is not None")
|
raise ValueError(
|
||||||
|
"There must be at least two elements in argument columns if jid is not None")
|
||||||
if jid is not None:
|
if jid is not None:
|
||||||
if platform == "android":
|
if platform == "android":
|
||||||
is_group = f"{jid}.type == 1"
|
is_group = f"{jid}.type == 1"
|
||||||
elif platform == "ios":
|
elif platform == "ios":
|
||||||
is_group = f"{jid} IS NOT NULL"
|
is_group = f"{jid} IS NOT NULL"
|
||||||
else:
|
else:
|
||||||
raise ValueError("Only android and ios are supported for argument platform if jid is not None")
|
raise ValueError(
|
||||||
|
"Only android and ios are supported for argument platform if jid is not None")
|
||||||
for index, chat in enumerate(filter):
|
for index, chat in enumerate(filter):
|
||||||
if include:
|
if include:
|
||||||
conditions.append(f"{' OR' if index > 0 else ''} {columns[0]} LIKE '%{chat}%'")
|
conditions.append(
|
||||||
|
f"{' OR' if index > 0 else ''} {columns[0]} LIKE '%{chat}%'")
|
||||||
if len(columns) > 1:
|
if len(columns) > 1:
|
||||||
conditions.append(f" OR ({columns[1]} LIKE '%{chat}%' AND {is_group})")
|
conditions.append(
|
||||||
|
f" OR ({columns[1]} LIKE '%{chat}%' AND {is_group})")
|
||||||
else:
|
else:
|
||||||
conditions.append(f"{' AND' if index > 0 else ''} {columns[0]} NOT LIKE '%{chat}%'")
|
conditions.append(
|
||||||
|
f"{' AND' if index > 0 else ''} {columns[0]} NOT LIKE '%{chat}%'")
|
||||||
if len(columns) > 1:
|
if len(columns) > 1:
|
||||||
conditions.append(f" AND ({columns[1]} NOT LIKE '%{chat}%' AND {is_group})")
|
conditions.append(
|
||||||
|
f" AND ({columns[1]} NOT LIKE '%{chat}%' AND {is_group})")
|
||||||
return f"AND ({' '.join(conditions)})"
|
return f"AND ({' '.join(conditions)})"
|
||||||
else:
|
else:
|
||||||
return ""
|
return ""
|
||||||
@@ -464,7 +538,8 @@ def determine_metadata(content: sqlite3.Row, init_msg: Optional[str]) -> Optiona
|
|||||||
elif content["action_type"] == 67:
|
elif content["action_type"] == 67:
|
||||||
return # (PM) this contact use secure service from Facebook???
|
return # (PM) this contact use secure service from Facebook???
|
||||||
elif content["action_type"] == 69:
|
elif content["action_type"] == 69:
|
||||||
return # (PM) this contact use secure service from Facebook??? What's the difference with 67????
|
# (PM) this contact use secure service from Facebook??? What's the difference with 67????
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
return # Unsupported
|
return # Unsupported
|
||||||
return msg
|
return msg
|
||||||
@@ -491,8 +566,10 @@ def get_status_location(output_folder: str, offline_static: str) -> str:
|
|||||||
w3css_path = os.path.join(static_folder, "w3.css")
|
w3css_path = os.path.join(static_folder, "w3.css")
|
||||||
if not os.path.isfile(w3css_path):
|
if not os.path.isfile(w3css_path):
|
||||||
with urllib.request.urlopen(w3css) as resp:
|
with urllib.request.urlopen(w3css) as resp:
|
||||||
with open(w3css_path, "wb") as f: f.write(resp.read())
|
with open(w3css_path, "wb") as f:
|
||||||
|
f.write(resp.read())
|
||||||
w3css = os.path.join(offline_static, "w3.css")
|
w3css = os.path.join(offline_static, "w3.css")
|
||||||
|
return w3css
|
||||||
|
|
||||||
|
|
||||||
def setup_template(template: Optional[str], no_avatar: bool, experimental: bool = False) -> jinja2.Template:
|
def setup_template(template: Optional[str], no_avatar: bool, experimental: bool = False) -> jinja2.Template:
|
||||||
@@ -522,44 +599,131 @@ def setup_template(template: Optional[str], no_avatar: bool, experimental: bool
|
|||||||
template_env.filters['sanitize_except'] = sanitize_except
|
template_env.filters['sanitize_except'] = sanitize_except
|
||||||
return template_env.get_template(template_file)
|
return template_env.get_template(template_file)
|
||||||
|
|
||||||
|
|
||||||
# iOS Specific
|
# iOS Specific
|
||||||
APPLE_TIME = 978307200
|
APPLE_TIME = 978307200
|
||||||
|
|
||||||
|
|
||||||
def slugify(value: str, allow_unicode: bool = False) -> str:
|
def safe_name(text: Union[str, bytes]) -> str:
|
||||||
"""
|
"""
|
||||||
Convert text to ASCII-only slugs for URL-safe strings.
|
Sanitize the input text and generates a safe file name.
|
||||||
Taken from https://github.com/django/django/blob/master/django/utils/text.py
|
This function serves a similar purpose to slugify() from
|
||||||
|
Django previously used in this project, but is a clean-room
|
||||||
|
Reimplementation tailored for performance and a narrower
|
||||||
|
Use case for this project. Licensed under the same terms
|
||||||
|
As the project (MIT).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value (str): The string to convert to a slug.
|
text (str|bytes): The string to be sanitized.
|
||||||
allow_unicode (bool, optional): Whether to allow Unicode characters. Defaults to False.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The slugified string with only alphanumerics, underscores, or hyphens.
|
str: The sanitized string with only alphanumerics, underscores, or hyphens.
|
||||||
"""
|
"""
|
||||||
value = str(value)
|
if isinstance(text, bytes):
|
||||||
if allow_unicode:
|
text = text.decode("utf-8", "ignore")
|
||||||
value = unicodedata.normalize('NFKC', value)
|
elif not isinstance(text, str):
|
||||||
else:
|
raise TypeError("value must be a string or bytes")
|
||||||
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
|
normalized_text = unicodedata.normalize("NFKC", text)
|
||||||
value = re.sub(r'[^\w\s-]', '', value.lower())
|
safe_chars = [char for char in normalized_text if char.isalnum() or char in "-_ ."]
|
||||||
return re.sub(r'[-\s]+', '-', value).strip('-_')
|
return "-".join(''.join(safe_chars).split())
|
||||||
|
|
||||||
|
|
||||||
|
def get_from_string(msg: Dict, chat_id: str) -> str:
|
||||||
|
"""Return the number or name for the sender"""
|
||||||
|
if msg["from_me"]:
|
||||||
|
return "Me"
|
||||||
|
if msg["sender"]:
|
||||||
|
return str(msg["sender"])
|
||||||
|
return str(chat_id)
|
||||||
|
|
||||||
|
|
||||||
|
def get_chat_type(chat_id: str) -> str:
|
||||||
|
"""Return the chat type based on the whatsapp id"""
|
||||||
|
if chat_id.endswith("@s.whatsapp.net"):
|
||||||
|
return "personal_chat"
|
||||||
|
if chat_id.endswith("@g.us"):
|
||||||
|
return "private_group"
|
||||||
|
logger.warning("Unknown chat type for %s, defaulting to private_group", chat_id)
|
||||||
|
return "private_group"
|
||||||
|
|
||||||
|
|
||||||
|
def get_from_id(msg: Dict, chat_id: str) -> str:
|
||||||
|
"""Return the user id for the sender"""
|
||||||
|
if msg["from_me"]:
|
||||||
|
return "user00000"
|
||||||
|
if msg["sender"]:
|
||||||
|
return "user" + msg["sender"]
|
||||||
|
return f"user{chat_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_reply_id(data: Dict, reply_key: int) -> Optional[int]:
|
||||||
|
"""Get the id of the message corresponding to the reply"""
|
||||||
|
if not reply_key:
|
||||||
|
return None
|
||||||
|
for msg_id, msg in data["messages"].items():
|
||||||
|
if msg["key_id"] == reply_key:
|
||||||
|
return msg_id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def telegram_json_format(jik: str, data: Dict, timezone_offset) -> Dict:
|
||||||
|
"""Convert the data to the Telegram export format"""
|
||||||
|
timing = Timing(timezone_offset or CURRENT_TZ_OFFSET)
|
||||||
|
try:
|
||||||
|
chat_id = int(''.join([c for c in jik if c.isdigit()]))
|
||||||
|
except ValueError:
|
||||||
|
# not a real chat: e.g. statusbroadcast
|
||||||
|
chat_id = 0
|
||||||
|
obj = {
|
||||||
|
"name": data["name"] if data["name"] else jik,
|
||||||
|
"type": get_chat_type(jik),
|
||||||
|
"id": chat_id,
|
||||||
|
"messages": [ {
|
||||||
|
"id": int(msgId),
|
||||||
|
"type": "message",
|
||||||
|
"date": timing.format_timestamp(msg["timestamp"], "%Y-%m-%dT%H:%M:%S"),
|
||||||
|
"date_unixtime": int(msg["timestamp"]),
|
||||||
|
"from": get_from_string(msg, chat_id),
|
||||||
|
"from_id": get_from_id(msg, chat_id),
|
||||||
|
"reply_to_message_id": get_reply_id(data, msg["reply"]),
|
||||||
|
"text": msg["data"],
|
||||||
|
"text_entities": [
|
||||||
|
{
|
||||||
|
# TODO this will lose formatting and different types
|
||||||
|
"type": "plain",
|
||||||
|
"text": msg["data"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
} for msgId, msg in data["messages"].items()]
|
||||||
|
}
|
||||||
|
# remove empty messages and replies
|
||||||
|
for msg_id, msg in enumerate(obj["messages"]):
|
||||||
|
if not msg["reply_to_message_id"]:
|
||||||
|
del obj["messages"][msg_id]["reply_to_message_id"]
|
||||||
|
obj["messages"] = [m for m in obj["messages"] if m["text"]]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
class WhatsAppIdentifier(StrEnum):
|
class WhatsAppIdentifier(StrEnum):
|
||||||
MESSAGE = "7c7fba66680ef796b916b067077cc246adacf01d" # AppDomainGroup-group.net.whatsapp.WhatsApp.shared-ChatStorage.sqlite
|
# AppDomainGroup-group.net.whatsapp.WhatsApp.shared-ChatStorage.sqlite
|
||||||
CONTACT = "b8548dc30aa1030df0ce18ef08b882cf7ab5212f" # AppDomainGroup-group.net.whatsapp.WhatsApp.shared-ContactsV2.sqlite
|
MESSAGE = "7c7fba66680ef796b916b067077cc246adacf01d"
|
||||||
CALL = "1b432994e958845fffe8e2f190f26d1511534088" # AppDomainGroup-group.net.whatsapp.WhatsApp.shared-CallHistory.sqlite
|
# AppDomainGroup-group.net.whatsapp.WhatsApp.shared-ContactsV2.sqlite
|
||||||
|
CONTACT = "b8548dc30aa1030df0ce18ef08b882cf7ab5212f"
|
||||||
|
# AppDomainGroup-group.net.whatsapp.WhatsApp.shared-CallHistory.sqlite
|
||||||
|
CALL = "1b432994e958845fffe8e2f190f26d1511534088"
|
||||||
DOMAIN = "AppDomainGroup-group.net.whatsapp.WhatsApp.shared"
|
DOMAIN = "AppDomainGroup-group.net.whatsapp.WhatsApp.shared"
|
||||||
|
|
||||||
|
|
||||||
class WhatsAppBusinessIdentifier(StrEnum):
|
class WhatsAppBusinessIdentifier(StrEnum):
|
||||||
MESSAGE = "724bd3b98b18518b455a87c1f3ac3a0d189c4466" # AppDomainGroup-group.net.whatsapp.WhatsAppSMB.shared-ChatStorage.sqlite
|
# AppDomainGroup-group.net.whatsapp.WhatsAppSMB.shared-ChatStorage.sqlite
|
||||||
CONTACT = "d7246a707f51ddf8b17ee2dddabd9e0a4da5c552" # AppDomainGroup-group.net.whatsapp.WhatsAppSMB.shared-ContactsV2.sqlite
|
MESSAGE = "724bd3b98b18518b455a87c1f3ac3a0d189c4466"
|
||||||
CALL = "b463f7c4365eefc5a8723930d97928d4e907c603" # AppDomainGroup-group.net.whatsapp.WhatsAppSMB.shared-CallHistory.sqlite
|
# AppDomainGroup-group.net.whatsapp.WhatsAppSMB.shared-ContactsV2.sqlite
|
||||||
|
CONTACT = "d7246a707f51ddf8b17ee2dddabd9e0a4da5c552"
|
||||||
|
# AppDomainGroup-group.net.whatsapp.WhatsAppSMB.shared-CallHistory.sqlite
|
||||||
|
CALL = "b463f7c4365eefc5a8723930d97928d4e907c603"
|
||||||
DOMAIN = "AppDomainGroup-group.net.whatsapp.WhatsAppSMB.shared"
|
DOMAIN = "AppDomainGroup-group.net.whatsapp.WhatsAppSMB.shared"
|
||||||
|
|
||||||
|
|
||||||
class JidType(IntEnum):
|
class JidType(IntEnum):
|
||||||
PM = 0
|
PM = 0
|
||||||
GROUP = 1
|
GROUP = 1
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import vobject
|
import logging
|
||||||
|
import re
|
||||||
|
import quopri
|
||||||
from typing import List, TypedDict
|
from typing import List, TypedDict
|
||||||
|
from Whatsapp_Chat_Exporter.data_model import ChatStore
|
||||||
|
from Whatsapp_Chat_Exporter.utility import CLEAR_LINE, Device
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ExportedContactNumbers(TypedDict):
|
class ExportedContactNumbers(TypedDict):
|
||||||
@@ -22,31 +29,154 @@ class ContactsFromVCards:
|
|||||||
# short number must be a bad contact, lets skip it
|
# short number must be a bad contact, lets skip it
|
||||||
if len(number) <= 5:
|
if len(number) <= 5:
|
||||||
continue
|
continue
|
||||||
|
chats_search = filter_chats_by_prefix(chats, number).values()
|
||||||
for chat in filter_chats_by_prefix(chats, number).values():
|
if chats_search:
|
||||||
|
for chat in chats_search:
|
||||||
if not hasattr(chat, 'name') or (hasattr(chat, 'name') and chat.name is None):
|
if not hasattr(chat, 'name') or (hasattr(chat, 'name') and chat.name is None):
|
||||||
setattr(chat, 'name', name)
|
setattr(chat, 'name', name)
|
||||||
|
else:
|
||||||
|
chats.add_chat(number + "@s.whatsapp.net", ChatStore(Device.ANDROID, name))
|
||||||
|
|
||||||
|
|
||||||
|
def decode_quoted_printable(value: str, charset: str) -> str:
|
||||||
|
"""Decode a vCard value that may be quoted-printable UTF-8."""
|
||||||
|
try:
|
||||||
|
bytes_val = quopri.decodestring(value)
|
||||||
|
return bytes_val.decode(charset, errors="replace")
|
||||||
|
except Exception:
|
||||||
|
# Fallback: return the original value if decoding fails
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to decode quoted-printable value: {value}, "
|
||||||
|
f"charset: {charset}. Please report this issue.{CLEAR_LINE}"
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _parse_vcard_line(line: str) -> tuple[str, dict[str, str], str] | None:
|
||||||
|
"""
|
||||||
|
Parses a single vCard property line into its components:
|
||||||
|
Property Name, Parameters (as a dict), and Value.
|
||||||
|
|
||||||
|
Example: 'FN;CHARSET=UTF-8:John Doe' -> ('FN', {'CHARSET': 'UTF-8'}, 'John Doe')
|
||||||
|
"""
|
||||||
|
# Find the first colon, which separates the property/parameters from the value.
|
||||||
|
colon_index = line.find(':')
|
||||||
|
if colon_index == -1:
|
||||||
|
return None # Invalid vCard line format
|
||||||
|
|
||||||
|
prop_and_params = line[:colon_index].strip()
|
||||||
|
value = line[colon_index + 1:].strip()
|
||||||
|
|
||||||
|
# Split property name from parameters
|
||||||
|
parts = prop_and_params.split(';')
|
||||||
|
property_name = parts[0].upper()
|
||||||
|
|
||||||
|
parameters = {}
|
||||||
|
for part in parts[1:]:
|
||||||
|
if '=' in part:
|
||||||
|
key, val = part.split('=', 1)
|
||||||
|
parameters[key.upper()] = val.strip('"') # Remove potential quotes from value
|
||||||
|
|
||||||
|
return property_name, parameters, value
|
||||||
|
|
||||||
|
|
||||||
|
def get_vcard_value(entry: str, field_name: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Scans the vCard entry for lines starting with the specific field_name
|
||||||
|
and returns a list of its decoded values, handling parameters like
|
||||||
|
ENCODING and CHARSET.
|
||||||
|
"""
|
||||||
|
target_name = field_name.upper()
|
||||||
|
cached_line = ""
|
||||||
|
charset = "utf-8"
|
||||||
|
values = []
|
||||||
|
|
||||||
|
for line in entry.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if cached_line:
|
||||||
|
if line.endswith('='):
|
||||||
|
cached_line += line[:-1]
|
||||||
|
continue # Wait for the next line to complete the value
|
||||||
|
values.append(decode_quoted_printable(cached_line + line, charset))
|
||||||
|
cached_line = ""
|
||||||
|
else:
|
||||||
|
# Skip empty lines or lines that don't start with the target field (after stripping)
|
||||||
|
if not line or not line.upper().startswith(target_name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
parsed = _parse_vcard_line(line)
|
||||||
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
prop_name, params, raw_value = parsed
|
||||||
|
|
||||||
|
if prop_name != target_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
encoding = params.get('ENCODING')
|
||||||
|
charset = params.get('CHARSET', 'utf-8')
|
||||||
|
|
||||||
|
# Apply decoding if ENCODING parameter is present
|
||||||
|
if encoding == 'QUOTED-PRINTABLE':
|
||||||
|
if raw_value.endswith('='):
|
||||||
|
# Handle soft line breaks in quoted-printable and cache the line
|
||||||
|
cached_line += raw_value[:-1]
|
||||||
|
continue # Wait for the next line to complete the value
|
||||||
|
values.append(decode_quoted_printable(raw_value, charset))
|
||||||
|
elif encoding:
|
||||||
|
raise NotImplementedError(f"Encoding '{encoding}' not supported yet.")
|
||||||
|
else:
|
||||||
|
values.append(raw_value)
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def process_vcard_entry(entry: str) -> dict | bool:
|
||||||
|
"""
|
||||||
|
Process a vCard entry using pure string manipulation
|
||||||
|
|
||||||
|
Args:
|
||||||
|
entry: A string containing a single vCard block.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary of the extracted data or False if required fields are missing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = None
|
||||||
|
|
||||||
|
# Extract name in priority: FN -> N -> ORG
|
||||||
|
for field in ("FN", "N", "ORG"):
|
||||||
|
if name_values := get_vcard_value(entry, field):
|
||||||
|
name = name_values[0].replace(';', ' ') # Simple cleanup for structured name
|
||||||
|
break
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return False
|
||||||
|
|
||||||
|
numbers = get_vcard_value(entry, "TEL")
|
||||||
|
if not numbers:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return {
|
||||||
|
"full_name": name,
|
||||||
|
# Remove duplications
|
||||||
|
"numbers": set(numbers),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def read_vcards_file(vcf_file_path, default_country_code: str):
|
def read_vcards_file(vcf_file_path, default_country_code: str):
|
||||||
contacts = []
|
contacts = []
|
||||||
with open(vcf_file_path, mode="r", encoding="utf-8") as f:
|
with open(vcf_file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||||
reader = vobject.readComponents(f)
|
content = f.read()
|
||||||
for row in reader:
|
|
||||||
if hasattr(row, 'fn'):
|
# Split into individual vCards
|
||||||
name = str(row.fn.value)
|
vcards = content.split("BEGIN:VCARD")
|
||||||
elif hasattr(row, 'n'):
|
for vcard in vcards:
|
||||||
name = str(row.n.value)
|
if "END:VCARD" not in vcard:
|
||||||
else:
|
|
||||||
name = None
|
|
||||||
if not hasattr(row, 'tel') or name is None:
|
|
||||||
continue
|
continue
|
||||||
contact: ExportedContactNumbers = {
|
|
||||||
"full_name": name,
|
if contact := process_vcard_entry(vcard):
|
||||||
"numbers": list(map(lambda tel: tel.value, row.tel_list)),
|
|
||||||
}
|
|
||||||
contacts.append(contact)
|
contacts.append(contact)
|
||||||
|
|
||||||
|
logger.info(f"Imported {len(contacts)} contacts/vcards{CLEAR_LINE}")
|
||||||
return map_number_to_name(contacts, default_country_code)
|
return map_number_to_name(contacts, default_country_code)
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +207,6 @@ def normalize_number(number: str, country_code: str):
|
|||||||
return number[len(starting_char):]
|
return number[len(starting_char):]
|
||||||
|
|
||||||
# leading zero should be removed
|
# leading zero should be removed
|
||||||
if starting_char == '0':
|
if number.startswith('0'):
|
||||||
number = number[1:]
|
number = number[1:]
|
||||||
return country_code + number # fall back
|
return country_code + number # fall back
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
# from contacts_names_from_vcards import readVCardsFile
|
|
||||||
|
|
||||||
from Whatsapp_Chat_Exporter.vcards_contacts import normalize_number, read_vcards_file
|
|
||||||
|
|
||||||
|
|
||||||
def test_readVCardsFile():
|
|
||||||
assert len(read_vcards_file("contacts.vcf", "973")) > 0
|
|
||||||
|
|
||||||
def test_create_number_to_name_dicts():
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_fuzzy_match_numbers():
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_normalize_number():
|
|
||||||
assert normalize_number('0531234567', '1') == '1531234567'
|
|
||||||
assert normalize_number('001531234567', '2') == '1531234567'
|
|
||||||
assert normalize_number('+1531234567', '34') == '1531234567'
|
|
||||||
assert normalize_number('053(123)4567', '34') == '34531234567'
|
|
||||||
assert normalize_number('0531-234-567', '58') == '58531234567'
|
|
||||||
@@ -3,11 +3,43 @@
|
|||||||
<head>
|
<head>
|
||||||
<title>Whatsapp - {{ name }}</title>
|
<title>Whatsapp - {{ name }}</title>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="stylesheet" href="{{w3css}}">
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
whatsapp: {
|
||||||
|
light: '#e7ffdb',
|
||||||
|
DEFAULT: '#25D366',
|
||||||
|
dark: '#075E54',
|
||||||
|
chat: '#efeae2',
|
||||||
|
'chat-light': '#f0f2f5',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<style>
|
<style>
|
||||||
html, body {
|
body, html {
|
||||||
font-size: 12px;
|
height: 100%;
|
||||||
scroll-behavior: smooth;
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
scroll-behavior: smooth !important;
|
||||||
|
}
|
||||||
|
.chat-list {
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.message-list {
|
||||||
|
height: calc(100vh - 90px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.chat-list, .message-list {
|
||||||
|
height: calc(100vh - 108px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
header {
|
header {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -19,18 +51,20 @@
|
|||||||
padding: 20px 0 20px 0;
|
padding: 20px 0 20px 0;
|
||||||
}
|
}
|
||||||
footer {
|
footer {
|
||||||
|
margin-top: 10px;
|
||||||
border-top: 2px solid #e3e6e7;
|
border-top: 2px solid #e3e6e7;
|
||||||
padding: 20px 0 20px 0;
|
padding: 20px 0 20px 0;
|
||||||
}
|
}
|
||||||
article {
|
article {
|
||||||
width:500px;
|
width:430px;
|
||||||
margin:100px auto;
|
margin: auto;
|
||||||
z-index:10;
|
z-index:10;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
img, video {
|
img, video, audio{
|
||||||
max-width:100%;
|
max-width:100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
div.reply{
|
div.reply{
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -44,21 +78,7 @@
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
div:target {
|
div:target {
|
||||||
border-style: solid;
|
animation: 3s highlight;
|
||||||
border-width: 2px;
|
|
||||||
animation: border-blink 0.5s steps(1) 5;
|
|
||||||
border-color: rgba(0,0,0,0)
|
|
||||||
}
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
@keyframes border-blink {
|
|
||||||
0% {
|
|
||||||
border-color: #2196F3;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
border-color: rgba(0,0,0,0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.avatar {
|
.avatar {
|
||||||
border-radius:50%;
|
border-radius:50%;
|
||||||
@@ -85,152 +105,159 @@
|
|||||||
max-width: 100px !important;
|
max-width: 100px !important;
|
||||||
max-height: 100px !important;
|
max-height: 100px !important;
|
||||||
}
|
}
|
||||||
|
@keyframes highlight {
|
||||||
|
from {
|
||||||
|
background-color: rgba(37, 211, 102, 0.1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.search-input {
|
||||||
|
transform: translateY(-100%);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
.search-input.active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.reply-box:active {
|
||||||
|
background-color:rgb(200 202 205 / var(--tw-bg-opacity, 1));
|
||||||
|
}
|
||||||
|
.info-box-tooltip {
|
||||||
|
--tw-translate-x: -50%;
|
||||||
|
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
<script>
|
||||||
|
function search(event) {
|
||||||
|
keywords = document.getElementById("mainHeaderSearchInput").value;
|
||||||
|
hits = [];
|
||||||
|
document.querySelectorAll(".message-text").forEach(elem => {
|
||||||
|
if (elem.innerText.trim().includes(keywords)){
|
||||||
|
hits.push(elem.parentElement.parentElement.id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log(hits);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<base href="{{ media_base }}" target="_blank">
|
<base href="{{ media_base }}" target="_blank">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="w3-center w3-top">
|
<article class="h-screen bg-whatsapp-chat-light">
|
||||||
{{ headline }}
|
<div class="w-full flex flex-col">
|
||||||
{% if status is not none %}
|
<div class="p-3 bg-whatsapp-dark flex items-center justify-between border-l border-[#d1d7db]">
|
||||||
<br>
|
<div class="flex items-center">
|
||||||
<span class="w3-small">{{ status }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</header>
|
|
||||||
<article class="w3-container">
|
|
||||||
<div class="table">
|
|
||||||
{% set last = {'last': 946688461.001} %}
|
|
||||||
{% for msg in msgs -%}
|
|
||||||
<div class="w3-row w3-padding-small w3-margin-bottom" id="{{ msg.key_id }}">
|
|
||||||
{% if determine_day(last.last, msg.timestamp) is not none %}
|
|
||||||
<div class="w3-center w3-padding-16 blue">{{ determine_day(last.last, msg.timestamp) }}</div>
|
|
||||||
{% if last.update({'last': msg.timestamp}) %}{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% if msg.from_me == true %}
|
|
||||||
<div class="w3-row">
|
|
||||||
<div class="w3-left blue">{{ msg.time }}</div>
|
|
||||||
<div class="name w3-right-align pad-left-10">You</div>
|
|
||||||
</div>
|
|
||||||
<div class="w3-row">
|
|
||||||
{% if not no_avatar and my_avatar is not none %}
|
|
||||||
<div class="w3-col m10 l10">
|
|
||||||
{% else %}
|
|
||||||
<div class="w3-col m12 l12">
|
|
||||||
{% endif %}
|
|
||||||
<div class="w3-right-align">
|
|
||||||
{% if msg.reply is not none %}
|
|
||||||
<div class="reply">
|
|
||||||
<span class="blue">Replying to </span>
|
|
||||||
<a href="#{{msg.reply}}" target="_self" class="reply_link no-base">
|
|
||||||
{% if msg.quoted_data is not none %}
|
|
||||||
"{{msg.quoted_data}}"
|
|
||||||
{% else %}
|
|
||||||
this message
|
|
||||||
{% endif %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if msg.meta == true or msg.media == false and msg.data is none %}
|
|
||||||
<div class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar w3-threequarter w3-center">
|
|
||||||
{% if msg.safe %}
|
|
||||||
<p>{{ msg.data | safe or 'Not supported WhatsApp internal message' }}</p>
|
|
||||||
{% else %}
|
|
||||||
<p>{{ msg.data or 'Not supported WhatsApp internal message' }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if msg.caption is not none %}
|
|
||||||
<div class="w3-container">
|
|
||||||
{{ msg.caption | urlize(none, true, '_blank') }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
{% if msg.media == false %}
|
|
||||||
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
|
|
||||||
{% else %}
|
|
||||||
{% if "image/" in msg.mime %}
|
|
||||||
<a href="{{ msg.data }}">
|
|
||||||
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
|
|
||||||
</a>
|
|
||||||
{% elif "audio/" in msg.mime %}
|
|
||||||
<audio controls="controls" autobuffer="autobuffer">
|
|
||||||
<source src="{{ msg.data }}" />
|
|
||||||
</audio>
|
|
||||||
{% elif "video/" in msg.mime %}
|
|
||||||
<video class="lazy" autobuffer {% if msg.message_type|int == 13 or msg.message_type|int == 11 %}autoplay muted loop playsinline{%else%}controls{% endif %}>
|
|
||||||
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
|
|
||||||
</video>
|
|
||||||
{% elif "/" in msg.mime %}
|
|
||||||
<div class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar w3-threequarter w3-center">
|
|
||||||
<p>The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a></p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
{% filter escape %}{{ msg.data }}{% endfilter %}
|
|
||||||
{% endif %}
|
|
||||||
{% if msg.caption is not none %}
|
|
||||||
<div class="w3-container">
|
|
||||||
{{ msg.caption | urlize(none, true, '_blank') }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if not no_avatar and my_avatar is not none %}
|
|
||||||
<div class="w3-col m2 l2 pad-left-10">
|
|
||||||
<a href="{{ my_avatar }}">
|
|
||||||
<img src="{{ my_avatar }}" onerror="this.style.display='none'" class="avatar" loading="lazy">
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="w3-row">
|
|
||||||
<div class="w3-left pad-right-10 name">
|
|
||||||
{% if msg.sender is not none %}
|
|
||||||
{{ msg.sender }}
|
|
||||||
{% else %}
|
|
||||||
{{ name }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="w3-right-align blue">{{ msg.time }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="w3-row">
|
|
||||||
{% if not no_avatar %}
|
{% if not no_avatar %}
|
||||||
<div class="w3-col m2 l2">
|
<div class="w3-col m2 l2">
|
||||||
{% if their_avatar is not none %}
|
{% if their_avatar is not none %}
|
||||||
<a href="{{ their_avatar }}"><img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar" loading="lazy"></a>
|
<a href="{{ their_avatar }}"><img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="w-10 h-10 rounded-full mr-3" loading="lazy"></a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar" loading="lazy">
|
<img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="w-10 h-10 rounded-full mr-3" loading="lazy">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="w3-col m10 l10">
|
|
||||||
{% else %}
|
|
||||||
<div class="w3-col m12 l12">
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="w3-left-align">
|
<div>
|
||||||
|
<h2 class="text-white font-medium">{{ headline }}</h2>
|
||||||
|
{% if status is not none %}<p class="text-[#8696a0] text-xs">{{ status }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<!-- <button id="searchButton">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</button> -->
|
||||||
|
<!-- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg> -->
|
||||||
|
{% if previous %}
|
||||||
|
<a href="./{{ previous }}" target="_self">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5l-7 7 7 7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if next %}
|
||||||
|
<a href="./{{ next }}" target="_self">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<!-- Search Input Overlay -->
|
||||||
|
<div id="mainSearchInput" class="search-input absolute article top-0 bg-whatsapp-dark p-3 flex items-center space-x-3">
|
||||||
|
<button id="closeMainSearch" class="text-[#aebac1]">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input type="text" placeholder="Search..." class="flex-1 bg-[#1f2c34] text-white rounded-lg px-3 py-1 focus:outline-none" id="mainHeaderSearchInput" onkeyup="search(event)">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 p-5 message-list">
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<!--Date-->
|
||||||
|
{% set last = {'last': 946688461.001} %}
|
||||||
|
{% for msg in msgs -%}
|
||||||
|
{% if determine_day(last.last, msg.timestamp) is not none %}
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<div class="bg-[#e1f2fb] rounded-lg px-2 py-1 text-xs text-[#54656f]">
|
||||||
|
{{ determine_day(last.last, msg.timestamp) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if last.update({'last': msg.timestamp}) %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<!--Actual messages-->
|
||||||
|
{% if msg.from_me == true %}
|
||||||
|
<div class="flex justify-end items-center group" id="{{ msg.key_id }}">
|
||||||
|
<div class="opacity-0 group-hover:opacity-100 transition-opacity duration-200 relative mr-2">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="relative group/tooltip">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#8696a0] hover:text-[#54656f] cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<use href="#info-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<div class="absolute bottom-full info-box-tooltip mb-2 hidden group-hover/tooltip:block z-50">
|
||||||
|
<div class="bg-black text-white text-xs rounded py-1 px-2 whitespace-nowrap">
|
||||||
|
Delivered at {{msg.received_timestamp or 'unknown'}}
|
||||||
|
{% if msg.read_timestamp is not none %}
|
||||||
|
<br>Read at {{ msg.read_timestamp }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-full right-3 -mt-1 border-4 border-transparent border-t-black"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-whatsapp-light rounded-lg p-2 max-w-[80%] shadow-sm">
|
||||||
{% if msg.reply is not none %}
|
{% if msg.reply is not none %}
|
||||||
<div class="reply">
|
<a href="#{{msg.reply}}" target="_self" class="no-base">
|
||||||
<span class="blue">Replying to </span>
|
<div class="mb-2 p-1 bg-whatsapp-chat-light rounded border-l-4 border-whatsapp text-sm reply-box">
|
||||||
<a href="#{{msg.reply}}" target="_self" class="reply_link no-base">
|
<p class="text-whatsapp font-medium text-xs">Replying to</p>
|
||||||
|
<p class="text-[#111b21] text-xs truncate">
|
||||||
{% if msg.quoted_data is not none %}
|
{% if msg.quoted_data is not none %}
|
||||||
"{{msg.quoted_data}}"
|
"{{msg.quoted_data}}"
|
||||||
{% else %}
|
{% else %}
|
||||||
this message
|
this message
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<p class="text-[#111b21] text-sm message-text">
|
||||||
{% if msg.meta == true or msg.media == false and msg.data is none %}
|
{% if msg.meta == true or msg.media == false and msg.data is none %}
|
||||||
<div class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar w3-threequarter w3-center">
|
<div class="flex justify-center mb-2">
|
||||||
|
<div class="bg-[#FFF3C5] rounded-lg px-3 py-2 text-sm text-[#856404] flex items-center">
|
||||||
{% if msg.safe %}
|
{% if msg.safe %}
|
||||||
<p>{{ msg.data | safe or 'Not supported WhatsApp internal message' }}</p>
|
{{ msg.data | safe or 'Not supported WhatsApp internal message' }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>{{ msg.data or 'Not supported WhatsApp internal message' }}</p>
|
{{ msg.data or 'Not supported WhatsApp internal message' }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{% if msg.caption is not none %}
|
{% if msg.caption is not none %}
|
||||||
<div class="w3-container">
|
<p>{{ msg.caption | urlize(none, true, '_blank') }}</p>
|
||||||
{{ msg.caption | urlize(none, true, '_blank') }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if msg.media == false %}
|
{% if msg.media == false %}
|
||||||
@@ -249,42 +276,154 @@
|
|||||||
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
|
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
|
||||||
</video>
|
</video>
|
||||||
{% elif "/" in msg.mime %}
|
{% elif "/" in msg.mime %}
|
||||||
<div class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar w3-threequarter w3-center">
|
The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a>
|
||||||
<p>The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a></p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{% filter escape %}{{ msg.data }}{% endfilter %}
|
{% filter escape %}{{ msg.data }}{% endfilter %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if msg.caption is not none %}
|
{% if msg.caption is not none %}
|
||||||
<div class="w3-container">
|
|
||||||
{{ msg.caption | urlize(none, true, '_blank') }}
|
{{ msg.caption | urlize(none, true, '_blank') }}
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-[10px] text-[#667781] text-right mt-1">{{ msg.time }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex justify-start items-center group" id="{{ msg.key_id }}">
|
||||||
|
<div class="bg-white rounded-lg p-2 max-w-[80%] shadow-sm">
|
||||||
|
{% if msg.reply is not none %}
|
||||||
|
<a href="#{{msg.reply}}" target="_self" class="no-base">
|
||||||
|
<div class="mb-2 p-1 bg-whatsapp-chat-light rounded border-l-4 border-whatsapp text-sm reply-box">
|
||||||
|
<p class="text-whatsapp font-medium text-xs">Replying to</p>
|
||||||
|
<p class="text-[#808080] text-xs truncate">
|
||||||
|
{% if msg.quoted_data is not none %}
|
||||||
|
{{msg.quoted_data}}
|
||||||
|
{% else %}
|
||||||
|
this message
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-[#111b21] text-sm">
|
||||||
|
{% if msg.meta == true or msg.media == false and msg.data is none %}
|
||||||
|
<div class="flex justify-center mb-2">
|
||||||
|
<div class="bg-[#FFF3C5] rounded-lg px-3 py-2 text-sm text-[#856404] flex items-center">
|
||||||
|
{% if msg.safe %}
|
||||||
|
{{ msg.data | safe or 'Not supported WhatsApp internal message' }}
|
||||||
|
{% else %}
|
||||||
|
{{ msg.data or 'Not supported WhatsApp internal message' }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if msg.caption is not none %}
|
||||||
|
<p>{{ msg.caption | urlize(none, true, '_blank') }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if msg.media == false %}
|
||||||
|
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
|
||||||
|
{% else %}
|
||||||
|
{% if "image/" in msg.mime %}
|
||||||
|
<a href="{{ msg.data }}">
|
||||||
|
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
|
||||||
|
</a>
|
||||||
|
{% elif "audio/" in msg.mime %}
|
||||||
|
<audio controls="controls" autobuffer="autobuffer">
|
||||||
|
<source src="{{ msg.data }}" />
|
||||||
|
</audio>
|
||||||
|
{% elif "video/" in msg.mime %}
|
||||||
|
<video class="lazy" autobuffer {% if msg.message_type|int == 13 or msg.message_type|int == 11 %}autoplay muted loop playsinline{%else%}controls{% endif %}>
|
||||||
|
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
|
||||||
|
</video>
|
||||||
|
{% elif "/" in msg.mime %}
|
||||||
|
The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a>
|
||||||
|
{% else %}
|
||||||
|
{% filter escape %}{{ msg.data }}{% endfilter %}
|
||||||
|
{% endif %}
|
||||||
|
{% if msg.caption is not none %}
|
||||||
|
{{ msg.caption | urlize(none, true, '_blank') }}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-baseline text-[10px] text-[#667781] mt-1 gap-2">
|
||||||
|
<span class="flex-shrink-0">
|
||||||
|
{% if msg.sender is not none %}
|
||||||
|
{{ msg.sender }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="flex-grow min-w-[4px]"></span>
|
||||||
|
<span class="flex-shrink-0">{{ msg.time }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- <div class="opacity-0 group-hover:opacity-100 transition-opacity duration-200 relative ml-2">
|
||||||
|
<div class="relative">
|
||||||
|
<div class="relative group/tooltip">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#8696a0] hover:text-[#54656f] cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<use href="#info-icon"></use>
|
||||||
|
</svg>
|
||||||
|
<div class="absolute bottom-full info-box-tooltip mb-2 hidden group-hover/tooltip:block z-50">
|
||||||
|
<div class="bg-black text-white text-xs rounded py-1 px-2 whitespace-nowrap">
|
||||||
|
Received at {{msg.received_timestamp or 'unknown'}}
|
||||||
|
</div>
|
||||||
|
<div class="absolute top-full right-3 ml-1 border-4 border-transparent border-t-black"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div> -->
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
<footer>
|
||||||
<footer class="w3-center">
|
<h2 class="text-center">
|
||||||
<h2>
|
{% if not next %}
|
||||||
{% if previous %}
|
|
||||||
<a href="./{{ previous }}" target="_self">Previous</a>
|
|
||||||
{% endif %}
|
|
||||||
<h2>
|
|
||||||
{% if next %}
|
|
||||||
<a href="./{{ next }}" target="_self">Next</a>
|
|
||||||
{% else %}
|
|
||||||
End of History
|
End of History
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
<br>
|
<br>
|
||||||
Portions of this page are reproduced from <a href="https://web.dev/articles/lazy-loading-video">work</a> created and <a href="https://developers.google.com/readme/policies">shared by Google</a> and used according to terms described in the <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache 2.0 License</a>.
|
Portions of this page are reproduced from <a href="https://web.dev/articles/lazy-loading-video">work</a> created and <a href="https://developers.google.com/readme/policies">shared by Google</a> and used according to terms described in the <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache 2.0 License</a>.
|
||||||
</footer>
|
</footer>
|
||||||
|
<svg style="display: none;">
|
||||||
|
<!-- Tooltip info icon -->
|
||||||
|
<symbol id="info-icon" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</body>
|
||||||
|
<script>
|
||||||
|
// Search functionality
|
||||||
|
const searchButton = document.getElementById('searchButton');
|
||||||
|
const mainSearchInput = document.getElementById('mainSearchInput');
|
||||||
|
const closeMainSearch = document.getElementById('closeMainSearch');
|
||||||
|
const mainHeaderSearchInput = document.getElementById('mainHeaderSearchInput');
|
||||||
|
|
||||||
|
// Function to show search input
|
||||||
|
const showSearch = () => {
|
||||||
|
mainSearchInput.classList.add('active');
|
||||||
|
mainHeaderSearchInput.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Function to hide search input
|
||||||
|
const hideSearch = () => {
|
||||||
|
mainSearchInput.classList.remove('active');
|
||||||
|
mainHeaderSearchInput.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
searchButton.addEventListener('click', showSearch);
|
||||||
|
closeMainSearch.addEventListener('click', hideSearch);
|
||||||
|
|
||||||
|
// Handle ESC key
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key === 'Escape' && mainSearchInput.classList.contains('active')) {
|
||||||
|
hideSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function() {
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
var lazyVideos = [].slice.call(document.querySelectorAll("video.lazy"));
|
var lazyVideos = [].slice.call(document.querySelectorAll("video.lazy"));
|
||||||
@@ -325,5 +464,4 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,467 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Whatsapp - {{ name }}</title>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
whatsapp: {
|
|
||||||
light: '#e7ffdb',
|
|
||||||
DEFAULT: '#25D366',
|
|
||||||
dark: '#075E54',
|
|
||||||
chat: '#efeae2',
|
|
||||||
'chat-light': '#f0f2f5',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style>
|
|
||||||
body, html {
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
scroll-behavior: smooth !important;
|
|
||||||
}
|
|
||||||
.chat-list {
|
|
||||||
height: calc(100vh - 120px);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.message-list {
|
|
||||||
height: calc(100vh - 90px);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.chat-list, .message-list {
|
|
||||||
height: calc(100vh - 108px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
header {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 20;
|
|
||||||
border-bottom: 2px solid #e3e6e7;
|
|
||||||
font-size: 2em;
|
|
||||||
font-weight: bolder;
|
|
||||||
background-color: white;
|
|
||||||
padding: 20px 0 20px 0;
|
|
||||||
}
|
|
||||||
footer {
|
|
||||||
margin-top: 10px;
|
|
||||||
border-top: 2px solid #e3e6e7;
|
|
||||||
padding: 20px 0 20px 0;
|
|
||||||
}
|
|
||||||
article {
|
|
||||||
width:430px;
|
|
||||||
margin: auto;
|
|
||||||
z-index:10;
|
|
||||||
font-size: 15px;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
img, video, audio{
|
|
||||||
max-width:100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
div.reply{
|
|
||||||
font-size: 13px;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
div:target::before {
|
|
||||||
content: '';
|
|
||||||
display: block;
|
|
||||||
height: 115px;
|
|
||||||
margin-top: -115px;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
div:target {
|
|
||||||
animation: 3s highlight;
|
|
||||||
}
|
|
||||||
.avatar {
|
|
||||||
border-radius:50%;
|
|
||||||
overflow:hidden;
|
|
||||||
max-width: 64px;
|
|
||||||
max-height: 64px;
|
|
||||||
}
|
|
||||||
.name {
|
|
||||||
color: #3892da;
|
|
||||||
}
|
|
||||||
.pad-left-10 {
|
|
||||||
padding-left: 10px;
|
|
||||||
}
|
|
||||||
.pad-right-10 {
|
|
||||||
padding-right: 10px;
|
|
||||||
}
|
|
||||||
.reply_link {
|
|
||||||
color: #168acc;
|
|
||||||
}
|
|
||||||
.blue {
|
|
||||||
color: #70777a;
|
|
||||||
}
|
|
||||||
.sticker {
|
|
||||||
max-width: 100px !important;
|
|
||||||
max-height: 100px !important;
|
|
||||||
}
|
|
||||||
@keyframes highlight {
|
|
||||||
from {
|
|
||||||
background-color: rgba(37, 211, 102, 0.1);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.search-input {
|
|
||||||
transform: translateY(-100%);
|
|
||||||
transition: transform 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
.search-input.active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
.reply-box:active {
|
|
||||||
background-color:rgb(200 202 205 / var(--tw-bg-opacity, 1));
|
|
||||||
}
|
|
||||||
.info-box-tooltip {
|
|
||||||
--tw-translate-x: -50%;
|
|
||||||
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
function search(event) {
|
|
||||||
keywords = document.getElementById("mainHeaderSearchInput").value;
|
|
||||||
hits = [];
|
|
||||||
document.querySelectorAll(".message-text").forEach(elem => {
|
|
||||||
if (elem.innerText.trim().includes(keywords)){
|
|
||||||
hits.push(elem.parentElement.parentElement.id);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
console.log(hits);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<base href="{{ media_base }}" target="_blank">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<article class="h-screen bg-whatsapp-chat-light">
|
|
||||||
<div class="w-full flex flex-col">
|
|
||||||
<div class="p-3 bg-whatsapp-dark flex items-center justify-between border-l border-[#d1d7db]">
|
|
||||||
<div class="flex items-center">
|
|
||||||
{% if not no_avatar %}
|
|
||||||
<div class="w3-col m2 l2">
|
|
||||||
{% if their_avatar is not none %}
|
|
||||||
<a href="{{ their_avatar }}"><img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="w-10 h-10 rounded-full mr-3" loading="lazy"></a>
|
|
||||||
{% else %}
|
|
||||||
<img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="w-10 h-10 rounded-full mr-3" loading="lazy">
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div>
|
|
||||||
<h2 class="text-white font-medium">{{ headline }}</h2>
|
|
||||||
{% if status is not none %}<p class="text-[#8696a0] text-xs">{{ status }}</p>{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex space-x-4">
|
|
||||||
<!-- <button id="searchButton">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
||||||
</svg>
|
|
||||||
</button> -->
|
|
||||||
<!-- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
||||||
</svg> -->
|
|
||||||
{% if previous %}
|
|
||||||
<a href="./{{ previous }}" target="_self">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5l-7 7 7 7" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if next %}
|
|
||||||
<a href="./{{ next }}" target="_self">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#aebac1]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<!-- Search Input Overlay -->
|
|
||||||
<div id="mainSearchInput" class="search-input absolute article top-0 bg-whatsapp-dark p-3 flex items-center space-x-3">
|
|
||||||
<button id="closeMainSearch" class="text-[#aebac1]">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<input type="text" placeholder="Search..." class="flex-1 bg-[#1f2c34] text-white rounded-lg px-3 py-1 focus:outline-none" id="mainHeaderSearchInput" onkeyup="search(event)">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 p-5 message-list">
|
|
||||||
<div class="flex flex-col space-y-2">
|
|
||||||
<!--Date-->
|
|
||||||
{% set last = {'last': 946688461.001} %}
|
|
||||||
{% for msg in msgs -%}
|
|
||||||
{% if determine_day(last.last, msg.timestamp) is not none %}
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<div class="bg-[#e1f2fb] rounded-lg px-2 py-1 text-xs text-[#54656f]">
|
|
||||||
{{ determine_day(last.last, msg.timestamp) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if last.update({'last': msg.timestamp}) %}{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
<!--Actual messages-->
|
|
||||||
{% if msg.from_me == true %}
|
|
||||||
<div class="flex justify-end items-center group" id="{{ msg.key_id }}">
|
|
||||||
<div class="opacity-0 group-hover:opacity-100 transition-opacity duration-200 relative mr-2">
|
|
||||||
<div class="relative">
|
|
||||||
<div class="relative group/tooltip">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#8696a0] hover:text-[#54656f] cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<use href="#info-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<div class="absolute bottom-full info-box-tooltip mb-2 hidden group-hover/tooltip:block z-50">
|
|
||||||
<div class="bg-black text-white text-xs rounded py-1 px-2 whitespace-nowrap">
|
|
||||||
Delivered at {{msg.received_timestamp or 'unknown'}}
|
|
||||||
{% if msg.read_timestamp is not none %}
|
|
||||||
<br>Read at {{ msg.read_timestamp }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="absolute top-full right-3 -mt-1 border-4 border-transparent border-t-black"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-whatsapp-light rounded-lg p-2 max-w-[80%] shadow-sm">
|
|
||||||
{% if msg.reply is not none %}
|
|
||||||
<a href="#{{msg.reply}}" target="_self" class="no-base">
|
|
||||||
<div class="mb-2 p-1 bg-whatsapp-chat-light rounded border-l-4 border-whatsapp text-sm reply-box">
|
|
||||||
<p class="text-whatsapp font-medium text-xs">Replying to</p>
|
|
||||||
<p class="text-[#111b21] text-xs truncate">
|
|
||||||
{% if msg.quoted_data is not none %}
|
|
||||||
"{{msg.quoted_data}}"
|
|
||||||
{% else %}
|
|
||||||
this message
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<p class="text-[#111b21] text-sm message-text">
|
|
||||||
{% if msg.meta == true or msg.media == false and msg.data is none %}
|
|
||||||
<div class="flex justify-center mb-2">
|
|
||||||
<div class="bg-[#FFF3C5] rounded-lg px-3 py-2 text-sm text-[#856404] flex items-center">
|
|
||||||
{% if msg.safe %}
|
|
||||||
{{ msg.data | safe or 'Not supported WhatsApp internal message' }}
|
|
||||||
{% else %}
|
|
||||||
{{ msg.data or 'Not supported WhatsApp internal message' }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if msg.caption is not none %}
|
|
||||||
<p>{{ msg.caption | urlize(none, true, '_blank') }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
{% if msg.media == false %}
|
|
||||||
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
|
|
||||||
{% else %}
|
|
||||||
{% if "image/" in msg.mime %}
|
|
||||||
<a href="{{ msg.data }}">
|
|
||||||
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
|
|
||||||
</a>
|
|
||||||
{% elif "audio/" in msg.mime %}
|
|
||||||
<audio controls="controls" autobuffer="autobuffer">
|
|
||||||
<source src="{{ msg.data }}" />
|
|
||||||
</audio>
|
|
||||||
{% elif "video/" in msg.mime %}
|
|
||||||
<video class="lazy" autobuffer {% if msg.message_type|int == 13 or msg.message_type|int == 11 %}autoplay muted loop playsinline{%else%}controls{% endif %}>
|
|
||||||
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
|
|
||||||
</video>
|
|
||||||
{% elif "/" in msg.mime %}
|
|
||||||
The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a>
|
|
||||||
{% else %}
|
|
||||||
{% filter escape %}{{ msg.data }}{% endfilter %}
|
|
||||||
{% endif %}
|
|
||||||
{% if msg.caption is not none %}
|
|
||||||
{{ msg.caption | urlize(none, true, '_blank') }}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<p class="text-[10px] text-[#667781] text-right mt-1">{{ msg.time }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="flex justify-start items-center group" id="{{ msg.key_id }}">
|
|
||||||
<div class="bg-white rounded-lg p-2 max-w-[80%] shadow-sm">
|
|
||||||
{% if msg.reply is not none %}
|
|
||||||
<a href="#{{msg.reply}}" target="_self" class="no-base">
|
|
||||||
<div class="mb-2 p-1 bg-whatsapp-chat-light rounded border-l-4 border-whatsapp text-sm reply-box">
|
|
||||||
<p class="text-whatsapp font-medium text-xs">Replying to</p>
|
|
||||||
<p class="text-[#808080] text-xs truncate">
|
|
||||||
{% if msg.quoted_data is not none %}
|
|
||||||
{{msg.quoted_data}}
|
|
||||||
{% else %}
|
|
||||||
this message
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
<p class="text-[#111b21] text-sm">
|
|
||||||
{% if msg.meta == true or msg.media == false and msg.data is none %}
|
|
||||||
<div class="flex justify-center mb-2">
|
|
||||||
<div class="bg-[#FFF3C5] rounded-lg px-3 py-2 text-sm text-[#856404] flex items-center">
|
|
||||||
{% if msg.safe %}
|
|
||||||
{{ msg.data | safe or 'Not supported WhatsApp internal message' }}
|
|
||||||
{% else %}
|
|
||||||
{{ msg.data or 'Not supported WhatsApp internal message' }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if msg.caption is not none %}
|
|
||||||
<p>{{ msg.caption | urlize(none, true, '_blank') }}</p>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
{% if msg.media == false %}
|
|
||||||
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
|
|
||||||
{% else %}
|
|
||||||
{% if "image/" in msg.mime %}
|
|
||||||
<a href="{{ msg.data }}">
|
|
||||||
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
|
|
||||||
</a>
|
|
||||||
{% elif "audio/" in msg.mime %}
|
|
||||||
<audio controls="controls" autobuffer="autobuffer">
|
|
||||||
<source src="{{ msg.data }}" />
|
|
||||||
</audio>
|
|
||||||
{% elif "video/" in msg.mime %}
|
|
||||||
<video class="lazy" autobuffer {% if msg.message_type|int == 13 or msg.message_type|int == 11 %}autoplay muted loop playsinline{%else%}controls{% endif %}>
|
|
||||||
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
|
|
||||||
</video>
|
|
||||||
{% elif "/" in msg.mime %}
|
|
||||||
The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a>
|
|
||||||
{% else %}
|
|
||||||
{% filter escape %}{{ msg.data }}{% endfilter %}
|
|
||||||
{% endif %}
|
|
||||||
{% if msg.caption is not none %}
|
|
||||||
{{ msg.caption | urlize(none, true, '_blank') }}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
<div class="flex items-baseline text-[10px] text-[#667781] mt-1 gap-2">
|
|
||||||
<span class="flex-shrink-0">
|
|
||||||
{% if msg.sender is not none %}
|
|
||||||
{{ msg.sender }}
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<span class="flex-grow min-w-[4px]"></span>
|
|
||||||
<span class="flex-shrink-0">{{ msg.time }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- <div class="opacity-0 group-hover:opacity-100 transition-opacity duration-200 relative ml-2">
|
|
||||||
<div class="relative">
|
|
||||||
<div class="relative group/tooltip">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#8696a0] hover:text-[#54656f] cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<use href="#info-icon"></use>
|
|
||||||
</svg>
|
|
||||||
<div class="absolute bottom-full info-box-tooltip mb-2 hidden group-hover/tooltip:block z-50">
|
|
||||||
<div class="bg-black text-white text-xs rounded py-1 px-2 whitespace-nowrap">
|
|
||||||
Received at {{msg.received_timestamp or 'unknown'}}
|
|
||||||
</div>
|
|
||||||
<div class="absolute top-full right-3 ml-1 border-4 border-transparent border-t-black"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<footer>
|
|
||||||
<h2 class="text-center">
|
|
||||||
{% if not next %}
|
|
||||||
End of History
|
|
||||||
{% endif %}
|
|
||||||
</h2>
|
|
||||||
<br>
|
|
||||||
Portions of this page are reproduced from <a href="https://web.dev/articles/lazy-loading-video">work</a> created and <a href="https://developers.google.com/readme/policies">shared by Google</a> and used according to terms described in the <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache 2.0 License</a>.
|
|
||||||
</footer>
|
|
||||||
<svg style="display: none;">
|
|
||||||
<!-- Tooltip info icon -->
|
|
||||||
<symbol id="info-icon" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</body>
|
|
||||||
<script>
|
|
||||||
// Search functionality
|
|
||||||
const searchButton = document.getElementById('searchButton');
|
|
||||||
const mainSearchInput = document.getElementById('mainSearchInput');
|
|
||||||
const closeMainSearch = document.getElementById('closeMainSearch');
|
|
||||||
const mainHeaderSearchInput = document.getElementById('mainHeaderSearchInput');
|
|
||||||
|
|
||||||
// Function to show search input
|
|
||||||
const showSearch = () => {
|
|
||||||
mainSearchInput.classList.add('active');
|
|
||||||
mainHeaderSearchInput.focus();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to hide search input
|
|
||||||
const hideSearch = () => {
|
|
||||||
mainSearchInput.classList.remove('active');
|
|
||||||
mainHeaderSearchInput.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
searchButton.addEventListener('click', showSearch);
|
|
||||||
closeMainSearch.addEventListener('click', hideSearch);
|
|
||||||
|
|
||||||
// Handle ESC key
|
|
||||||
document.addEventListener('keydown', (event) => {
|
|
||||||
if (event.key === 'Escape' && mainSearchInput.classList.contains('active')) {
|
|
||||||
hideSearch();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
document.addEventListener("DOMContentLoaded", function() {
|
|
||||||
var lazyVideos = [].slice.call(document.querySelectorAll("video.lazy"));
|
|
||||||
|
|
||||||
if ("IntersectionObserver" in window) {
|
|
||||||
var lazyVideoObserver = new IntersectionObserver(function(entries, observer) {
|
|
||||||
entries.forEach(function(video) {
|
|
||||||
if (video.isIntersecting) {
|
|
||||||
for (var source in video.target.children) {
|
|
||||||
var videoSource = video.target.children[source];
|
|
||||||
if (typeof videoSource.tagName === "string" && videoSource.tagName === "SOURCE") {
|
|
||||||
videoSource.src = videoSource.dataset.src;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
video.target.load();
|
|
||||||
video.target.classList.remove("lazy");
|
|
||||||
lazyVideoObserver.unobserve(video.target);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
lazyVideos.forEach(function(lazyVideo) {
|
|
||||||
lazyVideoObserver.observe(lazyVideo);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
// Prevent the <base> tag from affecting links with the class "no-base"
|
|
||||||
document.querySelectorAll('.no-base').forEach(link => {
|
|
||||||
link.addEventListener('click', function(event) {
|
|
||||||
const href = this.getAttribute('href');
|
|
||||||
if (href.startsWith('#')) {
|
|
||||||
window.location.hash = href;
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</html>
|
|
||||||
329
Whatsapp_Chat_Exporter/whatsapp_old.html
Normal file
329
Whatsapp_Chat_Exporter/whatsapp_old.html
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Whatsapp - {{ name }}</title>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="stylesheet" href="{{w3css}}">
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
font-size: 12px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
border-bottom: 2px solid #e3e6e7;
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: bolder;
|
||||||
|
background-color: white;
|
||||||
|
padding: 20px 0 20px 0;
|
||||||
|
}
|
||||||
|
footer {
|
||||||
|
border-top: 2px solid #e3e6e7;
|
||||||
|
padding: 20px 0 20px 0;
|
||||||
|
}
|
||||||
|
article {
|
||||||
|
width:500px;
|
||||||
|
margin:100px auto;
|
||||||
|
z-index:10;
|
||||||
|
font-size: 15px;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
img, video {
|
||||||
|
max-width:100%;
|
||||||
|
}
|
||||||
|
div.reply{
|
||||||
|
font-size: 13px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
div:target::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
height: 115px;
|
||||||
|
margin-top: -115px;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
div:target {
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 2px;
|
||||||
|
animation: border-blink 0.5s steps(1) 5;
|
||||||
|
border-color: rgba(0,0,0,0)
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
@keyframes border-blink {
|
||||||
|
0% {
|
||||||
|
border-color: #2196F3;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
border-color: rgba(0,0,0,0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.avatar {
|
||||||
|
border-radius:50%;
|
||||||
|
overflow:hidden;
|
||||||
|
max-width: 64px;
|
||||||
|
max-height: 64px;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
color: #3892da;
|
||||||
|
}
|
||||||
|
.pad-left-10 {
|
||||||
|
padding-left: 10px;
|
||||||
|
}
|
||||||
|
.pad-right-10 {
|
||||||
|
padding-right: 10px;
|
||||||
|
}
|
||||||
|
.reply_link {
|
||||||
|
color: #168acc;
|
||||||
|
}
|
||||||
|
.blue {
|
||||||
|
color: #70777a;
|
||||||
|
}
|
||||||
|
.sticker {
|
||||||
|
max-width: 100px !important;
|
||||||
|
max-height: 100px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<base href="{{ media_base }}" target="_blank">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="w3-center w3-top">
|
||||||
|
{{ headline }}
|
||||||
|
{% if status is not none %}
|
||||||
|
<br>
|
||||||
|
<span class="w3-small">{{ status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</header>
|
||||||
|
<article class="w3-container">
|
||||||
|
<div class="table">
|
||||||
|
{% set last = {'last': 946688461.001} %}
|
||||||
|
{% for msg in msgs -%}
|
||||||
|
<div class="w3-row w3-padding-small w3-margin-bottom" id="{{ msg.key_id }}">
|
||||||
|
{% if determine_day(last.last, msg.timestamp) is not none %}
|
||||||
|
<div class="w3-center w3-padding-16 blue">{{ determine_day(last.last, msg.timestamp) }}</div>
|
||||||
|
{% if last.update({'last': msg.timestamp}) %}{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if msg.from_me == true %}
|
||||||
|
<div class="w3-row">
|
||||||
|
<div class="w3-left blue">{{ msg.time }}</div>
|
||||||
|
<div class="name w3-right-align pad-left-10">You</div>
|
||||||
|
</div>
|
||||||
|
<div class="w3-row">
|
||||||
|
{% if not no_avatar and my_avatar is not none %}
|
||||||
|
<div class="w3-col m10 l10">
|
||||||
|
{% else %}
|
||||||
|
<div class="w3-col m12 l12">
|
||||||
|
{% endif %}
|
||||||
|
<div class="w3-right-align">
|
||||||
|
{% if msg.reply is not none %}
|
||||||
|
<div class="reply">
|
||||||
|
<span class="blue">Replying to </span>
|
||||||
|
<a href="#{{msg.reply}}" target="_self" class="reply_link no-base">
|
||||||
|
{% if msg.quoted_data is not none %}
|
||||||
|
"{{msg.quoted_data}}"
|
||||||
|
{% else %}
|
||||||
|
this message
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if msg.meta == true or msg.media == false and msg.data is none %}
|
||||||
|
<div class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar w3-threequarter w3-center">
|
||||||
|
{% if msg.safe %}
|
||||||
|
<p>{{ msg.data | safe or 'Not supported WhatsApp internal message' }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ msg.data or 'Not supported WhatsApp internal message' }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if msg.caption is not none %}
|
||||||
|
<div class="w3-container">
|
||||||
|
{{ msg.caption | urlize(none, true, '_blank') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if msg.media == false %}
|
||||||
|
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
|
||||||
|
{% else %}
|
||||||
|
{% if "image/" in msg.mime %}
|
||||||
|
<a href="{{ msg.data }}">
|
||||||
|
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
|
||||||
|
</a>
|
||||||
|
{% elif "audio/" in msg.mime %}
|
||||||
|
<audio controls="controls" autobuffer="autobuffer">
|
||||||
|
<source src="{{ msg.data }}" />
|
||||||
|
</audio>
|
||||||
|
{% elif "video/" in msg.mime %}
|
||||||
|
<video class="lazy" autobuffer {% if msg.message_type|int == 13 or msg.message_type|int == 11 %}autoplay muted loop playsinline{%else%}controls{% endif %}>
|
||||||
|
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
|
||||||
|
</video>
|
||||||
|
{% elif "/" in msg.mime %}
|
||||||
|
<div class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar w3-threequarter w3-center">
|
||||||
|
<p>The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a></p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% filter escape %}{{ msg.data }}{% endfilter %}
|
||||||
|
{% endif %}
|
||||||
|
{% if msg.caption is not none %}
|
||||||
|
<div class="w3-container">
|
||||||
|
{{ msg.caption | urlize(none, true, '_blank') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if not no_avatar and my_avatar is not none %}
|
||||||
|
<div class="w3-col m2 l2 pad-left-10">
|
||||||
|
<a href="{{ my_avatar }}">
|
||||||
|
<img src="{{ my_avatar }}" onerror="this.style.display='none'" class="avatar" loading="lazy">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="w3-row">
|
||||||
|
<div class="w3-left pad-right-10 name">
|
||||||
|
{% if msg.sender is not none %}
|
||||||
|
{{ msg.sender }}
|
||||||
|
{% else %}
|
||||||
|
{{ name }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="w3-right-align blue">{{ msg.time }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="w3-row">
|
||||||
|
{% if not no_avatar %}
|
||||||
|
<div class="w3-col m2 l2">
|
||||||
|
{% if their_avatar is not none %}
|
||||||
|
<a href="{{ their_avatar }}"><img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar" loading="lazy"></a>
|
||||||
|
{% else %}
|
||||||
|
<img src="{{ their_avatar_thumb or '' }}" onerror="this.style.display='none'" class="avatar" loading="lazy">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="w3-col m10 l10">
|
||||||
|
{% else %}
|
||||||
|
<div class="w3-col m12 l12">
|
||||||
|
{% endif %}
|
||||||
|
<div class="w3-left-align">
|
||||||
|
{% if msg.reply is not none %}
|
||||||
|
<div class="reply">
|
||||||
|
<span class="blue">Replying to </span>
|
||||||
|
<a href="#{{msg.reply}}" target="_self" class="reply_link no-base">
|
||||||
|
{% if msg.quoted_data is not none %}
|
||||||
|
"{{msg.quoted_data}}"
|
||||||
|
{% else %}
|
||||||
|
this message
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if msg.meta == true or msg.media == false and msg.data is none %}
|
||||||
|
<div class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar w3-threequarter w3-center">
|
||||||
|
{% if msg.safe %}
|
||||||
|
<p>{{ msg.data | safe or 'Not supported WhatsApp internal message' }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p>{{ msg.data or 'Not supported WhatsApp internal message' }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if msg.caption is not none %}
|
||||||
|
<div class="w3-container">
|
||||||
|
{{ msg.caption | urlize(none, true, '_blank') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if msg.media == false %}
|
||||||
|
{{ msg.data | sanitize_except() | urlize(none, true, '_blank') }}
|
||||||
|
{% else %}
|
||||||
|
{% if "image/" in msg.mime %}
|
||||||
|
<a href="{{ msg.data }}">
|
||||||
|
<img src="{{ msg.thumb if msg.thumb is not none else msg.data }}" {{ 'class="sticker"' | safe if msg.sticker }} loading="lazy"/>
|
||||||
|
</a>
|
||||||
|
{% elif "audio/" in msg.mime %}
|
||||||
|
<audio controls="controls" autobuffer="autobuffer">
|
||||||
|
<source src="{{ msg.data }}" />
|
||||||
|
</audio>
|
||||||
|
{% elif "video/" in msg.mime %}
|
||||||
|
<video class="lazy" autobuffer {% if msg.message_type|int == 13 or msg.message_type|int == 11 %}autoplay muted loop playsinline{%else%}controls{% endif %}>
|
||||||
|
<source type="{{ msg.mime }}" data-src="{{ msg.data }}" />
|
||||||
|
</video>
|
||||||
|
{% elif "/" in msg.mime %}
|
||||||
|
<div class="w3-panel w3-border-blue w3-pale-blue w3-rightbar w3-leftbar w3-threequarter w3-center">
|
||||||
|
<p>The file cannot be displayed here, however it should be located at <a href="./{{ msg.data }}">here</a></p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% filter escape %}{{ msg.data }}{% endfilter %}
|
||||||
|
{% endif %}
|
||||||
|
{% if msg.caption is not none %}
|
||||||
|
<div class="w3-container">
|
||||||
|
{{ msg.caption | urlize(none, true, '_blank') }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<footer class="w3-center">
|
||||||
|
<h2>
|
||||||
|
{% if previous %}
|
||||||
|
<a href="./{{ previous }}" target="_self">Previous</a>
|
||||||
|
{% endif %}
|
||||||
|
<h2>
|
||||||
|
{% if next %}
|
||||||
|
<a href="./{{ next }}" target="_self">Next</a>
|
||||||
|
{% else %}
|
||||||
|
End of History
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
<br>
|
||||||
|
Portions of this page are reproduced from <a href="https://web.dev/articles/lazy-loading-video">work</a> created and <a href="https://developers.google.com/readme/policies">shared by Google</a> and used according to terms described in the <a href="https://www.apache.org/licenses/LICENSE-2.0">Apache 2.0 License</a>.
|
||||||
|
</footer>
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function() {
|
||||||
|
var lazyVideos = [].slice.call(document.querySelectorAll("video.lazy"));
|
||||||
|
|
||||||
|
if ("IntersectionObserver" in window) {
|
||||||
|
var lazyVideoObserver = new IntersectionObserver(function(entries, observer) {
|
||||||
|
entries.forEach(function(video) {
|
||||||
|
if (video.isIntersecting) {
|
||||||
|
for (var source in video.target.children) {
|
||||||
|
var videoSource = video.target.children[source];
|
||||||
|
if (typeof videoSource.tagName === "string" && videoSource.tagName === "SOURCE") {
|
||||||
|
videoSource.src = videoSource.dataset.src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
video.target.load();
|
||||||
|
video.target.classList.remove("lazy");
|
||||||
|
lazyVideoObserver.unobserve(video.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
lazyVideos.forEach(function(lazyVideo) {
|
||||||
|
lazyVideoObserver.observe(lazyVideo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// Prevent the <base> tag from affecting links with the class "no-base"
|
||||||
|
document.querySelectorAll('.no-base').forEach(link => {
|
||||||
|
link.addEventListener('click', function(event) {
|
||||||
|
const href = this.getAttribute('href');
|
||||||
|
if (href.startsWith('#')) {
|
||||||
|
window.location.hash = href;
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
imgs/group.png
BIN
imgs/group.png
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
BIN
imgs/pm.png
BIN
imgs/pm.png
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 116 KiB |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "whatsapp-chat-exporter"
|
name = "whatsapp-chat-exporter"
|
||||||
version = "0.12.1"
|
version = "0.13.0rc1"
|
||||||
description = "A Whatsapp database parser that provides history of your Whatsapp conversations in HTML and JSON. Android, iOS, iPadOS, Crypt12, Crypt14, Crypt15 supported."
|
description = "A Whatsapp database parser that provides history of your Whatsapp conversations in HTML and JSON. Android, iOS, iPadOS, Crypt12, Crypt14, Crypt15 supported."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [
|
authors = [
|
||||||
@@ -19,11 +19,11 @@ keywords = [
|
|||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Operating System :: OS Independent",
|
"Operating System :: OS Independent",
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
@@ -33,7 +33,7 @@ classifiers = [
|
|||||||
"Topic :: Utilities",
|
"Topic :: Utilities",
|
||||||
"Topic :: Database"
|
"Topic :: Database"
|
||||||
]
|
]
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jinja2",
|
"jinja2",
|
||||||
"bleach"
|
"bleach"
|
||||||
@@ -44,10 +44,9 @@ android_backup = ["pycryptodome", "javaobj-py3"]
|
|||||||
crypt12 = ["pycryptodome"]
|
crypt12 = ["pycryptodome"]
|
||||||
crypt14 = ["pycryptodome"]
|
crypt14 = ["pycryptodome"]
|
||||||
crypt15 = ["pycryptodome", "javaobj-py3"]
|
crypt15 = ["pycryptodome", "javaobj-py3"]
|
||||||
all = ["pycryptodome", "javaobj-py3", "vobject"]
|
all = ["pycryptodome", "javaobj-py3"]
|
||||||
everything = ["pycryptodome", "javaobj-py3", "vobject"]
|
everything = ["pycryptodome", "javaobj-py3"]
|
||||||
backup = ["pycryptodome", "javaobj-py3"]
|
backup = ["pycryptodome", "javaobj-py3"]
|
||||||
vcards = ["vobject", "pycryptodome", "javaobj-py3"]
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
wtsexporter = "Whatsapp_Chat_Exporter.__main__:main"
|
wtsexporter = "Whatsapp_Chat_Exporter.__main__:main"
|
||||||
@@ -60,3 +59,8 @@ include = ["Whatsapp_Chat_Exporter"]
|
|||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
Whatsapp_Chat_Exporter = ["*.html"]
|
Whatsapp_Chat_Exporter = ["*.html"]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.3.5",
|
||||||
|
]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Contributed by @magpires https://github.com/KnugiHK/WhatsApp-Chat-Exporter/issue
|
|||||||
import re
|
import re
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
def process_phone_number(raw_phone):
|
def process_phone_number(raw_phone):
|
||||||
"""
|
"""
|
||||||
Process the raw phone string from the VCARD and return two formatted numbers:
|
Process the raw phone string from the VCARD and return two formatted numbers:
|
||||||
@@ -70,6 +71,7 @@ def process_phone_number(raw_phone):
|
|||||||
|
|
||||||
return original_formatted, modified_formatted
|
return original_formatted, modified_formatted
|
||||||
|
|
||||||
|
|
||||||
def process_vcard(input_vcard, output_vcard):
|
def process_vcard(input_vcard, output_vcard):
|
||||||
"""
|
"""
|
||||||
Process a VCARD file to standardize telephone entries and add a second TEL line
|
Process a VCARD file to standardize telephone entries and add a second TEL line
|
||||||
@@ -103,6 +105,7 @@ def process_vcard(input_vcard, output_vcard):
|
|||||||
with open(output_vcard, 'w', encoding='utf-8') as file:
|
with open(output_vcard, 'w', encoding='utf-8') as file:
|
||||||
file.writelines(output_lines)
|
file.writelines(output_lines)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Process a VCARD file to standardize telephone entries and add a second TEL line with the modified number (removing the extra ninth digit) for contacts with 9-digit subscribers."
|
description="Process a VCARD file to standardize telephone entries and add a second TEL line with the modified number (removing the extra ninth digit) for contacts with 9-digit subscribers."
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ def _extract_encrypted_key(keyfile):
|
|||||||
return _generate_hmac_of_hmac(key_stream)
|
return _generate_hmac_of_hmac(key_stream)
|
||||||
|
|
||||||
|
|
||||||
key = open("encrypted_backup.key", "rb").read()
|
if __name__ == "__main__":
|
||||||
database = open("wa.db.crypt15", "rb").read()
|
key = open("encrypted_backup.key", "rb").read()
|
||||||
main_key, hex_key = _extract_encrypted_key(key)
|
database = open("wa.db.crypt15", "rb").read()
|
||||||
for i in range(100):
|
main_key, hex_key = _extract_encrypted_key(key)
|
||||||
|
for i in range(100):
|
||||||
iv = database[i:i+16]
|
iv = database[i:i+16]
|
||||||
for j in range(100):
|
for j in range(100):
|
||||||
cipher = AES.new(main_key, AES.MODE_GCM, iv)
|
cipher = AES.new(main_key, AES.MODE_GCM, iv)
|
||||||
@@ -46,4 +47,4 @@ for i in range(100):
|
|||||||
print(db_compressed[:10])
|
print(db_compressed[:10])
|
||||||
exit()
|
exit()
|
||||||
|
|
||||||
print("Not found! Try to increase maximum search.")
|
print("Not found! Try to increase maximum search.")
|
||||||
|
|||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
44
tests/data/contacts.vcf
Normal file
44
tests/data/contacts.vcf
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:3.0
|
||||||
|
FN:Sample Contact
|
||||||
|
TEL;TYPE=CELL:+85288888888
|
||||||
|
END:VCARD
|
||||||
|
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:2.1
|
||||||
|
N:Lopez;Yard Lawn Guy;Jose;;
|
||||||
|
FN:Yard Lawn Guy, Jose Lopez
|
||||||
|
TEL;HOME:5673334444
|
||||||
|
END:VCARD
|
||||||
|
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:2.1
|
||||||
|
N;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:;=4A=6F=68=6E=20=42=75=74=6C=65=72=20=F0=9F=8C=9F=
|
||||||
|
=F0=9F=92=AB=F0=9F=8C=9F;;;
|
||||||
|
FN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:=4A=6F=68=6E=20=42=75=74=6C=65=72=20=F0=9F=8C=9F=
|
||||||
|
=F0=9F=92=AB=F0=9F=8C=9F
|
||||||
|
TEL;PREF:5556667777
|
||||||
|
END:VCARD
|
||||||
|
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:2.1
|
||||||
|
TEL;WORK;PREF:1234567890
|
||||||
|
ORG:Airline Contact #'s
|
||||||
|
NOTE;ENCODING=QUOTED-PRINTABLE:=53=70=69=72=69=74=20=41=69=72=6C=69=
|
||||||
|
=6E=65=73=20=38=30=30=2D=37=37=32=2D=37=31=31=37=55=6E=69=74=65=64=
|
||||||
|
=20=41=69=72=6C=69=6E=65=73=20=38=30=30=2D=32=34=31=2D=36=35=32=32
|
||||||
|
END:VCARD
|
||||||
|
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:2.1
|
||||||
|
TEL;WORK;PREF:3451112222
|
||||||
|
X-SAMSUNGADR;ENCODING=QUOTED-PRINTABLE:;;=31=31=31=31=32=20=4E=6F=72=74=68=20=45=6C=64=72=
|
||||||
|
=69=64=67=65=20=50=61=72=6B=77=61=79;=44=61=6C=6C=61=73;=54=58;=32=32=32=32=32
|
||||||
|
ORG:James Peacock Elementary
|
||||||
|
END:VCARD
|
||||||
|
|
||||||
|
BEGIN:VCARD
|
||||||
|
VERSION:2.1
|
||||||
|
TEL;CELL:8889990001
|
||||||
|
ORG:AAA Car Service
|
||||||
|
END:VCARD
|
||||||
@@ -4,7 +4,8 @@ import tempfile
|
|||||||
import os
|
import os
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from brazilian_number_processing import process_phone_number, process_vcard
|
from scripts.brazilian_number_processing import process_phone_number, process_vcard
|
||||||
|
|
||||||
|
|
||||||
class TestVCardProcessor(unittest.TestCase):
|
class TestVCardProcessor(unittest.TestCase):
|
||||||
|
|
||||||
@@ -248,7 +249,8 @@ END:VCARD
|
|||||||
output_path = input_path + '.out'
|
output_path = input_path + '.out'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
test_args = ['python' if os.name == 'nt' else 'python3', 'brazilian_number_processing.py', input_path, output_path]
|
test_args = ['python' if os.name == 'nt' else 'python3',
|
||||||
|
'scripts/brazilian_number_processing.py', input_path, output_path]
|
||||||
# We're just testing that the argument parsing works
|
# We're just testing that the argument parsing works
|
||||||
subprocess.call(
|
subprocess.call(
|
||||||
test_args,
|
test_args,
|
||||||
@@ -265,5 +267,6 @@ END:VCARD
|
|||||||
if os.path.exists(output_path):
|
if os.path.exists(output_path):
|
||||||
os.unlink(output_path)
|
os.unlink(output_path)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
50
tests/test_exporter.py
Normal file
50
tests/test_exporter.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import subprocess
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def command_runner():
|
||||||
|
"""
|
||||||
|
A pytest fixture to simplify running commands. This is a helper
|
||||||
|
function that you can use in multiple tests.
|
||||||
|
"""
|
||||||
|
def _run_command(command_list, check=True):
|
||||||
|
"""
|
||||||
|
Runs a command and returns the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command_list (list): A list of strings representing the command
|
||||||
|
and its arguments (e.g., ["python", "my_script.py", "arg1"]).
|
||||||
|
check (bool, optional): If True, raise an exception if the
|
||||||
|
command returns a non-zero exit code. Defaults to True.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
subprocess.CompletedProcess: The result of the command.
|
||||||
|
"""
|
||||||
|
return subprocess.run(
|
||||||
|
command_list,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=check,
|
||||||
|
)
|
||||||
|
return _run_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanity_check(command_runner):
|
||||||
|
"""
|
||||||
|
This is a basic sanity check to make sure all modules can be imported
|
||||||
|
This runs the exporter without any arguments. It should fail with a
|
||||||
|
message about missing arguments.
|
||||||
|
"""
|
||||||
|
result = command_runner(["wtsexporter"], False)
|
||||||
|
expected_stderr = "You must define the device type"
|
||||||
|
assert expected_stderr in result.stderr, f"STDERR was: {result.stderr}"
|
||||||
|
assert result.returncode == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_android(command_runner):
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
def test_ios(command_runner):
|
||||||
|
...
|
||||||
341
tests/test_incremental_merge.py
Normal file
341
tests/test_incremental_merge.py
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, mock_open, call, MagicMock
|
||||||
|
from Whatsapp_Chat_Exporter.utility import incremental_merge
|
||||||
|
from Whatsapp_Chat_Exporter.data_model import ChatStore
|
||||||
|
|
||||||
|
# Test data setup
|
||||||
|
BASE_PATH = "AppDomainGroup-group.net.whatsapp.WhatsApp.shared"
|
||||||
|
chat_data_1 = {
|
||||||
|
"12345678@s.whatsapp.net": {
|
||||||
|
"name": "Friend",
|
||||||
|
"type": "ios",
|
||||||
|
"my_avatar": os.path.join(BASE_PATH, "Media", "Profile", "Photo.jpg"),
|
||||||
|
"their_avatar": os.path.join(BASE_PATH, "Media", "Profile", "12345678-1709851420.thumb"),
|
||||||
|
"their_avatar_thumb": None,
|
||||||
|
"status": None,
|
||||||
|
"messages": {
|
||||||
|
"24690": {
|
||||||
|
"from_me": True,
|
||||||
|
"timestamp": 1463926635.571629,
|
||||||
|
"time": "10:17",
|
||||||
|
"media": False,
|
||||||
|
"key_id": "34B5EF10FBCA37B7E",
|
||||||
|
"meta": False,
|
||||||
|
"data": "I'm here",
|
||||||
|
"safe": False,
|
||||||
|
"sticker": False
|
||||||
|
},
|
||||||
|
"24691": { # This message only exists in target
|
||||||
|
"from_me": False,
|
||||||
|
"timestamp": 1463926641.571629,
|
||||||
|
"time": "10:17",
|
||||||
|
"media": False,
|
||||||
|
"key_id": "34B5EF10FBCA37B8E",
|
||||||
|
"meta": False,
|
||||||
|
"data": "Great to see you",
|
||||||
|
"safe": False,
|
||||||
|
"sticker": False
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chat_data_2 = {
|
||||||
|
"12345678@s.whatsapp.net": {
|
||||||
|
"name": "Friend",
|
||||||
|
"type": "ios",
|
||||||
|
"my_avatar": os.path.join(BASE_PATH, "Media", "Profile", "Photo.jpg"),
|
||||||
|
"their_avatar": os.path.join(BASE_PATH, "Media", "Profile", "12345678-1709851420.thumb"),
|
||||||
|
"their_avatar_thumb": None,
|
||||||
|
"status": None,
|
||||||
|
"messages": {
|
||||||
|
"24690": {
|
||||||
|
"from_me": True,
|
||||||
|
"timestamp": 1463926635.571629,
|
||||||
|
"time": "10:17",
|
||||||
|
"media": False,
|
||||||
|
"key_id": "34B5EF10FBCA37B7E",
|
||||||
|
"meta": False,
|
||||||
|
"data": "I'm here",
|
||||||
|
"safe": False,
|
||||||
|
"sticker": False
|
||||||
|
},
|
||||||
|
"24692": { # This message only exists in source
|
||||||
|
"from_me": False,
|
||||||
|
"timestamp": 1463926642.571629,
|
||||||
|
"time": "10:17",
|
||||||
|
"media": False,
|
||||||
|
"key_id": "34B5EF10FBCA37B9E",
|
||||||
|
"meta": False,
|
||||||
|
"data": "Hi there!",
|
||||||
|
"safe": False,
|
||||||
|
"sticker": False
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Expected merged data - should contain all messages with all fields initialized as they would be by Message class
|
||||||
|
chat_data_merged = {
|
||||||
|
"12345678@s.whatsapp.net": {
|
||||||
|
"name": "Friend",
|
||||||
|
"type": "ios",
|
||||||
|
"my_avatar": os.path.join(BASE_PATH, "Media", "Profile", "Photo.jpg"),
|
||||||
|
"their_avatar": os.path.join(BASE_PATH, "Media", "Profile", "12345678-1709851420.thumb"),
|
||||||
|
"their_avatar_thumb": None,
|
||||||
|
"status": None,
|
||||||
|
"media_base": "",
|
||||||
|
"messages": {
|
||||||
|
"24690": {
|
||||||
|
"from_me": True,
|
||||||
|
"timestamp": 1463926635.571629,
|
||||||
|
"time": "10:17",
|
||||||
|
"media": False,
|
||||||
|
"key_id": "34B5EF10FBCA37B7E",
|
||||||
|
"meta": False,
|
||||||
|
"data": "I'm here",
|
||||||
|
"sender": None,
|
||||||
|
"safe": False,
|
||||||
|
"mime": None,
|
||||||
|
"reply": None,
|
||||||
|
"quoted_data": None,
|
||||||
|
"caption": None,
|
||||||
|
"thumb": None,
|
||||||
|
"sticker": False,
|
||||||
|
"message_type": None,
|
||||||
|
"received_timestamp": None,
|
||||||
|
"read_timestamp": None
|
||||||
|
},
|
||||||
|
"24691": {
|
||||||
|
"from_me": False,
|
||||||
|
"timestamp": 1463926641.571629,
|
||||||
|
"time": "10:17",
|
||||||
|
"media": False,
|
||||||
|
"key_id": "34B5EF10FBCA37B8E",
|
||||||
|
"meta": False,
|
||||||
|
"data": "Great to see you",
|
||||||
|
"sender": None,
|
||||||
|
"safe": False,
|
||||||
|
"mime": None,
|
||||||
|
"reply": None,
|
||||||
|
"quoted_data": None,
|
||||||
|
"caption": None,
|
||||||
|
"thumb": None,
|
||||||
|
"sticker": False,
|
||||||
|
"message_type": None,
|
||||||
|
"received_timestamp": None,
|
||||||
|
"read_timestamp": None
|
||||||
|
},
|
||||||
|
"24692": {
|
||||||
|
"from_me": False,
|
||||||
|
"timestamp": 1463926642.571629,
|
||||||
|
"time": "10:17",
|
||||||
|
"media": False,
|
||||||
|
"key_id": "34B5EF10FBCA37B9E",
|
||||||
|
"meta": False,
|
||||||
|
"data": "Hi there!",
|
||||||
|
"sender": None,
|
||||||
|
"safe": False,
|
||||||
|
"mime": None,
|
||||||
|
"reply": None,
|
||||||
|
"quoted_data": None,
|
||||||
|
"caption": None,
|
||||||
|
"thumb": None,
|
||||||
|
"sticker": False,
|
||||||
|
"message_type": None,
|
||||||
|
"received_timestamp": None,
|
||||||
|
"read_timestamp": None
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_filesystem():
|
||||||
|
with (
|
||||||
|
patch("os.path.exists") as mock_exists,
|
||||||
|
patch("os.makedirs") as mock_makedirs,
|
||||||
|
patch("os.path.getmtime") as mock_getmtime,
|
||||||
|
patch("os.listdir") as mock_listdir,
|
||||||
|
patch("os.walk") as mock_walk,
|
||||||
|
patch("shutil.copy2") as mock_copy2,
|
||||||
|
):
|
||||||
|
yield {
|
||||||
|
"exists": mock_exists,
|
||||||
|
"makedirs": mock_makedirs,
|
||||||
|
"getmtime": mock_getmtime,
|
||||||
|
"listdir": mock_listdir,
|
||||||
|
"walk": mock_walk,
|
||||||
|
"copy2": mock_copy2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_incremental_merge_new_file(mock_filesystem):
|
||||||
|
"""Test merging when target file doesn't exist"""
|
||||||
|
source_dir = "/source"
|
||||||
|
target_dir = "/target"
|
||||||
|
media_dir = "media"
|
||||||
|
|
||||||
|
# Setup mock filesystem
|
||||||
|
mock_filesystem["exists"].side_effect = lambda x: x == "/source"
|
||||||
|
mock_filesystem["listdir"].return_value = ["chat.json"]
|
||||||
|
|
||||||
|
# Run the function
|
||||||
|
incremental_merge(source_dir, target_dir, media_dir, 2, True)
|
||||||
|
|
||||||
|
# Verify the operations
|
||||||
|
mock_filesystem["makedirs"].assert_called_once_with(target_dir, exist_ok=True)
|
||||||
|
mock_filesystem["copy2"].assert_called_once_with(
|
||||||
|
os.path.join(source_dir, "chat.json"),
|
||||||
|
os.path.join(target_dir, "chat.json")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_incremental_merge_existing_file_with_changes(mock_filesystem):
|
||||||
|
"""Test merging when target file exists and has changes"""
|
||||||
|
source_dir = "source"
|
||||||
|
target_dir = "target"
|
||||||
|
media_dir = "media"
|
||||||
|
|
||||||
|
# Setup mock filesystem
|
||||||
|
mock_filesystem["exists"].side_effect = lambda x: True
|
||||||
|
mock_filesystem["listdir"].return_value = ["chat.json"]
|
||||||
|
|
||||||
|
# Mock file operations with consistent path separators
|
||||||
|
source_file = os.path.join(source_dir, "chat.json")
|
||||||
|
target_file = os.path.join(target_dir, "chat.json")
|
||||||
|
mock_file_content = {
|
||||||
|
source_file: json.dumps(chat_data_2),
|
||||||
|
target_file: json.dumps(chat_data_1),
|
||||||
|
}
|
||||||
|
|
||||||
|
written_chunks = []
|
||||||
|
|
||||||
|
def mock_file_write(data):
|
||||||
|
written_chunks.append(data)
|
||||||
|
|
||||||
|
mock_write = MagicMock(side_effect=mock_file_write)
|
||||||
|
|
||||||
|
with patch("builtins.open", mock_open()) as mock_file:
|
||||||
|
def mock_file_read(filename, mode="r"):
|
||||||
|
if mode == 'w':
|
||||||
|
file_mock = mock_open().return_value
|
||||||
|
file_mock.write.side_effect = mock_write
|
||||||
|
return file_mock
|
||||||
|
else:
|
||||||
|
# Use normalized path for lookup
|
||||||
|
norm_filename = os.path.normpath(filename)
|
||||||
|
content = mock_file_content.get(norm_filename, '')
|
||||||
|
file_mock = mock_open(read_data=content).return_value
|
||||||
|
return file_mock
|
||||||
|
|
||||||
|
mock_file.side_effect = mock_file_read
|
||||||
|
|
||||||
|
# Run the function
|
||||||
|
incremental_merge(source_dir, target_dir, media_dir, 2, True)
|
||||||
|
|
||||||
|
# Verify file operations using os.path.join
|
||||||
|
mock_file.assert_any_call(source_file, "r")
|
||||||
|
mock_file.assert_any_call(target_file, "r")
|
||||||
|
mock_file.assert_any_call(target_file, "w")
|
||||||
|
|
||||||
|
# Rest of verification code...
|
||||||
|
assert mock_write.called, "Write method was never called"
|
||||||
|
written_data = json.loads(''.join(written_chunks))
|
||||||
|
assert written_data is not None, "No data was written"
|
||||||
|
assert written_data == chat_data_merged, "Merged data does not match expected result"
|
||||||
|
|
||||||
|
messages = written_data["12345678@s.whatsapp.net"]["messages"]
|
||||||
|
assert "24690" in messages, "Common message should be present"
|
||||||
|
assert "24691" in messages, "Target-only message should be preserved"
|
||||||
|
assert "24692" in messages, "Source-only message should be added"
|
||||||
|
assert len(messages) == 3, "Should have exactly 3 messages"
|
||||||
|
|
||||||
|
|
||||||
|
def test_incremental_merge_existing_file_no_changes(mock_filesystem):
|
||||||
|
"""Test merging when target file exists but has no changes"""
|
||||||
|
source_dir = "source"
|
||||||
|
target_dir = "target"
|
||||||
|
media_dir = "media"
|
||||||
|
|
||||||
|
# Setup mock filesystem
|
||||||
|
mock_filesystem["exists"].side_effect = lambda x: True
|
||||||
|
mock_filesystem["listdir"].return_value = ["chat.json"]
|
||||||
|
|
||||||
|
# Mock file operations with consistent path separators
|
||||||
|
source_file = os.path.join(source_dir, "chat.json")
|
||||||
|
target_file = os.path.join(target_dir, "chat.json")
|
||||||
|
mock_file_content = {
|
||||||
|
source_file: json.dumps(chat_data_1),
|
||||||
|
target_file: json.dumps(chat_data_1),
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("builtins.open", mock_open()) as mock_file:
|
||||||
|
def mock_file_read(filename, mode="r"):
|
||||||
|
if mode == 'w':
|
||||||
|
file_mock = mock_open().return_value
|
||||||
|
return file_mock
|
||||||
|
else:
|
||||||
|
# Use normalized path for lookup
|
||||||
|
norm_filename = os.path.normpath(filename)
|
||||||
|
content = mock_file_content.get(norm_filename, '')
|
||||||
|
file_mock = mock_open(read_data=content).return_value
|
||||||
|
return file_mock
|
||||||
|
|
||||||
|
mock_file.side_effect = mock_file_read
|
||||||
|
|
||||||
|
# Run the function
|
||||||
|
incremental_merge(source_dir, target_dir, media_dir, 2, True)
|
||||||
|
|
||||||
|
# Verify no write operations occurred on target file
|
||||||
|
write_calls = [
|
||||||
|
call for call in mock_file.mock_calls if call[0] == "().write"]
|
||||||
|
assert len(write_calls) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_incremental_merge_media_copy(mock_filesystem):
|
||||||
|
"""Test media file copying during merge"""
|
||||||
|
source_dir = "source"
|
||||||
|
target_dir = "target"
|
||||||
|
media_dir = "media"
|
||||||
|
|
||||||
|
# Setup mock filesystem
|
||||||
|
mock_filesystem["exists"].side_effect = lambda x: True
|
||||||
|
mock_filesystem["listdir"].return_value = ["chat.json"]
|
||||||
|
mock_filesystem["walk"].return_value = [
|
||||||
|
(os.path.join(source_dir, "media"), ["subfolder"], ["file1.jpg"]),
|
||||||
|
(os.path.join(source_dir, "media", "subfolder"), [], ["file2.jpg"]),
|
||||||
|
]
|
||||||
|
mock_filesystem["getmtime"].side_effect = lambda x: 1000 if "source" in x else 500
|
||||||
|
|
||||||
|
# Mock file operations with consistent path separators
|
||||||
|
source_file = os.path.join(source_dir, "chat.json")
|
||||||
|
target_file = os.path.join(target_dir, "chat.json")
|
||||||
|
mock_file_content = {
|
||||||
|
source_file: json.dumps(chat_data_1),
|
||||||
|
target_file: json.dumps(chat_data_1),
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("builtins.open", mock_open()) as mock_file:
|
||||||
|
def mock_file_read(filename, mode="r"):
|
||||||
|
if mode == 'w':
|
||||||
|
file_mock = mock_open().return_value
|
||||||
|
return file_mock
|
||||||
|
else:
|
||||||
|
# Use normalized path for lookup
|
||||||
|
norm_filename = os.path.normpath(filename)
|
||||||
|
content = mock_file_content.get(norm_filename, '')
|
||||||
|
file_mock = mock_open(read_data=content).return_value
|
||||||
|
return file_mock
|
||||||
|
|
||||||
|
mock_file.side_effect = mock_file_read
|
||||||
|
|
||||||
|
# Run the function
|
||||||
|
incremental_merge(source_dir, target_dir, media_dir, 2, True)
|
||||||
|
|
||||||
|
# Verify media file operations
|
||||||
|
assert mock_filesystem["makedirs"].call_count >= 2 # At least target dir and media dir
|
||||||
|
assert mock_filesystem["copy2"].call_count == 2 # Two media files copied
|
||||||
76
tests/test_nuitka_binary.py
Normal file
76
tests/test_nuitka_binary.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def command_runner():
|
||||||
|
"""
|
||||||
|
A pytest fixture to simplify running commands. This is a helper
|
||||||
|
function that you can use in multiple tests.
|
||||||
|
"""
|
||||||
|
def _run_command(command_list, check=True):
|
||||||
|
"""
|
||||||
|
Runs a command and returns the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
command_list (list): A list of strings representing the command
|
||||||
|
and its arguments (e.g., ["python", "my_script.py", "arg1"]).
|
||||||
|
check (bool, optional): If True, raise an exception if the
|
||||||
|
command returns a non-zero exit code. Defaults to True.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
subprocess.CompletedProcess: The result of the command.
|
||||||
|
"""
|
||||||
|
return subprocess.run(
|
||||||
|
command_list,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=check,
|
||||||
|
)
|
||||||
|
return _run_command
|
||||||
|
|
||||||
|
|
||||||
|
def test_nuitka_binary():
|
||||||
|
"""
|
||||||
|
Tests the creation and execution of a Nuitka-compiled binary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 14):
|
||||||
|
print("Skipping Nuitka test: Python 3.14 is not yet fully supported by Nuitka.")
|
||||||
|
return
|
||||||
|
|
||||||
|
nuitka_command = [
|
||||||
|
"python", "-m", "nuitka", "--onefile", "--assume-yes-for-downloads",
|
||||||
|
"--include-data-file=./Whatsapp_Chat_Exporter/whatsapp.html=./Whatsapp_Chat_Exporter/whatsapp.html",
|
||||||
|
"Whatsapp_Chat_Exporter",
|
||||||
|
"--output-filename=wtsexporter.exe" # use .exe on all platforms for compatibility
|
||||||
|
]
|
||||||
|
|
||||||
|
compile_result = subprocess.run(
|
||||||
|
nuitka_command,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
print(f"Nuitka compilation output: {compile_result.stdout}")
|
||||||
|
|
||||||
|
binary_path = "./wtsexporter.exe"
|
||||||
|
assert os.path.exists(binary_path), f"Binary {binary_path} was not created."
|
||||||
|
|
||||||
|
try:
|
||||||
|
execute_result = subprocess.run(
|
||||||
|
[binary_path, "--help"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
print(f"Binary execution output: {execute_result.stdout}")
|
||||||
|
assert "usage:" in execute_result.stdout.lower(), "Binary did not produce expected help output."
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Binary execution failed with error: {e.stderr}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if os.path.exists(binary_path):
|
||||||
|
os.remove(binary_path)
|
||||||
256
tests/test_utility.py
Normal file
256
tests/test_utility.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
import pytest
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from unittest.mock import patch, mock_open, MagicMock
|
||||||
|
from Whatsapp_Chat_Exporter.utility import *
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_time_unit():
|
||||||
|
assert convert_time_unit(0) == "less than a second"
|
||||||
|
assert convert_time_unit(1) == "a second"
|
||||||
|
assert convert_time_unit(10) == "10 seconds"
|
||||||
|
assert convert_time_unit(60) == "1 minute"
|
||||||
|
assert convert_time_unit(61) == "1 minute 1 second"
|
||||||
|
assert convert_time_unit(122) == "2 minutes 2 seconds"
|
||||||
|
assert convert_time_unit(3600) == "1 hour"
|
||||||
|
assert convert_time_unit(3661) == "1 hour 1 minute 1 second"
|
||||||
|
assert convert_time_unit(3720) == "1 hour 2 minutes"
|
||||||
|
assert convert_time_unit(3660) == "1 hour 1 minute"
|
||||||
|
assert convert_time_unit(7263) == "2 hours 1 minute 3 seconds"
|
||||||
|
assert convert_time_unit(86400) == "1 day"
|
||||||
|
assert convert_time_unit(86461) == "1 day 1 minute 1 second"
|
||||||
|
assert convert_time_unit(172805) == "2 days 5 seconds"
|
||||||
|
|
||||||
|
|
||||||
|
class TestBytesToReadable:
|
||||||
|
assert bytes_to_readable(0) == "0 B"
|
||||||
|
assert bytes_to_readable(500) == "500 B"
|
||||||
|
assert bytes_to_readable(1024) == "1.0 KB"
|
||||||
|
assert bytes_to_readable(2048) == "2.0 KB"
|
||||||
|
assert bytes_to_readable(1536) == "1.5 KB"
|
||||||
|
assert bytes_to_readable(1024**2) == "1.0 MB"
|
||||||
|
assert bytes_to_readable(5 * 1024**2) == "5.0 MB"
|
||||||
|
assert bytes_to_readable(1024**3) == "1.0 GB"
|
||||||
|
assert bytes_to_readable(1024**4) == "1.0 TB"
|
||||||
|
assert bytes_to_readable(1024**5) == "1.0 PB"
|
||||||
|
assert bytes_to_readable(1024**6) == "1.0 EB"
|
||||||
|
assert bytes_to_readable(1024**7) == "1.0 ZB"
|
||||||
|
assert bytes_to_readable(1024**8) == "1.0 YB"
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadableToBytes:
|
||||||
|
def test_conversion(self):
|
||||||
|
assert readable_to_bytes("0B") == 0
|
||||||
|
assert readable_to_bytes("100B") == 100
|
||||||
|
assert readable_to_bytes("50 B") == 50
|
||||||
|
assert readable_to_bytes("1KB") == 1024
|
||||||
|
assert readable_to_bytes("2.5 KB") == 2560
|
||||||
|
assert readable_to_bytes("2.0 KB") == 2048
|
||||||
|
assert readable_to_bytes("1MB") == 1024**2
|
||||||
|
assert readable_to_bytes("0.5 MB") == 524288
|
||||||
|
assert readable_to_bytes("1. MB") == 1048576
|
||||||
|
assert readable_to_bytes("1GB") == 1024**3
|
||||||
|
assert readable_to_bytes("1.GB") == 1024**3
|
||||||
|
assert readable_to_bytes("1TB") == 1024**4
|
||||||
|
assert readable_to_bytes("1PB") == 1024**5
|
||||||
|
assert readable_to_bytes("1EB") == 1024**6
|
||||||
|
assert readable_to_bytes("1ZB") == 1024**7
|
||||||
|
assert readable_to_bytes("1YB") == 1024**8
|
||||||
|
|
||||||
|
def test_case_insensitivity(self):
|
||||||
|
assert readable_to_bytes("1kb") == 1024
|
||||||
|
assert readable_to_bytes("2mB") == 2 * 1024**2
|
||||||
|
|
||||||
|
def test_whitespace(self):
|
||||||
|
assert readable_to_bytes(" 10 KB ") == 10 * 1024
|
||||||
|
assert readable_to_bytes(" 1 MB") == 1024**2
|
||||||
|
|
||||||
|
def test_invalid_unit(self):
|
||||||
|
with pytest.raises(ValueError, match="Invalid size format for size_str"):
|
||||||
|
readable_to_bytes("100X")
|
||||||
|
readable_to_bytes("A100")
|
||||||
|
readable_to_bytes("100$$$$$")
|
||||||
|
|
||||||
|
def test_invalid_number(self):
|
||||||
|
with pytest.raises(ValueError, match="Invalid size format for size_str"):
|
||||||
|
readable_to_bytes("ABC KB")
|
||||||
|
|
||||||
|
def test_missing_unit(self):
|
||||||
|
assert readable_to_bytes("100") == 100
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizeExcept:
|
||||||
|
def test_no_tags(self):
|
||||||
|
html = "This is plain text."
|
||||||
|
assert sanitize_except(html) == Markup("This is plain text.")
|
||||||
|
|
||||||
|
def test_allowed_br_tag(self):
|
||||||
|
html = "Line 1<br>Line 2"
|
||||||
|
assert sanitize_except(html) == Markup("Line 1<br>Line 2")
|
||||||
|
html = "<br/>Line"
|
||||||
|
assert sanitize_except(html) == Markup("<br>Line")
|
||||||
|
html = "Line<br />"
|
||||||
|
assert sanitize_except(html) == Markup("Line<br>")
|
||||||
|
|
||||||
|
def test_mixed_tags(self):
|
||||||
|
html = "<b>Bold</b><br><i>Italic</i><img src='evil.gif'><script>alert('XSS')</script>"
|
||||||
|
assert sanitize_except(html) == Markup(
|
||||||
|
"<b>Bold</b><br><i>Italic</i><img src='evil.gif'><script>alert('XSS')</script>")
|
||||||
|
|
||||||
|
def test_attribute_stripping(self):
|
||||||
|
html = "<br class='someclass'>"
|
||||||
|
assert sanitize_except(html) == Markup("<br>")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetermineDay:
|
||||||
|
def test_same_day(self):
|
||||||
|
timestamp1 = 1678838400 # March 15, 2023 00:00:00 GMT
|
||||||
|
timestamp2 = 1678881600 # March 15, 2023 12:00:00 GMT
|
||||||
|
assert determine_day(timestamp1, timestamp2) is None
|
||||||
|
|
||||||
|
def test_different_day(self):
|
||||||
|
timestamp1 = 1678886400 # March 15, 2023 00:00:00 GMT
|
||||||
|
timestamp2 = 1678972800 # March 16, 2023 00:00:00 GMT
|
||||||
|
assert determine_day(timestamp1, timestamp2) == datetime(2023, 3, 16).date()
|
||||||
|
|
||||||
|
def test_crossing_month(self):
|
||||||
|
timestamp1 = 1680220800 # March 31, 2023 00:00:00 GMT
|
||||||
|
timestamp2 = 1680307200 # April 1, 2023 00:00:00 GMT
|
||||||
|
assert determine_day(timestamp1, timestamp2) == datetime(2023, 4, 1).date()
|
||||||
|
|
||||||
|
def test_crossing_year(self):
|
||||||
|
timestamp1 = 1703980800 # December 31, 2023 00:00:00 GMT
|
||||||
|
timestamp2 = 1704067200 # January 1, 2024 00:00:00 GMT
|
||||||
|
assert determine_day(timestamp1, timestamp2) == datetime(2024, 1, 1).date()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetFileName:
|
||||||
|
def test_valid_contact_phone_number_no_chat_name(self):
|
||||||
|
chat = ChatStore(Device.ANDROID, name=None)
|
||||||
|
filename, name = get_file_name("1234567890@s.whatsapp.net", chat)
|
||||||
|
assert filename == "1234567890"
|
||||||
|
assert name == "1234567890"
|
||||||
|
|
||||||
|
def test_valid_contact_phone_number_with_chat_name(self):
|
||||||
|
chat = ChatStore(Device.IOS, name="My Chat Group")
|
||||||
|
filename, name = get_file_name("1234567890@s.whatsapp.net", chat)
|
||||||
|
assert filename == "1234567890-My-Chat-Group"
|
||||||
|
assert name == "My Chat Group"
|
||||||
|
|
||||||
|
def test_valid_contact_exported_chat(self):
|
||||||
|
chat = ChatStore(Device.ANDROID, name="Testing")
|
||||||
|
filename, name = get_file_name("ExportedChat", chat)
|
||||||
|
assert filename == "ExportedChat-Testing"
|
||||||
|
assert name == "Testing"
|
||||||
|
|
||||||
|
def test_valid_contact_special_ids(self):
|
||||||
|
chat = ChatStore(Device.ANDROID, name="Special Chat")
|
||||||
|
filename_000, name_000 = get_file_name("000000000000000", chat)
|
||||||
|
assert filename_000 == "000000000000000-Special-Chat"
|
||||||
|
assert name_000 == "Special Chat"
|
||||||
|
filename_001, name_001 = get_file_name("000000000000001", chat)
|
||||||
|
assert filename_001 == "000000000000001-Special-Chat"
|
||||||
|
assert name_001 == "Special Chat"
|
||||||
|
|
||||||
|
def test_unexpected_contact_format(self):
|
||||||
|
chat = ChatStore(Device.ANDROID, name="Some Chat")
|
||||||
|
with pytest.raises(ValueError, match="Unexpected contact format: invalid-contact"):
|
||||||
|
get_file_name("invalid-contact", chat)
|
||||||
|
|
||||||
|
def test_contact_with_hyphen_and_chat_name(self):
|
||||||
|
chat = ChatStore(Device.ANDROID, name="Another Chat")
|
||||||
|
filename, name = get_file_name("123-456-7890@g.us", chat)
|
||||||
|
assert filename == "Another-Chat"
|
||||||
|
assert name == "Another Chat"
|
||||||
|
|
||||||
|
def test_contact_with_hyphen_no_chat_name(self):
|
||||||
|
chat = ChatStore(Device.ANDROID, name=None)
|
||||||
|
filename, name = get_file_name("123-456-7890@g.us", chat)
|
||||||
|
assert filename == "123-456-7890"
|
||||||
|
assert name == "123-456-7890"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetCondForEmpty:
|
||||||
|
def test_enable_true(self):
|
||||||
|
condition = get_cond_for_empty(True, "c.jid", "c.broadcast")
|
||||||
|
assert condition == "AND (chat.hidden=0 OR c.jid='status@broadcast' OR c.broadcast>0)"
|
||||||
|
|
||||||
|
def test_enable_false(self):
|
||||||
|
condition = get_cond_for_empty(False, "other_jid", "other_broadcast")
|
||||||
|
assert condition == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetChatCondition:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetStatusLocation:
|
||||||
|
@patch('os.path.isdir')
|
||||||
|
@patch('os.path.isfile')
|
||||||
|
@patch('os.mkdir')
|
||||||
|
@patch('urllib.request.urlopen')
|
||||||
|
@patch('builtins.open', new_callable=mock_open)
|
||||||
|
def test_offline_static_set(self, mock_open_file, mock_urlopen, mock_mkdir, mock_isfile, mock_isdir):
|
||||||
|
mock_isdir.return_value = False
|
||||||
|
mock_isfile.return_value = False
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = b'W3.CSS Content'
|
||||||
|
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||||
|
output_folder = "output_folder"
|
||||||
|
offline_static = "offline_static"
|
||||||
|
|
||||||
|
result = get_status_location(output_folder, offline_static)
|
||||||
|
|
||||||
|
assert result == os.path.join(offline_static, "w3.css")
|
||||||
|
mock_mkdir.assert_called_once_with(os.path.join(output_folder, offline_static))
|
||||||
|
mock_urlopen.assert_called_once_with("https://www.w3schools.com/w3css/4/w3.css")
|
||||||
|
mock_open_file.assert_called_once_with(os.path.join(output_folder, offline_static, "w3.css"), "wb")
|
||||||
|
mock_open_file().write.assert_called_once_with(b'W3.CSS Content')
|
||||||
|
|
||||||
|
def test_offline_static_not_set(self):
|
||||||
|
result = get_status_location("output_folder", "")
|
||||||
|
assert result == "https://www.w3schools.com/w3css/4/w3.css"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSafeName:
|
||||||
|
def generate_random_string(length=50):
|
||||||
|
random.seed(10)
|
||||||
|
return ''.join(random.choice(string.ascii_letters + string.digits + "äöüß") for _ in range(length))
|
||||||
|
|
||||||
|
safe_name_test_cases = [
|
||||||
|
("This is a test string", "This-is-a-test-string"),
|
||||||
|
("This is a test string with special characters!@#$%^&*()",
|
||||||
|
"This-is-a-test-string-with-special-characters"),
|
||||||
|
("This is a test string with numbers 1234567890", "This-is-a-test-string-with-numbers-1234567890"),
|
||||||
|
("This is a test string with mixed case ThisIsATestString",
|
||||||
|
"This-is-a-test-string-with-mixed-case-ThisIsATestString"),
|
||||||
|
("This is a test string with extra spaces \u00A0 \u00A0 \u00A0 ThisIsATestString",
|
||||||
|
"This-is-a-test-string-with-extra-spaces-ThisIsATestString"),
|
||||||
|
("This is a test string with unicode characters äöüß",
|
||||||
|
"This-is-a-test-string-with-unicode-characters-äöüß"),
|
||||||
|
("這是一個包含中文的測試字符串", "這是一個包含中文的測試字符串"), # Chinese characters, should stay as is
|
||||||
|
(
|
||||||
|
f"This is a test string with long length {generate_random_string(1000)}",
|
||||||
|
f"This-is-a-test-string-with-long-length-{generate_random_string(1000)}",
|
||||||
|
),
|
||||||
|
("", ""), # Empty string
|
||||||
|
(" ", ""), # String with only space
|
||||||
|
("---", "---"), # String with only hyphens
|
||||||
|
("___", "___"), # String with only underscores
|
||||||
|
("a" * 100, "a" * 100), # Long string with single character
|
||||||
|
("a-b-c-d-e", "a-b-c-d-e"), # String with hyphen
|
||||||
|
("a_b_c_d_e", "a_b_c_d_e"), # String with underscore
|
||||||
|
("a b c d e", "a-b-c-d-e"), # String with spaces
|
||||||
|
("test.com/path/to/resource?param1=value1¶m2=value2",
|
||||||
|
"test.compathtoresourceparam1value1param2value2"), # Test with URL
|
||||||
|
("filename.txt", "filename.txt"), # Test with filename
|
||||||
|
("Αυτή είναι μια δοκιμαστική συμβολοσειρά με ελληνικούς χαρακτήρες.",
|
||||||
|
"Αυτή-είναι-μια-δοκιμαστική-συμβολοσειρά-με-ελληνικούς-χαρακτήρες."), # Greek characters
|
||||||
|
("This is a test with комбинированные знаки ̆ example",
|
||||||
|
"This-is-a-test-with-комбинированные-знаки-example") # Mixed with unicode
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("input_text, expected_output", safe_name_test_cases)
|
||||||
|
def test_safe_name(self, input_text, expected_output):
|
||||||
|
result = safe_name(input_text)
|
||||||
|
assert result == expected_output
|
||||||
48
tests/test_vcards_contacts.py
Normal file
48
tests/test_vcards_contacts.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# from contacts_names_from_vcards import readVCardsFile
|
||||||
|
|
||||||
|
import os
|
||||||
|
from Whatsapp_Chat_Exporter.vcards_contacts import normalize_number, read_vcards_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_readVCardsFile():
|
||||||
|
data_dir = os.path.join(os.path.dirname(__file__), "data")
|
||||||
|
data = read_vcards_file(os.path.join(data_dir, "contacts.vcf"), "852")
|
||||||
|
if data:
|
||||||
|
print("Found Names")
|
||||||
|
print("-----------------------")
|
||||||
|
for count, contact_tuple in enumerate(data, start=1):
|
||||||
|
# The name is the second element of the tuple (at index 1)
|
||||||
|
name = contact_tuple[1]
|
||||||
|
|
||||||
|
# Print the count and the name
|
||||||
|
print(f"{count}. {name}")
|
||||||
|
print(data)
|
||||||
|
assert len(data) == 6
|
||||||
|
# Test simple contact name
|
||||||
|
assert data[0][1] == "Sample Contact"
|
||||||
|
# Test complex name
|
||||||
|
assert data[1][1] == "Yard Lawn Guy, Jose Lopez"
|
||||||
|
# Test name with emoji
|
||||||
|
assert data[2][1] == "John Butler 🌟💫🌟"
|
||||||
|
# Test note with multi-line encoding
|
||||||
|
assert data[3][1] == "Airline Contact #'s"
|
||||||
|
# Test address with multi-line encoding
|
||||||
|
assert data[4][1] == "James Peacock Elementary"
|
||||||
|
# Test business entry using ORG but not F/FN
|
||||||
|
assert data[5][1] == "AAA Car Service"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_number_to_name_dicts():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_fuzzy_match_numbers():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_number():
|
||||||
|
assert normalize_number('0531234567', '1') == '1531234567'
|
||||||
|
assert normalize_number('001531234567', '2') == '1531234567'
|
||||||
|
assert normalize_number('+1531234567', '34') == '1531234567'
|
||||||
|
assert normalize_number('053(123)4567', '34') == '34531234567'
|
||||||
|
assert normalize_number('0531-234-567', '58') == '58531234567'
|
||||||
Reference in New Issue
Block a user