mirror of
https://github.com/yairm210/Unciv.git
synced 2025-02-10 19:09:06 +07:00
Spruced up ModManagementScreen - phase 1 (#3983)
* Spruced up ModManagementScreen - phase 1 * Spruced up ModManagementScreen - phase 1 - patch1
This commit is contained in:
parent
205f479ffe
commit
9ed73d0d3f
@ -940,6 +940,7 @@ Invalid ID! =
|
||||
|
||||
Mods =
|
||||
Download [modName] =
|
||||
Update [modName] =
|
||||
Could not download mod list =
|
||||
Download mod from URL =
|
||||
Download =
|
||||
@ -956,6 +957,9 @@ Disable as permanent visual mod =
|
||||
Installed =
|
||||
Downloaded! =
|
||||
Could not download mod =
|
||||
Online query result is incomplete =
|
||||
No description provided =
|
||||
[stargazers]✯ =
|
||||
|
||||
# Uniques that are relevant to more than one type of game object
|
||||
|
||||
|
@ -34,6 +34,8 @@ class ModOptions {
|
||||
|
||||
var lastUpdated = ""
|
||||
var modUrl = ""
|
||||
var author = ""
|
||||
var modSize = 0
|
||||
}
|
||||
|
||||
class Ruleset {
|
||||
|
@ -1,12 +1,11 @@
|
||||
package com.unciv.ui.pickerscreens
|
||||
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.files.FileHandle
|
||||
import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.scenes.scene2d.Actor
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.ScrollPane
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.TextArea
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
|
||||
import com.badlogic.gdx.scenes.scene2d.Touchable
|
||||
import com.badlogic.gdx.scenes.scene2d.ui.*
|
||||
import com.badlogic.gdx.utils.Align
|
||||
import com.badlogic.gdx.utils.Json
|
||||
import com.unciv.JsonParser
|
||||
@ -16,46 +15,137 @@ import com.unciv.models.ruleset.Ruleset
|
||||
import com.unciv.models.ruleset.RulesetCache
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.utils.*
|
||||
import com.unciv.ui.utils.UncivDateFormat.formatDate
|
||||
import com.unciv.ui.utils.UncivDateFormat.parseDate
|
||||
import com.unciv.ui.worldscreen.mainmenu.Github
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.collections.HashMap
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* The Mod Management Screen - called only from [MainMenuScreen]
|
||||
*/
|
||||
// All picker screens auto-wrap the top table in a ScrollPane.
|
||||
// Since we want the different parts to scroll separately, we disable the default ScrollPane, which would scroll everything at once.
|
||||
class ModManagementScreen: PickerScreen(disableScroll = true) {
|
||||
|
||||
val modTable = Table().apply { defaults().pad(10f) }
|
||||
val downloadTable = Table().apply { defaults().pad(10f) }
|
||||
val modActionTable = Table().apply { defaults().pad(10f) }
|
||||
private val modTable = Table().apply { defaults().pad(10f) }
|
||||
private val scrollInstalledMods = ScrollPane(modTable)
|
||||
private val downloadTable = Table().apply { defaults().pad(10f) }
|
||||
private val scrollOnlineMods = ScrollPane(downloadTable)
|
||||
private val modActionTable = Table().apply { defaults().pad(10f) }
|
||||
|
||||
val amountPerPage = 30
|
||||
|
||||
var lastSelectedButton: TextButton? = null
|
||||
val modDescriptions: HashMap<String, String> = hashMapOf()
|
||||
private var lastSelectedButton: Button? = null
|
||||
private var lastSyncMarkedButton: Button? = null
|
||||
private var selectedModName = ""
|
||||
private var selectedAuthor = ""
|
||||
|
||||
// keep running count of mods fetched from online search for comparison to total count as reported by GitHub
|
||||
private var downloadModCount = 0
|
||||
|
||||
// Description data from installed mods and online search
|
||||
private val modDescriptionsInstalled: HashMap<String, String> = hashMapOf()
|
||||
private val modDescriptionsOnline: HashMap<String, String> = hashMapOf()
|
||||
private fun showModDescription(modName: String) {
|
||||
val online = modDescriptionsOnline[modName] ?: ""
|
||||
val installed = modDescriptionsInstalled[modName] ?: ""
|
||||
val separator = if(online.isEmpty() || installed.isEmpty()) "" else "\n"
|
||||
descriptionLabel.setText(online + separator + installed)
|
||||
}
|
||||
|
||||
// Enable syncing entries in 'installed' and 'repo search ScrollPanes
|
||||
private class ScrollToEntry(val y: Float, val height: Float, val button: Button)
|
||||
private val installedScrollIndex = HashMap<String,ScrollToEntry>(30)
|
||||
private val onlineScrollIndex = HashMap<String,ScrollToEntry>(30)
|
||||
private var onlineScrollCurrentY = -1f
|
||||
|
||||
|
||||
// cleanup - background processing needs to be stopped on exit and memory freed
|
||||
private var runningSearchThread: Thread? = null
|
||||
private var stopBackgroundTasks = false
|
||||
override fun dispose() {
|
||||
// make sure the worker threads will not continue trying their time-intensive job
|
||||
runningSearchThread?.interrupt()
|
||||
stopBackgroundTasks = true
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
/** Helper class keeps references to decoration images of installed mods to enable dynamic visibility
|
||||
* (actually we do not use isVisible but refill a container selectively which allows the aggregate height to adapt and the set to center vertically)
|
||||
* @param container the table containing the indicators (one per mod, narrow, arranges up to three indicators vertically)
|
||||
* @param visualImage image indicating _enabled as permanent visual mod_
|
||||
* @param updatedImage image indicating _online mod has been updated_
|
||||
*/
|
||||
private class ModStateImages (
|
||||
val container: Table,
|
||||
isVisual: Boolean = false,
|
||||
isUpdated: Boolean = false,
|
||||
val visualImage: Image = ImageGetter.getImage("UnitPromotionIcons/Scouting"),
|
||||
val updatedImage: Image = ImageGetter.getImage("OtherIcons/Mods")
|
||||
) {
|
||||
// mad but it's really initializing with the primary constructor parameter and not calling update()
|
||||
var isVisual: Boolean = isVisual
|
||||
set(value) { if(field!=value) { field = value; update() } }
|
||||
var isUpdated: Boolean = isUpdated
|
||||
set(value) { if(field!=value) { field = value; update() } }
|
||||
private val spacer = Table().apply { width = 20f; height = 0f }
|
||||
fun update() {
|
||||
container.run {
|
||||
clear()
|
||||
if (isVisual) add(visualImage).row()
|
||||
if (isUpdated) add(updatedImage).row()
|
||||
if (!isVisual && !isUpdated) add(spacer)
|
||||
pack()
|
||||
}
|
||||
}
|
||||
}
|
||||
private val modStateImages = HashMap<String,ModStateImages>(30)
|
||||
|
||||
|
||||
init {
|
||||
setDefaultCloseAction(MainMenuScreen())
|
||||
refreshModTable()
|
||||
refreshInstalledModTable()
|
||||
|
||||
topTable.add("Current mods".toLabel()).padRight(35f) // 35 = 10 default pad + 25 to compensate for permanent visual mod decoration icon
|
||||
topTable.add("Downloadable mods".toLabel())
|
||||
// topTable.add("Mod actions")
|
||||
// Header row
|
||||
topTable.add().expandX() // empty cols left and right for separator
|
||||
topTable.add("Current mods".toLabel()).pad(5f).minWidth(200f).padLeft(25f)
|
||||
// 30 = 5 default pad + 20 to compensate for 'permanent visual mod' decoration icon
|
||||
topTable.add("Downloadable mods".toLabel()).pad(5f)
|
||||
topTable.add("".toLabel()).minWidth(200f) // placeholder for "Mod actions"
|
||||
topTable.add().expandX()
|
||||
topTable.row()
|
||||
|
||||
topTable.add(ScrollPane(modTable)).pad(10f)
|
||||
// horizontal separator looking like the SplitPane handle
|
||||
val separator = Table(skin)
|
||||
separator.background = skin.get("default-vertical", SplitPane.SplitPaneStyle::class.java).handle
|
||||
topTable.add(separator).minHeight(3f).fillX().colspan(5).row()
|
||||
|
||||
downloadTable.add(getDownloadButton()).row()
|
||||
tryDownloadPage(1)
|
||||
topTable.add(ScrollPane(downloadTable))
|
||||
// main row containing the three 'blocks' installed, online and information
|
||||
topTable.add() // skip empty first column
|
||||
topTable.add(scrollInstalledMods)
|
||||
|
||||
reloadOnlineMods()
|
||||
topTable.add(scrollOnlineMods)
|
||||
|
||||
topTable.add(modActionTable)
|
||||
}
|
||||
|
||||
fun tryDownloadPage(pageNum: Int) {
|
||||
thread {
|
||||
private fun reloadOnlineMods() {
|
||||
onlineScrollCurrentY = -1f
|
||||
downloadTable.clear()
|
||||
onlineScrollIndex.clear()
|
||||
downloadTable.add(getDownloadFromUrlButton()).padBottom(15f).row()
|
||||
downloadTable.add("...".toLabel()).row()
|
||||
tryDownloadPage(1)
|
||||
}
|
||||
|
||||
/** background worker: querying GitHub for Mods (repos with 'unciv-mod' in its topics)
|
||||
*
|
||||
* calls itself for the next page of search results
|
||||
*/
|
||||
private fun tryDownloadPage(pageNum: Int) {
|
||||
runningSearchThread = thread(name="GitHubSearch") {
|
||||
val repoSearch: Github.RepoSearch
|
||||
try {
|
||||
repoSearch = Github.tryGetGithubReposWithTopic(amountPerPage, pageNum)!!
|
||||
@ -63,83 +153,158 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
|
||||
Gdx.app.postRunnable {
|
||||
ToastPopup("Could not download mod list", this)
|
||||
}
|
||||
runningSearchThread = null
|
||||
return@thread
|
||||
}
|
||||
|
||||
Gdx.app.postRunnable {
|
||||
// clear and hide last cell if it is the "..." indicator
|
||||
val lastCell = downloadTable.cells.lastOrNull()
|
||||
if (lastCell != null && lastCell.actor is Label && (lastCell.actor as Label).text.toString() == "...") {
|
||||
lastCell.setActor<Actor>(null)
|
||||
lastCell.pad(0f)
|
||||
}
|
||||
|
||||
for (repo in repoSearch.items) {
|
||||
if (stopBackgroundTasks) return@postRunnable
|
||||
repo.name = repo.name.replace('-', ' ')
|
||||
|
||||
modDescriptions[repo.name] = repo.description + "\n" + "[${repo.stargazers_count}]✯".tr() +
|
||||
if (modDescriptions.contains(repo.name))
|
||||
"\n" + modDescriptions[repo.name]
|
||||
else ""
|
||||
modDescriptionsOnline[repo.name] =
|
||||
(repo.description ?: "-{No description provided}-".tr()) +
|
||||
"\n" + "[${repo.stargazers_count}]✯".tr()
|
||||
|
||||
var downloadButtonText = repo.name
|
||||
|
||||
val existingMod = RulesetCache.values.firstOrNull { it.name == repo.name }
|
||||
if (existingMod != null) {
|
||||
if (existingMod.modOptions.lastUpdated != "" && existingMod.modOptions.lastUpdated != repo.updated_at)
|
||||
if (existingMod.modOptions.lastUpdated != "" && existingMod.modOptions.lastUpdated != repo.updated_at) {
|
||||
downloadButtonText += " - {Updated}"
|
||||
}
|
||||
|
||||
val downloadButton = downloadButtonText.toTextButton()
|
||||
|
||||
downloadButton.onClick {
|
||||
lastSelectedButton?.color = Color.WHITE
|
||||
downloadButton.color = Color.BLUE
|
||||
lastSelectedButton = downloadButton
|
||||
descriptionLabel.setText(modDescriptions[repo.name])
|
||||
removeRightSideClickListeners()
|
||||
rightSideButton.enable()
|
||||
rightSideButton.setText("Download [${repo.name}]".tr())
|
||||
rightSideButton.onClick {
|
||||
rightSideButton.setText("Downloading...".tr())
|
||||
rightSideButton.disable()
|
||||
downloadMod(repo) {
|
||||
rightSideButton.setText("Downloaded!".tr())
|
||||
}
|
||||
modStateImages[repo.name]?.isUpdated = true
|
||||
}
|
||||
if (existingMod.modOptions.author.isEmpty()) {
|
||||
rewriteModOptions(repo, Gdx.files.local("mods").child(repo.name))
|
||||
existingMod.modOptions.author = repo.owner.login
|
||||
existingMod.modOptions.modSize = repo.size
|
||||
}
|
||||
}
|
||||
val downloadButton = downloadButtonText.toTextButton()
|
||||
downloadButton.onClick { onlineButtonAction(repo, downloadButton) }
|
||||
|
||||
modActionTable.clear()
|
||||
addModInfoToActionTable(repo.html_url, repo.updated_at)
|
||||
}
|
||||
downloadTable.add(downloadButton).row()
|
||||
val cell = downloadTable.add(downloadButton)
|
||||
downloadTable.row()
|
||||
if (onlineScrollCurrentY < 0f) onlineScrollCurrentY = cell.padTop
|
||||
onlineScrollIndex[repo.name] = ScrollToEntry(onlineScrollCurrentY, cell.prefHeight, downloadButton)
|
||||
onlineScrollCurrentY += cell.padBottom + cell.prefHeight + cell.padTop
|
||||
downloadModCount++
|
||||
}
|
||||
if (repoSearch.items.size == amountPerPage) {
|
||||
val nextPageButton = "Next page".toTextButton()
|
||||
nextPageButton.onClick {
|
||||
nextPageButton.remove()
|
||||
tryDownloadPage(pageNum + 1)
|
||||
|
||||
// Now the tasks after the 'page' of search results has been fully processed
|
||||
if (repoSearch.items.size < amountPerPage) {
|
||||
// The search has reached the last page!
|
||||
// Check: due to time passing between github calls it is not impossible we get a mod twice
|
||||
val checkedMods: MutableSet<String> = mutableSetOf()
|
||||
val duplicates: MutableList<Cell<Actor>> = mutableListOf()
|
||||
downloadTable.cells.forEach {
|
||||
cell->
|
||||
cell.actor?.name?.apply {
|
||||
if (checkedMods.contains(this)) {
|
||||
duplicates.add(cell)
|
||||
} else checkedMods.add(this)
|
||||
}
|
||||
}
|
||||
downloadTable.add(nextPageButton).row()
|
||||
duplicates.forEach {
|
||||
it.setActor(null)
|
||||
it.pad(0f) // the cell itself cannot be removed so stop it occupying height
|
||||
}
|
||||
downloadModCount -= duplicates.size
|
||||
// Check: It is also not impossible we missed a mod - just inform user
|
||||
if (repoSearch.total_count > downloadModCount || repoSearch.incomplete_results) {
|
||||
val retryLabel = "Online query result is incomplete".toLabel(Color.RED)
|
||||
retryLabel.touchable = Touchable.enabled
|
||||
retryLabel.onClick { reloadOnlineMods() }
|
||||
downloadTable.add(retryLabel)
|
||||
}
|
||||
} else {
|
||||
// the page was full so there may be more pages.
|
||||
// indicate that search will be continued
|
||||
downloadTable.add("...".toLabel()).row()
|
||||
}
|
||||
|
||||
downloadTable.pack()
|
||||
// Shouldn't actor.parent.actor = actor be a no-op? No, it has side effects we need.
|
||||
// See [commit for #3317](https://github.com/yairm210/Unciv/commit/315a55f972b8defe22e76d4a2d811c6e6b607e57)
|
||||
(downloadTable.parent as ScrollPane).actor = downloadTable
|
||||
|
||||
// continue search unless last page was reached
|
||||
if (repoSearch.items.size >= amountPerPage && !stopBackgroundTasks)
|
||||
tryDownloadPage(pageNum + 1)
|
||||
}
|
||||
runningSearchThread = null
|
||||
}
|
||||
}
|
||||
|
||||
fun addModInfoToActionTable(repoUrl: String, updatedAt: String) {
|
||||
private fun syncOnlineSelected(name: String, button: Button) {
|
||||
syncSelected(name, button, installedScrollIndex, scrollInstalledMods)
|
||||
}
|
||||
private fun syncInstalledSelected(name: String, button: Button) {
|
||||
syncSelected(name, button, onlineScrollIndex, scrollOnlineMods)
|
||||
}
|
||||
private fun syncSelected(name: String, button: Button, index: HashMap<String, ScrollToEntry>, scroll: ScrollPane) {
|
||||
// manage selection color for user selection
|
||||
lastSelectedButton?.color = Color.WHITE
|
||||
button.color = Color.BLUE
|
||||
lastSelectedButton = button
|
||||
if (lastSelectedButton == lastSyncMarkedButton) lastSyncMarkedButton = null
|
||||
// look for sync-able same mod in other list
|
||||
val pos = index[name] ?: return
|
||||
// scroll into view
|
||||
scroll.scrollY = (pos.y + (pos.height - scroll.height) / 2).coerceIn(0f, scroll.maxY)
|
||||
// and color it so it's easier to find. ROYAL and SLATE too dark.
|
||||
lastSyncMarkedButton?.color = Color.WHITE
|
||||
pos.button.color = Color.valueOf("7499ab") // about halfway between royal and sky
|
||||
lastSyncMarkedButton = pos.button
|
||||
}
|
||||
|
||||
/** Recreate the information part of the right-hand column
|
||||
* @param repo: the repository instance as received from the GitHub api
|
||||
*/
|
||||
private fun addModInfoToActionTable(repo: Github.Repo) {
|
||||
addModInfoToActionTable(repo.name, repo.html_url, repo.updated_at, repo.owner.login, repo.size)
|
||||
}
|
||||
/** Recreate the information part of the right-hand column
|
||||
* @param modName: The mod name (name from the RuleSet)
|
||||
* @param modOptions: The ModOptions as enriched by us with GitHub metadata when originally downloaded
|
||||
*/
|
||||
private fun addModInfoToActionTable(modName: String, modOptions: ModOptions) {
|
||||
addModInfoToActionTable(modName, modOptions.modUrl, modOptions.lastUpdated, modOptions.author, modOptions.modSize)
|
||||
}
|
||||
private fun addModInfoToActionTable(modName: String, repoUrl: String, updatedAt: String, author: String, modSize: Int) {
|
||||
// remember selected mod - for now needed only to display a background-fetched image while the user is watching
|
||||
selectedModName = modName
|
||||
selectedAuthor = author
|
||||
|
||||
// Display metadata
|
||||
if (author.isNotEmpty())
|
||||
modActionTable.add("Author: [$author]".toLabel()).row()
|
||||
if (modSize > 0)
|
||||
modActionTable.add("Size: [$modSize] kB".toLabel()).padBottom(15f).row()
|
||||
|
||||
// offer link to open the repo itself in a browser
|
||||
if (repoUrl != "") {
|
||||
modActionTable.add("Open Github page".toTextButton().onClick {
|
||||
Gdx.net.openURI(repoUrl)
|
||||
}).row()
|
||||
}
|
||||
|
||||
if (updatedAt != "") {
|
||||
// Everything under java.time is from Java 8 onwards, meaning older phones that use Java 7 won't be able to handle it :/
|
||||
// So we're forced to use ancient Java 6 classes instead of the newer and nicer LocalDateTime.parse :(
|
||||
// Direct solution from https://stackoverflow.com/questions/2201925/converting-iso-8601-compliant-string-to-java-util-date
|
||||
val df2 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) // example: 2021-04-11T14:43:33Z
|
||||
val date = df2.parse(updatedAt)
|
||||
|
||||
val updateString = "{Updated}: " +DateFormat.getDateInstance(DateFormat.SHORT).format(date)
|
||||
modActionTable.add(updateString.toLabel())
|
||||
// display "updated" date
|
||||
if (updatedAt.isNotEmpty()) {
|
||||
val date = updatedAt.parseDate()
|
||||
val updateString = "{Updated}: " + date.formatDate()
|
||||
modActionTable.add(updateString.toLabel()).row()
|
||||
}
|
||||
}
|
||||
|
||||
fun getDownloadButton(): TextButton {
|
||||
/** Create the special "Download from URL" button */
|
||||
private fun getDownloadFromUrlButton(): TextButton {
|
||||
val downloadButton = "Download mod from URL".toTextButton()
|
||||
downloadButton.onClick {
|
||||
val popup = Popup(this)
|
||||
@ -158,22 +323,42 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
|
||||
return downloadButton
|
||||
}
|
||||
|
||||
fun downloadMod(repo: Github.Repo, postAction: () -> Unit = {}) {
|
||||
thread { // to avoid ANRs - we've learnt our lesson from previous download-related actions
|
||||
/** Used as onClick handler for the online Mod list buttons */
|
||||
private fun onlineButtonAction(repo: Github.Repo, button: Button) {
|
||||
syncOnlineSelected(repo.name, button)
|
||||
showModDescription(repo.name)
|
||||
removeRightSideClickListeners()
|
||||
rightSideButton.enable()
|
||||
val label = if (modStateImages[repo.name]?.isUpdated == true)
|
||||
"Update [${repo.name}]"
|
||||
else "Download [${repo.name}]"
|
||||
rightSideButton.setText(label.tr())
|
||||
rightSideButton.onClick {
|
||||
rightSideButton.setText("Downloading...".tr())
|
||||
rightSideButton.disable()
|
||||
downloadMod(repo) {
|
||||
rightSideButton.setText("Downloaded!".tr())
|
||||
}
|
||||
}
|
||||
|
||||
modActionTable.clear()
|
||||
addModInfoToActionTable(repo)
|
||||
}
|
||||
|
||||
/** Download and install a mod in the background, called from the right-bottom button */
|
||||
private fun downloadMod(repo: Github.Repo, postAction: () -> Unit = {}) {
|
||||
thread(name="DownloadMod") { // to avoid ANRs - we've learnt our lesson from previous download-related actions
|
||||
try {
|
||||
val modFolder = Github.downloadAndExtract(repo.html_url, repo.default_branch,
|
||||
Gdx.files.local("mods"))
|
||||
if (modFolder == null) return@thread
|
||||
// rewrite modOptions file
|
||||
val modOptionsFile = modFolder.child("jsons/ModOptions.json")
|
||||
val modOptions = if (modOptionsFile.exists()) JsonParser().getFromJson(ModOptions::class.java, modOptionsFile) else ModOptions()
|
||||
modOptions.modUrl = repo.html_url
|
||||
modOptions.lastUpdated = repo.updated_at
|
||||
Json().toJson(modOptions, modOptionsFile)
|
||||
Gdx.files.local("mods"))
|
||||
?: return@thread
|
||||
rewriteModOptions(repo, modFolder)
|
||||
Gdx.app.postRunnable {
|
||||
ToastPopup("Downloaded!", this)
|
||||
RulesetCache.loadRulesets()
|
||||
refreshModTable()
|
||||
refreshInstalledModTable()
|
||||
showModDescription(repo.name)
|
||||
unMarkUpdatedMod(repo.name)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Gdx.app.postRunnable {
|
||||
@ -185,67 +370,125 @@ class ModManagementScreen: PickerScreen(disableScroll = true) {
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshModActions(mod: Ruleset, decorationImage: Actor) {
|
||||
/** Rewrite modOptions file for a mod we just installed to include metadata we got from the GitHub api
|
||||
*
|
||||
* (called on background thread)
|
||||
*/
|
||||
private fun rewriteModOptions(repo: Github.Repo, modFolder: FileHandle) {
|
||||
val modOptionsFile = modFolder.child("jsons/ModOptions.json")
|
||||
val modOptions = if (modOptionsFile.exists()) JsonParser().getFromJson(ModOptions::class.java, modOptionsFile) else ModOptions()
|
||||
modOptions.modUrl = repo.html_url
|
||||
modOptions.lastUpdated = repo.updated_at
|
||||
modOptions.author = repo.owner.login
|
||||
modOptions.modSize = repo.size
|
||||
Json().toJson(modOptions, modOptionsFile)
|
||||
}
|
||||
|
||||
/** Remove the visual indicators for an 'updated' mod after re-downloading it.
|
||||
* (" - Updated" on the button text in the online mod list and the icon beside the installed mod's button)
|
||||
* It should be up to date now (unless the repo's date is in the future relative to system time)
|
||||
*
|
||||
* (called under postRunnable posted by background thread)
|
||||
*/
|
||||
private fun unMarkUpdatedMod(name: String) {
|
||||
modStateImages[name]?.isUpdated = false
|
||||
val button = (onlineScrollIndex[name]?.button as? TextButton) ?: return
|
||||
button.setText(name)
|
||||
}
|
||||
|
||||
/** Rebuild the right-hand column for clicks on installed mods
|
||||
* Display single mod metadata, offer additional actions (delete is elsewhere)
|
||||
*/
|
||||
private fun refreshModActions(mod: Ruleset) {
|
||||
modActionTable.clear()
|
||||
// show mod information first
|
||||
addModInfoToActionTable(mod.name, mod.modOptions)
|
||||
|
||||
// offer 'permanent visual mod' toggle
|
||||
val visualMods = game.settings.visualMods
|
||||
if (!visualMods.contains(mod.name)) {
|
||||
decorationImage.isVisible = false
|
||||
val isVisual = visualMods.contains(mod.name)
|
||||
modStateImages[mod.name]?.isVisual = isVisual
|
||||
if (!isVisual) {
|
||||
modActionTable.add("Enable as permanent visual mod".toTextButton().onClick {
|
||||
visualMods.add(mod.name)
|
||||
game.settings.save()
|
||||
ImageGetter.setNewRuleset(ImageGetter.ruleset)
|
||||
refreshModActions(mod, decorationImage)
|
||||
refreshModActions(mod)
|
||||
})
|
||||
} else {
|
||||
decorationImage.isVisible = true
|
||||
modActionTable.add("Disable as permanent visual mod".toTextButton().onClick {
|
||||
visualMods.remove(mod.name)
|
||||
game.settings.save()
|
||||
ImageGetter.setNewRuleset(ImageGetter.ruleset)
|
||||
refreshModActions(mod, decorationImage)
|
||||
refreshModActions(mod)
|
||||
})
|
||||
}
|
||||
modActionTable.row()
|
||||
|
||||
addModInfoToActionTable(mod.modOptions.modUrl, mod.modOptions.lastUpdated)
|
||||
}
|
||||
|
||||
fun refreshModTable() {
|
||||
/** Rebuild the left-hand column containing all installed mods */
|
||||
private fun refreshInstalledModTable() {
|
||||
modTable.clear()
|
||||
val currentMods = RulesetCache.values.filter { it.name != "" }
|
||||
installedScrollIndex.clear()
|
||||
|
||||
var currentY = -1f
|
||||
val currentMods = RulesetCache.values.asSequence().filter { it.name != "" }.sortedBy { it.name }
|
||||
for (mod in currentMods) {
|
||||
val summary = mod.getSummary()
|
||||
modDescriptions[mod.name] = "Installed".tr() +
|
||||
modDescriptionsInstalled[mod.name] = "Installed".tr() +
|
||||
(if (summary.isEmpty()) "" else ": $summary")
|
||||
val decorationImage = ImageGetter.getPromotionIcon("Scouting", 25f)
|
||||
|
||||
var imageMgr = modStateImages[mod.name]
|
||||
val decorationTable =
|
||||
if (imageMgr != null) imageMgr.container
|
||||
else {
|
||||
val table = Table().apply { defaults().size(20f).align(Align.topLeft) }
|
||||
imageMgr = ModStateImages(table, isVisual = mod.name in game.settings.visualMods)
|
||||
modStateImages[mod.name] = imageMgr
|
||||
table
|
||||
}
|
||||
imageMgr.update() // rebuilds decorationTable content
|
||||
|
||||
val button = mod.name.toTextButton()
|
||||
button.onClick {
|
||||
lastSelectedButton?.color = Color.WHITE
|
||||
button.color = Color.BLUE
|
||||
lastSelectedButton = button
|
||||
refreshModActions(mod, decorationImage)
|
||||
syncInstalledSelected(mod.name, button)
|
||||
refreshModActions(mod)
|
||||
rightSideButton.setText("Delete [${mod.name}]".tr())
|
||||
rightSideButton.enable()
|
||||
descriptionLabel.setText(modDescriptions[mod.name])
|
||||
rightSideButton.isEnabled = true
|
||||
showModDescription(mod.name)
|
||||
removeRightSideClickListeners()
|
||||
rightSideButton.onClick {
|
||||
YesNoPopup("Are you SURE you want to delete this mod?",
|
||||
{ deleteMod(mod) }, this).open()
|
||||
rightSideButton.isEnabled = false
|
||||
YesNoPopup(
|
||||
question = "Are you SURE you want to delete this mod?",
|
||||
action = {
|
||||
deleteMod(mod)
|
||||
rightSideButton.setText("[${mod.name}] was deleted.".tr())
|
||||
},
|
||||
screen = this,
|
||||
restoreDefault = { rightSideButton.isEnabled = true }
|
||||
).open()
|
||||
}
|
||||
}
|
||||
|
||||
val decoratedButton = Table()
|
||||
decoratedButton.add(button)
|
||||
decorationImage.isVisible = game.settings.visualMods.contains(mod.name)
|
||||
decoratedButton.add(decorationImage).align(Align.topLeft)
|
||||
modTable.add(decoratedButton).row()
|
||||
decoratedButton.add(decorationTable).align(Align.center+Align.left)
|
||||
val cell = modTable.add(decoratedButton)
|
||||
modTable.row()
|
||||
if (currentY < 0f) currentY = cell.padTop
|
||||
installedScrollIndex[mod.name] = ScrollToEntry(currentY, cell.prefHeight, button)
|
||||
currentY += cell.padBottom + cell.prefHeight + cell.padTop
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteMod(mod: Ruleset) {
|
||||
/** Delete a Mod, refresh ruleset cache and update installed mod table */
|
||||
private fun deleteMod(mod: Ruleset) {
|
||||
val modFileHandle = Gdx.files.local("mods").child(mod.name)
|
||||
if (modFileHandle.isDirectory) modFileHandle.deleteDirectory()
|
||||
else modFileHandle.delete()
|
||||
else modFileHandle.delete() // This should never happen
|
||||
RulesetCache.loadRulesets()
|
||||
refreshModTable()
|
||||
modStateImages.remove(mod.name)
|
||||
refreshInstalledModTable()
|
||||
}
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ import com.unciv.logic.UncivShowableException
|
||||
import com.unciv.models.translations.tr
|
||||
import com.unciv.ui.pickerscreens.PickerScreen
|
||||
import com.unciv.ui.utils.*
|
||||
import java.text.SimpleDateFormat
|
||||
import com.unciv.ui.utils.UncivDateFormat.formatDate
|
||||
import java.util.*
|
||||
import java.util.concurrent.CancellationException
|
||||
import kotlin.concurrent.thread
|
||||
@ -180,8 +180,7 @@ class LoadGameScreen(previousScreen:CameraStageBaseScreen) : PickerScreen(disabl
|
||||
|
||||
|
||||
val savedAt = Date(save.lastModified())
|
||||
var textToSet = save.name() +
|
||||
"\n${"Saved at".tr()}: " + SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).format(savedAt)
|
||||
var textToSet = save.name() + "\n${"Saved at".tr()}: " + savedAt.formatDate()
|
||||
thread { // Even loading the game to get its metadata can take a long time on older phones
|
||||
try {
|
||||
val game = GameSaver.loadGamePreviewFromFile(save)
|
||||
|
@ -10,6 +10,8 @@ import com.badlogic.gdx.scenes.scene2d.utils.ChangeListener
|
||||
import com.badlogic.gdx.scenes.scene2d.utils.ClickListener
|
||||
import com.unciv.models.UncivSound
|
||||
import com.unciv.models.translations.tr
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.concurrent.thread
|
||||
import kotlin.random.Random
|
||||
|
||||
@ -102,8 +104,7 @@ fun Table.addSeparator(): Cell<Image> {
|
||||
|
||||
fun Table.addSeparatorVertical(): Cell<Image> {
|
||||
val image = ImageGetter.getWhiteDot()
|
||||
val cell = add(image).width(2f).fillY()
|
||||
return cell
|
||||
return add(image).width(2f).fillY()
|
||||
}
|
||||
|
||||
fun <T : Actor> Table.addCell(actor: T): Table {
|
||||
@ -200,3 +201,28 @@ fun <T> List<T>.randomWeighted(weights: List<Float>, random: Random = Random): T
|
||||
}
|
||||
return this.last()
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardize date formatting so dates are presented in a consistent style and all decisions
|
||||
* to change date handling are encapsulated here
|
||||
*/
|
||||
object UncivDateFormat {
|
||||
private val standardFormat = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US)
|
||||
|
||||
/** Format a date to ISO format with minutes */
|
||||
fun Date.formatDate(): String = standardFormat.format(this)
|
||||
// Previously also used:
|
||||
//val updateString = "{Updated}: " +DateFormat.getDateInstance(DateFormat.SHORT).format(date)
|
||||
|
||||
// Everything under java.time is from Java 8 onwards, meaning older phones that use Java 7 won't be able to handle it :/
|
||||
// So we're forced to use ancient Java 6 classes instead of the newer and nicer LocalDateTime.parse :(
|
||||
// Direct solution from https://stackoverflow.com/questions/2201925/converting-iso-8601-compliant-string-to-java-util-date
|
||||
|
||||
@Suppress("SpellCheckingInspection")
|
||||
private val utcFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
|
||||
|
||||
/** Parse an UTC date as passed by online API's
|
||||
* example: `"2021-04-11T14:43:33Z".parseDate()`
|
||||
*/
|
||||
fun String.parseDate(): Date = utcFormat.parse(this)
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
package com.unciv.ui.worldscreen.mainmenu
|
||||
|
||||
import com.badlogic.gdx.files.FileHandle
|
||||
import com.unciv.logic.GameInfo
|
||||
import com.unciv.logic.GameSaver
|
||||
import com.unciv.ui.saves.Gzip
|
||||
@ -8,8 +7,6 @@ import java.io.*
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.nio.charset.Charset
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
|
||||
object DropBox {
|
||||
@ -19,6 +16,7 @@ object DropBox {
|
||||
with(URL(url).openConnection() as HttpURLConnection) {
|
||||
requestMethod = "POST" // default is GET
|
||||
|
||||
@Suppress("SpellCheckingInspection")
|
||||
setRequestProperty("Authorization", "Bearer LTdBbopPUQ0AAAAAAAACxh4_Qd1eVMM7IBK3ULV3BgxzWZDMfhmgFbuUNF_rXQWb")
|
||||
|
||||
if (dropboxApiArg != "") setRequestProperty("Dropbox-API-Arg", dropboxApiArg)
|
||||
@ -76,8 +74,7 @@ object DropBox {
|
||||
|
||||
fun downloadFileAsString(fileName: String): String {
|
||||
val inputStream = downloadFile(fileName)
|
||||
val text = BufferedReader(InputStreamReader(inputStream)).readText()
|
||||
return text
|
||||
return BufferedReader(InputStreamReader(inputStream)).readText()
|
||||
}
|
||||
|
||||
fun uploadFile(fileName: String, data: String, overwrite: Boolean = false){
|
||||
@ -98,13 +95,14 @@ object DropBox {
|
||||
// return BufferedReader(InputStreamReader(result)).readText()
|
||||
// }
|
||||
|
||||
|
||||
@Suppress("PropertyName")
|
||||
class FolderList{
|
||||
var entries = ArrayList<FolderListEntry>()
|
||||
var cursor = ""
|
||||
var has_more = false
|
||||
}
|
||||
|
||||
@Suppress("PropertyName")
|
||||
class FolderListEntry{
|
||||
var name=""
|
||||
var path_display=""
|
||||
@ -128,127 +126,10 @@ class OnlineMultiplayer {
|
||||
/**
|
||||
* WARNING!
|
||||
* Does not initialize transitive GameInfo data.
|
||||
* It is therefore stateless and save to call for Multiplayer Turn Notifier, unlike tryDownloadGame().
|
||||
* It is therefore stateless and safe to call for Multiplayer Turn Notifier, unlike tryDownloadGame().
|
||||
*/
|
||||
fun tryDownloadGameUninitialized(gameId: String): GameInfo {
|
||||
val zippedGameInfo = DropBox.downloadFileAsString(getGameLocation(gameId))
|
||||
return GameSaver.gameInfoFromStringWithoutTransients(Gzip.unzip(zippedGameInfo))
|
||||
}
|
||||
}
|
||||
|
||||
object Github {
|
||||
// Consider merging this with the Dropbox function
|
||||
fun download(url: String, action: (HttpURLConnection) -> Unit = {}): InputStream? {
|
||||
with(URL(url).openConnection() as HttpURLConnection)
|
||||
{
|
||||
action(this)
|
||||
|
||||
try {
|
||||
return inputStream
|
||||
} catch (ex: Exception) {
|
||||
println(ex.message)
|
||||
val reader = BufferedReader(InputStreamReader(errorStream))
|
||||
println(reader.readText())
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// This took a long time to get just right, so if you're changing this, TEST IT THOROUGHLY on both Desktop and Phone
|
||||
fun downloadAndExtract(gitRepoUrl:String, defaultBranch:String, folderFileHandle:FileHandle): FileHandle? {
|
||||
val zipUrl = "$gitRepoUrl/archive/$defaultBranch.zip"
|
||||
val inputStream = download(zipUrl)
|
||||
if (inputStream == null) return null
|
||||
|
||||
val tempZipFileHandle = folderFileHandle.child("tempZip.zip")
|
||||
tempZipFileHandle.write(inputStream, false)
|
||||
val unzipDestination = tempZipFileHandle.sibling("tempZip") // folder, not file
|
||||
Zip.extractFolder(tempZipFileHandle, unzipDestination)
|
||||
val innerFolder = unzipDestination.list().first() // tempZip/<repoName>-master/
|
||||
|
||||
val finalDestinationName = innerFolder.name().replace("-$defaultBranch", "").replace('-', ' ')
|
||||
val finalDestination = folderFileHandle.child(finalDestinationName)
|
||||
finalDestination.mkdirs() // If we don't create this as a directory, it will think this is a file and nothing will work.
|
||||
for (innerFileOrFolder in innerFolder.list()) {
|
||||
innerFileOrFolder.moveTo(finalDestination)
|
||||
}
|
||||
|
||||
tempZipFileHandle.delete()
|
||||
unzipDestination.deleteDirectory()
|
||||
|
||||
return finalDestination
|
||||
}
|
||||
|
||||
|
||||
fun tryGetGithubReposWithTopic(amountPerPage:Int, page:Int): RepoSearch? {
|
||||
// Default per-page is 30 - when we get to above 100 mods, we'll need to start search-queries
|
||||
val inputStream = download("https://api.github.com/search/repositories?q=topic:unciv-mod&per_page=$amountPerPage&page=$page")
|
||||
if (inputStream == null) return null
|
||||
return GameSaver.json().fromJson(RepoSearch::class.java, inputStream.bufferedReader().readText())
|
||||
}
|
||||
|
||||
class RepoSearch {
|
||||
var items = ArrayList<Repo>()
|
||||
}
|
||||
|
||||
class Repo {
|
||||
var name = ""
|
||||
var description = ""
|
||||
var stargazers_count = 0
|
||||
var default_branch = ""
|
||||
var html_url = ""
|
||||
var updated_at = ""
|
||||
}
|
||||
}
|
||||
|
||||
object Zip {
|
||||
|
||||
// I went through a lot of similar answers that didn't work until I got to this gem by NeilMonday
|
||||
// (with mild changes to fit the FileHandles)
|
||||
// https://stackoverflow.com/questions/981578/how-to-unzip-files-recursively-in-java
|
||||
fun extractFolder(zipFile: FileHandle, unzipDestination: FileHandle) {
|
||||
println(zipFile)
|
||||
val BUFFER = 2048
|
||||
val file = zipFile.file()
|
||||
val zip = ZipFile(file)
|
||||
unzipDestination.mkdirs()
|
||||
val zipFileEntries = zip.entries()
|
||||
|
||||
// Process each entry
|
||||
while (zipFileEntries.hasMoreElements()) {
|
||||
// grab a zip file entry
|
||||
val entry = zipFileEntries.nextElement() as ZipEntry
|
||||
val currentEntry = entry.name
|
||||
val destFile = unzipDestination.child(currentEntry)
|
||||
val destinationParent = destFile.parent()
|
||||
|
||||
// create the parent directory structure if needed
|
||||
destinationParent.mkdirs()
|
||||
if (!entry.isDirectory) {
|
||||
val inputStream = BufferedInputStream(zip
|
||||
.getInputStream(entry))
|
||||
var currentByte: Int
|
||||
// establish buffer for writing file
|
||||
val data = ByteArray(BUFFER)
|
||||
|
||||
// write the current file to disk
|
||||
val fos = FileOutputStream(destFile.file())
|
||||
val dest = BufferedOutputStream(fos,
|
||||
BUFFER)
|
||||
|
||||
// read and write until last byte is encountered
|
||||
while (inputStream.read(data, 0, BUFFER).also { currentByte = it } != -1) {
|
||||
dest.write(data, 0, currentByte)
|
||||
}
|
||||
dest.flush()
|
||||
dest.close()
|
||||
inputStream.close()
|
||||
}
|
||||
if (currentEntry.endsWith(".zip")) {
|
||||
// found a zip file, try to open
|
||||
extractFolder(destFile, unzipDestination)
|
||||
}
|
||||
}
|
||||
zip.close() // Needed so we can delete the zip file later
|
||||
}
|
||||
}
|
336
core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt
Normal file
336
core/src/com/unciv/ui/worldscreen/mainmenu/GitHub.kt
Normal file
@ -0,0 +1,336 @@
|
||||
package com.unciv.ui.worldscreen.mainmenu
|
||||
|
||||
import com.badlogic.gdx.files.FileHandle
|
||||
import com.unciv.logic.GameSaver
|
||||
import java.io.*
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
|
||||
|
||||
/**
|
||||
* Utility managing Github access (except the link in WorldScreenCommunityPopup)
|
||||
*
|
||||
* Singleton - RateLimit is shared app-wide and has local variables, and is not tested for thread safety.
|
||||
* Therefore, additional effort is required should [tryGetGithubReposWithTopic] ever be called non-sequentially.
|
||||
* [download] and [downloadAndExtract] should be thread-safe as they are self-contained.
|
||||
* They do not join in the [RateLimit] handling because Github doc suggests each API
|
||||
* has a separate limit (and I found none for cloning via a zip).
|
||||
*/
|
||||
object Github {
|
||||
|
||||
// Consider merging this with the Dropbox function
|
||||
/**
|
||||
* Helper opens am url and accesses its input stream, logging errors to the console
|
||||
* @param url String representing a [URL] to download.
|
||||
* @param action Optional callback that will be executed between opening the connection and
|
||||
* accessing its data - passes the [connection][HttpURLConnection] and allows e.g. reading the response headers.
|
||||
* @return The [InputStream] if successful, `null` otherwise.
|
||||
*/
|
||||
fun download(url: String, action: (HttpURLConnection) -> Unit = {}): InputStream? {
|
||||
with(URL(url).openConnection() as HttpURLConnection)
|
||||
{
|
||||
action(this)
|
||||
|
||||
return try {
|
||||
inputStream
|
||||
} catch (ex: Exception) {
|
||||
println(ex.message)
|
||||
val reader = BufferedReader(InputStreamReader(errorStream))
|
||||
println(reader.readText())
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a mod and extract, deleting any pre-existing version.
|
||||
* @param gitRepoUrl Url of the repository as delivered by the Github search query
|
||||
* @param defaultBranch Branch name as delivered by the Github search query
|
||||
* @param folderFileHandle Destination handle of mods folder - also controls Android internal/external
|
||||
* @author **Warning**: This took a long time to get just right, so if you're changing this, ***TEST IT THOROUGHLY*** on _both_ Desktop _and_ Phone
|
||||
* @return FileHandle for the downloaded Mod's folder or null if download failed
|
||||
*/
|
||||
fun downloadAndExtract(
|
||||
gitRepoUrl: String,
|
||||
defaultBranch: String,
|
||||
folderFileHandle: FileHandle
|
||||
): FileHandle? {
|
||||
// Initiate download - the helper returns null when it fails
|
||||
val zipUrl = "$gitRepoUrl/archive/$defaultBranch.zip"
|
||||
val inputStream = download(zipUrl) ?: return null
|
||||
|
||||
// Download to temporary zip
|
||||
val tempZipFileHandle = folderFileHandle.child("tempZip.zip")
|
||||
tempZipFileHandle.write(inputStream, false)
|
||||
|
||||
// prepare temp unpacking folder
|
||||
val unzipDestination = tempZipFileHandle.sibling("tempZip") // folder, not file
|
||||
// prevent mixing new content with old - hopefully there will never be cadavers of our tempZip stuff
|
||||
if (unzipDestination.exists())
|
||||
if (unzipDestination.isDirectory) unzipDestination.deleteDirectory() else unzipDestination.delete()
|
||||
|
||||
Zip.extractFolder(tempZipFileHandle, unzipDestination)
|
||||
|
||||
val innerFolder = unzipDestination.list().first()
|
||||
// innerFolder should now be "tempZip/$repoName-$defaultBranch/" - use this to get mod name
|
||||
val finalDestinationName = innerFolder.name().replace("-$defaultBranch", "").replace('-', ' ')
|
||||
// finalDestinationName is now the mod name as we display it. Folder name needs to be identical.
|
||||
val finalDestination = folderFileHandle.child(finalDestinationName)
|
||||
|
||||
// prevent mixing new content with old
|
||||
var tempBackup: FileHandle? = null
|
||||
if (finalDestination.exists()) {
|
||||
tempBackup = finalDestination.sibling("$finalDestinationName.updating")
|
||||
finalDestination.moveTo(tempBackup)
|
||||
}
|
||||
|
||||
// Move temp unpacked content to their final place
|
||||
finalDestination.mkdirs() // If we don't create this as a directory, it will think this is a file and nothing will work.
|
||||
// The move will reset the last modified time (recursively, at least on Linux)
|
||||
// This sort will guarantee the desktop launcher will not re-pack textures and overwrite the atlas as delivered by the mod
|
||||
for (innerFileOrFolder in innerFolder.list()
|
||||
.sortedBy { file -> file.extension() == "atlas" } ) {
|
||||
innerFileOrFolder.moveTo(finalDestination)
|
||||
}
|
||||
|
||||
// clean up
|
||||
tempZipFileHandle.delete()
|
||||
unzipDestination.deleteDirectory()
|
||||
if (tempBackup != null)
|
||||
if (tempBackup.isDirectory) tempBackup.deleteDirectory() else tempBackup.delete()
|
||||
|
||||
return finalDestination
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the ability wo work with GitHub's rate limit, recognize blocks from previous attempts, wait and retry.
|
||||
*/
|
||||
object RateLimit {
|
||||
// https://docs.github.com/en/rest/reference/search#rate-limit
|
||||
const val maxRequestsPerInterval = 10
|
||||
const val intervalInMilliSeconds = 60000L
|
||||
private const val maxWaitLoop = 3
|
||||
|
||||
private var account = 0 // used requests
|
||||
private var firstRequest = 0L // timestamp window start (java epoch millisecond)
|
||||
|
||||
/*
|
||||
Github rate limits do not use sliding windows - you (if anonymous) get one window
|
||||
which starts with the first request (if a window is not already active)
|
||||
and ends 60s later, and a budget of 10 requests in that window. Once it expires,
|
||||
everything is forgotten and the process starts from scratch
|
||||
*/
|
||||
|
||||
private val millis: Long
|
||||
get() = System.currentTimeMillis()
|
||||
|
||||
/** calculate required wait in ms
|
||||
* @return Estimated number of milliseconds to wait for the rate limit window to expire
|
||||
*/
|
||||
private fun getWaitLength()
|
||||
= (firstRequest + intervalInMilliSeconds - millis)
|
||||
|
||||
/** Maintain and check a rate-limit
|
||||
* @return **true** if rate-limited, **false** if another request is allowed
|
||||
*/
|
||||
private fun isLimitReached(): Boolean {
|
||||
val now = millis
|
||||
val elapsed = if (firstRequest == 0L) intervalInMilliSeconds else now - firstRequest
|
||||
if (elapsed >= intervalInMilliSeconds) {
|
||||
firstRequest = now
|
||||
account = 1
|
||||
return false
|
||||
}
|
||||
if (account >= maxRequestsPerInterval) return true
|
||||
account++
|
||||
return false
|
||||
}
|
||||
|
||||
/** If rate limit in effect, sleep long enough to allow next request.
|
||||
*
|
||||
* @return **true** if waiting did not clear isLimitReached() (can only happen if the clock is broken),
|
||||
* or the wait has been interrupted by Thread.interrupt()
|
||||
* **false** if we were below the limit or slept long enough to drop out of it.
|
||||
*/
|
||||
fun waitForLimit(): Boolean {
|
||||
var loopCount = 0
|
||||
while (isLimitReached()) {
|
||||
val waitLength = getWaitLength()
|
||||
try {
|
||||
Thread.sleep(waitLength)
|
||||
} catch ( ex: InterruptedException ) {
|
||||
return true
|
||||
}
|
||||
if (++loopCount >= maxWaitLoop) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** http responses should be passed to this so the actual rate limit window can be evaluated and used.
|
||||
* The very first response and all 403 ones are good candidates if they can be expected to contain GitHub's rate limit headers.
|
||||
*
|
||||
* see: https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting
|
||||
*/
|
||||
fun notifyHttpResponse(response: HttpURLConnection) {
|
||||
if (response.responseMessage != "rate limit exceeded" && response.responseCode != 200) return
|
||||
|
||||
fun getHeaderLong(name: String, default: Long = 0L) =
|
||||
response.headerFields[name]?.get(0)?.toLongOrNull() ?: default
|
||||
val limit = getHeaderLong("X-RateLimit-Limit", maxRequestsPerInterval.toLong()).toInt()
|
||||
val remaining = getHeaderLong("X-RateLimit-Remaining").toInt()
|
||||
val reset = getHeaderLong("X-RateLimit-Reset")
|
||||
|
||||
if (limit != maxRequestsPerInterval)
|
||||
println("GitHub API Limit reported via http ($limit) not equal assumed value ($maxRequestsPerInterval)")
|
||||
account = maxRequestsPerInterval - remaining
|
||||
if (reset == 0L) return
|
||||
firstRequest = (reset + 1L) * 1000L - intervalInMilliSeconds
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query GitHub for repositories marked "unciv-mod"
|
||||
* @param amountPerPage Number of search results to return for this request.
|
||||
* @param page The "page" number, starting at 1.
|
||||
* @return Parsed [RepoSearch] json on success, `null` on failure.
|
||||
* @see <a href="https://docs.github.com/en/rest/reference/search#search-repositories">Github API doc</a>
|
||||
*/
|
||||
fun tryGetGithubReposWithTopic(amountPerPage:Int, page:Int): RepoSearch? {
|
||||
val link = "https://api.github.com/search/repositories?q=topic:unciv-mod&sort:stars&per_page=$amountPerPage&page=$page"
|
||||
var retries = 2
|
||||
while (retries > 0) {
|
||||
retries--
|
||||
// obey rate limit
|
||||
if (RateLimit.waitForLimit()) return null
|
||||
// try download
|
||||
val inputStream = download(link) {
|
||||
if (it.responseCode == 403 || it.responseCode == 200 && page == 1 && retries == 1) {
|
||||
// Pass the response headers to the rate limit handler so it can process the rate limit headers
|
||||
RateLimit.notifyHttpResponse(it)
|
||||
retries++ // An extra retry so the 403 is ignored in the retry count
|
||||
}
|
||||
} ?: continue
|
||||
return GameSaver.json().fromJson(RepoSearch::class.java, inputStream.bufferedReader().readText())
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed GitHub repo search response
|
||||
* @property total_count Total number of hits for the search (ignoring paging window)
|
||||
* @property incomplete_results A flag set by github to indicate search was incomplete (never seen it on)
|
||||
* @property items Array of [repositories][Repo]
|
||||
* @see <a href="https://docs.github.com/en/rest/reference/search#search-repositories--code-samples">Github API doc</a>
|
||||
*/
|
||||
@Suppress("PropertyName")
|
||||
class RepoSearch {
|
||||
var total_count = 0
|
||||
var incomplete_results = false
|
||||
var items = ArrayList<Repo>()
|
||||
}
|
||||
|
||||
/** Part of [RepoSearch] in Github API response - one repository entry in [items][RepoSearch.items] */
|
||||
@Suppress("PropertyName")
|
||||
class Repo {
|
||||
var name = ""
|
||||
var full_name = ""
|
||||
var description: String? = null
|
||||
var owner = RepoOwner()
|
||||
var stargazers_count = 0
|
||||
var default_branch = ""
|
||||
var html_url = ""
|
||||
var updated_at = ""
|
||||
//var pushed_at = "" // if > updated_at might indicate an update soon?
|
||||
var size = 0
|
||||
//var stargazers_url = ""
|
||||
//var homepage: String? = null // might use instead of go to repo?
|
||||
//var has_wiki = false // a wiki could mean proper documentation for the mod?
|
||||
}
|
||||
|
||||
/** Part of [Repo] in Github API response */
|
||||
@Suppress("PropertyName")
|
||||
class RepoOwner {
|
||||
var login = ""
|
||||
var avatar_url: String? = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Utility - extract Zip archives
|
||||
* @see [Zip.extractFolder]
|
||||
*/
|
||||
object Zip {
|
||||
private const val bufferSize = 2048
|
||||
|
||||
/**
|
||||
* Extract one Zip file recursively (nested Zip files are extracted in turn).
|
||||
*
|
||||
* The source Zip is not deleted, but successfully extracted nested ones are.
|
||||
*
|
||||
* **Warning**: Extracting into a non-empty destination folder will merge contents. Existing
|
||||
* files also included in the archive will be partially overwritten, when the new data is shorter
|
||||
* than the old you will get _mixed contents!_
|
||||
*
|
||||
* @param zipFile The Zip file to extract
|
||||
* @param unzipDestination The folder to extract into, preferably empty (not enforced).
|
||||
*/
|
||||
fun extractFolder(zipFile: FileHandle, unzipDestination: FileHandle) {
|
||||
// I went through a lot of similar answers that didn't work until I got to this gem by NeilMonday
|
||||
// (with mild changes to fit the FileHandles)
|
||||
// https://stackoverflow.com/questions/981578/how-to-unzip-files-recursively-in-java
|
||||
|
||||
println("Extracting $zipFile to $unzipDestination")
|
||||
// establish buffer for writing file
|
||||
val data = ByteArray(bufferSize)
|
||||
|
||||
fun streamCopy(fromStream: InputStream, toHandle: FileHandle) {
|
||||
val inputStream = BufferedInputStream(fromStream)
|
||||
var currentByte: Int
|
||||
|
||||
// write the current file to disk
|
||||
val fos = FileOutputStream(toHandle.file())
|
||||
val dest = BufferedOutputStream(fos, bufferSize)
|
||||
|
||||
// read and write until last byte is encountered
|
||||
while (inputStream.read(data, 0, bufferSize).also { currentByte = it } != -1) {
|
||||
dest.write(data, 0, currentByte)
|
||||
}
|
||||
dest.flush()
|
||||
dest.close()
|
||||
inputStream.close()
|
||||
}
|
||||
|
||||
val file = zipFile.file()
|
||||
val zip = ZipFile(file)
|
||||
//unzipDestination.mkdirs()
|
||||
val zipFileEntries = zip.entries()
|
||||
|
||||
// Process each entry
|
||||
while (zipFileEntries.hasMoreElements()) {
|
||||
// grab a zip file entry
|
||||
val entry = zipFileEntries.nextElement() as ZipEntry
|
||||
val currentEntry = entry.name
|
||||
val destFile = unzipDestination.child(currentEntry)
|
||||
val destinationParent = destFile.parent()
|
||||
|
||||
// create the parent directory structure if needed
|
||||
destinationParent.mkdirs()
|
||||
if (!entry.isDirectory) {
|
||||
streamCopy ( zip.getInputStream(entry), destFile)
|
||||
}
|
||||
// The new file has a current last modification time
|
||||
// and not the one stored in the archive - we could:
|
||||
// 'destFile.file().setLastModified(entry.time)'
|
||||
// but later handling will throw these away anyway,
|
||||
// and GitHub sets all timestamps to the download time.
|
||||
|
||||
if (currentEntry.endsWith(".zip")) {
|
||||
// found a zip file, try to open
|
||||
extractFolder(destFile, destinationParent)
|
||||
destFile.delete()
|
||||
}
|
||||
}
|
||||
zip.close() // Needed so we can delete the zip file later
|
||||
}
|
||||
}
|
@ -57,6 +57,8 @@ internal object DesktopLauncher {
|
||||
LwjglApplication(game, config)
|
||||
}
|
||||
|
||||
// Work in Progress?
|
||||
@Suppress("unused")
|
||||
private fun startMultiplayerServer() {
|
||||
// val games = HashMap<String, GameSetupInfo>()
|
||||
val files = HashMap<String, String>()
|
||||
@ -115,7 +117,7 @@ internal object DesktopLauncher {
|
||||
// https://github.com/yairm210/UnCiv/issues/1340
|
||||
|
||||
/**
|
||||
* These should be as big as possible in order to accommodate ALL the images together in one bug file.
|
||||
* These should be as big as possible in order to accommodate ALL the images together in one big file.
|
||||
* Why? Because the rendering function of the main screen renders all the images consecutively, and every time it needs to switch between textures,
|
||||
* this causes a delay, leading to horrible lag if there are enough switches.
|
||||
* The cost of this specific solution is that the entire game.png needs be be kept in-memory constantly.
|
||||
@ -163,14 +165,14 @@ internal object DesktopLauncher {
|
||||
private fun packImagesIfOutdated(settings: TexturePacker.Settings, input: String, output: String, packFileName: String) {
|
||||
fun File.listTree(): Sequence<File> = when {
|
||||
this.isFile -> sequenceOf(this)
|
||||
this.isDirectory -> this.listFiles().asSequence().flatMap { it.listTree() }
|
||||
this.isDirectory -> this.listFiles()!!.asSequence().flatMap { it.listTree() }
|
||||
else -> sequenceOf()
|
||||
}
|
||||
|
||||
val atlasFile = File("$output${File.separator}$packFileName.atlas")
|
||||
if (atlasFile.exists() && File("$output${File.separator}$packFileName.png").exists()) {
|
||||
val atlasModTime = atlasFile.lastModified()
|
||||
if (!File(input).listTree().any { it.extension in listOf("png", "jpg", "jpeg") && it.lastModified() > atlasModTime }) return
|
||||
if (File(input).listTree().none { it.extension in listOf("png", "jpg", "jpeg") && it.lastModified() > atlasModTime }) return
|
||||
}
|
||||
|
||||
TexturePacker.process(settings, input, output, packFileName)
|
||||
|
Loading…
Reference in New Issue
Block a user