max80.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. const PassiveListener = { passive: true };
  2. const LocalGet = { method: 'GET', mode: 'same-origin',
  3. redirect: 'follow' };
  4. // Get an element by id or an Element object
  5. function getelem(id) {
  6. return (id instanceof Element) ? id : document.getElementById(id);
  7. }
  8. // Find a child with a specific tag
  9. function chi(me,tag) { return me.getElementsByTagName(tag)[0]; }
  10. // Find a sibling element with a specific tag
  11. function sib(me,tag) { return chi(me.parentElement, tag); }
  12. // Add/remove class entries in bulk; tags is an Array each containing
  13. // an Array of arguments to toggle. On return the second element of
  14. // each Array will be updated to the current value.
  15. function classmod(elem,tags) {
  16. for (var i = 0; i < tags.length; i++)
  17. tags[i][1] = elem.classList.toggle(...tags[i]);
  18. return tags;
  19. }
  20. // Read a key=value text file and return it as a Promise of a Map
  21. function fetchconfig(url) {
  22. return fetch(url, LocalGet)
  23. .then(res => {
  24. if (!res.ok) {
  25. throw new Error('HTTP error '+response.status);
  26. } else {
  27. return res.text();
  28. }
  29. })
  30. .then(text => {
  31. var map = new Map();
  32. for (const c of text.split(/[\r\n]+/)) {
  33. var m = c.match(/^\s*([\;\/]?)((?:"[^"]*"|[^"])*?)\s*=(.*)$/);
  34. if (m && m[1] == "") {
  35. var k = m[2].replaceAll(/(^"|"$|(")")/g, "$2");
  36. map.set(k, m[3]);
  37. }
  38. }
  39. return map;
  40. });
  41. }
  42. // Get the value from an input field if valid, otherwise its default
  43. function valval(ie) {
  44. return ie.checkValidity() ? ie.value : ie.defaultValue;
  45. }
  46. // Parse a string for a valid boolean truth value
  47. function cfgbool(str) {
  48. return !!str && !str.match(/^([0fnd]|of)/i);
  49. }
  50. // Initialize a form from a map. Checkboxes take a cfgbool string;
  51. // if in the HTML their value is set to a false cfgbool string then
  52. // the checkbox is inverted logic.
  53. function initform(form,map,ro = false) {
  54. form = getelem(form);
  55. for (var e of form.elements) {
  56. if (e.classList.contains('noinit') ||
  57. e instanceof HTMLFieldSetElement)
  58. continue;
  59. if (ro && e.disabled != undefined && !e.classList.contains('noro'))
  60. e.disabled = true;
  61. if (e instanceof HTMLInputElement ||
  62. e instanceof HTMLSelectElement) {
  63. const val = map.get(e.name);
  64. if (val == null)
  65. continue;
  66. if (e.type == 'radio')
  67. e.checked = (val == e.value);
  68. else
  69. e.value = val;
  70. } else if (e instanceof HTMLButtonElement) {
  71. e.disabled = ro;
  72. }
  73. }
  74. }
  75. // Load form initialization data
  76. function loadform(form,url,ro = false) {
  77. fetchconfig(url)
  78. .then((map) => {initform(form,map,ro); })
  79. .catch(() => {});
  80. }
  81. // Replace the contents of selected HTML elements based on a map with
  82. // selectors.
  83. function fillin(map,html = false,top = document) {
  84. for (const [key,val] of map) {
  85. try {
  86. const m = key.match(/(.*?)(?:\;([\w.-]*)(\??))?$/);
  87. for (var e of top.querySelectorAll(m[1])) {
  88. try {
  89. if (!html) e.textContents = val;
  90. else if (!m[2]) e.innerHTML = val;
  91. else if (!m[3]) e.setAttribute(m[2],val);
  92. else if (!cfgbool(val)) e.removeAttribute(m[2]);
  93. else e.setAttribute(m[2],'');
  94. } catch(e) { };
  95. }
  96. } catch(e) { };
  97. }
  98. }
  99. // Load status or HTML data
  100. function load(url,html = false)
  101. {
  102. fetchconfig(url)
  103. .then(map => fillin(map,html))
  104. .catch(() => {});
  105. }
  106. // POST upload of data from within a form, with (optional)
  107. // progress and response text, and redirect after success
  108. function upload(form,data) {
  109. var xhr = new XMLHttpRequest();
  110. var progress = chi(form,'progress');
  111. if (progress) {
  112. progress.value = 0;
  113. xhr.upload.addEventListener('progress', (e) => {
  114. if (!e.lengthComputable)
  115. return;
  116. progress.max = e.total * 1.05;
  117. progress.value = e.loaded;
  118. }, PassiveListener);
  119. }
  120. classmod(form, [['started',1],['done',0],['ok',0],['err',0],['running',1]]);
  121. xhr.addEventListener('loadend', (e) => {
  122. const ok = xhr.status >= 200 && xhr.status < 400;
  123. if (progress && ok)
  124. progress.value = progress.max;
  125. var result = chi(form,'output');
  126. if (result) {
  127. var msg = xhr.responseText.trimEnd();
  128. if (!msg)
  129. msg = xhr.status + ' ' + xhr.statusText;
  130. result.textContent = msg;
  131. }
  132. classmod(form, [['ok',ok],['err',!ok],['running',0],['done',1]]);
  133. // Optionally redirect elsewhere after success
  134. const reft = parseFloat(form.dataset.ref) * 1000;
  135. if (ok && reft >= 0.0) {
  136. const refh = form.dataset.refUrl || window.location.href;
  137. setTimeout(() => { window.location.href = refh; }, reft);
  138. }
  139. }, PassiveListener);
  140. xhr.open(form.method, form.action);
  141. xhr.responseType = 'text';
  142. xhr.send(data);
  143. return xhr;
  144. }
  145. // key=value formatting of form data; including inverted checkboxes
  146. function textformdata(form) {
  147. var data = '';
  148. for (var e of form.elements) {
  149. var val = e.value;
  150. if (val == undefined || !e.name || e instanceof HTMLButtonElement) {
  151. continue;
  152. } else if (e instanceof HTMLInputElement) {
  153. if (e.type == 'radio' && !e.checked)
  154. continue;
  155. }
  156. data += e.name + '=' + val + "\r\n";
  157. }
  158. return data;
  159. }
  160. // POST form contents upload with response text
  161. function uploadform() {
  162. event.preventDefault();
  163. const form = event.target.form || event.target;
  164. var files = form.elements['file'];
  165. if (files == undefined)
  166. return upload(form,textformdata(form));
  167. else if (files.files.length == 1)
  168. return upload(form,files.files[0]);
  169. else
  170. return files.click();
  171. }
  172. // Flip the status of an INPUT element between text and password
  173. function showpwd(me = event.currentTarget) {
  174. const now_visible = me.classList.toggle('hide');
  175. const new_type = now_visible ? 'text' : 'password';
  176. me.classList.toggle('show',!now_visible);
  177. sib(me,'input').setAttribute('type', new_type);
  178. }
  179. // Insert translations as needed
  180. var translations = null;
  181. var delay_translate = false;
  182. function translate(top = document) {
  183. delay_translate = (!translations || document.readyState != 'complete');
  184. if (!delay_translate)
  185. fillin(translations, true, top);
  186. }
  187. document.addEventListener('load', (e) => translate(), PassiveListener);
  188. var lang_styleobj = document.createElement('style');
  189. document.head.append(lang_styleobj);
  190. function setlang(l = null) {
  191. l = l || document.documentElement.lang;
  192. if (!l.match(/^\w+(?:-\w+)*$/)) return; // Invalid language tag
  193. var sty = lang_styleobj.sheet;
  194. while (sty.rules.length) sty.deleteRule(0);
  195. sty.insertRule('[lang]:not(:lang('+l+')):not(:last-child),[lang]:lang('+l+')~[lang] {display: none}', 0);
  196. document.documentElement.lang = l;
  197. }
  198. setlang();
  199. fetchconfig('/sys/lang')
  200. .then((map) => {
  201. setlang(map.get('LANG'));
  202. translations = map;
  203. if (delay_translate)
  204. translate();
  205. })
  206. .catch(() => {});
  207. // HTML include hack
  208. class IncHTML extends HTMLElement {
  209. constructor() { self = super(); }
  210. connectedCallback() {
  211. fetch(self.getAttribute('src'), LocalGet)
  212. .then ((r) => r.text())
  213. .then ((text) => {
  214. const p = self.parentElement;
  215. self.outerHTML = text;
  216. translate(p);
  217. });
  218. }
  219. }
  220. customElements.define('x-inc', IncHTML);
  221. // Smart checkbox INPUT element
  222. class XBox extends HTMLInputElement {
  223. set negative(x) { return this.toggleAttribute('negative', x); }
  224. get negative() { return this.hasAttribute('negative'); }
  225. #truth;
  226. get truth() { return this.#truth; }
  227. update_truth() {
  228. const old_truth = this.#truth;
  229. this.#truth = this.checked != this.negative;
  230. if (this.#truth !== old_truth)
  231. this.resolve_conflicts();
  232. }
  233. set truth(x) {
  234. const new_truth = !!x;
  235. if (new_truth !== this.#truth) {
  236. this.checked = new_truth != this.negative;
  237. this.update_truth();
  238. }
  239. }
  240. set_list(n,x) { this.setAttribute(n, x ? x.join(',') : ''); }
  241. get_list(n) { var a = this.getAttribute(n); return a ? a.split(/\s*;\s*/) : []; }
  242. get needs() { return this.get_list('needs'); }
  243. set conflicts(x) { this.set_list('conflicts',x); }
  244. get conflicts() { return this.get_list('conflicts'); }
  245. set value(x) { this.truth = cfgbool(x); }
  246. get value() { return this.truth ? '1' : '0'; }
  247. constructor() {
  248. super();
  249. this.setAttribute('type', 'checkbox');
  250. this.checked = cfgbool(this.getAttribute('value')) != this.negative;
  251. this.addEventListener('change', this.update_truth);
  252. this.update_truth();
  253. }
  254. connectedCallback() { this.update_truth(); }
  255. adoptedCallback() { this.update_truth(); }
  256. static get observedAttributes() {
  257. return ['value', 'negative', 'conflicts', 'needs'];
  258. }
  259. attributeChangedCallback(name, oldval, newval) {
  260. if (oldval === newval)
  261. return;
  262. if (name == 'value') {
  263. this.truth = cfgbool(newval);
  264. } else if (name == 'negative') {
  265. this.checked = this.#truth != this.negative;
  266. } else if (name == 'conflicts' || name == 'needs') {
  267. this.resolve_conflicts();
  268. }
  269. }
  270. form_objects(list) {
  271. if (!this.form)
  272. return [];
  273. return list.map((n) => this.form.elements[n]);
  274. }
  275. do_resolve(which, sense) {
  276. for (const e of this.form_objects(which)) {
  277. if (e instanceof XBox)
  278. e.truth = sense;
  279. }
  280. }
  281. #resolving;
  282. resolve_conflicts() {
  283. const t = this.#truth;
  284. if (this.#resolving || !this.form)
  285. return;
  286. this.#resolving = true;
  287. if (t) {
  288. this.do_resolve(this.needs, true);
  289. this.do_resolve(this.conflicts, false);
  290. } else {
  291. for (const e of this.form.elements) {
  292. if (e instanceof XBox && e.truth) {
  293. if (e.form_objects(e.needs).some((x) => x === this))
  294. e.truth = false;
  295. }
  296. }
  297. }
  298. this.#resolving = false;
  299. }
  300. }
  301. customElements.define('x-box', XBox, { extends: 'input' });