diff --git a/build.sbt b/build.sbt index a87e563..8c7642e 100644 --- a/build.sbt +++ b/build.sbt @@ -1,5 +1,5 @@ ThisBuild / scalaVersion := "3.3.1" -ThisBuild / version := "0.9.1" +ThisBuild / version := "0.9.2" ThisBuild / organization := "net.maryknollrad" val catsEffectVersion = "3.5.1" diff --git a/cli/src/main/scala/Console.scala b/cli/src/main/scala/Console.scala index 526f0cf..9a28ad8 100644 --- a/cli/src/main/scala/Console.scala +++ b/cli/src/main/scala/Console.scala @@ -2,26 +2,44 @@ import cats.effect.* import cats.syntax.all.* object Console: - def nonEmpty(s: String) = if s.trim().isEmpty then Some("Answer should not be empty") else None + type ConsValidate = String => Either[String, String] + + def nonEmpty(s: String) = if s.isEmpty then Left("Answer should not be empty") else Right(s) private val ip = raw"(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})".r def isIPAddr(s: String) = s match - case ip(a, b, c, d) if a.toInt <= 255 && b.toInt <= 255 && c.toInt <= 255 && d.toInt <= 255 => None - case _ => Some(s"IP address is not in correct format : $s") + case ip(a, b, c, d) if a.toInt <= 255 && b.toInt <= 255 && c.toInt <= 255 && d.toInt <= 255 => Right(s) + case _ => Left(s"IP address is not in correct format : $s") def isNumber(s: String) = scala.util.Try(s.toInt).toOption match - case Some(_) => None - case _ => Some("Number should be given") + case Some(_) => Right(s) + case _ => Left("Number should be given") def numberInRange(r: Range)(s: String) = scala.util.Try(s.toInt).toOption match - case Some(n) if r.contains(n) => None - case _ => Some(s"Number should be given and in range between ${r.start} and ${r.end+1}") + case Some(n) if r.contains(n) => Right(s) + case _ => Left(s"Number should be given and in range between ${r.start} and ${r.end+1}") def lengthInRange(r: Range)(s: String) = - if r.contains(s.length) then None - else Some(s"String too long. Should be less than or equal to ${r.end}") + if r.contains(s.length) then Right(s) + else Left(s"String too long or short. Should be in range between ${r.start} and${r.end}") + + def oneOf(choices: Seq[String]) = + val lowCs = choices.withFilter(_.trim().nonEmpty).map(_.toLowerCase()) + val cSample = lowCs.map(c => + val rest = if c.tail.isEmpty then "" else s"[${c.tail}]" + s"${c.head}$rest") + assert(cSample.length > 1) + val choiceSample = cSample.init.mkString(",") ++ s" or ${cSample.last}" + (s: String) => + nonEmpty(s).flatMap(ss => + val lowS = ss.toLowerCase() + lowCs.find(c => c == lowS || c.head == lowS.head) match + case Some(_) => Right(s) + case _ => Left(s"Please enter $choiceSample")) + + def yesOrNo = oneOf(Seq("yes", "no")) - def printAndRead(msg: String, validateFunction: Option[String => Option[String]] = None, + def printAndRead(msg: String, validateFunction: Option[ConsValidate] = None, default: Option[String] = None): IO[String] = val defaultAppended = default.map(d => s"$msg [$d] : ").getOrElse(msg ++ " : ") val h = @@ -30,9 +48,11 @@ object Console: ans <- IO.readLine yield ans def r: IO[String] = h.flatMap(s => - val amended = if default.nonEmpty && s.trim().isEmpty() then default.get else s + val amended = default match + case Some(d) if s.trim().isEmpty() => d + case _ => s.trim() validateFunction.map(f => f(amended) match - case None => IO.pure(amended) - case Some(msg) => IO.println(msg) *> r + case Right(s) => IO.pure(s) + case Left(msg) => IO.println(msg) *> r ).getOrElse(IO.pure(amended))) r \ No newline at end of file diff --git a/cli/src/main/scala/YaderConf.scala b/cli/src/main/scala/YaderConf.scala index 06c4c33..c36e133 100644 --- a/cli/src/main/scala/YaderConf.scala +++ b/cli/src/main/scala/YaderConf.scala @@ -7,10 +7,11 @@ import net.maryknollrad.d4cs.* import javax.imageio.ImageIO import org.dcm4che3.io.DicomInputStream import net.sourceforge.tess4j.* -import org.apache.commons.text.StringEscapeUtils +import org.apache.commons.text.{StringEscapeUtils => SEU} object YaderConf extends IOApp: - private def config(host: String, port: Int, called: String, calling: String, encoding: String, tpath: String, insts: Seq[String]) = + private def config(host: String, port: Int, called: String, calling: String, encoding: String, + tpath: String, insts: Seq[String], rgx: Option[String]) = s"""host = "$host" |port = $port |called-ae = "$called" @@ -24,7 +25,7 @@ object YaderConf extends IOApp: |# postgres-password = "" | |# install path of tesseract - |tesseract-path = "${StringEscapeUtils.escapeJava(tpath)}" + |tesseract-path = "${SEU.escapeJava(tpath)}" |# to filter other hospital's exam, multiple string values are supported |institution = [${insts.map(inst => s"\"$inst\"").mkString(",")}] |# dose value is ct or DLP @@ -59,8 +60,8 @@ object YaderConf extends IOApp: |show-none = false | |# set default drl category, first category if not specified - |# default-drl-category = "korea" - |""".stripMargin + |# default-drl-category = "korea" + |""".stripMargin ++ rgx.map(r => s"# your regular expression : '${SEU.escapeJava(r)}'").getOrElse("") private def printHello = IO.println("Yader configuration utility\n\n") @@ -109,14 +110,20 @@ object YaderConf extends IOApp: private val tess = new Tesseract() private val windows = "windows.*".r - private val winTess = "C:\\Program Files\\Tesseract-OCR" + private def winTessPath = + val drives = Seq("C:", "D:", "E:") + val paths = Seq("\\Program Files\\Tesseract-OCR", "\\Tesseract-OCR") + drives.flatMap(d => paths.map(p => d ++ p)) + .find(path => { val p = os.Path(path); os.exists(p) && os.isDir(p) }) private val mac = "mac.*".r - private val macBrewTess = "/opt/homebrew/Cellar/tesseract/5.3.3" + private def macBrewTessPath = + val tp = os.Path("/opt/homebrew/Cellar/tesseract") + Option.when(os.exists(tp))(os.list(tp).filter(os.isDir).sorted.last).map(_.toString) private val linux = "linux.*".r private def findTesseract = System.getProperty("os.name").toLowerCase() match - case windows() if os.exists(os.Path(winTess)) => Some(winTess) - case mac() if os.exists(os.Path(macBrewTess)) => Some(macBrewTess) + case windows() => winTessPath + case mac() => macBrewTessPath case _ => None private def saveAndOCR(i: Int, ds: Tuple2[DicomInputStream, DicomInputStream], tp: Option[String] = None) = @@ -143,6 +150,51 @@ object YaderConf extends IOApp: private val unsuccessfulPing = "PING failed.\nServer is not reachable or the sever did not respond." private def printline = IO.println("-" * 80) + import scala.util.matching.Regex, Regex.Match + private def findFirstDoubleStringInMatch(m: Match) = + m.subgroups.flatMap(s => scala.util.Try(s.toDouble).toOption).headOption + + private def testRegex(texts: Seq[String]) = + val getrgx = printAndRead("Enter regular expression to apply", Some(nonEmpty)) + val applyAndFilter = (s: String) => + val rgx = scala.util.matching.Regex(s).unanchored + texts.map(rgx.findFirstMatchIn).zip(texts.zipWithIndex) + .map(t3 => (t3._2._2, t3._2._1, t3._1)) + val printResults = (rs: Seq[(Int, String, Option[Match])]) => rs.traverse : + case (i, txt, Some(m)) => + val matched = + if m.subgroups.isEmpty then + "No matching text found" + else + m.subgroups.mkString(s"Matched ${m.subgroups.length} strings : [\"", "\", \"", "\"]") + val numValue = + findFirstDoubleStringInMatch(m).map(d => s"Found number : $d").getOrElse("Could not find number.") + IO.println(s"$i)") *> IO.println(txt) + *> printline + *> IO.println(matched) + *> IO.println(numValue) + case _ => + IO.println("Unsuccessful matching.") + def run: IO[Option[String]] = + for + rgx <- getrgx + results = applyAndFilter(rgx) + _ <- printResults(results) + yOrN <- printAndRead("Is it correctly matched ? (Yes/No/Quit)", Some(oneOf(Seq("yes", "no", "quit")))) + r <- yOrN match + case "n" | "no" => run + case "y" | "yes" => IO.some(rgx) + case "q" | "quit" => IO.none + case _ => run + yield r + run + + private def split[A, B](s: Seq[(A, Option[B])], acc: (Seq[A], Seq[Option[B]])): (Seq[A], Seq[Option[B]]) = + if s.isEmpty then acc + else + val h = s.head + split(s.tail, (acc._1 :+ h._1, if h._2.isEmpty then acc._2 else acc._2 :+ h._2)) + private def runTestsAndSave(host: String, port: Int, called: String, calling: String, encoding: String) = DicomBase.resources(host, port, called, calling, encoding, false).use : case (cfind, cget) => @@ -173,22 +225,32 @@ object YaderConf extends IOApp: case (dcm, i) => saveAndOCR(i, dcm, otpath).flatMap({ _ match case Some((ans, oinst)) => - printline *> IO.println("Result") *> IO.println(ans) *> printline - *> IO.pure(oinst) + printline *> IO.println(s"Result ($i)") *> IO.println(ans) + *> printline + *> IO.some((ans, oinst)) case _ => - IO.pure(None) + IO.none }) - files = if ocrs.length == 1 then - "1 file. (dose0.png)" - else - s"${ocrs.length} files. (dose0.png - dose${ocrs.length-1}.png)" - insts = ocrs.flatten.distinct.sorted - _ <- printline *> IO.println(s"Stored $files") + succeeded = ocrs.flatten + files = succeeded.length match + case 0 => "0 file." + case 1 => "1 file. (dose0.png)" + case _ => s"${ocrs.length} files. (dose0.png - dose${ocrs.length-1}.png)" + _ <- printline *> IO.println(s"Stored $files") + (texts, insts) = split(succeeded, (Seq.empty, Seq.empty)) + rgx <- if succeeded.length > 0 then + printAndRead("Do you want to check regular expression for ct-info.conf (Yes/No) ?", Some(yesOrNo)).flatMap(_ match + case "yes" | "y" => testRegex(texts) + case _ => IO.none + ) else + IO.none _ <- otpath match case Some(tp) => - IO(os.write.over(os.pwd / "yader.conf", config(host, port, called, calling, encoding, tp, insts))) + IO(os.write.over(os.pwd / "yader.conf", config(host, port, called, calling, encoding, tp, insts.flatten, rgx))) *> printline *> IO.println("Saved yader.conf file.") - case _ => IO.unit + case _ => + IO.println("Failed to save config file. Please check Tesseract install path.") *> + IO.unit yield () } case false => IO.println(unsuccessfulPing)