Skip to content

Commit 02bc408

Browse files
committed
Add migrate-events script
1 parent 9165119 commit 02bc408

1 file changed

Lines changed: 294 additions & 0 deletions

File tree

migrate-events.scala

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
//> using scala 3.6.3
2+
//> using dep org.virtuslab::scala-yaml::0.3.1
3+
//> using dep co.fs2::fs2-io::3.12.2
4+
//> using dep com.typesafe:config:1.4.5
5+
6+
import cats.effect.{IO, IOApp}
7+
import cats.syntax.all.*
8+
import fs2.io.file.{Files, Path}
9+
import org.virtuslab.yaml.*
10+
import com.typesafe.config.{Config, ConfigFactory}
11+
import java.time.LocalDate
12+
import scala.jdk.CollectionConverters.*
13+
14+
case class ScheduleItem(
15+
time: String,
16+
title: String,
17+
speakers: Option[List[String]],
18+
summary: Option[String]
19+
) derives YamlCodec
20+
21+
case class Sponsor(
22+
name: String,
23+
logo: String,
24+
link: String,
25+
`type`: String,
26+
height: Option[Int]
27+
) derives YamlCodec
28+
29+
case class Meta(meetup: Option[String]) derives YamlCodec
30+
31+
case class EventConfig(
32+
title: String,
33+
short_title: Option[String],
34+
date_string: String,
35+
location: String,
36+
description: String,
37+
poster_hero: Option[String],
38+
poster_thumb: Option[String],
39+
schedule: Option[List[ScheduleItem]],
40+
sponsors: Option[List[Sponsor]],
41+
meta: Option[Meta]
42+
) derives YamlCodec
43+
44+
case class Event(conf: EventConfig, content: String, originalYaml: String) {
45+
46+
def loadSpeakerDirectory(): Map[String, String] =
47+
try {
48+
val config = ConfigFactory.parseFile(
49+
Path("src/blog/directory.conf").toNioPath.toFile
50+
)
51+
val speakerNames = config
52+
.root()
53+
.keySet()
54+
.asScala
55+
.toList
56+
.map { key =>
57+
key -> config.getConfig(key).getString("name")
58+
}
59+
.toMap
60+
speakerNames
61+
} catch {
62+
case _: Exception => Map.empty[String, String]
63+
}
64+
65+
def cleanOtherLinks(markdown: String): String = {
66+
var cleaned = markdown
67+
68+
// https://typelevel.org/blog/YYYY/MM/DD/post-name.html -> post-name.md
69+
val typelevelBlogPattern =
70+
"""https://typelevel\.org/blog/\d{4}/\d{2}/\d{2}/([^)\s]+)\.html""".r
71+
cleaned = typelevelBlogPattern.replaceAllIn(cleaned, "$1.md")
72+
73+
// /blog/YYYY/MM/DD/post-name.html -> post-name.md
74+
val relativeBlogPattern =
75+
"""(?<![a-z])/blog/\d{4}/\d{2}/\d{2}/([^)\s]+)\.html""".r
76+
cleaned = relativeBlogPattern.replaceAllIn(cleaned, "$1.md")
77+
78+
val siteUrlPattern = """\{\{\s*site\.url\s*\}\}""".r
79+
cleaned = siteUrlPattern.replaceAllIn(cleaned, "")
80+
81+
// Replace {{ page.meta.meetup }} with just the URL
82+
val meetupPattern = """\{\{\s*page\.meta\.meetup\s*\}\}""".r
83+
cleaned = conf.meta.flatMap(_.meetup) match {
84+
case Some(meetupUrl) => meetupPattern.replaceAllIn(cleaned, meetupUrl)
85+
case None => meetupPattern.replaceAllIn(cleaned, "")
86+
}
87+
88+
// Replace .html extensions with .md in relative links (but not absolute URLs starting with http)
89+
val htmlToMdPattern = """(?<!https?://[^\s)]*)(\\.html)""".r
90+
cleaned = htmlToMdPattern.replaceAllIn(cleaned, ".md")
91+
92+
cleaned = cleaned.replace("/projects", "/projects/README.md")
93+
cleaned = cleaned.replace("/conduct.html", "/code-of-conduct/README.md")
94+
cleaned =
95+
cleaned.replace("/code-of-conduct.html", "/code-of-conduct/README.md")
96+
97+
cleaned
98+
}
99+
100+
def buildHoconMetadata(date: String, eventDate: String, eventLocation: String, tags: List[String]): String =
101+
s"""|{%
102+
| laika.html.template: event.template.html
103+
| date: "$date"
104+
| event-date: "$eventDate"
105+
| event-location: "$eventLocation"
106+
| tags: ${tags.mkString("[", ", ", "]")}
107+
|%}""".stripMargin
108+
109+
def generateScheduleMarkdown(): String = conf.schedule
110+
.map { scheduleItems =>
111+
val tableRows = scheduleItems
112+
.map { item =>
113+
val timeColumn = item.time
114+
115+
val talkColumn = if (item.speakers.isEmpty) {
116+
item.title
117+
} else {
118+
item.speakers
119+
.map { speakers =>
120+
val speakerDirectory = loadSpeakerDirectory()
121+
val speakerNames = speakers
122+
.map(s => speakerDirectory.getOrElse(s, s))
123+
.mkString(", ")
124+
item.summary match
125+
case Some(value) =>
126+
s"@:style(schedule-title)${item.title}@:@ @:style(schedule-byline)${speakerNames}@:@ ${value}"
127+
case None => s"**${item.title}**<br/>${speakerNames}"
128+
}
129+
.getOrElse(item.title)
130+
}
131+
132+
s"| ${timeColumn} | ${talkColumn} |"
133+
}
134+
.mkString("\n")
135+
136+
s"""|| Time | Talk |
137+
||------|------|
138+
|$tableRows""".stripMargin
139+
}
140+
.getOrElse("")
141+
142+
def generateSponsorsHtml(): String = {
143+
conf.sponsors
144+
.map { sponsors =>
145+
val sponsorsByType = sponsors.groupBy(_.`type`)
146+
147+
val sections = List("platinum", "gold", "silver").flatMap {
148+
sponsorType =>
149+
sponsorsByType.get(sponsorType).map { typeSponsors =>
150+
val sponsorCells = typeSponsors
151+
.map { sponsor =>
152+
s"@:style(bulma-cell bulma-has-text-centered)[@:image(${sponsor.logo}) { alt: ${sponsor.name}, title: ${sponsor.name}, style: legacy-event-sponsor }](${sponsor.link})@:@"
153+
}
154+
.mkString("\n")
155+
156+
s"""|### ${sponsorType.capitalize}
157+
|@:style(bulma-grid bulma-is-col-min-12)
158+
|$sponsorCells
159+
|@:@""".stripMargin
160+
}
161+
}
162+
163+
sections.mkString("\n\n")
164+
}
165+
.getOrElse("")
166+
}
167+
168+
def toLaika(date: String, stage: Int): String = {
169+
val tags = Option.when(conf.title.contains("Summit"))("summits").toList ::: "events" :: Nil
170+
val metadata = buildHoconMetadata(date, conf.date_string, conf.location, tags)
171+
val title = s"# ${conf.title}"
172+
val image =
173+
conf.poster_hero.map(img => s"![${conf.title}]($img)").getOrElse("")
174+
175+
stage match {
176+
case 1 =>
177+
// Stage 1: Just move to new location, keep original format
178+
s"---\n$originalYaml---\n\n$content\n"
179+
180+
case 2 =>
181+
// Stage 2: HOCON metadata + title, no content changes
182+
s"$metadata\n\n$title\n\n$image\n\n$content\n"
183+
184+
case 3 =>
185+
// Stage 3: Stage 2 + link cleaning
186+
val transformedContent = cleanOtherLinks(content)
187+
s"$metadata\n\n$title\n\n$image\n\n$transformedContent\n"
188+
189+
case _ =>
190+
// Stage 4+: Use original content and replace Jekyll includes with generated HTML
191+
val transformedContent = cleanOtherLinks(content)
192+
193+
// Replace Jekyll includes with generated HTML
194+
var processedContent = transformedContent
195+
196+
// Remove schedule assign and replace schedule include
197+
val scheduleAssignPattern =
198+
"""\{\%\s*assign\s+schedule\s*=\s*page\.schedule\s*%\}\s*""".r
199+
processedContent =
200+
scheduleAssignPattern.replaceAllIn(processedContent, "")
201+
202+
val schedulePattern = """\{\%\s*include\s+schedule\.html\s*%\}""".r
203+
val scheduleReplacement =
204+
if (conf.schedule.isDefined) generateScheduleMarkdown() else ""
205+
processedContent =
206+
schedulePattern.replaceAllIn(processedContent, scheduleReplacement)
207+
208+
// Replace sponsors include
209+
val sponsorsPattern = """\{\%\s*include\s+sponsors\.html\s*%\}""".r
210+
val sponsorsReplacement =
211+
if (conf.sponsors.isDefined) generateSponsorsHtml() else ""
212+
processedContent =
213+
sponsorsPattern.replaceAllIn(processedContent, sponsorsReplacement)
214+
215+
// Remove venue_map includes (not supported)
216+
val venueMapPattern = """\{\%\s*include\s+venue_map\.html\s*%\}""".r
217+
processedContent = venueMapPattern.replaceAllIn(processedContent, "")
218+
219+
s"$metadata\n\n$title\n\n$image\n\n$processedContent\n"
220+
}
221+
}
222+
}
223+
224+
object EventParser {
225+
def parse(path: Path, content: String): Either[Throwable, Event] = {
226+
// Normalize Windows line endings to Unix
227+
val normalized = content.replace("\r\n", "\n")
228+
val parts = normalized.split("---\n", 3)
229+
if (parts.length < 3) {
230+
val fn = path.fileName
231+
Left(new Exception(s"Invalid event '$fn': no YAML front matter found"))
232+
} else {
233+
val yamlContent = parts(1)
234+
val markdownContent = parts(2).trim
235+
yamlContent
236+
.as[EventConfig]
237+
.map(conf => Event(conf, markdownContent, yamlContent))
238+
}
239+
}
240+
}
241+
242+
object MigrateEvents extends IOApp {
243+
val oldEventsDir = Path("collections/_events")
244+
val newBlogDir = Path("src/blog")
245+
246+
def getDateAndName(path: Path): Either[Throwable, (String, String)] = {
247+
val filename = path.fileName.toString
248+
val datePattern = """(\d{4}-\d{2}-\d{2})-(.+)""".r
249+
filename match {
250+
case datePattern(date, rest) =>
251+
Right((date, filename)) // Keep full filename
252+
case _ =>
253+
Left(new Exception(s"Filename doesn't match pattern: $filename"))
254+
}
255+
}
256+
257+
def readEvent(path: Path): IO[String] = Files[IO]
258+
.readAll(path)
259+
.through(fs2.text.utf8.decode)
260+
.compile
261+
.string
262+
263+
def writeEvent(path: Path, content: String): IO[Unit] = fs2.Stream
264+
.emit(content)
265+
.through(fs2.text.utf8.encode)
266+
.through(Files[IO].writeAll(path))
267+
.compile
268+
.drain
269+
270+
def migrateEvent(sourcePath: Path, stage: Int): IO[String] = for {
271+
(date, fullFilename) <- IO.fromEither(getDateAndName(sourcePath))
272+
content <- readEvent(sourcePath)
273+
event <- IO.fromEither(EventParser.parse(sourcePath, content))
274+
laikaContent = event.toLaika(date, stage)
275+
destPath = newBlogDir / fullFilename
276+
_ <- writeEvent(destPath, laikaContent)
277+
} yield fullFilename
278+
279+
def migrateAllEvents(stage: Int): IO[Long] = Files[IO]
280+
.list(oldEventsDir)
281+
.filter(_.fileName.toString.matches("""^\d{4}-\d{2}-\d{2}-.+\.md$"""))
282+
.evalMap(path => migrateEvent(path, stage))
283+
.evalMap(fullFilename => IO.println(s"Migrated: $fullFilename"))
284+
.compile
285+
.count
286+
287+
def run(args: List[String]): IO[cats.effect.ExitCode] = {
288+
val stage = args.headOption.flatMap(_.toIntOption).getOrElse(4)
289+
IO.println(s"Running migration with stage $stage") *>
290+
migrateAllEvents(stage)
291+
.flatMap(c => IO.println(s"Migrated $c events"))
292+
.as(cats.effect.ExitCode.Success)
293+
}
294+
}

0 commit comments

Comments
 (0)