From b01eb1eb3b579c74e6a4189ef33cc3fa24c40613 Mon Sep 17 00:00:00 2001 From: Matthieu Coudron 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 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 Co-authored-by: Johannes Rosenberger Co-authored-by: Johannes Rosenberger Co-authored-by: Robert Helgesson --- 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 + 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" ]; }; + }; + }; +}