|  | @@ -1,8 +1,16 @@
 | 
	
		
			
				|  |  | +const PassiveListener = { passive: true };
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  |  // Get an element by id or an Element object
 | 
	
		
			
				|  |  |  function getelem(id) {
 | 
	
		
			
				|  |  |      return (id instanceof Element) ? id : document.getElementById(id);
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | +// Find a child with a specific tag
 | 
	
		
			
				|  |  | +function chi(me,tag) { return me.getElementsByTagName(tag)[0]; }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +// Find a sibling element with a specific tag
 | 
	
		
			
				|  |  | +function sib(me,tag) { return chi(me.parentElement, tag); }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  |  // Read a key=value text file and return it as a Promise of a Map
 | 
	
		
			
				|  |  |  function fetchconfig(url) {
 | 
	
		
			
				|  |  |      return fetch(url, {redirect: "follow"})
 | 
	
	
		
			
				|  | @@ -26,45 +34,39 @@ function fetchconfig(url) {
 | 
	
		
			
				|  |  |  	});
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -// Initialize a form from a map
 | 
	
		
			
				|  |  | +// Parse a string for a valid boolean truth value
 | 
	
		
			
				|  |  | +function cfgbool(str) {
 | 
	
		
			
				|  |  | +    return str && !str.match(/^(0*|[fnd].*|of.*)$/i);
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +// Initialize a form from a map. Checkboxes take a cfgbool string;
 | 
	
		
			
				|  |  | +// if in the HTML their value is set to a false cfgbool string then
 | 
	
		
			
				|  |  | +// the checkbox is inverted logic.
 | 
	
		
			
				|  |  |  function initform(form,map) {
 | 
	
		
			
				|  |  |      var button = null;
 | 
	
		
			
				|  |  | -    var clearers = new Map;
 | 
	
		
			
				|  |  | +    var clearers = new Set;
 | 
	
		
			
				|  |  |      form = getelem(form);
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    for (var field of form.elements) {
 | 
	
		
			
				|  |  | -	if (((field instanceof HTMLInputElement) ||
 | 
	
		
			
				|  |  | -	     (field instanceof HTMLSelectElement)) &&
 | 
	
		
			
				|  |  | -	    !field.classList.contains("noload")) {
 | 
	
		
			
				|  |  | -	    const val = map.get(field.name);
 | 
	
		
			
				|  |  | -	    if (val == null) {
 | 
	
		
			
				|  |  | -	    } else if (field.type == 'checkbox') {
 | 
	
		
			
				|  |  | -		const checked = !val.match(/^(0*|[fnd].*|of.*)$/i);
 | 
	
		
			
				|  |  | -		field.checked = checked;
 | 
	
		
			
				|  |  | -		field.value = '1';
 | 
	
		
			
				|  |  | -	    } else if (field.type == 'radio') {
 | 
	
		
			
				|  |  | -		field.checked = (val == field.name);
 | 
	
		
			
				|  |  | -	    } else if (field.type == 'hidden' &&
 | 
	
		
			
				|  |  | -		       field.classList.contains('_clr')) {
 | 
	
		
			
				|  |  | -		field.remove();
 | 
	
		
			
				|  |  | +    for (var e of form.elements) {
 | 
	
		
			
				|  |  | +	if (e.classList.contains('noinit')) {
 | 
	
		
			
				|  |  | +	    continue;
 | 
	
		
			
				|  |  | +	} else if (e instanceof HTMLInputElement ||
 | 
	
		
			
				|  |  | +		   e instanceof HTMLSelectElement) {
 | 
	
		
			
				|  |  | +	    const val = map.get(e.name);
 | 
	
		
			
				|  |  | +	    if (val == null)
 | 
	
		
			
				|  |  | +		continue;
 | 
	
		
			
				|  |  | +	    if (e.type == 'checkbox') {
 | 
	
		
			
				|  |  | +		e.checked = cfgbool(val) == cfgbool(e.value);
 | 
	
		
			
				|  |  | +	    } else if (e.type == 'radio') {
 | 
	
		
			
				|  |  | +		e.checked = (val == e.name);
 | 
	
		
			
				|  |  |  	    } else {
 | 
	
		
			
				|  |  | -		field.value = val;
 | 
	
		
			
				|  |  | +		e.value = val;
 | 
	
		
			
				|  |  |  	    }
 | 
	
		
			
				|  |  | -	} else if (field instanceof HTMLButtonElement &&
 | 
	
		
			
				|  |  | -		   field.type == 'submit') {
 | 
	
		
			
				|  |  | -	    button = field;
 | 
	
		
			
				|  |  | +	} else if (e instanceof HTMLButtonElement &&
 | 
	
		
			
				|  |  | +		   e.type == 'submit') {
 | 
	
		
			
				|  |  | +	    button = e;
 | 
	
		
			
				|  |  |  	}
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -    for (const what of clearers.keys()) {
 | 
	
		
			
				|  |  | -	var clearer = document.createElement('INPUT');
 | 
	
		
			
				|  |  | -	clearer.type = 'hidden';
 | 
	
		
			
				|  |  | -	clearer.name = what;
 | 
	
		
			
				|  |  | -	clearer.value = '0';
 | 
	
		
			
				|  |  | -	clearer.classList.add('_clr');
 | 
	
		
			
				|  |  | -	form.prepend(clearer);
 | 
	
		
			
				|  |  | -    }
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  |      if (button) {
 | 
	
		
			
				|  |  |  	button.disabled = false; // All loaded, enable the submit button
 | 
	
		
			
				|  |  |      }
 | 
	
	
		
			
				|  | @@ -77,14 +79,23 @@ function loadform(form, url) {
 | 
	
		
			
				|  |  |  	.catch(() => {});
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -// Replace the contents of selected HTML elements based on a map with selectors
 | 
	
		
			
				|  |  | -function fillin(map,html)
 | 
	
		
			
				|  |  | +// Replace the contents of selected HTML elements based on a map with
 | 
	
		
			
				|  |  | +// selectors.
 | 
	
		
			
				|  |  | +function fillin(map,html = false,top = document)
 | 
	
		
			
				|  |  |  {
 | 
	
		
			
				|  |  |      for (const [key,val] of map) {
 | 
	
		
			
				|  |  |  	try {
 | 
	
		
			
				|  |  | -	    for (var e of document.querySelectorAll(key))
 | 
	
		
			
				|  |  | -		if (html) e.innerHTML = val; else e.innerText = val;
 | 
	
		
			
				|  |  | -	} catch (error) { }
 | 
	
		
			
				|  |  | +	    const m = key.match(/(.*?)(?:\;([\w.-]*)(\??))?$/);
 | 
	
		
			
				|  |  | +	    for (var e of top.querySelectorAll(m[1])) {
 | 
	
		
			
				|  |  | +		try {
 | 
	
		
			
				|  |  | +		    if (!html) e.textContents = val;
 | 
	
		
			
				|  |  | +		    else if (!m[2]) e.innerHTML = val;
 | 
	
		
			
				|  |  | +		    else if (!m[3]) e.setAttribute(m[2],val);
 | 
	
		
			
				|  |  | +		    else if (!cfgbool(val)) e.removeAttribute(m[2]);
 | 
	
		
			
				|  |  | +		    else e.setAttribute(m[2],'');
 | 
	
		
			
				|  |  | +		} catch(e) { };
 | 
	
		
			
				|  |  | +	    }
 | 
	
		
			
				|  |  | +	} catch(e) { };
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  |  
 | 
	
	
		
			
				|  | @@ -92,68 +103,114 @@ function fillin(map,html)
 | 
	
		
			
				|  |  |  function load(url,html = false)
 | 
	
		
			
				|  |  |  {
 | 
	
		
			
				|  |  |      fetchconfig(url)
 | 
	
		
			
				|  |  | -	.then(map => { fillin(map,html) })
 | 
	
		
			
				|  |  | +	.then(map => fillin(map,html))
 | 
	
		
			
				|  |  |  	.catch(() => {});
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -// POST file upload with progress and response text
 | 
	
		
			
				|  |  | -function uploadfile(event) {
 | 
	
		
			
				|  |  | -    event.preventDefault();
 | 
	
		
			
				|  |  | -    var form = event.currentTarget;
 | 
	
		
			
				|  |  | -    var elem = form.elements;
 | 
	
		
			
				|  |  | -    var files = elem["file"].files;
 | 
	
		
			
				|  |  | -    if (files.length != 1) {
 | 
	
		
			
				|  |  | -	/* Show error */
 | 
	
		
			
				|  |  | -	return;
 | 
	
		
			
				|  |  | -    }
 | 
	
		
			
				|  |  | -    var file = files[0];
 | 
	
		
			
				|  |  | +// POST upload of data from within a form, with progress and response text
 | 
	
		
			
				|  |  | +function upload(form,data) {
 | 
	
		
			
				|  |  |      var xhr = new XMLHttpRequest();
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    var progress = form.querySelector("progress");
 | 
	
		
			
				|  |  | -    if (progress != undefined) {
 | 
	
		
			
				|  |  | +    var progress = chi(form,'progress');
 | 
	
		
			
				|  |  | +    if (progress) {
 | 
	
		
			
				|  |  |  	progress.value = 0;
 | 
	
		
			
				|  |  | -	xhr.upload.addEventListener("progress", (event) => {
 | 
	
		
			
				|  |  | -	    if (event.lengthComputable) {
 | 
	
		
			
				|  |  | -		progress.max   = event.total * 1.05;
 | 
	
		
			
				|  |  | -		progress.value = event.loaded;
 | 
	
		
			
				|  |  | -	    }
 | 
	
		
			
				|  |  | -	});
 | 
	
		
			
				|  |  | +	xhr.upload.addEventListener('progress', (e) => {
 | 
	
		
			
				|  |  | +	    if (!e.lengthComputable)
 | 
	
		
			
				|  |  | +		return;
 | 
	
		
			
				|  |  | +	    progress.max   = e.total * 1.05;
 | 
	
		
			
				|  |  | +	    progress.value = e.loaded;
 | 
	
		
			
				|  |  | +	}, PassiveListener);
 | 
	
		
			
				|  |  |      }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    var result = form.querySelector("pre.result");
 | 
	
		
			
				|  |  | -    if (result != undefined) {
 | 
	
		
			
				|  |  | -	result.className = "result hide";
 | 
	
		
			
				|  |  | -    }
 | 
	
		
			
				|  |  | -    xhr.addEventListener("loadend", (event) => {
 | 
	
		
			
				|  |  | -	const ok = xhr.status >= 200 && xhr.status <= 299;
 | 
	
		
			
				|  |  | -	if (progress != undefined) {
 | 
	
		
			
				|  |  | +    var result = chi(form,'output');
 | 
	
		
			
				|  |  | +    if (result)
 | 
	
		
			
				|  |  | +	result.classList.remove('result');
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    xhr.addEventListener('readystatechange', (e) => {
 | 
	
		
			
				|  |  | +	if (xhr.readyState != XMLHttpRequest.DONE)
 | 
	
		
			
				|  |  | +	    return;
 | 
	
		
			
				|  |  | +	const ok = xhr.status >= 200 && xhr.status < 400;
 | 
	
		
			
				|  |  | +	if (progress)
 | 
	
		
			
				|  |  |  	    progress.value = ok ? progress.max : 0;
 | 
	
		
			
				|  |  | +	if (result) {
 | 
	
		
			
				|  |  | +	    var msg = xhr.responseText.trimEnd();
 | 
	
		
			
				|  |  | +	    if (!msg)
 | 
	
		
			
				|  |  | +		msg = xhr.status + ' ' + xhr.statusText;
 | 
	
		
			
				|  |  | +	    result.textContent = msg;
 | 
	
		
			
				|  |  | +	    result.classList.toggle('ok', ok);
 | 
	
		
			
				|  |  | +	    result.classList.toggle('err', !ok);
 | 
	
		
			
				|  |  | +	    result.classList.add('result');
 | 
	
		
			
				|  |  |  	}
 | 
	
		
			
				|  |  | -	if (result != undefined) {
 | 
	
		
			
				|  |  | -	    result.className = "result " + (ok ? "ok" : "err");
 | 
	
		
			
				|  |  | -	    result.innerText = xhr.responseText;
 | 
	
		
			
				|  |  | -	}
 | 
	
		
			
				|  |  | -    });
 | 
	
		
			
				|  |  | +	const rf = parseInt(xhr.getResponseHeader('Refresh'));
 | 
	
		
			
				|  |  | +	if (rf && ok)
 | 
	
		
			
				|  |  | +	    setTimeout(() => window.location.reload(true), rf * 1000);
 | 
	
		
			
				|  |  | +    }, PassiveListener);
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -    xhr.open("POST", form.action);
 | 
	
		
			
				|  |  | +    xhr.open(form.method, form.action);
 | 
	
		
			
				|  |  |      xhr.responseType = 'text';
 | 
	
		
			
				|  |  | -    xhr.send(file);
 | 
	
		
			
				|  |  | +    xhr.send(data);
 | 
	
		
			
				|  |  | +    return xhr;
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  | -// Enable a button (this ensures that the necessary scripts have been
 | 
	
		
			
				|  |  | -// run before one can submit)
 | 
	
		
			
				|  |  | -function enablebutton(id,on) {
 | 
	
		
			
				|  |  | -    getelem(id).disabled = !on;
 | 
	
		
			
				|  |  | +// Upload a data file blob
 | 
	
		
			
				|  |  | +function uploadfile() {
 | 
	
		
			
				|  |  | +    event.preventDefault();
 | 
	
		
			
				|  |  | +    const form = event.target.form || event.target;
 | 
	
		
			
				|  |  | +    var files = form.elements['file'];
 | 
	
		
			
				|  |  | +    return (files.files.length == 1)
 | 
	
		
			
				|  |  | +	? upload(form,files.files[0]) : files.click();
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +// key=value formatting of form data; including inverted checkboxes
 | 
	
		
			
				|  |  | +function textformdata(form) {
 | 
	
		
			
				|  |  | +    var data = '';
 | 
	
		
			
				|  |  | +    for (var e of form.elements) {
 | 
	
		
			
				|  |  | +	var val = e.value;
 | 
	
		
			
				|  |  | +	if (val == undefined || !e.name || e instanceof HTMLButtonElement) {
 | 
	
		
			
				|  |  | +	    continue;
 | 
	
		
			
				|  |  | +	} else if (e instanceof HTMLInputElement) {
 | 
	
		
			
				|  |  | +	    if (e.type == 'checkbox')
 | 
	
		
			
				|  |  | +		val = e.checked == cfgbool(val) ? '1' : '0';
 | 
	
		
			
				|  |  | +	    else if (e.type == 'radio' && !e.checked)
 | 
	
		
			
				|  |  | +		continue;
 | 
	
		
			
				|  |  | +	}
 | 
	
		
			
				|  |  | +	data += e.name + '=' + val + "\r\n";
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +    return data;
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +// POST form contents upload with response text
 | 
	
		
			
				|  |  | +function uploadform() {
 | 
	
		
			
				|  |  | +    event.preventDefault();
 | 
	
		
			
				|  |  | +    const form = event.target.form || event.target;
 | 
	
		
			
				|  |  | +    return upload(form,textformdata(form));
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  // Flip the status of an INPUT element between text and password
 | 
	
		
			
				|  |  | -function showpwd(id,me) {
 | 
	
		
			
				|  |  | -    var pwd = getelem(id);
 | 
	
		
			
				|  |  | +function showpwd(me = event.target) {
 | 
	
		
			
				|  |  |      const now_visible = me.classList.toggle('hide');
 | 
	
		
			
				|  |  | -    me.classList.toggle('show',!now_visible);
 | 
	
		
			
				|  |  |      const new_type = now_visible ? 'text' : 'password';
 | 
	
		
			
				|  |  | -    pwd.setAttribute('type', new_type);
 | 
	
		
			
				|  |  | +    me.classList.toggle('show',!now_visible);
 | 
	
		
			
				|  |  | +    sib(me,'input').setAttribute('type', new_type);
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +// Insert translations as needed
 | 
	
		
			
				|  |  | +var translations = null;
 | 
	
		
			
				|  |  | +var delay_translate = false;
 | 
	
		
			
				|  |  | +function translate(top = document) {
 | 
	
		
			
				|  |  | +    delay_translate = (!translations || document.readyState != 'complete');
 | 
	
		
			
				|  |  | +    if (!delay_translate)
 | 
	
		
			
				|  |  | +	fillin(translations, true, top);
 | 
	
		
			
				|  |  |  }
 | 
	
		
			
				|  |  | +document.addEventListener('load', (e) => translate(), PassiveListener);
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +fetchconfig('/sys/lang')
 | 
	
		
			
				|  |  | +    .then((map) => {
 | 
	
		
			
				|  |  | +	translations = map;
 | 
	
		
			
				|  |  | +	if (delay_translate)
 | 
	
		
			
				|  |  | +	    translate();
 | 
	
		
			
				|  |  | +    })
 | 
	
		
			
				|  |  | +    .catch(() => {});
 | 
	
		
			
				|  |  |  
 | 
	
		
			
				|  |  |  // Hack to include an HTML files. Sadly, does not support
 | 
	
		
			
				|  |  |  // including files with <script> tags.
 | 
	
	
		
			
				|  | @@ -161,10 +218,9 @@ function inc(url) {
 | 
	
		
			
				|  |  |      var me = document.currentScript;
 | 
	
		
			
				|  |  |      fetch(url, {redirect: "follow"})
 | 
	
		
			
				|  |  |  	.then((response) => response.text())
 | 
	
		
			
				|  |  | -	.then((text) => { me.outerHTML = text; });
 | 
	
		
			
				|  |  | -}
 | 
	
		
			
				|  |  | -
 | 
	
		
			
				|  |  | -// Insert translations
 | 
	
		
			
				|  |  | -function translate() {
 | 
	
		
			
				|  |  | -    load('/sys/lang', true);
 | 
	
		
			
				|  |  | +	.then((text) => {
 | 
	
		
			
				|  |  | +	    const p = me.parentElement;
 | 
	
		
			
				|  |  | +	    me.outerHTML = text;
 | 
	
		
			
				|  |  | +	    translate(p);
 | 
	
		
			
				|  |  | +	});
 | 
	
		
			
				|  |  |  }
 |