diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 16aca97..ce8469c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,5 +13,4 @@ dependencies { implementation(libs.bundles.logger) implementation(libs.bundles.database) - implementation("io.ktor:ktor-client-logging-jvm:2.3.4") } diff --git a/app/src/main/kotlin/Application.kt b/app/src/main/kotlin/Application.kt index 6e2dfbd..fb85ba3 100644 --- a/app/src/main/kotlin/Application.kt +++ b/app/src/main/kotlin/Application.kt @@ -26,7 +26,8 @@ package dev.triumphteam.docsly import com.zaxxer.hikari.HikariDataSource import dev.triumphteam.docsly.config.createOrGetConfig import dev.triumphteam.docsly.controller.apiGuild -import dev.triumphteam.docsly.database.entity.DocsTable +import dev.triumphteam.docsly.database.entity.DocumentsTable +import dev.triumphteam.docsly.database.entity.ProjectsTable import dev.triumphteam.docsly.defaults.Defaults import dev.triumphteam.docsly.meilisearch.Meili import dev.triumphteam.docsly.project.Projects @@ -56,7 +57,10 @@ public fun Application.module() { Database.connect(HikariDataSource(config.postgres.toHikariConfig())) transaction { - SchemaUtils.create(DocsTable) + SchemaUtils.create( + ProjectsTable, + DocumentsTable, + ) } /*install(CORS) { diff --git a/app/src/main/kotlin/controller/ApiGuildController.kt b/app/src/main/kotlin/controller/ApiGuildController.kt index 689c530..835da33 100644 --- a/app/src/main/kotlin/controller/ApiGuildController.kt +++ b/app/src/main/kotlin/controller/ApiGuildController.kt @@ -1,48 +1,59 @@ package dev.triumphteam.docsly.controller -import dev.triumphteam.docsly.api.GuildSetupRequest +import dev.triumphteam.docsly.database.entity.DocumentEntity import dev.triumphteam.docsly.defaults.Defaults +import dev.triumphteam.docsly.elements.DocElement +import dev.triumphteam.docsly.meilisearch.Meili +import dev.triumphteam.docsly.project.DocumentSearchResult +import dev.triumphteam.docsly.project.IndexDocument import dev.triumphteam.docsly.project.Projects import dev.triumphteam.docsly.resource.GuildApi import io.ktor.http.HttpStatusCode import io.ktor.server.application.call import io.ktor.server.application.plugin -import io.ktor.server.request.receive +import io.ktor.server.resources.get import io.ktor.server.resources.post import io.ktor.server.response.respond import io.ktor.server.routing.Routing +import org.jetbrains.exposed.sql.transactions.transaction public fun Routing.apiGuild() { val defaults = plugin(Defaults) val projects = plugin(Projects) + val meili = plugin(Meili) - post { setup -> - val guild = setup.parent.guild - val setupDefaults = call.receive().defaults - - val defaultProjects = defaults.defaultProjects() - - // Validate data - setupDefaults.forEach { (project, versions) -> - val defaultVersions = defaultProjects[project] ?: run { - call.respond(HttpStatusCode.BadRequest, "Invalid default project '$project'.") - return@post - } - - versions.forEach { version -> - // TODO: Figure better way to get version, and not use folder name - val replaced = version.replace(".", "_") - if (replaced !in defaultVersions) { - call.respond(HttpStatusCode.BadRequest, "Invalid default project version '$version' for project '$project'.") - return@post - } - } - } - + post { api -> // If it goes well nothing will throw and it'll work well! - projects.setupProjects(guild, setupDefaults) + projects.setup(api.parent.guild, defaults) // So we return "accepted" call.respond(HttpStatusCode.Accepted) } + + get { api -> + call.respond(projects.getProjects(api.parent.guild)) + } + + get { api -> + val project = projects.getProject(api.parent.guild, api.project, api.version) ?: run { + call.respond(HttpStatusCode.BadRequest) + return@get + } + + val index = meili.client.index(Projects.indexKeyFor(api.parent.guild, project)) + + val result = index.search(api.query, null) + .map { DocumentSearchResult(it.references.first(), it.id) } + .take(20) + + call.respond(result) + } + + get { api -> + val document = transaction { + DocumentEntity[api.id] + } + + call.respond(document.document) + } } diff --git a/app/src/main/kotlin/database/entity/DocsEntity.kt b/app/src/main/kotlin/database/entity/DocsEntity.kt index 7309a8c..0e87833 100644 --- a/app/src/main/kotlin/database/entity/DocsEntity.kt +++ b/app/src/main/kotlin/database/entity/DocsEntity.kt @@ -1,5 +1,7 @@ package dev.triumphteam.docsly.database.entity +import dev.triumphteam.docsly.database.entity.DocumentsTable.document +import dev.triumphteam.docsly.database.entity.DocumentsTable.projectId import dev.triumphteam.docsly.database.exposed.serializable import dev.triumphteam.docsly.elements.DocElement import org.jetbrains.exposed.dao.LongEntity @@ -8,19 +10,26 @@ import org.jetbrains.exposed.dao.id.EntityID import org.jetbrains.exposed.dao.id.LongIdTable import org.jetbrains.exposed.sql.Column -public object DocsTable : LongIdTable() { +/** + * Represents a table that stores documents associated with a project. + * + * This class extends the `LongIdTable` class and provides columns to store + * the project ID and document contents. + * + * @property projectId The column to store the ID of the project associated with the document. + * @property document The column to store the document contents. + */ +public object DocumentsTable : LongIdTable("docsly_documents") { - public val guild: Column = text("guild_id") - public val project: Column = text("project") - public val version: Column = text("version") - public val doc: Column = serializable("doc") + public val projectId: Column = integer("project_id").references(ProjectsTable.id) + public val document: Column = serializable("document") } -public class DocEntity(entityId: EntityID) : LongEntity(entityId) { - public companion object : LongEntityClass(DocsTable) +/** Document entity referencing the table [DocumentsTable]. */ +public class DocumentEntity(entityId: EntityID) : LongEntity(entityId) { - public var guild: String by DocsTable.guild - public var project: String by DocsTable.project - public var version: String by DocsTable.version - public var doc: DocElement by DocsTable.doc + public companion object : LongEntityClass(DocumentsTable) + + public var projectId: Int by DocumentsTable.projectId + public var document: DocElement by DocumentsTable.document } diff --git a/app/src/main/kotlin/database/entity/ProjectEntity.kt b/app/src/main/kotlin/database/entity/ProjectEntity.kt new file mode 100644 index 0000000..2ab5c73 --- /dev/null +++ b/app/src/main/kotlin/database/entity/ProjectEntity.kt @@ -0,0 +1,40 @@ +package dev.triumphteam.docsly.database.entity + +import org.jetbrains.exposed.dao.IntEntity +import org.jetbrains.exposed.dao.IntEntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IntIdTable +import org.jetbrains.exposed.sql.Column + +/** + * A database table for storing projects information. + * + * The ProjectsTable class extends the IntIdTable class to inherit the primary key column 'id'. + * It has columns to store the guild id, name, version, and a boolean flag for the latest version. + * The table has a unique constraint on the combination of guild, name, and version columns. + */ +public object ProjectsTable : IntIdTable("docsly_projects") { + + public val guild: Column = text("guild_id") + public val name: Column = text("name") + public val version: Column = text("version") + public val latest: Column = bool("latest") + + init { + uniqueIndex( + columns = arrayOf(guild, name, version), + customIndexName = "docsly_guild_name_version_uq" + ) + } +} + +/** Project entity referencing the table [ProjectsTable]. */ +public class ProjectEntity(entityId: EntityID) : IntEntity(entityId) { + + public companion object : IntEntityClass(ProjectsTable) + + public var guild: String by ProjectsTable.guild + public var name: String by ProjectsTable.name + public var version: String by ProjectsTable.version + public var latest: Boolean by ProjectsTable.latest +} diff --git a/app/src/main/kotlin/database/exposed/Entity.kt b/app/src/main/kotlin/database/exposed/Entity.kt new file mode 100644 index 0000000..958d3d3 --- /dev/null +++ b/app/src/main/kotlin/database/exposed/Entity.kt @@ -0,0 +1,14 @@ +package dev.triumphteam.docsly.database.exposed + +import org.jetbrains.exposed.dao.Entity +import org.jetbrains.exposed.dao.EntityClass +import org.jetbrains.exposed.dao.id.EntityID +import org.jetbrains.exposed.dao.id.IdTable + +public abstract class StringEntity(id: EntityID) : Entity(id) + +public abstract class StringEntityClass( + table: IdTable, + entityType: Class? = null, + entityCtor: ((EntityID) -> E)? = null +) : EntityClass(table, entityType, entityCtor) diff --git a/app/src/main/kotlin/database/exposed/ListColumn.kt b/app/src/main/kotlin/database/exposed/ListColumn.kt new file mode 100644 index 0000000..082809a --- /dev/null +++ b/app/src/main/kotlin/database/exposed/ListColumn.kt @@ -0,0 +1,69 @@ +/** + * MIT License + * + * Copyright (c) 2019-2022 TriumphTeam and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package dev.triumphteam.docsly.database.exposed + +import com.impossibl.postgres.jdbc.PGArray +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.ColumnType +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.statements.api.PreparedStatementApi +import org.jetbrains.exposed.sql.statements.jdbc.JdbcPreparedStatementImpl +import org.jetbrains.exposed.sql.vendors.currentDialect + +public fun Table.stringSet(name: String): Column> = + registerColumn(name, StringListColumnType()) + +public class StringListColumnType : ColumnType() { + + override fun sqlType(): String = "${currentDialect.dataTypeProvider.textType()}[]" + + /** When writing the value, it can either be a full on list, or individual values. */ + override fun notNullValueToDB(value: Any): Any = when (value) { + is Collection<*> -> value + is String -> setOf(value) + else -> error("$value of ${value::class.qualifiedName} is not valid for string set") + } + + /** When getting the value it can be more than just [PGArray]. */ + override fun valueFromDB(value: Any): Any = when (value) { + is PGArray -> (value.array as Array<*>).toSet() + is Set<*> -> value + is Array<*> -> value.toSet() + is Collection<*> -> value.toSet() + else -> error("Got unexpected array value of type: ${value::class.qualifiedName} ($value)") + } + + override fun setParameter(stmt: PreparedStatementApi, index: Int, value: Any?) { + if (value == null) { + stmt.setNull(index, this) + } else { + val preparedStatement = stmt as? JdbcPreparedStatementImpl ?: error("Currently only JDBC is supported") + val array = preparedStatement.statement.connection.createArrayOf( + currentDialect.dataTypeProvider.integerType(), + (value as Collection<*>).toTypedArray() + ) + stmt[index] = array + } + } +} diff --git a/app/src/main/kotlin/defaults/Defaults.kt b/app/src/main/kotlin/defaults/Defaults.kt index 52639b1..9db4bc4 100644 --- a/app/src/main/kotlin/defaults/Defaults.kt +++ b/app/src/main/kotlin/defaults/Defaults.kt @@ -3,41 +3,18 @@ package dev.triumphteam.docsly.defaults import io.ktor.server.application.Application import io.ktor.server.application.BaseApplicationPlugin import io.ktor.util.AttributeKey +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import org.slf4j.Logger +import org.slf4j.LoggerFactory import java.io.File -import java.io.FileNotFoundException import java.nio.file.Path import kotlin.io.path.Path import kotlin.io.path.isDirectory -import kotlin.io.path.notExists +import kotlin.io.path.name public class Defaults { - // Move to separate repo - private val defaultsFolder: Path = Path("data/defaults") - private val defaults: Map> = defaultsFolder.toFile().listFiles()?.mapNotNull { project -> - if (!project.isDirectory) return@mapNotNull null - - val versions = project.listFiles()?.mapNotNull { version -> - if (!version.isDirectory) null else version.name - }?.toSet() ?: emptySet() - - project.name to versions - }?.toMap() ?: emptyMap() - - public fun defaultProjects(): Map> { - return defaults - } - - public fun resolve(project: String, version: String): File { - val folder = defaultsFolder.resolve("$project/$version") - if (folder.notExists() || !folder.isDirectory()) { - throw FileNotFoundException("Could not find file with path '${"$project/$version"}'") - } - - return folder.toFile().listFiles()?.find { it.name.endsWith(".json") } - ?: throw FileNotFoundException("Could not find json file for '${"$project/$version"}'") - } - public companion object Plugin : BaseApplicationPlugin { override val key: AttributeKey = AttributeKey("defaults") @@ -46,4 +23,40 @@ public class Defaults { return Defaults() } } + + private val logger: Logger = LoggerFactory.getLogger(Defaults::class.java) + + // Move to separate repo + private val defaultsFolder: Path = Path("data/defaults") + public val defaults: Map = defaultsFolder.toFile().listFiles()?.flatMap { project -> + if (!project.isDirectory) return@flatMap emptyList() + + project.listFiles()?.mapNotNull inner@{ version -> + if (!version.isDirectory) return@inner null + + val files = version.listFiles() ?: return@inner run { + logger.warn("Ignoring folder ${version.name}, as it is empty.") + null + } + + val versionFile = files.find { it.name == "version.json" } ?: return@inner run { + logger.warn("Ignoring folder ${version.name}, as it does not have a version.json file.") + null + } + + val documentsFile = files.find { it.name == "documents.json" } ?: return@inner run { + logger.warn("Ignoring folder ${version.name}, as it does not have a documents.json file.") + null + } + + Json.decodeFromString(versionFile.readText()) to documentsFile + } ?: emptyList() + }?.toMap() ?: emptyMap() + + @Serializable + public data class FileProjectVersion( + public val project: String, + public val version: String, + public val latest: Boolean, + ) } diff --git a/app/src/main/kotlin/project/Projects.kt b/app/src/main/kotlin/project/Projects.kt index 2b076f1..b664bc7 100644 --- a/app/src/main/kotlin/project/Projects.kt +++ b/app/src/main/kotlin/project/Projects.kt @@ -1,6 +1,8 @@ package dev.triumphteam.docsly.project -import dev.triumphteam.docsly.database.entity.DocEntity +import dev.triumphteam.docsly.database.entity.DocumentEntity +import dev.triumphteam.docsly.database.entity.ProjectEntity +import dev.triumphteam.docsly.database.entity.ProjectsTable import dev.triumphteam.docsly.defaults.Defaults import dev.triumphteam.docsly.elements.DocElement import dev.triumphteam.docsly.meilisearch.Meili @@ -9,8 +11,11 @@ import io.ktor.server.application.BaseApplicationPlugin import io.ktor.server.application.plugin import io.ktor.util.AttributeKey import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import org.jetbrains.exposed.sql.Op +import org.jetbrains.exposed.sql.SqlExpressionBuilder +import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq +import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.transactions.transaction /** @@ -30,8 +35,8 @@ public class Projects(private val meili: Meili, private val defaults: Defaults) return Projects(pipeline.plugin(Meili), pipeline.plugin(Defaults)) } - public fun indexKeyFor(guild: String, project: String, version: String): String { - return "${guild}_${project}_$version" + public fun indexKeyFor(guild: String, projectEntity: ProjectEntity): String { + return "${guild}_${projectEntity.id.value}" } public val JSON: Json = Json { @@ -46,38 +51,70 @@ public class Projects(private val meili: Meili, private val defaults: Defaults) * @param guild The name of the guild. * @param projects A map containing project names as keys and sets of versions as values. */ - public suspend fun setupProjects(guild: String, projects: Map>) { + public suspend fun setup(guild: String, defaults: Defaults) { + // TODO: REPLACE WITH BETTER SETUP LATER + val mapped = transaction { - projects.mapValues { (project, versions) -> - versions.associateWith { version -> - val jsonFile = defaults.resolve(project, version.replace(".", "_")) - val docs = JSON.decodeFromString>(jsonFile.readText()) - - docs.map { doc -> - DocEntity.new { - this.guild = guild - this.project = project - this.version = version - this.doc = doc - } + defaults.defaults.entries.associate { (versionData, documentsFile) -> + + val docs = JSON.decodeFromString>(documentsFile.readText()) + + val project = transaction { + ProjectEntity.new { + this.guild = guild + this.name = versionData.project + this.version = versionData.version + this.latest = versionData.latest + } + } + + project to docs.map { doc -> + DocumentEntity.new { + this.projectId = project.id.value + this.document = doc } } } } - mapped.forEach { (project, versions) -> - versions.forEach { (version, docs) -> - docs.chunked(1000).forEach { chunk -> - meili.client.index( - indexKeyFor(guild, project, version.replace(".", "_")), - primaryKey = "id", - ).addDocuments( - chunk.map { doc -> - IndexDocument(doc.id.value, doc.doc.createReferences()) - }.also { println(JSON.encodeToString(it)) }, - ) + mapped.forEach { (project, documents) -> + documents.chunked(500).forEach { chunk -> + meili.client.index( + indexKeyFor(guild, project), + primaryKey = "id", + ).addDocuments( + chunk.map { doc -> + IndexDocument(doc.id.value, doc.document.createReferences()) + }, + ) + } + } + } + + public fun getProjects(guild: String): List { + return transaction { + ProjectEntity.find { + ProjectsTable.guild eq guild + }.groupBy(ProjectEntity::name) + .map { (project, entity) -> + ProjectData(project, entity.map(ProjectEntity::version)) + } + } + } + + public fun getProject(guild: String, project: String, version: String?): ProjectEntity? { + return transaction { + val query: SqlExpressionBuilder.() -> Op = if (version == null) { + { + (ProjectsTable.guild eq guild) and (ProjectsTable.name eq project) and (ProjectsTable.latest eq true) + } + } else { + { + (ProjectsTable.guild eq guild) and (ProjectsTable.name eq project) and (ProjectsTable.version eq version) } } + + ProjectEntity.find(query).firstOrNull() } } } diff --git a/build-logic/src/main/kotlin/docsly.base.gradle.kts b/build-logic/src/main/kotlin/docsly.base.gradle.kts index 05a8482..5014ef0 100644 --- a/build-logic/src/main/kotlin/docsly.base.gradle.kts +++ b/build-logic/src/main/kotlin/docsly.base.gradle.kts @@ -29,12 +29,11 @@ license { include("**/*.kt") } -java { - targetCompatibility = JavaVersion.VERSION_1_8 - withSourcesJar() -} - kotlin { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + explicitApi() } @@ -63,8 +62,6 @@ spotless { tasks { withType { kotlinOptions { - jvmTarget = "1.8" - languageVersion = "1.8" javaParameters = true freeCompilerArgs = listOf( "-Xcontext-receivers", diff --git a/common/src/main/kotlin/project/ProjectData.kt b/common/src/main/kotlin/project/ProjectData.kt new file mode 100644 index 0000000..6d11f33 --- /dev/null +++ b/common/src/main/kotlin/project/ProjectData.kt @@ -0,0 +1,9 @@ +package dev.triumphteam.docsly.project + +import kotlinx.serialization.Serializable + +@Serializable +public data class ProjectData(public val name: String, public val versions: List) + +@Serializable +public data class DocumentSearchResult(public val value: String, public val id: Long) diff --git a/common/src/main/kotlin/resource/ApiResources.kt b/common/src/main/kotlin/resource/ApiResources.kt index ebabde5..1de2591 100644 --- a/common/src/main/kotlin/resource/ApiResources.kt +++ b/common/src/main/kotlin/resource/ApiResources.kt @@ -41,19 +41,24 @@ public class GuildApi { @Resource("setup") public class Setup(public val parent: Guild) - /** Resource location for "/api/[guild]/[index]/[version]". *//* @Serializable - @Resource("{project}/{version}") - public class Project( + @Resource("projects") + public class Projects(public val parent: Guild) + + @Serializable + @Resource("search") + public class Search( public val parent: Guild, - public val index: String, - public val version: String, - ) { + public val project: String = "", + public val query: String = "", + public val version: String? = null, + ) - *//** Resource location for "/api/[guild]/[index]/[version]/search". *//* - @Serializable - @Resource("search") - public class Search(public val parent: Project) - }*/ + @Serializable + @Resource("document/{id}") + public class Document( + public val parent: Guild, + public val id: Long, + ) } } diff --git a/discord/build.gradle.kts b/discord/build.gradle.kts new file mode 100644 index 0000000..437c7be --- /dev/null +++ b/discord/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("docsly.base") +} + +dependencies { + implementation(projects.docslyCommon) + implementation(projects.docslyRendererDiscord) + + implementation(libs.kotlinx.serialization.hocon) + implementation(libs.bundles.ktor.client) + implementation(libs.bundles.logger) + implementation(libs.caffeine) + implementation(libs.kord) +} diff --git a/discord/src/main/kotlin/Application.kt b/discord/src/main/kotlin/Application.kt new file mode 100644 index 0000000..6318aa0 --- /dev/null +++ b/discord/src/main/kotlin/Application.kt @@ -0,0 +1,28 @@ +package dev.triumphteam.docsly.kord + +import dev.kord.core.Kord +import dev.kord.gateway.Intent +import dev.kord.gateway.Intents +import dev.triumphteam.docsly.kord.client.DocslyClient +import dev.triumphteam.docsly.kord.command.SearchCommand +import dev.triumphteam.docsly.kord.command.SetupCommand +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger(Application::class.java) + +public class Application + +public suspend fun main() { + val kord = Kord(System.getenv("DISCORD_TOKEN")) + + logger.info("Logging in!") + + val docslyClient = DocslyClient() + + SetupCommand(kord, docslyClient) + SearchCommand(kord, docslyClient) + + kord.login { + this.intents = Intents(Intent.Guilds, Intent.DirectMessages, Intent.GuildMessages) + } +} diff --git a/discord/src/main/kotlin/client/DocslyClient.kt b/discord/src/main/kotlin/client/DocslyClient.kt new file mode 100644 index 0000000..50926b3 --- /dev/null +++ b/discord/src/main/kotlin/client/DocslyClient.kt @@ -0,0 +1,69 @@ +package dev.triumphteam.docsly.kord.client + +import dev.kord.common.entity.Snowflake +import dev.triumphteam.docsly.elements.DocElement +import dev.triumphteam.docsly.project.DocumentSearchResult +import dev.triumphteam.docsly.project.ProjectData +import dev.triumphteam.docsly.resource.GuildApi +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.resources.Resources +import io.ktor.client.plugins.resources.get +import io.ktor.client.plugins.resources.post +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json + +public class DocslyClient { + + public val client: HttpClient = HttpClient(CIO) { + install(Resources) + install(ContentNegotiation) { json() } + /*install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.BODY + }*/ + + defaultRequest { + this.host = "localhost" + this.port = 8080 + + contentType(ContentType.Application.Json) + } + } + + public suspend fun setup(guild: Snowflake): HttpResponse { + return client.post(GuildApi.Guild.Setup(GuildApi.Guild(guild = guild.value.toString()))) + } + + public suspend fun getProjects(guild: Snowflake): List { + return client.get(GuildApi.Guild.Projects(GuildApi.Guild(guild = guild.value.toString()))) + .body>() + } + + public suspend fun search(guild: Snowflake, project: String, version: String?, query: String): List { + return client.get( + GuildApi.Guild.Search( + GuildApi.Guild( + guild = guild.value.toString(), + ), + project = project, + query = query, + version = version, + ) + ).body>() + } + + public suspend fun getDocument(guild: Snowflake, id: Long): DocElement { + return client.get( + GuildApi.Guild.Document( + GuildApi.Guild(guild = guild.value.toString()), + id = id, + ) + ).body() + } +} diff --git a/discord/src/main/kotlin/command/SearchCommand.kt b/discord/src/main/kotlin/command/SearchCommand.kt new file mode 100644 index 0000000..677ae9b --- /dev/null +++ b/discord/src/main/kotlin/command/SearchCommand.kt @@ -0,0 +1,135 @@ +package dev.triumphteam.docsly.kord.command + +import com.github.benmanes.caffeine.cache.Caffeine +import dev.kord.common.entity.Choice +import dev.kord.common.entity.Snowflake +import dev.kord.common.entity.optional.Optional +import dev.kord.core.Kord +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.behavior.interaction.suggest +import dev.kord.core.event.guild.GuildCreateEvent +import dev.kord.core.event.interaction.GuildAutoCompleteInteractionCreateEvent +import dev.kord.core.event.interaction.GuildChatInputCommandInteractionCreateEvent +import dev.kord.core.on +import dev.kord.rest.builder.interaction.string +import dev.triumphteam.docsly.kord.client.DocslyClient +import dev.triumphteam.docsly.project.ProjectData +import dev.triumphteam.docsly.renderer.DiscordDocumentRenderer + +public class SearchCommand( + private val kord: Kord, + private val docslyClient: DocslyClient, +) { + + private val projectsCache = Caffeine.newBuilder().build>() + private val renderer = DiscordDocumentRenderer() + + init { + kord.on { + kord.createGuildChatInputCommand(guild.id, "search", "Search for a doc.") { + string("project", "The project to search the docs for.") { + required = true + autocomplete = true + } + + string("query", "Search query.") { + required = true + autocomplete = true + } + + string("version", "The version of the project.") { + required = false + autocomplete = true + } + } + } + + kord.on { onCommandSuggestion() } + + kord.on { + if (interaction.command.rootName != "search") return@on + onCommand() + } + } + + private suspend fun GuildAutoCompleteInteractionCreateEvent.onCommandSuggestion() { + if (interaction.command.rootName != "search") return + + val guildId = interaction.guildId + + // Gets a list of projects and their version + val projects = projectsCache.getIfPresent(guildId) ?: docslyClient.getProjects(guildId).also { + projectsCache.put(guildId, it) + } + + val focusedOption = interaction.command.options.entries.firstOrNull { it.value.focused }?.key + val focusedValue = interaction.focusedOption.value + + when (focusedOption) { + "project" -> { + val list = projects.map { + Choice.StringChoice( + it.name, + Optional.Missing(), + it.name, + ) + } + + interaction.suggest( + if (focusedValue.isEmpty()) { + list + } else { + list.filter { it.name.startsWith(focusedValue) } + } + ) + } + + "version" -> { + val typedProject = interaction.command.options["project"]?.value as? String + val versions = projects.find { it.name == typedProject }?.versions ?: emptyList() + + val list = versions.map { + Choice.StringChoice(it, Optional.Missing(), it) + } + + interaction.suggest( + if (focusedValue.isEmpty()) { + list + } else { + list.filter { it.name.startsWith(focusedValue) } + } + ) + } + + "query" -> { + val typedProject = interaction.command.options["project"]?.value as? String ?: "" + val typedVersion = interaction.command.options["version"]?.value as? String + + val list = docslyClient.search(guildId, typedProject, typedVersion, focusedValue).map { result -> + Choice.StringChoice(result.value, Optional.Missing(), result.id.toString()) + } + + interaction.suggest(list) + } + } + } + + private suspend fun GuildChatInputCommandInteractionCreateEvent.onCommand() { + val defer = interaction.deferPublicResponse() + + val document = interaction.command.options["query"]?.value as? String ?: "" + + val id = document.toLongOrNull() ?: run { + defer.respond { + content = "uh" + } + return + } + + val docElement = docslyClient.getDocument(interaction.guildId, id) + + defer.respond { + content = renderer.render(docElement) + } + } +} diff --git a/discord/src/main/kotlin/command/SetupCommand.kt b/discord/src/main/kotlin/command/SetupCommand.kt new file mode 100644 index 0000000..3972876 --- /dev/null +++ b/discord/src/main/kotlin/command/SetupCommand.kt @@ -0,0 +1,40 @@ +package dev.triumphteam.docsly.kord.command + +import dev.kord.core.Kord +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.event.guild.GuildCreateEvent +import dev.kord.core.event.interaction.GuildChatInputCommandInteractionCreateEvent +import dev.kord.core.on +import dev.triumphteam.docsly.kord.client.DocslyClient +import io.ktor.http.HttpStatusCode + +public class SetupCommand( + kord: Kord, + private val docslyClient: DocslyClient, +) { + + init { + + kord.on { + kord.createGuildChatInputCommand(guild.id, "setup", "Temporary setup command.") + } + + kord.on { + if (interaction.command.rootName != "setup") return@on + onCommand() + } + } + + private suspend fun GuildChatInputCommandInteractionCreateEvent.onCommand() { + val defer = interaction.deferPublicResponse() + + val response = docslyClient.setup(interaction.guildId) + + defer.respond { + content = when (response.status) { + HttpStatusCode.Accepted -> "Done!" + else -> "OOPSIE WOOPSIE!! Uwu we made a fucky wucky." + } + } + } +} diff --git a/discord/src/main/kotlin/package-info.kt b/discord/src/main/kotlin/package-info.kt new file mode 100644 index 0000000..14208d9 --- /dev/null +++ b/discord/src/main/kotlin/package-info.kt @@ -0,0 +1,24 @@ +/** + * MIT License + * + * Copyright (c) 2019-2022 TriumphTeam and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package dev.triumphteam.docsly.kord diff --git a/discord/src/main/resources/log4j2.xml b/discord/src/main/resources/log4j2.xml new file mode 100644 index 0000000..e86bd27 --- /dev/null +++ b/discord/src/main/resources/log4j2.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9092de0..1ebec55 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,13 @@ postgres = "0.8.9" hikari = "5.0.1" # Logging -log4j = "2.18.0" +log4j = "2.20.0" + +# Discord +kord = "0.11.1" + +# Caching +caffeine = "3.1.8" # Formatting spotless = "6.12.0" @@ -55,13 +61,19 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k # DB exposed = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } exposed-dao = { module = "org.jetbrains.exposed:exposed-dao", version.ref = "exposed" } +exposed-jdbc = { module = "org.jetbrains.exposed:exposed-jdbc", version.ref = "exposed" } hikari = { module = "com.zaxxer:HikariCP", version.ref = "hikari" } postgres = { module = "com.impossibl.pgjdbc-ng:pgjdbc-ng", version.ref = "postgres" } # Logger -logger-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j" } logger-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" } -logger-impl = { module = "org.apache.logging.log4j:log4j-slf4j18-impl", version.ref = "log4j" } +logger-impl = { module = "org.apache.logging.log4j:log4j-slf4j2-impl", version.ref = "log4j" } + +# Discord +kord = { module = "dev.kord:kord-core", version.ref = "kord" } + +# Caching +caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } # Testing dokka-api-test = { module = "org.jetbrains.dokka:dokka-test-api", version.ref = "dokka" } @@ -94,13 +106,13 @@ ktor-client = [ "ktor-client-logging", ] logger = [ - "logger-api", "logger-core", "logger-impl" ] database = [ "exposed", "exposed-dao", + "exposed-jdbc", "hikari", "postgres" ] diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 27313fb..fce403e 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/renderer/discord/build.gradle.kts b/renderer/discord/build.gradle.kts new file mode 100644 index 0000000..c72ae79 --- /dev/null +++ b/renderer/discord/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("docsly.base") +} + +dependencies { + api(projects.docslySerializable) +} diff --git a/renderer/discord/src/main/kotlin/DiscordDocumentRenderer.kt b/renderer/discord/src/main/kotlin/DiscordDocumentRenderer.kt new file mode 100644 index 0000000..351304d --- /dev/null +++ b/renderer/discord/src/main/kotlin/DiscordDocumentRenderer.kt @@ -0,0 +1,154 @@ +package dev.triumphteam.docsly.renderer + +import dev.triumphteam.docsly.elements.BasicType +import dev.triumphteam.docsly.elements.DocElement +import dev.triumphteam.docsly.elements.FunctionType +import dev.triumphteam.docsly.elements.GenericType +import dev.triumphteam.docsly.elements.LiteralAnnotationArgument +import dev.triumphteam.docsly.elements.Modifier +import dev.triumphteam.docsly.elements.SerializableAnnotation +import dev.triumphteam.docsly.elements.SerializableAnnotationArgument +import dev.triumphteam.docsly.elements.SerializableFunction +import dev.triumphteam.docsly.elements.SerializableParameter +import dev.triumphteam.docsly.elements.SerializableType + +public class DiscordDocumentRenderer { + + private companion object { + private const val NEW_LINE = "\n" + private const val SPACE = " " + + private const val DOCUMENTATION_PH = "{documentation}" + private const val ABOVE_ANNOTATIONS_PH = "{above_annotations}" + private const val VISIBILITY_PH = "{visibility}" + private const val MODIFIERS_PH = "{modifiers}" + private const val DOC_TYPE_PH = "{doc_type}" + private const val GENERICS_PH = "{generics}" + private const val RECEIVER_PH = "{receiver}" + private const val NAME_PH = "{name}" + private const val TYPE_GENERICS_PH = "{type_generics}" + private const val PARAMETERS_PH = "{parameters}" + private const val RETURN_PH = "{return}" + + private val FUNCTION_TEMPLATE = """ + $DOCUMENTATION_PH + $ABOVE_ANNOTATIONS_PH + $VISIBILITY_PH${MODIFIERS_PH}${DOC_TYPE_PH}$GENERICS_PH ${RECEIVER_PH}$NAME_PH( + $PARAMETERS_PH + )$RETURN_PH + """.trimIndent() + } + + public fun render(document: DocElement): String { + return buildString { + appendLine("```kt") + appendLine( + when (document) { + is SerializableFunction -> renderFunction(document) + + else -> TODO("NOT IMPLEMENTED YET") + } + ) + appendLine("```") + } + } + + private fun renderFunction(function: SerializableFunction): String { + return FUNCTION_TEMPLATE + .replace(ABOVE_ANNOTATIONS_PH, renderAnnotationsAbove(function.annotations)) + .replace(VISIBILITY_PH, function.visibility.name.lowercase()) + .replace( + MODIFIERS_PH, + renderModifiers(function.modifiers), + ) + .replace(DOC_TYPE_PH, "fun") + .replace(GENERICS_PH, " " + renderGenerics(function.generics)) + .replace(RECEIVER_PH, function.receiver?.let { "${renderType(it)}." } ?: "") + .replace(NAME_PH, function.name) + .replace(PARAMETERS_PH, function.parameters.joinToString(NEW_LINE) { " ${renderParameter(it)}," }) + .replace(RETURN_PH, function.type?.let { ": ${renderType(it)}" } ?: "") + } + + private fun renderModifiers(modifiers: Set): String { + return modifiers.sortedBy(Modifier::order) + .joinToString(" ", prefix = " ", postfix = " ") { it.displayName ?: it.name.lowercase() } + } + + private fun renderAnnotationsAbove(annotations: List): String { + return annotations.joinToString(NEW_LINE) { annotation -> + buildString { + append("@") + append(annotation.type) + + val arguments = annotation.arguments + if (arguments.isNotEmpty()) { + appendLine("(") + arguments.entries.forEach { (name, argument) -> + appendLine(" $name = ${renderAnnotationArgument(argument)},") + } + append(")") + } + } + } + } + + private fun renderParameter(parameter: SerializableParameter): String { + return "${parameter.name}: ${renderType(parameter.type)}" + } + + private fun renderGenerics(generics: List): String { + if (generics.isEmpty()) return "" + + return "<${ + generics.joinToString(", ") { + it.name // TODO + } + }>" + } + + private fun renderType(type: SerializableType): String { + return when (type) { + is BasicType -> buildString { + val name = type.name + if (name != null) append(name).append(SPACE) + + val projection = type.projection + if (projection != null) append(projection.kotlin).append(SPACE) // TODO: JAVA + + append(type.type) + + val parameters = type.parameters + if (parameters.isNotEmpty()) { + append("<") + append( + parameters.joinToString(", ") { + renderType(it) + } + ) + append(">") + } + } + + is FunctionType -> buildString { + val parameters = type.params + append("(") + if (parameters.isNotEmpty()) { + append(parameters.joinToString(", ") { renderType(it) }) + } + append(")") + append(" -> ") + append(type.returnType?.let { renderType(it) } ?: "Unit") // TODO: default not be unit? and java + } + + else -> "" // TODO + } + } + + private fun renderAnnotationArgument(argument: SerializableAnnotationArgument): String { + return when (argument) { + is LiteralAnnotationArgument -> "\"${argument.typeName}\"" + + else -> "" + } + } +} diff --git a/renderer/discord/src/main/kotlin/package-info.kt b/renderer/discord/src/main/kotlin/package-info.kt new file mode 100644 index 0000000..da4c0c5 --- /dev/null +++ b/renderer/discord/src/main/kotlin/package-info.kt @@ -0,0 +1,24 @@ +/** + * MIT License + * + * Copyright (c) 2019-2022 TriumphTeam and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +package dev.triumphteam.docsly.renderer diff --git a/serializable/src/main/kotlin/elements/Serializable.kt b/serializable/src/main/kotlin/elements/Serializable.kt index cb61817..e236dfd 100644 --- a/serializable/src/main/kotlin/elements/Serializable.kt +++ b/serializable/src/main/kotlin/elements/Serializable.kt @@ -80,7 +80,7 @@ public enum class Visibility { PACKAGE; // Java only public companion object { - private val MAPPED_VALUES = values().associateBy { it.name.lowercase() } + private val MAPPED_VALUES = entries.associateBy { it.name.lowercase() } /** If the type is empty it'll be Java's [PACKAGE], else we take it from the mapped values. */ public fun fromString(name: String): Visibility? = if (name.isEmpty()) PACKAGE else MAPPED_VALUES[name] @@ -89,18 +89,41 @@ public enum class Visibility { /** Shout out to non-sealed for being the odd one out and needing [displayName], thanks Java. */ @Serializable -public enum class Modifier(public val displayName: String? = null) { +public enum class Modifier(public val order: Int, public val displayName: String? = null) { + // COMMON + + OPEN(0), FINAL(0), ABSTRACT(0), SEALED(0), + // KOTLIN ONLY - INLINE, VALUE, INFIX, EXTERNAL, SUSPEND, REIFIED, CROSSINLINE, NOINLINE, OVERRIDE, DATA, CONST, INNER, LATEINIT, OPERATOR, TAILREC, VARARG, + + REIFIED(0), CROSSINLINE(0), NOINLINE(0), + + CONST(0), + EXTERNAL(1), + OVERRIDE(2), + LATEINIT(3), + TAILREC(4), + VARARG(5), + SUSPEND(6), + INNER(7), + + FUN(8), ENUM(8), ANNOTATION(8), + + COMPANION(9), + INLINE(10), VALUE(10), + INFIX(11), + DATA(13), OPERATOR(12), // JAVA ONLY - STATIC, NATIVE, SYNCHRONIZED, STRICTFP, TRANSIENT, VOLATILE, TRANSITIVE, RECORD, NONSEALED("non-sealed"), DEFAULT, - // COMMON - OPEN, FINAL, ABSTRACT, SEALED; + STATIC(0), NATIVE(0), SYNCHRONIZED(0), STRICTFP(0), + TRANSIENT(0), VOLATILE(0), TRANSITIVE(0), RECORD(0), + NONSEALED(0, "non-sealed"), DEFAULT(0) + + ; public companion object { - private val MAPPED_VALUES = values().associateBy { it.displayName ?: it.name.lowercase() } + private val MAPPED_VALUES = entries.associateBy { it.displayName ?: it.name.lowercase() } public fun fromString(name: String): Modifier? = MAPPED_VALUES[name] } diff --git a/settings.gradle.kts b/settings.gradle.kts index 157113f..9b1154f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.internal.impldep.org.bouncycastle.asn1.x500.style.RFC4519Style.name + dependencyResolutionManagement { includeBuild("build-logic") repositories.gradlePluginPortal() @@ -22,16 +24,20 @@ listOf( "common", "app", - "discord" + "discord", + + "renderer/discord" ).forEach { includeProject(it) } include("test-module") -fun includeProject(name: String) { +fun includeProject(path: String) { + val name = path.replace("/", "-") include(name) { this.name = "${rootProject.name}-$name" + this.projectDir = file(path) } }