diff --git a/build.sbt b/build.sbt
index 87ec014..1fe9e47 100644
--- a/build.sbt
+++ b/build.sbt
@@ -3,7 +3,7 @@ aether.AetherKeys.aetherOldVersionMethod := true
ThisBuild / organization := "cc.sukazyo"
ThisBuild / organizationName := "A.C. Sukazyo Eyre"
-ThisBuild / scalaVersion := "3.4.0-RC4"
+ThisBuild / scalaVersion := "3.4.1"
resolvers ++= Seq(
"-ws-releases" at "https://mvn.sukazyo.cc/releases"
diff --git a/project/build.properties b/project/build.properties
index 3040987..04267b1 100644
--- a/project/build.properties
+++ b/project/build.properties
@@ -1 +1 @@
-sbt.version=1.9.4
+sbt.version=1.9.9
diff --git a/src/main/resources/assets_morny/langs/_index.hyl b/src/main/resources/assets_morny/langs/_index.hyl
new file mode 100644
index 0000000..5eb07b6
--- /dev/null
+++ b/src/main/resources/assets_morny/langs/_index.hyl
@@ -0,0 +1,30 @@
+en, 2 000 000 000
+ en_us, 328 000 000
+ en_gb, 66 300 000
+ en_sg, 5 000 000
+ en_mt, 1
+ en_ph, 100 000 000
+ en_nz, 5 000 000
+ en_za, 5 000 000
+ en_au, 25 000 000
+ en_ie, 5 000 000
+ en_ca, 25 000 000
+ en_in, 125 000 000
+zh, 1 000 000 000
+ zh_hans, 1 400 000 000
+ zh_cn, 1 300 000 000
+ zh_sg, 5 000 000
+ zh_mo, 2 000 000
+ zh_hant, 50 000 000
+ zh_tw, 23 000 000
+ zh_hk, 7 000 000
+ zh_ancient, 10
+ja, 125 000 000
+ ja_jp, 10 000 000
+fr, 338 000 000
+ fr_fr, 67 000 000
+ fr_ch, 8 000 000
+ fr_be, 11 000 000
+ fr_ca, 7 000 000
+ fr_lu, 1
+mt_mt, 520 000
diff --git a/src/main/resources/assets_morny/langs/en_us.hyt b/src/main/resources/assets_morny/langs/en_us.hyt
new file mode 100644
index 0000000..3382989
--- /dev/null
+++ b/src/main/resources/assets_morny/langs/en_us.hyt
@@ -0,0 +1,53 @@
+ Morny Translations File
+
+%1.0
+&encoding=utf8
+&indent=1
+
+morny.command.info.message.about
+| Morny Cono
+| An assistant rat(micromys minutus) from Annie。
+| ————————————————
+| {about_links}
+
+morny.command.info.version
+| version:
+| - Morny {version_codename}
+| - {version_base}
{version_delta_html}{version_git_suffix}
+| coeur md5_hash:
+| - {md5}
+| coding timestamp:
+| - {time_millis}
+| - {time_utc} [UTC]
+
+morny.command.info.message.runtime
+| system:
+| - {hostname}
+| - {os.name}
{os.arch}
{os.version}
+| java runtime:
+| - {java.vm.vendor}.{java.vm.name}
+| - {java.vm.version}
+| vm memory:
+| - {memory_used_mb}
/ {memory_available_mb}
MB
+| - {cpu_cores}
cores
+| coeur version:
+| - {version_full}
+| - {coeur_md5}
+| - {compile_time_utc} [UTC]
+| - [{compile_time_millis}
]
+| continuous:
+| - {running_duration}
+| - [{running_duration_ms}
]
+| - {startup_time_utc}
+| - [{startup_time_millis}
]
+
+morny.command.info.message.tasks
+| Coeur Task Scheduler:
+| - scheduled tasks: {coeur.tasks.amount}
+| - scheduler status: {coeur.tasks.state}
+| - current runner status: {coeur.tasks.runnerState}
+
+morny.command.info.message.event
+| Event Statistics :
+| in today
+| {event_statistics}""".stripMargin
diff --git a/src/main/resources/assets_morny/langs/zh_cn.hyt b/src/main/resources/assets_morny/langs/zh_cn.hyt
new file mode 100644
index 0000000..6a08f79
--- /dev/null
+++ b/src/main/resources/assets_morny/langs/zh_cn.hyt
@@ -0,0 +1,11 @@
+ Morny Translations File
+
+%1.0
+&encoding=utf8
+&indent=1
+
+morny.command.info.about
+| Morny Cono
+| 来自安妮的侍从小鼠。
+| ————————————————
+| {about_links}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/core/MornyAbout.scala b/src/main/scala/cc/sukazyo/cono/morny/core/MornyAbout.scala
index 22002d0..52113dd 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/core/MornyAbout.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/core/MornyAbout.scala
@@ -1,6 +1,6 @@
package cc.sukazyo.cono.morny.core
-import cc.sukazyo.cono.morny.core.MornyAssets
+import cc.sukazyo.cono.morny.data.MornyAssets
import java.io.IOException
diff --git a/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala b/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala
index f1b0f06..a0deba0 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/core/MornyCoeur.scala
@@ -397,7 +397,7 @@ class MornyCoeur (modules: List[MornyModule])(using val config: MornyConfig)(tes
break(Some(LoginResult(account, remote.username, remote.id)))
} catch
case r: boundary.Break[Option[LoginResult]] => throw r
- case e =>
+ case e: Throwable =>
logger `error`
s"""${e.toLogString}
|login failed"""
diff --git a/src/main/scala/cc/sukazyo/cono/morny/core/MornyAssets.scala b/src/main/scala/cc/sukazyo/cono/morny/data/MornyAssets.scala
similarity index 88%
rename from src/main/scala/cc/sukazyo/cono/morny/core/MornyAssets.scala
rename to src/main/scala/cc/sukazyo/cono/morny/data/MornyAssets.scala
index 76990b2..c5e4c92 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/core/MornyAssets.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/data/MornyAssets.scala
@@ -1,4 +1,4 @@
-package cc.sukazyo.cono.morny.core
+package cc.sukazyo.cono.morny.data
import cc.sukazyo.restools.ResourcesPackage
diff --git a/src/main/scala/cc/sukazyo/cono/morny/data/TelegramImages.scala b/src/main/scala/cc/sukazyo/cono/morny/data/TelegramImages.scala
index b3a1081..e2e4c68 100644
--- a/src/main/scala/cc/sukazyo/cono/morny/data/TelegramImages.scala
+++ b/src/main/scala/cc/sukazyo/cono/morny/data/TelegramImages.scala
@@ -1,7 +1,6 @@
package cc.sukazyo.cono.morny.data
-import cc.sukazyo.cono.morny.core.MornyAssets
-import cc.sukazyo.cono.morny.core.MornyAssets.AssetsException
+import cc.sukazyo.cono.morny.data.MornyAssets.AssetsException
import java.io.IOException
import scala.language.postfixOps
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/Definitions.scala b/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/Definitions.scala
new file mode 100644
index 0000000..75f9250
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/Definitions.scala
@@ -0,0 +1,14 @@
+package cc.sukazyo.cono.morny.util.hytrans
+
+//opaque type Definitions = Map[String, String]
+
+class Definitions (
+ innerData: Map[String, String]
+) extends Map[String, String] {
+
+ def iterator: Iterator[(String, String)] = innerData.iterator
+ def removed (key: String): Map[String, String] = innerData.removed(key)
+ def updated[V1 >: String] (key: String, value: V1): Map[String, V1] = innerData.updated(key, value)
+ def get (key: String): Option[String] = innerData.get(key)
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/LangTag.scala b/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/LangTag.scala
new file mode 100644
index 0000000..c6b7a70
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/LangTag.scala
@@ -0,0 +1,33 @@
+package cc.sukazyo.cono.morny.util.hytrans
+
+case class LangTag (
+ lang: String,
+ priority: Long
+) extends Comparable[LangTag] {
+
+ override def compareTo(o: LangTag): Int =
+ this.priority `compareTo` o.priority
+
+}
+
+object LangTag {
+
+ class IllegalLangTagException (message: String, val original: String)
+ extends IllegalArgumentException(message)
+
+ def normalizeLangTag(tag: String): String =
+ tag.replaceAll("-", "_").toLowerCase
+
+ @throws[IllegalLangTagException]
+ def ensureLangTag (tag: String): String =
+ tag.foreach {
+ case c if c.isLetter =>
+ case '_' =>
+ case ' ' | '\t' | '\r' | '\n' =>
+ throw IllegalLangTagException("Lang Tag cannot contains space", tag)
+ case ill =>
+ throw IllegalLangTagException(s"Illegal character '$ill' in Lang Tag \"$tag\"", tag)
+ }
+ tag
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/LanguageTree.scala b/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/LanguageTree.scala
new file mode 100644
index 0000000..6d288af
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/LanguageTree.scala
@@ -0,0 +1,188 @@
+package cc.sukazyo.cono.morny.util.hytrans
+
+import cc.sukazyo.cono.morny.util.hytrans.LangTag.IllegalLangTagException
+
+import java.io.{PrintWriter, StringWriter}
+import scala.collection.mutable
+import scala.util.boundary
+
+class LanguageTree {
+
+ class Node (val langTag: LangTag) extends Comparable[Node] {
+ private object LangTagOrdering extends Ordering[Node]:
+ override def compare (x: Node, y: Node): Int = y.langTag `compareTo` x.langTag
+
+ override def compareTo (o: Node): Int = this.langTag `compareTo` o.langTag
+
+ private type ChildCol = mutable.SortedSet[Node]
+
+ private var _parent: Option[Node] = None
+ private val _children: ChildCol = mutable.SortedSet.empty(using LangTagOrdering)
+
+ def parent: Option[Node] = _parent
+ def children: ChildCol = _children
+
+ def traverseParent (f: Node => Unit): Unit =
+ _parent.foreach { parent =>
+ f(parent)
+ parent.traverseParent(f)
+ }
+
+ private def traversingTree (f: Node => Unit)(using visited: mutable.Set[Node]): Unit =
+ _children.foreach { child =>
+ if !(visited contains child) then
+ child.traversingTree(f)
+ }
+ if !(visited contains this) then
+ f(this)
+ visited += this
+ this.parent.foreach { parent =>
+ if !(visited contains parent) then
+ parent.traversingTree(f)
+ }
+
+ def traverseTree (f: Node=>Unit): Unit =
+ val visited = mutable.Set.empty[Node]
+ f(this)
+ visited += this
+ traversingTree(f)(using visited)
+
+ @throws[IllegalArgumentException]
+ private infix def setParent (parent: Node): Unit =
+ this._parent = Some(parent)
+ parent.traverseParent { p =>
+ if p == parent then
+ throw new IllegalArgumentException(
+ s"failed set parent ${parent.langTag.lang} to ${langTag.lang}: " +
+ s"Cannot set parent to a child of itself"
+ )
+ this._parent = None
+ }
+
+ def detachParent (): Unit =
+ this._parent.foreach(_.removeChild(this))
+
+ def removeChild (child: Node): Unit =
+ child._parent = None
+ _children -= child
+
+ @throws[IllegalArgumentException]
+ infix def addChild (child: Node): Unit =
+ val child_old_parent = child._parent
+ if child._parent.nonEmpty then
+ child.detachParent()
+ try child setParent this
+ catch case e: IllegalArgumentException =>
+ child_old_parent.foreach(_ addChild child)
+ throw e
+ this._children += child
+
+ @throws[IllegalArgumentException]
+ infix def addChild (child: LangTag): Node =
+ val node = new Node(child)
+ addChild(node)
+ node
+
+ private def printTree (node: Node, prefix: String = "", printer: PrintWriter): Unit = {
+ printer.println(s"$prefix${node.langTag}")
+ node.children.foreach { child =>
+ printTree(child, prefix + " ", printer)
+ }
+ }
+
+ def printTree: String =
+ val s = StringWriter()
+ printTree(this, "", PrintWriter(s))
+ s.toString
+
+ override def toString: String =
+ printTree
+
+ }
+ object Node:
+ def defaultRoot: Node = Node(LangTag("root", 0))
+
+ val root: Node = Node.defaultRoot
+
+ def search (langTag: String): Option[Node] =
+ val _langTag = LangTag.normalizeLangTag(langTag)
+ boundary {
+ root.traverseTree { node =>
+ if node.langTag.lang == _langTag then
+ boundary.break(Some(node))
+ }
+ None
+ }
+
+}
+
+object LanguageTree {
+
+ @throws[IllegalArgumentException]
+ def parseTreeDocument (document: String): LanguageTree = {
+
+ val lines = document.replaceAll("\\r", "").split('\n')
+ val tree = new LanguageTree
+ val root = tree.root
+ import tree.Node
+ var currentLevel = mutable.ListBuffer[Node](root)
+
+ def countHeadingWhitespaceLevel (line: String)(using whitespaceSize: Int): Option[(Int, String)] =
+ val whitespace = line.takeWhile(_.isWhitespace)
+ if whitespace.length % whitespaceSize == 0 then
+ Some(whitespace.length / whitespaceSize -> line.drop(whitespace.length))
+ else None
+
+ for (i <- lines.indices) {
+ val line = lines(i)
+ val line_number = i+1
+ countHeadingWhitespaceLevel(line)(using 2) match
+ case Some((level, content)) =>
+ val langTag: LangTag = content.split(",", 2) match
+ case Array(lang) =>
+ try LangTag(LangTag.ensureLangTag(LangTag.normalizeLangTag(lang)), 0)
+ catch case e: IllegalLangTagException =>
+ throw new IllegalArgumentException(
+ s"illegal lang name at line $line_number: ${e.getMessage}"
+ ).initCause(e)
+ case Array(lang, priority) =>
+ try LangTag(
+ LangTag.ensureLangTag(LangTag.normalizeLangTag(lang)),
+ priority.filterNot(List(' ', ',', '-', '_', '\'').contains(_)).toInt
+ )
+ catch
+ case e: NumberFormatException =>
+ throw new IllegalArgumentException(
+ s"failed parse lang's priority at line $line_number: ${e.getMessage}"
+ ).initCause(e)
+ case e: IllegalLangTagException =>
+ throw new IllegalArgumentException(
+ s"illegal lang name at line $line_number: ${e.getMessage}"
+ ).initCause(e)
+ case _ =>
+ throw new IllegalArgumentException(
+ s"failed parse at line $line_number: line with invalid format."
+ )
+ val node = Node(langTag)
+ if level < (currentLevel.length + 1) then
+ currentLevel = currentLevel take (level + 1)
+ currentLevel.last addChild node
+ currentLevel += node
+ else if level == (currentLevel.length + 1) then
+ currentLevel.last addChild node
+ currentLevel += node
+ else
+ throw new IllegalArgumentException(
+ s"failed parse at line $line_number: line with invalid indentation."
+ )
+ case None =>
+ throw new IllegalArgumentException(
+ s"failed parse at line $line_number: line with invalid indentation."
+ )
+ }
+
+ tree
+
+ }
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/Parser.scala b/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/Parser.scala
new file mode 100644
index 0000000..ec29786
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/Parser.scala
@@ -0,0 +1,41 @@
+package cc.sukazyo.cono.morny.util.hytrans
+
+object Parser {
+
+ def parse (document: String): Definitions = {
+
+ val lines = document.replaceAll("\\r", "").split('\n')
+
+ val keyValues = collection.mutable.Map.empty[String, String]
+ var keyDef: String | Null = null
+ def newValue = StringBuilder()
+ var valueDef: StringBuilder = newValue
+ def addLine (line: String) =
+ valueDef ++= line += '\n'
+ //noinspection TypeAnnotation
+ def saveThis() =
+ if keyDef != null then
+ keyValues += (keyDef -> valueDef.toString.stripSuffix("\n"))
+ keyDef = null
+ valueDef = newValue
+
+ lines.foreach { line =>
+ line.headOption match {
+ case Some(' ') | Some('\t') | None => // empty lines, will be ignored
+ case Some('#') => // comment line, will be ignored
+ case Some('%') => // document meta definition line, currently not supported
+ case Some('|') => // content line
+ addLine(line drop 2)
+ case Some(_) => // a key definition line
+ saveThis()
+ keyDef = line
+ }
+ }
+
+ saveThis()
+
+ Definitions(keyValues.toMap)
+
+ }
+
+}
diff --git a/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/Translations.scala b/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/Translations.scala
new file mode 100644
index 0000000..906fcef
--- /dev/null
+++ b/src/main/scala/cc/sukazyo/cono/morny/util/hytrans/Translations.scala
@@ -0,0 +1,45 @@
+package cc.sukazyo.cono.morny.util.hytrans
+
+import cc.sukazyo.cono.morny.util.var_text.{Var, VarText}
+
+import scala.util.boundary
+
+class Translations (
+ langs: LanguageTree,
+ translations: Map[String, Definitions]
+) {
+
+ def traverse (using lang: String)(f: (LangTag, Definitions) => Unit): Unit = {
+ langs.search(lang)
+ .getOrElse(langs.root)
+ .traverseTree(node =>
+ translations.get(node.langTag.lang)
+ .map(
+ f(node.langTag, _)
+ )
+ )
+ }
+
+ def traverseWithKey (key: String)(using lang: String)(f: (LangTag, Option[String]) => Unit): Unit = {
+ this.traverse (using lang) { (langTag, definitions) =>
+ f(langTag, definitions.get(key))
+ }
+ }
+
+ def get (key: String)(using lang: String): Option[String] = {
+ boundary {
+ traverseWithKey(key) { (_, value) =>
+ if value.nonEmpty then
+ boundary.break(Some(value.get))
+ }
+ None
+ }
+ }
+
+ def trans (key: String)(using lang: String): VarText =
+ VarText(get(key).getOrElse(s"#[$key@$lang]"))
+
+ def trans (key: String, args: Var*)(using lang: String): String =
+ trans(key).render(args*)
+
+}