From 04acdd8af8eec707fed43b3a54fec35f7cdbc93c Mon Sep 17 00:00:00 2001 From: sollniss Date: Wed, 7 Jan 2026 23:28:50 +0900 Subject: [PATCH] anki: support multiple profiles --- modules/programs/anki/default.nix | 235 +++++++++++++------- modules/programs/anki/helper.nix | 109 +++++---- tests/modules/programs/anki/full-config.nix | 35 ++- 3 files changed, 241 insertions(+), 138 deletions(-) diff --git a/modules/programs/anki/default.nix b/modules/programs/anki/default.nix index a0c09c54..8369f79c 100644 --- a/modules/programs/anki/default.nix +++ b/modules/programs/anki/default.nix @@ -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 \`\`" - ''; - }; - - url = lib.mkOption { - type = with lib.types; nullOr str; - default = null; - example = "http://example.com/anki-sync/"; - description = '' - Custom sync server URL. See . - ''; - }; - - 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 \`\`" + ''; + }; + + url = lib.mkOption { + type = with lib.types; nullOr str; + default = null; + example = "http://example.com/anki-sync/"; + description = '' + Custom sync server URL. See . + ''; + }; + + 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 ( diff --git a/modules/programs/anki/helper.nix b/modules/programs/anki/helper.nix index b82642b3..6c27aad0 100644 --- a/modules/programs/anki/helper.nix +++ b/modules/programs/anki/helper.nix @@ -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() ''; }; } diff --git a/tests/modules/programs/anki/full-config.nix b/tests/modules/programs/anki/full-config.nix index 490f418d..e1f9bc78 100644 --- a/tests/modules/programs/anki/full-config.nix +++ b/tests/modules/programs/anki/full-config.nix @@ -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; + }; + }; }; };