This commit is contained in:
relikd
2021-06-05 20:52:18 +02:00
commit 4d3af13996
8 changed files with 668 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
__pycache__/
*.DS_Store

374
ABCDDB.py Executable file
View File

@@ -0,0 +1,374 @@
#!/usr/bin/env python3
import sys
import sqlite3
from base64 import b64encode
from urllib.parse import quote
PROD_ID = '-//Apple Inc.//Mac OS X 10.15.7//EN'
ITEM_COUNTER = 0
class ABCDDB(object):
@staticmethod
def load(db_path):
db = sqlite3.connect(db_path)
cur = db.cursor()
records = Record.queryAll(cur)
# query once, then distribute
for x in Email.queryAll(cur):
records[x.parent].email.append(x)
for x in Phone.queryAll(cur):
records[x.parent].phone.append(x)
for x in Address.queryAll(cur):
records[x.parent].address.append(x)
for x in SocialProfile.queryAll(cur):
records[x.parent].socialprofile.append(x)
for x in Note.queryAll(cur):
records[x.parent].note = x.text
for x in URL.queryAll(cur):
records[x.parent].urls.append(x)
for x in Service.queryAll(cur):
records[x.parent].service.append(x)
db.close()
return records.values()
def incrItem(value, label):
global ITEM_COUNTER
ITEM_COUNTER += 1
return 'item{0}.{1}\r\nitem{0}.X-ABLabel:{2}'.format(
ITEM_COUNTER, value, label)
def x520(val):
if not val:
return None
return val.replace(';', '\\;').replace(',', '\\,')
def buildLabel(prefix, label, isFirst, suffix, validOther=False):
typ = ''
if label == '_$!<Home>!$_':
typ = ';type=HOME'
elif label == '_$!<Work>!$_':
typ = ';type=WORK'
elif validOther and label == '_$!<Other>!$_':
typ = ';type=OTHER'
value = prefix + typ + (';type=pref:' if isFirst else ':') + suffix
if typ:
return value
else:
return incrItem(value, label)
class Record(object):
@staticmethod
def queryAll(cursor):
return {x[0]: Record(x) for x in cursor.execute('''
SELECT Z_PK,
ZFIRSTNAME, ZLASTNAME, ZMIDDLENAME, ZTITLE, ZSUFFIX,
ZNICKNAME, ZMAIDENNAME,
ZPHONETICFIRSTNAME, ZPHONETICMIDDLENAME, ZPHONETICLASTNAME,
ZPHONETICORGANIZATION, ZORGANIZATION, ZDEPARTMENT, ZJOBTITLE,
strftime('%Y-%m-%d', ZBIRTHDAY + 978307200, 'unixepoch'),
ZTHUMBNAILIMAGEDATA, ZDISPLAYFLAGS
FROM ZABCDRECORD
WHERE ZCONTAINER1 IS NOT NULL;''')}
def __init__(self, row):
self.id = row[0]
self.firstname = x520(row[1]) or ''
self.lastname = x520(row[2]) or ''
self.middlename = x520(row[3]) or ''
self.nameprefix = x520(row[4]) or ''
self.namesuffix = x520(row[5]) or ''
self.nickname = x520(row[6])
self.maidenname = x520(row[7])
self.phonetic_firstname = x520(row[8])
self.phonetic_middlename = x520(row[9])
self.phonetic_lastname = x520(row[10])
self.phonetic_org = x520(row[11])
self.organization = x520(row[12]) or ''
self.department = x520(row[13]) or ''
self.jobtitle = x520(row[14])
self.bday = row[15]
self.email = []
self.phone = []
self.address = []
self.socialprofile = []
self.note = None
self.urls = []
self.service = []
self.image = row[16]
self.iscompany = row[17] & 1
def __repr__(self):
return self.makeVCard()
def makeVCard(self):
global ITEM_COUNTER
ITEM_COUNTER = 0
t = 'BEGIN:VCARD\r\nVERSION:3.0'
t += '\r\nPRODID:' + PROD_ID
def optional(key, value):
nonlocal t
if value:
t += '\r\n' + key + ':' + value
def optionalArray(arr):
nonlocal t
isFirst = True
for x in arr:
t += '\r\n' + x.asStr(markPref=isFirst)
isFirst = False
t += '\r\nN:' + ';'.join((self.lastname, self.firstname,
self.middlename, self.nameprefix,
self.namesuffix))
if self.iscompany:
fullname = self.organization
else:
fullname = ' '.join(filter(None, [
self.nameprefix, self.firstname, self.middlename,
self.lastname, self.namesuffix]))
t += '\r\nFN:' + fullname
optional('NICKNAME', self.nickname)
optional('X-MAIDENNAME', self.maidenname)
optional('X-PHONETIC-FIRST-NAME', self.phonetic_firstname)
optional('X-PHONETIC-MIDDLE-NAME', self.phonetic_middlename)
optional('X-PHONETIC-LAST-NAME', self.phonetic_lastname)
if self.organization or self.department:
t += '\r\nORG:' + self.organization + ';' + self.department
optional('X-PHONETIC-ORG', self.phonetic_org)
optional('TITLE', self.jobtitle)
optionalArray(self.email)
optionalArray(self.phone)
optionalArray(self.address)
optionalArray(self.socialprofile)
optional('NOTE', self.note)
optionalArray(self.urls)
if self.bday:
key = 'BDAY'
if self.bday.startswith('1604'):
key += ';X-APPLE-OMIT-YEAR=1604'
t += '\r\n' + key + ':' + self.bday
for kind in ['Jabber', 'MSN', 'Yahoo', 'ICQ']:
isFirst = True
for x in self.service:
if x.service == kind:
t += '\r\n' + x.asSpecialStr(markPref=isFirst)
isFirst = False
optionalArray(self.service)
if self.image:
try:
t += '\r\n' + self.imageAsBase64()
except NotImplementedError:
print('''Image format not supported.
Could not extract image for contact: {}
@: {}...
skipping.'''.format(fullname, self.image[:20]), file=sys.stderr)
if self.iscompany:
t += '\r\nX-ABShowAs:COMPANY'
return t + '\r\nEND:VCARD\r\n'
def imageAsBase64(self):
if not self.image:
return
img = self.image[1:] # why does Apple prepend \x01 to all images?!
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.')
# 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))
class Email(object):
@staticmethod
def queryAll(cursor):
return (Email(x) for x in cursor.execute('''
SELECT ZOWNER, ZLABEL, ZADDRESS
FROM ZABCDEMAILADDRESS
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;'''))
def __init__(self, row):
self.parent = row[0]
self.label = x520(row[1])
self.email = x520(row[2])
def asStr(self, markPref):
return buildLabel('EMAIL;type=INTERNET', self.label, markPref,
self.email)
class Phone(object):
@staticmethod
def queryAll(cursor):
return (Phone(x) for x in cursor.execute('''
SELECT ZOWNER, ZLABEL, ZFULLNUMBER
FROM ZABCDPHONENUMBER
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;'''))
def __init__(self, row):
self.parent = row[0]
self.label = x520(row[1])
self.number = x520(row[2])
def asStr(self, markPref):
mapping = {
'_$!<Mobile>!$_': ';type=CELL;type=VOICE',
'iPhone': ';type=IPHONE;type=CELL;type=VOICE',
'_$!<Home>!$_': ';type=HOME;type=VOICE',
'_$!<Work>!$_': ';type=WORK;type=VOICE',
'_$!<Main>!$_': ';type=MAIN',
'_$!<HomeFAX>!$_': ';type=HOME;type=FAX',
'_$!<WorkFAX>!$_': ';type=WORK;type=FAX',
'_$!<OtherFAX>!$_': ';type=OTHER;type=FAX',
'_$!<Pager>!$_': ';type=PAGER',
'_$!<Other>!$_': ';type=OTHER;type=VOICE'
}
value = (';type=pref:' if markPref else ':') + self.number
if self.label in mapping.keys():
return 'TEL' + mapping[self.label] + value
else:
return incrItem('TEL' + value, self.label)
class Address(object):
@staticmethod
def queryAll(cursor):
return (Address(x) for x in cursor.execute('''
SELECT ZOWNER, ZLABEL,
ZSTREET, ZCITY, ZSTATE, ZZIPCODE, ZCOUNTRYNAME
FROM ZABCDPOSTALADDRESS
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;'''))
def __init__(self, row):
self.parent = row[0]
self.label = x520(row[1])
self.street = x520(row[2]) or ''
self.city = x520(row[3]) or ''
self.state = x520(row[4]) or ''
self.zip = x520(row[5]) or ''
self.country = x520(row[6]) or ''
def asStr(self, markPref):
value = ';'.join((self.street, self.city, self.state, self.zip,
self.country))
return buildLabel('ADR', self.label, markPref, ';;' + value,
validOther=True)
class SocialProfile(object):
@staticmethod
def queryAll(cursor):
return (SocialProfile(x) for x in cursor.execute('''
SELECT ZOWNER, ZSERVICENAME, ZUSERNAME
FROM ZABCDSOCIALPROFILE;'''))
def __init__(self, row):
self.parent = row[0]
self.service = row[1]
# no x520(); actually, Apple does that ... and it breaks on reimport
self.user = row[2] # Apple: x520()
def asStr(self, markPref):
# Apple does some x-user, x-apple, and url stuff that is wrong
return 'X-SOCIALPROFILE;type=' + self.service.lower() + ':' + self.user
class Note(object):
@staticmethod
def queryAll(cursor):
return (Note(x) for x in cursor.execute('''
SELECT ZCONTACT, ZTEXT
FROM ZABCDNOTE
WHERE ZTEXT IS NOT NULL;'''))
def __init__(self, row):
self.parent = row[0]
self.text = x520(row[1])
class URL(object):
@staticmethod
def queryAll(cursor):
return (URL(x) for x in cursor.execute('''
SELECT ZOWNER, ZLABEL, ZURL
FROM ZABCDURLADDRESS
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;'''))
def __init__(self, row):
self.parent = row[0]
self.label = x520(row[1])
self.url = x520(row[2])
def asStr(self, markPref):
return buildLabel('URL', self.label, markPref, self.url)
class Service(object):
@staticmethod
def queryAll(cursor):
return (Service(x) for x in cursor.execute('''
SELECT ZOWNER, ZSERVICENAME, ZLABEL, ZADDRESS
FROM ZABCDMESSAGINGADDRESS
INNER JOIN ZABCDSERVICE ON ZSERVICE = ZABCDSERVICE.Z_PK
ORDER BY ZOWNER, ZISPRIMARY DESC, ZORDERINGINDEX;'''))
def __init__(self, row):
self.parent = row[0]
self.service = row[1]
if self.service.endswith('Instant'):
self.service = self.service[:-7] # drop suffix
self.label = x520(row[2])
self.username = x520(row[3])
def isSpecial(self):
return self.service in ['Jabber', 'MSN', 'Yahoo', 'ICQ']
def asSpecialStr(self, markPref):
return buildLabel('X-' + self.service.upper(), self.label, markPref,
self.username)
def asStr(self, markPref):
if self.service in ['Jabber', 'GoogleTalk', 'Facebook']:
typ = 'xmpp'
elif self.service in ['GaduGadu', 'QQ']:
typ = 'x-apple'
elif self.service == 'ICQ':
typ = 'aim'
elif self.service == 'MSN':
typ = 'msnim'
elif self.service == 'Skype':
typ = 'skype'
elif self.service == 'Yahoo':
typ = 'ymsgr'
else:
raise NotImplementedError('Unkown Service: ' + self.service)
# 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 = self.username
return buildLabel('IMPP;X-SERVICE-TYPE=' + self.service, self.label,
markPref, typ + ':' + user)

