// noinspection HtmlUnknownTarget
let ui={
		
		//How many records to retrieve in paginated queries
		defaultPageSize:25,
		
		daysOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],
		monthsOfYear:["January","February","March","April","May","June","July","August","September","October","November","December"],

		forceReload:function(stripQueryString){
			let f=document.createElement("form");
			f.action=window.document.location.href;
			if(stripQueryString){
				f.action=window.document.location.href.split("?")[0];
			}
			f.method="post";
			document.body.appendChild(f);
			f.submit();
		},

		archivedProjectMessageBar(parent){
			if(data && parseInt(data['isarchived'])){
				ui.infoMessageBar('Project has been archived. No changes can be made.', parent);
			}
		},

		successMessageBar:function(msg, parent){
			return ui._messageBar(msg,'completedbar',parent);
		},
		errorMessageBar:function(msg, parent){
			return ui._messageBar(msg,'errorbar',parent);
		},
		warningMessageBar:function(msg, parent){
			return ui._messageBar(msg,'warningbar',parent);
		},
		infoMessageBar:function(msg, parent){
			return ui._messageBar(msg,'infobar',parent);
		},
		_messageBar:function(msg,cssClass,parent){
			let bar=document.createElement("div");
			bar.className="msgbar "+cssClass;
			bar.innerHTML=msg;
			if(null!=parent){ parent.appendChild(bar); }
			return bar;
		},
		/************************************************
		 * REPLACEMENT FOR PROTOTYPE FUNCTIONS
		 * Avoid using these.
		 ************************************************/

		/**
		 * Replaces elem.previous(querySelector)
		 * @param elem
		 * @param querySelector
		 * @returns {Element|null}
		 */
		previousElementSiblingMatchingSelector:function(elem, querySelector){
			let sibling=elem.previousElementSibling;
			if(!querySelector){ return sibling; }
			while(sibling){
				if(sibling.matches(querySelector)){ return sibling; }
				sibling=sibling.previousElementSibling;
			}
			return null;
		},

		/**
		 * Replaces elem.next(querySelector)
		 * @param elem
		 * @param querySelector
		 * @returns {Element|null}
		 */
		nextElementSiblingMatchingSelector:function(elem, querySelector){
			let sibling=elem.nextElementSibling;
			if(!querySelector){ return sibling; }
			while(sibling){
				if(sibling.matches(querySelector)){ return sibling; }
				sibling=sibling.nextElementSibling;
			}
			return null;
		},

		/**
		 * Returns the total offset from the top left of the document.
		 * @param elem
		 * @returns {{top: number, left: number}}
		 */
		cumulativeOffset:function (elem) {
			let clientRect = elem.getBoundingClientRect();
			return {
				left: clientRect.left + document.body.scrollLeft,
				top: clientRect.top + document.body.scrollTop
			};
		},

		/**
		 * Returns the element's offset from its closest positioned ancestor
		 * @param elem
		 * @returns {{top: number, left: number}}
		 */
		positionedOffset:function (elem){
			return {
				top: elem.offsetTop,
				left:elem.offsetLeft
			}
		},

		/**
		 * Returns the array, with all instances of filter removed.
		 * Replaces Prototype's Array.without.
		 * ui.arrayWithout(["fred","bob","alice","bob","dave"],"bob") >>> ["fred","alice","dave"]
		 * @param array The array to filter
		 * @param filter The item to remove
		 */
		arrayWithout:function (array, filter){
				let index=array.indexOf(filter);
				while(-1!==index){
					array.splice(index,1);
					index=array.indexOf(filter);
				}
				return array;
		},

		unescapeHTML:function(str) {
			let doc = new DOMParser().parseFromString(str, "text/html");
			return doc.documentElement.textContent;
		},


		/**
		 * Renders a box inside the parent element. If parent is not specified, it will default to document.getElementById("grid"), then document.getElementById("content");
		 * @param box An object containing some of the following keys:
		 * - id The box ID
		 * - classes A space-separated list of CSS classes to apply to the box
		 * - title The box title. If not present, the box will have no header
		 * - content HTML for the box body. Should not be used with "url" option
		 * - url The API url to fetch content for the box
		 * - pageSize How many records to fetch at a time, default is ui.defaultPageSize
		 * - sortBy The property name on which to sort
		 * - sortDescending If true, sort descending
		 * - headers The headers for the contained table, see ui.table.
		 * - showFilters Whether to show the filter box for each table column
		 * - cellTemplates Cell templates for contained table, see ui.table.
		 * - sortOrders Sort order info for columns in contained table, see ui.table.
		 * @param parent The parent element
		 */
		box:function(box, parent){
			let b=document.createElement("div");
			b.classList.add("box");
			let boxid='box'+Math.floor(1000000*Math.random());
			if(box["id"]){ boxid=box["id"]; }
			b.id=boxid;
			if(box.classes){ b.classList.add(...box.classes.split(" ")); }
			if(box.title){ 
				let h2=document.createElement("h2");
				h2.classList.add(skin["boxHeaderIconTheme"]+"icons");
				h2.innerHTML=box.title;
				b.appendChild(h2);
			} else {
				b.classList.add("noheader");
			}
			let bb=document.createElement("div");
			bb.classList.add("boxbody");
			bb.classList.add(skin["bodyIconTheme"]+"icons");
			b.appendChild(bb);
			let bg=document.createElement("div");
			bg.classList.add("boxbackground");
			b.appendChild(bg);

			let pageSize=25;
			if(parseInt(""+ui.defaultPageSize)){ pageSize=parseInt(""+ui.defaultPageSize); }
			if(parseInt(""+box["pageSize"])){ pageSize=parseInt(""+box["pageSize"]); }

			let content='';
			if(box.url){
				bb.dataset.apiurl=box.url;
				if(box.sortBy){
					bb.dataset.sortby=box.sortBy;
					if(box.sortDescending){
						bb.dataset.sortdescending=box.sortDescending+"";
					}
				}
				if(box.getAll){
					bb.dataset.getAll="1";
				} else {
					bb.dataset.pagenumber="1";
					bb.dataset.pagesize=pageSize+"";
				}
				content='Loading...';
			} else if(box.content){
				content=box.content;
			}
			bb.innerHTML=content;
			b.table=function(tbl,data){ return ui.table(tbl,data,boxid); };
			b.form=function(frm){ return ui.form(frm,b); };
			b.treeItem=function(item){ return ui.treeItem(item,b); };
			bb.table=function(tbl,data){ return ui.table(tbl,data,boxid); };
			bb.form=function(frm){ return ui.form(frm,b); };
			bb.treeItem=function(item){ return ui.treeItem(item,b); };
			if(null!=parent){
				if("string"===typeof parent){ parent=document.getElementById(parent); }
				parent.appendChild(b);
			}
			if(box.url){
				let successHandler=function(transport){
					b.table({
						contentBefore: box.contentBefore,
						headers:box.headers, cellTemplates:box.cellTemplates, showFilters:box.showFilters, sortOrders:box.sortOrders
					}, transport.responseJSON);
				};
				if(box.successHandler){ successHandler=box.successHandler; }
				if(box.url.indexOf("?")>1){
					box.url+="&";
				} else {
					box.url+="?";
				}
				if(box.getAll){
					box.url+="all=1";
				} else {
					box.url+="pagenumber=1&pagesize="+pageSize+"";
				}
				if(box.sortBy){
					box.url+="&sortby="+box.sortBy;
					if(box.sortDescending){
						box.url+="&sortdescending=1";
					}
				}
				new AjaxUtils.Request(box.url,{
						method:'get',
						onSuccess:function(transport){ successHandler(transport, b)},
						onFailure:function(transport){ ui.defaultFailureHandler(transport, b)}
				});
			}
			return bb;
		},
		
		/**
		 * Open a modal box with the specified title and content. If needed, a function to call on close can be specified.
		 * 
		 * Parameters: As for "ui.box" (title and content are most interesting here), plus optionally confirmClose or onclose.
		 * 
		 * title: The title of the box
		 * content: The content of the box
		 * confirmClose: if document.getElementById("modalBox").tainted===true at close, call ui.confirmCloseModalBox on close. The onclose option overrides this.
		 * onclose: A custom function called when the box is closed. It should return true if the box can be closed, false otherwise. Overrides confirmClose.
		 */
		modalBox:function(box){
			let mw=document.getElementById("modalWindow");
			if(!mw){
				document.body.innerHTML+='<div id="modalWindow"><div id="modalBackground"></div><div id="modalContent" onclick="ui.closeModalBox()"></div></div>';
			}
			if(document.getElementById('modalBox')){ document.getElementById('modalBox').remove(); }
			if(document.getElementById('modalTabSet')){ document.getElementById('modalTabSet').remove(); }
			box.id="modalBox";
			let mbox=ui.box(box,'modalContent');
			let mb=document.getElementById("modalBox");
			mb.querySelector("h2").innerHTML='<span>'+mb.querySelector("h2").innerHTML+'</span>';
			mb.querySelector("h2").innerHTML='<img alt="Close" id="modalboxclose" onclick="ui.closeModalBox()" '+
				'style="cursor: pointer; height:2em; position:absolute; right:0.25em; top:0;" '+'' +
				'src="/images/icons/'+skin["boxHeaderIconTheme"]+'icons/close.gif" /></div>'+
				mb.querySelector("h2").innerHTML;
			mw.style.display="block";
			if(box.onclose){ 
				mw.closeCallback=box.onclose;
			} else if(box.confirmClose){
				mw.closeCallback=ui.confirmCloseModalBox;
			}
			mb.tainted=false;
			document.addEventListener("keyup",ui.checkForCloseModalBoxEscapeKey);
			return mbox;
		},
		
		setModalBoxTitle:function(title){
			let mb=document.getElementById("modalBox");
			if(!mb){ return; }
			mb.querySelector("h2 span").innerHTML=title;
		},

		logToDialog:function(message, type){
			let mb=document.getElementById("modalBox");
			if(!mb){
				mb=ui.modalBox({ "title":"Log messages" })
			}
			if("success"===type){
				message='<span style="font-weight:bold;color:#090">'+message+'</span>';
			}if("error"===type || "failure"===type){
				message='<span style="font-weight:bold;color:#900">'+message+'</span>';
			}
			mb.querySelector(".boxbody").innerHTML+='<br/>'+message;
		},

		modalTabSet:function(tabSet){
			if(!tabSet){ tabSet={}; }
			let mw=document.getElementById("modalWindow");
			if(!mw){
				document.body.innerHTML+='<div id="modalWindow"><div id="modalBackground" onclick="ui.closeModalBox()"></div><div id="modalContent" onclick="ui.closeModalBox()"></div></div>';
			}
			if(document.getElementById('modalBox')){ document.getElementById('modalBox').remove(); }
			if(document.getElementById('modalTabSet')){ document.getElementById('modalTabSet').remove(); }
			mw.style.display="block";
			document.addEventListener("keyup",ui.checkForCloseModalBoxEscapeKey);
			let ts=ui.tabSet({ id:'modalTabSet' }, document.getElementById('modalContent'));
			let t =document.createElement("h2");
			t.classList.add("tab","close");
			t.innerHTML='Close';
			t.onclick=ui.closeModalBox;
			if(tabSet.onclose){
				mw.closeCallback=tabSet.onclose;
			} else if(tabSet.confirmClose){
				mw.closeCallback=ui.confirmCloseModalBox;
			}
			ts.appendChild(t);
			return ts;
		},
		
		checkForCloseModalBoxEscapeKey:function(evt){
			if(27===evt.keyCode){ ui.closeModalBox(); }
		},
		
		/**
		 * Closes a modal box. If onclose or confirmClose was specified and returns false, the box will not close.
		 */
		closeModalBox:function(){
			let mw=document.getElementById("modalWindow");
			if(mw.closeCallback && false===mw.closeCallback()){ return false; }
			mw.style.display="none";
			document.stopObserving("keyup",ui.checkForCloseModalBoxEscapeKey);
			let child=mw.querySelector("#modalBox,#modalTabSet");
			if(!child){ return false; }
			child.remove();
		},
		
		/**
		 * Default "confirm close" function for modal box. Returns true if document.getElementById("modalBox").tainted evaluates to false or the user confirms close.
		 * Specify this behaviour with confirmClose:true in the original call to ui.modalBox().
		 */
		confirmCloseModalBox:function(){
			if(false===document.getElementById("modalBox").tainted){ return true; }
			return confirm("Really close this box?");
		},
		

		tabSet:function(tabSet, parent){
			let ts=document.createElement("div");
			ts.classList.add("tabset");
			if(tabSet.id){ ts.id=tabSet.id; }
			if(tabSet.classes){ ts.classList.add(...tabSet.classes.split(" ")); }
			if(null!=parent){ parent.appendChild(ts);}
			let tw=document.createElement("div");
			tw.classList.add("tabwrapper");
			ts.appendChild(tw);
			ts.tab=function(tab){ return ui.tab(tab,ts); };
			ts.filesTab=function(relatedObjectIds){ return ui.filesTab(ts,relatedObjectIds); };
			ts.notesTab=function(idOverride){ return ui.notesTab(ts,idOverride); };
			ts.mapTab=function(mapOptions){ return ui.Map.mapTab(ts, mapOptions); };
			ts.startTab=document.location.hash.substring(1);
			return ts;
		},
		
		tab:function(tab,parent){
			let t =document.createElement("h2");
			if(tab.classes){ t.className=tab.classes; }
			t.classList.add("tab");
			t.innerHTML=tab.label;
			if(!tab.disabled){  t.addEventListener("click", ui.clickTab); }
			if(tab.callback && !tab.disabled){  t.addEventListener("click", tab.callback); }
			let tb=document.createElement("div");
			tb.classList.add("tabbody");
			tb.classList.add(skin["bodyIconTheme"]+"icons");
			if(tab.disabled){
				t.classList.add("disabledtab");
			} else {
				t.classList.add("enabledtab");
			}
			let pageSize=25;
			if(ui.defaultPageSize){ pageSize=parseInt(ui.defaultPageSize); }
			if(parseInt(""+tab["pageSize"])){ pageSize=parseInt(""+tab["pageSize"]); }

			let content='';
			if(tab.content){
				content=tab.content;
			} else {
				content='Loading...';
				if(tab.getAll){
					tb.dataset.getAll="1";
				} else {
					tb.dataset.pagesize = pageSize+"";
					tb.dataset.pagenumber = "1";
				}
				if(tab.url){
					tb.dataset.apiurl=tab.url;
					if(tab.sortBy){
						tb.dataset.sortby=tab.sortBy;
						if(tab.sortDescending){
							tb.dataset.sortdescending=tab.sortDescending;
						}
					}
				}
				if(tab.renderer){
					tb.renderer=tab.renderer;
				}
			}
			tb.innerHTML=content;
			if(tab.id){
				t.id=tab.id;
				tb.id=tab.id+"_body";
			}
			if(null!=parent){ 
				parent.appendChild(t);
				parent.appendChild(tb);
				if(!parent.querySelector("h2.current") || t.id===parent.startTab){ t.click(); }
			}
			t.table=function(tbl,data){ return ui.table(tbl,data,tb); };
			t.form=function(frm){ return ui.form(frm,tb); };
			t.treeItem=function(item){ return ui.treeItem(item,tb); };
			t.refresh=function(){ return ui.refreshTab(tb); };
			tb.table=function(tbl,data){ return ui.table(tbl,data,tb); };
			tb.form=function(frm){ return ui.form(frm,tb); };
			tb.treeItem=function(item){ return ui.treeItem(item,tb); };
			tb.refresh=function(){ return ui.refreshTab(tb); };

			t.warn=function(text) { return ui.addTabWarning(t, text)};
			tb.warn=function(text) { return ui.addTabWarning(t, text)};
			t.unwarn=function() { return ui.removeTabWarning(t)};
			tb.unwarn=function() { return ui.removeTabWarning(t)};

			if(tb.renderer){
				tb.renderer(tb);
			} else if(tab.url){
				tb.successHandler=function(transport){
					t.table({ headers:tab.headers, cellTemplates:tab.cellTemplates, contentBefore:tab.contentBefore, sortOrders:tab.sortOrders }, transport.responseJSON);
				};
				if(tab.successHandler){ tb.successHandler=tab.successHandler; }
				tb.failureHandler=function(transport){ ui.defaultFailureHandler(transport, tb)};
				if(tab.failureHandler){ tb.failureHandler=tab.failureHandler; }
				if(tab.url.indexOf("?")>0){
					tab.url+="&";
				} else {
					tab.url+="?";
				}
				if(tb.dataset.getAll){
					tab.url+="all=1";
				} else {
					tab.url += "pagenumber=1&pagesize=" + pageSize;
				}
				if(tab.sortBy){
					tab.url+="&sortby="+tab.sortBy;
					if(tab.sortDescending){
						tab.url+="&sortdescending=1";
					}
				}
				tb.url=tab.url;
				ui.refreshTab(t);
			}
			return t;
		},

		addTabWarning:function(tabHeader, warningText){
			tabHeader.classList.add("warning");
			tabHeader.title=warningText;
		},

		removeTabWarning:function(tabHeader){
			tabHeader.classList.remove("warning");
			tabHeader.title="";
		},

	refreshTab:function(tab){
			if(!tab.classList.contains("tabbody")){
				tab=tab.nextElementSibling;
				if(!tab.classList.contains("tabbody")){
					alert("refreshTab called on something that was not a tab");
					return false;
				}
			}
			if(tab.renderer){
				tab.innerHTML="Loading...";
				tab.renderer();
			} else if(tab.url){
				tab.innerHTML="Loading...";
				new AjaxUtils.Request(tab.url,{
					method:'get',
					onSuccess:function(transport){ tab.successHandler(transport, tab)},
					onFailure:function(transport){ tab.failureHandler(transport, tab)}
				});
			} else {
				return false;
			}
		},
		
		clickTab:function(evt){
			let t=evt.target.closest(".tab");
			ui.selectTab(t);
		},
		selectTab:function(t){
			if(t.classList.contains("disabledtab")){ return; }
			let ts=t.closest(".tabset");
			let tabs=ts.querySelectorAll(".tab");
			tabs.forEach(function(tab){ 
				tab.classList.remove("current");
			});
			t.classList.add("current");
		},

	fieldToFriendlyFileSize:function(obj,field){
		let str=obj[field];
		return ui.friendlyFileSize(str);
	},

	friendlyFileSize(bytes){
		if(!/^\d+$/.test(bytes)){ return "Unknown"; }
		bytes=parseInt(bytes);
		let threshold=1024;
		if(bytes<threshold){ return bytes+" bytes"; }
		threshold*=1024;
		if(bytes<threshold){ return (1024*bytes/threshold).toFixed(2)+" kb"; }
		threshold*=1024;
		if(bytes<threshold){ return (1024*bytes/threshold).toFixed(2)+" Mb"; }
		threshold*=1024;
		if(bytes<threshold){ return (1024*bytes/threshold).toFixed(2)+" Gb"; }
		return (1024*bytes/threshold).toFixed(2)+" Tb";
	},

	filesTab: function(ts, relatedObjectIds){
			if(data["projectname"] && 'Default Project'===data["projectname"]){
				//Not a project view because data["projectname"] is present
				ts.tab({
					'id':'files',
					'label':'Files',
					'content':'Set the protein first',
				});
				return false; 
			}

			ui.fileTabHeaders=['Name','Description','Size','Uploaded'];
			ui.fileTabTemplates=[
				'<a class="filelink" target="_blank" href="/api/file/{{id}}/{{filename}}">{{filename}}</a>',
				'{{description}}',
				[ui.fieldToFriendlyFileSize,'bytes'],
				[ui.fieldToFriendlyDate,'uploaddatetime'],
			];
			let afterFilesTab=function(){
				let canWrite=canEdit || (typeof canWriteInProject==="boolean" && canWriteInProject);
				let fb=document.getElementById("files_body");
				if(!canWrite){
					if(!fb.querySelector("a.filelink")){
						fb.querySelector("table").innerHTML+='<tr class="addrow"><td colspan="'+ui.fileTabHeaders.length+'">No files</td></tr>';
					}
					return;
				}
				fb.querySelector("table").innerHTML+='<tr class="addrow">'+
					'<td><input type="file" name="file"></td>'+
					'<td colspan="2"><input type="text" name="description" placeholder="Description" style="width:95%"></td>'+
					'<td><input type="button" name="addfile" id="addfile" value="Add file" onclick="if(0!==this.closest(\'form\').querySelector(\'[type=file]\').files.length){ ui.submitForm(this) }"></td>'+
					'</tr>';
				let hiddenFields='<input type="hidden" name="csrfToken" value="'+csrfToken+'" /><input type="hidden" name="parentid" value="'+data.id+'" />';
				if("project"===data["objecttype"]){
					hiddenFields='<input type="hidden" name="csrfToken" value="'+csrfToken+'" />'+
						'<input type="hidden" name="parentid" value="'+nullValue+'" />'+
						'<input type="hidden" name="projectid" value="'+data.id+'" />';
				}
				fb.innerHTML+='<form class="noprint" action="/api/file" id="files_form" method="post" enctype="multipart/form-data" onsubmit="return false;">'+
						hiddenFields+'</form>';
				document.getElementById("addfile").options={
					afterSuccess:function(){
						fb.refresh();
					}
				};
				document.getElementById("files_form").appendChild(fb.querySelector("table"));
				fb.querySelectorAll("tr").forEach(function(tr) {
					let div=document.createElement("div");
					div.classList.add("border");
					tr.appendChild(div);
				});

				/*
				 * Get files for any related object IDs that were passed in
				 */
				let relatedIds=document.getElementById("files").relatedObjectIds;
				if(!relatedIds){ relatedIds=[]; }
				relatedIds=[0].concat(relatedIds);
				for(let i=0;i<relatedIds.length;i++){
					let relatedId=parseInt(""+relatedIds[i]);
					if(0===relatedId){ continue; }
					new AjaxUtils.Request('/api/baseobject/'+relatedId+'/file', {
						method:'get',
						onSuccess:function(transport){
							let tbl=document.getElementById("files_body").querySelector("table");
							if(tbl.querySelector("tbody")){ tbl=tbl.querySelector("tbody"); }
							transport.responseJSON.rows.forEach(function(f){
								if(""===f.name && ""!==f.filename){ f.name=f.filename; }
								let tr=document.createElement("tr");
								ui.fileTabTemplates.forEach(function(t){
									let td=document.createElement("td");
									td.innerHTML=t.replace("{{id}}",f.id).
										replaceAll("{{filename}}",f.filename).
										replace("{{description}}",f.description).toLowerCase().
										replace("{{bytes}}",f["bytes"]);
									tr.appendChild(td);
								});
								tbl.appendChild(tr);
							});
							let addRow=tbl.querySelector(".addrow");
							if(addRow){
								addRow.remove();
								tbl.appendChild(addRow);
							}
						},
						onFailure:function(transport){
							//nothing to do
						}
					});
				}
			};

			let filesUrl='/api/baseobject/'+data.id+'/file';
			if("project"===data['objecttype']){
				filesUrl='/api/project/'+data.id+'/file';
			}
			let t=ts.tab({
				'id':'files',
				'label':'Files',
				'url':filesUrl,
				'all':1,
				'headers':ui.fileTabHeaders,
				'cellTemplates':ui.fileTabTemplates,
				'successHandler':function(transport){
					document.getElementById("files_body").table({ headers:ui.fileTabHeaders, cellTemplates:ui.fileTabTemplates }, transport.responseJSON);
					afterFilesTab();
				},
				'failureHandler':function(){
					//Failure is almost certainly "No files" so just render an empty table
					let tr=document.createElement("tr");
					tr.innerHTML="<th>"+ui.fileTabHeaders.join("</th><th>")+"</th>";
					let tbl=document.createElement("table");
					tbl.appendChild(tr);
					let fb=document.getElementById("files_body");
					fb.classList.add("hastable");
					fb.innerHTML="";
					fb.appendChild(tbl);
					afterFilesTab();
				}
			});
			t.relatedObjectIds=relatedObjectIds;
			return t;
		},
		
		noteFormTemplate:'<form id="notesform" method="post" class="noprint">'+
			'<input type="hidden" name="projectid" value="{{projectid}}"/>'+
			'<input type="hidden" name="parentid" value="{{parentid}}"/>'+
			'<input type="hidden" name="csrfToken" value="{{csrfToken}}"/>'+
			'<label style="position:relative;top:0;padding:0.25em 0.5em;margin-bottom:0.5em"><textarea style="width:100%;height:5em" name="text" id="text" placeholder="Add a note..."></textarea><input type="button" onclick="ui.addNote(this)" value="Add note"/><div style="clear:both"></div></label></form>',

		noteTemplate:'<a href="/user/{{userid}}">{{username}}</a>, {{createtime}}:<br/><div style="line-height:1.25em;margin-bottom:0.5em">{{text}}</div>',

		notesTab:function(ts, parentId){
			let notesUrl="";
			let projectId=nullValue;
			let projectName=nullValue;
			if(ts.record){
				parentId=ts.record["id"];
				projectId=ts.record["projectid"];
				projectName=ts.record["projectname"];
			} else if('project'===data['objecttype']){
				parentId=nullValue;
				projectId=data["id"];
				projectName=data["name"];
				notesUrl="/api/project/"+projectId+"/note";
			} else if(!parentId){
				parentId=data["id"];
			}

			if(""===notesUrl){
				notesUrl="/api/baseobject/"+parentId+"/note";
			}

			if("project"!==data["objecttype"] && (!projectName || "Default Project"===projectName)){
				ts.tab({
					"id":"notes",
					"label":"Notes",
					"content":"This item is in the default project, so notes cannot be added. If you have permission to do so, you should move it to a real working project.",
				});
				return false;
			}

			let noteForm=ui.noteFormTemplate.replace('{{projectid}}', projectId).replace('{{parentid}}', parentId).replace('{{csrfToken}}',csrfToken);
			let canWrite=canEdit || (typeof canWriteInProject==="boolean" && canWriteInProject);

			let t=ts.tab({
				'id':'notes',
				'label':'Notes',
				'url':notesUrl,
				'all':1,
				'cellTemplates':[ [ui.processNoteTemplate,'id'] ],
				'contentBefore': canWrite ? noteForm : '',
				'failureHandler':function(){ t.nextElementSibling.innerHTML=canWrite ? noteForm : 'No notes'; }
			});
			return t;
		},
		processNoteTemplate: function(note){
			let t=""+ui.noteTemplate;
			t=t.replace("{{createtime}}", ui.friendlyDate(note["createtime"]) );
			note.text=ui.urlifyAndBreakNoteText(note.text);
			let regex=/{{[().a-zA-Z0-9_-]+}}/g;
			let found=t.match(regex);
			if(found){
				found.forEach(function(f){
					let prop=f.slice(2,-2);
					t=t.replace(f,note[prop]);
				});
			}
			return t +"";
		},
		urlifyAndBreakNoteText:function(txt){
			txt=txt.replace(/(https?:\S+[^.\s,!?]+)/g,'<a hr'+'ef="$&">$&</a>');
			txt=txt.replace(/\r\n|\r|\n/g,"<br/>");
			return txt;
		},
		addNote: function(btn){
			let nf=btn.closest("form");
			if(""===nf.querySelector("[name=text]").value.trim()){
				//Note is blank
				return false;
			}
			btn.closest("label").classList.add("updating");
			btn.options={
				afterSuccess:function(){
					btn.closest(".tabbody").refresh();
				}
			};
			nf.action='/api/note';
			nf.method='post';
			nf.querySelector('[name=csrfToken]').value=csrfToken;
			ui.submitForm(btn);
		},

		mapTab:function(tabSet, mapOptions) {
			return ui.Map.mapTab(tabSet, mapOptions);
		},

		/**
		 * Writes a table template into the parent element and populates it with the supplied data.
		 * @param tbl An object describing the table, with some of the following properties:
		 * - contentBefore HTML to insert at the top of the table
		 * - headers An array of column headers (required)
		 * - cellTemplates An array of templates for each cell in a row (required). A function can be passed, see below.
		 * - showFilters An array of booleans, whether to show a filter box for each column.
		 * - sortOrders An array, if present must be same length as headers. "" is non-sortable column, "name" sorts by name ascending on first click, "-name" sorts by name descending on first click.
		 * - dataRowCallback A function, taking a table row as an argument, to be called on each data row after rendering.
		 * - stickyColumns How many columns on the left of the table should remain fixed when scrolling horizontally. Default is 1.
		 * Passing functions into cell templates: Pass an array of function name and (optionally) the property, for example [ui.checkmark, 'isactive']. When
		 * defining custom functions for use in this way, the function should take the data row and (optionally) the field name as arguments. It should return the HTML to insert into the table cell.
		 * @param tableData The JSON data to be rendered
		 * @param parent The parent element of the new table
		 * @return the table element
		 */
		table:function(tbl,tableData,parent){
			if("string"===typeof parent){ parent=document.getElementById(parent); }
			if(parent){
				if(parent.querySelector(".boxbody")){ parent=parent.querySelector(".boxbody"); }
				if(parent.classList.contains("tab")){ parent=parent.nextElementSibling; }
			}
			let t=document.createElement("table");
			if(tbl.id){
				t.id=tbl.id; 
			} else if(null!=parent){
				t.id=parent.id+"_table";
			} else {
				t.id="table"+Math.floor(1000000*Math.random());
			}
			t.className="uitable";
			t.cellTemplates=tbl.cellTemplates;
			t.hasNoMore=false;
			t.stickyColumns=tbl.stickyColumns ? parseInt(tbl.stickyColumns) : 1;

			if(parent.dataset.apiurl){
				let parts=parent.dataset.apiurl.substring(1).split("?")[0].replace(/\/$/, "").split("/");
				if('api'===parts[0]){ //it definitely should be!
					if(2===parts.length){
						// This is a get-all, e.g., /api/project, we want "project"
						t.dataset.otherType=parts[1];
					} else if(4<=parts.length){
						if(parseInt(parts[2])){
							// This is a get-role, e.g., /api/project/1234/permission, we want "permission"
							t.dataset.otherType=parts[3];
						} else if(parseInt(parts[3])){
							// This is a get-by-property|ies, e.g., api/permission/projectid/1234, we want "permission"
							t.dataset.otherType=parts[1];
						}
					}
				}
			}

			let tableHeader=document.createElement("thead");

			if(tbl.contentBefore){
				let rowBefore=document.createElement("tr");
				rowBefore.className="beforetable headerrow";
				let tdBefore=document.createElement("td");
				tdBefore.colSpan=tbl.cellTemplates.length;
				if(tbl.headers){ tdBefore.colSpan=tbl.headers.length; }
				tdBefore.innerHTML=tbl.contentBefore;
				rowBefore.appendChild(tdBefore);
				tableHeader.appendChild(rowBefore);
			}

			if(null!=tbl.headers && 0!==tbl.headers.length){
				let tr=document.createElement("tr");
				tr.className="headerrow";
				let hasSortOrders=false;
				if(null!=tbl.sortOrders && 0!==tbl.sortOrders.length){
					if(tbl.sortOrders.length!==tbl.headers.length){
						let rowBefore=document.createElement("tr");
						rowBefore.className="beforetable headerrow";
						let tdBefore=document.createElement("td");
						tdBefore.colSpan=tbl.headers.length;
						tdBefore.innerHTML='<div class="warning">Warning: Header and sort-order array are not the same length</div>';
						rowBefore.appendChild(tdBefore);
						tableHeader.appendChild(rowBefore);
					} else {
						hasSortOrders=true;
					}
				}

				let count=0;
				let showSort=hasSortOrders;
				if(tableData.rows && 1===tableData.rows.length){ showSort=false; }
				if(tableData.data && 1===tableData.data.length){ showSort=false; }
				tbl.headers.forEach(function(h){
					let th=document.createElement("th");
					if(showSort&& ''!==tbl.sortOrders[count]){
						let so=tbl.sortOrders[count];
						th.classList.add("sortable");
						if(0===so.indexOf("-")){
							//first click should sort descending
							th.dataset.sortby=so.substring(1);
							th.dataset.sortdescending="1";
							th.innerHTML='<span>'+h+'</span>';
						} else {
							//first click should sort ascending
							th.dataset.sortby=so;
							th.dataset.sortdescending="0";
							th.innerHTML='<span>'+h+'</span>';
						}
						if(parent.dataset.sortby===th.dataset.sortby){
							if(1*parent.dataset.sortdescending){
								th.classList.add("sorteddescending");
							} else {
								th.classList.add("sortedascending");
							}
						}
						th.onclick=ui.sortTable;
					} else {
						th.innerHTML='<span>'+h+'</span>';
					}
					count++;
					tr.appendChild(th);
				});
				tableHeader.appendChild(tr);
			}

			if(null!=tbl.showFilters && 0!==tbl.showFilters.length){
				let tr=document.createElement("tr");
				tr.className="filterrow";
				for (let i=0; i<tbl.cellTemplates.length; i++){
					let td=document.createElement("td");
					if(tbl.showFilters[i]){
						let fb=document.createElement("input");
						fb.type="text";
						fb.placeholder="Filter...";
						fb.onkeyup=ui.filterTable;
						fb.onblur=ui.filterTable;
						fb.dataset.tdIndex=""+i;
						td.appendChild(fb);
					}
					tr.appendChild(td);
				}
				tableHeader.appendChild(tr);
			}

			let headerTr=tableHeader.querySelector("tr");
			if(headerTr){
				t.appendChild(tableHeader);
			}

			let moreTr=document.createElement("tr");
			moreTr.className="listmore noprint";
			moreTr.id=t.id+"_more";
			let moreTd=document.createElement("td");
			moreTd.colSpan=t.cellTemplates.length;
			if(ui.isSmallScreen){
				moreTd.innerHTML='<a href="#" onclick="ui.lazyLoad(this.closest(\'table\'));return false">See more</a>';
			} else {
				moreTd.innerHTML="Loading...";
			}
			moreTr.appendChild(moreTd);
			t.appendChild(moreTr);
			if(null!=parent){
				parent.innerHTML="";
				parent.appendChild(t);
				parent.classList.add("hastable");
				parent.tableHeaders=tbl.headers;
				parent.tableCellTemplates=tbl.cellTemplates;
				parent.tableSortOrders=tbl.sortOrders;
				parent.tableShowFilters=tbl.showFilters;
				parent.tableContentBefore=tbl.contentBefore;
				if(tbl.dataRowCallback){
					parent.dataRowCallback=tbl.dataRowCallback;
				}
			}
			
			if(tableData.rows){
				tableData=tableData.rows;
			} else if(tableData.data) {
				tableData=tableData.data;
			}

			t.querySelectorAll("tr").forEach(function (tr){
				let div=document.createElement("div");
				div.classList.add("border");
				tr.appendChild(div);
			});

			ui.renderTableRows(t, tableData);
			if(tableData.length>=parent.dataset.pagesize){
				if(ui.isSmallScreen){
					
				} else {
					window[parent.id+"_lazyload"]=function(){ ui.lazyLoad(t) };
					window.setInterval(window[parent.id+"_lazyload"], 1000);
				}
			}
			t.updating=false;

			return t;
		},

		/**
		 * Adds the specified items to an existing table as table rows, using the table's row templates.
		 * @param tbl The table
		 * @param items The items to add.
		 */
		renderTableRows:function(tbl, items){
			let loadRow=tbl.querySelector("tr.listmore");
			let regex=/{{[().a-zA-Z0-9_-]+}}/g;
			let container=tbl.parentElement;
			let newRows=[];
			let numItems=items.length;
			for(let i=0;i<numItems;i++){
				let row=items[i];
				if("object"!==(typeof row).toLowerCase()){ continue; }
				let cells=[];
				tbl.cellTemplates.forEach(function(ct){
					if(ct instanceof Array && 2===ct.length){
						if(typeof(ct[0])!=='function'){
							cells.push('[Array]');
						} else {
							//call the function, with object and property name as args
							cells.push( ct[0](row, ct[1]) )
						}
					} else {
						cells.push(ct.trim());
					}
				});
				let tr=document.createElement("tr");
				tr.className="datarow";
				cells.forEach(function(c){
					let td=document.createElement("td");
					if(!c && ""!==c){
						td.innerHTML="[!]";
						td.title="Table render error.";
						tr.appendChild(td);
						return;
					}
					if(c.innerHTML){
						td.appendChild(c);
						tr.appendChild(td);
						return;
					}
					let found=c.match(regex);
					if(found){
						found.forEach(function(f){
							let prop=f.slice(2,-2);
							c=c.replace(f,row[prop]);
						});
					}
					td.innerHTML=c;
					tr.appendChild(td);
				});

				let div=document.createElement("div");
				div.classList.add("border");
				tr.appendChild(div)

				tr.rowData=row;
				newRows.push(tr);
				tbl.appendChild(tr);
				tbl.appendChild(loadRow);
			}
			let filters=ui.getTableFilters(tbl);
			newRows.forEach(function(tr){
				let container=tr.closest(".hastable");
				if(container && container.dataRowCallback){
					container.dataRowCallback(tr)
				}
				ui.filterTableRow(tr,filters);
			});

			if(!container || !container.dataset || !container.dataset.pagesize || items.length<parseInt(container.dataset.pagesize) || container.dataset.getAll){
				//there won't be another page, so
				tbl.hasNoMore=true;
				loadRow.remove();
			}
			tbl.updating=false;
			window.setTimeout(ui.handleTableStickyColumns, 25, tbl);
		},

		handleTableStickyColumns:function (tbl){
			if(tbl.stickyColumns){
				let parentElement=tbl.closest(".tabbody,.boxbody,.treebody");
				if(parentElement){ parentElement.style.overflowX="auto"; }
				let cumulativeWidth=0;
				for(let i=1;i<=tbl.stickyColumns;i++){
					let cells=tbl.querySelectorAll("tr>*:nth-child("+i+")");
					let offsetWidth=cells[0].offsetWidth;
					if(!offsetWidth){
						window.setTimeout(ui.handleTableStickyColumns, 25, tbl);
						break;
					}
					cells.forEach(function(cell){
						if(cell.closest("tr").classList.contains("filterrow")) {
							cell.classList.add("stickyFilterColumnCell");
						}
						cell.classList.add("stickyColumnCell");
						cell.style.left=cumulativeWidth+"px";
						cell.style.backdropFilter="blur(10px)";
					})
					cumulativeWidth+=offsetWidth;
				}
			}
		},

		sortTable:function(evt){
			let header=evt.target.closest("th");
			let container=header.closest(".boxbody,.tabbody,.treebody");
			if(!container.dataset.getAll && !container.dataset.pagesize){
				container.dataset.pagesize="50";
				container.dataset.pagenumber="1";
			}
			if(container.dataset.sortby===header.dataset.sortby){
				container.dataset.sortdescending=(0===parseInt(container.dataset.sortdescending))?"1":"0"; //toggle between 0 and 1
			} else {
				container.dataset.sortby=header.dataset.sortby;
				container.dataset.sortdescending=header.dataset.sortdescending;
			}
			let uri=container.dataset.apiurl;
			if(uri.indexOf("?")>1){
				uri+="&";
			} else {
				uri+="?";
			}
			if(container.dataset.getAll){
				uri+="all=1";
			} else {
				uri+="pagenumber=1&pagesize="+container.dataset.pagesize;
			}
			uri+="&sortby="+container.dataset.sortby+"&sortdescending="+container.dataset.sortdescending;
			container.tableFilters=ui.getTableFilters(header.closest("table"));
			container.innerHTML="Loading...";
			new AjaxUtils.Request(uri,{
				method:"get",
				onSuccess:function(transport){
					let tbl=container.table({
						headers:container.tableHeaders, 
						cellTemplates:container.tableCellTemplates, 
						sortOrders:container.tableSortOrders,
						showFilters:container.tableShowFilters,
						contentBefore:container.tableContentBefore
					},
					transport.responseJSON);
					let filterRow=tbl.querySelector("tr.filterrow");
					let filtered=false;
					if(container.tableFilters && filterRow){
						let count=0;
						filterRow.querySelectorAll("th,td").forEach(function(cell){
							let inp=cell.querySelector("input");
							if(inp){
								inp.value=container.tableFilters[count];
								if(""!==inp.value){
									filtered=true;
								}
							}
							count++;
						});
					}
					container.filters=null;
					if(filtered){
						window.setTimeout(function(){
							ui._doFilterTable(filterRow.querySelector("input"));
						},50);
					}
				},
				onFailure:function(transport){
					container.innerHTML='Could not get data from server.';
					AjaxUtils.checkResponse(transport);
				}
			})
		},

		filterTimeout:null,

		filterTable:function(evt) {
			window.clearTimeout(ui.filterTimeout);
			let inp = evt.target;
			ui.filterTimeout=window.setTimeout(function(){
				ui._doFilterTable(inp);
			}, 500);
		},
		_doFilterTable:function(inp){
			let tbl=inp.closest("table");
			let rows=tbl.querySelectorAll("tr.datarow");
			let filters=ui.getTableFilters(tbl);
			rows.forEach(function(tr){
				ui.filterTableRow(tr,filters);
			});
		},
		getTableFilters:function(tbl){
			let filters=[];
			let filterRow=tbl.querySelector("tr.filterrow");
			if(!filterRow){ return false; }
			filterRow.querySelectorAll("th,td").forEach(function(cell){
				let box=cell.querySelector("input");
				if(box) {
					filters.push(box.value);
				} else {
					filters.push("");
				}
			});
			return filters;
		},
		filterTableRow:function(tr,filters){
			let numFilters=filters.length;
			if(!filters){ numFilters=0; }
			for(let i=0; i<numFilters; i++) {
				let filter = filters[i].toLowerCase();
				if ("" === filter) {
					continue;
				}
				let cell = tr.querySelectorAll("th,td")[i];
				if(cell.innerText.toLowerCase().indexOf(filter)===-1){
					tr.style.display="none";
					return;
				}
			}
			tr.style.display="";
		},

		lazyLoad:function(tbl){
			let container=tbl.parentElement;
			if(container && container.dataset.getAll){ return false; } //nothing more to fetch, got it on the first request
			let pixelBuffer=100; //start lazy load when "loading" row is this many pixels below bottom edge of container
			if(!document.getElementById(tbl.id) || !tbl.parentElement || tbl.updating || tbl.hasNoMore || !tbl.querySelector("tr.listmore")){ return false; }
			if(tbl.closest(".tabbody") && "none"===window.getComputedStyle(tbl.closest(".tabbody"),null).display){
				//Table is in a tab but tab is not current, so...
				return false;
			}
			let containerHeight=tbl.parentElement.offsetHeight; //height of the containing element
			let containerScrollHeight=tbl.parentElement.scrollHeight; //total height of content
			let containerScrollTop=tbl.parentElement.scrollTop; //how far the content has been scrolled
			if(containerScrollHeight>(containerHeight+containerScrollTop+pixelBuffer)) {
				//loading row not visible, so...
				return false;
			}
			tbl.updating=true;

			container.dataset.pagenumber=((1*container.dataset.pagenumber)+1)+"";
			let pageNumber=1*container.dataset.pagenumber;
			let pageSize=1*container.dataset.pagesize;
			let url=container.dataset.apiurl;
			if(url.indexOf("?")>0){
				url+="&";
			} else {
				url+="?";
			}
			url+="pagesize="+pageSize+"&pagenumber="+pageNumber;
			if(container.dataset.sortby){
				url+="&sortby="+container.dataset.sortby+"&sortdescending="+container.dataset.sortdescending;
			}
			new AjaxUtils.Request(url,{
				method:"get",
				onSuccess:function(transport){ ui.lazyLoad_onSuccess(tbl,transport); },
				onFailure:function(transport){ ui.lazyLoad_onFailure(tbl,transport); },
			});
		},
		lazyLoad_onSuccess:function(tbl,transport){
			ui.renderTableRows(tbl, transport.responseJSON.rows);
		},
		lazyLoad_onFailure:function(tbl,transport){
			tbl.hasNoMore=true;
			let lastRow=tbl.querySelector("tr.listmore");
			if(404===transport.status){
				lastRow.remove();
			} else if(401===transport.status){
				ui.handleSessionExpired();
			} else if(transport.responseJSON && transport.responseJSON.error){
				lastRow.innerHTML=transport.responseJSON.error;
			} else {
				lastRow.innerHTML="There was an error. The server said:\n\n"+transport.responseText;
			}
		},
		
		/**
		 * Generates a grid wrapper inside the document body.
		 * @param showSlots Whether to generate light boxes in the background to show empty spaces in the grid. Default false. 
		 * @param gridId The HTML id of the grid wrapper. Default "grid".
		 * @return the grid wrapper element.
		 */
		grid:function(showSlots, gridId){
			let slots='';
			if(!gridId){ gridId="grid"; }
			if(showSlots){
				slots=ui.gridSlots();
			}
			let out='<div class="grid" id="'+gridId+'">'+slots+'</div>';
			document.getElementById("content").innerHTML+=out;
			let grid=document.getElementById(gridId);
			grid.box=function(box){ return ui.box(box,grid); };
			grid.tabSet=function(tabSet){ return ui.tabSet(tabSet,grid); };
			return grid;
		},
		gridSlots:function(){
			let slots="";
			for(let r=1;r<=3;r++){
				for(let c=1;c<=3;c++){
					slots+='<div class="noprint boxslot r'+r+' c'+c+'" data-gridrow="'+r+'" data-gridcol="'+c+'"></div>';
				}	
			}
			return slots;
		},


		/**
		 * Writes a tree node. 
		 * {
		 * 	id: The id of the header. The body will have the same ID plus "_body".
		 *  record: The object represented by this tree item. May be used by the updater, for example.
		 *  updater: A function to generate and insert the body content. It should expect the tree item header as a parameter.
		 *  header: The title shown on the tree node.
		 *  content: Static HTML content to use instead of an updater function.
		 *  isOpen: Whether the tree item should be open when rendered. Default false.
		 *  url: Optional URL for AJAX request, by default writes table,
		 *  headers: Array of table headers after AJAX request
		 *  cellTemplates: Array of table cell templates after AJAX request
		 *  successHandler: Optional, success handler function(transport, treeItem) for AJAX request
		 *  requestHeaders: Optional, key-value pairs for AJAX request headers
		 * }
		 */
		treeItem:function(item,parent){
			if(null!=parent && parent.querySelector(".boxbody")){ parent=parent.querySelector(".boxbody"); }
			let ti=document.createElement("div");
			ti.classList.add("treeitem");
			if(!item.isOpen){
				ti.classList.add("closed");
			}
			let head=document.createElement("h3");
			head.classList.add("treehead",skin["treeHeaderIconTheme"]+"icons");
			let toggle=document.createElement("span")
			toggle.classList.add("toggleitem");
			head.appendChild(toggle);
			ti.appendChild(head);
			head.innerHTML+=item.header;
			let tb=document.createElement("div");
			tb.classList.add("treebody");
			if(item.content){ 
				tb.innerHTML=item.content; 
			} else if(item.updater){
				tb.innerHTML='Loading...'; 
				ti.updater=item.updater;
			}
			ti.appendChild(tb);
			if(item.id){
				ti.id=item.id;
				head.id=item.id+"_head";
				tb.id=item.id+"_body";
			}
			ti.form=function(frm){ return ui.form(frm,tb) };
			tb.form=function(frm){ return ui.form(frm,tb) };
			ti.treeItem=function(item){ return ui.treeItem(item,tb) };
			tb.treeItem=function(item){ return ui.treeItem(item,tb) };
			ti.table=function(tbl,data){ return ui.table(tbl,data,tb); };
			tb.table=function(tbl,data){ return ui.table(tbl,data,tb); };
			ti.warn=function(text) { return ui.addTabWarning(head, text)};
			tb.warn=function(text) { return ui.addTabWarning(head, text)};
			ti.unwarn=function() { return ui.removeTabWarning(head)};
			tb.unwarn=function() { return ui.removeTabWarning(head)};
			if(item.url && !item.updater){
				ti.url=item.url;
				let successHandler=function(transport){
					tb.table({ headers:item.headers, cellTemplates:item.cellTemplates }, transport.responseJSON);
				};
				if(item.successHandler){ successHandler=item.successHandler; }
				if(item.url.indexOf("?")>1){
					ti.url+="&";
				} else {
					ti.url+="?";
				}
				if(tb.dataset.getAll){
					ti.url+="all=1";
				} else {
					if(!tb.dataset.pagesize){ tb.dataset.pagesize="25"; }
					ti.url+="pagenumber=1&pagesize="+tb.dataset.pagesize;
				}
				if(-1!==ti.url.indexOf(window.location.host)){
					new AjaxUtils.Request(ti.url,{
						method:'get',
						onSuccess:function(transport){ successHandler(transport, ti)},
						onFailure:function(transport){ ui.defaultFailureHandler(transport, ti)}
					});
				} else {
					ti.requestHeaders=item.requestHeaders;
					AjaxUtils.remoteAjax(
							ti.url,
							'get',
							{},
							function(transport){ successHandler(transport, ti)},
							function(transport){ ui.defaultFailureHandler(transport, ti)},
							ti.requestHeaders
					);
				}
			}

			ti.record=item.record;
			if(null!=parent){
				parent.appendChild(ti);
			}
			head.addEventListener("click",ui.toggleTreeItem);
			return ti;
		},
		
		toggleTreeItem:function(evt){
			let ctrl=evt.target;
			ui.doToggleTreeItem(ctrl.closest(".treeitem"));
		},
		doToggleTreeItem:function(treeItem){
			let toOpen=treeItem.classList.contains("closed");
			if(toOpen){
				treeItem.classList.remove("closed");
				let tb=treeItem.querySelector(".treebody");
				if(treeItem.updater){
					treeItem.updater(tb);
				}
			} else {
				treeItem.classList.add("closed");
			}
		},

		defaultSuccessHandler:function(transport,containerData){
			ui.table(containerData,transport.responseJSON,containerData.id);
		},

		defaultFailureHandler:function(transport,parent){
			let err=transport.responseText;
			if(transport.responseJSON && transport.responseJSON.error){
				err=transport.responseJSON.error;
			}
			if(parent.querySelector(".boxbody")){
				parent=parent.querySelector(".boxbody");
			}
			parent.innerHTML=err;
		},
		
		handleSessionExpired:function(){
			alert("Your session has expired, or you logged out in another tab. When you click OK, you can log in again.");
			ui.forceReload();
		},
		
		form:function(frm,parent){
			if(!frm.id){ frm.id='frm_'+Math.floor(1000000*Math.random()); }
			if(undefined===frm.readonly && undefined!==canEdit){ frm.readonly=!canEdit; }
			let f=document.createElement("form");
			f.id=frm.id;
			f.action=frm.action;
			if(undefined!==frm.readonly){ f.dataset.readonly= ""+(frm.readonly===true ? 1 : 0); }
			if(undefined!==frm.autosubmit && frm.autosubmit===false){ f.classList.add("suppressautoupdate"); }
			f.dataset.ajaxmethod=(frm.method) ? frm.method : "post";
			if(frm.classes){ f.classList.add(...frm.classes.split(" ")); }
			if(null!=parent){
				if(parent.querySelector(".boxbody")){ parent=parent.querySelector(".boxbody") }
				parent.appendChild(f);
			}
			f.hiddenField=function(fieldName,fieldValue){ return ui.hiddenField(fieldName,fieldValue,f); };
			f.textField=function(field){ return ui.textField(field,f); };
			f.textArea=function(field){ return ui.textArea(field,f); };
			f.passwordField=function(field){ return ui.passwordField(field,f); };
			f.fileField=function(field){ return ui.fileField(field,f); };
			f.formField=function(field){ return ui.formField(field,f); };
			f.roleField=function(field){ return ui.roleField(field,f); };
			f.starRatingField=function (field){ return ui.starRatingField(field, f); };
			f.dropdown   =function(field){ return ui.dropdown(field,f); };
			f.checkbox   =function(field){ return ui.checkbox(field,f); };
			f.createButton=function(options){ return ui.createButton(f,options); };
			f.buttonField=function(field){ return ui.buttonField(field,f); };
			f.submitButton=function(field){ return ui.submitButton(field,f); };
			f.datePicker=function(field){ return ui.datePicker(field,f); };
			f.dateTimePicker=function(field){ return ui.dateTimePicker(field,f); };
			f.dateField=function(field){ return ui.dateField(field,f); };
			f.dateTimeField=function(field){ return ui.dateTimeField(field,f); };
			f.radioButtons=function(field){ return ui.radioButtons(field,f); };
			f.audioField=function (field) { return ui.audioField(field,f); };
			f.colorField=function(field){ return ui.colorField(field, f); };
			f.sectionHeading=function(headingText){return ui.sectionHeading(headingText, f); }
			if('get'!==f.dataset.ajaxmethod){
				f.hiddenField("csrfToken",csrfToken);
			}
			return f;
		},

	/**
	 * @param field Details of the form field. Options include name, id, content, afterUpdate.
	 * @param parent
	 * @returns HTMLElement
	 */
		formField:function(field,parent){
			let lbl=document.createElement("label");
			lbl.htmlFor=field.name;
			if(field.id){ lbl.id=field.id+"_label"; }
			let s=document.createElement("span");
			s.classList.add("label");
			s.innerHTML=field.label;
			let validations=field.validations;
			if(!validations && fieldValidations){ validations=fieldValidations[field.name]; }
			if(validations && !field["readonly"]){
				if(-1!==validations.indexOf("required")){
					s.innerHTML+='<span class="required">*</span>';
					s.title="This field is required.";
				}
			}
			lbl.appendChild(s);
			if(field.content){ lbl.innerHTML+=field.content; }
			if(field.afterUpdate){ lbl.afterUpdate=field.afterUpdate; }
			if(null!=parent){ parent.appendChild(lbl); }
			return lbl;
		},
		addHelpText:function(label,fieldName){
			if(helpTexts && helpTexts[fieldName]){
				let h=document.createElement("div");
				h.classList.add("helptext");
				h.innerHTML=helpTexts[fieldName];
				label.appendChild(h);
			}
		},
		addSuppliedHelpText:function(label,suppliedText){
			let h=document.createElement("div");
			h.classList.add("helptext");
			h.innerHTML=suppliedText;
			label.appendChild(h);
		},
		createButton:function(parent,options){
			if(!options){ options={}; }
			let lbl=document.createElement("label");
			let cb=document.createElement("input");
			cb.type="button";
			cb.value="Create";
			cb.options=options;
			if(options.beforeSubmit){
				cb.onclick=function(){ options.beforeSubmit(); ui.submitForm(cb); }
			} else {
				cb.onclick=function(){ ui.submitForm(cb); }
			}
			if(options.tabAfterCreate){
				cb.dataset.tabOnViewPage=options.tabAfterCreate;
			}
			lbl.appendChild(cb);
			if(null!=parent){ 
				parent.appendChild(lbl);
				let frm=lbl.closest("form");
				frm.classList.add("suppressautoupdate");
				if(options.beforeSubmit){
					frm.onsubmit=function(){ options.beforeSubmit(); ui.submitForm(cb); return false; }
				} else {
					frm.onsubmit=function(){ ui.submitForm(cb); return false; }
				}
			}
			return lbl;
		},
		buttonField:function(field,parent){
			let lbl=document.createElement("label");
			let b=document.createElement("input");
			if(field.id){ b.id=field.id; }
			b.type="button";
			b.value=field.label;
			b.onclick=field.onclick;
			lbl.appendChild(b);
			if(field.helpText){
				ui.addSuppliedHelpText(lbl, field.helpText);
			}
			if(null!=parent){ 
				parent.appendChild(lbl);
				if(lbl.closest("form")){
					lbl.closest("form").classList.add("suppressautoupdate");
				}
			}
			return lbl;
		},
		submitButton:function(field,parent){
			let lbl=document.createElement("label");
			let b=document.createElement("input");
			if(field.id){ b.id=field.id; }
			b.type="submit";
			b.value=field.label;
			lbl.appendChild(b);
			if(null!=parent){ 
				parent.appendChild(lbl);
				lbl.closest("form").classList.add("suppressautoupdate");
			}
			return lbl;
		},
		passwordField:function(field,parent){
			field.type="password";
			return ui.textField(field,parent);
		},
		hiddenField:function(fieldName,fieldValue,parent){
			let f=document.createElement("input");
			f.type="hidden";
			f.name=fieldName;
			f.id=fieldName;
			f.value=fieldValue;
			if(null!=parent){
				parent.appendChild(f);
			}
			return f;
		},
		sectionHeading:function(headingText,parent){
			let lbl=document.createElement("label");
			let h3=document.createElement("h3");
			h3.innerHTML=headingText;
			lbl.appendChild(h3);
			if(null!=parent){
				parent.appendChild(lbl);
			}
			return lbl;
		},

		
		/**
		 * Writes a text input field with its label.
		 * Parameters for field:
		 * label The user-friendly ame for the field
		 * name The form field name that goes to the server
		 * value The default value if the field. If not specified, and this is a View page for a record, defaults to the value of field.name for that record.
		 * readonly Whether the field can be edited. If not specified, inherits from parent.
		 */
		textField:function(field,parent){
			let lbl=ui.formField(field,parent);
			if(undefined!==field.readOnly){ field.readonly=field.readOnly; }
			if(undefined===field.readonly && null!=parent && undefined!==parent.dataset.readonly){ field.readonly=parseInt(parent.dataset.readonly); }
			if(!field.value && data && data[field.name]!==null){ field.value=data[field.name]; }
			if(!field.value){ field.value=""; }
			if(field.readonly){
				if(""===field.value||!field.value){ field.value="&nbsp;"; }
				lbl.innerHTML+=field.value;
			} else {
				let tb=ui.textBox(field, lbl);
				if(field["validations"]){
					tb["validations"]=field["validations"];
				}
			}
			if(field.helpText){
				ui.addSuppliedHelpText(lbl,field.helpText);
			} else {
				ui.addHelpText(lbl,field.name);
			}
			if(null!=parent){
				parent.appendChild(lbl);
			}
			return lbl;
		},

		textBox:function(field,parent){
			if(undefined!==field.readOnly){ field.readonly=field.readOnly; }
			if(undefined===field.readonly && null!=parent && undefined!==parent.dataset.readonly){ field.readonly=parseInt(parent.dataset.readonly); }
			if(!field.value && data && data[field.name]!==null){ field.value=data[field.name]; }
			if(!field.value){ field.value=""; }
			let tb;
			if(field.readonly){
				if(""===field.value||!field.value){ field.value="&nbsp;"; }
				tb=document.createElement("span");
				tb.innerHTML=ui.decodeHtmlEntities(field.value);
			} else {
				tb=document.createElement("input");
				tb.type="text";
				if(field.type){ tb.type=field.type; }
				tb.name=field.name;
				if(field.id){ 
					tb.id=field.id;
				} else {
					tb.id=tb.name;
				}
				tb.value=ui.decodeHtmlEntities(field.value);
				tb.dataset.oldValue=ui.decodeHtmlEntities(field.value);
				if(field.apiUrl){
					tb.dataset.apiurl=field.apiUrl;
				}
				if(!field["suppressAutoUpdate"]){
					tb.addEventListener("keyup",function(){ ui.updateFormField(tb); });
					tb.addEventListener("click",function(){ ui.updateFormField(tb); });
					tb.addEventListener("blur",function(){ ui.doUpdateFormField(tb); });
				}
			}
			if(null!=parent){
				parent.appendChild(tb);
			}
			return tb;
		},

		textArea:function(field,parent){
			let lbl=ui.formField(field,parent);
			if(undefined!==field.readOnly){ field.readonly=field.readOnly; }
			if(undefined===field.readonly && null!=parent && undefined!==parent.dataset.readonly){ field.readonly=parseInt(parent.dataset.readonly); }
			if(!field.value && data && undefined!==data[field.name]){ field.value=data[field.name]; }
			if(!field.value){ field.value=""; }
			if(field.readonly){
				if(""===field.value||!field.value){ field.value="&nbsp;"; }
				lbl.innerHTML+=field.value;
			} else {
				let tb=document.createElement("textarea");
				tb.name=field.name;
				if(field.id){ 
					tb.id=field.id;
				} else {
					tb.id=tb.name;
				}
				if(!field.value){ field.value=""; }
				tb.innerHTML=ui.decodeHtmlEntities(field.value);
				tb.dataset.oldValue=ui.decodeHtmlEntities(field.value);
				lbl.appendChild(tb);
				if(field.helpText){
					ui.addSuppliedHelpText(lbl,field.helpText);
				} else {
					ui.addHelpText(lbl,field.name);
				}
				lbl.querySelector("textarea").addEventListener("keyup",function(){ ui.updateFormField(lbl.querySelector("textarea")); });
				lbl.querySelector("textarea").addEventListener("click",function(){ ui.updateFormField(lbl.querySelector("textarea")); });
				lbl.querySelector("textarea").addEventListener("blur",function(){ ui.doUpdateFormField(lbl.querySelector("textarea")); });
			}
			if(null!=parent){
				parent.appendChild(lbl);
			}
			return lbl;
		},
		
		radioButtons:function(field,parent){
			if(undefined!==field.readOnly){ field.readonly=field.readOnly; }
			if(undefined===field.readonly && null!=parent && undefined!==parent.dataset.readonly){ field.readonly=parseInt(parent.dataset.readonly); }
			if(!field.defaultValue && data && undefined!==data[field.name]){ field.defaultValue=data[field.name]; }
			if(!field.defaultValue){ field.defaultValue=""; }
			let lbl=ui.formField(field,parent);
			if(field.readonly){
				let s=document.createElement("span");
				field.options.forEach(function(o){
					if(o.value===field.value){
						s.innerHTML+=field.label;
					}
				});
				lbl.appendChild(s);
			} else {
				field.options.forEach(function(o){
					let l=document.createElement("label");
					l.classList.add("radiolabel");
					let b=document.createElement("input");
					b.type="radio";
					b.name=field.name;
					b.value=o.value;
					l.appendChild(b);
					lbl.appendChild(l);
					l.innerHTML+=o.label;
				});
				lbl.innerHTML+='<div class="shim">&nbsp;</div>';
				if(null!=parent){
					parent.appendChild(lbl);
				}
				lbl.querySelectorAll("label").forEach(function(l){
					l.addEventListener("click", ui.onRadioSelect);
					l.querySelector("input").addEventListener("click", ui.onRadioSelect);
				});
				window.setTimeout(function(){
					lbl.querySelector("input[value="+field.defaultValue+"]").click();
				},50);
			}
			return lbl;
		},
		onRadioSelect:function(evt){
			let elem=evt.target;
			if(elem.closest("label.radiolabel")){ elem=elem.closest("label.radiolabel"); }
			elem=elem.closest("label");
			ui.doRadioSelect(elem);
		},
		doRadioSelect:function(fieldLabelElement){
			fieldLabelElement.querySelectorAll("label.radiolabel").forEach(function(lbl){
				if(lbl.querySelector("input").checked){
					lbl.classList.add("selected");
				} else {
					lbl.classList.remove("selected");
				}
			})
			//TODO autosubmit
		},
		
		
		/**
		 * Returns aSELECT element with its label.
		 *
		 * @param field Object Describes the field. Parameters include:
		 * @param parent Element|null  An HTML element into which the SELECT should be inserted.
		 * @see ui.dropdownElement for parameters in field.
		 */
		dropdown:function(field,parent){
			if(undefined!==field.readOnly){ field.readonly=field.readOnly; }
			if(undefined===field.readonly && null!=parent && undefined!==parent.dataset.readonly){ field.readonly=parseInt(parent.dataset.readonly); }
			if(!field.value && data && undefined!==data[field.name]){ field.value=data[field.name]; }
			if(!field.value){ field.value=""; }
			let lbl=ui.formField(field,parent);
			ui.dropdownElement(field,lbl);
			ui.addHelpText(lbl,field.name);
			if(null!=parent){
				parent.appendChild(lbl);
			}
			return lbl;
		},
		/**
		 * Returns an unwrapped SELECT element for insertion into other elements. 
		 * 
		 * @param {Object} field Describes the field. Parameters include:
		 * 			- name Required. The name that will be sent to the server, e.g., an object property name.
		 * 			- options Required. An array of objects containing at minimum "value" and "label" properties.
		 * 			- readonly Optional. If specified, overrides the readonly attribute of any parent form. Default false.
		 * 			- value Optional. The value that should be pre-selected.
		 * 			- id Optional An HTML ID for the SELECT element.
		 * 			- apiUrl Optional If specified, the URL to which any updates should be submitted.
		 * 			- showOptionLabelOnReadOnly Optional if specified and true, the text shown in the read-only case will be the "label", not the "value"
		 * @param {Element} parent An HTML element into which the SELECT should be inserted.
		 */
		dropdownElement:function(field,parent){
			if(undefined!==field.readOnly){ field.readonly=field.readOnly; }
			if(undefined===field.readonly && null!=parent && parent["dataset"] && undefined!==parent["dataset"].readonly){
				field.readonly=parseInt(parent["dataset"].readonly);
			}
			if(!field.value && data && undefined!==data[field.name]){ field.value=data[field.name]; }
			if(!field.value){ field.value=""; }
			let s;
			if(field.readonly){
				s=document.createElement("span");
				if(!field["showOptionLabelOnReadOnly"]){
					s.innerHTML+=field.value;
				} else {
					for(let i=0;i<field["options"].length;i++){
						let o=field["options"][i];
						if(o["value"]===field["value"]){
							s.innerHTML+=o["label"];
							break;
						}
					}
					if(""===s.innerHTML){
						s.innerHTML="&nbsp;";
					}
				}
			} else {
				s=document.createElement("select");
				s.name=field.name;
				s.dataset.oldValue=field.value;
				if(field.id){ 
					s.id=field.id;
				} else {
					s.id=s.name;
				}
				field.options.forEach(function(o){
					let opt=document.createElement("option");
					opt.value=o.value;
					if(o.value+""===field.value+"") {	opt.selected=true; }
					opt.innerHTML=o.label;
					s.appendChild(opt);
				});
				if(field.apiUrl){
					s.dataset.apiurl=field.apiUrl;
				}
				s.addEventListener("change",function(){ ui.updateFormField(s); });
				s.addEventListener("blur",function(){ ui.doUpdateFormField(s); });
			}
			if(null!=parent){
				parent.appendChild(s);
			}
			return s;
		},

		/**
		 * Returns a checkbox field. If parent specified, also appends the checkbox field to the parent.
		 * @param {Object} field A description of the form field. Parameters can include:
		 * 						- readonly Whether the field is read-only. Default is parent form's readonly setting.
		 * 						- name The name to be submitted to the server
		 * 						- label The user-friendly label text for the form field.
		 * 						- handler An optional function to handle changes in state. Function receives the hidden input in the label element as a parameter.
		 * @param {Element} parent The parent element (usually a form)
		 */
		checkbox:function(field,parent){
			if(undefined!==field.readOnly){ field.readonly=field.readOnly; }
			if(undefined===field.readonly && null!=parent && undefined!==parent["dataset"].readonly){ field.readonly=parseInt(parent["dataset"].readonly); }
			if(!field.value && data && undefined!==data[field.name]){ field.value=data[field.name]; }
			if(!field.value){ field.value="0"; }
			let lbl=ui.formField(field,parent);


			let img=document.createElement("img");
			let checked="no";
			let prefix="";
			if(field.checked){
				checked="yes"; field.value=1;
			}
			if(field.value && 1===parseInt(field.value)){ checked="yes"; }
			if(!field.readonly){ prefix="btn_"; }
			img.src="/images/icons/"+skin["bodyIconTheme"]+"icons/"+prefix+checked+".gif";
			img.style.marginBottom="0";
			img.style.lineHeight="1em";
			lbl.appendChild(img);
			if(!field.readonly){
				let cb=document.createElement("input");
				cb.type="hidden";
				cb.name=field.name;
				if(field.value && 1===1*field.value){
					cb.value="1";
				} else {
					cb.value="0";
				}
				if(field.readonly){ cb.disabled=true; }
				if(field.handler){
					lbl.handler=field.handler;
				} else {
					lbl.handler=ui.updateFormField;
				}
				lbl.appendChild(cb);

				img.addEventListener("click",function(evt){
					let img=evt.target;
					let lbl=img.closest("label");
					let inp=lbl.querySelector("input");
					if(img.src.indexOf("no.gif")>0){
						img.src=img.src.replace("no.gif","yes.gif");
						inp.value="1";
					} else {
						img.src=img.src.replace("yes.gif","no.gif");
						inp.value="0";
					}
					lbl.handler(inp);
				});
			}
			
			if(field.helpText){
				ui.addSuppliedHelpText(lbl,field.helpText);
			} else {
				ui.addHelpText(lbl,field.name);
			}
			if(null!=parent){
				parent.appendChild(lbl);
			}
			return lbl;
		},

		colorField:function(field, parent){
			if(!field){ field={}; }
			if(!field["name"]){ field["name"]="color"; }
			if(!field["label"]){ field["label"]="Colour"; }
			if(!field["value"]){
				field["value"]="ffffff";
				if(data[field["name"]]){
					field["value"]=data[field["name"]];
				}
			}
			let r=parseInt(field["value"].substring(0,2), 16);
			let g=parseInt(field["value"].substring(2,4), 16);
			let b=parseInt(field["value"].substring(4,6), 16);

			let ff=ui.formField(field,parent);
			ff.innerHTML+='<input type="hidden" name="'+field['name']+'" value="'+field['value']+'" />';
			let swatch=document.createElement("div");
			swatch.className='swatch';
			swatch.style.width="4em";
			swatch.style.height="5em";
			swatch.style.display="inline-block";
			swatch.style.backgroundColor="#"+field["value"];
			swatch.style.cssFloat="right";
			swatch.style.margin="0.5em 0 0 1em";
			swatch.style.opacity="1";
			ff.appendChild(swatch);
			ff.innerHTML+='R: <input type="range" min="0" max="255" value="'+r+'" step="1" class="slider" onchange="ui.updateColorField(this)"><br/>';
			ff.innerHTML+='G: <input type="range" min="0" max="255" value="'+g+'" step="1" class="slider" onchange="ui.updateColorField(this)"><br/>';
			ff.innerHTML+='B: <input type="range" min="0" max="255" value="'+b+'" step="1" class="slider" onchange="ui.updateColorField(this)">';
			if(field.helpText){
				ui.addSuppliedHelpText(ff,field.helpText);
			} else {
				ui.addHelpText(ff,field.name);
			}
		},
		updateColorField:function(slider){
			let field=slider.closest("label");
			let sliders=field.querySelectorAll("input[type=range]");
			let color=parseInt(sliders[0].value).toString(16).padStart(2,"0")+
				parseInt(sliders[1].value).toString(16).padStart(2,"0")+
				parseInt(sliders[2].value).toString(16).padStart(2,"0");
			field.querySelector(".swatch").style.backgroundColor="#"+color;
			let hiddenField=field.querySelector("input[type=hidden]");
			hiddenField.value=color;
			ui.updateFormField(hiddenField);
		},

		STAR_EMPTY:"☆",
		STAR_FILLED:"★",

		/**
		 * Creates a star rating field, attaching it to the parent if specified.
		 * In the simple case of adding this to a create/update form on a view page, frm.starRatingField() is enough.
		 * @param field {Object} Details of the field. See ui.starRatingElement for options.
		 * @param parent {Element} The parent element of the star rating field (usually a form)
		 * @returns {HTMLDivElement}
		 */
		starRatingField:function (field,parent){
			if(!field){ field={}; }
			if(!field["label"]){ field["label"]="Rating"; }
			let ff=ui.formField(field,parent);
			ff.id=null;
			return ui.starRatingElement(field, ff);
		},

		/**
		 * Returns the HTML for a star rating element. This is useful for writing a star rating into a table cell using
		 * ui.table, specifying the relevant cellTemplates element as [ui.starRatingCellContents,"starrating"].
		 * @param obj An object containing a "starrating" property (for example, a crystal)
		 * @returns {string} the HTML of a read-only star rating.
		 */
		starRatingCellContents:function(obj){
			let field={
				readonly:(!isAdmin && !userUpdateProjects.includes(obj["projectid"]) && !userUpdateProjects.includes(parseInt(obj["projectid"]))) || (parseInt(""+obj['isarchived'])),
				value:obj["starrating"]
			}
			return ui.starRatingElement(field, null);
		},

		/**
		 * Generates and returns a star rating element, attaching it to the parent element if supplied.
		 * Optional parameters in field:
		 * - readonly:	true|false If not specified, this will use (1) a read-only attribute on the parent element's form,
		 * 				(2) a canEdit in the page header, (3) true.
		 * - name:		The name of the underlying form field. If not specified, defaults to "starrating".
		 * - value:		int,0-5 The number of stars to show initially. If not specified, this will use (1) a "starrating"
		 * 				attribute in a parent tr's rowData, (2) a "starrating" in the "data" object in the page header,
		 * 				(3) zero.
		 * 	- afterSuccess: A function, taking the star rating element as a parameter, which will be called after the
		 * 				star rating is successfully updated.
		 * @param field
		 * @param parent
		 * @returns {HTMLDivElement}
		 */
		starRatingElement:function (field,parent){
			if(!field){ field={}; }
			let readOnly=!canEdit;
			if(undefined!==field["readonly"]){
				readOnly=field["readonly"];
			} else if(parent && parent.closest("form") && "1"===parent.closest("form").dataset.readonly){
				readOnly=true;
			}
			if(!field["name"]){ field["name"]="starrating"; }
			if(!field["value"]){
				if(0===field["value"]){
					field["value"]=0;
				} else if(parent && parent.closest("tr") && parent.closest("tr").rowData  && parent.closest("tr").rowData["starrating"]){
					field["value"]=parseInt(parent.closest("tr").rowData["starrating"]);
				} else if(data && data["starrating"]){
					field["value"]=parseInt(data["starrating"]);
				} else {
					field["value"]=0;
				}
			}
			let div=document.createElement("div");
			if(field["afterSuccess"]){
				div.afterSuccess=field["afterSuccess"];
			}
			if(field["apiUrl"]){
				div.dataset.apiUrl = field["apiUrl"];
			}
			div.classList.add("starrating");
			let inp=document.createElement("input");
			inp.type="hidden";
			inp.name=field["name"];
			inp.value=field["value"];
			div.dataset.showValue=field["value"];
			inp.dataset.oldvalue="9999"; //will always be different from clicked value, so will always update
			div.appendChild(inp);
			if(readOnly){
				div.classList.add("readonly");
			} else {
				div.addEventListener("mouseout",ui.starRatingMouseout);
			}
			for(let i=1;i<=5;i++){
				let span=document.createElement("span");
				span.dataset.stars=i+"";
				span.innerHTML=ui.STAR_EMPTY;
				if(!readOnly){
					span.addEventListener("mouseover",ui.starRatingMouseover);
					span.addEventListener("click",ui.starRatingClick);
				}
				div.appendChild(span);
			}
			if(parent){ parent.appendChild(div); }
			ui.starRatingShowValue(div);
			return div;
		},

		starRatingShowValue:function (starRatingDiv){
			starRatingDiv=starRatingDiv.closest(".starrating");
			starRatingDiv.querySelectorAll("span").forEach(function(span){
				if(1*span.dataset.stars>1*starRatingDiv.dataset.showValue){
					span.innerHTML=ui.STAR_EMPTY;
				} else {
					span.innerHTML=ui.STAR_FILLED;
				}
			});
		},

		starRatingMouseover:function (evt){
			let span=evt.target;
			let div=span.closest(".starrating");
			if(div.classList.contains("readonly")){ return; }
			div.dataset.showValue=span.dataset.stars;
			ui.starRatingShowValue(div);
		},

		starRatingMouseout:function (evt){
			let div=evt.target.closest(".starrating");
			div.dataset.showValue=div.querySelector("input").value;
			ui.starRatingShowValue(div);
		},

		starRatingClick:function (evt) {
			evt.stopPropagation();
			let span = evt.target;
			let div = span.closest(".starrating");
			if (div.classList.contains("readonly")) {
				return;
			}
			ui.starRatingShowValue(div);
			let inp = div.querySelector("input");
			let lbl = div.closest("label");
			let frm = div.closest("form");
			let td = div.closest("td,th");
			let tr = div.closest("tr");
			let apiUrl = document.location.href;
			if (div.dataset.apiUrl) {
				apiUrl = div.dataset.apiUrl;
			} else if (frm && !frm.classList.contains("suppressautoupdate")) {
				//auto-updating form, e.g., on a view page
				apiUrl = frm.action;
			} else if (frm) {
				//non-auto-updating form, e.g., on a create page
				inp.value = span.dataset.stars;
				return false;
			} else if (tr && tr.rowData) {
				//In a data table row
				if (tr.rowData['objecttype']) {
					apiUrl = "/api/" + tr.rowData["objecttype"] + "/" + tr.rowData["id"];
				} else {
					let tbl = td.closest("table");
					if (tbl.dataset.otherType) {
						apiUrl = "/api/" + tbl.dataset.otherType + "/" + tr.rowData["id"];
					} else {
						return false;
					}
				}
			} else {
				return false;
			}
			if(lbl){ lbl.classList.add("updating"); }
			if(td){ td.classList.add("updating"); }
			div.dataset.showValue = span.dataset.stars;
			div.dataset.oldValue = inp.value;
			inp.value = span.dataset.stars;
			new AjaxUtils.Request(apiUrl, {
				method: "patch",
				parameters: {
					"csrfToken": csrfToken,
					"starrating": inp.value
				},
				onSuccess: function () {
					window.setTimeout(function(){
						div.closest(".updating").classList.remove("updating");
					},500);
					//Special handling for star rating in dataset modal tabs, update table behind.
					let ts = div.closest(".tabset");
					if (ts && ts.dataRow) {
						let newValue = div.querySelector("input").value;
						ts.dataRow.rowData.starrating = newValue;
						let rowStars = ts.dataRow.querySelector(".starrating");
						rowStars.querySelector("input").value = newValue;
						rowStars.dataset.showValue = newValue;
						ui.starRatingShowValue(rowStars);
					}
					if (div.afterSuccess) {
						div.afterSuccess(div);
					}
				},
				onFailure: function () {
					window.setTimeout(function(){
						div.closest(".updating").classList.remove("updating");
					},500);
					alert("Could not update the star rating.")
				},
			});
		},

		/**
		 * @param field Details of the field.
		 * @param parent The parent element, typically a form.
		 * @returns HTMLElement The label element surrounding the form field
		 */
		fileField:function(field,parent){
			let lbl=ui.formField(field,parent);
			let ff=document.createElement("input");
			ff.type="file";
			ff.name=field.name;
			lbl.appendChild(ff);
			if(null!=parent){
				parent.appendChild(lbl);
				lbl.closest("form").enctype="multipart/form-data";
			}
			return lbl;
		},

		audioField:function(field,parent){
			if(!field){ field={}; }
			let lbl=ui.formField(field,parent);
			if(null!=parent){
				parent.appendChild(lbl);
			}
			AudioRecording.init(lbl,field);
			return lbl;
		},

		roleField:function(field,parent){
			//	frm.roleField({ label:"Owner", name:'owner', parentObject:data, otherType:'user', idField:'owner', labelField:'ownername', readonly:(!isAdmin &&!isOwner) });
			if(undefined!==field.readOnly){ field.readonly=field.readOnly; }
			if(undefined===field.readonly && null!=parent && undefined!==parent.dataset.readonly){ field.readonly=parseInt(parent.dataset.readonly); }
			if(undefined===field["nullable"]){
				field["nullable"]=false;
			}
			if(!field.value && data && undefined!==data[field.name]){ field.value=data[field.name]; }
			if(!field.value){ field.value=""; }
			let lbl=ui.formField(field,parent);
			let hi=document.createElement("input");
			hi.type="hidden";
			hi.name=field.name;
			hi.value=field.otherId;
			hi.dataset.oldValue=field.otherId;
			lbl.appendChild(hi);
			let s=document.createElement("span");
			s.classList.add("roleSpan");
			lbl.appendChild(s);
			if(field.otherId){
				let a=document.createElement("a");
				a.href='/'+field.otherType+'/'+field.otherId;
				a.innerHTML=field.otherName;
				a.classList.add("role");
				a.dataset.otherId=field.otherId;
				a.dataset.otherName=field.otherName;
				s.appendChild(a);
			} else {
				s.innerHTML="None set";
			}
			if(!field.readonly){
				let b=document.createElement("input");
				b.type="button";
				b.id="rolebutton_"+field.name;
				b.value="Change...";
				b.style.marginLeft="0.5em";
				b.onclick=function(){ 
					document.querySelectorAll(".currentrolefield").forEach(function(elem){ elem.classList.remove("currentrolefield") });
					b.closest("label").classList.add("currentrolefield");
					ui.findRole(field); 
				};
				lbl.appendChild(b);
			}
			ui.addHelpText(lbl,field.name);
			if(null!=parent){
				parent.appendChild(lbl);
			}
			lbl.fieldData=field;
			return lbl;
		},
		
		findRole:function(field){
			let parentRole=document.body.querySelector(".currentrolefield");
			let link=parentRole.querySelector("a.role");
			field.otherName="";
			field.otherId="";
			if(link){
				field.otherName=link.dataset.otherName;
				field.otherId=link.dataset.otherId;
			}
			field.roleName=parentRole.querySelector("span.label").innerHTML;

			let headers=field.headers.slice();
			let filters=[];
			for(let i=0;i<headers.length;i++){
				filters.push(1);
			}
			let cells=field.cellTemplates.slice();

			filters.unshift(0);
			headers.unshift('&nbsp;');
			cells.unshift('<input type="button" data-itemname="'+ui.encodeHTMLEntities(field.cellTemplates[0])+'" value="Choose" onclick="ui.setRole(this)" />');
			let constraint="";
			if(field.constraint){
				let k=(Object.keys(field.constraint))[0];
				let v=field.constraint[k];
				constraint="/"+k+"/"+v;
			}
			if("project"===field.otherType){
				constraint+="/isarchived/0";
			}
			let mb=ui.modalBox({
				'url':'/api/'+field.otherType+constraint,
				'title':'Find '+field.otherType+'s',
				'headers':headers,
				'cellTemplates':cells,
				'showFilters':filters
			});
			ui.setRoleButtonsColumnWidth();
			ui.writeRoleClearButton(field);
		},

		writeRoleClearButton:function (field){
			if(!field["nullable"] ){ return; }
			let parentRole=document.body.querySelector(".currentrolefield");
			let link=parentRole.querySelector(".role");
			if(!link || !link.dataset.otherId){ return; }
			let mb=document.getElementById("modalBox");
			let tbl=mb.querySelector("table");
			if(!tbl){
				window.setTimeout(ui.writeRoleClearButton, 100, field);
				return;
			}
			let tr=tbl.querySelector(".datarow");
			let newTr=tr.cloneNode(true);
			newTr.insertAdjacentElement('beforebegin', tr);
			newTr.rowData= { "id":nullValue };
			newTr.querySelector("td").innerHTML="&nbsp;";
			let td2=newTr.querySelector("td+td");
			td2.innerHTML='<span style="float:left;margin-right: 0.5em;">'+field.roleName+" is "+field.otherName+' </span><input type="button" style="float:left; margin-right: 0.5em" value="Remove" onclick="ui.setRole(this)" />';
			tr.insertAdjacentElement('beforebegin', newTr);
		},

		setRoleButtonsColumnWidth:function(){
			let mb=document.getElementById("modalBox");
			if(!mb || !mb.querySelector("th")){
				return window.setTimeout(ui.setRoleButtonsColumnWidth, 50);
			}
			mb.querySelector("th").style.width="6em";
		},
		
		setRole:function(btn){
			let parentRole=document.body.querySelector(".currentrolefield");
			let newRole=btn.closest("tr").rowData;
			if(!!data && parentRole.fieldData["otherType"]===data["objecttype"] && newRole["id"]===data["id"]){
				let msg=data["name"]+" cannot be ";
				if("user"===data["objecttype"]){
					msg+="their";
				} else {
					msg+="its";
				}
				msg+=" own "+parentRole.fieldData["roleName"].toLowerCase()+".";
				alert(msg);
				btn.closest("tr").remove();
				return;
			}
			parentRole.classList.remove("currentrolefield");
			let s=parentRole.querySelector("span.roleSpan");
			let nameField=parentRole.fieldData.labelField || 'name';
			let idField='id';
			if(nullValue===newRole['id']){
				s.innerHTML="None set";
			} else {
				let a=document.createElement("a");
				a.classList.add("role");
				a.innerHTML=newRole[nameField];
				a.dataset.otherName=newRole[nameField];
				a.dataset.otherId=newRole[idField];
				a.href='/'+parentRole.fieldData.otherType+'/'+newRole[idField];
				s.innerHTML="";
				s.appendChild(a);
			}
			ui.closeModalBox();
			let hi=parentRole.querySelector("input");
			hi.value=newRole[idField];
			parentRole.roleRecord=newRole;
			ui.updateFormField(hi);
		},

		updateFormField:function(field){
			window.clearTimeout(submitTimer);
			if(field.tagName==="input" && ("hidden"===field.type ||"text"===field.type || "password"===field.type) && !validator.validate(field)){
				return false;
			}
			if(field.closest("label,td")){ field.closest("label,td").classList.remove("invalidfield"); }
			if(field.closest(".suppressautoupdate") && !field.dataset.apiurl){ return false; }
			window.submitTimer=setTimeout(function(){ ui.doUpdateFormField(field) },1000);
		},
		
		doUpdateFormField:function(field){
			if(field.dataset.oldValue===field.value){ return false; }
			if(field.closest(".suppressautoupdate") && !field.dataset.apiurl){ return false; }
			if(field.closest("label,td")){ field.closest("label,td").classList.add("updating"); }
			let body;
			let apiUrl='';
			if(field.dataset.apiurl){
				apiUrl=field.dataset.apiurl;
			} else if(field.closest("form")){
				apiUrl=field.closest("form").action;
			}
			if(field.type && field.type==="checkbox"){
				body=field.name+"="+(field.checked ? "1" : "0")+"&csrfToken="+csrfToken;
				field.dataset.oldValue=field.checked ? "0" : "1";
			} else {
				field.dataset.oldValue=field.value;
				body=field.name+"="+encodeURIComponent(field.value)+"&csrfToken="+csrfToken
			}
			window.setTimeout(function(){
				new AjaxUtils.Request(apiUrl,{
					method:'patch',
					postBody:body,
					onSuccess:function(transport){ ui.updateFormField_onSuccess(transport,field) },
					onFailure:function(transport){ ui.updateFormField_onFailure(transport,field) }
				});
			},250);
		},
		
		updateFormField_onSuccess:function(transport,field){
			field.closest("label,td").classList.remove('updating');
			field.dataset.oldValue=field.value;
			let afterUpdate=field.closest("label,td").afterUpdate;
			if(field.closest("label")){
				field.closest("label").classList.remove('invalidfield');
			} else {
				field.classList.remove('invalidfield');
			}
			if(afterUpdate){ afterUpdate(field); }
		},
		updateFormField_onFailure:function(transport,field){
			field.closest("label,td").classList.remove('updating');
			if(401===transport.status){
            	ui.handleSessionExpired();
            	return;
            }
			if(field.closest("label")){
				field.closest("label").classList.add('invalidfield');
			} else {
				field.classList.add('invalidfield');
			}
			if(transport.responseJSON && transport.responseJSON.error){
				alert(transport.responseJSON.error);
			} else {
				alert(transport.responseText);
			}
		},
		
		submitForm:function(submitButton){
			let frm=submitButton.closest("form");
			if(frm.querySelector(".invalidfield")){ return false; }
			let fields=frm.querySelectorAll("input[type=hidden], input[type=text], input[type=password], input[type=file], select, textarea");
			let isValid=true;
			let formData=new FormData();
			fields.forEach(function(f){
				if(!validator.validate(f)){
					isValid=false;
				} else if("file"===f.type){
					formData.append(f.name, f.files[0], f.files[0].name)
				} else {
					formData.append(f.name, f.value);
				}
			});
			if(!isValid){return false; }
			submitButton.closest("label,tr,div").classList.add("updating");
			let xhr=new XMLHttpRequest();
			let method=frm.method;
			if(frm.dataset.ajaxmethod){ method=frm.dataset.ajaxmethod; }
	        xhr.open(method, frm.action, true);
	        xhr.onload=function(){
	        	try{
	        		xhr.responseJSON=JSON.parse(xhr.responseText);
	        	} catch(ex) {
	        		//just eat it
	        	}
	            if(200===xhr.status || 201===xhr.status){
	                ui.submitForm_onSuccess(xhr,submitButton);
	            } else if(401===xhr.status){
	            	ui.handleSessionExpired();
	            } else {
	                ui.submitForm_onFailure(xhr,submitButton);
	            }
	        };
			xhr.send(formData);
		},
		submitForm_onSuccess:function(transport,submitButton){
			if(!transport.responseJSON || transport.responseJSON.error){
				return ui.submitForm_onFailure(transport,submitButton);
			}
			submitButton.closest("label,tr,div").classList.remove("updating");
			if(transport.responseJSON.created){
				if(submitButton.options && submitButton.options.afterSuccess){
					return submitButton.options.afterSuccess(transport.responseJSON);
				}
				let loc='/'+transport.responseJSON.type+'/'+transport.responseJSON.created.id;
				if(submitButton.dataset.tabOnViewPage){
					loc+='#'+submitButton.dataset.tabOnViewPage;
				}
				window.location=loc;
			}
		},
		submitForm_onFailure:function(transport,submitButton){
			submitButton.closest("label,tr,div").classList.remove("updating");
			if(transport.responseJSON && transport.responseJSON.error){
				alert(transport.responseJSON.error);
			} else {
				alert(transport.responseText);
			}
		},
		
		checkmark:function(obj, field){
			let str=obj[field];
			str=str.toLowerCase();
			if("1"===str || "yes"===str || "true"===str){
				return '<img alt="Yes" '+'src="/images/icons/yes.gif" />'; //break to kill "cannot resolve" warning
			}
			return '&nbsp;';
		},
		
		/** 
		 * Writes a date picker field with its label, with the time fields enabled. 
		 * @see datePicker for documentation.
		 */
		dateTimePicker:function(field, parent){
			field['showTime']=true;
			return ui.datePicker(field, parent);
		},
		dateField:function(field,parent){
			return ui.datePicker(field, parent);
		},
		dateTimeField:function(field,parent){
			return ui.dateTimePicker(field, parent);
		},
		
		/**
		 * Writes a date picker field with its label. 
		 * Sets and sends a GMT date string (YYYY-MM-DD HH:MM:SS), but displays in LOCAL time.
		 * 
		 * Common parameters for field:
		 * 
		 * label The user-friendly mame for the field
		 * name The form field name that goes to the server
		 * value The default value of the field. If not specified, and this is a View page for a record, defaults to the value of field.name for that record.
		 * readonly Whether the field can be edited. If not specified, inherits from parent.
		 * 
		 * Date picker parameters:
		 * 
		 * showTime: if true, hour and minute dropdowns will be shown under the calendar. Seconds will be unchanged.
		 * minuteStep: Show only minutes divisible by minuteStep. If undefined, same as 1 (all minutes).
		 *             Example: if 5, minutes will be 00, 05 ... 50, 55.
		 *             If the existing value does not correspond to one of those shown, the next lowest will be selected.
		 */
		datePicker:function(field, parent){
			let isCreate=false;
			if(null!==parent && parent.closest("form")){
				if("post"===parent.closest("form").dataset.ajaxmethod.toLowerCase()){
					isCreate=true; //otherwise it would be "patch"
				}
			}
			let now=new Date();
			let offsetMins=now.getTimezoneOffset();
			let lbl=ui.formField(field,parent);
			if(undefined!==field.readOnly){ field.readonly=field.readOnly; }
			if(undefined===field.readonly && null!=parent && undefined!==parent.dataset.readonly){ field.readonly=parseInt(parent.dataset.readonly); }
			if(!field.value && data && data[field.name]!==null){ field.value=data[field.name]; }
			let displayValue="(not set)";
			if(!field.value){
				field.value=now.toISOString().replace("T"," ").substring(0,19);
				if(isCreate){
					displayValue=ui.friendlyDate(field.value);
				}
			} else {
				displayValue=ui.friendlyDate(field.value);
			}
			if(field.readonly){
				lbl.innerHTML+='<span class="datepickerfriendlydate">'+displayValue+'</span>';
			} else {
				lbl.dataset.gmtOffset=""+offsetMins;
				lbl.dpDate=new Date(field.value);
				lbl.selectedDate=new Date(field.value);
				lbl.dpDate.setMinutes(lbl.dpDate.getMinutes()-offsetMins); //Correct from GMT to local
				lbl.selectedDate.setMinutes(lbl.dpDate.getMinutes()-offsetMins); //Correct from GMT to local for display
				let dp='<input type="hidden" name="'+field.name+'" id="'+field.name+'" value="'+field.value+'" />';
				dp+='<span class="datepickerfriendlydate">'+displayValue+'</span> <input type="button" value="Change..." onclick="ui.datePickerShow(this);return false"/>';
				dp+='<div class="datepicker" style="display:none;">';
				dp+='<div class="datepickercalendar" style="width:20em">';
				
				dp+='</div>';
				if(field.showTime){
					dp+='<span style="display:inline-block;width:21em;border-top:1px solid #666">';

					if(!field.minuteStep){ field.minuteStep=1; }
					let step=field.minuteStep;
					let selectedHour=lbl.dpDate.getHours();
					let selectedMinute=lbl.dpDate.getMinutes();
					if(1!==step){ selectedMinute=(Math.floor(selectedMinute/step))*step; }
					dp+='Time: <select class="datepickerhour" name="'+field.name+'_hour" onchange="ui.datePickerSet(this)">';
					for(let h=0;h<24;h++){
						let selected='';
						if(h===selectedHour){ selected='selected="selected"'; }
						dp+='<option '+selected+' value="'+(h<10?'0'+h:h)+'">'+(h<10?'0'+h:h)+'</option>';
					}
					dp+='</select> : <select class="datepickerminute" name="'+field.name+'_minute" onchange="ui.datePickerSet(this)">';

					for(let i=0;i<60;i+=step){
						let selected='';
						if(i===selectedMinute){ selected='selected="selected"'; }
						dp+='<option '+selected+' value="'+(i<10?'0'+i:i)+'">'+(i<10?'0'+i:i)+'</option>';
					}
					dp+='</select>';
					dp+='</span>';
				}
				dp+='</div>';
				lbl.innerHTML+=dp;
				ui.datePickerMoveCalendar(lbl,0);
			}
			if(null!=parent){
				parent.appendChild(lbl);
			}
			return lbl;
		},
		
		datePickerShow: function(btn){
			btn.closest("label").querySelector("div.datepicker").style.display="block";
			btn.remove();
		},
		
		datePickerMoveCalendar: function(label, offset){
			if(0!==parseInt(offset)){
				label.dpDate.setMonth(label.dpDate.getMonth()+offset);
			}
			label.querySelector(".datepickercalendar").innerHTML='<input type="button" class="unselected" onclick="ui.datePickerMoveCalendar(this.closest(\'label\'),-1);return false" value="&lt;&lt;" /> ' +
				'<span style="display:inline-block;text-align:center;width:13.1em"><strong>' +
				ui.monthsOfYear[label.dpDate.getMonth()] + ' ' + label.dpDate.getFullYear() +
				'</strong></span>' +
				' <input type="button" class="unselected" onclick="ui.datePickerMoveCalendar(this.closest(\'label\'),1);return false" value="&gt;&gt;" /><br/>';

			let tempDate=new Date();
			tempDate.setFullYear(label.dpDate.getFullYear());
			tempDate.setMonth(label.dpDate.getMonth()+1);
			tempDate.setDate(0);
			let daysInMonth=tempDate.getDate();
			tempDate.setDate(1);
			let dayOfFirst=tempDate.getDay(); //0=Sunday, 6=Sat
			
			let thisMonth=label.selectedDate.getMonth();
			let thisYear=label.selectedDate.getFullYear();
			let thisDay=label.selectedDate.getDate();
			let isThisMonthCalendar=(thisMonth===tempDate.getMonth() && thisYear===tempDate.getFullYear());

			let count=1;
			let dow=0;
			while(count<=dayOfFirst){
				let btn=document.createElement("input");
				btn.type="button";
				btn.value="00";
				btn.classList.add("unselected");
				btn.style.marginLeft='0.25em';
				btn.style.width='2.25em';
				btn.style.opacity="0.3";
				btn.style.color="transparent";
				label.querySelector(".datepickercalendar").appendChild(btn);
				count++;
				dow++;
			}
			count=1;
			while(count<=daysInMonth){
				let btn=document.createElement("input");
				btn.type="button";
				btn.value=(count<10?"0"+count:count);
				if(!isThisMonthCalendar || count!==thisDay){
					btn.classList.add("unselected");
				}
				btn.classList.add("datepickerday");
				btn.style.marginLeft='0.25em';
				btn.style.width='2.25em';
				btn.dataset.year=""+label.dpDate.getFullYear();
				btn.dataset.month=""+label.dpDate.getMonth();
				btn.dataset.day=""+count;
				btn.onclick=function(evt){ let elem=evt.target; ui.datePickerSet(elem); };
				if(0===dow || 6===dow){ btn.classList.add("weekend"); }
				label.querySelector(".datepickercalendar").appendChild(btn);
				count++;
				dow++;
				if(dow%7===0){
					label.querySelector(".datepickercalendar").appendChild(document.createElement("br"));
					dow=0;
				}
			}
			while(dow!==0 && dow<=6){
				let btn=document.createElement("input");
				btn.type="button";
				btn.value="00";
				btn.classList.add("unselected");
				btn.style.opacity="0.3";
				btn.style.color="transparent";
				btn.style.marginLeft='0.25em';
				btn.style.width='2.25em';
				label.querySelector(".datepickercalendar").appendChild(btn);
				count++;
				dow++;
			}
			label.querySelector(".datepickercalendar").style="display:block";
		},
		
		datePickerSet:function(elem){
			let lbl=elem.closest("label");
			let d=lbl.dpDate;
			let s=lbl.selectedDate;
			if(lbl.querySelector(".datepickerminute")){ 
				d.setMinutes(lbl.querySelector(".datepickerminute").value); 
				s.setMinutes(lbl.querySelector(".datepickerminute").value); 
			}
			if(lbl.querySelector(".datepickerhour")){ 
				d.setHours(lbl.querySelector(".datepickerhour").value); 
				s.setHours(lbl.querySelector(".datepickerhour").value); 
			}
			if(elem.classList.contains("datepickerday")){ 
				d.setDate(parseInt(elem.dataset.day));
				d.setMonth(parseInt(elem.dataset.month));
				d.setFullYear(parseInt(elem.dataset.year));
				s.setDate(parseInt(elem.dataset.day));
				s.setMonth(parseInt(elem.dataset.month));
				s.setFullYear(parseInt(elem.dataset.year));
				elem.closest(".datepickercalendar").querySelectorAll("input").forEach(function(b){
					b.classList.add("unselected");
				});
				elem.classList.remove("unselected");
			}
			let utcMonth=s.getUTCMonth()+1;
			let utcDay=s.getUTCDate();
			let utcHours=s.getUTCHours();
			let utcMinutes=s.getUTCMinutes();
			let utcSeconds=s.getUTCSeconds();
			if(utcMonth<10){ utcMonth="0"+utcMonth; }
			if(utcDay<10){ utcDay="0"+utcDay; }
			if(utcHours<10){ utcHours="0"+utcHours; }
			if(utcMinutes<10){ utcMinutes="0"+utcMinutes; }
			if(utcSeconds<10){ utcSeconds="0"+utcSeconds; }
			let utcDateString=s.getUTCFullYear()+"-"+utcMonth+"-"+utcDay+" "+utcHours+":"+utcMinutes+":"+utcSeconds;
			document.getElementById(lbl.htmlFor).value=utcDateString;
			lbl.querySelector(".datepickerfriendlydate").innerHTML=ui.friendlyDate(utcDateString);
			ui.updateFormField(lbl.querySelector("input"));
		},
		
		dateRegex:/^\d\d\d\d-\d\d-\d\d$/,
		dateTimeRegex:/^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d$/,
		friendlyDate:function(dateString){
			if(""===dateString){
				return "-";
			} else if(dateString.match(ui.dateTimeRegex)){
				return ui.friendlyDateTime(dateString);
			} else if(!dateString.match(ui.dateRegex)){
				return dateString;
			}
			let parts=dateString.split("-");
			let now =new Date(); //In local time
			let offsetMins=now.getTimezoneOffset();
			let d= new Date(parts[0], parts[1]-1, parts[2], 0, 0-offsetMins, 0); //A GMT date
			//we always show the time:
			let friendlyDate='';
			//Now set both to 0 and work out the difference, so we know ow to write the day part
			d.setMinutes(0);
			d.setHours(0);
			d.setMilliseconds(0);
			now.setMinutes(0);
			now.setHours(0);
			now.setMilliseconds(0);

			let diffDays=(now.getTime()-d.getTime())/86400000; //milliseconds to days
			if(-3>diffDays){
				if(d.getFullYear()===now.getFullYear()){
					//23 September
					friendlyDate+=d.getDate()+" "+ui.monthsOfYear[d.getMonth()];
				} else {
					//23 Sep 2012
					friendlyDate+=d.getDate()+" "+ui.monthsOfYear[d.getMonth()].substring(0,3)+" "+d.getFullYear();
				}	
			} else if(-1>diffDays){
				//Tuesday, 08:54
				friendlyDate+=ui.daysOfWeek[d.getDay()];
			} else if(0>diffDays){
				friendlyDate="Tomorrow";
			} else if(1>diffDays){
				friendlyDate="Today";
			} else if(2>diffDays){
				friendlyDate="Yesterday";
			} else if(4>diffDays){
				//Tuesday
				friendlyDate=ui.daysOfWeek[d.getDay()];
			} else if(d.getFullYear()===now.getFullYear()){
				//23 September
				friendlyDate=d.getDate()+" "+ui.monthsOfYear[d.getMonth()];
			} else {
				//23 Sep 2012
				friendlyDate=d.getDate()+" "+ui.monthsOfYear[d.getMonth()].substring(0,3)+" "+d.getFullYear();
			}
			return friendlyDate;
		},
		
		/**
		 * Formats a date string from the server as follows:
		 * If today, "hh:mm"
		 * If yesterday, "Yesterday, hh:mm"
		 * If 2 or 3 days ago, "Friday, hh:mm"
		 * If longer ago, "12 Jan 2015, hh:mm"
		 */
		friendlyDateTime:function(dateString){
			if(""===dateString){
				return "-";
			} else if(dateString.match(ui.dateRegex)){
				return ui.friendlyDate(dateString);
			} else if(!dateString.match(ui.dateTimeRegex)){
				return dateString;
			}
			let regex=/[-:\s]/g;
			let parts=dateString.split(regex);
			let now =new Date(); //In local time
			let offsetMins=now.getTimezoneOffset();
			let d= new Date(parts[0], parts[1]-1, parts[2], parts[3], (1*parts[4])-offsetMins, 0); //A GMT date
			//we always show the time:
			let dateStr=('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2);
			//Now set both to 0 and work out the difference, so we know ow to write the day part
			d.setMinutes(0);
			d.setHours(0);
			d.setMilliseconds(0);
			now.setMinutes(0);
			now.setHours(0);
			now.setMilliseconds(0);

			let diffDays=(now.getTime()-d.getTime())/86400000; //milliseconds to days
			if(-3>diffDays){
				if(d.getFullYear()===now.getFullYear()){
					//23 September, 08:54
					dateStr=d.getDate()+" "+ui.monthsOfYear[d.getMonth()]+", "+dateStr;
				} else {
					//23 Sep 2012, 08:54
					dateStr=d.getDate()+" "+ui.monthsOfYear[d.getMonth()].substring(0,3)+" "+d.getFullYear()+", "+dateStr;
				}	
			} else if(-1>diffDays){
				//Tuesday, 08:54
				dateStr=ui.daysOfWeek[d.getDay()]+", "+dateStr;
			} else if(0>diffDays){
				dateStr="Tomorrow, "+dateStr;
			} else if(1>diffDays){
				dateStr="Today, "+dateStr;
			} else if(2>diffDays){
				dateStr="Yesterday, "+dateStr;
			} else if(4>diffDays){
				//Tuesday, 08:54
				dateStr=ui.daysOfWeek[d.getDay()]+", "+dateStr;
			} else if(d.getFullYear()===now.getFullYear()){
				//23 September, 08:54
				dateStr=d.getDate()+" "+ui.monthsOfYear[d.getMonth()]+", "+dateStr;
			} else {
				//23 Sep 2012, 08:54
				dateStr=d.getDate()+" "+ui.monthsOfYear[d.getMonth()].substring(0,3)+" "+d.getFullYear()+", "+dateStr;
			}
			return dateStr;
		},
		fieldToFriendlyDate:function(obj,field){
			let str=obj[field];
			return ui.friendlyDate(str);
		},
		
		/**
		 * Returns a representation of the interval in a sensible unit. 
		 * If under 60s, returns "X sec"; under 1hr, "X min"; under 48hr, "x hr"; else "x days"
		 */
		secondsToFriendlyUnits:function(seconds){
			if(isNaN(seconds)){ return "??"; }
			if(seconds<60){ return Math.floor(seconds)+" sec"; }
			if(seconds<7200){ return Math.floor(seconds/60)+" min"; }
			if(seconds<172800){ return Math.floor(seconds/3600)+" hr"; }
			return Math.floor(seconds/86400)+" days";
		},
		
		
		decodeHtmlEntities:function(str){
			//See http://stackoverflow.com/questions/7394748/whats-the-right-way-to-decode-a-string-that-has-special-html-entities-in-it
			let ta=document.createElement("textarea");
			ta.innerHTML=str;
			return ta.value;
		},

		encodeHTMLEntities:function(str) {
			let ta = document.createElement('textarea');
			ta.innerText = str;
			return ta.innerHTML;
		},
		
		/* 
		 * SMALL SCREEN / MOBILE function
		 */

		/**
		 * Whether the current screen sizes trigger the "small screen" CSS. This relies on a custom --icebear-smallscreen property
		 * being set to 0 by default and to 1 in the small-screen CSS (as applied by media query). See :root in base.css.
		 */
		isSmallScreen:false,
		
		checkForSmallScreen:function(){
			ui.isSmallScreen=(1===parseInt(window.getComputedStyle(document.body).getPropertyValue('--icebear-smallscreen').trim()));
		},
		
		smallScreenThresholdCrossed:function(){
			if(undefined===ui.wasSmallScreen){ return false; }
			return ui.isSmallScreen!==ui.wasSmallScreen;
		},
		
		smallScreenInit:function(){
			let b=document.body;
			if(b.querySelector(".tabset .current")){
				b.querySelector(".tabset .current").classList.add("smallscreen_open");
			} else if(b.querySelector(".box h2, .tabset h2")){
				b.querySelector(".box h2, .tabset h2").classList.add("smallscreen_open");
			}
			document.querySelectorAll(".box h2, .tabset h2").forEach(function(h){
				h.addEventListener("click",ui.smallScreenToggleOpen);
			});
			ui.checkForSmallScreen();
			ui.wasSmallScreen=ui.isSmallScreen;
			window.addEventListener("resize",function(){
				ui.checkForSmallScreen();
			});
		},
		
		smallScreenToggleOpen:function(evt){
			let header=evt.target;
			if(!ui.isSmallScreen){ return false; }
			let wasOpen=false;
			let itemBody;
			if(header.closest(".box")){
				itemBody=header.closest(".box").querySelector(".boxbody");
				if(itemBody.style.display==="block"){ wasOpen=true; }
			}
			if(header.closest(".tabset")){
				itemBody=header.nextElementSibling;
				if(itemBody.style.display==="block"){ wasOpen=true; }
			}
			document.querySelectorAll(".boxbody, .tabbody").forEach(function(elem){
				elem.style.display="none";
			});
			if(itemBody && !wasOpen){
				itemBody.style.display="block";
				if(ui.isSmallScreen){
					header.scrollIntoView();
				}
			}
		},

		/**
		 * Makes regular AJAX requests throughout the lifetime of the page, to keep the 
		 * server-side session alive. It doesn't matter what we request, as long as
		 * we request something.
		 * 
		 * This should be used sparingly, as it overrides the session timeout mechanism.
		 * A good use case is where the user is doing other things and intermittently 
		 * updating the system over a long period, for example crystal fishing. 
		 */
		keepAlive: function(){
			let delaySeconds=30;
			window.setInterval(function(){
				new AjaxUtils.Request('/api/',{
					method:'get',
					onSuccess:function(xhr){},
					onFailure:function(xhr){}
				});
			}, 1000*delaySeconds);
		},

	copyToClipboard:function (text){
		if(navigator.clipboard){
			navigator.clipboard.writeText(text).then(function() {
				alert('Text copied to clipboard.');
			}, function() {
				alert('Could not copy text to clipboard');
			});
		} else {
			let textArea = document.createElement("textarea");
			textArea.value = text;
			textArea.style.top = "0";
			textArea.style.left = "0";
			textArea.style.position = "fixed";
			document.body.appendChild(textArea);
			textArea.focus();
			textArea.select();
			try {
				document.execCommand('copy');
				alert('Text copied to clipboard.');
			} catch (err) {
				alert('Could not copy text to clipboard');
			}

			document.body.removeChild(textArea);
		}
	},

	downloadAsFile(fileContents, filename, contentType){
		if(!contentType){ contentType="text/plain"; }
		const blob = new Blob([fileContents], { type: contentType })
		const objUrl = URL.createObjectURL(blob)
		const link = document.createElement('a')
		link.setAttribute('href', objUrl)
		link.setAttribute('download', filename)
		link.textContent = 'Click to Download'
		document.querySelector('body').append(link);
		link.click();
		link.remove();
	}

};

