diff --git a/box/api/__pycache__/http.cpython-312.pyc b/box/api/__pycache__/http.cpython-312.pyc index 1fbb87b..3d27a20 100644 Binary files a/box/api/__pycache__/http.cpython-312.pyc and b/box/api/__pycache__/http.cpython-312.pyc differ diff --git a/box/api/http.py b/box/api/http.py index 7ff81d8..10ce4f5 100644 --- a/box/api/http.py +++ b/box/api/http.py @@ -27,6 +27,7 @@ def create_app( spotify_backend: SpotifyBackend, pairing_manager: PairingManager, persist_state: Callable[[], None], + factory_reset: Callable[[], None], ) -> FastAPI: app = FastAPI() app.add_middleware( @@ -48,6 +49,11 @@ def create_app( "connected_ssid": snapshot.connected_ssid, "wifi_profiles_count": snapshot.wifi_profiles_count, "spotify_status": spotify_backend.status, + "last_nfc": { + "uid": state.last_nfc_uid, + "known": state.last_nfc_known, + }, + "last_nfc_at": state.last_nfc_at, "playback_state": { "state": state.state.value, "active_uid": state.active_uid, @@ -59,8 +65,47 @@ def create_app( "volume": state.volume, "last_error": state.last_error, }, + "blocked_tags": controller.config.get("blocked_tags", []), } + @app.get("/local-tags") + def local_tags(http_request: Request) -> dict[str, Any]: + if not _is_authorized(pairing_manager, http_request): + raise HTTPException(status_code=403, detail="pairing required") + tags = controller.config.get("tags", {}) + result = [] + if isinstance(tags, dict): + for uid, tag in tags.items(): + if not isinstance(tag, dict): + continue + path_value = tag.get("path") + if not isinstance(path_value, str): + continue + root = controller._resolve_path(path_value) + files = [] + total_size = 0 + if root.exists() and root.is_dir(): + for file_path in root.rglob("*"): + if not file_path.is_file(): + continue + rel_path = file_path.relative_to(root).as_posix() + files.append(rel_path) + try: + total_size += file_path.stat().st_size + except OSError: + continue + result.append( + { + "uid": uid, + "path": path_value, + "media_exists": bool(files), + "file_count": len(files), + "total_size": total_size, + "files": files, + } + ) + return {"tags": result} + @app.post("/command") def command(request: CommandRequest, http_request: Request) -> dict[str, Any]: cmd = request.command @@ -124,6 +169,42 @@ def create_app( spotify_backend.set_tokens(access_token, refresh_token, expires_at, account_id) elif cmd == "spotify_clear": spotify_backend.clear() + elif cmd == "unpair": + pairing_manager.unpair(reset_state=True) + elif cmd == "tag_assign": + uid = payload.get("uid") + media_path = payload.get("media_path") + if not isinstance(uid, str) or not isinstance(media_path, str): + raise HTTPException(status_code=400, detail="uid and media_path required") + controller.set_tag(uid, media_path) + elif cmd == "tag_remove": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.remove_tag(uid) + elif cmd == "tag_block": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.block_tag(uid) + elif cmd == "tag_unblock": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.unblock_tag(uid) + elif cmd == "export_tag": + uid = payload.get("uid") + target_folder = payload.get("target_folder") + if not isinstance(uid, str) or not isinstance(target_folder, str): + raise HTTPException(status_code=400, detail="uid and target_folder required") + if "/" in target_folder or "\\" in target_folder or not target_folder.strip(): + raise HTTPException(status_code=400, detail="invalid target_folder") + ok = controller.export_tag_media(uid, target_folder.strip()) + if not ok: + raise HTTPException(status_code=500, detail="export failed") + elif cmd == "factory_reset": + factory_reset() + return {"ok": True, "restart_required": True} else: raise HTTPException(status_code=400, detail="unknown command") @@ -139,6 +220,7 @@ def _requires_pairing(command: str) -> bool: "wifi_reset", "spotify_set_tokens", "spotify_clear", + "factory_reset", } return command not in allowed_without_pairing diff --git a/box/config/box_config.json b/box/config/box_config.json deleted file mode 100644 index 725725e..0000000 --- a/box/config/box_config.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "box_id": "box-0001", - "settings": { - "max_volume": 80, - "default_volume": 40 - }, - "tags": { - "UID_1": { - "type": "folder", - "path": "../media/book_1" - }, - "UID_2": { - "type": "folder", - "path": "../media/book_2" - }, - "UID_3": { - "type": "folder", - "path": "../media/book_3" - }, - "UID_4": { - "type": "folder", - "path": "../media/book_4" - }, - "UID_5": { - "type": "folder", - "path": "../media/book_5" - }, - "UID_6": { - "type": "folder", - "path": "../media/book_6" - } - }, - "resume": { - "UID_1": { - "file_index": 0, - "position": 12 - }, - "UID_2": { - "file_index": 1, - "position": 0 - } - } -} \ No newline at end of file diff --git a/box/core/__pycache__/announce.cpython-312.pyc b/box/core/__pycache__/announce.cpython-312.pyc index 4d77b6d..8a61cd3 100644 Binary files a/box/core/__pycache__/announce.cpython-312.pyc and b/box/core/__pycache__/announce.cpython-312.pyc differ diff --git a/box/core/__pycache__/box_controller.cpython-312.pyc b/box/core/__pycache__/box_controller.cpython-312.pyc index 2d4ce0e..36f5ea2 100644 Binary files a/box/core/__pycache__/box_controller.cpython-312.pyc and b/box/core/__pycache__/box_controller.cpython-312.pyc differ diff --git a/box/core/__pycache__/pairing.cpython-312.pyc b/box/core/__pycache__/pairing.cpython-312.pyc index 095cc6f..00cbe91 100644 Binary files a/box/core/__pycache__/pairing.cpython-312.pyc and b/box/core/__pycache__/pairing.cpython-312.pyc differ diff --git a/box/core/__pycache__/state.cpython-312.pyc b/box/core/__pycache__/state.cpython-312.pyc index cbc9a7f..aec93b2 100644 Binary files a/box/core/__pycache__/state.cpython-312.pyc and b/box/core/__pycache__/state.cpython-312.pyc differ diff --git a/box/core/announce.py b/box/core/announce.py index 5884b0d..455cf20 100644 --- a/box/core/announce.py +++ b/box/core/announce.py @@ -18,6 +18,9 @@ class AnnounceConfig: server_url: str firmware_version: str capabilities: dict[str, Any] + api_port: int + setup_port: int + media_root: str announce_interval_seconds: int = 30 polling_interval_seconds: int = 10 @@ -33,6 +36,9 @@ def build_payload( "fingerprint": fingerprint, "firmware_version": config.firmware_version, "capabilities": config.capabilities, + "api_port": config.api_port, + "setup_port": config.setup_port, + "media_root": config.media_root, } @@ -67,8 +73,9 @@ async def announce_loop( announce_url = f"{config.server_url}/api/boxes/announce" while not stop_event.is_set(): - if not pairing.is_paired(): - await asyncio.to_thread(_post_json, announce_url, payload) + response = await asyncio.to_thread(_post_json, announce_url, payload) + if response and response.get("force_unpair") is True: + pairing.unpair(reset_state=True) await asyncio.sleep(config.announce_interval_seconds) diff --git a/box/core/box_controller.py b/box/core/box_controller.py index 841db97..f0d396b 100644 --- a/box/core/box_controller.py +++ b/box/core/box_controller.py @@ -2,11 +2,15 @@ from __future__ import annotations +import json from pathlib import Path from typing import Any +import time +import shutil from core.state import BoxState, RuntimeState from player.base_player import BasePlayer +from core.media_sync import MediaSyncClient import hashlib @@ -17,11 +21,17 @@ class BoxController: player: BasePlayer, config: dict[str, Any], config_path: str, + media_sync: MediaSyncClient | None = None, ) -> None: self._state = state self._player = player self._config = config self._config_path = Path(config_path) + self._media_sync = media_sync + + @property + def config(self) -> dict[str, Any]: + return self._config @property def state(self) -> RuntimeState: @@ -40,10 +50,24 @@ class BoxController: print("error: server not reachable") self._trigger_error("server_unreachable") return None + blocked = self._config.get("blocked_tags") + if isinstance(blocked, list) and uid in blocked: + self._state.last_nfc_uid = uid + self._state.last_nfc_known = True + self._state.last_nfc_at = int(time.time()) + self._trigger_error("tag_blocked") + return None tag = self._resolve_tag(uid) + resolve_error = None + if tag is None: + tag, resolve_error = self._resolve_tag_from_server(uid) + self._state.last_nfc_uid = uid + self._state.last_nfc_known = tag is not None or resolve_error == "tag_blocked" + self._state.last_nfc_at = int(time.time()) + if resolve_error == "tag_blocked": + self._trigger_error("tag_blocked") + return None if tag is None: - print(f"error: uid '{uid}' not found in config tags") - self._trigger_error("uid_unmapped") return None self._state.last_error = None @@ -51,11 +75,22 @@ class BoxController: if not playlist: path_value = tag.get("path", "-") resolved = self._resolve_path(path_value) - print( - f"error: no playable files found for uid '{uid}' at path '{resolved}'" - ) - self._trigger_error("no_playable_files") - return None + if self._request_media_sync(uid): + playlist = self._build_playlist(tag) + if playlist: + self._state.last_error = None + else: + print( + f"error: no playable files found for uid '{uid}' at path '{resolved}'" + ) + self._trigger_error("no_playable_files") + return None + else: + print( + f"error: no playable files found for uid '{uid}' at path '{resolved}'" + ) + self._trigger_error("no_playable_files") + return None if uid in self._state._resume: file_index, position = self._state._resume[uid] @@ -100,11 +135,81 @@ class BoxController: def handle_nfc_off(self, uid: str) -> None: if self._state.state == BoxState.ERROR: return None + if self._state.last_nfc_uid == uid: + self._state.last_nfc_uid = None + self._state.last_nfc_known = None + self._state.last_nfc_at = None if self._state.active_uid != uid: return None self._store_progress(uid) self.stop() + def set_tag(self, uid: str, media_path: str) -> None: + tags = self._config.get("tags") + if not isinstance(tags, dict): + tags = {} + self._config["tags"] = tags + tags[uid] = {"type": "folder", "path": media_path} + if uid in self._state._resume: + self._state._resume.pop(uid, None) + self._state._resume[uid] = (0, 0) + if self._state.last_nfc_uid == uid: + self._state.last_nfc_known = True + self._save_config() + if not self._build_playlist(tags[uid]): + self._request_media_sync(uid) + + def _remove_tag_data(self, uid: str) -> None: + tags = self._config.get("tags") + if not isinstance(tags, dict): + return None + removed = None + if uid in tags: + removed = tags.pop(uid) + self._state._resume.pop(uid, None) + if self._state.active_uid == uid: + self.stop() + if self._state.last_nfc_uid == uid: + self._state.last_nfc_uid = None + self._state.last_nfc_known = None + self._state.last_nfc_at = None + if isinstance(removed, dict): + path_value = removed.get("path") + if isinstance(path_value, str): + media_root = (self._config_path.parent.parent / "media").resolve() + resolved = self._resolve_path(path_value) + if media_root in resolved.parents: + shutil.rmtree(resolved, ignore_errors=True) + + def remove_tag(self, uid: str) -> None: + self._remove_tag_data(uid) + blocked = self._config.get("blocked_tags") + if isinstance(blocked, list) and uid in blocked: + blocked = [value for value in blocked if value != uid] + self._config["blocked_tags"] = blocked + self._save_config() + + def block_tag(self, uid: str) -> None: + blocked = self._config.get("blocked_tags") + if not isinstance(blocked, list): + blocked = [] + if uid not in blocked: + blocked.append(uid) + self._config["blocked_tags"] = blocked + if self._state.active_uid == uid or self._state.last_nfc_uid == uid: + self._trigger_error("tag_blocked") + self._remove_tag_data(uid) + self._save_config() + + def unblock_tag(self, uid: str) -> None: + blocked = self._config.get("blocked_tags") + if not isinstance(blocked, list): + return None + if uid in blocked: + blocked = [value for value in blocked if value != uid] + self._config["blocked_tags"] = blocked + self._save_config() + def _resolve_tag(self, uid: str) -> dict[str, Any] | None: tags = self._config.get("tags", {}) if not isinstance(tags, dict): @@ -116,6 +221,56 @@ class BoxController: return None return tag + def _save_config(self) -> None: + data = dict(self._config) + self._config_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def _request_media_sync(self, uid: str) -> bool: + if not self._media_sync: + return False + ok = self._media_sync.request_tag_media(uid) + if ok: + print(f"info: media sync requested for uid '{uid}'") + else: + print(f"error: media sync request failed for uid '{uid}'") + return ok + + def _resolve_tag_from_server(self, uid: str) -> tuple[dict[str, Any] | None, str | None]: + if not self._media_sync: + return None, None + media_path, error = self._media_sync.resolve_tag(uid) + if error: + return None, error + if not media_path: + return None, None + self.set_tag(uid, f"media/{uid}") + return self._resolve_tag(uid), None + + def export_tag_media(self, uid: str, target_folder: str) -> bool: + if not self._media_sync: + return False + tag = self._resolve_tag(uid) + if not tag: + return False + path_value = tag.get("path") + if not isinstance(path_value, str): + return False + root = self._resolve_path(path_value) + if not root.exists() or not root.is_dir(): + return False + + for file_path in root.rglob("*"): + if not file_path.is_file(): + continue + rel_path = file_path.relative_to(root).as_posix() + try: + data = file_path.read_bytes() + except OSError: + return False + if not self._media_sync.upload_media_file(target_folder, rel_path, data): + return False + return True + def tick(self, seconds: int) -> None: if self._state.state == BoxState.ERROR: return None @@ -186,7 +341,10 @@ class BoxController: def _resolve_path(self, path_value: str) -> Path: path = Path(path_value) if not path.is_absolute(): - return (self._config_path.parent / path).resolve() + media_root = self._config_path.parent.parent / "media" + if path_value.startswith("media/"): + return (media_root / path_value.replace("media/", "", 1)).resolve() + return (media_root / path).resolve() return path def _store_progress(self, uid: str) -> None: @@ -199,8 +357,6 @@ class BoxController: durations = {"01.mp3": 180, "02.mp3": 200, "03.mp3": 220} if name in durations: return durations[name] - if not name.lower().endswith(".mp3"): - return 0 digest = hashlib.md5(name.encode("utf-8")).hexdigest() seed = int(digest[:8], 16) return 120 + (seed % 241) @@ -288,3 +444,12 @@ class BoxController: def trigger_error(self, code: str) -> None: self._trigger_error(code) + + def factory_reset(self) -> None: + self.stop() + self._state._resume.clear() + self._state.last_error = None + self._state.last_nfc_uid = None + self._state.last_nfc_known = None + self._state.last_nfc_at = None + self._config.clear() diff --git a/box/core/media_sync.py b/box/core/media_sync.py new file mode 100644 index 0000000..22634c4 --- /dev/null +++ b/box/core/media_sync.py @@ -0,0 +1,111 @@ +"""Client to request media sync from the server.""" + +from __future__ import annotations + +import json +import base64 +from dataclasses import dataclass +from typing import Callable +from urllib import request, error + + +@dataclass +class MediaSyncClient: + server_url: str + box_id: str + token_provider: Callable[[], str | None] + timeout_seconds: int = 5 + + def request_tag_media(self, uid: str) -> bool: + token = self.token_provider() + if not token: + return False + endpoint = f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/media-sync" + payload = json.dumps({"uid": uid}).encode("utf-8") + req = request.Request( + endpoint, + data=payload, + method="POST", + headers={ + "Content-Type": "application/json", + "X-API-Token": token, + }, + ) + try: + with request.urlopen(req, timeout=self.timeout_seconds) as response: + return 200 <= response.status < 300 + except error.HTTPError: + return False + except error.URLError: + return False + + def resolve_tag(self, uid: str) -> tuple[str | None, str | None]: + token = self.token_provider() + if not token: + return None, None + endpoint = ( + f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/resolve-tag" + ) + payload = json.dumps({"uid": uid}).encode("utf-8") + req = request.Request( + endpoint, + data=payload, + method="POST", + headers={ + "Content-Type": "application/json", + "X-API-Token": token, + }, + ) + try: + with request.urlopen(req, timeout=self.timeout_seconds) as response: + body = response.read().decode("utf-8") + except error.HTTPError as http_error: + try: + detail = json.loads(http_error.read().decode("utf-8")).get("detail") + except Exception: + detail = None + if detail == "tag_blocked": + return None, "tag_blocked" + return None, None + except error.URLError: + return None, None + + try: + data = json.loads(body) + except json.JSONDecodeError: + return None, None + media_path = data.get("media_path") + if isinstance(media_path, str) and media_path: + return media_path, None + return None, None + + def upload_media_file(self, target_folder: str, rel_path: str, data: bytes) -> bool: + token = self.token_provider() + if not token: + return False + endpoint = ( + f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/media-import" + ) + payload = json.dumps( + { + "target_folder": target_folder, + "rel_path": rel_path, + "content_base64": base64.b64encode(data).decode("ascii"), + } + ).encode("utf-8") + req = request.Request( + endpoint, + data=payload, + method="POST", + headers={ + "Content-Type": "application/json", + "X-API-Token": token, + }, + ) + try: + with request.urlopen(req, timeout=self.timeout_seconds) as response: + return 200 <= response.status < 300 + except error.HTTPError: + return False + except error.URLError: + return False diff --git a/box/core/pairing.py b/box/core/pairing.py index 147b448..4315898 100644 --- a/box/core/pairing.py +++ b/box/core/pairing.py @@ -48,6 +48,15 @@ class PairingManager: self._secret_store.save_api_token(token) self.set_state(PairingState.PAIRED) + def unpair(self, reset_state: bool = False) -> None: + self._secret_store.clear_api_token() + self.set_state(PairingState.UNPAIRED) + if reset_state: + data = self._state_store.load() + data["pairing"] = {"state": PairingState.UNPAIRED.name} + data["resume"] = {} + self._state_store.save(data) + def _load(self) -> None: data = self._state_store.load() pairing = data.get("pairing") if isinstance(data.get("pairing"), dict) else {} diff --git a/box/core/state.py b/box/core/state.py index b2a9de8..ecd90b2 100644 --- a/box/core/state.py +++ b/box/core/state.py @@ -28,6 +28,9 @@ class RuntimeState: file_index: Optional[int] = None position: Optional[int] = None last_error: Optional[str] = None + last_nfc_uid: Optional[str] = None + last_nfc_known: Optional[bool] = None + last_nfc_at: Optional[int] = None _resume: dict[str, tuple[int, int]] = field(default_factory=dict) _playlist: list[str] = field(default_factory=list) max_volume: int = 100 diff --git a/box/data/box.json b/box/data/box.json index 1cf280e..55dcebd 100644 --- a/box/data/box.json +++ b/box/data/box.json @@ -1,37 +1,13 @@ { - "box_id": "klangkiste-x1u8i4x5e4", + "box_id": "klangkiste-4w8u0c0y1i", "settings": {}, - "tags": { - "UID_1": { - "type": "folder", - "path": "media/book_1" - }, - "UID_2": { - "type": "folder", - "path": "media/book_2" - }, - "UID_3": { - "type": "folder", - "path": "media/book_3" - }, - "UID_4": { - "type": "folder", - "path": "media/book_4" - }, - "UID_5": { - "type": "folder", - "path": "media/book_5" - }, - "UID_6": { - "type": "folder", - "path": "media/book_6" - } - }, - "server_url": "http://127.0.0.1:7000", + "tags": {}, + "server_url": "http://10.10.10.23:5001", "firmware_version": "0.1.0", "capabilities": { "nfc": true, "audio": true, "spotify": true - } + }, + "blocked_tags": [] } \ No newline at end of file diff --git a/box/data/media/book_3/03.mp3 b/box/data/media/book_3/03.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/data/media/book_4/01.mp3 b/box/data/media/book_4/01.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/data/media/book_4/02.mp3 b/box/data/media/book_4/02.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/data/media/book_4/03.mp3 b/box/data/media/book_4/03.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/data/media/book_5/01.mp3 b/box/data/media/book_5/01.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/data/media/book_5/02.mp3 b/box/data/media/book_5/02.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/data/media/book_5/03.mp3 b/box/data/media/book_5/03.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/data/media/book_6/CD1/Titel A.mp3 b/box/data/media/book_6/CD1/Titel A.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/data/media/book_6/CD1/Titel B.mp3 b/box/data/media/book_6/CD1/Titel B.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/data/media/book_6/CD2/Titel A.mp3 b/box/data/media/book_6/CD2/Titel A.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/data/media/book_6/CD2/Titel B.mp3 b/box/data/media/book_6/CD2/Titel B.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/data/secrets.enc b/box/data/secrets.enc index df8bbbd..4aff3b7 100644 --- a/box/data/secrets.enc +++ b/box/data/secrets.enc @@ -1 +1 @@ -gAAAAABpZURNIBD1TNfUmVPuGAFX7CiHUaCvyrF-VW5u6lZ_M2OY_K-I8P97WuAlG-PwBm9WPJNADf1gsUPRf8LAgWblysCKta3tXbxYioraZaozTxHjnC1bGduROb6BmBkFhOSaRPBHGjc1PalRwZ7fJ9kunb7KLQ_g2abJRmOqFHtaR0Pz66XzPGW6Wl8foqhnonkcR-8i \ No newline at end of file +gAAAAABpZgUAtNvwWTJeaG5AVPuEFOUC321L1H9BVoQXpKCCAW209SvVYspQHMjZ3RamiRlwu9sr8UZA5zo0JBaNxSom2ZzgDUlUxpsa5zlL7ZHG9WGWIr1QFSBjJeBGeSTCu7gT4vu_3T-N_NWip2oaQUDY0VS8IHQ2damKibeV6JQJFqC2MJDVQKKbyGq25OtNsdvuYe7VDUbnA-Khs0TZJP0eSCeTlDwNGmrUPVDn8ec7X7-Y3prgV6tkIHvcpKE-XgXCc4uqievI0nCdWu0gp1HfXydpP-EQsrAN6ZJR264vdgIoq-s= \ No newline at end of file diff --git a/box/data/state.json b/box/data/state.json index 139df98..5d30da0 100644 --- a/box/data/state.json +++ b/box/data/state.json @@ -1,14 +1,6 @@ { - "wifi": { - "connected_ssid": null - }, - "resume": { - "UID_1": { - "file_index": 1, - "position": 79 - } - }, "pairing": { - "state": "PAIRING_PENDING" - } + "state": "PAIRED" + }, + "resume": {} } \ No newline at end of file diff --git a/box/main.py b/box/main.py index cce2774..56aaeb8 100644 --- a/box/main.py +++ b/box/main.py @@ -7,6 +7,7 @@ import asyncio import contextlib import os import sys +import shutil from pathlib import Path from typing import Any @@ -17,6 +18,7 @@ from core.announce import AnnounceConfig, announce_loop, pairing_poll_loop from core.box_controller import BoxController from core.identity import generate_box_id from core.lifecycle import NetworkLifecycle +from core.media_sync import MediaSyncClient from core.pairing import PairingManager from core.state import BoxState, RuntimeState from player.mock_player import MockPlayer @@ -25,7 +27,6 @@ from spotify.mock import MockSpotifyBackend from storage.box_store import BoxStore from storage.state_store import StateStore from storage.secret_store import SecretStore -from storage.json_storage import JsonStorage from wifi.mock import MockWifiBackend @@ -34,11 +35,6 @@ def build_parser() -> argparse.ArgumentParser: prog="klangkiste-box", description="Klangkiste Box CLI (Phase 1a, local simulation)", ) - parser.add_argument( - "--config", - default="config/box_config.json", - help="Path to local box config JSON (legacy seed)", - ) subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser("status", help="Show current runtime status") @@ -53,6 +49,7 @@ def build_parser() -> argparse.ArgumentParser: tick_parser.add_argument("seconds", type=int, help="Seconds to advance") subparsers.add_parser("run", help="Start interactive session + HTTP API") + subparsers.add_parser("factory-reset", help="Factory reset (delete all local data)") return parser @@ -81,17 +78,16 @@ def _state_from_data( return state -def _normalize_tag_paths(tags: dict[str, Any]) -> dict[str, Any]: - normalized = {} - for uid, tag in tags.items(): - if not isinstance(tag, dict): - continue - path = tag.get("path") - if isinstance(path, str) and path.startswith("../media/"): - tag = dict(tag) - tag["path"] = path.replace("../media/", "media/", 1) - normalized[uid] = tag - return normalized +def _prune_resume(state: RuntimeState, tags: dict[str, Any]) -> bool: + if not isinstance(tags, dict): + return False + valid_uids = {uid for uid, tag in tags.items() if isinstance(tag, dict)} + stale_uids = [uid for uid in state._resume.keys() if uid not in valid_uids] + if not stale_uids: + return False + for uid in stale_uids: + state._resume.pop(uid, None) + return True def _print_status(state: RuntimeState) -> None: @@ -119,12 +115,10 @@ def main() -> int: parser = build_parser() args = parser.parse_args() - config_path = Path(args.config) - if not config_path.is_absolute(): - config_path = (Path(__file__).resolve().parent / config_path).resolve() base_dir = Path(__file__).resolve().parent / "data" base_dir.mkdir(parents=True, exist_ok=True) - (base_dir / "media").mkdir(parents=True, exist_ok=True) + media_root = Path(__file__).resolve().parent / "media" + media_root.mkdir(parents=True, exist_ok=True) box_store = BoxStore(base_dir) box_meta = box_store.load() @@ -133,14 +127,12 @@ def main() -> int: if "settings" not in box_meta: box_meta["settings"] = {} if "tags" not in box_meta: - legacy_storage = JsonStorage(str(config_path)) - legacy_config = legacy_storage.load_config() - if isinstance(legacy_config.get("tags"), dict): - box_meta["tags"] = _normalize_tag_paths(legacy_config.get("tags", {})) - if isinstance(box_meta.get("tags"), dict): - box_meta["tags"] = _normalize_tag_paths(box_meta.get("tags", {})) - if "server_url" not in box_meta: - box_meta["server_url"] = os.getenv("KLANGKISTE_SERVER_URL", "http://127.0.0.1:7000") + box_meta["tags"] = {} + env_server_url = os.getenv("KLANGKISTE_SERVER_URL") + if env_server_url: + box_meta["server_url"] = env_server_url + elif "server_url" not in box_meta: + box_meta["server_url"] = "http://127.0.0.1:5001" if "firmware_version" not in box_meta: box_meta["firmware_version"] = "0.1.0" if "capabilities" not in box_meta: @@ -151,18 +143,27 @@ def main() -> int: state_data = state_store.load() state = _state_from_data(box_meta, state_data) + resume_pruned = _prune_resume(state, box_meta.get("tags", {})) secrets = SecretStore(base_dir, state.box_id) secrets.ensure_secret_seed() wifi_backend = MockWifiBackend(state_store, secrets) spotify_backend = MockSpotifyBackend(secrets) lifecycle = NetworkLifecycle(wifi_backend) pairing_manager = PairingManager(state_store, secrets) + media_sync = MediaSyncClient( + server_url=str(box_meta.get("server_url")), + box_id=state.box_id, + token_provider=pairing_manager.get_api_token, + ) controller = BoxController( state=state, player=MockPlayer(), config=box_meta, config_path=str(box_store.path), + media_sync=media_sync, ) + if resume_pruned: + _persist_resume(controller, state_store) if args.command == "status": _print_status(controller.state) @@ -186,7 +187,16 @@ def main() -> int: _persist_resume(controller, state_store) return 0 + if args.command == "factory-reset": + _factory_reset(controller, box_store, state_store, secrets, media_root) + print("Factory reset completed. Restart required.") + return 0 + if args.command == "run": + announce_interval = _parse_int_env("KLANGKISTE_ANNOUNCE_INTERVAL", 30) + poll_interval = _parse_int_env("KLANGKISTE_POLL_INTERVAL", 10) + api_port = _parse_int_env("KLANGKISTE_API_PORT", 8000) + setup_port = _parse_int_env("KLANGKISTE_SETUP_PORT", 9000) try: asyncio.run( _run_with_api( @@ -201,6 +211,11 @@ def main() -> int: server_url=str(box_meta.get("server_url")), firmware_version=str(box_meta.get("firmware_version")), capabilities=dict(box_meta.get("capabilities", {})), + api_port=api_port, + setup_port=setup_port, + media_root=str(media_root.resolve()), + announce_interval_seconds=announce_interval, + polling_interval_seconds=poll_interval, ), ) ) @@ -218,6 +233,19 @@ def _resume_to_json(state: RuntimeState) -> dict[str, dict[str, int]]: } +def _parse_int_env(name: str, default_value: int) -> int: + raw = os.getenv(name) + if not raw: + return default_value + try: + value = int(raw) + except ValueError: + return default_value + if value <= 0: + return default_value + return value + + def _stdin_is_foreground() -> bool: try: if not sys.stdin.isatty(): @@ -237,6 +265,9 @@ async def _run_with_api( secrets: SecretStore, announce_config: AnnounceConfig, ) -> None: + base_dir = Path(__file__).resolve().parent / "data" + media_root = Path(__file__).resolve().parent / "media" + box_store = BoxStore(base_dir) app = create_app( controller, lifecycle, @@ -244,12 +275,13 @@ async def _run_with_api( spotify_backend, pairing_manager, lambda: _persist_resume(controller, storage), + lambda: _factory_reset(controller, box_store, storage, secrets, media_root), ) api_server = uvicorn.Server( uvicorn.Config( app, host="0.0.0.0", - port=8000, + port=announce_config.api_port, log_level="warning", access_log=False, ) @@ -259,7 +291,7 @@ async def _run_with_api( uvicorn.Config( setup_app, host="0.0.0.0", - port=9000, + port=announce_config.setup_port, log_level="warning", access_log=False, ) @@ -285,7 +317,17 @@ async def _run_with_api( try: if _stdin_is_foreground(): - await _run_session(controller, storage) + await _run_session( + controller, + storage, + lambda: _factory_reset( + controller, + box_store, + storage, + secrets, + media_root, + ), + ) api_server.should_exit = True setup_server.should_exit = True await api_task @@ -309,7 +351,7 @@ async def _run_with_api( async def _run_session( - controller: BoxController, storage: StateStore + controller: BoxController, storage: StateStore, factory_reset: callable ) -> None: print("Klangkiste Box - Interactive Mode") print("Type 'help' for commands.") @@ -339,6 +381,10 @@ async def _run_session( if command == "status": _print_status(controller.state) continue + if command == "factory-reset": + factory_reset() + print("Factory reset completed. Restart required.") + break if command == "nfc": if len(args) != 1: print("error: usage: nfc ") @@ -414,6 +460,23 @@ def _persist_resume( storage.save(data) +def _factory_reset( + controller: BoxController, + box_store: BoxStore, + state_store: StateStore, + secrets: SecretStore, + media_root: Path, +) -> None: + controller.factory_reset() + if box_store.path.exists(): + box_store.path.unlink() + if state_store.path.exists(): + state_store.path.unlink() + secrets.clear_all() + shutil.rmtree(media_root, ignore_errors=True) + media_root.mkdir(parents=True, exist_ok=True) + + def _print_help() -> None: print("Commands:") print(" nfc simulate NFC tag detection") @@ -425,6 +488,7 @@ def _print_help() -> None: print(" vol-up | lauter increase volume") print(" vol-down | leiser decrease volume") print(" status show current status") + print(" factory-reset delete all local data (restart required)") print(" help show this help") print(" exit | quit leave interactive mode") diff --git a/box/media/book_1/01.mp3 b/box/media/book_1/01.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_1/02.mp3 b/box/media/book_1/02.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_1/03.mp3 b/box/media/book_1/03.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_2/01.mp3 b/box/media/book_2/01.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_2/02.mp3 b/box/media/book_2/02.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_2/03.mp3 b/box/media/book_2/03.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_3/01.mp3 b/box/media/book_3/01.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_3/02.mp3 b/box/media/book_3/02.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_3/03.mp3 b/box/media/book_3/03.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_4/01.mp3 b/box/media/book_4/01.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_4/02.mp3 b/box/media/book_4/02.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_4/03.mp3 b/box/media/book_4/03.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_5/01.mp3 b/box/media/book_5/01.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_5/02.mp3 b/box/media/book_5/02.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_5/03.mp3 b/box/media/book_5/03.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_6/CD1/Titel A.mp3 b/box/media/book_6/CD1/Titel A.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_6/CD1/Titel B.mp3 b/box/media/book_6/CD1/Titel B.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_6/CD2/Titel A.mp3 b/box/media/book_6/CD2/Titel A.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/media/book_6/CD2/Titel B.mp3 b/box/media/book_6/CD2/Titel B.mp3 deleted file mode 100644 index e69de29..0000000 diff --git a/box/package-lock.json b/box/package-lock.json new file mode 100644 index 0000000..a084a70 --- /dev/null +++ b/box/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "box", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/box/storage/__pycache__/secret_store.cpython-312.pyc b/box/storage/__pycache__/secret_store.cpython-312.pyc index 4d8bbe4..7c8578b 100644 Binary files a/box/storage/__pycache__/secret_store.cpython-312.pyc and b/box/storage/__pycache__/secret_store.cpython-312.pyc differ diff --git a/box/storage/secret_store.py b/box/storage/secret_store.py index ff83b22..1ec7fb5 100644 --- a/box/storage/secret_store.py +++ b/box/storage/secret_store.py @@ -65,6 +65,12 @@ class SecretStore: return token return None + def clear_api_token(self) -> None: + data = self.load_secrets() + if "api_token" in data: + data.pop("api_token") + self.save_secrets(data) + def clear_wifi_secrets(self) -> None: data = self.load_secrets() if "wifi_profiles" in data: diff --git a/box2/api.py b/box2/api.py new file mode 100644 index 0000000..60a99f2 --- /dev/null +++ b/box2/api.py @@ -0,0 +1,141 @@ +"""HTTP API for the Klangkiste Box (FastAPI).""" + +from __future__ import annotations + +from typing import Any, Callable + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from core.box_controller import BoxController +from storage.box_storage import BoxStorage +from wifi.manager import WifiManager +from spotify.manager import SpotifyManager + + +class CommandRequest(BaseModel): + command: str + payload: dict[str, Any] = {} + + +def create_app( + controller: BoxController, + persist_resume: Callable[[], None], + wifi_manager: WifiManager, + spotify_manager: SpotifyManager, + storage: BoxStorage, +) -> FastAPI: + app = FastAPI() + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.get("/status") + def status() -> dict[str, Any]: + state = controller.state + return { + "box_id": state.box_id, + "network_status": wifi_manager.network_status, + "connected_ssid": wifi_manager.connected_ssid, + "wifi_profiles_count": wifi_manager.profiles_count, + "spotify_status": spotify_manager.status, + "server_status": _server_status(storage), + "playback_status": { + "state": state.state.value, + "active_uid": state.active_uid, + "playback_root": state.playback_path, + "current_file": state.current_media, + "file_index": state.file_index, + "position": state.position, + "duration": state.current_duration, + "volume": state.volume, + "last_error": state.last_error, + }, + } + + @app.post("/command") + def command(request: CommandRequest) -> dict[str, Any]: + cmd = request.command + payload = request.payload or {} + print(f"API command: {cmd} payload={payload}") + + if cmd == "nfc_on": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.handle_nfc_on(uid) + elif cmd == "nfc_off": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.handle_nfc_off(uid) + elif cmd == "play_pause": + controller.play_pause() + elif cmd == "next": + controller.next_track() + elif cmd == "prev": + controller.prev_track() + elif cmd == "vol_up": + controller.volume_up() + elif cmd == "vol_down": + controller.volume_down() + elif cmd == "stop": + if controller.state.active_uid: + controller.handle_nfc_off(controller.state.active_uid) + else: + controller.stop() + elif cmd == "trigger_error": + error_type = payload.get("type") + if not isinstance(error_type, str): + raise HTTPException(status_code=400, detail="type is required") + controller.trigger_error(error_type) + elif cmd == "wifi_add_profile": + ssid = payload.get("ssid") + password = payload.get("password") + priority = payload.get("priority", 0) + if not isinstance(ssid, str) or not isinstance(password, str): + raise HTTPException(status_code=400, detail="ssid and password required") + if not isinstance(priority, int): + priority = 0 + wifi_manager.add_profile(ssid, password, priority) + elif cmd == "wifi_remove_profile": + ssid = payload.get("ssid") + if not isinstance(ssid, str): + raise HTTPException(status_code=400, detail="ssid is required") + wifi_manager.remove_profile(ssid) + elif cmd == "wifi_reset": + wifi_manager.reset() + elif cmd == "spotify_set_tokens": + access_token = payload.get("access_token") + refresh_token = payload.get("refresh_token") + expires_at = payload.get("expires_at") + account_id = payload.get("account_id") + if not isinstance(access_token, str) or not isinstance(refresh_token, str): + raise HTTPException(status_code=400, detail="tokens are required") + if not isinstance(expires_at, int): + raise HTTPException(status_code=400, detail="expires_at is required") + if account_id is not None and not isinstance(account_id, str): + raise HTTPException(status_code=400, detail="account_id must be string") + spotify_manager.set_tokens(access_token, refresh_token, expires_at, account_id) + elif cmd == "spotify_clear": + spotify_manager.clear() + else: + raise HTTPException(status_code=400, detail="unknown command") + + persist_resume() + return {"ok": True} + + return app + + +def _server_status(storage: BoxStorage) -> str: + box_meta = storage.load_box_meta() + if box_meta.get("server_reachable") is False: + return "UNREACHABLE" + if box_meta.get("server_reachable") is True: + return "OK" + return "UNKNOWN" diff --git a/box/data/media/.keep b/box2/api/__init__.py similarity index 100% rename from box/data/media/.keep rename to box2/api/__init__.py diff --git a/box2/api/http.py b/box2/api/http.py new file mode 100644 index 0000000..10ce4f5 --- /dev/null +++ b/box2/api/http.py @@ -0,0 +1,237 @@ +"""HTTP API (FastAPI) for the Box.""" + +from __future__ import annotations + +from typing import Any, Callable + +from fastapi import FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +from core.box_controller import BoxController +from core.lifecycle import NetworkLifecycle +from core.pairing import PairingManager, PairingState +from spotify.base import SpotifyBackend +from wifi.base import WifiBackend + + +class CommandRequest(BaseModel): + command: str + payload: dict[str, Any] = {} + + +def create_app( + controller: BoxController, + lifecycle: NetworkLifecycle, + wifi_backend: WifiBackend, + spotify_backend: SpotifyBackend, + pairing_manager: PairingManager, + persist_state: Callable[[], None], + factory_reset: Callable[[], None], +) -> FastAPI: + app = FastAPI() + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + + @app.get("/status") + def status() -> dict[str, Any]: + snapshot = lifecycle.snapshot() + state = controller.state + return { + "box_id": state.box_id, + "pairing_state": pairing_manager.state.value, + "wifi_state": snapshot.wifi_state.value, + "ip_address": snapshot.ip_address, + "connected_ssid": snapshot.connected_ssid, + "wifi_profiles_count": snapshot.wifi_profiles_count, + "spotify_status": spotify_backend.status, + "last_nfc": { + "uid": state.last_nfc_uid, + "known": state.last_nfc_known, + }, + "last_nfc_at": state.last_nfc_at, + "playback_state": { + "state": state.state.value, + "active_uid": state.active_uid, + "playback_root": state.playback_path, + "current_file": state.current_media, + "file_index": state.file_index, + "position": state.position, + "duration": state.current_duration, + "volume": state.volume, + "last_error": state.last_error, + }, + "blocked_tags": controller.config.get("blocked_tags", []), + } + + @app.get("/local-tags") + def local_tags(http_request: Request) -> dict[str, Any]: + if not _is_authorized(pairing_manager, http_request): + raise HTTPException(status_code=403, detail="pairing required") + tags = controller.config.get("tags", {}) + result = [] + if isinstance(tags, dict): + for uid, tag in tags.items(): + if not isinstance(tag, dict): + continue + path_value = tag.get("path") + if not isinstance(path_value, str): + continue + root = controller._resolve_path(path_value) + files = [] + total_size = 0 + if root.exists() and root.is_dir(): + for file_path in root.rglob("*"): + if not file_path.is_file(): + continue + rel_path = file_path.relative_to(root).as_posix() + files.append(rel_path) + try: + total_size += file_path.stat().st_size + except OSError: + continue + result.append( + { + "uid": uid, + "path": path_value, + "media_exists": bool(files), + "file_count": len(files), + "total_size": total_size, + "files": files, + } + ) + return {"tags": result} + + @app.post("/command") + def command(request: CommandRequest, http_request: Request) -> dict[str, Any]: + cmd = request.command + payload = request.payload or {} + + if _requires_pairing(cmd) and not _is_authorized(pairing_manager, http_request): + raise HTTPException(status_code=403, detail="pairing required") + + if cmd == "nfc_on": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.handle_nfc_on(uid) + elif cmd == "nfc_off": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.handle_nfc_off(uid) + elif cmd == "play_pause": + controller.play_pause() + elif cmd == "next": + controller.next_track() + elif cmd == "prev": + controller.prev_track() + elif cmd in {"vol_up", "volume_up"}: + controller.volume_up() + elif cmd in {"vol_down", "volume_down"}: + controller.volume_down() + elif cmd == "stop": + if controller.state.active_uid: + controller.handle_nfc_off(controller.state.active_uid) + else: + controller.stop() + elif cmd == "trigger_error": + error_type = payload.get("type") + if not isinstance(error_type, str): + raise HTTPException(status_code=400, detail="type is required") + controller.trigger_error(error_type) + elif cmd == "wifi_add_profile": + ssid = payload.get("ssid") + password = payload.get("password") + priority = payload.get("priority", 0) + if not isinstance(ssid, str) or not isinstance(password, str): + raise HTTPException(status_code=400, detail="ssid and password required") + if not isinstance(priority, int): + priority = 0 + wifi_backend.add_profile(ssid, password, priority) + elif cmd == "wifi_reset": + wifi_backend.reset() + elif cmd == "spotify_set_tokens": + access_token = payload.get("access_token") + refresh_token = payload.get("refresh_token") + expires_at = payload.get("expires_at") + account_id = payload.get("account_id") + if not isinstance(access_token, str) or not isinstance(refresh_token, str): + raise HTTPException(status_code=400, detail="tokens are required") + if not isinstance(expires_at, int): + raise HTTPException(status_code=400, detail="expires_at is required") + if account_id is not None and not isinstance(account_id, str): + raise HTTPException(status_code=400, detail="account_id must be string") + spotify_backend.set_tokens(access_token, refresh_token, expires_at, account_id) + elif cmd == "spotify_clear": + spotify_backend.clear() + elif cmd == "unpair": + pairing_manager.unpair(reset_state=True) + elif cmd == "tag_assign": + uid = payload.get("uid") + media_path = payload.get("media_path") + if not isinstance(uid, str) or not isinstance(media_path, str): + raise HTTPException(status_code=400, detail="uid and media_path required") + controller.set_tag(uid, media_path) + elif cmd == "tag_remove": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.remove_tag(uid) + elif cmd == "tag_block": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.block_tag(uid) + elif cmd == "tag_unblock": + uid = payload.get("uid") + if not isinstance(uid, str): + raise HTTPException(status_code=400, detail="uid is required") + controller.unblock_tag(uid) + elif cmd == "export_tag": + uid = payload.get("uid") + target_folder = payload.get("target_folder") + if not isinstance(uid, str) or not isinstance(target_folder, str): + raise HTTPException(status_code=400, detail="uid and target_folder required") + if "/" in target_folder or "\\" in target_folder or not target_folder.strip(): + raise HTTPException(status_code=400, detail="invalid target_folder") + ok = controller.export_tag_media(uid, target_folder.strip()) + if not ok: + raise HTTPException(status_code=500, detail="export failed") + elif cmd == "factory_reset": + factory_reset() + return {"ok": True, "restart_required": True} + else: + raise HTTPException(status_code=400, detail="unknown command") + + persist_state() + return {"ok": True} + + return app + + +def _requires_pairing(command: str) -> bool: + allowed_without_pairing = { + "wifi_add_profile", + "wifi_reset", + "spotify_set_tokens", + "spotify_clear", + "factory_reset", + } + return command not in allowed_without_pairing + + +def _is_authorized(pairing: PairingManager, http_request: Request) -> bool: + if not pairing.is_paired(): + return False + token = pairing.get_api_token() + if not token: + return False + header = http_request.headers.get("X-API-Token") + if not header: + return False + return header == token diff --git a/box/data/media/book_1/01.mp3 b/box2/audio/__init__.py similarity index 100% rename from box/data/media/book_1/01.mp3 rename to box2/audio/__init__.py diff --git a/box2/audio/base.py b/box2/audio/base.py new file mode 100644 index 0000000..884175e --- /dev/null +++ b/box2/audio/base.py @@ -0,0 +1,16 @@ +"""Audio backend interface (placeholder).""" + +from __future__ import annotations + +from typing import Protocol + + +class AudioBackend(Protocol): + def play(self, media_ref: str) -> None: + raise NotImplementedError + + def pause(self) -> None: + raise NotImplementedError + + def stop(self) -> None: + raise NotImplementedError diff --git a/box2/audio/mock.py b/box2/audio/mock.py new file mode 100644 index 0000000..e926680 --- /dev/null +++ b/box2/audio/mock.py @@ -0,0 +1,19 @@ +"""Mock audio backend (no real playback).""" + +from __future__ import annotations + +from audio.base import AudioBackend + + +class MockAudioBackend(AudioBackend): + def __init__(self) -> None: + self._current: str | None = None + + def play(self, media_ref: str) -> None: + self._current = media_ref + + def pause(self) -> None: + return None + + def stop(self) -> None: + self._current = None diff --git a/box/data/media/book_1/02.mp3 b/box2/core/__init__.py similarity index 100% rename from box/data/media/book_1/02.mp3 rename to box2/core/__init__.py diff --git a/box2/core/announce.py b/box2/core/announce.py new file mode 100644 index 0000000..455cf20 --- /dev/null +++ b/box2/core/announce.py @@ -0,0 +1,100 @@ +"""Server announce and pairing polling client.""" + +from __future__ import annotations + +import asyncio +import json +import urllib.request +from dataclasses import dataclass +from typing import Any + +from core.identity import compute_fingerprint +from core.pairing import PairingManager, PairingState +from storage.secret_store import SecretStore + + +@dataclass +class AnnounceConfig: + server_url: str + firmware_version: str + capabilities: dict[str, Any] + api_port: int + setup_port: int + media_root: str + announce_interval_seconds: int = 30 + polling_interval_seconds: int = 10 + + +def build_payload( + box_id: str, + secret_seed: str, + config: AnnounceConfig, +) -> dict[str, Any]: + fingerprint = compute_fingerprint(box_id, secret_seed) + return { + "box_id": box_id, + "fingerprint": fingerprint, + "firmware_version": config.firmware_version, + "capabilities": config.capabilities, + "api_port": config.api_port, + "setup_port": config.setup_port, + "media_root": config.media_root, + } + + +def _post_json(url: str, payload: dict[str, Any]) -> dict[str, Any] | None: + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + body = resp.read().decode("utf-8") + except Exception: + return None + try: + return json.loads(body) + except json.JSONDecodeError: + return None + + +async def announce_loop( + box_id: str, + secrets: SecretStore, + pairing: PairingManager, + config: AnnounceConfig, + stop_event: asyncio.Event, +) -> None: + secret_seed = secrets.ensure_secret_seed() + payload = build_payload(box_id, secret_seed, config) + announce_url = f"{config.server_url}/api/boxes/announce" + + while not stop_event.is_set(): + response = await asyncio.to_thread(_post_json, announce_url, payload) + if response and response.get("force_unpair") is True: + pairing.unpair(reset_state=True) + await asyncio.sleep(config.announce_interval_seconds) + + +async def pairing_poll_loop( + box_id: str, + pairing: PairingManager, + config: AnnounceConfig, + stop_event: asyncio.Event, +) -> None: + poll_url = f"{config.server_url}/api/boxes/poll-pairing" + payload = {"box_id": box_id} + while not stop_event.is_set(): + if pairing.is_paired(): + await asyncio.sleep(config.polling_interval_seconds) + continue + pairing.set_state(PairingState.PAIRING_PENDING) + response = await asyncio.to_thread(_post_json, poll_url, payload) + if response and response.get("paired") is True: + token = response.get("api_token") + if isinstance(token, str) and token: + pairing.save_api_token(token) + await asyncio.sleep(config.polling_interval_seconds) diff --git a/box2/core/box_controller.py b/box2/core/box_controller.py new file mode 100644 index 0000000..f0d396b --- /dev/null +++ b/box2/core/box_controller.py @@ -0,0 +1,455 @@ +"""Central orchestration placeholder for the Box runtime.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +import time +import shutil + +from core.state import BoxState, RuntimeState +from player.base_player import BasePlayer +from core.media_sync import MediaSyncClient +import hashlib + + +class BoxController: + def __init__( + self, + state: RuntimeState, + player: BasePlayer, + config: dict[str, Any], + config_path: str, + media_sync: MediaSyncClient | None = None, + ) -> None: + self._state = state + self._player = player + self._config = config + self._config_path = Path(config_path) + self._media_sync = media_sync + + @property + def config(self) -> dict[str, Any]: + return self._config + + @property + def state(self) -> RuntimeState: + return self._state + + def start(self) -> None: + """Start the main loop (not implemented in Phase 1a).""" + return None + + def handle_nfc_on(self, uid: str) -> None: + if self._state.state == BoxState.ERROR: + return None + if self._state.active_uid == uid and self._state.state != BoxState.IDLE: + return None + if self._config.get("server_reachable") is False: + print("error: server not reachable") + self._trigger_error("server_unreachable") + return None + blocked = self._config.get("blocked_tags") + if isinstance(blocked, list) and uid in blocked: + self._state.last_nfc_uid = uid + self._state.last_nfc_known = True + self._state.last_nfc_at = int(time.time()) + self._trigger_error("tag_blocked") + return None + tag = self._resolve_tag(uid) + resolve_error = None + if tag is None: + tag, resolve_error = self._resolve_tag_from_server(uid) + self._state.last_nfc_uid = uid + self._state.last_nfc_known = tag is not None or resolve_error == "tag_blocked" + self._state.last_nfc_at = int(time.time()) + if resolve_error == "tag_blocked": + self._trigger_error("tag_blocked") + return None + if tag is None: + return None + self._state.last_error = None + + playlist = self._build_playlist(tag) + if not playlist: + path_value = tag.get("path", "-") + resolved = self._resolve_path(path_value) + if self._request_media_sync(uid): + playlist = self._build_playlist(tag) + if playlist: + self._state.last_error = None + else: + print( + f"error: no playable files found for uid '{uid}' at path '{resolved}'" + ) + self._trigger_error("no_playable_files") + return None + else: + print( + f"error: no playable files found for uid '{uid}' at path '{resolved}'" + ) + self._trigger_error("no_playable_files") + return None + + if uid in self._state._resume: + file_index, position = self._state._resume[uid] + else: + file_index, position = 0, 0 + + if file_index < 0 or file_index >= len(playlist): + print( + f"error: stored progress points outside playlist for uid '{uid}'" + ) + self._trigger_error("invalid_resume") + return None + + if self._state.active_uid and self._state.active_uid != uid: + self._store_progress(self._state.active_uid) + self.stop() + + media_ref = playlist[file_index] + duration = self._duration_for(media_ref) + self._state.active_uid = uid + self._state.playback_type = tag.get("type") + self._state.playback_path = tag.get("path") + self._state.current_file = Path(media_ref).name + self._state.current_duration = duration + self._state.file_index = file_index + self._state.position = position + self._state._playlist = playlist + + self._player.play(media_ref) + self._state.current_media = media_ref + self._state.state = BoxState.PLAYING + + def pause(self) -> None: + self._player.pause() + if self._state.state == BoxState.PLAYING: + self._state.state = BoxState.PAUSED + + def stop(self) -> None: + self._player.stop() + self._clear_playback_state() + + def handle_nfc_off(self, uid: str) -> None: + if self._state.state == BoxState.ERROR: + return None + if self._state.last_nfc_uid == uid: + self._state.last_nfc_uid = None + self._state.last_nfc_known = None + self._state.last_nfc_at = None + if self._state.active_uid != uid: + return None + self._store_progress(uid) + self.stop() + + def set_tag(self, uid: str, media_path: str) -> None: + tags = self._config.get("tags") + if not isinstance(tags, dict): + tags = {} + self._config["tags"] = tags + tags[uid] = {"type": "folder", "path": media_path} + if uid in self._state._resume: + self._state._resume.pop(uid, None) + self._state._resume[uid] = (0, 0) + if self._state.last_nfc_uid == uid: + self._state.last_nfc_known = True + self._save_config() + if not self._build_playlist(tags[uid]): + self._request_media_sync(uid) + + def _remove_tag_data(self, uid: str) -> None: + tags = self._config.get("tags") + if not isinstance(tags, dict): + return None + removed = None + if uid in tags: + removed = tags.pop(uid) + self._state._resume.pop(uid, None) + if self._state.active_uid == uid: + self.stop() + if self._state.last_nfc_uid == uid: + self._state.last_nfc_uid = None + self._state.last_nfc_known = None + self._state.last_nfc_at = None + if isinstance(removed, dict): + path_value = removed.get("path") + if isinstance(path_value, str): + media_root = (self._config_path.parent.parent / "media").resolve() + resolved = self._resolve_path(path_value) + if media_root in resolved.parents: + shutil.rmtree(resolved, ignore_errors=True) + + def remove_tag(self, uid: str) -> None: + self._remove_tag_data(uid) + blocked = self._config.get("blocked_tags") + if isinstance(blocked, list) and uid in blocked: + blocked = [value for value in blocked if value != uid] + self._config["blocked_tags"] = blocked + self._save_config() + + def block_tag(self, uid: str) -> None: + blocked = self._config.get("blocked_tags") + if not isinstance(blocked, list): + blocked = [] + if uid not in blocked: + blocked.append(uid) + self._config["blocked_tags"] = blocked + if self._state.active_uid == uid or self._state.last_nfc_uid == uid: + self._trigger_error("tag_blocked") + self._remove_tag_data(uid) + self._save_config() + + def unblock_tag(self, uid: str) -> None: + blocked = self._config.get("blocked_tags") + if not isinstance(blocked, list): + return None + if uid in blocked: + blocked = [value for value in blocked if value != uid] + self._config["blocked_tags"] = blocked + self._save_config() + + def _resolve_tag(self, uid: str) -> dict[str, Any] | None: + tags = self._config.get("tags", {}) + if not isinstance(tags, dict): + return None + tag = tags.get(uid) + if not isinstance(tag, dict): + return None + if "type" not in tag or "path" not in tag: + return None + return tag + + def _save_config(self) -> None: + data = dict(self._config) + self._config_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def _request_media_sync(self, uid: str) -> bool: + if not self._media_sync: + return False + ok = self._media_sync.request_tag_media(uid) + if ok: + print(f"info: media sync requested for uid '{uid}'") + else: + print(f"error: media sync request failed for uid '{uid}'") + return ok + + def _resolve_tag_from_server(self, uid: str) -> tuple[dict[str, Any] | None, str | None]: + if not self._media_sync: + return None, None + media_path, error = self._media_sync.resolve_tag(uid) + if error: + return None, error + if not media_path: + return None, None + self.set_tag(uid, f"media/{uid}") + return self._resolve_tag(uid), None + + def export_tag_media(self, uid: str, target_folder: str) -> bool: + if not self._media_sync: + return False + tag = self._resolve_tag(uid) + if not tag: + return False + path_value = tag.get("path") + if not isinstance(path_value, str): + return False + root = self._resolve_path(path_value) + if not root.exists() or not root.is_dir(): + return False + + for file_path in root.rglob("*"): + if not file_path.is_file(): + continue + rel_path = file_path.relative_to(root).as_posix() + try: + data = file_path.read_bytes() + except OSError: + return False + if not self._media_sync.upload_media_file(target_folder, rel_path, data): + return False + return True + + def tick(self, seconds: int) -> None: + if self._state.state == BoxState.ERROR: + return None + if self._state.state != BoxState.PLAYING: + return None + if self._state.position is None or self._state.file_index is None: + return None + if not self._state._playlist: + return None + if seconds <= 0: + return None + + remaining = seconds + file_index = self._state.file_index + position = self._state.position + + while remaining > 0 and file_index < len(self._state._playlist): + media_ref = self._state._playlist[file_index] + duration = self._duration_for(media_ref) + if duration <= 0: + break + time_left = duration - position + if remaining < time_left: + position += remaining + remaining = 0 + else: + remaining -= time_left + file_index += 1 + position = 0 + + if file_index >= len(self._state._playlist): + last_index = len(self._state._playlist) - 1 + last_media = self._state._playlist[last_index] + last_duration = self._duration_for(last_media) + self._state.file_index = last_index + self._state.position = last_duration + if self._state.active_uid: + self._store_progress(self._state.active_uid) + self.stop() + return None + + self._state.file_index = file_index + self._state.position = position + media_ref = self._state._playlist[file_index] + self._state.current_media = media_ref + self._state.current_file = Path(media_ref).name + self._state.current_duration = self._duration_for(media_ref) + + def _build_playlist(self, tag: dict[str, Any]) -> list[str]: + path_value = tag.get("path") + if not isinstance(path_value, str): + return [] + path = self._resolve_path(path_value) + if not path.exists() or not path.is_dir(): + return [] + return [str(p) for p in self._collect_files(path)] + + def _collect_files(self, root: Path) -> list[Path]: + entries = list(root.iterdir()) + dirs = sorted([p for p in entries if p.is_dir()], key=lambda p: p.name) + files = sorted([p for p in entries if p.is_file()], key=lambda p: p.name) + result: list[Path] = [] + for directory in dirs: + result.extend(self._collect_files(directory)) + result.extend(files) + return result + + def _resolve_path(self, path_value: str) -> Path: + path = Path(path_value) + if not path.is_absolute(): + media_root = self._config_path.parent.parent / "media" + if path_value.startswith("media/"): + return (media_root / path_value.replace("media/", "", 1)).resolve() + return (media_root / path).resolve() + return path + + def _store_progress(self, uid: str) -> None: + file_index = self._state.file_index if self._state.file_index is not None else 0 + position = self._state.position if self._state.position is not None else 0 + self._state._resume[uid] = (file_index, position) + + def _duration_for(self, media_ref: str) -> int: + name = Path(media_ref).name + durations = {"01.mp3": 180, "02.mp3": 200, "03.mp3": 220} + if name in durations: + return durations[name] + digest = hashlib.md5(name.encode("utf-8")).hexdigest() + seed = int(digest[:8], 16) + return 120 + (seed % 241) + + def play_pause(self) -> None: + if self._state.state in {BoxState.IDLE, BoxState.ERROR}: + return None + if self._state.state == BoxState.PLAYING: + self._state.state = BoxState.PAUSED + return None + if self._state.state == BoxState.PAUSED: + self._state.state = BoxState.PLAYING + return None + + def next_track(self) -> None: + if self._state.state in {BoxState.IDLE, BoxState.ERROR}: + return None + if not self._state._playlist: + return None + if self._state.file_index is None: + return None + next_index = self._state.file_index + 1 + if next_index >= len(self._state._playlist): + if self._state.active_uid: + self._store_progress(self._state.active_uid) + self.stop() + return None + self._jump_to_index(next_index, 0) + self._state.state = BoxState.PLAYING + + def prev_track(self, threshold_seconds: int = 3) -> None: + if self._state.state in {BoxState.IDLE, BoxState.ERROR}: + return None + if self._state.file_index is None or self._state.position is None: + return None + if self._state.position > threshold_seconds: + self._state.position = 0 + return None + prev_index = max(self._state.file_index - 1, 0) + self._jump_to_index(prev_index, 0) + self._state.state = BoxState.PLAYING + + def volume_up(self, step: int = 5) -> None: + if self._state.state == BoxState.ERROR: + return None + self._state.volume = min(self._state.volume + step, self._state.max_volume) + + def volume_down(self, step: int = 5) -> None: + if self._state.state == BoxState.ERROR: + return None + self._state.volume = max(self._state.volume - step, 0) + + def _jump_to_index(self, file_index: int, position: int) -> None: + if not self._state._playlist: + return None + if file_index < 0 or file_index >= len(self._state._playlist): + return None + media_ref = self._state._playlist[file_index] + self._state.file_index = file_index + self._state.position = position + self._state.current_media = media_ref + self._state.current_file = Path(media_ref).name + self._state.current_duration = self._duration_for(media_ref) + self._player.play(media_ref) + + def _trigger_error(self, code: str) -> None: + if self._state.state in {BoxState.PLAYING, BoxState.PAUSED}: + self._player.pause() + self._state.state = BoxState.ERROR + self._state.last_error = code + self._player.play(f"system:error:{code}") + self._clear_playback_state() + + def _clear_playback_state(self) -> None: + self._state.state = BoxState.IDLE + self._state.active_uid = None + self._state.playback_type = None + self._state.playback_path = None + self._state.current_media = None + self._state.current_file = None + self._state.current_duration = None + self._state.file_index = None + self._state.position = None + self._state._playlist = [] + + def trigger_error(self, code: str) -> None: + self._trigger_error(code) + + def factory_reset(self) -> None: + self.stop() + self._state._resume.clear() + self._state.last_error = None + self._state.last_nfc_uid = None + self._state.last_nfc_known = None + self._state.last_nfc_at = None + self._config.clear() diff --git a/box2/core/controller.py b/box2/core/controller.py new file mode 100644 index 0000000..375c654 --- /dev/null +++ b/box2/core/controller.py @@ -0,0 +1,7 @@ +"""Compatibility re-export for BoxController.""" + +from __future__ import annotations + +from core.box_controller import BoxController + +__all__ = ["BoxController"] diff --git a/box2/core/identity.py b/box2/core/identity.py new file mode 100644 index 0000000..8bfd141 --- /dev/null +++ b/box2/core/identity.py @@ -0,0 +1,30 @@ +"""Box identity generation and management.""" + +from __future__ import annotations + +import hashlib +import secrets +import string + +PREFIX = "klangkiste-" +LETTERS = string.ascii_lowercase +DIGITS = string.digits +SUFFIX_LENGTH = 10 +STATIC_APP_SALT = "klangkiste-static-app-salt-v1" + + +def generate_box_id() -> str: + start_with_letter = secrets.choice([True, False]) + suffix_chars = [] + for i in range(SUFFIX_LENGTH): + if (i % 2 == 0) == start_with_letter: + suffix_chars.append(secrets.choice(LETTERS)) + else: + suffix_chars.append(secrets.choice(DIGITS)) + suffix = "".join(suffix_chars) + return f"{PREFIX}{suffix}" + + +def compute_fingerprint(box_id: str, secret_seed: str) -> str: + payload = f"{box_id}{secret_seed}{STATIC_APP_SALT}".encode("utf-8") + return hashlib.sha256(payload).hexdigest() diff --git a/box2/core/lifecycle.py b/box2/core/lifecycle.py new file mode 100644 index 0000000..6e6d004 --- /dev/null +++ b/box2/core/lifecycle.py @@ -0,0 +1,52 @@ +"""Network lifecycle state machine (logical).""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from wifi.base import WifiBackend + + +class WifiState(str, Enum): + WIFI_UNCONFIGURED = "WIFI_UNCONFIGURED" + WIFI_CONNECTING = "WIFI_CONNECTING" + WIFI_ONLINE = "WIFI_ONLINE" + WIFI_OFFLINE = "WIFI_OFFLINE" + + +@dataclass +class NetworkSnapshot: + wifi_state: WifiState + ip_address: str + connected_ssid: str | None + wifi_profiles_count: int + + +class NetworkLifecycle: + def __init__(self, wifi_backend: WifiBackend) -> None: + self._wifi = wifi_backend + self._wifi_state = WifiState.WIFI_UNCONFIGURED + self._ip_address = "192.168.4.1" + self.refresh() + + def refresh(self) -> None: + if self._wifi.profiles_count == 0: + self._wifi_state = WifiState.WIFI_UNCONFIGURED + self._ip_address = "192.168.4.1" + return + if self._wifi.connected_ssid: + self._wifi_state = WifiState.WIFI_ONLINE + self._ip_address = "127.0.0.1" + return + self._wifi_state = WifiState.WIFI_OFFLINE + self._ip_address = "127.0.0.1" + + def snapshot(self) -> NetworkSnapshot: + self.refresh() + return NetworkSnapshot( + wifi_state=self._wifi_state, + ip_address=self._ip_address, + connected_ssid=self._wifi.connected_ssid, + wifi_profiles_count=self._wifi.profiles_count, + ) diff --git a/box2/core/media_sync.py b/box2/core/media_sync.py new file mode 100644 index 0000000..22634c4 --- /dev/null +++ b/box2/core/media_sync.py @@ -0,0 +1,111 @@ +"""Client to request media sync from the server.""" + +from __future__ import annotations + +import json +import base64 +from dataclasses import dataclass +from typing import Callable +from urllib import request, error + + +@dataclass +class MediaSyncClient: + server_url: str + box_id: str + token_provider: Callable[[], str | None] + timeout_seconds: int = 5 + + def request_tag_media(self, uid: str) -> bool: + token = self.token_provider() + if not token: + return False + endpoint = f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/media-sync" + payload = json.dumps({"uid": uid}).encode("utf-8") + req = request.Request( + endpoint, + data=payload, + method="POST", + headers={ + "Content-Type": "application/json", + "X-API-Token": token, + }, + ) + try: + with request.urlopen(req, timeout=self.timeout_seconds) as response: + return 200 <= response.status < 300 + except error.HTTPError: + return False + except error.URLError: + return False + + def resolve_tag(self, uid: str) -> tuple[str | None, str | None]: + token = self.token_provider() + if not token: + return None, None + endpoint = ( + f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/resolve-tag" + ) + payload = json.dumps({"uid": uid}).encode("utf-8") + req = request.Request( + endpoint, + data=payload, + method="POST", + headers={ + "Content-Type": "application/json", + "X-API-Token": token, + }, + ) + try: + with request.urlopen(req, timeout=self.timeout_seconds) as response: + body = response.read().decode("utf-8") + except error.HTTPError as http_error: + try: + detail = json.loads(http_error.read().decode("utf-8")).get("detail") + except Exception: + detail = None + if detail == "tag_blocked": + return None, "tag_blocked" + return None, None + except error.URLError: + return None, None + + try: + data = json.loads(body) + except json.JSONDecodeError: + return None, None + media_path = data.get("media_path") + if isinstance(media_path, str) and media_path: + return media_path, None + return None, None + + def upload_media_file(self, target_folder: str, rel_path: str, data: bytes) -> bool: + token = self.token_provider() + if not token: + return False + endpoint = ( + f"{self.server_url.rstrip('/')}/api/boxes/{self.box_id}/media-import" + ) + payload = json.dumps( + { + "target_folder": target_folder, + "rel_path": rel_path, + "content_base64": base64.b64encode(data).decode("ascii"), + } + ).encode("utf-8") + req = request.Request( + endpoint, + data=payload, + method="POST", + headers={ + "Content-Type": "application/json", + "X-API-Token": token, + }, + ) + try: + with request.urlopen(req, timeout=self.timeout_seconds) as response: + return 200 <= response.status < 300 + except error.HTTPError: + return False + except error.URLError: + return False diff --git a/box2/core/pairing.py b/box2/core/pairing.py new file mode 100644 index 0000000..4315898 --- /dev/null +++ b/box2/core/pairing.py @@ -0,0 +1,70 @@ +"""Pairing state management for the Box.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + +from storage.state_store import StateStore +from storage.secret_store import SecretStore + + +class PairingState(str, Enum): + UNPAIRED = "UNPAIRED" + PAIRING_PENDING = "PAIRING_PENDING" + PAIRED = "PAIRED" + + +@dataclass +class PairingSnapshot: + state: PairingState + + +class PairingManager: + def __init__(self, state_store: StateStore, secret_store: SecretStore) -> None: + self._state_store = state_store + self._secret_store = secret_store + self._state = PairingState.UNPAIRED + self._load() + + @property + def state(self) -> PairingState: + return self._state + + def set_state(self, state: PairingState) -> None: + self._state = state + self._save() + + def snapshot(self) -> PairingSnapshot: + return PairingSnapshot(state=self._state) + + def is_paired(self) -> bool: + return self._state == PairingState.PAIRED + + def get_api_token(self) -> str | None: + return self._secret_store.get_api_token() + + def save_api_token(self, token: str) -> None: + self._secret_store.save_api_token(token) + self.set_state(PairingState.PAIRED) + + def unpair(self, reset_state: bool = False) -> None: + self._secret_store.clear_api_token() + self.set_state(PairingState.UNPAIRED) + if reset_state: + data = self._state_store.load() + data["pairing"] = {"state": PairingState.UNPAIRED.name} + data["resume"] = {} + self._state_store.save(data) + + def _load(self) -> None: + data = self._state_store.load() + pairing = data.get("pairing") if isinstance(data.get("pairing"), dict) else {} + state_raw = pairing.get("state") + if isinstance(state_raw, str) and state_raw in PairingState.__members__: + self._state = PairingState[state_raw] + + def _save(self) -> None: + data = self._state_store.load() + data["pairing"] = {"state": self._state.name} + self._state_store.save(data) diff --git a/box2/core/state.py b/box2/core/state.py new file mode 100644 index 0000000..ecd90b2 --- /dev/null +++ b/box2/core/state.py @@ -0,0 +1,36 @@ +"""State model for the Box runtime (placeholder).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class BoxState(str, Enum): + IDLE = "IDLE" + PLAYING = "PLAYING" + PAUSED = "PAUSED" + ERROR = "ERROR" + + +@dataclass +class RuntimeState: + box_id: str + state: BoxState = BoxState.IDLE + current_media: Optional[str] = None + volume: int = 0 + active_uid: Optional[str] = None + playback_type: Optional[str] = None + playback_path: Optional[str] = None + current_file: Optional[str] = None + current_duration: Optional[int] = None + file_index: Optional[int] = None + position: Optional[int] = None + last_error: Optional[str] = None + last_nfc_uid: Optional[str] = None + last_nfc_known: Optional[bool] = None + last_nfc_at: Optional[int] = None + _resume: dict[str, tuple[int, int]] = field(default_factory=dict) + _playlist: list[str] = field(default_factory=list) + max_volume: int = 100 diff --git a/box/media/.keep b/box2/data/.keep similarity index 100% rename from box/media/.keep rename to box2/data/.keep diff --git a/box2/data/box.json b/box2/data/box.json new file mode 100644 index 0000000..0758bed --- /dev/null +++ b/box2/data/box.json @@ -0,0 +1,12 @@ +{ + "box_id": "klangkiste-c1b9h2f3j5", + "settings": {}, + "tags": {}, + "server_url": "http://10.10.10.23:5001", + "firmware_version": "0.1.0", + "capabilities": { + "nfc": true, + "audio": true, + "spotify": true + } +} \ No newline at end of file diff --git a/box2/data/secrets.enc b/box2/data/secrets.enc new file mode 100644 index 0000000..480c190 --- /dev/null +++ b/box2/data/secrets.enc @@ -0,0 +1 @@ +gAAAAABpZpDxBsn-LkdKkkUukRVKtBoRtjK6EprtWis8WxJlqDqdybx_9ZtdZtPVcVEdFEgIWtmXJKRk9uAHVAFTAtnDiE_fxLRt4LHc3D7roHMehd853E4Dxb_DtWfLQ7XOYQ9Zsij1uF1w08v-WFiTfb4zOWq_usPeidr0Mo0BGD7V_R3LP9Vv1m61pZA--M8kwrMlHxiOGyn1QHGTagizuuk2IKuWwKmg_BeHfBeBndQuU-q6uaHfu9c68iAu1YU44mAemuYk3px4q31zEjgyqmJPVO7Tn93CgltVPGCYx7VhHhxfmbU= \ No newline at end of file diff --git a/box2/data/state.json b/box2/data/state.json new file mode 100644 index 0000000..5d30da0 --- /dev/null +++ b/box2/data/state.json @@ -0,0 +1,6 @@ +{ + "pairing": { + "state": "PAIRED" + }, + "resume": {} +} \ No newline at end of file diff --git a/box/data/media/book_1/03.mp3 b/box2/hardware/__init__.py similarity index 100% rename from box/data/media/book_1/03.mp3 rename to box2/hardware/__init__.py diff --git a/box2/hardware/base_buttons.py b/box2/hardware/base_buttons.py new file mode 100644 index 0000000..46e7884 --- /dev/null +++ b/box2/hardware/base_buttons.py @@ -0,0 +1,10 @@ +"""Base button interface.""" + +from __future__ import annotations + +from typing import Protocol + + +class BaseButtons(Protocol): + def poll(self) -> str | None: + raise NotImplementedError diff --git a/box2/hardware/base_nfc.py b/box2/hardware/base_nfc.py new file mode 100644 index 0000000..9969df2 --- /dev/null +++ b/box2/hardware/base_nfc.py @@ -0,0 +1,10 @@ +"""Base NFC reader interface.""" + +from __future__ import annotations + +from typing import Protocol + + +class BaseNFC(Protocol): + def read_tag(self) -> str | None: + raise NotImplementedError diff --git a/box2/hardware/mock_buttons.py b/box2/hardware/mock_buttons.py new file mode 100644 index 0000000..84e0792 --- /dev/null +++ b/box2/hardware/mock_buttons.py @@ -0,0 +1,20 @@ +"""Mock buttons for Phase 1a.""" + +from __future__ import annotations + +from collections import deque + +from hardware.base_buttons import BaseButtons + + +class MockButtons(BaseButtons): + def __init__(self) -> None: + self._queue: deque[str] = deque() + + def enqueue(self, button_id: str) -> None: + self._queue.append(button_id) + + def poll(self) -> str | None: + if not self._queue: + return None + return self._queue.popleft() diff --git a/box2/hardware/mock_nfc.py b/box2/hardware/mock_nfc.py new file mode 100644 index 0000000..7c7c98f --- /dev/null +++ b/box2/hardware/mock_nfc.py @@ -0,0 +1,18 @@ +"""Mock NFC reader for Phase 1a.""" + +from __future__ import annotations + +from hardware.base_nfc import BaseNFC + + +class MockNFC(BaseNFC): + def __init__(self) -> None: + self._next_uid: str | None = None + + def set_next_uid(self, uid: str | None) -> None: + self._next_uid = uid + + def read_tag(self) -> str | None: + uid = self._next_uid + self._next_uid = None + return uid diff --git a/box2/main.py b/box2/main.py new file mode 100644 index 0000000..56aaeb8 --- /dev/null +++ b/box2/main.py @@ -0,0 +1,497 @@ +"""CLI entrypoint for the Box runtime (Phase 1a).""" + +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import os +import sys +import shutil +from pathlib import Path +from typing import Any + +import uvicorn + +from api.http import create_app +from core.announce import AnnounceConfig, announce_loop, pairing_poll_loop +from core.box_controller import BoxController +from core.identity import generate_box_id +from core.lifecycle import NetworkLifecycle +from core.media_sync import MediaSyncClient +from core.pairing import PairingManager +from core.state import BoxState, RuntimeState +from player.mock_player import MockPlayer +from setup.webui import create_setup_app +from spotify.mock import MockSpotifyBackend +from storage.box_store import BoxStore +from storage.state_store import StateStore +from storage.secret_store import SecretStore +from wifi.mock import MockWifiBackend + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="klangkiste-box", + description="Klangkiste Box CLI (Phase 1a, local simulation)", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + subparsers.add_parser("status", help="Show current runtime status") + + nfc_on = subparsers.add_parser("simulate-nfc", help="Simulate NFC tag detection") + nfc_on.add_argument("uid", help="NFC UID") + + nfc_off = subparsers.add_parser("simulate-nfc-off", help="Simulate NFC tag removal") + nfc_off.add_argument("uid", help="NFC UID") + + tick_parser = subparsers.add_parser("tick", help="Simulate playback time") + tick_parser.add_argument("seconds", type=int, help="Seconds to advance") + + subparsers.add_parser("run", help="Start interactive session + HTTP API") + subparsers.add_parser("factory-reset", help="Factory reset (delete all local data)") + return parser + + +def _state_from_data( + box_meta: dict[str, Any], state_data: dict[str, Any] +) -> RuntimeState: + box_id = str(box_meta.get("box_id", "unknown")) + settings = box_meta.get("settings", {}) if isinstance(box_meta.get("settings"), dict) else {} + default_volume = settings.get("default_volume", 0) + max_volume = settings.get("max_volume", 100) + state = RuntimeState( + box_id=box_id, + volume=int(default_volume), + max_volume=int(max_volume), + ) + + resume_raw = state_data.get("resume", {}) + if isinstance(resume_raw, dict): + for uid, data in resume_raw.items(): + if not isinstance(uid, str) or not isinstance(data, dict): + continue + file_index = data.get("file_index") + position = data.get("position") + if isinstance(file_index, int) and isinstance(position, int): + state._resume[uid] = (file_index, position) + return state + + +def _prune_resume(state: RuntimeState, tags: dict[str, Any]) -> bool: + if not isinstance(tags, dict): + return False + valid_uids = {uid for uid, tag in tags.items() if isinstance(tag, dict)} + stale_uids = [uid for uid in state._resume.keys() if uid not in valid_uids] + if not stale_uids: + return False + for uid in stale_uids: + state._resume.pop(uid, None) + return True + + +def _print_status(state: RuntimeState) -> None: + current = state.current_media if state.current_media is not None else "-" + current_file = state.current_file if state.current_file is not None else "-" + current_duration = state.current_duration if state.current_duration is not None else "-" + active_uid = state.active_uid if state.active_uid is not None else "-" + playback = state.playback_path if state.playback_path is not None else "-" + file_index = state.file_index if state.file_index is not None else "-" + position = state.position if state.position is not None else "-" + print(f"box_id={state.box_id}") + print(f"state={state.state.value}") + print(f"active_uid={active_uid}") + print(f"playback={playback}") + print(f"current_file={current_file}") + print(f"file_index={file_index}") + print(f"position={position}") + print(f"duration={current_duration}") + print(f"current_media={current}") + print(f"volume={state.volume}") + print(f"max_volume={state.max_volume}") + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + base_dir = Path(__file__).resolve().parent / "data" + base_dir.mkdir(parents=True, exist_ok=True) + media_root = Path(__file__).resolve().parent / "media" + media_root.mkdir(parents=True, exist_ok=True) + + box_store = BoxStore(base_dir) + box_meta = box_store.load() + if "box_id" not in box_meta: + box_meta["box_id"] = generate_box_id() + if "settings" not in box_meta: + box_meta["settings"] = {} + if "tags" not in box_meta: + box_meta["tags"] = {} + env_server_url = os.getenv("KLANGKISTE_SERVER_URL") + if env_server_url: + box_meta["server_url"] = env_server_url + elif "server_url" not in box_meta: + box_meta["server_url"] = "http://127.0.0.1:5001" + if "firmware_version" not in box_meta: + box_meta["firmware_version"] = "0.1.0" + if "capabilities" not in box_meta: + box_meta["capabilities"] = {"nfc": True, "audio": True, "spotify": True} + box_store.save(box_meta) + + state_store = StateStore(base_dir) + state_data = state_store.load() + + state = _state_from_data(box_meta, state_data) + resume_pruned = _prune_resume(state, box_meta.get("tags", {})) + secrets = SecretStore(base_dir, state.box_id) + secrets.ensure_secret_seed() + wifi_backend = MockWifiBackend(state_store, secrets) + spotify_backend = MockSpotifyBackend(secrets) + lifecycle = NetworkLifecycle(wifi_backend) + pairing_manager = PairingManager(state_store, secrets) + media_sync = MediaSyncClient( + server_url=str(box_meta.get("server_url")), + box_id=state.box_id, + token_provider=pairing_manager.get_api_token, + ) + controller = BoxController( + state=state, + player=MockPlayer(), + config=box_meta, + config_path=str(box_store.path), + media_sync=media_sync, + ) + if resume_pruned: + _persist_resume(controller, state_store) + + if args.command == "status": + _print_status(controller.state) + return 0 + + if args.command == "simulate-nfc": + controller.handle_nfc_on(args.uid) + _print_status(controller.state) + _persist_resume(controller, state_store) + return 0 + + if args.command == "simulate-nfc-off": + controller.handle_nfc_off(args.uid) + _print_status(controller.state) + _persist_resume(controller, state_store) + return 0 + + if args.command == "tick": + controller.tick(args.seconds) + _print_status(controller.state) + _persist_resume(controller, state_store) + return 0 + + if args.command == "factory-reset": + _factory_reset(controller, box_store, state_store, secrets, media_root) + print("Factory reset completed. Restart required.") + return 0 + + if args.command == "run": + announce_interval = _parse_int_env("KLANGKISTE_ANNOUNCE_INTERVAL", 30) + poll_interval = _parse_int_env("KLANGKISTE_POLL_INTERVAL", 10) + api_port = _parse_int_env("KLANGKISTE_API_PORT", 8000) + setup_port = _parse_int_env("KLANGKISTE_SETUP_PORT", 9000) + try: + asyncio.run( + _run_with_api( + controller, + state_store, + lifecycle, + wifi_backend, + spotify_backend, + pairing_manager, + secrets, + AnnounceConfig( + server_url=str(box_meta.get("server_url")), + firmware_version=str(box_meta.get("firmware_version")), + capabilities=dict(box_meta.get("capabilities", {})), + api_port=api_port, + setup_port=setup_port, + media_root=str(media_root.resolve()), + announce_interval_seconds=announce_interval, + polling_interval_seconds=poll_interval, + ), + ) + ) + except KeyboardInterrupt: + return 0 + return 0 + + return 0 + + +def _resume_to_json(state: RuntimeState) -> dict[str, dict[str, int]]: + return { + uid: {"file_index": file_index, "position": position} + for uid, (file_index, position) in state._resume.items() + } + + +def _parse_int_env(name: str, default_value: int) -> int: + raw = os.getenv(name) + if not raw: + return default_value + try: + value = int(raw) + except ValueError: + return default_value + if value <= 0: + return default_value + return value + + +def _stdin_is_foreground() -> bool: + try: + if not sys.stdin.isatty(): + return False + return os.getpgrp() == os.tcgetpgrp(sys.stdin.fileno()) + except OSError: + return False + + +async def _run_with_api( + controller: BoxController, + storage: StateStore, + lifecycle: NetworkLifecycle, + wifi_backend: MockWifiBackend, + spotify_backend: MockSpotifyBackend, + pairing_manager: PairingManager, + secrets: SecretStore, + announce_config: AnnounceConfig, +) -> None: + base_dir = Path(__file__).resolve().parent / "data" + media_root = Path(__file__).resolve().parent / "media" + box_store = BoxStore(base_dir) + app = create_app( + controller, + lifecycle, + wifi_backend, + spotify_backend, + pairing_manager, + lambda: _persist_resume(controller, storage), + lambda: _factory_reset(controller, box_store, storage, secrets, media_root), + ) + api_server = uvicorn.Server( + uvicorn.Config( + app, + host="0.0.0.0", + port=announce_config.api_port, + log_level="warning", + access_log=False, + ) + ) + setup_app = create_setup_app(lifecycle, wifi_backend) + setup_server = uvicorn.Server( + uvicorn.Config( + setup_app, + host="0.0.0.0", + port=announce_config.setup_port, + log_level="warning", + access_log=False, + ) + ) + stop_event = asyncio.Event() + tick_task = asyncio.create_task( + _auto_tick_loop(controller, storage, stop_event) + ) + announce_task = asyncio.create_task( + announce_loop( + controller.state.box_id, + secrets=secrets, + pairing=pairing_manager, + config=announce_config, + stop_event=stop_event, + ) + ) + poll_task = asyncio.create_task( + pairing_poll_loop(controller.state.box_id, pairing_manager, announce_config, stop_event) + ) + api_task = asyncio.create_task(api_server.serve()) + setup_task = asyncio.create_task(setup_server.serve()) + + try: + if _stdin_is_foreground(): + await _run_session( + controller, + storage, + lambda: _factory_reset( + controller, + box_store, + storage, + secrets, + media_root, + ), + ) + api_server.should_exit = True + setup_server.should_exit = True + await api_task + except asyncio.CancelledError: + api_server.should_exit = True + setup_server.should_exit = True + finally: + stop_event.set() + tick_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await tick_task + announce_task.cancel() + poll_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await announce_task + with contextlib.suppress(asyncio.CancelledError): + await poll_task + setup_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await setup_task + + +async def _run_session( + controller: BoxController, storage: StateStore, factory_reset: callable +) -> None: + print("Klangkiste Box - Interactive Mode") + print("Type 'help' for commands.") + while True: + try: + raw = await asyncio.to_thread(input, "> ") + raw = raw.strip() + except asyncio.CancelledError: + break + except EOFError: + print() + break + if not raw: + continue + parts = raw.split() + command = parts[0] + args = parts[1:] + + if command in {"exit", "quit"}: + if controller.state.active_uid is not None: + controller.handle_nfc_off(controller.state.active_uid) + _persist_resume(controller, storage) + break + if command == "help": + _print_help() + continue + if command == "status": + _print_status(controller.state) + continue + if command == "factory-reset": + factory_reset() + print("Factory reset completed. Restart required.") + break + if command == "nfc": + if len(args) != 1: + print("error: usage: nfc ") + continue + controller.handle_nfc_on(args[0]) + _print_status(controller.state) + _persist_resume(controller, storage) + continue + if command == "nfc-off": + if len(args) != 1: + print("error: usage: nfc-off ") + continue + controller.handle_nfc_off(args[0]) + _print_status(controller.state) + _persist_resume(controller, storage) + continue + if command == "tick": + if len(args) != 1: + print("error: usage: tick ") + continue + try: + seconds = int(args[0]) + except ValueError: + print("error: seconds must be an integer") + continue + controller.tick(seconds) + _print_status(controller.state) + _persist_resume(controller, storage) + continue + if command in {"play", "pause"}: + controller.play_pause() + _print_status(controller.state) + continue + if command in {"next", "vor"}: + controller.next_track() + _print_status(controller.state) + _persist_resume(controller, storage) + continue + if command in {"prev", "zurueck"}: + controller.prev_track() + _print_status(controller.state) + _persist_resume(controller, storage) + continue + if command in {"vol-up", "lauter"}: + controller.volume_up() + _print_status(controller.state) + continue + if command in {"vol-down", "leiser"}: + controller.volume_down() + _print_status(controller.state) + continue + + print(f"error: unknown command '{command}'") + + +async def _auto_tick_loop( + controller: BoxController, + storage: StateStore, + stop_event: asyncio.Event, +) -> None: + while not stop_event.is_set(): + await asyncio.sleep(1) + if controller.state.state == BoxState.PLAYING: + controller.tick(1) + _persist_resume(controller, storage) + + +def _persist_resume( + controller: BoxController, storage: StateStore +) -> None: + data = storage.load() + data["resume"] = _resume_to_json(controller.state) + storage.save(data) + + +def _factory_reset( + controller: BoxController, + box_store: BoxStore, + state_store: StateStore, + secrets: SecretStore, + media_root: Path, +) -> None: + controller.factory_reset() + if box_store.path.exists(): + box_store.path.unlink() + if state_store.path.exists(): + state_store.path.unlink() + secrets.clear_all() + shutil.rmtree(media_root, ignore_errors=True) + media_root.mkdir(parents=True, exist_ok=True) + + +def _print_help() -> None: + print("Commands:") + print(" nfc simulate NFC tag detection") + print(" nfc-off simulate NFC tag removal") + print(" tick simulate time progression") + print(" play | pause toggle PLAYING/PAUSED") + print(" next | vor jump to next file") + print(" prev | zurueck jump to previous file") + print(" vol-up | lauter increase volume") + print(" vol-down | leiser decrease volume") + print(" status show current status") + print(" factory-reset delete all local data (restart required)") + print(" help show this help") + print(" exit | quit leave interactive mode") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/box2/package-lock.json b/box2/package-lock.json new file mode 100644 index 0000000..a084a70 --- /dev/null +++ b/box2/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "box", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/box/data/media/book_2/01.mp3 b/box2/player/__init__.py similarity index 100% rename from box/data/media/book_2/01.mp3 rename to box2/player/__init__.py diff --git a/box2/player/base_player.py b/box2/player/base_player.py new file mode 100644 index 0000000..564d3bc --- /dev/null +++ b/box2/player/base_player.py @@ -0,0 +1,19 @@ +"""Base player interface for audio playback.""" + +from __future__ import annotations + +from typing import Protocol + + +class BasePlayer(Protocol): + def play(self, media_ref: str) -> None: + raise NotImplementedError + + def pause(self) -> None: + raise NotImplementedError + + def stop(self) -> None: + raise NotImplementedError + + def set_volume(self, volume: int) -> None: + raise NotImplementedError diff --git a/box2/player/mock_player.py b/box2/player/mock_player.py new file mode 100644 index 0000000..74ea0ea --- /dev/null +++ b/box2/player/mock_player.py @@ -0,0 +1,23 @@ +"""Mock player for Phase 1a (no real audio).""" + +from __future__ import annotations + +from player.base_player import BasePlayer + + +class MockPlayer(BasePlayer): + def __init__(self) -> None: + self._current: str | None = None + self._volume: int = 0 + + def play(self, media_ref: str) -> None: + self._current = media_ref + + def pause(self) -> None: + return None + + def stop(self) -> None: + self._current = None + + def set_volume(self, volume: int) -> None: + self._volume = volume diff --git a/server/requirements.txt b/box2/requirements.txt similarity index 52% rename from server/requirements.txt rename to box2/requirements.txt index b240d3d..9a98b0c 100644 --- a/server/requirements.txt +++ b/box2/requirements.txt @@ -1,3 +1,5 @@ fastapi==0.115.6 uvicorn==0.30.6 pydantic==2.9.2 +cryptography==43.0.3 +python-multipart==0.0.9 diff --git a/box2/schemas/box_config.schema.json b/box2/schemas/box_config.schema.json new file mode 100644 index 0000000..05e7a81 --- /dev/null +++ b/box2/schemas/box_config.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://klangkiste.local/schemas/box_config.schema.json", + "title": "BoxConfig", + "type": "object", + "required": ["box_id", "settings", "tags"], + "properties": { + "box_id": {"type": "string"}, + "settings": { + "type": "object", + "required": ["max_volume", "default_volume"], + "properties": { + "max_volume": {"type": "integer", "minimum": 0, "maximum": 100}, + "default_volume": {"type": "integer", "minimum": 0, "maximum": 100} + }, + "additionalProperties": false + }, + "tags": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["type", "path"], + "properties": { + "type": { + "type": "string", + "enum": ["folder"] + }, + "path": {"type": "string"} + }, + "additionalProperties": false + } + }, + "resume": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": ["file_index", "position"], + "properties": { + "file_index": {"type": "integer", "minimum": 0}, + "position": {"type": "integer", "minimum": 0} + }, + "additionalProperties": false + } + }, + "server_reachable": { + "type": "boolean" + } + }, + "additionalProperties": false +} diff --git a/box2/schemas/box_status.schema.json b/box2/schemas/box_status.schema.json new file mode 100644 index 0000000..9a54e9e --- /dev/null +++ b/box2/schemas/box_status.schema.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://klangkiste.local/schemas/box_status.schema.json", + "title": "BoxStatus", + "type": "object", + "required": ["box_id", "state", "current_media", "volume"], + "properties": { + "box_id": {"type": "string"}, + "state": {"type": "string", "enum": ["IDLE", "PLAYING", "PAUSED"]}, + "current_media": {"type": ["string", "null"]}, + "volume": {"type": "integer", "minimum": 0, "maximum": 100} + }, + "additionalProperties": false +} diff --git a/box/data/media/book_2/02.mp3 b/box2/setup/__init__.py similarity index 100% rename from box/data/media/book_2/02.mp3 rename to box2/setup/__init__.py diff --git a/box2/setup/webui.py b/box2/setup/webui.py new file mode 100644 index 0000000..3034eb3 --- /dev/null +++ b/box2/setup/webui.py @@ -0,0 +1,71 @@ +"""Setup Web UI for WiFi configuration (mock).""" + +from __future__ import annotations + +from fastapi import FastAPI, Form +from fastapi.responses import HTMLResponse + +from core.lifecycle import NetworkLifecycle, WifiState +from wifi.base import WifiBackend + + +def create_setup_app(lifecycle: NetworkLifecycle, wifi_backend: WifiBackend) -> FastAPI: + app = FastAPI() + + @app.get("/setup", response_class=HTMLResponse) + def setup_page() -> str: + snapshot = lifecycle.snapshot() + networks = wifi_backend.scan_available_networks() + options = "".join(f"" for n in networks) + status = snapshot.wifi_state.value + connected = snapshot.connected_ssid or "-" + if snapshot.wifi_state != WifiState.WIFI_UNCONFIGURED: + return _render_readonly(status, connected) + return _render_form(status, connected, options) + + @app.post("/setup", response_class=HTMLResponse) + def setup_submit(ssid: str = Form(...), password: str = Form("")) -> str: + wifi_backend.add_profile(ssid=ssid, password=password, priority=10) + snapshot = lifecycle.snapshot() + status = snapshot.wifi_state.value + connected = snapshot.connected_ssid or "-" + return _render_readonly(status, connected) + + return app + + +def _render_form(status: str, connected: str, options: str) -> str: + return f""" + + + Klangkiste Setup + +

WLAN Setup

+

Status: {status}

+

Connected: {connected}

+
+ + +
+ + +
+ +
+ + +""" + + +def _render_readonly(status: str, connected: str) -> str: + return f""" + + + Klangkiste Setup + +

WLAN Status

+

Status: {status}

+

Connected: {connected}

+ + +""" diff --git a/box/data/media/book_2/03.mp3 b/box2/spotify/__init__.py similarity index 100% rename from box/data/media/book_2/03.mp3 rename to box2/spotify/__init__.py diff --git a/box2/spotify/base.py b/box2/spotify/base.py new file mode 100644 index 0000000..203c0ee --- /dev/null +++ b/box2/spotify/base.py @@ -0,0 +1,23 @@ +"""Spotify backend interface (logical).""" + +from __future__ import annotations + +from typing import Protocol + + +class SpotifyBackend(Protocol): + def set_tokens( + self, + access_token: str, + refresh_token: str, + expires_at: int, + account_id: str | None = None, + ) -> None: + raise NotImplementedError + + def clear(self) -> None: + raise NotImplementedError + + @property + def status(self) -> str: + raise NotImplementedError diff --git a/box2/spotify/manager.py b/box2/spotify/manager.py new file mode 100644 index 0000000..346524c --- /dev/null +++ b/box2/spotify/manager.py @@ -0,0 +1,54 @@ +"""Logical Spotify token manager (mock, no server dependency).""" + +from __future__ import annotations + +from typing import Any + +from storage.secrets_store import SecretsStore + + +class SpotifyManager: + def __init__(self, secrets: SecretsStore) -> None: + self._secrets = secrets + self._tokens: dict[str, Any] = {} + self._load() + + def set_tokens( + self, + access_token: str, + refresh_token: str, + expires_at: int, + account_id: str | None = None, + ) -> None: + self._tokens = { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_at": expires_at, + } + if account_id is not None: + self._tokens["account_id"] = account_id + self._save() + + def clear(self) -> None: + self._tokens = {} + secrets = self._secrets.load_secrets() + if "spotify" in secrets: + secrets.pop("spotify") + self._secrets.save_secrets(secrets) + + @property + def status(self) -> str: + if not self._tokens: + return "NOT_CONFIGURED" + return "READY" + + def _load(self) -> None: + secrets = self._secrets.load_secrets() + tokens = secrets.get("spotify") + if isinstance(tokens, dict): + self._tokens = dict(tokens) + + def _save(self) -> None: + secrets = self._secrets.load_secrets() + secrets["spotify"] = dict(self._tokens) + self._secrets.save_secrets(secrets) diff --git a/box2/spotify/mock.py b/box2/spotify/mock.py new file mode 100644 index 0000000..690626b --- /dev/null +++ b/box2/spotify/mock.py @@ -0,0 +1,55 @@ +"""Mock Spotify backend (logical).""" + +from __future__ import annotations + +from typing import Any + +from storage.secret_store import SecretStore +from spotify.base import SpotifyBackend + + +class MockSpotifyBackend(SpotifyBackend): + def __init__(self, secret_store: SecretStore) -> None: + self._secret_store = secret_store + self._tokens: dict[str, Any] = {} + self._load() + + def set_tokens( + self, + access_token: str, + refresh_token: str, + expires_at: int, + account_id: str | None = None, + ) -> None: + self._tokens = { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_at": expires_at, + } + if account_id is not None: + self._tokens["account_id"] = account_id + self._save() + + def clear(self) -> None: + self._tokens = {} + secrets = self._secret_store.load_secrets() + if "spotify" in secrets: + secrets.pop("spotify") + self._secret_store.save_secrets(secrets) + + @property + def status(self) -> str: + if not self._tokens: + return "NOT_CONFIGURED" + return "READY" + + def _load(self) -> None: + secrets = self._secret_store.load_secrets() + tokens = secrets.get("spotify") + if isinstance(tokens, dict): + self._tokens = dict(tokens) + + def _save(self) -> None: + secrets = self._secret_store.load_secrets() + secrets["spotify"] = dict(self._tokens) + self._secret_store.save_secrets(secrets) diff --git a/box/data/media/book_3/01.mp3 b/box2/storage/__init__.py similarity index 100% rename from box/data/media/book_3/01.mp3 rename to box2/storage/__init__.py diff --git a/box2/storage/box_storage.py b/box2/storage/box_storage.py new file mode 100644 index 0000000..318f823 --- /dev/null +++ b/box2/storage/box_storage.py @@ -0,0 +1,52 @@ +"""Persistent storage for Box metadata and runtime state.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +class BoxStorage: + def __init__(self, base_dir: str | None = None) -> None: + default_dir = Path(__file__).resolve().parents[1] / "data" + self._base_dir = Path(base_dir) if base_dir else default_dir + self._box_path = self._base_dir / "box.json" + self._state_path = self._base_dir / "state.json" + self._media_dir = self._base_dir / "media" + + @property + def base_dir(self) -> Path: + return self._base_dir + + @property + def media_dir(self) -> Path: + return self._media_dir + + @property + def box_path(self) -> Path: + return self._box_path + + @property + def state_path(self) -> Path: + return self._state_path + + def ensure_layout(self) -> None: + self._base_dir.mkdir(parents=True, exist_ok=True) + self._media_dir.mkdir(parents=True, exist_ok=True) + + def load_box_meta(self) -> dict[str, Any]: + if not self._box_path.exists(): + return {} + return json.loads(self._box_path.read_text(encoding="utf-8")) + + def save_box_meta(self, data: dict[str, Any]) -> None: + self._box_path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def load_state(self) -> dict[str, Any]: + if not self._state_path.exists(): + return {} + return json.loads(self._state_path.read_text(encoding="utf-8")) + + def save_state(self, data: dict[str, Any]) -> None: + self._state_path.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/box2/storage/box_store.py b/box2/storage/box_store.py new file mode 100644 index 0000000..c30128d --- /dev/null +++ b/box2/storage/box_store.py @@ -0,0 +1,34 @@ +"""Box metadata storage (app-level, within box/data).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from core.identity import generate_box_id + + +class BoxStore: + def __init__(self, base_dir: Path) -> None: + self._base_dir = base_dir + self._path = self._base_dir / "box.json" + + @property + def path(self) -> Path: + return self._path + + def load(self) -> dict[str, Any]: + if not self._path.exists(): + return {} + return json.loads(self._path.read_text(encoding="utf-8")) + + def save(self, data: dict[str, Any]) -> None: + self._path.write_text(json.dumps(data, indent=2), encoding="utf-8") + + def ensure_box_id(self) -> str: + data = self.load() + if "box_id" not in data: + data["box_id"] = generate_box_id() + self.save(data) + return str(data["box_id"]) diff --git a/box2/storage/json_storage.py b/box2/storage/json_storage.py new file mode 100644 index 0000000..28dfafa --- /dev/null +++ b/box2/storage/json_storage.py @@ -0,0 +1,21 @@ +"""JSON file storage placeholder for Phase 1a.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from storage.storage import Storage + + +class JsonStorage(Storage): + def __init__(self, path: str) -> None: + self._path = Path(path) + + def load_config(self) -> dict: + if not self._path.exists(): + return {} + return json.loads(self._path.read_text(encoding="utf-8")) + + def save_config(self, config: dict) -> None: + self._path.write_text(json.dumps(config, indent=2), encoding="utf-8") diff --git a/box2/storage/secret_store.py b/box2/storage/secret_store.py new file mode 100644 index 0000000..1ec7fb5 --- /dev/null +++ b/box2/storage/secret_store.py @@ -0,0 +1,98 @@ +"""Encrypted secret storage for sensitive Box data (app-level).""" + +from __future__ import annotations + +import base64 +import json +from hashlib import pbkdf2_hmac +from pathlib import Path +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken + +STATIC_SALT = b"klangkiste-static-salt-v1" +KDF_ITERATIONS = 200_000 + + +class SecretStore: + def __init__(self, base_dir: Path, box_id: str) -> None: + self._path = base_dir / "secrets.enc" + self._box_id = box_id + + def load_secrets(self) -> dict[str, Any]: + if not self._path.exists(): + return {} + token = self._path.read_bytes() + fernet = Fernet(self._derive_key()) + try: + payload = fernet.decrypt(token) + except InvalidToken: + return {} + return json.loads(payload.decode("utf-8")) + + def save_secrets(self, data: dict[str, Any]) -> None: + fernet = Fernet(self._derive_key()) + payload = json.dumps(data, indent=2).encode("utf-8") + token = fernet.encrypt(payload) + self._path.write_bytes(token) + + def ensure_secret_seed(self) -> str: + data = self.load_secrets() + seed = data.get("secret_seed") + if isinstance(seed, str) and seed: + return seed + seed_value = self._generate_seed() + data["secret_seed"] = seed_value + self.save_secrets(data) + return seed_value + + def get_secret_seed(self) -> str | None: + data = self.load_secrets() + seed = data.get("secret_seed") + if isinstance(seed, str) and seed: + return seed + return None + + def save_api_token(self, token: str) -> None: + data = self.load_secrets() + data["api_token"] = token + self.save_secrets(data) + + def get_api_token(self) -> str | None: + data = self.load_secrets() + token = data.get("api_token") + if isinstance(token, str) and token: + return token + return None + + def clear_api_token(self) -> None: + data = self.load_secrets() + if "api_token" in data: + data.pop("api_token") + self.save_secrets(data) + + def clear_wifi_secrets(self) -> None: + data = self.load_secrets() + if "wifi_profiles" in data: + data.pop("wifi_profiles") + self.save_secrets(data) + + def clear_all(self) -> None: + if self._path.exists(): + self._path.unlink() + + def _derive_key(self) -> bytes: + raw = pbkdf2_hmac( + "sha256", + self._box_id.encode("utf-8"), + STATIC_SALT, + KDF_ITERATIONS, + dklen=32, + ) + return base64.urlsafe_b64encode(raw) + + @staticmethod + def _generate_seed() -> str: + import secrets + + return secrets.token_hex(32) diff --git a/box2/storage/secrets_store.py b/box2/storage/secrets_store.py new file mode 100644 index 0000000..a0c022f --- /dev/null +++ b/box2/storage/secrets_store.py @@ -0,0 +1,57 @@ +"""Encrypted secret storage for sensitive Box data.""" + +from __future__ import annotations + +import base64 +import json +from hashlib import pbkdf2_hmac +from pathlib import Path +from typing import Any + +from cryptography.fernet import Fernet, InvalidToken + +STATIC_SALT = b"klangkiste-static-salt-v1" +KDF_ITERATIONS = 200_000 + + +class SecretsStore: + def __init__(self, base_dir: Path, box_id: str) -> None: + self._path = base_dir / "secrets.enc" + self._box_id = box_id + + def load_secrets(self) -> dict[str, Any]: + if not self._path.exists(): + return {} + token = self._path.read_bytes() + fernet = Fernet(self._derive_key()) + try: + payload = fernet.decrypt(token) + except InvalidToken: + return {} + return json.loads(payload.decode("utf-8")) + + def save_secrets(self, data: dict[str, Any]) -> None: + fernet = Fernet(self._derive_key()) + payload = json.dumps(data, indent=2).encode("utf-8") + token = fernet.encrypt(payload) + self._path.write_bytes(token) + + def clear_wifi_secrets(self) -> None: + data = self.load_secrets() + if "wifi" in data: + data.pop("wifi") + self.save_secrets(data) + + def clear_all(self) -> None: + if self._path.exists(): + self._path.unlink() + + def _derive_key(self) -> bytes: + raw = pbkdf2_hmac( + "sha256", + self._box_id.encode("utf-8"), + STATIC_SALT, + KDF_ITERATIONS, + dklen=32, + ) + return base64.urlsafe_b64encode(raw) diff --git a/box2/storage/state_store.py b/box2/storage/state_store.py new file mode 100644 index 0000000..f8e39ab --- /dev/null +++ b/box2/storage/state_store.py @@ -0,0 +1,24 @@ +"""Runtime state storage (resume, wifi state).""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + + +class StateStore: + def __init__(self, base_dir: Path) -> None: + self._path = base_dir / "state.json" + + @property + def path(self) -> Path: + return self._path + + def load(self) -> dict[str, Any]: + if not self._path.exists(): + return {} + return json.loads(self._path.read_text(encoding="utf-8")) + + def save(self, data: dict[str, Any]) -> None: + self._path.write_text(json.dumps(data, indent=2), encoding="utf-8") diff --git a/box2/storage/storage.py b/box2/storage/storage.py new file mode 100644 index 0000000..8acceba --- /dev/null +++ b/box2/storage/storage.py @@ -0,0 +1,13 @@ +"""Storage interface for Box configuration and state.""" + +from __future__ import annotations + +from typing import Protocol + + +class Storage(Protocol): + def load_config(self) -> dict: + raise NotImplementedError + + def save_config(self, config: dict) -> None: + raise NotImplementedError diff --git a/box/data/media/book_3/02.mp3 b/box2/wifi/__init__.py similarity index 100% rename from box/data/media/book_3/02.mp3 rename to box2/wifi/__init__.py diff --git a/box2/wifi/base.py b/box2/wifi/base.py new file mode 100644 index 0000000..3770f10 --- /dev/null +++ b/box2/wifi/base.py @@ -0,0 +1,45 @@ +"""WiFi backend interface (logical, no OS integration).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol + + +@dataclass +class WifiProfile: + ssid: str + password: str + priority: int = 0 + + +class WifiBackend(Protocol): + def scan_available_networks(self) -> list[str]: + raise NotImplementedError + + def add_profile(self, ssid: str, password: str, priority: int = 0) -> None: + raise NotImplementedError + + def remove_profile(self, ssid: str) -> None: + raise NotImplementedError + + def reset(self) -> None: + raise NotImplementedError + + def select_best_profile(self) -> WifiProfile | None: + raise NotImplementedError + + def connect(self, profile: WifiProfile) -> None: + raise NotImplementedError + + @property + def network_status(self) -> str: + raise NotImplementedError + + @property + def connected_ssid(self) -> str | None: + raise NotImplementedError + + @property + def profiles_count(self) -> int: + raise NotImplementedError diff --git a/box2/wifi/manager.py b/box2/wifi/manager.py new file mode 100644 index 0000000..e4fdf0d --- /dev/null +++ b/box2/wifi/manager.py @@ -0,0 +1,115 @@ +"""Logical WiFi manager (mock, no OS integration).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from storage.box_storage import BoxStorage +from storage.secrets_store import SecretsStore + + +@dataclass +class WifiProfile: + ssid: str + password: str + priority: int = 0 + + +class WifiManager: + def __init__(self, storage: BoxStorage, secrets: SecretsStore) -> None: + self._storage = storage + self._secrets = secrets + self._profiles: list[WifiProfile] = [] + self._connected_ssid: str | None = None + self._load_state() + + def scan_available_networks(self) -> list[str]: + return [] + + def add_profile(self, ssid: str, password: str, priority: int = 0) -> None: + self._profiles = [p for p in self._profiles if p.ssid != ssid] + self._profiles.append(WifiProfile(ssid=ssid, password=password, priority=priority)) + self._save_profiles() + self._auto_connect() + + def remove_profile(self, ssid: str) -> None: + self._profiles = [p for p in self._profiles if p.ssid != ssid] + if self._connected_ssid == ssid: + self._connected_ssid = None + self._save_profiles() + self._save_state() + self._auto_connect() + + def reset(self) -> None: + self._profiles = [] + self._connected_ssid = None + self._secrets.clear_wifi_secrets() + self._save_state() + + def select_best_profile(self) -> WifiProfile | None: + if not self._profiles: + return None + return sorted(self._profiles, key=lambda p: (-p.priority, p.ssid))[0] + + def connect(self, profile: WifiProfile) -> None: + self._connected_ssid = profile.ssid + self._save_state() + + @property + def network_status(self) -> str: + if not self._profiles: + return "NOT_CONFIGURED" + if self._connected_ssid: + return "CONNECTED" + return "DISCONNECTED" + + @property + def connected_ssid(self) -> str | None: + return self._connected_ssid + + @property + def profiles_count(self) -> int: + return len(self._profiles) + + def _auto_connect(self) -> None: + profile = self.select_best_profile() + if profile is not None: + self.connect(profile) + + def _load_state(self) -> None: + secrets = self._secrets.load_secrets() + profiles_raw = secrets.get("wifi_profiles", []) + if isinstance(profiles_raw, list): + for entry in profiles_raw: + if not isinstance(entry, dict): + continue + ssid = entry.get("ssid") + password = entry.get("password") + priority = entry.get("priority", 0) + if isinstance(ssid, str) and isinstance(password, str): + if not isinstance(priority, int): + priority = 0 + self._profiles.append( + WifiProfile(ssid=ssid, password=password, priority=priority) + ) + + state = self._storage.load_state() + wifi_state = state.get("wifi", {}) if isinstance(state.get("wifi"), dict) else {} + connected = wifi_state.get("connected_ssid") + if isinstance(connected, str): + self._connected_ssid = connected + self._auto_connect() + + def _save_profiles(self) -> None: + secrets = self._secrets.load_secrets() + secrets["wifi_profiles"] = [ + {"ssid": p.ssid, "password": p.password, "priority": p.priority} + for p in self._profiles + ] + self._secrets.save_secrets(secrets) + + def _save_state(self) -> None: + state = self._storage.load_state() + state["wifi"] = {"connected_ssid": self._connected_ssid} + self._storage.save_state(state) diff --git a/box2/wifi/mock.py b/box2/wifi/mock.py new file mode 100644 index 0000000..81587ca --- /dev/null +++ b/box2/wifi/mock.py @@ -0,0 +1,109 @@ +"""Mock WiFi backend (logical simulation).""" + +from __future__ import annotations + +from typing import Any + +from storage.state_store import StateStore +from storage.secret_store import SecretStore +from wifi.base import WifiBackend, WifiProfile + + +class MockWifiBackend(WifiBackend): + def __init__(self, state_store: StateStore, secret_store: SecretStore) -> None: + self._state_store = state_store + self._secret_store = secret_store + self._profiles: list[WifiProfile] = [] + self._connected_ssid: str | None = None + self._available_networks = ["HomeWiFi", "Hotspot"] + self._load() + + def scan_available_networks(self) -> list[str]: + return list(self._available_networks) + + def add_profile(self, ssid: str, password: str, priority: int = 0) -> None: + self._profiles = [p for p in self._profiles if p.ssid != ssid] + self._profiles.append(WifiProfile(ssid=ssid, password=password, priority=priority)) + self._save_profiles() + self._auto_connect() + + def remove_profile(self, ssid: str) -> None: + self._profiles = [p for p in self._profiles if p.ssid != ssid] + if self._connected_ssid == ssid: + self._connected_ssid = None + self._save_profiles() + self._save_state() + self._auto_connect() + + def reset(self) -> None: + self._profiles = [] + self._connected_ssid = None + self._secret_store.clear_wifi_secrets() + self._save_state() + + def select_best_profile(self) -> WifiProfile | None: + if not self._profiles: + return None + return sorted(self._profiles, key=lambda p: (-p.priority, p.ssid))[0] + + def connect(self, profile: WifiProfile) -> None: + self._connected_ssid = profile.ssid + self._save_state() + + @property + def network_status(self) -> str: + if not self._profiles: + return "WIFI_UNCONFIGURED" + if self._connected_ssid: + return "WIFI_ONLINE" + return "WIFI_OFFLINE" + + @property + def connected_ssid(self) -> str | None: + return self._connected_ssid + + @property + def profiles_count(self) -> int: + return len(self._profiles) + + def _auto_connect(self) -> None: + profile = self.select_best_profile() + if profile is not None: + self.connect(profile) + + def _load(self) -> None: + secrets = self._secret_store.load_secrets() + profiles_raw = secrets.get("wifi_profiles", []) + if isinstance(profiles_raw, list): + for entry in profiles_raw: + if not isinstance(entry, dict): + continue + ssid = entry.get("ssid") + password = entry.get("password") + priority = entry.get("priority", 0) + if isinstance(ssid, str) and isinstance(password, str): + if not isinstance(priority, int): + priority = 0 + self._profiles.append( + WifiProfile(ssid=ssid, password=password, priority=priority) + ) + + state = self._state_store.load() + wifi_state = state.get("wifi", {}) if isinstance(state.get("wifi"), dict) else {} + connected = wifi_state.get("connected_ssid") + if isinstance(connected, str): + self._connected_ssid = connected + self._auto_connect() + + def _save_profiles(self) -> None: + secrets = self._secret_store.load_secrets() + secrets["wifi_profiles"] = [ + {"ssid": p.ssid, "password": p.password, "priority": p.priority} + for p in self._profiles + ] + self._secret_store.save_secrets(secrets) + + def _save_state(self) -> None: + state = self._state_store.load() + state["wifi"] = {"connected_ssid": self._connected_ssid} + self._state_store.save(state) diff --git a/dev-script.sh b/dev-script.sh new file mode 100755 index 0000000..e396fb9 --- /dev/null +++ b/dev-script.sh @@ -0,0 +1,1264 @@ +#!/bin/bash +set -e + +# ===================================== +# StackPulse Dev-Skript – Hauptmenü & Aktionen +# ===================================== + +show_menu() { + cat <<'MENU' +Bitte wähle eine Aktion: + + 1) Neues Feature anlegen + 2) Merge & Commit + 3) Dev-Umgebungen + 4) Hotfix + 5) Branch-Verwaltung + 6) Branch wechseln + 0) Beenden +MENU +} + +pause_for_menu() { + echo "" + read -rp "Zurück zum Hauptmenü mit Enter... " _ +} + +merge_commit_menu() { + local selection + while true; do + echo "" + cat <<'MENU' +Merge & Commit – Aktionen: + + 1) Feature-Branch in dev mergen + 2) dev in master mergen + 3) Änderungen committen & pushen + 4) Docker-Release bauen & pushen + 5) Git-Stashes verwalten + 6) README in Feature-Branches aktualisieren + 0) Zurück +MENU + read -rp "Auswahl: " selection + echo "" + case $selection in + 1) + merge_feature_into_dev + pause_for_menu + ;; + 2) + merge_dev_into_master + pause_for_menu + ;; + 3) + push_changes + pause_for_menu + ;; + 4) + docker_release + pause_for_menu + ;; + 5) + manage_stash + pause_for_menu + ;; + 6) + sync_readme_to_features + pause_for_menu + ;; + 0) + break + ;; + *) + echo "❌ Ungültige Auswahl." + ;; + esac + done +} + +dev_environments_menu() { + local selection + while true; do + echo "" + cat <<'MENU' +Dev-Umgebungen: + + 1) Dev-Umgebung starten + 2) Docker Compose (lokal) starten + 0) Zurück +MENU + read -rp "Auswahl: " selection + echo "" + case $selection in + 1) + start_dev_environment + pause_for_menu + ;; + 2) + start_docker_compose + pause_for_menu + ;; + 0) + break + ;; + *) + echo "❌ Ungültige Auswahl." + ;; + esac + done +} + +hotfix_menu() { + local selection + while true; do + echo "" + cat <<'MENU' +Hotfix: + + 1) Neuen Hotfix erstellen + 2) Hotfix auf master anwenden (Cherry-Pick) + 3) Hotfix auf dev anwenden (Cherry-Pick) + 4) Hotfix auf Feature-Branches anwenden (Cherry-Pick) + 0) Zurück +MENU + read -rp "Auswahl: " selection + echo "" + case $selection in + 1) + create_hotfix_branch + pause_for_menu + ;; + 2) + apply_hotfix_to_master + pause_for_menu + ;; + 3) + apply_hotfix_to_dev + pause_for_menu + ;; + 4) + apply_hotfix_to_features + pause_for_menu + ;; + 0) + break + ;; + *) + echo "❌ Ungültige Auswahl." + ;; + esac + done +} + +create_hotfix_branch() { + local -a TAGS + local selection selected_tag hotfix_name + local version_without_prefix patch_part branch_version branch_name + local -a ver_parts + + if ! git diff-index --quiet HEAD --; then + echo "⚠️ Es gibt noch uncommittete Änderungen. Bitte committen oder stashen, bevor ein Hotfix-Branch erstellt wird." + return 1 + fi + + git fetch --tags >/dev/null 2>&1 || true + mapfile -t TAGS < <(git tag --sort=-version:refname) || true + + if [[ ${#TAGS[@]} -eq 0 ]]; then + echo "Keine Tags vorhanden." + return 1 + fi + + while true; do + echo "Verfügbare Tags:" + local i=1 tag_name + for tag_name in "${TAGS[@]}"; do + echo " $i) $tag_name" + ((i++)) + done + echo " 0) Zurück" + + read -rp "Auswahl: " selection + echo "" + + if [[ "$selection" == "0" ]]; then + return 0 + fi + + if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#TAGS[@]} )); then + selected_tag="${TAGS[selection-1]}" + break + else + echo "Ungültige Auswahl. Bitte erneut versuchen." + fi + done + + while true; do + read -rp "Hotfix-Namen eingeben (nur Buchstaben/Zahlen/._- | 0 zum Abbrechen): " hotfix_name + if [[ "$hotfix_name" == "0" ]]; then + echo "Abgebrochen." + return 0 + fi + if [[ -z "$hotfix_name" ]]; then + echo "Eingabe darf nicht leer sein." + continue + fi + if [[ "$hotfix_name" =~ [^a-zA-Z0-9._-] ]]; then + echo "Ungültiger Name. Erlaubt sind Buchstaben, Zahlen, Punkt, Unterstrich oder Bindestrich." + continue + fi + break + done + + if [[ $selected_tag != v* ]]; then + echo "Tag '$selected_tag' entspricht nicht dem erwarteten Format (vX.Y oder vX.Y.Z)." + return 1 + fi + + version_without_prefix="${selected_tag#v}" + IFS='.' read -r -a ver_parts <<< "$version_without_prefix" + if (( ${#ver_parts[@]} < 2 )); then + echo "Tag '$selected_tag' besitzt nicht genügend Versionsbestandteile." + return 1 + fi + if ! [[ ${ver_parts[0]} =~ ^[0-9]+$ && ${ver_parts[1]} =~ ^[0-9]+$ ]]; then + echo "Tag '$selected_tag' enthält keine numerische Haupt-/Nebenversion." + return 1 + fi + + patch_part=${ver_parts[2]:-0} + if ! [[ $patch_part =~ ^[0-9]+$ ]]; then + echo "Tag '$selected_tag' enthält eine ungültige Patch-Version." + return 1 + fi + + patch_part=$((patch_part + 1)) + ver_parts[2]=$patch_part + + local branch_prefix="v${ver_parts[0]}${ver_parts[1]}" + branch_version="${branch_prefix}.$patch_part" + branch_name="hotfix/${branch_version}-hotfix_${hotfix_name}" + + if git show-ref --verify --quiet "refs/heads/$branch_name"; then + echo "❌ Fehler: Der Branch '$branch_name' existiert lokal bereits." + return 1 + fi + if git ls-remote --heads origin "$branch_name" | grep -q "$branch_name"; then + echo "❌ Fehler: Der Branch '$branch_name' existiert bereits auf Remote." + return 1 + fi + + git checkout -b "$branch_name" "$selected_tag" + git push -u origin "$branch_name" + + echo "✅ Hotfix-Branch '$branch_name' wurde von Tag '$selected_tag' erstellt und gepusht." + echo " Basisversion: $branch_version" +} + +select_hotfix_branch() { + local __resultvar=$1 + local selection + local -a HOTFIX_BRANCHES + local branch + local i + + if [[ -z "$__resultvar" ]]; then + echo "Interner Fehler: Kein Ausgabe-Parameter übergeben." + return 1 + fi + + git fetch origin --prune >/dev/null 2>&1 || true + mapfile -t HOTFIX_BRANCHES < <(git branch -r --format='%(refname:lstrip=3)' | grep '^hotfix/') || true + + if [[ ${#HOTFIX_BRANCHES[@]} -eq 0 ]]; then + echo "Keine Hotfix-Branches gefunden." + return 1 + fi + + while true; do + echo "Verfügbare Hotfix-Branches:" + i=1 + for branch in "${HOTFIX_BRANCHES[@]}"; do + echo " $i) $branch" + ((i++)) + done + echo " 0) Zurück" + + read -rp "Auswahl: " selection + echo "" + + if [[ "$selection" == "0" ]]; then + echo "Abgebrochen." + return 1 + fi + + if [[ "$selection" =~ ^[0-9]+$ ]] && (( selection >= 1 && selection <= ${#HOTFIX_BRANCHES[@]} )); then + printf -v "$__resultvar" '%s' "${HOTFIX_BRANCHES[selection-1]}" + return 0 + else + echo "Ungültige Auswahl. Bitte erneut versuchen." + fi + done +} + +apply_hotfix_branch_to_target() { + local HOTFIX_BRANCH="$1" + local TARGET_BRANCH="$2" + local CURRENT_BRANCH REMOTE_HOTFIX base_commit + local -a commit_list + local applied_count skipped_count commit commit_desc + local patch_tmp err_tmp status_output + + if [[ -z "$HOTFIX_BRANCH" || -z "$TARGET_BRANCH" ]]; then + echo "Interner Fehler: Hotfix- oder Ziel-Branch fehlt." + return 1 + fi + + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + + if ! git diff-index --quiet HEAD --; then + echo "⚠️ Es gibt uncommittete Änderungen auf $CURRENT_BRANCH. Bitte bereinigen, bevor Hotfixes angewendet werden." + return 1 + fi + + git fetch origin "$HOTFIX_BRANCH" >/dev/null 2>&1 || true + + if git show-ref --verify --quiet "refs/heads/$TARGET_BRANCH"; then + git checkout "$TARGET_BRANCH" + git pull origin "$TARGET_BRANCH" + else + if ! git ls-remote --heads origin "$TARGET_BRANCH" >/dev/null 2>&1; then + echo "❌ Ziel-Branch '$TARGET_BRANCH' existiert nicht auf origin." + return 1 + fi + git checkout -b "$TARGET_BRANCH" "origin/$TARGET_BRANCH" + fi + + if ! git diff-index --quiet HEAD --; then + echo "⚠️ Bitte stelle sicher, dass $TARGET_BRANCH vor dem Anwenden sauber ist." + return 1 + fi + + REMOTE_HOTFIX="origin/$HOTFIX_BRANCH" + base_commit=$(git merge-base "$TARGET_BRANCH" "$REMOTE_HOTFIX") || true + + if [[ -z "$base_commit" ]]; then + echo "❌ Konnte gemeinsamen Stand zwischen $TARGET_BRANCH und $REMOTE_HOTFIX nicht ermitteln." + return 1 + fi + + mapfile -t commit_list < <(git rev-list --reverse "$base_commit".."$REMOTE_HOTFIX") || true + + if [[ ${#commit_list[@]} -eq 0 ]]; then + echo "ℹ️ $TARGET_BRANCH enthält bereits alle Hotfix-Commits." + if [[ "$CURRENT_BRANCH" != "$TARGET_BRANCH" ]]; then + git checkout "$CURRENT_BRANCH" + fi + return 0 + fi + + echo "Übernehme ${#commit_list[@]} Hotfix-Commit(s) nach $TARGET_BRANCH (ohne Commit) ..." + + applied_count=0 + skipped_count=0 + + for commit in "${commit_list[@]}"; do + commit_desc=$(git show -s --format='%h %s' "$commit") + echo "➡️ Übertrage $commit_desc" + + patch_tmp=$(mktemp) + err_tmp=$(mktemp) + + if ! git format-patch -1 --stdout "$commit" > "$patch_tmp"; then + echo "❌ Konnte Patch für $commit_desc nicht erstellen." + rm -f "$patch_tmp" "$err_tmp" + return 1 + fi + + if git apply --check --index --3way "$patch_tmp" >/dev/null 2>"$err_tmp"; then + if git apply --index --3way "$patch_tmp" >/dev/null 2>>"$err_tmp"; then + ((applied_count++)) + else + echo "❌ Fehler beim Anwenden von $commit_desc." + cat "$err_tmp" + rm -f "$patch_tmp" "$err_tmp" + return 1 + fi + else + if grep -qi 'already applied' "$err_tmp"; then + echo "ℹ️ $commit_desc ist bereits in $TARGET_BRANCH enthalten – übersprungen." + ((skipped_count++)) + else + echo "❌ Konflikte beim Anwenden von $commit_desc." + cat "$err_tmp" + rm -f "$patch_tmp" "$err_tmp" + echo " Bitte Konflikte manuell lösen; die Änderungen verbleiben auf $TARGET_BRANCH." + return 1 + fi + fi + + rm -f "$patch_tmp" "$err_tmp" + done + + if (( applied_count > 0 )); then + echo "✅ Hotfix-Änderungen wurden nach $TARGET_BRANCH übertragen. Es wurde kein Commit erstellt." + echo " Bitte Änderungen prüfen, bei Bedarf anpassen und manuell committen/pushen." + else + echo "ℹ️ Keine neuen Hotfix-Änderungen für $TARGET_BRANCH erforderlich. ($skipped_count übersprungen)" + fi + + status_output=$(git status --porcelain) + if [[ "$CURRENT_BRANCH" != "$TARGET_BRANCH" ]]; then + if [[ -z "$status_output" ]]; then + git checkout "$CURRENT_BRANCH" + else + echo "ℹ️ Du befindest dich weiterhin auf $TARGET_BRANCH, um die Änderungen zu prüfen." + echo " Kehre nach Abschluss manuell zu $CURRENT_BRANCH zurück." + fi + fi +} +apply_hotfix_to_master() { + local HOTFIX_BRANCH + if ! select_hotfix_branch HOTFIX_BRANCH; then + return 0 + fi + apply_hotfix_branch_to_target "$HOTFIX_BRANCH" "master" +} + +apply_hotfix_to_dev() { + local HOTFIX_BRANCH + if ! select_hotfix_branch HOTFIX_BRANCH; then + return 0 + fi + apply_hotfix_branch_to_target "$HOTFIX_BRANCH" "dev" +} + +apply_hotfix_to_features() { + local HOTFIX_BRANCH + local -a FEATURE_BRANCHES selection target_branches + local branch choice i + + if ! select_hotfix_branch HOTFIX_BRANCH; then + return 0 + fi + + mapfile -t FEATURE_BRANCHES < <(git branch -r --format='%(refname:lstrip=3)' | grep '^feature/') || true + + if [[ ${#FEATURE_BRANCHES[@]} -eq 0 ]]; then + echo "Keine Feature-Branches gefunden." + return 0 + fi + + echo "Verfügbare Feature-Branches:" + i=1 + for branch in "${FEATURE_BRANCHES[@]}"; do + echo " $i) $branch" + ((i++)) + done + echo " 0) Abbrechen" + + read -rp "Bitte Branch-Auswahl (z.B. 1 3 4 oder 0): " -a selection + echo "" + + if [[ ${selection[0]} == "0" ]]; then + echo "Abgebrochen." + return 0 + fi + + for choice in "${selection[@]}"; do + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#FEATURE_BRANCHES[@]} )); then + target_branches+=("${FEATURE_BRANCHES[choice-1]}") + else + echo "⚠️ Ungültige Auswahl ignoriert: $choice" + fi + done + + if [[ ${#target_branches[@]} -eq 0 ]]; then + echo "Keine gültigen Feature-Branches ausgewählt." + return 0 + fi + + for branch in "${target_branches[@]}"; do + echo "" + echo "➡️ Übernehme Hotfix auf $branch" + if ! apply_hotfix_branch_to_target "$HOTFIX_BRANCH" "$branch"; then + echo "❌ Abbruch nach Fehler in $branch." + return 1 + fi + done + + echo "" + echo "✅ Hotfix wurde auf alle ausgewählten Feature-Branches angewendet." +} + +create_new_feature() { + local dev_branch="dev" + local feature_name feature_branch base_commit + + read -rp "Bitte den Namen des neuen Features eingeben: " feature_name + if [[ -z "$feature_name" ]]; then + echo "❌ Fehler: Kein Feature-Name angegeben." + return 1 + fi + + if [[ "$feature_name" =~ [^a-zA-Z0-9._-] ]]; then + echo "❌ Fehler: Ungültiger Branch-Name. Erlaubt sind Buchstaben, Zahlen, Punkt, Unterstrich oder Bindestrich." + return 1 + fi + + feature_branch="feature/$feature_name" + + if ! git diff-index --quiet HEAD --; then + echo "⚠️ Es gibt noch uncommittete Änderungen. Bitte committen oder stashen, bevor ein neuer Branch erstellt wird." + return 1 + fi + + if git show-ref --verify --quiet "refs/heads/$feature_branch"; then + echo "❌ Fehler: Der Branch '$feature_branch' existiert lokal bereits." + return 1 + fi + + if git ls-remote --heads origin "$feature_branch" | grep -q "$feature_branch"; then + echo "❌ Fehler: Der Branch '$feature_branch' existiert bereits auf Remote." + return 1 + fi + + git checkout "$dev_branch" + git pull origin "$dev_branch" + + git checkout -b "$feature_branch" "$dev_branch" + git push -u origin "$feature_branch" + + base_commit=$(git rev-parse --short HEAD) + + echo "✅ Neuer Feature-Branch '$feature_branch' wurde erstellt und auf Remote gepusht." + echo " Basis: $dev_branch@$base_commit" +} + +merge_feature_into_dev() { + local DEV_BRANCH="dev" + local FEATURE_BRANCH branch_name + local choice i + local -a BRANCH_ARRAY + + mapfile -t BRANCH_ARRAY < <(git branch -r --format='%(refname:lstrip=3)' | grep '^feature/') || true + + if [[ ${#BRANCH_ARRAY[@]} -eq 0 ]]; then + echo "Keine Feature-Branches vorhanden." + return 0 + fi + + while true; do + echo "Verfügbare Feature-Branches:" + i=1 + for branch_name in "${BRANCH_ARRAY[@]}"; do + echo " $i) $branch_name" + ((i++)) + done + echo " 0) Zurück" + + read -rp "Auswahl: " choice + echo "" + + if [[ "$choice" == "0" ]]; then + return 0 + fi + + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#BRANCH_ARRAY[@]} )); then + FEATURE_BRANCH="${BRANCH_ARRAY[choice-1]}" + break + else + echo "Ungültige Auswahl. Bitte erneut versuchen." + fi + done + + echo "Ausgewählter Feature-Branch: $FEATURE_BRANCH" + + git checkout "$DEV_BRANCH" + git pull origin "$DEV_BRANCH" + + if ! git show-ref --verify --quiet "refs/heads/$FEATURE_BRANCH"; then + git checkout -b "$FEATURE_BRANCH" "origin/$FEATURE_BRANCH" + else + git checkout "$FEATURE_BRANCH" + git pull origin "$FEATURE_BRANCH" + fi + + git checkout "$DEV_BRANCH" + git merge --no-ff "$FEATURE_BRANCH" -m "Merge $FEATURE_BRANCH into $DEV_BRANCH" + + git push origin "$DEV_BRANCH" + + echo "Feature $FEATURE_BRANCH wurde erfolgreich in $DEV_BRANCH gemerged." +} + + +sync_readme_to_features() { + local BASE_BRANCH="dev" + local MASTER_BRANCH="master" + local -a FEATURE_BRANCHES TARGET_BRANCHES selection + local -a ALL_BRANCHES + local choice branch i + local readme_path="README.md" + + if [[ ! -f "$readme_path" ]]; then + echo "❌ README.md nicht gefunden." + return 1 + fi + + mapfile -t FEATURE_BRANCHES < <(git branch -r --format='%(refname:lstrip=3)' | grep '^feature/') || true + ALL_BRANCHES=("${FEATURE_BRANCHES[@]}" "master") + + if [[ ${#ALL_BRANCHES[@]} -eq 0 ]]; then + echo "Keine geeigneten Branches gefunden." + return 0 + fi + + echo "Verfügbare Branches:" + i=1 + for branch in "${ALL_BRANCHES[@]}"; do + echo " $i) $branch" + ((i++)) + done + echo " 0) Abbrechen" + + read -rp "Bitte Branch-Auswahl (z.B. 1 3 4 oder 0): " -a selection + echo "" + + if [[ ${selection[0]} == "0" ]]; then + echo "Abgebrochen." + return 0 + fi + + for choice in "${selection[@]}"; do + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#ALL_BRANCHES[@]} )); then + TARGET_BRANCHES+=("${ALL_BRANCHES[choice-1]}") + else + echo "⚠️ Ungültige Auswahl ignoriert: $choice" + fi + done + + if [[ ${#TARGET_BRANCHES[@]} -eq 0 ]]; then + echo "Keine gültigen Branches ausgewählt." + return 0 + fi + + local CURRENT_BRANCH + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + + git checkout "$BASE_BRANCH" + git pull origin "$BASE_BRANCH" + + for branch in "${TARGET_BRANCHES[@]}"; do + echo "\n➡️ Synchronisiere README in $branch" + + if ! git show-ref --verify --quiet "refs/heads/$branch"; then + git checkout -b "$branch" "origin/$branch" + else + git checkout "$branch" + git pull origin "$branch" + fi + + git checkout "$BASE_BRANCH" -- "$readme_path" + git add "$readme_path" + git commit -m "Sync README from $BASE_BRANCH" || echo "Keine README-Änderungen in $branch" + git push origin "$branch" + + git checkout "$BASE_BRANCH" + done + + git checkout "$CURRENT_BRANCH" + echo "\n✅ README wurde in den ausgewählten Feature-Branches aktualisiert." +} + + +merge_dev_into_master() { + local DEV_BRANCH="dev" + local MASTER_BRANCH="master" + + # Auf master wechseln und aktuell holen + git checkout "$MASTER_BRANCH" + git pull origin "$MASTER_BRANCH" + + # Dev aktuell holen + if ! git show-ref --verify --quiet "refs/heads/$DEV_BRANCH"; then + git checkout -b "$DEV_BRANCH" "origin/$DEV_BRANCH" + else + git checkout "$DEV_BRANCH" + git pull origin "$DEV_BRANCH" + fi + + # Merge Dev in Master + git checkout "$MASTER_BRANCH" + git merge --no-ff "$DEV_BRANCH" -m "Merge $DEV_BRANCH into $MASTER_BRANCH" + + # Push Master auf Remote + git push origin "$MASTER_BRANCH" + + echo "Branch $DEV_BRANCH wurde in $MASTER_BRANCH gemerged." +} + + +push_changes() { + set -e + + # Aktuellen Branch herausfinden + local BRANCH + BRANCH=$(git rev-parse --abbrev-ref HEAD) + + # Branch bestätigen + read -rp "Aktueller Branch: $BRANCH. Ist das korrekt? (y/n): " CONFIRM + if [[ "$CONFIRM" != "y" && "$CONFIRM" != "Y" ]]; then + echo "Abgebrochen." + return 1 + fi + + # Commit-Nachricht + local COMMIT_MSG + read -rp "Bitte Commit-Nachricht eingeben (default: 'Update'): " COMMIT_MSG + COMMIT_MSG=${COMMIT_MSG:-Update} + + # Änderungen stagen und committen + echo "Änderungen werden gestaged..." + git add . + echo "Commit wird erstellt..." + git commit -m "$COMMIT_MSG" || echo "Nichts zu committen" + + local VERSION_TAG="" + # Prüfen, ob Branch master ist + if [[ "$BRANCH" == "master" ]]; then + # Versionsnummer abfragen (0 bedeutet: kein Tag) + while true; do + read -rp "Bitte Versionsnummer für Master-Release Tag eingeben (oder 0 für keinen Tag): " VERSION_TAG + if [[ -n "$VERSION_TAG" ]]; then + break + else + echo "Eingabe darf nicht leer sein. Bitte eingeben." + fi + done + fi + + # Push Branch + echo "Push nach origin/$BRANCH..." + git push -f origin "$BRANCH" + + # Tag setzen und pushen, nur bei master und wenn nicht 0 + if [[ "$BRANCH" == "master" && "$VERSION_TAG" != "0" ]]; then + git tag -a "$VERSION_TAG" -m "Release $VERSION_TAG" + git push origin -f "$VERSION_TAG" + echo "Tag $VERSION_TAG gesetzt und gepusht." + else + if [[ "$BRANCH" == "master" && "$VERSION_TAG" == "0" ]]; then + echo "Kein Tag gesetzt (manuelle Auswahl: 0)." + fi + fi + + echo "Push abgeschlossen." +} + + + +docker_release() { + local ghcr_username="mboehmlaender" + local repo_name="stackpulse" + local branch version_tag + + + + + while true; do + read -rp "Bitte Versionsnummer für das Docker-Image eingeben (z.B. v0.1): " version_tag + if [[ -n "$version_tag" ]]; then + break + fi + echo "Versionsnummer darf nicht leer sein." + done + + branch=$(git rev-parse --abbrev-ref HEAD) + + if [[ "$branch" != "master" ]]; then + echo "Fehler: Du musst auf 'master' sein, um ein Release zu machen." + return 1 + fi + + if [[ -z "$CR_PAT" ]]; then + echo "CR_PAT (GitHub Token) nicht gesetzt! Bitte export CR_PAT=" + return 1 + fi + + echo "$CR_PAT" | docker login ghcr.io -u "$ghcr_username" --password-stdin + docker build -t "ghcr.io/$ghcr_username/$repo_name:$version_tag" . + docker tag "ghcr.io/$ghcr_username/$repo_name:$version_tag" "ghcr.io/$ghcr_username/$repo_name:latest" + + docker push "ghcr.io/$ghcr_username/$repo_name:$version_tag" + docker push "ghcr.io/$ghcr_username/$repo_name:latest" + + echo "Docker-Release $version_tag erfolgreich gebaut und zu GHCR gepusht!" +} + + + +sync_frontend_build() { + local source_dir="$1" + local target_dir="$2" + + if [[ -z "$source_dir" || -z "$target_dir" ]]; then + echo "sync_frontend_build benötigt Quell- und Zielverzeichnis." + return 1 + fi + + if [[ ! -d "$source_dir" ]]; then + echo "⚠️ Build-Verzeichnis '$source_dir' wurde nicht gefunden." + return 1 + fi + + mkdir -p "$target_dir" + + if command -v rsync >/dev/null 2>&1; then + rsync -a --delete "$source_dir"/ "$target_dir"/ + else + find "$target_dir" -mindepth 1 -maxdepth 1 -exec rm -rf {} + + cp -R "$source_dir"/. "$target_dir"/ + fi + + echo "✅ Frontend-Build nach '$target_dir' synchronisiert." +} + +start_dev_environment() { + local back_pid front_pid + + echo "🚀 Starte StackPulse Dev-Umgebung..." + + pushd backend >/dev/null + read -rp "➡️ Backend: npm install ausführen? (y/N) " backend_install_choice + if [[ "$backend_install_choice" =~ ^([YyJj]|[Yy]es|[Jj]a)$ ]]; then + npm install + else + echo "ℹ️ Backend npm install übersprungen." + fi + npm run migrate + npm start & + back_pid=$! + popd >/dev/null + + pushd frontend >/dev/null + read -rp "➡️ Frontend: npm install ausführen? (y/N) " frontend_install_choice + if [[ "$frontend_install_choice" =~ ^([YyJj]|[Yy]es|[Jj]a)$ ]]; then + npm install + else + echo "ℹ️ Frontend npm install übersprungen." + fi + npm run build + sync_frontend_build "dist" "../backend/public" + npm run dev -- --host & + front_pid=$! + popd >/dev/null + + echo "" + echo "✅ StackPulse läuft lokal:" + echo "Frontend (Vite Dev): http://localhost:5173" + echo "Backend API: http://localhost:4001" + echo "Die gebaute Oberfläche liegt unter backend/public" + echo "Beenden mit STRG+C" + + wait "$back_pid" "$front_pid" +} + +start_docker_compose() { + echo "🔄 Starte lokale Docker-Umgebung..." + docker compose -f docker-compose.dev.yml up --build --force-recreate +} + +manage_stash() { + local current_branch action user_input stash_name choice selected_stash + local -A stash_map + local -a stash_list + local i line stash_ref stash_msg + + current_branch=$(git rev-parse --abbrev-ref HEAD) + + echo "Aktueller Branch: $current_branch" + echo "Was möchtest du tun?" + echo "1) Neuen Stash anlegen" + echo "2) Vorhandenen Stash laden (apply)" + echo "3) Vorhandenen Stash löschen" + echo "4) Stash anwenden und löschen (pop)" + echo "0) Zurück" + read -rp "Auswahl: " action + + case $action in + 0) + return 0 + ;; + 1) + read -rp "Gib einen Namen für den Stash ein: " user_input + stash_name="$current_branch - $user_input" + + if git stash list | grep -q "$stash_name"; then + while IFS= read -r line; do + stash_ref=$(echo "$line" | awk -F: '{print $1}') + echo "Lösche vorhandenen Stash: $stash_ref" + git stash drop "$stash_ref" + done < <(git stash list | grep "$stash_name") + fi + + git stash push -u -m "$stash_name" + echo "Stash '$stash_name' wurde angelegt." + ;; + + 2|3|4) + local action_text + case $action in + 2) action_text="laden" ;; + 3) action_text="löschen" ;; + 4) action_text="anwenden & löschen" ;; + esac + + echo "Liste aller Stashes für Branch '$current_branch':" + mapfile -t stash_list < <(git stash list | grep "$current_branch") || true + + if [[ ${#stash_list[@]} -eq 0 ]]; then + echo "Keine Stashes für diesen Branch vorhanden." + return 0 + fi + + i=1 + for line in "${stash_list[@]}"; do + stash_ref=$(echo "$line" | awk -F: '{print $1}') + stash_msg=$(echo "$line" | cut -d':' -f3- | sed 's/^ //') + echo " $i) $stash_ref -> $stash_msg" + stash_map[$i]=$stash_ref + ((i++)) + done + echo " 0) Zurück" + + read -rp "Wähle einen Stash zum ${action_text} (Nummer): " choice + if [[ "$choice" == "0" ]]; then + return 0 + fi + if [[ -z "${stash_map[$choice]}" ]]; then + echo "Ungültige Auswahl!" + return 1 + fi + + selected_stash=${stash_map[$choice]} + + case $action in + 2) + echo "Wende Stash an: $selected_stash" + git stash apply "$selected_stash" + ;; + 3) + echo "Lösche Stash: $selected_stash" + git stash drop "$selected_stash" + ;; + 4) + echo "Wende Stash an und lösche ihn: $selected_stash" + git stash pop "$selected_stash" + ;; + esac + ;; + + *) + echo "Ungültige Auswahl!" + return 1 + ;; + esac +} + + +branch_management_menu() { + local selection + while true; do + echo "" + cat <<'MENU' +Branch-Verwaltung: + + 1) Branch löschen (lokal/remote) + 2) Branch-Listen aktualisieren + 0) Zurück +MENU + read -rp "Auswahl: " selection + echo "" + case $selection in + 1) + delete_branch + pause_for_menu + ;; + 2) + update_branch_lists + pause_for_menu + ;; + 0) + break + ;; + *) + echo "❌ Ungültige Auswahl." + ;; + esac + done +} + +delete_branch() { + local -a local_branches remote_branches all_branches + local -A has_local has_remote branch_map + local current_branch branch label selection selected_branch delete_choice + local i=1 + + current_branch=$(git rev-parse --abbrev-ref HEAD) + + mapfile -t local_branches < <(git branch --format='%(refname:short)') + mapfile -t remote_branches < <(git branch -r | grep -v 'HEAD' | sed 's|^[[:space:]]*origin/||') + mapfile -t all_branches < <(printf "%s\n" "${local_branches[@]}" "${remote_branches[@]}" | sed '/^$/d' | sort -u) + + for branch in "${local_branches[@]}"; do + [[ -n "$branch" ]] && has_local["$branch"]=1 + done + for branch in "${remote_branches[@]}"; do + [[ -n "$branch" ]] && has_remote["$branch"]=1 + done + + if [[ ${#all_branches[@]} -eq 0 ]]; then + echo "Keine Branches zum Löschen gefunden." + return 1 + fi + + echo "Verfügbare Branches zum Löschen:" + for branch in "${all_branches[@]}"; do + label="" + if [[ -n "${has_local[$branch]}" && -n "${has_remote[$branch]}" ]]; then + label="(lokal & remote)" + elif [[ -n "${has_local[$branch]}" ]]; then + label="(nur lokal)" + else + label="(nur remote)" + fi + + printf " %d) %s %s\n" "$i" "$branch" "$label" + branch_map[$i]=$branch + ((i++)) + done + echo " 0) Zurück" + + read -rp "Wähle einen Branch (Nummer): " selection + if [[ "$selection" == "0" ]]; then + echo "Abgebrochen." + return 0 + fi + if [[ -z "${branch_map[$selection]}" ]]; then + echo "Ungültige Auswahl." + return 1 + fi + + selected_branch=${branch_map[$selection]} + + if [[ "$selected_branch" == "$current_branch" && -n "${has_local[$selected_branch]}" ]]; then + echo "Der aktuell ausgecheckte Branch '$selected_branch' kann nicht gelöscht werden." + return 1 + fi + + if [[ -n "${has_local[$selected_branch]}" && -n "${has_remote[$selected_branch]}" ]]; then + echo "" + echo "Branch '$selected_branch' existiert lokal und remote." + echo " 1) Nur lokal löschen" + echo " 2) Nur remote löschen" + echo " 3) Lokal und remote löschen" + read -rp "Auswahl: " delete_choice + echo "" + case $delete_choice in + 1) + delete_choice="local" + ;; + 2) + delete_choice="remote" + ;; + 3) + delete_choice="both" + ;; + *) + echo "Ungültige Auswahl." + return 1 + ;; + esac + elif [[ -n "${has_local[$selected_branch]}" ]]; then + delete_choice="local" + else + delete_choice="remote" + fi + + if [[ "$delete_choice" == "local" || "$delete_choice" == "both" ]]; then + read -rp "Lokalen Branch '$selected_branch' wirklich löschen? (j/N): " confirmation + if [[ "$confirmation" =~ ^[JjYy]$ ]]; then + if git branch -D "$selected_branch"; then + echo "Lokaler Branch '$selected_branch' wurde gelöscht." + else + echo "Fehler beim Löschen des lokalen Branches '$selected_branch'." + return 1 + fi + else + echo "Löschen des lokalen Branches abgebrochen." + [[ "$delete_choice" == "local" ]] && return 0 + fi + fi + + if [[ "$delete_choice" == "remote" || "$delete_choice" == "both" ]]; then + read -rp "Remote-Branch '$selected_branch' auf origin wirklich löschen? (j/N): " confirmation + if [[ "$confirmation" =~ ^[JjYy]$ ]]; then + if git push origin --delete "$selected_branch"; then + echo "Remote-Branch 'origin/$selected_branch' wurde gelöscht." + else + echo "Fehler beim Löschen des Remote-Branches '$selected_branch'." + return 1 + fi + else + echo "Löschen des Remote-Branches abgebrochen." + return 0 + fi + fi + + return 0 +} + +update_branch_lists() { + echo "Aktualisiere Branch-Listen (git fetch origin --prune)..." + if git fetch origin --prune; then + echo "Branch-Listen wurden aktualisiert." + else + echo "Fehler beim Aktualisieren der Branch-Listen." + return 1 + fi +} + +switch_branch() { + local -a unversioned_files=("devscripts/*") + local file + local local_branches remote_branches all_branches master_branch dev_branch feature_branches hotfix_branches other_branches + local -a sorted_branches + local i=1 choice selected_branch + declare -A branch_map + + for file in "${unversioned_files[@]}"; do + if [[ -f "$file" ]]; then + mkdir -p /tmp/git_safe_backup + cp "$file" "/tmp/git_safe_backup/$(basename "$file")" + fi + done + local_branches=$(git branch | sed 's/* //' | sed 's/^[[:space:]]*//') + remote_branches=$(git branch -r | grep -v 'HEAD' | sed 's|^[[:space:]]*origin/||' | sed 's/^[[:space:]]*//') + all_branches=$(printf "%s\n%s\n" "$local_branches" "$remote_branches" | sort -u) + + master_branch=$(echo "$all_branches" | grep -x 'master' || true) + dev_branch=$(echo "$all_branches" | grep -x 'dev' || true) + feature_branches=$(echo "$all_branches" | grep '^feature/' | sort || true) + hotfix_branches=$(echo "$all_branches" | grep '^hotfix/' | sort || true) + other_branches=$(echo "$all_branches" | grep -Ev '^(master|dev|feature/|hotfix/)' | sort || true) + + [[ -n "$master_branch" ]] && sorted_branches+=("$master_branch") + [[ -n "$dev_branch" ]] && sorted_branches+=("$dev_branch") + if [[ -n "$feature_branches" ]]; then + while IFS= read -r line; do + [[ -n "$line" ]] && sorted_branches+=("$line") + done <<< "$feature_branches" + fi + if [[ -n "$hotfix_branches" ]]; then + while IFS= read -r line; do + [[ -n "$line" ]] && sorted_branches+=("$line") + done <<< "$hotfix_branches" + fi + if [[ -n "$other_branches" ]]; then + while IFS= read -r line; do + [[ -n "$line" ]] && sorted_branches+=("$line") + done <<< "$other_branches" + fi + + if [[ ${#sorted_branches[@]} -eq 0 ]]; then + echo "Keine Branches gefunden." + return 1 + fi + + echo "Verfügbare Branches:" + for line in "${sorted_branches[@]}"; do + echo " $i) $line" + branch_map[$i]=$line + ((i++)) + done + echo " 0) Zurück" + + read -rp "Wähle einen Branch (Nummer): " choice + if [[ "$choice" == "0" ]]; then + rm -rf /tmp/git_safe_backup + echo "Abgebrochen." + return 0 + fi + if [[ -z "${branch_map[$choice]}" ]]; then + echo "Ungültige Auswahl!" + rm -rf /tmp/git_safe_backup + return 1 + fi + + selected_branch=${branch_map[$choice]} + echo "Wechsle zu Branch: $selected_branch" + + git fetch origin + + if git show-ref --verify --quiet "refs/heads/$selected_branch"; then + git checkout "$selected_branch" + else + git checkout -b "$selected_branch" "origin/$selected_branch" + fi + + git reset --hard "origin/$selected_branch" + git clean -fd + + for file in "${unversioned_files[@]}"; do + if [[ -f "/tmp/git_safe_backup/$(basename "$file")" ]]; then + mkdir -p "$(dirname "$file")" + mv "/tmp/git_safe_backup/$(basename "$file")" "$file" + fi + done + rm -rf /tmp/git_safe_backup + + echo "Branch '$selected_branch' ist nun aktiv. Arbeitsverzeichnis entspricht exakt dem Remote-Stand." + echo "Gesicherte Dateien wurden wiederhergestellt." +} + +main() { + local selection + while true; do + echo "" + show_menu + read -rp "Auswahl: " selection + echo "" + case $selection in + 1) + create_new_feature + pause_for_menu + ;; + 2) + merge_commit_menu + ;; + 3) + dev_environments_menu + ;; + 4) + hotfix_menu + ;; + 5) + branch_management_menu + ;; + 6) + switch_branch + pause_for_menu + ;; + 0) + echo "Auf Wiedersehen!" + exit 0 + ;; + *) + echo "❌ Ungültige Auswahl." + ;; + esac + done +} + +main "$@" diff --git a/dev.sh b/dev.sh index f41678b..bcf5190 100755 --- a/dev.sh +++ b/dev.sh @@ -1,49 +1,111 @@ #!/usr/bin/env bash set -euo pipefail -echo "🚀 Starting Klangkiste (Box + GUI)" +echo "🚀 Starting Klangkiste (Box + Backend + Frontend)" + +HOST_IP="${KLANGKISTE_HOST_IP:-}" +if [[ -z "$HOST_IP" ]]; then + HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" +fi +if [[ -z "$HOST_IP" ]]; then + HOST_IP="127.0.0.1" +fi +BACKEND_URL_DEFAULT="http://${HOST_IP}:5001" +BACKEND_URL="${KLANGKISTE_BACKEND_URL:-$BACKEND_URL_DEFAULT}" + +read -rp "➡️ Backend: npm install ausführen? (y/N) " backend_install_choice +if [[ "$backend_install_choice" =~ ^([YyJj]|[Yy]es|[Jj]a)$ ]]; then + BACKEND_NPM_INSTALL="1" +else + BACKEND_NPM_INSTALL="0" +fi + +read -rp "➡️ Frontend: npm install ausführen? (y/N) " frontend_install_choice +if [[ "$frontend_install_choice" =~ ^([YyJj]|[Yy]es|[Jj]a)$ ]]; then + FRONTEND_NPM_INSTALL="1" +else + FRONTEND_NPM_INSTALL="0" +fi # Ensure both processes are stopped on exit or Ctrl+C. cleanup() { echo "" echo "🛑 Stopping Klangkiste..." - if [[ -n "${SERVER_PID:-}" ]]; then - kill -TERM "$SERVER_PID" 2>/dev/null || true - wait "$SERVER_PID" 2>/dev/null || true + if [[ -n "${BACKEND_PID:-}" ]]; then + kill -TERM "$BACKEND_PID" 2>/dev/null || true + wait "$BACKEND_PID" 2>/dev/null || true fi if [[ -n "${GUI_PID:-}" ]]; then kill -TERM "$GUI_PID" 2>/dev/null || true wait "$GUI_PID" 2>/dev/null || true fi + if [[ -n "${BOX_PID:-}" ]]; then + kill -TERM "$BOX_PID" 2>/dev/null || true + wait "$BOX_PID" 2>/dev/null || true + fi + if [[ -n "${BOX2_PID:-}" ]]; then + kill -TERM "$BOX2_PID" 2>/dev/null || true + wait "$BOX2_PID" 2>/dev/null || true + fi } trap cleanup INT TERM EXIT -# Server starten (FastAPI + GUI) -echo "▶️ Starting Server..." +# Backend starten (Node + SQLite) +echo "▶️ Starting Backend..." ( - cd server - python3 main.py + cd gui/backend + if [[ "$BACKEND_NPM_INSTALL" == "1" ]]; then + npm install + else + echo "ℹ️ Backend npm install übersprungen." + fi + npm run dev ) & -SERVER_PID=$! +BACKEND_PID=$! -# GUI starten (Vite) +# Frontend starten (Vite + React) GUI_PORT="${KLANGKISTE_GUI_PORT:-5174}" -echo "▶️ Starting GUI on port ${GUI_PORT}..." +echo "▶️ Starting Frontend on port ${GUI_PORT}..." ( - cd gui - npm run dev -- --host 0.0.0.0 --port "${GUI_PORT}" --strictPort + cd gui/frontend + if [[ "$FRONTEND_NPM_INSTALL" == "1" ]]; then + npm install + else + echo "ℹ️ Frontend npm install übersprungen." + fi + VITE_BACKEND_URL="${BACKEND_URL}" npm run dev -- --host 0.0.0.0 --port "${GUI_PORT}" --strictPort ) & GUI_PID=$! # Box starten (FastAPI + Run-Modus) echo "▶️ Starting Box..." -cd box -python3 main.py run +( + cd box + KLANGKISTE_SERVER_URL="${BACKEND_URL}" python3 main.py run +) & + +BOX_PID=$! + +# Box 2 starten (FastAPI + Run-Modus) +echo "▶️ Starting Box 2..." +( + cd box2 + KLANGKISTE_SERVER_URL="${BACKEND_URL}" \ + KLANGKISTE_API_PORT="8001" \ + KLANGKISTE_SETUP_PORT="9001" \ + python3 main.py run +) & + +BOX2_PID=$! echo "" -echo "✅ Server PID: $SERVER_PID" -echo "✅ GUI PID: $GUI_PID" +echo "✅ Backend PID: $BACKEND_PID" +echo "✅ Frontend PID: $GUI_PID" +echo "✅ Box PID: $BOX_PID" +echo "✅ Box2 PID: $BOX2_PID" echo "🛑 Press Ctrl+C to stop everything" echo "" + +wait "$BOX_PID" diff --git a/docs/README.md b/docs/README.md index 2b7fc48..a650a3c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,22 +21,20 @@ Quickstart (3-5 Minuten): http://127.0.0.1:9000/setup ``` 3) WLAN-Profil eintragen und speichern. -4) Status pruefen: +4) Backend pruefen: ``` -curl http://127.0.0.1:8000/status +curl http://127.0.0.1:5001/api/boxes ``` -5) Playback starten: +5) Frontend oeffnen und Box pairen: ``` -curl -X POST http://127.0.0.1:8000/command \ - -H "Content-Type: application/json" \ - -d '{"command":"nfc_on","payload":{"uid":"UID_1"}}' +http://127.0.0.1:5174 ``` ## Troubleshooting - Box startet nicht: - Pruefe Abhaengigkeiten und ob Ports 8000/9000 frei sind. - GUI erreicht API nicht: - - Pruefe `VITE_BOX_API_URL` und Box-Logs. + - Pruefe `VITE_BACKEND_URL` und Backend-Logs. ## Was ist Mock vs. real - Mock: WLAN, IP, Spotify, Audio, Hardware. Diese Backends sind logische Simulationen. diff --git a/docs/acceptance-tests.md b/docs/acceptance-tests.md index c75e82c..19498bc 100644 --- a/docs/acceptance-tests.md +++ b/docs/acceptance-tests.md @@ -186,7 +186,7 @@ curl -X POST http://127.0.0.1:8000/command \ - `/status` aendert sich nach Commands. 6) Abweichungen / Fehlersuche -- GUI "Failed to fetch": `VITE_BOX_API_URL` pruefen. +- GUI "Failed to fetch": `VITE_BACKEND_URL` pruefen. --- @@ -197,7 +197,7 @@ curl -X POST http://127.0.0.1:8000/command \ 2) Voraussetzungen - Tags in `box/data/box.json`. -- Medien in `box/data/media`. +- Medien im Backend unter `gui/backend/media`. 3) Durchfuehrung – Simulation / Emulation ``` @@ -245,7 +245,7 @@ curl -X POST http://127.0.0.1:8000/command \ - Rekursive Reihenfolge (DFS) ist korrekt. 2) Voraussetzungen -- Verschachtelte Ordner in `box/data/media`. +- Verschachtelte Ordner im Backend unter `gui/backend/media`. 3) Durchfuehrung – Simulation / Emulation - Tag auf `media/book_6` setzen, `nfc_on`. @@ -310,7 +310,7 @@ curl -X POST http://127.0.0.1:8000/command \ --- -# Server/Pairing-Tests (A-J) +# Backend/Pairing-Tests (A-J) ## Test A: Box-Erststart & Identitaet @@ -334,15 +334,21 @@ curl -X POST http://127.0.0.1:8000/command \ --- -## Test B: Announce -> neue Box erscheint im Server +## Test B: Announce -> neue Box erscheint im Backend 1) Ziel -- Box meldet sich beim Server und erscheint als UNPAIRED. +- Box meldet sich beim Backend und erscheint als UNPAIRED. 2) Voraussetzungen -- Server laeuft: +- Backend laeuft: ``` -python3 server/main.py +cd gui/backend +npm run dev +``` +- Frontend laeuft: +``` +cd gui/frontend +npm run dev -- --host 0.0.0.0 --port 5174 --strictPort ``` 3) Durchfuehrung – Simulation / Emulation @@ -351,26 +357,26 @@ python3 server/main.py python3 box/main.py run ``` - 30-60s warten (Announce-Intervall). -- Server-GUI oeffnen: +- Frontend-GUI oeffnen: ``` -http://127.0.0.1:7000/ui +http://127.0.0.1:5174 ``` -- Optional: Server-Storage pruefen: +- Optional: Backend-Storage pruefen: ``` -cat server/data/boxes.json +sqlite3 gui/backend/db/klangkiste.sqlite "SELECT box_id, state, last_seen FROM boxes;" ``` 4) Durchfuehrung – Reale Box (Hardware) - Box einschalten. -- Server-GUI oeffnen, Box sollte erscheinen. +- Frontend-GUI oeffnen, Box sollte erscheinen. 5) Erwartetes Ergebnis -- Box erscheint als UNPAIRED in der Server-GUI. -- `server/data/boxes.json` enthaelt `box_id` und `fingerprint`. +- Box erscheint als UNPAIRED in der Frontend-GUI. +- `gui/backend/db/klangkiste.sqlite` enthaelt `box_id` und `fingerprint`. 6) Abweichungen / Fehlersuche -- Keine Box sichtbar: Server-URL in `box/data/box.json` pruefen. -- Server nicht erreichbar: Server-Prozess/Port 7000 pruefen. +- Keine Box sichtbar: Backend-URL in `box/data/box.json` pruefen. +- Backend nicht erreichbar: Backend-Prozess/Port 5001 pruefen. --- @@ -387,7 +393,7 @@ cat server/data/boxes.json 4) Durchfuehrung – Reale Box (Hardware) - Zwei physische Boxen einschalten. -- Server-GUI zeigt beide UNPAIRED an. +- Frontend-GUI zeigt beide UNPAIRED an. 5) Erwartetes Ergebnis - Zwei Eintraege mit unterschiedlichen `box_id`. @@ -397,34 +403,34 @@ cat server/data/boxes.json --- -## Test D: Pairing-Freigabe ueber Server-GUI +## Test D: Pairing-Freigabe ueber Frontend-GUI 1) Ziel - Pairing liefert API-Token und Box wird PAIRED. 2) Voraussetzungen -- Box erscheint als UNPAIRED in `/ui`. +- Box erscheint als UNPAIRED in der Frontend-GUI. 3) Durchfuehrung – Simulation / Emulation -- Server-GUI oeffnen: +- Frontend-GUI oeffnen: ``` -http://127.0.0.1:7000/ui +http://127.0.0.1:5174 ``` -- Button "Box hinzufuegen" klicken. -- API-Token via Server-API abrufen: +- Button "Pairen" klicken. +- API-Token via Backend-API abrufen: ``` -curl -X POST http://127.0.0.1:7000/api/boxes/pair -H "Content-Type: application/json" -d '{"box_id":""}' +curl -X POST http://127.0.0.1:5001/api/boxes/pair -H "Content-Type: application/json" -d '{"box_id":""}' ``` 4) Durchfuehrung – Reale Box (Hardware) -- Gleiches Vorgehen ueber Server-GUI. +- Gleiches Vorgehen ueber Frontend-GUI. 5) Erwartetes Ergebnis - Box erscheint in der gepaarten Liste. - `api_token` wird an die Box ausgeliefert. 6) Abweichungen / Fehlersuche -- Pairing bleibt aus: `server/data/boxes.json` pruefen. +- Pairing bleibt aus: `gui/backend/db/klangkiste.sqlite` pruefen. --- @@ -526,24 +532,24 @@ rm -f box/data/box.json box/data/state.json box/data/secrets.enc --- -## Test I: Offline / Server nicht erreichbar +## Test I: Offline / Backend nicht erreichbar 1) Ziel -- Box laeuft weiter ohne Server. +- Box laeuft weiter ohne Backend. 2) Voraussetzungen -- Box laeuft, Server gestoppt. +- Box laeuft, Backend gestoppt. 3) Durchfuehrung – Simulation / Emulation -- Server stoppen. +- Backend stoppen. - Playback starten (mit Token falls gepairt). 4) Durchfuehrung – Reale Box (Hardware) -- Server vom Netz trennen. +- Backend vom Netz trennen. 5) Erwartetes Ergebnis - Lokale Playback-Logik laeuft weiter. -- Server-GUI zeigt Box ggf. als OFFLINE nach Timeout. +- Nicht testbar mit aktuellem Code: Backend markiert OFFLINE nicht. 6) Abweichungen / Fehlersuche - Box stoppt: lokale Logs pruefen. diff --git a/docs/api.md b/docs/api.md index 822121c..96319ff 100644 --- a/docs/api.md +++ b/docs/api.md @@ -29,6 +29,9 @@ Felder: - `connected_ssid`: verbundene SSID oder `null` - `wifi_profiles_count`: Anzahl gespeicherter Profile - `spotify_status`: `NOT_CONFIGURED | READY` +- `last_nfc`: + - `uid`: letzte erkannte UID oder `null` + - `known`: `true` wenn Tag in der Box zugeordnet ist - `playback_state`: - `state`: `IDLE | PLAYING | PAUSED` - `active_uid` @@ -54,7 +57,7 @@ Beispielantwort: "state": "PLAYING", "active_uid": "UID_1", "playback_root": "media/book_1", - "current_file": "/root/klangkiste/box/data/media/book_1/01.mp3", + "current_file": "/root/klangkiste/box/data/media/stories/dragons/album_1/track_01.mp3", "file_index": 0, "position": 42, "duration": 180, @@ -89,6 +92,13 @@ Unterstuetzte Commands: - `wifi_reset` - `spotify_set_tokens` `{ access_token, refresh_token, expires_at, account_id? }` - `spotify_clear` +- `unpair` +- `tag_assign` `{ uid, media_path }` +- `tag_remove` `{ uid }` + +## Tag-Commands +- `tag_assign` `{ uid, media_path }` weist ein Tag einem Medienordner zu. +- `tag_remove` `{ uid }` entfernt die Zuordnung fuer ein Tag. ## Auth fuer Commands - Vor Pairing sind nur diese Commands erlaubt: diff --git a/docs/cli.md b/docs/cli.md index 8673d72..77190b2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -52,4 +52,4 @@ exit | quit - Commands nicht gefunden: - Aus Repo-Root ausfuehren oder `box/main.py` voll referenzieren. - Kein Playback: - - Tags in `box/data/box.json` und Medien in `box/data/media` pruefen. +- Tags in `box/data/box.json` und Medien im Backend unter `gui/backend/media` pruefen. diff --git a/docs/debugging.md b/docs/debugging.md index 35288a0..52d3936 100644 --- a/docs/debugging.md +++ b/docs/debugging.md @@ -14,19 +14,19 @@ Nutze die Sektionen nach Symptomen. ### GUI zeigt "Failed to fetch" - API pruefen: ``` -curl http://127.0.0.1:8000/status +curl http://127.0.0.1:5001/api/boxes ``` - GUI API-Base pruefen: - - `VITE_BOX_API_URL` oder Default `http://localhost:8000` + - `VITE_BACKEND_URL` oder Default `http://127.0.0.1:5001` ### Playback startet nicht - Tags pruefen: ``` cat box/data/box.json ``` -- Medien pruefen: +- Medienkatalog pruefen: ``` -ls -la box/data/media +ls -la gui/backend/media ``` - Status pruefen: ``` @@ -44,10 +44,10 @@ cat box/data/state.json - Port 9000 frei? - Box laeuft? -### Box wird nicht erkannt (Server) +### Box wird nicht erkannt (Backend) - Server-URL in `box/data/box.json` pruefen (`server_url`). -- Server laeuft auf Port 7000? -- `server/data/boxes.json` pruefen. +- Backend laeuft auf Port 5001? +- `gui/backend/db/klangkiste.sqlite` pruefen. ### Box erscheint mehrfach - `box_id` in `box/data/box.json` pruefen. @@ -58,7 +58,7 @@ cat box/data/state.json - `box/data/box.json` darf nicht geloescht werden. ### Pairing haengt -- `server/data/boxes.json` pruefen: state, api_token. +- `gui/backend/db/klangkiste.sqlite` pruefen: state, api_token. - Box-Status: `pairing_state` in `/status` pruefen. ## Reale Box (Hardware) @@ -76,7 +76,7 @@ cat box/data/state.json - Nicht testbar mit aktuellem Code. ### Box wird nicht erkannt / erscheint mehrfach / Fingerprint wechselt / Pairing haengt -- Server-GUI (`/ui`) pruefen. +- Frontend-GUI (`http://127.0.0.1:5174`) pruefen. - Box-IP/Server-IP/VLAN pruefen. - Box-Logs pruefen. diff --git a/docs/dev-workflow.md b/docs/dev-workflow.md index 7d517a5..953f98a 100644 --- a/docs/dev-workflow.md +++ b/docs/dev-workflow.md @@ -13,34 +13,41 @@ Start/Stop der virtuellen Box, Logs, und GUI-API-Konfiguration. ``` ./dev.sh ``` -- GUI laeuft im Hintergrund. -- Box laeuft im Vordergrund und akzeptiert CLI-Kommandos. +- Frontend, Backend und Box laufen im Hintergrund. +- CLI-Kommandos fuer die Box bitte in einem separaten Terminal absetzen. ### Komponenten separat starten Box only: ``` python3 box/main.py run ``` -GUI only: +Frontend only: ``` -cd gui +cd gui/frontend +npm run dev -- --host 0.0.0.0 --port 5174 --strictPort +``` +Backend only: +``` +cd gui/backend npm run dev ``` ## Ports / Hosts -- Server API + GUI: `http://127.0.0.1:7000` (GUI unter `/ui`) +- Backend API: `http://127.0.0.1:5001` +- Frontend GUI: `http://127.0.0.1:5174` - Box API: `http://127.0.0.1:8000` - Setup UI: `http://127.0.0.1:9000/setup` - GUI: `http://127.0.0.1:5174` ## API-URL fuer die GUI -Default ist `http://localhost:8000`. Override: +Default ist `http://:5001` (aus `hostname -I`). Override: ``` -VITE_BOX_API_URL=http://127.0.0.1:8000 npm run dev +KLANGKISTE_BACKEND_URL=http://:5001 ./dev.sh +VITE_BACKEND_URL=http://:5001 npm run dev ``` ## Logs ansehen -- Server-Logs: Terminal mit `python3 server/main.py` +- Backend-Logs: Terminal mit `cd gui/backend && npm run dev` - Box-Logs: Terminal mit `python3 box/main.py run` - GUI-Logs: Browser-Konsole @@ -52,7 +59,7 @@ VITE_BOX_API_URL=http://127.0.0.1:8000 npm run dev - ✅ `./dev.sh` gestartet - ✅ Box-CLI Prompt sichtbar - ✅ GUI im Browser erreichbar -- ✅ `curl http://127.0.0.1:8000/status` liefert JSON +- ✅ `curl http://127.0.0.1:5001/api/boxes` liefert JSON ## Troubleshooting - Port belegt: @@ -60,4 +67,4 @@ VITE_BOX_API_URL=http://127.0.0.1:8000 npm run dev - CORS-Fehler: - API muss laufen und erreichbar sein. - GUI zeigt "Failed to fetch": - - `VITE_BOX_API_URL` pruefen. + - `VITE_BACKEND_URL` pruefen. diff --git a/docs/gui-control-panel.md b/docs/gui-control-panel.md index ef2c30f..b912c52 100644 --- a/docs/gui-control-panel.md +++ b/docs/gui-control-panel.md @@ -1,56 +1,57 @@ # GUI Control Panel ## Zweck -Nutzung des GUI Control Panels zur Steuerung und Beobachtung der virtuellen Box. +Nutzung des Frontend Control Panels zur Steuerung und Beobachtung der Boxen ueber das Backend. ## Voraussetzungen -- Box laeuft auf `http://127.0.0.1:8000` -- GUI gestartet (`npm run dev` oder `./dev.sh`) - - Bei gepaarter Box: `VITE_BOX_API_TOKEN` gesetzt +- Backend laeuft auf `http://127.0.0.1:5001` +- Frontend gestartet (`cd gui/frontend && npm run dev` oder `./dev.sh`) +- Box laeuft und meldet sich per Announce beim Backend ## Schritt-fuer-Schritt 1) GUI oeffnen: ``` http://127.0.0.1:5174 ``` -2) Buttons klicken, Status beobachten. +2) In "Neue Boxen" die Box auswaehlen und "Pairen" klicken. +3) In "Gepairte Boxen" eine Box anklicken. +4) Status und Commands nutzen. ## Controls (im GUI vorhanden) -- Play / Pause -- Next -- Prev -- Vol + -- Vol - -- NFC UID_1 ON -- NFC UID_1 OFF -- Refresh Status +- Pairen (Button in "Neue Boxen") +- Statusanzeige (automatisch) +- Play/Pause, Next, Prev, Vol +/-, Stop +- NFC on/off (UID eingeben) +- Unpair (Button in "Gepairte Boxen") +- Medienkatalog (Explorer-Ansicht, nur Lesen) +- Neuer Tag erkannt (Panel) mit ID-Zuweisung und Zuordnung +- Tags werden nur nach NFC-Erkennung erstellt und zugeordnet. -## Controls (nur API) +## Controls (nur Backend/API) - WiFi add/reset - Spotify set/clear -Beispiel via API: +Beispiel via Backend-API (weitergeleitet an die Box): ``` - -## Auth-Token fuer GUI -- Nach Pairing: Token ueber Server-API oder Server-GUI ermitteln.\n- GUI mit Token starten:\n```\nVITE_BOX_API_TOKEN= npm run dev\n``` -curl -X POST http://127.0.0.1:8000/command \ +curl -X POST http://127.0.0.1:5001/api/boxes//command \ -H "Content-Type: application/json" \ -d '{"command":"wifi_add_profile","payload":{"ssid":"HomeWiFi","password":"secret","priority":10}}' ``` ## Erwartete Ergebnisse - Nach jedem Klick wird der Status innerhalb von 1s aktualisiert. -- `last_error` wird angezeigt, wenn gesetzt. +- Fehler werden im GUI angezeigt. ## Checkliste -- ✅ GUI laedt und zeigt API-URL -- ✅ Status aktualisiert sich automatisch -- ✅ Buttons aendern `/status` +- ✅ GUI laedt und zeigt Backend-URL +- ✅ Neue Boxen erscheinen unter "Neue Boxen" +- ✅ Pairing verschiebt die Box in die gepaarte Liste +- ✅ Buttons aendern den `/status` ## Troubleshooting - GUI zeigt "Failed to fetch": - - `VITE_BOX_API_URL` pruefen. - - Box API muss laufen. + - Backend erreichbar? `http://127.0.0.1:5001/api/boxes` + - Browser-Konsole pruefen. - Buttons ohne Wirkung: - - API-Command-Namen in `docs/api.md` pruefen. + - Box gepairt? + - Backend-Logs pruefen. diff --git a/docs/playback-testing.md b/docs/playback-testing.md index ee6af5b..23ed78c 100644 --- a/docs/playback-testing.md +++ b/docs/playback-testing.md @@ -5,7 +5,8 @@ Tests fuer NFC-Playback, Rekursion, Resume und Buttons. ## Voraussetzungen - Box laeuft: `python3 box/main.py run` -- Medien unter `box/data/media/` +- Medienkatalog unter `gui/backend/media/` +- Box spielt nur Medien, die ueber Tags zugeordnet wurden. ## Schritt-fuer-Schritt ### NFC-Simulation @@ -57,6 +58,6 @@ Erwartung: UID_1 resumed. ## Troubleshooting - Kein Playback: - Tags in `box/data/box.json` pruefen. - - Dateien in `box/data/media` pruefen. +- Medienordner im Backend pruefen (`gui/backend/media`). - Duration = 0: - Unbekannte Dateinamen -> Hash-Dauer, nicht 0. diff --git a/gui/.gitignore b/gui/.gitignore deleted file mode 100644 index a547bf3..0000000 --- a/gui/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/gui/.vscode/extensions.json b/gui/.vscode/extensions.json deleted file mode 100644 index a7cea0b..0000000 --- a/gui/.vscode/extensions.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "recommendations": ["Vue.volar"] -} diff --git a/gui/README.md b/gui/README.md deleted file mode 100644 index 1511959..0000000 --- a/gui/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Vue 3 + Vite - -This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 ` + + diff --git a/gui/frontend/package-lock.json b/gui/frontend/package-lock.json new file mode 100644 index 0000000..bd3a2f0 --- /dev/null +++ b/gui/frontend/package-lock.json @@ -0,0 +1,2743 @@ +{ + "name": "klangkiste-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "klangkiste-frontend", + "version": "0.1.0", + "dependencies": { + "@phosphor-icons/react": "2.1.7", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "4.3.1", + "vite": "5.4.3" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@phosphor-icons/react": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.7.tgz", + "integrity": "sha512-g2e2eVAn1XG2a+LI09QU3IORLhnFNAFkNbo2iwbX6NOKSLOwvEMmTa7CgOzEbgNWR47z8i8kwjdvYZ5fkGx1mQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">= 16.8", + "react-dom": ">= 16.8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.1.tgz", + "integrity": "sha512-m/V2syj5CuVnaxcUJOQRel/Wr31FFXRFlnOoq1TVtkCxsY5veGMTEmpWHndrhB2U8ScHtCQB1e+4hWYExQc6Lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.5", + "@babel/plugin-transform-react-jsx-self": "^7.24.5", + "@babel/plugin-transform-react-jsx-source": "^7.24.1", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/boom": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/boom/-/boom-2.10.1.tgz", + "integrity": "sha512-KbiZEa9/vofNcVJXGwdWWn25reQ3V3dHBWbS07FTF3/TOehLnm9GEhJV4T6ZvGPkShRpmUqYwnaCrkj0mRnP6Q==", + "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "hoek": "2.x.x" + }, + "engines": { + "node": ">=0.10.40" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cryptiles": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-2.0.5.tgz", + "integrity": "sha512-FFN5KwpvvQTTS5hWPxrU8/QE4kQUc6uwZcrnlMBN82t1MgAtq8mnoDwINBly9Tdr02seeIIhtdF+UH1feBYGog==", + "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boom": "2.x.x" + }, + "engines": { + "node": ">=0.10.40" + } + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hawk": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hawk/-/hawk-3.1.3.tgz", + "integrity": "sha512-X8xbmTc1cbPXcQV4WkLcRMALuyoxhfpFATmyuCxJPOAvrDS4DNnsTAOmKUxMTOWU6TzrTOkxPKwIx5ZOpJVSrg==", + "deprecated": "This module moved to @hapi/hawk. Please make sure to switch over as this distribution is no longer supported and may contain bugs and critical security issues.", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "boom": "2.x.x", + "cryptiles": "2.x.x", + "hoek": "2.x.x", + "sntp": "1.x.x" + }, + "engines": { + "node": ">=0.10.32" + } + }, + "node_modules/hoek": { + "version": "2.16.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-2.16.3.tgz", + "integrity": "sha512-V6Yw1rIcYV/4JsnggjBU0l4Kr+EXhpwqXRusENU1Xx6ro00IHPHYNynCuBTOZAPlr3AAmLvchH9I7N/VUdvOwQ==", + "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.40" + } + }, + "node_modules/image-size": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", + "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)", + "optional": true + }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/less": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/less/-/less-2.7.3.tgz", + "integrity": "sha512-KPdIJKWcEAb02TuJtaLrhue0krtRLoRoo7x6BNJIBelO00t/CCdJQUnHW5V34OnHMWzIktSalJxRO+FvytQlCQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "bin": { + "lessc": "bin/lessc" + }, + "engines": { + "node": ">=0.12" + }, + "optionalDependencies": { + "errno": "^0.1.1", + "graceful-fs": "^4.1.2", + "image-size": "~0.5.0", + "mime": "^1.2.11", + "mkdirp": "^0.5.0", + "promise": "^7.1.1", + "request": "2.81.0", + "source-map": "^0.5.3" + } + }, + "node_modules/less/node_modules/ajv": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz", + "integrity": "sha512-I/bSHSNEcFFqXLf91nchoNB9D1Kie3QKcWdchYUaoIg1+1bdWDkdfdlvdIOJbi9U8xR0y+MWc5D+won9v95WlQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "co": "^4.6.0", + "json-stable-stringify": "^1.0.1" + } + }, + "node_modules/less/node_modules/assert-plus": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.2.0.tgz", + "integrity": "sha512-u1L0ZLywRziOVjUhRxI0Qg9G+4RnFB9H/Rq40YWn0dieDgO7vAYeJz6jKAO6t/aruzlDFLAPkQTT87e+f8Imaw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/less/node_modules/aws-sign2": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.6.0.tgz", + "integrity": "sha512-JnJpAS0p9RmixkOvW2XwDxxzs1bd4/VAGIl6Q0EC5YOo+p+hqIhtDhn/nmFnB/xUNXbLkpE2mOjgVIBRKD4xYw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/less/node_modules/form-data": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.1.4.tgz", + "integrity": "sha512-8HWGSLAPr+AG0hBpsqi5Ob8HrLStN/LWeqhpFl14d7FJgHK48TmgLoALPz69XSUR65YJzDfLUX/BM8+MLJLghQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.5", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/less/node_modules/har-schema": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-1.0.5.tgz", + "integrity": "sha512-f8xf2GOR6Rgwc9FPTLNzgwB+JQ2/zMauYXSWmX5YV5acex6VomT0ocSuwR7BfXo5MpHi+jL+saaux2fwsGJDKQ==", + "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/less/node_modules/har-validator": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-4.2.1.tgz", + "integrity": "sha512-5Gbp6RAftMYYV3UEI4c4Vv3+a4dQ7taVyvHt+/L6kRt+f4HX1GweAk5UDWN0SvdVnRBzGQ6OG89pGaD9uSFnVw==", + "deprecated": "this library is no longer supported", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "ajv": "^4.9.1", + "har-schema": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/less/node_modules/http-signature": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz", + "integrity": "sha512-iUn0NcRULlDGtqNLN1Jxmzayk8ogm7NToldASyZBpM2qggbphjXzNOiw3piN8tgz+e/DRs6X5gAzFwTI6BCRcg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^0.2.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, + "node_modules/less/node_modules/oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha512-VlF07iu3VV3+BTXj43Nmp6Irt/G7j/NgEctUS6IweH1RGhURjjCc2NWtzXFPXXWWfc7hgbXQdtiQu2LGp6MxUg==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/less/node_modules/performance-now": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-0.2.0.tgz", + "integrity": "sha512-YHk5ez1hmMR5LOkb9iJkLKqoBlL7WD5M8ljC75ZfzXriuBIVNuecaXuU7e+hOwyqf24Wxhh7Vxgt7Hnw9288Tg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/less/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/less/node_modules/punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/less/node_modules/qs": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.4.1.tgz", + "integrity": "sha512-LQy1Q1fcva/UsnP/6Iaa4lVeM49WiOitu2T4hZCyA/elLKu37L99qcBJk4VCCk+rdLvnMzfKyiN3SZTqdAZGSQ==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/less/node_modules/request": { + "version": "2.81.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.81.0.tgz", + "integrity": "sha512-IZnsR7voF0miGSu29EXPRgPTuEsI/+aibNSBbN1pplrfartF5wDYGADz3iD9vmBVf2r00rckWZf8BtS5kk7Niw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "aws-sign2": "~0.6.0", + "aws4": "^1.2.1", + "caseless": "~0.12.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.0", + "forever-agent": "~0.6.1", + "form-data": "~2.1.1", + "har-validator": "~4.2.1", + "hawk": "~3.1.3", + "http-signature": "~1.1.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.7", + "oauth-sign": "~0.8.1", + "performance-now": "^0.2.0", + "qs": "~6.4.0", + "safe-buffer": "^5.0.1", + "stringstream": "~0.0.4", + "tough-cookie": "~2.3.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.0.0" + }, + "engines": { + "node": ">= 4" + } + }, + "node_modules/less/node_modules/tough-cookie": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "punycode": "^1.4.1" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sntp": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/sntp/-/sntp-1.0.9.tgz", + "integrity": "sha512-7bgVOAnPj3XjrKY577S+puCKGCRlUrcrEdsMeRXlg9Ghf5df/xNi6sONUa43WrHUd3TjJBF7O04jYoiY0FVa0A==", + "deprecated": "This module moved to @hapi/sntp. Please make sure to switch over as this distribution is no longer supported and may contain bugs and critical security issues.", + "dev": true, + "optional": true, + "dependencies": { + "hoek": "2.x.x" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stringstream": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/stringstream/-/stringstream-0.0.6.tgz", + "integrity": "sha512-87GEBAkegbBcweToUrdzf3eLhWNg06FJTebl4BVJz/JgWy8CvEr9dRtX5qWphiynMSQlxxi+QqN0z5T32SLlhA==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense", + "optional": true + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/vite": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.3.tgz", + "integrity": "sha512-IH+nl64eq9lJjFqU+/yrRnrHPVTlgy42/+IzbOdaFDVlyLgI/wDlf+FCobXLX1cT0X5+7LMyH1mIy2xJdLfo8Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/gui/frontend/package.json b/gui/frontend/package.json new file mode 100644 index 0000000..874da08 --- /dev/null +++ b/gui/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "klangkiste-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "@phosphor-icons/react": "2.1.7", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "4.3.1", + "vite": "5.4.3" + } +} diff --git a/gui/frontend/src/App.jsx b/gui/frontend/src/App.jsx new file mode 100644 index 0000000..67c1b9a --- /dev/null +++ b/gui/frontend/src/App.jsx @@ -0,0 +1,2197 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + ArrowUp, + ArrowRight, + CaretDown, + CaretRight, + FloppyDisk, + FileAudio, + Folder, + List, + PencilSimple, + Plus, + Trash, + UploadSimple, +} from '@phosphor-icons/react'; +import { + getBoxes, + getStatus, + getMediaTree, + createMediaFolder, + renameMedia, + moveMedia, + deleteMedia, + uploadMedia, + getTags, + getBoxTags, + getBoxLocalTags, + getTagBlocks, + claimTag, + markTagWritten, + assignTag, + unassignTag, + deleteTag, + setTagMedia, + pullTagFromBox, + setTagBlock, + setBoxAlias, + setTagAlias, + pairBox, + sendCommand, + unpairBox, +} from './api.js'; + +const BOX_POLL_MS = 1500; +const STATUS_POLL_MS = 1000; + +function formatTime(ts) { + if (!ts) return '-'; + return new Date(ts * 1000).toLocaleString(); +} + +function parseCapabilities(raw) { + if (!raw) return '-'; + try { + const data = JSON.parse(raw); + return Object.entries(data) + .map(([key, value]) => `${key}:${value ? '1' : '0'}`) + .join(' '); + } catch (error) { + return '-'; + } +} + +function formatSize(bytes) { + if (bytes === 0) return '0 B'; + if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return '-'; + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let idx = 0; + while (size >= 1024 && idx < units.length - 1) { + size /= 1024; + idx += 1; + } + return `${size.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; +} + +function formatDuration(seconds) { + if (seconds === null || seconds === undefined || Number.isNaN(seconds)) return '-'; + const total = Math.max(0, Math.floor(seconds)); + const mins = Math.floor(total / 60); + const secs = total % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +function generateTagId() { + const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let generated = ''; + for (let i = 0; i < 10; i += 1) { + generated += alphabet[Math.floor(Math.random() * alphabet.length)]; + } + return generated; +} + +export default function App() { + const [boxes, setBoxes] = useState([]); + const [selectedId, setSelectedId] = useState(''); + const [status, setStatus] = useState(null); + const [error, setError] = useState(''); + const [nfcUid, setNfcUid] = useState('UID_1'); + const [mediaTree, setMediaTree] = useState(null); + const [mediaError, setMediaError] = useState(''); + const [currentPath, setCurrentPath] = useState(''); + const [selectedPaths, setSelectedPaths] = useState([]); + const [newFolderName, setNewFolderName] = useState(''); + const [renameName, setRenameName] = useState(''); + const [moveTarget, setMoveTarget] = useState(''); + const [activeModal, setActiveModal] = useState(''); + const [tagDeleteTarget, setTagDeleteTarget] = useState(''); + const [expandedFolders, setExpandedFolders] = useState(() => new Set(['__root__'])); + const [sidebarQuery, setSidebarQuery] = useState(''); + const [uploadAfterCreate, setUploadAfterCreate] = useState(false); + const [pendingUploadFiles, setPendingUploadFiles] = useState([]); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadInProgress, setUploadInProgress] = useState(false); + const [activeUploadLabel, setActiveUploadLabel] = useState(''); + const transferTimerRef = useRef(null); + const explorerRef = useRef(null); + const modalRef = useRef(null); + const uploadDropInputRef = useRef(null); + const dragSelectRef = useRef(false); + const lastDblClickRef = useRef(0); + const lastAnchorRef = useRef(''); + const explorerInitRef = useRef(false); + const expandedInitRef = useRef(false); + const [tags, setTags] = useState([]); + const [boxTags, setBoxTags] = useState([]); + const [blockedByBox, setBlockedByBox] = useState({}); + const [dbTagMedia, setDbTagMedia] = useState({}); + const [localBoxTags, setLocalBoxTags] = useState([]); + const [localBoxError, setLocalBoxError] = useState(''); + const [importTargetFolder, setImportTargetFolder] = useState(''); + const [importTargetUid, setImportTargetUid] = useState(''); + const [scanTagUid, setScanTagUid] = useState(''); + const [scanTagLabel, setScanTagLabel] = useState(''); + const [scanMediaPath, setScanMediaPath] = useState(''); + const [reuseTagUid, setReuseTagUid] = useState(''); + const lastNfcKeyRef = useRef(''); + const toastCounter = useRef(0); + const [toasts, setToasts] = useState([]); + const [tagAliasDrafts, setTagAliasDrafts] = useState({}); + const [boxAliasDrafts, setBoxAliasDrafts] = useState({}); + + function addToast(type, message) { + const id = `${Date.now()}-${toastCounter.current}`; + toastCounter.current += 1; + setToasts((prev) => [...prev, { id, type, message }]); + setTimeout(() => { + setToasts((prev) => prev.filter((toast) => toast.id !== id)); + }, 4000); + } + + useEffect(() => { + let active = true; + + async function refreshBoxes() { + const response = await getBoxes(); + if (!active) return; + if (!response.ok) { + setError(response.data.detail || 'Fehler beim Laden der Boxen.'); + return; + } + setBoxes(response.data.boxes || []); + setError(''); + } + + refreshBoxes(); + const handle = setInterval(refreshBoxes, BOX_POLL_MS); + return () => { + active = false; + clearInterval(handle); + }; + }, []); + + useEffect(() => { + function handleOutside(event) { + if (activeModal) return; + if (!explorerRef.current) return; + if (modalRef.current && modalRef.current.contains(event.target)) return; + if (!explorerRef.current.contains(event.target)) { + setSelectedPaths([]); + setRenameName(''); + } + } + function handleMouseUp() { + dragSelectRef.current = false; + } + window.addEventListener('mousedown', handleOutside); + window.addEventListener('mouseup', handleMouseUp); + return () => window.removeEventListener('mousedown', handleOutside); + }, [activeModal]); + + useEffect(() => { + if (!selectedId) { + setStatus(null); + setMediaTree(null); + setMediaError(''); + setSelectedPaths([]); + setNewFolderName(''); + setRenameName(''); + setActiveModal(''); + setBoxTags([]); + setDbTagMedia({}); + setScanTagUid(''); + setScanTagLabel(''); + setScanMediaPath(''); + setReuseTagUid(''); + setLocalBoxTags([]); + setLocalBoxError(''); + return; + } + + let active = true; + + async function refreshStatus() { + const response = await getStatus(selectedId); + if (!active) return; + if (!response.ok) { + setStatus({ error: response.data.detail || 'Status nicht verfuegbar.' }); + return; + } + setStatus(response.data); + } + + refreshStatus(); + const handle = setInterval(refreshStatus, STATUS_POLL_MS); + return () => { + active = false; + clearInterval(handle); + }; + }, [selectedId]); + + useEffect(() => { + let active = true; + + async function refreshMedia() { + const response = await getMediaTree(); + if (!active) return; + if (!response.ok) { + setMediaError(response.data.detail || 'Medien nicht verfuegbar.'); + setMediaTree(null); + return; + } + setMediaTree(response.data); + setMediaError(''); + } + + refreshMedia(); + return () => { + active = false; + }; + }, []); + + function findPathChain(node, target, chain = []) { + if (!node) return null; + const currentPath = node.path || ''; + const nextChain = [...chain, currentPath]; + if (currentPath === target) { + return nextChain; + } + if (!Array.isArray(node.children)) return null; + for (const child of node.children) { + if (child.type !== 'folder') continue; + const result = findPathChain(child, target, nextChain); + if (result) return result; + } + return null; + } + + useEffect(() => { + if (!mediaTree) return; + const saved = localStorage.getItem('klangkiste_explorer_path'); + if (saved === null) return; + const chain = findPathChain(mediaTree, saved); + if (!chain) return; + setCurrentPath(saved); + if (!expandedInitRef.current) { + const savedExpandedRaw = localStorage.getItem('klangkiste_explorer_expanded'); + let savedExpanded = []; + if (savedExpandedRaw) { + try { + const parsed = JSON.parse(savedExpandedRaw); + if (Array.isArray(parsed)) { + savedExpanded = parsed; + } + } catch (error) { + savedExpanded = []; + } + } + const merged = new Set([ + ...savedExpanded, + ...chain.map((p) => (p ? p : '__root__')), + ]); + setExpandedFolders(merged); + expandedInitRef.current = true; + } + explorerInitRef.current = true; + }, [mediaTree]); + + useEffect(() => { + if (!explorerInitRef.current) return; + localStorage.setItem('klangkiste_explorer_path', currentPath || ''); + }, [currentPath]); + + useEffect(() => { + if (!expandedInitRef.current) return; + localStorage.setItem( + 'klangkiste_explorer_expanded', + JSON.stringify(Array.from(expandedFolders)) + ); + }, [expandedFolders]); + + useEffect(() => { + const lastNfc = status?.last_nfc; + if (!lastNfc || lastNfc.known || !lastNfc.uid) { + return; + } + const key = `${status?.last_nfc_at ?? ''}:${lastNfc.uid ?? ''}`; + if (lastNfcKeyRef.current === key) { + return; + } + lastNfcKeyRef.current = key; + setScanTagLabel(''); + setScanMediaPath(''); + const hasUid = Boolean(lastNfc.uid); + const existsInDb = hasUid && tags.some((tag) => tag.uid === lastNfc.uid); + if (existsInDb) { + setScanTagUid(lastNfc.uid); + } else { + setScanTagUid(generateTagId()); + } + }, [status, tags]); + + useEffect(() => { + let active = true; + + async function refreshTags() { + const response = await getTags(); + if (!active) return; + if (!response.ok) { + setError(response.data.detail || 'Tags nicht verfuegbar.'); + return; + } + setTags(response.data.tags || []); + } + + refreshTags(); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + if (!selectedId) return; + let active = true; + + async function refreshBoxTags() { + const response = await getBoxTags(selectedId); + if (!active) return; + if (!response.ok) { + setError(response.data.detail || 'Box-Tags nicht verfuegbar.'); + return; + } + setBoxTags(response.data.tags || []); + } + + refreshBoxTags(); + return () => { + active = false; + }; + }, [selectedId]); + + useEffect(() => { + if (!selectedId) return; + let active = true; + + async function refreshLocalTags() { + const response = await getBoxLocalTags(selectedId); + if (!active) return; + if (!response.ok) { + setLocalBoxError(response.data.detail || 'Lokale Box-Tags nicht verfuegbar.'); + return; + } + setLocalBoxTags(response.data.tags || []); + setLocalBoxError(''); + } + + refreshLocalTags(); + return () => { + active = false; + }; + }, [selectedId]); + + useEffect(() => { + let active = true; + + async function refreshBlocked() { + if (!boxes.length) { + setBlockedByBox({}); + return; + } + const results = await Promise.all( + boxes.map(async (box) => { + const response = await getTagBlocks(box.box_id); + return { boxId: box.box_id, response }; + }) + ); + if (!active) return; + const next = {}; + results.forEach(({ boxId, response }) => { + if (response.ok) { + next[boxId] = response.data.blocked || []; + } + }); + setBlockedByBox(next); + } + + refreshBlocked(); + return () => { + active = false; + }; + }, [boxes]); + + const unpaired = useMemo( + () => boxes.filter((box) => box.state === 'UNPAIRED'), + [boxes] + ); + const paired = useMemo( + () => boxes.filter((box) => box.state === 'PAIRED'), + [boxes] + ); + + const mediaTagCounts = useMemo(() => { + const counts = {}; + tags.forEach((tag) => { + const mediaPath = tag.media_path || ''; + if (!mediaPath) return; + counts[mediaPath] = (counts[mediaPath] || 0) + 1; + }); + return counts; + }, [tags]); + + const topLevelFolders = useMemo(() => { + if (!mediaTree || !Array.isArray(mediaTree.children)) return []; + const folders = mediaTree.children.filter((child) => child.type === 'folder'); + const query = sidebarQuery.trim().toLowerCase(); + if (!query) return folders; + return folders.filter((folder) => (folder.name || '').toLowerCase().includes(query)); + }, [mediaTree, sidebarQuery]); + + const filteredTree = useMemo(() => { + if (!mediaTree) return null; + return { + ...mediaTree, + children: topLevelFolders, + }; + }, [mediaTree, topLevelFolders]); + + async function handlePair(boxId) { + const response = await pairBox(boxId); + if (!response.ok) { + setError(response.data.detail || 'Pairing fehlgeschlagen.'); + addToast('error', response.data.detail || 'Pairing fehlgeschlagen.'); + return; + } + setError(''); + addToast('success', `Box ${boxId} gepairt.`); + } + + async function handleCommand(command, payload = {}) { + if (!selectedId) { + setError('Bitte zuerst eine gepairte Box auswaehlen.'); + addToast('error', 'Bitte zuerst eine gepairte Box auswaehlen.'); + return; + } + const response = await sendCommand(selectedId, command, payload); + if (!response.ok) { + setError(response.data.detail || 'Command fehlgeschlagen.'); + addToast('error', response.data.detail || 'Command fehlgeschlagen.'); + return; + } + setError(''); + addToast('success', `Command ausgefuehrt: ${command}`); + } + + async function handleUnpair(boxId) { + const response = await unpairBox(boxId); + if (!response.ok) { + setError(response.data.detail || 'Unpair fehlgeschlagen.'); + addToast('error', response.data.detail || 'Unpair fehlgeschlagen.'); + return; + } + if (selectedId === boxId) { + setSelectedId(''); + setStatus(null); + } + setError(''); + addToast('success', `Box ${boxId} entkoppelt.`); + } + + async function handleMediaRefresh() { + const response = await getMediaTree(); + if (!response.ok) { + setMediaError(response.data.detail || 'Medien nicht verfuegbar.'); + addToast('error', response.data.detail || 'Medien nicht verfuegbar.'); + setMediaTree(null); + return; + } + setMediaTree(response.data); + setMediaError(''); + addToast('success', 'Medienliste aktualisiert.'); + } + + function getNodeByPath(node, targetPath) { + if (!node) return null; + if ((node.path || '') === targetPath) return node; + if (!Array.isArray(node.children)) return null; + for (const child of node.children) { + const found = getNodeByPath(child, targetPath); + if (found) return found; + } + return null; + } + + function listChildren(node) { + if (!node || !Array.isArray(node.children)) return []; + const folders = node.children.filter((child) => child.type === 'folder'); + const files = node.children.filter((child) => child.type === 'file'); + folders.sort((a, b) => a.name.localeCompare(b.name)); + files.sort((a, b) => a.name.localeCompare(b.name)); + return [...folders, ...files]; + } + + function buildBreadcrumb(pathValue) { + if (!pathValue) return []; + const parts = pathValue.split('/').filter(Boolean); + const crumbs = []; + let acc = ''; + parts.forEach((part) => { + acc = acc ? `${acc}/${part}` : part; + crumbs.push({ name: part, path: acc }); + }); + return crumbs; + } + + function collectTopLevelFolders(node) { + if (!node || !Array.isArray(node.children)) return []; + return node.children + .filter((child) => child.type === 'folder' && child.path) + .map((child) => child.path); + } + + function collectFolderPaths(node) { + if (!node || node.type !== 'folder') return []; + const paths = []; + if (node.path) paths.push(node.path); + if (!Array.isArray(node.children)) return paths; + node.children.forEach((child) => { + if (child.type === 'folder') { + paths.push(...collectFolderPaths(child)); + } + }); + return paths; + } + + function isSelected(pathValue) { + return selectedPaths.includes(pathValue); + } + + async function handleCreateFolder() { + if (!newFolderName.trim()) { + addToast('error', 'Ordnername fehlt.'); + return; + } + if (uploadAfterCreate && pendingUploadFiles.length === 0) { + addToast('error', 'Bitte Audiodateien auswaehlen.'); + return; + } + const response = await createMediaFolder(currentPath, newFolderName.trim()); + if (!response.ok) { + addToast('error', response.data.detail || 'Ordner anlegen fehlgeschlagen.'); + return; + } + const createdPath = currentPath + ? `${currentPath}/${newFolderName.trim()}` + : newFolderName.trim(); + setNewFolderName(''); + addToast('success', 'Ordner angelegt.'); + await handleMediaRefresh(); + if (uploadAfterCreate) { + setActiveUploadLabel(`Upload: ${pendingUploadFiles.length} Datei(en)`); + setUploadInProgress(true); + setUploadProgress(0); + const uploadResponse = await uploadMedia( + createdPath, + pendingUploadFiles, + (percent) => setUploadProgress(percent) + ); + if (!uploadResponse.ok) { + addToast('error', uploadResponse.data.detail || 'Upload fehlgeschlagen.'); + setUploadInProgress(false); + return; + } + setUploadInProgress(false); + setActiveUploadLabel(''); + setUploadAfterCreate(false); + setPendingUploadFiles([]); + setCurrentPath(createdPath); + addToast('success', 'Upload abgeschlossen.'); + await handleMediaRefresh(); + setActiveModal(''); + } + if (!uploadAfterCreate) { + setActiveModal(''); + } + } + + async function handleRename() { + if (selectedPaths.length !== 1) { + addToast('error', 'Bitte zuerst einen Eintrag waehlen.'); + return; + } + if (!renameName.trim()) { + addToast('error', 'Neuer Name fehlt.'); + return; + } + const response = await renameMedia(selectedPaths[0], renameName.trim()); + if (!response.ok) { + addToast('error', response.data.detail || 'Umbenennen fehlgeschlagen.'); + return; + } + setActiveModal(''); + addToast('success', 'Umbenannt.'); + await handleMediaRefresh(); + } + + async function handleDeleteSelected() { + if (selectedPaths.length === 0) { + addToast('error', 'Bitte zuerst einen Eintrag waehlen.'); + return; + } + for (const pathValue of selectedPaths) { + const response = await deleteMedia(pathValue); + if (!response.ok) { + addToast('error', response.data.detail || 'Loeschen fehlgeschlagen.'); + return; + } + } + setSelectedPaths([]); + setRenameName(''); + setActiveModal(''); + addToast('success', 'Geloescht.'); + await handleMediaRefresh(); + } + + async function handleMoveSelected() { + if (selectedPaths.length === 0) { + addToast('error', 'Bitte zuerst einen Eintrag waehlen.'); + return; + } + if (!moveTarget) { + addToast('error', 'Bitte Zielordner waehlen.'); + return; + } + const isRootTarget = moveTarget === '__root__'; + if (isRootTarget) { + const hasFile = selectedPaths.some((pathValue) => { + const node = getNodeByPath(mediaTree, pathValue); + return node && node.type === 'file'; + }); + if (hasFile) { + addToast('error', 'Dateien duerfen nicht in den Root-Ordner.'); + return; + } + } + for (const pathValue of selectedPaths) { + const response = await moveMedia(pathValue, isRootTarget ? '' : moveTarget); + if (!response.ok) { + addToast('error', response.data.detail || 'Verschieben fehlgeschlagen.'); + return; + } + } + setActiveModal(''); + addToast('success', 'Verschoben.'); + await handleMediaRefresh(); + } + + async function handleUpload(event) { + const files = Array.from(event.target.files || []); + if (!currentPath) { + addToast('error', 'Bitte zuerst einen Ordner anlegen.'); + event.target.value = ''; + return; + } + if (!files.length) return; + const audioFiles = files.filter((file) => file.type.startsWith('audio/')); + if (!audioFiles.length) { + addToast('error', 'Nur Audiodateien erlaubt.'); + return; + } + setActiveUploadLabel(`Upload: ${audioFiles.length} Datei(en)`); + setUploadInProgress(true); + setUploadProgress(0); + const response = await uploadMedia(currentPath, audioFiles, (percent) => + setUploadProgress(percent) + ); + if (!response.ok) { + addToast('error', response.data.detail || 'Upload fehlgeschlagen.'); + setUploadInProgress(false); + setActiveUploadLabel(''); + return; + } + setUploadInProgress(false); + setActiveUploadLabel(''); + addToast('success', 'Upload abgeschlossen.'); + await handleMediaRefresh(); + } + + function handleSelect(item, event) { + const itemPath = item.path || ''; + if (Date.now() - lastDblClickRef.current < 250) { + return; + } + if (event && event.shiftKey && lastAnchorRef.current) { + const node = getNodeByPath(mediaTree, currentPath); + const visible = listChildren(node).map((child) => child.path || ''); + const start = visible.indexOf(lastAnchorRef.current); + const end = visible.indexOf(itemPath); + if (start !== -1 && end !== -1) { + const [from, to] = start < end ? [start, end] : [end, start]; + setSelectedPaths(visible.slice(from, to + 1)); + lastAnchorRef.current = itemPath; + return; + } + } + if (event && (event.metaKey || event.ctrlKey)) { + setSelectedPaths((prev) => + prev.includes(itemPath) + ? prev.filter((p) => p !== itemPath) + : [...prev, itemPath] + ); + lastAnchorRef.current = itemPath; + return; + } + setSelectedPaths([itemPath]); + setRenameName(item.name || ''); + lastAnchorRef.current = itemPath; + } + + function handleDragSelect(item) { + if (!dragSelectRef.current) return; + const itemPath = item.path || ''; + setSelectedPaths((prev) => + prev.includes(itemPath) ? prev : [...prev, itemPath] + ); + } + + function handleOpen(item) { + if (item.type !== 'folder') return; + setCurrentPath(item.path || ''); + setSelectedPaths([]); + setRenameName(''); + const key = item.path || '__root__'; + setExpandedFolders((prev) => new Set(prev).add(key)); + } + + function handleGoUp() { + if (!currentPath) return; + const parts = currentPath.split('/').filter(Boolean); + parts.pop(); + const next = parts.join('/'); + setCurrentPath(next); + setSelectedPaths([]); + setRenameName(''); + } + + function toggleFolder(pathValue) { + const key = pathValue || '__root__'; + setExpandedFolders((prev) => { + const next = new Set(prev); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + } + + function renderFolderTree(node, depth = 0) { + if (!node || node.type !== 'folder') return null; + const isActive = (node.path || '') === currentPath; + const key = node.path || '__root__'; + const isExpanded = expandedFolders.has(key); + const childFolders = + Array.isArray(node.children) && node.children.filter((child) => child.type === 'folder'); + const hasChildren = childFolders && childFolders.length > 0; + const tagCount = mediaTagCounts[node.path || ''] || 0; + return ( +
+
handleOpen(node)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter') handleOpen(node); + }} + > + {hasChildren ? ( + + ) : ( +
+ {isExpanded && + hasChildren && + childFolders.map((child) => renderFolderTree(child, depth + 1))} +
+ ); + } + + async function handleClaimTagForScan() { + const currentUid = scanTagUid.trim(); + const existing = tags.find((tag) => tag.uid === currentUid); + if (existing) { + if (existing.status === 'NEW') { + const written = await markTagWritten(currentUid); + if (!written.ok) { + setError(written.data.detail || 'Tag schreiben fehlgeschlagen.'); + addToast('error', written.data.detail || 'Tag schreiben fehlgeschlagen.'); + return; + } + } + } else { + const response = await claimTag(currentUid, scanTagLabel.trim()); + if (!response.ok) { + setError(response.data.detail || 'Tag schreiben fehlgeschlagen.'); + addToast('error', response.data.detail || 'Tag schreiben fehlgeschlagen.'); + return; + } + setScanTagUid(response.data.uid || ''); + } + setError(''); + addToast('success', 'Tag geschrieben.'); + const shouldAssign = + selectedId && status?.last_nfc?.known === false && scanMediaPath; + if (shouldAssign) { + const mediaSet = await setTagMedia(currentUid, scanMediaPath); + if (!mediaSet.ok) { + setError(mediaSet.data.detail || 'Medium setzen fehlgeschlagen.'); + addToast('error', mediaSet.data.detail || 'Medium setzen fehlgeschlagen.'); + return; + } + const assigned = await assignTag(currentUid, selectedId); + if (!assigned.ok) { + setError(assigned.data.detail || 'Zuordnung fehlgeschlagen.'); + addToast('error', assigned.data.detail || 'Zuordnung fehlgeschlagen.'); + return; + } + addToast('success', 'Tag zugeordnet.'); + setScanTagUid(''); + setScanTagLabel(''); + setScanMediaPath(''); + } else if (selectedId && status?.last_nfc?.known === false) { + addToast('success', 'Tag gespeichert. Medium fehlt noch.'); + } + const updated = await getTags(); + if (updated.ok) { + setTags(updated.data.tags || []); + } + if (selectedId) { + const updatedBoxTags = await getBoxTags(selectedId); + if (updatedBoxTags.ok) { + setBoxTags(updatedBoxTags.data.tags || []); + } + } + } + + async function handleReuseImportedTag() { + if (!reuseTagUid) { + setError('Bitte eine gespeicherte Tag-ID waehlen.'); + addToast('error', 'Bitte eine gespeicherte Tag-ID waehlen.'); + return; + } + if (!selectedId) { + setError('Bitte zuerst eine Box auswaehlen.'); + addToast('error', 'Bitte zuerst eine Box auswaehlen.'); + return; + } + const tag = tags.find((entry) => entry.uid === reuseTagUid); + if (!tag || !tag.media_path) { + setError('Tag hat keine Medienzuordnung.'); + addToast('error', 'Tag hat keine Medienzuordnung.'); + return; + } + const response = await markTagWritten(reuseTagUid); + if (!response.ok) { + setError(response.data.detail || 'Tag schreiben fehlgeschlagen.'); + addToast('error', response.data.detail || 'Tag schreiben fehlgeschlagen.'); + return; + } + const assigned = await assignTag(reuseTagUid, selectedId); + if (!assigned.ok) { + setError(assigned.data.detail || 'Zuordnung fehlgeschlagen.'); + addToast('error', assigned.data.detail || 'Zuordnung fehlgeschlagen.'); + return; + } + setError(''); + addToast('success', 'Tag geschrieben und zugeordnet.'); + const updated = await getTags(); + if (updated.ok) { + setTags(updated.data.tags || []); + } + const updatedBoxTags = await getBoxTags(selectedId); + if (updatedBoxTags.ok) { + setBoxTags(updatedBoxTags.data.tags || []); + } + setScanTagUid(reuseTagUid); + setReuseTagUid(''); + } + + async function handleWriteTag(uid) { + const response = await markTagWritten(uid); + if (!response.ok) { + setError(response.data.detail || 'Tag schreiben fehlgeschlagen.'); + addToast('error', response.data.detail || 'Tag schreiben fehlgeschlagen.'); + return; + } + setError(''); + addToast('success', 'Tag geschrieben.'); + const updated = await getTags(); + if (updated.ok) { + setTags(updated.data.tags || []); + } + } + + async function handleAssignFromScan() { + if (!selectedId) { + setError('Bitte zuerst eine Box auswaehlen.'); + addToast('error', 'Bitte zuerst eine Box auswaehlen.'); + return; + } + if (!scanMediaPath) { + setError('Bitte Medienordner waehlen.'); + addToast('error', 'Bitte Medienordner waehlen.'); + return; + } + const uid = scanTagUid.trim(); + if (!uid) { + setError('Bitte zuerst eine Tag-ID schreiben.'); + addToast('error', 'Bitte zuerst eine Tag-ID schreiben.'); + return; + } + const existing = tags.find((tag) => tag.uid === uid); + if (!existing) { + setError('Tag-ID existiert nicht. Bitte zuerst schreiben.'); + addToast('error', 'Tag-ID existiert nicht. Bitte zuerst schreiben.'); + return; + } + + const mediaSet = await setTagMedia(uid, scanMediaPath); + if (!mediaSet.ok) { + setError(mediaSet.data.detail || 'Medium setzen fehlgeschlagen.'); + addToast('error', mediaSet.data.detail || 'Medium setzen fehlgeschlagen.'); + return; + } + const assigned = await assignTag(uid, selectedId); + if (!assigned.ok) { + setError(assigned.data.detail || 'Zuordnung fehlgeschlagen.'); + addToast('error', assigned.data.detail || 'Zuordnung fehlgeschlagen.'); + return; + } + + setError(''); + addToast('success', 'Tag zugeordnet.'); + setScanTagUid(''); + setScanTagLabel(''); + setScanMediaPath(''); + const updatedTags = await getTags(); + if (updatedTags.ok) { + setTags(updatedTags.data.tags || []); + } + const updatedBoxTags = await getBoxTags(selectedId); + if (updatedBoxTags.ok) { + setBoxTags(updatedBoxTags.data.tags || []); + } + } + + async function handlePullTagFromBox() { + if (!selectedId) { + setError('Bitte zuerst eine Box auswaehlen.'); + addToast('error', 'Bitte zuerst eine Box auswaehlen.'); + return; + } + if (!importTargetUid) { + setError('Kein Tag ausgewaehlt.'); + addToast('error', 'Kein Tag ausgewaehlt.'); + return; + } + if (!importTargetFolder.trim()) { + setError('Bitte Ordnernamen eingeben.'); + addToast('error', 'Bitte Ordnernamen eingeben.'); + return; + } + if (uploadInProgress) { + addToast('error', 'Es laeuft bereits ein Upload/Transfer.'); + return; + } + const startTransferProgress = () => { + setActiveUploadLabel(`Uebertragung: ${importTargetFolder.trim()}`); + setUploadInProgress(true); + setUploadProgress(5); + if (transferTimerRef.current) { + clearInterval(transferTimerRef.current); + } + transferTimerRef.current = setInterval(() => { + setUploadProgress((prev) => (prev < 90 ? prev + 5 : prev)); + }, 400); + }; + const stopTransferProgress = (success) => { + if (transferTimerRef.current) { + clearInterval(transferTimerRef.current); + transferTimerRef.current = null; + } + if (success) { + setUploadProgress(100); + setTimeout(() => { + setUploadInProgress(false); + setUploadProgress(0); + setActiveUploadLabel(''); + }, 600); + return; + } + setUploadInProgress(false); + setUploadProgress(0); + setActiveUploadLabel(''); + }; + startTransferProgress(); + const response = await pullTagFromBox( + selectedId, + importTargetUid, + importTargetFolder.trim() + ); + if (!response.ok) { + setError(response.data.detail || 'Import fehlgeschlagen.'); + addToast('error', response.data.detail || 'Import fehlgeschlagen.'); + stopTransferProgress(false); + return; + } + addToast('success', 'Medien vom Box-Tag uebertragen.'); + setImportTargetFolder(''); + setImportTargetUid(''); + setActiveModal(''); + stopTransferProgress(true); + const updatedTags = await getTags(); + if (updatedTags.ok) { + setTags(updatedTags.data.tags || []); + } + const updatedLocal = await getBoxLocalTags(selectedId); + if (updatedLocal.ok) { + setLocalBoxTags(updatedLocal.data.tags || []); + } + await handleMediaRefresh(); + } + + async function handleUnassignTag(uid) { + if (!selectedId) return; + const response = await unassignTag(uid, selectedId); + if (!response.ok) { + setError(response.data.detail || 'Zuordnung loeschen fehlgeschlagen.'); + addToast('error', response.data.detail || 'Zuordnung loeschen fehlgeschlagen.'); + return; + } + setError(''); + addToast('success', 'Zuordnung entfernt.'); + const updated = await getBoxTags(selectedId); + if (updated.ok) { + setBoxTags(updated.data.tags || []); + } + } + + async function handleDeleteTag(uid) { + const response = await deleteTag(uid); + if (!response.ok) { + setError(response.data.detail || 'Tag loeschen fehlgeschlagen.'); + addToast('error', response.data.detail || 'Tag loeschen fehlgeschlagen.'); + return; + } + setError(''); + addToast('success', 'Tag geloescht.'); + const updatedTags = await getTags(); + if (updatedTags.ok) { + setTags(updatedTags.data.tags || []); + } + if (selectedId) { + const updatedBoxTags = await getBoxTags(selectedId); + if (updatedBoxTags.ok) { + setBoxTags(updatedBoxTags.data.tags || []); + } + } + if (boxes.length) { + const results = await Promise.all( + boxes.map(async (box) => ({ + boxId: box.box_id, + response: await getTagBlocks(box.box_id), + })) + ); + const next = {}; + results.forEach(({ boxId, response }) => { + if (response.ok) { + next[boxId] = response.data.blocked || []; + } + }); + setBlockedByBox(next); + } + } + + async function handleSetTagMedia(uid) { + const mediaPath = dbTagMedia[uid] || ''; + const response = await setTagMedia(uid, mediaPath); + if (!response.ok) { + setError(response.data.detail || 'Medium setzen fehlgeschlagen.'); + addToast('error', response.data.detail || 'Medium setzen fehlgeschlagen.'); + return; + } + let assigned = null; + const lastNfcUid = status?.last_nfc?.uid || ''; + const shouldAssign = + selectedId && + status?.last_nfc?.known === false && + ((lastNfcUid && lastNfcUid === uid) || + (!lastNfcUid && scanTagUid && scanTagUid === uid)); + if (shouldAssign) { + assigned = await assignTag(uid, selectedId); + if (!assigned.ok) { + setError(assigned.data.detail || 'Zuordnung fehlgeschlagen.'); + addToast('error', assigned.data.detail || 'Zuordnung fehlgeschlagen.'); + return; + } + } + setError(''); + if (!mediaPath) { + addToast('success', 'Medium entfernt.'); + } else if (assigned) { + addToast('success', 'Medium gespeichert und Tag zugeordnet.'); + } else { + addToast('success', 'Medium gespeichert.'); + } + const updatedTags = await getTags(); + if (updatedTags.ok) { + setTags(updatedTags.data.tags || []); + } + if (selectedId) { + const updatedBoxTags = await getBoxTags(selectedId); + if (updatedBoxTags.ok) { + setBoxTags(updatedBoxTags.data.tags || []); + } + } + if (boxes.length) { + const results = await Promise.all( + boxes.map(async (box) => ({ + boxId: box.box_id, + response: await getTagBlocks(box.box_id), + })) + ); + const next = {}; + results.forEach(({ boxId, response }) => { + if (response.ok) { + next[boxId] = response.data.blocked || []; + } + }); + setBlockedByBox(next); + } + } + + async function handleClearTagMedia(uid) { + const response = await setTagMedia(uid, ''); + if (!response.ok) { + setError(response.data.detail || 'Medium entfernen fehlgeschlagen.'); + addToast('error', response.data.detail || 'Medium entfernen fehlgeschlagen.'); + return; + } + setError(''); + addToast('success', 'Medium entfernt.'); + setDbTagMedia((prev) => ({ ...prev, [uid]: '' })); + const updatedTags = await getTags(); + if (updatedTags.ok) { + setTags(updatedTags.data.tags || []); + } + if (selectedId) { + const updatedBoxTags = await getBoxTags(selectedId); + if (updatedBoxTags.ok) { + setBoxTags(updatedBoxTags.data.tags || []); + } + } + if (boxes.length) { + const results = await Promise.all( + boxes.map(async (box) => ({ + boxId: box.box_id, + response: await getTagBlocks(box.box_id), + })) + ); + const next = {}; + results.forEach(({ boxId, response }) => { + if (response.ok) { + next[boxId] = response.data.blocked || []; + } + }); + setBlockedByBox(next); + } + } + + async function handleToggleTagBlock(boxId, uid, nextBlocked) { + const response = await setTagBlock(boxId, uid, nextBlocked); + if (!response.ok) { + addToast('error', response.data.detail || 'Tag-Sperre fehlgeschlagen.'); + return; + } + setBlockedByBox((prev) => { + const current = new Set(prev[boxId] || []); + if (nextBlocked) { + current.add(uid); + } else { + current.delete(uid); + } + return { ...prev, [boxId]: Array.from(current) }; + }); + addToast( + 'success', + nextBlocked ? 'Tag gesperrt.' : 'Tag freigegeben.' + ); + } + + async function handleSaveTagAlias(uid) { + const value = (tagAliasDrafts[uid] ?? '').trim(); + const response = await setTagAlias(uid, value || null); + if (!response.ok) { + addToast('error', response.data.detail || 'Tag-Alias speichern fehlgeschlagen.'); + return; + } + const updated = await getTags(); + if (updated.ok) { + setTags(updated.data.tags || []); + } + addToast('success', 'Tag-Alias gespeichert.'); + } + + async function handleSaveBoxAlias(boxId) { + const value = (boxAliasDrafts[boxId] ?? '').trim(); + const response = await setBoxAlias(boxId, value || null); + if (!response.ok) { + addToast('error', response.data.detail || 'Box-Alias speichern fehlgeschlagen.'); + return; + } + const updated = await getBoxes(); + if (updated.ok) { + setBoxes(updated.data.boxes || []); + } + addToast('success', 'Box-Alias gespeichert.'); + } + + const currentItems = mediaTree + ? listChildren(getNodeByPath(mediaTree, currentPath)) + : []; + const showMeta = currentItems.some((item) => item.type === 'file'); + const mediaBytes = mediaTree?.size ?? null; + const freeBytes = mediaTree?.free_bytes ?? null; + + return ( +
+
+

Klangkiste Control Panel

+

Backend: {import.meta.env.VITE_BACKEND_URL || 'http://127.0.0.1:5001'}

+
+ +
+ {toasts.map((toast) => ( +
+ {toast.message} +
+ ))} +
+ + {error &&
{error}
} + +
+
+

Neue Boxen

+ {unpaired.length === 0 &&

Keine neuen Boxen.

} + {unpaired.map((box) => ( +
+
+ {box.alias || box.box_id} +
Zuletzt gesehen: {formatTime(box.last_seen)}
+
Firmware: {box.firmware_version}
+
ID: {box.box_id}
+
+ +
+ ))} +
+ +
+

Gepairte Boxen

+ {paired.length === 0 &&

Noch keine gepairten Boxen.

} + {paired.map((box) => ( +
setSelectedId(box.box_id)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter') setSelectedId(box.box_id); + }} + > +
+ {box.alias || box.box_id} +
Zuletzt gesehen: {formatTime(box.last_seen)}
+
Capabilities: {parseCapabilities(box.capabilities_json)}
+
ID: {box.box_id}
+
+
+ event.stopPropagation()} + onChange={(event) => { + event.stopPropagation(); + setBoxAliasDrafts((prev) => ({ + ...prev, + [box.box_id]: event.target.value, + })); + }} + /> + + {box.state} + +
+
+ ))} +
+
+ +
+
+

Live-Status

+ {!selectedId &&

Waehle eine gepairte Box aus.

} + {selectedId && !status &&

Status wird geladen...

} + {status && status.error &&

{status.error}

} + {status && !status.error && ( +
{JSON.stringify(status, null, 2)}
+ )} +
+ +
+

Commands

+
+ + + + + + +
+
+ setNfcUid(event.target.value)} + placeholder="UID_1" + /> + + +
+

+ Steuerung ist nur moeglich, wenn die Box gepairt ist. +

+
+
+ + {activeModal && ( +
{ + setActiveModal(''); + setUploadAfterCreate(false); + setPendingUploadFiles([]); + setTagDeleteTarget(''); + }} + > +
event.stopPropagation()} + ref={modalRef} + > + {activeModal === 'new-folder' && ( + <> +

Neuen Ordner anlegen

+ setNewFolderName(event.target.value)} + placeholder="Ordnername" + /> + {uploadAfterCreate && ( + <> +
{ + if (uploadInProgress) return; + uploadDropInputRef.current?.click(); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') uploadDropInputRef.current?.click(); + }} + onDragOver={(event) => { + event.preventDefault(); + }} + onDrop={(event) => { + event.preventDefault(); + if (uploadInProgress) return; + const files = Array.from(event.dataTransfer.files || []); + const audioFiles = files.filter((file) => + file.type.startsWith('audio/') + ); + setPendingUploadFiles(audioFiles); + setActiveUploadLabel( + audioFiles.length + ? `Upload: ${audioFiles.length} Datei(en)` + : '' + ); + }} + > +
Audiodateien auswaehlen
+
+ Datei hier ablegen oder klicken, um auszuwaehlen +
+ {pendingUploadFiles.length > 0 && ( +
+ {pendingUploadFiles.map((file) => ( +
{file.name}
+ ))} +
+ )} + { + const files = Array.from(event.target.files || []); + const audioFiles = files.filter((file) => + file.type.startsWith('audio/') + ); + setPendingUploadFiles(audioFiles); + setActiveUploadLabel( + audioFiles.length + ? `Upload: ${audioFiles.length} Datei(en)` + : '' + ); + }} + /> +
+

Upload startet direkt nach dem Anlegen.

+ {uploadInProgress && ( +
+
+
+ )} + + )} +
+ + +
+ + )} + {activeModal === 'rename' && ( + <> +

Umbenennen

+ setRenameName(event.target.value)} + placeholder="Neuer Name" + /> +
+ + +
+ + )} + {activeModal === 'delete' && ( + <> +

Loeschen

+

+ {selectedPaths.length} Eintrag(e) wirklich loeschen? +

+
+ + +
+ + )} + {activeModal === 'move' && ( + <> +

Verschieben

+ {selectedPaths.length > 0 && + selectedPaths.every((pathValue) => { + const node = getNodeByPath(mediaTree, pathValue); + return node && node.type === 'file'; + }) && ( +

+ Dateien duerfen nicht in den Root-Ordner verschoben werden. +

+ )} + +
+ + +
+ + )} + {activeModal === 'tag-delete' && ( + <> +

Tag entfernen

+

+ Was moechtest du entfernen? +

+
+ + + +
+ + )} +
+
+ )} + + {activeModal === 'import-tag' && ( +
+
+
+

Tag vom Box-Speicher uebertragen

+
+
+ + setImportTargetFolder(event.target.value)} + placeholder="z. B. grimm_volume_3" + /> + {uploadInProgress && ( +
+
+
+ )} +
+
+ + +
+
+
+ )} + + {status?.last_nfc && status.last_nfc.known === false && status.last_nfc.uid && ( +
+

+ {tags.find((tag) => tag.uid === status.last_nfc.uid && !tag.media_path) + ? 'Leerer Tag erkannt' + : 'Neuer Tag erkannt'} +

+

+ UID erkannt: {status.last_nfc.uid || '-'} +

+ {tags.find((tag) => tag.uid === status.last_nfc.uid) && ( +

+ Dieser Tag ist bekannt, aber noch nicht zugewiesen. +

+ )} + {!tags.find((tag) => tag.uid === status.last_nfc.uid && !tag.media_path) && ( +
+ + setScanTagLabel(event.target.value)} + placeholder="Label (optional)" + /> + +
+ )} + {tags.some((tag) => tag.status === 'IMPORTED') && ( +
+ + +
+ )} +
+ + +
+
+ )} + +
+
+

Medien Explorer

+ +
+ {uploadInProgress && ( +
+ {activeUploadLabel || 'Upload laeuft...'} +
+
+
+
+ )} +

Ordnerverwaltung, Upload und Datei-Listen im Server-Medienordner.

+ {mediaError &&

{mediaError}

} + {!mediaError && !mediaTree &&

Medienliste wird geladen...

} + {!mediaError && mediaTree && ( +
+
+
+ setSidebarQuery(event.target.value)} + aria-label="Ordner suchen" + /> +
+
+ {topLevelFolders.length === 0 && ( +
Keine Ordner gefunden.
+ )} + {filteredTree && renderFolderTree(filteredTree, 0)} +
+
+
+
+ +
+ setCurrentPath('')} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter') setCurrentPath(''); + }} + > + media + + {buildBreadcrumb(currentPath).map((crumb) => ( + setCurrentPath(crumb.path)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter') setCurrentPath(crumb.path); + }} + > + / {crumb.name} + + ))} +
+
+ + + + + +
+
+
+
+ Name + {showMeta ? ( + <> + Interpret + Titel + Länge + Größe + + ) : ( + <> + Typ + Größe + + )} +
+ {currentItems.map((item) => ( +
{ + if (event.button !== 0) return; + if (event.metaKey || event.ctrlKey || event.shiftKey) { + return; + } + dragSelectRef.current = true; + handleSelect(item, event); + }} + onMouseEnter={() => handleDragSelect(item)} + onClick={(event) => handleSelect(item, event)} + onDoubleClick={(event) => { + event.preventDefault(); + event.stopPropagation(); + lastDblClickRef.current = Date.now(); + handleOpen(item); + }} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter') handleOpen(item); + }} + > + + + {item.type === 'folder' ? ( + + ) : ( + + )} + + {item.name} + + {showMeta ? ( + <> + {item.type === 'folder' ? '-' : item.artist || '-'} + {item.type === 'folder' ? '-' : item.title || '-'} + + {item.type === 'folder' ? '-' : formatDuration(item.duration)} + + {formatSize(item.size)} + + ) : ( + <> + {item.type === 'folder' ? 'Ordner' : 'Datei'} + {formatSize(item.size)} + + )} +
+ ))} + {selectedPaths.length > 0 && ( +
+ + {showMeta ? ( + <> + + + + + {selectedPaths.length} ausgewaehlt + + + ) : ( + <> + + + {selectedPaths.length} ausgewaehlt + + + )} +
+ )} +
+
+
+ + Medienordner: {formatSize(mediaBytes)} · Verfügbar:{' '} + {formatSize(freeBytes)} + +
+
+ )} +
+ +
+
+

Tags nur auf dieser Box

+
+ {localBoxError &&

{localBoxError}

} + {!localBoxError && localBoxTags.length === 0 && ( +

Keine lokalen Tags ohne Server-Zuordnung.

+ )} + {localBoxTags.map((tag) => ( +
+
+ {tag.uid} +
+ Dateien: {tag.file_count} · {formatSize(tag.total_size)} +
+ {tag.media_exists && tag.files?.length > 0 && ( +
    + {tag.files.map((file) => ( +
  • {file}
  • + ))} +
+ )} + {!tag.media_exists && ( +

Keine Dateien im Box-Ordner gefunden.

+ )} +
+
+ +
+
+ ))} +
+ +
+
+

Tags (Datenbank)

+ {tags.length === 0 &&

Keine Tags vorhanden.

} + {tags.map((tag) => ( +
+
+
+ + {tag.alias ? `${tag.alias} (${tag.uid})` : tag.uid} + +
Status: {tag.status}
+
Medium: {tag.media_path || '-'}
+ {tag.label ?
Label: {tag.label}
: null} +
+
+ + setTagAliasDrafts((prev) => ({ + ...prev, + [tag.uid]: event.target.value, + })) + } + /> + + + {dbTagMedia[tag.uid] !== undefined && + dbTagMedia[tag.uid] !== (tag.media_path ?? '') ? ( + + ) : null} + +
+
+
+ ))} +
+
+ +
+

Tag-Matrix (Sperren)

+ {paired.length === 0 && ( +

Keine gepairten Boxen vorhanden.

+ )} + {tags.length === 0 &&

Keine Tags vorhanden.

} + {paired.length > 0 && tags.length > 0 && ( +
+
+ Tag + {paired.map((box) => ( + + {box.alias || box.box_id} + + ))} +
+ {tags.map((tag) => ( +
+ {tag.alias || tag.uid} + {paired.map((box) => { + const blocked = (blockedByBox[box.box_id] || []).includes(tag.uid); + return ( + + ); + })} +
+ ))} +
+ )} +
+
+ ); +} diff --git a/gui/frontend/src/api.js b/gui/frontend/src/api.js new file mode 100644 index 0000000..283e123 --- /dev/null +++ b/gui/frontend/src/api.js @@ -0,0 +1,239 @@ +const defaultHost = + typeof window !== 'undefined' && window.location ? window.location.hostname : '127.0.0.1'; +const API_BASE = import.meta.env.VITE_BACKEND_URL || `http://${defaultHost}:5001`; + +async function readJson(response) { + const text = await response.text(); + try { + return { ok: response.ok, status: response.status, data: JSON.parse(text) }; + } catch (error) { + return { ok: response.ok, status: response.status, data: { detail: text } }; + } +} + +export async function getBoxes() { + const response = await fetch(`${API_BASE}/api/boxes`); + return readJson(response); +} + +export async function pairBox(boxId) { + const response = await fetch(`${API_BASE}/api/boxes/pair`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ box_id: boxId }), + }); + return readJson(response); +} + +export async function getStatus(boxId) { + const response = await fetch(`${API_BASE}/api/boxes/${boxId}/status`); + return readJson(response); +} + +export async function getMediaTree() { + const response = await fetch(`${API_BASE}/api/media-tree`); + return readJson(response); +} + +export async function createMediaFolder(parentPath, name) { + const response = await fetch(`${API_BASE}/api/media/folder`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ parent_path: parentPath, name }), + }); + return readJson(response); +} + +export async function renameMedia(path, name) { + const response = await fetch(`${API_BASE}/api/media/rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, name }), + }); + return readJson(response); +} + +export async function moveMedia(path, targetParent) { + const response = await fetch(`${API_BASE}/api/media/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path, target_parent: targetParent }), + }); + return readJson(response); +} + +export async function deleteMedia(path) { + const response = await fetch(`${API_BASE}/api/media/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + return readJson(response); +} + +export function uploadMedia(targetPath, files, onProgress) { + const formData = new FormData(); + formData.append('target_path', targetPath); + files.forEach((file) => { + formData.append('files', file); + }); + + return new Promise((resolve) => { + const request = new XMLHttpRequest(); + request.open('POST', `${API_BASE}/api/media/upload`); + request.upload.onprogress = (event) => { + if (!event.lengthComputable || !onProgress) return; + const percent = Math.round((event.loaded / event.total) * 100); + onProgress(percent); + }; + request.onload = () => { + try { + const data = JSON.parse(request.responseText || '{}'); + resolve({ ok: request.status >= 200 && request.status < 300, status: request.status, data }); + } catch (error) { + resolve({ + ok: request.status >= 200 && request.status < 300, + status: request.status, + data: { detail: request.responseText || '' }, + }); + } + }; + request.onerror = () => { + resolve({ ok: false, status: 0, data: { detail: 'network error' } }); + }; + request.send(formData); + }); +} + +export async function getTags() { + const response = await fetch(`${API_BASE}/api/tags`); + return readJson(response); +} + +export async function generateTag(label) { + const response = await fetch(`${API_BASE}/api/tags/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label }), + }); + return readJson(response); +} + +export async function claimTag(uid, label) { + const response = await fetch(`${API_BASE}/api/tags/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uid, label }), + }); + return readJson(response); +} + +export async function markTagWritten(uid) { + const response = await fetch(`${API_BASE}/api/tags/${uid}/write`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + return readJson(response); +} + +export async function getBoxTags(boxId) { + const response = await fetch(`${API_BASE}/api/boxes/${boxId}/tags`); + return readJson(response); +} + +export async function getBoxLocalTags(boxId) { + const response = await fetch(`${API_BASE}/api/boxes/${boxId}/local-tags`); + return readJson(response); +} + +export async function getTagBlocks(boxId) { + const response = await fetch(`${API_BASE}/api/boxes/${boxId}/tag-blocks`); + return readJson(response); +} + +export async function setTagBlock(boxId, uid, blocked) { + const response = await fetch(`${API_BASE}/api/boxes/${boxId}/tag-blocks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uid, blocked }), + }); + return readJson(response); +} + +export async function setBoxAlias(boxId, alias) { + const response = await fetch(`${API_BASE}/api/boxes/${boxId}/alias`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ alias }), + }); + return readJson(response); +} + +export async function setTagAlias(uid, alias) { + const response = await fetch(`${API_BASE}/api/tags/${uid}/alias`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ alias }), + }); + return readJson(response); +} + +export async function assignTag(uid, boxId) { + const response = await fetch(`${API_BASE}/api/tags/${uid}/assign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ box_id: boxId }), + }); + return readJson(response); +} + +export async function unassignTag(uid, boxId) { + const response = await fetch(`${API_BASE}/api/tags/${uid}/unassign`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ box_id: boxId }), + }); + return readJson(response); +} + +export async function deleteTag(uid) { + const response = await fetch(`${API_BASE}/api/tags/${uid}`, { + method: 'DELETE', + }); + return readJson(response); +} + +export async function setTagMedia(uid, mediaPath) { + const response = await fetch(`${API_BASE}/api/tags/${uid}/media`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ media_path: mediaPath }), + }); + return readJson(response); +} + +export async function pullTagFromBox(boxId, uid, targetFolder) { + const response = await fetch(`${API_BASE}/api/boxes/${boxId}/pull-tag`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uid, target_folder: targetFolder }), + }); + return readJson(response); +} + +export async function sendCommand(boxId, command, payload = {}) { + const response = await fetch(`${API_BASE}/api/boxes/${boxId}/command`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command, payload }), + }); + return readJson(response); +} + +export async function unpairBox(boxId) { + const response = await fetch(`${API_BASE}/api/boxes/${boxId}/unpair`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + return readJson(response); +} diff --git a/gui/frontend/src/main.jsx b/gui/frontend/src/main.jsx new file mode 100644 index 0000000..d78ecd4 --- /dev/null +++ b/gui/frontend/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.jsx'; +import './styles.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/gui/frontend/src/styles.css b/gui/frontend/src/styles.css new file mode 100644 index 0000000..0c0271d --- /dev/null +++ b/gui/frontend/src/styles.css @@ -0,0 +1,826 @@ +:root { + color-scheme: light; + font-family: "IBM Plex Sans", "Segoe UI", sans-serif; + color: #131522; + background: #f4f2ef; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; +} + +.toast-container { + position: fixed; + top: 16px; + right: 16px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 9999; +} + +.toast { + display: flex; + align-items: center; + padding: 10px 14px; + border-radius: 10px; + font-size: 13px; + line-height: 1.4; + min-height: 38px; + box-shadow: 0 8px 20px rgba(13, 16, 31, 0.12); + background: #ffffff; + border: 1px solid #e4e0dd; +} + +.toast.success { + border-color: #b9d9b9; + background: #eef7ee; + color: #205b20; +} + +.toast.error { + border-color: #f2c9c9; + background: #ffefef; + color: #9a2a2a; +} + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 32px 24px 48px; +} + +header { + margin-bottom: 24px; +} + +header h1 { + margin: 0 0 6px; + font-size: 28px; +} + +header p { + margin: 0; + color: #525466; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + margin-bottom: 0; +} + +.panel { + background: #ffffff; + border-radius: 16px; + padding: 16px; + box-shadow: 0 8px 24px rgba(13, 16, 31, 0.08); +} + +.page > section { + margin-bottom: 24px; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.subheading { + margin: 16px 0 8px; + font-size: 14px; + color: #3b3f46; +} + +.card { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + border: 1px solid #e4e0dd; + border-radius: 12px; + padding: 12px; + margin-top: 12px; + cursor: pointer; +} + +.card.selected { + border-color: #d28735; + box-shadow: 0 0 0 2px rgba(210, 135, 53, 0.2); +} + +.stack { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-end; +} + +.stack-inline { + flex-direction: row; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; +} + +.tag-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; +} + +.box-tag-row { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 12px; + border: 1px solid #e4e0dd; + border-radius: 12px; + margin-top: 12px; +} + +.box-tag-actions { + display: flex; + align-items: flex-start; +} + +.file-list { + margin: 8px 0 0; + padding-left: 18px; + color: #3b3f46; + font-size: 13px; +} + +.file-list li { + margin-bottom: 4px; +} + +.tag-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.alias-input { + border: 1px solid #dde0e6; + border-radius: 8px; + padding: 6px 8px; + font-size: 12px; + min-width: 140px; +} + +.alias-input:focus { + outline: none; + border-color: #cfd4dc; + box-shadow: 0 0 0 2px rgba(47, 52, 66, 0.08); +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #3b3f46; +} + +.toggle input { + width: 16px; + height: 16px; +} + +.tag-matrix { + display: grid; + gap: 8px; + overflow-x: auto; +} + +.matrix-row { + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(120px, 1fr); + gap: 8px; + align-items: center; +} + +.matrix-row.header { + font-weight: 600; + color: #3b3f46; +} + +.matrix-cell { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid #e6e8ec; + border-radius: 10px; + background: #fbfbfc; + font-size: 12px; +} + +.matrix-cell.label { + font-weight: 600; + background: #ffffff; +} + +.meta { + font-size: 12px; + color: #6a6c7a; +} + +.pill { + background: #f0e2d4; + color: #7a4b17; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; +} + +.controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +button { + border: none; + background: #1f243d; + color: #ffffff; + padding: 8px 12px; + border-radius: 8px; + cursor: pointer; + font-size: 14px; +} + +button:hover { + background: #2c3252; +} + +select { + border-radius: 8px; + border: 1px solid #d6d1cc; + padding: 8px 10px; + min-width: 180px; + background: #ffffff; +} + +.button-ghost { + background: transparent; + color: #1f243d; + border: 1px solid #d6d1cc; +} + +.button-ghost:hover { + background: #f6f2ee; +} + +.card.compact { + padding: 10px; + margin-top: 10px; +} + +input { + border-radius: 8px; + border: 1px solid #d6d1cc; + padding: 8px 10px; + min-width: 140px; +} + +.status { + background: #111522; + color: #e8e8ef; + padding: 12px; + border-radius: 12px; + font-size: 12px; + overflow: auto; +} + +.error { + background: #ffefef; + color: #9a2a2a; + border: 1px solid #f2c9c9; + border-radius: 10px; + padding: 10px 12px; + margin-bottom: 16px; +} + +.muted { + color: #6a6c7a; +} + +.tree { + border: 1px solid #e6e1dc; + border-radius: 12px; + padding: 12px; + background: #faf7f4; +} + +.explorer { + display: grid; + grid-template-columns: 260px 1fr; + grid-template-rows: 1fr auto; + gap: 0; + border: 1px solid #e2e4e8; + border-radius: 12px; + overflow-x: auto; + overflow-y: hidden; + background: #ffffff; + min-height: 200px; + align-items: stretch; +} + +.explorer-sidebar { + grid-row: 1; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; + background: #ffffff; + height: 100%; +} + +.sidebar-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #8a919d; + margin: 0; +} + +.sidebar-toolbar { + display: flex; + align-items: center; + justify-content: flex-start; + border: 1px solid #e6e8ec; + background: #fbfbfc; + padding: 10px 12px; + min-height: 44px; + gap: 10px; +} + +.sidebar-tree { + border: 1px solid #e6e8ec; + border-radius: 10px; + background: #ffffff; + padding: 8px; + overflow: auto; +} + +.sidebar-search { + flex: 1; + width: 100%; + border: 1px solid #dde0e6; + background: #ffffff; + border-radius: 8px; + padding: 6px 10px; + font-size: 12px; + color: #2f3442; + min-width: 0; +} + +.sidebar-search:focus { + outline: none; + border-color: #cfd4dc; + box-shadow: 0 0 0 2px rgba(47, 52, 66, 0.08); +} + +.explorer-toolbar, +.sidebar-toolbar { + height: 44px; + min-height: 44px; + box-shadow: 0 1px 2px rgba(16, 18, 22, 0.06); + align-items: center; +} + +.explorer-actions.footer { + padding-top: 6px; +} + +.explorer-main { + grid-row: 1; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px 14px; + background: #ffffff; + height: 100%; +} + +.explorer-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + padding: 10px 12px; + border: 1px solid #e6e8ec; + border-radius: 10px; + background: #fbfbfc; +} + +.explorer-path { + display: flex; + flex-wrap: wrap; + gap: 6px; + font-size: 12px; + color: #6a7280; + align-items: center; +} + +.breadcrumb-root { + font-weight: 600; + color: #3b3f46; +} + +.path-link { + background: transparent; + border: none; + color: #3b3f46; + cursor: pointer; + padding: 0; + font-size: 12px; + font-weight: 600; +} + +.path-link:hover, +.path-link:focus { + background: transparent; + color: #3b3f46; + outline: none; +} + + +.toolbar-actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; +} + +.icon-button { + border: 1px solid #dde0e6; + background: #ffffff; + color: #1f243d; + padding: 6px 8px; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.icon-button:hover { + background: #f3f5f8; +} + +.icon-button.danger { + border-color: #f2c9c9; + color: #9a2a2a; +} + +.icon-button.upload { + position: relative; + overflow: hidden; +} + +.icon-button:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.icon-button.upload input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.explorer-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.explorer-list { + border: 1px solid #e6e8ec; + border-radius: 10px; + background: #ffffff; + overflow: auto; + max-height: 420px; + user-select: none; +} + +.explorer-row { + display: grid; + grid-template-columns: 1fr 120px 90px; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid #f4f5f7; + cursor: pointer; +} + +.explorer-list.has-meta .explorer-row { + grid-template-columns: 1fr 160px 200px 80px 90px; +} + +.explorer-footer { + grid-row: 2; + grid-column: 1 / -1; + margin: 0 12px 12px; + padding: 10px 12px; + border: 1px solid #e6e8ec; + border-radius: 10px; + background: #fbfbfc; + font-size: 12px; + color: #3b3f46; + display: flex; + justify-content: flex-end; +} + +.sidebar-tree { + flex: 1; +} + +.explorer-row.header { + background: #f6f7f9; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + cursor: default; +} + +.explorer-row.footer { + background: #fbfbfc; + font-size: 12px; + cursor: default; +} + +.footer-count { + text-align: right; + color: #6a7280; +} + +.explorer-row.selected { + background: #eef3ff; +} + +.tree-row { + background: transparent; + padding: 4px 6px; + border-radius: 6px; + display: flex; + align-items: center; + gap: 6px; +} + +.tree-row.folder { + background: transparent; + font-weight: 500; + color: #3b3f46; +} + +.tree-row.folder:hover { + background: transparent; +} + +.tree-row.active { + background: #e7edf7; +} + +.tree-tag-count { + font-size: 11px; + color: #6a7280; + background: #eef1f5; + border-radius: 999px; + padding: 0 6px; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + line-height: 18px; +} + +.media-tag-count { + margin-left: 6px; + font-size: 11px; + color: #6a7280; + background: #eef1f5; + border-radius: 999px; + padding: 2px 6px; +} + +.tree-caret { + background: transparent; + border: none; + padding: 0; + margin-right: 6px; + color: #8a919d; + cursor: pointer; + font-size: 12px; + width: 16px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.tree-caret:hover, +.tree-caret:focus { + background: transparent; + color: #8a919d; + outline: none; +} + +.tree-caret.disabled { + opacity: 0.35; + cursor: default; +} + +.tree-icon { + margin-right: 6px; + color: #8a919d; + font-size: 11px; +} + +.tree-icon.folder { + color: #5a7bb6; + margin-right: 8px; +} + +.row-name { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.row-icon { + width: 18px; + display: inline-flex; + justify-content: center; + color: #5a7bb6; +} + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(16, 18, 22, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.modal-card { + background: #ffffff; + border-radius: 12px; + padding: 16px; + width: min(420px, 90vw); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.18); + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-card h3 { + margin: 0; + font-size: 16px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-body { + display: flex; + flex-direction: column; + gap: 8px; +} + +.modal-label { + font-size: 12px; + color: #5a5f6b; +} + +.modal-card input { + width: 100%; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.upload-dropzone { + margin-top: 12px; + border: 1px dashed #d5d9e0; + border-radius: 12px; + padding: 14px; + background: #fbfbfc; + display: flex; + flex-direction: column; + gap: 6px; + cursor: pointer; + font-size: 12px; + color: #6a7280; +} + +.upload-dropzone input[type='file'] { + display: none; +} + +.upload-dropzone.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.dropzone-title { + font-size: 13px; + color: #2f3442; + font-weight: 600; +} + +.dropzone-hint { + font-size: 12px; + color: #6a7280; +} + +.dropzone-files { + font-size: 11px; + color: #3b3f46; + word-break: break-word; +} + +.upload-progress { + height: 6px; + border-radius: 999px; + background: #eef1f5; + overflow: hidden; + margin-top: 10px; +} + +.upload-progress > div { + height: 100%; + background: #5b7cfa; + width: 0%; + transition: width 0.2s ease; +} + +.upload-status { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; + padding: 10px 12px; + border: 1px solid #e6e8ec; + border-radius: 10px; + background: #fbfbfc; + font-size: 12px; + color: #3b3f46; +} + +.tree-node { + margin-left: 0; +} + +.tree-label { + display: inline-block; + user-select: none; + cursor: pointer; +} + +.depth-1 .tree-row { + margin-left: 12px; +} + +.depth-2 .tree-row { + margin-left: 24px; +} + +.depth-3 .tree-row { + margin-left: 36px; +} + +.depth-4 .tree-row { + margin-left: 48px; +} + +.depth-5 .tree-row { + margin-left: 60px; +} diff --git a/gui/frontend/vite.config.js b/gui/frontend/vite.config.js new file mode 100644 index 0000000..eaf2322 --- /dev/null +++ b/gui/frontend/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + host: true, + port: 5174, + strictPort: true, + }, +}); diff --git a/gui/index.html b/gui/index.html deleted file mode 100644 index 809e4bb..0000000 --- a/gui/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - gui - - -
- - - diff --git a/gui/package-lock.json b/gui/package-lock.json deleted file mode 100644 index 741a942..0000000 --- a/gui/package-lock.json +++ /dev/null @@ -1,1337 +0,0 @@ -{ - "name": "gui", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "gui", - "version": "0.0.0", - "dependencies": { - "vue": "^3.5.24" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^6.0.1", - "vite": "^7.2.4" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.5" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", - "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", - "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", - "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", - "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", - "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", - "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", - "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", - "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", - "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", - "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", - "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", - "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", - "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", - "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", - "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", - "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", - "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", - "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", - "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", - "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", - "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", - "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", - "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", - "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", - "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitejs/plugin-vue": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", - "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.53" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.26.tgz", - "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/shared": "3.5.26", - "entities": "^7.0.0", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz", - "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==", - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz", - "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==", - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.5", - "@vue/compiler-core": "3.5.26", - "@vue/compiler-dom": "3.5.26", - "@vue/compiler-ssr": "3.5.26", - "@vue/shared": "3.5.26", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.6", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz", - "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==", - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", - "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==", - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.26.tgz", - "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.26", - "@vue/shared": "3.5.26" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz", - "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==", - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.26", - "@vue/runtime-core": "3.5.26", - "@vue/shared": "3.5.26", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.26.tgz", - "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==", - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.26", - "@vue/shared": "3.5.26" - }, - "peerDependencies": { - "vue": "3.5.26" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", - "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" - }, - "node_modules/entities": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.0.tgz", - "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.55.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", - "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.55.1", - "@rollup/rollup-android-arm64": "4.55.1", - "@rollup/rollup-darwin-arm64": "4.55.1", - "@rollup/rollup-darwin-x64": "4.55.1", - "@rollup/rollup-freebsd-arm64": "4.55.1", - "@rollup/rollup-freebsd-x64": "4.55.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", - "@rollup/rollup-linux-arm-musleabihf": "4.55.1", - "@rollup/rollup-linux-arm64-gnu": "4.55.1", - "@rollup/rollup-linux-arm64-musl": "4.55.1", - "@rollup/rollup-linux-loong64-gnu": "4.55.1", - "@rollup/rollup-linux-loong64-musl": "4.55.1", - "@rollup/rollup-linux-ppc64-gnu": "4.55.1", - "@rollup/rollup-linux-ppc64-musl": "4.55.1", - "@rollup/rollup-linux-riscv64-gnu": "4.55.1", - "@rollup/rollup-linux-riscv64-musl": "4.55.1", - "@rollup/rollup-linux-s390x-gnu": "4.55.1", - "@rollup/rollup-linux-x64-gnu": "4.55.1", - "@rollup/rollup-linux-x64-musl": "4.55.1", - "@rollup/rollup-openbsd-x64": "4.55.1", - "@rollup/rollup-openharmony-arm64": "4.55.1", - "@rollup/rollup-win32-arm64-msvc": "4.55.1", - "@rollup/rollup-win32-ia32-msvc": "4.55.1", - "@rollup/rollup-win32-x64-gnu": "4.55.1", - "@rollup/rollup-win32-x64-msvc": "4.55.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/vite": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", - "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "esbuild": "^0.27.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vue": { - "version": "3.5.26", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", - "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@vue/compiler-dom": "3.5.26", - "@vue/compiler-sfc": "3.5.26", - "@vue/runtime-dom": "3.5.26", - "@vue/server-renderer": "3.5.26", - "@vue/shared": "3.5.26" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - } - } -} diff --git a/gui/package.json b/gui/package.json deleted file mode 100644 index 72bd075..0000000 --- a/gui/package.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "gui", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" - }, - "dependencies": { - "vue": "^3.5.24" - }, - "devDependencies": { - "@vitejs/plugin-vue": "^6.0.1", - "vite": "^7.2.4" - } -} diff --git a/gui/public/vite.svg b/gui/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/gui/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/gui/src/App.vue b/gui/src/App.vue deleted file mode 100644 index f465c63..0000000 --- a/gui/src/App.vue +++ /dev/null @@ -1,82 +0,0 @@ - - - diff --git a/gui/src/api.js b/gui/src/api.js deleted file mode 100644 index 63cca30..0000000 --- a/gui/src/api.js +++ /dev/null @@ -1,29 +0,0 @@ -// src/api.js - -const API_BASE = import.meta.env.VITE_BOX_API_URL || "http://10.10.10.23:8000"; -const API_TOKEN = import.meta.env.VITE_BOX_API_TOKEN || ""; - -export async function getStatus() { - const response = await fetch(`${API_BASE}/status`); - if (!response.ok) { - throw new Error(`Status request failed (${response.status})`); - } - return await response.json(); -} - -export async function sendCommand(command, payload = {}) { - console.log("GUI -> API", command, payload); - const headers = { "Content-Type": "application/json" }; - if (API_TOKEN) { - headers["X-API-Token"] = API_TOKEN; - } - const response = await fetch(`${API_BASE}/command`, { - method: "POST", - headers, - body: JSON.stringify({ command, payload }), - }); - if (!response.ok) { - throw new Error(`Command request failed (${response.status})`); - } - return await response.json(); -} diff --git a/gui/src/assets/vue.svg b/gui/src/assets/vue.svg deleted file mode 100644 index 770e9d3..0000000 --- a/gui/src/assets/vue.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/gui/src/components/HelloWorld.vue b/gui/src/components/HelloWorld.vue deleted file mode 100644 index 546ebbc..0000000 --- a/gui/src/components/HelloWorld.vue +++ /dev/null @@ -1,43 +0,0 @@ - - - - - diff --git a/gui/src/main.js b/gui/src/main.js deleted file mode 100644 index 2425c0f..0000000 --- a/gui/src/main.js +++ /dev/null @@ -1,5 +0,0 @@ -import { createApp } from 'vue' -import './style.css' -import App from './App.vue' - -createApp(App).mount('#app') diff --git a/gui/src/style.css b/gui/src/style.css deleted file mode 100644 index f691315..0000000 --- a/gui/src/style.css +++ /dev/null @@ -1,79 +0,0 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -.card { - padding: 2em; -} - -#app { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} diff --git a/gui/vite.config.js b/gui/vite.config.js deleted file mode 100644 index 8b55b29..0000000 --- a/gui/vite.config.js +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'vite' -import vue from '@vitejs/plugin-vue' - -// https://vite.dev/config/ -export default defineConfig({ - plugins: [vue()], - resolve: { - alias: [{ find: "@", replacement: "/src" }], - }, - server: { - host: true, // Hört auf allen Netzwerk-Interfaces, kein localhost notwendig - port: 5174 // optional, Standardport für Vite - } -}) diff --git a/kill-ports.sh b/kill-ports.sh new file mode 100755 index 0000000..4c8a710 --- /dev/null +++ b/kill-ports.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +PORTS=(5001 5174 8000 9000 8001 9001) + +have_lsof() { + command -v lsof >/dev/null 2>&1 +} + +have_ss() { + command -v ss >/dev/null 2>&1 +} + +list_listeners_lsof() { + local port="$1" + lsof -nP -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true +} + +list_listeners_ss() { + local port="$1" + ss -ltnp "sport = :${port}" 2>/dev/null || true +} + +kill_pid() { + local pid="$1" + kill -TERM "$pid" 2>/dev/null || true +} + +found_any=false + +for port in "${PORTS[@]}"; do + if have_lsof; then + output="$(list_listeners_lsof "$port")" + if [[ -z "$output" ]]; then + continue + fi + found_any=true + echo "Port ${port}:" + echo "$output" + pids=$(echo "$output" | awk 'NR>1 {print $2}' | sort -u) + elif have_ss; then + output="$(list_listeners_ss "$port")" + if [[ -z "$output" ]]; then + continue + fi + found_any=true + echo "Port ${port}:" + echo "$output" + pids=$(echo "$output" | awk -F 'pid=' 'NF>1 {print $2}' | awk -F ',' '{print $1}' | sort -u) + else + echo "error: neither lsof nor ss is available" + exit 1 + fi + + if [[ -z "$pids" ]]; then + continue + fi + + read -r -p "Kill these PIDs on port ${port}? (y/n) " answer + if [[ "$answer" != "y" ]]; then + echo "Skipping port ${port}." + continue + fi + + for pid in $pids; do + echo "Killing PID ${pid} (port ${port})" + kill_pid "$pid" + done + echo "Done for port ${port}." + echo "" + +done + +if [[ "$found_any" == "false" ]]; then + echo "No listeners found on ports: ${PORTS[*]}" +fi diff --git a/server/__pycache__/storage.cpython-312.pyc b/server/__pycache__/storage.cpython-312.pyc deleted file mode 100644 index ef876c2..0000000 Binary files a/server/__pycache__/storage.cpython-312.pyc and /dev/null differ diff --git a/server/data/boxes.json b/server/data/boxes.json deleted file mode 100644 index df1a7c3..0000000 --- a/server/data/boxes.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "klangkiste-x1u8i4x5e4": { - "fingerprint": "ecfcaddfcdf73417c8b377cb807b006f7d0756ca717fda35f377e8d18b297c43", - "state": "UNPAIRED", - "first_seen": 1768244301, - "last_seen": 1768244591, - "firmware_version": "0.1.0", - "capabilities": { - "nfc": true, - "audio": true, - "spotify": true - }, - "api_token": null - } -} \ No newline at end of file diff --git a/server/main.py b/server/main.py deleted file mode 100644 index 482ab73..0000000 --- a/server/main.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Minimal server API and GUI for pairing.""" - -from __future__ import annotations - -import secrets -import time -from pathlib import Path -from typing import Any - -from fastapi import FastAPI, HTTPException, Form -from fastapi.responses import HTMLResponse, RedirectResponse -from pydantic import BaseModel -import uvicorn - -from storage import BoxRecord, BoxRegistry - -DATA_DIR = Path(__file__).resolve().parent / "data" -DATA_DIR.mkdir(parents=True, exist_ok=True) - -registry = BoxRegistry(DATA_DIR) -app = FastAPI() - - -class AnnounceRequest(BaseModel): - box_id: str - fingerprint: str - firmware_version: str - capabilities: dict[str, Any] - - -class PollRequest(BaseModel): - box_id: str - - -class PairRequest(BaseModel): - box_id: str - - -def _now() -> int: - return int(time.time()) - - -def _validate_box_id(value: str) -> bool: - if not value.startswith("klangkiste-"): - return False - suffix = value.replace("klangkiste-", "", 1) - if len(suffix) != 10: - return False - return all(c.isdigit() or ("a" <= c <= "z") for c in suffix) - - -@app.post("/api/boxes/announce") -def announce(req: AnnounceRequest) -> dict[str, Any]: - if not _validate_box_id(req.box_id): - raise HTTPException(status_code=400, detail="invalid box_id") - if not req.fingerprint: - raise HTTPException(status_code=400, detail="fingerprint required") - - boxes = registry.load() - now = _now() - if req.box_id in boxes: - record = boxes[req.box_id] - if record.fingerprint and record.fingerprint != req.fingerprint: - raise HTTPException(status_code=400, detail="fingerprint mismatch") - record.last_seen = now - record.firmware_version = req.firmware_version - record.capabilities = req.capabilities - if record.state != "PAIRED": - record.state = "UNPAIRED" - else: - record = BoxRecord( - box_id=req.box_id, - fingerprint=req.fingerprint, - state="UNPAIRED", - first_seen=now, - last_seen=now, - firmware_version=req.firmware_version, - capabilities=req.capabilities, - ) - boxes[req.box_id] = record - registry.save(boxes) - return {"ok": True} - - -@app.post("/api/boxes/poll-pairing") -def poll_pairing(req: PollRequest) -> dict[str, Any]: - boxes = registry.load() - record = boxes.get(req.box_id) - if not record: - raise HTTPException(status_code=404, detail="unknown box") - if record.state == "PAIRED" and record.api_token: - return {"paired": True, "api_token": record.api_token} - return {"paired": False} - - -@app.post("/api/boxes/pair") -def pair_box(req: PairRequest) -> dict[str, Any]: - boxes = registry.load() - record = boxes.get(req.box_id) - if not record: - raise HTTPException(status_code=404, detail="unknown box") - if record.state != "PAIRED": - record.state = "PAIRED" - record.api_token = secrets.token_hex(32) - boxes[req.box_id] = record - registry.save(boxes) - return {"paired": True, "api_token": record.api_token} - - -@app.get("/ui", response_class=HTMLResponse) -def ui() -> str: - boxes = _apply_offline(registry.load()) - unpaired = [b for b in boxes.values() if b.state != "PAIRED"] - paired = [b for b in boxes.values() if b.state == "PAIRED"] - - def _row(box: BoxRecord) -> str: - return ( - f"{box.box_id}{box.firmware_version}" - f"{box.last_seen}" - f"
" - f"" - f"" - f"
" - ) - - unpaired_rows = "".join(_row(b) for b in unpaired) or "Keine" - paired_rows = "".join( - f"{b.box_id}{b.firmware_version}{b.last_seen}" - for b in paired - ) or "Keine" - - return f""" - - - Klangkiste Server - -

Neue Boxen (UNPAIRED)

- - - {unpaired_rows} -
box_idfirmwarelast_seenaction
-

Gepaarte Boxen

- - - {paired_rows} -
box_idfirmwarelast_seen
- - -""" - - -def _apply_offline(boxes: dict[str, BoxRecord], threshold_seconds: int = 120) -> dict[str, BoxRecord]: - now = _now() - for record in boxes.values(): - if now - record.last_seen > threshold_seconds: - record.state = "OFFLINE" - registry.save(boxes) - return boxes - - -@app.post("/ui/pair") -def ui_pair(box_id: str = Form(...)) -> RedirectResponse: - pair_box(PairRequest(box_id=box_id)) - return RedirectResponse(url="/ui", status_code=303) - - -if __name__ == "__main__": - uvicorn.run(app, host="0.0.0.0", port=7000, log_level="info") diff --git a/server/storage.py b/server/storage.py deleted file mode 100644 index 1de0a94..0000000 --- a/server/storage.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Server-side storage for box registry.""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Any - - -@dataclass -class BoxRecord: - box_id: str - fingerprint: str - state: str - first_seen: int - last_seen: int - firmware_version: str - capabilities: dict[str, Any] - api_token: str | None = None - - -class BoxRegistry: - def __init__(self, base_dir: Path) -> None: - self._path = base_dir / "boxes.json" - - def load(self) -> dict[str, BoxRecord]: - if not self._path.exists(): - return {} - raw = json.loads(self._path.read_text(encoding="utf-8")) - result: dict[str, BoxRecord] = {} - for box_id, data in raw.items(): - if not isinstance(data, dict): - continue - result[box_id] = BoxRecord( - box_id=box_id, - fingerprint=str(data.get("fingerprint", "")), - state=str(data.get("state", "UNPAIRED")), - first_seen=int(data.get("first_seen", 0)), - last_seen=int(data.get("last_seen", 0)), - firmware_version=str(data.get("firmware_version", "")), - capabilities=dict(data.get("capabilities", {})), - api_token=data.get("api_token"), - ) - return result - - def save(self, records: dict[str, BoxRecord]) -> None: - data = { - box_id: { - "fingerprint": rec.fingerprint, - "state": rec.state, - "first_seen": rec.first_seen, - "last_seen": rec.last_seen, - "firmware_version": rec.firmware_version, - "capabilities": rec.capabilities, - "api_token": rec.api_token, - } - for box_id, rec in records.items() - } - self._path.write_text(json.dumps(data, indent=2), encoding="utf-8")