diff --git a/build.gradle b/build.gradle index 4c98238..80f0d7a 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,13 @@ configurations { visible = false canBeResolved = true } + + frontend { + transitive = false + canBeConsumed = false + visible = false + canBeResolved = true + } } java { @@ -106,23 +113,12 @@ dependencies { deployableArchives project deployableArchives project(':jpacrepo-impl') deployableArchives project(':jpacrepo-api') -} -File nimDir = project.file('nim') -File srcDir = new File(nimDir, 'src') -File staticDir = new File(nimDir, 'static') - -Provider nimCompileTaskProvider = tasks.register("compileNim", Exec) { - inputs.files(project.fileTree(srcDir), project.fileTree(staticDir)) - File outputFile = new File(temporaryDir, "jpacrepo.js") - outputs.file(outputFile) - commandLine 'nim', 'js', '-d:release', "-o:$outputFile", 'src/jpacrepo.nim' - workingDir(nimDir) + frontend project(path: ':jpacrepo-frontend', configuration: 'tar') } Provider warTaskProvider = tasks.named('war', War) { War it -> - from staticDir - from nimCompileTaskProvider + from(configurations.frontend) archiveVersion = '' } @@ -140,14 +136,7 @@ tasks.named("test", Test) { '--add-opens', 'java.naming/javax.naming.spi=ALL-UNNAMED', '--add-opens', 'java.base/java.lang=ALL-UNNAMED', '--add-opens', 'java.base/java.io=ALL-UNNAMED', -// '--add-opens', 'java.base/java.util=ALL-UNNAMED', -// '--add-opens', 'java.management/javax.management.openmbean=ALL-UNNAMED', -// '--add-opens', 'java.management/javax.management=ALL-UNNAMED', ] -// classpath = configurations.testRuntimeClasspath + sourceSets.test.output -// File warPath = tasks.named("war", War).get().archiveFile.get().getAsFile() -// classpath += warPath -// classpath += tasks.named("war", War).get().outputs.files inputs.files(configurations.deployableArchives) systemProperty('jpacrepo.jar.path', configurations.deployableArchives.asPath) } diff --git a/gradle.properties b/gradle.properties index 7e11d75..254a36d 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,3 @@ -jpacrepo.version=2024.02.09 +jpacrepo.version=2024.02.12 -lys.version=2024.02.09 +lys.version=2024.02.12 diff --git a/jpacrepo-api/src/main/java/net/woggioni/jpacrepo/api/model/CompressionFormat.java b/jpacrepo-api/src/main/java/net/woggioni/jpacrepo/api/model/CompressionFormat.java index 2dd4cd8..6017e23 100644 --- a/jpacrepo-api/src/main/java/net/woggioni/jpacrepo/api/model/CompressionFormat.java +++ b/jpacrepo-api/src/main/java/net/woggioni/jpacrepo/api/model/CompressionFormat.java @@ -6,9 +6,12 @@ import lombok.RequiredArgsConstructor; import java.io.Serializable; @RequiredArgsConstructor -public enum CompressionFormat { +public enum CompressionFormat implements Comparable { XZ("xz"), GZIP("gz"), Z_STANDARD("zst"); @Getter private final String value; + + + } diff --git a/jpacrepo-api/src/main/java/net/woggioni/jpacrepo/api/model/PkgData.java b/jpacrepo-api/src/main/java/net/woggioni/jpacrepo/api/model/PkgData.java index 23d9f46..5681d84 100644 --- a/jpacrepo-api/src/main/java/net/woggioni/jpacrepo/api/model/PkgData.java +++ b/jpacrepo-api/src/main/java/net/woggioni/jpacrepo/api/model/PkgData.java @@ -84,6 +84,8 @@ public class PkgData implements Serializable { private long size; + private long installedSize; + @Getter(onMethod_ = { @ManyToMany, @JoinTable(name = "PkgData_license") diff --git a/jpacrepo-frontend/build.gradle b/jpacrepo-frontend/build.gradle index 840d00c..cd47748 100644 --- a/jpacrepo-frontend/build.gradle +++ b/jpacrepo-frontend/build.gradle @@ -1,11 +1,54 @@ plugins { alias(catalog.plugins.kotlin.multiplatform) + id "org.jetbrains.kotlin.plugin.serialization" version "1.9.22" } + + +configurations { + tar { + canBeConsumed = true + canBeResolved = false + } +} + + kotlin { + sourceSets { + jsMain { + dependencies { + implementation catalog.klevtree + implementation group: 'org.jetbrains.kotlinx', name: 'kotlinx-serialization-json', version: '1.6.2' +// implementation(npm("bootstrap", "5.3.2")) +// implementation(npm("bootstrap-icons", "1.11.3")) +// implementation(npm("style-loader", "3.3.4")) +// implementation(npm("css-loader", "6.10.0")) + } + } + } js(IR) { browser { + runTask { + sourceMaps = true + devServer.port = 8080 + } + webpackTask { + sourceMaps = false + } } binaries.executable() } } + + +Provider distTarTaskProvider = tasks.register('distTar', Tar) { tar -> + from(tasks.named('jsBrowserDistribution')) +} + + +def distributionTaskProvider = tasks.named("jsBrowserDistribution") + +artifacts { + tar(distributionTaskProvider) +} + diff --git a/jpacrepo-frontend/src/commonMain/kotlin/net/woggioni/jpacrepo/common/JpacrepoCommons.kt b/jpacrepo-frontend/src/commonMain/kotlin/net/woggioni/jpacrepo/common/JpacrepoCommons.kt new file mode 100644 index 0000000..f5fcce3 --- /dev/null +++ b/jpacrepo-frontend/src/commonMain/kotlin/net/woggioni/jpacrepo/common/JpacrepoCommons.kt @@ -0,0 +1,15 @@ +package net.woggioni.jpacrepo.common + +object JpacrepoCommons { + private val hexArray = "0123456789ABCDEF".toCharArray() + + fun ByteArray.toHexString(): String { + val hexChars = CharArray(this.size * 2) + for (j in this.indices) { + val v = this[j].toInt() and 0xFF + hexChars[j * 2] = hexArray[v ushr 4] + hexChars[j * 2 + 1] = hexArray[v and 0x0F] + } + return hexChars.concatToString() + } +} \ No newline at end of file diff --git a/jpacrepo-frontend/src/jsMain/kotlin/net/woggioni/jpacrepo/DocumentWalk.kt b/jpacrepo-frontend/src/jsMain/kotlin/net/woggioni/jpacrepo/DocumentWalk.kt new file mode 100644 index 0000000..14eb999 --- /dev/null +++ b/jpacrepo-frontend/src/jsMain/kotlin/net/woggioni/jpacrepo/DocumentWalk.kt @@ -0,0 +1,182 @@ +package net.woggioni.jpacrepo + +import kotlin.coroutines.Continuation +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.startCoroutine +import kotlin.coroutines.suspendCoroutine +import kotlin.js.Promise +import org.w3c.dom.Document +import org.w3c.dom.Element +import org.w3c.dom.events.Event + +object HtmlUtils { + fun Element.removeChildren() { + while (true) { + removeChild(firstChild ?: break) + } + } + + fun Element.on(eventName : String, eventListener : (Event) -> Unit) { + addEventListener(eventName, eventListener) + } + + fun launch(block: suspend () -> Unit) { + block.startCoroutine(object : Continuation { + override val context: CoroutineContext get() = EmptyCoroutineContext + override fun resumeWith(result: Result) {} + }) + } + + suspend fun Promise.await(): T = suspendCoroutine { cont -> + then({ cont.resume(it) }, { cont.resumeWithException(it) }) + } +} + +class HtmlBuilder private constructor(private val doc : Document, val el: Element) { + + companion object { + fun of(doc : Document, el: Element, cb : HtmlBuilder.(el : Element) -> T) : T { + return HtmlBuilder(doc, el).cb(el) + } + } + + private inline fun dfd( + name : String, + attrs : Map, + cb : HtmlBuilder.(el : Element) -> T) : T { + val child = doc.createElement(name) + for((key, value) in attrs) { + child.setAttribute(key, value) + } + el.appendChild(child) + return HtmlBuilder(doc, child).cb(child) + } + + fun html(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("html", attrs, cb) + fun head(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("head", attrs, cb) + fun body(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("body", attrs, cb) + + fun use(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("use", attrs, cb) + fun svg(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("svg", attrs, cb) + fun div(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("div", attrs, cb) + fun header(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("header", attrs, cb) + fun main(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("main", attrs, cb) + fun footer(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("footer", attrs, cb) + fun a(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("a", attrs, cb) + fun meta(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("meta", attrs, cb) + fun script(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("script", attrs, cb) + fun link(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("link", attrs, cb) + fun title(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("title", attrs, cb) + fun p(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("p", attrs, cb) + fun span(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("span", attrs, cb) + fun i(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("i", attrs, cb) + fun del(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("del", attrs, cb) + fun s(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("s", attrs, cb) + fun ins(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("ins", attrs, cb) + fun u(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("u", attrs, cb) + fun b(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("b", attrs, cb) + fun small(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("small", attrs, cb) + fun strong(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("strong", attrs, cb) + fun em(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("em", attrs, cb) + fun mark(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("mark", attrs, cb) + fun obj(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("object", attrs, cb) + fun h1(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("h1", attrs, cb) + fun h2(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("h2", attrs, cb) + fun h3(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("h3", attrs, cb) + fun h4(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("h4", attrs, cb) + fun h5(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("h5", attrs, cb) + fun h6(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("h6", attrs, cb) + fun table(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("table", attrs, cb) + fun thead(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("thead", attrs, cb) + fun tbody(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("tbody", attrs, cb) + fun tfoot(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("tfoot", attrs, cb) + fun tr(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("tr", attrs, cb) + fun th(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("th", attrs, cb) + fun td(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("td", attrs, cb) + fun ol(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("ol", attrs, cb) + fun ul(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("ul", attrs, cb) + fun li(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("li", attrs, cb) + fun img(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("img", attrs, cb) + fun form(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("form", attrs, cb) + fun label(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("label", attrs, cb) + fun button(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("button", attrs, cb) + fun input(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("input", attrs, cb) + fun select(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("select", attrs, cb) + fun option(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("option", attrs, cb) + fun meter(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("meter", attrs, cb) + fun nav(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("nav", attrs, cb) + fun menu(attrs : Map = emptyMap(), + cb : HtmlBuilder.(el : Element) -> T) : T = dfd("menu", attrs, cb) + + + fun classes(vararg classes : String) { + for(cls in classes) el.classList.add(cls) + } + + fun attr(key: String, value : String) { + el.setAttribute(key, value) + } + + fun text(txt : String) { + el.appendChild(doc.createTextNode(txt)) + } + + fun on(eventName : String, cb: (Event) -> Unit) { + el.addEventListener(eventName, cb) + } +} diff --git a/jpacrepo-frontend/src/jsMain/kotlin/net/woggioni/jpacrepo/Main.kt b/jpacrepo-frontend/src/jsMain/kotlin/net/woggioni/jpacrepo/Main.kt new file mode 100644 index 0000000..49fa716 --- /dev/null +++ b/jpacrepo-frontend/src/jsMain/kotlin/net/woggioni/jpacrepo/Main.kt @@ -0,0 +1,510 @@ +package net.woggioni.jpacrepo + +import kotlin.js.Promise +import kotlin.random.Random +import kotlinx.browser.document +import kotlinx.browser.window +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import net.woggioni.jpacrepo.HtmlUtils.await +import net.woggioni.jpacrepo.HtmlUtils.launch +import net.woggioni.jpacrepo.HtmlUtils.on +import net.woggioni.jpacrepo.HtmlUtils.removeChildren +import net.woggioni.jpacrepo.common.JpacrepoCommons.toHexString +import net.woggioni.klevtree.LevTrie +import org.w3c.dom.CustomEvent +import org.w3c.dom.Element +import org.w3c.dom.HTMLElement +import org.w3c.dom.HTMLFormElement +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.HTMLSelectElement +import org.w3c.dom.events.Event +import org.w3c.dom.get + +private val UNITS_OF_MEASURE = arrayOf("B", "KiB", "MiB", "GiB", "TiB", "PiB") +fun Long.toHumanReadableByteSize(): String { + var i = 0 + var multiple = 1 + while (i < UNITS_OF_MEASURE.size) { + val label = UNITS_OF_MEASURE[i] + val newMultiple = multiple * 1024; + if (this > multiple && (i + 1 > UNITS_OF_MEASURE.size || this < newMultiple)) { + return (this.toDouble() / multiple).asDynamic().toPrecision(3) + " " + label; + } + i += 1 + multiple = newMultiple; + } + throw Error("This should never happen") +} + +typealias PkgMap = Map>>> + +private fun createPackageArchive(): Promise { + return window.fetch(SERVER_PREFIX + "api/pkg/map") + .then { it.text() } + .then { Json.decodeFromString(it) } + .then(::PackageArchive) +} + +class PackageArchive(val pkgMap: PkgMap) { + val tries: Map + + init { + tries = pkgMap.asSequence().map { + it.key to LevTrie().apply { + algorithm = LevTrie.Algorithm.DAMERAU_LEVENSHTEIN + for (pkgName in it.value.keys) { + add(pkgName) + } + } + }.toMap() + } + + fun searchByArch(arch: String, needle: String, maxResult: Int): List { + return tries[arch]?.let { + it.fuzzySearch(needle, maxResult) + .asSequence() + .sortedBy { it.second } + .map { it.first } + .toList() + } ?: emptyList() + } +} + + +private const val REFRESH_TABLE_EVENT_NAME = "refreshTable" +private const val REFRESH_TABLE_ROW_EVENT_NAME = "refreshTableRow" +private const val REFRESH_SELECTED_PACKAGES_EVENT_NAME = "refreshSelectedPackages" +//private const val SERVER_PREFIX = "https://woggioni.net/jpacrepo/" +private const val SERVER_PREFIX = "" + +private fun fireTableRefreshEvent(el: Element) { + val evt = CustomEvent( + REFRESH_TABLE_EVENT_NAME + ).apply { + initCustomEvent( + REFRESH_TABLE_EVENT_NAME, + bubbles = true, + cancelable = true, + null + ) + } + el.dispatchEvent(evt) +} + +private fun fireTableRowRefreshEvent(el: Element) { + val evt = CustomEvent( + REFRESH_TABLE_ROW_EVENT_NAME + ).apply { + initCustomEvent( + REFRESH_TABLE_ROW_EVENT_NAME, + bubbles = false, + cancelable = true, + null + ) + } + el.dispatchEvent(evt) +} + +private fun fireSelectedPackagesRefreshEvent(el: Element) { + val evt = CustomEvent( + REFRESH_SELECTED_PACKAGES_EVENT_NAME + ).apply { + initCustomEvent( + REFRESH_SELECTED_PACKAGES_EVENT_NAME, + bubbles = true, + cancelable = true, + null + ) + } + el.dispatchEvent(evt) +} + +var pkgArchive: Promise = createPackageArchive() +var selectedArchitecture: String? = null +val selectedPackages: MutableSet = mutableSetOf() + +suspend fun buildTable(root: Element, searchTerm: String, commonElement: Element) { + selectedArchitecture?.let { arch -> + val archive = pkgArchive.await() + val results = archive.searchByArch(arch, searchTerm, 10) + val children = root.children + while (children.length > 0) { + children[0]!!.remove() + } + HtmlBuilder.of(document, root) { + table { + classes("table", "table-striped", "pkgtable") + thead { + tr { + th { + + } + th { + text("Name") + } + th { + text("Version") + } + th { + text("Compression format") + } + th { + text("Filename") + } + th { + text("Size") + } + } + } + tbody { + for (pkgName in results) { + val packages = archive.pkgMap[arch]?.let { it[pkgName] } + var selectedPackage: Pair? = let { + packages?.iterator()?.next()?.let { + val version = it.key + it.value.iterator().next().let { + val compressionFormat = it.key + PkgId(arch, pkgName, version, compressionFormat) to it.value + } + } + } + var selectedVersion = packages?.keys?.first() + tr { row -> + td { + i { + classes("bi-plus-circle") + attr("style", "font-size: 1.5rem; color: grey;") + on("click") { + selectedPackage?.let { + selectedPackages.add(it.second) + } + fireSelectedPackagesRefreshEvent(commonElement) + } + } + } + td { + text(pkgName) + } + td { + packages?.keys?.let { keys -> + select { + classes("form-select") + for (version in keys) { + option { + attr("value", version) + text(version) + } + } + on("input") { evt: Event -> + val htmlInputEvent = evt.target as? HTMLSelectElement + selectedVersion = htmlInputEvent?.value + fireTableRowRefreshEvent(row) + } + } + } + } + val getFirstAvailableCompressionFormat = { + val availablePackages = packages + ?.let { it[selectedVersion] } + availablePackages?.keys?.first() + } + var selectedCompressionFormat = getFirstAvailableCompressionFormat() + + fun HtmlBuilder.createCompressionFormatDropdown() { + val availablePackages = packages + ?.let { it[selectedVersion] } + availablePackages?.keys?.let { compressionFormats -> + select { + classes("form-select") + for (compressionFormat in compressionFormats) { + option { + attr("value", compressionFormat.name) + text(compressionFormat.toString()) + } + } + on("input") { evt: Event -> + val htmlInputEvent = evt.target as? HTMLSelectElement + selectedCompressionFormat = htmlInputEvent + ?.value + ?.let(CompressionFormat::valueOf) + fireTableRowRefreshEvent(row) + } + } + } + } + + val compressionFormatCell = td { + createCompressionFormatDropdown() + el + } + val fileNameCell = td { + selectedPackage?.let { + text(it.second.fileName) + } + el + } + val sizeCell = td { + selectedPackage?.let { + text(it.second.size.toHumanReadableByteSize()) + } + el + } + on(REFRESH_TABLE_ROW_EVENT_NAME) { evt -> + selectedCompressionFormat = getFirstAvailableCompressionFormat() + compressionFormatCell.removeChildren() + HtmlBuilder.of(document, compressionFormatCell) { + createCompressionFormatDropdown() + } + selectedPackage = archive.pkgMap[arch]?.let { + it[pkgName] + }?.let { + it[selectedVersion] + }?.let { + it[selectedCompressionFormat] + }?.let { pkgTuple -> + PkgId(arch, pkgName, selectedVersion!!, selectedCompressionFormat!!) to pkgTuple + } + selectedPackage?.let { pair -> + fileNameCell.textContent = pair.second.fileName + sizeCell.textContent = pair.second.size.toHumanReadableByteSize() + } + } + } + } + } + } + } + } +} + +private fun HtmlBuilder.buildPackageSearchSection(commonElement: Element) { + val root = el + var selectedPackageName: String? = null + div { + classes("row", "g-5") + div { + classes("col-md12") + form { + classes("needs-validation") + div { + classes("row", "g-3") + label { + classes("col-sm-2", "col-form-label") + attr("for", "packageName") + text("Search package") + } + div { + classes("col-sm-10", "col-form-label") + input { inputElement -> + attr("id", "packageName") + attr("type", "text") + attr("placeholder", "") + classes("form-control") + on("input") { + (it.target as? HTMLInputElement)?.value?.let { packageName -> + selectedPackageName = packageName + fireTableRefreshEvent( + inputElement + ) + } + } + } + } + } + } + } + } + div { + classes("d-flex") + text("Architecture:") + div { + classes("form-check") + pkgArchive.then { pkgArchive -> + for (arch in pkgArchive.pkgMap.keys) { + val id = Random.Default.nextBytes(4).toHexString() + div { + classes("form-check", "form-check-inline") + input { inputElement -> + attr("id", id) + attr("type", "radio") + attr("autocomplete", "off") + attr("name", "arch") + classes("form-check-input") + on("input") { inputEvent -> + (inputEvent.target as? HTMLInputElement) + ?.takeIf(HTMLInputElement::checked) + ?.let { + selectedArchitecture = arch + } + (inputEvent.target as? HTMLInputElement)?.value?.let { + fireTableRefreshEvent(inputElement) + } + } + } + label { + classes("form-check-label") + attr("for", id) + text(arch) + } + } + } + } + } + } + div { table -> + classes("row", "align-items-md-stretch") + root.on(REFRESH_TABLE_EVENT_NAME) { evt -> + launch { + (evt as? CustomEvent)?.let { pnuv -> + table.let { t -> + selectedPackageName?.let { pkgName -> + t.removeChildren() + buildTable(t, pkgName, commonElement) + } + } + } + } + } + } +} + +fun main(vararg args: String) { + HtmlBuilder.of(document, document.body as HTMLElement) { + main { main -> + div { + classes("container", "py-4") + div { + classes("p-5", "mb-4", "bg-body-tertiary", "rounded-3") + div { + classes("container-fluid", "py-5") + h1 { + classes("display-5", "fw-bold") + text("Jpacrepo") + } + p { + classes("col-md-8", "fs-4") + text("Personal archlinux package repository") + } + } + } + div { + classes("container") + div { + val commonElement = el + classes("row", "align-items-start") + div { + classes("col-3") + div { + classes("card") + div { + classes("card-header") + span { + classes("fs-5", "fw-semibold") + text("Package Cart") + } + } + fun HtmlBuilder.createSelectedPackageList() { + if (selectedPackages.isNotEmpty()) { + div { + classes("card-body") + ul { + classes("list-group") + for (selectedPackage in selectedPackages) { + li { + classes( + "list-group-item", + "d-flex", + "justify-content-between", + "align-items-start" + ) + text(selectedPackage.fileName) + i { + classes("bi-x-lg") + attr("style", "font-size: 1.5rem; color: grey;") + on("click") { + selectedPackages.remove(selectedPackage) + fireSelectedPackagesRefreshEvent(commonElement) + } + } + } + } + } + button { + classes("btn", "btn-primary", "w-100") + el.asDynamic().style.marginTop = "10px" + val totalSize = selectedPackages + .asSequence() + .map(PkgTuple::size) + .fold(0L, Long::plus) + text("Download (${totalSize.toHumanReadableByteSize()})") + on("click") { + val temporaryForm = + document.createElement("form") as HTMLFormElement + temporaryForm.method = "post" + temporaryForm.action = + SERVER_PREFIX + "api/pkg/downloadTar" + temporaryForm.asDynamic().style.display = "none" + val textField = document.createElement("input") as HTMLInputElement + textField.setAttribute("name", "pkgs") + textField.value = selectedPackages + .asSequence() + .map(PkgTuple::fileName) + .joinToString(" ") + temporaryForm.appendChild(textField) + el.appendChild(temporaryForm) + temporaryForm.submit() + el.removeChild(temporaryForm) + } + } + } + } + } + div { + createSelectedPackageList() + commonElement.on(REFRESH_SELECTED_PACKAGES_EVENT_NAME) { + el.removeChildren() + createSelectedPackageList() + } + } + } + } + div { + classes("col-9") + buildPackageSearchSection(commonElement) + } + el + } + } + footer { + classes("pt-3", "mt-4", "text-body-secondary", "border-top") + text("©2024") + } + } + } + } +} + +@Serializable +enum class CompressionFormat(private val value: String) { + XZ("xz"), GZIP("gz"), Z_STANDARD("zst"); + + override fun toString(): String { + return value + } +} + +data class PkgId( + var arch: String, + var name: String, + var version: String, + var compressionFormat: CompressionFormat, +) + +@Serializable +data class PkgTuple( + var md5sum: String, + var fileName: String, + var size: Long, +) + diff --git a/jpacrepo-frontend/src/jsMain/resources/css/jpacrepo.css b/jpacrepo-frontend/src/jsMain/resources/css/jpacrepo.css new file mode 100644 index 0000000..44e50ed --- /dev/null +++ b/jpacrepo-frontend/src/jsMain/resources/css/jpacrepo.css @@ -0,0 +1,11 @@ +svg.bi-plus-circle { + color: red; +} + +.table > tbody:nth-child(2) > tr > td:nth-child(1) > i { + visibility : hidden; +} + +.table > tbody:nth-child(2) > tr:hover > td:nth-child(1) > i { + visibility : visible; +} diff --git a/jpacrepo-frontend/src/jsMain/resources/index.html b/jpacrepo-frontend/src/jsMain/resources/index.html new file mode 100644 index 0000000..e1adc0b --- /dev/null +++ b/jpacrepo-frontend/src/jsMain/resources/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + Hello World! + + \ No newline at end of file diff --git a/jpacrepo-frontend/src/main/kotlin/net/woggioni/jpacrepo/DocumentWalk.kt b/jpacrepo-frontend/src/main/kotlin/net/woggioni/jpacrepo/DocumentWalk.kt deleted file mode 100644 index 6792ad8..0000000 --- a/jpacrepo-frontend/src/main/kotlin/net/woggioni/jpacrepo/DocumentWalk.kt +++ /dev/null @@ -1,70 +0,0 @@ -package net.woggioni.jpacrepo - -import org.w3c.dom.Document -import org.w3c.dom.Element - - -class HtmlBuilder private constructor(private val doc : Document, val el: Element) { - - companion object { - fun of(doc : Document, el: Element, cb : HtmlBuilder.(el : Element) -> T) : T { - return HtmlBuilder(doc, el).cb(el) - } - } - - private inline fun dfd( - name : String, - attrs : Map, - cb : HtmlBuilder.(el : Element) -> T) : T { - val child = doc.createElement(name) - for((key, value) in attrs) { - child.setAttribute(key, value) - } - el.appendChild(child) - return HtmlBuilder(doc, child).cb(child) - } - - fun html(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("html", attrs, cb) - fun head(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("head", attrs, cb) - fun body(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("body", attrs, cb) - - fun div(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("div", attrs, cb) - fun header(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("header", attrs, cb) - fun main(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("main", attrs, cb) - fun footer(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("footer", attrs, cb) - fun a(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("a", attrs, cb) - fun meta(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("meta", attrs, cb) - fun script(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("script", attrs, cb) - fun link(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("link", attrs, cb) - fun title(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("title", attrs, cb) - fun p(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("p", attrs, cb) - - fun h1(attrs : Map = emptyMap(), - cb : HtmlBuilder.(el : Element) -> T) : T = dfd("h1", attrs, cb) - - - fun classes(vararg classes : String) { - for(cls in classes) el.classList.add(cls) - } - - fun attr(key: String, value : String) { - el.setAttribute(key, value) - } - - fun text(txt : String) { - el.textContent = txt - } -} diff --git a/jpacrepo-frontend/src/main/kotlin/net/woggioni/jpacrepo/Main.kt b/jpacrepo-frontend/src/main/kotlin/net/woggioni/jpacrepo/Main.kt deleted file mode 100644 index fa49ab5..0000000 --- a/jpacrepo-frontend/src/main/kotlin/net/woggioni/jpacrepo/Main.kt +++ /dev/null @@ -1,61 +0,0 @@ -package net.woggioni.jpacrepo - -import kotlinx.browser.document -import net.woggioni.jpacrepo.Component.container -import org.w3c.dom.HTMLElement - - -object Component { - val container = document.getElementById("container") as HTMLElement - - fun sayHelloViaDom() { - container.textContent = "Hello, DOM! Kotlin is writing…" - } -} - -fun main(vararg args: String) { -// println("JavaScript generated through Kotlin") -// -// Component.sayHelloViaDom() -// sayHelloViaJsConsole() -// sayHelloViaInlinedJavaScript() - - HtmlBuilder.of(document, document.body as HTMLElement) { - classes("d-flex", "h-100", "text-center", "text-white", "bg-dark") - div { - classes("cover-container", "d-flex", "w-100", "h-100", "p-3", "mx-auto", "flex-column") - - } - header { - classes("mb-auto") - } - main { - classes("px-3") - h1 { - text("Cover your page.") - } - p { - classes("lead") - text("Cover is a one-page template for building simple and beautiful home pages. Download, edit the text, and add your own fullscreen background photo to make it your own.") - } - p { - classes("lead") - a { - classes("btn", "btn-lg", "btn-secondary", "fw-bold", "border-white", "bg-white") - text("Learn more") - } - } - } - footer { - classes("mt-auto", "text-white-50") - } - } -} - -private fun sayHelloViaJsConsole() { - console.log("Hello from `console.log()`!") -} - -private fun sayHelloViaInlinedJavaScript() { - js("document.writeln('Hello, from inlined JavaScript in Kotlin!')") -} \ No newline at end of file diff --git a/jpacrepo-frontend/src/main/resources/index.html b/jpacrepo-frontend/src/main/resources/index.html deleted file mode 100644 index f2d9c01..0000000 --- a/jpacrepo-frontend/src/main/resources/index.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - Hello World! - \ No newline at end of file diff --git a/jpacrepo-impl/src/main/java/net/woggioni/jpacrepo/impl/model/PkgDataParser.java b/jpacrepo-impl/src/main/java/net/woggioni/jpacrepo/impl/model/PkgDataParser.java index b8beeed..382a2bd 100644 --- a/jpacrepo-impl/src/main/java/net/woggioni/jpacrepo/impl/model/PkgDataParser.java +++ b/jpacrepo-impl/src/main/java/net/woggioni/jpacrepo/impl/model/PkgDataParser.java @@ -310,7 +310,7 @@ public class PkgDataParser { List value = entry.getValue(); switch (key) { case "size": - data.setSize(Long.parseLong(value.get(0))); + data.setInstalledSize(Long.parseLong(value.get(0))); break; case "arch": data.getPkgId().setArch(value.get(0)); @@ -395,6 +395,7 @@ public class PkgDataParser { data.setMd5sum(Hash.hash(Hash.Algorithm.MD5, fis).getBytes()); } data.setFileName(file.getFileName().toString()); + data.setSize(Files.size(file)); return data; } } else { diff --git a/src/main/java/net/woggioni/jpacrepo/service/PackageSynchronizerEJB.java b/src/main/java/net/woggioni/jpacrepo/service/PackageSynchronizerEJB.java index 952745d..3853809 100644 --- a/src/main/java/net/woggioni/jpacrepo/service/PackageSynchronizerEJB.java +++ b/src/main/java/net/woggioni/jpacrepo/service/PackageSynchronizerEJB.java @@ -110,7 +110,6 @@ public class PackageSynchronizerEJB implements FileSystemSynchronizer { long[] count = new long[]{0}; long totalPackages = fileListStreamSupplier.get().count(); var parser = new PkgDataParser(em); -// List stack = new ArrayList<>(); Con persistPackages = (Boolean drain) -> { while (inProgress.size() > maxInProgress || (drain && !inProgress.isEmpty())) { Optional.ofNullable(completionService.poll(1, TimeUnit.SECONDS)) @@ -123,16 +122,6 @@ public class PackageSynchronizerEJB implements FileSystemSynchronizer { throw ee.getCause(); } persistPackage(em, parser, pkgData, ++count[0], totalPackages); -// stack.add(pkgData); -// if(stack.size() >= 1000 || drain) { -// parser.addNewDependencies(parser.getNewDependencies(stack)); -// parser.addNewPackagers(parser.getNewPackagers(stack)); -// parser.addNewPkgBases(parser.getNewPkgBases(stack)); -// parser.addNewLicenses(parser.getNewLicenses(stack)); -// while(!stack.isEmpty()) { -// persistPackage(em, parser, stack.removeLast(), ++count[0], totalPackages); -// } -// } }); } }; diff --git a/src/main/java/net/woggioni/jpacrepo/service/PacmanServiceEJB.java b/src/main/java/net/woggioni/jpacrepo/service/PacmanServiceEJB.java index 4648a4c..3e37f93 100644 --- a/src/main/java/net/woggioni/jpacrepo/service/PacmanServiceEJB.java +++ b/src/main/java/net/woggioni/jpacrepo/service/PacmanServiceEJB.java @@ -204,13 +204,16 @@ public class PacmanServiceEJB implements PacmanServiceLocal { else { try (OutputStream output = Files.newOutputStream(file)) { JWO.copy(input, output, 0x10000); + var parser = new PkgDataParser(em); PkgData pkg = PkgDataParser.parseFile(file, CompressionFormatImpl.guess(Paths.get(fileName))); pkg.setFileName(fileName); + pkg = parser.hydrateJPA(pkg); getPackage(pkg.getPkgId()).ifPresent((Con) (pkgData -> { em.remove(pkgData); Files.delete(ctx.getRepoFolder().resolve(pkgData.getFileName())); })); + logger.info("Persisting package {}", pkg.getFileName()); em.persist(pkg); Files.move(file, ctx.getRepoFolder().resolve(fileName), StandardCopyOption.ATOMIC_MOVE); diff --git a/src/main/java/net/woggioni/jpacrepo/service/PacmanWebService.java b/src/main/java/net/woggioni/jpacrepo/service/PacmanWebService.java index ede0ffe..8833f2c 100644 --- a/src/main/java/net/woggioni/jpacrepo/service/PacmanWebService.java +++ b/src/main/java/net/woggioni/jpacrepo/service/PacmanWebService.java @@ -45,6 +45,7 @@ import net.woggioni.jpacrepo.api.wire.StringList; import net.woggioni.jpacrepo.config.AppConfig; import net.woggioni.jpacrepo.version.VersionComparator; import net.woggioni.jwo.CollectionUtils; +import net.woggioni.jwo.Fun; import net.woggioni.jwo.JWO; import org.apache.commons.compress.archivers.tar.TarArchiveEntry; import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; @@ -65,6 +66,8 @@ import java.util.NavigableMap; import java.util.NavigableSet; import java.util.Optional; import java.util.TreeMap; +import java.util.function.Function; +import java.util.function.ToLongFunction; import java.util.stream.Collectors; import static net.woggioni.jpacrepo.api.security.Roles.WRITER; @@ -140,19 +143,17 @@ public class PacmanWebService { EntityTag etag = new EntityTag(Integer.toString(cachedMap.hashCode()), true); Response.ResponseBuilder builder = request.evaluatePreconditions(etag); if (builder == null) { - - TreeMap>>> result = cachedMap.entrySet().stream().collect( + TreeMap>>> result = cachedMap.entrySet().stream().collect( Collectors.groupingBy((Map.Entry entry) -> entry.getKey().getArch(), TreeMap::new, Collectors.groupingBy((Map.Entry entry) -> entry.getKey().getName(), TreeMap::new, Collectors.groupingBy((Map.Entry entry) -> entry.getKey().getVersion(), () -> new TreeMap<>(VersionComparator.getInstance().reversed()), - Collectors.mapping( - Map.Entry::getValue, - CollectionUtils.toUnmodifiableTreeSet( - Comparator.comparing(PkgTuple::getFileName) - ) + CollectionUtils.toMap( + TreeMap::new, + (Map.Entry entry) -> entry.getKey().getCompressionFormat(), + Map.Entry::getValue ) ) ) @@ -305,6 +306,11 @@ public class PacmanWebService { @Consumes(MediaType.APPLICATION_FORM_URLENCODED) public Response downloadTar(@FormParam("pkgs") String formData) { String[] files = URLDecoder.decode(formData, StandardCharsets.UTF_8).split(" "); + Fun fun = Files::size; + long estimatedSize = Arrays.stream(files) + .map(ctx::getFile) + .mapToLong(fun::apply) + .sum(); Arrays.stream(files) .filter(fileName -> !Files.exists(ctx.getFile(fileName))) .forEach(fileName -> { @@ -318,11 +324,11 @@ public class PacmanWebService { public void write(OutputStream output) { try(TarArchiveOutputStream taos = new TarArchiveOutputStream(output)) { for (String fname : files) { - log.info(fname); java.nio.file.Path file = ctx.getFile(fname); try(InputStream input = Files.newInputStream(file)) { TarArchiveEntry entry = new TarArchiveEntry(fname); - entry.setSize(Files.size(file)); + long fileSize = Files.size(file); + entry.setSize(fileSize); taos.putArchiveEntry(entry); JWO.copy(input, taos, buffer); taos.closeArchiveEntry(); @@ -334,7 +340,10 @@ public class PacmanWebService { } } }; - return Response.ok(stream).header("Content-Disposition", "attachment; filename=pkgs.tar").build(); + return Response.ok(stream) + .header("Content-Length", estimatedSize) + .header("Content-Disposition", "attachment; filename=pkgs.tar") + .build(); } @GET diff --git a/src/main/java/net/woggioni/jpacrepo/service/jpa/Queries.java b/src/main/java/net/woggioni/jpacrepo/service/jpa/Queries.java index 2b19338..80af411 100644 --- a/src/main/java/net/woggioni/jpacrepo/service/jpa/Queries.java +++ b/src/main/java/net/woggioni/jpacrepo/service/jpa/Queries.java @@ -151,7 +151,7 @@ public class Queries { public static TypedQuery getPackageById(EntityManager em, PkgId pkgId) { var cb = em.getCriteriaBuilder(); var cq = cb.createQuery(PkgData.class); - var root = cq.from(PkgData_.class_); + var root = cq.from(PkgData.class); cq.select(root).where( cb.equal( root.get(PkgData_.pkgId), diff --git a/src/main/resources/META-INF/liquibase/jpacrepo.xml b/src/main/resources/META-INF/liquibase/jpacrepo.xml index c14c5df..c252255 100644 --- a/src/main/resources/META-INF/liquibase/jpacrepo.xml +++ b/src/main/resources/META-INF/liquibase/jpacrepo.xml @@ -12,6 +12,9 @@ + + + diff --git a/src/test/java/ClientTest.java b/src/test/java/ClientTest.java index da96c6a..63918fb 100644 --- a/src/test/java/ClientTest.java +++ b/src/test/java/ClientTest.java @@ -10,7 +10,7 @@ import jakarta.ws.rs.core.Response; import jakarta.ws.rs.core.UriBuilder; import lombok.SneakyThrows; import net.woggioni.jpacrepo.api.model.PkgData; -import net.woggioni.jpacrepo.api.service.PacmanServiceRemote; +import net.woggioni.jpacrepo.api.service.FileSystemSynchronizer; import net.woggioni.jpacrepo.impl.model.CompressionFormatImpl; import net.woggioni.jpacrepo.impl.model.PkgDataParser; import net.woggioni.jwo.Con; @@ -137,12 +137,12 @@ public class ClientTest { Context ctx = new InitialContext(prop); traverseJndiNode("/", context); // final PacmanService stateService = (PacmanService) ctx.lookup("/jpacrepo-1.0/remote/PacmanServiceEJB!service.PacmanService"); - final PacmanServiceRemote service = (PacmanServiceRemote) ctx.lookup( - "/jpacrepo/PacmanServiceEJB!net.woggioni.jpacrepo.api.service.PacmanServiceRemote" + final FileSystemSynchronizer service = (FileSystemSynchronizer) ctx.lookup( + "/jpacrepo/PackageSynchronizerEJB!net.woggioni.jpacrepo.api.service.FileSystemSynchronizer" ); // List pkgs = service.searchPackage("google-earth", null, null, 1, 10); // System.out.println(new XStream().toXML(pkgs)); - service.syncDB(); + service.syncDb(); // service.searchPkgId("jre8-openjdk", null, null, null) // .stream() // .map(service::getPackage)