Simplify UI and add batch build endpoint
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
**/node_modules/
|
||||||
|
**/.venv/
|
||||||
|
**/dev.out.log
|
||||||
|
**/dev.err.log
|
||||||
|
**/server.out.log
|
||||||
|
**/server.err.log
|
||||||
|
frontend/dist/
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
**/.venv/
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
backend/.pytest_cache/
|
||||||
|
backend/.mypy_cache/
|
||||||
|
backend/.ruff_cache/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
_out.json
|
||||||
|
_log_import.json
|
||||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
FROM node:20-alpine AS frontend-build
|
||||||
|
WORKDIR /app
|
||||||
|
ARG VITE_API_BASE=
|
||||||
|
ENV VITE_API_BASE=$VITE_API_BASE
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM python:3.11-slim AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV STATIC_DIR=/app/static
|
||||||
|
COPY backend/requirements.txt ./
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY backend/ ./backend/
|
||||||
|
COPY --from=frontend-build /app/dist/ ./static/
|
||||||
|
EXPOSE 8002
|
||||||
|
CMD ["python", "-m", "uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8002"]
|
||||||
68
README.md
Normal file
68
README.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# GeViSetWEB
|
||||||
|
|
||||||
|
Lightweight single-user web tool for editing GeViSet `.set` files with fast tables.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Parse `.set` files into editable tables (servers + action mappings)
|
||||||
|
- Edit, add, and delete G-Core and GeViScope servers
|
||||||
|
- Edit and extend action mappings
|
||||||
|
- Import new entries from Excel templates
|
||||||
|
- Export back to `.set`
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `backend/` FastAPI service
|
||||||
|
- `frontend/` React + Vite UI
|
||||||
|
- `samples/` Excel templates copied from the original workspace
|
||||||
|
|
||||||
|
## Backend setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/DEV/COPILOT_codex/GeViSetWEB/backend
|
||||||
|
python -m venv .venv
|
||||||
|
.\.venv\Scripts\activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
uvicorn app.main:app --reload --port 8002
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd C:/DEV/COPILOT_codex/GeViSetWEB/frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The UI expects the API at `http://localhost:8002` by default. To override, set `VITE_API_BASE` in a `.env` file.
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
Build the image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t gevisetweb .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8002:8002 gevisetweb
|
||||||
|
```
|
||||||
|
|
||||||
|
Open:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://<host>:8002
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The action mapping editor edits output actions (name/action/server) for speed.
|
||||||
|
- New action mappings imported from Excel use a template from the first mapping rule.
|
||||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
467
backend/app/excel.py
Normal file
467
backend/app/excel.py
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from typing import Any, Dict, List, Tuple
|
||||||
|
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
|
||||||
|
SERVER_HEADER_ALIASES = {
|
||||||
|
"hostname": "alias",
|
||||||
|
"alias": "alias",
|
||||||
|
"typ": "type",
|
||||||
|
"type": "type",
|
||||||
|
"ip server": "host",
|
||||||
|
"ip": "host",
|
||||||
|
"server": "host",
|
||||||
|
"username": "user",
|
||||||
|
"user": "user",
|
||||||
|
"password": "password",
|
||||||
|
}
|
||||||
|
|
||||||
|
KEYBOARD_HEADER_ALIASES = {
|
||||||
|
"description": "description",
|
||||||
|
"name": "name",
|
||||||
|
"ip address": "host",
|
||||||
|
"ip": "host",
|
||||||
|
"host": "host",
|
||||||
|
"ip host": "host",
|
||||||
|
"port": "port",
|
||||||
|
"ip port": "port",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value):
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_type(value: str) -> str:
|
||||||
|
lowered = value.lower()
|
||||||
|
if "geviscope" in lowered or "gsc" in lowered:
|
||||||
|
return "geviscope"
|
||||||
|
if "g-core" in lowered or "gcore" in lowered:
|
||||||
|
return "gcore"
|
||||||
|
return "gcore"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_sheets(contents: bytes):
|
||||||
|
values_wb = openpyxl.load_workbook(BytesIO(contents), data_only=True)
|
||||||
|
formulas_wb = openpyxl.load_workbook(BytesIO(contents), data_only=False)
|
||||||
|
return values_wb.active, formulas_wb.active
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_value(values_sheet, formulas_sheet, row: int, col: int) -> Tuple[Any, bool]:
|
||||||
|
if not col or col < 1:
|
||||||
|
return None, False
|
||||||
|
value = values_sheet.cell(row=row, column=col).value
|
||||||
|
if value is None:
|
||||||
|
fcell = formulas_sheet.cell(row=row, column=col)
|
||||||
|
if fcell.data_type == "f":
|
||||||
|
return None, True
|
||||||
|
return value, False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_servers_xlsx(contents: bytes) -> Dict[str, Any]:
|
||||||
|
sheet, formula_sheet = _load_sheets(contents)
|
||||||
|
formula_missing = 0
|
||||||
|
|
||||||
|
header_row = None
|
||||||
|
for row in range(1, min(15, sheet.max_row + 1)):
|
||||||
|
for col in range(1, min(10, sheet.max_column + 1)):
|
||||||
|
value, is_formula = _cell_value(sheet, formula_sheet, row, col)
|
||||||
|
formula_missing += 1 if is_formula else 0
|
||||||
|
cell = _clean(value).lower()
|
||||||
|
if cell == "hostname":
|
||||||
|
header_row = row
|
||||||
|
break
|
||||||
|
if header_row:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not header_row:
|
||||||
|
raise ValueError("Could not find header row with 'Hostname'")
|
||||||
|
|
||||||
|
header_map = {}
|
||||||
|
for col in range(1, sheet.max_column + 1):
|
||||||
|
value, is_formula = _cell_value(sheet, formula_sheet, header_row, col)
|
||||||
|
formula_missing += 1 if is_formula else 0
|
||||||
|
name = _clean(value).lower()
|
||||||
|
if name in SERVER_HEADER_ALIASES:
|
||||||
|
header_map[SERVER_HEADER_ALIASES[name]] = col
|
||||||
|
|
||||||
|
servers = []
|
||||||
|
skipped = 0
|
||||||
|
for row in range(header_row + 1, sheet.max_row + 1):
|
||||||
|
alias_value, alias_formula = _cell_value(sheet, formula_sheet, row, header_map.get("alias", 0))
|
||||||
|
host_value, host_formula = _cell_value(sheet, formula_sheet, row, header_map.get("host", 0))
|
||||||
|
formula_missing += int(alias_formula) + int(host_formula)
|
||||||
|
alias = _clean(alias_value)
|
||||||
|
host = _clean(host_value)
|
||||||
|
if not alias or not host:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
type_value, type_formula = _cell_value(sheet, formula_sheet, row, header_map.get("type", 0))
|
||||||
|
user_value, user_formula = _cell_value(sheet, formula_sheet, row, header_map.get("user", 0))
|
||||||
|
pass_value, pass_formula = _cell_value(sheet, formula_sheet, row, header_map.get("password", 0))
|
||||||
|
formula_missing += int(type_formula) + int(user_formula) + int(pass_formula)
|
||||||
|
server_type = _normalize_type(_clean(type_value))
|
||||||
|
user = _clean(user_value) or "sysadmin"
|
||||||
|
password = _clean(pass_value)
|
||||||
|
servers.append({
|
||||||
|
"alias": alias,
|
||||||
|
"host": host,
|
||||||
|
"user": user,
|
||||||
|
"password": password,
|
||||||
|
"type": server_type,
|
||||||
|
"enabled": True,
|
||||||
|
"deactivateEcho": False,
|
||||||
|
"deactivateLiveCheck": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"servers": servers, "skipped": skipped, "formula_cells_missing": formula_missing}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_actions_xlsx(contents: bytes) -> Dict[str, Any]:
|
||||||
|
sheet, formula_sheet = _load_sheets(contents)
|
||||||
|
formula_missing = 0
|
||||||
|
|
||||||
|
header_row = None
|
||||||
|
for row in range(1, min(15, sheet.max_row + 1)):
|
||||||
|
for col in range(1, min(20, sheet.max_column + 1)):
|
||||||
|
value, is_formula = _cell_value(sheet, formula_sheet, row, col)
|
||||||
|
formula_missing += 1 if is_formula else 0
|
||||||
|
if _clean(value).lower() == "camera id":
|
||||||
|
header_row = row
|
||||||
|
break
|
||||||
|
if header_row:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not header_row:
|
||||||
|
raise ValueError("Could not find header row with 'Camera ID'")
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
for col in range(1, sheet.max_column + 1):
|
||||||
|
value, is_formula = _cell_value(sheet, formula_sheet, header_row, col)
|
||||||
|
formula_missing += 1 if is_formula else 0
|
||||||
|
name = _clean(value)
|
||||||
|
if name:
|
||||||
|
headers.setdefault(name, []).append(col)
|
||||||
|
|
||||||
|
def col(name, idx=0):
|
||||||
|
values = headers.get(name, [])
|
||||||
|
if idx < len(values):
|
||||||
|
return values[idx]
|
||||||
|
return None
|
||||||
|
|
||||||
|
compact_mode = all(
|
||||||
|
name in headers
|
||||||
|
for name in [
|
||||||
|
"Input Action",
|
||||||
|
"Input Category",
|
||||||
|
"Input Caption",
|
||||||
|
"Output Action",
|
||||||
|
"Output Caption",
|
||||||
|
"Output Server Alias",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
for row in range(header_row + 1, sheet.max_row + 1):
|
||||||
|
camera_value, camera_formula = _cell_value(sheet, formula_sheet, row, col("Camera ID"))
|
||||||
|
formula_missing += 1 if camera_formula else 0
|
||||||
|
camera_id = _clean(camera_value)
|
||||||
|
if not camera_id:
|
||||||
|
continue
|
||||||
|
if compact_mode:
|
||||||
|
input_caption = _clean(_cell_value(sheet, formula_sheet, row, col("Input Caption"))[0])
|
||||||
|
output_server = _clean(_cell_value(sheet, formula_sheet, row, col("Output Server Alias"))[0])
|
||||||
|
entry = {
|
||||||
|
"cameraId": camera_id,
|
||||||
|
"server": _clean(_cell_value(sheet, formula_sheet, row, col("Server"))[0]) or output_server,
|
||||||
|
"serverType": _clean(_cell_value(sheet, formula_sheet, row, col("Server Type"))[0]),
|
||||||
|
"ptz": _clean(_cell_value(sheet, formula_sheet, row, col("PTZ"))[0]),
|
||||||
|
"caption": input_caption,
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"kind": "output1",
|
||||||
|
"category": _clean(_cell_value(sheet, formula_sheet, row, col("Input Category"))[0]),
|
||||||
|
"action": _clean(_cell_value(sheet, formula_sheet, row, col("Input Action"))[0]),
|
||||||
|
"videoInput": camera_id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
server_type = _clean(_cell_value(sheet, formula_sheet, row, col("Server Type"))[0]).lower()
|
||||||
|
output_action = _clean(_cell_value(sheet, formula_sheet, row, col("Output Action"))[0])
|
||||||
|
output_caption = _clean(_cell_value(sheet, formula_sheet, row, col("Output Caption"))[0])
|
||||||
|
output_entry = {
|
||||||
|
"category": "",
|
||||||
|
"action": output_action,
|
||||||
|
"caption": output_caption,
|
||||||
|
"server": output_server,
|
||||||
|
"ptzHead": camera_id,
|
||||||
|
"speed": "",
|
||||||
|
}
|
||||||
|
if "g-core" in server_type or "gcore" in server_type or "gng" in server_type:
|
||||||
|
output_entry["kind"] = "gcore"
|
||||||
|
else:
|
||||||
|
output_entry["kind"] = "gsc"
|
||||||
|
entry["outputs"].append(output_entry)
|
||||||
|
else:
|
||||||
|
input_value, input_formula = _cell_value(sheet, formula_sheet, row, col("Caption", 0))
|
||||||
|
formula_missing += 1 if input_formula else 0
|
||||||
|
input_caption = _clean(input_value)
|
||||||
|
entry = {
|
||||||
|
"cameraId": camera_id,
|
||||||
|
"server": _clean(_cell_value(sheet, formula_sheet, row, col("Server"))[0]),
|
||||||
|
"serverType": _clean(_cell_value(sheet, formula_sheet, row, col("Server Type"))[0]),
|
||||||
|
"ptz": _clean(_cell_value(sheet, formula_sheet, row, col("PTZ"))[0]),
|
||||||
|
"caption": input_caption,
|
||||||
|
"outputs": [
|
||||||
|
{
|
||||||
|
"kind": "output1",
|
||||||
|
"category": _clean(_cell_value(sheet, formula_sheet, row, col("Category", 0))[0]),
|
||||||
|
"action": _clean(_cell_value(sheet, formula_sheet, row, col("Action", 0))[0]),
|
||||||
|
"videoInput": _clean(_cell_value(sheet, formula_sheet, row, col("VideoInput", 0))[0]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "gsc",
|
||||||
|
"category": _clean(_cell_value(sheet, formula_sheet, row, col("Category", 1))[0]),
|
||||||
|
"action": _clean(_cell_value(sheet, formula_sheet, row, col("Action", 1))[0]),
|
||||||
|
"caption": _clean(_cell_value(sheet, formula_sheet, row, col("Caption", 1))[0]),
|
||||||
|
"server": _clean(_cell_value(sheet, formula_sheet, row, col("GeviScope alias", 0))[0]),
|
||||||
|
"ptzHead": _clean(_cell_value(sheet, formula_sheet, row, col("PTZ head", 0))[0]),
|
||||||
|
"speed": _clean(_cell_value(sheet, formula_sheet, row, col("speed", 0))[0]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"kind": "gcore",
|
||||||
|
"category": _clean(_cell_value(sheet, formula_sheet, row, col("Category", 2))[0]),
|
||||||
|
"action": _clean(_cell_value(sheet, formula_sheet, row, col("Action", 2))[0]),
|
||||||
|
"caption": _clean(_cell_value(sheet, formula_sheet, row, col("Caption", 2))[0]),
|
||||||
|
"server": _clean(_cell_value(sheet, formula_sheet, row, col("G-Core alias", 0))[0]),
|
||||||
|
"ptzHead": _clean(_cell_value(sheet, formula_sheet, row, col("PTZ head", 1))[0]),
|
||||||
|
"speed": _clean(_cell_value(sheet, formula_sheet, row, col("speed", 1))[0]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
rows.append(entry)
|
||||||
|
|
||||||
|
return {"rows": rows, "formula_cells_missing": formula_missing}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_keyboards_xlsx(contents: bytes) -> Dict[str, Any]:
|
||||||
|
sheet, formula_sheet = _load_sheets(contents)
|
||||||
|
formula_missing = 0
|
||||||
|
|
||||||
|
header_row = None
|
||||||
|
for row in range(1, min(15, sheet.max_row + 1)):
|
||||||
|
for col in range(1, min(10, sheet.max_column + 1)):
|
||||||
|
value, is_formula = _cell_value(sheet, formula_sheet, row, col)
|
||||||
|
formula_missing += 1 if is_formula else 0
|
||||||
|
cell = _clean(value).lower()
|
||||||
|
if cell in ("description", "name"):
|
||||||
|
header_row = row
|
||||||
|
break
|
||||||
|
if header_row:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not header_row:
|
||||||
|
raise ValueError("Could not find header row with 'Name' or 'Description'")
|
||||||
|
|
||||||
|
header_map = {}
|
||||||
|
for col in range(1, sheet.max_column + 1):
|
||||||
|
value, is_formula = _cell_value(sheet, formula_sheet, header_row, col)
|
||||||
|
formula_missing += 1 if is_formula else 0
|
||||||
|
name = _clean(value).lower()
|
||||||
|
if name in KEYBOARD_HEADER_ALIASES:
|
||||||
|
header_map[KEYBOARD_HEADER_ALIASES[name]] = col
|
||||||
|
|
||||||
|
keyboards = []
|
||||||
|
skipped = 0
|
||||||
|
for row in range(header_row + 1, sheet.max_row + 1):
|
||||||
|
name_value, name_formula = _cell_value(sheet, formula_sheet, row, header_map.get("name", 0))
|
||||||
|
desc_value, desc_formula = _cell_value(sheet, formula_sheet, row, header_map.get("description", 0))
|
||||||
|
host_value, host_formula = _cell_value(sheet, formula_sheet, row, header_map.get("host", 0))
|
||||||
|
port_value, port_formula = _cell_value(sheet, formula_sheet, row, header_map.get("port", 0))
|
||||||
|
formula_missing += int(name_formula) + int(desc_formula) + int(host_formula) + int(port_formula)
|
||||||
|
name = _clean(name_value)
|
||||||
|
description = _clean(desc_value)
|
||||||
|
host = _clean(host_value)
|
||||||
|
port_raw = _clean(port_value)
|
||||||
|
if not name and not description and not host and not port_raw:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if not host or not port_raw:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
port = int(float(port_raw))
|
||||||
|
except ValueError:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
keyboards.append({"name": name, "description": description, "host": host, "port": port})
|
||||||
|
|
||||||
|
return {"keyboards": keyboards, "skipped": skipped, "formula_cells_missing": formula_missing}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_video_outputs_xlsx(contents: bytes) -> Dict[str, Any]:
|
||||||
|
sheet, formula_sheet = _load_sheets(contents)
|
||||||
|
formula_missing = 0
|
||||||
|
|
||||||
|
header_row = None
|
||||||
|
for row in range(1, min(15, sheet.max_row + 1)):
|
||||||
|
for col in range(1, min(10, sheet.max_column + 1)):
|
||||||
|
value, is_formula = _cell_value(sheet, formula_sheet, row, col)
|
||||||
|
formula_missing += 1 if is_formula else 0
|
||||||
|
cell = _clean(value).lower().replace(" ", "")
|
||||||
|
if cell in ("localid", "local_id", "local"):
|
||||||
|
header_row = row
|
||||||
|
break
|
||||||
|
if header_row:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not header_row:
|
||||||
|
raise ValueError("Could not find header row with 'Local ID'")
|
||||||
|
|
||||||
|
header_map = {}
|
||||||
|
for col in range(1, sheet.max_column + 1):
|
||||||
|
value, is_formula = _cell_value(sheet, formula_sheet, header_row, col)
|
||||||
|
formula_missing += 1 if is_formula else 0
|
||||||
|
name = _clean(value).lower().replace(" ", "")
|
||||||
|
if name in ("localid", "local_id", "local"):
|
||||||
|
header_map["localId"] = col
|
||||||
|
elif name in ("globalid", "global_id", "global"):
|
||||||
|
header_map["globalId"] = col
|
||||||
|
elif name == "name":
|
||||||
|
header_map["name"] = col
|
||||||
|
elif name in ("description", "desc"):
|
||||||
|
header_map["description"] = col
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
skipped = 0
|
||||||
|
for row in range(header_row + 1, sheet.max_row + 1):
|
||||||
|
local_value, local_formula = _cell_value(sheet, formula_sheet, row, header_map.get("localId", 0))
|
||||||
|
global_value, global_formula = _cell_value(sheet, formula_sheet, row, header_map.get("globalId", 0))
|
||||||
|
name_value, name_formula = _cell_value(sheet, formula_sheet, row, header_map.get("name", 0))
|
||||||
|
desc_value, desc_formula = _cell_value(sheet, formula_sheet, row, header_map.get("description", 0))
|
||||||
|
formula_missing += int(local_formula) + int(global_formula) + int(name_formula) + int(desc_formula)
|
||||||
|
local_raw = _clean(local_value)
|
||||||
|
global_raw = _clean(global_value)
|
||||||
|
if not local_raw and not global_raw and not name_value and not desc_value:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
local_id = int(float(local_raw)) if local_raw else None
|
||||||
|
except ValueError:
|
||||||
|
local_id = None
|
||||||
|
try:
|
||||||
|
global_id = int(float(global_raw)) if global_raw else None
|
||||||
|
except ValueError:
|
||||||
|
global_id = None
|
||||||
|
outputs.append({
|
||||||
|
"localId": local_id,
|
||||||
|
"globalId": global_id,
|
||||||
|
"name": _clean(name_value),
|
||||||
|
"description": _clean(desc_value),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"outputs": outputs, "skipped": skipped, "formula_cells_missing": formula_missing}
|
||||||
|
|
||||||
|
def build_video_outputs_xlsx(outputs: List[Dict[str, Any]]) -> bytes:
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "VideoOutputs"
|
||||||
|
ws.append(["Local ID", "Global ID", "Name", "Description"])
|
||||||
|
for row in outputs:
|
||||||
|
ws.append([
|
||||||
|
row.get("localId"),
|
||||||
|
row.get("globalId"),
|
||||||
|
row.get("name", ""),
|
||||||
|
row.get("description", ""),
|
||||||
|
])
|
||||||
|
out = BytesIO()
|
||||||
|
wb.save(out)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def build_logging_xlsx(rows: List[Dict[str, Any]]) -> bytes:
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Logging"
|
||||||
|
ws.append(["Action name", "Log into database", "Notify user", "Notify GeViCom"])
|
||||||
|
for row in rows:
|
||||||
|
ws.append([
|
||||||
|
row.get("actionName", ""),
|
||||||
|
"X" if row.get("db") else "",
|
||||||
|
"X" if row.get("user") else "",
|
||||||
|
"X" if row.get("com") else "",
|
||||||
|
])
|
||||||
|
out = BytesIO()
|
||||||
|
wb.save(out)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_logging_xlsx(contents: bytes) -> Dict[str, Any]:
|
||||||
|
sheet, formula_sheet = _load_sheets(contents)
|
||||||
|
formula_missing = 0
|
||||||
|
|
||||||
|
header_row = None
|
||||||
|
for row in range(1, min(15, sheet.max_row + 1)):
|
||||||
|
for col in range(1, min(10, sheet.max_column + 1)):
|
||||||
|
value, is_formula = _cell_value(sheet, formula_sheet, row, col)
|
||||||
|
formula_missing += 1 if is_formula else 0
|
||||||
|
cell = _clean(value).lower().replace(" ", "")
|
||||||
|
if cell in ("actionname", "action", "actionid", "action_id"):
|
||||||
|
header_row = row
|
||||||
|
break
|
||||||
|
if header_row:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not header_row:
|
||||||
|
raise ValueError("Could not find header row with 'Action name'")
|
||||||
|
|
||||||
|
header_map = {}
|
||||||
|
for col in range(1, sheet.max_column + 1):
|
||||||
|
value, is_formula = _cell_value(sheet, formula_sheet, header_row, col)
|
||||||
|
formula_missing += 1 if is_formula else 0
|
||||||
|
name = _clean(value).lower().replace(" ", "")
|
||||||
|
if name in ("actionname", "action", "actionid", "action_id"):
|
||||||
|
header_map["actionName"] = col
|
||||||
|
elif name in ("logintodatabase", "logtodatabase", "database", "db"):
|
||||||
|
header_map["db"] = col
|
||||||
|
elif name in ("notifyuser", "user"):
|
||||||
|
header_map["user"] = col
|
||||||
|
elif name in ("notifygevicom", "gevicom", "com"):
|
||||||
|
header_map["com"] = col
|
||||||
|
|
||||||
|
def _to_bool(value: Any) -> bool:
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return value != 0
|
||||||
|
text = _clean(value).lower()
|
||||||
|
return text in ("x", "yes", "true", "1", "y")
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
skipped = 0
|
||||||
|
for row in range(header_row + 1, sheet.max_row + 1):
|
||||||
|
action_value, action_formula = _cell_value(sheet, formula_sheet, row, header_map.get("actionName", 0))
|
||||||
|
db_value, db_formula = _cell_value(sheet, formula_sheet, row, header_map.get("db", 0))
|
||||||
|
user_value, user_formula = _cell_value(sheet, formula_sheet, row, header_map.get("user", 0))
|
||||||
|
com_value, com_formula = _cell_value(sheet, formula_sheet, row, header_map.get("com", 0))
|
||||||
|
formula_missing += int(action_formula) + int(db_formula) + int(user_formula) + int(com_formula)
|
||||||
|
action_name = _clean(action_value)
|
||||||
|
if not action_name and not db_value and not user_value and not com_value:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if not action_name:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
rows.append({
|
||||||
|
"actionName": action_name,
|
||||||
|
"db": _to_bool(db_value),
|
||||||
|
"user": _to_bool(user_value),
|
||||||
|
"com": _to_bool(com_value),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"rows": rows, "skipped": skipped, "formula_cells_missing": formula_missing}
|
||||||
1398
backend/app/geviset.py
Normal file
1398
backend/app/geviset.py
Normal file
File diff suppressed because it is too large
Load Diff
353
backend/app/main.py
Normal file
353
backend/app/main.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import copy
|
||||||
|
import tempfile
|
||||||
|
import subprocess
|
||||||
|
from fastapi import FastAPI, UploadFile, File, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import json
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from . import geviset
|
||||||
|
from . import excel
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
app = FastAPI(title="GeViSet Web")
|
||||||
|
|
||||||
|
_LAST_TREE = None
|
||||||
|
_ROOT_DIR = Path(__file__).resolve().parents[2]
|
||||||
|
_SCRIPT_PATH = _ROOT_DIR / "scripts" / "build_mapping.js"
|
||||||
|
_TEMPLATE_RULES = _ROOT_DIR / "frontend" / "src" / "templates" / "templateRules.json"
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/set/parse")
|
||||||
|
async def parse_set(file: UploadFile = File(...)):
|
||||||
|
if not file.filename.lower().endswith(".set"):
|
||||||
|
raise HTTPException(status_code=400, detail="Only .set files are supported")
|
||||||
|
contents = await file.read()
|
||||||
|
try:
|
||||||
|
tree = geviset.load_set_bytes(contents)
|
||||||
|
mapping = geviset.extract_mapping(tree)
|
||||||
|
gcore = geviset.extract_gcore(tree)
|
||||||
|
gsc = geviset.extract_gsc(tree)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
global _LAST_TREE
|
||||||
|
_LAST_TREE = tree
|
||||||
|
return {
|
||||||
|
"filename": file.filename,
|
||||||
|
"token": "last",
|
||||||
|
"tree": tree,
|
||||||
|
"mapping": mapping,
|
||||||
|
"gcore": gcore,
|
||||||
|
"gsc": gsc,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/set/export")
|
||||||
|
async def export_set(payload: dict):
|
||||||
|
try:
|
||||||
|
tree = payload.get("tree")
|
||||||
|
token = payload.get("token")
|
||||||
|
if tree is None:
|
||||||
|
if token == "last" and _LAST_TREE is not None:
|
||||||
|
tree = copy.deepcopy(_LAST_TREE)
|
||||||
|
else:
|
||||||
|
raise ValueError("Missing tree in payload")
|
||||||
|
if "mapping" in payload:
|
||||||
|
geviset.apply_mapping(tree, payload["mapping"])
|
||||||
|
mapping_for_ids = geviset.extract_mapping(tree)
|
||||||
|
camera_ids = set()
|
||||||
|
ptz_by_id = {}
|
||||||
|
for rule in payload["mapping"].get("rules", []):
|
||||||
|
inp = rule.get("input", {})
|
||||||
|
vid = inp.get("videoInputId")
|
||||||
|
if isinstance(vid, int) and vid > 0 and isinstance(inp.get("ptz"), bool):
|
||||||
|
ptz_by_id[vid] = inp["ptz"]
|
||||||
|
else:
|
||||||
|
name = inp.get("name", "")
|
||||||
|
match = re.search(r"(\\d+)$", str(name))
|
||||||
|
if match and isinstance(inp.get("ptz"), bool):
|
||||||
|
ptz_by_id[int(match.group(1))] = inp["ptz"]
|
||||||
|
|
||||||
|
for rule in mapping_for_ids.get("rules", []):
|
||||||
|
inp = rule.get("input", {})
|
||||||
|
vid = inp.get("videoInputId")
|
||||||
|
if isinstance(vid, int) and vid > 0:
|
||||||
|
camera_ids.add(vid)
|
||||||
|
else:
|
||||||
|
name = inp.get("name", "")
|
||||||
|
match = re.search(r"(\\d+)$", str(name))
|
||||||
|
if match:
|
||||||
|
camera_ids.add(int(match.group(1)))
|
||||||
|
for field in rule.get("fields", []):
|
||||||
|
if field.get("name") == "VideoInput":
|
||||||
|
val = field.get("value")
|
||||||
|
if isinstance(val, int) and val > 0:
|
||||||
|
camera_ids.add(val)
|
||||||
|
elif isinstance(val, str) and val.isdigit():
|
||||||
|
camera_ids.add(int(val))
|
||||||
|
for out in rule.get("outputs", []):
|
||||||
|
action = out.get("action", "")
|
||||||
|
match = re.search(r"Camera:\\s*(\\d+)", action)
|
||||||
|
if match:
|
||||||
|
camera_ids.add(int(match.group(1)))
|
||||||
|
geviset.ensure_video_inputs(tree, camera_ids, ptz_by_id)
|
||||||
|
geviset.ensure_global_video_inputs(tree, camera_ids, ptz_by_id)
|
||||||
|
geviset.ensure_vx3_video_inputs(tree, camera_ids, ptz_by_id)
|
||||||
|
geviset.prune_video_inputs(tree, camera_ids)
|
||||||
|
print(
|
||||||
|
f"EXPORT camera_ids={len(camera_ids)} contains_101027={101027 in camera_ids}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if "gcore" in payload:
|
||||||
|
geviset.apply_gcore(tree, payload["gcore"])
|
||||||
|
if "gsc" in payload:
|
||||||
|
geviset.apply_gsc(tree, payload["gsc"])
|
||||||
|
if "keyboards" in payload:
|
||||||
|
geviset.apply_keyboards(tree, payload["keyboards"])
|
||||||
|
if "videoOutputs" in payload:
|
||||||
|
geviset.apply_video_outputs(tree, payload["videoOutputs"])
|
||||||
|
if "logging" in payload:
|
||||||
|
geviset.apply_logging(tree, payload["logging"])
|
||||||
|
data = geviset.save_set_bytes(tree)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([data]),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=updated.set"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/excel/servers")
|
||||||
|
async def import_servers(file: UploadFile = File(...)):
|
||||||
|
if not file.filename.lower().endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
|
||||||
|
contents = await file.read()
|
||||||
|
try:
|
||||||
|
parsed = excel.parse_servers_xlsx(contents)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/excel/action-mappings")
|
||||||
|
async def import_action_mappings(file: UploadFile = File(...)):
|
||||||
|
if not file.filename.lower().endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
|
||||||
|
contents = await file.read()
|
||||||
|
try:
|
||||||
|
parsed = excel.parse_actions_xlsx(contents)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/excel/keyboards")
|
||||||
|
async def import_keyboards(file: UploadFile = File(...)):
|
||||||
|
if not file.filename.lower().endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
|
||||||
|
contents = await file.read()
|
||||||
|
try:
|
||||||
|
parsed = excel.parse_keyboards_xlsx(contents)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/excel/video-outputs")
|
||||||
|
async def export_video_outputs(payload: dict):
|
||||||
|
try:
|
||||||
|
tree = payload.get("tree")
|
||||||
|
if tree is None:
|
||||||
|
raise ValueError("Missing tree in payload")
|
||||||
|
outputs = geviset.extract_video_outputs(tree)
|
||||||
|
data = excel.build_video_outputs_xlsx(outputs)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([data]),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=video_outputs.xlsx"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/excel/video-outputs/import")
|
||||||
|
async def import_video_outputs(file: UploadFile = File(...)):
|
||||||
|
if not file.filename.lower().endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
|
||||||
|
contents = await file.read()
|
||||||
|
try:
|
||||||
|
parsed = excel.parse_video_outputs_xlsx(contents)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/excel/logging")
|
||||||
|
async def export_logging(payload: dict):
|
||||||
|
try:
|
||||||
|
tree = payload.get("tree")
|
||||||
|
if tree is None:
|
||||||
|
raise ValueError("Missing tree in payload")
|
||||||
|
rows = geviset.extract_logging(tree)
|
||||||
|
data = excel.build_logging_xlsx(rows)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([data]),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=logging.xlsx"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/excel/logging/import")
|
||||||
|
async def import_logging(file: UploadFile = File(...)):
|
||||||
|
if not file.filename.lower().endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
|
||||||
|
contents = await file.read()
|
||||||
|
try:
|
||||||
|
parsed = excel.parse_logging_xlsx(contents)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/batch/build")
|
||||||
|
async def build_from_excel(
|
||||||
|
base_set: UploadFile = File(...),
|
||||||
|
servers: UploadFile = File(None),
|
||||||
|
actions: UploadFile = File(None),
|
||||||
|
outputs: UploadFile = File(None),
|
||||||
|
keyboards: UploadFile = File(None),
|
||||||
|
logging: UploadFile = File(None),
|
||||||
|
):
|
||||||
|
if not base_set.filename.lower().endswith(".set"):
|
||||||
|
raise HTTPException(status_code=400, detail="Base file must be .set")
|
||||||
|
try:
|
||||||
|
tree = geviset.load_set_bytes(await base_set.read())
|
||||||
|
|
||||||
|
if actions and actions.filename:
|
||||||
|
if not actions.filename.lower().endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=400, detail="Actions must be .xlsx")
|
||||||
|
actions_parsed = excel.parse_actions_xlsx(await actions.read())
|
||||||
|
mapping = geviset.extract_mapping(tree)
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
map_in = Path(tmpdir) / "mapping_in.json"
|
||||||
|
rows_in = Path(tmpdir) / "actions_rows.json"
|
||||||
|
map_out = Path(tmpdir) / "mapping_out.json"
|
||||||
|
map_in.write_text(json.dumps(mapping), encoding="utf-8")
|
||||||
|
rows_in.write_text(json.dumps(actions_parsed["rows"]), encoding="utf-8")
|
||||||
|
subprocess.check_call(
|
||||||
|
["node", str(_SCRIPT_PATH), str(map_in), str(rows_in), str(_TEMPLATE_RULES), str(map_out)]
|
||||||
|
)
|
||||||
|
mapping_updated = json.loads(map_out.read_text(encoding="utf-8"))
|
||||||
|
geviset.apply_mapping(tree, mapping_updated)
|
||||||
|
|
||||||
|
mapping_for_ids = geviset.extract_mapping(tree)
|
||||||
|
camera_ids = set()
|
||||||
|
ptz_by_id = {}
|
||||||
|
for rule in mapping_updated.get("rules", []):
|
||||||
|
inp = rule.get("input", {})
|
||||||
|
vid = inp.get("videoInputId")
|
||||||
|
if isinstance(vid, int) and vid > 0 and isinstance(inp.get("ptz"), bool):
|
||||||
|
ptz_by_id[vid] = inp["ptz"]
|
||||||
|
else:
|
||||||
|
name = inp.get("name", "")
|
||||||
|
match = re.search(r"(\\d+)$", str(name))
|
||||||
|
if match and isinstance(inp.get("ptz"), bool):
|
||||||
|
ptz_by_id[int(match.group(1))] = inp["ptz"]
|
||||||
|
|
||||||
|
for rule in mapping_for_ids.get("rules", []):
|
||||||
|
inp = rule.get("input", {})
|
||||||
|
vid = inp.get("videoInputId")
|
||||||
|
if isinstance(vid, int) and vid > 0:
|
||||||
|
camera_ids.add(vid)
|
||||||
|
else:
|
||||||
|
name = inp.get("name", "")
|
||||||
|
match = re.search(r"(\\d+)$", str(name))
|
||||||
|
if match:
|
||||||
|
camera_ids.add(int(match.group(1)))
|
||||||
|
for field in rule.get("fields", []):
|
||||||
|
if field.get("name") == "VideoInput":
|
||||||
|
val = field.get("value")
|
||||||
|
if isinstance(val, int) and val > 0:
|
||||||
|
camera_ids.add(val)
|
||||||
|
elif isinstance(val, str) and val.isdigit():
|
||||||
|
camera_ids.add(int(val))
|
||||||
|
for out in rule.get("outputs", []):
|
||||||
|
action = out.get("action", "")
|
||||||
|
match = re.search(r"Camera:\\s*(\\d+)", action)
|
||||||
|
if match:
|
||||||
|
camera_ids.add(int(match.group(1)))
|
||||||
|
|
||||||
|
geviset.ensure_video_inputs(tree, camera_ids, ptz_by_id)
|
||||||
|
geviset.ensure_global_video_inputs(tree, camera_ids, ptz_by_id)
|
||||||
|
geviset.ensure_vx3_video_inputs(tree, camera_ids, ptz_by_id)
|
||||||
|
geviset.prune_video_inputs(tree, camera_ids)
|
||||||
|
|
||||||
|
if servers and servers.filename:
|
||||||
|
if not servers.filename.lower().endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=400, detail="Servers must be .xlsx")
|
||||||
|
servers_parsed = excel.parse_servers_xlsx(await servers.read())
|
||||||
|
gcore_list = [s for s in servers_parsed["servers"] if s.get("type") == "gcore"]
|
||||||
|
gsc_list = [s for s in servers_parsed["servers"] if s.get("type") == "geviscope"]
|
||||||
|
for idx, s in enumerate(gcore_list, start=1):
|
||||||
|
s["id"] = str(idx)
|
||||||
|
for idx, s in enumerate(gsc_list, start=1):
|
||||||
|
s["id"] = str(idx)
|
||||||
|
bundle_gcore = geviset.extract_gcore(tree)
|
||||||
|
bundle_gsc = geviset.extract_gsc(tree)
|
||||||
|
bundle_gcore["servers"] = gcore_list
|
||||||
|
bundle_gsc["servers"] = gsc_list
|
||||||
|
geviset.apply_gcore(tree, bundle_gcore)
|
||||||
|
geviset.apply_gsc(tree, bundle_gsc)
|
||||||
|
|
||||||
|
if outputs and outputs.filename:
|
||||||
|
if not outputs.filename.lower().endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=400, detail="Outputs must be .xlsx")
|
||||||
|
outputs_parsed = excel.parse_video_outputs_xlsx(await outputs.read())
|
||||||
|
geviset.apply_video_outputs(tree, outputs_parsed["outputs"])
|
||||||
|
|
||||||
|
if keyboards and keyboards.filename:
|
||||||
|
if not keyboards.filename.lower().endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=400, detail="Keyboards must be .xlsx")
|
||||||
|
keyboards_parsed = excel.parse_keyboards_xlsx(await keyboards.read())
|
||||||
|
geviset.apply_keyboards(tree, keyboards_parsed["keyboards"])
|
||||||
|
|
||||||
|
if logging and logging.filename:
|
||||||
|
if not logging.filename.lower().endswith(".xlsx"):
|
||||||
|
raise HTTPException(status_code=400, detail="Logging must be .xlsx")
|
||||||
|
logging_parsed = excel.parse_logging_xlsx(await logging.read())
|
||||||
|
geviset.apply_logging(tree, logging_parsed["rows"])
|
||||||
|
|
||||||
|
data = geviset.save_set_bytes(tree)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([data]),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=combined.set"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
static_dir = os.getenv("STATIC_DIR")
|
||||||
|
if static_dir and os.path.isdir(static_dir):
|
||||||
|
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
|
||||||
4
backend/requirements.txt
Normal file
4
backend/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn==0.30.6
|
||||||
|
python-multipart==0.0.9
|
||||||
|
openpyxl==3.1.5
|
||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
gevisetweb:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8002:8002"
|
||||||
|
environment:
|
||||||
|
- STATIC_DIR=/app/static
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>GeViSet Web</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1731
frontend/package-lock.json
generated
Normal file
1731
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "geviset-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.1",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"typescript": "^5.6.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
158
frontend/src/App.tsx
Normal file
158
frontend/src/App.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const API_BASE = (() => {
|
||||||
|
const override = import.meta.env.VITE_API_BASE;
|
||||||
|
if (override) return override;
|
||||||
|
if (window.location.port === "5173") {
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}:8002`;
|
||||||
|
}
|
||||||
|
return window.location.origin;
|
||||||
|
})();
|
||||||
|
const BUILD_STAMP = new Date().toISOString();
|
||||||
|
|
||||||
|
type Slot = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
accept: string;
|
||||||
|
file: File | null;
|
||||||
|
setFile: (file: File | null) => void;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatSize(file: File) {
|
||||||
|
const kb = file.size / 1024;
|
||||||
|
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
||||||
|
return `${(kb / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const [busyLabel, setBusyLabel] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [baseFile, setBaseFile] = useState<File | null>(null);
|
||||||
|
const [serversFile, setServersFile] = useState<File | null>(null);
|
||||||
|
const [actionsFile, setActionsFile] = useState<File | null>(null);
|
||||||
|
const [outputsFile, setOutputsFile] = useState<File | null>(null);
|
||||||
|
const [keyboardsFile, setKeyboardsFile] = useState<File | null>(null);
|
||||||
|
const [loggingFile, setLoggingFile] = useState<File | null>(null);
|
||||||
|
|
||||||
|
const slots: Slot[] = [
|
||||||
|
{ id: "base", label: "Base .set", accept: ".set", file: baseFile, setFile: setBaseFile, required: true },
|
||||||
|
{ id: "servers", label: "Servers", accept: ".xlsx", file: serversFile, setFile: setServersFile },
|
||||||
|
{ id: "actions", label: "Actions", accept: ".xlsx", file: actionsFile, setFile: setActionsFile },
|
||||||
|
{ id: "outputs", label: "Outputs", accept: ".xlsx", file: outputsFile, setFile: setOutputsFile },
|
||||||
|
{ id: "keyboards", label: "Keyboards", accept: ".xlsx", file: keyboardsFile, setFile: setKeyboardsFile },
|
||||||
|
{ id: "logging", label: "Logging", accept: ".xlsx", file: loggingFile, setFile: setLoggingFile },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleBuild = async () => {
|
||||||
|
if (!baseFile) {
|
||||||
|
setStatus("Select a base .set file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("base_set", baseFile);
|
||||||
|
if (serversFile) body.append("servers", serversFile);
|
||||||
|
if (actionsFile) body.append("actions", actionsFile);
|
||||||
|
if (outputsFile) body.append("outputs", outputsFile);
|
||||||
|
if (keyboardsFile) body.append("keyboards", keyboardsFile);
|
||||||
|
if (loggingFile) body.append("logging", loggingFile);
|
||||||
|
|
||||||
|
setStatus("Building combined .set...");
|
||||||
|
setBusyLabel("Building .set");
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = window.setTimeout(() => controller.abort(), 300000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/batch/build`, {
|
||||||
|
method: "POST",
|
||||||
|
body,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setStatus(await res.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "combined.set";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
setStatus("Combined .set downloaded.");
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.name === "AbortError") {
|
||||||
|
setStatus("Build timed out.");
|
||||||
|
} else {
|
||||||
|
setStatus("Build failed.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
setBusyLabel(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shell simple">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar__brand">
|
||||||
|
<div className="logo">GV</div>
|
||||||
|
<div>
|
||||||
|
<div className="topbar__title">GeViSet Builder</div>
|
||||||
|
<div className="topbar__subtitle">Batch .set generator <EFBFBD> {BUILD_STAMP}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="topbar__actions">
|
||||||
|
<div className="statusbar">
|
||||||
|
{busyLabel && <span className="spinner" />}
|
||||||
|
{busyLabel ?? status ?? "Ready."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="simple__body">
|
||||||
|
<section className="card">
|
||||||
|
<div className="card__header">
|
||||||
|
<div>
|
||||||
|
<h2>Build Combined .set</h2>
|
||||||
|
<p>Select the base .set and any Excel files you want to apply. Only selected files are used.</p>
|
||||||
|
</div>
|
||||||
|
<button className="chip" onClick={handleBuild}>
|
||||||
|
Build .set
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card__content">
|
||||||
|
{slots.map((slot) => (
|
||||||
|
<div key={slot.id} className="file-block">
|
||||||
|
<label className="file-row">
|
||||||
|
{slot.label}{slot.required ? " *" : ""}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept={slot.accept}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0] || null;
|
||||||
|
slot.setFile(file);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{slot.file ? (
|
||||||
|
<div className="file-meta">
|
||||||
|
<span className="file-name">{slot.file.name}</span>
|
||||||
|
<span className="file-size">{formatSize(slot.file)}</span>
|
||||||
|
<button className="chip chip--ghost" onClick={() => slot.setFile(null)}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="file-meta file-meta--empty">Not selected</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
531
frontend/src/styles.css
Normal file
531
frontend/src/styles.css
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap");
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0d12;
|
||||||
|
--panel: #0e141d;
|
||||||
|
--panel-2: #0b1118;
|
||||||
|
--grid: #1c2634;
|
||||||
|
--edge: #2a374a;
|
||||||
|
--text: #e6edf6;
|
||||||
|
--muted: #90a0b3;
|
||||||
|
--accent: #4de3b8;
|
||||||
|
--accent-2: #3aa0ff;
|
||||||
|
--danger: #ff5d5d;
|
||||||
|
--chip: #121a26;
|
||||||
|
--chip-border: #2b394f;
|
||||||
|
font-family: "IBM Plex Sans", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background-color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(18, 25, 35, 0.7) 0%, rgba(8, 11, 16, 0.9) 100%),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgba(255, 255, 255, 0.02) 0px,
|
||||||
|
rgba(255, 255, 255, 0.02) 1px,
|
||||||
|
transparent 1px,
|
||||||
|
transparent 28px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 18px;
|
||||||
|
border-bottom: 1px solid var(--edge);
|
||||||
|
background: linear-gradient(180deg, #101723 0%, #0a0d12 100%);
|
||||||
|
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__brand {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
|
||||||
|
color: #041017;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__subtitle {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusbar {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
background: rgba(10, 15, 22, 0.8);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(77, 227, 184, 0.25);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 240px 1fr;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple__body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: min(900px, 100%);
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
border-bottom: 1px solid var(--edge);
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__header p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card__content {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-block {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta--empty {
|
||||||
|
color: rgba(144, 160, 179, 0.7);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
color: var(--text);
|
||||||
|
font-family: \"IBM Plex Mono\", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
font-family: \"IBM Plex Mono\", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--edge);
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__section {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(12, 18, 26, 0.9);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__title {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__hint {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--muted);
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 0;
|
||||||
|
border-bottom: 1px solid var(--edge);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat strong {
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel--dark {
|
||||||
|
background: #0a0f16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__header h2 {
|
||||||
|
margin: 0 0 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
border: 1px solid var(--chip-border);
|
||||||
|
background: var(--chip);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab--active {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(77, 227, 184, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
border: 1px solid var(--chip-border);
|
||||||
|
background: var(--chip);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip input,
|
||||||
|
.file-row input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip--ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--edge);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip--danger {
|
||||||
|
border-color: rgba(255, 93, 93, 0.5);
|
||||||
|
color: #ffd1d1;
|
||||||
|
background: rgba(255, 93, 93, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0b121b;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row--button {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search {
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
min-width: 160px;
|
||||||
|
background: #0b121b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table__head,
|
||||||
|
.table__row {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--servers .table__head,
|
||||||
|
.table--servers .table__row {
|
||||||
|
grid-template-columns: 1.1fr 1fr 0.8fr 0.5fr 0.45fr 0.45fr 0.5fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--actions .table__head,
|
||||||
|
.table--actions .table__row {
|
||||||
|
grid-template-columns: 1.1fr 0.9fr 1.1fr 0.9fr 1.2fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--keyboards .table__head,
|
||||||
|
.table--keyboards .table__row {
|
||||||
|
grid-template-columns: 1.2fr 1fr 0.6fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--outputs .table__head,
|
||||||
|
.table--outputs .table__row {
|
||||||
|
grid-template-columns: 0.5fr 0.6fr 1.6fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table__cell {
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0b121b;
|
||||||
|
color: inherit;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table__cell--head {
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table__cell--text {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table__cell--actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: #0b121b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px rgba(77, 227, 184, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle span {
|
||||||
|
width: 30px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #182232;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--edge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle span::after {
|
||||||
|
content: "";
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
position: absolute;
|
||||||
|
top: 1px;
|
||||||
|
left: 1px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d7e3f1;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + span {
|
||||||
|
background: rgba(77, 227, 184, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle input:checked + span::after {
|
||||||
|
transform: translateX(14px);
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.shell__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--edge);
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.table__head,
|
||||||
|
.table__row {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
1798
frontend/src/templates/templateRules.json
Normal file
1798
frontend/src/templates/templateRules.json
Normal file
File diff suppressed because it is too large
Load Diff
16
frontend/tsconfig.json
Normal file
16
frontend/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
10
frontend/vite.config.ts
Normal file
10
frontend/vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
host: "0.0.0.0",
|
||||||
|
port: 5173
|
||||||
|
}
|
||||||
|
});
|
||||||
BIN
samples/01_base.set
Normal file
BIN
samples/01_base.set
Normal file
Binary file not shown.
BIN
samples/02_test.xlsx
Normal file
BIN
samples/02_test.xlsx
Normal file
Binary file not shown.
BIN
samples/03_exported.set
Normal file
BIN
samples/03_exported.set
Normal file
Binary file not shown.
BIN
samples/04_GeViSet.set
Normal file
BIN
samples/04_GeViSet.set
Normal file
Binary file not shown.
BIN
samples/2601008_Actions_MKS_filled_test.xlsx
Normal file
BIN
samples/2601008_Actions_MKS_filled_test.xlsx
Normal file
Binary file not shown.
BIN
samples/2601017_Actions_MKS_V2.xlsx
Normal file
BIN
samples/2601017_Actions_MKS_V2.xlsx
Normal file
Binary file not shown.
BIN
samples/260107_Servery_MKS.xlsx
Normal file
BIN
samples/260107_Servery_MKS.xlsx
Normal file
Binary file not shown.
BIN
samples/260107_Servery_MKS_P6_import.xlsx
Normal file
BIN
samples/260107_Servery_MKS_P6_import.xlsx
Normal file
Binary file not shown.
BIN
samples/260108_Actions_MKS_filled.xlsx
Normal file
BIN
samples/260108_Actions_MKS_filled.xlsx
Normal file
Binary file not shown.
BIN
samples/260108_Actions_MKS_filled_V2_test.xlsx
Normal file
BIN
samples/260108_Actions_MKS_filled_V2_test.xlsx
Normal file
Binary file not shown.
BIN
samples/260109_Actions_MKS_P6_import.xlsx
Normal file
BIN
samples/260109_Actions_MKS_P6_import.xlsx
Normal file
Binary file not shown.
BIN
samples/260109_Actions_MKS_P6_import_new.xlsx
Normal file
BIN
samples/260109_Actions_MKS_P6_import_new.xlsx
Normal file
Binary file not shown.
BIN
samples/260109_Actions_MKS_P6_import_test.xlsx
Normal file
BIN
samples/260109_Actions_MKS_P6_import_test.xlsx
Normal file
Binary file not shown.
BIN
samples/260109_Actions_MKS_filled_test.xlsx
Normal file
BIN
samples/260109_Actions_MKS_filled_test.xlsx
Normal file
Binary file not shown.
BIN
samples/260109_MKS_P6.set
Normal file
BIN
samples/260109_MKS_P6.set
Normal file
Binary file not shown.
BIN
samples/260109_MKS_P6_V2.set
Normal file
BIN
samples/260109_MKS_P6_V2.set
Normal file
Binary file not shown.
BIN
samples/260109_MKS_P6_V3.set
Normal file
BIN
samples/260109_MKS_P6_V3.set
Normal file
Binary file not shown.
BIN
samples/260109_MKS_P6_V4_mbeg.set
Normal file
BIN
samples/260109_MKS_P6_V4_mbeg.set
Normal file
Binary file not shown.
BIN
samples/260109_MKS_P6_V5.set
Normal file
BIN
samples/260109_MKS_P6_V5.set
Normal file
Binary file not shown.
BIN
samples/260114_MKS_P6.set
Normal file
BIN
samples/260114_MKS_P6.set
Normal file
Binary file not shown.
BIN
samples/260115_MKS_ALL_V3.set
Normal file
BIN
samples/260115_MKS_ALL_V3.set
Normal file
Binary file not shown.
BIN
samples/260115_MKS_P6.set
Normal file
BIN
samples/260115_MKS_P6.set
Normal file
Binary file not shown.
BIN
samples/260115_MKS_P6_V2.set
Normal file
BIN
samples/260115_MKS_P6_V2.set
Normal file
Binary file not shown.
BIN
samples/260127_MKS_ALL.set
Normal file
BIN
samples/260127_MKS_ALL.set
Normal file
Binary file not shown.
BIN
samples/260128_MKS_ALL.set
Normal file
BIN
samples/260128_MKS_ALL.set
Normal file
Binary file not shown.
BIN
samples/260209/260209_MKS_Actions_import_complete.xlsx
Normal file
BIN
samples/260209/260209_MKS_Actions_import_complete.xlsx
Normal file
Binary file not shown.
BIN
samples/260209/260209_MKS_Keyboards_import.xlsx
Normal file
BIN
samples/260209/260209_MKS_Keyboards_import.xlsx
Normal file
Binary file not shown.
BIN
samples/260209/260209_MKS_Outputs.xlsx
Normal file
BIN
samples/260209/260209_MKS_Outputs.xlsx
Normal file
Binary file not shown.
BIN
samples/260209/260209_MKS_Servers_import_complete.xlsx
Normal file
BIN
samples/260209/260209_MKS_Servers_import_complete.xlsx
Normal file
Binary file not shown.
BIN
samples/260209/260209_MKS_logging_import.xlsx
Normal file
BIN
samples/260209/260209_MKS_logging_import.xlsx
Normal file
Binary file not shown.
BIN
samples/260209/GeViSoft_base.set
Normal file
BIN
samples/260209/GeViSoft_base.set
Normal file
Binary file not shown.
1
samples/260209/_actions_rows.json
Normal file
1
samples/260209/_actions_rows.json
Normal file
File diff suppressed because one or more lines are too long
1
samples/260209/_mapping_in.json
Normal file
1
samples/260209/_mapping_in.json
Normal file
File diff suppressed because one or more lines are too long
1
samples/260209/_mapping_out.json
Normal file
1
samples/260209/_mapping_out.json
Normal file
File diff suppressed because one or more lines are too long
BIN
samples/260209/combined_260209.set
Normal file
BIN
samples/260209/combined_260209.set
Normal file
Binary file not shown.
BIN
samples/260209/combined_260209_v2.set
Normal file
BIN
samples/260209/combined_260209_v2.set
Normal file
Binary file not shown.
BIN
samples/260209/combined_260209_v3.set
Normal file
BIN
samples/260209/combined_260209_v3.set
Normal file
Binary file not shown.
BIN
samples/260209/combined_260209_v4.set
Normal file
BIN
samples/260209/combined_260209_v4.set
Normal file
Binary file not shown.
BIN
samples/260209_MKS_Outputs.xlsx
Normal file
BIN
samples/260209_MKS_Outputs.xlsx
Normal file
Binary file not shown.
BIN
samples/Actions_Compact_Template.xlsx
Normal file
BIN
samples/Actions_Compact_Template.xlsx
Normal file
Binary file not shown.
BIN
samples/Client GeViIO.png
Normal file
BIN
samples/Client GeViIO.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
samples/Keyboards_Template.xlsx
Normal file
BIN
samples/Keyboards_Template.xlsx
Normal file
Binary file not shown.
BIN
samples/MKS_Keyboards_import.xlsx
Normal file
BIN
samples/MKS_Keyboards_import.xlsx
Normal file
Binary file not shown.
BIN
samples/MKS_Servers_import_complete.xlsx
Normal file
BIN
samples/MKS_Servers_import_complete.xlsx
Normal file
Binary file not shown.
BIN
samples/No_Video_input_VirtualVX3.png
Normal file
BIN
samples/No_Video_input_VirtualVX3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
samples/Video_input_mapping_settings.png
Normal file
BIN
samples/Video_input_mapping_settings.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
BIN
samples/combined (1).set
Normal file
BIN
samples/combined (1).set
Normal file
Binary file not shown.
BIN
samples/combined (2).set
Normal file
BIN
samples/combined (2).set
Normal file
Binary file not shown.
BIN
samples/updated (70).set
Normal file
BIN
samples/updated (70).set
Normal file
Binary file not shown.
BIN
samples/updated (70)_fiixed.set
Normal file
BIN
samples/updated (70)_fiixed.set
Normal file
Binary file not shown.
BIN
samples/updated (71).set
Normal file
BIN
samples/updated (71).set
Normal file
Binary file not shown.
BIN
samples/updated (71)_fiixed.set
Normal file
BIN
samples/updated (71)_fiixed.set
Normal file
Binary file not shown.
BIN
samples/updated (72).set
Normal file
BIN
samples/updated (72).set
Normal file
Binary file not shown.
BIN
samples/updated (73).set
Normal file
BIN
samples/updated (73).set
Normal file
Binary file not shown.
BIN
samples/updated (74).set
Normal file
BIN
samples/updated (74).set
Normal file
Binary file not shown.
BIN
samples/updated (75).set
Normal file
BIN
samples/updated (75).set
Normal file
Binary file not shown.
BIN
samples/updated (76).set
Normal file
BIN
samples/updated (76).set
Normal file
Binary file not shown.
BIN
samples/updated (77).set
Normal file
BIN
samples/updated (77).set
Normal file
Binary file not shown.
BIN
samples/updated (78).set
Normal file
BIN
samples/updated (78).set
Normal file
Binary file not shown.
400
scripts/build_mapping.js
Normal file
400
scripts/build_mapping.js
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const mapping = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
||||||
|
const rows = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
|
||||||
|
const templateRules = JSON.parse(fs.readFileSync(process.argv[4], 'utf8'));
|
||||||
|
|
||||||
|
const isTelemetryTemplate = (rule) => {
|
||||||
|
const filters = (rule.filters || []).map((f) => f.name);
|
||||||
|
const fields = (rule.fields || []).map((f) => f.name);
|
||||||
|
return (
|
||||||
|
filters.includes('.VideoInput') &&
|
||||||
|
!filters.includes('.Position') &&
|
||||||
|
!filters.includes('.VideoOutput') &&
|
||||||
|
fields.includes('VideoInput') &&
|
||||||
|
!fields.includes('Position')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deriveActionName = (inputCaption) => {
|
||||||
|
if (!inputCaption) return '';
|
||||||
|
let name = inputCaption.trim();
|
||||||
|
name = name.replace(/^gevi[\s_]+/i, '');
|
||||||
|
name = name.replace(/[_\s]\d+$/i, '');
|
||||||
|
return name.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalize = (value) => (value || '').trim().toLowerCase();
|
||||||
|
const normalizeInputName = (value) => (value || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
const findTemplate = (kind) => {
|
||||||
|
if (kind === 'telemetry') {
|
||||||
|
const preferred = mapping.rules.find((rule) => {
|
||||||
|
if (!isTelemetryTemplate(rule)) return false;
|
||||||
|
const name = String(rule.input?.name || '');
|
||||||
|
return /^GeVi\s+(Pan|Tilt|Zoom|Focus)/i.test(name);
|
||||||
|
});
|
||||||
|
if (preferred) return preferred;
|
||||||
|
return (
|
||||||
|
mapping.rules.find((rule) => isTelemetryTemplate(rule)) ||
|
||||||
|
(templateRules.telemetry || []).find((rule) => isTelemetryTemplate(rule)) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
mapping.rules.find((rule) => {
|
||||||
|
const filters = (rule.filters || []).map((f) => f.name);
|
||||||
|
const fields = (rule.fields || []).map((f) => f.name);
|
||||||
|
return (
|
||||||
|
filters.includes('.SwitchMode') &&
|
||||||
|
filters.includes('.VideoInput') &&
|
||||||
|
filters.includes('.VideoOutput') &&
|
||||||
|
fields.includes('SwitchMode') &&
|
||||||
|
fields.includes('VideoOutput')
|
||||||
|
);
|
||||||
|
}) ||
|
||||||
|
(templateRules.crossswitch || []).find((rule) => {
|
||||||
|
const filters = (rule.filters || []).map((f) => f.name);
|
||||||
|
const fields = (rule.fields || []).map((f) => f.name);
|
||||||
|
return (
|
||||||
|
filters.includes('.SwitchMode') &&
|
||||||
|
filters.includes('.VideoInput') &&
|
||||||
|
filters.includes('.VideoOutput') &&
|
||||||
|
fields.includes('SwitchMode') &&
|
||||||
|
fields.includes('VideoOutput')
|
||||||
|
);
|
||||||
|
}) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultPosFallback =
|
||||||
|
(templateRules.defaultpos || []).find((rule) => {
|
||||||
|
const filters = (rule.filters || []).map((f) => f.name);
|
||||||
|
const fields = (rule.fields || []).map((f) => f.name);
|
||||||
|
return filters.includes('.Position') || fields.includes('Position');
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
const fallbackTemplate = mapping.rules[0];
|
||||||
|
const telemetryTemplate = findTemplate('telemetry') || fallbackTemplate;
|
||||||
|
const crossSwitchTemplate = findTemplate('crossswitch') || fallbackTemplate;
|
||||||
|
const defaultPosTemplate =
|
||||||
|
mapping.rules.find((rule) => {
|
||||||
|
const filters = (rule.filters || []).map((f) => f.name);
|
||||||
|
const fields = (rule.fields || []).map((f) => f.name);
|
||||||
|
return filters.includes('.Position') || fields.includes('Position');
|
||||||
|
}) ||
|
||||||
|
defaultPosFallback ||
|
||||||
|
fallbackTemplate;
|
||||||
|
|
||||||
|
const findTelemetryActionTemplate = (actionName) => {
|
||||||
|
if (!actionName) return null;
|
||||||
|
const needle = actionName.toLowerCase();
|
||||||
|
return (
|
||||||
|
mapping.rules.find((rule) => {
|
||||||
|
const name = String(rule.input?.name || '').toLowerCase();
|
||||||
|
if (!name.startsWith('gevi ')) return false;
|
||||||
|
if (!name.includes(needle)) return false;
|
||||||
|
if (name.includes('prepos')) return false;
|
||||||
|
return isTelemetryTemplate(rule);
|
||||||
|
}) ||
|
||||||
|
(templateRules.telemetry || []).find((rule) => {
|
||||||
|
const name = String(rule.input?.name || '').toLowerCase();
|
||||||
|
if (!name.startsWith('gevi ')) return false;
|
||||||
|
if (!name.includes(needle)) return false;
|
||||||
|
if (name.includes('prepos')) return false;
|
||||||
|
return isTelemetryTemplate(rule);
|
||||||
|
}) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDefaultPosTemplate = (actionName) => {
|
||||||
|
if (!actionName) return null;
|
||||||
|
const needle = actionName.toLowerCase();
|
||||||
|
return (
|
||||||
|
mapping.rules.find((rule) => {
|
||||||
|
const name = String(rule.input?.name || '').toLowerCase();
|
||||||
|
const filters = (rule.filters || []).map((f) => f.name);
|
||||||
|
const fields = (rule.fields || []).map((f) => f.name);
|
||||||
|
if (!name.startsWith('gevi ')) return false;
|
||||||
|
if (!name.includes(needle)) return false;
|
||||||
|
return filters.includes('.Position') || fields.includes('Position');
|
||||||
|
}) ||
|
||||||
|
(templateRules.defaultpos || []).find((rule) => {
|
||||||
|
const name = String(rule.input?.name || '').toLowerCase();
|
||||||
|
const filters = (rule.filters || []).map((f) => f.name);
|
||||||
|
const fields = (rule.fields || []).map((f) => f.name);
|
||||||
|
if (!name.startsWith('gevi ')) return false;
|
||||||
|
if (!name.includes(needle)) return false;
|
||||||
|
return filters.includes('.Position') || fields.includes('Position');
|
||||||
|
}) ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const outputKey = (inputName, o) => [normalize(inputName), normalize(o.actionField), normalize(o.action), normalize(o.server)].join('|');
|
||||||
|
|
||||||
|
const ruleIndexByName = new Map();
|
||||||
|
const ruleIndexByCamera = new Map();
|
||||||
|
const ruleIndexByAction = new Map();
|
||||||
|
const existingKeys = new Set();
|
||||||
|
const flagTemplates = new Map();
|
||||||
|
const fieldFlagCounts = new Map();
|
||||||
|
|
||||||
|
mapping.rules.forEach((rule, idx) => {
|
||||||
|
const normalized = normalizeInputName(rule.input.name);
|
||||||
|
ruleIndexByName.set(normalized, idx);
|
||||||
|
const match = normalized.match(/(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
const cam = match[1];
|
||||||
|
if (!ruleIndexByCamera.has(cam)) ruleIndexByCamera.set(cam, []);
|
||||||
|
ruleIndexByCamera.get(cam).push(idx);
|
||||||
|
}
|
||||||
|
for (const out of rule.outputs || []) {
|
||||||
|
existingKeys.add(outputKey(rule.input.name, out));
|
||||||
|
const key = [normalize(out.actionField), normalize(out.action)].join('|');
|
||||||
|
if (!flagTemplates.has(key)) {
|
||||||
|
flagTemplates.set(key, { ...out.flags });
|
||||||
|
}
|
||||||
|
const fieldKey = normalize(out.actionField);
|
||||||
|
const flagPair = `${out.flags.primary}|${out.flags.secondary}`;
|
||||||
|
if (!fieldFlagCounts.has(fieldKey)) fieldFlagCounts.set(fieldKey, new Map());
|
||||||
|
const counts = fieldFlagCounts.get(fieldKey);
|
||||||
|
counts.set(flagPair, (counts.get(flagPair) || 0) + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallbackFlags = (actionField) => {
|
||||||
|
const counts = fieldFlagCounts.get(normalize(actionField));
|
||||||
|
if (!counts) return { primary: 0, secondary: 0 };
|
||||||
|
let best = '';
|
||||||
|
let bestCount = -1;
|
||||||
|
counts.forEach((count, key) => {
|
||||||
|
if (count > bestCount) {
|
||||||
|
bestCount = count;
|
||||||
|
best = key;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [primary, secondary] = best.split('|').map((val) => Number(val));
|
||||||
|
return { primary: primary || 0, secondary: secondary || 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
let maxRuleId = Math.max(0, ...mapping.rules.map((r) => Number(r.id || 0)));
|
||||||
|
|
||||||
|
const isCrossSwitchAction = (actionName, category) => /crossswitch/i.test(actionName) || /crossbar/i.test(category);
|
||||||
|
const DEFAULTPOS_ACTIONS = {
|
||||||
|
MoveToDefaultPosition: 'DefaultPosCallUp',
|
||||||
|
ClearDefaultPosition: 'DefaultPosClear',
|
||||||
|
SaveDefaultPosition: 'DefaultPosSave',
|
||||||
|
};
|
||||||
|
const normalizeOutputAction = (actionName) => DEFAULTPOS_ACTIONS[actionName] || actionName;
|
||||||
|
const isDefaultPosAction = (actionName) => Object.prototype.hasOwnProperty.call(DEFAULTPOS_ACTIONS, actionName);
|
||||||
|
const isFocusSpeedAction = (actionName) => /focusnear|focusfar/i.test(actionName);
|
||||||
|
const isFocusInputAction = (actionName) => /focusnear|focusfar|focusstop|focus/i.test(actionName);
|
||||||
|
const parseSpeed = (value) => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
};
|
||||||
|
const buildAction = (actionName, cameraId, speed) => {
|
||||||
|
if (!actionName) return '';
|
||||||
|
const params = [];
|
||||||
|
if (cameraId) params.push('Comment: ""');
|
||||||
|
if (cameraId) params.push(`Camera: ${cameraId}`);
|
||||||
|
if (speed !== undefined) params.push(`Speed: ${speed}`);
|
||||||
|
if (params.length) return `@ ${actionName} (${params.join(', ')})`;
|
||||||
|
return `@ ${actionName} ()`;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const output1 = row.outputs.find((o) => o.kind === 'output1');
|
||||||
|
const inputName = row.caption || `Camera ${row.cameraId}`;
|
||||||
|
const inputCategoryRaw = String(output1?.category ?? '');
|
||||||
|
const inputActionRaw = String(output1?.action ?? '').trim();
|
||||||
|
const inputActionName = inputActionRaw || deriveActionName(inputName);
|
||||||
|
const actionName = inputActionName;
|
||||||
|
const inputCategory = inputCategoryRaw.toLowerCase();
|
||||||
|
const cameraId = String(row.cameraId ?? '').trim();
|
||||||
|
const outputs = [];
|
||||||
|
const seenOutputs = new Set();
|
||||||
|
const serverType = String(row.serverType ?? '').toLowerCase();
|
||||||
|
const isGscTarget = serverType.includes('geviscope') || serverType.includes('gsc');
|
||||||
|
const isGcoreTarget = serverType.includes('g-core') || serverType.includes('gcore');
|
||||||
|
const prefer = isGscTarget ? 'gsc' : isGcoreTarget ? 'gcore' : 'both';
|
||||||
|
|
||||||
|
const gscOut = prefer !== 'gcore' ? row.outputs.find((o) => o.kind === 'gsc') : null;
|
||||||
|
if (gscOut) {
|
||||||
|
const gscActionRaw = String(gscOut.action ?? actionName).trim();
|
||||||
|
const gscActionName = normalizeOutputAction(gscActionRaw);
|
||||||
|
const gscCamera = String(gscOut.ptzHead ?? '').trim();
|
||||||
|
const gscSpeed = parseSpeed(gscOut.speed);
|
||||||
|
const gscSpeedValue = isFocusSpeedAction(gscActionName) ? gscSpeed : undefined;
|
||||||
|
const gscCategory = String(gscOut.category ?? '').toLowerCase();
|
||||||
|
const crossSwitch = isCrossSwitchAction(gscActionName, inputCategory) || /viewer/.test(gscCategory);
|
||||||
|
const gscCaption = gscOut.caption || `GSC ${gscActionName}${cameraId ? `_${cameraId}` : ''}`;
|
||||||
|
const gscServer = gscOut.server || (String(row.serverType ?? '').toLowerCase().includes('geviscope') ? row.server : '');
|
||||||
|
if (actionName || gscOut.caption || gscOut.category || gscOut.server || String(row.serverType ?? '').toLowerCase().includes('geviscope')) {
|
||||||
|
const gscAction = crossSwitch ? '@ ViewerConnectLive ()' : buildAction(gscActionName, gscCamera, gscSpeedValue);
|
||||||
|
const gscFlags = flagTemplates.get([normalize('GscAction'), normalize(gscAction)].join('|')) || fallbackFlags('GscAction');
|
||||||
|
const out = {
|
||||||
|
id: '1',
|
||||||
|
name: gscCaption,
|
||||||
|
flags: gscFlags,
|
||||||
|
actionField: 'GscAction',
|
||||||
|
serverField: 'GscServer',
|
||||||
|
action: gscAction,
|
||||||
|
server: gscServer || '',
|
||||||
|
};
|
||||||
|
const key = outputKey(inputName, out);
|
||||||
|
if (!seenOutputs.has(key) && !existingKeys.has(key)) {
|
||||||
|
outputs.push(out);
|
||||||
|
seenOutputs.add(key);
|
||||||
|
existingKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const gcoreOut = prefer !== 'gsc' ? row.outputs.find((o) => o.kind === 'gcore') : null;
|
||||||
|
if (gcoreOut) {
|
||||||
|
const gcoreActionRaw = String(gcoreOut.action ?? actionName).trim();
|
||||||
|
const gcoreActionName = normalizeOutputAction(gcoreActionRaw);
|
||||||
|
const gcoreCamera = String(gcoreOut.ptzHead ?? '').trim();
|
||||||
|
const gcoreSpeed = parseSpeed(gcoreOut.speed);
|
||||||
|
const gcoreSpeedValue = isFocusSpeedAction(gcoreActionName) ? gcoreSpeed : undefined;
|
||||||
|
const gcoreCategory = String(gcoreOut.category ?? '').toLowerCase();
|
||||||
|
const crossSwitch = isCrossSwitchAction(gcoreActionName, inputCategory) || /viewer/.test(gcoreCategory);
|
||||||
|
const gcoreCaption = gcoreOut.caption || `GNG ${gcoreActionName}${cameraId ? `_${cameraId}` : ''}`;
|
||||||
|
const gcoreServer = gcoreOut.server || (String(row.serverType ?? '').toLowerCase().includes('g-core') ? row.server : '');
|
||||||
|
if (actionName || gcoreOut.caption || gcoreOut.category || gcoreOut.server || String(row.serverType ?? '').toLowerCase().includes('g-core')) {
|
||||||
|
const gcoreAction = crossSwitch ? '@ ViewerConnectLive (Comment: "")' : buildAction(gcoreActionName, gcoreCamera, gcoreSpeedValue);
|
||||||
|
const gcoreFlags = flagTemplates.get([normalize('GCoreAction'), normalize(gcoreAction)].join('|')) || fallbackFlags('GCoreAction');
|
||||||
|
const out = {
|
||||||
|
id: '1',
|
||||||
|
name: gcoreCaption,
|
||||||
|
flags: gcoreFlags,
|
||||||
|
actionField: 'GCoreAction',
|
||||||
|
serverField: 'GCoreServer',
|
||||||
|
action: gcoreAction,
|
||||||
|
server: gcoreServer || '',
|
||||||
|
};
|
||||||
|
const key = outputKey(inputName, out);
|
||||||
|
if (!seenOutputs.has(key) && !existingKeys.has(key)) {
|
||||||
|
outputs.push(out);
|
||||||
|
seenOutputs.add(key);
|
||||||
|
existingKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = isCrossSwitchAction(actionName, inputCategory)
|
||||||
|
? 'crossswitch'
|
||||||
|
: isDefaultPosAction(actionName)
|
||||||
|
? 'defaultpos'
|
||||||
|
: 'telemetry';
|
||||||
|
|
||||||
|
let chosenTemplate = null;
|
||||||
|
if (kind === 'crossswitch') {
|
||||||
|
chosenTemplate = crossSwitchTemplate;
|
||||||
|
} else if (kind === 'defaultpos') {
|
||||||
|
chosenTemplate = findDefaultPosTemplate(normalizeOutputAction(actionName) || actionName) || defaultPosTemplate;
|
||||||
|
} else {
|
||||||
|
chosenTemplate = findTelemetryActionTemplate(actionName) || telemetryTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextFilters = chosenTemplate?.filters
|
||||||
|
? chosenTemplate.filters.map((f) => ({ ...f }))
|
||||||
|
: [];
|
||||||
|
const baseFilters = nextFilters;
|
||||||
|
const isTelemetry = kind === 'telemetry';
|
||||||
|
const nextFilterSet = isTelemetry
|
||||||
|
? baseFilters.map((f) => (f.name === '.VideoInput' ? { ...f, value: true } : f))
|
||||||
|
: baseFilters;
|
||||||
|
|
||||||
|
const baseFields = chosenTemplate?.fields ? [...chosenTemplate.fields] : [];
|
||||||
|
const nextFields = isTelemetry
|
||||||
|
? [...baseFields.filter((f) => f.name !== 'Position' && f.name !== 'VideoOutput'), ...(baseFields.some((f) => f.name === 'VideoInput') ? [] : [{ name: 'VideoInput', type: 'int32', value: 0 }])]
|
||||||
|
: baseFields;
|
||||||
|
|
||||||
|
const cleanedNextFields = isFocusInputAction(actionName) ? nextFields.filter((f) => f.name !== 'Temp') : nextFields;
|
||||||
|
|
||||||
|
const actionKey = actionName && cameraId ? `action:${normalize(actionName)}|cam:${cameraId}` : null;
|
||||||
|
let existingIdx = (actionKey ? ruleIndexByAction.get(actionKey) : undefined) ?? ruleIndexByName.get(normalizeInputName(inputName));
|
||||||
|
|
||||||
|
if (existingIdx === undefined && actionName && cameraId) {
|
||||||
|
const candidates = ruleIndexByCamera.get(cameraId) || [];
|
||||||
|
const actionNeedle = normalize(actionName);
|
||||||
|
for (const idx of candidates) {
|
||||||
|
const ruleName = normalizeInputName(mapping.rules[idx].input.name);
|
||||||
|
if (ruleName.includes(actionNeedle) && ruleName.endsWith(cameraId)) {
|
||||||
|
existingIdx = idx;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingIdx !== undefined) {
|
||||||
|
const existingRule = mapping.rules[existingIdx];
|
||||||
|
const existingOutputs = existingRule.outputs || [];
|
||||||
|
let nextId = Math.max(0, ...existingOutputs.map((o) => Number(o.id || 0))) + 1;
|
||||||
|
const mergedOutputs = [...existingOutputs];
|
||||||
|
for (const out of outputs) {
|
||||||
|
const key = outputKey(existingRule.input.name, out);
|
||||||
|
if (!existingKeys.has(key)) {
|
||||||
|
mergedOutputs.push({ ...out, id: String(nextId) });
|
||||||
|
nextId += 1;
|
||||||
|
existingKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const existingFilters = isTelemetry
|
||||||
|
? (existingRule.filters || []).filter((f) => f.name !== '.Position' && f.name !== '.VideoOutput').map((f) => (f.name === '.VideoInput' ? { ...f, value: true } : f))
|
||||||
|
: (existingRule.filters || []).map((f) => (f.name === '.VideoInput' ? { ...f, value: true } : f));
|
||||||
|
if (isTelemetry && !existingFilters.some((f) => f.name === '.VideoInput')) {
|
||||||
|
existingFilters.push({ name: '.VideoInput', type: 'bool', value: true });
|
||||||
|
}
|
||||||
|
const existingFields = isTelemetry
|
||||||
|
? (existingRule.fields || []).filter((f) => f.name !== 'Position' && f.name !== 'VideoOutput')
|
||||||
|
: (existingRule.fields || []);
|
||||||
|
if (isFocusInputAction(actionName)) {
|
||||||
|
for (let i = existingFields.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (existingFields[i]?.name === 'Temp') existingFields.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isTelemetry && !existingFields.some((f) => f.name === 'VideoInput')) {
|
||||||
|
existingFields.push({ name: 'VideoInput', type: 'int32', value: 0 });
|
||||||
|
}
|
||||||
|
mapping.rules[existingIdx] = {
|
||||||
|
...existingRule,
|
||||||
|
input: {
|
||||||
|
...existingRule.input,
|
||||||
|
videoInputId: existingRule.input.videoInputId ?? (cameraId ? Number(cameraId) : undefined),
|
||||||
|
ptz: typeof row.ptz === 'string' ? row.ptz.toUpperCase() === 'O' : existingRule.input.ptz,
|
||||||
|
temp: isFocusInputAction(actionName) ? undefined : existingRule.input.temp,
|
||||||
|
},
|
||||||
|
outputs: mergedOutputs,
|
||||||
|
filters: existingFilters,
|
||||||
|
fields: existingFields,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
maxRuleId += 1;
|
||||||
|
const ruleId = String(maxRuleId);
|
||||||
|
mapping.rules.push({
|
||||||
|
id: ruleId,
|
||||||
|
input: {
|
||||||
|
name: inputName,
|
||||||
|
flags: chosenTemplate?.input?.flags || { primary: 0, secondary: 0 },
|
||||||
|
videoInputId: cameraId ? Number(cameraId) : undefined,
|
||||||
|
ptz: typeof row.ptz === 'string' ? row.ptz.toUpperCase() === 'O' : undefined,
|
||||||
|
temp: isFocusInputAction(actionName) ? undefined : chosenTemplate?.input?.temp,
|
||||||
|
},
|
||||||
|
outputs,
|
||||||
|
_order: chosenTemplate?._order,
|
||||||
|
filters: nextFilterSet,
|
||||||
|
fields: cleanedNextFields,
|
||||||
|
});
|
||||||
|
ruleIndexByName.set(normalizeInputName(inputName), mapping.rules.length - 1);
|
||||||
|
if (actionKey) ruleIndexByAction.set(actionKey, mapping.rules.length - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(process.argv[5], JSON.stringify(mapping));
|
||||||
Reference in New Issue
Block a user