Sat, 17 May 2025 17:39:48 +0200
add "what's new" popup - resolves #670
--- a/build.gradle.kts Fri Mar 14 08:09:05 2025 +0100 +++ b/build.gradle.kts Sat May 17 17:39:48 2025 +0200 @@ -5,7 +5,7 @@ war } group = "de.uapcore" -version = "1.5.1" +version = "1.6.0" repositories { mavenCentral()
--- a/setup/postgres/psql_create_tables.sql Fri Mar 14 08:09:05 2025 +0100 +++ b/setup/postgres/psql_create_tables.sql Sat May 17 17:39:48 2025 +0200 @@ -1,10 +1,11 @@ create table lpit_user ( - userid serial primary key, - username text not null unique, - mail text, - lastname text, - givenname text + userid serial primary key, + username text not null unique, + mail text, + lastname text, + givenname text, + knows_updates_until timestamp with time zone ); create type vcstype as enum ('None', 'Mercurial', 'Git');
--- a/setup/postgres/psql_patch_1.5.0.sql Fri Mar 14 08:09:05 2025 +0100 +++ b/setup/postgres/psql_patch_1.5.0.sql Sat May 17 17:39:48 2025 +0200 @@ -1,4 +1,4 @@ --- apply this script to patch a version < 1.5.0 database to version 1.5.0 +-- apply this script to patch a version 1.1.0 database to version 1.5.0 alter table lpit_issue_history_event add userid integer null references lpit_user (userid) on delete set null;
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/setup/postgres/psql_patch_1.6.0.sql Sat May 17 17:39:48 2025 +0200 @@ -0,0 +1,3 @@ +-- apply this script to patch a version 1.5.0 database to version 1.6.0 + +alter table lpit_user add knows_updates_until timestamp with time zone;
--- a/src/main/kotlin/de/uapcore/lightpit/AbstractServlet.kt Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/AbstractServlet.kt Sat May 17 17:39:48 2025 +0200 @@ -28,6 +28,7 @@ import de.uapcore.lightpit.DataSourceProvider.Companion.SC_ATTR_NAME import de.uapcore.lightpit.dao.DataAccessObject import de.uapcore.lightpit.dao.createDataAccessObject +import de.uapcore.lightpit.entities.User import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServlet import jakarta.servlet.http.HttpServletRequest @@ -35,6 +36,7 @@ import java.sql.SQLException import java.time.ZoneId import java.util.* +import java.sql.Date as SqlDate abstract class AbstractServlet : HttpServlet() { @@ -89,8 +91,10 @@ ) { val params = mapping.first.obtainPathParameters(sanitizedRequestPath(req)) val method = mapping.second + val authenticatedUser = req.remoteUser?.let(dao::findUserByName) + showWhatsNewPopup(authenticatedUser, req, dao) logger.trace("invoke {0}", method) - method(HttpRequest(req, resp, params), dao) + method(HttpRequest(authenticatedUser, req, resp, params), dao) } private fun sanitizedRequestPath(req: HttpServletRequest) = req.pathInfo ?: "/" @@ -124,9 +128,7 @@ req.characterEncoding = "UTF-8" // set some internal request attributes - val http = HttpRequest(req, resp) val fullPath = req.servletPath + Optional.ofNullable(req.pathInfo).orElse("") - req.setAttribute(Constants.REQ_ATTR_BASE_HREF, http.baseHref) req.setAttribute(Constants.REQ_ATTR_PATH, fullPath) req.getHeader("Referer")?.let { // TODO: add a sanity check to avoid link injection @@ -136,7 +138,7 @@ // choose the requested language as session language (if available) if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) { // language selection stored in cookie - val cookieLocale = cookieLanguage(http) + val cookieLocale = cookieLanguage(req) // if no cookie, fall back to request locale a.k.a "Browser Language" val reqLocale = cookieLocale ?: req.locale @@ -145,7 +147,7 @@ val sessionLocale = if (availableLanguages.contains(reqLocale)) reqLocale else availableLanguages.first() // select the language (this will also refresh the cookie max-age) - selectLanguage(http, sessionLocale) + selectLanguage(req, resp, sessionLocale) logger.debug( "Setting language for new session {0}: {1}", session.id, sessionLocale.displayLanguage @@ -159,17 +161,18 @@ // determine the timezone if (session.getAttribute(Constants.SESSION_ATTR_TIMEZONE) == null) { // timezone selection stored in cookie - val cookieTimezone = cookieTimezone(http) + val cookieTimezone = cookieTimezone(req) // if no cookie, fall back to server's timezone (the browser does not transmit one) val timezone = cookieTimezone ?: ZoneId.systemDefault() - selectTimezone(http, timezone) + selectTimezone(req, resp, timezone) logger.debug("Timezone for session {0} set to {1}", session.id, timezone) } // if this is an error path, bypass the normal flow if (fullPath.startsWith("/error/")) { + val http = HttpRequest(null, req, resp) http.styleSheets = listOf("error") http.render("error") return @@ -210,6 +213,16 @@ } } + private fun showWhatsNewPopup(user: User?, req: HttpServletRequest, dao: DataAccessObject) { + if (user == null) return + logger.trace("show user with ID {0} what's new", user.id) + val userKnowsUpdatesUntil = dao.untilWhenUserKnowsUpdates(user) + if (userKnowsUpdatesUntil == null || userKnowsUpdatesUntil.before(SqlDate.valueOf(Constants.VERSION_DATE))) { + dao.updateUserKnowsUpdates(user) + req.setAttribute("showWhatsNew", true) + } + } + override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) { doProcess(req, resp, getMappings) } @@ -224,40 +237,48 @@ return locales.ifEmpty { listOf(Locale.ENGLISH) } } - private fun cookieLanguage(http: HttpRequest): Locale? = - http.request.cookies?.firstOrNull { c -> c.name == LANGUAGE_COOKIE_NAME } + private fun cookieLanguage(request: HttpServletRequest): Locale? = + request.cookies?.firstOrNull { c -> c.name == LANGUAGE_COOKIE_NAME } ?.runCatching {Locale.forLanguageTag(this.value)}?.getOrNull() protected fun sessionLanguage(http: HttpRequest) = http.session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) as Locale - private fun cookieTimezone(http: HttpRequest): ZoneId? = - http.request.cookies?.firstOrNull { c -> c.name == TIMEZONE_COOKIE_NAME } + private fun cookieTimezone(request: HttpServletRequest): ZoneId? = + request.cookies?.firstOrNull { c -> c.name == TIMEZONE_COOKIE_NAME } ?.runCatching { ZoneId.of(this.value)}?.getOrNull() protected fun sessionTimezone(http: HttpRequest) = http.session.getAttribute(Constants.SESSION_ATTR_TIMEZONE) as String protected fun selectTimezone(http: HttpRequest, zoneId: ZoneId) { - http.session.setAttribute(Constants.SESSION_ATTR_TIMEZONE, zoneId.id) + selectTimezone(http.request, http.response, zoneId) + } + + private fun selectTimezone(request: HttpServletRequest, response: HttpServletResponse, zoneId: ZoneId) { + request.session.setAttribute(Constants.SESSION_ATTR_TIMEZONE, zoneId.id) val cookie = Cookie(TIMEZONE_COOKIE_NAME, zoneId.id) cookie.isHttpOnly = true - cookie.path = http.request.contextPath + cookie.path = request.contextPath cookie.maxAge = COOKIE_MAX_AGE - http.response.addCookie(cookie) + response.addCookie(cookie) } protected fun selectLanguage(http: HttpRequest, locale: Locale) { - http.response.locale = locale - http.session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, locale) + selectLanguage(http.request, http.response, locale) + } + + private fun selectLanguage(request: HttpServletRequest, response: HttpServletResponse, locale: Locale) { + response.locale = locale + request.session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, locale) // delete cookie if language selection matches request locale, otherwise set cookie val cookie = Cookie(LANGUAGE_COOKIE_NAME, "") cookie.isHttpOnly = true - cookie.path = http.request.contextPath - if (http.request.locale.language == locale.language) { + cookie.path = request.contextPath + if (request.locale.language == locale.language) { cookie.maxAge = 0 } else { cookie.value = locale.language cookie.maxAge = COOKIE_MAX_AGE } - http.response.addCookie(cookie) + response.addCookie(cookie) } }
--- a/src/main/kotlin/de/uapcore/lightpit/Constants.kt Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/Constants.kt Sat May 17 17:39:48 2025 +0200 @@ -26,6 +26,8 @@ package de.uapcore.lightpit object Constants { + const val VERSION_DATE = "2025-05-17" + /** * The path where the JSP files reside. */
--- a/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt Sat May 17 17:39:48 2025 +0200 @@ -27,6 +27,7 @@ import de.uapcore.lightpit.dao.DataAccessObject import de.uapcore.lightpit.entities.HasNode +import de.uapcore.lightpit.entities.User import de.uapcore.lightpit.viewmodel.NavMenu import de.uapcore.lightpit.viewmodel.View import jakarta.servlet.http.HttpServletRequest @@ -52,14 +53,13 @@ class ValidatedValue<T>(val result: T): ValidationResult<T> class HttpRequest( + val user: User?, val request: HttpServletRequest, val response: HttpServletResponse, val pathParams: PathParameters = emptyMap() ) { val session: HttpSession = request.session - val remoteUser: String? = request.remoteUser - /** * The name of the content page. * @@ -153,6 +153,10 @@ */ val baseHref get() = "${request.scheme}://${request.serverName}$portInfo${request.contextPath}/" + init { + request.setAttribute(Constants.REQ_ATTR_BASE_HREF, baseHref) + } + private fun String.withExt(ext: String) = if (endsWith(ext)) this else plus(ext) private fun jspPath(name: String) = Constants.JSP_PATH_PREFIX.plus(name).withExt(".jsp")
--- a/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt Sat May 17 17:39:48 2025 +0200 @@ -31,6 +31,7 @@ import de.uapcore.lightpit.viewmodel.IssueSummary import de.uapcore.lightpit.viewmodel.VariantSummary import de.uapcore.lightpit.viewmodel.VersionSummary +import java.sql.Date interface DataAccessObject { @@ -39,6 +40,8 @@ fun findUserByName(username: String): User? fun insertUser(user: User) fun updateUser(user: User) + fun untilWhenUserKnowsUpdates(user: User): Date? + fun updateUserKnowsUpdates(user: User) /** * Lists all versions of the specified [project].
--- a/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt Sat May 17 17:39:48 2025 +0200 @@ -33,6 +33,7 @@ import de.uapcore.lightpit.viewmodel.VersionSummary import org.intellij.lang.annotations.Language import java.sql.Connection +import java.sql.Date import java.sql.PreparedStatement import java.sql.ResultSet @@ -129,6 +130,21 @@ executeUpdate() } } + + override fun untilWhenUserKnowsUpdates(user: User): Date? = + withStatement("select knows_updates_until from lpit_user where userid = ?") { + setInt(1, user.id) + querySingle { it.getDate(1) } + } + + override fun updateUserKnowsUpdates(user: User) { + withStatement("update lpit_user set knows_updates_until = now() where userid = ?") { + with(user) { + setInt(1, id) + } + executeUpdate() + } + } //</editor-fold> //<editor-fold desc="Version">
--- a/src/main/kotlin/de/uapcore/lightpit/logic/IssueLogic.kt Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/logic/IssueLogic.kt Sat May 17 17:39:48 2025 +0200 @@ -82,19 +82,18 @@ } fun processIssueForm(issue: Issue, reference: Issue, http: HttpRequest, dao: DataAccessObject) { - val remoteUser = http.remoteUser?.let { dao.findUserByName(it) } if (issue.hasChanged(reference)) { dao.updateIssue(issue) - dao.insertHistoryEvent(remoteUser, issue) + dao.insertHistoryEvent(http.user, issue) } val newComment = http.param("comment") if (!newComment.isNullOrBlank()) { val comment = IssueComment(-1, issue.id).apply { - author = remoteUser + author = http.user comment = newComment } val commentid = dao.insertComment(comment) - dao.insertHistoryEvent(remoteUser, issue, comment, commentid) + dao.insertHistoryEvent(http.user, issue, comment, commentid) } } @@ -110,7 +109,6 @@ } fun processIssueComment(issue:Issue, http: HttpRequest, dao: DataAccessObject): Boolean { - val remoteUser = http.remoteUser?.let { dao.findUserByName(it) } val commentId = http.param("commentid")?.toIntOrNull() ?: -1 if (commentId > 0) { val comment = dao.findComment(commentId) @@ -118,12 +116,12 @@ http.response.sendError(404) return false } - if (comment.author != null && comment.author?.id == remoteUser?.id) { + if (comment.author != null && comment.author?.id == http.user?.id) { val newComment = http.param("comment") if (!newComment.isNullOrBlank()) { comment.comment = newComment dao.updateComment(comment) - dao.insertHistoryEvent(remoteUser, issue, comment) + dao.insertHistoryEvent(http.user, issue, comment) } } else { http.response.sendError(403) @@ -131,11 +129,11 @@ } } else { val comment = IssueComment(-1, issue.id).apply { - author = remoteUser + author = http.user comment = http.param("comment") ?: "" } val newId = dao.insertComment(comment) - dao.insertHistoryEvent(remoteUser, issue, comment, newId) + dao.insertHistoryEvent(http.user, issue, comment, newId) } return true }
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/servlet/IssuesServlet.kt Sat May 17 17:39:48 2025 +0200 @@ -32,7 +32,7 @@ val issues = dao.listIssues(filter.includeDone) .sortedWith(IssueSorter(filter.sortPrimary, filter.sortSecondary, filter.sortTertiary)) - .filter(issueFilterFunction(filter, relationsMap, http.remoteUser ?: "<Anonymous>")) + .filter(issueFilterFunction(filter, relationsMap, http.user?.username ?: "<Anonymous>")) with(http) { pageTitle = i18n("issues")
--- a/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt Sat May 17 17:39:48 2025 +0200 @@ -125,7 +125,7 @@ specificVariant, variant ) .sortedWith(IssueSorter(filter.sortPrimary, filter.sortSecondary, filter.sortTertiary)) - .filter(issueFilterFunction(filter, relationsMap, http.remoteUser ?: "<Anonymous>")) + .filter(issueFilterFunction(filter, relationsMap, http.user?.username ?: "<Anonymous>")) with(http) { pageTitle = project.name @@ -461,9 +461,8 @@ ).applyFormData(http, dao, projectInfo.versions, projectInfo.variants) val openId = if (issue.id < 0) { - val remoteUser = http.remoteUser?.let { dao.findUserByName(it) } val id = dao.insertIssue(issue) - dao.insertHistoryEvent(remoteUser, issue, id) + dao.insertHistoryEvent(http.user, issue, id) id } else { val reference = dao.findIssue(issue.id)
--- a/src/main/resources/localization/strings.properties Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/resources/localization/strings.properties Sat May 17 17:39:48 2025 +0200 @@ -32,6 +32,7 @@ button.comment=Comment button.component.create=New Component button.component.edit=Edit Component +button.dismiss=Dismiss button.issue.all=All Issues button.issue.create.another=Create another Issue button.issue.create=New Issue @@ -45,6 +46,7 @@ button.variant.create=New Variant button.version.create=New Version button.version.edit=Edit Version +button.whats-new=Show Changelog commit.redirect-link=If redirection does not work, click the following link: commit.success=Operation successful - you will be redirected in a second. component.active=Active @@ -193,6 +195,7 @@ version.status.Unreleased=Unreleased version.status=Status version=Version +whats-new.info = A new version of LightPIT has been released. Do you want to check the release notes? issue.relations.type.DefectOf=defect of issue.relations.type.DefectOf.rev=defect issue.filter.sort.primary=Order by
--- a/src/main/resources/localization/strings_de.properties Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/resources/localization/strings_de.properties Sat May 17 17:39:48 2025 +0200 @@ -32,6 +32,7 @@ button.comment=Kommentieren button.component.create=Neue Komponente button.component.edit=Komponente Bearbeiten +button.dismiss=Nicht Jetzt button.issue.all=Alle Vorg\u00e4nge button.issue.create.another=Weiteren Vorgang erstellen button.issue.create=Neuer Vorgang @@ -45,6 +46,7 @@ button.variant.create=Neue Variante button.version.create=Neue Version button.version.edit=Version Bearbeiten +button.whats-new=Versionshinweise \u00d6ffnen commit.redirect-link=Falls die Weiterleitung nicht klappt, klicken Sie bitte hier: commit.success=Operation erfolgreich - Sie werden jeden Moment weitergeleitet. component.active=Aktiv @@ -193,6 +195,7 @@ version.status.Unreleased=Unver\u00f6ffentlicht version.status=Status version=Version +whats-new.info = Eine neue LightPIT-Version wurde ver\u00f6ffentlicht. Wollen Sie mehr erfahren? issue.relations.type.DefectOf.rev=Fehler issue.relations.type.DefectOf=Fehler von issue.filter.sort.primary=Sortiere nach
--- a/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/webapp/WEB-INF/changelogs/changelog-de.jspf Sat May 17 17:39:48 2025 +0200 @@ -24,6 +24,12 @@ --%> <%@ page contentType="text/html;charset=UTF-8" %> +<h3>Version 1.6.0 (Vorschau)</h3> + +<ul> + <li>Pop-Up hinzugefügt, das über eine neue LightPIT-Version informiert.</li> +</ul> + <h3>Version 1.5.1</h3> <ul>
--- a/src/main/webapp/WEB-INF/changelogs/changelog.jspf Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/webapp/WEB-INF/changelogs/changelog.jspf Sat May 17 17:39:48 2025 +0200 @@ -24,6 +24,12 @@ --%> <%@ page contentType="text/html;charset=UTF-8" %> +<h3>Version 1.6.0 (preview)</h3> + +<ul> + <li>Add popup informing about a new LightPIT release.</li> +</ul> + <h3>Version 1.5.1</h3> <ul>
--- a/src/main/webapp/WEB-INF/jsp/site.jsp Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/webapp/WEB-INF/jsp/site.jsp Sat May 17 17:39:48 2025 +0200 @@ -31,7 +31,7 @@ <%@taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> <%-- Version suffix for forcing browsers to update the CSS / JS files --%> -<c:set scope="page" var="versionSuffix" value="20250313"/> +<c:set scope="page" var="versionSuffix" value="20250517"/> <%-- Make the base href easily available at request scope --%> <c:set scope="page" var="baseHref" value="${requestScope[Constants.REQ_ATTR_BASE_HREF]}"/> @@ -87,6 +87,16 @@ </c:if> <script> const baseHref='${baseHref}'; + <c:if test="${showWhatsNew}"> + function closeWhatsNew(showMore) { + if (showMore) { + location.assign(baseHref + 'about'); + } else { + document.getElementById('whats-new').style.display = 'none'; + document.getElementById('page-area').classList.remove('blurred'); + } + } + </c:if> </script> <script src="issue-search.js?v=${versionSuffix}" type="text/javascript"></script> <c:if test="${not empty javascriptFile}"> @@ -94,7 +104,7 @@ </c:if> </head> <body> - <div id="page-area"> + <div id="page-area" <c:if test="${showWhatsNew}">class="blurred"</c:if> > <div id="mainMenu"> <div class="menuEntry" <c:if test="${fn:startsWith(requestPath, '/projects/')}">data-active</c:if> > @@ -143,5 +153,16 @@ </div> </div> </div> + <c:if test="${showWhatsNew}"> + <div id="whats-new"> + <div> + <fmt:message key="whats-new.info"/> + </div> + <div class="hright bigskip"> + <a class="button" onclick="closeWhatsNew(false)"><fmt:message key="button.dismiss"/></a> + <a class="button submit" onclick="closeWhatsNew(true)"><fmt:message key="button.whats-new"/></a> + </div> + </div> + </c:if> </body> </html>
--- a/src/main/webapp/lightpit.css Fri Mar 14 08:09:05 2025 +0100 +++ b/src/main/webapp/lightpit.css Sat May 17 17:39:48 2025 +0200 @@ -156,6 +156,21 @@ padding: 1.5em; } +#whats-new { + background: ghostwhite; + position: fixed; + top: 10%; + left: 50%; + transform: translate(-50%, 0); + padding: 1.5em; + border: #1c204e solid 2px; + border-radius: 4px; +} + +.blurred { + filter: blur(5px); +} + button, a.button { display: inline-block; font-size: medium; @@ -277,6 +292,10 @@ margin-top: .5em; } +.bigskip { + margin-top: 1.5em; +} + .info-box, .error-box, .warn-box { margin: 1.5em; border-style: dashed;