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