diff --git a/flake.nix b/flake.nix index ff8b1f1..9bc814b 100644 --- a/flake.nix +++ b/flake.nix @@ -47,8 +47,8 @@ rc2nix = pkgs.writeShellApplication { name = "rc2nix"; - runtimeInputs = with pkgs; [ ruby ]; - text = ''ruby ${script/rc2nix.rb} "$@"''; + runtimeInputs = with pkgs; [ python3 ]; + text = ''python3 ${script/rc2nix.py} "$@"''; }; }); diff --git a/script/rc2nix.py b/script/rc2nix.py new file mode 100755 index 0000000..06485c6 --- /dev/null +++ b/script/rc2nix.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python + +################################################################################ +# +# This file is part of the package Plasma Manager. It is subject to +# the license terms in the LICENSE file found in the top-level +# directory of this distribution and at: +# +# https://github.com/nix-community/plasma-manager +# +# No part of this package, including this file, may be copied, +# modified, propagated, or distributed except according to the terms +# contained in the LICENSE file. +# +################################################################################ + +import os +import re +import sys +from pathlib import Path +from typing import List, Dict, Callable, Optional, Tuple + +# The root directory where configuration files are stored. +XDG_CONFIG_HOME: str = os.path.expanduser(os.getenv("XDG_CONFIG_HOME", "~/.config")) + +class Rc2Nix: + # Files that we'll scan by default. + KNOWN_FILES: List[str] = [os.path.join(XDG_CONFIG_HOME, f) for f in [ + "kcminputrc", + "kglobalshortcutsrc", + "kactivitymanagerdrc", + "ksplashrc", + "kwin_rules_dialogrc", + "kmixrc", + "kwalletrc", + "kgammarc", + "krunnerrc", + "klaunchrc", + "plasmanotifyrc", + "systemsettingsrc", + "kscreenlockerrc", + "kwinrulesrc", + "khotkeysrc", + "ksmserverrc", + "kded5rc", + "plasmarc", + "kwinrc", + "kdeglobals", + "baloofilerc", + "dolphinrc", + "klipperrc", + "plasma-localerc", + "kxkbrc", + "ffmpegthumbsrc", + "kservicemenurc", + "kiorc", + ]] + + class RcFile: + # Any group that matches a listed regular expression is blocked + GROUP_BLOCK_LIST: List[str] = [ + r"^(ConfigDialog|FileDialogSize|ViewPropertiesDialog|KPropertiesDialog)$", + r"^\$Version$", + r"^ColorEffects:", + r"^Colors:", + r"^DoNotDisturb$", + r"^LegacySession:", + r"^MainWindow$", + r"^PlasmaViews", + r"^ScreenConnectors$", + r"^Session:", + ] + + # Similar to the GROUP_BLOCK_LIST but for setting keys. + KEY_BLOCK_LIST: List[str] = [ + r"^activate widget \d+$", # Depends on state :( + r"^ColorScheme(Hash)?$", + r"^History Items", + r"^LookAndFeelPackage$", + r"^Recent (Files|URLs)", + r"^Theme$i", + r"^Version$", + r"State$", + r"Timestamp$", + ] + + # List of functions that get called with a group name and a key name. + BLOCK_LIST_LAMBDA: List[Callable[[str, str], bool]] = [ + lambda group, key: group == "org.kde.kdecoration2" and key == "library" + ] + + def __init__(self, file_name: str): + self.file_name: str = file_name + self.settings: Dict[str, Dict[str, str]] = {} + self.last_group: Optional[str] = None + + def parse(self): + + def is_group_line(line: str) -> bool: + return re.match(r'^\s*(\[[^\]]+\]){1,}\s*$', line) is not None + + def is_setting_line(line: str) -> bool: + return re.match(r'^\s*([^=]+)=?(.*)\s*$', line) is not None + + def parse_group(line: str) -> str: + return re.sub(r'\s*\[([^\]]+)\]\s*', r'\1/', line.replace("/", "\\\\/")).rstrip("/") + + def parse_setting(line: str) -> Tuple[str, str]: + match = re.match(r'^\s*([^=]+)=?(.*)\s*$', line) + if match: + return match.groups() #type: ignore + raise Exception(f"{self.file_name}: can't parse setting line: {line}") + + with open(self.file_name, 'r') as file: + for line in file: + line = line.strip() + if not line: + continue + if is_group_line(line): + self.last_group = parse_group(line) + elif is_setting_line(line): + key, val = parse_setting(line) + self.process_setting(key, val) + else: + raise Exception(f"{self.file_name}: can't parse line: {line}") + + def process_setting(self, key: str, val: str): + + def should_skip_group(group: str) -> bool: + return any(re.match(reg, group) for reg in self.GROUP_BLOCK_LIST) + + def should_skip_key(key: str) -> bool: + return any(re.match(reg, key) for reg in self.KEY_BLOCK_LIST) + + def should_skip_by_lambda(group: str, key: str) -> bool: + return any(fn(group, key) for fn in self.BLOCK_LIST_LAMBDA) + + key = key.strip() + val = val.strip() + + if self.last_group is None: + raise Exception(f"{self.file_name}: setting outside of group: {key}={val}") + + if should_skip_group(self.last_group) or should_skip_key(key) or should_skip_by_lambda(self.last_group, key): + return + + if self.last_group not in self.settings: + self.settings[self.last_group] = {} + self.settings[self.last_group][key] = val + + class App: + def __init__(self, args: List[str]): + self.files: List[str] = Rc2Nix.KNOWN_FILES.copy() + + def run(self): + settings: Dict[str, Dict[str, Dict[str, str]]] = {} + + for file in self.files: + if not os.path.exists(file): + continue + + rc = Rc2Nix.RcFile(file) + rc.parse() + + path = Path(file).relative_to(XDG_CONFIG_HOME) + settings[str(path)] = rc.settings + + self.print_output(settings) + + def print_output(self, settings: Dict[str, Dict[str, Dict[str, str]]]): + print("{") + print(" programs.plasma = {") + print(" enable = true;") + print(" shortcuts = {") + print(self.pp_shortcuts(settings.get("kglobalshortcutsrc", {}), 6)) + print(" };") + print(" configFile = {") + print(self.pp_settings(settings, 6)) + print(" };") + print(" };") + print("}") + + def pp_settings(self, settings: Dict[str, Dict[str, Dict[str, str]]], indent: int) -> str: + result : List[str] = [] + for file in sorted(settings.keys()): + if file != "kglobalshortcutsrc": + for group in sorted(settings[file].keys()): + for key in sorted(settings[file][group].keys()): + if key != "_k_friendly_name": + result.append(f"{' ' * indent}\"{file}\".\"{group}\".\"{key}\" = {nix_val(settings[file][group][key])};") + return "\n".join(result) + + def pp_shortcuts(self, groups: Dict[str, Dict[str, str]], indent: int) -> str: + if not groups: + return "" + + result : List[str] = [] + for group in sorted(groups.keys()): + for action in sorted(groups[group].keys()): + if action != "_k_friendly_name": + keys = groups[group][action].split(r'(? 1: + keys_str = f"[{' '.join(nix_val(k.rstrip(',')) for k in keys)}]" + else: + ks = keys[0].split(",") + k = ks[0] if len(ks) == 3 and ks[0] == ks[1] else keys[0] + keys_str = "[ ]" if k == "" or k == "none" else nix_val(k.rstrip(",")) + + result.append(f"{' ' * indent}\"{group}\".\"{action}\" = {keys_str};") + return "\n".join(result) + +def nix_val(s: Optional[str]) -> str: + if s is None: + return "null" + if re.match(r'^true|false$', s, re.IGNORECASE): + return s.lower() + if re.match(r'^[0-9]+(\.[0-9]+)?$', s): + return s + return '"' + re.sub(r'(? str: + return '\033[91m' + s + '\033[0m' + +def green(s: str) -> str: + return '\033[32m' + s + '\033[0m' + +def gray(s: str) -> str: + return '\033[90m' + s + '\033[0m' + +current_dir = os.path.dirname(os.path.abspath(__file__)) +def path(relative_path: str) -> str: + return os.path.abspath(os.path.join(current_dir, relative_path)) + +rc2nix_py = path("../../script/rc2nix.py") +rc2nix_rb = path("../../script/rc2nix.rb") + +class TestRc2nix (unittest.TestCase): + + def test(self): + def run_script(*command: str) -> str: + rst = subprocess.run(command, env={'XDG_CONFIG_HOME': path('./test_data'), 'PATH': os.environ["PATH"]}, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + print(red(rst.stderr)) + rst.check_returncode() + return rst.stdout + + rst_py = run_script(rc2nix_py) + rst_rb = run_script(rc2nix_rb) + + self.assertEqual(rst_py.splitlines(), rst_rb.splitlines()) + + +if __name__ == '__main__': # pragma: no cover + _ = unittest.main()