src/main/java/de/uapcore/lightpit/modules/ProjectsModule.java

Sun, 21 Jun 2020 11:38:16 +0200

author
Mike Becker <universe@uap-core.de>
date
Sun, 21 Jun 2020 11:38:16 +0200
changeset 88
1438e5a22c55
parent 86
0a658e53177c
child 96
b7b685f31e39
permissions
-rw-r--r--

simplifies version overviews by removing "scheduled issues"

     1 /*
     2  * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
     3  *
     4  * Copyright 2018 Mike Becker. All rights reserved.
     5  *
     6  * Redistribution and use in source and binary forms, with or without
     7  * modification, are permitted provided that the following conditions are met:
     8  *
     9  *   1. Redistributions of source code must retain the above copyright
    10  *      notice, this list of conditions and the following disclaimer.
    11  *
    12  *   2. Redistributions in binary form must reproduce the above copyright
    13  *      notice, this list of conditions and the following disclaimer in the
    14  *      documentation and/or other materials provided with the distribution.
    15  *
    16  * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
    17  * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
    18  * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
    19  * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
    20  * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
    21  * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
    22  * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
    23  * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
    24  * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
    25  * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
    26  * POSSIBILITY OF SUCH DAMAGE.
    27  *
    28  */
    29 package de.uapcore.lightpit.modules;
    32 import de.uapcore.lightpit.*;
    33 import de.uapcore.lightpit.dao.DataAccessObjects;
    34 import de.uapcore.lightpit.entities.*;
    35 import de.uapcore.lightpit.viewmodel.*;
    36 import org.slf4j.Logger;
    37 import org.slf4j.LoggerFactory;
    39 import javax.servlet.annotation.WebServlet;
    40 import javax.servlet.http.HttpServletRequest;
    41 import javax.servlet.http.HttpServletResponse;
    42 import javax.servlet.http.HttpSession;
    43 import java.io.IOException;
    44 import java.sql.Date;
    45 import java.sql.SQLException;
    46 import java.util.ArrayList;
    47 import java.util.List;
    48 import java.util.NoSuchElementException;
    49 import java.util.Objects;
    50 import java.util.stream.Collectors;
    51 import java.util.stream.Stream;
    53 import static de.uapcore.lightpit.Functions.fqn;
    55 @WebServlet(
    56         name = "ProjectsModule",
    57         urlPatterns = "/projects/*"
    58 )
    59 public final class ProjectsModule extends AbstractLightPITServlet {
    61     private static final Logger LOG = LoggerFactory.getLogger(ProjectsModule.class);
    63     public static final String SESSION_ATTR_SELECTED_PROJECT = fqn(ProjectsModule.class, "selected_project");
    64     public static final String SESSION_ATTR_SELECTED_ISSUE = fqn(ProjectsModule.class, "selected_issue");
    65     public static final String SESSION_ATTR_SELECTED_VERSION = fqn(ProjectsModule.class, "selected_version");
    67     private class SessionSelection {
    68         final HttpSession session;
    69         final HttpServletRequest req;
    70         final DataAccessObjects dao;
    71         Project project;
    72         Version version;
    73         Issue issue;
    75         SessionSelection(HttpServletRequest req, DataAccessObjects dao) {
    76             this.req = req;
    77             this.dao = dao;
    78             session = req.getSession();
    79         }
    81         void newProject() {
    82             project = null;
    83             version = null;
    84             issue = null;
    85             updateAttributes();
    86             project = new Project(-1);
    87             updateAttributes();
    88         }
    90         void newVersion() throws SQLException {
    91             project = (Project) session.getAttribute(SESSION_ATTR_SELECTED_PROJECT);
    92             syncProject();
    93             version = null;
    94             issue = null;
    95             updateAttributes();
    96             version = new Version(-1);
    97             version.setProject(project);
    98             updateAttributes();
    99         }
   101         void newIssue() throws SQLException {
   102             project = (Project) session.getAttribute(SESSION_ATTR_SELECTED_PROJECT);
   103             syncProject();
   104             version = null;
   105             issue = null;
   106             updateAttributes();
   107             issue = new Issue(-1);
   108             issue.setProject(project);
   109             updateAttributes();
   110         }
   112         void selectVersion(Version selectedVersion) throws SQLException {
   113             issue = null;
   114             version = selectedVersion;
   115             if (!version.getProject().equals(project)) {
   116                 project = dao.getProjectDao().find(version.getProject().getId());
   117             }
   118             // our object contains more details
   119             version.setProject(project);
   120             updateAttributes();
   121         }
   123         void selectIssue(Issue selectedIssue) throws SQLException {
   124             issue = selectedIssue;
   125             if (!issue.getProject().equals(project)) {
   126                 project = dao.getProjectDao().find(issue.getProject().getId());
   127             }
   128             // our object contains more details
   129             issue.setProject(project);
   130             if (!issue.getResolvedVersions().contains(version)
   131                     && !issue.getAffectedVersions().contains(version)) {
   132                 version = null;
   133             }
   134             updateAttributes();
   135         }
   137         void syncProject() throws SQLException {
   138             final var projectSelection = getParameter(req, Integer.class, "pid");
   139             if (projectSelection.isPresent()) {
   140                 final var selectedProject = dao.getProjectDao().find(projectSelection.get());
   141                 if (!Objects.equals(selectedProject, project)) {
   142                     // reset version and issue if project changed
   143                     version = null;
   144                     issue = null;
   145                 }
   146                 project = selectedProject;
   147             } else {
   148                 project = project == null ? null : dao.getProjectDao().find(project.getId());
   149             }
   150         }
   152         void syncVersion() throws SQLException {
   153             final var versionSelection = getParameter(req, Integer.class, "vid");
   154             if (versionSelection.isPresent()) {
   155                 if (versionSelection.get() < 0) {
   156                     version = null;
   157                 } else {
   158                     final var selectedVersion = dao.getVersionDao().find(versionSelection.get());
   159                     if (!Objects.equals(selectedVersion, version)) {
   160                         issue = null;
   161                     }
   162                     selectVersion(selectedVersion);
   163                 }
   164             } else {
   165                 version = version == null ? null : dao.getVersionDao().find(version.getId());
   166             }
   167         }
   169         void syncIssue() throws SQLException {
   170             final var issueSelection = getParameter(req, Integer.class, "issue");
   171             if (issueSelection.isPresent()) {
   172                 final var selectedIssue = dao.getIssueDao().find(issueSelection.get());
   173                 dao.getIssueDao().joinVersionInformation(selectedIssue);
   174                 selectIssue(selectedIssue);
   175             } else {
   176                 issue = issue == null ? null : dao.getIssueDao().find(issue.getId());
   177             }
   178         }
   180         void sync() throws SQLException {
   181             project = (Project) session.getAttribute(SESSION_ATTR_SELECTED_PROJECT);
   182             version = (Version) session.getAttribute(SESSION_ATTR_SELECTED_VERSION);
   183             issue = (Issue) session.getAttribute(SESSION_ATTR_SELECTED_ISSUE);
   185             syncProject();
   186             syncVersion();
   187             syncIssue();
   189             updateAttributes();
   190         }
   192         private void updateAttributes() {
   193             session.setAttribute(SESSION_ATTR_SELECTED_PROJECT, project);
   194             session.setAttribute(SESSION_ATTR_SELECTED_VERSION, version);
   195             session.setAttribute(SESSION_ATTR_SELECTED_ISSUE, issue);
   196         }
   197     }
   199     @Override
   200     protected String getResourceBundleName() {
   201         return "localization.projects";
   202     }
   205     private static final int BREADCRUMB_LEVEL_ROOT = 0;
   206     private static final int BREADCRUMB_LEVEL_PROJECT = 1;
   207     private static final int BREADCRUMB_LEVEL_VERSION = 2;
   208     private static final int BREADCRUMB_LEVEL_ISSUE_LIST = 3;
   209     private static final int BREADCRUMB_LEVEL_ISSUE = 4;
   211     /**
   212      * Creates the breadcrumb menu.
   213      *
   214      * @param level           the current active level (0: root, 1: project, 2: version, 3: issue list, 4: issue)
   215      * @param selection the currently selected objects
   216      * @return a dynamic breadcrumb menu trying to display as many levels as possible
   217      */
   218     private List<MenuEntry> getBreadcrumbs(int level, SessionSelection selection) {
   219         MenuEntry entry;
   221         final var breadcrumbs = new ArrayList<MenuEntry>();
   222         entry = new MenuEntry(new ResourceKey("localization.lightpit", "menu.projects"),
   223                 "projects/");
   224         breadcrumbs.add(entry);
   225         if (level == BREADCRUMB_LEVEL_ROOT) entry.setActive(true);
   227         if (selection.project != null) {
   228             if (selection.project.getId() < 0) {
   229                 entry = new MenuEntry(new ResourceKey("localization.projects", "button.create"),
   230                         "projects/edit");
   231             } else {
   232                 entry = new MenuEntry(selection.project.getName(),
   233                         "projects/view?pid=" + selection.project.getId());
   234             }
   235             if (level == BREADCRUMB_LEVEL_PROJECT) entry.setActive(true);
   236             breadcrumbs.add(entry);
   237         }
   239         if (selection.version != null) {
   240             if (selection.version.getId() < 0) {
   241                 entry = new MenuEntry(new ResourceKey("localization.projects", "button.version.create"),
   242                         "projects/versions/edit");
   243             } else {
   244                 entry = new MenuEntry(selection.version.getName(),
   245                         "projects/versions/view?vid=" + selection.version.getId());
   246             }
   247             if (level == BREADCRUMB_LEVEL_VERSION) entry.setActive(true);
   248             breadcrumbs.add(entry);
   249         }
   251         if (selection.project != null) {
   252             String path = "projects/issues/?pid=" + selection.project.getId();
   253             if (selection.version != null) {
   254                 path += "&vid="+selection.version.getId();
   255             }
   256             entry = new MenuEntry(new ResourceKey("localization.projects", "menu.issues"),
   257                     path);
   258             if (level == BREADCRUMB_LEVEL_ISSUE_LIST) entry.setActive(true);
   259             breadcrumbs.add(entry);
   260         }
   262         if (selection.issue != null) {
   263             if (selection.issue.getId() < 0) {
   264                 entry = new MenuEntry(new ResourceKey("localization.projects", "button.issue.create"),
   265                         "projects/issues/edit");
   266             } else {
   267                 entry = new MenuEntry("#" + selection.issue.getId(),
   268                         // TODO: maybe change link to a view rather than directly opening the editor
   269                         "projects/issues/edit?issue=" + selection.issue.getId());
   270             }
   271             if (level == BREADCRUMB_LEVEL_ISSUE) entry.setActive(true);
   272             breadcrumbs.add(entry);
   273         }
   275         return breadcrumbs;
   276     }
   278     @RequestMapping(method = HttpMethod.GET)
   279     public ResponseType index(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
   280         final var sessionSelection = new SessionSelection(req, dao);
   281         sessionSelection.sync();
   283         final var projectDao = dao.getProjectDao();
   284         final var versionDao = dao.getVersionDao();
   286         final var projectList = projectDao.list();
   288         final var viewModel = new ProjectIndexView();
   289         for (var project : projectList) {
   290             final var info = new ProjectInfo(project);
   291             info.setVersions(versionDao.list(project));
   292             info.setIssueSummary(projectDao.getIssueSummary(project));
   293             viewModel.getProjects().add(info);
   294         }
   296         setViewModel(req, viewModel);
   297         setContentPage(req, "projects");
   298         setStylesheet(req, "projects");
   300         setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_ROOT, sessionSelection));
   302         return ResponseType.HTML;
   303     }
   305     private ProjectEditView configureEditForm(HttpServletRequest req, DataAccessObjects dao, SessionSelection selection) throws SQLException {
   306         final var viewModel = new ProjectEditView();
   307         viewModel.setProject(selection.project);
   308         viewModel.setUsers(dao.getUserDao().list());
   309         setViewModel(req, viewModel);
   310         setContentPage(req, "project-form");
   311         setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_PROJECT, selection));
   312         return viewModel;
   313     }
   315     @RequestMapping(requestPath = "edit", method = HttpMethod.GET)
   316     public ResponseType edit(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
   317         final var selection = new SessionSelection(req, dao);
   318         if (getParameter(req, Integer.class, "pid").isEmpty()) {
   319             selection.newProject();
   320         } else {
   321             selection.sync();
   322         }
   324         configureEditForm(req, dao, selection);
   326         return ResponseType.HTML;
   327     }
   329     @RequestMapping(requestPath = "commit", method = HttpMethod.POST)
   330     public ResponseType commit(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
   332         Project project = new Project(-1);
   333         try {
   334             project = new Project(getParameter(req, Integer.class, "id").orElseThrow());
   335             project.setName(getParameter(req, String.class, "name").orElseThrow());
   336             getParameter(req, String.class, "description").ifPresent(project::setDescription);
   337             getParameter(req, String.class, "repoUrl").ifPresent(project::setRepoUrl);
   338             getParameter(req, Integer.class, "owner").map(
   339                     ownerId -> ownerId >= 0 ? new User(ownerId) : null
   340             ).ifPresent(project::setOwner);
   342             dao.getProjectDao().saveOrUpdate(project);
   344             setRedirectLocation(req, "./projects/");
   345             setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
   346             LOG.debug("Successfully updated project {}", project.getName());
   347         } catch (NoSuchElementException | IllegalArgumentException | SQLException ex) {
   348             LOG.warn("Form validation failure: {}", ex.getMessage());
   349             LOG.debug("Details:", ex);
   350             final var selection = new SessionSelection(req, dao);
   351             selection.project = project;
   352             final var vm = configureEditForm(req, dao, selection);
   353             vm.setErrorText(ex.getMessage()); // TODO: error text
   354         }
   356         return ResponseType.HTML;
   357     }
   359     @RequestMapping(requestPath = "view", method = HttpMethod.GET)
   360     public ResponseType view(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException, IOException {
   361         final var selection = new SessionSelection(req, dao);
   362         selection.sync();
   364         if (selection.project == null) {
   365             resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No project selected.");
   366             return ResponseType.NONE;
   367         }
   369         final var versionDao = dao.getVersionDao();
   370         final var issueDao = dao.getIssueDao();
   372         final var viewModel = new ProjectView(selection.project);
   373         final var issues = issueDao.list(selection.project);
   374         for (var issue : issues) issueDao.joinVersionInformation(issue);
   375         viewModel.setIssues(issues);
   376         viewModel.setVersions(versionDao.list(selection.project));
   377         viewModel.updateVersionInfo();
   378         setViewModel(req, viewModel);
   380         setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_PROJECT, selection));
   381         setContentPage(req, "project-details");
   382         setStylesheet(req, "projects");
   384         return ResponseType.HTML;
   385     }
   387     @RequestMapping(requestPath = "versions/view", method = HttpMethod.GET)
   388     public ResponseType viewVersion(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException, IOException {
   389         final var selection = new SessionSelection(req, dao);
   390         selection.sync();
   391         if (selection.version == null) {
   392             resp.sendError(HttpServletResponse.SC_NOT_FOUND);
   393             return ResponseType.NONE;
   394         }
   396         final var issueDao = dao.getIssueDao();
   397         final var viewModel = new VersionView(selection.version);
   398         final var issues = issueDao.list(selection.version);
   399         for (var issue : issues) issueDao.joinVersionInformation(issue);
   400         viewModel.setIssues(issues);
   401         setViewModel(req, viewModel);
   403         setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_VERSION, selection));
   404         setContentPage(req, "version");
   405         setStylesheet(req, "projects");
   407         return ResponseType.HTML;
   408     }
   410     private VersionEditView configureEditVersionForm(HttpServletRequest req, DataAccessObjects dao, SessionSelection selection) throws SQLException {
   411         final var viewModel = new VersionEditView(selection.version);
   412         if (selection.version.getProject() == null) {
   413             viewModel.setProjects(dao.getProjectDao().list());
   414         }
   415         setViewModel(req, viewModel);
   416         setContentPage(req, "version-form");
   417         setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_VERSION, selection));
   418         return viewModel;
   419     }
   421     @RequestMapping(requestPath = "versions/edit", method = HttpMethod.GET)
   422     public ResponseType editVersion(HttpServletRequest req, DataAccessObjects dao) throws SQLException {
   423         final var selection = new SessionSelection(req, dao);
   424         if (getParameter(req, Integer.class, "vid").isEmpty()) {
   425             selection.newVersion();
   426         } else {
   427             selection.sync();
   428         }
   430         configureEditVersionForm(req, dao, selection);
   432         return ResponseType.HTML;
   433     }
   435     @RequestMapping(requestPath = "versions/commit", method = HttpMethod.POST)
   436     public ResponseType commitVersion(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException {
   438         var version = new Version(-1);
   439         try {
   440             version = new Version(getParameter(req, Integer.class, "id").orElseThrow());
   441             version.setProject(new Project(getParameter(req, Integer.class, "pid").orElseThrow()));
   442             version.setName(getParameter(req, String.class, "name").orElseThrow());
   443             getParameter(req, Integer.class, "ordinal").ifPresent(version::setOrdinal);
   444             version.setStatus(VersionStatus.valueOf(getParameter(req, String.class, "status").orElseThrow()));
   445             dao.getVersionDao().saveOrUpdate(version);
   447             // specifying the pid parameter will purposely reset the session selected version!
   448             setRedirectLocation(req, "./projects/view?pid="+version.getProject().getId());
   449             setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
   450         } catch (NoSuchElementException | IllegalArgumentException | SQLException ex) {
   451             LOG.warn("Form validation failure: {}", ex.getMessage());
   452             LOG.debug("Details:", ex);
   453             final var selection = new SessionSelection(req, dao);
   454             selection.selectVersion(version);
   455             final var viewModel = configureEditVersionForm(req, dao, selection);
   456             // TODO: set Error Text
   457         }
   459         return ResponseType.HTML;
   460     }
   462     private IssueEditView configureEditIssueForm(HttpServletRequest req, DataAccessObjects dao, SessionSelection selection) throws SQLException {
   463         final var viewModel = new IssueEditView(selection.issue);
   465         if (selection.issue.getProject() == null) {
   466             viewModel.setProjects(dao.getProjectDao().list());
   467         } else {
   468             viewModel.setVersions(dao.getVersionDao().list(selection.issue.getProject()));
   469         }
   470         viewModel.setUsers(dao.getUserDao().list());
   471         setViewModel(req, viewModel);
   473         setContentPage(req, "issue-form");
   474         setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_ISSUE, selection));
   475         return viewModel;
   476     }
   478     @RequestMapping(requestPath = "issues/", method = HttpMethod.GET)
   479     public ResponseType issues(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException, IOException {
   480         final var selection = new SessionSelection(req, dao);
   481         selection.sync();
   482         if (selection.project == null) {
   483             resp.sendError(HttpServletResponse.SC_NOT_FOUND, "No project selected.");
   484             return ResponseType.NONE;
   485         }
   487         final var viewModel = new IssuesView();
   488         viewModel.setProject(selection.project);
   489         if (selection.version == null) {
   490             viewModel.setIssues(dao.getIssueDao().list(selection.project));
   491         } else {
   492             viewModel.setVersion(selection.version);
   493             viewModel.setIssues(dao.getIssueDao().list(selection.version));
   494         }
   495         setViewModel(req, viewModel);
   497         setBreadcrumbs(req, getBreadcrumbs(BREADCRUMB_LEVEL_ISSUE_LIST, selection));
   498         setContentPage(req, "issues");
   499         setStylesheet(req, "projects");
   501         return ResponseType.HTML;
   502     }
   504     @RequestMapping(requestPath = "issues/edit", method = HttpMethod.GET)
   505     public ResponseType editIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException {
   506         final var selection = new SessionSelection(req, dao);
   507         if (getParameter(req, Integer.class, "issue").isEmpty()) {
   508             selection.newIssue();
   509         } else {
   510             selection.sync();
   511         }
   513         configureEditIssueForm(req, dao, selection);
   515         return ResponseType.HTML;
   516     }
   518     @RequestMapping(requestPath = "issues/commit", method = HttpMethod.POST)
   519     public ResponseType commitIssue(HttpServletRequest req, HttpServletResponse resp, DataAccessObjects dao) throws SQLException {
   521         Issue issue = new Issue(-1);
   522         try {
   523             issue = new Issue(getParameter(req, Integer.class, "id").orElseThrow());
   524             issue.setProject(new Project(getParameter(req, Integer.class, "pid").orElseThrow()));
   525             getParameter(req, String.class, "category").map(IssueCategory::valueOf).ifPresent(issue::setCategory);
   526             getParameter(req, String.class, "status").map(IssueStatus::valueOf).ifPresent(issue::setStatus);
   527             issue.setSubject(getParameter(req, String.class, "subject").orElseThrow());
   528             getParameter(req, Integer.class, "assignee").map(
   529                     userid -> userid >= 0 ? new User(userid) : null
   530             ).ifPresent(issue::setAssignee);
   531             getParameter(req, String.class, "description").ifPresent(issue::setDescription);
   532             getParameter(req, Date.class, "eta").ifPresent(issue::setEta);
   534             getParameter(req, Integer[].class, "affected")
   535                     .map(Stream::of)
   536                     .map(stream ->
   537                         stream.map(Version::new).collect(Collectors.toList())
   538                     ).ifPresent(issue::setAffectedVersions);
   539             getParameter(req, Integer[].class, "resolved")
   540                     .map(Stream::of)
   541                     .map(stream ->
   542                             stream.map(Version::new).collect(Collectors.toList())
   543                     ).ifPresent(issue::setResolvedVersions);
   545             dao.getIssueDao().saveOrUpdate(issue);
   547             // specifying the issue parameter keeps the edited issue as breadcrumb
   548             setRedirectLocation(req, "./projects/issues/?issue="+issue.getId());
   549             setContentPage(req, Constants.JSP_COMMIT_SUCCESSFUL);
   550         } catch (NoSuchElementException | IllegalArgumentException | SQLException ex) {
   551             // TODO: set request attribute with error text
   552             LOG.warn("Form validation failure: {}", ex.getMessage());
   553             LOG.debug("Details:", ex);
   554             final var selection = new SessionSelection(req, dao);
   555             selection.selectIssue(issue);
   556             final var viewModel = configureEditIssueForm(req, dao, selection);
   557             // TODO: set Error Text
   558         }
   560         return ResponseType.HTML;
   561     }
   562 }

mercurial