Index: /main/trunk/greenstone3/src/java/org/greenstone/gsdl3/service/GS2Construct.java
===================================================================
--- /main/trunk/greenstone3/src/java/org/greenstone/gsdl3/service/GS2Construct.java	(revision 31536)
+++ /main/trunk/greenstone3/src/java/org/greenstone/gsdl3/service/GS2Construct.java	(revision 31537)
@@ -29,4 +29,6 @@
 import java.util.Map;
 import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import org.apache.log4j.Logger;
@@ -42,4 +44,11 @@
 import org.greenstone.gsdl3.util.UserContext;
 import org.greenstone.gsdl3.util.XMLConverter;
+
+// https://developer.android.com/reference/org/json/JSONObject.html
+// https://developer.android.com/reference/org/json/JSONArray.html
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
 
 import org.w3c.dom.Document;
@@ -311,8 +320,37 @@
 	protected Element processModifyMetadata(Element request)
 	{
-	    if (!userHasCollectionEditPermissions(request)) {
+
+	    // There are two types of operations whereby metadata gets modified:
+	    // - document including document-meta editing: user needs document editing powers
+	    // - adding user comments: user just needs an account and needs to be logged in
+	    // We handle both cases in this service.
+
+	    Element param_list = (Element) GSXML.getChildByTagName(request, GSXML.PARAM_ELEM + GSXML.LIST_MODIFIER);
+	    HashMap<String, Serializable> params = GSXML.extractParams(param_list, false);
+
+	    // If a user is only adding comments, they don't need to have editing powers over a collection
+	    // but they need to be logged in
+	    String[] docids = isAddingUserComments(request, params);
+	    boolean isAddingUserComments = (docids == null) ? false : true;
+
+	    if(isAddingUserComments) { // adding user comments, check if user logged in
+		UserContext context = new UserContext(request);
+
+		// A restricted set of metadata is modifiable when adding user comments:
+		// only the username, usertimestamp and usercomment metadata fields.
+		// If that's all that's being modified, isAddingUserComments() would have returned true,
+		// so finally check if the caller is logged in as a user.
+		if (context.getUsername().equals("")) { 
+		    Document result_doc = XMLConverter.newDOM();
+		    Element result = GSXML.createBasicResponse(result_doc, "processModifyMetadata");
+		    GSXML.addError(result, "Cannot add user comments if not logged in."); // or if attempting to set meta not related to user comments.
+		    return result; // not logged in
+		}
+
+	    }
+	    else if (!userHasCollectionEditPermissions(request, params)) {
 		Document result_doc = XMLConverter.newDOM();
 		Element result = GSXML.createBasicResponse(result_doc, "processModifyMetadata");
-		GSXML.addError(result, "This user does not have the required permissions to perform this action.");
+		GSXML.addError(result, "This user does not have the required permissions to perform this action."); // also get here if user was attempting to set meta not related to user comments.
 		return result;
 	    }
@@ -323,5 +361,5 @@
 		
 		// process
-		Element response = runCommand(request, GS2PerlConstructor.MODIFY_METADATA_SERVER);
+		Element response = runCommand(request, GS2PerlConstructor.MODIFY_METADATA_SERVER, docids);
 		
 		if (response.getElementsByTagName(GSXML.ERROR_ELEM).getLength() <= 0) // if no errors, wait for process to finish
@@ -666,8 +704,12 @@
 	}
 
+	protected Element runCommand(Element request, int type) {
+		return runCommand(request, type, null);
+	}
+
 	/** returns a response element */
-	protected Element runCommand(Element request, int type)
-	{
-	  Document result_doc = XMLConverter.newDOM();
+	protected Element runCommand(Element request, int type, String[] docids)
+	{
+		Document result_doc = XMLConverter.newDOM();
 		// the response to send back
 		String name = GSPath.getFirstLink(request.getAttribute(GSXML.TO_ATT));
@@ -757,11 +799,10 @@
 			
 		    String oid = null;
-		    
+
 		    while (i.hasNext()) {
 
 				Map.Entry<String, Serializable> entry = i.next();
 				String paramname = entry.getKey();
-				paramname = paramname.replace("s1.", ""); // replaces all
-															// occurrences
+				paramname = paramname.replace("s1.", ""); // replaces all occurrences
 				if (paramname.equals("collection")) {
 					paramname = "c";
@@ -770,4 +811,5 @@
 					oid = (String) entry.getValue();
 				}
+				
 				String paramvalue = (String) entry.getValue();
 
@@ -778,5 +820,20 @@
 		    }
 		    
-		    markDocumentInFlatDatabase("R", coll_name, OID.getTop(oid));
+		    if(oid != null) { // if we have only one oid
+			markDocumentInFlatDatabase("R", coll_name, OID.getTop(oid));
+		    } else if (docids != null) { // check if we are dealing with many doc ids, as cold in theory happen when set-metadata-array is called
+			
+			for(int index = 0; index < docids.length; index++) {
+			    String docid = docids[index];
+			    markDocumentInFlatDatabase("R", coll_name, OID.getTop(docid));
+			}
+		    } else {
+			String msg = getTextString("general.no_valid_docid_error", lang);
+			logger.error("*** " + msg);
+			Text t = result_doc.createTextNode(msg);
+			status.appendChild(t);
+			status.setAttribute(GSXML.STATUS_ERROR_CODE_ATT, Integer.toString(GSStatus.ERROR));
+			return response;
+		    }
 		    
 		    constructor.setQueryString(querystring.toString());
@@ -965,4 +1022,63 @@
 
 
+    protected String[] isAddingUserComments(Element request, HashMap<String, Serializable> params) {
+
+	String metaserver_command = (String) params.get("a"); // e.g. set-archives-metadata or set-metadata-array
+	// quickest test:
+	// if not calling set-metadata-array, then it definitely won't be a set-usercomments operation
+	if(!metaserver_command.equals("set-metadata-array")) {
+	    return null;
+	}
+
+	// Confirm that the set-meta-array operation is only attempting to modify user comments metadata
+
+	String[] docids = null;
+	String json_str = (String) params.get("json"); // will have a "json" field if doing set-meta-array
+	Pattern p = Pattern.compile("^(username|usertimestamp|usercomment)$");
+
+	// check that the name of each that's metadata to be set is one of username|usercomment|usertimestamp
+	// Anything else means something more than adding user comments is being attempted, which is invalid
+	try {
+	    
+	    JSONArray docInfoList = new JSONArray(json_str);
+	    docids = new String[docInfoList.length()];
+	    for(int index = 0; index < docInfoList.length(); index++) {
+		JSONObject docInfo = docInfoList.getJSONObject(index);
+		if(docInfo.has("metatable")) { // should exist for metadata arrays
+		    
+		    docids[index] = docInfo.getString("docid"); // should exist if metatable existed
+
+		    logger.info("@@@ Found docid: " + docids[index]);
+
+		    JSONArray metatable = docInfo.getJSONArray("metatable");
+		    for(int i = 0; i < metatable.length(); i++) {
+			JSONObject meta = metatable.getJSONObject(i);
+
+			String metaname = meta.getString("metaname");
+			logger.info("### metaname: " + metaname);
+			Matcher m = p.matcher(metaname);
+			if(!m.matches()) {
+			    logger.info("### metaname: " + metaname + " doesn't match");
+			    return null;
+			}
+		    }
+		}
+	    }
+	} catch(JSONException jsonex) {
+	    logger.error("Exception when parsing json string: " + json_str);
+	    logger.error(jsonex);
+	    
+	}
+	
+	// if we're here, then it means that the JSON only asked for username|usercomment|usertimestamp meta
+	// meaning that the setmeta operation was a valid user comment operation.
+	// In that case, we have a docid for which we need to add a user comment 
+	// set-metadata-array can take more docids, but doesn't happen for a user comment. And one comment
+	// is added at a time, but 3 meta fields are set for each comment: username, usercomment and timestamp
+	// hence the use of set-meta-array.
+	return docids;
+
+    }
+
     /** Copy from DebugService.userHasEditPermissions
      This function checks that the user is logged in and that the user 
@@ -971,5 +1087,12 @@
 	Element param_list = (Element) GSXML.getChildByTagName(request, GSXML.PARAM_ELEM + GSXML.LIST_MODIFIER);
 	HashMap<String, Serializable> params = GSXML.extractParams(param_list, false);
+
+	return userHasCollectionEditPermissions(request, params);
+
+    }
+
+    protected boolean userHasCollectionEditPermissions(Element request, HashMap<String, Serializable> params) {
 	String collection = (String) params.get(COL_PARAM); // could be null on newcoll operation
+
 
     UserContext context = new UserContext(request);
@@ -977,4 +1100,22 @@
 	return !context.getUsername().equals("");
     }
+
+    // START DEBUG
+    Set<Map.Entry<String, Serializable>> entries = params.entrySet();
+    Iterator<Map.Entry<String, Serializable>> i = entries.iterator();
+    
+    StringBuffer parametersLine = new StringBuffer();
+    while (i.hasNext()) {
+	
+	Map.Entry<String, Serializable> entry = i.next();
+	String paramname = entry.getKey();
+	String paramvalue = (String) entry.getValue();
+	
+	parametersLine.append("\t" + paramname + ": " + paramvalue + "\n");
+    }
+    
+    logger.info("XXXXXXXXXXXXX PARAMETERS:\n" + parametersLine);
+    // END DEBUG
+
     for (String group : context.getGroups()) {
       // administrator always has permission
Index: /main/trunk/greenstone3/web/WEB-INF/classes/GS2Construct.properties
===================================================================
--- /main/trunk/greenstone3/web/WEB-INF/classes/GS2Construct.properties	(revision 31536)
+++ /main/trunk/greenstone3/web/WEB-INF/classes/GS2Construct.properties	(revision 31537)
@@ -59,4 +59,5 @@
 general.configure_constructor_error=Couldn't configure the collection constructor.
 general.process_start=Starting process...
+general.no_valid_docid_error=Both oid and docids are null. Could happen if adding user comments attempted to set non-usercomment meta.
 
 
@@ -65,3 +66,2 @@
 
 
-
Index: /main/trunk/greenstone3/web/WEB-INF/classes/interface_default.properties
===================================================================
--- /main/trunk/greenstone3/web/WEB-INF/classes/interface_default.properties	(revision 31536)
+++ /main/trunk/greenstone3/web/WEB-INF/classes/interface_default.properties	(revision 31537)
@@ -141,4 +141,5 @@
 
 doc.map_nearby_docs=Show documents near here
+
 maps.scrolllabel=Scroll through places
 
@@ -146,4 +147,15 @@
 external.text=The link you have selected is external to any of your currently selected collections. If you still wish to view this link and your browser has access to the Web, you can {0-go-forward-link} to this page; otherwise use your browsers 'back' button to return to the previous document.
 external.go_forward=go forward
+
+#########################################################
+#  User Comments section (available on a document page) #
+#########################################################
+
+usercomments.username=User name
+usercomments.add=Add Comment
+usercomments.submit=Submit Comment
+usercomments.submitted=Comment Submitted
+usercomments.isempty=Comment was empty.
+usercomments.heading=Comments
 
 #####################
Index: /main/trunk/greenstone3/web/interfaces/default/js/javascript-global-functions.js
===================================================================
--- /main/trunk/greenstone3/web/interfaces/default/js/javascript-global-functions.js	(revision 31536)
+++ /main/trunk/greenstone3/web/interfaces/default/js/javascript-global-functions.js	(revision 31537)
@@ -422,5 +422,7 @@
 }
 
-function callMetadataServer(callingFunction, url, responseFunction)
+// No function overloading in JavaScript. Can pass a custom object, however, see
+// http://stackoverflow.com/questions/456177/function-overloading-in-javascript-best-practices
+function callMetadataServer(callingFunction, url, responseFunction, opts)
 {
     var async_setting = true; // Internal processing of 'read' operations (get meta) is not order dependent
@@ -445,4 +447,10 @@
     } // otherwise, such as for get- metadata operation, we proceed as before, which will not require authentication
 
+    if (opts != null && opts["forceSync"] != null) {
+	async_setting = (!opts["forceSync"]);
+    }
+
+    console.log("Away to call: " + url);
+    var ajaxResponse = null;
 
     $.ajax(url, {async: async_setting})
@@ -451,4 +459,6 @@
 		console.log("(" + callingFunction + ") Response received from server: " + response);
 
+	    ajaxResponse = response;
+
 		//var xml = $.parseXML(response);
 		//console.log(xml);
@@ -456,5 +466,6 @@
 		if(responseFunction != null)
 		{
-			responseFunction(response);
+		    
+		    responseFunction(response);
 		}
 	})
@@ -463,4 +474,9 @@
 		console.log("(" + callingFunction + ") Failed");
 	});
+
+    console.log("Finished ajax call to: " + url);
+    
+    console.log("Got response: " + ajaxResponse);
+    return ajaxResponse;
 }
 
@@ -513,8 +529,71 @@
 }
 
+// New. Modified version of the GS2 version of this method in gsajaxapi.js.
+// The where parameter can be specified as one or more of: import, archives, index, live 
+// separated by |. If null, it is assumed to be index which is the original default 
+// behaviour of calling set-metadata-array. E.g. where=import|archives|index
+// THIS METHOD IS SYNCHRONOUS
+gs.functions.setMetadataArray = function(collection, site, docArray, metamode, where, responseFunction) 
+{
+    docArrayJSON = JSON.stringify(docArray);
+    
+    var params = "a=" + escape("set-metadata-array"); //"a=set-metadata-array";
+    if(where != null) {
+	params += "&where=" + escape(where); // if where not specified, meta-server will default to setting index meta
+	//} else {
+	//    params += "&where=import|archives|index";
+    }
+    params += "&c="+escape(collection);
+    params += "&site="+escape(site);
+    params += "&json="+escape(docArrayJSON);
+    
+    if (metamode!=null) {
+	params += "&metamode=" + escape(metamode);
+    }
+    
+
+    var response = callMetadataServer("Setting metadata in "+where, "cgi-bin/metadata-server.pl?"+params, responseFunction);
+
+    return response;
+    // return this.urlPostSync(mdserver,params); // gsajaxapi.js version for GS2
+}
+
+
 /*************************
 * GET METADATA FUNCTIONS *
 *************************/
 
+// New. Modified version of the GS2 version of this method in gsajaxapi.js.
+// See description for setMetadataArray above for information about the 'where' parameter.
+// THIS METHOD IS SYNCHRONOUS BY DEFAULT. Set forceSync to false to override this default behaviour
+gs.functions.getMetadataArray = function(collection, site, docArray, where, forceSync, responseFunction)
+{
+    docArrayJSON = JSON.stringify(docArray);
+	
+    var params = "a=" + escape("get-metadata-array"); //"a=set-metadata-array";
+    if(where != null) {
+	params += "&where=" + escape(where); // if where not specified, meta-server will default to setting index meta
+	//} else {
+	//    params += "&where=import|archives|index";
+    }
+    params += "&c="+escape(collection);
+    params += "&site="+escape(site);
+    params += "&json="+escape(docArrayJSON);
+        
+    // get operations are generally asynchronous, but allow calling function to force ajax call 
+    // to be synchronous or not. Default is synchronous, as it was for GS2
+    if(forceSync == null) {
+	forceSync = true;
+    }
+    // Objects/maps can use identifiers or strings for property names
+    // http://stackoverflow.com/questions/456177/function-overloading-in-javascript-best-practices
+    // https://www.w3schools.com/js/js_objects.asp
+    var response = callMetadataServer("Getting metadata from "+where, "cgi-bin/metadata-server.pl?"+params, responseFunction, {"forceSync":forceSync});
+
+    return response;
+    //return this.urlPostSync(mdserver,params);	// gsajaxapi.js version for GS2
+}
+
+
 gs.functions.getImportMetadata = function(collection, site, documentID, metadataName, responseFunction)
 {
Index: /main/trunk/greenstone3/web/interfaces/default/js/user_comments.js
===================================================================
--- /main/trunk/greenstone3/web/interfaces/default/js/user_comments.js	(revision 31537)
+++ /main/trunk/greenstone3/web/interfaces/default/js/user_comments.js	(revision 31537)
@@ -0,0 +1,317 @@
+// http://toddmotto.com/avoiding-anonymous-javascript-functions
+// https://toddmotto.com/everything-you-wanted-to-know-about-javascript-scope/
+// pass event to addEventListener onmouseover
+// http://stackoverflow.com/questions/256754/how-to-pass-arguments-to-addeventlistener-listener-function
+// http://stackoverflow.com/questions/8714472/cant-pass-event-to-addeventlistener-closure-issue
+
+
+/***************
+* USER COMMENTS
+****************/
+// http://stackoverflow.com/questions/6312993/javascript-seconds-to-time-with-format-hhmmss
+// Call as: alert(timestamp.printTime());
+function formatTime(timestamp) {
+    var int_timestamp    = parseInt(timestamp, 10); // don't forget the second param
+    var date = new Date(int_timestamp);
+    return date.toLocaleDateString() + " " + date.toLocaleTimeString();   
+}
+
+function loadUserComments() {
+    /*
+
+    if(gs.variables) {
+	var listing = "";
+	for (prop in gs.variables) {
+	    listing = listing + prop.toString() + ": " + gs.variables[prop] + "\n";
+	}
+	alert ("gs variables:\n" + listing);
+    } else {
+	alert("LOADING USER COMMENTS - no gs.variables set");
+    }
+    */
+    
+    // don't bother loading comments if we're not on a document page (in which case there's no usercommentdiv)
+    var usercommentdiv = document.getElementById("usercomments");
+    if(usercommentdiv == undefined || usercommentdiv == null) {
+	return;
+    }
+    
+    // else, if we have a usercommentdiv, we would have a docid. Get toplevel section of the docid
+    var doc_id = gs.variables["d"]; ///"_cgiargdJssafe_"; //escape("cgiargd");
+    var period = doc_id.indexOf(".");
+    if(period != -1) {
+	doc_id = doc_id.substring(0, period);
+    }
+    
+    var username_rec = {
+	metaname: "username",
+	metapos: "all"
+    };
+    
+    var timestamp_rec = {
+	metaname: "usertimestamp",
+	metapos: "all"
+    };
+    
+    var comment_rec = {
+	metaname: "usercomment",
+	metapos: "all"
+    };
+
+    var doc_rec = {
+	docid: doc_id,
+	metatable: [username_rec, timestamp_rec, comment_rec]	    
+    };
+
+    var docArray = [doc_rec];
+    //alert(JSON.stringify(docArray));
+
+    var json_result_str = gs.functions.getMetadataArray(gs.variables["c"], gs.variables["site"], docArray, "index");
+    //	alert(json_result_str);
+    var result = JSON.parse(json_result_str);
+    // result contains only one docrec (result[0]), since we asked for the usercomments of one docid
+    var metatable = result[0].metatable;
+    //	alert(JSON.stringify(metatable));
+
+    var i = 0;
+    var looping = true;
+    var print_heading = false; // UNUSED SECTION if(print_heading) below
+    
+    // metatable[0] = list of usernames, metatable[1] = list of timestamps, metatable[2] = list of comments	
+    // the 3 lists/arrays should be of even length. Assuming this, loop as long as there's another username
+    while(looping) {
+	var metaval_rec = metatable[0].metavals[i];
+	if(metaval_rec == undefined) {
+	    looping = false;
+	    } 
+	else {
+	    
+// BEGIN UNUSED
+	    if(print_heading) {
+		var heading=document.createElement("div");
+		var attr=document.createAttribute("class");
+		attr.nodeValue="usercommentheading";
+		heading.setAttributeNode(attr);
+		var txt=document.createTextNode(gs.variables["textusercommentssection"]); ///"_textusercommentssection_");
+		heading.appendChild(txt);
+		usercommentdiv.appendChild(heading);
+		
+ 		print_heading = false;
+	    }
+// END UNUSED
+	    
+    	    var username = metaval_rec.metavalue;
+	    var timestamp = metatable[1].metavals[i].metavalue;  
+	    var comment = metatable[2].metavals[i].metavalue; 
+	    
+	    //alert("Comment: " + username + " " + timestamp + " " + comment);
+	    
+	    // No need to sort by time, as the meta are already stored sorted 
+	    // and hence retrieved in the right order by using the i (metapos) counter
+	    // If sorting the array of comment records, which would be by timestamp, see
+	    // https://developer.mozilla.org/en-US/docs/JavaScript/Reference/Global_Objects/Array/sort
+	    
+	    // for each usercomment, create a child div with the username, timestamp and comment
+	    displayInUserCommentList(usercommentdiv, username, timestamp, comment);
+	    
+	    i++;
+	}		
+    }
+
+}
+
+
+function displayInUserCommentList(usercommentdiv, username, timestamp, comment) {
+    
+    //alert("Comment: " + username + " " + timestamp + " " + comment);
+
+    var divgroup=document.createElement("div");
+    var attr=document.createAttribute("class");
+    attr.nodeValue="usercomment";
+    divgroup.setAttributeNode(attr);
+    
+    var divuser=document.createElement("div");
+    var divtime=document.createElement("div");
+    var divcomment=document.createElement("div");
+    
+    
+    divgroup.appendChild(divuser);
+    var txt=document.createTextNode(username);
+    divuser.appendChild(txt);
+    
+    divgroup.appendChild(divtime);
+    txt=document.createTextNode(formatTime(timestamp)); // format timestamp for date/time display
+    divtime.appendChild(txt);
+    
+    // any quotes and colons in the fields would have been protected for transmitting as JSON
+    // so decode their entity values
+    comment = comment.replace(/&quot;/gmi, '"');
+    comment = comment.replace(/&58;/gmi, ':');
+    
+    divgroup.appendChild(divcomment);
+    txt=document.createTextNode(comment);
+    divcomment.appendChild(txt);
+    
+    usercommentdiv.appendChild(divgroup);
+    
+}
+
+
+// Unused. Replaced in favour of call to escape() in setMetaArray function that calls urlPostSync
+// http://stackoverflow.com/questions/6020714/escape-html-using-jquery
+function safeHTML(str) {
+    return str.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"',"&quot;").replace("'","&#x27;").replace("/", "&#x2F;"); //"\\""
+} 
+
+
+function addUserComment(_username, _comment, _docid, doc) {
+    
+    // don't add empty strings for name/comment		
+    
+    // http://stackoverflow.com/questions/498970/how-do-i-trim-a-string-in-javascript
+    //var trimmed_username=_username.replace(/^\s+|\s+$/g, '');
+    var trimmed_comment = _comment.replace(/^\s+|\s+$/g, '');
+    
+    if(!trimmed_comment) { // || !trimmed_username
+	doc.AddUserCommentForm.comment.value = "";		      
+	//doc.AddUserCommentForm.username.value = "";
+	doc.getElementById("usercommentfeedback").innerHTML = gs.variables["textisempty"]; ///"_textisempty_";
+	return;
+    }
+    
+    // Need to the add user comment meta of username, timestamp and comment to the
+    // topmost section of the document. So only get the docId up to any period mark:
+    var period = _docid.indexOf(".");
+    if(period != -1) {
+	_docid = _docid.substring(0, period);
+    }
+    
+    
+    // Want to store username, timestamp and comment in import/metadata.xml, archives/doc.xml
+    // and index/col.gdb.
+    
+    // For getting the current time, see
+    // http://stackoverflow.com/questions/3830244/get-current-date-time-in-seconds
+    var _timestamp = new Date().getTime(); // div by 1000 to get seconds. valueOf() may return string
+    
+    //alert("username:" + _username
+    //+ "\\ncomment: " + _comment
+    //+ "\\ncollection: " + collection 
+    //+ "\\ndocid: " + _docid
+    //+ "\\ntimestamp: " + _timestamp); 
+    
+    
+    // Entity encode the values before storing (at least <, >, forward slash.
+    // And single and double quote, ampersand)
+    // http://stackoverflow.com/questions/6020714/escape-html-using-jquery
+    // setMetadataArray escapes the entire JSON, is that better than escaping individually here?
+    //_docid = escape(_docid);
+    //_timestamp = escape(_timestamp);
+    //_username = escape(_username); //safeHTML(_username);
+    //_comment = escape(_comment); //safeHTML(_comment);
+    
+    // Use this if making individual api calls to set username meta, then timestamp then comment meta
+    // GSAPI already knows the collection
+    //gsapi.setMetadata(_docid, "username", null, _username, "accumulate", "import|archives|index");
+    //gsapi.setMetadata(_docid, "usertimestamp", null, _timestamp, "accumulate", "import|archives|index");
+    //gsapi.setMetadata(_docid, "usercomment", null, _comment, "accumulate", "import|archives|index");
+    
+    
+    // Use the new JSON metatable format to set username, timestamp and comment meta for docid in one go
+    
+    // For creating the JSON object that gets turned into a string, see
+    // http://msdn.microsoft.com/en-us/library/ie/cc836459%28v=vs.94%29.aspx
+    // http://jsfiddle.net/qmacro/W54hy/
+    
+    var username_rec = {
+	metaname: "username",
+	metavals: [_username]
+    };
+    
+    var timestamp_rec = {
+	metaname: "usertimestamp",
+	metavals: [_timestamp]
+    };
+    
+    var comment_rec = {
+	metaname: "usercomment",
+	metavals: [_comment]
+    };
+    
+    var doc_rec = {
+	docid: _docid,
+	metatable: [username_rec, timestamp_rec, comment_rec],
+	metamode: "accumulate"
+    };
+    
+    var docArray = [doc_rec];
+    
+    //alert(JSON.stringify(docArray));
+    
+    // GSAPI already knows the collection
+    var result = gs.functions.setMetadataArray(gs.variables["c"], gs.variables["site"], docArray, "accumulate", "import|archives|index");
+    
+    // clear the comment field as it has now been submitted, but not the username field
+    // as the user is logged in, so they should be able to commit again under their username.
+    doc.AddUserCommentForm.comment.value = ""; 
+    
+    // check for locked collection error
+    var errorIndex = result.indexOf("ERROR");
+    // check for any error discovered on the server side
+    var responseErrorIndex = result.indexOf("<error");
+
+    if(errorIndex != -1) {
+	var endIndex = result.indexOf("\\n");
+	var error = result.substring(errorIndex,endIndex);
+	errormessage="ERROR: Unable to add comment. " + error;
+	doc.getElementById("usercommentfeedback").innerHTML = errormessage;
+	//alert("Result: " + result);
+    } 
+    else if (responseErrorIndex != -1) {
+	var endIndex = result.indexOf("</error>");
+	var startIndex = result.indexOf(">", responseErrorIndex+1);
+	var error = result.substring(startIndex+1,endIndex);
+	errormessage="ERROR: Unable to add comment. " + error;
+	doc.getElementById("usercommentfeedback").innerHTML = errormessage;
+	//alert("Result: " + result);
+    }
+    else { // success!
+	doc.getElementById("usercommentfeedback").innerHTML = gs.variables["textcommentsubmitted"]; ///"_textcommentsubmitted_";		
+	
+	// update display of existing user comments to show the newly added comment
+	var usercommentdiv = document.getElementById("usercomments");
+	if(usercommentdiv != undefined) {
+	    displayInUserCommentList(usercommentdiv, _username, _timestamp, _comment);
+     	}
+    }     
+}
+
+function commentAreaSetup() {
+    loadUserComments();
+    //$("div#commentarea").html("<textarea required=\"required\" name=\"comment\" rows=\"10\" cols=\"64\" placeholder=\"Add your comment here...\"></textarea>");
+    $("div#commentarea").html('<textarea required="required" name="comment" rows="10" cols="64" placeholder="Add your comment here..."></textarea>');
+
+}
+
+
+// "Handlers added via $(document).ready() don't overwrite each other, but rather execute in turn"
+// as explained at http://stackoverflow.com/questions/15564029/adding-to-window-onload-event
+
+$(document).ready(commentAreaSetup);
+
+/*
+// http://stackoverflow.com/questions/807878/javascript-that-executes-after-page-load
+// ensure we don't replace any other onLoad() functions, but append the loadUserComments() 
+// function to the existing onLoad handlers
+
+if(window.onload) {
+    var curronload = window.onload;
+    var newonload = function() {
+        curronload();
+        loadUserComments();
+    };
+    window.onload = newonload;
+} else {
+    window.onload = loadUserComments;
+}
+*/
Index: /main/trunk/greenstone3/web/interfaces/default/style/core.css
===================================================================
--- /main/trunk/greenstone3/web/interfaces/default/style/core.css	(revision 31536)
+++ /main/trunk/greenstone3/web/interfaces/default/style/core.css	(revision 31537)
@@ -1240,2 +1240,40 @@
 }
 
+/* User comments area: display of existing user comments and display of the form and of the add user/logout links */
+
+#usercomments, #usercommentform, #usercommentlink a, #usercommentlogoutlink a {
+    font-family: sans-serif;
+    font-size: 12px;
+}
+
+#usercomments {
+    margin: 10px 0;
+}
+
+.usercommentheading {
+    font-weight: bold;
+    color: #006666;
+    border-top: solid 1px black;
+}
+
+.usercomment {
+    margin: 10px 0;
+}
+
+#usercommentlink, #usercommentlogoutlink {
+    margin: 10px 0 20px 0;
+}
+
+#usercommentlink a, #usercommentlogoutlink a {
+ text-decoration: none;
+ font-weight: bold;
+ color: #006666;
+}
+#usercommentlink a:visited, #usercommentlogoutlink a:visited {
+    color: #006666;
+}
+
+.centrediv {
+    width:50%;
+    margin: 0 auto;
+}
Index: /main/trunk/greenstone3/web/interfaces/default/transform/config_format.xsl
===================================================================
--- /main/trunk/greenstone3/web/interfaces/default/transform/config_format.xsl	(revision 31536)
+++ /main/trunk/greenstone3/web/interfaces/default/transform/config_format.xsl	(revision 31537)
@@ -110,5 +110,5 @@
 	     when the XSLT processes it (which would then result in it
 	     being changed into a self-closing element, which then is
-	     incorrectly rendered as HTML).  Doing thing with the
+	     incorrectly rendered as HTML).  Doing this with the
 	     value-of is better then injecting an xsl:comment in
 	     (another approach we have used in the past) as the
