13 Commits
v1.1.0 ... main

Author SHA1 Message Date
relikd
d9de27a40a chore: Makefile no-op 2026-01-18 14:56:07 +01:00
relikd
6ddcd028bf ref: update readme 2026-01-18 14:45:24 +01:00
relikd
f3ecab5da7 Merge pull request #8 from elsiehupp/pypi-instructions
Update instructions to use PyPI (and tidy Markdown formatting)
2026-01-18 13:52:16 +01:00
Elsie Hupp
c66d2057b4 Update instructions to use PyPI (and tidy Markdown formatting) 2026-01-17 11:41:40 -05:00
relikd
f9eeb2af74 chore: improve changelog 2025-12-03 02:56:28 +01:00
relikd
4cdf8afa5e chore: add changelog 2025-12-03 02:47:10 +01:00
relikd
421824f9fd fix: soft-fail on unknown service type 2025-12-03 02:26:09 +01:00
relikd
5c052d0627 fix: SQL sanitation with inner join 2025-12-03 02:19:10 +01:00
relikd
ba6936ca7e fix: typo 2025-12-03 01:32:12 +01:00
relikd
8bdaba0322 fix: py min version 3.6 due to f-string usage 2025-06-09 00:33:47 +02:00
relikd
7804801297 feat: support for external image references 2025-06-09 00:20:14 +02:00
relikd
d151863706 fix: import error if x520 field contains newline 2025-06-09 00:07:59 +02:00
relikd
5a2cb5f1e9 chore: auto-format & formatter warnings 2025-06-09 00:07:17 +02:00
7 changed files with 251 additions and 48 deletions

58
CHANGELOG.md Normal file
View File

@@ -0,0 +1,58 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project does adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.2.2] 2026-01-18
### Changed
- Update readme
## [1.2.1] 2025-12-03
### Fixed
- Soft-fail on unknown social service types. (continue export even if a service field fails)
- SQL sanitize respects `INNER JOIN`. Fixes an error introduced in v1.1.0 which would prohibit export of contacts with at least one social service.
## [1.2.0] 2025-06-09
### Added
- Support for external images (referenced only by id within db)
- Warning for missing `-wal` & `-shm` files
- Warning for missing (and hidden) `.AddressBook-v22_SUPPORT` image storage folder
### Removed
- Warning for missing column `ZSERVICENAME` (column is not that important anyway)
## [1.1.1] 2025-06-09
### Fixed
- Escape newline character in x520
## [1.1.0] 2024-01-27
### Added
- Multi-file output. Use a formatter string to export each vcard individually.
### Fixed
- Continue processing of remaining entries if a single contact card fails to process
- Ignore table columns that do not exist (e.g., `ZTHUMBNAILIMAGEDATA`)
## [1.0.1] 2023-02-21
### Added
- Types
- `abcddb2vcard` is now available on PyPi (`pip3 install abcddb2vcard`, then use `abcddb2vcard` or `vcard2img` in your shell)
### Fixed
- Crash when processing data fields with no corresponding Contact record
[1.2.2]: https://github.com/relikd/abcddb2vcard/compare/v1.2.1...v1.2.2
[1.2.1]: https://github.com/relikd/abcddb2vcard/compare/v1.2.0...v1.2.1
[1.2.0]: https://github.com/relikd/abcddb2vcard/compare/v1.1.1...v1.2.0
[1.1.1]: https://github.com/relikd/abcddb2vcard/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/relikd/abcddb2vcard/compare/v1.0.1...v1.1.0
[1.0.1]:https://github.com/relikd/abcddb2vcard/compare/4d3af13996bbd26dcb07285a8460f04af345fa85...v1.0.1

View File

@@ -1,3 +1,7 @@
.PHONY: help
help:
@echo available commands: install, uninstall, dist, publish
.PHONY: install
install:
[ -z "$${VIRTUAL_ENV}" ] \

126
README.md
View File

