diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
index ff9696e1..b1077fbd 100644
--- a/.idea/kotlinc.xml
+++ b/.idea/kotlinc.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abdd7d0e..5064d4db 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,12 @@
+## v0.4.1 - Snapshot
+* Added custom blossom injector dependency
+* Updated dependencies
+* Moved github update check util
+* Added new update check utils
+* Updated .idea files
+* Removed simplecoreapi properties to use injected variables
+* Added tests for every update checker (available at the moment)
+
## v0.4.0 - Snapshot
* Added Velocity Support
* Now we use the ILogger util to allow the usage of slf4j and JUL
diff --git a/build.gradle b/build.gradle
index 0eea59bc..efe37929 100644
--- a/build.gradle
+++ b/build.gradle
@@ -6,7 +6,7 @@ plugins {
id 'org.jetbrains.dokka' version '1.7.10'
}
-def projectVersion = (System.getenv("VERSION") ?: '0.4.0-SNAPSHOT').replaceFirst("v", "").replace('/', '')
+def projectVersion = (System.getenv("VERSION") ?: '0.4.1-SNAPSHOT').replaceFirst("v", "").replace('/', '')
group 'xyz.theprogramsrc'
version projectVersion
diff --git a/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/GitHubUpdateChecker.kt b/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/GitHubUpdateChecker.kt
index f3586387..9960dad7 100644
--- a/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/GitHubUpdateChecker.kt
+++ b/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/GitHubUpdateChecker.kt
@@ -10,15 +10,15 @@ import java.time.format.DateTimeFormatter
/**
* Representation of the GitHub Update Checker
* @param logger The logger to use, it must be an instance of [ILogger]
- * @param repo The repository to check
+ * @param repo The repository to check. The format should be /, for example TheProgramSrc/SimpleCoreAPI
* @param currentVersion the current version (tag name) of the product
+ * @param latestReleaseTag The tag name of the latest release. (Defaults to "latest")
*/
-class GitHubUpdateChecker(val logger: ILogger, val repo: String, val currentVersion: String, val latestReleaseTag: String = "latest") {
+class GitHubUpdateChecker(val logger: ILogger, val repo: String, val currentVersion: String, val latestReleaseTag: String = "latest"): UpdateChecker {
private var lastCheck = 0L
private var lastCheckResult = false
- private var lastRequest = 0L
- private var latestData = JsonObject()
+ private val requestedData = mutableMapOf>()
private val current = if(currentVersion.startsWith("v")) currentVersion else "v$currentVersion"
/**
@@ -26,9 +26,9 @@ class GitHubUpdateChecker(val logger: ILogger, val repo: String, val currentVers
* a message if there is one asking the end user to
* update the product.
*/
- fun checkWithPrint() {
- val latestData = getLatestReleaseData()
- val latestVersion = latestData.get("tag_name").asString
+ override fun checkWithPrint() {
+ val latestData = getReleaseData()
+ val latestVersion = latestData.get("version").asString
if(checkForUpdates()){
logger.info("Please update (from $current to $latestVersion)! Download it now from here: https://github.com/$repo/releases/tag/$latestVersion")
}
@@ -38,15 +38,13 @@ class GitHubUpdateChecker(val logger: ILogger, val repo: String, val currentVers
* Checks if there is an update available
* @return true if there is an update available, false otherwise
*/
- fun checkForUpdates(): Boolean {
+ override fun checkForUpdates(): Boolean {
val difference = System.currentTimeMillis() - lastCheck
if(difference > 60000 || lastCheck == 0L){
lastCheckResult = try {
val parser = DateTimeFormatter.ISO_INSTANT
- val currentData = JsonParser.parseString(URL("https://api.github.com/repos/$repo/releases/tags/$current").readText()).asJsonObject
- val currentReleasedAt = Instant.from(parser.parse(currentData.get("published_at").asString))
- val latestData = getLatestReleaseData()
- val latestReleasedAt = Instant.from(parser.parse(latestData.get("published_at").asString))
+ val currentReleasedAt = Instant.from(parser.parse(getReleaseData(current).get("published_at").asString))
+ val latestReleasedAt = Instant.from(parser.parse(getReleaseData(latestReleaseTag).get("published_at").asString))
Instant.from(currentReleasedAt).isBefore(latestReleasedAt)
}catch (e: Exception){
e.printStackTrace()
@@ -59,16 +57,33 @@ class GitHubUpdateChecker(val logger: ILogger, val repo: String, val currentVers
}
/**
- * Gets the information of the latest release
- * @return The information of the latest release
+ * Gets the information of a single release
+ * Object Sample:
+ * { "published_at": "2022-07-15T21:51:46.397962Z", "version": "v0.4.1-SNAPSHOT", "url": "https://github.com/TheProgramSrc/SimpleCoreAPI/releases/tag/v0.4.1-SNAPSHOT", "author_url": "https://github.com/Im-Fran" }
+ * - published_at: Is the date when the version was made public. This date must be able to be parsed by Instant#from
+ * - version: The version of the latest asset
+ * - url: The url to the version page (null if not available)
+ * - author_url: The url to the author profile (null if not available)
+ *
+ * @param id the name of the release. (If none specified the latest data is fetched. Defaults to "latest")
+ * @return The information of the given release name
+ * @since 0.4.1-SNAPSHOT
*/
- fun getLatestReleaseData(): JsonObject {
- val difference = System.currentTimeMillis() - lastRequest
- if(difference > 60000 || lastRequest == 0L){
- latestData = JsonParser.parseString(URL(if(latestReleaseTag != "latest") "https://api.github.com/repos/$repo/releases/tags/$latestReleaseTag" else "https://api.github.com/repos/$repo/releases/latest").readText()).asJsonObject
- lastRequest = System.currentTimeMillis()
+ override fun getReleaseData(id: String): JsonObject {
+ var cached = requestedData.getOrDefault(id, Pair(JsonObject(), 0L))
+ val difference = System.currentTimeMillis() - cached.second
+ if(difference > 60000 || cached.second == 0L){
+ val json = JsonParser.parseString(URL(if(id != "latest") "https://api.github.com/repos/$repo/releases/tags/$id" else "https://api.github.com/repos/$repo/releases/latest").readText()).asJsonObject
+ cached = Pair(JsonObject().apply {
+ addProperty("published_at", json.get("published_at").asString)
+ addProperty("version", json.get("tag_name").asString)
+ addProperty("url", json.get("html_url").asString)
+ addProperty("author_url", json.get("author").asJsonObject.get("html_url").asString)
+ }, System.currentTimeMillis())
+ requestedData[id] = cached
}
- return latestData
+ println(cached.first.toString() + " - $id")
+ return cached.first
}
}
\ No newline at end of file
diff --git a/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SongodaUpdateChecker.kt b/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SongodaUpdateChecker.kt
new file mode 100644
index 00000000..fdd18c03
--- /dev/null
+++ b/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SongodaUpdateChecker.kt
@@ -0,0 +1,92 @@
+package xyz.theprogramsrc.simplecoreapi.global.utils.update
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import xyz.theprogramsrc.simplecoreapi.global.utils.ILogger
+import java.net.URL
+import java.time.Instant
+import java.time.format.DateTimeFormatter
+
+class SongodaUpdateChecker(val logger: ILogger, val productId: String, val currentVersion: String): UpdateChecker {
+
+ private var lastCheck = 0L
+ private var lastCheckResult = false
+ private val requestedData = mutableMapOf>()
+ private val current = if(currentVersion.startsWith("v")) currentVersion else "v$currentVersion"
+
+ /**
+ * Checks if there is an update available and prints
+ * a message if there is one asking the end user to
+ * update the product.
+ */
+ override fun checkWithPrint() {
+ val latestData = getReleaseData()
+ val latestVersion = latestData.get("version").asString
+ if(checkForUpdates()){
+ logger.info("Please update (from $current to $latestVersion)! Download it now from here: https://songoda.org/marketplace/product/$productId")
+ }
+ }
+
+ /**
+ * Checks if there is an update available
+ * @return true if there is an update available, false otherwise
+ */
+ override fun checkForUpdates(): Boolean {
+ val difference = System.currentTimeMillis() - lastCheck
+ if(difference > 60000 || lastCheck == 0L) {
+ lastCheckResult = try {
+ val parser = DateTimeFormatter.ISO_INSTANT
+ val currentReleasedAt = Instant.from(parser.parse(getReleaseData(currentVersion).get("published_at").asString))
+ val latestReleasedAt = Instant.from(parser.parse(getReleaseData().get("published_at").asString))
+ currentReleasedAt.isBefore(latestReleasedAt)
+ }catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+
+ return lastCheckResult
+ }
+
+ /**
+ * Gets the information of a single release
+ * Object Sample:
+ * { "published_at": "2022-07-15T21:51:46.397962Z", "version": "v0.4.1-SNAPSHOT", "url": "https://github.com/TheProgramSrc/SimpleCoreAPI/releases/tag/v0.4.1-SNAPSHOT", "author_url": "https://github.com/Im-Fran" }
+ * - published_at: Is the date when the version was made public. This date must be able to be parsed by Instant#from
+ * - version: The version of the latest asset
+ * - url: The url to the version page (null if not available)
+ * - author_url: The url to the author profile (null if not available)
+ *
+ * @param id the name of the release. (If none specified the latest data is fetched. Defaults to "latest")
+ * @return The information of the given release name
+ * @since 0.4.1-SNAPSHOT
+ */
+ override fun getReleaseData(id: String): JsonObject {
+ var cached = requestedData.getOrDefault(id, Pair(JsonObject(), 0L))
+ val difference = System.currentTimeMillis() - cached.second
+ if(difference > 60000 || cached.second == 0L){
+ val url = if(id == "latest"){
+ "https://songoda.com/api/v2/products/id/$productId/versions?sort=-created_at&per_page=1"
+ } else {
+ "https://songoda.com/api/v2/products/id/$productId/versions?sort=-created_at&per_page=1&filter[version]=$id"
+ }
+
+ val data = JsonParser.parseString(URL(url).readText()).asJsonObject.getAsJsonArray("data")
+ if(data.isEmpty){
+ throw RuntimeException("We couldn't find any data for the product with id '$productId' and version '$id'. Please try again later.")
+ }
+ val json = data.get(0).asJsonObject
+
+ cached = Pair(JsonObject().apply {
+ addProperty("published_at", DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(json.get("created_at").asLong * 1000L)))
+ addProperty("version", json.get("version").asString)
+ addProperty("url", json.get("url").asString)
+ addProperty("author_url", "https://songoda.com/profiles/${json.get("uploaded_by").asJsonObject.get("name").asString}")
+ }, System.currentTimeMillis())
+ requestedData[id] = cached
+ }
+ return cached.first
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SpigotUpdateChecker.kt b/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SpigotUpdateChecker.kt
new file mode 100644
index 00000000..7c684529
--- /dev/null
+++ b/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SpigotUpdateChecker.kt
@@ -0,0 +1,92 @@
+package xyz.theprogramsrc.simplecoreapi.global.utils.update
+
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import xyz.theprogramsrc.simplecoreapi.global.utils.ILogger
+import java.net.URL
+import java.time.Instant
+import java.time.format.DateTimeFormatter
+import java.time.format.DateTimeFormatterBuilder
+
+class SpigotUpdateChecker(val logger: ILogger, val resourceId: String, val currentVersion: String): UpdateChecker {
+
+ private var lastCheck = 0L
+ private var lastCheckResult = false
+ private val requestedData = mutableMapOf>()
+ private val current = if(currentVersion.startsWith("v")) currentVersion else "v$currentVersion"
+
+ /**
+ * Checks if there is an update available and prints
+ * a message if there is one asking the end user to
+ * update the product.
+ */
+ override fun checkWithPrint() {
+ val latestData = getReleaseData()
+ val latestVersion = latestData.get("version").asString
+ if(checkForUpdates()){
+ logger.info("Please update (from $current to $latestVersion)! Download it now from here: https://spigotmc.org/resources/$resourceId")
+ }
+ }
+
+ override fun checkForUpdates(): Boolean {
+ val difference = System.currentTimeMillis() - lastCheck
+ if(difference > 60000 || lastCheck == 0L) {
+ lastCheckResult = try {
+ val parser = DateTimeFormatter.ISO_INSTANT
+ val currentReleasedAt = Instant.from(parser.parse(getReleaseData(currentVersion).get("published_at").asString))
+ val latestReleasedAt = Instant.from(parser.parse(getReleaseData().get("published_at").asString))
+ currentReleasedAt.isBefore(latestReleasedAt)
+ }catch (e: Exception) {
+ e.printStackTrace()
+ false
+ }
+ }
+
+ return lastCheckResult
+ }
+
+ /**
+ * Gets the information of a single release
+ * Object Sample:
+ * { "published_at": "2022-07-15T21:51:46.397962Z", "version": "v0.4.1-SNAPSHOT", "url": "https://github.com/TheProgramSrc/SimpleCoreAPI/releases/tag/v0.4.1-SNAPSHOT", "author_url": "https://github.com/Im-Fran" }
+ * - published_at: Is the date when the version was made public. This date must be able to be parsed by Instant#from
+ * - version: The version of the latest asset
+ * - url: The url to the version page (null if not available)
+ * - author_url: The url to the author profile (null if not available)
+ *
+ * @param id the name of the release. (If none specified the latest data is fetched. Defaults to "latest")
+ * @return The information of the given release name
+ * @since 0.4.1-SNAPSHOT
+ */
+ override fun getReleaseData(id: String): JsonObject {
+ var cached = requestedData.getOrDefault(id, Pair(JsonObject(), 0L))
+ val difference = System.currentTimeMillis() - cached.second
+ if(difference > 60000 || cached.second == 0L){
+ val json = if(id == "latest"){
+ JsonParser.parseString(URL("https://api.spiget.org/v2/resources/$resourceId/versions/latest").readText()).asJsonObject
+ } else {
+ var page = 1
+ var data: JsonObject? = null
+ while(data == null) {
+ val versions = JsonParser.parseString(URL("http://api.spiget.org/v2/resources/$resourceId/versions?size=50&page=$page").readText()).asJsonArray
+ if(versions.isEmpty) throw RuntimeException("Couldn't find any version for the given id: $id! Make sure you're using a valid version")
+ data = versions.firstOrNull {
+ it.asJsonObject.get("name").asString == id
+ }?.asJsonObject
+ page++
+ }
+ data
+ }
+
+ cached = Pair(JsonObject().apply {
+ addProperty("published_at", DateTimeFormatter.ISO_INSTANT.format(Instant.ofEpochMilli(json.get("releaseDate").asLong * 1000L)))
+ addProperty("version", json.get("name").asString)
+ }, System.currentTimeMillis())
+ requestedData[id] = cached
+ }
+ return cached.first
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/UpdateChecker.kt b/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/UpdateChecker.kt
new file mode 100644
index 00000000..195a46f8
--- /dev/null
+++ b/src/main/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/UpdateChecker.kt
@@ -0,0 +1,34 @@
+package xyz.theprogramsrc.simplecoreapi.global.utils.update
+
+import com.google.gson.JsonObject
+
+interface UpdateChecker {
+
+ /**
+ * Checks if there is an update available and prints
+ * a message if there is one asking the end user to
+ * update the product.
+ */
+ fun checkWithPrint()
+
+ /**
+ * Checks if there is an update available
+ * @return true if there is an update available, false otherwise
+ */
+ fun checkForUpdates(): Boolean
+
+ /**
+ * Gets the information of a single release
+ * Object Sample:
+ * { "published_at": "2022-07-15T21:51:46.397962Z", "version": "v0.4.1-SNAPSHOT", "url": "https://github.com/TheProgramSrc/SimpleCoreAPI/releases/tag/v0.4.1-SNAPSHOT", "author_url": "https://github.com/Im-Fran" }
+ * - published_at: Is the date when the version was made public. This date must be able to be parsed by Instant#from
+ * - version: The version of the latest asset
+ * - url: The url to the version page (null if not available)
+ * - author_url: The url to the author profile (null if not available)
+ *
+ * @param id the name of the release. (If none specified the latest data is fetched. Defaults to "latest")
+ * @return The information of the given release name
+ * @since 0.4.1-SNAPSHOT
+ */
+ fun getReleaseData(id: String = "latest"): JsonObject
+}
\ No newline at end of file
diff --git a/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/GitHubUpdateCheckerTest.kt b/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/GitHubUpdateCheckerTest.kt
deleted file mode 100644
index edd6c60b..00000000
--- a/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/GitHubUpdateCheckerTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package xyz.theprogramsrc.simplecoreapi.global
-
-import org.junit.jupiter.api.Assertions.assertEquals
-import org.junit.jupiter.api.Test
-import xyz.theprogramsrc.simplecoreapi.global.utils.update.GitHubUpdateChecker
-import xyz.theprogramsrc.simplecoreapi.global.utils.logger.JavaLogger
-import xyz.theprogramsrc.simplecoreapi.global.utils.logger.SLF4JLogger
-import java.util.logging.Logger
-
-internal class GitHubUpdateCheckerTest {
-
- private val check1 = GitHubUpdateChecker(JavaLogger(Logger.getLogger("GitHubUpdateCheckerTest - 1")), "TheProgramSrc/SimpleCoreAPI", "0.3.0-SNAPSHOT", "v0.3.0-SNAPSHOT")
- private val check2 = GitHubUpdateChecker(SLF4JLogger(org.slf4j.LoggerFactory.getLogger("GitHubUpdateCheckerTest - 2")), "TheProgramSrc/SimpleCoreAPI", "0.3.0-SNAPSHOT")
-
- @Test
- fun noUpdatesAvailableTest() {
- assertEquals(false, check1.checkForUpdates())
- }
-
- @Test
- fun updatesAvailableTest() {
- assertEquals(true, check2.checkForUpdates())
- }
-}
diff --git a/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/GitHubUpdateCheckerTest.kt b/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/GitHubUpdateCheckerTest.kt
new file mode 100644
index 00000000..4e770b56
--- /dev/null
+++ b/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/GitHubUpdateCheckerTest.kt
@@ -0,0 +1,28 @@
+package xyz.theprogramsrc.simplecoreapi.global.utils.update
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import xyz.theprogramsrc.simplecoreapi.global.utils.logger.*
+import java.util.logging.Logger
+
+internal class GitHubUpdateCheckerTest {
+
+ private val check1 = GitHubUpdateChecker(JavaLogger(Logger.getLogger("GitHubUpdateCheckerTest - 1")), "TheProgramSrc/SimpleCoreAPI", "0.4.0-SNAPSHOT", "v0.4.0-SNAPSHOT")
+ private val check2 = GitHubUpdateChecker(SLF4JLogger(org.slf4j.LoggerFactory.getLogger("GitHubUpdateCheckerTest - 2")), "TheProgramSrc/SimpleCoreAPI", "0.3.6-SNAPSHOT")
+
+ @Test
+ fun getReleaseData() {
+ val data = check1.getReleaseData()
+ assertFalse(data.keySet().isEmpty())
+ }
+
+ @Test
+ fun noUpdatesAvailableTest() {
+ assertFalse(check1.checkForUpdates())
+ }
+
+ @Test
+ fun updatesAvailableTest() {
+ assertTrue(check2.checkForUpdates())
+ }
+}
diff --git a/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SongodaUpdateCheckerTest.kt b/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SongodaUpdateCheckerTest.kt
new file mode 100644
index 00000000..bb64ffdf
--- /dev/null
+++ b/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SongodaUpdateCheckerTest.kt
@@ -0,0 +1,28 @@
+package xyz.theprogramsrc.simplecoreapi.global.utils.update
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import xyz.theprogramsrc.simplecoreapi.global.utils.logger.*
+import java.util.logging.Logger
+
+internal class SongodaUpdateCheckerTest {
+
+ private val check1 = SongodaUpdateChecker(JavaLogger(Logger.getLogger("SongodaUpdateCheckerTest - 1")), "255", "3.18.1")
+ private val check2 = SongodaUpdateChecker(SLF4JLogger(org.slf4j.LoggerFactory.getLogger("SongodaUpdateCheckerTest - 2")), "255", "3.18.0")
+
+ @Test
+ fun getReleaseData() {
+ val data = check1.getReleaseData()
+ assertFalse(data.keySet().isEmpty())
+ }
+
+ @Test
+ fun noUpdatesAvailableTest() {
+ assertFalse(check1.checkForUpdates())
+ }
+
+ @Test
+ fun updatesAvailableTest() {
+ assertTrue(check2.checkForUpdates())
+ }
+}
\ No newline at end of file
diff --git a/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SpigotUpdateChecker.kt b/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SpigotUpdateChecker.kt
new file mode 100644
index 00000000..85d62a48
--- /dev/null
+++ b/src/test/kotlin/xyz/theprogramsrc/simplecoreapi/global/utils/update/SpigotUpdateChecker.kt
@@ -0,0 +1,28 @@
+package xyz.theprogramsrc.simplecoreapi.global.utils.update
+
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import xyz.theprogramsrc.simplecoreapi.global.utils.logger.*
+import java.util.logging.Logger
+
+internal class SpigotUpdateCheckerTest {
+
+ private val check1 = SpigotUpdateChecker(JavaLogger(Logger.getLogger("SpigotUpdateCheckerTest - 1")), "77825", "3.18.1")
+ private val check2 = SpigotUpdateChecker(SLF4JLogger(org.slf4j.LoggerFactory.getLogger("SpigotUpdateCheckerTest - 2")), "77825", "3.18.0")
+
+ @Test
+ fun getReleaseData() {
+ val data = check1.getReleaseData()
+ assertFalse(data.keySet().isEmpty())
+ }
+
+ @Test
+ fun noUpdatesAvailableTest() {
+ assertFalse(check1.checkForUpdates())
+ }
+
+ @Test
+ fun updatesAvailableTest() {
+ assertTrue(check2.checkForUpdates())
+ }
+}
\ No newline at end of file