Repository: OpenRefine/OpenRefine (java)

Issue:
Support localized Modified date time in Open Project project listing
Currently the "Last Modified" column in the project listing on the Open Project page has the date and time hard coded in pseudo ISO 8601 format. It would be desirable to have the date times formatted using the localized format for the user, while also retaining the ability to sort the column in descending (default) or ascending order

### Proposed solution
There are probably several possible solutions, but here are two:
- Prepend a hidden span to the formatted date - https://stackoverflow.com/a/15114891/167425
- Use a custom parser - https://mottie.github.io/tablesorter/docs/example-parsers-advanced.html

### Alternatives considered
Switch to a different sortable table package that supports Dates natively

### Additional context
<!-- Add any other context or screenshots about the feature request here. -->


Pull request:
Localize date on Open Project page. Fixes #6172
Fixes #6172. Built on #6169.

Changes proposed in this pull request:
- format dates according the the language the user set in
  OpenRefine preferences with browser language as fallback
- right justify numbers in Row Count column
- consolidate CSS for project name
- fix pre-existing CSS off-by-one errors
- fix unrelated typo with user metadata


Patch:
diff --git a/extensions/jython/src/com/google/refine/jython/JythonEvaluable.java b/extensions/jython/src/com/google/refine/jython/JythonEvaluable.java
index e16cc4828..e240320f4 100644
--- a/extensions/jython/src/com/google/refine/jython/JythonEvaluable.java
+++ b/extensions/jython/src/com/google/refine/jython/JythonEvaluable.java
@@ -49,6 +49,8 @@ import org.python.core.PyNone;
 import org.python.core.PyObject;
 import org.python.core.PyString;
 import org.python.util.PythonInterpreter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import com.google.refine.expr.EvalError;
 import com.google.refine.expr.Evaluable;
