Add infrastructure for contacts and calendars

This also adds the modules

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

that integrate with the new infrastructure.

Co-authored-by: Andrew Scott <3648487+ayyjayess@users.noreply.github.com>
Co-authored-by: Sebastian Zivota <sebastian.zivota@mailbox.org>
This commit is contained in:
Sebastian Zivota 2019-04-07 13:21:23 +02:00 committed by Robert Helgesson
parent 23220d43f3
commit e8abc2ac53
No known key found for this signature in database
GPG key ID: 36BDAA14C2797E89
10 changed files with 977 additions and 0 deletions

View file

@ -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;
}];
};
}

View file

@ -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 != {}) {
};
}

View file

@ -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.
'';
}
];
};
}

View file

@ -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 { })

View file

@ -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.
'';
};
};
}

View file

@ -0,0 +1,13 @@
{ config, lib, ... }:
with lib;
{
options.khal = {
type = mkOption {
type = types.nullOr (types.enum [ "calendar" "discover"]);
description = ''
'';
};
};
}

64
modules/programs/khal.nix Normal file
View file

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

View file

@ -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 <literal>"remote wins"</literal>
or <literal>"local wins"</literal>
or a list that contains a command to run.
By default, an error message is printed.
'';
};
partialSync = mkOption {
type = types.nullOr (types.enum [ "revert" "error" "ignore" ]);
default = null;
description = ''
What should happen if synchronization in one direction
is impossible due to one storage being read-only.
Defaults to <literal>"revert"</literal>.</para>
<para>See
<link xlink:href="https://vdirsyncer.pimutils.org/en/stable/config.html#pair-section"/>
for more information.
'';
};
metadata = mkOption {
type = types.listOf types.str;
default = [];
example = [ "color" "displayname" ];
description = ''
Metadata keys that should be synchronized
when vdirsyncer metasync is executed.
'';
};
timeRange = mkOption {
type = types.nullOr (types.submodule {
options = {
start = mkOption {
type = types.str;
description = "Start of time range to show.";
};
end = mkOption {
type = types.str;
description = "End of time range to show.";
};
};
});
default = null;
description = ''
A time range to synchronize. start and end
can be any Python expression that returns
a <literal>datetime.datetime</literal> object.
'';
example = {
start = "datetime.now() - timedelta(days=365)";
end = "datetime.now() + timedelta(days=365)";
};
};
itemTypes = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
description = ''
Kinds of items to show. The default is to
show everything. This depends on particular
features of the server, the results are not
validated.
'';
};
verify = mkOption {
type = types.nullOr types.bool;
default = null;
description = "Verify SSL certificate.";
};
verifyFingerprint = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
Optional. SHA1 or MD5 fingerprint of the expected server certificate.</para>
<para>See
<link xlink:href="https://vdirsyncer.pimutils.org/en/stable/ssl-tutorial.html#ssl-tutorial"/>
for more information.
'';
};
auth = mkOption {
type = types.nullOr (types.enum ["basic" "digest" "guess"]);
default = null;
description = ''
Authentication settings. The default is <literal>"basic"</literal>.
'';
};
authCert = mkOption {
type = types.nullOr (types.either types.str (types.listOf types.str));
default = null;
description = ''
Either a path to a certificate with a client certificate
and the key or a list of paths to the files with them.
'';
};
userAgent = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
The user agent to report to the server.
Defaults to <literal>"vdirsyncer"</literal>.
'';
};
postHook = mkOption {
type = types.lines;
default = "";
description = ''
Command to call for each item creation and modification.
The command will be called with the path of the new/updated
file.
'';
};
## Options for google storages
tokenFile = mkOption {
type = types.nullOr types.str;
default = null;
description = ''
A file path where access tokens
are stored.
'';
};
clientIdCommand = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
example = [ "pass" "client_id" ];
description = ''
A command that prints the OAuth credentials to standard
output.
OAuth credentials, obtained from the Google API Manager.</para>
<para> See
<link xlink:href="https://vdirsyncer.pimutils.org/en/stable/config.html#google"/>
for more information.
'';
};
clientSecretCommand = mkOption {
type = types.nullOr (types.listOf types.str);
default = null;
example = [ "pass" "client_secret" ];
description = ''
A command that prints the OAuth credentials to standard
output.
OAuth credentials, obtained from the Google API Manager.</para>
<para> See
<link xlink:href="https://vdirsyncer.pimutils.org/en/stable/config.html#google"/>
for more information.
'';
};
};
}

