diff --git a/tools/cldr-apps/js/src/esm/cldrAccount.mjs b/tools/cldr-apps/js/src/esm/cldrAccount.mjs
index f1a5df7968c..36056ac4274 100644
--- a/tools/cldr-apps/js/src/esm/cldrAccount.mjs
+++ b/tools/cldr-apps/js/src/esm/cldrAccount.mjs
@@ -763,14 +763,24 @@ function prettyLocaleList(locales) {
}
function getUserSeen(u) {
- const when = u.data.active ? u.data.active : u.data.seen;
- if (!when) {
- return "";
- }
+ const when = (u.data.active ? u.data.active : u.data.seen) || "never";
const what = u.data.active ? "active" : "seen";
let html = "" + what + ": " + when + "";
if (what === "seen") {
- html += "
" + u.data.lastlogin + "";
+ let created = u.data.firstdate;
+ if (!created) {
+ created = "unknown date; never voted?";
+ } else if (created < "2025-10-17") {
+ // Change 2023-05-04T20:00:00.000Z to 2023-05-04, for example.
+ created = "approximately " + created.replace(/T.+/, "");
+ }
+ const lastlogin = u.data.lastlogin || "never";
+ html +=
+ "
" +
+ lastlogin +
+ "
(account created " +
+ created +
+ ")";
}
return html;
}
diff --git a/tools/cldr-apps/js/src/esm/cldrAddUser.mjs b/tools/cldr-apps/js/src/esm/cldrAddUser.mjs
index aecfd485e4c..a5af025dffb 100644
--- a/tools/cldr-apps/js/src/esm/cldrAddUser.mjs
+++ b/tools/cldr-apps/js/src/esm/cldrAddUser.mjs
@@ -73,11 +73,11 @@ async function getOrgLocales(orgName) {
.doFetch(resource)
.then(cldrAjax.handleFetchErrors)
.then((r) => r.json())
- .then(setOrgLocales)
+ .then((json) => setOrgLocales(orgName, json))
.catch((e) => addError(`Error: ${e} getting org locales`));
}
-function setOrgLocales(json) {
+function setOrgLocales(orgName, json) {
if (json.err) {
cldrRetry.handleDisconnect(json.err, json, "", "Loading org locales");
return;
@@ -87,6 +87,7 @@ function setOrgLocales(json) {
? Object.keys(cldrLoad.getTheLocaleMap().locmap.locales).join(" ")
: json.locales;
callbackToSetData({
+ orgName,
orgLocales,
});
}
diff --git a/tools/cldr-apps/js/src/views/AddUser.vue b/tools/cldr-apps/js/src/views/AddUser.vue
index 7f116f9b213..925871b33d7 100644
--- a/tools/cldr-apps/js/src/views/AddUser.vue
+++ b/tools/cldr-apps/js/src/views/AddUser.vue
@@ -262,8 +262,8 @@ function setData(data) {
if (data.orgObject) {
setOrgData(data.orgObject);
}
- if (data.orgLocales) {
- setOrgLocales(data.orgLocales);
+ if (data.orgName && data.orgLocales) {
+ setOrgLocales(data.orgName, data.orgLocales);
}
if (data.error) {
justGotError.value = true; // See comment in errorsExist()
@@ -303,7 +303,8 @@ function setOrgData(orgObject) {
newUserOrg.value = "";
}
-function setOrgLocales(orgLocales) {
+function setOrgLocales(orgName, orgLocales) {
+ newUserOrg.value = orgName;
if (!orgLocales) {
console.error("No locales for organization " + newUserOrg.value);
}
diff --git a/tools/cldr-apps/src/main/java/org/unicode/cldr/web/DBUtils.java b/tools/cldr-apps/src/main/java/org/unicode/cldr/web/DBUtils.java
index f44c0efe2d5..11aa7a9a5dc 100644
--- a/tools/cldr-apps/src/main/java/org/unicode/cldr/web/DBUtils.java
+++ b/tools/cldr-apps/src/main/java/org/unicode/cldr/web/DBUtils.java
@@ -329,6 +329,18 @@ public static boolean tableHasColumn(Connection conn, String table, String colum
> 0;
}
+ public static boolean addColumnIfMissing(
+ Connection conn, String tableName, String columnName, String type) throws SQLException {
+ if (DBUtils.tableHasColumn(conn, tableName, columnName)) {
+ return false; // Did not create since it already exists
+ }
+ Statement s = conn.createStatement();
+ String sql = "ALTER TABLE " + tableName + " ADD COLUMN " + columnName + " " + type;
+ s.execute(sql);
+ s.close();
+ return true;
+ }
+
private static byte[] encode_u8(String what) {
byte[] u8;
if (what == null) {
diff --git a/tools/cldr-apps/src/main/java/org/unicode/cldr/web/STFactory.java b/tools/cldr-apps/src/main/java/org/unicode/cldr/web/STFactory.java
index 3e1af037dc7..550a1396b1d 100644
--- a/tools/cldr-apps/src/main/java/org/unicode/cldr/web/STFactory.java
+++ b/tools/cldr-apps/src/main/java/org/unicode/cldr/web/STFactory.java
@@ -1511,18 +1511,8 @@ public synchronized void setupDB() {
s.close();
s = null; // don't close twice.
System.err.println("Created table " + DBUtils.Table.VOTE_VALUE);
- } else if (!DBUtils.tableHasColumn(
- conn, DBUtils.Table.VOTE_VALUE.toString(), VOTE_TYPE)) {
- s = conn.createStatement();
- sql =
- "ALTER TABLE "
- + DBUtils.Table.VOTE_VALUE
- + " ADD COLUMN "
- + VOTE_TYPE
- + " TINYINT NOT NULL";
- s.execute(sql);
- s.close();
- s = null;
+ } else if (DBUtils.addColumnIfMissing(
+ conn, DBUtils.Table.VOTE_VALUE.toString(), VOTE_TYPE, "TINYINT NOT NULL")) {
System.err.println(
"Added column " + VOTE_TYPE + " to table " + DBUtils.Table.VOTE_VALUE);
}
diff --git a/tools/cldr-apps/src/main/java/org/unicode/cldr/web/SurveyJSONWrapper.java b/tools/cldr-apps/src/main/java/org/unicode/cldr/web/SurveyJSONWrapper.java
index 24618650164..ed57fe59387 100644
--- a/tools/cldr-apps/src/main/java/org/unicode/cldr/web/SurveyJSONWrapper.java
+++ b/tools/cldr-apps/src/main/java/org/unicode/cldr/web/SurveyJSONWrapper.java
@@ -91,7 +91,8 @@ public static JSONObject wrap(UserRegistry.User u) throws JSONException {
.put("emailHash", u.getEmailHash())
.put("userlevelName", u.getLevel())
.put("org", u.org)
- .put("time", u.last_connect);
+ .put("firstdate", u.firstdate)
+ .put("time", u.lastlogin);
}
public static JSONObject wrap(CheckCLDR check) throws JSONException {
diff --git a/tools/cldr-apps/src/main/java/org/unicode/cldr/web/UserList.java b/tools/cldr-apps/src/main/java/org/unicode/cldr/web/UserList.java
index c8e33dabdfd..c33217d5d6a 100644
--- a/tools/cldr-apps/src/main/java/org/unicode/cldr/web/UserList.java
+++ b/tools/cldr-apps/src/main/java/org/unicode/cldr/web/UserList.java
@@ -446,8 +446,7 @@ private void putShownUser(JSONArray shownUsers, UserSettings u) throws JSONExcep
(u.session == null)
? ""
: SurveyMain.timeDiff(u.session.getLastBrowserCallMillisSinceEpoch());
- String seen =
- (user.last_connect == null) ? "" : SurveyMain.timeDiff(user.last_connect.getTime());
+ String seen = (user.lastlogin == null) ? "" : SurveyMain.timeDiff(user.lastlogin.getTime());
boolean havePermToChange = me.isAdminFor(user);
boolean userCanDeleteUser = UserRegistry.userCanDeleteUser(me, user.id, user.userlevel);
VoteResolver.Level level = VoteResolver.Level.fromSTLevel(user.userlevel);
@@ -461,7 +460,8 @@ private void putShownUser(JSONArray shownUsers, UserSettings u) throws JSONExcep
.put("havePermToChange", havePermToChange)
.put("id", user.id)
.put("intlocs", user.intlocs)
- .put("lastlogin", user.last_connect)
+ .put("firstdate", user.firstdate)
+ .put("lastlogin", user.lastlogin)
.put("locales", normalizeLocales(user.locales))
.put("badLocales", user.badLocales)
.put("name", user.name)
diff --git a/tools/cldr-apps/src/main/java/org/unicode/cldr/web/UserRegistry.java b/tools/cldr-apps/src/main/java/org/unicode/cldr/web/UserRegistry.java
index c36f4390c8f..19b2d0ac773 100644
--- a/tools/cldr-apps/src/main/java/org/unicode/cldr/web/UserRegistry.java
+++ b/tools/cldr-apps/src/main/java/org/unicode/cldr/web/UserRegistry.java
@@ -6,12 +6,19 @@
package org.unicode.cldr.web;
+import static java.lang.Math.abs;
+
import com.google.gson.JsonSyntaxException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
@@ -19,6 +26,7 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.TimeZone;
import java.util.TreeMap;
import java.util.TreeSet;
import org.apache.commons.codec.digest.DigestUtils;
@@ -152,27 +160,28 @@ public static String levelAsStr(int level) {
public static final String CLDR_INTEREST = "cldr_interest";
- public static final String SQL_insertStmt =
+ private static final String SQL_insertStmt =
"INSERT INTO "
+ CLDR_USERS
- + "(userlevel,name,org,email,password,locales,lastlogin) "
- + "VALUES(?,?,?,?,?,?,NULL)";
- public static final String SQL_queryStmt_FRO =
- "SELECT id,name,userlevel,org,locales,intlocs,lastlogin from "
+ + "(userlevel,name,org,email,password,locales,firstdate,lastlogin) "
+ + "VALUES(?,?,?,?,?,?,?,NULL)";
+ private static final String SQL_queryStmt_FRO =
+ "SELECT id,name,userlevel,org,locales,intlocs,firstdate,lastlogin FROM "
+ CLDR_USERS
- + " where email=? AND password=?";
- public static final String SQL_queryIdStmt_FRO =
- "SELECT name,org,email,userlevel,intlocs,locales,lastlogin,password from "
+ + " WHERE email=? AND password=?";
+ private static final String SQL_queryIdStmt_FRO =
+ "SELECT name,org,email,userlevel,intlocs,locales,firstdate,lastlogin,password FROM "
+ CLDR_USERS
- + " where id=?";
- public static final String SQL_queryEmailStmt_FRO =
- "SELECT id,name,userlevel,org,locales,intlocs,lastlogin,password from "
+ + " WHERE id=?";
+ private static final String SQL_queryEmailStmt_FRO =
+ "SELECT id,name,userlevel,org,locales,intlocs,firstdate,lastlogin,password FROM "
+ CLDR_USERS
- + " where email=?";
- public static final String SQL_touchStmt =
- "UPDATE " + CLDR_USERS + " set lastlogin=CURRENT_TIMESTAMP where id=?";
- public static final String SQL_removeIntLoc = "DELETE FROM " + CLDR_INTEREST + " WHERE uid=?";
- public static final String SQL_updateIntLoc =
+ + " WHERE email=?";
+
+ private static final String SQL_touchStmt =
+ "UPDATE " + CLDR_USERS + " SET lastlogin=CURRENT_TIMESTAMP WHERE id=?";
+ private static final String SQL_removeIntLoc = "DELETE FROM " + CLDR_INTEREST + " WHERE uid=?";
+ private static final String SQL_updateIntLoc =
"INSERT INTO " + CLDR_INTEREST + " (uid,forum) VALUES(?,?)";
private UserSettingsData userSettings = null;
@@ -185,14 +194,14 @@ public class User implements Comparable, UserInfo, JSONString {
@Schema(description = "User ID")
public int id; // id number
- @Schema(description = "numeric userlevel")
+ @Schema(description = "Numeric userlevel")
public int userlevel = LOCKED; // user level
@Schema(hidden = true)
private String password; // password
@Schema(description = "User email")
- public String email; //
+ public String email;
@Schema(description = "User org")
public String org; // organization
@@ -200,13 +209,19 @@ public class User implements Comparable, UserInfo, JSONString {
@Schema(description = "User name")
public String name; // full name
+ @Schema(
+ description = "User account creation date, approximate if before 2025-10-17",
+ implementation = java.util.Date.class)
+ public Timestamp firstdate;
+
@Schema(name = "time", implementation = java.util.Date.class)
- public java.sql.Timestamp last_connect;
+ // Here and in some http responses, lastlogin is named "time".
+ public java.sql.Timestamp lastlogin;
@Schema(hidden = true)
public String locales;
- @Schema(name = "badLocales", description = "set of incorrectly specified locales")
+ @Schema(name = "badLocales", description = "Set of incorrectly specified locales")
public String[] badLocales = null;
@Schema(hidden = true)
@@ -638,13 +653,13 @@ public void setLocales(String list, String rawList) {
} else {
locales = list;
}
- Set localeSet = new HashSet();
+ Set localeSet = new HashSet<>();
for (final String s : LocaleNormalizer.splitToArray(list)) {
if (!LocaleNormalizer.isAllLocales(s)) {
localeSet.add(s);
}
}
- Set rawSet = new HashSet();
+ Set rawSet = new HashSet<>();
for (final String s : LocaleNormalizer.splitToArray(rawList)) {
if (!LocaleNormalizer.isAllLocales(s)) {
rawSet.add(s);
@@ -652,7 +667,7 @@ public void setLocales(String list, String rawList) {
}
// take out the rawSet as un-normalized
rawSet.removeAll(localeSet);
- Set badSet = new TreeSet();
+ Set badSet = new TreeSet<>();
badSet.addAll(
rawSet); // anything still in the rawSet is bad somehow, it's un-normalized.
// anything in the localeSet that's not an extant locale
@@ -745,6 +760,10 @@ private void setupDB() throws SQLException {
createUserTable(conn);
conn.commit();
} else {
+ if (!DBUtils.tableHasColumn(conn, CLDR_USERS, "firstdate")) {
+ logger.warning("firstdate column was missing; calling addFirstDateColumn");
+ addFirstDateColumn(conn);
+ }
/* update table to DATETIME instead of TIMESTAMP */
Statement s = conn.createStatement();
sql = "alter table cldr_users change lastlogin lastlogin DATETIME";
@@ -812,41 +831,32 @@ private void createUserTable(Connection conn) throws SQLException {
Statement s = conn.createStatement();
sql =
- ("create table "
+ ("CREATE TABLE "
+ CLDR_USERS
+ "(id INT NOT NULL "
+ DBUtils.DB_SQL_IDENTITY
+ ", "
- + "userlevel int not null, "
+ + "userlevel INT NOT NULL, "
+ "name "
+ DBUtils.DB_SQL_UNICODE
- + " not null, "
- + "email varchar(128) not null UNIQUE, "
- + "org varchar(256) not null, "
- + "password varchar(100) not null, "
- + "audit varchar(1024) , "
- + "locales varchar(1024) , "
- +
- // "prefs varchar(1024) , " + /* deprecated Dec 2010. Not used
- // anywhere */
- "intlocs varchar(1024) , "
- + // added apr 2006: ALTER table
- // CLDR_USERS ADD COLUMN intlocs
- // VARCHAR(1024)
- "lastlogin "
+ + " NOT NULL, "
+ + "email VARCHAR(128) NOT NULL UNIQUE, "
+ + "org VARCHAR(256) NOT NULL, "
+ + "password VARCHAR(100) NOT NULL, "
+ + "audit VARCHAR(1024) , "
+ + "locales VARCHAR(1024) , "
+ + "intlocs VARCHAR(1024) , "
+ + "firstdate "
+ DBUtils.DB_SQL_TIMESTAMP0
- + // added may 2006:
- // alter table
- // CLDR_USERS ADD
- // COLUMN lastlogin
- // TIMESTAMP
- (!DBUtils.db_Mysql ? ",primary key(id)" : "")
+ + " , lastlogin "
+ + DBUtils.DB_SQL_TIMESTAMP0
+ + (!DBUtils.db_Mysql ? ",PRIMARY KEY(id)" : "")
+ ")");
s.execute(sql);
sql =
("INSERT INTO "
+ CLDR_USERS
- + "(userlevel,name,org,email,password) "
+ + "(userlevel,name,org,email,password,firstdate) "
+ "VALUES("
+ ADMIN
+ ","
@@ -857,6 +867,8 @@ private void createUserTable(Connection conn) throws SQLException {
+ "',"
+ "'"
+ SurveyMain.vap
+ + "', '"
+ + DBUtils.sqlNow()
+ "')");
s.execute(sql);
SurveyLog.debug("DB: added user Admin");
@@ -941,8 +953,9 @@ public UserRegistry.User getInfo(int id) {
u.intlocs = rs.getString(5);
final String locales = rs.getString(6);
u.setLocales(LocaleNormalizer.normalizeQuietly(locales), locales);
- u.last_connect = rs.getTimestamp(7);
- u.password = rs.getString(8);
+ u.firstdate = rs.getTimestamp(7);
+ u.lastlogin = rs.getTimestamp(8);
+ u.password = rs.getString(9);
ret = u; // let it finish..
if (id >= arraySize) {
@@ -1031,10 +1044,10 @@ public void touch(int id) {
*/
public UserRegistry.User get(String pass, String email, String ip, boolean letmein)
throws LogoutException {
- if ((email == null) || (email.length() == 0)) {
+ if ((email == null) || (email.isEmpty())) {
return null; // nothing to do
}
- if (((pass != null && pass.length() == 0)) && !letmein) {
+ if (((pass != null && pass.isEmpty())) && !letmein) {
return null; // nothing to do
}
@@ -1072,7 +1085,8 @@ public UserRegistry.User get(String pass, String email, String ip, boolean letme
u.org = rs.getString(4);
u.setLocales(rs.getString(5));
u.intlocs = rs.getString(6);
- u.last_connect = rs.getTimestamp(7);
+ u.firstdate = rs.getTimestamp(7);
+ u.lastlogin = rs.getTimestamp(8);
u.claSigned = (u.getCla() != null);
// good so far..
@@ -1171,14 +1185,14 @@ public java.sql.PreparedStatement list(String organization, Connection conn)
return DBUtils.prepareStatementForwardReadOnly(
conn,
"listAllUsers",
- "SELECT id,userlevel,name,email,org,locales,intlocs,lastlogin FROM "
+ "SELECT id,userlevel,name,email,org,locales,intlocs,firstdate,lastlogin FROM "
+ CLDR_USERS
+ " ORDER BY org,userlevel,name ");
} else {
PreparedStatement ps =
DBUtils.prepareStatementWithArgsFRO(
conn,
- "SELECT id,userlevel,name,email,org,locales,intlocs,lastlogin FROM "
+ "SELECT id,userlevel,name,email,org,locales,intlocs,firstdate,lastlogin FROM "
+ CLDR_USERS
+ " WHERE org=? ORDER BY org,userlevel,name");
ps.setString(1, organization);
@@ -1774,6 +1788,7 @@ public User newUser(WebContext ctx, User u) {
u.name = u.name.replace('\'', '_');
final String locales = (u.locales == null) ? "" : u.locales.replace('\'', '_');
u.setLocales(LocaleNormalizer.normalizeQuietly(locales), locales);
+ u.firstdate = DBUtils.sqlNow();
Connection conn = null;
PreparedStatement insertStmt = null;
@@ -1786,6 +1801,7 @@ public User newUser(WebContext ctx, User u) {
insertStmt.setString(4, u.email);
insertStmt.setString(5, u.getPassword());
insertStmt.setString(6, u.locales);
+ insertStmt.setTimestamp(7, u.firstdate);
if (!insertStmt.execute()) {
if (!hushUserMessages) logger.info("Added.");
conn.commit();
@@ -2121,7 +2137,7 @@ public synchronized Map getVoterToInfo() {
conn = DBUtils.getInstance().getAConnection();
ps = list(null, conn);
rs = ps.executeQuery();
- // id,userlevel,name,email,org,locales,intlocs,lastlogin
+ // id,userlevel,name,email,org,locales,intlocs,firstdate,lastlogin
while (rs.next()) {
// We don't go through the cache, because not all users may
// be loaded.
@@ -2134,7 +2150,8 @@ public synchronized Map getVoterToInfo() {
u.org = rs.getString(5);
u.setLocales(rs.getString(6));
u.intlocs = rs.getString(7);
- u.last_connect = rs.getTimestamp(8);
+ u.firstdate = rs.getTimestamp(8);
+ u.lastlogin = rs.getTimestamp(9);
// now, map it to a UserInfo
VoterInfo v = u.createVoterInfo();
@@ -2240,7 +2257,7 @@ private Set getAnonymousUsersFromDb() {
conn = DBUtils.getInstance().getAConnection();
ps = list(null, conn);
rs = ps.executeQuery();
- // id,userlevel,name,email,org,locales,intlocs,lastlogin
+ // id,userlevel,name,email,org,locales,intlocs,firstdate,lastlogin
while (rs.next()) {
int userlevel = rs.getInt(2);
if (userlevel == ANONYMOUS) {
@@ -2251,7 +2268,8 @@ private Set getAnonymousUsersFromDb() {
u.org = rs.getString(5);
u.setLocales(rs.getString(6));
u.intlocs = rs.getString(7);
- u.last_connect = rs.getTimestamp(8);
+ u.firstdate = rs.getTimestamp(8);
+ u.lastlogin = rs.getTimestamp(9);
u.claSigned = true;
set.add(u);
}
@@ -2424,4 +2442,399 @@ private void normalizeUserTableOrgs() {
DBUtils.close(rs, ps, conn);
}
}
+
+ /**
+ * Create and populate the firstdate column. For each user, set firstdate based on the oldest
+ * version in which they voted, according to vote tables from version 25 and later; users who
+ * never voted get values based on user ID numbers, which are in chronological order. This is
+ * expected to be a one-time operation performed 2015-10 by ST at startup before the start of
+ * version v49. Subsequently new users will get firstdate set when their accounts are created.
+ *
+ * @param conn the db connection
+ * @throws SQLException if error
+ */
+ private void addFirstDateColumn(Connection conn) throws SQLException {
+ logger.warning("Starting addFirstDateColumn");
+ final long timeStart = System.currentTimeMillis();
+ // First get the list of all users from the db users table, and map each one to
+ // placeholder version zero.
+ final TreeMap userFirstVersion = new TreeMap<>();
+ final String sql = "SELECT id FROM " + CLDR_USERS;
+ ResultSet rs = null;
+ PreparedStatement ps = null;
+ try {
+ ps = conn.prepareStatement(sql);
+ rs = ps.executeQuery();
+ while (rs.next()) {
+ int userId = rs.getInt(1);
+ if (userId < 1 || userId > 3300) {
+ logger.warning("UNEXPECTED userId: " + userId);
+ }
+ userFirstVersion.put(userId, 0);
+ }
+ } finally {
+ DBUtils.close(rs, ps);
+ }
+ // Next get the first version for each user in the map.
+ final Map verToDate = mapVersionToFirstDate(conn);
+ Map verToLastUser = new TreeMap<>();
+ getFirstVersions(conn, userFirstVersion, verToDate, verToLastUser);
+ improveFirstVersions(userFirstVersion, verToLastUser);
+
+ // Finally, create the new firstdate column, and populate it using userFirstDate.
+ if (DBUtils.addColumnIfMissing(conn, CLDR_USERS, "firstdate", DBUtils.DB_SQL_TIMESTAMP0)) {
+ logger.warning("firstdate column was missing; added; calling populateFirstDateColumn");
+ populateFirstDateColumn(conn, userFirstVersion, verToDate);
+ } else {
+ logger.severe("addFirstDateColumn failed to add column");
+ }
+ long elapsedTime = (System.currentTimeMillis() - timeStart) / (1000 * 60);
+ logger.warning("Finished addFirstDateColumn, elapsed time = " + elapsedTime + " minutes");
+ }
+
+ /**
+ * Replace the placeholder time stamps in the given map with the first date for each user
+ *
+ * @param conn the db connection
+ * @param userFirstVersion map from user ID to version number, to be modified
+ * @param verToDate map from version number to date
+ * @param verToLastUser map from version number to the highest user ID that voted in that
+ * version
+ * @throws SQLException if error
+ */
+ private void getFirstVersions(
+ Connection conn,
+ Map userFirstVersion,
+ Map verToDate,
+ Map verToLastUser)
+ throws SQLException {
+ long timeStart = System.currentTimeMillis();
+ int fixedCountAllVersions = 0;
+ for (int ver : verToDate.keySet()) {
+ int fixedCountThisVersion = 0;
+ String voteTable =
+ DBUtils.Table.VOTE_VALUE.forVersion(Integer.toString(ver), false).toString();
+ if (DBUtils.hasTable(voteTable)) {
+ // Basically "SELECT DISTINCT submitter FROM " + voteTable, but
+ // skip votes copied from one user to another ("WHERE NOT EXISTS ...").
+ // Ignore the locale (omit "b.locale = a.locale AND") since copying has
+ // evidently occurred between locales such as "nb" and "no"; assume that
+ // exactly matching timestamps (last_mod) for the same xpath are not coincidental.
+ final String sql =
+ "SELECT DISTINCT submitter FROM "
+ + voteTable
+ + " AS a WHERE NOT EXISTS (SELECT * FROM "
+ + voteTable
+ + " AS b WHERE b.xpath = a.xpath AND b.last_mod = a.last_mod "
+ + "AND b.submitter < a.submitter)";
+ logger.warning("sql = " + sql);
+ PreparedStatement ps = null;
+ ResultSet rs = null;
+ ArrayList idList = new ArrayList<>();
+ try {
+ ps = conn.prepareStatement(sql);
+ rs = ps.executeQuery();
+ while (rs.next()) {
+ int userId = rs.getInt("submitter");
+ idList.add(userId);
+ if (!userFirstVersion.containsKey(userId)) {
+ logger.warning(
+ "Vote table has user missing from users table, skipping user ID: "
+ + userId);
+ } else if (userFirstVersion.get(userId) == 0) {
+ userFirstVersion.put(userId, ver);
+ ++fixedCountAllVersions;
+ ++fixedCountThisVersion;
+ }
+ }
+ } finally {
+ DBUtils.close(ps, rs);
+ }
+ logger.warning(
+ "Got users from version "
+ + ver
+ + "; fixed (in this vote table): "
+ + fixedCountThisVersion
+ + "; so far fixed (in any vote table): "
+ + fixedCountAllVersions
+ + "; seconds elapsed = "
+ + (System.currentTimeMillis() - timeStart) / 1000);
+ Collections.sort(idList);
+ Collections.reverse(idList);
+ int idListSize = idList.size();
+ if (idListSize > 10) {
+ idList.subList(10, idListSize).clear();
+ }
+ verToLastUser.put(ver, idList.get(0));
+ logger.warning("Highest IDs in version " + ver + " are: " + idList);
+ }
+ }
+ }
+
+ /**
+ * Take advantage of the fact that user id is assigned sequentially in chronological order, to
+ * fill in the missing first-version (for users not found in the vote tables), and also to
+ * improve first-version that are out of order (for users who never voted or were added long
+ * before they made votes that are found in the vote tables).
+ *
+ * @param userFirstVersion map from user ID to version number, to be modified
+ * @param verToLastUser map from version number to the highest user ID that voted in that
+ * version
+ */
+ private void improveFirstVersions(
+ TreeMap userFirstVersion, Map verToLastUser) {
+ conformToNeighbors(userFirstVersion);
+ improveByOrder(userFirstVersion);
+ flagByRanges(userFirstVersion, verToLastUser);
+ }
+
+ private void improveByOrder(TreeMap userFirstVersion) {
+ int goodFirstVersion = 0;
+ for (int userId : userFirstVersion.descendingKeySet()) {
+ int ver = userFirstVersion.get(userId);
+ if (goodFirstVersion == 0) {
+ if (ver != 0) {
+ goodFirstVersion = ver;
+ }
+ } else if (ver == 0) {
+ logger.warning(
+ "improveByOrder: userId = "
+ + userId
+ + "; changing ver zero to "
+ + goodFirstVersion);
+ userFirstVersion.put(userId, goodFirstVersion);
+ } else if (ver > goodFirstVersion) {
+ logger.warning(
+ "improveByOrder: userId = "
+ + userId
+ + "; changing ver "
+ + ver
+ + " to "
+ + goodFirstVersion);
+ userFirstVersion.put(userId, goodFirstVersion);
+ } else if (ver < goodFirstVersion) {
+ long versionGap = goodFirstVersion - ver;
+ if (versionGap > 2) {
+ logger.warning(
+ "improveByOrder: userId = "
+ + userId
+ + "; NOT changing goodFirstVersion "
+ + goodFirstVersion
+ + " to "
+ + ver
+ + " since the gap is too large, versionGap = "
+ + versionGap);
+ } else {
+ logger.warning(
+ "improveByOrder: userId = "
+ + userId
+ + "; changing goodFirstVersion "
+ + goodFirstVersion
+ + " to "
+ + ver
+ + "; versionGap = "
+ + versionGap);
+ goodFirstVersion = ver;
+ }
+ }
+ }
+ }
+
+ private void flagByRanges(
+ TreeMap userFirstVersion, Map verToLastUser) {
+ for (int userId : userFirstVersion.keySet()) {
+ int ver = userFirstVersion.get(userId);
+ if (ver == 0) {
+ continue;
+ }
+ if (!verToLastUser.containsKey(ver)) {
+ logger.warning("flagByRanges: version " + ver + " is missing from verToLastUser");
+ continue;
+ }
+ int lastUserPerRange = verToLastUser.get(ver);
+ if (userId == lastUserPerRange + 1) {
+ verToLastUser.put(ver, userId); // OK, extend the range
+ } else if (userId > lastUserPerRange) {
+ logger.warning(
+ "flagByRanges: userId = "
+ + userId
+ + "; out of range for version "
+ + ver
+ + "; lastUserPerRange = "
+ + lastUserPerRange);
+ }
+ }
+ }
+
+ /**
+ * Fix values that are too far removed from the mean of their close neighbors. For example, the
+ * user ID might imply the account was created in version 30, but the vote table might indicate
+ * the user voted in version 25. Such anomalies probably result from copying votes from one user
+ * to another.
+ *
+ * @param userFirstVersion map from user ID to version number, to be modified
+ */
+ private void conformToNeighbors(TreeMap userFirstVersion) {
+ // Omit user IDs that map to zero, and separate the remainder into a
+ // list of users and a list of corresponding versions.
+ ArrayList denseUsers = new ArrayList<>();
+ ArrayList denseVers = new ArrayList<>();
+ for (int userId : userFirstVersion.keySet()) {
+ int ver = userFirstVersion.get(userId);
+ if (ver != 0) {
+ denseUsers.add(userId);
+ denseVers.add(ver);
+ }
+ }
+ int fixedCount = 0;
+ for (int i = 0; i < denseVers.size(); i++) {
+ int ver = denseVers.get(i);
+ int userId = denseUsers.get(i);
+ int fixedVer = fixAnomaly(denseVers, i);
+ if (fixedVer < ver) {
+ userFirstVersion.put(userId, fixedVer);
+ logger.warning(
+ "Anomaly fixed: userId = "
+ + userId
+ + "; ver = "
+ + ver
+ + " changed to "
+ + fixedVer);
+ ++fixedCount;
+ } else if (fixedVer > ver) {
+ logger.warning(
+ "Anomaly NOT fixed since fixed version would be later: userId = "
+ + userId
+ + "; ver = "
+ + ver
+ + " NOT changed to "
+ + fixedVer);
+ }
+ }
+ logger.warning("Total " + fixedCount + " anomalies fixed");
+ }
+
+ /**
+ * If the element at the specified index differs too much from the mean value of its neighbors,
+ * return that mean value. Otherwise, return the element unchanged.
+ *
+ * @param a the array
+ * @param i the index
+ * @return the possibly corrected value
+ */
+ private int fixAnomaly(ArrayList a, int i) {
+ final int RANGE = 10; // how many neighbors to check on each side
+ int start = i - RANGE;
+ if (start < 0) {
+ start = 0;
+ }
+ int end = i + RANGE;
+ if (end > a.size() - 1) {
+ end = a.size() - 1;
+ }
+ ArrayList b = new ArrayList<>();
+ for (int j = start; j < end; j++) {
+ if (j != i) {
+ b.add(a.get(j));
+ }
+ }
+ int[] c = new int[b.size()];
+ for (int j = 0; j < b.size(); j++) {
+ c[j] = b.get(j);
+ }
+ double m = median(c);
+ int val = a.get(i);
+ double delta = abs(m - val);
+ return (delta > 1.0) ? (int) Math.round(m) : val;
+ }
+
+ static double median(int[] x) {
+ Arrays.sort(x);
+ int n = x.length;
+ if (n % 2 != 0) {
+ return x[n / 2];
+ } else {
+ return (double) (x[(n - 1) / 2] + x[n / 2]) / 2.0;
+ }
+ }
+
+ private Map mapVersionToFirstDate(Connection conn) throws SQLException {
+ // As of 2025-10, only these 19 versions have votes:
+ // 25, 26, 28, 30, 32, 33, 34, 35, 36, 37, 38, 40, 41, 42, 43, 44, 45, 46, 48
+ final int firstVersion = SurveyAjax.oldestVersionForImportingVotes;
+ final int lastVersion = Integer.parseInt(SurveyMain.getNewVersion());
+ Map verToDate = new TreeMap<>();
+ for (int ver = firstVersion; ver <= lastVersion; ver++) {
+ String voteTable =
+ DBUtils.Table.VOTE_VALUE.forVersion(Integer.toString(ver), false).toString();
+ if (DBUtils.hasTable(voteTable)) {
+ ResultSet rs = null;
+ PreparedStatement ps = null;
+ try {
+ final String sql =
+ "SELECT last_mod FROM " + voteTable + " ORDER BY last_mod LIMIT 1";
+ ps = conn.prepareStatement(sql);
+ rs = ps.executeQuery();
+ while (rs.next()) {
+ Timestamp date = rs.getTimestamp(1);
+ Timestamp adjustedDate = removeTime(date);
+ verToDate.put(ver, adjustedDate);
+ logger.warning(
+ "Version "
+ + ver
+ + " first date = "
+ + adjustedDate
+ + " (adjusted from "
+ + date
+ + ")");
+ }
+ } finally {
+ DBUtils.close(ps, rs);
+ }
+ }
+ }
+ return verToDate;
+ }
+
+ // Convert 2025-04-10 16:53:25 to 2025-04-10 00:00:00, for example
+ private static Timestamp removeTime(Timestamp timestamp) {
+ Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC"));
+ cal.setTime(timestamp);
+ cal.set(Calendar.HOUR_OF_DAY, 0);
+ cal.set(Calendar.MINUTE, 0);
+ cal.set(Calendar.SECOND, 0);
+ cal.set(Calendar.MILLISECOND, 0);
+ return new Timestamp(cal.getTimeInMillis());
+ }
+
+ /**
+ * Populate the firstdate column in the db based on the given maps
+ *
+ * @param conn the db connection
+ * @param userFirstVersion map from user ID to version number, already completed
+ * @param verToDate map from version number to date
+ * @throws SQLException if error
+ */
+ private void populateFirstDateColumn(
+ Connection conn,
+ Map userFirstVersion,
+ Map verToDate)
+ throws SQLException {
+ final String sql = "UPDATE " + CLDR_USERS + " SET firstdate=? WHERE id=?";
+ PreparedStatement ps = null;
+ try {
+ ps = conn.prepareStatement(sql);
+ for (int userId : userFirstVersion.keySet()) {
+ int ver = userFirstVersion.get(userId);
+ if (ver != 0) {
+ Timestamp firstdate = verToDate.get(ver);
+ ps.setTimestamp(1, firstdate);
+ ps.setInt(2, userId);
+ ps.executeUpdate();
+ }
+ }
+ } finally {
+ DBUtils.close(ps);
+ }
+ }
}