Compare commits

...

16 Commits

Author SHA1 Message Date
KnugiHK e9663499a2 fix a crash where os.sep is different from the slash in final_path 2026-08-12 20:21:32 +08:00
Knugi f4f572e3ea Merge pull request #230 from Sanjays2402/fix/ios-nested-media-directories
Fix nested iOS media directory extraction
2026-07-22 02:20:20 +08:00
Sanjay Santhanam 12981a148b Fix nested iOS media directory extraction
Create parent directories when an iOS backup manifest contains a nested directory path. Add a regression test covering the reported iOS 26 layout.
2026-07-19 15:48:38 -07:00
KnugiHK 3ecb74b0bd Merge branch 'main' into dev 2026-07-18 13:42:11 +08:00
KnugiHK 68089f5ae4 Update actions 2026-07-18 13:41:38 +08:00
Knugi 1729a39e5e Merge pull request #216 from Jomolungma/215-missing-contacts-database
Fix for bug 215, ignore missing contacts database.in encrypted iOS backups.
2026-07-05 19:36:07 +08:00
Jomolungma 8fa658c7a7 Fix for bug 215, ignore missing contacts database. 2026-05-19 17:01:15 +02:00
Knugi cac2a2464d Update outdated script reference in README (#213) 2026-05-18 15:03:06 +00:00
KnugiHK 2ebb389ad1 Fix the support on grouped vCard properties (#207 )
Parse and match vCard properties that use grouping prefixes (e.g. item1.TEL) by extracting the property name correctly.

Regression caused by the removal of the vobject dependency.
2026-04-01 01:04:51 +08:00
Knugi 0056204d87 Merge pull request #206 from tang-vu/contribai/improve/quality/crash-in-timestamp-formatting-when-timez
 Quality: Crash in timestamp formatting when timezone_offset is None
2026-03-26 21:56:43 +08:00
KnugiHK abf4f3c814 Implement tests for classes TimeZone and Timing 2026-03-26 21:53:44 +08:00
KnugiHK 9e138d3a1f Cache TimeZone object in Timing class 2026-03-26 21:31:52 +08:00
KnugiHK 18a0d822b3 Timezone offset should also accepts float 2026-03-26 21:30:10 +08:00
KnugiHK bb860533d5 Add a default value for timezone_offset in Timing.__init__ 2026-03-26 21:23:59 +08:00
Tang Vu a2bcc39e63 refactor: crash in timestamp formatting when timezone_offset is none
In `Timing.format_timestamp`, if `self.timezone_offset` is `None` (which is explicitly allowed by the `Optional[int]` type hint), it instantiates `TimeZone(None)`. 
When `datetime.fromtimestamp()` calls the `utcoffset()` method on this timezone object, it executes `timedelta(hours=self.offset)`, which evaluates to `timedelta(hours=None)`. This raises a `TypeError: unsupported type for timedelta hours component: NoneType`, causing the application to crash during export.


Affected files: data_model.py

Signed-off-by: Tang Vu <vuminhtang2212@gmail.com>
2026-03-26 03:25:44 +07:00
KnugiHK a0719bc2bf Update README.md 2026-01-24 23:05:00 +08:00
14 changed files with 182 additions and 56 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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`
+7 -7
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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:
+1 -1
View File
@@ -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
```
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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"
+31 -27
View File
@@ -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()
+13 -7
View File
@@ -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)
+9
View File
@@ -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
+55
View File
@@ -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"
+27
View File
@@ -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()
+27 -2
View File
@@ -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():