@@ -116,5 +116,5 @@
 	     HTML.  This can lead to further complications if
 	     Javascript using the 'empty' div truely expects it to
-	     have no connent of any form. 
+	     have no content of any form. 
 	-->
 
Index: /main/trunk/greenstone3/web/interfaces/default/transform/layouts/header.xsl
===================================================================
--- /main/trunk/greenstone3/web/interfaces/default/transform/layouts/header.xsl	(revision 31536)
+++ /main/trunk/greenstone3/web/interfaces/default/transform/layouts/header.xsl	(revision 31537)
@@ -409,4 +409,5 @@
     
   </xsl:template>
+
   <xsl:template name="generateLoginURL">
     <xsl:value-of select="$library_name"/>
@@ -431,4 +432,34 @@
     </xsl:for-each>
     
+  </xsl:template>
+
+  <!-- Writing the reverse of generateLoginURL since the toggleUserMenuScript does a lot more than I want. -->
+  <!-- https://www.w3schools.com/xml/xsl_functions.asp#string -->  
+  <xsl:template name="generateLogoutURL">
+    
+    <xsl:variable name="url" select="/page/pageRequest/@fullURL"/>
+    <xsl:variable name="tmpURL" select="substring-before($url, '&amp;amp;logout=')"/>
+    <xsl:variable name="beforeHash" select="substring-before($url, '#')"/>
+    <xsl:variable name="afterHash" select="substring-after($url, '#')"/>
+    <!-- Get rid of any lingering &amp;logout= already in the URL.
+	 Can't use fn:replace() as it's only been defined since XSLT 2.0. We use XSLT 1.x -->
+    <xsl:variable name="fullURL">
+      <xsl:choose>
+	<xsl:when test="$tmpURL != ''"><xsl:value-of select="$tmpURL" /></xsl:when>
+	<xsl:otherwise><xsl:value-of select="$url" /></xsl:otherwise>
+      </xsl:choose>
+    </xsl:variable>
+
+    <!-- Output the logout link: the current page's URL (with any lingering logout suffix removed) 
+	 followed by ?logout= or &amp;logout= followed by any # portion of the current page's URL -->
+    <xsl:choose>
+      <xsl:when test="$beforeHash != ''"><xsl:value-of select="$beforeHash" /></xsl:when>
+      <xsl:otherwise><xsl:value-of select="$fullURL" /></xsl:otherwise>
+    </xsl:choose>
+    <xsl:choose>
+      <xsl:when test="contains($fullURL, '?')"><xsl:text>&amp;logout=</xsl:text></xsl:when>
+      <xsl:otherwise>?logout=</xsl:otherwise>
+    </xsl:choose>
+    <xsl:if test="$afterHash != ''">#<xsl:value-of select="$afterHash" /></xsl:if>   
   </xsl:template>
   
