"""Folder-organized macro library for the BT Keyboard streamer. A *macro* is a recorded sequence of keyboard + mouse + Ctrl-Alt-Del events captured in the BT Keyboard window. Macros are grouped into user-managed *folders*. Folders can be created / renamed / deleted; macros are NOT renameable by design (re-record to change one). Event format (tagged arrays so a single list can mix keys and mouse): key: ["k", t_ms, action, hid] action 0=down/1=up mouse: ["m", t_ms, buttons, x, y, wheel] x/y absolute 0..32767 Stored as one JSON file in the app config dir (same convention as bt_profiles.py — atomic write under a lock): { "folders": { "": { "": {"events":[...], "duration_ms": int, "created": } } } } """ import json import os import threading import time from utils.constants import APPDATA_DIR _FILE = os.path.join(APPDATA_DIR, "bt_kbd_macros.json") _lock = threading.Lock() DEFAULT_FOLDER = "Default" def _load_all() -> dict: try: with open(_FILE, "r", encoding="utf-8") as f: d = json.load(f) except (OSError, json.JSONDecodeError): return {} folders = d.get("folders") if isinstance(d, dict) else None return folders if isinstance(folders, dict) else {} def _save_all(folders: dict) -> None: os.makedirs(APPDATA_DIR, exist_ok=True) tmp = _FILE + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump({"folders": folders}, f, indent=2) os.replace(tmp, _FILE) def _clean(name: str) -> str: return (name or "").strip() # ---- Folders ---- def list_folders() -> list: """All folder names, sorted, with DEFAULT_FOLDER guaranteed present.""" with _lock: folders = _load_all() if DEFAULT_FOLDER not in folders: folders[DEFAULT_FOLDER] = {} _save_all(folders) return sorted(folders.keys()) def create_folder(name: str) -> bool: name = _clean(name) if not name: return False with _lock: folders = _load_all() if name in folders: return False folders[name] = {} _save_all(folders) return True def rename_folder(old: str, new: str) -> bool: old, new = _clean(old), _clean(new) if not old or not new or old == new: return False with _lock: folders = _load_all() if old not in folders or new in folders: return False folders[new] = folders.pop(old) # moves all contained macros _save_all(folders) return True def delete_folder(name: str) -> bool: name = _clean(name) with _lock: folders = _load_all() if name not in folders: return False del folders[name] if not folders: folders[DEFAULT_FOLDER] = {} _save_all(folders) return True # ---- Macros ---- def list_macros(folder: str) -> list: """Macro names in a folder, sorted.""" folder = _clean(folder) with _lock: return sorted(_load_all().get(folder, {}).keys()) def save_macro(folder: str, name: str, events, duration_ms: int = 0) -> bool: """Create/overwrite ``name`` in ``folder`` (folder auto-created).""" folder, name = _clean(folder) or DEFAULT_FOLDER, _clean(name) if not name: return False with _lock: folders = _load_all() folders.setdefault(folder, {}) folders[folder][name] = { "events": [list(e) for e in events], "duration_ms": int(duration_ms), "created": time.time(), } _save_all(folders) return True def load_macro(folder: str, name: str) -> dict | None: """Return {"events","duration_ms","created"} or None.""" folder, name = _clean(folder), _clean(name) with _lock: m = _load_all().get(folder, {}).get(name) if not isinstance(m, dict): return None evs = m.get("events") return { "events": evs if isinstance(evs, list) else [], "duration_ms": int(m.get("duration_ms", 0) or 0), "created": m.get("created", 0), } def macro_exists(folder: str, name: str) -> bool: folder, name = _clean(folder), _clean(name) with _lock: return name in _load_all().get(folder, {}) def delete_macro(folder: str, name: str) -> bool: folder, name = _clean(folder), _clean(name) with _lock: folders = _load_all() if name in folders.get(folder, {}): del folders[folder][name] _save_all(folders) return True return False