diff --git a/apps/personal/contacts/main.py b/apps/personal/contacts/main.py index 70e7975b..ada27e94 100644 --- a/apps/personal/contacts/main.py +++ b/apps/personal/contacts/main.py @@ -1,70 +1,135 @@ # coding=utf-8 import argparse -import doctest - import os -from address_book import AddressBook, ZPUI_HOME, 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, DialogBox, + PrettyPrinter as Printer, UniversalInput) +from distutils.spawn import find_executable -logger = setup_logger(__name__, "info") +from libs.address_book import AddressBook, Contact +from libs.webdav import vdirsyncer +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 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.reload() + + def reload(self): + self.menu = NumberedMenu(self.build_main_menu_content(), self.i, + 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] - - def create_contact_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() - - -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 - + 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)]) -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())) + 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()] + Listbox(contact_attrs, self.i, self.o).activate() + + def open_actions_menu(self): + menu_contents = [ + ['CardDAV Setup Wizard', lambda: self.open_remote_setup_wizard()], + ['CardDAV Sync', lambda: self.synchronize_carddav()], + ['Reset address book', lambda: self.reset_addressbook()] + ] + 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', 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, self.i, self.o, sleep_time=2, skippable=True) + # Reload the now empty address book + self.reload() + + def synchronize_carddav(self): + with LoadingIndicator(self.i, self.o, message='Syncing contacts'): + exit_status = vdirsyncer.sync('contacts') + + if (exit_status != 0): + error_msg = "Error in contact synchronization. See ZPUI logs for \ + details." + Printer(error_msg, self.i, self.o, sleep_time=3) + self.open_actions_menu() + + with LoadingIndicator(self.i, self.o, message='Importing contacts'): + self.address_book.import_vcards_from_directory( + vdirsyncer.get_storage_directory_for('contacts') + ) + + # 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, self.i, self.o, sleep_time=3) + 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() - 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/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/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/libs/address_book/address_book.py b/libs/address_book/address_book.py new file mode 100644 index 00000000..3b48492f --- /dev/null +++ b/libs/address_book/address_book.py @@ -0,0 +1,165 @@ +import os +import pickle + +from helpers import ZP_DATA_DIR, Singleton, flatten, setup_logger +from vcard_converter import VCardContactConverter +from contact import Contact + +logger = setup_logger(__name__, "warning") + +SAVE_FILENAME = "contacts.pickle" + +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") + >>> a.add_contact(c1) + >>> len(a.contacts) + 1 + + Adds another contact so similar it will be merged with the previous + >>> c2 = Contact() + >>> c2.name = ["john"] + >>> c2.telephone = ["911"] + >>> a.add_contact(c2) + + the updated contact is retrieved + >>> a.find(name="john").telephone + ['911'] + >>> a.find(name="john").org + ['wikipedia'] + >>> len(a.contacts) + 1 + + Add a third similar contact, without auto_merge + >>> c3 = Contact(name="John", telephone="911") + >>> 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() + + @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() + 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: + self._contacts = pickle.load(f_save) + + def _save_to_file(self): + for c in self.contacts: + c.consolidate() + with open(self._get_save_file_path(), 'w') as f_save: + pickle.dump(self._contacts, f_save) + + 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): + # type: (Contact) -> 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): + # 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] + + def cmp(a1, a2): + # type: (tuple, tuple) -> int + return a1[0] > a2[0] + + 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 + 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 new in parsed_contacts: + is_duplicate = new in self._contacts + + if is_duplicate: + logger.info("Ignore duplicated contact for: {}" + .format(new.name)) + break + + self.add_contact(new) diff --git a/apps/personal/contacts/address_book.py b/libs/address_book/contact.py similarity index 55% rename from apps/personal/contacts/address_book.py rename to libs/address_book/contact.py index 49a36de7..673972c5 100644 --- a/apps/personal/contacts/address_book.py +++ b/libs/address_book/contact.py @@ -6,111 +6,9 @@ logger = setup_logger(__name__, "warning") - -class AddressBook(Singleton): - def __init__(self): - """ - Adds a single contact - >>> a = AddressBook() - >>> c1 = Contact(name="john", org="wikipedia") - >>> a.add_contact(c1) - >>> len(a.contacts) - 1 - - Adds another contact so similar it will be merged with the previous - >>> c2 = Contact() - >>> c2.name = ["john"] - >>> c2.telephone = ["911"] - >>> a.add_contact(c2) - - the updated contact is retrieved - >>> a.find(name="john").telephone - ['911'] - >>> a.find(name="john").org - ['wikipedia'] - >>> len(a.contacts) - 1 - - Add a third similar contact, without auto_merge - >>> c3 = Contact(name="John", telephone="911") - >>> a.add_contact(c3, auto_merge=False) - >>> len(a.contacts) - 2 - - - """ - # todo : encrypt ? - self._contacts = [] - - @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) - - 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: - self._contacts = pickle.load(f_save) - - def save_to_file(self): - for c in self.contacts: - c.consolidate() - with open(self.get_save_file_path(), 'w') as f_save: - pickle.dump(self._contacts, f_save) - - @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) - - 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): - # type: (str) -> list - return [c for c in self.contacts if len(getattr(c, attribute_name))] - - def find_best_duplicate(self, contact): - # type: (Contact) -> 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): - # 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] - - def cmp(a1, a2): - # type: (tuple, tuple) -> int - return a1[0] > a2[0] - - return sorted(match_score_contact_list, cmp=cmp) - - class Contact(object): - """ + """ Represents a contact as used by the address book. + >>> c = Contact() >>> c.name [] @@ -129,17 +27,59 @@ 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__ + + 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] + 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 """ @@ -180,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 !"] @@ -193,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 """ @@ -224,38 +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 - - -SAVE_FILENAME = "contacts.pickle" -ZPUI_HOME = "~/.phone/" diff --git a/apps/personal/contacts/vcard_converter.py b/libs/address_book/vcard_converter.py similarity index 92% rename from apps/personal/contacts/vcard_converter.py rename to libs/address_book/vcard_converter.py index d8d13d05..0d8427b7 100644 --- a/apps/personal/contacts/vcard_converter.py +++ b/libs/address_book/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 = { @@ -28,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 diff --git a/libs/webdav/__init__.py b/libs/webdav/__init__.py new file mode 100644 index 00000000..d2284f5f --- /dev/null +++ b/libs/webdav/__init__.py @@ -0,0 +1 @@ +import vdirsyncer diff --git a/libs/webdav/vdirsyncer.py b/libs/webdav/vdirsyncer.py new file mode 100644 index 00000000..64608819 --- /dev/null +++ b/libs/webdav/vdirsyncer.py @@ -0,0 +1,147 @@ +""" 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 +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(ZP_CONFIG_DIR, + 'vdirsyncer_config') +DEFAULT_VDIRSYNCER_STORAGE_DIRECTORY = os.path.join(ZP_DATA_DIR, 'vdirsyncer') + +def _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) + +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 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 = 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 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( + '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 set_carddav_remote +def set_calddav_remote(): + """ Not yet implemented. + """ + return + +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 = get_storage_directory_for('_vdirsyncer_status') + + logger.info('Generating vdirsyncer configuration') + + jinja2_env = Environment(loader=FileSystemLoader('libs/webdav'), + trim_blocks=True) + template = jinja2_env.get_template('vdirsyncer_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') + # FIXME: ensure file is not world-readable + with open(vdirsyncer_config_file, 'w') as fh: + fh.write(rendered_vdirsyncer_config) + fh.close diff --git a/libs/webdav/vdirsyncer_config.j2 b/libs/webdav/vdirsyncer_config.j2 new file mode 100644 index 00000000..f73e9fef --- /dev/null +++ b/libs/webdav/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 %}