Files
2026-10-08 15:29:06 +00:00

1847 lines
67 KiB
JavaScript

/**
* @package Joomla.Component.Builder
*
* @created 30th April, 2015
* @author Llewellyn van der Merwe <https://dev.vdm.io>
* @git Joomla Component Builder <https://git.vdm.dev/joomla/Component-Builder>
* @copyright Copyright (C) 2015 Vast Development Method. All rights reserved.
* @license GNU General Public License version 2 or later; see LICENSE.txt
*/
/* JS Document */
/**
* The extrusion page: harvest a source, pair every candidate with what the
* target component already has, and import only what was approved.
*
* Language note: every user-facing string comes through the text map the
* template prints (window.JCBExtrusion.text), where each one is a natural
* string inside Text::_() -- never a language constant. JCB manages those
* strings itself when this code is imported, so none may be added to the
* language files by hand.
*/
(function () {
'use strict';
// This file loads in the document head, before the template's inline
// bootstrap defines window.JCBExtrusion -- so the bootstrap is read
// lazily, once the page stands, never at evaluation time.
let E = { url: '', canImport: false, text: {} };
let T = E.text;
/**
* The page state: the harvest payload, the current catalogue of the
* target component, the explicit decisions of the person, and the
* rows ticked for bulk work.
*/
const state = {
busy: false,
data: null,
plan: null,
reviewPending: true,
catalogueRun: 0,
catalogue: null,
catalogueFailed: '',
decisions: {},
selected: new Set(),
candidateIndex: new Map(),
confirmation: null,
modal: null,
modalPowers: null,
modalPowersTruncated: false,
picker: null,
// what each row would change, weighed under the decisions as they
// stand; the diffs of the rows a person has open, and nothing else --
// a closed diff is dropped; and the rows whose weight is being read
// again because a decision just moved it
changes: {},
diffs: {},
weighing: new Set(),
weighingFailed: '',
// which disclosures a person has open, so a re-draw leaves the board
// exactly where they were reading it
opened: new Set()
};
// the weighing that is on its way, if any: decisions made in a burst are
// weighed once, and only the latest answer is allowed to land
let weighingTimer = null;
let weighingRun = 0;
let powerSearchTimer = null;
let powerSearchRun = 0;
let batchingDecisions = false;
const $ = (id) => document.getElementById(id);
const $$ = (selector, root) => Array.from((root || document).querySelectorAll(selector));
const esc = (value) => String(value ?? '').replace(/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[c]));
/**
* One POST to the ajax gateway.
*/
async function post(task, params) {
const body = new FormData();
Object.entries(params || {}).forEach(([key, value]) => body.append(key, value));
let response;
try {
response = await fetch(E.url + task, { method: 'POST', body: body });
} catch (error) {
throw new Error(T.networkFailed);
}
const status = ' (HTTP ' + response.status + ')';
const reference = response.headers.get('X-Request-ID') || '';
const referenceText = /^[a-zA-Z0-9._:-]{1,100}$/.test(reference)
? ' ' + T.failureReference + ' ' + reference : '';
if (!response.ok) {
// Never expose a proxy/server HTML response or its status text.
throw new Error(T.httpFailed + status + referenceText);
}
let payload;
try {
payload = await response.json();
} catch (error) {
throw new Error(T.invalidResponse + status + referenceText);
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error(T.invalidResponse + status + referenceText);
}
if (payload.error) {
const failure = payload.failure || {};
const safeReference = /^[a-f0-9]{16}$/.test(failure.reference || '')
? ' ' + T.failureReference + ' ' + failure.reference : '';
payload.error = (typeof payload.error === 'string' ? payload.error : T.operationFailed)
+ safeReference;
}
return payload;
}
/** Lock the run controls until the single requested operation answers. */
function beginRun() {
state.busy = true;
const controls = $$('#extrusion-tabs .nav-link, #extrusion-harvest-button, #extrusion-repair-namespaces-button, #extrusion-import-button');
const disabled = controls.map((control) => control.disabled);
controls.forEach((control) => { control.disabled = true; });
return () => {
state.busy = false;
controls.forEach((control, index) => { control.disabled = disabled[index]; });
renderReview();
};
}
/**
* Show one pane and mirror it in the tab strip.
*/
function showPane(name) {
$$('.extrusion-pane').forEach((pane) => {
pane.style.display = pane.dataset.extrusionPane === name ? 'block' : 'none';
});
$$('#extrusion-tabs .nav-link').forEach((tab) => {
tab.classList.toggle('active', tab.dataset.extrusionTab === name);
});
}
function enableTab(name) {
const tab = document.querySelector('#extrusion-tabs [data-extrusion-tab="' + name + '"]');
if (tab) {
tab.disabled = false;
}
}
/** The dedicated repair action remains the same operation through review and apply. */
function isNamespaceRepair() {
return Number((state.config || {}).repair_namespaces) === 1;
}
/** Make the namespace-only operation visible without changing ordinary import labels. */
function renderRunMode() {
const repair = isNamespaceRepair();
$('extrusion-pairing-title').textContent = repair ? T.repairPairingTitle : T.pairingTitle;
$('extrusion-namespace-repair-notice').hidden = !repair;
$('extrusion-import-label').textContent = repair ? T.repairLabel : T.importLabel;
$('extrusion-results-title').textContent = repair ? T.repairReportTitle : T.reportTitle;
$$('#extrusion-bulk-bar [data-extrusion-bulk="create"]').forEach((button) => {
button.hidden = repair;
});
}
/**
* Read the whole setup form into the run configuration.
*/
function readConfig() {
const form = $('adminForm');
const value = (name, fallback) => {
const field = form.elements[name];
return field ? field.value : fallback;
};
const radio = (name, fallback) => {
const field = form.elements[name];
if (!field) {
return fallback;
}
return field.value !== undefined && field.value !== ''
? field.value : fallback;
};
const componentField = value('component_id', '');
const config = {
admin_path: value('admin_path', '').trim(),
site_path: value('site_path', '').trim(),
libraries: value('libraries', '').split(/\r?\n/)
.map((line) => line.trim()).filter((line) => line !== ''),
component: componentField === '' ? 0 : parseInt(componentField, 10) || 0,
detect: componentField === '',
component_code: value('component_code', '').trim(),
mode: radio('mode', 'create'),
on_existing: radio('on_existing', 'update'),
layout: value('layout', 'auto'),
language_tag: value('language_tag', 'en-GB'),
table_class: radio('table_class', 'auto'),
dry_run: radio('dry_run', '0'),
strict: radio('strict', '0'),
depth: parseInt(value('depth', '12'), 10) || 12,
max_files: parseInt(value('max_files', '20000'), 10) || 20000
};
['admin', 'site', 'site_views', 'tabs', 'conditions', 'language',
'translations', 'relations', 'component_details'].forEach((scope) => {
config['scope_' + scope] = radio('scope_' + scope, '1');
});
// radios render as a set, so read the checked one directly
$$('#adminForm input[type="radio"]:checked').forEach((input) => {
if (input.name in config || input.name.indexOf('scope_') === 0) {
config[input.name] = input.value;
}
});
return config;
}
/**
* Run the harvest and land on the pairing board.
*/
async function harvest(repairNamespaces = false) {
if (state.busy) {
return;
}
const notice = $('extrusion-setup-notice');
notice.style.display = 'none';
const config = readConfig();
config.repair_namespaces = repairNamespaces ? 1 : 0;
if (repairNamespaces && (config.mode !== 'update' || config.component <= 0)) {
notice.textContent = T.repairNeedTarget;
notice.style.display = 'block';
return;
}
if (repairNamespaces && config.libraries.length === 0) {
notice.textContent = T.repairNeedLibraries;
notice.style.display = 'block';
return;
}
if (repairNamespaces) {
config.on_existing = 'update';
config.detect = false;
}
if (config.admin_path === '' && config.site_path === ''
&& config.libraries.length === 0) {
notice.textContent = T.needSource;
notice.style.display = 'block';
return;
}
state.config = config;
renderRunMode();
window.clearTimeout(weighingTimer);
invalidateReview();
const finishRun = beginRun();
$('extrusion-running-title').textContent = config.admin_path || T.theSource;
$('extrusion-running-verb').textContent = T.harvesting;
showPane('running');
let payload;
try {
payload = await post('extrusionHarvest', { config: JSON.stringify(config) });
} catch (error) {
payload = { error: error.message || T.requestFailed };
}
// only an answer that says success is one: a missing or malformed
// payload must land back on setup with a message, never on an empty
// pairing board
if (!payload || payload.error || !payload.success) {
finishRun();
showPane('setup');
notice.textContent = (payload && payload.error) ? payload.error : T.harvestFailed;
notice.style.display = 'block';
return;
}
// the powers arrive raw from the harvest, so they take their board
// identity here, once -- before anything renders or looks them up
if (payload.powers && payload.powers.classes) {
payload.powers.classes.forEach((candidate) => {
candidate.kind = 'power';
candidate.key = candidate.source_key;
candidate.label = candidate.class;
candidate.detail = candidate.fqn;
});
}
state.config.source_component = payload.source_component ?? payload.component ?? 0;
state.data = payload;
state.decisions = {};
state.selected.clear();
state.changes = payload.changes || {};
state.diffs = {};
state.weighing.clear();
state.weighingFailed = payload.weighing || '';
window.clearTimeout(weighingTimer);
weighingRun++;
fillComponentSelect(payload);
acceptReview(payload);
await loadCatalogue(payload.component || 0);
finishRun();
enableTab('setup');
enableTab('pairing');
renderBoard();
showPane('pairing');
}
/**
* Offer every published component as a pairing target.
*/
function fillComponentSelect(payload) {
const select = $('extrusion-component-select');
select.innerHTML = '';
const none = document.createElement('option');
none.value = '0';
none.textContent = T.noTarget;
none.disabled = isNamespaceRepair();
select.appendChild(none);
(payload.components || []).forEach((component) => {
const option = document.createElement('option');
option.value = String(component.id);
option.textContent = component.name + ' [' + component.code + ']';
select.appendChild(option);
});
select.value = String(payload.component || 0);
$$('.extrusion-detected', select.parentNode).forEach((note) => note.remove());
if (payload.detected && payload.detected.name) {
const detected = document.createElement('div');
detected.className = 'alert alert-info extrusion-detected';
detected.textContent = T.detected + ' ' + payload.detected.name
+ ' [' + payload.detected.code + ']';
select.parentNode.appendChild(detected);
}
}
/**
* Fetch the catalogue of one component and re-pair everything by name,
* exactly the way the server pairs -- lowercase, name then system name.
*/
async function loadCatalogue(componentId) {
const run = ++state.catalogueRun;
let catalogue;
try {
catalogue = await post('extrusionCatalogue', { component_id: componentId });
} catch (error) {
catalogue = { error: error.message || T.requestFailed };
}
if (run !== state.catalogueRun) {
return false;
}
state.catalogue = (catalogue && !catalogue.error) ? catalogue : null;
// a failed catalogue must be said, never quietly rendered as a board
// where nothing happens to match
state.catalogueFailed = state.catalogue === null
? ((catalogue && catalogue.error) || T.catalogueFailed) : '';
rematch();
return true;
}
function matchByGuid(guid, pool) {
const wanted = String(guid || '').trim().toLowerCase();
if (wanted === '') {
return null;
}
for (const row of pool || []) {
if (String(row.guid || '').trim().toLowerCase() === wanted) {
return { guid: row.guid, label: row.name || row.system, by: 'guid' };
}
}
return null;
}
function matchByName(names, pool) {
const wanted = names.map((name) => String(name || '').trim().toLowerCase())
.filter((name) => name !== '');
for (const row of pool || []) {
for (const field of ['name', 'system']) {
const value = String(row[field] || '').trim().toLowerCase();
if (value !== '' && wanted.indexOf(value) !== -1) {
return { guid: row.guid, label: row.name || row.system, by: 'name' };
}
}
}
return null;
}
function match(candidate, names, pool) {
return matchByGuid(candidate.guid, pool) || matchByName(names, pool);
}
/**
* Re-draw the proposed pairings from the current catalogue. Explicit
* decisions are cleared, because they pointed into another component.
*/
/**
* A name answering inside what the component itself links is that record
* rediscovered, exactly as the server pairs it -- so the board proposes
* the update the import will perform, never a creation it will not.
*/
function scopedLabel(candidate) {
if (candidate.match && candidate.match.by === 'name') {
candidate.match.by = 'scoped';
}
}
function rematch() {
if (!state.data || !state.data.candidates) {
return;
}
const candidates = state.data.candidates;
const catalogue = state.catalogue || {};
(candidates.admin_view || []).forEach((view) => {
view.match = match(view, [view.label, view.detail], catalogue.admin_views);
scopedLabel(view);
// the fields the paired view already links are its own wiring: a
// column answering to one of them is that wiring rediscovered, so
// it weighs like an identity rather than a mere resemblance
const scoped = view.match
? (catalogue.fields || []).filter((row) => row.view === view.match.guid)
: [];
(view.fields || []).forEach((field) => {
// a shared member is decided on its owner's row: the group is
// one field, so this row takes no pairing of its own unless
// the person detaches it first
if (field.shared) {
field.match = null;
field.detached = false;
return;
}
let found = matchByGuid(field.guid, catalogue.fields);
if (!found) {
found = matchByName([field.label, field.detail], scoped);
if (found) {
found.by = 'scoped';
}
}
field.match = found
|| matchByName([field.label, field.detail], catalogue.fields);
});
});
(candidates.site_view || []).forEach((candidate) => {
candidate.match = match(candidate, [candidate.label], catalogue.site_views);
scopedLabel(candidate);
});
(candidates.custom_admin_view || []).forEach((candidate) => {
candidate.match = match(candidate, [candidate.label], catalogue.custom_admin_views);
scopedLabel(candidate);
});
(candidates.layout || []).forEach((candidate) => {
candidate.match = match(candidate, [candidate.label], catalogue.layouts);
});
(candidates.template || []).forEach((candidate) => {
candidate.match = match(candidate, [candidate.label], catalogue.templates);
});
state.decisions = {};
state.selected.clear();
}
/**
* The proposal one candidate arrives with, before any explicit decision.
*
* Only a guid in common is an identity, so only a guid match proposes an
* update. A shared name is a resemblance: it stays on offer as the Update
* action's suggested target, but the default is a fresh creation -- the
* person decides whether the lookalike is really the same thing.
*/
function proposal(candidate) {
if (candidate.kind === 'power') {
if (candidate.status === 'matched') {
const target = candidate.target || {};
return { action: candidate.action === 'skip' ? 'skip' : 'update',
target: candidate.matched_guid, label: target.system_name || target.name || candidate.matched_guid };
}
return { action: candidate.status === 'new' ? 'create'
: (candidate.status === 'ignored' || candidate.status === 'filtered' ? 'ignore' : 'blocked') };
}
return candidate.match
&& (candidate.match.by === 'guid' || candidate.match.by === 'scoped')
? { action: 'update', target: candidate.match.guid, label: candidate.match.label }
: { action: 'create' };
}
function decision(candidate) {
const kind = state.decisions[candidate.kind] || {};
return kind[candidate.key] || proposal(candidate);
}
function decide(candidate, verdict) {
// a decision moves what this row would write, and what the rows tied
// to it would write: the weights are read again under the pairing
// they have now, rather than left showing a number that has moved
unweigh(candidate);
state.decisions[candidate.kind] = state.decisions[candidate.kind] || {};
if (verdict === null) {
delete state.decisions[candidate.kind][candidate.key];
} else {
state.decisions[candidate.kind][candidate.key] = verdict;
}
}
function explicit(candidate) {
const kind = state.decisions[candidate.kind] || {};
return Object.prototype.hasOwnProperty.call(kind, candidate.key);
}
/**
* The configuration a run on the board uses: the setup as harvested,
* aimed at the component the board pairs against -- detected or chosen --
* so a weighing, a diff and the import are all the same run.
*/
function runConfig(importing = false, scopes = []) {
const config = Object.assign({}, state.config);
const select = $('extrusion-component-select');
config.component = parseInt(select.value, 10) || 0;
config.detect = false;
delete config.approved_plan;
delete config.acknowledged_scopes;
if (importing && state.plan) {
config.approved_plan = state.plan.fingerprint;
config.acknowledged_scopes = {};
scopes.forEach((scope) => { config.acknowledged_scopes[scope] = true; });
}
return config;
}
/** Invalidate immediately, including an older request still in flight. */
function invalidateReview() {
weighingRun++;
state.reviewPending = true;
state.plan = null;
closeConfirmation();
renderReview();
}
/** Keep source keys, never guessed target GUIDs, as the board identity. */
function acceptReview(payload) {
if (payload.powers && Array.isArray(payload.powers.classes)) {
payload.powers.classes.forEach((candidate) => {
candidate.kind = 'power';
candidate.key = candidate.source_key;
candidate.label = candidate.class;
candidate.detail = candidate.fqn;
});
state.data.powers = payload.powers;
}
const plan = payload.plan;
const same = state.plan && plan && state.plan.fingerprint === plan.fingerprint;
state.plan = plan && /^[a-f0-9]{64}$/.test(plan.fingerprint || '') ? plan : null;
state.reviewPending = false;
if (!same) {
closeConfirmation();
}
}
function reviewReady() {
if (state.busy || !E.canImport || state.catalogueFailed || state.reviewPending || state.weighingFailed || !state.plan
|| state.plan.status !== 'preview' || Object.keys(state.plan.blockers || {}).length) {
return false;
}
return true;
}
/** Public plans carry blocker records; display their explanations. */
function planBlockers(plan) {
return Object.values((plan || {}).blockers || {}).map((blocker) =>
blocker && typeof blocker === 'object' ? String(blocker.reason || T.operationFailed) : String(blocker));
}
function renderReview() {
const notice = $('extrusion-review-notice');
if (!notice) {
return;
}
const blockers = planBlockers(state.plan);
const pending = state.reviewPending || !state.plan;
notice.className = 'alert ' + (pending ? 'alert-info' : (blockers.length ? 'alert-warning' : 'alert-success'));
const ambiguous = pending ? [] : (((state.data || {}).powers || {}).classes || []);
$('extrusion-ambiguity-notice').hidden = !ambiguous.some((candidate) => candidate.status === 'ambiguous');
const ambiguousKeys = new Set(ambiguous.filter((candidate) => candidate.status === 'ambiguous')
.map((candidate) => candidate.source_key));
const reasons = [...new Set(Object.values((state.plan || {}).blockers || {})
.filter((entry) => !entry || typeof entry !== 'object'
|| !ambiguousKeys.has(String(entry.key || '').split('.')[1]))
.map((entry) => entry && typeof entry === 'object' ? entry.reason : String(entry)))];
notice.innerHTML = esc(state.weighingFailed || (pending ? T.reviewPending
: (blockers.length ? T.reviewBlocked : T.reviewReady)))
+ (!pending && reasons.length ? '<details class="extrusion-review-details"><summary>'
+ esc(T.blockerDetails) + '</summary><ul>'
+ reasons.map((reason) => '<li>' + esc(reason) + '</li>').join('') + '</ul></details>' : '');
const button = $('extrusion-import-button');
if (button) {
button.disabled = !reviewReady();
}
}
/** Render the server's actual target and bounded evidence, independently of the source. */
function powerDetails(candidate) {
if (state.reviewPending) {
return '<span class="extrusion-power-evidence">' + esc(T.reviewPending) + '</span>';
}
const target = candidate.target;
let html = '<span class="extrusion-power-evidence">';
if (candidate.action === 'skip') {
html += '<span>' + esc(T.skippedExisting) + '</span> ';
}
if (target) {
html += '<span>' + esc(T.actualTarget) + ': <strong class="extrusion-target-name">'
+ esc(target.system_name || target.name) + '</strong></span>'
+ '<code class="extrusion-target-guid">' + esc(target.guid) + '</code>'
+ '<code class="extrusion-target-namespace">' + esc(target.namespace) + '</code>';
} else if (candidate.status === 'new') {
html += '<span>' + esc(T.newIdentity) + ': <code>' + esc(candidate.write_guid) + '</code></span>';
}
(candidate.status === 'ambiguous' ? [T.ambiguousHint] : (candidate.blockers || [])).forEach((reason) => {
html += '<span class="text-danger">' + esc(reason) + '</span>';
});
const alternatives = (candidate.alternatives || []).filter((entry) => !target || entry.guid !== target.guid);
if (alternatives.length) {
html += '<details><summary>' + esc(T.otherCandidates) + ' (' + alternatives.length + ')</summary>';
alternatives.forEach((entry) => {
html += '<span><code>' + esc(entry.guid) + '</code> ' + esc(entry.system_name)
+ '</span><code>' + esc(entry.namespace) + '</code>';
});
html += '</details>';
}
const namespace = candidate.namespace_proposal;
if (namespace && (namespace.relocation || namespace.provenance === 'namespace-repair')) {
html += '<span>' + esc(namespace.provenance === 'namespace-repair' ? T.repairProposal : T.relocation)
+ ': <code>' + esc(namespace.value) + '</code></span>';
}
return html + '</span>';
}
/**
* What one row of the board would change, as it was last weighed.
*/
function weight(candidate) {
return state.changes[candidate.kind + '|' + candidate.key] || null;
}
/**
* Mark a row, and the rows tied to it, as owed a fresh weighing.
*
* A view's fields are written under the view's pairing, and a field's
* links are written on its view's row, so a decision on either moves the
* other. The whole board is weighed again; these are the rows that say
* so while the answer is on its way.
*/
function unweigh(candidate) {
state.weighing.add(candidate.kind + '|' + candidate.key);
if (candidate.kind === 'admin_view') {
(candidate.fields || []).forEach((field) => {
state.weighing.add('field|' + field.key);
});
} else if (candidate.kind === 'field') {
state.weighing.add('admin_view|' + candidate.key.split('.')[0]);
}
scheduleWeighing();
}
function scheduleWeighing() {
if (batchingDecisions) {
return;
}
invalidateReview();
window.clearTimeout(weighingTimer);
weighingTimer = window.setTimeout(reweigh, 400);
}
/**
* Read every row's weight again under the decisions as they stand.
*
* Only the latest answer counts: a decision made while one is on its way
* starts another, and the earlier answer is dropped when it lands. A diff
* left open on a row whose weight moved is read again too, so nothing on
* the board answers for a pairing that is gone.
*/
async function reweigh() {
const run = ++weighingRun;
let payload;
try {
payload = await post('extrusionWeigh', {
config: JSON.stringify(runConfig()),
decisions: JSON.stringify(buildDecisions())
});
} catch (error) {
payload = { error: error.message || T.requestFailed };
}
if (run !== weighingRun) {
return;
}
const owed = new Set(state.weighing);
state.weighing.clear();
if (!payload || payload.error || !payload.changes) {
state.weighingFailed = (payload && payload.error) || T.weighingFailed;
renderBoard();
return;
}
acceptReview(payload);
const before = state.changes;
state.changes = payload.changes;
state.weighingFailed = '';
Object.keys(state.diffs).forEach((id) => {
if (!state.changes[id]) {
closeDiff(id);
} else if (owed.has(id) || moved(before[id], state.changes[id])) {
fetchDiff(id);
}
});
renderBoard();
}
function moved(was, now) {
return !was || was.changed !== now.changed
|| was.additions !== now.additions || was.deletions !== now.deletions;
}
/**
* The badge that says what a row would change, and opens the diff.
*/
function changeBadge(candidate) {
const id = candidate.kind + '|' + candidate.key;
if (state.weighing.has(id)) {
return '<span class="extrusion-change extrusion-change-weighing" title="'
+ esc(T.weighingHint) + '">' + esc(T.weighing) + '</span>';
}
const owed = weight(candidate);
if (!owed) {
return '';
}
// a record that would change nothing is not written -- the engine
// settles that itself. The row is never set to ignore for it: ignoring
// takes the row out of the run altogether, and the view would lose the
// links and conditions that still have to be written for it
if (!owed.changed) {
return '<span class="extrusion-change extrusion-change-none" title="'
+ esc(T.noChangeHint) + '">' + esc(T.noChange) + '</span>';
}
const open = Object.prototype.hasOwnProperty.call(state.diffs, id);
return '<button type="button" class="extrusion-change' + (open ? ' open' : '')
+ '" data-extrusion-act="diff" aria-expanded="' + (open ? 'true' : 'false')
+ '" title="' + esc(T.diffHint) + '">'
+ '<span class="extrusion-add">+' + owed.additions + '</span>'
+ '<span class="extrusion-del">&minus;' + owed.deletions + '</span></button>';
}
/**
* Open or close one row's diff. Only what is open is held.
*/
async function toggleDiff(candidate) {
const id = candidate.kind + '|' + candidate.key;
if (Object.prototype.hasOwnProperty.call(state.diffs, id)) {
closeDiff(id);
renderBoard();
return;
}
if (candidate.kind === 'field') {
state.opened.add('fields:admin_view|' + candidate.key.split('.')[0]);
}
await fetchDiff(id);
}
/**
* Read one row's diff under the decisions as they stand, and hold it.
*/
async function fetchDiff(id) {
const run = weighingRun;
state.diffs[id] = { loading: true };
renderBoard();
let payload;
try {
payload = await post('extrusionDiff', {
config: JSON.stringify(runConfig()),
decisions: JSON.stringify(buildDecisions()),
row: id
});
} catch (error) {
payload = { error: error.message || T.requestFailed };
}
if (run !== weighingRun) {
return;
}
if (payload && payload.plan && state.plan
&& payload.plan.fingerprint !== state.plan.fingerprint) {
scheduleWeighing();
}
// the person may have closed it while it was on its way
if (Object.prototype.hasOwnProperty.call(state.diffs, id)) {
state.diffs[id] = payload && payload.error
? { error: payload.error }
: { records: (payload && payload.records) || [] };
}
renderBoard();
}
function closeDiff(id) {
delete state.diffs[id];
}
/**
* One row's diff, read only, side by side.
*/
function renderDiff(id) {
const held = state.diffs[id];
if (!held) {
return '';
}
if (held.loading) {
return '<div class="extrusion-diff"><p class="extrusion-diff-note">'
+ esc(T.diffLoading) + '</p></div>';
}
if (held.error) {
return '<div class="extrusion-diff"><p class="extrusion-diff-note">'
+ esc(held.error) + '</p></div>';
}
if (!held.records.length) {
return '<div class="extrusion-diff"><p class="extrusion-diff-note">'
+ esc(T.noChange) + '</p></div>';
}
let html = '<div class="extrusion-diff">';
held.records.forEach((record) => {
html += '<div class="extrusion-diff-record"><h4>' + esc(record.table)
+ ' <small>' + esc(record.action === 'create' ? T.diffCreates : T.diffUpdates)
+ '</small></h4>';
record.columns.forEach((column) => {
html += '<div class="extrusion-diff-column"><h5>' + esc(column.name)
+ ' <span class="extrusion-add">+' + column.additions + '</span>'
+ ' <span class="extrusion-del">&minus;' + column.deletions + '</span></h5>'
+ '<table class="extrusion-diff-table">';
column.hunks.forEach((hunk, index) => {
if (index > 0) {
html += '<tr class="extrusion-diff-gap"><td colspan="4">&hellip;</td></tr>';
}
html += diffRows(hunk.lines);
});
html += '</table></div>';
});
html += '</div>';
});
return html + '</div>';
}
/**
* The lines of one hunk, the removals beside the additions that replace
* them -- which is how a person reads a change, one side against the other.
*/
function diffRows(lines) {
let html = '';
let removed = [];
let added = [];
const flush = () => {
const rows = Math.max(removed.length, added.length);
for (let index = 0; index < rows; index++) {
html += diffRow(removed[index] || null, added[index] || null);
}
removed = [];
added = [];
};
lines.forEach((line) => {
if (line.op === 'remove') {
removed.push(line);
} else if (line.op === 'add') {
added.push(line);
} else {
flush();
html += diffRow(line, line);
}
});
flush();
return html;
}
function diffRow(left, right) {
const side = (line, kind) => {
if (!line) {
return '<td class="extrusion-diff-num"></td><td class="extrusion-diff-line empty"></td>';
}
const number = kind === 'old' ? line.old : line.new;
return '<td class="extrusion-diff-num">' + (number === null ? '' : number)
+ '</td><td class="extrusion-diff-line ' + esc(line.op) + '">'
+ esc(line.text) + '</td>';
};
return '<tr>' + side(left, 'old') + side(right, 'new') + '</tr>';
}
/**
* Every candidate on the board, flat, for bulk work and the import.
*/
function allCandidates() {
const rows = [];
const data = state.data || {};
const candidates = data.candidates || {};
(candidates.admin_view || []).forEach((view) => {
rows.push(view);
(view.fields || []).forEach((field) => rows.push(field));
});
['site_view', 'custom_admin_view', 'layout', 'template'].forEach((kind) => {
(candidates[kind] || []).forEach((candidate) => rows.push(candidate));
});
if (data.powers && data.powers.classes) {
data.powers.classes.forEach((candidate) => rows.push(candidate));
}
return rows;
}
/**
* Render the whole pairing board.
*/
function renderBoard() {
const board = $('extrusion-board');
state.candidateIndex = new Map(allCandidates().map((candidate) =>
[candidate.kind + '|' + candidate.key, candidate]));
const data = state.data || {};
const candidates = data.candidates || {};
let html = '';
if (state.catalogueFailed) {
html += '<div class="alert alert-warning" data-extrusion-warning="catalogue">'
+ esc(T.catalogueFailed) + ' ' + esc(state.catalogueFailed) + '</div>';
}
if (state.weighingFailed) {
html += '<div class="alert alert-warning" data-extrusion-warning="weighing">'
+ esc(state.weighingFailed) + '</div>';
}
if ((candidates.admin_view || []).length) {
html += kindSection('admin_view', T.adminViews, candidates.admin_view, true);
}
['site_view', 'custom_admin_view', 'layout', 'template'].forEach((kind) => {
const label = {
site_view: T.siteViews,
custom_admin_view: T.customAdminViews,
layout: T.layouts,
template: T.templates
}[kind];
if ((candidates[kind] || []).length) {
html += kindSection(kind, label, candidates[kind], false);
}
});
if (data.powers && (data.powers.classes || []).length) {
html += powersSection(data.powers);
}
board.innerHTML = html;
renderReview();
applyFilter($('extrusion-filter').value);
$$('#extrusion-board [data-extrusion-open]').forEach((node) => {
node.addEventListener('toggle', () => {
const key = node.dataset.extrusionOpen;
if (node.open) {
state.opened.add(key);
} else {
state.opened.delete(key);
}
});
});
}
/** One compact status, independent of the effective change underneath it. */
function matchingStatus(candidate) {
if (candidate.kind === 'power') {
return candidate.status || 'unresolved';
}
if (candidate.shared && !candidate.detached) {
return 'shared';
}
const current = decision(candidate);
if (current.action === 'ignore') {
return 'ignored';
}
if (current.action === 'update' && current.target) {
return 'matched';
}
return candidate.match ? 'similar' : 'unmatched';
}
/** Classify only the current validated proposal, never an old update label. */
function plannedChange(candidate) {
if (state.reviewPending) {
return 'pending';
}
const current = decision(candidate);
if (['ignore', 'ignored', 'filtered'].includes(current.action)) {
return 'ignore';
}
if (current.action === 'blocked' || ['ambiguous', 'conflict', 'unresolved'].includes(candidate.status)) {
return 'blocked';
}
if (current.action === 'skip' || (candidate.shared && !candidate.detached)) {
return 'nochange';
}
const proposal = weight(candidate);
return proposal ? (proposal.changed ? proposal.action : 'nochange') : 'pending';
}
function rowHeading(candidate) {
const status = matchingStatus(candidate);
return '<span class="extrusion-row-heading"><b>' + esc(candidate.label) + '</b>'
+ ' <span class="badge bg-secondary extrusion-match-status" data-extrusion-status="'
+ esc(status) + '">' + esc(T['status_' + status] || status) + '</span>'
+ (candidate.detail && candidate.detail !== candidate.label
? ' <small>' + esc(candidate.detail) + '</small>' : '') + '</span>';
}
function groupCheck(label) {
return '<input type="checkbox" class="extrusion-group-check" data-extrusion-group'
+ ' aria-label="' + esc(T.selectGroup + ' ' + label) + '" /> ';
}
function counts(list) {
let matched = 0;
let similar = 0;
let shared = 0;
let unresolved = 0;
(list || []).forEach((candidate) => {
if (candidate.kind === 'power' && !['matched', 'new'].includes(candidate.status)) {
unresolved++;
} else if (candidate.shared && !candidate.detached) {
shared++;
} else if ((candidate.match
&& (candidate.match.by === 'guid' || candidate.match.by === 'scoped'))
|| (candidate.kind === 'power' && candidate.status === 'matched')) {
matched++;
} else if (candidate.match) {
similar++;
}
});
return ' <small>(' + list.length + ' ' + esc(T.items) + ', ' + matched + ' '
+ esc(T.matched) + ', ' + similar + ' ' + esc(T.similar) + ', '
+ (shared ? shared + ' ' + esc(T.shared) + ', ' : '')
+ (unresolved ? unresolved + ' ' + esc(T.unresolved) + ', ' : '')
+ (list.length - matched - shared - unresolved) + ' ' + esc(T.newItem) + ')</small>';
}
function kindSection(kind, label, list, withFields) {
let html = '<details class="extrusion-kind" open data-extrusion-group-container data-extrusion-kind="' + kind + '">'
+ '<summary>' + groupCheck(label) + esc(label) + counts(list) + '</summary>'
+ '<div class="extrusion-rows">';
list.forEach((candidate) => {
html += row(candidate);
if (withFields && (candidate.fields || []).length) {
const fieldsKey = 'fields:' + candidate.kind + '|' + candidate.key;
html += '<details class="extrusion-fields" data-extrusion-group-container data-extrusion-open="'
+ esc(fieldsKey) + '"' + (state.opened.has(fieldsKey) ? ' open' : '')
+ '><summary>'
+ groupCheck(T.fields + ': ' + candidate.label) + esc(T.fields) + counts(candidate.fields) + '</summary>'
+ '<div class="extrusion-rows">';
candidate.fields.forEach((field) => {
html += row(field);
});
html += '</div></details>';
}
});
return html + '</div></details>';
}
function powersSection(powers) {
const byLibrary = {};
(powers.classes || []).forEach((candidate) => {
byLibrary[candidate.library] = byLibrary[candidate.library] || {};
const bundle = candidate.bundle || '.';
byLibrary[candidate.library][bundle] = byLibrary[candidate.library][bundle] || [];
byLibrary[candidate.library][bundle].push(candidate);
});
let html = '<details class="extrusion-kind" open data-extrusion-group-container data-extrusion-kind="power">'
+ '<summary>' + groupCheck(T.powers) + esc(T.powers) + counts(powers.classes) + '</summary>'
+ '<div class="extrusion-rows">';
Object.keys(byLibrary).sort().forEach((library) => {
html += '<details class="extrusion-library" open data-extrusion-group-container><summary>'
+ groupCheck(library) + esc(library) + '</summary>';
Object.keys(byLibrary[library]).sort().forEach((bundle) => {
const list = byLibrary[library][bundle];
html += '<details class="extrusion-bundle" open data-extrusion-group-container><summary>'
+ groupCheck(bundle) + esc(bundle)
+ counts(list) + '</summary><div class="extrusion-rows">';
list.forEach((candidate) => {
html += row(candidate);
});
html += '</div></details>';
});
html += '</details>';
});
return html + '</div></details>';
}
/**
* One candidate row: tick, identity, and the three decisions --
* create new first, then update with a chosen target, then ignore.
*
* A shared member renders as its group's row instead: one field, owned by
* the view that states it first, this view linking it. It takes no
* decision of its own unless the person detaches it -- a detached member
* becomes an ordinary row whose verdict speaks for this one view only.
*/
function row(candidate) {
const id = candidate.kind + '|' + candidate.key;
if (candidate.shared && !candidate.detached) {
return '<div class="extrusion-row extrusion-shared" data-extrusion-row="'
+ esc(id) + '">'
+ '<span class="extrusion-tick"></span>'
+ '<span class="extrusion-identity">' + rowHeading(candidate)
+ ' <span class="badge bg-info extrusion-shared-note">'
+ esc(T.sharedWith) + ' ' + esc(candidate.shared.owner) + '</span>'
+ '</span>'
+ '<span class="extrusion-actions">'
+ '<button type="button" class="btn btn-sm extrusion-act" '
+ 'data-extrusion-act="detach" title="' + esc(T.detachHint) + '">'
+ esc(T.detach) + '</button>'
+ '</span></div>';
}
const current = decision(candidate);
const isExplicit = explicit(candidate);
const isGroup = Boolean(candidate.shared_by && candidate.shared_by.length);
const active = (action) => current.action === action ? ' active' : '';
const targetLabel = current.action === 'update' && current.label
? esc(current.label) : esc(T.update);
return '<div class="extrusion-row' + (isExplicit ? ' explicit' : '')
+ '" data-extrusion-row="' + esc(id) + '">'
+ '<label class="extrusion-tick"><input type="checkbox" data-extrusion-check="'
+ esc(id) + '" aria-label="' + esc(T.selectItem + ' ' + candidate.label) + '"'
+ (state.selected.has(id) ? ' checked' : '') + ' /></label>'
+ '<span class="extrusion-identity">' + rowHeading(candidate)
+ (isGroup
? ' <span class="badge bg-success extrusion-shared-note">'
+ esc(T.oneField) + ' ' + (candidate.shared_by.length + 1) + ' '
+ esc(T.views) + '</span>'
: '')
+ (candidate.shared && candidate.detached
? ' <span class="badge bg-warning extrusion-shared-note">'
+ esc(T.detached) + '</span>'
: '')
+ (candidate.kind === 'power' ? powerDetails(candidate) : '')
+ '</span>'
+ '<span class="extrusion-actions">'
+ changeBadge(candidate)
+ (isNamespaceRepair() ? '' : '<button type="button" class="btn btn-sm extrusion-act' + active('create')
+ '" data-extrusion-act="create">' + esc(T.createNew) + '</button>')
+ '<button type="button" class="btn btn-sm extrusion-act extrusion-act-update' + active('update')
+ '" data-extrusion-act="update" title="' + esc(T.chooseTarget) + '">'
+ targetLabel + '</button>'
+ '<button type="button" class="btn btn-sm extrusion-act' + active('ignore')
+ '" data-extrusion-act="ignore">' + esc(T.ignore) + '</button>'
+ (isExplicit || (candidate.shared && candidate.detached)
? '<button type="button" class="btn btn-sm extrusion-act extrusion-act-reset" '
+ 'data-extrusion-act="reset" title="' + esc(T.proposed) + '">&#8634;</button>'
: '')
+ '</span>' + renderDiff(id) + '</div>';
}
function findCandidate(id) {
return state.candidateIndex.get(id) || null;
}
function wireBoard() {
$('extrusion-board').addEventListener('click', (event) => {
const group = event.target.closest('[data-extrusion-group]');
if (group) {
event.stopPropagation();
selectableRows(group.closest('[data-extrusion-group-container]')).forEach((rowElement) => {
const id = rowElement.dataset.extrusionRow;
if (group.checked) {
state.selected.add(id);
} else {
state.selected.delete(id);
}
rowElement.querySelector('[data-extrusion-check]').checked = group.checked;
});
refreshBulkBar();
return;
}
const button = event.target.closest('[data-extrusion-act]');
if (button) {
const rowElement = button.closest('[data-extrusion-row]');
const candidate = findCandidate(rowElement.dataset.extrusionRow);
if (!candidate) {
return;
}
const action = button.dataset.extrusionAct;
if (action === 'diff') {
toggleDiff(candidate);
return;
}
if (action === 'create') {
decide(candidate, { action: 'create' });
} else if (action === 'ignore') {
decide(candidate, { action: 'ignore' });
} else if (action === 'detach') {
// the person takes this one view out of its group: the row
// becomes an ordinary candidate whose verdict is its own --
// and detaching IS a verdict, or the import would quietly
// return the view to its group
candidate.detached = true;
decide(candidate, { action: 'create' });
} else if (action === 'reset') {
decide(candidate, null);
if (candidate.shared) {
candidate.detached = false;
state.selected.delete(candidate.kind + '|' + candidate.key);
}
} else if (action === 'update') {
openModal(candidate);
return;
}
renderBoard();
return;
}
const check = event.target.closest('[data-extrusion-check]');
if (check) {
const id = check.dataset.extrusionCheck;
if (check.checked) {
state.selected.add(id);
} else {
state.selected.delete(id);
}
refreshBulkBar();
}
});
}
/**
* The one shared searchable picker. A candidate kind draws its choices
* from the catalogue pool of that kind, so even eight hundred powers
* stay one filtered list instead of eight hundred select fields.
*/
function modalPool(kind) {
const catalogue = state.catalogue || {};
const pools = {
admin_view: catalogue.admin_views,
field: catalogue.fields,
site_view: catalogue.site_views,
custom_admin_view: catalogue.custom_admin_views,
layout: catalogue.layouts,
template: catalogue.templates,
power: state.modalPowers === null ? catalogue.powers : state.modalPowers
};
return (pools[kind] || []).map((row) => ({
guid: row.guid,
label: row.name || row.system || row.guid,
detail: row.system && row.system !== row.name
? row.system : (row.namespace || '')
}));
}
function openModal(candidate) {
state.modal = candidate;
state.modalPowers = null;
state.modalPowersTruncated = false;
powerSearchRun++;
window.clearTimeout(powerSearchTimer);
$('extrusion-power-search-hint').hidden = candidate.kind !== 'power';
const search = $('extrusion-modal-search');
// a name lookalike is never acted on by itself, but it is the first
// thing the person deciding an update should see
search.value = candidate.match && candidate.match.by === 'name'
? String(candidate.match.label || '') : '';
renderModalList(search.value);
$('extrusion-modal').style.display = 'flex';
search.focus();
}
function renderModalList(filter) {
const list = $('extrusion-modal-list');
const wanted = filter.trim().toLowerCase();
const pool = state.modal ? modalPool(state.modal.kind) : [];
const rows = pool.filter((row) => wanted === ''
|| row.label.toLowerCase().indexOf(wanted) !== -1
|| row.guid.toLowerCase().indexOf(wanted) !== -1
|| String(row.detail).toLowerCase().indexOf(wanted) !== -1).slice(0, 100);
if (!rows.length) {
list.innerHTML = '<div class="extrusion-modal-empty">' + esc(T.noMatches) + '</div>';
return;
}
list.innerHTML = rows.map((row) => '<button type="button" class="extrusion-modal-row" '
+ 'data-extrusion-target="' + esc(row.guid) + '" data-extrusion-label="' + esc(row.label) + '">'
+ '<b>' + esc(row.label) + '</b>'
+ (row.detail ? ' <small>' + esc(row.detail) + '</small>' : '')
+ '</button>').join('')
+ (state.modalPowersTruncated
? '<div class="extrusion-modal-empty">' + esc(T.powerSearchTruncated) + '</div>' : '');
}
function closeModal() {
state.modal = null;
state.modalPowers = null;
state.modalPowersTruncated = false;
powerSearchRun++;
window.clearTimeout(powerSearchTimer);
$('extrusion-modal').style.display = 'none';
}
/** Search outside the linked Power set only when the person supplies a value. */
function searchModal(filter) {
const run = ++powerSearchRun;
window.clearTimeout(powerSearchTimer);
state.modalPowers = null;
state.modalPowersTruncated = false;
renderModalList(filter);
if (!state.modal || state.modal.kind !== 'power' || filter.trim() === '') {
return;
}
powerSearchTimer = window.setTimeout(async () => {
let payload;
try {
payload = await post('extrusionCatalogue', {
component_id: $('extrusion-component-select').value,
power_search: filter.trim()
});
} catch (error) {
payload = { error: error.message || T.requestFailed };
}
if (run !== powerSearchRun || !state.modal || state.modal.kind !== 'power') {
return;
}
if (payload.error || !Array.isArray(payload.powers)) {
$('extrusion-modal-list').innerHTML = '<div class="extrusion-modal-empty">'
+ esc(payload.error || T.catalogueFailed) + '</div>';
return;
}
state.modalPowers = payload.powers;
state.modalPowersTruncated = payload.power_search && payload.power_search.truncated === true;
renderModalList(filter);
}, 300);
}
/**
* Bulk work over the ticked rows.
*/
function bulk(action) {
const visible = new Set(selectableRows($('extrusion-board')).map((rowElement) => rowElement.dataset.extrusionRow));
batchingDecisions = true;
state.selected.forEach((id) => {
if (!visible.has(id)) {
return;
}
const candidate = findCandidate(id);
if (!candidate) {
return;
}
// a shared member still standing in its group has no decision to
// bulk: it is decided on its owner's row, or detached one by one
if (candidate.shared && !candidate.detached) {
state.selected.delete(id);
return;
}
if (action === 'reset') {
decide(candidate, null);
if (candidate.shared) {
candidate.detached = false;
state.selected.delete(id);
}
} else {
decide(candidate, { action: action });
}
});
batchingDecisions = false;
scheduleWeighing();
renderBoard();
}
function refreshBulkBar() {
$('extrusion-selected-count').textContent = String(state.selected.size);
$$('#extrusion-bulk-bar [data-extrusion-bulk]').forEach((button) => {
button.disabled = state.selected.size === 0 || state.busy;
});
$$('#extrusion-board [data-extrusion-group-container]').forEach((container) => {
const checkbox = container.querySelector(':scope > summary > [data-extrusion-group]');
const rows = selectableRows(container);
const count = rows.filter((rowElement) => state.selected.has(rowElement.dataset.extrusionRow)).length;
checkbox.checked = rows.length > 0 && count === rows.length;
checkbox.indeterminate = count > 0 && count < rows.length;
checkbox.disabled = rows.length === 0;
});
}
/** Collapsed descendants remain selectable; filtered or shared members do not. */
function selectableRows(container) {
return $$('.extrusion-row[data-extrusion-visible="true"]', container)
.filter((rowElement) => rowElement.querySelector('[data-extrusion-check]'));
}
/**
* Hide rows the filter does not name, and open every group that still
* holds a match -- a hit inside a collapsed group is no hit at all.
*/
function applyFilter(value) {
const wanted = value.trim().toLowerCase();
const kind = $('extrusion-filter-type').value;
const status = $('extrusion-filter-status').value;
const change = $('extrusion-filter-change').value;
const active = wanted !== '' || kind !== '' || status !== '' || change !== '';
let visible = 0;
$$('#extrusion-board .extrusion-row').forEach((rowElement) => {
const candidate = findCandidate(rowElement.dataset.extrusionRow);
const match = candidate && (wanted === '' || rowElement.textContent.toLowerCase().includes(wanted))
&& (kind === '' || candidate.kind === kind)
&& (status === '' || matchingStatus(candidate) === status)
&& (change === '' || plannedChange(candidate) === change);
rowElement.dataset.extrusionVisible = match ? 'true' : 'false';
rowElement.style.display = match ? '' : 'none';
if (match) {
visible++;
} else {
state.selected.delete(rowElement.dataset.extrusionRow);
const check = rowElement.querySelector('[data-extrusion-check]');
if (check) {
check.checked = false;
}
}
if (match && active) {
let group = rowElement.closest('details');
while (group) {
group.open = true;
group = group.parentElement
? group.parentElement.closest('details') : null;
}
}
});
$$('#extrusion-board [data-extrusion-group-container]').forEach((group) => {
group.hidden = !group.querySelector('.extrusion-row[data-extrusion-visible="true"]');
});
$('extrusion-visible-count').textContent = String(visible);
$('extrusion-total-count').textContent = String(state.candidateIndex.size);
$('extrusion-filter-empty').hidden = visible > 0;
refreshBulkBar();
}
/**
* Only what a person explicitly decided travels as a verdict.
*
* An untouched row ships nothing: the engine settles it -- shared groups
* keep one field, matched candidates update what stands, and what is
* genuinely new takes the engine's own identity, so a re-run stays
* deterministic. A proposal shipped back as if a person had chosen it
* would detach shared members and force fresh copies of fields that
* merely resemble something -- the exact duplication this board exists
* to prevent.
*
* A decision on a group's owner row speaks for the whole group, so it
* travels under its own kind; a decision on a detached member speaks for
* that one view only.
*/
function buildDecisions() {
const payload = {};
allCandidates().forEach((candidate) => {
if (!explicit(candidate)) {
return;
}
const current = decision(candidate);
const verdict = current.action === 'update'
? { action: 'update', target: current.target }
: { action: current.action };
const kind = candidate.kind === 'field'
&& candidate.shared_by && candidate.shared_by.length
? 'field_group' : candidate.kind;
payload[kind] = payload[kind] || {};
payload[kind][candidate.key] = verdict;
});
return payload;
}
/**
* Approval exists only for the exact plan shown when this dialog opened.
*/
function openConfirmation() {
state.confirmation = {
fingerprint: state.plan.fingerprint,
scopes: [...(state.plan.required_approvals || [])]
};
const repair = isNamespaceRepair();
$('extrusion-confirm-title').textContent = repair ? T.repairConfirmTitle : T.confirmTitle;
$('extrusion-confirm-description').textContent = repair ? T.repairConfirmDescription : T.confirmDescription;
$('extrusion-confirm-import').textContent = repair ? T.repairConfirmLabel : T.confirmLabel;
$('extrusion-confirm-dry-run').hidden = String((state.config || {}).dry_run) !== '1';
$('extrusion-confirm-modal').style.display = 'flex';
$('extrusion-confirm-cancel').focus();
}
function closeConfirmation() {
const opened = state.confirmation !== null;
state.confirmation = null;
const dialog = $('extrusion-confirm-modal');
if (dialog) {
dialog.style.display = 'none';
}
if (opened && $('extrusion-import-button')) {
$('extrusion-import-button').focus();
}
}
function confirmImport() {
const approval = state.confirmation;
closeConfirmation();
if (!approval || !reviewReady() || approval.fingerprint !== state.plan.fingerprint
|| JSON.stringify(approval.scopes) !== JSON.stringify(state.plan.required_approvals || [])) {
return;
}
runImport(approval);
}
/**
* Run the import under the decisions of the board.
*/
async function runImport(approval = null) {
if (!reviewReady()) {
renderReview();
return;
}
const scopes = state.plan.required_approvals || [];
if (scopes.length && (!approval || approval.fingerprint !== state.plan.fingerprint
|| JSON.stringify(approval.scopes) !== JSON.stringify(scopes))) {
openConfirmation();
return;
}
const config = runConfig(true, approval ? approval.scopes : []);
const decisions = buildDecisions();
const finishRun = beginRun();
$('extrusion-running-title').textContent = config.admin_path || T.theSource;
$('extrusion-running-verb').textContent = isNamespaceRepair() ? T.repairing : T.importing;
showPane('running');
let payload;
try {
payload = await post('extrusionImport', {
config: JSON.stringify(config),
decisions: JSON.stringify(decisions)
});
} catch (error) {
payload = { error: error.message || T.requestFailed };
} finally {
// A disconnected request may already have written. Never retain
// its approval or retry it; another import needs a fresh review.
invalidateReview();
finishRun();
}
if (!payload || (!payload.error && !payload.success)) {
payload = { error: T.importFailed };
}
enableTab('results');
renderResults(payload || { error: T.importFailed });
showPane('results');
}
/**
* The live report: what was written, skipped, and failed, and every
* message the engines had to say.
*/
function renderResults(payload) {
const container = $('extrusion-results');
let html = '';
if (payload.error) {
html += '<div class="alert alert-danger">' + esc(payload.error) + '</div>';
} else {
html += '<div class="alert alert-success">' + esc(payload.success || T.importDone) + '</div>';
}
const report = payload.report || {};
planBlockers(payload.plan || report.plan).forEach((reason) => {
html += '<div class="alert alert-warning">' + esc(reason) + '</div>';
});
if (report.dry_run) {
html += '<div class="alert alert-info">' + esc(T.dryRun) + '</div>';
}
const levels = { success: 'success', info: 'info', warning: 'warning', error: 'danger' };
const messages = payload.messages || {};
let messageHtml = '';
Object.keys(levels).forEach((level) => {
(messages[level] || []).forEach((entry) => {
messageHtml += '<div class="alert alert-' + levels[level] + '">'
+ esc(entry.message)
+ (entry.subject ? ' <small>' + esc(entry.subject) + '</small>' : '')
+ '</div>';
});
});
if (messageHtml) {
html += '<h4>' + esc(T.messages) + '</h4>' + messageHtml;
}
['written', 'shared', 'adopted', 'consolidated', 'reuse', 'kept',
'skipped', 'failed'].forEach((section) => {
const tree = report[section];
if (!tree || typeof tree !== 'object') {
return;
}
const rows = flatten(tree, section);
if (!rows.length) {
return;
}
const label = {
written: T.written,
shared: T.sharedSection,
adopted: T.adopted,
consolidated: T.consolidated,
reuse: T.reused,
kept: T.kept,
skipped: T.skipped,
failed: T.failed
}[section];
html += '<details' + (section === 'failed' ? ' open' : '') + '><summary>'
+ esc(label) + ' <small>(' + rows.length + ')</small></summary>'
+ '<table class="table table-sm"><tbody>'
+ rows.map((entry) => '<tr><td>' + esc(entry.path) + '</td><td>'
+ esc(entry.value) + '</td></tr>').join('')
+ '</tbody></table></details>';
});
container.innerHTML = html;
}
function flatten(tree, prefix) {
const rows = [];
Object.entries(tree).forEach(([key, value]) => {
const path = prefix + '.' + key;
if (value && typeof value === 'object') {
rows.push(...flatten(value, path));
} else {
rows.push({ path: path, value: String(value) });
}
});
return rows;
}
/**
* The folder picker: walk the site from its root and select, never type.
*
* The server only ever answers with folders below the site root, so what
* is chosen is always a real folder of this site; the full path is
* composed from the base the server reports.
*/
async function openFolders(relative) {
const picker = state.picker;
const pathLine = $('extrusion-folder-path');
const list = $('extrusion-folder-list');
let payload;
try {
payload = await post('extrusionFolders', { path: relative });
} catch (error) {
payload = { error: error.message || T.folderFailed };
}
if (!picker || state.picker !== picker) {
return;
}
if (!payload || payload.error) {
list.innerHTML = '<div class="extrusion-modal-empty">'
+ esc((payload && payload.error) || T.folderFailed) + '</div>';
return;
}
state.picker.base = payload.base;
state.picker.path = payload.path;
pathLine.textContent = payload.path === ''
? T.siteRoot : payload.path;
let html = '';
if (payload.parent !== null) {
html += '<button type="button" class="extrusion-modal-row" data-extrusion-folder="'
+ esc(payload.parent) + '" data-extrusion-up="1"><b>..</b> <small>'
+ esc(T.upOneFolder) + '</small></button>';
}
(payload.folders || []).forEach((folder) => {
const next = payload.path === '' ? folder : payload.path + '/' + folder;
html += '<button type="button" class="extrusion-modal-row" data-extrusion-folder="'
+ esc(next) + '"><b>' + esc(folder) + '</b></button>';
});
if (html === '') {
html = '<div class="extrusion-modal-empty">' + esc(T.emptyFolder) + '</div>';
}
list.innerHTML = html;
}
function openFolderPicker(target) {
state.picker = { target: target, base: '', path: '' };
$('extrusion-folder-modal').style.display = 'flex';
openFolders('');
}
function closeFolderPicker() {
state.picker = null;
$('extrusion-folder-modal').style.display = 'none';
}
function chooseFolder() {
if (!state.picker) {
return;
}
const full = state.picker.base
+ (state.picker.path === '' ? '' : '/' + state.picker.path);
const field = document.querySelector('[name="' + state.picker.target + '"]');
if (field) {
if (state.picker.target === 'libraries') {
field.value = (field.value.trim() === ''
? full : field.value.trim() + '\n' + full);
} else {
field.value = full;
}
}
closeFolderPicker();
}
/**
* Put a select button beside every folder field, so the paths are walked
* and chosen rather than typed.
*/
function decorateFolderFields() {
[['admin_path', T.selectFolder], ['site_path', T.selectFolder],
['libraries', T.addLibrary]].forEach(([name, label]) => {
const field = document.querySelector('[name="' + name + '"]');
if (!field) {
return;
}
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-sm btn-outline-primary extrusion-folder-select';
button.setAttribute('data-extrusion-pick', name);
button.textContent = label;
button.addEventListener('click', () => openFolderPicker(name));
field.insertAdjacentElement('afterend', button);
});
}
/**
* Wire the page once it stands.
*/
document.addEventListener('DOMContentLoaded', () => {
if (!$('extrusion-page')) {
return;
}
E = window.JCBExtrusion || E;
T = E.text || {};
decorateFolderFields();
// Keep the dedicated repair action with the existing Advanced disclosure.
const advanced = $('adminForm').elements['show_advanced_options'];
const toggleRepairOptions = () => {
$('extrusion-namespace-repair-options').hidden = !advanced || advanced.value !== '1';
};
$$('#adminForm [name="show_advanced_options"]').forEach((field) => {
field.addEventListener('change', toggleRepairOptions);
});
toggleRepairOptions();
// the component name input stands only when everything is created
// new: with a target selected or detection on, the target answers
const componentSelect = document.querySelector('[name="component_id"]');
const componentCode = document.querySelector('[name="component_code"]');
if (componentSelect && componentCode) {
const nameRow = componentCode.closest('.control-group') || componentCode;
const toggleName = () => {
nameRow.style.display = componentSelect.value === '0' ? '' : 'none';
};
componentSelect.addEventListener('change', toggleName);
toggleName();
}
$('extrusion-folder-close').addEventListener('click', closeFolderPicker);
$('extrusion-folder-choose').addEventListener('click', chooseFolder);
$('extrusion-folder-list').addEventListener('click', (event) => {
const folder = event.target.closest('[data-extrusion-folder]');
if (folder) {
openFolders(folder.dataset.extrusionFolder);
}
});
$('extrusion-folder-modal').addEventListener('click', (event) => {
if (event.target === $('extrusion-folder-modal')) {
closeFolderPicker();
}
});
$('extrusion-harvest-button').addEventListener('click', () => harvest(false));
$('extrusion-repair-namespaces-button').addEventListener('click', () => harvest(true));
wireBoard();
$('extrusion-back-button').addEventListener('click', () => showPane('setup'));
const importButton = $('extrusion-import-button');
if (importButton) {
importButton.addEventListener('click', () => runImport());
}
$$('#extrusion-tabs .nav-link').forEach((tab) => {
tab.addEventListener('click', () => {
if (!tab.disabled) {
showPane(tab.dataset.extrusionTab);
}
});
});
$('extrusion-confirm-import').addEventListener('click', confirmImport);
$('extrusion-confirm-cancel').addEventListener('click', closeConfirmation);
$('extrusion-confirm-modal').addEventListener('click', (event) => {
if (event.target === $('extrusion-confirm-modal')) {
closeConfirmation();
}
});
$('extrusion-confirm-modal').addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
closeConfirmation();
} else if (event.key === 'Tab') {
const first = $('extrusion-confirm-import');
const last = $('extrusion-confirm-cancel');
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
});
$('extrusion-component-select').addEventListener('change', async (event) => {
invalidateReview();
state.decisions = {};
state.selected.clear();
state.diffs = {};
if (!await loadCatalogue(parseInt(event.target.value, 10) || 0)) {
return;
}
// the weights were read against the component paired before, so
// every row is weighed again against the one chosen now
allCandidates().forEach((candidate) => {
state.weighing.add(candidate.kind + '|' + candidate.key);
});
state.diffs = {};
scheduleWeighing();
renderBoard();
});
$$('#extrusion-bulk-bar [data-extrusion-bulk]').forEach((button) => {
button.addEventListener('click', () => bulk(button.dataset.extrusionBulk));
});
$('extrusion-filter').addEventListener('input', (event) => applyFilter(event.target.value));
['type', 'status', 'change'].forEach((filter) => {
$('extrusion-filter-' + filter).addEventListener('change', () => applyFilter($('extrusion-filter').value));
});
$('extrusion-show-ambiguous').addEventListener('click', () => {
$('extrusion-filter-type').value = 'power';
$('extrusion-filter-status').value = 'ambiguous';
$('extrusion-filter-change').value = '';
$('extrusion-filter').value = '';
applyFilter('');
$('extrusion-filter-status').focus();
});
$('extrusion-modal-close').addEventListener('click', closeModal);
$('extrusion-modal-search').addEventListener('input',
(event) => searchModal(event.target.value));
$('extrusion-modal-list').addEventListener('click', (event) => {
const target = event.target.closest('[data-extrusion-target]');
if (target && state.modal) {
decide(state.modal, {
action: 'update',
target: target.dataset.extrusionTarget,
label: target.dataset.extrusionLabel
});
closeModal();
renderBoard();
}
});
$('extrusion-modal').addEventListener('click', (event) => {
if (event.target === $('extrusion-modal')) {
closeModal();
}
});
// the loading dots of the running pane
$$('.loading-dots').forEach((dots) => {
let x = 0;
setInterval(() => {
dots.textContent = '.'.repeat((x++ % 8) || 1);
}, 500);
});
});
})();