From b01eb1eb3b579c74e6a4189ef33cc3fa24c40613 Mon Sep 17 00:00:00 2001
From: Matthieu Coudron <teto@users.noreply.github.com>
Date: Mon, 12 Jun 2023 23:21:24 +0200
Subject: [PATCH] Add infrastructure for contacts and calendars (#4078)

* Add infrastructure for contacts and calendars

This also adds the modules

  - programs.vdirsyncer,
  - programs.khal, and
  - services.vdirsyncer

that integrate with the new infrastructure.

Co-authored-by: Andrew Scott <3648487+ayyjayess@users.noreply.github.com>
Co-authored-by: Sebastian Zivota <sebastian.zivota@mailbox.org>

wip

* vdirsyncer: allow option userName, disallow userNameCommand

1. account option `userName` is now allowed by `programs.vdirsyncer`

2. The commented out account option `userNameCommand` was required to be set
   by `programs.vdirsyncer` (e.g. as `null`).
   It is now disallowed (commented out) by vdirsyncer.

* khal: added options 'color' and 'priority'
* Apply nixfmt

---------

Co-authored-by: Sebastian Zivota <sebastian.zivota@mailbox.org>
Co-authored-by: Johannes Rosenberger <johannes.rosenberger@jorsn.eu>
Co-authored-by: Johannes Rosenberger <johannes@jorsn.eu>
Co-authored-by: Robert Helgesson <robert@rycee.net>
---
 modules/accounts/calendar.nix               | 163 ++++++++++++
 modules/accounts/contacts.nix               | 134 ++++++++++
 modules/misc/news.nix                       |  24 ++
 modules/modules.nix                         |   2 +
 modules/programs/khal-accounts.nix          |  17 ++
 modules/programs/khal-calendar-accounts.nix |  58 ++++
 modules/programs/khal.nix                   | 170 ++++++++++++
 modules/programs/vdirsyncer-accounts.nix    | 187 +++++++++++++
 modules/programs/vdirsyncer.nix             | 276 ++++++++++++++++++++
 modules/services/vdirsyncer.nix             |  87 ++++++
 10 files changed, 1118 insertions(+)
 create mode 100644 modules/accounts/calendar.nix
 create mode 100644 modules/accounts/contacts.nix
 create mode 100644 modules/programs/khal-accounts.nix
 create mode 100644 modules/programs/khal-calendar-accounts.nix
 create mode 100644 modules/programs/khal.nix
 create mode 100644 modules/programs/vdirsyncer-accounts.nix
 create mode 100644 modules/programs/vdirsyncer.nix
 create mode 100644 modules/services/vdirsyncer.nix

diff --git a/modules/accounts/calendar.nix b/modules/accounts/calendar.nix
new file mode 100644
index 000000000..d53d3afa4
--- /dev/null
+++ b/modules/accounts/calendar.nix
@@ -0,0 +1,163 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.accounts.calendar;
+
+  localModule = name:
+    types.submodule {
+      options = {
+        path = mkOption {
+          type = types.str;
+          default = "${cfg.basePath}/${name}";
+          defaultText = "‹accounts.contact.basePath›/‹name›";
+          description = "The path of the storage.";
+        };
+
+        type = mkOption {
+          type = types.enum [ "filesystem" "singlefile" ];
+          description = "The type of the storage.";
+        };
+
+        fileExt = mkOption {
+          type = types.nullOr types.str;
+          default = null;
+          description = "The file extension to use.";
+        };
+
+        encoding = mkOption {
+          type = types.nullOr types.str;
+          default = null;
+          description = ''
+            File encoding for items, both content and file name.
+            Defaults to UTF-8.
+          '';
+        };
+      };
+    };
+
+  remoteModule = types.submodule {
+    options = {
+      type = mkOption {
+        type = types.enum [ "caldav" "http" "google_calendar" ];
+        description = "The type of the storage.";
+      };
+
+      url = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = "The URL of the storage.";
+      };
+
+      userName = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = "User name for authentication.";
+      };
+
+      # userNameCommand = mkOption {
+      #   type = types.nullOr (types.listOf types.str);
+      #   default = null;
+      #   example = [ "~/get-username.sh" ];
+      #   description = ''
+      #     A command that prints the user name to standard output.
+      #   '';
+      # };
+
+      passwordCommand = mkOption {
+        type = types.nullOr (types.listOf types.str);
+        default = null;
+        example = [ "pass" "caldav" ];
+        description = ''
+          A command that prints the password to standard output.
+        '';
+      };
+    };
+  };
+
+  calendarOpts = { name, config, ... }: {
+    options = {
+      name = mkOption {
+        type = types.str;
+        readOnly = true;
+        description = ''
+          Unique identifier of the calendar. This is set to the
+          attribute name of the calendar configuration.
+        '';
+      };
+
+      primary = mkOption {
+        type = types.bool;
+        default = false;
+        description = ''
+          Whether this is the primary account. Only one account may be
+          set as primary.
+        '';
+      };
+
+      primaryCollection = mkOption {
+        type = types.str;
+        description = ''
+          The primary collection of the account. Required when an
+          account has multiple collections.
+        '';
+      };
+
+      local = mkOption {
+        type = types.nullOr (localModule name);
+        default = null;
+        description = ''
+          Local configuration for the calendar.
+        '';
+      };
+
+      remote = mkOption {
+        type = types.nullOr remoteModule;
+        default = null;
+        description = ''
+          Remote configuration for the calendar.
+        '';
+      };
+    };
+
+    config = { name = name; };
+  };
+
+in {
+  options.accounts.calendar = {
+    basePath = mkOption {
+      type = types.str;
+      apply = p:
+        if hasPrefix "/" p then p else "${config.home.homeDirectory}/${p}";
+      description = ''
+        The base directory in which to save calendars. May be a
+        relative path, in which case it is relative the home
+        directory.
+      '';
+    };
+
+    accounts = mkOption {
+      type = types.attrsOf (types.submodule [
+        calendarOpts
+        (import ../programs/vdirsyncer-accounts.nix)
+        (import ../programs/khal-accounts.nix)
+        (import ../programs/khal-calendar-accounts.nix)
+      ]);
+      default = { };
+      description = "List of calendars.";
+    };
+  };
+  config = mkIf (cfg.accounts != { }) {
+    assertions = let
+      primaries =
+        catAttrs "name" (filter (a: a.primary) (attrValues cfg.accounts));
+    in [{
+      assertion = length primaries <= 1;
+      message = "Must have at most one primary calendar account but found "
+        + toString (length primaries) + ", namely "
+        + concatStringsSep ", " primaries;
+    }];
+  };
+}
diff --git a/modules/accounts/contacts.nix b/modules/accounts/contacts.nix
new file mode 100644
index 000000000..83f57d8e2
--- /dev/null
+++ b/modules/accounts/contacts.nix
@@ -0,0 +1,134 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.accounts.contact;
+
+  localModule = name:
+    types.submodule {
+      options = {
+        path = mkOption {
+          type = types.str;
+          default = "${cfg.basePath}/${name}";
+          defaultText = "‹accounts.contact.basePath›/‹name›";
+          description = "The path of the storage.";
+        };
+
+        type = mkOption {
+          type = types.enum [ "filesystem" "singlefile" ];
+          description = "The type of the storage.";
+        };
+
+        fileExt = mkOption {
+          type = types.nullOr types.str;
+          default = null;
+          description = "The file extension to use.";
+        };
+
+        encoding = mkOption {
+          type = types.nullOr types.str;
+          default = null;
+          description = ''
+            File encoding for items, both content and file name.
+            Defaults to UTF-8.
+          '';
+        };
+      };
+    };
+
+  remoteModule = types.submodule {
+    options = {
+      type = mkOption {
+        type = types.enum [ "carddav" "http" "google_contacts" ];
+        description = "The type of the storage.";
+      };
+
+      url = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = "The URL of the storage.";
+      };
+
+      userName = mkOption {
+        type = types.nullOr types.str;
+        default = null;
+        description = "User name for authentication.";
+      };
+
+      # userNameCommand = mkOption {
+      #   type = types.nullOr (types.listOf types.str);
+      #   default = null;
+      #   example = [ "~/get-username.sh" ];
+      #   description = ''
+      #     A command that prints the user name to standard output.
+      #   '';
+      # };
+
+      passwordCommand = mkOption {
+        type = types.nullOr (types.listOf types.str);
+        default = null;
+        example = [ "pass" "caldav" ];
+        description = ''
+          A command that prints the password to standard output.
+        '';
+      };
+    };
+  };
+
+  contactOpts = { name, config, ... }: {
+    options = {
+      name = mkOption {
+        type = types.str;
+        readOnly = true;
+        description = ''
+          Unique identifier of the contact account. This is set to the
+          attribute name of the contact configuration.
+        '';
+      };
+
+      local = mkOption {
+        type = types.nullOr (localModule name);
+        default = null;
+        description = ''
+          Local configuration for the contacts.
+        '';
+      };
+
+      remote = mkOption {
+        type = types.nullOr remoteModule;
+        default = null;
+        description = ''
+          Remote configuration for the contacts.
+        '';
+      };
+    };
+
+    config = { name = name; };
+  };
+
+in {
+  options.accounts.contact = {
+    basePath = mkOption {
+      type = types.str;
+      apply = p:
+        if hasPrefix "/" p then p else "${config.home.homeDirectory}/${p}";
+      description = ''
+        The base directory in which to save contacts. May be a
+        relative path, in which case it is relative the home
+        directory.
+      '';
+    };
+
+    accounts = mkOption {
+      type = types.attrsOf (types.submodule [
+        contactOpts
+        (import ../programs/vdirsyncer-accounts.nix)
+        (import ../programs/khal-accounts.nix)
+      ]);
+      default = { };
+      description = "List of contacts.";
+    };
+  };
+}
diff --git a/modules/misc/news.nix b/modules/misc/news.nix
index 1685b4ed2..3b0378c9c 100644
--- a/modules/misc/news.nix
+++ b/modules/misc/news.nix
@@ -1070,6 +1070,30 @@ in
           A new module is available: 'programs.boxxy'.
         '';
       }