@@ -3,48 +3,130 @@
This python script reads an AddressBook database file (`AddressBook-v22.abcddb`) and export its content to a vCard file (`.vcf`).
I created this script to automate my contacts backup procedure.
The output of this script should be exactly the same as dragging and dropping the All Contacts card.
The output of this script should be exactly the same as dragging and dropping the "All Contacts" card.
### Usage
## Installation
To install `abcddb2vcard` from [PyPi](https://pypi.org/project/abcddb2vcard/), use `pip`:
```sh
python3 abcddb2vcard.py backup/contacts_$(date +"%Y-%m-%d").vcf
pip install abcddb2vcard
```
> assuming db is located at "~/Library/Application Support/AddressBook/AddressBook-v22.abcddb"
`abcddb2vcard` can then be used from any working directory in the Terminal.
#### Export into individual files
To uninstall:
```sh
python3 abcddb2vcard.py outdir -s 'path/%{fullname}.vcf'
```
#### Extract contact images
```sh
python3 vcard2image.py AllContacts.vcf ./profile_pics/
pip uninstall abcddb2vcard
```
### Supported data fields
## Usage
`firstname`, `lastname`, `middlename`, `nameprefix`, `namesuffix`, `nickname`, `maidenname`, `phonetic_firstname`, `phonetic_middlename`, `phonetic_lastname`, `phonetic_organization`, `organization`, `department`, `jobtitle`, `birthday`, `[email]`, `[phone]`, `[address]`, `[socialprofile]`, `note`, `[url]`, `[xmpp-service]`, `image`, `iscompany`
Export all contacts
```sh
abcddb2vcard backup/contacts_$(date +"%Y-%m-%d").vcf
```
Export into individual files
```sh
abcddb2vcard outdir -s 'path/%{fullname}.vcf'
```
Extract contact images
```sh
vcard2img AllContacts.vcf ./profile_pics/
```
### Limitations
### Usage help
The `image` field currently only supports JPG images.
I have honestly no idea where PNG images are stored.
For PNGs the database only stores a UUID instead of the file itself.
If you happen to know where I can find these, open an issue or pull request.
#### abcddb2vcard
```
usage: abcddb2vcard [-h] [-f] [--dry-run] [-i AddressBook.abcddb] [-s FORMAT]
outfile.vcf
Extract data from AddressBook database (.abcddb) to Contacts VCards file
(.vcf)
positional arguments:
outfile.vcf VCard output file.
optional arguments:
-h, --help show this help message and exit
-f, --force Overwrite existing output file.
--dry-run Do not write file(s), just print filenames.
-i AddressBook.abcddb, --input AddressBook.abcddb
Specify another abcddb input file. Default:
~/Library/Application Support/AddressBook/AddressBook-v22.abcddb
-s FORMAT, --split FORMAT
Output into several vcf files instead of a single
file. File format can use any field of type Record.
E.g. "%{id}_%{fullname}.vcf".
```
#### vcard2img
```
usage: vcard2img [-h] infile.vcf outdir
Extract all profile pictures from a Contacts VCards file (.vcf)
positional arguments:
infile.vcf VCard input file.
outdir Output directory.
optional arguments:
-h, --help show this help message and exit
```
### Disclaimer
## Supported data fields
- `firstname`
- `lastname`
- `middlename`
- `nameprefix`
- `namesuffix`
- `nickname`
- `maidenname`
- `phonetic_firstname`
- `phonetic_middlename`
- `phonetic_lastname`
- `phonetic_organization`
- `organization`
- `department`
- `jobtitle`
- `birthday`
- `[email]`
- `[phone]`
- `[address]`
- `[socialprofile]`
- `note`
- `[url]`
- `[xmpp-service]`
- `image`
- `iscompany`
## Limitations
Currently, the `image` field only supports JPG images.
But as far as I see, Apple converts PNG to JPG before storing the image.
If you encounter a db which includes other image types, please let me know.
## Disclaimer
You should check the output for yourself before using it in a production environment.
I have tested the script with many arbitrary fields, however there may be some edge cases missing.
Feel free to create an issue for missing or wrong field values.
**Note:** The output of `diff` or `FileMerge.app` can be different to this output.
Apples does some weird transformations on vcf export that are not only unnecessary but in many cases break the re-import of the file.
> **Note:** The output of `diff` or `FileMerge.app` can be different to this output.
Apple uses some data transformations (on vcf export) which are not only unnecessary but may break the re-import of the file.

View File

@@ -1,16 +1,18 @@
#!/usr/bin/env python3
import os
import re
import sys
import sqlite3
from base64 import b64encode
from urllib.parse import quote
# from urllib.parse import quote
from typing import List, Dict, Any, Iterable, Optional
ITEM_COUNTER = 0
rx_query = re.compile(r'SELECT([\s\S]*)FROM[\s]+([A-Z_]+)')
rx_query = re.compile(r'SELECT([\s\S]*)FROM[\s]+([A-Z_]+)(?:[\s]+INNER JOIN\s+([A-Z_]+))?')
rx_cols = re.compile(r'[\s,;](Z[A-Z_]+)')
rx_tags = re.compile(r'\%\{[A-Za-z_]+?\}')
# ===============================
# Helper methods
# ===============================
@@ -25,7 +27,7 @@ def incrItem(value: str, label: str) -> str:
def x520(val: str) -> Optional[str]:
if not val:
return None
return val.replace(';', '\\;').replace(',', '\\,')
return val.replace(';', '\\;').replace(',', '\\,').replace('\n', '\\n')
def buildLabel(
@@ -51,16 +53,19 @@ def buildLabel(
def sanitize(cursor: sqlite3.Cursor, query: str) -> str:
cols, table = rx_query.findall(query)[0]
cols, table, joined = rx_query.findall(query)[0]
sel_cols = {x for x in rx_cols.findall(cols)}
all_cols = {x[1] for x in cursor.execute(f'PRAGMA table_info({table});')}
if joined:
all_cols |= {x[1] for x in cursor.execute(f'PRAGMA table_info({joined});')}
missing_cols = sel_cols.difference(all_cols)
for missing in missing_cols:
print(f'WARN: column "{missing}" not found in {table}. Ignoring.',
print(f'[WARN] Column "{missing}" not found in {table}. Ignoring.',
file=sys.stderr)
query = query.replace(missing, 'NULL')
return query
# ===============================
# VCARD Attributes
# ===============================
@@ -275,11 +280,12 @@ class Service(Queryable):
elif self.service == 'Yahoo':
typ = 'ymsgr'
else:
raise NotImplementedError('Unkown Service: ' + self.service)
typ = 'unknown'
print(f'Unknown Service: "{self.service}"', file=sys.stderr)
# Dear Apple, why do you do such weird shit, URL encoding? bah!
# Even worse, you break it so that reimport fails.
# user = quote(self.username, safe='!/()=_:.\'$&').replace('%2C', '\\,')
# user= quote(self.username, safe='!/()=_:.\'$&').replace('%2C', '\\,')
user = self.username
return buildLabel('IMPP;X-SERVICE-TYPE=' + self.service, self.label,
markPref, typ + ':' + user)
@@ -411,29 +417,55 @@ class Record:
optionalArray(self.service)
if self.image:
try:
data.append(self.imageAsBase64(self.image))
except NotImplementedError:
print('''Image format not supported.
Could not extract image for contact: {}
@: {!r}...
skipping.'''.format(self.fullname, self.image[:20]), file=sys.stderr)
data.append(self.imageAsBase64())
if self.iscompany:
data.append('X-ABShowAs:COMPANY')
data.append('END:VCARD')
return '\r\n'.join(data) + '\r\n'
def imageAsBase64(self, image: bytes) -> str:
img = image[1:] # why does Apple prepend \x01 to all images?!
def imageAsBase64(self) -> str:
if not self.image:
return '' # already checked before call, never happens
t = 'PHOTO;ENCODING=b;TYPE='
if img[6:10] == b'JFIF':
t += 'JPEG:' + b64encode(img).decode('ascii')
else:
raise NotImplementedError(
'Image types other than JPEG are not supported yet.')
if self.image[6:10] == b'JFIF':
t += 'JPEG:' + b64encode(self.image).decode('ascii')
# place 'P' manually for nice 75 char alignment
return t[0] + '\r\n '.join(t[i:i + 74] for i in range(1, len(t), 74))
def imagePreprocess(self, basePath: str) -> None:
# Assumption: Apple uses the first character to determine storage type
# \x01: embedded image
# \x02: external reference
if not self.image:
return # no image exists, nothing to do
if self.image[0] == 1: # loaded into memory
self.image = self.image[1:] # remove storage type indicator
elif self.image[0] == 2: # external referenced image
# for whatever reason this is null-terminated
imgName = self.image[1:].rstrip(b'\x00').decode('ascii')
imgPath = os.path.join(basePath, imgName)
if os.path.isfile(imgPath):
with open(imgPath, 'rb') as fp:
self.image = fp.read()
else:
self.image = None
raise FileNotFoundError(
f'Image reference not found: {imgPath}')
else:
raise NotImplementedError(
'Unexpected image data[{}]: {!r}'.format(
len(self.image), self.image[:20] + b'...'))
# by now loaded into memory either way
if self.image[6:10] != b'JFIF':
self.image = None
# We could convert to JPEG but I don't like to introduce a
# dependecy on Pillow solely for this use-case.
# Should never trigger because Apple converts to JPEG anyway.
raise NotImplementedError('Only JPEG images are supported.')
# ===============================
# Main Entry
@@ -452,7 +484,8 @@ class ABCDDB:
if not rec:
rec = Record.initEmpty(attr.parent)
records[attr.parent] = rec
print('Found unreferenced data field:', attr, file=sys.stderr)
print('[WARN] Found unreferenced data field:', attr,
file=sys.stderr)
return rec
# query once, then distribute
@@ -478,4 +511,31 @@ class ABCDDB:
_getOrMake(service).service.append(service)
db.close()
# support for externally referenced image files
# relative to abcddb file: ".AddressBook-v22_SUPPORT/_EXTERNAL_DATA"
dbBaseDir = os.path.dirname(os.path.abspath(db_path))
dbFilename = os.path.basename(db_path)
hiddenMediaDir = f'.{os.path.splitext(dbFilename)[0]}_SUPPORT'
extImgDir = os.path.join(dbBaseDir, hiddenMediaDir, '_EXTERNAL_DATA')
if not os.path.isfile(db_path + '-wal'):
print(f'[WARN] "{dbFilename}-wal" not found.',
'Both (-wal & -shm) may store recent changes.',
'Data could be incomplete.',
file=sys.stderr)
if not os.path.isdir(extImgDir):
print(f'[WARN] Hidden folder "{hiddenMediaDir}" is missing.',
'Some images may not be exported (warnings below).',
file=sys.stderr)
for rec in records.values():
try:
rec.imagePreprocess(extImgDir)
except Exception as e:
print('''Could not extract image for contact: {}
reason: {}
skipping.'''.format(rec.fullname, e), file=sys.stderr)
return list(records.values())

View File

@@ -2,6 +2,6 @@
'''
Convert AddressBook database (.abcddb) to Contacts VCards file (.vcf)
'''
__version__ = '1.1.0'
__version__ = '1.2.2'
from .ABCDDB import ABCDDB

View File

@@ -59,7 +59,7 @@ def main() -> None:
export_count += 1
except Exception as e:
print(f'Error processing contact {rec.id} {rec.fullname}: {e}',
file=sys.stderr)
file=sys.stderr)
# choose which export mode to use
if args.split: # multi-file mode

View File

@@ -21,7 +21,7 @@ setup(
},
long_description_content_type="text/markdown",
long_description=longdesc,
python_requires='>=3.5',
python_requires='>=3.6',
keywords=[
'abcddb',
'abcd',
@@ -41,7 +41,6 @@ setup(
'Programming Language :: Python',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',