diff --git a/src/main/kotlin/com/jeluchu/core/configuration/Routes.kt b/src/main/kotlin/com/jeluchu/core/configuration/Routes.kt index dd75b8d..48ab9ee 100644 --- a/src/main/kotlin/com/jeluchu/core/configuration/Routes.kt +++ b/src/main/kotlin/com/jeluchu/core/configuration/Routes.kt @@ -1,6 +1,8 @@ package com.jeluchu.core.configuration import com.jeluchu.features.anime.routes.animeEndpoints +import com.jeluchu.features.anitakume.routes.anitakumeEndpoints +import com.jeluchu.features.gallery.routes.galleryEndpoints import com.jeluchu.features.news.routes.newsEndpoints import com.jeluchu.features.rankings.routes.rankingsEndpoints import com.jeluchu.features.schedule.routes.scheduleEndpoints @@ -17,7 +19,9 @@ fun Application.initRoutes( newsEndpoints(mongoDatabase) animeEndpoints(mongoDatabase) themesEndpoints(mongoDatabase) + galleryEndpoints(mongoDatabase) rankingsEndpoints(mongoDatabase) scheduleEndpoints(mongoDatabase) + anitakumeEndpoints(mongoDatabase) } } \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/core/messages/ErrorMessages.kt b/src/main/kotlin/com/jeluchu/core/messages/ErrorMessages.kt index 0c87737..36ac709 100644 --- a/src/main/kotlin/com/jeluchu/core/messages/ErrorMessages.kt +++ b/src/main/kotlin/com/jeluchu/core/messages/ErrorMessages.kt @@ -4,6 +4,7 @@ import com.jeluchu.core.enums.* sealed class ErrorMessages(val message: String) { data object NotFound : ErrorMessages("Nyaaaaaaaan! This request has not been found by our alpaca-neko") + data object NotFoundContent : ErrorMessages("No related content found") data object AnimeNotFound : ErrorMessages("This malId is not in our database") data object InvalidMalId : ErrorMessages("The provided id of malId is invalid") data object InvalidDay : ErrorMessages("Invalid 'day' parameter. Valid values are: ${Day.entries.joinToString(", ") { it.name.lowercase() }}") diff --git a/src/main/kotlin/com/jeluchu/core/utils/Constants.kt b/src/main/kotlin/com/jeluchu/core/utils/Constants.kt index 6dfd46d..fc7d534 100644 --- a/src/main/kotlin/com/jeluchu/core/utils/Constants.kt +++ b/src/main/kotlin/com/jeluchu/core/utils/Constants.kt @@ -3,9 +3,13 @@ package com.jeluchu.core.utils object BaseUrls { const val JIKAN = "https://api.jikan.moe/v4/" const val ANIME_THEMES = "https://api.animethemes.moe/" + const val ANIME_PICTURES = "https://api.anime-pictures.net/api/v3/" } object RssUrls { + // Anitakume Podcast + const val ANITAKUME = "https://www.ivoox.com/feed_fg_f1660716_filtro_1.xml" + // Spanish news const val SOMOSKUDASAI = "https://somoskudasai.com/feed/" const val MANGALATAM = "https://www.mangalatam.com/feeds/posts/default?alt=rss" @@ -33,6 +37,7 @@ object RssSources { } object Endpoints { + const val POSTS = "posts" const val ANIME = "anime" const val SCHEDULES = "schedules" const val TOP_ANIME = "top/anime" @@ -50,8 +55,12 @@ object Routes { const val ANIME = "/anime" const val MANGA = "/manga" const val PEOPLE = "/people" + const val SEARCH = "/search" + const val GALLERY = "/gallery" const val SCHEDULE = "/schedule" + const val LAST_POST = "/lastPosts" const val DIRECTORY = "/directory" + const val ANITAKUME = "/anitakume" const val CHARACTER = "/characters" const val LAST_EPISODES = "/lastEpisodes" const val ID = "/{id}" @@ -75,6 +84,7 @@ object Collections { const val NEWS_ES = "news_es" const val NEWS_EN = "news_en" const val SCHEDULES = "schedule" + const val ANITAKUME = "anitakume" const val ANIME_THEMES = "anime_themes" const val ARTISTS_INDEX = "artists_index" const val ANIME_DETAILS = "anime_details" @@ -83,4 +93,6 @@ object Collections { const val MANGA_RANKING = "manga_ranking" const val PEOPLE_RANKING = "people_ranking" const val CHARACTER_RANKING = "character_ranking" + const val ANIME_PICTURES_QUERY = "anime_pictures_query" + const val ANIME_PICTURES_RECENT = "anime_pictures_recent" } \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/anime/mappers/AnimeMappers.kt b/src/main/kotlin/com/jeluchu/features/anime/mappers/AnimeMappers.kt index eefb21c..30a28ff 100644 --- a/src/main/kotlin/com/jeluchu/features/anime/mappers/AnimeMappers.kt +++ b/src/main/kotlin/com/jeluchu/features/anime/mappers/AnimeMappers.kt @@ -35,7 +35,7 @@ fun documentToMoreInfoEntity(doc: Document): MoreInfoEntity { status = doc.getStringSafe("status"), type = doc.getStringSafe("type"), url = doc.getStringSafe("url"), - promo = doc.getDocumentSafe("promo")?.let { documentToVideoPromo(it) } ?: VideoPromo(), + promo = doc.getDocumentSafe("promo")?.let { documentToVideoPromo(it) }, duration = doc.getStringSafe("duration"), rank = doc.getIntSafe("rank", 0), titles = doc.getListSafe("titles").map { documentToAlternativeTitles(it) }, diff --git a/src/main/kotlin/com/jeluchu/features/anime/models/anime/MoreInfoEntity.kt b/src/main/kotlin/com/jeluchu/features/anime/models/anime/MoreInfoEntity.kt index 7ed3c09..0d9dd4c 100644 --- a/src/main/kotlin/com/jeluchu/features/anime/models/anime/MoreInfoEntity.kt +++ b/src/main/kotlin/com/jeluchu/features/anime/models/anime/MoreInfoEntity.kt @@ -22,7 +22,7 @@ data class MoreInfoEntity( var status: String = "", var type: String = "", val url: String = "", - val promo: VideoPromo = VideoPromo(), + val promo: VideoPromo? = VideoPromo(), val duration: String = "", val rank: Int = 0, val titles: List = emptyList(), diff --git a/src/main/kotlin/com/jeluchu/features/anitakume/mappers/AnitakumeMappers.kt b/src/main/kotlin/com/jeluchu/features/anitakume/mappers/AnitakumeMappers.kt new file mode 100644 index 0000000..ab501f8 --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/anitakume/mappers/AnitakumeMappers.kt @@ -0,0 +1,35 @@ +package com.jeluchu.features.anitakume.mappers + +import com.jeluchu.core.extensions.getStringSafe +import com.jeluchu.core.extensions.parseRssDate +import com.jeluchu.features.anitakume.models.AnitakumeEntity +import com.prof18.rssparser.model.RssChannel +import org.bson.Document + +fun RssChannel.toPodcast( + list: MutableList +) = list.apply{ + items.forEach { item -> + add( + AnitakumeEntity( + image = item.itunesItemData?.image.orEmpty(), + title = item.title?.substringAfter(" ยท ").orEmpty(), + description = item.description.orEmpty(), + date = item.pubDate?.parseRssDate().orEmpty(), + link = item.link.orEmpty(), + audio = item.audio.orEmpty(), + duration = item.itunesItemData?.duration.orEmpty() + ) + ) + } +} + +fun documentToAnitakumeEntity(doc: Document) = AnitakumeEntity( + image = doc.getStringSafe("image"), + title = doc.getStringSafe("title"), + description = doc.getStringSafe("description"), + date = doc.getStringSafe("date"), + link = doc.getStringSafe("link"), + audio = doc.getStringSafe("audio"), + duration = doc.getStringSafe("duration") +) \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/anitakume/models/AnitakumeEntity.kt b/src/main/kotlin/com/jeluchu/features/anitakume/models/AnitakumeEntity.kt new file mode 100644 index 0000000..619eb8f --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/anitakume/models/AnitakumeEntity.kt @@ -0,0 +1,14 @@ +package com.jeluchu.features.anitakume.models + +import kotlinx.serialization.Serializable + +@Serializable +data class AnitakumeEntity( + val image: String, + val title: String, + val description: String, + val date: String, + val link: String, + val audio: String, + val duration: String +) \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/anitakume/routes/AnitakumeRoutes.kt b/src/main/kotlin/com/jeluchu/features/anitakume/routes/AnitakumeRoutes.kt new file mode 100644 index 0000000..f757909 --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/anitakume/routes/AnitakumeRoutes.kt @@ -0,0 +1,15 @@ +package com.jeluchu.features.anitakume.routes + +import com.jeluchu.core.extensions.getToJson +import com.jeluchu.core.utils.Routes +import com.jeluchu.features.anitakume.services.AnitakumeService +import com.jeluchu.features.news.services.NewsService +import com.mongodb.client.MongoDatabase +import io.ktor.server.routing.* + +fun Route.anitakumeEndpoints( + mongoDatabase: MongoDatabase, + service: AnitakumeService = AnitakumeService(mongoDatabase) +) = route(Routes.ANITAKUME) { + getToJson { service.getEpisodes(call) } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/anitakume/services/AnitakumeService.kt b/src/main/kotlin/com/jeluchu/features/anitakume/services/AnitakumeService.kt new file mode 100644 index 0000000..16fcdb8 --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/anitakume/services/AnitakumeService.kt @@ -0,0 +1,60 @@ +package com.jeluchu.features.anitakume.services + +import com.jeluchu.core.enums.TimeUnit +import com.jeluchu.core.extensions.needsUpdate +import com.jeluchu.core.extensions.update +import com.jeluchu.core.utils.Collections +import com.jeluchu.core.utils.RssSources +import com.jeluchu.core.utils.RssUrls +import com.jeluchu.core.utils.parseDataToDocuments +import com.jeluchu.features.anitakume.mappers.documentToAnitakumeEntity +import com.jeluchu.features.anitakume.mappers.toPodcast +import com.jeluchu.features.anitakume.models.AnitakumeEntity +import com.jeluchu.features.news.mappers.documentToNewsEntity +import com.jeluchu.features.news.mappers.toNews +import com.jeluchu.features.news.models.NewEntity +import com.mongodb.client.MongoDatabase +import com.prof18.rssparser.RssParser +import io.ktor.http.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.bson.Document + +class AnitakumeService( + database: MongoDatabase +) { + private val timers = database.getCollection(Collections.TIMERS) + private val anitakume = database.getCollection(Collections.ANITAKUME) + + suspend fun getEpisodes(call: RoutingCall) { + val needsUpdate = timers.needsUpdate( + amount = 1, + key = Collections.ANITAKUME, + unit = TimeUnit.DAY + ) + + if (needsUpdate) { + anitakume.deleteMany(Document()) + + val response = mutableListOf().apply { + RssParser().getRssChannel(RssUrls.ANITAKUME).toPodcast(this@apply) + } + + val documentsToInsert = parseDataToDocuments(response, AnitakumeEntity.serializer()) + if (documentsToInsert.isNotEmpty()) anitakume.insertMany(documentsToInsert) + timers.update(Collections.ANITAKUME) + + val elements = documentsToInsert.map { documentToAnitakumeEntity(it) } + call.respond(HttpStatusCode.OK, Json.encodeToString(elements)) + } else { + val animes = anitakume + .find() + .toList() + + val elements = animes.map { documentToNewsEntity(it) } + call.respond(HttpStatusCode.OK, Json.encodeToString(elements)) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/gallery/mappers/GalleryMappers.kt b/src/main/kotlin/com/jeluchu/features/gallery/mappers/GalleryMappers.kt new file mode 100644 index 0000000..d9f92f0 --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/gallery/mappers/GalleryMappers.kt @@ -0,0 +1,63 @@ +package com.jeluchu.features.gallery.mappers + +import com.jeluchu.core.extensions.getBooleanSafe +import com.jeluchu.core.extensions.getDocumentSafe +import com.jeluchu.core.extensions.getFloatSafe +import com.jeluchu.core.extensions.getIntSafe +import com.jeluchu.core.extensions.getListSafe +import com.jeluchu.core.extensions.getStringSafe +import com.jeluchu.core.extensions.parseRssDate +import com.jeluchu.features.anime.mappers.documentToVideoPromo +import com.jeluchu.features.anime.models.anime.VideoPromo +import com.jeluchu.features.gallery.models.PostsResponse +import com.jeluchu.features.gallery.models.ProcessedPost +import com.jeluchu.features.news.models.NewEntity +import com.prof18.rssparser.model.RssChannel +import org.bson.Document + +fun PostsResponse.Post.toProcessedPost(page: Int): ProcessedPost { + val imageUrl = "https://oimages.anime-pictures.net/${md5.take(3)}/$md5$ext" + + return ProcessedPost( + id = id, + image = imageUrl, + width = width, + height = height, + pubtime = pubtime, + size = size, + erotics = erotics, + spoiler = spoiler, + haveAlpha = haveAlpha, + page = page + ) +} + +fun PostsResponse.Post.toProcessedPostQuery(page: Int, query: String): ProcessedPost { + val imageUrl = "https://oimages.anime-pictures.net/${md5.take(3)}/$md5$ext" + + return ProcessedPost( + id = id, + image = imageUrl, + width = width, + height = height, + pubtime = pubtime, + size = size, + erotics = erotics, + spoiler = spoiler, + haveAlpha = haveAlpha, + page = page, + query = query + ) +} + +fun documentToProcessedPost(doc: Document) = ProcessedPost( + id = doc.getIntSafe("id"), + image = doc.getStringSafe("image"), + width = doc.getIntSafe("width"), + height = doc.getIntSafe("height"), + pubtime = doc.getStringSafe("pubtime"), + size = doc.getIntSafe("size"), + erotics = doc.getIntSafe("erotics"), + spoiler = doc.getBooleanSafe("spoiler"), + haveAlpha = doc.getBooleanSafe("haveAlpha") +) \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/gallery/models/PostsResponse.kt b/src/main/kotlin/com/jeluchu/features/gallery/models/PostsResponse.kt new file mode 100644 index 0000000..0c8f09b --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/gallery/models/PostsResponse.kt @@ -0,0 +1,97 @@ +package com.jeluchu.features.gallery.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PostsResponse( + @SerialName("exclusive_tag") + val exclusiveTag: String? = null, + + @SerialName("posts_per_page") + val postsPerPage: Int = 0, + + @SerialName("response_posts_count") + val responsePostsCount: Int = 0, + + @SerialName("page_number") + val pageNumber: Int = 0, + + @SerialName("posts") + val posts: List = emptyList(), + + @SerialName("posts_count") + val postsCount: Int = 0, + + @SerialName("max_pages") + val maxPages: Int = 0 +) { + @Serializable + data class Post( + @SerialName("id") + val id: Int = 0, + + @SerialName("md5") + val md5: String = "", + + @SerialName("md5_pixels") + val md5Pixels: String = "", + + @SerialName("width") + val width: Int = 0, + + @SerialName("height") + val height: Int = 0, + + @SerialName("pubtime") + val pubtime: String = "", + + @SerialName("datetime") + val datetime: String = "", + + @SerialName("score") + val score: Int = 0, + + @SerialName("score_number") + val scoreNumber: Int = 0, + + @SerialName("size") + val size: Int = 0, + + @SerialName("download_count") + val downloadCount: Int = 0, + + @SerialName("erotics") + val erotics: Int = 0, + + @SerialName("color") + val color: List = emptyList(), + + @SerialName("ext") + val ext: String = "", + + @SerialName("status") + val status: Int = 0, + + @SerialName("status_type") + val statusType: Int = 0, + + @SerialName("redirect_id") + val redirectId: String? = null, + + @SerialName("spoiler") + val spoiler: Boolean = false, + + @SerialName("have_alpha") + val haveAlpha: Boolean = false, + + @SerialName("tags_count") + val tagsCount: Int = 0, + + @SerialName("artefacts_degree") + val artefactsDegree: Double = 0.0, + + @SerialName("smooth_degree") + val smoothDegree: Double = 0.0 + ) +} \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/gallery/models/ProcessedPost.kt b/src/main/kotlin/com/jeluchu/features/gallery/models/ProcessedPost.kt new file mode 100644 index 0000000..119c37b --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/gallery/models/ProcessedPost.kt @@ -0,0 +1,40 @@ +package com.jeluchu.features.gallery.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ProcessedPost( + @SerialName("id") + val id: Int, + + @SerialName("image") + val image: String, + + @SerialName("width") + val width: Int, + + @SerialName("height") + val height: Int, + + @SerialName("pubtime") + val pubtime: String, + + @SerialName("size") + val size: Int, + + @SerialName("erotics") + val erotics: Int, + + @SerialName("spoiler") + val spoiler: Boolean, + + @SerialName("have_alpha") + val haveAlpha: Boolean, + + @SerialName("page") + val page: Int = 0, + + @SerialName("query") + val query: String = "0" +) \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/gallery/routes/GalleryRoutes.kt b/src/main/kotlin/com/jeluchu/features/gallery/routes/GalleryRoutes.kt new file mode 100644 index 0000000..63a318d --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/gallery/routes/GalleryRoutes.kt @@ -0,0 +1,19 @@ +package com.jeluchu.features.gallery.routes + +import com.jeluchu.core.extensions.getToJson +import com.jeluchu.core.utils.Routes +import com.jeluchu.features.gallery.services.GalleryService +import com.jeluchu.features.news.services.NewsService +import com.mongodb.client.MongoDatabase +import io.ktor.server.routing.* + +fun Route.galleryEndpoints( + mongoDatabase: MongoDatabase, + service: GalleryService = GalleryService(mongoDatabase) +) = route(Routes.GALLERY) { + route(Routes.LAST_POST) { + getToJson { service.getLastPosts(call) } + } + + getToJson { service.getQueryImages(call) } +} \ No newline at end of file diff --git a/src/main/kotlin/com/jeluchu/features/gallery/services/GalleryService.kt b/src/main/kotlin/com/jeluchu/features/gallery/services/GalleryService.kt new file mode 100644 index 0000000..5dbef02 --- /dev/null +++ b/src/main/kotlin/com/jeluchu/features/gallery/services/GalleryService.kt @@ -0,0 +1,149 @@ +package com.jeluchu.features.gallery.services + +import com.jeluchu.core.connection.RestClient +import com.jeluchu.core.enums.TimeUnit +import com.jeluchu.core.extensions.needsUpdate +import com.jeluchu.core.extensions.update +import com.jeluchu.core.messages.ErrorMessages +import com.jeluchu.core.models.ErrorResponse +import com.jeluchu.core.models.PaginationResponse +import com.jeluchu.core.utils.BaseUrls +import com.jeluchu.core.utils.Collections +import com.jeluchu.core.utils.Endpoints +import com.jeluchu.core.utils.parseDataToDocuments +import com.jeluchu.features.gallery.mappers.documentToProcessedPost +import com.jeluchu.features.gallery.mappers.toProcessedPost +import com.jeluchu.features.gallery.mappers.toProcessedPostQuery +import com.jeluchu.features.gallery.models.PostsResponse +import com.jeluchu.features.gallery.models.ProcessedPost +import com.mongodb.client.MongoDatabase +import com.mongodb.client.model.Filters +import io.ktor.http.* +import io.ktor.server.response.* +import io.ktor.server.response.respond +import io.ktor.server.routing.* +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okio.IOException +import java.net.URLEncoder + +class GalleryService( + database: MongoDatabase +) { + private val timers = database.getCollection(Collections.TIMERS) + private val queryPosts = database.getCollection(Collections.ANIME_PICTURES_QUERY) + private val recentPosts = database.getCollection(Collections.ANIME_PICTURES_RECENT) + + suspend fun getLastPosts(call: RoutingCall) { + val size = 80 + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1 + val timerKey = "${Collections.ANIME_PICTURES_RECENT}_${page}" + val skipCount = (page - 1) * size + + val needsUpdate = timers.needsUpdate( + amount = 30, + key = timerKey, + unit = TimeUnit.DAY + ) + + if (needsUpdate) { + recentPosts.deleteMany(Filters.and(Filters.eq("page", page))) + val response = RestClient.request( + BaseUrls.ANIME_PICTURES + Endpoints.POSTS + "?page=$page", + PostsResponse.serializer() + ).posts.map { it.toProcessedPost(page) } + + val documentsToInsert = parseDataToDocuments(response, ProcessedPost.serializer()) + if (documentsToInsert.isNotEmpty()) recentPosts.insertMany(documentsToInsert) + timers.update(timerKey) + + val elements = documentsToInsert.map { documentToProcessedPost(it) } + + val paginationResponse = PaginationResponse( + page = page, + size = size, + data = elements + ) + + call.respond(HttpStatusCode.OK, Json.encodeToString(paginationResponse)) + } else { + val posts = recentPosts + .find(Filters.and(Filters.eq("page", page))) + .skip(skipCount) + .limit(size) + .toList() + + val elements = posts.map { documentToProcessedPost(it) } + val response = PaginationResponse( + page = page, + size = size, + data = elements + ) + + call.respond(HttpStatusCode.OK, Json.encodeToString(response)) + } + } + + suspend fun getQueryImages(call: RoutingCall) { + val size = 80 + val query = call.request.queryParameters["query"].orEmpty() + val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 0 + val timerKey = "${Collections.ANIME_PICTURES_QUERY}_${query}_${page}" + val skipCount = (page - 1) * size + val fixedPage = page + 1 + + val needsUpdate = timers.needsUpdate( + amount = 30, + key = timerKey, + unit = TimeUnit.DAY + ) + + if (needsUpdate) { + queryPosts.deleteMany(Filters.and(Filters.eq("page", fixedPage))) + + try { + val response = RestClient.request( + BaseUrls.ANIME_PICTURES + Endpoints.POSTS + "?search_tag=${URLEncoder.encode(query, "UTF-8")}" + "&lang=en&type=json_v3" + "&page=$page", + PostsResponse.serializer() + ).posts.map { it.toProcessedPostQuery(fixedPage, query) } + + val documentsToInsert = parseDataToDocuments(response, ProcessedPost.serializer()) + if (documentsToInsert.isNotEmpty()) queryPosts.insertMany(documentsToInsert) + timers.update(timerKey) + + val elements = documentsToInsert.map { documentToProcessedPost(it) } + + val paginationResponse = PaginationResponse( + page = fixedPage, + size = size, + data = elements + ) + + call.respond(HttpStatusCode.OK, Json.encodeToString(paginationResponse)) + } catch (e: IOException) { + val paginationResponse = PaginationResponse( + page = fixedPage, + size = size, + data = emptyList() + ) + + call.respond(HttpStatusCode.OK, Json.encodeToString(paginationResponse)) + } + } else { + val posts = queryPosts + .find(Filters.and(Filters.eq("page", fixedPage))) + .skip(skipCount) + .limit(size) + .toList() + + val elements = posts.map { documentToProcessedPost(it) } + val response = PaginationResponse( + page = fixedPage, + size = size, + data = elements + ) + + call.respond(HttpStatusCode.OK, Json.encodeToString(response)) + } + } +} \ No newline at end of file