mirror of
https://github.com/yairm210/Unciv.git
synced 2025-09-11 21:26:22 -04:00
Download mod releases or any mod zip (#10881)
* Allow almost any mod zip download source * Linting * Add basic "Release page section" link capability, more linting * Refactor: Move Github to logic * Refactor: Split Github into files * Refactor: centralizing all API knowledge * Try bumping detekt version to fix false complaint * Attempt to trick detekt to not complain about `it` * Better overload-ambiguity-solving + detekt-compatible SAM use
This commit is contained in:
parent
68786d7603
commit
50a6e5bbdb
2
.github/workflows/detektAnalysis.yml
vendored
2
.github/workflows/detektAnalysis.yml
vendored
@ -29,7 +29,7 @@ jobs:
|
|||||||
id: setup_detekt
|
id: setup_detekt
|
||||||
uses: peter-murray/setup-detekt@v2
|
uses: peter-murray/setup-detekt@v2
|
||||||
with:
|
with:
|
||||||
detekt_version: '1.23.1'
|
detekt_version: '1.23.4'
|
||||||
|
|
||||||
- name: Detekt errors
|
- name: Detekt errors
|
||||||
run: detekt-cli --parallel --config detekt/config/detekt-errors.yml
|
run: detekt-cli --parallel --config detekt/config/detekt-errors.yml
|
||||||
|
@ -1789,7 +1789,9 @@ Download [modName] =
|
|||||||
Update [modName] =
|
Update [modName] =
|
||||||
Could not download mod list =
|
Could not download mod list =
|
||||||
Download mod from URL =
|
Download mod from URL =
|
||||||
Please enter the mod repository -or- archive zip -or- branch url: =
|
Please enter the mod repository -or- archive zip -or- branch -or- release url: =
|
||||||
|
That is not a valid ZIP file =
|
||||||
|
Invalid Mod archive structure =
|
||||||
Invalid link! =
|
Invalid link! =
|
||||||
Paste from clipboard =
|
Paste from clipboard =
|
||||||
Download =
|
Download =
|
||||||
|
@ -39,7 +39,7 @@ import com.unciv.models.ruleset.unique.UniqueType
|
|||||||
import com.unciv.models.translations.tr
|
import com.unciv.models.translations.tr
|
||||||
import com.unciv.ui.audio.MusicMood
|
import com.unciv.ui.audio.MusicMood
|
||||||
import com.unciv.ui.audio.MusicTrackChooserFlags
|
import com.unciv.ui.audio.MusicTrackChooserFlags
|
||||||
import com.unciv.ui.screens.pickerscreens.Github.repoNameToFolderName
|
import com.unciv.logic.github.Github.repoNameToFolderName
|
||||||
import com.unciv.ui.screens.savescreens.Gzip
|
import com.unciv.ui.screens.savescreens.Gzip
|
||||||
import com.unciv.ui.screens.worldscreen.status.NextTurnProgress
|
import com.unciv.ui.screens.worldscreen.status.NextTurnProgress
|
||||||
import com.unciv.utils.DebugUtils
|
import com.unciv.utils.DebugUtils
|
||||||
|
356
core/src/com/unciv/logic/github/Github.kt
Normal file
356
core/src/com/unciv/logic/github/Github.kt
Normal file
@ -0,0 +1,356 @@
|
|||||||
|
package com.unciv.logic.github
|
||||||
|
|
||||||
|
import com.badlogic.gdx.Files
|
||||||
|
import com.badlogic.gdx.files.FileHandle
|
||||||
|
import com.badlogic.gdx.graphics.Pixmap
|
||||||
|
import com.unciv.json.fromJsonFile
|
||||||
|
import com.unciv.json.json
|
||||||
|
import com.unciv.logic.BackwardCompatibility.updateDeprecations
|
||||||
|
import com.unciv.logic.UncivShowableException
|
||||||
|
import com.unciv.logic.github.Github.download
|
||||||
|
import com.unciv.logic.github.Github.downloadAndExtract
|
||||||
|
import com.unciv.logic.github.Github.tryGetGithubReposWithTopic
|
||||||
|
import com.unciv.logic.github.GithubAPI.getUrlForTreeQuery
|
||||||
|
import com.unciv.models.ruleset.ModOptions
|
||||||
|
import com.unciv.utils.Log
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.FileFilter
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.util.zip.ZipException
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
private const val contentDispositionHeader = "Content-Disposition"
|
||||||
|
private const val attachmentDispositionPrefix = "attachment;filename="
|
||||||
|
|
||||||
|
// 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? {
|
||||||
|
try {
|
||||||
|
// Problem type 1 - opening the URL connection
|
||||||
|
with(URL(url).openConnection() as HttpURLConnection)
|
||||||
|
{
|
||||||
|
action(this)
|
||||||
|
// Problem type 2 - getting the information
|
||||||
|
try {
|
||||||
|
return inputStream
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
// No error handling, just log the message.
|
||||||
|
// NOTE that this 'read error stream' CAN ALSO fail, but will be caught by the big try/catch
|
||||||
|
val reader = BufferedReader(InputStreamReader(errorStream, Charsets.UTF_8))
|
||||||
|
Log.error("Message from GitHub: %s", reader.readText())
|
||||||
|
throw ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Log.error("Exception during GitHub download", ex)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a mod and extract, deleting any pre-existing version.
|
||||||
|
* @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(
|
||||||
|
repo: GithubAPI.Repo,
|
||||||
|
folderFileHandle: FileHandle
|
||||||
|
): FileHandle? {
|
||||||
|
var modNameFromFileName = repo.name
|
||||||
|
|
||||||
|
val defaultBranch = repo.default_branch
|
||||||
|
val zipUrl: String
|
||||||
|
val tempName: String
|
||||||
|
if (repo.direct_zip_url.isEmpty()) {
|
||||||
|
val gitRepoUrl = repo.html_url
|
||||||
|
// Initiate download - the helper returns null when it fails
|
||||||
|
zipUrl = GithubAPI.getUrlForBranchZip(gitRepoUrl, defaultBranch)
|
||||||
|
|
||||||
|
// Get a mod-specific temp file name
|
||||||
|
tempName = "temp-" + gitRepoUrl.hashCode().toString(16)
|
||||||
|
} else {
|
||||||
|
zipUrl = repo.direct_zip_url
|
||||||
|
tempName = "temp-" + repo.toString().hashCode().toString(16)
|
||||||
|
}
|
||||||
|
val inputStream = download(zipUrl) {
|
||||||
|
val disposition = it.getHeaderField(contentDispositionHeader)
|
||||||
|
if (disposition.startsWith(attachmentDispositionPrefix))
|
||||||
|
modNameFromFileName = disposition.removePrefix(attachmentDispositionPrefix)
|
||||||
|
.removeSuffix(".zip").replace('.', ' ')
|
||||||
|
// We could check Content-Type=[application/x-zip-compressed] here, but the ZipFile will catch that anyway. Would save some bandwidth, however.
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
// Download to temporary zip
|
||||||
|
val tempZipFileHandle = folderFileHandle.child("$tempName.zip")
|
||||||
|
tempZipFileHandle.write(inputStream, false)
|
||||||
|
|
||||||
|
// prepare temp unpacking folder
|
||||||
|
val unzipDestination = tempZipFileHandle.sibling(tempName) // 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()
|
||||||
|
|
||||||
|
try {
|
||||||
|
Zip.extractFolder(tempZipFileHandle, unzipDestination)
|
||||||
|
} catch (ex: ZipException) {
|
||||||
|
throw UncivShowableException("That is not a valid ZIP file", ex)
|
||||||
|
}
|
||||||
|
|
||||||
|
val (innerFolder, modName) = resolveZipStructure(unzipDestination, modNameFromFileName)
|
||||||
|
|
||||||
|
// modName can be "$repoName-$defaultBranch"
|
||||||
|
val finalDestinationName = modName.replace("-$defaultBranch", "").repoNameToFolderName()
|
||||||
|
// 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.renameOrMove(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.renameOrMove(finalDestination)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clean up
|
||||||
|
tempZipFileHandle.delete()
|
||||||
|
unzipDestination.deleteDirectory()
|
||||||
|
if (tempBackup != null)
|
||||||
|
if (tempBackup.isDirectory) tempBackup.deleteDirectory() else tempBackup.delete()
|
||||||
|
|
||||||
|
return finalDestination
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidModFolder(dir: FileHandle): Boolean {
|
||||||
|
val goodFolders = listOf("Images", "jsons", "maps", "music", "sounds", "Images\\..*")
|
||||||
|
.map { Regex(it, RegexOption.IGNORE_CASE) }
|
||||||
|
val goodFiles = listOf(".*\\.atlas", ".*\\.png", "preview.jpg", ".*\\.md", "Atlases.json", ".nomedia", "license")
|
||||||
|
.map { Regex(it, RegexOption.IGNORE_CASE) }
|
||||||
|
var good = 0
|
||||||
|
var bad = 0
|
||||||
|
for (file in dir.list()) {
|
||||||
|
val goodList = if (file.isDirectory) goodFolders else goodFiles
|
||||||
|
if (goodList.any { file.name().matches(it) }) good++ else bad++
|
||||||
|
}
|
||||||
|
return good > 0 && good > bad
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether the unpacked zip contains a subfolder with mod content or is already the mod.
|
||||||
|
* If there's a subfolder we'll assume it is the mod name, optionally suffixed with branch or release-tag name like github does.
|
||||||
|
* @return Pair: actual mod content folder to name (subfolder name or [defaultModName])
|
||||||
|
*/
|
||||||
|
private fun resolveZipStructure(dir: FileHandle, defaultModName: String): Pair<FileHandle, String> {
|
||||||
|
if (isValidModFolder(dir))
|
||||||
|
return dir to defaultModName
|
||||||
|
val subdirs = dir.list(FileFilter { it.isDirectory }) // See detekt/#6822 - a direct lambda-to-SAM with typed `it` fails detektAnalysis
|
||||||
|
if (subdirs.size == 1 && isValidModFolder(subdirs[0]))
|
||||||
|
return subdirs[0] to subdirs[0].name()
|
||||||
|
throw UncivShowableException("Invalid Mod archive structure")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun FileHandle.renameOrMove(dest: FileHandle) {
|
||||||
|
// Gdx tries a java rename for Absolute and External, but NOT for Local - rectify that
|
||||||
|
if (type() == Files.FileType.Local) {
|
||||||
|
// See #5346: renameTo 'unpacks' a source folder if the destination exists and is an
|
||||||
|
// empty folder, dropping the name of the source entirely.
|
||||||
|
// Safer to ask for a move to the not-yet-existing full resulting path instead.
|
||||||
|
if (isDirectory)
|
||||||
|
if (file().renameTo(dest.child(name()).file())) return
|
||||||
|
else
|
||||||
|
if (file().renameTo(dest.file())) return
|
||||||
|
}
|
||||||
|
moveTo(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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][GithubAPI.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, searchRequest: String = ""): GithubAPI.RepoSearch? {
|
||||||
|
val link = GithubAPI.getUrlForModListing(searchRequest, amountPerPage, 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 json().fromJson(GithubAPI.RepoSearch::class.java, inputStream.bufferedReader().readText())
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a Pixmap from a "preview" png or jpg file at the root of the repo, falling back to the
|
||||||
|
* repo owner's avatar [avatarUrl]. The file content url is constructed from [modUrl] and [defaultBranch]
|
||||||
|
* by replacing the host with `raw.githubusercontent.com`.
|
||||||
|
*/
|
||||||
|
fun tryGetPreviewImage(modUrl: String, defaultBranch: String, avatarUrl: String?): Pixmap? {
|
||||||
|
// Side note: github repos also have a "Social Preview" optionally assignable on the repo's
|
||||||
|
// settings page, but that info is inaccessible using the v3 API anonymously. The easiest way
|
||||||
|
// to get it would be to query the the repo's frontend page (modUrl), and parse out
|
||||||
|
// `head/meta[property=og:image]/@content`, which is one extra spurious roundtrip and a
|
||||||
|
// non-trivial waste of bandwidth.
|
||||||
|
// Thus we ask for a "preview" file as part of the repo contents instead.
|
||||||
|
val fileLocation = GithubAPI.getUrlForPreview(modUrl, defaultBranch)
|
||||||
|
try {
|
||||||
|
val file = download("$fileLocation.jpg")
|
||||||
|
?: download("$fileLocation.png")
|
||||||
|
// Note: avatar urls look like: https://avatars.githubusercontent.com/u/<number>?v=4
|
||||||
|
// So the image format is only recognizable from the response "Content-Type" header
|
||||||
|
// or by looking for magic markers in the bits - which the Pixmap constructor below does.
|
||||||
|
?: avatarUrl?.let { download(it) }
|
||||||
|
?: return null
|
||||||
|
val byteArray = file.readBytes()
|
||||||
|
val buffer = ByteBuffer.allocateDirect(byteArray.size).put(byteArray).position(0)
|
||||||
|
return Pixmap(buffer)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Queries github for a tree and calculates the sum of the blob sizes.
|
||||||
|
* @return -1 on failure, else size rounded to kB
|
||||||
|
* @see <a href="https://docs.github.com/en/rest/git/trees#get-a-tree">Github API "Get a tree"</a>
|
||||||
|
*/
|
||||||
|
fun getRepoSize(repo: GithubAPI.Repo): Int {
|
||||||
|
val link = repo.getUrlForTreeQuery()
|
||||||
|
|
||||||
|
var retries = 2
|
||||||
|
while (retries > 0) {
|
||||||
|
retries--
|
||||||
|
// obey rate limit
|
||||||
|
if (RateLimit.waitForLimit()) return -1
|
||||||
|
// try download
|
||||||
|
val inputStream = download(link) {
|
||||||
|
if (it.responseCode == 403 || it.responseCode == 200 && 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
|
||||||
|
|
||||||
|
val tree = json().fromJson(GithubAPI.Tree::class.java, inputStream.bufferedReader().readText())
|
||||||
|
if (tree.truncated) return -1 // unlikely: >100k blobs or blob > 7MB
|
||||||
|
|
||||||
|
var totalSizeBytes = 0L
|
||||||
|
for (file in tree.tree)
|
||||||
|
totalSizeBytes += file.size
|
||||||
|
|
||||||
|
// overflow unlikely: >2TB
|
||||||
|
return ((totalSizeBytes + 512) / 1024).toInt()
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query GitHub for topics named "unciv-mod*"
|
||||||
|
* @return Parsed [TopicSearchResponse][GithubAPI.TopicSearchResponse] json on success, `null` on failure.
|
||||||
|
*/
|
||||||
|
fun tryGetGithubTopics(): GithubAPI.TopicSearchResponse? {
|
||||||
|
val link = GithubAPI.urlToQueryModTopics
|
||||||
|
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 && 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 json().fromJson(GithubAPI.TopicSearchResponse::class.java, inputStream.bufferedReader().readText())
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrite modOptions file for a mod we just installed to include metadata we got from the GitHub api
|
||||||
|
*
|
||||||
|
* (called on background thread)
|
||||||
|
*/
|
||||||
|
fun rewriteModOptions(repo: GithubAPI.Repo, modFolder: FileHandle) {
|
||||||
|
val modOptionsFile = modFolder.child("jsons/ModOptions.json")
|
||||||
|
val modOptions = if (modOptionsFile.exists()) json().fromJsonFile(ModOptions::class.java, modOptionsFile) else ModOptions()
|
||||||
|
|
||||||
|
// If this is false we didn't get github repo info, do a defensive merge so the Repo.parseUrl or download
|
||||||
|
// code can decide defaults but leave any meaningful field of a zip-included ModOptions alone.
|
||||||
|
val overwriteAlways = repo.direct_zip_url.isEmpty()
|
||||||
|
|
||||||
|
if (overwriteAlways || modOptions.modUrl.isEmpty()) modOptions.modUrl = repo.html_url
|
||||||
|
if (overwriteAlways || modOptions.defaultBranch == "master" && repo.default_branch.isNotEmpty())
|
||||||
|
modOptions.defaultBranch = repo.default_branch
|
||||||
|
if (overwriteAlways || modOptions.lastUpdated.isEmpty()) modOptions.lastUpdated = repo.pushed_at
|
||||||
|
if (overwriteAlways || modOptions.author.isEmpty()) modOptions.author = repo.owner.login
|
||||||
|
if (overwriteAlways || modOptions.modSize == 0) modOptions.modSize = repo.size
|
||||||
|
if (overwriteAlways || modOptions.topics.isEmpty()) modOptions.topics = repo.topics
|
||||||
|
|
||||||
|
modOptions.updateDeprecations()
|
||||||
|
json().toJson(modOptions, modOptionsFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val outerBlankReplacement = '='
|
||||||
|
// Github disallows **any** special chars and replaces them with '-' - so use something ascii the
|
||||||
|
// OS accepts but still is recognizable as non-original, to avoid confusion
|
||||||
|
|
||||||
|
/** Convert a [Repo][[GithubAPI.Repo] name to a local name for both display and folder name
|
||||||
|
*
|
||||||
|
* Replaces '-' with blanks but ensures no leading or trailing blanks.
|
||||||
|
* As mad modders know no limits, trailing "-" did indeed happen, causing things to break due to trailing blanks on a folder name.
|
||||||
|
* As "test-" and "test" are different allowed repository names, trimmed blanks are replaced with one equals sign per side.
|
||||||
|
* @param onlyOuterBlanks If `true` ignores inner dashes - only start and end are treated. Useful when modders have manually created local folder names using dashes.
|
||||||
|
*/
|
||||||
|
fun String.repoNameToFolderName(onlyOuterBlanks: Boolean = false): String {
|
||||||
|
var result = if (onlyOuterBlanks) this else replace('-', ' ')
|
||||||
|
if (result.endsWith(' ')) result = result.trimEnd() + outerBlankReplacement
|
||||||
|
if (result.startsWith(' ')) result = outerBlankReplacement + result.trimStart()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inverse of [repoNameToFolderName] */
|
||||||
|
// As of this writing, only used for loadMissingMods
|
||||||
|
fun String.folderNameToRepoName(): String {
|
||||||
|
var result = replace(' ', '-')
|
||||||
|
if (result.endsWith(outerBlankReplacement)) result = result.trimEnd(outerBlankReplacement) + '-'
|
||||||
|
if (result.startsWith(outerBlankReplacement)) result = '-' + result.trimStart(outerBlankReplacement)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
242
core/src/com/unciv/logic/github/GithubAPI.kt
Normal file
242
core/src/com/unciv/logic/github/GithubAPI.kt
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
package com.unciv.logic.github
|
||||||
|
|
||||||
|
import com.unciv.json.json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Namespace" collects all Github API structural knowledge
|
||||||
|
* - Response schema
|
||||||
|
* - Query URL builders
|
||||||
|
*/
|
||||||
|
@Suppress("PropertyName") // We're declaring an external API schema
|
||||||
|
object GithubAPI {
|
||||||
|
// region URL formatters
|
||||||
|
|
||||||
|
/** Format a download URL for a branch archive */
|
||||||
|
// URL format see: https://docs.github.com/en/repositories/working-with-files/using-files/downloading-source-code-archives#source-code-archive-urls
|
||||||
|
// Note: https://api.github.com/repos/owner/mod/zipball would be an alternative. Its response is a redirect, but our lib follows that and delivers the zip just fine.
|
||||||
|
// Problems with the latter: Internal zip structure different, finalDestinationName would need a patch. Plus, normal URL escaping for owner/reponame does not work.
|
||||||
|
internal fun getUrlForBranchZip(gitRepoUrl: String, branch: String) = "$gitRepoUrl/archive/refs/heads/$branch.zip"
|
||||||
|
|
||||||
|
/** Format a URL to query for Mod repos by topic */
|
||||||
|
internal fun getUrlForModListing(searchRequest: String, amountPerPage: Int, page: Int) =
|
||||||
|
// Add + if needed to separate the query text from its parameters
|
||||||
|
"https://api.github.com/search/repositories?q=$searchRequest${ if (searchRequest.isEmpty()) "" else "+" }%20topic:unciv-mod%20fork:true&sort:stars&per_page=$amountPerPage&page=$page"
|
||||||
|
|
||||||
|
/** Format URL to fetch one specific [Repo] metadata from the API */
|
||||||
|
private fun getUrlForSingleRepoQuery(owner: String, repoName: String) = "https://api.github.com/repos/$owner/$repoName"
|
||||||
|
|
||||||
|
/** Format a download URL for a release archive */
|
||||||
|
private fun Repo.getUrlForReleaseZip() = "$html_url/archive/refs/tags/$release_tag.zip"
|
||||||
|
|
||||||
|
/** Format a URL to query a repo tree - to calculate actual size */
|
||||||
|
internal fun Repo.getUrlForTreeQuery() =
|
||||||
|
"https://api.github.com/repos/$full_name/git/trees/$default_branch?recursive=true"
|
||||||
|
|
||||||
|
/** Format a URL to fetch a preview image - without extension */
|
||||||
|
internal fun getUrlForPreview(modUrl: String, branch: String) = "$modUrl/$branch/preview"
|
||||||
|
.replace("github.com", "raw.githubusercontent.com")
|
||||||
|
|
||||||
|
/** A query returning all known topics staring with "unciv-mod" and having at least two uses */
|
||||||
|
// `+repositories:>1` means ignore unused or practically unused topics
|
||||||
|
internal const val urlToQueryModTopics = "https://api.github.com/search/topics?q=unciv-mod+repositories:%3E1&sort=name&order=asc"
|
||||||
|
|
||||||
|
//endregion
|
||||||
|
//region responses
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
*/
|
||||||
|
class RepoSearch {
|
||||||
|
@Suppress("MemberVisibilityCanBePrivate")
|
||||||
|
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] */
|
||||||
|
class Repo {
|
||||||
|
|
||||||
|
/** Unlike the rest of this class, this is not part of the API but added by us locally
|
||||||
|
* to track whether [getRepoSize][Github.getRepoSize] has been run successfully for this repo */
|
||||||
|
var hasUpdatedSize = false
|
||||||
|
|
||||||
|
/** Not part of the github schema: Explicit final zip download URL for non-github or release downloads */
|
||||||
|
var direct_zip_url = ""
|
||||||
|
/** Not part of the github schema: release tag, for debugging (DL via direct_zip_url) */
|
||||||
|
var release_tag = ""
|
||||||
|
|
||||||
|
var name = ""
|
||||||
|
var full_name = ""
|
||||||
|
var description: String? = null
|
||||||
|
var owner = RepoOwner()
|
||||||
|
var stargazers_count = 0
|
||||||
|
var default_branch = ""
|
||||||
|
var html_url = ""
|
||||||
|
var pushed_at = "" // don't use updated_at - see https://github.com/yairm210/Unciv/issues/6106
|
||||||
|
var size = 0
|
||||||
|
var topics = mutableListOf<String>()
|
||||||
|
//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?
|
||||||
|
|
||||||
|
/** String representation to be used for logging */
|
||||||
|
override fun toString() = name.ifEmpty { direct_zip_url }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Create a [Repo] metadata instance from a [url], supporting various formats
|
||||||
|
* from a repository landing page url to a free non-github zip download.
|
||||||
|
*
|
||||||
|
* @see GithubAPI.parseUrl
|
||||||
|
* @return `null` for invalid links or any other failures
|
||||||
|
*/
|
||||||
|
fun parseUrl(url: String): Repo? = Repo().parseUrl(url)
|
||||||
|
|
||||||
|
/** Query Github API for [owner]'s [repoName] repository metadata */
|
||||||
|
fun query(owner: String, repoName: String): Repo? {
|
||||||
|
val response = Github.download(getUrlForSingleRepoQuery(owner, repoName))
|
||||||
|
?: return null
|
||||||
|
val repoString = response.bufferedReader().readText()
|
||||||
|
return json().fromJson(Repo::class.java, repoString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Part of [Repo] in Github API response */
|
||||||
|
class RepoOwner {
|
||||||
|
var login = ""
|
||||||
|
var avatar_url: String? = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Topic search response */
|
||||||
|
class TopicSearchResponse {
|
||||||
|
// Commented out: Github returns them, but we're not interested
|
||||||
|
// var total_count = 0
|
||||||
|
// var incomplete_results = false
|
||||||
|
var items = ArrayList<Topic>()
|
||||||
|
class Topic {
|
||||||
|
var name = ""
|
||||||
|
var display_name: String? = null // Would need to be curated, which is alottawork
|
||||||
|
// var featured = false
|
||||||
|
// var curated = false
|
||||||
|
var created_at = "" // iso datetime with "Z" timezone
|
||||||
|
var updated_at = "" // iso datetime with "Z" timezone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Class to receive a github API "Get a tree" response parsed as json */
|
||||||
|
// Parts of the response we ignore are commented out
|
||||||
|
internal class Tree {
|
||||||
|
//val sha = ""
|
||||||
|
//val url = ""
|
||||||
|
|
||||||
|
class TreeFile {
|
||||||
|
//val path = ""
|
||||||
|
//val mode = 0
|
||||||
|
//val type = "" // blob / tree
|
||||||
|
//val sha = ""
|
||||||
|
//val url = ""
|
||||||
|
var size: Long = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("MemberNameEqualsClassName")
|
||||||
|
var tree = ArrayList<TreeFile>()
|
||||||
|
var truncated = false
|
||||||
|
}
|
||||||
|
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Flexible URL parsing
|
||||||
|
/**
|
||||||
|
* Initialize `this` with an url, extracting all possible fields from it
|
||||||
|
* (html_url, author, repoName, branchName).
|
||||||
|
*
|
||||||
|
* Allow url formats:
|
||||||
|
* * Basic repo url:
|
||||||
|
* https://github.com/author/repoName
|
||||||
|
* * or complete 'zip' url from github's code->download zip menu:
|
||||||
|
* https://github.com/author/repoName/archive/refs/heads/branchName.zip
|
||||||
|
* * or the branch url same as one navigates to on github through the "branches" menu:
|
||||||
|
* https://github.com/author/repoName/tree/branchName
|
||||||
|
* * or release tag
|
||||||
|
* https://github.com/author/repoName/releases/tag/tagname
|
||||||
|
* https://github.com/author/repoName/archive/refs/tags/tagname.zip
|
||||||
|
*
|
||||||
|
* In the case of the basic repo url, an [API query](https://docs.github.com/en/rest/repos/repos#get-a-repository) is sent to determine the default branch.
|
||||||
|
* Other url forms will not go online.
|
||||||
|
*
|
||||||
|
* @return a new Repo instance for the 'Basic repo url' case, otherwise `this`, modified, to allow chaining, `null` for invalid links or any other failures
|
||||||
|
* @see <a href="https://docs.github.com/en/rest/repos/repos#get-a-repository--code-samples">Github API Repository Code Samples</a>
|
||||||
|
*/
|
||||||
|
private fun Repo.parseUrl(url: String): Repo? {
|
||||||
|
fun processMatch(matchResult: MatchResult): Repo {
|
||||||
|
html_url = matchResult.groups[1]!!.value
|
||||||
|
owner.login = matchResult.groups[2]!!.value
|
||||||
|
name = matchResult.groups[3]!!.value
|
||||||
|
default_branch = matchResult.groups[4]!!.value
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
html_url = url
|
||||||
|
default_branch = "master"
|
||||||
|
val matchZip = Regex("""^(.*/(.*)/(.*))/archive/(?:.*/)?heads/([^.]+).zip$""").matchEntire(url)
|
||||||
|
if (matchZip != null && matchZip.groups.size > 4)
|
||||||
|
return processMatch(matchZip)
|
||||||
|
|
||||||
|
val matchBranch = Regex("""^(.*/(.*)/(.*))/tree/([^/]+)$""").matchEntire(url)
|
||||||
|
if (matchBranch != null && matchBranch.groups.size > 4)
|
||||||
|
return processMatch(matchBranch)
|
||||||
|
|
||||||
|
// Releases and tags -
|
||||||
|
// TODO Query for latest release and save as Mod Version?
|
||||||
|
// https://docs.github.com/en/rest/releases/releases#get-the-latest-release
|
||||||
|
// TODO Query a specific release for its name attribute - the page will link the tag
|
||||||
|
// https://docs.github.com/en/rest/releases/releases#get-a-release-by-tag-name
|
||||||
|
|
||||||
|
val matchTagArchive = Regex("""^(.*/(.*)/(.*))/archive/(?:.*/)?tags/([^.]+).zip$""").matchEntire(url)
|
||||||
|
if (matchTagArchive != null && matchTagArchive.groups.size > 4) {
|
||||||
|
processMatch(matchTagArchive)
|
||||||
|
release_tag = default_branch
|
||||||
|
// leave default_branch even if it's actually a tag not a branch name
|
||||||
|
// so the suffix of the inner first level folder inside the zip can be removed later
|
||||||
|
direct_zip_url = url
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
val matchTagPage = Regex("""^(.*/(.*)/(.*))/releases/(?:.*/)?tag/([^.]+)$""").matchEntire(url)
|
||||||
|
if (matchTagPage != null && matchTagPage.groups.size > 4) {
|
||||||
|
processMatch(matchTagPage)
|
||||||
|
release_tag = default_branch
|
||||||
|
direct_zip_url = getUrlForReleaseZip()
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
val matchRepo = Regex("""^.*//.*/(.+)/(.+)/?$""").matchEntire(url)
|
||||||
|
if (matchRepo != null && matchRepo.groups.size > 2) {
|
||||||
|
// Query API if we got the 'https://github.com/author/repoName' URL format to get the correct default branch
|
||||||
|
val repo = Repo.query(matchRepo.groups[1]!!.value, matchRepo.groups[2]!!.value)
|
||||||
|
if (repo != null) return repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only complain about invalid link if it isn't a http protocol (to think about: android document protocol? file protocol?)
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://"))
|
||||||
|
return null
|
||||||
|
|
||||||
|
// From here, we'll always return success and treat the url as direct-downloadable zip.
|
||||||
|
// The Repo instance will be a pseudo-repo not corresponding to an actual github repo.
|
||||||
|
html_url = ""
|
||||||
|
direct_zip_url = url
|
||||||
|
owner.login = "-unknown-"
|
||||||
|
default_branch = "master" // only used to remove this suffix should the zip contain a inner folder
|
||||||
|
// But see if we can extract a file name from the url
|
||||||
|
// Will use filename from response headers, if content-disposition is sent, for the Mod name instead, done in downloadAndExtract
|
||||||
|
val matchAnyZip = Regex("""^.*//(?:.*/)*([^/]+\.zip)$""").matchEntire(url)
|
||||||
|
if (matchAnyZip != null && matchAnyZip.groups.size > 1)
|
||||||
|
name = matchAnyZip.groups[1]!!.value
|
||||||
|
full_name = name
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
}
|
90
core/src/com/unciv/logic/github/RateLimit.kt
Normal file
90
core/src/com/unciv/logic/github/RateLimit.kt
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
package com.unciv.logic.github
|
||||||
|
|
||||||
|
import com.unciv.utils.debug
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implements the ability wo work with GitHub's rate limit, recognize blocks from previous attempts, wait and retry.
|
||||||
|
* @see <a href="https://docs.github.com/en/rest/reference/search#rate-limit">Github API doc</a>
|
||||||
|
*/
|
||||||
|
object RateLimit {
|
||||||
|
private const val maxRequestsPerInterval = 10
|
||||||
|
private 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 <a href="https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting">Github API doc</a>
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
debug("GitHub API Limit reported via http (%s) not equal assumed value (%s)", limit, maxRequestsPerInterval)
|
||||||
|
account = maxRequestsPerInterval - remaining
|
||||||
|
if (reset == 0L) return
|
||||||
|
firstRequest = (reset + 1L) * 1000L - intervalInMilliSeconds
|
||||||
|
}
|
||||||
|
}
|
88
core/src/com/unciv/logic/github/Zip.kt
Normal file
88
core/src/com/unciv/logic/github/Zip.kt
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
package com.unciv.logic.github
|
||||||
|
|
||||||
|
import com.badlogic.gdx.files.FileHandle
|
||||||
|
import com.unciv.utils.debug
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.BufferedOutputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
|
||||||
|
debug("Extracting %s to %s", zipFile, 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
|
||||||
|
}
|
||||||
|
}
|
@ -30,9 +30,7 @@ class TileStatFunctions(val tile: Tile) {
|
|||||||
fun getTileStats(city: City?, observingCiv: Civilization?,
|
fun getTileStats(city: City?, observingCiv: Civilization?,
|
||||||
localUniqueCache: LocalUniqueCache = LocalUniqueCache(false)
|
localUniqueCache: LocalUniqueCache = LocalUniqueCache(false)
|
||||||
): Stats {
|
): Stats {
|
||||||
val stats = Stats()
|
val stats = getTileStatsBreakdown(city, observingCiv, localUniqueCache).toStats()
|
||||||
for (statsToAdd in getTileStatsBreakdown(city, observingCiv, localUniqueCache))
|
|
||||||
stats.add(statsToAdd.second)
|
|
||||||
|
|
||||||
for ((stat, value) in getTilePercentageStats(observingCiv, city, localUniqueCache)) {
|
for ((stat, value) in getTilePercentageStats(observingCiv, city, localUniqueCache)) {
|
||||||
stats[stat] *= value.toPercent()
|
stats[stat] *= value.toPercent()
|
||||||
|
@ -2,8 +2,9 @@ package com.unciv.models.metadata
|
|||||||
|
|
||||||
import com.badlogic.gdx.Gdx
|
import com.badlogic.gdx.Gdx
|
||||||
import com.unciv.json.json
|
import com.unciv.json.json
|
||||||
|
import com.unciv.logic.github.GithubAPI
|
||||||
import com.unciv.ui.components.widgets.TranslatedSelectBox
|
import com.unciv.ui.components.widgets.TranslatedSelectBox
|
||||||
import com.unciv.ui.screens.pickerscreens.Github
|
import com.unciv.logic.github.Github
|
||||||
|
|
||||||
|
|
||||||
class ModCategories : ArrayList<ModCategories.Category>() {
|
class ModCategories : ArrayList<ModCategories.Category>() {
|
||||||
@ -21,12 +22,12 @@ class ModCategories : ArrayList<ModCategories.Category>() {
|
|||||||
constructor() :
|
constructor() :
|
||||||
this("", "", false, "", "")
|
this("", "", false, "", "")
|
||||||
|
|
||||||
constructor(topic: Github.TopicSearchResponse.Topic) :
|
constructor(topic: GithubAPI.TopicSearchResponse.Topic) :
|
||||||
this(labelSuggestion(topic), topic.name, true, topic.created_at, topic.updated_at)
|
this(labelSuggestion(topic), topic.name, true, topic.created_at, topic.updated_at)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val All = Category("All mods", "unciv-mod", false, "", "")
|
val All = Category("All mods", "unciv-mod", false, "", "")
|
||||||
fun labelSuggestion(topic: Github.TopicSearchResponse.Topic) =
|
fun labelSuggestion(topic: GithubAPI.TopicSearchResponse.Topic) =
|
||||||
topic.display_name?.takeUnless { it.isBlank() }
|
topic.display_name?.takeUnless { it.isBlank() }
|
||||||
?: topic.name.removePrefix("unciv-mod-").replaceFirstChar(Char::titlecase)
|
?: topic.name.removePrefix("unciv-mod-").replaceFirstChar(Char::titlecase)
|
||||||
}
|
}
|
||||||
|
@ -4,6 +4,7 @@ import com.badlogic.gdx.Gdx
|
|||||||
import com.badlogic.gdx.graphics.Texture
|
import com.badlogic.gdx.graphics.Texture
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.Image
|
import com.badlogic.gdx.scenes.scene2d.ui.Image
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
import com.badlogic.gdx.scenes.scene2d.ui.Table
|
||||||
|
import com.unciv.logic.github.GithubAPI
|
||||||
import com.unciv.models.metadata.BaseRuleset
|
import com.unciv.models.metadata.BaseRuleset
|
||||||
import com.unciv.models.ruleset.Ruleset
|
import com.unciv.models.ruleset.Ruleset
|
||||||
import com.unciv.models.ruleset.validation.ModCompatibility
|
import com.unciv.models.ruleset.validation.ModCompatibility
|
||||||
@ -14,7 +15,7 @@ import com.unciv.ui.components.extensions.toCheckBox
|
|||||||
import com.unciv.ui.components.extensions.toLabel
|
import com.unciv.ui.components.extensions.toLabel
|
||||||
import com.unciv.ui.components.extensions.toTextButton
|
import com.unciv.ui.components.extensions.toTextButton
|
||||||
import com.unciv.ui.components.input.onClick
|
import com.unciv.ui.components.input.onClick
|
||||||
import com.unciv.ui.screens.pickerscreens.Github
|
import com.unciv.logic.github.Github
|
||||||
import com.unciv.utils.Concurrency
|
import com.unciv.utils.Concurrency
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
|
|
||||||
@ -34,7 +35,7 @@ internal class ModInfoAndActionPane : Table() {
|
|||||||
/** Recreate the information part of the right-hand column
|
/** Recreate the information part of the right-hand column
|
||||||
* @param repo: the repository instance as received from the GitHub api
|
* @param repo: the repository instance as received from the GitHub api
|
||||||
*/
|
*/
|
||||||
fun update(repo: Github.Repo) {
|
fun update(repo: GithubAPI.Repo) {
|
||||||
isBuiltin = false
|
isBuiltin = false
|
||||||
enableVisualCheckBox = false
|
enableVisualCheckBox = false
|
||||||
update(
|
update(
|
||||||
|
@ -13,6 +13,8 @@ import com.badlogic.gdx.utils.SerializationException
|
|||||||
import com.unciv.UncivGame
|
import com.unciv.UncivGame
|
||||||
import com.unciv.json.fromJsonFile
|
import com.unciv.json.fromJsonFile
|
||||||
import com.unciv.json.json
|
import com.unciv.json.json
|
||||||
|
import com.unciv.logic.UncivShowableException
|
||||||
|
import com.unciv.logic.github.GithubAPI
|
||||||
import com.unciv.models.ruleset.Ruleset
|
import com.unciv.models.ruleset.Ruleset
|
||||||
import com.unciv.models.ruleset.RulesetCache
|
import com.unciv.models.ruleset.RulesetCache
|
||||||
import com.unciv.models.tilesets.TileSetCache
|
import com.unciv.models.tilesets.TileSetCache
|
||||||
@ -41,8 +43,8 @@ import com.unciv.ui.screens.basescreen.BaseScreen
|
|||||||
import com.unciv.ui.screens.basescreen.RecreateOnResize
|
import com.unciv.ui.screens.basescreen.RecreateOnResize
|
||||||
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen
|
import com.unciv.ui.screens.mainmenuscreen.MainMenuScreen
|
||||||
import com.unciv.ui.screens.modmanager.ModManagementOptions.SortType
|
import com.unciv.ui.screens.modmanager.ModManagementOptions.SortType
|
||||||
import com.unciv.ui.screens.pickerscreens.Github
|
import com.unciv.logic.github.Github
|
||||||
import com.unciv.ui.screens.pickerscreens.Github.repoNameToFolderName
|
import com.unciv.logic.github.Github.repoNameToFolderName
|
||||||
import com.unciv.ui.screens.pickerscreens.PickerScreen
|
import com.unciv.ui.screens.pickerscreens.PickerScreen
|
||||||
import com.unciv.utils.Concurrency
|
import com.unciv.utils.Concurrency
|
||||||
import com.unciv.utils.Log
|
import com.unciv.utils.Log
|
||||||
@ -93,7 +95,7 @@ class ModManagementScreen private constructor(
|
|||||||
|
|
||||||
private var lastSelectedButton: ModDecoratedButton? = null
|
private var lastSelectedButton: ModDecoratedButton? = null
|
||||||
private var lastSyncMarkedButton: ModDecoratedButton? = null
|
private var lastSyncMarkedButton: ModDecoratedButton? = null
|
||||||
private var selectedMod: Github.Repo? = null
|
private var selectedMod: GithubAPI.Repo? = null
|
||||||
|
|
||||||
private val modDescriptionLabel: WrappableLabel
|
private val modDescriptionLabel: WrappableLabel
|
||||||
|
|
||||||
@ -233,7 +235,7 @@ class ModManagementScreen private constructor(
|
|||||||
*/
|
*/
|
||||||
private fun tryDownloadPage(pageNum: Int) {
|
private fun tryDownloadPage(pageNum: Int) {
|
||||||
runningSearchJob = Concurrency.run("GitHubSearch") {
|
runningSearchJob = Concurrency.run("GitHubSearch") {
|
||||||
val repoSearch: Github.RepoSearch
|
val repoSearch: GithubAPI.RepoSearch
|
||||||
try {
|
try {
|
||||||
repoSearch = Github.tryGetGithubReposWithTopic(amountPerPage, pageNum)!!
|
repoSearch = Github.tryGetGithubReposWithTopic(amountPerPage, pageNum)!!
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
@ -254,7 +256,7 @@ class ModManagementScreen private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addModInfoFromRepoSearch(repoSearch: Github.RepoSearch, pageNum: Int) {
|
private fun addModInfoFromRepoSearch(repoSearch: GithubAPI.RepoSearch, pageNum: Int) {
|
||||||
// clear and remove last cell if it is the "..." indicator
|
// clear and remove last cell if it is the "..." indicator
|
||||||
val lastCell = onlineModsTable.cells.lastOrNull()
|
val lastCell = onlineModsTable.cells.lastOrNull()
|
||||||
if (lastCell != null && lastCell.actor is Label && (lastCell.actor as Label).text.toString() == "...") {
|
if (lastCell != null && lastCell.actor is Label && (lastCell.actor as Label).text.toString() == "...") {
|
||||||
@ -363,7 +365,7 @@ class ModManagementScreen private constructor(
|
|||||||
val downloadButton = "Download mod from URL".toTextButton()
|
val downloadButton = "Download mod from URL".toTextButton()
|
||||||
downloadButton.onClick {
|
downloadButton.onClick {
|
||||||
val popup = Popup(this)
|
val popup = Popup(this)
|
||||||
popup.addGoodSizedLabel("Please enter the mod repository -or- archive zip -or- branch url:").row()
|
popup.addGoodSizedLabel("Please enter the mod repository -or- archive zip -or- branch -or- release url:").row()
|
||||||
val textField = UncivTextField.create("").apply { maxLength = 666 }
|
val textField = UncivTextField.create("").apply { maxLength = 666 }
|
||||||
popup.add(textField).width(stage.width / 2).row()
|
popup.add(textField).width(stage.width / 2).row()
|
||||||
val pasteLinkButton = "Paste from clipboard".toTextButton()
|
val pasteLinkButton = "Paste from clipboard".toTextButton()
|
||||||
@ -375,7 +377,7 @@ class ModManagementScreen private constructor(
|
|||||||
actualDownloadButton.onClick {
|
actualDownloadButton.onClick {
|
||||||
actualDownloadButton.setText("Downloading...".tr())
|
actualDownloadButton.setText("Downloading...".tr())
|
||||||
actualDownloadButton.disable()
|
actualDownloadButton.disable()
|
||||||
val repo = Github.Repo().parseUrl(textField.text)
|
val repo = GithubAPI.Repo.parseUrl(textField.text)
|
||||||
if (repo == null) {
|
if (repo == null) {
|
||||||
ToastPopup("«RED»{Invalid link!}«»", this@ModManagementScreen)
|
ToastPopup("«RED»{Invalid link!}«»", this@ModManagementScreen)
|
||||||
.apply { isVisible = true }
|
.apply { isVisible = true }
|
||||||
@ -392,7 +394,7 @@ class ModManagementScreen private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Used as onClick handler for the online Mod list buttons */
|
/** Used as onClick handler for the online Mod list buttons */
|
||||||
private fun onlineButtonAction(repo: Github.Repo, button: ModDecoratedButton) {
|
private fun onlineButtonAction(repo: GithubAPI.Repo, button: ModDecoratedButton) {
|
||||||
syncOnlineSelected(repo.name, button)
|
syncOnlineSelected(repo.name, button)
|
||||||
showModDescription(repo.name)
|
showModDescription(repo.name)
|
||||||
|
|
||||||
@ -435,7 +437,7 @@ class ModManagementScreen private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Download and install a mod in the background, called both from the right-bottom button and the URL entry popup */
|
/** Download and install a mod in the background, called both from the right-bottom button and the URL entry popup */
|
||||||
private fun downloadMod(repo: Github.Repo, postAction: () -> Unit = {}) {
|
private fun downloadMod(repo: GithubAPI.Repo, postAction: () -> Unit = {}) {
|
||||||
Concurrency.run("DownloadMod") { // to avoid ANRs - we've learnt our lesson from previous download-related actions
|
Concurrency.run("DownloadMod") { // to avoid ANRs - we've learnt our lesson from previous download-related actions
|
||||||
try {
|
try {
|
||||||
val modFolder = Github.downloadAndExtract(
|
val modFolder = Github.downloadAndExtract(
|
||||||
@ -456,8 +458,14 @@ class ModManagementScreen private constructor(
|
|||||||
unMarkUpdatedMod(repoName)
|
unMarkUpdatedMod(repoName)
|
||||||
postAction()
|
postAction()
|
||||||
}
|
}
|
||||||
|
} catch (ex: UncivShowableException) {
|
||||||
|
Log.error("Could not download $repo", ex)
|
||||||
|
launchOnGLThread {
|
||||||
|
ToastPopup(ex.message, this@ModManagementScreen)
|
||||||
|
postAction()
|
||||||
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
Log.error("Could not download ${repo.name}", ex)
|
Log.error("Could not download $repo", ex)
|
||||||
launchOnGLThread {
|
launchOnGLThread {
|
||||||
ToastPopup("Could not download [${repo.name}]", this@ModManagementScreen)
|
ToastPopup("Could not download [${repo.name}]", this@ModManagementScreen)
|
||||||
postAction()
|
postAction()
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
package com.unciv.ui.screens.modmanager
|
package com.unciv.ui.screens.modmanager
|
||||||
|
|
||||||
import com.badlogic.gdx.scenes.scene2d.ui.TextButton
|
import com.unciv.logic.github.GithubAPI
|
||||||
import com.unciv.models.metadata.ModCategories
|
import com.unciv.models.metadata.ModCategories
|
||||||
import com.unciv.models.ruleset.Ruleset
|
import com.unciv.models.ruleset.Ruleset
|
||||||
import com.unciv.models.translations.tr
|
import com.unciv.models.translations.tr
|
||||||
import com.unciv.ui.components.extensions.toLabel
|
|
||||||
import com.unciv.ui.components.extensions.toTextButton
|
|
||||||
import com.unciv.ui.screens.pickerscreens.Github
|
|
||||||
|
|
||||||
/** Helper class holds combined mod info for ModManagementScreen, used for both installed and online lists.
|
/** Helper class holds combined mod info for ModManagementScreen, used for both installed and online lists.
|
||||||
*
|
*
|
||||||
@ -18,7 +15,7 @@ internal class ModUIData private constructor(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val description: String,
|
val description: String,
|
||||||
val ruleset: Ruleset?,
|
val ruleset: Ruleset?,
|
||||||
val repo: Github.Repo?,
|
val repo: GithubAPI.Repo?,
|
||||||
var isVisual: Boolean = false,
|
var isVisual: Boolean = false,
|
||||||
var hasUpdate: Boolean = false
|
var hasUpdate: Boolean = false
|
||||||
) {
|
) {
|
||||||
@ -30,7 +27,7 @@ internal class ModUIData private constructor(
|
|||||||
ruleset, null, isVisual = isVisual
|
ruleset, null, isVisual = isVisual
|
||||||
)
|
)
|
||||||
|
|
||||||
constructor(repo: Github.Repo, isUpdated: Boolean): this (
|
constructor(repo: GithubAPI.Repo, isUpdated: Boolean): this (
|
||||||
repo.name,
|
repo.name,
|
||||||
(repo.description ?: "-{No description provided}-".tr()) +
|
(repo.description ?: "-{No description provided}-".tr()) +
|
||||||
"\n" + "[${repo.stargazers_count}]✯".tr(),
|
"\n" + "[${repo.stargazers_count}]✯".tr(),
|
||||||
|
@ -1,603 +0,0 @@
|
|||||||
package com.unciv.ui.screens.pickerscreens
|
|
||||||
|
|
||||||
import com.badlogic.gdx.Files
|
|
||||||
import com.badlogic.gdx.files.FileHandle
|
|
||||||
import com.badlogic.gdx.graphics.Pixmap
|
|
||||||
import com.unciv.json.fromJsonFile
|
|
||||||
import com.unciv.json.json
|
|
||||||
import com.unciv.logic.BackwardCompatibility.updateDeprecations
|
|
||||||
import com.unciv.models.ruleset.ModOptions
|
|
||||||
import com.unciv.ui.screens.pickerscreens.Github.RateLimit
|
|
||||||
import com.unciv.ui.screens.pickerscreens.Github.download
|
|
||||||
import com.unciv.ui.screens.pickerscreens.Github.downloadAndExtract
|
|
||||||
import com.unciv.ui.screens.pickerscreens.Github.tryGetGithubReposWithTopic
|
|
||||||
import com.unciv.utils.Log
|
|
||||||
import com.unciv.utils.debug
|
|
||||||
import java.io.BufferedInputStream
|
|
||||||
import java.io.BufferedOutputStream
|
|
||||||
import java.io.BufferedReader
|
|
||||||
import java.io.FileOutputStream
|
|
||||||
import java.io.InputStream
|
|
||||||
import java.io.InputStreamReader
|
|
||||||
import java.net.HttpURLConnection
|
|
||||||
import java.net.URL
|
|
||||||
import java.nio.ByteBuffer
|
|
||||||
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? {
|
|
||||||
try {
|
|
||||||
// Problem type 1 - opening the URL connection
|
|
||||||
with(URL(url).openConnection() as HttpURLConnection)
|
|
||||||
{
|
|
||||||
action(this)
|
|
||||||
// Problem type 2 - getting the information
|
|
||||||
try {
|
|
||||||
return inputStream
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
// No error handling, just log the message.
|
|
||||||
// NOTE that this 'read error stream' CAN ALSO fail, but will be caught by the big try/catch
|
|
||||||
val reader = BufferedReader(InputStreamReader(errorStream, Charsets.UTF_8))
|
|
||||||
Log.error("Message from GitHub: %s", reader.readText())
|
|
||||||
throw ex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
Log.error("Exception during GitHub download", ex)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a mod and extract, deleting any pre-existing version.
|
|
||||||
* @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(
|
|
||||||
repo: Repo,
|
|
||||||
folderFileHandle: FileHandle
|
|
||||||
): FileHandle? {
|
|
||||||
val defaultBranch = repo.default_branch
|
|
||||||
val gitRepoUrl = repo.html_url
|
|
||||||
// Initiate download - the helper returns null when it fails
|
|
||||||
// URL format see: https://docs.github.com/en/repositories/working-with-files/using-files/downloading-source-code-archives#source-code-archive-urls
|
|
||||||
// Note: https://api.github.com/repos/owner/mod/zipball would be an alternative. Its response is a redirect, but our lib follows that and delivers the zip just fine.
|
|
||||||
// Problems with the latter: Internal zip structure different, finalDestinationName would need a patch. Plus, normal URL escaping for owner/reponame does not work.
|
|
||||||
val zipUrl = "$gitRepoUrl/archive/refs/heads/$defaultBranch.zip"
|
|
||||||
val inputStream = download(zipUrl) ?: return null
|
|
||||||
|
|
||||||
// Get a mod-specific temp file name
|
|
||||||
val tempName = "temp-" + gitRepoUrl.hashCode().toString(16)
|
|
||||||
|
|
||||||
// Download to temporary zip
|
|
||||||
val tempZipFileHandle = folderFileHandle.child("$tempName.zip")
|
|
||||||
tempZipFileHandle.write(inputStream, false)
|
|
||||||
|
|
||||||
// prepare temp unpacking folder
|
|
||||||
val unzipDestination = tempZipFileHandle.sibling(tempName) // 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 "$tempName/$repoName-$defaultBranch/" - use this to get mod name
|
|
||||||
val finalDestinationName = innerFolder.name().replace("-$defaultBranch", "").repoNameToFolderName()
|
|
||||||
// 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.renameOrMove(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.renameOrMove(finalDestination)
|
|
||||||
}
|
|
||||||
|
|
||||||
// clean up
|
|
||||||
tempZipFileHandle.delete()
|
|
||||||
unzipDestination.deleteDirectory()
|
|
||||||
if (tempBackup != null)
|
|
||||||
if (tempBackup.isDirectory) tempBackup.deleteDirectory() else tempBackup.delete()
|
|
||||||
|
|
||||||
return finalDestination
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun FileHandle.renameOrMove(dest: FileHandle) {
|
|
||||||
// Gdx tries a java rename for Absolute and External, but NOT for Local - rectify that
|
|
||||||
if (type() == Files.FileType.Local) {
|
|
||||||
// See #5346: renameTo 'unpacks' a source folder if the destination exists and is an
|
|
||||||
// empty folder, dropping the name of the source entirely.
|
|
||||||
// Safer to ask for a move to the not-yet-existing full resulting path instead.
|
|
||||||
if (isDirectory)
|
|
||||||
if (file().renameTo(dest.child(name()).file())) return
|
|
||||||
else
|
|
||||||
if (file().renameTo(dest.file())) return
|
|
||||||
}
|
|
||||||
moveTo(dest)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
private const val maxRequestsPerInterval = 10
|
|
||||||
private 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)
|
|
||||||
debug("GitHub API Limit reported via http (%s) not equal assumed value (%s)", limit, 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, searchRequest: String = ""): RepoSearch? {
|
|
||||||
// Add + here to separate the query text from its parameters
|
|
||||||
val searchText = if (searchRequest != "") "$searchRequest+" else ""
|
|
||||||
val link = "https://api.github.com/search/repositories?q=${searchText}%20topic:unciv-mod%20fork:true&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 json().fromJson(RepoSearch::class.java, inputStream.bufferedReader().readText())
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get a Pixmap from a "preview" png or jpg file at the root of the repo, falling back to the
|
|
||||||
* repo owner's avatar [avatarUrl]. The file content url is constructed from [modUrl] and [defaultBranch]
|
|
||||||
* by replacing the host with `raw.githubusercontent.com`.
|
|
||||||
*/
|
|
||||||
fun tryGetPreviewImage(modUrl: String, defaultBranch: String, avatarUrl: String?): Pixmap? {
|
|
||||||
// Side note: github repos also have a "Social Preview" optionally assignable on the repo's
|
|
||||||
// settings page, but that info is inaccessible using the v3 API anonymously. The easiest way
|
|
||||||
// to get it would be to query the the repo's frontend page (modUrl), and parse out
|
|
||||||
// `head/meta[property=og:image]/@content`, which is one extra spurious roundtrip and a
|
|
||||||
// non-trivial waste of bandwidth.
|
|
||||||
// Thus we ask for a "preview" file as part of the repo contents instead.
|
|
||||||
val fileLocation = "$modUrl/$defaultBranch/preview"
|
|
||||||
.replace("github.com", "raw.githubusercontent.com")
|
|
||||||
try {
|
|
||||||
val file = download("$fileLocation.jpg")
|
|
||||||
?: download("$fileLocation.png")
|
|
||||||
// Note: avatar urls look like: https://avatars.githubusercontent.com/u/<number>?v=4
|
|
||||||
// So the image format is only recognizable from the response "Content-Type" header
|
|
||||||
// or by looking for magic markers in the bits - which the Pixmap constructor below does.
|
|
||||||
?: avatarUrl?.let { download(it) }
|
|
||||||
?: return null
|
|
||||||
val byteArray = file.readBytes()
|
|
||||||
val buffer = ByteBuffer.allocateDirect(byteArray.size).put(byteArray).position(0)
|
|
||||||
return Pixmap(buffer)
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Class to receive a github API "Get a tree" response parsed as json */
|
|
||||||
// Parts of the response we ignore are commented out
|
|
||||||
private class Tree {
|
|
||||||
//val sha = ""
|
|
||||||
//val url = ""
|
|
||||||
|
|
||||||
class TreeFile {
|
|
||||||
//val path = ""
|
|
||||||
//val mode = 0
|
|
||||||
//val type = "" // blob / tree
|
|
||||||
//val sha = ""
|
|
||||||
//val url = ""
|
|
||||||
var size: Long = 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("MemberNameEqualsClassName")
|
|
||||||
var tree = ArrayList<TreeFile>()
|
|
||||||
var truncated = false
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Queries github for a tree and calculates the sum of the blob sizes.
|
|
||||||
* @return -1 on failure, else size rounded to kB
|
|
||||||
*/
|
|
||||||
fun getRepoSize(repo: Repo): Int {
|
|
||||||
// See https://docs.github.com/en/rest/git/trees#get-a-tree
|
|
||||||
val link = "https://api.github.com/repos/${repo.full_name}/git/trees/${repo.default_branch}?recursive=true"
|
|
||||||
|
|
||||||
var retries = 2
|
|
||||||
while (retries > 0) {
|
|
||||||
retries--
|
|
||||||
// obey rate limit
|
|
||||||
if (RateLimit.waitForLimit()) return -1
|
|
||||||
// try download
|
|
||||||
val inputStream = download(link) {
|
|
||||||
if (it.responseCode == 403 || it.responseCode == 200 && 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
|
|
||||||
|
|
||||||
val tree = json().fromJson(Tree::class.java, inputStream.bufferedReader().readText())
|
|
||||||
if (tree.truncated) return -1 // unlikely: >100k blobs or blob > 7MB
|
|
||||||
|
|
||||||
var totalSizeBytes = 0L
|
|
||||||
for (file in tree.tree)
|
|
||||||
totalSizeBytes += file.size
|
|
||||||
|
|
||||||
// overflow unlikely: >2TB
|
|
||||||
return ((totalSizeBytes + 512) / 1024).toInt()
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
|
||||||
@Suppress("MemberVisibilityCanBePrivate")
|
|
||||||
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 {
|
|
||||||
|
|
||||||
/** Unlike the rest of this class, this is not part of the API but added by us locally
|
|
||||||
* to track whether [getRepoSize] has been run successfully for this repo */
|
|
||||||
var hasUpdatedSize = false
|
|
||||||
|
|
||||||
var name = ""
|
|
||||||
var full_name = ""
|
|
||||||
var description: String? = null
|
|
||||||
var owner = RepoOwner()
|
|
||||||
var stargazers_count = 0
|
|
||||||
var default_branch = ""
|
|
||||||
var html_url = ""
|
|
||||||
var pushed_at = "" // don't use updated_at - see https://github.com/yairm210/Unciv/issues/6106
|
|
||||||
var size = 0
|
|
||||||
var topics = mutableListOf<String>()
|
|
||||||
//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?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize `this` with an url, extracting all possible fields from it
|
|
||||||
* (html_url, author, repoName, branchName).
|
|
||||||
*
|
|
||||||
* Allow url formats:
|
|
||||||
* * Basic repo url:
|
|
||||||
* https://github.com/author/repoName
|
|
||||||
* * or complete 'zip' url from github's code->download zip menu:
|
|
||||||
* https://github.com/author/repoName/archive/refs/heads/branchName.zip
|
|
||||||
* * or the branch url same as one navigates to on github through the "branches" menu:
|
|
||||||
* https://github.com/author/repoName/tree/branchName
|
|
||||||
*
|
|
||||||
* In the case of the basic repo url, an API query is sent to determine the default branch.
|
|
||||||
* Other url forms will not go online.
|
|
||||||
*
|
|
||||||
* @return `this` to allow chaining, `null` for invalid links or any other failures
|
|
||||||
*/
|
|
||||||
fun parseUrl(url: String): Repo? {
|
|
||||||
fun processMatch(matchResult: MatchResult): Repo {
|
|
||||||
html_url = matchResult.groups[1]!!.value
|
|
||||||
owner.login = matchResult.groups[2]!!.value
|
|
||||||
name = matchResult.groups[3]!!.value
|
|
||||||
default_branch = matchResult.groups[4]!!.value
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
html_url = url
|
|
||||||
default_branch = "master"
|
|
||||||
val matchZip = Regex("""^(.*/(.*)/(.*))/archive/(?:.*/)?([^.]+).zip$""").matchEntire(url)
|
|
||||||
if (matchZip != null && matchZip.groups.size > 4)
|
|
||||||
return processMatch(matchZip)
|
|
||||||
|
|
||||||
val matchBranch = Regex("""^(.*/(.*)/(.*))/tree/([^/]+)$""").matchEntire(url)
|
|
||||||
if (matchBranch != null && matchBranch.groups.size > 4)
|
|
||||||
return processMatch(matchBranch)
|
|
||||||
|
|
||||||
val matchRepo = Regex("""^.*//.*/(.+)/(.+)/?$""").matchEntire(url)
|
|
||||||
if (matchRepo != null && matchRepo.groups.size > 2) {
|
|
||||||
// Query API if we got the first URL format to get the correct default branch
|
|
||||||
val response = download("https://api.github.com/repos/${matchRepo.groups[1]!!.value}/${matchRepo.groups[2]!!.value}")
|
|
||||||
?: return null
|
|
||||||
val repoString = response.bufferedReader().readText()
|
|
||||||
return json().fromJson(Repo::class.java, repoString)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Part of [Repo] in Github API response */
|
|
||||||
@Suppress("PropertyName")
|
|
||||||
class RepoOwner {
|
|
||||||
var login = ""
|
|
||||||
var avatar_url: String? = null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query GitHub for topics named "unciv-mod*"
|
|
||||||
* @return Parsed [TopicSearchResponse] json on success, `null` on failure.
|
|
||||||
*/
|
|
||||||
fun tryGetGithubTopics(): TopicSearchResponse? {
|
|
||||||
// `+repositories:>1` means ignore unused or practically unused topics
|
|
||||||
val link = "https://api.github.com/search/topics?q=unciv-mod+repositories:%3E1&sort=name&order=asc"
|
|
||||||
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 && 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 json().fromJson(TopicSearchResponse::class.java, inputStream.bufferedReader().readText())
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Topic search response */
|
|
||||||
@Suppress("PropertyName")
|
|
||||||
class TopicSearchResponse {
|
|
||||||
// Commented out: Github returns them, but we're not interested
|
|
||||||
// var total_count = 0
|
|
||||||
// var incomplete_results = false
|
|
||||||
var items = ArrayList<Topic>()
|
|
||||||
class Topic {
|
|
||||||
var name = ""
|
|
||||||
var display_name: String? = null // Would need to be curated, which is alottawork
|
|
||||||
// var featured = false
|
|
||||||
// var curated = false
|
|
||||||
var created_at = "" // iso datetime with "Z" timezone
|
|
||||||
var updated_at = "" // iso datetime with "Z" timezone
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rewrite modOptions file for a mod we just installed to include metadata we got from the GitHub api
|
|
||||||
*
|
|
||||||
* (called on background thread)
|
|
||||||
*/
|
|
||||||
fun rewriteModOptions(repo: Repo, modFolder: FileHandle) {
|
|
||||||
val modOptionsFile = modFolder.child("jsons/ModOptions.json")
|
|
||||||
val modOptions = if (modOptionsFile.exists()) json().fromJsonFile(ModOptions::class.java, modOptionsFile) else ModOptions()
|
|
||||||
modOptions.modUrl = repo.html_url
|
|
||||||
modOptions.defaultBranch = repo.default_branch
|
|
||||||
modOptions.lastUpdated = repo.pushed_at
|
|
||||||
modOptions.author = repo.owner.login
|
|
||||||
modOptions.modSize = repo.size
|
|
||||||
modOptions.topics = repo.topics
|
|
||||||
modOptions.updateDeprecations()
|
|
||||||
json().toJson(modOptions, modOptionsFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
private const val outerBlankReplacement = '='
|
|
||||||
// Github disallows **any** special chars and replaces them with '-' - so use something ascii the
|
|
||||||
// OS accepts but still is recognizable as non-original, to avoid confusion
|
|
||||||
|
|
||||||
/** Convert a [Repo] name to a local name for both display and folder name
|
|
||||||
*
|
|
||||||
* Replaces '-' with blanks but ensures no leading or trailing blanks.
|
|
||||||
* As mad modders know no limits, trailing "-" did indeed happen, causing things to break due to trailing blanks on a folder name.
|
|
||||||
* As "test-" and "test" are different allowed repository names, trimmed blanks are replaced with one equals sign per side.
|
|
||||||
* @param onlyOuterBlanks If `true` ignores inner dashes - only start and end are treated. Useful when modders have manually created local folder names using dashes.
|
|
||||||
*/
|
|
||||||
fun String.repoNameToFolderName(onlyOuterBlanks: Boolean = false): String {
|
|
||||||
var result = if (onlyOuterBlanks) this else replace('-', ' ')
|
|
||||||
if (result.endsWith(' ')) result = result.trimEnd() + outerBlankReplacement
|
|
||||||
if (result.startsWith(' ')) result = outerBlankReplacement + result.trimStart()
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Inverse of [repoNameToFolderName] */
|
|
||||||
// As of this writing, only used for loadMissingMods
|
|
||||||
fun String.folderNameToRepoName(): String {
|
|
||||||
var result = replace(' ', '-')
|
|
||||||
if (result.endsWith(outerBlankReplacement)) result = result.trimEnd(outerBlankReplacement) + '-'
|
|
||||||
if (result.startsWith(outerBlankReplacement)) result = '-' + result.trimStart(outerBlankReplacement)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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
|
|
||||||
|
|
||||||
debug("Extracting %s to %s", zipFile, 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
|
|
||||||
}
|
|
||||||
}
|
|
@ -27,8 +27,8 @@ import com.unciv.ui.components.input.onClick
|
|||||||
import com.unciv.ui.popups.LoadingPopup
|
import com.unciv.ui.popups.LoadingPopup
|
||||||
import com.unciv.ui.popups.Popup
|
import com.unciv.ui.popups.Popup
|
||||||
import com.unciv.ui.popups.ToastPopup
|
import com.unciv.ui.popups.ToastPopup
|
||||||
import com.unciv.ui.screens.pickerscreens.Github
|
import com.unciv.logic.github.Github
|
||||||
import com.unciv.ui.screens.pickerscreens.Github.folderNameToRepoName
|
import com.unciv.logic.github.Github.folderNameToRepoName
|
||||||
import com.unciv.utils.Concurrency
|
import com.unciv.utils.Concurrency
|
||||||
import com.unciv.utils.Log
|
import com.unciv.utils.Log
|
||||||
import com.unciv.utils.launchOnGLThread
|
import com.unciv.utils.launchOnGLThread
|
||||||
|
Loading…
x
Reference in New Issue
Block a user