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 <sebastian.zivota@mailbox.org>

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 <sebastian.zivota@mailbox.org>
Co-authored-by: Johannes Rosenberger <johannes.rosenberger@jorsn.eu>
Co-authored-by: Johannes Rosenberger <johannes@jorsn.eu>
Co-authored-by: Robert Helgesson <robert@rycee.net>
This commit is contained in:
Matthieu Coudron 2023-06-12 23:21:24 +02:00 committed by GitHub
parent 9e37a1b6f9
commit b01eb1eb3b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 1118 additions and 0 deletions

View file

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

View file

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

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

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

View file

@ -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
<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.
</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.
</para><para>
See
<link xlink:href="https://vdirsyncer.pimutils.org/en/stable/config.html#google"/>
for more information.
'';
};
};
}

View file

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