diff --git a/src/cthulhu/gsettings_migrator.py b/src/cthulhu/gsettings_migrator.py new file mode 100644 index 0000000..a2f9ff2 --- /dev/null +++ b/src/cthulhu/gsettings_migrator.py @@ -0,0 +1,462 @@ +# Cthulhu +# +# Copyright 2026 Igalia, S.L. +# Author: Joanmarie Diggs +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. + +"""Shared migration logic for converting between JSON settings and GSettings. + +This module has no imports of debug, settings_manager, or anything that pulls +in Atspi, so it can be used by both the cthulhu runtime and the standalone +gsettings_import_export tool. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from gi.repository import Gio, GLib + +VOICE_TYPES: list[str] = ["default", "uppercase", "hyperlink", "system"] + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +VOICE_MIGRATION_MAP: dict[str, tuple[str, str, Any]] = { + "established": ("established", "b", False), + "rate": ("rate", "i", 50), + "average-pitch": ("pitch", "d", 5.0), + "gain": ("volume", "d", 10.0), +} + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +VOICE_FAMILY_FIELDS: dict[str, str] = { + "name": "family-name", + "lang": "family-lang", + "dialect": "family-dialect", + "gender": "family-gender", + "variant": "family-variant", +} + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +LEGACY_KEY_ALIASES: dict[str, str] = { + "progressBarVerbosity": "progressBarSpeechVerbosity", + "progressBarUpdateInterval": "progressBarSpeechInterval", +} + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +REVERSE_LEGACY_KEY_ALIASES: dict[str, str] = {v: k for k, v in LEGACY_KEY_ALIASES.items()} + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +KEYBINDINGS_METADATA_KEYS: frozenset[str] = frozenset({"keyboardLayout", "cthulhuModifierKeys"}) + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +@dataclass +class SettingsMapping: + """Describes a mapping between a preferences key and a GSettings key.""" + + migration_key: str + gs_key: str + gtype: str # "b", "s", "i", "d", "as" + default: Any + enum_map: dict[int, str] | None = None + string_enum: bool = False + + +def sanitize_gsettings_path(name: str) -> str: + """Sanitize a name for use in a GSettings path.""" + + sanitized = name.lower() + sanitized = re.sub(r"[^a-z0-9-]", "-", sanitized) + sanitized = re.sub(r"-+", "-", sanitized) + return sanitized.strip("-") + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def resolve_enum_nick(value: Any, enum_map: dict[int, str]) -> str | None: + """Resolve a JSON enum value (string nick, int, or bool) to a GSettings enum nick.""" + + if isinstance(value, str): + return value if value in enum_map.values() else None + return enum_map.get(value) + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +_NAVIGATION_ENABLED_KEYS = ( + "caretNavigationEnabled", + "structuralNavigationEnabled", + "tableNavigationEnabled", +) + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def force_navigation_enabled(json_dict: dict) -> None: + """Force navigation-enabled keys to True during migration.""" + + # In the old system these defaulted to False because per-script logic controlled + # activation. In the new system they represent the user's preference and default + # to True. Migrating the old False would disable navigation for all scripts. + for key in _NAVIGATION_ENABLED_KEYS: + if key in json_dict: + json_dict[key] = True + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def fix_bool_enum_values(json_dict: dict) -> None: + """Fix enum settings stored as booleans instead of integers in JSON.""" + + # Some enum-typed settings are booleans in user JSON files instead of the + # expected integers. Python's bool-is-int means False==0 and True==1, + # which silently resolve to the wrong enum nick during migration. + bool_enum_fixes: dict[str, dict[bool, int]] = { + "findResultsVerbosity": {True: 2, False: 2}, # FIND_SPEAK_ALL + } + for key, mapping in bool_enum_fixes.items(): + if key in json_dict and isinstance(json_dict[key], bool): + json_dict[key] = mapping[json_dict[key]] + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def apply_legacy_aliases(json_dict: dict) -> None: + """Copy legacy key names to their modern equivalents if modern key is absent.""" + + for legacy_key, modern_key in LEGACY_KEY_ALIASES.items(): + if legacy_key in json_dict and modern_key not in json_dict: + json_dict[modern_key] = json_dict[legacy_key] + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def add_legacy_aliases(result: dict) -> None: + """Add legacy key names to exported dict for backwards compatibility with stable.""" + + for modern_key, legacy_key in REVERSE_LEGACY_KEY_ALIASES.items(): + if modern_key in result: + result[legacy_key] = result[modern_key] + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def hoist_keybindings_metadata(profile_prefs: dict) -> None: + """Move keyboardLayout and cthulhuModifierKeys from keybindings to profile level.""" + + keybindings = profile_prefs.get("keybindings", {}) + for key in KEYBINDINGS_METADATA_KEYS: + if key in keybindings and key not in profile_prefs: + profile_prefs[key] = keybindings[key] + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def sink_keybindings_metadata(profile_data: dict, keybindings: dict) -> None: + """Move keyboardLayout and cthulhuModifierKeys from profile level into keybindings.""" + + for key in KEYBINDINGS_METADATA_KEYS: + if key in profile_data: + keybindings[key] = profile_data.pop(key) + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +DESKTOP_MODIFIER_KEYS_DEFAULT: list[str] = ["Insert", "KP_Insert"] +LAPTOP_MODIFIER_KEYS_DEFAULT: list[str] = ["Caps_Lock", "Shift_Lock"] + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def populate_per_layout_modifier_keys( + gs: Gio.Settings, + profile_prefs: dict, + skip_defaults: bool, +) -> bool: + """Populates keyboard-layout and modifier keys from keybinding metadata during migration.""" + + layout = profile_prefs.get("keyboardLayout", 1) + is_desktop = layout == 1 + wrote_any = False + + layout_nick = "desktop" if is_desktop else "laptop" + if not skip_defaults or layout_nick != "desktop": + gs.set_string("keyboard-layout", layout_nick) + wrote_any = True + + modifier_keys = profile_prefs.get("cthulhuModifierKeys") + if modifier_keys is None: + return wrote_any + + if is_desktop: + key = "desktop-modifier-keys" + default = DESKTOP_MODIFIER_KEYS_DEFAULT + else: + key = "laptop-modifier-keys" + default = LAPTOP_MODIFIER_KEYS_DEFAULT + + if skip_defaults and modifier_keys == default: + return wrote_any + + gs.set_strv(key, modifier_keys) + return True + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def _write_one_mapping( + gs: Gio.Settings, + m: SettingsMapping, + value: Any, + skip_defaults: bool, +) -> bool: + """Write a single mapping value to GSettings. Returns True if written.""" + + if m.enum_map is not None: + nick = resolve_enum_nick(value, m.enum_map) + if nick is None: + return False + if skip_defaults and m.default in (nick, value): + return False + gs.set_string(m.gs_key, nick) + return True + + if skip_defaults and value == m.default: + return False + if m.gtype == "b": + gs.set_boolean(m.gs_key, value) + elif m.gtype == "s": + gs.set_string(m.gs_key, value) + elif m.gtype == "i": + gs.set_int(m.gs_key, int(value)) + elif m.gtype == "d": + gs.set_double(m.gs_key, float(value)) + elif m.gtype == "as": + gs.set_strv(m.gs_key, value) + else: + return False + return True + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def json_to_gsettings( + json_dict: dict, + gs: Gio.Settings, + mappings: list[SettingsMapping], + skip_defaults: bool = True, +) -> bool: + """Writes JSON settings to a Gio.Settings object. Returns True if any value was written.""" + + wrote_any = False + for m in mappings: + if m.migration_key not in json_dict: + continue + if _write_one_mapping(gs, m, json_dict[m.migration_key], skip_defaults): + wrote_any = True + return wrote_any + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def gsettings_to_json(gs: Gio.Settings, mappings: list[SettingsMapping]) -> dict: + """Reads explicitly-set GSettings values into a JSON-compatible dict.""" + + result: dict[str, Any] = {} + for m in mappings: + user_value = gs.get_user_value(m.gs_key) + if user_value is None: + continue + if m.enum_map is not None: + gs_str = user_value.get_string() + if m.string_enum: + result[m.migration_key] = gs_str + else: + reverse_map = {v: k for k, v in m.enum_map.items()} + json_value = reverse_map.get(gs_str) + if json_value is not None: + result[m.migration_key] = json_value + elif m.gtype == "b": + result[m.migration_key] = user_value.get_boolean() + elif m.gtype == "s": + result[m.migration_key] = user_value.get_string() + elif m.gtype == "i": + result[m.migration_key] = user_value.get_int32() + elif m.gtype == "d": + result[m.migration_key] = user_value.get_double() + elif m.gtype == "as": + result[m.migration_key] = list(user_value.unpack()) + return result + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def import_voice(gs: Gio.Settings, voice_data: dict, skip_defaults: bool = True) -> bool: + """Import ACSS voice data to a Gio.Settings object. Returns True if any value was written.""" + + migrated = False + for acss_key, (gs_key, gs_type, default) in VOICE_MIGRATION_MAP.items(): + if acss_key not in voice_data: + continue + value = voice_data[acss_key] + if skip_defaults: + if gs_type == "b" and bool(value) == default: + continue + if gs_type == "i" and int(value) == default: + continue + if gs_type == "d" and float(value) == default: + continue + if gs_type == "b": + gs.set_boolean(gs_key, bool(value)) + elif gs_type == "i": + gs.set_int(gs_key, int(value)) + elif gs_type == "d": + gs.set_double(gs_key, float(value)) + migrated = True + + family = voice_data.get("family", {}) + if isinstance(family, dict): + for json_field, gs_key in VOICE_FAMILY_FIELDS.items(): + val = family.get(json_field) + if val is not None and str(val): + gs.set_string(gs_key, str(val)) + migrated = True + + return migrated + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def export_voice(gs: Gio.Settings) -> dict: + """Export voice settings from a Gio.Settings to ACSS format.""" + + voice_data: dict[str, Any] = {} + for acss_key, (gs_key, gs_type, _default) in VOICE_MIGRATION_MAP.items(): + user_value = gs.get_user_value(gs_key) + if user_value is None: + continue + if gs_type == "b": + voice_data[acss_key] = user_value.get_boolean() + elif gs_type == "i": + voice_data[acss_key] = user_value.get_int32() + elif gs_type == "d": + voice_data[acss_key] = user_value.get_double() + + family_dict: dict[str, str] = {} + for json_field, gs_key in VOICE_FAMILY_FIELDS.items(): + user_value = gs.get_user_value(gs_key) + if user_value is not None: + val = user_value.get_string() + if val: + family_dict[json_field] = val + if family_dict: + voice_data["family"] = family_dict + + return voice_data + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def import_synthesizer(gs: Gio.Settings, profile_prefs: dict) -> bool: + """Import speechServerInfo to GSettings. Returns True if written.""" + + speech_server_info = profile_prefs.get("speechServerInfo") + if speech_server_info is None or len(speech_server_info) < 2: + return False + server_name = speech_server_info[0] + synthesizer = speech_server_info[1] + if server_name is None and synthesizer is None: + return False + gs.set_string("speech-server", server_name or "") + gs.set_string("synthesizer", synthesizer or "") + return True + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def export_synthesizer(gs: Gio.Settings) -> tuple[str, str] | None: + """Export synthesizer from GSettings. Returns (display_name, module_id) or None.""" + + synth_value = gs.get_user_value("synthesizer") + if synth_value is None: + return None + synth = synth_value.get_string() + server_value = gs.get_user_value("speech-server") + server_name = server_value.get_string() if server_value is not None else "" + if synth: + return (server_name, synth) + return ("", "") + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def import_pronunciations(gs: Gio.Settings, pronunciations_dict: dict) -> bool: + """Import pronunciation dictionary to GSettings. Returns True if written. + + JSON format: {word: [word, replacement]} or {word: replacement} + GSettings format: a{ss} {word: replacement} + """ + + converted: dict[str, str] = {} + for key, value in pronunciations_dict.items(): + if isinstance(value, list) and len(value) >= 2: + converted[key] = value[1] + elif isinstance(value, list) and len(value) == 1: + converted[key] = value[0] + elif isinstance(value, str): + converted[key] = value + if not converted: + return False + + gs.set_value("entries", GLib.Variant("a{ss}", converted)) + return True + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def export_pronunciations(gs: Gio.Settings) -> dict: + """Export pronunciation dictionary from GSettings to JSON format. + + GSettings format: a{ss} {word: replacement} + JSON format: {word: [word, replacement]} + """ + + user_value = gs.get_user_value("entries") + if user_value is None: + return {} + entries = user_value.unpack() + result: dict[str, list[str]] = {} + for word, replacement in entries.items(): + result[word] = [word, replacement] + return result + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def import_keybindings(gs: Gio.Settings, keybindings_dict: dict) -> bool: + """Import keybinding overrides to GSettings. Returns True if written. + + JSON format: {command_name: [[keysym, mask, mods, clicks], ...]} + GSettings format: a{saas} (same structure, all strings) + """ + + converted: dict[str, list[list[str]]] = {} + for key, value in keybindings_dict.items(): + if key in KEYBINDINGS_METADATA_KEYS: + continue + if isinstance(value, list): + bindings: list[list[str]] = [ + [str(v) for v in binding] for binding in value if isinstance(binding, list) + ] + converted[key] = bindings + if not converted: + return False + + gs.set_value("entries", GLib.Variant("a{saas}", converted)) + return True + + +# TODO - JD: Delete this in v52 (remove -i/--import-dir support). +def export_keybindings(gs: Gio.Settings) -> dict: + """Export keybinding overrides from GSettings to JSON format.""" + + user_value = gs.get_user_value("entries") + if user_value is None: + return {} + return user_value.unpack() diff --git a/src/cthulhu/gsettings_registry.py b/src/cthulhu/gsettings_registry.py new file mode 100644 index 0000000..c7e9e56 --- /dev/null +++ b/src/cthulhu/gsettings_registry.py @@ -0,0 +1,546 @@ +# Cthulhu +# +# Copyright 2026 Igalia, S.L. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. + +"""TOML-backed settings registry with the Orca 50 GSettings API shape.""" + +from __future__ import annotations + +import os +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any, overload + +from tomlkit import dumps, document, parse + +from . import debug, gsettings_migrator + + +CTHULHU_LEGACY_SCHEMA_ALIASES: dict[str, tuple[str, str]] = { + "aiAssistantEnabled": ("ai-assistant", "enabled"), + "aiProvider": ("ai-assistant", "provider"), + "aiApiKeyFile": ("ai-assistant", "api-key-file"), + "aiOllamaModel": ("ai-assistant", "ollama-model"), + "aiOllamaEndpoint": ("ai-assistant", "ollama-endpoint"), + "aiConfirmationRequired": ("ai-assistant", "confirmation-required"), + "aiActionTimeout": ("ai-assistant", "action-timeout"), + "aiScreenshotQuality": ("ai-assistant", "screenshot-quality"), + "aiMaxContextLength": ("ai-assistant", "max-context-length"), + "ocrLanguageCode": ("ocr", "language-code"), + "ocrScaleFactor": ("ocr", "scale-factor"), + "ocrGrayscaleImg": ("ocr", "grayscale-image"), + "ocrInvertImg": ("ocr", "invert-image"), + "ocrBlackWhiteImg": ("ocr", "black-white-image"), + "ocrBlackWhiteImgValue": ("ocr", "black-white-threshold"), + "ocrColorCalculation": ("ocr", "color-calculation"), + "ocrColorCalculationMax": ("ocr", "color-calculation-max"), + "ocrCopyToClipboard": ("ocr", "copy-to-clipboard"), + "activePlugins": ("plugins", "active-plugins"), + "pluginSources": ("plugins", "plugin-sources"), +} + + +@dataclass +class SettingDescriptor: + """Describes a setting for registry metadata and runtime saving.""" + + gsettings_key: str + schema: str + gtype: str + default: Any + getter: Callable[[], Any] | None = None + voice_type: str | None = None + genum: str | None = None + migration_key: str | None = None + + +SettingsMapping = gsettings_migrator.SettingsMapping + +_NOT_SET = object() + + +def _is_table(value: object) -> bool: + return hasattr(value, "items") and hasattr(value, "__setitem__") + + +def _plain(value: Any) -> Any: + if _is_table(value): + return {key: _plain(item) for key, item in value.items()} + if isinstance(value, list): + return [_plain(item) for item in value] + return value + + +class GSettingsRegistry: + """Central registry facade backed by Cthulhu TOML settings files.""" + + def __init__(self) -> None: + self._app_name: str | None = None + self._profile: str = "default" + self._prefs_dir: Path | None = None + self._descriptors: dict[tuple[str, str], SettingDescriptor] = {} + self._mappings: dict[str, list[SettingsMapping]] = {} + self._enums: dict[str, dict[str, int]] = {} + self._schemas: dict[str, str] = {} + self._runtime_values: dict[tuple[str, str, str | None], Any] = {} + self._ignore_runtime: bool = False + + def set_ignore_runtime(self, ignore: bool) -> None: + """Sets whether layered_lookup should skip runtime overrides.""" + + self._ignore_runtime = ignore + + @staticmethod + def sanitize_gsettings_path(name: str) -> str: + """Sanitize a name for schema/path compatibility.""" + + return gsettings_migrator.sanitize_gsettings_path(name) + + def _settings_path(self, app_name: str = "") -> Path | None: + if self._prefs_dir is None: + return None + if app_name: + return self._prefs_dir / "app-settings" / f"{app_name}.toml" + return self._prefs_dir / "user-settings.toml" + + @staticmethod + def _read_document(path: Path | None): + if path is None or not path.exists(): + return document() + try: + return parse(path.read_text(encoding="utf-8")) + except Exception as error: + msg = f"GSETTINGS REGISTRY: Failed to read {path}: {error}" + debug.print_message(debug.LEVEL_WARNING, msg, True) + return document() + + @staticmethod + def _write_document(path: Path | None, prefs_doc) -> None: + if path is None: + return + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(dumps(prefs_doc), encoding="utf-8") + + @staticmethod + def _table(parent, key: str): + value = parent.get(key) + if not _is_table(value): + parent[key] = {} + value = parent[key] + return value + + def _ensure_profile(self, prefs_doc, profile: str, display_name: str | None = None): + prefs_doc["format-version"] = 2 + profiles = self._table(prefs_doc, "profiles") + profile_table = self._table(profiles, profile) + metadata = self._table(profile_table, "metadata") + metadata.setdefault("display-name", display_name or profile.replace("-", " ").title()) + metadata.setdefault("internal-name", profile) + return profile_table + + def _schema_table(self, prefs_doc, schema: str, profile: str): + profile_table = self._ensure_profile(prefs_doc, profile) + return self._table(profile_table, schema) + + def _lookup_in_document( + self, + prefs_doc, + schema: str, + key: str, + profile: str, + voice_type: str | None = None, + ) -> tuple[bool, Any]: + profiles = prefs_doc.get("profiles", {}) + if not _is_table(profiles): + return False, None + profile_table = profiles.get(profile, {}) + if not _is_table(profile_table): + return False, None + schema_table = profile_table.get(schema, {}) + if not _is_table(schema_table): + return False, None + if voice_type: + schema_table = schema_table.get(voice_type, {}) + if not _is_table(schema_table): + return False, None + if key not in schema_table: + return False, None + return True, _plain(schema_table[key]) + + @overload + def layered_lookup( + self, + schema: str, + key: str, + gtype: str, + genum: str | None = None, + voice_type: str | None = None, + app_name: str | None = None, + *, + default: Any, + ) -> Any: ... + + @overload + def layered_lookup( + self, + schema: str, + key: str, + gtype: str, + genum: str | None = None, + voice_type: str | None = None, + app_name: str | None = None, + ) -> Any | None: ... + + def layered_lookup( + self, + schema: str, + key: str, + gtype: str, + genum: str | None = None, + voice_type: str | None = None, + app_name: str | None = None, + default: Any = _NOT_SET, + ) -> Any | None: + """Returns a setting value via app, profile, default-profile, or default layers.""" + + del gtype, genum + if not self._ignore_runtime: + found, value = self.get_runtime_value(schema, key, voice_type) + if found: + return value + + profile = self.get_active_profile() + effective_app = self.get_active_app() if app_name is None else app_name + if effective_app: + prefs_doc = self._read_document(self._settings_path(effective_app)) + found, value = self._lookup_in_document(prefs_doc, schema, key, profile, voice_type) + if found: + return value + + prefs_doc = self._read_document(self._settings_path()) + found, value = self._lookup_in_document(prefs_doc, schema, key, profile, voice_type) + if found: + return value + if profile != "default": + found, value = self._lookup_in_document( + prefs_doc, + schema, + key, + "default", + voice_type, + ) + if found: + return value + if default is _NOT_SET: + return None + return default + + def set_runtime_value( + self, + schema: str, + key: str, + value: Any, + voice_type: str | None = None, + ) -> None: + """Stores a runtime value override.""" + + self._runtime_values[(schema, key, voice_type)] = value + + def get_runtime_value( + self, + schema: str, + key: str, + voice_type: str | None = None, + ) -> tuple[bool, Any]: + """Returns (found, value) for a runtime override.""" + + runtime_key = (schema, key, voice_type) + if runtime_key in self._runtime_values: + return True, self._runtime_values[runtime_key] + return False, None + + def remove_runtime_value(self, schema: str, key: str, voice_type: str | None = None) -> None: + """Removes a single runtime value override.""" + + self._runtime_values.pop((schema, key, voice_type), None) + + def clear_runtime_values(self) -> None: + """Clears all runtime value overrides.""" + + self._runtime_values.clear() + + def get_pronunciations(self, profile: str = "", app_name: str = "") -> dict: + """Returns pronunciation entries for a profile/app layer.""" + + return self._get_entries("pronunciations", profile, app_name) + + def get_keybindings(self, profile: str = "", app_name: str = "") -> dict: + """Returns keybinding entries for a profile/app layer.""" + + return self._get_entries("keybindings", profile, app_name) + + def _get_entries(self, schema: str, profile: str = "", app_name: str = "") -> dict: + if not profile: + profile = self._profile + prefs_doc = self._read_document(self._settings_path(app_name)) + found, value = self._lookup_in_document(prefs_doc, schema, "entries", profile) + if found and isinstance(value, dict): + return value + return {} + + def set_active_app(self, app_name: str | None) -> None: + """Sets the active app name for TOML lookups.""" + + self._app_name = app_name or None + + def set_active_profile(self, profile: str) -> None: + """Sets the active profile for TOML lookups.""" + + self._profile = profile or "default" + + def get_active_app(self) -> str | None: + """Returns the active app name for TOML lookups.""" + + return self._app_name + + def get_active_profile(self) -> str: + """Returns the active profile for TOML lookups.""" + + return self._profile + + def gsetting( + self, + key: str, + schema: str, + default: Any, + summary: str, + gtype: str = "", + genum: str | None = None, + voice_type: str | None = None, + migration_key: str | None = None, + ) -> Callable[[Callable], Callable]: + """Decorator marking a method's associated settings key.""" + + del summary + + def decorator(func: Callable) -> Callable: + self._descriptors[(schema, key)] = SettingDescriptor( + gsettings_key=key, + schema=schema, + gtype=gtype, + default=default, + getter=None, + voice_type=voice_type, + genum=genum, + migration_key=migration_key, + ) + func.gsetting_key = key # type: ignore[attr-defined] + return func + + return decorator + + def gsettings_schema(self, schema_id: str, name: str) -> Callable[[type], type]: + """Class decorator declaring this class contributes to a settings schema.""" + + self._schemas[name] = schema_id + + def decorator(cls: type) -> type: + return cls + + return decorator + + def gsettings_enum(self, enum_id: str, values: dict[str, int]) -> Callable[[type], type]: + """Decorator marking an enum class for schema metadata.""" + + self._enums[enum_id] = values + + def decorator(cls: type) -> type: + return cls + + return decorator + + def get_enum_values(self, enum_id: str) -> dict[str, int] | None: + """Returns the nick-to-int mapping for a registered enum.""" + + return self._enums.get(enum_id) + + def register_settings_mappings(self, schema_name: str, mappings: list[SettingsMapping]) -> None: + """Registers legacy-to-schema mappings for a schema.""" + + self._mappings[schema_name] = mappings + + def _build_mappings_from_descriptors(self, schema_name: str) -> list[SettingsMapping]: + mappings: list[SettingsMapping] = [] + for (schema, _key), desc in self._descriptors.items(): + if schema != schema_name or desc.migration_key is None: + continue + enum_map: dict[int, str] | None = None + if desc.genum and desc.genum in self._enums: + enum_map = {v: k for k, v in self._enums[desc.genum].items()} + mappings.append( + SettingsMapping( + desc.migration_key, + desc.gsettings_key, + desc.gtype, + desc.default, + enum_map, + ), + ) + return mappings + + def _get_settings_mappings(self, schema_name: str) -> list[SettingsMapping]: + if schema_name in self._mappings: + return self._mappings[schema_name] + return self._build_mappings_from_descriptors(schema_name) + + def get_schema_names(self) -> list[str]: + """Returns the registered schema names.""" + + return list(self._schemas.keys()) + + def get_settings( + self, + schema_name: str, + profile: str, + sub_path: str = "", + app_name: str = "", + ) -> None: + """Compatibility stub for callers that probe for Gio.Settings.""" + + del schema_name, profile, sub_path, app_name + return None + + def save_schema( + self, + schema_name: str, + settings: dict, + profile: str, + app_name: str = "", + skip_defaults: bool = False, + ) -> None: + """Writes one schema table to Cthulhu TOML settings.""" + + profile = profile or self.get_active_profile() + prefs_doc = self._read_document(self._settings_path(app_name)) + schema_table = self._schema_table(prefs_doc, schema_name, profile) + for key, value in settings.items(): + descriptor = self._descriptors.get((schema_name, key)) + if skip_defaults and descriptor is not None and value == descriptor.default: + schema_table.pop(key, None) + continue + schema_table[key] = value + self._write_document(self._settings_path(app_name), prefs_doc) + + def save_schema_to_gsettings( + self, + schema_name: str, + prefs_dict: dict, + profile: str, + app_name: str = "", + skip_defaults: bool = False, + ) -> None: + """Compatibility alias that writes mapped settings to TOML.""" + + settings: dict[str, Any] = {} + for mapping in self._get_settings_mappings(schema_name): + if mapping.migration_key in prefs_dict: + settings[mapping.gs_key] = prefs_dict[mapping.migration_key] + self.save_schema(schema_name, settings, profile, app_name, skip_defaults) + + def _migrate_legacy_modifier_keys(self, source: dict, target: dict) -> None: + layout = source.get("keyboardLayout", 1) + modifier_keys = source.get("cthulhuModifierKeys") + if layout == 2: + target["keyboard-layout"] = "laptop" + if modifier_keys is not None: + target["laptop-modifier-keys"] = list(modifier_keys) + else: + target["keyboard-layout"] = "desktop" + if modifier_keys is not None: + target["desktop-modifier-keys"] = list(modifier_keys) + + def migrate_all(self, prefs_dir: str) -> bool: + """Migrates Cthulhu TOML settings to the Orca 50 schema/key shape.""" + + if not prefs_dir: + return False + + self._prefs_dir = Path(prefs_dir) + settings_path = self._settings_path() + prefs_doc = self._read_document(settings_path) + if prefs_doc.get("format-version") == 2: + return False + + migrated_doc = document() + migrated_doc["format-version"] = 2 + + general = _plain(prefs_doc.get("general", {})) + if not isinstance(general, dict): + general = {} + + profiles = _plain(prefs_doc.get("profiles", {})) + if not isinstance(profiles, dict) or not profiles: + profiles = {"default": {"profile": ["Default", "default"]}} + + for profile_name, profile_data in profiles.items(): + if not isinstance(profile_data, dict): + profile_data = {} + profile_tuple = profile_data.get("profile") + if isinstance(profile_tuple, list) and len(profile_tuple) >= 2: + display_name = str(profile_tuple[0]) + internal_name = str(profile_tuple[1]) + else: + internal_name = str(profile_name) + display_name = internal_name.replace("-", " ").title() + + profile_table = self._ensure_profile(migrated_doc, internal_name, display_name) + source = dict(general) + for key, value in profile_data.items(): + if key not in {"profile", "keybindings", "pronunciations"}: + source[key] = value + + legacy_keybindings = profile_data.get("keybindings", {}) + if isinstance(legacy_keybindings, dict): + for key in gsettings_migrator.KEYBINDINGS_METADATA_KEYS: + if key in legacy_keybindings and key not in source: + source[key] = legacy_keybindings[key] + + keybindings = self._table(profile_table, "keybindings") + self._migrate_legacy_modifier_keys(source, keybindings) + if isinstance(legacy_keybindings, dict): + entries = { + key: value + for key, value in legacy_keybindings.items() + if key not in gsettings_migrator.KEYBINDINGS_METADATA_KEYS + } + if entries: + keybindings["entries"] = entries + + legacy_pronunciations = profile_data.get("pronunciations", {}) + if isinstance(legacy_pronunciations, dict) and legacy_pronunciations: + pronunciations = self._table(profile_table, "pronunciations") + pronunciations["entries"] = legacy_pronunciations + + for legacy_key, (schema_name, schema_key) in CTHULHU_LEGACY_SCHEMA_ALIASES.items(): + if legacy_key not in source: + continue + schema_table = self._table(profile_table, schema_name) + schema_table[schema_key] = source[legacy_key] + + self._write_document(settings_path, migrated_doc) + return True + + +_registry: GSettingsRegistry = GSettingsRegistry() + + +def get_registry() -> GSettingsRegistry: + """Returns the GSettingsRegistry singleton.""" + + return _registry diff --git a/src/cthulhu/meson.build b/src/cthulhu/meson.build index d025922..9def9d8 100644 --- a/src/cthulhu/meson.build +++ b/src/cthulhu/meson.build @@ -52,6 +52,8 @@ cthulhu_python_sources = files([ 'formatting.py', 'focus_manager.py', 'generator.py', + 'gsettings_migrator.py', + 'gsettings_registry.py', 'gstreamer_support.py', 'guilabels.py', 'highlighter.py', diff --git a/src/cthulhu/settings_manager.py b/src/cthulhu/settings_manager.py index 61e9bda..5090b0d 100644 --- a/src/cthulhu/settings_manager.py +++ b/src/cthulhu/settings_manager.py @@ -163,6 +163,12 @@ class SettingsManager(object): if self.profile: self.setProfile(self.profile) + from . import gsettings_registry + + registry = gsettings_registry.get_registry() + registry.set_active_profile(self.profile or "default") + registry.migrate_all(self._prefsDir or "") + def _loadBackend(self) -> bool: """Load specific backend for manage user settings"""