let validator={

		validate:function(field){
			let fieldName=field.name;
			let isValid=true;
			let err="";
			let validations;
			if(field["validations"]){
				validations=field["validations"];
			} else if(!fieldValidations || !fieldValidations[fieldName]){
				validations = fieldValidations[fieldName];
			} else {
				return true;
			}

			if(!(validations instanceof Array)){
				if(!validator.isValid(validations,field.value)){
					isValid=false;
					err="This "+validationPatterns[validations]["message"];
				}
			} else {
				for(let i=0;i<validations.length;i++){
					if(!validator.isValid(validations[i],field.value)){
						isValid=false;
						err="This "+validationPatterns[validations[i]]["message"];
					}
				}
			}
			if(field.closest("label")){
				let lbl=field.closest("label");
				if(!isValid){
					lbl.classList.add("invalidfield");
					if(lbl.querySelector(".helptext")){ lbl.querySelector(".helptext").style.display="none"; }
					if(lbl.querySelector(".errortext")){
						lbl.querySelector(".errortext").remove();
					}
					let et=document.createElement("div");
					et.classList.add("errortext");
					et.innerHTML=err;
					lbl.appendChild(et);
				} else {
					lbl.classList.remove("invalidfield");
					if(lbl.querySelector(".errortext")){
						lbl.querySelector(".errortext").remove();	
						if(lbl.querySelector(".helptext")){ lbl.querySelector(".helptext").style.display=""; }
					}
				}
			}
			return isValid;
		},
		isValid:function(validationType,fieldValue){
			let vType=validationPatterns[validationType];
			if(!vType){ return true; } //no validation for this type, assume true and let the server sort it
			if(vType["helper"]){
				if(validator[vType["helper"]]){ return validator[vType["helper"]](fieldValue); }
				return vType["helper"](fieldValue);
			} else if(vType.pattern){
				let re=new RegExp("^"+vType.pattern+"$");
				if(!re.test(fieldValue)){
					return false;
				}
			}
			return true;
		},
		isValidEmailAddress:function(str){
			return !!str.match(/^.*@.*$/);
		},
    	isValidIPv4Address:function(str){
			if(""===str){ return true; }
			if(!str.match(/^\d\d?\d?\.\d\d?\d?\.\d\d?\d?\.\d\d?\d?$/)){ return false; }
			let parts=str.split(".");
			let isValid=true;
			parts.forEach(function(part){
				let num=parseInt(part+"");
				if(isNaN(num) || num<0 || num>255){ isValid=false; }
			});
			return isValid;
		},
		isValidDnaSequence:function(str){
			str=str.replace(/\s/g,'');
			return !(str.length % 3 !== 0 || !str.match(/^[acgtACGT]*$/));
		},
		
};

