Files
home-manager/modules/programs/obsidian.nix
Alex Epelde 936d579f53 obsidian: no IFD, use RFC42 settings
The current communityPlugins and themes implementations do IFD, slowing down evaluation of user configurations. Instead of reading the plugin IDs and theme names from the package outputs' manifest.json, I have made it so the plugin ID / theme name is read from the package's passthru if possible.

Using (pkgs.formats.json { }).type gives better type checking than nullOr (attrsOf anything).
2026-04-22 10:56:48 -05:00

621 lines
20 KiB
Nix

{
config,
pkgs,
lib,
...
}:
let
inherit (lib)
mkOption
mkEnableOption
mkPackageOption
mkDefault
literalExpression
types
;
settingsFormat = pkgs.formats.json { };
cfg = config.programs.obsidian;
corePluginsList = [
"audio-recorder"
"backlink"
"bases"
"bookmarks"
"canvas"
"command-palette"
"daily-notes"
"editor-status"
"file-explorer"
"file-recovery"
"footnotes"
"global-search"
"graph"
"markdown-importer"
"note-composer"
"outgoing-link"
"outline"
"page-preview"
"properties"
"publish"
"random-note"
"slash-command"
"slides"
"switcher"
"sync"
"tag-pane"
"templates"
"webviewer"
"word-count"
"workspaces"
"zk-prefixer"
];
corePluginsOptions = {
options = {
enable = mkOption {
type = types.bool;
default = true;
description = "Whether to enable the plugin.";
};
name = mkOption {
type = types.enum corePluginsList;
description = "The plugin.";
};
settings = mkOption {
inherit (settingsFormat) type;
description = "Plugin settings to include.";
default = null;
};
};
};
corePluginsSettingsType =
with types;
nullOr (
listOf (coercedTo (enum corePluginsList) (p: { name = p; }) (submodule corePluginsOptions))
);
communityPluginsOptions = {
options = {
enable = mkOption {
type = types.bool;
default = true;
description = "Whether to enable the plugin.";
};
pkg = mkOption {
type = types.package;
description = "The plugin package.";
};
settings = mkOption {
inherit (settingsFormat) type;
description = "Settings to include in the plugin's `data.json`.";
default = null;
};
};
};
communityPluginsSettingsType =
with types;
nullOr (listOf (coercedTo package (p: { pkg = p; }) (submodule communityPluginsOptions)));
checkCssPath = path: lib.filesystem.pathIsRegularFile path && lib.strings.hasSuffix ".css" path;
toCssName = path: lib.strings.removeSuffix ".css" (baseNameOf path);
cssSnippetsOptions =
{ config, ... }:
{
options = {
enable = mkOption {
type = types.bool;
default = true;
description = "Whether to enable the snippet.";
};
name = mkOption {
type = types.str;
defaultText = literalExpression ''lib.strings.removeSuffix ".css" (builtins.baseNameOf source)'';
description = "Name of the snippet.";
};
source = mkOption {
type = with types; nullOr (addCheck path checkCssPath);
description = "Path of the source file.";
default = null;
};
text = mkOption {
type = with types; nullOr str;
description = "Text of the file.";
default = null;
};
};
config.name = mkDefault (toCssName config.source);
};
cssSnippetsSettingsType =
with types;
nullOr (
listOf (coercedTo (addCheck path checkCssPath) (p: { source = p; }) (submodule cssSnippetsOptions))
);
themesOptions = {
options = {
enable = mkOption {
type = types.bool;
default = true;
description = "Whether to set the theme as active.";
};
pkg = mkOption {
type = types.package;
description = "The theme package.";
};
};
};
themesSettingsType =
with types;
nullOr (listOf (coercedTo package (p: { pkg = p; }) (submodule themesOptions)));
hotkeysOptions = {
options = {
modifiers = mkOption {
type = with types; listOf str;
description = "The hotkey modifiers.";
default = [ ];
};
key = mkOption {
type = types.str;
description = "The hotkey.";
};
};
};
hotkeysSettingsType = with types; nullOr (attrsOf (listOf (submodule hotkeysOptions)));
extraFilesOptions =
{ name, ... }:
{
options = {
source = mkOption {
type = with types; nullOr path;
description = "Path of the source file or directory.";
default = null;
};
text = mkOption {
type = with types; nullOr str;
description = "Text of the file.";
default = null;
};
target = mkOption {
type = types.str;
defaultText = literalExpression "name";
description = "Path to target relative to the vault's directory.";
};
};
config.target = mkDefault name;
};
extraFilesSettingsType = with types; nullOr (attrsOf (submodule extraFilesOptions));
in
{
meta.maintainers = [ lib.maintainers.karaolidis ];
options.programs.obsidian = {
enable = mkEnableOption "obsidian";
package = mkPackageOption pkgs "obsidian" { nullable = true; };
cli.enable = mkOption {
type = types.bool;
default = false;
description = "Whether to enable the Obsidian CLI in `obsidian.json`.";
};
defaultSettings = {
app = mkOption {
description = ''
Settings to write to `app.json`.
Vault-specific settings take priority and will override these, if set.
'';
inherit (settingsFormat) type;
default = null;
};
appearance = mkOption {
description = ''
Settings to write to `appearance.json`.
Vault-specific settings take priority and will override these, if set.
'';
inherit (settingsFormat) type;
default = null;
};
corePlugins = mkOption {
description = ''
Core plugins to activate.
Vault-specific settings take priority and will override these, if set.
'';
type = corePluginsSettingsType;
default = null;
};
communityPlugins = mkOption {
description = "
Community plugins to install and activate.
Vault-specific settings take priority and will override these, if set.
";
type = communityPluginsSettingsType;
default = null;
};
cssSnippets = mkOption {
description = "
CSS snippets to install.
Vault-specific settings take priority and will override these, if set.
";
type = cssSnippetsSettingsType;
default = null;
};
themes = mkOption {
description = "
Themes to install.
Vault-specific settings take priority and will override these, if set.
";
type = themesSettingsType;
default = null;
};
hotkeys = mkOption {
description = "
Hotkeys to configure.
Vault-specific settings take priority and will override these, if set.
";
type = hotkeysSettingsType;
default = null;
};
extraFiles = mkOption {
description = "
Extra files to link to the vault directory.
Vault-specific settings take priority and will override these, if set.
";
type = extraFilesSettingsType;
default = null;
};
};
vaults = mkOption {
description = "List of vaults to create.";
type = types.attrsOf (
types.submodule (
{ name, ... }:
{
options = {
enable = mkOption {
type = types.bool;
default = true;
description = "Whether this vault should be generated.";
};
target = mkOption {
type = types.str;
defaultText = literalExpression "name";
description = "Path to target vault relative to the user's {env}`HOME`.";
};
settings = {
app = mkOption {
description = "Settings to write to app.json.";
inherit (settingsFormat) type;
default = cfg.defaultSettings.app;
defaultText = literalExpression "config.programs.obsidian.defaultSettings.app";
};
appearance = mkOption {
description = "Settings to write to appearance.json.";
inherit (settingsFormat) type;
default = cfg.defaultSettings.appearance;
defaultText = literalExpression "config.programs.obsidian.defaultSettings.appearance";
};
corePlugins = mkOption {
description = "Core plugins to activate.";
type = corePluginsSettingsType;
default = cfg.defaultSettings.corePlugins;
defaultText = literalExpression "config.programs.obsidian.defaultSettings.corePlugins";
};
communityPlugins = mkOption {
description = "Community plugins to install and activate.";
type = communityPluginsSettingsType;
default = cfg.defaultSettings.communityPlugins;
defaultText = literalExpression "config.programs.obsidian.defaultSettings.communityPlugins";
};
cssSnippets = mkOption {
description = "CSS snippets to install.";
type = cssSnippetsSettingsType;
default = cfg.defaultSettings.cssSnippets;
defaultText = literalExpression "config.programs.obsidian.defaultSettings.cssSnippets";
};
themes = mkOption {
description = "Themes to install.";
type = themesSettingsType;
default = cfg.defaultSettings.themes;
defaultText = literalExpression "config.programs.obsidian.defaultSettings.themes";
};
hotkeys = mkOption {
description = "Hotkeys to configure.";
type = hotkeysSettingsType;
default = cfg.defaultSettings.hotkeys;
defaultText = literalExpression "config.programs.obsidian.defaultSettings.hotkeys";
};
extraFiles = mkOption {
description = "Extra files to link to the vault directory.";
type = extraFilesSettingsType;
default = cfg.defaultSettings.extraFiles;
defaultText = literalExpression "config.programs.obsidian.defaultSettings.extraFiles";
};
};
};
config.target = mkDefault name;
}
)
);
default = { };
};
};
config =
let
vaults = builtins.filter (vault: vault.enable == true) (builtins.attrValues cfg.vaults);
getManifest =
item:
let
manifest = builtins.fromJSON (builtins.readFile "${item.pkg}/manifest.json");
in
item.pkg.manifestId or manifest.id or manifest.name;
in
lib.mkIf cfg.enable {
home = {
packages = lib.mkIf (cfg.package != null) [ cfg.package ];
file =
let
mkApp =
vault:
lib.lists.optionals (vault.settings.app != null) [
{
name = "${vault.target}/.obsidian/app.json";
value.source = settingsFormat.generate "app.json" vault.settings.app;
}
];
mkAppearance =
vault:
lib.lists.optionals
(
vault.settings.appearance != null
|| vault.settings.themes != null
|| vault.settings.cssSnippets != null
)
[
{
name = "${vault.target}/.obsidian/appearance.json";
value = {
source = settingsFormat.generate "appearance.json" (
(lib.attrsets.optionalAttrs (vault.settings.appearance != null) vault.settings.appearance)
// (lib.attrsets.optionalAttrs (vault.settings.cssSnippets != null) {
enabledCssSnippets = map (snippet: snippet.name) (
builtins.filter (snippet: snippet.enable) vault.settings.cssSnippets
);
})
// (lib.attrsets.optionalAttrs (vault.settings.themes != null) (
let
activeTheme = lib.lists.findSingle (
theme: theme.enable
) null (throw "Only one theme can be enabled at a time.") vault.settings.themes;
in
lib.attrsets.optionalAttrs (activeTheme != null) {
cssTheme = getManifest activeTheme;
}
))
);
};
}
];
mkCorePlugins =
vault:
lib.lists.optionals (vault.settings.corePlugins != null) (
[
{
name = "${vault.target}/.obsidian/core-plugins.json";
value.source = settingsFormat.generate "core-plugins.json" (
builtins.listToAttrs (
map (name: {
inherit name;
value = builtins.any (plugin: name == plugin.name && plugin.enable) vault.settings.corePlugins;
}) corePluginsList
)
);
}
]
++ map (plugin: {
name = "${vault.target}/.obsidian/${plugin.name}.json";
value.source = settingsFormat.generate "${plugin.name}.json" plugin.settings;
}) (builtins.filter (plugin: plugin.settings != null) vault.settings.corePlugins)
);
mkCommunityPlugins =
vault:
lib.lists.optionals (vault.settings.communityPlugins != null) (
[
{
name = "${vault.target}/.obsidian/community-plugins.json";
value.source = settingsFormat.generate "community-plugins.json" (
map getManifest (builtins.filter (plugin: plugin.enable) vault.settings.communityPlugins)
);
}
]
++ map (plugin: {
name = "${vault.target}/.obsidian/plugins/${getManifest plugin}";
value = {
source = plugin.pkg;
recursive = true;
};
}) vault.settings.communityPlugins
++ map (plugin: {
name = "${vault.target}/.obsidian/plugins/${getManifest plugin}/data.json";
value.source = settingsFormat.generate "data.json" plugin.settings;
}) (builtins.filter (plugin: plugin.settings != null) vault.settings.communityPlugins)
);
mkCssSnippets =
vault:
lib.lists.optionals (vault.settings.cssSnippets != null) (
map (snippet: {
name = "${vault.target}/.obsidian/snippets/${snippet.name}.css";
value =
if snippet.source != null then
{
inherit (snippet) source;
}
else
{
inherit (snippet) text;
};
}) vault.settings.cssSnippets
);
mkThemes =
vault:
lib.lists.optionals (vault.settings.themes != null) (
map (theme: {
name = "${vault.target}/.obsidian/themes/${getManifest theme}";
value.source = theme.pkg;
}) vault.settings.themes
);
mkHotkeys =
vault:
lib.lists.optionals (vault.settings.hotkeys != null) [
{
name = "${vault.target}/.obsidian/hotkeys.json";
value.source = settingsFormat.generate "hotkeys.json" vault.settings.hotkeys;
}
];
mkExtraFiles =
vault:
lib.lists.optionals (vault.settings.extraFiles != null) (
map (file: {
name = "${vault.target}/.obsidian/${file.target}";
value =
if file.source != null then
{
inherit (file) source;
}
else
{
inherit (file) text;
};
}) (builtins.attrValues vault.settings.extraFiles)
);
in
builtins.listToAttrs (
lib.lists.flatten (
map (vault: [
(mkApp vault)
(mkAppearance vault)
(mkCorePlugins vault)
(mkCommunityPlugins vault)
(mkCssSnippets vault)
(mkThemes vault)
(mkHotkeys vault)
(mkExtraFiles vault)
]) vaults
)
);
activation.obsidian =
let
obsidianConfigDir =
if pkgs.stdenv.isDarwin then
"${config.home.homeDirectory}/Library/Application Support/obsidian"
else
"${config.xdg.configHome}/obsidian";
template = settingsFormat.generate "obsidian.json" {
vaults = builtins.listToAttrs (
map (vault: {
name = builtins.substring 0 16 (builtins.hashString "md5" vault.target);
value = {
path = "${config.home.homeDirectory}/${vault.target}";
};
}) vaults
);
updateDisabled = true;
cli = cfg.cli.enable;
};
in
lib.hm.dag.entryAfter [ "writeBoundary" ] ''
OBSIDIAN_CONFIG="${obsidianConfigDir}/obsidian.json"
if [ -f "$OBSIDIAN_CONFIG" ]; then
verboseEcho "Merging existing Obsidian config with generated template"
tmp="$(mktemp)"
run ${lib.getExe pkgs.jq} -s '(.[0] // {}) * (.[1] // {})' "$OBSIDIAN_CONFIG" "${template}" > "$tmp"
run install -m644 "$tmp" "$OBSIDIAN_CONFIG"
rm -f "$tmp"
else
verboseEcho "Installing fresh Obsidian config"
run install -D -m644 "${template}" "$OBSIDIAN_CONFIG"
fi
'';
};
assertions = [
{
assertion = builtins.all (
vault:
builtins.all (
snippet:
(snippet.source != null && snippet.text == null) || (snippet.source == null && snippet.text != null)
) (lib.lists.optionals (vault.settings.cssSnippets != null) vault.settings.cssSnippets)
) (builtins.attrValues cfg.vaults);
message = "Each CSS snippet must have exactly one of 'source' or 'text' set";
}
{
assertion = builtins.all (
vault:
builtins.all
(file: (file.source != null && file.text == null) || (file.source == null && file.text != null))
(
lib.lists.optionals (vault.settings.extraFiles != null) (
builtins.attrValues vault.settings.extraFiles
)
)
) (builtins.attrValues cfg.vaults);
message = "Each extra file must have exactly one of 'source' or 'text' set";
}
];
};
}