Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
818c9f6af8
|
||
|
|
8a9384de15
|
||
|
|
8b141b9da0
|
||
|
|
dd0097a1f8
|
@@ -49,3 +49,11 @@ alias offline := switch-offline
|
|||||||
@fmt:
|
@fmt:
|
||||||
echo "Formatting files..."
|
echo "Formatting files..."
|
||||||
nix fmt
|
nix fmt
|
||||||
|
|
||||||
|
@sync-noctalia:
|
||||||
|
echo "Folding noctalia GUI overrides back into the repo..."
|
||||||
|
python3 scripts/sync-noctalia.py --apply
|
||||||
|
|
||||||
|
@sync-noctalia-preview:
|
||||||
|
echo "Previewing noctalia config sync (no changes)..."
|
||||||
|
python3 scripts/sync-noctalia.py
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ symbol = " "
|
|||||||
symbol = " "
|
symbol = " "
|
||||||
|
|
||||||
[git_commit]
|
[git_commit]
|
||||||
tag_symbol = ' '
|
tag_symbol = " "
|
||||||
|
|
||||||
[golang]
|
[golang]
|
||||||
symbol = " "
|
symbol = " "
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ local servers = {
|
|||||||
java_language_server = {},
|
java_language_server = {},
|
||||||
jsonls = {},
|
jsonls = {},
|
||||||
pyrefly = {},
|
pyrefly = {},
|
||||||
|
ruff = {},
|
||||||
-- taplo = {},
|
-- taplo = {},
|
||||||
tombi = {},
|
tombi = {},
|
||||||
ts_ls = {},
|
ts_ls = {},
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ in
|
|||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
config = lib.mkIf cfg.enable {
|
||||||
my.hm.home.packages = with pkgs; [
|
my.hm.home.packages = with pkgs; [
|
||||||
python3
|
(python3.withPackages (
|
||||||
|
ps: with ps; [
|
||||||
|
tomli
|
||||||
|
tomli-w
|
||||||
|
]
|
||||||
|
))
|
||||||
uv
|
uv
|
||||||
pyrefly
|
pyrefly
|
||||||
|
ruff
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,44 +6,44 @@
|
|||||||
|
|
||||||
# Default colors
|
# Default colors
|
||||||
[colors.primary]
|
[colors.primary]
|
||||||
background = '#24283b'
|
background = "#24283b"
|
||||||
foreground = '#c0caf5'
|
foreground = "#c0caf5"
|
||||||
|
|
||||||
[colors.cursor]
|
[colors.cursor]
|
||||||
cursor = '#c0caf5'
|
cursor = "#c0caf5"
|
||||||
text = '#24283b'
|
text = "#24283b"
|
||||||
|
|
||||||
[colors.selection]
|
[colors.selection]
|
||||||
text = '#c0caf5'
|
text = "#c0caf5"
|
||||||
background = '#2e3c64'
|
background = "#2e3c64"
|
||||||
|
|
||||||
# Normal colors
|
# Normal colors
|
||||||
[colors.normal]
|
[colors.normal]
|
||||||
black = '#1d202f'
|
black = "#1d202f"
|
||||||
red = '#f7768e'
|
red = "#f7768e"
|
||||||
green = '#9ece6a'
|
green = "#9ece6a"
|
||||||
yellow = '#e0af68'
|
yellow = "#e0af68"
|
||||||
blue = '#7aa2f7'
|
blue = "#7aa2f7"
|
||||||
magenta = '#bb9af7'
|
magenta = "#bb9af7"
|
||||||
cyan = '#7dcfff'
|
cyan = "#7dcfff"
|
||||||
white = '#a9b1d6'
|
white = "#a9b1d6"
|
||||||
|
|
||||||
# Bright colors
|
# Bright colors
|
||||||
[colors.bright]
|
[colors.bright]
|
||||||
black = '#414868'
|
black = "#414868"
|
||||||
red = '#ff899d'
|
red = "#ff899d"
|
||||||
green = '#9fe044'
|
green = "#9fe044"
|
||||||
yellow = '#faba4a'
|
yellow = "#faba4a"
|
||||||
blue = '#8db0ff'
|
blue = "#8db0ff"
|
||||||
magenta = '#c7a9ff'
|
magenta = "#c7a9ff"
|
||||||
cyan = '#a4daff'
|
cyan = "#a4daff"
|
||||||
white = '#c0caf5'
|
white = "#c0caf5"
|
||||||
|
|
||||||
# Indexed Colors
|
# Indexed Colors
|
||||||
[[colors.indexed_colors]]
|
[[colors.indexed_colors]]
|
||||||
index = 16
|
index = 16
|
||||||
color = '#ff9e64'
|
color = "#ff9e64"
|
||||||
|
|
||||||
[[colors.indexed_colors]]
|
[[colors.indexed_colors]]
|
||||||
index = 17
|
index = 17
|
||||||
color = '#db4b4b'
|
color = "#db4b4b"
|
||||||
|
|||||||
@@ -1,14 +1,34 @@
|
|||||||
[bar.main]
|
[bar.main]
|
||||||
capsule = false
|
capsule = false
|
||||||
capsule_thickness = 0.75
|
capsule_thickness = 0.75
|
||||||
center = ["media"]
|
center = [
|
||||||
end = ["notifications", "Spacer-12", "tray", "Spacer-12", "output_volume", "input_volume"]
|
"media",
|
||||||
|
]
|
||||||
|
end = [
|
||||||
|
"notifications",
|
||||||
|
"Spacer-12",
|
||||||
|
"tray",
|
||||||
|
"Spacer-12",
|
||||||
|
"output_volume",
|
||||||
|
"input_volume",
|
||||||
|
]
|
||||||
font_family = "monospace"
|
font_family = "monospace"
|
||||||
margin_edge = 6
|
margin_edge = 11
|
||||||
margin_ends = 10
|
margin_ends = 15
|
||||||
padding = 12
|
padding = 10
|
||||||
position = "top"
|
position = "top"
|
||||||
radius = 16
|
radius = 16
|
||||||
scale = 1.1
|
scale = 1.15
|
||||||
shadow = false
|
shadow = false
|
||||||
start = ["control-center", "Spacer-12", "clock", "Spacer-12", "sysmon_cpu", "sysmon_ram", "battery", "Spacer-12", "workspaces"]
|
start = [
|
||||||
|
"control-center",
|
||||||
|
"Spacer-12",
|
||||||
|
"clock",
|
||||||
|
"Spacer-12",
|
||||||
|
"sysmon_cpu",
|
||||||
|
"sysmon_ram",
|
||||||
|
"battery",
|
||||||
|
"Spacer-12",
|
||||||
|
"workspaces",
|
||||||
|
]
|
||||||
|
thickness = 38
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ wallpaper = "~/workspace/nixos-dotfiles/assets/wallpaper.png"
|
|||||||
enabled = true
|
enabled = true
|
||||||
schema_version = 2
|
schema_version = 2
|
||||||
widget_order = [
|
widget_order = [
|
||||||
"lockscreen-login-box@DP-1",
|
"lockscreen-login-box@DP-1",
|
||||||
"lockscreen-login-box@DP-2",
|
"lockscreen-login-box@DP-2",
|
||||||
"lockscreen-widget-clock@DP-1",
|
"lockscreen-widget-clock@DP-1",
|
||||||
"lockscreen-widget-clock@DP-2",
|
"lockscreen-widget-clock@DP-2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[lockscreen_widgets.grid]
|
[lockscreen_widgets.grid]
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ telemetry_enabled = false
|
|||||||
|
|
||||||
clipboard_enabled = true
|
clipboard_enabled = true
|
||||||
clipboard_history_max_entries = 1000
|
clipboard_history_max_entries = 1000
|
||||||
key_source = "secret-service"
|
|
||||||
|
|
||||||
[shell.animation]
|
[shell.animation]
|
||||||
speed = 0.8
|
speed = 0.8
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ show_label = false
|
|||||||
|
|
||||||
[widget.media]
|
[widget.media]
|
||||||
art_size = 30
|
art_size = 30
|
||||||
font_scale = 0.85
|
font_scale = 0.84
|
||||||
title_scroll = "always"
|
title_scroll = "always"
|
||||||
hide_when_no_media = true
|
hide_when_no_media = true
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ type = "volume"
|
|||||||
[widget.notifications]
|
[widget.notifications]
|
||||||
capsule_radius = "auto"
|
capsule_radius = "auto"
|
||||||
hide_when_no_unread = false
|
hide_when_no_unread = false
|
||||||
scale = 1.15
|
scale = 1.05
|
||||||
|
|
||||||
[widget.output_volume]
|
[widget.output_volume]
|
||||||
show_label = false
|
show_label = false
|
||||||
|
|||||||
@@ -35,6 +35,8 @@
|
|||||||
# GVFS for virtual filesystems
|
# GVFS for virtual filesystems
|
||||||
services.gvfs.enable = true;
|
services.gvfs.enable = true;
|
||||||
|
|
||||||
|
services.speechd.enable = false;
|
||||||
|
|
||||||
# Enable desktop-related modules by default
|
# Enable desktop-related modules by default
|
||||||
my = {
|
my = {
|
||||||
audio.enable = true;
|
audio.enable = true;
|
||||||
|
|||||||
Executable
+488
@@ -0,0 +1,488 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fold noctalia GUI overrides back into the declarative dotfiles config.
|
||||||
|
|
||||||
|
This script folds `noctalia config export merged` back into the per-aspect
|
||||||
|
*.toml files so the repo alone reproduces the current effective config:
|
||||||
|
|
||||||
|
* export values win (added if missing, updated if changed),
|
||||||
|
* repo-only keys and empty-table declarations are never deleted,
|
||||||
|
* f32 serialization noise (0.74999998696148396 vs 0.75) is ignored,
|
||||||
|
* floats are written back clean (0.8400000000000001 -> 0.84),
|
||||||
|
* comments (tombi directives) are preserved across the rewrite.
|
||||||
|
|
||||||
|
Files are parsed with `tomli` and rewritten with `tomli_w`, then touched
|
||||||
|
files are re-formatted with `tombi` to match repo style. Note that `tomli_w`
|
||||||
|
renders every non-empty array on multiple lines and `tombi` preserves that
|
||||||
|
layout, so arrays in a folded file are reflowed from inline to multiline the
|
||||||
|
first time that file is touched.
|
||||||
|
|
||||||
|
After a successful fold the GUI override file is moved to
|
||||||
|
<state-dir>/settings.bak.d/<timestamp>.toml (the 3 most recent are kept) and
|
||||||
|
the running instance is reloaded.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
sync-noctalia.py # preview the fold; change nothing
|
||||||
|
sync-noctalia.py --apply # apply, backup settings.toml, reload
|
||||||
|
sync-noctalia.py --export-file <file> # read the export from F instead of noctalia
|
||||||
|
sync-noctalia.py --config-dir <dir> # target a different config dir
|
||||||
|
sync-noctalia.py --state-dir <dir> # state dir holding settings.toml
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import CalledProcessError
|
||||||
|
|
||||||
|
import tomli
|
||||||
|
import tomli_w
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parent.parent
|
||||||
|
DEFAULT_CONFIG_DIR = REPO / "modules" / "desktop" / "wm" / "niri" / "noctalia"
|
||||||
|
DEFAULT_STATE_DIR = Path.home() / ".local" / "state" / "noctalia"
|
||||||
|
KEEP_BACKUPS = 3
|
||||||
|
FLOAT_TOL = 1e-4
|
||||||
|
FLOAT_PRECISION = 6
|
||||||
|
|
||||||
|
|
||||||
|
def parse_export(export_file: Path | None) -> dict:
|
||||||
|
if export_file:
|
||||||
|
return tomli.loads(export_file.read_text())
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["noctalia", "config", "export", "merged"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
sys.exit("error: `noctalia` not found on PATH")
|
||||||
|
except CalledProcessError as err:
|
||||||
|
sys.exit(f"error: `noctalia config export merged` failed:\n{err.stderr}")
|
||||||
|
return tomli.loads(proc.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def values_differ(a, b) -> bool:
|
||||||
|
"""True if two values differ as config values (float-tolerant, recursive)."""
|
||||||
|
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
||||||
|
return not math.isclose(float(a), float(b), rel_tol=0.0, abs_tol=FLOAT_TOL)
|
||||||
|
if isinstance(a, list) and isinstance(b, list):
|
||||||
|
return len(a) != len(b) or any(values_differ(x, y) for x, y in zip(a, b))
|
||||||
|
if isinstance(a, dict) and isinstance(b, dict):
|
||||||
|
return set(a) != set(b) or any(values_differ(a[k], b[k]) for k in a)
|
||||||
|
return a != b
|
||||||
|
|
||||||
|
|
||||||
|
def collect_changes(repo_sub, exp_sub, prefix, changes):
|
||||||
|
"""Recursively diff export subtree against repo subtree, appending
|
||||||
|
(path, old_value, new_value) tuples. New subtrees carry old_value=None
|
||||||
|
and a dict new_value."""
|
||||||
|
for k, v in exp_sub.items():
|
||||||
|
path = prefix + [k]
|
||||||
|
if isinstance(v, dict):
|
||||||
|
cur = repo_sub.get(k)
|
||||||
|
if isinstance(cur, dict):
|
||||||
|
collect_changes(cur, v, path, changes)
|
||||||
|
else:
|
||||||
|
changes.append((path, cur, v))
|
||||||
|
else:
|
||||||
|
cur = repo_sub.get(k)
|
||||||
|
if cur is None or values_differ(cur, v):
|
||||||
|
changes.append((path, cur, v))
|
||||||
|
|
||||||
|
|
||||||
|
def clean_value(v):
|
||||||
|
"""Round floats to FLOAT_PRECISION places, recursively.
|
||||||
|
|
||||||
|
tomli_w serialises floats with str(), which would keep f32 noise like
|
||||||
|
0.74999998696148396 written by a float slider; rounding keeps written
|
||||||
|
values clean without losing precision we care about.
|
||||||
|
"""
|
||||||
|
if isinstance(v, float):
|
||||||
|
return round(v, FLOAT_PRECISION)
|
||||||
|
if isinstance(v, list):
|
||||||
|
return [clean_value(x) for x in v]
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return {k: clean_value(x) for k, x in v.items()}
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# Preview rendering (display only; file I/O goes through tomli_w)
|
||||||
|
|
||||||
|
|
||||||
|
def _dotted(segments) -> str:
|
||||||
|
"""Render segments as a TOML dotted path, quoting non-bare segments."""
|
||||||
|
out = []
|
||||||
|
for s in segments:
|
||||||
|
if not s or not all(c.isalnum() or c in "_-" for c in s):
|
||||||
|
out.append('"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"')
|
||||||
|
else:
|
||||||
|
out.append(s)
|
||||||
|
return ".".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_float(v: float) -> str:
|
||||||
|
s = f"{v:.6f}".rstrip("0").rstrip(".")
|
||||||
|
return s + ".0" if "." not in s else s
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_value(v, indent: str = "") -> str:
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return "true" if v else "false"
|
||||||
|
if isinstance(v, int):
|
||||||
|
return str(v)
|
||||||
|
if isinstance(v, float):
|
||||||
|
return _clean_float(v)
|
||||||
|
if isinstance(v, str):
|
||||||
|
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||||
|
if isinstance(v, list):
|
||||||
|
return "[" + ", ".join(serialize_value(e, indent) for e in v) + "]"
|
||||||
|
if isinstance(v, dict):
|
||||||
|
inner = ", ".join(
|
||||||
|
f"{_dotted([k])} = {serialize_value(x, '')}" for k, x in v.items()
|
||||||
|
)
|
||||||
|
return "{ " + inner + " }"
|
||||||
|
raise TypeError(f"unsupported TOML value: {v!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_subtree(path, subtree) -> str:
|
||||||
|
"""Serialize a dict as a TOML section block: headers followed by keys."""
|
||||||
|
out = []
|
||||||
|
|
||||||
|
def walk(p, d):
|
||||||
|
out.append(f"[{_dotted(p)}]\n")
|
||||||
|
for k, v in d.items():
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
out.append(f"{k} = {serialize_value(v)}\n")
|
||||||
|
for k, v in d.items():
|
||||||
|
if isinstance(v, dict):
|
||||||
|
walk(p + [k], v)
|
||||||
|
|
||||||
|
walk(path, subtree)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
# Comment preservation
|
||||||
|
|
||||||
|
|
||||||
|
def extract_comment_blocks(text: str) -> list[tuple[list[str], str | None]]:
|
||||||
|
"""Contiguous comment lines, each paired with the table header that follows
|
||||||
|
them (or None for a trailing run). tomli_w drops comments when rewriting a
|
||||||
|
file, so they are re-anchored from the original text."""
|
||||||
|
lines = text.splitlines()
|
||||||
|
runs = []
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
if lines[i].lstrip().startswith("#"):
|
||||||
|
j = i
|
||||||
|
while j < len(lines) and lines[j].lstrip().startswith("#"):
|
||||||
|
j += 1
|
||||||
|
runs.append((i, j))
|
||||||
|
i = j
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
blocks = []
|
||||||
|
for start, end in runs:
|
||||||
|
anchor = None
|
||||||
|
for k in range(end, len(lines)):
|
||||||
|
t = lines[k].strip()
|
||||||
|
if t.startswith("[") and t.endswith("]"):
|
||||||
|
anchor = t
|
||||||
|
break
|
||||||
|
blocks.append((lines[start:end], anchor))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def restore_comments(original_text: str, rendered_text: str) -> str:
|
||||||
|
"""Re-insert the original file's comments into tomli_w's output, before the
|
||||||
|
table header each comment preceded (any stragglers go at EOF)."""
|
||||||
|
blocks = extract_comment_blocks(original_text)
|
||||||
|
if not blocks:
|
||||||
|
return rendered_text
|
||||||
|
pending = list(blocks)
|
||||||
|
out = []
|
||||||
|
for line in rendered_text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("[") and stripped.endswith("]"):
|
||||||
|
for block, anchor in list(pending):
|
||||||
|
if anchor == stripped:
|
||||||
|
out.extend(block)
|
||||||
|
pending.remove((block, anchor))
|
||||||
|
out.append(line)
|
||||||
|
for block, _anchor in pending:
|
||||||
|
out.extend(block)
|
||||||
|
return "\n".join(out) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
# Dict-based edits
|
||||||
|
|
||||||
|
|
||||||
|
def apply_path(data: dict, path, value, *, as_table: bool, old):
|
||||||
|
"""Set ``path`` to ``value`` in ``data``, creating intermediate tables.
|
||||||
|
|
||||||
|
Leaf values are assigned in place (a new key appends at the end of its
|
||||||
|
table). Sub-tables are inserted at their sorted position among sibling
|
||||||
|
sub-tables, matching the repo's alphabetical `[section]` order; a scalar
|
||||||
|
being replaced by a table is dropped first.
|
||||||
|
"""
|
||||||
|
node = data
|
||||||
|
for seg in path[:-1]:
|
||||||
|
nxt = node.get(seg)
|
||||||
|
if not isinstance(nxt, dict):
|
||||||
|
nxt = {}
|
||||||
|
node[seg] = nxt
|
||||||
|
node = nxt
|
||||||
|
key = path[-1]
|
||||||
|
if as_table:
|
||||||
|
if old is not None:
|
||||||
|
node.pop(key, None)
|
||||||
|
items = list(node.items())
|
||||||
|
pos = len(items)
|
||||||
|
for i, (k, v) in enumerate(items):
|
||||||
|
if isinstance(v, dict) and k > key:
|
||||||
|
pos = i
|
||||||
|
break
|
||||||
|
items.insert(pos, (key, value))
|
||||||
|
node.clear()
|
||||||
|
node.update(items)
|
||||||
|
else:
|
||||||
|
node[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
# Verification and state handling
|
||||||
|
|
||||||
|
|
||||||
|
def completeness_diffs(export, repo_after) -> list:
|
||||||
|
"""Leaf paths where repo_after still differs from the export."""
|
||||||
|
diffs = []
|
||||||
|
|
||||||
|
def walk(e, r, prefix):
|
||||||
|
for k, v in e.items():
|
||||||
|
p = prefix + [k]
|
||||||
|
if isinstance(v, dict):
|
||||||
|
if not isinstance(r.get(k), dict):
|
||||||
|
diffs.append((p, None, v))
|
||||||
|
else:
|
||||||
|
walk(v, r[k], p)
|
||||||
|
else:
|
||||||
|
cur = r.get(k)
|
||||||
|
if cur is None or values_differ(cur, v):
|
||||||
|
diffs.append((p, cur, v))
|
||||||
|
|
||||||
|
walk(export, repo_after, [])
|
||||||
|
return diffs
|
||||||
|
|
||||||
|
|
||||||
|
def run_validate(config_dir: Path) -> bool:
|
||||||
|
print(f" validate: noctalia config validate {config_dir}")
|
||||||
|
proc = subprocess.run(
|
||||||
|
["noctalia", "config", "validate", str(config_dir)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
out = proc.stdout + proc.stderr
|
||||||
|
for line in out.splitlines():
|
||||||
|
print(f" {line}")
|
||||||
|
if "WARN" in out:
|
||||||
|
print(
|
||||||
|
" note: confirm none of the warnings above were newly introduced by this fold"
|
||||||
|
)
|
||||||
|
return proc.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_tombi(files) -> None:
|
||||||
|
for f in files:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["tombi", "format", str(f)], capture_output=True, text=True, check=False
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(f" tombi format {f.name}: FAILED\n{proc.stdout}{proc.stderr}")
|
||||||
|
|
||||||
|
|
||||||
|
def backup_settings(state_dir: Path, keep: int = KEEP_BACKUPS):
|
||||||
|
src = state_dir / "settings.toml"
|
||||||
|
if not src.exists():
|
||||||
|
return None
|
||||||
|
bak = state_dir / "settings.bak.d"
|
||||||
|
bak.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||||
|
dest = bak / f"{ts}.toml"
|
||||||
|
n = 1
|
||||||
|
while dest.exists():
|
||||||
|
dest = bak / f"{ts}-{n}.toml"
|
||||||
|
n += 1
|
||||||
|
shutil.move(str(src), str(dest))
|
||||||
|
# Prune oldest backups, counting only timestamp-shaped files we own.
|
||||||
|
backups = sorted(
|
||||||
|
f for f in bak.glob("*.toml") if re.fullmatch(r"\d{8}-\d{6}(?:-\d+)?", f.stem)
|
||||||
|
)
|
||||||
|
for old in backups[:-keep]:
|
||||||
|
old.unlink()
|
||||||
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
def reload_instance():
|
||||||
|
proc = subprocess.run(
|
||||||
|
["noctalia", "msg", "config-reload"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode == 0:
|
||||||
|
print(" reload: noctalia msg config-reload")
|
||||||
|
else:
|
||||||
|
print(" reload: noctalia not running or reload failed")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--apply", action="store_true", help="apply the fold (default: preview only)"
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--config-dir", type=Path, default=DEFAULT_CONFIG_DIR, help=argparse.SUPPRESS
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--state-dir", type=Path, default=DEFAULT_STATE_DIR, help=argparse.SUPPRESS
|
||||||
|
)
|
||||||
|
ap.add_argument("--export-file", type=Path, default=None, help=argparse.SUPPRESS)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
config_dir = args.config_dir.resolve()
|
||||||
|
state_dir = args.state_dir
|
||||||
|
if not config_dir.is_dir():
|
||||||
|
sys.exit(f"error: config dir not found: {config_dir}")
|
||||||
|
|
||||||
|
if not args.export_file:
|
||||||
|
live = Path(os.path.realpath(Path.home() / ".config" / "noctalia"))
|
||||||
|
if live != config_dir:
|
||||||
|
print(
|
||||||
|
f"warning: ~/.config/noctalia is not linked to {config_dir} (found {live});"
|
||||||
|
)
|
||||||
|
print(" the live export may describe a different config")
|
||||||
|
|
||||||
|
export = parse_export(args.export_file)
|
||||||
|
|
||||||
|
# Parse per-aspect files; map each top-level root to its owning file.
|
||||||
|
files = sorted(config_dir.glob("*.toml"))
|
||||||
|
texts = {}
|
||||||
|
parsed = {}
|
||||||
|
for f in files:
|
||||||
|
text = f.read_text()
|
||||||
|
texts[f] = text
|
||||||
|
parsed[f] = tomli.loads(text)
|
||||||
|
root_map = {}
|
||||||
|
for f, d in parsed.items():
|
||||||
|
for root in d:
|
||||||
|
root_map.setdefault(root, f)
|
||||||
|
|
||||||
|
# Collect every change the export implies relative to the repo.
|
||||||
|
changes = []
|
||||||
|
new_files = {}
|
||||||
|
for root, subtree in export.items():
|
||||||
|
if not isinstance(subtree, dict):
|
||||||
|
continue # do not fold top-level scalars
|
||||||
|
tgt = root_map.get(root)
|
||||||
|
if tgt is None:
|
||||||
|
new_files[root] = subtree
|
||||||
|
continue
|
||||||
|
cur = parsed[tgt].get(root)
|
||||||
|
collect_changes(cur if isinstance(cur, dict) else {}, subtree, [root], changes)
|
||||||
|
|
||||||
|
sets_by_file, subs_by_file = {}, {}
|
||||||
|
for path, old, val in changes:
|
||||||
|
f = root_map[path[0]]
|
||||||
|
if isinstance(val, dict): # new subtree
|
||||||
|
subs_by_file.setdefault(f, []).append((path, old, val))
|
||||||
|
else:
|
||||||
|
sets_by_file.setdefault(f, []).append((path, old, val))
|
||||||
|
|
||||||
|
if not changes:
|
||||||
|
print("No changes to fold. Repo config already matches the export.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Preview
|
||||||
|
touched = set(sets_by_file) | set(subs_by_file)
|
||||||
|
for f in sorted(touched, key=str):
|
||||||
|
rel = os.path.relpath(f, REPO)
|
||||||
|
print(f"\n{rel}")
|
||||||
|
for path, old, val in sorted(
|
||||||
|
sets_by_file.get(f, []), key=lambda c: _dotted(c[0])
|
||||||
|
):
|
||||||
|
key = _dotted(path)
|
||||||
|
if old is None:
|
||||||
|
print(f" + {key} = {serialize_value(val)}")
|
||||||
|
else:
|
||||||
|
print(f" ~ {key}: {serialize_value(old)} -> {serialize_value(val)}")
|
||||||
|
for path, old, subtree in subs_by_file.get(f, []):
|
||||||
|
print(f" + [{_dotted(path)}]")
|
||||||
|
for line in serialize_subtree(path, subtree).splitlines():
|
||||||
|
if line.strip():
|
||||||
|
print(f" {line}")
|
||||||
|
for root in new_files:
|
||||||
|
print(
|
||||||
|
f"\n + new section root: {root} (new file {root.replace('_', '-')}.toml)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not args.apply:
|
||||||
|
print(
|
||||||
|
f"\n{len(changes)} change{'s' if len(changes) > 1 else ''} in {len(touched)} file(s)."
|
||||||
|
)
|
||||||
|
print("Run with --apply to fold into the repo.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Apply
|
||||||
|
for f in sorted(touched, key=str):
|
||||||
|
data = parsed[f]
|
||||||
|
for path, old, val in sets_by_file.get(f, []):
|
||||||
|
apply_path(data, path, clean_value(val), as_table=False, old=old)
|
||||||
|
for path, old, subtree in subs_by_file.get(f, []):
|
||||||
|
apply_path(data, path, clean_value(subtree), as_table=True, old=old)
|
||||||
|
f.write_text(restore_comments(texts[f], tomli_w.dumps(data)))
|
||||||
|
|
||||||
|
new_paths = []
|
||||||
|
for root, subtree in new_files.items():
|
||||||
|
p = config_dir / (root.replace("_", "-") + ".toml")
|
||||||
|
p.write_text(tomli_w.dumps(clean_value({root: subtree})))
|
||||||
|
new_paths.append(p)
|
||||||
|
files.append(p)
|
||||||
|
|
||||||
|
print(" formatting changed files with tombi...")
|
||||||
|
run_tombi(sorted(touched, key=str) + new_paths)
|
||||||
|
|
||||||
|
# Completeness: repo-as-loaded must now equal the export (within tolerance).
|
||||||
|
repo_after = {}
|
||||||
|
for f in files:
|
||||||
|
repo_after.update(tomli.loads(f.read_text()))
|
||||||
|
diffs = completeness_diffs(export, repo_after)
|
||||||
|
if diffs:
|
||||||
|
print("error: repo does not fully reproduce the export after applying:")
|
||||||
|
for p, old, val in diffs[:20]:
|
||||||
|
print(f" {_dotted(p)}: {serialize_value(old)} != {serialize_value(val)}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not run_validate(config_dir):
|
||||||
|
sys.exit("error: validation failed after applying changes")
|
||||||
|
|
||||||
|
bak = backup_settings(state_dir)
|
||||||
|
if bak:
|
||||||
|
print(f" backed up {state_dir}/settings.toml -> {bak}")
|
||||||
|
reload_instance()
|
||||||
|
else:
|
||||||
|
# Should not reach this branch, since `len(changes) > 0`, GUI overrides must exist.
|
||||||
|
print("warning: settings.toml does not exist. Nothing backuped")
|
||||||
|
|
||||||
|
print(f"\nDone. {len(changes)} change{'s' if len(changes) > 1 else ''} folded.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+21
-5
@@ -1,12 +1,28 @@
|
|||||||
{ inputs, ... }:
|
{ inputs, ... }: {
|
||||||
{
|
imports = [
|
||||||
imports = [ inputs.treefmt.flakeModule ];
|
inputs.treefmt.flakeModule
|
||||||
perSystem.treefmt = {
|
];
|
||||||
|
perSystem.treefmt = { lib, pkgs, ... }: {
|
||||||
projectRootFile = "flake.nix";
|
projectRootFile = "flake.nix";
|
||||||
programs = {
|
programs = {
|
||||||
nixfmt.enable = true;
|
nixfmt.enable = true;
|
||||||
stylua.enable = true;
|
|
||||||
keep-sorted.enable = true;
|
keep-sorted.enable = true;
|
||||||
|
stylua.enable = true;
|
||||||
|
ruff.enable = true;
|
||||||
};
|
};
|
||||||
|
settings.formatter.tombi = {
|
||||||
|
command = "${pkgs.bash}/bin/bash";
|
||||||
|
options = [
|
||||||
|
"-euc"
|
||||||
|
''
|
||||||
|
for file in "$@"; do
|
||||||
|
${lib.getExe pkgs.tombi} fmt $file
|
||||||
|
done
|
||||||
|
''
|
||||||
|
"--"
|
||||||
|
];
|
||||||
|
includes = [ "*.toml" ];
|
||||||
|
};
|
||||||
|
settings.excludes = [ "secrets/*" ];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-9
@@ -1,18 +1,13 @@
|
|||||||
#:schema https://raw.githubusercontent.com/crate-ci/typos/refs/heads/master/config.schema.json
|
#:schema https://raw.githubusercontent.com/crate-ci/typos/refs/heads/master/config.schema.json
|
||||||
|
|
||||||
[files]
|
[files]
|
||||||
extend-exclude = [
|
extend-exclude = ["secrets"]
|
||||||
"secrets"
|
|
||||||
]
|
[default]
|
||||||
|
extend-ignore-identifiers-re = ["KEY_102ND", "VE_UE"]
|
||||||
|
|
||||||
[default.extend-words]
|
[default.extend-words]
|
||||||
# noice.nvim
|
# noice.nvim
|
||||||
noice = "noice"
|
noice = "noice"
|
||||||
enew = "enew"
|
enew = "enew"
|
||||||
iif = "iif"
|
iif = "iif"
|
||||||
|
|
||||||
[default]
|
|
||||||
extend-ignore-identifiers-re = [
|
|
||||||
"KEY_102ND",
|
|
||||||
"VE_UE"
|
|
||||||
]
|
|
||||||
|
|||||||
Reference in New Issue
Block a user