View file

@ -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.
</para>
<para>For more information, see
<link xlink:href="https://vdirsyncer.pimutils.org/en/stable/config.html#general-section"/>
'';
};
};
};
config = mkIf cfg.enable {
assertions = let
requiredOptions = t:
if (t == "caldav" || t == "carddav" || t == "http") then [ "url" ]
else if (t == "filesystem") then [ "path" "fileExt" ]
else if (t == "singlefile") then [ "path" ]
else if (t == "google_calendar" || t == "google_contacts") then
[ "tokenFile" "clientId" "clientSecret"]
else throw "Unrecognized storage type: ${t}";
allowedOptions = let
remoteOptions = [
"userName"
"userNameCommand"
"password"
"passwordCommand"
"passwordPrompt"
"verify"
"verifyFingerprint"
"auth"
"authCert"
"userAgent"
];
in t:
if (t == "caldav")
then [ "timeRange" "itemTypes" ] ++ remoteOptions
else if (t == "carddav" || t == "http")
then remoteOptions
else if (t == "filesystem")
then [ "fileExt" "encoding" "postHook" ]
else if (t == "singlefile")
then [ "encoding" ]
else if (t == "google_calendar") then
[ "timeRange" "itemTypes" "clientIdCommand" "clientSecretCommand" ]
else if (t == "google_contacts") then [ "clientIdCommand" "clientSecretCommand" ]
else throw "Unrecognized storage type: ${t}";
assertStorage = n: v:
let
allowed = allowedOptions v.type ++ (requiredOptions v.type);
in
mapAttrsToList (
a: v': [
{
assertion = (elem a allowed);
message = ''
Storage ${n} is of type ${v.type}. Option
${a} is not allowed for this type.
'';
}
] ++
(let required = filter (a: !hasAttr "${a}Command" v) (requiredOptions v.type);
in map (a: [{
assertion = hasAttr a v;
message = ''
Storage ${n} is of type ${v.type}, but required
option ${a} is not set.
'';
}]) required)
) (removeAttrs v ["type" "_module"]);
storageAssertions = flatten (mapAttrsToList assertStorage localStorages)
++ flatten (mapAttrsToList assertStorage remoteStorages);
in storageAssertions;
home.packages = [ cfg.package ];
xdg.configFile."vdirsyncer/config".source = configFile;
};
}

View file

@ -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
<citerefentry>
<refentrytitle>systemd.time</refentrytitle>
<manvolnum>7</manvolnum>
</citerefentry>
for more information about the format.
'';
};
verbosity = mkOption {
type = types.nullOr (types.enum [ "CRITICAL" "ERROR" "WARNING" "INFO" "DEBUG"]);
default = null;
description = ''
Whether vdirsyncer should produce verbose output.
'';
};
configFile = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Optional configuration file to link to use instead of
the default file (<filename>$XDG_CONFIG_HOME/vdirsyncer/config</filename>).
'';
};
};
config = mkIf cfg.enable {
systemd.user.services.vdirsyncer = {
Unit = {
Description = "vdirsyncer calendar&contacts synchronization";
PartOf = [ "network-online.target" ];
};
Service = {
Type = "oneshot";
# TODO `vdirsyncer discover`
ExecStart = "${cfg.package}/bin/vdirsyncer sync ${concatStringsSep " " vdirsyncerOptions}";
};
};
systemd.user.timers.vdirsyncer = {
Unit = {
Description = "vdirsyncer calendar&contacts synchronization";
};
Timer = {
OnCalendar = cfg.frequency;
Unit = "vdirsyncer.service";
};
Install = {
WantedBy = [ "timers.target" ];
};
};
};
}