diff --git a/.devcontainer/docker/devcontainer.json b/.devcontainer/docker/devcontainer.json index 6a4e6ae0..e4df9bae 100644 --- a/.devcontainer/docker/devcontainer.json +++ b/.devcontainer/docker/devcontainer.json @@ -4,7 +4,7 @@ "features": { "ghcr.io/devcontainers/features/node:1": { // Should match what is defined in ui/package.json - "version": "22.19.0" + "version": "22.20.0" } }, "mounts": [ @@ -31,8 +31,11 @@ // Frontend "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", - "bradlc.vscode-tailwindcss" + "bradlc.vscode-tailwindcss", + "codeandstuff.package-json-upgrade", + // Localization + "inlang.vs-code-extension" ] } } -} +} \ No newline at end of file diff --git a/.devcontainer/podman/devcontainer.json b/.devcontainer/podman/devcontainer.json index bba58a31..14b7ea2c 100644 --- a/.devcontainer/podman/devcontainer.json +++ b/.devcontainer/podman/devcontainer.json @@ -4,7 +4,7 @@ "features": { "ghcr.io/devcontainers/features/node:1": { // Should match what is defined in ui/package.json - "version": "22.19.0" + "version": "22.20.0" } }, "runArgs": [ diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..d89bc64c --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,25 @@ +{ + "recommendations": [ + // coding styles + "chrislajoie.vscode-modelines", + "editorconfig.editorconfig", + // GitHub + "GitHub.vscode-pull-request-github", + "github.vscode-github-actions", + // Golang + "golang.go", + // C / C++ + "ms-vscode.cpptools", + "ms-vscode.cpptools-extension-pack", + // CMake / Makefile + "ms-vscode.makefile-tools", + "ms-vscode.cmake-tools", + // Frontend + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "bradlc.vscode-tailwindcss", + "codeandstuff.package-json-upgrade", + // Localization + "inlang.vs-code-extension" + ] +} \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 964526d6..5c906860 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -97,38 +97,42 @@ tail -f /var/log/jetkvm.log ``` /kvm/ -├── main.go # App entry point -├── config.go # Settings & configuration -├── display.go # Device UI control -├── web.go # API endpoints -├── cmd/ # Command line main -├── internal/ # Internal Go packages -│ ├── confparser/ # Configuration file implementation -│ ├── hidrpc/ # HIDRPC implementation for HID devices (keyboard, mouse, etc.) -│ ├── logging/ # Logging implementation -│ ├── mdns/ # mDNS implementation -│ ├── native/ # CGO / Native code glue layer (on-device hardware) -│ │ ├── cgo/ # C files for the native library (HDMI, Touchscreen, etc.) -│ │ └── eez/ # EEZ Studio Project files (for Touchscreen) -│ ├── network/ # Network implementation -│ ├── timesync/ # Time sync/NTP implementation -│ ├── tzdata/ # Timezone data and generation -│ ├── udhcpc/ # DHCP implementation -│ ├── usbgadget/ # USB gadget -│ ├── utils/ # SSH handling -│ └── websecure/ # TLS certificate management -├── resource/ # netboot iso and other resources -├── scripts/ # Bash shell scripts for building and deploying -└── static/ # (react client build output) -└── ui/ # React frontend - ├── public/ # UI website static images and fonts - └── src/ # Client React UI - ├── assets/ # UI in-page images - ├── components/ # UI components - ├── hooks/ # Hooks (stores, RPC handling, virtual devices) - ├── keyboardLayouts/ # Keyboard layout definitions - ├── providers/ # Feature flags - └── routes/ # Pages (login, settings, etc.) +├── main.go # App entry point +├── config.go # Settings & configuration +├── display.go # Device UI control +├── web.go # API endpoints +├── cmd/ # Command line main +├── internal/ # Internal Go packages +│ ├── confparser/ # Configuration file implementation +│ ├── hidrpc/ # HIDRPC implementation for HID devices (keyboard, mouse, etc.) +│ ├── logging/ # Logging implementation +│ ├── mdns/ # mDNS implementation +│ ├── native/ # CGO / Native code glue layer (on-device hardware) +│ │ ├── cgo/ # C files for the native library (HDMI, Touchscreen, etc.) +│ │ └── eez/ # EEZ Studio Project files (for Touchscreen) +│ ├── network/ # Network implementation +│ ├── timesync/ # Time sync/NTP implementation +│ ├── tzdata/ # Timezone data and generation +│ ├── udhcpc/ # DHCP implementation +│ ├── usbgadget/ # USB gadget +│ ├── utils/ # SSH handling +│ └── websecure/ # TLS certificate management +├── resource/ # netboot iso and other resources +├── scripts/ # Bash shell scripts for building and deploying +└── static/ # (react client build output) +└── ui/ # React frontend + ├── localization/ # Client UI localization (i18n) + │ ├── jetKVM.UI.inlang/ # Settings for inlang + │ └── messages/ # Messages localized + ├── public/ # UI website static images and fonts + └── src/ # Client React UI + ├── assets/ # UI in-page images + ├── components/ # UI components + ├── hooks/ # Hooks (stores, RPC handling, virtual devices) + ├── keyboardLayouts/ # Keyboard layout definitions + │ ├── paraglide/ # (localization compiled messages output) + ├── providers/ # Feature flags + └── routes/ # Pages (login, settings, etc.) ``` **Key files for beginners:** diff --git a/tools/find_duplicate_translations.py b/tools/find_duplicate_translations.py new file mode 100644 index 00000000..fdb24904 --- /dev/null +++ b/tools/find_duplicate_translations.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import re +from datetime import datetime +from pathlib import Path + +def flatten_strings(obj, prefix=""): + if isinstance(obj, dict): + for k, v in obj.items(): + key = f"{prefix}.{k}" if prefix else k + yield from flatten_strings(v, key) + else: + # only consider scalar strings for translation targets + if isinstance(obj, str): + yield prefix, obj + +def normalize(s, ignore_case=False, trim=False, collapse_ws=False): + if collapse_ws: + s = re.sub(r"\s+", " ", s) + if trim: + s = s.strip() + if ignore_case: + s = s.lower() + return s + +def main(): + p = argparse.ArgumentParser(description="Find identical translation targets with different keys in en.json") + p.add_argument("--en", default="../ui/localization/messages/en.json", help="path to en.json") + p.add_argument("--out", default="../reports/duplicate_translations.json", help="output report path (JSON)") + p.add_argument("--ignore-case", action="store_true", help="ignore case when comparing values") + p.add_argument("--trim", action="store_true", help="trim surrounding whitespace before comparing") + p.add_argument("--collapse-ws", action="store_true", help="collapse internal whitespace before comparing") + args = p.parse_args() + + en_path = Path(args.en) + if not en_path.is_file(): + print(f"en.json not found: {en_path}") + raise SystemExit(2) + + with en_path.open(encoding="utf-8") as f: + payload = json.load(f) + + entries = list(flatten_strings(payload)) + total_keys = len(entries) + + groups = {} + original_values = {} + for key, val in entries: + norm = normalize(val, ignore_case=args.ignore_case, trim=args.trim, collapse_ws=args.collapse_ws) + groups.setdefault(norm, []).append(key) + # keep the first seen original for reporting + original_values.setdefault(norm, val) + + duplicates = [] + for norm, keys in groups.items(): + if len(keys) > 1: + duplicates.append({ + "normalized_value": norm, + "original_value": original_values.get(norm), + "keys": sorted(keys), + "count": len(keys) + }) + + report = { + "generated_at": datetime.utcnow().isoformat() + "Z", + "en_json": str(en_path), + "total_string_keys": total_keys, + "duplicate_groups": sorted(duplicates, key=lambda d: (-d["count"], d["normalized_value"])), + "duplicate_count": len(duplicates) + } + + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", encoding="utf-8") as f: + json.dump(report, f, indent=2, ensure_ascii=False) + + print(f"Wrote {out_path} — total keys: {total_keys}, duplicate groups: {len(duplicates)}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/find_unused_messages.py b/tools/find_unused_messages.py new file mode 100644 index 00000000..fc90c03b --- /dev/null +++ b/tools/find_unused_messages.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import re +from datetime import datetime +from pathlib import Path + +def flatten(d, prefix=""): + for k, v in d.items(): + key = f"{prefix}.{k}" if prefix else k + if isinstance(v, dict): + yield from flatten(v, key) + else: + yield key + +def gather_files(src_dir, exts=(".ts", ".tsx", ".js", ".jsx", ".html", ".vue", ".json")): + for root, _, files in os.walk(src_dir): + parts = root.split(os.sep) + if "node_modules" in parts or ".git" in parts: + continue + for fn in files: + if fn.endswith(exts): + yield Path(root) / fn + +def find_usages(keys, files): + usages = {k: [] for k in keys} + # Precompile patterns for speed + patterns = {k: re.compile(r"\bm\." + re.escape(k) + r"\s*\(") for k in keys} + for file in files: + try: + text = file.read_text(encoding="utf-8") + except Exception: + continue + lines = text.splitlines() + for i, line in enumerate(lines, start=1): + for k, pat in patterns.items(): + if pat.search(line): + usages[k].append({ + "file": str(file), + "line": i, + "text": line.strip() + }) + return usages + +def main(): + p = argparse.ArgumentParser(description="Generate JSON report of localization key usage (m.key_name_here()).") + p.add_argument("--en", default="../ui/localization/messages/en.json", help="path to en.json") + p.add_argument("--src", default="../ui", help="root source directory to scan") + p.add_argument("--out", default="../reports/localization_report.json", help="output report file") + args = p.parse_args() + + en_path = Path(args.en) + if not en_path.is_file(): + print(f"en.json not found: {en_path}", flush=True) + raise SystemExit(2) + + with en_path.open(encoding="utf-8") as f: + payload = json.load(f) + + keys = sorted(list(flatten(payload))) + files = list(gather_files(args.src)) + usages = find_usages(keys, files) + + report = { + "generated_at": datetime.utcnow().isoformat() + "Z", + "en_json": str(en_path), + "src_root": args.src, + "total_keys": len(keys), + "keys": {} + } + + unused_count = 0 + for k in keys: + occ = usages.get(k, []) + used = bool(occ) + if not used: + unused_count += 1 + report["keys"][k] = { + "used": used, + "occurrences": occ + } + + report["unused_count"] = unused_count + report["unused_keys"] = [k for k, v in report["keys"].items() if not v["used"]] + + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", encoding="utf-8") as f: + json.dump(report, f, indent=2, ensure_ascii=False) + + print(f"Report written to {out_path}") + print(f"Total keys: {report['total_keys']}, Unused: {report['unused_count']}") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/sort_messages.py b/tools/sort_messages.py new file mode 100644 index 00000000..51d0a38a --- /dev/null +++ b/tools/sort_messages.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +import json +from pathlib import Path + +messages_dir = Path(__file__).resolve().parent.parent / 'ui' / 'localization' / 'messages' +files = list(messages_dir.glob('*.json')) + +for f in files: + data = json.loads(f.read_text(encoding='utf-8')) + # Keep $schema first if present + schema = None + if '$schema' in data: + schema = data.pop('$schema') + sorted_items = dict(sorted(data.items())) + if schema is not None: + out = {'$schema': schema} + out.update(sorted_items) + else: + out = sorted_items + f.write_text(json.dumps(out, ensure_ascii=False, indent=4) + '\n', encoding='utf-8') +print(f'Processed {len(files)} files in {messages_dir}') diff --git a/ui/eslint.config.cjs b/ui/eslint.config.cjs index 6e972586..ad4338a3 100644 --- a/ui/eslint.config.cjs +++ b/ui/eslint.config.cjs @@ -9,8 +9,6 @@ const { fixupConfigRules, } = require("@eslint/compat"); -const tsParser = require("@typescript-eslint/parser"); -const reactRefresh = require("eslint-plugin-react-refresh"); const js = require("@eslint/js"); const { @@ -23,6 +21,9 @@ const compat = new FlatCompat({ allConfig: js.configs.all }); +const tsParser = require("@typescript-eslint/parser"); +const reactRefresh = require("eslint-plugin-react-refresh"); + module.exports = defineConfig([{ languageOptions: { globals: { @@ -66,7 +67,7 @@ module.exports = defineConfig([{ groups: ["builtin", "external", "internal", "parent", "sibling"], "newlines-between": "always", }], - + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], @@ -81,7 +82,10 @@ module.exports = defineConfig([{ map: [ ["@components", "./src/components"], ["@routes", "./src/routes"], + ["@hooks", "./src/hooks"], + ["@providers", "./src/providers"], ["@assets", "./src/assets"], + ["@localizations", "./localization/paraglide"], ["@", "./src"], ], diff --git a/ui/index.html b/ui/index.html index 3c6c5606..77936233 100644 --- a/ui/index.html +++ b/ui/index.html @@ -45,31 +45,39 @@ - + - +
- +