1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
|
#!/usr/bin/env python3
# udt-appthemes: install the generated app themes that need more than a copy.
# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
# 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())
|