startnet editor v2: settings/menu forms, syntax-highlight+lint, backup/restore+diff
Rebuilt the /startnet editor (Fable/Opus 4-stage build) into tabs: - Settings: server IP (+ menu timeout/default when a choice construct exists) as form fields; apply rewrites only the targeted tokens. - Boot Menu: add/remove/reorder image entries; regenerates only the menu echo/dispatch + action blocks, refuses reorders that would desync the enrollment %choice% router. - Raw: full-text editor (still source of truth) with line-number gutter, batch syntax highlighting, a lint panel (unmatched goto/label, CRLF), and diff-vs-current. - History: timestamped snapshots on every save, per-row diff + restore. New wim.py helpers (framework-free): parse/apply_settings, parse/apply_boot_menu, lint_startnet, save/list/read_snapshot, unified_diff; BACKUPS_DIR=/var/lib/pxe-webapp/startnet-backups. Fable review fixed a CRITICAL pre-existing bug: update_startnet's newline=CRLF write retranslated posted CRLF into \r\r\n, corrupting boot.wim on every raw save; now normalizes to LF first. Also fixed a false CRLF lint warning (verbatim read) and menu payload validation. All JS inline (no CDN).
This commit is contained in:
244
webapp/app.py
244
webapp/app.py
@@ -932,29 +932,89 @@ def enrollment_delete(filename):
|
|||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes - startnet.cmd Editor (boot.wim)
|
# Routes - startnet.cmd Editor (boot.wim)
|
||||||
|
#
|
||||||
|
# The raw full-text editor (POST /startnet/save) is the source of truth and the
|
||||||
|
# always-available fallback. The Settings and Boot-menu tabs are conveniences
|
||||||
|
# that parse the CURRENT extracted content to prefill, then round-trip an
|
||||||
|
# apply_* result back through the SAME wim.update_startnet save path. Every save
|
||||||
|
# path snapshots the prior startnet.cmd first (feature 4) so a bad edit can be
|
||||||
|
# rolled back from the Snapshots tab.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _startnet_wiminfo():
|
||||||
|
"""Parse `wiminfo boot.wim` into a flat dict (empty on any failure)."""
|
||||||
|
import subprocess
|
||||||
|
info = {}
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["wiminfo", config.BOOT_WIM],
|
||||||
|
capture_output=True, text=True, timeout=15,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if ":" in line:
|
||||||
|
key, _, val = line.partition(":")
|
||||||
|
info[key.strip()] = val.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _startnet_current():
|
||||||
|
"""Extracted startnet.cmd text from boot.wim, or '' if unavailable."""
|
||||||
|
if not os.path.isfile(config.BOOT_WIM):
|
||||||
|
return ""
|
||||||
|
return wim.extract_startnet(config.BOOT_WIM) or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _startnet_save_content(new_content, prior, action, detail):
|
||||||
|
"""Snapshot `prior` then write `new_content` to boot.wim via wimtools.
|
||||||
|
|
||||||
|
Central save path shared by the raw editor, the settings tab, the menu tab,
|
||||||
|
and snapshot-restore so EVERY write snapshots first. Flashes success/failure
|
||||||
|
and returns True on success. Lint warnings are surfaced but never block.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
wim.save_snapshot(prior, note=action)
|
||||||
|
except Exception as exc:
|
||||||
|
# A snapshot failure should not silently drop the safety net; warn but
|
||||||
|
# still let the operator save (the raw editor is the source of truth).
|
||||||
|
flash(f"Warning: could not snapshot prior startnet.cmd: {exc}", "warning")
|
||||||
|
|
||||||
|
ok, err = wim.update_startnet(config.BOOT_WIM, new_content)
|
||||||
|
if not ok:
|
||||||
|
flash(f"Failed to update boot.wim: {err}", "danger")
|
||||||
|
return False
|
||||||
|
|
||||||
|
lint = wim.lint_startnet(new_content)
|
||||||
|
for e in lint.get("errors", []):
|
||||||
|
loc = f"line {e['line']}: " if e.get("line") else ""
|
||||||
|
flash(f"Lint error - {loc}{e['message']}", "warning")
|
||||||
|
for w in lint.get("warnings", []):
|
||||||
|
loc = f"line {w['line']}: " if w.get("line") else ""
|
||||||
|
flash(f"Lint warning - {loc}{w['message']}", "warning")
|
||||||
|
|
||||||
|
audit(action, detail)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
@app.route("/startnet")
|
@app.route("/startnet")
|
||||||
def startnet_editor():
|
def startnet_editor():
|
||||||
import subprocess
|
|
||||||
wim_exists = os.path.isfile(config.BOOT_WIM)
|
wim_exists = os.path.isfile(config.BOOT_WIM)
|
||||||
content = ""
|
content = ""
|
||||||
wim_info = {}
|
wim_info = {}
|
||||||
|
settings = {"server_ip": None, "menu_timeout": None, "menu_default": None}
|
||||||
|
boot_menu = []
|
||||||
|
lint = {"errors": [], "warnings": []}
|
||||||
|
|
||||||
if wim_exists:
|
if wim_exists:
|
||||||
content = wim.extract_startnet(config.BOOT_WIM) or ""
|
content = _startnet_current()
|
||||||
try:
|
wim_info = _startnet_wiminfo()
|
||||||
result = subprocess.run(
|
settings = wim.parse_settings(content)
|
||||||
["wiminfo", config.BOOT_WIM],
|
boot_menu = wim.parse_boot_menu(content)
|
||||||
capture_output=True, text=True, timeout=15,
|
lint = wim.lint_startnet(content)
|
||||||
)
|
|
||||||
if result.returncode == 0:
|
snapshots = wim.list_snapshots()
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
if ":" in line:
|
|
||||||
key, _, val = line.partition(":")
|
|
||||||
wim_info[key.strip()] = val.strip()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"startnet_editor.html",
|
"startnet_editor.html",
|
||||||
@@ -962,6 +1022,10 @@ def startnet_editor():
|
|||||||
wim_path=config.BOOT_WIM,
|
wim_path=config.BOOT_WIM,
|
||||||
content=content,
|
content=content,
|
||||||
wim_info=wim_info,
|
wim_info=wim_info,
|
||||||
|
settings=settings,
|
||||||
|
boot_menu=boot_menu,
|
||||||
|
lint=lint,
|
||||||
|
snapshots=snapshots,
|
||||||
image_types=config.IMAGE_TYPES,
|
image_types=config.IMAGE_TYPES,
|
||||||
friendly_names=config.FRIENDLY_NAMES,
|
friendly_names=config.FRIENDLY_NAMES,
|
||||||
)
|
)
|
||||||
@@ -974,15 +1038,157 @@ def startnet_save():
|
|||||||
return redirect(url_for("startnet_editor"))
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
content = request.form.get("content", "")
|
content = request.form.get("content", "")
|
||||||
ok, err = wim.update_startnet(config.BOOT_WIM, content)
|
prior = _startnet_current()
|
||||||
if ok:
|
if _startnet_save_content(content, prior, "STARTNET_SAVE", "boot.wim updated (raw)"):
|
||||||
audit("STARTNET_SAVE", "boot.wim updated")
|
|
||||||
flash("startnet.cmd updated successfully in boot.wim.", "success")
|
flash("startnet.cmd updated successfully in boot.wim.", "success")
|
||||||
else:
|
|
||||||
flash(f"Failed to update boot.wim: {err}", "danger")
|
|
||||||
return redirect(url_for("startnet_editor"))
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/startnet/settings", methods=["POST"])
|
||||||
|
def startnet_save_settings():
|
||||||
|
"""Apply the Settings tab into the CURRENT extracted content, then save.
|
||||||
|
|
||||||
|
Only server_ip / menu_timeout / menu_default are touched, via targeted
|
||||||
|
replacement (apply_settings never rebuilds the file). Blank fields are left
|
||||||
|
alone. The raw text stays the source of truth: we apply into what is on
|
||||||
|
boot.wim right now, not a rebuilt file.
|
||||||
|
"""
|
||||||
|
if not os.path.isfile(config.BOOT_WIM):
|
||||||
|
flash("boot.wim not found.", "danger")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
prior = _startnet_current()
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
ip = (request.form.get("server_ip") or "").strip()
|
||||||
|
if ip:
|
||||||
|
settings["server_ip"] = ip
|
||||||
|
|
||||||
|
to = (request.form.get("menu_timeout") or "").strip()
|
||||||
|
if to:
|
||||||
|
try:
|
||||||
|
settings["menu_timeout"] = int(to)
|
||||||
|
except ValueError:
|
||||||
|
flash("Menu timeout must be a whole number of seconds.", "danger")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
md = (request.form.get("menu_default") or "").strip()
|
||||||
|
if md:
|
||||||
|
settings["menu_default"] = md
|
||||||
|
|
||||||
|
new_content = wim.apply_settings(prior, settings)
|
||||||
|
if new_content == prior:
|
||||||
|
flash("No settings changed.", "info")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
if _startnet_save_content(new_content, prior, "STARTNET_SETTINGS",
|
||||||
|
f"settings applied: {settings}"):
|
||||||
|
flash("Startnet settings applied and saved to boot.wim.", "success")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/startnet/menu", methods=["POST"])
|
||||||
|
def startnet_save_menu():
|
||||||
|
"""Apply the Boot-menu tab into the CURRENT extracted content, then save.
|
||||||
|
|
||||||
|
The tab posts an `items` JSON array of {label, target, target_image}; list
|
||||||
|
ORDER defines the new 1..N numbering (any 'num' is ignored). apply_boot_menu
|
||||||
|
regenerates only the echo lines, choice count, dispatch, and action blocks;
|
||||||
|
existing block bodies are byte-for-byte preserved. It RAISES ValueError when
|
||||||
|
the structure is ambiguous or a renumber would desync the enrollment
|
||||||
|
%choice% router (e.g. reorder/remove of a cross-referenced image); we catch
|
||||||
|
it, flash the reason, and leave the raw text untouched.
|
||||||
|
"""
|
||||||
|
if not os.path.isfile(config.BOOT_WIM):
|
||||||
|
flash("boot.wim not found.", "danger")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
payload = request.form.get("items", "[]")
|
||||||
|
try:
|
||||||
|
items = json.loads(payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
flash("Invalid boot-menu payload.", "danger")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
if not isinstance(items, list):
|
||||||
|
flash("Boot-menu payload must be a list of items.", "danger")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
for it in items:
|
||||||
|
if not isinstance(it, dict) or not (it.get("target") or "").strip():
|
||||||
|
flash("Every boot-menu entry needs a non-empty Target.", "danger")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
prior = _startnet_current()
|
||||||
|
try:
|
||||||
|
new_content = wim.apply_boot_menu(prior, items)
|
||||||
|
except ValueError as exc:
|
||||||
|
flash(f"Boot menu not rewritten (kept raw text): {exc}", "danger")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
if new_content == prior:
|
||||||
|
flash("No boot-menu changes to save.", "info")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
if _startnet_save_content(new_content, prior, "STARTNET_MENU",
|
||||||
|
f"boot menu rebuilt ({len(items)} item(s))"):
|
||||||
|
flash("Boot menu rebuilt and saved to boot.wim.", "success")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/startnet/restore", methods=["POST"])
|
||||||
|
def startnet_restore_snapshot():
|
||||||
|
"""Restore a snapshot back through the save path.
|
||||||
|
|
||||||
|
Reads the snapshot (path-traversal safe), snapshots the ABOUT-TO-BE-REPLACED
|
||||||
|
current startnet first, then writes the restored bytes to boot.wim.
|
||||||
|
"""
|
||||||
|
if not os.path.isfile(config.BOOT_WIM):
|
||||||
|
flash("boot.wim not found.", "danger")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
snapshot_id = (request.form.get("snapshot_id") or "").strip()
|
||||||
|
restored = wim.read_snapshot(snapshot_id)
|
||||||
|
if restored is None:
|
||||||
|
flash(f"Snapshot not found: {snapshot_id}", "danger")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
prior = _startnet_current()
|
||||||
|
if _startnet_save_content(restored, prior, "STARTNET_RESTORE",
|
||||||
|
f"restored snapshot {snapshot_id}"):
|
||||||
|
flash(f"Restored startnet.cmd from {snapshot_id}.", "success")
|
||||||
|
return redirect(url_for("startnet_editor"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/startnet/diff", methods=["POST"])
|
||||||
|
def startnet_diff():
|
||||||
|
"""Return a unified diff as JSON. Two modes (body may be form or JSON):
|
||||||
|
|
||||||
|
- snapshot_id -> diff that snapshot (fromfile) vs current boot.wim (tofile)
|
||||||
|
- content -> diff current boot.wim (fromfile) vs pending edit (tofile)
|
||||||
|
|
||||||
|
JSON callers must send the CSRF token in the X-CSRF-Token header.
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
snapshot_id = (request.form.get("snapshot_id") or body.get("snapshot_id") or "").strip()
|
||||||
|
pending = request.form.get("content")
|
||||||
|
if pending is None:
|
||||||
|
pending = body.get("content")
|
||||||
|
|
||||||
|
current = _startnet_current()
|
||||||
|
|
||||||
|
if snapshot_id:
|
||||||
|
snap = wim.read_snapshot(snapshot_id)
|
||||||
|
if snap is None:
|
||||||
|
return jsonify({"error": f"snapshot not found: {snapshot_id}"}), 404
|
||||||
|
diff = wim.unified_diff(snap, current, fromfile=snapshot_id, tofile="current")
|
||||||
|
return jsonify({"diff": diff, "mode": "snapshot", "snapshot_id": snapshot_id})
|
||||||
|
|
||||||
|
if pending is not None:
|
||||||
|
diff = wim.unified_diff(current, pending, fromfile="current", tofile="pending")
|
||||||
|
return jsonify({"diff": diff, "mode": "pending"})
|
||||||
|
|
||||||
|
return jsonify({"error": "provide snapshot_id or content"}), 400
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Routes - Audit Log
|
# Routes - Audit Log
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
"""boot.wim manipulation via wimtools (wimextract / wimupdate / wimdir).
|
"""boot.wim manipulation via wimtools (wimextract / wimupdate / wimdir)
|
||||||
|
plus pure-Python helpers for the startnet.cmd editor.
|
||||||
|
|
||||||
Used by the startnet.cmd editor to extract + update the boot script
|
Two layers live here:
|
||||||
that runs when WinPE boots from PXE.
|
|
||||||
|
1. wimtools wrappers (extract_startnet / update_startnet / list_files) that
|
||||||
|
read + write startnet.cmd inside boot.wim. update_startnet writes CRLF.
|
||||||
|
|
||||||
|
2. Framework-free text helpers used by the rebuilt startnet.cmd editor:
|
||||||
|
settings-as-forms (parse_settings / apply_settings), the boot-menu builder
|
||||||
|
(parse_boot_menu / apply_boot_menu), lint_startnet, timestamped snapshots
|
||||||
|
(save_snapshot / list_snapshots / read_snapshot) and unified_diff.
|
||||||
|
|
||||||
|
The helpers hold NO Flask imports on purpose; the routes stage wires them up.
|
||||||
|
startnet.cmd is a WinPE BATCH script with CRLF endings. Every regen helper
|
||||||
|
preserves the file's existing line ending and only rewrites the exact lines it
|
||||||
|
targets, leaving everything else byte-for-byte. When a structure cannot be
|
||||||
|
located unambiguously the regen helpers raise ValueError so the caller can fall
|
||||||
|
back to the raw full-text editor (the source of truth).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import difflib
|
||||||
|
import glob
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
# Root-writable dir for timestamped startnet.cmd snapshots. Overridable by env
|
||||||
|
# so tests / alt deploys can redirect it. save_snapshot() creates it on demand.
|
||||||
|
BACKUPS_DIR = os.environ.get(
|
||||||
|
"STARTNET_BACKUPS_DIR", "/var/lib/pxe-webapp/startnet-backups"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_startnet(wim_path):
|
def extract_startnet(wim_path):
|
||||||
@@ -22,7 +48,11 @@ def extract_startnet(wim_path):
|
|||||||
)
|
)
|
||||||
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
||||||
if result.returncode == 0 and os.path.isfile(startnet_path):
|
if result.returncode == 0 and os.path.isfile(startnet_path):
|
||||||
with open(startnet_path, "r", encoding="utf-8", errors="replace") as fh:
|
# newline="" keeps the file's CRLF bytes verbatim; default universal
|
||||||
|
# newlines would fold CRLF to LF, breaking the lint CRLF check and
|
||||||
|
# storing LF-only snapshots.
|
||||||
|
with open(startnet_path, "r", encoding="utf-8", errors="replace",
|
||||||
|
newline="") as fh:
|
||||||
return fh.read()
|
return fh.read()
|
||||||
return None
|
return None
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -38,6 +68,10 @@ def update_startnet(wim_path, content):
|
|||||||
"""
|
"""
|
||||||
tmpdir = tempfile.mkdtemp()
|
tmpdir = tempfile.mkdtemp()
|
||||||
try:
|
try:
|
||||||
|
# Normalize to LF first: newline="\r\n" translates each bare \n, so
|
||||||
|
# CRLF input (browser textareas post CRLF) would otherwise become
|
||||||
|
# \r\r\n. After normalizing, output is always clean CRLF.
|
||||||
|
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
startnet_path = os.path.join(tmpdir, "startnet.cmd")
|
||||||
with open(startnet_path, "w", encoding="utf-8", newline="\r\n") as fh:
|
with open(startnet_path, "w", encoding="utf-8", newline="\r\n") as fh:
|
||||||
fh.write(content)
|
fh.write(content)
|
||||||
@@ -68,3 +102,626 @@ def list_files(wim_path, path="/"):
|
|||||||
return []
|
return []
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Pure text helpers for the startnet.cmd editor (no Flask, no wimtools).
|
||||||
|
# All operate on the startnet.cmd text. Regen helpers preserve the file's
|
||||||
|
# existing line ending and only touch their target lines.
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
def _detect_eol(content):
|
||||||
|
"""Return the dominant line ending, defaulting to CRLF for WinPE batch."""
|
||||||
|
if "\r\n" in content:
|
||||||
|
return "\r\n"
|
||||||
|
if "\n" in content:
|
||||||
|
return "\n"
|
||||||
|
return "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _split_lines(content):
|
||||||
|
"""Split into lines WITHOUT terminators, tolerant of CRLF/LF/CR.
|
||||||
|
|
||||||
|
Rejoining with eol.join(...) reproduces a trailing newline because a
|
||||||
|
trailing terminator yields a final empty element.
|
||||||
|
"""
|
||||||
|
return content.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Shared regexes ---------------------------------------------------------
|
||||||
|
_LABEL_RE = re.compile(r"^\s*:([A-Za-z0-9_][A-Za-z0-9_\-]*)\s*$")
|
||||||
|
_NETUSE_IMG_RE = re.compile(
|
||||||
|
r"net use\s+\w+:\s+\\\\[0-9.]+\\winpeapps\\([A-Za-z0-9_\-]+)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
_GOTO_END_RE = re.compile(r"^\s*goto\s+end\s*$", re.IGNORECASE)
|
||||||
|
_DISPATCH_RE = re.compile(
|
||||||
|
r'^(\s*)if\s+"%choice%"=="(\d+)"\s+goto\s+([A-Za-z0-9_\-]+)\s*$', re.IGNORECASE
|
||||||
|
)
|
||||||
|
_ECHO_NUM_RE = re.compile(r"^(\s*)echo\s+(\d+)\.\s?(.*?)\s*$", re.IGNORECASE)
|
||||||
|
_CHOICE_PROMPT_RE = re.compile(
|
||||||
|
r"^(\s*set\s+/p\s+choice=.*\(1-)(\d+)(\).*)$", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature 1: settings as forms
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _detect_server_ip(content):
|
||||||
|
"""Best-effort PXE server IP: the IP most used in \\\\IP\\ UNC paths.
|
||||||
|
|
||||||
|
Falls back to a `ping <ip>` target. Returns the dotted-quad or None.
|
||||||
|
"""
|
||||||
|
ips = re.findall(r"\\\\(\d{1,3}(?:\.\d{1,3}){3})\\", content)
|
||||||
|
if not ips:
|
||||||
|
ips = re.findall(
|
||||||
|
r"ping\b[^\r\n]*?\b(\d{1,3}(?:\.\d{1,3}){3})\b", content, re.IGNORECASE
|
||||||
|
)
|
||||||
|
if not ips:
|
||||||
|
return None
|
||||||
|
return Counter(ips).most_common(1)[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_server_ip(content, old, new):
|
||||||
|
"""Replace old IP with new everywhere it stands as a whole IPv4 token.
|
||||||
|
|
||||||
|
The negative look-around on [\\d.] keeps it from matching a fragment of a
|
||||||
|
longer dotted number, and only the detected server IP is ever passed in, so
|
||||||
|
unrelated addresses (e.g. 8.8.8.8 in the enroll.cmd here-doc) are untouched.
|
||||||
|
This covers UNC (\\\\IP\\...), the ping wait-for-network line, and any URL.
|
||||||
|
"""
|
||||||
|
pat = re.compile(r"(?<![\d.])" + re.escape(old) + r"(?![\d.])")
|
||||||
|
return pat.sub(new, content)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_menu_timeout_default(content):
|
||||||
|
"""Pull timeout seconds + default key off a `choice /t N /d X` line.
|
||||||
|
|
||||||
|
startnet uses `set /p` (no timeout) today, so this is usually (None, None).
|
||||||
|
Only a real `choice` command is recognised; nothing is invented.
|
||||||
|
"""
|
||||||
|
timeout = None
|
||||||
|
default = None
|
||||||
|
for line in _split_lines(content):
|
||||||
|
if re.search(r"\bchoice\b", line, re.IGNORECASE) and "/t" in line.lower():
|
||||||
|
mt = re.search(r"/t\s*:?\s*(\d+)", line, re.IGNORECASE)
|
||||||
|
md = re.search(r"/d\s*:?\s*([A-Za-z0-9]+)", line, re.IGNORECASE)
|
||||||
|
if mt:
|
||||||
|
timeout = int(mt.group(1))
|
||||||
|
if md:
|
||||||
|
default = md.group(1)
|
||||||
|
break
|
||||||
|
return timeout, default
|
||||||
|
|
||||||
|
|
||||||
|
def parse_settings(content):
|
||||||
|
"""content -> settings dict of safe, common knobs.
|
||||||
|
|
||||||
|
Keys:
|
||||||
|
server_ip str|None PXE server IP used in UNC/ping paths
|
||||||
|
menu_timeout int|None seconds, from a `choice /t` construct if present
|
||||||
|
menu_default str|None default choice key, from `choice /d` if present
|
||||||
|
"""
|
||||||
|
ip = _detect_server_ip(content)
|
||||||
|
timeout, default = _detect_menu_timeout_default(content)
|
||||||
|
return {"server_ip": ip, "menu_timeout": timeout, "menu_default": default}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_settings(content, settings):
|
||||||
|
"""settings dict -> content, via targeted replacement only.
|
||||||
|
|
||||||
|
- server_ip: if given and different from the current detected IP, rewrites
|
||||||
|
that IP everywhere it appears as a whole IPv4 token (UNC, ping, URL).
|
||||||
|
- menu_timeout / menu_default: updated ONLY if a `choice /t../d..` construct
|
||||||
|
already exists; never injected (conservative - avoids risky rewrites).
|
||||||
|
Unknown/None keys are left alone. Line endings are preserved.
|
||||||
|
"""
|
||||||
|
out = content
|
||||||
|
new_ip = settings.get("server_ip")
|
||||||
|
if new_ip:
|
||||||
|
cur = _detect_server_ip(content)
|
||||||
|
if cur and cur != new_ip:
|
||||||
|
out = _replace_server_ip(out, cur, new_ip)
|
||||||
|
|
||||||
|
if settings.get("menu_timeout") is not None or settings.get("menu_default") is not None:
|
||||||
|
out = _apply_menu_timeout_default(
|
||||||
|
out, settings.get("menu_timeout"), settings.get("menu_default")
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_menu_timeout_default(content, timeout, default):
|
||||||
|
"""Rewrite /t and /d on an existing `choice` line only. No-op otherwise."""
|
||||||
|
eol = _detect_eol(content)
|
||||||
|
lines = _split_lines(content)
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if re.search(r"\bchoice\b", line, re.IGNORECASE) and "/t" in line.lower():
|
||||||
|
if timeout is not None:
|
||||||
|
line = re.sub(
|
||||||
|
r"(/t\s*:?\s*)\d+", r"\g<1>" + str(timeout), line, flags=re.IGNORECASE
|
||||||
|
)
|
||||||
|
if default is not None:
|
||||||
|
line = re.sub(
|
||||||
|
r"(/d\s*:?\s*)[A-Za-z0-9]+",
|
||||||
|
r"\g<1>" + str(default),
|
||||||
|
line,
|
||||||
|
flags=re.IGNORECASE,
|
||||||
|
)
|
||||||
|
lines[i] = line
|
||||||
|
break
|
||||||
|
return eol.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature 2: boot-menu builder
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _find_label_line(lines, name):
|
||||||
|
name = name.lower()
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
m = _LABEL_RE.match(line)
|
||||||
|
if m and m.group(1).lower() == name:
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_image_blocks(lines):
|
||||||
|
"""Find per-image action blocks: a ':<target>' label whose body maps
|
||||||
|
Z: to \\\\IP\\winpeapps\\<image> and ends with 'goto end'.
|
||||||
|
|
||||||
|
Returns list of dicts: {target, image, start, end} where start is the label
|
||||||
|
line index and end is the 'goto end' line index (inclusive block span).
|
||||||
|
"""
|
||||||
|
label_idxs = [i for i, l in enumerate(lines) if _LABEL_RE.match(l)]
|
||||||
|
label_set = set(label_idxs)
|
||||||
|
blocks = []
|
||||||
|
for i in label_idxs:
|
||||||
|
target = _LABEL_RE.match(lines[i]).group(1)
|
||||||
|
# body runs to the next label or EOF
|
||||||
|
end_bound = len(lines)
|
||||||
|
for j in range(i + 1, len(lines)):
|
||||||
|
if j in label_set:
|
||||||
|
end_bound = j
|
||||||
|
break
|
||||||
|
image = None
|
||||||
|
goto_end_idx = None
|
||||||
|
for j in range(i + 1, end_bound):
|
||||||
|
if image is None:
|
||||||
|
mi = _NETUSE_IMG_RE.search(lines[j])
|
||||||
|
if mi:
|
||||||
|
image = mi.group(1)
|
||||||
|
if _GOTO_END_RE.match(lines[j]):
|
||||||
|
goto_end_idx = j
|
||||||
|
if image is not None and goto_end_idx is not None:
|
||||||
|
blocks.append(
|
||||||
|
{"target": target, "image": image, "start": i, "end": goto_end_idx}
|
||||||
|
)
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _find_dispatch_lines(lines):
|
||||||
|
"""All `if "%choice%"=="N" goto TARGET` lines as dicts {idx,num,target,indent}."""
|
||||||
|
out = []
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
m = _DISPATCH_RE.match(line)
|
||||||
|
if m:
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"idx": i,
|
||||||
|
"indent": m.group(1),
|
||||||
|
"num": int(m.group(2)),
|
||||||
|
"target": m.group(3),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _find_choice_prompt(lines, start):
|
||||||
|
for i in range(start, len(lines)):
|
||||||
|
if _CHOICE_PROMPT_RE.match(lines[i]):
|
||||||
|
return i
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _find_numbered_echo_run(lines, lo, hi):
|
||||||
|
"""Contiguous run of numbered 'echo N. Label' lines within [lo, hi).
|
||||||
|
|
||||||
|
Returns (indices, indent, labels_by_num) or raises ValueError if the numbered
|
||||||
|
echo lines are not on consecutive source lines (ambiguous - refuse to edit).
|
||||||
|
"""
|
||||||
|
matched = []
|
||||||
|
for i in range(lo, hi):
|
||||||
|
m = _ECHO_NUM_RE.match(lines[i])
|
||||||
|
if m:
|
||||||
|
matched.append((i, m))
|
||||||
|
if not matched:
|
||||||
|
raise ValueError("no numbered 'echo N. ...' menu lines found")
|
||||||
|
idxs = [i for i, _ in matched]
|
||||||
|
if idxs != list(range(idxs[0], idxs[0] + len(idxs))):
|
||||||
|
raise ValueError("menu echo lines are not contiguous")
|
||||||
|
indent = matched[0][1].group(1)
|
||||||
|
labels = {int(m.group(2)): m.group(3).rstrip() for _, m in matched}
|
||||||
|
return idxs, indent, labels
|
||||||
|
|
||||||
|
|
||||||
|
def _analyze_boot_menu(lines):
|
||||||
|
"""Locate every editable region of the image menu. Raises ValueError if the
|
||||||
|
clearly-delimited structure is missing/ambiguous. Returns a dict of parts."""
|
||||||
|
menu_idx = _find_label_line(lines, "menu")
|
||||||
|
if menu_idx is None:
|
||||||
|
raise ValueError("no :menu label found")
|
||||||
|
choice_idx = _find_choice_prompt(lines, menu_idx)
|
||||||
|
if choice_idx is None:
|
||||||
|
raise ValueError("no 'set /p choice=' prompt found after :menu")
|
||||||
|
|
||||||
|
echo_idxs, echo_indent, echo_labels = _find_numbered_echo_run(
|
||||||
|
lines, menu_idx, choice_idx
|
||||||
|
)
|
||||||
|
|
||||||
|
image_blocks = _find_image_blocks(lines)
|
||||||
|
if not image_blocks:
|
||||||
|
raise ValueError("no image action blocks (net use winpeapps ... goto end)")
|
||||||
|
img_targets = {b["target"] for b in image_blocks}
|
||||||
|
|
||||||
|
all_dispatch = _find_dispatch_lines(lines)
|
||||||
|
image_dispatch = [d for d in all_dispatch if d["target"] in img_targets]
|
||||||
|
if not image_dispatch:
|
||||||
|
raise ValueError("no image %choice% dispatch lines found")
|
||||||
|
d_idxs = [d["idx"] for d in image_dispatch]
|
||||||
|
if d_idxs != list(range(d_idxs[0], d_idxs[-1] + 1)):
|
||||||
|
raise ValueError("image %choice% dispatch lines are not contiguous")
|
||||||
|
|
||||||
|
blocks_sorted = sorted(image_blocks, key=lambda b: b["start"])
|
||||||
|
region_start = blocks_sorted[0]["start"]
|
||||||
|
region_end = blocks_sorted[-1]["end"]
|
||||||
|
# every line inside the block region must belong to a block span or be blank
|
||||||
|
covered = set()
|
||||||
|
for b in blocks_sorted:
|
||||||
|
covered.update(range(b["start"], b["end"] + 1))
|
||||||
|
for j in range(region_start, region_end + 1):
|
||||||
|
if j not in covered and lines[j].strip() != "":
|
||||||
|
raise ValueError(
|
||||||
|
"image action blocks are not contiguous (foreign content at "
|
||||||
|
"line %d)" % (j + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"menu_idx": menu_idx,
|
||||||
|
"choice_idx": choice_idx,
|
||||||
|
"echo_idxs": echo_idxs,
|
||||||
|
"echo_indent": echo_indent,
|
||||||
|
"echo_labels": echo_labels,
|
||||||
|
"image_blocks": blocks_sorted,
|
||||||
|
"image_dispatch": image_dispatch,
|
||||||
|
"all_dispatch": all_dispatch,
|
||||||
|
"region_start": region_start,
|
||||||
|
"region_end": region_end,
|
||||||
|
"dispatch_start": d_idxs[0],
|
||||||
|
"dispatch_end": d_idxs[-1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_boot_menu(content):
|
||||||
|
"""content -> list of image-menu items, ordered by menu number.
|
||||||
|
|
||||||
|
Each item dict:
|
||||||
|
num int choice number that routes to this image
|
||||||
|
label str echo text after 'N. ' (kept verbatim, trailing ws only stripped)
|
||||||
|
target str goto label of the action block (the ':<target>')
|
||||||
|
target_image str winpeapps\\<image> the block maps Z: to
|
||||||
|
|
||||||
|
Returns [] if the delimited menu region cannot be found (caller uses raw text).
|
||||||
|
"""
|
||||||
|
lines = _split_lines(content)
|
||||||
|
try:
|
||||||
|
a = _analyze_boot_menu(lines)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
by_target = {b["target"]: b for b in a["image_blocks"]}
|
||||||
|
items = []
|
||||||
|
for d in a["image_dispatch"]:
|
||||||
|
blk = by_target[d["target"]]
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"num": d["num"],
|
||||||
|
"label": a["echo_labels"].get(d["num"], ""),
|
||||||
|
"target": d["target"],
|
||||||
|
"target_image": blk["image"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
items.sort(key=lambda it: it["num"])
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _synth_block(item, ip, eol_indent=""):
|
||||||
|
"""Build a fresh per-image action block for a newly added menu item."""
|
||||||
|
target = item["target"]
|
||||||
|
image = item.get("target_image") or target
|
||||||
|
label = item.get("label") or target
|
||||||
|
return [
|
||||||
|
":%s" % target,
|
||||||
|
"echo.",
|
||||||
|
"echo Starting %s setup..." % label,
|
||||||
|
r'start "FlatApp" %SYSTEMDRIVE%\GESetup\FlatSetupLoader.exe',
|
||||||
|
r"for /l %%i in (1,1,2000000) do rem",
|
||||||
|
r"net use Z: \\%s\winpeapps\%s /user:pxe-upload pxe /persistent:no"
|
||||||
|
% (ip, image),
|
||||||
|
"goto end",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def apply_boot_menu(content, items):
|
||||||
|
"""items -> content: regenerate ONLY the menu echo lines, the choice prompt
|
||||||
|
count, the image %choice% dispatch (renumbered 1..N), and reorder/add/remove
|
||||||
|
the per-image action blocks. Existing block BODIES are kept byte-for-byte;
|
||||||
|
only new blocks are synthesised. Everything else is untouched.
|
||||||
|
|
||||||
|
items: list of {label, target, target_image} (a 'num' key, if present, is
|
||||||
|
ignored - order in the list defines the new numbering).
|
||||||
|
|
||||||
|
Raises ValueError when the structure is ambiguous OR when renumbering would
|
||||||
|
desync a secondary `%choice%` router (e.g. the enrollment routing that also
|
||||||
|
keys off choice numbers). The caller then falls back to the raw editor.
|
||||||
|
"""
|
||||||
|
if not items:
|
||||||
|
raise ValueError("boot menu must have at least one item")
|
||||||
|
|
||||||
|
eol = _detect_eol(content)
|
||||||
|
lines = _split_lines(content)
|
||||||
|
ip = _detect_server_ip(content) or "172.16.9.1"
|
||||||
|
a = _analyze_boot_menu(lines)
|
||||||
|
|
||||||
|
old_num_by_target = {d["target"]: d["num"] for d in a["image_dispatch"]}
|
||||||
|
old_target_by_num = {d["num"]: d["target"] for d in a["image_dispatch"]}
|
||||||
|
new_num_by_target = {it["target"]: i + 1 for i, it in enumerate(items)}
|
||||||
|
|
||||||
|
# Safety: a secondary %choice% router (not one of the image blocks) that
|
||||||
|
# references an image number whose meaning is about to change is a desync.
|
||||||
|
image_dispatch_idxs = {d["idx"] for d in a["image_dispatch"]}
|
||||||
|
for d in a["all_dispatch"]:
|
||||||
|
if d["idx"] in image_dispatch_idxs:
|
||||||
|
continue
|
||||||
|
n = d["num"]
|
||||||
|
if n in old_target_by_num: # this number currently selects an image
|
||||||
|
tgt = old_target_by_num[n]
|
||||||
|
if tgt not in new_num_by_target or new_num_by_target[tgt] != n:
|
||||||
|
raise ValueError(
|
||||||
|
"refusing to renumber: secondary %%choice%% routing at line "
|
||||||
|
"%d references image choice %d whose number would change"
|
||||||
|
% (d["idx"] + 1, n)
|
||||||
|
)
|
||||||
|
|
||||||
|
blocks_by_target = {
|
||||||
|
b["target"]: lines[b["start"]:b["end"] + 1] for b in a["image_blocks"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- build replacement lines for each region ---
|
||||||
|
new_echo = [
|
||||||
|
"%secho %d. %s" % (a["echo_indent"], i + 1, it.get("label") or it["target"])
|
||||||
|
for i, it in enumerate(items)
|
||||||
|
]
|
||||||
|
disp_indent = a["image_dispatch"][0]["indent"]
|
||||||
|
new_dispatch = [
|
||||||
|
'%sif "%%choice%%"=="%d" goto %s' % (disp_indent, i + 1, it["target"])
|
||||||
|
for i, it in enumerate(items)
|
||||||
|
]
|
||||||
|
cm = _CHOICE_PROMPT_RE.match(lines[a["choice_idx"]])
|
||||||
|
new_choice = "%s%d%s" % (cm.group(1), len(items), cm.group(3))
|
||||||
|
|
||||||
|
new_region = []
|
||||||
|
for i, it in enumerate(items):
|
||||||
|
if i > 0:
|
||||||
|
new_region.append("") # blank line between blocks, matching style
|
||||||
|
if it["target"] in blocks_by_target:
|
||||||
|
new_region.extend(blocks_by_target[it["target"]])
|
||||||
|
else:
|
||||||
|
new_region.extend(_synth_block(it, ip))
|
||||||
|
|
||||||
|
# Apply splices high-index-first so earlier indices stay valid.
|
||||||
|
splices = [
|
||||||
|
(a["region_start"], a["region_end"], new_region),
|
||||||
|
(a["dispatch_start"], a["dispatch_end"], new_dispatch),
|
||||||
|
(a["choice_idx"], a["choice_idx"], [new_choice]),
|
||||||
|
(a["echo_idxs"][0], a["echo_idxs"][-1], new_echo),
|
||||||
|
]
|
||||||
|
for start, end, repl in sorted(splices, key=lambda s: s[0], reverse=True):
|
||||||
|
lines[start:end + 1] = repl
|
||||||
|
|
||||||
|
return eol.join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature 3: lint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _strip_for_parens(line):
|
||||||
|
"""Neutralise a line for paren counting: drop comments, quoted spans, and
|
||||||
|
caret-escaped chars (so `^>NUL`, `echo ... ^(` etc. do not count)."""
|
||||||
|
s = line.lstrip()
|
||||||
|
low = s.lower()
|
||||||
|
if low.startswith("rem ") or low == "rem" or s.startswith("::"):
|
||||||
|
return ""
|
||||||
|
# remove escaped pairs first, then any caret-escaped char
|
||||||
|
s = s.replace("^^", "")
|
||||||
|
s = re.sub(r"\^.", "", s)
|
||||||
|
# drop double-quoted spans
|
||||||
|
s = re.sub(r'"[^"]*"', "", s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def lint_startnet(content):
|
||||||
|
"""Static checks over startnet.cmd. Advisory - never blocks a save.
|
||||||
|
|
||||||
|
Returns {"errors": [...], "warnings": [...]} where each entry is
|
||||||
|
{"line": int|None, "message": str}. Errors: goto with no matching label.
|
||||||
|
Warnings: lone-LF (non-CRLF) lines, unbalanced parentheses.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
lines = _split_lines(content)
|
||||||
|
|
||||||
|
labels = set()
|
||||||
|
for line in lines:
|
||||||
|
m = _LABEL_RE.match(line)
|
||||||
|
if m:
|
||||||
|
labels.add(m.group(1).lower())
|
||||||
|
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
# A `goto` inside an `echo` line is emitted text (e.g. the enroll.cmd
|
||||||
|
# here-doc), not a jump - skip it to avoid false positives.
|
||||||
|
if line.lstrip().lower().startswith("echo"):
|
||||||
|
continue
|
||||||
|
for m in re.finditer(
|
||||||
|
r"goto\s+:?([A-Za-z0-9_][A-Za-z0-9_\-]*)", line, re.IGNORECASE
|
||||||
|
):
|
||||||
|
target = m.group(1).lower()
|
||||||
|
if target == "eof":
|
||||||
|
continue # goto :eof is a cmd.exe builtin
|
||||||
|
if target not in labels:
|
||||||
|
errors.append(
|
||||||
|
{
|
||||||
|
"line": i + 1,
|
||||||
|
"message": "goto %s has no matching :%s label"
|
||||||
|
% (m.group(1), m.group(1)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# CRLF check: count lone LFs (a \n not preceded by \r)
|
||||||
|
crlf = content.count("\r\n")
|
||||||
|
lf = content.count("\n")
|
||||||
|
lone = lf - crlf
|
||||||
|
if lone > 0:
|
||||||
|
first = None
|
||||||
|
idx = content.find("\n")
|
||||||
|
ln = 1
|
||||||
|
while idx != -1:
|
||||||
|
if idx == 0 or content[idx - 1] != "\r":
|
||||||
|
first = ln
|
||||||
|
break
|
||||||
|
idx = content.find("\n", idx + 1)
|
||||||
|
ln += 1
|
||||||
|
warnings.append(
|
||||||
|
{
|
||||||
|
"line": first,
|
||||||
|
"message": "%d line(s) are not CRLF-terminated; WinPE batch "
|
||||||
|
"expects CRLF" % lone,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parenthesis balance (best-effort, comments/quotes/carets removed)
|
||||||
|
delta = 0
|
||||||
|
for line in lines:
|
||||||
|
cleaned = _strip_for_parens(line)
|
||||||
|
delta += cleaned.count("(") - cleaned.count(")")
|
||||||
|
if delta != 0:
|
||||||
|
warnings.append(
|
||||||
|
{
|
||||||
|
"line": None,
|
||||||
|
"message": "parentheses look unbalanced (delta %+d) - check "
|
||||||
|
"if(...) blocks" % delta,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"errors": errors, "warnings": warnings}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature 4: snapshots + diff
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _ensure_backups_dir():
|
||||||
|
os.makedirs(BACKUPS_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _snapshot_meta(path):
|
||||||
|
st = os.stat(path)
|
||||||
|
base = os.path.basename(path)
|
||||||
|
note = ""
|
||||||
|
note_path = path + ".note"
|
||||||
|
if os.path.isfile(note_path):
|
||||||
|
try:
|
||||||
|
with open(note_path, "r", encoding="utf-8", errors="replace") as fh:
|
||||||
|
note = fh.read().strip()
|
||||||
|
except Exception:
|
||||||
|
note = ""
|
||||||
|
return {
|
||||||
|
"id": base,
|
||||||
|
"path": path,
|
||||||
|
"mtime": st.st_mtime,
|
||||||
|
"timestamp": datetime.datetime.fromtimestamp(st.st_mtime).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
),
|
||||||
|
"size": st.st_size,
|
||||||
|
"note": note,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_snapshot(content, note=""):
|
||||||
|
"""Write content verbatim to a timestamped file under BACKUPS_DIR.
|
||||||
|
|
||||||
|
Snapshots the bytes exactly (no line-ending translation) so a restore
|
||||||
|
round-trips faithfully. Returns the snapshot metadata dict (see
|
||||||
|
_snapshot_meta). Call this with the PRIOR startnet before overwriting it.
|
||||||
|
"""
|
||||||
|
_ensure_backups_dir()
|
||||||
|
ts = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
base = "startnet-%s.cmd" % ts
|
||||||
|
path = os.path.join(BACKUPS_DIR, base)
|
||||||
|
n = 1
|
||||||
|
while os.path.exists(path):
|
||||||
|
base = "startnet-%s-%d.cmd" % (ts, n)
|
||||||
|
path = os.path.join(BACKUPS_DIR, base)
|
||||||
|
n += 1
|
||||||
|
with open(path, "w", encoding="utf-8", newline="") as fh:
|
||||||
|
fh.write(content)
|
||||||
|
if note:
|
||||||
|
with open(path + ".note", "w", encoding="utf-8", newline="") as fh:
|
||||||
|
fh.write(note)
|
||||||
|
return _snapshot_meta(path)
|
||||||
|
|
||||||
|
|
||||||
|
def list_snapshots():
|
||||||
|
"""Return snapshot metadata dicts, newest first."""
|
||||||
|
_ensure_backups_dir()
|
||||||
|
metas = []
|
||||||
|
for path in glob.glob(os.path.join(BACKUPS_DIR, "startnet-*.cmd")):
|
||||||
|
if path.endswith(".note"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
metas.append(_snapshot_meta(path))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
metas.sort(key=lambda m: m["mtime"], reverse=True)
|
||||||
|
return metas
|
||||||
|
|
||||||
|
|
||||||
|
def read_snapshot(snapshot_id):
|
||||||
|
"""Return a snapshot's text by id (its filename), or None if not found.
|
||||||
|
|
||||||
|
Rejects path traversal: the id must be a bare basename under BACKUPS_DIR.
|
||||||
|
"""
|
||||||
|
if not snapshot_id or os.path.basename(snapshot_id) != snapshot_id:
|
||||||
|
return None
|
||||||
|
if ".." in snapshot_id or "/" in snapshot_id or "\\" in snapshot_id:
|
||||||
|
return None
|
||||||
|
path = os.path.join(BACKUPS_DIR, snapshot_id)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
return None
|
||||||
|
# newline="" so the stored CRLF bytes come back verbatim (faithful restore).
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace", newline="") as fh:
|
||||||
|
return fh.read()
|
||||||
|
|
||||||
|
|
||||||
|
def unified_diff(a, b, fromfile="current", tofile="new"):
|
||||||
|
"""Unified diff between two startnet texts, as a single string ('' if equal).
|
||||||
|
|
||||||
|
Line endings are normalised for comparison so a pure CRLF/LF difference does
|
||||||
|
not produce a wall of noise; content differences are what surface.
|
||||||
|
"""
|
||||||
|
a_lines = _split_lines(a)
|
||||||
|
b_lines = _split_lines(b)
|
||||||
|
return "\n".join(
|
||||||
|
difflib.unified_diff(
|
||||||
|
a_lines, b_lines, fromfile=fromfile, tofile=tofile, lineterm=""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,47 +3,140 @@
|
|||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<style>
|
<style>
|
||||||
.cmd-editor {
|
/* ---- shared editor typography (keep pre + textarea metrics identical) ---- */
|
||||||
font-family: 'Consolas', 'Courier New', monospace;
|
.stn-mono {
|
||||||
font-size: 0.9rem;
|
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
||||||
min-height: 600px;
|
font-size: 13px;
|
||||||
height: 70vh;
|
line-height: 21px;
|
||||||
resize: vertical;
|
letter-spacing: normal;
|
||||||
width: 100%;
|
tab-size: 4;
|
||||||
display: block;
|
-moz-tab-size: 4;
|
||||||
background-color: #1e1e1e;
|
}
|
||||||
color: #d4d4d4;
|
|
||||||
|
/* ---- Raw tab: gutter + highlight overlay + textarea ---- */
|
||||||
|
.raw-editor-wrap {
|
||||||
|
display: flex;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
padding: 1rem;
|
background: #1e1e1e;
|
||||||
tab-size: 4;
|
overflow: hidden;
|
||||||
|
height: 62vh;
|
||||||
|
min-height: 460px;
|
||||||
|
}
|
||||||
|
.raw-gutter {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 3.25rem;
|
||||||
|
padding: 1rem 0.5rem 1rem 0;
|
||||||
|
text-align: right;
|
||||||
|
color: #6a737d;
|
||||||
|
background: #191919;
|
||||||
|
border-right: 1px solid #2a2a2a;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.raw-code {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.raw-highlight,
|
||||||
|
.raw-textarea {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 0;
|
||||||
|
white-space: pre;
|
||||||
|
word-wrap: normal;
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.raw-highlight {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
color: #d4d4d4;
|
||||||
|
background: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.raw-textarea {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
resize: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #d4d4d4; /* visible if JS/overlay never activates (fallback) */
|
||||||
|
caret-color: #d4d4d4;
|
||||||
|
}
|
||||||
|
/* only once the overlay is proven live do we hide the textarea's own glyphs */
|
||||||
|
.raw-editor-wrap.overlay-live .raw-textarea { color: transparent; }
|
||||||
|
.raw-textarea:focus { outline: none; }
|
||||||
|
.raw-textarea::selection { background: rgba(65, 129, 255, 0.35); }
|
||||||
|
|
||||||
|
/* batch token colors (VS Code-ish dark) */
|
||||||
|
.tok-comment { color: #6a9955; font-style: italic; }
|
||||||
|
.tok-label { color: #dcdcaa; font-weight: 600; }
|
||||||
|
.tok-keyword { color: #569cd6; }
|
||||||
|
.tok-var { color: #4ec9b0; }
|
||||||
|
.tok-string { color: #ce9178; }
|
||||||
|
|
||||||
|
/* ---- lint panel ---- */
|
||||||
|
.lint-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.lint-list li {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.4rem 0.2rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.lint-list li:last-child { border-bottom: 0; }
|
||||||
|
.lint-line {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-light);
|
||||||
|
min-width: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- diff viewer ---- */
|
||||||
|
.diff-view {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #1e1e1e;
|
||||||
|
color: #d4d4d4;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
max-height: 55vh;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre;
|
||||||
|
font-family: 'SF Mono', 'Consolas', monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.cmd-editor:focus {
|
.diff-add { color: #6a9955; }
|
||||||
outline: none;
|
.diff-del { color: #f14c4c; }
|
||||||
border-color: var(--primary);
|
.diff-hunk { color: #569cd6; }
|
||||||
box-shadow: 0 0 0 0.15rem rgba(65, 129, 255, 0.25);
|
.diff-meta { color: #808080; }
|
||||||
|
.diff-identical {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- misc ---- */
|
||||||
.wim-info dl { margin: 0; }
|
.wim-info dl { margin: 0; }
|
||||||
.wim-info dt {
|
.wim-info dt {
|
||||||
font-weight: 600;
|
font-weight: 600; color: var(--text-light);
|
||||||
color: var(--text-light);
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.wim-info dd {
|
|
||||||
margin: 0 0 0.65rem 0;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
}
|
||||||
|
.wim-info dd { margin: 0 0 0.65rem 0; color: var(--text); }
|
||||||
.cmd-ref-item { margin-bottom: 0.85rem; }
|
.cmd-ref-item { margin-bottom: 0.85rem; }
|
||||||
.cmd-ref-item code {
|
.cmd-ref-item code { display: block; margin-bottom: 0.2rem; word-break: break-all; }
|
||||||
display: block;
|
|
||||||
margin-bottom: 0.2rem;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.cmd-ref-item small { color: var(--text-light); }
|
.cmd-ref-item small { color: var(--text-light); }
|
||||||
|
.tab-content-pad { padding-top: 1.25rem; }
|
||||||
|
.menu-num-cell { font-variant-numeric: tabular-nums; color: var(--text-light); width: 2.5rem; }
|
||||||
|
.menu-move-btns { display: inline-flex; gap: 0.2rem; }
|
||||||
|
.field-hint { font-size: 12px; color: var(--text-light); margin-top: 0.25rem; }
|
||||||
|
.stn-modal { max-width: min(900px, 96vw); }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -52,9 +145,7 @@
|
|||||||
<h1>startnet.cmd Editor</h1>
|
<h1>startnet.cmd Editor</h1>
|
||||||
{% if wim_exists %}
|
{% if wim_exists %}
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
|
<span class="badge badge-secondary"><i class="bi bi-hdd"></i> boot.wim</span>
|
||||||
<i class="bi bi-save"></i> Save to boot.wim
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -64,88 +155,594 @@
|
|||||||
<strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>.
|
<strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>.
|
||||||
Run the PXE server setup playbook and import WinPE boot files first.
|
Run the PXE server setup playbook and import WinPE boot files first.
|
||||||
</div>
|
</div>
|
||||||
|
{% if snapshots %}
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">Snapshots</div>
|
||||||
|
<p class="text-light">boot.wim is missing, but prior startnet.cmd snapshots are still on disk. Restore is unavailable until the WIM is present.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
<div class="row">
|
<ul class="nav nav-tabs editor-tabs" id="stnTabs" role="tablist">
|
||||||
<div class="col-lg-9">
|
<li class="nav-item" role="presentation">
|
||||||
<div class="pxe-card">
|
<button class="nav-link active" id="tab-settings-btn" data-bs-toggle="tab" data-bs-target="#tab-settings" type="button" role="tab">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<i class="bi bi-sliders"></i> Settings
|
||||||
<span class="mono"><i class="bi bi-terminal"></i> Windows\System32\startnet.cmd</span>
|
</button>
|
||||||
<span class="badge badge-secondary">boot.wim</span>
|
</li>
|
||||||
</div>
|
<li class="nav-item" role="presentation">
|
||||||
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
|
<button class="nav-link" id="tab-menu-btn" data-bs-toggle="tab" data-bs-target="#tab-menu" type="button" role="tab">
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
<i class="bi bi-list-ol"></i> Boot Menu
|
||||||
<textarea name="content" class="cmd-editor" id="cmdEditor"
|
</button>
|
||||||
spellcheck="false">{{ content }}</textarea>
|
</li>
|
||||||
</form>
|
<li class="nav-item" role="presentation">
|
||||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
<button class="nav-link" id="tab-raw-btn" data-bs-toggle="tab" data-bs-target="#tab-raw" type="button" role="tab">
|
||||||
<small class="text-muted">
|
<i class="bi bi-code-slash"></i> Raw
|
||||||
Editing the startnet.cmd inside <code>{{ wim_path }}</code>
|
{% if lint.errors %}<span class="badge badge-danger ms-1">{{ lint.errors|length }}</span>
|
||||||
</small>
|
{% elif lint.warnings %}<span class="badge badge-warning ms-1">{{ lint.warnings|length }}</span>{% endif %}
|
||||||
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
|
</button>
|
||||||
<i class="bi bi-save"></i> Save to boot.wim
|
</li>
|
||||||
</button>
|
<li class="nav-item" role="presentation">
|
||||||
</div>
|
<button class="nav-link" id="tab-history-btn" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab">
|
||||||
</div>
|
<i class="bi bi-clock-history"></i> History
|
||||||
|
{% if snapshots %}<span class="badge badge-secondary ms-1">{{ snapshots|length }}</span>{% endif %}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="tab-content tab-content-pad">
|
||||||
<div class="section-title">Common startnet.cmd Commands</div>
|
|
||||||
<div class="row">
|
<!-- =============================== SETTINGS =============================== -->
|
||||||
<div class="col-md-6">
|
<div class="tab-pane fade show active" id="tab-settings" role="tabpanel">
|
||||||
<div class="cmd-ref-item">
|
<div class="row">
|
||||||
<code>wpeinit</code>
|
<div class="col-lg-7">
|
||||||
<small>Initialize WinPE networking</small>
|
<div class="pxe-card">
|
||||||
</div>
|
<h3 class="pxe-card-title">Boot Settings</h3>
|
||||||
<div class="cmd-ref-item">
|
<p class="text-light" style="font-size:13px;">
|
||||||
<code>net use Z: \\172.16.9.1\winpeapps</code>
|
These edit only their targeted lines in startnet.cmd. Leave a field
|
||||||
<small>Map Samba share for deployment</small>
|
<strong>blank to keep</strong> the current value. Everything round-trips
|
||||||
</div>
|
through the same boot.wim as the Raw tab.
|
||||||
|
</p>
|
||||||
|
<form action="{{ url_for('startnet_save_settings') }}" method="post">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="setServerIp">PXE / Deployment Server IP</label>
|
||||||
|
<input type="text" class="form-control stn-mono" id="setServerIp" name="server_ip"
|
||||||
|
value="{{ settings.server_ip if settings.server_ip is not none else '' }}"
|
||||||
|
placeholder="e.g. 172.16.9.1">
|
||||||
|
<div class="field-hint">Address startnet.cmd maps the winpeapps share from.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="setTimeout">Menu Timeout (seconds)</label>
|
||||||
|
<input type="number" min="0" class="form-control" id="setTimeout" name="menu_timeout"
|
||||||
|
value="{{ settings.menu_timeout if settings.menu_timeout is not none else '' }}"
|
||||||
|
placeholder="e.g. 30">
|
||||||
|
<div class="field-hint">Whole seconds before the default menu entry auto-selects.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-field">
|
||||||
|
<label for="setDefault">Default Menu Entry</label>
|
||||||
|
<input type="text" class="form-control" id="setDefault" name="menu_default"
|
||||||
|
value="{{ settings.menu_default if settings.menu_default is not none else '' }}"
|
||||||
|
placeholder="e.g. 1">
|
||||||
|
<div class="field-hint">The entry chosen when the timeout elapses.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="pxe-btn pxe-btn-primary">
|
||||||
|
<i class="bi bi-save"></i> Apply & Save to boot.wim
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
</div>
|
||||||
<div class="cmd-ref-item">
|
<div class="col-lg-5">
|
||||||
<code>wpeutil WaitForNetwork</code>
|
<div class="section-card wim-info">
|
||||||
<small>Wait for network to be ready</small>
|
<div class="section-title">WIM Info</div>
|
||||||
</div>
|
<dl>
|
||||||
<div class="cmd-ref-item">
|
{% for key, val in wim_info.items() %}
|
||||||
<code>Z:\gea-standard\Deploy\Tools\deploy.cmd</code>
|
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
|
||||||
<small>Launch deployment script</small>
|
<dt>{{ key }}</dt>
|
||||||
</div>
|
<dd>{{ val }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if not wim_info %}
|
||||||
|
<p class="text-light mb-0">Could not read WIM info.</p>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-3">
|
<!-- =============================== BOOT MENU ============================== -->
|
||||||
<div class="section-card wim-info">
|
<div class="tab-pane fade" id="tab-menu" role="tabpanel">
|
||||||
<div class="section-title">WIM Info</div>
|
<div class="pxe-card">
|
||||||
<dl>
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
{% for key, val in wim_info.items() %}
|
<h3 class="pxe-card-title mb-0">Boot Menu Entries</h3>
|
||||||
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
|
<button type="button" class="pxe-btn pxe-btn-secondary pxe-btn-sm" id="menuAddBtn">
|
||||||
<dt>{{ key }}</dt>
|
<i class="bi bi-plus-lg"></i> Add Entry
|
||||||
<dd>{{ val }}</dd>
|
</button>
|
||||||
{% endif %}
|
</div>
|
||||||
{% endfor %}
|
<div class="alert alert-info" style="font-size:13px;">
|
||||||
{% if not wim_info %}
|
<i class="bi bi-info-circle"></i>
|
||||||
<p class="text-muted mb-0">Could not read WIM info.</p>
|
<strong>Appending</strong> a new image at the end always works. Reordering or
|
||||||
{% endif %}
|
removing an entry whose number the enrollment <code>%choice%</code> router
|
||||||
</dl>
|
cross-references may be <strong>refused by the server</strong> to keep numbering
|
||||||
|
in sync - if so, the raw text is left untouched and the reason is flashed.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="{{ url_for('startnet_save_menu') }}" method="post" id="menuForm">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="items" id="menuItemsData" value="[]">
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table" id="menuTable" style="white-space:normal;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:2.5rem;">#</th>
|
||||||
|
<th>Label</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Target Image</th>
|
||||||
|
<th style="width:9rem;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="menuBody"><!-- rows injected by JS --></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="menuEmpty" class="text-light" style="padding:1rem 0; display:none;">
|
||||||
|
No menu entries parsed. Use the Raw tab, or add an entry above.
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="submit" class="pxe-btn pxe-btn-primary">
|
||||||
|
<i class="bi bi-save"></i> Save Menu to boot.wim
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ================================= RAW ================================= -->
|
||||||
|
<div class="tab-pane fade" id="tab-raw" role="tabpanel">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<div class="pxe-card">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span class="mono"><i class="bi bi-terminal"></i> Windows\System32\startnet.cmd</span>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm" id="rawDiffBtn">
|
||||||
|
<i class="bi bi-file-earmark-diff"></i> Diff vs current
|
||||||
|
</button>
|
||||||
|
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary pxe-btn-sm">
|
||||||
|
<i class="bi bi-save"></i> Save to boot.wim
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="raw-editor-wrap" id="rawWrap">
|
||||||
|
<div class="raw-gutter stn-mono" id="rawGutter" aria-hidden="true">1</div>
|
||||||
|
<div class="raw-code">
|
||||||
|
<pre class="raw-highlight stn-mono" id="rawHighlight" aria-hidden="true"></pre>
|
||||||
|
<textarea name="content" class="raw-textarea stn-mono" id="cmdEditor"
|
||||||
|
spellcheck="false" autocomplete="off" autocapitalize="off"
|
||||||
|
wrap="off">{{ content }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||||
|
<small class="text-light">Editing startnet.cmd inside <code>{{ wim_path }}</code></small>
|
||||||
|
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
|
||||||
|
<i class="bi bi-save"></i> Save to boot.wim
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">Common startnet.cmd Commands</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="cmd-ref-item">
|
||||||
|
<code>wpeinit</code><small>Initialize WinPE networking</small>
|
||||||
|
</div>
|
||||||
|
<div class="cmd-ref-item">
|
||||||
|
<code>net use Z: \\172.16.9.1\winpeapps</code><small>Map Samba share for deployment</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="cmd-ref-item">
|
||||||
|
<code>wpeutil WaitForNetwork</code><small>Wait for network to be ready</small>
|
||||||
|
</div>
|
||||||
|
<div class="cmd-ref-item">
|
||||||
|
<code>Z:\gea-standard\Deploy\Tools\deploy.cmd</code><small>Launch deployment script</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">
|
||||||
|
Lint
|
||||||
|
{% if lint.errors %}<span class="badge badge-danger">{{ lint.errors|length }} err</span>{% endif %}
|
||||||
|
{% if lint.warnings %}<span class="badge badge-warning">{{ lint.warnings|length }} warn</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if not lint.errors and not lint.warnings %}
|
||||||
|
<p class="text-light mb-0"><i class="bi bi-check-circle text-success"></i> No issues found.</p>
|
||||||
|
{% else %}
|
||||||
|
<ul class="lint-list">
|
||||||
|
{% for e in lint.errors %}
|
||||||
|
<li>
|
||||||
|
<span class="lint-line">{{ ('L' ~ e.line) if e.line is not none else '--' }}</span>
|
||||||
|
<span><i class="bi bi-x-octagon text-danger"></i> {{ e.message }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
{% for w in lint.warnings %}
|
||||||
|
<li>
|
||||||
|
<span class="lint-line">{{ ('L' ~ w.line) if w.line is not none else '--' }}</span>
|
||||||
|
<span><i class="bi bi-exclamation-triangle text-warning"></i> {{ w.message }}</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card wim-info">
|
||||||
|
<div class="section-title">WIM Info</div>
|
||||||
|
<dl>
|
||||||
|
{% for key, val in wim_info.items() %}
|
||||||
|
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
|
||||||
|
<dt>{{ key }}</dt>
|
||||||
|
<dd>{{ val }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if not wim_info %}
|
||||||
|
<p class="text-light mb-0">Could not read WIM info.</p>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- =============================== HISTORY =============================== -->
|
||||||
|
<div class="tab-pane fade" id="tab-history" role="tabpanel">
|
||||||
|
<div class="pxe-card">
|
||||||
|
<h3 class="pxe-card-title">Snapshots</h3>
|
||||||
|
<p class="text-light" style="font-size:13px;">
|
||||||
|
Every save snapshots the prior startnet.cmd first. Diff a snapshot against the
|
||||||
|
current boot.wim, or restore it (restoring also snapshots the current copy first).
|
||||||
|
</p>
|
||||||
|
{% if not snapshots %}
|
||||||
|
<p class="text-light mb-0">No snapshots yet.</p>
|
||||||
|
{% else %}
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Note</th>
|
||||||
|
<th style="width:14rem;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in snapshots %}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{{ s.timestamp }}</td>
|
||||||
|
<td>{{ '%.1f'|format(s.size / 1024) }} KB</td>
|
||||||
|
<td>{{ s.note or '-' }}</td>
|
||||||
|
<td class="actions">
|
||||||
|
<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm snap-diff-btn"
|
||||||
|
data-snapshot-id="{{ s.id }}">
|
||||||
|
<i class="bi bi-file-earmark-diff"></i> Diff
|
||||||
|
</button>
|
||||||
|
<form action="{{ url_for('startnet_restore_snapshot') }}" method="post" class="d-inline"
|
||||||
|
onsubmit="return confirm('Restore this snapshot to boot.wim? The current startnet.cmd is snapshotted first.');">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="snapshot_id" value="{{ s.id }}">
|
||||||
|
<button type="submit" class="pxe-btn pxe-btn-secondary pxe-btn-sm">
|
||||||
|
<i class="bi bi-arrow-counterclockwise"></i> Restore
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================= DIFF MODAL ============================= -->
|
||||||
|
<div class="modal fade" id="diffModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered stn-modal">
|
||||||
|
<div class="pxe-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="diffModalTitle">Diff</h3>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="diffModalBody"><div class="text-light" style="padding:1rem;">Loading...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Close</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block extra_scripts %}
|
{% block extra_scripts %}
|
||||||
|
{% if wim_exists %}
|
||||||
<script>
|
<script>
|
||||||
// Tab key inserts a tab in the editor instead of moving focus
|
(function () {
|
||||||
document.getElementById('cmdEditor')?.addEventListener('keydown', function(e) {
|
"use strict";
|
||||||
if (e.key === 'Tab') {
|
|
||||||
e.preventDefault();
|
var CSRF = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||||
var start = this.selectionStart;
|
var DIFF_URL = "{{ url_for('startnet_diff') }}";
|
||||||
var end = this.selectionEnd;
|
|
||||||
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
|
// ------------------------------------------------------------------ escape
|
||||||
this.selectionStart = this.selectionEnd = start + 1;
|
function esc(s) {
|
||||||
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// ---------------------------------------------------- batch highlighter
|
||||||
|
// Per-line so comments/labels never leak across lines. Operates on ESCAPED
|
||||||
|
// text and only ever wraps whole matched tokens, so it cannot corrupt input.
|
||||||
|
var KW = /\b(echo|set|setlocal|endlocal|if|else|for|in|do|goto|call|exit|pause|cls|start|del|copy|xcopy|robocopy|md|mkdir|rd|rmdir|cd|pushd|popd|choice|timeout|ping|reg|net|use|wpeinit|wpeutil|ver|title|color|shutdown|wmic|diskpart|dism|bcdboot|drvload|not|exist|defined|errorlevel|equ|neq|gtr|geq|lss|leq)\b/gi;
|
||||||
|
|
||||||
|
function hlLine(raw) {
|
||||||
|
var line = esc(raw);
|
||||||
|
var trimmed = line.replace(/^\s+/, '');
|
||||||
|
// whole-line comment (rem, ::, @rem)
|
||||||
|
if (/^(@?rem\b|::)/i.test(trimmed)) {
|
||||||
|
return '<span class="tok-comment">' + line + '</span>';
|
||||||
|
}
|
||||||
|
// label line ( :name )
|
||||||
|
if (/^:[^:\s]/.test(trimmed)) {
|
||||||
|
return '<span class="tok-label">' + line + '</span>';
|
||||||
|
}
|
||||||
|
// token pass: strings first, then %vars%, then leading keyword-ish words
|
||||||
|
line = line.replace(/"[^&]*"|"[^"]*"/g, function (m) {
|
||||||
|
return '<span class="tok-string">' + m + '</span>';
|
||||||
|
});
|
||||||
|
line = line.replace(/%[^%\s]+%|%%?[a-zA-Z]\b|![^!\s]+!/g, function (m) {
|
||||||
|
return '<span class="tok-var">' + m + '</span>';
|
||||||
|
});
|
||||||
|
line = line.replace(KW, function (m) {
|
||||||
|
return '<span class="tok-keyword">' + m + '</span>';
|
||||||
|
});
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrap = document.getElementById('rawWrap');
|
||||||
|
var ta = document.getElementById('cmdEditor');
|
||||||
|
var hl = document.getElementById('rawHighlight');
|
||||||
|
var gutter = document.getElementById('rawGutter');
|
||||||
|
|
||||||
|
function renderHighlight() {
|
||||||
|
if (!ta || !hl) return;
|
||||||
|
var text = ta.value;
|
||||||
|
var lines = text.split('\n');
|
||||||
|
var out = [];
|
||||||
|
for (var i = 0; i < lines.length; i++) out.push(hlLine(lines[i]));
|
||||||
|
// trailing newline guard so the overlay height tracks the textarea
|
||||||
|
hl.innerHTML = out.join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGutter() {
|
||||||
|
if (!ta || !gutter) return;
|
||||||
|
var n = ta.value.split('\n').length;
|
||||||
|
var buf = [];
|
||||||
|
for (var i = 1; i <= n; i++) buf.push(i);
|
||||||
|
gutter.textContent = buf.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncScroll() {
|
||||||
|
if (hl) { hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft; }
|
||||||
|
if (gutter) { gutter.scrollTop = ta.scrollTop; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ta) {
|
||||||
|
try {
|
||||||
|
renderHighlight();
|
||||||
|
renderGutter();
|
||||||
|
syncScroll();
|
||||||
|
// Only hide the textarea glyphs once the overlay is confirmed built.
|
||||||
|
if (wrap && hl.innerHTML) wrap.classList.add('overlay-live');
|
||||||
|
|
||||||
|
ta.addEventListener('input', function () {
|
||||||
|
renderHighlight();
|
||||||
|
renderGutter();
|
||||||
|
syncScroll();
|
||||||
|
});
|
||||||
|
ta.addEventListener('scroll', syncScroll);
|
||||||
|
|
||||||
|
// Tab key inserts a real tab instead of moving focus.
|
||||||
|
ta.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
e.preventDefault();
|
||||||
|
var start = this.selectionStart, end = this.selectionEnd;
|
||||||
|
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
|
||||||
|
this.selectionStart = this.selectionEnd = start + 1;
|
||||||
|
renderHighlight(); renderGutter(); syncScroll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Highlight is a nicety; on any failure fall back to the plain textarea.
|
||||||
|
if (wrap) wrap.classList.remove('overlay-live');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- diff modal
|
||||||
|
var diffModalEl = document.getElementById('diffModal');
|
||||||
|
var diffModal = diffModalEl && window.bootstrap ? new bootstrap.Modal(diffModalEl) : null;
|
||||||
|
|
||||||
|
function renderDiff(diffText) {
|
||||||
|
var body = document.getElementById('diffModalBody');
|
||||||
|
if (!diffText) {
|
||||||
|
body.innerHTML = '<div class="diff-identical"><i class="bi bi-check-circle"></i> ' +
|
||||||
|
'No differences - identical to the current boot.wim.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var lines = diffText.split('\n');
|
||||||
|
var html = '';
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
var ln = lines[i], cls = '';
|
||||||
|
if (/^\+\+\+|^---/.test(ln)) cls = 'diff-meta';
|
||||||
|
else if (ln.charAt(0) === '@') cls = 'diff-hunk';
|
||||||
|
else if (ln.charAt(0) === '+') cls = 'diff-add';
|
||||||
|
else if (ln.charAt(0) === '-') cls = 'diff-del';
|
||||||
|
html += '<span class="' + cls + '">' + esc(ln) + '</span>\n';
|
||||||
|
}
|
||||||
|
body.innerHTML = '<pre class="diff-view">' + html + '</pre>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDiff(title, payload) {
|
||||||
|
var body = document.getElementById('diffModalBody');
|
||||||
|
document.getElementById('diffModalTitle').textContent = title;
|
||||||
|
body.innerHTML = '<div class="text-light" style="padding:1rem;">Loading diff...</div>';
|
||||||
|
if (diffModal) diffModal.show();
|
||||||
|
fetch(DIFF_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().then(function (j) { return { ok: r.ok, j: j }; });
|
||||||
|
}).then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
body.innerHTML = '<div class="alert alert-danger mb-0">' +
|
||||||
|
esc(res.j.error || 'Diff failed.') + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderDiff(res.j.diff || '');
|
||||||
|
}).catch(function () {
|
||||||
|
body.innerHTML = '<div class="alert alert-danger mb-0">Could not reach the diff endpoint.</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawDiffBtn = document.getElementById('rawDiffBtn');
|
||||||
|
if (rawDiffBtn) {
|
||||||
|
rawDiffBtn.addEventListener('click', function () {
|
||||||
|
openDiff('Pending edit vs current boot.wim', { content: ta.value });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelectorAll('.snap-diff-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var id = btn.getAttribute('data-snapshot-id');
|
||||||
|
openDiff('Snapshot ' + id + ' vs current', { snapshot_id: id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------- boot-menu editor
|
||||||
|
var MENU = {{ boot_menu | tojson }};
|
||||||
|
var IMAGE_TYPES = {{ image_types | tojson }};
|
||||||
|
var body = document.getElementById('menuBody');
|
||||||
|
var emptyMsg = document.getElementById('menuEmpty');
|
||||||
|
var form = document.getElementById('menuForm');
|
||||||
|
var hidden = document.getElementById('menuItemsData');
|
||||||
|
|
||||||
|
function optionsFor(selected) {
|
||||||
|
var opts = '';
|
||||||
|
var found = false;
|
||||||
|
for (var i = 0; i < IMAGE_TYPES.length; i++) {
|
||||||
|
var v = IMAGE_TYPES[i];
|
||||||
|
if (v === selected) found = true;
|
||||||
|
opts += '<option value="' + esc(v) + '"' + (v === selected ? ' selected' : '') + '>' + esc(v) + '</option>';
|
||||||
|
}
|
||||||
|
// preserve an existing target_image that is not in the current image list
|
||||||
|
if (selected && !found) {
|
||||||
|
opts = '<option value="' + esc(selected) + '" selected>' + esc(selected) + ' (unlisted)</option>' + opts;
|
||||||
|
}
|
||||||
|
opts = '<option value="">-- none --</option>' + opts;
|
||||||
|
return opts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowHtml(item, idx, total) {
|
||||||
|
return '' +
|
||||||
|
'<td class="menu-num-cell">' + (idx + 1) + '</td>' +
|
||||||
|
'<td><input type="text" class="form-control form-control-sm m-label" value="' + esc(item.label || '') + '" placeholder="Menu label"></td>' +
|
||||||
|
'<td><input type="text" class="form-control form-control-sm m-target" value="' + esc(item.target || '') + '" placeholder="Target (goto/call)"></td>' +
|
||||||
|
'<td><select class="form-select form-control-sm m-image">' + optionsFor(item.target_image || '') + '</select></td>' +
|
||||||
|
'<td class="actions">' +
|
||||||
|
'<span class="menu-move-btns">' +
|
||||||
|
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-up" ' + (idx === 0 ? 'disabled' : '') + ' title="Move up"><i class="bi bi-arrow-up"></i></button>' +
|
||||||
|
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-down" ' + (idx === total - 1 ? 'disabled' : '') + ' title="Move down"><i class="bi bi-arrow-down"></i></button>' +
|
||||||
|
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-del" title="Remove"><i class="bi bi-trash text-danger"></i></button>' +
|
||||||
|
'</span>' +
|
||||||
|
'</td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the live DOM back into an array (so in-progress typing is captured).
|
||||||
|
function collect() {
|
||||||
|
var items = [];
|
||||||
|
body.querySelectorAll('tr').forEach(function (tr) {
|
||||||
|
items.push({
|
||||||
|
label: (tr.querySelector('.m-label') || {}).value || '',
|
||||||
|
target: (tr.querySelector('.m-target') || {}).value || '',
|
||||||
|
target_image: (tr.querySelector('.m-image') || {}).value || ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(items) {
|
||||||
|
body.innerHTML = '';
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
var tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = rowHtml(items[i], i, items.length);
|
||||||
|
body.appendChild(tr);
|
||||||
|
}
|
||||||
|
if (emptyMsg) emptyMsg.style.display = items.length ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
render(MENU.map(function (m) {
|
||||||
|
return { label: m.label, target: m.target, target_image: m.target_image };
|
||||||
|
}));
|
||||||
|
|
||||||
|
body.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('button');
|
||||||
|
if (!btn) return;
|
||||||
|
var tr = btn.closest('tr');
|
||||||
|
var rows = Array.prototype.slice.call(body.querySelectorAll('tr'));
|
||||||
|
var idx = rows.indexOf(tr);
|
||||||
|
var items = collect();
|
||||||
|
if (btn.classList.contains('m-up') && idx > 0) {
|
||||||
|
var t = items[idx - 1]; items[idx - 1] = items[idx]; items[idx] = t;
|
||||||
|
render(items);
|
||||||
|
} else if (btn.classList.contains('m-down') && idx < items.length - 1) {
|
||||||
|
var d = items[idx + 1]; items[idx + 1] = items[idx]; items[idx] = d;
|
||||||
|
render(items);
|
||||||
|
} else if (btn.classList.contains('m-del')) {
|
||||||
|
items.splice(idx, 1);
|
||||||
|
render(items);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var addBtn = document.getElementById('menuAddBtn');
|
||||||
|
if (addBtn) {
|
||||||
|
addBtn.addEventListener('click', function () {
|
||||||
|
var items = collect();
|
||||||
|
items.push({ label: '', target: '', target_image: '' });
|
||||||
|
render(items);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function () {
|
||||||
|
hidden.value = JSON.stringify(collect());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user