Repository: lucc/khard

Issue:
Allow autodiscovery of contact books via globbing
It would be nice, if khard would support globbing for the adressbook configuration like khal does: [khal-discover](https://khal.readthedocs.io/en/latest/configure.html#exemplary-discover-usage)

In the config file this should look like this:

    [[adressbooks]]
    path = ~/.contacts/*
    type = discover

Hints from issue comments:
@lucc: Nice idea, you can try to implement a new address book class that does this.@antonmosich: @lucc I tried to tackle this, and decided to use a different approach. You can look at it in my [discover branch](https://github.com/lucc/khard/compare/main...antonmosich:khard:discover). Would you be fine with that approach? Then I could document it and open a pull request.@lucc: @antonmosich yes, please do that.

Solution patch:
diff --git a/doc/source/man.rst b/doc/source/man.rst
index c3d5704..36913dd 100644
--- a/doc/source/man.rst
+++ b/doc/source/man.rst
@@ -8,4 +8,4 @@ The following man pages are available for khard:
 
    khard(1) <man/khard>
    khard-subcommands(1) <man/khard-subcommands>
-   khard.conf(1) <man/khard.conf>
+   khard.conf(5) <man/khard.conf>
diff --git a/doc/source/man/khard.conf.rst b/doc/source/man/khard.conf.rst
index 2f6cb74..2167310 100644
--- a/doc/source/man/khard.conf.rst
+++ b/doc/source/man/khard.conf.rst
@@ -41,8 +41,10 @@ addressbooks
   This section contains several subsections, but at least one. Each subsection
   can have an arbitrary name which will be the name of an addressbook known to
   khard.  Each of these subsections **must** have a *path* key with the path to
-  the folder containing the vCard files for that addressbook.  The *path* value
-  supports environment variables and tilde prefixes.  :program:`khard` expects
+  the folder containing the vCard files for that addressbook. Optionally, you
+  can set the *type* value to either ``discover`` or ``vdir``, the default. The
+  *path* value supports environment variables and tilde prefixes. When using
+  the ``discover`` type, it also supports globbing. :program:`khard` expects
   the vCard files to hold only one VCARD record each and end in a :file:`.vcf`
   extension.
 
diff --git a/khard/config.py b/khard/config.py
index c64e3fe..03bc59d 100644
--- a/khard/config.py
+++ b/khard/config.py
@@ -8,6 +8,7 @@ import os
 import re
 import shlex
 from typing import Iterable, Optional, Union
+from glob import iglob
 
 import configobj
 try:
@@ -94,7 +95,9 @@ class Config:
         self.abooks: AddressBookCollection
         locale.setlocale(locale.LC_ALL, '')
         config = self._load_config_file(config_file)
-        self.config = self._validate(config)
+        config = self._validate(config)
+        config["addressbooks"] = self._unfold_discover_books(config["addressbooks"])
+        self.config = config
         self._set_attributes()
 
     @classmethod
@@ -137,6 +140,53 @@ class Config:
             raise ConfigError
         return config
 
+    @classmethod
+    def _unfold_discover_books(cls, addressbooks: configobj.Section) -> configobj.Section:
+        """Expand globs in path of addressbooks of type "discover"
+
+        This expands all addressbooks of type "discover" into (potentially)
+        multiple addressbooks of type "vdir". The names are automatically generated
+        based on the directory name.
+
+        :param config: the configuration to be changed
+        :returns: the changed configuration with no "discover" addressbooks
+        """
+        for section_name, book in addressbooks.copy().items():
+            if book["type"] != "discover":
+                continue
+            hits = iglob(os.path.expanduser(book["path"]), recursive=True)
+            dirs = cls._find_leaf_dirs(hits)
+            for bookpath in dirs:
+                bookname = os.path.basename(bookpath)
+                # Make sure our name is unique
+                counter = 0
+                while bookname in addressbooks:
+                    counter += 1
+                    if bookname + f"-{counter}" in addressbooks:
+                        continue
+                    bookname += f"-{counter}"
+                    break
+                addressbooks[bookname] = {
+                    "type": "vdir",
+                    "path": bookpath,
+                }
+            addressbooks.pop(section_name)
+        return addressbooks
+
+    @staticmethod
+    def _find_leaf_dirs(hits: Iterable[str]) -> set[str]:
+        """Find leaf directories in a tree of hits when using glob.iglob
+
+        The hits are neither guaranteed to be unique nor leaf directories, both
+        of which are enforced by this function.
+
+        :param hits: the hits of a glob as returned by glob.iglob
+        :returns: a set of path strings
+        """
+        dirs = {os.path.normpath(hit) for hit in hits if os.path.isdir(hit)}
+        parents = {os.path.normpath(os.path.join(dir, os.pardir)) for dir in dirs}
+        return dirs - parents
+
     def _set_attributes(self) -> None:
         """Set the attributes from the internal config instance on self."""
         general = self.config["general"]
diff --git a/khard/data/config.spec b/khard/data/config.spec
index a73230d..8b46a20 100644
--- a/khard/data/config.spec
+++ b/khard/data/config.spec
@@ -25,3 +25,4 @@ skip_unparsable = boolean(default=False)
 [addressbooks]
   [[__many__]]
     path = string
+    type = option('vdir', 'discover', default='vdir')


Test patch:
diff --git a/test/fixture/discover.conf b/test/fixture/discover.conf
new file mode 100644
index 0000000..a9ccea1
--- /dev/null
+++ b/test/fixture/discover.conf
@@ -0,0 +1,4 @@
+[addressbooks]
+[[discovered]]
+path = test/fixture/*.abook
+type = discover
diff --git a/test/test_config.py b/test/test_config.py
index b5342de..6e76b63 100644
--- a/test/test_config.py
+++ b/test/test_config.py
@@ -50,6 +50,13 @@ class LoadingConfigFile(unittest.TestCase):
                 with self.assertRaises(ConfigError):
                     config.Config(name)
 
+    def test_discover_books(self):
+        filename = "test/fixture/discover.conf"
+        cfg = config.Config(filename)
+        cfg.init_address_books()
+        expected = {'broken.abook', 'nick.abook', 'test.abook', 'minimal.abook'}
+        self.assertEqual(set(cfg.abooks._abooks.keys()), expected)
+
     @mock.patch.dict("os.environ", EDITOR="editor", MERGE_EDITOR="meditor")
     def test_load_minimal_file_by_name(self):
         cfg = config.Config("test/fixture/minimal.conf")