Feature/streamline settings (#1614)

* Cleanup graphql setting mutation

* Validate values read from config

* Generate server-reference.conf files from ServerConfig

* Remove unnecessary enum value handling in config value update

Commit df0078b725 introduced the usage of config4k, which handles enums automatically. Thus, this handling is outdated and not needed anymore

* Generate gql SettingsType from ServerConfig

* Extract settings backup logic

* Generate settings backup files

* Move "group" arg to second position

To make it easier to detect and have it at the same position consistently for all settings.

* Remove setting generation from compilation

* Extract setting generation code into new module

* Extract pure setting generation code into new module

* Remove generated settings files from src tree

* Force each setting to set a default value
This commit is contained in:
schroda
2025-09-01 23:02:58 +02:00
committed by GitHub
parent 11b2a6b616
commit 8ef2877040
48 changed files with 2443 additions and 1330 deletions

View File

@@ -0,0 +1,62 @@
plugins {
id(
libs.plugins.kotlin.jvm
.get()
.pluginId,
)
}
dependencies {
// Core Kotlin
implementation(kotlin("stdlib-jdk8"))
implementation(kotlin("reflect"))
// Config handling
implementation(libs.config)
implementation(libs.config4k)
// Logging
implementation(libs.slf4japi)
implementation(libs.kotlinlogging)
// Serialization
implementation(libs.serialization.json)
implementation(libs.serialization.protobuf)
// Depend on server-config module for access to ServerConfig and SettingsRegistry
implementation(projects.server.serverConfig)
}
tasks {
register<JavaExec>("generateSettings") {
group = "build setup"
description = "Generates settings from ServerConfig"
dependsOn(compileKotlin)
// Use this module's classpath which includes server-config as dependency
classpath = sourceSets.main.get().runtimeClasspath
mainClass.set("suwayomi.tachidesk.server.settings.generation.SettingsGeneratorKt")
// Get reference to server project for file paths
val serverProject = project(":server")
// Set working directory to the server module directory
workingDir = serverProject.projectDir
inputs.files(
serverProject.sourceSets.main.get().allSource.filter {
it.name.contains("ServerConfig") || it.name.contains("Settings")
},
)
outputs.files(
serverProject.file("build/generated/src/main/resources/server-reference.conf"),
serverProject.file("build/generated/src/test/resources/server-reference.conf"),
serverProject.file("build/generated/src/main/kotlin/suwayomi/tachidesk/graphql/types/SettingsType.kt"),
serverProject.file("build/generated/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupServerSettings.kt"),
serverProject.file("build/generated/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/handlers/BackupSettingsHandler.kt"),
)
}
}

View File

@@ -0,0 +1,36 @@
@file:JvmName("SettingsGeneratorKt")
package suwayomi.tachidesk.server.settings.generation
import java.io.File
/**
* Main function to generate settings files from ServerConfig
* This is called by the generateSettingsFiles Gradle task
*/
fun main() {
println("Generating settings files from ServerConfig registry...")
try {
// Set output directories relative to the current working directory (server module)
val outputDir = File("build/generated/src/main/resources")
val testOutputDir = File("build/generated/src/test/resources")
val graphqlOutputDir = File("build/generated/src/main/kotlin/suwayomi/tachidesk/graphql/types")
val backupSettingsOutputDir = File("build/generated/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models")
val backupSettingsHandlerOutputDir = File("build/generated/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/handlers")
SettingsGenerator.generate(
outputDir = outputDir,
testOutputDir = testOutputDir,
graphqlOutputDir = graphqlOutputDir,
backupSettingsOutputDir = backupSettingsOutputDir,
backupSettingsHandlerOutputDir = backupSettingsHandlerOutputDir,
)
println("✅ Settings files generation completed successfully!")
} catch (e: Exception) {
println("❌ Error generating settings files: ${e.message}")
e.printStackTrace()
System.exit(1)
}
}

View File

@@ -0,0 +1,37 @@
package suwayomi.tachidesk.server.settings.generation
import suwayomi.tachidesk.server.settings.SettingsRegistry
internal fun String.addIndentation(times: Int): String = this.prependIndent(" ".repeat(times))
object KotlinFileGeneratorHelper {
fun createFileHeader(packageName: String): String =
buildString {
appendLine("@file:Suppress(\"ktlint\")")
appendLine()
appendLine("/*")
appendLine(" * Copyright (C) Contributors to the Suwayomi project")
appendLine(" *")
appendLine(" * This Source Code Form is subject to the terms of the Mozilla Public")
appendLine(" * License, v. 2.0. If a copy of the MPL was not distributed with this")
appendLine(" * file, You can obtain one at https://mozilla.org/MPL/2.0/. */")
appendLine()
appendLine("package $packageName")
appendLine()
}
fun createImports(
staticImports: List<String>,
settings: List<SettingsRegistry.SettingMetadata>,
): String =
buildString {
staticImports.forEach { appendLine("import $it") }
settings
.mapNotNull { it.typeInfo.imports }
.flatten()
.distinct()
.forEach { appendLine("import $it") }
appendLine()
}
}

View File

@@ -0,0 +1,84 @@
package suwayomi.tachidesk.server.settings.generation
import suwayomi.tachidesk.server.settings.SettingsRegistry
import java.io.File
import kotlin.text.appendLine
object SettingsBackupServerSettingsGenerator {
fun generate(
settings: Map<String, SettingsRegistry.SettingMetadata>,
outputFile: File,
) {
outputFile.parentFile.mkdirs()
val settingsToInclude = settings.values
if (settingsToInclude.isEmpty()) {
println("Warning: No settings found to create BackupServerSettings from.")
return
}
val sortedSettings = settingsToInclude.sortedBy { it.protoNumber }
outputFile.writeText(
buildString {
appendLine(KotlinFileGeneratorHelper.createFileHeader("suwayomi.tachidesk.manga.impl.backup.proto.models"))
writeImports(sortedSettings)
writeClass(sortedSettings)
},
)
println("BackupServerSettingsGenerator generated successfully! Total settings: ${settingsToInclude.size}")
}
private fun StringBuilder.writeImports(settings: List<SettingsRegistry.SettingMetadata>) {
appendLine(
KotlinFileGeneratorHelper.createImports(
listOf(
"kotlinx.serialization.Serializable",
"kotlinx.serialization.protobuf.ProtoNumber",
"suwayomi.tachidesk.graphql.types.Settings",
),
settings,
),
)
}
private fun StringBuilder.writeClass(sortedSettings: List<SettingsRegistry.SettingMetadata>) {
appendLine("@Serializable")
appendLine("data class BackupServerSettings(")
writeSettings(sortedSettings, indentation = 4)
appendLine(") : Settings")
appendLine()
}
private fun StringBuilder.writeSettings(
sortedSettings: List<SettingsRegistry.SettingMetadata>,
indentation: Int,
) {
sortedSettings.forEach { setting ->
val deprecated = setting.deprecated
if (deprecated != null) {
val replaceWithSuffix = deprecated.replaceWith?.let { ", ReplaceWith(\"$it\")" } ?: ""
appendLine(
"@Deprecated(\"${deprecated.message}\"$replaceWithSuffix)".addIndentation(
indentation,
),
)
}
appendLine(
"@ProtoNumber(${setting.protoNumber}) override var ${setting.name}: ${getSettingType(setting)},"
.addIndentation(indentation),
)
}
}
private fun getSettingType(setting: SettingsRegistry.SettingMetadata): String =
setting.typeInfo.backupType
?: setting.typeInfo.specificType
?: setting.typeInfo.type.simpleName
?: throw RuntimeException("Unknown setting type: ${setting.typeInfo}")
}

View File

@@ -0,0 +1,137 @@
package suwayomi.tachidesk.server.settings.generation
import suwayomi.tachidesk.server.settings.SettingsRegistry
import java.io.File
import kotlin.text.appendLine
object SettingsBackupSettingsHandlerGenerator {
fun generate(
settings: Map<String, SettingsRegistry.SettingMetadata>,
outputFile: File,
) {
outputFile.parentFile.mkdirs()
val settingsToInclude = settings.values
if (settingsToInclude.isEmpty()) {
println("Warning: No settings found to create BackupServerSettings from.")
return
}
val groupedSettings = settingsToInclude.groupBy { it.group }
outputFile.writeText(
buildString {
appendLine(KotlinFileGeneratorHelper.createFileHeader("suwayomi.tachidesk.manga.impl.backup.proto.handlers"))
writeImports(groupedSettings.values.flatten())
writeHandler(groupedSettings)
},
)
println("BackupServerSettings generated successfully! Total settings: ${settingsToInclude.size}")
}
private fun StringBuilder.writeImports(settings: List<SettingsRegistry.SettingMetadata>) {
appendLine(
KotlinFileGeneratorHelper.createImports(
listOf(
"suwayomi.tachidesk.graphql.mutations.SettingsMutation",
"suwayomi.tachidesk.manga.impl.backup.BackupFlags",
"suwayomi.tachidesk.manga.impl.backup.proto.models.BackupServerSettings",
"suwayomi.tachidesk.server.serverConfig",
"suwayomi.tachidesk.server.settings.SettingsRegistry",
),
settings,
),
)
}
private fun StringBuilder.writeHandler(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
appendLine("object BackupSettingsHandler {")
writeBackupFunction(groupedSettings)
appendLine()
writeRestoreFunction(groupedSettings.values.flatten())
appendLine("}")
appendLine()
}
private fun StringBuilder.writeBackupFunction(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
val indentation = 4
val contentIndentation = indentation * 2
appendLine("fun backup(flags: BackupFlags): BackupServerSettings? {".addIndentation(indentation))
appendLine("if (!flags.includeServerSettings) { return null }".addIndentation(contentIndentation))
appendLine()
appendLine("return BackupServerSettings(".addIndentation(contentIndentation))
writeSettings(groupedSettings, indentation * 3)
appendLine(")".addIndentation(contentIndentation))
appendLine("}".addIndentation(indentation))
}
private fun StringBuilder.writeRestoreFunction(settings: List<SettingsRegistry.SettingMetadata>) {
val indentation = 4
val contentIndentation = indentation * 2
appendLine("fun restore(backupServerSettings: BackupServerSettings?) {".addIndentation(indentation))
appendLine("if (backupServerSettings == null) { return }".addIndentation(contentIndentation))
appendLine()
appendLine("SettingsMutation().updateSettings(".addIndentation(contentIndentation))
appendLine("backupServerSettings.copy(".addIndentation(indentation * 3))
val deprecatedSettings = settings.filter { it.typeInfo.restoreLegacy != null }
deprecatedSettings.forEach { setting ->
appendLine(
"${setting.name} = SettingsRegistry.get(\"${setting.name}\")!!.typeInfo.restoreLegacy!!(".addIndentation(indentation * 4) +
"backupServerSettings.${setting.name}" +
") as ${getSettingType(setting, false)},",
)
}
appendLine("),".addIndentation(indentation * 3))
appendLine(")".addIndentation(contentIndentation))
appendLine("}".addIndentation(indentation))
}
private fun StringBuilder.writeSettings(
groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>,
indentation: Int,
) {
groupedSettings.forEach { (group, settings) ->
appendLine("// $group".addIndentation(indentation))
settings.forEach { setting -> writeSetting(setting, indentation) }
}
}
private fun StringBuilder.writeSetting(
setting: SettingsRegistry.SettingMetadata,
indentation: Int,
) {
appendLine("${setting.name} = ${getConfigAccess(setting)},".addIndentation(indentation))
}
private fun getSettingType(
setting: SettingsRegistry.SettingMetadata,
asBackup: Boolean,
): String {
val possibleType = setting.typeInfo.specificType ?: setting.typeInfo.type.simpleName
val exception = RuntimeException("Unknown setting type: ${setting.typeInfo}")
if (asBackup) {
return setting.typeInfo.backupType ?: possibleType ?: throw exception
}
return possibleType ?: throw exception
}
private fun getConfigAccess(setting: SettingsRegistry.SettingMetadata): String {
if (setting.typeInfo.convertToBackupType != null) {
return "SettingsRegistry.get(\"${setting.name}\")!!.typeInfo.convertToBackupType!!(" +
"serverConfig.${setting.name}.value" +
") as ${getSettingType(setting, true)}"
}
return "serverConfig.${setting.name}.value"
}
}

View File

@@ -0,0 +1,109 @@
package suwayomi.tachidesk.server.settings.generation
import com.typesafe.config.ConfigRenderOptions
import io.github.config4k.toConfig
import suwayomi.tachidesk.server.settings.SettingsRegistry
import java.io.File
object SettingsConfigFileGenerator {
private const val SERVER_PREFIX = "server."
fun generate(
outputDir: File,
testOutputDir: File,
settings: Map<String, SettingsRegistry.SettingMetadata>,
) {
// Config files only include up-to-date settings.
val settingsToInclude = settings.filterValues { it.deprecated == null }
if (settingsToInclude.isEmpty()) {
println("Warning: No settings found to write to config files.")
return
}
generateServerReferenceConf(settingsToInclude, outputDir)
generateServerReferenceConf(settingsToInclude, testOutputDir)
println("Settings config file generated successfully! Total settings: ${settingsToInclude.size}")
println("- Main config: ${outputDir.resolve("server-reference.conf").absolutePath}")
println("- Test config: ${testOutputDir.resolve("server-reference.conf").absolutePath}")
}
private fun generateServerReferenceConf(
settings: Map<String, SettingsRegistry.SettingMetadata>,
outputDir: File,
) {
outputDir.mkdirs()
val outputFile = outputDir.resolve("server-reference.conf")
val groupedSettings = settings.values.groupBy { it.group }
// Write the config with comments
outputFile.writeText(
buildString {
writeSettings(groupedSettings)
},
)
}
private fun StringBuilder.writeSettings(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
val renderOptions =
ConfigRenderOptions
.defaults()
.setOriginComments(false)
.setComments(false)
.setFormatted(true)
.setJson(false)
var isFirstGroup = true
groupedSettings.forEach { (groupName, groupSettings) ->
// Prevent empty line at start of the file
if (!isFirstGroup) {
appendLine()
}
isFirstGroup = false
appendLine("# $groupName")
groupSettings.forEach { setting ->
writeSetting(setting, renderOptions)
}
}
}
private fun StringBuilder.writeSetting(
setting: SettingsRegistry.SettingMetadata,
renderOptions: ConfigRenderOptions,
) {
val key = "$SERVER_PREFIX${setting.name}"
val configValue = setting.defaultValue.toConfig("internal").getValue("internal")
var renderedValue = configValue.render(renderOptions)
// Force quotes on all string values for consistency
// Check if it's a string value that's not already quoted
if (setting.defaultValue is String && !renderedValue.startsWith("\"")) {
renderedValue = "\"$renderedValue\""
}
val settingString = "$key = $renderedValue"
val description = setting.description
if (description != null) {
val descriptionLines = description.split("\n")
if (descriptionLines.isEmpty()) {
return
}
appendLine("$settingString # ${descriptionLines[0]}")
descriptionLines.drop(1).forEach { line ->
appendLine("# $line")
}
return
}
appendLine(settingString)
}
}

View File

@@ -0,0 +1,82 @@
package suwayomi.tachidesk.server.settings.generation
import com.typesafe.config.ConfigFactory
import suwayomi.tachidesk.server.ServerConfig
import suwayomi.tachidesk.server.settings.SettingsRegistry
import suwayomi.tachidesk.server.util.ConfigTypeRegistration
import java.io.File
import kotlin.reflect.KProperty1
import kotlin.reflect.full.memberProperties
/**
* Utility to generate settings files from ServerConfig and SettingsRegistry
* This can be run as a standalone main function to generate all required files
*/
object SettingsGenerator {
init {
// Register custom types for config serialization
ConfigTypeRegistration.registerCustomTypes()
triggerSettingRegistration()
}
/**
* Force registration of all settings without full ServerConfig instantiation
*/
private fun triggerSettingRegistration() {
// This creates a minimal instance just to trigger delegate registration
try {
val mockConfig =
ConfigFactory.parseString(
"""
server {
ip = "0.0.0.0"
port = 4567
}
""".trimIndent(),
)
val tempConfig = ServerConfig { mockConfig.getConfig("server") }
// Access all properties to trigger delegate registrations
tempConfig::class.memberProperties.forEach { prop ->
try {
@Suppress("UNCHECKED_CAST")
(prop as KProperty1<ServerConfig, Any?>).get(tempConfig)
} catch (e: Exception) {
// Ignore errors during registration
}
}
} catch (e: Exception) {
// Registration failed, but we tried
}
}
fun generate(
outputDir: File,
testOutputDir: File,
graphqlOutputDir: File,
backupSettingsOutputDir: File,
backupSettingsHandlerOutputDir: File,
) {
val settings = SettingsRegistry.getAll()
if (settings.isEmpty()) {
println("Warning: No settings found in registry. Settings might not be initialized.")
return
}
println(" - Total: ${settings.size}")
println(" - Deprecated: ${settings.values.count { it.deprecated != null }}")
println(" - Require restart: ${settings.values.count { it.requiresRestart }}")
SettingsConfigFileGenerator.generate(outputDir, testOutputDir, settings)
val settingsTypeFile = graphqlOutputDir.resolve("SettingsType.kt")
SettingsGraphqlTypeGenerator.generate(settings, settingsTypeFile)
val backupServerSettingsFile = backupSettingsOutputDir.resolve("BackupServerSettings.kt")
SettingsBackupServerSettingsGenerator.generate(settings, backupServerSettingsFile)
val backupSettingsHandlerFile = backupSettingsHandlerOutputDir.resolve("BackupSettingsHandler.kt")
SettingsBackupSettingsHandlerGenerator.generate(settings, backupSettingsHandlerFile)
}
}

View File

@@ -0,0 +1,173 @@
package suwayomi.tachidesk.server.settings.generation
import suwayomi.tachidesk.server.settings.SettingsRegistry
import java.io.File
import kotlin.text.appendLine
object SettingsGraphqlTypeGenerator {
fun generate(
settings: Map<String, SettingsRegistry.SettingMetadata>,
outputFile: File,
) {
outputFile.parentFile.mkdirs()
val settingsToInclude = settings.values
if (settingsToInclude.isEmpty()) {
println("Warning: No settings found to create graphql type from.")
return
}
val groupedSettings = settingsToInclude.groupBy { it.group }
outputFile.writeText(
buildString {
appendLine(KotlinFileGeneratorHelper.createFileHeader("suwayomi.tachidesk.graphql.types"))
writeImports(groupedSettings.values.flatten())
writeSettingsInterface(groupedSettings)
writePartialSettingsType(groupedSettings)
writeSettingsType(groupedSettings)
},
)
println("Graphql type generated successfully! Total settings: ${settingsToInclude.size}")
}
private fun StringBuilder.writeImports(settings: List<SettingsRegistry.SettingMetadata>) {
appendLine(
KotlinFileGeneratorHelper.createImports(
listOf(
"com.expediagroup.graphql.generator.annotations.GraphQLDeprecated",
"com.expediagroup.graphql.generator.annotations.GraphQLIgnore",
"suwayomi.tachidesk.graphql.server.primitives.Node",
"suwayomi.tachidesk.server.ServerConfig",
"suwayomi.tachidesk.server.serverConfig",
"suwayomi.tachidesk.server.settings.SettingsRegistry",
),
settings,
),
)
}
private fun StringBuilder.writeSettingsInterface(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
appendLine("interface Settings : Node {")
writeSettings(groupedSettings, indentation = 4, asType = true, isOverride = false, isNullable = true, isInterface = true)
appendLine("}")
appendLine()
}
private fun StringBuilder.writePartialSettingsType(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
appendLine("data class PartialSettingsType(")
writeSettings(groupedSettings, indentation = 4, asType = true, isOverride = true, isNullable = true, isInterface = false)
appendLine(") : Settings")
appendLine()
}
private fun StringBuilder.writeSettingsType(groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>) {
appendLine("class SettingsType(")
writeSettings(groupedSettings, indentation = 4, asType = true, isOverride = true, isNullable = false, isInterface = false)
appendLine(") : Settings {")
// Write secondary constructor
val indentation = 4
appendLine("@Suppress(\"UNCHECKED_CAST\")".addIndentation(indentation))
appendLine("constructor(config: ServerConfig = serverConfig) : this(".addIndentation(indentation))
writeSettings(
groupedSettings,
indentation = indentation * 2,
asType = false,
isOverride = false,
isNullable = false,
isInterface = false,
)
appendLine(")".addIndentation(indentation))
appendLine("}")
appendLine()
}
private fun StringBuilder.writeSettings(
groupedSettings: Map<String, List<SettingsRegistry.SettingMetadata>>,
indentation: Int,
asType: Boolean,
isOverride: Boolean,
isNullable: Boolean,
isInterface: Boolean,
) {
groupedSettings.forEach { (group, settings) ->
appendLine("// $group".addIndentation(indentation))
settings.forEach { setting -> writeSetting(setting, indentation, asType, isOverride, isNullable, isInterface) }
}
}
private fun StringBuilder.writeSetting(
setting: SettingsRegistry.SettingMetadata,
indentation: Int,
asType: Boolean,
isOverride: Boolean,
isNullable: Boolean,
isInterface: Boolean,
) {
if (!asType) {
appendLine("${getConfigAccess(setting)},".addIndentation(indentation))
return
}
if (setting.requiresRestart) {
appendLine("@GraphQLIgnore".addIndentation(indentation))
}
val deprecated = setting.deprecated
if (deprecated != null) {
val replaceWithSuffix = deprecated.replaceWith?.let { ", ReplaceWith(\"$it\")" } ?: ""
appendLine(
"@GraphQLDeprecated(\"${deprecated.message}\"$replaceWithSuffix)".addIndentation(
indentation,
),
)
}
val overridePrefix = if (isOverride) "override " else ""
val nullableSuffix = if (isNullable) "?" else ""
val commaSuffix = if (isOverride) "," else ""
appendLine(
"${overridePrefix}val ${setting.name}: ${getGraphQLType(
setting,
isInterface,
)}$nullableSuffix$commaSuffix".addIndentation(indentation),
)
}
private fun getGraphQLType(
setting: SettingsRegistry.SettingMetadata,
isInterface: Boolean,
): String {
val possibleType = setting.typeInfo.specificType ?: setting.typeInfo.type.simpleName
val exception = RuntimeException("Unknown setting type: ${setting.typeInfo}")
if (isInterface) {
return setting.typeInfo.interfaceType ?: possibleType ?: throw exception
}
return possibleType ?: throw exception
}
private fun getConfigAccess(setting: SettingsRegistry.SettingMetadata): String {
if (setting.typeInfo.convertToGqlType != null) {
return "SettingsRegistry.get(\"${setting.name}\")!!.typeInfo.convertToGqlType!!(" +
"config.${setting.name}.value" +
") as ${getGraphQLType(setting, false)}"
}
return "config.${setting.name}.value"
}
}