diff --git a/tools/afp_build.scala b/tools/afp_build.scala --- a/tools/afp_build.scala +++ b/tools/afp_build.scala @@ -1,345 +1,342 @@ /* Author: Lars Hupel and Fabian Huch, TU Muenchen Unified AFP CI integration. */ package afp import isabelle.* -import isabelle.CI_Build.{hg_id, print_section, Build_Config, Failed, Job, Profile, Result, Status} -import afp.Metadata.{Email, Entry, Entries} - -import java.time.{Instant, ZoneId} +import java.time.ZoneId import java.time.format.DateTimeFormatter -import java.util.{Map => JMap, Properties => JProperties} object AFP_Build { /* mailing */ object Mailer { - def failed_subject(name: Entry.Name): String = s"Build of AFP entry $name failed" + def failed_subject(name: Metadata.Entry.Name): String = s"Build of AFP entry $name failed" - def failed_text(session_name: String, entry: Entry.Name, isabelle_id: String, + def failed_text(session_name: String, entry: Metadata.Entry.Name, isabelle_id: String, afp_id: String, result: Process_Result): String = s""" The build for the session $session_name, belonging to the AFP entry $entry failed. You are receiving this mail because you are the maintainer of that AFP entry. The following information might help you with resolving the problem. Build log: ${Isabelle_System.getenv("BUILD_URL")} Isabelle ID: $isabelle_id AFP ID: $afp_id Timeout? ${result.timeout} Exit code: ${result.rc} Last 50 lines from stdout (if available): ${result.out_lines.takeRight(50).mkString("\n")} Last 50 lines from stderr (if available): ${result.err_lines.takeRight(50).mkString("\n")} """ } /* metadata tooling */ class Metadata_Tools private( val structure: AFP_Structure, val server: Mail.Server, - val entries: Entries + val entries: Metadata.Entries ) { - def maintainers(name: Entry.Name): List[Email] = { + def maintainers(name: Metadata.Entry.Name): List[Metadata.Email] = { entries.get(name) match { case None => Nil case Some(entry) => entry.notifies } } - val sessions: Map[Entry.Name, List[String]] = + val sessions: Map[Metadata.Entry.Name, List[String]] = entries.values.map(entry => entry.name -> structure.entry_sessions(entry.name).map(_.name)).toMap - def session_entry(session_name: String): Option[Entry.Name] = { + def session_entry(session_name: String): Option[Metadata.Entry.Name] = { val entry = sessions.find { case (_, sessions) => sessions.contains(session_name) } entry.map { case (name, _) => name } } - def by_entry(sessions: List[String]): Map[Option[Entry.Name], List[String]] = + def by_entry(sessions: List[String]): Map[Option[Metadata.Entry.Name], List[String]] = sessions.groupBy(session_entry) - def notify(name: Entry.Name, subject: String, text: String): Boolean = { + def notify(name: Metadata.Entry.Name, subject: String, text: String): Boolean = { val recipients = entries.get(name).map(_.notifies).getOrElse(Nil) if (recipients.nonEmpty) { val from = Some(Mail.address("ci@isabelle.systems")) val to = recipients.map(mail => Mail.address(mail.address)) val mail = Mail(subject, to, text, from, "Jenkins Admin") server.send(mail) } recipients.nonEmpty } } object Metadata_Tools { def load(afp: AFP_Structure, options: Options): Metadata_Tools = new Metadata_Tools(afp, CI_Build.mail_server(options), afp.load()) } /* utilities */ def status_as_json(isabelle_id: String, afp_id: String, start_time: String, - status: Map[Option[String], Status]): String = { + status: Map[Option[String], CI_Build.Status]): String = { val entries_strings = status.collect { case (Some(entry), status) => s"""{"entry": "$entry", "status": "${status.str}"}""" } val entries_string = entries_strings.mkString("[", ",\n", "]") s""" {"build_data": {"isabelle_id": "$isabelle_id", "afp_id": "$afp_id", "time": "$start_time", "url": "${Isabelle_System.getenv("BUILD_URL")}", "job": "${Isabelle_System.getenv("JOB_NAME")}" }, "entries": $entries_string } """ } - def status_as_html(status: Map[Option[String], Status]): String = { + def status_as_html(status: Map[Option[String], CI_Build.Status]): String = { val entries_strings = status.collect { - case (None, Failed) => + case (None, CI_Build.Failed) => s"
  • Distribution
  • " - case (Some(entry), Failed) => + case (Some(entry), CI_Build.Failed) => s"""
  • $entry
  • """ } if (entries_strings.isEmpty) "" else entries_strings.mkString("Failed entries: ") } /* ci build jobs */ val afp = - Job("afp", "builds the AFP, without slow sessions", Profile.from_host, + CI_Build.Job("afp", "builds the AFP, without slow sessions", CI_Build.Profile.from_host, { val afp = AFP_Structure() val status_file = Path.explode("$ISABELLE_HOME/status.json").file val deps_file = Path.explode("$ISABELLE_HOME/dependencies.json").file - def pre_hook(options: Options): Result = { + def pre_hook(options: Options): CI_Build.Result = { println(s"AFP id ${ afp.hg_id }") if (status_file.exists()) status_file.delete() - Result.ok + CI_Build.Result.ok } - def post_hook(results: Build.Results, options: Options, start_time: Time): Result = { - print_section("DEPENDENCIES") + def post_hook(results: Build.Results, options: Options, start_time: Time): CI_Build.Result = { + CI_Build.print_section("DEPENDENCIES") println("Generating dependencies file ...") val result = Isabelle_System.bash("isabelle afp_dependencies") result.check println("Writing dependencies file ...") File.write(deps_file, result.out) - print_section("COMPLETED") - Result.ok + CI_Build.print_section("COMPLETED") + CI_Build.Result.ok } - Build_Config( + CI_Build.Build_Config( clean = false, include = List(afp.thys_dir), pre_hook = pre_hook, post_hook = post_hook, selection = Sessions.Selection( session_groups = List("AFP"), exclude_session_groups = List("slow"))) }) val all = - Job("all", "builds Isabelle + AFP (without slow)", Profile.from_host, + CI_Build.Job("all", "builds Isabelle + AFP (without slow)", CI_Build.Profile.from_host, { val afp = AFP_Structure() val isabelle_home = Path.explode(Isabelle_System.getenv_strict("ISABELLE_HOME")) - val isabelle_id = hg_id(isabelle_home) + val isabelle_id = CI_Build.hg_id(isabelle_home) val status_file = Path.explode("$ISABELLE_HOME/status.json").file val report_file = Path.explode("$ISABELLE_HOME/report.html").file val deps_file = Path.explode("$ISABELLE_HOME/dependencies.json").file - def pre_hook(options: Options): Result = { + def pre_hook(options: Options): CI_Build.Result = { println(s"AFP id ${ afp.hg_id }") if (status_file.exists()) status_file.delete() if (report_file.exists()) report_file.delete() File.write(report_file, "") val server = CI_Build.mail_server(options) if (!server.defined) { println(s"Mail configuration not found.") - Result.error + CI_Build.Result.error } else { server.check() - Result.ok + CI_Build.Result.ok } } - def post_hook(results: Build.Results, options: Options, start_time: Time): Result = { - print_section("DEPENDENCIES") + def post_hook(results: Build.Results, options: Options, start_time: Time): CI_Build.Result = { + CI_Build.print_section("DEPENDENCIES") println("Generating dependencies file ...") val result = Isabelle_System.bash("isabelle afp_dependencies") result.check println("Writing dependencies file ...") File.write(deps_file, result.out) val metadata = Metadata_Tools.load(afp, options) val status = metadata.by_entry(results.sessions.toList).view.mapValues { sessions => - Status.merge(sessions.map(Status.from_results(results, _))) + CI_Build.Status.merge(sessions.map(CI_Build.Status.from_results(results, _))) }.toMap - print_section("REPORT") + CI_Build.print_section("REPORT") println("Writing report file ...") File.write(report_file, status_as_html(status)) - print_section("SITEGEN") + CI_Build.print_section("SITEGEN") println("Writing status file ...") val formatted_time = start_time.instant.atZone(ZoneId.systemDefault) .format(DateTimeFormatter.RFC_1123_DATE_TIME) File.write(status_file, status_as_json(isabelle_id, afp.hg_id, formatted_time, status)) println("Running sitegen ...") val script = afp.base_dir + Path.explode("admin/sitegen-devel") val sitegen_cmd = Bash.strings(List(script.file.toString, status_file.toString, deps_file.toString)) val sitegen_res = Isabelle_System.bash(sitegen_cmd, progress_stdout = println, progress_stderr = println) if (!sitegen_res.ok) { println("sitegen failed") } if (!results.ok) { - print_section("NOTIFICATIONS") + CI_Build.print_section("NOTIFICATIONS") for (session_name <- results.sessions) { val result = results(session_name) if (!result.ok && !results.cancelled(session_name) && metadata.server.defined) { metadata.session_entry(session_name).foreach { entry => val subject = Mailer.failed_subject(entry) val text = Mailer.failed_text(session_name, entry, isabelle_id, afp.hg_id, result) val notified = metadata.notify(entry, subject, text) if (!notified) println(s"Entry $entry: WARNING no maintainers specified") } } } } - print_section("COMPLETED") - Result(sitegen_res.rc) + CI_Build.print_section("COMPLETED") + CI_Build.Result(sitegen_res.rc) } - Build_Config( + CI_Build.Build_Config( clean = false, include = List(afp.thys_dir), pre_hook = pre_hook, post_hook = post_hook, selection = Sessions.Selection( all_sessions = true, exclude_session_groups = List("slow"))) }) val mac = - Job("mac", "builds the AFP (without some sessions) on Mac Os", - Profile.from_host.copy(threads = 8, jobs = 1), + CI_Build.Job("mac", "builds the AFP (without some sessions) on Mac Os", + CI_Build.Profile.from_host.copy(threads = 8, jobs = 1), { val afp = AFP_Structure() - def pre_hook(options: Options): Result = { + def pre_hook(options: Options): CI_Build.Result = { println(s"Build for AFP id ${ afp.hg_id }") - Result.ok + CI_Build.Result.ok } - Build_Config( + CI_Build.Build_Config( include = List(afp.thys_dir), pre_hook = pre_hook, selection = Sessions.Selection( all_sessions = true, exclude_sessions = List("HOL-Proofs", "HOL-ODE-Numerics", "Linear_Programming", "HOL-Nominal-Examples", "HOL-Analysis"), exclude_session_groups = List("slow"))) }) val slow = - Job("slow", "builds the AFP slow sessions", Profile.from_host.copy(threads = 8, jobs = 1), + CI_Build.Job("slow", "builds the AFP slow sessions", + CI_Build.Profile.from_host.copy(threads = 8, jobs = 1), { val afp = AFP_Structure() - def pre_hook(options: Options): Result = { + def pre_hook(options: Options): CI_Build.Result = { println(s"Build for AFP id ${ afp.hg_id }") - Result.ok + CI_Build.Result.ok } - Build_Config( + CI_Build.Build_Config( include = List(afp.thys_dir), pre_hook = pre_hook, selection = Sessions.Selection(session_groups = List("slow"))) }) val testboard = - Job("testboard", "builds the AFP testboard", Profile.from_host, + CI_Build.Job("testboard", "builds the AFP testboard", CI_Build.Profile.from_host, { val afp = AFP_Structure() val report_file = Path.explode("$ISABELLE_HOME/report.html").file - def pre_hook(options: Options): Result = { + def pre_hook(options: Options): CI_Build.Result = { println(s"AFP id ${ afp.hg_id }") if (report_file.exists()) report_file.delete() File.write(report_file, "") - Result.ok + CI_Build.Result.ok } - def post_hook(results: Build.Results, options: Options, start_time: Time): Result = { + def post_hook(results: Build.Results, options: Options, start_time: Time): CI_Build.Result = { val metadata = Metadata_Tools.load(afp, options) val status = metadata.by_entry(results.sessions.toList).view.mapValues { sessions => - Status.merge(sessions.map(Status.from_results(results, _))) + CI_Build.Status.merge(sessions.map(CI_Build.Status.from_results(results, _))) }.toMap - print_section("REPORT") + CI_Build.print_section("REPORT") println("Writing report file ...") File.write(report_file, status_as_html(status)) - print_section("COMPLETED") - Result.ok + CI_Build.print_section("COMPLETED") + CI_Build.Result.ok } - Build_Config( + CI_Build.Build_Config( clean = false, include = List(afp.thys_dir), pre_hook = pre_hook, post_hook = post_hook, selection = Sessions.Selection( all_sessions = true, exclude_session_groups = List("slow"))) }) } class CI_Builds extends Isabelle_CI_Builds( AFP_Build.afp, AFP_Build.all, AFP_Build.slow, AFP_Build.mac, AFP_Build.testboard) diff --git a/tools/afp_check_metadata.scala b/tools/afp_check_metadata.scala --- a/tools/afp_check_metadata.scala +++ b/tools/afp_check_metadata.scala @@ -1,198 +1,198 @@ /* Author: Fabian Huch, TU Muenchen Tool to check metadata consistency. */ package afp import isabelle.* -import afp.Metadata.{Author, DOI, Email, Homepage, TOML, Topic} +import afp.Metadata.TOML import isabelle.TOML.{parse, Format, Key, Table} object AFP_Check_Metadata { def diff(t1: Table, t2: Table): List[Key] = (t1.domain diff t2.domain).toList ++ t1.table.values.flatMap { case (k, tr1) => t2.table.get(k).toList.flatMap(diff(tr1, _)) } def afp_check_metadata( afp_structure: AFP_Structure, strict: Boolean = false, reformat: Boolean = false, format_all: Boolean = false, slow: Boolean = false, verbose: Boolean = false, progress: Progress = new Progress ): Unit = { def warn(msg: String): Unit = if (strict) error(msg) else progress.echo_warning(msg) progress.echo_if(verbose, "Loading metadata...") val authors = afp_structure.load_authors val topics = afp_structure.load_topics val licenses = afp_structure.load_licenses val releases = afp_structure.load_releases val entries = afp_structure.entries.map(name => afp_structure.load_entry(name, authors, topics, licenses, releases)) /* TOML encoding/decoding */ def check_toml[A](kind: String, a: A, from: A => Table, to: Table => A): Unit = if (to(from(a)) != a) error("Inconsistent toml encode/decode: " + kind) progress.echo_if(verbose, "Checking toml conversions...") check_toml("authors", authors.values.toList, TOML.from_authors, TOML.to_authors) check_toml("topics", Metadata.Topics.root_topics(topics), TOML.from_topics, TOML.to_topics) check_toml("licenses", licenses.values.toList, TOML.from_licenses, TOML.to_licenses) check_toml("releases", releases.values.flatten.toList, TOML.from_releases, TOML.to_releases) entries.foreach(entry => check_toml("entry " + entry.name, entry, TOML.from_entry, t => TOML.to_entry(entry.name, t, authors, topics, licenses, releases.getOrElse(entry.name, Nil)))) /* duplicate ids */ var seen_ids = Set.empty[String] def check_id(id: String): Unit = if (seen_ids.contains(id)) error("Duplicate id: " + id) else seen_ids += id progress.echo_if(verbose, "Checking for duplicate ids...") authors.values.foreach { author => check_id(author.id) author.emails.map(_.id).foreach(check_id) author.homepages.map(_.id).foreach(check_id) } topics.values.map(_.id).foreach(check_id) licenses.values.map(_.id).foreach(check_id) entries.map(_.name).foreach(check_id) /* unread fields */ progress.echo_if(verbose, "Checking for unused fields...") def check_unused_toml[A](file: Path, to: Table => A, from: A => Table): Unit = { val toml = parse(File.read(file)) val recoded = from(to(toml)) val diff_keys = diff(parse(File.read(file)), recoded) if (diff_keys.nonEmpty) warn("Unused fields: " + commas_quote(diff_keys)) } check_unused_toml(afp_structure.authors_file, TOML.to_authors, TOML.from_authors) check_unused_toml(afp_structure.topics_file, TOML.to_topics, TOML.from_topics) check_unused_toml(afp_structure.licenses_file, TOML.to_licenses, TOML.from_licenses) check_unused_toml(afp_structure.releases_file, TOML.to_releases, TOML.from_releases) entries.foreach(entry => check_unused_toml(afp_structure.entry_file(entry.name), t => TOML.to_entry(entry.name, t, authors, topics, licenses, releases.getOrElse(entry.name, Nil)), TOML.from_entry)) /* unused values */ def warn_unused(name: String, unused: Set[String]): Unit = if (unused.nonEmpty) warn("Extra (unused) " + name + ": " + commas_quote(unused.toList)) progress.echo_if(verbose, "Checking for unused values...") val all_affils = entries.flatMap(entry => entry.authors ++ entry.contributors ++ entry.notifies) warn_unused("authors", authors.keySet diff all_affils.map(_.author).toSet) - def author_affil_id(author: Author.ID, affil: String): String = author + " " + affil + def author_affil_id(author: Metadata.Author.ID, affil: String): String = author + " " + affil val affils = authors.values.flatMap(author => (author.emails.map(_.id) ++ author.homepages.map(_.id)).map(author_affil_id(author.id, _))) val used_affils = all_affils.collect { - case Email(author, id, _) => author_affil_id(author, id) - case Homepage(author, id, _) => author_affil_id(author, id) + case Metadata.Email(author, id, _) => author_affil_id(author, id) + case Metadata.Homepage(author, id, _) => author_affil_id(author, id) } warn_unused("affiliations", affils.toSet diff used_affils.toSet) val leaf_topics = topics.values.filter(_.sub_topics.isEmpty).map(_.id) warn_unused("topics", leaf_topics.toSet diff entries.flatMap(_.topics).map(_.id).toSet) warn_unused("licenses", licenses.keySet diff entries.map(_.license.id).toSet) /* formatting of commonly patched files */ if (reformat) { afp_structure.save_authors(authors.values.toList) if (format_all) { afp_structure.save_topics(Metadata.Topics.root_topics(topics)) afp_structure.save_licenses(licenses.values.toList) afp_structure.save_releases(releases.values.toList.flatten) entries.foreach(afp_structure.save_entry) } } else { def check_toml_format(toml: Table, file: Path): Unit = { val present = File.read(file) val formatted = Format(toml) if (present != formatted) progress.echo_warning("Badly formatted toml: " + file) } progress.echo_if(verbose, "Checking formatting...") check_toml_format(TOML.from_authors(authors.values.toList), afp_structure.authors_file) if (format_all) { check_toml_format(TOML.from_topics(topics.values.toList), afp_structure.topics_file) check_toml_format(TOML.from_licenses(licenses.values.toList), afp_structure.licenses_file) check_toml_format(TOML.from_releases(releases.values.toList.flatten), afp_structure.releases_file) entries.foreach(entry => check_toml_format(TOML.from_entry(entry), afp_structure.entry_file(entry.name))) } } /* extra */ if (slow) { progress.echo_if(verbose, "Checking DOIs...") - entries.flatMap(entry => entry.related).collect { case d: DOI => d.formatted() } + entries.flatMap(entry => entry.related).collect { case d: Metadata.DOI => d.formatted() } } progress.echo_if(verbose, "Checked " + authors.size + " authors with " + affils.size + " affiliations, " + topics.size + " topics, " + releases.values.flatten.size + " releases, " + licenses.size + " licenses, and " + entries.size + " entries.") } val isabelle_tool = Isabelle_Tool("afp_check_metadata", "Checks the AFP metadata files", Scala_Project.here, { args => var slow = false var reformat = false var format_all = false var strict = false var verbose = false val getopts = Getopts(""" Usage: isabelle afp_check_metadata [OPTIONS] Options are: -a check formatting of all metadata -s activate slow checks -v verbose -R reformat metadata files -S strict mode (fail on warnings) Check AFP metadata files for consistency. """, "a" -> (_ => format_all = true), "s" -> (_ => slow = true), "v" -> (_ => verbose = true), "R" -> (_ => reformat = true), "S" -> (_ => strict = true)) getopts(args) val progress = new Console_Progress() val afp_structure = AFP_Structure() afp_check_metadata(afp_structure, strict = strict, reformat = reformat, format_all = format_all, slow = slow, verbose = verbose, progress = progress) }) } diff --git a/tools/afp_dependencies.scala b/tools/afp_dependencies.scala --- a/tools/afp_dependencies.scala +++ b/tools/afp_dependencies.scala @@ -1,85 +1,86 @@ /* Author: Fabian Huch, TU Muenchen Tool to extract dependencies of AFP entries. */ package afp import isabelle._ -import Metadata.Entry - object AFP_Dependencies { - case class Dependency(entry: Entry.Name, distrib_deps: List[Entry.Name], afp_deps: List[Entry.Name]) + case class Dependency( + entry: Metadata.Entry.Name, + distrib_deps: List[Metadata.Entry.Name], + afp_deps: List[Metadata.Entry.Name]) object JSON { private def from_dep(dependency: Dependency): (String, isabelle.JSON.T) = dependency.entry -> isabelle.JSON.Object( "distrib_deps" -> dependency.distrib_deps, "afp_deps" -> dependency.afp_deps ) def from_dependency(dep: Dependency): isabelle.JSON.T = isabelle.JSON.Object(from_dep(dep)) def from_dependencies(deps: List[Dependency]): isabelle.JSON.T = deps.map(from_dep).toMap } def afp_dependencies(afp_dir: Path): List[Dependency] = { val tree = Sessions.load_structure(Options.init(), Nil, List(afp_dir)) val selected = tree.selection(Sessions.Selection(false, false, Nil, Nil, Nil, Nil)) .build_graph.keys def get_entry(name: String): Option[String] = { val info = tree(name) val dir = info.dir if (selected.contains(dir.dir.expand)) None else Some(dir.base.implode) } selected.groupBy(get_entry).toList.collect { case (Some(e), sessions) => val dependencies = sessions.flatMap(tree.imports_graph.imm_preds) .map(d => (d, get_entry(d))) val distrib_deps = dependencies.collect { case (d, None) => d }.distinct val afp_deps = dependencies.collect { case (_, Some(d)) if d != e => d }.distinct Dependency(e, distrib_deps, afp_deps) } } val isabelle_tool = Isabelle_Tool("afp_dependencies", "extract dependencies between AFP entries", Scala_Project.here, { args => var output_file: Option[Path] = None val getopts = Getopts(""" Usage: isabelle afp_dependencies [OPTIONS] Options are: -o FILE output file name Extract dependencies between AFP entries. """, "o:" -> (arg => output_file = Some(Path.explode(arg)))) getopts(args) val afp_dir = Path.explode("$AFP").expand val progress = new Console_Progress() val res = afp_dependencies(afp_dir).map(JSON.from_dependency) val json = isabelle.JSON.Format(res) output_file match { case Some(file) => File.write(file, json) case None => progress.echo(json) } }) } diff --git a/tools/afp_release.scala b/tools/afp_release.scala --- a/tools/afp_release.scala +++ b/tools/afp_release.scala @@ -1,108 +1,106 @@ /* Author: Fabian Huch, TU Muenchen Tooling to manage AFP releases. */ package afp import isabelle.* -import afp.Metadata.{Entry, Isabelle, Release} - import java.time.LocalDate object AFP_Release { def afp_import_releases( user: String, host: String, releases_dir: Path, base_dir: Path, options: Options ): Unit = { val Release_Tar = """afp-(.+)-(\d{4}-\d{2}-\d{2})\.tar\.gz""".r val afp_structure = AFP_Structure(base_dir) val isabelle_releases = split_lines(File.read(afp_structure.metadata_dir + Path.basic("release-dates"))) val Isa_Release = """(.+) = (.+)""".r val release_dates = isabelle_releases.filterNot(_.isBlank).map { case Isa_Release(isabelle_version, date) => LocalDate.parse(date) -> isabelle_version case line => error("Could not parse: " + quote(line)) } using(SSH.open_session(options, host, user = user)) { ssh => val releases = ssh.read_dir(releases_dir).collect { case Release_Tar(entry, date_str) => val date = LocalDate.parse(date_str) release_dates.findLast { case (isa_date, _) => !isa_date.isAfter(date) } match { - case Some(_, isabelle) => Release(entry, date, isabelle) + case Some(_, isabelle) => Metadata.Release(entry, date, isabelle) case None => error("No Isabelle version found for " + date_str) } } afp_structure.save_releases(releases) } } - def afp_release(date: LocalDate, isabelle: Isabelle.Version, base_dir: Path): Unit = { - def add_release(entry: Entry): Entry = - entry.copy(releases = entry.releases :+ Release(entry.name, date, isabelle)) + def afp_release(date: LocalDate, isabelle: Metadata.Isabelle.Version, base_dir: Path): Unit = { + def add_release(entry: Metadata.Entry): Metadata.Entry = + entry.copy(releases = entry.releases :+ Metadata.Release(entry.name, date, isabelle)) val afp_structure = AFP_Structure(base_dir) val releases = afp_structure.load().values.toList.map(add_release).flatMap(_.releases) afp_structure.save_releases(releases) } val isabelle_tool = Isabelle_Tool("afp_release", "Create an AFP release", Scala_Project.here, { args => var isabelle = Isabelle_System.isabelle_identifier().getOrElse("") var date = LocalDate.now() var options = Options.init() var releases: String = "afpweb@isa-afp.org:/srv/afp/release" var import_releases = false val getopts = Getopts(""" Usage: isabelle afp_release [OPTIONS] Options are: -i ID Isabelle id (default: """ + quote(isabelle) + """) -d DATE release date (default: """ + quote(date.toString) + """) -o OPTION override Isabelle system OPTION (via NAME=VAL or NAME) -r REMOTE remote location of releases (default: """ + quote(releases) + """)" -I import releases from directory instead Register releases for old Isabelle version (when creating a new AFP release), or import all releases from release dir. """, "i:" -> (arg => isabelle = arg), "d:" -> (arg => date = LocalDate.parse(arg)), "o:" -> (arg => options = options + arg), "r:" -> (arg => releases = arg), "I" -> (_ => import_releases = true)) getopts(args) val base_dir = Path.explode("$AFP_BASE") if (import_releases) { val Remote = """([^@]+)@([^:]+):(.*)""".r releases match { case Remote(user, host, dir) => afp_import_releases(user = user, host = host, releases_dir = Path.explode(dir), base_dir = base_dir, options = options) case _ => error("Invalid remote: " + quote(releases)) } } else { if (isabelle.isEmpty) getopts.usage() afp_release(date, isabelle, base_dir) } }) } diff --git a/tools/afp_submit.scala b/tools/afp_submit.scala --- a/tools/afp_submit.scala +++ b/tools/afp_submit.scala @@ -1,1613 +1,1610 @@ /* Author: Fabian Huch, TU Muenchen AFP submission system backend. */ package afp import isabelle.* import isabelle.HTML.* import afp.Web_App.{ACTION, API, FILE, Params} import afp.Web_App.Params.{List_Key, Nest_Key, empty} import afp.Web_App.More_HTML.* import afp.Metadata.{Affiliation, Author, Authors, DOI, Email, Entry, Formatted, Homepage, License, Licenses, Orcid, Reference, Release, Releases, Topic, Topics, Unaffiliated} import java.text.Normalizer import java.time.LocalDate -import scala.collection.immutable.{ListMap, StringView} -import scala.util.matching.Regex - object AFP_Submit { object Val_Opt { def ok[A](value: A): Val_Opt[A] = Val_Opt(Some(value), None) def user_err[A](msg: String): Val_Opt[A] = Val_Opt(None, Some(msg)) def error[A]: Val_Opt[A] = Val_Opt(None, None) } case class Val_Opt[A] private(opt: Option[A], err: Option[String]) { def is_empty: Boolean = opt.isEmpty } object Val { def ok[A](value: A): Val[A] = Val(value, None) def err[A](value: A, msg: String): Val[A] = Val(value, Some(msg)) } case class Val[A] private(v: A, err: Option[String]) { def with_err(err: String): Val[A] = Val.err(v, err) def perhaps_err(opt: Val_Opt[_]): Val[A] = opt.err.map(with_err).getOrElse(this) } /* data model */ object Model { object Related extends Enumeration { val DOI, Plaintext = Value def from_string(s: String): Option[Value] = values.find(_.toString == s) def get(r: Reference): Value = r match { case afp.Metadata.DOI(_) => DOI case afp.Metadata.Formatted(_) => Plaintext } } case class Create_Entry( name: Val[String] = Val.ok(""), title: Val[String] = Val.ok(""), affils: Val[List[Affiliation]] = Val.ok(Nil), notifies: Val[List[Email]] = Val.ok(Nil), author_input: Option[Author] = None, notify_input: Option[Author] = None, topics: Val[List[Topic]] = Val.ok(Nil), topic_input: Option[Topic] = None, license: License, `abstract`: Val[String] = Val.ok(""), related: List[Reference] = Nil, related_kind: Option[Related.Value] = None, related_input: Val[String] = Val.ok("") ) { def used_affils: Set[Affiliation] = (affils.v ++ notifies.v).toSet def used_authors: Set[Author.ID] = used_affils.map(_.author) } case class Create( entries: Val[List[Create_Entry]] = Val.ok(Nil), new_authors: Val[List[Author]] = Val.ok(Nil), new_author_input: String = "", new_author_orcid: String = "", new_affils: Val[List[Affiliation]] = Val.ok(Nil), new_affils_author: Option[Author] = None, new_affils_input: String = "", ) extends T { def update_entry(num: Int, entry: Create_Entry): Create = copy(entries = Val.ok(entries.v.updated(num, entry))) def updated_authors(old_authors: Authors): Authors = (old_authors ++ new_authors.v.map(author => author.id -> author)).map { case (id, author) => val emails = author.emails ++ new_affils.v.collect { case e: Email if e.author == id => e } val homepages = author.homepages ++ new_affils.v.collect { case h: Homepage if h.author == id => h } id -> author.copy(emails = emails.distinct, homepages = homepages.distinct) } def used_affils: Set[Affiliation] = entries.v.toSet.flatMap(_.used_affils) def used_authors: Set[Author.ID] = new_affils.v.map(_.author).toSet ++ entries.v.flatMap(_.used_authors) def create(authors: Authors): (Authors, List[Entry]) = (updated_authors(authors), entries.v.map(entry => Entry( name = entry.name.v, title = entry.title.v, authors = entry.affils.v, date = LocalDate.now(), topics = entry.topics.v, `abstract` = entry.`abstract`.v.trim, notifies = entry.notifies.v, license = entry.license, note = "", related = entry.related))) } object Build extends Enumeration { val Pending, Running, Aborted, Failed, Success = Value } object Status extends Enumeration { val Submitted, Review, Added, Rejected = Value def from_string(s: String): Option[Value] = values.find(_.toString == s) } case class Overview(id: String, date: LocalDate, name: String, status: Status.Value) case class Metadata(authors: Authors, entries: List[Entry]) { def new_authors(old_authors: Authors): Set[Author] = entries.flatMap(_.authors).map(_.author).filterNot(old_authors.contains).toSet.map(authors) def new_affils(old_authors: Authors): Set[Affiliation] = entries.flatMap(entry => entry.authors ++ entry.notifies).toSet.filter { case _: Unaffiliated => false case e: Email => !old_authors.get(e.author).exists(_.emails.contains(e)) case h: Homepage => !old_authors.get(h.author).exists(_.homepages.contains(h)) } } sealed trait T case object Invalid extends T case class Upload(meta: Metadata, message: String, error: String = "") extends T case class Created(id: String) extends T case class Submission( id: Handler.ID, meta: Metadata, message: String, build: Build.Value, status: Option[Status.Value], log: String) extends T case class Submission_List(submissions: List[Overview]) extends T } /* Physical submission handling */ trait Handler { def create( date: Date, meta: Model.Metadata, message: String, archive: Bytes, ext: String ): Handler.ID def list(): Model.Submission_List def get(id: Handler.ID, topics: Topics, licenses: Licenses): Option[Model.Submission] def submit(id: Handler.ID): Unit def set_status(id: Handler.ID, status: Model.Status.Value): Unit def abort_build(id: Handler.ID): Unit def get_patch(id: Handler.ID): Option[Path] def get_archive(id: Handler.ID): Option[Path] } object Handler { import Model._ type ID = String object ID { private val format = Date.Format.make( List( Date.Formatter.pattern("uuuu-MM-dd_HH-mm-ss_SSS"), Date.Formatter.pattern("uuuu-MM-dd_HH-mm-ss_SSS_VV")), _ + "_" + Date.timezone().getId) def apply(submission_time: Date): ID = format(submission_time) def unapply(id: ID): Option[Date] = format.unapply(id) def check(id: ID): Option[ID] = unapply(id).map(apply) } /* Handler for local edits */ class Edit(afp_structure: AFP_Structure) extends Handler { val authors = afp_structure.load_authors val topics = afp_structure.load_topics val licenses = afp_structure.load_licenses val releases = afp_structure.load_releases val dates = afp_structure.load().view.mapValues(_.date).toMap def create( date: Date, meta: Metadata, message: String, archive: Bytes, ext: String ): ID = { val entry = meta.entries match { case e :: Nil => e case _ => isabelle.error("Must be a single entry") } val old = afp_structure.load_entry(entry.name, authors, topics, licenses, releases) val updated = old.copy( title = entry.title, authors = entry.authors, topics = entry.topics, `abstract` = entry.`abstract`, notifies = entry.notifies, license = entry.license, related = entry.related) afp_structure.save_entry(updated) // TODO what happens to the authors entry.name } def list(): Submission_List = Submission_List(afp_structure.entries.sortBy(dates.get).reverse.map { entry => Overview(entry, dates(entry), entry, Status.Added) }) def get(id: ID, topics: Topics, licenses: Licenses): Option[Submission] = if (!afp_structure.entries.contains(id)) None else { val entry = afp_structure.load_entry(id, authors, topics, licenses, releases) val meta = Metadata(authors, List(entry)) Some(Submission(id, meta, "", Model.Build.Success, Some(Status.Added), "")) } def submit(id: ID): Unit = () def set_status(id: ID, status: Model.Status.Value): Unit = () def abort_build(id: ID): Unit = () def get_patch(id: ID): Option[Path] = None def get_archive(id: ID): Option[Path] = None } /* Adapter to existing submission system */ class Adapter(submission_dir: Path, afp_structure: AFP_Structure) extends Handler { private val up: Path = submission_dir + Path.basic("up") private def up(id: ID): Path = up + Path.basic(id) private def down(id: ID): Path = submission_dir + Path.basic("down") + Path.basic(id) private def signal(id: ID, s: String): Unit = File.write(up(id) + Path.basic(s), s.toUpperCase) private def is_signal(id: ID, s: String): Boolean = (up(id) + Path.basic(s)).file.exists() private def read_build(id: ID): Build.Value = { val build = down(id) + Path.basic("result") if (!build.file.exists) Build.Pending else File.read(build).trim match { case "" => Build.Running case "NOT_FINISHED" => Build.Running case "FAILED" => if (is_signal(id, "kill")) Build.Aborted else Build.Failed case "SUCCESS" => Build.Success case s => isabelle.error("Unkown build status: " + quote(s)) } } private def status_file(id: ID): Path = up(id) + Path.basic("AFP_STATUS") private def read_status(id: ID): Option[Status.Value] = { val status = status_file(id) if (!status.file.exists()) None else File.read(status).trim match { case "SUBMITTED" => Some(Status.Submitted) case "PROCESSING" => Some(Status.Review) case "REJECTED" => Some(Status.Rejected) case "ADDED" => Some(Status.Added) case s => isabelle.error("Unknown status: " + quote(s)) } } private def info_file(id: ID): Path = up(id) + Path.basic("info.json") private def patch_file(id: ID): Path = up(id) + Path.basic("patch") private val archive_name: String = "archive" def make_partial_patch(base_dir: Path, src: Path, dst: Path): String = { val patch = Isabelle_System.make_patch(base_dir, src, dst, "--unidirectional-new-file") split_lines(patch).filterNot(_.startsWith("Only in")).mkString("\n") } def create(date: Date, meta: Metadata, message: String, archive: Bytes, ext: String): ID = { val id = ID(date) val dir = up(id) dir.file.mkdirs() val structure = AFP_Structure(dir) structure.save_authors(meta.authors.values.toList.sortBy(_.id)) meta.entries.foreach(structure.save_entry) val archive_file = dir + Path.basic(archive_name + ext) Bytes.write(archive_file, archive) val metadata_rel = File.relative_path(afp_structure.base_dir, afp_structure.metadata_dir).getOrElse( afp_structure.metadata_dir) val metadata_patch = make_partial_patch(afp_structure.base_dir, metadata_rel, structure.metadata_dir) File.write(patch_file(id), metadata_patch) val info = JSON.Format(JSON.Object( "comment" -> message, "entries" -> meta.entries.map(_.name), "notify" -> meta.entries.flatMap(_.notifies).map(_.address).distinct)) File.write(info_file(id), info) signal(id, "done") id } def list(): Submission_List = Submission_List( File.read_dir(up).flatMap(ID.unapply).reverse.flatMap { date => val id = ID(date) val day = date.rep.toLocalDate read_status(id).map(Overview(id, day, AFP_Structure(up(id)).entries_unchecked.head, _)) }) def get(id: ID, topics: Topics, licenses: Licenses): Option[Submission] = ID.check(id).filter(up(_).file.exists).map { id => val structure = AFP_Structure(up(id)) val authors = structure.load_authors val entries = structure.entries_unchecked.map( structure.load_entry(_, authors, topics, licenses, Releases.empty)) val log_file = down(id) + Path.basic("isabelle.log") val log = if (log_file.file.exists) File.read(log_file) else "" JSON.parse(File.read(info_file(id))) match { case JSON.Object(m) if m.contains("comment") => val meta = Metadata(authors, entries) Submission(id, meta, m("comment").toString, read_build(id), read_status(id), log) case _ => isabelle.error("Could not read info") } } private def check(id: ID): Option[ID] = ID.check(id).filter(up(_).file.exists) def submit(id: ID): Unit = check(id).foreach(signal(_, "mail")) def set_status(id: ID, status: Status.Value): Unit = check(id).foreach { id => val content = status match { case Status.Submitted => "SUBMITTED" case Status.Review => "PROCESSING" case Status.Added => "ADDED" case Status.Rejected => "REJECTED" } File.write(status_file(id), content) } def abort_build(id: ID): Unit = check(id).foreach(signal(_, "kill")) def get_patch(id: ID): Option[Path] = check(id).map(patch_file) def get_archive(id: ID): Option[Path] = check(id).flatMap { id => val dir = up(id) File.read_dir(dir).find(_.startsWith(archive_name + ".")).map(dir + Path.basic(_)) } } } /* server */ object Mode extends Enumeration { val EDIT, SUBMISSION = Value } class Server( api: API, afp_structure: AFP_Structure, mode: Mode.Value, handler: Handler, devel: Boolean, verbose: Boolean, progress: Progress, port: Int = 0 ) extends Web_App.Server[Model.T](api, port, verbose, progress) { val repo = Mercurial.the_repository(afp_structure.base_dir) var authors: Authors = Authors.empty var topics: Topics = Topics.empty var licenses: Licenses = Licenses.empty var releases: Releases = Releases.empty var entries: Set[Entry.Name] = Set.empty private def load(): Unit = synchronized { authors = afp_structure.load_authors topics = afp_structure.load_topics licenses = afp_structure.load_licenses releases = afp_structure.load_releases entries = afp_structure.entries.toSet } load() /* endpoints */ val SUBMIT = Path.explode("submit") val SUBMISSION = Path.explode("submission") val SUBMISSIONS = Path.explode("submissions") val API_SUBMISSION = Path.explode("api/submission") val API_SUBMISSION_UPLOAD = Path.explode("api/submission/upload") val API_SUBMISSION_CREATE = Path.explode("api/submission/create") val API_SUBMISSION_STATUS = Path.explode("api/submission/status") val API_RESUBMIT = Path.explode("api/resubmit") val API_BUILD_ABORT = Path.explode("api/build/abort") val API_SUBMIT = Path.explode("api/submit") val API_SUBMISSION_AUTHORS_ADD = Path.explode("api/submission/authors/add") val API_SUBMISSION_AUTHORS_REMOVE = Path.explode("api/submission/authors/remove") val API_SUBMISSION_AFFILIATIONS_ADD = Path.explode("api/submission/affiliations/add") val API_SUBMISSION_AFFILIATIONS_REMOVE = Path.explode("api/submission/affiliations/remove") val API_SUBMISSION_ENTRIES_ADD = Path.explode("api/submission/entries/add") val API_SUBMISSION_ENTRIES_REMOVE = Path.explode("api/submission/entries/remove") val API_SUBMISSION_ENTRY_TOPICS_ADD = Path.explode("api/submission/entry/topics/add") val API_SUBMISSION_ENTRY_TOPICS_REMOVE = Path.explode("api/submission/entry/topics/remove") val API_SUBMISSION_ENTRY_AUTHORS_ADD = Path.explode("api/submission/entry/authors/add") val API_SUBMISSION_ENTRY_AUTHORS_REMOVE = Path.explode("api/submission/entry/authors/remove") val API_SUBMISSION_ENTRY_NOTIFIES_ADD = Path.explode("api/submission/entry/notifies/add") val API_SUBMISSION_ENTRY_NOTIFIES_REMOVE = Path.explode("api/submission/entry/notifies/remove") val API_SUBMISSION_ENTRY_RELATED_ADD = Path.explode("api/submission/entry/related/add") val API_SUBMISSION_ENTRY_RELATED_REMOVE = Path.explode("api/submission/entry/related/remove") val API_SUBMISSION_DOWNLOAD = Path.explode("api/download/patch") val API_SUBMISSION_DOWNLOAD_ZIP = Path.explode("api/download/archive.zip") val API_SUBMISSION_DOWNLOAD_TGZ = Path.explode("api/download/archive.tar.gz") val API_CSS = Path.explode("api/main.css") /* fields */ val ABSTRACT = "abstract" val ADDRESS = "address" val AFFILIATION = "affiliation" val ARCHIVE = "archive" val AUTHOR = "author" val DATE = "date" val ENTRY = "entry" val ID = "id" val INPUT = "input" val KIND = "kind" val LICENSE = "license" val MESSAGE = "message" val NAME = "name" val NOTIFY = "notify" val ORCID = "orcid" val RELATED = "related" val STATUS = "status" val TITLE = "title" val TOPIC = "topic" /* utils */ def keyed(id: String, value: String): String = "[" + id + "] " + value def author_string(author: Author): String = { val orcid = author.orcid.map(orcid => " (ORCID id: " + orcid.identifier + ")").getOrElse("") keyed(author.id, author.name) + orcid } def author_option(author: Author): XML.Elem = option(author.id, author_string(author)) def affil_id(affil: Affiliation): String = affil match { case Unaffiliated(_) => "" case Email(_, id, _) => id case Homepage(_, id, _) => id } def affil_address(affil: Affiliation): String = affil match { case Unaffiliated(_) => "" case Email(_, _, address) => address case Homepage(_, _, url) => url.toString } def affil_string(affil: Affiliation): String = affil match { case Unaffiliated(_) => "No email or homepage" case Email(_, id, address) => keyed(id, address) case Homepage(_, id, url) => keyed(id, url.toString) } def related_string(related: Reference): String = related match { case Metadata.DOI(identifier) => identifier case Metadata.Formatted(rep) => rep } def indexed[A, B](l: List[A], key: Params.Key, field: String, f: (A, Params.Key) => B) = l.zipWithIndex map { case (a, i) => f(a, List_Key(key, field, i)) } def fold[A](it: List[Params.Data], a: A)(f: (Params.Data, A) => Option[A]): Option[A] = it.foldLeft(Option(a)) { case (None, _) => None case (Some(a), param) => f(param, a) } def download_link(href: String, body: XML.Body): XML.Elem = class_("download")(link(href, body)) + ("target" -> "_blank") def frontend_link(path: Path, params: Properties.T, body: XML.Body): XML.Elem = link(api.app_url(path, params), body) + ("target" -> "_parent") def render_if(cond: Boolean, body: XML.Body): XML.Body = if (cond) body else Nil def render_if(cond: Boolean, elem: XML.Elem): XML.Body = if (cond) List(elem) else Nil def render_error(for_elem: String, validated: Val[_]): XML.Body = validated.err.map(error => break ::: List(css("color: red")(label(for_elem, error)))).getOrElse(Nil) /* view */ def render_create(model: Model.Create): XML.Body = { val updated_authors = model.updated_authors(authors) val authors_list = updated_authors.values.toList.sortBy(_.id) val (entry_authors, other_authors) = updated_authors.values.toList.sortBy(_.id).partition(author => model.used_authors.contains(author.id)) val email_authors = authors_list.filter(_.emails.nonEmpty) def render_topic(topic: Topic, key: Params.Key): XML.Elem = par( hidden(Nest_Key(key, ID), topic.id) :: text(topic.id) ::: action_button(api.api_url(API_SUBMISSION_ENTRY_TOPICS_REMOVE), "x", key) :: Nil) def render_affil(affil: Affiliation, key: Params.Key): XML.Elem = { val author = updated_authors(affil.author) val affils = author.emails ::: author.homepages ::: Unaffiliated(author.id) :: Nil par( hidden(Nest_Key(key, ID), affil.author) :: text(author_string(updated_authors(affil.author))) ::: selection(Nest_Key(key, AFFILIATION), Some(affil_id(affil)), affils.map(affil => option(affil_id(affil), affil_string(affil)))) :: action_button(api.api_url(API_SUBMISSION_ENTRY_AUTHORS_REMOVE), "x", key) :: Nil) } def render_notify(email: Email, key: Params.Key): XML.Elem = { val author = updated_authors(email.author) par( hidden(Nest_Key(key, ID), email.author) :: text(author_string(updated_authors(email.author))) ::: selection( Nest_Key(key, AFFILIATION), Some(affil_id(email)), author.emails.map(affil => option(affil_id(affil), affil_string(affil)))) :: action_button(api.api_url(API_SUBMISSION_ENTRY_NOTIFIES_REMOVE), "x", key) :: Nil) } def render_related(related: Reference, key: Params.Key): XML.Elem = par( hidden(Nest_Key(key, KIND), Model.Related.get(related).toString) :: hidden(Nest_Key(key, INPUT), related_string(related)) :: text(related_string(related)) ::: action_button(api.api_url(API_SUBMISSION_ENTRY_RELATED_REMOVE), "x", key) :: Nil) def render_entry(entry: Model.Create_Entry, key: Params.Key): XML.Elem = fieldset(legend("Entry") :: par( fieldlabel(Nest_Key(key, TITLE), "Title of article") :: textfield(Nest_Key(key, TITLE), "Example Submission", entry.title.v) :: render_error(Nest_Key(key, TITLE), entry.title)) :: par( fieldlabel(Nest_Key(key, NAME), "Short name") :: textfield(Nest_Key(key, NAME), "Example_Submission", entry.name.v) :: explanation(Nest_Key(key, NAME), "Name of the folder where your ROOT and theory files reside.") :: render_error(Nest_Key(key, NAME), entry.name)) :: fieldset(legend("Topics") :: indexed(entry.topics.v, key, TOPIC, render_topic) ::: selection(Nest_Key(key, TOPIC), entry.topic_input.map(_.id), topics.values.toList.map(topic => option(topic.id, topic.id))) :: action_button(api.api_url(API_SUBMISSION_ENTRY_TOPICS_ADD), "add", key) :: render_error("", entry.topics)) :: par(List( fieldlabel(Nest_Key(key, LICENSE), "License"), radio(Nest_Key(key, LICENSE), entry.license.id, licenses.values.toList.map(license => license.id -> license.name)), explanation(Nest_Key(key, LICENSE), "Note: For LGPL submissions, please include the header \"License: LGPL\" in each file"))) :: par( fieldlabel(Nest_Key(key, ABSTRACT), "Abstract") :: placeholder("HTML and MathJax, no LaTeX")( textarea(Nest_Key(key, ABSTRACT), entry.`abstract`.v) + ("rows" -> "8") + ("cols" -> "70")) :: explanation(Nest_Key(key, ABSTRACT), "Note: You can use HTML or MathJax (not LaTeX!) to format your abstract.") :: render_error(Nest_Key(key, ABSTRACT), entry.`abstract`)) :: fieldset(legend("Authors") :: indexed(entry.affils.v, key, AUTHOR, render_affil) ::: selection(Nest_Key(key, AUTHOR), entry.author_input.map(_.id), authors_list.map(author => option(author.id, author_string(author)))) :: action_button(api.api_url(API_SUBMISSION_ENTRY_AUTHORS_ADD), "add", key) :: explanation(Nest_Key(key, AUTHOR), "Add an author from the list. Register new authors first below.") :: render_error(Nest_Key(key, AUTHOR), entry.affils)) :: fieldset(legend("Contact") :: indexed(entry.notifies.v, key, NOTIFY, render_notify) ::: selection(Nest_Key(key, NOTIFY), entry.notify_input.map(_.id), optgroup("Suggested", email_authors.filter(author => entry.used_authors.contains(author.id)).map(author_option)) :: email_authors.filter(author => !entry.used_authors.contains(author.id)).map(author_option)) :: action_button(api.api_url(API_SUBMISSION_ENTRY_NOTIFIES_ADD), "add", key) :: explanation(Nest_Key(key, NOTIFY), "These addresses serve two purposes: " + "1. They are used to send you updates about the state of your submission. " + "2. They are the maintainers of the entry once it is accepted. " + "Typically this will be one or more of the authors.") :: render_error("", entry.notifies)) :: fieldset(legend("Related Publications") :: indexed(entry.related, key, RELATED, render_related) ::: selection(Nest_Key(Nest_Key(key, RELATED), KIND), entry.related_kind.map(_.toString), Model.Related.values.toList.map(v => option(v.toString, v.toString))) :: textfield(Nest_Key(Nest_Key(key, RELATED), INPUT), "10.1109/5.771073", entry.related_input.v) :: action_button(api.api_url(API_SUBMISSION_ENTRY_RELATED_ADD), "add", key) :: explanation(Nest_Key(Nest_Key(key, RELATED), INPUT), "Publications related to the entry, as DOIs (10.1109/5.771073) or plaintext (HTML)." + "Typically a publication by the authors describing the entry," + " background literature (articles, books) or web resources. ") :: render_error(Nest_Key(Nest_Key(key, RELATED), INPUT), entry.related_input)) :: render_if(mode == Mode.SUBMISSION, action_button(api.api_url(API_SUBMISSION_ENTRIES_REMOVE), "remove entry", key))) def render_new_author(author: Author, key: Params.Key): XML.Elem = par( hidden(Nest_Key(key, ID), author.id) :: hidden(Nest_Key(key, NAME), author.name) :: hidden(Nest_Key(key, ORCID), author.orcid.map(_.identifier).getOrElse("")) :: text(author_string(author)) ::: render_if(!model.used_authors.contains(author.id), action_button(api.api_url(API_SUBMISSION_AUTHORS_REMOVE), "x", key))) def render_new_affil(affil: Affiliation, key: Params.Key): XML.Elem = par( hidden(Nest_Key(key, AUTHOR), affil.author) :: hidden(Nest_Key(key, ID), affil_id(affil)) :: hidden(Nest_Key(key, AFFILIATION), affil_address(affil)) :: text(author_string(updated_authors(affil.author)) + ": " + affil_string(affil)) ::: render_if(!model.used_affils.contains(affil), action_button(api.api_url(API_SUBMISSION_AFFILIATIONS_REMOVE), "x", key))) val (upload, preview) = mode match { case Mode.EDIT => ("Save", "preview and save >") case Mode.SUBMISSION => ("Upload", "preview and upload >") } List(submit_form(api.api_url(API_SUBMISSION), indexed(model.entries.v, Params.empty, ENTRY, render_entry) ::: render_error("", model.entries) ::: render_if(mode == Mode.SUBMISSION, par(List( explanation("", "You can submit multiple entries at once. " + "Put the corresponding folders in the archive " + "and use the button below to add more input fields for metadata. "), api_button(api.api_url(API_SUBMISSION_ENTRIES_ADD), "additional entry")))) ::: break ::: fieldset(legend("New Authors") :: explanation("", "If you are new to the AFP, add yourself here.") :: indexed(model.new_authors.v, Params.empty, AUTHOR, render_new_author) ::: fieldlabel(Nest_Key(AUTHOR, NAME), "Name") :: textfield(Nest_Key(AUTHOR, NAME), "Gerwin Klein", model.new_author_input) :: fieldlabel(Nest_Key(AUTHOR, ORCID), "ORCID id (optional)") :: textfield(Nest_Key(AUTHOR, ORCID), "0000-0002-1825-0097", model.new_author_orcid) :: api_button(api.api_url(API_SUBMISSION_AUTHORS_ADD), "add") :: render_error("", model.new_authors)) :: fieldset(legend("New email or homepage") :: explanation("", "Add new email or homepages here. " + "If you would like to update an existing, " + "submit with the old one and write to the editors.") :: indexed(model.new_affils.v, Params.empty, AFFILIATION, render_new_affil) ::: fieldlabel(AFFILIATION, "Author") :: selection(AFFILIATION, model.new_affils_author.map(_.id), optgroup("Entry authors", entry_authors.map(author_option)) :: other_authors.map(author_option)) :: fieldlabel(Nest_Key(AFFILIATION, ADDRESS), "Email or Homepage") :: textfield(Nest_Key(AFFILIATION, ADDRESS), "https://proofcraft.org", model.new_affils_input) :: api_button(api.api_url(API_SUBMISSION_AFFILIATIONS_ADD), "add") :: render_error("", model.new_affils)) :: break ::: fieldset(List(legend(upload), api_button(api.api_url(API_SUBMISSION_UPLOAD), preview))) :: Nil)) } def render_metadata(meta: Model.Metadata): XML.Body = { def render_topic(topic: Topic, key: Params.Key): XML.Elem = item(hidden(Nest_Key(key, ID), topic.id) :: text(topic.id)) def render_affil(affil: Affiliation, key: Params.Key): XML.Elem = item( hidden(Nest_Key(key, ID), affil.author) :: hidden(Nest_Key(key, AFFILIATION), affil_id(affil)) :: text(author_string(meta.authors(affil.author)) + ", " + affil_string(affil))) def render_related(related: Reference, key: Params.Key): XML.Elem = item( hidden(Nest_Key(key, KIND), Model.Related.get(related).toString) :: hidden(Nest_Key(key, INPUT), related_string(related)) :: input_raw(related_string(related)) :: Nil) def render_entry(entry: Entry, key: Params.Key): XML.Elem = fieldset(List( legend("Entry"), par( fieldlabel(Nest_Key(key, TITLE), "Title") :: hidden(Nest_Key(key, TITLE), entry.title) :: text(entry.title)), par( fieldlabel(Nest_Key(key, NAME), "Short Name") :: hidden(Nest_Key(key, NAME), entry.name) :: text(entry.name)), par( fieldlabel(Nest_Key(key, DATE), "Date") :: hidden(Nest_Key(key, DATE), entry.date.toString) :: text(entry.date.toString)), par(List( fieldlabel("", "Topics"), list(indexed(entry.topics, key, TOPIC, render_topic)))), par( fieldlabel(Nest_Key(key, LICENSE), "License") :: hidden(Nest_Key(key, LICENSE), entry.license.id) :: text(entry.license.name)), par(List( fieldlabel(Nest_Key(key, ABSTRACT), "Abstract"), hidden(Nest_Key(key, ABSTRACT), entry.`abstract`), class_("mathjax_process")(span(List(input_raw(entry.`abstract`)))))), par(List( fieldlabel("", "Authors"), list(indexed(entry.authors, key, AUTHOR, render_affil)))), par(List( fieldlabel("", "Contact"), list(indexed(entry.notifies, key, NOTIFY, render_affil)))), par(List( fieldlabel("", "Related Publications"), list(indexed(entry.related, key, RELATED, render_related)))))) def render_new_author(author: Author, key: Params.Key): XML.Elem = par(List( hidden(Nest_Key(key, ID), author.id), hidden(Nest_Key(key, NAME), author.name), hidden(Nest_Key(key, ORCID), author.orcid.map(_.identifier).getOrElse("")))) def render_new_affil(affil: Affiliation, key: Params.Key): XML.Elem = par(List( hidden(Nest_Key(key, AUTHOR), affil.author), hidden(Nest_Key(key, ID), affil_id(affil)), hidden(Nest_Key(key, AFFILIATION), affil_address(affil)))) indexed(meta.entries, Params.empty, ENTRY, render_entry) ::: indexed(meta.new_authors(authors).toList, Params.empty, AUTHOR, render_new_author) ::: indexed(meta.new_affils(authors).toList, Params.empty, AFFILIATION, render_new_affil) } def render_submission(submission: Model.Submission): XML.Body = { def status_text(status: Option[Model.Status.Value]): String = status.map { case Model.Status.Submitted => "AFP editors notified." case Model.Status.Review => "Review in progress." case Model.Status.Added => "Added to the AFP." case Model.Status.Rejected => "Submission rejected." } getOrElse "Draft saved. Check the logs for errors and warnings, " + "and submit to editors once successfully built." val archive_url = if (handler.get_archive(submission.id).exists(_.get_ext == "zip")) API_SUBMISSION_DOWNLOAD_ZIP else API_SUBMISSION_DOWNLOAD_TGZ val resubmit = mode match { case Mode.EDIT => "Update" case Mode.SUBMISSION => "Resubmit" } List(submit_form(api.api_url(SUBMISSION, List(ID -> submission.id)), render_if(mode == Mode.SUBMISSION, download_link(api.api_url(archive_url, List(ID -> submission.id)), text("archive")) :: download_link(api.api_url(API_SUBMISSION_DOWNLOAD, List(ID -> submission.id)), text("metadata patch")) :: text(" (apply with: 'patch -p0 < FILE')")) ::: render_if(mode == Mode.SUBMISSION, par( hidden(MESSAGE, submission.message) :: text("Comment: " + submission.message))) ::: section("Metadata") :: render_metadata(submission.meta) ::: section("Status") :: span(text(status_text(submission.status))) :: render_if(submission.build != Model.Build.Running, action_button(api.api_url(API_RESUBMIT), resubmit, submission.id)) ::: render_if(submission.build == Model.Build.Running, action_button(api.api_url(API_BUILD_ABORT), "Abort build", submission.id)) ::: render_if(submission.build == Model.Build.Success && submission.status.isEmpty, action_button(api.api_url(API_SUBMIT), "Send submission to AFP editors", submission.id)) ::: render_if(mode == Mode.SUBMISSION, fieldset(legend("Build") :: bold(text(submission.build.toString)) :: par(text("Isabelle log:") ::: source(submission.log) :: Nil) :: Nil)))) } def render_upload(upload: Model.Upload): XML.Body = { val submit = mode match { case Mode.EDIT => "save" case Mode.SUBMISSION => "submit" } List(submit_form(api.api_url(API_SUBMISSION), List( fieldset(legend("Overview") :: render_metadata(upload.meta)), fieldset(legend("Submit") :: api_button(api.api_url(API_SUBMISSION), "< edit metadata") :: render_if(mode == Mode.SUBMISSION, List( par(List( fieldlabel(MESSAGE, "Message for the editors (optional)"), textfield(MESSAGE, "", upload.message), explanation( MESSAGE, "Note: Anything special or noteworthy about your submission can be covered here. " + "It will not become part of your entry. " + "You're also welcome to leave suggestions for our AFP submission service here. ") )), par(List( fieldlabel(ARCHIVE, "Archive file (.tar.gz or .zip)"), browse(ARCHIVE, List(".zip", ".tar.gz")), explanation(ARCHIVE, "Note: Your zip or tar file must contain exactly one folder for each entry with your theories, ROOT, etc. " + "The folder name must be the short/folder name used in the submission form. " + "Hidden files and folders (e.g., __MACOSX) are not allowed."))))) ::: api_button(api.api_url(API_SUBMISSION_CREATE), submit) :: render_error(ARCHIVE, Val.err((), upload.error)))))) } def render_submission_list(submission_list: Model.Submission_List): XML.Body = { def render_overview(overview: Model.Overview, key: Params.Key): XML.Elem = item( hidden(Nest_Key(key, ID), overview.id) :: hidden(Nest_Key(key, DATE), overview.date.toString) :: hidden(Nest_Key(key, NAME), overview.name) :: span(text(overview.date.toString)) :: span(List(frontend_link(SUBMISSION, List(ID -> overview.id), text(overview.name)))) :: render_if(mode == Mode.SUBMISSION, class_("right")(span(List( selection(Nest_Key(key, STATUS), Some(overview.status.toString), Model.Status.values.toList.map(v => option(v.toString, v.toString))), action_button(api.api_url(API_SUBMISSION_STATUS), "update", key)))))) def list1(ls: List[XML.Elem]): XML.Elem = if (ls.isEmpty) par(Nil) else list(ls) val ls = indexed(submission_list.submissions, Params.empty, ENTRY, (o, k) => (o, k)) val finished = ls.filter(t => Set(Model.Status.Added, Model.Status.Rejected).contains(t._1.status)) List(submit_form(api.api_url(API_SUBMISSION_STATUS), render_if(mode == Mode.SUBMISSION, text("Open") ::: list1(ls.filter(_._1.status == Model.Status.Submitted).map(render_overview)) :: text("In Progress") ::: list1(ls.filter(_._1.status == Model.Status.Review).map(render_overview)) :: text("Finished")) ::: list1(finished.map(render_overview)) :: Nil)) } def render_created(created: Model.Created): XML.Body = { val status = mode match { case Mode.SUBMISSION => "View your submission status: " case Mode.EDIT => "View the entry at: " } List(div( span(text("Entry successfully saved. " + status)) :: break ::: frontend_link(SUBMISSION, List(ID -> created.id), text(api.app_url(SUBMISSION, List(ID -> created.id)))) :: break ::: render_if(mode == Mode.SUBMISSION, span(text("(keep that url!)."))))) } def render_invalid: XML.Body = text("Invalid request") def render(model: Model.T): XML.Body = model match { case create: Model.Create => render_create(create) case upload: Model.Upload => render_upload(upload) case submission: Model.Submission => render_submission(submission) case submissions: Model.Submission_List => render_submission_list(submissions) case created: Model.Created => render_created(created) case Model.Invalid => render_invalid } /* validation */ def validate_topic(id: String, selected: List[Topic]): Val_Opt[Topic] = if (id.isEmpty) Val_Opt.user_err("Select topic first") else { topics.values.find(_.id == id) match { case Some(topic) => if (selected.contains(topic)) Val_Opt.user_err("Topic already selected") else Val_Opt.ok(topic) case _ => Val_Opt.error } } def validate_new_author( id: String, name: String, orcid_str: String, authors: Authors ): Val_Opt[Author] = { val Id = """[a-zA-Z][a-zA-Z0-9]*""".r id match { case Id() if !authors.contains(id) => if (name.isEmpty || name.trim != name) Val_Opt.user_err("Name must not be empty") else if (orcid_str.isEmpty) Val_Opt.ok(Author(id, name)) else Exn.capture(Orcid(orcid_str)) match { case Exn.Res(orcid) => if (authors.values.exists(_.orcid.exists(_ == orcid))) Val_Opt.user_err("Author with that orcid already exists") else Val_Opt.ok(Author(id, name, orcid = Some(orcid))) case _ => Val_Opt.user_err("Invalid orcid") } case _ => Val_Opt.error } } def validate_new_affil(id: String, address: String, author: Author): Val_Opt[Affiliation] = { if (address.isBlank) Val_Opt.user_err("Address must not be empty") else if (address.contains("@")) { val Id = (author.id + """_email\d*""").r id match { case Id() if !author.emails.map(_.id).contains(id) => val Address = """([^@\s]+)@([^@\s]+)""".r address match { case Address(user, host) => Val_Opt.ok(Email(author.id, id, user, host)) case _ => Val_Opt.user_err("Invalid email address") } case _ => Val_Opt.error } } else { val Id = (author.id + """_homepage\d*""").r id match { case Id() if !author.homepages.map(_.id).contains(id) => if (Url.is_wellformed(address)) Val_Opt.ok(Homepage(author.id, id, Url(address))) else Val_Opt.user_err("Invalid url") case _ => Val_Opt.error } } } def validate_related( kind: Model.Related.Value, related: String, references: List[Reference] ): Val_Opt[Reference] = if (related.isBlank) Val_Opt.user_err("Reference must not be empty") else { kind match { case Model.Related.DOI => Exn.capture(DOI(related)) match { case Exn.Res(doi) => if (references.contains(doi)) Val_Opt.user_err("Already present") else Val_Opt.ok(doi) case _ => Val_Opt.user_err("Invalid DOI format") } case Model.Related.Plaintext => val formatted = Formatted(related) if (references.contains(formatted)) Val_Opt.user_err("Already present") else Val_Opt.ok(formatted) } } /* param parsing */ def parse_create(params: Params.Data): Option[Model.Create] = { def parse_topic(topic: Params.Data, topics: List[Topic]): Option[Topic] = validate_topic(topic.get(ID).value, topics).opt def parse_email(email: Params.Data, authors: Authors): Option[Email] = authors.get(email.get(ID).value).flatMap( _.emails.find(_.id == email.get(AFFILIATION).value)) def parse_affil(affil: Params.Data, authors: Authors): Option[Affiliation] = authors.get(affil.get(ID).value).flatMap { author => val id = affil.get(AFFILIATION).value if (id.isEmpty) Some(Unaffiliated(author.id)) else (author.emails ++ author.homepages).collectFirst { case e: Email if e.id == id => e case h: Homepage if h.id == id => h } } def parse_related(related: Params.Data, references: List[Reference]): Option[Reference] = Model.Related.from_string(related.get(KIND).value).flatMap( validate_related(_, related.get(INPUT).value, references).opt) def parse_new_author(author: Params.Data, authors: Authors): Option[Author] = validate_new_author( author.get(ID).value, author.get(NAME).value, author.get(ORCID).value, authors).opt def parse_new_affil(affil: Params.Data, authors: Authors): Option[Affiliation] = authors.get(affil.get(AUTHOR).value).flatMap(author => validate_new_affil(affil.get(ID).value, affil.get(AFFILIATION).value, author).opt) def parse_entry(entry: Params.Data, authors: Authors): Option[Model.Create_Entry] = for { topics <- fold(entry.list(TOPIC), List.empty[Topic]) { case (topic, topics) => parse_topic(topic, topics).map(topics :+ _) } affils <- fold(entry.list(AUTHOR), List.empty[Affiliation]) { case (affil, affils) => parse_affil(affil, authors).map(affils :+ _) } notifies <- fold(entry.list(NOTIFY), List.empty[Email]) { case (email, emails) => parse_email(email, authors).map(emails :+ _) } related <- fold(entry.list(RELATED), List.empty[Reference]) { case (related, references) => parse_related(related, references).map(references :+ _) } license <- licenses.get(entry.get(LICENSE).value) } yield Model.Create_Entry( name = Val.ok(entry.get(NAME).value), title = Val.ok(entry.get(TITLE).value), topics = Val.ok(topics), topic_input = parse_topic(entry.get(TOPIC), Nil), license = license, `abstract` = Val.ok(entry.get(ABSTRACT).value), author_input = authors.get(entry.get(AUTHOR).value), notify_input = authors.get(entry.get(NOTIFY).value), affils = Val.ok(affils), notifies = Val.ok(notifies), related = related, related_kind = Model.Related.from_string(entry.get(RELATED).get(KIND).value), related_input = Val.ok(entry.get(RELATED).get(INPUT).value)) for { (new_author_ids, all_authors) <- fold(params.list(AUTHOR), (List.empty[Author.ID], authors)) { case (author, (new_authors, authors)) => parse_new_author(author, authors).map(author => (new_authors :+ author.id, authors.updated(author.id, author))) } (new_affils, all_authors) <- fold(params.list(AFFILIATION), (List.empty[Affiliation], all_authors)) { case (affil, (new_affils, authors)) => parse_new_affil(affil, authors).map { affil => val author = authors(affil.author) (new_affils :+ affil, affil match { case _: Unaffiliated => authors case e: Email => authors.updated(author.id, author.copy(emails = author.emails :+ e)) case h: Homepage => authors.updated(author.id, author.copy(homepages = author.homepages :+ h)) }) } } new_authors = new_author_ids.map(all_authors) entries <- fold(params.list(ENTRY), List.empty[Model.Create_Entry]) { case (entry, entries) => parse_entry(entry, all_authors).map(entries :+ _) } } yield Model.Create( entries = Val.ok(entries), new_authors = Val.ok(new_authors), new_author_input = params.get(AUTHOR).get(NAME).value, new_author_orcid = params.get(AUTHOR).get(ORCID).value, new_affils = Val.ok(new_affils), new_affils_author = all_authors.get(params.get(AFFILIATION).value), new_affils_input = params.get(AFFILIATION).get(ADDRESS).value) } def parse_submission_list(params: Params.Data): Option[Model.Submission_List] = { def parse_overview(entry: Params.Data): Option[Model.Overview] = for { date <- Exn.capture(LocalDate.parse(entry.get(DATE).value)) match { case Exn.Res(date) => Some(date) case Exn.Exn(_) => None } status <- Model.Status.from_string(entry.get(STATUS).value) } yield Model.Overview(entry.get(ID).value, date, entry.get(NAME).value, status) val submissions = fold(params.list(ENTRY), List.empty[Model.Overview]) { case (entry, overviews) => parse_overview(entry).map(overviews :+ _) } submissions.map(Model.Submission_List.apply) } /* control */ def add_entry(params: Params.Data): Option[Model.T] = parse_create(params).map { model => model.copy(entries = Val.ok(model.entries.v :+ Model.Create_Entry(license = licenses.head._2))) } def remove_entry(params: Params.Data): Option[Model.T] = for { model <- parse_create(params) num_entry <- List_Key.num(ENTRY, params.get(Web_App.ACTION).value) } yield model.copy(entries = Val.ok(Utils.remove_at(num_entry, model.entries.v))) def add_author(params: Params.Data): Option[Model.T] = for { model <- parse_create(params) num_entry <- List_Key.num(ENTRY, params.get(Web_App.ACTION).value) entry <- model.entries.v.unapply(num_entry) } yield entry.author_input match { case None => model.update_entry(num_entry, entry.copy( affils = entry.affils.with_err("Select author first"))) case Some(author) => val default_affil = author.emails.headOption.orElse( author.homepages.headOption).getOrElse(Unaffiliated(author.id)) model.update_entry(num_entry, entry.copy( author_input = None, affils = Val.ok(entry.affils.v :+ default_affil))) } def remove_author(params: Params.Data): Option[Model.T] = for { model <- parse_create(params) (action, num_affil) <- List_Key.split(AUTHOR, params.get(Web_App.ACTION).value) num_entry <- List_Key.num(ENTRY, action) entry <- model.entries.v.unapply(num_entry) } yield model.update_entry(num_entry, entry.copy(affils = Val.ok(Utils.remove_at(num_affil, entry.affils.v)))) def add_notify(params: Params.Data): Option[Model.T] = for { model <- parse_create(params) num_entry <- List_Key.num(ENTRY, params.get(Web_App.ACTION).value) entry <- model.entries.v.unapply(num_entry) entry1 <- entry.notify_input match { case Some(author) => author.emails.headOption.map(email => entry.copy( notify_input = None, notifies = Val.ok(entry.notifies.v :+ email))) case None => Some(entry.copy(notifies = entry.notifies.with_err("Select author first"))) } } yield model.update_entry(num_entry, entry1) def remove_notify(params: Params.Data): Option[Model.T] = for { model <- parse_create(params) (action, num_notify) <- List_Key.split(NOTIFY, params.get(Web_App.ACTION).value) num_entry <- List_Key.num(ENTRY, action) entry <- model.entries.v.unapply(num_entry) } yield model.update_entry(num_entry, entry.copy(notifies = Val.ok(Utils.remove_at(num_notify, entry.notifies.v)))) def add_topic(params: Params.Data): Option[Model.T] = for { model <- parse_create(params) num_entry <- List_Key.num(ENTRY, params.get(Web_App.ACTION).value) entry <- model.entries.v.unapply(num_entry) entry_params <- params.list(ENTRY).unapply(num_entry) } yield { val topic = validate_topic(entry_params.get(TOPIC).value, entry.topics.v) val topic_input = if (topic.is_empty) entry.topic_input else None model.update_entry(num_entry, entry.copy( topic_input = topic_input, topics = Val.ok(entry.topics.v ++ topic.opt).perhaps_err(topic))) } def remove_topic(params: Params.Data): Option[Model.T] = for { model <- parse_create(params) (action, num_topic) <- List_Key.split(TOPIC, params.get(Web_App.ACTION).value) num_entry <- List_Key.num(ENTRY, action) entry <- model.entries.v.unapply(num_entry) } yield { val entry1 = entry.copy(topics = Val.ok(Utils.remove_at(num_topic, entry.topics.v))) model.copy(entries = Val.ok(model.entries.v.updated(num_entry, entry1))) } def add_related(params: Params.Data): Option[Model.T] = for { model <- parse_create(params) num_entry <- List_Key.num(ENTRY, params.get(Web_App.ACTION).value) entry <- model.entries.v.unapply(num_entry) } yield { val entry1 = entry.related_kind match { case None => entry.copy(related_input = entry.related_input.with_err("Select reference kind first")) case Some(kind) => val reference = validate_related(kind, entry.related_input.v, entry.related) val related_input = if (reference.is_empty) entry.related_input.v else "" entry.copy( related = entry.related ++ reference.opt, related_input = Val.ok(related_input).perhaps_err(reference)) } model.update_entry(num_entry, entry1) } def remove_related(params: Params.Data): Option[Model.T] = for { model <- parse_create(params) (action, num_related) <- List_Key.split(RELATED, params.get(Web_App.ACTION).value) num_entry <- List_Key.num(ENTRY, action) entry <- model.entries.v.unapply(num_entry) } yield { val entry1 = entry.copy(related = Utils.remove_at(num_related, entry.related)) model.copy(entries = Val.ok(model.entries.v.updated(num_entry, entry1))) } def add_new_author(params: Params.Data): Option[Model.T] = parse_create(params).map { model => val name = model.new_author_input.trim if (name.isEmpty) model.copy(new_authors = model.new_authors.with_err("Name must not be empty")) else { def as_ascii(str: String) = { var res: String = str List("ö" -> "oe", "ü" -> "ue", "ä" -> "ae", "ß" -> "ss").foreach { case (c, rep) => res = res.replace(c, rep) } Normalizer.normalize(res, Normalizer.Form.NFD).replaceAll("[^\\x00-\\x7F]", "") } def make_author_id(name: String): String = { val normalized = as_ascii(name) val Suffix = """^.*?([a-zA-Z]*)$""".r val suffix = normalized match { case Suffix(suffix) => suffix case _ => "" } val Prefix = """^([a-zA-Z]*).*$""".r val prefix = normalized.stripSuffix(suffix) match { case Prefix(prefix) => prefix case _ => "" } val updated_authors = model.updated_authors(authors) var ident = suffix.toLowerCase for { c <- prefix.toLowerCase if updated_authors.contains(ident) } ident += c.toString Utils.make_unique(ident, updated_authors.keySet) } val id = make_author_id(name) val author = validate_new_author(id, model.new_author_input, model.new_author_orcid, model.updated_authors(authors)) val new_author_input = if (author.is_empty) model.new_author_input else "" val new_author_orcid = if (author.is_empty) model.new_author_orcid else "" model.copy( new_author_input = new_author_input, new_author_orcid = new_author_orcid, new_authors = Val.ok(model.new_authors.v ++ author.opt).perhaps_err(author)) } } def remove_new_author(params: Params.Data): Option[Model.T] = for { model <- parse_create(params) num_author <- List_Key.num(AUTHOR, params.get(Web_App.ACTION).value) author <- model.new_authors.v.unapply(num_author) if !model.used_authors.contains(author.id) } yield model.copy(new_authors = Val.ok(Utils.remove_at(num_author, model.new_authors.v))) def add_new_affil(params: Params.Data): Option[Model.T] = parse_create(params).map { model => model.new_affils_author match { case Some(author) => val address = model.new_affils_input.trim if (address.isEmpty) model.copy(new_affils = model.new_affils.with_err("Must not be empty")) else { val id = if (address.contains("@")) Utils.make_unique(author.id + "_email", author.emails.map(_.id).toSet) else Utils.make_unique(author.id + "_homepage", author.homepages.map(_.id).toSet) val affil = validate_new_affil(id, address, author) val new_affils_input = if (affil.is_empty) model.new_affils_input else "" model.copy( new_affils_input = new_affils_input, new_affils = Val.ok(model.new_affils.v ++ affil.opt).perhaps_err(affil)) } case None => model.copy(new_affils = model.new_affils.with_err("Select author first")) } } def remove_new_affil(params: Params.Data): Option[Model.T] = for { model <- parse_create(params) num_affil <- List_Key.num(AFFILIATION, params.get(Web_App.ACTION).value) affil <- model.new_affils.v.unapply(num_affil) if !model.used_affils.contains(affil) } yield model.copy(new_affils = Val.ok(Utils.remove_at(num_affil, model.new_affils.v))) def upload(params: Params.Data): Option[Model.T] = parse_create(params).map { create => var ok = true def validate[A](validator: A => Val[A], value: A): Val[A] = { val res = validator(value) if (res.err.nonEmpty) ok = false res } def title(title: String): Val[String] = if (title.isBlank) Val.err(title, "Title must not be blank") else if (title.trim != title) Val.err(title, "Title must not contain extra spaces") else Val.ok(title) def name(name: String): Val[String] = if (name.isBlank) Val.err(name, "Name must not be blank") else if (!"[a-zA-Z0-9_-]+".r.matches(name)) Val.err(name, "Invalid character in name") else mode match { case Mode.EDIT => if (Server.this.entries.contains(name)) Val.ok(name) else Val.err(name, "Entry does not exist") case Mode.SUBMISSION => if (Server.this.entries.contains(name)) Val.err(name, "Entry already exists") else Val.ok(name) } def entries(entries: List[Model.Create_Entry]): Val[List[Model.Create_Entry]] = if (entries.isEmpty) Val.err(entries, "Must contain at least one entry") else if (Library.duplicates(entries.map(_.name)).nonEmpty) Val.err(entries, "Entries must have different names") else Val.ok(entries) def new_authors(authors: List[Author]): Val[List[Author]] = if (!authors.forall(author => create.used_authors.contains(author.id))) Val.err(authors, "Unused authors") else Val.ok(authors) def new_affils(affils: List[Affiliation]): Val[List[Affiliation]] = if (!affils.forall(affil => create.used_affils.contains(affil))) Val.err(affils, "Unused affils") else Val.ok(affils) def entry_authors(authors: List[Affiliation]): Val[List[Affiliation]] = if (authors.isEmpty) Val.err(authors, "Must contain at least one author") else if (!Utils.is_distinct(authors)) Val.err(authors, "Duplicate affiliations") else Val.ok(authors) def notifies(notifies: List[Email]): Val[List[Email]] = if (notifies.isEmpty) Val.err(notifies, "Must contain at least one maintainer") else if (!Utils.is_distinct(notifies)) Val.err(notifies, "Duplicate emails") else Val.ok(notifies) def topics(topics: List[Topic]): Val[List[Topic]] = if (topics.isEmpty) Val.err(topics, "Must contain at least one topic") else Val.ok(topics) def `abstract`(txt: String): Val[String] = if (txt.isBlank) Val.err(txt, "Entry must contain an abstract") else if (List("\\cite", "\\emph", "\\texttt").exists(txt.contains(_))) Val.err(txt, "LaTeX not allowed, use MathJax for math symbols") else Val.ok(txt) val validated = create.copy( entries = validate( entries, create.entries.v.map(entry => entry.copy( name = validate(name, entry.name.v), title = validate(title, entry.title.v), affils = validate(entry_authors, entry.affils.v), notifies = validate(notifies, entry.notifies.v), topics = validate(topics, entry.topics.v), `abstract` = validate(`abstract`, entry.`abstract`.v) ))), new_authors = validate(new_authors, create.new_authors.v), new_affils = validate(new_affils, create.new_affils.v)) if (ok) { val (updated_authors, entries) = create.create(authors) Model.Upload(Model.Metadata(updated_authors, entries), params.get(MESSAGE).value, "") } else validated } def create(params: Params.Data): Option[Model.T] = { upload(params) match { case Some(upload: Model.Upload) => mode match { case Mode.EDIT => handler.create(Date.now(), upload.meta, upload.message, Bytes.empty, "") Some(Model.Created(upload.meta.entries.head.name)) case Mode.SUBMISSION => val archive = Bytes.decode_base64(params.get(ARCHIVE).get(FILE).value) val file_name = params.get(ARCHIVE).value if (archive.is_empty || file_name.isEmpty) { Some(upload.copy(error = "Select a file")) } else if (!file_name.endsWith(".zip") && !file_name.endsWith(".tar.gz")) { Some(upload.copy(error = "Only .zip and .tar.gz archives allowed")) } else { val ext = if (file_name.endsWith(".zip")) ".zip" else ".tar.gz" val id = handler.create(Date.now(), upload.meta, upload.message, archive, ext) Some(Model.Created(id)) } } case _ => None } } def empty_submission: Option[Model.T] = Some(Model.Create(entries = Val.ok(List(Model.Create_Entry(license = licenses.head._2))))) def get_submission(props: Properties.T): Option[Model.Submission] = Properties.get(props, ID).flatMap(handler.get(_, topics, licenses)) def resubmit(params: Params.Data): Option[Model.T] = handler.get(params.get(ACTION).value, topics, licenses).map(submission => Model.Upload(submission.meta, submission.message, "")) def submit(params: Params.Data): Option[Model.T] = handler.get(params.get(ACTION).value, topics, licenses).flatMap { submission => if (submission.status.nonEmpty) None else { handler.submit(submission.id) Some(submission.copy(status = Some(Model.Status.Submitted))) } } def abort_build(params: Params.Data): Option[Model.T] = handler.get(params.get(ACTION).value, topics, licenses).flatMap { submission => if (submission.build != Model.Build.Running) None else { handler.abort_build(submission.id) Some(submission.copy(build = Model.Build.Aborted)) } } def set_status(params: Params.Data): Option[Model.T] = { for { list <- parse_submission_list(params) num_entry <- List_Key.num(ENTRY, params.get(ACTION).value) changed <- list.submissions.unapply(num_entry) } yield { if (changed.status == Model.Status.Added && !devel) { progress.echo_if(verbose, "Updating server data...") val id_before = repo.id() repo.pull() repo.update() val id_after = repo.id() if (id_before != id_after) { load() progress.echo("Updated repo from " + id_before + " to " + id_after) } } handler.set_status(changed.id, changed.status) list } } def submission_list: Option[Model.T] = Some(handler.list()) def download(props: Properties.T): Option[Path] = for { id <- Properties.get(props, ID) patch <- handler.get_patch(id) } yield patch def download_archive(props: Properties.T): Option[Path] = for { id <- Properties.get(props, ID) archive <- handler.get_archive(id) } yield archive def style_sheet: Option[Path] = Some(afp_structure.base_dir + Path.make(List("tools", "main.css"))) val error_model = Model.Invalid val endpoints = List( Get(SUBMIT, "empty submission form", _ => empty_submission), Get(SUBMISSION, "get submission", get_submission), Get(SUBMISSIONS, "list submissions", _ => submission_list), Get_File(API_SUBMISSION_DOWNLOAD, "download patch", download), Get_File(API_SUBMISSION_DOWNLOAD_ZIP, "download archive", download_archive), Get_File(API_SUBMISSION_DOWNLOAD_TGZ, "download archive", download_archive), Get_File(API_CSS, "download css", _ => style_sheet), Post(API_RESUBMIT, "get form for resubmit", resubmit), Post(API_SUBMIT, "submit to editors", submit), Post(API_BUILD_ABORT, "abort the build", abort_build), Post(API_SUBMISSION, "get submission form", parse_create), Post(API_SUBMISSION_AUTHORS_ADD, "add author", add_new_author), Post(API_SUBMISSION_AUTHORS_REMOVE, "remove author", remove_new_author), Post(API_SUBMISSION_AFFILIATIONS_ADD, "add affil", add_new_affil), Post(API_SUBMISSION_AFFILIATIONS_REMOVE, "remove affil", remove_new_affil), Post(API_SUBMISSION_ENTRIES_ADD, "add entry", add_entry), Post(API_SUBMISSION_ENTRIES_REMOVE, "remove entry", remove_entry), Post(API_SUBMISSION_ENTRY_AUTHORS_ADD, "add entry author", add_author), Post(API_SUBMISSION_ENTRY_AUTHORS_REMOVE, "remove entry author", remove_author), Post(API_SUBMISSION_ENTRY_NOTIFIES_ADD, "add notify", add_notify), Post(API_SUBMISSION_ENTRY_NOTIFIES_REMOVE, "remove notify", remove_notify), Post(API_SUBMISSION_ENTRY_TOPICS_ADD, "add topic", add_topic), Post(API_SUBMISSION_ENTRY_TOPICS_REMOVE, "remove topic", remove_topic), Post(API_SUBMISSION_ENTRY_RELATED_ADD, "add related", add_related), Post(API_SUBMISSION_ENTRY_RELATED_REMOVE, "remove related", remove_related), Post(API_SUBMISSION_UPLOAD, "upload archive", upload), Post(API_SUBMISSION_CREATE, "create submission", create), Post(API_SUBMISSION_STATUS, "set submission status", set_status)) val head = List( XML.Elem(Markup("script", List( "type" -> "text/javascript", "id" -> "MathJax-script", "async" -> "async", "src" -> "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js")), text("\n")), script( "MathJax={tex:{inlineMath:[['$','$'],['\\\\(','\\\\)']]},processEscapes:true,svg:{fontCache:'global'}}"), style_file(api.api_url(API_CSS))) } /* Isabelle tool wrapper */ val isabelle_tool = Isabelle_Tool("afp_submit", "start afp submission server", Scala_Project.here, { args => var backend_path = Path.current var frontend_url = Url("http://localhost:8080") var devel = false var verbose = false var port = 8080 var dir: Option[Path] = None val getopts = Getopts(""" Usage: isabelle afp_submit [OPTIONS] Options are: -a PATH backend path (if endpoint is not server root) -b URL application frontend url. Default: """ + frontend_url + """" -d devel mode (serves frontend and skips automatic AFP repository updates) -p PORT server port. Default: """ + port + """ -v verbose -D DIR submission directory Start afp submission server. Server is in "edit" mode unless directory to store submissions in is specified. """, "a:" -> (arg => backend_path = Path.explode(arg)), "b:" -> (arg => frontend_url = Url(arg)), "d" -> (_ => devel = true), "p:" -> (arg => port = Value.Int.parse(arg)), "v" -> (_ => verbose = true), "D:" -> (arg => dir = Some(Path.explode(arg)))) getopts(args) val afp_structure = AFP_Structure() val progress = new Console_Progress(verbose = verbose) val (handler, mode) = dir match { case Some(dir) => (Handler.Adapter(dir, afp_structure), Mode.SUBMISSION) case None => (Handler.Edit(afp_structure), Mode.EDIT) } val api = new API(frontend_url, backend_path, devel = devel) val server = new Server(api = api, afp_structure = afp_structure, mode = mode, handler = handler, devel = devel, verbose = verbose, progress = progress, port = port) server.run() }) } diff --git a/tools/utils.scala b/tools/utils.scala --- a/tools/utils.scala +++ b/tools/utils.scala @@ -1,58 +1,58 @@ /* Author: Fabian Huch, TU Muenchen Utilities. */ package afp import isabelle.* -import java.io.{BufferedReader, InputStreamReader, IOException} +import java.io.IOException import scala.collection.immutable.ListMap object Utils { val TIMEOUT = 30*1000 def group_sorted[A, K](l: List[A], f: A => K): ListMap[K, List[A]] = l.foldLeft(ListMap.empty[K, List[A]]) { case (m, a) => m.updatedWith(f(a)) { case Some(as) => Some(as :+ a) case None => Some(List(a)) } } def grouped_sorted[A, K](l: List[A], f: A => K): ListMap[K, A] = group_sorted(l, f).map { case (k, v :: Nil) => k -> v case (k, vs) => error("Not distinct for " + quote(k.toString) + ": " + commas_quote(vs.map(_.toString))) } def the_entry[K, V](m: Map[K, V], k: K): V = m.getOrElse(k, error("Expected key " + quote(k.toString))) def fetch_text(url: Url, params: Map[String, String]): String = try { val conn = url.open_connection() conn.setConnectTimeout(TIMEOUT) conn.setReadTimeout(TIMEOUT) params.foreach { case (param, value) => conn.setRequestProperty(param, value) } File.read_stream(conn.getInputStream) } catch { case _: IOException => error("Could not read from " + quote(url.toString)) } def remove_at[A](i: Int, l: List[A]): List[A] = l.take(i) ++ l.drop(i + 1) def make_unique(prefix: String, elems: Set[String]): String = { if (!elems.contains(prefix)) prefix else { var num = 1 while (elems.contains(prefix + num)) { num += 1 } prefix + num } } def is_distinct[A](it: List[A]): Boolean = it.size == it.distinct.size } \ No newline at end of file diff --git a/tools/web_app.scala b/tools/web_app.scala --- a/tools/web_app.scala +++ b/tools/web_app.scala @@ -1,498 +1,497 @@ /* Author: Fabian Huch, TU Muenchen Technical layer for web apps using server-side rendering with HTML forms. */ package afp import isabelle.* - import scala.annotation.tailrec object Web_App { val FILE = "file" val ACTION = "action" /* form html elements */ object More_HTML { import HTML._ def css(s: String): Attribute = new Attribute("style", s) def name(n: String): Attribute = new Attribute("name", n) def value(v: String): Attribute = new Attribute("value", v) def placeholder(p: String): Attribute = new Attribute("placeholder", p) val fieldset = new Operator("fieldset") val button = new Operator("button") def legend(txt: String): XML.Elem = XML.Elem(Markup("legend", Nil), text(txt)) def input(typ: String): XML.Elem = XML.Elem(Markup("input", List("type" -> typ)), Nil) def hidden(k: String, v: String): XML.Elem = id(k)(name(k)(value(v)(input("hidden")))) def textfield(i: String, p: String, v: String): XML.Elem = id(i)(name(i)(value(v)(placeholder(p)(input("text"))))) def browse(i: String, accept: List[String]): XML.Elem = id(i)(name(i)(input("file"))) + ("accept" -> accept.mkString(",")) def label(`for`: String, txt: String): XML.Elem = XML.Elem(Markup("label", List("for" -> `for`)), text(txt)) def fieldlabel(for_elem: String, txt: String): XML.Elem = label(for_elem, " " + txt + ": ") def explanation(for_elem: String, txt: String): XML.Elem = par(List(emph(List(label(for_elem, txt))))) def option(k: String, v: String): XML.Elem = XML.Elem(Markup("option", List("value" -> k)), text(v)) def optgroup(txt: String, opts: XML.Body): XML.Elem = XML.Elem(Markup("optgroup", List("label" -> txt)), opts) def select(i: String, opts: XML.Body): XML.Elem = XML.Elem(Markup("select", List("id" -> i, "name" -> i)), opts) def textarea(i: String, v: String): XML.Elem = XML.Elem(Markup("textarea", List("id" -> i, "name" -> i, "value" -> v)), text(v + "\n")) def radio(i: String, v: String, values: List[(String, String)]): XML.Elem = { def to_option(k: String): XML.Elem = { val elem = id(i + k)(name(i)(value(k)(input("radio")))) if (v == k) elem + ("checked" -> "checked") else elem } span(values.map { case (k, v) => span(List(label(i + k, v), to_option(k))) }) } def selection(i: String, selected: Option[String], opts: XML.Body): XML.Elem = { def sel(elem: XML.Tree): XML.Tree = { selected match { case Some(value) => val Value = new Properties.String("value") elem match { case XML.Elem(Markup("optgroup", props), body) => XML.Elem(Markup("optgroup", props), body.map(sel)) case e@XML.Elem(Markup("option", Value(v)), _) if v == value => e + ("selected" -> "selected") case e => e } case None => elem } } def is_empty_group(elem: XML.Tree): Boolean = elem match { case XML.Elem(Markup("optgroup", _), body) if body.isEmpty => true case _ => false } val default = if (selected.isEmpty) List(option("", "") + ("hidden" -> "hidden")) else Nil select(i, default ::: opts.filterNot(is_empty_group).map(sel)) } def api_button(call: String, label: String): XML.Elem = button(text(label)) + ("formaction" -> call) + ("type" -> "submit") def action_button(call: String, label: String, action: String): XML.Elem = name(ACTION)(value(action)(api_button(call, label))) def submit_form(endpoint: String, body: XML.Body): XML.Elem = { val default_button = css("display: none")(input("submit") + ("formaction" -> endpoint)) val attrs = List("method" -> "post", "target" -> "iframe", "enctype" -> "multipart/form-data") XML.Elem(Markup("form", attrs), default_button :: body) } } /* request parameters */ object Params { type Key = String val empty: Key = "" object Nest_Key { def apply(k: Key, field: String): Key = if (k == empty) field else k + "_" + field def unapply(k: Key): Option[(Key, String)] = k.split('_').filterNot(_.isEmpty).toList.reverse match { case k :: ks => Some(ks.reverse.mkString("_"), k) case _ => None } } object List_Key { def apply(k: Key, field: String, i: Int): Key = Nest_Key(k, field + "_" + i.toString) def unapply(k: Key): Option[(Key, (String, Int))] = k.split('_').filterNot(_.isEmpty).toList.reverse match { case Value.Int(i) :: k :: ks => Some(ks.reverse.mkString("_"), (k, i)) case _ => None } def num(field: String, k: Key): Option[Int] = k match { case List_Key(_, (f, i)) if f == field => Some(i) case _ => None } def split(field: String, k: Key): Option[(Key, Int)] = k match { case List_Key(key, (f, i)) if f == field => Some(key, i) case _ => None } } /* structured data */ class Data private[Params]( v: Option[String] = None, elem: Map[String, Data] = Map.empty, elems: Map[String, List[Data]] = Map.empty ) { def is_empty: Boolean = v.isEmpty && elem.isEmpty && elems.isEmpty override def toString: String = { val parts = v.map(v => if (v.length <= 100) quote(v) else quote(v.take(100) + "...")).toList ++ elem.toList.map { case (k, v) => k + " -> " + v.toString } ++ elems.toList.map { case (k, v) => k + " -> (" + v.mkString(",") + ")" } "{" + parts.mkString(", ") + "}" } def value: String = v.getOrElse("") def get(field: String): Data = elem.getOrElse(field, new Data()) def list(field: String): List[Data] = elems.getOrElse(field, Nil) } object Data { def from_multipart(parts: List[Multi_Part.Data]): Data = { sealed trait E case class Value(rep: String) extends E case class Index(i: Int, to: E) extends E case class Nest(field: String, to: E) extends E def group_map[A, B, C](v: List[(C, A)], agg: List[A] => B): Map[C, B] = v.groupBy(_._1).view.mapValues(_.map(_._2)).mapValues(agg).toMap def to_list(l: List[(Int, E)]): List[Data] = { val t: List[(Int, Data)] = group_map(l, parse).toList t.sortBy(_._1).map(_._2) } def parse(e: List[E]): Data = { val value = e.collectFirst { case Value(rep) => rep } val nest_by_key = e.collect { case Nest(field, v: Value) => field -> v case Nest(field, v: Nest) => field -> v } val elem = group_map(nest_by_key, parse) val list_by_key = e.collect { case Nest(field, Index(i, to)) => field -> (i -> to) } val elems = group_map(list_by_key, to_list) new Data(value, elem, elems) } @tailrec def expand(key: Key, to: E): E = key match { case List_Key(key, (field, i)) => expand(key, Nest(field, Index(i, to))) case Nest_Key(key, field) => expand(key, Nest(field, to)) case _ => to } val params = parts.flatMap { case Multi_Part.Param(name, value) => List(name -> value) case Multi_Part.File(name, file_name, content) => List(name -> file_name, Nest_Key(name, Web_App.FILE) -> content.encode_base64) } parse(params.map { case (k, v) => expand(k, Value(v)) }) } } } /* form http handling */ object Multi_Part { abstract class Data(name: String) case class Param(name: String, value: String) extends Data(name) case class File(name: String, file_name: String, content: Bytes) extends Data(name) def parse(body: Bytes): List[Data] = { /* Seq for text with embedded bytes */ case class Seq(text: String, bytes: Bytes) { def split_one(sep: String): Option[(Seq, Seq)] = { val text_i = text.indexOf(sep) if (text_i >= 0 && sep.nonEmpty) { val (before_text, at_text) = text.splitAt(text_i) val after_text = at_text.substring(sep.length) // text might be shorter than bytes because of misinterpreted characters var found = false var bytes_i = 0 while (!found && bytes_i < bytes.length) { var sep_ok = true var sep_i = 0 while (sep_ok && sep_i < sep.length) { if (bytes.charAt(bytes_i + sep_i) == sep.charAt(sep_i)) { sep_i += 1 } else { bytes_i += 1 sep_ok = false } } if (sep_ok) found = true } val before_bytes = bytes.subSequence(0, bytes_i) val after_bytes = bytes.subSequence(bytes_i + sep.length, bytes.length) Some(Seq(before_text, before_bytes), Seq(after_text, after_bytes)) } else None } } val s = Seq(body.text, body) def perhaps_unprefix(pfx: String, s: Seq): Seq = Library.try_unprefix(pfx, s.text) match { case Some(text) => Seq(text, s.bytes.subSequence(pfx.length, s.bytes.length)) case None => s } val Separator = """--(.*)""".r s.split_one(HTTP.NEWLINE) match { case Some(Seq(Separator(sep), _), rest) => val Param = """Content-Disposition: form-data; name="([^"]+)"""".r val File = """Content-Disposition: form-data; name="([^"]+)"; filename="([^"]+)"""".r def parts(body: Seq): Option[List[Data]] = for { (first_line, more) <- body.split_one(HTTP.NEWLINE) more1 = perhaps_unprefix(HTTP.NEWLINE, more) (value, rest) <- more1.split_one(HTTP.NEWLINE + "--" + sep) rest1 = perhaps_unprefix(HTTP.NEWLINE, rest) } yield first_line.text match { case Param(name) => Multi_Part.Param(name, Line.normalize(value.text)) :: parts(rest1).getOrElse(Nil) case File(name, file_name) => value.split_one(HTTP.NEWLINE + HTTP.NEWLINE) match { case Some(_, content) => Multi_Part.File(name, file_name, content.bytes) :: parts(rest1).getOrElse(Nil) case _ => parts(rest1).getOrElse(Nil) } case _ => Nil } parts(rest).getOrElse(Nil) case _ => Nil } } } /* API with backend base path, and application url (e.g. for frontend links). */ class API(val app: Url, val base_path: Path, val devel: Boolean = false) { def url(path: Path, params: Properties.T = Nil): String = { def param(p: Properties.Entry): String = Url.encode(p._1) + "=" + Url.encode(p._2) if (params.isEmpty) path.implode else path.implode + "?" + params.map(param).mkString("&") } def api_path(path: Path, external: Boolean = true): Path = (if (devel) Path.explode("backend") else Path.current) + (if (external) base_path else Path.current) + path def api_url(path: Path, params: Properties.T = Nil, external: Boolean = true): String = "/" + url(api_path(path, external = external), params) def app_url(path: Path, params: Properties.T = Nil): String = app.toString + "/" + url(path, params) } abstract class Server[A]( api: API, port: Int = 0, verbose: Boolean = false, progress: Progress = new Progress() ) { def render(model: A): XML.Body val error_model: A val endpoints: List[Endpoint] val head: XML.Body def output_document(content: XML.Body, post_height: Boolean = true): String = { val attrs = if (post_height) List("onLoad" -> "parent.postMessage(document.body.scrollHeight, '*')") else Nil cat_lines( List( HTML.header, HTML.output(XML.elem("head", HTML.head_meta :: head), hidden = true, structural = true), HTML.output(XML.Elem(Markup("body", attrs), content), hidden = true, structural = true), HTML.footer)) } class UI(path: Path) extends HTTP.Service(path.implode, "GET") { def apply(request: HTTP.Request): Option[HTTP.Response] = { progress.echo_if(verbose, "GET ui") val on_load = """ (function() { window.addEventListener('message', (event) => { if (Number.isInteger(event.data)) { this.style.height=event.data + 32 + 'px' } }) }).call(this)""" val set_src = """ const base = '""" + api.app.toString.replace("/", "\\/") + """' document.getElementById('iframe').src = base + '""" + api.api_url(path).replace("/", "\\/") + """' + window.location.search""" Some(HTTP.Response.html(output_document( List( XML.Elem( Markup( "iframe", List( "id" -> "iframe", "name" -> "iframe", "style" -> "border-style: none; width: 100%", "onload" -> on_load)), HTML.text("content")), HTML.script(set_src)), post_height = false))) } } sealed abstract class Endpoint(path: Path, method: String = "GET") extends HTTP.Service(api.api_path(path, external = false).implode, method) { def reply(request: HTTP.Request): HTTP.Response final def apply(request: HTTP.Request): Option[HTTP.Response] = Exn.capture(reply(request)) match { case Exn.Res(res) => Some(res) case Exn.Exn(exn) => val id = UUID.random_string() progress.echo_error_message("Internal error <" + id + ">: " + exn) error("Internal server error. ID: " + id) } } private def query_params(request: HTTP.Request): Properties.T = { def decode(s: String): Option[Properties.Entry] = s match { case Properties.Eq(k, v) => Some(Url.decode(k) -> Url.decode(v)) case _ => None } Library.try_unprefix(request.query, request.uri_name).toList.flatMap(params => space_explode('&', params).flatMap(decode)) } /* endpoint types */ class Get(val path: Path, description: String, get: Properties.T => Option[A]) extends Endpoint(path) { def reply(request: HTTP.Request): HTTP.Response = { progress.echo_if(verbose, "GET " + description) val params = query_params(request) progress.echo_if(verbose, "params: " + params.toString()) val model = get(params) match { case Some(model) => model case None => progress.echo_if(verbose, "Parsing failed") error_model } HTTP.Response.html(output_document(render(model))) } } class Get_File(path: Path, description: String, download: Properties.T => Option[Path]) extends Endpoint(path) { def reply(request: HTTP.Request): HTTP.Response = { progress.echo_if(verbose, "DOWNLOAD " + description) val params = query_params(request) progress.echo_if(verbose, "params: " + params.toString()) download(params) match { case Some(path) => HTTP.Response.content(HTTP.Content.read(path)) case None => progress.echo_if(verbose, "Fetching file path failed") HTTP.Response.html(output_document(render(error_model))) } } } class Post(path: Path, description: String, post: Params.Data => Option[A]) extends Endpoint(path, method = "POST") { def reply(request: HTTP.Request): HTTP.Response = { progress.echo_if(verbose, "POST " + description) val parts = Multi_Part.parse(request.input) val params = Params.Data.from_multipart(parts) progress.echo_if(verbose, "params: " + params.toString) val model = post(params) match { case Some(model) => model case None => progress.echo_if(verbose, "Parsing failed") error_model } HTTP.Response.html(output_document(render(model))) } } /* server */ private lazy val services = endpoints ::: (if (api.devel) endpoints.collect { case g: Get => new UI(g.path) } else Nil) private lazy val server = HTTP.server(port = port, name = "", services = services) def run(): Unit = { start() @tailrec def loop(): Unit = { Thread.sleep(Long.MaxValue) loop() } Isabelle_Thread.interrupt_handler(_ => server.stop()) { loop() } } def start(): Unit = { server.start() progress.echo("Server started on port " + server.http_server.getAddress.getPort) } def stop(): Unit = { server.stop() progress.echo("Server stopped") } } } \ No newline at end of file