#!/usr/bin/env python3 # udt-appthemes: install the generated app themes that need more than a copy. # Copyright (C) 2026 Danilo M. # Licensed under the GNU General Public License v2 only. """Install the app themes that need more than a copy: Obsidian, Typora, btop, Joplin and Feishin. Separate from install.sh because enabling an Obsidian snippet means editing JSON that the user also owns: appearance.json lists every enabled snippet, so the entry has to be appended rather than the file rewritten, or a vault loses whatever layout snippets it had. """ import json import shutil import sys from pathlib import Path REPO = Path(__file__).resolve().parent.parent SNIPPET = "udt" OBSIDIAN_REGISTRY = Path.home() / ".config" / "obsidian" / "obsidian.json" TYPORA_THEMES = Path.home() / ".config" / "Typora" / "themes" BTOP = Path.home() / ".config" / "btop" JOPLIN = Path.home() / ".config" / "joplin-desktop" FEISHIN = Path.home() / ".config" / "feishin" def vaults(): """The vaults Obsidian itself knows about. Read from Obsidian's own registry rather than found by scanning for .obsidian directories. A scan cannot tell a vault from an abandoned one: ~/Documents/Obsidian holds a leftover .obsidian from a vault that no longer exists, and writing to it touched config for something Obsidian never opens. """ try: data = json.loads(OBSIDIAN_REGISTRY.read_text()) except (OSError, json.JSONDecodeError): return [] found = [] for entry in data.get("vaults", {}).values(): path = entry.get("path") if not path: continue config = Path(path) / ".obsidian" if config.is_dir(): found.append(config) return sorted(found) def install_obsidian(source): """Copy the snippet into each vault and enable it, preserving the rest.""" done = [] for config in vaults(): snippets = config / "snippets" snippets.mkdir(exist_ok=True) shutil.copyfile(source, snippets / f"{SNIPPET}.css") appearance = config / "appearance.json" try: data = json.loads(appearance.read_text()) except (OSError, json.JSONDecodeError): data = {} enabled = data.get("enabledCssSnippets", []) if SNIPPET not in enabled: # Append: a vault may already have snippets on, and replacing the # list would silently switch them off. enabled.append(SNIPPET) data["enabledCssSnippets"] = enabled appearance.write_text(json.dumps(data, indent=2) + "\n") done.append(config.parent.name or str(config.parent)) return done def install_typora(source): """Drop the theme in. Typora picks it from Themes once the file exists.""" if not TYPORA_THEMES.is_dir(): return False shutil.copyfile(source, TYPORA_THEMES / "udt.css") return True def install_btop(source): """Install the theme and point btop.conf's color_theme at it. Only that one line is rewritten: btop.conf is btop's own, and it writes the file back on exit, so a btop left running across a scheme switch restores its old color_theme. Restart it after installing. """ if not BTOP.is_dir(): return False (BTOP / "themes").mkdir(exist_ok=True) shutil.copyfile(source, BTOP / "themes" / "udt.theme") conf = BTOP / "btop.conf" try: lines = conf.read_text().splitlines() except OSError: lines = [] want = 'color_theme = "udt"' lines = [want if line.startswith("color_theme") else line for line in lines] if want not in lines: lines.append(want) conf.write_text("\n".join(lines) + "\n") return True def install_joplin(source): """Write userchrome.css, unless the user already keeps their own there. Joplin has one userchrome.css per profile and no include, so a file this script did not write is left alone rather than replaced. """ if not JOPLIN.is_dir(): return None dest = JOPLIN / "userchrome.css" if dest.exists() and "Generated by udt-palette" not in dest.read_text(): return False shutil.copyfile(source, dest) return True def install_feishin(source): """Drop the theme into Feishin's Themes folder, which it watches live.""" if not FEISHIN.is_dir(): return False (FEISHIN / "Themes").mkdir(exist_ok=True) shutil.copyfile(source, FEISHIN / "Themes" / "UDT.json") return True def main(): obsidian = REPO / "templates" / "obsidian" / "udt.css" typora = REPO / "templates" / "typora" / "udt.css" if not obsidian.exists() or not typora.exists(): print("udt-appthemes: run udt-palette first", file=sys.stderr) return 1 names = install_obsidian(obsidian) if names: print(f"obsidian: {len(names)} vaults ({', '.join(names)})") if install_typora(typora): print("typora: udt.css installed (select it in Themes)") templates = REPO / "templates" if install_btop(templates / "btop" / "udt.theme"): print("btop: udt theme set (restart btop)") joplin = install_joplin(templates / "joplin" / "userchrome.css") if joplin: print("joplin: userchrome.css installed (restart Joplin)") elif joplin is False: print("joplin: userchrome.css is your own, left alone") if install_feishin(templates / "feishin" / "UDT.json"): print("feishin: UDT theme installed (select it once in Settings, it then follows live)") return 0 if __name__ == "__main__": sys.exit(main())