Index: /main/trunk/greenstone3/web/interfaces/default/transform/layouts/usercomments.xsl
===================================================================
--- /main/trunk/greenstone3/web/interfaces/default/transform/layouts/usercomments.xsl	(revision 31537)
+++ /main/trunk/greenstone3/web/interfaces/default/transform/layouts/usercomments.xsl	(revision 31537)
@@ -0,0 +1,70 @@
+<?xml version="1.0" encoding="ISO-8859-1"?>
+<xsl:stylesheet version="1.0"
+	xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+	xmlns:java="http://xml.apache.org/xslt/java"
+	xmlns:util="xalan://org.greenstone.gsdl3.util.XSLTUtil"
+	xmlns:gslib="http://www.greenstone.org/skinning"
+	xmlns:gsf="http://www.greenstone.org/greenstone3/schema/ConfigFormat"
+	extension-element-prefixes="java util"
+	exclude-result-prefixes="java util gsf">
+
+<xsl:template name="userCommentsSection">
+
+  <!-- 1. Make some variables available to javascript that the usercomments related js functions need -->
+  <gsf:variable name="d"><xsl:value-of select="/page/pageRequest/paramList/param[@name='d']/@value"/></gsf:variable>
+  <gsf:variable name="c"><xsl:value-of select="/page/pageRequest/paramList/param[@name='c']/@value"/></gsf:variable>
+  <gsf:variable name="site"><xsl:value-of select="/page/pageResponse/interfaceOptions/option[@name='site_name']/@value"/></gsf:variable>
+
+  <gsf:variable name="textusercommentssection"><xsl:value-of select="util:getInterfaceText($interface_name, /page/@lang, 'usercomments.heading')"/></gsf:variable>
+  <gsf:variable name="textisempty"><xsl:value-of select="util:getInterfaceText($interface_name, /page/@lang, 'usercomments.isempty')"/></gsf:variable>
+  <gsf:variable name="textcommentsubmitted"><xsl:value-of select="util:getInterfaceText($interface_name, /page/@lang, 'usercomments.submitted')"/></gsf:variable>
+
+  <!-- 2. Load the javascript, which will do stuff on window load/ready for which it needs the above gs.variables -->
+  <script type="text/javascript" src="interfaces/{$interface_name}/js/user_comments.js"><xsl:text> </xsl:text></script>
+
+  
+  <!-- 3. Set up the User comments section in the HTML -->
+  <div id="commentssection" class="centrediv">    
+    <div id="usercomments">
+      <!-- The div that loads the user comments that were already submitted -->
+      <div class="usercommentheading"><xsl:value-of select="util:getInterfaceText($interface_name, /page/@lang, 'usercomments.heading')"/></div>
+    </div>
+
+    <!-- If the user's logged in, show the comment form, else show the link to the login page -->
+    <xsl:choose>
+      <xsl:when test="/page/pageRequest/userInformation">	
+	<!-- Logged in, allow user to add a comment by displaying a form -->
+	<form name="AddUserCommentForm" id="usercommentform">
+	  <input type="hidden" name="username"><xsl:attribute name="value"><xsl:value-of select="/page/pageRequest/userInformation/@username"/></xsl:attribute></input>
+	  <div>
+	    <p><xsl:value-of select="util:getInterfaceText($interface_name, /page/@lang, 'usercomments.add')"/></p>
+	    <!-- The textarea will be added in by javascript into div#commentarea to avoid the problem of XML turning empty tags into self-closing ones and a self-closing text-area becomes invalid HTML -->
+	    <!--<textarea required="required" name="comment" rows="10" cols="64" placeholder="Add your comment here..."></textarea>-->
+	    <div id="commentarea">Comment area to appear here</div>
+	    <input type="hidden" name="d"><xsl:attribute name="value"><xsl:value-of select="/page/pageRequest/paramList/param[@name='d']/@value"/></xsl:attribute></input>
+	  </div>
+	  
+	  <input type="submit" onclick="addUserComment(document.AddUserCommentForm.username.value, document.AddUserCommentForm.comment.value, document.AddUserCommentForm.d.value, document); return false;"><xsl:attribute name="value"><xsl:value-of select="util:getInterfaceText($interface_name, /page/@lang, 'usercomments.submit')"/></xsl:attribute></input>
+	  <label id="usercommentfeedback"><xsl:comment>Text to prevent empty tags from becoming self-closing tags</xsl:comment></label>
+
+	  <div id="usercommentlogoutlink">
+	    <a><xsl:attribute name="href"><xsl:call-template name="generateLogoutURL"/></xsl:attribute>
+	    <xsl:value-of select="util:getInterfaceText($interface_name, /page/@lang, 'menu.logout')"/></a>
+	  </div>
+	</form>
+      </xsl:when>
+
+      <!-- User not logged in, "add comment" link allows user to login first -->
+      <xsl:otherwise>
+	<div id="usercommentlink">
+	  <a><xsl:attribute name="href"><xsl:call-template name="generateLoginURL"/></xsl:attribute>
+	  <xsl:value-of select="util:getInterfaceText($interface_name, /page/@lang, 'usercomments.add')"/></a>
+	</div>
+      </xsl:otherwise>
+    </xsl:choose>
+
+  </div>
+
+</xsl:template>
+
+</xsl:stylesheet>
Index: /main/trunk/greenstone3/web/interfaces/default/transform/pages/document.xsl
===================================================================
--- /main/trunk/greenstone3/web/interfaces/default/transform/pages/document.xsl	(revision 31536)
+++ /main/trunk/greenstone3/web/interfaces/default/transform/pages/document.xsl	(revision 31537)
@@ -12,4 +12,7 @@
 	<xsl:import href="layouts/main.xsl"/>
 	<xsl:import href="layouts/toc.xsl"/>
+
+	<!-- templates for adding user comments -->
+	<xsl:import href="layouts/usercomments.xsl"/>
 	
 	<xsl:variable name="bookswitch">
@@ -367,4 +370,5 @@
 				<xsl:call-template name="documentHeading"/><br/>
 				<xsl:call-template name="documentContent"/>
+				<br /><xsl:call-template name="userCommentsSection"/>
 			</xsl:when>	
 			<xsl:otherwise> <!-- display the standard greenstone document -->
