universe@184: /* universe@184: * Copyright 2021 Mike Becker. All rights reserved. universe@184: * universe@184: * Redistribution and use in source and binary forms, with or without universe@184: * modification, are permitted provided that the following conditions are met: universe@184: * universe@184: * 1. Redistributions of source code must retain the above copyright universe@184: * notice, this list of conditions and the following disclaimer. universe@184: * universe@184: * 2. Redistributions in binary form must reproduce the above copyright universe@184: * notice, this list of conditions and the following disclaimer in the universe@184: * documentation and/or other materials provided with the distribution. universe@184: * universe@184: * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" universe@184: * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE universe@184: * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE universe@184: * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE universe@184: * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL universe@184: * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR universe@184: * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER universe@184: * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, universe@184: * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE universe@184: * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. universe@184: */ universe@184: universe@184: package de.uapcore.lightpit.viewmodel universe@184: universe@184: import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension universe@184: import com.vladsch.flexmark.ext.tables.TablesExtension universe@184: import com.vladsch.flexmark.html.HtmlRenderer universe@184: import com.vladsch.flexmark.parser.Parser universe@184: import com.vladsch.flexmark.util.data.MutableDataSet universe@234: import com.vladsch.flexmark.util.data.SharedDataKeys universe@268: import de.uapcore.lightpit.HttpRequest universe@184: import de.uapcore.lightpit.entities.* universe@263: import de.uapcore.lightpit.types.* universe@184: import kotlin.math.roundToInt universe@184: universe@249: class IssueSorter(private vararg val criteria: Criteria) : Comparator { universe@249: enum class Field { universe@271: DONE, PHASE, STATUS, CATEGORY, ETA, UPDATED, CREATED; universe@271: universe@271: val resourceKey: String by lazy { universe@271: if (this == DONE) "issue.filter.sort.done" universe@271: else if (this == PHASE) "issue.filter.sort.phase" universe@271: else "issue.${this.name.lowercase()}" universe@271: } universe@249: } universe@249: universe@271: data class Criteria(val field: Field, val asc: Boolean = true) { universe@271: override fun toString(): String { universe@271: return "$field.$asc" universe@271: } universe@271: } universe@249: universe@249: override fun compare(left: Issue, right: Issue): Int { universe@249: if (left == right) { universe@260: return 0 universe@249: } universe@249: for (c in criteria) { universe@249: val result = when (c.field) { universe@267: Field.PHASE -> left.status.phase.compareTo(right.status.phase) universe@267: Field.DONE -> (left.status.phase == IssueStatusPhase.Done).compareTo(right.status.phase == IssueStatusPhase.Done) universe@265: Field.STATUS -> left.status.compareTo(right.status) universe@265: Field.CATEGORY -> left.category.compareTo(right.category) universe@265: Field.ETA -> left.compareEtaTo(right.eta) universe@249: Field.UPDATED -> left.updated.compareTo(right.updated) universe@265: Field.CREATED -> left.created.compareTo(right.created) universe@249: } universe@249: if (result != 0) { universe@249: return if (c.asc) result else -result universe@249: } universe@249: } universe@249: return 0 universe@249: } universe@249: } universe@249: universe@184: class IssueSummary { universe@184: var open = 0 universe@184: var active = 0 universe@184: var done = 0 universe@184: universe@184: val total get() = open + active + done universe@184: universe@184: val openPercent get() = 100 - activePercent - donePercent universe@184: val activePercent get() = if (total > 0) (100f * active / total).roundToInt() else 0 universe@184: val donePercent get() = if (total > 0) (100f * done / total).roundToInt() else 100 universe@184: universe@184: /** universe@184: * Adds the specified issue to the summary by incrementing the respective counter. universe@184: * @param issue the issue universe@184: */ universe@184: fun add(issue: Issue) { universe@184: when (issue.status.phase) { universe@184: IssueStatusPhase.Open -> open++ universe@184: IssueStatusPhase.WorkInProgress -> active++ universe@184: IssueStatusPhase.Done -> done++ universe@184: } universe@184: } universe@184: } universe@184: universe@284: data class CommitLink(val url: String, val hash: String, val message: String) universe@284: universe@184: class IssueDetailView( universe@292: val pathInfos: PathInfos, universe@184: val issue: Issue, universe@184: val comments: List, universe@184: val project: Project, universe@263: projectIssues: List, universe@263: val currentRelations: List, universe@263: /** universe@263: * Optional resource key to an error message for the relation editor. universe@263: */ universe@284: val relationError: String?, universe@284: commitRefs: List universe@184: ) : View() { universe@291: val relationTypes = RelationType.entries universe@263: val linkableIssues = projectIssues.filterNot { it.id == issue.id } universe@284: val commitLinks: List universe@263: universe@234: private val parser: Parser universe@234: private val renderer: HtmlRenderer universe@184: universe@184: init { universe@184: val options = MutableDataSet() universe@234: .set(SharedDataKeys.EXTENSIONS, listOf(TablesExtension.create(), StrikethroughExtension.create())) universe@234: parser = Parser.builder(options).build() universe@268: renderer = HtmlRenderer.builder( universe@268: options universe@268: .set(HtmlRenderer.ESCAPE_HTML, true) universe@234: ).build() universe@184: universe@234: issue.description = formatMarkdown(issue.description ?: "") universe@184: for (comment in comments) { universe@234: comment.commentFormatted = formatMarkdown(comment.comment) universe@184: } universe@284: universe@284: val commitBaseUrl = project.repoUrl universe@284: commitLinks = (if (commitBaseUrl == null || project.vcs == VcsType.None) emptyList() else commitRefs.map { universe@284: CommitLink(buildCommitUrl(commitBaseUrl, project.vcs, it.hash), it.hash, it.message) universe@284: }) universe@184: } universe@234: universe@284: private fun buildCommitUrl(baseUrl: String, vcs: VcsType, hash: String): String = universe@284: with (StringBuilder(baseUrl)) { universe@284: if (!endsWith("/")) append('/') universe@284: when (vcs) { universe@284: VcsType.Mercurial -> append("rev/") universe@284: else -> append("commit/") universe@284: } universe@284: append(hash) universe@284: toString() universe@284: } universe@284: universe@234: private fun formatEmojis(text: String) = text universe@234: .replace("(/)", "✅") universe@234: .replace("(x)", "❌") universe@234: .replace("(!)", "⚡") universe@234: universe@234: private fun formatMarkdown(text: String) = universe@234: renderer.render(parser.parse(formatEmojis(text))) universe@184: } universe@184: universe@184: class IssueEditView( universe@184: val issue: Issue, universe@184: val versions: List, universe@184: val components: List, universe@184: val users: List, universe@184: val project: Project, // TODO: allow null values to create issues from the IssuesServlet universe@292: val pathInfos: PathInfos universe@184: ) : EditView() { universe@184: universe@184: val versionsUpcoming: List universe@184: val versionsRecent: List universe@184: universe@291: val issueStatus = IssueStatus.entries universe@291: val issueCategory = IssueCategory.entries universe@184: universe@184: init { universe@184: val recent = mutableListOf() universe@231: issue.affected?.let { recent.add(it) } universe@184: val upcoming = mutableListOf() universe@231: issue.resolved?.let { upcoming.add(it) } universe@231: universe@184: for (v in versions) { universe@184: if (v.status.isReleased) { universe@184: if (v.status != VersionStatus.Deprecated) recent.add(v) universe@184: } else { universe@184: upcoming.add(v) universe@184: } universe@184: } universe@186: versionsRecent = recent.distinct() universe@186: versionsUpcoming = upcoming.distinct() universe@184: } universe@184: } universe@184: universe@268: class IssueFilter(http: HttpRequest) { universe@268: universe@291: val issueStatus = IssueStatus.entries universe@291: val issueCategory = IssueCategory.entries universe@291: val sortCriteria = IssueSorter.Field.entries.flatMap { listOf(IssueSorter.Criteria(it, true), IssueSorter.Criteria(it, false)) } universe@268: val flagIncludeDone = "f.0" universe@268: val flagMine = "f.1" universe@268: val flagBlocker = "f.2" universe@268: universe@268: val includeDone: Boolean = evalFlag(http, flagIncludeDone) universe@268: val onlyMine: Boolean = evalFlag(http, flagMine) universe@268: val onlyBlocker: Boolean = evalFlag(http, flagBlocker) universe@268: val status: List = evalEnum(http, "s") universe@268: val category: List = evalEnum(http, "c") universe@268: universe@271: val sortPrimary: IssueSorter.Criteria = evalSort(http, "primary", IssueSorter.Criteria(IssueSorter.Field.DONE)) universe@271: val sortSecondary: IssueSorter.Criteria = evalSort(http, "secondary", IssueSorter.Criteria(IssueSorter.Field.ETA)) universe@271: val sortTertiary: IssueSorter.Criteria = evalSort(http, "tertiary", IssueSorter.Criteria(IssueSorter.Field.UPDATED, false)) universe@271: universe@271: private fun evalSort(http: HttpRequest, prio: String, defaultValue: IssueSorter.Criteria): IssueSorter.Criteria { universe@271: val param = http.param("sort_$prio") universe@271: if (param != null) { universe@271: http.session.removeAttribute("sort_$prio") universe@271: val p = param.split(".") universe@271: if (p.size > 1) { universe@271: try { universe@271: http.session.setAttribute("sort_$prio", IssueSorter.Criteria(enumValueOf(p[0]), p[1].toBoolean())) universe@271: } catch (_:IllegalArgumentException) { universe@271: // ignore malfored values universe@271: } universe@271: } universe@271: } universe@271: return http.session.getAttribute("sort_$prio") as IssueSorter.Criteria? ?: defaultValue universe@271: } universe@271: universe@268: private fun evalFlag(http: HttpRequest, name: String): Boolean { universe@268: val param = http.paramArray("filter") universe@268: if (param.isNotEmpty()) { universe@268: if (param.contains(name)) { universe@268: http.session.setAttribute(name, true) universe@268: } else { universe@268: http.session.removeAttribute(name) universe@268: } universe@268: } universe@268: return http.session.getAttribute(name) != null universe@268: } universe@268: universe@268: private inline fun > evalEnum(http: HttpRequest, prefix: String): List { universe@268: val sattr = "f.${prefix}" universe@268: val param = http.paramArray("filter") universe@268: if (param.isNotEmpty()) { universe@268: val list = param.filter { it.startsWith("${prefix}.") } universe@268: .map { it.substring(prefix.length + 1) } universe@268: .map { universe@268: try { universe@268: // quick and very dirty validation universe@268: enumValueOf(it) universe@268: } catch (_: IllegalArgumentException) { universe@268: // skip universe@268: } universe@268: } universe@268: if (list.isEmpty()) { universe@268: http.session.removeAttribute(sattr) universe@268: } else { universe@268: http.session.setAttribute(sattr, list.joinToString(",")) universe@268: } universe@268: } universe@268: universe@268: return http.session.getAttribute(sattr) universe@268: ?.toString() universe@268: ?.split(",") universe@268: ?.map { enumValueOf(it) } universe@268: ?: emptyList() universe@268: } universe@268: }