/*********************************************
 * Drag and drop
 *********************************************/

let DragDrop={

	initialized:false,
	dragging:false,
	draggedElement:null,
	startX:null,
	startY:null,
	offsetX:null,
	offsetY:null,
	oldZIndex:null,
	
	initialize:function(){
		DragDrop.initialized=true;
		document.addEventListener("mousedown", DragDrop.mouseDown);
		document.addEventListener("mousemove", DragDrop.mouseMove);
		document.addEventListener("mouseup", DragDrop.mouseUp);
	},

	setup:function(draggables, droppables, doneCallback, abortCallback){
		if(!DragDrop.initialized){
			DragDrop.initialize();
		}
		if(!draggables || 0===draggables.length){ return false; }
		if(!droppables || 0===droppables.length){ return false; }
		draggables.forEach(function(draggable){
			draggable.classList.add("draggable");
			draggable.droppables=droppables;
			draggable.dragDoneCallback=doneCallback;
			draggable.dragAbortedCallback=abortCallback;
		});
	},

	cancel:function(draggables, droppables){
		if(!draggables || 0===draggables.length){ return false; }
		if(!droppables || 0===droppables.length){ return false; }
		draggables.forEach(function(draggable){
			draggable.classList.remove("draggable");
			draggable.droppables=null;
			draggable.dragDoneCallback=null;
			draggable.dragAbortedCallback=null;
		});
	},

	mouseDown:function(e){
		let elem=e.target;
		if(!elem.classList.contains("draggable") && !elem.classList.contains("handle")){ return false; }
		if(elem.classList.contains("handle")){
			elem=elem.closest(".draggable");
		}
		DragDrop.dragging=true;
		DragDrop.draggedElement=elem;
		DragDrop.draggedElement.classList.add("dragging");
		let posX, posY;
		if(elem.closest(".boxbody")){
			let oldParent=elem.closest(".boxbody");
			posX=ui.cumulativeOffset(elem).left-ui.cumulativeOffset(oldParent.parentElement.parentElement).left;
			posY=ui.cumulativeOffset(elem).top-ui.cumulativeOffset(oldParent.parentElement.parentElement).top;

			elem.oldParent=oldParent;
			if(elem.nextSibling){ elem.oldNextSibling=elem.nextSibling; }
			if("absolute"!==elem.style.position){
				elem.dataset.wasrelative="1";
			}
			elem.dataset.wastop=ui.positionedOffset(elem).top+"";
			elem.dataset.wasleft=ui.positionedOffset(elem).left+"";
			elem.style.width=elem.clientWidth+"px";
			elem.style.height=elem.clientHeight+"px";
			elem.closest(".box").parentElement.appendChild(elem.remove());
		} else {
			posX=ui.positionedOffset(elem).left;
			posY=ui.positionedOffset(elem).top;
		}
		DragDrop.startX=e.clientX;
		DragDrop.startY=e.clientY;
		DragDrop.endX=null;
		DragDrop.endY=null;
		DragDrop.offsetX=posX;
		DragDrop.offsetY=posY;
		DragDrop.oldZIndex=elem.style.zIndex;
		if(!elem.droppables){ return false; }
		elem.style.zIndex="10000";
		elem.droppables.forEach(function(droppable){
			droppable.classList.add("droppable");
			droppable.leftEdge=ui.cumulativeOffset(droppable).left;
			droppable.rightEdge=droppable.leftEdge+droppable.offsetWidth;
			droppable.topEdge=ui.cumulativeOffset(droppable).top;
			droppable.bottomEdge=droppable.topEdge+droppable.offsetHeight;
		});
		document.body.focus();
		DragDrop.mouseMove(e);
		document.onselectstart=function(){ return false; }; //IE: Don't select text
		elem.ondragstart = function(){ return false; }; //IE: Don't drag images
		return false; //Non-IE: don't select text
	},
	
	mouseMove:function(e){
		if(!DragDrop.dragging || !DragDrop.draggedElement){
			return;
		}
		let elem=e.target;
		let de=DragDrop.draggedElement;
		de.style.left=(DragDrop.offsetX + e.clientX - DragDrop.startX)+"px";
		de.style.top =(DragDrop.offsetY + e.clientY - DragDrop.startY)+"px";
		document.querySelectorAll(".droppable").forEach(function(droppable){
			if(droppable.leftEdge<e.clientX && droppable.rightEdge>e.clientX && droppable.topEdge<e.clientY && droppable.bottomEdge>e.clientY){
				droppable.classList.add("activedroppable");
				DragDrop.activeDroppable=droppable;
			} else {
				droppable.classList.remove("activedroppable");
			}
		});
		document.body.focus();
		document.onselectstart=function(){ return false; }; //IE: Don't select text
		elem.ondragstart = function(){ return false; }; //IE: Don't drag images
		return false; //Non-IE: don't select text
	},
	
	mouseUp:function(e){
		if(!DragDrop.dragging || !DragDrop.draggedElement){
			return;
		}
		DragDrop.endX=e.clientX;
		DragDrop.endY=e.clientY;
		if(!DragDrop.activeDroppable){
			DragDrop.abortDrop();
			return false;
		}
		document.querySelectorAll(".droppable").forEach(function(droppable){
			droppable.classList.remove("droppable");
			droppable.classList.remove("activedroppable");
		});
		
		window.setTimeout(function(){ DragDrop.draggedElement.dragDoneCallback(DragDrop.draggedElement, DragDrop.activeDroppable); DragDrop.finishDrop(); },50);
		DragDrop.draggedElement.style.zIndex=DragDrop.oldZIndex;
	},

	abortDrop:function(){
		document.querySelectorAll(".droppable").forEach(function(droppable){
			droppable.classList.remove("droppable");
			droppable.classList.remove("activedroppable");
		});
		let dragged=DragDrop.draggedElement;
		dragged.style.left=(DragDrop.offsetX)+"px";
		dragged.style.top =(DragDrop.offsetY)+"px";
		dragged.offsetX=DragDrop.offsetX;
		dragged.offsetY=DragDrop.offsetY;
		if(dragged.oldParent){
			if(dragged.oldNextSibling){
				dragged.before(dragged.oldNextSibling);
			} else {
				dragged.oldParent.appendChild(dragged);
			}
		}
		DragDrop.dragging=false;
		DragDrop.draggedElement.classList.remove("dragging");
		if(dragged.dragAbortedCallback){
			dragged.dragAbortedCallback(dragged);
		}
		DragDrop.draggedElement=null;
		DragDrop.activeDroppable=null;
	},
	
	defaultOnDrop:function(){
		DragDrop.draggedElement.style.top="0";
		DragDrop.draggedElement.style.left="0";
		DragDrop.activeDroppable.appendChild(DragDrop.draggedElement);
	},
	
	finishDrop:function(){
		DragDrop.dragging=false;
		DragDrop.draggedElement.classList.remove("dragging");
		DragDrop.draggedElement=null;
		DragDrop.activeDroppable=null;
	}

};

