anki: support multiple profiles
This commit is contained in:
parent
bff85cb66b
commit
04acdd8af8
3 changed files with 241 additions and 138 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue