code:
package life.xchange

import life.xchange.model.mod.*
import life.xchange.passage.*
import life.xchange.compatibility.CircularLoadOrderException
import life.xchange.db.UnpackedMod
import life.xchange.db.UnpackedMods
import life.xchange.compatibility.Checker
import life.xchange.util.*
import org.intellij.lang.annotations.Language
import org.jetbrains.exposed.dao.with
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
import java.util.*
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import kotlin.io.path.*

val String.scriptElement: ScriptElement?
  get() =
    """(<script(?:\s+\S*?\s*?)*?>).*?</script>""".toRegex(RegexOption.DOT_MATCHES_ALL)
      .findAll(this)
      .firstOrNull { it.groups[1]?.value?.contains("""id="twine-user-script"""") ?: false }
      ?.let { ScriptElement(it.value, it.groupValues[1]) }
val String.htmlPassages: Map<PassageName, HTMLPassage>
  get() =
    """(<${HTMLPassage.tagName}(?:\s+\S*?\s*?)*?>).*?${HTMLPassage.endTag}""".toRegex(RegexOption.DOT_MATCHES_ALL)
      .findAll(this)
      .associate { result -> HTMLPassage(result.value, result.groupValues[1]).let { it.name to it } }

val String.tweePassages: Collection<TweePassage>
  get() {
    val passages = mutableSetOf<TweePassage>()
    var currentPassage: TweePassage? = null
    lines().forEach { line ->
      if (line.startsWith(":: ")) {
        val (name, tags, metadata) = """^:: (.*?)\s*(?:\[(.*)])?\s*(?:\{(.*)})?\s*$""".toRegex()
          .find(line)?.destructured ?: error("Malformed twee header")
        currentPassage = TweePassage(PassageName(name), tags, metadata).also { passages += it }
      } else {
        val passage = currentPassage ?: return@forEach
        passage.contents += htmlEscape(line) + "\n"
      }
    }
    return passages.onEach { it.contents = it.contents.trim() }
  }

fun htmlEscape(string: String): String = buildString {
  string.forEach {
    append(when (it) {
      '&'  -> "&"
      '<'  -> "<"
      '>'  -> ">"
      '"'  -> """
      '\'' -> "'"
      else -> it
    })
  }
}

fun cleanupOldStuff() {
  if (appliedDir.exists()) {
    appliedDir.listDirectoryEntries().forEach {
      it.charSetSafeRead().lineSequence().forEach { addedFile ->
        rootDir.resolve(addedFile).deleteIfExists()
      }
      it.deleteIfExists()
    }

    carefully(appliedDir::deleteExisting)
  }

  rootDir.resolve(htmlFile.nameWithoutExtension + " Mod.html").deleteIfExists()

  if (backupZip.exists()) {
    backupZip.extractZipTo(rootDir)
    backupZip.deleteIfExists()
  }
}

fun applyPatchFile(inputFile: Path, file: File, log: (String) -> Unit = ::println) {
  log("Applying patch ${file.name}")

  val passages = inputFile.charSetSafeRead().htmlPassages
  val versionPassageName = PassageName("Game Version")

  val versionPassage = passages[versionPassageName] ?: run {
    log("Version passage not found, aborting")
    return
  }
  val inputVersion = """(?<=Version )\S*""".toRegex().find(versionPassage.contents)?.value ?: run {
    log("Version not found in version passage, aborting")
    return
  }

  ZipFile(file).use { zipFile ->
    val versionEntry = zipFile.getEntry(".versions") ?: run {
      log("Malformed patch: version file not found")
      return
    }
    val versions = zipFile.charSetSafeRead(versionEntry).trim()

    val (fromVersion) = versions.split(" -> ")

    if (fromVersion != inputVersion) {
      log("Incorrect version")
      log("  ${file.name} patches $versions")
      log("  ${inputFile.name} is version $inputVersion")
      return
    }

    applyZipFile(zipFile = zipFile, excludedExtensions = mapOf("versions" to versions), log = log)
    log("")
  }

  ModProcessor(log).loadMods(inputFile, moddedDir, Settings.copyInsteadOfLink)
}

fun applyZipFile(
  zipFile: ZipFile,
  excludedExtensions: Map<String, String> = mapOf(),
  log: (String) -> Unit = ::println
) {
  for (entry in zipFile.entries().toList().filterNot(ZipEntry::isDirectory).sortedBy(ZipEntry::getName)) {
    try {
      log("  " + entry.name)
      val entryPath = rootDir.resolve(entry.name)
      val excludedMessage = excludedExtensions.firstNotNullOfOrNull { (key, value) ->
        value.takeIf { key.equals(entryPath.extension, ignoreCase = true) }
      }
      when {
        excludedMessage != null      -> log("    $excludedMessage")
        else                         -> {
          val dir = entryPath.parent
          dir.createDirectories()

          zipFile.copyEntryToPath(entry, entryPath)
        }
      }
    } catch (e: Exception) {
      log("    ${e::class.simpleName}: ${e.message}")
    }
  }
}

infix fun Path.samePathAs(other: Path): Boolean = toAbsolutePath().normalize() == other.toAbsolutePath().normalize()

fun findBaseGameVersion(baseGamePassages: Map<PassageName, HTMLPassage>): String? {
  val contents = baseGamePassages[PassageName("Game Version")]?.contents?.lines() ?: return null
  return contents.firstNotNullOfOrNull { "^Version (.*?)$".toRegex().find(it) }?.groupValues?.get(1)
}

@OptIn(ExperimentalPathApi::class)
class ModProcessor(val log: (String) -> Unit = ::println) {
  private lateinit var gameText: String
  private val passages = mutableMapOf<PassageName, HTMLPassage>()
  val passagesView get() = passages.toMap()
  private val aroundedPassages: MutableMap<PassageName, PassageName> = mutableMapOf()
  private val passageMods: MutableMap<PassageName, MutableSet<String>> = mutableMapOf()

  private fun debug(text: String) {
    if (Settings.debugLoadProcess) {
      log("DEBUG: $text")
    }
  }

  companion object {
    private fun validateExists(path: Path, log: (String) -> Unit = ::println) {
      if (!path.exists()) {
        val error = "${path.name} not found"
        log("ERROR: $error")
        error(error)
      }
    }

    fun validateMods(inputPath: Path, log: (String) -> Unit = ::println): Map<String, List<String>> {
      log("Validating\n")

      validateExists(inputPath, log)

      val (errors, loadOrder) = transaction {
        val checker = Checker(inputPath)
        checker.checkModCompatibility() to updateLoadOrders(mods)
      }

      log("All done!")

      return loadOrder.associate { it.name to errors.getOrElse(it.name, ::listOf) }
    }

    private val mods: List<UnpackedMod>
      get() = transaction { UnpackedMod.find { UnpackedMods.enabled eq true }.sorted().with(UnpackedMod::files) }

    @Throws(CircularLoadOrderException::class)
    fun updateLoadOrders(modList: Iterable<UnpackedMod> = mods): List<UnpackedMod> = transaction {
      UnpackedMods.update({ UnpackedMods.enabled eq false }) { it[loadOrder] = null }

      Checker.topologicalSort(modList.filterNot(UnpackedMod::isBaseGame)).onEachIndexed { index, mod ->
        mod.loadOrder = index
      }
    }
  }

  fun loadMods(inputHtmlFile: Path, outputDirectory: Path, copyFiles: Boolean = false): Map<String, List<String>> {
    if (inputHtmlFile.parent samePathAs outputDirectory) error("Output directory must not contain input file")

    fun indent(text: String, indent: Int = 0) = log(" ".repeat(2 * indent) + text)
    val fileMessageAction = if (Settings.showFileOperations) log else ::debug

    log("Modding ${inputHtmlFile.name}")

    validateExists(inputHtmlFile, log)
    if (Settings.cleanModdedOnLoad) {
      fileMessageAction("Deleting existing modded directory")
      outputDirectory.createDirectories()
      outputDirectory.deleteRecursively()
    }
    outputDirectory.createDirectories()

    val modsPassageName = PassageName("__mods__")
    val modsEnabledPassageName = PassageName("__modsenabled__")
    val modsButtonPassageName = PassageName("__modbutton__")
    val modsVariablePassageName = PassageName("__modsvariable__")
    debug("Reading html file")
    initializeHtml(inputHtmlFile.charSetSafeRead())
    debug("Getting base game version")
    val baseGameVersion = baseGame.version?.toString()

    log("\nLoading and validating mod metadata")
    val errors = Checker.checkModCompatibility(baseGameVersion)

    var currentMod: String
    val fileMods = mutableMapOf<Path, MutableSet<String>>()
    val scripts = mutableSetOf<String>()

    val fileMap = mutableMapOf<String, FileInfo>()

    log("\nApplying enabled mods")
    debug("Opening transaction")
    val modLoadOrder = transaction {
      debug("Getting mods in load order")
      updateLoadOrders().with(UnpackedMod::files).onEach { mod ->
        indent(mod.name, 1)
        currentMod = mod.name
        val modDir = mod.baseDirectory
        mod.walkFiles { inFile ->
          val path = inFile.relativeTo(modDir).normalize()

          val type = ModFileType.from(inFile)
          when {
            type == ModFileType.TWEE -> processTweeFile(currentMod, inFile.charSetSafeRead())
            type == ModFileType.META -> {}
            !mod.isBaseGame && type == ModFileType.JS -> {
              if (Settings.showFiles) indent("$path", 2)

              scripts.add("/* ${mod.name} - $path */\n" + inFile.charSetSafeRead())
            }
            else -> {
              fileMap[path.toString()] = inFile.info()

              val messages = mutableListOf<String>()
              val outFile = outputDirectory.resolve(path)
              val overwriting = outFile.exists()

              when {
                overwriting -> {
                  val overwritten = fileMods.getOrPut(outFile) { mutableSetOf(baseGame.name) }
                  if (Settings.showOverwritten) {
                    messages.add("Overwritten:")
                    overwritten.forEach { name ->
                      messages.add("  $name")
                    }
                  }
                  overwritten.add(currentMod)
                }

                else -> {
                  fileMods[outFile] = mutableSetOf(currentMod)
                  if (Settings.showFiles) messages.add("New file")
                }
              }

              if (messages.isNotEmpty()) {
                indent("$path", 2)
                messages.forEach { indent(it, 3) }
              }
            }
          }
        }
      }
        .also { debug("Done with mods, closing transaction") }
    }

    log("")

    outputDirectory.walk().forEach {
      val relative = it.relativeTo(outputDirectory)
      if (!relative.toString().matches(inputHtmlFile.name.replace(".", "(\\.\\d)?\\.").toRegex()) && !fileMap.containsKey(relative.toString())) {
        fileMessageAction("Deleting $it")
        it.deleteExisting()
      }
    }

    fileMap.forEach { (relativePath, info) ->
      val outputPath = outputDirectory.resolve(relativePath)
      if (!outputPath.exists() || info.fileSize != outputPath.fileSize()) {
        val sourceFile = Paths.get(info.path)
        outputPath.deleteIfExists()
        outputPath.createParentDirectories()
        if (copyFiles) {
          fileMessageAction("Copying $sourceFile to $outputPath")
          sourceFile.copyTo(outputPath, true)
          outputPath.setLastModifiedTime(sourceFile.getLastModifiedTime())
        } else {
          fileMessageAction("Linking $outputPath to $sourceFile")
          outputPath.softOrHardLink(sourceFile)
        }
      }
    }

    passages[modsPassageName]?.let { modsPassage ->
      debug("Building mods passage")
      val newContents = when {
        modLoadOrder.isEmpty() -> ""
        else -> modLoadOrder.joinToString("\n") { it.name }
      }
      passages[modsPassageName] = modsPassage.withContent(newContents)
    }

    passages[modsEnabledPassageName]?.let { modsEnabledPassage ->
      debug("Building mods enabled passage")
      val newContents = "(MODDED)"
      passages[modsEnabledPassageName] = modsEnabledPassage.withContent(newContents)
    }

    passages[modsButtonPassageName]?.let { modsButtonPassage ->
      debug("Building mods button passage")
      val newContents = """(display:"mod list")"""
      passages[modsButtonPassageName] = modsButtonPassage.withContent(newContents)
    }

    val varPassage = TweePassage(modsVariablePassageName, "header", "").apply {
      contents = htmlEscape("""
        (set:${'$'}enabled_mods to (dm:${modLoadOrder.joinToString(",") {
          """"${it.name}",(dm:"name","${it.name}","version","${it.version}","author","${it.author}")"""
        }}))(set:${'$'}mod_enabled to (macro:str-type _name, ...codehook-type _hooks, [(set:_enabled to 1st of _hooks)(set:_not_enabled to 2nd of (_hooks + (a:[])))(out:)[(if:(dmnames:${'$'}enabled_mods) contains _name)[_enabled](else:)[_not_enabled]]]))(set:${'$'}mod_not_enabled to (macro:str-type _name, codehook-type _hook, [(out:)[(${'$'}mod_enabled:_name, [], _hook)]]))
      """.trimIndent())
    }
    passages[modsVariablePassageName] = varPassage.toHtmlPassage()

    debug("Writing modded html")
    val scriptElement = gameText.scriptElement ?: ScriptElement.create().also {
      debug("No twine user script found in html, creating a new one")
      val passageTag = passages.values.first().tag
      gameText = gameText.replace(passageTag, it.full + passageTag)
    }

    val startScript = Regex.escape(scriptElement.tag).toRegex()
    val endScript = Regex.escape(scriptElement.endTag).toRegex()
    val startPassages = """(?<!</tw-passagedata>)<tw-passagedata\b""".toRegex()
    val endPassages = """</tw-passagedata>(?!<tw-passagedata\b)""".toRegex()
    val outputHtmlFile = outputDirectory.resolve(inputHtmlFile.name)
    debug("Keeping ${Settings.keepOldFiles} files")
    (9 downTo Settings.keepOldFiles).forEach {
      val file = outputDirectory.resolve(outputHtmlFile.name.replace(".html", ".$it.html"))
      if (file.exists()) {
        fileMessageAction("Deleting ${file.name}")
        file.deleteExisting()
      }
    }
    (Settings.keepOldFiles downTo 1).forEach {
      val oldFile = outputDirectory.resolve(outputHtmlFile.name.replace(".html", ".$it.html"))
      if (oldFile.exists()) {
        val newFile = outputDirectory.resolve(outputHtmlFile.name.replace(".html", ".${it + 1}.html"))
        fileMessageAction("Moving ${oldFile.name} to ${newFile.name}")
        oldFile.moveTo(newFile)
      }
    }
    if (Settings.keepOldFiles > 0) {
      if (outputHtmlFile.exists()) {
        val newFile = outputDirectory.resolve(outputHtmlFile.name.replace(".html", ".1.html"))
        fileMessageAction("Moving ${outputHtmlFile.name} to ${newFile.name}")
        outputHtmlFile.moveTo(newFile)
      }
    }
    log("Writing $outputHtmlFile")
    outputHtmlFile.bufferedWriter().use { writer ->
      Replacer.replace(gameText,
        listOf(
          ReplacementFromTo(startScript, endScript, sequenceOf(scriptElement.tag, scriptElement.contents, *scripts.toTypedArray(), scriptElement.endTag)) {
            debug("Found script element, appending new scripts")
          },
          ReplacementFromTo(startPassages, endPassages, passages.values.asSequence().map(HTMLPassage::full)) {
            when (it) {
              ReplacingStatus.InMatch -> debug("Found start of passages, replacing with new passages")
              ReplacingStatus.AfterMatch -> debug("Found end of passages, resuming normal writing")
              else -> {}
            }
          },
        ),
        writer::appendLine
      )
    }

    // write results file
    rootDir.resolve("mod_results.txt").bufferedWriter().use { writer ->
      fileMessageAction("Writing results file")
      writer.appendLine("Mods loaded:")

      modLoadOrder.map(UnpackedMod::name).forEach(writer::appendLine)

      if (errors.isNotEmpty()) {
        writer.newLine()
        writer.appendLine("Overlaps:")

        errors.forEach { (modName, conflictList) ->
          writer.appendLine("$modName: ${conflictList.joinToString()}")
        }
      }

      mods.filter { it.metadataReadFailure != null }.forEach { mod ->
        val msg = "Warning: Failed to read mod metadata for ${mod.name}: ${mod.metadataReadFailure}"
        log(msg)
        writer.appendLine(msg)
      }

      debug("Finishing results file")
    }

    log("\nDone!")

    return errors
  }

  /**
   * Initialize this processor with the given HTML.
   */
  fun initializeHtml(@Language("html") html: String) {
    gameText = html
    passages.clear()
    passages.putAll(gameText.htmlPassages)
    passageMods.clear()
    passageMods.putAll(passages.keys.associateWith { mutableSetOf(baseGame.name) })
  }

  fun processTweeFile(currentMod: String, text: String) {
    fun indent(text: String, indent: Int = 0) = log(" ".repeat((2 + indent) * 2) + text)

    text.tweePassages.forEach { tweePassage ->
      var passage = tweePassage

      val messages = mutableListOf<String>()

      debug("Processing ${passage.name}")

      if (passage.tags.contains("around")) {
        // this passage uses AOP, apply as around advice
        val existing = passages[passage.name] ?: run {
          indent("${passage.name}")
          indent("Passage is marked as around advice but no existing passage of that name exists. Dropping.", 1)
          return@forEach
        }

        val destPassage = HTMLPassage(
          existing.name.value + "-aop-" + UUID.randomUUID(),
          existing.attributes + ("tags" to ""), // tags will be applied to the wrapper passage
          existing.contents
        )
        // record the original's new name if we're first to around it, otherwise we're arounding another around
        if (aroundedPassages[existing.name] == null) {
          debug("Computing around advice")
          aroundedPassages[existing.name] = destPassage.name
          // Move the mods list for that passage to its new name so if anyone else overwrites it, it'll show properly in the log
          passageMods[destPassage.name] = passageMods[passage.name] ?: mutableSetOf()
          passageMods.remove(passage.name)
        }
        passages[destPassage.name] = destPassage

        // hide the around tag, add tags from wrapped passage
        val wrapperPassage = passage.withTags(tags = passage.tags.filter { it != "around" } + existing.tags)
        wrapperPassage.contents = "(set:const-type _around to "${destPassage.name.value}")" + wrapperPassage.contents
        val wrapperHtml = wrapperPassage.toHtmlPassage()
        passages[wrapperPassage.name] = wrapperHtml

        if (Settings.showOverwritten) messages.add("Advising existing passage")
      } else {
        //if passage was arounded, replace the displaced passage instead of the around passage
        aroundedPassages[passage.name]?.let {
          if (Settings.showOverwritten) messages.add("Overwriting displaced: $it")
          passage = passage.withName(it)
        }
        // normal passage, replace or add
        val htmlPassage = passage.toHtmlPassage()

        when (passages[passage.name]) {
          null -> if (Settings.showNewPassages) messages.add("New passage")
          else -> if (Settings.showOverwritten) {
            messages.add("Overwriting:")
            passageMods[passage.name]?.let { overwritten ->
              overwritten.forEach {
                messages.add("  $it")
              }
            }
          }
        }

        passages[passage.name] = htmlPassage
      }

      if (messages.isNotEmpty()) {
        indent("${passage.name}")
        messages.forEach { indent(it, 1) }
      }

      passageMods.getOrPut(passage.name, ::mutableSetOf).add(currentMod)
    }
  }
}


code:
package life.xchange

import com.formdev.flatlaf.IntelliJTheme
import com.formdev.flatlaf.IntelliJTheme.ThemeLaf

class PinkLaf : ThemeLaf(IntelliJTheme(PinkLaf::class.java.getResourceAsStream("/cute_pink_light.theme.json")))

repo_name: xchange/life

license: unknown


code:
package life.xchange

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import life.xchange.util.setHidden
import java.util.*
import kotlin.io.path.createFile
import kotlin.io.path.exists
import kotlin.io.path.name
import kotlin.io.path.outputStream
import kotlin.reflect.KProperty

internal val settings = Properties()

const val SETTINGS_VERSION: Int = 1

object Settings {
  var settingsVersion by IntSetting(0)
  var laf by StringSetting("Pink") { reloadLaf() }
  var showMetadataReadFailures by BooleanSetting(false)
  var launchVersionCheck by BooleanSetting(false)
  var deleteArchiveOnAdd by BooleanSetting(false)
  var enableModsOnAdd by BooleanSetting(true)
  var showNewPassages by BooleanSetting(true)
  var debugLoadProcess by BooleanSetting(false)
  var showFiles by BooleanSetting(true)
  var showFileOperations by BooleanSetting(false)
  var columnOrder by ListSetting(listOf("Enabled", "Mod Name", "Version", "Author", "Load Order"))
  var copyInsteadOfLink by BooleanSetting(false)
  var showOverwritten by BooleanSetting(true)
  var cleanModdedOnLoad by BooleanSetting(false)
  var closeAfterLoad by BooleanSetting(false)
  var runAfterLoad by BooleanSetting(false)
  var runAfterLoadURI by StringSetting(::defaultURI)
  var keepOldFiles by IntSetting(0)

  val defaultURI: String get() = moddedDir.resolve(htmlFile.name).toUri().toString()
}

private fun updateSettings(f: (Properties) -> Unit) {
  carefully {
    f(settings)
    if (!settingsFile.exists()) {
      settingsFile.createFile()
      settingsFile.setHidden()
    }
    settingsFile.outputStream().use { settings.store(it, null) }
  }
}

sealed class Setting<T>(private val defaultValueMaker: () -> T, private val onSave: (T) -> Unit = {}) {

  abstract fun serialize(settingValue: T): String
  abstract fun deserialize(propertiesValue: String?): T?

  operator fun getValue(thisRef: Any?, property: KProperty<*>): T = settings.getProperty(property.name)?.let(::deserialize) ?: defaultValueMaker()
  operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
    updateSettings { it[property.name] = serialize(value) }
    onSave(value)
  }
}

class StringSetting(defaultValueMaker: () -> String, onSave: (String) -> Unit = {}) : Setting<String>(
  defaultValueMaker, onSave) {
  constructor(defaultValue: String, onSave: (String) -> Unit = {}) : this(
    { defaultValue },
    onSave
  )

  override fun serialize(settingValue: String): String = settingValue
  override fun deserialize(propertiesValue: String?) = propertiesValue
}

class IntSetting(defaultValueMaker: () -> Int, onSave: (Int) -> Unit = {}) : Setting<Int>(
  defaultValueMaker, onSave) {
  constructor(defaultValue: Int, onSave: (Int) -> Unit = {}) : this({ defaultValue }, onSave)

  override fun serialize(settingValue: Int): String = "$settingValue"
  override fun deserialize(propertiesValue: String?) = propertiesValue?.toIntOrNull()
}

class BooleanSetting(defaultValueMaker: () -> Boolean, onSave: (Boolean) -> Unit = {}) : Setting<Boolean>(
  defaultValueMaker, onSave) {
  constructor(defaultValue: Boolean, onSave: (Boolean) -> Unit = {}) : this(
    { defaultValue },
    onSave
  )

  override fun serialize(settingValue: Boolean): String = "$settingValue"
  override fun deserialize(propertiesValue: String?) = propertiesValue.toBoolean()
}

class ListSetting(defaultValueMaker: () -> List<String>, onSave: (List<String>) -> Unit = {}) : Setting<List<String>>(
  defaultValueMaker, onSave) {
  constructor(defaultValue: List<String>, onSave: (List<String>) -> Unit = {}) : this(
    { defaultValue },
    onSave
  )

  override fun serialize(settingValue: List<String>): String = Json.encodeToString(settingValue)
  override fun deserialize(propertiesValue: String?): List<String>? = try {
    propertiesValue?.let<String, List<String>>(Json.Default::decodeFromString)
  } catch (e: Exception) {
    null
  }
}


code:
package life.xchange.cli

import org.apache.commons.cli.*
import java.nio.file.Path
import java.text.ParseException
import kotlin.reflect.KClass
import kotlin.system.exitProcess

object CLI {
  val html: Option = Option.builder("f")
    .longOpt("file")
    .hasArg()
    .argName("file")
    .desc("game HTML file to mod")
    .type(Path::class.java)
    .build()
  val debug: Option = Option.builder("d")
    .longOpt("debug")
    .desc("debug database errors to console instead of file")
    .build()
  val root: Option = Option.builder("r")
    .longOpt("root")
    .hasArg()
    .argName("dir")
    .desc("root directory for the game")
    .type(Path::class.java)
    .build()
  val help: Option = Option.builder("h")
    .longOpt("help")
    .desc("show this help message")
    .build()

  private val options by lazy {
    Options().apply {
      addOption(html)
      addOption(debug)
      addOption(root)
      addOption(help)
    }
  }

  fun parse(args: Array<String>): CommandLine = try {
    DefaultParser(false).parse(options, args).apply {
      if (argList.firstOrNull()?.lowercase() !in Operation.operations) {
        argList.clear()
      }
    }
  } catch (e: ParseException) {
    showHelp()
    exitProcess(1)
  }

  fun showHelp() {
    HelpFormatter().printHelp("mod-loader\nOptions:", options)
    println("Commands:")
    val width = Operation.operations.maxOf(String::length)
    Operation::class.sealedSubclasses.forEach {
      val name = it.simpleName.toString().lowercase()
      println(" $name${" ".repeat(width - name.length)}   ${Operation.descriptions[it]}")
    }
  }
}


code:
package life.xchange.cli

import life.xchange.*
import life.xchange.db.UnpackedMod
import life.xchange.db.UnpackedMods
import life.xchange.rework.ModFileAdder
import life.xchange.tui.runTui
import life.xchange.util.helpfulStackTrace
import org.jetbrains.exposed.sql.transactions.transaction
import kotlin.io.path.*
import kotlin.reflect.KClass
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.full.memberProperties
import kotlin.reflect.full.primaryConstructor

sealed interface Operation {
  data class TextUI(val args: List<String>) : Operation {
    override fun execute() {
      runTui()
    }
  }

  data class Load(val args: List<String>) : Operation {
    override fun execute() {
      ModProcessor().loadMods(htmlFile, moddedDir)
    }
  }

  data class Query(val args: List<String>) : Operation {
    override fun execute() {
      println("* means the mod is enabled")
      transaction {
        UnpackedMod.all().sorted().forEach { mod ->
          print(if (mod.enabled) " * " else "   ")
          print("'${mod.name}'")
          mod.version?.let { print(" version $it") }
          mod.author?.let { print(" by $it") }
          println()
        }
      }
    }
  }

  data class Add(val args: List<String>) : Operation {
    override fun execute() {
      args.map { Path(it) }.forEach { path ->
        println(path)
        if (path.notExists()) {
          println("File not found")
        } else {
          ModFileAdder.addModFile(path.toFile())
        }
      }
    }
  }

  data class Remove(val args: List<String>) : Operation {
    override fun execute() {
      transaction {
        val mods = UnpackedMod.find { UnpackedMods.name inList args }.filterNot(UnpackedMod::isBaseGame)

        ModFileAdder.removeMods(mods, doConfirm = false)
      }
    }
  }

  data class Enable(val args: List<String>) : Operation {
    override fun execute() {
      transaction {
        UnpackedMod.find { UnpackedMods.name inList args }.filterNot(UnpackedMod::isBaseGame).forEach { mod ->
          println("Enabling ${mod.name}")
          mod.enabled = true
        }
      }
    }
  }

  data class Disable(val args: List<String>) : Operation {
    override fun execute() {
      transaction {
        UnpackedMod.find { UnpackedMods.name inList args }.filterNot(UnpackedMod::isBaseGame).forEach { mod ->
          println("Disabling ${mod.name}")
          mod.enabled = false
        }
      }
    }
  }

  data class Compare(val args: List<String>) : Operation {
    override fun execute() {
      val previousPath = args.getOrNull(0)?.let(::Path)
      val currentPath = args.getOrNull(1)?.let(::Path)

      err(previousPath == null || currentPath == null, "Two html files are required to compare")
      compare(previousPath!!, currentPath!!)
    }
  }

  data class Setting(val args: List<String>) : Operation {
    override fun execute() {
      if (args.isEmpty()) {
        Settings::class.memberProperties.filterIsInstance<KMutableProperty1<Settings, *>>().forEach { property ->
          println("${property.name} = ${property.get(Settings)}")
        }
      } else {
        args.forEach { arg ->
          when {
            arg.contains("=") -> {
              val (name, value) = arg.split("=".toRegex(), 2).map(String::trim)
              Settings::class.memberProperties.filterIsInstance<KMutableProperty1<Settings, Any>>().firstOrNull { it.name == name }?.let {
                val v: Any = when (it.returnType.classifier) {
                  Boolean::class -> value.toBoolean()
                  Int::class -> value.toInt()
                  List::class -> value.split(",").map(String::trim)
                  else -> value
                }
                it.set(Settings, v)
                println("${it.name} = ${it.get(Settings)}")
              }
            }
            else -> Settings::class.memberProperties.firstOrNull { it.name == arg }?.let {
              println("${it.name} = ${it.get(Settings)}")
            }
          }
        }
      }
    }
  }

  fun execute()

  companion object {
    val descriptions = mapOf(
      Load::class to "load the enabled mods",
      Query::class to "query the list of added mods",
      TextUI::class to "show the text ui",
      Add::class to "<file file...> mod files to add",
      Remove::class to "<modName modName...> mod names to remove",
      Enable::class to "<modName modName...> mod names to enable",
      Disable::class to "<modName modName...> mod names to disable",
      Compare::class to "<file file> two html files to compare",
      Setting::class to "blank to query all, <setting setting...> settings to query, <setting=value setting=value...> settings to set",
    )

    val operations: List<String> get() = Operation::class.sealedSubclasses.mapNotNull(KClass<*>::simpleName).map(String::lowercase)

    fun dispatch(args: List<String>, noMatchBlock: () -> Unit) {
      val opKClass = Operation::class.sealedSubclasses.firstOrNull { it.simpleName!!.lowercase() == args.firstOrNull()?.lowercase() }
      if (opKClass != null) {
        try {
          val constructor = opKClass.primaryConstructor ?: opKClass.constructors.first()
          constructor.call(args.drop(1)).execute()
        } catch (e: Throwable) {
          println(e.helpfulStackTrace())
        }
      } else {
        noMatchBlock()
      }
    }
  }
}


code:
package life.xchange.compare

import com.github.difflib.text.DiffRow
import com.github.difflib.text.DiffRowGenerator
import life.xchange.*
import life.xchange.model.mod.PassageName
import life.xchange.ui.ColumnDefinition
import life.xchange.ui.DefinedTable
import life.xchange.ui.DefinedTableFilterPanel
import life.xchange.ui.DefinedTableModel
import org.apache.commons.lang3.StringEscapeUtils
import java.awt.Color
import java.awt.Dimension
import java.awt.Font
import java.nio.file.Path
import javax.swing.*
import javax.swing.text.AttributeSet
import javax.swing.text.SimpleAttributeSet
import javax.swing.text.StyleConstants
import javax.swing.text.StyleContext
import javax.swing.text.StyledDocument
import javax.swing.text.TabSet
import javax.swing.text.TabStop

private val generator = DiffRowGenerator.create()
  .showInlineDiffs(true)
  .inlineDiffByWord(true)
  .lineNormalizer { it }
  .equalizer { s, s2 -> s == s2 }
  .decompressDeltas(true)
  .oldTag { f -> if (f) "<DIFF>" else "</DIFF>" }
  .newTag { f -> if (f) "<DIFF>" else "</DIFF>" }
  .build()

class CompareHTMLFilesPanel(previousPath: Path, currentPath: Path) : JPanel() {
  init {
    val previous = previousPath.charSetSafeRead().htmlPassages
    val current = currentPath.charSetSafeRead().htmlPassages

    layout = BoxLayout(this, BoxLayout.LINE_AXIS)
    val compare = current.mapNotNull { (name, passage) ->
      when (val contents = previous[name]?.contents ?: "") {
        passage.contents -> null
        else -> name to Compare(contents, passage.contents)
      }
    }.toMap() + previous.mapNotNull { (name, passage) ->
      when (current[name]) {
        null -> name to Compare(passage.contents, "")
        else -> null
      }
    }

    val tabs: TabSet
    val paragraphAttributeSet: AttributeSet
    val leftPane = NoWrapTextPane().apply {
      isEditable = false
      font = Font("Monospaced", font.style, font.size - 2)
      tabs = TabSet((1..50).map { TabStop(it * getFontMetrics(font).charWidth(' ') * 2f) }.toTypedArray())
      paragraphAttributeSet = StyleContext.getDefaultStyleContext().addAttribute(SimpleAttributeSet.EMPTY, StyleConstants.TabSet, tabs)
      setParagraphAttributes(paragraphAttributeSet, false)
    }
    val rightPane = NoWrapTextPane().apply {
      isEditable = false
      font = leftPane.font
      setParagraphAttributes(paragraphAttributeSet, false)
    }
    fun Color.blendWith(other: Color, weight: Double = 1.0): Color {
      val w = weight + 1
      return Color(
        ((red + other.red * weight) / w).toInt(),
        ((green + other.green * weight) / w).toInt(),
        ((blue + other.blue * weight) / w).toInt(),
      )
    }
    val bg = UIManager.getColor("TextPane.background")
    val red = Color.red
    val green = Color(0, 0xa0, 0)
    val removedLine = leftPane.addStyle(null, null)
    StyleConstants.setBackground(removedLine, bg.blendWith(red, 0.3))
    val removed = leftPane.addStyle(null, null)
    StyleConstants.setBackground(removed, bg.blendWith(red, 1.0))
    val addedLine = leftPane.addStyle(null, null)
    StyleConstants.setBackground(addedLine, bg.blendWith(green, 0.3))
    val added = leftPane.addStyle(null, null)
    StyleConstants.setBackground(added, bg.blendWith(green, 1.0))

    val table = CompareTable(compare)
    table.selectionModel.addListSelectionListener {
      if (it.valueIsAdjusting) return@addListSelectionListener

      val selected = if (table.selectedRow >= 0) table.selectedItem else null
      val old = StringEscapeUtils.unescapeHtml3(compare[selected]?.old) ?: ""
      val new = StringEscapeUtils.unescapeHtml3(compare[selected]?.new) ?: ""

      val rows = generator.generateDiffRows(old.lines(), new.lines())

      fun StyledDocument.append(text: String, wordStyle: AttributeSet?, lineStyle: AttributeSet?) {
        if (text.isEmpty()) return
        if (!text.contains("</?DIFF>".toRegex())) {
          insertString(length, text, lineStyle)
          return
        }

        val (a, b) = text.split("</?DIFF>".toRegex(), 2)
        insertString(length, a, lineStyle)
        append(b, lineStyle, wordStyle)
      }

      fun StyledDocument.appendLine(text: String, wordStyle: AttributeSet?, lineStyle: AttributeSet?) {
        append(text + "\n", wordStyle, lineStyle)
      }

      leftPane.text = ""
      rightPane.text = ""
      rows.forEach { row ->
        leftPane.styledDocument.appendLine(row.oldLine, removed, removedLine.takeUnless { row.tag == DiffRow.Tag.EQUAL })
        rightPane.styledDocument.appendLine(row.newLine, added, addedLine.takeUnless { row.tag == DiffRow.Tag.EQUAL })
      }
      leftPane.caretPosition = 0
      rightPane.caretPosition = 0
    }

    val leftScroll = buildScroll(leftPane, Dimension(600, 900), horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS)
    val rightScroll = buildScroll(rightPane, Dimension(600, 900), horizontalScrollBarPolicy = JScrollPane.HORIZONTAL_SCROLLBAR_ALWAYS)

    leftScroll.verticalScrollBar.addAdjustmentListener {
      if (!it.valueIsAdjusting) {
        rightScroll.verticalScrollBar.value = it.value
      }
    }
    rightScroll.verticalScrollBar.addAdjustmentListener {
      if (!it.valueIsAdjusting) {
        leftScroll.verticalScrollBar.value = it.value
      }
    }

    split(
      DefinedTableFilterPanel(table, PassageName::value, { buildScroll(it, Dimension(350, 900)) }),
      split(leftScroll, rightScroll)
    )
  }
}

data class Compare(val old: String, val new: String)

class CompareTable(val compare: Map<PassageName, *>) : DefinedTable<PassageName, CompareTable.TableModel>() {
  override val tableModel = TableModel()

  inner class TableModel :
    DefinedTableModel<PassageName>(listOf(ColumnDefinition(PassageName::value, String::class.java, "Passage", 200))) {
    override fun loadItems(): List<PassageName> = compare.keys.sortedBy { it.value }
  }

  init {
    initTableStuff()
    setSelectionMode(ListSelectionModel.SINGLE_SELECTION)
  }
}

class NoWrapTextPane : JTextPane() {
  override fun getScrollableTracksViewportWidth() = ui.getPreferredSize(this).width <= parent.size.width
  override fun getPreferredSize(): Dimension = ui.getPreferredSize(this)
}


code:
package life.xchange.compatibility

import life.xchange.baseGame
import life.xchange.charSetSafeRead
import life.xchange.db.*
import life.xchange.findBaseGameVersion
import life.xchange.htmlPassages
import org.jetbrains.exposed.dao.with
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inSubQuery
import org.jetbrains.exposed.sql.SqlExpressionBuilder.neq
import org.jetbrains.exposed.sql.transactions.transaction
import java.lang.RuntimeException
import java.nio.file.Path

class Checker(private val baseGameVersion: String?) {
  constructor(inputPath: Path) : this(findBaseGameVersion(inputPath.charSetSafeRead().htmlPassages))

  companion object {
    const val childrenText = ":::::"
    const val childrenSeparator = ";;;;;"

    fun checkModCompatibility(baseGameVersion: String?) =
      Checker(baseGameVersion).checkModCompatibility()

    @Throws(CircularLoadOrderException::class)
    fun topologicalSort(mods: Iterable<UnpackedMod>): List<UnpackedMod> {
      val loadsAfter = mods.sortedBy(UnpackedMod::name).associateWith { setOf<UnpackedMod>() }.toMutableMap()

      mods.forEach { mod ->
        mod.references.filter { it.relationship != ModRelationship.INCOMPATIBLE }.forEach { other ->
          mods.filter(other.otherModName::equivalentTo).forEach { otherMod ->
            when (other.order) {
              ModLoadOrder.AFTER -> loadsAfter.compute(mod) { _, set -> (set ?: setOf()) + otherMod }
              ModLoadOrder.BEFORE -> loadsAfter.compute(otherMod) { _, set -> (set ?: setOf()) + mod }
              else -> {}
            }
          }
        }
      }

      val order = mutableListOf(baseGame)
      val orderSet = mutableSetOf(baseGame)
      while (loadsAfter.isNotEmpty()) {
        val next = loadsAfter.entries.firstOrNull { (_, set) -> (set - orderSet).isEmpty() }
        if (next != null) {
          order.add(next.key)
          orderSet.add(next.key)
          loadsAfter.remove(next.key)
        } else {
          throw CircularLoadOrderException()
        }
      }

      return order
    }
  }

  private val errors = mutableMapOf<String, List<String>>()

  private fun addError(key: String, msg: String) {
    errors.compute(key) { _, list -> list?.plusElement(msg) ?: listOf(msg) }
  }

  fun checkModCompatibility(): Map<String, List<String>> {
    if (baseGameVersion == null) {
      val msg = "Unable to determine the base game version - The Mod Loader may be out of date. " +
          "Mods will not be checked for compatibility with the base game."
      addError("Overall", msg)
    }

      transaction {
        val activeMods = UnpackedMod.find { UnpackedMods.enabled eq true }
        val modsByName = activeMods.associateBy { it.name }

        activeMods.forEach { mod ->
          if (baseGameVersion?.let { mod.baseGameVersion?.matchesVersion(it)?.not() } == true) {
            val msg = "Does not support base game version $baseGameVersion (requires ${mod.baseGameVersion})"
            addError(mod.name, msg)
          }

          mod.references.forEach {
            when (it.relationship) {
              ModRelationship.REQUIRED -> {
                val equivalence: ModEquivalence? = modsByName.values.firstNotNullOfOrNull(it.otherModName::equivalenceTo)
                var msg: String? = null
                when (val otherMod = modsByName[it.otherModName]) {
                  null -> when (equivalence) {
                    null -> msg = "Requires another mod named ${it.otherModName}, which could not be found"
                    else -> when (val version = equivalence.reference.versionSpec?.exactly) {
                      null -> msg = "Requires version ${it.versionSpec} of ${it.otherModName}. ${equivalence.mod.name} is equivalent, but does not specify to which version."
                      else -> {
                        val actualVersion = Version(version)
                        if (it.versionSpec?.matchesVersion(actualVersion) == false) {
                          msg = "Requires version ${it.versionSpec} of ${it.otherModName}, but an equivalent to version $actualVersion is loaded (${equivalence.mod.name} version ${equivalence.mod.version})"
                        }
                      }
                    }
                  }
                  else -> {
                    val actualVersion = otherMod.version
                    if (it.versionSpec?.matchesVersion(actualVersion) == false) {
                      msg =
                        "Requires version ${it.versionSpec} of ${otherMod.name}, but version $actualVersion is loaded"
                    }
                  }
                }
                if (msg != null) {
                  addError(mod.name, msg)
                }
              }

              ModRelationship.COMPATIBLE -> {
                val actualVersion = modsByName[it.otherModName]?.version
                if (it.versionSpec?.matchesVersion(actualVersion) == false) {
                  addError(
                    mod.name,
                    "Is compatible with version ${it.versionSpec} of ${it.otherModName}, but version $actualVersion is loaded"
                  )
                }
              }

              ModRelationship.INCOMPATIBLE -> {
                val otherMod = modsByName[it.otherModName]
                if (otherMod != null) {
                  val spec = it.versionSpec
                  val actualVersion = otherMod.version
                  val msg = if (spec == null || actualVersion == null) {
                    "Explicitly marked as incompatible with ${it.otherModName}"
                  } else if (spec.matchesVersion(actualVersion)) {
                    "Explicitly marked as incompatible with version $actualVersion of ${it.otherModName}"
                  } else null
                  if (msg != null) {
                    addError(mod.name, msg)
                  }
                }

              }
              ModRelationship.EQUIVALENT -> {
                val otherMod = modsByName[it.otherModName]
                if (otherMod != null) {
                  val spec = it.versionSpec
                  val actualVersion = otherMod.version
                  val msg = when {
                    spec == null && actualVersion == null -> "Marked as equivalent to ${it.otherModName}"
                    actualVersion == null -> "Marked as equivalent to version $spec of ${it.otherModName}"
                    spec == null -> "Marked as equivalent to ${it.otherModName} ($actualVersion loaded)"
                    else -> "Marked as equivalent to version $spec of ${it.otherModName} ($actualVersion loaded)"
                  }
                  addError(mod.name, msg)
                }
              }
            }
          }
        }

        determineUnresolvedConflicts().groupBy(ModConflict::modA).forEach { (mod, conflictList) ->
          conflictList.groupBy(ModConflict::modB).forEach { (otherMod, list) ->
            addError(mod, "Overlaps with $otherMod$childrenText${list.joinToString(childrenSeparator) { it.passage }}.")
          }
        }
      }

    return errors
  }

  /**
   * Get all the passages that at least two mods have without 'around'
   *
   * @return a map of passage names to a collection of the mods that specify that passage
   */
  private fun findPotentiallyConflictingPassages(): Map<String, Iterable<UnpackedMod>> {
    val enabled = UnpackedMods.enabled eq true
    val notBaseGame = UnpackedMods.id neq 0
    val notAround = not(stringLiteral("around") eq anyFrom(UnpackedModPassages.tags))
    val nameHasMultipleMods = UnpackedModPassages.name inSubQuery UnpackedModPassages.innerJoin(UnpackedMods)
      .select(UnpackedModPassages.name)
      .where(enabled and notAround and notBaseGame)
      .groupBy(UnpackedModPassages.name)
      .having { UnpackedModPassages.mod.count() greater 1 }

    return UnpackedModPassage.wrapRows(
      UnpackedModPassages.innerJoin(UnpackedMods).selectAll()
        .where(enabled and notAround and notBaseGame and nameHasMultipleMods)
        .orderBy(UnpackedModPassages.name to SortOrder.ASC, UnpackedMods.name to SortOrder.ASC)
    )
      .with(UnpackedModPassage::mod)
      .groupBy({ it.name }, { it.mod })
  }

  /**
   * Check all the mods for each passage against each other. If neither specifies compatibility, it's a conflict.
   *
   * @return a set of ModConflicts representing each pair of mods that conflict on a passage name
   */
  private fun determineUnresolvedConflicts(): Set<ModConflict> = buildSet {
    findPotentiallyConflictingPassages().forEach { (passage, mods) ->
      val toCheck = mods.toMutableSet()
      mods.forEach { mod ->
        toCheck -= mod
        toCheck.forEach { otherMod ->
          val modSpecifiesCompatibility =
            mod.references.any { it.isCompatible && it.otherModName.equivalentTo(otherMod) }
          val otherSpecifiesCompatibility =
            otherMod.references.any { it.isCompatible && it.otherModName.equivalentTo(mod) }

          if (!modSpecifiesCompatibility && !otherSpecifiesCompatibility) {
            add(ModConflict(mod.name, otherMod.name, passage))
          }
        }
      }
    }
  }

  class ModConflict(val modA: String, val modB: String, val passage: String) {
    override fun equals(other: Any?): Boolean = when {
      other !is ModConflict -> false
      passage != other.passage -> false
      else -> setOf(modA, modB).containsAll(setOf(other.modA, other.modB))
    }

    override fun hashCode(): Int =
      31 * (modA.hashCode() + modB.hashCode()) + passage.hashCode()
  }
}

class CircularLoadOrderException : RuntimeException()
class MissingRequirementsException(val requiredModNames: Collection<String>) : RuntimeException()


code:
package life.xchange.compatibility

import com.charleskorn.kaml.Yaml
import kotlinx.serialization.Serializable
import java.io.OutputStream

@Serializable
data class ModMetadata(
    val metaVersion: Int,
    val name: String,
    val version: String,
    val author: String,
    val baseGameVersion: VersionSpec? = null,
    val url: String? = null,
    val requiredMods: List<OtherModReference> = listOf(),
    val compatibleMods: List<OtherModReference> = listOf(),
    val incompatibleMods: List<OtherModReference> = listOf(),
    val equivalentMods: List<OtherModReference> = listOf(),
) {
    companion object {
        fun loadFromString(yaml: String): ModMetadata? {
            if (yaml.isEmpty()) return null
            return Yaml.default.decodeFromString(serializer(), yaml)
        }
    }

    fun streamTo(stream: OutputStream) {
        Yaml.default.encodeToStream(serializer(), this, stream)
    }
}

@Serializable
class Version(private val versionString: String) : Comparable<Version> {
    private val versionParts: List<String> = """\d+|[a-z]+""".toRegex(RegexOption.IGNORE_CASE)
        .findAll(versionString)
        .map(MatchResult::value)
        .toList()

    private fun compareVersions(one: String, other: String): Int = when {
        one.all(Char::isDigit) && other.all(Char::isDigit) -> one.toInt().compareTo(other.toInt())
        else -> one.lowercase().compareTo(other.lowercase())
    }

    override fun compareTo(other: Version): Int {
        versionParts.zip(other.versionParts).forEach { (me, them) ->
            val compare = compareVersions(me, them)
            if (compare != 0) return compare
        }
        return versionParts.size.compareTo(other.versionParts.size)
    }

    override fun equals(other: Any?): Boolean = other is Version && compareTo(other) == 0
    override fun hashCode(): Int = versionParts.hashCode()
    override fun toString(): String = versionString
}

@Serializable
data class OtherModReference(val name: String, val version: VersionSpec? = null, val loadMyMod: ModLoadOrder? = null)

@Serializable
data class VersionSpec(val exactly: String? = null, val atLeast: String? = null, val atMost: String? = null, val before: String? = null) {
    fun matchesVersion(version: Version?): Boolean = version == null || matchesVersion(version.toString())

    fun matchesVersion(version: String): Boolean {
        if (exactly != null) return version == exactly
        val v = Version(version)
        if (atLeast != null && v < Version(atLeast)) return false
        if (atMost != null && v > Version(atMost)) return false
        return before == null || v < Version(before)
    }

    override fun toString(): String {
        if (exactly != null) {
            return "$exactly (exactly)"
        }
        if (atLeast != null && atMost != null) {
            return "between $atLeast and $atMost"
        }
        if (atLeast != null && before != null) {
            return "at least $atLeast and before $before"
        }
        if (atLeast != null) {
            return "$atLeast or newer"
        }
        if (atMost != null) {
            return "$atMost or older"
        }
        if (before != null) {
            return "before $before"
        }
        return "any"
    }
}

enum class ModRelationship {
    REQUIRED, COMPATIBLE, INCOMPATIBLE, EQUIVALENT
}

enum class ModLoadOrder {
    BEFORE, AFTER, ANY
}


code:
package life.xchange.db

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import kotlinx.serialization.json.Json
import life.xchange.compatibility.*
import life.xchange.model.mod.ModInfo
import life.xchange.rootDir
import life.xchange.util.walk
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.*
import org.jetbrains.exposed.sql.json.json
import org.jetbrains.exposed.sql.transactions.transaction
import java.nio.file.Path
import kotlin.sequences.Sequence

private const val stupidBig = 1_000_000

interface HasMod {
  val mod: UnpackedMod
}

interface ManuallyCreated {
  val isManual: Boolean
}

object ModReferences : IntIdTable("MOD_REFERENCE") {
  val mod = reference("MOD_ID", UnpackedMods, ReferenceOption.CASCADE, ReferenceOption.CASCADE)
  val otherModName = varchar("OTHER_MOD_NAME", stupidBig)
  val versionSpec = json<VersionSpec>("VERSION_SPEC", Json).nullable()
  val relationship = enumerationByName<ModRelationship>("RELATIONSHIP", 50)
  val order = enumerationByName<ModLoadOrder>("MOD_LOAD_ORDER", 50).nullable()
  val isManual = bool("IS_MANUAL").default(false)
}

class ModReference(id: EntityID<Int>) : IntEntity(id), HasMod, ManuallyCreated {
  companion object : IntEntityClass<ModReference>(ModReferences)

  override var mod by UnpackedMod referencedOn ModReferences.mod
  var otherModName by ModReferences.otherModName
  var versionSpec by ModReferences.versionSpec
  var relationship by ModReferences.relationship
  var order by ModReferences.order
  override var isManual by ModReferences.isManual

  val isCompatible get() = relationship != ModRelationship.INCOMPATIBLE
}

object UnpackedMods : IntIdTable("UNPACKED_MOD") {
  val name = varchar("MOD_NAME", stupidBig).uniqueIndex()
  val author = varchar("AUTHOR", stupidBig).nullable()
  val version = varchar("VERSION", stupidBig).nullable()
  val baseGameVersion = json<VersionSpec>("BASE_GAME_VERSION", Json).nullable()
  val enabled = bool("ENABLED")
  val loadOrder = integer("LOAD_ORDER").nullable()
  val metadataReadFailure = varchar("METADATA_READ_FAILURE", stupidBig).nullable()
}

class UnpackedMod(id: EntityID<Int>) : IntEntity(id), Comparable<UnpackedMod>, ModInfo {
  companion object : IntEntityClass<UnpackedMod>(UnpackedMods)

  override var name by UnpackedMods.name
  var author by UnpackedMods.author
  override var version by UnpackedMods.version.transform({ it.toString() }, { it?.takeUnless("null"::equals)?.let(::Version) })
  var baseGameVersion by UnpackedMods.baseGameVersion
  var enabled by UnpackedMods.enabled
  var loadOrder by UnpackedMods.loadOrder
  override var metadataReadFailure by UnpackedMods.metadataReadFailure

  val files by UnpackedModFile referrersOn UnpackedModFiles.mod
  val references by ModReference referrersOn ModReferences.mod
  val passages by UnpackedModPassage referrersOn UnpackedModPassages.mod
  val profiles by ModProfile via ModProfileMods

  val isBaseGame: Boolean get() = id.value == 0

  override val baseDirectory get() = when {
    isBaseGame -> rootDir
    else -> super.baseDirectory
  }

  fun walkFiles(block: (Path) -> Unit) {
    fileWalker.forEach(block)
  }

  val fileWalker: Sequence<Path> get() {
    val dir = baseDirectory

    return when {
      isBaseGame -> dir.resolve("aud").walk() + dir.resolve("img").walk()
      else -> dir.walk()
    }
  }

  override fun toString(): String = name + if (version != null) " version $version" else ""

  override fun compareTo(other: UnpackedMod): Int = sequenceOf(
    (loadOrder ?: Int.MAX_VALUE).compareTo(other.loadOrder ?: Int.MAX_VALUE),
    name.lowercase().compareTo(other.name.lowercase())
  ).firstOrNull { it != 0 } ?: 0
}

data class ModEquivalence(val mod: UnpackedMod, val reference: ModReference) {
  val version: Version? get() = reference.versionSpec?.exactly?.let(::Version) ?: mod.version
}
fun String.equivalentTo(mod: UnpackedMod) = mod.name == this || equivalenceTo(mod) != null
fun String.equivalenceTo(mod: UnpackedMod) = mod.references.firstOrNull {
  it.relationship == ModRelationship.EQUIVALENT && it.otherModName == this
}?.let { ModEquivalence(mod, it) }

object UnpackedModFiles : IntIdTable("UNPACKED_MOD_FILE") {
  val mod = reference(name = "UNPACKED_MOD_ID", foreign = UnpackedMods, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
  val path = varchar("FILE_PATH", stupidBig).index()

  init {
    uniqueIndex(mod, path)
  }
}

class UnpackedModFile(id: EntityID<Int>) : IntEntity(id), HasMod {
  companion object : IntEntityClass<UnpackedModFile>(UnpackedModFiles) {
    val specialNames = setOf("meta.meta", "twee.twee")
  }

  override var mod by UnpackedMod referencedOn UnpackedModFiles.mod
  var path by UnpackedModFiles.path

  val isSpecial: Boolean get() = path in specialNames
}

object UnpackedModPassages : IntIdTable("UNPACKED_MOD_PASSAGE") {
  val mod = reference(name = "UNPACKED_MOD_ID", foreign = UnpackedMods, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
  val name = varchar("PASSAGE_NAME", stupidBig).index()
  val tags = array<String>("TAGS")
}

class UnpackedModPassage(id: EntityID<Int>) : IntEntity(id), HasMod {
  companion object : IntEntityClass<UnpackedModPassage>(UnpackedModPassages)

  override var mod by UnpackedMod referencedOn UnpackedModPassages.mod
  var name by UnpackedModPassages.name
  var tags by UnpackedModPassages.tags

  override fun toString(): String = name + if (tags.isNotEmpty()) " $tags" else ""
}

object ModProfiles : IntIdTable("MOD_PROFILE") {
  val name = varchar("PROFILE_NAME", stupidBig).uniqueIndex()
}

class ModProfile(id: EntityID<Int>) : IntEntity(id) {
  companion object : IntEntityClass<ModProfile>(ModProfiles)

  var name by ModProfiles.name
  var mods by UnpackedMod via ModProfileMods

  override fun toString() = name
}

object ModProfileMods : IntIdTable("MOD_PROFILE_MOD") {
  val profile = reference("MOD_PROFILE_ID", foreign = ModProfiles, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
  val mod = reference("UNPACKED_MOD_ID", foreign = UnpackedMods, onDelete = ReferenceOption.CASCADE, onUpdate = ReferenceOption.CASCADE)
}

object ModDatabase {
  fun init(dbPath: Path, fileTrace: Int = 0, outTrace: Int = 0) {
    Database.connect(hikari(dbPath, fileTrace, outTrace))

    transaction {
      SchemaUtils.createMissingTablesAndColumns(UnpackedMods, UnpackedModFiles, ModReferences, UnpackedModPassages, ModProfiles, ModReferences, ModProfileMods)
    }
  }

  private fun hikari(dbPath: Path, fileTrace: Int, outTrace: Int): HikariDataSource = HikariDataSource(HikariConfig().apply {
    driverClassName = "org.h2.Driver"
    jdbcUrl = listOf("jdbc:h2:$dbPath", "TRACE_LEVEL_SYSTEM_OUT=$outTrace", "TRACE_LEVEL_FILE=$fileTrace").joinToString(";")
    maximumPoolSize = 5
    isAutoCommit = false
    transactionIsolation = "TRANSACTION_REPEATABLE_READ"
    validate()
  })
}


code:
package life.xchange

import java.awt.*
import java.awt.event.ActionEvent
import java.awt.event.KeyAdapter
import java.awt.event.KeyEvent
import java.awt.event.MouseAdapter
import java.awt.event.MouseEvent
import javax.swing.*
import javax.swing.event.DocumentEvent
import javax.swing.event.DocumentListener
import kotlin.reflect.KMutableProperty0

fun Container.button(text: String, action: (ActionEvent) -> Unit = {}): JButton = JButton(text).also {
  it.addActionListener(action)
  add(it)
}

fun JFrame.jMenuBar(init: JMenuBar.() -> Unit) = JMenuBar().also {
  init(it)
  jMenuBar = it
}

fun popupMenu(vararg actions: Pair<String, (ActionEvent) -> Unit>, init: JPopupMenu.() -> Unit = {}) = JPopupMenu().also {
  actions.forEach { (name, action) ->
    when (name) {
      "" -> it.addSeparator()
      else -> it.item(name, action)
    }
  }
  init(it)
}

fun JMenuBar.menu(text: String, init: JMenu.() -> Unit = {}) = JMenu(text).also {
  init(it)
  add(it)
}

fun JMenu.item(text: String, action: (ActionEvent) -> Unit = {}) = JMenuItem(text).also {
  it.addActionListener(action)
  add(it)
}

fun JPopupMenu.item(text: String, action: (ActionEvent) -> Unit = {}) = JMenuItem(text).also {
  it.addActionListener(action)
  add(it)
}

fun <T> JMenu.radioButtonGroup(items: Iterable<T>, nameOf: (T) -> String, selected: () -> String, action: (T) -> Unit) {
  val buttonGroup = ButtonGroup()
  items.forEach { item ->
    add(JRadioButtonMenuItem(nameOf(item)).apply {
      addActionListener {
        action(item)
      }
      buttonGroup.add(this)
      if (selected() == nameOf(item)) {
        buttonGroup.setSelected(model, true)
      }
    })
  }
}

fun JFrame.contentPane(axis: Int? = null, borderSize: Int? = null, preferredSize: Dimension? = null, init: JPanel.() -> Unit = {}) = buildPanel(axis, borderSize, preferredSize, init).also {
  contentPane = it
}

fun buildPanel(axis: Int? = null, borderSize: Int? = null, preferredSize: Dimension? = null, init: JPanel.() -> Unit = {}) = JPanel().also {
  it.layout = if (axis != null) BoxLayout(it, axis) else FlowLayout()
  if (preferredSize != null) it.preferredSize = preferredSize
  if (borderSize != null) it.border = BorderFactory.createEmptyBorder(borderSize, borderSize, borderSize, borderSize)

  init(it)
}

fun Container.panel(axis: Int? = null, borderSize: Int? = null, init: JPanel.() -> Unit = {}) = buildPanel(axis, borderSize) {
  init()
  this@panel.add(this)
}

fun buildScroll(view: Component, preferredSize: Dimension? = null, verticalScrollBarPolicy: Int? = null, horizontalScrollBarPolicy: Int? = null,  init: JScrollPane.() -> Unit = {}) = JScrollPane(view).also {
  if (preferredSize != null) it.preferredSize = preferredSize
  if (verticalScrollBarPolicy != null) it.verticalScrollBarPolicy = verticalScrollBarPolicy
  if (horizontalScrollBarPolicy != null) it.horizontalScrollBarPolicy = horizontalScrollBarPolicy

  init(it)
}

fun Container.scroll(view: Component, preferredSize: Dimension? = null, verticalScrollBarPolicy: Int? = null, horizontalScrollBarPolicy: Int? = null, init: JScrollPane.() -> Unit = {}) = buildScroll(view, preferredSize, verticalScrollBarPolicy, horizontalScrollBarPolicy) {
  init()
  this@scroll.add(this)
}

fun buildSplit(left: Component, right: Component, init: JSplitPane.() -> Unit = {}) = JSplitPane().also {
  it.leftComponent = left
  it.rightComponent = right

  init(it)
}

fun Container.split(left: Component, right: Component, init: JSplitPane.() -> Unit = {}) = buildSplit(left, right) {
  init()
  this@split.add(this)
}

fun Container.text(text: String, init: JLabel.() -> Unit = {}) = JLabel(text).also {
  init(it)
  add(it)
}

fun JMenu.setting(setting: KMutableProperty0<Boolean>, text: String, onSave: (Boolean) -> Unit = {}) = JCheckBoxMenuItem(text).also { box ->
  box.addActionListener {
    setting.set(box.state)
    onSave(box.state)
  }
  box.state = setting.get()
  add(box)
}

fun Component.constrain(
  gridBagLayout: GridBagLayout,
  gridx: Int = -1,
  gridy: Int = -1,
  gridwidth: Int = 1,
  gridheight: Int = 1,
  weightx: Double = 0.0,
  weighty: Double = 0.0,
  anchor: Int = 10,
  fill: Int = 0,
  insets: Insets = Insets(0, 0, 0, 0),
  ipadx: Int = 0,
  ipady: Int = 0,
) {
  gridBagLayout.setConstraints(this, GridBagConstraints(gridx, gridy, gridwidth, gridheight, weightx, weighty, anchor, fill, insets, ipadx, ipady))
}

fun Component.onMouse(released: (MouseEvent) -> Unit = {}, pressed: (MouseEvent) -> Unit = {}) {
  addMouseListener(object : MouseAdapter() {
    override fun mousePressed(e: MouseEvent) { pressed(e) }
    override fun mouseReleased(e: MouseEvent) { released(e) }
  })
}

fun Component.onKey(pressed: (KeyEvent) -> Unit) {
  addKeyListener(object : KeyAdapter() {
    override fun keyPressed(e: KeyEvent) { pressed(e) }
  })
}

fun JTextField.onValueChange(timeoutMs: Int = 500, action: (String) -> Unit) {
  document.addDocumentListener(object : DocumentListener {
    val timer: Timer = Timer(timeoutMs) { action(document.getText(0, document.length)) }.apply { isRepeats = false }
    fun doAction() = when {
      timer.isRunning -> timer.restart()
      else -> timer.start()
    }

    override fun insertUpdate(e: DocumentEvent) { doAction() }
    override fun removeUpdate(e: DocumentEvent) { doAction() }
    override fun changedUpdate(e: DocumentEvent) { doAction() }
  })
}


code:
package life.xchange.model.mod

import com.charleskorn.kaml.EmptyYamlDocumentException
import life.xchange.*
import life.xchange.compatibility.ModMetadata
import life.xchange.compatibility.Version
import java.nio.file.Path
import java.util.zip.ZipFile
import kotlin.io.path.name

val disallowedCharacters = """[\\/:*?"<>|\x00-\x1f]""".toRegex()
val String.sanitized: String get() = replace(disallowedCharacters, "")

interface ModInfo {
    val name: String
    val version: Version?
    val metadataReadFailure: String?
    val baseDirectory: Path get() = unpackedDir.resolve(name.sanitized)
}

sealed class Mod(
    val path: Path,
    val fileName: ModName = ModName(path.name),
) : ModInfo {
    companion object {
        fun from(path: Path, name: ModName = ModName(path.name)): Mod? = when (ModFileType.from(path)) {
            ModFileType.XCL, ModFileType.ZIP -> ZipMod(path, name)
            ModFileType.TWEE -> TweeMod(path, name)
            ModFileType.JS -> JSMod(path, name)
            else -> null
        }
    }
    abstract val passages: Set<Passage>
    override var metadataReadFailure: String? = null
        protected set
    open val metadata: ModMetadata? get() = null

    override val version: Version? get() = metadata?.version?.takeUnless { it == "null" }?.let(::Version)
    override val name get() = metadata?.name ?: fileName.value

    override fun equals(other: Any?): Boolean = other is Mod && fileName == other.fileName
    override fun hashCode(): Int = fileName.hashCode()
    override fun toString(): String = "$name version ${metadata?.version} ($fileName)"
}

class JSMod(path: Path, name: ModName = ModName(path.name)) : Mod(path, name) {
    override val passages: Set<Passage> = setOf()
}

class TweeMod(path: Path, name: ModName = ModName(path.name)) : Mod(path, name) {
    private val text by lazy { path.charSetSafeRead() }
    private val comment by lazy {
        text.lineSequence()
            .dropWhile { !it.startsWith(":: ") && !it.startsWith("# meta") }
            .takeWhile {
                !it.startsWith(":: ") && !it.startsWith("# /meta")
            }
            .joinToString("\n")
    }

    override val metadata: ModMetadata? by lazy {
        try {
            ModMetadata.loadFromString(comment)
        } catch (e: EmptyYamlDocumentException) {
            null
        } catch (e: Exception) {
            metadataReadFailure = "${e::class.simpleName}: ${e.message}"
            null
        }
    }

    override val passages: Set<Passage> by lazy {
        text.tweePassages.toSet()
    }
}

class ZipMod(path: Path, name: ModName = ModName(path.name)) : Mod(path, name) {
    fun <R> withZipFile(action: (ZipFile) -> R) = ZipFile(path.toFile()).use(action)

    override val metadata: ModMetadata? by lazy {
        try {
            withZipFile { zip ->
                zip.entries().asSequence().find { it.name.endsWith(".meta") }?.let {
                    try {
                        ModMetadata.loadFromString(zip.charSetSafeRead(it))
                    } catch (e: EmptyYamlDocumentException) {
                        null
                    }
                }
            }
        } catch (e: Exception) {
            metadataReadFailure = "${e::class.simpleName}: ${e.message}"
            null
        }
    }

    override val passages: Set<Passage> by lazy {
        withZipFile { zip ->
            zip.entries().asSequence()
                .filter { e -> ModFileType.from(rootDir.resolve(e.name)) == ModFileType.TWEE }
                .map(zip::charSetSafeRead)
                .flatMap(String::tweePassages)
                .toSet()
        }
    }
}

interface Passage {
    val name: PassageName
    val tags: List<String>
}

@JvmInline
value class ModName(val value: String) {
  override fun toString() = value
}

@JvmInline
value class PassageName(val value: String) {
  override fun toString() = value
}


code:
package life.xchange.passage

import life.xchange.model.mod.PassageName
import life.xchange.htmlEscape
import life.xchange.model.mod.Passage

fun String.extractAttributes(tagName: String): Map<String, String> =
  replace("^<$tagName\\s+|\\s*>$".toRegex(), "").split("(?<=\")\\s+".toRegex()).associate {
    val (key, value) = """([^=]+)="(.*)"""".toRegex().find(it)!!.destructured
    key to value
  }

fun Map<String, String>.buildTag(tagName: String) =
  "<$tagName ${entries.joinToString(" ") { (key, value) -> """$key="$value"""" }}>"

abstract class HTMLElement(
    private val tagName: String,
    full: String,
    val tag: String,
    val attributes: Map<String, String>,
    val endTag: String = "</$tagName>"
) {
  var full: String = full
    protected set
  val contents get() = full.replace(tag, "").replace(endTag, "")

  abstract fun replaceContents(contents: String): String
}

class HTMLPassage(
    full: String,
    tag: String,
    attributes: Map<String, String> = tag.extractAttributes(tagName)
) : HTMLElement(tagName, full, tag, attributes), Passage {
  companion object {
    const val tagName = "tw-passagedata"
    const val endTag = "</$tagName>"

    fun buildTag(attributes: Map<String, String>) = attributes.buildTag(tagName)
  }

  constructor(name: String, attributes: Map<String, String>, contents: String) : this(
      buildTag(attributes + mapOf("name" to name)) + contents + HTMLPassage.endTag,
      buildTag(attributes + mapOf("name" to name)),
      attributes + mapOf("name" to name)
  )

  override val name = attributes["name"]?.let(::PassageName) ?: error("Passage has no name")

  override val tags: List<String>
    get() = attributes["tags"]?.split(' ', '\t')?.filter { it.isNotBlank() } ?: emptyList()

  override fun replaceContents(contents: String) = "$tag${htmlEscape(contents)}$endTag"

  fun withContent(contents: String) = HTMLPassage(name.value, attributes, contents)
}

class ScriptElement(
    full: String,
    tag: String,
    attributes: Map<String, String> = tag.extractAttributes(tagName)
) : HTMLElement(tagName, full, tag, attributes) {
  companion object {
    const val tagName = "script"
    fun create(): ScriptElement {
      val tag = mapOf(
          "role" to "script",
          "id" to "twine-user-script",
          "type" to "text/twine-javascript"
      ).buildTag(tagName)
      return ScriptElement("$tag</$tagName>", tag)
    }
  }

  override fun replaceContents(contents: String) = "$tag$contents$endTag"
  fun appendContents(append: String): String {
    full = "$tag$contents$append$endTag"
    return full
  }
}


code:
package life.xchange.passage

import life.xchange.model.mod.PassageName
import life.xchange.model.mod.Passage

class TweePassage private constructor(
  override val name: PassageName,
  private val attributes: Map<String, String>,
  var contents: String = ""
) : Passage {
  constructor(
    name: PassageName,
    tags: String,
    metadata: String
  ) : this(
    name,
    // incredibly naive JSON parse, but good enough for what twee does
    attributes = mapOf("name" to name.value, "tags" to tags) +
        """"(.*?)"\s*:\s*"(.*?)"""".toRegex().findAll(metadata).associate {
          it.groupValues[1] to it.groupValues[2]
        })

  override val tags: List<String>
    get() = attributes["tags"]?.split(' ', '\t')?.filter { it.isNotBlank() } ?: emptyList()

  fun withTags(tags: List<String> = this.tags) = TweePassage(
    name,
    attributes + ("tags" to tags.joinToString(" ")),
    contents
  )

  fun withName(newName: PassageName) = TweePassage(
    newName,
    attributes = attributes + ("name" to newName.value),
    contents = contents
  )

  fun toHtmlPassage(): HTMLPassage = HTMLPassage(name.value, attributes, contents)

  override fun hashCode(): Int = name.hashCode()
  override fun equals(other: Any?): Boolean = other is TweePassage && other.name == name
}


code:
package life.xchange.rework

import life.xchange.*
import life.xchange.compatibility.*
import life.xchange.db.*
import life.xchange.model.mod.*
import life.xchange.ui.ColumnDefinition
import life.xchange.ui.DefinedTable
import life.xchange.ui.DefinedTableModel
import life.xchange.util.*
import org.jetbrains.exposed.dao.Entity
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.SqlExpressionBuilder.inList
import org.jetbrains.exposed.sql.transactions.transaction
import java.awt.Dimension
import java.io.File
import java.nio.file.Path
import java.nio.file.Paths
import javax.swing.BoxLayout
import kotlin.io.path.*
import kotlin.sequences.Sequence

@OptIn(ExperimentalPathApi::class)
object ModFileAdder {
  fun removeMod(mod: UnpackedMod, baseDir: Path = unpackedDir, doConfirm: Boolean = true, log: (String) -> Unit = ::println): Boolean = transaction {
    if (doConfirm) {
      val profiles = mod.profiles.toList()
      val message = when {
        profiles.isEmpty() -> "Are you sure you want to delete ${mod.name}?"
        else -> "${mod.name} is enabled in ${profiles.size.pluralProfiles}. Are you sure you want to delete it?"
      }
      if (!confirm(message, "Delete?")) return@transaction false
    }

    doRemoveMod(mod, baseDir, log)

    mod.delete()

    true
  }

  private val Number.pluralProfiles get() = "$this profile${if (toInt() == 1) "" else "s"}"

  fun removeMods(mods: Collection<UnpackedMod>, baseDir: Path = unpackedDir, doConfirm: Boolean = true, log: (String) -> Unit = ::println): Boolean =
    mods.isNotEmpty() && transaction {
      if (doConfirm) {
        val modsInProfiles = mods.filterNot { it.profiles.empty() }.associateWith { it.profiles.count() }

        data class DeleteMod(val name: String, val profileCount: Long)
        class DeleteModsTable(private val columns: List<ColumnDefinition<DeleteMod, *>>) : DefinedTable<DeleteMod, DeleteModsTable.TableModel>() {
          override val tableModel = TableModel()
          inner class TableModel : DefinedTableModel<DeleteMod>(columns) {
            override fun loadItems(): List<DeleteMod> = mods.map { DeleteMod(it.name, modsInProfiles[it] ?: 0) }
          }
          init {
            initTableStuff()
          }
        }

        val message: String
        val columns: List<ColumnDefinition<DeleteMod, *>>
        val nameDefinition = ColumnDefinition(DeleteMod::name, String::class.java, "Mod Name", 200)
        val countDefinition = ColumnDefinition(DeleteMod::profileCount, Long::class.java, "Profiles", 50)

        when {
          modsInProfiles.isEmpty() -> {
            message = "Are you sure you want to delete ${mods.size} mods?"
            columns = listOf(nameDefinition)
          }
          else -> {
            message = "Are you sure you want to delete ${mods.size} mods? ${modsInProfiles.size} of them are enabled in profiles."
            columns = listOf(nameDefinition, countDefinition)
          }
        }

        val panel = buildPanel(axis = BoxLayout.PAGE_AXIS) {
          text(message)
          scroll(DeleteModsTable(columns), Dimension(200, 300))
        }

        if (!confirm(panel, "Delete?")) return@transaction false
      }

      mods.forEach {
        doRemoveMod(it, baseDir, log)
        it.delete()
      }

      true
    }

  private fun doRemoveMod(mod: UnpackedMod, baseDir: Path, log: (String) -> Unit) {
    log("Removing ${mod.name}")
    baseDir.resolve(mod.name).takeIf { it.exists() }?.deleteRecursively()
  }

  fun addModFile(
    file: File,
    skipOverwrite: Boolean = false,
    log: (String) -> Unit = ::println
  ): UnpackedMod? {
    val path = file.toPath()
    val mod = Mod.from(path) ?: error("That's not a mod")

    log("Adding mod ${mod.name} (${path.name})")

    val newDir = mod.baseDirectory

    val existing: UnpackedMod? = transaction { UnpackedMod.find { UnpackedMods.name eq mod.name }.singleOrNull() }

    if (existing != null) {
      if (skipOverwrite || !confirm("$existing is already loaded. Update it from $path?", "Update?")) return null
      log("  Overwriting ${existing.name}")
    }

    return when (mod) {
      is TweeMod -> addTweeMod(mod, existing, newDir, log)
      is JSMod -> addJSMod(mod, existing, newDir, log)
      is ZipMod -> addZipModFile(mod, existing, newDir, log)
    }.also {
      if (Settings.deleteArchiveOnAdd) carefully(file::delete)
    }
  }

  fun refreshAllModInfo(log: ((String) -> Unit)? = null) {
    transaction {
      UnpackedMod.all().filterNot(UnpackedMod::isBaseGame).sortedBy(UnpackedMod::name).forEach { doModInfoRefresh(it, log) }
    }
    log?.invoke(" ")
  }

  private fun Transaction.doModInfoRefresh(unpackedMod: UnpackedMod, log: ((String) -> Unit)? = null) {
    val passages = mutableSetOf<Passage>()

    log?.invoke("Refreshing ${unpackedMod.name} info...")

    unpackedMod.fileWalker.filter { ModFileType.from(it) == ModFileType.TWEE }.forEach {
      passages.addAll(it.charSetSafeRead().tweePassages)
    }

    upsertPassages(unpackedMod, passages)

    updateFiles(unpackedMod)
  }

  fun refreshModInfo(unpackedMod: UnpackedMod, log: ((String) -> Unit)? = null) {
    transaction {
      when {
        unpackedMod.isBaseGame -> processBaseGame()
        else -> doModInfoRefresh(unpackedMod, log)
      }
    }
  }

  private fun updateFiles(unpackedMod: UnpackedMod) {
    val files = unpackedMod.fileWalker
    val existing = unpackedMod.files.map(UnpackedModFile::path).toSet()
    val currentFiles = files.map { it.relativeTo(unpackedMod.baseDirectory).toString() }.toSet()

    val toRemove = existing - currentFiles
    val toAdd = currentFiles - existing

    UnpackedModFiles.deleteWhere { path inList toRemove }
    toAdd.forEach {
      UnpackedModFile.new {
        mod = unpackedMod
        path = it
      }
    }
  }

  fun processBaseGame(baseGamePath: Path = htmlFile, log: ((String) -> Unit)? = null) {
    log?.invoke("Refreshing ${baseGame.name} info...")

    val text = baseGamePath.charSetSafeRead()
    val passages = text.htmlPassages
    val version = findBaseGameVersion(passages) ?: "UNKNOWN"

    transaction {
      baseGame.version = Version(version)
      baseGame.baseGameVersion = VersionSpec(exactly = version)

      upsertPassages(baseGame, passages.values)

      updateFiles(baseGame)
    }
  }

  private fun Path.appendTweeLines(sequence: Sequence<String>) {
    if (!exists()) createFile()
    sequence.dropWhile { !it.startsWith(":: ") }.chunked(1_000).forEach { chunk ->
      appendText(chunk.joinToString("\n") + "\n")
    }
  }

  private fun addMod(modToAdd: Mod, existing: UnpackedMod?, newDir: Path, log: (String) -> Unit, updateFiles: Transaction.(UnpackedMod, (Path) -> Unit) -> Unit): UnpackedMod {
    newDir.createDirectories()

    return transaction {
      val unpackedMod = existing ?: UnpackedMod.new { name = modToAdd.name; enabled = Settings.enableModsOnAdd }
      unpackedMod.version = modToAdd.version

      modToAdd.metadata?.let { metadata ->
        unpackedMod.baseGameVersion = metadata.baseGameVersion
        unpackedMod.author = metadata.author

        upsertRelationships(unpackedMod, metadata)
      }

      modToAdd.metadataReadFailure?.let { failure ->
        unpackedMod.metadataReadFailure = failure

        log("  Failure reading metadata")
        log("    $failure")
      }

      val existingFiles = unpackedMod.files.toMutableSet()

      updateFiles(unpackedMod) { newPath ->
        upsert(existingFiles, newPath.relativeTo(newDir).toString(), { it == path }, UnpackedModFile::new, false) { pathString ->
          mod = unpackedMod
          path = pathString
        }
      }

      existingFiles.forEach(UnpackedModFile::delete)

      commit()

      unpackedMod
    }
  }

  private fun addJSMod(jsMod: JSMod, existing: UnpackedMod?, newDir: Path, log: (String) -> Unit): UnpackedMod =
    addMod(jsMod, existing, newDir, log) { _, unpackFile ->
      newDir.resolve(jsMod.path.name).also { jsFile ->
        log("  Copying file")
        jsMod.path.copyTo(jsFile)

        unpackFile(jsFile)
      }
    }

  private fun addTweeMod(tweeMod: TweeMod, existing: UnpackedMod?, newDir: Path, log: (String) -> Unit): UnpackedMod =
    addMod(tweeMod, existing, newDir, log) { unpackedMod, unpackFile ->
      newDir.resolve("twee.twee").also { tweeFile ->
        tweeFile.deleteIfExists()

        log("  Writing passages")

        upsertPassages(unpackedMod, tweeMod.passages)

        tweeMod.path.charSetSafeLineSequence { tweeFile.appendTweeLines(it) }

        unpackFile(tweeFile)
      }

      tweeMod.metadata?.let { metadata ->
        newDir.resolve("meta.meta").also { metadataFile ->
          log("  Extracting metadata")
          metadataFile.outputStream().buffered().use { metadata.streamTo(it) }

          unpackFile(metadataFile)
        }
      }
    }

  private fun addZipModFile(zipMod: ZipMod, existing: UnpackedMod?, newDir: Path, log: (String) -> Unit): UnpackedMod =
    addMod(zipMod, existing, newDir, log) { unpackedMod, unpackFile ->
      val passages = mutableSetOf<Passage>()

      val existingFileInfo = mutableMapOf<String, FileInfo>()
      existing?.walkFiles {
        existingFileInfo[it.relativeTo(existing.baseDirectory).normalize().toString()] = it.info()
      }

      zipMod.withZipFile { zip ->
        zip.entries().asSequence().filter { !it.isDirectory }.forEach { entry ->
          val entryPath = newDir.resolve(entry.name)
          when (val type = ModFileType.from(entryPath)) {
            ModFileType.ZIP, ModFileType.XCL -> log("  Nested mods are not supported, ignoring")
            else -> {
              val outPath = when (type) {
                ModFileType.META -> newDir.resolve("meta.meta")
                else -> entryPath
              }
              log("  Extracting ${entry.name}")

              val pathKey = outPath.relativeTo(newDir).normalize().toString()
              val existingInfo = existingFileInfo[pathKey]
              val alwaysUpdate = type?.isText == true
              val whenChanged = existingInfo == null || entry.size != existingInfo.fileSize

              if (alwaysUpdate || whenChanged) {
                outPath.createParentDirectories()
                zip.copyEntryToPath(entry, outPath)
              } else {
                log("    Already exists with same file size, assuming unchanged")
              }

              if (type == ModFileType.TWEE) {
                passages.addAll(zip.charSetSafeRead(entry).tweePassages)
              }

              existingFileInfo.remove(pathKey)

              unpackFile(outPath)
            }
          }
        }
      }

      existingFileInfo.forEach { (_, info) ->
        Paths.get(info.path).deleteExisting()
      }

      upsertPassages(unpackedMod, passages)
    }
}

fun <T : Entity<*>, V> Transaction.upsert(
  unpackedMod: UnpackedMod,
  values: Iterator<V>,
  supplier: (UnpackedMod) -> Iterable<T>,
  where: T.(V) -> Boolean,
  create: (T.() -> Unit) -> T,
  doUpdate: Boolean = true,
  update: T.(V) -> Unit = {},
) {
  val existing = supplier(unpackedMod).filterNot { it is ManuallyCreated && it.isManual }.toMutableSet()

  values.forEach { upsert(existing, it, where, create, doUpdate, update) }

  existing.forEach(Entity<*>::delete)
}

fun <T : Entity<*>, V> Transaction.upsert(
  existing: MutableCollection<T>,
  value: V,
  where: T.(V) -> Boolean,
  create: (T.() -> Unit) -> T,
  doUpdate: Boolean = true,
  update: T.(V) -> Unit = {},
) {
  when (val entity = existing.firstOrNull { it.where(value) }) {
    null -> create { update(value) }
    else -> entity.also {
      existing.remove(it)
      if (doUpdate) it.update(value)
    }
  }
}

fun Transaction.upsertPassages(unpackedMod: UnpackedMod, passages: Iterable<Passage>) {
  upsert(unpackedMod, passages.iterator(), UnpackedMod::passages, { it.name.value == name }, UnpackedModPassage::new) {
    mod = unpackedMod
    name = it.name.value
    tags = it.tags
  }
}

fun Transaction.upsertRelationships(unpackedMod: UnpackedMod, metadata: ModMetadata) {
  val relationships = mapOf(
    ModRelationship.REQUIRED to metadata.requiredMods,
    ModRelationship.COMPATIBLE to metadata.compatibleMods,
    ModRelationship.INCOMPATIBLE to metadata.incompatibleMods,
    ModRelationship.EQUIVALENT to metadata.equivalentMods,
  ).flatten()

  upsert(unpackedMod, relationships.iterator(), UnpackedMod::references, { otherModName == it.second.name }, ModReference::new) { (rel, ref) ->
    mod = unpackedMod
    otherModName = ref.name
    versionSpec = ref.version
    order = when (val order = ref.loadMyMod) {
      null -> ModLoadOrder.AFTER.takeIf { rel == ModRelationship.REQUIRED }
      else -> order
    }
    relationship = rel
  }
}