/*********************************************
 * AJAX utilities
 *********************************************/


let AjaxUtils={

	/**
	 * Makes an AJAX request to the server, and calls the specified onSuccess or onFailure functions as appropriate.
	 * Should a 401 Authorization Required response status be returned, the user session will be assumed to have
	 * expired.
	 * @param uri The URI to call.
	 * @param options Options for the request. Valid options include:
	 * 	- method: The HTTP method. Defaults to "get".
	 * 	- postBody: A raw body. (See "parameters" for the alternative.)
	 * 	- parameters: An object of key-value pairs.
	 * 	- requestHeaders: An object of key-value pairs.
	 * 	- onSuccess: A function to be run if the HTTP response status is 200 OK or 201 Created. This function should
	 * 		accept an XMLHttpRequest object, which will additionally contain a responseJSON parameter.
	 * 	- onFailure: A function to be run if the HTTP response status is not 200, 201 or 401.  This function should
	 *        accept an XMLHttpRequest object, which will additionally contain a responseJSON parameter if a JSON
	 *        object can be parsed from the response body.
	 */
	Request:function (uri,options){
		AjaxUtils.request(uri, options);
	},

	request:function(uri, options){
		let xhr=new XMLHttpRequest();
		let method=options["method"].toLowerCase() || "get";
		let postBody=options['postBody'] || "";
		if(options["parameters"]){
			if("get"===method){
				uri+='?';
				Object.keys(options["parameters"]).forEach(function(key){
					uri+=key+"="+encodeURIComponent(options["parameters"][key])+"&";
				});
			} else {
				if(undefined===options["parameters"]["csrfToken"]){
					options["parameters"]["csrfToken"]=csrfToken;
				}
				Object.keys(options["parameters"]).forEach(function(key){
					postBody+=key+"="+encodeURIComponent(options["parameters"][key])+"&";
				});
			}
		}
		xhr.open(method, uri, true);
		if(options["requestHeaders"]){
			Object.keys(options["requestHeaders"]).forEach(function (key){
				xhr.setRequestHeader(key, options["requestHeaders"][key]);
			});
		}
		xhr.onload=function(){
			if(!options.onSuccess){ console.log("No options.onSuccess for request to "+uri); }
			try{
				xhr.responseJSON=JSON.parse(xhr.responseText);
			} catch(ex) {
				//just eat it. Handler should fail it.
			}
			if(200===xhr.status || 201===xhr.status){
				options.onSuccess(xhr);
			} else if(401===xhr.status && !options['suppressSessionExpiredMessage']){
				ui.handleSessionExpired();
			} else {
				options.onFailure(xhr);
			}
		};
		xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
		xhr.send(postBody);
	},

	//call this as the first line of any onSuccess handler. 
	checkResponse:function(transport){
		try {
			let json=JSON.parse(transport.responseText);
			if(json["notLoggedIn"]){
				document.location.href='/Login';
				return false;
			} else if(json.error){ 
				alert(json.error);
				return false; 
			}
		} catch(err) {
			//transport.status===0 is likely "request cancelled", e.g., page reload fired while
			//request was in progress (Chrome does this)
			if(0!==transport.status){
				alert("Something went wrong. The server said:\n\n"+transport.responseText);
			}
			return false;
		}
		return true;
	},
	
	/**
	 * Submits the AJAX request to the remote server, via the IceBear corsproxy class.
	 */
	remoteAjax:function(uri, method, parameters, onSuccess, onFailure, headers, bypassProxy){
		if(!parameters){ parameters={}; }
		method=method.toLowerCase();
		if("get"!==method){ method="post"; }
		parameters['url']=uri;
		let request={
			method:method,
			onSuccess:function(transport){ onSuccess(transport); },
			onFailure:function(transport){ onFailure(transport); }
		};
		if(headers){
			request.requestHeaders=headers;
		}

		if("get"===method){
			if(!bypassProxy){
				uri=encodeURIComponent(uri);
				uri="/api/corsproxy?url="+uri;
			}
		} else {
			let params={};
			if("object"===(typeof parameters).toLowerCase()){
				params=parameters;
			} else {
				params['rawPostBody']=parameters;
			}
			params["csrfToken"]=csrfToken;
			if(!bypassProxy){
				params["url"]=uri;
				uri="/api/corsproxy";
			}
			request["parameters"]=params;

		}
		new AjaxUtils.Request(uri,request);
	},
		
};


