-
Notifications
You must be signed in to change notification settings - Fork 66
/
carddav_object.py
1675 lines (1514 loc) · 71.6 KB
/
carddav_object.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Classes and logic to handle vCards in khard.
This module explicitly supports the vCard specifications version 3.0 and 4.0
which can be found here:
- version 3.0: https://tools.ietf.org/html/rfc2426
- version 4.0: https://tools.ietf.org/html/rfc6350
"""
import copy
import datetime
import locale
import logging
import os
import re
import sys
import time
from typing import Callable, Dict, List, Optional, Tuple, Union
from atomicwrites import atomic_write
from ruamel import yaml
import vobject
from . import address_book
from . import helpers
from .object_type import ObjectType
from .query import AnyQuery, Query
logger = logging.getLogger(__name__)
def convert_to_vcard(name: str, value: Union[str, List[str]],
allowed_object_type: ObjectType) -> Union[str, List[str]]:
"""converts user input into vcard compatible data structures
:param name: object name, only required for error messages
:param value: user input
:param allowed_object_type: set the accepted return type for vcard
attribute
:returns: cleaned user input, ready for vcard or a ValueError
"""
if isinstance(value, str):
if allowed_object_type == ObjectType.list_with_strings:
return [value.strip()]
return value.strip()
if isinstance(value, list):
if allowed_object_type == ObjectType.string:
raise ValueError("Error: " + name + " must contain a string.")
if not all(isinstance(entry, str) for entry in value):
raise ValueError("Error: " + name +
" must not contain a nested list")
# filter out empty list items and strip leading and trailing space
return [x.strip() for x in value if x.strip()]
if allowed_object_type == ObjectType.string:
raise ValueError("Error: " + name + " must be a string.")
if allowed_object_type == ObjectType.list_with_strings:
raise ValueError("Error: " + name + " must be a list with strings.")
raise ValueError("Error: " + name +
" must be a string or a list with strings.")
def multi_property_key(item: Union[str, Dict]) -> List:
"""key function to pass to sorted(), allowing sorting of dicts with lists
and strings. Dicts will be sorted by their label, after other types.
:param item: member of the list being sorted
:type item: a dict with a single entry or any sortable type
:returns: a list with two members. The first is int(isinstance(item, dict).
The second is either the key from the dict or the unchanged item if it
is not a dict.
:rtype: list(int, type(item)) or list(int, str)
"""
if isinstance(item, dict):
return [1, list(item)[0]]
return [0, item]
class VCardWrapper:
"""Wrapper class around a vobject.vCard object.
This class can wrap a single vCard and presents its data in a manner
suitable for khard. Additionally some details of the vCard specifications
in RFC 2426 (version 3.0) and RFC 6350 (version 4.0) that are not enforced
by the vobject library are enforced here.
"""
_default_version = "3.0"
_supported_versions = ("3.0", "4.0")
# vcard v3.0 supports the following type values
phone_types_v3 = ("bbs", "car", "cell", "fax", "home", "isdn", "msg",
"modem", "pager", "pcs", "video", "voice", "work")
email_types_v3 = ("home", "internet", "work", "x400")
address_types_v3 = ("dom", "intl", "home", "parcel", "postal", "work")
# vcard v4.0 supports the following type values
phone_types_v4 = ("text", "voice", "fax", "cell", "video", "pager",
"textphone", "home", "work")
email_types_v4 = ("home", "internet", "work")
address_types_v4 = ("home", "work")
def __init__(self, vcard: vobject.vCard, version: Optional[str] = None
) -> None:
"""Initialize the wrapper around the given vcard.
:param vobject.vCard vcard: the vCard to wrap
:param version: the version of the RFC to use (if the card has none)
:type version: str or None
"""
self.vcard = vcard
if not self.version:
version = version or self._default_version
logger.warning("Wrapping unversioned vCard object, setting "
"version to %s.", version)
self.version = version
elif self.version not in self._supported_versions:
logger.warning("Wrapping vCard with unsupported version %s, this "
"might change any incompatible attributes.",
version)
def __str__(self) -> str:
return self.formatted_name
def _get_string_field(self, field: str) -> str:
"""Get a string field from the underlying vCard.
:param field: the field value to get
:returns: the field value or the empty string
"""
try:
return getattr(self.vcard, field).value
except AttributeError:
return ""
def _get_multi_property(self, name: str) -> List:
"""Get a vCard property that can exist more than once.
It does not matter what the individual vcard properties store as their
value. This function returnes them untouched inside an agregating
list.
If the property is part of a group containing exactly two items, with
exactly one ABLABEL. the property will be prefixed with that ABLABEL.
:param name: the name of the property (should be UPPER case)
:returns: the values from all occurences of the named property
"""
values = []
for child in self.vcard.getChildren():
if child.name == name:
ablabel = self._get_ablabel(child)
if ablabel:
values.append({ablabel: child.value})
else:
values.append(child.value)
return sorted(values, key=multi_property_key)
def _delete_vcard_object(self, name: str) -> None:
"""Delete all fields with the given name from the underlying vCard.
If a field that will be deleted is in a group with an X-ABLABEL field,
that X-ABLABEL field will also be deleted. These fields are commonly
added by the Apple address book to attach custom labels to some fields.
:param name: the name of the fields to delete
"""
# first collect all vcard items, which should be removed
to_be_removed = []
for child in self.vcard.getChildren():
if child.name == name:
if child.group:
for label in self.vcard.getChildren():
if label.name == "X-ABLABEL" and \
label.group == child.group:
to_be_removed.append(label)
to_be_removed.append(child)
# then delete them one by one
for item in to_be_removed:
self.vcard.remove(item)
@staticmethod
def _parse_type_value(types: List[str], supported_types: List[str]
) -> Tuple[List[str], List[str], int]:
"""Parse type value of phone numbers, email and post addresses.
:param types: list of type values
:param supported_types: all allowed standard types
:returns: tuple of standard and custom types and pref integer
"""
custom_types = []
standard_types = []
pref = 0
for type in types:
type = type.strip()
if type:
if type.lower() in supported_types:
standard_types.append(type)
elif type.lower() == "pref":
pref += 1
elif re.match(r"^pref=\d{1,2}$", type.lower()):
pref += int(type.split("=")[1])
else:
if type.lower().startswith("x-"):
custom_types.append(type[2:])
standard_types.append(type)
else:
custom_types.append(type)
standard_types.append("X-{}".format(type))
return (standard_types, custom_types, pref)
def _get_types_for_vcard_object(self, object: vobject.base.ContentLine,
default_type: str) -> List[str]:
"""get list of types for phone number, email or post address
:param object: vcard class object
:param default_type: use if the object contains no type
:returns: list of type labels
"""
type_list = []
# try to find label group for custom value type
if object.group:
for label in self.vcard.getChildren():
if label.name == "X-ABLABEL" and label.group == object.group:
custom_type = label.value.strip()
if custom_type:
type_list.append(custom_type)
# then load type from params dict
standard_types = object.params.get("TYPE")
if standard_types is not None:
if not isinstance(standard_types, list):
standard_types = [standard_types]
for type in standard_types:
type = type.strip()
if type and type.lower() != "pref":
if not type.lower().startswith("x-"):
type_list.append(type)
elif type[2:].lower() not in [x.lower()
for x in type_list]:
# add x-custom type in case it's not already added by
# custom label for loop above but strip x- before
type_list.append(type[2:])
# try to get pref parameter from vcard version 4.0
try:
type_list.append("pref={}".format(
int(object.params.get("PREF")[0])))
except (IndexError, TypeError, ValueError):
# else try to determine, if type params contain pref attribute
try:
for type in object.params.get("TYPE"):
if type.lower() == "pref" and "pref" not in type_list:
type_list.append("pref")
except TypeError:
pass
# return type_list or default type
if type_list:
return type_list
return [default_type]
@property
def version(self) -> str:
return self._get_string_field("version")
@version.setter
def version(self, value: str) -> None:
if value not in self._supported_versions:
logger.warning("Setting vcard version to unsupported version %s",
value)
# All vCards should only always have one version, this is a requirement
# for version 4 but also makes sense for all other versions.
self._delete_vcard_object("VERSION")
version = self.vcard.add("version")
version.value = convert_to_vcard("version", value, ObjectType.string)
@property
def uid(self) -> str:
return self._get_string_field("uid")
@uid.setter
def uid(self, value: str) -> None:
# All vCards should only always have one UID, this is a requirement
# for version 4 but also makes sense for all other versions.
self._delete_vcard_object("UID")
uid = self.vcard.add('uid')
uid.value = convert_to_vcard("uid", value, ObjectType.string)
def _update_revision(self) -> None:
"""Generate a new REV field for the vCard, replace any existing
All vCards should only always have one revision, this is a
requirement for version 4 but also makes sense for all other
versions.
:rtype: NoneType
"""
self._delete_vcard_object("REV")
rev = self.vcard.add('rev')
rev.value = datetime.datetime.now().strftime("%Y%m%dT%H%M%SZ")
@property
def birthday(self) -> Union[None, str, datetime.datetime]:
"""Return the birthday as a datetime object or a string depending on
weather it is of type text or not. If no birthday is present in the
vcard None is returned.
:returns: contacts birthday or None if not available
"""
# vcard 4.0 could contain a single text value
try:
if self.vcard.bday.params.get("VALUE")[0] == "text":
return self.vcard.bday.value
except (AttributeError, IndexError, TypeError):
pass
# else try to convert to a datetime object
try:
return helpers.string_to_date(self.vcard.bday.value)
except (AttributeError, ValueError):
pass
return None
@birthday.setter
def birthday(self, date: Union[str, datetime.datetime]) -> None:
"""Store the given date as BDAY in the vcard.
:param date: the new date to store as birthday
"""
value, text = self._prepare_birthday_value(date)
if value is None:
logger.warning('Failed to set anniversary to %s', date)
return
bday = self.vcard.add('bday')
bday.value = value
if text:
bday.params['VALUE'] = ['text']
@property
def anniversary(self) -> Union[None, str, datetime.datetime]:
"""
:returns: contacts anniversary or None if not available
"""
# vcard 4.0 could contain a single text value
try:
if self.vcard.anniversary.params.get("VALUE")[0] == "text":
return self.vcard.anniversary.value
except (AttributeError, IndexError, TypeError):
pass
# else try to convert to a datetime object
try:
return helpers.string_to_date(self.vcard.anniversary.value)
except (AttributeError, ValueError):
# vcard 3.0: x-anniversary (private object)
try:
return helpers.string_to_date(self.vcard.x_anniversary.value)
except (AttributeError, ValueError):
pass
return None
@anniversary.setter
def anniversary(self, date: Union[str, datetime.datetime]) -> None:
value, text = self._prepare_birthday_value(date)
if value is None:
logger.warning('Failed to set anniversary to %s', date)
return
if text:
anniversary = self.vcard.add('anniversary')
anniversary.params['VALUE'] = ['text']
anniversary.value = value
elif self.version == "4.0":
self.vcard.add('anniversary').value = value
else:
self.vcard.add('x-anniversary').value = value
def _get_ablabel(self, item: vobject.base.ContentLine) -> str:
"""Get an ABLABEL for a specified item in the vCard.
Will return the ABLABEL only if the item is part of a group with exactly
two items, exactly one of which is an ABLABEL.
:param item: the item to be labelled
:returns: the ABLABEL in the circumstances above or an empty string
"""
label = ""
if item.group:
count = 0
for child in self.vcard.getChildren():
if child.group and child.group == item.group:
count += 1
if child.name == "X-ABLABEL":
if label == "":
label = child.value
else:
return ""
if count != 2:
label = ""
return label
def _get_new_group(self, group_type: str = "") -> str:
"""Get an unused group name for adding new groups. Uses the form item123
or itemgroup_type123 if a grouptype is specified.
:param group_type: (Optional) a string to add between "item" and
the number
:returns: the name of the first unused group of the specified form
"""
counter = 1
while True:
group_name = "item{}{}".format(group_type, counter)
for child in self.vcard.getChildren():
if child.group and child.group == group_name:
counter += 1
break
else:
return group_name
def _add_labelled_object(
self, obj_type: str, user_input, name_groups: bool = False,
allowed_object_type: ObjectType = ObjectType.string) -> None:
"""Add an object to the VCARD. If user_input is a dict, the object will
be added to a group with an ABLABEL created from the key of the dict.
:param obj_type: type of object to add to the VCARD.
:param user_input: Contents of the object to add. If a dict
:type user_input: str or list(str) or dict(str) or dict(list(str))
:param name_groups: (Optional) If True, use the obj_type in the
group name for labelled objects.
:param allowed_object_type: (Optional) set the accepted return type
for vcard attribute
"""
obj = self.vcard.add(obj_type)
if isinstance(user_input, dict):
if len(user_input) > 1:
raise ValueError(
"Error: {} must be a string or a dict containing one "
"key/value pair.".format(obj_type))
label = list(user_input)[0]
group_name = self._get_new_group(obj_type if name_groups else "")
obj.group = group_name
obj.value = convert_to_vcard(obj_type, user_input[label],
allowed_object_type)
ablabel_obj = self.vcard.add('X-ABLABEL')
ablabel_obj.group = group_name
ablabel_obj.value = label
else:
obj.value = convert_to_vcard(obj_type, user_input,
allowed_object_type)
def _prepare_birthday_value(self, date: Union[str, datetime.datetime]
) -> Tuple[Optional[str], bool]:
"""Prepare a value to be stored in a BDAY or ANNIVERSARY attribute.
:param date: the date like value to be stored
:type date: datetime.datetime or str
:returns: the object to set as the .value for the attribute and weather
it should be stored as plain text
:rtype: tuple(str,bool)
"""
if isinstance(date, str):
if self.version == "4.0":
return date.strip(), True
return None, False
tz = date.tzname()
if date.year == 1900 and date.month != 0 and date.day != 0 \
and date.hour == 0 and date.minute == 0 and date.second == 0 \
and self.version == "4.0":
fmt = '--%m%d'
elif tz and tz[3:]:
if self.version == "4.0":
fmt = "%Y%m%dT%H%M%S{}".format(tz[3:])
else:
fmt = "%FT%T{}".format(tz[3:])
elif date.hour != 0 or date.minute != 0 or date.second != 0:
if self.version == "4.0":
fmt = "%Y%m%dT%H%M%SZ"
else:
fmt = "%FT%TZ"
else:
if self.version == "4.0":
fmt = "%Y%m%d"
else:
fmt = "%F"
return date.strftime(fmt), False
@property
def formatted_name(self) -> str:
return self._get_string_field("fn")
@formatted_name.setter
def formatted_name(self, value: str) -> None:
"""Set the FN field to the new value.
All previously existing FN fields are deleted. Version 4 of the specs
requires the vCard to only habe one FN field. For other versions we
enforce this equally.
:param str value: the new formatted name
:returns: None
"""
self._delete_vcard_object("FN")
if value:
final = convert_to_vcard("FN", value, ObjectType.string)
elif self._get_first_names() or self._get_last_names():
# autofill the FN field from the N field
names = [self._get_name_prefixes(),
self._get_first_names(),
self._get_last_names(),
self._get_name_suffixes()]
names = [x for x in names if x]
final = helpers.list_to_string(names, " ")
else: # add an empty FN
final = ""
self.vcard.add("FN").value = final
def _get_names_part(self, part: str) -> List[str]:
"""Get some part of the "N" entry in the vCard as a list
:param part: the name to get e.g. "prefix" or "given"
:returns: a list of entries for this name part
"""
try:
the_list = getattr(self.vcard.n.value, part)
except AttributeError:
return []
else:
# check if list only contains empty strings
if not ''.join(the_list):
return []
return the_list if isinstance(the_list, list) else [the_list]
def _get_name_prefixes(self) -> List[str]:
return self._get_names_part("prefix")
def _get_first_names(self) -> List[str]:
return self._get_names_part("given")
def _get_additional_names(self) -> List[str]:
return self._get_names_part("additional")
def _get_last_names(self) -> List[str]:
return self._get_names_part("family")
def _get_name_suffixes(self) -> List[str]:
return self._get_names_part("suffix")
def get_first_name_last_name(self) -> str:
"""Compute the full name of the contact by joining first, additional
and last names together
"""
names = self._get_first_names() + self._get_additional_names() + \
self._get_last_names()
if names:
return helpers.list_to_string(names, " ")
return self.formatted_name
def get_last_name_first_name(self) -> str:
"""Compute the full name of the contact by joining the last names and
then after a comma the first and additional names together
"""
last_names: List[str] = []
if self._get_last_names():
last_names += self._get_last_names()
first_and_additional_names = self._get_first_names() + \
self._get_additional_names()
if last_names and first_and_additional_names:
return "{}, {}".format(
helpers.list_to_string(last_names, " "),
helpers.list_to_string(first_and_additional_names, " "))
if last_names:
return helpers.list_to_string(last_names, " ")
if first_and_additional_names:
return helpers.list_to_string(first_and_additional_names, " ")
return self.formatted_name
def _add_name(self, prefix: Union[str, List[str]],
first_name: Union[str, List[str]],
additional_name: Union[str, List[str]],
last_name: Union[str, List[str]],
suffix: Union[str, List[str]]) -> None:
"""Add an N entry to the vCard. No old entries are affected.
:param prefix:
:param first_name:
:param additional_name:
:param last_name:
:param suffix:
"""
name_obj = self.vcard.add('n')
stringlist = ObjectType.string_or_list_with_strings
name_obj.value = vobject.vcard.Name(
prefix=convert_to_vcard("name prefix", prefix, stringlist),
given=convert_to_vcard("first name", first_name, stringlist),
additional=convert_to_vcard("additional name", additional_name,
stringlist),
family=convert_to_vcard("last name", last_name, stringlist),
suffix=convert_to_vcard("name suffix", suffix, stringlist))
@property
def organisations(self) -> List[Union[List[str], Dict[str, List[str]]]]:
"""
:returns: list of organisations, sorted alphabetically
"""
return self._get_multi_property("ORG")
def _add_organisation(self, organisation: Union[str, List[str]]) -> None:
"""Add one ORG entry to the underlying vcard
:param organisation: the value to add
"""
self._add_labelled_object("org", organisation, True,
ObjectType.list_with_strings)
# check if fn attribute is already present
if not self.vcard.getChildValue("fn") and self.organisations:
# if not, set fn to organisation name
first_org = self.organisations[0]
if isinstance(first_org, dict):
first_org = list(first_org.values())[0]
org_value = helpers.list_to_string(first_org, ", ")
self.formatted_name = org_value.replace("\n", " ").replace("\\",
"")
showas_obj = self.vcard.add('x-abshowas')
showas_obj.value = "COMPANY"
@property
def titles(self) -> List[Union[str, Dict[str, str]]]:
return self._get_multi_property("TITLE")
def _add_title(self, title) -> None:
self._add_labelled_object("title", title, True)
@property
def roles(self) -> List[Union[str, Dict[str, str]]]:
return self._get_multi_property("ROLE")
def _add_role(self, role) -> None:
self._add_labelled_object("role", role, True)
@property
def nicknames(self) -> List[Union[str, Dict[str, str]]]:
return self._get_multi_property("NICKNAME")
def _add_nickname(self, nickname) -> None:
self._add_labelled_object("nickname", nickname, True)
@property
def notes(self) -> List[Union[str, Dict[str, str]]]:
return self._get_multi_property("NOTE")
def _add_note(self, note) -> None:
self._add_labelled_object("note", note, True)
@property
def webpages(self) -> List[Union[str, Dict[str, str]]]:
return self._get_multi_property("URL")
def _add_webpage(self, webpage) -> None:
self._add_labelled_object("url", webpage, True)
@property
def categories(self) -> Union[List[str], List[List[str]]]:
category_list = []
for child in self.vcard.getChildren():
if child.name == "CATEGORIES":
value = child.value
category_list.append(
value if isinstance(value, list) else [value])
if len(category_list) == 1:
return category_list[0]
return sorted(category_list)
def _add_category(self, categories: List[str]) -> None:
"""Add categories to the vCard
:param categories:
"""
categories_obj = self.vcard.add('categories')
categories_obj.value = convert_to_vcard("category", categories,
ObjectType.list_with_strings)
@property
def phone_numbers(self) -> Dict[str, List[str]]:
"""
:returns: dict of type and phone number list
"""
phone_dict: Dict[str, List[str]] = {}
for child in self.vcard.getChildren():
if child.name == "TEL":
# phone types
type = helpers.list_to_string(
self._get_types_for_vcard_object(child, "voice"), ", ")
if type not in phone_dict:
phone_dict[type] = []
# phone value
#
# vcard version 4.0 allows URI scheme "tel" in phone attribute value
# Doc: https://tools.ietf.org/html/rfc6350#section-6.4.1
# example: TEL;VALUE=uri;PREF=1;TYPE="voice,home":tel:+1-555-555-5555;ext=5555
if child.value.lower().startswith("tel:"):
# cut off the "tel:" uri prefix
phone_dict[type].append(child.value[4:])
else:
# free text field
phone_dict[type].append(child.value)
# sort phone number lists
for number_list in phone_dict.values():
number_list.sort()
return phone_dict
def _add_phone_number(self, type, number):
standard_types, custom_types, pref = self._parse_type_value(
helpers.string_to_list(type, ","), self.phone_types_v4 if
self.version == "4.0" else self.phone_types_v3)
if not standard_types and not custom_types and pref == 0:
raise ValueError("Error: label for phone number " + number +
" is missing.")
if len(custom_types) > 1:
raise ValueError("Error: phone number " + number + " got more "
"than one custom label: " +
helpers.list_to_string(custom_types, ", "))
phone_obj = self.vcard.add('tel')
if self.version == "4.0":
phone_obj.value = "tel:{}".format(
convert_to_vcard("phone number", number, ObjectType.string))
phone_obj.params['VALUE'] = ["uri"]
if pref > 0:
phone_obj.params['PREF'] = str(pref)
else:
phone_obj.value = convert_to_vcard("phone number", number,
ObjectType.string)
if pref > 0:
standard_types.append("pref")
if standard_types:
phone_obj.params['TYPE'] = standard_types
if custom_types:
custom_label_count = 0
for label in self.vcard.getChildren():
if label.name == "X-ABLABEL" and label.group.startswith(
"itemtel"):
custom_label_count += 1
group_name = "itemtel{}".format(custom_label_count + 1)
phone_obj.group = group_name
label_obj = self.vcard.add('x-ablabel')
label_obj.group = group_name
label_obj.value = custom_types[0]
@property
def emails(self) -> Dict[str, List[str]]:
"""
:returns: dict of type and email address list
"""
email_dict: Dict[str, List[str]] = {}
for child in self.vcard.getChildren():
if child.name == "EMAIL":
type = helpers.list_to_string(
self._get_types_for_vcard_object(child, "internet"), ", ")
if type not in email_dict:
email_dict[type] = []
email_dict[type].append(child.value)
# sort email address lists
for email_list in email_dict.values():
email_list.sort()
return email_dict
def add_email(self, type, address):
standard_types, custom_types, pref = self._parse_type_value(
helpers.string_to_list(type, ","), self.email_types_v4 if
self.version == "4.0" else self.email_types_v3)
if not standard_types and not custom_types and pref == 0:
raise ValueError("Error: label for email address " + address +
" is missing.")
if len(custom_types) > 1:
raise ValueError("Error: email address " + address + " got more "
"than one custom label: " +
helpers.list_to_string(custom_types, ", "))
email_obj = self.vcard.add('email')
email_obj.value = convert_to_vcard("email address", address,
ObjectType.string)
if self.version == "4.0":
if pref > 0:
email_obj.params['PREF'] = str(pref)
else:
if pref > 0:
standard_types.append("pref")
if standard_types:
email_obj.params['TYPE'] = standard_types
if custom_types:
custom_label_count = 0
for label in self.vcard.getChildren():
if label.name == "X-ABLABEL" and label.group.startswith(
"itememail"):
custom_label_count += 1
group_name = "itememail{}".format(custom_label_count + 1)
email_obj.group = group_name
label_obj = self.vcard.add('x-ablabel')
label_obj.group = group_name
label_obj.value = custom_types[0]
@property
def post_addresses(self) -> Dict[str, List[Dict[str, Union[List, str]]]]:
"""
:returns: dict of type and post address list
"""
post_adr_dict: Dict[str, List[Dict[str, Union[List, str]]]] = {}
for child in self.vcard.getChildren():
if child.name == "ADR":
type = helpers.list_to_string(self._get_types_for_vcard_object(
child, "home"), ", ")
if type not in post_adr_dict:
post_adr_dict[type] = []
post_adr_dict[type].append({"box": child.value.box,
"extended": child.value.extended,
"street": child.value.street,
"code": child.value.code,
"city": child.value.city,
"region": child.value.region,
"country": child.value.country})
# sort post address lists
for post_adr_list in post_adr_dict.values():
post_adr_list.sort(key=lambda x: (
helpers.list_to_string(x['city'], " ").lower(),
helpers.list_to_string(x['street'], " ").lower()))
return post_adr_dict
def get_formatted_post_addresses(self) -> Dict[str, List[str]]:
list2str = helpers.list_to_string
formatted_post_adr_dict: Dict[str, List[str]] = {}
for type, post_adr_list in self.post_addresses.items():
formatted_post_adr_dict[type] = []
for post_adr in post_adr_list:
get = lambda name: list2str(post_adr.get(name, ""), " ")
strings = []
if "street" in post_adr:
strings.append(list2str(post_adr.get("street", ""), "\n"))
if "box" in post_adr and "extended" in post_adr:
strings.append("{} {}".format(get("box"), get("extended")))
elif "box" in post_adr:
strings.append(get("box"))
elif "extended" in post_adr:
strings.append(get("extended"))
if "code" in post_adr and "city" in post_adr:
strings.append("{} {}".format(get("code"), get("city")))
elif "code" in post_adr:
strings.append(get("code"))
elif "city" in post_adr:
strings.append(get("city"))
if "region" in post_adr and "country" in post_adr:
strings.append("{}, {}".format(get("region"),
get("country")))
elif "region" in post_adr:
strings.append(get("region"))
elif "country" in post_adr:
strings.append(get("country"))
formatted_post_adr_dict[type].append('\n'.join(strings))
return formatted_post_adr_dict
def _add_post_address(self, type, box, extended, street, code, city,
region, country):
standard_types, custom_types, pref = self._parse_type_value(
helpers.string_to_list(type, ","),
self.address_types_v4 if self.version == "4.0" else
self.address_types_v3)
if not standard_types and not custom_types and pref == 0:
raise ValueError("Error: label for post address " + street +
" is missing.")
if len(custom_types) > 1:
raise ValueError("Error: post address " + street + " got more "
"than one custom " "label: " +
helpers.list_to_string(custom_types, ", "))
adr_obj = self.vcard.add('adr')
adr_obj.value = vobject.vcard.Address(
box=convert_to_vcard("box address field", box,
ObjectType.string_or_list_with_strings),
extended=convert_to_vcard("extended address field", extended,
ObjectType.string_or_list_with_strings),
street=convert_to_vcard("street", street,
ObjectType.string_or_list_with_strings),
code=convert_to_vcard("post code", code,
ObjectType.string_or_list_with_strings),
city=convert_to_vcard("city", city,
ObjectType.string_or_list_with_strings),
region=convert_to_vcard("region", region,
ObjectType.string_or_list_with_strings),
country=convert_to_vcard("country", country,
ObjectType.string_or_list_with_strings))
if self.version == "4.0":
if pref > 0:
adr_obj.params['PREF'] = str(pref)
else:
if pref > 0:
standard_types.append("pref")
if standard_types:
adr_obj.params['TYPE'] = standard_types
if custom_types:
custom_label_count = 0
for label in self.vcard.getChildren():
if label.name == "X-ABLABEL" and label.group.startswith(
"itemadr"):
custom_label_count += 1
group_name = "itemadr{}".format(custom_label_count + 1)
adr_obj.group = group_name
label_obj = self.vcard.add('x-ablabel')
label_obj.group = group_name
label_obj.value = custom_types[0]
class YAMLEditable(VCardWrapper):
"""Conversion of vcards to YAML and updateing the vcard from YAML"""
def __init__(self, vcard: vobject.vCard,
supported_private_objects: Optional[List[str]] = None,
version: Optional[str] = None, localize_dates: bool = False
) -> None:
"""Initialize atributes needed for yaml conversions
:param supported_private_objects: the list of private property names
that will be loaded from the actual vcard and represented in this
pobject
:param version: the version of the RFC to use in this card
:param localize_dates: should the formatted output of anniversary
and birthday be localized or should the isoformat be used instead
"""
self.supported_private_objects = supported_private_objects or []
self.localize_dates = localize_dates
super().__init__(vcard, version)
#####################
# getters and setters
#####################
def _get_private_objects(self) -> Dict[str, List[str]]:
supported = [x.lower() for x in self.supported_private_objects]
private_objects: Dict[str, List[str]] = {}
for child in self.vcard.getChildren():
lower = child.name.lower()
if lower.startswith("x-") and lower[2:] in supported:
key_index = supported.index(lower[2:])
key = self.supported_private_objects[key_index]
if key not in private_objects:
private_objects[key] = []
ablabel = self._get_ablabel(child)
private_objects[key].append(
{ablabel: child.value} if ablabel else child.value)
# sort private object lists
for value in private_objects.values():
value.sort(key=multi_property_key)
return private_objects
def _add_private_object(self, key: str, value) -> None:
self._add_labelled_object('X-' + key.upper(), value)
def get_formatted_anniversary(self) -> str:
return self._format_date_object(self.anniversary, self.localize_dates)
def get_formatted_birthday(self) -> str:
return self._format_date_object(self.birthday, self.localize_dates)
#######################
# object helper methods
#######################
@staticmethod
def _format_date_object(date: Union[None, str, datetime.datetime],
localize: bool) -> str:
if not date:
return ""
if isinstance(date, str):
return date
if date.year == 1900 and date.month != 0 and date.day != 0 \
and date.hour == 0 and date.minute == 0 and date.second == 0:
return date.strftime("--%m-%d")
tz = date.tzname()
if (tz and tz[3:]) or (date.hour != 0 or date.minute != 0
or date.second != 0):
if localize:
return date.strftime(locale.nl_langinfo(locale.D_T_FMT))
utc_offset = -time.timezone / 60 / 60
return date.strftime("%FT%T+{:02}:00".format(int(utc_offset)))
if localize:
return date.strftime(locale.nl_langinfo(locale.D_FMT))
return date.strftime("%F")
@staticmethod
def _filter_invalid_tags(contents: str) -> str:
for pat, repl in [('aim', 'AIM'), ('gadu', 'GADUGADU'),
('groupwise', 'GROUPWISE'), ('icq', 'ICQ'),
('xmpp', 'JABBER'), ('msn', 'MSN'),
('yahoo', 'YAHOO'), ('skype', 'SKYPE'),
('irc', 'IRC'), ('sip', 'SIP')]:
contents = re.sub('X-messaging/'+pat+'-All', 'X-'+repl, contents,
flags=re.IGNORECASE)
return contents
@staticmethod
def _parse_yaml(input: str) -> Dict:
"""Parse a YAML document into a dictinary and validate the data to some
degree.
:param str input: the YAML document to parse
:returns: the parsed datastructure
:rtype: dict
"""
yaml_parser = yaml.YAML(typ='base')
# parse user input string
try:
contact_data = yaml_parser.load(input)