Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cdf8afa5e | ||
|
|
421824f9fd | ||
|
|
5c052d0627 | ||
|
|
ba6936ca7e | ||
|
|
8bdaba0322 | ||
|
|
7804801297 | ||
|
|
d151863706 | ||
|
|
5a2cb5f1e9 |
40
CHANGELOG.md
Normal file
40
CHANGELOG.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 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.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 exporting external image files
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.1] – 2025-06-09
|
||||||
|
### Fixed
|
||||||
|
- Escape newline character in x520
|
||||||
|
|
||||||
|
|
||||||
|
## [1.1.0] – 2024-01-27
|
||||||
|
### Added
|
||||||
|
- Multi-file export
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ignore non-existing columns
|
||||||
|
|
||||||
|
|
||||||
|
## [1.0.1] – 2023-02-21
|
||||||
|
Initial release
|
||||||
|
|
||||||
|
|
||||||
|
[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
|
||||||
@@ -35,9 +35,8 @@ python3 vcard2image.py AllContacts.vcf ./profile_pics/
|
|||||||
### Limitations
|
### Limitations
|
||||||
|
|
||||||
The `image` field currently only supports JPG images.
|
The `image` field currently only supports JPG images.
|
||||||
I have honestly no idea where PNG images are stored.
|
But as far as I see, Apple converts PNG to JPG before storing the image.
|
||||||
For PNGs the database only stores a UUID instead of the file itself.
|
If you encounter a db which includes other image types, please let me know.
|
||||||
If you happen to know where I can find these, open an issue or pull request.
|
|
||||||
|
|
||||||
|
|
||||||
### Disclaimer
|
### Disclaimer
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from base64 import b64encode
|
from base64 import b64encode
|
||||||
from urllib.parse import quote
|
# from urllib.parse import quote
|
||||||
from typing import List, Dict, Any, Iterable, Optional
|
from typing import List, Dict, Any, Iterable, Optional
|
||||||
|
|
||||||
ITEM_COUNTER = 0
|
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_cols = re.compile(r'[\s,;](Z[A-Z_]+)')
|
||||||
rx_tags = re.compile(r'\%\{[A-Za-z_]+?\}')
|
rx_tags = re.compile(r'\%\{[A-Za-z_]+?\}')
|
||||||
|
|
||||||
|
|
||||||
# ===============================
|
# ===============================
|
||||||
# Helper methods
|
# Helper methods
|
||||||
# ===============================
|
# ===============================
|
||||||
@@ -25,7 +27,7 @@ def incrItem(value: str, label: str) -> str:
|
|||||||
def x520(val: str) -> Optional[str]:
|
def x520(val: str) -> Optional[str]:
|
||||||
if not val:
|
if not val:
|
||||||
return None
|
return None
|
||||||
return val.replace(';', '\\;').replace(',', '\\,')
|
return val.replace(';', '\\;').replace(',', '\\,').replace('\n', '\\n')
|
||||||
|
|
||||||
|
|
||||||
def buildLabel(
|
def buildLabel(
|
||||||
@@ -51,16 +53,19 @@ def buildLabel(
|
|||||||
|
|
||||||
|
|
||||||
def sanitize(cursor: sqlite3.Cursor, query: str) -> str:
|
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)}
|
sel_cols = {x for x in rx_cols.findall(cols)}
|
||||||
all_cols = {x[1] for x in cursor.execute(f'PRAGMA table_info({table});')}
|
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)
|
missing_cols = sel_cols.difference(all_cols)
|
||||||
for missing in missing_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)
|
file=sys.stderr)
|
||||||
query = query.replace(missing, 'NULL')
|
query = query.replace(missing, 'NULL')
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
|
||||||
# ===============================
|
# ===============================
|
||||||
# VCARD Attributes
|
# VCARD Attributes
|
||||||
# ===============================
|
# ===============================
|
||||||
@@ -275,11 +280,12 @@ class Service(Queryable):
|
|||||||
elif self.service == 'Yahoo':
|
elif self.service == 'Yahoo':
|
||||||
typ = 'ymsgr'
|
typ = 'ymsgr'
|
||||||
else:
|
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!
|
# Dear Apple, why do you do such weird shit, URL encoding? bah!
|
||||||
# Even worse, you break it so that reimport fails.
|
# 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
|
user = self.username
|
||||||
return buildLabel('IMPP;X-SERVICE-TYPE=' + self.service, self.label,
|
return buildLabel('IMPP;X-SERVICE-TYPE=' + self.service, self.label,
|
||||||
markPref, typ + ':' + user)
|
markPref, typ + ':' + user)
|
||||||
@@ -411,29 +417,55 @@ class Record:
|
|||||||
optionalArray(self.service)
|
optionalArray(self.service)
|
||||||
|
|
||||||
if self.image:
|
if self.image:
|
||||||
try:
|
data.append(self.imageAsBase64())
|
||||||
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)
|
|
||||||
if self.iscompany:
|
if self.iscompany:
|
||||||
data.append('X-ABShowAs:COMPANY')
|
data.append('X-ABShowAs:COMPANY')
|
||||||
data.append('END:VCARD')
|
data.append('END:VCARD')
|
||||||
return '\r\n'.join(data) + '\r\n'
|
return '\r\n'.join(data) + '\r\n'
|
||||||
|
|
||||||
def imageAsBase64(self, image: bytes) -> str:
|
def imageAsBase64(self) -> str:
|
||||||
img = image[1:] # why does Apple prepend \x01 to all images?!
|
if not self.image:
|
||||||
|
return '' # already checked before call, never happens
|
||||||
t = 'PHOTO;ENCODING=b;TYPE='
|
t = 'PHOTO;ENCODING=b;TYPE='
|
||||||
if img[6:10] == b'JFIF':
|
if self.image[6:10] == b'JFIF':
|
||||||
t += 'JPEG:' + b64encode(img).decode('ascii')
|
t += 'JPEG:' + b64encode(self.image).decode('ascii')
|
||||||
else:
|
|
||||||
raise NotImplementedError(
|
|
||||||
'Image types other than JPEG are not supported yet.')
|
|
||||||
# place 'P' manually for nice 75 char alignment
|
# 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))
|
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
|
# Main Entry
|
||||||
@@ -452,7 +484,8 @@ class ABCDDB:
|
|||||||
if not rec:
|
if not rec:
|
||||||
rec = Record.initEmpty(attr.parent)
|
rec = Record.initEmpty(attr.parent)
|
||||||
records[attr.parent] = rec
|
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
|
return rec
|
||||||
|
|
||||||
# query once, then distribute
|
# query once, then distribute
|
||||||
@@ -478,4 +511,31 @@ class ABCDDB:
|
|||||||
_getOrMake(service).service.append(service)
|
_getOrMake(service).service.append(service)
|
||||||
|
|
||||||
db.close()
|
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())
|
return list(records.values())
|
||||||
|
|||||||
@@ -2,6 +2,6 @@
|
|||||||
'''
|
'''
|
||||||
Convert AddressBook database (.abcddb) to Contacts VCards file (.vcf)
|
Convert AddressBook database (.abcddb) to Contacts VCards file (.vcf)
|
||||||
'''
|
'''
|
||||||
__version__ = '1.1.0'
|
__version__ = '1.2.1'
|
||||||
|
|
||||||
from .ABCDDB import ABCDDB
|
from .ABCDDB import ABCDDB
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def main() -> None:
|
|||||||
export_count += 1
|
export_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'Error processing contact {rec.id} {rec.fullname}: {e}',
|
print(f'Error processing contact {rec.id} {rec.fullname}: {e}',
|
||||||
file=sys.stderr)
|
file=sys.stderr)
|
||||||
|
|
||||||
# choose which export mode to use
|
# choose which export mode to use
|
||||||
if args.split: # multi-file mode
|
if args.split: # multi-file mode
|
||||||
|
|||||||
3
setup.py
3
setup.py
@@ -21,7 +21,7 @@ setup(
|
|||||||
},
|
},
|
||||||
long_description_content_type="text/markdown",
|
long_description_content_type="text/markdown",
|
||||||
long_description=longdesc,
|
long_description=longdesc,
|
||||||
python_requires='>=3.5',
|
python_requires='>=3.6',
|
||||||
keywords=[
|
keywords=[
|
||||||
'abcddb',
|
'abcddb',
|
||||||
'abcd',
|
'abcd',
|
||||||
@@ -41,7 +41,6 @@ setup(
|
|||||||
'Programming Language :: Python',
|
'Programming Language :: Python',
|
||||||
'Programming Language :: Python :: 3',
|
'Programming Language :: Python :: 3',
|
||||||
'Programming Language :: Python :: 3 :: Only',
|
'Programming Language :: Python :: 3 :: Only',
|
||||||
'Programming Language :: Python :: 3.5',
|
|
||||||
'Programming Language :: Python :: 3.6',
|
'Programming Language :: Python :: 3.6',
|
||||||
'Programming Language :: Python :: 3.7',
|
'Programming Language :: Python :: 3.7',
|
||||||
'Programming Language :: Python :: 3.8',
|
'Programming Language :: Python :: 3.8',
|
||||||
|
|||||||
Reference in New Issue
Block a user