let Cookies= {

		unset:function (cname, path){
			if(!path){ path="/"; }
			Cookies.set(cname, "", -1, path);
		},

		set:function(cName, cValue, expiryDays, path) {
			if(!expiryDays){ expiryDays=180; }
			if(!path){ path="/"; }
		    let d = new Date();
		    d.setTime(d.getTime() + (expiryDays*24*60*60*1000));
		    let expires = "expires="+d.toUTCString();
		    document.cookie = cName + "=" + cValue + "; " + expires+ "; Path="+path;
		},
		
		get:function(cname) {
		    let name = cname + "=";
		    let ca = document.cookie.split(';');
		    for(let i=0; i<ca.length; i++) {
		        let c = ca[i];
		        while (c.charAt(0)===' ') c = c.substring(1);
		        if (c.indexOf(name) === 0) return c.substring(name.length,c.length);
		    }
		    return null;
		}
};

let UserConfig={
		
		items:{},
		
		get:function(name, defaultValue){
			if(!userId){ return defaultValue; }
			if(!UserConfig.items[name]){
				UserConfig.items[name]=defaultValue;
				window.setTimeout(function(){ UserConfig.set(name, defaultValue); }, 50);
				return defaultValue;
			}
			return UserConfig.items[name];
		},
		
		set:function(name, newValue, onSuccess){
			if(!onSuccess){ onSuccess=function(){}; }
			UserConfig.items[name]=newValue;
			new AjaxUtils.Request('/api/userconfig/'+name,{
				method:'patch',
				postBody:'csrfToken='+csrfToken+'&'+name+'='+newValue,
				onSuccess:onSuccess
			});
		},

};