+
+      {
+        time = "2020-04-26T13:32:17+00:00";
+        message = ''
+          A number of new modules are available:
+
+            - 'accounts.calendar',
+            - 'accounts.contact',
+            - 'programs.khal',
+            - 'programs.vdirsyncer', and
+            - 'services.vdirsyncer' (Linux only).
+
+          The two first modules offer a number of options for
+          configuring calendar and contact accounts. This includes,
+          for example, information about carddav and caldav servers.
+
+          The khal and vdirsyncer modules make use of this new account
+          infrastructure.
+
+          Note, these module are still somewhat experimental and their
+          structure should not be seen as final, some modifications
+          may be necessary as new modules are added.
+        '';
+      }
     ];
   };
 }
diff --git a/modules/modules.nix b/modules/modules.nix
index e2d3fb0cf..73008004a 100644
--- a/modules/modules.nix
+++ b/modules/modules.nix
@@ -110,6 +110,7 @@ let
     ./programs/k9s.nix
     ./programs/kakoune.nix
     ./programs/keychain.nix
+    ./programs/khal.nix
     ./programs/kitty.nix
     ./programs/kodi.nix
     ./programs/lazygit.nix
@@ -197,6 +198,7 @@ let
     ./programs/topgrade.nix
     ./programs/translate-shell.nix
     ./programs/urxvt.nix
