From 6cac24bb25a9e6b7cf094cab87be1333b088410d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sun, 28 Oct 2018 09:09:59 +0100 Subject: [PATCH 01/12] Contacts: add basic sync support with vdirsyncer, split AddressBook and Contact --- apps/personal/contacts/address_book.py | 163 +++------------------- apps/personal/contacts/contact.py | 155 ++++++++++++++++++++ apps/personal/contacts/main.py | 99 +++++++++---- apps/personal/contacts/vcard_converter.py | 4 +- 4 files changed, 241 insertions(+), 180 deletions(-) create mode 100644 apps/personal/contacts/contact.py diff --git a/apps/personal/contacts/address_book.py b/apps/personal/contacts/address_book.py index 49a36de7..6ddbf732 100644 --- a/apps/personal/contacts/address_book.py +++ b/apps/personal/contacts/address_book.py @@ -3,6 +3,8 @@ from helpers import Singleton, flatten from helpers import setup_logger +from vcard_converter import VCardContactConverter +from contact import Contact logger = setup_logger(__name__, "warning") @@ -100,7 +102,8 @@ def find_duplicates(self, contact): # type: (Contact) -> list if contact in self._contacts: return [1, contact] - match_score_contact_list = [(c.match_score(contact), c) for c in self.contacts] + match_score_contact_list = [(c.match_score(contact), c) for c in + self.contacts] def cmp(a1, a2): # type: (tuple, tuple) -> int @@ -108,154 +111,20 @@ def cmp(a1, a2): return sorted(match_score_contact_list, cmp=cmp) + def import_vcards_from_directory(self, directory): + logger.info("Import vCards from {}".format(directory)) -class Contact(object): - """ - >>> c = Contact() - >>> c.name - [] - >>> c = Contact(name="John") - >>> c.name - ['John'] - """ - - def __init__(self, **kwargs): - self.name = [] - self.address = [] - self.telephone = [] - self.email = [] - self.url = [] - self.note = [] - self.org = [] - self.photo = [] - self.title = [] - self.from_kwargs(kwargs) - - def from_kwargs(self, kwargs): - provided_attrs = {attr: kwargs[attr] for attr in self.get_all_attributes() if attr in kwargs.keys()} - for attr_name in provided_attrs: - attr_value = provided_attrs[attr_name] - if isinstance(attr_value, list): - setattr(self, attr_name, attr_value) - else: - setattr(self, attr_name, [attr_value]) - - def match_score(self, other): - # type: (Contact) -> int - """ - Computes how many element matches with other and self - >>> c1 = Contact(name="John", telephone="911") - >>> c2 = Contact(name="Johnny") - >>> c1.match_score(c2) - 0 - >>> c2.telephone = ["123", "911"] # now the contacts have 911 in common - >>> c1.match_score(c2) - 1 - - Now add a common nickname to them, ignoring case - >>> c1.name.append("deepthroat") - >>> c2.name.append("DeepThroat") - >>> c1.match_score(c2) - 2 - """ - common_attrs = set(self.get_filled_attributes()).intersection(other.get_filled_attributes()) - return sum([self.common_attribute_count(getattr(self, attr), getattr(other, attr)) for attr in common_attrs]) - - def consolidate(self): - """ - Merge duplicate attributes - >>> john = Contact() - >>> john.name = ['John', 'John Doe', ' John Doe', 'Darling'] - >>> john.consolidate() - >>> 'Darling' in john.name - True - >>> 'John Doe' in john.name - True - >>> len(john.name) - 2 - >>> john.org = [['whatever org']] - >>> john.consolidate() - >>> john.org - ['whatever org'] - """ - my_attributes = self.get_filled_attributes() - for name in my_attributes: # removes exact duplicates - self.consolidate_attribute(name) - - def get_filled_attributes(self): - """ - >>> c = Contact() - >>> c.name = ["John", "Johnny"] - >>> c.note = ["That's him !"] - >>> c.get_filled_attributes() - ['name', 'note'] - """ - return [a for a in dir(self) - if not callable(getattr(self, a)) and not a.startswith("__") and len(getattr(self, a))] - - def get_all_attributes(self): - return [a for a in dir(self) if not callable(getattr(self, a)) and not a.startswith("__")] - - def consolidate_attribute(self, attribute_name): - # type: (str) -> None - attr_value = getattr(self, attribute_name) - attr_value = flatten(attr_value) - attr_value = list(set([i.strip() for i in attr_value if isinstance(i, basestring)])) # removes exact duplicates - - attr_value[:] = [x for x in attr_value if not self._is_contained_in_other_element_of_the_list(x, attr_value)] - - setattr(self, attribute_name, list(set(attr_value))) - - def merge(self, other): - # type: (Contact) -> None - """ - >>> c1 = Contact() - >>> c1.name = ["John"] - >>> c2 = Contact() - >>> c2.name = ["John"] - >>> c2.telephone = ["911"] - >>> c1.merge(c2) - >>> c1.telephone - ['911'] - """ - attr_sum = self.get_filled_attributes() + other.get_filled_attributes() - for attr_name in attr_sum: - attrs_sum = getattr(self, attr_name) + getattr(other, attr_name) - setattr(self, attr_name, attrs_sum) - self.consolidate() - - def short_name(self): - for attr_name in self.get_filled_attributes(): - for attribute in getattr(self, attr_name): - if not isinstance(attribute, basestring) and not isinstance(attribute, list): - continue - if isinstance(attribute, list): - for entry_str in attribute: - if not isinstance(entry_str, basestring): - continue - else: - return attribute - return "unknown" - - @staticmethod - def common_attribute_count(a1, a2): - # type: (list, list) -> int - a1_copy = [i.lower() for i in a1 if isinstance(i, basestring)] - a2_copy = [i.lower() for i in a2 if isinstance(i, basestring)] - return len(set(a1_copy).intersection(a2_copy)) - - @staticmethod - def _is_contained_in_other_element_of_the_list(p_element, the_list): - """ - """ - # type: (object, list) -> bool - copy = list(the_list) - copy.remove(p_element) - for element in copy: - if p_element in element: - return True - return False + # Extract *cvf files from the directory + home = os.path.expanduser(directory) + if not os.path.exists(home): + os.mkdir(home) + vcard_files = [os.path.join(home, f) for f in os.listdir(home) if + f.lower().endswith("vcf")] + # Import into current AddressBook instance + parsed_contacts = VCardContactConverter.from_vcards(vcard_files) + for c in parsed_contacts: + self.add_contact(c) SAVE_FILENAME = "contacts.pickle" ZPUI_HOME = "~/.phone/" diff --git a/apps/personal/contacts/contact.py b/apps/personal/contacts/contact.py new file mode 100644 index 00000000..cc83609c --- /dev/null +++ b/apps/personal/contacts/contact.py @@ -0,0 +1,155 @@ +import os +import pickle + +from helpers import Singleton, flatten +from helpers import setup_logger +#from vcard_converter import VCardContactConverter + +logger = setup_logger(__name__, "warning") + +class Contact(object): + """ + >>> c = Contact() + >>> c.name + [] + >>> c = Contact(name="John") + >>> c.name + ['John'] + """ + + def __init__(self, **kwargs): + self.name = [] + self.address = [] + self.telephone = [] + self.email = [] + self.url = [] + self.note = [] + self.org = [] + self.photo = [] + self.title = [] + self.from_kwargs(kwargs) + + def from_kwargs(self, kwargs): + provided_attrs = {attr: kwargs[attr] for attr in self.get_all_attributes() if attr in kwargs.keys()} + for attr_name in provided_attrs: + attr_value = provided_attrs[attr_name] + if isinstance(attr_value, list): + setattr(self, attr_name, attr_value) + else: + setattr(self, attr_name, [attr_value]) + + def match_score(self, other): + # type: (Contact) -> int + """ + Computes how many element matches with other and self + >>> c1 = Contact(name="John", telephone="911") + >>> c2 = Contact(name="Johnny") + >>> c1.match_score(c2) + 0 + >>> c2.telephone = ["123", "911"] # now the contacts have 911 in common + >>> c1.match_score(c2) + 1 + + Now add a common nickname to them, ignoring case + >>> c1.name.append("deepthroat") + >>> c2.name.append("DeepThroat") + >>> c1.match_score(c2) + 2 + """ + common_attrs = set(self.get_filled_attributes()).intersection(other.get_filled_attributes()) + return sum([self.common_attribute_count(getattr(self, attr), getattr(other, attr)) for attr in common_attrs]) + + def consolidate(self): + """ + Merge duplicate attributes + >>> john = Contact() + >>> john.name = ['John', 'John Doe', ' John Doe', 'Darling'] + >>> john.consolidate() + >>> 'Darling' in john.name + True + >>> 'John Doe' in john.name + True + >>> len(john.name) + 2 + >>> john.org = [['whatever org']] + >>> john.consolidate() + >>> john.org + ['whatever org'] + """ + my_attributes = self.get_filled_attributes() + for name in my_attributes: # removes exact duplicates + self.consolidate_attribute(name) + + def get_filled_attributes(self): + """ + >>> c = Contact() + >>> c.name = ["John", "Johnny"] + >>> c.note = ["That's him !"] + >>> c.get_filled_attributes() + ['name', 'note'] + """ + return [a for a in dir(self) + if not callable(getattr(self, a)) and not a.startswith("__") and len(getattr(self, a))] + + def get_all_attributes(self): + return [a for a in dir(self) if not callable(getattr(self, a)) and not a.startswith("__")] + + def consolidate_attribute(self, attribute_name): + # type: (str) -> None + attr_value = getattr(self, attribute_name) + attr_value = flatten(attr_value) + attr_value = list(set([i.strip() for i in attr_value if isinstance(i, basestring)])) # removes exact duplicates + + attr_value[:] = [x for x in attr_value if not self._is_contained_in_other_element_of_the_list(x, attr_value)] + + setattr(self, attribute_name, list(set(attr_value))) + + def merge(self, other): + # type: (Contact) -> None + """ + >>> c1 = Contact() + >>> c1.name = ["John"] + >>> c2 = Contact() + >>> c2.name = ["John"] + >>> c2.telephone = ["911"] + >>> c1.merge(c2) + >>> c1.telephone + ['911'] + """ + attr_sum = self.get_filled_attributes() + other.get_filled_attributes() + for attr_name in attr_sum: + attrs_sum = getattr(self, attr_name) + getattr(other, attr_name) + setattr(self, attr_name, attrs_sum) + self.consolidate() + + def short_name(self): + for attr_name in self.get_filled_attributes(): + for attribute in getattr(self, attr_name): + if not isinstance(attribute, basestring) and not isinstance(attribute, list): + continue + if isinstance(attribute, list): + for entry_str in attribute: + if not isinstance(entry_str, basestring): + continue + else: + return attribute + return "unknown" + + @staticmethod + def common_attribute_count(a1, a2): + # type: (list, list) -> int + a1_copy = [i.lower() for i in a1 if isinstance(i, basestring)] + a2_copy = [i.lower() for i in a2 if isinstance(i, basestring)] + return len(set(a1_copy).intersection(a2_copy)) + + @staticmethod + def _is_contained_in_other_element_of_the_list(p_element, the_list): + """ + """ + # type: (object, list) -> bool + copy = list(the_list) + copy.remove(p_element) + for element in copy: + if p_element in element: + return True + return False diff --git a/apps/personal/contacts/main.py b/apps/personal/contacts/main.py index 70e7975b..d9714594 100644 --- a/apps/personal/contacts/main.py +++ b/apps/personal/contacts/main.py @@ -4,14 +4,17 @@ import os -from address_book import AddressBook, ZPUI_HOME, Contact +from address_book import AddressBook, ZPUI_HOME +from contact import Contact from apps import ZeroApp from helpers import setup_logger -from ui import NumberedMenu, Listbox -from vcard_converter import VCardContactConverter +from ui import NumberedMenu, Listbox, Menu, LoadingIndicator, Printer +from distutils.spawn import find_executable logger = setup_logger(__name__, "info") +VDIRSYNCER_CONFIG = '/tmp/vdirsyncer_config' +VDIRSYNCER_VCF_DIRECOTRY = '/tmp/contacts/contacts' class ContactApp(ZeroApp): def __init__(self, i, o): @@ -19,42 +22,76 @@ def __init__(self, i, o): self.menu_name = "Contacts" self.address_book = AddressBook() self.menu = None + self.vdirsyncer_executable = find_executable('vdirsyncer') def on_start(self): self.address_book.load_from_file() - self.menu = NumberedMenu(self.create_menu_content(), self.i, self.o, prepend_numbers=False) + self.menu = NumberedMenu(self.build_main_menu_content(), i=self.i, + o=self.o, prepend_numbers=False) self.menu.activate() - def create_menu_content(self): + def build_main_menu_content(self): all_contacts = self.address_book.contacts - return [[c.short_name(), lambda x=c: self.create_contact_page(x)] for c in all_contacts] + menu_entries = [] + menu_entries.append(["|| Actions", lambda: self.open_actions_menu()]) + for c in all_contacts: + menu_entries.append([c.short_name(), lambda x=c: + self.open_contact_details_page(x)]) - def create_contact_page(self, contact): + return menu_entries + + def open_contact_details_page(self, contact): # type: (Contact) -> None - contact_attrs = [getattr(contact, a) for a in contact.get_filled_attributes()] + contact_attrs = [getattr(contact, a) for a in + contact.get_filled_attributes()] Listbox(i=self.i, o=self.o, contents=contact_attrs).activate() - -def find_contact_files(folder): - # type: (str) -> list(str) - home = os.path.expanduser(folder) - if not os.path.exists(home): - os.mkdir(home) - contact_card_files = [os.path.join(home, f) for f in os.listdir(home) if f.lower().endswith("vcf")] - return contact_card_files - - -def load_vcf(folder): - # type: (str) -> None - contact_card_files = find_contact_files(folder) - contacts = VCardContactConverter.from_vcards(contact_card_files) - - address_book = AddressBook() - for contact in contacts: - address_book.add_contact(contact) - address_book.save_to_file() - logger.info("Saved to {}".format(address_book.get_save_file_path())) - + def open_actions_menu(self): + menu_contents = [ + ["Configure", lambda: self.open_settings_page()], + ["Synchronize", lambda: self.synchronize_carddav(lambda: self.open_actions_menu())] + ] + Menu(menu_contents, i=self.i, o=self.o, name="My menu").activate() + + def open_settings_page(self): + if self.vdirsyncer_executable: + vdirsyncer_executable = self.vdirsyncer_executable + else: + vdirsyncer_executable = 'Not found' + + attrs = [ + ["-- VdirSyncer"], + ["Executable: {}".format(vdirsyncer_executable) ], + ["Config: {}".format(VDIRSYNCER_CONFIG)] + ] + Listbox(i=self.i, o=self.o, contents=attrs).activate() + + def synchronize_carddav(self, callback): + if (not os.path.isfile(self.vdirsyncer_executable) or + not os.access(self.vdirsyncer_executable, os.X_OK)): + Printer('Could not execute vdirsyncer.', i=self.i, o=self.o, + sleep_time=2, skippable=True) + callback() + return; + + vdirsyncer_command = "{} -c {} sync contacts".format( + self.vdirsyncer_executable, VDIRSYNCER_CONFIG + ) + logger.info("Calling vdirsyncer to synchronize contacts") + with LoadingIndicator(self.i, self.o, message="Syncing contacts"): + exit_status = os.system(vdirsyncer_command) + + if (exit_status != 0): + error_msg = 'Error in contact synchronization. Did you configure \ + vdirsyncer?' + Printer(error_msg, i=self.i, o=self.o, sleep_time=2, + skippable=True) + + with LoadingIndicator(self.i, self.o, message="Importing contacts"): + self.address_book.import_vcards_from_directory(VDIRSYNCER_VCF_DIRECOTRY) + self.address_book.save_to_file() + + callback() if __name__ == '__main__': parser = argparse.ArgumentParser() @@ -67,4 +104,6 @@ def load_vcf(folder): logger.info("Running tests...") doctest.testmod() - load_vcf(arguments.src_folder) + address_book = AddressBook() + address_book.import_vcards_from_directory(arguments.src_folder) + address_book.save_to_file() diff --git a/apps/personal/contacts/vcard_converter.py b/apps/personal/contacts/vcard_converter.py index d8d13d05..69578bc7 100644 --- a/apps/personal/contacts/vcard_converter.py +++ b/apps/personal/contacts/vcard_converter.py @@ -2,9 +2,7 @@ logger = setup_logger(__name__, "warning") import vobject - -from address_book import Contact - +from contact import Contact class VCardContactConverter(object): vcard_mapping = { From 81c73c52c858db944eac367d525385ef7f2e2bea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sat, 17 Nov 2018 11:34:38 +0100 Subject: [PATCH 02/12] Contacts: allow to reset the address book, rename a few menu items --- apps/personal/contacts/address_book.py | 11 ++++++-- apps/personal/contacts/contact.py | 1 - apps/personal/contacts/main.py | 39 +++++++++++++++++++------- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/apps/personal/contacts/address_book.py b/apps/personal/contacts/address_book.py index 6ddbf732..9fc10eb3 100644 --- a/apps/personal/contacts/address_book.py +++ b/apps/personal/contacts/address_book.py @@ -8,6 +8,9 @@ logger = setup_logger(__name__, "warning") +# FIXME: load from config module? +SAVE_FILENAME = "contacts.pickle" +ZPUI_HOME = "~/.phone/" class AddressBook(Singleton): def __init__(self): @@ -75,6 +78,10 @@ def save_to_file(self): with open(self.get_save_file_path(), 'w') as f_save: pickle.dump(self._contacts, f_save) + def reset(self): + self._contacts = [] + self.save_to_file() + @staticmethod def get_save_file_path(): path = os.environ.get("XDG_DATA_HOME") @@ -124,7 +131,5 @@ def import_vcards_from_directory(self, directory): # Import into current AddressBook instance parsed_contacts = VCardContactConverter.from_vcards(vcard_files) for c in parsed_contacts: + best_duplicate = self.find_duplicates(c) self.add_contact(c) - -SAVE_FILENAME = "contacts.pickle" -ZPUI_HOME = "~/.phone/" diff --git a/apps/personal/contacts/contact.py b/apps/personal/contacts/contact.py index cc83609c..0c04a53c 100644 --- a/apps/personal/contacts/contact.py +++ b/apps/personal/contacts/contact.py @@ -3,7 +3,6 @@ from helpers import Singleton, flatten from helpers import setup_logger -#from vcard_converter import VCardContactConverter logger = setup_logger(__name__, "warning") diff --git a/apps/personal/contacts/main.py b/apps/personal/contacts/main.py index d9714594..608b6de4 100644 --- a/apps/personal/contacts/main.py +++ b/apps/personal/contacts/main.py @@ -8,13 +8,14 @@ from contact import Contact from apps import ZeroApp from helpers import setup_logger -from ui import NumberedMenu, Listbox, Menu, LoadingIndicator, Printer +from ui import NumberedMenu, Listbox, Menu, LoadingIndicator, Printer, DialogBox from distutils.spawn import find_executable logger = setup_logger(__name__, "info") -VDIRSYNCER_CONFIG = '/tmp/vdirsyncer_config' -VDIRSYNCER_VCF_DIRECOTRY = '/tmp/contacts/contacts' +# FIXME: load from config module? +VDIRSYNCER_CONFIG = '~/.config/vdirsyncer/config' +VDIRSYNCER_VCF_DIRECOTRY = '~/.local/share/vdirsyncer/contacts/contacts' class ContactApp(ZeroApp): def __init__(self, i, o): @@ -25,6 +26,9 @@ def __init__(self, i, o): self.vdirsyncer_executable = find_executable('vdirsyncer') def on_start(self): + self.reload() + + def reload(self): self.address_book.load_from_file() self.menu = NumberedMenu(self.build_main_menu_content(), i=self.i, o=self.o, prepend_numbers=False) @@ -48,8 +52,9 @@ def open_contact_details_page(self, contact): def open_actions_menu(self): menu_contents = [ - ["Configure", lambda: self.open_settings_page()], - ["Synchronize", lambda: self.synchronize_carddav(lambda: self.open_actions_menu())] + ["Settings", lambda: self.open_settings_page()], + ["CardDAV Import", lambda: self.synchronize_carddav()], + ["Reset address book", lambda: self.reset_addressbook()] ] Menu(menu_contents, i=self.i, o=self.o, name="My menu").activate() @@ -66,7 +71,19 @@ def open_settings_page(self): ] Listbox(i=self.i, o=self.o, contents=attrs).activate() - def synchronize_carddav(self, callback): + def reset_addressbook(self): + alert = "This action will delete all of your contacts. Are you sure?" + do_reset = DialogBox('yc', i=self.i, o=self.o, message=alert, + name="Address book reset").activate() + + if do_reset: + self.address_book.reset() + announce = "All of your contacts were deleted." + Printer(announce, i=self.i, o=self.o, sleep_time=2, skippable=True) + # Reload the now empty address book + self.reload() + + def synchronize_carddav(self): if (not os.path.isfile(self.vdirsyncer_executable) or not os.access(self.vdirsyncer_executable, os.X_OK)): Printer('Could not execute vdirsyncer.', i=self.i, o=self.o, @@ -82,16 +99,18 @@ def synchronize_carddav(self, callback): exit_status = os.system(vdirsyncer_command) if (exit_status != 0): - error_msg = 'Error in contact synchronization. Did you configure \ - vdirsyncer?' - Printer(error_msg, i=self.i, o=self.o, sleep_time=2, + error_msg = "Error in contact synchronization. Did you configure \ + vdirsyncer?" + Printer(error_msg, i=self.i, o=self.o, sleep_time=3, skippable=True) + self.open_actions_menu() with LoadingIndicator(self.i, self.o, message="Importing contacts"): self.address_book.import_vcards_from_directory(VDIRSYNCER_VCF_DIRECOTRY) self.address_book.save_to_file() - callback() + # Reload the synced address book + self.reload() if __name__ == '__main__': parser = argparse.ArgumentParser() From 89d091e90478743ea6237c915d580ec41df65b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sat, 17 Nov 2018 12:24:15 +0100 Subject: [PATCH 03/12] Contacts: avoid duplicated entries after CardDAV import --- apps/personal/contacts/address_book.py | 17 +++++++++++++---- apps/personal/contacts/contact.py | 6 ++++++ apps/personal/contacts/vcard_converter.py | 4 ++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/personal/contacts/address_book.py b/apps/personal/contacts/address_book.py index 9fc10eb3..430104ee 100644 --- a/apps/personal/contacts/address_book.py +++ b/apps/personal/contacts/address_book.py @@ -119,7 +119,7 @@ def cmp(a1, a2): return sorted(match_score_contact_list, cmp=cmp) def import_vcards_from_directory(self, directory): - logger.info("Import vCards from {}".format(directory)) + logger.info("Contacts: import vCards from {}".format(directory)) # Extract *cvf files from the directory home = os.path.expanduser(directory) @@ -130,6 +130,15 @@ def import_vcards_from_directory(self, directory): # Import into current AddressBook instance parsed_contacts = VCardContactConverter.from_vcards(vcard_files) - for c in parsed_contacts: - best_duplicate = self.find_duplicates(c) - self.add_contact(c) + for new in parsed_contacts: + is_duplicate = False + for existing in self._contacts: + if (new == existing): + is_duplicate = True + break + + if not is_duplicate: + self.add_contact(new) + else: + logger.info("Contacts: ignore duplicated contact for: {}" + .format(new.name)) diff --git a/apps/personal/contacts/contact.py b/apps/personal/contacts/contact.py index 0c04a53c..3bbd038a 100644 --- a/apps/personal/contacts/contact.py +++ b/apps/personal/contacts/contact.py @@ -28,6 +28,12 @@ def __init__(self, **kwargs): self.title = [] self.from_kwargs(kwargs) + def __eq__(self, other): + return self.__dict__ == other.__dict__ + + def __str__(self): + return str(self.__dict__) + def from_kwargs(self, kwargs): provided_attrs = {attr: kwargs[attr] for attr in self.get_all_attributes() if attr in kwargs.keys()} for attr_name in provided_attrs: diff --git a/apps/personal/contacts/vcard_converter.py b/apps/personal/contacts/vcard_converter.py index 69578bc7..0d8427b7 100644 --- a/apps/personal/contacts/vcard_converter.py +++ b/apps/personal/contacts/vcard_converter.py @@ -26,6 +26,10 @@ def to_zpui_contact(v_contact): attr = getattr(contact, VCardContactConverter.vcard_mapping[key]) assert type(attr) == list attr += [v.value for v in v_contact.contents[key]] + + # Remove duplicated attributes due to 'fn' and 'n' both mapping to 'name' + contact.consolidate() + return contact @staticmethod From 1a8557d1dc0e72a66a49f85255bfec1e34c5cc89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sat, 17 Nov 2018 13:03:27 +0100 Subject: [PATCH 04/12] Contacts: small refactor in VCard importation --- apps/personal/contacts/address_book.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/personal/contacts/address_book.py b/apps/personal/contacts/address_book.py index 430104ee..a73dc0b6 100644 --- a/apps/personal/contacts/address_book.py +++ b/apps/personal/contacts/address_book.py @@ -131,14 +131,11 @@ def import_vcards_from_directory(self, directory): # Import into current AddressBook instance parsed_contacts = VCardContactConverter.from_vcards(vcard_files) for new in parsed_contacts: - is_duplicate = False - for existing in self._contacts: - if (new == existing): - is_duplicate = True - break - - if not is_duplicate: - self.add_contact(new) - else: + is_duplicate = new in self._contacts + + if is_duplicate: logger.info("Contacts: ignore duplicated contact for: {}" .format(new.name)) + break + + self.add_contact(new) From a9af3b587285bea76b2cec30427759c731756f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 3 Jan 2019 20:15:49 +0100 Subject: [PATCH 05/12] Address Book: remove useless app name from log messages --- apps/personal/contacts/address_book.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/personal/contacts/address_book.py b/apps/personal/contacts/address_book.py index a73dc0b6..bb58b520 100644 --- a/apps/personal/contacts/address_book.py +++ b/apps/personal/contacts/address_book.py @@ -119,7 +119,7 @@ def cmp(a1, a2): return sorted(match_score_contact_list, cmp=cmp) def import_vcards_from_directory(self, directory): - logger.info("Contacts: import vCards from {}".format(directory)) + logger.info("Import vCards from {}".format(directory)) # Extract *cvf files from the directory home = os.path.expanduser(directory) @@ -134,7 +134,7 @@ def import_vcards_from_directory(self, directory): is_duplicate = new in self._contacts if is_duplicate: - logger.info("Contacts: ignore duplicated contact for: {}" + logger.info("Ignore duplicated contact for: {}" .format(new.name)) break From 7844b3069d10bf3402160ea51a7302c0541bdc9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Thu, 3 Jan 2019 20:17:21 +0100 Subject: [PATCH 06/12] Contacts App: add vdirsyncer CardDAV remote setup wizard --- apps/personal/contacts/main.py | 105 +++++++++++++++++++------------ helpers/vdirsyncer.py | 109 +++++++++++++++++++++++++++++++++ helpers/vdirsyncer/config.j2 | 23 +++++++ helpers/xdg.py | 79 ++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 40 deletions(-) create mode 100644 helpers/vdirsyncer.py create mode 100644 helpers/vdirsyncer/config.j2 create mode 100644 helpers/xdg.py diff --git a/apps/personal/contacts/main.py b/apps/personal/contacts/main.py index 608b6de4..78c772eb 100644 --- a/apps/personal/contacts/main.py +++ b/apps/personal/contacts/main.py @@ -8,19 +8,20 @@ from contact import Contact from apps import ZeroApp from helpers import setup_logger -from ui import NumberedMenu, Listbox, Menu, LoadingIndicator, Printer, DialogBox +from ui import (NumberedMenu, Listbox, Menu, LoadingIndicator, Printer, + DialogBox, UniversalInput) from distutils.spawn import find_executable +from helpers.vdirsyncer import (vdirsyncer_sync, vdirsyncer_discover, + vdirsyncer_set_carddav_remote, + vdirsyncer_get_storage_directory_for, + vdirsyncer_generate_config) -logger = setup_logger(__name__, "info") - -# FIXME: load from config module? -VDIRSYNCER_CONFIG = '~/.config/vdirsyncer/config' -VDIRSYNCER_VCF_DIRECOTRY = '~/.local/share/vdirsyncer/contacts/contacts' +logger = setup_logger(__name__, 'info') class ContactApp(ZeroApp): def __init__(self, i, o): super(ContactApp, self).__init__(i, o) - self.menu_name = "Contacts" + self.menu_name = 'Contacts' self.address_book = AddressBook() self.menu = None self.vdirsyncer_executable = find_executable('vdirsyncer') @@ -37,7 +38,7 @@ def reload(self): def build_main_menu_content(self): all_contacts = self.address_book.contacts menu_entries = [] - menu_entries.append(["|| Actions", lambda: self.open_actions_menu()]) + menu_entries.append(['|| Actions', lambda: self.open_actions_menu()]) for c in all_contacts: menu_entries.append([c.short_name(), lambda x=c: self.open_contact_details_page(x)]) @@ -52,33 +53,20 @@ def open_contact_details_page(self, contact): def open_actions_menu(self): menu_contents = [ - ["Settings", lambda: self.open_settings_page()], - ["CardDAV Import", lambda: self.synchronize_carddav()], - ["Reset address book", lambda: self.reset_addressbook()] - ] - Menu(menu_contents, i=self.i, o=self.o, name="My menu").activate() - - def open_settings_page(self): - if self.vdirsyncer_executable: - vdirsyncer_executable = self.vdirsyncer_executable - else: - vdirsyncer_executable = 'Not found' - - attrs = [ - ["-- VdirSyncer"], - ["Executable: {}".format(vdirsyncer_executable) ], - ["Config: {}".format(VDIRSYNCER_CONFIG)] + ['CardDAV Setup Wizard', lambda: self.open_remote_setup_wizard()], + ['CardDAV Sync', lambda: self.synchronize_carddav()], + ['Reset address book', lambda: self.reset_addressbook()] ] - Listbox(i=self.i, o=self.o, contents=attrs).activate() + Menu(menu_contents, i=self.i, o=self.o, name='My menu').activate() def reset_addressbook(self): - alert = "This action will delete all of your contacts. Are you sure?" + alert = 'This action will delete all of your contacts. Are you sure?' do_reset = DialogBox('yc', i=self.i, o=self.o, message=alert, - name="Address book reset").activate() + name='Address book reset').activate() if do_reset: self.address_book.reset() - announce = "All of your contacts were deleted." + announce = 'All of your contacts were deleted.' Printer(announce, i=self.i, o=self.o, sleep_time=2, skippable=True) # Reload the now empty address book self.reload() @@ -91,36 +79,73 @@ def synchronize_carddav(self): callback() return; - vdirsyncer_command = "{} -c {} sync contacts".format( - self.vdirsyncer_executable, VDIRSYNCER_CONFIG - ) - logger.info("Calling vdirsyncer to synchronize contacts") - with LoadingIndicator(self.i, self.o, message="Syncing contacts"): - exit_status = os.system(vdirsyncer_command) + with LoadingIndicator(self.i, self.o, message='Syncing contacts'): + exit_status = vdirsyncer_sync('contacts') if (exit_status != 0): - error_msg = "Error in contact synchronization. Did you configure \ - vdirsyncer?" + error_msg = "Error in contact synchronization. See ZPUI logs for \ + details." Printer(error_msg, i=self.i, o=self.o, sleep_time=3, skippable=True) self.open_actions_menu() - with LoadingIndicator(self.i, self.o, message="Importing contacts"): - self.address_book.import_vcards_from_directory(VDIRSYNCER_VCF_DIRECOTRY) + with LoadingIndicator(self.i, self.o, message='Importing contacts'): + self.address_book.import_vcards_from_directory( + vdirsyncer_get_storage_directory_for('contacts') + ) self.address_book.save_to_file() # Reload the synced address book self.reload() + def open_remote_setup_wizard(self): + # Define wizard fields + url_field = UniversalInput(self.i, self.o, + message='CardDAV URL:', + name='CardDAV URL field') + username_field = UniversalInput(self.i, self.o, + message='CardDAV Username:', + name='CardDAV username field') + password_field = UniversalInput(self.i, self.o, + message='CardDAV Password:', + name='CardDAV password field') + + # Run wizard + url = url_field.activate() + username = username_field.activate() + password = password_field.activate() + + # Update ZPUI vdirsyncer config, generate vdirsyncer config file + vdirsyncer_set_carddav_remote(url, username, password) + vdirsyncer_generate_config() + + # Initialize vdirsyncer remote + with LoadingIndicator(self.i, self.o, message='Initializing remote'): + exit_status = vdirsyncer_discover('contacts') + + if (exit_status != 0): + error_msg = "Error in remote initialization. Check vdirsyncer \ + configuration" + Printer(error_msg, i=self.i, o=self.o, sleep_time=3, + skippable=True) + return + + # Synchronize contacts if the user request it + sync_now = DialogBox('yn', self.i, self.o, + message='Remote saved. Sync now?', + name='Sync synced contacts').activate() + if sync_now: self.synchronize_carddav() + if __name__ == '__main__': parser = argparse.ArgumentParser() - parser.add_argument('-i', '--src-folder', dest='src_folder', action='store', metavar="DIR", + logger.info('Generating vdirsyncer configuration') + parser.add_argument('-i', '--src-folder', dest='src_folder', action='store', metavar='DIR', help='Folder to read vcard from', default=ZPUI_HOME) parser.add_argument('-t', '--run-tests', dest='test', action='store_true', default=False) arguments = parser.parse_args() if arguments.test: - logger.info("Running tests...") + logger.info('Running tests...') doctest.testmod() address_book = AddressBook() diff --git a/helpers/vdirsyncer.py b/helpers/vdirsyncer.py new file mode 100644 index 00000000..105d8ab3 --- /dev/null +++ b/helpers/vdirsyncer.py @@ -0,0 +1,109 @@ +from jinja2 import Template, Environment, FileSystemLoader +from helpers.config_parse import write_config +from helpers.xdg import XDG_CONFIG_HOME, XDG_DATA_HOME +from main import load_config +import os + +from helpers.logger import setup_logger +logger = setup_logger(__name__, "info") + +DEFAULT_VDIRSYNCER_BINARY = '/usr/bin/vdirsyncer' +DEFAULT_VDIRSYNCER_CONFIG_FILE = os.path.join(XDG_CONFIG_HOME, + 'vdirsyncer', + 'zp_config') +#DEFAULT_VDIRSYNCER_STORAGE_DIRECTORY = os.path.join(XDG_DATA_HOME, 'vdirsyncer') +DEFAULT_VDIRSYNCER_STORAGE_DIRECTORY = os.path.join('/tmp', 'vdirsyncer') + +def vdirsyncer_sync(vdirsyncer_pair): + return vdirsyncer_execute(['sync', vdirsyncer_pair]) + +def vdirsyncer_discover(vdirsyncer_pair): + return vdirsyncer_execute(['discover', vdirsyncer_pair]) + +def vdirsyncer_execute(vdirsyncer_args): + zp_config = load_config()[0] + zp_vdirsyncer_config = zp_config.get('vdirsyncer', {}) + vdirsyncer_binary = zp_vdirsyncer_config.get( + 'binary', DEFAULT_VDIRSYNCER_BINARY + ) + vdirsyncer_config_file = zp_vdirsyncer_config.get( + 'config_file', DEFAULT_VDIRSYNCER_CONFIG_FILE + ) + + vdirsyncer_command = "{} -c {}".format(vdirsyncer_binary, + vdirsyncer_config_file) + vdirsyncer_command += ' ' + ' '.join(vdirsyncer_args) + + logger.info("External binary call: {}".format(vdirsyncer_command)) + return os.system(vdirsyncer_command) + +# TODO: add support for multiple remotes +def vdirsyncer_set_carddav_remote(url, username, password): + zp_config, zp_config_path = load_config() + zp_vdirsyncer_config = zp_config.get('vdirsyncer', {}) + contacts_storage_directory = vdirsyncer_get_storage_directory_for('contacts') + + zp_vdirsyncer_config['contacts'] = { + 'pair': { + 'a': 'contacts_local', + 'b': 'contacts_remote', + 'collections': 'null' + }, + 'local': { + 'type': 'filesystem', + 'path': contacts_storage_directory, + 'fileext': '.vcf' + }, + 'remote': { + 'type': 'carddav', + 'url': url, + 'username': username, + 'password': password + } + } + + zp_config['vdirsyncer'] = zp_vdirsyncer_config + return write_config(zp_config, zp_config_path) + +def vdirsyncer_get_storage_directory_for(pair): + zp_config = load_config()[0] + zp_vdirsyncer_config = zp_config.get('vdirsyncer', {}) + vdirsyncer_storage_directory = zp_vdirsyncer_config.get( + 'storage_directory', DEFAULT_VDIRSYNCER_STORAGE_DIRECTORY + ) + + directory = os.path.join(vdirsyncer_storage_directory, pair) + + # Create the directory ourself so that vdirsyncer does not ask stdin! + if not os.path.exists(directory): + os.makedirs(directory) + + return directory + +# TODO: similar to vdirsyncer_set_carddav_remote +def vdirsyncer_set_calddav_remote(): + return + +def vdirsyncer_generate_config(): + zp_config = load_config()[0] + zp_vdirsyncer_config = zp_config.get('vdirsyncer', {}) + status_directory = vdirsyncer_get_storage_directory_for('_vdirsyncer_status') + + logger.info('Generating vdirsyncer configuration') + + jinja2_env = Environment(loader=FileSystemLoader('helpers/vdirsyncer'), + trim_blocks=True) + template = jinja2_env.get_template('config.j2') + rendered_vdirsyncer_config = template.render( + contacts=zp_vdirsyncer_config['contacts'], + status_directory=status_directory + ) + + vdirsyncer_config_file = zp_vdirsyncer_config.get( + 'config_file', DEFAULT_VDIRSYNCER_CONFIG_FILE + ) + + logger.info('Writing vdirsyncer configuration') + with open(vdirsyncer_config_file, 'w') as fh: + fh.write(rendered_vdirsyncer_config) + fh.close diff --git a/helpers/vdirsyncer/config.j2 b/helpers/vdirsyncer/config.j2 new file mode 100644 index 00000000..f73e9fef --- /dev/null +++ b/helpers/vdirsyncer/config.j2 @@ -0,0 +1,23 @@ +# This file was generated by ZPUI. Do not edit by hand. + +[general] +status_path = "{{ status_directory }}" + +[pair contacts] +{% for key, value in contacts['pair'].items() %} +{% if key == 'collections' %} +{{ key }} = {{ value }} +{% else %} +{{ key }} = "{{ value }}" +{% endif %} +{% endfor %} + +[storage contacts_local] +{% for key, value in contacts['local'].items() %} +{{ key }} = "{{ value }}" +{% endfor %} + +[storage contacts_remote] +{% for key, value in contacts['remote'].items() %} +{{ key }} = "{{ value }}" +{% endfor %} diff --git a/helpers/xdg.py b/helpers/xdg.py new file mode 100644 index 00000000..be3355d0 --- /dev/null +++ b/helpers/xdg.py @@ -0,0 +1,79 @@ +# coding=utf-8 +"""XDG Base Directory Specification variables. + +XDG_CACHE_HOME, XDG_CONFIG_HOME, and XDG_DATA_HOME are strings +containing the value of the environment variable of the same name, or +the default defined in the specification if the environment variable is +unset or empty. + +XDG_CONFIG_DIRS and XDG_DATA_DIRS are lists of strings containing the +value of the environment variable of the same name split on colons, or +the default defined in the specification if the environment variable is +unset or empty. + +XDG_RUNTIME_DIR is a string containing the value of the environment +variable of the same name, or None if the environment variable is not +set. +""" + +# Copyright © 2016-2018 Scott Stevenson +# +# Permission to use, copy, modify, and/or distribute this software for any +# purpose with or without fee is hereby granted, provided that the above +# copyright notice and this permission notice appear in all copies. +# +# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +# AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +# OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +# PERFORMANCE OF THIS SOFTWARE. + +import os + +__all__ = [ + "XDG_CACHE_HOME", + "XDG_CONFIG_DIRS", + "XDG_CONFIG_HOME", + "XDG_DATA_DIRS", + "XDG_DATA_HOME", + "XDG_RUNTIME_DIR", +] + + +def _getenv(variable, default): + """Get an environment variable. + + Parameters + ---------- + variable : str + The environment variable. + default : str + A default value that will be returned if the environment + variable is unset or empty. + + Returns + ------- + str + The value of the environment variable, or the default value. + + """ + return os.environ.get(variable) or default + + +XDG_CACHE_HOME = _getenv( + "XDG_CACHE_HOME", os.path.expandvars(os.path.join("$HOME", ".cache")) +) +XDG_CONFIG_DIRS = _getenv("XDG_CONFIG_DIRS", "/etc/xdg").split(":") +XDG_CONFIG_HOME = _getenv( + "XDG_CONFIG_HOME", os.path.expandvars(os.path.join("$HOME", ".config")) +) +XDG_DATA_DIRS = _getenv( + "XDG_DATA_DIRS", "/usr/local/share/:/usr/share/" +).split(":") +XDG_DATA_HOME = _getenv( + "XDG_DATA_HOME", + os.path.expandvars(os.path.join("$HOME", ".local", "share")), +) +XDG_RUNTIME_DIR = os.getenv("XDG_RUNTIME_DIR") From bb19598ec43bc41cfdd25e44b2dca70775908a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Fri, 4 Jan 2019 18:13:59 +0100 Subject: [PATCH 07/12] Contacts App: minor refactoring following initial review of PR #134 --- apps/personal/contacts/contact.py | 5 ++--- apps/personal/contacts/main.py | 28 ++++++++++++---------------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/apps/personal/contacts/contact.py b/apps/personal/contacts/contact.py index 3bbd038a..c0d646cb 100644 --- a/apps/personal/contacts/contact.py +++ b/apps/personal/contacts/contact.py @@ -35,9 +35,8 @@ def __str__(self): return str(self.__dict__) def from_kwargs(self, kwargs): - provided_attrs = {attr: kwargs[attr] for attr in self.get_all_attributes() if attr in kwargs.keys()} - for attr_name in provided_attrs: - attr_value = provided_attrs[attr_name] + provided_attrs = {attr: kwargs[attr] for attr in self.get_all_attributes() if attr in kwargs} + for attr_name, attr_value in provided_attrs.items(): if isinstance(attr_value, list): setattr(self, attr_name, attr_value) else: diff --git a/apps/personal/contacts/main.py b/apps/personal/contacts/main.py index 78c772eb..eddd5228 100644 --- a/apps/personal/contacts/main.py +++ b/apps/personal/contacts/main.py @@ -8,8 +8,8 @@ from contact import Contact from apps import ZeroApp from helpers import setup_logger -from ui import (NumberedMenu, Listbox, Menu, LoadingIndicator, Printer, - DialogBox, UniversalInput) +from ui import (NumberedMenu, Listbox, Menu, LoadingIndicator, DialogBox, + PrettyPrinter as Printer, UniversalInput) from distutils.spawn import find_executable from helpers.vdirsyncer import (vdirsyncer_sync, vdirsyncer_discover, vdirsyncer_set_carddav_remote, @@ -31,14 +31,13 @@ def on_start(self): def reload(self): self.address_book.load_from_file() - self.menu = NumberedMenu(self.build_main_menu_content(), i=self.i, - o=self.o, prepend_numbers=False) + self.menu = NumberedMenu(self.build_main_menu_content(), self.i, + self.o, prepend_numbers=False) self.menu.activate() def build_main_menu_content(self): all_contacts = self.address_book.contacts - menu_entries = [] - menu_entries.append(['|| Actions', lambda: self.open_actions_menu()]) + menu_entries = [['|| Actions', lambda: self.open_actions_menu()]] for c in all_contacts: menu_entries.append([c.short_name(), lambda x=c: self.open_contact_details_page(x)]) @@ -49,7 +48,7 @@ def open_contact_details_page(self, contact): # type: (Contact) -> None contact_attrs = [getattr(contact, a) for a in contact.get_filled_attributes()] - Listbox(i=self.i, o=self.o, contents=contact_attrs).activate() + Listbox(contact_attrs, self.i, self.o).activate() def open_actions_menu(self): menu_contents = [ @@ -57,26 +56,25 @@ def open_actions_menu(self): ['CardDAV Sync', lambda: self.synchronize_carddav()], ['Reset address book', lambda: self.reset_addressbook()] ] - Menu(menu_contents, i=self.i, o=self.o, name='My menu').activate() + Menu(menu_contents, self.i, self.o, name='My menu').activate() def reset_addressbook(self): alert = 'This action will delete all of your contacts. Are you sure?' - do_reset = DialogBox('yc', i=self.i, o=self.o, message=alert, + do_reset = DialogBox('yc', self.i, self.o, message=alert, name='Address book reset').activate() if do_reset: self.address_book.reset() announce = 'All of your contacts were deleted.' - Printer(announce, i=self.i, o=self.o, sleep_time=2, skippable=True) + Printer(announce, self.i, self.o, sleep_time=2, skippable=True) # Reload the now empty address book self.reload() def synchronize_carddav(self): if (not os.path.isfile(self.vdirsyncer_executable) or not os.access(self.vdirsyncer_executable, os.X_OK)): - Printer('Could not execute vdirsyncer.', i=self.i, o=self.o, + Printer('Could not execute vdirsyncer.', self.i, self.o, sleep_time=2, skippable=True) - callback() return; with LoadingIndicator(self.i, self.o, message='Syncing contacts'): @@ -85,8 +83,7 @@ def synchronize_carddav(self): if (exit_status != 0): error_msg = "Error in contact synchronization. See ZPUI logs for \ details." - Printer(error_msg, i=self.i, o=self.o, sleep_time=3, - skippable=True) + Printer(error_msg, self.i, self.o, sleep_time=3) self.open_actions_menu() with LoadingIndicator(self.i, self.o, message='Importing contacts'): @@ -126,8 +123,7 @@ def open_remote_setup_wizard(self): if (exit_status != 0): error_msg = "Error in remote initialization. Check vdirsyncer \ configuration" - Printer(error_msg, i=self.i, o=self.o, sleep_time=3, - skippable=True) + Printer(error_msg, self.i, self.o, sleep_time=3) return # Synchronize contacts if the user request it From 6a4001033c1b7c23e2f18a58df67391a95ec8ea7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sat, 5 Jan 2019 21:06:45 +0100 Subject: [PATCH 08/12] Contacts App: move reusable code to /libs --- apps/personal/contacts/main.py | 12 ++++++------ libs/address_book/__init__.py | 2 ++ .../contacts => libs/address_book}/address_book.py | 0 .../contacts => libs/address_book}/contact.py | 0 .../address_book}/vcard_converter.py | 0 libs/webdav/__init__.py | 0 {helpers => libs/webdav}/vdirsyncer.py | 7 +++---- .../config.j2 => libs/webdav/vdirsyncer_config.j2 | 0 8 files changed, 11 insertions(+), 10 deletions(-) create mode 100644 libs/address_book/__init__.py rename {apps/personal/contacts => libs/address_book}/address_book.py (100%) rename {apps/personal/contacts => libs/address_book}/contact.py (100%) rename {apps/personal/contacts => libs/address_book}/vcard_converter.py (100%) create mode 100644 libs/webdav/__init__.py rename {helpers => libs/webdav}/vdirsyncer.py (92%) rename helpers/vdirsyncer/config.j2 => libs/webdav/vdirsyncer_config.j2 (100%) diff --git a/apps/personal/contacts/main.py b/apps/personal/contacts/main.py index eddd5228..76063d2a 100644 --- a/apps/personal/contacts/main.py +++ b/apps/personal/contacts/main.py @@ -4,17 +4,17 @@ import os -from address_book import AddressBook, ZPUI_HOME -from contact import Contact from apps import ZeroApp from helpers import setup_logger from ui import (NumberedMenu, Listbox, Menu, LoadingIndicator, DialogBox, PrettyPrinter as Printer, UniversalInput) from distutils.spawn import find_executable -from helpers.vdirsyncer import (vdirsyncer_sync, vdirsyncer_discover, - vdirsyncer_set_carddav_remote, - vdirsyncer_get_storage_directory_for, - vdirsyncer_generate_config) + +from libs.address_book import AddressBook, Contact +from libs.webdav.vdirsyncer import (vdirsyncer_sync, vdirsyncer_discover, + vdirsyncer_set_carddav_remote, + vdirsyncer_get_storage_directory_for, + vdirsyncer_generate_config) logger = setup_logger(__name__, 'info') diff --git a/libs/address_book/__init__.py b/libs/address_book/__init__.py new file mode 100644 index 00000000..2c9139d0 --- /dev/null +++ b/libs/address_book/__init__.py @@ -0,0 +1,2 @@ +from address_book import AddressBook +from contact import Contact diff --git a/apps/personal/contacts/address_book.py b/libs/address_book/address_book.py similarity index 100% rename from apps/personal/contacts/address_book.py rename to libs/address_book/address_book.py diff --git a/apps/personal/contacts/contact.py b/libs/address_book/contact.py similarity index 100% rename from apps/personal/contacts/contact.py rename to libs/address_book/contact.py diff --git a/apps/personal/contacts/vcard_converter.py b/libs/address_book/vcard_converter.py similarity index 100% rename from apps/personal/contacts/vcard_converter.py rename to libs/address_book/vcard_converter.py diff --git a/libs/webdav/__init__.py b/libs/webdav/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/helpers/vdirsyncer.py b/libs/webdav/vdirsyncer.py similarity index 92% rename from helpers/vdirsyncer.py rename to libs/webdav/vdirsyncer.py index 105d8ab3..68f92227 100644 --- a/helpers/vdirsyncer.py +++ b/libs/webdav/vdirsyncer.py @@ -11,8 +11,7 @@ DEFAULT_VDIRSYNCER_CONFIG_FILE = os.path.join(XDG_CONFIG_HOME, 'vdirsyncer', 'zp_config') -#DEFAULT_VDIRSYNCER_STORAGE_DIRECTORY = os.path.join(XDG_DATA_HOME, 'vdirsyncer') -DEFAULT_VDIRSYNCER_STORAGE_DIRECTORY = os.path.join('/tmp', 'vdirsyncer') +DEFAULT_VDIRSYNCER_STORAGE_DIRECTORY = os.path.join(XDG_DATA_HOME, 'vdirsyncer') def vdirsyncer_sync(vdirsyncer_pair): return vdirsyncer_execute(['sync', vdirsyncer_pair]) @@ -91,9 +90,9 @@ def vdirsyncer_generate_config(): logger.info('Generating vdirsyncer configuration') - jinja2_env = Environment(loader=FileSystemLoader('helpers/vdirsyncer'), + jinja2_env = Environment(loader=FileSystemLoader('libs/webdav'), trim_blocks=True) - template = jinja2_env.get_template('config.j2') + template = jinja2_env.get_template('vdirsyncer_config.j2') rendered_vdirsyncer_config = template.render( contacts=zp_vdirsyncer_config['contacts'], status_directory=status_directory diff --git a/helpers/vdirsyncer/config.j2 b/libs/webdav/vdirsyncer_config.j2 similarity index 100% rename from helpers/vdirsyncer/config.j2 rename to libs/webdav/vdirsyncer_config.j2 From ffb8db92aa6c656fea43cd0354f08dac9d55dfd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sat, 5 Jan 2019 22:16:10 +0100 Subject: [PATCH 09/12] Add 'paths' helper module for consistant cache, config and data dirs --- helpers/__init__.py | 9 ++-- helpers/general.py | 25 ---------- helpers/paths.py | 61 ++++++++++++++++++++++++ helpers/xdg.py | 79 ------------------------------- libs/address_book/address_book.py | 12 +---- libs/webdav/vdirsyncer.py | 13 +++-- 6 files changed, 75 insertions(+), 124 deletions(-) create mode 100644 helpers/paths.py delete mode 100644 helpers/xdg.py diff --git a/helpers/__init__.py b/helpers/__init__.py index 1d710631..2715d798 100644 --- a/helpers/__init__.py +++ b/helpers/__init__.py @@ -1,5 +1,8 @@ -from config_parse import read_config, write_config, read_or_create_config, save_config_gen, save_config_method_gen -from general import local_path_gen, flatten, Singleton +from config_parse import (read_config, write_config, read_or_create_config, + save_config_gen, save_config_method_gen) +from logger import setup_logger +from general import flatten, Singleton +from paths import (XDG_CACHE_HOME, XDG_CONFIG_HOME, XDG_DATA_HOME, + ZP_CACHE_DIR, ZP_CONFIG_DIR, ZP_DATA_DIR, local_path_gen) from runners import BooleanEvent, Oneshot, BackgroundRunner from usability import ExitHelper, remove_left_failsafe -from logger import setup_logger diff --git a/helpers/general.py b/helpers/general.py index 401967a1..1a8eba77 100644 --- a/helpers/general.py +++ b/helpers/general.py @@ -1,28 +1,3 @@ -import os -import sys - - -def local_path_gen(_name_): - """This function generates a ``local_path`` function you can use - in your scripts to get an absolute path to a file in your app's - directory. You need to pass ``__name__`` to ``local_path_gen``. Example usage: - - .. code-block:: python - - from helpers import local_path_gen - local_path = local_path_gen(__name__) - ... - config_path = local_path("config.json") - - The resulting local_path function supports multiple arguments, - passing all of them to ``os.path.join`` internally.""" - app_path = os.path.dirname(sys.modules[_name_].__file__) - - def local_path(*path): - return os.path.join(app_path, *path) - return local_path - - def flatten(foo): for x in foo: if hasattr(x, '__iter__'): diff --git a/helpers/paths.py b/helpers/paths.py new file mode 100644 index 00000000..6df5eab3 --- /dev/null +++ b/helpers/paths.py @@ -0,0 +1,61 @@ +"""Paths helpers + +This module is used across ZPUI to obtain consistant path for configurations, +data and cache files. +""" + +# Parts of this file are inspired from Scott Stevenson's xdg python package, +# licensed under the ISC licenses. +# See https://github.com/srstevenson/xdg/blob/3.0.2/xdg.py ; + +import os +import sys + +def _getenv(variable, default): + return os.environ.get(variable) or default + +def _ensure_directory_exists(path): + if not os.path.exists(path): + os.makedirs(path, 0760) + + if not os.path.isdir(path): + raise os.error('Expected a directory but found file instead.') + +def _zp_dir(path): + path = os.path.join(path, 'zp') + _ensure_directory_exists(path) + return path + +XDG_CACHE_HOME = _getenv( + "XDG_CACHE_HOME", os.path.expandvars(os.path.join("$HOME", ".cache")) +) +XDG_CONFIG_HOME = _getenv( + "XDG_CONFIG_HOME", os.path.expandvars(os.path.join("$HOME", ".config")) +) +XDG_DATA_HOME = _getenv( + "XDG_DATA_HOME", + os.path.expandvars(os.path.join("$HOME", ".local", "share")), +) +ZP_CACHE_DIR = _zp_dir(XDG_CACHE_HOME) +ZP_CONFIG_DIR = _zp_dir(XDG_CONFIG_HOME) +ZP_DATA_DIR = _zp_dir(XDG_DATA_HOME) + +def local_path_gen(_name_): + """This function generates a ``local_path`` function you can use + in your scripts to get an absolute path to a file in your app's + directory. You need to pass ``__name__`` to ``local_path_gen``. Example usage: + + .. code-block:: python + + from helpers import local_path_gen + local_path = local_path_gen(__name__) + ... + config_path = local_path("config.json") + + The resulting local_path function supports multiple arguments, + passing all of them to ``os.path.join`` internally.""" + app_path = os.path.dirname(sys.modules[_name_].__file__) + + def local_path(*path): + return os.path.join(app_path, *path) + return local_path diff --git a/helpers/xdg.py b/helpers/xdg.py deleted file mode 100644 index be3355d0..00000000 --- a/helpers/xdg.py +++ /dev/null @@ -1,79 +0,0 @@ -# coding=utf-8 -"""XDG Base Directory Specification variables. - -XDG_CACHE_HOME, XDG_CONFIG_HOME, and XDG_DATA_HOME are strings -containing the value of the environment variable of the same name, or -the default defined in the specification if the environment variable is -unset or empty. - -XDG_CONFIG_DIRS and XDG_DATA_DIRS are lists of strings containing the -value of the environment variable of the same name split on colons, or -the default defined in the specification if the environment variable is -unset or empty. - -XDG_RUNTIME_DIR is a string containing the value of the environment -variable of the same name, or None if the environment variable is not -set. -""" - -# Copyright © 2016-2018 Scott Stevenson -# -# Permission to use, copy, modify, and/or distribute this software for any -# purpose with or without fee is hereby granted, provided that the above -# copyright notice and this permission notice appear in all copies. -# -# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -# REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -# AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -# INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -# LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -# OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -# PERFORMANCE OF THIS SOFTWARE. - -import os - -__all__ = [ - "XDG_CACHE_HOME", - "XDG_CONFIG_DIRS", - "XDG_CONFIG_HOME", - "XDG_DATA_DIRS", - "XDG_DATA_HOME", - "XDG_RUNTIME_DIR", -] - - -def _getenv(variable, default): - """Get an environment variable. - - Parameters - ---------- - variable : str - The environment variable. - default : str - A default value that will be returned if the environment - variable is unset or empty. - - Returns - ------- - str - The value of the environment variable, or the default value. - - """ - return os.environ.get(variable) or default - - -XDG_CACHE_HOME = _getenv( - "XDG_CACHE_HOME", os.path.expandvars(os.path.join("$HOME", ".cache")) -) -XDG_CONFIG_DIRS = _getenv("XDG_CONFIG_DIRS", "/etc/xdg").split(":") -XDG_CONFIG_HOME = _getenv( - "XDG_CONFIG_HOME", os.path.expandvars(os.path.join("$HOME", ".config")) -) -XDG_DATA_DIRS = _getenv( - "XDG_DATA_DIRS", "/usr/local/share/:/usr/share/" -).split(":") -XDG_DATA_HOME = _getenv( - "XDG_DATA_HOME", - os.path.expandvars(os.path.join("$HOME", ".local", "share")), -) -XDG_RUNTIME_DIR = os.getenv("XDG_RUNTIME_DIR") diff --git a/libs/address_book/address_book.py b/libs/address_book/address_book.py index bb58b520..3e27b0d9 100644 --- a/libs/address_book/address_book.py +++ b/libs/address_book/address_book.py @@ -1,16 +1,13 @@ import os import pickle -from helpers import Singleton, flatten -from helpers import setup_logger +from helpers import ZP_DATA_DIR, Singleton, flatten, setup_logger from vcard_converter import VCardContactConverter from contact import Contact logger = setup_logger(__name__, "warning") -# FIXME: load from config module? SAVE_FILENAME = "contacts.pickle" -ZPUI_HOME = "~/.phone/" class AddressBook(Singleton): def __init__(self): @@ -42,9 +39,7 @@ def __init__(self): >>> len(a.contacts) 2 - """ - # todo : encrypt ? self._contacts = [] @property @@ -84,10 +79,7 @@ def reset(self): @staticmethod def get_save_file_path(): - path = os.environ.get("XDG_DATA_HOME") - if path: - return os.path.join(path, SAVE_FILENAME) - return os.path.join(os.path.expanduser(ZPUI_HOME), SAVE_FILENAME) + return os.path.join(ZP_DATA_DIR, SAVE_FILENAME) def find(self, **kwargs): # type: (dict) -> Contact diff --git a/libs/webdav/vdirsyncer.py b/libs/webdav/vdirsyncer.py index 68f92227..e16314e1 100644 --- a/libs/webdav/vdirsyncer.py +++ b/libs/webdav/vdirsyncer.py @@ -1,6 +1,5 @@ from jinja2 import Template, Environment, FileSystemLoader -from helpers.config_parse import write_config -from helpers.xdg import XDG_CONFIG_HOME, XDG_DATA_HOME +from helpers import ZP_CONFIG_DIR, ZP_DATA_DIR, write_config from main import load_config import os @@ -8,10 +7,9 @@ logger = setup_logger(__name__, "info") DEFAULT_VDIRSYNCER_BINARY = '/usr/bin/vdirsyncer' -DEFAULT_VDIRSYNCER_CONFIG_FILE = os.path.join(XDG_CONFIG_HOME, - 'vdirsyncer', - 'zp_config') -DEFAULT_VDIRSYNCER_STORAGE_DIRECTORY = os.path.join(XDG_DATA_HOME, 'vdirsyncer') +DEFAULT_VDIRSYNCER_CONFIG_FILE = os.path.join(ZP_CONFIG_DIR, + 'vdirsyncer_config') +DEFAULT_VDIRSYNCER_STORAGE_DIRECTORY = os.path.join(ZP_DATA_DIR, 'vdirsyncer') def vdirsyncer_sync(vdirsyncer_pair): return vdirsyncer_execute(['sync', vdirsyncer_pair]) @@ -50,7 +48,7 @@ def vdirsyncer_set_carddav_remote(url, username, password): }, 'local': { 'type': 'filesystem', - 'path': contacts_storage_directory, + 'path': contacts_storage_directory, 'fileext': '.vcf' }, 'remote': { @@ -103,6 +101,7 @@ def vdirsyncer_generate_config(): ) logger.info('Writing vdirsyncer configuration') + # FIXME: ensure file is not world-readable with open(vdirsyncer_config_file, 'w') as fh: fh.write(rendered_vdirsyncer_config) fh.close From ce69110d30d4491d2b961efead2efa2384b63a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sun, 6 Jan 2019 16:23:53 +0100 Subject: [PATCH 10/12] Add minimal documentation to the vdirsycner module --- apps/personal/contacts/main.py | 24 +++--------- libs/webdav/__init__.py | 1 + libs/webdav/vdirsyncer.py | 68 +++++++++++++++++++++++++++------- 3 files changed, 61 insertions(+), 32 deletions(-) diff --git a/apps/personal/contacts/main.py b/apps/personal/contacts/main.py index 76063d2a..e1bb8fb3 100644 --- a/apps/personal/contacts/main.py +++ b/apps/personal/contacts/main.py @@ -1,7 +1,5 @@ # coding=utf-8 import argparse -import doctest - import os from apps import ZeroApp @@ -11,10 +9,7 @@ from distutils.spawn import find_executable from libs.address_book import AddressBook, Contact -from libs.webdav.vdirsyncer import (vdirsyncer_sync, vdirsyncer_discover, - vdirsyncer_set_carddav_remote, - vdirsyncer_get_storage_directory_for, - vdirsyncer_generate_config) +from libs.webdav import vdirsyncer logger = setup_logger(__name__, 'info') @@ -24,7 +19,6 @@ def __init__(self, i, o): self.menu_name = 'Contacts' self.address_book = AddressBook() self.menu = None - self.vdirsyncer_executable = find_executable('vdirsyncer') def on_start(self): self.reload() @@ -71,14 +65,8 @@ def reset_addressbook(self): self.reload() def synchronize_carddav(self): - if (not os.path.isfile(self.vdirsyncer_executable) or - not os.access(self.vdirsyncer_executable, os.X_OK)): - Printer('Could not execute vdirsyncer.', self.i, self.o, - sleep_time=2, skippable=True) - return; - with LoadingIndicator(self.i, self.o, message='Syncing contacts'): - exit_status = vdirsyncer_sync('contacts') + exit_status = vdirsyncer.sync('contacts') if (exit_status != 0): error_msg = "Error in contact synchronization. See ZPUI logs for \ @@ -88,7 +76,7 @@ def synchronize_carddav(self): with LoadingIndicator(self.i, self.o, message='Importing contacts'): self.address_book.import_vcards_from_directory( - vdirsyncer_get_storage_directory_for('contacts') + vdirsyncer.get_storage_directory_for('contacts') ) self.address_book.save_to_file() @@ -113,12 +101,12 @@ def open_remote_setup_wizard(self): password = password_field.activate() # Update ZPUI vdirsyncer config, generate vdirsyncer config file - vdirsyncer_set_carddav_remote(url, username, password) - vdirsyncer_generate_config() + vdirsyncer.set_carddav_remote(url, username, password) + vdirsyncer.generate_config() # Initialize vdirsyncer remote with LoadingIndicator(self.i, self.o, message='Initializing remote'): - exit_status = vdirsyncer_discover('contacts') + exit_status = vdirsyncer.discover('contacts') if (exit_status != 0): error_msg = "Error in remote initialization. Check vdirsyncer \ diff --git a/libs/webdav/__init__.py b/libs/webdav/__init__.py index e69de29b..d2284f5f 100644 --- a/libs/webdav/__init__.py +++ b/libs/webdav/__init__.py @@ -0,0 +1 @@ +import vdirsyncer diff --git a/libs/webdav/vdirsyncer.py b/libs/webdav/vdirsyncer.py index e16314e1..64608819 100644 --- a/libs/webdav/vdirsyncer.py +++ b/libs/webdav/vdirsyncer.py @@ -1,3 +1,12 @@ +""" Vdirsyncer interface +This modules provides a basic interface over vdirsyncer [0]: + +> Vdirsyncer is a command-line tool for synchronizing calendars and addressbooks +> between a variety of servers and the local filesystem. + +[0] https://vdirsyncer.pimutils.org/en/stable/ +""" + from jinja2 import Template, Environment, FileSystemLoader from helpers import ZP_CONFIG_DIR, ZP_DATA_DIR, write_config from main import load_config @@ -11,13 +20,7 @@ 'vdirsyncer_config') DEFAULT_VDIRSYNCER_STORAGE_DIRECTORY = os.path.join(ZP_DATA_DIR, 'vdirsyncer') -def vdirsyncer_sync(vdirsyncer_pair): - return vdirsyncer_execute(['sync', vdirsyncer_pair]) - -def vdirsyncer_discover(vdirsyncer_pair): - return vdirsyncer_execute(['discover', vdirsyncer_pair]) - -def vdirsyncer_execute(vdirsyncer_args): +def _execute(vdirsyncer_args): zp_config = load_config()[0] zp_vdirsyncer_config = zp_config.get('vdirsyncer', {}) vdirsyncer_binary = zp_vdirsyncer_config.get( @@ -34,11 +37,35 @@ def vdirsyncer_execute(vdirsyncer_args): logger.info("External binary call: {}".format(vdirsyncer_command)) return os.system(vdirsyncer_command) +def sync(vdirsyncer_pair): + """ This function synchronize two vdirsyncer storage entries, given a pair. + + A pair, defined in vdirsyncer's configuration, is usually composed of a + local directory and a remote (CardDAV, CalDAV, ...). Do not forget to + initialize the mapping with discover/1 before the first synchronization. + """ + return _execute(['sync', vdirsyncer_pair]) + +def discover(vdirsyncer_pair): + """ This function scans and initialize the storage entries of a pair. It + must be run before the initial synchronization, and after every change to + vdirsyncer's configuration. + + **If the pair contains a local directory which does not exist, vdirsycner + will ask on the standard input whether to create it or not: this will block + ZPUI.** + """ + return _execute(['discover', vdirsyncer_pair]) + # TODO: add support for multiple remotes -def vdirsyncer_set_carddav_remote(url, username, password): +def set_carddav_remote(url, username, password): + """ This function configure the (unique) CardDAV remote in ZPUI's + configuration. You will have to run generate_config/0 in order to write + vdirsyncer's configuration to disk. + """ zp_config, zp_config_path = load_config() zp_vdirsyncer_config = zp_config.get('vdirsyncer', {}) - contacts_storage_directory = vdirsyncer_get_storage_directory_for('contacts') + contacts_storage_directory = get_storage_directory_for('contacts') zp_vdirsyncer_config['contacts'] = { 'pair': { @@ -62,7 +89,14 @@ def vdirsyncer_set_carddav_remote(url, username, password): zp_config['vdirsyncer'] = zp_vdirsyncer_config return write_config(zp_config, zp_config_path) -def vdirsyncer_get_storage_directory_for(pair): +def get_storage_directory_for(pair): + """ Returns the path of the local storage for a given pair. + + A pair, defined in vdirsyncer's configuration, is usually composed of a + local directory and a remote (CardDAV, CalDAV, ...). This function returns + the path to the local directory containing vcf (for a CardDAV remote) + or ics (for a CalDAV remote) files. + """ zp_config = load_config()[0] zp_vdirsyncer_config = zp_config.get('vdirsyncer', {}) vdirsyncer_storage_directory = zp_vdirsyncer_config.get( @@ -77,14 +111,20 @@ def vdirsyncer_get_storage_directory_for(pair): return directory -# TODO: similar to vdirsyncer_set_carddav_remote -def vdirsyncer_set_calddav_remote(): +# TODO: similar to set_carddav_remote +def set_calddav_remote(): + """ Not yet implemented. + """ return -def vdirsyncer_generate_config(): +def generate_config(): + """ Generate and write to disk vdirsyncer's configuration file, based on + ZPUI's configuration. You must run discover/1 afterwards if the + configuration was modified. + """ zp_config = load_config()[0] zp_vdirsyncer_config = zp_config.get('vdirsyncer', {}) - status_directory = vdirsyncer_get_storage_directory_for('_vdirsyncer_status') + status_directory = get_storage_directory_for('_vdirsyncer_status') logger.info('Generating vdirsyncer configuration') From 123c48b777117fca3e45c77fc8eda8c1c736d2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sun, 6 Jan 2019 17:10:12 +0100 Subject: [PATCH 11/12] Add minimal documentation to the address_book module + properly define public API for the mentionned module --- apps/personal/contacts/main.py | 2 - libs/address_book/address_book.py | 109 +++++++++++++++++++----------- 2 files changed, 68 insertions(+), 43 deletions(-) diff --git a/apps/personal/contacts/main.py b/apps/personal/contacts/main.py index e1bb8fb3..ada27e94 100644 --- a/apps/personal/contacts/main.py +++ b/apps/personal/contacts/main.py @@ -24,7 +24,6 @@ def on_start(self): self.reload() def reload(self): - self.address_book.load_from_file() self.menu = NumberedMenu(self.build_main_menu_content(), self.i, self.o, prepend_numbers=False) self.menu.activate() @@ -78,7 +77,6 @@ def synchronize_carddav(self): self.address_book.import_vcards_from_directory( vdirsyncer.get_storage_directory_for('contacts') ) - self.address_book.save_to_file() # Reload the synced address book self.reload() diff --git a/libs/address_book/address_book.py b/libs/address_book/address_book.py index 3e27b0d9..af9c24ef 100644 --- a/libs/address_book/address_book.py +++ b/libs/address_book/address_book.py @@ -11,7 +11,9 @@ class AddressBook(Singleton): def __init__(self): - """ + """ This class provides the address book used by the Contacts + application. + Adds a single contact >>> a = AddressBook() >>> c1 = Contact(name="john", org="wikipedia") @@ -38,66 +40,39 @@ def __init__(self): >>> a.add_contact(c3, auto_merge=False) >>> len(a.contacts) 2 - """ self._contacts = [] + self._load_from_file() - @property - def contacts(self): - # type: () -> list - return self._contacts - - def add_contact(self, contact, auto_merge=True): - # type: (Contact, bool) -> None - if not auto_merge or not len(self.contacts): - self._contacts.append(contact) - return - - duplicate = self.find_best_duplicate(contact) - if duplicate: - duplicate.merge(contact) - else: - self._contacts.append(contact) + @staticmethod + def _get_save_file_path(): + return os.path.join(ZP_DATA_DIR, SAVE_FILENAME) - def load_from_file(self): - save_path = self.get_save_file_path() + def _load_from_file(self): + save_path = self._get_save_file_path() if not os.path.exists(save_path): logger.error("Could not load. File {} not found".format(save_path)) return - with open(self.get_save_file_path(), 'r') as f_save: + with open(self._get_save_file_path(), 'r') as f_save: self._contacts = pickle.load(f_save) - def save_to_file(self): + def _save_to_file(self): for c in self.contacts: c.consolidate() - with open(self.get_save_file_path(), 'w') as f_save: + with open(self._get_save_file_path(), 'w') as f_save: pickle.dump(self._contacts, f_save) - def reset(self): - self._contacts = [] - self.save_to_file() - - @staticmethod - def get_save_file_path(): - return os.path.join(ZP_DATA_DIR, SAVE_FILENAME) - - def find(self, **kwargs): - # type: (dict) -> Contact - # simple wrapper around find_best_duplicate - c = Contact(**kwargs) - return self.find_best_duplicate(c) - - def get_contacts_with(self, attribute_name): + def _get_contacts_with(self, attribute_name): # type: (str) -> list return [c for c in self.contacts if len(getattr(c, attribute_name))] - def find_best_duplicate(self, contact): + def _find_best_duplicate(self, contact): # type: (Contact) -> Contact - match_score_contact_list = self.find_duplicates(contact) + match_score_contact_list = self._find_duplicates(contact) if match_score_contact_list[0][0] > 0: return match_score_contact_list[0][1] - def find_duplicates(self, contact): + def _find_duplicates(self, contact): # type: (Contact) -> list if contact in self._contacts: return [1, contact] @@ -110,7 +85,59 @@ def cmp(a1, a2): return sorted(match_score_contact_list, cmp=cmp) + @property + def contacts(self): + """ Returns a list containing all the contacts of this address book.""" + # type: () -> list + return self._contacts + + def add_contact(self, contact, auto_merge=True): + """Add a contact to this address book. + + Args: + + * ``contact``: the contact object to add + + Kwargs: + + * ``auto_merge``: wether to automatically merge ``contact`` if + there already is a similar entry in the address book + """ + # type: (Contact, bool) -> None + if not auto_merge or not len(self.contacts): + self._contacts.append(contact) + return + + duplicate = self._find_best_duplicate(contact) + if duplicate: + duplicate.merge(contact) + else: + self._contacts.append(contact) + + # Save changes to disk + self._save_to_file() + + def reset(self): + """Delete all the contacts of this address book.""" + self._contacts = [] + self._save_to_file() + + def find(self, **kwargs): + """Search for a contact in this address book and return the best + match. + """ + # type: (dict) -> Contact + # simple wrapper around find_best_duplicate + c = Contact(**kwargs) + return self._find_best_duplicate(c) + def import_vcards_from_directory(self, directory): + """Import every VCF file in ``directory`` to this address book. + + Args: + + * ``directory``: absolute path to a directory containing VCF files + """ logger.info("Import vCards from {}".format(directory)) # Extract *cvf files from the directory From a180045151e8d1f3f0d1f525fd4a62f5300eec74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Timoth=C3=A9e=20Floure?= Date: Sun, 6 Jan 2019 18:06:12 +0100 Subject: [PATCH 12/12] Minor refactor (move a few methods around) of the address_book/contact modules --- libs/address_book/address_book.py | 5 ++ libs/address_book/contact.py | 89 ++++++++++++++++--------------- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/libs/address_book/address_book.py b/libs/address_book/address_book.py index af9c24ef..3b48492f 100644 --- a/libs/address_book/address_book.py +++ b/libs/address_book/address_book.py @@ -40,6 +40,11 @@ def __init__(self): >>> a.add_contact(c3, auto_merge=False) >>> len(a.contacts) 2 + + Reset the address book + >>> a.reset() + >>> len(a.contacts) + 0 """ self._contacts = [] self._load_from_file() diff --git a/libs/address_book/contact.py b/libs/address_book/contact.py index c0d646cb..673972c5 100644 --- a/libs/address_book/contact.py +++ b/libs/address_book/contact.py @@ -7,7 +7,8 @@ logger = setup_logger(__name__, "warning") class Contact(object): - """ + """ Represents a contact as used by the address book. + >>> c = Contact() >>> c.name [] @@ -26,7 +27,7 @@ def __init__(self, **kwargs): self.org = [] self.photo = [] self.title = [] - self.from_kwargs(kwargs) + self._from_kwargs(kwargs) def __eq__(self, other): return self.__dict__ == other.__dict__ @@ -34,14 +35,51 @@ def __eq__(self, other): def __str__(self): return str(self.__dict__) - def from_kwargs(self, kwargs): - provided_attrs = {attr: kwargs[attr] for attr in self.get_all_attributes() if attr in kwargs} + def _from_kwargs(self, kwargs): + provided_attrs = {attr: kwargs[attr] for attr in self._get_all_attributes() if attr in kwargs} for attr_name, attr_value in provided_attrs.items(): if isinstance(attr_value, list): setattr(self, attr_name, attr_value) else: setattr(self, attr_name, [attr_value]) + def _get_all_attributes(self): + return [a for a in dir(self) if not callable(getattr(self, a)) and not a.startswith("__")] + + def _consolidate_attribute(self, attribute_name): + # type: (str) -> None + attr_value = getattr(self, attribute_name) + attr_value = flatten(attr_value) + # removes exact duplicates + attr_value = list(set([i.strip() for i in attr_value if isinstance(i, basestring)])) + + attr_value[:] = [x for x in attr_value if not self._is_contained_in_other_element_of_the_list(x, attr_value)] + + setattr(self, attribute_name, list(set(attr_value))) + + @staticmethod + def _is_contained_in_other_element_of_the_list(p_element, the_list): + # type: (object, list) -> bool + copy = list(the_list) + copy.remove(p_element) + for element in copy: + if p_element in element: + return True + return False + + def short_name(self): + for attr_name in self.get_filled_attributes(): + for attribute in getattr(self, attr_name): + if not isinstance(attribute, basestring) and not isinstance(attribute, list): + continue + if isinstance(attribute, list): + for entry_str in attribute: + if not isinstance(entry_str, basestring): + continue + else: + return attribute + return "unknown" + def match_score(self, other): # type: (Contact) -> int """ @@ -82,10 +120,10 @@ def consolidate(self): """ my_attributes = self.get_filled_attributes() for name in my_attributes: # removes exact duplicates - self.consolidate_attribute(name) + self._consolidate_attribute(name) def get_filled_attributes(self): - """ + """ Returns a list of the (non-empty) fields contained in this contact. >>> c = Contact() >>> c.name = ["John", "Johnny"] >>> c.note = ["That's him !"] @@ -95,19 +133,6 @@ def get_filled_attributes(self): return [a for a in dir(self) if not callable(getattr(self, a)) and not a.startswith("__") and len(getattr(self, a))] - def get_all_attributes(self): - return [a for a in dir(self) if not callable(getattr(self, a)) and not a.startswith("__")] - - def consolidate_attribute(self, attribute_name): - # type: (str) -> None - attr_value = getattr(self, attribute_name) - attr_value = flatten(attr_value) - attr_value = list(set([i.strip() for i in attr_value if isinstance(i, basestring)])) # removes exact duplicates - - attr_value[:] = [x for x in attr_value if not self._is_contained_in_other_element_of_the_list(x, attr_value)] - - setattr(self, attribute_name, list(set(attr_value))) - def merge(self, other): # type: (Contact) -> None """ @@ -126,34 +151,10 @@ def merge(self, other): setattr(self, attr_name, attrs_sum) self.consolidate() - def short_name(self): - for attr_name in self.get_filled_attributes(): - for attribute in getattr(self, attr_name): - if not isinstance(attribute, basestring) and not isinstance(attribute, list): - continue - if isinstance(attribute, list): - for entry_str in attribute: - if not isinstance(entry_str, basestring): - continue - else: - return attribute - return "unknown" - @staticmethod def common_attribute_count(a1, a2): + """Count the number of identical fields between two contacts.""" # type: (list, list) -> int a1_copy = [i.lower() for i in a1 if isinstance(i, basestring)] a2_copy = [i.lower() for i in a2 if isinstance(i, basestring)] return len(set(a1_copy).intersection(a2_copy)) - - @staticmethod - def _is_contained_in_other_element_of_the_list(p_element, the_list): - """ - """ - # type: (object, list) -> bool - copy = list(the_list) - copy.remove(p_element) - for element in copy: - if p_element in element: - return True - return False