@@ -58,6 +60,8 @@ import com.google.refine.expr.ParsingException;
 
 public class JythonEvaluable implements Evaluable {
 
+    final static Logger logger = LoggerFactory.getLogger("jython");
+
     static public LanguageSpecificParser createParser() {
         return new LanguageSpecificParser() {
 
@@ -79,6 +83,7 @@ public class JythonEvaluable implements Evaluable {
     // don't have access to the servlet context from this class this is
     // the best we can do for now.
     static {
+        logger.debug("Executing static block in JythonEvaluable");
         File libPath = new File("webapp/WEB-INF/lib/jython");
         if (!libPath.exists() && !libPath.canRead()) {
             libPath = new File("main/webapp/WEB-INF/lib/jython");
@@ -93,10 +98,16 @@ public class JythonEvaluable implements Evaluable {
             PythonInterpreter.initialize(System.getProperties(), props, new String[] { "" });
         }
 
-        _engine = new PythonInterpreter();
+        logger.debug("Done with static block in Jython initialization");
     }
 
     public JythonEvaluable(String s) {
+        if (_engine == null) {
+            // TODO: This could potentially be done in the background, after startup, but before the user needs it
+            logger.debug("Invoking constructor for PythonInterpreter()");
+            _engine = new PythonInterpreter();
+            logger.debug("Done constructor for PythonInterpreter()");
+        }
         this.s_functionName = String.format("__temp_%d__", Math.abs(s.hashCode()));
 
         // indent and create a function out of the code
diff --git a/main/pom.xml b/main/pom.xml
index 9a9e2c4e8..033738f26 100644
--- a/main/pom.xml
+++ b/main/pom.xml
@@ -228,8 +228,8 @@
       <version>${commons-validator.version}</version>
     </dependency>
     <dependency>
-      <groupId>velocity</groupId>
-      <artifactId>velocity</artifactId>
+      <groupId>org.apache.velocity</groupId>
+      <artifactId>velocity-engine-core</artifactId>
       <version>${velocity.version}</version>
     </dependency>
     <dependency>
diff --git a/main/src/com/google/refine/ProjectManager.java b/main/src/com/google/refine/ProjectManager.java
index 40530adbc..4ce487239 100644
--- a/main/src/com/google/refine/ProjectManager.java
+++ b/main/src/com/google/refine/ProjectManager.java
@@ -38,6 +38,7 @@ import java.io.InputStream;
 import java.time.Duration;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -76,7 +77,10 @@ public abstract class ProjectManager {
     static protected final Duration QUICK_SAVE_MAX_TIME = Duration.ofSeconds(30);
 
     protected Map<Long, ProjectMetadata> _projectsMetadata;
-    protected Map<String, Integer> _projectsTags;// TagName, number of projects having that tag
+    /**
+     * Count of projects which have the given tag
+     */
+    private Map<String, Integer> _projectsTags;
     protected PreferenceStore _preferenceStore;
 
     final Logger logger = LoggerFactory.getLogger(this.getClass());
@@ -104,7 +108,7 @@ public abstract class ProjectManager {
         _projectsMetadata = new HashMap<Long, ProjectMetadata>();
         _preferenceStore = new PreferenceStore();
         _projects = new HashMap<Long, Project>();
-        _projectsTags = new HashMap<String, Integer>();
+        _projectsTags = new HashMap<>();
 
         preparePreferenceStore(_preferenceStore);
     }
@@ -134,18 +138,7 @@ public abstract class ProjectManager {
             projectMetadata.setRowCount(project.rows.size());
             _projects.put(project.id, project);
             _projectsMetadata.put(project.id, projectMetadata);
-            if (_projectsTags == null)
-                _projectsTags = new HashMap<>();
-            String[] tags = projectMetadata.getTags();
-            if (tags != null) {
-                for (String tag : tags) {
-                    if (_projectsTags.containsKey(tag)) {
-                        _projectsTags.put(tag, _projectsTags.get(tag) + 1);
-                    } else {
-                        _projectsTags.put(tag, 1);
-                    }
-                }
-            }
+            addProjectTags(projectMetadata.getTags());
         }
     }
 
@@ -348,7 +341,7 @@ public abstract class ProjectManager {
     }
 
     /**
-     * Gets the project metadata from memory Requires that the metadata has already been loaded from the data store
+     * Gets the project metadata from memory. Requires that the metadata has already been loaded from the data store
      * 
      * @param id
      * @return
@@ -358,7 +351,7 @@ public abstract class ProjectManager {
     }
 
     /**
-     * Gets the project metadata from memory Requires that the metadata has already been loaded from the data store
+     * Gets the project metadata from memory. Requires that the metadata has already been loaded from the data store
      * 
      * @param name
      * @return
@@ -488,11 +481,59 @@ public abstract class ProjectManager {
         return _projectsMetadata;
     }
 
+    /**
+     * Increment usage count for all tags in the list.
+     *
+     * @param tags
+     *            String[] array containing all tag names to be updated
+     */
+    public void addProjectTags(String[] tags) {
+        if (tags != null) {
+            for (String tag : tags) {
+                if (_projectsTags.containsKey(tag)) {
+                    _projectsTags.put(tag, _projectsTags.get(tag) + 1);
+                } else {
+                    _projectsTags.put(tag, 1);
+                }
+            }
+        }
+    }
+
+    /**
+     * Decrement usage count for all tags in the list and remove any which are no longer used at all.
+     *
+     * @param tags
+     *            String[] array containing all tag names to be updated
+     */
+    public void removeProjectTags(String[] tags) {
+        for (String tag : tags) {
+            if (_projectsTags.containsKey(tag)) {
+                int occurrence = _projectsTags.get(tag);
+                if (occurrence <= 1)
+                    _projectsTags.remove(tag);
+                else {
+                    _projectsTags.put(tag, occurrence - 1);
+                }
+            }
+        }
+    }
+
+    /**
+     * Get tag names and usage counts for all projects
+     *
+     * @return unmodifiable map keyed by tag name with usage counts as the values
+     */
+    @JsonIgnore
+    public Map<String, Integer> getAllProjectsTags() {
+        return Collections.unmodifiableMap(_projectsTags);
+    }
+
     /**
      * Gets all the project tags currently held in memory
-     * 
-     * @return
+     *
+     * @deprecated Deprecated for v3.8. Use {@link #getAllProjectsTags()}
      */
+    @Deprecated
     @JsonIgnore
     public Map<String, Integer> getAllProjectTags() {
         return _projectsTags;
diff --git a/main/src/com/google/refine/commands/project/DeleteProjectCommand.java b/main/src/com/google/refine/commands/project/DeleteProjectCommand.java
index efc502625..85293fff7 100644
--- a/main/src/com/google/refine/commands/project/DeleteProjectCommand.java
+++ b/main/src/com/google/refine/commands/project/DeleteProjectCommand.java
@@ -34,14 +34,12 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 package com.google.refine.commands.project;
 
 import java.io.IOException;
-import java.util.Map;
 
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
 import com.google.refine.ProjectManager;
-import com.google.refine.ProjectMetadata;
 import com.google.refine.commands.Command;
 
 public class DeleteProjectCommand extends Command {
@@ -58,21 +56,6 @@ public class DeleteProjectCommand extends Command {
         try {
             long projectID = Long.parseLong(request.getParameter("project"));
 
-            // Remove the project tags from the general map
-            Map<String, Integer> allProjectTags = ProjectManager.singleton.getAllProjectTags();
-            ProjectMetadata metadata = ProjectManager.singleton.getProjectMetadata(projectID);
-            for (String tag : metadata.getTags()) {
-                if (allProjectTags.containsKey(tag)) {
-                    int occurrence = allProjectTags.get(tag);
-
-                    if (occurrence == 1)
-                        allProjectTags.remove(tag);
-                    else {
-                        allProjectTags.put(tag, occurrence - 1);
-                    }
-                }
-            }
-
             ProjectManager.singleton.deleteProject(projectID);
 
             respond(response, "{ \"code\" : \"ok\" }");
@@ -81,4 +64,5 @@ public class DeleteProjectCommand extends Command {
             respondException(response, e);
         }
     }
+
 }
diff --git a/main/src/com/google/refine/commands/project/RenameProjectCommand.java b/main/src/com/google/refine/commands/project/RenameProjectCommand.java
index 4dad20473..a0fe53c70 100644
--- a/main/src/com/google/refine/commands/project/RenameProjectCommand.java
+++ b/main/src/com/google/refine/commands/project/RenameProjectCommand.java
@@ -58,7 +58,7 @@ public class RenameProjectCommand extends Command {
 
             pm.setName(name);
 
-            respond(response, "{ \"code\" : \"ok\" }");
+            respond(response, "{\"code\":\"ok\"}");
         } catch (Exception e) {
             respondException(response, e);
         }
diff --git a/main/src/com/google/refine/commands/project/SetProjectMetadataCommand.java b/main/src/com/google/refine/commands/project/SetProjectMetadataCommand.java
index 09205950f..3cf45c486 100644
--- a/main/src/com/google/refine/commands/project/SetProjectMetadataCommand.java
+++ b/main/src/com/google/refine/commands/project/SetProjectMetadataCommand.java
@@ -54,7 +54,7 @@ public class SetProjectMetadataCommand extends Command {
         ProjectMetadata meta = null;
 
         if (project == null) {
-            respond(response, "{ \"code\" : \"error\", \"message\" : \"Project cannot be found\" }");
+            respond(response, "{\"code\":\"error\",\"message\":\"Project cannot be found\"}");
             return;
         }
 
diff --git a/main/src/com/google/refine/commands/project/SetProjectTagsCommand.java b/main/src/com/google/refine/commands/project/SetProjectTagsCommand.java
index 923ce2faf..5e726659c 100644
--- a/main/src/com/google/refine/commands/project/SetProjectTagsCommand.java
+++ b/main/src/com/google/refine/commands/project/SetProjectTagsCommand.java
@@ -26,16 +26,12 @@
 package com.google.refine.commands.project;
 
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
+import java.util.stream.Stream;
 
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
-import com.google.common.base.CharMatcher;
-
 import com.google.refine.ProjectManager;
 import com.google.refine.ProjectMetadata;
 import com.google.refine.commands.Command;
@@ -66,43 +62,16 @@ public class SetProjectTagsCommand extends Command {
         String oldT = request.getParameter("old");
         String newT = request.getParameter("new");
 
-        Map<String, Integer> allProjectTags = ProjectManager.singleton.getAllProjectTags();
-
-        // Lets remove the old tags from the general map
-        String[] oldTags = oldT.split(",");
-        for (String tag : oldTags) {
-            if (allProjectTags != null && allProjectTags.containsKey(tag)) {
-                int occurrence = allProjectTags.get(tag);
+        String[] oldTags = oldT == null ? new String[0] : oldT.split(",");
+        ProjectManager.singleton.removeProjectTags(oldTags);
 
-                if (occurrence == 1) {
-                    allProjectTags.remove(tag);
-                } else {
-                    allProjectTags.put(tag, occurrence - 1);
-                }
-            }
-        }
+        String[] newTags = newT == null ? new String[0]
+                : Stream.of(newT.split(" |\\,")).map(String::trim).filter(t -> !t.isEmpty()).toArray(String[]::new);
+        ProjectManager.singleton.addProjectTags(newTags);
 
-        // Lets add the new tags to the general map
-        String[] newTags = newT.split(" |\\,");
-        List<String> polishedTags = new ArrayList<String>(newTags.length);
-        for (String tag : newTags) {
-            tag = CharMatcher.whitespace().trimFrom(tag);
+        metadata.setTags(newTags);
 
-            if (!tag.isEmpty()) {
-                if (allProjectTags != null) {
-                    if (allProjectTags.containsKey(tag)) {
-                        allProjectTags.put(tag, allProjectTags.get(tag) + 1);
-                    } else {
-                        allProjectTags.put(tag, 1);
-                    }
-                }
-                polishedTags.add(tag);
-            }
-        }
-
-        // Let's update the project tags
-        metadata.setTags(polishedTags.toArray(new String[polishedTags.size()]));
-
-        respond(response, "{ \"code\" : \"ok\" }");
+        respond(response, "{\"code\":\"ok\"}");
     }
+
 }
diff --git a/main/src/com/google/refine/commands/workspace/GetAllProjectTagsCommand.java b/main/src/com/google/refine/commands/workspace/GetAllProjectTagsCommand.java
index 44000c74e..0fdd801b3 100644
--- a/main/src/com/google/refine/commands/workspace/GetAllProjectTagsCommand.java
+++ b/main/src/com/google/refine/commands/workspace/GetAllProjectTagsCommand.java
@@ -55,7 +55,7 @@ public class GetAllProjectTagsCommand extends Command {
     public void doGet(HttpServletRequest request, HttpServletResponse response)
             throws ServletException, IOException {
 
-        Map<String, Integer> tagMap = ProjectManager.singleton.getAllProjectTags();
+        Map<String, Integer> tagMap = ProjectManager.singleton.getAllProjectsTags();
         Set<String> tags = tagMap == null ? Collections.emptySet() : tagMap.keySet();
         respondJSON(response, new AllProjectsTags(tags));
     }
diff --git a/main/src/com/google/refine/history/HistoryEntry.java b/main/src/com/google/refine/history/HistoryEntry.java
index 6b734a82c..076a58f21 100644
--- a/main/src/com/google/refine/history/HistoryEntry.java
+++ b/main/src/com/google/refine/history/HistoryEntry.java
@@ -1,6 +1,6 @@
 /*
 
-Copyright 2010, Google Inc.
+Copyright 2010, 2023 Google Inc. & OpenRefine contributors
 All rights reserved.
 
 Redistribution and use in source and binary forms, with or without
@@ -35,8 +35,8 @@ package com.google.refine.history;
 
 import java.io.IOException;
 import java.io.Writer;
+import java.time.Instant;
 import java.time.OffsetDateTime;
-import java.time.ZoneId;
 import java.util.Properties;
 
 import com.fasterxml.jackson.annotation.JacksonInject;
@@ -69,7 +69,7 @@ public class HistoryEntry {
     @JsonProperty("description")
     final public String description;
     @JsonProperty("time")
-    final public OffsetDateTime time;
+    final public Instant time;
 
     // the manager (deals with IO systems or databases etc.)
     @JsonIgnore
@@ -105,15 +105,23 @@ public class HistoryEntry {
             @JacksonInject("projectID") long projectID,
             @JsonProperty("description") String description,
             @JsonProperty(OPERATION) AbstractOperation operation) {
-        this(id, projectID, description, operation, OffsetDateTime.now(ZoneId.of("Z")));
+        this(id, projectID, description, operation, Instant.now());
     }
 
     public HistoryEntry(long id, Project project, String description, AbstractOperation operation, Change change) {
-        this(id, project.id, description, operation, OffsetDateTime.now(ZoneId.of("Z")));
+        this(id, project.id, description, operation, Instant.now());
         setChange(change);
     }
 
+    @Deprecated(since = "3.8")
     protected HistoryEntry(long id, long projectID, String description, AbstractOperation operation, OffsetDateTime time) {
+        // TODO: I'm not sure attempting to preserve this backward compatibility is worthwhile, since:
+        // a) this constructor is unused in the core, and
+        // b) there's a public field which has changed datatype
+        this(id, projectID, description, operation, time.toInstant());
+    }
+
+    protected HistoryEntry(long id, long projectID, String description, AbstractOperation operation, Instant time) {
         this.id = id;
         this.projectID = projectID;
         this.description = description;
diff --git a/main/src/com/google/refine/io/FileProjectManager.java b/main/src/com/google/refine/io/FileProjectManager.java
index d950766a4..09d175538 100644
--- a/main/src/com/google/refine/io/FileProjectManager.java
+++ b/main/src/com/google/refine/io/FileProjectManager.java
@@ -39,7 +39,6 @@ import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map.Entry;
 import java.util.Set;
@@ -130,19 +129,7 @@ public class FileProjectManager extends ProjectManager {
 
             if (metadata != null) {
                 _projectsMetadata.put(projectID, metadata);
-                if (_projectsTags == null) {
-                    _projectsTags = new HashMap<String, Integer>();
-                }
-
-                if (metadata != null && metadata.getTags() != null) {
-                    for (String tag : metadata.getTags()) {
-                        if (_projectsTags.containsKey(tag)) {
-                            _projectsTags.put(tag, _projectsTags.get(tag) + 1);
-                        } else {
-                            _projectsTags.put(tag, 1);
-                        }
-                    }
-                }
+                addProjectTags(metadata.getTags());
                 return true;
             } else {
                 return false;
@@ -341,6 +328,16 @@ public class FileProjectManager extends ProjectManager {
     @Override
     public void deleteProject(long projectID) {
         synchronized (this) {
+
+            // Remove this project's tags from the overall map
+            ProjectMetadata metadata = getProjectMetadata(projectID);
+            if (metadata != null) {
+                String[] tags = metadata.getTags();
+                if (tags != null) { // should only ever happen during tests with mocked ProjectMetadata
+                    removeProjectTags(tags);
+                }
+            }
+
             removeProject(projectID);
 
             File dir = getProjectDir(projectID);
@@ -470,14 +467,8 @@ public class FileProjectManager extends ProjectManager {
 
             _projectsMetadata.put(id, metadata);
 
-            if (metadata != null && metadata.getTags() != null) {
-                for (String tag : metadata.getTags()) {
-                    if (_projectsTags.containsKey(tag)) {
-                        _projectsTags.put(tag, _projectsTags.get(tag) + 1);
-                    } else {
-                        _projectsTags.put(tag, 1);
-                    }
-                }
+            if (metadata != null) {
+                addProjectTags(metadata.getTags());
             }
         }
     }
diff --git a/main/webapp/WEB-INF/velocity.properties b/main/webapp/WEB-INF/velocity.properties
index 5e78fcb88..8e9747099 100644
--- a/main/webapp/WEB-INF/velocity.properties
+++ b/main/webapp/WEB-INF/velocity.properties
@@ -7,5 +7,11 @@ velocimacro.context.localscope=false
 velocimacro.library.autoreload=false
 velocimacro.permissions.allow.inline.local.scope=true
 
+# UTF-8 is the default encoding for 2.x, so these might be superfluous
 input.encoding=UTF-8
 output.encoding=UTF-8
+
+# Settings to maximize backward compatibility with 1.x
+runtime.conversion.handler = none
+space.gobbling = bc
+directive.if.emptycheck = false
diff --git a/main/webapp/modules/core/langs/translation-es.json b/main/webapp/modules/core/langs/translation-es.json
index 0286cc564..9b9b8c168 100644
--- a/main/webapp/modules/core/langs/translation-es.json
+++ b/main/webapp/modules/core/langs/translation-es.json
@@ -374,7 +374,7 @@
     "core-recon/include": "¿Incluir",
     "core-recon/contact-service": "Contactando al servicio de cotejo",
     "core-recon/enter-url": "Ingrese la URL del servicio",
-    "core-recon/url-already-registered":"El servicio de cotejo que intenta agregar ya se encuentra registrado.",
+    "core-recon/url-already-registered": "El servicio de cotejo que intenta agregar ya se encuentra registrado.",
     "core-util-enc/all": "Todas las codificaciones",
     "core-util-enc/working": "Trabajando",
     "core-util-enc/encoding": "Codificación",
@@ -748,7 +748,7 @@
     "core-views/column-join-before-column-picker": "Seleccione y ordene las columnas que deben unirse",
     "core-facets/choice-count": "$1 {{plural:$1|elección|elecciones}}",
     "core-dialogs/clusters-filtered": "<b>$1</b> {{plural:$1|agrupamiento filtrado|agrupamientos filtrados}} de un total de <b>$2</b>",
-    "core-dialogs/clusters-found": "<b>$1</b> {{plural:$1|agrupamiento encontrado|agrupamientos encontrados}}",
+    "core-dialogs/clusters-found": "<b>$1</b>&nbsp;{{plural:$1|cluster|clusters}} encontrado",
     "core-index-import/files-selected": "$1 de $2 {{plural:$2|archivo seleccionado|archivos seleccionados}}",
     "core-index-import/file-count": "$1 {{plural:$1|archivo|archivos}}",
     "core-index-import/match-count": "$1 {{plural:$1|coincidencia|coincidencias}}",
diff --git a/main/webapp/modules/core/scripts/index/open-project-ui.js b/main/webapp/modules/core/scripts/index/open-project-ui.js
index 40bb9601c..9540b75d5 100644
--- a/main/webapp/modules/core/scripts/index/open-project-ui.js
+++ b/main/webapp/modules/core/scripts/index/open-project-ui.js
@@ -63,7 +63,7 @@ Refine.OpenProjectUI.prototype._fetchProjects = function() {
       null,
       function(data) {
         self._renderProjects(data);
-        self.resize();
+        self.resize(); // other version of this function excludes this resize
       },
       "json"
   );
@@ -119,7 +119,7 @@ Refine.OpenProjectUI._filterTags = function(tag) {
   });
 };
 
-
+// FIXME: This is overwriting an earlier function definition
 Refine.OpenProjectUI.prototype._fetchProjects = function() {
     var self = this;
     $.ajax({
@@ -134,12 +134,9 @@ Refine.OpenProjectUI.prototype._fetchProjects = function() {
     });
 };
 
-const dateOptions = { year: 'numeric', month: '2-digit', day: '2-digit' };
-const timeOptions = { hour: '2-digit', minute: '2-digit', hour12: false };
-const dateFormatter = new Intl.DateTimeFormat(navigator.language, dateOptions);
-const timeFormatter =  new Intl.DateTimeFormat(navigator.language, timeOptions);
-
 Refine.OpenProjectUI.prototype._renderProjects = function(data) {
+  const options  = { dateStyle: 'medium', timeStyle: 'medium' };
+  const dateFormatter = new Intl.DateTimeFormat(Refine.userLang || navigator.language, options);
   var self = this;
   var projects = [];
   for (var n in data.projects) {
@@ -154,19 +151,10 @@ Refine.OpenProjectUI.prototype._renderProjects = function(data) {
          console.log('Project '+project.id+' name is not set. skipping...');
          continue;
       }
-      // project.modified is ISO 8601 format
-      const date = new Date(project.modified);
-      const dateParts = dateFormatter.formatToParts(date);
-      const timeParts = timeFormatter.formatToParts(date);
-      const findPart = (parts, type) => parts.find(part => part.type === type)?.value;
 
-      const year = findPart(dateParts, 'year');
-      const month = findPart(dateParts, 'month');
-      const day = findPart(dateParts, 'day');
-      const hour = findPart(timeParts, 'hour');
-      const minute = findPart(timeParts, 'minute');
-
-      project.date = `${year}-${month}-${day} ${hour}:${minute}`;
+      // project.modified is ISO 8601 format string
+      const date = new Date(project.modified);
+      project.date = dateFormatter.format(date);
       
       if (typeof project.userMetadata !== "undefined")  {
           for (var m in data.customMetadataColumns) {
@@ -179,7 +167,7 @@ Refine.OpenProjectUI.prototype._renderProjects = function(data) {
                   if (!found) {
                       project.userMetadata.push({
                           name: data.customMetadataColumns[m].name,
-                          dispay: data.customMetadataColumns[m].display,
+                          display: data.customMetadataColumns[m].display,
                           value: ""
                       });
                   }
@@ -260,7 +248,7 @@ Refine.OpenProjectUI.prototype._renderProjects = function(data) {
       );
       
       $('<div></div>')
-      .html(project.date)
+      .html('<span style="display:none">' + project.modified + '</span>' + project.date)
       .addClass("last-modified")
       .appendTo($(tr.insertCell(tr.cells.length)));
       
diff --git a/main/webapp/modules/core/scripts/util/i18n.js b/main/webapp/modules/core/scripts/util/i18n.js
index e7ec8e58a..3816e0105 100644
--- a/main/webapp/modules/core/scripts/util/i18n.js
+++ b/main/webapp/modules/core/scripts/util/i18n.js
@@ -37,5 +37,7 @@ I18NUtil.init = function (module) {
     $('html').attr('lang', lang.replace('_', '-'));
     if (module === 'core') {
       $.i18n({locale: lang});
+      // TODO: This should be globally accessible, but this is OK for our current needs
+      Refine.userLang = lang;
     }
 }
diff --git a/main/webapp/modules/core/styles/index/open-project-ui.css b/main/webapp/modules/core/styles/index/open-project-ui.css
index 78157e5f6..665c83c27 100644
--- a/main/webapp/modules/core/styles/index/open-project-ui.css
+++ b/main/webapp/modules/core/styles/index/open-project-ui.css
@@ -69,6 +69,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 #projects-container .project .project-name {
   font-size: 120%;
+  white-space: nowrap;
 }
 
 #projects-container .project .last-modified {
@@ -84,24 +85,28 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
   border: 0;
 }
 
-#projects-container>table thead th:nth-child(2) {
+#projects-container table thead th:nth-child(2) {
   border-left: 0;
 }
 
-#projects-container>table thead th:nth-child(3) {
+#projects-container table thead th:nth-child(3) {
   white-space: nowrap;
 }
 
-/* Name */
-#projects-container>table td:nth-child(4) {
-  white-space: nowrap;
+#projects-container table thead th:nth-child(9) {
+  text-align: end;
 }
 
 /* Subject */
-#projects-container>table td:nth-child(6) {
+#projects-container table tbody td:nth-child(7) {
   white-space: nowrap;
 }
 
+/* Row Count is right aligned for numbers */
+#projects-container table tbody td:nth-child(9) {
+  text-align: end;
+}
+
 #projects-container>table>tbody>tr>td {
   word-wrap: break-word;
 }
diff --git a/pom.xml b/pom.xml
index e6bacf7d9..ed400d2a1 100644
--- a/pom.xml
+++ b/pom.xml
@@ -74,14 +74,14 @@
     <!-- dependency versions -->
     <jackson.version>2.13.4</jackson.version>
     <servlet-api.version>4.0.1</servlet-api.version>
-    <butterfly.version>1.2.4</butterfly.version>
+    <butterfly.version>1.2.5</butterfly.version>
     <slf4j.version>2.0.9</slf4j.version>
-    <log4j.version>2.21.1</log4j.version>
+    <log4j.version>2.22.0</log4j.version>
     <jetty.version>10.0.16</jetty.version>
     <okhttp.version>4.10.0</okhttp.version>
     <jena.version>4.10.0</jena.version>
-    <poi.version>5.2.4</poi.version>
-    <commons-lang3.version>3.13.0</commons-lang3.version>
+    <poi.version>5.2.5</poi.version>
+    <commons-lang3.version>3.14.0</commons-lang3.version>
     <commons-fileupload.version>1.5</commons-fileupload.version>
     <commons-collections.version>3.2.2</commons-collections.version>
     <commons-text.version>1.11.0</commons-text.version>
@@ -90,7 +90,7 @@
     <!-- v1.15 of commons-codec is needed for Jena 3.14+ - copied from org.apache.jena:jena:pom -->
     <commons-codec.version>1.16.0</commons-codec.version>
     <commons-compress.version>1.25.0</commons-compress.version>
-    <velocity.version>1.5</velocity.version>
+    <velocity.version>2.3</velocity.version>
     <marc4j.version>2.9.5</marc4j.version>
     <jsoup.version>1.15.3</jsoup.version>
     <odfdom-java.version>0.9.0</odfdom-java.version> <!-- do not update to 0.10.0, see issue #4397 -->


Test patch:
diff --git a/main/tests/cypress/cypress/e2e/project/grid/column/edit-column/add_columns_based_on_this_column.cy.js b/main/tests/cypress/cypress/e2e/project/grid/column/edit-column/add_columns_based_on_this_column.cy.js
index f92beceb9..b97f970e2 100644
--- a/main/tests/cypress/cypress/e2e/project/grid/column/edit-column/add_columns_based_on_this_column.cy.js
+++ b/main/tests/cypress/cypress/e2e/project/grid/column/edit-column/add_columns_based_on_this_column.cy.js
@@ -29,9 +29,9 @@ describe(__filename, function () {
 
     cy.get('input[bind="columnNameInput"]').type('Test_Python_toLower');
     cy.get('select[bind="expressionPreviewLanguageSelect"]').select('jython');
-    cy.typeExpression('return value.lower()');
+    cy.typeExpression('return value.lower()',{timeout: 10000});
     cy.get(
-      '.expression-preview-table-wrapper tr:nth-child(2) td:last-child'
+      '.expression-preview-table-wrapper tr:nth-child(2) td:last-child',{timeout: 10000}
     ).should('to.contain', 'butter,with salt');
     cy.confirmDialogPanel();
 
diff --git a/main/tests/cypress/cypress/e2e/project/grid/column/reconcile/copy_reconciliation_data.cy.js b/main/tests/cypress/cypress/e2e/project/grid/column/reconcile/copy_reconciliation_data.cy.js
index 0e54a416b..005e8d844 100644
--- a/main/tests/cypress/cypress/e2e/project/grid/column/reconcile/copy_reconciliation_data.cy.js
+++ b/main/tests/cypress/cypress/e2e/project/grid/column/reconcile/copy_reconciliation_data.cy.js
@@ -42,9 +42,10 @@ describe('Copy reconciliation data', () => {
       'Copy 4 recon judgments from column species to duplicated_column'
     );
 
-    // ensure 4 are matched on the duplicate column
-    cy.get(
-      'table.data-table td .data-table-cell-content:contains("Choose new match")'
-    ).should('have.length', 4);
+    cy.get('table.data-table th').contains('duplicated_column').should('exist').then(() => {
+      cy.get('table.data-table tbody tr')
+              .find('td:last .data-table-cell-content:contains("Choose new match")')
+              .should('have.length', 4);
+    });
   });
 });
diff --git a/main/tests/cypress/cypress/e2e/project/grid/misc/expressions.cy.js b/main/tests/cypress/cypress/e2e/project/grid/misc/expressions.cy.js
index 8d497a25d..c8664582f 100644
--- a/main/tests/cypress/cypress/e2e/project/grid/misc/expressions.cy.js
+++ b/main/tests/cypress/cypress/e2e/project/grid/misc/expressions.cy.js
@@ -47,10 +47,10 @@ describe(__filename, function () {
     cy.loadAndVisitProject('food.mini');
     loadExpressionPanel();
     cy.get('select[bind="expressionPreviewLanguageSelect"]').select('jython');
-    cy.typeExpression('return value.lower()');
+    cy.typeExpression('return value.lower()',{timeout: 10000});
     cy.get('.expression-preview-parsing-status').contains('No syntax error.');
     cy.get(
-      '.expression-preview-table-wrapper tr:nth-child(2) td:last-child'
+      '.expression-preview-table-wrapper tr:nth-child(2) td:last-child',{timeout: 10000}
     ).should('to.contain', 'butter,with salt');
   });
 
@@ -76,7 +76,7 @@ describe(__filename, function () {
     cy.loadAndVisitProject('food.mini');
     loadExpressionPanel();
     cy.get('select[bind="expressionPreviewLanguageSelect"]').select('jython');
-    cy.typeExpression('(;)');
+    cy.typeExpression('(;)',{timeout: 10000});
     cy.get('.expression-preview-parsing-status').contains('Internal error');
   });
 
@@ -103,7 +103,7 @@ describe(__filename, function () {
     cy.loadAndVisitProject('food.mini');
     loadExpressionPanel();
     cy.get('select[bind="expressionPreviewLanguageSelect"]').select('jython');
-    cy.typeExpression('return value.thisPythonFunctionDoesNotExists()');
+    cy.typeExpression('return value.thisPythonFunctionDoesNotExists()',{timeout: 10000});
 
     cy.get(
       '.expression-preview-table-wrapper tr:nth-child(2) td:last-child'
diff --git a/main/tests/cypress/cypress/support/commands.js b/main/tests/cypress/cypress/support/commands.js
index d9d70f9c5..798b41262 100644
--- a/main/tests/cypress/cypress/support/commands.js
+++ b/main/tests/cypress/cypress/support/commands.js
@@ -294,15 +294,10 @@ Cypress.Commands.add('waitForImportUpdate', () => {
  * Utility method to fill something into the expression input
  * Need to wait for OpenRefine to preview the result, hence the cy.wait
  */
-Cypress.Commands.add('typeExpression', (expression) => {
-  if (expression.length <= 30) {
-    cy.get('textarea.expression-preview-code').type(expression);
-    cy.get('tbody > tr:nth-child(1) > td:nth-child(3)').should('contain',expression);
-  } else {
-    cy.get('textarea.expression-preview-code').type(expression);
-    cy.get('tbody > tr:nth-child(1) > td:nth-child(3)').should('contain',expression.substring(0,30) + ' ...');
-  }
-
+Cypress.Commands.add('typeExpression', (expression, options = {}) => {
+    cy.get('textarea.expression-preview-code', options).type(expression);
+    const expectedText = expression.length <= 30 ? expression : `${expression.substring(0, 30)} ...`;
+    cy.get('tbody > tr:nth-child(1) > td:nth-child(3)', options).should('contain', expectedText);
 });
 
 /**
diff --git a/main/tests/cypress/package.json b/main/tests/cypress/package.json
index efec0f35f..e79896d7e 100644
--- a/main/tests/cypress/package.json
+++ b/main/tests/cypress/package.json
@@ -16,11 +16,11 @@
         "lint": "prettier --check . && eslint ."
     },
     "dependencies": {
-        "cypress": "13.5.1",
+        "cypress": "13.6.0",
         "cypress-file-upload": "^5.0.8"
     },
     "devDependencies": {
-        "eslint": "^8.53.0",
+        "eslint": "^8.54.0",
         "eslint-config-google": "^0.14.0",
         "eslint-config-prettier": "^9.0.0",
         "eslint-plugin-cypress": "^2.15.1",
diff --git a/main/tests/cypress/yarn.lock b/main/tests/cypress/yarn.lock
index 7d7854f72..5743e1169 100644
--- a/main/tests/cypress/yarn.lock
+++ b/main/tests/cypress/yarn.lock
@@ -66,10 +66,10 @@
     minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
-"@eslint/js@8.53.0":
-  version "8.53.0"
-  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.53.0.tgz#bea56f2ed2b5baea164348ff4d5a879f6f81f20d"
-  integrity sha512-Kn7K8dx/5U6+cT1yEhpX1w4PCSg0M+XyRILPgvwcEBjerFWCwQj5sbr3/VmxqV0JGHCBCzyd6LxypEuehypY1w==
+"@eslint/js@8.54.0":
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.54.0.tgz#4fab9a2ff7860082c304f750e94acd644cf984cf"
+  integrity sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==
 
 "@humanwhocodes/config-array@^0.11.13":
   version "0.11.13"
@@ -436,10 +436,10 @@ cypress-file-upload@^5.0.8:
   resolved "https://registry.yarnpkg.com/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz#d8824cbeaab798e44be8009769f9a6c9daa1b4a1"
   integrity sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g==
 
-cypress@13.5.1:
-  version "13.5.1"
-  resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.5.1.tgz#8b19bf0b9f31ea43f78980b2479bd3f25197d5cc"
-  integrity sha512-yqLViT0D/lPI8Kkm7ciF/x/DCK/H/DnogdGyiTnQgX4OVR2aM30PtK+kvklTOD1u3TuItiD9wUQAF8EYWtyZug==
+cypress@13.6.0:
+  version "13.6.0"
+  resolved "https://registry.yarnpkg.com/cypress/-/cypress-13.6.0.tgz#b98b7b837679012ed09c7ecee5565bf7b31d4982"
+  integrity sha512-quIsnFmtj4dBUEJYU4OH0H12bABJpSujvWexC24Ju1gTlKMJbeT6tTO0vh7WNfiBPPjoIXLN+OUqVtiKFs6SGw==
   dependencies:
     "@cypress/request" "^3.0.0"
     "@cypress/xvfb" "^1.2.4"
@@ -595,15 +595,15 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4
   resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800"
   integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==
 
-eslint@^8.53.0:
-  version "8.53.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.53.0.tgz#14f2c8244298fcae1f46945459577413ba2697ce"
-  integrity sha512-N4VuiPjXDUa4xVeV/GC/RV3hQW9Nw+Y463lkWaKKXKYMvmRiRDAtfpuPFLN+E1/6ZhyR8J2ig+eVREnYgUsiag==
+eslint@^8.54.0:
+  version "8.54.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.54.0.tgz#588e0dd4388af91a2e8fa37ea64924074c783537"
+  integrity sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==
   dependencies:
     "@eslint-community/eslint-utils" "^4.2.0"
     "@eslint-community/regexpp" "^4.6.1"
     "@eslint/eslintrc" "^2.1.3"
-    "@eslint/js" "8.53.0"
+    "@eslint/js" "8.54.0"
     "@humanwhocodes/config-array" "^0.11.13"
     "@humanwhocodes/module-importer" "^1.0.1"
     "@nodelib/fs.walk" "^1.2.8"
diff --git a/main/tests/server/src/com/google/refine/ProjectManagerTests.java b/main/tests/server/src/com/google/refine/ProjectManagerTests.java
index 740ab7e91..3204fd76c 100644
--- a/main/tests/server/src/com/google/refine/ProjectManagerTests.java
+++ b/main/tests/server/src/com/google/refine/ProjectManagerTests.java
@@ -41,10 +41,12 @@ import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
 import static org.mockito.Mockito.when;
+import static org.testng.AssertJUnit.assertEquals;
 
 import java.lang.reflect.Field;
 import java.time.Instant;
 import java.util.ArrayList;
+import java.util.Set;
 
 import org.mockito.Mockito;
 import org.slf4j.LoggerFactory;
@@ -63,6 +65,7 @@ public class ProjectManagerTests extends RefineTest {
 
     private static final Instant BASE_DATE = Instant.parse("1970-01-02T00:30:00Z");
     private static final int ROW_COUNT = 3;
+    private static final String[] TAGS = { "testtag1", "testtag2" };
     ProjectManagerStub pm;
     ProjectManagerStub SUT;
     Project project;
@@ -88,6 +91,7 @@ public class ProjectManagerTests extends RefineTest {
         addRows(project);
 
         metadata = mock(ProjectMetadata.class);
+        when(metadata.getTags()).thenReturn(TAGS);
         procmgr = mock(ProcessManager.class);
         when(project.getProcessManager()).thenReturn(procmgr);
         when(procmgr.hasPending()).thenReturn(false); // always false for now, but should test separately
@@ -118,6 +122,7 @@ public class ProjectManagerTests extends RefineTest {
 
         verifyNoMoreInteractions(project);
         verifyNoMoreInteractions(metadata);
+        assertEquals(SUT.getAllProjectsTags().keySet(), Set.of(TAGS));
     }
 
     // TODO test registerProject in race condition
diff --git a/main/tests/server/src/com/google/refine/browsing/facets/ListFacetTests.java b/main/tests/server/src/com/google/refine/browsing/facets/ListFacetTests.java
index c18348913..825b2de5b 100644
--- a/main/tests/server/src/com/google/refine/browsing/facets/ListFacetTests.java
+++ b/main/tests/server/src/com/google/refine/browsing/facets/ListFacetTests.java
@@ -28,9 +28,14 @@
 package com.google.refine.browsing.facets;
 
 import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
 
 import com.fasterxml.jackson.core.JsonParseException;
 import com.fasterxml.jackson.databind.JsonMappingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import org.testng.annotations.Test;
 
 import com.google.refine.RefineTest;
@@ -69,8 +74,8 @@ public class ListFacetTests extends RefineTest {
             + "\"columnName\":\"Column A\","
             + "\"invert\":false,"
             + "\"choices\":["
-            + "     {\"v\":{\"v\":\"foobar\",\"l\":\"foobar\"},\"c\":1,\"s\":true},"
-            + "     {\"v\":{\"v\":\"barbar\",\"l\":\"barbar\"},\"c\":1,\"s\":false}"
+            + "     {\"v\":{\"v\":\"barbar\",\"l\":\"barbar\"},\"c\":1,\"s\":false},"
+            + "     {\"v\":{\"v\":\"foobar\",\"l\":\"foobar\"},\"c\":1,\"s\":true}"
             + "]}";
 
     private static String selectedEmptyChoiceFacet = "{"
@@ -79,9 +84,9 @@ public class ListFacetTests extends RefineTest {
             + "\"columnName\":\"Column A\","
             + "\"invert\":false,"
             + "\"choices\":["
-            + "    {\"v\":{\"v\":\"ebar\",\"l\":\"ebar\"},\"c\":1,\"s\":false},"
-            + "    {\"v\":{\"v\":\"cbar\",\"l\":\"cbar\"},\"c\":1,\"s\":false},"
             + "    {\"v\":{\"v\":\"abar\",\"l\":\"abar\"},\"c\":1,\"s\":false},"
+            + "    {\"v\":{\"v\":\"cbar\",\"l\":\"cbar\"},\"c\":1,\"s\":false},"
+            + "    {\"v\":{\"v\":\"ebar\",\"l\":\"ebar\"},\"c\":1,\"s\":false},"
             + "    {\"v\":{\"v\":\"foobar\",\"l\":\"true\"},\"c\":0,\"s\":true}"
             + "]}";
 
@@ -103,7 +108,14 @@ public class ListFacetTests extends RefineTest {
         Facet facet = facetConfig.apply(project);
         facet.computeChoices(project, engine.getAllFilteredRows());
 
-        TestUtils.isSerializedTo(facet, jsonFacet);
+        ObjectNode actual = ParsingUtilities.mapper.valueToTree(facet);
+        List<JsonNode> choicesList = new ArrayList<>();
+
+        actual.findValues("choices").get(0).forEach(choicesList::add);
+        choicesList.sort(Comparator.comparing(JsonNode::toString));
+        actual.replace("choices", ParsingUtilities.mapper.createArrayNode().addAll(choicesList));
+
+        TestUtils.assertEqualsAsJson(actual.toString(), jsonFacet);
     }
 
     @Test
@@ -128,6 +140,14 @@ public class ListFacetTests extends RefineTest {
         ListFacetConfig facetConfig = ParsingUtilities.mapper.readValue(jsonConfig, ListFacetConfig.class);
         Facet facet = facetConfig.apply(project);
         facet.computeChoices(project, engine.getAllFilteredRows());
-        TestUtils.isSerializedTo(facet, selectedEmptyChoiceFacet);
+
+        ObjectNode actual = ParsingUtilities.mapper.valueToTree(facet);
+        List<JsonNode> choicesList = new ArrayList<>();
+
+        actual.findValues("choices").get(0).forEach(choicesList::add);
+        choicesList.sort(Comparator.comparing(n -> n.get("v").toString()));
+        actual.replace("choices", ParsingUtilities.mapper.createArrayNode().addAll(choicesList));
+
+        TestUtils.assertEqualsAsJson(actual.toString(), selectedEmptyChoiceFacet);
     }
 }
diff --git a/main/tests/server/src/com/google/refine/commands/CommandTestBase.java b/main/tests/server/src/com/google/refine/commands/CommandTestBase.java
index 7c90339c1..8ee589834 100644
--- a/main/tests/server/src/com/google/refine/commands/CommandTestBase.java
+++ b/main/tests/server/src/com/google/refine/commands/CommandTestBase.java
@@ -3,6 +3,8 @@ package com.google.refine.commands;
 
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertFalse;
 
 import java.io.IOException;
 import java.io.PrintWriter;
@@ -11,11 +13,15 @@ import java.io.StringWriter;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
 import org.testng.annotations.BeforeMethod;
 
+import com.google.refine.RefineTest;
+import com.google.refine.util.ParsingUtilities;
 import com.google.refine.util.TestUtils;
 
-public class CommandTestBase {
+public class CommandTestBase extends RefineTest {
 
     protected HttpServletRequest request = null;
     protected HttpServletResponse response = null;
@@ -40,4 +46,14 @@ public class CommandTestBase {
     protected void assertCSRFCheckFailed() {
         TestUtils.assertEqualsAsJson(writer.toString(), "{\"code\":\"error\",\"message\":\"Missing or invalid csrf_token parameter\"}");
     }
+
+    /**
+     * Convenience method to check that CSRF protection was NOT triggered
+     */
+    protected void assertErrorNotCSRF() throws JsonProcessingException {
+        String response = writer.toString();
+        JsonNode node = ParsingUtilities.mapper.readValue(response, JsonNode.class);
+        assertEquals(node.get("code").toString(), "\"error\"");
+        assertFalse(response.contains("Missing or invalid csrf_token parameter"));
+    }
 }
diff --git a/main/tests/server/src/com/google/refine/commands/project/RenameProjectCommandTests.java b/main/tests/server/src/com/google/refine/commands/project/RenameProjectCommandTests.java
index 4dbaa3c56..5b583ff23 100644
--- a/main/tests/server/src/com/google/refine/commands/project/RenameProjectCommandTests.java
+++ b/main/tests/server/src/com/google/refine/commands/project/RenameProjectCommandTests.java
@@ -1,6 +1,8 @@
 
 package com.google.refine.commands.project;
 
+import static org.mockito.Mockito.when;
+
 import java.io.IOException;
 
 import javax.servlet.ServletException;
@@ -8,6 +10,7 @@ import javax.servlet.ServletException;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
+import com.google.refine.commands.Command;
 import com.google.refine.commands.CommandTestBase;
 
 public class RenameProjectCommandTests extends CommandTestBase {
@@ -22,4 +25,12 @@ public class RenameProjectCommandTests extends CommandTestBase {
         command.doPost(request, response);
         assertCSRFCheckFailed();
     }
+
+    @Test
+    public void testNoProjectId() throws ServletException, IOException {
+        when(request.getParameter("csrf_token")).thenReturn(Command.csrfFactory.getFreshToken());
+        command.doPost(request, response);
+        assertErrorNotCSRF();
+    }
+
 }
diff --git a/main/tests/server/src/com/google/refine/commands/project/SetProjectMetadataCommandTests.java b/main/tests/server/src/com/google/refine/commands/project/SetProjectMetadataCommandTests.java
index ccfb38f05..aae52672b 100644
--- a/main/tests/server/src/com/google/refine/commands/project/SetProjectMetadataCommandTests.java
+++ b/main/tests/server/src/com/google/refine/commands/project/SetProjectMetadataCommandTests.java
@@ -33,6 +33,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 package com.google.refine.commands.project;
 
+import static com.google.refine.util.TestUtils.assertEqualsAsJson;
 import static org.mockito.ArgumentMatchers.anyLong;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
@@ -41,6 +42,7 @@ import static org.mockito.Mockito.when;
 
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.io.StringWriter;
 import java.util.List;
 import java.util.Map;
 
@@ -59,12 +61,12 @@ import org.testng.annotations.Test;
 
 import com.google.refine.ProjectManager;
 import com.google.refine.ProjectMetadata;
-import com.google.refine.RefineTest;
 import com.google.refine.commands.Command;
+import com.google.refine.commands.CommandTestBase;
 import com.google.refine.model.Project;
 import com.google.refine.util.ParsingUtilities;
 
-public class SetProjectMetadataCommandTests extends RefineTest {
+public class SetProjectMetadataCommandTests extends CommandTestBase {
 
     @Override
     @BeforeTest
@@ -77,22 +79,19 @@ public class SetProjectMetadataCommandTests extends RefineTest {
 
     // variables
     long PROJECT_ID_LONG = 1234;
-    String PROJECT_ID = "1234";
+    String PROJECT_ID = Long.toString(PROJECT_ID_LONG);
     String SUBJECT = "subject for project";
 
     // mocks
-    HttpServletRequest request = null;
-    HttpServletResponse response = null;
     ProjectManager projMan = null;
     Project proj = null;
-    PrintWriter pw = null;
 
     @BeforeMethod
     public void SetUp() throws IOException {
         projMan = mock(ProjectManager.class);
         ProjectManager.singleton = projMan;
         proj = mock(Project.class);
-        pw = mock(PrintWriter.class);
+        writer = new StringWriter();
 
         request = mock(HttpServletRequest.class);
         response = mock(HttpServletResponse.class);
@@ -108,7 +107,7 @@ public class SetProjectMetadataCommandTests extends RefineTest {
         when(proj.getMetadata()).thenReturn(metadata);
 
         try {
-            when(response.getWriter()).thenReturn(pw);
+            when(response.getWriter()).thenReturn(new PrintWriter(writer));
         } catch (IOException e1) {
             Assert.fail();
         }
@@ -121,7 +120,7 @@ public class SetProjectMetadataCommandTests extends RefineTest {
         projMan = null;
         ProjectManager.singleton = null;
         proj = null;
-        pw = null;
+        writer = null;
         request = null;
         response = null;
     }
@@ -155,15 +154,13 @@ public class SetProjectMetadataCommandTests extends RefineTest {
         } catch (IOException e) {
             Assert.fail();
         }
-        verify(pw, times(1)).write("{ \"code\" : \"ok\" }");
+        assertEqualsAsJson(writer.toString(), "{\"code\":\"ok\"}");
 
         Assert.assertEquals(proj.getMetadata().getSubject(), SUBJECT);
     }
 
     /**
      * set a user defined metadata field
-     * 
-     * @throws JSONException
      */
     @Test
     public void setUserMetadataFieldTest() {
@@ -191,7 +188,7 @@ public class SetProjectMetadataCommandTests extends RefineTest {
         } catch (IOException e) {
             Assert.fail();
         }
-        verify(pw, times(1)).write("{ \"code\" : \"ok\" }");
+        assertEqualsAsJson(writer.toString(), "{\"code\":\"ok\"}");
 
         ObjectNode obj = (ObjectNode) proj.getMetadata().getUserMetadata().get(0);
         Assert.assertEquals(obj.get("name").asText(), "clientID");
@@ -235,6 +232,20 @@ public class SetProjectMetadataCommandTests extends RefineTest {
         Assert.assertThrows(() -> meta.setAnyField("customMetadata", customMetadata));
     }
 
+    @Test
+    public void testCSRFProtection() throws ServletException, IOException {
+        when(request.getParameter("csrf_token")).thenReturn("");
+        SUT.doPost(request, response);
+        assertCSRFCheckFailed();
+    }
+
+    @Test
+    public void testNoProjectId() throws ServletException, IOException {
+        when(request.getParameter("project")).thenReturn(null);
+        SUT.doPost(request, response);
+        assertErrorNotCSRF();
+    }
+
     private String getValidCustomMetadata() {
         return "{\n" + //
                 "  \"name\":\"test\",\n" + //
diff --git a/main/tests/server/src/com/google/refine/commands/project/SetProjectTagsCommandTests.java b/main/tests/server/src/com/google/refine/commands/project/SetProjectTagsCommandTests.java
index bd2027755..3d72db5a4 100644
--- a/main/tests/server/src/com/google/refine/commands/project/SetProjectTagsCommandTests.java
+++ b/main/tests/server/src/com/google/refine/commands/project/SetProjectTagsCommandTests.java
@@ -1,24 +1,114 @@
 
 package com.google.refine.commands.project;
 
+import static com.google.refine.util.TestUtils.assertEqualsAsJson;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.testng.Assert.assertEquals;
+
 import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Arrays;
+import java.util.Map;
 
 import javax.servlet.ServletException;
 
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import org.testng.Assert;
 import org.testng.annotations.BeforeMethod;
 import org.testng.annotations.Test;
 
+import com.google.refine.ProjectManager;
+import com.google.refine.ProjectManagerStub;
+import com.google.refine.ProjectMetadata;
+import com.google.refine.commands.Command;
 import com.google.refine.commands.CommandTestBase;
+import com.google.refine.model.Project;
+import com.google.refine.model.ProjectStub;
+import com.google.refine.util.ParsingUtilities;
 
 public class SetProjectTagsCommandTests extends CommandTestBase {
 
+    private static long PROJECT_ID = 1234;
+    private String[] TAGS = new String[] { "tag1", "tag2" };
+    ProjectManager projectManager = null;
+    Project project = null;
+    PrintWriter pw = null;
+
     @BeforeMethod
-    public void setUpCommand() {
+    public void setUpCommand() throws JsonProcessingException {
         command = new SetProjectTagsCommand();
+
+        pw = new PrintWriter(writer);
+
+        ProjectMetadata metadata = new ProjectMetadata();
+        metadata.setUserMetadata((ArrayNode) ParsingUtilities.mapper.readTree("[ {name: \"clientID\", display: true} ]"));
+        metadata.setTags(TAGS);
+
+        projectManager = new ProjectManagerStub();
+        ProjectManager.singleton = projectManager;
+        project = new ProjectStub(PROJECT_ID);
+        ProjectManager.singleton.registerProject(project, metadata);
+
+        // mock dependencies
+        when(request.getParameter("project")).thenReturn(Long.toString(PROJECT_ID));
+        when(request.getParameter("csrf_token")).thenReturn(Command.csrfFactory.getFreshToken());
+
+        try {
+            when(response.getWriter()).thenReturn(pw);
+        } catch (IOException e1) {
+            Assert.fail();
+        }
+    }
+
+    @Test
+    public void setProjectTagsTest() {
+        // Check that our tags got set up correctly at project registration time.
+        assertEquals(ProjectManager.singleton.getAllProjectsTags().keySet().stream().sorted().toArray(String[]::new), TAGS);
+
+        when(request.getParameter("old")).thenReturn(String.join(",", TAGS));
+        when(request.getParameter("new")).thenReturn("a b c");
+        // run
+        try {
+            command.doPost(request, response);
+        } catch (ServletException e) {
+            Assert.fail();
+        } catch (IOException e) {
+            Assert.fail();
+        }
+
+        // verify
+        verify(request, times(1)).getParameter("csrf_token");
+        verify(request, times(1)).getParameter("project");
+
+        verify(response, times(1))
+                .setHeader("Content-Type", "application/json");
+        try {
+            verify(response, times(1)).getWriter();
+        } catch (IOException e) {
+            Assert.fail();
+        }
+        assertEqualsAsJson(writer.toString(), "{\"code\":\"ok\"}");
+
+        String[] tags = project.getMetadata().getTags();
+        assertEquals(tags.length, 3);
+        Arrays.sort(tags);
+        assertEquals(tags[1], "b");
+
+        Map<String, Integer> tagCounts = ProjectManager.singleton.getAllProjectsTags();
+        assertEquals(tagCounts.size(), 3);
+        assertEquals(ProjectManager.singleton.getAllProjectsTags().keySet().stream().sorted().toArray(String[]::new)[1], "b");
+
+        projectManager.deleteProject(PROJECT_ID);
+        // TODO: ProjectManagerStub doesn't manage tags on project delete, so this fails
+//        assertEquals(ProjectManager.singleton.getAllProjectsTags().size(), 0);
     }
 
     @Test
     public void testCSRFProtection() throws ServletException, IOException {
+        when(request.getParameter("csrf_token")).thenReturn(null);
         command.doPost(request, response);
         assertCSRFCheckFailed();
     }
diff --git a/main/tests/server/src/com/google/refine/io/FileProjectManagerTests.java b/main/tests/server/src/com/google/refine/io/FileProjectManagerTests.java
index a03583dba..dce133832 100644
--- a/main/tests/server/src/com/google/refine/io/FileProjectManagerTests.java
+++ b/main/tests/server/src/com/google/refine/io/FileProjectManagerTests.java
@@ -66,7 +66,6 @@ public class FileProjectManagerTests {
         protected FileProjectManagerStub(File dir) {
             super(dir);
             _projectsMetadata.put(5555L, mock(ProjectMetadata.class));
-
         }
     }
 
@@ -115,6 +114,7 @@ public class FileProjectManagerTests {
     public void deleteProjectAndSaveWorkspace() throws IOException {
         FileProjectManager manager = new FileProjectManagerStub(workspaceDir);
         manager.saveWorkspace();
+        // TODO: Test tag updating on project deletion
         manager.deleteProject(5555);
         manager.saveWorkspace();