diff --git a/modules/accounts/calendar.nix b/modules/accounts/calendar.nix
new file mode 100644
index 000000000..1a9876375
--- /dev/null
+++ b/modules/accounts/calendar.nix
@@ -0,0 +1,177 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+ cfg = config.accounts.calendar;
+
+ localModule = name: types.submodule {
+ options = {
+ path = mkOption {
+ type = types.str;
+ default = "${cfg.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 = mkMerge [
+ {
+ name = name;
+ khal.type = mkOptionDefault null;
+ }
+ ];
+ };
+
+in
+
+{
+ options.accounts.calendar = {
+ basePath = mkOption {
+ type = types.str;
+ default = "${config.home.homeDirectory}/.calendars/";
+ defaultText = "$HOME/.calendars";
+ description = ''
+ The base directory in which to save calendars.
+ '';
+ };
+
+ 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 accounts 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..f1c22398c
--- /dev/null
+++ b/modules/accounts/contacts.nix
@@ -0,0 +1,140 @@
+{ config, lib, pkgs, ... }:
+
+with lib;
+
+let
+
+ cfg = config.accounts.contact;
+
+ localModule = name: types.submodule {
+ options = {
+ path = mkOption {
+ type = types.str;
+ default = "${cfg.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 = mkMerge [
+ {
+ name = name;
+ }
+ ];
+ };
+
+in
+
+{
+ options.accounts.contact = {
+ basePath = mkOption {
+ type = types.str;
+ default = "${config.home.homeDirectory}/.contacts/";
+ defaultText = "$HOME/.contacts";
+ description = ''
+ The base directory in which to save contacts.
+ '';
+ };
+
+ accounts = mkOption {
+ type = types.attrsOf (types.submodule [
+ contactOpts
+ (import ../programs/vdirsyncer-accounts.nix)
+ (import ../programs/khal-accounts.nix)
+ ]);
+ default = {};
+ description = "List of contacts.";
+ };
+ };
+ config = mkIf (cfg.accounts != {}) {
+ };
+}
diff --git a/modules/misc/news.nix b/modules/misc/news.nix
index 041c8d478..837c9f48c 100644
--- a/modules/misc/news.nix
+++ b/modules/misc/news.nix
@@ -1481,6 +1481,30 @@ in
A new module is available: 'programs.lf'
'';
}
+
+ {
+ 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 21db8b373..9c53e86d1 100644
--- a/modules/modules.nix
+++ b/modules/modules.nix
@@ -22,6 +22,8 @@ let
allModules = [
(loadModule ./accounts/email.nix { })
+ (loadModule ./accounts/calendar.nix { })
+ (loadModule ./accounts/contacts.nix { })
(loadModule ./files.nix { })
(loadModule ./home-environment.nix { })
(loadModule ./manual.nix { })
@@ -74,6 +76,7 @@ let
(loadModule ./programs/jq.nix { })
(loadModule ./programs/kakoune.nix { })
(loadModule ./programs/keychain.nix { })
+ (loadModule ./programs/khal.nix { })
(loadModule ./programs/kitty.nix { })
(loadModule ./programs/lesspipe.nix { })
(loadModule ./programs/lf.nix { })
@@ -107,6 +110,7 @@ let
(loadModule ./programs/texlive.nix { })
(loadModule ./programs/tmux.nix { })
(loadModule ./programs/urxvt.nix { })
+ (loadModule ./programs/vdirsyncer.nix { })
(loadModule ./programs/vim.nix { })
(loadModule ./programs/vscode.nix { })
(loadModule ./programs/vscode/haskell.nix { })
@@ -161,6 +165,7 @@ let
(loadModule ./services/udiskie.nix { })
(loadModule ./services/unclutter.nix { })
(loadModule ./services/unison.nix { condition = hostPlatform.isLinux; })
+ (loadModule ./services/vdirsyncer.nix { condition = hostPlatform.isLinux; })
(loadModule ./services/window-managers/awesome.nix { })
(loadModule ./services/window-managers/bspwm/default.nix { condition = hostPlatform.isLinux; })
(loadModule ./services/window-managers/i3-sway/i3.nix { })
diff --git a/modules/programs/khal-accounts.nix b/modules/programs/khal-accounts.nix
new file mode 100644
index 000000000..cb615b29c
--- /dev/null
+++ b/modules/programs/khal-accounts.nix
@@ -0,0 +1,26 @@
+{ config, lib, ... }:
+
+with lib;
+
+{
+ options.khal = {
+ enable = lib.mkEnableOption "khal access";
+
+ readOnly = mkOption {
+ type = types.bool;
+ description = ''
+ Keep khal from making any changes to this account.
+ '';
+ default = false;
+ };
+
+ glob = mkOption {
+ type = types.str;
+ default = "*";
+ description = ''
+ The glob expansion to be searched for events or birthdays when type
+ is set to discover.
+ '';
+ };
+ };
+}
diff --git a/modules/programs/khal-calendar-accounts.nix b/modules/programs/khal-calendar-accounts.nix
new file mode 100644
index 000000000..617a39b62
--- /dev/null
+++ b/modules/programs/khal-calendar-accounts.nix
@@ -0,0 +1,13 @@
+{ config, lib, ... }:
+
+with lib;
+
+{
+ options.khal = {
+ type = mkOption {
+ type = types.nullOr (types.enum [ "calendar" "discover"]);
+ description = ''
+ '';
+ };
+ };
+}
diff --git a/modules/programs/khal.nix b/modules/programs/khal.nix
new file mode 100644
index 000000000..97f9ad404
--- /dev/null
+++ b/modules/programs/khal.nix
@@ -0,0 +1,64 @@
+# 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);
+in
+
+{
+ options.programs.khal = {
+ enable = mkEnableOption "khal, a CLI calendar application";
+ };
+
+ config = mkIf cfg.enable {
+ home.packages = [ pkgs.khal ];
+
+ xdg.configFile."khal/config".text = concatStringsSep "\n" (
+ [
+ "[calendars]"
+ ]
+ ++ (mapAttrsToList (name: value: concatStringsSep "\n"
+ ([
+ ''[[${name}]]''
+ ''path = ${value.local.path + "/" + (optionalString (value.khal.type == "discover") value.khal.glob)}''
+ ]
+ ++ optional (value.khal.readOnly) "readonly = True"
+ ++ optional (!isNull value.khal.type) "type = ${value.khal.type}"
+ ++ ["\n"]
+ )
+ ) khalAccounts)
+ ++
+ [
+ (generators.toINI {} {
+ default = optionalAttrs (!isNull primaryAccount) {
+ default_calendar = if isNull primaryAccount.primaryCollection then primaryAccount.name else primaryAccount.primaryCollection;
+ };
+
+ locale = {
+ timeformat = "%H:%M";
+ dateformat = "%Y-%m-%d";
+ longdateformat = "%Y-%m-%d";
+ datetimeformat = "%Y-%m-%d %H:%M";
+ longdatetimeformat = "%Y-%m-%d %H:%M";
+ weeknumbers = "right";
+ };
+ })
+ ]
+ );
+ };
+}
diff --git a/modules/programs/vdirsyncer-accounts.nix b/modules/programs/vdirsyncer-accounts.nix
new file mode 100644
index 000000000..96789bae7
--- /dev/null
+++ b/modules/programs/vdirsyncer-accounts.nix
@@ -0,0 +1,193 @@
+{ 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 "remote wins"
+ or "local wins"
+ 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 "revert".
+ See
+
+ 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 datetime.datetime 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.
+
+ See
+
+ for more information.
+ '';
+ };
+
+ auth = mkOption {
+ type = types.nullOr (types.enum ["basic" "digest" "guess"]);
+ default = null;
+ description = ''
+ Authentication settings. The default is "basic".
+ '';
+ };
+
+ 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 "vdirsyncer".
+ '';
+ };
+
+ 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.
+
+ OAuth credentials, obtained from the Google API Manager.
+
+ See
+
+ 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.
+
+ OAuth credentials, obtained from the Google API Manager.
+
+ See
+
+ for more information.
+ '';
+ };
+ };
+}
diff --git a/modules/programs/vdirsyncer.nix b/modules/programs/vdirsyncer.nix
new file mode 100644
index 000000000..f8df5764b
--- /dev/null
+++ b/modules/programs/vdirsyncer.nix
@@ -0,0 +1,245 @@
+{ 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"
+ "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 mkList (map wrapString 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 = ${mkList (map wrapString (["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.
+
+
+ For more information, see
+
+ '';
+ };
+ };
+ };
+
+ 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..1df4f9ac1
--- /dev/null
+++ b/modules/services/vdirsyncer.nix
@@ -0,0 +1,90 @@
+{ 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 = literalExample "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
+
+ systemd.time
+ 7
+
+ 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 ($XDG_CONFIG_HOME/vdirsyncer/config).
+ '';
+ };
+ };
+
+ 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" ];
+ };
+ };
+ };
+}