anki: support multiple profiles

This commit is contained in:
sollniss 2026-01-07 23:28:50 +09:00 committed by Austin Horstman
parent bff85cb66b
commit 04acdd8af8
3 changed files with 241 additions and 138 deletions

View file

@ -17,12 +17,31 @@ in
{
meta.maintainers = [ lib.maintainers.junestepp ];
imports = [
(lib.mkRenamedOptionModule
[ "programs" "anki" "sync" "passwordFile" ]
[ "programs" "anki" "sync" "keyFile" ]
imports =
(map
(
x:
lib.mkRenamedOptionModule
[ "programs" "anki" "sync" x ]
[ "programs" "anki" "profiles" "User 1" "sync" x ]
)
[
"username"
"usernameFile"
"keyFile"
"url"
"autoSync"
"syncMedia"
"autoSyncMediaMinutes"
"networkTimeout"
]
)
];
++ [
(lib.mkRenamedOptionModule
[ "programs" "anki" "sync" "passwordFile" ]
[ "programs" "anki" "profiles" "User 1" "sync" "keyFile" ]
)
];
options.programs.anki = {
enable = lib.mkEnableOption "Anki";
@ -201,80 +220,6 @@ in
'';
};
sync = {
username = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
example = "lovelearning@email.com";
description = "Sync account username.";
};
usernameFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = "Path to a file containing the sync account username.";
};
keyFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = ''
Path to a file containing the sync account sync key. This is different from
the account password.
To get the sync key, follow these steps:
- Enable this Home Manager module: `programs.anki.enable = true`
- Open Anki.
- Navigate to the sync settings page. (Tools > Preferences > Syncing)
- Log in to your AnkiWeb account.
- Select "Yes" to the prompt about saving preferences and syncing.
- A Home Manager warning prompt will show. Select "Show details...".
- Get your sync key from the message: "syncKey changed from \`None\` to \`<YOUR SYNC KEY WILL BE HERE>\`"
'';
};
url = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
example = "http://example.com/anki-sync/";
description = ''
Custom sync server URL. See <https://docs.ankiweb.net/sync-server.html>.
'';
};
autoSync = lib.mkOption {
type = with lib.types; nullOr bool;
default = null;
example = true;
description = "Automatically sync on profile open/close.";
};
syncMedia = lib.mkOption {
type = with lib.types; nullOr bool;
default = null;
example = true;
description = "Synchronize audio and images too.";
};
autoSyncMediaMinutes = lib.mkOption {
type = with lib.types; nullOr ints.unsigned;
default = null;
example = 15;
description = ''
Automatically sync media every X minutes. Set this to 0 to disable
periodic media syncing.
'';
};
networkTimeout = lib.mkOption {
type = with lib.types; nullOr ints.unsigned;
default = null;
example = 60;
description = "Network timeout in seconds.";
};
};
addons = lib.mkOption {
type = with lib.types; listOf package;
default = [ ];
@ -310,15 +255,112 @@ in
'';
description = "List of Anki add-on packages to install.";
};
profiles = lib.mkOption {
description = ''
Anki profiles and their settings.
Profiles are primarily intended to be one per person, and are not recommended for splitting up your own content.
'';
default = {
"User 1" = { };
};
type = lib.types.attrsOf (
lib.types.submodule {
options = {
default = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open this profile on startup.";
};
sync = {
username = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
example = "lovelearning@email.com";
description = "Sync account username.";
};
usernameFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = "Path to a file containing the sync account username.";
};
keyFile = lib.mkOption {
type = with lib.types; nullOr path;
default = null;
description = ''
Path to a file containing the sync account sync key. This is different from
the account password.
To get the sync key, follow these steps:
- Enable this Home Manager module: `programs.anki.enable = true`
- Open Anki.
- Navigate to the sync settings page. (Tools > Preferences > Syncing)
- Log in to your AnkiWeb account.
- Select "Yes" to the prompt about saving preferences and syncing.
- A Home Manager warning prompt will show. Select "Show details...".
- Get your sync key from the message: "syncKey changed from \`None\` to \`<YOUR SYNC KEY WILL BE HERE>\`"
'';
};
url = lib.mkOption {
type = with lib.types; nullOr str;
default = null;
example = "http://example.com/anki-sync/";
description = ''
Custom sync server URL. See <https://docs.ankiweb.net/sync-server.html>.
'';
};
autoSync = lib.mkOption {
type = with lib.types; nullOr bool;
default = null;
example = true;
description = "Automatically sync on profile open/close.";
};
syncMedia = lib.mkOption {
type = with lib.types; nullOr bool;
default = null;
example = true;
description = "Synchronize audio and images too.";
};
autoSyncMediaMinutes = lib.mkOption {
type = with lib.types; nullOr ints.unsigned;
default = null;
example = 15;
description = ''
Automatically sync media every X minutes. Set this to 0 to disable
periodic media syncing.
'';
};
networkTimeout = lib.mkOption {
type = with lib.types; nullOr ints.unsigned;
default = null;
example = 60;
description = "Network timeout in seconds (clamped between 30 and 99999).";
};
};
};
}
);
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = !(cfg.sync.username != null && cfg.sync.usernameFile != null);
assertion =
let
defaultProfiles = lib.filterAttrs (_: prof: prof.default) cfg.profiles;
in
lib.length (lib.attrNames defaultProfiles) <= 1;
message = ''
The `programs.anki.sync` `username` option is mutually exclusive with
the `usernameFile` option.
Only one profile in `programs.anki.profiles` can be marked as default.
'';
}
{
@ -328,7 +370,34 @@ in
add-ons. Make sure you are using `pkgs.anki`.
'';
}
];
]
++ lib.concatLists (
lib.mapAttrsToList (
name: profile:
let
# Profile name must be a valid filename.
profileNameInvalidChars = lib.concatLists (
lib.filter (val: lib.isList val) (lib.split ''([.:?\"<>|\*\\\/])'' name)
);
in
[
{
assertion = profileNameInvalidChars == [ ];
message = ''
`programs.anki.profiles.${lib.strings.escapeNixString name}` has invalid
character(s) in its name: ${lib.concatStrings profileNameInvalidChars}
'';
}
{
assertion = !(profile.sync.username != null && profile.sync.usernameFile != null);
message = ''
The `programs.anki.profiles.${lib.strings.escapeNixString name}.sync`
`username` option is mutually exclusive with the `usernameFile` option.
'';
}
]
) cfg.profiles
);
home.packages = [
(cfg.package.withAddons (

View file

@ -117,28 +117,40 @@ let
profile_manager.set_answer_key(ease, key)
# Profile specific options
${lib.concatMapAttrsStringSep "\n" (name: pCfg: ''
profile_manager.create("${name}")
profile_manager.openProfile("${name}")
profile_manager.create("User 1")
profile_manager.openProfile("User 1")
# Without this, the collection DB won't get automatically optimized.
profile_manager.profile["lastOptimize"] = None
# Without this, the collection DB won't get automatically optimized.
profile_manager.profile["lastOptimize"] = None
auto_sync: bool | None = ${pyOptionalBool pCfg.sync.autoSync}
if auto_sync is not None:
profile_manager.profile["autoSync"] = auto_sync
auto_sync: bool | None = ${pyOptionalBool cfg.sync.autoSync}
if auto_sync is not None:
profile_manager.profile["autoSync"] = auto_sync
sync_media: bool | None = ${pyOptionalBool pCfg.sync.syncMedia}
if sync_media is not None:
profile_manager.profile["syncMedia"] = sync_media
sync_media: bool | None = ${pyOptionalBool cfg.sync.syncMedia}
if sync_media is not None:
profile_manager.profile["syncMedia"] = sync_media
media_sync_minutes_str: str = "${toString pCfg.sync.autoSyncMediaMinutes}"
if media_sync_minutes_str:
profile_manager.set_periodic_sync_media_minutes(int(media_sync_minutes_str))
media_sync_minutes_str: str = "${toString cfg.sync.autoSyncMediaMinutes}"
if media_sync_minutes_str:
profile_manager.set_periodic_sync_media_minutes(int(media_sync_minutes_str))
network_timeout_str: str = "${toString pCfg.sync.networkTimeout}"
if network_timeout_str:
profile_manager.set_network_timeout(int(network_timeout_str))
network_timeout_str: str = "${toString cfg.sync.networkTimeout}"
if network_timeout_str:
profile_manager.set_network_timeout(int(network_timeout_str))
profile_manager.save()
'') cfg.profiles}
default_profile: str | None = ${
let
defaultProfiles = lib.attrNames (lib.filterAttrs (_: prof: prof.default) cfg.profiles);
in
if defaultProfiles == [ ] then "None" else ''"${lib.head defaultProfiles}"''
}
if default_profile is not None:
profile_manager.set_last_loaded_profile_name(default_profile)
profile_manager.save()
'';
@ -169,24 +181,29 @@ in
import aqt
from pathlib import Path
username: str | None = ${if cfg.sync.username == null then "None" else "'${cfg.sync.username}'"}
username_file: Path | None = ${
if cfg.sync.usernameFile == null then "None" else "Path('${cfg.sync.usernameFile}')"
}
key_file: Path | None = ${
if cfg.sync.keyFile == null then "None" else "Path('${cfg.sync.keyFile}')"
}
custom_sync_url: str | None = ${if cfg.sync.url == null then "None" else "'${cfg.sync.url}'"}
def set_server() -> None:
if custom_sync_url:
aqt.mw.pm.set_custom_sync_url(custom_sync_url)
if username:
aqt.mw.pm.set_sync_username(username)
elif username_file and username_file.exists():
aqt.mw.pm.set_sync_username(username_file.read_text().strip())
if key_file and key_file.exists():
aqt.mw.pm.set_sync_key(key_file.read_text().strip())
${lib.concatMapAttrsStringSep "\n " (name: pCfg: ''
if aqt.mw.pm.name == "${name}":
username: str | None = ${
if pCfg.sync.username == null then "None" else "'${pCfg.sync.username}'"
}
username_file: Path | None = ${
if pCfg.sync.usernameFile == null then "None" else "Path('${pCfg.sync.usernameFile}')"
}
key_file: Path | None = ${
if pCfg.sync.keyFile == null then "None" else "Path('${pCfg.sync.keyFile}')"
}
custom_sync_url: str | None = ${if pCfg.sync.url == null then "None" else "'${pCfg.sync.url}'"}
if custom_sync_url:
aqt.mw.pm.set_custom_sync_url(custom_sync_url)
if username:
aqt.mw.pm.set_sync_username(username)
elif username_file and username_file.exists():
aqt.mw.pm.set_sync_username(username_file.read_text().strip())
if key_file and key_file.exists():
aqt.mw.pm.set_sync_key(key_file.read_text().strip())
'') cfg.profiles}
aqt.gui_hooks.profile_did_open.append(set_server)
'';
@ -197,11 +214,13 @@ in
pname = "home-manager";
version = "1.0";
src = pkgs.writeTextDir "__init__.py" ''
import aqt
from aqt.qt import QWidget, QMessageBox
from anki.hooks import wrap
from unittest.mock import patch
from typing import Any
import aqt
from anki.hooks import wrap
from aqt.qt import QWidget, QMessageBox
def make_config_differences_str(initial_config: dict[str, Any],
new_config: dict[str, Any]) -> str:
details = ""
@ -243,18 +262,14 @@ in
aqt.mw.pm.save = on_preferences_save
def state_will_change(new_state: aqt.main.MainWindowState,
old_state: aqt.main.MainWindowState):
if new_state != "profileManager":
return
def show_profile_changes_warning() -> None:
QMessageBox.warning(
aqt.mw,
"NixOS Info",
("Profiles cannot be changed or added while settings are managed with "
"Home Manager.")
("Profiles cannot be changed here while settings are managed with "
"Home Manager.")
)
return None
# Ensure Anki doesn't try to save to the read-only DB settings file.
aqt.mw.pm.save = lambda: None
@ -262,8 +277,10 @@ in
# Tell the user when they try to change settings that won't be persisted.
aqt.gui_hooks.dialog_manager_did_open_dialog.append(dialog_did_open)
# Show warning when users try to switch or customize profiles.
aqt.gui_hooks.state_will_change.append(state_will_change)
# Warn the user when they try to change profiles imperatively.
patch.object(aqt.mw, "onAddProfile", show_profile_changes_warning).start()
patch.object(aqt.mw, "onRenameProfile", show_profile_changes_warning).start()
patch.object(aqt.mw, "onRemProfile", show_profile_changes_warning).start()
'';
};
}

View file

@ -1,7 +1,8 @@
{ pkgs, ... }:
let
# This would normally not be a file in the store for security reasons.
testKeyFile = pkgs.writeText "test-key-file" "a-sync-key";
fooKeyFile = pkgs.writeText "foo-key-file" "a-sync-key";
barKeyFile = pkgs.writeText "bar-key-file" "a-sync-key";
in
{
programs.anki = {
@ -30,14 +31,30 @@ in
theme = "dark";
uiScale = 1.0;
videoDriver = "opengl";
sync = {
autoSync = true;
syncMedia = true;
autoSyncMediaMinutes = 15;
networkTimeout = 60;
url = "http://example.com/anki-sync/";
username = "lovelearning@email.com";
keyFile = testKeyFile;
profiles = {
foo = {
default = true;
sync = {
autoSync = true;
syncMedia = true;
autoSyncMediaMinutes = 15;
networkTimeout = 60;
url = "http://foo.com/anki-sync/";
username = "foo@email.com";
keyFile = fooKeyFile;
};
};
bar = {
sync = {
autoSync = false;
syncMedia = false;
autoSyncMediaMinutes = 30;
networkTimeout = 120;
url = "http://foo.com/anki-sync/";
username = "bar@email.com";
keyFile = barKeyFile;
};
};
};
};