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
+ 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.
+
+ 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.
+
+ See
+
+ 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.
+
+
+ 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..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
+
+ 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" ]; };
+ };
+ };
+}