let Login={

		 doLogin: function(){
			let box=document.getElementById("loginbox");
			let username=document.getElementById('username').value;
			let password=document.getElementById('password').value;
			if(""===username || ""===password){
				box.querySelector("h2").innerHTML="Username and password required";
				return false;
			}
			let parameters={
				username:username,
				password:password
			 };
			if(box.querySelector("[name=linkaccount]")){
				parameters["linkaccount"]="linkaccount";
			}
			box.querySelector("input[type=submit]").closest("label").classList.add("updating");
			new AjaxUtils.Request('/api/Login',{
				suppressSessionExpiredMessage:true,
				method:'post',
				parameters:parameters,
				onSuccess:Login.doLogin_onSuccess,
				onFailure:Login.doLogin_onFailure
			});
			return false; //don't submit the form as well.
		},
		doLogin_onSuccess: function(transport){
			if(!codeAndDbMatch){
				let isAdmin=1*transport.responseJSON["isadmin"];
				if(!isAdmin){
					transport.responseJSON.error=("That account is not an administrator");
					return Login.doLogin_onFailure(transport);
				}
				requestedUri="/config/#version";
			}
			if(""===requestedUri){ requestedUri="/"; }
			if(transport.responseJSON["linkedAccount"]){
				Login.afterLinkAccount();
			} else if(transport.responseJSON["createdAccount"]){
				Login.afterCreateAccount();
			} else {
				window.location.href=requestedUri;
				ui.forceReload();
			}
		},
		doLogin_onFailure: function(transport){
		 	let box=document.getElementById("loginbox");
			box.querySelector(".updating").classList.remove("updating");
			if(transport.responseJSON && transport.responseJSON.error){
				box.querySelector("h2").innerHTML=transport.responseJSON.error;
			} else {
				alert("There was an error\n\n"+transport.responseText);
			}
		},

		createAccount: function (evt){
			 evt.target.closest("label").addClassName("updating");
			 new AjaxUtils.request("/api/Login", {
				 method:"post",
				 parameters:{ "createaccount":"createaccount" },
				 onSuccess: Login.afterCreateAccount,
				 onFailure: function (xhr){
					 let bb=evt.target.closest(".boxbody");
					 bb.innerHTML="";
					 ui.errorMessageBar("Could not create account",bb);
					 bb.innerHTML+='<p>There was an error and your IceBear account could not be created. Please talk to your administrator.</p>';
					 if(xhr.responseJSON && xhr.responseJSON["error"]){
						 bb.innerHTML+='<p>The server said: '+xhr.responseJSON["error"]+'</p>';
					 }
				 }
			 });
		},

		afterLinkAccount:function (){
			document.getElementById("grid").querySelectorAll(".box").forEach(function (box){
				box.remove();
			});
			let b=document.getElementById("grid").box({
				'title':'Account linked',
				'classes':'r1 c2 h2 w1',
				'content':''
			});
			ui.successMessageBar("Account linked",b);
			b.innerHTML+='<p>Your login credentials were linked to your existing IceBear account.</p>';
			let f=b.form({});
			let btn=f.buttonField({
				"label":"Continue to IceBear"
			});
			btn.addEventListener("click",function (){
				window.location.href=requestedUri;
				ui.forceReload();
			});
		},

		afterCreateAccount:function (){
			document.getElementById("grid").querySelectorAll(".box").forEach(function (box){
				box.remove();
			});
			let b=document.getElementById("grid").box({
				'title':'Account linked',
				'classes':'r1 c2 h3 w1',
				'content':''
			});
			ui.successMessageBar("IceBear account created",b);
			b.innerHTML+='<p>Your IceBear account has been created. Please read this carefully:</p>'+
				'<p>Ask your supervisor, collaborator, or IceBear administrator to grant you '+
				'access to the relevant projects. Until this has been done, you won\'t be able to see or work on these '+'' +
				'projects in IceBear.</p>'+
				'<p>If you have plates in an imager, you may be able to see them in IceBear.  These will be in a default project, '+
				'where only you can see them. When you have been given access to the correct project, set the plate\'s protein '+
				'and construct to move it into that project.</p>'+
				'<p>You will also be able to see some common items like imagers, standard screens, and containers.</p>';
			let f=b.form({});
			let btn=f.buttonField({
				"label":"Continue to IceBear"
			});
			btn.addEventListener("click",function (){
				window.location.href=requestedUri;
				ui.forceReload();
			});
		},
	/**
	 * Logs the current user out.
	 * @param force If true, don't confirm, just log out immediately.
	 * @returns {boolean}
	 */

		logOut: function(force){
			if(!force && !confirm("Really log out?")){ return false; }
			new AjaxUtils.Request("/api/Logout",{
				method:'post',
				onSuccess:function(){
					if(shibbolethIdentityProvider){
						document.location.href='/Shibboleth.sso/Logout?entityID='+encodeURIComponent(shibbolethIdentityProvider)+'&return='+encodeURIComponent(document.location.href);
					} else {
						ui.forceReload();
					}
				},
				onFailure:function(transport){
					let msg="The server reported an error.";
					if(transport.responseJSON && transport.responseJSON.error){
						msg+="\n\n"+transport.responseJSON.error;
					}
					alert(msg);
					ui.forceReload();
				}
			});
		},

		renderIdentityProvider: function(idp){
			if(false===idp["enabled"]){ return false; }
			let boxBody=document.getElementById("loginbox").querySelector(".boxbody");
			if(!idp["logoURL"]){
				idp["logoURL"]="/images/icons/lock.png";
			}
			let a=document.createElement("a");
			a.classList.add("idp");
			if(idp["name"].startsWith("IceBear")){
				Login.hideLoginForm();
				a.addEventListener("click", Login.showLoginFormAndHideIdpLinks);
			} else if(idp["entityID"]){
				a.href="/Shibboleth.sso/Login?target="+document.location.origin+"&entityID="+idp["entityID"];
				a.addEventListener("click",function(){
					Cookies.set("urlAfterAuth", document.location.href);
				});
			} else if(idp["discoveryService"] && idp["ownEntityID"]){
				let ds=idp["discoveryService"];
				if(ds.endsWith("/")){ ds=ds.slice(0,-1); }
				a.href=ds+"?entityID="+encodeURIComponent(idp["ownEntityID"])+"&return="+encodeURIComponent(document.location.origin);
				a.addEventListener("click",function(){
					Cookies.set("ownEntityID", idp["ownEntityID"]);
					Cookies.set("urlAfterAuth", document.location.href);
					Cookies.set("sentToDiscoveryService", 1);
				});
			} else {
                a.href="#";
				a.addEventListener("click",function (){
					alert("IdP "+idp["name"]+" is badly configured in conf/identityProviders.json. Must have entityID, OR both discoveryService AND ownEntityID.");
					return false;
				});
			}
			let div=document.createElement("div");
			div.title="Log in with "+idp["name"];
			div.style.backgroundImage="url("+idp["logoURL"]+")";
			let span=document.createElement("span");
			span.innerHTML=idp["name"];
			a.appendChild(div);
			a.appendChild(span);
			boxBody.appendChild(a);
			a.style.height=(div.clientHeight+span.clientHeight)+"px";
			return true;
		},

	showLoginFormAndHideIdpLinks: function(){
		let box=document.getElementById("loginbox");
		box.querySelector("h2").innerHTML="Log in to IceBear";
		box.querySelectorAll(".idp").forEach(function (idp){
			idp.style.display="none";
		});
		let frm=box.querySelector("form");
		frm.style.display="block";
		frm.querySelector("input[type=text]").focus();
		if(!document.getElementById("cancelLogin")){
			let a=document.createElement("a");
			a.href="#";
			a.innerHTML="Cancel";
			a.id="cancelLogin";
			a.style.position="relative";
			a.style.top="1em";
			a.onclick=function(){
				Login.hideLoginForm();
				return false;
			}
			frm.appendChild(a);
		}
	},

	hideLoginForm:function(){
		let box=document.getElementById("loginbox");
		box.querySelector("form").style.display="none";
		box.querySelectorAll(".idp").forEach(function (idp){
			idp.style.display="inline-block";
		});
		box.querySelector("h2").innerHTML="Choose how to log in to IceBear";
	},

	authTimer:null,
	authTimeoutSeconds:0,
	authDelaySeconds:10, //wait this long after session should have expired before checking
	startAuthTimer:function (){
		if(!currentUser){ return; }
		document.addEventListener("mousemove",function(){ Login.resetAuthTimer(Login.authTimeoutSeconds); });
		document.addEventListener("keyup",function(){ Login.resetAuthTimer(Login.authTimeoutSeconds); });
		Login.resetAuthTimer(Login.authTimeoutSeconds);
	},
	resetAuthTimer:function(secondsRemaining){
		window.clearTimeout(Login.authTimer);
		let milliseconds=1000*(secondsRemaining+Login.authDelaySeconds);
		Login.authTimer=window.setTimeout(Login.checkStillLoggedIn, milliseconds);
	},

	checkStillLoggedIn:function(){
		new AjaxUtils.Request('/api/authCheck',{
			method:"get",
			onSuccess:function (xhr){
				if(xhr["responseJSON"] && xhr["responseJSON"]["secondsRemaining"]){
					let secondsRemaining=1*xhr["responseJSON"]["secondsRemaining"];
					if(secondsRemaining>0) {
						Login.resetAuthTimer(secondsRemaining);
					} else {
						Login.logOut(true);
					}
				}
			},
			onFailure:function (){}
		});
	}

};

/**
 * Functions for rendering and updating the header search box.
 */
let HeaderSearchBox={

		regex:/^[A-Za-z0-9?*_-]*$/g,

		onKeyUp:function (evt){
			if(evt.keyCode){
				let k=evt.keyCode;
				if(37===k || 39===k){ return; } //left/right arrow
				if(38===k){ HeaderSearchBox.selectPrevious(); } //Up arrow
				if(40===k){ HeaderSearchBox.selectNext(); } //Down arrow
				if(13===k){ HeaderSearchBox.goToSelected(); } //Enter
				if(27===k){ HeaderSearchBox.stop(); } //Escape
			}
		},

		onInput:function(evt){
			let elem=evt.target;
			let terms=elem.value.trim();
			if(""===terms || !terms.match(HeaderSearchBox.regex)){ return false; }
			if(2>=terms.length){
				HeaderSearchBox.stop(evt);
			} else if(elem.searchresults && 0<elem.searchresults.length && 0===elem.value.indexOf(elem.searchresults.searchterms)){
				HeaderSearchBox.updateResults(elem);
			} else {
				HeaderSearchBox.search(elem,terms);
			}
		},
		
		search:function(elem,terms){
			elem.searchterms='';
			elem.searchresults=[];
			if(!elem.dataset.classlist){
				alert("Cannot search - no list of types to search for. See an admin."); 
				return false;
			}
			elem.classList.add("updating");
			new AjaxUtils.Request("/api/search/"+encodeURIComponent(terms)+"?pagenumber=1&pagesize=50&classes="+elem.dataset.classlist,{
				method:"get",
				onSuccess:function(transport){ HeaderSearchBox.search_onSuccess(transport,elem); },
				onFailure:function(transport){ HeaderSearchBox.search_onFailure(transport,elem); },
			});
		},
		
		search_onSuccess:function(transport,elem){
			elem.classList.remove("updating");
			//are these results still valid?
			if(0!==elem.value.toLowerCase().indexOf(transport.responseJSON.searchterms.toLowerCase())){
				return HeaderSearchBox.search(elem, elem.value);
			}
			elem.searchterms=transport.responseJSON.searchterms;
			elem.searchresults=transport.responseJSON.rows;
			HeaderSearchBox.makeResultsBox(elem);
			HeaderSearchBox.updateResults(elem);
			document.getElementById("searchboxresults").style.display="block";
		},
		
		search_onFailure:function(transport,elem){
			let r=document.getElementById("searchboxresults");
			if(401===transport.status){
				r.style.display="none";
	        	ui.handleSessionExpired();
				return;
			}
			HeaderSearchBox.makeResultsBox(elem);
			elem.searchresults=[];
			HeaderSearchBox.updateResults(elem);
			r.style.display="none";
		},

		makeResultsBox:function(elem){
			if(!document.getElementById("searchboxresults")){
				let rect=elem.getBoundingClientRect();
				let sb=document.createElement("div");
				sb.id="searchboxresults";
				sb.className="box hastable";
				document.body.appendChild(sb);
				sb.style.top=rect.bottom+"px";
				sb.style.right=(document.getElementById("header").offsetWidth-(rect.left+rect.width))+"px";
				sb.style.width="0";
				sb.style.minWidth=elem.offsetWidth+"px";
			}
		},
		
		updateResults:function(elem){
			let rows=elem.searchresults;
			let rect=elem.getBoundingClientRect();
			let sb=document.getElementById("searchboxresults");
			sb.style.top=rect.bottom+"px";
			sb.style.right=(document.getElementById("header").offsetWidth-(rect.left+rect.width))+"px";
			sb.style.width="0";
			sb.style.minWidth=elem.offsetWidth+"px";
			sb.innerHTML="<table></table>";
			let t=sb.querySelector("table");
			while(t.firstElementChild){ t=t.firstElementChild; }

			let pattern=elem.value.toLowerCase().replace("?",".?").replace("*",".*");
			rows.forEach(function(r){
				if(0===r.name.toLowerCase().indexOf("dummypin_")){ return; }
				if(!r.name.toLowerCase().match(pattern)){ return; /*from this iteration of the loop*/ }
				let tr=document.createElement("tr");
				t.appendChild(tr);
				let imageTitle=r["objecttype"].charAt(0).toUpperCase() + r["objecttype"].slice(1);
				tr.innerHTML+='<td><a href="/'+r["objecttype"]+'/'+r.id+'"><img alt="" title="'+imageTitle+'" src="/images/icons/'+skin["bodyIconTheme"]+'icons/header/bc_'+r["objecttype"]+'.png" style="height:1.5em;margin-right:0.5em"/>'+r.name.replace(/\s/g, "&nbsp;")+'</a></td>';
			});
			sb.querySelector("tr").classList.add("selected");
			
			window.setTimeout(function(){
				sb.style.width=(16+Math.max(t.offsetWidth,elem.offsetWidth))+"px";
			},10);
			
		},
		
		selectPrevious:function(){
			let sb=document.getElementById("searchboxresults");
			if(!sb){ return false; }
			let sel=sb.querySelector(".selected");
			if(sel.previousElementSibling){
				sel.classList.remove("selected");
				sel.previousElementSibling.classList.add("selected");
				sel.previousElementSibling.scrollIntoView({ behavior:"smooth" });
			}
		},
		
		selectNext:function(){
			let sb=document.getElementById("searchboxresults");
			if(!sb){ return false; }
			let sel=sb.querySelector(".selected");
			if(sel.nextElementSibling){
				sel.classList.remove("selected");
				sel.nextElementSibling.classList.add("selected");
				sel.nextElementSibling.scrollIntoView({ behavior:"smooth" });
			}
		},

		goToSelected:function(){
			let sb=document.getElementById("searchboxresults");
			if(!sb){ return false; }
			let sel=sb.querySelector(".selected");
			sel.classList.remove("selected");
			sel.classList.add("updating");
			document.location.href=sel.querySelector("a").href;
		},
		
		stop:function(evt){
			let elem=evt.target;
			elem.classList.remove("updating");
			if(document.getElementById("searchboxresults")){
				window.setTimeout(function(){
					document.getElementById("searchboxresults").style.display="none";
				}, 100);
			}
		}
		
};


/*********************************************
 * Note functionality
 *********************************************/

let Note={

	/**
	 *
	 * @param parentElement The element into which the buttons should be written.
	 * @param noteBoxId The ID of the note text box that these buttons should update. This need not exist yet when calling
	 * 					this function, but must exist when the button is clicked.
	 * @param buttonDescriptions An array of objects, each having two properties:
	 * 					- "label", used for the button text
	 * 					- "note", the text to be inserted into the note box when this button is clicked
	 * @param buttonStyle Any extra CSS needed to style the buttons
	 * @returns {boolean}
	 */
	writeQuickNoteButtons:function(parentElement, noteBoxId, buttonDescriptions,  buttonStyle){
		if(!buttonStyle){ buttonStyle=""; }
		if(!parentElement){ return false; }
		buttonDescriptions.forEach(function(desc){
			let btn=document.createElement("input");
			btn.type="button";
			btn.dataset.noteText=desc.note;
			btn.dataset.noteBoxId=noteBoxId;
			btn.value=desc.label;
			btn.style=buttonStyle;
			btn.style.cursor="pointer";
			btn.addEventListener("click", Note.toggleQuickNote);
			parentElement.appendChild(btn);
		});
		return true;
	},

	/**
	 * Handles a click on a "quick note" button. The button must have dataset.noteText and dataset.noteBoxId.
	 * If the supplied text is present and followed by a newline (\n), it will be removed; if not, it will be added at the
	 * beginning of the textarea followed by a newline.
	 * @param evt The click event on the note button.
	 */
	toggleQuickNote:function(evt){
		let btn=evt.target;
		let notesBox=document.getElementById(btn.dataset.noteBoxId);
		let noteText=btn.dataset.noteText+"\n";
		if(!notesBox || !noteText){ return false; }
		if(-1===notesBox.value.indexOf(noteText)){
			//Quick note not present. Add it.
			notesBox.value=noteText+notesBox.value;
		} else {
			//Quick note is present. Remove it.
			notesBox.value=notesBox.value.replace(noteText,"");
		}
	}

};


/*********************************************
 * Audio recording functionality
 *********************************************/
let AudioRecording={

	recorder:null,

	/**
	 * Initialises the supplied element as an audio field. Typically, this will be a label element in a form, as written
	 * by ui.audioField.
	 * @param elem The element to initialise.
	 * @param options
	 *  - baseObjectId: The ID of the object to which recordings should be attached. Defaults to data.id.
	 *  - maxPrevious: Show no more than this many previous recordings
	 *  - readonly: If true, don't render recording controls
	 */
	init: function(elem,options){
		if(!options){ options={}; }
		elem.options=options;
		elem.classList.add("audiofield");
		if(elem.querySelector("span.label")){
			elem.querySelector("span.label").remove();
		}
		let baseObjectId=options['baseObjectId'];
		if(!baseObjectId && data && data.id){
			baseObjectId=data.id;
		}
		if(elem.querySelector(".recordingcontrols")){
			//Resetting after making a recording, so re-use the baseObjectId
			baseObjectId=elem.querySelector(".recordingcontrols").dataset.parentObjectId;
			//and nuke the innerHTML
		}
		elem.innerHTML="";
		if(!baseObjectId){
			elem.innerHTML='Could not write audio field. No baseObjectId supplied and no data.id in page.';
			return false;
		}
		elem.dataset.parentObjectId=baseObjectId;
		if(undefined===options.readonly || 1!==1*options.readonly){
			//write recording controls
			let recordingControls=document.createElement("div");
			recordingControls.dataset.parentObjectId=baseObjectId;
			recordingControls.className="recordingcontrols";
			let img=document.createElement("img");
			img.src="/images/icons/"+skin["bodyIconTheme"]+"icons/btn_mic.gif";
			recordingControls.appendChild(img);
			let msg=document.createElement("span");
			msg.className="recordingmessage";
			recordingControls.appendChild(msg);
			recordingControls.chunks=[];
			if(!window.MediaRecorder){
				msg.innerHTML+="Your browser doesn't support recording audio.";
				recordingControls.classList.add("recordingerror");
			} else if(!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia){
				msg.innerHTML+="Can't record audio. Ask your IceBear administrator about this.";
				recordingControls.classList.add("recordingerror");
			} else {
				msg.innerHTML+="Click to record audio notes";
				recordingControls.onclick=AudioRecording.start;
			}
			elem.appendChild(recordingControls);
		}
		let previousRecordingsDiv=document.createElement("div");
		previousRecordingsDiv.className="recordings";
		elem.appendChild(previousRecordingsDiv);
		AudioRecording.getPreviousRecordings(elem);
	},

	getPreviousRecordings:function (elem, successHandler, failureHandler) {
		if(!successHandler){ successHandler=AudioRecording.getPreviousRecordings_onSuccess; }
		if(!failureHandler){ failureHandler=AudioRecording.getPreviousRecordings_onFailure; }
		new AjaxUtils.Request('/api/baseobject/'+elem.dataset.parentObjectId+'/recording?sortby=id&sortdescending=yes',{
			method:'get',
			onSuccess:function (transport) { successHandler(transport.responseJSON, elem); },
			onFailure:function (transport) { failureHandler(transport, elem); }
		});
	},

	getPreviousRecordings_onSuccess:function(recordings, elem){
		let previousRecordingsDiv=elem.querySelector('.recordings');
		previousRecordingsDiv.innerHTML="";
		if(recordings["rows"]){
			recordings=recordings["rows"];
			let counter=0;
			let options=elem.options;
			recordings.forEach(function(r){
				if(!options || !options["maxPrevious"] || counter<options["maxPrevious"]){
					previousRecordingsDiv.innerHTML+='<div><audio controls="controls" src="/api/audiorecordingfile/'+r.id+'"></audio> By <a href="/user/'+r.userid+'">'+r["userfullname"]+'</a> '+ui.friendlyDateTime(r["datetime"])+'</div>';
				}
				counter++;
			});
		}
	},

	getPreviousRecordings_onFailure:function(transport, elem){
		let previousRecordingsDiv=elem.querySelector('.recordings');
		let controlsDiv=elem.querySelector('.recordingcontrols');
		previousRecordingsDiv.innerHTML='';
		if(404===transport.status) {
			if(!controlsDiv || ""===controlsDiv.innerHTML){
				elem.remove();
			}
		} else {
			previousRecordingsDiv.innerHTML='Could not fetch previous recordings';
		}
	},

	start:function(evt){
		let recordingDiv=evt.target.closest(".recordingcontrols");
		let msg=recordingDiv.querySelector(".recordingmessage");
		if(AudioRecording.recorder){
			return false;
		}
		msg.innerHTML="Waiting for microphone permissions...";
		let constraints={ audio:true };
		navigator.mediaDevices.getUserMedia(constraints)
			.then(function(stream) {
				let options={
					mimeType:'audio/webm;codecs=opus'
				};
				AudioRecording.recorder=new MediaRecorder(stream,options);
				AudioRecording.recorder.ondataavailable = function(e) {
					recordingDiv.chunks.push(e.data);
				};
				recordingDiv.onclick=AudioRecording.stop;
				AudioRecording.recorder.start();
				recordingDiv.classList.add("recording");
				msg.innerHTML="Recording...";
			})
			.catch(function(err) {
				recordingDiv.style.backgroundColor="#f99";
				recordingDiv.innerHTML=err.message+" ("+err.name+")";
				recordingDiv.classList.add("recordingerror");
			});
	},

	stop:function(evt) {
		AudioRecording.recorder.stop();
		let recordingDiv = evt.target.closest(".recordingcontrols");
		window.setTimeout(function(){
			AudioRecording.afterStop(recordingDiv);
		},50);
	},
	afterStop:function(recordingDiv){
		recordingDiv.classList.remove("recording");
		recordingDiv.querySelector(".recordingmessage").innerHTML="Saving, please wait...";
		recordingDiv.closest("label").classList.add("updating");
		window.setTimeout(function(){
			AudioRecording.save(recordingDiv);
		},1000)
	},

	save:function(recordingDiv){
		let formData=new FormData();
		formData.append("csrfToken",csrfToken);
		formData.append("parentid",recordingDiv.dataset.parentObjectId);
		formData.append("audiodata", new Blob(recordingDiv.chunks), 'audio.ogg');
		let xhr=new XMLHttpRequest();
		xhr.open('post', '/api/audiorecording/', true);
		xhr.onload=function(){
			try{
				xhr.responseJSON=JSON.parse(xhr.responseText);
			} catch(ex) {
				//just eat it
			}
			if(400>xhr.status){
				AudioRecording.onUploadSuccess(xhr,recordingDiv);
			} else if(401===xhr.status){
				ui.handleSessionExpired();
			} else {
				AudioRecording.onUploadFailure(xhr,recordingDiv);
			}
		};
		xhr.send(formData);
	},

	onUploadSuccess:function(xhr,recordingDiv){
		recordingDiv.chunks=[];
		recordingDiv.onclick=AudioRecording.start;
		AudioRecording.recorder=null;
		recordingDiv.closest("label").classList.remove("updating");
		//wipe recordings list and reload
		let audioField=recordingDiv.closest("label");
		AudioRecording.init(audioField, audioField.options);
	},

	onUploadFailure:function(xhr, recordingDiv){
		recordingDiv.closest("label").classList.remove("updating");
		alert("There was a problem uploading the audio to the server.");
		let msg;
		if(xhr.responseJSON && xhr.responseJSON.error){
			msg=xhr.responseJSON.error;
		} else {
			msg=xhr.responseText;
		}
		alert("There was a problem uploading the audio to the server.\n\n"+msg);
	},

};


/*********************************************
 * Homepage functionality
 * - brick parsing and rendering
 * - drag-drop actions
 *********************************************/

