mirror of
https://github.com/KnugiHK/WhatsApp-Chat-Exporter.git
synced 2026-09-08 20:16:55 +02:00
Compare commits
16 Commits
cf1f37db38
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e9663499a2 | |||
| f4f572e3ea | |||
| 12981a148b | |||
| 3ecb74b0bd | |||
| 68089f5ae4 | |||
| 1729a39e5e | |||
| 8fa658c7a7 | |||
| cac2a2464d | |||
| 2ebb389ad1 | |||
| 0056204d87 | |||
| abf4f3c814 | |||
| 9e138d3a1f | |||
| 18a0d822b3 | |||
| bb860533d5 | |||
| a2bcc39e63 | |||
| a0719bc2bf |
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# Add any setup steps before running the `github/codeql-action/init` action.
|
||||
# This includes steps like installing compilers or runtimes (`actions/setup-node`
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
--assume-yes-for-downloads Whatsapp_Chat_Exporter --output-filename=wtsexporter_linux_x64
|
||||
sha256sum wtsexporter_linux_x64
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-path: ./wtsexporter_linux_x64
|
||||
- uses: actions/upload-artifact@v7
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
windows-x64:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
windows-arm:
|
||||
runs-on: windows-11-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
macos-arm:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
macos-intel:
|
||||
runs-on: macos-15-intel
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -143,7 +143,7 @@ jobs:
|
||||
mv wtsexporter wtsexporter_macos_x64
|
||||
shasum -a 256 wtsexporter_macos_x64
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v3
|
||||
uses: actions/attest-build-provenance@v4
|
||||
with:
|
||||
subject-path: ./wtsexporter_macos_x64
|
||||
- uses: actions/upload-artifact@v7
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Deploy to gh-pages
|
||||
if: github.ref == 'refs/heads/main' # Ensure deployment only happens from main
|
||||
uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e
|
||||
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./docs
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
||||
@@ -113,7 +113,7 @@ Do an iPhone/iPad Backup with iTunes/Finder first.
|
||||
> [!NOTE]
|
||||
> If you are working on unencrypted iOS/iPadOS backup, skip this.
|
||||
|
||||
If you want to work on an encrypted iOS/iPadOS Backup, you should install `iphone_backup_decrypt` from [KnugiHK/iphone_backup_decrypt](https://github.com/KnugiHK/iphone_backup_decrypt) before you run the extract_iphone_media.py.
|
||||
If you want to work on an encrypted iOS/iPadOS Backup, you should install `iphone_backup_decrypt` from [KnugiHK/iphone_backup_decrypt](https://github.com/KnugiHK/iphone_backup_decrypt) before you run the `ios_media_handler.py`.
|
||||
```sh
|
||||
pip install git+https://github.com/KnugiHK/iphone_backup_decrypt
|
||||
```
|
||||
|
||||
@@ -8,14 +8,14 @@ class Timing:
|
||||
Handles timestamp formatting with timezone support.
|
||||
"""
|
||||
|
||||
def __init__(self, timezone_offset: Optional[int]) -> None:
|
||||
def __init__(self, timezone_offset: Optional[Union[int, float]] = None) -> None:
|
||||
"""
|
||||
Initialize Timing object.
|
||||
|
||||
Args:
|
||||
timezone_offset (Optional[int]): Hours offset from UTC
|
||||
timezone_offset (Optional[Union[int, float]]): Hours offset from UTC. Defaults to None (auto-detect).
|
||||
"""
|
||||
self.timezone_offset = timezone_offset
|
||||
self.tz = TimeZone(timezone_offset) if timezone_offset is not None else None
|
||||
|
||||
def format_timestamp(self, timestamp: Optional[Union[int, float]], format: str) -> Optional[str]:
|
||||
"""
|
||||
@@ -30,7 +30,7 @@ class Timing:
|
||||
"""
|
||||
if timestamp is not None:
|
||||
timestamp = timestamp / 1000 if timestamp > 9999999999 else timestamp
|
||||
return datetime.fromtimestamp(timestamp, TimeZone(self.timezone_offset)).strftime(format)
|
||||
return datetime.fromtimestamp(timestamp, self.tz).strftime(format)
|
||||
return None
|
||||
|
||||
|
||||
@@ -39,12 +39,12 @@ class TimeZone(tzinfo):
|
||||
Custom timezone class with fixed offset.
|
||||
"""
|
||||
|
||||
def __init__(self, offset: int) -> None:
|
||||
def __init__(self, offset: Union[int, float]) -> None:
|
||||
"""
|
||||
Initialize TimeZone object.
|
||||
|
||||
Args:
|
||||
offset (int): Hours offset from UTC
|
||||
offset (Union[int, float]): Hours offset from UTC
|
||||
"""
|
||||
self.offset = offset
|
||||
|
||||
|
||||
@@ -506,7 +506,7 @@ def process_media_item(content, data, media_folder, mime, separate_media, fix_do
|
||||
)
|
||||
else:
|
||||
final_path = file_path
|
||||
message.data = os.path.join(*final_path.split(os.sep)[1:])
|
||||
message.data = os.path.join(*Path(final_path).parts[1:])
|
||||
else:
|
||||
# Handle missing media
|
||||
message.data = "The media is missing"
|
||||
|
||||
@@ -101,33 +101,40 @@ class BackupExtractor:
|
||||
decrypt_chunk_size=self.decrypt_chunk_size,
|
||||
)
|
||||
logging.info(f"iOS backup is opened successfully")
|
||||
logging.info("Decrypting WhatsApp database...", extra={"clear": True})
|
||||
logging.info("Decrypting WhatsApp database...")
|
||||
try:
|
||||
self.backup.extract_file(
|
||||
relative_path=RelativePath.WHATSAPP_MESSAGES,
|
||||
domain_like=self.identifiers.DOMAIN,
|
||||
output_filename=self.identifiers.MESSAGE,
|
||||
)
|
||||
self.backup.extract_file(
|
||||
relative_path=RelativePath.WHATSAPP_CONTACTS,
|
||||
domain_like=self.identifiers.DOMAIN,
|
||||
output_filename=self.identifiers.CONTACT,
|
||||
)
|
||||
self.backup.extract_file(
|
||||
relative_path=RelativePath.WHATSAPP_CALLS,
|
||||
domain_like=self.identifiers.DOMAIN,
|
||||
output_filename=self.identifiers.CALL,
|
||||
)
|
||||
try:
|
||||
self.backup.extract_file(
|
||||
relative_path=RelativePath.WHATSAPP_MESSAGES,
|
||||
domain_like=self.identifiers.DOMAIN,
|
||||
output_filename=self.identifiers.MESSAGE,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logging.error(
|
||||
"Essential WhatsApp files are missing from the iOS backup. "
|
||||
"Perhapse you enabled end-to-end encryption for the backup? "
|
||||
"See https://wts.knugi.dev/docs.html?dest=iose2e"
|
||||
)
|
||||
exit(6)
|
||||
try:
|
||||
self.backup.extract_file(
|
||||
relative_path=RelativePath.WHATSAPP_CONTACTS,
|
||||
domain_like=self.identifiers.DOMAIN,
|
||||
output_filename=self.identifiers.CONTACT,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logging.warning(f"Contact database not found. Skipping...")
|
||||
try:
|
||||
self.backup.extract_file(
|
||||
relative_path=RelativePath.WHATSAPP_CALLS,
|
||||
domain_like=self.identifiers.DOMAIN,
|
||||
output_filename=self.identifiers.CALL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
logging.warning(f"Call database not found. Skipping...")
|
||||
except ValueError:
|
||||
logging.error("Failed to decrypt backup: incorrect password?")
|
||||
exit(7)
|
||||
except FileNotFoundError:
|
||||
logging.error(
|
||||
"Essential WhatsApp files are missing from the iOS backup. "
|
||||
"Perhapse you enabled end-to-end encryption for the backup? "
|
||||
"See https://wts.knugi.dev/docs.html?dest=iose2e"
|
||||
)
|
||||
exit(6)
|
||||
else:
|
||||
logging.info(f"WhatsApp database decrypted successfully")
|
||||
|
||||
@@ -223,10 +230,7 @@ class BackupExtractor:
|
||||
flags = row["flags"]
|
||||
|
||||
if flags == 2: # Directory
|
||||
try:
|
||||
os.mkdir(destination)
|
||||
except FileExistsError:
|
||||
pass
|
||||
os.makedirs(destination, exist_ok=True)
|
||||
elif flags == 1: # File
|
||||
shutil.copyfile(os.path.join(self.base_dir, folder, hashes), destination)
|
||||
metadata = BPListReader(row["metadata"]).parse()
|
||||
|
||||
@@ -66,13 +66,18 @@ def _parse_vcard_line(line: str) -> tuple[str, dict[str, str], str] | None:
|
||||
value = line[colon_index + 1:].strip()
|
||||
|
||||
# Split property name from parameters
|
||||
parts = prop_and_params.split(';')
|
||||
property_name = parts[0].upper()
|
||||
property_part, *params = prop_and_params.split(';')
|
||||
|
||||
# We only care about property name for now, but the grouping mechanism may be
|
||||
# useful in the future if we want to associate multiple properties together.
|
||||
parts = property_part.split('.')
|
||||
_, property_name = parts if len(parts) == 2 else (None, parts[0])
|
||||
property_name = property_name.upper()
|
||||
|
||||
parameters = {}
|
||||
for part in parts[1:]:
|
||||
if '=' in part:
|
||||
key, val = part.split('=', 1)
|
||||
for param in params:
|
||||
if '=' in param:
|
||||
key, val = param.split('=', 1)
|
||||
parameters[key.upper()] = val.strip('"') # Remove potential quotes from value
|
||||
|
||||
return property_name, parameters, value
|
||||
@@ -98,8 +103,9 @@ def get_vcard_value(entry: str, field_name: str) -> list[str]:
|
||||
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):
|
||||
# Skip empty lines or lines that don't start with the target
|
||||
# field (after stripping), considering potential grouping prefixes
|
||||
if not line or (not line.upper().startswith(target_name) and f".{target_name}" not in line.upper().split(':')[0]):
|
||||
continue
|
||||
|
||||
parsed = _parse_vcard_line(line)
|
||||
|
||||
@@ -42,3 +42,12 @@ VERSION:2.1
|
||||
TEL;CELL:8889990001
|
||||
ORG:AAA Car Service
|
||||
END:VCARD
|
||||
|
||||
BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
item1.TEL;CELL:7777777778
|
||||
item2.TEL;CELL:7777777779
|
||||
item1.FN:Racing Team
|
||||
item2.FN:Racing Team
|
||||
END:VCARD
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
from Whatsapp_Chat_Exporter.data_model import TimeZone, Timing
|
||||
from datetime import timedelta
|
||||
|
||||
|
||||
class TestTimeZone:
|
||||
def test_utcoffset(self):
|
||||
tz = TimeZone(5.5)
|
||||
assert tz.utcoffset(None) == timedelta(hours=5.5)
|
||||
|
||||
def test_dst(self):
|
||||
tz = TimeZone(2)
|
||||
assert tz.dst(None) == timedelta(0)
|
||||
|
||||
|
||||
class TestTiming:
|
||||
@pytest.mark.parametrize("offset, expected_hour", [
|
||||
(8, "08:00"), # Integer (e.g., Hong Kong Standard Time)
|
||||
(-8, "16:00"), # Negative Integer (e.g., PST)
|
||||
(5.5, "05:30"), # Positive Float (e.g., IST)
|
||||
(-3.5, "20:30"), # Negative Float (e.g., Newfoundland)
|
||||
])
|
||||
|
||||
def test_format_timestamp_various_offsets(self, offset, expected_hour):
|
||||
"""Verify that both int and float offsets calculate time correctly."""
|
||||
t = Timing(offset)
|
||||
result = t.format_timestamp(1672531200, "%H:%M")
|
||||
assert result == expected_hour
|
||||
|
||||
@pytest.mark.parametrize("ts_input", [
|
||||
1672531200, # Unix timestamp as int
|
||||
1672531200.0, # Unix timestamp as float
|
||||
])
|
||||
|
||||
def test_timestamp_input_types(self, ts_input):
|
||||
"""Verify the method accepts both int and float timestamps."""
|
||||
t = Timing(0)
|
||||
result = t.format_timestamp(ts_input, "%Y")
|
||||
assert result == "2023"
|
||||
|
||||
def test_timing_none_offset(self):
|
||||
"""Verify initialization with None doesn't crash and uses system time."""
|
||||
t = Timing(None)
|
||||
assert t.tz is None
|
||||
# Should still return a valid string based on local machine time without crashing
|
||||
result = t.format_timestamp(1672531200, "%Y")
|
||||
assert result == "2023"
|
||||
|
||||
def test_millisecond_scaling(self):
|
||||
"""Verify that timestamps in milliseconds are correctly scaled down."""
|
||||
t = Timing(0)
|
||||
# Milliseconds as int
|
||||
assert t.format_timestamp(1672531200000, "%Y") == "2023"
|
||||
# Milliseconds as float
|
||||
assert t.format_timestamp(1672531200000.0, "%Y") == "2023"
|
||||
@@ -0,0 +1,27 @@
|
||||
import sqlite3
|
||||
from types import SimpleNamespace
|
||||
|
||||
from Whatsapp_Chat_Exporter.ios_media_handler import BackupExtractor
|
||||
|
||||
|
||||
def test_extract_media_files_creates_nested_directories(tmp_path, monkeypatch):
|
||||
backup_dir = tmp_path / "backup"
|
||||
backup_dir.mkdir()
|
||||
with sqlite3.connect(backup_dir / "Manifest.db") as manifest:
|
||||
manifest.execute(
|
||||
"CREATE TABLE Files "
|
||||
"(fileID TEXT, domain TEXT, relativePath TEXT, "
|
||||
"flags INTEGER, file BLOB)"
|
||||
)
|
||||
manifest.execute(
|
||||
"INSERT INTO Files VALUES (?, ?, ?, ?, ?)",
|
||||
("unused", "WhatsApp.shared", "parent/child", 2, None),
|
||||
)
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
identifiers = SimpleNamespace(DOMAIN="WhatsApp.shared")
|
||||
|
||||
extractor = BackupExtractor(backup_dir, identifiers, decrypt_chunk_size=0)
|
||||
extractor._extract_media_files()
|
||||
|
||||
assert (tmp_path / "WhatsApp.shared" / "parent" / "child").is_dir()
|
||||
@@ -1,7 +1,7 @@
|
||||
# from contacts_names_from_vcards import readVCardsFile
|
||||
|
||||
import os
|
||||
from Whatsapp_Chat_Exporter.vcards_contacts import normalize_number, read_vcards_file
|
||||
from Whatsapp_Chat_Exporter.vcards_contacts import normalize_number, read_vcards_file, get_vcard_value
|
||||
|
||||
|
||||
def test_readVCardsFile():
|
||||
@@ -17,7 +17,7 @@ def test_readVCardsFile():
|
||||
# Print the count and the name
|
||||
print(f"{count}. {name}")
|
||||
print(data)
|
||||
assert len(data) == 6
|
||||
assert len(data) == 8
|
||||
# Test simple contact name
|
||||
assert data[0][1] == "Sample Contact"
|
||||
# Test complex name
|
||||
@@ -30,6 +30,31 @@ def test_readVCardsFile():
|
||||
assert data[4][1] == "James Peacock Elementary"
|
||||
# Test business entry using ORG but not F/FN
|
||||
assert data[5][1] == "AAA Car Service"
|
||||
# Test grouped entry
|
||||
assert data[6][1] == "Racing Team (1)"
|
||||
assert data[7][1] == "Racing Team (2)"
|
||||
|
||||
|
||||
def test_grouping_mechanism():
|
||||
no_group_vcf = """
|
||||
BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
TEL;CELL:7777777778
|
||||
TEL;CELL:7777777779
|
||||
TEL;CELL:7777777780
|
||||
ORG:Racing Team
|
||||
END:VCARD"""
|
||||
group_vcf = """
|
||||
BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
item1.TEL;CELL:7777777778
|
||||
item2.TEL;CELL:7777777779
|
||||
item3.TEL;CELL:7777777780
|
||||
ORG:Racing Team
|
||||
END:VCARD"""
|
||||
assert get_vcard_value(no_group_vcf, "TEL") == ["7777777778", "7777777779", "7777777780"]
|
||||
assert get_vcard_value(group_vcf, "TEL") == ["7777777778", "7777777779", "7777777780"]
|
||||
|
||||
|
||||
|
||||
def test_create_number_to_name_dicts():
|
||||
|
||||
Reference in New Issue
Block a user