diff options
author | Norbert Preining <norbert@preining.info> | 2019-09-02 13:46:59 +0900 |
---|---|---|
committer | Norbert Preining <norbert@preining.info> | 2019-09-02 13:46:59 +0900 |
commit | e0c6872cf40896c7be36b11dcc744620f10adf1d (patch) | |
tree | 60335e10d2f4354b0674ec22d7b53f0f8abee672 /support/tlcockpit/src/main |
Initial commit
Diffstat (limited to 'support/tlcockpit/src/main')
17 files changed, 2553 insertions, 0 deletions
diff --git a/support/tlcockpit/src/main/resources/TLCockpit/spinner-small.gif b/support/tlcockpit/src/main/resources/TLCockpit/spinner-small.gif Binary files differnew file mode 100644 index 0000000000..10dfdccdbd --- /dev/null +++ b/support/tlcockpit/src/main/resources/TLCockpit/spinner-small.gif diff --git a/support/tlcockpit/src/main/resources/TLCockpit/spinner.gif b/support/tlcockpit/src/main/resources/TLCockpit/spinner.gif Binary files differnew file mode 100644 index 0000000000..bff38ea7a9 --- /dev/null +++ b/support/tlcockpit/src/main/resources/TLCockpit/spinner.gif diff --git a/support/tlcockpit/src/main/resources/TLCockpit/tlcockpit-128.jpg b/support/tlcockpit/src/main/resources/TLCockpit/tlcockpit-128.jpg Binary files differnew file mode 100644 index 0000000000..fe92ead1e3 --- /dev/null +++ b/support/tlcockpit/src/main/resources/TLCockpit/tlcockpit-128.jpg diff --git a/support/tlcockpit/src/main/resources/TLCockpit/tlcockpit-48.jpg b/support/tlcockpit/src/main/resources/TLCockpit/tlcockpit-48.jpg Binary files differnew file mode 100644 index 0000000000..d9d5dc6ca8 --- /dev/null +++ b/support/tlcockpit/src/main/resources/TLCockpit/tlcockpit-48.jpg diff --git a/support/tlcockpit/src/main/resources/logback.xml b/support/tlcockpit/src/main/resources/logback.xml new file mode 100644 index 0000000000..ee899bf75d --- /dev/null +++ b/support/tlcockpit/src/main/resources/logback.xml @@ -0,0 +1,11 @@ +<configuration> + <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> + <encoder> + <pattern>%d{HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern> + </encoder> + </appender> + + <root level="info"> + <appender-ref ref="STDOUT" /> + </root> +</configuration>
\ No newline at end of file diff --git a/support/tlcockpit/src/main/scala/TLCockpit/ApplicationMain.scala b/support/tlcockpit/src/main/scala/TLCockpit/ApplicationMain.scala new file mode 100644 index 0000000000..4574ac4966 --- /dev/null +++ b/support/tlcockpit/src/main/scala/TLCockpit/ApplicationMain.scala @@ -0,0 +1,1643 @@ +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +package TLCockpit + +import javafx.scene.Node + +import TLCockpit.Utils._ +import TeXLive._ +import TeXLive.OsTools._ + +import scala.collection.{immutable, mutable} +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.{Future, Promise, SyncVar} +import scala.concurrent.ExecutionContext.Implicits.global +import scala.io.Source +import scala.util.{Failure, Success} +import scala.sys.process._ +import scalafx.beans.property.BooleanProperty +import scalafx.scene.text.Font +// ScalaFX imports +import scalafx.event.Event +import scalafx.beans.property.{ObjectProperty, StringProperty} +import scalafx.geometry.{Pos, Orientation} +import scalafx.scene.Cursor +import scalafx.scene.control.Alert.AlertType +import scalafx.scene.image.{Image, ImageView} +import scalafx.scene.input.{KeyCode, KeyEvent, MouseEvent} +import scalafx.scene.paint.Color +// needed see https://github.com/scalafx/scalafx/issues/137 +import scalafx.scene.control.TableColumn._ +import scalafx.scene.control.TreeTableColumn._ +import scalafx.scene.control.TreeItem +import scalafx.scene.control.Menu._ +import scalafx.scene.control.ListCell +import scalafx.Includes._ +import scalafx.application.{JFXApp, Platform} +import scalafx.application.JFXApp.PrimaryStage +import scalafx.geometry.Insets +import scalafx.scene.Scene +import scalafx.scene.layout._ +import scalafx.scene.control._ +import scalafx.event.ActionEvent +import scalafx.collections.ObservableBuffer +import scalafx.collections.ObservableMap + +// JSON support - important load TLPackageJsonProtocol later! +import spray.json._ +import TeXLive.JsonProtocol._ +import org.json4s._ +import org.json4s.jackson.JsonMethods._ + + +// logging +import com.typesafe.scalalogging.LazyLogging +import ch.qos.logback.classic.{Level,Logger} +import org.slf4j.LoggerFactory + +object ApplicationMain extends JFXApp with LazyLogging { + + val version: String = getClass.getPackage.getImplementationVersion + + // parse command line arguments + // nothing => INFO + // -q WARN -qq ERROR + // -d => DEBUG -dd => TRACE + val cmdlnlog: Int = parameters.unnamed.map( { + case "-d" => Level.DEBUG_INT + case "-dd" => Level.TRACE_INT + case "-q" => Level.WARN_INT + case "-qq" => Level.ERROR_INT + case _ => -1 + } ).foldLeft(Level.OFF_INT)(scala.math.min) + if (cmdlnlog == -1) { + // Unknown log level has been passed in, error out + Console.err.println("Unsupported command line argument passed in, terminating.") + Platform.exit() + sys.exit(0) + } + // if nothing has been passed on the command line, use INFO + val newloglevel: Int = if (cmdlnlog == Level.OFF_INT) Level.INFO_INT else cmdlnlog + + LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME). + asInstanceOf[Logger].setLevel(Level.toLevel(newloglevel)) + + logger.trace("starting program tlcockpit") + + val javaVersion = System.getProperty("java.version") + val javaVersionSplit: Array[String] = javaVersion.split('.') + logger.debug(s"Got javaVersion ${javaVersion}") + if (javaVersionSplit.length == 1) { + logger.warn(s"Cannot find Java version from ${javaVersion}, continuing anyway!") + } else { + val major = toInt(javaVersionSplit(0)) + val minor = toInt(javaVersionSplit(1)) + major match { + case Some(i) => + if (major.get == 1) { + minor match { + case Some(j) => + if (minor.get < 8) { + logger.error(s"Java version ${javaVersion} too old, need >= 1.8, terminating!") + Platform.exit() + sys.exit(1) + } else if (minor.get == 8) { + if (BuildInfo.javaVersion != 8) { + logger.warn(s"Build and run versions disagree: build: ${BuildInfo.javaVersion}, run: ${major.get}.${minor.get}, trying anyway!") + } + } + case None => + logger.warn(s"Cannot find Java version from ${javaVersion}, continuing anyway!") + } + } else { + if (major.get > 9) { + if (major.get != BuildInfo.javaVersion) { + logger.warn(s"Build and run versions disagree: build: ${BuildInfo.javaVersion}, run: ${major.get}, trying anyway!") + } + } else { + logger.warn(s"Strange version number, please report: ${javaVersion}, continuing anyway!") + } + } + case None => + logger.warn(s"Cannot find Java version from ${javaVersion}, continuing anyway!") + } + // in all other cases just hope it is fine + logger.info(s"Running on Java Version ${javaVersion}") + } + + + var tlmgrBusy = BooleanProperty(false) + + // necessary action when Window is closed with X or some other operation + override def stopApp(): Unit = { + tlmgr.cleanup() + } + + val iconImage = new Image(getClass.getResourceAsStream("tlcockpit-48.jpg")) + val logoImage = new Image(getClass.getResourceAsStream("tlcockpit-128.jpg")) + val busyImage = new Image(getClass.getResourceAsStream("spinner-small.gif")) + val msgFont = new Font(30f) + + val busySpinner: ImageView = new ImageView(busyImage) { + // scaleX = 0.3 + // scaleY = 0.3 + } + + def SpinnerPlaceHolder(txt: String): Node = { + val tmp = new Label(txt) + tmp.wrapText = true + tmp.opacity = 0.4f + tmp.font = msgFont + tmp.graphic = busySpinner + tmp + } + + val tlpkgs: ObservableMap[String, TLPackageShort] = ObservableMap[String,TLPackageShort]() + val pkgs: ObservableMap[String, TLPackageDisplay] = ObservableMap[String, TLPackageDisplay]() + val upds: ObservableMap[String, TLUpdateDisplay] = ObservableMap[String, TLUpdateDisplay]() + val bkps: ObservableMap[String, Map[String, TLBackupDisplay]] = ObservableMap[String, Map[String,TLBackupDisplay]]() // pkgname -> (version -> TLBackup)* + + val logText: ObservableBuffer[String] = ObservableBuffer[String]() + val outputText: ObservableBuffer[String] = ObservableBuffer[String]() + val errorText: ObservableBuffer[String] = ObservableBuffer[String]() + + val outputfield: TextArea = new TextArea { + editable = false + wrapText = true + text = "" + } + val logfield: TextArea = new TextArea { + editable = false + wrapText = true + text = "" + } + val errorfield: TextArea = new TextArea { + editable = false + wrapText = true + text = "" + } + logText.onChange({ + logfield.text = logText.mkString("\n") + logfield.scrollTop = Double.MaxValue + }) + errorText.onChange({ + errorfield.text = errorText.mkString("\n") + errorfield.scrollTop = Double.MaxValue + if (errorfield.text.value.nonEmpty) { + outerrpane.expanded = true + outerrtabs.selectionModel().select(2) + } + }) + outputText.onChange({ + outputfield.text = outputText.mkString("\n") + outputfield.scrollTop = Double.MaxValue + }) + + val update_all_menu: MenuItem = new MenuItem("Update all") { + val cmd: String = "--all" + { + if (disable_auto_install) " --no-auto-install" else "" } + { + if (disable_auto_removal) " --no-auto-remove" else "" } + { + if (enable_reinstall_forcible) " --reinstall-forcibly-removed" else "" } + onAction = (ae) => callback_update(cmd) + disable = true + } + val update_self_menu: MenuItem = new MenuItem("Update self") { + onAction = (ae) => callback_update("--self") + disable = true + } + + val outerrtabs: TabPane = new TabPane { + minWidth = 400 + tabs = Seq( + new Tab { + text = "Output" + closable = false + content = outputfield + }, + new Tab { + text = "Logging" + closable = false + content = logfield + }, + new Tab { + text = "Errors" + closable = false + content = errorfield + } + ) + } + val outerrpane: TitledPane = new TitledPane { + text = "Debug" + collapsible = true + expanded = false + content = outerrtabs + } + + val cmdline = new TextField() + cmdline.onKeyPressed = { + (ae: KeyEvent) => if (ae.code == KeyCode.Enter) callback_run_cmdline() + } + + + + // read the perl dump of ctan mirrors by converting it to JSON code and parsing it + def parse_ctan_mirrors(tlroot: String): Map[String,Map[String,Seq[String]]] = { + try { + val fileName = tlroot + "/tlpkg/installer/ctan-mirrors.pl" + val foo: String = Source.fromFile(fileName).getLines.mkString("") + val jsonMirrorString = foo.substring(10).replace("=>", ":").replace("""'""", "\"").replace(";", "") + val ast = jsonMirrorString.parseJson + ast.convertTo[Map[String, Map[String, Map[String, Int]]]].map { + contpair => + (contpair._1, contpair._2.map { + countrypair => (countrypair._1, countrypair._2.keys.toSeq) + }) + } + } catch { case e: Exception => + logText.append("Cannot find or parse ctan-mirrors.pl") + logger.debug("Cannot find or parse ctan-mirrors.pl") + Map[String,Map[String,Seq[String]]]() + } + } + + def callback_quit(): Unit = { + tlmgr.cleanup() + Platform.exit() + sys.exit(0) + } + + def callback_run_text(s: String): Unit = { + tlmgr_send(s, (a: String, b: Array[String]) => {}) + } + + def callback_run_cmdline(): Unit = { + tlmgr_send(cmdline.text.value, (status,output) => { + outputText.append(output.mkString("\n")) + outerrpane.expanded = true + outerrtabs.selectionModel().select(0) + }) + } + + def not_implemented_info(): Unit = { + new Alert(AlertType.Warning) { + initOwner(stage) + title = "Warning" + headerText = "This functionality is not implemented by now!" + contentText = "Sorry for the inconveniences." + }.showAndWait() + } + + val OutputBuffer: ObservableBuffer[String] = ObservableBuffer[String]() + var OutputBufferIndex:Int = 0 + val OutputFlushLines = 100 + OutputBuffer.onChange { + // length is number of lines! + var foo = "" + OutputBuffer.synchronized( + if (OutputBuffer.length - OutputBufferIndex > OutputFlushLines) { + foo = OutputBuffer.slice(OutputBufferIndex, OutputBufferIndex + OutputFlushLines).mkString("") + OutputBufferIndex += OutputFlushLines + Platform.runLater { + outputText.append(foo) + } + } + ) + } + def reset_output_buffer(): Unit = { + OutputBuffer.clear() + OutputBufferIndex = 0 + } + + def callback_run_external(ss: Array[String], unbuffered: Boolean = true): Unit = { + outputText.clear() + // logText.clear() + outerrpane.expanded = true + outerrtabs.selectionModel().select(0) + // outputText.append(s"Running ${ss.mkString(" ")}" + (if (unbuffered) " (unbuffered)" else " (buffered)")) + val foo = Future { + ss.foreach { s => + val runcmd = if (isCygwin) "bash -l -c \"" + s + "\"" else s + Platform.runLater { + outputText.append(s"Running ${s}" + (if (unbuffered) " (unbuffered)" else " (buffered)")) + actionLabel.text = s"[${s}]" + } + runcmd ! ProcessLogger( + line => if (unbuffered) Platform.runLater(outputText.append(line)) + else OutputBuffer.synchronized(OutputBuffer.append(line + "\n")), + line => Platform.runLater(logText.append(line)) + ) + } + } + foo.onComplete { + case Success(ret) => + Platform.runLater { + actionLabel.text = "" + outputText.append(OutputBuffer.slice(OutputBufferIndex,OutputBuffer.length).mkString("")) + outputText.append("Completed") + reset_output_buffer() + outputfield.scrollTop = Double.MaxValue + } + case Failure(t) => + Platform.runLater { + actionLabel.text = "" + outputText.append(OutputBuffer.slice(OutputBufferIndex,OutputBuffer.length).mkString("")) + outputText.append("Completed") + reset_output_buffer() + outputfield.scrollTop = Double.MaxValue + errorText.append(s"An ERROR has occurred running one of ${ss.mkString(" ")}: " + t.getMessage) + errorfield.scrollTop = Double.MaxValue + outerrpane.expanded = true + outerrtabs.selectionModel().select(2) + } + } + } + + def callback_about(): Unit = { + new Alert(AlertType.Information) { + initOwner(stage) + title = "About TLCockpit" + graphic = new ImageView(logoImage) + headerText = "TLCockpit version " + version + "\n\nManage your TeX Live with speed!" + contentText = "Copyright 2017-2018 Norbert Preining\nLicense: GPL3+\nSources: https://github.com/TeX-Live/tlcockpit" + }.showAndWait() + } + + // Output of update --self + /* +tlmgr> +update --self +location-url /home/norbert/public_html/tlnet /home/norbert/public_html/tlcritical /home/norbert/Domains/server/texlive.info/contrib/2017 /home/norbert/public_html/tltexjp +total-bytes 381087 +end-of-header +texlive.infra u 4629 46295 381087 ??:?? ??:?? tlcritical - - +end-of-updates +STDERR running mktexlsr ... +STDERR done running mktexlsr. +STDERR running mtxrun --generate ... +STDERR done running mtxrun --generate. +OK +STDOUT (with patch STDERR) tlmgr has been updated, restarting! +protocol 1 +tlmgr> + + The problem with the update function lies in the + protocol 1 + which is not accepted/expected by the update function! + */ + def set_line_update_function(mode: String): Unit = { + var prevName = "" + stdoutLineUpdateFunc = (l:String) => { + logger.trace("DEBUG line update: " + l + "=") + l match { + case u if u.startsWith("location-url") => None + case u if u.startsWith("total-bytes") => None + case u if u.startsWith("end-of-header") => None + // case u if u.startsWith("end-of-updates") => None + case u if u == "OK" => None + case u if u.startsWith("tlmgr>") => None + case u => + if (prevName != "") { + if (mode == "update") { + // parallelism is a pain, I get concurrent access here, but don't know with whom? + // ConcurrentModificationExceptions often occur when you're modifying + // a collection while you are iterating over its elements. + // val newkids: ObservableBuffer[TreeItem[TLUpdateDisplay]] = + // updateTable.root.value.children.filter(_.value.value.name.value != prevName) + // .map(_.asInstanceOf[TreeItem[TLUpdateDisplay]]) + // the last map is only necessary becasue ScalaFX is buggy and does not produce + // proper types here!!! Probably similar to https://github.com/scalafx/scalafx/issues/137 + // updateTable.root.value.children = newkids + upds.remove(prevName) + trigger_update("upds") + } else if (mode == "remove") { + tlpkgs(prevName).installed = false + } else { // install + tlpkgs(prevName).installed = true + } + if (mode == "remove") { + pkgs(prevName).lrev = ObjectProperty[Int](0) + pkgs(prevName).installed = StringProperty("Not installed") + tlpkgs(prevName).lrev = 0 + } else { // install and update + pkgs(prevName).lrev = pkgs(prevName).rrev + pkgs(prevName).installed = StringProperty("Installed") // TODO support Mixed!!! + tlpkgs(prevName).lrev = tlpkgs(prevName).rrev + } + packageTable.refresh() + } + if (u.startsWith("end-of-updates")) { + if (mode == "update") { + Platform.runLater { + updateTable.placeholder = SpinnerPlaceHolder("Post actions running") + actionLabel.text = "[post actions running]" + } + } + // nothing to be done, all has been done above + logger.debug("DEBUG got end of updates") + // } else if (u.startsWith("protocol ")) { + // logger.debug("Got protocol line, seems tlmgr got updated and restarted!") + // // nothing else to be done + // } else if (u.startsWith("tlmgr has been updated, restarting")) { + // logger.debug("tlmgr got updated and restarted, ignoring output") + } else { + logger.debug("DEBUG getting update line") + prevName = if (mode == "update") { + val foo = parse_one_update_line(l) + val pkgname = foo.name.value + upds(pkgname).status = StringProperty("Updating ...") + updateTable.refresh() + pkgname + } else if (mode == "install") { + val fields = l.split("\t") + val pkgname = fields(0) + pkgs(pkgname).installed = StringProperty("Installing ...") + packageTable.refresh() + pkgname + } else { // remove + val fields = l.split("\t") + val pkgname = fields(0) + pkgs(pkgname).installed = StringProperty("Removing ...") + packageTable.refresh() + pkgname + } + } + } + } + } + + def callback_update(s: String): Unit = { + val prevph = updateTable.placeholder.value + set_line_update_function("update") + val cmd = if (s == "--self") "update --self --no-restart" else s"update $s" + tlmgr_send(cmd, (a,b) => { + stdoutLineUpdateFunc = defaultStdoutLineUpdateFunc + Platform.runLater { + updateTable.placeholder = prevph + } + if (s == "--self") { + reinitialize_tlmgr() + // this doesn't work seemingly + // update_upds_list() + } + }) + } + + def callback_remove(pkg: String): Unit = { + set_line_update_function("remove") + tlmgr_send(s"remove $pkg", (_, _) => { + stdoutLineUpdateFunc = defaultStdoutLineUpdateFunc + }) + } + def callback_install(pkg: String): Unit = { + set_line_update_function("install") + tlmgr_send(s"install $pkg", (_,_) => { + stdoutLineUpdateFunc = defaultStdoutLineUpdateFunc + }) + } + + + def callback_restore(str: String, rev: String): Unit = { + tlmgr_send(s"restore --force $str $rev", (_,_) => { + tlpkgs(str).lrev = rev.toLong + pkgs(str).lrev = ObjectProperty[Int](rev.toInt) + packageTable.refresh() + Platform.runLater { actionLabel.text = "[running post actions]" } + }) + } + + bkps.onChange( (obs,chs) => { + val doit = chs match { + case ObservableMap.Add(k, v) => k.toString == "root" + case ObservableMap.Replace(k, va, vr) => k.toString == "root" + case ObservableMap.Remove(k, v) => k.toString == "root" + } + if (doit) { + logger.debug("DEBUG bkps.onChange called new length = " + bkps.keys.toArray.length) + val newroot = new TreeItem[TLBackupDisplay](new TLBackupDisplay("root", "", "")) { + children = bkps + .filter(_._1 != "root") + .map(p => { + val pkgname: String = p._1 + // we sort by negative of revision number, which give inverse sort + val versmap: Array[(String, TLBackupDisplay)] = p._2.toArray.sortBy(-_._2.rev.value.toInt) + + val foo: Seq[TreeItem[TLBackupDisplay]] = versmap.tail.sortBy(-_._2.rev.value.toInt).map { q => + new TreeItem[TLBackupDisplay](q._2) + }.toSeq + new TreeItem[TLBackupDisplay](versmap.head._2) { + children = foo + } + }).toArray.sortBy(_.value.value.name.value) + } + Platform.runLater { + backupTable.root = newroot + } + } + }) + + def view_pkgs_by_collections(pkgbuf: scala.collection.mutable.Map[String, TLPackageDisplay], + binbuf: scala.collection.mutable.Map[String, ArrayBuffer[TLPackageDisplay]], + colbuf: scala.collection.mutable.Map[String, ArrayBuffer[TLPackageDisplay]]): Seq[TreeItem[TLPackageDisplay]] = { + val bin_pkg_map = compute_bin_pkg_mapping(pkgbuf, binbuf) + colbuf.map( + p => { + val colname: String = p._1 + val coldeps: Seq[TLPackageDisplay] = p._2 + val coltlpd: TLPackageDisplay = pkgbuf(colname) + + new TreeItem[TLPackageDisplay](coltlpd) { + children = coldeps.filter(q => tlpkgs(q.name.value).category != "Collection").sortBy(_.name.value).map(sub => { + val binmap: (Boolean, Seq[TLPackageDisplay]) = bin_pkg_map(sub.name.value) + val ismixed: Boolean = binmap._1 + val kids: Seq[TLPackageDisplay] = binmap._2.sortBy(_.name.value) + val ti = if (ismixed) { + // replace installed status with "Mixed" + new TreeItem[TLPackageDisplay]( + new TLPackageDisplay(sub.name.value, sub.lrev.value.toString, sub.rrev.value.toString, sub.shortdesc.value, sub.size.value.toString, "Mixed") + ) { + children = kids.map(new TreeItem[TLPackageDisplay](_)) + } + } else { + new TreeItem[TLPackageDisplay](sub) { + children = kids.map(new TreeItem[TLPackageDisplay](_)) + } + } + ti + } + ) + } + } + ).toSeq + // ArrayBuffer.empty[TreeItem[TLPackageDisplay]] + } + + def view_pkgs_by_names(pkgbuf: scala.collection.mutable.Map[String, TLPackageDisplay], + binbuf: scala.collection.mutable.Map[String, ArrayBuffer[TLPackageDisplay]]): Seq[TreeItem[TLPackageDisplay]] = { + val bin_pkg_map: Map[String, (Boolean, Seq[TLPackageDisplay])] = compute_bin_pkg_mapping(pkgbuf, binbuf) + pkgbuf.map{ + p => { + val binmap: (Boolean, Seq[TLPackageDisplay]) = bin_pkg_map(p._1) + val pkgtlp: TLPackageDisplay = p._2 + val ismixed: Boolean = binmap._1 + val kids: Seq[TLPackageDisplay] = binmap._2.sortBy(_.name.value) + if (ismixed) { + new TreeItem[TLPackageDisplay]( + new TLPackageDisplay(pkgtlp.name.value, pkgtlp.lrev.value.toString, pkgtlp.rrev.value.toString, pkgtlp.shortdesc.value, pkgtlp.size.value.toString, "Mixed") + ) { + children = kids.map(new TreeItem[TLPackageDisplay](_)) + } + } else { + new TreeItem[TLPackageDisplay](pkgtlp) { + children = kids.map(new TreeItem[TLPackageDisplay](_)) + } + } + } + }.toSeq + } + + def compute_bin_pkg_mapping(pkgbuf: scala.collection.mutable.Map[String, TLPackageDisplay], + binbuf: scala.collection.mutable.Map[String, ArrayBuffer[TLPackageDisplay]]): Map[String, (Boolean, Seq[TLPackageDisplay])] = { + pkgbuf.map { + p => { + val kids: Seq[TLPackageDisplay] = if (binbuf.keySet.contains(p._2.name.value)) { + binbuf(p._2.name.value) + } else { + Seq() + } + // for ismixed we && all the installed status. If all are installed, we get true + val allinstalled = (kids :+ p._2).foldRight[Boolean](true)((k, b) => k.installed.value == "Installed" && b) + val someinstalled = (kids :+ p._2).exists(_.installed.value == "Installed") + val mixedinstalled = !allinstalled && someinstalled + (p._1, (mixedinstalled, kids)) + } + }.toMap + } + pkgs.onChange( (obs,chs) => { + val doit = chs match { + case ObservableMap.Add(k, v) => k.toString == "root" + case ObservableMap.Replace(k, va, vr) => k.toString == "root" + // don't call the trigger on root removal! + // case ObservableMap.Remove(k, v) => k.toString == "root" + case ObservableMap.Remove(k,v) => false + } + if (doit) { + logger.debug("DEBUG: entering pkgs.onChange") + // val pkgbuf: ArrayBuffer[TLPackageDisplay] = ArrayBuffer.empty[TLPackageDisplay] + val pkgbuf = scala.collection.mutable.Map.empty[String, TLPackageDisplay] + val binbuf = scala.collection.mutable.Map.empty[String, ArrayBuffer[TLPackageDisplay]] + val colbuf = scala.collection.mutable.Map.empty[String, ArrayBuffer[TLPackageDisplay]] + pkgs.foreach(pkg => { + // complicated part, determine whether it is a sub package or not! + // we strip of initial texlive. prefixes to make sure we deal + // with real packages + if ((pkg._1.startsWith("texlive.infra") && pkg._1.stripPrefix("texlive.infra").contains(".")) || + pkg._1.stripPrefix("texlive.infra").contains(".")) { + val foo: Array[String] = if (pkg._1.startsWith("texlive.infra")) + Array("texlive.infra", pkg._1.stripPrefix("texlive.infra")) + else + pkg._1.split('.') + val pkgname = foo(0) + if (pkgname != "") { + val binname = foo(1) + if (binbuf.keySet.contains(pkgname)) { + binbuf(pkgname) += pkg._2 + } else { + binbuf(pkgname) = ArrayBuffer[TLPackageDisplay](pkg._2) + } + } + } else if (pkg._1 == "root") { + // ignore the dummy root element, + // only used for speeding up event handling + } else { + pkgbuf(pkg._1) = pkg._2 + } + }) + // Another round to propagate purely .win32 packages like wintools.win32 or + // dviout.win32 from binpkg status to full pkg, since they don't have + // accompanying main packages + binbuf.foreach(p => { + if (!pkgbuf.contains(p._1)) { + if (p._2.length > 1) { + errorText += "THAT SHOULD NOT HAPPEN: >>" + p._1 + "<< >>" + p._2.length + "<<" + p._2.foreach(f => logger.trace("-> " + f.name.value)) + } else { + logger.trace("DEBUG Moving " + p._2.head.name.value + " up to pkgbuf " + p._1) + pkgbuf(p._2.head.name.value) = p._2.head + // TODO will this work out with the foreach loop above??? + binbuf -= p._1 + } + } + }) + // another loop to collection and fill the collections buffer + pkgs.foreach(pkg => { + if (tlpkgs.contains(pkg._1)) { + if (tlpkgs(pkg._1).category == "Collection") { + val foo: immutable.Seq[String] = tlpkgs(pkg._1).depends + colbuf(pkg._1) = ArrayBuffer[TLPackageDisplay]() + // TODO we need to deal with packages that get removed!!! + // for now just make sure we don't add them here! + colbuf(pkg._1) ++= foo.filter(pkgbuf.contains).map(pkgbuf(_)) + } + } else if (pkg._1 == "root") { + // do nothing + } else { + errorText += "Cannot find information for " + pkg._1 + } + }) + // now we have all normal packages in pkgbuf, and its sub-packages in binbuf + // we need to create TreeItems + val viewkids: Seq[TreeItem[TLPackageDisplay]] = + if (ViewByPkg.selected.value) + view_pkgs_by_names(pkgbuf, binbuf) + else + view_pkgs_by_collections(pkgbuf, binbuf, colbuf) + logger.debug("DEBUG: leaving pkgs.onChange before runLater") + Platform.runLater { + packageTable.root = new TreeItem[TLPackageDisplay](new TLPackageDisplay("root", "0", "0", "", "0", "")) { + expanded = true + children = viewkids.sortBy(_.value.value.name.value) + } + } + } + }) + upds.onChange( (obs, chs) => { + var doit = chs match { + case ObservableMap.Add(k, v) => k.toString == "root" + case ObservableMap.Replace(k, va, vr) => k.toString == "root" + case ObservableMap.Remove(k, v) => k.toString == "root" + } + if (doit) { + val infraAvailable = upds.keys.exists(_.startsWith("texlive.infra")) + // only allow for updates of other packages when no infra update available + val updatesAvailable = !infraAvailable && upds.keys.exists(p => !p.startsWith("texlive.infra") && !(p == "root")) + val newroot = new TreeItem[TLUpdateDisplay](new TLUpdateDisplay("root", "", "", "", "", "")) { + children = upds + .filter(_._1 != "root") + .map(p => new TreeItem[TLUpdateDisplay](p._2)) + .toArray + .sortBy(_.value.value.name.value) + } + Platform.runLater { + update_self_menu.disable = !infraAvailable + update_all_menu.disable = !updatesAvailable + updateTable.root = newroot + if (infraAvailable) { + texlive_infra_update_warning() + } + } + } + }) + + def texlive_infra_update_warning(): Unit = { + new Alert(AlertType.Warning) { + initOwner(stage) + title = "TeX Live Infrastructure Update Available" + headerText = "Updates to the TeX Live Manager (Infrastructure) available." + contentText = "Please use \"Update self\" from the Menu!" + }.showAndWait() + } + + def load_backups_update_bkps_view(): Unit = { + val prevph = backupTable.placeholder.value + backupTable.placeholder = SpinnerPlaceHolder("Loading backups") + tlmgr_send("restore --json", (status, lines) => { + val jsonAst = lines.mkString("").parseJson + val backups: Map[String, Map[String, TLBackupDisplay]] = + jsonAst + .convertTo[List[TLBackup]] + .groupBy[String](_.name) + .map(p => (p._1, p._2.map(q => (q.rev, new TLBackupDisplay(q.name, q.rev, q.date))).toMap)) + bkps.clear() + bkps ++= backups + trigger_update("bkps") + backupTable.placeholder = prevph + }) + } + + def update_pkgs_view(): Unit = { + val newpkgs: Map[String, TLPackageDisplay] = + tlpkgs + .filter { p => + val searchTerm = searchEntry.text.value.toLowerCase + p._1.toLowerCase.contains(searchTerm) || + p._2.shortdesc.getOrElse("").toLowerCase.contains(searchTerm) + } + .map { p => + (p._2.name, + new TLPackageDisplay( + p._2.name, p._2.lrev.toString, p._2.rrev.toString, + p._2.shortdesc.getOrElse(""), "0", if (p._2.installed) "Installed" else "Not installed" + ) + ) + }.toMap + pkgs.clear() + pkgs ++= newpkgs + trigger_update("pkgs") + } + + /* + def load_tlpdb_update_pkgs_view():Unit = { + val prevph = packageTable.placeholder.value + packageTable.placeholder = SpinnerPlaceHolder("Loading database") + tlmgr_send("info --json", (status, lines) => { + logger.debug(s"load tlpdb update pkgs: got status ${status}") + logger.trace(s"load tlpdb update pkgs: got lines = " + lines.head) + val jsonAst = lines.mkString("").parseJson + tlpkgs.clear() + tlpkgs ++= jsonAst.convertTo[List[TLPackage]].map { p => (p.name, p)} + update_pkgs_view() + packageTable.placeholder = prevph + }) + } + */ + + def load_tlpdb_update_pkgs_view_no_json():Unit = { + val prevph = packageTable.placeholder.value + packageTable.placeholder = SpinnerPlaceHolder("Loading database") + tlmgr_send("info --data name,localrev,remoterev,category,size,installed,depends,shortdesc", (status, lines) => { + logger.debug(s"load tlpdb update (no json) pkgs: got status ${status}") + logger.trace(s"load tlpdb update (no json) pkgs: got lines = " + lines.head) + val newtlpkgs: Map[String, TLPackageShort] = lines.map(l => { + // logger.debug(s"got line >>>${l}<<<") + val fields = l.split(",",8) + val pkgname = fields(0) + val shortdesc = fields(7).stripPrefix(""""""").stripSuffix(""""""").replaceAll("""\\"""",""""""") + val lrev = fields(1).toLong + val rrev = fields(2).toLong + val cat = fields(3) + val size = fields(4).toLong + val installed = fields(5) == "1" + val depends = fields(6).split(":").toList + TLPackageShort(pkgname, if (shortdesc == "") None else Some(shortdesc), lrev, rrev, cat, depends, installed, lrev > 0) + }).map{ p => + logger.trace("Constructed TLPackage: " + p) + (p.name, p) + }.toMap + tlpkgs.clear() + tlpkgs ++= newtlpkgs + update_pkgs_view() + packageTable.placeholder = prevph + }) + } + + def parse_one_update_line(l: String): TLUpdateDisplay = { + logger.debug(s"Got update line >>${l}") + val fields = l.split("\t") + logger.debug(s"Splitting into ${fields}") + val pkgname = fields(0) + val status = fields(1) match { + case "d" => "Removed on server" + case "f" => "Forcibly removed" + case "u" => "Update available" + case "r" => "Local is newer" + case "a" => "New on server" + case "i" => "Not installed" + case "I" => "Reinstall" + } + val localrev = fields(2) + val serverrev = fields(3) + val size = if (fields(1) == "d") "0" else humanReadableByteSize(fields(4).toLong) + val runtime = fields(5) + val esttot = fields(6) + val tag = fields(7) + val lctanv = fields(8) + val rctanv = fields(9) + val tlpkg: TLPackageDisplay = pkgs(pkgname) + val shortdesc = tlpkg.shortdesc.value + new TLUpdateDisplay(pkgname, status, + localrev + { + if (lctanv != "-") s" ($lctanv)" else "" + }, + serverrev + { + if (rctanv != "-") s" ($rctanv)" else "" + }, + shortdesc, size) + } + + def load_updates_update_upds_view(): Unit = { + val prevph = updateTable.placeholder.value + updateTable.placeholder = SpinnerPlaceHolder("Loading updates") + tlmgr_send("update --list", (status, lines) => { + logger.debug(s"got updates length ${lines.length}") + logger.trace(s"tlmgr last output = ${lines}") + val newupds: Map[String, TLUpdateDisplay] = lines.filter { + case u if u.startsWith("location-url") => false + case u if u.startsWith("total-bytes") => false + case u if u.startsWith("end-of-header") => false + case u if u.startsWith("end-of-updates") => false + case u => true + }.map { l => + val foo = parse_one_update_line(l) + (foo.name.value, foo) + }.toMap + val infraAvailable = newupds.keys.exists(_.startsWith("texlive.infra")) + upds.clear() + if (infraAvailable) { + upds ++= Seq( ("texlive.infra", newupds("texlive.infra") ) ) + } else { + upds ++= newupds + } + trigger_update("upds") + updateTable.placeholder = prevph + }) + } + + def trigger_update(s:String): Unit = { + logger.debug("DEBUG: Triggering update of " + s) + if (s == "pkgs") { + pkgs("root") = new TLPackageDisplay("root", "0", "0", "", "0", "") + } else if (s == "upds") { + upds("root") = new TLUpdateDisplay("root", "", "", "", "", "") + } else if (s == "bkps") { + bkps("root") = Map[String, TLBackupDisplay](("0", new TLBackupDisplay("root", "0", "0"))) + } + } + + def doListView(files: Seq[String], clickable: Boolean): scalafx.scene.Node = { + if (files.length <= 5) { + val vb = new VBox() + vb.children = files.map { f => + val fields = f.split(" ") + new Label(fields(0)) { + if (clickable) { + textFill = Color.Blue + onMouseClicked = { me: MouseEvent => OsTools.openFile(tlmgr.tlroot + "/" + fields(0)) } + cursor = Cursor.Hand + } + } + } + vb + } else { + val vb = new ListView[String] {} + vb.minHeight = 150 + vb.prefHeight = 150 + vb.maxHeight = 200 + vb.vgrow = Priority.Always + vb.orientation = Orientation.Vertical + vb.cellFactory = { p => { + val foo = new ListCell[String] + foo.item.onChange { (_, _, str) => foo.text = str } + if (clickable) { + foo.textFill = Color.Blue + foo.onMouseClicked = { me: MouseEvent => OsTools.openFile(tlmgr.tlroot + "/" + foo.text.value) } + foo.cursor = Cursor.Hand + } + foo + } + } + // vb.children = docFiles.map { f => + vb.items = ObservableBuffer(files.map { f => + val fields = f.split(" ") + fields(0) + }) + vb + } + } + + val mainMenu: Menu = new Menu("TLCockpit") { + items = List( + // temporarily move here as we disable the Help menu + new MenuItem("About") { + onAction = (ae) => callback_about() + }, + new MenuItem("Exit") { + onAction = (ae: ActionEvent) => callback_quit() + }) + } + val toolsMenu: Menu = new Menu("Tools") { + items = List( + new MenuItem("Update filename databases ...") { + onAction = (ae) => { + callback_run_external(Array("mktexlsr", "mtxrun --generate")) + // callback_run_external("mtxrun --generate") + } + }, + // too many lines are quickly output -> GUI becomes hanging until + // all the callbacks are done - call fmtutil with unbuffered = false + new MenuItem("Rebuild all formats ...") { onAction = (ae) => callback_run_external(Array("fmtutil --sys --all"), false) }, + new MenuItem("Update font map database ...") { + onAction = (ae) => callback_run_external(Array("updmap --sys")) + } + ) + } + val ViewByPkg = new RadioMenuItem("by package name") { + onAction = (ae) => { + searchEntry.text = "" + update_pkgs_view() + } + } + val ViewByCol = new RadioMenuItem("by collections") { + onAction = (ae) => { + searchEntry.text = "" + update_pkgs_view() + } + } + ViewByPkg.selected = true + ViewByCol.selected = false + val pkgsMenu: Menu = new Menu("Packages") { + val foo = new ToggleGroup + foo.toggles = Seq(ViewByPkg, ViewByCol) + items = List(ViewByPkg, ViewByCol) + } + var disable_auto_removal = false + var disable_auto_install = false + var enable_reinstall_forcible = false + val updMenu: Menu = new Menu("Updates") { + items = List( + update_all_menu, + update_self_menu, + new SeparatorMenuItem, + new CheckMenuItem("Disable auto removal") { onAction = (ae) => disable_auto_removal = selected.value }, + new CheckMenuItem("Disable auto install") { onAction = (ae) => disable_auto_install = selected.value }, + new CheckMenuItem("Reinstall forcibly removed") { onAction = (ae) => enable_reinstall_forcible = selected.value } + ) + } + + + def callback_general_options(): Unit = { + tlmgr_send("option showall --json", (status, lines) => { + val jsonAst = lines.mkString("").parseJson + val tlpdopts: List[TLOption] = jsonAst.convertTo[List[TLOption]] + Platform.runLater { + val dg = new OptionsDialog(tlpdopts) + dg.showAndWait() match { + case Some(changedOpts) => + changedOpts.foreach(p => { + // don't believe it or not, but \" does *NOT* work in Scala in + // interpolated strings, and it seems there is no better way + // than that one ... + tlmgr_send(s"option ${p._1} ${'"'}${p._2}${'"'}", (_,_) => None) + }) + case None => + } + } + }) + } + + + def callback_paper(): Unit = { + tlmgr_send("paper --json", (status, lines) => { + val jsonAst = lines.mkString("").parseJson + val paperconfs: Map[String, TLPaperConf] = jsonAst.convertTo[List[TLPaperConf]].map { p => (p.program, p) }.toMap + val currentPapers: Map[String, String] = paperconfs.mapValues(p => p.options.head) + Platform.runLater { + val dg = new PaperDialog(paperconfs) + dg.showAndWait() match { + case Some(newPapers) => + logger.debug(s"Got result ${newPapers}") + // collect changed settings + val changedPapers = newPapers.filter(p => currentPapers(p._1) != p._2) + logger.debug(s"Got changed papers ${changedPapers}") + changedPapers.foreach(p => { + tlmgr_send(s"paper ${p._1} paper ${p._2}", (_,_) => None) + }) + case None => + } + } + }) + } + + def callback_pkg_info(pkg: String) = { + tlmgr_send(s"info --json ${pkg}", (status, lines) => { + try { + val jsonAst = lines.mkString("").parseJson + val tlpkgs = jsonAst.convertTo[List[TLPackage]] + Platform.runLater { + new PkgInfoDialog(tlpkgs.head).showAndWait() + } + } catch { + case foo : spray.json.DeserializationException => + new Alert(AlertType.Warning) { + initOwner(stage) + title = "Warning" + headerText = s"Cannot display information for ${pkg}" + contentText = s"We couldn't parse the output of\ntlmgr info --json ${pkg}\n" + }.showAndWait() + case bar : ArrayIndexOutOfBoundsException => + new Alert(AlertType.Warning) { + initOwner(stage) + title = "Warning" + headerText = s"Cannot find information for ${pkg}" + contentText = s"We couldn't find information for ${pkg}\n" + }.showAndWait() + } + }) + } + + val optionsMenu: Menu = new Menu("Options") { + items = List( + new MenuItem("General ...") { onAction = (ae) => callback_general_options() }, + new MenuItem("Paper ...") { onAction = (ae) => callback_paper() }, + /* new MenuItem("Platforms ...") { disable = true; onAction = (ae) => not_implemented_info() }, + new SeparatorMenuItem, + new CheckMenuItem("Expert options") { disable = true }, + new CheckMenuItem("Enable debugging options") { disable = true }, + new CheckMenuItem("Disable auto-install of new packages") { disable = true }, + new CheckMenuItem("Disable auto-removal of server-deleted packages") { disable = true } */ + ) + } + val expertPane: TitledPane = new TitledPane { + text = "Experts only" + collapsible = true + expanded = false + content = new VBox { + spacing = 10 + children = List( + new HBox { + spacing = 10 + alignment = Pos.CenterLeft + children = List( + new Label("tlmgr shell command:"), + cmdline, + new Button { + text = "Go" + onAction = (event: ActionEvent) => callback_run_cmdline() + } + ) + } + ) + } + } + val updateTable: TreeTableView[TLUpdateDisplay] = { + val colName = new TreeTableColumn[TLUpdateDisplay, String] { + text = "Package" + cellValueFactory = { _.value.value.value.name } + prefWidth = 150 + } + val colStatus = new TreeTableColumn[TLUpdateDisplay, String] { + text = "Status" + cellValueFactory = { _.value.value.value.status } + prefWidth = 120 + } + val colDesc = new TreeTableColumn[TLUpdateDisplay, String] { + text = "Description" + cellValueFactory = { _.value.value.value.shortdesc } + prefWidth = 300 + } + val colLRev = new TreeTableColumn[TLUpdateDisplay, String] { + text = "Local rev" + cellValueFactory = { _.value.value.value.lrev } + prefWidth = 100 + } + val colRRev = new TreeTableColumn[TLUpdateDisplay, String] { + text = "Remote rev" + cellValueFactory = { _.value.value.value.rrev } + prefWidth = 100 + } + val colSize = new TreeTableColumn[TLUpdateDisplay, String] { + text = "Size" + cellValueFactory = { _.value.value.value.size } + prefWidth = 70 + } + val table = new TreeTableView[TLUpdateDisplay]( + new TreeItem[TLUpdateDisplay](new TLUpdateDisplay("root","","","","","")) { + expanded = false + }) { + columns ++= List(colName, colStatus, colDesc, colLRev, colRRev, colSize) + } + colDesc.prefWidth.bind(table.width - colName.width - colLRev.width - colRRev.width - colSize.width - colStatus. width - 15) + table.prefHeight = 300 + table.vgrow = Priority.Always + table.placeholder = new Label("No updates available") { + opacity = 0.4f + font = msgFont + } + table.showRoot = false + table.rowFactory = { _ => + val row = new TreeTableRow[TLUpdateDisplay] {} + val infoMI = new MenuItem("Info") { + onAction = (ae) => callback_pkg_info(row.item.value.name.value) + } + val updateMI = new MenuItem("Update") { + onAction = (ae) => callback_update(row.item.value.name.value) + } + val installMI = new MenuItem("Install") { + onAction = (ae) => callback_install(row.item.value.name.value) + } + val removeMI = new MenuItem("Remove") { + onAction = (ae) => callback_remove(row.item.value.name.value) + } + val ctm = new ContextMenu(infoMI, updateMI, installMI, removeMI) + row.item.onChange { (_,_,newTL) => + if (newTL != null) { + if (newTL.status.value == "New on server") { + installMI.disable = false + removeMI.disable = true + updateMI.disable = true + } else if (newTL.status.value == "Removed on server") { + installMI.disable = true + removeMI.disable = false + updateMI.disable = true + } else { + installMI.disable = true + removeMI.disable = false + updateMI.disable = false + } + } + } + row.contextMenu = ctm + row + } + table + } + val packageTable: TreeTableView[TLPackageDisplay] = { + val colName = new TreeTableColumn[TLPackageDisplay, String] { + text = "Package" + cellValueFactory = { _.value.value.value.name } + prefWidth = 150 + } + val colDesc = new TreeTableColumn[TLPackageDisplay, String] { + text = "Description" + cellValueFactory = { _.value.value.value.shortdesc } + prefWidth = 300 + } + val colInst = new TreeTableColumn[TLPackageDisplay, String] { + text = "Installed" + cellValueFactory = { _.value.value.value.installed } + prefWidth = 100 + } + val table = new TreeTableView[TLPackageDisplay]( + new TreeItem[TLPackageDisplay](new TLPackageDisplay("root","0","0","","0","")) { + expanded = false + }) { + columns ++= List(colName, colDesc, colInst) + } + colDesc.prefWidth.bind(table.width - colInst.width - colName.width - 15) + table.prefHeight = 300 + table.showRoot = false + table.vgrow = Priority.Always + table.rowFactory = { p => + val row = new TreeTableRow[TLPackageDisplay] {} + val infoMI = new MenuItem("Info") { + onAction = (ae) => callback_pkg_info(row.item.value.name.value) + } + val installMI = new MenuItem("Install") { + onAction = (ae) => callback_install(row.item.value.name.value) + } + val removeMI = new MenuItem("Remove") { + onAction = (ae) => callback_remove(row.item.value.name.value) + } + val ctm = new ContextMenu(infoMI, installMI, removeMI) + row.item.onChange { (_,_,newTL) => + if (newTL != null) { + val is_installed: Boolean = !(newTL.installed.value == "Not installed") + installMI.disable = is_installed + removeMI.disable = !is_installed + } + } + row.contextMenu = ctm + row + } + table + } + val backupTable: TreeTableView[TLBackupDisplay] = { + val colName = new TreeTableColumn[TLBackupDisplay, String] { + text = "Package" + cellValueFactory = { _.value.value.value.name } + prefWidth = 150 + } + val colRev = new TreeTableColumn[TLBackupDisplay, String] { + text = "Revision" + cellValueFactory = { _.value.value.value.rev } + prefWidth = 100 + } + val colDate = new TreeTableColumn[TLBackupDisplay, String] { + text = "Date" + cellValueFactory = { _.value.value.value.date } + prefWidth = 300 + } + val table = new TreeTableView[TLBackupDisplay]( + new TreeItem[TLBackupDisplay](new TLBackupDisplay("root","","")) { + expanded = false + }) { + columns ++= List(colName, colRev, colDate) + } + colDate.prefWidth.bind(table.width - colRev.width - colName.width - 15) + table.prefHeight = 300 + table.showRoot = false + table.vgrow = Priority.Always + table.placeholder = new Label("No backups available") { + opacity = 0.4f + font = msgFont + } + table.rowFactory = { _ => + val row = new TreeTableRow[TLBackupDisplay] {} + val ctm = new ContextMenu( + new MenuItem("Info") { + onAction = (ae) => callback_pkg_info(row.item.value.name.value) + }, + new MenuItem("Restore") { + onAction = (ae) => callback_restore(row.item.value.name.value, row.item.value.rev.value) + } + ) + row.contextMenu = ctm + row + } + table + } + val searchEntry = new TextField() + searchEntry.hgrow = Priority.Sometimes + searchEntry.onKeyPressed = { + (ae: KeyEvent) => if (ae.code == KeyCode.Enter) update_pkgs_view() + } + val statusLabel = new Label("Idle") + val actionLabel = new Label("") { + hgrow = Priority.Always + maxWidth = Double.MaxValue + } + val statusBox = new HBox { + children = Seq( + new Label("Tlmgr status:") { + vgrow = Priority.Always + alignmentInParent = Pos.CenterLeft + }, + statusLabel, + actionLabel + ) + maxWidth = Double.MaxValue + hgrow = Priority.Always + alignment = Pos.Center + alignmentInParent = Pos.CenterLeft + padding = Insets(10) + spacing = 10 + } + + val searchBox = new HBox { + children = Seq( + new Label("Search:") { + vgrow = Priority.Always + alignmentInParent = Pos.CenterLeft + }, + searchEntry, + new Button("Go") { + onAction = _ => update_pkgs_view() + }, + new Button("Reset") { + onAction = _ => { + searchEntry.text = "" + update_pkgs_view() + } + } + ) + alignment = Pos.Center + // do not add padding at the bottom as we have one from the status field + padding = Insets(10,10,0,10) + spacing = 10 + } + val pkgsContainer = new VBox { + children = Seq(packageTable,searchBox) + } + + val pkgstabs: TabPane = new TabPane { + minWidth = 400 + vgrow = Priority.Always + tabs = Seq( + new Tab { + text = "Packages" + closable = false + content = pkgsContainer + }, + new Tab { + text = "Updates" + closable = false + content = updateTable + }, + new Tab { + text = "Backups" + closable = false + content = backupTable + } + ) + } + // val spacerMenu: Menu = new Menu(" ") + // spacerMenu.disable = true + // spacerMenu.hgrow = Priority.Always + val menuBar: MenuBar = new MenuBar { + useSystemMenuBar = true + // menus.addAll(mainMenu, optionsMenu, helpMenu) + menus.addAll(mainMenu, pkgsMenu, toolsMenu, optionsMenu) + } + var updLoaded = false + var bckLoaded = false + pkgstabs.selectionModel().selectedItem.onChange( + (a,b,c) => { + if (a.value.text() == "Backups") { + if (!bckLoaded) { + load_backups_update_bkps_view() + bckLoaded = true + } + menuBar.menus = Seq(mainMenu, toolsMenu, optionsMenu) + } else if (a.value.text() == "Updates") { + // only update if not done already + if (!updLoaded) { + load_updates_update_upds_view() + updLoaded = true + } + menuBar.menus = Seq(mainMenu, updMenu, toolsMenu, optionsMenu) + } else if (a.value.text() == "Packages") { + menuBar.menus = Seq(mainMenu, pkgsMenu, toolsMenu, optionsMenu) + } + } + ) + + + stage = new PrimaryStage { + title = "TLCockpit" + scene = new Scene { + root = { + // val topBox = new HBox { + // children = List(menuBar, statusLabel) + // } + // topBox.hgrow = Priority.Always + // topBox.maxWidth = Double.MaxValue + val topBox = menuBar + val centerBox = new VBox { + padding = Insets(10) + children = List(pkgstabs, statusBox, expertPane, outerrpane) + } + new BorderPane { + // padding = Insets(20) + top = topBox + // left = leftBox + center = centerBox + // bottom = bottomBox + } + } + } + icons.add(iconImage) + } + + stage.onCloseRequest = (e: Event) => callback_quit() + + stage.width = 800 + + + var currentPromise = Promise[(String,Array[String])]() + val pendingJobs = scala.collection.mutable.Queue[(String,(String, Array[String]) => Unit)]() + + def initialize_tlmgr(): TlmgrProcess = { + tlmgrBusy.value = true + actionLabel.text = "[initializing tlmgr]" + // create new sync vars for each process + val outputLine = new SyncVar[String] + val errorLine = new SyncVar[String] + + val tt = new TlmgrProcess( + (s: String) => { + logger.trace(s"outputline put ${s}") + outputLine.put(s) + }, + (s: String) => { + logger.trace(s"errorline put ${s}") + errorLine.put(s) + } + ) + if (!tt.start_process()) { + logger.debug("Cannot start tlmgr process, terminating!") + Platform.exit() + sys.exit(1) + } + logger.debug("initialize_tlmgr: sleeping after starting process") + Thread.sleep(1000) + /* val tlmgrMonitor = Future { + while (true) { + if (!tlmgr.isAlive) { + logger.debug("TLMGR HAS DIED!!!!") + // Platform.exit() + // sys.exit(1) + } + Thread.sleep(5000) + } + }*/ + val stdoutFuture = Future { + val tlmgrOutput = ArrayBuffer[String]() + var tlmgrStatus = "" + var alive = true + logger.debug("initialize tlmgr: starting stdout reader thread") + while (alive) { + logger.trace("stdout reader before outputLine.take") + val s = outputLine.take + logger.trace("stdout reader after outputLine.take") + if (s == null) { + alive = false + logger.debug("got null from stdout, tlmgr seems to be terminated for restart") + if (!currentPromise.isCompleted) { + logger.debug(s"Fulfilling remaining open promise with ${tlmgrStatus} and ${tlmgrOutput.toArray.mkString}") + currentPromise.success((tlmgrStatus, tlmgrOutput.toArray)) + } + tlmgrStatus = "" + tlmgrOutput.clear() + tlmgrBusy.value = false + } else { + logger.trace(s"DEBUG: got ==" + s + "==") + if (s == "OK") { + tlmgrStatus = s + } else if (s == "ERROR") { + tlmgrStatus = s + } else if (s == "tlmgr> ") { + logger.trace(s"Fulfilling current promise with ${tlmgrStatus} and ${tlmgrOutput.toArray.mkString}!") + if (!currentPromise.isCompleted) { + currentPromise.success((tlmgrStatus, tlmgrOutput.toArray)) + } + tlmgrStatus = "" + tlmgrOutput.clear() + tlmgrBusy.value = false + if (pendingJobs.nonEmpty) { + logger.debug("pending Job found!") + val nextCmd = pendingJobs.dequeue() + logger.debug(s"running ${nextCmd._1}") + tlmgr_run_one_cmd(nextCmd._1, nextCmd._2) + } + } else { + tlmgrOutput += s + stdoutLineUpdateFunc(s) + } + } + } + logger.debug("initialize tlmgr: finishing stdout reader thread") + } + tlmgrBusy.onChange({ + Platform.runLater { + statusLabel.text = if (tlmgrBusy.value) "Busy" else "Idle" + if (!tlmgrBusy.value) + actionLabel.text = "" + } + }) + + stdoutFuture.onComplete { + case Success(value) => + logger.debug(s"tlmgr stdout reader terminated: ${value}") + case Failure(e) => + logger.debug(s"tlmgr stdout reader terminated with error: ${e}") + } + + val stderrFuture = Future { + var alive = true + logger.debug("initialize tlmgr: starting stderr reader thread") + while (alive) { + val s = errorLine.take + if (s == null) + alive = false + else + stderrLineUpdateFunc(s) + } + logger.debug("initialize tlmgr: finishing stderr reader thread") + } + stderrFuture.onComplete { + case Success(value) => + logger.debug(s"tlmgr stderr reader terminated: ${value}") + case Failure(e) => + logger.debug(s"tlmgr stderr reader terminated with failure: ${e}") + } + + tt + } + + def tlmgr_post_init() = { + + // check for tlmgr revision + tlmgr_send("version", (status,output) => { + logger.debug(s"Callback after version, got ${status} and ${output.mkString("\n")}") + output.foreach ( l => { + if (l.startsWith("revision ")) { + val tlmgrRev = l.stripPrefix("revision ") + if (tlmgrRev == "unknown") { + logger.debug("Unknown tlmgr revision, assuming git/svn version") + logText += "Unknown tlmgr revision, assuming git/svn version" + } else { + if (tlmgrRev.toInt < 45838) { + new Alert(AlertType.Error) { + initOwner(stage) + title = "TeX Live Manager tlmgr is too old" + headerText = "TeX Live Manager tlmgr is too old" + contentText = "Please update from the command line\nusing 'tlmgr update --self'\nTerminating!" + }.showAndWait() + callback_quit() + } + } + } + }) + pkgs.clear() + upds.clear() + bkps.clear() + logger.debug("Before loading tlpdb") + load_tlpdb_update_pkgs_view_no_json() + logger.debug("after loading tlpdb") + }) + } + + def tlmgr_run_one_cmd(s: String, onCompleteFunc: (String, Array[String]) => Unit): Unit = { + currentPromise = Promise[(String, Array[String])]() + tlmgrBusy.value = true + Platform.runLater { + actionLabel.text = s"[${s}]" + } + currentPromise.future.onComplete { + case Success((a, b)) => + logger.debug("tlmgr run one cmd: current future completed!") + Platform.runLater { + logger.debug("tlmgr run one cmd: running on complete function") + onCompleteFunc(a, b) + } + case Failure(ex) => + logger.debug("Running tlmgr command did no succeed" + ex.getMessage) + errorText += "Running a tlmgr command did not succeed: " + ex.getMessage + } + logger.debug(s"sending to tlmgr: ${s}") + tlmgr.send_command(s) + } + + def tlmgr_send(s: String, onCompleteFunc: (String, Array[String]) => Unit): Unit = { + // logText.clear() + outputText.clear() + // don't close debug panel when it is open + // outerrpane.expanded = false + if (!currentPromise.isCompleted) { + logger.debug(s"tlmgr busy, put onto pending jobs: $s") + logger.debug("Currently running job: " + currentPromise) + pendingJobs += ((s, onCompleteFunc)) + } else { + logger.debug(s"tlmgr_send sending ${s}") + tlmgr_run_one_cmd(s, onCompleteFunc) + } + } + + def reinitialize_tlmgr(): Unit = { + logger.debug("reinit tlmgr: entering, clearing pending jobs") + pendingJobs.clear() + logger.debug("reinit tlmgr: cleared pending jobs") + // if (!currentPromise.isCompleted) { + // logger.debug("reinit tlmgr: current promise not complete, completing it") + // currentPromise.success(("",Array[String]())) + // logger.debug("reinit tlmgr: after completing current promise") + // } + logger.debug("reinit tlmgr: cleaning up tlmgr") + tlmgr.cleanup() + logger.debug("reinit tlmgr: sleeping 1s") + Thread.sleep(1000) + logger.debug("reinit tlmgr: initializaing new tlmgr") + tlmgr = initialize_tlmgr() + logger.debug("reinit tlmgr: finished") + tlmgr_post_init() + logger.debug("reinit tlmgr: post init done") + updLoaded = false + pkgstabs.getSelectionModel.select(0) + } + + def defaultStdoutLineUpdateFunc(l: String) : Unit = { logger.trace(s"DEBUG: got ==$l== from tlmgr") } + def defaultStderrLineUpdateFunc(l: String) : Unit = { Platform.runLater { logText.append(l) } } + + var stdoutLineUpdateFunc: String => Unit = defaultStdoutLineUpdateFunc + var stderrLineUpdateFunc: String => Unit = defaultStderrLineUpdateFunc + + + var tlmgr = initialize_tlmgr() + tlmgr_post_init() + +} // object ApplicationMain + +// vim:set tabstop=2 expandtab : // diff --git a/support/tlcockpit/src/main/scala/TLCockpit/LocationDialog.scala b/support/tlcockpit/src/main/scala/TLCockpit/LocationDialog.scala new file mode 100644 index 0000000000..647e0cc038 --- /dev/null +++ b/support/tlcockpit/src/main/scala/TLCockpit/LocationDialog.scala @@ -0,0 +1,140 @@ +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +package TLCockpit + +import TLCockpit.ApplicationMain.stage +import TeXLive.{OsTools, TLOption} +import com.typesafe.scalalogging.LazyLogging + +import scalafx.Includes._ +import scalafx.event.ActionEvent +import scalafx.geometry.{HPos, Insets} +import scalafx.scene.Node +import scalafx.scene.control._ +import scalafx.scene.layout._ + +class LocationDialog(locs: Map[String,String]) extends LazyLogging { + + case class Result(selected: Map[String, String]) + + val dialog = new Dialog[Result]() { + initOwner(stage) + title = s"Default package repositories" + headerText = s"Default package repositories" + resizable = true + } + dialog.dialogPane().buttonTypes = Seq(ButtonType.OK, ButtonType.Cancel) + val grid = new GridPane() { + hgap = 10 + vgap = 10 + padding = Insets(20) + } + + val newlocs = scala.collection.mutable.ArrayBuffer[(TextField, TextField)]() + + var crow = 0 + + val mirrorlocs: Map[String, Map[String, Seq[String]]] = TLCockpit.ApplicationMain.parse_ctan_mirrors(TLCockpit.ApplicationMain.tlmgr.tlroot) + + def do_one(tag: String, url: String): Unit = { + val tagNode = new TextField() { + text = tag + } + tagNode.disable = (tag == "main") + val urlNode = new TextField() { + text = url + } + grid.add(tagNode, 0, crow) + grid.add(urlNode, 1, crow) + newlocs += ((tagNode, urlNode)) + if (tag == "main") { + if (mirrorlocs.nonEmpty) { + grid.add(new MenuButton("Choose Mirror") { + items = mirrorlocs.toSeq.sortWith(_._1 < _._1) + .map(continent_pair => new Menu(continent_pair._1) { + items = continent_pair._2.toSeq.sortWith(_._1 < _._1) + .map(country_pair => new Menu(country_pair._1) { + items = country_pair._2.sortWith(_ < _).map(mirror => new MenuItem(mirror) { + onAction = (ae) => urlNode.text = mirror + }) + }) + }) + }, 2, crow) + } + } else { + grid.add(new Button("Delete") { + onAction = _ => { + tagNode.disable = true + urlNode.disable = true + this.disable = true + } + }, 2, crow) + } + crow += 1 + } + + do_one("main", locs("main")) + locs.filter(_._1 != "main").foreach(p => do_one(p._1, p._2)) + + val addButton = new Button("Add") + addButton.onAction = _ => { + val tagNode = new TextField() + val urlNode = new TextField() + newlocs += ((tagNode, urlNode)) + logger.trace(s"current row = ${crow}") + grid.children.remove(addButton) + grid.add(tagNode, 0, crow) + grid.add(urlNode, 1, crow) + grid.add(new Button("Delete") { + onAction = _ => { + tagNode.disable = true + urlNode.disable = true + this.disable = true + } + }, 2, crow) + crow += 1 + logger.trace(s"adding addButton at row ${crow}") + grid.add(addButton, 2, crow) + dialog.dialogPane.value.scene.value.window.value.sizeToScene() + } + grid.add(addButton, 2, crow) + + // val changedValues = scala.collection.mutable.Map[String,String]() + + + grid.columnConstraints = Seq(new ColumnConstraints(100, 100, 200), new ColumnConstraints(250, 250, 5000, Priority.Always, HPos.LEFT, true)) + dialog.dialogPane().content = grid + dialog.width = 500 + dialog.height = 1500 + + dialog.resultConverter = dialogButton => + if (dialogButton == ButtonType.OK) { + val newlocsfiltered = + newlocs.filter(p => (p._1.text.value + p._2.text.value).trim != "") + .filter(p => !p._2.disabled.value) + .map(p => (if (p._1.text.value == "") p._2.text.value else p._1.text.value, p._2.text.value)) + .toMap + Result(newlocsfiltered) + } else + null + + def showAndWait(): Option[Map[String,String]] = { + val result = dialog.showAndWait() + + result match { + case Some(Result(foo)) => + logger.debug("Got result " + foo) + Some(foo) + case Some(foo) => + logger.debug("Got strange result " + foo) + None + case None => + None + } + } + +} diff --git a/support/tlcockpit/src/main/scala/TLCockpit/OptionsDialog.scala b/support/tlcockpit/src/main/scala/TLCockpit/OptionsDialog.scala new file mode 100644 index 0000000000..52a9d5d0ce --- /dev/null +++ b/support/tlcockpit/src/main/scala/TLCockpit/OptionsDialog.scala @@ -0,0 +1,136 @@ +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +package TLCockpit + +import TLCockpit.ApplicationMain.{not_implemented_info, stage} +import TeXLive.{OsTools, TLOption, TLPaperConf} +import com.typesafe.scalalogging.LazyLogging + +import scalafx.Includes._ +import scalafx.collections.ObservableBuffer +import scalafx.event.ActionEvent +import scalafx.geometry.{HPos, Insets} +import scalafx.scene.Node +import scalafx.scene.control._ +import scalafx.scene.layout._ + +class OptionsDialog(opts: List[TLOption]) extends LazyLogging { + + case class Result(selected: Map[String,String]) + + val dialog = new Dialog[Result]() { + initOwner(stage) + title = s"General options" + headerText = s"General options" + resizable = true + } + dialog.dialogPane().buttonTypes = Seq(ButtonType.OK, ButtonType.Cancel) + val grid = new GridPane() { + hgap = 10 + vgap = 10 + padding = Insets(20) + } + + var crow = 0 + val optsMap = opts.map(p => (p.name, p)).toMap + val orderOpts = Seq("location", "create_formats", "install_srcfiles", "install_docfiles", + "backupdir", "autobackup") ++ { + if (OsTools.isWindows) Seq("desktop_integration", "file_assocs", "w32_multi_user") + else Seq("sys_bin", "sys_info", "sys_man") + } + val changedValues = scala.collection.mutable.Map[String,String]() + val nodes = orderOpts.map( nm => { + val tlopt = optsMap(nm) // this is dangerous in case we change the names in TLPDB! + grid.add(new Label(tlopt.description), 0, crow) + val value = tlopt.value.getOrElse(tlopt.default) + val nd: Node = if (tlopt.format.startsWith("b")) + new CheckBox() { + selected = if (value == "1") true else false + selected.onChange( (_,_,newVal) => { changedValues(nm) = if (newVal) "1" else "0" }) + } + else if (tlopt.format.startsWith("n")) { + // parse additional specs + if (tlopt.format.startsWith("n:")) { + var nrSpec = tlopt.format.substring(2) + var splits: Array[String] = nrSpec.split("""\.\.""") + val start: Int = splits(0).toInt + val end: Option[Int] = if (splits.length > 1) Some(splits(1).toInt) else None + new Spinner[Int](start, end.getOrElse(50), value.toInt) { + value.onChange( (_,_,newVal) => { changedValues(nm) = newVal.toString }) + if (nm == "autobackup") + tooltip = new Tooltip("-1 ... arbitrary many backups\n0 ... no backups\notherwise number of backups") + } + } else { + new Spinner[Int](-50, 50, value.toInt) { + value.onChange( (_,_,newVal) => { changedValues(nm) = newVal.toString }) + } + } + } else if (tlopt.format == "p") + new TextField() { + text = value + text.onChange( (_,_,newVal) => { changedValues(nm) = newVal }) + } + else if (tlopt.format == "u") { + val locs = value.split(' ') + val locsmap = if (locs.length == 1) Map[String,String](("main", value)) + else locs.map(p => { + val uu = p.split('#') + (uu(1), uu(0)) + }).toMap + val buttonStr = if (locs.length == 1) value else "Multiple repository" + new Button(buttonStr) { + onAction = (event: ActionEvent) => { + val dg = new LocationDialog(locsmap) + dg.showAndWait() match { + case Some(newlocs) => + if (newlocs.toList.length == 1) { + changedValues(nm) = newlocs("main") + this.text = newlocs("main") + } else { + changedValues(nm) = newlocs.map(p => p._2 + "#" + p._1).mkString(" ") + this.text = "Multiple repository" + } + case None => + } + } + } + } else + new Label("Unknown setting") + + grid.add(nd, 1, crow) + crow += 1 + (tlopt.name, nd) + }).toMap + + grid.columnConstraints = Seq(new ColumnConstraints(300, 300, 500), new ColumnConstraints(200, 200, 5000, Priority.Always, HPos.LEFT, true)) + dialog.dialogPane().content = grid + dialog.width = 500 + dialog.height = 1500 + + dialog.resultConverter = dialogButton => + if (dialogButton == ButtonType.OK) { + // TODO we could check that the values actually have changed + Result(changedValues.toMap) + } else + null + + def showAndWait(): Option[Map[String,String]] = { + val result = dialog.showAndWait() + + result match { + case Some(Result(foo)) => + logger.debug("Got result " + foo) + Some(foo) + case Some(foo) => + logger.debug("Got strange result " + foo) + None + case None => + None + } + } + +} diff --git a/support/tlcockpit/src/main/scala/TLCockpit/PaperDialog.scala b/support/tlcockpit/src/main/scala/TLCockpit/PaperDialog.scala new file mode 100644 index 0000000000..3d7a2e8403 --- /dev/null +++ b/support/tlcockpit/src/main/scala/TLCockpit/PaperDialog.scala @@ -0,0 +1,90 @@ +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +package TLCockpit + +import TLCockpit.ApplicationMain.stage +import TeXLive.TLPaperConf +import com.typesafe.scalalogging.LazyLogging + +import scalafx.Includes._ +import scalafx.collections.ObservableBuffer +import scalafx.geometry.{HPos, Insets} +import scalafx.scene.control._ +import scalafx.scene.layout._ + +class PaperDialog(paperconf: Map[String, TLPaperConf]) extends LazyLogging { + + case class Result(selected: Map[String,String]) + + val dialog = new Dialog[Result]() { + initOwner(stage) + title = s"Paper configuration" + headerText = s"Paper configuration" + resizable = true + } + dialog.dialogPane().buttonTypes = Seq(ButtonType.OK, ButtonType.Cancel) + val grid = new GridPane() { + hgap = 10 + vgap = 10 + padding = Insets(20) + } + + var crow = 0 + + def update_all_to(str: String): Unit = { + cbseq.foreach(f => f._2.value = str) + } + grid.add(new Label("Set all to:"), 0, crow) + grid.add(new HBox(20) { + children = List( + new Button("a4") { onAction = _ => update_all_to("a4") }, + new Button("letter") { onAction = _ => update_all_to("letter")} + )}, 1, crow) + crow += 1 + def do_one(k: String, v: ChoiceBox[String], row: Int): Int = { + grid.add(new Label(k), 0, row) + grid.add(v, 1, row) + row + 1 + } + + val cbseq: Map[String, ChoiceBox[String]] = paperconf.map { p => + val prog: String = p._1 + val opts: TLPaperConf = p._2 + val paperopts: Seq[String] = opts.options + val cb = new ChoiceBox[String](ObservableBuffer(paperopts.sorted)) + cb.value = paperopts.head + crow = do_one(prog,cb, crow) + (prog, cb) + } + + grid.columnConstraints = Seq(new ColumnConstraints(100, 100, 150), new ColumnConstraints(150, 150, 5000, Priority.Always, HPos.LEFT, true)) + dialog.dialogPane().content = grid + dialog.width = 300 + dialog.height = 1500 + + dialog.resultConverter = dialogButton => + if (dialogButton == ButtonType.OK) + Result(cbseq.mapValues(_.value.value)) + else + null + + def showAndWait(): Option[Map[String,String]] = { + val result = dialog.showAndWait() + + result match { + case Some(Result(foo)) => + logger.debug("Got result " + foo) + Some(foo) + case Some(foo) => + logger.debug("Got strange result " + foo) + None + case None => + None + } + } + +} diff --git a/support/tlcockpit/src/main/scala/TLCockpit/PkgInfoDialog.scala b/support/tlcockpit/src/main/scala/TLCockpit/PkgInfoDialog.scala new file mode 100644 index 0000000000..89ba164de2 --- /dev/null +++ b/support/tlcockpit/src/main/scala/TLCockpit/PkgInfoDialog.scala @@ -0,0 +1,174 @@ +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +package TLCockpit + +import TLCockpit.ApplicationMain.{stage, tlmgr, tlpkgs} +import TLCockpit.Utils._ +import TeXLive.{OsTools, TLPackage} + +import scalafx.Includes._ +import scalafx.collections.ObservableBuffer +import scalafx.geometry.{HPos, Insets, Orientation} +import scalafx.scene.Cursor +import scalafx.scene.layout.{ColumnConstraints, GridPane, Priority, VBox} +import scalafx.scene.control._ +import scalafx.scene.input.MouseEvent +import scalafx.scene.paint.Color + +class PkgInfoDialog(tlp: TLPackage) extends Dialog { + val dialog = new Dialog() { + initOwner(stage) + title = s"Package Information for $pkg" + headerText = s"Package Information for $pkg" + resizable = true + } + dialog.dialogPane().buttonTypes = Seq(ButtonType.OK) + val isInstalled = tlp.installed + val grid = new GridPane() { + hgap = 10 + vgap = 10 + padding = Insets(20) + } + + def copyableTextfield(k: String): TextField = { + new TextField() { + text = k + editable = false + style = "-fx-background-color: rgba(0, 0, 0, 0);" + margin = Insets(0) + padding = Insets(0) + } + } + + def do_one(k: String, v: String, row: Int): Int = { + grid.add(copyableTextfield(k), 0, row) + grid.add(copyableTextfield(v), 1, row) + // grid.add(new Label(k), 0, row) + // grid.add(new Label(v) { + // wrapText = true + // }, 1, row) + row + 1 + } + + var crow = 0 + val pkg = tlp.name + crow = do_one("package", pkg, crow) + crow = do_one("category", tlp.category, crow) + crow = do_one("shortdesc", tlp.shortdesc.getOrElse(""), crow) + crow = do_one("longdesc", tlp.longdesc.getOrElse(""), crow) + crow = do_one("installed", if (tlp.installed) "Yes" else "No", crow) + crow = do_one("available", if (tlp.available) "Yes" else "No", crow) + if (tlp.installed) + crow = do_one("local revision", tlp.lrev.toString, crow) + if (tlp.available) + crow = do_one("remote revision", tlp.rrev.toString, crow) + val binsizesum = tlp.binsize.values.toList.sum * TeXLive.tlBlockSize + val binsizestr = if (binsizesum > 0) "bin " + humanReadableByteSize(binsizesum) + " " else "" + val runsizestr = if (tlp.runsize > 0) "run " + humanReadableByteSize(tlp.runsize) + " " else "" + val srcsizestr = if (tlp.srcsize > 0) "src " + humanReadableByteSize(tlp.srcsize) + " " else "" + val docsizestr = if (tlp.docsize > 0) "doc " + humanReadableByteSize(tlp.docsize) + " " else "" + crow = do_one("sizes", runsizestr + docsizestr + binsizestr + srcsizestr, crow) + val catdata = tlp.cataloguedata + if (catdata.version.isDefined) + crow = do_one("cat-version", catdata.version.get, crow) + if (catdata.date.isDefined) + crow = do_one("cat-date", catdata.date.get, crow) + if (catdata.license.isDefined) + crow = do_one("cat-license", catdata.license.get, crow) + if (catdata.topics.isDefined) + crow = do_one("cat-topics", catdata.topics.get, crow) + if (catdata.related.isDefined) + crow = do_one("cat-related", catdata.related.get, crow) + // add files section + //println(tlpkgs(pkg)) + val docFiles = tlp.docfiles + if (docFiles.nonEmpty) { + // grid.add(new Label("doc files"), 0, crow) + grid.add(copyableTextfield("doc files"), 0, crow) + grid.add(doListView(docFiles.map(s => s.file.replaceFirst("RELOC", "texmf-dist")), isInstalled), 1, crow) + crow += 1 + } + val runFiles = tlp.runfiles + if (runFiles.nonEmpty) { + // grid.add(new Label("run files"), 0, crow) + grid.add(copyableTextfield("run files"), 0, crow) + grid.add(doListView(runFiles.map(s => s.replaceFirst("RELOC", "texmf-dist")), false), 1, crow) + crow += 1 + } + val srcFiles = tlp.srcfiles + if (srcFiles.nonEmpty) { + // grid.add(new Label("src files"), 0, crow) + grid.add(copyableTextfield("src files"), 0, crow) + grid.add(doListView(srcFiles.map(s => s.replaceFirst("RELOC", "texmf-dist")), false), 1, crow) + crow += 1 + } + val binFiles = tlp.binfiles + if (binFiles.nonEmpty) { + // grid.add(new Label("bin files"), 0, crow) + grid.add(copyableTextfield("bin files"), 0, crow) + grid.add(doListView(binFiles.flatMap(_._2).toSeq.map(s => s.replaceFirst("RELOC", "texmf-dist")), false), 1, crow) + crow += 1 + } + grid.columnConstraints = Seq(new ColumnConstraints(100, 200, 200), new ColumnConstraints(100, 400, 5000, Priority.Always, HPos.LEFT, true)) + dialog.dialogPane().content = grid + dialog.width = 600 + dialog.height = 1500 + // dialog + + def showAndWait(): Unit = this.dialog.showAndWait() + + def doListView(files: Seq[String], clickable: Boolean): scalafx.scene.Node = { + if (files.length <= 5) { + val vb = new VBox() + vb.children = files.map { f => + val fields = f.split(" ") + val foo = copyableTextfield(fields(0)) + if (clickable) { + foo.style = foo.getStyle + "-fx-text-fill: blue;" + foo.onMouseClicked = { me: MouseEvent => OsTools.openFile(tlmgr.tlroot + "/" + fields(0)) } + foo.cursor = Cursor.Hand + } + foo + // new Label(fields(0)) { + // if (clickable) { + // textFill = Color.Blue + // onMouseClicked = { me: MouseEvent => OsTools.openFile(tlmgr.tlroot + "/" + fields(0)) } + // cursor = Cursor.Hand + // } + // } + } + vb + } else { + val vb = new ListView[String] {} + vb.minHeight = 100 + vb.prefHeight = 100 + vb.maxHeight = 200 + vb.vgrow = Priority.Always + vb.orientation = Orientation.Vertical + vb.cellFactory = { p => { + val foo = new ListCell[String] + // ensure tighter spacing similar to the other entires + foo.padding = Insets(0) + foo.item.onChange { (_, _, str) => foo.text = str } + if (clickable) { + foo.textFill = Color.Blue + foo.onMouseClicked = { me: MouseEvent => OsTools.openFile(tlmgr.tlroot + "/" + foo.text.value) } + foo.cursor = Cursor.Hand + } + foo + } + } + // vb.children = docFiles.map { f => + vb.items = ObservableBuffer(files.map { f => + val fields = f.split(" ") + fields(0) + }) + vb + } + } + +} diff --git a/support/tlcockpit/src/main/scala/TLCockpit/TLDisplayClasses.scala b/support/tlcockpit/src/main/scala/TLCockpit/TLDisplayClasses.scala new file mode 100644 index 0000000000..b49c13c162 --- /dev/null +++ b/support/tlcockpit/src/main/scala/TLCockpit/TLDisplayClasses.scala @@ -0,0 +1,36 @@ +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +package TLCockpit + +import scalafx.beans.property.{ObjectProperty, StringProperty} + + +// Note!!! we have to use ObjectProperty[Int] here instead of IntegerProperty +// since IntegerProperty does NOT implement Observable[Int,Int] +// see https://github.com/scalafx/scalafx/issues/243 +case class TLPackageDisplay(name: StringProperty, var lrev: ObjectProperty[Int], rrev: ObjectProperty[Int], + shortdesc: StringProperty, size: ObjectProperty[Int], var installed: StringProperty) { + def this(_name: String, _lrev: String, _rrev: String, _shortdesc: String, _size: String, _installed: String) = + this( + StringProperty(_name), ObjectProperty[Int](_lrev.toInt), ObjectProperty[Int](_rrev.toInt), + StringProperty(_shortdesc), ObjectProperty[Int](_size.toInt), StringProperty(_installed) + ) +} + +case class TLBackupDisplay(name: StringProperty, rev: StringProperty, date: StringProperty) { + def this(_name: String, _rev: String, _date: String) = + this(StringProperty(_name), StringProperty(_rev), StringProperty(_date)) +} + +case class TLUpdateDisplay(name: StringProperty, var status: StringProperty, var lrev: StringProperty, rrev: StringProperty, + shortdesc: StringProperty, size: StringProperty) { + def this(_name: String, _status: String, _lrev: String, _rrev: String, _shortdesc: String, _size: String) = + this( + StringProperty(_name), StringProperty(_status), StringProperty(_lrev), StringProperty(_rrev), + StringProperty(_shortdesc), StringProperty(_size) + ) +}
\ No newline at end of file diff --git a/support/tlcockpit/src/main/scala/TLCockpit/Utils.scala b/support/tlcockpit/src/main/scala/TLCockpit/Utils.scala new file mode 100644 index 0000000000..0753e127c6 --- /dev/null +++ b/support/tlcockpit/src/main/scala/TLCockpit/Utils.scala @@ -0,0 +1,32 @@ +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +package TLCockpit + +object Utils { + /** + * @see https://stackoverflow.com/questions/3263892/format-file-size-as-mb-gb-etc + * @see https://en.wikipedia.org/wiki/Zettabyte + * @param fileSize Up to Exabytes + * @return + */ + def humanReadableByteSize(fileSize: Long): String = { + if(fileSize <= 0) return "0 B" + // kilo, Mega, Giga, Tera, Peta, Exa, Zetta, Yotta + val units: Array[String] = Array("B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") + val digitGroup: Int = (Math.log10(fileSize)/Math.log10(1024)).toInt + f"${fileSize/Math.pow(1024, digitGroup)}%3.1f ${units(digitGroup)}" + } + + def toInt(s: String): Option[Int] = { + try { + Some(s.toInt) + } catch { + case e: Exception => None + } + } + +} diff --git a/support/tlcockpit/src/main/scala/TeXLive/JsonProtocol.scala b/support/tlcockpit/src/main/scala/TeXLive/JsonProtocol.scala new file mode 100644 index 0000000000..2302d308bd --- /dev/null +++ b/support/tlcockpit/src/main/scala/TeXLive/JsonProtocol.scala @@ -0,0 +1,22 @@ +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +package TeXLive + +import spray.json._ + + +object JsonProtocol extends DefaultJsonProtocol { + implicit val catalogueDataFormat = jsonFormat6(CatalogueData) + implicit val docfileFormat = jsonFormat3(DocFile) + implicit val tlpackageshortFormat = jsonFormat8(TLPackageShort) + implicit val tlpackageFormat = jsonFormat19(TLPackage) + implicit val tlbackupFormat = jsonFormat3(TLBackup) + implicit val tlpaperconfFormat = jsonFormat3(TLPaperConf) + implicit val tloptionsFormat = jsonFormat14(TLOptions) + implicit val tloptionFormat = jsonFormat6(TLOption) +} + diff --git a/support/tlcockpit/src/main/scala/TeXLive/OsTools.scala b/support/tlcockpit/src/main/scala/TeXLive/OsTools.scala new file mode 100644 index 0000000000..1e0aa0f03c --- /dev/null +++ b/support/tlcockpit/src/main/scala/TeXLive/OsTools.scala @@ -0,0 +1,55 @@ +package TeXLive +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +import scala.concurrent.Future +import scala.concurrent.ExecutionContext.Implicits.global +import sys.process._ + +object OsTools { + val OS: String = System.getProperty("os.name").map(_.toLower) + val CygwinRootIsSet: Boolean = + sys.env.get("CYGWIN_ROOT") match { + case Some(_) => true + case None => false + } + def isWindows: Boolean = { + OS.startsWith("windows") && !CygwinRootIsSet + } + def isCygwin: Boolean = { + OS.startsWith("windows") && CygwinRootIsSet + } + def isApple: Boolean = { + OS.startsWith("mac os") + } + def isUnix: Boolean = { + !(isWindows || isApple || isCygwin) + } + + def openFileCmd(f: String): Seq[String] = { + val absf = new java.io.File(f).getCanonicalPath + if (isWindows) { + Seq("cmd", "/c", "start", absf) + } else if (isCygwin) { + Seq("cygstart", absf) + } else if (isApple) { + Seq("open", absf) + } else { + Seq("xdg-open", absf) + } + } + + def openFile(f: String): Unit = { + val cmd = openFileCmd(f) + val bar = Future { + try { + cmd.! + } catch { + case e: Exception => println("Cannot run command: " + cmd + " -- " + e + "\n") + } + } + } +} diff --git a/support/tlcockpit/src/main/scala/TeXLive/TLClasses.scala b/support/tlcockpit/src/main/scala/TeXLive/TLClasses.scala new file mode 100644 index 0000000000..e647ef726e --- /dev/null +++ b/support/tlcockpit/src/main/scala/TeXLive/TLClasses.scala @@ -0,0 +1,75 @@ +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +package TeXLive + +// classes that wrap up structures in TeX Live +// all of them have associated JSON parsers + +case class CatalogueData(version: Option[String], topics: Option[String], license: Option[String], + date: Option[String], related: Option[String], ctan: Option[String]) + +case class DocFile(file: String, details: Option[String], language: Option[String]) +case class TLPaperConf(program: String, file: String, options: List[String]) +case class TLBackup(name: String, rev: String, date: String) + +case class TLPackageShort +( + name: String, + shortdesc: Option[String], + var lrev: Long, + rrev: Long, + category: String, + depends: List[String], + var installed: Boolean, + available: Boolean +) + +case class TLPackage +( + name: String, + shortdesc: Option[String], + longdesc: Option[String], + var lrev: Long, + rrev: Long, + category: String, + docfiles: List[DocFile], + runfiles: List[String], + srcfiles: List[String], + binfiles: Map[String,List[String]], + docsize: Long, + runsize: Long, + srcsize: Long, + binsize: Map[String,Long], + cataloguedata: CatalogueData, + depends: List[String], + catalogue: Option[String], + var installed: Boolean, + available: Boolean +) + +case class TLOption (name: String, description: String, format: String, + tlmgrname: String, value: Option[String], default: String) + +case class TLOptions +( + sys_bin: String, + sys_man: String, + sys_info: String, + backupdir: String, + autobackup: Long, + install_srcfiles: Boolean, + install_docfiles: Boolean, + create_formats: Boolean, + post_code: Boolean, + generate_updmap: Boolean, + location: Map[String,String], + desktop_integration: Option[Boolean], + w32_multi_user: Option[Boolean], + file_assocs: Option[Long] +) + + diff --git a/support/tlcockpit/src/main/scala/TeXLive/TlmgrProcess.scala b/support/tlcockpit/src/main/scala/TeXLive/TlmgrProcess.scala new file mode 100644 index 0000000000..3040d3abf7 --- /dev/null +++ b/support/tlcockpit/src/main/scala/TeXLive/TlmgrProcess.scala @@ -0,0 +1,130 @@ +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +package TeXLive + +import java.io._ + +import TeXLive.OsTools._ +import com.typesafe.scalalogging.LazyLogging + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.SyncVar +import scala.sys.process._ + +class TlmgrProcess(updout: String => Unit, upderr: String => Unit) extends LazyLogging { + val inputString = new SyncVar[String] // used for the tlmgr process input + // val outputString = new SyncVar[String] // used for the tlmgr process output + // val errorBuffer: StringBuffer = new StringBuffer() // buffer used for both tmgr process error console AND logging + + val tlroot: String = "kpsewhich -var-value SELFAUTOPARENT".!!.trim + logger.debug("tlroot ==" + tlroot + "==") + + // set in only one place, in the main thread + var process: Process = _ + + def send_command(input: String): Unit = { + logger.debug(s"send_command: ${input}") + try { + assert(!inputString.isSet) + inputString.put(input) + } catch { + case exc: Throwable => + logger.warn("Main thread: " + + (if (exc.isInstanceOf[NoSuchElementException]) "Timeout" else "Exception: " + exc)) + } + } + + def isAlive(): Boolean = { + if (process != null) + process.isAlive() + else + // return true on not-started process + true + } + + def start_process(): Boolean = { + logger.debug("tlmgr process: entering starting process") + // process creation + if (process == null) { + logger.debug("tlmgr process: start_process: creating procIO") + val procIO = new ProcessIO(inputFn, outputFn(_, updout), outputFn(_, upderr)) + val startCmd = + if (isWindows) { + logger.debug("detected Windows, using tlmgr.bat") + Seq("tlmgr.bat", "-v", "--machine-readable", "shell") + } else if (isCygwin) { + logger.debug("detected Cygwin, using bash -l -c tlmgr") + Seq("bash", "-l", "-c", "tlmgr -v --machine-readable shell") + } else { + logger.debug("detected Unixish, using tlmgr") + Seq("tlmgr", "-v", "--machine-readable", "shell") + } + val processBuilder: ProcessBuilder = startCmd + logger.debug("tlmgr process: start_process: running new tlmgr process") + process = processBuilder.run(procIO) + } + logger.debug("tlmgr process: start_process: checking for being alive") + process.isAlive() + } + + def cleanup(): Unit = { + if (process != null) { + logger.debug("tlmgr process: cleanup - sending quit") + send_command("quit") + logger.debug("tlmgr process: cleanup - sleeping 2s") + Thread.sleep(1000) + logger.debug("tlmgr process: cleanup - destroying process") + process.destroy() + } + } + + /* The standard input passing function */ + private[this] def inputFn(stdin: OutputStream): Unit = { + val writer = new BufferedWriter(new OutputStreamWriter(stdin)) + try { + var input = "" + while (true) { + input = inputString.take() + if (input == "quit") { + stdin.close() + return + } else { + writer.write(input + "\n") + logger.trace("writing " + input + " to process") + writer.flush() + } + } + stdin.close() + } catch { + case exc: Throwable => + logger.warn("Exception in inputFn thread: " + exc + "\n") + stdin.close() + } + } + + private[this] def outputFn(outStr: InputStream, updfun: String => Unit): Unit = { + val reader = new BufferedReader(new InputStreamReader(outStr)) + try { + var line: String = "" + while (true) { + line = reader.readLine + logger.trace("DDD did read " + line + " from process") + try { + updfun(line) + } catch { + case exc: Throwable => + logger.debug("Update output line function failed, continuing anyway. Exception: " + exc) + } + } + outStr.close() + } catch { + case exc: Throwable => + logger.warn("Exception in outputFn thread: " + exc + "\n") + outStr.close() + } + } +} diff --git a/support/tlcockpit/src/main/scala/TeXLive/package.scala b/support/tlcockpit/src/main/scala/TeXLive/package.scala new file mode 100644 index 0000000000..074a7a8615 --- /dev/null +++ b/support/tlcockpit/src/main/scala/TeXLive/package.scala @@ -0,0 +1,9 @@ +// TLCockpit +// Copyright 2017-2018 Norbert Preining +// Licensed according to GPLv3+ +// +// Front end for tlmgr + +package object TeXLive { + val tlBlockSize = 4096 // TL gives sizes in multiples of BlockSize! +} |