completes kotlin migration

Fri, 02 Apr 2021 11:59:14 +0200

author
Mike Becker <universe@uap-core.de>
date
Fri, 02 Apr 2021 11:59:14 +0200
changeset 184
e8eecee6aadf
parent 183
61669abf277f
child 185
5ec9fcfbdf9c

completes kotlin migration

src/main/java/de/uapcore/lightpit/AbstractServlet.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/modules/ErrorModule.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/modules/LanguageModule.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/modules/UsersModule.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ComponentEditView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ComponentInfo.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ComponentsView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/IssueDetailView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/IssueEditView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/IssuesView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/LanguageView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ProjectDetails.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ProjectDetailsView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ProjectEditView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ProjectInfo.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/ProjectView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/UsersEditView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/UsersView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/VersionEditView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/VersionInfo.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/VersionsView.java file | annotate | diff | comparison | revisions
src/main/java/de/uapcore/lightpit/viewmodel/util/IssueSorter.java file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/AbstractServlet.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/Constants.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/DataSourceProvider.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/dao/DaoFactory.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/entities/Component.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/entities/Entity.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/entities/Issue.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/entities/IssueSummary.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/entities/Project.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/entities/Version.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/filter/Filter.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/filter/IssueFilter.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/servlet/ErrorServlet.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/servlet/LanguageServlet.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/servlet/UsersServlet.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/types/WebColor.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/util/Filter.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/util/Issues.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/Components.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/LanguageView.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/Users.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/Versions.kt file | annotate | diff | comparison | revisions
src/main/kotlin/de/uapcore/lightpit/viewmodel/View.kt file | annotate | diff | comparison | revisions
src/main/resources/localization/strings.properties file | annotate | diff | comparison | revisions
src/main/resources/localization/strings_de.properties file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/component-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/components.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/error.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/issue-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/issue-view.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/project-details.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/project-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/project-navmenu.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/projects.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/site.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/user-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/users.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/version-form.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jsp/versions.jsp file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jspf/issue-list.jspf file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jspf/navmenu.jspf file | annotate | diff | comparison | revisions
src/main/webapp/WEB-INF/jspf/project-header.jspf file | annotate | diff | comparison | revisions
--- a/src/main/java/de/uapcore/lightpit/AbstractServlet.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,460 +0,0 @@
-/*
- * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
- *
- * Copyright 2021 Mike Becker. All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- *   1. Redistributions of source code must retain the above copyright
- *      notice, this list of conditions and the following disclaimer.
- *
- *   2. Redistributions in binary form must reproduce the above copyright
- *      notice, this list of conditions and the following disclaimer in the
- *      documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
- * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- * POSSIBILITY OF SUCH DAMAGE.
- *
- */
-package de.uapcore.lightpit;
-
-import de.uapcore.lightpit.dao.DataAccessObject;
-import de.uapcore.lightpit.dao.PostgresDataAccessObject;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.servlet.ServletException;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import javax.servlet.http.HttpSession;
-import java.io.IOException;
-import java.lang.reflect.*;
-import java.sql.Connection;
-import java.sql.SQLException;
-import java.util.*;
-import java.util.function.Function;
-import java.util.stream.Collectors;
-
-/**
- * A special implementation of a HTTPServlet which is focused on implementing
- * the necessary functionality for LightPIT pages.
- */
-public abstract class AbstractServlet extends HttpServlet {
-
-    private static final Logger LOG = LoggerFactory.getLogger(AbstractServlet.class);
-
-    /**
-     * Invocation mapping gathered from the {@link RequestMapping} annotations.
-     * <p>
-     * Paths in this map must always start with a leading slash, although
-     * the specification in the annotation must not start with a leading slash.
-     * <p>
-     * The reason for this is the different handling of empty paths in
-     * {@link HttpServletRequest#getPathInfo()}.
-     */
-    private final Map<HttpMethod, Map<PathPattern, Method>> mappings = new HashMap<>();
-
-    /**
-     * Creates a set of data access objects for the specified connection.
-     *
-     * @param connection the SQL connection
-     * @return a set of data access objects
-     */
-    private DataAccessObject createDataAccessObjects(Connection connection) {
-        final var df = (DataSourceProvider) getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME());
-        if (df.getDialect() == DataSourceProvider.Dialect.Postgres) {
-            return new PostgresDataAccessObject(connection);
-        }
-        throw new UnsupportedOperationException("Non-exhaustive if-else - this is a bug.");
-    }
-
-    private void invokeMapping(Map.Entry<PathPattern, Method> mapping, HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException {
-        final var pathPattern = mapping.getKey();
-        final var method = mapping.getValue();
-        try {
-            LOG.trace("invoke {}#{}", method.getDeclaringClass().getName(), method.getName());
-            final var paramTypes = method.getParameterTypes();
-            final var paramValues = new Object[paramTypes.length];
-            for (int i = 0; i < paramTypes.length; i++) {
-                if (paramTypes[i].isAssignableFrom(HttpServletRequest.class)) {
-                    paramValues[i] = req;
-                } else if (paramTypes[i].isAssignableFrom(HttpServletResponse.class)) {
-                    paramValues[i] = resp;
-                }
-                if (paramTypes[i].isAssignableFrom(DataAccessObject.class)) {
-                    paramValues[i] = dao;
-                }
-                if (paramTypes[i].isAssignableFrom(PathParameters.class)) {
-                    paramValues[i] = pathPattern.obtainPathParameters(sanitizeRequestPath(req));
-                }
-            }
-            method.invoke(this, paramValues);
-        } catch (InvocationTargetException ex) {
-            LOG.error("invocation of method {}::{} failed: {}",
-                    method.getDeclaringClass().getName(), method.getName(), ex.getTargetException().getMessage());
-            LOG.debug("Details: ", ex.getTargetException());
-            resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getTargetException().getMessage());
-        } catch (ReflectiveOperationException | ClassCastException ex) {
-            LOG.error("invocation of method {}::{} failed: {}",
-                    method.getDeclaringClass().getName(), method.getName(), ex.getMessage());
-            LOG.debug("Details: ", ex);
-            resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ex.getMessage());
-        }
-    }
-
-    @Override
-    public void init() throws ServletException {
-        scanForRequestMappings();
-
-        LOG.trace("{} initialized", getServletName());
-    }
-
-    private void scanForRequestMappings() {
-        try {
-            Method[] methods = getClass().getDeclaredMethods();
-            for (Method method : methods) {
-                Optional<RequestMapping> mapping = Optional.ofNullable(method.getAnnotation(RequestMapping.class));
-                if (mapping.isPresent()) {
-                    if (mapping.get().requestPath().isBlank()) {
-                        LOG.warn("{} is annotated with {} but request path is empty",
-                                method.getName(), RequestMapping.class.getSimpleName()
-                        );
-                        continue;
-                    }
-
-                    if (!Modifier.isPublic(method.getModifiers())) {
-                        LOG.warn("{} is annotated with {} but is not public",
-                                method.getName(), RequestMapping.class.getSimpleName()
-                        );
-                        continue;
-                    }
-                    if (Modifier.isAbstract(method.getModifiers())) {
-                        LOG.warn("{} is annotated with {} but is abstract",
-                                method.getName(), RequestMapping.class.getSimpleName()
-                        );
-                        continue;
-                    }
-
-                    boolean paramsInjectible = true;
-                    for (var param : method.getParameterTypes()) {
-                        paramsInjectible &= HttpServletRequest.class.isAssignableFrom(param)
-                                            || HttpServletResponse.class.isAssignableFrom(param)
-                                            || PathParameters.class.isAssignableFrom(param)
-                                            || DataAccessObject.class.isAssignableFrom(param);
-                    }
-                    if (paramsInjectible) {
-                        try {
-                            PathPattern pathPattern = new PathPattern(mapping.get().requestPath());
-
-                            final var methodMappings = mappings.computeIfAbsent(mapping.get().method(), k -> new HashMap<>());
-                            final var currentMapping = methodMappings.putIfAbsent(pathPattern, method);
-                            if (currentMapping != null) {
-                                LOG.warn("Cannot map {} {} to {} in class {} - this would override the mapping to {}",
-                                        mapping.get().method(),
-                                        mapping.get().requestPath(),
-                                        method.getName(),
-                                        getClass().getSimpleName(),
-                                        currentMapping.getName()
-                                );
-                            }
-
-                            LOG.debug("{} {} maps to {}::{}",
-                                    mapping.get().method(),
-                                    mapping.get().requestPath(),
-                                    getClass().getSimpleName(),
-                                    method.getName()
-                            );
-                        } catch (IllegalArgumentException ex) {
-                            LOG.warn("Request mapping for {} failed: path pattern '{}' is syntactically invalid",
-                                    method.getName(), mapping.get().requestPath()
-                            );
-                        }
-                    } else {
-                        LOG.warn("{} is annotated with {} but has the wrong parameters - only HttpServletRequest, HttpServletResponse, PathParameters, and DataAccessObjects are allowed",
-                                method.getName(), RequestMapping.class.getSimpleName()
-                        );
-                    }
-                }
-            }
-        } catch (SecurityException ex) {
-            LOG.error("Scan for request mappings on declared methods failed.", ex);
-        }
-    }
-
-    @Override
-    public void destroy() {
-        mappings.clear();
-        LOG.trace("{} destroyed", getServletName());
-    }
-
-    /**
-     * Sets the name of the content page.
-     * <p>
-     * It is sufficient to specify the name without any extension. The extension
-     * is added automatically if not specified.
-     *
-     * @param req      the servlet request object
-     * @param pageName the name of the content page
-     * @see Constants#REQ_ATTR_CONTENT_PAGE
-     */
-    protected void setContentPage(HttpServletRequest req, String pageName) {
-        req.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, jspPath(pageName));
-    }
-
-    /**
-     * Sets the navigation menu.
-     *
-     * @param req     the servlet request object
-     * @param jspName the name of the menu's jsp file
-     * @see Constants#REQ_ATTR_NAVIGATION
-     */
-    protected void setNavigationMenu(HttpServletRequest req, String jspName) {
-        req.setAttribute(Constants.REQ_ATTR_NAVIGATION, jspPath(jspName));
-    }
-
-    /**
-     * @param req      the servlet request object
-     * @param location the location where to redirect
-     * @see Constants#REQ_ATTR_REDIRECT_LOCATION
-     */
-    protected void setRedirectLocation(HttpServletRequest req, String location) {
-        if (location.startsWith("./")) {
-            location = location.replaceFirst("\\./", baseHref(req));
-        }
-        req.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, location);
-    }
-
-    /**
-     * Specifies the names of additional stylesheets used by this Servlet.
-     * <p>
-     * It is sufficient to specify the name without any extension. The extension
-     * is added automatically if not specified.
-     *
-     * @param req         the servlet request object
-     * @param stylesheets the names of the stylesheets
-     */
-    public void setStylesheet(HttpServletRequest req, String ... stylesheets) {
-        req.setAttribute(Constants.REQ_ATTR_STYLESHEET, Arrays
-                .stream(stylesheets)
-                .map(s -> enforceExt(s, ".css"))
-                .collect(Collectors.toUnmodifiableList()));
-    }
-
-    /**
-     * Sets the view model object.
-     * The type must match the expected type in the JSP file.
-     *
-     * @param req       the servlet request object
-     * @param viewModel the view model object
-     */
-    public void setViewModel(HttpServletRequest req, Object viewModel) {
-        req.setAttribute(Constants.REQ_ATTR_VIEWMODEL, viewModel);
-    }
-
-    private <T> Optional<T> parseParameter(String paramValue, Class<T> clazz) {
-        if (paramValue == null) return Optional.empty();
-        if (clazz.equals(Boolean.class)) {
-            if (paramValue.equalsIgnoreCase("false") || paramValue.equals("0")) {
-                return Optional.of((T) Boolean.FALSE);
-            } else {
-                return Optional.of((T) Boolean.TRUE);
-            }
-        }
-        if (clazz.equals(String.class)) return Optional.of((T) paramValue);
-        if (java.sql.Date.class.isAssignableFrom(clazz)) {
-            try {
-                return Optional.of((T) java.sql.Date.valueOf(paramValue));
-            } catch (IllegalArgumentException ex) {
-                return Optional.empty();
-            }
-        }
-        try {
-            final Constructor<T> ctor = clazz.getConstructor(String.class);
-            return Optional.of(ctor.newInstance(paramValue));
-        } catch (ReflectiveOperationException e) {
-            // does not type check and is not convertible - treat as if the parameter was never set
-            return Optional.empty();
-        }
-    }
-
-    /**
-     * Obtains a request parameter of the specified type.
-     * The specified type must have a single-argument constructor accepting a string to perform conversion.
-     * The constructor of the specified type may throw an exception on conversion failures.
-     *
-     * @param req   the servlet request object
-     * @param clazz the class object of the expected type
-     * @param name  the name of the parameter
-     * @param <T>   the expected type
-     * @return the parameter value or an empty optional, if no parameter with the specified name was found
-     */
-    protected <T> Optional<T> getParameter(HttpServletRequest req, Class<T> clazz, String name) {
-        if (clazz.isArray()) {
-            final String[] paramValues = req.getParameterValues(name);
-            int len = paramValues == null ? 0 : paramValues.length;
-            final var array = (T) Array.newInstance(clazz.getComponentType(), len);
-            for (int i = 0; i < len; i++) {
-                try {
-                    final Constructor<?> ctor = clazz.getComponentType().getConstructor(String.class);
-                    Array.set(array, i, ctor.newInstance(paramValues[i]));
-                } catch (ReflectiveOperationException e) {
-                    throw new RuntimeException(e);
-                }
-            }
-            return Optional.of(array);
-        } else {
-            return parseParameter(req.getParameter(name), clazz);
-        }
-    }
-
-    /**
-     * Tries to look up an entity with a key obtained from a request parameter.
-     *
-     * @param req   the servlet request object
-     * @param clazz the class representing the type of the request parameter
-     * @param name  the name of the request parameter
-     * @param find  the find function (typically a DAO function)
-     * @param <T>   the type of the request parameter
-     * @param <R>   the type of the looked up entity
-     * @return the retrieved entity or an empty optional if there is no such entity or the request parameter was missing
-     * @throws SQLException if the find function throws an exception
-     */
-    protected <T, R> Optional<R> findByParameter(HttpServletRequest req, Class<T> clazz, String name, Function<? super T, ? extends R> find) {
-        final var param = getParameter(req, clazz, name);
-        if (param.isPresent()) {
-            return Optional.ofNullable(find.apply(param.get()));
-        } else {
-            return Optional.empty();
-        }
-    }
-
-    protected void setAttributeFromParameter(HttpServletRequest req, String name) {
-        final var parm = req.getParameter(name);
-        if (parm != null) {
-            req.setAttribute(name, parm);
-        }
-    }
-
-    private String sanitizeRequestPath(HttpServletRequest req) {
-        return Optional.ofNullable(req.getPathInfo()).orElse("/");
-    }
-
-    private Optional<Map.Entry<PathPattern, Method>> findMapping(HttpMethod method, HttpServletRequest req) {
-        return Optional.ofNullable(mappings.get(method)).flatMap(rm ->
-                rm.entrySet().stream().filter(
-                        kv -> kv.getKey().matches(sanitizeRequestPath(req))
-                ).findAny()
-        );
-    }
-
-    protected void renderSite(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
-        req.getRequestDispatcher(jspPath("site")).forward(req, resp);
-    }
-
-    protected Optional<String[]> availableLanguages() {
-        return Optional.ofNullable(getServletContext().getInitParameter(Constants.CTX_ATTR_LANGUAGES)).map((x) -> x.split("\\s*,\\s*"));
-    }
-
-    private static String baseHref(HttpServletRequest req) {
-        return String.format("%s://%s:%d%s/",
-                req.getScheme(),
-                req.getServerName(),
-                req.getServerPort(),
-                req.getContextPath());
-    }
-
-    private static String enforceExt(String filename, String ext) {
-        return filename.endsWith(ext) ? filename : filename + ext;
-    }
-
-    private static String jspPath(String filename) {
-        return enforceExt(Constants.JSP_PATH_PREFIX + filename, ".jsp");
-    }
-
-    private void doProcess(HttpMethod method, HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
-        // the very first thing to do is to force UTF-8
-        req.setCharacterEncoding("UTF-8");
-
-        // choose the requested language as session language (if available) or fall back to english, otherwise
-        HttpSession session = req.getSession();
-        if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) {
-            Optional<List<String>> availableLanguages = availableLanguages().map(Arrays::asList);
-            Optional<Locale> reqLocale = Optional.of(req.getLocale());
-            Locale sessionLocale = reqLocale.filter((rl) -> availableLanguages.map((al) -> al.contains(rl.getLanguage())).orElse(false)).orElse(Locale.ENGLISH);
-            session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale);
-            LOG.debug("Setting language for new session {}: {}", session.getId(), sessionLocale.getDisplayLanguage());
-        } else {
-            Locale sessionLocale = (Locale) session.getAttribute(Constants.SESSION_ATTR_LANGUAGE);
-            resp.setLocale(sessionLocale);
-            LOG.trace("Continuing session {} with language {}", session.getId(), sessionLocale);
-        }
-
-        // set some internal request attributes
-        final String fullPath = req.getServletPath() + Optional.ofNullable(req.getPathInfo()).orElse("");
-        req.setAttribute(Constants.REQ_ATTR_BASE_HREF, baseHref(req));
-        req.setAttribute(Constants.REQ_ATTR_PATH, fullPath);
-
-        // if this is an error path, bypass the normal flow
-        if (fullPath.startsWith("/error/")) {
-            final var mapping = findMapping(method, req);
-            if (mapping.isPresent()) {
-                invokeMapping(mapping.get(), req, resp, null);
-            }
-            return;
-        }
-
-        // obtain a connection and create the data access objects
-        final var db = (DataSourceProvider) req.getServletContext().getAttribute(DataSourceProvider.Companion.getSC_ATTR_NAME());
-        final var ds = db.getDataSource();
-        if (ds == null) {
-            resp.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE, "JNDI DataSource lookup failed. See log for details.");
-            return;
-        }
-        try (final var connection = ds.getConnection()) {
-            final var dao = createDataAccessObjects(connection);
-            try {
-                connection.setAutoCommit(false);
-                // call the handler, if available, or send an HTTP 404 error
-                final var mapping = findMapping(method, req);
-                if (mapping.isPresent()) {
-                    invokeMapping(mapping.get(), req, resp, dao);
-                } else {
-                    resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-                }
-                connection.commit();
-            } catch (SQLException ex) {
-                LOG.warn("Database transaction failed (Code {}): {}", ex.getErrorCode(), ex.getMessage());
-                LOG.debug("Details: ", ex);
-                resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Unhandled Transaction Error - Code: " + ex.getErrorCode());
-                connection.rollback();
-            }
-        } catch (SQLException ex) {
-            LOG.error("Severe Database Exception (Code {}): {}", ex.getErrorCode(), ex.getMessage());
-            LOG.debug("Details: ", ex);
-            resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.getErrorCode());
-        }
-    }
-
-    @Override
-    protected final void doGet(HttpServletRequest req, HttpServletResponse resp)
-            throws ServletException, IOException {
-        doProcess(HttpMethod.GET, req, resp);
-    }
-
-    @Override
-    protected final void doPost(HttpServletRequest req, HttpServletResponse resp)
-            throws ServletException, IOException {
-        doProcess(HttpMethod.POST, req, resp);
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/modules/ErrorModule.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,61 +0,0 @@
-/*
- * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
- *
- * Copyright 2021 Mike Becker. All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- *   1. Redistributions of source code must retain the above copyright
- *      notice, this list of conditions and the following disclaimer.
- *
- *   2. Redistributions in binary form must reproduce the above copyright
- *      notice, this list of conditions and the following disclaimer in the
- *      documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
- * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- * POSSIBILITY OF SUCH DAMAGE.
- *
- */
-package de.uapcore.lightpit.modules;
-
-import de.uapcore.lightpit.AbstractServlet;
-import de.uapcore.lightpit.HttpMethod;
-import de.uapcore.lightpit.RequestMapping;
-
-import javax.servlet.ServletException;
-import javax.servlet.annotation.WebServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import java.io.IOException;
-import java.util.Optional;
-
-@WebServlet(
-        name = "ErrorModule",
-        urlPatterns = "/error/*"
-)
-public final class ErrorModule extends AbstractServlet {
-
-    public static final String REQ_ATTR_RETURN_LINK = "returnLink";
-
-    @RequestMapping(requestPath = "generic", method = HttpMethod.GET)
-    public void onError(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
-        Optional.ofNullable(req.getHeader("Referer")).ifPresent(
-                referer -> req.setAttribute(REQ_ATTR_RETURN_LINK, referer)
-        );
-
-        setStylesheet(req, "error");
-        setContentPage(req, "error");
-
-        renderSite(req, resp);
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/modules/LanguageModule.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,113 +0,0 @@
-/*
- * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
- *
- * Copyright 2021 Mike Becker. All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- *   1. Redistributions of source code must retain the above copyright
- *      notice, this list of conditions and the following disclaimer.
- *
- *   2. Redistributions in binary form must reproduce the above copyright
- *      notice, this list of conditions and the following disclaimer in the
- *      documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
- * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- * POSSIBILITY OF SUCH DAMAGE.
- *
- */
-package de.uapcore.lightpit.modules;
-
-import de.uapcore.lightpit.AbstractServlet;
-import de.uapcore.lightpit.Constants;
-import de.uapcore.lightpit.HttpMethod;
-import de.uapcore.lightpit.RequestMapping;
-import de.uapcore.lightpit.viewmodel.LanguageView;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.servlet.ServletException;
-import javax.servlet.annotation.WebServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import java.io.IOException;
-import java.util.*;
-
-@WebServlet(
-        name = "LanguageModule",
-        urlPatterns = "/language/*"
-)
-public final class LanguageModule extends AbstractServlet {
-
-    private static final Logger LOG = LoggerFactory.getLogger(LanguageModule.class);
-
-    private final List<Locale> languages = new ArrayList<>();
-
-    @Override
-    public void init() throws ServletException {
-        super.init();
-
-        Optional<String[]> langs = availableLanguages();
-        if (langs.isPresent()) {
-            for (String lang : langs.get()) {
-                try {
-                    Locale locale = Locale.forLanguageTag(lang);
-                    if (locale.getLanguage().isEmpty()) {
-                        throw new IllformedLocaleException();
-                    }
-                    languages.add(locale);
-                } catch (IllformedLocaleException ex) {
-                    LOG.warn("Specified language {} in context parameter cannot be mapped to an existing locale - skipping.", lang);
-                }
-            }
-
-        } else {
-            languages.add(Locale.ENGLISH);
-            LOG.warn("Context parameter 'available-languages' not found. Only english will be available.");
-        }
-    }
-
-    @Override
-    public void destroy() {
-        super.destroy();
-        languages.clear();
-    }
-
-    @RequestMapping(method = HttpMethod.GET)
-    public void handle(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
-
-        final var viewModel = new LanguageView();
-        viewModel.setLanguages(languages);
-        viewModel.setBrowserLanguage(req.getLocale());
-        viewModel.setCurrentLanguage((Locale)req.getSession().getAttribute(Constants.SESSION_ATTR_LANGUAGE));
-
-        setViewModel(req, viewModel);
-        setStylesheet(req, "language");
-        setContentPage(req, "language");
-
-        renderSite(req, resp);
-    }
-
-    @RequestMapping(method = HttpMethod.POST)
-    public void switchLanguage(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
-
-        Optional<Locale> chosenLanguage = Optional.ofNullable(req.getParameter("language"))
-                .map(Locale::forLanguageTag)
-                .filter((l) -> !l.getLanguage().isEmpty());
-
-        chosenLanguage.ifPresent((l) -> req.getSession().setAttribute(Constants.SESSION_ATTR_LANGUAGE, l));
-        chosenLanguage.ifPresent(resp::setLocale);
-
-        handle(req, resp);
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,660 +0,0 @@
-/*
- * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
- *
- * Copyright 2021 Mike Becker. All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- *   1. Redistributions of source code must retain the above copyright
- *      notice, this list of conditions and the following disclaimer.
- *
- *   2. Redistributions in binary form must reproduce the above copyright
- *      notice, this list of conditions and the following disclaimer in the
- *      documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
- * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- * POSSIBILITY OF SUCH DAMAGE.
- *
- */
-package de.uapcore.lightpit.modules;
-
-
-import de.uapcore.lightpit.*;
-import de.uapcore.lightpit.dao.DataAccessObject;
-import de.uapcore.lightpit.entities.*;
-import de.uapcore.lightpit.filter.AllFilter;
-import de.uapcore.lightpit.filter.IssueFilter;
-import de.uapcore.lightpit.filter.NoneFilter;
-import de.uapcore.lightpit.filter.SpecificFilter;
-import de.uapcore.lightpit.types.IssueCategory;
-import de.uapcore.lightpit.types.IssueStatus;
-import de.uapcore.lightpit.types.VersionStatus;
-import de.uapcore.lightpit.types.WebColor;
-import de.uapcore.lightpit.viewmodel.*;
-import de.uapcore.lightpit.viewmodel.util.IssueSorter;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.servlet.ServletException;
-import javax.servlet.annotation.WebServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import java.io.IOException;
-import java.sql.Date;
-import java.sql.SQLException;
-import java.util.NoSuchElementException;
-import java.util.Optional;
-import java.util.stream.Collectors;
-import java.util.stream.Stream;
-
-@WebServlet(
-        name = "ProjectsModule",
-        urlPatterns = "/projects/*"
-)
-public final class ProjectsModule extends AbstractServlet {
-
-    private static final Logger LOG = LoggerFactory.getLogger(ProjectsModule.class);
-
-    private static int parseIntOrZero(String str) {
-        try {
-            return Integer.parseInt(str);
-        } catch (NumberFormatException ex) {
-            return 0;
-        }
-    }
-
-    private void populate(ProjectView viewModel, PathParameters pathParameters, DataAccessObject dao) {
-        dao.listProjects().stream().map(ProjectInfo::new).forEach(viewModel.getProjectList()::add);
-
-        if (pathParameters == null)
-            return;
-
-        // Select Project
-        final var project = dao.findProjectByNode(pathParameters.get("project"));
-        if (project == null)
-            return;
-
-        final var info = new ProjectInfo(project);
-        info.setVersions(dao.listVersions(project));
-        info.setComponents(dao.listComponents(project));
-        info.setIssueSummary(dao.collectIssueSummary(project));
-        viewModel.setProjectInfo(info);
-
-        // Select Version
-        final var versionNode = pathParameters.get("version");
-        if (versionNode != null) {
-            if ("no-version".equals(versionNode)) {
-                viewModel.setVersionFilter(ProjectView.NO_VERSION);
-            } else if ("all-versions".equals(versionNode)) {
-                viewModel.setVersionFilter(ProjectView.ALL_VERSIONS);
-            } else {
-                viewModel.setVersionFilter(dao.findVersionByNode(project, versionNode));
-            }
-        }
-
-        // Select Component
-        final var componentNode = pathParameters.get("component");
-        if (componentNode != null) {
-            if ("no-component".equals(componentNode)) {
-                viewModel.setComponentFilter(ProjectView.NO_COMPONENT);
-            } else if ("all-components".equals(componentNode)) {
-                viewModel.setComponentFilter(ProjectView.ALL_COMPONENTS);
-            } else {
-                viewModel.setComponentFilter(dao.findComponentByNode(project, componentNode));
-            }
-        }
-    }
-
-    private static String sanitizeNode(String node, String defaultValue) {
-        String result = node == null || node.isBlank() ? defaultValue : node;
-        result = result.replace('/', '-');
-        if (result.equals(".") || result.equals("..")) {
-            return "_"+result;
-        } else {
-            return result;
-        }
-    }
-
-    private void forwardView(HttpServletRequest req, HttpServletResponse resp, ProjectView viewModel, String name) throws ServletException, IOException {
-        setViewModel(req, viewModel);
-        setContentPage(req, name);
-        setStylesheet(req, "projects");
-        setNavigationMenu(req, "project-navmenu");
-        renderSite(req, resp);
-    }
-
-    @RequestMapping(method = HttpMethod.GET)
-    public void index(HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws ServletException, IOException {
-        final var viewModel = new ProjectView();
-        populate(viewModel, null, dao);
-
-        for (var info : viewModel.getProjectList()) {
-            info.setVersions(dao.listVersions(info.getProject()));
-            info.setIssueSummary(dao.collectIssueSummary(info.getProject()));
-        }
-
-        forwardView(req, resp, viewModel, "projects");
-    }
-
-    private void configureProjectEditor(ProjectEditView viewModel, Project project, DataAccessObject dao) {
-        viewModel.setProject(project);
-        viewModel.setUsers(dao.listUsers());
-    }
-
-    @RequestMapping(requestPath = "$project/edit", method = HttpMethod.GET)
-    public void edit(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParams, DataAccessObject dao) throws IOException, SQLException, ServletException {
-        final var viewModel = new ProjectEditView();
-        populate(viewModel, pathParams, dao);
-
-        if (!viewModel.isProjectInfoPresent()) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        configureProjectEditor(viewModel, viewModel.getProjectInfo().getProject(), dao);
-        forwardView(req, resp, viewModel, "project-form");
-    }
-
-    @RequestMapping(requestPath = "create", method = HttpMethod.GET)
-    public void create(HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws SQLException, ServletException, IOException {
-        final var viewModel = new ProjectEditView();
-        populate(viewModel, null, dao);
-        configureProjectEditor(viewModel, new Project(-1), dao);
-        forwardView(req, resp, viewModel, "project-form");
-    }
-
-    @RequestMapping(requestPath = "commit", method = HttpMethod.POST)
-    public void commit(HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException, ServletException {
-
-        try {
-            final var project = new Project(getParameter(req, Integer.class, "pid").orElseThrow());
-            project.setName(getParameter(req, String.class, "name").orElseThrow());
-
-            final var node = getParameter(req, String.class, "node").orElse(null);
-            project.setNode(sanitizeNode(node, project.getName()));
-            getParameter(req, Integer.class, "ordinal").ifPresent(project::setOrdinal);
-
-            getParameter(req, String.class, "description").ifPresent(project::setDescription);
-            getParameter(req, String.class, "repoUrl").ifPresent(project::setRepoUrl);
-            getParameter(req, Integer.class, "owner").map(
-                    ownerId -> ownerId >= 0 ? new User(ownerId) : null
-            ).ifPresent(project::setOwner);
-
-            if (project.getId() > 0) {
-                dao.updateProject(project);
-            } else {
-                dao.insertProject(project);
-            }
-
-            setRedirectLocation(req, "./projects/");
-            setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
-            LOG.debug("Successfully updated project {}", project.getName());
-
-            renderSite(req, resp);
-        } catch (NoSuchElementException | IllegalArgumentException ex) {
-            resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED);
-            // TODO: implement - fix issue #21
-        }
-    }
-
-    @RequestMapping(requestPath = "$project/$component/$version/issues/", method = HttpMethod.GET)
-    public void issues(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParams, DataAccessObject dao) throws SQLException, IOException, ServletException {
-        final var viewModel = new ProjectDetailsView();
-        populate(viewModel, pathParams, dao);
-
-        if (!viewModel.isEveryFilterValid()) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        final var project = viewModel.getProjectInfo().getProject();
-        final var version = viewModel.getVersionFilter();
-        final var component = viewModel.getComponentFilter();
-
-        // TODO: use new IssueFilter class for the ViewModel
-
-        final var projectFilter = new SpecificFilter<>(project);
-        final IssueFilter filter;
-        if (version.equals(ProjectView.NO_VERSION)) {
-            if (component.equals(ProjectView.ALL_COMPONENTS)) {
-                filter = new IssueFilter(projectFilter,
-                        new NoneFilter<>(),
-                        new AllFilter<>()
-                );
-            } else if (component.equals(ProjectView.NO_COMPONENT)) {
-                filter = new IssueFilter(projectFilter,
-                        new NoneFilter<>(),
-                        new NoneFilter<>()
-                );
-            } else {
-                filter = new IssueFilter(projectFilter,
-                        new NoneFilter<>(),
-                        new SpecificFilter<>(component)
-                );
-            }
-        } else if (version.equals(ProjectView.ALL_VERSIONS)) {
-            if (component.equals(ProjectView.ALL_COMPONENTS)) {
-                filter = new IssueFilter(projectFilter,
-                        new AllFilter<>(),
-                        new AllFilter<>()
-                );
-            } else if (component.equals(ProjectView.NO_COMPONENT)) {
-                filter = new IssueFilter(projectFilter,
-                        new AllFilter<>(),
-                        new NoneFilter<>()
-                );
-            } else {
-                filter = new IssueFilter(projectFilter,
-                        new AllFilter<>(),
-                        new SpecificFilter<>(component)
-                );
-            }
-        } else {
-            if (component.equals(ProjectView.ALL_COMPONENTS)) {
-                filter = new IssueFilter(projectFilter,
-                        new SpecificFilter<>(version),
-                        new AllFilter<>()
-                );
-            } else if (component.equals(ProjectView.NO_COMPONENT)) {
-                filter = new IssueFilter(projectFilter,
-                        new SpecificFilter<>(version),
-                        new NoneFilter<>()
-                );
-            } else {
-                filter = new IssueFilter(projectFilter,
-                        new SpecificFilter<>(version),
-                        new SpecificFilter<>(component)
-                );
-            }
-        }
-
-        final var issues = dao.listIssues(filter);
-        issues.sort(new IssueSorter(
-                new IssueSorter.Criteria(IssueSorter.Field.DONE, true),
-                new IssueSorter.Criteria(IssueSorter.Field.ETA, true),
-                new IssueSorter.Criteria(IssueSorter.Field.UPDATED, false)
-        ));
-
-
-        viewModel.getProjectDetails().updateDetails(issues);
-        if (version.getId() > 0)
-            viewModel.getProjectDetails().updateVersionInfo(version);
-
-        forwardView(req, resp, viewModel, "project-details");
-    }
-
-    @RequestMapping(requestPath = "$project/versions/", method = HttpMethod.GET)
-    public void versions(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObject dao) throws IOException, SQLException, ServletException {
-        final var viewModel = new VersionsView();
-        populate(viewModel, pathParameters, dao);
-
-        final var projectInfo = viewModel.getProjectInfo();
-        if (projectInfo == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        final var issues = dao.listIssues(
-                new IssueFilter(
-                        new SpecificFilter<>(projectInfo.getProject()),
-                        new AllFilter<>(),
-                        new AllFilter<>()
-                )
-        );
-        viewModel.update(projectInfo.getVersions(), issues);
-
-        forwardView(req, resp, viewModel, "versions");
-    }
-
-    @RequestMapping(requestPath = "$project/versions/$version/edit", method = HttpMethod.GET)
-    public void editVersion(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObject dao) throws IOException, ServletException {
-        final var viewModel = new VersionEditView();
-        populate(viewModel, pathParameters, dao);
-
-        if (viewModel.getProjectInfo() == null || viewModel.getVersionFilter() == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        viewModel.setVersion(viewModel.getVersionFilter());
-
-        forwardView(req, resp, viewModel, "version-form");
-    }
-
-    @RequestMapping(requestPath = "$project/create-version", method = HttpMethod.GET)
-    public void createVersion(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObject dao) throws IOException, ServletException {
-        final var viewModel = new VersionEditView();
-        populate(viewModel, pathParameters, dao);
-
-        if (viewModel.getProjectInfo() == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        viewModel.setVersion(new Version(-1, viewModel.getProjectInfo().getProject().getId()));
-
-        forwardView(req, resp, viewModel, "version-form");
-    }
-
-    @RequestMapping(requestPath = "commit-version", method = HttpMethod.POST)
-    public void commitVersion(HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException, ServletException {
-
-        try {
-            final var project = dao.findProject(getParameter(req, Integer.class, "pid").orElseThrow());
-            if (project == null) {
-                // TODO: improve error handling, because not found is not correct for this POST request
-                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-                return;
-            }
-            final var version = new Version(getParameter(req, Integer.class, "id").orElseThrow(), project.getId());
-            version.setName(getParameter(req, String.class, "name").orElseThrow());
-
-            final var node = getParameter(req, String.class, "node").orElse(null);
-            version.setNode(sanitizeNode(node, version.getName()));
-
-            getParameter(req, Integer.class, "ordinal").ifPresent(version::setOrdinal);
-            version.setStatus(VersionStatus.valueOf(getParameter(req, String.class, "status").orElseThrow()));
-
-            if (version.getId() > 0) {
-                dao.updateVersion(version);
-            } else {
-                dao.insertVersion(version);
-            }
-
-            setRedirectLocation(req, "./projects/" + project.getNode() + "/versions/");
-            setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
-
-            renderSite(req, resp);
-        } catch (NoSuchElementException | IllegalArgumentException ex) {
-            resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED);
-            // TODO: implement - fix issue #21
-        }
-    }
-
-    @RequestMapping(requestPath = "$project/components/", method = HttpMethod.GET)
-    public void components(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObject dao) throws IOException, SQLException, ServletException {
-        final var viewModel = new ComponentsView();
-        populate(viewModel, pathParameters, dao);
-
-        final var projectInfo = viewModel.getProjectInfo();
-        if (projectInfo == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        final var issues = dao.listIssues(
-                new IssueFilter(
-                        new SpecificFilter<>(projectInfo.getProject()),
-                        new AllFilter<>(),
-                        new AllFilter<>()
-                )
-        );
-        viewModel.update(projectInfo.getComponents(), issues);
-
-        forwardView(req, resp, viewModel, "components");
-    }
-
-    @RequestMapping(requestPath = "$project/components/$component/edit", method = HttpMethod.GET)
-    public void editComponent(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObject dao) throws IOException, ServletException {
-        final var viewModel = new ComponentEditView();
-        populate(viewModel, pathParameters, dao);
-
-        if (viewModel.getProjectInfo() == null || viewModel.getComponentFilter() == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        viewModel.setComponent(viewModel.getComponentFilter());
-        viewModel.setUsers(dao.listUsers());
-
-        forwardView(req, resp, viewModel, "component-form");
-    }
-
-    @RequestMapping(requestPath = "$project/create-component", method = HttpMethod.GET)
-    public void createComponent(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObject dao) throws IOException, ServletException {
-        final var viewModel = new ComponentEditView();
-        populate(viewModel, pathParameters, dao);
-
-        if (viewModel.getProjectInfo() == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        viewModel.setComponent(new Component(-1, viewModel.getProjectInfo().getProject().getId()));
-        viewModel.setUsers(dao.listUsers());
-
-        forwardView(req, resp, viewModel, "component-form");
-    }
-
-    @RequestMapping(requestPath = "commit-component", method = HttpMethod.POST)
-    public void commitComponent(HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException, ServletException {
-
-        try {
-            final var project = dao.findProject(getParameter(req, Integer.class, "pid").orElseThrow());
-            if (project == null) {
-                // TODO: improve error handling, because not found is not correct for this POST request
-                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-                return;
-            }
-            final var component = new Component(getParameter(req, Integer.class, "id").orElseThrow(), project.getId());
-            component.setName(getParameter(req, String.class, "name").orElseThrow());
-
-            final var node = getParameter(req, String.class, "node").orElse(null);
-            component.setNode(sanitizeNode(node, component.getName()));
-
-            component.setColor(getParameter(req, WebColor.class, "color").orElseThrow());
-            getParameter(req, Integer.class, "ordinal").ifPresent(component::setOrdinal);
-            getParameter(req, Integer.class, "lead").map(
-                    userid -> userid >= 0 ? new User(userid) : null
-            ).ifPresent(component::setLead);
-            getParameter(req, String.class, "description").ifPresent(component::setDescription);
-
-            if (component.getId() > 0) {
-                dao.updateComponent(component);
-            } else {
-                dao.insertComponent(component);
-            }
-
-            setRedirectLocation(req, "./projects/" + project.getNode() + "/components/");
-            setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
-
-            renderSite(req, resp);
-        } catch (NoSuchElementException | IllegalArgumentException ex) {
-            resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED);
-            // TODO: implement - fix issue #21
-        }
-    }
-
-    private void configureIssueEditor(IssueEditView viewModel, Issue issue, DataAccessObject dao) {
-        final var project = viewModel.getProjectInfo().getProject();
-        issue.setProject(project); // automatically set current project for new issues
-        viewModel.setIssue(issue);
-        viewModel.configureVersionSelectors(viewModel.getProjectInfo().getVersions());
-        viewModel.setUsers(dao.listUsers());
-        viewModel.setComponents(dao.listComponents(project));
-    }
-
-    @RequestMapping(requestPath = "$project/issues/$issue/view", method = HttpMethod.GET)
-    public void viewIssue(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObject dao) throws IOException, ServletException {
-        final var viewModel = new IssueDetailView();
-        populate(viewModel, pathParameters, dao);
-
-        final var projectInfo = viewModel.getProjectInfo();
-        if (projectInfo == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        final var issue = dao.findIssue(parseIntOrZero(pathParameters.get("issue")));
-        if (issue == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        viewModel.setIssue(issue);
-        viewModel.setComments(dao.listComments(issue));
-
-        viewModel.processMarkdown();
-
-        forwardView(req, resp, viewModel, "issue-view");
-    }
-
-    // TODO: why should the issue editor be child of $project?
-    @RequestMapping(requestPath = "$project/issues/$issue/edit", method = HttpMethod.GET)
-    public void editIssue(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObject dao) throws IOException, SQLException, ServletException {
-        final var viewModel = new IssueEditView();
-        populate(viewModel, pathParameters, dao);
-
-        final var projectInfo = viewModel.getProjectInfo();
-        if (projectInfo == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        final var issue = dao.findIssue(parseIntOrZero(pathParameters.get("issue")));
-        if (issue == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        configureIssueEditor(viewModel, issue, dao);
-
-        forwardView(req, resp, viewModel, "issue-form");
-    }
-
-    @RequestMapping(requestPath = "$project/create-issue", method = HttpMethod.GET)
-    public void createIssue(HttpServletRequest req, HttpServletResponse resp, PathParameters pathParameters, DataAccessObject dao) throws IOException, ServletException {
-        final var viewModel = new IssueEditView();
-        populate(viewModel, pathParameters, dao);
-
-        final var projectInfo = viewModel.getProjectInfo();
-        if (projectInfo == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-
-        setAttributeFromParameter(req, "more");
-        setAttributeFromParameter(req, "cid");
-        setAttributeFromParameter(req, "vid");
-
-        final var issue = new Issue(-1, projectInfo.getProject(), null);
-        issue.setProject(projectInfo.getProject());
-        configureIssueEditor(viewModel, issue, dao);
-
-        forwardView(req, resp, viewModel, "issue-form");
-    }
-
-    @RequestMapping(requestPath = "commit-issue", method = HttpMethod.POST)
-    public void commitIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException, ServletException {
-        try {
-            final var project = dao.findProject(getParameter(req, Integer.class, "pid").orElseThrow());
-            if (project == null) {
-                // TODO: improve error handling, because not found is not correct for this POST request
-                resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-                return;
-            }
-            final var componentId = getParameter(req, Integer.class, "component");
-            final Component component;
-            if (componentId.isPresent()) {
-                component = dao.findComponent(componentId.get());
-            } else {
-                component = null;
-            }
-            final var issue = new Issue(getParameter(req, Integer.class, "id").orElseThrow(), project, component);
-            getParameter(req, String.class, "category").map(IssueCategory::valueOf).ifPresent(issue::setCategory);
-            getParameter(req, String.class, "status").map(IssueStatus::valueOf).ifPresent(issue::setStatus);
-            issue.setSubject(getParameter(req, String.class, "subject").orElseThrow());
-            getParameter(req, Integer.class, "assignee").map(userid -> {
-                if (userid >= 0) {
-                    return new User(userid);
-                } else if (userid == -2) {
-                    return Optional.ofNullable(component).map(Component::getLead).orElse(null);
-                } else {
-                    return null;
-                }
-            }
-            ).ifPresent(issue::setAssignee);
-            getParameter(req, String.class, "description").ifPresent(issue::setDescription);
-            getParameter(req, Date.class, "eta").ifPresent(issue::setEta);
-
-            getParameter(req, Integer[].class, "affected")
-                    .map(Stream::of)
-                    .map(stream ->
-                            stream.map(id -> new Version(id, project.getId()))
-                                    .collect(Collectors.toList())
-                    ).ifPresent(issue::setAffectedVersions);
-            getParameter(req, Integer[].class, "resolved")
-                    .map(Stream::of)
-                    .map(stream ->
-                            stream.map(id -> new Version(id, project.getId()))
-                                    .collect(Collectors.toList())
-                    ).ifPresent(issue::setResolvedVersions);
-
-            if (issue.getId() > 0) {
-                dao.updateIssue(issue);
-            } else {
-                dao.insertIssue(issue);
-            }
-
-            if (getParameter(req, Boolean.class, "create-another").orElse(false)) {
-                // TODO: fix #38 - automatically select component (and version)
-                setRedirectLocation(req, "./projects/" + issue.getProject().getNode() + "/create-issue?more=true");
-            } else{
-                setRedirectLocation(req, "./projects/" + issue.getProject().getNode() + "/issues/" + issue.getId() + "/view");
-            }
-            setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
-
-            renderSite(req, resp);
-        } catch (NoSuchElementException | IllegalArgumentException ex) {
-            resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED);
-            // TODO: implement - fix issue #21
-        }
-    }
-
-    @RequestMapping(requestPath = "commit-issue-comment", method = HttpMethod.POST)
-    public void commentIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws IOException, ServletException {
-        final var issueIdParam = getParameter(req, Integer.class, "issueid");
-        if (issueIdParam.isEmpty()) {
-            resp.sendError(HttpServletResponse.SC_FORBIDDEN, "Detected manipulated form.");
-            return;
-        }
-        final var issue = dao.findIssue(issueIdParam.get());
-        if (issue == null) {
-            resp.sendError(HttpServletResponse.SC_NOT_FOUND);
-            return;
-        }
-        try {
-            final var issueComment = new IssueComment(getParameter(req, Integer.class, "commentid").orElse(-1), issue.getId());
-            issueComment.setComment(getParameter(req, String.class, "comment").orElse(""));
-
-            if (issueComment.getComment().isBlank()) {
-                throw new IllegalArgumentException("comment.null");
-            }
-
-            LOG.debug("User {} is commenting on issue #{}", req.getRemoteUser(), issue.getId());
-            if (req.getRemoteUser() != null) {
-                Optional.ofNullable(dao.findUserByName(req.getRemoteUser())).ifPresent(issueComment::setAuthor);
-            }
-
-            dao.insertComment(issueComment);
-
-            setRedirectLocation(req, "./projects/" + issue.getProject().getNode()+"/issues/"+issue.getId()+"/view");
-            setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
-
-            renderSite(req, resp);
-        } catch (NoSuchElementException | IllegalArgumentException ex) {
-            resp.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED);
-            // TODO: implement - fix issue #21
-        }
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/modules/UsersModule.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,113 +0,0 @@
-/*
- * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
- *
- * Copyright 2021 Mike Becker. All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- *   1. Redistributions of source code must retain the above copyright
- *      notice, this list of conditions and the following disclaimer.
- *
- *   2. Redistributions in binary form must reproduce the above copyright
- *      notice, this list of conditions and the following disclaimer in the
- *      documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
- * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
- * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
- * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
- * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
- * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
- * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
- * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
- * POSSIBILITY OF SUCH DAMAGE.
- *
- */
-package de.uapcore.lightpit.modules;
-
-import de.uapcore.lightpit.AbstractServlet;
-import de.uapcore.lightpit.Constants;
-import de.uapcore.lightpit.HttpMethod;
-import de.uapcore.lightpit.RequestMapping;
-import de.uapcore.lightpit.dao.DataAccessObject;
-import de.uapcore.lightpit.entities.User;
-import de.uapcore.lightpit.viewmodel.UsersEditView;
-import de.uapcore.lightpit.viewmodel.UsersView;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-import javax.servlet.ServletException;
-import javax.servlet.annotation.WebServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import java.io.IOException;
-import java.sql.SQLException;
-import java.util.NoSuchElementException;
-
-@WebServlet(
-        name = "UsersModule",
-        urlPatterns = "/teams/*"
-)
-public final class UsersModule extends AbstractServlet {
-
-    private static final Logger LOG = LoggerFactory.getLogger(UsersModule.class);
-
-    @RequestMapping(method = HttpMethod.GET)
-    public void index(HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws SQLException, ServletException, IOException {
-        final var viewModel = new UsersView();
-        viewModel.setUsers(dao.listUsers());
-        setViewModel(req, viewModel);
-        setContentPage(req, "users");
-
-        renderSite(req, resp);
-    }
-
-    @RequestMapping(requestPath = "edit", method = HttpMethod.GET)
-    public void edit(HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws SQLException, ServletException, IOException {
-
-        final var viewModel = new UsersEditView();
-        viewModel.setUser(findByParameter(req, Integer.class, "id", dao::findUser).orElse(new User(-1)));
-
-        setViewModel(req, viewModel);
-        setContentPage(req, "user-form");
-
-        renderSite(req, resp);
-    }
-
-    @RequestMapping(requestPath = "commit", method = HttpMethod.POST)
-    public void commit(HttpServletRequest req, HttpServletResponse resp, DataAccessObject dao) throws ServletException, IOException {
-
-        User user = new User(-1);
-        try {
-            user = new User(getParameter(req, Integer.class, "userid").orElseThrow());
-            user.setUsername(getParameter(req, String.class, "username").orElseThrow());
-            getParameter(req, String.class, "givenname").ifPresent(user::setGivenname);
-            getParameter(req, String.class, "lastname").ifPresent(user::setLastname);
-            getParameter(req, String.class, "mail").ifPresent(user::setMail);
-
-            if (user.getId() > 0) {
-                dao.updateUser(user);
-            } else {
-                dao.insertUser(user);
-            }
-
-            setRedirectLocation(req, "./teams/");
-            setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
-
-            LOG.debug("Successfully updated user {}", user.getUsername());
-        } catch (NoSuchElementException | IllegalArgumentException ex) {
-            final var viewModel = new UsersEditView();
-            viewModel.setUser(user);
-            // TODO: viewModel.setErrorText()
-            setViewModel(req, viewModel);
-            setContentPage(req, "user-form");
-            LOG.warn("Form validation failure: {}", ex.getMessage());
-            LOG.debug("Details:", ex);
-        }
-
-        renderSite(req, resp);
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/ComponentEditView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,40 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Component;
-import de.uapcore.lightpit.entities.User;
-
-import java.util.List;
-
-public class ComponentEditView extends ProjectView {
-    private Component component;
-    private List<User> users;
-    private String errorText;
-
-    public ComponentEditView() {
-        setSelectedPage(SELECTED_PAGE_COMPONENTS);
-    }
-
-    public void setComponent(Component component) {
-        this.component = component;
-    }
-
-    public Component getComponent() {
-        return component;
-    }
-
-    public List<User> getUsers() {
-        return users;
-    }
-
-    public void setUsers(List<User> users) {
-        this.users = users;
-    }
-
-    public String getErrorText() {
-        return errorText;
-    }
-
-    public void setErrorText(String errorText) {
-        this.errorText = errorText;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/ComponentInfo.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,42 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Component;
-import de.uapcore.lightpit.entities.Issue;
-import de.uapcore.lightpit.entities.IssueSummary;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class ComponentInfo {
-
-    private final Component component;
-
-    private final IssueSummary issueSummary = new IssueSummary();
-
-    private final List<Issue> issues = new ArrayList<>();
-
-    public ComponentInfo(Component component) {
-        this.component = component;
-    }
-
-    public Component getComponent() {
-        return component;
-    }
-
-    public IssueSummary getIssueSummary() {
-        return issueSummary;
-    }
-
-    public List<Issue> getIssues() {
-        return issues;
-    }
-
-    public void collectIssues(List<Issue> issues) {
-        for (Issue issue : issues) {
-            if (component.equals(issue.getComponent())) {
-                this.issues.add(issue);
-                this.issueSummary.add(issue);
-            }
-        }
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/ComponentsView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,29 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Component;
-import de.uapcore.lightpit.entities.Issue;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class ComponentsView extends ProjectView {
-
-    private List<ComponentInfo> componentInfos = new ArrayList<>();
-
-    public ComponentsView() {
-        setSelectedPage(SELECTED_PAGE_COMPONENTS);
-    }
-
-    public void update(List<Component> components, List<Issue> issues) {
-        componentInfos.clear();
-        for (var component : components) {
-            final var info = new ComponentInfo(component);
-            info.collectIssues(issues);
-            componentInfos.add(info);
-        }
-    }
-
-    public List<ComponentInfo> getComponentInfos() {
-        return componentInfos;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/IssueDetailView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,47 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension;
-import com.vladsch.flexmark.ext.tables.TablesExtension;
-import com.vladsch.flexmark.html.HtmlRenderer;
-import com.vladsch.flexmark.parser.Parser;
-import com.vladsch.flexmark.util.data.MutableDataSet;
-import de.uapcore.lightpit.entities.Issue;
-import de.uapcore.lightpit.entities.IssueComment;
-
-import java.util.Arrays;
-import java.util.List;
-
-public class IssueDetailView extends ProjectView {
-    private Issue issue;
-
-    private List<IssueComment> comments;
-
-    public void setIssue(Issue issue) {
-        this.issue = issue;
-    }
-
-    public Issue getIssue() {
-        return issue;
-    }
-
-    public List<IssueComment> getComments() {
-        return comments;
-    }
-
-    public void setComments(List<IssueComment> comments) {
-        this.comments = comments;
-    }
-
-    public void processMarkdown() {
-        final var options = new MutableDataSet()
-                .set(Parser.EXTENSIONS, Arrays.asList(TablesExtension.create(), StrikethroughExtension.create()))
-                .toImmutable();
-        final var parser = Parser.builder(options).build();
-        final var renderer = HtmlRenderer.builder(options).build();
-
-        issue.setDescription(renderer.render(parser.parse(issue.getDescription())));
-        for (var comment : comments) {
-            comment.setComment(renderer.render(parser.parse(comment.getComment())));
-        }
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/IssueEditView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,75 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Component;
-import de.uapcore.lightpit.entities.Project;
-import de.uapcore.lightpit.entities.User;
-import de.uapcore.lightpit.entities.Version;
-import de.uapcore.lightpit.types.IssueCategory;
-import de.uapcore.lightpit.types.IssueStatus;
-import de.uapcore.lightpit.types.VersionStatus;
-
-import java.util.*;
-
-public class IssueEditView extends IssueDetailView {
-    private List<Project> projects = Collections.emptyList();
-    private Set<Version> versionsUpcoming = new HashSet<>();
-    private Set<Version> versionsRecent = new HashSet<>();
-    private List<User> users;
-    private List<Component> components;
-
-    public List<Project> getProjects() {
-        return projects;
-    }
-
-    public void setProjects(List<Project> projects) {
-        this.projects = projects;
-    }
-
-    public Collection<Version> getVersionsUpcoming() {
-        return versionsUpcoming;
-    }
-
-    public Collection<Version> getVersionsRecent() {
-        return versionsRecent;
-    }
-
-    public void configureVersionSelectors(List<Version> versions) {
-        versionsRecent.clear();
-        versionsUpcoming.clear();
-        // keep the current selection, if any
-        versionsRecent.addAll(getIssue().getAffectedVersions());
-        versionsUpcoming.addAll(getIssue().getResolvedVersions());
-        for (var v : versions) {
-            if (v.getStatus().isReleased()) {
-                if (!v.getStatus().equals(VersionStatus.Deprecated))
-                    versionsRecent.add(v);
-            } else {
-                versionsUpcoming.add(v);
-            }
-        }
-    }
-
-    public List<User> getUsers() {
-        return users;
-    }
-
-    public void setUsers(List<User> users) {
-        this.users = users;
-    }
-
-    public List<Component> getComponents() {
-        return components;
-    }
-
-    public void setComponents(List<Component> components) {
-        this.components = components;
-    }
-
-    public IssueStatus[] getIssueStatus() {
-        return IssueStatus.values();
-    }
-
-    public IssueCategory[] getIssueCategory() {
-        return IssueCategory.values();
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/IssuesView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,37 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Issue;
-import de.uapcore.lightpit.entities.Project;
-import de.uapcore.lightpit.entities.Version;
-
-import java.util.List;
-
-public class IssuesView {
-    private List<Issue> issues;
-    private Project project;
-    private Version version;
-
-    public List<Issue> getIssues() {
-        return issues;
-    }
-
-    public void setIssues(List<Issue> issues) {
-        this.issues = issues;
-    }
-
-    public Version getVersion() {
-        return version;
-    }
-
-    public void setVersion(Version version) {
-        this.version = version;
-    }
-
-    public Project getProject() {
-        return project;
-    }
-
-    public void setProject(Project project) {
-        this.project = project;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/LanguageView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,36 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import java.util.List;
-import java.util.Locale;
-
-public class LanguageView {
-
-    private List<Locale> languages;
-    private Locale browserLanguage;
-    private Locale currentLanguage;
-
-
-    public List<Locale> getLanguages() {
-        return languages;
-    }
-
-    public void setLanguages(List<Locale> languages) {
-        this.languages = languages;
-    }
-
-    public Locale getBrowserLanguage() {
-        return browserLanguage;
-    }
-
-    public void setBrowserLanguage(Locale browserLanguage) {
-        this.browserLanguage = browserLanguage;
-    }
-
-    public Locale getCurrentLanguage() {
-        return currentLanguage;
-    }
-
-    public void setCurrentLanguage(Locale currentLanguage) {
-        this.currentLanguage = currentLanguage;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/ProjectDetails.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,38 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Issue;
-import de.uapcore.lightpit.entities.IssueSummary;
-import de.uapcore.lightpit.entities.Version;
-
-import java.util.List;
-
-public class ProjectDetails {
-
-    private VersionInfo versionInfo = null;
-
-    private List<Issue> issues;
-    private IssueSummary issueSummary;
-
-    public void updateDetails(List<Issue> issues) {
-        this.issues = issues;
-        issueSummary = new IssueSummary();
-        issues.forEach(issueSummary::add);
-    }
-
-    public void updateVersionInfo(Version version) {
-        versionInfo = new VersionInfo(version);
-        versionInfo.collectIssues(issues);
-    }
-
-    public List<Issue> getIssues() {
-        return issues;
-    }
-
-    public IssueSummary getIssueSummary() {
-        return issueSummary;
-    }
-
-    public VersionInfo getVersionInfo() {
-        return versionInfo;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/ProjectDetailsView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,10 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-public class ProjectDetailsView extends ProjectView {
-
-    private final ProjectDetails projectDetails = new ProjectDetails();
-
-    public ProjectDetails getProjectDetails() {
-        return projectDetails;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/ProjectEditView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,37 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Project;
-import de.uapcore.lightpit.entities.User;
-
-import java.util.List;
-
-public class ProjectEditView extends ProjectView {
-
-    private Project project;
-    private List<User> users;
-    private String errorText;
-
-    public Project getProject() {
-        return project;
-    }
-
-    public void setProject(Project project) {
-        this.project = project;
-    }
-
-    public List<User> getUsers() {
-        return users;
-    }
-
-    public void setUsers(List<User> users) {
-        this.users = users;
-    }
-
-    public String getErrorText() {
-        return errorText;
-    }
-
-    public void setErrorText(String errorText) {
-        this.errorText = errorText;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/ProjectInfo.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,69 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Component;
-import de.uapcore.lightpit.entities.IssueSummary;
-import de.uapcore.lightpit.entities.Project;
-import de.uapcore.lightpit.entities.Version;
-
-import java.util.Collections;
-import java.util.List;
-
-public class ProjectInfo {
-
-    private final Project project;
-    private List<Version> versions = Collections.emptyList();
-    private List<Component> components = Collections.emptyList();
-    private IssueSummary issueSummary = new IssueSummary();
-
-    public ProjectInfo(Project project) {
-        this.project = project;
-    }
-
-    public Project getProject() {
-        return project;
-    }
-
-    public List<Version> getVersions() {
-        return versions;
-    }
-
-    public void setVersions(List<Version> versions) {
-        this.versions = versions;
-    }
-
-    public List<Component> getComponents() {
-        return components;
-    }
-
-    public void setComponents(List<Component> components) {
-        this.components = components;
-    }
-
-    public Version getLatestVersion() {
-        // expects versions to be sorted by status descending
-        for (var v : versions) {
-            if (v.getStatus().isReleased())
-                return v;
-        }
-        return null;
-    }
-
-    public Version getNextVersion() {
-        // expects versions to be sorted by status descending
-        Version next = null;
-        for (var v : versions) {
-            if (v.getStatus().isReleased())
-                break;
-            next = v;
-        }
-        return next;
-    }
-
-    public IssueSummary getIssueSummary() {
-        return issueSummary;
-    }
-
-    public void setIssueSummary(IssueSummary issueSummary) {
-        this.issueSummary = issueSummary;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/ProjectView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,79 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Component;
-import de.uapcore.lightpit.entities.Version;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class ProjectView {
-
-    public static final int SELECTED_PAGE_ISSUES = 0;
-    public static final int SELECTED_PAGE_VERSIONS = 1;
-    public static final int SELECTED_PAGE_COMPONENTS = 2;
-
-    // TODO: use new Filter class
-
-    public static final Version ALL_VERSIONS = new Version(0,0);
-    public static final Version NO_VERSION = new Version(-1,0);
-    public static final Component ALL_COMPONENTS = new Component(0,0);
-    public static final Component NO_COMPONENT = new Component(-1,0);
-
-    static {
-        ALL_VERSIONS.setNode("all-versions");
-        NO_VERSION.setNode("no-version");
-        ALL_COMPONENTS.setNode("all-components");
-        NO_COMPONENT.setNode("no-component");
-    }
-
-    private final List<ProjectInfo> projectList = new ArrayList<>();
-    private ProjectInfo projectInfo;
-    private Version versionFilter;
-    private Component componentFilter;
-
-    private int selectedPage = SELECTED_PAGE_ISSUES;
-
-    public List<ProjectInfo> getProjectList() {
-        return projectList;
-    }
-
-    public ProjectInfo getProjectInfo() {
-        return projectInfo;
-    }
-
-    public void setProjectInfo(ProjectInfo projectInfo) {
-        this.projectInfo = projectInfo;
-    }
-
-    public int getSelectedPage() {
-        return selectedPage;
-    }
-
-    public void setSelectedPage(int selectedPage) {
-        this.selectedPage = selectedPage;
-    }
-
-    public Version getVersionFilter() {
-        return versionFilter;
-    }
-
-    public void setVersionFilter(Version versionFilter) {
-        this.versionFilter = versionFilter;
-    }
-
-    public Component getComponentFilter() {
-        return componentFilter;
-    }
-
-    public void setComponentFilter(Component componentFilter) {
-        this.componentFilter = componentFilter;
-    }
-
-    public boolean isProjectInfoPresent() {
-        return projectInfo != null;
-    }
-
-    public boolean isEveryFilterValid() {
-        return projectInfo != null && versionFilter != null && componentFilter != null;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/UsersEditView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,24 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.User;
-
-public class UsersEditView {
-    private User user;
-    private String errorText;
-
-    public User getUser() {
-        return user;
-    }
-
-    public void setUser(User user) {
-        this.user = user;
-    }
-
-    public String getErrorText() {
-        return errorText;
-    }
-
-    public void setErrorText(String errorText) {
-        this.errorText = errorText;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/UsersView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,17 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.User;
-
-import java.util.List;
-
-public class UsersView {
-    private List<User> users;
-
-    public List<User> getUsers() {
-        return users;
-    }
-
-    public void setUsers(List<User> users) {
-        this.users = users;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/VersionEditView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,33 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Version;
-import de.uapcore.lightpit.types.VersionStatus;
-
-public class VersionEditView extends ProjectView {
-    private Version version;
-    private String errorText;
-
-    public VersionEditView() {
-        setSelectedPage(SELECTED_PAGE_VERSIONS);
-    }
-
-    public void setVersion(Version version) {
-        this.version = version;
-    }
-
-    public Version getVersion() {
-        return version;
-    }
-
-    public VersionStatus[] getVersionStatus() {
-        return VersionStatus.values();
-    }
-
-    public String getErrorText() {
-        return errorText;
-    }
-
-    public void setErrorText(String errorText) {
-        this.errorText = errorText;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/VersionInfo.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,64 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Issue;
-import de.uapcore.lightpit.entities.IssueSummary;
-import de.uapcore.lightpit.entities.Version;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class VersionInfo {
-
-    private final Version version;
-
-    private final IssueSummary reportedTotal = new IssueSummary();
-    private final IssueSummary resolvedTotal = new IssueSummary();
-
-    private final List<Issue> reported = new ArrayList<>();
-    private final List<Issue> resolved = new ArrayList<>();
-
-    public VersionInfo(Version version) {
-        this.version = version;
-    }
-
-    public Version getVersion() {
-        return version;
-    }
-
-    public void addReported(Issue issue) {
-        reportedTotal.add(issue);
-        reported.add(issue);
-    }
-
-    public void addResolved(Issue issue) {
-        resolvedTotal.add(issue);
-        resolved.add(issue);
-    }
-
-    public IssueSummary getReportedTotal() {
-        return reportedTotal;
-    }
-
-    public IssueSummary getResolvedTotal() {
-        return resolvedTotal;
-    }
-
-    public List<Issue> getReported() {
-        return reported;
-    }
-
-    public List<Issue> getResolved() {
-        return resolved;
-    }
-
-    public void collectIssues(List<Issue> issues) {
-        for (Issue issue : issues) {
-            if (issue.getAffectedVersions().contains(version)) {
-                addReported(issue);
-            }
-            if (issue.getResolvedVersions().contains(version)) {
-                addResolved(issue);
-            }
-        }
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/VersionsView.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,29 +0,0 @@
-package de.uapcore.lightpit.viewmodel;
-
-import de.uapcore.lightpit.entities.Issue;
-import de.uapcore.lightpit.entities.Version;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class VersionsView extends ProjectView {
-
-    private List<VersionInfo> versionInfos = new ArrayList<>();
-
-    public VersionsView() {
-        setSelectedPage(SELECTED_PAGE_VERSIONS);
-    }
-
-    public void update(List<Version> versions, List<Issue> issues) {
-        versionInfos.clear();
-        for (var version : versions) {
-            final var info = new VersionInfo(version);
-            info.collectIssues(issues);
-            versionInfos.add(info);
-        }
-    }
-
-    public List<VersionInfo> getVersionInfos() {
-        return versionInfos;
-    }
-}
--- a/src/main/java/de/uapcore/lightpit/viewmodel/util/IssueSorter.java	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,83 +0,0 @@
-package de.uapcore.lightpit.viewmodel.util;
-
-import de.uapcore.lightpit.entities.Issue;
-import de.uapcore.lightpit.types.IssueStatusPhase;
-
-import java.util.Arrays;
-import java.util.Comparator;
-
-public class IssueSorter implements Comparator<Issue> {
-
-    public enum Field {
-        DONE, ETA, UPDATED
-    }
-
-    public static class Criteria {
-        private Field field;
-        private boolean asc;
-
-        public Criteria(Field field, boolean asc) {
-            this.field = field;
-            this.asc = asc;
-        }
-
-        @Override
-        public boolean equals(Object obj) {
-            if (obj == null || !obj.getClass().equals(Criteria.class))
-                return false;
-            final var other = (Criteria)obj;
-            return other.field.equals(field) && other.asc == asc;
-        }
-    }
-
-    private final Criteria[] criteria;
-
-    public IssueSorter(Criteria ... criteria) {
-        this.criteria = criteria;
-    }
-
-    private int compare(Issue left, Issue right, Criteria criteria) {
-        if (left.equals(right))
-            return 0;
-
-        int result;
-        switch (criteria.field) {
-            case DONE:
-                result = Boolean.compare(
-                        left.getStatus().getPhase().equals(IssueStatusPhase.Companion.getDone()),
-                        right.getStatus().getPhase().equals(IssueStatusPhase.Companion.getDone()));
-                break;
-            case ETA:
-                if (left.getEta() != null && right.getEta() != null)
-                    result = left.getEta().compareTo(right.getEta());
-                else if (left.getEta() == null && right.getEta() == null)
-                    result = 0;
-                else
-                    result = left.getEta() != null ? -1 : 1;
-                break;
-            case UPDATED:
-                result = left.getUpdated().compareTo(right.getUpdated());
-                break;
-            default:
-                throw new UnsupportedOperationException();
-        }
-        return criteria.asc ? result : -result;
-    }
-
-    @Override
-    public int compare(Issue left, Issue right) {
-        for (var c : criteria) {
-            int r = compare(left, right, c);
-            if (r != 0) return r;
-        }
-        return 0;
-    }
-
-    @Override
-    public boolean equals(Object o) {
-        if (o == null || !o.getClass().equals(IssueSorter.class))
-            return false;
-        final var other = (IssueSorter) o;
-        return Arrays.equals(criteria, other.criteria);
-    }
-}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/AbstractServlet.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit
+
+import de.uapcore.lightpit.DataSourceProvider.Companion.SC_ATTR_NAME
+import de.uapcore.lightpit.dao.DataAccessObject
+import de.uapcore.lightpit.dao.createDataAccessObject
+import java.sql.SQLException
+import java.util.*
+import javax.servlet.http.HttpServlet
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
+
+abstract class AbstractServlet : LoggingTrait, HttpServlet() {
+
+    /**
+     * Contains the GET request mappings.
+     */
+    private val getMappings = mutableMapOf<PathPattern, MappingMethod>()
+
+    /**
+     * Contains the POST request mappings.
+     */
+    private val postMappings = mutableMapOf<PathPattern, MappingMethod>()
+
+    protected fun get(pattern: String, method: MappingMethod) {
+        getMappings[PathPattern(pattern)] = method
+    }
+
+    protected fun post(pattern: String, method: MappingMethod) {
+        postMappings[PathPattern(pattern)] = method
+    }
+
+    private fun notFound(http: HttpRequest, dao: DataAccessObject) {
+        http.response.sendError(HttpServletResponse.SC_NOT_FOUND)
+    }
+
+    private fun findMapping(
+        mappings: Map<PathPattern, MappingMethod>,
+        req: HttpServletRequest
+    ): Pair<PathPattern, MappingMethod> {
+        val requestPath = sanitizedRequestPath(req)
+        val candidates = mappings.filter { it.key.matches(requestPath) }
+        return if (candidates.isEmpty()) {
+            Pair(PathPattern(requestPath), ::notFound)
+        } else {
+            if (candidates.size > 1) {
+                logger().warn("Ambiguous mapping for request path '{}'", requestPath)
+            }
+            candidates.entries.first().toPair()
+        }
+    }
+
+    private fun invokeMapping(
+        mapping: Pair<PathPattern, MappingMethod>,
+        req: HttpServletRequest,
+        resp: HttpServletResponse,
+        dao: DataAccessObject
+    ) {
+        val params = mapping.first.obtainPathParameters(sanitizedRequestPath(req))
+        val method = mapping.second
+        logger().trace("invoke {}", method)
+        method(HttpRequest(req, resp, params), dao)
+    }
+
+    private fun sanitizedRequestPath(req: HttpServletRequest) = req.pathInfo ?: "/"
+
+    private fun doProcess(
+        req: HttpServletRequest,
+        resp: HttpServletResponse,
+        mappings: Map<PathPattern, MappingMethod>
+    ) {
+        val session = req.session
+
+        // the very first thing to do is to force UTF-8
+        req.characterEncoding = "UTF-8"
+
+        // choose the requested language as session language (if available) or fall back to english, otherwise
+        if (session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) == null) {
+            val availableLanguages = availableLanguages()
+            val reqLocale = req.locale
+            val sessionLocale = if (availableLanguages.contains(reqLocale)) reqLocale else availableLanguages.first()
+            session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, sessionLocale)
+            logger().debug(
+                "Setting language for new session {}: {}", session.id, sessionLocale.displayLanguage
+            )
+        } else {
+            val sessionLocale = session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) as Locale
+            resp.locale = sessionLocale
+            logger().trace("Continuing session {} with language {}", session.id, sessionLocale)
+        }
+
+        // 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
+            req.setAttribute(Constants.REQ_ATTR_REFERER, it)
+        }
+
+        // if this is an error path, bypass the normal flow
+        if (fullPath.startsWith("/error/")) {
+            http.styleSheets = listOf("error")
+            http.render("error")
+            return
+        }
+
+        // obtain a connection and create the data access objects
+        val dsp = req.servletContext.getAttribute(SC_ATTR_NAME) as DataSourceProvider
+        val dialect = dsp.dialect
+        val ds = dsp.dataSource
+        if (ds == null) {
+            resp.sendError(
+                HttpServletResponse.SC_SERVICE_UNAVAILABLE,
+                "JNDI DataSource lookup failed. See log for details."
+            )
+            return
+        }
+        try {
+            ds.connection.use { connection ->
+                val dao = createDataAccessObject(dialect, connection)
+                try {
+                    connection.autoCommit = false
+                    invokeMapping(findMapping(mappings, req), req, resp, dao)
+                    connection.commit()
+                } catch (ex: SQLException) {
+                    logger().warn("Database transaction failed (Code {}): {}", ex.errorCode, ex.message)
+                    logger().debug("Details: ", ex)
+                    resp.sendError(
+                        HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
+                        "Unhandled Transaction Error - Code: " + ex.errorCode
+                    )
+                    connection.rollback()
+                }
+            }
+        } catch (ex: SQLException) {
+            logger().error("Severe Database Exception (Code {}): {}", ex.errorCode, ex.message)
+            logger().debug("Details: ", ex)
+            resp.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Database Error - Code: " + ex.errorCode)
+        }
+    }
+
+    override fun doGet(req: HttpServletRequest, resp: HttpServletResponse) {
+        doProcess(req, resp, getMappings)
+    }
+
+    override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) {
+        doProcess(req, resp, postMappings)
+    }
+
+    protected fun availableLanguages(): List<Locale> {
+        val langTags = servletContext.getInitParameter(Constants.CTX_ATTR_LANGUAGES)?.split(",")?.map(String::trim) ?: emptyList()
+        val locales = langTags.map(Locale::forLanguageTag).filter { it.language.isNotEmpty() }
+        return if (locales.isEmpty()) listOf(Locale.ENGLISH) else locales
+    }
+
+}
--- a/src/main/kotlin/de/uapcore/lightpit/Constants.kt	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/Constants.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -52,7 +52,7 @@
     const val CTX_ATTR_DB_DIALECT = "db-dialect"
 
     /**
-     * Key for the request attribute containing the optional navigation menu jsp.
+     * Key for the request attribute containing the optional navigation menu.
      */
     const val REQ_ATTR_NAVIGATION = "navMenu"
 
@@ -88,6 +88,11 @@
     const val REQ_ATTR_REDIRECT_LOCATION = "redirectLocation"
 
     /**
+     * Key for the optional return link based on the referer header.
+     */
+    const val REQ_ATTR_REFERER = "returnLink"
+
+    /**
      * Key for the current language selection within the session.
      */
     const val SESSION_ATTR_LANGUAGE = "language"
--- a/src/main/kotlin/de/uapcore/lightpit/DataSourceProvider.kt	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/DataSourceProvider.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -61,12 +61,12 @@
         /**
          * The attribute name in the Servlet context under which an instance of this class can be found.
          */
-        val SC_ATTR_NAME = "lightpit.service.DataSourceProvider"
+        const val SC_ATTR_NAME = "lightpit.service.DataSourceProvider"
 
         /**
          * Timeout in seconds for the validation test.
          */
-        private val DB_TEST_TIMEOUT = 10
+        private const val DB_TEST_TIMEOUT = 10
 
         /**
          * The default schema to test against when validating the connection.
@@ -74,12 +74,12 @@
          *
          * @see Constants.CTX_ATTR_DB_SCHEMA
          */
-        private val DB_DEFAULT_SCHEMA = "lightpit"
+        private const val DB_DEFAULT_SCHEMA = "lightpit"
 
         /**
          * The JNDI resource name for the data source.
          */
-        private val DS_JNDI_NAME = "jdbc/lightpit/app"
+        private const val DS_JNDI_NAME = "jdbc/lightpit/app"
     }
 
     private fun checkConnection(ds: DataSource, testSchema: String) {
--- a/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/RequestMapping.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -25,45 +25,106 @@
 
 package de.uapcore.lightpit
 
+import de.uapcore.lightpit.dao.DataAccessObject
+import de.uapcore.lightpit.viewmodel.NavMenu
+import de.uapcore.lightpit.viewmodel.View
+import javax.servlet.http.HttpServletRequest
+import javax.servlet.http.HttpServletResponse
+import javax.servlet.http.HttpSession
 import kotlin.math.min
 
-/**
- * Maps requests to methods.
- *
- * This annotation is used to annotate methods within classes which
- * override [AbstractServlet].
- */
-@MustBeDocumented
-@Retention(AnnotationRetention.RUNTIME)
-@Target(AnnotationTarget.FUNCTION)
-annotation class RequestMapping(
+typealias MappingMethod = (HttpRequest, DataAccessObject) -> Unit
+typealias PathParameters = Map<String, String>
+
+class HttpRequest(
+    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.
+     *
+     * @see Constants#REQ_ATTR_CONTENT_PAGE
+     */
+    var contentPage = ""
+        set(value) {
+            field = value
+            request.setAttribute(Constants.REQ_ATTR_CONTENT_PAGE, jspPath(value))
+        }
+
+    /**
+     * A list of additional style sheets.
+     *
+     * @see Constants#REQ_ATTR_STYLESHEET
+     */
+    var styleSheets = emptyList<String>()
+        set(value) {
+            field = value
+            request.setAttribute(Constants.REQ_ATTR_STYLESHEET,
+                value.map { it.withExt(".css") }
+            )
+        }
 
     /**
-     * Specifies the HTTP method.
+     * The name of the navigation menu JSP.
      *
-     * @return the HTTP method handled by the annotated Java method
+     * @see Constants#REQ_ATTR_NAVIGATION
      */
-    val method: HttpMethod,
+    var navigationMenu: NavMenu? = null
+        set(value) {
+            field = value
+            request.setAttribute(Constants.REQ_ATTR_NAVIGATION, navigationMenu)
+        }
+
+    var redirectLocation = ""
+        set(value) {
+            field = value
+            request.setAttribute(Constants.REQ_ATTR_REDIRECT_LOCATION, baseHref + value)
+        }
 
     /**
-     * Specifies the request path relative to the module path.
-     * The trailing slash is important.
-     * A node may start with a dollar ($) sign.
-     * This part of the path is then treated as an path parameter.
-     * Path parameters can be obtained by including the [PathParameters] type in the signature.
+     * The view object.
      *
-     * @return the request path the annotated method should handle
+     * @see Constants#REQ_ATTR_VIEWMODEL
+     */
+    var view: View? = null
+        set(value) {
+            field = value
+            request.setAttribute(Constants.REQ_ATTR_VIEWMODEL, value)
+        }
+
+    /**
+     * The base path of this application.
      */
-    val requestPath: String = "/"
-)
+    val baseHref get() = "${request.scheme}://${request.serverName}:${request.serverPort}${request.contextPath}/"
+
+    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")
+
+    fun param(name: String): String? = request.getParameter(name)
+    fun paramArray(name: String): Array<String> = request.getParameterValues(name) ?: emptyArray()
 
-class PathParameters : HashMap<String, String>()
+    fun render(page: String? = null) {
+        page?.let { contentPage = it }
+        request.getRequestDispatcher(jspPath("site")).forward(request, response)
+    }
+
+    fun renderCommit(location: String? = null) {
+        location?.let { redirectLocation = it }
+        contentPage = Constants.JSP_COMMIT_SUCCESSFUL
+        render()
+    }
+}
 
 /**
  * A path pattern optionally containing placeholders.
  *
  * The special directories . and .. are disallowed in the pattern.
- * Placeholders start with a $ sign.
+ * Placeholders start with a % sign.
  *
  * @param pattern the pattern
  */
@@ -91,7 +152,7 @@
         for (i in nodePatterns.indices) {
             val pattern = nodePatterns[i]
             val node = nodes[i]
-            if (pattern.startsWith("$")) continue
+            if (pattern.startsWith("%")) continue
             if (pattern != node) return false
         }
         return true
@@ -106,12 +167,12 @@
      * @see .matches
      */
     fun obtainPathParameters(path: String): PathParameters {
-        val params = PathParameters()
+        val params = mutableMapOf<String, String>()
         val nodes = parse(path)
         for (i in 0 until min(nodes.size, nodePatterns.size)) {
             val pattern = nodePatterns[i]
             val node = nodes[i]
-            if (pattern.startsWith("$")) {
+            if (pattern.startsWith("%")) {
                 params[pattern.substring(1)] = node
             }
         }
@@ -121,8 +182,8 @@
     override fun hashCode(): Int {
         val str = StringBuilder()
         for (node in nodePatterns) {
-            if (node.startsWith("$")) {
-                str.append("/$")
+            if (node.startsWith("%")) {
+                str.append("/%")
             } else {
                 str.append('/')
                 str.append(node)
@@ -138,7 +199,7 @@
             for (i in nodePatterns.indices) {
                 val left = nodePatterns[i]
                 val right = other.nodePatterns[i]
-                if (left.startsWith("$") && right.startsWith("$")) continue
+                if (left.startsWith("%") && right.startsWith("%")) continue
                 if (left != right) return false
             }
             return true
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/DaoFactory.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.dao
+
+import de.uapcore.lightpit.DataSourceProvider
+import java.sql.Connection
+
+fun createDataAccessObject(
+    dialect: DataSourceProvider.Dialect,
+    connection: Connection
+): DataAccessObject =
+    when (dialect) {
+        DataSourceProvider.Dialect.Postgres -> PostgresDataAccessObject(connection)
+    }
\ No newline at end of file
--- a/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/DataAccessObject.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -26,7 +26,10 @@
 package de.uapcore.lightpit.dao
 
 import de.uapcore.lightpit.entities.*
-import de.uapcore.lightpit.filter.IssueFilter
+import de.uapcore.lightpit.util.IssueFilter
+import de.uapcore.lightpit.viewmodel.ComponentSummary
+import de.uapcore.lightpit.viewmodel.IssueSummary
+import de.uapcore.lightpit.viewmodel.VersionSummary
 
 interface DataAccessObject {
     fun listUsers(): List<User>
@@ -35,18 +38,29 @@
     fun insertUser(user: User)
     fun updateUser(user: User)
 
+    /**
+     * Lists all versions of the specified [project].
+     *
+     * The list is first ordered by the ordinal of the version and
+     * then by name, both descending.
+     */
     fun listVersions(project: Project): List<Version>
+    fun listVersionSummaries(project: Project): List<VersionSummary>
     fun findVersion(id: Int): Version?
     fun findVersionByNode(project: Project, node: String): Version?
     fun insertVersion(version: Version)
     fun updateVersion(version: Version)
 
     fun listComponents(project: Project): List<Component>
+    fun listComponentSummaries(project: Project): List<ComponentSummary>
     fun findComponent(id: Int): Component?
     fun findComponentByNode(project: Project, node: String): Component?
     fun insertComponent(component: Component)
     fun updateComponent(component: Component)
 
+    /**
+     * Lists all projects ordered by name.
+     */
     fun listProjects(): List<Project>
     fun findProject(id: Int): Project?
     fun findProjectByNode(node: String): Project?
@@ -57,7 +71,7 @@
 
     fun listIssues(filter: IssueFilter): List<Issue>
     fun findIssue(id: Int): Issue?
-    fun insertIssue(issue: Issue)
+    fun insertIssue(issue: Issue): Int
     fun updateIssue(issue: Issue)
 
     fun listComments(issue: Issue): List<IssueComment>
--- a/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/dao/PostgresDataAccessObject.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -26,8 +26,11 @@
 package de.uapcore.lightpit.dao
 
 import de.uapcore.lightpit.entities.*
-import de.uapcore.lightpit.filter.*
 import de.uapcore.lightpit.types.WebColor
+import de.uapcore.lightpit.util.*
+import de.uapcore.lightpit.viewmodel.ComponentSummary
+import de.uapcore.lightpit.viewmodel.IssueSummary
+import de.uapcore.lightpit.viewmodel.VersionSummary
 import java.sql.Connection
 import java.sql.PreparedStatement
 import java.sql.ResultSet
@@ -129,15 +132,19 @@
     //</editor-fold>
 
     //<editor-fold desc="Version">
+
+    private fun obtainVersion(rs: ResultSet) =
+        Version(rs.getInt("versionid"), rs.getInt("project")).apply {
+            name = rs.getString("name")
+            node = rs.getString("node")
+            ordinal = rs.getInt("ordinal")
+            status = rs.getEnum("status")
+        }
+
     private fun selectVersions(stmt: PreparedStatement) = sequence {
         stmt.executeQuery().use { rs ->
             while (rs.next()) {
-                yield(Version(rs.getInt("versionid"), rs.getInt("project")).apply {
-                    name = rs.getString("name")
-                    node = rs.getString("node")
-                    ordinal = rs.getInt("ordinal")
-                    status = rs.getEnum("status")
-                })
+                yield(obtainVersion(rs))
             }
         }
     }
@@ -163,6 +170,36 @@
             """
         )
     }
+    private val stmtVersionSummaries by lazy {
+        connection.prepareStatement(
+            """
+            with version_map(issueid, versionid, isresolved) as (
+                select issueid, versionid, 1
+                from lpit_issue_resolved_version
+                union
+                select issueid, versionid, 0
+                from lpit_issue_affected_version
+            ),
+            issues as (
+                select versionid, phase, isresolved, count(issueid) as total
+                from lpit_issue
+                join version_map using (issueid)
+                join lpit_issue_phases using (status)
+                group by versionid, phase, isresolved
+            ),
+            summary as (
+                select versionid, phase, isresolved, total
+                from lpit_version v
+                left join issues using (versionid)
+                where v.project = ?
+            )
+            select versionid, project, name, node, ordinal, status, phase, isresolved, total
+            from lpit_version
+            join summary using (versionid)
+            order by ordinal, name
+            """
+        )
+    }
     private val stmtVersionByID by lazy {
         connection.prepareStatement(
             """${versionQuery}
@@ -199,6 +236,27 @@
         return selectVersions(stmtVersions).toList()
     }
 
+    override fun listVersionSummaries(project: Project): List<VersionSummary> {
+        stmtVersionSummaries.setInt(1, project.id)
+        return sequence {
+            stmtVersionSummaries.executeQuery().use { rs ->
+                while (rs.next()) {
+                    val versionSummary = VersionSummary(obtainVersion(rs))
+                    val phase = rs.getInt("phase")
+                    val total = rs.getInt("total")
+                    val issueSummary =
+                        if (rs.getBoolean("isresolved")) versionSummary.resolvedTotal else versionSummary.reportedTotal
+                    when (phase) {
+                        0 -> issueSummary.open = total
+                        1 -> issueSummary.active = total
+                        2 -> issueSummary.done = total
+                    }
+                    yield(versionSummary)
+                }
+            }
+        }.toList()
+    }
+
     override fun findVersion(id: Int): Version? {
         stmtVersionByID.setInt(1, id)
         return selectVersions(stmtVersionByID).firstOrNull()
@@ -224,21 +282,25 @@
     //</editor-fold>
 
     //<editor-fold desc="Component">
+
+    private fun obtainComponent(rs: ResultSet): Component =
+        Component(rs.getInt("id"), rs.getInt("project")).apply {
+            name = rs.getString("name")
+            node = rs.getString("node")
+            color = try {
+                WebColor(rs.getString("color"))
+            } catch (ex: IllegalArgumentException) {
+                WebColor("000000")
+            }
+            ordinal = rs.getInt("ordinal")
+            description = rs.getString("description")
+            lead = selectUserInfo(rs)
+        }
+
     private fun selectComponents(stmt: PreparedStatement) = sequence {
         stmt.executeQuery().use { rs ->
             while (rs.next()) {
-                yield(Component(rs.getInt("id"), rs.getInt("project")).apply {
-                    name = rs.getString("name")
-                    node = rs.getString("node")
-                    color = try {
-                        WebColor(rs.getString("color"))
-                    } catch (ex: IllegalArgumentException) {
-                        WebColor("000000")
-                    }
-                    ordinal = rs.getInt("ordinal")
-                    description = rs.getString("description")
-                    lead = selectUserInfo(rs)
-                })
+                yield(obtainComponent(rs))
             }
         }
     }
@@ -272,6 +334,30 @@
             """
         )
     }
+    private val stmtComponentSummaries by lazy {
+        connection.prepareStatement(
+            """
+            with issues as (
+                select component, phase, count(issueid) as total
+                from lpit_issue
+                join lpit_issue_phases using (status)
+                group by component, phase
+            ),
+            summary as (
+                select c.id, phase, total
+                from lpit_component c
+                left join issues i on c.id = i.component 
+                where c.project = ?
+            )
+            select c.id, project, name, node, color, ordinal, description,
+                userid, username, givenname, lastname, mail, phase, total
+            from lpit_component c
+            left join lpit_user on lead = userid
+            join summary s on c.id = s.id
+            order by ordinal, name
+            """
+        )
+    }
     private val stmtComponentById by lazy {
         connection.prepareStatement(
             """${componentQuery}
@@ -305,6 +391,25 @@
         return selectComponents(stmtComponents).toList()
     }
 
+    override fun listComponentSummaries(project: Project): List<ComponentSummary> {
+        stmtComponentSummaries.setInt(1, project.id)
+        return sequence {
+            stmtComponentSummaries.executeQuery().use { rs ->
+                while (rs.next()) {
+                    val componentSummary = ComponentSummary(obtainComponent(rs))
+                    val phase = rs.getInt("phase")
+                    val total = rs.getInt("total")
+                    when (phase) {
+                        0 -> componentSummary.issueSummary.open = total
+                        1 -> componentSummary.issueSummary.active = total
+                        2 -> componentSummary.issueSummary.done = total
+                    }
+                    yield(componentSummary)
+                }
+            }
+        }.toList()
+    }
+
     override fun findComponent(id: Int): Component? {
         stmtComponentById.setInt(1, id)
         return selectComponents(stmtComponentById).firstOrNull()
@@ -471,7 +576,7 @@
                             node = rs.getString("componentnode")
                         }
                 }
-                val issue = Issue(rs.getInt("issueid"), proj, comp).apply {
+                val issue = Issue(rs.getInt("issueid"), proj).apply {
                     component = comp
                     status = rs.getEnum("status")
                     category = rs.getEnum("category")
@@ -672,14 +777,15 @@
         }
     }
 
-    override fun insertIssue(issue: Issue) {
+    override fun insertIssue(issue: Issue): Int {
         val col = setIssueFields(stmtInsertIssue, issue)
         stmtInsertIssue.setInt(col, issue.project.id)
-        stmtInsertIssue.executeQuery().use { rs ->
+        val id = stmtInsertIssue.executeQuery().use { rs ->
             rs.next()
-            issue.id = rs.getInt(1)
+            rs.getInt(1)
         }
         insertVersionInfo(issue)
+        return id
     }
 
     override fun updateIssue(issue: Issue) {
--- a/src/main/kotlin/de/uapcore/lightpit/entities/Component.kt	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/entities/Component.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -27,11 +27,11 @@
 
 import de.uapcore.lightpit.types.WebColor
 
-data class Component(override val id: Int, var projectid: Int) : Entity {
+data class Component(override val id: Int, val projectid: Int) : Entity, HasNode {
     var name: String = ""
-    var node: String = name
+    override var node: String = name
+    var ordinal = 0
     var color = WebColor("000000")
-    var ordinal = 0
     var description: String? = null
     var lead: User? = null
 }
\ No newline at end of file
--- a/src/main/kotlin/de/uapcore/lightpit/entities/Entity.kt	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/entities/Entity.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -27,4 +27,8 @@
 
 interface Entity {
     val id: Int
-}
\ No newline at end of file
+}
+
+interface HasNode {
+    val node: String
+}
--- a/src/main/kotlin/de/uapcore/lightpit/entities/Issue.kt	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/entities/Issue.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -32,13 +32,13 @@
 import java.sql.Timestamp
 import java.time.Instant
 
-data class Issue(override var id: Int, var project: Project, var component: Component? = null) : Entity {
-
+data class Issue(override val id: Int, var project: Project) : Entity {
+    var component: Component? = null
     var status = IssueStatus.InSpecification
     var category = IssueCategory.Feature
 
     var subject: String = ""
-    var description: String? = null
+    var description: String = ""
     var assignee: User? = null
 
     var created: Timestamp = Timestamp.from(Instant.now())
--- a/src/main/kotlin/de/uapcore/lightpit/entities/IssueSummary.kt	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,53 +0,0 @@
-/*
- * Copyright 2021 Mike Becker. All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- * 1. Redistributions of source code must retain the above copyright
- * notice, this list of conditions and the following disclaimer.
- *
- * 2. Redistributions in binary form must reproduce the above copyright
- * notice, this list of conditions and the following disclaimer in the
- * documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
- * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
- * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
- * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
- * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
- * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
- * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
- * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-package de.uapcore.lightpit.entities
-
-import de.uapcore.lightpit.types.IssueStatusPhase
-import kotlin.math.roundToInt
-
-class IssueSummary {
-    var open = 0
-    var active = 0
-    var done = 0
-
-    val total get() = open + active + done
-
-    val openPercent get() = 100 - activePercent - donePercent
-    val activePercent get() = if (total > 0) (100f * active / total).roundToInt() else 0
-    val donePercent get() = if (total > 0) (100f * done / total).roundToInt() else 100
-
-    /**
-     * Adds the specified issue to the summary by incrementing the respective counter.
-     * @param issue the issue
-     */
-    fun add(issue: Issue) {
-        when (issue.status.phase) {
-            IssueStatusPhase.Open -> open++
-            IssueStatusPhase.WorkInProgress -> active++
-            IssueStatusPhase.Done -> done++
-        }
-    }
-}
\ No newline at end of file
--- a/src/main/kotlin/de/uapcore/lightpit/entities/Project.kt	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/entities/Project.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -25,9 +25,9 @@
 
 package de.uapcore.lightpit.entities
 
-data class Project(override val id: Int) : Entity {
+data class Project(override val id: Int) : Entity, HasNode {
     var name: String = ""
-    var node: String = name
+    override var node: String = name
     var ordinal = 0
     var description: String? = null
     var repoUrl: String? = null
--- a/src/main/kotlin/de/uapcore/lightpit/entities/Version.kt	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/entities/Version.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -27,9 +27,9 @@
 
 import de.uapcore.lightpit.types.VersionStatus
 
-data class Version(override val id: Int, var projectid: Int) : Entity, Comparable<Version> {
+data class Version(override val id: Int, val projectid: Int) : Entity, HasNode, Comparable<Version> {
     var name: String = ""
-    var node = name
+    override var node = name
     var ordinal = 0
     var status = VersionStatus.Future
 
--- a/src/main/kotlin/de/uapcore/lightpit/filter/Filter.kt	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,32 +0,0 @@
-/*
- * Copyright 2021 Mike Becker. All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- * 1. Redistributions of source code must retain the above copyright
- * notice, this list of conditions and the following disclaimer.
- *
- * 2. Redistributions in binary form must reproduce the above copyright
- * notice, this list of conditions and the following disclaimer in the
- * documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
- * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
- * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
- * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
- * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
- * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
- * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
- * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-package de.uapcore.lightpit.filter
-
-sealed class Filter<T>
-class AllFilter<T> : Filter<T>()
-class NoneFilter<T> : Filter<T>()
-data class SpecificFilter<T>(val obj: T) : Filter<T>()
-data class RangeFilter<T>(val lower: T, val upper: T) : Filter<T>() where T : Comparable<T>
--- a/src/main/kotlin/de/uapcore/lightpit/filter/IssueFilter.kt	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,36 +0,0 @@
-/*
- * Copyright 2021 Mike Becker. All rights reserved.
- *
- * Redistribution and use in source and binary forms, with or without
- * modification, are permitted provided that the following conditions are met:
- *
- * 1. Redistributions of source code must retain the above copyright
- * notice, this list of conditions and the following disclaimer.
- *
- * 2. Redistributions in binary form must reproduce the above copyright
- * notice, this list of conditions and the following disclaimer in the
- * documentation and/or other materials provided with the distribution.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
- * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
- * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
- * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
- * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
- * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
- * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
- * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
- * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
- * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-package de.uapcore.lightpit.filter
-
-import de.uapcore.lightpit.entities.Component
-import de.uapcore.lightpit.entities.Project
-import de.uapcore.lightpit.entities.Version
-
-data class IssueFilter(
-    val project: Filter<Project> = AllFilter(),
-    val version: Filter<Version> = AllFilter(),
-    val component: Filter<Component> = AllFilter()
-)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/ErrorServlet.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.servlet
+
+import de.uapcore.lightpit.AbstractServlet
+import javax.servlet.annotation.WebServlet
+
+@WebServlet(urlPatterns = ["/error/*"])
+class ErrorServlet : AbstractServlet()
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/LanguageServlet.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.servlet
+
+import de.uapcore.lightpit.AbstractServlet
+import de.uapcore.lightpit.Constants
+import de.uapcore.lightpit.HttpRequest
+import de.uapcore.lightpit.dao.DataAccessObject
+import de.uapcore.lightpit.viewmodel.LanguageView
+import java.util.*
+import javax.servlet.annotation.WebServlet
+
+@WebServlet(urlPatterns = ["/language/*"])
+class LanguageServlet : AbstractServlet() {
+
+    init {
+        get("/", this::viewLanguages)
+        post("/", this::selectLanguage)
+    }
+
+    private fun viewLanguages(http: HttpRequest, dao: DataAccessObject) {
+        with(http) {
+            view = LanguageView(
+                availableLanguages(),
+                session.getAttribute(Constants.SESSION_ATTR_LANGUAGE) as Locale,
+                request.locale
+            )
+            styleSheets = listOf("language")
+            render("language")
+        }
+    }
+
+    private fun selectLanguage(http: HttpRequest, dao: DataAccessObject) {
+        val lang = http.param("language")
+        if (lang != null) {
+            val locale = Locale.forLanguageTag(lang)
+            if (!locale.language.isNullOrBlank()) {
+                http.response.locale = locale
+                http.session.setAttribute(Constants.SESSION_ATTR_LANGUAGE, locale)
+            }
+        }
+
+        viewLanguages(http, dao)
+    }
+
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/ProjectServlet.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,522 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.servlet
+
+import de.uapcore.lightpit.AbstractServlet
+import de.uapcore.lightpit.HttpRequest
+import de.uapcore.lightpit.dao.DataAccessObject
+import de.uapcore.lightpit.entities.*
+import de.uapcore.lightpit.types.IssueCategory
+import de.uapcore.lightpit.types.IssueStatus
+import de.uapcore.lightpit.types.VersionStatus
+import de.uapcore.lightpit.types.WebColor
+import de.uapcore.lightpit.util.AllFilter
+import de.uapcore.lightpit.util.IssueFilter
+import de.uapcore.lightpit.util.SpecificFilter
+import de.uapcore.lightpit.viewmodel.*
+import java.sql.Date
+import javax.servlet.annotation.WebServlet
+
+@WebServlet(urlPatterns = ["/projects/*"])
+class ProjectServlet : AbstractServlet() {
+
+    init {
+        get("/", this::projects)
+        get("/%project", this::project)
+        get("/%project/issues/%version/%component/", this::project)
+        get("/%project/edit", this::projectForm)
+        get("/-/create", this::projectForm)
+        post("/-/commit", this::projectCommit)
+
+        get("/%project/versions/", this::versions)
+        get("/%project/versions/%version/edit", this::versionForm)
+        get("/%project/versions/-/create", this::versionForm)
+        post("/%project/versions/-/commit", this::versionCommit)
+
+        get("/%project/components/", this::components)
+        get("/%project/components/%component/edit", this::componentForm)
+        get("/%project/components/-/create", this::componentForm)
+        post("/%project/components/-/commit", this::componentCommit)
+
+        get("/%project/issues/%version/%component/%issue", this::issue)
+        get("/%project/issues/%version/%component/%issue/edit", this::issueForm)
+        get("/%project/issues/%version/%component/%issue/comment", this::issueComment)
+        get("/%project/issues/%version/%component/-/create", this::issueForm)
+        get("/%project/issues/%version/%component/-/commit", this::issueCommit)
+    }
+
+    fun projects(http: HttpRequest, dao: DataAccessObject) {
+        val projects = dao.listProjects()
+        val projectInfos = projects.map {
+            ProjectInfo(
+                project = it,
+                versions = dao.listVersions(it),
+                components = emptyList(), // not required in this view
+                issueSummary = dao.collectIssueSummary(it)
+            )
+        }
+
+        with(http) {
+            view = ProjectsView(projectInfos)
+            navigationMenu = projectNavMenu(projects)
+            styleSheets = listOf("projects")
+            render("projects")
+        }
+    }
+
+    private fun activeProjectNavMenu(
+        projects: List<Project>,
+        projectInfo: ProjectInfo,
+        selectedVersion: Version? = null,
+        selectedComponent: Component? = null
+    ) =
+        projectNavMenu(
+            projects,
+            projectInfo.versions,
+            projectInfo.components,
+            projectInfo.project,
+            selectedVersion,
+            selectedComponent
+        )
+
+    sealed class LookupResult<T> {
+        class NotFound<T> : LookupResult<T>()
+        data class Found<T>(val elem: T?) : LookupResult<T>()
+    }
+
+    private fun <T : HasNode> HttpRequest.lookupPathParam(paramName: String, list: List<T>): LookupResult<T> {
+        val node = pathParams[paramName]
+        return if (node == null || node == "-") {
+            LookupResult.Found(null)
+        } else {
+            val result = list.find { it.node == node }
+            if (result == null) {
+                LookupResult.NotFound()
+            } else {
+                LookupResult.Found(result)
+            }
+        }
+    }
+
+    private fun obtainProjectInfo(http: HttpRequest, dao: DataAccessObject): ProjectInfo? {
+        val project = dao.findProjectByNode(http.pathParams["project"] ?: "") ?: return null
+
+        val versions: List<Version> = dao.listVersions(project)
+        val components: List<Component> = dao.listComponents(project)
+
+        return ProjectInfo(
+            project,
+            versions,
+            components,
+            dao.collectIssueSummary(project)
+        )
+    }
+
+    private fun sanitizeNode(name: String): String {
+        val san = name.replace(Regex("[/\\\\]"), "-")
+        return if (san.startsWith(".")) {
+            "v$san"
+        } else {
+            san
+        }
+    }
+
+    data class PathInfos(
+        val projectInfo: ProjectInfo,
+        val version: Version?,
+        val component: Component?
+    ) {
+        val issuesHref by lazyOf("projects/${projectInfo.project.node}/issues/${version?.node ?: "-"}/${component?.node ?: "-"}/")
+    }
+
+    private fun withPathInfo(http: HttpRequest, dao: DataAccessObject): PathInfos? {
+        val projectInfo = obtainProjectInfo(http, dao)
+        if (projectInfo == null) {
+            http.response.sendError(404)
+            return null
+        }
+
+        val version = when (val result = http.lookupPathParam("version", projectInfo.versions)) {
+            is LookupResult.NotFound -> {
+                http.response.sendError(404)
+                return null
+            }
+            is LookupResult.Found -> {
+                result.elem
+            }
+        }
+        val component = when (val result = http.lookupPathParam("component", projectInfo.components)) {
+            is LookupResult.NotFound -> {
+                http.response.sendError(404)
+                return null
+            }
+            is LookupResult.Found -> {
+                result.elem
+            }
+        }
+
+        return PathInfos(projectInfo, version, component)
+    }
+
+    fun project(http: HttpRequest, dao: DataAccessObject) {
+        withPathInfo(http, dao)?.run {
+            val issues = dao.listIssues(IssueFilter(
+                project = SpecificFilter(projectInfo.project),
+                version = version?.let { SpecificFilter(it) } ?: AllFilter(),
+                component = component?.let { SpecificFilter(it) } ?: AllFilter()
+            ))
+
+            with(http) {
+                view = ProjectDetails(projectInfo, issues, version, component)
+                navigationMenu = activeProjectNavMenu(
+                    dao.listProjects(),
+                    projectInfo,
+                    version,
+                    component
+                )
+                styleSheets = listOf("projects")
+                render("project-details")
+            }
+        }
+    }
+
+    fun projectForm(http: HttpRequest, dao: DataAccessObject) {
+        val projectInfo = obtainProjectInfo(http, dao)
+        if (projectInfo == null) {
+            http.response.sendError(404)
+            return
+        }
+
+        with(http) {
+            view = ProjectEditView(projectInfo.project, dao.listUsers())
+            navigationMenu = activeProjectNavMenu(
+                dao.listProjects(),
+                projectInfo
+            )
+            styleSheets = listOf("projects")
+            render("project-form")
+        }
+    }
+
+    fun projectCommit(http: HttpRequest, dao: DataAccessObject) {
+        // TODO: replace defaults with throwing validator exceptions
+        val project = Project(http.param("id")?.toIntOrNull() ?: -1).apply {
+            name = http.param("name") ?: ""
+            node = http.param("node") ?: ""
+            description = http.param("description") ?: ""
+            ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
+            repoUrl = http.param("repoUrl") ?: ""
+            owner = (http.param("owner")?.toIntOrNull() ?: -1).let {
+                if (it < 0) null else dao.findUser(it)
+            }
+            // intentional defaults
+            if (node.isBlank()) node = name
+            // sanitizing
+            node = sanitizeNode(node)
+        }
+
+        if (project.id < 0) {
+            dao.insertProject(project)
+        } else {
+            dao.updateProject(project)
+        }
+
+        http.renderCommit("projects/${project.node}")
+    }
+
+    fun versions(http: HttpRequest, dao: DataAccessObject) {
+        val projectInfo = obtainProjectInfo(http, dao)
+        if (projectInfo == null) {
+            http.response.sendError(404)
+            return
+        }
+
+        with(http) {
+            view = VersionsView(
+                projectInfo,
+                dao.listVersionSummaries(projectInfo.project)
+            )
+            navigationMenu = activeProjectNavMenu(
+                dao.listProjects(),
+                projectInfo
+            )
+            styleSheets = listOf("projects")
+            render("versions")
+        }
+    }
+
+    fun versionForm(http: HttpRequest, dao: DataAccessObject) {
+        val projectInfo = obtainProjectInfo(http, dao)
+        if (projectInfo == null) {
+            http.response.sendError(404)
+            return
+        }
+
+        val version: Version
+        when (val result = http.lookupPathParam("version", projectInfo.versions)) {
+            is LookupResult.NotFound -> {
+                http.response.sendError(404)
+                return
+            }
+            is LookupResult.Found -> {
+                version = result.elem ?: Version(-1, projectInfo.project.id)
+            }
+        }
+
+        with(http) {
+            view = VersionEditView(projectInfo, version)
+            navigationMenu = activeProjectNavMenu(
+                dao.listProjects(),
+                projectInfo,
+                selectedVersion = version
+            )
+            styleSheets = listOf("projects")
+            render("version-form")
+        }
+    }
+
+    fun versionCommit(http: HttpRequest, dao: DataAccessObject) {
+        val id = http.param("id")?.toIntOrNull()
+        val projectid = http.param("projectid")?.toIntOrNull() ?: -1
+        val project = dao.findProject(projectid)
+        if (id == null || project == null) {
+            http.response.sendError(400)
+            return
+        }
+
+        // TODO: replace defaults with throwing validator exceptions
+        val version = Version(id, projectid).apply {
+            name = http.param("name") ?: ""
+            node = http.param("node") ?: ""
+            ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
+            status = http.param("status")?.let(VersionStatus::valueOf) ?: VersionStatus.Future
+            // intentional defaults
+            if (node.isBlank()) node = name
+            // sanitizing
+            node = sanitizeNode(node)
+        }
+
+        if (id < 0) {
+            dao.insertVersion(version)
+        } else {
+            dao.updateVersion(version)
+        }
+
+        http.renderCommit("projects/${project.node}/versions/")
+    }
+
+    fun components(http: HttpRequest, dao: DataAccessObject) {
+        val projectInfo = obtainProjectInfo(http, dao)
+        if (projectInfo == null) {
+            http.response.sendError(404)
+            return
+        }
+
+        with(http) {
+            view = ComponentsView(
+                projectInfo,
+                dao.listComponentSummaries(projectInfo.project)
+            )
+            navigationMenu = activeProjectNavMenu(
+                dao.listProjects(),
+                projectInfo
+            )
+            styleSheets = listOf("projects")
+            render("components")
+        }
+    }
+
+    fun componentForm(http: HttpRequest, dao: DataAccessObject) {
+        val projectInfo = obtainProjectInfo(http, dao)
+        if (projectInfo == null) {
+            http.response.sendError(404)
+            return
+        }
+
+        val component: Component
+        when (val result = http.lookupPathParam("component", projectInfo.components)) {
+            is LookupResult.NotFound -> {
+                http.response.sendError(404)
+                return
+            }
+            is LookupResult.Found -> {
+                component = result.elem ?: Component(-1, projectInfo.project.id)
+            }
+        }
+
+        with(http) {
+            view = ComponentEditView(projectInfo, component, dao.listUsers())
+            navigationMenu = activeProjectNavMenu(
+                dao.listProjects(),
+                projectInfo,
+                selectedComponent = component
+            )
+            styleSheets = listOf("projects")
+            render("component-form")
+        }
+    }
+
+    fun componentCommit(http: HttpRequest, dao: DataAccessObject) {
+        val id = http.param("id")?.toIntOrNull()
+        val projectid = http.param("projectid")?.toIntOrNull() ?: -1
+        val project = dao.findProject(projectid)
+        if (id == null || project == null) {
+            http.response.sendError(400)
+            return
+        }
+
+        // TODO: replace defaults with throwing validator exceptions
+        val component = Component(id, projectid).apply {
+            name = http.param("name") ?: ""
+            node = http.param("node") ?: ""
+            ordinal = http.param("ordinal")?.toIntOrNull() ?: 0
+            color = WebColor(http.param("color") ?: "#000000")
+            description = http.param("description")
+            lead = (http.param("lead")?.toIntOrNull() ?: -1).let {
+                if (it < 0) null else dao.findUser(it)
+            }
+            // intentional defaults
+            if (node.isBlank()) node = name
+            // sanitizing
+            node = sanitizeNode(node)
+        }
+
+        if (id < 0) {
+            dao.insertComponent(component)
+        } else {
+            dao.updateComponent(component)
+        }
+
+        http.renderCommit("projects/${project.node}/components/")
+    }
+
+    fun issue(http: HttpRequest, dao: DataAccessObject) {
+        withPathInfo(http, dao)?.run {
+            val issue = dao.findIssue(http.pathParams["issue"]?.toIntOrNull() ?: -1)
+            if (issue == null) {
+                http.response.sendError(404)
+                return
+            }
+
+            val comments = dao.listComments(issue)
+
+            with(http) {
+                view = IssueDetailView(issue, comments, projectInfo.project, version, component)
+                navigationMenu = activeProjectNavMenu(
+                    dao.listProjects(),
+                    projectInfo,
+                    version,
+                    component
+                )
+                styleSheets = listOf("projects")
+                render("issue-view")
+            }
+        }
+    }
+
+    fun issueForm(http: HttpRequest, dao: DataAccessObject) {
+        withPathInfo(http, dao)?.run {
+            val issue = dao.findIssue(http.pathParams["issue"]?.toIntOrNull() ?: -1) ?: Issue(
+                -1,
+                projectInfo.project,
+            )
+
+            // pre-select component, if available in the path info
+            issue.component = component
+
+            with(http) {
+                view = IssueEditView(
+                    issue,
+                    projectInfo.versions,
+                    projectInfo.components,
+                    dao.listUsers(),
+                    projectInfo.project,
+                    version,
+                    component
+                )
+                navigationMenu = activeProjectNavMenu(
+                    dao.listProjects(),
+                    projectInfo,
+                    version,
+                    component
+                )
+                styleSheets = listOf("projects")
+                render("issue-form")
+            }
+        }
+    }
+
+    fun issueComment(http: HttpRequest, dao: DataAccessObject) {
+        withPathInfo(http, dao)?.run {
+            val issue = dao.findIssue(http.pathParams["issue"]?.toIntOrNull() ?: -1)
+            if (issue == null) {
+                http.response.sendError(404)
+                return
+            }
+
+            // TODO: throw validator exception instead of using a default
+            val comment = IssueComment(-1, issue.id).apply {
+                author = http.remoteUser?.let { dao.findUserByName(it) }
+                comment = http.param("comment") ?: ""
+            }
+
+            dao.insertComment(comment)
+
+            http.renderCommit("${issuesHref}${issue.id}")
+        }
+    }
+
+    fun issueCommit(http: HttpRequest, dao: DataAccessObject) {
+        withPathInfo(http, dao)?.run {
+            // TODO: throw validator exception instead of using defaults
+            val issue = Issue(
+                http.param("id")?.toIntOrNull() ?: -1,
+                projectInfo.project
+            ).apply {
+                component = dao.findComponent(http.param("component")?.toIntOrNull() ?: -1)
+                category = IssueCategory.valueOf(http.param("category") ?: "")
+                status = IssueStatus.valueOf(http.param("status") ?: "")
+                subject = http.param("subject") ?: ""
+                description = http.param("description") ?: ""
+                assignee = http.param("assignee")?.toIntOrNull()?.let {
+                    when (it) {
+                        -1 -> null
+                        -2 -> component?.lead
+                        else -> dao.findUser(it)
+                    }
+                }
+                eta = http.param("eta")?.let { Date.valueOf(it) }
+
+                affectedVersions = http.paramArray("affected")
+                    .mapNotNull { param -> param.toIntOrNull()?.let { Version(it, projectInfo.project.id) } }
+                resolvedVersions = http.paramArray("resolved")
+                    .mapNotNull { param -> param.toIntOrNull()?.let { Version(it, projectInfo.project.id) } }
+            }
+
+            http.renderCommit("${issuesHref}${issue.id}")
+        }
+    }
+}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/servlet/UsersServlet.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,112 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.servlet
+
+import de.uapcore.lightpit.AbstractServlet
+import de.uapcore.lightpit.HttpRequest
+import de.uapcore.lightpit.LoggingTrait
+import de.uapcore.lightpit.dao.DataAccessObject
+import de.uapcore.lightpit.entities.User
+import de.uapcore.lightpit.logger
+import de.uapcore.lightpit.viewmodel.UserEditView
+import de.uapcore.lightpit.viewmodel.UsersView
+import javax.servlet.annotation.WebServlet
+
+@WebServlet(urlPatterns = ["/users/*"])
+class UsersServlet : AbstractServlet(), LoggingTrait {
+
+    init {
+        get("/", this::index)
+        get("/-/create", this::create)
+        get("/%userid/edit", this::edit)
+        post("/-/commit", this::commit)
+    }
+
+    private val list = "users"
+    private val form = "user-form"
+
+    fun index(http: HttpRequest, dao: DataAccessObject) {
+        with(http) {
+            view = UsersView(dao.listUsers())
+            render(list)
+        }
+    }
+
+    fun create(http: HttpRequest, dao: DataAccessObject) {
+        with(http) {
+            view = UserEditView(User(-1))
+            render(form)
+        }
+    }
+
+    fun edit(http: HttpRequest, dao: DataAccessObject) {
+        val id = http.pathParams["userid"]?.toIntOrNull()
+        if (id == null) {
+            http.response.sendError(404)
+        } else {
+            val user = dao.findUser(id)
+            if (user == null) {
+                http.response.sendError(404)
+            } else {
+                with(http) {
+                    view = UserEditView(user)
+                    render(form)
+                }
+            }
+        }
+    }
+
+    fun commit(http: HttpRequest, dao: DataAccessObject) {
+        val id = http.param("userid")?.toIntOrNull()
+        if (id == null) {
+            http.response.sendError(400)
+            return
+        }
+
+        val user = User(id)
+        with(user) {
+            username = http.param("username") ?: ""
+            givenname = http.param("givenname")
+            lastname = http.param("lastname")
+            mail = http.param("mail")
+        }
+
+        if (dao.findUserByName(user.username) != null) {
+            with(http) {
+                view = UserEditView(user).apply { errorText = "validation.username.unique" }
+            }
+        }
+
+        if (user.id > 0) {
+            logger().info("Update user ${user.username} with id ${user.id}.")
+            dao.updateUser(user)
+        } else {
+            logger().info("Insert user ${user.username}.")
+            dao.insertUser(user)
+        }
+        http.renderCommit("users/")
+    }
+}
\ No newline at end of file
--- a/src/main/kotlin/de/uapcore/lightpit/types/WebColor.kt	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/kotlin/de/uapcore/lightpit/types/WebColor.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -27,7 +27,7 @@
 
 
 /**
- * Represents a web color in hexadezimal representation.
+ * Represents a web color in hexadecimal representation.
  * @param arg the 6 digits hex string optionally preceded by a hash symbol
  */
 class WebColor(arg: String) {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/util/Filter.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.util
+
+sealed class Filter<T>
+class AllFilter<T> : Filter<T>()
+class NoneFilter<T> : Filter<T>()
+data class SpecificFilter<T>(val obj: T) : Filter<T>()
+data class RangeFilter<T>(val lower: T, val upper: T) : Filter<T>() where T : Comparable<T>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/util/Issues.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.util
+
+import de.uapcore.lightpit.entities.Component
+import de.uapcore.lightpit.entities.Issue
+import de.uapcore.lightpit.entities.Project
+import de.uapcore.lightpit.entities.Version
+import de.uapcore.lightpit.types.IssueStatusPhase
+
+data class IssueFilter(
+    val project: Filter<Project> = AllFilter(),
+    val version: Filter<Version> = AllFilter(),
+    val component: Filter<Component> = AllFilter()
+)
+
+data class IssueSorter(val criteria: List<Criteria>) : Comparator<Issue> {
+    enum class Field {
+        DONE, ETA, UPDATED
+    }
+
+    data class Criteria(val field: Field, val asc: Boolean)
+
+    override fun compare(left: Issue, right: Issue): Int {
+        if (left == right) {
+            return 0;
+        }
+        for (c in criteria) {
+            val result = when (c.field) {
+                Field.DONE -> (left.status.phase == IssueStatusPhase.Done).compareTo(right.status.phase == IssueStatusPhase.Done)
+                Field.ETA -> {
+                    val l = left.eta
+                    val r = right.eta
+                    if (l == null && r == null) 0
+                    else if (l == null) 1
+                    else if (r == null) -1
+                    else l.compareTo(r)
+                }
+                Field.UPDATED -> left.updated.compareTo(right.updated)
+            }
+            if (result != 0) {
+                return if (c.asc) result else -result
+            }
+        }
+        return 0
+    }
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Components.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.viewmodel
+
+import de.uapcore.lightpit.entities.Component
+import de.uapcore.lightpit.entities.User
+
+class ComponentSummary(
+    val component: Component,
+) {
+    val issueSummary = IssueSummary()
+}
+
+class ComponentsView(
+    val projectInfo: ProjectInfo,
+    val componentInfos: List<ComponentSummary>
+) : View()
+
+class ComponentEditView(
+    val projectInfo: ProjectInfo,
+    val component: Component,
+    val users: List<User>
+) : EditView()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Issues.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.viewmodel
+
+import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
+import com.vladsch.flexmark.ext.tables.TablesExtension
+import com.vladsch.flexmark.html.HtmlRenderer
+import com.vladsch.flexmark.parser.Parser
+import com.vladsch.flexmark.util.data.MutableDataSet
+import de.uapcore.lightpit.entities.*
+import de.uapcore.lightpit.types.IssueCategory
+import de.uapcore.lightpit.types.IssueStatus
+import de.uapcore.lightpit.types.IssueStatusPhase
+import de.uapcore.lightpit.types.VersionStatus
+import kotlin.math.roundToInt
+
+class IssueSummary {
+    var open = 0
+    var active = 0
+    var done = 0
+
+    val total get() = open + active + done
+
+    val openPercent get() = 100 - activePercent - donePercent
+    val activePercent get() = if (total > 0) (100f * active / total).roundToInt() else 0
+    val donePercent get() = if (total > 0) (100f * done / total).roundToInt() else 100
+
+    /**
+     * Adds the specified issue to the summary by incrementing the respective counter.
+     * @param issue the issue
+     */
+    fun add(issue: Issue) {
+        when (issue.status.phase) {
+            IssueStatusPhase.Open -> open++
+            IssueStatusPhase.WorkInProgress -> active++
+            IssueStatusPhase.Done -> done++
+        }
+    }
+}
+
+class IssueDetailView(
+    val issue: Issue,
+    val comments: List<IssueComment>,
+    val project: Project,
+    val version: Version? = null,
+    val component: Component? = null
+) : View() {
+
+    init {
+        val options = MutableDataSet()
+            .set(Parser.EXTENSIONS, listOf(TablesExtension.create(), StrikethroughExtension.create()))
+        val parser = Parser.builder(options).build()
+        val renderer = HtmlRenderer.builder(options).build()
+        val process = fun(it: String) = renderer.render(parser.parse(it))
+
+        issue.description = process(issue.description)
+        for (comment in comments) {
+            comment.comment = process(comment.comment)
+        }
+    }
+}
+
+class IssueEditView(
+    val issue: Issue,
+    val versions: List<Version>,
+    val components: List<Component>,
+    val users: List<User>,
+    val project: Project, // TODO: allow null values to create issues from the IssuesServlet
+    val version: Version? = null,
+    val component: Component? = null
+) : EditView() {
+
+    val versionsUpcoming: List<Version>
+    val versionsRecent: List<Version>
+
+    val issueStatus = IssueStatus.values()
+    val issueCategory = IssueCategory.values()
+
+    init {
+        val recent = mutableListOf<Version>()
+        val upcoming = mutableListOf<Version>()
+        recent.addAll(issue.affectedVersions)
+        upcoming.addAll(issue.resolvedVersions)
+        for (v in versions) {
+            if (v.status.isReleased) {
+                if (v.status != VersionStatus.Deprecated) recent.add(v)
+            } else {
+                upcoming.add(v)
+            }
+        }
+        versionsRecent = recent
+        versionsUpcoming = upcoming
+    }
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/LanguageView.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.viewmodel
+
+import java.util.*
+
+class LanguageView(
+    val languages: List<Locale>,
+    val currentLanguage: Locale,
+    val browserLanguage: Locale
+) : View()
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/NavMenus.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,128 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.viewmodel
+
+import de.uapcore.lightpit.entities.Component
+import de.uapcore.lightpit.entities.Project
+import de.uapcore.lightpit.entities.Version
+
+class NavMenuEntry(
+    val level: Int,
+    val caption: String,
+    val href: String,
+    val title: String = "",
+    val active: Boolean = false,
+    val resolveCaption: Boolean = false,
+    val iconColor: String? = null
+) {
+    val iconUseCssClass = iconColor != null && !iconColor.startsWith("#")
+}
+
+class NavMenu(val entries: List<NavMenuEntry>)
+
+fun projectNavMenu(
+    projects: List<Project>,
+    versions: List<Version> = emptyList(),
+    components: List<Component> = emptyList(),
+    selectedProject: Project? = null,
+    selectedVersion: Version? = null,
+    selectedComponent: Component? = null
+) = NavMenu(
+    sequence {
+        val cnode = selectedComponent?.node ?: "-"
+        val vnode = selectedVersion?.node ?: "-"
+        for (project in projects) {
+            val active = project == selectedProject
+            yield(
+                NavMenuEntry(
+                    level = 0,
+                    caption = project.name,
+                    href = "projects/${project.node}",
+                    active = active
+                )
+            )
+            if (active) {
+                yield(
+                    NavMenuEntry(
+                        level = 1,
+                        caption = "navmenu.versions",
+                        resolveCaption = true,
+                        href = "projects/${project.node}/versions/"
+                    )
+                )
+                yield(
+                    NavMenuEntry(
+                        level = 2,
+                        caption = "navmenu.all",
+                        resolveCaption = true,
+                        href = "projects/${project.node}/issues/-/${cnode}/",
+                        iconColor = "#000000"
+                    )
+                )
+                for (version in versions) {
+                    yield(
+                        NavMenuEntry(
+                            level = 2,
+                            caption = version.name,
+                            title = "version.status.${version.status}",
+                            href = "projects/${project.node}/issues/${version.node}/${cnode}/",
+                            iconColor = "version-${version.status}",
+                            active = version == selectedVersion
+                        )
+                    )
+                }
+                yield(
+                    NavMenuEntry(
+                        level = 1,
+                        caption = "navmenu.components",
+                        resolveCaption = true,
+                        href = "projects/${project.node}/components/"
+                    )
+                )
+                yield(
+                    NavMenuEntry(
+                        level = 2,
+                        caption = "navmenu.all",
+                        resolveCaption = true,
+                        href = "projects/${project.node}/issues/${vnode}/-/",
+                        iconColor = "#000000"
+                    )
+                )
+                for (component in components) {
+                    yield(
+                        NavMenuEntry(
+                            level = 2,
+                            caption = component.name,
+                            href = "projects/${project.node}/issues/${vnode}/${component.node}/",
+                            iconColor = "${component.color}",
+                            active = component == selectedComponent
+                        )
+                    )
+                }
+            }
+        }
+    }.toList()
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Projects.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.viewmodel
+
+import de.uapcore.lightpit.entities.*
+import de.uapcore.lightpit.types.VersionStatus
+
+class ProjectInfo(
+    val project: Project,
+    /**
+     * List of versions, sorted by status descending.
+     */
+    var versions: List<Version>,
+    var components: List<Component>,
+    var issueSummary: IssueSummary
+) {
+    val latestVersion = versions.firstOrNull { it.status == VersionStatus.Released }
+    val nextVersion = versions.findLast { !it.status.isReleased }
+}
+
+class ProjectsView(
+    val projects: List<ProjectInfo>
+) : View()
+
+class ProjectDetails(
+    val projectInfo: ProjectInfo,
+    val issues: List<Issue>,
+    val version: Version? = null,
+    val component: Component? = null
+) : View() {
+    val issueSummary = IssueSummary()
+    val versionInfo: VersionInfo?
+
+    init {
+        issues.forEach(issueSummary::add)
+        versionInfo = version?.let { VersionInfo(it, issues) }
+    }
+}
+
+class ProjectEditView(
+    val project: Project,
+    val users: List<User>
+) : EditView()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Users.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.viewmodel
+
+import de.uapcore.lightpit.entities.User
+
+class UsersView(
+    val users: List<User>
+) : View()
+
+class UserEditView(
+    val user: User
+) : EditView()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/Versions.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.viewmodel
+
+import de.uapcore.lightpit.entities.Issue
+import de.uapcore.lightpit.entities.Version
+import de.uapcore.lightpit.types.VersionStatus
+
+class VersionInfo(
+    val version: Version,
+    val issues: List<Issue>
+) {
+    val reportedTotal = IssueSummary()
+    val resolvedTotal = IssueSummary()
+    val reported: List<Issue>
+    val resolved: List<Issue>
+
+    init {
+        val reported = mutableListOf<Issue>()
+        val resolved = mutableListOf<Issue>()
+        for (issue in issues) {
+            if (issue.affectedVersions.contains(version)) {
+                reportedTotal.add(issue)
+                reported.add(issue)
+            }
+            if (issue.resolvedVersions.contains(version)) {
+                resolvedTotal.add(issue)
+                resolved.add(issue)
+            }
+        }
+        this.reported = reported
+        this.resolved = resolved
+    }
+}
+
+class VersionSummary(
+    val version: Version
+) {
+    val reportedTotal = IssueSummary()
+    val resolvedTotal = IssueSummary()
+}
+
+class VersionsView(
+    val projectInfo: ProjectInfo,
+    val versionInfos: List<VersionSummary>
+) : View()
+
+class VersionEditView(
+    val projectInfo: ProjectInfo,
+    val version: Version
+) : EditView() {
+    val versionStatus = VersionStatus.values()
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/kotlin/de/uapcore/lightpit/viewmodel/View.kt	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2021 Mike Becker. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ *
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+ * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+ * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+ * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+ * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+ * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package de.uapcore.lightpit.viewmodel
+
+abstract class View
+abstract class EditView : View() {
+    var errorText: String? = null
+}
--- a/src/main/resources/localization/strings.properties	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/resources/localization/strings.properties	Fri Apr 02 11:59:14 2021 +0200
@@ -33,6 +33,7 @@
 button.language.submit = Switch language
 button.okay=OK
 button.project.create=New Project
+button.project.edit=Edit Project
 button.user.create=Add Developer
 button.version.create=New Version
 button.version.edit=Edit Version
@@ -80,7 +81,6 @@
 issue.status=Status
 issue.subject=Subject
 issue.updated=Updated
-issue.without-version=No Assigned Version
 issues.active=In Progress
 issues.done=Done
 issues.open=Open
@@ -94,7 +94,6 @@
 menu.users=Developer
 navmenu.all=all
 navmenu.components=Components
-navmenu.unassigned=unassigned
 navmenu.versions=Versions
 no-projects=Welcome to LightPIT. Start off by creating a new project!
 no-users=No developers have been configured yet.
@@ -118,6 +117,7 @@
 user.lastname=Last Name
 user.mail=E-Mail
 username=User Name
+validation.username.unique=Username is already taken.
 version.latest=Latest Version
 version.next=Next Version
 version.status.Deprecated=Deprecated
@@ -126,4 +126,4 @@
 version.status.Released=Released
 version.status.Unreleased=Unreleased
 version.status=Status
-version=Version
+version=Version
\ No newline at end of file
--- a/src/main/resources/localization/strings_de.properties	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/resources/localization/strings_de.properties	Fri Apr 02 11:59:14 2021 +0200
@@ -33,6 +33,7 @@
 button.language.submit = Sprache ausw\u00e4hlen
 button.okay=OK
 button.project.create=Neues Projekt
+button.project.edit=Projekt Bearbeiten
 button.user.create=Neuer Entwickler
 button.version.create=Neue Version
 button.version.edit=Version Bearbeiten
@@ -80,7 +81,6 @@
 issue.status=Status
 issue.subject=Thema
 issue.updated=Aktualisiert
-issue.without-version=Keine Version zugeordnet
 issues.active=In Arbeit
 issues.done=Erledigt
 issues.open=Offen
@@ -94,7 +94,6 @@
 menu.users=Entwickler
 navmenu.all=Alle
 navmenu.components=Komponenten
-navmenu.unassigned=Nicht Zugewiesen
 navmenu.versions=Versionen
 no-projects=Wilkommen bei LightPIT. Beginnen Sie mit der Erstellung eines Projektes!
 no-users=Bislang wurden keine Entwickler hinterlegt.
@@ -118,6 +117,7 @@
 user.lastname=Nachname
 user.mail=E-Mail
 username=Benutzername
+validation.username.unique=Der Benutzername wird bereits verwendet.
 version.latest=Neuste Version
 version.next=N\u00e4chste Version
 version.status.Deprecated=Veraltet
--- a/src/main/webapp/WEB-INF/jsp/component-form.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/component-form.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -32,7 +32,7 @@
 <c:set var="component" scope="page" value="${viewmodel.component}"/>
 <c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/>
 
-<form action="./projects/commit-component" method="post">
+<form action="./projects/${project.node}/components/-/commit" method="post">
     <table class="formtable" style="width: 70ch">
         <colgroup>
             <col>
@@ -43,7 +43,7 @@
             <th><fmt:message key="project"/></th>
             <td>
                 <c:out value="${project.name}" />
-                <input type="hidden" name="pid" value="${project.id}" />
+                <input type="hidden" name="projectid" value="${project.id}" />
             </td>
         </tr>
         <tr>
--- a/src/main/webapp/WEB-INF/jsp/components.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/components.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -35,8 +35,8 @@
 <%@include file="../jspf/project-header.jspf"%>
 
 <div id="tool-area">
-    <a href="./projects/${project.node}/create-component" class="button"><fmt:message key="button.component.create"/></a>
-    <a href="./projects/${project.node}/create-issue" class="button"><fmt:message key="button.issue.create"/></a>
+    <a href="./projects/${project.node}/components/-/create" class="button"><fmt:message key="button.component.create"/></a>
+    <a href="./projects/${project.node}/issues/-/-/-/create" class="button"><fmt:message key="button.issue.create"/></a>
 </div>
 
 <h2><fmt:message key="progress" /></h2>
@@ -75,7 +75,7 @@
             <td rowspan="2" style="width: 2em;"><a href="./projects/${project.node}/components/${componentInfo.component.node}/edit">&#x270e;</a></td>
             <td rowspan="2">
                 <div class="navmenu-icon" style="background-color: ${componentInfo.component.color}"></div>
-                <a href="./projects/${project.node}/${componentInfo.component.node}/all-versions/issues/">
+                <a href="./projects/${project.node}/issues/-/${componentInfo.component.node}/">
                     <c:out value="${componentInfo.component.name}"/>
                 </a>
             </td>
--- a/src/main/webapp/WEB-INF/jsp/error.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/error.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -26,14 +26,13 @@
 --%>
 <%@page pageEncoding="UTF-8" %>
 <%@page import="de.uapcore.lightpit.Constants" %>
-<%@page import="de.uapcore.lightpit.modules.ErrorModule" %>
 <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
 <%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
 <%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
 
 <c:set scope="page" var="baseHref" value="${requestScope[Constants.REQ_ATTR_BASE_HREF]}"/>
 <c:set scope="page" var="errorCode" value="${requestScope['javax.servlet.error.status_code']}"/>
-<c:set scope="page" var="returnLink" value="${requestScope[ErrorModule.REQ_ATTR_RETURN_LINK]}"/>
+<c:set scope="page" var="returnLink" value="${requestScope[Constants.REQ_ATTR_REFERER]}"/>
 
 <div id="error-page">
     <h1><fmt:message key="error.headline"/></h1>
--- a/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/issue-form.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -29,10 +29,15 @@
 <%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
 
 <jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.IssueEditView" scope="request"/>
-<c:set var="issue" scope="page" value="${viewmodel.issue}" />
 
-<%-- TODO: change to ./issues/commit --%>
-<form action="./projects/commit-issue" method="post">
+<c:set var="issue" scope="page" value="${viewmodel.issue}" />
+<c:set var="project" scope="page" value="${viewmodel.project}"/>
+<c:set var="component" scope="page" value="${viewmodel.component}"/>
+<c:set var="version" scope="page" value="${viewmodel.version}"/>
+
+<c:set var="issuesHref" value="./projects/${project.node}/issues/${empty version ? '-' : version.node }/${empty component ? '-' : component.node}/"/>
+
+<form action="${issuesHref}-/commit-issue" method="post">
     <table class="formtable fullwidth">
         <colgroup>
             <col>
@@ -49,12 +54,12 @@
             <th><fmt:message key="project"/></th>
             <td>
                 <c:choose>
-                    <c:when test="${not empty issue.project}">
+                    <c:when test="${issue.project.id ge 0}">
                         <c:out value="${issue.project.name}" />
-                        <input type="hidden" name="pid" value="${issue.project.id}" />
+                        <input type="hidden" name="project" value="${issue.project.id}" />
                     </c:when>
                     <c:otherwise>
-                        <select name="pid" required>
+                        <select name="project" required>
                             <c:forEach var="project" items="${viewmodel.projects}">
                                 <option value="${project.id}">
                                     <c:out value="${project.name}" />
@@ -179,13 +184,12 @@
                 <label for="create-another"><fmt:message key="button.issue.create.another"/> </label>
                 <input type="hidden" name="id" value="${issue.id}"/>
                 <c:if test="${issue.id ge 0}">
-                <a href="./projects/${issue.project.node}/issues/${issue.id}/view" class="button">
+                <a href="${issuesHref}${issue.id}" class="button">
                     <fmt:message key="button.cancel"/>
                 </a>
                 </c:if>
                 <c:if test="${issue.id lt 0}">
-                    <%-- TODO: fix #14 --%>
-                    <a href="./projects/${issue.project.node}/all-components/all-versions/issues/" class="button">
+                    <a href="${issuesHref}" class="button">
                         <fmt:message key="button.cancel"/>
                     </a>
                 </c:if>
--- a/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/issue-view.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -29,8 +29,14 @@
 <%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
 
 <jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.IssueDetailView" scope="request"/>
+
+<c:set var="project" scope="page" value="${viewmodel.project}"/>
+<c:set var="component" scope="page" value="${viewmodel.component}"/>
+<c:set var="version" scope="page" value="${viewmodel.version}"/>
 <c:set var="issue" scope="page" value="${viewmodel.issue}" />
 
+<c:set var="issuesHref" scope="page" value="./projects/${project.node}/issues/${empty version ? '-' : version.node }/${empty component ? '-' : component.node}/"/>
+
 <table class="formtable fullwidth">
     <colgroup>
         <col>
@@ -137,11 +143,10 @@
     <tfoot>
     <tr>
         <td colspan="2">
-            <%-- TODO: fix #14 --%>
-            <a href="./projects/${issue.project.node}/all-components/all-versions/issues/" class="button">
+            <a href="${issuesHref}" class="button">
                 <fmt:message key="button.cancel"/>
             </a>
-            <a href="./projects/${issue.project.node}/issues/${issue.id}/edit" class="button submit">
+            <a href="${issuesHref}${issue.id}/edit" class="button submit">
                 <fmt:message key="button.issue.edit"/>
             </a>
         </td>
@@ -152,7 +157,7 @@
 <hr class="comments-separator"/>
 <h2><fmt:message key="issue.comments"/></h2>
 <c:if test="${viewmodel.issue.id ge 0}">
-<form id="comment-form" action="./projects/commit-issue-comment" method="post">
+<form id="comment-form" action="${issuesHref}${issue.id}/comment" method="post">
     <table class="formtable fullwidth">
         <tbody>
             <tr>
@@ -162,7 +167,6 @@
         <tfoot>
             <tr>
                 <td>
-                    <input type="hidden" name="issueid" value="${issue.id}"/>
                     <button type="submit"><fmt:message key="button.comment"/></button>
                 </td>
             </tr>
--- a/src/main/webapp/WEB-INF/jsp/project-details.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/project-details.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -24,37 +24,28 @@
 OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 --%>
-<%@page pageEncoding="UTF-8" import="de.uapcore.lightpit.viewmodel.ProjectView" %>
 <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
 <%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
 <%@taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>
 
-<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.ProjectDetailsView" scope="request" />
+<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.ProjectDetails" scope="request" />
 
 <c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/>
-<c:set var="component" scope="page" value="${viewmodel.componentFilter}"/>
+<c:set var="component" scope="page" value="${viewmodel.component}"/>
+<c:set var="version" scope="page" value="${viewmodel.version}"/>
 <%@include file="../jspf/project-header.jspf"%>
 
 <div id="tool-area">
-    <c:remove var="createIssueParams"/>
-    <c:if test="${viewmodel.versionFilter.id gt 0}">
-        <c:set var="createIssueParams">&vid=${viewmodel.versionFilter.id}</c:set>
-    </c:if>
-    <c:if test="${viewmodel.componentFilter.id gt 0}">
-        <c:set var="createIssueParams">${createIssueParams}&cid=${viewmodel.componentFilter.id}</c:set>
-    </c:if>
-    <c:if test="${not empty createIssueParams}">
-        <c:set var="createIssueParams">?${fn:substringAfter(createIssueParams, "&")}</c:set>
+    <a href="./projects/${project.node}/issues/${empty version ? '-' : version.node}/${empty component ? '-' : component.node}/-/create" class="button"><fmt:message key="button.issue.create"/></a>
+    <a href="./projects/${project.node}/edit" class="button"><fmt:message key="button.project.edit"/></a>
+    <c:if test="${not empty version}">
+        <a href="./projects/${project.node}/versions/${version.node}/edit" class="button"><fmt:message key="button.version.edit"/></a>
     </c:if>
-    <a href="./projects/${project.node}/create-issue${createIssueParams}" class="button"><fmt:message key="button.issue.create"/></a>
-    <c:if test="${viewmodel.versionFilter.id gt 0}">
-        <a href="./projects/${project.node}/versions/${viewmodel.versionFilter.node}/edit" class="button"><fmt:message key="button.version.edit"/></a>
+    <a href="./projects/${project.node}/versions/-/create" class="button"><fmt:message key="button.version.create"/></a>
+    <c:if test="${not empty component}">
+        <a href="./projects/${project.node}/components/${component.node}/edit" class="button"><fmt:message key="button.component.edit"/></a>
     </c:if>
-    <a href="./projects/${project.node}/create-version" class="button"><fmt:message key="button.version.create"/></a>
-    <c:if test="${viewmodel.componentFilter.id gt 0}">
-        <a href="./projects/${project.node}/components/${viewmodel.componentFilter.node}/edit" class="button"><fmt:message key="button.component.edit"/></a>
-    </c:if>
-    <a href="./projects/${project.node}/create-component" class="button"><fmt:message key="button.component.create"/></a>
+    <a href="./projects/${project.node}/components/-/create" class="button"><fmt:message key="button.component.create"/></a>
 </div>
 
 <h2><fmt:message key="progress" /></h2>
@@ -63,24 +54,19 @@
 <%@include file="../jspf/issue-summary.jspf"%>
 
 <c:choose>
-    <c:when test="${viewmodel.versionFilter eq ProjectView.NO_VERSION or viewmodel.versionFilter eq ProjectView.ALL_VERSIONS}">
+    <c:when test="${empty viewmodel.versionInfo}">
         <h2>
-            <c:if test="${viewmodel.versionFilter eq ProjectView.NO_VERSION}">
-                <fmt:message key="issue.without-version" />
-            </c:if>
-            <c:if test="${viewmodel.versionFilter ne ProjectView.NO_VERSION}">
-                <fmt:message key="issues" />
-            </c:if>
+            <fmt:message key="issues" />
         </h2>
-        <c:set var="summary" value="${viewmodel.projectDetails.issueSummary}"/>
-        <c:set var="issues" value="${viewmodel.projectDetails.issues}"/>
+        <c:set var="summary" value="${viewmodel.issueSummary}"/>
+        <c:set var="issues" value="${viewmodel.issues}"/>
         <%@include file="../jspf/issue-summary.jspf"%>
         <c:if test="${not empty issues}">
             <%@include file="../jspf/issue-list.jspf"%>
         </c:if>
     </c:when>
     <c:otherwise>
-        <c:set var="versionInfo" value="${viewmodel.projectDetails.versionInfo}"/>
+        <c:set var="versionInfo" value="${viewmodel.versionInfo}"/>
         <h2>
             <fmt:message key="version" /> <c:out value="${versionInfo.version.name}" /> - <fmt:message key="version.status.${versionInfo.version.status}"/>
         </h2>
--- a/src/main/webapp/WEB-INF/jsp/project-form.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/project-form.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -31,7 +31,7 @@
 <jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.ProjectEditView" scope="request" />
 <c:set var="project" scope="page" value="${viewmodel.project}"/>
 
-<form action="./projects/commit" method="post">
+<form action="./projects/-/commit" method="post">
     <table class="formtable">
         <colgroup>
             <col>
@@ -77,7 +77,7 @@
         <tfoot>
         <tr>
             <td colspan="2">
-                <input type="hidden" name="pid" value="${project.id}"/>
+                <input type="hidden" name="id" value="${project.id}"/>
                 <a href="./projects/" class="button">
                     <fmt:message key="button.cancel"/>
                 </a>
--- a/src/main/webapp/WEB-INF/jsp/project-navmenu.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,104 +0,0 @@
-<%--
-DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
-
-Copyright 2021 Mike Becker. All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-1. Redistributions of source code must retain the above copyright
-notice, this list of conditions and the following disclaimer.
-
-2. Redistributions in binary form must reproduce the above copyright
-notice, this list of conditions and the following disclaimer in the
-documentation and/or other materials provided with the distribution.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
-FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
-SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
-CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
-OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
---%>
-<%@page pageEncoding="UTF-8"
-        import="de.uapcore.lightpit.viewmodel.ProjectView"
-        import="de.uapcore.lightpit.types.VersionStatus"
-%>
-<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
-<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
-
-<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.ProjectView" scope="request"/>
-
-<c:forEach var="projectInfo" items="${viewmodel.projectList}">
-    <c:set var="isActive" value="${viewmodel.projectInfo.project eq projectInfo.project}" />
-    <div class="menuEntry level-0" <c:if test="${isActive}">data-active</c:if> >
-        <a href="projects/${projectInfo.project.node}/versions/">
-            <c:out value="${projectInfo.project.name}"/>
-        </a>
-    </div>
-    <c:if test="${isActive}">
-        <!-- VERSIONS -->
-        <c:set var="componentNode" value="${not empty viewmodel.componentFilter ? viewmodel.componentFilter.node : 'all-components'}"/>
-        <div class="menuEntry level-1" <c:if test="${viewmodel.selectedPage eq ProjectView.SELECTED_PAGE_VERSIONS}">data-active</c:if> >
-            <a href="projects/${projectInfo.project.node}/versions/">
-                <fmt:message key="navmenu.versions"/>
-            </a>
-        </div>
-        <div class="menuEntry level-2" <c:if test="${viewmodel.versionFilter eq ProjectView.ALL_VERSIONS}">data-active</c:if>>
-            <div class="navmenu-icon" style="background: black"></div>
-            <a href="projects/${projectInfo.project.node}/${componentNode}/all-versions/issues/">
-                <fmt:message key="navmenu.all" />
-            </a>
-        </div>
-        <div class="menuEntry level-2" <c:if test="${viewmodel.versionFilter eq ProjectView.NO_VERSION}">data-active</c:if>>
-            <div class="navmenu-icon" style="background: black"></div>
-            <a href="projects/${projectInfo.project.node}/${componentNode}/no-version/issues/">
-                <fmt:message key="navmenu.unassigned" />
-            </a>
-        </div>
-        <c:forEach var="version" items="${viewmodel.projectInfo.versions}">
-            <c:set var="isVersionActive" value="${viewmodel.versionFilter eq version}" />
-            <c:if test="${version.status ne VersionStatus.Deprecated or isVersionActive}">
-            <div class="menuEntry level-2" <c:if test="${isVersionActive}">data-active</c:if>
-                    title="<fmt:message key="version.status.${version.status}" />">
-                <div class="navmenu-icon version-${version.status}"></div>
-                <a href="projects/${projectInfo.project.node}/${componentNode}/${version.node}/issues/">
-                    <c:out value="${version.name}"/>
-                </a>
-            </div>
-            </c:if>
-        </c:forEach>
-        <!-- COMPONENTS -->
-        <c:set var="versionNode" value="${not empty viewmodel.versionFilter ? viewmodel.versionFilter.node : 'all-versions'}"/>
-        <div class="menuEntry level-1" <c:if test="${viewmodel.selectedPage eq ProjectView.SELECTED_PAGE_COMPONENTS}">data-active</c:if>>
-            <a href="projects/${projectInfo.project.node}/components/">
-                <fmt:message key="navmenu.components"/>
-            </a>
-        </div>
-        <div class="menuEntry level-2" <c:if test="${viewmodel.componentFilter eq ProjectView.ALL_COMPONENTS}">data-active</c:if>>
-            <div class="navmenu-icon" style="background: black"></div>
-            <a href="projects/${projectInfo.project.node}/all-components/${versionNode}/issues/">
-                <fmt:message key="navmenu.all" />
-            </a>
-        </div>
-        <div class="menuEntry level-2"  <c:if test="${viewmodel.componentFilter eq ProjectView.NO_COMPONENT}">data-active</c:if>>
-            <div class="navmenu-icon" style="background: black"></div>
-            <a href="projects/${projectInfo.project.node}/no-component/${versionNode}/issues/">
-                <fmt:message key="navmenu.unassigned" />
-            </a>
-        </div>
-        <c:forEach var="component" items="${viewmodel.projectInfo.components}">
-            <c:set var="isComponentActive" value="${viewmodel.componentFilter eq component}" />
-            <div class="menuEntry level-2" <c:if test="${isComponentActive}">data-active</c:if> >
-                <div class="navmenu-icon" style="background-color: ${component.color}"></div>
-                <a href="projects/${projectInfo.project.node}/${component.node}/${versionNode}/issues/">
-                    <c:out value="${component.name}"/>
-                </a>
-            </div>
-        </c:forEach>
-    </c:if>
-</c:forEach>
--- a/src/main/webapp/WEB-INF/jsp/projects.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/projects.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -28,19 +28,19 @@
 <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
 <%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
 
-<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.ProjectView" scope="request"/>
+<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.ProjectsView" scope="request"/>
 
-<c:if test="${empty viewmodel.projectList}">
+<c:if test="${empty viewmodel.projects}">
     <div class="info-box">
         <fmt:message key="no-projects"/>
     </div>
 </c:if>
 
 <div id="tool-area">
-    <a href="./projects/create" class="button"><fmt:message key="button.project.create"/></a>
+    <a href="./projects/-/create" class="button"><fmt:message key="button.project.create"/></a>
 </div>
 
-<c:if test="${not empty viewmodel.projectList}">
+<c:if test="${not empty viewmodel.projects}">
     <table id="project-list" class="datatable medskip">
         <colgroup>
             <col>
@@ -65,11 +65,11 @@
         </tr>
         </thead>
         <tbody>
-        <c:forEach var="projectInfo" items="${viewmodel.projectList}">
+        <c:forEach var="projectInfo" items="${viewmodel.projects}">
             <c:set var="project" scope="page" value="${projectInfo.project}"/>
             <tr class="nowrap">
                 <td style="width: 2em;"><a href="./projects/${project.node}/edit">&#x270e;</a></td>
-                <td><a href="./projects/${project.node}/versions/"><c:out value="${project.name}"/></a>
+                <td><a href="./projects/${project.node}"><c:out value="${project.name}"/></a>
                 </td>
                 <td>
                     <c:if test="${not empty project.repoUrl}">
@@ -79,12 +79,12 @@
                 </td>
                 <td class="hright">
                     <c:if test="${not empty projectInfo.latestVersion}">
-                        <a href="./projects/${project.node}/all-components/${projectInfo.latestVersion.node}/issues/"><c:out value="${projectInfo.latestVersion.name}"/></a>
+                        <a href="./projects/${project.node}/issues/${projectInfo.latestVersion.node}/-/"><c:out value="${projectInfo.latestVersion.name}"/></a>
                     </c:if>
                 </td>
                 <td class="hright">
                     <c:if test="${not empty projectInfo.nextVersion}">
-                        <a href="./projects/${project.node}/all-components/${projectInfo.nextVersion.node}/issues/"><c:out value="${projectInfo.nextVersion.name}"/></a>
+                        <a href="./projects/${project.node}/issues/${projectInfo.nextVersion.node}/-/"><c:out value="${projectInfo.nextVersion.name}"/></a>
                     </c:if>
                 </td>
                 <td class="hright">${projectInfo.issueSummary.open}</td>
--- a/src/main/webapp/WEB-INF/jsp/site.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/site.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -79,8 +79,8 @@
             <fmt:message key="menu.projects"/>
         </a>
     </div>
-    <div class="menuEntry" <c:if test="${fn:startsWith(requestPath, '/teams/')}">data-active</c:if> >
-        <a href="teams/">
+    <div class="menuEntry" <c:if test="${fn:startsWith(requestPath, '/users/')}">data-active</c:if> >
+        <a href="users/">
             <fmt:message key="menu.users"/>
         </a>
     </div>
@@ -93,7 +93,7 @@
 <div>
     <c:if test="${not empty navMenu}">
         <div id="sideMenu">
-            <c:import url="${navMenu}"/>
+            <%@include file="../jspf/navmenu.jspf"%>
         </div>
     </c:if>
     <div id="content-area" <c:if test="${not empty navMenu}">class="sidebar-spacing"</c:if>>
--- a/src/main/webapp/WEB-INF/jsp/user-form.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/user-form.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -28,10 +28,10 @@
 <%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
 <%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
 
-<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.UsersEditView" scope="request"/>
+<jsp:useBean id="viewmodel" type="de.uapcore.lightpit.viewmodel.UserEditView" scope="request"/>
 <c:set var="user" scope="page" value="${viewmodel.user}" />
 
-<form action="./teams/commit" method="post">
+<form action="./users/-/commit" method="post">
     <table class="formtable">
         <colgroup>
             <col>
@@ -57,10 +57,19 @@
         </tr>
         </tbody>
         <tfoot>
+        <c:if test="${not empty viewmodel.errorText}">
+        <tr>
+            <td colspan="2">
+                <div class="error-box">
+                    <fmt:message key="${viewmodel.errorText}"/>
+                </div>
+            </td>
+        </tr>
+        </c:if>
         <tr>
             <td colspan="2">
                 <input type="hidden" name="userid" value="${user.id}"/>
-                <a href="./teams/" class="button">
+                <a href="./users/" class="button">
                     <fmt:message key="button.cancel"/>
                 </a>
                 <button type="submit"><fmt:message key="button.okay"/></button>
--- a/src/main/webapp/WEB-INF/jsp/users.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/users.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -37,7 +37,7 @@
 </c:if>
 
 <div id="tool-area">
-    <a href="./teams/edit" class="button"><fmt:message key="button.user.create"/></a>
+    <a href="./users/-/create" class="button"><fmt:message key="button.user.create"/></a>
 </div>
 
 <c:if test="${not empty viewmodel.users}">
@@ -51,7 +51,7 @@
         <tbody>
         <c:forEach var="user" items="${viewmodel.users}">
             <tr>
-                <td><a href="./teams/edit?id=${user.id}">&#x270e;</a></td>
+                <td><a href="./users/${user.id}/edit">&#x270e;</a></td>
                 <td><c:out value="${user.displayname}"/></td>
             </tr>
         </c:forEach>
--- a/src/main/webapp/WEB-INF/jsp/version-form.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/version-form.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -32,7 +32,7 @@
 <c:set var="version" scope="page" value="${viewmodel.version}"/>
 <c:set var="project" scope="page" value="${viewmodel.projectInfo.project}"/>
 
-<form action="./projects/commit-version" method="post">
+<form action="./projects/${project.node}/versions/-/commit" method="post">
     <table class="formtable" style="width: 35ch">
         <colgroup>
             <col>
@@ -43,7 +43,7 @@
             <th><fmt:message key="project"/></th>
             <td>
                 <c:out value="${project.name}" />
-                <input type="hidden" name="pid" value="${project.id}" />
+                <input type="hidden" name="projectid" value="${project.id}" />
             </td>
         </tr>
         <tr>
--- a/src/main/webapp/WEB-INF/jsp/versions.jsp	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jsp/versions.jsp	Fri Apr 02 11:59:14 2021 +0200
@@ -34,8 +34,8 @@
 <%@include file="../jspf/project-header.jspf"%>
 
 <div id="tool-area">
-    <a href="./projects/${project.node}/create-version" class="button"><fmt:message key="button.version.create"/></a>
-    <a href="./projects/${project.node}/create-issue" class="button"><fmt:message key="button.issue.create"/></a>
+    <a href="./projects/${project.node}/versions/-/create" class="button"><fmt:message key="button.version.create"/></a>
+    <a href="./projects/${project.node}/issues/-/-/-/create" class="button"><fmt:message key="button.issue.create"/></a>
 </div>
 
 <h2><fmt:message key="progress" /></h2>
@@ -80,7 +80,7 @@
         <tr>
             <td rowspan="2" style="width: 2em;"><a href="./projects/${project.node}/versions/${versionInfo.version.node}/edit">&#x270e;</a></td>
             <td rowspan="2">
-                <a href="./projects/${project.node}/all-components/${versionInfo.version.node}/issues/">
+                <a href="./projects/${project.node}/issues/${versionInfo.version.node}/-/">
                     <c:out value="${versionInfo.version.name}"/>
                 </a>
                 <div class="version-tag version-${versionInfo.version.status}">
--- a/src/main/webapp/WEB-INF/jspf/issue-list.jspf	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jspf/issue-list.jspf	Fri Apr 02 11:59:14 2021 +0200
@@ -1,5 +1,7 @@
 <%--
 issues: List<Issue>
+version: Version?
+component: Component?
 --%>
 <table class="fullwidth datatable medskip">
     <colgroup>
@@ -17,7 +19,7 @@
         <tr>
             <td>
                 <span class="phase-${issue.status.phase.number}">
-                    <a href="./projects/${issue.project.node}/issues/${issue.id}/view">
+                    <a href="./projects/${issue.project.node}/issues/${empty version ? '-' : version.node }/${empty component ? '-' : component.node}/${issue.id}">
                         #${issue.id}&nbsp;-&nbsp;<c:out value="${issue.subject}" />
                     </a>
                 </span>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/WEB-INF/jspf/navmenu.jspf	Fri Apr 02 11:59:14 2021 +0200
@@ -0,0 +1,55 @@
+<%--
+DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
+
+Copyright 2021 Mike Becker. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+notice, this list of conditions and the following disclaimer in the
+documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+--%>
+<%@page pageEncoding="UTF-8" %>
+<%@taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
+<%@taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
+
+<jsp:useBean id="navMenu" type="de.uapcore.lightpit.viewmodel.NavMenu" scope="request" />
+
+<c:forEach var="entry" items="${navMenu.entries}">
+    <div class="menuEntry level-${entry.level}"
+        <c:if test="${entry.active}"> data-active </c:if>
+        <c:if test="${not empty entry.title}">title="<fmt:message key="${entry.title}"/>" </c:if>
+    >
+        <c:if test="${not empty entry.iconColor}">
+            <c:if test="${entry.iconUseCssClass}">
+                <div class="navmenu-icon ${entry.iconColor}"></div>
+            </c:if>
+            <c:if test="${not entry.iconUseCssClass}">
+                <div class="navmenu-icon" style="background: ${entry.iconColor}"></div>
+            </c:if>
+        </c:if>
+        <a href="./${entry.href}">
+            <c:if test="${entry.resolveCaption}">
+                <fmt:message key="${entry.caption}"/>
+            </c:if>
+            <c:if test="${not entry.resolveCaption}">
+                <c:out value="${entry.caption}"/>
+            </c:if>
+        </a>
+    </div>
+</c:forEach>
--- a/src/main/webapp/WEB-INF/jspf/project-header.jspf	Sat Jan 23 14:47:59 2021 +0100
+++ b/src/main/webapp/WEB-INF/jspf/project-header.jspf	Fri Apr 02 11:59:14 2021 +0200
@@ -22,7 +22,7 @@
             </c:if>
         </div>
     </div>
-    <c:if test="${not empty component and component.id gt 0}">
+    <c:if test="${not empty component}">
         <div class="row">
             <div class="caption"><fmt:message key="component"/>:</div>
             <div><c:out value="${component.name}"/></div>

mercurial