let Homepage={

	isDefaultHomepage:false,
	apiUrl:"",

	bricks:{},
	registerBrick:function (brick){
		if(brick.name){
			Homepage.bricks[brick.name]=brick;
		}
	},

	toggleConfig:function(){
		if(document.getElementById("configbutton").isActive){
			Homepage.stopConfig();
		} else {
			Homepage.startConfig();
		}
	},
	startConfig:function(){
		document.body.style.overflow="hidden";
		document.getElementById("configbutton").isActive=true;
		DragDrop.setup(document.querySelectorAll(".box"),document.querySelectorAll(".boxslot"),Homepage.onDrop);
		document.querySelectorAll(".box").forEach(function(box) {
			//Add the Close icon
			let h2 = box.querySelector("h2");
			h2.innerHTML = '<span class="closeicon" >&nbsp;</span>' + h2.innerHTML;
			h2.classList.add("handle");
		});
		document.querySelectorAll(".closeicon").forEach(function(ci){
			ci.addEventListener("click",Homepage.removeBrick);
		});
		document.querySelectorAll(".boxslot").forEach(function(s){
			s.addEventListener("mouseover",s.mouseoverListener=function(){ s.classList.add("activedroppable"); });
			s.addEventListener("mouseout",s.mouseoutListener=function(){ s.classList.remove("activedroppable"); });
			s.addEventListener("click",s.clickListener=function(){ Homepage.startAdd(s) });
		});
		Homepage.renderResizeControls();
	},
	stopConfig:function(){
		document.getElementById("configbutton").isActive=false;
		document.querySelectorAll(".closeicon").forEach(function(ci){
			ci.remove();
		});
		document.querySelectorAll(".homepageresize").forEach(function(div){
			div.remove();
		});
		DragDrop.cancel(document.querySelectorAll(".box"),document.querySelectorAll(".boxslot"));
		document.querySelectorAll(".boxslot").forEach(function(s){
			s.removeEventListener("mouseover",s.mouseoverListener);
			s.removeEventListener("mouseout",s.mouseoutListener);
			s.removeEventListener("click",s.clickListener);
		});
	},

	onDrop:function(box, slot){
		let boxWidth = 1;
		let boxHeight = 1;
		let slotRow = 1;
		let slotCol = 1;
		if(box.classList.contains("w2")){ boxWidth=2; }
		if(box.classList.contains("w3")){ boxWidth=3; }
		if(box.classList.contains("h2")){ boxHeight=2; }
		if(box.classList.contains("h3")){ boxHeight=3; }
		if(slot.classList.contains("r2")){ slotRow=2; }
		if(slot.classList.contains("r3")){ slotRow=3; }
		if(slot.classList.contains("c2")){ slotCol=2; }
		if(slot.classList.contains("c3")){ slotCol=3; }
		const bottomEdge = slotRow + boxHeight - 1;
		const rightEdge = slotCol + boxWidth - 1;
		if(bottomEdge>3 || rightEdge>3){
			DragDrop.abortDrop();
			return false;
		}

		//Don't allow bricks to overlap
		let occupied = [];
		occupied.push([-1,-1,-1,-1]);
		occupied.push([-1,0,0,0]);
		occupied.push([-1,0,0,0]);
		occupied.push([-1,0,0,0]);
		document.querySelectorAll(".box").forEach(function(b){
			if(b.id!==box.id) {
				const h = 1 * b.dataset.h;
				const w = 1 * b.dataset.w;
				const r = 1 * b.dataset.r;
				const c = 1 * b.dataset.c;
				for (let row = r; row < r + h; row++) {
					for (let col = c; col < c + w; col++) {
						occupied[row][col] = 1;
					}
				}
			}
		});
		for(let r=slotRow;r<slotRow+boxHeight;r++){
			for(let c=slotCol;c<slotCol+boxWidth;c++){
				if(1===occupied[r][c]){
					DragDrop.abortDrop();
					return false;
				}
			}
		}

		box.classList.remove("r1","r2","r3");
		box.classList.remove("c1","c2","c3");
		if(slot.classList.contains("r1")){ box.classList.add("r1"); box.dataset.r="1"; }
		if(slot.classList.contains("c1")){ box.classList.add("c1"); box.dataset.c="1"; }
		if(slot.classList.contains("r2")){ box.classList.add("r2"); box.dataset.r="2"; }
		if(slot.classList.contains("c2")){ box.classList.add("c2"); box.dataset.c="2"; }
		if(slot.classList.contains("r3")){ box.classList.add("r3"); box.dataset.r="3"; }
		if(slot.classList.contains("c3")){ box.classList.add("c3"); box.dataset.c="3"; }
		box.style.left=null;
		box.style.top=null;
		let patchUrl;
		if(Homepage.isDefaultHomepage){
			patchUrl='/api/homepagedefaultbrick/'+box.dataset.homepagedefaultbrickid;
		} else {
			patchUrl='/api/homepageuserbrick/'+box.dataset.homepageuserbrickid;
		}
		new AjaxUtils.Request(patchUrl, {
			method:"patch",
			postBody:"csrfToken="+csrfToken+"&row="+slotRow+"&col="+slotCol,
			onSuccess:Homepage.drop_onSuccess,
			onFailure:Homepage.drop_onFailure
		});
	},
	drop_onSuccess:function(transport){
		AjaxUtils.checkResponse(transport);
		Homepage.renderResizeControls();
	},
	drop_onFailure:function(transport){
		AjaxUtils.checkResponse(transport);
		Homepage.renderResizeControls();
	},

	removeBrick:function(evt){
		const closeIcon = evt.target;
		const box = closeIcon.closest(".box");
		if(!confirm("Remove the brick?")){
			return false;
		}
		let deleteUrl;
		if(Homepage.isDefaultHomepage){
			deleteUrl='/api/homepagedefaultbrick/'+box.dataset.homepagedefaultbrickid;
		} else {
			deleteUrl='/api/homepageuserbrick/'+box.dataset.homepageuserbrickid;
		}
		new AjaxUtils.Request(deleteUrl, {
			method:"delete",
			postBody:"csrfToken="+csrfToken,
			onSuccess:function(transport){ Homepage.remove_onSuccess(transport,box) },
			onFailure:Homepage.remove_onFailure
		});
	},
	remove_onSuccess:function(transport,box){
		AjaxUtils.checkResponse(transport);
		box.remove();
		Homepage.renderResizeControls();
	},
	remove_onFailure:function(transport){
		AjaxUtils.checkResponse(transport);
		Homepage.renderResizeControls();
	},


	startAdd:function(slot){
		slot.classList.remove("activedroppable");
		ui.modalBox({
			title: "Add brick to homepage",
			content: "Loading..."
		});
		new AjaxUtils.Request("/api/homepagebrick",{
			method:'get',
			onSuccess:function(transport){ Homepage.startAdd_onSuccess(transport, slot); },
			onFailure:Homepage.startAdd_onFailure
		});
	},

	startAdd_onSuccess:function(transport, destinationSlot){
		if(!AjaxUtils.checkResponse(transport)){
			return false;
		}
		const addRow = 1 * destinationSlot.dataset.gridrow;
		const addCol = 1 * destinationSlot.dataset.gridcol;
		//calculate fit/non-fit sizes, then:
		let adminOnlyBricks = [];
		let fittingBricks = [];
		let nonFittingBricks = [];
		let fittingSizes = [];
		let nonFittingSizes = [];
		fittingSizes.push("1x1"); //they clicked on this square, so it's open
		if(addRow>=3){ //double height won't fit on bottom
			nonFittingSizes.push("1x2");
			nonFittingSizes.push("2x2");
			nonFittingSizes.push("3x2");
		}
		if(addRow>=2){ //triple height won't fit in middle row
			nonFittingSizes.push("1x3");
			nonFittingSizes.push("2x3");
			nonFittingSizes.push("3x3");
		}
		if(addCol>=3){ //double width won't fit in right-hand column
			nonFittingSizes.push("2x1");
			nonFittingSizes.push("2x2");
			nonFittingSizes.push("2x3");
		}
		if(addCol>=2){ //triple width won't fit in middle column
			nonFittingSizes.push("3x1");
			nonFittingSizes.push("3x2");
			nonFittingSizes.push("3x3");
		}
		let occupied=[];
		occupied.push([-1,-1,-1,-1]);
		occupied.push([-1,0,0,0]);
		occupied.push([-1,0,0,0]);
		occupied.push([-1,0,0,0]);
		document.querySelectorAll(".homepagebrick").forEach(function(b){
			const h = 1 * b.dataset.h;
			const w = 1 * b.dataset.w;
			const r = 1 * b.dataset.r;
			const c = 1 * b.dataset.c;
			for(let row=r;row<r+h;row++){
				for(let col=c;col<c+w;col++){
					occupied[row][col]=1;
				}
			}
		});
		transport.responseJSON.rows.forEach(function(brick){
			if(!Homepage["bricks"][brick.name]){
				if(isAdmin){
					alert("Brick mismatch: Brick "+brick.name+" exists in the database but not in the brick definitions.\n\n"+
						"If this brick should exist, ensure that its definition file ("+brick.name+".js) exists in client/bricks/ then "+
						"remove homepagebricks.js from IceBear's file cache.\n\n"+
						"If it should not exist, delete its row from the database.\n\n"+
						"Contact support if you need help with this."
					);
				}
				return;
			}
			if(!document.getElementById(brick.name)){
				//not already in the layout
				let brickDefinition=Homepage["bricks"][brick.name];
				const h = brickDefinition.height * 1;
				const w = brickDefinition.width * 1;
				const brickSize = w + "x" + h;
				if(Homepage.isDefaultHomepage && brickDefinition.adminOnly){
					adminOnlyBricks.push(brickDefinition);
				} else if(fittingSizes.indexOf(brickSize)>-1){
					fittingBricks.push(brickDefinition);
				} else if(nonFittingSizes.indexOf(brickSize)>-1){
					nonFittingBricks.push(brickDefinition);
				} else {
					//is brick going to overlap another if added at chosen spot? non-fitting. else fitting.
					let brickFits = true;
					for(let r=addRow;r<=addRow+h-1;r++){
						for(let c=addCol;c<=addCol+w-1;c++){
							if(1===occupied[r][c]){
								brickFits=false;
								break;
							}
						}
						if(!brickFits){ break; }
					}
					if(brickFits){
						fittingBricks.push(brickDefinition);
						fittingSizes.push(brickSize);
					} else {
						nonFittingBricks.push(brickDefinition);
						nonFittingSizes.push(brickSize);
					}
				}
			}
		});
		let mb=document.getElementById("modalBox");
		if(0===fittingBricks.length && 0===nonFittingBricks.length){
			mb.querySelector(".boxbody").innerHTML="No bricks are available to add to your homepage.";
			return;
		}
		mb.querySelector(".boxbody").classList.add("hastable");
		let out='<table>';
		if(0===fittingBricks.length){
			out+='<tr><th colspan="2" style="text-align:center;">No bricks will fit in that slot. These are available but won\'t fit:</th></tr>';
		} else {
			out+='<tr><th colspan="2" style="text-align:center;">Available bricks:</th></tr>';
			fittingBricks.forEach(function(brick){
				let icon='<span title="Default size '+brick.width+"x"+brick.height+' brick" class="bricksizeicon h'+brick.height+"w"+brick.width+'">&nbsp;</span>';
				out+='<tr class="bricklist" id="add_'+brick.name+'"><th>'+icon+'&nbsp;'+brick.title+'</th><td>'+brick.description+'</td></tr>';
			});
			if(0!==nonFittingBricks.length){
				out+='<tr><th colspan="2" style="text-align:center;">These bricks are available but won\'t fit in that slot:</th></tr>';
			}
		}
		nonFittingBricks.forEach(function(brick){
			let icon='<span title="Default size '+brick.width+"x"+brick.height+' brick" class="bricksizeicon h'+brick.height+"w"+brick.width+'">&nbsp;</span>';
			out+='<tr class="bricklist" id="add_'+brick.name+'"><th>'+icon+'&nbsp;'+brick.title+'</th><td>'+brick.description+'</td></tr>';
		});
		if(0!==adminOnlyBricks.length){
			out+='<tr><th colspan="2" style="text-align:center;">These bricks are administrator-only and can\'t be on the default homepage:</th></tr>';
			adminOnlyBricks.forEach(function(brick){
				let icon='<span title="Default size '+brick.width+"x"+brick.height+' brick" class="bricksizeicon h'+brick.height+"w"+brick.width+'">&nbsp;</span>';
				out+='<tr class="bricklist" id="add_'+brick.name+'"><th>'+icon+'&nbsp;'+brick.title+'</th><td>'+brick.description+'</td></tr>';
			});
		}
		out+='</table>';
		mb.querySelector(".boxbody").innerHTML=out;
		fittingBricks.forEach(function(brick){
			let addBrick=document.getElementById('add_'+brick.name);
			addBrick.brick=brick;
			addBrick.style.cursor="pointer";
			addBrick.addEventListener("click", function(){ Homepage.addHomepageBrick(this); } );
			addBrick.addEventListener("mouseover", function(){ this.classList.add("activedroppable"); } );
			addBrick.addEventListener("mouseout", function(){ this.classList.remove("activedroppable"); } );
		});
		mb.activeSlot=destinationSlot;
	},

	startAdd_onFailure:function(boxId,transport){
		let msg=transport.responseText;
		if(transport.responseJSON && transport.responseJSON.error){
			msg=transport.responseJSON.error;
		}
		alert("Could not add brick to homepage. The server said:\n\n"+msg+"\n\nWhen you click OK, the page will reload.");
		ui.forceReload();
	},

	addHomepageBrick: function(tr){
		tr.innerHTML='<th style="text-align:center;" colspan="2">Adding brick to homepage...</th>';
		const brick = tr.brick;
		const addDestination = document.getElementById("modalBox").activeSlot;
		const addRow = addDestination.dataset.gridrow;
		const addCol = addDestination.dataset.gridcol;
		new AjaxUtils.Request(Homepage.apiUrl,{
			method:'post',
			postBody:'homepagebrickid='+brick.id+'&row='+addRow+'&col='+addCol+'&csrfToken='+csrfToken,
			onSuccess:Homepage.addHomepageBrick_onSuccess,
			onFailure:Homepage.addHomepageBrick_onFailure
		});
	},
	addHomepageBrick_onSuccess: function(transport){
		if(!transport.responseJSON || !transport.responseJSON.created){
			return Homepage.addHomepageBrick_onFailure(transport);
		}
		Homepage.renderBrick(transport.responseJSON.created);
		Homepage.stopConfig();
		Homepage.startConfig();
		ui.closeModalBox();
	},
	addHomepageBrick_onFailure: function(transport){
		let msg=transport.responseText;
		if(transport.responseJSON && transport.responseJSON.error){
			msg=transport.responseJSON.error;
		}
		alert("Server reported an error:\n\n"+msg+"\n\nThe page will reload when you click OK.");
		ui.forceReload();
	},

	init:function(isDefaultHomepage){
		Homepage.isDefaultHomepage=isDefaultHomepage;
		Homepage.apiUrl=isDefaultHomepage?"/api/homepagedefaultbrick":"/api/homepageuserbrick";
		new AjaxUtils.Request(Homepage.apiUrl,{
			method:"get",
			onSuccess:Homepage.init_onSuccess,
			onFailure:Homepage.init_onFailure
		});
	},
	init_onSuccess:function(transport){
		AjaxUtils.checkResponse(transport);
		transport.responseJSON.rows.forEach(function(b){
			b.headertemplates=eval(b.headertemplates);
			b.rowtemplates=eval(b.rowtemplates);
			Homepage.renderBrick(b);
		});
		if(Homepage.isDefaultHomepage){ Homepage.startConfig(); }
		ui.smallScreenInit();
	},
	init_onFailure:function(transport){
		AjaxUtils.checkResponse(transport);
	},

	renderBrick:function(brickDimensions){
		/** @namespace isAdmin **//* Defined in page header, IDE warnings hack */
		/** @namespace isTechnician **//* Defined in page header, IDE warnings hack */
		let brickDefinition=Homepage["bricks"][brickDimensions.name];
		let classes="homepagebrick r"+brickDimensions.row+" c"+brickDimensions.col+" w"+brickDimensions.width+" h"+brickDimensions.height;
		if(brickDefinition.adminOnly && (Homepage.isDefaultHomepage || !isAdmin)){
			let content="<h3>Admin-only brick</h3>";
			if(Homepage.isDefaultHomepage){
				content+="<p>This brick is only for administrators. It shouldn't be on the default homepage.</p>";
			} else if(!isAdmin){
				content+="<p>This brick is useless because you're not an administrator.</p>";
			}
			content+='<input type="button" id="" value="Remove brick" class="removebutton"/>';
			let box = grid.box({
				id: brickDimensions.name,
				classes: classes,
				title: brickDimensions.title,
				content: content,
			}).closest(".box");
			box.dataset.r=brickDimensions.row;
			box.dataset.c=brickDimensions.col;
			box.dataset.h=brickDimensions.height;
			box.dataset.w=brickDimensions.width;
			box.dataset.homepagebrickid=brickDimensions.homepagebrickid;
			box.dataset.homepagedefaultbrickid=brickDimensions.id;
			box.classList.add("badAdminOnly");
			box.querySelector(".removebutton").addEventListener("click",Homepage.removeBrick);
			return;
		}
		let box = grid.box({
			id: brickDefinition.name,
			classes: classes,
			title: brickDefinition.title,
			content: brickDefinition.content,
			url: brickDefinition.url ? brickDefinition.url.replace('{{userid}}', currentUser.id) : null,
			getAll: brickDefinition.getAll,
			headers: brickDefinition.headers,
			sortBy: brickDefinition.sortBy,
			sortDescending: brickDefinition.sortDescending,
			cellTemplates: brickDefinition.cellTemplates,
			successHandler: brickDefinition.onSuccess
		}).closest(".box");
		box.dataset.r=brickDimensions.row;
		box.dataset.c=brickDimensions.col;
		box.dataset.h=brickDimensions.height;
		box.dataset.w=brickDimensions.width;
		box.dataset.homepagebrickid=brickDimensions.homepagebrickid;
		if(Homepage.isDefaultHomepage){
			box.dataset.homepagedefaultbrickid=brickDimensions.id;
		} else {
			box.dataset.homepageuserbrickid=brickDimensions.id;
		}
	},

	renderResizeControls:function (){
		document.querySelectorAll(".homepageresize").forEach(function (div){
			div.remove();
		});
		let occupiedBoxes=[
			["X","X","X","X","X"],
			["X","","","","X"],
			["X","","","","X"],
			["X","","","","X"],
			["X","X","X","X","X"],
		];
		document.querySelectorAll(".box").forEach(function(box){
			for(let x=1*box.dataset.c;x<1*box.dataset.c+1*box.dataset.w;x++){
				for(let y=1*box.dataset.r;y<1*box.dataset.r+1*box.dataset.h;y++){
					occupiedBoxes[y][x]="B";
				}
			}
		});
		document.querySelectorAll(".box").forEach(function(box){
			if(box.classList.contains("badAdminOnly")){
				return;
			}
			//Add the width/height handles
			let canSmallerX=!box.classList.contains("w1");
			let canSmallerY=!box.classList.contains("h1");
			let canBigger={
				"top":true,
				"left":true,
				"bottom":true,
				"right":true
			};
			for(let x=1*box.dataset.c;x<1*box.dataset.c+1*box.dataset.w;x++) {
				if(occupiedBoxes[box.dataset.r-1][x]!==""){
					canBigger["top"]=false;
				}
				if(occupiedBoxes[1*box.dataset.r+1*box.dataset.h][x]!==""){
					canBigger["bottom"]=false;
				}
			}
			for(let y=1*box.dataset.r;y<1*box.dataset.r+1*box.dataset.h;y++){
				if(occupiedBoxes[y][box.dataset.c-1]!==""){
					canBigger["left"]=false;
				}
				if(occupiedBoxes[y][1*box.dataset.c+1*box.dataset.w]!==""){
					canBigger["right"]=false;
				}
			}
			let controls={
				//define the arrow controls
				//"side":["bigger","smaller"]
				"top":["\u2bc5","\u2bc6"], //up, down
				"bottom":["\u2bc6","\u2bc5"], //down, up
				"left":["\u2bc7","\u2bc8"], //left, right
				"right":["\u2bc8","\u2bc7"] //right, left
			};
			Object.keys(controls).forEach(function (side){
				let handle=document.createElement("div");
				handle.classList.add("homepageresize");
				handle.classList.add("homepageresize"+side);
				if(canBigger[side]){
					let bigger=document.createElement("span");
					bigger.classList.add("homepageresizebigger");
					bigger.innerHTML=controls[side][0];
					bigger.addEventListener("click", Homepage.resizeBrick);
					handle.appendChild(bigger);
				}
				if((canSmallerX && ("left"===side || "right"===side)) || (canSmallerY && ("top"===side || "bottom"===side)) ){
					let smaller=document.createElement("span");
					smaller.classList.add("homepageresizesmaller");
					smaller.innerHTML=controls[side][1];
					smaller.addEventListener("click", Homepage.resizeBrick);
					handle.appendChild(smaller);
				}
				box.appendChild(handle);
			});
			[".homepageresizebottom",".homepageresizeright"].forEach(function (side){
				let handle=box.querySelector(side);
				if(handle){
					let bigger=handle.querySelector(".homepageresizebigger");
					if(bigger){
						handle.appendChild(bigger);
					}
				}
			});
		});
	},

	resizeBrick:function (evt){
		let btn=evt.target;
		let box=btn.closest(".box");
		let btnWrapper=btn.closest(".homepageresize");
		let r=1*box.dataset.r;
		let c=1*box.dataset.c;
		let w=1*box.dataset.w;
		let h=1*box.dataset.h;
		if(btn.classList.contains("homepageresizebigger")){
			if(btnWrapper.classList.contains("homepageresizetop")){
				r-=1;
				h+=1;
			} else if(btnWrapper.classList.contains("homepageresizebottom")){
				h+=1;
			} else if(btnWrapper.classList.contains("homepageresizeleft")){
				c-=1;
				w+=1;
			} else if(btnWrapper.classList.contains("homepageresizeright")){
				w+=1;
			}
		} else if(btn.classList.contains("homepageresizesmaller")){
			if(btnWrapper.classList.contains("homepageresizetop")){
				r+=1;
				h-=1;
			} else if(btnWrapper.classList.contains("homepageresizebottom")){
				h-=1;
			} else if(btnWrapper.classList.contains("homepageresizeleft")){
				c+=1;
				w-=1;
			} else if(btnWrapper.classList.contains("homepageresizeright")){
				w-=1;
			}
		} else {
			return false;
		}
		let patchUrl;
		if(null!=box.dataset.homepageuserbrickid){
			patchUrl='/api/homepageuserbrick/'+box.dataset.homepageuserbrickid;
		} else {
			patchUrl='/api/homepagedefaultbrick/'+box.dataset.homepagedefaultbrickid;
		}
		new AjaxUtils.Request(patchUrl, {
			method:"patch",
			postBody:"csrfToken="+csrfToken+"&row="+r+"&col="+c+"&height="+h+"&width="+w,
			onSuccess:Homepage.resizeBrick_onSuccess,
			onFailure:Homepage.resizeBrick_onFailure
		});
	},
	resizeBrick_onSuccess:function (xhr){
			let response=xhr.responseJSON;
			let brick=document.querySelector("[data-homepageuserbrickid='"+response["id"]+"']");
			if(!brick){
				brick=document.querySelector("[data-homepagedefaultbrickid='"+response["id"]+"']");
			}
			if(!brick){
				alert("Bad brick ID came back from server.");
			}
			brick.remove();
			Homepage.renderBrick(response);
			Homepage.stopConfig();
			Homepage.startConfig();
    },
	resizeBrick_onFailure:function (xhr){
		alert(xhr.status+": "+xhr.responseText)

	},


}


/*********************************************
 * Permissions
 *********************************************/