+    ./programs/vdirsyncer.nix
     ./programs/vim.nix
     ./programs/vim-vint.nix
     ./programs/vscode.nix
diff --git a/modules/programs/khal-accounts.nix b/modules/programs/khal-accounts.nix
new file mode 100644
index 000000000..ad94adc99
--- /dev/null
+++ b/modules/programs/khal-accounts.nix
@@ -0,0 +1,17 @@
+{ config, lib, ... }:
+
+with lib;
+
+{
+  options.khal = {
+    enable = lib.mkEnableOption "khal access";
+
+    readOnly = mkOption {
+      type = types.bool;
+      default = false;
+      description = ''
+        Keep khal from making any changes to this account.
+      '';
+    };
+  };
+}
diff --git a/modules/programs/khal-calendar-accounts.nix b/modules/programs/khal-calendar-accounts.nix
new file mode 100644
index 000000000..40856ccab
--- /dev/null
+++ b/modules/programs/khal-calendar-accounts.nix
@@ -0,0 +1,58 @@
+{ config, lib, ... }:
+
+with lib;
+
+{
+  options.khal = {
+    type = mkOption {
+      type = types.nullOr (types.enum [ "calendar" "discover" ]);
+      default = null;
+      description = ''
+        There is no description of this option.
+      '';
+    };
+
+    glob = mkOption {
+      type = types.str;
+      default = "*";
+      description = ''
+        The glob expansion to be searched for events or birthdays when
+        type is set to discover.
+      '';
+    };
+
+    color = mkOption {
+      type = types.nullOr (types.enum [
+        "black"
+        "white"
+        "brown"
+        "yellow"
+        "dark gray"
+        "dark green"
+        "dark blue"
+        "light gray"
+        "light green"
+        "light blue"
+        "dark magenta"
+        "dark cyan"
+        "dark red"
+        "light magenta"
+        "light cyan"
+        "light red"
+      ]);
+      default = null;
+      description = ''
+        Color in which events in this calendar are displayed.
+      '';
+      example = "light green";
+    };
+
+    priority = mkOption {
+      type = types.int;
+      default = 10;
+      description = ''
+        Priority of a calendar used for coloring.
+      '';
+    };
+  };
+}
diff --git a/modules/programs/khal.nix b/modules/programs/khal.nix
new file mode 100644
index 000000000..b9ff170f6
--- /dev/null
+++ b/modules/programs/khal.nix
@@ -0,0 +1,170 @@
+# khal config loader is sensitive to leading space !
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.programs.khal;
+
+  khalCalendarAccounts =
+    filterAttrs (_: a: a.khal.enable) config.accounts.calendar.accounts;
+
+  khalContactAccounts = mapAttrs (_: v: v // { type = "birthdays"; })
+    (filterAttrs (_: a: a.khal.enable) config.accounts.contact.accounts);
+
+  khalAccounts = khalCalendarAccounts // khalContactAccounts;
+
+  primaryAccount = findSingle (a: a.primary) null null
+    (mapAttrsToList (n: v: v // { name = n; }) khalAccounts);
+
+  definedAttrs = filterAttrs (_: v: !isNull v);
+
+  toKeyValueIfDefined = attrs: generators.toKeyValue { } (definedAttrs attrs);
+
+  genCalendarStr = name: value:
+    concatStringsSep "\n" ([
+      "[[${name}]]"
+      "highlight_event_days = True"
+      "path = ${
+        value.local.path + "/"
+        + (optionalString (value.khal.type == "discover") value.khal.glob)
+        + "/*"
+      }"
+    ] ++ optional (value.khal.readOnly) "readonly = True" ++ [
+      (toKeyValueIfDefined (getAttrs [ "type" "color" "priority" ] value.khal))
+    ] ++ [ "\n" ]);
+
+  localeFormatOptions = let T = lib.types;
+  in mapAttrs (n: v:
+    v // {
+      description = v.description + ''
+
+        Format strings are for python 'strftime', similarly to man 3 strftime.
+      '';
+    }) {
+      dateformat = {
+        type = T.str;
+        default = "%x";
+        description = ''
+          khal will display and understand all dates in this format.
+        '';
+      };
+
+      timeformat = {
+        type = T.str;
+        default = "%X";
+        description = ''
+          khal will display and understand all times in this format.
+        '';
+      };
+
+      datetimeformat = {
+        type = T.str;
+        default = "%c";
+        description = ''
+          khal will display and understand all datetimes in this format.
+        '';
+      };
+
+      longdateformat = {
+        type = T.str;
+        default = "%x";
+        description = ''
+          khal will display and understand all dates in this format.
+          It should contain a year (e.g. %Y).
+        '';
+      };
+
+      longdatetimeformat = {
+        type = T.str;
+        default = "%c";
+        description = ''
+          khal will display and understand all datetimes in this format.
+          It should contain a year (e.g. %Y).
+        '';
+      };
+    };
+
+  localeOptions = let T = lib.types;
+  in localeFormatOptions // {
+    unicode_symbols = {
+      type = T.bool;
+      default = true;
+      description = ''
+        By default khal uses some unicode symbols (as in ‘non-ascii’) as
+        indicators for things like repeating events.
+        If your font, encoding etc. does not support those symbols, set this
+        to false (this will enable ascii based replacements).
+      '';
+    };
+
+    default_timezone = {
+      type = T.nullOr T.str;
+      default = null;
+      description = ''
+        Default for new events or if khal does not understand the timezone
+        in an ical file.
+        If 'null', the timezone of your computer will be used.
+      '';
+    };
+
+    local_timezone = {
+      type = T.nullOr T.str;
+      default = null;
+      description = ''
+        khal will show all times in this timezone.
+        If 'null', the timezone of your computer will be used.
+      '';
+    };
+
+    firstweekday = {
+      type = T.ints.between 0 6;
+      default = 0;
+      description = ''
+        the first day of the week, where Monday is 0 and Sunday is 6
+      '';
+    };
+
+    weeknumbers = {
+      type = T.enum [ "off" "left" "right" ];
+      default = "off";
+      description = ''
+        Enable weeknumbers in calendar and interactive (ikhal) mode.
+        As those are iso weeknumbers, they only work properly if firstweekday
+        is set to 0.
+      '';
+    };
+  };
+
+in {
+  options.programs.khal = {
+    enable = mkEnableOption "khal, a CLI calendar application";
+    locale = mkOption {
+      type = lib.types.submodule {
+        options = mapAttrs (n: v: mkOption v) localeOptions;
+      };
+      description = ''
+        khal locale settings. 
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    home.packages = [ pkgs.khal ];
+
+    xdg.configFile."khal/config".text = concatStringsSep "\n" ([ "[calendars]" ]
+      ++ mapAttrsToList genCalendarStr khalAccounts ++ [
+        (generators.toINI { } {
+          # locale = definedAttrs (cfg.locale // { _module = null; });
+
+          default = optionalAttrs (!isNull primaryAccount) {
+            default_calendar = if isNull primaryAccount.primaryCollection then
+              primaryAccount.name
+            else
+              primaryAccount.primaryCollection;
+          };
+        })
+      ]);
+  };
+}
diff --git a/modules/programs/vdirsyncer-accounts.nix b/modules/programs/vdirsyncer-accounts.nix
new file mode 100644
index 000000000..564a09488
--- /dev/null
+++ b/modules/programs/vdirsyncer-accounts.nix
@@ -0,0 +1,187 @@
+{ lib, ... }:
+
+with lib;
+
+let
+
+  collection = types.either types.str (types.listOf types.str);
+
+in {
+  options.vdirsyncer = {
+    enable = mkEnableOption "synchronization using vdirsyncer";
+
+    collections = mkOption {
+      type = types.nullOr (types.listOf collection);
+      default = null;
+      description = ''
+        The collections to synchronize between the storages.
+      '';
+    };
+
+    conflictResolution = mkOption {
+      type = types.nullOr
+        (types.either (types.enum [ "remote wins" "local wins" ])
+          (types.listOf types.str));
+      default = null;
+      description = ''
+        What to do in case of a conflict between the storages. Either
+        <literal>remote wins</literal> or
+        <literal>local wins</literal> or
+        a list that contains a command to run. By default, an error
+        message is printed.
+      '';
+    };
+
+    partialSync = mkOption {
+      type = types.nullOr (types.enum [ "revert" "error" "ignore" ]);
+      default = null;
+      description = ''
+        What should happen if synchronization in one direction
+        is impossible due to one storage being read-only.
+        Defaults to <literal>revert</literal>.
+        </para><para>
+        See
+        <link xlink:href="https://vdirsyncer.pimutils.org/en/stable/config.html#pair-section"/>
+        for more information.
+      '';
+    };
+
+    metadata = mkOption {
+      type = types.listOf types.str;
+      default = [ ];
+      example = [ "color" "displayname" ];
+      description = ''
+        Metadata keys that should be synchronized when vdirsyncer
+        metasync is executed.
+      '';
+    };
+
+    timeRange = mkOption {
+      type = types.nullOr (types.submodule {
+        options = {
+          start = mkOption {
+            type = types.str;
+            description = "Start of time range to show.";
+          };
+
+          end = mkOption {
+            type = types.str;
+            description = "End of time range to show.";
+          };
+        };
+      });
+      default = null;
+      description = ''
+        A time range to synchronize. start and end can be any Python
+        expression that returns a <literal>datetime.datetime</literal>
+        object.
+      '';
+      example = {
+        start = "datetime.now() - timedelta(days=365)";
+        end = "datetime.now() + timedelta(days=365)";
+      };
+    };
+
+    itemTypes = mkOption {
+      type = types.nullOr (types.listOf types.str);
+      default = null;
+      description = ''
+        Kinds of items to show. The default is to show everything.
+        This depends on particular features of the server, the results
+        are not validated.
+      '';
+    };
+
+    verify = mkOption {
+      type = types.nullOr types.bool;
+      default = null;
+      description = "Verify SSL certificate.";
+    };
+
+    verifyFingerprint = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description = ''
+        Optional. SHA1 or MD5 fingerprint of the expected server certificate.
+        </para><para>
+        See
+        <link xlink:href="https://vdirsyncer.pimutils.org/en/stable/ssl-tutorial.html#ssl-tutorial"/>
+        for more information.
+      '';
+    };
+
+    auth = mkOption {
+      type = types.nullOr (types.enum [ "basic" "digest" "guess" ]);
+      default = null;
+      description = ''
+        Authentication settings. The default is <literal>basic</literal>.
+      '';
+    };
+
+    authCert = mkOption {
+      type = types.nullOr (types.either types.str (types.listOf types.str));
+      default = null;
+      description = ''
+        Either a path to a certificate with a client certificate and
+        the key or a list of paths to the files with them.
+      '';
+    };
+
+    userAgent = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description = ''
+        The user agent to report to the server. Defaults to
+        <literal>vdirsyncer</literal>.
+      '';
+    };
+
+    postHook = mkOption {
+      type = types.lines;
+      default = "";
+      description = ''
+        Command to call for each item creation and modification.
+        The command will be called with the path of the new/updated
+        file.
+      '';
+    };
+
+    ## Options for google storages
+
+    tokenFile = mkOption {
+      type = types.nullOr types.str;
+      default = null;
+      description = ''
+        A file path where access tokens are stored.
+      '';
+    };
+
+    clientIdCommand = mkOption {
+      type = types.nullOr (types.listOf types.str);
+      default = null;
+      example = [ "pass" "client_id" ];
+      description = ''
+        A command that prints the OAuth credentials to standard
+        output.
+        </para><para>
+        See
+        <link xlink:href="https://vdirsyncer.pimutils.org/en/stable/config.html#google"/>
+        for more information.
+      '';
+    };
+
+    clientSecretCommand = mkOption {
+      type = types.nullOr (types.listOf types.str);
+      default = null;
+      example = [ "pass" "client_secret" ];
+      description = ''
+        A command that prints the OAuth credentials to standard
+        output.
+        </para><para>
+        See
+        <link xlink:href="https://vdirsyncer.pimutils.org/en/stable/config.html#google"/>
+        for more information.
+      '';
+    };
+  };
+}
diff --git a/modules/programs/vdirsyncer.nix b/modules/programs/vdirsyncer.nix
new file mode 100644
index 000000000..258692df1
--- /dev/null
+++ b/modules/programs/vdirsyncer.nix
@@ -0,0 +1,276 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.programs.vdirsyncer;
+
+  vdirsyncerCalendarAccounts = filterAttrs (_: v: v.vdirsyncer.enable)
+    (mapAttrs' (n: v: nameValuePair ("calendar_" + n) v)
+      config.accounts.calendar.accounts);
+
+  vdirsyncerContactAccounts = filterAttrs (_: v: v.vdirsyncer.enable)
+    (mapAttrs' (n: v: nameValuePair ("contacts_" + n) v)
+      config.accounts.contact.accounts);
+
+  vdirsyncerAccounts = vdirsyncerCalendarAccounts // vdirsyncerContactAccounts;
+
+  wrap = s: ''"${s}"'';
+
+  listString = l: "[${concatStringsSep ", " l}]";
+
+  boolString = b: if b then "true" else "false";
+
+  localStorage = a:
+    filterAttrs (_: v: v != null)
+    ((getAttrs [ "type" "fileExt" "encoding" ] a.local) // {
+      path = a.local.path;
+      postHook = pkgs.writeShellScriptBin "post-hook" a.vdirsyncer.postHook
+        + "/bin/post-hook";
+    });
+
+  remoteStorage = a:
+    filterAttrs (_: v: v != null) ((getAttrs [
+      "type"
+      "url"
+      "userName"
+      #"userNameCommand"
+      "passwordCommand"
+    ] a.remote) // (if a.vdirsyncer == null then
+      { }
+    else
+      getAttrs [
+        "itemTypes"
+        "verify"
+        "verifyFingerprint"
+        "auth"
+        "authCert"
+        "userAgent"
+        "tokenFile"
+        "clientIdCommand"
+        "clientSecretCommand"
+        "timeRange"
+      ] a.vdirsyncer));
+
+  pair = a:
+    with a.vdirsyncer;
+    filterAttrs (k: v: k == "collections" || (v != null && v != [ ]))
+    (getAttrs [ "collections" "conflictResolution" "metadata" "partialSync" ]
+      a.vdirsyncer);
+
+  pairs = mapAttrs (_: v: pair v) vdirsyncerAccounts;
+  localStorages = mapAttrs (_: v: localStorage v) vdirsyncerAccounts;
+  remoteStorages = mapAttrs (_: v: remoteStorage v) vdirsyncerAccounts;
+
+  optionString = n: v:
+    if (n == "type") then
+      ''type = "${v}"''
+    else if (n == "path") then
+      ''path = "${v}"''
+    else if (n == "fileExt") then
+      ''fileext = "${v}"''
+    else if (n == "encoding") then
+      ''encoding = "${v}"''
+    else if (n == "postHook") then
+      ''post_hook = "${v}"''
+    else if (n == "url") then
+      ''url = "${v}"''
+    else if (n == "timeRange") then ''
+      start_date = "${v.start}"
+      end_date = "${v.end}"'' else if (n == "itemTypes") then
+      "item_types = ${listString (map wrap v)}"
+    else if (n == "userName") then
+      ''username = "${v}"''
+    else if (n == "userNameCommand") then
+      "username.fetch = ${listString (map wrap ([ "command" ] ++ v))}"
+    else if (n == "password") then
+      ''password = "${v}"''
+    else if (n == "passwordCommand") then
+      "password.fetch = ${listString (map wrap ([ "command" ] ++ v))}"
+    else if (n == "passwordPrompt") then
+      ''password.fetch = ["prompt", "${v}"]''
+    else if (n == "verify") then
+      "verify = ${if v then "true" else "false"}"
+    else if (n == "verifyFingerprint") then
+      ''verify_fingerprint = "${v}"''
+    else if (n == "auth") then
+      ''auth = "${v}"''
+    else if (n == "authCert" && isString (v)) then
+      ''auth_cert = "${v}"''
+    else if (n == "authCert") then
+      "auth_cert = ${listString (map wrap v)}"
+    else if (n == "userAgent") then
+      ''useragent = "${v}"''
+    else if (n == "tokenFile") then
+      ''token_file = "${v}"''
+    else if (n == "clientId") then
+      ''client_id = "${v}"''
+    else if (n == "clientIdCommand") then
+      "client_id.fetch = ${listString (map wrap ([ "command" ] ++ v))}"
+    else if (n == "clientSecret") then
+      ''client_secret = "${v}"''
+    else if (n == "clientSecretCommand") then
+      "client_secret.fetch = ${listString (map wrap ([ "command" ] ++ v))}"
+    else if (n == "metadata") then
+      "metadata = ${listString (map wrap v)}"
+    else if (n == "partialSync") then
+      ''partial_sync = "${v}"''
+    else if (n == "collections") then
+      let
+        contents =
+          map (c: if (isString c) then ''"${c}"'' else listString (map wrap c))
+          v;
+      in "collections = ${
+        if ((isNull v) || v == [ ]) then "null" else listString contents
+      }"
+    else if (n == "conflictResolution") then
+      if v == "remote wins" then
+        ''conflict_resolution = "a wins"''
+      else if v == "local wins" then
+        ''conflict_resolution = "b wins"''
+      else
+        "conflict_resolution = ${listString (map wrap ([ "command" ] ++ v))}"
+    else
+      throw "Unrecognized option: ${n}";
+
+  attrsString = a: concatStringsSep "\n" (mapAttrsToList optionString a);
+
+  pairString = n: v: ''
+    [pair ${n}]
+    a = "${n}_remote"
+    b = "${n}_local"
+    ${attrsString v}
+  '';
+
+  configFile = pkgs.writeText "config" ''
+    [general]
+    status_path = "${cfg.statusPath}"
+
+    ### Pairs
+
+    ${concatStringsSep "\n" (mapAttrsToList pairString pairs)}
+
+    ### Local storages
+
+    ${concatStringsSep "\n\n"
+    (mapAttrsToList (n: v: "[storage ${n}_local]" + "\n" + attrsString v)
+      localStorages)}
+
+    ### Remote storages
+
+    ${concatStringsSep "\n\n"
+    (mapAttrsToList (n: v: "[storage ${n}_remote]" + "\n" + attrsString v)
+      remoteStorages)}
+  '';
+
+in {
+  options = {
+    programs.vdirsyncer = {
+      enable = mkEnableOption "vdirsyncer";
+
+      package = mkOption {
+        type = types.package;
+        default = pkgs.vdirsyncer;
+        defaultText = "pkgs.vdirsyncer";
+        description = ''
+          vdirsyncer package to use.
+        '';
+      };
+
+      statusPath = mkOption {
+        type = types.str;
+        default = "${config.xdg.dataHome}/vdirsyncer/status";
+        defaultText = "$XDG_DATA_HOME/vdirsyncer/status";
+        description = ''
+          A directory where vdirsyncer will store some additional data for the next sync.
+          </para>
+
+          <para>For more information, see
+          <link xlink:href="https://vdirsyncer.pimutils.org/en/stable/config.html#general-section"/>
+        '';
+      };
+    };
+  };
+
+  config = mkIf cfg.enable {
+    assertions = let
+
+      requiredOptions = t:
+        if (t == "caldav" || t == "carddav" || t == "http") then
+          [ "url" ]
+        else if (t == "filesystem") then [
+          "path"
+          "fileExt"
+        ] else if (t == "singlefile") then
+          [ "path" ]
+        else if (t == "google_calendar" || t == "google_contacts") then [
+          "tokenFile"
+          "clientId"
+          "clientSecret"
+        ] else
+          throw "Unrecognized storage type: ${t}";
+
+      allowedOptions = let
+        remoteOptions = [
+          "userName"
+          "userNameCommand"
+          "password"
+          "passwordCommand"
+          "passwordPrompt"
+          "verify"
+          "verifyFingerprint"
+          "auth"
+          "authCert"
+          "userAgent"
+        ];
+      in t:
+      if (t == "caldav") then
+        [ "timeRange" "itemTypes" ] ++ remoteOptions
+      else if (t == "carddav" || t == "http") then
+        remoteOptions
+      else if (t == "filesystem") then [
+        "fileExt"
+        "encoding"
+        "postHook"
+      ] else if (t == "singlefile") then
+        [ "encoding" ]
+      else if (t == "google_calendar") then [
+        "timeRange"
+        "itemTypes"
+        "clientIdCommand"
+        "clientSecretCommand"
+      ] else if (t == "google_contacts") then [
+        "clientIdCommand"
+        "clientSecretCommand"
+      ] else
+        throw "Unrecognized storage type: ${t}";
+
+      assertStorage = n: v:
+        let allowed = allowedOptions v.type ++ (requiredOptions v.type);
+        in mapAttrsToList (a: v':
+          [{
+            assertion = (elem a allowed);
+            message = ''
+              Storage ${n} is of type ${v.type}. Option
+              ${a} is not allowed for this type.
+            '';
+          }] ++ (let
+            required =
+              filter (a: !hasAttr "${a}Command" v) (requiredOptions v.type);
+          in map (a: [{
+            assertion = hasAttr a v;
+            message = ''
+              Storage ${n} is of type ${v.type}, but required
+              option ${a} is not set.
+            '';
+          }]) required)) (removeAttrs v [ "type" "_module" ]);
+
+      storageAssertions = flatten (mapAttrsToList assertStorage localStorages)
+        ++ flatten (mapAttrsToList assertStorage remoteStorages);
+
+    in storageAssertions;
+    home.packages = [ cfg.package ];
+    xdg.configFile."vdirsyncer/config".source = configFile;
+  };
+}
diff --git a/modules/services/vdirsyncer.nix b/modules/services/vdirsyncer.nix
new file mode 100644
index 000000000..2f622d0a3
--- /dev/null
+++ b/modules/services/vdirsyncer.nix
@@ -0,0 +1,87 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+  cfg = config.services.vdirsyncer;
+
+  vdirsyncerOptions = [ ]
+    ++ optional (cfg.verbosity != null) "--verbosity ${cfg.verbosity}"
+    ++ optional (cfg.configFile != null) "--config ${cfg.configFile}";
+
+in {
+  meta.maintainers = [ maintainers.pjones ];
+
+  options.services.vdirsyncer = {
+    enable = mkEnableOption "vdirsyncer";
+
+    package = mkOption {
+      type = types.package;
+      default = pkgs.vdirsyncer;
+      defaultText = "pkgs.vdirsyncer";
+      example = literalExpression "pkgs.vdirsyncer";
+      description = "The package to use for the vdirsyncer binary.";
+    };
+
+    frequency = mkOption {
+      type = types.str;
+      default = "*:0/5";
+      description = ''
+        How often to run vdirsyncer.  This value is passed to the systemd
+        timer configuration as the onCalendar option.  See
+        <citerefentry>
+          <refentrytitle>systemd.time</refentrytitle>
+          <manvolnum>7</manvolnum>
+        </citerefentry>
+        for more information about the format.
+      '';
+    };
+
+    verbosity = mkOption {
+      type = types.nullOr
+        (types.enum [ "CRITICAL" "ERROR" "WARNING" "INFO" "DEBUG" ]);
+      default = null;
+      description = ''
+        Whether vdirsyncer should produce verbose output.
+      '';
+    };
+
+    configFile = mkOption {
+      type = types.nullOr types.path;
+      default = null;
+      description = ''
+        Optional configuration file to link to use instead of
+        the default file (<filename>$XDG_CONFIG_HOME/vdirsyncer/config</filename>).
+      '';
+    };
+  };
+
+  config = mkIf cfg.enable {
+    systemd.user.services.vdirsyncer = {
+      Unit = {
+        Description = "vdirsyncer calendar&contacts synchronization";
+        PartOf = [ "network-online.target" ];
+      };
+
+      Service = {
+        Type = "oneshot";
+        # TODO `vdirsyncer discover`
+        ExecStart = "${cfg.package}/bin/vdirsyncer sync ${
+            concatStringsSep " " vdirsyncerOptions
+          }";
+      };
+    };
+
+    systemd.user.timers.vdirsyncer = {
+      Unit = { Description = "vdirsyncer calendar&contacts synchronization"; };
+
+      Timer = {
+        OnCalendar = cfg.frequency;
+        Unit = "vdirsyncer.service";
+      };
+
+      Install = { WantedBy = [ "timers.target" ]; };
+    };
+  };
+}