7
LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2021 Oleg Geier
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

44
README.md Normal file
View File

@@ -0,0 +1,44 @@
# abcddb2vcard
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.
### Usage
```
python3 abcddb2vcard.py backup/contacts_$(date +"%Y-%m-%d").vcf
```
> assuming db is located at "~/Library/Application Support/AddressBook/AddressBook-v22.abcddb"
#### Extract contact images
```
python3 vcard2image.py AllContacts.vcf ./profile_pics/
```
### 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
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.
### 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.

21
abcddb2vcard.py Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env python3
import sys
from ABCDDB import ABCDDB
from pathlib import Path
if len(sys.argv) != 2:
print(' Usage:', Path(__file__).name, 'outfile.vcf')
exit(0)
outfile = Path(sys.argv[1])
if not outfile.parent.exists():
print('Output directory does not exist.', file=sys.stderr)
exit(1)
contacts = ABCDDB.load(Path.home().joinpath(
'Library/Application Support/AddressBook/AddressBook-v22.abcddb'))
# contacts = [list(contacts)[-1]] # test on last imported contact
with open(outfile, 'w') as f:
for rec in contacts:
f.write(rec.makeVCard())
print(len(contacts), 'contacts.')

BIN
test.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

166
test.vcf Normal file
View File

@@ -0,0 +1,166 @@
BEGIN:VCARD
VERSION:3.0
PRODID:-//Apple Inc.//Mac OS X 10.15.7//EN
N:Lastname;Firstname\,apdx;middlename;prefixname;suffixname
FN:Company\;\,name:or-sowith_and$%&/)("§$!`?
NICKNAME:Nickname
X-MAIDENNAME:maidenname
X-PHONETIC-FIRST-NAME:phoneticfirst
X-PHONETIC-MIDDLE-NAME:phoneticmiddle
X-PHONETIC-LAST-NAME:phoneticlast
ORG:Company\;\,name:or-sowith_and$%&/)("§$!`?;department
X-PHONETIC-ORG:phoneticcompany
TITLE:jobtitle
EMAIL;type=INTERNET;type=HOME;type=pref:emailhome
item1.EMAIL;type=INTERNET:comma\,ma\;il
item1.X-ABLabel:hi\,ther\;e
item2.EMAIL;type=INTERNET:emailother
item2.X-ABLabel:_$!<Other>!$_
EMAIL;type=INTERNET;type=WORK:emailwork
TEL;type=CELL;type=VOICE;type=pref:phonemobile
item3.TEL:otherphone
item3.X-ABLabel:cu\;stom\,s
TEL;type=HOME;type=VOICE:phonehome
TEL;type=HOME;type=FAX:phonefax
TEL;type=MAIN:phonemain
TEL;type=IPHONE;type=CELL;type=VOICE:phoneiphone
TEL;type=WORK;type=FAX:workfax
TEL;type=OTHER;type=FAX:otherfax
TEL;type=PAGER:pager
TEL;type=WORK;type=VOICE:phonework
TEL;type=OTHER;type=VOICE:phoneopther
ADR;type=HOME;type=pref:;;homestreet;homecity;state;homepostal;homecountry
item4.ADR:;;customstreet;customcity;;custompostal;customcountry
item4.X-ABLabel:dfdf
ADR;type=OTHER:;;otherstreet;othercity;;otherpostal;othercountry
ADR;type=WORK:;;workstreet;workcity;sta;workpostal;workcountry
X-SOCIALPROFILE;type=twitter:twit,t
X-SOCIALPROFILE;type=flickr:flic,kr
X-SOCIALPROFILE;type=myspace:my;spa,ce
X-SOCIALPROFILE;type=facebook:faceo,ok
X-SOCIALPROFILE;type=yelp:ye,lp
X-SOCIALPROFILE;type=tencentweibo:tr,ansent
X-SOCIALPROFILE;type=linkedin:lin;ked,in
X-SOCIALPROFILE;type=sinaweibo:s,ina
NOTE:notes
item5.URL;type=pref:urlhomepage
item5.X-ABLabel:_$!<HomePage>!$_
item6.URL:!"§$%&/()=?¡“¶¢[]|{}≠¿'´-—_:.<>\,\;
item6.X-ABLabel:ss
item7.URL:url other
item7.X-ABLabel:_$!<Other>!$_
URL;type=WORK:url work
URL;type=HOME:url home
BDAY;X-APPLE-OMIT-YEAR=1604:1604-01-01
X-JABBER;type=WORK;type=pref:jabwork
item8.X-JABBER:jabother
item8.X-ABLabel:_$!<Other>!$_
X-JABBER;type=HOME:ja\,bb\;er
X-MSN;type=HOME;type=pref:m\,s\;n
item9.X-YAHOO;type=pref:\,yahoo\;
item9.X-ABLabel:_$!<Other>!$_
X-ICQ;type=HOME;type=pref:i\,c\;q
X-ICQ;type=HOME:!"§$%&/()=?¡“¶¢[]|{}≠¿'´-—_:.<>\,\;
item10.X-ICQ:v\,ie\;\;r
item10.X-ABLabel:lo\;l
X-ICQ;type=HOME:.<>\,\;
IMPP;X-SERVICE-TYPE=ICQ;type=HOME;type=pref:aim:i\,c\;q
IMPP;X-SERVICE-TYPE=ICQ;type=HOME:aim:!"§$%&/()=?¡“¶¢[]|{}≠¿'´-—_:.<>\,\;
IMPP;X-SERVICE-TYPE=GaduGadu;type=HOME:x-apple:g\,ad\;z
item11.IMPP;X-SERVICE-TYPE=ICQ:aim:v\,ie\;\;r
item11.X-ABLabel:lo\;l
IMPP;X-SERVICE-TYPE=Jabber;type=WORK:xmpp:jabwork
item12.IMPP;X-SERVICE-TYPE=Skype:skype:sk\;yp\,e
item12.X-ABLabel:_$!<Other>!$_
IMPP;X-SERVICE-TYPE=Facebook;type=HOME:xmpp:face\,boo\;k
IMPP;X-SERVICE-TYPE=GoogleTalk;type=HOME:xmpp:go\,go\;le
item13.IMPP;X-SERVICE-TYPE=Jabber:xmpp:jabother
item13.X-ABLabel:_$!<Other>!$_
item14.IMPP;X-SERVICE-TYPE=Yahoo:ymsgr:\,yahoo\;
item14.X-ABLabel:_$!<Other>!$_
IMPP;X-SERVICE-TYPE=ICQ;type=HOME:aim:.<>\,\;
item15.IMPP;X-SERVICE-TYPE=QQ:x-apple:\,qq\;
item15.X-ABLabel:_$!<Other>!$_
IMPP;X-SERVICE-TYPE=Jabber;type=HOME:xmpp:ja\,bb\;er
IMPP;X-SERVICE-TYPE=MSN;type=HOME:msnim:m\,s\;n
PHOTO;ENCODING=b;TYPE=JPEG:/9j/4AAQSkZJRgABAQAASABIAAD/4QBMRXhpZgAATU0AKgAA
AAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAcqADAAQAAAABAA
AAXQAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCy
BOmACZjs+EJ+/8AAEQgAXQByAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQ
YHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEV
UtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dX
Z3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY
2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC/
/EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy
0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eo
KDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj
5OXm5+jp6vLz9PX29/j5+v/bAEMAAgICAgICAwICAwQDAwMEBQQEBAQFBwUFBQUFBwgHBwcHBw
cICAgICAgICAoKCgoKCgsLCwsLDQ0NDQ0NDQ0NDf/bAEMBAgICAwMDBgMDBg0JBwkNDQ0NDQ0N
DQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDf/dAAQACP/aAAwDAQ
ACEQMRAD8A/Tf9nH9nX4XeG/hZ4f1PUfD+m6vrGsaZa397e39tHcyNJcxrKUQyq21F3bcLjpk8
19A/8Kn+F3/Qn6D/AOCu1/8AjdV/hCf+LUeDD/1L2lf+ksVejbjW9arOc3KTPIyvK8HRwlOnTp
RSSXRdjgv+FT/C/wD6FDQfr/Zlr/8AGqT/AIVP8Lv+hP0H/wAFdr/8brvtxo3GsuZ9zu+pYf8A
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
ARDwP/AD7l+B//2Q==
X-ABShowAs:COMPANY
END:VCARD

54
vcard2image.py Executable file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
import os
import re
import sys
from base64 import b64decode
from pathlib import Path
if len(sys.argv) != 3:
print(' Usage:', Path(__file__).name, 'infile.vcf', 'outdir/')
exit(0)
infile = Path(sys.argv[1])
outdir = Path(sys.argv[2])
if not infile.exists():
print('Does not exist: ', infile, file=sys.stderr)
exit(1)
elif not outdir.exists():
if not outdir.parent.exists():
print('Output directory does not exist.', file=sys.stderr)
exit(1)
else:
os.mkdir(outdir)
with open(infile, 'r') as f:
c1 = 0
c2 = 0
name = ''
img = None
collect = False
for line in f.readlines():
line = line.rstrip()
if line == 'BEGIN:VCARD':
c1 += 1
name = ''
img = None
collect = False
elif line.startswith('FN:'):
name = line.split(':', 1)[1]
elif line.startswith('PHOTO;'):
img = line.split(':', 1)[1]
collect = True
elif collect:
if line[0] == ' ':
img += line[1:]
else:
collect = False
if line == 'END:VCARD' and img:
c2 += 1
name = name.replace('\\,', ',').replace('\\;', ';').replace(
'/', '-')
with open(outdir.joinpath(name + '.jpg'), 'wb') as fw:
fw.write(b64decode(img))
print(c1, 'contacts.', c2, 'images.')