let Permission={
	writePermissions:function(transport){
		let permissionsBody=document.getElementById("permissions_body");
		if(!permissionsBody){
			alert("Could not write permissions - no permissions_body element");
			return;
		} else if(!transport.responseJSON){
			permissionsBody.innerHTML="Could not understand reply from server:</br></br>"+transport.responseText;
			return;
		}
		permissionsBody.innerHTML="";
		permissionsBody.classList.add("hastable");
		let tbl=document.createElement("table");
		let tr=document.createElement("tr");
		let th=document.createElement("th");
		th.innerHTML='Usergroup';
		tr.appendChild(th);
		th=document.createElement("th");
		th.innerHTML='Access';
		tr.appendChild(th);
		th=document.createElement("th");
		th.colSpan=3;
		if(1*(""+data['isarchived'])){
			th.innerHTML='&nbsp;';
		} else {
			th.innerHTML='Change access rights';
		}
		tr.appendChild(th);
		tbl.appendChild(tr);
		let mainObjectType=data["objecttype"];
		let canChangePermissions=false;
		if("project"===mainObjectType){
			canChangePermissions=(isAdmin || parseInt(data['owner'])===parseInt(userId));
		}
		permissionsBody.appendChild(tbl);
		transport.responseJSON.rows.forEach(function(rowData){
			if(!Project.showArchivedProjects && 1===parseInt(rowData['isarchived'])){
				return;
			}
			tr=document.createElement("tr");
			tr.classList.add("datarow");
			tr.rowData=rowData;
			tbl.appendChild(tr);
			if("project"===mainObjectType){
				tr.dataset.projectId=data['id'];
				tr.dataset.isArchivedProject=""+data['isarchived'];
				tr.dataset.usergroupId=rowData['id'];
				tr.dataset.projectName=data['name'];
				tr.dataset.usergroupName=rowData['name'];
			} else if("usergroup"===mainObjectType){
				tr.dataset.projectId=rowData['id'];
				tr.dataset.isArchivedProject=""+rowData['isarchived'];
				tr.dataset.usergroupId=data['id'];
				tr.dataset.projectName=rowData['name'];
				tr.dataset.usergroupName=data['name'];
				canChangePermissions=(isAdmin || parseInt(rowData['owner'])===parseInt(userId));
			}
			Permission.updatePermissionRow(tr);
		});
		tbl.querySelectorAll("tr").forEach(function(tr){
			let div=document.createElement("div");
			div.classList.add("border");
			tr.appendChild(div);
		});
	},

	updatePermissionRow:function(tr){
		let mainObjectType=data["objecttype"];
		let isArchivedProject=1*tr.dataset.isArchivedProject;
		let rowData=tr.rowData;
		tr.innerHTML="";
		let th=document.createElement("th");
		th.innerHTML='<a href="/'+rowData["objecttype"]+'/'+rowData.id+'">'+rowData.name+'</a>';
		tr.appendChild(th);
		let canRead=false;
		let canWrite=false;
		let message;
		let noAccessButton='&nbsp;';
		let readAccessButton='&nbsp;';
		let writeAccessButton='&nbsp;';
		if(groupNames["ADMINISTRATORS"]===rowData.name || ("usergroup"===mainObjectType && groupNames["ADMINISTRATORS"]===data.name)){
			canRead=true;
			canWrite=!isArchivedProject;
			if(isArchivedProject){
				message='Project is archived. Administrators have read access.';
			} else {
				message='Administrators always have full read and write access';
			}
		} else if(groupNames["SHIPPERS"]===rowData.name || ("usergroup"===mainObjectType && groupNames["SHIPPERS"]===data.name)){
			canRead=true;
			canWrite=false;
			if(isArchivedProject){
				message='Project is archived. Shippers have read access.';
			} else {
				message='Shippers always have read access so that they can ship crystals';
			}
		} else {
			if(""!==rowData["permissions"]["read"]){ canRead=true; }
			if(""!==rowData["permissions"]["update"]){ canWrite=true; }
			let isProjectOwner=false;
			if( ("project"===mainObjectType && parseInt(data['owner'])===parseInt(userId)) || ("usergroup"===mainObjectType && parseInt(tr.rowData['owner'])===parseInt(userId)) ){
				isProjectOwner=true;
			}
			if(!isArchivedProject &&(isAdmin || isProjectOwner)){
				if(canRead || canWrite){ noAccessButton='<input class="noaccessbutton" type="button" value="Revoke access" onclick="Permission.setNoAccess(this)"/>'; }
				if(!canRead || canWrite){ readAccessButton='<input class="readaccessbutton" type="button" value="Give read-only access" onclick="Permission.setReadAccess(this)"/>'; }
				if(!canWrite){ writeAccessButton='<input class="fullaccessbutton" type="button" value="Give full access" onclick="Permission.setFullAccess(this)"/>'; }
			} else {
				//Read-only permissions, cannot edit
				//message='Group "Group name" has no access to project "Project"', etc.
				if(isArchivedProject){
					if(canRead){
						message='Project "'+tr.dataset.projectName+'" is archived. Group "'+tr.dataset.usergroupName+'" has read access.';
					} else {
						message='Project "'+tr.dataset.projectName+'" is archived. Group "'+tr.dataset.usergroupName+'" has no access.';
					}
				} else if(canWrite){
					message='Group "'+tr.dataset.usergroupName+'" has full access to project "'+tr.dataset.projectName+'"';
				} else if (canRead){
					message='Group "'+tr.dataset.usergroupName+'" has read access to project "'+tr.dataset.projectName+'"';
				} else {
					message='Group "'+tr.dataset.usergroupName+'" has no access to project "'+tr.dataset.projectName+'"';
				}
			}
		}
		let td=document.createElement("td");
		if(isArchivedProject && canRead){
			td.innerHTML="Read-only access";
		} else if(canWrite){
			td.innerHTML="Full access";
		} else if(canRead){
			td.innerHTML="Read-only access";
		} else {
			td.innerHTML="No access";
		}
		tr.appendChild(td);
		if(message){
			td=document.createElement("td");
			td.colSpan=3;
			td.innerHTML=message;
			tr.appendChild(td);
		} else {
			td=document.createElement("td");
			td.innerHTML=noAccessButton;
			tr.appendChild(td);
			td=document.createElement("td");
			td.innerHTML=readAccessButton;
			tr.appendChild(td);
			td=document.createElement("td");
			td.innerHTML=writeAccessButton;
			tr.appendChild(td);
		}

		let bgColor;
		let textColor;
		if(isArchivedProject && canRead) {
			bgColor = "#ffd";
			textColor = "#f60";
		} else if(canWrite) {
			bgColor="#dfd";
			textColor="#090";
		} else if(canRead) {
			bgColor="#ffd";
			textColor="#f60";
		} else {
			bgColor="#fdd";
			textColor="#c00";
		}
		tr.style.backgroundColor=bgColor;
		tr.querySelector("th+td").style.color=textColor;
		tr.querySelector("th+td").style.fontWeight="bold";
	},

	setNoAccess:function (btn){
		let tr=btn.closest("tr");
		let readPermissionId=tr.rowData['permissions']["read"];
		if(!readPermissionId){ readPermissionId=tr.rowData['permissions']["create"]; }
		if(!readPermissionId){ readPermissionId=tr.rowData['permissions']["update"]; }
		if(!readPermissionId){ readPermissionId=tr.rowData['permissions']["delete"]; }
		if(!readPermissionId){ alert("Already set to no access"); return false; }
		tr.classList.add("updating");
		AjaxUtils.request("/api/permission/"+readPermissionId,{
			method:"delete",
			postBody:'csrfToken='+csrfToken,
			onSuccess:function(transport){
				Permission.afterSetAccess(transport,tr);
			},
			onFailure:Permission.onSetAccessFailure
		});
	},
	setReadAccess:function (btn){
		let tr=btn.closest("tr");
		tr.classList.add("updating");
		//has read, so remove update/create/delete
		if(""!==tr.rowData["permissions"]["read"]){
			//has read, so remove update/create/delete
			let permissionId=tr.rowData["permissions"]["create"];
			if(!permissionId){ permissionId=tr.rowData["permissions"]["update"]; }
			if(!permissionId){ permissionId=tr.rowData["permissions"]["delete"]; }
			if(!permissionId){
				alert("Read-only access already set");
				return;
			}
			AjaxUtils.request("/api/permission/"+permissionId,{
				method:"delete",
				postBody:'csrfToken='+csrfToken,
				onSuccess:function(transport){
					Permission.afterSetAccess(transport,tr);
				},
				onFailure:Permission.onSetAccessFailure
			});
		} else {
			//does not have read, so create only read
			AjaxUtils.request("/api/permission/",{
				method:"post",
				postBody:"csrfToken="+csrfToken+"&projectid="+tr.dataset.projectId+"&usergroupid="+tr.dataset.usergroupId+"&type=read",
				onSuccess:function(transport){
					Permission.afterSetAccess(transport,tr);
				},
				onFailure:Permission.onSetAccessFailure
			});
		}
	},
	setFullAccess:function (btn){
		let tr=btn.closest("tr");
		tr.classList.add("updating");
		AjaxUtils.request("/api/permission/",{
			method:"post",
			postBody:"csrfToken="+csrfToken+"&projectid="+tr.dataset.projectId+"&usergroupid="+tr.dataset.usergroupId+"&type=update",
			onSuccess:function(transport){
				Permission.afterSetAccess(transport,tr);
			},
			onFailure:Permission.onSetAccessFailure
		});
	},

	afterSetAccess:function(transport, tr){
		//set its rowData.permissions according to response
		if(transport.responseJSON["deleted"]){
			transport.responseJSON["deleted"].forEach(function(deleted){
				tr.rowData["permissions"][deleted["type"]]="";
			});
		}
		if(transport.responseJSON["created"]){
			transport.responseJSON["created"].forEach(function(created){
				tr.rowData["permissions"][created["type"]]=created["id"];
			});
		}
		window.setTimeout(function () {
			Permission.updatePermissionRow(tr);
			tr.classList.remove("updating");
		}, 50);
	},
	onSetAccessFailure:function(transport){
		let msg="";
		if(transport.responseJSON && transport.responseJSON.error){
			msg="\n\n("+transport.responseJSON.error+")";
		}
		alert("Could not set permissions correctly.\nWhen you click OK, the page will reload."+msg);
		document.location.reload();
	}


}

ui.Map={

	markerColor:"red",

	mapTab:function(tabSet, mapOptions){
		let mapTab=tabSet.tab({ 'id':'map', 'label':'Map', 'content':'' });
		document.getElementById("map").onclick=function() {
			ui.Map.init(mapTab.nextElementSibling, mapOptions);
		};
		return mapTab;
	},

	init:function(parentElement, options){

		let canEdit=!(options && "readonly" in options && options["readonly"]);

		parentElement.innerHTML='';
		if(typeof L =="undefined"){
			ui.errorMessageBar('Cannot render map because Leaflet JS and CSS are not included',parentElement);
			return;
		}
		let mapDiv=parentElement.querySelector(".mapDiv");
		if(!mapDiv){
			mapDiv=document.createElement("div");
			mapDiv.classList.add("mapDiv");
			mapDiv.style.height="100%";
			mapDiv.style.width="100%";
			parentElement.appendChild(mapDiv);
			parentElement.style.padding="0";
		}
		//defaults
		let lat=50;
		let lon=20;
		let zoom=3;
		let radius=100;
		let marker=null;

		if(options && options["geofence"]){
			zoom=15;
			let geofence=options["geofence"];
			//if(typeof geofence == "string"){ geofence=geofence.split(','); }
			marker=ui.Map.getMarker(geofence);
			let center=ui.Map.calculateCenterPosition(geofence);
			lat=center["lat"];
			lon=center["lon"];
			if(3===geofence.length){
				radius=geofence[2];
			}
		}

		let osmUrl='https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
		let osm = new L.TileLayer(osmUrl, {minZoom:2, maxZoom:19});
		let map=L.map(mapDiv, { center:[lat, lon], zoom:zoom });
		map.addLayer(osm);
		if(marker){
			mapDiv.marker=marker;
			marker.addTo(map);
		}
		mapDiv.map=map;

		if(canEdit){
			parentElement.style.paddingTop="3em";
			let f=parentElement.form({});
			f.style.position="absolute";
			f.style.top="0.5em";
			f.style.left="0.5em";
			f.style.right="0.5em";
			let ff=f.formField({
				"label":"Click here to edit position",
				"content":"&nbsp;",
			})
			ff.addEventListener("click",ui.Map.startEdit);
			f.hiddenField("lat", lat);
			f.hiddenField("lon", lon);
			f.hiddenField("radius", radius);
			if(!options || !options["geofence"]){
				f.querySelector("[name=lat]").value="";
				f.querySelector("[name=lon]").value="";
				ff.click();
			}
		}
	},

	radiusIncrement:25,
	minRadius:50,
	maxRadius:1000,

	startEdit: function(evt){
		let container=evt.target.closest(".boxbody, .tabbody");
		let mapWrapper=container.querySelector(".mapDiv");
		let map=mapWrapper.map;
		map.on("click", ui.Map.setPosition);
		let frm=container.querySelector("form");
		let lbl=frm.querySelector("label");
		lbl.remove();
		let radius=frm.querySelector("#radius").value;
		if(data.geofence && ""!==data.geofence){
			let parts=data.geofence.split(",");
			if(3===parts.length){ radius=parts[2]; }
		}
		frm.querySelector("#radius").remove();
		let html="Click the map to set position, then adjust the radius here: ";
		html+='<input type="number" max="'+ui.Map.maxRadius+'" min="'+ui.Map.minRadius+'" '+
			'step="'+ui.Map.radiusIncrement+'" value="'+radius+'" name="radius"/>';
		lbl=frm.formField({
			"label":html,
			"content":"&nbsp;"
		});
		lbl.querySelector("input").addEventListener("change", ui.Map.startUpdateTimer);
	},

	setPosition: function(evt){
		let lat=evt.latlng.lat.toFixed(5);
		let lon=evt.latlng.lng.toFixed(5);
		let container=evt.target._container;
		container=container.closest(".boxbody, .tabbody");
		let frm=container.querySelector("form");
		frm.lat.value=lat;
		frm.lon.value=lon;
		ui.Map.redrawGeofence(container);
		ui.Map.saveGeofence(container);
	},

	startUpdateTimer:function(evt){
		let container=evt.target.closest(".boxbody, .tabbody");
		if(container.timeout){
			window.clearTimeout(container.timeout);
		}
		container.timeout=window.setTimeout(function(){
			ui.Map.saveGeofence(container);
		}, 500);
		ui.Map.redrawGeofence(container);
	},

	redrawGeofence:function(container){
		let frm=container.querySelector("form");
		let lat=frm["lat"].value;
		let lon=frm["lon"].value;
		let radius=1*frm["radius"].value;
		let mapDiv=container.querySelector(".mapDiv");
		if(mapDiv.marker){
			mapDiv.map.removeLayer(mapDiv.marker);
		}
		let marker=ui.Map.getMarker([lat,lon,radius]);
		mapDiv.marker=marker;
		marker.addTo(mapDiv.map);
	},

	saveGeofence:function(container){
		let frm=container.querySelector("form");
		let lat=frm["lat"].value;
		let lon=frm["lon"].value;
		if(""===lat || ""===lon){ return; }
		let radius=1*frm["radius"].value;
		if(radius<ui.Map.minRadius || radius>ui.Map.maxRadius){
			return;
		}
		if(lat<-90 || lat>90 || lon<-180 || lon>180){
			return;
		}
		let geofence=lat+","+lon+","+radius;
		frm.querySelector("label").classList.add("updating");
		new AjaxUtils.Request('/api/shipmentdestination/'+data.id,{
			method:"patch",
			parameters:{
				'csrfToken':csrfToken,
				'geofence':geofence
			},
			onSuccess:function (){
				container.querySelector("label").classList.remove("updating");
			},
			onFailure:function (transport){
				AjaxUtils.checkResponse(transport);
			}
		});
	},

	calculateCenterPosition:function(geofence){
		if(!geofence){ return null; }
		if(typeof geofence == "string"){ geofence=geofence.split(','); }
		if(3===geofence.length){
			return {"lat":geofence[0], "lon":geofence[1] };
		}
		let maxLat=0, minLat=90, maxLon=0, minLon=180;
		for(let i=0;i<geofence.length;i+=2){
			maxLat=Math.max(maxLat, geofence[i]);
			minLat=Math.min(minLat, geofence[i]);
			maxLon=Math.max(maxLon, geofence[i+1]);
			minLon=Math.min(minLon, geofence[i+1]);
		}
		return { "lat":(maxLat+minLat)/2, "lon":(maxLon+minLon)/2 };
	},

	getMarker:function(geofence){
		if(typeof geofence == "string"){ geofence=geofence.split(','); }
		if(3===geofence.length){
			return L.circle([1*geofence[0], 1*geofence[1]], 1*geofence[2], { 'color':ui.Map.markerColor });
		}
		let points=[];
		for(let i=0;i<geofence.length;i+=2){
			points.push([1*geofence[i],1*geofence[i+1]]);
		}
		return  L.polygon(points, { 'color':ui.Map.markerColor });
	}
}

let Project={

	showArchivedProjects:true,

	setShowArchivedProjectsFromProjectList:function(clicked, show){
		Project.setShowArchivedProjects(clicked, show, function(){ document.location.reload(); });
		return false;
	},

	setShowArchivedProjectsFrom404:function(evt){
		Project.setShowArchivedProjects(evt.target, 1, function(){ document.location.reload(); });
		return false;
	},

	setShowArchivedProjects:function(clicked, show, doAfter){
		if(1*Project.showArchivedProjects===1*show){ return false; }
		clicked.closest("th,td,tr,a,label").classList.add("updating");
		new AjaxUtils.Request("/api/project/"+window["sharedProject"]["id"],{
			"method":"patch",
			"parameters":{ "showArchivedProjects":1*show },
			"onSuccess":function (xhr){
				clicked.closest("th,td,tr,a,label").classList.remove("updating");
				AjaxUtils.checkResponse(xhr);
				doAfter(clicked);
			},
			"onFailure":function (xhr){
				clicked.closest("th,td,tr,a,label").classList.remove("updating");
				AjaxUtils.checkResponse(xhr);
			}
		})
	},

	archive:function(evt){
		if(!isAdmin && (!data || !data["owner"] || 1*data["owner"]!==1*userId)){
			alert("Only administrators and the project owner can archive projects.");
			return false;
		}
		let mb=ui.modalBox({
			"title":"Archiving a project",
			"content":""
		});
		ui.warningMessageBar("You are about to archive this project: "+data["name"],mb);
		mb.innerHTML+=
				"<p>This will make the project <strong>read-only</strong> for everyone who can see it, including " +
				"administrators and the project owner. No changes can be made to the project, or to anything in it, " +
				"once it has been archived.</p>"+
				"<p>The project, and everything in it, will be hidden from everyone by default, including administrators " +
				"and the project owner. Anyone who can currently see the project will be able to view it after clicking " +
				"\"Show archived projects\" in the project list.</p>"+
				"<p>Only an administrator can un-archive an archived project.</p>"
		let frm=mb.form({});
		frm.buttonField({
			"label":"Archive this project",
			"onclick":function(evt){ Project.setArchivedStatus(evt.target, 1); }
		});
		return false;
	},
	unArchive:function (evt){
		if(!isAdmin){
			alert("Only administrators can un-archive projects.");
			return false;
		}
		let mb=ui.modalBox({
			"title":"Un-archiving a project",
			"content":""
		});
		ui.warningMessageBar("You are about to un-archive this project: "+data["name"],mb);
		mb.innerHTML+=
			"<p>This will <strong>make the project writable</strong> for anyone with the relevant permissions. Review " +
			"the information on the Permissions tab, and edit it if needed, after un-archiving the project.</p>"
		let frm=mb.form({});
		frm.buttonField({
			"label":"Un-archive this project",
			"onclick":function(evt){ Project.setArchivedStatus(evt.target, 0); }
		});
		return false;
	},
	setArchivedStatus:function (clicked, status){
		let lbl=clicked.closest("label");
		if(lbl.classList.contains("updating")){ return false; }
		lbl.classList.add("updating");
		new AjaxUtils.Request("/api/project/"+data["id"],{
			method:"patch",
			parameters:{ "isarchived":1*status },
			onSuccess:function (){ document.location.reload(); },
			onFailure:function (xhr){
				lbl.classList.remove("updating");
				AjaxUtils.checkResponse(xhr);
			}
		});
		return false;
	},

	/**
	 * Returns a link to the item's project. The default project is not visible to most users, but they may have plates that are
	 * in that project. Giving them a clickable link encourages them to click it and see a 404, so we don't link to it.
	 * @param item
	 * @returns string
	 */
	getLink:function (item){
		let id=1*item["projectid"] ? 1*item["projectid"] : 1*item["id"];
		let name=item["projectname"] ? item["projectname"] : item["name"];
		if(-1!==userReadProjects.indexOf(id)){
			return '<a href="/project/'+id+'/">'+name+'</a>';
		}
		return name;
	}
}

let BackgroundImages={

	USERCONFIG_NAME:"backgroundImage",
	ADD_IMAGE:"Add image...",
	NONE:"(None)",

	writeThumbnails(ownerId, wrapper){
		wrapper.classList.add("updating","backgroundswrapper");
		let uri='/api/backgroundimage/userid/'+ownerId;
		wrapper.dataset.ownerId=1*ownerId+"";
		let shim=document.createElement("div");
		shim.classList.add("shim");
		new AjaxUtils.Request(uri,{
			method:"get",
			onSuccess:function (xhr){
				wrapper.classList.remove("updating");
				BackgroundImages.writeNoneAndAddThumbnails(wrapper);
				xhr.responseJSON.rows.forEach(function(img){
					BackgroundImages.writeThumbnail(img, wrapper);
				});
				wrapper.appendChild(shim);
			},
			onFailure:function (xhr){
				wrapper.classList.remove("updating");
				if(404!==xhr.status){
					wrapper.innerHTML="Could not retrieve images from server.";
				} else {
					BackgroundImages.writeNoneAndAddThumbnails(wrapper);
					wrapper.appendChild(shim);
				}
			}
		});
	},

	writeNoneAndAddThumbnails:function (wrapper){
		if(wrapper.classList.contains("hasNone")) {
			BackgroundImages.writeThumbnail(BackgroundImages.NONE, wrapper);
		}
		if(wrapper.classList.contains("hasAdd")){
			BackgroundImages.writeThumbnail(BackgroundImages.ADD_IMAGE, wrapper);
		}
	},

	writeThumbnail:function(imageOrText, wrapper){
		let div=document.createElement("div");
		div.classList.add("image");
		if(BackgroundImages.NONE===imageOrText){
			div.innerText=imageOrText;
			div.dataset.imageId="0";
			div.classList.add("imagenone");
			if(wrapper.classList.contains("isConfig")) {
				div.addEventListener("click", BackgroundImages.setDefaultBackgroundImage);
			} else {
				div.addEventListener("click", BackgroundImages.setOwnBackgroundImage);
			}
		} else if(BackgroundImages.ADD_IMAGE===imageOrText){
			div.classList.add("imageadd");
			div.innerText=imageOrText;
			let f=document.createElement("input");
			f.name="image";
			f.type="file";
			f.addEventListener("change",BackgroundImages.addBackgroundImage);
			div.appendChild(f);
		} else if(imageOrText["id"]){
			div.dataset.imageId=""+imageOrText["id"];
			div.style.backgroundImage="url(/api/backgroundthumb/"+imageOrText["id"]+")";

			if(wrapper.classList.contains("isConfig")){
				div.addEventListener("click", BackgroundImages.setDefaultBackgroundImage);
			} else if(wrapper.classList.contains("imagesLibrary") || wrapper.classList.contains("ownImages")){
				div.addEventListener("click", BackgroundImages.setOwnBackgroundImage);
			}

			if(wrapper.classList.contains("isConfig") || wrapper.classList.contains("ownImages") || wrapper.classList.contains("userImages")) {
				let del=document.createElement("div");
				del.classList.add("imagedelete");
				del.addEventListener("click", BackgroundImages.deleteBackgroundImage);
				div.appendChild(del);
			}
		} else {
			return false;
		}
		if(1*div.dataset.imageId===defaultBackgroundImageId && wrapper.classList.contains("isConfig")){
			div.classList.add("current");
		} else if (1*div.dataset.imageId===backgroundImageId && !wrapper.classList.contains("isConfig")){
			div.classList.add("current");
		}
		if(wrapper && wrapper.querySelector(".imageadd")){
			wrapper.querySelector(".imageadd").insertAdjacentElement("beforebegin", div);
		} else if(wrapper){
			wrapper.appendChild(div);
		}
		return div;
	},

	setDefaultBackgroundImage:function(evt){
		let img=evt.target;
		let imageId=img.dataset.imageId;
		if(imageId===backgroundImageId){ return; }
		new AjaxUtils.Request('/api/config/core_backgroundimage_default',{
			method:"patch",
			parameters:{ "core_backgroundimage_default":imageId },
			onSuccess:function (){ BackgroundImages._setImageOnSuccess(img); },
			onFailure:function (){
				alert("Could not set background image.");
			},
		});
	},

	setOwnBackgroundImage:function(evt){
		let img=evt.target;
		let imageId=img.dataset.imageId;
		if(imageId===backgroundImageId){ return; }
		UserConfig.set(BackgroundImages.USERCONFIG_NAME, imageId, function (){ BackgroundImages._setImageOnSuccess(img); });
	},

	_setImageOnSuccess:function(img){
		img.closest(".boxbody,.tabbody").querySelector(".current").classList.remove("current");
		img.classList.add("current");
		backgroundImageId=img.dataset.imageId;
		if(backgroundImageId){
			document.body.style.backgroundImage="url(/api/backgroundimage/"+backgroundImageId+")";
		} else {
			document.body.style.backgroundImage="none";
		}
	},

	addBackgroundImage:function (evt){
		let img=evt.target.closest(".image");
		if(!img){ return false; }
		let fileField=img.querySelector("input")
		let wrapper=img.parentElement;
		img.style.backgroundRepeat="repeat";
		let formData=new FormData();
		formData.append('image', fileField.files[0], fileField.files[0].name);
		if(wrapper.classList.contains("isConfig")){
			formData.append('common','common');
		}
		formData.append('csrfToken', encodeURIComponent(csrfToken));
		let xhr=new XMLHttpRequest();
		let method="post";
		xhr.open(method, "/api/backgroundimage", true);
		xhr.onload=function(){
			try{
				xhr.responseJSON=JSON.parse(xhr.responseText);
			} catch(ex) {
				alert("Could not understand reply from server:\n\n"+xhr.responseText);
				return false;
			}
			if(200===xhr.status || 201===xhr.status){
				if(xhr.responseJSON.created){
					BackgroundImages.writeThumbnail(xhr.responseJSON.created, wrapper);
				} else {
					alert("Could not understand reply from server:\n\n"+xhr.responseText);
				}
			} else if(401===xhr.status){
				ui.handleSessionExpired();
			} else if(xhr.responseJSON.error){
				alert("Image upload failed. The server said:\n\n"+xhr.responseJSON.error);
			} else {
				alert("Image upload failed. The server said:\n\n"+xhr.responseText);
			}
		};
		xhr.send(formData);
	},

	deleteBackgroundImage:function(evt){
		evt.stopPropagation();
		if(!confirm("Really delete the image?")){ return false; }
		let img=evt.target.closest(".image");
		new AjaxUtils.Request("/api/backgroundimage/"+img.dataset.imageId,{
			"method":"delete",
			"postBody":"csrfToken="+csrfToken,
			"onSuccess":function (){
				if(img.classList.contains("current")){
					backgroundImageId=0;
					document.body.style.backgroundImage="none";
					let none=img.closest(".tabbody").querySelector('.imagenone');
					if(none){ none.classList.add("current"); }
				}
				img.remove();
			},
			"onFailure":function(xhr){
				AjaxUtils.checkResponse();
			}
		});
		return false;
	},

	writeUserImagesTab:function (tabSet){
		if("user"!==data["objecttype"]){ return; }
		let isCurrentUser=(1*userId===1*data["id"]);
		if(!isAdmin && (!isCurrentUser || !canChooseBackgrounds)){ return;	}
		let t=tabSet.tab({
			id:"backgrounds",
			label:"Backgrounds",
			content:""
		}).nextElementSibling;
		t.innerHTML="";

		if(isCurrentUser && canChooseBackgrounds){
			let imagesLibrary=t.treeItem({
				"header":"Backgrounds library"
			});
			imagesLibrary.querySelector(".treehead").click();
			imagesLibrary=imagesLibrary.querySelector(".treebody");
			imagesLibrary.classList.add("hasNone","imagesLibrary");
			BackgroundImages.writeThumbnails(0, imagesLibrary);
		}

		let usersImages=null;
		if(isCurrentUser && canUploadBackgrounds){
			usersImages=t.treeItem({"header":"My backgrounds"});
			usersImages=usersImages.querySelector(".treebody");
			usersImages.classList.add("hasAdd","ownImages");
			BackgroundImages.writeThumbnails(data["id"], usersImages);
		} else if(isAdmin){
			usersImages=t.treeItem({"header":data["fullname"]+"'s backgrounds"});
			usersImages=usersImages.querySelector(".treebody");
			if(!canUploadBackgrounds){
				ui.infoMessageBar('Config prohibits user from selecting these backgrounds.',usersImages);
			}
			usersImages.classList.add("userImages");
			BackgroundImages.writeThumbnails(data["id"], usersImages);
			window.setTimeout(function (){
				if(!usersImages.querySelector(".image")){
					ui.infoMessageBar('User has no personal backgrounds.',usersImages);
				}
			},1000);
		}

	}

}