diff --git a/build.gradle.kts b/build.gradle.kts index a368036..9e98272 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -236,7 +236,7 @@ tasks { if (platform.isForge && platform.mcVersion >= 12100) { exclude("forge.mixins.resourcify.refmap.json") - archiveFileName.set("$mod_name (${getPrettyVersionRange()}-${platform.loaderStr})-${mod_version}.jar") + archiveFileName.set("$mod_name (${getPrettyVersionRange(true)}-${platform.loaderStr})-${mod_version}.jar") } } remapJar { @@ -247,7 +247,7 @@ tasks { input.set(shadowJar.get().archiveFile) archiveClassifier.set("") finalizedBy("copyJar") - archiveFileName.set("$mod_name (${getPrettyVersionRange()}-${platform.loaderStr})-${mod_version}.jar") + archiveFileName.set("$mod_name (${getPrettyVersionRange(true)}-${platform.loaderStr})-${mod_version}.jar") if (platform.isForgeLike && platform.mcVersion >= 12004) { atAccessWideners.add("resourcify.accesswidener") } @@ -374,10 +374,10 @@ fun getSupportedVersionRange(): Pair = when (platform.mcVersion else -> error("Undefined version range for ${platform.mcVersion}") } -fun getPrettyVersionRange(): String { +fun getPrettyVersionRange(forFile: Boolean = false): String { val supportedVersionRange = getSupportedVersionRange() return when { - supportedVersionRange.first == "1.21.2" -> "1.21.3-4" + supportedVersionRange.first == "1.21.2" -> if (forFile) "1.21.3-4" else "1.21.3/4" supportedVersionRange.first == "1.21.1" -> "1.21.1" supportedVersionRange.first == supportedVersionRange.second -> supportedVersionRange.first listOf("1.16", "1.18").contains(supportedVersionRange.first) -> "${supportedVersionRange.first}.x" diff --git a/changelog.md b/changelog.md index 00849a4..1fdaaf4 100644 --- a/changelog.md +++ b/changelog.md @@ -1,7 +1,15 @@ -## Resourcify 1.5.2 +## Resourcify 1.6.0 -- When possible, links to modrinth in a project description will now be opened in Resourcify. +### New features + +- Add **CurseForge support for updating**! Resourcify will now check both Modrinth and Curseforge for updates, and you + can choose which source to use per project. +- When possible, links to modrinth in a project description will now be opened in Resourcify (this is configurable in + the config). - Add support for side mouse buttons to go back and forward between pages. + +### Fixes + - Fix CurseForge version filter using major version instead of exact version. - You can now select multiple Minecraft versions when using the CurseForge source. diff --git a/gradle.properties b/gradle.properties index 6f457bd..d77e361 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ mod_name = Resourcify mod_id = resourcify -mod_version = 1.5.2 +mod_version = 1.6.0 org.gradle.daemon=true org.gradle.parallel=true diff --git a/src/main/java/dev/dediamondpro/resourcify/util/MurmurHash2.java b/src/main/java/dev/dediamondpro/resourcify/util/MurmurHash2.java new file mode 100644 index 0000000..102635b --- /dev/null +++ b/src/main/java/dev/dediamondpro/resourcify/util/MurmurHash2.java @@ -0,0 +1,87 @@ +/* + * This file is part of Resourcify + * Copyright (C) 2024 DeDiamondPro + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License Version 3 as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package dev.dediamondpro.resourcify.util; + +// Written in java because I don't like kotlin's bit operators +public class MurmurHash2 { + private static boolean isWhiteSpaceCharacter(byte b) { + return b == 9 || b == 10 || b == 13 || b == 32; + } + + public static long cfHash(byte[] data, int length) { + // "Normalize" the byte array + // Adapted from https://github.com/CurseForgeCommunity/.NET-APIClient/blob/2c4f5f613d20025f9286fdd53592f8519022918f/Murmur2.cs + // To avoid creating a copy in memory we will shift the existing data in the array + int shiftCount = 0; + for (int i = 0; i < length; i++) { + if (isWhiteSpaceCharacter(data[i])) { + shiftCount++; + } else { + data[i - shiftCount] = data[i]; + } + } + int hash = hash32(data, length - shiftCount, 1); + // Extend to long without extending the sign, avoid getting negative numbers + return hash & 0xFFFFFFFFL; + } + + /** + * Taken from https://github.com/tnm/murmurhash-java/blob/1cef5b1bdb1856d1d4d48b5572f35baacb57d0f5/src/main/java/ie/ucd/murmur/MurmurHash.java#L33-L67 + * Under the public domain + */ + public static int hash32(final byte[] data, int length, int seed) { + // 'm' and 'r' are mixing constants generated offline. + // They're not really 'magic', they just happen to work well. + final int m = 0x5bd1e995; + final int r = 24; + + // Initialize the hash to a random value + int h = seed ^ length; + int length4 = length / 4; + + for (int i = 0; i < length4; i++) { + final int i4 = i * 4; + int k = (data[i4] & 0xff) + + ((data[i4 + 1] & 0xff) << 8) + + ((data[i4 + 2] & 0xff) << 16) + + ((data[i4 + 3] & 0xff) << 24); + k *= m; + k ^= k >>> r; + k *= m; + h *= m; + h ^= k; + } + + // Handle the last few bytes of the input array + switch (length % 4) { + case 3: + h ^= (data[(length & ~3) + 2] & 0xff) << 16; + case 2: + h ^= (data[(length & ~3) + 1] & 0xff) << 8; + case 1: + h ^= (data[length & ~3] & 0xff); + h *= m; + } + + h ^= h >>> 13; + h *= m; + h ^= h >>> 15; + + return h; + } +} diff --git a/src/main/kotlin/dev/dediamondpro/resourcify/config/SettingsPage.kt b/src/main/kotlin/dev/dediamondpro/resourcify/config/SettingsPage.kt index fef5ea0..98ae527 100644 --- a/src/main/kotlin/dev/dediamondpro/resourcify/config/SettingsPage.kt +++ b/src/main/kotlin/dev/dediamondpro/resourcify/config/SettingsPage.kt @@ -55,37 +55,10 @@ class SettingsPage() : PaginatedScreen(adaptScale = false) { } childOf mainBox // Source - val sourceBox = UIBlock(Color(0, 0, 0, 100)).constrain { - x = 0.pixels() - y = SiblingConstraint(padding = 4f) - width = 100.percent() - height = ChildBasedMaxSizeConstraint() + 4.pixels() - } childOf mainBox - val sourceDescriptionBox = UIContainer().constrain { - x = 4.pixels() - y = 4.pixels() - width = 100.percent() - 168.pixels() - height = ChildLocationSizeConstraint() - } childOf sourceBox - UIWrappedText("resourcify.config.source.title".localize()).constrain { - width = 100.percent() - } childOf sourceDescriptionBox - UIWrappedText("resourcify.config.source.description".localize()).constrain { - y = SiblingConstraint(padding = 4f) - width = 100.percent() - color = Color.LIGHT_GRAY.toConstraint() - } childOf sourceDescriptionBox - DropDown( - ServiceRegistry.getAllServices().map { it.getName() }, - true, mutableListOf(Config.instance.defaultService) - ).constrain { - x = 4.pixels(true) - y = CenterConstraint() - width = 160.pixels() - }.onSelectionUpdate { - Config.instance.defaultService = it.first() - Config.save() - } childOf sourceBox + val allServices = ServiceRegistry.getAllServices().map { it.getName() } + addDropdownOption("resourcify.config.source", allServices, Config.instance.defaultService) { + Config.instance.defaultService = it + } // Thumbnail quality addCheckBoxOption("resourcify.config.thumbnail", Config.instance.fullResThumbnail) { @@ -146,4 +119,44 @@ class SettingsPage() : PaginatedScreen(adaptScale = false) { Config.save() } childOf box } + + private fun addDropdownOption( + localizationString: String, + options: List, + selectedOption: String, + onUpdate: (String) -> Unit + ) { + val box = UIBlock(Color(0, 0, 0, 100)).constrain { + x = 0.pixels() + y = SiblingConstraint(padding = 4f) + width = 100.percent() + height = ChildBasedMaxSizeConstraint() + 4.pixels() + } childOf mainBox + val sourceDescriptionBox = UIContainer().constrain { + x = 4.pixels() + y = 4.pixels() + width = 100.percent() - 168.pixels() + height = ChildLocationSizeConstraint() + } childOf box + UIWrappedText("$localizationString.title".localize()).constrain { + width = 100.percent() + } childOf sourceDescriptionBox + UIWrappedText("$localizationString.description".localize()).constrain { + y = SiblingConstraint(padding = 4f) + width = 100.percent() + color = Color.LIGHT_GRAY.toConstraint() + } childOf sourceDescriptionBox + DropDown( + options, true, mutableListOf( + if (options.contains(selectedOption)) selectedOption else options.first() + ) + ).constrain { + x = 4.pixels(true) + y = CenterConstraint() + width = 160.pixels() + }.onSelectionUpdate { + onUpdate(it.first()) + Config.save() + } childOf box + } } \ No newline at end of file diff --git a/src/main/kotlin/dev/dediamondpro/resourcify/gui/update/UpdateGui.kt b/src/main/kotlin/dev/dediamondpro/resourcify/gui/update/UpdateGui.kt index 2cdc67b..2518e32 100644 --- a/src/main/kotlin/dev/dediamondpro/resourcify/gui/update/UpdateGui.kt +++ b/src/main/kotlin/dev/dediamondpro/resourcify/gui/update/UpdateGui.kt @@ -21,14 +21,12 @@ import dev.dediamondpro.resourcify.constraints.ChildLocationSizeConstraint import dev.dediamondpro.resourcify.gui.PaginatedScreen import dev.dediamondpro.resourcify.gui.update.components.UpdateCard import dev.dediamondpro.resourcify.mixins.PackScreenAccessor -import dev.dediamondpro.resourcify.gui.update.modrinth.ModrinthUpdateFormat -import dev.dediamondpro.resourcify.services.modrinth.FullModrinthProject import dev.dediamondpro.resourcify.platform.Platform -import dev.dediamondpro.resourcify.services.IVersion -import dev.dediamondpro.resourcify.services.ProjectType -import dev.dediamondpro.resourcify.services.modrinth.ModrinthService -import dev.dediamondpro.resourcify.services.modrinth.ModrinthVersion -import dev.dediamondpro.resourcify.util.* +import dev.dediamondpro.resourcify.services.* +import dev.dediamondpro.resourcify.util.PackUtils +import dev.dediamondpro.resourcify.util.localize +import dev.dediamondpro.resourcify.util.markdown +import dev.dediamondpro.resourcify.util.supplyAsync import gg.essential.elementa.UIComponent import gg.essential.elementa.components.* import gg.essential.elementa.constraints.CenterConstraint @@ -39,34 +37,15 @@ import gg.essential.universal.ChatColor import gg.essential.universal.UKeyboard import gg.essential.universal.UMinecraft import net.minecraft.client.gui.GuiScreen -import org.apache.http.client.utils.URIBuilder import java.awt.Color import java.io.File -import java.net.URL +import java.util.concurrent.CompletableFuture //#if MC >= 11904 //$$ import dev.dediamondpro.resourcify.mixins.ResourcePackOrganizerAccessor //#endif class UpdateGui(val type: ProjectType, private val folder: File) : PaginatedScreen() { - private val hashes = supplyAsync { - val files = PackUtils.getPackFiles(folder) - files.associateBy { Utils.getSha1(it)!! } - } - private val updates = supplyAsync { - getUpdates(type, hashes.get().keys.toList()).map { (k, v) -> v to k }.toMap() - } - private val mods = updates.thenApply { updates -> - if (updates.isEmpty()) return@thenApply emptyMap() - val idString = updates.keys.joinToString(",", "[", "]") { "\"${it.getProjectId()}\"" } - URIBuilder("${ModrinthService.API}/projects").setParameter("ids", idString) - .build().toURL().getJson>()!! - .map { project -> project to updates.keys.first { it.getProjectId() == project.getId() } } - .sortedBy { (_, newVersion) -> - if (Platform.getSelectedResourcePacks().contains(hashes.get()[updates[newVersion]]!!)) 0 - else 1 - }.toMap() - } private val cards = mutableListOf() private var topText: UIText? = null private var startSize = 0 @@ -130,7 +109,7 @@ class UpdateGui(val type: ProjectType, private val folder: File) : PaginatedScre color = Color.YELLOW.toConstraint() } childOf window - mods.exceptionally { + getUpdates().exceptionally { it.printStackTrace() emptyMap() }.thenAccept { projects -> @@ -191,8 +170,8 @@ class UpdateGui(val type: ProjectType, private val folder: File) : PaginatedScre y = CenterConstraint() } childOf topBar - cards.addAll(projects.filter { it.value.getDownloadUrl() != null }.map { (project, newVersion) -> - UpdateCard(project, newVersion, hashes.get()[updates.get()[newVersion]]!!, this).constrain { + cards.addAll(projects.map { (file, data) -> + UpdateCard(data, file, this).constrain { y = SiblingConstraint(padding = 2f) width = 100.percent() } childOf updateContainer @@ -209,6 +188,51 @@ class UpdateGui(val type: ProjectType, private val folder: File) : PaginatedScre } } + private fun getUpdates(): CompletableFuture>> { + val files = PackUtils.getPackFiles(folder) + + // Fetch updates from each service + val futures = mutableMapOf>>() + for (service in ServiceRegistry.getAllServices()) { + val future = service.getUpdates(files, type).thenApply { updates -> + val ids = updates.values.filterNotNull().map { it.getProjectId() } + if (ids.isEmpty()) { + return@thenApply emptyMap() + } + val projects = service.getProjectsFromIds(ids) + // Add project into map + return@thenApply updates.filter { it.value == null || projects.containsKey(it.value!!.getProjectId()) } + .map { + it.key to if (it.value == null) null else UpdateData( + projects[it.value!!.getProjectId()]!!, + it.value!! + ) + } + .toMap() + } + futures[service] = future + } + + // Aggregate results + return supplyAsync { + val updates = mutableMapOf>() + for ((source, future) in futures) { + val result = future.exceptionally { + it.printStackTrace() + emptyMap() + }.get() + for ((file, project) in result) { + if (!updates.containsKey(file)) { + updates[file] = mutableMapOf() + } + updates[file]!![source] = project + } + } + // Do not include it if it is up to date at every available service + return@supplyAsync updates.filter { it.value.values.any { data -> data != null } } + } + } + fun registerUpdate(updateCard: UpdateCard, reload: Boolean) { selectedUpdates.add(updateCard) if (reload) reloadOnClose = true @@ -233,7 +257,7 @@ class UpdateGui(val type: ProjectType, private val folder: File) : PaginatedScre } } - fun showChangeLog(project: FullModrinthProject, version: IVersion, updateButton: UIComponent) { + fun showChangeLog(project: IProject, version: IVersion, updateButton: UIComponent) { updateContainer.hide() changelogContainer.constrain { x = (this@UpdateGui as GuiScreen).width.pixels() } changelogContainer.clearChildren() @@ -320,32 +344,5 @@ class UpdateGui(val type: ProjectType, private val folder: File) : PaginatedScre } } - companion object { - private val updateInfo = mutableMapOf() - - fun getUpdates(type: ProjectType, hashes: List): Map { - fetchUpdates(type, hashes.filter { !updateInfo.containsKey(it) }, hashes) - return hashes.filter { updateInfo[it] != null }.associateWith { updateInfo[it]!! } - } - - private fun fetchUpdates(type: ProjectType, hashes: List, allHashes: List) { - if (hashes.isEmpty()) return - val loader = when (type) { - ProjectType.RESOURCE_PACK, ProjectType.AYCY_RESOURCE_PACK -> "minecraft" - ProjectType.IRIS_SHADER -> "iris" - ProjectType.OPTIFINE_SHADER -> "optifine" - else -> return - } - val data = ModrinthUpdateFormat(loaders = listOf(loader), hashes = hashes) - val updates: Map = - URL("${ModrinthService.API}/version_files/update").postAndGetJson(data) ?: return - hashes.forEach { hash -> - updateInfo[hash] = if (updates.containsKey(hash)) { - if (allHashes.contains(updates[hash]!!.getSha1())) null else updates[hash] - } else { - null - } - } - } - } + data class UpdateData(val project: IProject, val version: IVersion) } \ No newline at end of file diff --git a/src/main/kotlin/dev/dediamondpro/resourcify/gui/update/components/UpdateCard.kt b/src/main/kotlin/dev/dediamondpro/resourcify/gui/update/components/UpdateCard.kt index 597d55a..43c3c01 100644 --- a/src/main/kotlin/dev/dediamondpro/resourcify/gui/update/components/UpdateCard.kt +++ b/src/main/kotlin/dev/dediamondpro/resourcify/gui/update/components/UpdateCard.kt @@ -17,9 +17,12 @@ package dev.dediamondpro.resourcify.gui.update.components +import dev.dediamondpro.resourcify.config.Config +import dev.dediamondpro.resourcify.elements.DropDown import dev.dediamondpro.resourcify.gui.update.UpdateGui -import dev.dediamondpro.resourcify.services.modrinth.FullModrinthProject import dev.dediamondpro.resourcify.platform.Platform +import dev.dediamondpro.resourcify.services.IProject +import dev.dediamondpro.resourcify.services.IService import dev.dediamondpro.resourcify.services.IVersion import dev.dediamondpro.resourcify.services.ProjectType import dev.dediamondpro.resourcify.util.* @@ -43,12 +46,12 @@ import java.util.concurrent.locks.ReentrantLock //#endif class UpdateCard( - project: FullModrinthProject, - private val newVersion: IVersion, + private val data: Map, val file: File, private val gui: UpdateGui ) : UIBlock(color = Color(0, 0, 0, 100)) { - private val updateUrl = newVersion.getDownloadUrl()!! + private var selectedService: IService + private var selectedData: UpdateGui.UpdateData? private var progressBox: UIBlock? = null private var text: UIText? = null @@ -57,6 +60,12 @@ class UpdateCard( height = 56.pixels() } + selectedService = data.keys.firstOrNull { it.getName() == Config.instance.defaultService } ?: data.keys.first() + selectedData = data[selectedService] + createCard(getProject(), selectedData?.version) + } + + private fun createCard(project: IProject, version: IVersion?) { val iconUrl = project.getIconUrl() if (iconUrl == null) { UIImage.ofResourceCustom("/assets/resourcify/pack.png") @@ -73,47 +82,83 @@ class UpdateCard( y = 8.pixels() textScale = 2.pixels() } childOf this - UIText(newVersion.getName()).constrain { - x = 56.pixels() - y = SiblingConstraint(padding = 4f) - } childOf this - val versionNumberHolder = UIContainer().constrain { - x = 56.pixels() - y = SiblingConstraint(padding = 4f) - } childOf this - UIText(newVersion.getVersionType().localizedName.localize()).constrain { - x = 0.pixels() - y = 0.pixels() - color = newVersion.getVersionType().color.toConstraint() - } childOf versionNumberHolder - newVersion.getVersionNumber()?.let { - UIText(it).constrain { - x = SiblingConstraint(padding = 4f) + + if (version == null) { + UIText("${ChatColor.YELLOW}${"resourcify.updates.up-to-date".localize()}").constrain { + x = 56.pixels() + y = SiblingConstraint(padding = 4f) + } childOf this + } else { + UIText(version.getName()).constrain { + x = 56.pixels() + y = SiblingConstraint(padding = 4f) + } childOf this + val versionNumberHolder = UIContainer().constrain { + x = 56.pixels() + y = SiblingConstraint(padding = 4f) + } childOf this + UIText(version.getVersionType().localizedName.localize()).constrain { + x = 0.pixels() y = 0.pixels() + color = version.getVersionType().color.toConstraint() } childOf versionNumberHolder + version.getVersionNumber()?.let { + UIText(it).constrain { + x = SiblingConstraint(padding = 4f) + y = 0.pixels() + } childOf versionNumberHolder + } + + val buttonHolder = UIContainer().constrain { + x = 4.pixels(true) + y = 4.pixels() + width = 73.pixels() + height = 48.pixels() + } childOf this + + createUpdateButton() childOf buttonHolder + + val changeLogButton = UIBlock(Color(150, 150, 150)).constrain { + y = 0.pixels(true) + width = 73.pixels() + height = 50.percent() - 2.pixels() + }.onMouseClick { + gui.showChangeLog(project, version, createUpdateButton()) + } childOf buttonHolder + UIText("${ChatColor.BOLD}${localize("resourcify.updates.changelog")}").constrain { + x = CenterConstraint() + y = CenterConstraint() + } childOf changeLogButton } - val buttonHolder = UIContainer().constrain { - x = 4.pixels(true) + val sourceHolder = UIContainer().constrain { + x = 50.percent() - 50.pixels() y = 4.pixels() - width = 73.pixels() + width = 100.pixels() height = 48.pixels() } childOf this + val sourceTextHolder = UIContainer().constrain { + height = 50.percent() + } childOf sourceHolder + UIText("resourcify.updates.source".localize()).constrain { + y = CenterConstraint() + } childOf sourceTextHolder - createUpdateButton() childOf buttonHolder - + DropDown( + data.keys.map { it.getName() }, onlyOneOption = true, + selectedOptions = mutableListOf(selectedService.getName()) + ).onSelectionUpdate { newService -> + selectedService = data.keys.firstOrNull { it.getName() == newService.first() } ?: return@onSelectionUpdate + selectedData = data[selectedService] - val changeLogButton = UIBlock(Color(150, 150, 150)).constrain { + this@UpdateCard.clearChildren() + createCard(getProject(), selectedData?.version) + }.constrain { + x = 0.pixels() y = 0.pixels(true) - width = 73.pixels() + width = 100.percent() height = 50.percent() - 2.pixels() - }.onMouseClick { - gui.showChangeLog(project, newVersion, createUpdateButton()) - } childOf buttonHolder - UIText("${ChatColor.BOLD}${localize("resourcify.updates.changelog")}").constrain { - x = CenterConstraint() - y = CenterConstraint() - } childOf changeLogButton + } childOf sourceHolder } private fun createUpdateButton(): UIComponent { @@ -124,11 +169,12 @@ class UpdateCard( }.onMouseClick { downloadUpdate() } + val downloadUrl = selectedData?.version?.getDownloadUrl() ?: return updateButton progressBox = UIBlock(Color(0, 0, 0, 100)).constrain { x = 0.pixels(true) y = 0.pixels() width = basicWidthConstraint { - val progress = DownloadManager.getProgress(updateUrl) + val progress = DownloadManager.getProgress(downloadUrl) if (progress == null) 0f else (1 - progress) * it.parent.getWidth() } @@ -142,6 +188,8 @@ class UpdateCard( } fun downloadUpdate() { + val updateUrl = selectedData?.version?.getDownloadUrl() ?: return + val newVersion = selectedData?.version ?: return if (DownloadManager.getProgress(updateUrl) == null) { gui.registerUpdate(this, Platform.getSelectedResourcePacks().contains(file)) text?.setText("${ChatColor.BOLD}${localize("resourcify.updates.updating")}") @@ -201,7 +249,11 @@ class UpdateCard( } fun getProgress(): Float { - return DownloadManager.getProgress(updateUrl) ?: 0f + return DownloadManager.getProgress(selectedData?.version?.getDownloadUrl() ?: return 0f) ?: 0f + } + + private fun getProject(): IProject { + return selectedData?.project ?: data.values.first { it != null }!!.project } companion object { diff --git a/src/main/kotlin/dev/dediamondpro/resourcify/services/IService.kt b/src/main/kotlin/dev/dediamondpro/resourcify/services/IService.kt index 80b39e3..ebe906c 100644 --- a/src/main/kotlin/dev/dediamondpro/resourcify/services/IService.kt +++ b/src/main/kotlin/dev/dediamondpro/resourcify/services/IService.kt @@ -21,6 +21,7 @@ import dev.dediamondpro.minemark.elementa.style.MarkdownStyle import dev.dediamondpro.resourcify.services.ads.DefaultAdProvider import dev.dediamondpro.resourcify.services.ads.IAdProvider import dev.dediamondpro.resourcify.util.ElementaUtils +import java.io.File import java.net.URI import java.util.concurrent.CompletableFuture @@ -63,4 +64,12 @@ interface IService { fun canFetchProjectUrl(uri: URI): Boolean fun fetchProjectFromUrl(uri: URI): Pair>? + + fun getProjectsFromIds(ids: List): Map + + /** + * Returning null as the version means the service has the project, but it is already up to date. + * If the service does not have the project, it should not be included in the response + */ + fun getUpdates(files: List, type: ProjectType): CompletableFuture> } \ No newline at end of file diff --git a/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeFingerprint.kt b/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeFingerprint.kt new file mode 100644 index 0000000..8cafb3b --- /dev/null +++ b/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeFingerprint.kt @@ -0,0 +1,30 @@ +/* + * This file is part of Resourcify + * Copyright (C) 2024 DeDiamondPro + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License Version 3 as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package dev.dediamondpro.resourcify.services.curseforge + +data class CurseForgeFingerprint(val fingerprints: List) + +data class CurseForgeFingerprintResponse(val data: CurseForgeFingerprintMatchResult) + +data class CurseForgeFingerprintMatchResult(val exactMatches: List) + +data class CurseForgeFingerprintMatch( + val id: Int, + val file: CurseForgeVersion, + val latestFiles: List +) \ No newline at end of file diff --git a/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeModsBatch.kt b/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeModsBatch.kt new file mode 100644 index 0000000..cc42fdb --- /dev/null +++ b/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeModsBatch.kt @@ -0,0 +1,22 @@ +/* + * This file is part of Resourcify + * Copyright (C) 2024 DeDiamondPro + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License Version 3 as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +package dev.dediamondpro.resourcify.services.curseforge + +data class CurseForgeModsBatch(val modIds: List) + +data class CurseForgeModsBatchResponse(val data: List) diff --git a/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeService.kt b/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeService.kt index c5790b5..7c28c32 100644 --- a/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeService.kt +++ b/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeService.kt @@ -22,13 +22,12 @@ import dev.dediamondpro.minemark.style.HeadingLevelStyleConfig import dev.dediamondpro.minemark.style.HeadingStyleConfig import dev.dediamondpro.minemark.style.ImageStyleConfig import dev.dediamondpro.minemark.style.LinkStyleConfig -import dev.dediamondpro.resourcify.services.IProject -import dev.dediamondpro.resourcify.services.ISearchData -import dev.dediamondpro.resourcify.services.IService -import dev.dediamondpro.resourcify.services.ProjectType +import dev.dediamondpro.resourcify.platform.Platform +import dev.dediamondpro.resourcify.services.* import dev.dediamondpro.resourcify.util.* import org.apache.http.client.utils.URIBuilder import java.awt.Color +import java.io.File import java.net.URI import java.net.URL import java.util.concurrent.CompletableFuture @@ -100,17 +99,18 @@ object CurseForgeService : IService { fetchCategories() return categories?.thenApply { val classId = type.getClassId() - mapOf("resourcify.categories.categories".localize() to - it.filter { category -> // Filter out data pack category in resource packs, we handle this automatically - category.classId == classId && category.id != 5193 - }.sortedBy { category -> - if (!category.name.matches(Regex("^[0-9].*"))) "\uFFFF${category.name}" - else category.name.replace(Regex("[^0-9]"), "").toInt().toChar().toString() - }.associate { category -> - category.id.toString() to "resourcify.categories.${ - category.name.lowercase().replace(" ", "_") - }".localizeOrDefault(category.name.capitalizeAll()) - }) + mapOf( + "resourcify.categories.categories".localize() to + it.filter { category -> // Filter out data pack category in resource packs, we handle this automatically + category.classId == classId && category.id != 5193 + }.sortedBy { category -> + if (!category.name.matches(Regex("^[0-9].*"))) "\uFFFF${category.name}" + else category.name.replace(Regex("[^0-9]"), "").toInt().toChar().toString() + }.associate { category -> + category.id.toString() to "resourcify.categories.${ + category.name.lowercase().replace(" ", "_") + }".localizeOrDefault(category.name.capitalizeAll()) + }) } ?: supply { emptyMap() } } @@ -147,6 +147,82 @@ object CurseForgeService : IService { else -> null } + override fun getProjectsFromIds(ids: List): Map { + return URL("$API/mods") + .postAndGetJson( + CurseForgeModsBatch(ids.map { it.toInt() }), headers = mapOf("x-api-key" to API_KEY) + )?.data?.associateBy { project -> ids.first { it == project.getId() } } + ?: error("Failed to fetch mods.") + + } + + override fun getUpdates(files: List, type: ProjectType): CompletableFuture> { + return supplyAsync { + val hashes = files.associateBy { + if (it.length() >= 1024 * 1024 * 512) { + // If this file is larger than 512MiB, we will not attempt to load it since for cf's hashing + // we need to load the entire file in memory + return@associateBy null + } + val bytes = it.readBytes() + MurmurHash2.cfHash(bytes, bytes.size) + }.filterKeys { it != null } + val mcVersion = Platform.getMcVersion() + val result = URL("$API/fingerprints/432") + .postAndGetJson( + CurseForgeFingerprint(hashes.keys.map { it!! }.toList()), + headers = mapOf("x-api-key" to API_KEY) + )?.data?.exactMatches?.filter { + hashes.containsKey(it.file.fileFingerprint) + // If there is no download url, we can't download this project so we ignore it + && it.file.hasDownloadUrl() + } ?: error("Failed to fetch updates") + + val fileFutures: MutableMap> = + mutableMapOf() + for (match in result) { + val fileCandidate = match.latestFiles.firstOrNull { file -> + file.getMinecraftVersions().contains(mcVersion) + } + if (fileCandidate == null && !match.latestFiles.any { it.fileFingerprint == match.file.fileFingerprint }) { + fileFutures[match] = supplyAsync { + URIBuilder("$API/mods/${match.id}/files") + .addParameter("gameVersion", mcVersion) + // We only care about the most recent match + .addParameter("pageSize", "1") + .build().toURL() + .getJson(headers = mapOf("x-api-key" to API_KEY)) + ?.data?.firstOrNull() ?: error("Failed to find matching version") + } + } else { + // Latest files contains update or file is up to date + fileFutures[match] = supply { fileCandidate } + } + } + + val updates: MutableMap = mutableMapOf() + for ((match, future) in fileFutures) { + try { + val file = future.get() + if (file?.hasDownloadUrl() == false) { + continue + } + updates[hashes[match.file.fileFingerprint]!!] = file.let { + // If the file is up to date, return null + if (match.file.fileFingerprint == it?.fileFingerprint) { + null + } else { + it + } + } + } catch (_: Exception) { + } + } + + return@supplyAsync updates + } + } + override fun getSortOptions(): Map = mapOf( "1" to "resourcify.browse.sort.relevance", "6" to "resourcify.browse.sort.downloads", diff --git a/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeVersion.kt b/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeVersion.kt index b9f5682..5210777 100644 --- a/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeVersion.kt +++ b/src/main/kotlin/dev/dediamondpro/resourcify/services/curseforge/CurseForgeVersion.kt @@ -34,6 +34,7 @@ data class CurseForgeVersion( private val downloadCount: Int, private val fileDate: String, private val dependencies: List, + val fileFingerprint: Long ) : IVersion { @Transient private var changeLogRequest: CompletableFuture? = null diff --git a/src/main/kotlin/dev/dediamondpro/resourcify/services/modrinth/ModrinthService.kt b/src/main/kotlin/dev/dediamondpro/resourcify/services/modrinth/ModrinthService.kt index d7ae43f..c3fb746 100644 --- a/src/main/kotlin/dev/dediamondpro/resourcify/services/modrinth/ModrinthService.kt +++ b/src/main/kotlin/dev/dediamondpro/resourcify/services/modrinth/ModrinthService.kt @@ -17,12 +17,10 @@ package dev.dediamondpro.resourcify.services.modrinth -import dev.dediamondpro.resourcify.services.IProject -import dev.dediamondpro.resourcify.services.ISearchData -import dev.dediamondpro.resourcify.services.IService -import dev.dediamondpro.resourcify.services.ProjectType +import dev.dediamondpro.resourcify.services.* import dev.dediamondpro.resourcify.util.* import org.apache.http.client.utils.URIBuilder +import java.io.File import java.net.URI import java.net.URL import java.util.concurrent.CompletableFuture @@ -145,6 +143,33 @@ object ModrinthService : IService { } } + override fun getProjectsFromIds(ids: List): Map { + val idString = ids.joinToString(",", "[", "]") { "\"${it}\"" } + return URIBuilder("${API}/projects").setParameter("ids", idString) + .build().toURL().getJson>()!! + .associateBy { project -> ids.first { project.getId() == it } } + } + + override fun getUpdates(files: List, type: ProjectType): CompletableFuture> { + return supplyAsync { + val hashes = files.mapNotNull { + val hash = Utils.getSha1(it) + if (hash == null) null else hash to it + }.toMap() + val loader = when (type) { + ProjectType.RESOURCE_PACK, ProjectType.AYCY_RESOURCE_PACK -> "minecraft" + ProjectType.IRIS_SHADER -> "iris" + ProjectType.OPTIFINE_SHADER -> "optifine" + else -> error("$type is not supported in updates") + } + val data: Map = URL("${API}/version_files/update").postAndGetJson, ModrinthUpdateFormat>( + ModrinthUpdateFormat(loaders = listOf(loader), hashes = hashes.keys.toList()) + ) ?: error("Failed to fetch updates") + // Associate with file, and if we already have the latest version, set the result to null + data.map { hashes[it.key]!! to if (it.key == it.value.getSha1()) null else it.value }.toMap() + } + } + private fun ProjectType.getProjectType(): String? = when (this) { ProjectType.RESOURCE_PACK -> "resourcepack" ProjectType.AYCY_RESOURCE_PACK -> "resourcepack" diff --git a/src/main/kotlin/dev/dediamondpro/resourcify/gui/update/modrinth/ModrinthUpdateFormat.kt b/src/main/kotlin/dev/dediamondpro/resourcify/services/modrinth/ModrinthUpdateFormat.kt similarity index 94% rename from src/main/kotlin/dev/dediamondpro/resourcify/gui/update/modrinth/ModrinthUpdateFormat.kt rename to src/main/kotlin/dev/dediamondpro/resourcify/services/modrinth/ModrinthUpdateFormat.kt index 46527fb..a200642 100644 --- a/src/main/kotlin/dev/dediamondpro/resourcify/gui/update/modrinth/ModrinthUpdateFormat.kt +++ b/src/main/kotlin/dev/dediamondpro/resourcify/services/modrinth/ModrinthUpdateFormat.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package dev.dediamondpro.resourcify.gui.update.modrinth +package dev.dediamondpro.resourcify.services.modrinth import com.google.gson.annotations.SerializedName import dev.dediamondpro.resourcify.platform.Platform diff --git a/src/main/resources/assets/resourcify/lang/en_us.json b/src/main/resources/assets/resourcify/lang/en_us.json index 2108da0..87d0bc9 100644 --- a/src/main/resources/assets/resourcify/lang/en_us.json +++ b/src/main/resources/assets/resourcify/lang/en_us.json @@ -53,6 +53,8 @@ "resourcify.updates.update_singular": "update", "resourcify.updates.update_plural": "updates", "resourcify.updates.changelog": "Changelog", + "resourcify.updates.source": "Source:", + "resourcify.updates.up-to-date": "Up to date", "resourcify.config.title": "Resourcify Config", "resourcify.config.source.title": "Default Source",