Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9de27a40a | ||
|
|
6ddcd028bf | ||
|
|
f3ecab5da7 | ||
|
|
c66d2057b4 | ||
|
|
f9eeb2af74 | ||
|
|
4cdf8afa5e | ||
|
|
421824f9fd | ||
|
|
5c052d0627 | ||
|
|
ba6936ca7e | ||
|
|
8bdaba0322 | ||
|
|
7804801297 | ||
|
|
d151863706 | ||
|
|
5a2cb5f1e9 | ||
|
|
2604eed96e | ||
|
|
2ab5609483 | ||
|
|
43d979f630 | ||
|
|
f3ae2e3ff4 |
58
CHANGELOG.md
Normal file
58
CHANGELOG.md
Normal 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
|
||||
4
Makefile
4
Makefile
@@ -1,3 +1,7 @@
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo available commands: install, uninstall, dist, publish
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
[ -z "$${VIRTUAL_ENV}" ] \
|
||||
|
||||
124
README.md
124
README.md
@@ -3,42 +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
|
||||
|
||||
```
|
||||
python3 abcddb2vcard.py backup/contacts_$(date +"%Y-%m-%d").vcf
|
||||
To install `abcddb2vcard` from [PyPi](https://pypi.org/project/abcddb2vcard/), use `pip`:
|
||||
|
||||
```sh
|
||||
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.
|
||||
|
||||
#### Extract contact images
|
||||
To uninstall:
|
||||
|
||||
```
|
||||
python3 vcard2image.py AllContacts.vcf ./profile_pics/
|
||||
```sh
|
||||
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.
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
#!/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_]+)(?:[\s]+INNER JOIN\s+([A-Z_]+))?')
|
||||
rx_cols = re.compile(r'[\s,;](Z[A-Z_]+)')
|
||||
rx_tags = re.compile(r'\%\{[A-Za-z_]+?\}')
|
||||
|
||||
|
||||
# ===============================
|
||||
@@ -22,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(
|
||||
@@ -47,6 +52,20 @@ def buildLabel(
|
||||
return incrItem(value, label)
|
||||
|
||||
|
||||
def sanitize(cursor: sqlite3.Cursor, query: str) -> str:
|
||||
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.',
|
||||
file=sys.stderr)
|
||||
query = query.replace(missing, 'NULL')
|
||||
return query
|
||||
|
||||
|
||||
# ===============================
|
||||
# VCARD Attributes
|
||||
# ===============================
|
||||
@@ -64,8 +83,11 @@ class Queryable: # Protocol
|
||||
def parent(self) -> int:
|
||||
return self._parent
|
||||
|
||||
def classStr(self, value: str) -> str:
|
||||
return '<{} "{}">'.format(self.__class__.__name__, value)
|
||||
def __repr__(self) -> str:
|
||||
return '<{} "{}">'.format(self.__class__.__name__, self.asPrintable())
|
||||
|
||||
def asPrintable(self) -> str:
|
||||
return '?'
|
||||
|
||||
def asVCard(self, markPref: bool) -> str:
|
||||
raise NotImplementedError()
|
||||
@@ -74,18 +96,18 @@ class Queryable: # Protocol
|
||||
class Email(Queryable):
|
||||
@staticmethod
|
||||
def queryAll(cursor: sqlite3.Cursor) -> Iterable['Email']:
|
||||
return (Email(x) for x in cursor.execute('''
|
||||
return (Email(x) for x in cursor.execute(sanitize(cursor, '''
|
||||
SELECT ZOWNER, ZLABEL, ZADDRESS
|
||||
FROM ZABCDEMAILADDRESS
|
||||
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;'''))
|
||||
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;''')))
|
||||
|
||||
def __init__(self, row: List[Any]):
|
||||
self._parent = row[0] # type: int
|
||||
self.label = x520(row[1]) or '' # type: str
|
||||
self.email = x520(row[2]) or '' # type: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.classStr(self.email)
|
||||
def asPrintable(self) -> str:
|
||||
return self.email
|
||||
|
||||
def asVCard(self, markPref: bool) -> str:
|
||||
return buildLabel(
|
||||
@@ -95,18 +117,18 @@ class Email(Queryable):
|
||||
class Phone(Queryable):
|
||||
@staticmethod
|
||||
def queryAll(cursor: sqlite3.Cursor) -> Iterable['Phone']:
|
||||
return (Phone(x) for x in cursor.execute('''
|
||||
return (Phone(x) for x in cursor.execute(sanitize(cursor, '''
|
||||
SELECT ZOWNER, ZLABEL, ZFULLNUMBER
|
||||
FROM ZABCDPHONENUMBER
|
||||
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;'''))
|
||||
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;''')))
|
||||
|
||||
def __init__(self, row: List[Any]):
|
||||
self._parent = row[0] # type: int
|
||||
self.label = x520(row[1]) or '' # type: str
|
||||
self.number = x520(row[2]) or '' # type: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.classStr(self.number)
|
||||
def asPrintable(self) -> str:
|
||||
return self.number
|
||||
|
||||
def asVCard(self, markPref: bool) -> str:
|
||||
mapping = {
|
||||
@@ -131,11 +153,11 @@ class Phone(Queryable):
|
||||
class Address(Queryable):
|
||||
@staticmethod
|
||||
def queryAll(cursor: sqlite3.Cursor) -> Iterable['Address']:
|
||||
return (Address(x) for x in cursor.execute('''
|
||||
return (Address(x) for x in cursor.execute(sanitize(cursor, '''
|
||||
SELECT ZOWNER, ZLABEL,
|
||||
ZSTREET, ZCITY, ZSTATE, ZZIPCODE, ZCOUNTRYNAME
|
||||
FROM ZABCDPOSTALADDRESS
|
||||
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;'''))
|
||||
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;''')))
|
||||
|
||||
def __init__(self, row: List[Any]):
|
||||
self._parent = row[0] # type: int
|
||||
@@ -146,9 +168,9 @@ class Address(Queryable):
|
||||
self.zip = x520(row[5]) or '' # type: str
|
||||
self.country = x520(row[6]) or '' # type: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.classStr(', '.join(filter(None, (
|
||||
self.street, self.city, self.state, self.zip, self.country))))
|
||||
def asPrintable(self) -> str:
|
||||
return ', '.join(filter(None, (
|
||||
self.street, self.city, self.state, self.zip, self.country)))
|
||||
|
||||
def asVCard(self, markPref: bool) -> str:
|
||||
value = ';'.join((
|
||||
@@ -160,9 +182,9 @@ class Address(Queryable):
|
||||
class SocialProfile(Queryable):
|
||||
@staticmethod
|
||||
def queryAll(cursor: sqlite3.Cursor) -> Iterable['SocialProfile']:
|
||||
return (SocialProfile(x) for x in cursor.execute('''
|
||||
return (SocialProfile(x) for x in cursor.execute(sanitize(cursor, '''
|
||||
SELECT ZOWNER, ZSERVICENAME, ZUSERNAME
|
||||
FROM ZABCDSOCIALPROFILE;'''))
|
||||
FROM ZABCDSOCIALPROFILE;''')))
|
||||
|
||||
def __init__(self, row: List[Any]):
|
||||
self._parent = row[0] # type: int
|
||||
@@ -170,8 +192,8 @@ class SocialProfile(Queryable):
|
||||
# no x520(); actually, Apple does that ... and it breaks on reimport
|
||||
self.user = row[2] or '' # type: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.classStr(self.service + ':' + self.user)
|
||||
def asPrintable(self) -> str:
|
||||
return self.service + ':' + self.user
|
||||
|
||||
def asVCard(self, markPref: bool) -> str:
|
||||
# Apple does some x-user, x-apple, and url stuff that is wrong
|
||||
@@ -181,17 +203,17 @@ class SocialProfile(Queryable):
|
||||
class Note(Queryable):
|
||||
@staticmethod
|
||||
def queryAll(cursor: sqlite3.Cursor) -> Iterable['Note']:
|
||||
return (Note(x) for x in cursor.execute('''
|
||||
return (Note(x) for x in cursor.execute(sanitize(cursor, '''
|
||||
SELECT ZCONTACT, ZTEXT
|
||||
FROM ZABCDNOTE
|
||||
WHERE ZTEXT IS NOT NULL;'''))
|
||||
WHERE ZTEXT IS NOT NULL;''')))
|
||||
|
||||
def __init__(self, row: List[Any]):
|
||||
self._parent = row[0] # type: int
|
||||
self.text = x520(row[1]) or '' # type: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.classStr(self.text)
|
||||
def asPrintable(self) -> str:
|
||||
return self.text
|
||||
|
||||
def asVCard(self, markPref: bool) -> str:
|
||||
return self.text
|
||||
@@ -200,18 +222,18 @@ class Note(Queryable):
|
||||
class URL(Queryable):
|
||||
@staticmethod
|
||||
def queryAll(cursor: sqlite3.Cursor) -> Iterable['URL']:
|
||||
return (URL(x) for x in cursor.execute('''
|
||||
return (URL(x) for x in cursor.execute(sanitize(cursor, '''
|
||||
SELECT ZOWNER, ZLABEL, ZURL
|
||||
FROM ZABCDURLADDRESS
|
||||
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;'''))
|
||||
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;''')))
|
||||
|
||||
def __init__(self, row: List[Any]):
|
||||
self._parent = row[0] # type: int
|
||||
self.label = x520(row[1]) or '' # type: str
|
||||
self.url = x520(row[2]) or '' # type: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.classStr(self.url)
|
||||
def asPrintable(self) -> str:
|
||||
return self.url
|
||||
|
||||
def asVCard(self, markPref: bool) -> str:
|
||||
return buildLabel('URL', self.label, markPref, self.url)
|
||||
@@ -220,11 +242,11 @@ class URL(Queryable):
|
||||
class Service(Queryable):
|
||||
@staticmethod
|
||||
def queryAll(cursor: sqlite3.Cursor) -> Iterable['Service']:
|
||||
return (Service(x) for x in cursor.execute('''
|
||||
return (Service(x) for x in cursor.execute(sanitize(cursor, '''
|
||||
SELECT ZOWNER, ZSERVICENAME, ZLABEL, ZADDRESS
|
||||
FROM ZABCDMESSAGINGADDRESS
|
||||
INNER JOIN ZABCDSERVICE ON ZSERVICE = ZABCDSERVICE.Z_PK
|
||||
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;'''))
|
||||
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;''')))
|
||||
|
||||
def __init__(self, row: List[Any]):
|
||||
self._parent = row[0] # type: int
|
||||
@@ -234,9 +256,8 @@ class Service(Queryable):
|
||||
self.label = x520(row[2]) or '' # type: str
|
||||
self.username = x520(row[3]) or '' # type: str
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.classStr(', '.join((
|
||||
self.service, self.label, self.username)))
|
||||
def asPrintable(self) -> str:
|
||||
return ', '.join((self.service, self.label, self.username))
|
||||
|
||||
def isSpecial(self) -> bool:
|
||||
return self.service in ['Jabber', 'MSN', 'Yahoo', 'ICQ']
|
||||
@@ -259,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)
|
||||
@@ -281,7 +303,7 @@ class Record:
|
||||
'SELECT Z_ENT FROM Z_PRIMARYKEY WHERE Z_NAME == "ABCDContact"'
|
||||
).fetchone()[0]
|
||||
# find all records that match this id
|
||||
return {x[0]: Record(x) for x in cursor.execute('''
|
||||
return {x[0]: Record(x) for x in cursor.execute(sanitize(cursor, '''
|
||||
SELECT Z_PK,
|
||||
ZFIRSTNAME, ZLASTNAME, ZMIDDLENAME, ZTITLE, ZSUFFIX,
|
||||
ZNICKNAME, ZMAIDENNAME,
|
||||
@@ -290,7 +312,7 @@ class Record:
|
||||
strftime('%Y-%m-%d', ZBIRTHDAY + 978307200, 'unixepoch'),
|
||||
ZTHUMBNAILIMAGEDATA, ZDISPLAYFLAGS
|
||||
FROM ZABCDRECORD
|
||||
WHERE Z_ENT = ?;''', [z_ent])}
|
||||
WHERE Z_ENT = ?;'''), [z_ent])}
|
||||
|
||||
@staticmethod
|
||||
def initEmpty(id: int) -> 'Record':
|
||||
@@ -323,26 +345,35 @@ class Record:
|
||||
self.image = row[16] # type: Optional[bytes]
|
||||
display_flags = row[17] or 0 # type: int
|
||||
self.iscompany = bool(display_flags & 1) # type: bool
|
||||
self.fullname = self.organization if self.iscompany else ' '.join(
|
||||
filter(None, [self.nameprefix, self.firstname, self.middlename,
|
||||
self.lastname, self.namesuffix]))
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.makeVCard()
|
||||
|
||||
def formatFilename(self, format: str) -> str:
|
||||
matches = rx_tags.findall(format)
|
||||
for tag in matches:
|
||||
value = getattr(self, tag[2:-1])
|
||||
if isinstance(value, list):
|
||||
value = value[0] if len(value) else None
|
||||
if isinstance(value, Queryable):
|
||||
value = value.asPrintable()
|
||||
format = format.replace(tag, str(value or '').replace('/', ':'))
|
||||
return format
|
||||
|
||||
def makeVCard(self) -> str:
|
||||
global ITEM_COUNTER
|
||||
ITEM_COUNTER = 0
|
||||
|
||||
name = ';'.join((self.lastname, self.firstname, self.middlename,
|
||||
self.nameprefix, self.namesuffix))
|
||||
fullname = self.organization if self.iscompany else ' '.join(
|
||||
filter(None, [self.nameprefix, self.firstname, self.middlename,
|
||||
self.lastname, self.namesuffix]))
|
||||
|
||||
# rquired fields: BEGIN, END, VERSION, N, FN
|
||||
data = [
|
||||
'BEGIN:VCARD',
|
||||
'VERSION:3.0',
|
||||
'N:' + name,
|
||||
'FN:' + fullname,
|
||||
'N:' + ';'.join((self.lastname, self.firstname, self.middlename,
|
||||
self.nameprefix, self.namesuffix)),
|
||||
'FN:' + self.fullname,
|
||||
]
|
||||
|
||||
def optional(key: str, value: Optional[str]) -> None:
|
||||
@@ -386,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(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
|
||||
@@ -427,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
|
||||
@@ -453,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())
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
'''
|
||||
Convert AddressBook database (.abcddb) to Contacts VCards file (.vcf)
|
||||
'''
|
||||
__version__ = '1.0.1'
|
||||
__version__ = '1.2.2'
|
||||
|
||||
from .ABCDDB import ABCDDB
|
||||
|
||||
@@ -4,12 +4,13 @@ Extract data from AddressBook database (.abcddb) to Contacts VCards file (.vcf)
|
||||
'''
|
||||
import os
|
||||
import sys
|
||||
from io import TextIOWrapper
|
||||
from pathlib import Path
|
||||
from argparse import ArgumentParser
|
||||
try:
|
||||
from .ABCDDB import ABCDDB
|
||||
from .ABCDDB import ABCDDB, Record
|
||||
except ImportError: # fallback if not run as module
|
||||
from ABCDDB import ABCDDB # type: ignore[import, no-redef]
|
||||
from ABCDDB import ABCDDB, Record # type: ignore[import, no-redef]
|
||||
|
||||
DB_FILE = str(Path.home().joinpath(
|
||||
'Library', 'Application Support', 'AddressBook', 'AddressBook-v22.abcddb'))
|
||||
@@ -21,9 +22,16 @@ def main() -> None:
|
||||
help='VCard output file.')
|
||||
cli.add_argument('-f', '--force', action='store_true',
|
||||
help='Overwrite existing output file.')
|
||||
cli.add_argument('--dry-run', action='store_true',
|
||||
help='Do not write file(s), just print filenames.')
|
||||
cli.add_argument('-i', '--input', type=str, metavar='AddressBook.abcddb',
|
||||
default=DB_FILE, help='Specify another abcddb input file.'
|
||||
' Default: ' + DB_FILE)
|
||||
cli.add_argument('-s', '--split', type=str, metavar='FORMAT', help='''
|
||||
Output into several vcf files instead of a single file.
|
||||
File format can use any field of type Record.
|
||||
E.g. "%%{id}_%%{fullname}.vcf".
|
||||
''')
|
||||
args = cli.parse_args()
|
||||
|
||||
# check input args
|
||||
@@ -34,17 +42,49 @@ def main() -> None:
|
||||
elif not os.path.isdir(os.path.dirname(args.output) or os.curdir):
|
||||
print('Output parent directory does not exist.', file=sys.stderr)
|
||||
exit(1)
|
||||
elif os.path.isfile(args.output) and not args.force:
|
||||
elif os.path.exists(args.output) and not args.force:
|
||||
print('Output file already exist. Use -f to force overwrite.',
|
||||
file=sys.stderr)
|
||||
exit(1)
|
||||
|
||||
# perform export
|
||||
contacts = ABCDDB.load(args.input)
|
||||
with open(args.output, 'w') as f:
|
||||
for rec in contacts:
|
||||
export_count = 0
|
||||
|
||||
# reused for appending to an open file
|
||||
def writeRec(f: TextIOWrapper, rec: Record):
|
||||
nonlocal export_count
|
||||
try:
|
||||
f.write(rec.makeVCard())
|
||||
print(len(contacts), 'contacts.')
|
||||
export_count += 1
|
||||
except Exception as e:
|
||||
print(f'Error processing contact {rec.id} {rec.fullname}: {e}',
|
||||
file=sys.stderr)
|
||||
|
||||
# choose which export mode to use
|
||||
if args.split: # multi-file mode
|
||||
outDir = Path(args.output)
|
||||
prevFilenames = set()
|
||||
for rec in contacts:
|
||||
filename = outDir / Path(rec.formatFilename(args.split))
|
||||
if filename in prevFilenames:
|
||||
print(f'WARN: overwriting "{filename}"', file=sys.stderr)
|
||||
prevFilenames.add(filename)
|
||||
|
||||
os.makedirs(filename.parent, exist_ok=True)
|
||||
if args.dry_run:
|
||||
print(filename)
|
||||
else:
|
||||
with open(filename, 'w') as f:
|
||||
writeRec(f, rec)
|
||||
else: # single-file mode
|
||||
if args.dry_run:
|
||||
print(args.output)
|
||||
else:
|
||||
with open(args.output, 'w') as f:
|
||||
for rec in contacts:
|
||||
writeRec(f, rec)
|
||||
print(f'{export_count}/{len(contacts)} contacts.')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
3
setup.py
3
setup.py
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user