/** * unattend-editor.js - the Unattend tab of a GE Aerospace image page * (templates/unattend_editor.html). Needs static/image-pages.js (PxeImage, * loaded by _image_header.html) and window.PXE_UNATTEND {previewUrl, * saveUrl, baseSha}. * * - command rows: auto-growing textareas, length counters against the * schema limits, move up/down, drag to reorder, add, remove; * - a filter over both command lists (reordering is off while it is set); * - section index that follows the scroll, and marks sections with edits; * - Review changes (Ctrl+S): posts the form (or the Raw XML text) to * previewUrl, shows the diff, the lint findings and the full XML in the * shared review modal, then saves with the previewed file hash. * * Rows keep data-orig (their index in the file as parsed) so the server * can tell a moved or edited item from a new one. */ (function () { 'use strict'; var CFG = window.PXE_UNATTEND || {}; function $(sel, root) { return (root || document).querySelector(sel); } function $$(sel, root) { return Array.prototype.slice.call((root || document).querySelectorAll(sel)); } var LIST_IDS = { spec: 'unaSpec', fl: 'unaFl', driverpaths: 'unaDriverPaths', accounts: 'unaAccounts' }; var CMD_FIELD = { spec: 'path', fl: 'commandline' }; function list(name) { return document.getElementById(LIST_IDS[name]); } function rows(name) { var l = list(name); return l ? $$(':scope > .una-row, :scope > .una-simple', l) : []; } function origOf(row) { return row.hasAttribute('data-orig') ? parseInt(row.getAttribute('data-orig'), 10) : null; } // --------------------------------------------------------------------- // Reading the form into the dict services/unattend.apply_form takes // --------------------------------------------------------------------- function setKey(obj, key, value) { var parts = key.split('.'); if (parts.length === 2) { obj[parts[0]] = obj[parts[0]] || {}; obj[parts[0]][parts[1]] = value; } else { obj[key] = value; } } function cmdRow(row, field) { var o = { description: $('.una-desc', row).value, orig: origOf(row) }; o[field] = $('.una-cmd', row).value; return o; } function collect() { var f = {}; $$('.una-field').forEach(function (el) { setKey(f, el.getAttribute('data-key'), el.value); }); // A password typed here is plain text: an encoded one (PlainText false // in the file) that is replaced must not keep PlainText false. var alPass = document.getElementById('unaAlPass'); if (alPass && f.autologon && alPass.value !== alPass.defaultValue) f.autologon.plain_text = 'true'; f.driver_paths = rows('driverpaths').map(function (r) { return { path: $('.una-dp', r).value, orig: origOf(r) }; }); f.user_accounts = rows('accounts').map(function (r) { var a = { plain_text: r.getAttribute('data-plain') || 'true', orig: origOf(r) }; $$('[data-f]', r).forEach(function (i) { a[i.getAttribute('data-f')] = i.value; }); var pw = $('[data-f="password"]', r); if (pw && pw.value !== pw.defaultValue) a.plain_text = 'true'; return a; }); f.specialize_commands = rows('spec').map(function (r) { return cmdRow(r, 'path'); }); f.firstlogon_commands = rows('fl').map(function (r) { return cmdRow(r, 'commandline'); }); return f; } // What each index entry / card covers, to mark sections that have edits. var SECTIONS = { machine: ['computer_name', 'time_zone', 'registered_organization', 'registered_owner'], intl: ['intl'], driverpaths: ['driver_paths'], accounts: ['user_accounts'], autologon: ['autologon'], oobe: ['oobe'], oobetz: ['oobe_timezone'], spec: ['specialize_commands'], fl: ['firstlogon_commands'] }; function sectionState(f) { var out = {}; Object.keys(SECTIONS).forEach(function (s) { out[s] = JSON.stringify(SECTIONS[s].map(function (k) { return f[k]; })); }); return out; } // --------------------------------------------------------------------- // Row bookkeeping: positions, counts, empty notes, edited marks // --------------------------------------------------------------------- function rowSnapshot(row) { return JSON.stringify($$('input, textarea', row).map(function (i) { return i.value; })); } function renumber(name) { var rs = rows(name); rs.forEach(function (r, i) { var p = $('.una-pos', r); if (p) p.textContent = i + 1; }); $$('[data-count="' + name + '"]').forEach(function (c) { c.textContent = rs.length; }); var empty = $('[data-empty="' + name + '"]'); if (empty) empty.hidden = rs.length > 0; } // Length as lint-unattend.py counts it: the escaped text in the file. function xmlLength(s) { var n = s.length; for (var i = 0; i < s.length; i++) { var c = s.charAt(i); if (c === '&') n += 4; else if (c === '<' || c === '>') n += 3; } return n; } function counter(el, input, limit, always) { if (!el || !limit) return; var n = xmlLength(input.value); var near = n > limit * 0.9; el.classList.toggle('near', near && n <= limit); el.classList.toggle('over', n > limit); el.title = 'Characters as written in the XML (& counts 5, < and > count 4); limit ' + limit + '.'; if (!always && n < limit * 0.8) { el.textContent = ''; return; } if (el.getAttribute('data-for') === 'desc') { el.textContent = 'Description ' + n + ' / ' + limit + ' chars' + (n > limit ? ': over the limit, Windows rejects the whole pass' : ''); } else { el.textContent = n + ' / ' + limit + (n > limit ? ' over!' : ''); } } function refreshCounters(row) { var cmd = $('.una-cmd', row); if (!cmd) return; var lim = parseInt(cmd.getAttribute('data-limit') || row.parentNode.getAttribute('data-limit'), 10); counter($('[data-for="cmd"]', row), cmd, lim, true); var desc = $('.una-desc', row); counter($('[data-for="desc"]', row), desc, parseInt(desc.getAttribute('data-limit'), 10), false); var foot = $('.una-foot', row); if (foot) foot.hidden = !foot.textContent.trim(); } function grow(ta) { if (!ta.offsetParent) return; // hidden: sized when shown ta.style.height = 'auto'; ta.style.height = (ta.scrollHeight + 2) + 'px'; } function growAll() { $$('textarea.una-cmd').forEach(grow); } // --------------------------------------------------------------------- // Dirty state // --------------------------------------------------------------------- var raw = document.getElementById('unaRawXml'); var reviewBtn = document.getElementById('unaReview'); var initial = '', initialSections = {}, rawInitial = raw ? raw.value : ''; var saving = false; function formDirty() { return JSON.stringify(collect()) !== initial; } function rawDirty() { return raw && raw.value !== rawInitial; } function update() { var f = collect(); var fd = JSON.stringify(f) !== initial; var rd = rawDirty(); var head = document.querySelector('.image-head'); if (head) head.classList.toggle('dirty', fd || rd); var page = document.getElementById('unaPage'); if (page) page.classList.toggle('dirty', fd || rd); if (reviewBtn) reviewBtn.disabled = !(fd || rd); var now = sectionState(f); var groups = {}; Object.keys(now).forEach(function (s) { var changed = now[s] !== initialSections[s]; var a = $('.una-index a[href="#sec-' + s + '"]'); if (!a) return; a.classList.toggle('has-edits', changed); if (changed) groups[a.getAttribute('data-group')] = true; }); $$('.una-index a.ix-group').forEach(function (a) { a.classList.toggle('has-edits', !!groups[a.getAttribute('data-group')]); }); $$('.una-field').forEach(function (el) { if (el.type !== 'hidden') el.classList.toggle('una-field-changed', el.value !== el.getAttribute('data-initial')); }); $$('.una-row[data-orig], .una-simple[data-orig]').forEach(function (r) { r.classList.toggle('is-edited', rowSnapshot(r) !== r.getAttribute('data-initial')); }); } // --------------------------------------------------------------------- // Editing: input, add, remove, move, drag // --------------------------------------------------------------------- document.addEventListener('input', function (e) { var t = e.target; if (t.classList && t.classList.contains('una-cmd')) { if (/[\r\n]/.test(t.value)) { // one line in the file; a pasted break is a space var pos = t.selectionStart; t.value = t.value.replace(/[\r\n]+/g, ' '); t.setSelectionRange(pos, pos); } grow(t); } var row = t.closest && t.closest('.una-row'); if (row) refreshCounters(row); if (t.id === 'unaFilter') applyFilter(); else scheduleUpdate(); }); document.addEventListener('change', function (e) { if (e.target.closest && e.target.closest('#unaFormView, #unaRawView')) scheduleUpdate(); }); document.addEventListener('keydown', function (e) { if (e.target.classList && e.target.classList.contains('una-cmd') && e.key === 'Enter') { e.preventDefault(); // commands are single lines } if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); if (reviewBtn && !reviewBtn.disabled) review(); } if (e.target.id === 'unaFilter' && e.key === 'Escape') { e.target.value = ''; applyFilter(); } }); var pending = null; function scheduleUpdate() { if (pending) return; pending = window.requestAnimationFrame(function () { pending = null; update(); }); } function listName(row) { var l = row.parentNode; return l ? l.getAttribute('data-list') : null; } function afterChange(name) { renumber(name); scheduleUpdate(); } document.addEventListener('click', function (e) { var btn = e.target.closest('.una-up, .una-down, .una-remove, .una-add'); if (!btn) return; if (btn.classList.contains('una-add')) { addRow(btn.getAttribute('data-list')); return; } var row = btn.closest('.una-row, .una-simple'); var name = listName(row); // Close the row's menu first: left open it covers the next row's menu. var menu = btn.closest('details.more'); if (menu) menu.open = false; if (btn.classList.contains('una-remove')) { row.remove(); } else if (btn.classList.contains('una-up') && row.previousElementSibling) { row.parentNode.insertBefore(row, row.previousElementSibling); if (menu) menu.querySelector('summary').focus(); } else if (btn.classList.contains('una-down') && row.nextElementSibling) { row.parentNode.insertBefore(row.nextElementSibling, row); if (menu) menu.querySelector('summary').focus(); } afterChange(name); }); var TPL = { spec: 'unaTplCmd', fl: 'unaTplCmd', driverpaths: 'unaTplDp', accounts: 'unaTplAcct' }; function addRow(name) { var filter = document.getElementById('unaFilter'); if (filter && filter.value) { filter.value = ''; applyFilter(); } var tpl = document.getElementById(TPL[name]); var row = tpl.content.firstElementChild.cloneNode(true); list(name).appendChild(row); var cmd = $('.una-cmd', row); if (cmd) { cmd.setAttribute('data-limit', list(name).getAttribute('data-limit')); grow(cmd); refreshCounters(row); } afterChange(name); row.scrollIntoView({ block: 'center', behavior: 'smooth' }); var first = $('textarea, input', row); if (first) first.focus({ preventScroll: true }); } // Drag only from the grip, so text in the row stays selectable. var dragRow = null; document.addEventListener('mousedown', function (e) { var grip = e.target.closest('.una-grip'); if (grip) grip.closest('.una-row').setAttribute('draggable', 'true'); }); document.addEventListener('mouseup', function () { if (!dragRow) $$('.una-row[draggable]').forEach(function (r) { r.removeAttribute('draggable'); }); }); document.addEventListener('dragstart', function (e) { var row = e.target.closest && e.target.closest('.una-row'); if (!row || !row.getAttribute('draggable')) return; dragRow = row; row.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); }); function clearDrop() { $$('.drop-before, .drop-after').forEach(function (r) { r.classList.remove('drop-before', 'drop-after'); }); } function dropTarget(e) { var row = e.target.closest && e.target.closest('.una-row'); if (!dragRow || !row || row === dragRow || row.parentNode !== dragRow.parentNode) return null; var box = row.getBoundingClientRect(); return { row: row, after: e.clientY > box.top + box.height / 2 }; } document.addEventListener('dragover', function (e) { var t = dropTarget(e); if (!t) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearDrop(); t.row.classList.add(t.after ? 'drop-after' : 'drop-before'); }); document.addEventListener('drop', function (e) { var t = dropTarget(e); if (!t) return; e.preventDefault(); t.row.parentNode.insertBefore(dragRow, t.after ? t.row.nextSibling : t.row); afterChange(listName(dragRow)); }); document.addEventListener('dragend', function () { if (dragRow) { dragRow.classList.remove('dragging'); dragRow.removeAttribute('draggable'); } dragRow = null; clearDrop(); }); // --------------------------------------------------------------------- // Filter over both command lists // --------------------------------------------------------------------- function applyFilter() { var input = document.getElementById('unaFilter'); if (!input) return; var q = input.value.trim().toLowerCase(); var shown = 0, total = 0; ['spec', 'fl'].forEach(function (name) { var l = list(name); l.classList.toggle('filtering', !!q); var hits = 0, rs = rows(name); rs.forEach(function (r, i) { total++; var hay = [$('.una-desc', r).value, $('.una-cmd', r).value, ($('.una-note', r) || {}).textContent || ''].join(' ').toLowerCase(); var hit = !q || hay.indexOf(q) >= 0 || q === String(i + 1); r.hidden = !hit; if (hit) hits++; }); shown += hits; var none = $('[data-nomatch="' + name + '"]'); if (none) none.hidden = !(q && rs.length && !hits); }); var count = document.getElementById('unaFilterCount'); if (count) { count.innerHTML = q ? '' + shown + ' of ' + total + ' commands match. Reordering is off while filtering.' : total + ' commands'; } if (q) growAll(); } // --------------------------------------------------------------------- // Section index follows the scroll // --------------------------------------------------------------------- // The current section is the last one whose top has passed a line a // third of the way down the window; its link (or, in the narrow chip // bar, its group's chip) is marked. var sections = $$('.una-section'); var spyQueued = false; function spy() { spyQueued = false; var line = window.innerHeight * 0.33; var current = sections[0]; sections.forEach(function (s) { if (s.getBoundingClientRect().top <= line) current = s; }); if (!current) return; var id = current.id, group = current.getAttribute('data-group'); $$('.una-index a').forEach(function (a) { var on = a.classList.contains('ix-group') ? a.getAttribute('data-group') === group : a.getAttribute('href').slice(1) === id; a.classList.toggle('active', on); }); // Narrow bar: when the current entry is not shown (a group heading, or // a section folded into its group chip), light the first shown one. var shown = $$('.una-index a').filter(function (a) { return a.offsetParent; }); if (!shown.some(function (a) { return a.classList.contains('active'); })) { var alt = shown.filter(function (a) { return a.getAttribute('data-group') === group; })[0]; if (alt) alt.classList.add('active'); } } window.addEventListener('scroll', function () { if (!spyQueued) { spyQueued = true; window.requestAnimationFrame(spy); } }, { passive: true }); var resizeTimer = null; window.addEventListener('resize', function () { clearTimeout(resizeTimer); resizeTimer = setTimeout(function () { growAll(); spy(); }, 150); }); // --------------------------------------------------------------------- // Review and save // --------------------------------------------------------------------- function activeView() { var rawPane = document.getElementById('unaRawView'); return rawPane && rawPane.classList.contains('active') ? 'raw' : 'form'; } function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; } function lintBlock(lint) { var box = el('div', 'una-review-lint'); if (!lint || !lint.findings || !lint.findings.length) { var okLine = el('div', 'una-lint-ok'); okLine.appendChild(el('i', 'bi bi-check-circle-fill')); okLine.appendChild(document.createTextNode('Lint: no findings in the result (the checks of scripts/lint-unattend.py).')); box.appendChild(okLine); return box; } var bad = lint.new_errors > 0; var a = el('div', 'notice una-lintbox' + (bad ? ' bad' : '')); a.appendChild(el('strong', '', bad ? 'This change adds ' + lint.new_errors + ' lint error' + (lint.new_errors === 1 ? '' : 's') + ' and will not be saved. One error invalidates the whole answer file for its pass.' : 'Lint findings in the result (none added by this change):')); var ul = el('ul', 'una-lint-list'); lint.findings.forEach(function (f) { var li = el('li'); li.appendChild(el('span', 'chip ' + (f.level === 'ERROR' ? 'c-err' : 'c-warn'), f.level)); if (f['new']) li.appendChild(el('span', 'chip c-edit', 'new')); li.appendChild(document.createTextNode(' ' + f.message)); ul.appendChild(li); }); a.appendChild(ul); box.appendChild(a); return box; } function review() { // The view on screen, unless only the other one has edits. var view = activeView(); var fd = formDirty(), rd = rawDirty(); var mode = view; if (view === 'raw' && !rd && fd) mode = 'form'; if (view === 'form' && !fd && rd) mode = 'raw'; var body = { mode: mode, base_sha: CFG.baseSha }; if (mode === 'form') body.form = collect(); else body.raw_xml = raw.value; var modalEl = document.getElementById('imageReviewModal'); var out = document.getElementById('imageReviewBody'); var err = document.getElementById('imageReviewError'); var confirmBtn = document.getElementById('imageReviewConfirm'); if (!modalEl || !window.bootstrap || !window.PxeImage) return; document.getElementById('imageReviewTitle').textContent = 'Review changes: FlatUnattendW10.xml (' + (mode === 'form' ? 'Form' : 'Raw XML') + ')'; out.innerHTML = '