diff --git a/tools/afp_build.scala b/tools/afp_build.scala
--- a/tools/afp_build.scala
+++ b/tools/afp_build.scala
@@ -1,347 +1,345 @@
/* 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}
+import afp.Metadata.{Email, Entry, Entries}
import java.time.{Instant, 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_text(session_name: String, entry: 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: Map[Entry.Name, Entry]
+ val entries: Entries
) {
def maintainers(name: Entry.Name): List[Email] = {
entries.get(name) match {
case None => Nil
case Some(entry) => entry.notifies
}
}
val sessions: Map[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] = {
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]] =
sessions.groupBy(session_entry)
def notify(name: 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 = {
- val entries = afp.load().map(entry => entry.name -> entry).toMap
- new Metadata_Tools(afp, CI_Build.mail_server(options), entries)
- }
+ 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 = {
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 = {
val entries_strings = status.collect {
case (None, Failed) =>
s"
Distribution"
case (Some(entry), 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,
{
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 = {
println(s"AFP id ${ afp.hg_id }")
if (status_file.exists())
status_file.delete()
Result.ok
}
def post_hook(results: Build.Results, options: Options, start_time: Time): Result = {
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
}
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,
{
val afp = AFP_Structure()
val isabelle_home = Path.explode(Isabelle_System.getenv_strict("ISABELLE_HOME"))
val isabelle_id = 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 = {
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
}
else {
server.check()
Result.ok
}
}
def post_hook(results: Build.Results, options: Options, start_time: Time): Result = {
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, _)))
}.toMap
print_section("REPORT")
println("Writing report file ...")
File.write(report_file, status_as_html(status))
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")
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)
}
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),
{
val afp = AFP_Structure()
def pre_hook(options: Options): Result = {
println(s"Build for AFP id ${ afp.hg_id }")
Result.ok
}
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),
{
val afp = AFP_Structure()
def pre_hook(options: Options): Result = {
println(s"Build for AFP id ${ afp.hg_id }")
Result.ok
}
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,
{
val afp = AFP_Structure()
val report_file = Path.explode("$ISABELLE_HOME/report.html").file
def pre_hook(options: Options): Result = {
println(s"AFP id ${ afp.hg_id }")
if (report_file.exists())
report_file.delete()
File.write(report_file, "")
Result.ok
}
def post_hook(results: Build.Results, options: Options, start_time: Time): 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, _)))
}.toMap
print_section("REPORT")
println("Writing report file ...")
File.write(report_file, status_as_html(status))
print_section("COMPLETED")
Result.ok
}
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,202 +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 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 orig_authors = afp_structure.load_authors
- val orig_topics = afp_structure.load_topics
- val orig_licenses = afp_structure.load_licenses
- val orig_releases = afp_structure.load_releases
- val authors = orig_authors.map(author => author.id -> author).toMap
- val topics = Utils.grouped_sorted(orig_topics.flatMap(_.all_topics), (t: Topic) => t.id)
- val licenses = orig_licenses.map(license => license.id -> license).toMap
- val releases = orig_releases.groupBy(_.entry)
+ 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", orig_topics, TOML.from_topics, TOML.to_topics)
+ 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
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)
}
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(orig_authors)
+ afp_structure.save_authors(authors.values.toList)
if (format_all) {
- afp_structure.save_topics(orig_topics)
- afp_structure.save_licenses(orig_licenses)
- afp_structure.save_releases(orig_releases)
+ 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(orig_authors), afp_structure.authors_file)
+ 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() }
}
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_release.scala b/tools/afp_release.scala
--- a/tools/afp_release.scala
+++ b/tools/afp_release.scala
@@ -1,108 +1,108 @@
/* 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 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))
val afp_structure = AFP_Structure(base_dir)
- val releases = afp_structure.load().map(add_release).flatMap(_.releases)
+ 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_site_gen.scala b/tools/afp_site_gen.scala
--- a/tools/afp_site_gen.scala
+++ b/tools/afp_site_gen.scala
@@ -1,490 +1,485 @@
/* Author: Fabian Huch, TU Muenchen
Generation and compilation of SSG project for the AFP website.
*/
package afp
import isabelle.*
import afp.Metadata.{Affiliation, Author, ACM, AMS, Classification, DOI, Email, Entry, Formatted, Homepage, Reference, Release, Topic, Unaffiliated}
object AFP_Site_Gen {
/* cache */
class Cache(layout: Hugo.Layout, progress: Progress = new Progress()) {
private val doi_cache = Path.basic("dois.json")
private var dois: Map[String, String] = {
val file = layout.cache_dir + doi_cache
if (file.file.exists) {
val content = File.read(file)
val json =
try { isabelle.JSON.parse(content) }
catch { case ERROR(msg) => error("Could not parse " + file.toString + ": " + msg) }
JSON.to_dois(json)
}
else {
progress.echo_warning("No DOI cache found - resolving might take some time")
Map.empty
}
}
def resolve_doi(doi: DOI): String = {
dois.get(doi.identifier) match {
case Some(value) => value
case None =>
val res = doi.formatted()
dois += (doi.identifier -> res)
layout.write_cache(doi_cache, JSON.from_dois(dois))
res
}
}
}
/* json */
object JSON {
type T = isabelle.JSON.T
object Object {
type T = isabelle.JSON.Object.T
def apply(entries: isabelle.JSON.Object.Entry*): T = isabelle.JSON.Object.apply(entries: _*)
}
def opt(k: String, v: String): Object.T = if (v.isEmpty) Object() else Object(k -> v)
def opt(k: String, v: Option[T]): Object.T = v.map(v => Object(k -> v)).getOrElse(Object())
def opt[A <: Iterable[_]](k: String, vals: A): Object.T =
if (vals.isEmpty) Object() else Object(k -> vals)
def from_dois(dois: Map[String, String]): Object.T = dois
def to_dois(dois: T): Map[String, String] = dois match {
case m: Map[_, _] if m.keySet.forall(_.isInstanceOf[String]) &&
m.values.forall(_.isInstanceOf[String]) =>
m.asInstanceOf[Map[String, String]]
case _ => error("Could not read dois")
}
def from_email(email: Email): Object.T =
Object(
"user" -> email.user.split('.').toList,
"host" -> email.host.split('.').toList)
def from_authors(authors: List[Author]): Object.T =
authors.map(author =>
author.id -> (Object(
"name" -> author.name,
"emails" -> author.emails.map(from_email),
"homepages" -> author.homepages.map(_.url.toString)) ++
opt("orcid", author.orcid.map(orcid => Object(
"id" -> orcid.identifier,
"url" -> orcid.url.toString))))).toMap
def from_classification(classification: Classification): Object.T =
Object(
"desc" -> classification.desc,
"url" -> classification.url.toString,
"type" -> (classification match {
case _: ACM => "ACM"
case _: AMS => "AMS"
}))
def from_topics(topics: List[Topic]): Object.T =
Object(topics.map(topic =>
topic.name -> (
opt("classification", topic.classification.map(from_classification)) ++
opt("topics", from_topics(topic.sub_topics)))): _*)
def from_affiliations(affiliations: List[Affiliation]): Object.T = {
Utils.group_sorted(affiliations, (a: Affiliation) => a.author).view.mapValues(
{ author_affiliations =>
val homepage = author_affiliations.collectFirst { case homepage: Homepage => homepage }
val email = author_affiliations.collectFirst { case email: Email => email }
Object() ++
opt("homepage", homepage.map(_.url.toString)) ++
opt("email", email.map(from_email))
}).toMap
}
def from_change_history(entry: (Metadata.Date, String)): Object.T =
Object(
"date" -> entry._1.toString,
"value" -> entry._2)
def from_release(release: Release): Object.T =
Object(
"date" -> release.date.toString,
"isabelle" -> release.isabelle)
def from_related(related: Reference, cache: Cache): T =
related match {
case d: DOI =>
val href = d.url.toString
cache.resolve_doi(d).replace(href, "" + href + "")
case Formatted(text) => text
}
def from_entry(entry: Entry, cache: Cache): Object.T = (
Object(
"title" -> entry.title,
"authors" -> entry.authors.map(_.author).distinct,
"affiliations" -> from_affiliations(entry.authors ++ entry.contributors),
"date" -> entry.date.toString,
"topics" -> entry.topics.map(_.id),
"abstract" -> entry.`abstract`,
"license" -> entry.license.name) ++
opt("contributors", entry.contributors.map(_.author).distinct) ++
opt("releases", entry.releases.sortBy(_.isabelle).reverse.map(from_release)) ++
opt("note", entry.note) ++
opt("history", entry.change_history.toList.sortBy(_._1).reverse.map(from_change_history)) ++
opt("extra", entry.extra) ++
opt("related", entry.related.map(from_related(_, cache))))
def from_keywords(keywords: List[String]): T =
keywords.sorted.map(keyword => Object("keyword" -> keyword))
}
/* stats */
def afp_stats(deps: Sessions.Deps, structure: AFP_Structure, entries: List[Entry]): JSON.T = {
def round(int: Int): Int = Math.round(int.toFloat / 100) * 100
def nodes(entry: Entry): List[Document.Node.Name] =
structure.entry_sessions(entry.name)
.flatMap(session => deps(session.name).proper_session_theories)
val theorem_commands = List("theorem", "lemma", "corollary", "proposition", "schematic_goal")
var entry_lines = Map.empty[Entry, Int]
var entry_lemmas = Map.empty[Entry, Int]
for {
entry <- entries
node <- nodes(entry)
lines = split_lines(File.read(node.path)).map(_.trim)
} {
entry_lines += entry -> (entry_lines.getOrElse(entry, 0) + lines.count(_.nonEmpty))
entry_lemmas += entry -> (entry_lemmas.getOrElse(entry, 0) +
lines.count(line => theorem_commands.exists(line.startsWith)))
}
val first_year = entries.flatMap(_.releases).map(_.date.getYear).min
def years(upto: Int): List[Int] = Range.inclusive(first_year, upto).toList
val current_year = Date.now().rep.getYear
val all_years = years(current_year)
// per Isabelle release year
val by_year = entries.groupBy(_.date.getYear)
val size_by_year = by_year.view.mapValues(_.length).toMap
val loc_by_year = by_year.view.mapValues(_.map(entry_lines).sum).toMap
val authors_by_year = by_year.view.mapValues(_.flatMap(_.authors).map(_.author)).toMap
val num_lemmas = entries.map(entry_lemmas).sum
val num_lines = entries.map(entry_lines).sum
// accumulated
def total_articles(year: Int): Int =
years(year).map(size_by_year.getOrElse(_, 0)).sum
def total_loc(year: Int): Int =
round(years(year).map(loc_by_year.getOrElse(_, 0)).sum)
def total_authors(year: Int): Int =
years(year).flatMap(authors_by_year.getOrElse(_, Nil)).distinct.length
def fresh_authors(year: Int): Int =
total_authors(year) - total_authors(year - 1)
val sorted = entries.sortBy(_.date)
def map_repetitions(elems: List[String], to: String): List[String] =
elems.foldLeft(("", List.empty[String])) {
case((last, acc), s) => (s, acc :+ (if (last == s) to else s))
}._2
JSON.Object(
"years" -> all_years,
"num_lemmas" -> num_lemmas,
"num_loc" -> num_lines,
"articles_year" -> all_years.map(total_articles),
"loc_years" -> all_years.map(total_loc),
"author_years" -> all_years.map(fresh_authors),
"author_years_cumulative" -> all_years.map(total_authors),
"loc_articles" -> sorted.map(entry_lines),
"all_articles" -> sorted.map(_.name),
"article_years_unique" -> map_repetitions(sorted.map(_.date.getYear.toString), ""))
}
/* site generation */
def afp_site_gen(
layout: Hugo.Layout,
status_file: Option[Path],
afp_structure: AFP_Structure,
clean: Boolean = false,
progress: Progress = new Progress()
): Unit = {
/* clean old */
if (clean) {
progress.echo("Cleaning up generated files...")
layout.clean()
}
/* add topics */
progress.echo("Preparing topics...")
val topics = afp_structure.load_topics
- val topics_by_id =
- Utils.grouped_sorted(topics.flatMap(_.all_topics), (t: Metadata.Topic) => t.id)
+ val root_topics = Metadata.Topics.root_topics(topics)
- layout.write_data(Path.basic("topics.json"), JSON.from_topics(topics))
+ layout.write_data(Path.basic("topics.json"), JSON.from_topics(root_topics))
/* add licenses */
progress.echo("Preparing licenses...")
- val licenses_by_id = Utils.grouped_sorted(afp_structure.load_licenses,
- (l: Metadata.License) => l.id)
+ val licenses = afp_structure.load_licenses
/* add releases */
progress.echo("Preparing releases...")
- val releases_by_entry = afp_structure.load_releases.groupBy(_.entry)
+ val releases = afp_structure.load_releases
/* prepare authors and entries */
progress.echo("Preparing authors...")
- val full_authors = afp_structure.load_authors
- val authors_by_id = Utils.grouped_sorted(full_authors, (a: Metadata.Author) => a.id)
+ val authors = afp_structure.load_authors
var seen_affiliations: List[Affiliation] = Nil
val entries =
afp_structure.entries.flatMap { name =>
- val entry = afp_structure.load_entry(name, authors_by_id, topics_by_id, licenses_by_id,
- releases_by_entry)
-
+ val entry = afp_structure.load_entry(name, authors, topics, licenses, releases)
seen_affiliations = seen_affiliations :++ entry.authors ++ entry.contributors
Some(entry)
}
- val authors =
+ val seen_authors =
Utils.group_sorted(seen_affiliations.distinct, (a: Affiliation) => a.author).map {
case (id, affiliations) =>
val seen_emails = affiliations.collect { case e: Email => e }
val seen_homepages = affiliations.collect { case h: Homepage => h }
- authors_by_id(id).copy(emails = seen_emails, homepages = seen_homepages)
+ authors(id).copy(emails = seen_emails, homepages = seen_homepages)
}
- layout.write_data(Path.basic("authors.json"), JSON.from_authors(authors.toList))
+ layout.write_data(Path.basic("authors.json"), JSON.from_authors(seen_authors.toList))
/* extract keywords */
progress.echo("Extracting keywords...")
var seen_keywords = Set.empty[String]
val entry_keywords =
entries.filterNot(_.statistics_ignore).map { entry =>
val scored_keywords = Rake.extract_keywords(entry.`abstract`)
seen_keywords ++= scored_keywords.map(_._1)
entry.name -> scored_keywords.map(_._1)
}.toMap
seen_keywords =
seen_keywords.filter(k => !k.endsWith("s") || !seen_keywords.contains(k.stripSuffix("s")))
layout.write_static(Path.make(List("data", "keywords.json")),
JSON.from_keywords(seen_keywords.toList))
def get_keywords(name: Metadata.Entry.Name): List[String] =
entry_keywords.getOrElse(name, Nil).filter(seen_keywords.contains).take(8)
/* add entries and theory listings */
progress.echo("Preparing entries...")
val sessions_structure = afp_structure.sessions_structure
val sessions_deps = Sessions.deps(sessions_structure)
val browser_info = Browser_Info.context(sessions_structure)
def theories_of(session_name: String): List[String] =
sessions_deps(session_name).proper_session_theories.map(_.theory_base_name)
def write_session_json(session_name: String, base: JSON.Object.T): Unit = {
val session_json =
base ++ JSON.Object(
"title" -> session_name,
"url" -> ("/sessions/" + session_name.toLowerCase),
"theories" -> theories_of(session_name).map(thy_name => JSON.Object(
"name" -> thy_name,
"path" -> (browser_info.session_dir(session_name) + Path.basic(thy_name).html).implode
)))
layout.write_content(Path.make(List("sessions", session_name + ".md")), session_json)
}
val cache = new Cache(layout, progress)
val entry_sessions =
entries.map(entry => entry -> afp_structure.entry_sessions(entry.name)).toMap
val session_entry = entry_sessions.flatMap((entry, sessions) =>
sessions.map(session => session.name -> entry)).toMap
entries.foreach { entry =>
val deps =
for {
session <- entry_sessions(entry)
dep_session <- sessions_structure.imports_graph.imm_preds(session.name)
if sessions_structure(dep_session).groups.contains("AFP")
dep <- session_entry.get(dep_session)
if dep != entry
} yield dep.name
val sessions =
afp_structure.entry_sessions(entry.name).map { session =>
write_session_json(session.name, JSON.Object("entry" -> entry.name))
JSON.Object(
"session" -> session.name,
"theories" -> theories_of(session.name))
}
val entry_json =
JSON.from_entry(entry, cache) ++ JSON.Object(
"dependencies" -> deps.distinct,
"sessions" -> sessions,
"url" -> ("/entries/" + entry.name + ".html"),
"keywords" -> get_keywords(entry.name))
layout.write_content(Path.make(List("entries", entry.name + ".md")), entry_json)
}
for {
(session_name, (info, _)) <- sessions_structure.imports_graph.iterator
if !info.groups.contains("AFP")
} write_session_json(session_name, JSON.Object("rss" -> false))
/* add statistics */
progress.echo("Preparing statistics...")
val statistics_json =
afp_stats(sessions_deps, afp_structure, entries.filterNot(_.statistics_ignore))
layout.write_data(Path.basic("statistics.json"), statistics_json)
/* project */
progress.echo("Preparing project files")
layout.copy_project()
/* status */
status_file match {
case Some(status_file) =>
progress.echo("Preparing devel version...")
val status_json = isabelle.JSON.parse(File.read(status_file))
layout.write_data(Path.basic("status.json"), status_json)
case None =>
}
progress.echo("Finished sitegen preparation.")
}
/* build site */
def afp_build_site(
out_dir: Path, layout: Hugo.Layout,
do_watch: Boolean = false,
clean: Boolean = false,
progress: Progress = new Progress()
): Unit = {
if (clean) {
progress.echo("Cleaning output dir...")
Hugo.clean(out_dir)
}
if (do_watch) {
Hugo.watch(layout, out_dir, progress)
} else {
progress.echo("Building site...")
Hugo.build(layout, out_dir)
progress.echo("Build in " + (out_dir + Path.basic("index.html")).absolute.implode)
}
}
/* tool wrapper */
val isabelle_tool = Isabelle_Tool("afp_site_gen", "generates afp website source",
Scala_Project.here,
{ args =>
var base_dir = Path.explode("$AFP_BASE")
var status_file: Option[Path] = None
var hugo_dir = base_dir + Path.make(List("web", "hugo"))
var out_dir: Path = base_dir + Path.make(List("web", "out"))
var build_only = false
var devel_mode = false
var fresh = false
val getopts = Getopts("""
Usage: isabelle afp_site_gen [OPTIONS]
Options are:
-B DIR afp base dir (default """" + base_dir.implode + """")
-D FILE build status file for devel version
-H DIR generated hugo project dir (default """" + hugo_dir.implode + """")
-O DIR output dir for build (default """ + out_dir.implode + """)
-b build only
-d devel mode (overrides hugo dir, builds site in watch mode)
-f fresh build: clean up existing hugo and build directories
Generates the AFP website source. HTML files of entries are dynamically loaded.
Providing a status file will build the development version of the archive.
Site will be built from generated source if output dir is specified.
""",
"B:" -> (arg => base_dir = Path.explode(arg)),
"D:" -> (arg => status_file = Some(Path.explode(arg))),
"H:" -> (arg => hugo_dir = Path.explode(arg)),
"O:" -> (arg => out_dir = Path.explode(arg)),
"b" -> (_ => build_only = true),
"d" -> (_ => devel_mode = true),
"f" -> (_ => fresh = true))
getopts(args)
status_file.foreach(path =>
if (!path.is_file || !path.file.exists()) error("Invalid status file: " + path))
if (devel_mode) hugo_dir = base_dir + Path.make(List("admin", "site"))
val afp_structure = AFP_Structure(base_dir)
val layout = Hugo.Layout(hugo_dir)
val progress = new Console_Progress()
if (!build_only) {
progress.echo("Preparing site generation in " + hugo_dir.implode)
afp_site_gen(layout = layout, status_file = status_file, afp_structure = afp_structure,
clean = fresh, progress = progress)
}
afp_build_site(out_dir = out_dir, layout = layout, do_watch = devel_mode,
clean = fresh, progress = progress)
})
}
\ No newline at end of file
diff --git a/tools/afp_structure.scala b/tools/afp_structure.scala
--- a/tools/afp_structure.scala
+++ b/tools/afp_structure.scala
@@ -1,129 +1,134 @@
/* Author: Fabian Huch, TU Muenchen
AFP Metadata file structure with save and load operations.
*/
package afp
import isabelle.*
class AFP_Structure private(val base_dir: Path) {
/* files */
val metadata_dir = base_dir + Path.basic("metadata")
val thys_dir = AFP.main_dir(base_dir)
def entry_thy_dir(name: Metadata.Entry.Name): Path = thys_dir + Path.basic(name)
val authors_file = metadata_dir + Path.basic("authors.toml")
val releases_file = metadata_dir + Path.basic("releases.toml")
val licenses_file = metadata_dir + Path.basic("licenses.toml")
val topics_file = metadata_dir + Path.basic("topics.toml")
val entries_dir = metadata_dir + Path.basic("entries")
def entry_file(name: Metadata.Entry.Name): Path = entries_dir + Path.basic(name + ".toml")
/* load */
private def load[A](file: Path, parser: isabelle.TOML.Table => A): A = {
val content = File.read(file)
val toml =
try { TOML.parse(content) }
catch { case ERROR(msg) => error("Could not parse " + file.toString + ": " + msg) }
parser(toml)
}
- def load_authors: List[Metadata.Author] = load(authors_file, Metadata.TOML.to_authors)
-
- def load_releases: List[Metadata.Release] = load(releases_file, Metadata.TOML.to_releases)
-
- def load_licenses: List[Metadata.License] = load(licenses_file, Metadata.TOML.to_licenses)
+ def load_authors: Metadata.Authors =
+ Metadata.Authors(load(authors_file, Metadata.TOML.to_authors))
- def load_topics: List[Metadata.Topic] = load(topics_file, Metadata.TOML.to_topics)
+ def load_releases: Metadata.Releases =
+ Metadata.Releases(load(releases_file, Metadata.TOML.to_releases))
- def load_entry(name: Metadata.Entry.Name,
- authors: Map[Metadata.Author.ID, Metadata.Author],
- topics: Map[Metadata.Topic.ID, Metadata.Topic],
- licenses: Map[Metadata.License.ID, Metadata.License],
- releases: Map[Metadata.Entry.Name, List[Metadata.Release]]
+ def load_licenses: Metadata.Licenses =
+ Metadata.Licenses(load(licenses_file, Metadata.TOML.to_licenses))
+
+ def load_topics: Metadata.Topics =
+ Metadata.Topics(load(topics_file, Metadata.TOML.to_topics))
+
+ def load_entry(
+ name: Metadata.Entry.Name,
+ authors: Metadata.Authors,
+ topics: Metadata.Topics,
+ licenses: Metadata.Licenses,
+ releases: Metadata.Releases
): Metadata.Entry = {
val entry_releases = releases.getOrElse(name, Nil)
load(entry_file(name), toml =>
Metadata.TOML.to_entry(name, toml, authors, topics, licenses, entry_releases))
}
- def load(): List[Metadata.Entry] = {
- val authors = load_authors.map(author => author.id -> author).toMap
- val topics = Utils.grouped_sorted(load_topics.flatMap(_.all_topics), (t: Metadata.Topic) => t.id)
- val licenses = load_licenses.map(license => license.id -> license).toMap
- val releases = load_releases.groupBy(_.entry)
- entries.map(name => load_entry(name, authors, topics, licenses, releases))
+ def load(): Metadata.Entries = {
+ val authors = load_authors
+ val topics = load_topics
+ val licenses = load_licenses
+ val releases = load_releases
+ Metadata.Entries(entries.map(name => load_entry(name, authors, topics, licenses, releases)))
}
/* save */
private def save(file: Path, content: isabelle.TOML.Table): Unit = {
file.dir.file.mkdirs()
File.write(file, TOML.Format(content))
}
def save_authors(authors: List[Metadata.Author]): Unit =
save(authors_file, Metadata.TOML.from_authors(authors))
def save_releases(releases: List[Metadata.Release]): Unit =
save(releases_file, Metadata.TOML.from_releases(releases))
- def save_topics(topics: List[Metadata.Topic]): Unit =
- save(topics_file, Metadata.TOML.from_topics(topics))
+ def save_topics(root_topics: List[Metadata.Topic]): Unit =
+ save(topics_file, Metadata.TOML.from_topics(root_topics))
def save_licenses(licenses: List[Metadata.License]): Unit =
save(licenses_file, Metadata.TOML.from_licenses(licenses))
def save_entry(entry: Metadata.Entry): Unit =
save(entry_file(entry.name), Metadata.TOML.from_entry(entry))
/* sessions */
def entries_unchecked: List[Metadata.Entry.Name] = {
val Entry = """([a-zA-Z0-9+_-]+)\.toml""".r
File.read_dir(entries_dir).map {
case Entry(name) => name
case f => error("Unrecognized file in metadata: " + f)
}
}
def entries: List[Metadata.Entry.Name] = {
val session_entries = Sessions.parse_roots(thys_dir + Path.basic("ROOTS"))
val session_set = session_entries.toSet
val metadata_set = entries_unchecked.toSet
if (session_set != metadata_set) {
val inter = session_set.intersect(metadata_set)
val session_without_metadata =
if (session_set.subsetOf(inter)) ""
else "No metadata for session in ROOTS: " + commas_quote(session_set -- inter)
val metadata_without_session =
if (metadata_set.subsetOf(inter)) ""
else "Metadata entries missing in ROOTS: " + commas_quote(metadata_set -- inter)
error("Metadata does not match sessions:\n" + session_without_metadata + metadata_without_session)
} else session_entries
}
def sessions_structure: Sessions.Structure =
Sessions.load_structure(options = Options.init(), select_dirs = List(thys_dir))
def entry_sessions(name: Metadata.Entry.Name): List[Sessions.Session_Entry] =
Sessions.parse_root(thys_dir + Path.make(List(name, "ROOT"))).collect { case e: Sessions.Session_Entry => e }
def hg_id: String = Mercurial.repository(base_dir).id()
}
object AFP_Structure {
def apply(base_dir: Path = AFP.BASE): AFP_Structure = new AFP_Structure(base_dir.absolute)
}
diff --git a/tools/afp_submit.scala b/tools/afp_submit.scala
--- a/tools/afp_submit.scala
+++ b/tools/afp_submit.scala
@@ -1,1625 +1,1613 @@
/* 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, DOI, Email, Entry, Formatted, Homepage, License, Orcid, Reference, Release, Topic, Unaffiliated}
+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.StringView
+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: Map[Author.ID, Author]): Map[Author.ID, Author] =
+ 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: Map[Author.ID, Author]): (Map[Author.ID, Author], List[Entry]) =
+ 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: Map[Author.ID, Author], entries: List[Entry]) {
- def new_authors(old_authors: Map[Author.ID, Author]): Set[Author] =
+ 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: Map[Author.ID, Author]): Set[Affiliation] =
+ 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: Map[Topic.ID, Topic],
- licenses: Map[License.ID, License]
- ): Option[Model.Submission]
+ 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.map(author => author.id -> author).toMap
- val topics = afp_structure.load_topics.flatMap(_.all_topics)
- val all_topics = topics.map(topic => topic.id -> topic).toMap
- val licenses = afp_structure.load_licenses.map(license => license.id -> license).toMap
- val releases = afp_structure.load_releases.groupBy(_.entry)
- val dates = afp_structure.load().map(entry => entry.name -> entry.date).toMap
+ 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, all_topics, licenses, releases)
+ 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: Map[ID, Topic], licenses: Map[ID, License]
- ): Option[Submission] =
+ 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, all_topics, licenses, releases)
+ 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: Map[Topic.ID, Topic],
- licenses: Map[License.ID, License]
- ): Option[Submission] =
+ 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.map(author => author.id -> author).toMap
+ val authors = structure.load_authors
val entries = structure.entries_unchecked.map(
- structure.load_entry(_, authors, topics, licenses, Map.empty))
+ 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: Map[Author.ID, Metadata.Author] = Map.empty
- var topics: List[Topic] = Nil
- var all_topics: Map[Topic.ID, Topic] = Map.empty
- var licenses: Map[License.ID, License] = Map.empty
- var releases: Map[Entry.Name, List[Release]] = Map.empty
+ 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.map(author => author.id -> author).toMap
- topics = afp_structure.load_topics.flatMap(_.all_topics)
- all_topics = topics.map(topic => topic.id -> topic).toMap
- licenses = afp_structure.load_licenses.map(license => license.id -> license).toMap
- releases = afp_structure.load_releases.groupBy(_.entry)
+ 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.map(topic => option(topic.id, topic.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.find(_.id == id) match {
+ 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: Map[Author.ID, Author]
+ 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: Map[Author.ID, Author]): Option[Email] =
+ 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: Map[Author.ID, Author]): Option[Affiliation] =
+ 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: Map[Author.ID, Author]): Option[Author] =
+ 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: Map[Author.ID, Author]): Option[Affiliation] =
+ 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: Map[Author.ID, Author]): Option[Model.Create_Entry] =
+ 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(_, all_topics, licenses))
+ Properties.get(props, ID).flatMap(handler.get(_, topics, licenses))
def resubmit(params: Params.Data): Option[Model.T] =
- handler.get(params.get(ACTION).value, all_topics, licenses).map(submission =>
+ 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, all_topics, licenses).flatMap { submission =>
+ 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, all_topics, licenses).flatMap { submission =>
+ 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/metadata.scala b/tools/metadata.scala
--- a/tools/metadata.scala
+++ b/tools/metadata.scala
@@ -1,418 +1,460 @@
/* Author: Fabian Huch, TU Muenchen
AFP metadata model and TOML serialization.
*/
package afp
import isabelle.*
import java.time.LocalDate
import java.net.URI
+import scala.collection.immutable.ListMap
+
object Metadata {
/* affiliations */
sealed trait Affiliation {
def author: Author.ID
}
case class Unaffiliated(override val author: Author.ID)
extends Affiliation
case class Email(override val author: Author.ID, id: Email.ID, address: String)
extends Affiliation {
private val Address = "([^@]+)@(.+)".r
val (user, host) = address match {
case Address(user, host) => (user, host)
case _ => error("Invalid address: " + address)
}
}
object Email {
type ID = String
def apply(author: Author.ID, id: Email.ID, user: String, host: String): Email =
Email(author, id, user + "@" + host)
}
case class Homepage(override val author: Author.ID, id: Homepage.ID, url: Url) extends Affiliation
object Homepage {
type ID = String
}
/* authors */
case class Orcid(identifier: String) {
require(
"^([0-9]{4})-([0-9]{4})-([0-9]{4})-([0-9]{3}[0-9X])$".r.matches(identifier),
"Invalid format for orcid: " + quote(identifier))
def url: Url = Url("https://orcid.org/" + identifier)
}
case class Author(
id: Author.ID,
name: String,
emails: List[Email] = Nil,
homepages: List[Homepage] = Nil,
orcid: Option[Orcid] = None
)
object Author {
type ID = String
}
+ type Authors = ListMap[Author.ID, Author]
+ object Authors {
+ def empty: Authors = Authors(Nil)
+ def apply(authors: List[Author]): Authors =
+ ListMap.from(authors.map(author => author.id -> author))
+ }
+
/* topics */
sealed trait Classification {
def desc: String
def url: Url
}
case class ACM(id: String, override val desc: String) extends Classification {
val url = Url("https://dl.acm.org/topic/ccs2012/" + id)
}
case class AMS(id: String, hierarchy: List[String]) extends Classification {
val code: String = id.length match {
case 2 => id + "-XX"
case 3 => id + "xx"
case 5 => id
case _ => error("Invalid ams id:" + id)
}
override val desc: String = hierarchy.mkString(" / ")
override val url: Url = Url("https://mathscinet.ams.org/mathscinet/msc/msc2020.html?t=" + code)
}
case class Topic(
id: Topic.ID,
name: String,
classification: List[Classification] = Nil,
sub_topics: List[Topic] = Nil
) {
def all_topics: List[Topic] = this :: sub_topics.flatMap(_.all_topics)
}
object Topic {
type ID = String
}
+ type Topics = ListMap[Topic.ID, Topic]
+ object Topics {
+ def empty: Topics = Topics(Nil)
+ def apply(root_topics: List[Topic]): Topics =
+ ListMap.from(root_topics.flatMap(_.all_topics).map(topic => topic.id -> topic))
+
+ def root_topics(topics: Topics): List[Topic] = {
+ val sub_topics = topics.values.flatMap(_.sub_topics).map(_.id).toSet
+ topics.values.filterNot(topic => sub_topics.contains(topic.id)).toList
+ }
+ }
+
+
/* releases */
type Date = LocalDate
object Isabelle {
type Version = String
}
case class Release(entry: Entry.Name, date: Date, isabelle: Isabelle.Version)
+ type Releases = ListMap[Entry.Name, List[Release]]
+ object Releases {
+ def empty: Releases = Releases(Nil)
+ def apply(releases: List[Release]): Releases =
+ Utils.group_sorted(releases, (release: Release) => release.entry)
+ }
+
/* license */
case class License(id: License.ID, name: String)
object License {
type ID = String
}
+ type Licenses = ListMap[License.ID, License]
+ object Licenses {
+ def empty: Licenses = Licenses(Nil)
+ def apply(licenses: List[License]): Licenses =
+ ListMap.from(licenses.map(license => license.id -> license))
+ }
+
/* references */
sealed trait Reference
case class DOI(identifier: String) extends Reference {
require("^10.([1-9][0-9]{3,})/(.+)".r.matches(identifier),
"invalid format for DOI: " + quote(identifier))
def uri: URI = new URI("doi:" + identifier)
def url: Url = Url("https://doi.org/" + identifier)
def formatted(style: String = "apa"): String =
Utils.fetch_text(url, Map("Accept" -> ("text/x-bibliography; style=" + style)))
}
case class Formatted(rep: String) extends Reference
/* misc */
type Change_History = Map[Date, String]
type Extra = Map[String, String]
/* entry */
case class Entry(
name: Entry.Name,
title: String,
authors: List[Affiliation],
date: Date,
topics: List[Topic],
`abstract`: String,
notifies: List[Email],
license: License,
note: String,
contributors: List[Affiliation] = Nil,
change_history: Change_History = Map.empty,
extra: Extra = Map.empty,
releases: List[Release] = Nil,
statistics_ignore: Boolean = false,
related: List[Reference] = Nil)
object Entry {
type Name = String
}
+ type Entries = ListMap[Entry.Name, Entry]
+ object Entries {
+ def empty: Entries = Entries(Nil)
+ def apply(entries: List[Entry]) = ListMap.from(entries.map(entry => entry.name -> entry))
+ }
+
/* toml */
private def by_id[A](elems: Map[String, A], id: String): A =
elems.getOrElse(id, error("Elem " + quote(id) + " not found in " + commas_quote(elems.keys)))
object TOML {
import isabelle.TOML.{Array, Boolean, Key, Local_Date, String, Table}
/* affils */
def from_email(email: Email): Table =
Table(
"user" -> Array(email.user.split('.').map(String(_))),
"host" -> Array(email.host.split('.').map(String(_))))
def to_email(author_id: Author.ID, email_id: Email.ID, email: Table): Email = {
val user = email.array("user").string.values.map(_.rep)
val host = email.array("host").string.values.map(_.rep)
Email(author_id, email_id, user.mkString("."), host.mkString("."))
}
/* author */
def from_author(author: Author): Table =
Table(
"name" -> String(author.name),
"emails" -> Table(author.emails.map(e => e.id -> from_email(e))),
"homepages" -> Table(author.homepages.map(h => h.id -> String(h.url.toString)))) ++
author.orcid.map(orcid => Table("orcid" -> String(orcid.identifier))).getOrElse(Table())
def to_author(author_id: Author.ID, author: Table): Author = {
val emails = author.table("emails").table.values.map {
case (id, email) => to_email(author_id, id, email)
}
val homepages = author.table("homepages").string.values.map {
case (id, url) => Homepage(author = author_id, id = id, url = Url(url.rep))
}
val orcid = author.string.get("orcid").map(_.rep).map(Orcid(_))
Author(
id = author_id,
name = author.string("name").rep,
orcid = orcid,
emails = emails,
homepages = homepages)
}
def from_authors(authors: List[Author]): Table =
Table(authors.map(author => author.id -> from_author(author)))
def to_authors(authors: Table): List[Author] = authors.table.values.map(to_author)
/* topics */
def from_acm(acm: ACM): Table =
Table("id" -> String(acm.id), "desc" -> String(acm.desc))
def to_acm(acm: Table): ACM =
ACM(acm.string("id").rep, acm.string("desc").rep)
def from_ams(ams: AMS): Table =
Table("id" -> String(ams.id), "hierarchy" -> Array(ams.hierarchy.map(String(_))))
def to_ams(ams: Table): AMS =
AMS(ams.string("id").rep, ams.array("hierarchy").string.values.map(_.rep))
def from_classifications(classifications: List[Classification]): Table =
Table(classifications.map {
case acm: ACM => "acm" -> from_acm(acm)
case ams: AMS => "ams" -> from_ams(ams)
})
def to_classifications(classifications: Table): List[Classification] =
classifications.table.values.map {
case ("ams", ams) => to_ams(ams)
case ("acm", acm) => to_acm(acm)
case (c, _) => error("Unknown topic classification: " + quote(c))
}
def from_topics(root_topics: List[Topic]): Table =
Table(root_topics.map(t => t.name -> (
Table("classification" -> from_classifications(t.classification)) ++
from_topics(t.sub_topics))))
def to_topics(root_topics: Table): List[Topic] = {
def to_topics_rec(topics: List[(Key, Table)], root: Topic.ID): List[Topic] = {
topics.map {
case (name, data) =>
val id = (if (root.nonEmpty) root + "/" else "") + name
val classifications = to_classifications(data.table("classification"))
val sub_topics = data.table.values.filterNot(_._1 == "classification")
Topic(id, name, classifications, to_topics_rec(sub_topics, id))
}
}
to_topics_rec(root_topics.table.values, "")
}
/* releases */
def from_releases(releases: List[Release]): Table =
Table(Utils.group_sorted(releases, (r: Release) => r.entry).view.mapValues { entry_releases =>
Table(entry_releases.map(r => r.date.toString -> String(r.isabelle)))
}.toList)
def to_releases(map: Table): List[Release] =
map.table.values.flatMap {
case (entry, releases) => releases.string.values.map {
case (date, version) =>
Release(entry = entry, date = LocalDate.parse(date), isabelle = version.rep)
}
}
/* affiliation */
def from_affiliations(affiliations: List[Affiliation]): Table =
Table(Utils.group_sorted(affiliations, (a: Affiliation) => a.author).view.mapValues(vs =>
Table(vs.collect {
case Email(_, id, _) => "email" -> String(id)
case Homepage(_, id, _) => "homepage" -> String(id)
})).toList)
- def to_affiliations(affiliations: Table, authors: Map[Author.ID, Author]): List[Affiliation] = {
+ def to_affiliations(affiliations: Table, authors: Authors): List[Affiliation] = {
def to_affiliation(affiliation: (Key, String), author: Author): Affiliation = {
affiliation match {
case ("email", id) => author.emails.find(_.id == id.rep) getOrElse
error("Email not found: " + quote(id.rep))
case ("homepage", id) => author.homepages.find(_.id == id.rep) getOrElse
error("Homepage not found: " + quote(id.rep))
case e => error("Unknown affiliation type: " + e)
}
}
affiliations.table.values.flatMap {
case (id, author_affiliations) =>
val author = by_id(authors, id)
if (author_affiliations.is_empty) List(Unaffiliated(author.id))
else author_affiliations.string.values.map(to_affiliation(_, author))
}
}
def from_emails(emails: List[Email]): Table =
Table(emails.map(email => email.author -> String(email.id)))
- def to_emails(emails: Table, authors: Map[Author.ID, Author]): List[Email] =
+ def to_emails(emails: Table, authors: Authors): List[Email] =
emails.string.values.map {
case (author, id) => by_id(authors, author).emails.find(_.id == id.rep) getOrElse
error("Email not found: " + quote(id.rep))
}
/* license */
def from_licenses(licenses: List[License]): Table =
Table(licenses.map(license => license.id -> Table("name" -> String(license.name))))
def to_licenses(licenses: Table): List[License] = {
licenses.table.values.map {
case (id, license) => License(id, license.string("name").rep)
}
}
- def to_license(license: String, licenses: Map[License.ID, License]): License =
+ def to_license(license: String, licenses: Licenses): License =
licenses.getOrElse(license.rep, error("No such license: " + quote(license.rep)))
/* history */
def from_change_history(change_history: Change_History): Table =
Table(change_history.map { case (date, str) => date.toString -> String(str) })
def to_change_history(change_history: Table): Change_History =
change_history.string.values.map {
case (date, entry) => LocalDate.parse(date) -> entry.rep
}.toMap
/* references */
def from_related(references: List[Reference]): Table = {
val dois = references.collect { case d: DOI => d }
val formatted = references.collect { case f: Formatted => f }
Table(
"dois" -> Array(dois.map(_.identifier).map(String(_))),
"pubs" -> Array(formatted.map(_.rep).map(String(_))))
}
def to_related(references: Table): List[Reference] = {
val dois = references.array.get("dois").toList.flatMap(_.string.values.map(_.rep))
val pubs = references.array.get("pubs").toList.flatMap(_.string.values.map(_.rep))
dois.map(DOI(_)) ++ pubs.map(Formatted(_))
}
/* entry */
def from_entry(entry: Entry): Table = {
Table(
"title" -> String(entry.title),
"authors" -> from_affiliations(entry.authors),
"contributors" -> from_affiliations(entry.contributors),
"date" -> Local_Date(entry.date),
"topics" -> Array(entry.topics.map(_.id).map(String(_))),
"abstract" -> String(entry.`abstract`),
"notify" -> from_emails(entry.notifies),
"license" -> String(entry.license.id),
"note" -> String(entry.note),
"history" -> from_change_history(entry.change_history),
"extra" -> Table(entry.extra.view.mapValues(String(_)).toList),
"related" -> from_related(entry.related)) ++
(if (entry.statistics_ignore) Table("statistics_ignore" -> Boolean(true)) else Table())
}
def to_entry(
name: Entry.Name,
entry: Table,
- authors: Map[Author.ID, Author],
- topics: Map[Topic.ID, Topic],
- licenses: Map[License.ID, License],
+ authors: Authors,
+ topics: Topics,
+ licenses: Licenses,
releases: List[Release]
): Entry =
Entry(
name = name,
title = entry.string("title").rep,
authors = to_affiliations(entry.table("authors"), authors),
date = entry.local_date("date").rep,
topics = entry.array("topics").string.values.map(_.rep).map(by_id(topics, _)),
`abstract` = entry.string("abstract").rep,
notifies = to_emails(entry.table("notify"), authors),
license = to_license(entry.string("license"), licenses),
note = entry.string("note").rep,
contributors = to_affiliations(entry.table("contributors"), authors),
change_history = to_change_history(entry.table("history")),
extra = entry.table("extra").string.values.map((k, v) => (k, v.rep)).toMap,
releases = releases,
statistics_ignore = entry.boolean.get("statistics_ignore").map(_.rep).getOrElse(false),
related = to_related(entry.table("related")))
}
}