"""User configuration. Mirrors the shape used by the other tools in this tree: JSON under `~/.config//config.json`, colours as ANSI slot numbers written as strings, and a handful of UI toggles. Colour slots rather than hex, because the terminal decides what slot 8 looks like. This is user-editable configuration, deliberately separate from the AUR build state in `aur.py`: one is yours to edit by hand, the other is the tool's memory. """ from __future__ import annotations import json import os from dataclasses import asdict, dataclass, field from pathlib import Path APP = "updater" def config_dir() -> Path: base = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config") return Path(base) / APP def config_path() -> Path: return config_dir() / "config.json" @dataclass class Config: #: packages never updated by this tool, keyed by name ignored: list[str] = field(default_factory=list) #: advisory ids you have read and decided to live with. Acknowledge is not #: "fixed": most advisories on a rolling distribution have no fix available, #: so the useful action is to stop being told about the ones you have seen. acked: list[str] = field(default_factory=list) #: epoch of the newest news item you have read. Arch uses the news feed to #: announce upgrades that will not complete on their own, so this is the #: difference between "nothing new" and "you have not looked". news_seen: float = 0.0 #: ANSI slot numbers, as strings, matching theme.py's roles color_accent: str = "10" # bright green color_muted: str = "8" # bright black color_ok: str = "2" # green color_warn: str = "3" # yellow color_alert: str = "1" # red color_old: str = "1" color_new: str = "10" color_ver: str = "6" # cyan #: UI cursor_icon: str = "▸" hide_hints: bool = False show_vcs: bool = True #: how many rows a section shows before collapsing by default expand_threshold: int = 8 #: set when the file has been created or edited, so we can tell a first run @classmethod def load(cls, path: Path | None = None) -> Config: path = path or config_path() try: raw = json.loads(path.read_text()) except (OSError, ValueError): return cls() # A valid JSON document is not necessarily an object. `[]` in a # user-editable config crashed the program at startup, before any # command ran. if not isinstance(raw, dict): return cls() known = cls.__dataclass_fields__ return cls(**{k: v for k, v in raw.items() if k in known}) def save(self, path: Path | None = None) -> None: """Write atomically: a half-written config would lose your ignore list.""" path = path or config_path() path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".tmp") tmp.write_text(json.dumps(asdict(self), indent=2, sort_keys=True) + "\n") tmp.replace(path) # -- ignores ----------------------------------------------------------- def is_ignored(self, package: str) -> bool: return package in self.ignored def ignore(self, package: str) -> None: if package not in self.ignored: self.ignored.append(package) self.ignored.sort() def unignore(self, package: str) -> None: if package in self.ignored: self.ignored.remove(package) # -- advisory acknowledgements ---------------------------------------- def is_acked(self, advisory: str) -> bool: return advisory in self.acked def ack(self, advisory: str) -> None: if advisory not in self.acked: self.acked.append(advisory) self.acked.sort() def unack(self, advisory: str) -> None: if advisory in self.acked: self.acked.remove(advisory) def toggle_acked(self, advisory: str) -> bool: """Flip one advisory. Returns True if it is now acknowledged.""" if self.is_acked(advisory): self.unack(advisory) return False self.ack(advisory) return True def clear_acks(self) -> int: """Forget every acknowledgement. Returns how many there were.""" count = len(self.acked) self.acked = [] return count def toggle_ignored(self, package: str) -> bool: """Flip one package. Returns True if it is now ignored.""" if self.is_ignored(package): self.unignore(package) return False self.ignore(package) return True