diff --git a/maintainers/maintainer-list.nix b/maintainers/maintainer-list.nix
index 58cf591b197f..6792993f7015 100644
--- a/maintainers/maintainer-list.nix
+++ b/maintainers/maintainer-list.nix
@@ -12543,6 +12543,13 @@
githubId = 39434424;
name = "Felix Springer";
};
+ junestepp = {
+ email = "git@junestepp.me";
+ github = "junestepp";
+ githubId = 26205306;
+ name = "June Stepp";
+ keys = [ { fingerprint = "2561 0243 2233 CFE6 E13E 3C33 348C 6EB3 39AE C582"; } ];
+ };
junjihashimoto = {
email = "junji.hashimoto@gmail.com";
github = "junjihashimoto";
diff --git a/pkgs/games/anki/addons/adjust-sound-volume/default.nix b/pkgs/games/anki/addons/adjust-sound-volume/default.nix
new file mode 100644
index 000000000000..cdeb7d3d727a
--- /dev/null
+++ b/pkgs/games/anki/addons/adjust-sound-volume/default.nix
@@ -0,0 +1,23 @@
+{
+ lib,
+ anki-utils,
+ fetchFromGitHub,
+ nix-update-script,
+}:
+anki-utils.buildAnkiAddon (finalAttrs: {
+ pname = "adjust-sound-volume";
+ version = "0.0.6";
+ src = fetchFromGitHub {
+ owner = "mnogu";
+ repo = "adjust-sound-volume";
+ rev = "v${finalAttrs.version}";
+ hash = "sha256-6reIUz+tHKd4KQpuofLa/tIL5lCloj3yODZ8Cz29jFU=";
+ };
+ passthru.updateScript = nix-update-script { };
+ meta = {
+ description = "Add a new menu item for adjusting the sound volume";
+ homepage = "https://github.com/mnogu/adjust-sound-volume";
+ license = lib.licenses.agpl3Plus;
+ maintainers = with lib.maintainers; [ junestepp ];
+ };
+})
diff --git a/pkgs/games/anki/addons/anki-connect/default.nix b/pkgs/games/anki/addons/anki-connect/default.nix
new file mode 100644
index 000000000000..27a0411e52f7
--- /dev/null
+++ b/pkgs/games/anki/addons/anki-connect/default.nix
@@ -0,0 +1,27 @@
+{
+ lib,
+ anki-utils,
+ fetchFromSourcehut,
+ nix-update-script,
+}:
+anki-utils.buildAnkiAddon (finalAttrs: {
+ pname = "anki-connect";
+ version = "24.7.25.0";
+ src = fetchFromSourcehut {
+ owner = "~foosoft";
+ repo = "anki-connect";
+ rev = finalAttrs.version;
+ hash = "sha256-N98EoCE/Bx+9QUQVeU64FXHXSek7ASBVv1b9ltJ4G1U=";
+ };
+ sourceRoot = "${finalAttrs.src.name}/plugin";
+ passthru.updateScript = nix-update-script { };
+ meta = {
+ description = ''
+ Enable external applications such as Yomichan to communicate
+ with Anki over a simple HTTP API
+ '';
+ homepage = "https://foosoft.net/projects/anki-connect/";
+ license = lib.licenses.gpl3Plus;
+ maintainers = with lib.maintainers; [ junestepp ];
+ };
+})
diff --git a/pkgs/games/anki/addons/anki-utils.nix b/pkgs/games/anki/addons/anki-utils.nix
new file mode 100644
index 000000000000..e2247ebac874
--- /dev/null
+++ b/pkgs/games/anki/addons/anki-utils.nix
@@ -0,0 +1,126 @@
+{
+ lib,
+ stdenv,
+ symlinkJoin,
+ lndir,
+ formats,
+ runCommand,
+}:
+{
+ buildAnkiAddon = lib.extendMkDerivation {
+ constructDrv = stdenv.mkDerivation;
+ extendDrvArgs =
+ finalAttrs:
+ {
+ pname,
+ version,
+ src,
+ sourceRoot ? "",
+ configurePhase ? ''
+ runHook preConfigure
+ runHook postConfigure
+ '',
+ buildPhase ? ''
+ runHook preBuild
+ runHook postBuild
+ '',
+ dontPatchELF ? true,
+ dontStrip ? true,
+ nativeBuildInputs ? [ ],
+ passthru ? { },
+ meta ? { },
+ # Script run after "user_files" folder is populated.
+ # Used when an add-on needs to process and change "user_files" based
+ # on what the user added to it.
+ processUserFiles ? "",
+ ...
+ }:
+ {
+ inherit
+ version
+ src
+ sourceRoot
+ configurePhase
+ buildPhase
+ dontPatchELF
+ dontStrip
+ nativeBuildInputs
+ ;
+
+ pname = "anki-addon-${pname}";
+
+ installPrefix = "share/anki/addons/${pname}";
+
+ installPhase = ''
+ runHook preInstall
+
+ mkdir -p "$out/$installPrefix"
+ find . -mindepth 1 -maxdepth 1 | xargs -d'\n' mv -t "$out/$installPrefix/"
+
+ runHook postInstall
+ '';
+
+ passthru = {
+ withConfig =
+ {
+ # JSON add-on config. The available options for an add-on are in its
+ # config.json file.
+ # See https://addon-docs.ankiweb.net/addon-config.html#config-json
+ config ? { },
+ # Path to a folder to be merged with the add-on "user_files" folder.
+ # See https://addon-docs.ankiweb.net/addon-config.html#user-files.
+ userFiles ? null,
+ }:
+ let
+ metaConfigFormat = formats.json { };
+ addonMetaConfig = metaConfigFormat.generate "meta.json" { inherit config; };
+ in
+ symlinkJoin {
+ pname = "${finalAttrs.pname}-with-config";
+ inherit (finalAttrs) version meta;
+
+ paths = [
+ finalAttrs.finalPackage
+ ];
+
+ postBuild = ''
+ cd $out/${finalAttrs.installPrefix}
+
+ rm -f meta.json
+ ln -s ${addonMetaConfig} meta.json
+
+ mkdir -p user_files
+ ${
+ if (userFiles != null) then
+ ''
+ ${lndir}/bin/lndir -silent "${userFiles}" user_files
+ ''
+ else
+ ""
+ }
+
+ ${processUserFiles}
+ '';
+ };
+ } // passthru;
+
+ meta = {
+ platforms = lib.platforms.all;
+ } // meta;
+ };
+ };
+
+ buildAnkiAddonsDir =
+ addonPackages:
+ let
+ addonDirs = map (pkg: "${pkg}/share/anki/addons") addonPackages;
+ addons = lib.concatMapStringsSep " " (p: "${p}/*") addonDirs;
+ in
+ runCommand "anki-addons" { } ''
+ mkdir $out
+ [[ '${addons}' ]] || exit 0
+ for addon in ${addons}; do
+ ln -s "$addon" $out/
+ done
+ '';
+}
diff --git a/pkgs/games/anki/addons/default.nix b/pkgs/games/anki/addons/default.nix
new file mode 100644
index 000000000000..d602b1ea877e
--- /dev/null
+++ b/pkgs/games/anki/addons/default.nix
@@ -0,0 +1,18 @@
+{
+ callPackage,
+}:
+{
+ adjust-sound-volume = callPackage ./adjust-sound-volume { };
+
+ anki-connect = callPackage ./anki-connect { };
+
+ local-audio-yomichan = callPackage ./local-audio-yomichan { };
+
+ passfail2 = callPackage ./passfail2 { };
+
+ recolor = callPackage ./recolor { };
+
+ reviewer-refocus-card = callPackage ./reviewer-refocus-card { };
+
+ yomichan-forvo-server = callPackage ./yomichan-forvo-server { };
+}
diff --git a/pkgs/games/anki/addons/local-audio-yomichan/default.nix b/pkgs/games/anki/addons/local-audio-yomichan/default.nix
new file mode 100644
index 000000000000..7ba361c8590c
--- /dev/null
+++ b/pkgs/games/anki/addons/local-audio-yomichan/default.nix
@@ -0,0 +1,73 @@
+{
+ lib,
+ anki-utils,
+ fetchFromGitHub,
+ python3,
+ nix-update-script,
+}:
+anki-utils.buildAnkiAddon (finalAttrs: {
+ pname = "local-audio-yomichan";
+ version = "0-unstable-2025-04-26";
+ src = fetchFromGitHub {
+ owner = "yomidevs";
+ repo = "local-audio-yomichan";
+ rev = "34750f1d8ca1cb473128fea7976a4d981e5e78a4";
+ sparseCheckout = [ "plugin" ];
+ hash = "sha256-2gyggcvxParay+1B7Sg2COKyocoxaRO1WTz+ymdRp4w=";
+ };
+ sourceRoot = "${finalAttrs.src.name}/plugin";
+ processUserFiles = ''
+ # Addon will try to load extra stuff unless Python package name is "plugin".
+ temp=$(mktemp -d)
+ ln -s $PWD $temp/plugin
+ # Addoon expects `user_files` dir at `$XDG_DATA_HOME/local-audio-yomichan`
+ ln -s $PWD/user_files $temp/local-audio-yomichan
+
+ PYTHONPATH=$temp \
+ WO_ANKI=1 \
+ XDG_DATA_HOME=$temp \
+ ${lib.getExe python3} -c \
+ "from plugin import db_utils; \
+ db_utils.init_db()"
+ '';
+ passthru.updateScript = nix-update-script {
+ extraArgs = [ "--version=branch" ];
+ };
+ meta = {
+ description = "Run a local audio server for Yomitan";
+ longDescription = ''
+ This add-on must be configured with an audio collection.
+
+ Example:
+
+ ```nix
+ pkgs.ankiAddons.local-audio-yomichan.withConfig {
+ userFiles =
+ let
+ audio-collection =
+ pkgs.runCommand "local-yomichan-audio-collection"
+ {
+ outputHashMode = "recursive";
+ outputHash = "sha256-NxbcXh2SDPfCd+ZHAWT5JdxRecNbT4Xpo0pxX5/DOfo=";
+
+ src = pkgs.requireFile {
+ name = "local-yomichan-audio-collection-2023-06-11-opus.tar.xz";
+ url = "https://github.com/yomidevs/local-audio-yomichan?tab=readme-ov-file#steps";
+ sha256 = "1xsxp8iggklv77rj972mqaa1i8f9hvr3ir0r2mwfqcdz4q120hr1";
+ };
+ }
+ '''
+ mkdir -p $out
+ cd $out
+ tar -xf "$src"
+ ''';
+ in
+ "''${audio-collection}/user_files";
+ }
+ ```
+ '';
+ homepage = "https://github.com/yomidevs/local-audio-yomichan";
+ license = lib.licenses.mit;
+ maintainers = with lib.maintainers; [ junestepp ];
+ };
+})
diff --git a/pkgs/games/anki/addons/passfail2/default.nix b/pkgs/games/anki/addons/passfail2/default.nix
new file mode 100644
index 000000000000..73746c1a568a
--- /dev/null
+++ b/pkgs/games/anki/addons/passfail2/default.nix
@@ -0,0 +1,34 @@
+{
+ lib,
+ anki-utils,
+ fetchFromGitHub,
+ nix-update-script,
+}:
+anki-utils.buildAnkiAddon (finalAttrs: {
+ pname = "passfail2";
+ version = "0.3.0-unstable-2024-10-17";
+ src = fetchFromGitHub {
+ owner = "lambdadog";
+ repo = "passfail2";
+ rev = "d5313e4f1217e968b36edbc0a4fe92386209ffe6";
+ hash = "sha256-HMe6/fHpYj/MN0dUFj3W71vK7qqcp9l1xm8SAiKkJLs=";
+ };
+ buildPhase = ''
+ runHook preBuild
+
+ substitute build_info.py.in build_info.py \
+ --replace-fail '$version' '"${finalAttrs.version}"'
+
+ runHook postBuild
+ '';
+ passthru.updateScript = nix-update-script { };
+ meta = {
+ description = ''
+ Replaces the default Anki review buttons with only two options:
+ “Fail” and “Pass”
+ '';
+ homepage = "https://github.com/lambdadog/passfail2";
+ license = lib.licenses.gpl3Plus;
+ maintainers = with lib.maintainers; [ junestepp ];
+ };
+})
diff --git a/pkgs/games/anki/addons/recolor/default.nix b/pkgs/games/anki/addons/recolor/default.nix
new file mode 100644
index 000000000000..3e60b0193904
--- /dev/null
+++ b/pkgs/games/anki/addons/recolor/default.nix
@@ -0,0 +1,27 @@
+{
+ lib,
+ anki-utils,
+ fetchFromGitHub,
+ nix-update-script,
+}:
+anki-utils.buildAnkiAddon (finalAttrs: {
+ pname = "recolor";
+ version = "3.1";
+ src = fetchFromGitHub {
+ owner = "AnKing-VIP";
+ repo = "AnkiRecolor";
+ rev = finalAttrs.version;
+ sparseCheckout = [ "src/addon" ];
+ hash = "sha256-28DJq2l9DP8O6OsbNQCZ0pm4S6CQ3Yz0Vfvlj+iQw8Y=";
+ };
+ sourceRoot = "${finalAttrs.src.name}/src/addon";
+ passthru.updateScript = nix-update-script { };
+ meta = {
+ description = "ReColor your Anki desktop to whatever aesthetic you like";
+ homepage = "https://github.com/AnKing-VIP/AnkiRecolor";
+ # No license file, but it can be assumed to be AGPL3 based on
+ # https://ankiweb.net/account/terms.
+ license = lib.licenses.agpl3Only;
+ maintainers = with lib.maintainers; [ junestepp ];
+ };
+})
diff --git a/pkgs/games/anki/addons/reviewer-refocus-card/default.nix b/pkgs/games/anki/addons/reviewer-refocus-card/default.nix
new file mode 100644
index 000000000000..6fde24afd22e
--- /dev/null
+++ b/pkgs/games/anki/addons/reviewer-refocus-card/default.nix
@@ -0,0 +1,30 @@
+{
+ lib,
+ anki-utils,
+ fetchFromGitHub,
+ nix-update-script,
+}:
+anki-utils.buildAnkiAddon (finalAttrs: {
+ pname = "reviewer-refocus-card";
+ version = "0-unstable-2022-12-24";
+ src = fetchFromGitHub {
+ owner = "glutanimate";
+ repo = "anki-addons-misc";
+ rev = "7b981836e0a6637a1853f3e8d73d022ab95fed31";
+ sparseCheckout = [ "src/reviewer_refocus_card" ];
+ hash = "sha256-181hyc4ED+0lBzn1FnrBvNIYIUQF8xEDB3uHK6SkpHw=";
+ };
+ sourceRoot = "${finalAttrs.src.name}/src/reviewer_refocus_card";
+ passthru.updateScript = nix-update-script {
+ extraArgs = [ "--version=branch" ];
+ };
+ meta = {
+ description = ''
+ Set focus to the card area, allowing you to scroll through your cards using
+ Page Up / Page Down, etc
+ '';
+ homepage = "https://github.com/glutanimate/anki-addons-misc";
+ license = lib.licenses.agpl3Only;
+ maintainers = with lib.maintainers; [ junestepp ];
+ };
+})
diff --git a/pkgs/games/anki/addons/yomichan-forvo-server/default.nix b/pkgs/games/anki/addons/yomichan-forvo-server/default.nix
new file mode 100644
index 000000000000..751798f6e3a6
--- /dev/null
+++ b/pkgs/games/anki/addons/yomichan-forvo-server/default.nix
@@ -0,0 +1,25 @@
+{
+ lib,
+ anki-utils,
+ fetchFromGitHub,
+ nix-update-script,
+}:
+anki-utils.buildAnkiAddon {
+ pname = "yomichan-forvo-server";
+ version = "0-unstable-2024-10-21";
+ src = fetchFromGitHub {
+ owner = "jamesnicolas";
+ repo = "yomichan-forvo-server";
+ rev = "364fc6d5d10969f516e0fa283460dfaf08c98e15";
+ hash = "sha256-Jpee9hkXCiBmSW7hzJ1rAg45XVIiLC8WENc09+ySFVI=";
+ };
+ passthru.updateScript = nix-update-script {
+ extraArgs = [ "--version=branch" ];
+ };
+ meta = {
+ description = "Audio server for yomichan that scrapes forvo for audio files";
+ homepage = "https://github.com/jamesnicolas/yomichan-forvo-server";
+ license = lib.licenses.unlicense;
+ maintainers = with lib.maintainers; [ junestepp ];
+ };
+}
diff --git a/pkgs/games/anki/default.nix b/pkgs/games/anki/default.nix
index ab520dea5da0..15b88f67a7bc 100644
--- a/pkgs/games/anki/default.nix
+++ b/pkgs/games/anki/default.nix
@@ -10,6 +10,7 @@
lame,
mpv-unwrapped,
ninja,
+ callPackage,
nixosTests,
nodejs,
jq,
@@ -91,6 +92,8 @@ python3.pkgs.buildPythonApplication rec {
./patches/disable-auto-update.patch
./patches/remove-the-gl-library-workaround.patch
./patches/skip-formatting-python-code.patch
+ # Used in with-addons.nix
+ ./patches/allow-setting-addons-folder.patch
];
inherit cargoDeps;
@@ -269,6 +272,7 @@ python3.pkgs.buildPythonApplication rec {
'';
passthru = {
+ withAddons = ankiAddons: callPackage ./with-addons.nix { inherit ankiAddons; };
tests.anki-sync-server = nixosTests.anki-sync-server;
};
@@ -292,6 +296,7 @@ python3.pkgs.buildPythonApplication rec {
inherit (mesa.meta) platforms;
maintainers = with maintainers; [
euank
+ junestepp
oxij
];
# Reported to crash at launch on darwin (as of 2.1.65)
diff --git a/pkgs/games/anki/patches/allow-setting-addons-folder.patch b/pkgs/games/anki/patches/allow-setting-addons-folder.patch
new file mode 100644
index 000000000000..12f7846267d5
--- /dev/null
+++ b/pkgs/games/anki/patches/allow-setting-addons-folder.patch
@@ -0,0 +1,15 @@
+diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py
+index 469908c1b2..34612d6e08 100644
+--- a/qt/aqt/profiles.py
++++ b/qt/aqt/profiles.py
+@@ -310,7 +310,9 @@ def profileFolder(self, create: bool = True) -> str:
+ return path
+
+ def addonFolder(self) -> str:
+- return self._ensureExists(os.path.join(self.base, "addons21"))
++ path = Path(os.environ.get("ANKI_ADDONS") or Path(self.base) / "addons21")
++ path.mkdir(parents=True, exist_ok=True)
++ return str(path.resolve())
+
+ def backupFolder(self) -> str:
+ return self._ensureExists(os.path.join(self.profileFolder(), "backups"))
diff --git a/pkgs/games/anki/with-addons.nix b/pkgs/games/anki/with-addons.nix
new file mode 100644
index 000000000000..d47c2c16f316
--- /dev/null
+++ b/pkgs/games/anki/with-addons.nix
@@ -0,0 +1,107 @@
+{
+ lib,
+ symlinkJoin,
+ makeWrapper,
+ anki,
+ anki-utils,
+ writeTextDir,
+ ankiAddons ? [ ],
+}:
+/*
+ `ankiAddons`
+ : A set of Anki add-ons to be installed. Here's a an example:
+
+ ~~~
+ pkgs.anki.withAddons [
+ # When the add-on is already available in nixpkgs
+ pkgs.ankiAddons.anki-connect
+
+ # When the add-on is not available in nixpkgs
+ (pkgs.anki-utils.buildAnkiAddon (finalAttrs: {
+ pname = "recolor";
+ version = "3.1";
+ src = pkgs.fetchFromGitHub {
+ owner = "AnKing-VIP";
+ repo = "AnkiRecolor";
+ rev = finalAttrs.version;
+ sparseCheckout = [ "src/addon" ];
+ hash = "sha256-28DJq2l9DP8O6OsbNQCZ0pm4S6CQ3Yz0Vfvlj+iQw8Y=";
+ };
+ sourceRoot = "source/src/addon";
+ }))
+
+ # When the add-on needs to be configured
+ pkgs.ankiAddons.passfail2.withConfig {
+ config = {
+ again_button_name = "not quite";
+ good_button_name = "excellent";
+ };
+
+ user_files = ./dir-to-be-merged-into-addon-user-files-dir;
+ };
+ ]
+ ~~~
+
+ The original `anki` executable will be wrapped so that it uses the addons from
+ `ankiAddons`.
+
+ This only works with Anki versions patched to support the `ANKI_ADDONS` environment
+ variable. `pkgs.anki` has this, but `pkgs.anki-bin` does not.
+*/
+let
+ defaultAddons = [
+ (anki-utils.buildAnkiAddon {
+ pname = "nixos";
+ version = "1.0";
+ src = writeTextDir "__init__.py" ''
+ import aqt
+ from aqt.qt import QMessageBox
+ import json
+
+ def addons_dialog_will_show(dialog: aqt.addons.AddonsDialog) -> None:
+ dialog.setEnabled(False)
+ QMessageBox.information(
+ dialog,
+ "NixOS Info",
+ ("These add-ons are managed by NixOS.
"
+ "See "
+ "github.com/NixOS/nixpkgs/tree/master/pkgs/games/anki/with-addons.nix")
+ )
+
+ def addon_tried_to_write_config(module: str, conf: dict) -> None:
+ message_box = QMessageBox(
+ QMessageBox.Icon.Warning,
+ "NixOS Info",
+ (f"The add-on module: \"{module}\" tried to update its config.
"
+ "See "
+ "github.com/NixOS/nixpkgs/tree/master/pkgs/games/anki/with-addons.nix"
+ " for how to configure add-ons managed by NixOS.")
+ )
+ message_box.setDetailedText(json.dumps(conf))
+ message_box.exec()
+
+ aqt.gui_hooks.addons_dialog_will_show.append(addons_dialog_will_show)
+ aqt.mw.addonManager.writeConfig = addon_tried_to_write_config
+ '';
+ meta.maintainers = with lib.maintainers; [ junestepp ];
+ })
+ ];
+in
+symlinkJoin {
+ inherit (anki) version;
+ pname = "${anki.pname}-with-addons";
+
+ paths = [ anki ];
+
+ nativeBuildInputs = [ makeWrapper ];
+ postBuild = ''
+ wrapProgram $out/bin/anki \
+ --set ANKI_ADDONS "${anki-utils.buildAnkiAddonsDir (ankiAddons ++ defaultAddons)}"
+ '';
+
+ meta = builtins.removeAttrs anki.meta [
+ "name"
+ "outputsToInstall"
+ "position"
+ ];
+}
diff --git a/pkgs/top-level/all-packages.nix b/pkgs/top-level/all-packages.nix
index 31f5b7fe7c9e..160f7c5a0006 100644
--- a/pkgs/top-level/all-packages.nix
+++ b/pkgs/top-level/all-packages.nix
@@ -14838,6 +14838,8 @@ with pkgs;
amoeba-data = callPackage ../games/amoeba/data.nix { };
anki = callPackage ../games/anki { };
+ anki-utils = callPackage ../games/anki/addons/anki-utils.nix { };
+ ankiAddons = recurseIntoAttrs (callPackage ../games/anki/addons { });
anki-bin = callPackage ../games/anki/bin.nix { };
anki-sync-server = callPackage ../games/anki/sync-server.nix { };