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).
749 lines
28 KiB
HTML
749 lines
28 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}startnet.cmd Editor - PXE Server Manager{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<style>
|
|
/* ---- shared editor typography (keep pre + textarea metrics identical) ---- */
|
|
.stn-mono {
|
|
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
|
|
font-size: 13px;
|
|
line-height: 21px;
|
|
letter-spacing: normal;
|
|
tab-size: 4;
|
|
-moz-tab-size: 4;
|
|
}
|
|
|
|
/* ---- Raw tab: gutter + highlight overlay + textarea ---- */
|
|
.raw-editor-wrap {
|
|
display: flex;
|
|
border: 1px solid var(--border);
|
|
border-radius: 0.25rem;
|
|
background: #1e1e1e;
|
|
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;
|
|
}
|
|
.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;
|
|
}
|
|
.diff-add { color: #6a9955; }
|
|
.diff-del { color: #f14c4c; }
|
|
.diff-hunk { color: #569cd6; }
|
|
.diff-meta { color: #808080; }
|
|
.diff-identical {
|
|
padding: 2rem;
|
|
text-align: center;
|
|
color: var(--success);
|
|
}
|
|
|
|
/* ---- misc ---- */
|
|
.wim-info dl { margin: 0; }
|
|
.wim-info dt {
|
|
font-weight: 600; color: var(--text-light);
|
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
|
}
|
|
.wim-info dd { margin: 0 0 0.65rem 0; color: var(--text); }
|
|
.cmd-ref-item { margin-bottom: 0.85rem; }
|
|
.cmd-ref-item code { display: block; margin-bottom: 0.2rem; word-break: break-all; }
|
|
.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>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="page-header">
|
|
<h1>startnet.cmd Editor</h1>
|
|
{% if wim_exists %}
|
|
<div class="header-actions">
|
|
<span class="badge badge-secondary"><i class="bi bi-hdd"></i> boot.wim</span>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{% if not wim_exists %}
|
|
<div class="alert alert-warning">
|
|
<strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>.
|
|
Run the PXE server setup playbook and import WinPE boot files first.
|
|
</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 %}
|
|
|
|
<ul class="nav nav-tabs editor-tabs" id="stnTabs" role="tablist">
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link active" id="tab-settings-btn" data-bs-toggle="tab" data-bs-target="#tab-settings" type="button" role="tab">
|
|
<i class="bi bi-sliders"></i> Settings
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" id="tab-menu-btn" data-bs-toggle="tab" data-bs-target="#tab-menu" type="button" role="tab">
|
|
<i class="bi bi-list-ol"></i> Boot Menu
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" id="tab-raw-btn" data-bs-toggle="tab" data-bs-target="#tab-raw" type="button" role="tab">
|
|
<i class="bi bi-code-slash"></i> Raw
|
|
{% if lint.errors %}<span class="badge badge-danger ms-1">{{ lint.errors|length }}</span>
|
|
{% elif lint.warnings %}<span class="badge badge-warning ms-1">{{ lint.warnings|length }}</span>{% endif %}
|
|
</button>
|
|
</li>
|
|
<li class="nav-item" role="presentation">
|
|
<button class="nav-link" id="tab-history-btn" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab">
|
|
<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="tab-content tab-content-pad">
|
|
|
|
<!-- =============================== SETTINGS =============================== -->
|
|
<div class="tab-pane fade show active" id="tab-settings" role="tabpanel">
|
|
<div class="row">
|
|
<div class="col-lg-7">
|
|
<div class="pxe-card">
|
|
<h3 class="pxe-card-title">Boot Settings</h3>
|
|
<p class="text-light" style="font-size:13px;">
|
|
These edit only their targeted lines in startnet.cmd. Leave a field
|
|
<strong>blank to keep</strong> the current value. Everything round-trips
|
|
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-lg-5">
|
|
<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>
|
|
|
|
<!-- =============================== BOOT MENU ============================== -->
|
|
<div class="tab-pane fade" id="tab-menu" role="tabpanel">
|
|
<div class="pxe-card">
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<h3 class="pxe-card-title mb-0">Boot Menu Entries</h3>
|
|
<button type="button" class="pxe-btn pxe-btn-secondary pxe-btn-sm" id="menuAddBtn">
|
|
<i class="bi bi-plus-lg"></i> Add Entry
|
|
</button>
|
|
</div>
|
|
<div class="alert alert-info" style="font-size:13px;">
|
|
<i class="bi bi-info-circle"></i>
|
|
<strong>Appending</strong> a new image at the end always works. Reordering or
|
|
removing an entry whose number the enrollment <code>%choice%</code> router
|
|
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>
|
|
|
|
{% endif %}
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
{% if wim_exists %}
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
|
|
var CSRF = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
|
var DIFF_URL = "{{ url_for('startnet_diff') }}";
|
|
|
|
// ------------------------------------------------------------------ escape
|
|
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>
|
|
{% endif %}
|
|
{% endblock %}
|