<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>jmglov's blog</title>
  <link href="https://jmglov.net/blog/atom.xml" rel="self"/>
  <link href="https://jmglov.net/blog/"/>
  <updated>2025-01-02T09:54:05+00:00</updated>
  <id>https://jmglov.net/blog/</id>
  <author>
    <name>Josh Glover</name>
  </author>
  <entry>
    <id>https://jmglov.net/blog/2025-01-02-running-round-malaren.html</id>
    <link href="https://jmglov.net/blog/2025-01-02-running-round-malaren.html"/>
    <title>Why can't I just run around Lake Mälaren?</title>
    <updated>2025-01-02T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2025-01-02-running-round-malaren-preview.png" alt="A lakeshore under a blue sky" title="Which way is Mälaren, Gandalf?" width=800px /></p><p>"Is this the lake we're running around?"</p><p>My sister uttered these fateful words on a sunny day in May 2024, knowing not the events she would set into motion. For the lake she was referring to, dear reader, was none other than <a href='https://en.wikipedia.org/wiki/M%C3%A4laren'>Lake
Mälaren</a>, which:</p><blockquote><p> is the third-largest freshwater lake in Sweden (after Vänern and Vättern). Its  area is 1,140 km2 (440 sq mi) and its greatest depth is 64 m (210 ft). Mälaren  spans 120 km (74.6 mi) from east to west. </p></blockquote><p>I responded flippantly at the time, probably something to the tune of "How much time you got?", and she laughed, but unbeknownst to me, an idea was planted in the fertile soil of her mind, one which would sprout months later in a text conversation.</p><p>"So Josh," she probably wrote (I'm far too lazy to look up what she actually wrote) "remember when I asked you about running around that lake? Why don't you just do it?"</p><p>"OMG what is wrong with you?" I possibly responded. "Don't you remember me saying that it's like a million miles"&ndash;I have no idea how many miles 300-ish kilometres is&ndash;"in circumference?"</p><p>"Yeah," she maybe said, "but what if you didn't do it all at once? You know how Grandpop hiked the entire <a href='https://en.wikipedia.org/wiki/Appalachian_Trail'>Appalachian
Trail</a> in fits and starts? You could do the same thing, right?"</p><p>She had me there. Whilst I certainly can't run 300-ish kilometres in one go, there's no good reason why I can't run around the damned thing 20-ish kilometres at a time. Just rinse and repeat 15 times and Bob's my uncle, right? (Actually, Will's my uncle, but that's neither here nor there.)</p><p>It just so happened that around the same time, in a great fit of irony, I ran down to the lake from my house for a swim, capped off as always by a dive off a 5 metre platform deployed for the amusement of beachgoers (save when the lake is actually frozen over, because diving headfirst into ice is discouraged by 9 of 10 leading brain surgeons).</p><p><img src="assets/break-the-ice-dude.gif" alt="A man tries to jump into a frozen pool" title="Don't do this!" /></p><p>I've done this hundreds of times, but when I hit the water this time, my left hand caught the water in a way that caused my left arm to rotate in a direction that the human shoulder joint is apparently not designed to do, and since I'm famously "over 35", that led to my shoulder hurting whenever I raise my left arm above my head. 🙄</p><p>So I didn't run for a few months, but I did start plotting my path around the lake, and last week, on a particularly mild December day (it was 9° C, somehow), I decided to take a run clockwise down the lakeshore, thinking I could always stop and walk back to a bus stop or <a href='https://en.wikipedia.org/wiki/Stockholm_Metro'>tunnelbana</a> station if my shoulder started hurting mid-run. My plan was to first run down to my usual beach (4.5 kilometres from my house), then continue down the shore to <a href='https://en.wikipedia.org/wiki/Alvik_metro_station'>Alvik
station</a> on the Green line, which I guessed might be 15-ish kilometres from the beach. In addition to my usual Runkeeper app (which I plan to replace with a ClojureScript app in Scittle one of these days), I tracked my run with a new app I had installed, <a href='https://organicmaps.app/'>Organic
Maps</a>, which uses <a href='https://www.openstreetmap.org/about'>OpenStreetMap</a> data and can export GPX files.</p><p>To cut to the chase (something I find difficult to be sure), I made it down to Alvik!</p><p><img src="assets/2025-01-02-malaren-circuit.png" alt="A map with my first segment highlighted" title="Do this!" /></p><p>The run turned out to be 14.07 km in total, which meant that I got just under 10 km of the way around Mälaren. Only 30 more runs to go! 🎉</p><p><img src="assets/2025-01-02-malaren-entire.png" alt="A map zoomed out to show the entirety of the lake, with my first segment highlighted" title="OMG how?" /></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2025-01-01-why-cant-i-just.html</id>
    <link href="https://jmglov.net/blog/2025-01-01-why-cant-i-just.html"/>
    <title>Why can't I just?</title>
    <updated>2025-01-01T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2025-01-01-why-cant-i-just-resolution.jpg" alt="A picture of a three-monitor computer display labelled &quot;My New Year's resolution is 5760x1080&quot;" title="Now that's a wide load!" width=800px /></p><p>I'm not one for New Year's resolutions, so instead I'll simply ask myself some questions.</p><p>Why can't I just</p><ul><li>go to the gym three times a week?</li><li>get 10,000 steps a day even without Rover pulling me along?</li><li>go for a nice long run at least once a week, even if there's snow on the  ground?</li><li>write something in my blog every morning, even if it's just a series of random  thoughts or a bawdy limerick of my own composition?</li><li>write anything in my blog that's fewer than 10,000 words? 🙄</li><li>finish my draft of the <a href='https://jmglov.net/blog/draft-heart-of-clojure-2024.html'>Heart of Clojure conference
  report</a>?</li><li>record Season 1 of <a href='https://politechspod.com/'>Politechs</a> with Ray?</li><li>finish writing my <a href='https://otranscribe.com/'>oTranscribe</a> clone with Scittle?</li><li>take part 2 of the amazing <a href='https://www.nand2tetris.org/'>Nand to Tetris</a>  course?</li><li>read more of "Crepúsculo: Un amor peligroso"?</li><li>OMG finally stop eating cheese already?</li></ul><p>The answer to all of these questions is basically "you can just, so why not just?"</p><p>Happy New Year! 🎉</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-09-18-podcast-soundcljoud.html</id>
    <link href="https://jmglov.net/blog/2024-09-18-podcast-soundcljoud.html"/>
    <title>Building a podcast with Clojure</title>
    <updated>2024-09-18T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>In addition to spending far too much of my time doing silly things with Clojure and then even farther too much of my time writing about doing silly things with Clojure, I spend some of my time thinking about, talking about, and participating in labour organising here in Sweden. As I was talking about unions and such to Ray one day, no doubt six tangents into one of my usual rambling explorations of an idea, he interrupted my flow. "Stop!" he said, "for I have a plan so cunning you could pin a tail on it and call it a weasel!" Curiosity piqued, I enquired as to the nature of said plan. "We should make a podcast," he continued, "and on this podcast, we should talk about tech workers and why it makes sense for them to unionise. And we should focus on Sweden, since it's a fairly unique labour market, plus you know interesting people who we could interview."</p><p>"Ray," I rejoined, "that truly is a plan of weasel-grade cunning. I have but one suggestion that will turn this good idea into a great one." "And what," quoth he, "pray tell, is that suggestion?" "Babashka! Scittle! Clojure!" I exclaimed, so full of excitement I was having troubling supporting my proper nouns with clauses of explanatory power. "We could use all of this amazing technology for all of the heavy lifting around making a podcast! We could <a href='2022-06-24-s3-https.html'>build a website
using S3 static hosting</a>, then we could use an approach similar to <a href='https://jmglov.net/blog/tags/blog.html'>how I built my
blog</a> to create pages for episodes with show notes and transcripts and all that good stuff!"</p><p>So it was agreed, and thus <a href='https://orgtech.se'>Organising Tech in Sweden</a> came to be.</p><p><img src="assets/2024-09-18-podcast-soundcljoud-orgtech.png" alt="The words 'Organising Tech in Sweden' superimposed on raised fists with a Swedish flag with a circuit board pattern in the background" title="Divided we beg, united we bargain!" width=512px /></p><h2 id="building_the_website">Building the website</h2><p>As with any of my recent projects, my first step is always to create a directory and drop a <a href='https://github.com/babashka/scittle/'>Scittle</a>-enabled <code>bb.edn</code> in it:</p><pre class="language-text"><code class="lang-text language-text">: &#126;; mkdir &#126;/code/orgtech-se
: &#126;; cd !$
</code></pre><p><strong>bb.edn</strong></p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {io.github.babashka/sci.nrepl
        {:git/sha &quot;2f8a9ed2d39a1b09d2b4d34d95494b56468f4a23&quot;}
        io.github.babashka/http-server
        {:git/sha &quot;e203166a020509d126149ff8046489857ce5c89f&quot;}}
 :tasks
 {http-server {:doc &quot;Starts http server for serving static files&quot;
               :requires &#40;&#91;babashka.http-server :as http&#93;&#41;
               :task &#40;do &#40;http/serve {:port 1341 :dir &quot;public&quot;}&#41;
                         &#40;println &quot;Serving static assets at http://localhost:1341&quot;&#41;&#41;}

  browser-nrepl {:doc &quot;Start browser nREPL&quot;
                 :requires &#40;&#91;sci.nrepl.browser-server :as bp&#93;&#41;
                 :task &#40;bp/start! {}&#41;}

  -dev {:depends &#91;http-server browser-nrepl&#93;}

  dev {:task &#40;do &#40;run '-dev {:parallel true}&#41;
                 &#40;deref &#40;promise&#41;&#41;&#41;}}}
</code></pre><p>Since this is a static website, we can create a static <code>public/index.html</code> for it, with the <a href='2022-07-05-hacking-blog-favicon.html'>usual favicon</a> and <a href='2022-08-17-hacking-blog-sharing.html'>social
sharing</a> stuff:</p><pre class="language-html"><code class="lang-html language-html">&lt;html xmlns=&quot;http://www.w3.org/1999/xhtml&quot;&gt;

&lt;head&gt;
  &lt;title&gt;Organising Tech in Sweden Podcast&lt;/title&gt;

  &lt;meta charset=&quot;utf-8&quot; /&gt;
  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width&quot; /&gt;

  &lt;link rel=&quot;stylesheet&quot; href=&quot;/css/main.css&quot;&gt;

  &lt;!-- Favicon from https://realfavicongenerator.net/ --&gt;
  &lt;link rel=&quot;apple-touch-icon&quot; sizes=&quot;180x180&quot; href=&quot;/apple-touch-icon.png&quot;&gt;
  &lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;32x32&quot; href=&quot;/favicon-32x32.png&quot;&gt;
  &lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;16x16&quot; href=&quot;/favicon-16x16.png&quot;&gt;
  &lt;link rel=&quot;manifest&quot; href=&quot;/site.webmanifest&quot;&gt;
  &lt;link rel=&quot;mask-icon&quot; href=&quot;/safari-pinned-tab.svg&quot; color=&quot;#5bbad5&quot;&gt;
  &lt;meta name=&quot;msapplication-TileColor&quot; content=&quot;#da532c&quot;&gt;
  &lt;meta name=&quot;theme-color&quot; content=&quot;#ffffff&quot;&gt;

  &lt;!-- Social sharing &#40;Facebook, Twitter, LinkedIn, etc.&#41; --&gt;
  &lt;meta name=&quot;title&quot; content=&quot;Organising Tech in Sweden&quot;&gt;
  &lt;meta name=&quot;twitter:title&quot; content=&quot;Organising Tech in Sweden&quot;&gt;
  &lt;meta property=&quot;og:title&quot; content=&quot;Organising Tech in Sweden&quot;&gt;
  &lt;meta property=&quot;og:type&quot; content=&quot;website&quot;&gt;

  &lt;meta name=&quot;description&quot; content=&quot;A limited podcast series exploring union organising in Swedish tech companies&quot;&gt;
  &lt;meta name=&quot;twitter:description&quot;
    content=&quot;A limited podcast series exploring union organising in Swedish tech companies&quot;&gt;
  &lt;meta property=&quot;og:description&quot; content=&quot;A limited podcast series exploring union organising in Swedish tech companies&quot;&gt;

  &lt;meta name=&quot;twitter:url&quot; content=&quot;https://orgtech.se/&quot;&gt;
  &lt;meta property=&quot;og:url&quot; content=&quot;https://orgtech.se/&quot;&gt;

  &lt;meta name=&quot;twitter:image&quot; content=&quot;https://orgtech.se/img/orgtech-se-preview.jpg&quot;&gt;
  &lt;meta name=&quot;twitter:card&quot; content=&quot;summary&#95;large&#95;image&quot;&gt;
  &lt;meta property=&quot;og:image&quot; content=&quot;https://orgtech.se/img/orgtech-se-preview.jpg&quot;&gt;
  &lt;meta property=&quot;og:image:alt&quot;
    content=&quot;Podcast logo: 'Organising Tech in Sweden' superimposed on raised fists with a Swedish flag with a circuit board pattern in the background&quot;&gt;
&lt;/head&gt;

&lt;body&gt;
  &lt;div id=&quot;wrapper&quot;&gt;
    &lt;div id=&quot;left-side&quot;&gt;
      &lt;div id=&quot;cover-image&quot;&gt;
        &lt;img src=&quot;/img/orgtech-se-cover.jpg&quot;
          title=&quot;Organising Tech in Sweden&quot;
          alt=&quot;Podcast logo: 'Organising Tech in Sweden' superimposed on raised fists with a Swedish flag with a circuit board pattern in the background&quot; /&gt;
      &lt;/div&gt;
      &lt;div id=&quot;aggregators-1&quot;&gt;
        &lt;div id=&quot;apple&quot;&gt;
          &lt;a class=&quot;apple-button&quot;
            href=&quot;https://podcasts.apple.com/us/podcast/organising-tech-in-sweden/id1766442275?itsct=podcast&#95;box&#95;badge&amp;amp;itscg=30200&amp;amp;ls=1&quot;&gt;
            &lt;img src=&quot;https://tools.applemediaservices.com/api/badges/listen-on-apple-podcasts/badge/en-us?size=250x83&amp;amp;releaseDate=1725494400&quot;
              title=&quot;Listen on Apple Podcasts&quot;
              alt=&quot;Listen on Apple Podcasts&quot;
              class=&quot;apple-button&quot;&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div id=&quot;spotify&quot;&gt;
          &lt;a href=&quot;https://open.spotify.com/show/53psoLoX187axvmgb80l1x&quot;&gt;
            &lt;img src=&quot;/img/spotify-podcast-badge-blk-grn-330x80.svg&quot;
              title=&quot;Listen on Spotify&quot;
              alt=&quot;Listen on Spotify&quot;&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;aggregators-2&quot;&gt;
        &lt;div id=&quot;podbean&quot;&gt;
          &lt;a href=&quot;https://www.podbean.com/podcast-detail/2r2tz-31b053/Organising-Tech-in-Sweden-Podcast&quot;
            rel=&quot;noopener noreferrer&quot; target=&quot;&#95;blank&quot;&gt;
            &lt;img src=&quot;https://pbcdn1.podbean.com/fs1/site/images/badges/w600&#95;1.png&quot;
              title=&quot;Listen on Podbean&quot;
              alt=&quot;Listen on Podbean&quot;&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;div id=&quot;main&quot;&gt;
      &lt;div id=&quot;header&quot;&gt;
        &lt;h1 id=&quot;title&quot; class=&quot;header&quot;&gt;Episode 1 is out now!&lt;/h1&gt;
        &lt;!-- &lt;h1 id=&quot;title&quot; class=&quot;header&quot;&gt;&lt;a href=&quot;episodes/&quot;&gt;Episodes&lt;/a&gt;&lt;/h1&gt; --&gt;
        &lt;div id=&quot;socials&quot;&gt;
          &lt;a href=&quot;https://x.com/orgtech&#95;se&quot;&gt;
            &lt;img src=&quot;/img/twitter-color-svgrepo-com.svg&quot;
              title=&quot;Follow us on Twitter!&quot;
              alt=&quot;Twitter logo&quot; /&gt;
          &lt;/a&gt;
          &lt;a href=&quot;https://bsky.app/profile/orgtech-se.bsky.social&quot;&gt;
            &lt;img src=&quot;/img/bluesky-logo.svg&quot;
              title=&quot;Follow us on Bluesky!&quot;
              alt=&quot;Bluesky logo&quot; /&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;text&quot;&gt;
        &lt;p&gt;
          Organising Tech in Sweden is a limited podcast series exploring union
          organising in Swedish tech companies. Join us as we sit down with some
          of the people involved in the campaigns to win collective bargaining
          rights at two of Sweden's tech unicorns, Klarna and Spotify.
        &lt;/p&gt;
        &lt;div id=&quot;production-info&quot;&gt;
          &lt;div&gt;
            &lt;p&gt;
              Listen to our latest episode:&lt;br /&gt;
              🔊 &lt;a href=&quot;/episodes/ep01-klarna-part1&quot;&gt;Organising Klarna - Part 1&lt;/a&gt;
            &lt;/p&gt;
            &lt;p&gt;
              Produced by Hakuna Matata Produktion
            &lt;/p&gt;
            &lt;p&gt;
              Cover art by &lt;a href=&quot;https://anyakjordan.com/&quot;&gt;Anya K. Jordan&lt;/a&gt;
              &lt;a href=&quot;https://bsky.app/profile/anyakjordan.bsky.social&quot;&gt;@anyakjordan.bsky.social&lt;/a&gt;
            &lt;/p&gt;
            &lt;p&gt;
              Theme music by &lt;a href=&quot;https://soundcloud.com/ptzery&quot;&gt;Ptzery&lt;/a&gt;
            &lt;/p&gt;
          &lt;/div&gt;
          &lt;div id=&quot;hmp-logo&quot;&gt;
            &lt;img src=&quot;/img/hakuna-matata-produktion.png&quot;
              title=&quot;Hakuna Matata Produktion&quot;
              alt=&quot;Hakuna Matata Produktion logo&quot;&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div id=&quot;news&quot;&gt;
    &lt;h1&gt;News&lt;/h1&gt;
    &lt;h2&gt;Episode 1 is out!&lt;/h2&gt;
    &lt;p&gt;🔊 &lt;a href=&quot;/episodes/ep01-klarna-part1&quot;&gt;Organising Klarna - Part 1&lt;/a&gt;&lt;/p&gt;
    &lt;p&gt;
      We kick off Organising Tech in Sweden in style by recounting the story of
      how a collective bargaining agreement &#40;CBA&#41; was won at Klarna, a major
      Swedish fintech. In fact, Klarna was the first unicorn in Sweden to be
      unionised &#40;and probably the first unicorn in Europe as well&#41;!
    &lt;/p&gt;
    &lt;p&gt;
      To hear all about how this went down, your co-hosts Josh and Ray are joined by
      Thomas, the founder of the Klarna Unionen Club &#40;a union &quot;local&quot;, to use
      terminology that might be more familiar to US listeners&#41;; Sen, the chair of
      the club who won the bargaining agreement against the odds; and Kim, a former
      Klarna employee with extensive knowledge of Swedish labour law and market
      policy.
    &lt;/p&gt;
  &lt;/div&gt;
&lt;/body&gt;

&lt;/html&gt;
</code></pre><p>We can grab all the nice images and such from the interwebs:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; curl \
  https://orgtech.se/orgtech-se-favicon-and-img.tar.gz \
  | tar xvz -C public
</code></pre><p>And of course we need to make it nice and responsive so it looks good both on a computer screen and a mobile phone screen. Let's create <code>public/css/main.css</code> and drop some stylish styles therein:</p><pre class="language-css"><code class="lang-css language-css">body {
  font:
    1.2em Helvetica,
    Arial,
    sans-serif;
  margin: 20px;
  padding: 0;
}

body &gt; div {
  max-width: 100%;
  margin-left: auto;
  margin-right: auto;
}

img {
  max-width: 100%;
}

a {
  text-decoration: none;
  &amp;:hover {
    text-decoration: underline;
  }
}

#header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 10px;
}

h1 {
  font-weight: bold;
  font-size: larger;
}

#socials {
  display: flex;
  gap: 10px;
}

#socials img {
  max-width: 32px;
  &amp;:hover {
    transform: scale&#40;1.1&#41;;
  }
}

@media screen and &#40;min-width: 600px&#41; {

  body &gt; div {
    max-width: 800px;
    margin-top: 1em;
  }

  #wrapper {
    display: flex;
  }

  #cover-image {
    margin-right: 20px;
    max-width: 40%;
  }

}
</code></pre><p>Now we can fire up a local webserver:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; bb dev
Serving assets at http://localhost:1341
Serving static assets at http://localhost:1341
nREPL server started on port 1339...
Websocket server started on 1340...
</code></pre><p>Gaze ye now upon the glories of http://localhost:1341!</p><p><img src="assets/2024-09-18-podcast-soundcljoud-index.png" alt="The words 'Organising Tech in Sweden' superimposed on raised fists with a Swedish flag with a circuit board pattern in the background" title="What even is a computer?" width=800px border=1 /></p><h2 id="publishing_the_website">Publishing the website</h2><p>We of course have already registered a domain and done the intricate dance of <a href='2022-06-24-s3-https.html'>setting up S3 static website hosting and CloudFront and all of
that</a>, so all we need to do to publish our website is copy some files into our S3 bucket. And of course, what better way to do this than with a <a href='https://book.babashka.org/#tasks'>Babashka task</a>?</p><p>As avid REPL-drivers, we want to use our REPL for task development as well, so the first thing we do is create a <code>tasks.clj</code> with a boring <code>publish</code> function in it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks&#41;

&#40;defn publish &#91;{:keys &#91;website-bucket out-dir&#93; :as opts}&#93;
  &#40;println &#40;format &quot;Publishing %s/ to s3://%s/&quot;
                   out-dir website-bucket&#41;&#41;&#41;
</code></pre><p>Now we need to hook that up to <code>bb.edn</code> by setting the classpath appropriately, pulling in our new <code>tasks</code> namespace, defining some options, and adding a <code>publish</code> task:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps { ... }
 :paths &#91;&quot;.&quot;&#93;
 :tasks
 {:requires &#40;&#91;tasks&#93;&#41;
  :init &#40;def opts
          {:website-bucket &quot;orgtech.se&quot;
           :out-dir &quot;public&quot;}&#41;
  ;; ...
  publish &#40;tasks/publish opts&#41;}}
</code></pre><p>We can now test this:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; bb publish
Publishing public/ to s3://orgtech.se/
</code></pre><p>Jumping back to <code>tasks.clj</code>, we fire up a trusty <a href='https://docs.cider.mx/cider/index.html'>CIDER</a> REPL with a <strong>C-c M-j</strong> (<code>cider-jack-in-clj</code>) flourish, followed by <strong>C-c C-k</strong> (<code>cider-load-buffer</code>) to evaluate the buffer (readers following along with an <a href='https://www.vim.org/'>inferior</a> <a href='https://www.jetbrains.com/idea/'>text</a> <a href='https://code.visualstudio.com/'>editor</a> will have to perform whatever complex ritual necessary to start a REPL and connect to it and then evaluate the "file" or whatever your text editor calls the thing you're editing).</p><p>Thus equipped, we can open up a <a href='https://betweentwoparens.com/blog/rich-comment-blocks/#rich-comment'>Rich
comment</a>, define some <code>opts</code>, and evaluate our <code>publish</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def opts {:website-bucket &quot;orgtech.se&quot;
             :out-dir &quot;public&quot;}&#41;  ; C-c C-v f c e
  ;; =&gt; #'tasks/opts

  &#40;publish opts&#41;  ; C-c C-e

  &#41;
</code></pre><p>Our REPL buffer now looks something like this:</p><pre class="language-text"><code class="lang-text language-text">Started nREPL server at 127.0.0.1:44571
For more info visit: https://book.babashka.org/#&#95;nrepl
;; Connected to nREPL server - nrepl://127.0.0.1:44571
;; CIDER 1.12.0 &#40;Split&#41;, babashka.nrepl 0.0.6-SNAPSHOT
;; Babashka 1.3.188
;;     Docs: &#40;doc function-name&#41;
;;           &#40;find-doc part-of-name&#41;
;;   Source: &#40;source function-name&#41;
;;  Javadoc: &#40;javadoc java-object-or-class&#41;
;;     Exit: &lt;C-c C-q&gt;
;;  Results: Stored in vars &#42;1, &#42;2, &#42;3, an exception in &#42;e;
;;  Startup: /home/jmglov/.nix-profile/bin/bb nrepl-server localhost:0
Publishing public/ to s3://orgtech.se/
user&gt; 
</code></pre><p>OK, now it's time to figure out how to do the actual copying of files to S3. We could of course use the spectacular <a href='https://github.com/grzm/awyeah-api'>awyeah-api</a> to do stuff to AWS right from our Clojure code, but that smacks of effort. 🤔</p><p>Fortunately, we remember that Babashka was originally conceived as a replacement for Bash shell scripting (I mean, the "bash" is right there in the name, so that's kind of a major clue), and we know that there's an <a href='https://aws.amazon.com/cli/'>AWS command line
tool</a> that knows how to sync stuff from a local directory to S3:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; aws s3 sync help
SYNC&#40;&#41;                                                                  SYNC&#40;&#41;

NAME
       sync -

DESCRIPTION
       Syncs  directories  and S3 prefixes. Recursively copies new and updated
       files from the source directory to the destination. Only creates  fold-
       ers in the destination if they contain one or more files.

SYNOPSIS
            sync
          &lt;LocalPath&gt; &lt;S3Uri&gt; or &lt;S3Uri&gt; &lt;LocalPath&gt; or &lt;S3Uri&gt; &lt;S3Uri&gt;

&#91;...&#93;

EXAMPLES

       The following sync command syncs objects from a local diretory  to  the
       specified  prefix and bucket by uploading the local files to s3.  A lo-
       cal file will require uploading if the size of the local file  is  dif-
       ferent  than  the  size of the s3 object, the last modified time of the
       local file is newer than the last modified time of the  s3  object,  or
       the  local  file  does not exist under the specified bucket and prefix.
       In this example, the user syncs the bucket mybucket to the  local  cur-
       rent  directory.   The  local  current  directory  contains  the  files
       test.txt and test2.txt.  The bucket mybucket contains no objects:

          aws s3 sync . s3://mybucket

       Output:

          upload: test.txt to s3://mybucket/test.txt
          upload: test2.txt to s3://mybucket/test2.txt

&#91;...&#93;
</code></pre><p>This looks like just the thing we need, so let's use the power of <a href='https://github.com/babashka/process'>babashka.process</a> to invoke <code>aws s3 sync</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
  &#40;:require &#91;babashka.process :as p&#93;&#41;&#41;

&#40;defn publish &#91;{:keys &#91;website-bucket out-dir&#93; :as opts}&#93;
  &#40;let &#91;sync-cmd &#91;&quot;aws s3 sync&quot;
                  &#40;format &quot;%s/&quot; out-dir&#41;
                  &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#93;
    &#40;apply println sync-cmd&#41;
    &#40;apply p/shell sync-cmd&#41;&#41;&#41;

&#40;comment

  &#40;def opts {:website-bucket &quot;orgtech.se&quot;
             :out-dir &quot;public&quot;}&#41;  ; C-c C-v f c e
  ;; =&gt; #'tasks/opts

  &#40;publish opts&#41;  ; C-c C-e

  &#41;
</code></pre><p>After a brief delay, our REPL buffer now helpfully tells us:</p><pre class="language-text"><code class="lang-text language-text">aws s3 sync public/ s3://orgtech.se/
user&gt; 
</code></pre><p>And if we have a look in that there bucket, we see some files:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; aws s3 ls --recursive s3://orgtech.se/
2024-08-23 10:40:05     102613 android-chrome-192x192.png
2024-08-23 10:40:05     337153 android-chrome-512x512.png
2024-08-23 10:40:05      96934 apple-touch-icon.png
2024-08-23 10:40:05        246 browserconfig.xml
2024-08-23 10:40:05        720 css/main.css
2024-08-23 10:40:05      47189 favicon-16x16.png
2024-08-23 10:40:05      48597 favicon-32x32.png
2024-08-23 10:40:05      12014 favicon.ico
2024-08-23 10:40:05        745 img/bluesky-logo.svg
2024-08-23 10:40:05    3231594 img/orgtech-se-cover.jpg
2024-08-23 10:40:05     513884 img/orgtech-se-preview.jpg
2024-08-23 10:40:05       1943 img/twitter-color-svgrepo-com.svg
2024-08-23 10:40:05       1933 img/volume.png
2024-08-23 10:40:05       3074 index.html
2024-08-23 10:40:05      33084 mstile-150x150.png
2024-08-23 10:40:05        426 site.webmanifest
</code></pre><p>And now browsing to <a href='https://orgtech.se/'>https://orgtech.se/</a> reveals a lovely little website that looks just like the one on http://localhost:1341. 🎉</p><p>Let's change the header in <code>public/index.html</code> to test out the syncing:</p><pre class="language-html"><code class="lang-html language-html">        &lt;h1 id=&quot;title&quot; class=&quot;header&quot;&gt;Coming Thursday, 12 September!&lt;/h1&gt;
</code></pre><p>Before we YOLO eval our <code>publish</code> function again, we notice that <code>aws s3 sync</code> has a lovely little <code>--dryrun</code> option, which doesn't actually do the stuff but rather prints out what stuff it would do. Let's implement this!</p><p><strong>tasks.clj</strong></p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn publish &#91;{:keys &#91;website-bucket out-dir dryrun&#93;
                :as opts}&#93;
  &#40;let &#91;sync-cmd &#40;concat &#91;&quot;aws s3 sync&quot;&#93;
                         &#40;when dryrun &#91;&quot;--dryrun&quot;&#93;&#41;
                         &#91;&#40;format &quot;%s/&quot; out-dir&#41;
                          &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#41;&#93;
    &#40;apply println sync-cmd&#41;
    &#40;apply p/shell sync-cmd&#41;&#41;&#41;

&#40;comment

  &#40;publish &#40;assoc opts :dryrun true&#41;&#41;  ; C-c C-e

  &#41;
</code></pre><p>The REPL window helpfully says:</p><pre class="language-text"><code class="lang-text language-text">aws s3 sync --dryrun public/ s3://orgtech.se/
user&gt; 
</code></pre><p>but we don't see the output of the <code>aws s3 sync</code> command itself. This is due to the REPL not capturing stdout for the subprocess, I guess. We can handle this thusly:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
  &#40;:require &#91;babashka.process :as p&#93;&#41;&#41;

&#40;defn shell &#91;&amp; args&#93;
  &#40;let &#91;p &#40;apply p/shell {:out :string
                        :err :string
                        :continue true}
                 args&#41;&#93;
    &#40;println &#40;:out p&#41;&#41;
    &#40;when-not &#40;zero? &#40;:exit p&#41;&#41;
      &#40;println &#40;:err p&#41;&#41;&#41;
    p&#41;&#41;

&#40;defn publish &#91;{:keys &#91;website-bucket out-dir dryrun&#93;
                :as opts}&#93;
  &#40;let &#91;sync-cmd &#40;concat &#91;&quot;aws s3 sync&quot;&#93;
                         &#40;when dryrun &#91;&quot;--dryrun&quot;&#93;&#41;
                         &#91;&#40;format &quot;%s/&quot; out-dir&#41;
                          &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#41;&#93;
    &#40;apply println sync-cmd&#41;
    &#40;apply shell sync-cmd&#41;&#41;&#41;

&#40;comment

  &#40;publish &#40;assoc opts :dryrun true&#41;&#41;  ; C-c C-e

  &#41;
</code></pre><p>And now the REPL sez:</p><pre class="language-text"><code class="lang-text language-text">aws s3 sync --dryrun public/ s3://orgtech.se/
&#40;dryrun&#41; upload: public/index.html to s3://orgtech.se/index.html

user&gt; 
</code></pre><p>This is what we expect to see: only <code>index.html</code> will be uploaded, since it's the only thing that has changed.</p><p>It would be nice to run this from the command line, but we currently have no way of passing the <code>dryrun</code> option through short of adding it to the <code>opts</code> map in <code>bb.edn</code>. Fortunately for us, there's <a href='https://github.com/babashka/cli'>babashka-cli</a>, which does all sorts of awesome command-line parsing! Let's put it to work:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
  &#40;:require &#91;babashka.cli :as cli&#93;
            &#91;babashka.process :as p&#93;&#41;&#41;

;; ...

&#40;comment

  &#40;cli/parse-opts &#91;&quot;--website-bucket&quot; &quot;orgtech.se&quot;
                   &quot;--out-dir&quot; &quot;public&quot;
                   &quot;--dryrun&quot;&#93;&#41;
  ;; =&gt; {:website-bucket &quot;orgtech.se&quot;, :out-dir &quot;public&quot;, :dryrun true}

  &#41;
</code></pre><p>Now we can use <code>parse-opts</code> in our <code>publish</code> function like so:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn publish &#91;default-opts&#93;
  &#40;let &#91;{:keys &#91;website-bucket out-dir dryrun&#93;
         :as opts} &#40;merge default-opts
                          &#40;cli/parse-opts &#42;command-line-args&#42;&#41;&#41;
        sync-cmd &#40;concat &#91;&quot;aws s3 sync&quot;&#93;
                         &#40;when dryrun &#91;&quot;--dryrun&quot;&#93;&#41;
                         &#91;&#40;format &quot;%s/&quot; out-dir&#41;
                          &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#41;&#93;
    &#40;apply println sync-cmd&#41;
    &#40;apply shell sync-cmd&#41;&#41;&#41;
</code></pre><p>Running this from the command line, we get the desired result:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; bb publish --dryrun
aws s3 sync --dryrun public/ s3://orgtech.se/
&#40;dryrun&#41; upload: public/index.html to s3://orgtech.se/index.html
</code></pre><p>And if we omit the <code>--dryrun</code> arg:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech-se; bb publish --dryrun
aws s3 sync --dryrun public/ s3://orgtech.se/
upload: public/index.html to s3://orgtech.se/index.html
</code></pre><p>Amazing!</p><h2 id="do_you_invalidate_parking%3F">Do you invalidate parking?</h2><p>If we open <a href='https://orgtech.se/index.html'>https://orgtech.se/index.html</a> in a browser and view source, however, we get a nasty surprise: the lovely newline we added at the end of the file isn't there! What in the world is going on here?</p><p>Well, it turns out that one of the primary functions of a CDN (Content Distribution Network) like CloudFront is to cache responses so every request that hits an endpoint doesn't have to go all the way back to the origin (in this case, our S3 bucket) to serve the response. So we've fallen prey to #2 in the list of the 4 hardest problems in Computer Science:</p><ol><li>Naming things</li><li>Caching things</li><li>Off by one errors</li></ol><p>What to do, what to do?</p><p>Luckily for us, CloudFront gives us a way to invalidate the cache so the first request for a given endpoint re-fetches from the origin. Even more luckily for us, the AWS CLI surfaces this:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech; aws cloudfront create-invalidation help
CREATE-INVALIDATION&#40;&#41;                                    CREATE-INVALIDATION&#40;&#41;

NAME
       create-invalidation -

DESCRIPTION
       Create a new invalidation.

       See also: AWS API Documentation

SYNOPSIS
            create-invalidation
          --distribution-id &lt;value&gt;
          &#91;--paths &lt;value&gt;&#93;
&#91;...&#93;

OPTIONS
       --distribution-id &#40;string&#41;  The distribution's id.
       --paths  &#40;string&#41;           The space-separated  paths to be invalidated.
&#91;...&#93;
</code></pre><p>So what we can do is create an invalidation right after syncing to the S3 bucket in our <code>publish</code> function. In order to do this, we'll need a distribution ID. Let's ask CloudFront about the distributions we have:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech; aws cloudfront list-distributions \
  | bb -i '&#40;let &#91;ds &#40;-&gt; &#40;str/join &quot;\n&quot; &#42;input&#42;&#41;
                    &#40;json/parse-string true&#41;
                    &#40;get-in &#91;:DistributionList :Items&#93;&#41;&#41;&#93;
             &#40;map &#40;juxt #&#40;get-in % &#91;:Aliases :Items 0&#93;&#41; :Id&#41; ds&#41;&#41;'
&#40;&#91;&quot;www.jmglov.net&quot; &quot;F2ABC12UVWXYZ9&quot;&#93;
 &#91;&quot;politechspod.com&quot; &quot;F7E33IJKLMN0P6&quot;&#93;
 &#91;&quot;www.orgtech.se&quot; &quot;FDCBA42RSTUV3&quot;&#93;&#41;
</code></pre><p>This looks like the one we're after:</p><pre class="language-text"><code class="lang-text language-text">&#91;&quot;www.orgtech.se&quot; &quot;FDCBA42RSTUV3&quot;&#93;
</code></pre><p>Let's go ahead and add the distribution ID to our <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
 {:requires &#40;&#91;tasks&#93;&#41;
  :init &#40;def opts
          {:website-bucket &quot;orgtech.se&quot;
           :out-dir &quot;public&quot;
           :distribution-id &quot;FDCBA42RSTUV3&quot;}&#41;
  ;; ...
  }}
</code></pre><p>Now we can use this in <code>tasks.clj</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn publish &#91;default-opts&#93;
  &#40;let &#91;{:keys &#91;website-bucket out-dir distribution-id dryrun&#93;
         :as opts} &#40;merge default-opts
                          &#40;cli/parse-opts &#42;command-line-args&#42;&#41;&#41;
        sync-cmd &#40;concat &#91;&quot;aws s3 sync&quot;&#93;
                         &#40;when dryrun &#91;&quot;--dryrun&quot;&#93;&#41;
                         &#91;&#40;format &quot;%s/&quot; out-dir&#41;
                          &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#41;
        invalidate-cmd &#91;&quot;aws cloudfront create-invalidation&quot;
                        &quot;--distribution-id&quot; distribution-id
                        &quot;--paths&quot; :???&#93;&#93;
        ;; ...
      &#41;&#41;
</code></pre><p>OK, now where can we get our paths? Well, recall that <code>aws s3 sync --dryrun</code> helpfully outputs what is to be done:</p><pre class="language-text"><code class="lang-text language-text">aws s3 sync --dryrun public/ s3://orgtech.se/
&#40;dryrun&#41; upload: public/index.html to s3://orgtech.se/index.html
</code></pre><p>Let's consume this from Babashka to grab the paths! First, we'll dirty the dishes:</p><pre class="language-text"><code class="lang-text language-text">: orgtech-se; touch public/index.html public/css/main.css 

: orgtech-se; aws s3 sync --dryrun public/ s3://orgtech.se/
&#40;dryrun&#41; upload: public/css/main.css to s3://orgtech.se/css/main.css
&#40;dryrun&#41; upload: public/index.html to s3://orgtech.se/index.html
</code></pre><p>And then parse that output in our <code>tasks.clj</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
  &#40;:require ; ...
            &#91;clojure.string :as str&#93;&#41;&#41;

&#40;comment

  &#40;def default-opts {:website-bucket &quot;orgtech.se&quot;
                     :out-dir &quot;public&quot;
                     :distribution-id &quot;FDCBA42RSTUV3&quot;}&#41;  ; C-c C-v f c e
  ;; =&gt; #'tasks/default-opts

  &#40;-&gt;&gt; &#40;shell &quot;aws s3 sync --dryrun public/ s3://orgtech.se/&quot;&#41;
       :out
       str/split-lines
       &#40;map #&#40;str/replace % #&quot;&#94;&#91;&#40;&#93;dryrun&#91;&#41;&#93; upload: public&#40;/\S+&#41; to .+$&quot; &quot;$1&quot;&#41;&#41;&#41;
  ;; =&gt; &#40;&quot;/css/main.css&quot; &quot;/index.html&quot;&#41;

  &#41;
</code></pre><p>Now that we know how to determine which files have changed, let's plug this into our <code>publish</code> function to add to the <code>aws cloudfront create-invalidation</code> command:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn publish &#91;default-opts&#93;
  &#40;let &#91;{:keys &#91;website-bucket out-dir distribution-id dryrun&#93;
         :as opts} &#40;merge default-opts
                          &#40;cli/parse-opts &#42;command-line-args&#42;&#41;&#41;
        sync-cmd &#40;concat &#91;&quot;aws s3 sync&quot;&#93;
                         &#40;when dryrun &#91;&quot;--dryrun&quot;&#93;&#41;
                         &#91;&#40;format &quot;%s/&quot; out-dir&#41;
                          &#40;format &quot;s3://%s/&quot; website-bucket&#41;&#93;&#41;
        ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
        paths-re &#40;re-pattern &#40;format &quot;&#94;&#91;&#40;&#93;dryrun&#91;&#41;&#93; upload: %s&#40;/\\S+&#41; to .+$&quot;
                                     out-dir&#41;&#41;
        invalidate-cmd &#40;concat &#91;&quot;aws cloudfront create-invalidation&quot;
                                &quot;--distribution-id&quot; distribution-id
                                &quot;--paths&quot;&#93;
                               &#40;-&gt;&gt; &#40;apply shell &#40;concat sync-cmd &#91;&quot;--dryrun&quot;&#93;&#41;&#41;
                                    :out
                                    str/split-lines
                                    &#40;map #&#40;str/replace % paths-re &quot;$1&quot;&#41;&#41;&#41;&#41;
        ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆
        &#93;
    &#40;apply println sync-cmd&#41;
    &#40;apply shell sync-cmd&#41;
    ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇
    &#40;apply println invalidate-cmd&#41;
    &#40;when-not dryrun
      &#40;apply shell invalidate-cmd&#41;&#41;
    ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆
    &#41;&#41;

&#40;comment

  &#40;publish &#40;assoc default-opts :dryrun true&#41;&#41; ; C-c C-e

  &#41;
</code></pre><p>Our REPL buffer duly notes:</p><pre class="language-text"><code class="lang-text language-text">aws s3 sync --dryrun public/ s3://orgtech.se/
&#40;dryrun&#41; upload: public/css/main.css to s3://orgtech.se/css/main.css
&#40;dryrun&#41; upload: public/index.html to s3://orgtech.se/index.html

aws cloudfront create-invalidation --distribution-id FDCBA42RSTUV3
                                   --paths /css/main.css /index.html
</code></pre><p>Looks good, so let's try it for realz:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech; bb publish
aws s3 sync public/ s3://orgtech.se/
aws cloudfront create-invalidation --distribution-id FDCBA42RSTUV3
                                   --paths /css/main.css /index.html
{
    &quot;Location&quot;: &quot;https://cloudfront.amazonaws.com/2020-05-31/distribution/FDCBA42RSTUV3/invalidation/ICECSBHVIW089I89RLYODUBMXI&quot;,
    &quot;Invalidation&quot;: {
        &quot;Id&quot;: &quot;ICECSBHVIW089I89RLYODUBMXI&quot;,
        &quot;Status&quot;: &quot;InProgress&quot;,
        &quot;CreateTime&quot;: &quot;2024-08-25T07:14:55.130Z&quot;,
        &quot;InvalidationBatch&quot;: {
            &quot;Paths&quot;: {
                &quot;Quantity&quot;: 2,
                &quot;Items&quot;: &#91;
                    &quot;/css/main.css&quot;,
                    &quot;/index.html&quot;
                &#93;
            },
            &quot;CallerReference&quot;: &quot;cli-1724570094-253923&quot;
        }
    }
}
</code></pre><p>This is promising. Let's refill our coffee and then check to see if the invalidation has finishing invalidating:</p><pre class="language-text"><code class="lang-text language-text">: &#126;/code/orgtech; aws cloudfront get-invalidation \
  --distribution-id FDCBA42RSTUV3 \
  --id ICECSBHVIW089I89RLYODUBMXI
{
    &quot;Invalidation&quot;: {
        &quot;Id&quot;: &quot;ICECSBHVIW089I89RLYODUBMXI&quot;,
        &quot;Status&quot;: &quot;Completed&quot;,
        &quot;CreateTime&quot;: &quot;2024-08-25T07:14:55.130Z&quot;,
        &quot;InvalidationBatch&quot;: {
            &quot;Paths&quot;: {
                &quot;Quantity&quot;: 2,
                &quot;Items&quot;: &#91;
                    &quot;/css/main.css&quot;,
                    &quot;/index.html&quot;
                &#93;
            },
            &quot;CallerReference&quot;: &quot;cli-1724570094-253923&quot;
        }
    }
}
</code></pre><p>If we now Shift-reload the page in our browser, we'll see the wonderful new header! 🎉</p><h2 id="transcription_made_easy">Transcription made easy</h2><p>Now that we have an amazing website and a way to publish it, let's record an episode and then make a nice trailer to get people pumped up! We'll use <a href='https://zencastr.com/'>Zencastr</a> to do this, which produces a lovely MP3 for us as well as a transcript. For now, we have the following files on disk:</p><pre class="language-text"><code class="lang-text language-text">: organising-tech-in-sweden; tree
.
├── bb.edn
├── ep00-trailer
│   ├── otis-ep00-trailer.mp3
│   └── otis-ep0-trailer&#95;transcription.txt
├── ep01-klarna-part1
│   ├── otis-ep01-klarna-part1.mp3
│   └── otis-ep01-klarna-part1&#95;transcription.txt
├── public
│   ├── ...
│   └── index.html
└── tasks.clj
</code></pre><p>Zencastr's transcripts are, um, functional, shall we say, but any machine transcription tool will require a human who speaks the actual language being transcribed (in this case, English) to clean things up. Luckily, there's an amazing free (as in source <strong>and</strong> as in beer!) browser-based tool called <a href='https://otranscribe.com/'>oTranscribe</a> that lets us listen to our lovely audio whilst editing the transcript, with keyboard shortcuts for pausing and resuming playback, rewinding and fast forwarding, adjusting playback speed, etc.</p><p>To unlock all this goodness, we'll need to convert our boring Zencastr transcripts, which look like this:</p><pre class="language-text"><code class="lang-text language-text">00:02.00
jmglov
Already and we are live now. So welcome everyone to organizing tech in Sweden
I am here my name is Josh I'm here with a ah. Cast of characters that will
delight in a maze and I will introduce them here in a minute but before we get
to the cool people. Let me introduce. My co-host Ray joining us all the way from
Belgium Ray you want to say hey.

00:30.61
Ray
Yeah on uncool Belgium hello everyone? Well it's a bit warmer than Sweden now.
But okay I yeah you were talking about being oh yeah, okay fine.

00:42.78
jmglov
Yeah, all right? So we are here like I said to talk about organizing tech in
Sweden and um, basically what we want to do is introduce. Folks who might not
know much about Sweden other than Ekea is from here and chocolate. Oh no wait.
That's Switzerland for some reason and the us. Oh you know Belgium sure sure.
Sure. Um.

&#91;...&#93;
</code></pre><p>into amazing OTR (oTranscribe's file format) ones, which look like some HTML stuffed into some JSON.</p><p>To do this converting, we could use <a href='https://github.com/jmglov/transcribble'>Transcribble</a>, which I wrote a while back and forgot to blog about. Or we could just open up <a href='https://otranscribe.com/'>https://otranscribe.com/</a> in our browser and click the big blue "Start transcribing" button, then click the "Choose audio (or video) file" button and choose our <code>&#126;/code/orgtech-se/ep01-klarna-part1/otis-ep01-klarna-part1.mp3</code> file, and then paste in our Zencastr transcript, warts and all. If we click the Play button (or hit Esc, which is oTranscribe's play/pause keyboard shortcut), our episode will start playing, and we can hit Ctrl+J to add a timestamp to the transcript when we hear me say "So, welcome everyone to Organising Tech in Sweden". After much listening and editing, which we will just handwave away here, we now have a pristine transcript!</p><p><img src="assets/2024-09-18-podcast-soundcljoud-otr.png" alt="The oTranscribe transcription UI" title="Cleaning this up is an exercise for the reader" width=800px border=1 /></p><p>We'll now click the "Export" button to pop up the "Download transcript as..." dialog, select "oTranscribe format (.otr)", and save as a new <code>&#126;/code/orgtech-se/ep01-klarna-part1/otis-ep01-klarna-part1.otr</code> file.</p><h2 id="feed_me_some_episodes">Feed me some episodes</h2><p>Now that we have some files, we need to stuff those in a feed. Luckily, <a href='2024-07-09-soundcljoud.html#faking_a_podcast_with_selmer'>we have
some experience with podcast
feeds</a>. Using <a href='https://github.com/yogthos/Selmer'>Selmer</a> to write the feed worked out pretty nicely then, so let's elect to do the same thing again. In fact, since we already did the hard work of creating code that knows how to write an RSS file for a music album, why don't we see if we can modify it a bit to support podcasts as well?</p><p>Let's pop over to <code>&#126;/code/soundcljoud/processor/main.clj</code> and remind ourselves how we turned an album into an RSS feed:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn process-album &#91;opts dir&#93;
  &#40;let &#91;info &#40;album-info opts dir&#41;
        tmpdir &#40;fs/create-temp-dir {:prefix &quot;soundcljoud.&quot;}&#41;
        info &#40;update info :tracks &#40;partial map #&#40;process-track % tmpdir&#41;&#41;&#41;&#93;
    &#40;spit &#40;fs/file tmpdir &quot;album.rss&quot;&#41; &#40;rss/album-feed opts info&#41;&#41;
    &#40;assoc info :out-dir tmpdir&#41;&#41;&#41;
</code></pre><p>In this case, we fetched Discogs metadata for the album in the <code>album-info</code> function, created a temporary directory, did some transcoding in <code>process-track</code>, then used <code>rss/album-feed</code> to apply a Selmer template to our album metadata. Opening up the <code>soundcljoud.rss</code> namespace, we see that the <code>album-feed</code> function is extremely specific to music albums:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn album-feed &#91;opts album-info&#93;
  &#40;let &#91;template &#40;-&gt; &#40;io/resource &quot;album-feed.rss&quot;&#41; slurp&#41;&#93;
    &#40;selmer/render template
                   &#40;-&gt; album-info
                       &#40;update :tracks
                               &#40;partial map #&#40;update % :mp3-filename
                                                     fs/file-name&#41;&#41;&#41;
                       &#40;assoc :date &#40;now&#41;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Whilst there's no obvious way to repurpose it, we can follow the same basic pattern:</p><ol><li>Load the template</li><li>Massage the "info" about the <del>album</del> podcast as needed</li><li>Render the template with our "info"</li></ol><p>Let's sketch out a <code>podcast-feed</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn podcast-feed &#91;opts podcast-info&#93;  ;; ❓ podcast-info how?
  &#40;let &#91;template :???&#93;  ;; ❓ where do we get this?
    &#40;-&gt;&gt; podcast-info
         ;; ❓ maybe some massaging here?
         &#40;selmer/render template&#41;&#41;&#41;&#41;
</code></pre><p>The first question is where we get the <code>podcast-info</code>. We got <code>album-info</code> from Discogs, but since Discogs presumably knows nothing about our podcast (and why would it?), let's create a static <code>&#126;/code/orgtech/podcast.edn</code> file instead, fill it with whatever data our podcast feed will need (I guess it's time to rhyme), and read in the EDN before calling this function.</p><p>Having made that decision, we must now ask ourselves where we will get our podcast feed template from. In the case of albums, we provided a template as a resource directly from Soundcljoud, so why don't we do that again?</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn podcast-feed &#91;opts podcast-info&#93;
  &#40;let &#91;template &#40;-&gt; &#40;io/resource &quot;podcast-feed.rss&quot;&#41; slurp&#41;&#93;
    &#40;-&gt;&gt; podcast-info
         ;; ❓ maybe some massaging here?
         &#40;selmer/render template&#41;&#41;&#41;&#41;
</code></pre><p>In order to know what if any massaging <code>podcast-info</code> will need, we'll need to create the template and the <code>podcast.edn</code> file and see where the gaps are. Let's consult Apple's handy <a href='https://help.apple.com/itc/podcasts_connect/#/itcb54353390'>A Podcaster’s Guide to
RSS</a> and start writing <code>resources/podcast-feed.rss</code>. First, we need the standard feed skeleton:</p><pre class="language-xml"><code class="lang-xml language-xml">&lt;?xml version='1.0' encoding='UTF-8'?&gt;
&lt;rss version=&quot;2.0&quot;
     xmlns:itunes=&quot;http://www.itunes.com/dtds/podcast-1.0.dtd&quot;
     xmlns:atom=&quot;http://www.w3.org/2005/Atom&quot;&gt;
  &lt;channel&gt;
    &lt;!-- TBD: some stuff here --&gt;
  &lt;/channel&gt;
&lt;/rss&gt;
</code></pre><p>Now to start populating the contents <code>&lt;channel&gt;</code> tag. According to Apple, we need the following:</p><table><thead><tr><th>Show tags</th><th>Usage</th><th>Parent tag</th></tr></thead><tbody><tr><td><code>&lt;title&gt;</code></td><td>The show title.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;description&gt;</code></td><td>The show description.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;itunes:image&gt;</code></td><td>The artwork for the show.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;language&gt;</code></td><td>The language spoken on the show.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;itunes:explicit&gt;</code></td><td>The podcast parental advisory information.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;itunes:category&gt;</code></td><td>The show category information.</td><td><code>&lt;channel&gt;</code></td></tr></tbody></table><p>This is straightforward enough (except for <code>&lt;itunes:category&gt;</code>, which we'll come back to):</p><pre class="language-xml"><code class="lang-xml language-xml">  &lt;channel&gt;
    &lt;title&gt;{{podcast.title}}&lt;/title&gt;
    &lt;description&gt;{{podcast.description|safe}}&lt;/description&gt;
    &lt;itunes:image href=&quot;{{base-url}}{{podcast.image}}&quot;/&gt;
    &lt;language&gt;{{podcast.language}}&lt;/language&gt;
    &lt;itunes:explicit&gt;{{podcast.explicit}}&lt;/itunes:explicit&gt;
  &lt;/channel&gt;
</code></pre><p>By the way, that <code>{{podcast.description|safe}}</code> thingy is a Selmer filter that <a href='https://github.com/yogthos/Selmer?tab=readme-ov-file#safe'>exempts the variable from being
HTML-escaped</a>. Since our description text goes in the body of the <code>&lt;description&gt;</code> tag, we don't want things like "rock & roll" getting rendered as "rock &amp; roll", because that would be yucky.</p><p>Now we need to add that data to our <code>podcast.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:base-url &quot;https://orgtech.se&quot;
 :podcast {:title &quot;Organising Tech in Sweden&quot;
           :description &quot;Organising Tech in Sweden is a limited podcast series exploring union organising in Swedish tech companies. Join us as we sit down with some of the people involved in the campaigns to win collective bargaining rights at two of Sweden's tech unicorns, Klarna and Spotify.&quot;
           :image &quot;/img/orgtech-se-cover.jpg&quot;
           :language &quot;en&quot;
           :explicit true}}
</code></pre><p>As we were writing <code>podcast.edn</code>, we realised that <code>podcast-info</code> was actually the data in the EDN file under the <code>:podcast</code> key, so we are really just providing an <code>opts</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn podcast-feed &#91;opts&#93;
  &#40;let &#91;template &#40;-&gt; &#40;io/resource &quot;podcast-feed.rss&quot;&#41; slurp&#41;&#93;
    &#40;-&gt;&gt; opts
         ;; ❓ maybe some massaging here?
         &#40;selmer/render template&#41;&#41;&#41;&#41;
</code></pre><p>Let's give this a go in our REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;require '&#91;clojure.edn :as edn&#93;&#41;
  ;; =&gt; nil

  &#40;def opts &#40;-&gt; &#40;slurp &quot;/home/jmglov/code/orgtech-se/podcast.edn&quot;&#41;
                &#40;edn/read-string&#41;&#41;&#41;
  ;; =&gt; #'soundcljoud.rss/opts

  opts
  ;; =&gt; {:base-url &quot;https://orgtech.se&quot;,
  ;;     :podcast
  ;;     {:title &quot;Organising Tech in Sweden&quot;,
  ;;      :description
  ;;      &quot;Organising Tech in Sweden is a...&quot;,
  ;;      :image &quot;/img/orgtech-se-cover.jpg&quot;,
  ;;      :language &quot;en&quot;,
  ;;      :explicit true}}

  &#40;podcast-feed opts&#41;
  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;
  ;;     &lt;rss version=\&quot;2.0\&quot;
  ;;          xmlns:itunes=\&quot;http://www.itunes.com/dtds/podcast-1.0.dtd\&quot;
  ;;          xmlns:atom=\&quot;http://www.w3.org/2005/Atom\&quot;&gt;
  ;;       &lt;channel&gt;
  ;;         &lt;title&gt;Organising Tech in Sweden&lt;/title&gt;
  ;;         &lt;description&gt;Organising Tech in Sweden is a...&lt;/description&gt;
  ;;         &lt;itunes:image href=\&quot;https://orgtech.se/img/orgtech-se-cover.jpg\&quot;/&gt;
  ;;         &lt;language&gt;en&lt;/language&gt;
  ;;         &lt;itunes:explicit&gt;true&lt;/itunes:explicit&gt;
  ;;       &lt;/channel&gt;
  ;;     &lt;/rss&gt;&quot;

  &#41;
</code></pre><p>Let's now come back to that tricky <code>&lt;itunes:category&gt;</code> tag. Referring back to the Apple docs, we see:</p><blockquote><p> For a complete list of categories and subcategories, see Apple Podcast  categories. </p><p> Select the category that best reflects the content of your show. If available,  you can also define a subcategory. </p><p> Single category: </p><p>   <code>&lt;itunes:category text=&quot;History&quot; /&gt;</code> </p><p> Category with subcategory: </p><p>   <code>&lt;itunes:category text=&quot;Society &amp;amp; Culture&quot;&gt;</code>    <code>  &lt;itunes:category text=&quot;Documentary&quot; /&gt;</code>    <code>&lt;/itunes:category&gt;</code> </p></blockquote><p>We can add categories to our <code>podcast-feed.rss</code> template using Selmer's <a href='https://github.com/yogthos/Selmer?tab=readme-ov-file#for'>for</a> tag:</p><pre class="language-xml"><code class="lang-xml language-xml">  &lt;channel&gt;
    &lt;!-- ... --&gt;
{% for category in podcast.categories %}
    &lt;itunes:category text=&quot;{{category.text}}&quot;&gt;
{% for subcategory in category.subcategories %}
      &lt;itunes:category text=&quot;{{subcategory.text}}&quot; /&gt;
{% endfor %}
    &lt;/itunes:category&gt;
{% endfor %}
  &lt;/channel&gt;
</code></pre><p>And now we need to pick a category or two and add them to our <code>podcast.edn</code>. The <a href='https://podcasters.apple.com/support/1691-apple-podcasts-categories'>Apple Podcasts
categories</a> page lists the options, of which we choose:</p><ul><li>Technology (which has no subcategories)</li><li>News, with subcategory Politics</li></ul><p>Expressing this in EDN, we get:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:base-url &quot;https://orgtech.se&quot;
 :podcast { ; ...
           :categories &#91;{:text &quot;Technology&quot;}
                        {:text &quot;News&quot;
                         :subcategories &#91;{:text &quot;Politics&quot;}&#93;}&#93;}}
</code></pre><p>And our REPL shows us what we'd expect to see:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def opts &#40;-&gt; &#40;slurp &quot;/home/jmglov/code/orgtech-se/podcast.edn&quot;&#41;
                &#40;edn/read-string&#41;&#41;&#41;
  ;; =&gt; #'soundcljoud.rss/opts

  &#40;podcast-feed opts&#41;
  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;
  ;;     &lt;rss version=\&quot;2.0\&quot;
  ;;          xmlns:itunes=\&quot;http://www.itunes.com/dtds/podcast-1.0.dtd\&quot;
  ;;          xmlns:atom=\&quot;http://www.w3.org/2005/Atom\&quot;&gt;
  ;;       &lt;channel&gt;
  ;;         ...
  ;;         &lt;itunes:category text=\&quot;Technology\&quot;&gt;
  ;;         &lt;/itunes:category&gt;
  ;;         &lt;itunes:category text=\&quot;News\&quot;&gt;
  ;;           &lt;itunes:category text=\&quot;Politics\&quot; /&gt;
  ;;         &lt;/itunes:category&gt;
  ;;       &lt;/channel&gt;
  ;;     &lt;/rss&gt;&quot;

  &#41;
</code></pre><p>Having sorted our required tags, let's take a look at Apple's recommended and "situational" tags (which we'll just treat as "recommended"):</p><table><thead><tr><th>Show tags</th><th>Usage</th><th>Parent tag</th></tr></thead><tbody><tr><td><code>&lt;itunes:author&gt;</code></td><td>The group responsible for creating the show.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;link&gt;</code></td><td>The website associated with a podcast.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;itunes:title&gt;</code></td><td>The show title specific for Apple Podcasts.</td><td><code>&lt;channel&gt;</code></td></tr><tr><td><code>&lt;itunes:type&gt;</code></td><td>The type of show. Its values can be one of the following:</td><td></td></tr><tr><td></td><td>• <strong>Episodic</strong>. Episodes are intended to be consumed without any specific order.</td><td></td></tr><tr><td></td><td>• <strong>Serial</strong>. Episodes are intended to be consumed in sequential order.</td><td></td></tr><tr><td><code>&lt;copyright&gt;</code></td><td>The show copyright details.</td><td><code>&lt;channel&gt;</code></td></tr></tbody></table><p>Again, this is quite straightforward to add to our template:</p><pre class="language-xml"><code class="lang-xml language-xml">  &lt;channel&gt;
    &lt;!-- ... --&gt;
    &lt;itunes:author&gt;{{podcast.author}}&lt;/itunes:author&gt;
    &lt;link&gt;{{base-url}}&lt;/link&gt;
    &lt;itunes:title&gt;{{podcast.title}}&lt;/itunes:title&gt;
    &lt;itunes:type&gt;{{podcast.type}}&lt;/itunes:type&gt;
    &lt;copyright&gt;{{podcast.copyright}}&lt;/copyright&gt;
  &lt;/channel&gt;
</code></pre><p>and to our <code>podcast.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:base-url &quot;https://orgtech.se&quot;
 :podcast { ; ...
           :author &quot;Organising Tech in Sweden&quot;
           :type &quot;Serial&quot;
           :copyright &quot;All rights reserved, Organising Tech in Sweden&quot;}}
</code></pre><p>Testing things out in our REPL, we see what we expect to see. 🙂</p><p>Now it's time to add some episodes! Here are the Apple Podcast required, recommended, and situational tags for episodes:</p><table><thead><tr><th>Show tags</th><th>Usage</th><th>Parent tag</th></tr></thead><tbody><tr><td><code>&lt;title&gt;</code></td><td>An episode title.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;enclosure&gt;</code></td><td>The episode content, file size, and file type information. The <code>&lt;enclosure&gt;</code> tag has three attributes:</td><td><code>&lt;item&gt;</code></td></tr><tr><td></td><td>• <strong>URL</strong>. The URL attribute points to your podcast media file.</td><td></td></tr><tr><td></td><td>• <strong>Length</strong>. The length attribute is the file size in bytes.</td><td></td></tr><tr><td></td><td>• <strong>Type</strong>. The type attribute provides the correct category for the type of file.</td><td></td></tr><tr><td><code>&lt;guid&gt;</code></td><td>The episode’s globally unique identifier (<a href='https://cyber.harvard.edu/rss/rss.html#ltguidgtSubelementOfLtitemgt'>GUID</a>)</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;pubDate&gt;</code></td><td>The date and time when an episode was released. Format the date using the <a href='http://www.faqs.org/rfcs/rfc2822.html'>RFC 2822</a> specifications. For example: <code>Sat, 01 Apr 2023 19:00:00 GMT</code>.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;description&gt;</code></td><td>An episode description.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;itunes:duration&gt;</code></td><td>The duration of an episode. Different duration formats are accepted however it is recommended to convert the length of the episode into seconds.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;link&gt;</code></td><td>An episode link URL.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;itunes:explicit&gt;</code></td><td>The podcast parental advisory information.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;itunes:title&gt;</code></td><td>The show title specific for Apple Podcasts.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;itunes:episode&gt;</code></td><td>An episode number.</td><td><code>&lt;item&gt;</code></td></tr><tr><td><code>&lt;itunes:episodeType&gt;</code></td><td>The episode type.</td><td><code>&lt;item&gt;</code></td></tr><tr><td></td><td>• <strong>Full</strong>. Specify full when you are submitting the complete content of your show.</td><td></td></tr><tr><td></td><td>• <strong>Trailer</strong>. Specify trailer when you are submitting a short, promotional piece of content that represents a preview of your current show.</td><td></td></tr><tr><td></td><td>• <strong>Bonus</strong>. Specify bonus when you are submitting extra content for your show (for example, behind the scenes information or interviews with the cast) or cross-promotional content for another show.</td><td></td></tr><tr><td><code>&lt;itunes:transcript&gt;</code></td><td>A link to the episode transcript in the Closed Caption format.</td><td><code>&lt;item&gt;</code></td></tr></tbody></table><p>Unfortunately, Transcribble doesn't yet support VTT or SRT transcripts, so we can't provide the transcript directly in iTunes. What we will do instead is display the OTR transcript that we previously prepared in oTranscribe on our episode page (which is yet to be written, but we'll get there in the end). In order to do this, let's add a custom <code>&lt;transcriptUrl&gt;</code> tag.</p><p>Let's start with our template as usual:</p><pre class="language-xml"><code class="lang-xml language-xml">  &lt;channel&gt;
    &lt;!-- ... --&gt;
{% for episode in episodes %}
    &lt;item&gt;
      &lt;title&gt;{{episode.title}}&lt;/title&gt;
      &lt;enclosure
          url=&quot;{{base-url}}{{episode.path}}/{{episode.audio-file}}&quot;
          length=&quot;{{episode.audio-filesize}}&quot;
          type=&quot;{{episode.mime-type}}&quot; /&gt;
      &lt;guid&gt;{{base-url}}{{episode.path}}/{{episode.audio-file}}&lt;/guid&gt;
      &lt;pubDate&gt;{{episode.date}}&lt;/pubDate&gt;
      &lt;description&gt;&lt;!&#91;CDATA&#91;{{episode.description|safe}}&#93;&#93;&gt;&lt;/description&gt;
      &lt;itunes:duration&gt;{{episode.duration}}&lt;/itunes:duration&gt;
      &lt;link&gt;{{base-url}}{{episode.path}}&lt;/link&gt;
      &lt;itunes:title&gt;{{episode.title}}&lt;/itunes:title&gt;
      {% if episode.number %}&lt;itunes:episode&gt;{{episode.number}}&lt;/itunes:episode&gt;{% endif %}
      &lt;itunes:episodeType&gt;{{episode.type}}&lt;/itunes:episodeType&gt;
      &lt;transcriptUrl&gt;{{base-url}}{{episode.path}}/{{episode.transcript-file}}&lt;/transcriptUrl&gt;
    &lt;/item&gt;
{% endfor %}
  &lt;/channel&gt;
</code></pre><p>And now we know what episodes need to look like in our <code>podcast.edn</code> file:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
 :episodes
 &#91;{:number 0
   :date &quot;Thu, 5 Sep 2024 00:00:00 +0000&quot;
   :type &quot;Trailer&quot;
   :title &quot;Trailer&quot;
   :summary &quot;Union organising seems to be in the air these days, as tech workers wake up and realise that they are, in fact, workers.&quot;
   :description &quot;
&lt;p&gt;
  Union organising seems to be in the air these days, as tech workers wake up and
  realise that they are, in fact, workers. Here in Sweden, it's no exception.
  Join us as we sit down with some of the people involved in organising two of
  Sweden's foremost tech unicorns, Klarna and Spotify. This is Organising Tech in
  Sweden.
&lt;/p&gt;
&lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;
  To view full show notes, including transcripts, please visit the
  &lt;a href=\&quot;{{base-url}}{{episode.path}}/\&quot;&gt;episode page&lt;/a&gt;.
&lt;/p&gt;
&lt;p&gt;
  Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;
  &lt;a href=\&quot;https://bsky.app/profile/anyakjordan.bsky.social\&quot;&gt;@anyakjordan.bsky.social&lt;/a&gt;
&lt;/p&gt;
&lt;p&gt;
  Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;
&lt;/p&gt;&quot;
   :path &quot;/episodes/ep00-trailer&quot;
   :audio-file &quot;otis-ep00-trailer.mp3&quot;
   :transcript-file &quot;otis-ep00-trailer.otr&quot;
   :explicit false
   :mime-type &quot;audio/mpeg&quot;}
  {:number 1
   :date &quot;Thu, 12 Sep 2024 00:00:00 +0000&quot;
   :type &quot;Full&quot;
   :title &quot;Organising Klarna - Part 1&quot;
   :summary &quot;A conversation with three of the organisers behind the successful campaign to win a Collective Bargaining Agreement at Klarna&quot;
   :description &quot;
&lt;p&gt;
  We kick off Organising Tech in Sweden in style by recounting the story of how
  a collective bargaining agreement &#40;CBA&#41; was won at Klarna, a major Swedish
  fintech. In fact, Klarna was the first unicorn in Sweden to be unionised &#40;and
  probably the first unicorn in Europe as well&#41;!
&lt;/p&gt;
&lt;p&gt;
  To hear all about how this went down, your co-hosts Josh and Ray are joined by
  Thomas, the founder of the Klarna Unionen Club &#40;a union \&quot;local\&quot;, to use
  terminology that might be more familiar to US listeners&#41;; Sen, the chair of
  the club who won the bargaining agreement against the odds; and Kim, a former
  Klarna employee with extensive knowledge of Swedish labour law and market
  policy.
&lt;/p&gt;
&lt;p&gt;
  This is part 1 of the conversation, which will be concluded in Episode 2.
&lt;/p&gt;
&lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;
  To view full show notes, including transcripts, please visit the
  &lt;a href=\&quot;{{base-url}}{{episode.path}}/\&quot;&gt;episode page&lt;/a&gt;.
&lt;/p&gt;
&lt;p&gt;
  Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;
  &lt;a href=\&quot;https://bsky.app/profile/anyakjordan.bsky.social\&quot;&gt;@anyakjordan.bsky.social&lt;/a&gt;
&lt;/p&gt;
&lt;p&gt;
  Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;
&lt;/p&gt;&quot;
   :path &quot;/episodes/ep01-klarna-part1&quot;
   :audio-file &quot;otis-ep01-klarna-part1.mp3&quot;
   :transcript-file &quot;otis-ep01-klarna-part1.otr&quot;
   :explicit false
   :mime-type &quot;audio/mpeg&quot;}
  {:preview? true
   :number 2
   :date &quot;Thu, 19 Sep 2024 00:00:00 +0000&quot;
   :type &quot;Full&quot;
   :title &quot;Organising Klarna - Part 2&quot;
   :summary &quot;The conclusion of our conversation with three of the organisers behind the successful campaign to win a Collective Bargaining Agreement at Klarna&quot;
   :description &quot;
&lt;p&gt;
  We finish our conversation with Sen, Thomas, and Kim about how a collective
  bargaining agreement &#40;CBA&#41; was won at Klarna. In this episode, we cover the
  impact of immigrant workers on organising, the impact of organising on
  organisers, and the impact of strikes on negotiations. All of this and a happy
  ending too!
&lt;/p&gt;
&lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;
  To view full show notes, including transcripts, please visit the
  &lt;a href=\&quot;{{base-url}}{{episode.path}}/\&quot;&gt;episode page&lt;/a&gt;.
&lt;/p&gt;
&lt;p&gt;
  Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;
  &lt;a href=\&quot;https://bsky.app/profile/anyakjordan.bsky.social\&quot;&gt;@anyakjordan.bsky.social&lt;/a&gt;
&lt;/p&gt;
&lt;p&gt;
  Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;
&lt;/p&gt;&quot;
   :path &quot;/episodes/ep02-klarna-part2&quot;
   :audio-file &quot;otis-ep02-klarna-part2.mp3&quot;
   :transcript-file &quot;otis-ep02-klarna-part2.otr&quot;
   :explicit false
   :mime-type &quot;audio/mpeg&quot;}&#93;}
</code></pre><p>Testing this in our REPL...</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def opts &#40;-&gt; &#40;slurp &quot;/home/jmglov/code/orgtech-se/podcast.edn&quot;&#41;
                &#40;edn/read-string&#41;&#41;&#41;
  ;; =&gt; #'soundcljoud.rss/opts

  &#40;podcast-feed opts&#41;
  ;; =&gt; java.lang.NullPointerException soundcljoud.rss /home/jmglov/code/soundcljoud/processor/src/soundcljoud/rss.clj:34:26

  &#41;
</code></pre><p>...we get an unpleasant surprise. 😮</p><p>This is a bit annoying to debug, but we can surmise that one of the template variables in the episode template must be missing. Doing a little visual inspection identifies the culprit:</p><pre class="language-xml"><code class="lang-xml language-xml">      &lt;enclosure
          url=&quot;{{base-url}}{{episode.path}}/{{episode.audio-file}}&quot;
          length=&quot;{{episode.audio-filesize}}&quot;
          type=&quot;{{episode.mime-type}}&quot; /&gt;
</code></pre><p>We don't have <code>audio-filesize</code> in our episode data structure. 😢</p><h2 id="deep_tissue_massage">Deep tissue massage</h2><p>All is not lost, however. Let's cast our minds back to the definition of the <code>podcast-feed</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn podcast-feed &#91;opts&#93;
  &#40;let &#91;template &#40;-&gt; &#40;io/resource &quot;podcast-feed.rss&quot;&#41; slurp&#41;&#93;
    &#40;-&gt;&gt; opts
         ;; ❓ maybe some massaging here?
         &#40;selmer/render template&#41;&#41;&#41;&#41;
</code></pre><p>The answer to the question "maybe some massaging here?" now reveals itself to be "Yes. Yes! A thousand times yes!" We also know at least one massage technique we're going to need to use, namely setting the <code>audio-filesize</code> key for each episode. Let's start out by giving ourselves a way to update episodes:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn update-episode &#91;opts episode&#93;
  episode&#41;

&#40;defn update-episodes &#91;opts&#93;
  &#40;update opts :episodes #&#40;map &#40;partial update-episode opts&#41; %&#41;&#41;&#41;

&#40;defn podcast-feed &#91;opts&#93;
  &#40;let &#91;template &#40;-&gt; &#40;io/resource &quot;podcast-feed.rss&quot;&#41; slurp&#41;&#93;
    &#40;-&gt;&gt; opts
         update-episodes
         &#40;selmer/render template&#41;&#41;&#41;&#41;
</code></pre><p>Now we can figure out how to add the filesize to each episode. As usual, Babashka's got us covered! Checking out the babashka.fs API documentation, we find a function called <a href='https://github.com/babashka/fs/blob/master/API.md#babashka.fs/size'>babashka.fs/size</a>:</p><blockquote><p> <strong>size</strong> </p><p> <code>&#40;size f&#41;</code> </p><p> Returns the size of a file (in bytes). </p></blockquote><p>Let's mess around a bit in the REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def base-dir &quot;/home/jmglov/code/orgtech-se&quot;&#41;
  ;; =&gt; #'soundcljoud.rss/base-dir

  &#40;def opts &#40;-&gt; &#40;slurp &#40;fs/file base-dir &quot;podcast.edn&quot;&#41;&#41;
                &#40;edn/read-string&#41;
                &#40;assoc :base-dir base-dir&#41;&#41;&#41;

  &#40;let &#91;episode &#40;-&gt; opts :episodes first&#41;
        filename &#40;format &quot;%s%s/%s&quot;
                         base-dir &#40;:path episode&#41; &#40;:audio-file episode&#41;&#41;&#93;
    &#40;fs/size filename&#41;&#41;
  ;; =&gt; java.nio.file.NoSuchFileException: 
  ;; /home/jmglov/code/orgtech-se/episodes/ep00-trailer/otis-ep00-trailer.mp3 
  ;; /home/jmglov/code/soundcljoud/processor/src/soundcljoud/rss.clj:4:5

  &#41;
</code></pre><p>Oops! Seems like we've traded one problem for another. 😬</p><p>On disk, the files are laid out like this:</p><pre class="language-text"><code class="lang-text language-text">: organising-tech-in-sweden; tree
.
├── bb.edn
├── ep00-trailer
│   ├── otis-ep00-trailer.mp3
│   └── otis-ep0-trailer&#95;transcription.txt
├── ep01-klarna-part1
│   ├── otis-ep01-klarna-part1.mp3
│   └── otis-ep01-klarna-part1&#95;transcription.txt
├── ep02-klarna-part2
│   ├── otis-ep02-klarna-part2.mp3
│   └── otis-ep02-klarna-part2&#95;transcription.txt
├── public
│   ├── ...
│   └── index.html
└── tasks.clj
</code></pre><p>But we are looking for the audio file in the path in which it should exist on the server, which makes sense from an RSS feed perspective, which should use paths corresponding to the published site. Our <code>publish</code> task uses <code>aws s3 sync</code> to publish everything in our <code>public/</code> directory, so if we drop the MP3s there, they will get put in the correct place on the S3 website. For now, let's cheat by using our REPL to put the files where they need to go:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def opts &#40;-&gt; &#40;slurp &#40;fs/file base-dir &quot;podcast.edn&quot;&#41;&#41;
                &#40;edn/read-string&#41;
                &#40;assoc :base-dir base-dir
                       :out-dir &quot;public&quot;&#41;&#41;&#41;
  ;; =&gt; #'soundcljoud.rss/opts

  &#40;doseq &#91;episode &#40;:episodes opts&#41;
          :let &#91;filename &#40;format &quot;%s/%s%s/%s&quot;
                                 &#40;:base-dir opts&#41; &#40;:out-dir opts&#41;
                                 &#40;:path episode&#41; &#40;:audio-file episode&#41;&#41;
                src-filename &#40;fs/file dir
                                      &#40;fs/file-name &#40;:path episode&#41;&#41;
                                      &#40;:audio-file episode&#41;&#41;&#93;&#93;
    &#40;when-not &#40;fs/exists? filename&#41;
      &#40;fs/create-dirs &#40;fs/parent filename&#41;&#41;
      &#40;fs/copy src-filename filename&#41;&#41;&#41;
  ;; =&gt; nil

  &#41;
</code></pre><p>OK, this will do for now. Let's grab this code, clean it up a bit, and shove it into our <code>update-episode</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn update-episode &#91;{:keys &#91;base-dir out-dir&#93; :as opts}
                      {:keys &#91;audio-file path&#93; :as episode}&#93;
  &#40;assoc episode :audio-filesize
         &#40;fs/size &#40;format &quot;%s/%s%s/%s&quot; base-dir out-dir path audio-file&#41;&#41;&#41;&#41;
</code></pre><p>Before testing this out in the REPL, we should add the <code>:out-dir</code> key to our <code>podcast.edn</code> so we don't rely on the caller to add it to <code>opts</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:base-url &quot;https://orgtech.se&quot;
 :podcast { ... }
 :episodes &#91; ... &#93;}
</code></pre><p>OK, now we're ready to give it a spin in the REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;podcast-feed &#40;assoc opts :out-dir &quot;public&quot;&#41;&#41;
  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;\n
  ;;     &lt;rss version=\&quot;2.0\&quot;\n
  ;;          xmlns:itunes=\&quot;http://www.itunes.com/dtds/podcast-1.0.dtd\&quot;\n
  ;;          xmlns:atom=\&quot;http://www.w3.org/2005/Atom\&quot;&gt;\n
  ;;       &lt;channel&gt;\n
  ;;         &lt;title&gt;Organising Tech in Sweden&lt;/title&gt;\n
  ;;         &lt;description&gt;Organising Tech in Sweden is a...&lt;/description&gt;\n
  ;;         &lt;itunes:image href=\&quot;https://orgtech.se/img/orgtech-se-cover.jpg\&quot;/&gt;\n
  ;;         &lt;language&gt;en&lt;/language&gt;\n
  ;;         &lt;itunes:explicit&gt;true&lt;/itunes:explicit&gt;\n\n
  ;;         &lt;itunes:category text=\&quot;Technology\&quot;&gt;\n\n
  ;;         &lt;/itunes:category&gt;\n\n
  ;;         &lt;itunes:category text=\&quot;News\&quot;&gt;\n\n
  ;;           &lt;itunes:category text=\&quot;Politics\&quot; /&gt;\n\n
  ;;         &lt;/itunes:category&gt;\n\n
  ;;         &lt;itunes:author&gt;Organising Tech in Sweden&lt;/itunes:author&gt;\n
  ;;         &lt;link&gt;https://orgtech.se&lt;/link&gt;\n
  ;;         &lt;itunes:title&gt;Organising Tech in Sweden&lt;/itunes:title&gt;\n
  ;;         &lt;itunes:type&gt;Serial&lt;/itunes:type&gt;\n
  ;;         &lt;copyright&gt;All rights reserved&lt;/copyright&gt;\n\n
  ;;         &lt;item&gt;\n
  ;;           &lt;title&gt;Trailer&lt;/title&gt;\n
  ;;           &lt;enclosure\n
  ;;               url=\&quot;https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.mp3\&quot;\n
  ;;               length=\&quot;1016937\&quot;\n
  ;;               type=\&quot;audio/mpeg\&quot; /&gt;\n
  ;;           &lt;guid&gt;https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.mp3&lt;/guid&gt;\n
  ;;           &lt;pubDate&gt;Thu, 5 Sep 2024 00:00:00 +0000&lt;/pubDate&gt;\n
  ;;           &lt;description&gt;&lt;!&#91;CDATA&#91;\n
  ;;             &lt;p&gt;\n  Union organising seems to be in the air these days...&lt;/p&gt;\n
  ;;             &lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;\n
  ;;               To view full show notes, including transcripts, please visit the\n
  ;;               &lt;a href=\&quot;{{base-url}}{{episode.path}}/\&quot;&gt;episode page&lt;/a&gt;.\n
  ;;             &lt;/p&gt;\n
  ;;             &lt;p&gt;\n
  ;;               Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;\n
  ;;             &lt;/p&gt;\n
  ;;             &lt;p&gt;\n
  ;;               Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;\n&lt;/p&gt;
  ;;           &#93;&#93;&gt;&lt;/description&gt;\n
  ;;           &lt;itunes:duration&gt;&lt;/itunes:duration&gt;\n
  ;;           &lt;link&gt;https://orgtech.se/episodes/ep00-trailer&lt;/link&gt;\n
  ;;           &lt;itunes:title&gt;Trailer&lt;/itunes:title&gt;\n
  ;;           &lt;itunes:episode&gt;0&lt;/itunes:episode&gt;\n
  ;;           &lt;itunes:episodeType&gt;Trailer&lt;/itunes:episodeType&gt;\n
  ;;           &lt;transcriptUrl&gt;
  ;;             https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.otr
  ;;           &lt;/transcriptUrl&gt;\n
  ;;         &lt;/item&gt;\n\n
  ;;         ...
  ;;       &lt;/channel&gt;\n
  ;;     &lt;/rss&gt;\n&quot;

  &#41;
</code></pre><p>This looks like a good start, but a few things jump out at us:</p><ol><li>Our <code>&lt;itunes:duration&gt;</code> tag is empty</li><li>We still have a few Selmer template variables in our output, for example: `<a   href=\"{{base-url}}{{episode.path}}/\">episode page</a>`</li></ol><p>Let's tackle the duration issue first, because we already have the tools to fix that in the soundcljoud.processor code that we wrote for Garth:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns soundcljoud.audio
  &#40;:require &#91;babashka.fs :as fs&#93;
            &#91;babashka.process :as p&#93;
            &#91;cheshire.core :as json&#93;
            &#91;clojure.string :as str&#93;&#41;&#41;

&#40;defn mp3-duration &#91;filename&#93;
  &#40;-&gt; &#40;p/shell {:out :string}
               &quot;ffprobe -v quiet -print&#95;format json -show&#95;format -show&#95;streams&quot;
               filename&#41;
      :out
      &#40;json/parse-string keyword&#41;
      :streams
      first
      :duration
      &#40;str/replace #&quot;&#91;.&#93;\d+$&quot; &quot;&quot;&#41;&#41;&#41;

;; ...
</code></pre><p>Let's pull soundcljoud.audio into our namespace and then grab the duration in <code>update-episode</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns soundcljoud.rss
  &#40;:require ; ...
            &#91;soundcljoud.audio :as audio&#93;&#41;
  &#40;:import ...&#41;&#41;

;; ...

&#40;defn update-episode &#91;{:keys &#91;base-dir src-dir&#93; :as opts}
                      {:keys &#91;audio-file path&#93; :as episode}&#93;
  &#40;let &#91;filename &#40;format &quot;%s/%s%s/%s&quot; base-dir src-dir path audio-file&#41;&#93;
    &#40;assoc episode
           :audio-filesize &#40;fs/size filename&#41;
           :duration &#40;audio/mp3-duration filename&#41;&#41;&#41;&#41;

;; ...

&#40;comment

  &#40;podcast-feed opts&#41;
  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;\n
  ;;     &lt;rss version=\&quot;2.0\&quot; ...&gt;\n
  ;;       &lt;channel&gt;\n
  ;;         &lt;title&gt;Organising Tech in Sweden&lt;/title&gt;\n
  ;;         &lt;description&gt;Organising Tech in Sweden is a...&lt;/description&gt;\n
  ;;         ...
  ;;         &lt;item&gt;\n
  ;;           &lt;title&gt;Trailer&lt;/title&gt;\n
  ;;           ...
  ;;           &lt;itunes:duration&gt;42&lt;/itunes:duration&gt;\n
  ;;           ...
  ;;         &lt;/item&gt;\n\n
  ;;         ...
  ;;       &lt;/channel&gt;\n
  ;;     &lt;/rss&gt;\n&quot;

&#41;
</code></pre><p>This looks good, so let's turn our roving eye to the last remaining problem.</p><h2 id="it%27s_templates_all_the_way_down%2C_young_man">It's templates all the way down, young man</h2><p>After rendering our RSS feed template, we somehow still have unrendered Selmer in our output:</p><pre class="language-xml"><code class="lang-xml language-xml">&lt;description&gt;
  &lt;!&#91;CDATA&#91;
  &lt;p&gt;
    Union organising seems to be in the air these days...
  &lt;/p&gt;
  &lt;p class=&quot;soundcljoud-hidden&quot;&gt;
    To view full show notes, including transcripts, please visit the
    &lt;a href=&quot;{{base-url}}{{episode.path}}/&quot;&gt;episode page&lt;/a&gt;.
  &lt;/p&gt;
  &lt;p&gt;
    Cover art by &lt;a href=&quot;https://anyakjordan.com/&quot;&gt;Anya K. Jordan&lt;/a&gt;
  &lt;/p&gt;
  &lt;p&gt;
    Theme music by &lt;a href=&quot;https://soundcloud.com/ptzery&quot;&gt;Ptzery&lt;/a&gt;
  &lt;/p&gt;&#93;&#93;&gt;
&lt;/description&gt;
</code></pre><p>Let's see what's going on in our <code>podcast-feed.rss</code> template for episodes:</p><pre class="language-xml"><code class="lang-xml language-xml">{% for episode in episodes %}
    &lt;item&gt;
      &lt;title&gt;{{episode.title}}&lt;/title&gt;
      ...
      &lt;description&gt;&lt;!&#91;CDATA&#91;{{episode.description|safe}}&#93;&#93;&gt;&lt;/description&gt;
      ...
    &lt;/item&gt;
{% endfor %}
</code></pre><p>So we're plugging <code>episode.description</code> into the template. Let's see what that looks like in our <code>podcast.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
 :episodes
 &#91;{:number 0
   :title &quot;Trailer&quot;
   ;; ...
   :description &quot;
&lt;p&gt;
  Union organising seems to be in the air these days, as tech workers wake up and
  realise that they are, in fact, workers. Here in Sweden, it's no exception.
  Join us as we sit down with some of the people involved in organising two of
  Sweden's foremost tech unicorns, Klarna and Spotify. This is Organising Tech in
  Sweden.
&lt;/p&gt;
&lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;
  To view full show notes, including transcripts, please visit the
  &lt;a href=\&quot;{{base-url}}{{episode.path}}/\&quot;&gt;episode page&lt;/a&gt;.
&lt;/p&gt;
&lt;p&gt;
  Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;
  &lt;a href=\&quot;https://bsky.app/profile/anyakjordan.bsky.social\&quot;&gt;@anyakjordan.bsky.social&lt;/a&gt;
&lt;/p&gt;
&lt;p&gt;
  Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;
&lt;/p&gt;&quot;
   ;; ...
   }
  ;; ...
&#93;}
</code></pre><p>Ah-ha! The value of <code>episode.description</code> itself contains some templating. So it looks like we need to render that as well.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn update-episode &#91;{:keys &#91;base-dir src-dir&#93; :as opts}
                      {:keys &#91;audio-file path&#93; :as episode}&#93;
  &#40;let &#91;filename &#40;format &quot;%s/%s%s/%s&quot; base-dir src-dir path audio-file&#41;&#93;
    &#40;assoc episode
           :audio-filesize &#40;fs/size filename&#41;
           :duration &#40;audio/mp3-duration filename&#41;
           :description &#40;selmer/render &#40;:description episode&#41;
                                       &#40;assoc opts :episode episode&#41;&#41;&#41;&#41;&#41;

;; ...

&#40;comment

  &#40;podcast-feed opts&#41;
  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;\n
  ;;     &lt;rss version=\&quot;2.0\&quot; ...&gt;\n
  ;;       &lt;channel&gt;\n
  ;;         &lt;title&gt;Organising Tech in Sweden&lt;/title&gt;\n
  ;;         &lt;description&gt;Organising Tech in Sweden is a...&lt;/description&gt;\n
  ;;         ...
  ;;         &lt;item&gt;\n
  ;;           &lt;title&gt;Trailer&lt;/title&gt;\n
  ;;           &lt;description&gt;&lt;!&#91;CDATA&#91;\n
  ;;             &lt;p&gt;\n  Union organising seems to be in the air these days...&lt;/p&gt;\n
  ;;             &lt;p class=\&quot;soundcljoud-hidden\&quot;&gt;\n
  ;;               To view full show notes, including transcripts, please visit the\n
  ;;               &lt;a href=\&quot;https://orgtech.se/episodes/ep00-trailer/\&quot;&gt;episode page&lt;/a&gt;.\n
  ;;             &lt;/p&gt;\n
  ;;             &lt;p&gt;\n
  ;;               Cover art by &lt;a href=\&quot;https://anyakjordan.com/\&quot;&gt;Anya K. Jordan&lt;/a&gt;\n
  ;;             &lt;/p&gt;\n
  ;;             &lt;p&gt;\n
  ;;               Theme music by &lt;a href=\&quot;https://soundcloud.com/ptzery\&quot;&gt;Ptzery&lt;/a&gt;\n&lt;/p&gt;
  ;;           &#93;&#93;&gt;&lt;/description&gt;\n
  ;;           ...
  ;;         &lt;/item&gt;\n\n
  ;;         ...
  ;;       &lt;/channel&gt;\n
  ;;     &lt;/rss&gt;\n&quot;

  &#41;
</code></pre><p>OK, this looks much better! And in fact, it looks so much better that we can declare victory and move on to figuring out how to write this beautiful feed to disk!</p><p>To do that, let's jump back to our <code>orgtech-se/bb.edn</code> and add a task for rendering the feed. We'll need to add the Soundcljoud processor to our deps, then we can pretend we have a <code>tasks/render</code> function and call it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {io.github.babashka/sci.nrepl
        {:git/sha &quot;2f8a9ed2d39a1b09d2b4d34d95494b56468f4a23&quot;}
        io.github.babashka/http-server
        {:git/sha &quot;e203166a020509d126149ff8046489857ce5c89f&quot;}
        ;; You can always depend on Soundcljoud!
        ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
        io.github.jmglov/soundcljoud
        {:local/root &quot;/home/jmglov/code/soundcljoud/processor&quot;}
        ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆
        io.github.jmglov/transcribble
        {:local/root &quot;/home/jmglov/code/transcribble/cli&quot;}}
 :paths &#91;&quot;.&quot;&#93;
 :tasks
 {
  ;; ...

  ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
  render {:doc &quot;Create webpages from templates&quot;
          :task &#40;tasks/render opts&#41;}
  ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆
   
  ;; ...
 }}
</code></pre><p>And now we pop over to <code>tasks.clj</code> to implement the task. Sadly, we need to restart our REPL since we added a new dependency and I'm too lazy to learn how to use the new <code>clojure.repl.deps.add-lib</code> from Clojure 1.12 (added to Babashka in version <a href='https://github.com/babashka/babashka/releases/tag/v1.4.192'>1.4.192</a>). In Emacs, we can do this with <strong>C-c C-z</strong> (<code>cider-switch-to-repl-buffer</code>) to jump to the REPL buffer, then <strong>C-c C-q</strong> (<code>cider-quit</code>) to stop the REPL, then **C-c M-j** (<code>cider-jack-in-clj</code>) to start a new REPL. Easy peasy!</p><p>Thus armed with a new REPL, let's pull in the namespaces required to load our <code>podcast.edn</code> and then actually load our <code>podcast.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
  &#40;:require &#91;babashka.cli :as cli&#93;
            &#91;babashka.process :as p&#93;
            ;; Pull in some new namespaces
            ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇 
            &#91;babashka.fs :as fs&#93;
            &#91;clojure.edn :as edn&#93;
            ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆
            &#41;&#41;

&#40;comment

  &#40;def default-opts {:website-bucket &quot;orgtech.se&quot;
                     :out-dir &quot;public&quot;
                     :distribution-id &quot;FDCBA42RSTUV3&quot;}&#41;  ; C-c C-v f c e
  ;; =&gt; #'tasks/default-opts

  &#40;def opts
    &#40;let &#91;base-dir &#40;str &#40;fs/cwd&#41;&#41;&#93;
      &#40;merge default-opts
             &#40;-&gt; &#40;fs/file base-dir &quot;podcast.edn&quot;&#41;
                 slurp
                 edn/read-string
                 &#40;assoc :base-dir base-dir&#41;&#41;&#41;&#41;&#41;
  ;; =&gt; #'tasks/opts

  opts
  ;; =&gt; {:website-bucket &quot;orgtech.se/blog&quot;,
  ;;     :out-dir &quot;public&quot;,
  ;;     :distribution-id &quot;EPTUS11MTYJF7&quot;,
  ;;     :base-url &quot;https://orgtech.se&quot;,
  ;;     :src-dir &quot;public&quot;,
  ;;     :podcast { ... },
  ;;     :episodes &#91; ... &#93;,
  ;;     :base-dir &quot;/home/jmglov/code/orgtech-se&quot;}

&#41;
</code></pre><p>To set ourselves up for success with <code>soundcljoud.rss/podcast-feed</code>, we know that we need our MP3 files in the right place. And in fact, we cheated a bit in our REPL to copy those files to the right place, which means we have some code lying around that we can use! And whilst we're at it, we should also copy the transcript files, since we're referring to them in the rendered feed.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;doseq &#91;episode &#40;:episodes opts&#41;
          file &#40;map episode &#91;:audio-file :transcript-file&#93;&#41;
          :let &#91;filename &#40;format &quot;%s/%s%s/%s&quot;
                                 &#40;:base-dir opts&#41; &#40;:src-dir opts&#41;
                                 &#40;:path episode&#41; file&#41;
                src-filename &#40;fs/file &#40;:base-dir opts&#41;
                                      &#40;fs/file-name &#40;:path episode&#41;&#41;
                                      file&#41;&#93;&#93;
    &#40;when-not &#40;fs/exists? filename&#41;
      &#40;fs/create-dirs &#40;fs/parent filename&#41;&#41;
      &#40;fs/copy src-filename filename&#41;&#41;&#41;
  ;; =&gt; nil

  &#40;-&gt;&gt; &#40;fs/glob &#40;fs/file &#40;:base-dir opts&#41; &#40;:src-dir opts&#41;&#41; &quot;episodes/&#42;&#42;&quot;&#41;
       &#40;map #&#40;-&gt; &#40;str %&#41;
                 &#40;str/replace &#40;:base-dir opts&#41; &quot;&quot;&#41;&#41;&#41;&#41;
  ;; =&gt; &#40;&quot;/public/episodes/ep01-klarna-part1&quot;
  ;;     &quot;/public/episodes/ep01-klarna-part1/otis-ep01-klarna-part1.otr&quot;
  ;;     &quot;/public/episodes/ep01-klarna-part1/otis-ep01-klarna-part1.mp3&quot;
  ;;     &quot;/public/episodes/ep02-klarna-part2&quot;
  ;;     &quot;/public/episodes/ep02-klarna-part2/otis-ep02-klarna-part2.otr&quot;
  ;;     &quot;/public/episodes/ep02-klarna-part2/otis-ep02-klarna-part2.mp3&quot;
  ;;     &quot;/public/episodes/ep00-trailer&quot;
  ;;     &quot;/public/episodes/ep00-trailer/otis-ep00-trailer.otr&quot;
  ;;     &quot;/public/episodes/ep00-trailer/otis-ep00-trailer.mp3&quot;&#41;

  &#41;
</code></pre><p>Now that the files are, well, filed, let's see about rendering the podcast feed.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
  &#40;:require ; ...
            &#91;soundcljoud.rss :as rss&#93;&#41;&#41;

&#40;comment

  &#40;let &#91;feed-file &#40;fs/file &#40;:src-dir opts&#41; &quot;feed.rss&quot;&#41;&#93;
    &#40;println &#40;format &quot;Writing RSS feed %s&quot; feed-file&#41;&#41;
    &#40;-&gt;&gt; &#40;rss/podcast-feed opts&#41;
         &#40;spit feed-file&#41;&#41;&#41;
  ;; =&gt; nil

  &#40;slurp &quot;public/feed.rss&quot;&#41;
  ;; =&gt; &quot;&lt;?xml version='1.0' encoding='UTF-8'?&gt;\n
  ;;     &lt;rss version=\&quot;2.0\&quot;\n
  ;;          xmlns:itunes=\&quot;http://www.itunes.com/dtds/podcast-1.0.dtd\&quot;\n
  ;;          xmlns:atom=\&quot;http://www.w3.org/2005/Atom\&quot;&gt;\n
  ;;       &lt;channel&gt;\n
  ;;         &lt;title&gt;Organising Tech in Sweden&lt;/title&gt;\n
  ;;         &lt;description&gt;Organising Tech in Sweden is a...&lt;/description&gt;\n
  ;;         &lt;itunes:image href=\&quot;https://orgtech.se/img/orgtech-se-cover.jpg\&quot;/&gt;\n
  ;;         &lt;language&gt;en&lt;/language&gt;\n
  ;;         &lt;itunes:explicit&gt;true&lt;/itunes:explicit&gt;\n
  ;;         &lt;itunes:category text=\&quot;Technology\&quot;&gt;\n
  ;;         &lt;/itunes:category&gt;\n
  ;;         &lt;itunes:category text=\&quot;News\&quot;&gt;\n
  ;;           &lt;itunes:category text=\&quot;Politics\&quot; /&gt;\n
  ;;         &lt;/itunes:category&gt;\n
  ;;         &lt;itunes:author&gt;Organising Tech in Sweden&lt;/itunes:author&gt;\n
  ;;         &lt;link&gt;https://orgtech.se&lt;/link&gt;\n
  ;;         &lt;itunes:title&gt;Organising Tech in Sweden&lt;/itunes:title&gt;\n
  ;;         &lt;itunes:type&gt;Serial&lt;/itunes:type&gt;\n
  ;;         &lt;copyright&gt;All rights reserved, Organising Tech in Sweden&lt;/copyright&gt;\n
  ;;         &lt;item&gt;\n
  ;;           &lt;title&gt;Trailer&lt;/title&gt;\n
  ;;           &lt;enclosure\n
  ;;               url=\&quot;https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.mp3\&quot;\n
  ;;               length=\&quot;1016937\&quot;\n
  ;;               type=\&quot;audio/mpeg\&quot; /&gt;\n
  ;;           &lt;guid&gt;https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.mp3&lt;/guid&gt;\n
  ;;           &lt;pubDate&gt;Thu, 5 Sep 2024 00:00:00 +0000&lt;/pubDate&gt;\n
  ;;           &lt;description&gt;&lt;!&#91;CDATA&#91;\n
  ;;             &lt;p&gt;\n
  ;;               Union organising seems to be in the air these days...
  ;;             &lt;/p&gt;&#93;&#93;&gt;
  ;;           &lt;/description&gt;\n
  ;;           &lt;itunes:duration&gt;42&lt;/itunes:duration&gt;\n
  ;;           &lt;link&gt;https://orgtech.se/episodes/ep00-trailer&lt;/link&gt;\n
  ;;           &lt;itunes:title&gt;Trailer&lt;/itunes:title&gt;\n
  ;;           &lt;itunes:episode&gt;0&lt;/itunes:episode&gt;\n
  ;;           &lt;itunes:episodeType&gt;Trailer&lt;/itunes:episodeType&gt;\n
  ;;           &lt;transcriptUrl&gt;https://orgtech.se/episodes/ep00-trailer/otis-ep00-trailer.otr&lt;/transcriptUrl&gt;\n
  ;;         &lt;/item&gt;\n
  ;;         ...
  ;;       &lt;/channel&gt;
  ;;     &lt;/rss&gt;

  &#41;
</code></pre><p>OK, now we have everything we need to write our <code>render</code> function, so let's get to it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn render &#91;default-opts&#93;
  &#40;let &#91;base-dir &#40;str &#40;fs/cwd&#41;&#41;
        {:keys &#91;episodes src-dir&#93; :as opts}
        &#40;merge default-opts
               &#40;cli/parse-opts &#42;command-line-args&#42;&#41;
               &#40;-&gt; &#40;fs/file base-dir &quot;podcast.edn&quot;&#41;
                   slurp
                   edn/read-string
                   &#40;assoc :base-dir base-dir&#41;&#41;&#41;
        feed-file &#40;fs/file src-dir &quot;feed.rss&quot;&#41;&#93;
    &#40;doseq &#91;{:keys &#91;path&#93; :as episode} &#40;:episodes opts&#41;
            file &#40;map episode &#91;:audio-file :transcript-file&#93;&#41;
            :let &#91;filename &#40;format &quot;%s/%s%s/%s&quot; base-dir src-dir path file&#41;
                  src-filename &#40;fs/file base-dir &#40;fs/file-name path&#41; file&#41;&#93;&#93;
      &#40;when-not &#40;fs/exists? filename&#41;
        &#40;fs/create-dirs &#40;fs/parent filename&#41;&#41;
        &#40;fs/copy src-filename filename&#41;&#41;&#41;
    &#40;println &#40;format &quot;Writing RSS feed %s&quot; feed-file&#41;&#41;
    &#40;-&gt;&gt; &#40;rss/podcast-feed opts&#41;
         &#40;spit feed-file&#41;&#41;&#41;&#41;

&#40;comment

  &#40;render default-opts&#41;
  ;; =&gt; nil

  &#41;
</code></pre><p>We should now be able to aim our web browser at http://localhost:1341/feed.rss and see a lovely podcast feed.</p><p><img src="assets/2024-09-18-podcast-soundcljoud-feed.png" alt="RSS feed displayed in a web browser" title="Feed me nothing but the good stuff!" width=800px border=1 /></p><p>As lovely as this loveliness is, our eye is inexorably and tragically drawn to one thing which we do not love:</p><pre class="language-xml"><code class="lang-xml language-xml">    &lt;item&gt;
      ...
      &lt;link&gt;https://orgtech.se/episodes/ep00-trailer&lt;/link&gt;
      ...
    &lt;/item&gt;
</code></pre><p>This page, dear reader, does not exist!</p><h2 id="selmer_to_the_rescue">Selmer to the rescue</h2><p>Where does this <code>&lt;link&gt;</code> thingy come from, and why do we need it anyway? Well, if we refer back to <a href='https://help.apple.com/itc/podcasts_connect/#/itcb54353390'>A Podcaster's Guide to
RSS</a>, we see:</p><blockquote><p> <code>&lt;link&gt;</code> </p><p> An episode link URL.  This is used when an episode has a corresponding webpage. </p></blockquote><p>Ah, so it's an episode page we need, eh? Well, we have a bunch of info about the episode in our <code>podcast.edn</code> file, and some code that loops over episodes and does stuff in <code>tasks/render</code>, and a deep and abiding love for Selmer, so let's whip up an episode page template, then plug some stuff in whilst we're looping over episodes. We'll start with the template, which we'll drop into a new <code>templates/episode-page.html</code> file:</p><pre class="language-html"><code class="lang-html language-html">&lt;!doctype html&gt;
&lt;html class=&quot;no-js&quot; lang=&quot;&quot;&gt;

&lt;head&gt;
  &lt;title&gt;
    {{podcast.title}} Episode {{episode.number}} - {{episode.title}}
  &lt;/title&gt;

  &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;
  &lt;meta charset=&quot;utf-8&quot;&gt;
  &lt;meta http-equiv=&quot;x-ua-compatible&quot; content=&quot;ie=edge&quot;&gt;

  &lt;link rel=&quot;stylesheet&quot; href=&quot;/css/main.css&quot;&gt;

  &lt;!-- Favicon from https://realfavicongenerator.net/ --&gt;
  &lt;link rel=&quot;apple-touch-icon&quot; sizes=&quot;180x180&quot; href=&quot;/apple-touch-icon.png&quot;&gt;
  &lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;32x32&quot; href=&quot;/favicon-32x32.png&quot;&gt;
  &lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;16x16&quot; href=&quot;/favicon-16x16.png&quot;&gt;
  &lt;link rel=&quot;manifest&quot; href=&quot;/site.webmanifest&quot;&gt;
  &lt;link rel=&quot;mask-icon&quot; href=&quot;/safari-pinned-tab.svg&quot; color=&quot;#5bbad5&quot;&gt;
  &lt;meta name=&quot;msapplication-TileColor&quot; content=&quot;#da532c&quot;&gt;
  &lt;meta name=&quot;theme-color&quot; content=&quot;#ffffff&quot;&gt;

  &lt;!-- Social sharing &#40;Facebook, Twitter, LinkedIn, etc.&#41; --&gt;
  &lt;meta name=&quot;title&quot; content=&quot;{{podcast.title}} Episode {{episode.number}} - {{episode.title}}&quot;&gt;
  &lt;meta name=&quot;twitter:title&quot; content=&quot;{{podcast.title}} Episode {{episode.number}} - {{episode.title}}&quot;&gt;
  &lt;meta property=&quot;og:title&quot; content=&quot;{{podcast.title}} Episode {{episode.number}} - {{episode.title}}&quot;&gt;
  &lt;meta property=&quot;og:type&quot; content=&quot;website&quot;&gt;

  &lt;meta name=&quot;description&quot; content=&quot;{{episode.summary}}&quot;&gt;
  &lt;meta name=&quot;twitter:description&quot; content=&quot;{{episode.summary}}&quot;&gt;
  &lt;meta property=&quot;og:description&quot; content=&quot;{{episode.summary}}&quot;&gt;

  &lt;meta name=&quot;twitter:url&quot; content=&quot;{{base-url}}{{episode.path}}/index.html&quot;&gt;
  &lt;meta property=&quot;og:url&quot; content=&quot;{{base-url}}{{episode.path}}/index.html&quot;&gt;

  &lt;meta name=&quot;twitter:image&quot; content=&quot;{{base-url}}{{preview-image}}&quot;&gt;
  &lt;meta name=&quot;twitter:card&quot; content=&quot;summary&#95;large&#95;image&quot;&gt;
  &lt;meta property=&quot;og:image&quot; content=&quot;{{base-url}}{{preview-image}}&quot;&gt;
  &lt;meta property=&quot;og:image:alt&quot; content=&quot;{{podcast.image-alt}}&quot;&gt;
&lt;/head&gt;

&lt;body&gt;
  &lt;div id=&quot;wrapper&quot;&gt;
    &lt;div id=&quot;left-side&quot;&gt;
      &lt;img id=&quot;cover-image&quot; src=&quot;{{podcast.image}}&quot; alt=&quot;{{podcast.image-alt}}&quot; /&gt;
      &lt;div id=&quot;aggregators-1&quot;&gt;
        &lt;div id=&quot;apple&quot;&gt;
          &lt;a class=&quot;apple-button&quot;
            href=&quot;https://podcasts.apple.com/us/podcast/organising-tech-in-sweden/id1766442275?itsct=podcast&#95;box&#95;badge&amp;amp;itscg=30200&amp;amp;ls=1&quot;&gt;
            &lt;img src=&quot;https://tools.applemediaservices.com/api/badges/listen-on-apple-podcasts/badge/en-us?size=250x83&amp;amp;releaseDate=1725494400&quot;
              title=&quot;Listen on Apple Podcasts&quot; alt=&quot;Listen on Apple Podcasts&quot; class=&quot;apple-button&quot;&gt;
          &lt;/a&gt;
        &lt;/div&gt;
        &lt;div id=&quot;spotify&quot;&gt;
          &lt;a href=&quot;https://open.spotify.com/show/53psoLoX187axvmgb80l1x&quot;&gt;
            &lt;img src=&quot;/img/spotify-podcast-badge-blk-grn-330x80.svg&quot; title=&quot;Listen on Spotify&quot;
              alt=&quot;Listen on Spotify&quot;&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;aggregators-2&quot;&gt;
        &lt;div id=&quot;podbean&quot;&gt;
          &lt;a href=&quot;https://www.podbean.com/podcast-detail/2r2tz-31b053/Organising-Tech-in-Sweden-Podcast&quot;
            rel=&quot;noopener noreferrer&quot; target=&quot;&#95;blank&quot;&gt;
            &lt;img src=&quot;https://pbcdn1.podbean.com/fs1/site/images/badges/w600&#95;1.png&quot;
              title=&quot;Listen on Podbean&quot; alt=&quot;Listen on Podbean&quot;&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;div id=&quot;main&quot;&gt;
      &lt;nav id=&quot;header&quot;&gt;
        &lt;h1 id=&quot;title&quot;&gt;{{episode.title}}&lt;/h1&gt;
        &lt;div id=&quot;socials&quot;&gt;
          {% for social in socials %}
          &lt;a href=&quot;{{social.url}}&quot;&gt;
            &lt;img src=&quot;{{social.image}}&quot; alt=&quot;{{social.image-alt}}&quot; /&gt;
          &lt;/a&gt;
          {% endfor %}
        &lt;/div&gt;
      &lt;/nav&gt;
      &lt;div id=&quot;description&quot;&gt;{{episode.description|safe}}&lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
  &lt;div id=&quot;transcript&quot;&gt;
    &lt;h1&gt;Transcript&lt;/h1&gt;
    &lt;div id=&quot;transcript-body&quot;&gt;{{episode.transcript-html|safe}}&lt;/div&gt;
  &lt;/div&gt;
&lt;/body&gt;

&lt;/html&gt;
</code></pre><p>We should also sprinkle a little extra CSS into our <code>public/css/main.css</code>:</p><pre class="language-css"><code class="lang-css language-css">/&#42; ... &#42;/

#aggregators-1 {
  display: flex;
  justify-content: space-between;
  margin-top: 10px;
}

#aggregators-1 img {
  width: 175px;
}

#apple a {
  display: inline-block;
  overflow: hidden;
}

.apple-button {
  border-radius: 13px;
}

#aggregators-2 {
  display: flex;
  justify-content: space-between;
  margin-top: 10px;
}

#podbean img {
  height: 42px;
}

/&#42; Some paragraphs in the description shouldn't be displayed on the episode page &#42;/
p.soundcljoud-hidden {
  display: none;
}

#transcript {
  background-color: #e4f1fe;
  border: solid 1px;
  padding-left: 1em;
  padding-right: 1em;
  margin-top: 1em;
}

#transcript-body &gt; br {
  display: none;
}

span.timestamp {
  margin-right: 5px;
  color: blue;
  cursor: pointer;
  &amp;:hover {
    text-decoration: underline;
  }
}

@media screen and &#40;min-width: 600px&#41; {

  /&#42; ... &#42;/

  #aggregators-1 img {
    width: 155px;
  }

  #podbean img {
    height: 37px;
  }

  #podbean img {
    margin-top: 0px;
  }

}
</code></pre><p>We'll need the following template vars:</p><ul><li>base-url</li><li>episode.description</li><li>episode.number</li><li>episode.path</li><li>episode.summary</li><li>episode.title</li><li>episode.transcript-html</li><li>podcast.image</li><li>podcast.image-alt</li><li>podcast.title</li><li>preview-image</li><li>socials<ul><li>social.url</li><li>social.image</li><li>social.image-alt</li></ul></li></ul><p>Most of this we already have, but there are a couple new things. Let's take the easiest two first.</p><ul><li>podcast.image-alt</li><li>preview-image</li></ul><p>We'll add some alt text for our podcast cover image and a social preview image to <code>podcast.edn</code>. The alt text is just a description of the cover image, and it turns out that the preview image is one of the many things we hardcoded into our <code>public/index.html</code> way back when.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
 :preview-image &quot;/img/orgtech-se-preview.jpg&quot;
 ;; ...
 :podcast { ; ...
           :image-alt &quot;Organising Tech in Sweden superimposed on raised fists with a Swedish flag with a circuit board pattern in the background&quot;
           ;; ...
           }
 ;; ...
 }
</code></pre><p>Let's turn next to <code>socials</code>. This is how we refer to it in the template:</p><pre class="language-html"><code class="lang-html language-html">        {% for social in socials %}
        &lt;a href=&quot;{{social.url}}&quot;&gt;
          &lt;img src=&quot;{{social.image}}&quot; alt=&quot;{{social.image-alt}}&quot; /&gt;
        &lt;/a&gt;
        {% endfor %}
</code></pre><p>This means that it needs to be a list, and each list item should be a map containing three keys:</p><ul><li>social.url</li><li>social.image</li><li>social.image-alt</li></ul><p>Let's add the following to our <code>podcast.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
 :preview-image &quot;/img/orgtech-se-preview.jpg&quot;
 ;; ...
 :socials &#91;{:name &quot;Twitter&quot;
            :url &quot;https://x.com/orgtech&#95;se&quot;
            :image &quot;/img/twitter-color-svgrepo-com.svg&quot;
            :image-alt &quot;Twitter logo&quot;}
           {:name &quot;BlueSky&quot;
            :url &quot;https://bsky.app/profile/orgtech-se.bsky.social&quot;
            :image &quot;/img/bluesky-logo.svg&quot;
            :image-alt &quot;BlueSky logo&quot;}&#93;
 ;; ...
 }
</code></pre><p>Finally, we need to conjure up one last key for each episode:</p><ul><li>episode.transcript-html</li></ul><p>Episodes already have a <code>:transcript-file</code> key, which refers to an OTR file. Let's have a quick look at one of those and see what is contained therein:</p><pre class="language-json"><code class="lang-json language-json">{
  &quot;text&quot;: &quot;&lt;p&gt;&#91;Theme music begins&#93;&lt;/p&gt;&lt;p&gt;&lt;span class=\&quot;timestamp\&quot; data-timestamp=\&quot;12.111684\&quot;&gt;00:12&lt;/span&gt;&lt;b&gt;Josh&lt;/b&gt;: ... &lt;/p&gt;&quot;,
  &quot;media&quot;: &quot;otis-ep01-klarna-part1.mp3&quot;,
  &quot;media-time&quot;: 1315.629803
}
</code></pre><p>What we have here is a JSON file with a thin veneer of metadata around a looooong HTML string. Let's deal with this back in <code>podcast.rss/update-episode</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn update-episode &#91;{:keys &#91;base-dir src-dir&#93; :as opts}
                      ;;                 👇👇👇👇👇👇👇
                      {:keys &#91;audio-file transcript-file path&#93; :as episode}&#93;
                      ;;                 👆👆👆👆👆👆👆
  &#40;let &#91;filename &#40;format &quot;%s/%s%s/%s&quot; base-dir src-dir path audio-file&#41;
        ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
        transcript &#40;format &quot;%s/%s%s/%s&quot; base-dir src-dir path transcript-file&#41;&#93;
        ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆
    &#40;assoc episode
           :audio-filesize &#40;fs/size filename&#41;
           :duration &#40;audio/mp3-duration filename&#41;
           :description &#40;selmer/render &#40;:description episode&#41;
                                       &#40;assoc opts :episode episode&#41;&#41;
           ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
           :transcript-html &#40;-&gt; &#40;slurp transcript&#41;
                                &#40;json/parse-string keyword&#41;
                                :text&#41;&#41;&#41;&#41;
</code></pre><p>Having satisfied ourselves that we have all of the data we need for our template, let's get to rendering. In our <code>tasks/render</code> function, we're looping over episodes in order to copy the MP3 and OTR files into the right place. Sadly, we can't just pop <code>selmer/render</code> into that <code>doseq</code> and be done with it, because we need to ensure the audio file is in the right place before we can use <code>update-episode</code>. No problem, we'll just add a new bit after spitting our podcast feed:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
  &#40;:require ; ...
            &#91;selmer.parser :as selmer&#93;&#41;&#41;

&#40;defn render &#91;default-opts&#93;
  &#40;let &#91;...&#93;
    ;; ...
    &#40;println &#40;format &quot;Writing RSS feed %s&quot; feed-file&#41;&#41;
    &#40;-&gt;&gt; &#40;rss/podcast-feed opts&#41;
         &#40;spit feed-file&#41;&#41;
    &#40;let &#91;template &#40;slurp &quot;templates/episode-page.html&quot;&#41;
          opts &#40;rss/update-episodes opts&#41;&#93;
      &#40;doseq &#91;{:keys &#91;path&#93; :as episode} &#40;:episodes opts&#41;
              :let &#91;filename &#40;format &quot;%s/%s%s/%s&quot;
                                     base-dir out-dir path &quot;index.html&quot;&#41;&#93;&#93;
        &#40;println &quot;Writing episode page&quot; filename&#41;
        &#40;-&gt;&gt; &#40;selmer/render template &#40;assoc opts :episode episode&#41;&#41;
             &#40;spit filename&#41;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>And now, the moment of truth!</p><pre class="language-text"><code class="lang-text language-text">: orgtech-se; bb render
Writing RSS feed public/feed.rss
Writing episode page &#126;/code/orgtech-se/public/episodes/ep00-trailer/index.html
Writing episode page &#126;/code/orgtech-se/public/episodes/ep01-klarna-part1/index.html
Writing episode page &#126;/code/orgtech-se/public/episodes/ep02-klarna-part2/index.html
</code></pre><p>And now if we visit (for example) http://localhost:1341/episodes/ep01-klarna-part1, we should see an amazing webpage:</p><p><img src="assets/2024-09-18-podcast-soundcljoud-episode.png" alt="Episode page displayed in a web browser" title="I feel good, but also empty" width=800px border=1 /></p><h2 id="podcast_half_empty%2C_or_podcast_half_full%3F">Podcast half empty, or podcast half full?</h2><p>Astute observers may have noticed one issue with the episode page: there's no way to play the episode. 🤦🏼</p><p>Fear not! In the next instalment, we'll look at playing a podcast with ClojureScript, perhaps even using our own friend <a href='tags/soundcljoud.html'>Soundcljoud</a>!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-08-13-soundcljoud-rangey.html</id>
    <link href="https://jmglov.net/blog/2024-08-13-soundcljoud-rangey.html"/>
    <title>Soundcljoud gets more rangey</title>
    <updated>2024-08-13T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2024-08-13-soundcljoud-rangey-preview.png" alt="A golfer with the Soundcljoud logo as their head about to hit a shot; Photo by Andrew Rice on Unsplash" title="FORE!" width=800px /></p><p><a href='2024-07-20-soundcljoud-cloudy.html'>Last time</a> on "Soundcljoud gets more cloudy", I found myself deeply saddened that the eternal truths I was seeking in the music of Garth Brooks remained elusive due to my attempts to seek forward in a track were rebuffed by my browser, instead abruptly returning me to the beginning of the track. 😳</p><p>Appropriately chastened, I popped the bonnet and had a look at what my user agent was doing on my behalf. When I loaded a track, I saw a request like this:</p><pre class="language-text"><code class="lang-text language-text">GET /Garth+Brooks/Fresh+Horses/Garth+Brooks+-+The+Old+Stuff.mp3 HTTP/1.1
Range: bytes=0-
</code></pre><p>and a response like this:</p><pre class="language-text"><code class="lang-text language-text">HTTP/1.1 200 OK
Content-length: 5943424
Content-Type: audio/mpeg
Server: http-kit
</code></pre><p>with a bunch of bytes in the body. In fact, a bountiful buffet of beautiful bytes, five whole million of them! And another 943,424 thrown in for dessert.</p><p>Herein lies the rub. What the browser wants back is some indication that the server knows how to return a range of bytes, because the browser doesn't want to fetch the entire damned file every time the user starts playing a track. After all, the user might be trying to remember if the track entitled "The Old Stuff" contains the amazing homage to a "worn out tape of Chris LeDoux" (spoiler: it does not), and just listening to the first few seconds to determine this, then, disappointed, moving on to another track to sample the first few seconds of that one.</p><p>And how, you might ask, does the server indicate its range savviness? Well, according to our good friends over at the <a href='https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests'>Mozilla Developer
Network</a>, by returning a response such as this:</p><pre class="language-text"><code class="lang-text language-text">HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-length: 1048576
Content-Range: bytes 0-1048575/5943424
Content-Type: audio/mpeg
</code></pre><h2 id="whence_ranges%3F">Whence ranges?</h2><p>Let's refresh our memory a bit by firing up <a href='https://github.com/jmglov/soundcljoud'>Soundcljoud</a>:</p><pre class="language-text"><code class="lang-text language-text">cd &#126;/code/soundcljoud/player
bb dev
</code></pre><p>Now we can pop over to <a href='http://localhost:1341/'>http://localhost:1341/</a>, open up the <code>soundcljoud.cljs</code> in Emacs (or whatever inferior text editor you choose to inflict upon yourself), hit <strong>C-c l C</strong> (<code>cider-connect-cljs</code>) to start a REPL connected to localhost port 1339 (REPL type <code>nbb</code>), and finally evaluate <code>load-ui!</code> to get things going:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;load-ui! &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses&quot;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

  &#41;
</code></pre><p><img src="assets/2024-08-13-soundcljoud-rangey-eye.png" alt="The Soundcljoud UI, playing album Garth Brooks - Fresh Horses" title="The all-seeing eye of Garth is upon us" width=800px border=1 /></p><p>Opening the network tab, we see exactly what the browser asked for and exactly what the server responded:</p><p><img src="assets/2024-08-13-soundcljoud-rangey-range.png" alt="Browser developer tools, showing the network request and response for the MP3 file" title="Yeah, I didn't want *all* of the bytes..." width=800px border=1 /></p><p>First, the browser asks for some bytes, starting at the beginning of the file:</p><pre class="language-text"><code class="lang-text language-text">Range: bytes=0-
</code></pre><p>Since the end of the byte range isn't specified, the server is free to decide how many bytes to send back. Let's say we'll send back 1 MB (1048576 bytes). Our response should start by indicating that we're not returning the entire file, but rather just a part of it:</p><pre class="language-text"><code class="lang-text language-text">HTTP/1.1 206 Partial Content
</code></pre><p>Now we need to say which bytes we're returning, out of the total number of bytes in the file, as well as the length of the response, in bytes:</p><pre class="language-text"><code class="lang-text language-text">Content-Range: bytes 0-1048575/6062208
Content-length: 1048576
</code></pre><p>Note that the byte range is zero-indexed and <strong>inclusive</strong> on the end, meaning that the last byte we return is at index 1048575, whilst the content length is the <strong>number of bytes</strong> in the response body.</p><p>Finally, we need to let the client know what kind of range requests we support. We'll limit this to bytes:</p><pre class="language-text"><code class="lang-text language-text">Accept-Ranges: bytes
</code></pre><p>We must now flip Hegel on his head, as the saying goes, and move from lofty ideas to dirty, inconvenient material reality. In other words, we gotta implement range requests in our actual webserver.</p><h2 id="getting_materialistic">Getting materialistic</h2><p>Let's cast our minds back to what happens when we type</p><pre class="language-text"><code class="lang-text language-text">bb dev
</code></pre><p>in our terminal. According to our <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {io.github.babashka/sci.nrepl
        {:git/sha &quot;2f8a9ed2d39a1b09d2b4d34d95494b56468f4a23&quot;}
        io.github.babashka/http-server
        {:git/sha &quot;b38c1f16ad2c618adae2c3b102a5520c261a7dd3&quot;}}
 :tasks
 {http-server
  {:doc &quot;Starts http server for serving static files&quot;
   :requires &#40;&#91;babashka.http-server :as http&#93;&#41;
   :task &#40;do &#40;http/serve {:port 1341 :dir &quot;public&quot;}&#41;
             &#40;println &quot;Serving static assets at http://localhost:1341&quot;&#41;&#41;}

  browser-nrepl
  {:doc &quot;Start browser nREPL&quot;
   :requires &#40;&#91;sci.nrepl.browser-server :as bp&#93;&#41;
   :task &#40;bp/start! {}&#41;}

  -dev
  {:depends &#91;http-server browser-nrepl&#93;}

  dev
  {:task &#40;do &#40;run '-dev {:parallel true}&#41;
           &#40;deref &#40;promise&#41;&#41;&#41;}}}
</code></pre><p>OK, so it looks like <a href='https://github.com/babashka/http-server'>io.github.babashka/http-server</a> is the thing serving up our content. Let's go ahead and clone that so we can start digging through the code:</p><pre class="language-text"><code class="lang-text language-text">cd &#126;/code
git clone git@github.com:babashka/http-server.git
</code></pre><p>Tracing through <code>bb.edn</code>, we see that the webserver is started by calling <code>babashka.http-server/serve</code> with a config map containing the port and directory:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ;; ...
 :tasks
 {http-server
  {:requires &#40;&#91;babashka.http-server :as http&#93;&#41;
   :task &#40;do &#40;http/serve {:port 1341 :dir &quot;public&quot;}&#41;
             &#40;println &quot;Serving static assets at http://localhost:1341&quot;&#41;&#41;}
 ;; ...
 }}
</code></pre><p>Let's see what's going on thereabouts in the http-server source code. Opening <a href='https://github.com/babashka/http-server/blob/e625b1a367023bc400d38474677d071abd8c02fb/src/babashka/http_server.clj'>src/babashka/http_server.clj</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn serve
  &quot;Serves static assets using web server.
Options:
  &#42; `:dir` - directory from which to serve assets
  &#42; `:port` - port
  &#42; `:headers` - map of headers {key value}&quot;
  &#91;{:keys &#91;port&#93;
    :or {port 8090}
    :as opts}&#93;
  &#40;let &#91;dir &#40;or &#40;:dir opts&#41; &quot;.&quot;&#41;
        opts &#40;assoc opts :dir dir :port port&#41;
        dir &#40;fs/path dir&#41;&#93;
    &#40;assert &#40;fs/directory? dir&#41; &#40;str &quot;The given dir `&quot; dir &quot;` is not a directory.&quot;&#41;&#41;
    &#40;binding &#91;&#42;out&#42; &#42;err&#42;&#93;
      &#40;println &#40;str &quot;Serving assets at http://localhost:&quot; &#40;:port opts&#41;&#41;&#41;&#41;
    &#40;server/run-server &#40;file-router dir &#40;opts :headers&#41;&#41; opts&#41;&#41;&#41;
</code></pre><p>we see a bunch of ceremony before <code>server/run-server</code> is called with a <code>file-router</code> (whatever that is) and some opts; basically the port and directory we passed in from <code>bb.edn</code>. But what, pray tell, is this mystical <code>server</code> namespace?</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns babashka.http-server
  &#40;:require &#91;babashka.fs :as fs&#93;
            &#91;clojure.string :as str&#93;
            #&#95;&#91;clojure.tools.cli :refer &#91;parse-opts&#93;&#93;
            &#91;hiccup2.core :as html&#93;
            &#91;babashka.cli :as cli&#93;
            &#91;org.httpkit.server :as server&#93;&#41;
  &#40;:import &#91;java.net URLDecoder URLEncoder&#93;&#41;&#41;
</code></pre><p>Aha! 'Tis none other than <a href='https://github.com/http-kit/http-kit'>http-kit</a>, a "minimalist and efficient Ring-compatible HTTP client+server for Clojure". Looking at the <a href='https://github.com/http-kit/http-kit/wiki/3-Server#start-server'>documentation for
<code>run-server</code></a>, we see that the <code>file-router</code> thingy must return a <a href='https://github.com/ring-clojure/ring/wiki/Concepts#handlers'>Ring
handler</a>, which is nothing more than a function that takes a request map as its argument and returns a response map. This function will be called by http-kit upon every request.</p><p><code>start-server</code> returns a function that we can call to <a href='https://github.com/http-kit/http-kit/wiki/3-Server#stop-server'>stop the
server</a>.</p><p>Using this knowledge, let's dig into the <code>file-router</code> handler function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn file-router &#91;dir headers&#93;
  &#40;fn &#91;{:keys &#91;uri&#93;}&#93;
    &#40;let &#91;f &#40;fs/path dir &#40;str/replace-first &#40;URLDecoder/decode uri&#41; #&quot;&#94;/&quot; &quot;&quot;&#41;&#41;
          index-file &#40;fs/path f &quot;index.html&quot;&#41;&#93;
      &#40;update &#40;cond
                &#40;and &#40;fs/directory? f&#41; &#40;fs/readable? index-file&#41;&#41;
                &#40;body index-file&#41;

                &#40;fs/directory? f&#41;
                &#40;index dir f&#41;

                &#40;fs/readable? f&#41;
                &#40;body f&#41;

                &#40;and &#40;nil? &#40;fs/extension f&#41;&#41; &#40;fs/readable? &#40;with-ext f &quot;.html&quot;&#41;&#41;&#41;
                &#40;body &#40;with-ext f &quot;.html&quot;&#41; headers&#41;

                :else
                {:status 404 :body &#40;str &quot;Not found `&quot; f &quot;` in &quot; dir&#41;}&#41;
              :headers &#40;fn &#91;response-headers&#93;
                         &#40;merge headers response-headers&#41;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>OK, what's going on here? Well, we're returning a function (i.e. the Ring handler) that basically grabs the path part of the URI (which will be relative to the directory named by our <code>:dir</code> option; in other words, <code>soundcljoud/player/public</code>) and asks a series of questions in a <a href='https://clojuredocs.org/clojure.core/cond'>cond</a> form:</p><ol><li>Does the path refer to a directory? If so, does there exist an <code>index.html</code>   that is readable by the webserver?</li><li>Otherwise, does the path refer to a directory (without an <code>index.html</code>)?</li><li>Otherwise, does the path refer to a file that is readable by the webserver?</li><li>Otherwise, does the path refer to a thing which, if we slap a <code>.html</code>   extension on the end, is a file that is readable by the webserver?</li><li>Why is this user wasting our time requesting stuff that we don't have?</li></ol><p>Let's think for a second about which case we're interested in. Our browser is requesting <code>/Garth Brooks/Fresh Horses/Garth Brooks - The Old Stuff.mp3</code>, which is going to hit condition #3 in the list:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">                &#40;fs/readable? f&#41;
                &#40;body f&#41;
</code></pre><p>Let's see what's going on with this body. And yes, I am aware that sounds like the title of a <a href='https://en.wikipedia.org/wiki/Pitbull_(rapper'>Pitbull</a>) collabo with <a href='https://en.wikipedia.org/wiki/Nicki_Minaj'>Nicki Minaj</a>.</p><p><img src="assets/2024-08-13-soundcljoud-rangey-body.png" alt="Fake cover art for a What's Going on with that Body? single" title="¿Qué pasa con ese cuerpo?" /></p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- body
  &#40;&#91;path&#93;
   &#40;body path {}&#41;&#41;
  &#40;&#91;path headers&#93;
   {:headers &#40;merge {&quot;Content-Type&quot; &#40;ext-mime-type &#40;fs/file-name path&#41;&#41;} headers&#41;
    :body &#40;fs/file path&#41;}&#41;&#41;
</code></pre><p>The only thing happening here is that the <a href='https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types'>MIME
type</a> of the file is being looked up using its extension and added as the <code>Content-Type</code> header, then the path itself is turned into a <code>java.io.File</code> with the <a href='https://github.com/babashka/fs/blob/master/API.md#babashka.fs/file'>babashka.fs/file</a> function and added to the response map under the <code>:body</code> key. Presumably, http-kit will then take that <code>java.io.File</code> object and send the bytes back as the response body.</p><p>This looks very similar to what we will need to do, with the exception that instead of sending back all of the bytes in the file, we'll just want to send back those that were asked for.</p><p>Now that we know more or less where to start, let's fire up a REPL and start playing!</p><h2 id="we_aim_to_serve">We aim to serve</h2><p>The first thing we need to do is Ctrl-c our <code>bb dev</code> process, since we won't be able to start a webserver on port 1341 with that one in the way.</p><p>Next, let's open up <code>http-server/src/babashka/http&#95;server.clj</code> in Emacs and start a REPL with <strong>C-c M-j</strong> (<code>cider-jack-in-clj</code>), choosing <code>babashka</code> as the command to start the REPL. Now, we load the buffer with <strong>C-c C-k</strong> (<code>cider-load-buffer</code>), and sign in relief as we're back in the REPL again.</p><p>For our first order of business, let's try starting a server from the REPL to serve up the files in the <code>soundcljoud/player/public</code> directory on port 1341, just like we had before:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def dir &quot;../soundcljoud/player/public&quot;&#41;  ; C-c C-v f c e
  ;; =&gt; #'babashka.http-server/dir
  
  &#40;def server
    &#40;server/run-server &#40;file-router dir {}&#41;
                       {:dir dir, :port 1341}&#41;&#41;
  ;; =&gt; #'babashka.http-server/server

  &#41;
</code></pre><p>OK, so we maybe have a webserver running. Let's try fetching a file to be sure:</p><pre class="language-text"><code class="lang-text language-text">: jmglov@alhana; curl http://localhost:1341/site.webmanifest
{
    &quot;name&quot;: &quot;Soundcljoud&quot;,
    &quot;short&#95;name&quot;: &quot;Soundcljoud&quot;,
    &quot;icons&quot;: &#91;
        {
            &quot;src&quot;: &quot;icons/android-chrome-192x192.png&quot;,
            &quot;sizes&quot;: &quot;192x192&quot;,
            &quot;type&quot;: &quot;image/png&quot;
        },
        {
            &quot;src&quot;: &quot;icons/android-chrome-512x512.png&quot;,
            &quot;sizes&quot;: &quot;512x512&quot;,
            &quot;type&quot;: &quot;image/png&quot;
        }
    &#93;,
    &quot;theme&#95;color&quot;: &quot;#ffffff&quot;,
    &quot;background&#95;color&quot;: &quot;#ffffff&quot;,
    &quot;display&quot;: &quot;standalone&quot;
}
</code></pre><p>Looks good!</p><h2 id="hacking_the_cloud">Hacking the cloud</h2><p>The next step is making Soundcljoud use our local HTTP server instead of starting a new one. Back in <code>soundcljoud/player</code>, we open up <code>bb.edn</code>. Let's go ahead and change the deps first:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {io.github.babashka/sci.nrepl
        {:git/sha &quot;2f8a9ed2d39a1b09d2b4d34d95494b56468f4a23&quot;}
        io.github.babashka/http-server
        {:git/sha &quot;b38c1f16ad2c618adae2c3b102a5520c261a7dd3&quot;}}
 ;; ...
 }
</code></pre><p>For the <code>io.github.babashka/http-server</code> dep, we can change the value from a Git reference to a local directory like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {io.github.babashka/sci.nrepl
        {:git/sha &quot;2f8a9ed2d39a1b09d2b4d34d95494b56468f4a23&quot;}
        io.github.babashka/http-server
        {:local/root &quot;../../http-server&quot;}}
 ;; ...
 }
</code></pre><p>Next, we'll need to figure out how to start just the browser REPL. Let's take a look at the existing <code>dev</code> task that we've been using:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ;; ...
 :tasks {http-server
         {:doc &quot;Starts http server for serving static files&quot;
          :requires &#40;&#91;babashka.http-server :as http&#93;&#41;
          :task &#40;do
                  &#40;http/serve {:port 1341 :dir &quot;public&quot;}&#41;
                  &#40;println &quot;Serving static assets at http://localhost:1341&quot;&#41;&#41;}

         browser-nrepl
         {:doc &quot;Start browser nREPL&quot;
          :requires &#40;&#91;sci.nrepl.browser-server :as bp&#93;&#41;
          :task &#40;bp/start! {}&#41;}

         -dev
         {:depends &#91;http-server browser-nrepl&#93;}

         dev
         {:task &#40;do &#40;run '-dev {:parallel true}&#41;
                  &#40;deref &#40;promise&#41;&#41;&#41;}}}
</code></pre><p>So <code>dev</code> just runs the <code>-dev</code> task in parallel, then derefs an empty promise to avoid exiting (calling <code>deref</code> on a promise will block the calling thread until the promise delivers, which an empty promise never will). The <code>-dev</code> task itself depends on <code>http-server</code> and <code>browser-nrepl</code>, but does nothing on its own.</p><p>Let's create a new task that follows this pattern but only starts the browser NREPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ;; ...
 :tasks { ;; ...

         dev
         {:task &#40;do &#40;run '-dev {:parallel true}&#41;
                  &#40;deref &#40;promise&#41;&#41;&#41;}

         browser
         {:task &#40;do &#40;run 'browser-nrepl {:parallel true}&#41;
                    &#40;deref &#40;promise&#41;&#41;&#41;}}}
</code></pre><p>Now let's fire it up and see what happens:</p><pre class="language-text"><code class="lang-text language-text">: jmglov@alhana; bb browser
nREPL server started on port 1339...
Websocket server started on 1340...
</code></pre><p>Cool! If we now open http://localhost:1341/ in the browser, switch back to our open <code>soundcljoud.cljs</code> buffer and hit <strong>C-c l C</strong>, we see some very welcome log messages in our terminal:</p><pre class="language-text"><code class="lang-text language-text">nREPL server started on port 1339...
:msg &quot;{:versions {\&quot;scittle-nrepl\&quot; {\&quot;major\&quot; \&quot;0\&quot;, ...&quot;
</code></pre><p>With baited breath, we evaluate the <code>load-ui!</code> form and... see the good ol' Eye of Garth! 🎉</p><p>This means Soundcljoud is using the http-server we're running from our REPL.</p><h2 id="homing_in_on_the_range">Homing in on the range</h2><p>Switching back to the <code>http-server/src/babashka/http&#95;server.clj</code> buffer, let's figure out how to do some REPL-driven development to implement handling range requests.</p><p>The first order of business might be giving ourselves a way to log the requests we're getting from the client. Let's create an atom at the top of the file for this very purpose:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defonce state &#40;atom {:requests &#91;&#93;, :log &#91;&#93;}&#41;&#41;
</code></pre><p>I'm using <a href='https://clojuredocs.org/clojure.core/defonce'>defonce</a> instead of plain 'ol <code>def</code> here because I tend to hit <strong>C-c C-k</strong> quite often whilst editing code, which not only causes the buffer to be re-evaluated, but also causes Emacs to ask me if I want to save my changes to the file, which is useful to keep code that's running in the system from drifting away from the code that's written in the source file. If I used <code>def</code> instead of <code>defonce</code>, my state atom would be reset every time I re-evaluate the buffer.</p><p>Now, we know that the function returned by <code>file-router</code> is a Ring handler, so let's jump there and see about how we can shove each request into our <code>state</code> atom:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn file-router &#91;dir headers&#93;
  &#40;fn &#91;{:keys &#91;uri&#93;}&#93;
    ;; ...
    &#41;&#41;
</code></pre><p>OK, at the moment, the handler function only cares about the <code>:uri</code> key in the request. Let's bind the entire request and then add it to the atom:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn file-router &#91;dir headers&#93;
  &#40;fn &#91;{:keys &#91;uri&#93; :as req}&#93;
    &#40;swap! state update :requests conj req&#41;
    ;; ...
    &#41;&#41;
</code></pre><p>In order to test this, we need to restart the server since we made a change to the anonymous function returned by <code>file-router</code>. To do this, we stop the server by calling the function that <code>server/run-server</code> returned when we evaluated it, then evaluate the <code>server/run-server</code> expression again:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;server&#41;
  ;; =&gt; nil

  &#40;def server
    &#40;server/run-server &#40;file-router dir {}&#41;
                       {:dir dir, :port 1341}&#41;&#41;
  ;; =&gt; #'babashka.http-server/server

  &#41;
</code></pre><p>Now, let's curl the manifest file again:</p><pre class="language-text"><code class="lang-text language-text">: jmglov@alhana; curl http://localhost:1341/site.webmanifest
{
    &quot;name&quot;: &quot;Soundcljoud&quot;,
    ...
}
</code></pre><p>If we look at our state atom now, we can see that the request was successfully logged:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;:requests @state&#41;
  ;; =&gt; &#91;{:remote-addr &quot;0:0:0:0:0:0:0:1&quot;,
  ;;      :start-time 1004192760289113,
  ;;      :headers
  ;;      {&quot;accept&quot; &quot;&#42;/&#42;&quot;, &quot;host&quot; &quot;localhost:1341&quot;, &quot;user-agent&quot; &quot;curl/8.4.0&quot;},
  ;;      :async-channel
  ;;      #object&#91;org.httpkit.server.AsyncChannel 0x44d028e7 &quot;/&#91;0:0:0:0:0:0:0:1&#93;:1341&lt;-&gt;/&#91;0:0:0:0:0:0:0:1&#93;:45890&quot;&#93;,
  ;;      :server-port 1341,
  ;;      :content-length 0,
  ;;      :websocket? false,
  ;;      :content-type nil,
  ;;      :character-encoding &quot;utf8&quot;,
  ;;      :uri &quot;/site.webmanifest&quot;,
  ;;      :server-name &quot;localhost&quot;,
  ;;      :query-string nil,
  ;;      :body nil,
  ;;      :scheme :http,
  ;;      :request-method :get}&#93;

  &#41;
</code></pre><p>OK, now that we've got some basic logging in place, let's get back to thinking about range requests. A good place to start is by looking at the requests we get from Soundcljoud when it loads a file, so let's pop back over to that browser window and click on a track.</p><p>Once we've done that, we can look at the request in our http-server REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; @state
       :requests
       &#40;map #&#40;select-keys % &#91;:start-time :headers :uri&#93;&#41;&#41;
       last&#41;
  ;; =&gt; {:start-time 1006716878994472,
  ;;     :headers
  ;;     {&quot;range&quot; &quot;bytes=0-&quot;,
  ;;      &quot;sec-fetch-site&quot; &quot;same-origin&quot;,
  ;;      &quot;sec-ch-ua-mobile&quot; &quot;?0&quot;,
  ;;      &quot;host&quot; &quot;localhost:1341&quot;,
  ;;      &quot;user-agent&quot;
  ;;      &quot;Mozilla/5.0 &#40;X11; Linux x86&#95;64&#41; AppleWebKit/537.36 &#40;KHTML, like Gecko&#41; Chrome/120.0.0.0 Safari/537.36&quot;,
  ;;      &quot;sec-ch-ua&quot;
  ;;      &quot;\&quot;Not&#95;A Brand\&quot;;v=\&quot;8\&quot;, \&quot;Chromium\&quot;;v=\&quot;120\&quot;, \&quot;Brave\&quot;;v=\&quot;120\&quot;&quot;,
  ;;      &quot;sec-ch-ua-platform&quot; &quot;\&quot;Linux\&quot;&quot;,
  ;;      &quot;referer&quot; &quot;http://localhost:1341/&quot;,
  ;;      &quot;connection&quot; &quot;keep-alive&quot;,
  ;;      &quot;accept&quot; &quot;&#42;/&#42;&quot;,
  ;;      &quot;accept-language&quot; &quot;en-GB,en&quot;,
  ;;      &quot;sec-fetch-dest&quot; &quot;audio&quot;,
  ;;      &quot;accept-encoding&quot; &quot;identity;q=1, &#42;;q=0&quot;,
  ;;      &quot;sec-fetch-mode&quot; &quot;no-cors&quot;,
  ;;      &quot;sec-gpc&quot; &quot;1&quot;},
  ;;     :uri
  ;;     &quot;/Garth%20Brooks/Fresh%20Horses/01%20-%20Garth%20Brooks%20-%20The%20Old%20Stuff.mp3&quot;}

  &#41;
</code></pre><p>The interesting bit is this header right here, which is the thing that tells us that what we're dealing with here is a range request:</p><pre class="language-text"><code class="lang-text language-text">  ;;     :headers
  ;;     {&quot;range&quot; &quot;bytes=0-&quot;,
</code></pre><p>Remember those 5 questions we asked back in <code>file-router</code>?</p><ol><li>Does the path refer to a directory? If so, does there exist an <code>index.html</code>   that is readable by the webserver?</li><li>Otherwise, does the path refer to a directory (without an <code>index.html</code>)?</li><li>Otherwise, does the path refer to a file that is readable by the webserver?</li><li>Otherwise, does the path refer to a thing which, if we slap a <code>.html</code>   extension on the end, is a file that is readable by the webserver?</li><li>Why is this user wasting our time requesting stuff that we don't have?</li></ol><p>Well, let's insert a new question in there as #3, and bump the rest down:</p><ol><li>Does the path refer to a directory? If so, does there exist an <code>index.html</code>   that is readable by the webserver?</li><li>Otherwise, does the path refer to a directory (without an <code>index.html</code>)?</li><li><strong>Otherwise, does the path refer to a file that is readable by the webserver and we have a <code>range</code> header in our request?</strong></li><li>Otherwise, does the path refer to a file that is readable by the webserver?</li><li>Otherwise, does the path refer to a thing which, if we slap a <code>.html</code>   extension on the end, is a file that is readable by the webserver?</li><li>Why is this user wasting our time requesting stuff that we don't have?</li></ol><p>Let's write that in Clojure instead of English:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn file-router &#91;dir headers&#93;
  &#40;fn &#91;{:keys &#91;uri&#93; :as req}&#93;
    &#40;swap! state update :requests conj req&#41;
    &#40;let &#91;f &#40;fs/path dir &#40;str/replace-first &#40;URLDecoder/decode uri&#41; #&quot;&#94;/&quot; &quot;&quot;&#41;&#41;
          index-file &#40;fs/path f &quot;index.html&quot;&#41;&#93;
      &#40;update &#40;cond
                &#40;and &#40;fs/directory? f&#41; &#40;fs/readable? index-file&#41;&#41;
                &#40;body index-file&#41;

                &#40;fs/directory? f&#41;
                &#40;index dir f&#41;

                ;; 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇
                &#40;and &#40;fs/readable? f&#41; &#40;contains? &#40;:headers req&#41; &quot;range&quot;&#41;&#41;
                &#40;do
                  &#40;swap! state update :log conj &quot;Handling range request&quot;&#41;
                  &#40;body f&#41;&#41;
                ;; 👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆👆

                &#40;fs/readable? f&#41;
                &#40;body f&#41;

                &#40;and &#40;nil? &#40;fs/extension f&#41;&#41; &#40;fs/readable? &#40;with-ext f &quot;.html&quot;&#41;&#41;&#41;
                &#40;body &#40;with-ext f &quot;.html&quot;&#41; headers&#41;

                :else
                {:status 404 :body &#40;str &quot;Not found `&quot; f &quot;` in &quot; dir&#41;}&#41;
              :headers &#40;fn &#91;response-headers&#93;
                         &#40;merge headers response-headers&#41;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Now we can try this out. Unfortunately, we need to restart the server again to have it pick up the new code. The issue is that <code>file-handler</code> is returning an anonymous function, so when we edit the code and re-evaluate the buffer, we're not updating the copy of the function that http-kit is using as the request handler, we're updating <code>file-handler</code> itself, so the next time it's called, it will return a new handler function. In the writing of this blog, I did try pulling the anonymous function out and giving it a name, which I expected to fix this issue, but that didn't work, for reasons that aren't clear to me (maybe because http-kit is running the server on a different thread?). Yell at me in the Clojurians Slack thread if you know how to do this. 😅</p><p>Anyway, let's stop the server as usual:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;server&#41;
  ;; =&gt; nil

  &#41;
</code></pre><p>And now, since we know we're going to need to do this dance every time we make changes to the code, let's write a little convenience function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;defn restart-server &#91;&#93;
    &#40;when &#40;:server @state&#41;
      &#40;&#40;:server @state&#41;&#41;&#41;
    &#40;reset! state
            {:requests &#91;&#93;
             :log &#91;&#93;
             :server
             &#40;server/run-server &#40;file-router dir {}&#41;
                                {:dir dir, :port 1341}&#41;}&#41;&#41;
  ;; =&gt; #'babashka.http-server/restart-server

  &#41;
</code></pre><p>Now we can just call <code>restart-server</code> whenever we need to, well, restart the server. Let's do so now:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;restart-server&#41;
  ;; =&gt; {:requests &#91;&#93;,
  ;;     :server
  ;;     #object&#91;clojure.lang.AFunction$1 0x3ece031a &quot;clojure.lang.AFunction$1@3ece031a&quot;&#93;}

  &#41;
</code></pre><p>Having done this, let's pop back over to Soundcljoud and click on another track, then inspect the log to make sure we see the message we expect:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; @state
       :log
       last&#41;
  ;; =&gt; &quot;Handling range request&quot;

  &#41;
</code></pre><p>Looks good! Except for the fact that we're still returning the entire file in the request body, of course. Still, the key to REPL-driven development is rapidly iterating, so let's take that next iteration now!</p><h2 id="what%27s_in_a_range%3F">What's in a range?</h2><p>Since we've captured the request, let's go ahead and pull the range header out so we can play with it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; &#40;:requests @state&#41; last &#40;get-in &#91;:headers &quot;range&quot;&#93;&#41;&#41;
  ;; =&gt; &quot;bytes=0-&quot;

  &#40;def range-header &#42;1&#41;
  ;; =&gt; #'babashka.http-server/range-header

  &#40;let &#91;&#91;start end&#93; &#40;-&gt; range-header
                        &#40;str/replace #&quot;&#94;bytes=&quot; &quot;&quot;&#41;
                        &#40;str/split #&quot;-&quot;&#41;&#41;&#93;
    &#91;start end&#93;&#41;
  ;; =&gt; &#91;&quot;0&quot; nil&#93;

  &#41;
</code></pre><p>The header parsing thing looks like a good thing to make into a function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- parse-range-header &#91;range-header&#93;
  &#40;map #&#40;when % &#40;Long/parseLong %&#41;&#41;
       &#40;-&gt; range-header
           &#40;str/replace #&quot;&#94;bytes=&quot; &quot;&quot;&#41;
           &#40;str/split #&quot;-&quot;&#41;&#41;&#41;&#41;

&#40;comment

  &#40;parse-range-header range-header&#41;
  ;; =&gt; &#40;0&#41;

&#41;
</code></pre><p>OK, now let's shift gears and figure out how to return a specific byte range from a file. After much searching, I found a magical way to seek to an arbitrary location in a file in Java (and hence Clojure, through the magic of interop). Every <a href='https://docs.oracle.com/javase/8/docs/api/java/io/FileInputStream.html'>FileInputStream</a> has an associated <a href='https://docs.oracle.com/javase/8/docs/api/java/nio/channels/FileChannel.html'>FileChannel</a>, and this FileChannel has a helpful <a href='https://docs.oracle.com/javase/8/docs/api/java/nio/channels/FileChannel.html#position-long-'>position()</a> instance method, which sets the position in the FileChannel for subsequent read operations on the channel.</p><p>Now, how to perform a read operation on a FileInputStream? Looking at the documentation, this method looks quite useful:</p><p><a href='https://docs.oracle.com/javase/8/docs/api/java/io/FileInputStream.html#read-byte:A-'><strong>read</strong></a></p><pre class="language-java"><code class="lang-java language-java">public int read&#40;byte&#91;&#93; b&#41;
         throws IOException
</code></pre><blockquote><p> Reads up to b.length bytes of data from this input stream into an array of  bytes. This method blocks until some input is available. </p></blockquote><p>And how do we create a <code>byte&#91;&#93;</code> array of an arbitrary size in Clojure? Why, by using the aptly-named <a href='https://clojuredocs.org/clojure.core/byte-array'>byte-array</a> function, naturally! 😀</p><p>Let's try this out, using our helpful <code>site.webmanifest</code> file:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;arr &#40;byte-array 32&#41;&#93;
    &#40;with-open &#91;is &#40;java.io.FileInputStream. manifest-file&#41;&#93;
      &#40;-&gt; is .getChannel &#40;.position 0&#41;&#41;
      &#40;.read is arr&#41;&#41;
    &#40;String. arr&#41;&#41;
  ;; =&gt; &quot;{\n    \&quot;name\&quot;: \&quot;Soundcljoud\&quot;,\n   &quot;

  &#40;let &#91;arr &#40;byte-array 16&#41;&#93;
    &#40;with-open &#91;is &#40;java.io.FileInputStream. manifest-file&#41;&#93;
      &#40;-&gt; is .getChannel &#40;.position 14&#41;&#41;
      &#40;.read is arr&#41;&#41;
    &#40;String. arr&#41;&#41;
  ;; =&gt; &quot;\&quot;Soundcljoud\&quot;,\n &quot;

  &#41;
</code></pre><p>Now we're cooking with gas! 💥</p><p>Let's see if we can make a nice function out of this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- read-bytes &#91;f &#91;start end&#93;&#93;
  &#40;let &#91;arr &#40;byte-array &#40;- end start&#41;&#41;&#93;
    &#40;with-open &#91;is &#40;java.io.FileInputStream. f&#41;&#93;
      &#40;-&gt; is .getChannel &#40;.position start&#41;&#41;
      &#40;.read is arr&#41;&#41;
    arr&#41;&#41;

&#40;comment

  &#40;-&gt; &#40;read-bytes manifest-file &#91;0 31&#93;&#41;
      &#40;String.&#41;&#41;
  ;; =&gt; &quot;{\n    \&quot;name\&quot;: \&quot;Soundcljoud\&quot;,\n   &quot;

  &#40;-&gt; &#40;read-bytes manifest-file &#91;14 29&#93;&#41;
      &#40;String.&#41;&#41;
  ;; =&gt; &quot;\&quot;Soundcljoud\&quot;,\n &quot;

  &#41;
</code></pre><p>There's one issue remaining, though. Remember the range header we got from Soundcljoud?</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  range-header
  ;; =&gt; &quot;bytes=0-&quot;

  &#40;parse-range-header range-header&#41;
  ;; =&gt; &#40;0&#41;

&#41;
</code></pre><p>We have a <code>start</code>, but not an <code>end</code>. 😱</p><p>Let's think about what we want to do in this case. The client is effectively saying, "give me as many bytes as you feel inclined to do, starting at this offset in the file". So how many bytes are we inclined to hand out willy-nilly? I dunno, how about 1 mega of them bytes?</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- read-bytes &#91;f &#91;start end&#93;&#93;
  &#40;let &#91;end &#40;or end &#40;dec &#40;+ start &#40;&#42; 1024 1024&#41;&#41;&#41;
        arr &#40;byte-array &#40;- end start&#41;&#41;&#93;
    &#40;with-open &#91;is &#40;java.io.FileInputStream. f&#41;&#93;
      &#40;-&gt; is .getChannel &#40;.position start&#41;&#41;
      &#40;.read is arr&#41;&#41;
    arr&#41;&#41;

&#40;comment

  &#40;-&gt; &#40;read-bytes manifest-file &#91;0 31&#93;&#41;
      &#40;String.&#41;&#41;  ; ⚠ OMG wait don't evaluate this for the love of Pete!

&#41;
</code></pre><p>Yeah, so you really don't want to evaluate that last <code>read-bytes</code> expression. "And why's that," you might ask? "Well," I might answer, "cast your mind back to the Java documentation":</p><p><a href='https://docs.oracle.com/javase/8/docs/api/java/io/FileInputStream.html#read-byte:A-'><strong>read</strong></a></p><pre class="language-java"><code class="lang-java language-java">public int read&#40;byte&#91;&#93; b&#41;
         throws IOException
</code></pre><blockquote><p> Reads up to b.length bytes of data from this input stream into an array of  bytes. 👉 <strong>This method blocks until some input is available.</strong> 👈 </p></blockquote><p>"And how do you know this is a problem?" you might query. "Well," I might respond, "um, just 'cuz? I mean... I certainly didn't evaluate this and hang my REPL process and then have to forcibly kill Emacs or anything, because that would be a rookie mistake. Haha." And then I might laugh nervously and quickly change the subject. "So, how 'bout them Yankees?" I might mutter, maybe even looking at my shoes.</p><p>So blerg, what to do, what to do?</p><p>Well, we do know (or at least <strong>can</strong> know) how many bytes are in the file, so maybe we don't read past the end of the file? Amazing insights you get in this here blog, innit?</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- read-bytes &#91;f &#91;start end&#93;&#93;
  &#40;let &#91;end &#40;or end &#40;dec &#40;min &#40;fs/size f&#41;
                              &#40;+ start &#40;&#42; 1024 1024&#41;&#41;&#41;&#41;&#41;
        arr &#40;byte-array &#40;- end start&#41;&#41;&#93;
    &#40;with-open &#91;is &#40;java.io.FileInputStream. f&#41;&#93;
      &#40;-&gt; is .getChannel &#40;.position start&#41;&#41;
      &#40;.read is arr&#41;&#41;
    arr&#41;&#41;

&#40;comment

  &#40;let &#91;f manifest-file
        end nil
        end &#40;or end &#40;dec &#40;min &#40;fs/size f&#41; &#40;&#42; 1024 1024&#41;&#41;&#41;&#41;&#93;
    end&#41;
  ;; =&gt; 457

  ;; Should be safe to do this... 🙈

  &#40;-&gt; &#40;read-bytes manifest-file &#91;0 31&#93;&#41;
      &#40;String.&#41;&#41;
  ;; =&gt; &quot;{\n    \&quot;name\&quot;: \&quot;Soundcljoud\&quot;,\n   &quot;

  &#40;-&gt; &#40;read-bytes manifest-file &#91;14 29&#93;&#41;
      &#40;String.&#41;&#41;
  ;; =&gt; &quot;\&quot;Soundcljoud\&quot;,\n &quot;

  ;; Never in doubt... 😌

  &#41;
</code></pre><p>OK, we're making some progress here. In fact, it seems that we have most of the pieces we'll need to actually fulfil a range request, so let's see about sticking them together in a reasonable way.</p><h2 id="how_do_you_respond%3F">How do you respond?</h2><p>Let's review what the response to a range request is supposed to look like:</p><pre class="language-text"><code class="lang-text language-text">HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-Length: 1048576
Content-Range: bytes 0-1048575/5943424
Content-Type: audio/mpeg
</code></pre><p>At the moment, we're just using the <code>body</code> function to respond to range requests:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn file-router &#91;dir headers&#93;
  ;; ...
              &#40;cond
                ;; ...
                &#40;and &#40;fs/readable? f&#41; &#40;contains? &#40;:headers req&#41; &quot;range&quot;&#41;&#41;
                &#40;do
                  &#40;swap! state update :log conj &quot;Handling range request&quot;&#41;
                  &#40;body f&#41;&#41;
                ;; ...
              &#41;
 ;; ...
 &#41;
</code></pre><p>And <code>body</code> just chucks the file into a map with some headers:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- body
  &#40;&#91;path&#93;
   &#40;body path {}&#41;&#41;
  &#40;&#91;path headers&#93;
   {:headers &#40;merge {&quot;Content-Type&quot; &#40;ext-mime-type &#40;fs/file-name path&#41;&#41;} headers&#41;
    :body &#40;fs/file path&#41;}&#41;&#41;
</code></pre><p>Let's follow suit. Since http-kit is so magical and wonderful, we'll go out on a limb and make the assumption that if we just stuff our byte array into the response body, http-kit will do The Right Thing™.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- byte-range
  &#40;&#91;path request-headers&#93;
   &#40;byte-range path request-headers {}&#41;&#41;
  &#40;&#91;path request-headers response-headers&#93;
   &#40;let &#91;f &#40;fs/file path&#41;
         &#91;start end
          :as requested-range&#93; &#40;parse-range-header &#40;request-headers &quot;range&quot;&#41;&#41;
         arr &#40;read-bytes f requested-range&#41;
         num-bytes-read &#40;count arr&#41;&#93;
     {:status 206
      :headers &#40;merge {&quot;Content-Type&quot; &#40;ext-mime-type &#40;fs/file-name path&#41;&#41;
                       &quot;Accept-Ranges&quot; &quot;bytes&quot;
                       &quot;Content-Length&quot; num-bytes-read
                       &quot;Content-Range&quot; &#40;format &quot;bytes %d-%d/%d&quot;
                                               start
                                               &#40;+ start num-bytes-read&#41;
                                               &#40;fs/size f&#41;&#41;}
                      response-headers&#41;
      :body arr}&#41;&#41;&#41;

&#40;comment

  &#40;byte-range manifest-file {&quot;range&quot; &quot;bytes=0-&quot;}&#41;
  ;; =&gt; {:status 206,
  ;;     :headers
  ;;     {&quot;Content-Type&quot; nil,
  ;;      &quot;Accept-Ranges&quot; &quot;bytes&quot;,
  ;;      &quot;Content-Length&quot; 458,
  ;;      &quot;Content-Range&quot; &quot;bytes 0-457/458&quot;},
  ;;     :body
  ;;     &#91;123, 10, 32, 32, 32, 32, 34, 110, 97, 109, 101, 34, 58, 32, 34, 83, 111, 117,
  ;;      110, 100, 99, 108, 106, 111, 117, 100, 34, 44, 10, 32, 32, 32, 32, 34, 115,
  ;;      104, 111, 114, 116, 95, 110, 97, 109, 101, 34, 58, 32, 34, 83, 111, 117, 110,
  ;;      100, 99, 108, 106, 111, 117, 100, 34, 44, 10, 32, 32, 32, 32, 34, 105, 99,
  ;;      111, 110, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10,
  ;;      32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 115, 114, 99, 34, 58, 32,
  ;;      34, 105, 99, 111, 110, 115, 47, 97, 110, 100, 114, 111, 105, 100, 45, 99,
  ;;      104, 114, 111, 109, 101, 45, 49, 57, 50, 120, 49, 57, 50, 46, 112, 110, 103,
  ;;      34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 115, 105,
  ;;      122, 101, 115, 34, 58, 32, 34, 49, 57, 50, 120, 49, 57, 50, 34, 44, 10, 32,
  ;;      32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 116, 121, 112, 101, 34, 58,
  ;;      32, 34, 105, 109, 97, 103, 101, 47, 112, 110, 103, 34, 10, 32, 32, 32, 32,
  ;;      32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32,
  ;;      32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 115, 114, 99, 34, 58, 32, 34,
  ;;      105, 99, 111, 110, 115, 47, 97, 110, 100, 114, 111, 105, 100, 45, 99, 104,
  ;;      114, 111, 109, 101, 45, 53, 49, 50, 120, 53, 49, 50, 46, 112, 110, 103, 34,
  ;;      44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 115, 105, 122,
  ;;      101, 115, 34, 58, 32, 34, 53, 49, 50, 120, 53, 49, 50, 34, 44, 10, 32, 32,
  ;;      32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 116, 121, 112, 101, 34, 58, 32,
  ;;      34, 105, 109, 97, 103, 101, 47, 112, 110, 103, 34, 10, 32, 32, 32, 32, 32,
  ;;      32, 32, 32, 125, 10, 32, 32, 32, 32, 93, 44, 10, 32, 32, 32, 32, 34, 116,
  ;;      104, 101, 109, 101, 95, 99, 111, 108, 111, 114, 34, 58, 32, 34, 35, 102, 102,
  ;;      102, 102, 102, 102, 34, 44, 10, 32, 32, 32, 32, 34, 98, 97, 99, 107, 103,
  ;;      114, 111, 117, 110, 100, 95, 99, 111, 108, 111, 114, 34, 58, 32, 34, 35, 102,
  ;;      102, 102, 102, 102, 102, 34, 44, 10, 32, 32, 32, 32, 34, 100, 105, 115, 112,
  ;;      108, 97, 121, 34, 58, 32, 34, 115, 116, 97, 110, 100, 97, 108, 111, 110, 101,
  ;;      34, 10, 125, 10&#93;}

  &#41;
</code></pre><p>That looks fairly reasonable. Let's now complete the plumbing so when we turn on the tap of range requests, we get a delicious stream of ice cold, alpine spring fed responses flowing back:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn file-router &#91;dir headers&#93;
  ;; ...
              &#40;cond
                ;; ...
                &#40;and &#40;fs/readable? f&#41; &#40;contains? &#40;:headers req&#41; &quot;range&quot;&#41;&#41;
                &#40;do
                  &#40;swap! state update :log conj &quot;Handling range request&quot;&#41;
                  &#40;byte-range f &#40;:headers req&#41;&#41;&#41;
                ;; ...
              &#41;
 ;; ...
 &#41;

&#40;comment

  &#40;&#40;file-router dir {}&#41; {:headers {&quot;range&quot; &quot;bytes=0-&quot;}
                         :uri &quot;/site.webmanifest&quot;}&#41;
  ;; =&gt; {:status 206,
  ;;     :headers
  ;;     {&quot;Content-Type&quot; nil,
  ;;      &quot;Accept-Ranges&quot; &quot;bytes&quot;,
  ;;      &quot;Content-Length&quot; 458,
  ;;      &quot;Content-Range&quot; &quot;bytes 0-457/458&quot;},
  ;;     :body
  ;;     &#91;123, 10, 32, 32, 32, 32, 34, 110, 97, 109, 101, 34, 58, 32, 34, 83, 111, 117,
  ;;      110, 100, 99, 108, 106, 111, 117, 100, 34, 44, 10, 32, 32, 32, 32, 34, 115,
  ;;      104, 111, 114, 116, 95, 110, 97, 109, 101, 34, 58, 32, 34, 83, 111, 117, 110,
  ;;      100, 99, 108, 106, 111, 117, 100, 34, 44, 10, 32, 32, 32, 32, 34, 105, 99,
  ;;      111, 110, 115, 34, 58, 32, 91, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10,
  ;;      32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 115, 114, 99, 34, 58, 32,
  ;;      34, 105, 99, 111, 110, 115, 47, 97, 110, 100, 114, 111, 105, 100, 45, 99,
  ;;      104, 114, 111, 109, 101, 45, 49, 57, 50, 120, 49, 57, 50, 46, 112, 110, 103,
  ;;      34, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 115, 105,
  ;;      122, 101, 115, 34, 58, 32, 34, 49, 57, 50, 120, 49, 57, 50, 34, 44, 10, 32,
  ;;      32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 116, 121, 112, 101, 34, 58,
  ;;      32, 34, 105, 109, 97, 103, 101, 47, 112, 110, 103, 34, 10, 32, 32, 32, 32,
  ;;      32, 32, 32, 32, 125, 44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 123, 10, 32, 32,
  ;;      32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 115, 114, 99, 34, 58, 32, 34,
  ;;      105, 99, 111, 110, 115, 47, 97, 110, 100, 114, 111, 105, 100, 45, 99, 104,
  ;;      114, 111, 109, 101, 45, 53, 49, 50, 120, 53, 49, 50, 46, 112, 110, 103, 34,
  ;;      44, 10, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 115, 105, 122,
  ;;      101, 115, 34, 58, 32, 34, 53, 49, 50, 120, 53, 49, 50, 34, 44, 10, 32, 32,
  ;;      32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 34, 116, 121, 112, 101, 34, 58, 32,
  ;;      34, 105, 109, 97, 103, 101, 47, 112, 110, 103, 34, 10, 32, 32, 32, 32, 32,
  ;;      32, 32, 32, 125, 10, 32, 32, 32, 32, 93, 44, 10, 32, 32, 32, 32, 34, 116,
  ;;      104, 101, 109, 101, 95, 99, 111, 108, 111, 114, 34, 58, 32, 34, 35, 102, 102,
  ;;      102, 102, 102, 102, 34, 44, 10, 32, 32, 32, 32, 34, 98, 97, 99, 107, 103,
  ;;      114, 111, 117, 110, 100, 95, 99, 111, 108, 111, 114, 34, 58, 32, 34, 35, 102,
  ;;      102, 102, 102, 102, 102, 34, 44, 10, 32, 32, 32, 32, 34, 100, 105, 115, 112,
  ;;      108, 97, 121, 34, 58, 32, 34, 115, 116, 97, 110, 100, 97, 108, 111, 110, 101,
  ;;      34, 10, 125, 10&#93;}

  &#41;
</code></pre><p>Looks great... except for the <code>Content-Type: nil</code> bit, since our server has no clue what a <code>.webmanifest</code> extension portends, but who cares about such trivial details, since we're not gonna be getting range requests for non-media files anyway. Plus, a standard request for that file does the same thing:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;&#40;file-router dir {}&#41; {:headers {}
                         :uri &quot;/site.webmanifest&quot;}&#41;
  ;; =&gt; {:headers {&quot;Content-Type&quot; nil},
  ;;     :body
  ;;     #object&#91;java.io.File 0x659969c9 &quot;../soundcljoud/player/public/site.webmanifest&quot;&#93;}

  &#41;
</code></pre><p>🤷</p><p>Before we break out the 🍾 though, let's try this in the wild. And before we try this in the wild, it probably behoves us—at least, I feel rather behoved, and it's my blog, so I'm going to follow this deep sense of behoval where it leads—to log responses as well as requests, so let's make one last minor change to good 'ol <code>file-router</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn file-router &#91;dir headers&#93;
  &#40;fn &#91;{:keys &#91;uri&#93; :as req}&#93;
    ;; 👉 Move the state swappage from here...
    &#40;let &#91;f &#40;fs/path dir &#40;str/replace-first &#40;URLDecoder/decode uri&#41; #&quot;&#94;/&quot; &quot;&quot;&#41;&#41;
          index-file &#40;fs/path f &quot;index.html&quot;&#41;
          res
          &#40;update &#40;cond
                    &#40;and &#40;fs/directory? f&#41; &#40;fs/readable? index-file&#41;&#41;
                    &#40;body index-file&#41;

                    &#40;fs/directory? f&#41;
                    &#40;index dir f&#41;

                    &#40;and &#40;fs/readable? f&#41; &#40;contains? &#40;:headers req&#41; &quot;range&quot;&#41;&#41;
                    &#40;do
                      &#40;swap! state update :log conj &quot;Handling range request&quot;&#41;
                      &#40;byte-range f &#40;:headers req&#41;&#41;&#41;

                    &#40;fs/readable? f&#41;
                    &#40;body f&#41;

                    &#40;and &#40;nil? &#40;fs/extension f&#41;&#41; &#40;fs/readable? &#40;with-ext f &quot;.html&quot;&#41;&#41;&#41;
                    &#40;body &#40;with-ext f &quot;.html&quot;&#41; headers&#41;

                    :else
                    {:status 404 :body &#40;str &quot;Not found `&quot; f &quot;` in &quot; dir&#41;}&#41;
                  :headers &#40;fn &#91;response-headers&#93;
                             &#40;merge headers response-headers&#41;&#41;&#41;&#93;
      ;; ...to here 👇
      &#40;swap! state
             update :requests
             conj {:request req, :response &#40;dissoc res :body&#41;}&#41;
      res&#41;&#41;&#41;
</code></pre><h2 id="i%27m_the_one_that_put_the_range_in_the_rover">I'm the one that put the Range in the Rover</h2><p>Casting our minds back to <a href='2024-07-20-soundcljoud-cloudy.html#is_this_the_end%3F'>the last post in this potentially infinite sequence
of posts</a>, we recall that Soundcljoud was unable to seek in the audio file. Let's repeat this experience by jumping over to <code>soundcljoud/player/public/soundcljoud.cljs</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; &#40;get-el &quot;audio&quot;&#41;
      &#40;.-seekable&#41;
      &#40;.-length&#41;&#41;
  ;; =&gt; 1

  &#40;let &#91;s &#40;-&gt; &#40;get-el &quot;audio&quot;&#41;
              &#40;.-seekable&#41;&#41;&#93;
    &#91;&#40;.start s 0&#41; &#40;.end s 0&#41;&#93;&#41;
  ;; =&gt; &#91;0 0&#93;
  
  &#41;
</code></pre><p>This is what we expected, since we haven't restarted the server to apply our changes. Let's do that now (back in our http-server REPL):</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;restart-server&#41;
  ;; =&gt; {:requests &#91;&#93;,
  ;;     :log &#91;&#93;,
  ;;     :server
  ;;     #object&#91;clojure.lang.AFunction$1 0x2d75d828 &quot;clojure.lang.AFunction$1@2d75d828&quot;&#93;}

  &#41;
</code></pre><p>Now we can click on another track in Soundcljoud and see what happens. 😬</p><p><img src="assets/2024-08-13-soundcljoud-rangey-hope.png" alt="Clicking on a track in the Soundcljoud UI" title="It's the hope that kills" width=800 border=1 /></p><p>OK, nothing blew up. Let's look at the request in the http-server logs:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;:requests @state&#41;
       &#40;filter #&#40;str/ends-with? &#40;get-in % &#91;:request :uri&#93;&#41; &quot;.mp3&quot;&#41;&#41;
       &#40;map &#40;fn &#91;{:keys &#91;request response&#93;}&#93;
              {:request {:uri &#40;:uri request&#41;
                         :headers &#40;select-keys &#40;:headers request&#41;
                                               &#91;&quot;range&quot;&#93;&#41;}
               :response response}&#41;&#41;&#41;
  ;; =&gt; &#40;{:request
  ;;      {:uri
  ;;       &quot;/Garth%20Brooks/Fresh%20Horses/Garth%20Brooks%20-%20It%27s%20Midnight%20Cinderella.mp3&quot;,
  ;;       :headers {&quot;range&quot; &quot;bytes=0-&quot;}},
  ;;      :response
  ;;      {:status 206,
  ;;       :headers
  ;;       {&quot;Content-Type&quot; &quot;audio/mpeg&quot;,
  ;;        &quot;Accept-Ranges&quot; &quot;bytes&quot;,
  ;;        &quot;Content-Length&quot; 1048576,
  ;;        &quot;Content-Range&quot; &quot;bytes 0-1048575/3426432&quot;}}}&#41;

  &#41;
</code></pre><p>So far, so good. But if we now seek, can we find? Let's ask in our Soundcljoud REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;seekable &#40;-&gt; &#40;get-el &quot;audio&quot;&#41; &#40;.-seekable&#41;&#41;&#93;
    &#40;-&gt;&gt; &#40;.-length seekable&#41;
         range
         &#40;map &#40;fn &#91;i&#93;
                &#91;&#40;.start seekable i&#41; &#40;.end seekable i&#41;&#93;&#41;&#41;&#41;&#41;
  ;; =&gt; &#40;&#91;0 142.654694&#93;&#41;
  
  &#41;
</code></pre><p>And if we actually click play? OMG we hear the sweet sweet sounds of a steel guitar! And if we seek forward in the track? Garth sings! Let's just check in with http-server one last time to see what it thinks:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;:requests @state&#41;
       &#40;filter #&#40;str/ends-with? &#40;get-in % &#91;:request :uri&#93;&#41; &quot;.mp3&quot;&#41;&#41;
       &#40;map &#40;fn &#91;{:keys &#91;request response&#93;}&#93;
              {:request {:uri &#40;:uri request&#41;
                         :headers &#40;select-keys &#40;:headers request&#41;
                                               &#91;&quot;range&quot;&#93;&#41;}
               :response response}&#41;&#41;&#41;
  ;; =&gt; &#40;{:request
  ;;      {:uri
  ;;       &quot;/Garth%20Brooks/Fresh%20Horses/Garth%20Brooks%20-%20It%27s%20Midnight%20Cinderella.mp3&quot;,
  ;;       :headers {&quot;range&quot; &quot;bytes=0-&quot;}},
  ;;      :response
  ;;      {:status 206,
  ;;       :headers
  ;;       {&quot;Content-Type&quot; &quot;audio/mpeg&quot;,
  ;;        &quot;Accept-Ranges&quot; &quot;bytes&quot;,
  ;;        &quot;Content-Length&quot; 1048575,
  ;;        &quot;Content-Range&quot; &quot;bytes 0-1048575/3426432&quot;}}}
  ;;     {:request
  ;;      {:uri
  ;;       &quot;/Garth%20Brooks/Fresh%20Horses/Garth%20Brooks%20-%20It%27s%20Midnight%20Cinderella.mp3&quot;,
  ;;       :headers {&quot;range&quot; &quot;bytes=0-&quot;}},
  ;;      :response
  ;;      {:status 206,
  ;;       :headers
  ;;       {&quot;Content-Type&quot; &quot;audio/mpeg&quot;,
  ;;        &quot;Accept-Ranges&quot; &quot;bytes&quot;,
  ;;        &quot;Content-Length&quot; 1048575,
  ;;        &quot;Content-Range&quot; &quot;bytes 0-1048575/3426432&quot;}}}
  ;;     {:request
  ;;      {:uri
  ;;       &quot;/Garth%20Brooks/Fresh%20Horses/Garth%20Brooks%20-%20It%27s%20Midnight%20Cinderella.mp3&quot;,
  ;;       :headers {&quot;range&quot; &quot;bytes=1048575-&quot;}},
  ;;      :response
  ;;      {:status 206,
  ;;       :headers
  ;;       {&quot;Content-Type&quot; &quot;audio/mpeg&quot;,
  ;;        &quot;Accept-Ranges&quot; &quot;bytes&quot;,
  ;;        &quot;Content-Length&quot; 1048575,
  ;;        &quot;Content-Range&quot; &quot;bytes 1048575-2097150/3426432&quot;}}}
  ;;     {:request
  ;;      {:uri
  ;;       &quot;/Garth%20Brooks/Fresh%20Horses/Garth%20Brooks%20-%20It%27s%20Midnight%20Cinderella.mp3&quot;,
  ;;       :headers {&quot;range&quot; &quot;bytes=2097150-&quot;}},
  ;;      :response
  ;;      {:status 206,
  ;;       :headers
  ;;       {&quot;Content-Type&quot; &quot;audio/mpeg&quot;,
  ;;        &quot;Accept-Ranges&quot; &quot;bytes&quot;,
  ;;        &quot;Content-Length&quot; 1048575,
  ;;        &quot;Content-Range&quot; &quot;bytes 2097150-3145725/3426432&quot;}}}
  ;;     {:request
  ;;      {:uri
  ;;       &quot;/Garth%20Brooks/Fresh%20Horses/Garth%20Brooks%20-%20It%27s%20Midnight%20Cinderella.mp3&quot;,
  ;;       :headers {&quot;range&quot; &quot;bytes=3145725-&quot;}},
  ;;      :response
  ;;      {:status 206,
  ;;       :headers
  ;;       {&quot;Content-Type&quot; &quot;audio/mpeg&quot;,
  ;;        &quot;Accept-Ranges&quot; &quot;bytes&quot;,
  ;;        &quot;Content-Length&quot; 280706,
  ;;        &quot;Content-Range&quot; &quot;bytes 3145725-3426431/3426432&quot;}}}&#41;

  &#41;
</code></pre><p>Now that, my friends, smells like the sweet sweet smell of...</p><p><img src="assets/2024-08-13-victory.jpg" alt="A woman on a beach at sunrise with her head thrown back, saying "Victory"" title="Never in doubt" width=800px /></p><p>Ah... it's been a while since I've been able to use that lovely image. 🌅</p><h2 id="is_this_the_end%3F">Is this the end?</h2><p>Well... the fact that I'm asking this rhetorical question points to the answer likely being "no". 😅</p><p>And in fact it isn't the end, because I feel (perhaps arrogantly so) that this range support could be useful to others using babashka.http-server, so I should probably open up a pull request for the <a href='https://github.com/borkdude'>borkiest of
dudes</a> to review. I'll quickly <a href='https://github.com/jmglov/http-server'>fork http-server</a> on Github, then update my remotes in <a href='https://magit.vc/'>magit</a> to make <code>origin</code> point to <code>git@github.com:jmglov/http-server.git</code> and <code>upstream</code> point to <code>git@github.com:babashka/http-server.git</code>, stash my changes, create a <code>range-requests</code> branch, then pop the stash.</p><p>I doubt Señor Borkdude will be terribly impressed by my <a href='https://betweentwoparens.com/blog/rich-comment-blocks/#rich-comment'>Rich
comment</a> and state atom, so I'd better go ahead and remove that nonsense before committing. I'll open a <a href='https://github.com/babashka/http-server/issues/16'>feature
request</a> on the Github project as well, since I know this is how Borkdude prefers to work.</p><p>With this, I have a fairly minimal commit that I'm ready to subject to the slings and arrows of outrageous fortune that are part of any Borkdude code review:</p><pre class="language-diff"><code class="lang-diff language-diff">range-requests a87a841e02d362ae8dc346153b166d28882c3c6e
Author:     Josh Glover &lt;jmglov@jmglov.net&gt;
AuthorDate: Tue Aug 13 14:18:47 2024 +0200
Commit:     Josh Glover &lt;jmglov@jmglov.net&gt;
CommitDate: Tue Aug 13 17:08:31 2024 +0200

Support range requests

2 files changed, 42 insertions&#40;+&#41;
CHANGELOG.md                 |  4 ++++
src/babashka/http&#95;server.clj | 38 ++++++++++++++++++++++++++++++++++++++

modified   CHANGELOG.md
@@ -2,6 +2,10 @@
 
 &#91;Http-server&#93;&#40;https://github.com/babashka/http-server&#41;: Serve static assets with &#91;babashka&#93;&#40;https://babashka.org/&#41;
 
+## Unreleased
+
+- &#91;#16&#93;&#40;https://github.com/babashka/http-server/issues/16&#41;: support range requests
+
 ## 0.1.13
 
 - &#91;#13&#93;&#40;https://github.com/babashka/http-server/issues/13&#41;: add an ending slash to the dir link, and don't encode the slashes &#40;&#91;@KDr2&#93;&#40;https://github.com/KDr2&#41;&#41;
modified   src/babashka/http&#95;server.clj
@@ -165,6 +165,41 @@
    {:headers &#40;merge {&quot;Content-Type&quot; &#40;ext-mime-type &#40;fs/file-name path&#41;&#41;} headers&#41;
     :body &#40;fs/file path&#41;}&#41;&#41;
 
+&#40;defn- parse-range-header &#91;range-header&#93;
+  &#40;map #&#40;when % &#40;Long/parseLong %&#41;&#41;
+       &#40;-&gt; range-header
+           &#40;str/replace #&quot;&#94;bytes=&quot; &quot;&quot;&#41;
+           &#40;str/split #&quot;-&quot;&#41;&#41;&#41;&#41;
+
+&#40;defn- read-bytes &#91;f &#91;start end&#93;&#93;
+  &#40;let &#91;end &#40;or end &#40;dec &#40;min &#40;fs/size f&#41;
+                              &#40;+ start &#40;&#42; 1024 1024&#41;&#41;&#41;&#41;&#41;
+        arr &#40;byte-array &#40;- end start&#41;&#41;&#93;
+    &#40;with-open &#91;is &#40;java.io.FileInputStream. f&#41;&#93;
+      &#40;-&gt; is .getChannel &#40;.position start&#41;&#41;
+      &#40;.read is arr&#41;&#41;
+    arr&#41;&#41;
+
+&#40;defn- byte-range
+  &#40;&#91;path request-headers&#93;
+   &#40;byte-range path request-headers {}&#41;&#41;
+  &#40;&#91;path request-headers response-headers&#93;
+   &#40;let &#91;f &#40;fs/file path&#41;
+         &#91;start end
+          :as requested-range&#93; &#40;parse-range-header &#40;request-headers &quot;range&quot;&#41;&#41;
+         arr &#40;read-bytes f requested-range&#41;
+         num-bytes-read &#40;count arr&#41;&#93;
+     {:status 206
+      :headers &#40;merge {&quot;Content-Type&quot; &#40;ext-mime-type &#40;fs/file-name path&#41;&#41;
+                       &quot;Accept-Ranges&quot; &quot;bytes&quot;
+                       &quot;Content-Length&quot; num-bytes-read
+                       &quot;Content-Range&quot; &#40;format &quot;bytes %d-%d/%d&quot;
+                                               start
+                                               &#40;+ start num-bytes-read&#41;
+                                               &#40;fs/size f&#41;&#41;}
+                      response-headers&#41;
+      :body arr}&#41;&#41;&#41;
+
 &#40;defn- with-ext &#91;path ext&#93;
   &#40;fs/path &#40;fs/parent path&#41; &#40;str &#40;fs/file-name path&#41; ext&#41;&#41;&#41;
 
@@ -179,6 +214,9 @@
                 &#40;fs/directory? f&#41;
                 &#40;index dir f&#41;
 
+                &#40;and &#40;fs/readable? f&#41; &#40;contains? &#40;:headers req&#41; &quot;range&quot;&#41;&#41;
+                &#40;byte-range f &#40;:headers req&#41;&#41;
+
                 &#40;fs/readable? f&#41;
                 &#40;body f&#41;
</code></pre><p>Wish me well, folks! If I'm not heard from again, you'll know that my <a href='https://github.com/babashka/http-server/pull/17'>pull
request</a> was found to be sub-par and I was sent to Java Jail to work on an enterprise workflow management system. 😭</p><h1 id="ok_but_now_are_we_done%3F">OK but now are we done?</h1><p>Soundcljoud has clearly now implemented the critical functionality of Soundcloud, so I could call it a day, but I'm loathe to do that when I could instead extend it to be the best podcast player that ever was! Maybe I'll rebrand it OverClj... or better yet, CljerCast! VCs, get your wallets ready and stay posted for the next instalment of the exciting Soundcljoud series, right here on jmglov.net!</p><p>Previously on Soundcljoud:</p><ul><li>Part 1: <a href='2024-07-09-soundcljoud.html'>Soundcljoud, or a young man's Soundcloud
clonejure</a></li><li>Part 2: <a href='2024-07-20-soundcljoud-cloudy.html'>Soundcljoud gets more
cloudy</a></li></ul><h2 id="photo_credits">Photo credits</h2><p>What's Going on with that Body cover art:</p><ul><li>Mashup by Josh Glover</li><li>Concert photo by <a href='https://unsplash.com/photos/people-watching-concert-Jb7TLs6fW_I'>Andre Benz on
Unsplash</a></li><li>Photo of Pitbull by Photobra Adam Bielawski - Own work, CC BY-SA 3.0  <a href='https://commons.wikimedia.org/w/index.php?curid=14736058'>https://commons.wikimedia.org/w/index.php?curid=14736058</a></li><li>Photo of Nicki Minaj by Rory from Glasgow, United Kingdom - IMG_4388, CC BY 2.0 <a href='https://commons.wikimedia.org/w/index.php?curid=115814812'>https://commons.wikimedia.org/w/index.php?curid=115814812</a></li></ul>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-07-20-soundcljoud-cloudy.html</id>
    <link href="https://jmglov.net/blog/2024-07-20-soundcljoud-cloudy.html"/>
    <title>Soundcljoud gets more cloudy</title>
    <updated>2024-07-20T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2024-07-20-soundcljoud-bb.png" alt="A logo of a face wearing a red hoodie with orange sunglasses featuring the Soundcloud logo" title="Above the clouds, infinite skills create miracles" /></p><p><a href='2024-07-09-soundcljoud.html'>Last time</a> on "Soundcljoud, or a young man's Soundcloud clonejure", I promised to clone Soundcloud, but then got bogged down in telling <a href='2024-07-09-soundcljoud.html#rambling_exposition'>the story of my
life</a> and never got around to the actual cloning part. 😬</p><p>To be fair to myself, I did do <a href='2024-07-09-soundcljoud.html#omg_finally_stuff_about_clojure'>a bunch of
stuff</a> to prepare for cloning, so now we can get to it with no further ado! (Skipping the ado bit is very out of character for me, I know. I'll just claim this parenthetical as my ado and thus fulfil your expectations of me as the most verbose writer in the Clojure community. You're welcome!)</p><h2 id="popping_in_a_scittle">Popping in a Scittle</h2><p>If you've followed along with any of <a href='tags/clonejure.html'>my other cloning
adventures</a>, you'll know where I'm going with this: straight to <a href='https://github.com/babashka/scittle/'>Scittle</a> Town!</p><p>I'll start by creating a <code>player</code> directory and dropping a <code>bb.edn</code> into it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {io.github.babashka/sci.nrepl
        {:git/sha &quot;2f8a9ed2d39a1b09d2b4d34d95494b56468f4a23&quot;}
        io.github.babashka/http-server
        {:git/sha &quot;b38c1f16ad2c618adae2c3b102a5520c261a7dd3&quot;}}
 :tasks {http-server {:doc &quot;Starts http server for serving static files&quot;
                      :requires &#40;&#91;babashka.http-server :as http&#93;&#41;
                      :task &#40;do &#40;http/serve {:port 1341 :dir &quot;public&quot;}&#41;
                                &#40;println &quot;Serving static assets at http://localhost:1341&quot;&#41;&#41;}

         browser-nrepl {:doc &quot;Start browser nREPL&quot;
                        :requires &#40;&#91;sci.nrepl.browser-server :as bp&#93;&#41;
                        :task &#40;bp/start! {}&#41;}

         -dev {:depends &#91;http-server browser-nrepl&#93;}

         dev {:task &#40;do &#40;run '-dev {:parallel true}&#41;
                        &#40;deref &#40;promise&#41;&#41;&#41;}}}
</code></pre><p>In short, what's happening here is I'm setting up a Babashka project with a <code>dev</code> task that starts a webserver on port 1341 serving up the files in the <code>public/</code> directory, starts an nREPL server on port 1339 that we can connect to with Emacs (or any inferior text editor of your choosing), and a websocket server on port 1340 that is connected to the nREPL server on one end and waiting for a ClojureScript app to connect to the other end.</p><p>Speaking of the <code>public/</code> directory, I need a <code>public/index.html</code> file to serve up:</p><pre class="language-html"><code class="lang-html language-html">&lt;!doctype html&gt;
&lt;html class=&quot;no-js&quot; lang=&quot;&quot;&gt;

&lt;head&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;meta http-equiv=&quot;x-ua-compatible&quot; content=&quot;ie=edge&quot;&gt;
    &lt;title&gt;Soundcljoud&lt;/title&gt;
    &lt;meta name=&quot;description&quot; content=&quot;&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&gt;

    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.js&quot; type=&quot;application/javascript&quot;&gt;&lt;/script&gt;
    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.promesa.js&quot; type=&quot;application/javascript&quot;&gt;&lt;/script&gt;
    &lt;script&gt;var SCITTLE&#95;NREPL&#95;WEBSOCKET&#95;PORT = 1340;&lt;/script&gt;
    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.nrepl.js&quot;
        type=&quot;application/javascript&quot;&gt;&lt;/script&gt;
    &lt;script type=&quot;application/x-scittle&quot; src=&quot;soundcljoud.cljs&quot;&gt;&lt;/script&gt;
&lt;/head&gt;

&lt;body&gt;
  &lt;h1&gt;Soundcljoud&lt;/h1&gt;
  &lt;div id=&quot;wrapper&quot; style=&quot;display: none;&quot;&gt;
    &lt;div id=&quot;player&quot;&gt;
      &lt;div class=&quot;cover-image&quot;&gt;
        &lt;img src=&quot;&quot; alt=&quot;&quot; /&gt;
      &lt;/div&gt;
      &lt;div id=&quot;controls&quot;&gt;
        &lt;audio controls src=&quot;&quot;&gt;&lt;/audio&gt;
        &lt;div id=&quot;tracks&quot; style=&quot;&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/body&gt;

&lt;/html&gt;
</code></pre><p>The <code>index.html</code> file loads three JavaScript scripts:</p><ol><li>Scittle itself, which knows how to interpret ClojureScript scripts</li><li>The Scittle <a href='https://github.com/funcool/promesa'>Promesa</a> plugin, which   provides some niceties for dealing with promises</li><li>The Scittle nREPL plugin, which will connect to that websocket server on port   1340 and complete the circuit that will allow us to REPL-drive our browser   from Emacs (or the inferior text editor of your choosing)</li></ol><p>Once this JavaScript is in place, <code>index.html</code> loads the <code>soundcljoud.cljs</code> ClojureScript file, which we'll come to in just a second.</p><p>For a (much) more detailed explanation, refer to the <a href='2024-02-22-cljcastr.html#popping_in_a_scittle'>Popping in a
Scittle</a> section of my <a href='2024-02-22-cljcastr.html'>cljcastr,
or a young man's Zencastr clonejure</a> blog post.</p><p>The body of <code>index.html</code> is all about setting up a basic HTML page with this structure:</p><pre class="language-text"><code class="lang-text language-text">+----------------------+
| Soundcljoud          |
+-------+--------------+  &lt;---
| Album | Audio player |      }
| cover +--------------+      } &lt;div id=&quot;wrapper&quot;&gt;
| image | Tracks list  |      }
+-------+--------------+  &lt;---
</code></pre><p>Note that everything inside the wrapper div is hidden from the start:</p><pre class="language-html"><code class="lang-html language-html">  &lt;div id=&quot;wrapper&quot; style=&quot;display: none;&quot;&gt;
</code></pre><p>We don't know anything about the album we want to display yet, and there's no point in showing a bunch of empty divs until we do.</p><p>Let's drop a <code>public/style.css</code> in as well:</p><pre class="language-css"><code class="lang-css language-css">body {
  font:
    1.2em Helvetica,
    Arial,
    sans-serif;
  margin: 20px;
  padding: 0;
}

img {
  max-width: 100%;
}

#wrapper {
  max-width: 960px;
  margin: 2em auto;
}

#controls {
  display: flex;
  flex-direction: column;
  gap: 5px;
}

#tracks {
  display: flex;
  flex-direction: column;
  gap: 3px;
}

@media screen and &#40;min-width: 900px&#41; {
  #wrapper {
    display: flex;
  }

  #player {
    display: flex;
    gap: 3%;
  }

  #cover-image {
    margin-right: 5%;
    max-width: 60%;
  }

  #controls {
    width: 25%;
  }
}
</code></pre><p>All of this stuff is about using screen real estate effectively. The first chunk of CSS applies universally, but the bit inside this:</p><pre class="language-css"><code class="lang-css language-css">@media screen and &#40;min-width: 900px&#41; {
  /&#42; ... &#42;/
}
</code></pre><p>only applies to windows at least 900px wide. So our page defaults to a layout that's appropriate for phones (or really narrow browser windows), but then adjusts to move more content "above the fold" so you can probably see the entire UI without scrolling if you're viewing the page on a standard computer.</p><p>Now that we have all of the HTML and CSS plumbing in place, let's add a <code>public/soundcljoud.cljs</code> file to get started with some ClojureScripting:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns soundcljoud
  &#40;:require &#91;promesa.core :as p&#93;&#41;&#41;
</code></pre><h2 id="firing_up_the_repl">Firing up the REPL</h2><p>Before we can start REPL-driving, we need to put the key in the ignition and give it a right twist! In other words, we open up a terminal in the top-level <code>player/</code> directory and invoke Babashka:</p><pre class="language-text"><code class="lang-text language-text">: jmglov@alhana; bb dev
Serving static assets at http://localhost:1341
nREPL server started on port 1339...
Websocket server started on 1340...
</code></pre><p>If we now connect to <a href='http://localhost:1341/'>http://localhost:1341/</a>, we'll be rewarded with a simple webpage:</p><p><img src="assets/2024-07-20-soundcljoud-cloudy-boring.png" alt="Screenshot of a web browser window saying Soundcljoud" title="All that buildup for this?" width=800 border=1 /></p><p>This by itself is of course monumentally boring, so let's inject some excitement into our lives by jumping into <code>soundcljoud.cljs</code> and pressing <code>C-c l C</code> (<code>cider-connect-cljs</code>), selecting <code>localhost</code>, port 1339, and <code>nbb</code> for the REPL type (assuming you're in Emacs; if you're using some other editor, perform the incantations necessary to connect your ClojureScript REPL to localhost:1339).</p><p>If everything went according to plan, you should see something like this in your terminal window:</p><pre class="language-text"><code class="lang-text language-text">:msg &quot;{:versions
       {\&quot;scittle-nrepl\&quot;
        {\&quot;major\&quot; \&quot;0\&quot;, \&quot;minor\&quot; \&quot;0\&quot;, \&quot;incremental\&quot; \&quot;1\&quot;}},
       :ops
       {\&quot;complete\&quot; {}, \&quot;info\&quot; {}, \&quot;lookup\&quot; {}, \&quot;eval\&quot; {},
        \&quot;load-file\&quot; {}, \&quot;describe\&quot; {}, \&quot;close\&quot; {}, \&quot;clone\&quot; {},
        \&quot;eldoc\&quot; {}},
       :status &#91;\&quot;done\&quot;&#93;,
       :id \&quot;3\&quot;,
       :session \&quot;3264dc1e-1b46-48a6-b11a-f606fea032b7\&quot;,
       :ns \&quot;soundcljoud\&quot;}&quot;
:msg &quot;{:value \&quot;nil\&quot;,
       :id \&quot;5\&quot;,
       :session \&quot;3264dc1e-1b46-48a6-b11a-f606fea032b7\&quot;,
       :ns \&quot;soundcljoud\&quot;}&quot;
:msg &quot;{:status &#91;\&quot;done\&quot;&#93;,
       :id \&quot;5\&quot;,
       :session \&quot;3264dc1e-1b46-48a6-b11a-f606fea032b7\&quot;,
       :ns \&quot;soundcljoud\&quot;}&quot;
</code></pre><p>And something like this in your editor's REPL window:</p><pre class="language-text"><code class="lang-text language-text">;; Connected to nREPL server - nrepl://localhost:1339
;; CIDER 1.12.0 &#40;Split&#41;
;;
;; ClojureScript REPL type: nbb
;;
nil&gt; 
</code></pre><p>Let's prove that it works by evaluating the buffer with <code>C-c C-k</code> (<code>cider-load-buffer</code>), adding a <a href='https://betweentwoparens.com/blog/rich-comment-blocks/#rich-comment'>Rich
comment</a>, putting some ClojureScript in there that grabs our wrapper div, positioning our cursor at the end of the form, and evaluating that sucker with <code>C-c C-v f c e</code> (<code>cider-pprint-eval-last-sexp-to-comment</code>):</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns soundcljoud
  &#40;:require &#91;promesa.core :as p&#93;&#41;&#41;

&#40;comment

  &#40;js/document.querySelector &quot;#wrapper&quot;&#41;
  ;; =&gt; #object&#91;HTMLDivElement &#91;object HTMLDivElement&#93;&#93;

&#41;
</code></pre><p>We've proven that we can evaluate ClojureScript code in the running browser process from our REPL buffer, which is nifty for sure, but our page still bores us, and the result of evaluating that code is pretty useless:</p><pre class="language-text"><code class="lang-text language-text">#object&#91;HTMLDivElement &#91;object HTMLDivElement&#93;&#93;
</code></pre><p>Let's actually do something with the div we've pulled down, and whilst we're at it, provide a useful way of logging stuff:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns soundcljoud
  &#40;:require &#91;promesa.core :as p&#93;&#41;&#41;

&#40;defn log
  &#40;&#91;msg&#93;
   &#40;log msg nil&#41;&#41;
  &#40;&#91;msg obj&#93;
   &#40;if obj
     &#40;js/console.log msg obj&#41;
     &#40;js/console.log msg&#41;&#41;
   obj&#41;&#41;

&#40;comment

  &#40;let &#91;div &#40;js/document.querySelector &quot;#wrapper&quot;&#41;&#93;
    &#40;set! &#40;.-style div&#41; &quot;display: flex&quot;&#41;
    &#40;log &quot;All is revealed!&quot; div&#41;&#41;
  ;; =&gt; #object&#91;HTMLDivElement &#91;object HTMLDivElement&#93;&#93;

&#41;
</code></pre><p><img src="assets/2024-07-20-soundcljoud-cloudy-revealed.png" alt="Screenshot of a web browser window with an audio player" title="Players players everywhere but not a track to play!" width=800 border=1 /></p><p>Fantastic! By using <code>js/document.log</code> (by the way, that <code>js/</code> prefix is the way you instruct ClojureScript to do some JavaScript interop; it's basically saying "look for the next symbol in the top-level scope in JavaScript land"), we now get the fancy inspection tools in the browser's JavaScript console so we can expand parts of the object and drill down to see stuff we're interested in.</p><p>Now that we've established a baseline, we can get stuck in and do some real work. 💪🏻</p><h2 id="reading_some_rss">Reading some RSS</h2><p>Do you remember the <a href='2024-07-09-soundcljoud.html#converting_from_ogg_to_mp3'>MP3 files and RSS
feed</a> we prepared in the previous blog post? Let's plop those down in our <code>public/</code> directory so we can access them from the webapp we're slowly constructing:</p><pre class="language-text"><code class="lang-text language-text">: jmglov@alhana; mkdir -p 'public/Garth Brooks/Fresh Horses'

: jmglov@alhana; cp /tmp/soundcljoud.12524185230907219576/&#42;.{rss,mp3} !$

: jmglov@alhana; ls -1 !$
album.rss
'Garth Brooks - Cowboys and Angels.mp3'
'Garth Brooks - Ireland.mp3'
&quot;Garth Brooks - It's Midnight Cinderella.mp3&quot;
&quot;Garth Brooks - Rollin'.mp3&quot;
&quot;Garth Brooks - She's Every Woman.mp3&quot;
&quot;Garth Brooks - That Ol' Wind.mp3&quot;
'Garth Brooks - The Beaches of Cheyenne.mp3'
'Garth Brooks - The Change.mp3'
'Garth Brooks - The Fever.mp3'
'Garth Brooks - The Old Stuff.mp3'
</code></pre><p>Now that our files are in place, let's see about loading the RSS feed from ClojureScript:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def base-path &quot;/Garth+Brooks/Fresh+Horses&quot;&#41;
  ;; =&gt; #'soundcljoud/base-path

  &#40;p/-&gt;&gt; &#40;js/fetch &#40;js/Request. &#40;str base-path &quot;/album.rss&quot;&#41;&#41;&#41;
         &#40;.text&#41;
         &#40;log &quot;Fetched XML:&quot;&#41;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p>In our console, we can see what we fetched:</p><pre class="language-text"><code class="lang-text language-text">Fetched XML: &lt;?xml version='1.0' encoding='UTF-8'?&gt;
&lt;rss version=&quot;2.0&quot;
     xmlns:itunes=&quot;http://www.itunes.com/dtds/podcast-1.0.dtd&quot;
     xmlns:atom=&quot;http://www.w3.org/2005/Atom&quot;&gt;
  &lt;channel&gt;
    &lt;atom:link
        href=&quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/album.rss&quot;
        rel=&quot;self&quot;
        type=&quot;application/rss+xml&quot;/&gt;
    &lt;title&gt;Garth Brooks - Fresh Horses&lt;/title&gt;
    &lt;link&gt;https://api.discogs.com/masters/212114&lt;/link&gt;
    &lt;pubDate&gt;Sun, 01 Jan 1995 00:00:00 +0000&lt;/pubDate&gt;
    &lt;itunes:subtitle&gt;Album: Garth Brooks - Fresh Horses&lt;/itunes:subtitle&gt;
    &lt;itunes:author&gt;Garth Brooks&lt;/itunes:author&gt;
    &lt;itunes:image href=&quot;https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ&#95;8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg&quot;/&gt;
    
    &lt;item&gt;
      &lt;itunes:title&gt;The Old Stuff&lt;/itunes:title&gt;
      &lt;title&gt;The Old Stuff&lt;/title&gt;
      &lt;itunes:author&gt;Garth Brooks&lt;/itunes:author&gt;
      &lt;enclosure
          url=&quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+The+Old+Stuff.mp3&quot;
          length=&quot;5943424&quot; type=&quot;audio/mpeg&quot; /&gt;
      &lt;pubDate&gt;Sun, 01 Jan 1995 00:00:00 +0000&lt;/pubDate&gt;
      &lt;itunes:duration&gt;252&lt;/itunes:duration&gt;
      &lt;itunes:episode&gt;1&lt;/itunes:episode&gt;
      &lt;itunes:episodeType&gt;full&lt;/itunes:episodeType&gt;
      &lt;itunes:explicit&gt;false&lt;/itunes:explicit&gt;
    &lt;/item&gt;
   ... 
    &lt;item&gt;
      &lt;itunes:title&gt;Ireland&lt;/itunes:title&gt;
      &lt;title&gt;Ireland&lt;/title&gt;
      &lt;itunes:author&gt;Garth Brooks&lt;/itunes:author&gt;
      &lt;enclosure
          url=&quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+Ireland.mp3&quot;
          length=&quot;6969472&quot; type=&quot;audio/mpeg&quot; /&gt;
      &lt;pubDate&gt;Sun, 01 Jan 1995 00:00:00 +0000&lt;/pubDate&gt;
      &lt;itunes:duration&gt;301&lt;/itunes:duration&gt;
      &lt;itunes:episode&gt;10&lt;/itunes:episode&gt;
      &lt;itunes:episodeType&gt;full&lt;/itunes:episodeType&gt;
      &lt;itunes:explicit&gt;false&lt;/itunes:explicit&gt;
    &lt;/item&gt;
    
  &lt;/channel&gt;
&lt;/rss&gt;
</code></pre><p>That looks quite familiar! That also looks like a bunch of text, which is not the nicest thing to extract data from. Luckily, that's a bunch of structured text, and more luckily, it's XML (XML is great, and don't let anyone tell you otherwise! And don't get me started on how we've reinvented XML but poorly with JSON Schema and all of this other nonsense we've built up around JSON because we realised that things like data validation are important when exchanging data between machines. 🤦🏼‍♂️), and most luckily of all, browsers know how to parse XML (which makes sense, as modern HTML is in fact XML):</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn parse-xml &#91;xml-str&#93;
  &#40;.parseFromString &#40;js/window.DOMParser.&#41; xml-str &quot;text/xml&quot;&#41;&#41;

&#40;comment

  &#40;p/-&gt;&gt; &#40;js/fetch &#40;js/Request. &#40;str base-path &quot;/album.rss&quot;&#41;&#41;&#41;
         &#40;.text&#41;
         parse-xml
         &#40;log &quot;Fetched XML:&quot;&#41;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p><img src="assets/2024-07-20-soundcljoud-cloudy-xml.png" alt="Screenshot of a web browser window with an XML document in the JS console" title="XML, anyone?" width=800 border=1 /></p><p>Let's do the right thing and make a function out of this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn fetch-xml &#91;path&#93;
  &#40;p/-&gt;&gt; &#40;js/fetch &#40;js/Request. path&#41;&#41;
         &#40;.text&#41;
         parse-xml
         &#40;log &quot;Fetched XML:&quot;&#41;&#41;&#41;
</code></pre><p>Now that we know how to fetch and parse XML, let's see how to extract useful information from it. Looking at the log output, we can see that the parsed XML is of type <code>#document</code>, just like our good friend <code>js/document</code> (the current webpage that the browser is displaying). That's right, we have a Document Object Model, which means we can use all the tasty DOM functions we're used to, such as <code>document.querySelector&#40;&#41;</code> to grab a node using an XPATH query.</p><p>Let's start with the album title:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;p/let &#91;title &#40;p/-&gt; &#40;fetch-xml &#40;str base-path &quot;/album.rss&quot;&#41;&#41;
                      &#40;.querySelector &quot;title&quot;&#41;
                      &#40;.-innerHTML&#41;&#41;&#93;
    &#40;set! &#40;.-innerHTML &#40;js/document.querySelector &quot;h1&quot;&#41;&#41; title&#41;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p>Cool! We now see "Garth Brooks - Fresh Horses" as our page heading! Let's see about grabbing the album art next:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;p/let &#91;xml &#40;fetch-xml &#40;str base-path &quot;/album.rss&quot;&#41;&#41;
          title &#40;p/-&gt; xml
                      &#40;.querySelector &quot;title&quot;&#41;
                      &#40;.-innerHTML&#41;&#41;
          image &#40;p/-&gt; xml
                      &#40;.querySelector &quot;image&quot;&#41;
                      &#40;.getAttribute &quot;href&quot;&#41;&#41;&#93;
    &#40;set! &#40;.-innerHTML &#40;js/document.querySelector &quot;h1&quot;&#41;&#41; title&#41;
    &#40;set! &#40;.-src &#40;js/document.querySelector &quot;.cover-image &gt; img&quot;&#41;&#41; image&#41;
    &#40;set! &#40;.-style &#40;js/document.querySelector &quot;#wrapper&quot;&#41;&#41; &quot;display: flex;&quot;&#41;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p><img src="assets/2024-07-20-soundcljoud-cloudy-garth.png" alt="Screenshot of a web browser window with the album art for Fresh Horses and an audio player" title="Almost time to ride out!" width=800 border=1 /></p><p>Before we go any further, let's create some functions from this big blob of code. At the moment, we're complecting two things:</p><ol><li>Extracting data from the XML DOM</li><li>Updating the HTML DOM to display the data</li></ol><p>Let's do the functional programming thing and create a purely functional core and a mutable shell. Instead of extracting and updating, we'll create a function that transforms the XML DOM representation of an album into a ClojureScript representation:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn xml-get &#91;el k&#93;
  &#40;-&gt; el
      &#40;.querySelector k&#41;
      &#40;.-innerHTML&#41;&#41;&#41;

&#40;defn xml-get-attr &#91;el k attr&#93;
  &#40;-&gt; el
      &#40;.querySelector k&#41;
      &#40;.getAttribute attr&#41;&#41;&#41;

&#40;defn -&gt;album &#91;xml&#93;
  {:title &#40;xml-get xml &quot;title&quot;&#41;
   :image &#40;xml-get-attr xml &quot;image&quot; &quot;href&quot;&#41;}&#41;

&#40;defn load-album &#91;path&#93;
  &#40;p/-&gt; &#40;fetch-xml path&#41; -&gt;album&#41;&#41;

&#40;comment

  &#40;p/let &#91;{:keys &#91;title image&#93; :as album} &#40;load-album &#40;str base-path &quot;/album.rss&quot;&#41;&#41;&#93;
    &#40;set! &#40;.-innerHTML &#40;js/document.querySelector &quot;h1&quot;&#41;&#41; title&#41;
    &#40;set! &#40;.-src &#40;js/document.querySelector &quot;.cover-image &gt; img&quot;&#41;&#41; image&#41;
    &#40;set! &#40;.-style &#40;js/document.querySelector &quot;#wrapper&quot;&#41;&#41; &quot;display: flex;&quot;&#41;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p>Now that we have a nice ClojureScript data structure to represent our album, let's tackle the DOM mutations we need to do to display the album:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-el &#91;selector&#93;
  &#40;if &#40;instance? js/HTMLElement selector&#41;
    selector  ; already an element; just return it
    &#40;js/document.querySelector selector&#41;&#41;&#41;

&#40;defn set-styles! &#91;el styles&#93;
  &#40;set! &#40;.-style el&#41; styles&#41;&#41;

&#40;defn display-album! &#91;{:keys &#91;title image&#93; :as album}&#93;
  &#40;let &#91;header &#40;get-el &quot;h1&quot;&#41;
        cover &#40;get-el &quot;.cover-image &gt; img&quot;&#41;
        wrapper &#40;get-el &quot;#wrapper&quot;&#41;&#93;
    &#40;set! &#40;.-innerHTML header&#41; title&#41;
    &#40;set! &#40;.-src cover&#41; image&#41;
    &#40;set-styles! wrapper &quot;display: flex;&quot;&#41;
    album&#41;&#41;

&#40;comment

  &#40;p/-&gt; &#40;load-album &#40;str base-path &quot;/album.rss&quot;&#41;&#41; display-album!&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><h2 id="tracking_down_the_tracks">Tracking down the tracks</h2><p>Displaying the album title and cover art is all well and good, but in order to complete our Soundcloud clone, we need some way of actually listening to the music on the album. If you recall, our RSS feed contains a series of <code>&lt;item&gt;</code> tags representing the tracks:</p><pre class="language-xml"><code class="lang-xml language-xml">    &lt;item&gt;
      &lt;itunes:title&gt;The Old Stuff&lt;/itunes:title&gt;
      &lt;title&gt;The Old Stuff&lt;/title&gt;
      &lt;itunes:author&gt;Garth Brooks&lt;/itunes:author&gt;
      &lt;enclosure
          url=&quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+The+Old+Stuff.mp3&quot;
          length=&quot;5943424&quot; type=&quot;audio/mpeg&quot; /&gt;
      &lt;pubDate&gt;Sun, 01 Jan 1995 00:00:00 +0000&lt;/pubDate&gt;
      &lt;itunes:duration&gt;252&lt;/itunes:duration&gt;
      &lt;itunes:episode&gt;1&lt;/itunes:episode&gt;
      &lt;itunes:episodeType&gt;full&lt;/itunes:episodeType&gt;
      &lt;itunes:explicit&gt;false&lt;/itunes:explicit&gt;
    &lt;/item&gt;
   ... 
    &lt;item&gt;
      &lt;itunes:title&gt;Ireland&lt;/itunes:title&gt;
      &lt;title&gt;Ireland&lt;/title&gt;
      &lt;itunes:author&gt;Garth Brooks&lt;/itunes:author&gt;
      &lt;enclosure
          url=&quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+Ireland.mp3&quot;
          length=&quot;6969472&quot; type=&quot;audio/mpeg&quot; /&gt;
      &lt;pubDate&gt;Sun, 01 Jan 1995 00:00:00 +0000&lt;/pubDate&gt;
      &lt;itunes:duration&gt;301&lt;/itunes:duration&gt;
      &lt;itunes:episode&gt;10&lt;/itunes:episode&gt;
      &lt;itunes:episodeType&gt;full&lt;/itunes:episodeType&gt;
      &lt;itunes:explicit&gt;false&lt;/itunes:explicit&gt;
    &lt;/item&gt;
</code></pre><p>What we need from each item in order to display and play the track is:</p><ul><li>Song title</li><li>Artist (for this album, all tracks are from Garth, but an album could be a  compilation of songs by different artists, so let's grab the artist  in case we later decide to display it)</li><li>Track number</li><li>URL of the source audio</li></ul><p>Let's write an aspirational function that assumes it will be called with a DOM element representing an <code>&lt;item&gt;</code> and transforms it into a ClojureScript map, just as we did for the item itself:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn -&gt;track &#91;item-el&#93;
  {:artist &#40;xml-get item-el &quot;author&quot;&#41;
   :title &#40;xml-get item-el &quot;title&quot;&#41;
   :number &#40;-&gt; &#40;xml-get item-el &quot;episode&quot;&#41; js/parseInt&#41;
   :src &#40;xml-get-attr item-el &quot;enclosure&quot; &quot;url&quot;&#41;}&#41;
</code></pre><p>For the track number, we need to convert it to an integer, since the text contents of an XML elements are, well, text, and we'll want to sort our tracks numerically.</p><p>Now that we have a function to convert an <code>&lt;item&gt;</code> into a track, let's plug that into our <code>-&gt;album</code> function to add a list of tracks to the album:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn -&gt;album &#91;xml&#93;
  {:title &#40;xml-get xml &quot;title&quot;&#41;
   :image &#40;xml-get-attr xml &quot;image&quot; &quot;href&quot;&#41;
   :tracks &#40;-&gt;&gt; &#40;.querySelectorAll xml &quot;item&quot;&#41;
                &#40;map -&gt;track&#41;
                &#40;sort-by :number&#41;&#41;}&#41;
</code></pre><p>OK, we have data representing a list of tracks, so we need to consider how we want to display it. If we cast our mind back to our HTML, we have a div where the tracks should go:</p><pre class="language-html"><code class="lang-html language-html">&lt;body&gt;
  ...
  &lt;div id=&quot;wrapper&quot; style=&quot;display: none;&quot;&gt;
    &lt;div id=&quot;player&quot;&gt;
      ...
      &lt;div id=&quot;controls&quot;&gt;
        ...
        &lt;div id=&quot;tracks&quot; style=&quot;&quot;&gt;&lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/div&gt;
&lt;/body&gt;
</code></pre><p>What we can do is create a <code>&lt;span&gt;</code> for each track, something like this:</p><pre class="language-html"><code class="lang-html language-html">&lt;span&gt;1. The Old Stuff&lt;/span&gt;
</code></pre><p>Let's go ahead and write that function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn track-&gt;span &#91;{:keys &#91;number artist title&#93; :as track}&#93;
  &#40;let &#91;span &#40;js/document.createElement &quot;span&quot;&#41;&#93;
    &#40;set! &#40;.-innerHTML span&#41; &#40;str number &quot;. &quot; title&#41;&#41;
    span&#41;&#41;

&#40;comment

  &#40;p/-&gt;&gt; &#40;load-album &#40;str base-path &quot;/album.rss&quot;&#41;&#41;
         :tracks
         first
         track-&gt;span
         &#40;log &quot;The first track is:&quot;&#41;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p>In the JavaScript console, we see:</p><pre class="language-text"><code class="lang-text language-text">The first track is: &lt;span&gt;1. The Old Stuff&lt;/span&gt;
</code></pre><p>This is cool, because the <code>track-&gt;span</code> function is still pure—there's no mutation occurring there. We have one and only one place where that's doing mutation, and that's <code>display-album!</code>, which is where we can hook into our functional core and display the tracks. In order to do that, we'll take our list of tracks, turn them into a list of <code>&lt;span&gt;</code> elements, and then set them as the children of the <code>#tracks</code> div.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn set-children! &#91;el children&#93;
  &#40;.replaceChildren el&#41;
  &#40;doseq &#91;child children&#93;
    &#40;.appendChild el child&#41;&#41;
  el&#41;

&#40;defn display-album! &#91;{:keys &#91;title image tracks&#93; :as album}&#93;
  &#40;let &#91;header &#40;get-el &quot;h1&quot;&#41;
        cover &#40;get-el &quot;.cover-image &gt; img&quot;&#41;
        wrapper &#40;get-el &quot;#wrapper&quot;&#41;&#93;
    &#40;set! &#40;.-innerHTML header&#41; title&#41;
    &#40;set! &#40;.-src cover&#41; image&#41;
    &#40;-&gt;&gt; tracks
         &#40;map track-&gt;span&#41;
         &#40;set-children! &#40;get-el &quot;#tracks&quot;&#41;&#41;&#41;
    &#40;set-styles! wrapper &quot;display: flex;&quot;&#41;
    album&#41;&#41;

&#40;comment

  &#40;p/-&gt; &#40;load-album &#40;str base-path &quot;/album.rss&quot;&#41;&#41; display-album!&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p><img src="assets/2024-07-20-soundcljoud-cloudy-tracks.png" alt="Screenshot of a web browser window with the album art for Fresh Horses, an audio player, and a list of tracks" title="That tracks" width=800 border=1 /></p><p>This is fantastic... if all we want to do is know what's on an album. But of course my initial problem was wanting to <strong>listen</strong> to Garth and not having a way to do that. Now I have written much Clojure and ClojureScript, and still cannot listen to Garth. 🤔</p><h2 id="play_it_again%2C_sam">Play it again, Sam</h2><p>Of course what I do have is an HTML <code>&lt;audio&gt;</code> element and an MP3 file with a source URL, and I bet if I can just put these two things together, my ears will soon be filled with the sweet sweet sounds of 90s country music.</p><p>Let's start out with the simplest thing we can do, which is to activate the first track on the album once it's loaded. Since <code>display-album!</code> returns the album, we can just add some code to the end of the pipeline:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def base-path &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses&quot;&#41;
  ;; =&gt; #'soundcljoud/base-path

  &#40;p/-&gt;&gt; &#40;load-album &#40;str base-path &quot;/album.rss&quot;&#41;&#41;
         display-album!
         :tracks
         first
         :src
         &#40;set! &#40;.-src &#40;get-el &quot;audio&quot;&#41;&#41;&#41;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p>As soon as we evaluate this code, the <code>&lt;audio&gt;</code> element comes to life, displaying a duration and activating the play button. Pressing the play button, we do in fact hear some Garth! 🎉</p><p>However, our UX is quite poor, since there's no visual representation of which track is playing. We can fix this by emboldening the active track:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;p/let &#91;{:keys &#91;number src&#93; :as track}
          &#40;p/-&gt;&gt; &#40;load-album &#40;str base-path &quot;/album.rss&quot;&#41;&#41;
                 display-album!
                 :tracks
                 first&#41;&#93;
    &#40;-&gt; &#40;get-el &quot;#tracks&quot;&#41;
        &#40;.-children&#41;
        seq
        &#40;nth &#40;dec number&#41;&#41;
        &#40;set-styles! &quot;font-weight: bold;&quot;&#41;&#41;
    &#40;set! &#40;.-src &#40;get-el &quot;audio&quot;&#41;&#41; src&#41;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p><img src="assets/2024-07-20-soundcljoud-cloudy-play.png" alt="Screenshot of our UI with the first track highlighted and loaded in the audio element" title="That's a bold move!" width=800 border=1 /></p><p>Speaking of UX, though, one would imagine that they'd be able to change to a track by clicking on it. At the moment, clicking does nothing, but that's easy enough to fix by adding an event handler to our span for each track that activates the track. Let's create a function and shovel our track activating code in there:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn activate-track! &#91;{:keys &#91;number src&#93; :as track}&#93;
  &#40;log &quot;Activating track:&quot; &#40;clj-&gt;js track&#41;&#41;
  &#40;let &#91;track-spans &#40;seq &#40;.-children &#40;get-el &quot;#tracks&quot;&#41;&#41;&#41;&#93;
    &#40;-&gt; track-spans
        &#40;nth &#40;dec number&#41;&#41;
        &#40;set-styles! &quot;font-weight: bold;&quot;&#41;&#41;&#41;
  &#40;set! &#40;.-src &#40;get-el &quot;audio&quot;&#41;&#41; src&#41;
  track&#41;
</code></pre><p>By the way, that <code>clj-&gt;js</code> function takes a ClojureScript data structure (in this case, our track map) and recursively transforms it into a JavaScript object so it can be printed nicely in the JS console.</p><p>OK, now that we have <code>activate-track!</code> as a function, we can use it in a click handler:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn track-&gt;span &#91;{:keys &#91;number title&#93; :as track}&#93;
  &#40;let &#91;span &#40;js/document.createElement &quot;span&quot;&#41;&#93;
    &#40;set! &#40;.-innerHTML span&#41; &#40;str number &quot;. &quot; title&#41;&#41;
    &#40;.addEventListener span &quot;click&quot; &#40;partial activate-track! track&#41;&#41;
    span&#41;&#41;

&#40;comment

  &#40;p/-&gt; &#40;load-album &#40;str base-path &quot;/album.rss&quot;&#41;&#41;
        display-album!
        :tracks
        first
        activate-track!&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p>Evaluating this code activates the first track on the album as before, and then clicking another track highlights it in bold and loads it into the <code>&lt;audio&gt;</code> element. That's good, but what isn't so good is that the first track stays bold. 😬</p><p>Luckily, there's an easy fix for this. All we need to do is reset the weight of all the track spans before bolding the active one in <code>activate-track!</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn activate-track! &#91;{:keys &#91;number src&#93; :as track}&#93;
  &#40;log &quot;Activating track:&quot; &#40;clj-&gt;js track&#41;&#41;
  &#40;let &#91;track-spans &#40;seq &#40;.-children &#40;get-el &quot;#tracks&quot;&#41;&#41;&#41;&#93;
    &#40;doseq &#91;span track-spans&#93;
      &#40;set-styles! span &quot;font-weight: normal;&quot;&#41;&#41;
    &#40;-&gt; track-spans
        &#40;nth &#40;dec number&#41;&#41;
        &#40;set-styles! &quot;font-weight: bold;&quot;&#41;&#41;&#41;
  &#40;set! &#40;.-src &#40;get-el &quot;audio&quot;&#41;&#41; src&#41;
  track&#41;
</code></pre><p>Amazing!</p><p>Whilst we're ticking off UX issues, let's think about what should happen when our user clicks on a different track. At the moment, we load the track into the player and then the user has to click the play button to start listening to it. That is perfectly reasonable when first loading the album, but if I'm listening to a track and then select another one, I would kinda expect the new track to start playing automatically instead of me having to click play manually.</p><p>Let's see how we can do this. According to the <a href='https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement'>HTMLMediaElement</a> documentation, our <code>&lt;audio&gt;</code> element should have <code>paused</code> attribute telling us whether playback is happening. Let's try it out:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;p/-&gt; &#40;load-album &#40;str base-path &quot;/album.rss&quot;&#41;&#41;
        display-album!
        :tracks
        first
        activate-track!&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

  &#40;-&gt; &#40;get-el &quot;audio&quot;&#41;
      &#40;.-paused&#41;&#41;
  ;; =&gt; true

&#41;
</code></pre><p>Now if we click the play button and check the value of the <code>paused</code> attribute again:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; &#40;get-el &quot;audio&quot;&#41;
      &#40;.-paused&#41;&#41;
  ;; =&gt; false

&#41;
</code></pre><p>Excellent! Now let's see how we programatically start playing a newly loaded track. Referring back to the documentation, we discover a <a href='https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play'>HTMLMediaElement.play()</a> method. Let's try that out:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;p/-&gt; &#40;load-album &#40;str base-path &quot;/album.rss&quot;&#41;&#41;
        display-album!
        :tracks
        second
        activate-track!&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

  &#40;-&gt; &#40;get-el &quot;audio&quot;&#41;
      &#40;.play&#41;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p>Evaluating this code results in "Cowboys and Angels" starting to play!</p><p>Now we can use what we've learned to teach <code>activate-track!</code> to start playing the track when appropriate:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn activate-track! &#91;{:keys &#91;number src&#93; :as track}&#93;
  &#40;log &quot;Activating track:&quot; &#40;clj-&gt;js track&#41;&#41;
  &#40;let &#91;track-spans &#40;seq &#40;.-children &#40;get-el &quot;#tracks&quot;&#41;&#41;&#41;
        audio-el &#40;get-el &quot;audio&quot;&#41;
        paused? &#40;.-paused audio-el&#41;&#93;
    &#40;doseq &#91;span track-spans&#93;
      &#40;set-styles! span &quot;font-weight: normal;&quot;&#41;&#41;
    &#40;-&gt; track-spans
        &#40;nth &#40;dec number&#41;&#41;
        &#40;set-styles! &quot;font-weight: bold;&quot;&#41;&#41;
    &#40;set! &#40;.-src audio-el&#41; src&#41;
    &#40;when-not paused?
      &#40;.play audio-el&#41;&#41;&#41;
  track&#41;

&#40;comment

  &#40;p/-&gt; &#40;load-album &#40;str base-path &quot;/album.rss&quot;&#41;&#41;
        display-album!
        :tracks
        first
        activate-track!&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

&#41;
</code></pre><p>When the album loads, the first track is activated but doesn't start playing. Clicking on another track activates it but doesn't start playing it. However, if we click the play button and start listening to the active track, then click on another track, the new track is activated and immediately starts playing.</p><p>This, my friends, is some seriously good UX! Of course, we can improve it further.</p><h2 id="keep_playing_it%2C_sam">Keep playing it, Sam</h2><p>The next UX nit that we should pick is the fact that when a track ends, our poor user has to manually click on the next track and then manually click the play button just to keep listening to the album. This seems a bit mean of us, so let's see what we can do in order to be the nice people that we know we are, deep down inside.</p><p>Our good friend HTMLMediaElement has <a href='https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement#events'>a bunch of
events</a> that tell us useful things about what's happening with the media, and one of these events is <code>ended</code>:</p><blockquote><p> Fired when playback stops when end of the media (<audio> or <video>) is  reached or because no further data is available. </p></blockquote><p>This seems like it will fit the bill quite nicely. Hopping back in our hammock for a minute, we think about what should happen when the end of a track is reached:</p><ul><li>The next track is activated and starts playing, unless</li><li>It's the last track on the album, in which case nothing should happen.</li></ul><p>We can of course add a <code>ended</code> event listener to the <code>&lt;audio&gt;</code> element every time a new track is activated, but this is problematic because we would then want to remove the previous event listener, and it turns out that <a href='https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener#matching_event_listeners_for_removal'>removing event
listeners is a bit
complicated</a>. What if we instead had an event listener that knew what track was currently playing, where that track comes in the album, and what track (if any) is next? Then we'd only have to attach a listener once, right after we load the album. Let's think through how we could do that.</p><p>So far, we've been relying on the state of the DOM to tell us things like if the track is paused. A much more functional approach would be to control the state ourselves using immutable data structures and so on. A nice side effect of this (sorry, Haskell folks, Clojurists are just fine with uncontrolled side effects) is that it actually makes REPL-driven development easier as well! 🤯</p><p>Let's start by extracting a function to handle the tedium of loading the album, displaying it, and then activating the first track:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn load-ui! &#91;dir&#93;
  &#40;p/-&gt;&gt; &#40;load-album &#40;str dir &quot;/album.rss&quot;&#41;&#41;
         display-album!
         :tracks
         first
         activate-track!&#41;&#41;
</code></pre><p>Now that we have this, we'll define a top-level <a href='https://clojure.org/reference/atoms'>atom</a> to hold the state, then update our <code>load-ui!</code> function to stuff the album into the atom once it's loaded:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def state &#40;atom nil&#41;&#41;

&#40;defn load-ui! &#91;dir&#93;
  &#40;p/-&gt;&gt; &#40;load-album &#40;str dir &quot;/album.rss&quot;&#41;&#41;
         display-album!
         &#40;assoc {} :album&#41;
         &#40;reset! state&#41;
         :album
         :tracks
         first
         activate-track!&#41;&#41;
</code></pre><p>What we're doing here is creating a map to hold the state, then assoc-ing the loaded album into the map under the <code>:album</code> key, then putting that map into the <code>state</code> atom with <a href='https://clojuredocs.org/clojure.core/reset%21'>reset!</a>, which returns the new value saved in the atom, which is the one we just put in there, which will look like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:title &quot;Garth Brooks - Fresh Horses&quot;,
 :image &quot;https://i.discogs.com/.../LTMxNjguanBlZw.jpeg&quot;,
 :tracks
 &#40;{:artist &quot;Garth Brooks&quot;,
   :title &quot;The Old Stuff&quot;,
   :number 1,
   :src &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+The+Old+Stuff.mp3&quot;}
  ...
  {:artist &quot;Garth Brooks&quot;,
   :title &quot;Ireland&quot;,
   :number 10,
   :src &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+Ireland.mp3&quot;}&#41;, :paused? true}
</code></pre><p>We'll then grab the album back out of the map and proceed as before to activate the first track. This is a little gross, but we'll clean it up as we go.</p><p>Oh yeah, and remember when I promised this would make debugging easier? Check this out:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;load-ui! &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses&quot;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

  @state
  ;; =&gt; {:album {:title &quot;Garth Brooks - Fresh Horses&quot;,
  ;;             :image &quot;https://i.discogs.com/.../LTMxNjguanBlZw.jpeg&quot;,
  ;;             :tracks
  ;;             &#40;{:artist &quot;Garth Brooks&quot;,
  ;;               :title &quot;The Old Stuff&quot;,
  ;;               :number 1,
  ;;               :src &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+The+Old+Stuff.mp3&quot;}
  ;;               ...
  ;;               {:artist &quot;Garth Brooks&quot;,
  ;;                :title &quot;Ireland&quot;,
  ;;                :number 10,
  ;;                :src &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+Ireland.mp3&quot;}&#41;}}

&#41;
</code></pre><p>That's right, we no longer have to rely on logging stuff to the JS console in our promise chains!</p><p>OK, but we haven't really changed anything other than making the <code>load-ui!</code> function more complicated. Let's add a little more to our state atom so we can actually tackle the problem of auto-advancing tracks. First, we'll add a <code>:paused?</code> key:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn load-ui! &#91;dir&#93;
  &#40;p/-&gt;&gt; &#40;load-album &#40;str dir &quot;/album.rss&quot;&#41;&#41;
         display-album!
         &#40;assoc {:paused? true} :album&#41;
         &#40;reset! state&#41;
         :album
         :tracks
         first
         activate-track!&#41;&#41;

&#40;comment

  &#40;load-ui! &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses&quot;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

  @state
  ;; =&gt; {:paused? true, :album {...}}

&#41;
</code></pre><p>Now let's add an event listener to the <code>&lt;audio&gt;</code> element that updates the state when the play button is pressed, doing a little cleanup of the <code>load-ui!</code> function whilst we're at it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn load-ui! &#91;dir&#93;
  &#40;p/let &#91;album &#40;load-album &#40;str dir &quot;/album.rss&quot;&#41;&#41;&#93;
    &#40;display-album! album&#41;
    &#40;reset! state {:paused? true, :album album}&#41;
    &#40;-&gt;&gt; album
         :tracks
         first
         activate-track!&#41;
    &#40;.addEventListener &#40;get-el &quot;audio&quot;&#41; &quot;play&quot;
                       #&#40;swap! state assoc :paused? false&#41;&#41;&#41;&#41;

&#40;comment

  &#40;load-ui! &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses&quot;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

  &#40;:paused? @state&#41;
  ;; =&gt; true

  ;; Click the play button and...
  &#40;:paused? @state&#41;
  ;; =&gt; false

&#41;
</code></pre><p>If you're not familiar with <a href='https://clojuredocs.org/clojure.core/swap%21'>swap!</a>, it takes an atom and a function which will be called with the current value of the atom, then sets the next value of the atom to whatever the function returns, just like <a href='https://clojuredocs.org/clojure.core/update'>update</a> does for plain old maps. And also just like <code>update</code>, it has a shorthand form so that instead of writing this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;swap! state #&#40;assoc % :paused? false&#41;&#41;
</code></pre><p>you can write this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;swap! state assoc :paused? false&#41;
</code></pre><p>in which case <code>swap!</code> will treat the arg after the atom as a function which will be called with the current value first, then the rest of the args to <code>swap!</code>. You can imagine that <code>swap!</code> is written something like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn swap!
  &#40;&#91;atom f&#93;
   &#40;reset! atom &#40;f @atom&#41;&#41;&#41;
  &#40;&#91;atom f &amp; args&#93;
   &#40;reset! atom &#40;apply f @atom args&#41;&#41;&#41;&#41;
</code></pre><p>It's obviously not written like that, even though that would technically probably maybe work. It's actually written like <a href='https://github.com/clojure/clojure/blob/clojure-1.11.1/src/clj/clojure/core.clj#L2362'>this</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn swap!
  &quot;Atomically swaps the value of atom to be:
  &#40;apply f current-value-of-atom args&#41;. Note that f may be called
  multiple times, and thus should be free of side effects.  Returns
  the value that was swapped in.&quot;
  {:added &quot;1.0&quot;
   :static true}
  &#40;&#91;&#94;clojure.lang.IAtom atom f&#93; &#40;.swap atom f&#41;&#41;
  &#40;&#91;&#94;clojure.lang.IAtom atom f x&#93; &#40;.swap atom f x&#41;&#41;
  &#40;&#91;&#94;clojure.lang.IAtom atom f x y&#93; &#40;.swap atom f x y&#41;&#41;
  &#40;&#91;&#94;clojure.lang.IAtom atom f x y &amp; args&#93; &#40;.swap atom f x y args&#41;&#41;&#41;
</code></pre><p>But you get the point.</p><p>Aaaaaanyway, I seem to have digressed—which is firmly on brand for this blog, so I apologise for nothing!</p><p>But yeah, at this point, we're back to the functionality that we had before. If we click on a track whilst the player is paused, the new track is selected but doesn't start playing, and if we click on a new track whilst the player is playing, the player plays on by playing the new track. Got it?</p><p>However, <code>activate-track!</code> is still relying on the DOM to keep track of whether the player is paused. Let's fix this by checking the <code>state</code> atom instead:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn activate-track! &#91;{:keys &#91;number src&#93; :as track}&#93;
  &#40;log &quot;Activating track:&quot; &#40;clj-&gt;js track&#41;&#41;
  &#40;let &#91;track-spans &#40;seq &#40;.-children &#40;get-el &quot;#tracks&quot;&#41;&#41;&#41;
        audio-el &#40;get-el &quot;audio&quot;&#41;
        ;; Instead of this 👇
        ;; paused? &#40;.-paused audio-el&#41;
        ;; Do this! 👇
        {:keys &#91;paused?&#93;} @state&#93;
      ;; ...
    &#41;
  track&#41;
</code></pre><p>Next, let's write a function to advance to the next track:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn advance-track! &#91;&#93;
  &#40;let &#91;{:keys &#91;active-track album&#93;} @state
        {:keys &#91;tracks&#93;} album
        last-track? &#40;= active-track &#40;count tracks&#41;&#41;&#93;
    &#40;when-not last-track?
      &#40;activate-track! &#40;nth tracks active-track&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Oops, this is relying on <code>:active-track</code> being present in the <code>state</code> atom. Let's put it there in <code>activate-track!</code></p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn activate-track! &#91;{:keys &#91;number src&#93; :as track}&#93;
  &#40;log &quot;Activating track:&quot; &#40;clj-&gt;js track&#41;&#41;
  &#40;let &#91;track-spans &#40;seq &#40;.-children &#40;get-el &quot;#tracks&quot;&#41;&#41;&#41;
        audio-el &#40;get-el &quot;audio&quot;&#41;
        {:keys &#91;paused?&#93;} @state&#93;
    ;; ...
    &#41;
  ;; Swappity swap swap! 👇
  &#40;swap! state assoc :active-track number&#41;
  track&#41;

&#40;comment

  &#40;load-ui! &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses&quot;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

  @state
  ;; =&gt; {:paused? true,
  ;;     :active-track 1,
  ;;     :album {...}}

&#41;
</code></pre><p>Now we should be able to actually call <code>advance-track!</code> to, well, advance the track:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;load-ui! &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses&quot;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

  &#40;:active-track @state&#41;
  ;; =&gt; 1

  &#40;advance-track!&#41;
  ;; =&gt; {:artist &quot;Garth Brooks&quot;, :title &quot;Cowboys And Angels&quot;, :number 2, :src &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+Cowboys+and+Angels.mp3&quot;}

  &#40;:active-track @state&#41;
  ;; =&gt; 2

&#41;
</code></pre><p>We will have also seen the highlighted track change when we evaluated the <code>&#40;advance-track!&#41;</code> form! 🎉</p><h2 id="is_this_the_end%3F">Is this the end?</h2><p>What we're building up to is of course the ability to play our album continuously. When one track ends, the next should begin. And our good friend <code>&lt;audio&gt;</code> has just what we need, in the form of the <a href='https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/ended_event'>ended</a> event. If we add one line of code to register <code>advance-track!</code> as the listener for the <code>ended</code> event:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn load-ui! &#91;dir&#93;
  &#40;p/let &#91;album &#40;load-album &#40;str dir &quot;/album.rss&quot;&#41;&#41;&#93;
    &#40;display-album! album&#41;
    &#40;reset! state {:paused? true, :album album}&#41;
    &#40;-&gt;&gt; album
         :tracks
         first
         activate-track!&#41;
    &#40;.addEventListener &#40;get-el &quot;audio&quot;&#41; &quot;play&quot;
                       #&#40;swap! state assoc :paused? false&#41;&#41;
    &#40;.addEventListener &#40;get-el &quot;audio&quot;&#41; &quot;ended&quot;
                       advance-track!&#41;&#41;&#41;

&#40;comment

  &#40;load-ui! &quot;http://localhost:1341/Garth+Brooks/Fresh+Horses&quot;&#41;
  ;; =&gt; #&lt;Promise&#91;&#126;&#93;&gt;

  ;; Click ▶️ and witness the glory!
&#41;
</code></pre><p>We win!</p><p><img src="assets/2024-07-20-soundcljoud-cloudy-autoplay.png" alt="Screenshot of our UI with the last track highlighted and the console showing activating track for all previous tracks" title="Autoplays assemble!" width=800 border=1 /></p><p>Winners who have won before and know how to win will of course know that the best thing to do after winning is to stride triumphantly to the podium, receive your 🥇, wave to your adoring public, soak up the applause like warm sunshine on a July day (unless you're in the southern hemisphere, in which case the warm sunshine is best appreciated in December, unless you're close enough to the equator to appreciate warm sunshine whenever you damn well please, unless you're too close to the equator and that sunshine is too warm to appreciate because you're sweating like wild), and then head home, find a comfy chair and open a bottle of champagne or fizzy water or tasty whiskey or whatever.</p><p>I, of course, am no such winner, so instead of retiring to my comfy chair with a glass of <a href='https://thewateroflife.org/2023/02/26/lagavulin-offerman-edition/'>Lagavulin</a>, I want to jump ahead in a track, so I confidently reach for the audio control and click ahead in the timeline, and... nothing happens WTF?</p><p>Reading more documentation, I discover that I can see the current time in seconds in the track by reading its <a href='https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/currentTime'>currentTime</a> property, and I can seek to an arbitrary time by setting <code>currentTime</code>, so let's give that a try, shall we? (Spoiler: we shall.)</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;.-currentTime &#40;get-el &quot;audio&quot;&#41;&#41;
  ;; =&gt; 37.010544

  &#40;set! &#40;.-currentTime &#40;get-el &quot;audio&quot;&#41;&#41; 50&#41;
  ;; =&gt; nil

  ;; Why did my track start over? 🤬
  
  &#40;.-currentTime &#40;get-el &quot;audio&quot;&#41;&#41;
  ;; =&gt; 2.006649

&#41;
</code></pre><p>To make a long story short, this all boils down to how the browser actually implements seeking. When it first loads the audio track, it issues a request like this:</p><pre class="language-text"><code class="lang-text language-text">GET /Garth+Brooks/Fresh+Horses/Garth+Brooks+-+The+Old+Stuff.mp3 HTTP/1.1
Range: bytes=0-
</code></pre><p>and expects a response like this:</p><pre class="language-text"><code class="lang-text language-text">HTTP/1.1 206 Partial Content
Accept-Ranges: bytes
Content-length: 5943424
Content-Range: bytes 0-1024000/5943424
Content-Type: audio/mpeg
</code></pre><p>It will then buffer the bytes it got back and make the track seekable within those bytes, as described <a href='https://developer.mozilla.org/en-US/docs/Web/Media/Audio_and_video_delivery/buffering_seeking_time_ranges'>here</a>. You can peer under the hood by inspecting the <code>buffered</code> and <code>seekable</code> properties of the <code>&lt;audio&gt;</code> element:</p><pre class="language-javascript"><code class="lang-javascript language-javascript">audio.buffered.length; // returns 2
audio.buffered.start&#40;0&#41;; // returns 0
audio.buffered.end&#40;0&#41;; // returns 5
audio.buffered.start&#40;1&#41;; // returns 15
audio.buffered.end&#40;1&#41;; // returns 19
</code></pre><p>But if we do this in our player, we experience a deep feeling of melancholy:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;b &#40;-&gt; &#40;get-el &quot;audio&quot;&#41;
              &#40;.-buffered&#41;&#41;&#93;
    &#91;&#40;.start b 0&#41; &#40;.end b 0&#41;&#93;&#41;
  ;; =&gt; &#91;0 144.758&#93;

  &#40;-&gt; &#40;get-el &quot;audio&quot;&#41;
      &#40;.-seekable&#41;
      &#40;.-length&#41;&#41;
  ;; =&gt; 1

  &#40;let &#91;s &#40;-&gt; &#40;get-el &quot;audio&quot;&#41;
              &#40;.-seekable&#41;&#41;&#93;
    &#91;&#40;.start s 0&#41; &#40;.end s 0&#41;&#93;&#41;
  ;; =&gt; &#91;0 0&#93;

&#41;
</code></pre><p>The buffering looks fine, but it seems that we can only seek between 0 seconds and 0 seconds in the track, which kinda explains why attempting to set <code>currentTime</code> to any number that isn't 0 results in seeking back to 0. 😭</p><p>Seeking apparently only works if we get that blessed <code>206 Partial Content</code> response from the webserver, so the browser knows how to make subsequent range requests to buffer more data, and unfortunately, the built-in <a href='https://github.com/babashka/http-server'>babashka.http-server</a> that we're using to serve up files in <code>public/</code> responds like this:</p><pre class="language-text"><code class="lang-text language-text">HTTP/1.1 200 OK
Content-length: 5943424
Content-Type: audio/mpeg
Server: http-kit
</code></pre><p>No partial content?</p><p><img src="assets/2024-07-20-soundcljoud-cloudy-noseek.jpg" alt="Screenshot of a chef saying no seek for you, come back 1 year" title="Call Antifa, quick!" /></p><p>We may attempt to fix this next time on "Soundcljoud, or a young man's Soundcloud clonejure", that is if there is a next time.</p><p>Part 1: <a href='2024-07-09-soundcljoud.html'>Soundcljoud, or a young man's Soundcloud clonejure</a></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-07-09-soundcljoud.html</id>
    <link href="https://jmglov.net/blog/2024-07-09-soundcljoud.html"/>
    <title>Soundcljoud, or a young man's Soundcloud clonejure</title>
    <updated>2024-07-09T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2024-07-09-soundcljoud-preview.jpg" alt="A stack of CDs. Photo by Brett Jordan on Unsplash." title="I should be allowed to glue my poster" width=800px /></p><p>😱 <strong>Warning!</strong></p><p>This blog post is ostensibly about Clojure (of the <a href='https://babashka.org/'>Babashka</a> variety), but not long after starting to write it, I found myself some 3100 words into some rambling exposition about the history of audio technology and how it intersected with my life, and had not typed the word "ClojureScript" even once (though it may appear that I've now typed it twice, I actually wrote this bit <em>post scriptum</em>, but decided to attach it before the post, which I suppose makes it a prelude and not a postscript, but I digress).</p><p>Whilst this won't surprise returning readers, I thought it worth warning first-timers, and offering all readers the chance to <a href='#omg_finally_stuff_about_clojure'>skip
over</a> all the stage-setting and other self-indulgent nonsense, simply by clicking the link that says "skip over".</p><p>If you'd like to delay your gratification, you are in luck! Read on, my friend!</p><h2 id="rambling_exposition">Rambling exposition</h2><p>Once upon a time there were vinyl platters into which the strategic etching of grooves could encode sound waves. If one placed said platter on a table and turned it say, 78 times a minute, and attached a needle to an arm and dropped it onto the rotating platter, one could use the vibrations in the arm caused by the needle moving left and right in the grooves to decode the sound waves, which you then turn into a fluctuating electric current and cause it to flow through a coil which vibrates some fabric and reproduces the sound waves. And this was good, for we could listen to music.</p><p>The only problem with these "records", as they were called, is that they were kinda big and you couldn't fit them in your backpack. So <a href='https://en.wikipedia.org/wiki/Compact_Disc_Digital_Audio#History'>some Dutch people and
some Japanese people teamed
up</a> and compacted the records, and renamed them discs because a record is required by law (in Japan) to be a certain diameter or you can't call it a record. They decided to make them out of plastic instead of vinyl, and then realised that they couldn't cut grooves into plastic because they kept breaking the discs—perhaps the choice of hammer and chisel to cut the grooves wasn't ideal, but who am I to judge? "だってさ、" said one of the Japanese engineers, "<a href='https://translate.google.com/?sl=ja&tl=en&text=%E9%87%9D%E3%81%A7%E3%83%87%E3%82%A3%E3%82%B9%E3%82%AF%E3%81%AB%E3%81%A1%E3%81%A3%E3%81%A1%E3%82%83%E3%81%84%E7%A9%B4%E3%82%84%E3%81%A3%E3%81%9F%E3%82%89%E3%80%81%E3%81%A9%E3%81%86%E3%81%AA%E3%82%8B%E3%81%8B%E3%81%AA&op=translate'>針でディスクにちっちゃい穴やったら、どうなるかな</a>？" No one knew, so they just tried it, and lo! the disc didn't break! But also lo! poking at the disc with a needle made little bumps on the other side of the disc, because of the law of conservation of mass or something... I don't know, I had to drop out of physics in uni because it apparently takes me 20 hours to solve one simple orbital mechanics problem; I mean, come on, how hard is it to calculate the orbit of a planet around three stars? Jeez.</p><p>But anyway, they made some bumps, which was annoying at first but then turned out to be a very good thing indeed when someone had the realisation that if squinted at the disc with a binary way of thinking, you could consider a bump to be a 1, and a flat place on the disc to be a 0, and then if you were to build a digital analyser that sampled the position of a sound wave, say, 44,100 times a second and wrote down the results in binary, you could encode the resulting string of 1s and 0s onto the disc with a series of bumps.</p><p>But how to decode the bumps when trying to play the thing back? The solution was super obvious this time: a frickin' laser beam! (Frickin' laser beams were on everyone's mind back in the early 80s because of Star Wars—the movies; the <a href='https://en.wikipedia.org/wiki/Strategic_Defense_Initiative'>missile defence
system</a> wouldn't show up until a few years later). If they just fired a frickin' laser beam continuously whilst rotating the disc and added a photodiode next to the laser, the light bouncing back off a bump would knock the wavelength of the light 1/2 out of phase, which would partially cancel the reflected light, lowering the intensity, which the photodiode would pick up and interpret as a 1. Obviously.</p><p>Except for one thing. Try as they might, the engineers couldn't make the frickin' laser beam bounce off the frickin' surface of the frickin' polycarbonate. If the plastic was too dark, it just absorbed the light, and if it was too light, it certainly reflected it, but not with high enough intensity for the photodiode to tell the difference between a 1 and a 0. 😢</p><p>This was a real head-scratcher, and they were well and truly stuck until one day one of the Dutch engineers was enjoying a beer from a frosty glass at a table at an outdoor cafe on Museumplein on a hot day and the condensation on the glass made the coaster stick to the bottom of the glass in the annoying way it does when one doesn't put a little table salt on the coaster first—amateur!—and the coaster fell into a nearby ashtray (people used to put these paper tubes stuffed with tobacco in their mouths, light them on fire, and suck the smoke deep into their lungs; ask your parents, kids) and got all coated in ash. The engineer wrinkled their nose in disgust before having an amazing insight. "What if," they thought to themselves, "we coated one side of the polycarbonate with something shiny that would reflect the frickin' laser?" Their train of thought then continued thusly: "And what is both reflective and cheap? Why, this selfsame aluminium of which this here ashtray is constructed!"</p><p>And thus the last engineering challenge was overcome, and there was much rejoicing!</p><p>The first test of the technology was a recording of <a href='https://en.wikipedia.org/wiki/Compact_disc#Initial_launch_and_adoption'>Richard Strauss's "An
Alpine
Symphony"</a> made in the beginning of December 1981, which was then presented to the world the following spring. It took a whole year before the first commercial compact disc was released, and by 1983, the technology had really taken off, thus introducing digital music to the world and ironically sowing the seeds of the format's demise. But I'm getting ahead of myself again.</p><p>Sometime around 1992, give or take, my parents got me a portable CD player (by this time, people, being ~lazy~ efficient by nature, had stopped saying "compact disc" and started abbreviating it to "CD") and one disc: Aerosmith's tour de force <a href='https://www.discogs.com/release/370731-Aerosmith-Get-A-Grip'>"Get a
Grip"</a>. Thus began a period of intense musical accumulation by yours truly.</p><p>But remember when I said the CD format contained within it the seeds of its own demise? Quoth <a href='https://en.wikipedia.org/wiki/MP3#Background'>Wikipedia</a>, and verily thus:</p><blockquote><p> In 1894, the American physicist Alfred M. Mayer reported that a tone could be  rendered inaudible by another tone of lower frequency. In 1959, Richard Ehmer  described a complete set of auditory curves regarding this phenomenon. Between  1967 and 1974, Eberhard Zwicker did work in the areas of tuning and masking of  critical frequency-bands, which in turn built on the fundamental research in  the area from Harvey Fletcher and his collaborators at Bell Labs </p></blockquote><p>You see where this is going, right? Good, because I wouldn't want to condescend to you by mentioning things like space-efficient compression with transforming Fouriers into <a href='https://en.wikipedia.org/wiki/Fast_Fourier_transform'>Fast
Fouriers</a> modifying discrete cosines and other trivia that would bore any 3rd grade physics student.</p><p>So anyway, <a href='https://en.wikipedia.org/wiki/Fraunhofer_Society'>some Germans</a> scribbled down an algorithm and convinced the <a href='https://en.wikipedia.org/wiki/Moving_Picture_Experts_Group'>Motion Picture Experts
Group</a> to standardise it as the MPEG-1 Audio Layer III format, and those Germans somehow patented this "innovation" that no one had even bothered to write down because it was so completely obvious to anyone who bothered to think about it for more than the time a CD takes to revolve once or twice. This patent enraged such people as <a href='https://en.wikipedia.org/wiki/Richard_Stallman'>Richard Stallman</a> (who, to be fair, is easily enraged by such minor things as people objecting to his mysogyny and <a href='https://en.wikipedia.org/wiki/Richard_Stallman#Comments_about_Jeffrey_Epstein_scandal'>opinions on the acceptability of romantic relationships with
minors</a>), leading some people to develop a technically superior <strong>and</strong> <a href='https://en.wiktionary.org/wiki/free_as_in_beer'>free as in
beer</a> audio coding format that they named after a <a href='https://en.wikipedia.org/wiki/List_of_Discworld_characters#Vorbis'>Terry Pratchett
character</a> and a <a href='https://en.wikipedia.org/wiki/Netrek'>clone</a> of a <a href='https://en.wikipedia.org/wiki/Empire_(PLATO&#41;'>clone</a> of <a href='https://en.wikipedia.org/wiki/Spacewar!'>Spacewar!</a>. The name, if you haven't guessed it by now from the copious amount of clues I've dropped here (just call me <a href='https://en.wikipedia.org/wiki/List_of_Cluedo_characters#Colonel_Mustard'>Colonel
Mustard</a>) was <a href='https://en.wikipedia.org/wiki/Vorbis'>Ogg Vorbis</a>.</p><p>By early summer 2005, I had accumulated a large quantity of CDs, which weighed roughly a metric shit-tonne. In addition to the strain they placed on my poor second- or third-hand bookshelves, I was due to move to Japan in the fall, and suspected that the sheer mass of my collection would interfere with the ability of whatever plane I would be taking to Tokyo to become airborne, which would be a real bummer. However, a solution presented itself, courtesy of one of the technical shortcomings of the compact disc technology itself.</p><p>Remember how CDs have this metallic layer that reflects the laser back at the sensor? Turns out that this layer is quite vulnerable, and a scratch that removes even a tiny bit of the metal results in the laser not being reflected as the disc rotates past the missing metal, which causes that block of data to be discarded by the player as faulty. To recover from this, the player would <a href='https://en.wikipedia.org/wiki/Skip_(audio_playback'>do one
of the
following</a>#Basic_players):</p><ol><li>Repeat the previous block of audio</li><li>Skip the faulty block</li><li>Try and retry to read it, causing a stopping and starting of the music</li></ol><p>For the listener, this is a sub-optimal auditory experience, and most listeners don't like any sub mixed in with their optimal.</p><p>Luckily, consumer-grade CD recorders started appearing in the mid-90s, when <a href='https://en.wikipedia.org/wiki/CD-R'>HP
released the first sub-$1000 model</a>. As a teenager in the 90s, I certainly couldn't afford $1000, but in 1997, I started working as a PC repair technician, and we had a CD "burner" (as they were known back then, not to be confused with a "burner" phone, which didn't exist back then, at least not in the cultural zeitgeist of the time) for such uses as device drivers which were too big to fit on a 3.5 inch "floppy" disk (those disks weren't actually floppy, but their 5.25 inch predecessors certainly were). I sensed an opportunity to protect my investment in digital music by "ripping" my discs (transferring the data on a CD onto the computer) and then burning them back to recordable CDs, at which point I could leave the original CD in its protective case and only expose my copy to the harsh elements.</p><p>Of course, one could also leave the ripped audio on one's computer and listen to it at any time of one's choosing, which was really convenient since you didn't have to change CDs when the disc ended or you were just in the mood to listen to something different. The problem is that the raw audio from the CDs (encoded in the WAV format that even modern people are probably familiar with) was fairly large, with a single CD taking up as much as 700MB of space. That may not seem like much until you know that most personal computers in the late 90s had somewhere between 3 and 16 GB of storage, which was enough to store between 20 and 220 CDs, assuming you had nothing else on the drive, which was unlikely since you needed to have software for playing back the files which meant you needed an operating system such as Windows...</p><p>To move somewhat more rapidly to the point, one solution to the issue of space was rooted in an even older technology than the compact disc (though younger than the venerable phonograph record): the cassette tape! A cassette tape was... OK, given that I've written nigh upon 2000 words at this point without mentioning Soundcloud or ClojureScript, perhaps I'll just link you to <a href='https://en.wikipedia.org/wiki/Cassette_tape'>the
Wikipedia article on the cassette
tape</a> instead of attempting to explain how it works in an amusing (to me) fashion. Interesting (to me) sidenote, though: the cassette tape was also invented by our intrepid Dutch friends over at Philips! 🤯</p><p>And my point was... oh yeah, mixtapes! Cassette tapes were one of the first media that gave your average consumer access to a recorder at an affordable price (the earliest such media that I know of was the <a href='https://en.wikipedia.org/wiki/Reel-to-reel_audio_tape_recording'>reel-to-reel
tape</a>, which was like a giant cassette tape without the plastic bit that protects the tape), and in addition to <a href='https://genius.com/Mop-follow-instructions-lyrics'>stuffing tissue in the top of a tape just to record Marley
Marl</a> that we borrowed from our friend down the street, we also made "mixtapes", an alchemical process whereby we boiled down our tape collection and extracted only the bangers (or tear-jerkers, or hopelessly optimistic love songs, or whatever mood we were trying to capture) and recorded those onto a tape, giving us 60 minutes of magic to blare in our cars or hand to that cutie in chemistry class to try and win their affection.</p><p>With the invention of the CD and the burner, we were back in the mixtape business, and this time we had up to 80 minutes to express ourselves. By the time I entered university back in 19&#42;cough&#42;, I had saved up enough from my job as a PC technician to buy my own burner, and at university, I gained somewhat of a reputation as a mixtape maestro. People would bring me a stack of CDs and ask me to produce a mixtape to light up the dancefloor or get heads nodding along to the dope-ass DJs of the time (I'm looking at you, <a href='https://en.wikipedia.org/wiki/DJ_Premier'>Premo</a>!), and also pick a cheeky title to scrawl onto the recordable CD in Sharpie. The one that sticks in my memory was called "The Wu Tang Clan ain't Nothin' to Fuck With"...</p><p>OK, but anyway, what if 80 minutes wasn't enough? Remember several minutes of rambling ago when I mentioned the MPEG-1 Audio Layer III format, and you may (or may not) have been like, "WTF is that?" What if I told you that MPEG-1 Audio Layer III is usually referred to by its initials (kinda): MP3? Now you see where I'm going, right? By taking raw CD audio and compressing it with the MP3 encoding algorithm, one could now fit something like 140 songs onto a recordable CD (assuming 5MB per song and 700MB of capacity on the CD), or roughly 10 albums.</p><p>So back to the summer of 2005, when I'm getting ready to move to Japan and I realise I can't realistically take all of my CDs with me. What do I do? I rip them onto my computer, encode them as not as MP3s but as Ogg Vorbis files because, y'know, freedom and stuff, burn them onto a recordable CD along with ~9 of their compatriots, and pack them in a box, write their names on a <a href='https://en.wikipedia.org/wiki/Bill_of_lading'>bill of
lading</a> which I tape to the box once it gets full, and then store the box in my parents' basement. The freshly recorded backup CD goes into once of those big CD case thingies that we used to have:</p><p><img src="assets/2024-07-09-soundcljoud-cd-case.jpg" alt="A black Case Logic 200 CD capacity case" title="That's not a case, mate. This is a case!" width=800px /></p><p>My CD ripping frenzy was concluded in time for my move to Japan, but did not end there, because I ended up getting a job at <a href='https://amazon.co.jp'>this bookstore that also sold CDs and
other stuff</a>, and publishers would send books and CDs to the buyers that worked at said bookstore, who would then decided if and how many copies of said books and CDs to buy for stock, and then usually put the books and CDs on a shelf in a printer room, where random bookstore employees such as myself were welcome to take them. So I got some cool books, and loads and loads of CDs, many of them from Japanese artists, which were promptly ripped, Ogg Vorbisified, and written to a 500GB USB hard drive that I had bought from the bookstore with my employee discount. Hurrah!</p><p>And thus when 2008 rolled around and I left Tokyo for Dublin, I did so with the vast majority of my music safely encoded into Ogg Vorbis format and written to spinning platters. Sadly, my sojourn on the shamrock shores of the Emerald Isle didn't last long, but happily, my next stop in Stockholm has been of the more permanent variety. By the time I moved here in 2010, Apple and Amazon's MP3 stores were starting to become passé, with streaming services replacing them as the Cool New Thing™, led a brash young Swedish startup called Spotify. And lo! did my collection of Ogg Vorbis files seem unnecessary, since I could now play every song ever recorded whenever I wanted to without having to lug around a hard drive full of files.</p><p>Except, at some point, some artists decided that they didn't want their music on Spotify, <a href='https://www.billboard.com/business/streaming/neil-young-spotify-joe-rogan-vaccines-letter-remove-music-1235022525/'>some for admirable
reasons</a> and others for, um, other rea$on$, and now I couldn't listen to every song ever recorded whenever I wanted to without having to lug around a hard drive full of files. Plus Spotify never had a lot of the Japanese music that I had on file. This was suboptimal to be sure, but my laziness overwhelmed my desire to listen to all of my music, until one fateful day that I was sad about something and decided that I absolutely had to listen to some really sad country music, and the first song that came to mind was Garth Brook's "<a href='https://www.youtube.com/watch?v=nIHWhUVxJh8'>Much too Young to Feel this
Damn Old</a>". Much to my dismay, Garth was one of those artists who had withheld their catalogue from Spotify, meaning I had to resort to a cover of the song instead.</p><p>My sadness was replaced by rage, and I turned to Clojure to exact my revenge on Spotify for not having reached terms to licence music from one of the greatest Country & Western recording artists of all time!</p><h2 id="omg_finally_stuff_about_clojure">OMG finally stuff about Clojure</h2><p>If you wisely clicked the link at the beginning to skip my <a href='#Rambling_exposition'>rambling
exposition</a>, welcome to a discussion of how I solved a serious problem caused by a certain <a href='https://en.wikipedia.org/wiki/Garth_Brooks'>Country & Western super-duper
star</a> (much like VA Beach legend <a href='https://en.wikipedia.org/wiki/Timbaland_&_Magoo'>Magoo</a>—RIP—<a href='https://genius.com/Timbaland-and-magoo-cop-that-shit-lyrics'>on every CD, he
spits 48 bars</a>) wisely flicking the V at the odious <a href='https://en.wikipedia.org/wiki/Daniel_Ek'>Daniel
Ek</a> and the terrible <a href='https://en.wikipedia.org/wiki/Tim_Cook'>Tim
Cook</a> but <a href='https://uproxx.com/indie/garth-brooks-spotify-amazon-streaming-apple-music/'>somehow being
A-OK</a> with <a href='https://en.wikipedia.org/wiki/Jeff_Bezos'>an even more repulsive
billionaire</a>'s streaming service.</p><p>To briefly recap, I really wanted to listen to some tear jerkin' country, and Spotify doesn't carry Garth, but I had purchased <a href='https://en.wikipedia.org/wiki/Garth_Brooks_discography'>all of his
albums</a> on CD back in the day (all the ones recorded before 1998, anyway) and ripped them into Ogg Vorbis format. Which is great, because I can listen to Garth anytime I want, as long as that desire occurs whilst I happen to be sitting within reach of the laptop onto which I copied all of those files. However, I like to do such things as not sit within reach of my laptop all the time, so now I'm back to square almost one.</p><p>One day, as I was bemoaning my fate, I had a flash of inspiration! What if I put those files somewhere a web browser could reach them, and then I could listen to them anytime I happened to be sitting within reach of a web browser, which is basically always, since I have a web browser that fits in my pocket (I think it can also make "phone calls", whatever those are). For example, I could upload them to <a href='https://soundcloud.com/'>Soundcloud</a>. The only problem with that is that Soundcloud would claim that I was infringing on Garth's copyright, and they'd kinda have a point, since not only could I listen to "<a href='https://en.wikipedia.org/wiki/The_Beaches_of_Cheyenne'>The Beaches of
Cheyenne</a>" anytime I wanted to, having obtained a licence to do so by virtue of forking over $15 back in 1996 for a piece of plastic dipped in metal, but so could any random person with an internet connection.</p><p>This left me with only one option: clone Soundcloud! With Clojure! And call it Soundcljoud because I just can't help myself! And write a long and frankly absurdly self-indulgent blog post about it!</p><h2 id="ok_really_clojure_now_i_promise">OK really Clojure now I promise</h2><p>As I mentioned, I have a bunch of Ogg Vorbis files on my laptop:</p><pre class="language-text"><code class="lang-text language-text">: jmglov@alhana; ls -1 &#126;/Music/g/Garth\ Brooks/
'Beyond the Season'
'Double Live &#40;Disc 1&#41;'
'Double Live &#40;Disc 2&#41;'
'Fresh Horses'
'Garth Brooks'
'In Pieces'
'No Fences'
&quot;Ropin' the Wind&quot;
Sevens
'The Chase'
'The Hits'
</code></pre><p>I also have <a href='https://babashka.org/'>Babashka</a>:</p><p><img src="assets/2024-07-09-soundcljoud-bb.png" alt="A logo of a face wearing a red hoodie with orange sunglasses featuring the Soundcloud logo" title="Above the clouds, infinite skills create miracles" /></p><p>So let's get to cloning!</p><p>The basic idea is to turn these Ogg Vorbis files into MP3 files, which the standard <a href='https://developer.mozilla.org/en-US/docs/Web/HTML/Element/audio'>&lt;audio&gt; HTML
element</a> knows how to play, and then wrap a little ClojureScript around that element to stuff my sweet sweet country music into the <code>&lt;audio&gt;</code> element and then call it a day.</p><p>We'll accomplish the first part with Babashka and some command-line tools. I'll start by creating a new directory and dropping a <code>bb.edn</code> into it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;src&quot; &quot;resources&quot;&#93;}
</code></pre><p>Now I can create a <code>src/soundcljoud/main.clj</code> like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns soundcljoud.main
  &#40;:require &#91;babashka.fs :as fs&#93;
            &#91;babashka.process :as p&#93;
            &#91;clojure.string :as str&#93;&#41;&#41;
</code></pre><p>Firing up a REPL in my trusty Emacs with <code>C-c M-j</code> and then evaluating the buffer with <code>C-c C-k</code>, let me introduce Babashka to good ol' Garth:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def dir &#40;fs/file &#40;fs/home&#41; &quot;Music/g/Garth Brooks/Fresh Horses&quot;&#41;&#41; ; C-c C-v f c e
  ;; =&gt; #'soundcljoud.main/dir

&#41;
</code></pre><p>If you're a returning reader, you'll of course have translated <code>C-c C-k</code> to Control + c <pause> Control + k in your head and <code>C-c C-v f c e</code> to Control + c <pause> Control + v <pause> f <pause> c <pause> e and understood that they mean <code>cider-load-buffer</code> and <code>cider-pprint-eval-last-sexp-to-comment</code>, respectively. If you're a first-timer, what's happening here is that I'm using a so-called <a href='https://betweentwoparens.com/blog/rich-comment-blocks/#rich-comment'>Rich
comment</a> (which protects the code within the <code>&#40;comment&#41;</code> form from evaluation when the buffer is evaluated) to evaluate forms one at a time as I REPL-drive my way towards a working program, for this is The Lisp Way.</p><p>Let's take a look at the Ogg Vorbis files in this directory:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;fs/glob dir &quot;&#42;.ogg&quot;&#41;
       &#40;map str&#41;&#41;
  ;; =&gt; &#40;&quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - Ireland.ogg&quot;
  ;;     &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Fever.ogg&quot;
  ;;     &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - She's Every Woman.ogg&quot;
  ;;     &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Old Stuff.ogg&quot;
  ;;     &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - Rollin'.ogg&quot;
  ;;     &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Beaches of Cheyenne.ogg&quot;
  ;;     &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - That Ol' Wind.ogg&quot;
  ;;     &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - It's Midnight Cinderella.ogg&quot;
  ;;     &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Change.ogg&quot;
  ;;     &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - Cowboys and Angels.ogg&quot;&#41;

&#41;
</code></pre><p>Knowing my fastidious nature, I bet I wrote some useful tags into those Ogg files. Let's use <code>vorbiscomment</code> to check:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;fs/glob dir &quot;&#42;.ogg&quot;&#41;
       &#40;map str&#41;
       first
       &#40;p/shell {:out :string} &quot;vorbiscomment&quot;&#41;
       :out
       str/split-lines&#41;
  ;; =&gt; &#91;&quot;title=Ireland&quot; &quot;artist=Garth Brooks&quot; &quot;album=Fresh Horses&quot;&#93;

&#41;
</code></pre><p>Most excellent! With a tiny bit more work, we can turn these strings into a map:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;fs/glob dir &quot;&#42;.ogg&quot;&#41;
       &#40;map str&#41;
       first
       &#40;p/shell {:out :string} &quot;vorbiscomment&quot;&#41;
       :out
       str/split-lines
       &#40;map #&#40;let &#91;&#91;k v&#93; &#40;str/split % #&quot;=&quot;&#41;&#93; &#91;&#40;keyword k&#41; v&#93;&#41;&#41;
       &#40;into {}&#41;&#41;
  ;; =&gt; {:title &quot;Ireland&quot;, :artist &quot;Garth Brooks&quot;, :album &quot;Fresh Horses&quot;}

&#41;
</code></pre><p>And now I think we're ready to write a function that takes a filename and returns this info:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn track-info &#91;filename&#93;
  &#40;-&gt;&gt; &#40;p/shell {:out :string} &quot;vorbiscomment&quot; filename&#41;
       :out
       str/split-lines
       &#40;map #&#40;let &#91;&#91;k v&#93; &#40;str/split % #&quot;=&quot;&#41;&#93; &#91;&#40;keyword k&#41; v&#93;&#41;&#41;
       &#40;into {}&#41;
       &#40;merge {:filename filename}&#41;&#41;&#41;
</code></pre><p>Now that we've established that we have some Ogg Vorbis files with appropriate metadata, let's jump in the hammock for a second and think about how we want to proceed. What we're actually trying to accomplish is to make these tracks playable on the web. What if we create a podcast RSS feed per album, then we can use any podcast app to play the album?</p><h2 id="faking_a_podcast_with_selmer">Faking a podcast with Selmer</h2><p>Let's go this route, since it seems like very little work! We'll start by creating a <a href='https://github.com/yogthos/Selmer'>Selmer</a> template in <code>resources/album-feed.rss</code>:</p><pre class="language-xml"><code class="lang-xml language-xml">&lt;?xml version='1.0' encoding='UTF-8'?&gt;
&lt;rss version=&quot;2.0&quot;
     xmlns:itunes=&quot;http://www.itunes.com/dtds/podcast-1.0.dtd&quot;
     xmlns:atom=&quot;http://www.w3.org/2005/Atom&quot;&gt;
  &lt;channel&gt;
    &lt;atom:link
        href=&quot;{{base-url}}/{{album|urlescape}}/album.rss&quot;
        rel=&quot;self&quot;
        type=&quot;application/rss+xml&quot;/&gt;
    &lt;title&gt;{{artist}} - {{album}}&lt;/title&gt;
    &lt;link&gt;{{link}}&lt;/link&gt;
    &lt;pubDate&gt;{{date}}&lt;/pubDate&gt;
    &lt;lastBuildDate&gt;{{date}}&lt;/lastBuildDate&gt;
    &lt;ttl&gt;60&lt;/ttl&gt;
    &lt;language&gt;en&lt;/language&gt;
    &lt;copyright&gt;All rights reserved&lt;/copyright&gt;
    &lt;webMaster&gt;{{owner-email}}&lt;/webMaster&gt;
    &lt;description&gt;Album: {{artist}} - {{album}}&lt;/description&gt;
    &lt;itunes:subtitle&gt;Album: {{artist}} - {{album}}&lt;/itunes:subtitle&gt;
    &lt;itunes:owner&gt;
      &lt;itunes:name&gt;{{owner-name}}&lt;/itunes:name&gt;
      &lt;itunes:email&gt;{{owner-email}}&lt;/itunes:email&gt;
    &lt;/itunes:owner&gt;
    &lt;itunes:author&gt;{{artist}}&lt;/itunes:author&gt;
    &lt;itunes:explicit&gt;no&lt;/itunes:explicit&gt;
    &lt;itunes:image href=&quot;{{image}}&quot;/&gt;
    &lt;image&gt;
      &lt;url&gt;{{image}}&lt;/url&gt;
      &lt;title&gt;{{artist}} - {{album}}&lt;/title&gt;
      &lt;link&gt;{{link}}&lt;/link&gt;
    &lt;/image&gt;
    {% for track in tracks %}
    &lt;item&gt;
      &lt;itunes:title&gt;{{track.title}}&lt;/itunes:title&gt;
      &lt;title&gt;{{track.title}}&lt;/title&gt;
      &lt;itunes:author&gt;{{artist}}&lt;/itunes:author&gt;
      &lt;enclosure
          url=&quot;{{base-url}}/{{artist|urlescape}}/{{album|urlescape}}/{{track.mp3-filename|urlescape}}&quot;
          length=&quot;{{track.mp3-size}}&quot; type=&quot;audio/mpeg&quot; /&gt;
      &lt;pubDate&gt;{{date}}&lt;/pubDate&gt;
      &lt;itunes:duration&gt;{{track.duration}}&lt;/itunes:duration&gt;
      &lt;itunes:episode&gt;{{track.number}}&lt;/itunes:episode&gt;
      &lt;itunes:episodeType&gt;full&lt;/itunes:episodeType&gt;
      &lt;itunes:explicit&gt;false&lt;/itunes:explicit&gt;
    &lt;/item&gt;
    {% endfor %}
  &lt;/channel&gt;
&lt;/rss&gt;
</code></pre><p>If you're not familiar with Selmer, the basic idea is that anything inside <code>{{}}</code> tags is a variable, and you also have some looping constructs like <code>{% for %}</code> and so on. So let's look at the variables that we slapped in that template:</p><p>General info:</p><ul><li>base-url</li><li>owner-name</li><li>owner-email</li></ul><p>Album-specific stuff:</p><ul><li>album</li><li>artist</li><li>link</li><li>date</li><li>image</li></ul><p>Track-specific stuff:</p><ul><li>track.title</li><li>track.mp3-filename</li><li>track.mp3-size</li><li>track.duration</li><li>track.number</li></ul><p>OK, so where are we going to get all this? The general info is easy; we can just decide what we want it to be and slap it in a variable:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def opts {:base-url &quot;http://localhost:1341&quot;
             :owner-name &quot;Josh Glover&quot;
             :owner-email &quot;jmglov@jmglov.net&quot;}&#41;
  ;; =&gt; #'soundcljoud.main/opts

&#41;
</code></pre><p>The album-specific stuff is a little more challenging. <code>album</code> and <code>artist</code> we can get from our <code>track-info</code> function, and <code>link</code> can be something like <code>base-url</code> + <code>artist</code> + <code>album</code>, but what about <code>date</code> (the date the album was released) and <code>image</code> (the cover image of the album)? Well, for this we can use a music database that offers API access, such as <a href='https://www.discogs.com/'>Discogs</a>. Let's start by creating an account and then visiting the <a href='https://www.discogs.com/settings/developers'>Developers settings
page</a> to generate a personal access token, which we'll save in <code>resources/discogs-token.txt</code>. With this in hand, let's try searching for an album. We'll need to add an HTTP client (luckily, <a href='https://github.com/babashka/http-client'>Babashka ships with one</a>), a JSON parser (luckily, <a href='https://github.com/dakrone/cheshire'>Babashka ships with one</a>) and a way to load the <code>resources/discogs-token.txt</code> to our namespace, then we can use the API.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns soundcljoud.main
  &#40;:require &#91;babashka.fs :as fs&#93;
            &#91;babashka.process :as p&#93;
            &#91;clojure.string :as str&#93;
            ;; ⬇⬇⬇ New stuff ⬇⬇⬇
            &#91;babashka.http-client :as http&#93;
            &#91;cheshire.core :as json&#93;
            &#91;clojure.java.io :as io&#93;&#41;&#41;

&#40;comment

  &#40;def discogs-token &#40;-&gt; &#40;io/resource &quot;discogs-token.txt&quot;&#41;
                         slurp
                         str/trim-newline&#41;&#41;
  ;; =&gt; #'soundcljoud.main/discogs-token

  &#40;def album-info &#40;-&gt;&gt; &#40;fs/glob dir &quot;&#42;.ogg&quot;&#41;
                       &#40;map str&#41;
                       first
                       track-info&#41;&#41;
  ;; =&gt; #'soundcljoud.main/album-info

  &#40;-&gt; &#40;http/get &quot;https://api.discogs.com/database/search&quot;
                {:query-params {:artist &#40;:artist album-info&#41;
                                :release&#95;title &#40;:album album-info&#41;
                                :token discogs-token}
                 :headers {:User-Agent &quot;SoundCljoud/0.1 +https://jmglov.net&quot;}}&#41;
      :body
      &#40;json/parse-string keyword&#41;
      :results
      first&#41;
  ;;  {:format &#91;&quot;CD&quot; &quot;Album&quot;&#93;,
  ;;   :master&#95;url &quot;https://api.discogs.com/masters/212114&quot;,
  ;;   :cover&#95;image
  ;;   &quot;https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ&#95;8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg&quot;,
  ;;   :title &quot;Garth Brooks - Fresh Horses&quot;,
  ;;   :style &#91;&quot;Country Rock&quot; &quot;Pop Rock&quot;&#93;,
  ;;   :year &quot;1995&quot;,
  ;;   :id 212114,
  ;;   ...
  ;;  }

&#41;
</code></pre><p>This looks very promising indeed! We now have the release year, which we can put in our RSS feed as <code>date</code>, and the cover image, which we can put in <code>image</code>. Now let's grab info for the tracks:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def master-url &#40;:master&#95;url &#42;1&#41;&#41;
  ;; =&gt; #'soundcljoud.main/master-url

&#41;
</code></pre><p>That <code>&#40;:master&#95;url &#42;1&#41;</code> thing might be new to you, so let me explain before we continue. The REPL keeps track of the result of the last three evaluations, and binds them to <code>&#42;1</code>, <code>&#42;2</code>, and <code>&#42;3</code>. So <code>&#40;:master&#95;url &#42;1&#41;</code> says "give me the <code>:master&#95;url</code> key of the result of the last evaluation, which I assume is a map or I'm <a href='https://www.urbandictionary.com/define.php?term=shit%20out%20of%20luck'>SOL</a>".</p><p>OK, back to the fetching track info:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def master-url &#40;:master&#95;url &#42;1&#41;&#41;
  ;; =&gt; #'soundcljoud.main/master-url

  &#40;-&gt; &#40;http/get master-url
                {:query-params {:token discogs-token}
                 :headers {:User-Agent &quot;SoundCljoud/0.1 +https://jmglov.net&quot;}}&#41;
      :body
      &#40;json/parse-string keyword&#41;
      :tracklist&#41;
  ;; =&gt; &#91;{:position &quot;1&quot;,
  ;;      :title &quot;The Old Stuff&quot;,
  ;;      :duration &quot;4:12&quot;}
  ;;     {:position &quot;2&quot;,
  ;;      :title &quot;Cowboys And Angels&quot;,
  ;;      :duration &quot;3:16&quot;}
  ;;     ...
  ;;    &#93;

&#41;
</code></pre><p>We now have all the pieces, so let's clean this up by turning it into a series of functions:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def discogs-base-url &quot;https://api.discogs.com&quot;&#41;
&#40;def user-agent &quot;SoundCljoud/0.1 +https://jmglov.net&quot;&#41;

&#40;defn load-token &#91;&#93;
  &#40;-&gt; &#40;io/resource &quot;discogs-token.txt&quot;&#41;
      slurp
      str/trim-newline&#41;&#41;

&#40;defn api-get
  &#40;&#91;token path&#93;
   &#40;api-get token path {}&#41;&#41;
  &#40;&#91;token path opts&#93;
   &#40;let &#91;url &#40;if &#40;str/starts-with? path discogs-base-url&#41;
               path
               &#40;str discogs-base-url path&#41;&#41;&#93;
     &#40;-&gt; &#40;http/get url
                   &#40;merge {:headers {:User-Agent user-agent}}
                          opts&#41;&#41;
         :body
         &#40;json/parse-string keyword&#41;&#41;&#41;&#41;&#41;

&#40;defn search-album &#91;token {:keys &#91;artist album&#93;}&#93;
  &#40;api-get token &quot;/database/search&quot;
           {:query-params {:artist artist
                           :release&#95;title album
                           :token token}}&#41;&#41;

&#40;defn album-info &#91;token {:keys &#91;artist album&#93; :as metadata}&#93;
  &#40;let &#91;{:keys &#91;cover&#95;image master&#95;url year&#93;}
        &#40;-&gt;&gt; &#40;search-album token metadata&#41;
             :results
             first&#41;
        {:keys &#91;tracklist&#93;} &#40;api-get token master&#95;url&#41;&#93;
    &#40;merge metadata {:link master&#95;url
                     :image cover&#95;image
                     :year year
                     :tracks &#40;map &#40;fn &#91;{:keys &#91;title position&#93;}&#93;
                                    {:title title
                                     :artist artist
                                     :album album
                                     :number position
                                     :year year}&#41;
                                  tracklist&#41;}&#41;&#41;&#41;
</code></pre><p>Putting it all together, let's load all the album info in a format that's amenable to stuffing into our RSS template:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;tracks &#40;-&gt;&gt; &#40;fs/glob dir &quot;&#42;.ogg&quot;&#41;
                    &#40;map &#40;comp track-info fs/file&#41;&#41;&#41;&#93;
    &#40;album-info &#40;load-token&#41; &#40;first tracks&#41;&#41;&#41;&#41;
  ;; =&gt; {:title &quot;Ireland&quot;,
  ;;     :artist &quot;Garth Brooks&quot;,
  ;;     :album &quot;Fresh Horses&quot;,
  ;;     :link &quot;https://api.discogs.com/masters/212114&quot;,
  ;;     :image &quot;https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ&#95;8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg&quot;,
  ;;     :year &quot;1995&quot;,
  ;;     :tracks
  ;;     &#40;{:title &quot;The Old Stuff&quot;,
  ;;       :artist &quot;Garth Brooks&quot;,
  ;;       :album &quot;Fresh Horses&quot;,
  ;;       :year &quot;1995&quot;,
  ;;       :number 1}
  ;;      {:title &quot;Cowboys and Angels&quot;,
  ;;       :artist &quot;Garth Brooks&quot;,
  ;;       :album &quot;Fresh Horses&quot;,
  ;;       :year &quot;1995&quot;,
  ;;       :number 2}
  ;;      ...
  ;;      {:title &quot;Ireland&quot;,
  ;;       :artist &quot;Garth Brooks&quot;,
  ;;       :album &quot;Fresh Horses&quot;,
  ;;       :year &quot;1995&quot;,
  ;;       :number 10}&#41;}

&#41;
</code></pre><p>Now that we have a big ol' map containing all the metadata an RSS feed could possibly desire, let's use Selmer to turn our template into some actual RSS! We'll need to add Selmer itself to our namespace, and also grab some <code>java.time</code> stuff in order to produce the <a href='http://www.faqs.org/rfcs/rfc2822.html'>RFC 2822</a> datetime <a href='https://podcasters.apple.com/support/823-podcast-requirements'>required by the podcast RSS
format</a>, then we can get onto the templating itself.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns soundcljoud.main
  &#40;:require ...
            &#91;selmer.parser :as selmer&#93;&#41;
  &#40;:import &#40;java.time ZonedDateTime&#41;
           &#40;java.time.format DateTimeFormatter&#41;&#41;&#41;

&#40;def dt-formatter
  &#40;DateTimeFormatter/ofPattern &quot;EEE, dd MMM yyyy HH:mm:ss xxxx&quot;&#41;&#41;

&#40;defn -&gt;rfc-2822-date &#91;date&#93;
  &#40;-&gt; &#40;Integer/parseInt date&#41;
      &#40;ZonedDateTime/of 1 1 0 0 0 0 java.time.ZoneOffset/UTC&#41;
      &#40;.format dt-formatter&#41;&#41;&#41;

&#40;defn album-feed &#91;opts album-info&#93;
  &#40;let &#91;template &#40;-&gt; &#40;io/resource &quot;album-feed.rss&quot;&#41; slurp&#41;&#93;
    &#40;-&gt;&gt; &#40;update album-info :tracks
                 &#40;partial map #&#40;update % :mp3-filename fs/file-name&#41;&#41;&#41;
         &#40;merge opts {:date &#40;-&gt;rfc-2822-date &#40;:year album-info&#41;&#41;}&#41;
         &#40;selmer/render template&#41;&#41;&#41;&#41;

&#40;comment

  &#40;let &#91;tracks &#40;-&gt;&gt; &#40;fs/glob dir &quot;&#42;.ogg&quot;&#41;
                    &#40;map &#40;comp track-info fs/file&#41;&#41;&#41;&#93;
    &#40;-&gt;&gt; &#40;album-info &#40;load-token&#41; &#40;first tracks&#41;&#41;
         &#40;album-feed opts&#41;&#41;&#41;
  ;; =&gt; java.lang.NullPointerException soundcljoud.main
  ;; {:type :sci/error, :line 3, :column 53, ...}
  ;;  at sci.impl.utils$rethrow&#95;with&#95;location&#95;of&#95;node.invokeStatic &#40;utils.cljc:135&#41;
  ;;  ...
  ;; Caused by: java.lang.NullPointerException: null
  ;;  at babashka.fs$file&#95;name.invokeStatic &#40;fs.cljc:182&#41;
  ;;  ...

&#41;
</code></pre><p>Oops! It appears that <code>fs/file-name</code> is angry at us. Searching for it, we identify the culprit:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;partial map #&#40;update % :mp3-filename fs/file-name&#41;&#41;
</code></pre><p>Nowhere in our <code>album-info</code> map have we mentioned <code>:mp3-filename</code>, which actually makes sense given that we only have an Ogg Vorbis file and not an MP3. Let's see what we can do about that, shall we? (Spoiler: we shall.)</p><h2 id="converting_from_ogg_to_mp3">Converting from Ogg to MP3</h2><p>We'll honour Rich Hickey by decomplecting this problem into two problems:</p><ol><li>Converting an Ogg Vorbis file into a WAV</li><li>Converting a WAV into an MP3</li></ol><p>Let's start with problem #1 by taking a look at what we get back from <code>album-info</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;tracks &#40;-&gt;&gt; &#40;fs/glob dir &quot;&#42;.ogg&quot;&#41;
                    &#40;map &#40;comp track-info fs/file&#41;&#41;&#41;&#93;
    &#40;album-info &#40;load-token&#41; &#40;first tracks&#41;&#41;&#41;&#41;
  ;; =&gt; {:title &quot;Ireland&quot;,
  ;;     :artist &quot;Garth Brooks&quot;,
  ;;     :album &quot;Fresh Horses&quot;,
  ;;     :link &quot;https://api.discogs.com/masters/212114&quot;,
  ;;     :image &quot;https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ&#95;8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg&quot;,
  ;;     :year &quot;1995&quot;,
  ;;     :tracks
  ;;     &#40;{:title &quot;The Old Stuff&quot;,
  ;;       :artist &quot;Garth Brooks&quot;,
  ;;       :album &quot;Fresh Horses&quot;,
  ;;       :year &quot;1995&quot;,
  ;;       :number 1}
  ;;      {:title &quot;Cowboys and Angels&quot;,
  ;;       :artist &quot;Garth Brooks&quot;,
  ;;       :album &quot;Fresh Horses&quot;,
  ;;       :year &quot;1995&quot;,
  ;;       :number 2}
  ;;      ...
  ;;      {:title &quot;Ireland&quot;,
  ;;       :artist &quot;Garth Brooks&quot;,
  ;;       :album &quot;Fresh Horses&quot;,
  ;;       :year &quot;1995&quot;,
  ;;       :number 10}&#41;}

&#41;
</code></pre><p>The problem here is that we've lost the filename that came from <code>fs/glob</code>, so we have no idea which files we need to convert. Let's fix this by tweaking <code>album-info</code> to take the token and directory, rather than just the track info of the first file in the directory:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn normalise-title &#91;title&#93;
  &#40;-&gt; title
      str/lower-case
      &#40;str/replace #&quot;&#91;&#94;a-z&#93;&quot; &quot;&quot;&#41;&#41;&#41;

&#40;defn album-info &#91;token tracks&#93;
  &#40;let &#91;{:keys &#91;artist album&#93; :as track} &#40;first tracks&#41;
        track-filename &#40;-&gt;&gt; tracks
                            &#40;map &#40;fn &#91;{:keys &#91;filename title&#93;}&#93;
                                   &#91;&#40;normalise-title title&#41; filename&#93;&#41;&#41;
                            &#40;into {}&#41;&#41;
        {:keys &#91;cover&#95;image master&#95;url year&#93;}
        &#40;-&gt;&gt; &#40;search-album token track&#41;
             :results
             first&#41;
        {:keys &#91;tracklist&#93;} &#40;api-get token master&#95;url&#41;&#93;
    &#40;merge track {:link master&#95;url
                  :image cover&#95;image
                  :year year
                  :tracks &#40;map &#40;fn &#91;{:keys &#91;title position&#93;}&#93;
                                 {:title title
                                  :artist artist
                                  :album album
                                  :number position
                                  :year year
                                  :filename &#40;track-filename &#40;normalise-title title&#41;&#41;}&#41;
                               tracklist&#41;}&#41;&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; &#40;fs/glob dir &quot;&#42;.ogg&quot;&#41;
       &#40;map &#40;comp track-info fs/file&#41;&#41;
       &#40;album-info &#40;load-token&#41;&#41;&#41;
  ;; =&gt; {:artist &quot;Garth Brooks&quot;,
  ;;     :album &quot;Fresh Horses&quot;,
  ;;     :link &quot;https://api.discogs.com/masters/212114&quot;,
  ;;     :image
  ;;     &quot;https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ&#95;8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg&quot;,
  ;;     :year &quot;1995&quot;,
  ;;     :tracks
  ;;     &#40;{:title &quot;The Old Stuff&quot;,
  ;;       :artist &quot;Garth Brooks&quot;,
  ;;       :album &quot;Fresh Horses&quot;,
  ;;       :number &quot;1&quot;,
  ;;       :year &quot;1995&quot;,
  ;;       :filename
  ;;       #object&#91;java.io.File 0x96d79f0 &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Old Stuff.ogg&quot;&#93;}
  ;; ...
  ;;      {:title &quot;Ireland&quot;,
  ;;       :artist &quot;Garth Brooks&quot;,
  ;;       :album &quot;Fresh Horses&quot;,
  ;;       :number &quot;10&quot;,
  ;;       :year &quot;1995&quot;,
  ;;       :filename
  ;;       #object&#91;java.io.File 0x13968577 &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - Ireland.ogg&quot;&#93;}&#41;}

&#41;
</code></pre><p>Much better! Given this, let's convert this file into a WAV:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def info &#40;-&gt;&gt; &#40;fs/glob dir &quot;&#42;.ogg&quot;&#41;
                 &#40;map &#40;comp track-info fs/file&#41;&#41;
                 &#40;album-info &#40;load-token&#41;&#41;&#41;&#41;
  ;; =&gt; #'soundcljoud.main/info

  &#40;def tmpdir &#40;fs/create-dirs &quot;/tmp/soundcljoud&quot;&#41;&#41;
  ;; =&gt; #'soundcljoud.main/tmpdir

  &#40;let &#91;{:keys &#91;filename&#93; :as track} &#40;-&gt;&gt; info :tracks first&#41;
        out-filename &#40;fs/file tmpdir &#40;str/replace &#40;fs/file-name filename&#41;
                                                  &quot;.ogg&quot; &quot;.wav&quot;&#41;&#41;&#93;
    &#40;p/shell &quot;oggdec&quot; &quot;-o&quot; out-filename filename&#41;
    &#40;assoc track :wav-filename out-filename&#41;&#41;
  ;; =&gt; {:title &quot;The Old Stuff&quot;,
  ;;     :artist &quot;Garth Brooks&quot;,
  ;;     :album &quot;Fresh Horses&quot;,
  ;;     :number &quot;1&quot;,
  ;;     :year &quot;1995&quot;,
  ;;     :filename
  ;;     #object&#91;java.io.File 0x96d79f0 &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Old Stuff.ogg&quot;&#93;,
  ;;     :wav-filename
  ;;     #object&#91;java.io.File 0x4221dcb2 &quot;/tmp/soundcljoud/Garth Brooks - The Old Stuff.wav&quot;&#93;}

&#41;
</code></pre><p>Lovely! Let's make a nice function out of this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn ogg-&gt;wav &#91;{:keys &#91;filename&#93; :as track} tmpdir&#93;
  &#40;let &#91;out-filename &#40;fs/file tmpdir &#40;str/replace &#40;fs/file-name filename&#41;
                                                  &quot;.ogg&quot; &quot;.wav&quot;&#41;&#41;&#93;
    &#40;println &#40;format &quot;Converting %s -&gt; %s&quot; filename out-filename&#41;&#41;
    &#40;p/shell &quot;oggdec&quot; &quot;-o&quot; out-filename filename&#41;
    &#40;assoc track :wav-filename out-filename&#41;&#41;&#41;
</code></pre><p>Now let's see if problem #2 is equally tractable.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;{:keys &#91;filename artist album title year number&#93; :as track}
        &#40;-&gt;&gt; info :tracks first&#41;
        wav-file &#40;fs/file tmpdir
                          &#40;-&gt; &#40;fs/file-name filename&#41;
                              &#40;str/replace #&quot;&#91;.&#93;&#91;&#94;.&#93;+$&quot; &quot;.wav&quot;&#41;&#41;&#41;
        mp3-file &#40;str/replace wav-file &quot;.wav&quot; &quot;.mp3&quot;&#41;
        ffmpeg-args &#91;&quot;ffmpeg&quot; &quot;-i&quot; wav-file
                     &quot;-vn&quot;  ; no video
                     &quot;-q:a&quot; &quot;2&quot;  ; dynamic bitrate averaging 192 KB/s
                     &quot;-y&quot;  ; overwrite existing files without prompting
                     mp3-file&#93;&#93;
    &#40;p/shell &quot;ffmpeg&quot; &quot;-i&quot; wav-file
             &quot;-vn&quot;       ; no video
             &quot;-q:a&quot; &quot;2&quot;  ; dynamic bitrate averaging 192 KB/s
             &quot;-y&quot;        ; overwrite existing files without prompting
             mp3-file&#41;&#41;
  ;; =&gt; {:exit 0,
  ;;     ...
  ;;     }

  &#40;fs/size &quot;/tmp/soundcljoud/Garth Brooks - The Old Stuff.mp3&quot;&#41;
  ;; =&gt; 5941943

&#41;
</code></pre><p>Nice! There's one annoying thing about this, though. My Ogg Vorbis file had metadata tags telling me stuff and also things about the contents of the file, whereas my MP3 is inscrutable, save for the filename. Let's ameliorate this with our good friend <a href='https://id3v2.sourceforge.net/'>id3v2</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;{:keys &#91;filename artist album title year number&#93; :as track}
        &#40;-&gt;&gt; info :tracks first&#41;
        wav-file &#40;fs/file tmpdir
                          &#40;-&gt; &#40;fs/file-name filename&#41;
                              &#40;str/replace #&quot;&#91;.&#93;&#91;&#94;.&#93;+$&quot; &quot;.wav&quot;&#41;&#41;&#41;
        mp3-file &#40;str/replace wav-file &quot;.wav&quot; &quot;.mp3&quot;&#41;
        ffmpeg-args &#91;&quot;ffmpeg&quot; &quot;-i&quot; wav-file
                     &quot;-vn&quot;  ; no video
                     &quot;-q:a&quot; &quot;2&quot;  ; dynamic bitrate averaging 192 KB/s
                     &quot;-y&quot;  ; overwrite existing files without prompting
                     mp3-file&#93;&#93;
    &#40;p/shell &quot;id3v2&quot;
             &quot;-a&quot; artist &quot;-A&quot; album &quot;-t&quot; title &quot;-y&quot; year &quot;-T&quot; number
             mp3-file&#41;&#41;
  ;; =&gt; {:exit 0,
  ;;     ...
  ;;     }

  &#40;-&gt;&gt; &#40;p/shell {:out :string}
                &quot;id3v2&quot; &quot;--list&quot;
                &quot;/tmp/soundcljoud/Garth Brooks - The Old Stuff.mp3&quot;&#41;
       :out
       str/split-lines&#41;
  ;; =&gt; &#91;&quot;id3v1 tag info for /tmp/soundcljoud/Garth Brooks - The Old Stuff.mp3:&quot;
  ;;     &quot;Title  : The Old Stuff                   Artist: Garth Brooks&quot;
  ;;     &quot;Album  : Fresh Horses                    Year: 1995, Genre: Unknown &#40;255&#41;&quot;
  ;;     &quot;Comment:                                 Track: 1&quot;
  ;;     &quot;id3v2 tag info for /tmp/soundcljoud/Garth Brooks - The Old Stuff.mp3:&quot;
  ;;     &quot;TPE1 &#40;Lead performer&#40;s&#41;/Soloist&#40;s&#41;&#41;: Garth Brooks&quot;
  ;;     &quot;TALB &#40;Album/Movie/Show title&#41;: Fresh Horses&quot;
  ;;     &quot;TIT2 &#40;Title/songname/content description&#41;: The Old Stuff&quot;
  ;;     &quot;TRCK &#40;Track number/Position in set&#41;: 1&quot;&#93;

&#41;
</code></pre><p>There's an awful lot of copy and paste code here, so let's consolidate MP3 conversion and tag writing into a single function. We should also make sure that function returns a track info map that contains all the good stuff that our RSS template needs. Casting our mind back to the track-specific stuff, we need:</p><ul><li>track.title</li><li>track.number</li><li>track.mp3-filename</li><li>track.mp3-size</li><li>track.duration</li></ul><p><code>mp3-filename</code> we have, and <code>m3-size</code> we can get with the same <code>fs/size</code> call that we previously used to check if the MP3 file existed. <code>duration</code> is a little more interesting. What the RSS feed standard is looking for is a duration in one of the following formats:</p><ul><li>hours:minutes:seconds</li><li>minutes:seconds</li><li>seconds</li></ul><p>We can use the <a href='https://ffmpeg.org/ffprobe.html'>ffprobe</a> tool that ships with <a href='https://ffmpeg.org/'>FFmpeg</a> to get some info about the MP3:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; &#40;p/shell {:out :string}
               &quot;ffprobe -v quiet -print&#95;format json -show&#95;format -show&#95;streams&quot;
               &quot;/tmp/soundcljoud/01 - Garth Brooks - The Old Stuff.mp3&quot;&#41;
      :out
      &#40;json/parse-string keyword&#41;
      :streams
      first&#41;
  ;; =&gt; {:tags {:encoder &quot;Lavc60.3.&quot;},
  ;;     :r&#95;frame&#95;rate &quot;0/0&quot;,
  ;;     :sample&#95;rate &quot;44100&quot;,
  ;;     :channel&#95;layout &quot;stereo&quot;,
  ;;     :channels 2,
  ;;     :duration &quot;252.473469&quot;,
  ;;     :codec&#95;name &quot;mp3&quot;,
  ;;     :bit&#95;rate &quot;188278&quot;,
  ;;     ...
  ;;     :codec&#95;tag &quot;0x0000&quot;}

&#41;
</code></pre><p>Cool! <code>ffprobe</code> reports duration in seconds (with some extra nanoseconds that we don't need), so let's write a function that grabs the duration and chops off everything after the decimal place, then we can consolidate the WAV -> MP3 conversion and ID3 tag writing in another function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn mp3-duration &#91;filename&#93;
  &#40;-&gt; &#40;p/shell {:out :string}
               &quot;ffprobe -v quiet -print&#95;format json -show&#95;format -show&#95;streams&quot;
               filename&#41;
      :out
      &#40;json/parse-string keyword&#41;
      :streams
      first
      :duration
      &#40;str/replace #&quot;&#91;.&#93;\d+$&quot; &quot;&quot;&#41;&#41;&#41;

&#40;defn wav-&gt;mp3 &#91;{:keys &#91;filename artist album title year number&#93; :as track} tmpdir&#93;
  &#40;let &#91;wav-file &#40;fs/file tmpdir
                          &#40;-&gt; &#40;fs/file-name filename&#41;
                              &#40;str/replace #&quot;&#91;.&#93;&#91;&#94;.&#93;+$&quot; &quot;.wav&quot;&#41;&#41;&#41;
        mp3-file &#40;str/replace wav-file &quot;.wav&quot; &quot;.mp3&quot;&#41;
        ffmpeg-args &#91;&quot;ffmpeg&quot; &quot;-i&quot; wav-file
                     &quot;-vn&quot;  ; no video
                     &quot;-q:a&quot; &quot;2&quot;  ; dynamic bitrate averaging 192 KB/s
                     &quot;-y&quot;  ; overwrite existing files without prompting
                     mp3-file&#93;
        id3v2-args &#91;&quot;id3v2&quot;
                    &quot;-a&quot; artist &quot;-A&quot; album &quot;-t&quot; title &quot;-y&quot; year &quot;-T&quot; number
                    mp3-file&#93;&#93;
    &#40;println &#40;format &quot;Converting %s -&gt; %s&quot; wav-file mp3-file&#41;&#41;
    &#40;apply println &#40;map str ffmpeg-args&#41;&#41;
    &#40;apply p/shell ffmpeg-args&#41;
    &#40;println &quot;Writing ID3 tag&quot;&#41;
    &#40;apply println id3v2-args&#41;
    &#40;apply p/shell &#40;map str id3v2-args&#41;&#41;
    &#40;assoc track
           :mp3-filename mp3-file
           :mp3-size &#40;fs/size mp3-file&#41;
           :duration &#40;mp3-duration mp3-file&#41;&#41;&#41;&#41;

&#40;comment

  &#40;-&gt; info :tracks first &#40;wav-&gt;mp3 tmpdir&#41;&#41;
  ;; =&gt; {:number &quot;1&quot;,
  ;;     :duration &quot;252&quot;,
  ;;     :artist &quot;Garth Brooks&quot;,
  ;;     :title &quot;The Old Stuff&quot;,
  ;;     :year &quot;1995&quot;,
  ;;     :filename
  ;;     #object&#91;java.io.File 0x96d79f0 &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Old Stuff.ogg&quot;&#93;,
  ;;     :mp3-filename &quot;/tmp/soundcljoud/Garth Brooks - The Old Stuff.mp3&quot;,
  ;;     :album &quot;Fresh Horses&quot;,
  ;;     :mp3-size 5943424}

&#41;
</code></pre><p>Looking good! Now we should have everything we need for the RSS feed, so let's try to put it all together:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn process-track &#91;track tmpdir&#93;
  &#40;-&gt; track
      &#40;ogg-&gt;wav tmpdir&#41;
      &#40;wav-&gt;mp3 tmpdir&#41;&#41;&#41;

&#40;defn process-album &#91;opts dir&#93;
  &#40;let &#91;info &#40;-&gt;&gt; &#40;fs/glob dir &quot;&#42;.ogg&quot;&#41;
                  &#40;map &#40;comp track-info fs/file&#41;&#41;
                  &#40;album-info &#40;load-token&#41;&#41;&#41;
        tmpdir &#40;fs/create-temp-dir {:prefix &quot;soundcljoud.&quot;}&#41;&#93;
    &#40;spit &#40;fs/file tmpdir &quot;album.rss&quot;&#41; &#40;rss/album-feed opts info&#41;&#41;
    &#40;assoc info :out-dir tmpdir&#41;&#41;&#41;

&#40;comment

  &#40;process-album opts dir&#41;
  ;; =&gt; {:out-dir &quot;/tmp/soundcljoud.12524185230907219576&quot;
  ;;     :artist &quot;Garth Brooks&quot;,
  ;;     :album &quot;Fresh Horses&quot;,
  ;;     :link &quot;https://api.discogs.com/masters/212114&quot;,
  ;;     :image
  ;;     &quot;https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ&#95;8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg&quot;,
  ;;     :year &quot;1995&quot;,
  ;;     :tracks
  ;;     &#40;{:number &quot;1&quot;,
  ;;       :duration &quot;252&quot;,
  ;;       :artist &quot;Garth Brooks&quot;,
  ;;       :title &quot;The Old Stuff&quot;,
  ;;       :year &quot;1995&quot;,
  ;;       :filename
  ;;       #object&#91;java.io.File 0x344bc92b &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - The Old Stuff.ogg&quot;&#93;,
  ;;       :mp3-filename
  ;;       &quot;/tmp/soundcljoud.12524185230907219576/Garth Brooks - The Old Stuff.mp3&quot;,
  ;;       :album &quot;Fresh Horses&quot;,
  ;;       :wav-filename
  ;;       #object&#91;java.io.File 0x105830d2 &quot;/tmp/soundcljoud.12524185230907219576/Garth Brooks - The Old Stuff.wav&quot;&#93;,
  ;;       :mp3-size 5943424}
  ;;      ...
  ;;      {:number &quot;10&quot;,
  ;;       :duration &quot;301&quot;,
  ;;       :artist &quot;Garth Brooks&quot;,
  ;;       :title &quot;Ireland&quot;,
  ;;       :year &quot;1995&quot;,
  ;;       :filename
  ;;       #object&#91;java.io.File 0x59ba6e31 &quot;&#126;/Music/g/Garth Brooks/Fresh Horses/Garth Brooks - Ireland.ogg&quot;&#93;,
  ;;       :mp3-filename
  ;;       &quot;/tmp/soundcljoud.12524185230907219576/Garth Brooks - Ireland.mp3&quot;,
  ;;       :album &quot;Fresh Horses&quot;,
  ;;       :wav-filename
  ;;       #object&#91;java.io.File 0x4de1472 &quot;/tmp/soundcljoud.12524185230907219576/Garth Brooks - Ireland.wav&quot;&#93;,
  ;;       :mp3-size 6969472}&#41;}
&#41;
</code></pre><p>We also have a <code>/tmp/soundcljoud.12524185230907219576/album.rss</code> file containing:</p><pre class="language-xml"><code class="lang-xml language-xml">&lt;?xml version='1.0' encoding='UTF-8'?&gt;
&lt;rss version=&quot;2.0&quot;
     xmlns:itunes=&quot;http://www.itunes.com/dtds/podcast-1.0.dtd&quot;
     xmlns:atom=&quot;http://www.w3.org/2005/Atom&quot;&gt;
  &lt;channel&gt;
    &lt;title&gt;Garth Brooks - Fresh Horses&lt;/title&gt;
    &lt;link&gt;https://api.discogs.com/masters/212114&lt;/link&gt;
    &lt;pubDate&gt;Sun, 01 Jan 1995 00:00:00 +0000&lt;/pubDate&gt;
    &lt;itunes:subtitle&gt;Album: Garth Brooks - Fresh Horses&lt;/itunes:subtitle&gt;
    &lt;itunes:author&gt;Garth Brooks&lt;/itunes:author&gt;
    &lt;itunes:image href=&quot;https://i.discogs.com/0eLXmM1tK1grkH8cstgDT6eV2TlL0NvgWPZBoyScJ&#95;8/rs:fit/g:sm/q:90/h:600/w:600/czM6Ly9kaXNjb2dz/LWRhdGFiYXNlLWlt/YWdlcy9SLTY4NDcx/Ny0xNzE3NDU5MDIy/LTMxNjguanBlZw.jpeg&quot;/&gt;
    
    &lt;item&gt;
      &lt;itunes:title&gt;The Old Stuff&lt;/itunes:title&gt;
      &lt;title&gt;The Old Stuff&lt;/title&gt;
      &lt;itunes:author&gt;Garth Brooks&lt;/itunes:author&gt;
      &lt;enclosure
          url=&quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/01+-+Garth+Brooks+-+The+Old+Stuff.mp3&quot;
          length=&quot;5943424&quot; type=&quot;audio/mpeg&quot; /&gt;
      &lt;pubDate&gt;Sun, 01 Jan 1995 00:00:00 +0000&lt;/pubDate&gt;
      &lt;itunes:duration&gt;252&lt;/itunes:duration&gt;
      &lt;itunes:episode&gt;1&lt;/itunes:episode&gt;
      &lt;itunes:episodeType&gt;full&lt;/itunes:episodeType&gt;
      &lt;itunes:explicit&gt;false&lt;/itunes:explicit&gt;
    &lt;/item&gt;

    ...
    
    &lt;item&gt;
      &lt;itunes:title&gt;Ireland&lt;/itunes:title&gt;
      &lt;title&gt;Ireland&lt;/title&gt;
      &lt;itunes:author&gt;Garth Brooks&lt;/itunes:author&gt;
      &lt;enclosure
          url=&quot;http://localhost:1341/Garth+Brooks/Fresh+Horses/Garth+Brooks+-+Ireland.mp3&quot;
          length=&quot;6969472&quot; type=&quot;audio/mpeg&quot; /&gt;
      &lt;pubDate&gt;Sun, 01 Jan 1995 00:00:00 +0000&lt;/pubDate&gt;
      &lt;itunes:duration&gt;301&lt;/itunes:duration&gt;
      &lt;itunes:episode&gt;10&lt;/itunes:episode&gt;
      &lt;itunes:episodeType&gt;full&lt;/itunes:episodeType&gt;
      &lt;itunes:explicit&gt;false&lt;/itunes:explicit&gt;
    &lt;/item&gt;
    
  &lt;/channel&gt;
&lt;/rss&gt;
</code></pre><p>In theory, if we put this RSS file and our MP3 somewhere a podcast player can find them, we should be able to listen to some Garth Brooks! However, http://localhost:1341/ is not likely to be reachable by a podcast player, so perhaps we should put a webserver there and whilst we're at it, just write our own little Soundcloud clone webapp. Seems reasonable, right?</p><p>We'll get into that in the next instalment of "Soundcljoud, or a young man's Soundcloud clonejure."</p><p>Part 2: <a href='2024-07-20-soundcljoud-cloudy.html'>Soundcljoud gets more cloudy</a></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-02-22-cljcastr.html</id>
    <link href="https://jmglov.net/blog/2024-02-22-cljcastr.html"/>
    <title>cljcastr, or a young man's Zencastr clonejure</title>
    <updated>2024-02-22T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2024-02-22-cljcastr-preview.png" alt="A man with the Babashka logo for a face sits in front of a laptop and a mic" title="Photo by Jeremy Enns on Unsplash" width=800px /></p><p>Who amongst us hasn't wanted to make a podcast? And of those who, who amongst them hasn't drooled over <a href='https://zencastr.com/'>Zencastr</a>, which is a web-based podcast recording studio thingy?</p><p>I don't even know how to answer that question, as convoluted as it got, but what I'm trying to say is that I got to see Zencastr's cool interface, and have been meaning to play around with the browser's audio / video API anyway, so why not see if I can whip up a quick Zencastr clone in Clojure? I mean, how hard can it be?</p><h2 id="popping_in_a_scittle">Popping in a Scittle</h2><p>You may recall from my <a href='2024-01-22-clickr-goes-fe.html'>adventures cloning
Flickr</a> that I love ClojureScript but feel sad at my own lack of knowledge when trying to use <a href='https://github.com/thheller/shadow-cljs'>shadow-cljs</a>. You may also recall that the sweet sweet antidote to this was <a href='https://github.com/babashka/scittle/'>Scittle</a>, which allows you to "execute Clojure(Script) directly from browser script tags via SCI". Since I'm now an expert Scittler, I figured that's the obvious place to start a Zencastr clone. So let's start a project!</p><pre class="language-text"><code class="lang-text language-text">$ mkdir cljcastr &amp;&amp; cd cljcastr
</code></pre><p>Then we need a <code>bb.edn</code>, which we can just steal from Scittle's <a href='https://github.com/babashka/scittle/blob/cfaf6b0b33e6a2fd1fb82ef99871be38ce0e5c53/doc/nrepl/bb.edn'>nrepl
demo</a> and modify ever so slightly to serve resources out of the <code>public/</code> directory:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {io.github.babashka/sci.nrepl
        {:git/sha &quot;2f8a9ed2d39a1b09d2b4d34d95494b56468f4a23&quot;}
        io.github.babashka/http-server
        {:git/sha &quot;b38c1f16ad2c618adae2c3b102a5520c261a7dd3&quot;}}
 :tasks {http-server {:doc &quot;Starts http server for serving static files&quot;
                      :requires &#40;&#91;babashka.http-server :as http&#93;&#41;
                      :task &#40;do &#40;http/serve {:port 1341 :dir &quot;public&quot;}&#41;
                                &#40;println &quot;Serving static assets at http://localhost:1341&quot;&#41;&#41;}

         browser-nrepl {:doc &quot;Start browser nREPL&quot;
                        :requires &#40;&#91;sci.nrepl.browser-server :as bp&#93;&#41;
                        :task &#40;bp/start! {}&#41;}

         -dev {:depends &#91;http-server browser-nrepl&#93;}

         dev {:task &#40;do &#40;run '-dev {:parallel true}&#41;
                        &#40;deref &#40;promise&#41;&#41;&#41;}}}
</code></pre><p>Given this, let's create a <code>public/index.html</code> to bootstrap our ClojureScript:</p><pre class="language-html"><code class="lang-html language-html">&lt;!doctype html&gt;
&lt;html class=&quot;no-js&quot; lang=&quot;&quot;&gt;

&lt;head&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;meta http-equiv=&quot;x-ua-compatible&quot; content=&quot;ie=edge&quot;&gt;
    &lt;title&gt;cljcastr&lt;/title&gt;
    &lt;meta name=&quot;description&quot; content=&quot;&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&gt;

    &lt;link rel=&quot;apple-touch-icon&quot; href=&quot;/apple-touch-icon.png&quot;&gt;
    &lt;!-- Place favicon.ico in the root directory --&gt;

    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.js&quot; type=&quot;application/javascript&quot;&gt;&lt;/script&gt;
    &lt;script&gt;var SCITTLE&#95;NREPL&#95;WEBSOCKET&#95;PORT = 1340;&lt;/script&gt;
    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.nrepl.js&quot;
        type=&quot;application/javascript&quot;&gt;&lt;/script&gt;
    &lt;script type=&quot;application/x-scittle&quot; src=&quot;cljcastr.cljs&quot;&gt;&lt;/script&gt;
&lt;/head&gt;

&lt;body&gt;
    &lt;!--&#91;if lt IE 8&#93;&gt;
      &lt;p class=&quot;browserupgrade&quot;&gt;
      You are using an &lt;strong&gt;outdated&lt;/strong&gt; browser. Please
      &lt;a href=&quot;http://browsehappy.com/&quot;&gt;upgrade your browser&lt;/a&gt; to improve
      your experience.
      &lt;/p&gt;
    &lt;!&#91;endif&#93;--&gt;
&lt;/body&gt;

&lt;/html&gt;
</code></pre><p>And of course we need to get stylish with a <code>public/style.css</code>:</p><pre class="language-css"><code class="lang-css language-css">body {
  font-family: Proxima Nova,helvetica neue,helvetica,arial,sans-serif;
}
</code></pre><p>And finally, we need <code>public/cljcastr.clj</code> to script some Clojure:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">;; To start a REPL:
;;
;; bb dev
;;
;; Then connect to it in Emacs:
;;
;; C-c l C &#40;cider-connect-cljs&#41;, host: localhost; port: 1339; REPL type: nbb

&#40;ns cljcastr&#41;
</code></pre><p>I always forget how to start the REPL and connect to it, so I left myself some nice explicit instructions, which we shall now follow. In the terminal:</p><pre class="language-text"><code class="lang-text language-text">$ bb dev
Serving static assets at http://localhost:1341
nREPL server started on port 1339...
Websocket server started on 1340...
</code></pre><p>We'll then visit http://localhost:1341 in the browser and open up the JavaScript console, which should say:</p><pre class="language-text"><code class="lang-text language-text">   :ws #object&#91;WebSocket &#91;object WebSocket&#93;&#93;
&gt; 
</code></pre><p>Finally, back in Emacs, hitting <strong>C-c l C</strong> (cider-connect-cljs), selecting localhost for the host, 1339 for the port, and nbb for the REPL type, then <strong>C-c C-k</strong> (cider-load-buffer) shows us this in the terminal:</p><pre class="language-text"><code class="lang-text language-text">:msg &quot;{:versions {\&quot;scittle-nrepl\&quot; {\&quot;major\&quot; \&quot;0\&quot;, \&quot;minor\&quot; \&quot;0\&quot;, \&quot;incremental\&quot; \&quot;1\&quot;}}, :ops {\&quot;complete\&quot; {}, \&quot;info\&quot; {}, \&quot;lookup\&quot; {}, \&quot;eval\&quot; {}, \&quot;load-file\&quot; {}, \&quot;describe\&quot; {}, \&quot;close\&quot; {}, \&quot;clone\&quot; {}, \&quot;eldoc\&quot; {}}, :status &#91;\&quot;done\&quot;&#93;, :id \&quot;3\&quot;, :session \&quot;5e3f1fb0-1f13-4db0-a25a-b63a9e7d7d72\&quot;, :ns \&quot;cljcastr\&quot;}&quot;
:msg &quot;{:value \&quot;nil\&quot;, :id \&quot;5\&quot;, :session \&quot;5e3f1fb0-1f13-4db0-a25a-b63a9e7d7d72\&quot;, :ns \&quot;cljcastr\&quot;}&quot;
:msg &quot;{:status &#91;\&quot;done\&quot;&#93;, :id \&quot;5\&quot;, :session \&quot;5e3f1fb0-1f13-4db0-a25a-b63a9e7d7d72\&quot;, :ns \&quot;cljcastr\&quot;}&quot;
</code></pre><p>Exciting! Let's prove we're connected with a <a href='https://betweentwoparens.com/blog/rich-comment-blocks/#rich-comment'>Rich
comment</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;println &quot;Now we're cooking with Scittle!&quot;&#41;  ; &lt;- C-c C-v f c e &#40;cider-pprint-eval-last-sexp-to-comment&#41;
  ;; =&gt; nil

  &#41;
</code></pre><p>If all went well, we should see glorious things in the JavaScript console:</p><p><img src="assets/2024-02-22-cljcastr-cooking.png" alt="Screenshot of a browser window with the JavaScript console displaying: Now we're cooking with Scittle!" title="Sauté on medium-high heat for 15 minutes" width=800px /></p><h2 id="left_to_our_own_devices">Left to our own devices</h2><p>Now that we have a solid platform to stand on (namely: the REPL), let's get on with the cljcasting! We'll start by asking ourselves what audio and video devices we have at our disposal.</p><p>Modern browsers implement the <a href='https://developer.mozilla.org/en-US/docs/Web/API/Media_Capture_and_Streams_API'>Media Capture and Streams
API</a>, which provides support for streaming audio and video data. You can read a bit about the backstory of the API in a nice little article by Eric Bidelman and Sam Dutton: <a href='https://web.dev/articles/getusermedia-intro'>Capture audio and video in
HTML5</a>. This article points to <a href='https://simpl.info/getusermedia/sources/'>a
great demo of A/V capture</a> that Sam Dutton did.</p><p>I am relating all this because Sam Dutton's demo comes with <a href='https://github.com/samdutton/simpl/tree/gh-pages/getusermedia/sources'>source
code</a> that shows not tells how to use this API, and like any great artist, I stole that code and used it for my own nefarious purposes. Well, "nefarious" might be a bit of a stretch, but c'mon, I've got a reputation to uphold over here. 😅</p><p>Our entrypoint into the wonderful world of browser-based A/V is the <a href='https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices'>MediaDevices</a> interface, which is exposed as <code>navigator.mediaDevices</code>. MediaDevices has an instance method <a href='https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/enumerateDevices'>enumerateDevices()</a>, which we can use to, well, enumerate the audio and video devices availabile to our browser:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;.enumerateDevices js/navigator.mediaDevices&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p>Blergh, looks like it returns a <a href='https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise'>promise</a> instead of an actual value (OK, OK, a promise is a value, but you know what I mean). That means that we need to feed a function to the promise that actually does the thing. We do this by using <a href='https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then'>Promise.then()</a>, which calls a function when the promise is fulfilled and returns a promise which wraps the return value of the function, allowing us to chain calls in a very similar way to Clojure's threading operators, <code>-&gt;</code> and <code>-&gt;&gt;</code>.</p><p>Now, before we do this, I discovered during the writing of this post that my browser hides all devices from me until I give it permission to use my audio and video devices. We can trigger that permission request with this incantation:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;.getUserMedia js/navigator.mediaDevices #js {:video true, :audio true}&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p>What should happen is that we're presented with a dialog asking for permission to use our video camera and microphone. Assuming we trust ourselves this far, we can accept and get back to seeing what <code>mediaDevices.enumerateDevices&#40;&#41;</code> returns:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; &#40;.enumerateDevices js/navigator.mediaDevices&#41;
      &#40;.then println&#41;&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p>This results in some awesome stuff being printed to the JS console:</p><pre class="language-text"><code class="lang-text language-text">#js &#91;#object&#91;InputDeviceInfo &#91;object InputDeviceInfo&#93;&#93;
     #object&#91;InputDeviceInfo &#91;object InputDeviceInfo&#93;&#93;
     #object&#91;InputDeviceInfo &#91;object InputDeviceInfo&#93;&#93;
     #object&#91;InputDeviceInfo &#91;object InputDeviceInfo&#93;&#93;
     #object&#91;InputDeviceInfo &#91;object InputDeviceInfo&#93;&#93;
     #object&#91;InputDeviceInfo &#91;object InputDeviceInfo&#93;&#93;
     #object&#91;MediaDeviceInfo &#91;object MediaDeviceInfo&#93;&#93;
     #object&#91;MediaDeviceInfo &#91;object MediaDeviceInfo&#93;&#93;
     #object&#91;MediaDeviceInfo &#91;object MediaDeviceInfo&#93;&#93;
     #object&#91;MediaDeviceInfo &#91;object MediaDeviceInfo&#93;&#93;
     #object&#91;MediaDeviceInfo &#91;object MediaDeviceInfo&#93;&#93;
     #object&#91;MediaDeviceInfo &#91;object MediaDeviceInfo&#93;&#93;&#93;
</code></pre><p>OK, so we have an array of <a href='https://developer.mozilla.org/en-US/docs/Web/API/MediaDeviceInfo'>MediaDeviceInfo</a> and <a href='https://developer.mozilla.org/en-US/docs/Web/API/InputDeviceInfo'>InputDeviceInfo</a> objects, which have convenient <code>.label</code> and <code>.kind</code> properties that we can avail ourselves of:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; &#40;.enumerateDevices js/navigator.mediaDevices&#41;
      &#40;.then &#40;fn &#91;devices&#93;
               &#40;-&gt;&gt; devices
                    &#40;group-by #&#40;.-kind %&#41;&#41;
                    &#40;sort-by key&#41;
                    &#40;map &#40;fn &#91;&#91;kind ds&#93;&#93;
                           &#40;str kind &quot;:\n  &quot;
                                &#40;str/join &quot;\n  &quot; &#40;map #&#40;.-label %&#41; ds&#41;&#41;&#41;&#41;&#41;
                    &#40;str/join &quot;\n&quot;&#41;
                    println&#41;&#41;&#41;&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p>This gives us something much more reasonable in the console:</p><pre class="language-text"><code class="lang-text language-text">audioinput:
  Default
  Tiger Lake-LP Smart Sound Technology Audio Controller Digital Microphone
  HD Webcam B910 Analog Stereo
  Yeti X Analog Stereo
audiooutput:
  Default
  Tiger Lake-LP Smart Sound Technology Audio Controller HDMI / DisplayPort 3 Output
  Tiger Lake-LP Smart Sound Technology Audio Controller HDMI / DisplayPort 2 Output
  Tiger Lake-LP Smart Sound Technology Audio Controller HDMI / DisplayPort 1 Output
  Tiger Lake-LP Smart Sound Technology Audio Controller Speaker + Headphones
  Yeti X Analog Stereo
videoinput:
  Integrated Camera &#40;04f2:b6ea&#41;
  UVC Camera &#40;046d:0823&#41; &#40;046d:0823&#41;
</code></pre><p>This looks like useful information indeed! Let's extract some functions out of the mess we made in our REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns cljcastr
  &#40;:require &#91;clojure.string :as str&#93;&#41;&#41;

&#40;defn log-devices &#91;devices&#93;
  &#40;-&gt;&gt; devices
       &#40;group-by #&#40;.-kind %&#41;&#41;
       &#40;sort-by key&#41;
       &#40;map &#40;fn &#91;&#91;kind ds&#93;&#93;
              &#40;str kind &quot;:\n  &quot;
                   &#40;str/join &quot;\n  &quot; &#40;map #&#40;.-label %&#41; ds&#41;&#41;&#41;&#41;&#41;
       &#40;str/join &quot;\n&quot;&#41;
       println&#41;
  devices&#41;

&#40;defn get-devices &#91;&#93;
  &#40;.enumerateDevices js/navigator.mediaDevices&#41;&#41;
</code></pre><p>Now, taking inspiration from Sam Dutton's demo, let's make a UI that lets you choose your video source and stream from it into a window:</p><p><img src="assets/2024-02-22-cljcastr-demo.png" alt="Screenshot of a browser window showing Sam Dutton's mediaDevices demo" title="A portrait of the young man as an artist" width=800px /></p><p>We'll start by opening up our <code>public/index.html</code> and sprinkling in some UI elements:</p><pre class="language-html"><code class="lang-html language-html">&lt;body&gt;
    &lt;!--&#91;if lt IE 8&#93;&gt;
      ...
    &lt;!&#91;endif&#93;--&gt;

  &lt;div id=&quot;container&quot;&gt;
    &lt;h1&gt;cljcastr&lt;/h1&gt;
    &lt;div class=&quot;select&quot;&gt;
      &lt;label for=&quot;videoSource&quot;&gt;Video source:&lt;/label&gt;
      &lt;select id=&quot;videoSource&quot;&gt;&lt;/select&gt;
    &lt;/div&gt;
    &lt;video autoplay muted playsinline&gt;&lt;/video&gt;
  &lt;/div&gt;
&lt;/body&gt;
</code></pre><p>Since the labels of my devices were quite lengthy, let's make the select quite widthy by dropping the following in <code>public/style.css</code>:</p><pre class="language-css"><code class="lang-css language-css">select {
  width: 300px;
}
</code></pre><p>After doing this, we'll sadly have to refresh the browser to get it to pick up the changes to <code>index.html</code> and <code>style.css</code>. We could of course add in some awesome watching and live reloading like <a href='https://github.com/borkdude/quickblog'>quickblog</a> does, but that smacks of effort and we don't have any useful state in the REPL to mourn anyway, so we'll bite our tongue and hope we don't have too many HTML or CSS changes left to make.</p><p>Now that we have the bones of a UI, let's actually populate the select element with the video devices that we've detected. We can try grabbing all of the video input devices:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; &#40;get-devices&#41;
      &#40;.then &#40;fn &#91;devices&#93;
               &#40;-&gt;&gt; devices
                    &#40;filter #&#40;= &quot;videoinput&quot; &#40;.-kind %&#41;&#41;&#41;
                    log-devices&#41;&#41;&#41;&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p>The JS console now reads:</p><pre class="language-text"><code class="lang-text language-text">videoinput:
  Integrated Camera &#40;04f2:b6ea&#41;
  UVC Camera &#40;046d:0823&#41; &#40;046d:0823&#41;
</code></pre><p>Stuffing these in the select should be fairly straightforward:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def video-select &#40;.querySelector js/document &quot;select#videoSource&quot;&#41;&#41;

&#40;comment

  &#40;-&gt; &#40;get-devices&#41;
      &#40;.then &#40;fn &#91;devices&#93;
               &#40;doseq &#91;device
                       &#40;-&gt;&gt; devices
                            &#40;filter #&#40;= &quot;videoinput&quot; &#40;.-kind %&#41;&#41;&#41;
                            log-devices&#41;&#93;
                 &#40;let &#91;option &#40;.createElement js/document &quot;option&quot;&#41;&#93;
                   &#40;set! &#40;.-value option&#41; &#40;.-deviceId device&#41;&#41;
                   &#40;set! &#40;.-text option&#41;
                         &#40;or &#40;.-label device&#41;
                             &#40;str &quot;Camera &quot; &#40;inc &#40;.-length video-select&#41;&#41;&#41;&#41;&#41;
                   &#40;.appendChild video-select option&#41;&#41;&#41;&#41;&#41;&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p>Et voilà! The browser now shows our cameras:</p><p><img src="assets/2024-02-22-cljcastr-videoselect.png" alt="Screenshot of a browser window showing a selection box containing two video input sources" title="Ah, the choices!" width=800px /></p><p>Having proven this works, let's make a function out of it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn populate-device-selects! &#91;devices&#93;
  &#40;doseq &#91;device
          &#40;-&gt;&gt; devices
               &#40;filter #&#40;= &quot;videoinput&quot; &#40;.-kind %&#41;&#41;&#41;
               log-devices&#41;&#93;
    &#40;let &#91;option &#40;.createElement js/document &quot;option&quot;&#41;&#93;
      &#40;set! &#40;.-value option&#41; &#40;.-deviceID device&#41;&#41;
      &#40;set! &#40;.-text option&#41;
            &#40;or &#40;.-label device&#41;
                &#40;str &quot;Camera &quot; &#40;inc &#40;.-length video-select&#41;&#41;&#41;&#41;&#41;
      &#40;.appendChild video-select option&#41;&#41;&#41;&#41;
</code></pre><p>In case you haven't come across this convention before, adding a <code>!</code> to the end of a function name indicates that the function is mutating something, in this case, adding options to the select element.</p><h2 id="%26lt%3Bvideo%26gt%3B_killed_the_adobe_flash_star">&lt;video&gt; killed the Adobe Flash star</h2><p>Having given ourselves a way to select a video input device, we just need to actually display the video being input into said device. For this, we'll need to avail ourselves of the <a href='https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia'>MediaDevices.getUserMedia()</a> method. Given a device ID, it will "prompt the user for permission to use a media input which produces a <a href='https://developer.mozilla.org/en-US/docs/Web/API/MediaStream'>MediaStream</a>".</p><p>Let's check which video input device is selected:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;.-value video-select&#41;
  ;; =&gt; &quot;d9862f4684c6b3f21bf95436a09b58dfa1b7a442e79aff225314e5e9bab45217&quot;

  &#41;
</code></pre><p>If we feed this ID to <code>getUserMedia&#40;&#41;</code>, we should get a stream back:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; js/navigator.mediaDevices
      &#40;.getUserMedia &#40;clj-&gt;js {:video {:deviceId {:exact &#40;.-value video-select&#41;}}}&#41;&#41;
      &#40;.then #&#40;println &#40;.getVideoTracks %&#41;&#41;&#41;&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p>This <code>clj-&gt;js</code> business is taking a ClojureScript hashmap and turning it into a JavaScript object with nested objects. You have to remember to use it whenever you're calling JavaScript functions that take "maps" as arguments, lest those functions basically ignore your arguments. Don't ask me how I know! 😅</p><p>As an interesting aside, ClojureScript also has a <code>#js</code> reader tag, which says "turn the following ClojureScript literal into the JavaScript equivalent". As an interesting aside to the aside, this is not recursive. Don't ask me how I know! 😅</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  #js {:video &quot;killed the radio star&quot;}
  ;; =&gt; #js {:video &quot;killed the radio star&quot;}

  #js {:video {:deviceId {:exact &#40;.-value video-select&#41;}}}
  ;; =&gt; #js {:video
  ;;         {:deviceId
  ;;          {:exact
  ;;           &quot;24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50&quot;}}}

  &#40;clj-&gt;js {:video {:deviceId {:exact &#40;.-value video-select&#41;}}}&#41;
  ;; =&gt; #js {:video
  ;;         #js {:deviceId
  ;;              #js {:exact
  ;;                   &quot;24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50&quot;}}}

  &#41;
</code></pre><p>OK, getting back to our code:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; js/navigator.mediaDevices
      &#40;.getUserMedia &#40;clj-&gt;js {:video {:deviceId {:exact &#40;.-value video-select&#41;}}}&#41;&#41;
      &#40;.then #&#40;println &#40;.getVideoTracks %&#41;&#41;&#41;&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p>When we evaluated this, two interesting things should have happened. First, the JS console should say something like this:</p><pre class="language-text"><code class="lang-text language-text">#js &#91;#object&#91;MediaStreamTrack &#91;object MediaStreamTrack&#93;&#93;&#93;
</code></pre><p>And second, the recording light on your webcam should light up. OMG we're getting somewhere! 🎉</p><p>Of course, our goal isn't simply to turn on the webcam, but rather to turn it on and then start streaming video to our webpage. This is actually pretty straightforward, compared to what we've done to get to this point.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def video-element &#40;.querySelector js/document &quot;video&quot;&#41;&#41;

&#40;comment

  &#40;-&gt; js/navigator.mediaDevices
      &#40;.getUserMedia &#40;clj-&gt;js {:video {:deviceId {:exact &#40;.-value video-select&#41;}}}&#41;&#41;
      &#40;.then #&#40;set! &#40;.-srcObject video-element&#41; %&#41;&#41;&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p>The results are stunning...ly bad. Unless of course you're more photogenic than I am, in which case, congrats!</p><p><img src="assets/2024-02-22-cljcastr-streaming.png" alt="Screenshot of a browser window showing a video of me" title="I'm getting tired of this view" width=800px /></p><p>Now that we're streaming video, it looks pretty ugly to have the video pressed right up against the bottom of the select element, so let's add some margin in our <code>style.css</code>:</p><pre class="language-css"><code class="lang-css language-css">select {
  width: 300px;
  margin-bottom: 10px;
}
</code></pre><p>With all of this plumbing, we can hook it up to the actual select box so it automatically starts playing video when we make a camera selection, rather than requiring us to go all 1337 h4ckZ0r in the REPL.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def active-stream &#40;atom nil&#41;&#41;

&#40;def video-element &#40;.querySelector js/document &quot;video&quot;&#41;&#41;

&#40;defn log-error &#91;e&#93;
  &#40;.error js/console e&#41;&#41;

&#40;defn log-devices &#91;devices&#93;
  ;; ...
  &#41;

&#40;defn get-devices &#91;&#93;
  ;; ...
  &#41;

&#40;defn populate-device-selects! &#91;devices&#93;
  ;; ...
  &#41;

&#40;defn select-device! &#91;select-element tracks&#93;
  &#40;let &#91;label &#40;-&gt; tracks first &#40;.-label&#41;&#41;
        index &#40;-&gt;&gt; &#40;.-options select-element&#41;
                   &#40;zipmap &#40;range&#41;&#41;
                   &#40;some &#40;fn &#91;&#91;i option&#93;&#93;
                           &#40;and &#40;= label &#40;.-text option&#41;&#41; i&#41;&#41;&#41;&#41;&#93;
    &#40;when index
      &#40;println &quot;Setting selected video source to index&quot; index&#41;
      &#40;set! &#40;.-selectedIndex select-element&#41; index&#41;&#41;&#41;&#41;

&#40;defn start-video! &#91;stream&#93;
  &#40;reset! active-stream stream&#41;
  &#40;select-device! video-select &#40;.getVideoTracks stream&#41;&#41;
  &#40;set! &#40;.-srcObject video-element&#41; stream&#41;&#41;

&#40;defn stop-video! &#91;&#93;
  &#40;when @active-stream
    &#40;println &quot;Stopping currently playing video&quot;&#41;
    &#40;doseq &#91;track &#40;.getTracks @active-stream&#41;&#93;
      &#40;.stop track&#41;&#41;&#41;&#41;

&#40;defn set-video-stream! &#91;&#93;
  &#40;stop-video!&#41;
  &#40;let &#91;video-source &#40;.-value video-select&#41;
        constraints {:video {:deviceId &#40;when &#40;not-empty video-source&#41;
                                         {:exact video-source}&#41;}}&#93;
    &#40;println &quot;Getting media with constraints:&quot; constraints&#41;
    &#40;-&gt; js/navigator.mediaDevices
        &#40;.getUserMedia &#40;clj-&gt;js constraints&#41;&#41;
        &#40;.then start-video!&#41;
        &#40;.catch log-error&#41;&#41;&#41;&#41;

&#40;defn load-ui! &#91;&#93;
  &#40;set! &#40;.-onchange video-select&#41; set-video-stream!&#41;
  &#40;-&gt; &#40;set-video-stream!&#41;
      &#40;.then get-devices&#41;
      &#40;.then log-devices&#41;
      &#40;.then populate-device-selects!&#41;&#41;&#41;

&#40;comment

  &#40;load-ui!&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p>Let's break these functions down to see what's going on here:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn load-ui! &#91;&#93;
  &#40;set! &#40;.-onchange video-select&#41; set-video-stream!&#41;
  &#40;-&gt; &#40;set-video-stream!&#41;
      ;; ...
      &#41;&#41;
</code></pre><p>First, we set the change handler for the video select element to the <code>set-video-stream!</code> function, then we call <code>set-video-stream!</code>.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn set-video-stream! &#91;&#93;
  &#40;stop-video!&#41;
  ;; ...
  &#41;
</code></pre><p><code>set-video-stream!</code> calls <code>stop-video!</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn stop-video! &#91;&#93;
  &#40;when @active-stream
    &#40;println &quot;Stopping currently playing video&quot;&#41;
    &#40;doseq &#91;track &#40;.getTracks @active-stream&#41;&#93;
      &#40;.stop track&#41;&#41;&#41;&#41;
</code></pre><p><code>stop-video!</code> checks to see if we have a truthy value in our <code>active-stream</code> atom, which we won't at this point, since we initiatise the atom with a <code>nil</code> value:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def active-stream &#40;atom nil&#41;&#41;
</code></pre><p>Back to <code>set-video-stream!</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn set-video-stream! &#91;&#93;
  ;; ...
  &#40;let &#91;video-source &#40;.-value video-select&#41;
        constraints {:video {:deviceId &#40;when &#40;not-empty video-source&#41;
                                         {:exact video-source}&#41;}}&#93;
    ;; ...
    &#41;&#41;
</code></pre><p>Since we haven't yet populated the video select element with video sources, <code>video-select.value</code> will be <code>&quot;&quot;</code>, which is not not empty (in other words, it's empty), so our <code>constraints</code> map will look like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;video-source &#40;.-value video-select&#41;
        constraints {:video {:deviceId &#40;when &#40;not-empty video-source&#41;
                                         {:exact video-source}&#41;}}&#93;
    constraints&#41;
  ;; =&gt; {:video {:deviceId nil}}

  &#41;
</code></pre><p>Feeding this to <code>navigator.mediaDevices.getUserMedia&#40;&#41;</code> will result in prompting the user for permission to access whichever of their cameras the browser considers the default, then turning on that camera and providing a <a href='https://developer.mozilla.org/en-US/docs/Web/API/MediaStream'>MediaStream</a> containing a video track with the input, which we then feed to <code>start-video!</code>.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn set-video-stream! &#91;&#93;
  ;; ...
  &#40;let &#91; ;; ...
       &#93;
    &#40;println &quot;Getting media with constraints:&quot; constraints&#41;
    &#40;-&gt; js/navigator.mediaDevices
        &#40;.getUserMedia &#40;clj-&gt;js constraints&#41;&#41;
        &#40;.then start-video!&#41;
        &#40;.catch log-error&#41;&#41;&#41;&#41;
</code></pre><p><code>start-video!</code> is fairly simple:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn start-video! &#91;stream&#93;
  &#40;reset! active-stream stream&#41;
  &#40;select-device! video-select &#40;.getVideoTracks stream&#41;&#41;
  &#40;set! &#40;.-srcObject video-element&#41; stream&#41;&#41;
</code></pre><p>The first thing it does is reset the value of the <code>active-stream</code> atom to the stream returned by <code>getUserMedia&#40;&#41;</code>, then calls <code>select-device!</code> with the video select DOM element and the video tracks of the stream, then finally sets the <code>srcObject</code> property of the <code>&lt;video&gt;</code> element to the stream, which results in us seeing ourselves (or whatever our default camera is aimed at).</p><p><code>select-device!</code> is responsible for setting the value of a select element to the device corresponding to the first of the <a href='https://developer.mozilla.org/en-US/docs/Web/API/MediaStreamTrack'>MediaStreamTrack</a> objects we passed it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn select-device! &#91;select-element tracks&#93;
  &#40;let &#91;label &#40;-&gt; tracks first &#40;.-label&#41;&#41;
        index &#40;-&gt;&gt; &#40;.-options select-element&#41;
                   &#40;zipmap &#40;range&#41;&#41;
                   &#40;some &#40;fn &#91;&#91;i option&#93;&#93;
                           &#40;and &#40;= label &#40;.-text option&#41;&#41; i&#41;&#41;&#41;&#41;&#93;
    &#40;when index
      &#40;println &quot;Setting selected video source to index&quot; index&#41;
      &#40;set! &#40;.-selectedIndex select-element&#41; index&#41;&#41;&#41;&#41;
</code></pre><p>In this case, that will be the video select element and the video tracks from the default camera.</p><p>The tracks are labelled with the name of the device they correspond to:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;.getVideoTracks @active-stream&#41;
       &#40;map #&#40;.-label %&#41;&#41;&#41;
  ;; =&gt; &#40;&quot;Integrated Camera &#40;04f2:b6ea&#41;&quot;&#41;

  &#41;
</code></pre><p>Which are the same names we used to populate our video select options:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;.-options video-select&#41;
       &#40;map #&#40;.-text %&#41;&#41;&#41;
  ;; =&gt; &#40;&quot;Integrated Camera &#40;04f2:b6ea&#41;&quot;
  ;;     &quot;UVC Camera &#40;046d:0823&#41; &#40;046d:0823&#41;&quot;&#41;

  &#41;
</code></pre><p>To select an option, we need to set the <code>selectedIndex</code> property of the select element to the index corresponding to the option we want. We can turn the list of options into a map of index to option using <a href='https://clojuredocs.org/clojure.core/zipmap'>zipmap</a>, which takes a list of keys and a list of values and returns a map with the keys mapped to the corresponding values:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;.-options video-select&#41;
       &#40;zipmap &#40;range&#41;&#41;&#41;
  ;; =&gt; {0 #object&#91;HTMLOptionElement &#91;object HTMLOptionElement&#93;&#93;
  ;;     1 #object&#91;HTMLOptionElement &#91;object HTMLOptionElement&#93;&#93;}

  &#41;
</code></pre><p>Finally, we need to return the index of first option where the value of the <code>text</code> property matches the label we're looking for:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;.-options video-select&#41;
       &#40;zipmap &#40;range&#41;&#41;
       &#40;some &#40;fn &#91;&#91;i option&#93;&#93;
               &#40;and &#40;= label &#40;.-text option&#41;&#41; i&#41;&#41;&#41;&#41;&#41;
  ;; =&gt; 0

  &#41;
</code></pre><p>Note that the <a href='https://clojuredocs.org/clojure.core/some'>some</a> function returns the first truthy value return by the predicate function, so we can use a neat little trick to return the index:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;and &#40;= label &#40;.-text option&#41;&#41; i&#41;
</code></pre><p>When the text matches the label, the first clause of the <code>and</code> will be truthy (a literal <code>true</code>), and the second clause, the index, will also be truthy because only <code>false</code> and <code>nil</code> are not truthy in Clojure, and <code>and</code> returns the last truthy value, which is the index, so the return value of <code>some</code> is the index corresponding to the label. Without this trick, we'd have to resort to something like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;label &quot;Integrated Camera &#40;04f2:b6ea&#41;&quot;&#93;
    &#40;-&gt;&gt; &#40;.-options video-select&#41;
         &#40;zipmap &#40;range&#41;&#41;
         &#40;filter &#40;fn &#91;&#91;i option&#93;&#93;
                   &#40;= label &#40;.-text option&#41;&#41;&#41;&#41;
         ffirst&#41;&#41;
  ;; =&gt; 0

  &#41;
</code></pre><p>I hope we can all agree that this is gross! 🤮</p><p>So that's what happens on the initial load of the page. If we have more than one camera, we can select it, which results in <code>set-video-stream!</code> being called again:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn set-video-stream! &#91;&#93;
  &#40;stop-video!&#41;
  &#40;let &#91;video-source &#40;.-value video-select&#41;
        constraints {:video {:deviceId &#40;when &#40;not-empty video-source&#41;
                                         {:exact video-source}&#41;}}&#93;
    &#40;println &quot;Getting media with constraints:&quot; constraints&#41;
    &#40;-&gt; js/navigator.mediaDevices
        &#40;.getUserMedia &#40;clj-&gt;js constraints&#41;&#41;
        &#40;.then start-video!&#41;
        &#40;.catch log-error&#41;&#41;&#41;&#41;
</code></pre><p>This time, the video select element will have a value:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;video-source &#40;.-value video-select&#41;
        constraints {:video {:deviceId &#40;when &#40;not-empty video-source&#41;
                                         {:exact video-source}&#41;}}&#93;
    constraints&#41;
  ;; =&gt; {:video
  ;;     {:deviceId
  ;;      {:exact
  ;;       &quot;24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50&quot;}}}

  &#41;
</code></pre><p>Hence <code>.getUserMedia&#40;&#41;</code> will return a <code>MediaStream</code> for that specific camera, and then <code>start-video!</code> and the rest of it work as before.</p><p>OK, that was a lot! 😅</p><h2 id="audioimmolation">Audioimmolation</h2><p>Now that we have video on lockdown, let's see if we can add some sweet sweet audio. We'll start with the HTML:</p><pre class="language-html"><code class="lang-html language-html">&lt;!doctype html&gt;
&lt;html class=&quot;no-js&quot; lang=&quot;&quot;&gt;
&lt;!-- ... --&gt;
&lt;body&gt;
  &lt;!-- ... --&gt;
  &lt;div id=&quot;container&quot;&gt;
    &lt;h1&gt;cljcastr&lt;/h1&gt;
    &lt;div id=&quot;sources&quot;&gt;
      &lt;div class=&quot;select&quot;&gt;
        &lt;label for=&quot;videoSource&quot;&gt;Video source:&lt;/label&gt;
        &lt;select id=&quot;videoSource&quot;&gt;&lt;/select&gt;
      &lt;/div&gt;
      &lt;div class=&quot;select&quot;&gt;
        &lt;label for=&quot;audioSource&quot;&gt;Audio source:&lt;/label&gt;
        &lt;select id=&quot;audioSource&quot;&gt;&lt;/select&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;video autoplay muted playsinline&gt;&lt;/video&gt;
  &lt;/div&gt;
&lt;/body&gt;

&lt;/html&gt;
</code></pre><p>Note that we're wrapping the two <code>.select</code> divs in another div. In our <code>style.css</code>, we can move the margin down to this div instead of directly on the <code>&lt;select&gt;</code> elements:</p><pre class="language-css"><code class="lang-css language-css">div#sources {
  margin-bottom: 10px;
}
</code></pre><p>If we refresh the page, we'll now see a select element labelled "Audio source" pop up.</p><p>Now back to <code>cljcastr.cljs</code>! First we add a binding for the audio select to the top of the file alongside the video select:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns cljcastr
  &#40;:require &#91;clojure.string :as str&#93;&#41;&#41;

&#40;def video-element &#40;.querySelector js/document &quot;video&quot;&#41;&#41;
&#40;def video-select &#40;.querySelector js/document &quot;select#videoSource&quot;&#41;&#41;
&#40;def audio-select &#40;.querySelector js/document &quot;select#audioSource&quot;&#41;&#41;
</code></pre><p>Now, let's walk through the UI flow, starting with <code>load-ui!</code>, and see where to sprinkle in audio stuff:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn load-ui! &#91;&#93;
  &#40;set! &#40;.-onchange video-select&#41; set-video-stream!&#41;
  &#40;-&gt; &#40;set-video-stream!&#41;
      &#40;.then get-devices&#41;
      &#40;.then log-devices&#41;
      &#40;.then populate-device-selects!&#41;&#41;&#41;
</code></pre><p>Digging into <code>set-video-stream!</code>, it looks like we can grab the audio source in exactly the same way as we do the video one, so let's add that in:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn set-video-stream! &#91;&#93;
  &#40;stop-video!&#41;
  &#40;let &#91;audio-source &#40;.-value audio-select&#41;
        video-source &#40;.-value video-select&#41;
        constraints {:audio {:deviceId &#40;when &#40;not-empty audio-source&#41;
                                         {:exact audio-source}&#41;}
                     :video {:deviceId &#40;when &#40;not-empty video-source&#41;
                                         {:exact video-source}&#41;}}&#93;
    &#40;println &quot;Getting media with constraints:&quot; constraints&#41;
    &#40;-&gt; js/navigator.mediaDevices
        &#40;.getUserMedia &#40;clj-&gt;js constraints&#41;&#41;
        &#40;.then start-video!&#41;
        &#40;.catch log-error&#41;&#41;&#41;&#41;
</code></pre><p>We should also rename the function, now that it's responsible for audio as well. <code>set-media-stream!</code> seems like a pretty decent name, so let's go for that! Whilst we're at the renaming, we can rename <code>stop-video!</code> to <code>stop-media!</code> as well. The contents of the function itself look pretty good, except the log statement, so we can fix that:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn stop-media! &#91;&#93;
  &#40;when @active-stream
    &#40;println &quot;Stopping currently playing media&quot;&#41;
    &#40;doseq &#91;track &#40;.getTracks @active-stream&#41;&#93;
      &#40;.stop track&#41;&#41;&#41;&#41;
</code></pre><p>If we keep going in <code>set-media-stream!</code>, the <code>.getUserMedia&#40;&#41;</code> call is fine, since we've added an audio constraint. The next thing that happens is the call to <code>start-video!</code>, which we can rename to <code>start-media!</code> and then have a look at:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn start-media! &#91;stream&#93;
  &#40;reset! active-stream stream&#41;
  &#40;select-device! video-select &#40;.getVideoTracks stream&#41;&#41;
  &#40;set! &#40;.-srcObject video-element&#41; stream&#41;&#41;
</code></pre><p>It looks like we can use <code>select-device!</code> to handle the audio as well, so let's try that out:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn start-media! &#91;stream&#93;
  &#40;reset! active-stream stream&#41;
  &#40;select-device! audio-select &#40;.getAudioTracks stream&#41;&#41;
  &#40;select-device! video-select &#40;.getVideoTracks stream&#41;&#41;
  &#40;set! &#40;.-srcObject video-element&#41; stream&#41;&#41;
</code></pre><p>OK, it looks like we're in pretty good shape in <code>set-media-stream!</code> now, so let's keep walking through <code>load-ui!</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn load-ui! &#91;&#93;
  &#40;set! &#40;.-onchange video-select&#41; set-media-stream!&#41;
  &#40;-&gt; &#40;set-media-stream!&#41;
      &#40;.then get-devices&#41;
      &#40;.then log-devices&#41;
      &#40;.then populate-device-selects!&#41;&#41;&#41;
</code></pre><p>Next up after the call to <code>set-media-stream!</code> is the call to <code>get-devices</code>, so let's dig in there:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-devices &#91;&#93;
  &#40;.enumerateDevices js/navigator.mediaDevices&#41;&#41;
</code></pre><p>That looks pretty reasonable, so let's look at the final function called from <code>load-ui!</code>, which is <code>populate-device-selects!</code>.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn populate-device-selects! &#91;devices&#93;
  &#40;doseq &#91;device
          &#40;-&gt;&gt; devices
               &#40;filter #&#40;= &quot;videoinput&quot; &#40;.-kind %&#41;&#41;&#41;
               &#40;log-devices &quot;Populating video inputs with devices&quot;&#41;&#41;&#93;
    &#40;let &#91;option &#40;.createElement js/document &quot;option&quot;&#41;&#93;
      &#40;set! &#40;.-value option&#41; &#40;.-deviceId device&#41;&#41;
      &#40;set! &#40;.-text option&#41;
            &#40;or &#40;.-label device&#41;
                &#40;str &quot;Camera &quot; &#40;inc &#40;.-length video-select&#41;&#41;&#41;&#41;&#41;
      &#40;.appendChild video-select option&#41;&#41;&#41;&#41;
</code></pre><p>Yikes! 😱 Looks like we have a little refactoring to do here. After diving into the closest phonebooth (they still have those, right?) to replace our nerdy glasses with our LISP superhero cape, we can do a top-down design move and rewrite the function the way we wish it worked:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn populate-device-selects! &#91;devices&#93;
  &#40;populate-device-select! audio-select &#40;audio-devices devices&#41;&#41;
  &#40;populate-device-select! video-select &#40;video-devices devices&#41;&#41;&#41;
</code></pre><p>Looks quite nice, doesn't it? Given this, let's write <code>populate-device-select!</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn populate-device-select! &#91;select-element devices&#93;
  &#40;let &#91;select-label &#40;-&gt;&gt; &#40;.-labels select-element&#41; first .-textContent&#41;&#93;
    &#40;doseq &#91;device &#40;log-devices &#40;str &quot;Populating options for &quot; select-label&#41; devices&#41;&#93;
      &#40;let &#91;option &#40;.createElement js/document &quot;option&quot;&#41;&#93;
        &#40;set! &#40;.-value option&#41; &#40;.-deviceId device&#41;&#41;
        &#40;set! &#40;.-text option&#41; &#40;.-label device&#41;&#41;
        &#40;.appendChild select-element option&#41;&#41;&#41;&#41;&#41;
</code></pre><p>It's nice to specify in the log output which select we're populating, and since we specified a label in our HTML:</p><pre class="language-html"><code class="lang-html language-html">&lt;label for=&quot;videoSource&quot;&gt;Video source:&lt;/label&gt;
&lt;select id=&quot;videoSource&quot;&gt;&lt;/select&gt;
</code></pre><p>we can access the label through the <code>labels</code> property on the select element. Since we know that we only have one label, we can take the first one and grab the value of its <code>textContent</code> property:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;let &#91;select-label &#40;-&gt;&gt; &#40;.-labels select-element&#41; first .-textContent&#41;&#93;
  ;; ...
  &#41;
</code></pre><p>Pretty neat!</p><p>OK, now that we have <code>populate-device-select!</code>, the last two functions we need to write are <code>audio-devices</code> and <code>video-devices</code>. Well, it turns out that we've more or less already written <code>video-devices</code> in the original <code>populate-device-selects!</code> code:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; devices
     &#40;filter #&#40;= &quot;videoinput&quot; &#40;.-kind %&#41;&#41;&#41;
     &#40;log-devices &quot;Populating video inputs with devices&quot;&#41;&#41;
</code></pre><p>Let's transform this into a function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn video-devices &#91;devices&#93;
  &#40;filter #&#40;= &quot;videoinput&quot; &#40;.-kind %&#41;&#41; devices&#41;&#41;
</code></pre><p>Given this, writing <code>audio-devices</code> is just some copy / paste / <code>query-replace</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn audio-devices &#91;devices&#93;
  &#40;filter #&#40;= &quot;audioinput&quot; &#40;.-kind %&#41;&#41; devices&#41;&#41;
</code></pre><p>We can test this out:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;doseq &#91;f &#91;audio-devices video-devices&#93;&#93;
    &#40;-&gt; &#40;get-devices&#41;
        &#40;.then &#40;comp log-devices f&#41;&#41;&#41;&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p>We should now see something like this in the JavaScript console:</p><pre class="language-text"><code class="lang-text language-text">audioinput:
  Default &#40;default&#41;
  Tiger Lake-LP Smart Sound Technology Audio Controller Digital Microphone &#40;94edc85f1f91926d1e9f9da6995188d6263dee15e8a45a6d1add28f64f74c13b&#41;
  HD Webcam B910 Analog Stereo &#40;f78a32bacbfbe6ffe238b0d3b046f11bf4ed8e5ad8ce6cf25f18d431be3cd9af&#41;
  Yeti X Analog Stereo &#40;5af7607e641d0c8061291e648a5bec4958a588147bf0ffcc61a1ef5f2afb6cb6&#41;
videoinput:
  Integrated Camera &#40;04f2:b6ea&#41; &#40;d9862f4684c6b3f21bf95436a09b58dfa1b7a442e79aff225314e5e9bab45217&#41;
  UVC Camera &#40;046d:0823&#41; &#40;046d:0823&#41; &#40;24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50&#41;
</code></pre><p>Amazing!</p><p>At this point, we should be able to call <code>load-ui!</code> and see both the audio and video sources in their respective select dropdowns:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;load-ui!&#41;
  ;; =&gt; #object&#91;Promise &#91;object Promise&#93;&#93;

  &#41;
</code></pre><p><img src="assets/2024-02-22-cljcastr-audioselect.png" alt="Screenshot of a browser window showing a selection box containing four audio input sources" title="Get in mah ear!" width=800px /></p><p>As a brief aside, I'm annoyed by that 404 (Not Found) when trying to get <code>favicon.ico</code>, so let's fix that, using lessons learned in <a href='2022-07-05-hacking-blog-favicon.html'>Hacking the blog:
favicon</a>!</p><h2 id="an_iconic_favicon">An iconic favicon</h2><p>We'll just pop over to <a href='https://realfavicongenerator.net/'>RealFaviconGenerator</a> and supply a logo such as this one:</p><p><img src="assets/2024-02-22-cljcastr-logo.png" alt="A microphone with the Clojure logo for the top part" title="Everything's better with Clojure on top!" width=260 /></p><p>Then download the favicon package and unzip it into our webserver root:</p><pre class="language-text"><code class="lang-text language-text">$ cd public
$ unzip &#126;/Downloads/cljcastr-favicon&#95;package&#95;v0.16.zip
Archive:  /home/jmglov/Downloads/cljcastr-favicon&#95;package&#95;v0.16.zip
  inflating: android-chrome-192x192.png
  inflating: mstile-150x150.png
  inflating: favicon-16x16.png
  inflating: safari-pinned-tab.svg
  inflating: favicon.ico
  inflating: site.webmanifest
  inflating: android-chrome-512x512.png
  inflating: apple-touch-icon.png
  inflating: browserconfig.xml
  inflating: favicon-32x32.png
</code></pre><p>And finally, drop this goodness into the <code>&lt;head&gt;</code> section of <code>public/index.html</code>:</p><pre class="language-html"><code class="lang-html language-html">&lt;head&gt;
    &lt;!-- ... --&gt;
    &lt;link rel=&quot;apple-touch-icon&quot; sizes=&quot;180x180&quot; href=&quot;/apple-touch-icon.png&quot;&gt;
    &lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;32x32&quot; href=&quot;/favicon-32x32.png&quot;&gt;
    &lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;16x16&quot; href=&quot;/favicon-16x16.png&quot;&gt;
    &lt;link rel=&quot;manifest&quot; href=&quot;/site.webmanifest&quot;&gt;
    &lt;link rel=&quot;mask-icon&quot; href=&quot;/safari-pinned-tab.svg&quot; color=&quot;#5bbad5&quot;&gt;
    &lt;meta name=&quot;msapplication-TileColor&quot; content=&quot;#da532c&quot;&gt;
    &lt;meta name=&quot;theme-color&quot; content=&quot;#ffffff&quot;&gt;
    &lt;!-- ... --&gt;
&lt;/head&gt;
</code></pre><p>Reloading the cljcastr page should now show a delightful little icon in the tab.</p><h2 id="hey_mr._selector">Hey Mr. Selector</h2><p>The only thing lacking at this point is adding an <code>onchange</code> event handler to the audio select. We can do that in <code>load-ui!</code>, and then we might as well call <code>load-ui!</code> on page load whilst we're at it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn load-ui! &#91;&#93;
  &#40;set! &#40;.-onchange audio-select&#41; set-media-stream!&#41;
  &#40;set! &#40;.-onchange video-select&#41; set-media-stream!&#41;
  &#40;-&gt; &#40;set-media-stream!&#41;
      &#40;.then get-devices&#41;
      &#40;.then log-devices&#41;
      &#40;.then populate-device-selects!&#41;&#41;&#41;

&#40;load-ui!&#41;
</code></pre><p>Evaluating the buffer results in seeing ourselves, and we seem to be able to switch video and audio sources happily, but there's one annoying thing happening when switching audio source. Quoth the JS console:</p><pre class="language-text"><code class="lang-text language-text">Getting media with constraints:
  {:audio
   {:deviceId
    {:exact
     5af7607e641d0c8061291e648a5bec4958a588147bf0ffcc61a1ef5f2afb6cb6}},
   :video
   {:deviceId
    {:exact
     24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50}}}
Setting selected video source to index 3
Setting selected video source to index 1
</code></pre><p>It looks like that first "video source" is actually an audio source. 😬</p><p>Looking at <code>select-device!</code>, it's obvious why this is:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn select-device! &#91;select-element tracks&#93;
  &#40;let &#91;label &#40;-&gt; tracks first &#40;.-label&#41;&#41;
        index &#40;-&gt;&gt; &#40;.-options select-element&#41;
                   &#40;zipmap &#40;range&#41;&#41;
                   &#40;some &#40;fn &#91;&#91;i option&#93;&#93;
                           &#40;and &#40;= label &#40;.-text option&#41;&#41; i&#41;&#41;&#41;&#41;&#93;
    &#40;when index
      &#40;println &quot;Setting selected video source to index&quot; index&#41;
      &#40;set! &#40;.-selectedIndex select-element&#41; index&#41;&#41;&#41;&#41;
</code></pre><p>Since we have the select DOM element here, we can use the same trick as in <code>populate-device-select!</code> to get its label. Let's extract that stuff to a function of its very own, then update <code>populate-device-select!</code> and <code>select-device!</code> to use it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn label-for &#91;element&#93;
  &#40;-&gt;&gt; &#40;.-labels element&#41; first .-textContent&#41;&#41;

&#40;defn populate-device-select! &#91;select-element devices&#93;
  &#40;doseq &#91;device &#40;log-devices &#40;str &quot;Populating options for &quot;
                                   &#40;label-for select-element&#41;&#41;
                              devices&#41;&#93;
    ;; ...
    &#41;&#41;

&#40;defn select-device! &#91;select-element tracks&#93;
  &#40;let &#91;
        ;; ...
       &#93;
    &#40;when index
      &#40;println &quot;Setting index for&quot; &#40;label-for select-element&#41; index&#41;
      &#40;set! &#40;.-selectedIndex select-element&#41; index&#41;&#41;&#41;&#41;
</code></pre><p>This looks much better now!</p><pre class="language-text"><code class="lang-text language-text">Getting media with constraints:
  {:audio
   {:deviceId
    {:exact
     5af7607e641d0c8061291e648a5bec4958a588147bf0ffcc61a1ef5f2afb6cb6}},
   :video
   {:deviceId
    {:exact
     24705d21befb46ac4b2596716eee02c2fecb819447ef3edb91562aad41d2db50}}}
Setting index for Audio source: 3
Setting index for Video source: 1
</code></pre><h2 id="stop%2C_in_the_name_of_privacy%2C_before_you_break_my_heart">Stop, in the name of privacy, before you break my heart</h2><p>This is all wonderful, but if you're anything like me, you probably don't like your webcam and mic surveilling you when you're not actively using them. Let's add a stop button that shuts down this whole dog and pony show. First, we can add the button in <code>index.html</code>:</p><pre class="language-html"><code class="lang-html language-html">  &lt;div id=&quot;container&quot;&gt;
    &lt;h1&gt;cljcastr&lt;/h1&gt;
    &lt;div id=&quot;sources&quot;&gt;
      &lt;div id=&quot;selects&quot;&gt;
        &lt;div class=&quot;select&quot;&gt; &lt;!-- ... --&gt; &lt;/div&gt;
        &lt;div class=&quot;select&quot;&gt; &lt;!-- ... --&gt; &lt;/div&gt;
      &lt;/div&gt;
      &lt;div id=&quot;stop&quot;&gt;
        &lt;input id=&quot;stop&quot; type=&quot;button&quot; value=&quot;Stop&quot; /&gt;
      &lt;/div&gt;
    &lt;/div&gt;
    &lt;video autoplay muted playsinline&gt;&lt;/video&gt;
  &lt;/div&gt;
</code></pre><p>Yes, yes, I added another <code>&lt;div&gt;</code>. Listen, I never claimed to actually know what I was doing with this whole new-fangled HTML thing, OK? Back in my day, we had <a href='https://www.howtogeek.com/661871/the-web-before-the-web-a-look-back-at-gopher/'>Gopher</a> and counted ourselves lucky! Also, we FTP'd files uphill both ways in a blizzard and so on.</p><p>Speaking of not knowing what I'm doing, lemme sprinkle some CSS on the top of this lovely cake:</p><pre class="language-css"><code class="lang-css language-css">div#sources {
  display: flex;
  margin-bottom: 10px;
}

div#stop {
  padding-left: 10px;
}
</code></pre><p>Now that we have a button, let's make it do stuff and things:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns cljcastr
  &#40;:require &#91;clojure.string :as str&#93;&#41;&#41;

&#40;def video-element &#40;.querySelector js/document &quot;video&quot;&#41;&#41;
&#40;def video-select &#40;.querySelector js/document &quot;select#videoSource&quot;&#41;&#41;
&#40;def audio-select &#40;.querySelector js/document &quot;select#audioSource&quot;&#41;&#41;
&#40;def stop-button &#40;.querySelector js/document &quot;input#stop&quot;&#41;&#41;

;; ...

&#40;defn load-ui! &#91;&#93;
  &#40;set! &#40;.-onclick stop-button&#41; stop-media!&#41;
  ;; ...
  &#41;
</code></pre><p>Reload the page, click the button, and watch your face disappear!</p><p><img src="assets/2024-02-22-cljcastr-stop.png" alt="Screenshot of a browser window showing a black video screen" title="Hello darkness my old friend" width=800px /></p><p>And there you have it! A fully functional Zencastr clone!</p><p><i>&#42;Ahem&#42;</i></p><p>Perhaps we're missing recording and connecting to other people and transcription and so on, but those are just bonus features that people don't really need for podcasting, right? Anyway, I'm quite proud of <a href='https://github.com/jmglov/cljcastr'>what we
accomplished</a> in 106 lines of ClojureScript! 🏆</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-02-14-elif-shafak.html</id>
    <link href="https://jmglov.net/blog/2024-02-14-elif-shafak.html"/>
    <title>The 40 rules of loving Elif Şafak</title>
    <updated>2024-02-14T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2024-02-14-elif-shafak-preview.png" alt="Elif Şafak sitting on a desk with bookshelves in the background and a lamp with a pink shade in the foreground" title="Elif Şafak, probably looking at her millions of prizes for amazing writing" width=800px /></p><p>It's Valentine's Day, so let's talk about love. Specifically, let's talk about the forty rules of love. 💕</p><p>If you haven't heard of Elif Şafak (pronounced "Shafak" in English), you are in good company, if you consider me from six years ago to be good company (no offence if you don't; we don't know each other that well—unless we do, in which case whyyyyyyyy don't you like me?). In fact, if you haven't heard of her, you are a lucky lucky person, because you have literary treats beyond your imagination in your future! A writer partial to a pun might even go so far as to call her books Turkish delights. I, of course, would never be so prosaic.</p><p>I first discovered Elif Şafak through my wife, who has absolutely exquisite taste in books. She was reading "<a href='https://www.elifsafak.com.tr/book/the-bastard-of-istanbul'>The Bastard of
Istanbul</a>", and she kept reading passages out loud to me. I told her that I wanted to read it as soon as she finished, and she told me that she actually had another of Elif Şafak's books, "<a href='https://www.elifsafak.com.tr/book/honour'>Honour</a>", and that I should definitely read it.</p><p>I grabbed it off the bookshelf immediately and dove into a story of "a young Kurdish woman in London trying to come to terms with the terrible murder her brother has committed." This is a really serious topic, and Şafak approaches from a modern secular feminist perspective, whilst managing to be both critical of and respectful of Islam. The book is also side-splittingly funny at times, and this is typical of her writing. She weaves humour throughout her at times heart-rending stories, and is a masterful observer of people who uses her sharp wit to ridicule at times, but also draws upon a deep wellspring of compassion to paint her characters as complex humans instead of cartoon villains.</p><p>By the time I finished "Honour", my wife was done with "The Bastard of Istanbul", so I started it right away. And if "Honour" engaged me, "The Bastard of Istanbul" grabbed me by the shirt collar and wouldn't let go until I finished it, for it was set in Istanbul, a city that had long fascinated me. Here's the setup for the book:</p><blockquote><p> Whatever falls from the sky above, thou shall not curse it. </p><p> That includes the rain. </p><p> No matter what might pour down, no matter how heavy the cloudburst or how icy  the sleet, you should never ever utter profanities against whatever the  heavens might have in store for us. Everybody knows this. And that includes  Zeliha. </p><p> Yet, there she was on this first Friday of July, walking on a sidewalk that  flowed next to hopelessly clogged traffic; rushing to an appointment she was  now late for, swearing like a trooper, hissing one profanity after another at  the broken pavement stones, at her high heels, at the man stalking her, at  each and every driver who honked frantically when it was an urban fact that  clamor had no effect on unclogging traffic, at the whole Ottoman dynasty for  once upon a time conquering the city of Constantinople, and then sticking by  its mistake, and yes, at the rain . . . this damn summer rain. </p></blockquote><p>OK, I said that the story is set in Istanbul, which is true, but it's only part of the truth. It's also set in Tucson, Arizona and San Francisco. It follows two characters, Asya Kazancı and Armanoush Tchakhmakhchian, and shows how their families are connected through the <a href='https://en.wikipedia.org/wiki/Armenian_genocide'>Armenian
Genocide</a>.</p><p>If you know anything about modern Türkiye, you might know that there's a law against "insulting Turkishness", and some right wing lawyer actually used it to sue Elif Şafak in 2006. Despite the prosecutor initially dropping the charges after finding no insult, the lawyer appealed to a higher court. Şafak faced up to three years in prison, but was fortunately acquitted due to lack of legal grounds and insufficient evidence (see the <a href='https://en.wikipedia.org/wiki/The_Bastard_of_Istanbul#Trial_against_the_author'>Wikipedia
article</a> for more details.) This kind of legal harassment continued to dog her, leading her to eventually emigrate to London.</p><p>The next of her books that my wife bought would become my favourite: "<a href='https://www.elifsafak.com.tr/book/the-forty-rules-of-love'>The Forty
Rules of Love</a>". It is set in 13th centural Anatolia, and also 21st century USA. It tells the story of the historical figure <a href='https://en.wikipedia.org/wiki/Rumi'>Rumi</a>, who was a Muslim cleric who had a chance meeting with a wandering dervish named <a href='https://en.wikipedia.org/wiki/Shams_Tabrizi'>Shams</a> (who is also a historical figure, something I didn't know until writing this post) and became transformed into one of the greatest romantic poets the world has ever known. The story is revealed to us as it's revealed to Ella, a 21st century housewife who receives a mysterious book called "Sweet Blashemy" in the post.</p><p>"The Forty Rules of Love" is not only my favourite Elif Şafak book, it's one of my favourite books, full stop. Please please please do yourself the favour of reading it. You won't regret it! And if you do, please write to me and explain why you regretted it, so I can tell you that you didn't in fact regret it, but only thought you did. Or maybe don't @ me at all. Your choice, really.</p><p>So why do I bring up Elif Şafak now? Well, I just read another of her books, "<a href='https://www.elifsafak.com.tr/book/three-daughters-of-eve'>Three Daughters of
Eve</a>", which almost displaced "The Forty Rules of Love" as my fav. This book is:</p><blockquote><p> a story about identity, politics, religion, women and God. It is the story of  Peri, a young Turkish woman who grows up observing the clash between her  father's lonely secularism and her mother's Islamic religiosity. Peri earns a  scholarship from Oxford University and arrives in England. She becomes friends  with the charming Shirin, an atheist Iranian girl, and Mona, a headscarved  Egyptian-American. It is an unlikely friendship among three very different  girls from Muslim backgrounds. Shirin, “the sinner”, Mona, “the believer”, and  Peri, “the Confused.” </p><p> Peri's life changes dramatically when she meets the charismatic but  controversial Professor Azur who teaches about God. Peri falls in love but it  is a love that will only bring an unexpected twist and a dark secret that she  will have to carry for many years to come. By observing Peri's life in  contemporary Istanbul, Shafak takes a close look at Turkey today and reveals  the problems that Turkish society hides within. </p></blockquote><p>In true Şafak fashion (Şafashion?), the story is told in two different times, starting out in present day Istanbul, but flashing back to Peri's childhood in Istanbul in the 1980s and then Oxford in the 1990s as events unfolding in the present trigger Peri's memories of her youth.</p><p>I guess my point is that Elif Şafak is super awesome and you should read her books. 😂</p><p>I for one still have a few to go.</p><p><img src="assets/2024-02-14-bookshelf.jpg" alt="A portion of a bookshelf holding many books by Elif Şafak" title="Read these books!" width=800px /></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-02-13-bb-playground.html</id>
    <link href="https://jmglov.net/blog/2024-02-13-bb-playground.html"/>
    <title>Playing on the Babashka playground</title>
    <updated>2024-02-13T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2024-02-13-bb-playground-preview.png" alt="A child with the Babashka logo as a face on a playground slide" title="Photo by Amber Faust on Unsplash" width=800px /></p><p>As a longtime Linux user, I often find myself starting to type a complicated command. As a longtime programmer, I often find myself thinking, "Maybe I should just automate this," and open up a <code>whatever.sh</code> in Emacs. As a longtime Bash scripter, I often find myself hundreds of lines into a script and looking up how to do string substition for the 60,000th time. As a recent-ish convert to <a href='https://babashka.org/'>Babashka</a>, I often find myself closing the <code>whatever.sh</code> buffer quickly and opening <code>whatever.bb</code> instead. As a true believer in the power of the REPL, I often ask myself why I'm writing code in <code>whatever.bb</code> and then executing it in my terminal and rolling my eyes when it doesn't work and going back to Emacs and changing something and executing it in my terminal again like a caveman instead of just <strong>C-c C-v f c e</strong>-ing like a normal person.</p><p>Surely there must be a better way!</p><p><img src="assets/2024-02-13-cunning-plan.png" alt="Blackadder saying: I've got a plan so cunning you could put a tail on it and call it a weasel" title="I too have a cunning plan!" /></p><p>Enter the playground! And not just any playground, but a playground where joyous Babashkas (Babashki?) frolic, REPLing their little hearts out!</p><p>Here's what I did:</p><h2 id="constructing_the_playground">Constructing the playground</h2><p>First, I created a directory called <code>bb-playground</code> and dropped a <code>bb.edn</code> in it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;dev&quot; &quot;src&quot;&#93;}
</code></pre><p>Now if I create a <code>dev/user.clj</code>, I can start playing around in my REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns user
  &#40;:require &#91;clojure.string :as str&#93;&#41;&#41;

;; 1. Start a REPL with C-c M-j
;; 2. Evaluate this buffer with C-c C-k

&#40;comment

  ;; Do fun stuff here by putting your cursor at the end of an expression and
  ;; whacking C-c C-v f c e

  &#40;-&gt;&gt; &#40;System/getProperties&#41;
       &#40;filter &#40;fn &#91;&#91;k &#95;&#93;&#93; &#40;str/starts-with? k &quot;babashka.&quot;&#41;&#41;&#41;
       &#40;into {}&#41;&#41;
  ;; =&gt; {&quot;babashka.version&quot; &quot;1.3.188&quot;,
  ;;     &quot;babashka.config&quot; &quot;/home/jmglov/Documents/code/bb-playground/bb.edn&quot;}

  &#41;
</code></pre><p>That was quite easy, and very useful if I just want to play around with Clojure or any of the <a href='https://book.babashka.org/#libraries'>libraries that ship with
Babashka</a>, but what if I want to do something like list all of the objects in a certain S3 bucket with a specific prefix?</p><h2 id="making_the_playground_more_fun">Making the playground more fun</h2><p>In order to do what I want, I'm going to need my old favourite <a href='https://github.com/grzm/awyeah-api'>awyeah-api</a>, which itself needs some friends from the the Cognitect <a href='https://github.com/cognitect-labs/aws-api'>aws-api</a> library:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {com.cognitect.aws/endpoints {:mvn/version &quot;1.1.12.504&quot;}
        com.cognitect.aws/s3 {:mvn/version &quot;848.2.1413.0&quot;}
        com.grzm/awyeah-api {:git/url &quot;https://github.com/grzm/awyeah-api&quot;
                             :git/sha &quot;e5513349a2fd8a980a62bbe0d45a0d55bfcea141&quot;
                             :git/tag &quot;v0.8.84&quot;}
        org.babashka/spec.alpha {:git/url &quot;https://github.com/babashka/spec.alpha&quot;
                                 :git/sha &quot;1a841c4cc1d4f6dab7505a98ed2d532dd9d56b78&quot;}}}
</code></pre><p>So I could just paste those deps into my <code>bb.edn</code>, but I want the latest and greatest from the AWS APIs, which I can grab from <a href='https://github.com/cognitect-labs/aws-api/blob/main/latest-releases.edn'>aws-api/latest-releases.edn</a>, but then I have to get git hashes for awyeah-api and org.babashka/spec.alpha and paste them into my <code>bb.edn</code> and that seems like a lot of work that I'm too lazy to do.</p><p>What I can do instead is use the power of <a href='https://github.com/babashka/neil'>neil</a> to add my dependencies for me!</p><p>Since <a href='https://github.com/jlesquembre'>someone</a> has been lovely enough to add neil to <a href='https://github.com/NixOS/nixpkgs/blob/c0b7a892fb042ede583bdaecbbdc804acb85eabe/pkgs/development/tools/neil/default.nix'>nixpkgs</a>, installing it is as easy as plopping it into my <a href='https://github.com/jmglov/nixos-config/blob/59983ddf4e959f634d0d87aee9fbdd2a01f8ce95/jmglov/home.nix#L37'>home.nix</a> and running:</p><pre class="language-text"><code class="lang-text language-text">: &#126;; sudo nixos-rebuild switch 
</code></pre><p>Now I can add dependencies like this:</p><pre class="language-text"><code class="lang-text language-text">: bb-playground; neil dep add --deps-file bb.edn com.cognitect.aws/endpoints
</code></pre><p>The only problem is that I'm never in a million years going to remember all the arguments to neil. 🤔</p><h2 id="working_around_my_poor_memory">Working around my poor memory</h2><p>What if my <code>bb.edn</code> knew how to add dependencies to itself? Like, could I just run:</p><pre class="language-text"><code class="lang-text language-text">bb add-dep com.cognitect.aws/s3
</code></pre><p>and be done with it? Even I should be able to remember that! 😅</p><p>Well, Babashka has this thing called the <a href='https://book.babashka.org/#tasks'>task
runner</a>, whereby you can drop stuff like this in your <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:tasks
 {:requires &#40;&#91;babashka.fs :as fs&#93;&#41;
  clean &#40;do &#40;println &quot;Removing target folder.&quot;&#41;
            &#40;fs/delete-tree &quot;target&quot;&#41;&#41;
  }
 }
</code></pre><p>and then run:</p><pre class="language-text"><code class="lang-text language-text">$ ls target
total 4
-rw-r--r-- 1 jmglov users 107 Dec 18 13:28 stuff.jar
$ bb clean
Removing target folder.
$ ls target
ls: cannot access 'target': No such file or directory
</code></pre><p>So with this, let's add a task to our <code>bb.edn</code>!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;dev&quot; &quot;src&quot;&#93;
 :deps {com.cognitect.aws/endpoints {:mvn/version &quot;1.1.12.626&quot;}}
 :aliases {}
 :tasks
 {add-dep &#40;println &quot;What to do, what to do?&quot;&#41;}}
</code></pre><p>And try it out:</p><pre class="language-text"><code class="lang-text language-text">: bb-playground; bb add-dep
What to do, what to do?
</code></pre><p>Cool! Now, if we think about what we want to do, it's basically: prepend "neil dep add &ndash;deps-file bb.edn" to the command line passed to <code>bb add-dep</code>. <a href='https://book.babashka.org/#_command_line_arguments'>According to the docs</a>: ></p><blockquote><p> Command line arguments are available as <em>command-line-args</em>, just like in  Clojure. </p></blockquote><p>We can drop this in our <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
 :tasks
 {add-dep &#40;println &quot;ARGS:&quot; &#42;command-line-args&#42;&#41;}}}
</code></pre><p>And see what the command line looks like when we play around:</p><pre class="language-text"><code class="lang-text language-text">: bb-playground; bb add-dep com.cognitect.aws/s3
ARGS: &#40;com.cognitect.aws/s3&#41;
</code></pre><p>OK, nice. So now we want to pass that along to neil. We can do this with <a href='https://github.com/babashka/process#shell'>babashka.process/shell</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
:tasks
 {:requires &#40;&#91;babashka.process :as p&#93;&#41;
  add-dep &#40;apply p/shell &quot;neil dep add --deps-file bb.edn&quot; &#42;command-line-args&#42;&#41;}}
</code></pre><p>Now let's try this out:</p><pre class="language-text"><code class="lang-text language-text">: bb-playground; bb add-dep com.cognitect.aws/s3

: bb-playground; cat bb.edn 
{:paths &#91;&quot;dev&quot; &quot;src&quot;&#93;
 :deps {com.cognitect.aws/endpoints {:mvn/version &quot;1.1.12.626&quot;} com.cognitect.aws/s3 {:mvn/version &quot;848.2.1413.0&quot;}}
 :aliases {}
 :tasks
 {:requires &#40;&#91;babashka.process :as p&#93;
             &#91;clojure.string :as str&#93;&#41;
  add-dep &#40;apply p/shell &quot;neil dep add --deps-file bb.edn&quot; &#42;command-line-args&#42;&#41;}}
</code></pre><p>OMG wat!</p><p>Another cool thing we can do with the task runner is ask it what tasks we can run:</p><pre class="language-text"><code class="lang-text language-text">: bb-playground; bb tasks
The following tasks are available:

add-dep
</code></pre><p>We can even make this nicer by adding a description to the task:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
:tasks
 {:requires &#40;&#91;babashka.process :as p&#93;&#41;
  add-dep {:doc &quot;Add a dependency to the playground&quot;
           :task &#40;apply p/shell &quot;neil dep add --deps-file bb.edn&quot; &#42;command-line-args&#42;&#41;}}}
</code></pre><p>Now we get some additional memory joggage:</p><pre class="language-text"><code class="lang-text language-text">: bb-playground; bb tasks
The following tasks are available:

add-dep Add a dependency to the playground
</code></pre><p>OK, but what if we don't remember what args <code>add-dep</code> takes? Let's try asking for help:</p><pre class="language-text"><code class="lang-text language-text">: bb-playground; bb add-dep --help
Usage: neil add dep &#91;lib&#93; &#91;options&#93;
Options:
  --lib                         Fully qualified library name.
  --version                     Optional. When not provided, picks newest version from Clojars or Maven Central.
  --sha                         When provided, assumes lib refers to Github repo.
  --latest-sha                  When provided, assumes lib refers to Github repo and then picks latest SHA from it.
  --tag                         When provided, assumes lib refers to Github repo.
  --latest-tag                  When provided, assumes lib refers to Github repo and then picks latest tag from it.
  --deps/root                   Sets deps/root to give value.
  --as                          Use as dependency name in deps.edn
  --alias      &lt;alias&gt;          Add to alias &lt;alias&gt;.
  --deps-file  &lt;file&gt;  deps.edn Add to &lt;file&gt; instead of deps.edn.
</code></pre><p>Oh neat! Of course, it's a bit confusing that the usage line says "neil add dep" instead of "bb add-dep". Let's fix that!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{ ; ...
 :tasks
 {:requires &#40;&#91;babashka.process :as p&#93;
             &#91;clojure.string :as str&#93;&#41;
  add-dep {:doc &quot;Add a dependency to the playground&quot;
           :task &#40;let &#91;args &#40;or &#42;command-line-args&#42; &#91;&quot;--help&quot;&#93;&#41;
                       neil-args &#40;concat &#91;&quot;neil&quot; &quot;dep&quot; &quot;add&quot; &quot;--deps-file&quot; &quot;bb.edn&quot;&#93; args&#41;
                       {:keys &#91;out&#93;} &#40;apply p/shell {:out :string} neil-args&#41;&#93;
                   &#40;if &#40;= &quot;--help&quot; &#40;first args&#41;&#41;
                     &#40;-&gt;&gt; &#91;&#40;str/replace out &quot;Usage: neil add dep&quot; &quot;Usage: bb add-dep&quot;&#41;
                           &quot;Examples:\n&quot;
                           &quot;bb add-dep com.cognitect.aws/endpoints&quot;
                           &quot;bb add-dep com.cognitect.aws/s3 --version 848.2.1413.0&quot;
                           &quot;bb add-dep grzm/awyeah-api --latest-sha&quot;&#93;
                          &#40;str/join &quot;\n&quot;&#41;
                          println&#41;
                     &#40;println out&#41;&#41;&#41;}}}
</code></pre><p>Now if we ask for help:</p><pre class="language-text"><code class="lang-text language-text">Usage: bb add-dep &#91;lib&#93; &#91;options&#93;
Options:
  --lib                         Fully qualified library name.
  --version                     Optional. When not provided, picks newest version from Clojars or Maven Central.
  --sha                         When provided, assumes lib refers to Github repo.
  --latest-sha                  When provided, assumes lib refers to Github repo and then picks latest SHA from it.
  --tag                         When provided, assumes lib refers to Github repo.
  --latest-tag                  When provided, assumes lib refers to Github repo and then picks latest tag from it.
  --deps/root                   Sets deps/root to give value.
  --as                          Use as dependency name in deps.edn
  --alias      &lt;alias&gt;          Add to alias &lt;alias&gt;.
  --deps-file  &lt;file&gt;  deps.edn Add to &lt;file&gt; instead of deps.edn.

Examples:

bb add-dep com.cognitect.aws/endpoints
bb add-dep com.cognitect.aws/s3 --version 848.2.1413.0
bb add-dep grzm/awyeah-api --latest-sha
</code></pre><p>The one problem with all of this is that we've been doing the thing that I was complaining about at the top: writing some code, saving the file, executing it in a terminal, realising it doesn't quite work, going back to the editor... etc.</p><p><img src="assets/2024-02-13-cowbell.jpg" alt="Christopher Walken cowbell meme: I've got a fever... and the only prescription is more REPL" title="Bring me your finest CIDER!" /></p><h2 id="getting_back_to_the_repl">Getting back to the REPL</h2><p>OK, so remember the REPL we had running over in <code>dev/user.clj</code>? We can use that to drive our <code>bb.edn</code> development. Let's create a <code>src/tasks.clj</code> file and copy all the <code>add-dep</code> stuff over to it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
  &#40;:require &#91;babashka.process :as p&#93;
            &#91;clojure.string :as str&#93;&#41;&#41;

&#40;defn add-dep &#91;command-line-args&#93;
  &#40;let &#91;args &#40;or command-line-args &#91;&quot;--help&quot;&#93;&#41;
        neil-args &#40;concat &#91;&quot;neil&quot; &quot;dep&quot; &quot;add&quot; &quot;--deps-file&quot; &quot;bb.edn&quot;&#93; args&#41;
        {:keys &#91;out&#93;} &#40;apply p/shell {:out :string} neil-args&#41;&#93;
    &#40;if &#40;= &quot;--help&quot; &#40;first args&#41;&#41;
      &#40;-&gt;&gt; &#91;&#40;str/replace out &quot;Usage: neil add dep&quot; &quot;Usage: bb add-dep&quot;&#41;
            &quot;Examples:\n&quot;
            &quot;bb add-dep com.cognitect.aws/endpoints&quot;
            &quot;bb add-dep com.cognitect.aws/s3 --version 848.2.1413.0&quot;
            &quot;bb add-dep grzm/awyeah-api --latest-sha&quot;&#93;
           &#40;str/join &quot;\n&quot;&#41;
           println&#41;
      &#40;println out&#41;&#41;&#41;&#41;
</code></pre><p>Let's give it a <strong>C-c C-k</strong> to evaluate the buffer, just to make sure everything is in order. 😉</p><p>Now we can use this from <code>bb.edn</code></p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;dev&quot; &quot;src&quot;&#93;
 :deps {com.cognitect.aws/endpoints {:mvn/version &quot;1.1.12.626&quot;}
        com.cognitect.aws/s3 {:mvn/version &quot;848.2.1413.0&quot;}}
 :aliases {}
 :tasks
 {:requires &#40;&#91;tasks&#93;&#41;
  add-dep {:doc &quot;Add a dependency to the playground&quot;
           :task &#40;tasks/add-dep &#42;command-line-args&#42;&#41;}}}
</code></pre><p>And just to verify that it still works:</p><pre class="language-text"><code class="lang-text language-text">: bb-playground; bb add-dep --help
Usage: bb add-dep &#91;lib&#93; &#91;options&#93;
Options:
  --lib                         Fully qualified library name.
  --version                     Optional. When not provided, picks newest version from Clojars or Maven Central.
  --sha                         When provided, assumes lib refers to Github repo.
  --latest-sha                  When provided, assumes lib refers to Github repo and then picks latest SHA from it.
  --tag                         When provided, assumes lib refers to Github repo.
  --latest-tag                  When provided, assumes lib refers to Github repo and then picks latest tag from it.
  --deps/root                   Sets deps/root to give value.
  --as                          Use as dependency name in deps.edn
  --alias      &lt;alias&gt;          Add to alias &lt;alias&gt;.
  --deps-file  &lt;file&gt;  deps.edn Add to &lt;file&gt; instead of deps.edn.

Examples:

bb add-dep com.cognitect.aws/endpoints
bb add-dep com.cognitect.aws/s3 --version 848.2.1413.0
bb add-dep grzm/awyeah-api --latest-sha
</code></pre><p>From now on, we won't need to leave our REPL to develop our tasks! Let's prove it by making <code>add-dep</code> print out the new dependencies after adding them:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns tasks
  &#40;:require &#91;babashka.process :as p&#93;
            &#91;clojure.edn :as edn&#93;
            &#91;clojure.pprint :refer &#91;pprint&#93;&#93;
            &#91;clojure.string :as str&#93;&#41;&#41;

&#40;defn add-dep &#91;command-line-args&#93;
  &#40;let &#91; ; ...
        &#93;
    &#40;if &#40;= &quot;--help&quot; &#40;first args&#41;&#41;
      ;; ...
      &#40;do
        &#40;println &quot;Dependency added. Dependencies are now:&quot;&#41;
        &#40;-&gt; &#40;slurp &quot;bb.edn&quot;&#41;
            edn/read-string
            :deps
            pprint&#41;&#41;&#41;&#41;&#41;

&#40;comment

  &#40;add-dep &#91;&quot;grzm/awyeah-api&quot;&#93;&#41;
  ;; nil

  &#41;
</code></pre><p>The REPL buffer should now say:</p><pre class="language-text"><code class="lang-text language-text">Dependency added. Dependencies are now:
{com.cognitect.aws/endpoints #:mvn{:version &quot;1.1.12.626&quot;},
 com.cognitect.aws/s3 #:mvn{:version &quot;848.2.1413.0&quot;},
 grzm/awyeah-api
 #:git{:url &quot;https://github.com/grzm/awyeah-api&quot;,
       :sha &quot;d98a9f6210c61d64f22e9b577d2254d6f6d2f35f&quot;}}
</code></pre><p>Hoorah! And just to prove that this also works from the terminal:</p><pre class="language-text"><code class="lang-text language-text">: bb-playground; bb add-dep babashka/spec.alpha
Dependency added. Dependencies are now:
{com.cognitect.aws/endpoints #:mvn{:version &quot;1.1.12.626&quot;},
 com.cognitect.aws/s3 #:mvn{:version &quot;848.2.1413.0&quot;},
 grzm/awyeah-api
 #:git{:url &quot;https://github.com/grzm/awyeah-api&quot;,
       :sha &quot;d98a9f6210c61d64f22e9b577d2254d6f6d2f35f&quot;},
 babashka/spec.alpha
 #:git{:url &quot;https://github.com/babashka/spec.alpha&quot;,
       :sha &quot;951b49b8c173244e66443b8188e3ff928a0a71e7&quot;}}
</code></pre><h2 id="so_about_listing_that_bucket...">So about listing that bucket...</h2><p>If we pop back over to <code>user.clj</code> and give it a <strong>C-c C-k</strong> get our REPL firmly planted back in that namespace, let's start REPL-driving some S3 goodness:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns user
  &#40;:require &#91;clojure.string :as str&#93;
            &#91;com.grzm.awyeah.client.api :as aws&#93;&#41;&#41;

&#40;comment

  &#40;def s3 &#40;aws/client {:api :s3}&#41;&#41;

  &#41;
</code></pre><p>Tragically, the second we try to evaluate this, everything goes a bit sideways:</p><pre class="language-text"><code class="lang-text language-text">clojure.lang.ExceptionInfo: Could not locate com/grzm/awyeah/client/api.bb, com/grzm/awyeah/client/api.clj or com/grzm/awyeah/client/api.cljc on classpath.
{:type :sci/error, :line 2, :column 3, :message &quot;Could not locate com/grzm/awyeah/client/api.bb, com/grzm/awyeah/client/api.clj or com/grzm/awyeah/client/api.cljc on classpath.&quot;, :sci.impl/callstack #object&#91;clojure.lang.Volatile 0x70ac29ff {:status :ready, :val &#40;{:line 2, :column 3, :file &quot;/home/jmglov/Documents/code/bb-playground/dev/user.clj&quot;, :ns #object&#91;sci.lang.Namespace 0x460699aa &quot;user&quot;&#93;}&#41;}&#93;, :file &quot;/home/jmglov/Documents/code/bb-playground/dev/user.clj&quot;}
 at sci.impl.utils$rethrow&#95;with&#95;location&#95;of&#95;node.invokeStatic &#40;utils.cljc:135&#41;
    &#91;...&#93;
Caused by: java.io.FileNotFoundException: Could not locate com/grzm/awyeah/client/api.bb, com/grzm/awyeah/client/api.clj or com/grzm/awyeah/client/api.cljc on classpath.
 at babashka.main$exec$fn&#95;&#95;32207$load&#95;fn&#95;&#95;32218.invoke &#40;main.clj:924&#41;
    sci.impl.load$handle&#95;require&#95;libspec.invokeStatic &#40;load.cljc:163&#41;
    &#91;...&#93;
</code></pre><p>Oh yes, our REPL was started before we added all the dependencies. 🤦🏼</p><p>Now, if you thought for a second about restarting the REPL, this is clearly your first time on this blog. We never take the coward's way out around here! Even (or especially) when that would be super fast and easy and the alternative is many many keystrokes and the occasional muttered curse word under our breath!</p><p>So let's screw our courage to the sticking point and hotload those damned dependencies! (OK, so the cursing isn't always under our breath.)</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns user
  &#40;:require &#91;babashka.deps :as deps&#93;
            &#91;clojure.edn :as edn&#93;&#41;&#41;

&#40;comment

  &#40;-&gt; &#40;slurp &quot;bb.edn&quot;&#41;
      edn/read-string
      deps/add-deps&#41;
  ;; =&gt; nil

  &#40;require '&#91;com.grzm.awyeah.client.api :as aws&#93;&#41;
  ;; =&gt; nil

  &#41;
</code></pre><p>Exceptional! Or rather, not exceptional, since no exceptions were thrown. Which is what we wanted. 😅</p><p>This actually looks pretty useful, so let's make it a function so we can just call it whenever we add a dependency:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns user
  &#40;:require &#91;babashka.deps :as deps&#93;
            &#91;clojure.edn :as edn&#93;&#41;&#41;

&#40;defn refresh-deps &#91;&#93;
  &#40;-&gt; &#40;slurp &quot;bb.edn&quot;&#41;
      edn/read-string
      deps/add-deps&#41;&#41;
</code></pre><p>Having done this, we can get back to listing stuff in S3.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def s3 &#40;aws/client {:api :s3, :region &quot;eu-west-1&quot;}&#41;&#41;
  ;; =&gt; #'user/s3

  &#40;-&gt;&gt; &#40;aws/invoke s3 {:op :ListObjectsV2
                       :request {:Bucket &quot;misc.jmglov.net&quot;}}&#41;
       :Contents
       count&#41;
  ;; =&gt; 1000

  &#41;
</code></pre><p>Oh my, that's a lot of stuff! And it's somewhat suspicious that it's exactly 1000 stuffs. Especially since 1000 is the default page size for the <a href='https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html'>ListObjectsV2</a> API request. In fact, I seem to remember writing a couple <a href='2022-09-22-aws-paging.html'>blog</a> <a href='2022-10-02-page-2.html'>posts</a> about paging and S3. I'll just go ahead and liberate some code from that second post there:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns user
  &#40;:require &#91;babashka.deps :as deps&#93;
            &#91;com.grzm.awyeah.client.api :as aws&#93;
            &#91;clojure.edn :as edn&#93;&#41;
  &#40;:import &#40;java.time Instant&#41;&#41;&#41;

&#40;defmacro -&gt;map &#91;&amp; ks&#93;
  &#40;assert &#40;every? symbol? ks&#41;&#41;
  &#40;zipmap &#40;map keyword ks&#41;
          ks&#41;&#41;

&#40;defn lazy-concat &#91;colls&#93;
  &#40;lazy-seq
   &#40;when-first &#91;c colls&#93;
     &#40;lazy-cat c &#40;lazy-concat &#40;rest colls&#41;&#41;&#41;&#41;&#41;&#41;

&#40;defn log &#91;msg data&#93;
  &#40;prn {:msg msg
        :data data
        :timestamp &#40;str &#40;Instant/now&#41;&#41;}&#41;&#41;

&#40;defn error &#91;msg data&#93;
  &#40;log msg data&#41;
  &#40;throw &#40;ex-info msg data&#41;&#41;&#41;

&#40;defn validate-aws-response &#91;res&#93;
  &#40;when &#40;:cognitect.anomalies/category res&#41;
    &#40;let &#91;data &#40;merge &#40;select-keys res &#91;:cognitect.anomalies/category&#93;&#41;
                      {:err-msg &#40;:Message res&#41;
                       :err-type &#40;:&#95;&#95;type res&#41;}&#41;&#93;
      &#40;error &quot;AWS request failed&quot; data&#41;&#41;&#41;
  res&#41;

&#40;defn mk-s3-req
  &#40;&#91;s3-bucket prefix s3-page-size&#93;
   &#40;mk-s3-req s3-bucket prefix s3-page-size nil&#41;&#41;
  &#40;&#91;s3-bucket prefix s3-page-size continuation-token&#93;
   &#40;merge {:Bucket s3-bucket
           :Prefix prefix}
          &#40;when s3-page-size
            {:MaxKeys s3-page-size}&#41;
          &#40;when continuation-token
            {:ContinuationToken continuation-token}&#41;&#41;&#41;&#41;

&#40;defn get-s3-page &#91;{:keys &#91;s3-client s3-bucket s3-page-size&#93;}
                   prefix
                   {continuation-token :NextContinuationToken
                    truncated? :IsTruncated
                    page-num :page-num
                    :as prev}&#93;
  &#40;when prev &#40;log &quot;Got page&quot; &#40;dissoc prev :Contents&#41;&#41;&#41;
  &#40;let &#91;page-num &#40;inc &#40;or page-num 0&#41;&#41;
        done? &#40;false? truncated?&#41;
        request &#40;mk-s3-req s3-bucket prefix s3-page-size continuation-token&#41;
        response &#40;when-not done?
                   &#40;log &#40;format &quot;Requesting page %d&quot; page-num&#41; request&#41;
                   &#40;-&gt; &#40;aws/invoke s3-client {:op :ListObjectsV2
                                              :request request}&#41;
                       validate-aws-response
                       &#40;assoc :page-num page-num&#41;&#41;&#41;&#93;
    response&#41;&#41;

&#40;defn list-objects &#91;{:keys &#91;s3-bucket limit&#93; :as logs-client} prefix&#93;
  &#40;log &quot;Listing S3 objects&quot; &#40;merge &#40;-&gt;map s3-bucket prefix&#41;
                                   &#40;when limit {:limit limit}&#41;&#41;&#41;
  &#40;let &#91;apply-limit &#40;if limit &#40;partial take limit&#41; identity&#41;&#93;
    &#40;-&gt;&gt; &#40;iteration &#40;partial get-s3-page logs-client prefix&#41;
                    :vf :Contents&#41;
         lazy-concat
         apply-limit
         &#40;map :Key&#41;&#41;&#41;&#41;
</code></pre><p>I won't explain all this here. If you're curious, please do read the <a href='page-2.html'>Page
2</a> post.</p><p>In any case, having done all of this, let's try using it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def cfg {:aws-region &quot;eu-west-1&quot;
            :s3-bucket &quot;misc.jmglov.net&quot;
            :s3-page-size 1000}&#41;
  ;; =&gt; #'user/cfg

  &#40;def ctx &#40;assoc cfg :s3-client
                  &#40;aws/client {:api :s3, :region &quot;eu-west-1&quot;}&#41;&#41;&#41;
  ;; =&gt; #'user/ctx

  &#40;-&gt;&gt; &#40;list-objects ctx &quot;&quot;&#41;
       &#40;take 5&#41;&#41;
  ;; =&gt; &#40;&quot;.write&#95;access&#95;check&#95;file.temp&quot;
  ;;     &quot;1-what-do-i-want.json&quot;
  ;;     &quot;Abeba&#95;Birhane.json&quot;
  ;;     &quot;Adrian&#95;C&#95;Jackson.json&quot;
  ;;     &quot;Advice&#95;Aniyia&#95;Williams.json&quot;&#41;

  &#41;
</code></pre><p>Now we're getting somewhere!</p><h2 id="no_one_is_afraid_of_json_voorhees">No one is afraid of JSON Voorhees</h2><p>I recall using this bucket for some transcription I did for the excellent <a href='https://blubrry.com/1475055/'>Conversations with Kim Crayton</a> podcast, which is what all those JSON files are. Perhaps I can do some organising here by moving them to a separate "folder". Let's just see how many I'm dealing with here:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns user
  &#40;:require ; ...
            &#91;clojure.string :as str&#93;&#41;
  &#40;:import &#40;java.time Instant&#41;&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; &#40;list-objects ctx &quot;&quot;&#41;
       &#40;filter #&#40;str/ends-with? % &quot;.json&quot;&#41;&#41;
       count&#41;
  ;; =&gt; 224

  &#41;
</code></pre><p>And how many of these are in the "root directory"?</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;list-objects ctx &quot;&quot;&#41;
       &#40;filter #&#40;and &#40;str/ends-with? % &quot;.json&quot;&#41;
                     &#40;not &#40;str/includes? % &quot;/&quot;&#41;&#41;&#41;&#41;
       count&#41;
  ;; =&gt; 209

  &#41;
</code></pre><p>That is many! Let's see about moving one into another folder:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def src-filename &#40;-&gt;&gt; &#40;list-objects ctx &quot;&quot;&#41;
                         &#40;filter #&#40;and &#40;str/ends-with? % &quot;.json&quot;&#41;
                                       &#40;not &#40;str/includes? % &quot;/&quot;&#41;&#41;&#41;&#41;
                         first&#41;&#41;
  ;; =&gt; #'user/src-filename

  &#40;aws/invoke &#40;:s3-client ctx&#41; {:op :GetObject
                                :request {:Bucket &#40;:s3-bucket ctx&#41;
                                          :Key src-filename}}&#41;
  ;; =&gt; {:LastModified #inst &quot;2023-02-25T10:40:19.000-00:00&quot;,
  ;;     :ETag &quot;\&quot;95a40408c21908a18e596f9b46eb10ac\&quot;&quot;,
  ;;     :Body
  ;;     #object&#91;java.io.BufferedInputStream 0x19011b9f &quot;java.io.BufferedInputStream@19011b9f&quot;&#93;,
  ;;     :Metadata {},
  ;;     :ServerSideEncryption &quot;AES256&quot;,
  ;;     :ContentLength 290662,
  ;;     :ContentType &quot;binary/octet-stream&quot;,
  ;;     :AcceptRanges &quot;bytes&quot;,
  ;;     :VersionId &quot;ROt3VOKf67.OaoHFFsBTWCVSL4vIb7MI&quot;}

  &#41;
</code></pre><p>OK, seems like the <code>:Body</code> is what we want here. It's an input stream, which is exactly what the <a href='https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObject.html'>PutObject</a> API request wants, according to <a href='https://github.com/cognitect-labs/aws-api/blob/44711d911988b4d8dd309c19277ce53848605b49/examples/s3_examples.clj#L64'>aws-api/examples/s3_examples.clj</a>. Let's give it a shot:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def target-filename &#40;format &quot;podcasts/conversations-with-kim-crayton/%s&quot; src-filename&#41;&#41;
  ;; =&gt; #'user/target-filename

  &#40;let &#91;{:keys &#91;s3-client s3-bucket&#93;} ctx
        obj &#40;aws/invoke s3-client {:op :GetObject
                                   :request {:Bucket s3-bucket
                                             :Key src-filename}}&#41;&#93;
    &#40;aws/invoke s3-client {:op :PutObject
                           :request {:Bucket s3-bucket
                                     :Key target-filename
                                     :Body &#40;:Body obj&#41;}}&#41;&#41;
  ;; =&gt; {:ETag &quot;\&quot;95a40408c21908a18e596f9b46eb10ac\&quot;&quot;,
  ;;     :ServerSideEncryption &quot;AES256&quot;,
  ;;     :VersionId &quot;KuURLdZOq.qwk3Q6OYNG92Q2C49JYivc&quot;}

  &#40;aws/invoke &#40;:s3-client ctx&#41; {:op :GetObject
                                :request {:Bucket &#40;:s3-bucket ctx&#41;
                                          :Key target-filename}}&#41;
  ;; =&gt; {:LastModified #inst &quot;2024-02-08T15:37:40.000-00:00&quot;,
  ;;     :ETag &quot;\&quot;95a40408c21908a18e596f9b46eb10ac\&quot;&quot;,
  ;;     :Body
  ;;     #object&#91;java.io.BufferedInputStream 0x65579e67 &quot;java.io.BufferedInputStream@65579e67&quot;&#93;,
  ;;     :Metadata {},
  ;;     :ServerSideEncryption &quot;AES256&quot;,
  ;;     :ContentLength 290662,
  ;;     :ContentType &quot;binary/octet-stream&quot;,
  ;;     :AcceptRanges &quot;bytes&quot;,
  ;;     :VersionId &quot;KuURLdZOq.qwk3Q6OYNG92Q2C49JYivc&quot;}

  &#41;
</code></pre><p>Now all we have to do is remove the "file" from the "root directory":</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;aws/invoke &#40;:s3-client ctx&#41; {:op :DeleteObject
                                :request {:Bucket &#40;:s3-bucket ctx&#41;
                                          :Key src-filename}}&#41;
  ;; =&gt; {:DeleteMarker true, :VersionId &quot;sY4dbN7Knyff0B67Id5BeenVAT1bmN.k&quot;}

  &#40;aws/invoke &#40;:s3-client ctx&#41; {:op :GetObject
                                :request {:Bucket &#40;:s3-bucket ctx&#41;
                                          :Key src-filename}}&#41;
  ;; =&gt; {:Error
  ;;     {:HostIdAttrs {},
  ;;      :KeyAttrs {},
  ;;      :Message &quot;The specified key does not exist.&quot;,
  ;;      :Key &quot;1-what-do-i-want.json&quot;,
  ;;      :CodeAttrs {},
  ;;      :RequestIdAttrs {},
  ;;      :HostId
  ;;      &quot;kE4zTuMao5e8TbMPn7rs1h48fNc9kEuMfBqLmayvcP+/SmEfbgfBGCsmJ3iZKcl6hpeyYKvSWXU=&quot;,
  ;;      :MessageAttrs {},
  ;;      :RequestId &quot;YV31XT7C98PQWTMX&quot;,
  ;;      :Code &quot;NoSuchKey&quot;},
  ;;     :ErrorAttrs {},
  ;;     :cognitect.aws.http/status 404,
  ;;     :cognitect.anomalies/category :cognitect.anomalies/not-found,
  ;;     :cognitect.aws.error/code &quot;NoSuchKey&quot;}

  &#41;
</code></pre><p><img src="assets/excellent.jpg" alt="Bill and Ted saying: excellent" title="We are not worthy of the REPL!" width=800px /></p><h2 id="getting_all_corporate_and_boring_and_stuff">Getting all corporate and boring and stuff</h2><p>We've been happily playing on the playground, but now we've created some stuff that might be useful, specifically a function that lists a bunch of objects and some code that moves an object from one key to another. Let's apply some organisation to make this stuff more reusable.</p><p>First, we can move all of the utility functions out of <code>user.clj</code> into a new <code>src/util.clj</code> file:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns util
  &#40;:import &#40;java.time Instant&#41;&#41;&#41;

&#40;defmacro -&gt;map &#91;&amp; ks&#93;
  &#40;assert &#40;every? symbol? ks&#41;&#41;
  &#40;zipmap &#40;map keyword ks&#41;
          ks&#41;&#41;

&#40;defn lazy-concat &#91;colls&#93;
  &#40;lazy-seq
   &#40;when-first &#91;c colls&#93;
     &#40;lazy-cat c &#40;lazy-concat &#40;rest colls&#41;&#41;&#41;&#41;&#41;&#41;

&#40;defn log &#91;msg data&#93;
  &#40;prn {:msg msg
        :data data
        :timestamp &#40;str &#40;Instant/now&#41;&#41;}&#41;&#41;

&#40;defn error &#91;msg data&#93;
  &#40;log msg data&#41;
  &#40;throw &#40;ex-info msg data&#41;&#41;&#41;
</code></pre><p>And then the S3-specific stuff goes in a new <code>src/s3.clj</code> file:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns s3
  &#40;:require &#91;com.grzm.awyeah.client.api :as aws&#93;
            &#91;clojure.string :as str&#93;
            &#91;util :refer &#91;log error lazy-concat -&gt;map&#93;&#93;&#41;&#41;

&#40;defn validate-aws-response &#91;res&#93;
  &#40;when &#40;:cognitect.anomalies/category res&#41;
    &#40;let &#91;data &#40;merge &#40;select-keys res &#91;:cognitect.anomalies/category&#93;&#41;
                      {:err-msg &#40;:Message res&#41;
                       :err-type &#40;:&#95;&#95;type res&#41;}&#41;&#93;
      &#40;error &quot;AWS request failed&quot; data&#41;&#41;&#41;
  res&#41;

&#40;defn mk-s3-req
  &#40;&#91;s3-bucket prefix s3-page-size&#93;
   &#40;mk-s3-req s3-bucket prefix s3-page-size nil&#41;&#41;
  &#40;&#91;s3-bucket prefix s3-page-size continuation-token&#93;
   &#40;merge {:Bucket s3-bucket
           :Prefix prefix}
          &#40;when s3-page-size
            {:MaxKeys s3-page-size}&#41;
          &#40;when continuation-token
            {:ContinuationToken continuation-token}&#41;&#41;&#41;&#41;

&#40;defn get-s3-page &#91;{:keys &#91;s3-client s3-bucket s3-page-size&#93;}
                   prefix
                   {continuation-token :NextContinuationToken
                    truncated? :IsTruncated
                    page-num :page-num
                    :as prev}&#93;
  &#40;when prev &#40;log &quot;Got page&quot; &#40;dissoc prev :Contents&#41;&#41;&#41;
  &#40;let &#91;page-num &#40;inc &#40;or page-num 0&#41;&#41;
        done? &#40;false? truncated?&#41;
        request &#40;mk-s3-req s3-bucket prefix s3-page-size continuation-token&#41;
        response &#40;when-not done?
                   &#40;log &#40;format &quot;Requesting page %d&quot; page-num&#41; request&#41;
                   &#40;-&gt; &#40;aws/invoke s3-client {:op :ListObjectsV2
                                              :request request}&#41;
                       validate-aws-response
                       &#40;assoc :page-num page-num&#41;&#41;&#41;&#93;
    response&#41;&#41;

&#40;defn list-objects &#91;{:keys &#91;s3-bucket limit&#93; :as logs-client} prefix&#93;
  &#40;log &quot;Listing S3 objects&quot; &#40;merge &#40;-&gt;map s3-bucket prefix&#41;
                                   &#40;when limit {:limit limit}&#41;&#41;&#41;
  &#40;let &#91;apply-limit &#40;if limit &#40;partial take limit&#41; identity&#41;&#93;
    &#40;-&gt;&gt; &#40;iteration &#40;partial get-s3-page logs-client prefix&#41;
                    :vf :Contents&#41;
         lazy-concat
         apply-limit
         &#40;map :Key&#41;&#41;&#41;&#41;

&#40;defn mk-client &#91;{:keys &#91;aws-region&#93; :as cfg}&#93;
  &#40;assoc cfg :s3-client
         &#40;aws/client {:api :s3, :region aws-region}&#41;&#41;&#41;
</code></pre><p>With this plumbing, let's write a function that actually moves an object:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn move-object &#91;{:keys &#91;s3-client s3-bucket&#93; :as ctx} source-key target-key&#93;
  &#40;let &#91;obj &#40;aws/invoke s3-client {:op :GetObject
                                   :request {:Bucket s3-bucket
                                             :Key source-key}}&#41;&#93;
    &#40;aws/invoke s3-client {:op :PutObject
                           :request {:Bucket s3-bucket
                                     :Key target-key
                                     :Body &#40;:Body obj&#41;}}&#41;
    &#40;aws/invoke &#40;:s3-client ctx&#41; {:op :DeleteObject
                                  :request {:Bucket s3-bucket
                                            :Key source-key}}&#41;&#41;&#41;
</code></pre><p>And now back in <code>user.clj</code>, let's try it all out:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;source-key &#40;-&gt;&gt; &#40;s3/list-objects ctx &quot;&quot;&#41;
                        &#40;filter #&#40;and &#40;str/ends-with? % &quot;.json&quot;&#41;
                                      &#40;not &#40;str/includes? % &quot;/&quot;&#41;&#41;&#41;&#41;
                        first&#41;
        target-key &#40;format &quot;podcasts/conversations-with-kim-crayton/%s&quot; source-key&#41;&#93;
    &#40;s3/move-object ctx source-key target-key&#41;&#41;
  ;; =&gt; {:DeleteMarker true, :VersionId &quot;B6.FetnSH9qYS7FwCJOuY0NxiPL1ur7b&quot;}

  &#41;
</code></pre><h2 id="wrapping_it_all_up_in_a_neat_little_package">Wrapping it all up in a neat little package</h2><p>Having built this beautiful little playground, I now have a REPL just lying around that I can try stuff out in, automate little things that I would normally do with a Bash "one-liner" that quickly grows into a Bash "100-liner", and if and when I discover useful little functions, move them into namespaces under <code>src/</code>, ready to be copied and pasted into real programs.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-02-12-boring-boring-arsenal.html</id>
    <link href="https://jmglov.net/blog/2024-02-12-boring-boring-arsenal.html"/>
    <title>Boring boring Arsenal</title>
    <updated>2024-02-12T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2024-02-12-boring-boring-arsenal-preview.png" alt="Declan Rice makes sure everyone knows he isn't celebrating his goal against his former club, West Ham" title="Jeez guys, didn't mean to do that" width=800px /></p><p>Life as an Arsenal fan was pretty great last season. No expectations whatsoever going into the season meant that those non-expectations were vastly exceeded. Infinitely exceeded, in fact. Of course, that bit late in the season where we wheeled out the old cannon and proceeded to blow our own foot off was a bit disappointing, but I was able to remind myself that at the beginning of the season, I said I'd be delighted to be back in the Champions Leage at the end of the season, and also Man City are the biggest cheaters to ever cheat at football (and I'm including fellow Manucian and diving shite-weister Wayne Rooney in this list, so that's saying summat, innit?), so I guess second is quite all right. I mean, ending the season with the Arsène Wenger Memorial Top Four Trophy is a great achievement, right? Not to mention starting the next season by winning the Jose Mourinho Counted It As A Trophy So It Definitely Is One Community Shield Trophy! 🏆</p><p>This season, given that expectations were on, has been a little less great. It started well, if slightly unconvincingly, with a couple of wins. That draw against Fullham that came next was a bit "same old Arsenal, always letting some chap score a last minute equaliser / winner", but whatever. We turned United over at home the following week, though, and it felt like a bit of spring had sprung. And then of course we had to have a pointless international break, and when we got back from that, it seemed like the tension had gone completely of of the spring, but we kinda flopped around and eeked out a 1-0 win over a dreadful Everton side away at Goodison Park.</p><p>And then came the first North London Derby of the year. I had foolishly booked a table at the local Arsenal pub (next time you're in Stockholm, pop over to <a href='https://flyinghorse.se/'>The
Flying Horse</a> and tell them I sent ya) for myself, three mates, my son, and three of his mates so that we could watch the match together with a load of other Arsenal fans (and somehow a guy who wore a Spurs shirt and then my son's friend the Chelsea supporter and my son's other friend the Man U supporter—why is he friends with these yahoos again?).</p><p>So it started out with some hilarity as giant goober Romero put one in his own goal, spurring a chorus of "<a href='https://www.youtube.com/watch?v=1MqLehx0Uno'>What do you think of
Tottenham?</a>", but then who else but the famous Swedish winger Huengminsson knocked one in the other end just before half time, but then absurdly assholish Romero fouled Saka in the box because of course he did, the outrageous oaf, and then Saka channelled some 80s Val Kilmer or 90s Dennis Bergkamp or 30s Eugene O'Neill and went all Iceman and put away the pen, but then the big Swede equalised again a minute later and that wanker in a Spurs shirt actually celebrated and somehow managed to keep his teeth (he was lucky that Swedish supporters only get violent when it's hockey being watched).</p><p>So that sucked.</p><p>The next two games did most decidedly not suck, though. First a four goal thumping of Bournemouth, then the classic <a href='https://www.youtube.com/watch?v=Ixr2TiF2Ahg'>One Nil to the
Arsenal</a> against City!</p><p>But then Chelsea. Ugh. What a shambles that was, and we were super lucky that Super Dec and Trossardinho were on hand to score a couple of late goals and get us out of the bus stop in Fullham with a point. 😢</p><p>5-0 over Sheffield WTF then 0-1 to Newcastle WTF!? 🤬</p><p>If you don't remember that game, it was the one where Joel Willock crossed the ball after it had gone out of play and then some wanker was offside and then Joelinton pushed Gabriel over and the referee awarded a goal and then VAR reviewed looked at each incident in turn and basically concluded that the referee on the field couldn't have been certain the ball was out and couldn't have seen the shove on Gabriel and somehow it wasn't offside and allowed the goal to stand and then Mikel Arteta lost his shit and said the standard of officiating was shocking and then the FA charged him with saying a thing that many other managers had said and was true, and then ruled that in fact Arteta hadn't done anything wrong because what he said was actually true and it didn't fucking matter because we still lost the three points.</p><p>We righted the ship a bit with five wins on the trot, though none of them was super convincing and we did that annoying thing against Wolves where we have a game good and won and then give up a late goal to sully the clean sheet. I know, I know, it's a bit of a champagne problem to complain about winning a game but not keeping a clean sheet, but listen: after years of sausages, I've now become accustomed once again to caviar.</p><p>Then that loss to Villa. Oh. my. gawd. Booooooooring and frustrating and... argh!</p><p>So Arteta, who has always been a defence-first sort of coach, finally feels he has the players to implement his preferred style of football, which is all about "control" and "killing the game with 300,000 passes in the opposition half", which is basically code for "boring". I mean, I guess I know how fans of Pep Guardiola teams feel: boring football is fun when you win and just plain eye-rollingly boring when you don't. And we didn't win that Villa game. Nor did we win the Liverpool game two weeks later, even though they were right there for the taking. And then we didn't win the West Ham game a week later nor did we win the Fulham game on New Year's Eve. At home!</p><p>Boring boring Arsenal. Top of the league before Christmas, down in forth a week later. Playing the slow, turgid, predictable kind of football that everyone in the league has figured out. Predictably, Arteta mentioned how predictable we had become in the press conference following the Fulham match, and incisively stated that we needed to find a way to be less predictable, or more unpredictable, whichever, really.</p><p>So off we jetted to Dubai for some warm weather training, and then we came back and hosted Crystal Palace, and I must have gotten something in my eye because I thought I saw Zinchenko playing, but standing where a left back would normally stand, and not only that but surely that couldn't be Ødegaard drifting over to the right side of midfield, could it? That's not the way we're supposed to play!</p><p>So I went to wash my eyes out, and when I came back, it was 2-0 to us and apparently Gabriel had scored a brace? OK, same old Arsenal, always scoring from set pieces. 🙄</p><p>Then Trossard scored, and I was watching the clock tick down, hoping for the whistle but knowing that some dude from Palace would come up with a last minute clean sheet soiler, probably caroming the ball off of three Arsenal players after Zinchenko tries a dribble on his own goal line, when OMG Little Gabi scored a goal in the 94th minute and then a more or less identical goal in the 95th? That I did not predict.</p><p>Nor did Roy Hodgson, whom I honestly felt sorry for. This poor guy has been dragged out of retirement twice by Crystal Palace to bail them out after sacking some horror show of a manager, and seems like a lovely old chap who deserves to put his feet up with a cuppa and finally give "<a href='https://www.goodreads.com/book/show/415.Gravity_s_Rainbow'>Gravity's
Rainbow</a>" a right go, but instead is being subjected to 5-0 thrashings.</p><p>It was at this point that I was confident enough to reserve a table at the pub again. I looked for a match that was a few weeks away (it was the 20th of January or so when I made this decision), we might have a chance of winning or at least not losing, and started in the afternoon so we could bring the boys along to the pub with us (under 18s aren't allowed in pubs in Sweden after 8 PM). Only one match in the next month met those criteria: West Ham. I rounded up the usual suspects and made the booking.</p><p>Then two days later, two of my mates were having a few drinks and then decided to have a few more, and before they knew it, they were suggesting we go to the pub for the Liverpool match on the 4th. Liverpool! Who were top of the league and playing well and had just frustrated us at Anfield!</p><p>Upon getting that text, my reaction was immediately, "Are you serious? There's no way I'm subjecting myself to that level of sadness in public!" But somehow that got lost in translation between my brain and my fingers, because when I read my response the next morning, I had apparently typed, "Fuck it dudes, let's go!" I had also apparently booked a table, at least according to the confirmation email I had from The Flying Horse in my email. 🤦🏼</p><p>OK, so next came Nottingham Forest, and things were going well, comfortable 2-0 win, clean sheet, and then of course there's some mixup at the back and some dude scores in the 89th minute and I HATE THAT SO MUCH! 🍾</p><p>The following week, I was a bundle of nerves as the time approached for me to catch the bus to the train to the pub to see Liverpool beat us at home. I asked my son if he'd give me a point, but he was holding out for all three like he's never seen Arsenal play before, so I was feeling pretty despondent as I slunk into the pub and found my table.</p><p>The place was absolutely packed with Arsenal supporters. Maybe not as many as for the NLD earlier in the season, but still packed. I quickly downed a beer to take the nerves off, then ordered another for the first half. I was about halfway through that one when Saka scored! The place went absolutely wild! Strangers were hugging each other and dancing around like they'd sustained serious head trauma. Ah, life was good!</p><p>Life continued to be good as halftime approached, and I was just getting up to see a man about a horse when Saliba and Raya had a little misunderstanding about who should deal with the ball in our box and concluded that they should just let that other dude deal with it but then that other dude turned out to be a Liverpool player and he decided to shoot it and it deflected off somebody and onto Gabriel's arm and on into the goal and whyyyyyyyyy? Why do I do this to myself?</p><p>After sorting out my horse-related business, I got another beer and resigned myself to 45 minutes of us plodding around the pitch, taking 25 touches before passing it sideways or backwards and inevitably conceeding a late winner to Diogo Jota. I finished my beer, ordered another. The server was approaching to deliver it when Virgil van Dyck and Alisson Becker had a conference of their own about who should deal with a ball in their box and Martinelli just bodied Big Virge and scored, and everybody screamed, which scared the server so much she jumped about a metre into the air but somehow didn't lose her grip on the tray of drinks.</p><p>Maybe it was all the beers, but after that goal went in, I started enjoying myself. Liverpool were being held at arm's length, and Jorginho was putting on a masterclass of metronomic passing and pretending to get fouled and wasting time, and the sense of dread that Jota would snag an equaliser faded more and more and then Trossard got that ball on the wing and just embarassed two Liverpool players and then picked his spot on van Dyck's heel to bank it off through Alisson's legs and into the back of the net and euphoria set in.</p><p>So that's why when I sat down yesterday at the pub before the West Ham match, I confidently predicted that we'd win 2-1, with some West Ham Hammer hammering home a clean sheet soiler in the 99th minute. Others at the table were more bullish. My son though that we'd win 2-1 as well, but do so by first going behind early and then scoring two in the second half. Sadru said 3-1, sparking some debate because we were the away team so technically he meant 1-3, but c'mon, that's just splitting hairs. Alastir must have been a few drinks ahead of me, because he predicted 4-1, I mean, 1-4. James went for 1-3 as well, as did my son's Arsenal supporting mate. His Chelsea supporting mate said 2-1 to West Ham because in his opinion, Martinelli is an incredibly average footballer and Saka is super one-dimensional and never uses his right foot, causing eyes to be rolled around the table. His United supporting mate took the piss so hard he knocked the urinal cake right out when he called 7-0 to West Ham. Jerk.</p><p>The West Ham fans decided to boo Declan Rice every time he touched the ball. The same Declan Rice who was their captain, agreed to stay one more year when some big clubs were sniffing around the summer before last, and led West Ham to a European trophy (of sorts)? Yeah, apparently no matter how classy you are when you leave a club, no matter that your club's chairman confirmed you're leaving by saying, "You can't ask for a man who has committed more to us this season," you're a traitorous traitor who deserves naught but scorn if you leave.</p><p>So when we got a corner in the 32nd minute and Rice walked over to take it, Sadru made a joke about how Rice must really want to not celebrate a goal, so we'd surely get one here, and lo and behold Saliba met Rice's great delivery perfectly and planted a bullet header in the back of the net! We didn't get to see Rice not celebrating the goal because the camera stayed on Saliba, who very much didn't not celebrate it.</p><p>The atmosphere in the pub was raucous, and got even raucouser when Saka was hacked down by West Ham's hapless keeper and converted cooly from the spot. We laughed as the cameras showed West Ham fans already heading to the concourse for their halftime pints, then cheered when we won another corner, Rice put in another great ball, and Big Gabi was on hand to head home with authority. I guess Rice didn't celebrate, but we didn't get to see because of Gabriel's emphatic knee slide being featured exclusively on our screen. More West Ham fans got out of their seats.</p><p>Before we'd even had a chance to sit back down, Trossardinho cut into the box, embarrassed two defenders, and curled one into the top bins. Cue pandemonium in the pub, replaced by hilarity as the cameras showed West Ham fans streaming out of the stadium, having seen enough.</p><p>We took the mick out of my son's Chelsea supporting mate when Saka scored again (he's so one-dimensional; all he does is score goals!), then this happened:</p><p><img src="assets/2024-02-12-boring-boring-arsenal-preview.png" alt="Declan Rice makes sure everyone knows he isn't celebrating his goal against his former club, West Ham" title="Jeez guys, didn't mean to do that" width=800px /></p><p>I was decidedly not bored. 😂</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-02-09-counting-blog-posts.html</id>
    <link href="https://jmglov.net/blog/2024-02-09-counting-blog-posts.html"/>
    <title>Counting blog posts in 50 simple steps</title>
    <updated>2024-02-09T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2024-02-09-preview.png" alt="An abacus" title="Photo by Crissy Jarvis on Unsplash" width=800px /></p><p>As I was writing <a href='2024-02-08-back-in-the-hammock-again.html'>yesterday's post</a>, I wanted to count the number of posts I had written during my last involuntary vacation in the summer of 2022. There is of course a very simple way to do this:</p><ol><li>Navigate to the <a href='archive.html'>archive page</a> of my blog</li><li>Realise that whilst there are only (as of the writing of yesterday's post)   two posts in 2024 (three as of the writing of this post, since yesterday's is   now there, and in fact four as of the reading of this post, because now this   post is also there) and three in 2023, there were enough in 2022 that   counting them by hand smacks of effort</li><li>Decide to use <a href='https://github.com/babashka/babashka'>Babashka</a> to count the   posts, since this blog is powered by   <a href='https://github.com/borkdude/quickblog'>quickblog</a> and thus the posts are   right at hand</li><li>Assert that it should be as easy as dropping a <code>user.clj</code> on the blog's   classpath</li><li>Open up the blog's   <a href='https://github.com/jmglov/jmglov.net/blob/main/bb.edn'>bb.edn</a> to figure out   how to do this:<pre class="language-clojure"><code class="lang-clojure language-clojure">   {:deps {jmglov/jmglov {:local/root &quot;.&quot;}
           io.github.borkdude/quickblog {:local/root &quot;../clojure/quickblog&quot;}
           #&#95;&quot;You use the newest SHA here:&quot;
           #&#95;{:git/sha &quot;b69c11f4292702f78a8ac0a9f32379603bebf2af&quot;}
          }
    ;; ...
   }
   </code></pre></li><li>Realise that the classpath is set up in <code>deps.edn</code>, because of this   incantation:<pre class="language-clojure"><code class="lang-clojure language-clojure">   {jmglov/jmglov {:local/root &quot;.&quot;}
   </code></pre></li><li>Open up <code>deps.edn</code> instead:<pre class="language-clojure"><code class="lang-clojure language-clojure">   {:paths &#91;&quot;.&quot; &quot;classes&quot;&#93;
    :deps {markdown-clj/markdown-clj {:mvn/version &quot;1.10.7&quot;}
           org.babashka/cli {:mvn/version &quot;0.8.55&quot;}
           babashka/fs {:mvn/version &quot;0.1.6&quot;}
           org.clojure/data.xml {:mvn/version &quot;0.2.0-alpha6&quot;}
           hiccup/hiccup {:mvn/version &quot;2.0.0-alpha2&quot;}
           babashka/pods {:git/url &quot;https://github.com/babashka/pods&quot;
                          :git/sha &quot;93081b75e66fb4c4d161f89e714c6b9e8d55c8d5&quot;}
           rewrite-clj/rewrite-clj {:mvn/version &quot;1.1.45&quot;}
           selmer/selmer {:mvn/version &quot;1.12.53&quot;}}}
   </code></pre></li><li>Armed with the knowledge that base directory is on the path, drop a   <code>user.clj</code> there:<pre class="language-clojure"><code class="lang-clojure language-clojure">   &#40;ns user&#41;
   </code></pre></li><li>Poise your hands elegantly above your keyboard like a concert pianist, then,   imagining the swells of the string section as they invite you in and the   upraised faces of the expecting crowd, attack the keyboard with a <strong>C-c M-j</strong>   (<code>cider-jack-in-clj</code>) and select babashka from the REPL options! 🎉</li><li>Refer back to <code>bb.edn</code> to see how the quickblog opts are defined:<pre class="language-clojure"><code class="lang-clojure language-clojure">    {:deps { ; ...
            }
     :tasks
     {:init &#40;def opts {:blog-title &quot;jmglov's blog&quot;
                       :blog-author &quot;Josh Glover&quot;
                       :blog-description &quot;A blog about stuff but also things.&quot;
                       :blog-root &quot;https://jmglov.net/blog/&quot;
                       :about-link &quot;https://jmglov.net/&quot;
                       :twitter-handle &quot;jmglov&quot;
                       :assets-dir &quot;blog/assets&quot;
                       :num-index-posts 3
                       :cache-dir &quot;.cache&quot;
                       :favicon true
                       :favicon-dir &quot;favicon&quot;
                       :out-dir &quot;public/blog&quot;
                       :posts-dir &quot;blog/posts&quot;
                       :templates-dir &quot;blog/templates&quot;}&#41;
   
      :requires &#40;&#91;babashka.cli&#93;
                 &#91;babashka.fs :as fs&#93;
                 &#91;clojure.string :as str&#93;
                 &#91;quickblog.api :as qb&#93;
                 &#91;quickblog.cli :as cli&#93;&#41;
      ;; ...
     }}
    </code></pre></li><li>Start to copy the <code>&#40;def opts {...}&#41;</code> bit into <code>user.clj</code>, but then, struck    by a blinding flash of insight that <code>bb.edn</code> is just EDN, decide to Not    Repeat Yourself (<a href='https://en.wikipedia.org/wiki/Don't_repeat_yourself'>NRY</a>,    obv) and just read in <code>bb.edn</code> and set opts from what's defined there:<pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;ns user
      &#40;:require &#91;clojure.edn :as edn&#93;&#41;&#41;

    &#40;comment
   
      &#40;-&gt; &#40;slurp &quot;bb.edn&quot;&#41;
          edn/read-string
          :tasks
          :init&#41;
      ;; &#40;def opts {:blog-title &quot;jmglov's blog&quot;
                    :blog-author &quot;Josh Glover&quot;
                    :blog-description &quot;A blog about stuff but also things.&quot;
                    :blog-root &quot;https://jmglov.net/blog/&quot;
                    :about-link &quot;https://jmglov.net/&quot;
                    :twitter-handle &quot;jmglov&quot;
                    :assets-dir &quot;blog/assets&quot;
                    :num-index-posts 3
                    :cache-dir &quot;.cache&quot;
                    :favicon true
                    :favicon-dir &quot;favicon&quot;
                    :out-dir &quot;public/blog&quot;
                    :posts-dir &quot;blog/posts&quot;
                    :templates-dir &quot;blog/templates&quot;}&#41;
    &#41;
   </code></pre></li><li>Celebrate your genius and <a href='https://clojuredocs.org/clojure.core/eval'>eval</a>    that string (making sure of course that you're only eval'ing it if it's    <code>&#40;def opts ...&#41;</code> and nothing untoward and/or sinister)!<pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;comment
   
      &#40;let &#91;&#91;form &amp; params :as expr&#93; &#40;-&gt; &#40;slurp &quot;bb.edn&quot;&#41;
                                         edn/read-string
                                         :tasks
                                         :init&#41;&#93;
        &#40;when &#40;and &#40;= 'def form&#41; &#40;= 'opts &#40;first params&#41;&#41;&#41;
          &#40;eval expr&#41;&#41;&#41;
      ;; =&gt; #'user/opts
    
      opts
      ;; =&gt; {:blog-description &quot;A blog about stuff but also things.&quot;,
      ;;     :blog-author &quot;Josh Glover&quot;,
      ;;     :num-index-posts 3,
      ;;     :favicon-dir &quot;favicon&quot;,
      ;;     :posts-dir &quot;blog/posts&quot;,
      ;;     :assets-dir &quot;blog/assets&quot;,
      ;;     :templates-dir &quot;blog/templates&quot;,
      ;;     :favicon true,
      ;;     :out-dir &quot;public/blog&quot;,
      ;;     :blog-root &quot;https://jmglov.net/blog/&quot;,
      ;;     :link-posts true,
      ;;     :cache-dir &quot;.cache&quot;,
      ;;     :about-link &quot;https://jmglov.net/&quot;,
      ;;     :blog-title &quot;jmglov's blog&quot;}
    
    &#41;
    </code></pre></li><li>Revel in the power of Lisp: verily code is data and data is code!</li><li>Realise that you are a silly silly person and that there's a much less    ridiculous way to do this: move the opts to a file named <code>opts.edn</code>...<pre class="language-clojure"><code class="lang-clojure language-clojure">        {:blog-title &quot;jmglov's blog&quot;
     :blog-author &quot;Josh Glover&quot;
     :blog-description &quot;A blog about stuff but also things.&quot;
     :blog-root &quot;https://jmglov.net/blog/&quot;
     :about-link &quot;https://jmglov.net/&quot;
     :assets-dir &quot;blog/assets&quot;
     :num-index-posts 3
     :cache-dir &quot;.cache&quot;
     :favicon true
     :favicon-dir &quot;favicon&quot;
     :out-dir &quot;public/blog&quot;
     :posts-dir &quot;blog/posts&quot;
     :templates-dir &quot;blog/templates&quot;
     :link-posts true}
    </code></pre></li><li>...and read them in <code>bb.edn</code>:<pre class="language-clojure"><code class="lang-clojure language-clojure">    { ; ...
     :tasks
     {:requires &#40;&#91;babashka.cli&#93;
                 &#91;babashka.fs :as fs&#93;
                 &#91;clojure.edn :as edn&#93;
                 &#91;clojure.string :as str&#93;
                 &#91;quickblog.api :as qb&#93;
                 &#91;quickblog.cli :as cli&#93;&#41;
      :init &#40;def opts &#40;slurp &quot;opts.edn&quot;&#41;&#41;
      ;; ...
     }}
    </code></pre></li><li>...and also in <code>user.clj</code>:<pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;ns user
      &#40;:require &#91;clojure.edn :as edn&#93;&#41;&#41;
      
    &#40;comment
    
      &#40;def opts &#40;-&gt; &#40;slurp &quot;opts.edn&quot;&#41; edn/read-string&#41;&#41;
      ;; =&gt; #'user/opts
    
      opts
      ;; =&gt; {:blog-description &quot;A blog about stuff but also things.&quot;,
      ;;     :blog-author &quot;Josh Glover&quot;,
      ;;     :num-index-posts 3,
      ;;     :favicon-dir &quot;favicon&quot;,
      ;;     :posts-dir &quot;blog/posts&quot;,
      ;;     :assets-dir &quot;blog/assets&quot;,
      ;;     :templates-dir &quot;blog/templates&quot;,
      ;;     :favicon true,
      ;;     :out-dir &quot;public/blog&quot;,
      ;;     :blog-root &quot;https://jmglov.net/blog/&quot;,
      ;;     :link-posts true,
      ;;     :cache-dir &quot;.cache&quot;,
      ;;     :about-link &quot;https://jmglov.net/&quot;,
      ;;     :blog-title &quot;jmglov's blog&quot;}
    
     &#41;
    </code></pre></li><li>Now try and remember how to load posts in quickblog. Maybe <a href='https://github.com/borkdude/quickblog/blob/ebf91f5859d36aeee1a52af14538f379eb76c64a/API.md#render'>the API
    documentation</a>    has a clue?<pre class="language-text"><code class="lang-text language-text">    &#40;render opts&#41;
    
    Renders posts declared in posts.edn to out-dir.
    </code></pre></li><li>Conclude that whilst rendering the blog shouldn't be necessary to count the    posts, the <a href='https://github.com/borkdude/quickblog/blob/ebf91f5859d36aeee1a52af14538f379eb76c64a/src/quickblog/api.clj#L467'>source code for
    <code>render</code></a>    must contain incantations of great power that load posts before rendering    them:<pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;defn render
      &quot;Renders posts declared in `posts.edn` to `out-dir`.&quot;
      &#91;opts&#93;
      &#40;let &#91;{:keys &#91;assets-dir
                    assets-out-dir
                    cache-dir
                    favicon-dir
                    favicon-out-dir
                    out-dir
                    posts-file
                    templates-dir&#93;
             :as opts}
            &#40;-&gt; opts apply-default-opts lib/refresh-cache&#41;&#93;
      ;; ...
      &#41;&#41;
    </code></pre></li><li>Armed with this arcane knowledge, return to <code>user.clj</code>:<pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;ns user
      &#40;:require ; ...
                &#91;quickblog.api :as qb&#93;
                &#91;quickblog.internal :as lib&#93;&#41;&#41;
    
    &#40;defn load-opts &#91;base-opts&#93;
      &#40;-&gt; base-opts
          #'qb/apply-default-opts
          lib/refresh-cache&#41;&#41;
    
    &#40;comment
    
      &#40;def base-opts &#40;-&gt; &#40;slurp &quot;opts.edn&quot;&#41; edn/read-string&#41;&#41;
      ;; =&gt; #'user/base-opts
    
      &#40;def opts &#40;load-opts base-opts&#41;&#41;
      ;; =&gt; #'user/opts
    
      &#40;keys opts&#41;
      ;; =&gt; &#40;:blog-description
      ;;     :blog-author
      ;;     :num-index-posts
      ;;     :favicon-dir
      ;;     :posts-dir
      ;;     :assets-dir
      ;;     :modified-posts
      ;;     :cached-posts
      ;;     :templates-dir
      ;;     :deleted-posts
      ;;     :favicon
      ;;     :modified-metadata
      ;;     :out-dir
      ;;     :blog-root
      ;;     :modified-tags
      ;;     :link-posts
      ;;     :cache-dir
      ;;     :about-link
      ;;     :blog-title
      ;;     :posts&#41;
    
     &#41;
    </code></pre></li><li>Count all the posts!<pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;comment
    
      &#40;-&gt;&gt; opts
           :posts
           count&#41;
      ;; =&gt; 71
    
     &#41;
    </code></pre></li><li>Break out the champagne! 🍾</li><li>Remember that the goal wasn't to count all of the posts, but rather the ones    from the summer of 2022 before I started my old new job 😢</li><li>See what a post looks like so you can figure out this whole date thing:<pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;comment
    
      ;; =&gt; &#40;&#91;&quot;2022-08-26-doing-software-wrong.md&quot;
      ;;      {:description
      ;;       &quot;In which I make a bold statement, but then rather than explaining it or providing any evidence whatsoever, go on to talk about something completely different.&quot;,
      ;;       :tags #{&quot;waffle&quot;},
      ;;       :date &quot;2022-08-26&quot;,
      ;;       :file &quot;2022-08-26-doing-software-wrong.md&quot;,
      ;;       :title &quot;We're doing software wrong&quot;,
      ;;       :image-alt
      ;;       &quot;A man on a mobile phone stands in front of a wall with the word \&quot;productivity\&quot; written on it - Photo by Andreas Klassen on Unsplash&quot;,
      ;;       :image &quot;assets/2022-08-26-preview.jpg&quot;,
      ;;       :html #&lt;Delay@6b4fc6d6: :not-delivered&gt;}&#93;&#41;
    
     &#41;
    </code></pre></li><li>Realise that <code>:posts</code> is a map of filename to post, but no matter!<pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; opts
       :posts
       vals
       &#40;map :date&#41;&#41;
  ;; =&gt; &#40;&quot;2022-08-26&quot;
  ;;     &quot;2022-06-22&quot;
  ;;     &quot;2023-11-12&quot;
  ;;     &quot;2022-07-01&quot;
  ;;     &quot;2022-07-31&quot;
  ;;     &quot;2022-07-09&quot;
  ;;     &quot;2022-06-21&quot;
  ;;     ...
  ;;     &quot;2022-07-02&quot;&#41;

 &#41;
</code></pre></li><li>Sigh as you come to terms with the fact that you're going to need to do some    date parsing and you never remember how to use    <a href='https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/time/package-summary.html'>java.time</a>    and argh!</li><li>Smile as you remember about <a href='https://github.com/juxt/tick'>tick</a>, which is a    library from our good friends at <a href='https://www.juxt.pro/'>JUXT</a> that provides    a nicer API for this sort of stuff</li><li>Since you hate restarting your REPL, use the power of Babashka to hotload    the dependency right in <code>user.clj</code>:<pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns user
  &#40;:require &#91;babashka.deps :as deps&#93;
            ;; ...
            &#41;&#41;

&#40;comment

  &#40;deps/add-deps '{:deps {tick/tick {:mvn/version &quot;0.7.5&quot;}}}&#41;
  ;; =&gt; nil

  &#40;require '&#91;tick.core :as t&#93;&#41;
  ;; =&gt; clojure.lang.ExceptionInfo: Could not resolve symbol: java.time.temporal.TemporalQuery user cljc/java&#95;time/format/date&#95;time&#95;formatter.clj:33:444

 &#41;
</code></pre></li><li>Swear as you realise that tick must not work with Babashka 🤬</li><li>Scratch your head as you read that tick is "a Clojure(Script) & babashka    library for dealing with time" and explicitly mentions Babashka so 😕</li><li>Search #babashka on Clojurians Slack for tick and find <a href='https://clojurians.slack.com/archives/CLX41ASCS/p1677842827651279'>borkdude
    mentioning</a>    <a href='https://clojurians.slack.com/archives/C06MAR553/p1677842754963519'>a message in
    #announcements</a>    that claims that "the new version works with babashka". Note the date of    this message as 2023-04-03.</li><li>Suspect that you may be running an older version of Babashka that doesn't    ship with <code>java.time.temporal.TemporalQuery</code> and have a look at    <a href='https://github.com/babashka/babashka/blob/771f69d922b9c674bdfa1ae02f53691c54e28170/src/babashka/impl/classes.clj#L453'>src/babashka/impl/classes.clj</a>    in the Babashka codebase to see when it was added</li><li>Track down <a href='https://github.com/babashka/babashka/commit/ead237eee335fc3bc4cdfc88e3fb4af821878b3c'>commit
    ead237e</a>    and note that it made it into Babashka    <a href='https://github.com/babashka/babashka/releases/tag/v1.2.174'>v1.2.174</a></li><li>Figure out what version of Babashka you're running:<pre class="language-text"><code class="lang-text language-text">    $ bb --version
    babashka v1.1.173
    </code></pre></li><li>Realise that you've found the issue and need to upgrade Babashka</li><li>Since you're using <a href='https://github.com/nix-community/home-manager'>Home
    Manager</a> on    <a href='https://nixos.org/'>NixOS</a> like a normal person who is totally normal, open    up your    <a href='https://github.com/jmglov/nixos-config/blob/79752ae530ab0036b569cc0bc848cdda29a43af8/jmglov/home.nix'>home.nix</a></li><li>Note that apparently you're installing the binary version of Babashka in    your own package that you wrote like a normal person who is totally normal    and open up    <a href='https://github.com/jmglov/nixos-config/blob/79752ae530ab0036b569cc0bc848cdda29a43af8/jmglov/pkgs/babashka-bin/default.nix'>pkgs/babashka-bin/default.nix</a>:<pre class="language-nix"><code class="lang-nix language-nix">    { stdenv, ... }:
    
    let
      arch = if stdenv.isAarch64 then &quot;aarch64&quot; else &quot;amd64&quot;;
      osName = if stdenv.isDarwin then
        &quot;macos&quot;
      else if stdenv.isLinux then
        &quot;linux&quot;
      else
        null;
      sha256 = assert !isNull osName;
        {
          linux = {
            aarch64 =
              &quot;bc7e733863486b334b8bff83ba13b416800e0ce45050153cb413906b46090d68&quot;;
            amd64 =
              &quot;25975d5424e7dea9fbaef5a6551ce7d3834631b5e28bdc4caf037bf45af57dfd&quot;;
          };
          macos = {
            # No MacOS builds for ARM at the moment
            # aarch64 =
            #   &quot;11c4b4bd0b534db1ecd732b03bc376f8b21bbda0d88cacb4bbe15b8469029123&quot;;
            amd64 =
              &quot;792ade86e61703170f3de3082183173db66a9a98b11d01c95ace0235f0a5e345&quot;;
          };
        }.${osName}.${arch};
    in stdenv.mkDerivation rec {
      pname = &quot;babashka&quot;;
      version = &quot;1.1.173&quot;;
      filename = if osName == &quot;macos&quot; then
      # No static builds for MacOS
        &quot;babashka-${version}-${osName}-${arch}.tar.gz&quot;
      else
        &quot;babashka-${version}-${osName}-${arch}-static.tar.gz&quot;;
    
      src = builtins.fetchurl {
        inherit sha256;
        url =
          &quot;https://github.com/babashka/babashka/releases/download/v${version}/${filename}&quot;;
      };
    
      dontFixup = true;
      dontUnpack = true;
    
      installPhase = ''
        mkdir -p $out/bin
        cd $out/bin &amp;&amp; tar xvzf $src
      '';
    }
    </code></pre></li><li>Avoid the urge to celebrate your own genius and instead pop over to the    <a href='https://github.com/babashka/babashka/releases'>Babashka releases page</a> on    Github and find that the latest release is    <a href='https://github.com/babashka/babashka/releases/tag/v1.3.188'>v1.3.188</a></li><li>Grab the SHA256 hashes you're going to need to plug into your Nix package:<pre class="language-text"><code class="lang-text language-text">    $ curl -L https://github.com/babashka/babashka/releases/download/v1.3.188/babashka-1.3.188-linux-aarch64-static.tar.gz.sha256
417280537b20754b675b7552d560c4c2817a93fbcaa0d51e426a1bff385e3e47
    $ curl -L https://github.com/babashka/babashka/releases/download/v1.3.188/babashka-1.3.188-linux-amd64-static.tar.gz.sha256
    89431b0659e84a468da05ad78daf2982cbc8ea9e17f315fa2e51fecc78af7cc0
    $ curl -L https://github.com/babashka/babashka/releases/download/v1.3.188/babashka-1.3.188-macos-aarch64.tar.gz.sha256
    77eb9ec502260fa94008e1e43edc5678fab8dc1a5082b7eb3d28ae594ea54e09
    $ curl -L https://github.com/babashka/babashka/releases/download/v1.3.188/babashka-1.3.188-macos-amd64.tar.gz.sha256
    d8854833a052bb578360294d6975b85ed917b9f86da0068fb3c263f8cbcc9e15
    </code></pre></li><li>Update the SHAs and Babashka version in your Nix package:<pre class="language-nix"><code class="lang-nix language-nix">    let
      # ...
      sha256 = {
        linux = {
          aarch64 =
            &quot;417280537b20754b675b7552d560c4c2817a93fbcaa0d51e426a1bff385e3e47&quot;;
          amd64 =
            &quot;89431b0659e84a468da05ad78daf2982cbc8ea9e17f315fa2e51fecc78af7cc0&quot;;
        };
        macos = {
          aarch64 =
            &quot;77eb9ec502260fa94008e1e43edc5678fab8dc1a5082b7eb3d28ae594ea54e09&quot;;
          amd64 =
            &quot;d8854833a052bb578360294d6975b85ed917b9f86da0068fb3c263f8cbcc9e15&quot;;
        };
      }.${osName}.${arch};
    in stdenv.mkDerivation rec {
      pname = &quot;babashka&quot;;
      version = &quot;1.3.188&quot;;
      # ...
    }
    </code></pre></li><li>Update Babashka:<pre class="language-text"><code class="lang-text language-text">    $ sudo nixos-rebuild switch
    building Nix...
    building the system configuration...
    these 8 derivations will be built:
      /nix/store/x9c0ip7xchwzhkhznvjz5r57krcqjm3r-babashka-1.3.188.drv
      /nix/store/lsq07jvqmk5kywbdrj55vh3ndjrw2vwm-home-manager-path.drv
    &#91;...&#93;
    building '/nix/store/x9c0ip7xchwzhkhznvjz5r57krcqjm3r-babashka-1.3.188.drv'...
    patching sources
    updateAutotoolsGnuConfigScriptsPhase
    configuring
    no configure script, doing nothing
    building
    no Makefile or custom buildPhase, doing nothing
    installing
    bb
    &#91;...&#93;
    activating the configuration...
    setting up /etc...
    reloading user units for jmglov...
    setting up tmpfiles
    restarting the following units: home-manager-jmglov.service
    </code></pre></li><li>Trust but verify:<pre class="language-text"><code class="lang-text language-text">    $ bb --version
    babashka v1.3.188
    </code></pre></li><li>Hang your head in shame as you prepare to restart your REPL, but take the    opportunity to add tick to your <code>deps.edn</code> so you won't have to hotload it    in <code>user.clj</code>:<pre class="language-clojure"><code class="lang-clojure language-clojure">    {:paths &#91;&quot;.&quot; &quot;classes&quot;&#93;
     :deps { ; ...
            tick/tick {:mvn/version &quot;0.7.5&quot;}}}
    </code></pre></li><li>Drop back into <code>user.clj</code>, then <strong>C-c C-z</strong> to hop to your REPL buffer,    <strong>C-c C-q</strong> to quit it, then <strong>C-c M-j</strong> to start a new REPL, then require    tick in the ns form:<pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;ns user
      &#40;:require ; ...
                &#91;tick.core :as t&#93;&#41;&#41;
    </code></pre></li><li>Evaluate the buffer with <strong>C-c C-k</strong> and get to ticking!</li><li>Figure out how to parse a date string like "2022-07-02" by looking at the    <a href='https://github.com/juxt/tick/blob/master/docs/cheatsheet.md#fromto-strings'>tick
    cheatsheet</a>,    a sheet that lets you cheat, apparently:<pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;comment
    
      &#40;t/date &quot;2022-07-02&quot;&#41;
      ;; =&gt; #time/date &quot;2022-07-02&quot;
    
     &#41;
    </code></pre></li><li>Turn the date strings into date dates:<pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;comment
    
      &#40;def opts &#40;-&gt; &#40;slurp &quot;opts.edn&quot;&#41; edn/read-string load-opts&#41;&#41;
      ;; =&gt; #'user/opts
    
      &#40;-&gt;&gt; opts
           :posts
           vals
           &#40;map &#40;comp t/date :date&#41;&#41;&#41;
      ;; =&gt; &#40;#time/date &quot;2022-08-26&quot;
      ;;     #time/date &quot;2022-06-22&quot;
      ;;     #time/date &quot;2023-11-12&quot;
      ;;     #time/date &quot;2022-07-01&quot;
      ;;     ...
      ;;     #time/date &quot;2022-07-02&quot;&#41;
    
     &#41;
    </code></pre></li><li>Ask yourself what you were doing again?</li><li>Oh yeah, counting posts before September 1, 2022, which was when I started    my old new job</li><li>Say this in Clojure, not English!<pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;comment
    
      &#40;-&gt;&gt; opts
           :posts
           vals
           &#40;remove #&#40;= &quot;FIXME&quot; &#40;:date %&#41;&#41;&#41;
           &#40;map &#40;comp t/date :date&#41;&#41;
           &#40;filter #&#40;t/&lt; % &#40;t/date &quot;2022-09-01&quot;&#41;&#41;&#41;
           count&#41;
      ;; =&gt; 55
    
     &#41;
    </code></pre></li><li>Sit back and reflect on just how easy that was and how it took less time    than just counting those 55 things with your finger</li></ol>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-02-08-back-in-the-hammock-again.html</id>
    <link href="https://jmglov.net/blog/2024-02-08-back-in-the-hammock-again.html"/>
    <title>Back in the hammock again</title>
    <updated>2024-02-08T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2024-02-08-back-in-the-hammock-again-preview.png" alt="A person sits in a hammock overlooking a mountain lake" title="Photo by Zach Betten on Unsplash" width=800px /></p><p>Well well well, here I am again, staring down the barrel of a month of free time thanks to the largesse of venture capitalists providing funding to enable my employer to offer me an involuntary paid vacation. 😅</p><p>So what to do with my time?</p><p>Since you're reading this, you may have guessed that part of what I'm planning to do with my time is get back to writing. Apparently full time employment interferes with my writing in a serious way. According to the <a href='archive.html'>old
archive</a>, I only managed three posts in the whole of 2023, compared to 60 in 2022. 55 of those 60 came in the 78 days between 15 June, when I started the blog, and 1 September, when I started my old new job.</p><p>I restarted my career as a professional writer on 8 January, 2024 at 10:30 in the AM, and since then, I've only managed two posts. 😞</p><p>In my defence though, see exhibits A and B:</p><p>Exhibit A: The two posts I did produce were a couple of real doozies; <a href='2024-01-17-clickr.html'>clickr,
or a young man's Flickr clonejure</a> weighed in at 7,559 words, and <a href='2024-01-22-clickr-goes-fe.html'>clickr goes frontend</a> even topped that at an eye-watering 10,945 words! 🤯</p><p>Exhibit B: I spent the last three weeks feverishly interviewing for a new job, since a) "writer" is a profession that isn't well known for being easy to make a living by, and b) I'm not what they call "actually good at writing".</p><p>In any case, I signed an offer today with a start date of 4 March, so I have just under a month to sharpen my quill and pump out some new #content!</p><p>Having thus established some context, I'll share with you the experience of being laid off twice in 18 months: it sucks. Verily does it suck.</p><p>However, the degree to which each instance of being laid off sucked varied greatly. In the first case, big VCs leaned on management to "cut the fat" and management then blamed Putin for destroying the world economy and tried to strongarm people out the door, despite the robust labour protections offered to the majority of those employees under the <a href='https://www.unionen.se/in-english/how-swedish-labour-market-works'>Swedish
model</a>. As a member of the board of the local union, I spent the first 6-8 weeks of my garden leave helping union members understand their rights, evaluate their options, and negotiate with HR. This was important work that I'm proud of, and I would do it again in a heartbeat, but it's not exactly conducive to process being told you're surplus to requirements by an employer you've been with for 6 years and consistently received positive performance reviews from.</p><p>Contrast this to my experience being laid off a year and a half later by <a href='https://pitch.com/about'>Pitch</a>, where leadership took a sober look at market conditions and the trajectory of the company and concluded there was no path to success without a radical restructuring of the company. The founder and CEO <a href='https://twitter.com/christianreber/status/1744292271858622518'>explained it like
this</a>:</p><blockquote><p> As many of you know, being a venture-backed company in 2023 was <del>insanely</del>  challenging. We created sky-high expectations for our business, our employees,  and ourselves as founders. Towards the end of last year, my co-founders and I  noticed that those expectations were simply too high, and we decided that we  wanted to take a new and completely different path for Pitch. Instead of  pushing harder and harder to turn Pitch into a hyper-growth company with  venture funding, we concluded it was better to build a profitable company and  grow Pitch organically from here. We had conversations with our investors  about resetting our company and cap table, so there’s potential for meaningful  impact for everyone involved. Despite having more than 4 years of runway, we  know that a sustainable path has a much higher chance of success than the path  we were on. </p><p> This change also means we’re reducing the size of the Pitch team by around 2/3  today. Going forward, we’ll be a significantly smaller team focused on  creating maximum value for our customers and driving sustainable growth. I am  very sorry that this happened, and  <strong>I take full responsibility for leading Pitch up to this point.</strong> </p></blockquote><p>Contrast that final sentence with "Putin broke the economy and I'm deeply saddened". 🙄</p><p>Pitch leadership talks a big game about their people first approach, which is not really unique in these days of "woke capitalism" where many companies feel the need to prostrate themselves at the alter of Diversity, Equity, & Inclusion (just to lower the chances of this being taken out of context, OF COURSE I'M BEING SARCASTIC, CAPITALISM IS FAR FROM WOKE!), but they are in the truly rarefied company of those who stick to their values when times get tough.</p><p>Pitch's new CEO laid things out transparently, telling employees what the situation was, how the decision was made, and the reasons for making it. Every employee who was laid off received a generous severance package, regardless of if they'd been with the company for 5 years or 5 days. We retained access to Slack and Zoom and so on for the rest of the day so that we could say our goodbyes. In short, we were treated like adults rather than potential enemies. 💜</p><p>So yeah, here I am, back in the hammock again. May the good times roll!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-01-22-clickr-goes-fe.html</id>
    <link href="https://jmglov.net/blog/2024-01-22-clickr-goes-fe.html"/>
    <title>clickr goes frontend</title>
    <updated>2024-01-22T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2024-01-22-drake.png" alt="A cartoon version of the Drake hotline bling meme: Drake is disgusted by the
S3 console and delighted by the Flickr UI" title="How many Clojurians does it take to clone Flickr?" width=800px /></p><p>Previously on this blog, <a href='2024-01-17-clickr.html'>my heart was filled with sadness as I realised that
archiving photos to S3 wasn't actually a Flickr
replacement</a>. Currently on this blog, my heart is filled with a steely resolve as I take my destiny into my own hands and... I guess write some CSS or something?</p><p>As cartoon Drake so helpfully points out above, the S3 console is missing a few of the features that you would expect from an online photo album:</p><ul><li>A highlighted image for the album</li><li>The album's name and description</li><li>Visual display of the photos in the album</li></ul><p>Clearly something must be done about this! Luckily for me, an S3 bucket can actually be used to serve up a website—in fact, this very blog is coming to you live from S3—so if I can just write some HTML alongside the photos, Robert should be one of my parents' siblings. If you cast your mind back to last week, you may recall that in the process of backing up albums from Flickr, we wrote some code that had a pretty nice data representation of an album:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;get-albums ctx&#41;
       first
       &#40;download-album! ctx&#41;&#41;
  ;; =&gt; {:id &quot;72177720314024335&quot;,
  ;;     :title &quot;clickr demo&quot;,
  ;;     :description &quot;Photo album demo for my clickr blog post&quot;,
  ;;     :photos
  ;;     &#40;{:description nil,
  ;;       :date-taken nil,
  ;;       :geo-data nil,
  ;;       :rotation -1,
  ;;       :width 0,
  ;;       :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;,
  ;;       :filename &quot;53460147147.jpg&quot;,
  ;;       :id &quot;53460147147&quot;,
  ;;       :object
  ;;       #object&#91;com.flickr4java.flickr.photos.Photo 0x4a25d150 &quot;com.flickr4java.flickr.photos.Photo@14ea992b&quot;&#93;,
  ;;       :height 0}
  ;;      ;; &#91;...&#93;
  ;;      {:description nil,
  ;;       :date-taken nil,
  ;;       :geo-data nil,
  ;;       :rotation -1,
  ;;       :width 0,
  ;;       :title &quot;daniel-jennings-img-7554&quot;,
  ;;       :filename &quot;53460151727.jpg&quot;,
  ;;       :id &quot;53460151727&quot;,
  ;;       :object
  ;;       #object&#91;com.flickr4java.flickr.photos.Photo 0x3fb5100f &quot;com.flickr4java.flickr.photos.Photo@436e36e8&quot;&#93;,
  ;;       :height 0}&#41;,
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photosets.Photoset 0x2be46a3b &quot;com.flickr4java.flickr.photosets.Photoset@2be46a3b&quot;&#93;}

  &#41;
</code></pre><p>This is a fantastic starting point, because if Clojure is good at anything, it's transforming data from one shape to another, and HTML is just data. Let's take a closer look at what this album looks like in Flickr, and see if we can't identify a basic layout to <del>steal</del> borrow with pride:</p><p><img src="assets/2024-01-17-album-flickr.png" alt="The Flickr site, displaying the photos in the clickr demo album" title="OMG 🤩" width=800px] /></p><p>If we were to lay this out in HTML, it could look something like this:</p><pre class="language-html"><code class="lang-html language-html">&lt;body id=&quot;body&quot;&gt;
    &lt;div id=&quot;album&quot;&gt;
        &lt;div id=&quot;back&quot;&gt;⬅ Back to albums list&lt;/div&gt;
        &lt;div id=&quot;album-header&quot;&gt;
            &lt;div id=&quot;album-title&quot;&gt;clickr demo&lt;/div&gt;
            &lt;div id=&quot;album-description&quot;&gt;Photo album demo for my clickr blog post&lt;/div&gt;
        &lt;/div&gt;
        &lt;div id=&quot;photos&quot;&gt;
            &lt;img id=&quot;photo-53460147147&quot; src=&quot;53460147147.jpg&quot; /&gt;
            &lt;!-- ... --&gt;
        &lt;/div&gt;
    &lt;/div&gt;
&lt;/body&gt;
</code></pre><p>You only have to squint at the Clojure data structure a little bit to see how it could be massaged into this shape. So let's get massaging!</p><h2 id="damn_the_hiccups%2C_full_speed_ahead%21">Damn the hiccups, full speed ahead!</h2><p>One of the classic ways to turn Clojure data into HTML is <a href='https://github.com/weavejester/hiccup'>Hiccup</a>, which I really like and have used quite a bit. However, in the production of this blog, I got introduced to <a href='https://github.com/yogthos/Selmer'>Selmer</a>, which is the template system that <a href='https://github.com/borkdude/quickblog'>quickblog</a> uses to render HTML, and is so cool that it is one of the batteries included in <a href='https://babashka.org/'>Babashka</a>. All of this is to say: let's use Selmer here! I can actually just take the HTML fragment above and Selmerise it with only a few keystrokes!</p><pre class="language-html"><code class="lang-html language-html">&lt;body id=&quot;body&quot;&gt;
    &lt;div id=&quot;album&quot;&gt;
        &lt;div id=&quot;back&quot;&gt;⬅ Back to albums list&lt;/div&gt;
        &lt;div id=&quot;album-header&quot;&gt;
            &lt;div id=&quot;album-title&quot;&gt;{{album.title}}&lt;/div&gt;
            &lt;div id=&quot;album-description&quot;&gt;{{album.description}}&lt;/div&gt;
        &lt;/div&gt;
        &lt;div id=&quot;photos&quot;&gt;
            {% for photo in album.photos %}
            &lt;img id=&quot;photo-{{photo.id}}&quot; src=&quot;{{photo.filename}}&quot; /&gt;
            {% endfor %}
        &lt;/div&gt;
    &lt;/div&gt;
&lt;/body&gt;
</code></pre><p>OK, that was easy. But of course a <code>&lt;body&gt;</code> does not an HTML page make, so let's create a <code>resources/templates/album.html</code> file and throw in the rest of the stuff that we need:</p><pre class="language-html"><code class="lang-html language-html">&lt;!doctype html&gt;
&lt;html class=&quot;no-js&quot; lang=&quot;en&quot;&gt;

&lt;head&gt;
    &lt;title&gt;{{album.title}}&lt;/title&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;meta http-equiv=&quot;x-ua-compatible&quot; content=&quot;ie=edge&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
&lt;/head&gt;

&lt;body id=&quot;body&quot;&gt;
    &lt;!--&#91;if lt IE 8&#93;&gt;
          &lt;p class=&quot;browserupgrade&quot;&gt;
          You are using an &lt;strong&gt;outdated&lt;/strong&gt; browser. Please
          &lt;a href=&quot;http://browsehappy.com/&quot;&gt;upgrade your browser&lt;/a&gt; to improve
          your experience.
          &lt;/p&gt;
        &lt;!&#91;endif&#93;--&gt;
    &lt;div id=&quot;album&quot;&gt;
        &lt;div id=&quot;back&quot;&gt;⬅ Back to albums list&lt;/div&gt;
        &lt;div id=&quot;album-header&quot;&gt;
            &lt;div id=&quot;album-title&quot;&gt;{{album.title}}&lt;/div&gt;
            &lt;div id=&quot;album-description&quot;&gt;{{album.description}}&lt;/div&gt;
        &lt;/div&gt;
        &lt;div id=&quot;photos&quot;&gt;
            {% for photo in album.photos %}
            &lt;img id=&quot;photo-{{photo.id}}&quot; src=&quot;{{photo.filename}}&quot; /&gt;
            {% endfor %}
        &lt;/div&gt;
    &lt;/div&gt;
&lt;/body&gt;

&lt;/html&gt;
</code></pre><p>This is actually all we need to make an awesome photo album. Let's write the Clojure code that will write the HTML that will make the browser happy!</p><p>First, we need to add Selmer to our <code>deps.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;src&quot; &quot;dev&quot;&#93;
 :deps {babashka/fs {:mvn/version &quot;0.4.19&quot;}
        com.cognitect.aws/api {:mvn/version &quot;0.8.686&quot;}
        com.cognitect.aws/endpoints {:mvn/version &quot;1.1.12.504&quot;}
        com.cognitect.aws/s3 {:mvn/version &quot;848.2.1413.0&quot;}
        com.flickr4java/flickr4java {:mvn/version &quot;3.0.1&quot;}
        selmer/selmer {:mvn/version &quot;1.12.59&quot;}}}
</code></pre><p>Then let's create a namespace that will be responsible for producing HTML:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns clickr.html
  &#40;:require &#91;selmer.parser :as selmer&#93;&#41;&#41;
</code></pre><p>Then we can write a function that turns an album into the HTML representation of said album:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn album-&gt;html &#91;&#95;ctx album&#93;
  &#40;selmer/render &#40;slurp &quot;resources/templates/album.html&quot;&#41;
                 {:album album}&#41;&#41;
</code></pre><p>Yes, it really is that simple! But don't take my word for it; ask the REPL!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;require '&#91;clickr.flickr :as flickr&#93;&#41;
  ;; =&gt; nil

  &#40;def config {:api-key &quot;beefface5678910&quot;
               :secret &quot;facecafe1234&quot;
               :s3-bucket &quot;photos.jmglov.net&quot;
               :s3-prefix &quot;clickr&quot;
               :out-dir &quot;/home/jmglov/Pictures/clickr&quot;}&#41;
  ;; =&gt; #'clickr.html/config

  &#40;def ctx &#40;flickr/init-client config&#41;&#41;
  ;; =&gt; #'clickr.html/ctx

  &#40;def album &#40;-&gt;&gt; &#40;flickr/get-albums ctx&#41; first &#40;flickr/download-album ctx&#41;&#41;&#41;
  ;; =&gt; #'clickr.html/album

  &#40;album-&gt;html ctx album&#41;
  ;; =&gt; &quot;&lt;!doctype html&gt;\n&lt;html class=\&quot;no-js\&quot; lang=\&quot;en\&quot;&gt;\n\n&lt;head&gt;\n    &lt;title&gt;clickr demo&lt;/title&gt;\n ... \n&lt;/body&gt;\n\n&lt;/html&gt;\n&quot;

  &#41;
</code></pre><p>Of course, all of this HTML isn't very useful unless we write it somewhere a browser can find it. Let's follow the same pattern we used for <code>download-album!</code>: we'll take the data representation of an album, write an <code>index.html</code> in its output directory (assuming it has already been downloaded), and then assoc the location of the HTML file into the album.</p><p>Since we're building paths, let's require in our old friend <code>babashka.fs</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns clickr.html
  &#40;:require &#91;babashka.fs :as fs&#93;
            &#91;selmer.parser :as selmer&#93;&#41;&#41;
</code></pre><p>Now we have everything we need to write our function.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn write-album-html! &#91;ctx {:keys &#91;out-dir&#93; :as album}&#93;
  &#40;when-not out-dir
    &#40;throw &#40;ex-info &quot;Album must be downloaded before writing it to HTML&quot;
                    {:album album}&#41;&#41;&#41;
  &#40;let &#91;html &#40;album-&gt;html ctx album&#41;
        html-file &#40;fs/file out-dir &quot;index.html&quot;&#41;&#93;
    &#40;spit html-file html&#41;
    &#40;assoc album :html-file html-file&#41;&#41;&#41;

&#40;comment

  &#40;write-album-html! ctx album&#41;
  ;; =&gt; {:id &quot;72177720314024335&quot;,
  ;;     :title &quot;clickr demo&quot;,
  ;;     :description &quot;Photo album demo for my clickr blog post&quot;,
  ;;     :photos &#40;...&#41;
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photosets.Photoset 0x2e486856 &quot;com.flickr4java.flickr.photosets.Photoset@2e486856&quot;&#93;,
  ;;     :out-dir #object&#91;java.io.File 0x68210928 &quot;/tmp/72177720314024335&quot;&#93;,
  ;;     :html-file
  ;;     #object&#91;java.io.File 0x6c47b5ea &quot;/tmp/72177720314024335/index.html&quot;&#93;}

  &#41;
</code></pre><p>If we open up <code>/tmp/72177720314024335/index.html</code> in a web browser, we are sure to be greeted with a glorious sight!</p><p><img src="assets/2024-01-22-disappointment.png" alt="A webpage with three lines of text and no photos" title="This is not the album you're looking for" width=800px /></p><p>OK, so that was a crushing disappointment. 🙁</p><p>Remember how amazing this looks in Flickr? Let's use the browser's inspector to peek behind the curtain and see how Flickr does it:</p><p><img src="assets/2024-01-22-flickr-magic.png" alt="Web browser inspector with a photo div highlighted on Flickr" title="Pay full attention to the CSS behind the curtain" width=800px /></p><p>Ah, so they're not using <code>&lt;img&gt;</code> tags at all; they're using <code>&lt;div&gt;</code> tags with some magic <a href='https://developer.mozilla.org/en-US/docs/Web/CSS/background-image'>background-image</a> CSS property. So CSS is the key to this whole thing, eh? People, it looks like we're gonna need a stylesheet!</p><h2 id="getting_stylish">Getting stylish</h2><p>Doing some clever reverse engineering of Flickr, we whip up the following stylesheet:</p><pre class="language-css"><code class="lang-css language-css">body {
  font-family: Proxima Nova,helvetica neue,helvetica,arial,sans-serif;
}

#album {
  box-sizing: border-box;
  margin-left: auto;
  margin-right: auto;
}

#album-header {
  align-items: center;
  background-color: #000;
  background-position: 50%;
  background-size: cover;
  color: #fff;
  display: flex;
  flex-direction: column;
  height: 300px;
  justify-content: center;
  position: relative;
  text-shadow: 0 1px 1px #000;
}

#album-header &gt; div {
  color: #ffffff;
  font-weight: 300;
  overflow: hidden;
  text-align: center;
  text-overflow: ellipsis;
  text-shadow: 0 1px 1px #000000;
}

#album-title {
  font-size: 2em;
  white-space: nowrap;
}

#album-description {
  font-size: 24px;
  font-style: italic;
  line-height: 29px;
  margin-top: 13px;
  max-height: 29px;
  word-wrap: break-word;
}

#photos {
  position: relative;
}

.photo {
  background-position: 50%;
  background-repeat: no-repeat;
  background-size: cover;
  position: absolute;
}
</code></pre><p>Now we need to write this to the album directory next to <code>index.html</code>. Let's drop this CSS into <code>resources/templates/style.css</code> and write an <code>album-&gt;css</code> function. Whilst <code>style.css</code> doesn't actually contain any template variables, maybe it could one day, so let's just run it through Selmer, which is basically a no-op on files not containing template variables.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn album-&gt;css &#91;&#95;ctx album&#93;
  &#40;selmer/render &#40;slurp &quot;resources/templates/style.css&quot;&#41;
                 {:album album}&#41;&#41;
</code></pre><p>Hrm... this looks exactly the same as <code>album-&gt;html</code> except for the filename, so let's refactor a tiny bit:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn apply-album-template &#91;&#95;ctx template-file album&#93;
  &#40;selmer/render &#40;slurp template-file&#41;
                 {:album album}&#41;&#41;

&#40;defn album-&gt;html &#91;ctx album&#93;
  &#40;apply-album-template ctx &quot;resources/templates/album.html&quot; album&#41;&#41;

&#40;defn album-&gt;css &#91;ctx album&#93;
  &#40;apply-album-template ctx &quot;resources/templates/style.css&quot; album&#41;&#41;
</code></pre><p>Now we just need to plug this into <code>write-album-html!</code> to write <code>style.css</code> into the album directory:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn write-album-html! &#91;ctx {:keys &#91;out-dir&#93; :as album}&#93;
  &#40;when-not out-dir
    &#40;throw &#40;ex-info &quot;Album must be downloaded before writing it to HTML&quot;
                    {:album album}&#41;&#41;&#41;
  &#40;let &#91;html &#40;album-&gt;html ctx album&#41;
        html-file &#40;fs/file out-dir &quot;index.html&quot;&#41;
        css &#40;album-&gt;css ctx album&#41;
        css-file &#40;fs/file out-dir &quot;style.css&quot;&#41;&#93;
    &#40;spit html-file html&#41;
    &#40;spit css-file css&#41;
    &#40;assoc album :html-file html-file, :css-file css-file&#41;&#41;&#41;
</code></pre><p>Oh yeah, and include the stylesheet in the HTML template:</p><pre class="language-html"><code class="lang-html language-html">&lt;head&gt;
    &lt;title&gt;{{album.title}}&lt;/title&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;meta http-equiv=&quot;x-ua-compatible&quot; content=&quot;ie=edge&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&gt;
&lt;/head&gt;
</code></pre><p>Let's test this out in the REPL again:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;write-album-html! ctx album&#41;
  ;; =&gt; {:id &quot;72177720314024335&quot;,
  ;;     :title &quot;clickr demo&quot;,
  ;;     :description &quot;Photo album demo for my clickr blog post&quot;,
  ;;     :photos &#40;...&#41;
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photosets.Photoset 0x2e486856 &quot;com.flickr4java.flickr.photosets.Photoset@2e486856&quot;&#93;,
  ;;     :out-dir #object&#91;java.io.File 0x68210928 &quot;/tmp/72177720314024335&quot;&#93;,
  ;;     :html-file
  ;;     #object&#91;java.io.File 0x4e5079c0 &quot;/tmp/72177720314024335/index.html&quot;&#93;,
  ;;     :css-file #object&#91;java.io.File 0x334cab6a &quot;/tmp/72177720314024335/style.css&quot;&#93;}

  &#41;
</code></pre><p>Cool! If we now reload the browser on <code>/tmp/72177720314024335/index.html</code>, we see some stuff move around and fonts turn prettier and so on, so we've got our styles.</p><h2 id="div_and_conquer">div and conquer</h2><p>The next thing we need to do is replace our <code>&lt;img&gt;</code> tags with <code>&lt;div&gt;</code> ones. While we're at it, we might as well also use the first photo in the album as the background of the album header div, since Flickr does that and it looks pretty daggone cool!</p><pre class="language-html"><code class="lang-html language-html">    &lt;div id=&quot;album-header&quot; style=&quot;background-image: url&#40;'{{album.photos.0.filename}}'&#41;&quot;&gt;
        &lt;div id=&quot;back&quot;&gt;⬅ Back to albums list&lt;/div&gt;
        &lt;div id=&quot;album-header&quot;&gt;
            &lt;div id=&quot;album-title&quot;&gt;{{album.title}}&lt;/div&gt;
            &lt;div id=&quot;album-description&quot;&gt;{{album.description}}&lt;/div&gt;
        &lt;/div&gt;
        &lt;div id=&quot;photos&quot;&gt;
            {% for photo in album.photos %}
            &lt;div id=&quot;photo-{{photo.id}}&quot; class=&quot;photo&quot; style=&quot;background-image: url&#40;'{{photo.filename}}'&#41;;&quot;&gt;
            &lt;/div&gt;
            {% endfor %}
        &lt;/div&gt;
    &lt;/div&gt;
</code></pre><p>If we re-render our album, however</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;write-album-html! ctx album&#41;
  ;; =&gt; {:id &quot;72177720314024335&quot;,
  ;;     :title &quot;clickr demo&quot;,
  ;;     :description &quot;Photo album demo for my clickr blog post&quot;,
  ;;     :photos &#40;...&#41;
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photosets.Photoset 0x2e486856 &quot;com.flickr4java.flickr.photosets.Photoset@2e486856&quot;&#93;,
  ;;     :out-dir #object&#91;java.io.File 0x68210928 &quot;/tmp/72177720314024335&quot;&#93;,
  ;;     :html-file
  ;;     #object&#91;java.io.File 0x4e5079c0 &quot;/tmp/72177720314024335/index.html&quot;&#93;,
  ;;     :css-file #object&#91;java.io.File 0x334cab6a &quot;/tmp/72177720314024335/style.css&quot;&#93;}

  &#41;
</code></pre><p>Two things are surprising:</p><ol><li>Our first photo isn't displayed as the background of the album header div,   despite the <a href='https://github.com/yogthos/Selmer?tab=readme-ov-file#variables-and-tags'>Selmer
   docs</a>   claiming that we can index into nested data stuctures.</li><li>Our photos have gone from being way too big to being way too small. So small,   in fact, that even the world's most powerful scanning tunneling microscope   could not detect them.</li></ol><p>The first point is only surprising because I forgot how Clojure works. To see why, let's try out the example from the Selmer docs for ourselves:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;selmer/render &quot;{{foo.bar.0.baz}}&quot; {:foo {:bar &#91;{&quot;baz&quot; &quot;hi&quot;}&#93;}}&#41;
  ;; =&gt; &quot;hi&quot;

  &#41;
</code></pre><p>OK, so <a href='https://github.com/yogthos'>good ol' Yogthos</a> isn't a liar, which is good, because I've read a lot of his stuff and believed what he was saying. Having trusted and verified, let's do the same thing with our album:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;selmer/render &quot;{{album.photos.0.filename}}&quot; {:album album}&#41;
  ;; =&gt; &quot;&quot;

  &#40;-&gt; album :photos first :filename&#41;
  ;; =&gt; &quot;53460147147.jpg&quot;

  &#41;
</code></pre><p>What gives? He's got a vector of <code>:bar</code>s in his <code>:foo</code>, and we've got a list of <code>:photos</code> in our <code>:album</code>, so what's the difference here? 🤔</p><p>Oh wait... I said he has a vector and we have a list. Those words are different. And not only are they different, one of them is a flat out lie! 😬</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def yogthos-data {:foo {:bar &#91;{&quot;baz&quot; &quot;hi&quot;}&#93;}}&#41;
  ;; =&gt; #'clickr.html/yogthos-data

  &#40;type &#40;get-in yogthos-data &#91;:foo :bar&#93;&#41;&#41;
  ;; =&gt; clojure.lang.PersistentVector

  &#40;type &#40;:photos album&#41;&#41;
  ;; =&gt; clojure.lang.LazySeq

  &#40;get-in yogthos-data &#91;:foo :bar 0 &quot;baz&quot;&#93;&#41;
  ;; =&gt; &quot;hi&quot;

  &#40;get-in album &#91;:photos 0 :filename&#93;&#41;
  ;; =&gt; nil

  &#41;
</code></pre><p>So yeah, you can't index into a lazy sequence like you can a vector. Luckily, it's easy to turn a lazy sequence into a vector:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;get-in &#40;vec &#40;:photos album&#41;&#41; &#91;0 :filename&#93;&#41;
  ;; =&gt; &quot;53460147147.jpg&quot;

  &#41;
</code></pre><p>Since we've been superDRY and extracted a function to do the templating stuff, we can make a one line change to fix this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn apply-album-template &#91;&#95;ctx template-file album&#93;
  &#40;selmer/render &#40;slurp template-file&#41;
                 {:album &#40;update album :photos vec&#41;}&#41;&#41;

&#40;comment

  &#40;write-album-html! ctx album&#41;
  ;; =&gt; { ... }

  &#41;

</code></pre><p>If we reload the page in our browser, we see a cool space station photo in the album header, but the second problem remains: no photos!</p><p>If we inspect one of the divs, we see what's going on pretty quickly:</p><p><img src="assets/2024-01-22-oops.png" alt="The album webpage with no visible photos" title="Wherefore art thou, dear photos?" width=800px /></p><p>All of the photo divs are 0 pixels wide by 0 pixels high. 😬</p><p>If we look back at Flickr, we see that they set a <code>width</code> and <code>height</code> style on each photo element. We can try that just to see what happens:</p><pre class="language-html"><code class="lang-html language-html">        &lt;div id=&quot;photos&quot;&gt;
            {% for photo in album.photos %}
            &lt;div id=&quot;photo-{{photo.id}}&quot; class=&quot;photo&quot;
                style=&quot;background-image: url&#40;'{{photo.filename}}'&#41;; width: 300px; height: 180px;&quot;&gt;
            &lt;/div&gt;
            {% endfor %}
        &lt;/div&gt;
</code></pre><p>OK, now we can see some photo, by which I mean only one photo. Inspecting the page, we see that all of the divs are there and have the correct width and height, but they seem to be on top of each other. Which is less than ideal, bordering on decidedly suboptimal. 🙁</p><p><img src="assets/2024-01-22-single.png" alt="The album webpage with only one visible photo" title="Sometimes one is not enough" width=800px /></p><p>OK, so maybe that's what all of those <code>transform: translate</code> CSS incantations are about. Reading <a href='https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/translate'>the
documentation</a>, it seems that we can do cool stuff like</p><pre class="language-CSS"><code class="lang-CSS language-CSS">transform: translate&#40;10px, 20px&#41;;
</code></pre><p>to move an element 10 pixels to the right and 20 pixels down. So if we want to have a nice three column layout like Flickr's with 4 pixels between each image, we could lay things out something like this:</p><pre class="language-html"><code class="lang-html language-html">&lt;div style=&quot;transform: translate&#40;0px,   4px&#41;;   ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;304px, 4px&#41;;   ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;608px, 4px&#41;;   ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;0px,   184px&#41;; ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;304px, 184px&#41;; ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;608px, 184px&#41;; ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;0px,   368px&#41;; ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;304px, 368px&#41;; ...&quot;&gt;&lt;/div&gt;
</code></pre><p><img src="assets/2024-01-22-janked.png" alt="The album webpage with only one visible photo" title="Sometimes one is not enough" width=800px /></p><p>I mean, this is... better? Except all of the photos are cropped in an odd way, and don't make use of the full width of the screen, and don't resize nicely like Flickr's do and... well, kinda suck.</p><p>It looks like we're going to need some math and stuff to dig ourselves out of this hole. 😱</p><h2 id="tell_me_about_yourself">Tell me about yourself</h2><p>The first order of business is to figure out what the dimensions of the photos we download are. If we cast our eyes back to the data representation of a photo, we see:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; album :photos first&#41;
  ;; =&gt; {:description nil,
  ;;     :date-taken nil,
  ;;     :geo-data nil,
  ;;     :rotation -1,
  ;;     :width 0,
  ;;     :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;,
  ;;     :filename &quot;53460147147.jpg&quot;,
  ;;     :id &quot;53460147147&quot;,
  ;;     :out-file
  ;;     #object&#91;java.io.File 0x13356709 &quot;/tmp/72177720314024335/53460147147.jpg&quot;&#93;,
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photos.Photo 0x4291d927 &quot;com.flickr4java.flickr.photos.Photo@14ea992b&quot;&#93;,
  ;;     :height 0}

  &#41;
</code></pre><p>The problem is that <code>width</code> and <code>height</code> are both 0, which is definitely not so helpful. Since Flickr won't tell us what we need to know, let's see if Java can.</p><p>Luckily, there's a <a href='https://docs.oracle.com/javase/8/docs/api/javax/imageio/ImageIO.html'>javax.imageio.ImageIO</a> class that looks like it will do exactly what we need!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;import '&#40;javax.imageio ImageIO&#41;&#41;
  ;; =&gt; javax.imageio.ImageIO

  &#40;let &#91;img &#40;ImageIO/read &#40;-&gt; album :photos first :out-file&#41;&#41;&#93;
    {:width &#40;.getWidth img&#41;
     :height &#40;.getHeight img&#41;}&#41;
  ;; =&gt; {:width 1024, :height 576}

  &#41;
</code></pre><p>Nice! Let's go back to our <code>clickr.flickr</code> namespace and add this to our <code>download-photo!</code> function so that we get the correct width and height for each photo. First, we need to import the <code>ImageIO</code> class:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns clickr.flickr
  &#40;:require &#91;babashka.fs :as fs&#93;
            &#91;clickr.util :as util&#93;
            &#91;clojure.java.io :as io&#93;
            &#91;clojure.string :as str&#93;&#41;
  &#40;:import &#40;com.flickr4java.flickr Flickr
                                   RequestContext
                                   REST&#41;
           &#40;com.flickr4java.flickr.auth Permission&#41;
           &#40;com.flickr4java.flickr.photos Size&#41;
           &#40;com.flickr4java.flickr.util FileAuthStore&#41;
           &#40;java.io BufferedInputStream
                    FileOutputStream&#41;
           &#40;javax.imageio ImageIO&#41;&#41;&#41;
</code></pre><p>And then we can do the actual reading of the image file:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn download-photo! &#91;{:keys &#91;flickr out-dir&#93; :as ctx}
                       {:keys &#91;filename&#93; :as photo}&#93;
  &#40;let &#91;p-interface &#40;.getPhotosInterface &#40;:client flickr&#41;&#41;
        out-file &#40;fs/file out-dir filename&#41;&#93;
    &#40;with-open &#91;in &#40;BufferedInputStream. &#40;.getImageAsStream p-interface &#40;:object photo&#41; Size/LARGE&#41;&#41;
                out &#40;FileOutputStream. out-file&#41;&#93;
      &#40;io/copy in out&#41;&#41;
    &#40;let &#91;img &#40;ImageIO/read out-file&#41;&#93;
      &#40;assoc photo
             :out-file out-file
             :width &#40;.getWidth img&#41;
             :height &#40;.getHeight img&#41;&#41;&#41;&#41;&#41;

&#40;comment

  &#40;download-photo! ctx &#40;-&gt; album :photos first&#41;&#41;
  ;; =&gt; {:description nil,
  ;;     :date-taken nil,
  ;;     :geo-data nil,
  ;;     :rotation -1,
  ;;     :width 1024,
  ;;     :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;,
  ;;     :filename &quot;53460147147.jpg&quot;,
  ;;     :id &quot;53460147147&quot;,
  ;;     :out-file
  ;;     #object&#91;java.io.File 0x7a5efcc7 &quot;/home/jmglov/Pictures/clickr/53460147147.jpg&quot;&#93;,
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photos.Photo 0x3d11130b &quot;com.flickr4java.flickr.photos.Photo@14ea992b&quot;&#93;,
  ;;     :height 576}

  &#41;
</code></pre><p>Lookin' good!</p><p>There's just one thing that annoys me here, which is that we're always downloading the file, even if it already exists locally. Let's fix that real quick whilst we're here:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn download-photo! &#91;{:keys &#91;flickr out-dir&#93; :as ctx}
                       {:keys &#91;filename&#93; :as photo}&#93;
  &#40;let &#91;p-interface &#40;.getPhotosInterface &#40;:client flickr&#41;&#41;
        out-file &#40;fs/file out-dir filename&#41;&#93;
    &#40;when-not &#40;fs/exists? out-file&#41;
      &#40;with-open &#91;in &#40;BufferedInputStream. &#40;.getImageAsStream p-interface &#40;:object photo&#41; Size/LARGE&#41;&#41;
                  out &#40;FileOutputStream. out-file&#41;&#93;
        &#40;io/copy in out&#41;&#41;&#41;
    &#40;let &#91;img &#40;ImageIO/read out-file&#41;&#93;
      &#40;assoc photo
             :out-file out-file
             :width &#40;.getWidth img&#41;
             :height &#40;.getHeight img&#41;&#41;&#41;&#41;&#41;
</code></pre><p>OK, now that we know the dimensions of each photo, what do we do with that? I guess we could extend our template to add the width and height and translate stuff, then do some calculations on the photos and write it to the HTML...</p><pre class="language-html"><code class="lang-html language-html">        &lt;div id=&quot;photos&quot;&gt;
            {% for photo in album.photos %}
            &lt;div id=&quot;photo-{{photo.id}}&quot; class=&quot;photo&quot;
                style=&quot;background-image: url&#40;'{{photo.filename}}'&#41;; width: {{photo.width}}px; height: {{photo.height}}px; transform: translate&#40;{{photo.offset-x}}px, {{photo.offset-y}}px&#41;;&quot;&gt;
            &lt;/div&gt;
            {% endfor %}
        &lt;/div&gt;
</code></pre><p>But this seems kinda yucky. Also we have no way of knowing how wide the browser window will be, so we can't make good use of the space, and we can't dynamically resize when the browser window is resized, and so on and so forth. 😢</p><p>My friends, I'm afraid we're going to have to resort to JavaScript! 😱</p><p>But wait... this is a Clojure blog (sort of), and we Clojurians have a secret weapon!</p><p><img src="assets/2024-01-22-clojurescript.png" alt="A photo of David Nolen and Mike Fikes with a ClojureScript logo" title="Nolen and Fikes rock, ClojureScript reaches!" width=800px /></p><h2 id="mellon_collie_and_the_infinite_sadness_of_getting_clojurescript_to_build">Mellon Collie and the Infinite Sadness of getting ClojureScript to build</h2><p>The only problem with ClojureScript is that it transpiles to JavaScript, which means you need to somehow get that JavaScript built and then stick it somewhere the browser can find it and OMG that smacks of effort!</p><p>We have lovely tools such as <a href='https://github.com/thheller/shadow-cljs'>shadow-cljs</a> which turn the infinite sadness into finite sadness, but I'm going to go on record as not being too happy about sadness (though sadness has a part to play in the human experience, so maybe I should be happy about that?), so am I SOL (er, Sorta Outta Luck?) at this point?</p><p>Of course not, because we Clojurians have more than one secret weapon!</p><p><img src="assets/2024-01-22-borkdude.png" alt="A photo of Michiel Borkent AKA borkdude saying 'Can I get a borkdude?'" title="Get borked, man!" width=800px /></p><p>A friend and were discussing borkdude's prodigious output one day, and one of us made the observation that there was something Unix-y about his stuff, that he provided a bunch of basic pieces that could be combined to do pretty much whatever you need to do, but without sucking in all of Maven Central as transitive dependencies. I don't know if this is a great comparison, or if borkdude himself would agree, but hey! this is my blog, and borkdude can write a polemic about how much I suck on his own blog if he wants to.</p><p>Anyway.</p><p>One of the most amazing things borkdude provideth is this thing called <a href='https://github.com/babashka/scittle/'>Scittle</a>, which allows you to "execute Clojure(Script) directly from browser script tags via SCI". This is an absolute game changer for lazy programmers like me, or even non-lazy programmers unlike me who want to put some ClojureScript on a page but aren't setting out to build complex Single Page Apps.</p><p>So let's drop a scittle in our <code>&lt;head&gt;</code> and get to ClojureScripting!</p><pre class="language-html"><code class="lang-html language-html">&lt;head&gt;
    &lt;title&gt;{{album.title}}&lt;/title&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;meta http-equiv=&quot;x-ua-compatible&quot; content=&quot;ie=edge&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;style.css&quot;&gt;

    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.js&quot; type=&quot;application/javascript&quot;&gt;&lt;/script&gt;
&lt;/head&gt;
</code></pre><p>With this one little line, I now have the full power of ClojureScript at my disposal! I can now solve problems like knowing how wide my window is:</p><pre class="language-html"><code class="lang-html language-html">&lt;head&gt;
    &lt;!-- ... --&gt;

    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.js&quot; type=&quot;application/javascript&quot;&gt;&lt;/script&gt;
    &lt;script type=&quot;application/x-scittle&quot;&gt;
      &#40;println &#40;str &quot;Window width: &quot; &#40;.-innerWidth js/window&#41; &quot;px&quot;&#41;&#41;
    &lt;/script&gt;
&lt;/head&gt;
</code></pre><p>Having dropped this into my <code>resources/templates/album.html</code>, if I <code>write-album-html!</code> again, reload my browser, and take a look at the JavaScript console, wondrous sights fill my eyes:</p><p><img src="assets/2024-01-22-width.png" alt="The album webpage, displaying 'Window width: 1362px' in the JavaScript console" title="Behold the wonder of Scittle!" width=800px /></p><p>Of course, ClojureScript without a REPL is like a lovely bowl of mango sorbet without a spoon, so let's visit the <a href='https://github.com/babashka/scittle/tree/main/doc/nrepl'>Scittle
docs</a> and get ourselves a lovely silver spoon!</p><p>According to that page, all I need to do is drop the following into my HTML file:</p><pre class="language-html"><code class="lang-html language-html">&lt;head&gt;
    &lt;!-- ... --&gt;

    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.js&quot; type=&quot;application/javascript&quot;&gt;&lt;/script&gt;
    &lt;script&gt;var SCITTLE&#95;NREPL&#95;WEBSOCKET&#95;PORT = 1340;&lt;/script&gt;
    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.nrepl.js&quot;
        type=&quot;application/javascript&quot;&gt;&lt;/script&gt;
    &lt;script type=&quot;application/x-scittle&quot; src=&quot;album.cljs&quot;&gt;&lt;/script&gt;
&lt;/head&gt;
</code></pre><p>Then I can pop an <code>album.cljs</code> right next to my <code>index.html</code> and win!</p><p>Let's try this out. I'll create a basic <code>resources/templates/album.cljs</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns album&#41;

&#40;defn print-window-width &#91;&#93;
  &#40;println &#40;str &quot;Window width: &quot; &#40;.-innerWidth js/window&#41; &quot;px&quot;&#41;&#41;&#41;
</code></pre><p>And back in my <code>src/clickr/html.clj</code>, write it to the output dir along with the other files:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn write-album-html! &#91;ctx {:keys &#91;out-dir&#93; :as album}&#93;
  &#40;when-not out-dir
    &#40;throw &#40;ex-info &quot;Album must be downloaded before writing it to HTML&quot;
                    {:album album}&#41;&#41;&#41;
  &#40;let &#91;html &#40;album-&gt;html ctx album&#41;
        html-file &#40;fs/file out-dir &quot;index.html&quot;&#41;
        css &#40;album-&gt;css ctx album&#41;
        css-file &#40;fs/file out-dir &quot;style.css&quot;&#41;
        cljs &#40;apply-album-template ctx &quot;resources/templates/album.cljs&quot; album&#41;
        cljs-file &#40;fs/file out-dir &quot;album.cljs&quot;&#41;&#93;
    &#40;spit html-file html&#41;
    &#40;spit css-file css&#41;
    &#40;spit cljs-file cljs&#41;
    &#40;assoc album :html-file html-file, :css-file css-file, :cljs-file cljs-file&#41;&#41;&#41;

&#40;comment

  &#40;write-album-html! ctx album&#41;
  ;; =&gt; { ... }

  &#41;
</code></pre><p>But when we reload our page, we get some gross stuff about <code>DOMException</code>s and <code>CORS</code> and other things that make us go "hmm?".</p><pre class="language-text"><code class="lang-text language-text">scittle.nrepl.js:18 Uncaught DOMException: Failed to construct 'WebSocket': The URL 'ws://:1340/&#95;nrepl' is invalid.
    at https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.nrepl.js:18:158
    at https://cdn.jsdelivr.net/npm/scittle@0.6.15/dist/scittle.nrepl.js:20:4
index.html:1 Access to XMLHttpRequest at 'file:///tmp/72177720314024335/album.cljs' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, isolated-app, brave, https, chrome-untrusted, data, chrome-extension, chrome.
scittle.js:1881 GET file:///tmp/72177720314024335/album.cljs net::ERR&#95;FAILED
</code></pre><p>This is the opposite of fun. <code>&#40;complement fun&#41;</code>, if you will. It looks like we're going to need an actual webserver to continue here. Luckily, if we but read a tiny bit further in <code>scittle/doc/nrepl/README.md</code>, it seems that we can have a webserver quite easily:</p><blockquote><p> When you run bb dev in this directory, and then open http://localhost:1341 you  should be able evaluate expressions in playground.cljs. See a demo here. </p><p> Note that the nREPL server connection stays alive even after the browser  window refreshes. </p></blockquote><p>So if we grab the <a href='https://github.com/babashka/scittle/blob/main/doc/nrepl/bb.edn'>bb.edn</a> from here and drop it in our <code>resources/templates/</code> directory, and add it to our ever-expanding <code>write-album-html!</code> function...</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn write-album-html! &#91;ctx {:keys &#91;out-dir&#93; :as album}&#93;
  &#40;when-not out-dir
    &#40;throw &#40;ex-info &quot;Album must be downloaded before writing it to HTML&quot;
                    {:album album}&#41;&#41;&#41;
  &#40;let &#91;html &#40;album-&gt;html ctx album&#41;
        html-file &#40;fs/file out-dir &quot;index.html&quot;&#41;
        css &#40;album-&gt;css ctx album&#41;
        css-file &#40;fs/file out-dir &quot;style.css&quot;&#41;
        cljs &#40;apply-album-template ctx &quot;resources/templates/album.cljs&quot; album&#41;
        cljs-file &#40;fs/file out-dir &quot;album.cljs&quot;&#41;
        bb-edn &#40;apply-album-template ctx &quot;resources/templates/bb.edn&quot; album&#41;
        bb-edn-file &#40;fs/file out-dir &quot;bb.edn&quot;&#41;&#93;
    &#40;spit html-file html&#41;
    &#40;spit css-file css&#41;
    &#40;spit cljs-file cljs&#41;
    &#40;spit bb-edn-file bb-edn&#41;
    &#40;assoc album :html-file html-file, :css-file css-file
           :cljs-file cljs-file, :bb-edn-file bb-edn-file&#41;&#41;&#41;

&#40;comment

  &#40;write-album-html! ctx album&#41;
  ;; =&gt; { ... }

  &#41;
</code></pre><p>Now we can pop over to <code>/tmp/72177720314024335/</code> and fire up Babashka:</p><pre class="language-text"><code class="lang-text language-text">: jmglov@laurana; cd /tmp/72177720314024335/
: jmglov@laurana; bb dev
Serving static assets at http://localhost:1341
nREPL server started on port 1339...
Websocket server started on 1340...
</code></pre><p>and then load up http://localhost:1341/ in our web browser, open up <code>/tmp/72177720314024335/album.cljs</code> in Emacs, run <code>cider-connect-cljs</code>, select <code>localhost</code> then port <code>1339</code>, select <code>nbb</code> as the ClojureScript REPL type, maybe hit <code>C-g</code> a few times if we see something like</p><pre class="language-text"><code class="lang-text language-text">Fri Jan 19 09:03:52 CET 2024 &#91;worker-3&#93; ERROR - handle websocket frame org.httpkit.server.Frame$TextFrame@1de1c580
java.lang.RuntimeException: No reader function for tag object
        at clojure.lang.EdnReader$TaggedReader.readTagged&#40;EdnReader.java:801&#41;
        at clojure.lang.EdnReader$TaggedReader.invoke&#40;EdnReader.java:783&#41;
        &#91;...&#93;
</code></pre><p>in the terminal where we're running <code>bb dev</code> (there's a <a href='https://clojurians.slack.com/archives/C034FQN490E/p1703238977455889'>whole
thread</a> over on Clojurians Slack about this where borkdude and Benjamin fixed this, but I switched laptops since then—a long story for another time—and it came back, but it doesn't hurt anything, so I just carried on with my disgusting workaround), and then whack <code>C-c C-k</code> (your keybindings may vary, of course, so it's <code>cider-load-buffer</code> you want) and drop a <a href='https://betweentwoparens.com/blog/rich-comment-blocks/#rich-comment'>Rich
comment</a> in the file</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;print-window-width&#41;
  ;; =&gt; nil

  &#41;
</code></pre><p>and then <code>C-c C-v f c e</code> (<code>cider-pprint-eval-last-sexp-to-comment</code>) and finally pop a bottle of emoji! 🥂</p><p><img src="assets/2024-01-22-repl.png" alt="The album webpage, displaying 'Window width: 1362px' in the JavaScript console" title="Now we're cooking with REPL!" width=800px /></p><p>OK, so let's take stock of what we've done here. We now have a webpage which loads Scittle which in turn loads our shiny new <code>album.cljs</code>, which we can load in our web browser by virtue of a Babashka webserver configured by the <code>bb.edn</code> we dropped in the album dir, and oh by the way, we can connect a REPL to it and cause stuff to happen. That's something we can now build a Flickr clone on!</p><h2 id="sizing_things_up">Sizing things up</h2><p>Let's make a few design decisions about how the album should look. And by "make design decisions", I mean "rip design decisions off from Flickr". Here's what we want:</p><ol><li>If the browser window is reasonably wide, the album should be 80% the width   of the window</li><li>If the browser window is reasonably wide, display 3 photos per row</li><li>All rows should be the same width as the album header</li><li>All photos in a row should be scaled to the same height</li><li>As the window is resized, photos should be rescaled to fit nicely</li><li>If the window is too small to display three photos per row, display two per   row instead, and expand the album to fill the width of the window</li><li>If the window is too small to display two photos per row, display one per   row instead</li></ol><p>Let's start off by just assuming the window is reasonably wide and seeing if we can't get the photos displayed three to a row, with the row being the same width as the album header and all photos in each row being the same size.</p><p>The first order of business is to expose the photos metadata to ClojureScript. Thanks to our decision to run all of our resource files through Selmer, this turns out to be a one-liner in <code>album.cljs</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns album&#41;

&#40;def photos {{photos-edn | safe}}&#41;
</code></pre><p>Of course, that <code>photos-edn</code> has to come from somewhere, so let's pop into our <code>clickr.html</code> namespace and turn our photos datastructure into some EDN:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def album &#40;-&gt;&gt; &#40;flickr/get-albums ctx&#41;
                  first
                  &#40;flickr/download-album! ctx&#41;&#41;&#41;
  ;; =&gt; #'clickr.html/album

  &#40;with-out-str
    &#40;prn &#40;:photos album&#41;&#41;&#41;
  ;; =&gt; &quot;&#40;{:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1024, :title \&quot;sean-hargreaves-phoenix-new-5-final-a\&quot;, :filename \&quot;53460147147.jpg\&quot;, :id \&quot;53460147147\&quot;, :object #object&#91;com.flickr4java.flickr.photos.Photo 0x11aef77d \&quot;com.flickr4java.flickr.photos.Photo@14ea992b\&quot;&#93;, :height 576} {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1024, :title \&quot;patryk-urbaniak-for-all-mankind-004\&quot;, :filename \&quot;53461405604.jpg\&quot;, :id \&quot;53461405604\&quot;, :object #object&#91;com.flickr4java.flickr.photos.Photo 0x63127ec5 \&quot;com.flickr4java.flickr.photos.Photo@22e889e0\&quot;&#93;, :height 576} {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1024, :title \&quot;jared-michael-forallmankind-011\&quot;, :filename \&quot;53461091151.jpg\&quot;, :id \&quot;53461091151\&quot;, :object #object&#91;com.flickr4java.flickr.photos.Photo 0x46c7dec8 \&quot;com.flickr4java.flickr.photos.Photo@33f7f318\&quot;&#93;, :height 512} {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1024, :title \&quot;sean-hargreaves-transport-ship-new-final-1b\&quot;, :filename \&quot;53461088046.jpg\&quot;, :id \&quot;53461088046\&quot;, :object #object&#91;com.flickr4java.flickr.photos.Photo 0x25078237 \&quot;com.flickr4java.flickr.photos.Photo@88e20c4f\&quot;&#93;, :height 576} {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1024, :title \&quot;sean-hargreaves-057-asteroid-mining-ship-platformcables-1b-sh-2022-7-08\&quot;, :filename \&quot;53460163402.jpg\&quot;, :id \&quot;53460163402\&quot;, :object #object&#91;com.flickr4java.flickr.photos.Photo 0x43748cf5 \&quot;com.flickr4java.flickr.photos.Photo@451d67cd\&quot;&#93;, :height 576} {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 722, :title \&quot;jean-luc-sabourin-fam-season-2-soviet-notext\&quot;, :filename \&quot;53460161007.jpg\&quot;, :id \&quot;53460161007\&quot;, :object #object&#91;com.flickr4java.flickr.photos.Photo 0x33e03608 \&quot;com.flickr4java.flickr.photos.Photo@c948c4a2\&quot;&#93;, :height 1023} {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 666, :title \&quot;trung-doan-mankind\&quot;, :filename \&quot;53461214223.jpg\&quot;, :id \&quot;53461214223\&quot;, :object #object&#91;com.flickr4java.flickr.photos.Photo 0x4b299f22 \&quot;com.flickr4java.flickr.photos.Photo@1a270f5c\&quot;&#93;, :height 1000} {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1023, :title \&quot;daniel-jennings-img-7554\&quot;, :filename \&quot;53460151727.jpg\&quot;, :id \&quot;53460151727\&quot;, :object #object&#91;com.flickr4java.flickr.photos.Photo 0x5226aa4a \&quot;com.flickr4java.flickr.photos.Photo@436e36e8\&quot;&#93;, :height 299}&#41;\n&quot;

  &#41;
</code></pre><p>OK, that technically worked, but... gross! It would be much nicer if this mess was human readable in some way. Luckily for us, Rich Hickey provideth, in the form of <a href='https://clojuredocs.org/clojure.pprint/pprint'>clojure.pprint/pprint</a>. Let's just swap that in for <code>prn</code> and see how it goes:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">
&#40;comment

  &#40;require '&#91;clojure.pprint :as pprint&#93;&#41;
  ;; =&gt; nil

  &#40;with-out-str
    &#40;pprint/pprint &#40;:photos album&#41;&#41;&#41;
  ;; =&gt; &quot;&#40;{:description nil,\n  :date-taken nil,\n  :geo-data nil,\n  :rotation -1,\n  :width 1024,\n  :title \&quot;sean-hargreaves-phoenix-new-5-final-a\&quot;,\n  :filename \&quot;53460147147.jpg\&quot;,\n  :id \&quot;53460147147\&quot;,\n  :object\n  #object&#91;com.flickr4java.flickr.photos.Photo 0x11aef77d \&quot;com.flickr4java.flickr.photos.Photo@14ea992b\&quot;&#93;,\n  :height 576}\n ... {:description nil,\n  :date-taken nil,\n  :geo-data nil,\n  :rotation -1,\n  :width 1023,\n  :title \&quot;daniel-jennings-img-7554\&quot;,\n  :filename \&quot;53460151727.jpg\&quot;,\n  :id \&quot;53460151727\&quot;,\n  :object\n  #object&#91;com.flickr4java.flickr.photos.Photo 0x5226aa4a \&quot;com.flickr4java.flickr.photos.Photo@436e36e8\&quot;&#93;,\n  :height 299}&#41;\n&quot;

  &#41;
</code></pre><p>OK, that still looks silly in my REPL, but if I print it, I get:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;{:description nil,
  :date-taken nil,
  :geo-data nil,
  :rotation -1,
  :width 1024,
  :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;,
  :filename &quot;53460147147.jpg&quot;,
  :id &quot;53460147147&quot;,
  :out-file
  #object&#91;java.io.File 0x13356709 &quot;/tmp/72177720314024335/53460147147.jpg&quot;&#93;,
  :object
  #object&#91;com.flickr4java.flickr.photos.Photo 0x4291d927 &quot;com.flickr4java.flickr.photos.Photo@14ea992b&quot;&#93;,
  :height 576}
 {:description nil,
  :date-taken nil,
  :geo-data nil,
  :rotation -1,
  :width 1024,
  :title &quot;patryk-urbaniak-for-all-mankind-004&quot;,
  :filename &quot;53461405604.jpg&quot;,
  :id &quot;53461405604&quot;,
  :out-file
  #object&#91;java.io.File 0x17191ef4 &quot;/tmp/72177720314024335/53461405604.jpg&quot;&#93;,
  :object
  #object&#91;com.flickr4java.flickr.photos.Photo 0xb886372 &quot;com.flickr4java.flickr.photos.Photo@22e889e0&quot;&#93;,
  :height 576}
 ;; ...
 &#41;
</code></pre><p>This is better, in that it can be read by a human. Let's make sure that it can be read by a machine, though:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;require '&#91;clojure.edn :as edn&#93;&#41;
  ;; =&gt; nil

  &#40;-&gt; &#40;with-out-str
        &#40;pprint/pprint &#40;:photos album&#41;&#41;&#41;
      edn/read-string&#41;
  ;; =&gt; Execution error at clickr.html/eval27054 &#40;REPL:243&#41;.
  ;;    No reader function for tag object

  &#41;
</code></pre><p>Oopsy! I think those <code>#object</code> literals are causing trouble. No worries, we can just dissoc them:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; &#40;with-out-str
        &#40;pprint/pprint &#40;-&gt; album
                           :photos
                           &#40;map #&#40;dissoc % :out-file :object&#41;&#41;&#41;&#41;&#41;
      edn/read-string&#41;
  ;; =&gt; Execution error &#40;IllegalArgumentException&#41; at clickr.html/eval27066$fn &#40;REPL:241&#41;.
  ;;    Don't know how to create ISeq from: clickr.html$eval27066$fn&#95;&#95;27067$fn&#95;&#95;27068

  &#41;
</code></pre><p>Arg! Looks like the reader is having trouble with that lazy sequence. Sounds familiar, eh? 🙄 Let's turn it into a vector:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">
&#40;comment

  &#40;-&gt; &#40;with-out-str
        &#40;pprint/pprint &#40;-&gt;&gt; album
                            :photos
                            &#40;map #&#40;dissoc % :out-file :object&#41;&#41;
                            vec&#41;&#41;&#41;
      edn/read-string&#41;
  ;; =&gt; &#91;{:description nil,
  ;;      :date-taken nil,
  ;;      :geo-data nil,
  ;;      :rotation -1,
  ;;      :width 1024,
  ;;      :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;,
  ;;      :filename &quot;53460147147.jpg&quot;,
  ;;      :id &quot;53460147147&quot;,
  ;;      :height 576}
  ;;     &#91;...&#93;
  ;;     {:description nil,
  ;;      :date-taken nil,
  ;;      :geo-data nil,
  ;;      :rotation -1,
  ;;      :width 1023,
  ;;      :title &quot;daniel-jennings-img-7554&quot;,
  ;;      :filename &quot;53460151727.jpg&quot;,
  ;;      :id &quot;53460151727&quot;,
  ;;      :height 299}&#93;

  &#41;
</code></pre><p>Nice, now we can round-trip our EDN, so let's plug this into the CLJS template.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns clickr.html
  &#40;:require &#91;babashka.fs :as fs&#93;
            &#91;selmer.parser :as selmer&#93;
            &#91;clojure.pprint :as pprint&#93;&#41;&#41;

&#40;defn -&gt;edn &#91;data&#93;
  &#40;with-out-str &#40;pprint/pprint data&#41;&#41;&#41;

&#40;defn apply-album-template &#91;&#95;ctx template-file album&#93;
  &#40;selmer/render &#40;slurp template-file&#41;
                 {:album &#40;update album :photos vec&#41;
                  :photos-edn &#40;-&gt;&gt; album
                                   :photos
                                   &#40;map #&#40;dissoc % :out-file :object&#41;&#41;
                                   vec
                                   -&gt;edn&#41;}&#41;&#41;

&#40;comment

  &#40;write-album-html! ctx album&#41;
  ;; =&gt; { ... }

  &#41;
</code></pre><p>If we take a look at the resulting <code>/tmp/72177720314024335/album.cljs</code>, we see that by golly do we ever have photos!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns album&#41;

&#40;def photos &#91;{:description nil,
  :date-taken nil,
  :geo-data nil,
  :rotation -1,
  :width 0,
  :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;,
  :filename &quot;53460147147.jpg&quot;,
  :id &quot;53460147147&quot;,
  :height 0}
 ;; &#91;...&#93;
 {:description nil,
  :date-taken nil,
  :geo-data nil,
  :rotation -1,
  :width 0,
  :title &quot;daniel-jennings-img-7554&quot;,
  :filename &quot;53460151727.jpg&quot;,
  :id &quot;53460151727&quot;,
  :height 0}&#93;
&#41;
</code></pre><p>Let's open up <code>/tmp/72177720314024335/album.cljs</code> directly in Emacs so we can continue our REPL-driven development without the need to keep evaluating <code>write-album-html!</code>. We'll start out by reloading the browser to make sure it's picking up the latest <code>album.cljs</code>. This should be the last time we'll need to reload the page, unless something goes wrong. Next, let's evaluate the entire buffer (<code>C-c C-k</code> or <code>cider-load-buffer</code> or however you do stuff in your editor), and finally make sure all is good with our REPL connection:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;println &quot;OK, I'm reloaded!&quot;&#41;
  ;; =&gt; nil

  &#41;
</code></pre><p>Opening up the JavaScript console in our browser, we see the message we printed out, so all is good there. Time to get to actually computing stuff and things!</p><p>Our first rule was:</p><p><strong>1. If the browser window is reasonably wide, the album should be 80% the width of the window</strong></p><p>Let's add a function to get the current window width:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-window-width &#91;&#93;
  &#40;.-innerWidth js/window&#41;&#41;

&#40;comment

  &#40;get-window-width&#41;
  ;; =&gt; 1513

  &#41;
</code></pre><p>Great! Now let's define what we mean by "reasonably wide". We want to display 3 photos side by side, and having them be 300 pixels wide seems like a good size. Then we're going to need 4 pixels of padding between each photo, so that adds up to 908 pixels. This is the width of the album div, though, and it will need to fit into 80% of the window width, meaning that the window needs to be... um, some number of pixels wide?</p><p>OK, instead of dividing by 0.8, let's actually write our code with the album div in mind instead of the window width. We can encode all of these rules in some nice Clojure data:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def config
  {:album-min-width 900
   :album-width-pct 0.8
   :num-photos-per-row 3
   :photo-padding 4}&#41;
</code></pre><p>Now let's write a function that computes the width of the album div based on all those computations that I was trying to do in my head:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn provisional-album-width &#91;{:keys &#91;album-width-pct&#93; :as config}&#93;
  &#40;&#42; &#40;get-window-width&#41; album-width-pct&#41;&#41;

&#40;comment

  &#40;provisional-album-width config&#41;
  ;; =&gt; 1210.4

  &#41;
</code></pre><p>Cool, so at the current window width, the album is wide enough. Let's now set the width to that provisional width. This requires us to know the name of the album div so we can grab it from the DOM, so let's add that to our config:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def config
  {:album-div-name &quot;album&quot;
   :album-min-width 900
   :album-width-pct 0.8
   :num-photos-per-row 3
   :photo-padding 4}&#41;

&#40;comment

  &#40;.getElementById js/document &#40;:album-div-name config&#41;&#41;
  ;; =&gt; #object&#91;HTMLDivElement &#91;object HTMLDivElement&#93;&#93;

  &#41;
</code></pre><p>Looks promising! To set the width of that div, we need to set a style property on it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; &#40;.getElementById js/document &#40;:album-div-name config&#41;&#41;
      .-style
      &#40;.setProperty &quot;width&quot; &quot;1210.4px&quot;&#41;&#41;
  ;; =&gt; nil

  &#41;
</code></pre><p>As soon as we evaluate this in the REPL, the div resizes live in our browser window! 🤯</p><p><img src="assets/2024-01-22-pctwidth.png" alt="The album webpage with the album div 80% of the window width" title="Such REPL driving!" width=800px /></p><p>Now that we've figured out all the pieces, let's write a function that sets the album width:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn set-album-width!
  &quot;Sets the width of the album div based on the current window size and returns
   the new size of the album div.&quot;
  &#91;{:keys &#91;album-div-name album-min-width num-photos-per-row photo-padding&#93;
    :as config}&#93;
  &#40;let &#91;provisional-width &#40;provisional-album-width config&#41;
        padding-width &#40;&#42; photo-padding &#40;dec num-photos-per-row&#41;&#41;
        min-width &#40;+ album-min-width padding-width&#41;
        new-width &#40;if &#40;&gt;= provisional-width min-width&#41;
                    provisional-width
                    &#40;&#42; &#40;get-window-width&#41; 0.95&#41;&#41;&#93;
    &#40;-&gt; &#40;.getElementById js/document album-div-name&#41;
        .-style
        &#40;.setProperty &quot;width&quot; &#40;str new-width &quot;px&quot;&#41;&#41;&#41;
    new-width&#41;&#41;

&#40;comment

  &#40;set-album-width! config&#41;
  ;; =&gt; 1210.4

  &#41;
</code></pre><p>The div has resized again, this time to 80% of the window width! 🎉</p><h2 id="scaling_photos_is_easier_than_scaling_everest%2C_right%3F">Scaling photos is easier than scaling Everest, right?</h2><p>Let's have a look at our next rule:</p><p><strong>2. If the browser window is reasonably wide, display 3 photos per row</strong></p><p>Splitting the photos into rows is quite straightforward:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;partition-all 3 photos&#41;
  ;; =&gt; &#40;&#40;{:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1024, :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;, :filename &quot;53460147147.jpg&quot;, :id &quot;53460147147&quot;, :height 576}
  ;;      {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1024, :title &quot;patryk-urbaniak-for-all-mankind-004&quot;, :filename &quot;53461405604.jpg&quot;, :id &quot;53461405604&quot;, :height 576}
  ;;      {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1024, :title &quot;jared-michael-forallmankind-011&quot;, :filename &quot;53461091151.jpg&quot;, :id &quot;53461091151&quot;, :height 512}&#41;
  ;;     &#40;{:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1024, :title &quot;sean-hargreaves-transport-ship-new-final-1b&quot;, :filename &quot;53461088046.jpg&quot;, :id &quot;53461088046&quot;, :height 576}
  ;;      {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1024, :title &quot;sean-hargreaves-057-asteroid-mining-ship-platformcables-1b-sh-2022-7-08&quot;, :filename &quot;53460163402.jpg&quot;, :id &quot;53460163402&quot;, :height 576}
  ;;      {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 722, :title &quot;jean-luc-sabourin-fam-season-2-soviet-notext&quot;, :filename &quot;53460161007.jpg&quot;, :id &quot;53460161007&quot;, :height 1023}&#41;
  ;;     &#40;{:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 666, :title &quot;trung-doan-mankind&quot;, :filename &quot;53461214223.jpg&quot;, :id &quot;53461214223&quot;, :height 1000}
  ;;      {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 1023, :title &quot;daniel-jennings-img-7554&quot;, :filename &quot;53460151727.jpg&quot;, :id &quot;53460151727&quot;, :height 299}&#41;&#41;

  &#41;
</code></pre><p>Moving on to the next rule:</p><p><strong>3. All rows should be the same width as the album header</strong></p><p>The width of a row is the width of the photos in the row plus the padding between them, so given the width of a row and the number of photos in it, we can figure out how wide each photo should be like so:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;row-width &#40;set-album-width! config&#41;
        padding-width &#40;&#42; &#40;dec &#40;:num-photos-per-row config&#41;&#41;
                         &#40;:photo-padding config&#41;&#41;&#93;
    &#40;-&gt; row-width
        &#40;- padding-width&#41;
        &#40;/ &#40;:num-photos-per-row config&#41;&#41;&#41;&#41;
  ;; =&gt; 400.8

  &#41;
</code></pre><p>Now we can scale the photos like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn scale-photos &#91;&#95;config target-width photos&#93;
  &#40;map #&#40;assoc % :width target-width&#41; photos&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; photos
       &#40;scale-photos config 400.8&#41;
       &#40;map :width&#41;&#41;
  ;; =&gt; &#40;400.8 400.8 400.8 400.8 400.8 400.8 400.8 400.8&#41;

  &#41;
</code></pre><p>Let's write a function that updates the styles of the div corresponding to a specific photo:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn set-photo-styles! &#91;&#95;config {:keys &#91;id width height&#93; :as photo}&#93;
  &#40;let &#91;div-id &#40;str &quot;photo-&quot; id&#41;&#93;
    &#40;doto &#40;-&gt; &#40;.getElementById js/document div-id&#41; -style&#41;
      &#40;.setProperty &quot;width&quot; &#40;str width &quot;px&quot;&#41;&#41;
      &#40;.setProperty &quot;height&quot; &#40;str height &quot;px&quot;&#41;&#41;&#41;
    photo&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; photos
       &#40;scale-photos config 400.8&#41;
       &#40;map &#40;partial set-photo-styles! config&#41;&#41;&#41;
  ;; =&gt; &#40;{:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 400.8, :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;, :filename &quot;53460147147.jpg&quot;, :id &quot;53460147147&quot;, :height 576} ... &#41;

  &#41;
</code></pre><p>OK, this is progress. We've successfully set the width of all photo divs:</p><p><img src="assets/2024-01-22-scaled.png" alt="The album webpage with all photos set to 400.8 pixels width" title="Don't let perfect be the enemy of wrong" width=800px /></p><p>The next step is to use the <code>transform: translate&#40;&#41;</code> stuff we learned about earlier to arrange the photos how we want them. Given our desired width of 400.8 pixels and height of 576 pixels (the height of the first photo, which must be the height of all the photos, right?), we want something like this:</p><pre class="language-html"><code class="lang-html language-html">&lt;div style=&quot;transform: translate&#40;0px,     4px&#41;;    ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;404.8px, 4px&#41;;    ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;809.6px, 4px&#41;;    ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;0px,     580px&#41;;  ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;404.8px, 580px&#41;;  ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;809.6px, 580px&#41;;  ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;0px,     1160px&#41;; ...&quot;&gt;&lt;/div&gt;
&lt;div style=&quot;transform: translate&#40;404.8px, 1160px&#41;; ...&quot;&gt;&lt;/div&gt;
</code></pre><p>Let's see if we can figure out how to make this happen, starting with a single row. To make it easier to see what's going on, let's hide all the photos not in the first row:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; photos
       &#40;drop 3&#41;
       &#40;map &#40;fn &#91;{:keys &#91;id width height&#93; :as photo}&#93;
              &#40;let &#91;div-id &#40;str &quot;photo-&quot; id&#41;&#93;
                &#40;-&gt; &#40;.getElementById js/document div-id&#41;
                    .-style
                    &#40;.setProperty &quot;display&quot; &quot;none&quot;&#41;&#41;&#41;&#41;&#41;
       doall&#41;
  ;; =&gt; &#40;nil nil nil nil nil&#41;

  &#41;
</code></pre><p>Having done this, let's update our <code>set-photo-styles!</code> function to set the <code>transform</code> CSS property based on the <code>:x-offset</code> and <code>:y-offset</code> keys of a photo:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn set-photo-styles!
  &#91;&#95;config {:keys &#91;id width height x-offset y-offset&#93; :as photo}&#93;
  &#40;let &#91;div-id &#40;str &quot;photo-&quot; id&#41;
        transform &#40;str &quot;translate&#40;&quot; x-offset &quot;px, &quot; y-offset &quot;px&#41;&quot;&#41;&#93;
    &#40;doto &#40;-&gt; &#40;.getElementById js/document div-id&#41; .-style&#41;
      &#40;.setProperty &quot;width&quot; &#40;str width &quot;px&quot;&#41;&#41;
      &#40;.setProperty &quot;height&quot; &#40;str height &quot;px&quot;&#41;&#41;
      &#40;.setProperty &quot;transform&quot; transform&#41;&#41;
    photo&#41;&#41;

&#40;comment

  &#40;let &#91;&#91;p1 p2 p3 &amp; &#95;&#93; &#40;scale-photos config 400.8 photos&#41;&#93;
    &#40;set-photo-styles! config &#40;assoc p1 :x-offset 0, :y-offset 4&#41;&#41;
    &#40;set-photo-styles! config &#40;assoc p2 :x-offset 404.8, :y-offset 4&#41;&#41;
    &#40;set-photo-styles! config &#40;assoc p3 :x-offset 809.6, :y-offset 4&#41;&#41;&#41;
  ;; =&gt; {:description nil, :date-taken nil, :geo-data nil, :y-offset 4, :rotation -1, :width 400.8, :title &quot;jared-michael-forallmankind-011&quot;, :filename &quot;53461091151.jpg&quot;, :id &quot;53461091151&quot;, :height 512, :x-offset 809.6}

  &#41;
</code></pre><p>We cheated a bit there, but the results look good:</p><p><img src="assets/2024-01-22-manual.png" alt="The album webpage with the first row laid out correctly" title="Don't underestimate the power of hard-coding!" width=800px /></p><p>Let's see if we can do this without hard-coding all the things. We basically want to set the x-offset of each photo to the x-offset of the previous photo, plus the width of the previous photo, plus the padding, so we need a function like <code>map</code>, except that it remembers stuff from the previous item being mapped over. There is of course a function like that: <a href='https://clojuredocs.org/clojure.core/reduce'>reduce</a>. Let's try it out:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; photos
       &#40;scale-photos config 400.8&#41;
       &#40;take 3&#41;
       &#40;reduce &#40;fn &#91;{:keys &#91;x-offset&#93; :as acc} {:keys &#91;width&#93; :as photo}&#93;
                 &#40;let &#91;new-x-offset &#40;+ x-offset width &#40;:photo-padding config&#41;&#41;&#93;
                   &#40;-&gt; acc
                       &#40;assoc :x-offset new-x-offset&#41;
                       &#40;update :arranged conj &#40;assoc photo :x-offset x-offset&#41;&#41;&#41;&#41;&#41;
               {:x-offset 0, :arranged &#91;&#93;}&#41;
       :arranged
       &#40;map &#40;partial set-photo-styles! config&#41;&#41;
       &#40;map :x-offset&#41;&#41;
  ;; =&gt; &#40;0 404.8 809.6&#41;

  &#41;
</code></pre><p>What we're doing here is computing the x-offset for the next photo in row as we iterate over the photos, then setting the x-offset of the current photo to the previously computed x-offset and appending it to the vector of arranged photos. We can see that it worked since the x-offsets of each photo are the same as we computed by hand above, and when we fed the row to <code>set-photo-styles!</code>, nothing moved in the browser! 🎉</p><p>Let's give this function a name to make it a little less mysterious:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn arrange-row &#91;{:keys &#91;photo-padding&#93; :as config} photos&#93;
  &#40;-&gt;&gt; photos
       &#40;reduce &#40;fn &#91;{:keys &#91;x-offset&#93; :as acc} {:keys &#91;width&#93; :as photo}&#93;
                 &#40;let &#91;new-x-offset &#40;+ x-offset width photo-padding&#41;&#93;
                   &#40;-&gt; acc
                       &#40;assoc :x-offset new-x-offset&#41;
                       &#40;update :arranged conj &#40;assoc photo :x-offset x-offset&#41;&#41;&#41;&#41;&#41;
               {:x-offset 0, :arranged &#91;&#93;}&#41;
       :arranged&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; photos
       &#40;scale-photos config 400.8&#41;
       &#40;take 3&#41;
       &#40;arrange-row config&#41;
       &#40;map &#40;partial set-photo-styles! config&#41;&#41;
       &#40;map :x-offset&#41;&#41;
  ;; =&gt; &#40;0 404.8 809.6&#41;

  &#41;
</code></pre><p>Much better!</p><h2 id="getting_down_2d">Getting down 2D</h2><p>Now that we've dealt with a single row, let's see if we can apply the same strategy to the y-offset for all rows. But first, let's make all the photos visible again:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; photos
       &#40;drop 3&#41;
       &#40;map &#40;fn &#91;{:keys &#91;id width height&#93; :as photo}&#93;
              &#40;let &#91;div-id &#40;str &quot;photo-&quot; id&#41;&#93;
                &#40;-&gt; &#40;.getElementById js/document div-id&#41;
                    .-style
                    &#40;.removeProperty &quot;display&quot;&#41;&#41;&#41;&#41;&#41;
       doall&#41;
  ;; =&gt; &#40;&quot;none&quot; &quot;none&quot; &quot;none&quot; &quot;none&quot; &quot;none&quot;&#41;

  &#41;
</code></pre><p>We want to start by scaling the photos to our desired width and then partitioning them into rows:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; photos
       &#40;scale-photos config 400.8&#41;
       &#40;partition-all 3&#41;&#41;
  ;; =&gt; &#40;&#40;{:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 400.8, :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;, :filename &quot;53460147147.jpg&quot;, :id &quot;53460147147&quot;, :height 576}
  ;;      {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 400.8, :title &quot;patryk-urbaniak-for-all-mankind-004&quot;, :filename &quot;53461405604.jpg&quot;, :id &quot;53461405604&quot;, :height 576}
  ;;      {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 400.8, :title &quot;jared-michael-forallmankind-011&quot;, :filename &quot;53461091151.jpg&quot;, :id &quot;53461091151&quot;, :height 512}&#41;
  ;;     &#40;{:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 400.8, :title &quot;sean-hargreaves-transport-ship-new-final-1b&quot;, :filename &quot;53461088046.jpg&quot;, :id &quot;53461088046&quot;, :height 576}
  ;;      {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 400.8, :title &quot;sean-hargreaves-057-asteroid-mining-ship-platformcables-1b-sh-2022-7-08&quot;, :filename &quot;53460163402.jpg&quot;, :id &quot;53460163402&quot;, :height 576}
  ;;      {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 400.8, :title &quot;jean-luc-sabourin-fam-season-2-soviet-notext&quot;, :filename &quot;53460161007.jpg&quot;, :id &quot;53460161007&quot;, :height 1023}&#41;
  ;;     &#40;{:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 400.8, :title &quot;trung-doan-mankind&quot;, :filename &quot;53461214223.jpg&quot;, :id &quot;53461214223&quot;, :height 1000} 
  ;;      {:description nil, :date-taken nil, :geo-data nil, :rotation -1, :width 400.8, :title &quot;daniel-jennings-img-7554&quot;, :filename &quot;53460151727.jpg&quot;, :id &quot;53460151727&quot;, :height 299}&#41;&#41;

  &#41;
</code></pre><p>Having done that, we can <code>reduce</code> over the rows, calculating the y-offset for the next row as the current y-offset plus the height of the row:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; photos
       &#40;scale-photos config 400.8&#41;
       &#40;partition-all 3&#41;
       &#40;reduce &#40;fn &#91;{:keys &#91;y-offset&#93; :as acc} row-photos&#93;
                 &#40;let &#91;new-y-offset &#40;+ y-offset
                                       &#40;:height &#40;first row-photos&#41;&#41;
                                       &#40;:photo-padding config&#41;&#41;
                       arranged-row &#40;-&gt;&gt; row-photos
                                         &#40;arrange-row config&#41;
                                         &#40;map #&#40;assoc % :y-offset y-offset&#41;&#41;&#41;&#93;
                   &#40;-&gt; acc
                       &#40;assoc :y-offset new-y-offset&#41;
                       &#40;update :arranged concat arranged-row&#41;&#41;&#41;&#41;
               {:y-offset &#40;:photo-padding config&#41;, :arranged &#91;&#93;}&#41;
       :arranged
       &#40;map &#40;partial set-photo-styles! config&#41;&#41;
       &#40;map &#40;juxt :x-offset :y-offset&#41;&#41;&#41;
  ;; =&gt; &#40;&#91;0 4&#93; &#91;404.8 4&#93; &#91;809.6 4&#93; &#91;0 584&#93; &#91;404.8 584&#93; &#91;809.6 584&#93; &#91;0 1164&#93; &#91;404.8 1164&#93;&#41;

  &#41;
</code></pre><p>OK, this seems about right. Let's clean this mess up a bit:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn arrange-photos
  &#91;{:keys &#91;num-photos-per-row photo-padding&#93; :as config} photos&#93;
  &#40;-&gt;&gt; photos
       &#40;partition-all num-photos-per-row&#41;
       &#40;reduce &#40;fn &#91;{:keys &#91;y-offset&#93; :as acc} row-photos&#93;
                 &#40;let &#91;arranged-row &#40;-&gt;&gt; row-photos
                                         &#40;arrange-row config&#41;
                                         &#40;map #&#40;assoc % :y-offset y-offset&#41;&#41;&#41;
                       new-y-offset &#40;+ y-offset
                                       &#40;:height &#40;first arranged-row&#41;&#41;
                                       photo-padding&#41;&#93;
                   &#40;-&gt; acc
                       &#40;assoc :y-offset new-y-offset&#41;
                       &#40;update :arranged concat arranged-row&#41;&#41;&#41;&#41;
               {:y-offset photo-padding, :arranged &#91;&#93;}&#41;
       :arranged&#41;&#41;

&#40;defn display-album!
  &#91;{:keys &#91;num-photos-per-row photo-padding&#93; :as config} photos&#93;
  &#40;let &#91;album-width &#40;set-album-width! config&#41;
        padding-width &#40;&#42; photo-padding &#40;dec num-photos-per-row&#41;&#41;
        photo-width &#40;-&gt; &#40;- album-width padding-width&#41;
                        &#40;/ num-photos-per-row&#41;&#41;&#93;
    &#40;-&gt;&gt; photos
         &#40;scale-photos config photo-width&#41;
         &#40;arrange-photos config&#41;
         &#40;map &#40;partial set-photo-styles! config&#41;&#41;
         doall&#41;&#41;&#41;

&#40;comment

  &#40;display-album! config photos&#41;
  ;; =&gt; &#40; ... &#41;

  &#41;
</code></pre><p>Now, let's have a quick look at the browser. Yeah, things look reasonably nice... until we scroll down. 😬</p><p>Let's compare what our album looks like to what it looks like on Flickr:</p><p><img src="assets/2024-01-22-compare.png" alt="A cartoon version of the Drake hotline bling meme: Drake is disgusted by our album and delighted by the Flickr UI" title="OMG my eeeeeyyyeees!" width=800px /></p><p>Oh good lord Paladine what in the actual abyss?! Our photos are cropped weirdly and apparently we forgot all about this:</p><p><strong>4. All photos in a row should be scaled to the same height</strong></p><p>Let the self-flagellation begin! Or maybe we just fix the height thing. Whichever.</p><h2 id="opting_for_the_latter_is_the_brave_thing_to_do">Opting for the latter is the brave thing to do</h2><p>We have a function called <code>scale-photos</code>, but what it's actually doing is setting the width. Scaling would be resizing both width and height, maintaining the same aspect ratio. Let's see if we can fix that:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn scale-photos &#91;&#95;config scaling-factor photos&#93;
  &#40;-&gt;&gt; photos
       &#40;map &#40;fn &#91;photo&#93;
              &#40;-&gt; photo
                  &#40;update :height / scaling-factor&#41;
                  &#40;update :width / scaling-factor&#41;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>So if we want to scale each photo down to half size, we'd do this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; photos
       &#40;scale-photos config 2&#41;
       &#40;map &#40;juxt :width :height&#41;&#41;&#41;
  ;; =&gt; &#40;&#91;512 288&#93; &#91;512 288&#93; &#91;512 256&#93; &#91;512 288&#93; &#91;512 288&#93; &#91;361 511.5&#93; &#91;333 500&#93; &#91;511.5 149.5&#93;&#41;

  &#40;-&gt;&gt; photos
       &#40;map &#40;juxt :width :height&#41;&#41;&#41;
  ;; =&gt; &#40;&#91;1024 576&#93; &#91;1024 576&#93; &#91;1024 512&#93; &#91;1024 576&#93; &#91;1024 576&#93; &#91;722 1023&#93; &#91;666 1000&#93; &#91;1023 299&#93;&#41;
  
  &#41;
</code></pre><p>That looks pretty good. Now, how to compute the scaling factor? Let's say we have a row of three photos, with a total width of 3072, and an album width of 1210.4:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; photos
       &#40;take 3&#41;
       &#40;map :width&#41;
       &#40;reduce +&#41;&#41;
  ;; =&gt; 3072

  &#40;provisional-album-width config&#41;
  ;; =&gt; 1210.4

  &#41;
</code></pre><p>To get the scaling factor, we just need to divide the total width of the photos by the width of the album, taking padding into consideration, of course:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;{:keys &#91;num-photos-per-row photo-padding&#93;} config&#93;
    &#40;-&gt; &#40;-&gt;&gt; photos
             &#40;take num-photos-per-row&#41;
             &#40;map :width&#41;
             &#40;reduce +&#41;&#41;
        &#40;/ &#40;- &#40;provisional-album-width config&#41;
              &#40;&#42; photo-padding &#40;dec num-photos-per-row&#41;&#41;&#41;&#41;&#41;&#41;
  ;; =&gt; 2.5548902195608783

  &#41;
</code></pre><p>This has just revealed something interesting that we should have noticed far earlier: the total width of the photos in a row varies, unless all of the photos just happen to be the same width (which is not the case for us):</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; photos
       &#40;partition-all 3&#41;
       &#40;map #&#40;reduce + &#40;map :width %&#41;&#41;&#41;&#41;
  ;; =&gt; &#40;3072 2770 1689&#41;

  &#41;
</code></pre><p>This means that we need to scale each row independently, which we can actually do right in <code>arrange-row</code>, as long as we provide album width in the config map. Let's write a function to compute the scale factor so as not to make a mess in <code>arrange-row</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-scale-factor &#91;{:keys &#91;album-width photo-padding&#93; :as config} photos&#93;
  &#40;let &#91;row-width &#40;-&gt;&gt; photos
                       &#40;map :width&#41;
                       &#40;reduce +&#41;&#41;
        available-width &#40;- album-width &#40;&#42; photo-padding &#40;dec &#40;count photos&#41;&#41;&#41;&#41;&#93;
    &#40;/ row-width available-width&#41;&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; photos
       &#40;partition-all 3&#41;
       &#40;map &#40;partial get-scale-factor &#40;assoc config :album-width 1210.4&#41;&#41;&#41;&#41;
  ;; =&gt; &#40;2.5548902195608783 2.303725881570193 1.4000331564986737&#41;

  &#41;
</code></pre><p>Now we can add the scaling to <code>arrange-row</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn arrange-row &#91;{:keys &#91;photo-padding&#93; :as config} photos&#93;
  &#40;let &#91;scale-factor &#40;get-scale-factor config photos&#41;&#93;
    &#40;-&gt;&gt; photos
         &#40;scale-photos config scale-factor&#41;
         &#40;reduce &#40;fn &#91;{:keys &#91;x-offset&#93; :as acc} {:keys &#91;width&#93; :as photo}&#93;
                   &#40;let &#91;new-x-offset &#40;+ x-offset width photo-padding&#41;&#93;
                     &#40;-&gt; acc
                         &#40;assoc :x-offset new-x-offset&#41;
                         &#40;update :arranged conj &#40;assoc photo :x-offset x-offset&#41;&#41;&#41;&#41;&#41;
                 {:x-offset 0, :arranged &#91;&#93;}&#41;
         :arranged&#41;&#41;&#41;
</code></pre><p>And finally we need to add the album width to the config map in <code>display-album!</code> and remove the call to <code>scale-photos</code>, since that's happening for each row now:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn display-album!
  &#91;{:keys &#91;num-photos-per-row photo-padding&#93; :as config} photos&#93;
  &#40;let &#91;album-width &#40;set-album-width! config&#41;
        config &#40;assoc config :album-width album-width&#41;
        padding-width &#40;&#42; photo-padding &#40;dec num-photos-per-row&#41;&#41;
        photo-width &#40;-&gt; &#40;- album-width padding-width&#41;
                        &#40;/ num-photos-per-row&#41;&#41;&#93;
    &#40;-&gt;&gt; photos
         &#40;arrange-photos config&#41;
         &#40;map &#40;partial set-photo-styles! config&#41;&#41;
         doall&#41;&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; &#40;display-album! config photos&#41;
       &#40;map #&#40;select-keys % &#91;:width :height :x-offset :y-offset&#93;&#41;&#41;&#41;
  ;; =&gt; &#40;{:width 400.8, :height 225.45, :x-offset 0, :y-offset 4}
  ;;     {:width 400.8, :height 225.45, :x-offset 404.8, :y-offset 4}
  ;;     {:width 400.8, :height 200.4, :x-offset 809.6, :y-offset 4}
  ;;     {:width 444.4973285198556, :height 250.0297472924188, :x-offset 0, :y-offset 584}
  ;;     {:width 444.4973285198556, :height 250.0297472924188, :x-offset 448.4973285198556, :y-offset 584}
  ;;     {:width 313.4053429602888, :height 444.0632490974729, :x-offset 896.9946570397112, :y-offset 584}
  ;;     {:width 475.7030195381883, :height 714.2687981053879, :x-offset 0, :y-offset 1164}
  ;;     {:width 730.6969804618118, :height 213.56637063351096, :x-offset 479.7030195381883, :y-offset 1164}&#41;

  &#41;
</code></pre><p>We're almost there now! The final missing piece is to normalise the height of the photos before scaling them for the row width. Let's write a function that scales down photos so they're all the same height as the shortest photo:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn normalise-height &#91;&#95;config photos&#93;
  &#40;let &#91;min-height &#40;apply min &#40;map :height photos&#41;&#41;&#93;
    &#40;-&gt;&gt; photos
         &#40;map &#40;fn &#91;{:keys &#91;height width&#93; :as photo}&#93;
                &#40;if &#40;&gt; height min-height&#41;
                  &#40;assoc photo
                         :height min-height
                         :width &#40;/ min-height &#40;/ height width&#41;&#41;&#41;
                  photo&#41;&#41;&#41;&#41;&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; photos
       &#40;take 3&#41;
       &#40;normalise-height config&#41;
       &#40;map &#40;juxt :width :height&#41;&#41;&#41;
  ;; =&gt; &#40;&#91;910.2222222222222 512&#93; &#91;910.2222222222222 512&#93; &#91;1024 512&#93;&#41;

  &#41;
</code></pre><p>Now we just need to plug this into <code>arrange-row</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn arrange-row &#91;{:keys &#91;photo-padding&#93; :as config} photos&#93;
  &#40;let &#91;normalised-photos &#40;normalise-height config photos&#41;
        scale-factor &#40;get-scale-factor config normalised-photos&#41;&#93;
    &#40;-&gt;&gt; normalised-photos
         &#40;scale-photos config scale-factor&#41;
         &#40;reduce &#40;fn &#91;{:keys &#91;x-offset&#93; :as acc} {:keys &#91;width&#93; :as photo}&#93;
                   &#40;let &#91;new-x-offset &#40;+ x-offset width photo-padding&#41;&#93;
                     &#40;-&gt; acc
                         &#40;assoc :x-offset new-x-offset&#41;
                         &#40;update :arranged conj &#40;assoc photo :x-offset x-offset&#41;&#41;&#41;&#41;&#41;
                 {:x-offset 0, :arranged &#91;&#93;}&#41;
         :arranged&#41;&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; &#40;display-album! config photos&#41;
       &#40;map #&#40;select-keys % &#91;:width :height :x-offset :y-offset&#93;&#41;&#41;&#41;
  ;; =&gt; &#40;{:width 384.76800000000003, :height 216.43200000000002, :x-offset 0, :y-offset 4}
  ;;     {:width 384.76800000000003, :height 216.43200000000002, :x-offset 388.76800000000003, :y-offset 4}
  ;;     {:width 432.86400000000003, :height 216.43200000000002, :x-offset 777.5360000000001, :y-offset 4}
  ;;     {:width 501.6282612020187, :height 282.16589692613553, :x-offset 0, :y-offset 224.43200000000002}
  ;;     {:width 501.6282612020187, :height 282.16589692613553, :x-offset 505.6282612020187, :y-offset 224.43200000000002}
  ;;     {:width 199.1434775959627, :height 282.16589692613553, :x-offset 1011.2565224040374, :y-offset 224.43200000000002}
  ;;     {:width 196.57030865682486, :height 295.1506135988361, :x-offset 0, :y-offset 510.59789692613555}
  ;;     {:width 1009.8296913431751, :height 295.1506135988361, :x-offset 200.57030865682486, :y-offset 510.59789692613555}&#41;

  &#41;
</code></pre><p>With great trepidation, we glance at our browser window... and see wonderful things!</p><p><img src="assets/2024-01-22-yay.png" alt="A cartoon version of the Drake hotline bling meme: Drake is delighted by our album and delighted by the Flickr UI" title="They keep callin' on my cellphone, my cellphone" width=800px /></p><p>I would actually argue that our album is more aesthetically pleasing than the Flickr version, since it doesn't have a huge gap at the lower right-hand corner. 😍</p><h2 id="are_you_feeling_dynamic%3F">Are you feeling dynamic?</h2><p>As happy as we are with ourselves (and deservedly so), we still have three rules left to follow:</p><p><strong>5. As the window is resized, photos should be rescaled to fit nicely</strong></p><p><strong>6. If the window is too small to display three photos per row, display two per row instead, and expand the album to fill the width of the window</strong></p><p><strong>7. If the window is too small to display two photos per row, display one per row instead</strong></p><p>Rule #5 is fairly straightforward. If we create a function that will update the page by calling <code>display-album!</code> with the correct arguments, we can add an event handler to the browser <a href='https://developer.mozilla.org/en-US/docs/Web/API/Window'>window</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn update-page! &#91;&amp; &#95;&#93;
  &#40;display-album! config photos&#41;&#41;

&#40;.addEventListener js/window &quot;resize&quot; update-page!&#41;
&#40;update-page!&#41;
</code></pre><p>We'll also call <code>update-page!</code> when the page loads for the first time. If we evaluate the buffer, nothing seems to happen, but if we resize the window now, our photos rescale! 🎉</p><p>Now let's figure out how to handle rules #6 and #7. What we can do is define a list of minimum photo widths that will cause a reduction in the number of photos per row:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def config
  {:album-div-name &quot;album&quot;
   :album-min-width 900
   :album-width-pct 0.8
   :min-photo-widths &#91;275 240&#93;
   :num-photos-per-row 3
   :photo-padding 4}&#41;
</code></pre><p>We can then compute the number of photos per row by trying the default value, and if that results in the average photo width dipping below the first minimum width, reduce the photos per row by one and see if the new average width is smaller than the next minimum width, and so on. If you don't know what I mean, don't worry; this is probably easier to say in Clojure than in English:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-num-photos-per-row
  &#91;{:keys &#91;min-photo-widths num-photos-per-row&#93; :as config} album-width&#93;
  &#40;loop &#91;num-photos num-photos-per-row
         &#91;min-width &amp; min-widths&#93; min-photo-widths&#93;
    &#40;let &#91;avg-width &#40;/ album-width num-photos&#41;&#93;
      &#40;if &#40;or &#40;nil? min-width&#41;
              &#40;= 1 num-photos&#41;
              &#40;&gt;= avg-width min-width&#41;&#41;
        num-photos
        &#40;recur &#40;dec num-photos&#41; min-widths&#41;&#41;&#41;&#41;&#41;

&#40;comment

  &#40;get-num-photos-per-row config 1200&#41;
  ;; =&gt; 3

  &#40;get-num-photos-per-row config 800&#41;
  ;; =&gt; 2

  &#40;get-num-photos-per-row config 400&#41;
  ;; =&gt; 1

  &#40;get-num-photos-per-row config 100&#41;
  ;; =&gt; 1

  &#40;get-num-photos-per-row &#40;assoc config :num-photos-per-row 4&#41; 1600&#41;
  ;; =&gt; 4

  &#41;
</code></pre><p>Now if we hook this into <code>display-album!</code>, we should see the number of photos per row change as we resize the window:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn display-album!
  &#91;{:keys &#91;num-photos-per-row photo-padding&#93; :as config} photos&#93;
  &#40;let &#91;album-width &#40;set-album-width! config&#41;
        num-photos-per-row &#40;get-num-photos-per-row config album-width&#41;
        config &#40;assoc config
                      :album-width album-width
                      :num-photos-per-row num-photos-per-row&#41;
        padding-width &#40;&#42; photo-padding &#40;dec num-photos-per-row&#41;&#41;
        photo-width &#40;-&gt; &#40;- album-width padding-width&#41;
                        &#40;/ num-photos-per-row&#41;&#41;&#93;
    &#40;-&gt;&gt; photos
         &#40;arrange-photos config&#41;
         &#40;map &#40;partial set-photo-styles! config&#41;&#41;
         doall&#41;&#41;&#41;
</code></pre><p>Let's see:</p><p><img src="assets/2024-01-22-three.png" alt="The album with a window size of 906 and 3 photos per row" title="906 pixels gives us 3 photos per row" /></p><p><img src="assets/2024-01-22-two.png" alt="The album with a window size of 754 and 3 photos per row" title="754 pixels gives us 3 photos per row" /></p><p><img src="assets/2024-01-22-one.png" alt="The album with a window size of 451 and 3 photos per row" title="451 pixels gives us 3 photos per row" /></p><p>Pretty pretty pretty pretty cool.</p><p>And with that, let's declare victory! There are of course many features that could be added, such as:</p><ul><li>Making the "← Back to albums list" actually take you back to a list of albums  (which doesn't currently exist)</li><li>Clicking on a photo to display it full size</li><li>Displaying the photo title and description when mousing over it, like Flickr  does</li></ul><p>and so on and so forth. And who knows what the future will bring; there may yet be another post describing how to implement one or more of those things, but I think that 10,924 words is enough for one post, don't you?</p><p>Part 1: <a href='2024-01-17-clickr.html'>clickr, or a young man's Flickr clonejure</a></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2024-01-17-clickr.html</id>
    <link href="https://jmglov.net/blog/2024-01-17-clickr.html"/>
    <title>clickr, or a young man's Flickr clonejure</title>
    <updated>2024-01-17T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><img src="assets/2024-01-17-phoenix.jpg" alt="A space station" title="The Phoenix rises!" width=800px /></p><p>Way back when, before I got my first Macbook (the white 2006 model, so cool!) and thus had no iPhoto to keep all of my photos for me, I used some open source photo album thingy on Linux (it was so long ago that I don't even remember what it was called) on a server running under my desk with a fixed IP. Using this amazing setup, I could create photo albums and share them with family who wanted to keep up with my thrilling life. When we moved to Tokyo in 2005, I got rid of the Linux server (and the desk it was under and the apartment the desk was in) and brought only a laptop with me. When we first arrived, we didn't even have internet, so I borrowed wifi from someone in the next building who had helpfully eschewed a password, and I thought it would be a bit cheeky to locate that person and ask them to purchase a fixed IP so that I could run a webserver so I could share photo albums with my family.</p><p>Luckily for me, two people named Stewart Butterfield and Caterina Fake felt my pain and started a company called Ludicorp and tried to create a web-based massively multiplayer online game called Game Neverending, which they failed to do but made some pretty cool tools for assets which they then decided could be turned into an online photo sharing service which they named Flickr.</p><p>Which is a very long-winded way of saying—and unless you're very new to this blog, you will know that I am a very long-winded person—that I signed up for Flickr for my photo-sharing needs.</p><p>A few years later, I bought the aforementioned Macbook, and started using iPhoto, which was much nicer than copying photos from a memory card onto my hard drive and then uploading them to Flickr, so I got kinda lazy about creating albums on Flickr, until my son was born and my family started demanding to see pictures of him.</p><p>Flash forward to many years later, and now we all have smartphones and stuff (even my mom!), so we just take pictures which automatically go to iCloud and iPhoto and iDon'tKnowWhereElse and then share them with each other in Signal and Telegram and WhatsApp and 95 other smartphone messaging apps like normal people, so we don't really use Flickr any more. In fact, I only remember we have Flickr when they email me once a year to remind me they're about to charge my credit card another $100 or whatever.</p><p>My reluctance to pay for something that I don't use is exceeded only by my reluctance to spend time on manually migrating off that thing, so I didn't do anything about this until a couple of years ago, when I just so happened to get the annual renewal email right before my winter vacation started, so I was like "Oh, now I'll have some free time and I'll probably be bored so I should probably see if Flickr has an API that I could use to download my photos and put them onto S3 like the good lord intended."</p><p>Flickr did in fact have an API, and better yet, they had a <a href='https://github.com/boncey/Flickr4Java'>Java
client</a>, which I wrapped up in some Clojure and named <a href='https://github.com/jmglov/clickr'>clickr</a> because it's like Flickr in Clojure ha ha ha I'm so clever. I got the listing of albums and downloading of photos and uploading of them to S3 working pretty easily, but for some reason didn't actually back up the albums to S3 except for the first one, probably because I evaluated some code in the REPL to start the back up and then switched tabs to read a blog or something and then forgot what I was doing and closed my laptop or something similarly absentminded.</p><p>Flash forward to a couple of weeks ago, when I once again got the famous email from Flickr and remembered that I really should get around to backing up those albums so I can stop paying Flickr and pay AWS a few more cents a year to store them for me. Since I now hate Clojure and only love <a href='https://babashka.org/'>Babashka</a>, I decided that instead of going back to the Clojure wrapper around the Java library, I'd whip up a quick HTTP client in Babashka instead and then just use <a href='https://github.com/grzm/awyeah-api'>awyeah-api</a> for the S3-ing. I got a few hours into struggling with signing requests to get an OAuth access token which I could then get the user's (me) authorization to exchange for an access token—you know, <a href='https://www.flickr.com/services/api/auth.oauth.html'>as you do</a>—when I decided that my life was way to short for taking a URL such as</p><pre class="language-text"><code class="lang-text language-text">https://www.flickr.com/services/oauth/request&#95;token
?oauth&#95;nonce=89601180
&amp;oauth&#95;timestamp=1305583298
&amp;oauth&#95;consumer&#95;key=653e7a6ecc1d528c516cc8f92cf98611
&amp;oauth&#95;signature&#95;method=HMAC-SHA1
&amp;oauth&#95;version=1.0
&amp;oauth&#95;callback=http%3A%2F%2Fwww.example.com
</code></pre><p>and creating a base string such as</p><pre class="language-text"><code class="lang-text language-text">GET&amp;https%3A%2F%2Fwww.flickr.com%2Fservices%2Foauth%2Frequest&#95;token&amp;oauth&#95;callback%3Dhttp%253A%252F%252Fwww.example.com%26oauth&#95;consumer&#95;key%3D653e7a6ecc1d528c516cc8f92cf98611%26oauth&#95;nonce%3D95613465%26oauth&#95;signature&#95;method%3DHMAC-SHA1%26oauth&#95;timestamp%3D1305586162%26oauth&#95;version%3D1.0
</code></pre><p>and then generating the following signature</p><pre class="language-text"><code class="lang-text language-text">7w18YS2bONDPL%2FzgyzP5XTr5af4%3D
</code></pre><p>and then wondering why in the hell Flickr's API kept giving me a signature incorrect error when the damn signature was obviously correct because you know...</p><p><img src="assets/2024-01-17-flickr-oauth-flow.jpg" alt="A diagram showing the three step OAuth flow for Flickr" title="What in the actual what?" width=800px /></p><p>I rapidly came to the conclusion that I should retreat to the safe embrace of JVM Clojure and just let the Java client do all the nasty work of authenticating so I could do the fun work of Clojuring.</p><h2 id="onwards_and_upwards%21">Onwards and upwards!</h2><p>I cloned my trusty <a href='https://github.com/jmglov/clickr'>clickr</a> repo, then screamed as I saw a <code>project.clj</code> and realised that I didn't even have <a href='https://leiningen.org/'>Leiningen</a> installed because I roll with <a href='https://clojure.org/guides/deps_and_cli'>tools.deps</a> now and I can't remember any lein commands and OMG it's nearly 2024 (this was a few weeks ago, remember?) and I'd better generate a <code>deps.edn</code> like a normal person:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;src&quot; &quot;dev&quot;&#93;
 :deps {com.flickr4java/flickr4java {:mvn/version &quot;3.0.1&quot;}}}
</code></pre><p>Having done this, I could open <code>src/clickr/flickr.clj</code> and try and remember how to use some code I wrote a <del>million</del> couple years ago and didn't document in any way, shape, or form because I'm a bad boy and life's too short for documentation!</p><p>According to Flickr4Java:</p><blockquote><p> To use the API just construct an instance of the class  com.flickr4java.flickr.test.Flickr and request the interfaces which you need  to work with. </p></blockquote><pre class="language-java"><code class="lang-java language-java">String apiKey = &quot;YOUR&#95;API&#95;KEY&quot;;
String sharedSecret = &quot;YOUR&#95;SHARED&#95;SECRET&quot;;
Flickr f = new Flickr&#40;apiKey, sharedSecret, new REST&#40;&#41;&#41;;
</code></pre><p>OK, so we can start our namespace out by importing the Flickr client:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns clickr.flickr
  &#40;:import &#40;com.flickr4java.flickr Flickr
                                   REST&#41;&#41;&#41;
</code></pre><p>And then C-c M-j (<code>cider-jack-in-clj</code>) to start a REPL, and then C-c C-k (<code>cider-load-buffer</code>) to evaluate the buffer, then it seems like I need an API key and the corresponding secret, which I can grab from https://www.flickr.com/services/apps/by/jmglov. Lemme just drop that in a map for safe-keeping and do a little C-v f c e (<code>cider-pprint-eval-last-sexp-to-comment</code>, obv):</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def config {:api-key &quot;beefface5678910&quot;
               :secret &quot;facecafe1234&quot;}&#41;
  ;; =&gt; #'clickr.flickr/config

  &#41;
</code></pre><p>OK, so creating a client should be as simple as this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">  &#40;def flickr &#40;Flickr. &#40;:api-key config&#41; &#40;:secret config&#41; &#40;REST.&#41;&#41;&#41;
  ;; =&gt; #'clickr.flickr/flickr
</code></pre><p>And with this, it should be easy to figure out how to get a list of albums, right? Right?</p><h2 id="what_the_flickr%3F">What the Flickr?</h2><p>Popping over to <a href='https://www.flickr.com/services/api/'>Flickr's API reference</a>, I'll just do a quick search for album and... 0 occurrences? Wait what now?</p><p>OK, time to scan the page for something that looks album-ish. 🙄</p><p>Ah, <a href='https://www.flickr.com/services/api/flickr.photosets.getList.html'>photosets.getList</a> looks promising. <a href='https://github.com/search?q=repo%3Aboncey%2FFlickr4Java%20photoset&type=code'>Searching the Flickr4Java repo for
photoset</a> turns up a <a href='https://github.com/boncey/Flickr4Java/blob/e6b8b51f89c87260039485427ed0b7dbb1354eed/src/examples/java/Backup.java'>src/examples/java/Backup.java</a>, which looks quite helpful:</p><pre class="language-java"><code class="lang-java language-java">public class Backup {
    private final String nsid;
    private final Flickr flickr;
    private AuthStore authStore;

    public Backup&#40;String apiKey, String nsid, String sharedSecret, File authsDir&#41; throws FlickrException {
        flickr = new Flickr&#40;apiKey, sharedSecret, new REST&#40;&#41;&#41;;
        this.nsid = nsid;

        if &#40;authsDir != null&#41; {
            this.authStore = new FileAuthStore&#40;authsDir&#41;;
        }
    }

    // ...

    public static void main&#40;String&#91;&#93; args&#41; throws Exception {
        if &#40;args.length &lt; 4&#41; {
            System.out.println&#40;&quot;Usage: java &quot; + Backup.class.getName&#40;&#41; + &quot; api&#95;key nsid shared&#95;secret output&#95;dir&quot;&#41;;
            System.exit&#40;1&#41;;
        }
        Backup bf = new Backup&#40;args&#91;0&#93;, args&#91;1&#93;, args&#91;2&#93;, new File&#40;System.getProperty&#40;&quot;user.home&quot;&#41; + File.separatorChar + &quot;.flickrAuth&quot;&#41;&#41;;
        bf.doBackup&#40;new File&#40;args&#91;3&#93;&#41;&#41;;
    }
}
</code></pre><p>If we look at the <code>doBackup&#40;&#41;</code> method, it looks like there's some authorisation we need to do above and beyond the API key and secret, which aligns with all the Flickr API docs said about "every request must be signed OAuth blah blah blah":</p><pre class="language-java"><code class="lang-java language-java">RequestContext rc = RequestContext.getRequestContext&#40;&#41;;

if &#40;this.authStore != null&#41; {
    Auth auth = this.authStore.retrieve&#40;this.nsid&#41;;
    if &#40;auth == null&#41; {
        this.authorize&#40;&#41;;
    } else {
        rc.setAuth&#40;auth&#41;;
    }
}
</code></pre><p>Assuming we have no <code>Auth</code> object from the start, let's have a look at what the <code>authorize&#40;&#41;</code> method does:</p><pre class="language-java"><code class="lang-java language-java">private void authorize&#40;&#41; throws IOException, FlickrException {
    AuthInterface authInterface = flickr.getAuthInterface&#40;&#41;;
    OAuth1RequestToken requestToken = authInterface.getRequestToken&#40;&#41;;

    String url = authInterface.getAuthorizationUrl&#40;requestToken, Permission.READ&#41;;
    System.out.println&#40;&quot;Follow this URL to authorise yourself on Flickr&quot;&#41;;
    System.out.println&#40;url&#41;;
    System.out.println&#40;&quot;Paste in the token it gives you:&quot;&#41;;
    System.out.print&#40;&quot;&gt;&gt;&quot;&#41;;

    String tokenKey = new Scanner&#40;System.in&#41;.nextLine&#40;&#41;;

    OAuth1Token accessToken = authInterface.getAccessToken&#40;requestToken, tokenKey&#41;;

    Auth auth = authInterface.checkToken&#40;accessToken&#41;;
    RequestContext.getRequestContext&#40;&#41;.setAuth&#40;auth&#41;;
    this.authStore.store&#40;auth&#41;;
    System.out.println&#40;&quot;Thanks.  You probably will not have to do this every time.  Now starting backup.&quot;&#41;;
}
</code></pre><p>OK, translating this to Clojure is pretty straight-forward. I'll start with my usual approach when dealing with API clients of creating a "context" which contains my config, various clients, and any other data that I might need to pass around. Let's create an <code>init-client</code> function that takes my config as an argument and returns such a context:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn init-client &#91;{:keys &#91;api-key secret&#93; :as ctx}&#93;
  &#40;assoc ctx :flickr {:client &#40;Flickr. api-key secret &#40;REST.&#41;&#41;}&#41;&#41;
</code></pre><p>We can call that and get a context back containing the client:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def config {:api-key &quot;beefface5678910&quot;
               :secret &quot;facecafe1234&quot;}&#41;
  ;; =&gt; #'clickr.flickr/config

  &#40;init-client config&#41;
  ;; =&gt; {:api-key &quot;beefface5678910&quot;,
  ;;     :secret &quot;facecafe1234&quot;,
  ;;     :flickr
  ;;     {:client
  ;;      #object&#91;com.flickr4java.flickr.Flickr 0x12246439 &quot;com.flickr4java.flickr.Flickr@12246439&quot;&#93;}}

  &#41;
</code></pre><p>Now I have a client, but I need to perform the authorisation dance from the Java code. In the constructor of the Java class, they create a <code>FileAuthStore</code> in the <code>authsDir</code>, which is actually the directory called <code>output&#95;dir</code> in the <code>main&#40;&#41;</code> function. 🙄</p><p>Let's be a good citizen by creating this auth store in the user's home directory. In order to do this, let's add the awesome <a href='https://github.com/babashka/fs'>babashka.fs</a> library to our deps:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;src&quot; &quot;dev&quot;&#93;
 :deps {babashka/fs {:mvn/version &quot;0.4.19&quot;}
        com.flickr4java/flickr4java {:mvn/version &quot;3.0.1&quot;}}}
</code></pre><p>babashka.fs contains a bunch of really useful utility functions that work in both Babashka and JVM Clojure!</p><p>Tragically, after adding a new dep, I need to restart my REPL. It is possible to hotload dependencies into a running REPL process, which should have <a href='https://clojure.org/news/2023/04/14/clojure-1-12-alpha2'>gotten
easier in Clojure
1.12</a>, and of course borkdude has a <a href='https://github.com/borkdude/deps.add-lib'>deps.add-lib</a> project which should make it even easier, but I haven't used this stuff yet, so I'll just do the primitive thing and restart (swearing under my breath), of course.</p><p>Now that we have babashka.fs, creating a <code>FileAuthStore</code> in the <code>&#126;/.flickrAuth</code> directory is as simple as this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns clickr.flickr
  &#40;:require &#91;babashka.fs :as fs&#93;&#41;
  &#40;:import &#40;com.flickr4java.flickr Flickr
                                   REST&#41;
           &#40;com.flickr4java.flickr.util FileAuthStore&#41;&#41;&#41;

&#40;defn make-auth-store &#91;&#93;
  &#40;FileAuthStore. &#40;fs/file &#40;fs/home&#41; &quot;.flickrAuth&quot;&#41;&#41;&#41;
</code></pre><p>Let's create the auth store in <code>init-client</code> and add it to the context along with the Flickr client itself:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn init-client &#91;{:keys &#91;api-key secret&#93; :as ctx}&#93;
  &#40;let &#91;client &#40;Flickr. api-key secret &#40;REST.&#41;&#41;
        auth-store &#40;make-auth-store&#41;&#93;
    &#40;assoc ctx :flickr {:client client, :auth-store auth-store}&#41;&#41;&#41;
</code></pre><p>Now that we have a way to create an auth store, let's write an <code>authorise</code> function to exchange a request token for an access token:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns clickr.flickr
  &#40;:require &#91;babashka.fs :as fs&#93;&#41;
  &#40;:import &#40;com.flickr4java.flickr Flickr
                                   REST&#41;
           &#40;com.flickr4java.flickr.auth Permission&#41;
           &#40;com.flickr4java.flickr.util FileAuthStore&#41;&#41;&#41;

&#40;defn authorise &#91;{:keys &#91;flickr&#93; :as ctx}&#93;
  &#40;let &#91;{:keys &#91;client&#93;} flickr
        auth-interface &#40;.getAuthInterface client&#41;
        req-token &#40;.getRequestToken auth-interface&#41;
        url &#40;.getAuthorizationUrl auth-interface req-token Permission/READ&#41;&#93;
    &#40;update ctx :flickr assoc :url url, :req-token req-token&#41;&#41;&#41;
</code></pre><p>Let's try it out:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def ctx &#40;init-client config&#41;&#41;
  ;; =&gt; #'clickr.flickr/ctx

  &#40;authorise ctx&#41;
  ;; =&gt; {:api-key &quot;beefface5678910&quot;,
  ;;     :secret &quot;facecafe1234&quot;,
  ;;     :flickr
  ;;     {:client
  ;;      #object&#91;com.flickr4java.flickr.Flickr 0x12246439 &quot;com.flickr4java.flickr.Flickr@12246439&quot;&#93;
  ;;      :auth-store
  ;;      #object&#91;com.flickr4java.flickr.util.FileAuthStore 0x6b1f411f &quot;com.flickr4java.flickr.util.FileAuthStore@6b1f411f&quot;&#93;
  ;;      :url
  ;;      &quot;https://www.flickr.com/services/oauth/authorize?oauth&#95;token=72157720906512072-49cebfb020e7fb06&amp;perms=read&quot;
  ;;      :req-token
  ;;      #object&#91;com.github.scribejava.core.model.OAuth1RequestToken 0x2617e72c &quot;com.github.scribejava.core.model.OAuth1RequestToken@43e76617&quot;&#93;}}

  &#41;
</code></pre><p>If I visit that URL in a web browser where we're signed into Flickr, I'm prompted to authorise the app corresponding to the API key I previously created:</p><p><img src="assets/2024-01-17-flickr-prompt.png" alt="A prompt to authorize an app to my Flickr account" title="What could possibly go wrong?" width=800px /></p><p>If I click "OK, I'll authorize it", I get a code that I should "type into the application". Let's set that up by modifying the <code>authorise</code> function to accept a code, which it will use to exchange the request token for an access token, then store that access token in the auth store that we previously created.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns clickr.flickr
  &#40;:require &#91;babashka.fs :as fs&#93;&#41;
  &#40;:import &#40;com.flickr4java.flickr Flickr
                                   RequestContext
                                   REST&#41;
           &#40;com.flickr4java.flickr.auth Permission&#41;
           &#40;com.flickr4java.flickr.util FileAuthStore&#41;&#41;&#41;

&#40;defn authorise &#91;{:keys &#91;flickr&#93; :as ctx}&#93;
  &#40;let &#91;{:keys &#91;client auth-store req-token token-key&#93;} flickr
        auth-interface &#40;.getAuthInterface client&#41;&#93;
    &#40;if &#40;and req-token token-key&#41;
      &#40;let &#91;access-token &#40;.getAccessToken auth-interface req-token token-key&#41;
            auth &#40;.checkToken auth-interface access-token&#41;&#93;
        &#40;.setAuth &#40;RequestContext/getRequestContext&#41; auth&#41;
        &#40;.store auth-store auth&#41;
        &#40;update ctx :flickr dissoc :req-token :token-key :url&#41;&#41;
      &#40;let &#91;req-token &#40;.getRequestToken auth-interface&#41;
            url &#40;.getAuthorizationUrl auth-interface req-token Permission/READ&#41;&#93;
        &#40;update ctx :flickr assoc :url url, :req-token req-token&#41;&#41;&#41;&#41;&#41;
</code></pre><p>The idea here is that if you pass a request token and token code in the Flickr context, you want to exchange a request token for an access token, and if you don't, you want to obtain a request token and the URL to authorise it.</p><p>OK, let's give it a whirl:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def ctx &#40;authorise ctx&#41;&#41;
  ;; =&gt; #'clickr.flickr/ctx

  &#40;get-in ctx &#91;:flickr :url&#93;&#41;
  ;; =&gt; &quot;https://www.flickr.com/services/oauth/authorize?oauth&#95;token=72157720906550331-8cf736dfbfe34398&amp;perms=read&quot;

  &#40;authorise &#40;assoc-in ctx &#91;:flickr :token-key&#93; &quot;123-456-789&quot;&#41;&#41;
  ;; =&gt; {:api-key &quot;beefface5678910&quot;,
  ;;     :secret &quot;facecafe1234&quot;,
  ;;     :flickr
  ;;     {:client
  ;;      #object&#91;com.flickr4java.flickr.Flickr 0x67f9df60 &quot;com.flickr4java.flickr.Flickr@67f9df60&quot;&#93;,
  ;;      :auth-store
  ;;      #object&#91;com.flickr4java.flickr.util.FileAuthStore 0x237de92 &quot;com.flickr4java.flickr.util.FileAuthStore@237de92&quot;&#93;}}

  &#41;
</code></pre><p>In theory, we should now have a Flickr4Java request context with an access token, and that access token stored in our auth store. Let's check out what's in the auth store:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;.retrieveAll &#40;get-in ctx &#91;:flickr :auth-store&#93;&#41;&#41;
  ;; =&gt; &#91;#object&#91;com.flickr4java.flickr.auth.Auth 0x7737c4f2 &quot;com.flickr4java.flickr.auth.Auth@7737c4f2&quot;&#93;&#93;

  &#41;
</code></pre><p>Ooh, exciting!</p><p>If we cast our mind back to that Java code:</p><pre class="language-java"><code class="lang-java language-java">Auth auth = this.authStore.retrieve&#40;this.nsid&#41;;
if &#40;auth == null&#41; {
    this.authorize&#40;&#41;;
} else {
    rc.setAuth&#40;auth&#41;;
}
</code></pre><p>we see that the <code>authorize&#40;&#41;</code> method is only called if we don't have an access token (the <code>Auth</code> object). If we have one, we just stuff it in the request context and move on with our life. Let's make one final change to our <code>authorise</code> function to do the same thing:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn authorise &#91;{:keys &#91;flickr&#93; :as ctx}&#93;
  &#40;let &#91;{:keys &#91;client auth-store req-token token-key&#93;} flickr
        auth-interface &#40;.getAuthInterface client&#41;
        auth &#40;-&gt; auth-store .retrieveAll first&#41;&#93;
    &#40;cond
      ;; We have a valid access token from the auth store
      auth
      &#40;do
        &#40;.setAuth &#40;RequestContext/getRequestContext&#41; auth&#41;
        &#40;update ctx :flickr assoc :auth auth&#41;&#41;

      ;; We have a request token and a token key, so exchange the request
      ;; token for an access token
      &#40;and req-token token-key&#41;
      &#40;let &#91;access-token &#40;.getAccessToken auth-interface req-token token-key&#41;
            auth &#40;.checkToken auth-interface access-token&#41;&#93;
        &#40;.setAuth &#40;RequestContext/getRequestContext&#41; auth&#41;
        &#40;.store auth-store auth&#41;
        &#40;update ctx :flickr dissoc :req-token :token-key :url&#41;&#41;

      ;; We don't have any tokens, so grab a request token and the URL to
      ;; authorise it
      :default
      &#40;let &#91;req-token &#40;.getRequestToken auth-interface&#41;
            url &#40;.getAuthorizationUrl auth-interface req-token Permission/READ&#41;&#93;
        &#40;update ctx :flickr assoc :url url, :req-token req-token&#41;&#41;&#41;&#41;&#41;
</code></pre><p>We can see if it works by creating a brand new client and calling <code>authorise</code> on it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt; &#40;init-client config&#41; authorise&#41;
  ;; =&gt; {:api-key &quot;beefface5678910&quot;,
  ;;     :secret &quot;facecafe1234&quot;,
  ;;     :flickr
  ;;     {:client
  ;;      #object&#91;com.flickr4java.flickr.Flickr 0x15c343a9 &quot;com.flickr4java.flickr.Flickr@15c343a9&quot;&#93;,
  ;;      :auth-store
  ;;      #object&#91;com.flickr4java.flickr.util.FileAuthStore 0x6df7251f &quot;com.flickr4java.flickr.util.FileAuthStore@6df7251f&quot;&#93;
  ;;      :auth
  ;;      #object&#91;com.flickr4java.flickr.auth.Auth 0x4d1d0eb0 &quot;com.flickr4java.flickr.auth.Auth@4d1d0eb0&quot;&#93;}}

  &#41;
</code></pre><p>That calls for a celebration!</p><p><img src="assets/2023-11-11-victory.jpg" alt="A woman on a beach at sunrise with her head thrown back, saying "Victory"" title="Victory!" width=800px] /></p><p>Given that you only need to go through the rigmarole of exchanging a request token for an auth token once, I feel like it's reasonable to have <code>init-client</code> go ahead and authorise the client. This will work just fine for the rigmarole case as well, since you can just call <code>authorise</code> on the client yourself if it has a <code>:url</code> key set, so there's no harm in doing this.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn init-client &#91;{:keys &#91;api-key secret&#93; :as ctx}&#93;
  &#40;let &#91;client &#40;Flickr. api-key secret &#40;REST.&#41;&#41;
        auth-store &#40;make-auth-store&#41;&#93;
    &#40;-&gt; ctx
        &#40;assoc :flickr {:client client, :auth-store auth-store}&#41;
        authorise&#41;&#41;&#41;
</code></pre><h2 id="ok%2C_so_what_about_those_albums_again%3F">OK, so what about those albums again?</h2><p>Before we got sidetracked by all of this annoying security stuff, the actual goal was to get a list of my photo albums, which we learned are apparently called "photosets" in the Flickr API. Let's have a look at how the example Java code deals with them:</p><pre class="language-java"><code class="lang-java language-java">PhotosetsInterface pi = flickr.getPhotosetsInterface&#40;&#41;;
Iterator sets = pi.getList&#40;this.nsid&#41;.getPhotosets&#40;&#41;.iterator&#40;&#41;;
</code></pre><p>The <code>nsid</code> thingy turns out to just be my Flickr user ID, which I can grab from the <code>Auth</code> object. Let's grab an album and see what it's all about!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def album
    &#40;let &#91;ps-interface &#40;.getPhotosetsInterface &#40;get-in ctx &#91;:flickr :client&#93;&#41;&#41;
          user-id &#40;.. &#40;get-in ctx &#91;:flickr :auth&#93;&#41; getUser getId&#41;&#93;
      &#40;-&gt; ps-interface &#40;.getList user-id&#41; .getPhotosets first&#41;&#41;&#41;
  ;; =&gt; #'clickr.flickr/album

  album
  ;; =&gt; #object&#91;com.flickr4java.flickr.photosets.Photoset 0x726a87d4 &quot;com.flickr4java.flickr.photosets.Photoset@726a87d4&quot;&#93;

  &#41;
</code></pre><p>Cool, I guess. Looking at the Flickr API reference for <a href='https://www.flickr.com/services/api/flickr.photosets.getList.html'>photosets.getList</a>, we can see an example XML response:</p><pre class="language-xml"><code class="lang-xml language-xml">&lt;photosets page=&quot;1&quot; pages=&quot;1&quot; perpage=&quot;30&quot; total=&quot;2&quot; cancreate=&quot;1&quot;&gt;
  &lt;photoset id=&quot;72157626216528324&quot; primary=&quot;5504567858&quot; secret=&quot;017804c585&quot; server=&quot;5174&quot; farm=&quot;6&quot; photos=&quot;22&quot; videos=&quot;0&quot; count&#95;views=&quot;137&quot; count&#95;comments=&quot;0&quot; can&#95;comment=&quot;1&quot; date&#95;create=&quot;1299514498&quot; date&#95;update=&quot;1300335009&quot;&gt;
    &lt;title&gt;Avis Blanche&lt;/title&gt;
    &lt;description&gt;My Grandma's Recipe File.&lt;/description&gt;
  &lt;/photoset&gt;
  &lt;photoset id=&quot;72157624618609504&quot; primary=&quot;4847770787&quot; secret=&quot;6abd09a292&quot; server=&quot;4153&quot; farm=&quot;5&quot; photos=&quot;43&quot; videos=&quot;12&quot; count&#95;views=&quot;523&quot; count&#95;comments=&quot;1&quot; can&#95;comment=&quot;1&quot; date&#95;create=&quot;1280530593&quot; date&#95;update=&quot;1308091378&quot;&gt;
    &lt;title&gt;Mah Kittehs&lt;/title&gt;
    &lt;description&gt;Sixty and Niner. Born on the 3rd of May, 2010, or thereabouts. Came to my place on Thursday, July 29, 2010.&lt;/description&gt;
  &lt;/photoset&gt;
&lt;/photosets&gt;
</code></pre><p>Let's make some assumptions about what getters the <code>Photoset</code> class has and see if we can find out anything interesting about our album:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;.getId album&#41;
  ;; =&gt; &quot;72177720314024335&quot;

  &#40;.getTitle album&#41;
  ;; =&gt; &quot;clickr demo&quot;

  &#40;.getDescription album&#41;
  ;; =&gt; &quot;Photo album demo for my clickr blog post&quot;

  &#41;
</code></pre><p>Nice! Given this, let's write a function that turns a <code>Photoset</code> into a plain old map (or POM—that's what that means, right?):</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn -&gt;album &#91;photoset&#93;
  {:id &#40;.getId photoset&#41;
   :title &#40;.getTitle photoset&#41;
   :description &#40;.getDescription photoset&#41;
   :object photoset}&#41;

&#40;comment

  &#40;-&gt;album album&#41;
  ;; =&gt; {:id &quot;72177720314024335&quot;,
  ;;     :title &quot;clickr demo&quot;,
  ;;     :description &quot;Photo album demo for my clickr blog post&quot;,
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photosets.Photoset 0x726a87d4 &quot;com.flickr4java.flickr.photosets.Photoset@726a87d4&quot;&#93;}

  &#41;
</code></pre><p>And now that we have this, why not a function that grabs all of my albums?</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-albums &#91;{:keys &#91;flickr&#93; :as ctx}&#93;
  &#40;let &#91;user-id &#40;.. &#40;:auth flickr&#41; getUser getId&#41;&#93;
    &#40;-&gt;&gt; &#40;-&gt; &#40;:client flickr&#41;
             &#40;.getPhotosetsInterface&#41;
             &#40;.getList user-id&#41;
             &#40;.getPhotosets&#41;&#41;
         &#40;map -&gt;album&#41;&#41;&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; &#40;get-albums ctx&#41;
       count&#41;
  ;; =&gt; 165

  &#40;-&gt;&gt; &#40;get-albums ctx&#41;
       &#40;take 2&#41;&#41;
  ;; =&gt; &#40;{:id &quot;72177720314024335&quot;,
  ;;      :title &quot;clickr demo&quot;,
  ;;      :description &quot;Photo album demo for my clickr blog post&quot;,
  ;;      :object
  ;;      #object&#91;com.flickr4java.flickr.photosets.Photoset 0x9d8b7ae &quot;com.flickr4java.flickr.photosets.Photoset@9d8b7ae&quot;&#93;}
  ;;     {:id &quot;72157706528674711&quot;,
  ;;      :title &quot;Kai's 8th Birthday&quot;,
  ;;      :description
  ;;      &quot;April 2015. Kaiche's first year at SIS. Treating his classmates to muffins in class. Sushi and cake with mommy and daddy. &quot;,
  ;;      :object
  ;;      #object&#91;com.flickr4java.flickr.photosets.Photoset 0x1bb741fe &quot;com.flickr4java.flickr.photosets.Photoset@1bb741fe&quot;&#93;}&#41;

  &#41;
</code></pre><p>Alright, it looks like I have quite a few albums to deal with here. But an album should contain some photos, right? Let's see if we can figure out how to grab them.</p><p>In Java, that apparently looks like this:</p><pre class="language-java"><code class="lang-java language-java">Photoset set = &#40;Photoset&#41; sets.next&#40;&#41;;
PhotoList photos = pi.getPhotos&#40;set.getId&#40;&#41;, 500, 1&#41;;
</code></pre><p>Let's try that in Clojure:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def album &#40;-&gt; &#40;get-albums ctx&#41; first&#41;&#41;
  ;; =&gt; #'clickr.flickr/album

  &#40;def ps-interface &#40;.getPhotosetsInterface &#40;get-in ctx &#91;:flickr :client&#93;&#41;&#41;&#41;
  ;; =&gt; #'clickr.flickr/ps-interface

  &#40;def photos &#40;.getPhotos ps-interface &#40;:id album&#41; 500 1&#41;&#41;
  ;; =&gt; #'clickr.flickr/photos

  &#40;count photos&#41;
  ;; =&gt; 8

  &#40;first photos&#41;
  ;; =&gt; #object&#91;com.flickr4java.flickr.photos.Photo 0xd254585 &quot;com.flickr4java.flickr.photos.Photo@14ea992b&quot;&#93;
  &#41;
</code></pre><p>According to the Flickr API documentation for <a href='https://www.flickr.com/services/api/flickr.photosets.getPhotos.html'>photosets.getPhotos</a>, the <code>500</code> and the <code>1</code> are the number of photos to return per page and the page number, both of which are the default values. Seems fine.</p><p>The docs are a little sparse on what's in a photo, though:</p><pre class="language-xml"><code class="lang-xml language-xml">&lt;photoset id=&quot;4&quot; primary=&quot;2483&quot; page=&quot;1&quot; perpage=&quot;500&quot; pages=&quot;1&quot; total=&quot;2&quot;&gt;
  &lt;photo id=&quot;2484&quot; secret=&quot;123456&quot; server=&quot;1&quot; title=&quot;my photo&quot; isprimary=&quot;0&quot; /&gt;
  &lt;photo id=&quot;2483&quot; secret=&quot;123456&quot; server=&quot;1&quot; title=&quot;flickr rocks&quot; isprimary=&quot;1&quot; /&gt;
&lt;/photoset&gt;
</code></pre><p>Let's ask the JVM what getters the photo class has:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">  &#40;def photo &#40;first photos&#41;&#41;
  ;; =&gt; #'clickr.flickr/photo

  &#40;-&gt;&gt; photo class .getDeclaredMethods
       &#40;map #&#40;.getName %&#41;&#41;
       &#40;filter #&#40;str/starts-with? % &quot;get&quot;&#41;&#41;
       sort&#41;
  ;; =&gt; &#40;&quot;getBaseImageUrl&quot;
  ;;     &quot;getComments&quot;
  ;;     &quot;getCountry&quot;
  ;;     &quot;getCounty&quot;
  ;;     &quot;getDateAdded&quot;
  ;;     &quot;getDatePosted&quot;
  ;;     &quot;getDateTaken&quot;
  ;;     &quot;getDescription&quot;
  ;;     &quot;getEditability&quot;
  ;;     &quot;getFarm&quot;
  ;;     &quot;getGeoData&quot;
  ;;     &quot;getHdMp4&quot;
  ;;     &quot;getHdMp4Url&quot;
  ;;     &quot;getIconFarm&quot;
  ;;     &quot;getIconServer&quot;
  ;;     &quot;getId&quot;
  ;;     &quot;getImage&quot;
  ;;     &quot;getImageAsStream&quot;
  ;;     &quot;getLarge1600Size&quot;
  ;;     &quot;getLarge1600Url&quot;
  ;;     &quot;getLarge2048Size&quot;
  ;;     &quot;getLarge2048Url&quot;
  ;;     &quot;getLargeAsStream&quot;
  ;;     &quot;getLargeImage&quot;
  ;;     &quot;getLargeSize&quot;
  ;;     &quot;getLargeUrl&quot;
  ;;     &quot;getLastUpdate&quot;
  ;;     &quot;getLicense&quot;
  ;;     &quot;getLocality&quot;
  ;;     &quot;getMedia&quot;
  ;;     &quot;getMediaStatus&quot;
  ;;     &quot;getMedium640Size&quot;
  ;;     &quot;getMedium640Url&quot;
  ;;     &quot;getMedium800Size&quot;
  ;;     &quot;getMedium800Url&quot;
  ;;     &quot;getMediumAsStream&quot;
  ;;     &quot;getMediumImage&quot;
  ;;     &quot;getMediumSize&quot;
  ;;     &quot;getMediumUrl&quot;
  ;;     &quot;getMobileMp4&quot;
  ;;     &quot;getMobileMp4Url&quot;
  ;;     &quot;getNotes&quot;
  ;;     &quot;getOriginalAsStream&quot;
  ;;     &quot;getOriginalBaseImageUrl&quot;
  ;;     &quot;getOriginalFormat&quot;
  ;;     &quot;getOriginalHeight&quot;
  ;;     &quot;getOriginalImage&quot;
  ;;     &quot;getOriginalImage&quot;
  ;;     &quot;getOriginalImageAsStream&quot;
  ;;     &quot;getOriginalSecret&quot;
  ;;     &quot;getOriginalSize&quot;
  ;;     &quot;getOriginalUrl&quot;
  ;;     &quot;getOriginalWidth&quot;
  ;;     &quot;getOwner&quot;
  ;;     &quot;getPathAlias&quot;
  ;;     &quot;getPermissions&quot;
  ;;     &quot;getPhotoUrl&quot;
  ;;     &quot;getPlaceId&quot;
  ;;     &quot;getPublicEditability&quot;
  ;;     &quot;getRegion&quot;
  ;;     &quot;getRotation&quot;
  ;;     &quot;getSecret&quot;
  ;;     &quot;getServer&quot;
  ;;     &quot;getSiteMP4Size&quot;
  ;;     &quot;getSiteMP4Url&quot;
  ;;     &quot;getSizes&quot;
  ;;     &quot;getSmall320Size&quot;
  ;;     &quot;getSmall320Url&quot;
  ;;     &quot;getSmallAsInputStream&quot;
  ;;     &quot;getSmallImage&quot;
  ;;     &quot;getSmallSize&quot;
  ;;     &quot;getSmallSquareAsInputStream&quot;
  ;;     &quot;getSmallSquareImage&quot;
  ;;     &quot;getSmallSquareUrl&quot;
  ;;     &quot;getSmallUrl&quot;
  ;;     &quot;getSquareLargeSize&quot;
  ;;     &quot;getSquareLargeUrl&quot;
  ;;     &quot;getSquareSize&quot;
  ;;     &quot;getStats&quot;
  ;;     &quot;getTags&quot;
  ;;     &quot;getTakenGranularity&quot;
  ;;     &quot;getThumbnailAsInputStream&quot;
  ;;     &quot;getThumbnailImage&quot;
  ;;     &quot;getThumbnailSize&quot;
  ;;     &quot;getThumbnailUrl&quot;
  ;;     &quot;getTitle&quot;
  ;;     &quot;getUrl&quot;
  ;;     &quot;getUrls&quot;
  ;;     &quot;getUsage&quot;
  ;;     &quot;getVideoOriginalSize&quot;
  ;;     &quot;getVideoOriginalUrl&quot;
  ;;     &quot;getVideoPlayerSize&quot;
  ;;     &quot;getVideoPlayerUrl&quot;
  ;;     &quot;getViews&quot;&#41;
</code></pre><p>Let's write a <code>-&gt;photo</code> function to POM-ify this suckah!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn -&gt;photo &#91;photo&#93;
  {:id &#40;.getId photo&#41;
   :title &#40;.getTitle photo&#41;
   :description &#40;.getDescription photo&#41;
   :date-taken &#40;.getDateTaken photo&#41;
   :width &#40;.getOriginalWidth photo&#41;
   :height &#40;.getOriginalHeight photo&#41;
   :geo-data &#40;.getGeoData photo&#41;
   :rotation &#40;.getRotation photo&#41;
   :object photo}&#41;

&#40;comment

  &#40;-&gt;photo photo&#41;
  ;; =&gt; {:description nil,
  ;;     :date-taken nil,
  ;;     :geo-data nil,
  ;;     :rotation -1,
  ;;     :width 0,
  ;;     :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;,
  ;;     :id &quot;53460147147&quot;,
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photos.Photo 0xd254585 &quot;com.flickr4java.flickr.photos.Photo@14ea992b&quot;&#93;,
  ;;     :height 0}

  &#41;
</code></pre><p>And now that we have this, let's add the photos to the the album map:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn -&gt;album &#91;{:keys &#91;flickr&#93; :as ctx} photoset&#93;
  &#40;let &#91;ps-interface &#40;.getPhotosetsInterface &#40;:client flickr&#41;&#41;&#93;
    {:id &#40;.getId photoset&#41;
     :title &#40;.getTitle photoset&#41;
     :description &#40;.getDescription photoset&#41;
     :photos &#40;-&gt;&gt; &#40;.getPhotos ps-interface &#40;:id album&#41; 500 1&#41;
                  &#40;map &#40;partial -&gt;photo ctx&#41;&#41;&#41;
     :object photoset}&#41;&#41;
</code></pre><p>Note that in order to list the photos in an album, we need the <code>PhotosetInterface</code>, which we get from the Flickr client in the context, so we need to add the context as an argument to <code>-&gt;album</code>. In fact, let's make it a convention that <code>ctx</code> is always the first argument to functions in this namespace, and add it to <code>-&gt;photo</code> as well:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn -&gt;photo &#91;&#95;ctx photo&#93;
  {:id &#40;.getId photo&#41;
   :title &#40;.getTitle photo&#41;
   :description &#40;.getDescription photo&#41;
   :date-taken &#40;.getDateTaken photo&#41;
   :width &#40;.getOriginalWidth photo&#41;
   :height &#40;.getOriginalHeight photo&#41;
   :geo-data &#40;.getGeoData photo&#41;
   :rotation &#40;.getRotation photo&#41;
   :object photo}&#41;
</code></pre><p>We don't actually need it in this function, so we'll prepend an underscore to the arg name so CIDER or LSP or clj-kondo or whatever tooling you're using won't yell at us that it's unused. This is a convention that I first discovered in Erlang, and I think it's a cool way to say that "this is the context, which we don't need here (yet)".</p><p>Finally, we need to pass the context along in <code>get-albums</code> as well:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-albums &#91;{:keys &#91;flickr&#93; :as ctx}&#93;
  &#40;let &#91;user-id &#40;.. &#40;:auth flickr&#41; getUser getId&#41;&#93;
    &#40;-&gt;&gt; &#40;-&gt; &#40;:client flickr&#41;
             &#40;.getPhotosetsInterface&#41;
             &#40;.getList user-id&#41;
             &#40;.getPhotosets&#41;&#41;
         &#40;map &#40;partial -&gt;album ctx&#41;&#41;&#41;&#41;&#41;
</code></pre><h2 id="making_off_with_the_goods">Making off with the goods</h2><p>Now that we have a way to list albums and the photos within them, let's see how to download said photos. Reading on in <code>Backup.java</code>, I encounter this good stuff:</p><pre class="language-java"><code class="lang-java language-java">Photo p = &#40;Photo&#41; setIterator.next&#40;&#41;;
String url = p.getLargeUrl&#40;&#41;;
URL u = new URL&#40;url&#41;;
String filename = u.getFile&#40;&#41;;
filename = filename.substring&#40;filename.lastIndexOf&#40;&quot;/&quot;&#41; + 1, filename.length&#40;&#41;&#41;;
System.out.println&#40;&quot;Now writing &quot; + filename + &quot; to &quot; + setDirectory.getCanonicalPath&#40;&#41;&#41;;
BufferedInputStream inStream = new BufferedInputStream&#40;photoInt.getImageAsStream&#40;p, Size.LARGE&#41;&#41;;
File newFile = new File&#40;setDirectory, filename&#41;;

FileOutputStream fos = new FileOutputStream&#40;newFile&#41;;

int read;

while &#40;&#40;read = inStream.read&#40;&#41;&#41; != -1&#41; {
    fos.write&#40;read&#41;;
}
fos.flush&#40;&#41;;
fos.close&#40;&#41;;
inStream.close&#40;&#41;;
</code></pre><p>That looks like one way to do it. 😬</p><p>Let's see if we can clean this up a little. This whole mess:</p><pre class="language-java"><code class="lang-java language-java">while &#40;&#40;read = inStream.read&#40;&#41;&#41; != -1&#41; {
    fos.write&#40;read&#41;;
}
</code></pre><p>can be replaced with <a href='https://clojuredocs.org/clojure.java.io/copy'>clojure.java.io/copy</a>, so let's update the <code>ns</code> form to require in <code>clojure.java.io</code>, and import the other Java classes mentioned in this code snippet whilst we're at it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns clickr.flickr
  &#40;:require &#91;babashka.fs :as fs&#93;
            &#91;clojure.java.io :as io&#93;&#41;
  &#40;:import &#40;com.flickr4java.flickr Flickr
                                   RequestContext
                                   REST&#41;
           &#40;com.flickr4java.flickr.auth Permission&#41;
           &#40;com.flickr4java.flickr.photos Size&#41;
           &#40;com.flickr4java.flickr.util FileAuthStore&#41;
           &#40;java.io BufferedInputStream
                    FileOutputStream&#41;&#41;&#41;
</code></pre><p>I also find this stuff to get the filename of the photo to be a bit annoying:</p><pre class="language-java"><code class="lang-java language-java">String url = p.getLargeUrl&#40;&#41;;
URL u = new URL&#40;url&#41;;
String filename = u.getFile&#40;&#41;;
filename = filename.substring&#40;filename.lastIndexOf&#40;&quot;/&quot;&#41; + 1, filename.length&#40;&#41;&#41;;
</code></pre><p>Let's make the filename the photo's ID plus its format. We can do this in our <code>-&gt;photo</code> function like so:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn -&gt;photo &#91;&#95; photo&#93;
  &#40;let &#91;id &#40;.getId photo&#41;
        extension &#40;.getOriginalFormat photo&#41;
        filename &#40;format &quot;%s.%s&quot; id extension&#41;&#93;
    {:id id
     :filename filename
     :title &#40;.getTitle photo&#41;
     :description &#40;.getDescription photo&#41;
     :date-taken &#40;.getDateTaken photo&#41;
     :width &#40;.getOriginalWidth photo&#41;
     :height &#40;.getOriginalHeight photo&#41;
     :geo-data &#40;.getGeoData photo&#41;
     :rotation &#40;.getRotation photo&#41;
     :object photo}&#41;&#41;
</code></pre><p>Now we can write a function to download a photo nicely. To be good citizens, let's put the file in the tmp directory (<code>/tmp</code> on Linux and MacOS, who knows where on Windows). Luckily for us, babashka.fs has a handy <code>temp-dir</code> function that can do this! 🎉</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn download-photo! &#91;{:keys &#91;flickr&#93; :as ctx}
                       {:keys &#91;filename&#93; :as photo}&#93;
  &#40;let &#91;p-interface &#40;.getPhotosInterface &#40;:client flickr&#41;&#41;&#93;
    &#40;with-open &#91;in &#40;BufferedInputStream. &#40;.getImageAsStream p-interface &#40;:object photo&#41; Size/LARGE&#41;&#41;
                out &#40;FileOutputStream. &#40;fs/file &#40;fs/temp-dir&#41; filename&#41;&#41;&#93;
      &#40;io/copy in out&#41;&#41;&#41;&#41;
</code></pre><p>Now let's try calling it and seeing if it works!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def album &#40;-&gt; &#40;get-albums ctx&#41; first&#41;&#41;
  ;; =&gt; #'clickr.flickr/album

  &#40;def photo &#40;-&gt; album :photos first&#41;&#41;
  ;; =&gt; #'clickr.flickr/photo

  photo
  ;; =&gt; {:description nil,
  ;;     :date-taken nil,
  ;;     :geo-data nil,
  ;;     :rotation -1,
  ;;     :width 0,
  ;;     :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;,
  ;;     :filename &quot;53460147147.jpg&quot;,
  ;;     :id &quot;53460147147&quot;,
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photos.Photo 0x5d288375 &quot;com.flickr4java.flickr.photos.Photo@14ea992b&quot;&#93;,
  ;;     :height 0}

  &#40;download-photo! ctx photo&#41;
  ;; =&gt; nil

  &#40;fs/exists? &#40;fs/file &#40;fs/temp-dir&#41; &#40;:filename photo&#41;&#41;&#41;
  ;; =&gt; true

  &#41;
</code></pre><p>Sure enough, we now have a <code>/tmp/53460147147.jpg</code> file!</p><p><img src="assets/2024-01-17-phoenix.jpg" alt="A space station" title="The Phoenix rises!" width=800px /></p><h2 id="backing_things_up">Backing things up</h2><p>Now that we've proven that we can download a photo, let's think about how we want the backup process to work. What we probably want is to create a "folder" in our S3 bucket for each album, and then put all of the photos for that album inside it. Let's write a function to download an entire album.</p><p>The first order of business is to create a directory to hold the photos in the album that we're about to download. Let's use the album ID as the name of the directory and drop it in the tmp directory:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;fs/file &#40;fs/temp-dir&#41; &#40;:id album&#41;&#41;
  ;; =&gt; #object&#91;java.io.File 0x4e8e72c2 &quot;/tmp/72177720314024335&quot;&#93;

  &#41;
</code></pre><p>We can now use <code>create-dirs</code> to create the directory:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">
&#40;comment

  &#40;-&gt;&gt; &#40;fs/file &#40;fs/temp-dir&#41; &#40;:id album&#41;&#41; fs/create-dirs&#41;
  ;; =&gt; #object&#91;sun.nio.fs.UnixPath 0x1fbb7d68 &quot;/tmp/72177720314024335&quot;&#93;

  &#41;
</code></pre><p>OK, so we have a directory to hold the photos, but we're going to have to update <code>download-photo!</code> to use that directory instead of dropping stuff straight into <code>/tmp</code>. Easy enough:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn download-photo! &#91;{:keys &#91;flickr out-dir&#93; :as ctx}
                       {:keys &#91;filename&#93; :as photo}&#93;
  &#40;let &#91;p-interface &#40;.getPhotosInterface &#40;:client flickr&#41;&#41;&#93;
    &#40;with-open &#91;in &#40;BufferedInputStream. &#40;.getImageAsStream p-interface &#40;:object photo&#41; Size/LARGE&#41;&#41;
                out &#40;FileOutputStream. &#40;fs/file out-dir filename&#41;&#41;&#93;
      &#40;io/copy in out&#41;&#41;&#41;&#41;

&#40;comment

  &#40;def album-dir &#40;-&gt;&gt; &#40;fs/file &#40;fs/temp-dir&#41; &#40;:id album&#41;&#41; fs/create-dirs&#41;&#41;
  ;; =&gt; #'clickr.flickr/album-dir

  &#40;download-photo! &#40;assoc ctx :out-dir album-dir&#41; photo&#41;
  ;; =&gt; nil

  &#40;fs/exists? &#40;fs/file album-dir &#40;format &quot;%s.jpg&quot; &#40;:id photo&#41;&#41;&#41;&#41;
  ;; =&gt; true

  &#41;
</code></pre><p>Having thus tamed <code>download-photo!</code>, we can write <code>download-album!</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn download-album! &#91;ctx {:keys &#91;id photos&#93; :as album}&#93;
  &#40;let &#91;album-dir &#40;-&gt;&gt; id &#40;fs/file &#40;fs/temp-dir&#41;&#41; fs/create-dirs fs/file&#41;&#93;
    &#40;-&gt;&gt; photos
         &#40;map &#40;partial download-photo! &#40;assoc ctx :out-dir album-dir&#41;&#41;&#41;
         doall&#41;&#41;&#41;

&#40;comment

  &#40;download-album! ctx album&#41;
  ;; =&gt; &#40;nil nil nil nil nil nil nil nil&#41;

  &#40;fs/glob album-dir &quot;&#42;&quot;&#41;
  ;; =&gt; &#91;#object&#91;sun.nio.fs.UnixPath 0x72466b2f &quot;/tmp/72177720314024335/53461405604.jpg&quot;&#93;
  ;;     #object&#91;sun.nio.fs.UnixPath 0x788d40e5 &quot;/tmp/72177720314024335/53460163402.jpg&quot;&#93;
  ;;     #object&#91;sun.nio.fs.UnixPath 0x26f7ff12 &quot;/tmp/72177720314024335/53460161007.jpg&quot;&#93;
  ;;     #object&#91;sun.nio.fs.UnixPath 0x250e8e92 &quot;/tmp/72177720314024335/53460147147.jpg&quot;&#93;
  ;;     #object&#91;sun.nio.fs.UnixPath 0x17fa6f15 &quot;/tmp/72177720314024335/53461214223.jpg&quot;&#93;
  ;;     #object&#91;sun.nio.fs.UnixPath 0x5e28f67 &quot;/tmp/72177720314024335/53461091151.jpg&quot;&#93;
  ;;     #object&#91;sun.nio.fs.UnixPath 0x1cf4ffa6 &quot;/tmp/72177720314024335/53460151727.jpg&quot;&#93;
  ;;     #object&#91;sun.nio.fs.UnixPath 0x4ddf88b4 &quot;/tmp/72177720314024335/53461088046.jpg&quot;&#93;&#93;

  &#41;
</code></pre><p>Fantastic! Though I have to admit that I don't find the list of <code>nil</code>s very friendly. Let's make one last change to <code>download-photo</code> so that it returns the photo, with the location of the downloaded file added to it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn download-photo! &#91;{:keys &#91;flickr out-dir&#93; :as ctx}
                       {:keys &#91;filename&#93; :as photo}&#93;
  &#40;let &#91;p-interface &#40;.getPhotosInterface &#40;:client flickr&#41;&#41;
        out-file &#40;fs/file out-dir filename&#41;&#93;
    &#40;with-open &#91;in &#40;BufferedInputStream. &#40;.getImageAsStream p-interface &#40;:object photo&#41; Size/LARGE&#41;&#41;
                out &#40;FileOutputStream. out-file&#41;&#93;
      &#40;io/copy in out&#41;&#41;
    &#40;assoc photo :out-file out-file&#41;&#41;&#41;

&#40;comment

  &#40;download-photo! &#40;assoc ctx :out-dir album-dir&#41; photo&#41;
  ;; =&gt; {:description nil,
  ;;     :date-taken nil,
  ;;     :geo-data nil,
  ;;     :rotation -1,
  ;;     :width 0,
  ;;     :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;,
  ;;     :filename &quot;53460147147.jpg&quot;,
  ;;     :id &quot;53460147147&quot;,
  ;;     :out-file
  ;;     #object&#91;java.io.File 0x44f5abfa &quot;/tmp/72177720314024335/53460147147.jpg&quot;&#93;,
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photos.Photo 0x4d361782 &quot;com.flickr4java.flickr.photos.Photo@14ea992b&quot;&#93;,
  ;;     :height 0}

  &#41;
</code></pre><p>Much nicer! We can now do the same thing to <code>download-album!</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn download-album! &#91;ctx {:keys &#91;id photos&#93; :as album}&#93;
  &#40;let &#91;album-dir &#40;-&gt;&gt; id &#40;fs/file &#40;fs/temp-dir&#41;&#41; fs/create-dirs fs/file&#41;
        photos &#40;-&gt;&gt; photos
                    &#40;map &#40;partial download-photo! &#40;assoc ctx :out-dir album-dir&#41;&#41;&#41;
                    doall&#41;&#93;
    &#40;assoc album :out-dir album-dir, :photos photos&#41;&#41;&#41;

&#40;comment

  &#40;download-album! ctx album&#41;
  ;; =&gt; {:id &quot;72177720314024335&quot;,
  ;;     :title &quot;clickr demo&quot;,
  ;;     :description &quot;Photo album demo for my clickr blog post&quot;,
  ;;     :photos
  ;;     &#40;{:description nil,
  ;;       :date-taken nil,
  ;;       :geo-data nil,
  ;;       :rotation -1,
  ;;       :width 0,
  ;;       :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;,
  ;;       :filename &quot;53460147147.jpg&quot;,
  ;;       :id &quot;53460147147&quot;,
  ;;       :out-file
  ;;       #object&#91;java.io.File 0x163fa069 &quot;/tmp/72177720314024335/53460147147.jpg&quot;&#93;,
  ;;       :object
  ;;       #object&#91;com.flickr4java.flickr.photos.Photo 0x4d361782 &quot;com.flickr4java.flickr.photos.Photo@14ea992b&quot;&#93;,
  ;;       :height 0}
  ;; &#91;...&#93;
  ;;      {:description nil,
  ;;       :date-taken nil,
  ;;       :geo-data nil,
  ;;       :rotation -1,
  ;;       :width 0,
  ;;       :title &quot;daniel-jennings-img-7554&quot;,
  ;;       :filename &quot;53460151727.jpg&quot;,
  ;;       :id &quot;53460151727&quot;,
  ;;       :out-file
  ;;       #object&#91;java.io.File 0x289e2344 &quot;/tmp/72177720314024335/53460151727.jpg&quot;&#93;,
  ;;       :object
  ;;       #object&#91;com.flickr4java.flickr.photos.Photo 0x805e360 &quot;com.flickr4java.flickr.photos.Photo@436e36e8&quot;&#93;,
  ;;       :height 0}&#41;,
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photosets.Photoset 0x19d70721 &quot;com.flickr4java.flickr.photosets.Photoset@19d70721&quot;&#93;,
  ;;     :out-dir #object&#91;java.io.File 0x67b12ec4 &quot;/tmp/72177720314024335&quot;&#93;}

  &#41;
</code></pre><h2 id="uploading_to_s3">Uploading to S3</h2><p>We're most of the way there now. The final piece of the puzzle is taking our lovely directory full of downloaded files and putting it on S3, as described above. To accomplish this, let's avail ourselves of the Cognitect <a href='https://github.com/cognitect-labs/aws-api'>aws-api</a> library that has <a href='tags/aws.html'>served us
so well in the past</a>. First, let's add it to <code>deps.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;src&quot; &quot;dev&quot;&#93;
 :deps {babashka/fs {:mvn/version &quot;0.4.19&quot;}
        com.cognitect.aws/api {:mvn/version &quot;0.8.686&quot;}
        com.cognitect.aws/endpoints {:mvn/version &quot;1.1.12.504&quot;}
        com.cognitect.aws/s3 {:mvn/version &quot;848.2.1413.0&quot;}
        com.flickr4java/flickr4java {:mvn/version &quot;3.0.1&quot;}}}
</code></pre><p>Sadly, we'll now need to restart our REPL. I really need to get hot reloading working! Sounds like a project for another day, though I'm sure it will be <a href='tags/50-simple-steps.html'>incredibly simple</a>...</p><p>In any case, having now restarted our REPL, let's create a <code>clickr.s3</code> namespace for ourselves:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns clickr.s3
  &#40;:require &#91;cognitect.aws.client.api :as aws&#93;
            &#91;clojure.string :as str&#93;
            &#91;clojure.java.io :as io&#93;&#41;
  &#40;:import &#40;java.io ByteArrayInputStream&#41;&#41;&#41;
</code></pre><p>Now we'll need an S3 client. Let's follow the same pattern we used for our Flickr client:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn init-client &#91;{:keys &#91;aws-region&#93; :as ctx}&#93;
  &#40;let &#91;client &#40;aws/client {:api :s3, :region aws-region}&#41;&#93;
    &#40;assoc ctx :s3 {:client client}&#41;&#41;&#41;
</code></pre><p>Now we can actually use the same config as we used before! 🤯 We just need to add the AWS region in there, then we can call <code>init-client</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">
&#40;comment

  &#40;def config {:api-key &quot;beefface5678910&quot;
               :secret &quot;facecafe1234&quot;
               :aws-region &quot;eu-west-1&quot;}&#41;
  ;; =&gt; #'clickr.s3/config

  &#40;def ctx &#40;init-client config&#41;&#41;
  ;; =&gt; #'clickr.s3/ctx

  &#40;-&gt; ctx :s3 :client&#41;
  ;; =&gt; #object&#91;cognitect.aws.client.impl.Client 0x6b5a7704 &quot;cognitect.aws.client.impl.Client@6b5a7704&quot;&#93;

  &#41;
</code></pre><p>With a working client in hand, we can write a function to upload a photo. Let's follow the same pattern as <code>download-photo!</code>: do the side-effecting thing and then return the photo, assoc-ing in the S3 key where we uploaded it.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn upload-photo! &#91;{:keys &#91;s3 s3-bucket&#93; :as ctx}
                     {:keys &#91;out-file&#93; :as photo}&#93;
  &#40;let &#91;s3-key :???&#93;
    &#40;aws/invoke &#40;:client s3&#41;
                {:op :PutObject
                 :request {:Bucket s3-bucket
                           :Key s3-key
                           :Body :???}}&#41;
    &#40;assoc photo :s3-key s3-key&#41;&#41;&#41;
</code></pre><p>OK, a couple things here. First, we need to add the S3 bucket to our context. That's easy enough:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">
&#40;comment

  &#40;def config {:api-key &quot;beefface5678910&quot;
               :secret &quot;facecafe1234&quot;
               :aws-region &quot;eu-west-1&quot;
               :s3-bucket &quot;photos.jmglov.net&quot;}&#41;
  ;; =&gt; #'clickr.s3/config

  &#40;def ctx &#40;init-client config&#41;&#41;
  ;; =&gt; #'clickr.s3/ctx

  ctx
  ;; =&gt; {:api-key &quot;beefface5678910&quot;,
  ;;     :secret &quot;facecafe1234&quot;,
  ;;     :aws-region &quot;eu-west-1&quot;,
  ;;     :s3-bucket &quot;photos.jmglov.net&quot;,
  ;;     :s3
  ;;     {:client
  ;;      #object&#91;cognitect.aws.client.impl.Client 0xcd319cb &quot;cognitect.aws.client.impl.Client@cd319cb&quot;&#93;}}

  &#41;
</code></pre><p>Next, we need a way to read in the photo file. babashka.fs comes to the rescue again with a function called <code>read-all-bytes</code>! Let's require in babashka.fs:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns clickr.s3
  &#40;:require &#91;babashka.fs :as fs&#93;
            &#91;cognitect.aws.client.api :as aws&#93;&#41;&#41;
</code></pre><p>And now we can use that in our <code>upload-photo!</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn upload-photo! &#91;{:keys &#91;s3 s3-bucket&#93; :as ctx}
                     {:keys &#91;out-file&#93; :as photo}&#93;
  &#40;let &#91;s3-key :???&#93;
    &#40;aws/invoke &#40;:client s3&#41;
                {:op :PutObject
                 :request {:Bucket s3-bucket
                           :Key s3-key
                           :Body &#40;fs/read-all-bytes out-file&#41;}}&#41;
    &#40;assoc photo :s3-key s3-key&#41;&#41;&#41;
</code></pre><p>Finally, we need to figure out what the S3 key should be. Using the convention from <code>download-album!</code>, we know the photo will be in a directory corresponding to the album ID. Let's use that as the key and prepend <code>clickr/</code> to it so that we don't pollute the top-level of the S3 bucket with a bunch of nonsense. We can grab the directory and filename using our old friend babashka.fs, of course:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def photo {:out-file &quot;/tmp/72177720314024335/53460151727.jpg&quot;}&#41;
  ;; =&gt; #'clickr.s3/photo

  &#40;-&gt; photo :out-file fs/file-name&#41;
  ;; =&gt; &quot;53460151727.jpg&quot;

  &#40;-&gt; photo :out-file fs/parent fs/file-name&#41;
  ;; =&gt; &quot;72177720314024335&quot;

  &#41;
</code></pre><p>Cool! With this, we have enough to construct the S3 key:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn upload-photo! &#91;{:keys &#91;s3 s3-bucket&#93; :as ctx}
                     {:keys &#91;out-file&#93; :as photo}&#93;
  &#40;let &#91;s3-key &#40;format &quot;%s/%s/%s&quot;
                       &quot;clickr&quot;
                       &#40;-&gt; photo :out-file fs/parent fs/file-name&#41;
                       &#40;-&gt; photo :out-file fs/file-name&#41;&#41;&#93;
    &#40;aws/invoke &#40;:client s3&#41;
                {:op :PutObject
                 :request {:Bucket s3-bucket
                           :Key s3-key
                           :Body &#40;fs/read-all-bytes out-file&#41;}}&#41;
    &#40;assoc photo :s3-key s3-key&#41;&#41;&#41;
</code></pre><p>We can try this out:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;upload-photo! ctx photo&#41;
  ;; =&gt; {:out-file &quot;/tmp/72177720314024335/53460151727.jpg&quot;,
  ;;     :s3-key &quot;clickr/72177720314024335/53460151727.jpg&quot;}

  &#41;
</code></pre><p>And one should always trust but verify, right?</p><pre class="language-text"><code class="lang-text language-text">: jmglov@laurana; aws s3 ls s3://photos.jmglov.net/clickr/72177720314024335/53460151727.jpg
2024-01-17 12:06:17      90743 53460151727.jpg
</code></pre><p>Wow, that was surprisingly painless!</p><p>One thing that's bothering me a little, though, is that hardcoded "clickr" in there. Let's move that to the config like we did with the S3 bucket:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn upload-photo! &#91;{:keys &#91;s3 s3-bucket s3-prefix&#93; :as ctx}
                     {:keys &#91;out-file&#93; :as photo}&#93;
  &#40;let &#91;s3-key &#40;format &quot;%s/%s/%s&quot;
                       s3-prefix
                       &#40;-&gt; photo :out-file fs/parent fs/file-name&#41;
                       &#40;-&gt; photo :out-file fs/file-name&#41;&#41;&#93;
    &#40;aws/invoke &#40;:client s3&#41;
                {:op :PutObject
                 :request {:Bucket s3-bucket
                           :Key s3-key
                           :Body &#40;fs/read-all-bytes out-file&#41;}}&#41;
    &#40;assoc photo :s3-key s3-key&#41;&#41;&#41;

&#40;comment

  &#40;def config {:api-key &quot;beefface5678910&quot;
               :secret &quot;facecafe1234&quot;
               :aws-region &quot;eu-west-1&quot;
               :s3-bucket &quot;photos.jmglov.net&quot;
               :s3-prefix &quot;clickr&quot;}&#41;
  ;; =&gt; #'clickr.s3/config

  &#40;def ctx &#40;init-client config&#41;&#41;
  ;; =&gt; #'clickr.s3/ctx

  ctx
  ;; =&gt; {:api-key &quot;beefface5678910&quot;,
  ;;     :secret &quot;facecafe1234&quot;,
  ;;     :aws-region &quot;eu-west-1&quot;,
  ;;     :s3-bucket &quot;photos.jmglov.net&quot;,
  ;;     :s3-prefix &quot;clickr&quot;,
  ;;     :s3
  ;;     {:client
  ;;      #object&#91;cognitect.aws.client.impl.Client 0x2dc1f1c2 &quot;cognitect.aws.client.impl.Client@2dc1f1c2&quot;&#93;}}

  &#40;upload-photo! ctx photo&#41;
  ;; =&gt; {:out-file &quot;/tmp/72177720314024335/53460151727.jpg&quot;,
  ;;     :s3-key &quot;clickr/72177720314024335/53460151727.jpg&quot;}

  &#41;
</code></pre><p>Having done this, writing a function to upload all the photos in an album is quite straightforward:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn upload-album! &#91;ctx {:keys &#91;photos&#93; :as album}&#93;
  &#40;update album :photos #&#40;doall &#40;map &#40;partial upload-photo! ctx&#41; %&#41;&#41;&#41;&#41;

&#40;comment

  &#40;def album-dir &quot;/tmp/72177720314024335&quot;&#41;
  ;; =&gt; #'clickr.s3/album-dir

  &#40;def album {:id &quot;72177720314024335&quot;
              :out-dir album-dir
              :photos &#40;-&gt;&gt; &#40;fs/glob album-dir &quot;&#42;&quot;&#41;
                           &#40;map &#40;fn &#91;out-file&#93; {:out-file &#40;str out-file&#41;}&#41;&#41;&#41;}&#41;
  ;; =&gt; #'clickr.s3/album

  &#40;upload-album! ctx album&#41;
  ;; =&gt; {:id &quot;72177720314024335&quot;,
  ;;     :out-dir &quot;/tmp/72177720314024335&quot;,
  ;;     :photos
  ;;     &#40;{:out-file &quot;/tmp/72177720314024335/53461405604.jpg&quot;,
  ;;       :s3-key &quot;clickr/72177720314024335/53461405604.jpg&quot;}
  ;;      {:out-file &quot;/tmp/72177720314024335/53460163402.jpg&quot;,
  ;;       :s3-key &quot;clickr/72177720314024335/53460163402.jpg&quot;}
  ;;      {:out-file &quot;/tmp/72177720314024335/53460161007.jpg&quot;,
  ;;       :s3-key &quot;clickr/72177720314024335/53460161007.jpg&quot;}
  ;;      {:out-file &quot;/tmp/72177720314024335/53460147147.jpg&quot;,
  ;;       :s3-key &quot;clickr/72177720314024335/53460147147.jpg&quot;}
  ;;      {:out-file &quot;/tmp/72177720314024335/53461214223.jpg&quot;,
  ;;       :s3-key &quot;clickr/72177720314024335/53461214223.jpg&quot;}
  ;;      {:out-file &quot;/tmp/72177720314024335/53461091151.jpg&quot;,
  ;;       :s3-key &quot;clickr/72177720314024335/53461091151.jpg&quot;}
  ;;      {:out-file &quot;/tmp/72177720314024335/53460151727.jpg&quot;,
  ;;       :s3-key &quot;clickr/72177720314024335/53460151727.jpg&quot;}
  ;;      {:out-file &quot;/tmp/72177720314024335/53461088046.jpg&quot;,
  ;;       :s3-key &quot;clickr/72177720314024335/53461088046.jpg&quot;}&#41;}

  &#41;
</code></pre><h2 id="tying_it_all_together">Tying it all together</h2><p>All of this looks reasonably reasonable, but our REPL-driven development of the <code>clickr.s3</code> namespace was pretty mocktacular, which fills me with a vague sense of unease. Let's make sure we can upload an honest to goodness album!</p><p>To make sure we don't have any cruft lying around in our REPL, let's create a new namespace and require in the flickr and s3 stuff:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns user
  &#40;:require &#91;clickr.flickr :as flickr&#93;
            &#91;clickr.s3 :as s3&#93;&#41;&#41;
</code></pre><p>We'll need some config, which we can just copy and paste straight from our experiments in the s3 namespace:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def config {:api-key &quot;beefface5678910&quot;
               :secret &quot;facecafe1234&quot;
               :aws-region &quot;eu-west-1&quot;
               :s3-bucket &quot;photos.jmglov.net&quot;
               :s3-prefix &quot;clickr&quot;}&#41;
  ;; =&gt; #'user/config

  &#41;
</code></pre><p>With this config firmly in hand, we can create flickr and s3 clients:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def ctx &#40;-&gt; config flickr/init-client s3/init-client&#41;&#41;
  ;; =&gt; #'user/ctx

  ctx
  ;; =&gt; {:api-key &quot;beefface5678910&quot;,
  ;;     :secret &quot;facecafe1234&quot;,
  ;;     :aws-region &quot;eu-west-1&quot;,
  ;;     :s3-bucket &quot;photos.jmglov.net&quot;,
  ;;     :s3-prefix &quot;clickr&quot;,
  ;;     :flickr
  ;;     {:client
  ;;      #object&#91;com.flickr4java.flickr.Flickr 0x2c4181a2 &quot;com.flickr4java.flickr.Flickr@2c4181a2&quot;&#93;,
  ;;      :auth-store
  ;;      #object&#91;com.flickr4java.flickr.util.FileAuthStore 0x5368861c &quot;com.flickr4java.flickr.util.FileAuthStore@5368861c&quot;&#93;,
  ;;      :auth
  ;;      #object&#91;com.flickr4java.flickr.auth.Auth 0x7c8ff0d6 &quot;com.flickr4java.flickr.auth.Auth@7c8ff0d6&quot;&#93;},
  ;;     :s3
  ;;     {:client
  ;;      #object&#91;cognitect.aws.client.impl.Client 0x5b35646d &quot;cognitect.aws.client.impl.Client@5b35646d&quot;&#93;}}

  &#41;
</code></pre><p>Download an album:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def album &#40;-&gt;&gt; &#40;flickr/get-albums' ctx&#41;
                  first
                  &#40;flickr/download-album!' ctx&#41;&#41;&#41;
  ;; =&gt; #'user/album

  &#40;-&gt;&gt; album :photos &#40;map :out-file&#41;&#41;
  ;; =&gt; &#40;#object&#91;java.io.File 0x1c2cd5ae &quot;/tmp/72177720314024335/53460147147.jpg&quot;&#93;
  ;;     #object&#91;java.io.File 0x1678a01 &quot;/tmp/72177720314024335/53461405604.jpg&quot;&#93;
  ;;     #object&#91;java.io.File 0x4f708220 &quot;/tmp/72177720314024335/53461091151.jpg&quot;&#93;
  ;;     #object&#91;java.io.File 0x75723234 &quot;/tmp/72177720314024335/53461088046.jpg&quot;&#93;
  ;;     #object&#91;java.io.File 0x6cace2fd &quot;/tmp/72177720314024335/53460163402.jpg&quot;&#93;
  ;;     #object&#91;java.io.File 0x51265aeb &quot;/tmp/72177720314024335/53460161007.jpg&quot;&#93;
  ;;     #object&#91;java.io.File 0x1a0ca36d &quot;/tmp/72177720314024335/53461214223.jpg&quot;&#93;
  ;;     #object&#91;java.io.File 0x7e8bc18a &quot;/tmp/72177720314024335/53460151727.jpg&quot;&#93;&#41;

  &#41;
</code></pre><p>And upload it to S3!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;s3/upload-album! ctx album&#41;
  ;; =&gt; {:id &quot;72177720314024335&quot;,
  ;;     :title &quot;clickr demo&quot;,
  ;;     :description &quot;Photo album demo for my clickr blog post&quot;,
  ;;     :photos
  ;;     &#40;{:description nil,
  ;;       :date-taken nil,
  ;;       :geo-data nil,
  ;;       :rotation -1,
  ;;       :width 0,
  ;;       :title &quot;sean-hargreaves-phoenix-new-5-final-a&quot;,
  ;;       :filename &quot;53460147147.jpg&quot;,
  ;;       :id &quot;53460147147&quot;,
  ;;       :s3-key &quot;clickr/72177720314024335/53460147147.jpg&quot;,
  ;;       :out-file
  ;;       #object&#91;java.io.File 0x1c2cd5ae &quot;/tmp/72177720314024335/53460147147.jpg&quot;&#93;,
  ;;       :object
  ;;       #object&#91;com.flickr4java.flickr.photos.Photo 0x3bbff9e1 &quot;com.flickr4java.flickr.photos.Photo@14ea992b&quot;&#93;,
  ;;       :height 0}
  ;; &#91;...&#93;
  ;;      {:description nil,
  ;;       :date-taken nil,
  ;;       :geo-data nil,
  ;;       :rotation -1,
  ;;       :width 0,
  ;;       :title &quot;daniel-jennings-img-7554&quot;,
  ;;       :filename &quot;53460151727.jpg&quot;,
  ;;       :id &quot;53460151727&quot;,
  ;;       :s3-key &quot;clickr/72177720314024335/53460151727.jpg&quot;,
  ;;       :out-file
  ;;       #object&#91;java.io.File 0x7e8bc18a &quot;/tmp/72177720314024335/53460151727.jpg&quot;&#93;,
  ;;       :object
  ;;       #object&#91;com.flickr4java.flickr.photos.Photo 0x1e20bd4c &quot;com.flickr4java.flickr.photos.Photo@436e36e8&quot;&#93;,
  ;;       :height 0}&#41;,
  ;;     :object
  ;;     #object&#91;com.flickr4java.flickr.photosets.Photoset 0x6411aa2d &quot;com.flickr4java.flickr.photosets.Photoset@6411aa2d&quot;&#93;,
  ;;     :out-dir #object&#91;java.io.File 0x377eb990 &quot;/tmp/72177720314024335&quot;&#93;}

  &#41;
</code></pre><p>And check that all is right with the world:</p><pre class="language-text"><code class="lang-text language-text">: jmglov@laurana; aws s3 ls s3://photos.jmglov.net/clickr/72177720314024335/
2024-01-17 12:48:26     125643 53460147147.jpg
2024-01-17 12:48:29      90743 53460151727.jpg
2024-01-17 12:48:28      88417 53460161007.jpg
2024-01-17 12:48:28     185725 53460163402.jpg
2024-01-17 12:48:27     178392 53461088046.jpg
2024-01-17 12:48:27     106074 53461091151.jpg
2024-01-17 12:48:29      88013 53461214223.jpg
2024-01-17 12:48:26      98035 53461405604.jpg
</code></pre><p><img src="assets/2023-11-11-victory.jpg" alt="A woman on a beach at sunrise with her head thrown back, saying "Victory"" title="Victory!" width=800px] /></p><p>But wait just a second here...</p><h2 id="the_unbearable_lightness_of_being_dissatisfied">The unbearable lightness of being dissatisfied</h2><p><img src="assets/2024-01-17-sadness.jpg" alt="A person sitting alone, holding their face in their hands" title="Sadness!" width=800px] /></p><p>Much like Angelica Schuyler, I'll never be satisfied, because having to browse my albums with the S3 console is kind of a let down, not to mention that this:</p><p><img src="assets/2024-01-17-album-flickr.png" alt="The Flickr site, displaying the photos in the clickr demo album" title="OMG 🤩" width=800px] /></p><p>looks way better than this:</p><p><img src="assets/2024-01-17-s3-console.png" alt="The AWS S3 console, displaying the files we uploaded as a table of text" title="Sadness!" width=800px] /></p><p>What is a young man to do? Well, you'll have to stick around for the next instalment of <a href='tags/clickr.html'>this exciting series</a>, which I promise I'll actually write, because I've started writing it already and... um... trust me?</p><p>Part 2: <a href='2024-01-22-clickr-goes-fe.html'>clickr goes frontend</a></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2023-11-12-awno-here-we-go.html</id>
    <link href="https://jmglov.net/blog/2023-11-12-awno-here-we-go.html"/>
    <title>Awno! Here we go!</title>
    <updated>2023-11-12T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<h2 id="admission_of_guilt">Admission of guilt</h2><p>Between the writing of the previous post in this series and now, two things happened:</p><ol><li>I got distracted by other stuff</li><li>grzm <a href='https://github.com/grzm/awyeah-api/issues/8#issuecomment-1820183384'>fixed the
   issue</a>   properly upstream! 🎉</li></ol><p>I decided to go ahead and post this for the record, just in case there's anything useful for anyone in there, and also to clear my palate (and my conscience) for some new blogging. 😉</p><p>So here you go:</p><h2 id="just_for_the_record">Just for the record</h2><p>In <a href='2023-11-11-awno-api.html'>last week's exciting instalment of Josh does something
inadvisable</a>, we had finished smooshing <a href='https://github.com/grzm/awyeah-api'>awyeah-api</a> into an unholy fork of <a href='https://github.com/cognitect-labs/aws-api'>aws-api</a> to make something completely horrific: <a href='https://github.com/jmglov/awno-api'>awno-api</a>. And by "finished", I mean "wrote some code and assumed that it will obviously work".</p><p>Of course, even at my most hubristic, I realise that assuming something will work just because I wrote it is somewhat... irresponsible. So I guess we'd better actually try it out before committing to main.</p><h2 id="testing%2C_testing..._is_this_thing_on%3F">Testing, testing... is this thing on?</h2><p>awyeah-api has an intriguing <a href='https://github.com/grzm/awyeah-api/blob/main/bin/test'>bin/tests</a>, which we've previously copied, so let's do a quick replace all of <code>com.grzm.awyeah</code> with <code>net.jmglov.awno</code>:</p><pre class="language-clj"><code class="lang-clj language-clj">#!/usr/bin/env bash

set -euo pipefail

repo&#95;root=&quot;$&#40;git rev-parse --show-toplevel&#41;&quot;

cd &quot;${repo&#95;root}&quot;
if &#91;&#91; -z ${SKIP&#95;BB+x} &#93;&#93; ; then
    echo &quot;bb tests&quot;
    bb --main net.jmglov.awno.test/run-tests
else
    echo &quot;skipping bb tests&quot;
fi

echo
echo &quot;clj tests&quot;
clj -X:dev:test:clj
</code></pre><p>Then we'll need the actual <code>net.jmglov.awno.test</code> namespace, so we'll grab <a href='https://github.com/grzm/awyeah-api/blob/main/test/src/com/grzm/awyeah/test.cljc'>test/src/com/grzm/awyeah/test.cljc</a>:</p><pre class="language-text"><code class="lang-text language-text">cp ../awyeah-api/test/src/com/grzm/awyeah/test.cljc test/src/net/jmglov/awno/
</code></pre><p>Then do the usual replace all of <code>com.grzm.awyeah</code> with <code>net.jmglov.awno</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns net.jmglov.awno.test
  &#40;:require
   &#91;clojure.pprint :as pprint&#93;
   &#91;clojure.test :as test&#93;&#41;&#41;

#?&#40;:bb &#40;taoensso.timbre/set-level! :info&#41;&#41;

&#40;def test-namespaces
  '&#91;net.jmglov.awno.client.api.localstack-test
    net.jmglov.awno.client.api-test
    net.jmglov.awno.client.impl-test
    net.jmglov.awno.client.test-double-test
    net.jmglov.awno.config-test
    net.jmglov.awno.credentials-test
    net.jmglov.awno.ec2-metadata-utils-test
    net.jmglov.awno.endpoint-test
    net.jmglov.awno.interceptors-test
    net.jmglov.awno.protocols-test
    net.jmglov.awno.protocols.rest-test
    net.jmglov.awno.region-test
    net.jmglov.awno.retry-test
    net.jmglov.awno.shape-test
    ;; omitting net.jmglov.awno.signers-test
    ;; Requires org.apache.commons.io.input.BOMInputStream which I haven't figured out
    ;; how to port to something compatible with Babashka
    net.jmglov.awno.util-test&#93;&#41;

&#40;defn run-tests
  &#40;&#91;&#93;
   &#40;run-tests {:test-namespaces test-namespaces}&#41;&#41;
  &#40;&#91;{nses :test-namespaces}&#93;
   &#40;dorun &#40;map require nses&#41;&#41;
   &#40;let &#91;res &#40;apply test/run-tests nses&#41;&#93;
     &#40;pprint/pprint res&#41;
     &#40;when &#40;-&gt;&gt; &#40;&#40;juxt :fail :error&#41; res&#41;
                &#40;some #&#40;pos? %&#41;&#41;&#41;
       &#40;System/exit 1&#41;&#41;&#41;&#41;&#41;
</code></pre><p>And then give it a whirl!</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bin/test 
bb tests
----- Error --------------------------------------------------------------------
Type:     java.lang.Exception
Message:  Could not find namespace: net.jmglov.awno.client.api.localstack-test.
Location: /home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/test.cljc:32:4
</code></pre><p>Ah yes, another reason to hate <a href='https://localstack.cloud/'>LocalStack</a>. 😠</p><p>LocalStack claims to be "A fully functional local cloud stack [which enables you
to] develop and test your cloud and serverless apps offline!" In my experience, it is a somewhat functional local cloud stack which enables me to develop and test my cloud and serverless apps offline—as long as I limit don't use any services that aren't supported by LocalStack (to be fair, the <a href='https://docs.localstack.cloud/user-guide/aws/feature-coverage/'>list of supported
services</a> is growing), and don't expect the local service to actually behave like the real one. 😅</p><p>Before continuing with my shit-talking, let me just insert my standard disclaimer here:</p><p>{thing-which-i-hate} is a perfectly serviceable piece of technology, which many people find useful. I'm not any smarter than those people, and their choice to use {thing-which-i-hate} is perfectly valid. All the vitriol I pour on {thing-which-i-hate} is tongue-in-cheek and should be taken with a serious grain of salt, in full knowledge that I often don't know what I'm talking about, because my experience isn't representative of all use cases, and so on and so forth.</p><p>OK, having gotten that out of the way, let's remove all references to the odious LocalStack and move on with our lives!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def test-namespaces
  '&#91;net.jmglov.awno.client.api-test
    net.jmglov.awno.client.impl-test
    net.jmglov.awno.client.test-double-test
    net.jmglov.awno.config-test
    net.jmglov.awno.credentials-test
    net.jmglov.awno.ec2-metadata-utils-test
    net.jmglov.awno.endpoint-test
    net.jmglov.awno.interceptors-test
    net.jmglov.awno.protocols-test
    net.jmglov.awno.protocols.rest-test
    net.jmglov.awno.region-test
    net.jmglov.awno.retry-test
    net.jmglov.awno.shape-test
    ;; omitting net.jmglov.awno.signers-test
    ;; Requires org.apache.commons.io.input.BOMInputStream which I haven't figured out
    ;; how to port to something compatible with Babashka
    net.jmglov.awno.util-test&#93;&#41;
</code></pre><p>If at first you don't succeed, dust yourself off and try again (you gotta dust it off and try again):</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bin/test 
bb tests
----- Error --------------------------------------------------------------------
Type:     java.lang.Exception
Message:  Could not find namespace: net.jmglov.awno.client.api-test.
Location: /home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/test.cljc:32:4
</code></pre><p>Oh come on!</p><p>In all the <code>git mv</code> nonsense, we probably just misplaced a file or two, so let's see if we can track this test down:</p><pre class="language-text"><code class="lang-text language-text">: awno-api; find test/src/ -name api&#95;test.clj
test/src/net/jmglov/awno/api&#95;test.clj
</code></pre><p>Aha! There it is. Before getting too cocky, let's see which other test files we're missing:</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bb -e '
&gt; &#40;def base-dir &quot;test/src&quot;&#41;
&#40;with-open &#91;r &#40;io/reader &#40;format &quot;%s/net/jmglov/awno/test.cljc&quot; base-dir&#41;&#41;&#93;
  &#40;let &#91;namespaces
        &#40;-&gt;
         &#40;-&gt;&gt; &#40;line-seq r&#41;
              &#40;drop-while #&#40;not &#40;str/starts-with? % &quot;&#40;def test-namespaces&quot;&#41;&#41;&#41;
              &#40;take-while not-empty&#41;
              &#40;str/join &quot;\n&quot;&#41;
              edn/read-string&#41;
         &#40;nth 3&#41;&#41;
        filenames &#40;-&gt;&gt; namespaces
                       &#40;map #&#40;-&gt;&gt; &#40;-&gt; %
                                      &#40;str/replace &quot;.&quot; &quot;/&quot;&#41;
                                      &#40;str/replace &quot;-&quot; &quot;&#95;&quot;&#41;&#41;
                                  &#40;format &quot;%s/%s.clj&quot; base-dir&#41;&#41;&#41;&#41;
        missing &#40;remove fs/exists? filenames&#41;&#93;
    &#40;println &#40;str/join &quot;\n&quot; missing&#41;&#41;&#41;&#41;
'
</code></pre><p>Babashka helpfully tells us:</p><pre class="language-text"><code class="lang-text language-text">test/src/net/jmglov/awno/client/api&#95;test.clj
test/src/net/jmglov/awno/client/impl&#95;test.clj
test/src/net/jmglov/awno/client/test&#95;double&#95;test.clj
</code></pre><p>I bet we can even track down those missing files!</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bb -e '
&#40;def base-dir &quot;test/src&quot;&#41;
&#40;with-open &#91;r &#40;io/reader &#40;format &quot;%s/net/jmglov/awno/test.cljc&quot; base-dir&#41;&#41;&#93;
  &#40;let &#91;namespaces
        &#40;-&gt;
         &#40;-&gt;&gt; &#40;line-seq r&#41;
              &#40;drop-while #&#40;not &#40;str/starts-with? % &quot;&#40;def test-namespaces&quot;&#41;&#41;&#41;
              &#40;take-while not-empty&#41;
              &#40;str/join &quot;\n&quot;&#41;
              edn/read-string&#41;
         &#40;nth 3&#41;&#41;
        filenames &#40;-&gt;&gt; namespaces
                       &#40;map #&#40;-&gt;&gt; &#40;-&gt; %
                                      &#40;str/replace &quot;.&quot; &quot;/&quot;&#41;
                                      &#40;str/replace &quot;-&quot; &quot;&#95;&quot;&#41;&#41;
                                  &#40;format &quot;%s/%s.clj&quot; base-dir&#41;&#41;&#41;&#41;
        missing &#40;remove fs/exists? filenames&#41;
        found &#40;-&gt;&gt; missing
                   &#40;map &#40;fn &#91;mf&#93;
                          &#91;mf &#40;-&gt;&gt; &#40;fs/file-name mf&#41;
                                   &#40;format &quot;&#42;&#42;/%s&quot;&#41;
                                   &#40;fs/glob base-dir&#41;
                                   first
                                   str&#41;&#93;&#41;&#41;&#41;&#93;
    &#40;doseq &#91;&#91;missing-file found-file&#93; found&#93;
      &#40;println found-file &quot;-&gt;&quot; missing-file&#41;&#41;&#41;&#41;
'
</code></pre><p>And look what we have here:</p><pre class="language-text"><code class="lang-text language-text">test/src/net/jmglov/awno/api&#95;test.clj -&gt; test/src/net/jmglov/awno/client/api&#95;test.clj
test/src/cognitect/client/impl&#95;test.clj -&gt; test/src/net/jmglov/awno/client/impl&#95;test.clj
test/src/cognitect/client/test&#95;double&#95;test.clj -&gt; test/src/net/jmglov/awno/client/test&#95;double&#95;test.clj
</code></pre><p>Yup, got them all. Now to put them where they go and fix the namespaces:</p><pre class="language-text"><code class="lang-text language-text">&#40;def base-dir &quot;test/src&quot;&#41;
&#40;with-open &#91;r &#40;io/reader &#40;format &quot;%s/net/jmglov/awno/test.cljc&quot; base-dir&#41;&#41;&#93;
  &#40;let &#91;namespaces
        &#40;-&gt;
         &#40;-&gt;&gt; &#40;line-seq r&#41;
              &#40;drop-while #&#40;not &#40;str/starts-with? % &quot;&#40;def test-namespaces&quot;&#41;&#41;&#41;
              &#40;take-while not-empty&#41;
              &#40;str/join &quot;\n&quot;&#41;
              edn/read-string&#41;
         &#40;nth 3&#41;&#41;
        filenames &#40;-&gt;&gt; namespaces
                       &#40;map &#40;fn &#91;ns&#42;&#93;
                              &#91;ns&#42;
                               &#40;-&gt;&gt; &#40;-&gt; ns&#42;
                                        &#40;str/replace &quot;.&quot; &quot;/&quot;&#41;
                                        &#40;str/replace &quot;-&quot; &quot;&#95;&quot;&#41;&#41;
                                    &#40;format &quot;%s/%s.clj&quot; base-dir&#41;&#41;&#93;&#41;&#41;&#41;
        missing &#40;remove &#40;fn &#91;&#91;&#95; filename&#93;&#93; &#40;fs/exists? filename&#41;&#41; filenames&#41;
        found &#40;-&gt;&gt; missing
                   &#40;map &#40;fn &#91;&#91;ns&#42; filename&#93;&#93;
                          {:ns&#42; ns&#42;
                           :target filename
                           :source &#40;-&gt;&gt; &#40;fs/file-name filename&#41;
                                        &#40;format &quot;&#42;&#42;/%s&quot;&#41;
                                        &#40;fs/glob base-dir&#41;
                                        first
                                        str&#41;}&#41;&#41;&#41;&#93;
    &#40;doseq &#91;{:keys &#91;ns&#42; source target&#93;} found
            :let &#91;fixed-ns
                  &#40;with-open &#91;r &#40;io/reader source&#41;&#93;
                    &#40;-&gt;&gt; &#40;line-seq r&#41;
                         &#40;map &#40;fn &#91;line&#93;
                                &#40;if &#40;re-matches #&quot;&#94;&#91;&#40;&#93;ns .+$&quot; line&#41;
                                  &#40;str &quot;&#40;ns &quot; ns&#42;&#41;
                                  line&#41;&#41;&#41;
                         &#40;str/join &quot;\n&quot;&#41;&#41;&#41;&#93;&#93;
      &#40;spit source fixed-ns&#41;
      &#40;println &quot;Moving&quot; source &quot;-&gt;&quot; target&#41;
      &#40;fs/create-dirs &#40;fs/parent target&#41;&#41;
      &#40;fs/move source target&#41;&#41;&#41;&#41;
</code></pre><p>Quoth Babashka:</p><pre class="language-text"><code class="lang-text language-text">Moving test/src/net/jmglov/awno/api&#95;test.clj -&gt; test/src/net/jmglov/awno/client/api&#95;test.clj
Moving test/src/cognitect/client/impl&#95;test.clj -&gt; test/src/net/jmglov/awno/client/impl&#95;test.clj
Moving test/src/cognitect/client/test&#95;double&#95;test.clj -&gt; test/src/net/jmglov/awno/client/test&#95;double&#95;test.clj
</code></pre><p>Lets open up <code>test/src/net/jmglov/awno/client/api&#95;test.clj</code> and see what we've got:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns net.jmglov.awno.client.api-test
  &#40;:require &#91;clojure.datafy :as datafy&#93;
            &#91;clojure.test :as t :refer &#91;deftest is testing&#93;&#93;
            &#91;net.jmglov.awno.client.api :as aws&#93;
            &#91;net.jmglov.awno.client.protocol :as client.protocol&#93;
            &#91;net.jmglov.awno.client.shared :as shared&#93;
            &#91;net.jmglov.awno.http :as http&#93;&#41;&#41;

;; &#91;...&#93;
</code></pre><p>Wow! Looking good!</p><h2 id="testing%2C_testing%2C_one_two...">Testing, testing, one two...</h2><p>Having moved the furniture around, let's try running the tests again:</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bin/test 
bb tests
----- Error --------------------------------------------------------------------
Type:     java.lang.Exception
Message:  Could not find namespace: net.jmglov.awno.dynaload.
Location: /home/jmglov/Documents/code/clojure/awno-api/src/net/jmglov/awno/client/validation.clj:6:3
</code></pre><p>OMG, it looks like there was another dynaload that we missed in our previous babashkafication of aws-api. Let's open <code>src/net/jmglov/awno/client/validation.clj</code> see what's going on.</p><p>It looks like dynaload is being used for the following three vars:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def &#94;:private registry-ref &#40;delay &#40;dynaload/load-var 'clojure.spec.alpha/registry&#41;&#41;&#41;
&#40;def &#94;:private valid?-ref &#40;delay &#40;dynaload/load-var 'clojure.spec.alpha/valid?&#41;&#41;&#41;
&#40;def &#94;:private explain-data-ref &#40;delay &#40;dynaload/load-var 'clojure.spec.alpha/explain-data&#41;&#41;&#41;
</code></pre><p>According to <a href='https://github.com/grzm/awyeah-api/blob/main/docs/porting-decisions.markdown'>porting-decisions.markdown</a>:</p><blockquote><p> The aws-api library defines the <code>cognitect.dynaload/load-var</code> function to  dynamically require and resolve the var referenced by a given symbol. Clojure  1.10 provides the same functionality with the <code>requiring-resolve</code> function.  Given that <code>requiring-resolve</code> is compiled into the babashka image, I've chosen  to replace <code>load-var</code> with <code>requiring-resolve</code> rather than relying on  <a href='https://github.com/babashka/sci'>sci</a> to interpret <code>load-var</code> at run-time. </p></blockquote><p>Cool, so let's replace all occurrences of <code>dynaload/load-var</code> with <code>requiring-resolve</code>, then rip dynaload out of the <code>ns</code> form, leaving us with this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">;; Copyright &#40;c&#41; Cognitect, Inc.
;; All rights reserved.

&#40;ns &#94;:skip-wiki net.jmglov.awno.client.validation
  &quot;For internal use. Don't call directly.&quot;
  &#40;:require &#91;net.jmglov.awno.client.protocol :as client.protocol&#93;
            &#91;net.jmglov.awno.service :as service&#93;&#41;&#41;

&#40;set! &#42;warn-on-reflection&#42; true&#41;

&#40;defn validate-requests?
  &quot;For internal use. Don't call directly.&quot;
  &#91;client&#93;
  &#40;some-&gt; client client.protocol/-get-info :validate-requests? deref&#41;&#41;

&#40;def &#94;:private registry-ref &#40;delay &#40;requiring-resolve 'clojure.spec.alpha/registry&#41;&#41;&#41;
&#40;defn registry
  &quot;For internal use. Don't call directly.&quot;
  &#91;&amp; args&#93; &#40;apply @registry-ref args&#41;&#41;

&#40;def &#94;:private valid?-ref &#40;delay &#40;requiring-resolve 'clojure.spec.alpha/valid?&#41;&#41;&#41;
&#40;defn valid?
  &quot;For internal use. Don't call directly.&quot;
  &#91;&amp; args&#93; &#40;apply @valid?-ref args&#41;&#41;

&#40;def &#94;:private explain-data-ref &#40;delay &#40;requiring-resolve 'clojure.spec.alpha/explain-data&#41;&#41;&#41;
&#40;defn explain-data
  &quot;For internal use. Don't call directly.&quot;
  &#91;&amp; args&#93; &#40;apply @explain-data-ref args&#41;&#41;

&#40;defn request-spec
  &quot;For internal use. Don't call directly.&quot;
  &#91;service op&#93;
  &#40;when-let &#91;spec &#40;service/request-spec-key service op&#41;&#93;
    &#40;when &#40;contains? &#40;-&gt; &#40;registry&#41; keys set&#41; spec&#41;
      spec&#41;&#41;&#41;

&#40;defn invalid-request-anomaly
  &quot;For internal use. Don't call directly.&quot;
  &#91;spec request&#93;
  &#40;assoc &#40;explain-data spec request&#41;
         :cognitect.anomalies/category :cognitect.anomalies/incorrect&#41;&#41;

&#40;defn unsupported-op-anomaly
  &quot;For internal use. Don't call directly.&quot;
  &#91;service op&#93;
  {:cognitect.anomalies/category :cognitect.anomalies/unsupported
   :cognitect.anomalies/message &quot;Operation not supported&quot;
   :service &#40;keyword &#40;service/service-name service&#41;&#41;
   :op op}&#41;
</code></pre><p>Let's go ahead and search the project for any other occurrences of dynaload that we may have missed. Ah-hah! <code>net.jmglov.awno.client.api</code> is also dynaloading. We can repeat the dance of replacing <code>dynaload/load-var</code> with <code>requiring-resolve</code>, then removing it from the <code>ns</code> form, then evaluating the buffer:</p><pre class="language-text"><code class="lang-text language-text">clojure.lang.ExceptionInfo: defrecord/deftype currently only support protocol implementations, {:file &quot;/home/jmglov/Documents/code/clojure/awno-api/src/net/jmglov/awno/client/impl.clj&quot;}
found: clojure.lang.IObj
</code></pre><p>Yikes! Taking a look at <code>impl.clj</code>, this seems to be the problem:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;deftype Client &#91;client-meta info&#93;
  clojure.lang.IObj
  &#40;meta &#91;&#95;&#93; @client-meta&#41;
  &#40;withMeta &#91;this m&#93; &#40;swap! client-meta merge m&#41; this&#41;

  ILookup
  &#40;valAt &#91;this k&#93;
    &#40;.valAt this k nil&#41;&#41;

  &#40;valAt &#91;this k default&#93;
    &#40;case k
      :api
      &#40;-&gt; info :service :metadata :net.jmglov.awno/service-name&#41;
      :region
      &#40;some-&gt; info :region-provider region/fetch&#41;
      :endpoint
      &#40;some-&gt; info :endpoint-provider &#40;endpoint/fetch &#40;.valAt this :region&#41;&#41;&#41;
      :credentials
      &#40;some-&gt; info :credentials-provider credentials/fetch&#41;
      :service
      &#40;some-&gt; info :service &#40;select-keys &#91;:metadata&#93;&#41;&#41;
      :http-client
      &#40;:http-client info&#41;
      default&#41;&#41;

  client.protocol/Client
  &#40;-get-info &#91;&#95;&#93; info&#41;

  ;; &#91;...&#93;
  &#41;
</code></pre><p><code>Client</code> implements the <code>clojure.lang.IObj</code> and <code>ILookup</code> interfaces, and Babashka only supports implementing protocols.</p><p>Let's see how awyeah-api handles this by looking at <a href='https://github.com/grzm/awyeah-api/blob/main/src/com/grzm/awyeah/client/impl.clj'>src/com/grzm/awyeah/client/impl.clj</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defrecord Client &#91;info&#93;
  client.protocol/Client
  &#40;-get-info &#91;&#95;&#93; info&#41;

  ;; &#91;...&#93;
  &#41;

;; -&gt;Client is intended for internal use
&#40;alter-meta! #'-&gt;Client assoc :skip-wiki true&#41;

&#40;defn client &#91;client-meta info&#93;
  &#40;let &#91;region &#40;some-&gt; info :region-provider region/fetch&#41;&#93;
    &#40;-&gt; &#40;with-meta &#40;-&gt;Client info&#41; @client-meta&#41;
        &#40;assoc :region region
               :endpoint &#40;some-&gt; info :endpoint-provider &#40;endpoint/fetch region&#41;&#41;
               :credentials &#40;some-&gt; info :credentials-provider credentials/fetch&#41;
               :service &#40;some-&gt; info :service &#40;select-keys &#91;:metadata&#93;&#41;&#41;
               :http-client &#40;:http-client info&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Interesting. We can do the same thing, transforming <code>deftype</code> into <code>defrecord</code> and removing the <code>clojure.lang.IObj</code> and <code>ILookup</code> interface methods:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defrecord Client &#91;info&#93;
  client.protocol/Client
  &#40;-get-info &#91;&#95;&#93; info&#41;

  &#40;-invoke &#91;client op-map&#93;
    &#40;a/&lt;!! &#40;client.protocol/-invoke-async client op-map&#41;&#41;&#41;

  &#40;-invoke-async &#91;client {:keys &#91;op request&#93; :as op-map}&#93;
    &#40;let &#91;result-chan &#40;or &#40;:ch op-map&#41; &#40;a/promise-chan&#41;&#41;
          {:keys &#91;service retriable? backoff&#93;} &#40;client.protocol/-get-info client&#41;
          spec &#40;and &#40;validation/validate-requests? client&#41; &#40;validation/request-spec service op&#41;&#41;&#93;
      &#40;cond
        &#40;not &#40;contains? &#40;:operations service&#41; &#40;:op op-map&#41;&#41;&#41;
        &#40;a/put! result-chan &#40;validation/unsupported-op-anomaly service op&#41;&#41;

        &#40;and spec &#40;not &#40;validation/valid? spec request&#41;&#41;&#41;
        &#40;a/put! result-chan &#40;validation/invalid-request-anomaly spec request&#41;&#41;

        :else
        ;; In case :body is an InputStream, ensure that we only read
        ;; it once by reading it before we send it to with-retry.
        &#40;let &#91;req &#40;-&gt; &#40;aws.protocols/build-http-request service op-map&#41;
                      &#40;update :body util/-&gt;bbuf&#41;&#41;&#93;
          &#40;retry/with-retry
            #&#40;send-request client op-map req&#41;
            result-chan
            &#40;or &#40;:retriable? op-map&#41; retriable?&#41;
            &#40;or &#40;:backoff op-map&#41; backoff&#41;&#41;&#41;&#41;

      result-chan&#41;&#41;

  &#40;-stop &#91;aws-client&#93;
    &#40;let &#91;{:keys &#91;http-client&#93;} &#40;client.protocol/-get-info aws-client&#41;&#93;
      &#40;when-not &#40;#'shared/shared-http-client? http-client&#41;
        &#40;http/stop http-client&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Now we need to add a <code>client</code> function to return a <code>Client</code> record to callers. In the <code>deftype</code> version, fields are implemented like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">  &#40;valAt &#91;this k default&#93;
    &#40;case k
      :api
      &#40;-&gt; info :service :metadata :net.jmglov.awno/service-name&#41;
      :region
      &#40;some-&gt; info :region-provider region/fetch&#41;
      :endpoint
      &#40;some-&gt; info :endpoint-provider &#40;endpoint/fetch &#40;.valAt this :region&#41;&#41;&#41;
      :credentials
      &#40;some-&gt; info :credentials-provider credentials/fetch&#41;
      :service
      &#40;some-&gt; info :service &#40;select-keys &#91;:metadata&#93;&#41;&#41;
      :http-client
      &#40;:http-client info&#41;
      default&#41;&#41;
</code></pre><p>Let's follow the lead of awyeah-api and use a Clojure map to do the same:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn client &#91;client-meta info&#93;
  &#40;let &#91;region &#40;some-&gt; info :region-provider region/fetch&#41;&#93;
    &#40;-&gt; &#40;with-meta &#40;-&gt;Client info&#41; @client-meta&#41;
        &#40;assoc :api &#40;-&gt; info :service :metadata :cognitect.aws/service-name&#41;
               :region region
               :endpoint &#40;some-&gt; info :endpoint-provider &#40;endpoint/fetch region&#41;&#41;
               :credentials &#40;some-&gt; info :credentials-provider credentials/fetch&#41;
               :service &#40;some-&gt; info :service &#40;select-keys &#91;:metadata&#93;&#41;&#41;
               :http-client &#40;:http-client info&#41;&#41;&#41;&#41;&#41;
</code></pre><p>We also need to update the <code>-invoke</code> method of <code>Client</code> to call <code>-invoke-async</code> as a function, not an instance method:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">  &#40;-invoke &#91;client op-map&#93;
    &#40;a/&lt;!! &#40;client.protocol/-invoke-async client op-map&#41;&#41;&#41;
</code></pre><p>Note the <code>:api</code> key we added to the map, which isn't present in the awyeah-api version. Taking a look at <a href='https://github.com/cognitect-labs/aws-api/blob/main/src/cognitect/aws/client/impl.clj'>src/cognitect/aws/client/impl.clj</a> in aws-api with <code>magit-blame</code>, we can see why:</p><pre class="language-text"><code class="lang-text language-text">  &#40;valAt &#91;this k default&#93;
    &#40;case k
David Chelimsky	2022-12-01 20:31 add keyword access to :api key on client
      :api
      &#40;-&gt; info :service :metadata :cognitect.aws/service-name&#41;
Maria Clara Crespo	2022-09-12 11:23 introduce test double client
      :region
      &#40;some-&gt; info :region-provider region/fetch&#41;
</code></pre><p>Since this change is clearly intentional, we ported it over, changing the <code>:net.jmglov.awno/service-name</code> keyword (which resulted from our <code>projectile-replace cognitect.aws -&gt; net.jmglov.awno</code>) back to <code>:cognitect.aws/service-name</code>.</p><p>And now that we've replaced the type with a record, we need to make sure it can only be instantiated using our <code>client</code> function. Let's make <code>-&gt;Client</code> private:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">;; -&gt;Client is intended for internal use
&#40;alter-meta! #'-&gt;Client assoc :skip-wiki true&#41;
</code></pre><p>Now, we need to track down all callers of <code>-&gt;Client</code> outside this namespace. There only turns out to be one, in <code>net.jmglov.awno.http-client</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;client/-&gt;Client
     &#40;atom {'clojure.core.protocols/datafy &#40;fn &#91;c&#93;
                                             &#40;let &#91;info &#40;client.protocol/-get-info c&#41;
                                                   region &#40;region/fetch &#40;:region-provider info&#41;&#41;
                                                   endpoint &#40;endpoint/fetch &#40;:endpoint-provider info&#41; region&#41;&#93;
                                               &#40;-&gt; info
                                                   &#40;select-keys &#91;:service&#93;&#41;
                                                   &#40;assoc :api &#40;-&gt; info :service :metadata :cognitect.aws/service-name&#41;&#41;
                                                   &#40;assoc :region region :endpoint endpoint&#41;
                                                   &#40;update :endpoint select-keys &#91;:hostname :protocols :signatureVersions&#93;&#41;
                                                   &#40;update :service select-keys &#91;:metadata&#93;&#41;
                                                   &#40;assoc :ops &#40;ops c&#41;&#41;&#41;&#41;&#41;}&#41;
     {:service              service
      :retriable?           &#40;or retriable? retry/default-retriable?&#41;
      :backoff              &#40;or backoff retry/default-backoff&#41;
      :http-client          http-client
      :endpoint-provider    endpoint-provider
      :region-provider      region-provider
      :credentials-provider credentials-provider
      :validate-requests?   &#40;atom nil&#41;}&#41;
</code></pre><p>Making the change is as easy as replacing <code>client/-&gt;Client</code> with <code>client/client</code>! Oh yeah, and replacing that <code>:net.jmglov.awno/service-name</code> with <code>:cognitect.aws/service-name</code>.</p><p>However, when we eval the buffer, we get another nasty dynaload-related surprise:</p><pre><code>clojure.lang.ExceptionInfo: Could not resolve symbol: dynaload/load-ns
{:type :sci/error,
 :file &quot;/home/jmglov/Documents/code/clojure/awno-api/src/net/jmglov/awno/client/api.clj&quot;,
 :phase &quot;analysis&quot;}
</code></pre><p>Apparently there's more to dynaload than just <code>load-var</code>. And strangely enough, <code>com.grzm.awyeah.client.api</code> contains a dynaload require:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns com.grzm.awyeah.client.api
  &quot;API functions for using a client to interact with AWS services.&quot;
  &#40;:require
   ;; &#91;...&#93;
   &#91;com.grzm.awyeah.dynaload :as dynaload&#93;
   ;; &#91;...&#93;
   &#91;com.grzm.awyeah.signers&#93;&#41;&#41;
</code></pre><p>No mention of this was made in <code>porting-decisions.markdown</code>. 😭</p><p>Let's just copy <code>com.grzm.awyeah.dynaload</code> and jmglov-ify it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">;; Copyright &#40;c&#41; Cognitect, Inc.
;; All rights reserved.

&#40;ns &#94;:skip-wiki net.jmglov.awno.dynaload&#41;

&#40;set! &#42;warn-on-reflection&#42; true&#41;

&#40;defonce &#94;:private dynalock &#40;Object.&#41;&#41;

&#40;defn load-ns &#91;ns&#93;
  &#40;locking dynalock
    &#40;require &#40;symbol ns&#41;&#41;&#41;&#41;
</code></pre><p>With that done, <code>net.jmglov.awno.client.api</code> evaluates with no errors! 🏆</p><h2 id="testing%2C_testing%2C_one_two_three...">Testing, testing, one two three...</h2><p>Surely our tests will run now! Right?</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bin/test
bb tests
----- Error --------------------------------------------------------------------
Type:     java.lang.Exception
Message:  Could not find namespace: clojure.test.check.clojure-test.
Location: /home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/client/impl&#95;test.clj:3:3
</code></pre><p>Urk! Looks like awyeah-api didn't include property-based tests. Let's add <a href='https://github.com/clojure/test.check'>test.check</a> to our <code>bb.edn</code> and see what happens. And don't worry, we're only going to end up with <code>test.check</code> added to our runtime dependencies when running <code>bb</code> directly, not when using <code>awno-api</code> as a dependency, since <code>deps.edn</code> is used in that case, not <code>bb.edn</code>. This is important for me, since my interest in awyeah-api is solely as a way to use AWS stuff from <a href='https://github.com/jmglov/blambda'>blambda</a>, I don't want extra stuff taking up space in my deps layer. Not to mention having test dependencies in your production code is just icky. 😅</p><p>So anyway, whacking</p><pre class="language-clojure"><code class="lang-clojure language-clojure">org.clojure/test.check {:mvn/version &quot;1.1.1&quot;}
</code></pre><p>into the <code>:deps</code> map then re-running the tests yields the following:</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bin/test
bb tests
----- Error --------------------------------------------------------------------
Type:     java.lang.Exception
Message:  Could not find namespace: cognitect.client.impl-test.
Location: /home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/client/test&#95;double&#95;test.clj:3:3
</code></pre><p>Oops. Looks like <code>cognitect.client</code> is in need of some <code>projectile-replace</code>. Replacing it with <code>net.jmglov.awno.client</code> should do the trick.</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bin/test
bb tests
----- Error --------------------------------------------------------------------
Type:     clojure.lang.ExceptionInfo
Message:  defrecord/deftype currently only support protocol implementations, found: ILookup
Data:     {:type :sci/error, :line 23, :column 1, :file &quot;/home/jmglov/Documents/code/clojure/awno-api/src/net/jmglov/awno/client/test&#95;double.clj&quot;}
Location: /home/jmglov/Documents/code/clojure/awno-api/src/net/jmglov/awno/client/test&#95;double.clj:23:1
Phase:    macroexpand
</code></pre><p>Urk! At this point, I'm reminded of a funny Rich Hickey quote:</p><p><img src="assets/2023-11-12-guardrails.png" alt="Rich Hickey giving a talk, saying the following: I think we’re in this world I’d like to call Guard Rail Programming... I can make change because I have tests! Who does that? Who drives their car around, banging against the guard rails? Do the guard rails help you get to where you want to go?" title="Simple made snarky" /></p><p>Opening up <code>net.jmglov.awno.client.test-double</code>, we see something familiar:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;deftype Client &#91;info handlers&#93;
  ILookup
  &#40;valAt &#91;this k&#93;
    &#40;.valAt this k nil&#41;&#41;

  &#40;valAt &#91;&#95;this k default&#93;
    &#40;case k
      :api
      &#40;-&gt; info :service :metadata :net.jmglov.awno/service-name&#41;
      :service
      &#40;:service info&#41;
      default&#41;&#41;

  client.protocol/Client
  &#40;-get-info &#91;&#95;&#93; info&#41;
  
  ;; &#91;...&#93;
  &#41;
</code></pre><p>Why don't we repeat our tried and tested remedy of replacing <code>deftype</code> with <code>defrecord</code>, then ripping out the <code>ILookup</code> interface and replacing it with a map? That gives us this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;deftype Client &#91;info handlers&#93;
  client.protocol/Client
  &#40;-get-info &#91;&#95;&#93; info&#41;

  &#40;-invoke &#91;this {:keys &#91;op request&#93; :as op-map}&#93;
    ;; &#91;...&#93;
    &#41;

  &#40;-invoke-async &#91;this {:keys &#91;ch&#93; :as op-map}&#93;
    ;; &#91;...&#93;
    &#41;

  &#40;-stop &#91;&#95;aws-client&#93;&#41;
  
  TestDoubleClient
  &#40;-instrument &#91;client ops&#93;
    ;; &#91;...&#93;
    &#41;&#41;

;; -&gt;Client is intended for internal use
&#40;alter-meta! #'-&gt;Client assoc :skip-wiki true&#41;
&#40;alter-meta! #'TestDoubleClient assoc :skip-wiki true&#41;

&#40;defn instrument
  &quot;Given a test double client and a `:ops` map of operations to handlers,
   instruments the client with handlers. See `client` for more info about
   `:ops`.&quot;
  &#91;client ops&#93;
  &#40;-instrument client ops&#41;&#41;

&#40;defn client
  &quot;Given a map with :api and :ops &#40;optional&#41;, returns a test double client that
  &#91;...&#93;
  - will not validate response payloads&quot;
  &#91;{:keys &#91;api ops&#93;}&#93;
  &#40;let &#91;service &#40;service/service-description &#40;name api&#41;&#41;&#93;
    &#40;doto &#40;-&gt;Client {:api &#40;-&gt; service :metadata :cognitect.aws/service-name&#41;
                     :service service
                     :validate-requests? &#40;atom true&#41;} &#40;atom {}&#41;&#41;
      &#40;instrument ops&#41;&#41;&#41;&#41;
</code></pre><p>OK, <strong>surely</strong> the tests will run now!</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bin/test
bb tests

Testing net.jmglov.awno.client.api-test

ERROR in &#40;test-underlying-http-client&#41; &#40;/home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/client/api&#95;test.clj:9&#41;
defaults to shared client
expected: &#40;= #{&#40;shared/http-client&#41;} &#40;into #{&#40;shared/http-client&#41;} &#40;-&gt;&gt; clients &#40;map &#40;fn &#91;c&#93; &#40;-&gt; c client.protocol/-get-info :http-client&#41;&#41;&#41;&#41;&#41;&#41;
  actual: clojure.lang.ExceptionInfo: Cannot find resource net.jmglov.awno/s3/service.edn.
{}
 at sci.lang.Var.invoke &#40;lang.cljc:202&#41;
    sci.impl.analyzer$return&#95;call$reify&#95;&#95;4543.eval &#40;analyzer.cljc:1399&#41;
&#91;...&#93;

Testing net.jmglov.awno.util-test

Ran 48 tests containing 675 assertions.
19 failures, 33 errors.
{:test 48, :pass 623, :fail 19, :error 33, :type :summary}
</code></pre><p><img src="assets/2023-11-11-victory.jpg" alt="A woman on a beach at sunrise with her head thrown back, saying "Victory"" title="Never in doubt" width=800px /></p><p>Well, sorta.</p><h2 id="what_the_what%3F">What the what?</h2><p><code>bb test</code> produced 6544 lines of output whilst failing those 19 tests and erroring out of a further 33. Let's strip away all the noise and see if we can see what's actually happening here.</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bin/test 2&gt;&amp;1 &gt;/tmp/err.log
ERROR in &#40;test-underlying-http-client&#41; &#40;/home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/client/api&#95;test.clj:9&#41;
defaults to shared client
expected: &#40;= #{&#40;shared/http-client&#41;} &#40;into #{&#40;shared/http-client&#41;} &#40;-&gt;&gt; clients &#40;map &#40;fn &#91;c&#93; &#40;-&gt; c client.protocol/-get-info :http-client&#41;&#41;&#41;&#41;&#41;&#41;
  actual: clojure.lang.ExceptionInfo: Cannot find resource net.jmglov.awno/s3/service.edn.
--
&#91;...&#93;
--
ERROR in &#40;raw-response-values&#41; &#40;/home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/client/test&#95;double&#95;test.clj:33&#41;
Uncaught exception, not in assertion.
expected: nil
  actual: clojure.lang.ExceptionInfo: null
--
&#91;...&#93;
--
ERROR in &#40;test-parse-date&#41; &#40;/home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/shape&#95;test.clj:5&#41;
iso8601 format handles presence and absence of fractional seconds
expected: &#40;= #inst &quot;2020-07-06T10:59:13.417-00:00&quot; &#40;shape/parse-date {:timestampFormat &quot;iso8601&quot;} &quot;2020-07-06T10:59:13.417Z&quot;&#41;&#41;
  actual: java.time.format.DateTimeParseException: Text '2020-07-06T10:59:13.417Z' could not be parsed at index 19
</code></pre><p>OK, not as bad as it could be. There are only three classes of errors here, repeated many times. The first one should be extremely simple to deal with. A quick search in the project for <code>service.edn</code> yields only one hit, in the <code>net.jmglov.awno.service</code> namespace:</p><pre class="language-` clojure"><code class="lang-` clojure language-` clojure">&#40;def base-ns &quot;net.jmglov.awno&quot;&#41;

&#40;def base-resource-path &quot;net.jmglov.awno&quot;&#41;
</code></pre><p>Looks like my search and replace was a bit excessively exuberant. Let's try restoring their Cognitectiness:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def base-ns &quot;cognitect.aws&quot;&#41;

&#40;def base-resource-path &quot;cognitect/aws&quot;&#41;
</code></pre><p>Before re-running all the tests, it would be quite lovely to give ourselves a way to run tests for a subset of namespaces, instead of all the ones specified in <code>net.jmglov.awno.test</code>. Actually, looking at <code>run-tests</code>, maybe we can!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn run-tests
  &#40;&#91;&#93;
   &#40;run-tests {:test-namespaces test-namespaces}&#41;&#41;
  &#40;&#91;{nses :test-namespaces}&#93;
   &#40;dorun &#40;map require nses&#41;&#41;
   &#40;let &#91;res &#40;apply test/run-tests nses&#41;&#93;
     &#40;pprint/pprint res&#41;
     &#40;when &#40;-&gt;&gt; &#40;&#40;juxt :fail :error&#41; res&#41;
                &#40;some #&#40;pos? %&#41;&#41;&#41;
       &#40;System/exit 1&#41;&#41;&#41;&#41;&#41;
</code></pre><p>The 1-arity version of <code>run-tests</code> lets us pass a map with <code>:test-namespaces</code>, and Babashka's <code>-x</code> flag uses <a href='https://book.babashka.org/#cli'>babashka-cli</a> to let us pass function arguments as command line flags, so we should be able to do this!</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bb -x net.jmglov.awno.test/run-tests --test-namespaces net.jmglov.awno.client.api-test
----- Error --------------------------------------------------------------------
Type:     java.lang.IllegalArgumentException
Message:  Don't know how to create ISeq from: java.lang.Character
Location: /home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/test.cljc:32:4
</code></pre><p>Blerg! Looks like the value of the <code>:test-namespaces</code> key is a string, rather than a list of strings. Luckily, babashka-cli lets you add metadata to functions to <a href='https://github.com/babashka/cli#clojure-cli'>control parsing behaviour</a>. Let's sprinkle some on <code>run-tests</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn run-tests
  {:org.babashka/cli {:coerce {:test-namespaces &#91;:symbol&#93;}}}
  &#40;&#91;&#93;
   &#40;run-tests {:test-namespaces test-namespaces}&#41;&#41;
  &#40;&#91;{nses :test-namespaces}&#93;
   &#40;dorun &#40;map require nses&#41;&#41;
   &#40;let &#91;res &#40;apply test/run-tests nses&#41;&#93;
     &#40;pprint/pprint res&#41;
     &#40;when &#40;-&gt;&gt; &#40;&#40;juxt :fail :error&#41; res&#41;
                &#40;some #&#40;pos? %&#41;&#41;&#41;
       &#40;System/exit 1&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Now babashka-cli will coerce <code>:test-namespaces</code> to a list of symbols! 😲</p><p>Let's try it out on a couple of test namespaces that were actually passing:</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bb -x net.jmglov.awno.test/run-tests \
  --test-namespaces net.jmglov.awno.config-test net.jmglov.awno.credentials-test
{:test-namespaces &#91;net.jmglov.awno.config-test net.jmglov.awno.credentials-test&#93;}

Testing net.jmglov.awno.config-test

Testing net.jmglov.awno.credentials-test

Ran 9 tests containing 41 assertions.
0 failures, 0 errors.
{:test 9, :pass 41, :fail 0, :error 0, :type :summary}
</code></pre><p>This looks good, so let's give api-test another go:</p><pre class="language-text"><code class="lang-text language-text">: awno-api; bb -x net.jmglov.awno.test/run-tests \
  --test-namespaces net.jmglov.awno.client.api-test

Testing net.jmglov.awno.client.api-test

Ran 3 tests containing 11 assertions.
0 failures, 0 errors.
{:test 3, :pass 11, :fail 0, :error 0, :type :summary}
</code></pre><p>OK, so that fix worked! Let's take a look at the next class of errors.</p><pre class="language-text"><code class="lang-text language-text">ERROR in &#40;raw-response-values&#41; &#40;/home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/client/test&#95;double&#95;test.clj:33&#41;
Uncaught exception, not in assertion.
expected: nil
  actual: clojure.lang.ExceptionInfo: null
</code></pre><h2 id="abort%21">Abort!</h2><p>It was at this point that I ran out of steam. I apologise for my lack of dedication to the art of self-flagellation and will flagellate myself accordingly.</p><p><img src="assets/2023-11-12-abort.jpg" alt="Abort!" title="A supercar driving out of the window of a skyscraper" width=800px /></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2023-11-11-awno-api.html</id>
    <link href="https://jmglov.net/blog/2023-11-11-awno-api.html"/>
    <title>Awno! Mutilating awyeah-api</title>
    <updated>2023-11-11T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>You remember how I've been trying to write a <a href='2023-01-04-blambda-analyses-sites.html'>site
analyser</a> for my blog for the last year or so? Well, I was using DynamoDB for storing some metrics, and I realised that would bankrupt me, so I decided to use <a href='https://docs.aws.amazon.com/athena/'>Amazon
Athena</a> run SQL queries directly on my access logs instead. That should have been a great idea, but when I fired up my REPL with <a href='https://github.com/grzm/awyeah-api'>awyeah-api</a>, I got quite the unpleasant surprise:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def athena &#40;aws/client {:api :athena
                           :region &#40;or &#40;System/getenv &quot;AWS&#95;DEFAULT&#95;REGION&quot;&#41; &quot;eu-west-1&quot;&#41;}&#41;&#41;
  ;; =&gt; #'user/athena

  &#40;aws/invoke athena {:op :ListWorkGroups
                      :request {}}&#41;
  ;; =&gt; {:&#95;&#95;type &quot;InvalidSignatureException&quot;,
  ;;     :message
  ;;     &quot;The request signature we calculated does not match the signature you provided. Check your AWS Secret Access Key and signing method. Consult the service documentation for details.\n\nThe Canonical String for this request should have been\n'POST\n/\n\naccept:application/json\ncontent-type:application/x-amz-json-1.1\nhost:athena.eu-west-1.amazonaws.com:443\nx-amz-date:20231105T115515Z\nx-amz-target:AmazonAthena.ListWorkGroups\n\naccept;content-type;host;x-amz-date;x-amz-target\n44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a'\n\nThe String-to-Sign should have been\n'AWS4-HMAC-SHA256\n20231105T115515Z\n20231105/eu-west-1/athena/aws4&#95;request\n4dd8829addcae8b5fe35d78323f484298d478ab9af786b3caa12bac0d2f57a8c'\n&quot;,
  ;;     :cognitect.anomalies/category :cognitect.anomalies/incorrect}
</code></pre><p>What the what?</p><p>Digging into this with some tasty debug <code>prn</code>s, I <a href='https://github.com/grzm/awyeah-api/issues/8#issuecomment-1576607738'>found
out</a> that the issue was that Athena was expecting a canonical string like this:</p><pre class="language-text"><code class="lang-text language-text">POST
/

accept:application/json
content-type:application/x-amz-json-1.1
host:athena.eu-west-1.amazonaws.com:443
x-amz-date:20230605T111631Z
x-amz-target:AmazonAthena.ListWorkGroups

accept;content-type;host;x-amz-date;x-amz-target
44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
</code></pre><p>And awyeah-api was generating a canonical string like that:</p><pre class="language-text"><code class="lang-text language-text">POST
/

accept:application/json
content-type:application/x-amz-json-1.1
host:athena.eu-west-1.amazonaws.com
x-amz-date:20230605T111631Z
x-amz-target:AmazonAthena.ListWorkGroups

accept;content-type;host;x-amz-date;x-amz-target
44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a
</code></pre><p>I'm sure you've already spotted the difference, right? 🙄</p><p>Take a look at the <code>host</code> header. Athena wants this:</p><pre class="language-text"><code class="lang-text language-text">host:athena.eu-west-1.amazonaws.com:443
</code></pre><p>And we're giving it that:</p><pre class="language-text"><code class="lang-text language-text">host:athena.eu-west-1.amazonaws.com
</code></pre><p>For want of a <code>:443</code>, the kingdom was lost! 😭</p><h2 id="what_is_to_be_done%3F">What is to be done?</h2><p>After filing a <a href='https://github.com/grzm/awyeah-api/issues/8#issuecomment-1576607738'>bug
report</a>, I started reading through all the commits to <a href='https://github.com/cognitect-labs/aws-api'>aws-api</a> since <code>0.8.603</code>, which is the latest commit that's been ported over to awyeah-api. These two in particular looked pretty tasty:</p><p><img src="assets/2023-11-11-aws-api-commits.png" alt="Commit messages on Github" title="The sus imposter from Among Us" width=800px /></p><p>I ported those two over to a clone of awyeah-api, but my REPL still informed me that I sucked:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;aws/invoke athena {:op :ListWorkGroups
                      :request {}}&#41;
  ;; =&gt; {:&#95;&#95;type &quot;InvalidSignatureException&quot;,
  ;;     :message
  ;;     &quot;The request signature we calculated does not match the signature you provided. Haven't you heard Einstein's definition of insanity, dude? Try something else already!\n&quot;,
  ;;     :cognitect.anomalies/category :cognitect.anomalies/you-suck}
</code></pre><p>Never one to give up so easily, I continued porting stuff, all the way up to the latest aws-api commits (skipping a few commits that I just couldn't figure out how to port), and not only did the <code>InvalidSignatureException</code> persist, but now I'd broken the awyeah-api tests. 🤦</p><h2 id="what_now_is_to_be_done%3F">What now is to be done?</h2><p>After sleeping on it, I realised that the root of my problem is that I was just porting code over without really thinking about what the code was doing, and that a better approach would perhaps be to understand the code. Obv.</p><p>But instead of just understanding the code I was porting and why, I decided that I needed to understand how Michael Glaesemann AKA <a href='https://github.com/grzm'>grzm</a> had made aws-api work with Babashka in the first place. And how better to do that than to fork aws-api into my own Github repo and start replicating the awyeah-api stuff on top of it? (I'm sure there are much better ways to do that, but if you've been reading this blog for more than five minutes, you'll know that I never take the better way when a worse and more painful way is available. Just call me Gandalf in the Mines of Moria.)</p><p>And what better name to capture the dismay that I'm sure you're feeling as you read this than <a href='https://github.com/jmglov/awno-api'>awno-api</a>? And what better tense to capture the excitement of a fool's errand than the present?</p><h2 id="let_the_mutilation_begin%21">Let the mutilation begin!</h2><p>After cloning my new forked repo, let's get to work making aws-api into something that will work with babashka. The first order of business is purely mechanical:</p><pre class="language-text"><code class="lang-text language-text">cd &#126;/Documents/code/clojure/awyeah-api
cp -r bb.edn bin CHANGES.markdown docs etc ../awno-api/
cd ../awno-api
mkdir -p src/net/jmglov test/src/net/jmglov
git mv src/cognitect/aws src/net/jmglov/awno
git mv test/src/cognitect/aws test/src/net/jmglov/awno
git rm -rf doc latest-releases.edn pom.xml UPGRADE.md README.md CHANGES.md 
rm -rf src/cognitect test/src/cognitect
</code></pre><p>Now let's update <code>bb.edn</code> to use the latest versions of all the good stuff. We can grab the commit SHA for the latest version of the babashka port of Spec from <a href='https://github.com/babashka/spec.alpha'>https://github.com/babashka/spec.alpha</a>, then the various versions of the AWS libraries from <a href='https://github.com/cognitect-labs/aws-api/blob/main/latest-releases.edn'>latest-releases.edn</a> in the aws-api repo, resulting in a shiny new <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;resources&quot; &quot;src&quot; &quot;test/resources&quot; &quot;test/src&quot;&#93;
 :deps
 {org.babashka/spec.alpha {:git/url &quot;https://github.com/babashka/spec.alpha&quot;
                           :git/sha &quot;8df0712896f596680da7a32ae44bb000b7e45e68&quot;}
  org.clojure/tools.logging {:mvn/version &quot;1.2.4&quot;}

  com.cognitect.aws/endpoints {:mvn/version &quot;1.1.12.307&quot;}

  com.cognitect.aws/ec2
  {:mvn/version &quot;822.2.1122.0&quot;, :aws/serviceFullName &quot;Amazon Elastic Compute Cloud&quot;},
  com.cognitect.aws/lambda
  {:mvn/version &quot;822.2.1122.0&quot;, :aws/serviceFullName &quot;AWS Lambda&quot;},
  com.cognitect.aws/s3
  {:mvn/version &quot;822.2.1145.0&quot;}
  com.cognitect.aws/ssm
  {:mvn/version &quot;822.2.1122.0&quot;, :aws/serviceFullName &quot;Amazon Simple Systems Manager &#40;SSM&#41;&quot;}
  com.cognitect.aws/sts
  {:mvn/version &quot;822.2.1109.0&quot;, :aws/serviceFullName &quot;AWS Security Token Service&quot;}}}
</code></pre><p>Now to open up <code>awno-api/README.markdown</code>, cry havoc, and let slip the dogs of <code>projectile-replace</code> with <code>M-p R</code>! (If you're not using <a href='https://github.com/doomemacs/'>Doom
Emacs</a>, you'll probably need to smack some different keys; if you don't know which ones, you can always take the `M-x projectile-replace` route).</p><p><img src="assets/2023-11-11-projectile-replace.png" alt="Emacs prompting to replace cognitect.aws with net.jmglov.awno" title="Fly, my pretties!" width=800px /></p><p>After replacing all of the instances (you'll be prompted to replace each occurrence, but you hit <code>!</code> at the first prompt if you want to YOLO replace all occurrences), you can use <code>C-x s</code> to invoke <code>save-some-buffers</code>, then hit <code>!</code> at the prompt to save all of the files you just replaced <code>cognitect.aws</code> in.</p><p>Now that all your namespaces are belong to us, it's time for the moment of truth! Will the REPL start? With baited breath, let's hit <code>C-c M-j</code> to invoke <code>cider-jack-in-clj</code>, instruct <a href='https://github.com/clojure-emacs/cider'>CIDER</a> to use babashka, and hope for the best.</p><pre class="language-text"><code class="lang-text language-text">Started nREPL server at 127.0.0.1:35223
For more info visit: https://book.babashka.org/#&#95;nrepl
;; Connected to nREPL server - nrepl://127.0.0.1:35223
;; CIDER 1.7.0-snapshot &#40;package: 1.7.0-snapshot&#41;, babashka.nrepl 0.0.6-SNAPSHOT
;; Babashka 1.3.176
;;     Docs: &#40;doc function-name&#41;
;;           &#40;find-doc part-of-name&#41;
;;   Source: &#40;source function-name&#41;
;;  Javadoc: &#40;javadoc java-object-or-class&#41;
;;     Exit: &lt;C-c C-q&gt;
;;  Results: Stored in vars &#42;1, &#42;2, &#42;3, an exception in &#42;e;
;;  Startup: /home/jmglov/.nix-profile/bin/bb nrepl-server localhost:0
WARNING: Can't determine Clojure version.  The refactor-nrepl middleware requires clojure 1.8.0 &#40;or newer&#41;WARNING: clj-refactor and refactor-nrepl are out of sync.
Their versions are 3.6.0 and n/a, respectively.
You can mute this warning by changing cljr-suppress-middleware-warnings.
user&gt; 
</code></pre><p><img src="assets/2023-11-11-noice.gif" alt="Jake Peralta from Brooklyn 99 saying "Noice"" title="Toit!" /></p><h2 id="what%27s_the_big_deal%2C_anyway%3F">What's the big deal, anyway?</h2><p>Equipped with our trusty REPL, it's time to get down to the real work of really making it work. Luckily for us, good ol' grzm has captured the decisions he made whilst porting aws-api in a nice <a href='https://github.com/grzm/awyeah-api/blob/main/docs/porting-decisions.markdown'>porting-decisions.markdown</a> doc, so let's start reading.</p><p>Right at the top of the doc, he mentions a <a href='https://github.com/grzm/awyeah-api/blob/main/docs/porting-decisions.markdown#missing-classes'>set of Java
classes</a> that are missing from babashka:</p><ul><li><code>java.lang.Runnable</code></li><li><code>java.lang.ThreadLocal</code></li><li><code>java.util.concurrent.ThreadFactory</code></li><li><code>java.util.concurrent.ScheduledFuture</code></li><li><code>java.util.concurrent.ScheduledExecutorService</code></li></ul><p>Since awyeah-api was originally written, three of those five classes have been added to babashka, according to <a href='https://github.com/babashka/babashka/blob/6353ab9de948679152afb3aa2daa811740d59b0f/src/babashka/impl/classes.clj#L23'>babashka.impl.classes</a>:</p><ul><li><code>java.lang.Runnable</code></li><li><code>java.util.concurrent.ThreadFactory</code></li><li><code>java.util.concurrent.ScheduledExecutorService</code></li></ul><p>So now we only have to deal with the other two.</p><p>According to grzm:</p><blockquote><p> These classes are referenced in two namespaces: <code>cognitect.aws.util</code>  and <code>cognitect.aws.credentials</code>. <code>ThreadLocal</code> is used in  <code>cognitect.aws.util</code> to make <a href='http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4228335'><code>java.text.SimpleDateFormat</code>  thread-safe</a>.  As I'm not concerned with supporting pre-Java 8 versions, I've decided to use  the thread-safe <code>java.time.format.DateTimeFormatter</code> rather than drop  thread-safety workarounds for <code>SimpleDateFormat</code> or implement them in some  other way. </p><p> The <code>cognitect.aws.util</code> namespace is used throughout the aws-api  library, either directly or transitively. </p><p> The balance of the unincluded classes are used in  <code>cognitect.aws.credentials</code> to provide auto-refreshing of AWS  credentials. As babashka is commonly used for short-lived scripts as  opposed to long-running server applications, rather than provide an  alternate implementation for credential refresh, I've chosen to omit  this functionality. If credential auto-refresh is something I find  <i>is</i> useful in a babashka context some time in the future, a solution  can be explored at that time. </p></blockquote><p>Snatching up that tasty breadcrumb, let's open our shiny new <code>net.jmglov.awno.util</code> namespace.</p><h2 id="thread_safety%3F_we_don%27t_need_no_stinkin%27_thread_safety%21">Thread safety? We don't need no stinkin' thread safety!</h2><p>As we have no more appetite than grzm for supporting Java versions older than Java 8, let's just follow his lead and kick <code>ThreadLocal</code> to the curb. That means replacing this mess from aws-api:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">
&#40;defn  date-format
  &quot;Return a thread-safe GMT date format that can be used with `format-date` and `parse-date`.

  See http://bugs.sun.com/bugdatabase/view&#95;bug.do?bug&#95;id=4228335&quot;
  &#94;ThreadLocal &#91;&#94;String fmt&#93;
  &#40;proxy &#91;ThreadLocal&#93; &#91;&#93;
    &#40;initialValue &#91;&#93;
      &#40;doto &#40;SimpleDateFormat. fmt&#41;
        &#40;.setTimeZone &#40;TimeZone/getTimeZone &quot;GMT&quot;&#41;&#41;&#41;&#41;&#41;&#41;

&#40;defn format-date
  &#40;&#91;fmt&#93;
   &#40;format-date fmt &#40;Date.&#41;&#41;&#41;
  &#40;&#91;&#94;ThreadLocal fmt inst&#93;
   &#40;.format &#94;SimpleDateFormat &#40;.get fmt&#41; inst&#41;&#41;&#41;
</code></pre><p>With this lovely stuff from awyeah-api:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn date-format
  &quot;Return a thread-safe GMT date format that can be used with `format-date` and `parse-date`.&quot;
  &#91;&#94;String fmt&#93;
  &#40;.withZone &#40;DateTimeFormatter/ofPattern fmt&#41; &#40;ZoneId/of &quot;GMT&quot;&#41;&#41;&#41;

&#40;defn format-date
  &#40;&#91;formatter&#93;
   &#40;format-date formatter &#40;Date.&#41;&#41;&#41;
  &#40;&#91;formatter &#94;Date inst&#93;
   &#40;.format &#40;ZonedDateTime/ofInstant &#40;.toInstant inst&#41; &#40;ZoneId/of &quot;UTC&quot;&#41;&#41;
            &#94;DateTimeFormatter formatter&#41;&#41;&#41;
</code></pre><p>Copy and paste-driven development FTW! 🎉</p><p>The <code>parse-date</code> function from aws-api needed a little love too. This:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn parse-date
  &#91;&#94;ThreadLocal fmt s&#93;
  &#40;.parse &#94;SimpleDateFormat &#40;.get fmt&#41; s&#41;&#41;
</code></pre><p>Becomes that:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn parse-date
  &#91;formatter s&#93;
  &#40;Date/from &#40;.toInstant &#40;ZonedDateTime/parse s formatter&#41;&#41;&#41;&#41;
</code></pre><p>This leaves only five more occurrences of <code>ThreadLocal</code> in the namespace:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def &#94;ThreadLocal x-amz-date-format
  &#40;date-format &quot;yyyyMMdd'T'HHmmss'Z'&quot;&#41;&#41;

&#40;def &#94;ThreadLocal x-amz-date-only-format
  &#40;date-format &quot;yyyyMMdd&quot;&#41;&#41;

&#40;def &#94;ThreadLocal iso8601-date-format
  &#40;date-format &quot;yyyy-MM-dd'T'HH:mm:ssXXX&quot;&#41;&#41;

&#40;def &#94;ThreadLocal iso8601-msecs-date-format
  &#40;date-format &quot;yyyy-MM-dd'T'HH:mm:ss.SSS'Z'&quot;&#41;&#41;

&#40;def &#94;ThreadLocal rfc822-date-format
  &#40;date-format &quot;EEE, dd MMM yyyy HH:mm:ss z&quot;&#41;&#41;
</code></pre><p>For these, all we need to do is remove the <code>&#94;ThreadLocal</code> type hint and we're golden! Well, almost golden. We're using a few <code>java.time</code> classes that weren't in the aws-api version, so we need to add those, and whilst we're at it, let's go ahead and remove the classes that aren't necessary anymore, and alphabetise the requires and imports in the <code>ns</code> form and OMG what are square brackets doing in the <code>:import</code> section? <a href='https://stuartsierra.com/2016/clojure-how-to-ns.html'>According to St.
Stuart</a>, thou must always use round brackets with <code>:import</code>, never square!</p><p>Making the sign to ward off evil, we fix the glitch.</p><p><img src="assets/2023-11-11-glitch.jpg" alt="The Bobs from Office Space saying "So we fixed the glitch"" title="I'm gonna need you to go ahead and come in Sunday, too" /></p><p>With all of this done, we should be able to eval the namespace in our REPL, right? <code>C-c C-k</code> (<code>cider-load-buffer</code>) will tell the tale:</p><pre class="language-text"><code class="lang-text language-text">clojure.lang.ExceptionInfo: Could not find namespace: clojure.data.json.
{:type :sci/error, :line 9, :column 3, :message &quot;Could not find namespace: clojure.data.json.&quot;, :sci.impl/callstack #object&#91;clojure.lang.Volatile 0xfb2c337 {:status :ready, :val &#40;{:line 9, :column 3, :file &quot;/home/jmglov/Documents/code/clojure/awno-api/src/net/jmglov/awno/util.clj&quot;, :ns #object&#91;sci.lang.Namespace 0x2c8684c &quot;net.jmglov.awno.util&quot;&#93;}&#41;}&#93;, :file &quot;/home/jmglov/Documents/code/clojure/awno-api/src/net/jmglov/awno/util.clj&quot;}
 at sci.impl.utils$rethrow&#95;with&#95;location&#95;of&#95;node.invokeStatic &#40;utils.cljc:129&#41;
    sci.impl.analyzer$return&#95;ns&#95;op$reify&#95;&#95;4355.eval &#40;analyzer.cljc:1189&#41;
    sci.impl.analyzer$return&#95;do$reify&#95;&#95;3968.eval &#40;analyzer.cljc:130&#41;
    sci.impl.interpreter$eval&#95;form.invokeStatic &#40;interpreter.cljc:40&#41;
    sci.core$eval&#95;form.invokeStatic &#40;core.cljc:329&#41;
    babashka.nrepl.impl.server$eval&#95;msg$fn&#95;&#95;27295$fn&#95;&#95;27296.invoke &#40;server.clj:108&#41;
    &#91;...&#93;
</code></pre><p>Blerg!</p><h2 id="json_voorhees">JSON Voorhees</h2><p>No <code>clojure.data.json</code>, eh? Let's return to <code>porting-decisions.markdown</code> and see is grzm has anything to say about that. <a href='https://github.com/grzm/awyeah-api/blob/main/docs/porting-decisions.markdown#clojuredatajson-and-cheshire'>Indeed he
does</a>!</p><blockquote><p> The aws-api library depends on  <a href='https://github.com/clojure/data.json'><code>clojure.data.json</code></a> for JSON serialization and  deserialization, a pure Clojure library. Babashka includes  <a href='https://github.com/dakrone/cheshire'>Cheshire</a> for JSON support and not <code>clojure.data.json</code>. </p><p> The Clojure source of <code>clojure.data.json</code> can be interpreted by sci,  so I could include <code>clojure.data.json</code> as a dependency and use it  as-is. The <code>clojure.data.json</code> usage in aws-api is easily replaced by  Cheshire. Replacing <code>clojure.data.json</code> with Cheshire means one less  dependency to include, and we can leverage compiled code rather than  interpreted. To isolate the library choice, I've extracted the  library-specific calls in the <code>com.grzm.awyeah.json</code> namespace. </p></blockquote><p>We can copy <code>src/com/grzm/awyeah/json.clj</code> from awyeah-api over to awno-api and fix up the namespace declaration. Let's also make <code>read-str</code> plug compatible with <code>clojure.data.json</code> by adding a three arity form that allows passing a custom key function and of course supporting the deprecated <code>json-str</code> function (because it's sometimes used in aws-api instead of <code>write-str</code>):</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns net.jmglov.awno.json
  &#40;:require &#91;cheshire.core :as json&#93;&#41;&#41;

&#40;defn write-str &#91;x&#93;
  &#40;json/generate-string x&#41;&#41;

;; Implement deprecated function as well
&#40;def json-str write-str&#41;

&#40;defn read-str
  &#40;&#91;s&#93;
   &#40;read-str s :key-fn keyword&#41;&#41;
  &#40;&#91;s &#95; key-fn&#93;
   &#40;json/parse-string s key-fn&#41;&#41;&#41;
</code></pre><p>This will handle all the calls like this in the aws-api codebase:</p><pre class="language-clj"><code class="lang-clj language-clj">&#40;json/read-str :key-fn keyword&#41;
</code></pre><p>Having done that, let's <code>M-p R</code> (like <a href='https://www.npr.org/'>NPR</a>, but runs <code>projectile-replace</code> instead of giving you some news with a vaugely progressive yet US-centric slant) to replace <code>clojure.data.json</code> with <code>net.jmglov.awno.json</code> project-wide. Don't forget to <code>C-x s !</code> again to save all of the files we just modified.</p><p>Now let's try evaluating the namespace...</p><p><img src="assets/2023-11-11-victory.jpg" alt="A woman on a beach at sunrise with her head thrown back, saying "Victory"" title="Never in doubt" width=800px /></p><p>At this point, we should be able to run the <code>net.jmglov.awno.util</code> tests. Popping over to <code>test/src/net/jmglov/awno/util&#95;test.clj</code>, we see that the Cognitect folks have left us a nice little Rich comment at the bottom of the file:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment
  &#40;t/run-tests&#41;&#41;
</code></pre><p>Let's expand it a bit, put the cursor at the end of the <code>t/run-tests</code> form, and do some <code>C-c C-v f c e</code> to invoke <code>cider-pprint-eval-last-sexp-to-comment</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;t/run-tests&#41;
  ;; =&gt; {:test 6, :pass 13, :fail 0, :error 0, :type :summary}

  &#41;
</code></pre><p>Boom! Now we're well underway!</p><h2 id="you_don%27t_need_to_see_his_credentials">You don't need to see his credentials</h2><p>Back in <code>porting-decisions.markdown</code>, there's some mention of the other Java classes not included in babashka:</p><blockquote><p> The balance of the unincluded classes are used in  <code>cognitect.aws.credentials</code> to provide auto-refreshing of AWS  credentials. As babashka is commonly used for short-lived scripts as  opposed to long-running server applications, rather than provide an  alternate implementation for credential refresh, I've chosen to omit  this functionality. If credential auto-refresh is something I find  <i>is</i> useful in a babashka context some time in the future, a solution  can be explored at that time. </p></blockquote><p>Seems like we can also live without credential auto-refresh, so let's have a quick look at the <code>net.jmglov.awno.credentials</code> and see what we'll need to do to make it work.</p><p>Looks like we need to rip this bit out, since it uses thready thread stuff that we hate and fear:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defonce &#94;:private scheduled-executor-service
  &#40;delay
   &#40;Executors/newScheduledThreadPool 1 &#40;reify ThreadFactory
                                         &#40;newThread &#91;&#95; r&#93;
                                           &#40;doto &#40;Thread. r&#41;
                                             &#40;.setName &quot;net.jmglov.awno-api.credentials-provider&quot;&#41;
                                             &#40;.setDaemon true&#41;&#41;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Now we can simplify <code>refresh!</code> from this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn &#94;:skip-wiki refresh!
  &quot;For internal use. Don't call directly.

  Invokes `&#40;fetch provider&#41;`, resets the `credentials-atom` with and
  returns the result.

  If the credentials returned by the provider are not valid, resets
  both atoms to nil and returns nil.&quot;
  &#91;credentials-atom scheduled-refresh-atom provider scheduler&#93;
  &#40;try
    &#40;let &#91;{:keys &#91;::ttl&#93; :as new-creds} &#40;fetch provider&#41;&#93;
      &#40;reset! scheduled-refresh-atom
              &#40;when ttl
                &#40;.schedule &#94;ScheduledExecutorService scheduler
                           &#94;Runnable #&#40;refresh! credentials-atom scheduled-refresh-atom provider scheduler&#41;
                           &#94;long ttl
                           TimeUnit/SECONDS&#41;&#41;&#41;
      &#40;reset! credentials-atom new-creds&#41;&#41;
    &#40;catch Throwable t
      &#40;reset! scheduled-refresh-atom nil&#41;
      &#40;log/error t &quot;Error fetching credentials.&quot;&#41;&#41;&#41;&#41;
</code></pre><p>To that:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn &#94;:skip-wiki refresh!
  &quot;For internal use. Don't call directly.

  Invokes `&#40;fetch provider&#41;`, resets the `credentials-atom` with and
  returns the result.

  If the credentials returned by the provider are not valid, resets
  both atoms to nil and returns nil.&quot;
  &#91;credentials-atom provider&#93;
  &#40;try
    &#40;let &#91;new-creds &#40;fetch provider&#41;&#93;
      &#40;reset! credentials-atom new-creds&#41;&#41;
    &#40;catch Throwable t
      &#40;log/error t &quot;Error fetching credentials.&quot;&#41;&#41;&#41;&#41;
</code></pre><p>And then we can rip out this stuff:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">
&#40;defn cached-credentials-with-auto-refresh
  &quot;Returns a CredentialsProvider which wraps `provider`, caching
  credentials returned by `fetch`, and auto-refreshing the cached
  credentials in a background thread when the credentials include a
  ::ttl.

  Call `stop` to cancel future auto-refreshes.

  The default ScheduledExecutorService uses a ThreadFactory that
  spawns daemon threads. You can override this by providing your own
  ScheduledExecutorService.

  Alpha. Subject to change.&quot;
  &#40;&#91;provider&#93;
   &#40;cached-credentials-with-auto-refresh provider @scheduled-executor-service&#41;&#41;
  &#40;&#91;provider scheduler&#93;
   &#40;let &#91;credentials-atom       &#40;atom nil&#41;
         scheduled-refresh-atom &#40;atom nil&#41;&#93;
     &#40;reify
       CredentialsProvider
       &#40;fetch &#91;&#95;&#93;
         &#40;or @credentials-atom
             &#40;refresh! credentials-atom scheduled-refresh-atom provider scheduler&#41;&#41;&#41;
       Stoppable
       &#40;-stop &#91;&#95;&#93;
         &#40;-stop provider&#41;
         &#40;when-let &#91;r @scheduled-refresh-atom&#93;
           &#40;.cancel &#94;ScheduledFuture r true&#41;&#41;&#41;&#41;&#41;&#41;&#41;

&#40;defn &#94;:deprecated auto-refreshing-credentials
  &quot;Deprecated. Use cached-credentials-with-auto-refresh&quot;
  &#40;&#91;provider&#93; &#40;cached-credentials-with-auto-refresh provider&#41;&#41;
  &#40;&#91;provider scheduler&#93; &#40;cached-credentials-with-auto-refresh provider scheduler&#41;&#41;&#41;
</code></pre><p>And replace it with a much simpler function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">
&#40;defn cached-credentials
  &quot;Returns a CredentialsProvider which wraps `provider`, caching
  credentials returned by `fetch`, and auto-refreshing the cached
  credentials in a background thread when the credentials include a
  ::ttl.

  Call `stop` to cancel future auto-refreshes.

  The default ScheduledExecutorService uses a ThreadFactory that
  spawns daemon threads. You can override this by providing your own
  ScheduledExecutorService.

  Alpha. Subject to change.&quot;
  &#91;provider&#93;
  &#40;let &#91;credentials-atom &#40;atom nil&#41;&#93;
    &#40;reify
      CredentialsProvider
      &#40;fetch &#91;&#95;&#93;
        &#40;or @credentials-atom
            &#40;refresh! credentials-atom provider&#41;&#41;&#41;
      Stoppable
      &#40;-stop &#91;&#95;&#93;
        &#40;-stop provider&#41;&#41;&#41;&#41;&#41;
</code></pre><p>We can now replace all occurrences of <code>cached-credentials-with-auto-refresh</code> in the file with <code>cached-credentials</code>.</p><p>And now the <code>stop</code> function doesn't actually need to do anything:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn stop
  &quot;no-op&quot;
  &#91;&#95;credentials&#93;&#41;
</code></pre><p>With this, <code>C-c C-k</code> evaluates the namespace without errors, so let's go over to <code>net.jmglov.awno.credentials-test</code> and see if the tests work. But first, let's rip out anything to do with auto-refreshing credentials. That means replacing all of the <code>cached-credentials-with-auto-refresh</code> in the file with <code>cached-credentials</code> and removing the explicit test of auto-refreshing:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;deftest auto-refresh-test
  &#40;let &#91;cnt &#40;atom 0&#41;
        p &#40;reify credentials/CredentialsProvider
            &#40;credentials/fetch &#91;&#95;&#93;
              &#40;swap! cnt inc&#41;
              {:aws/access-key-id &quot;id&quot;
               :aws/secret-access-key &quot;secret&quot;
               ::credentials/ttl 1}&#41;&#41;
        creds &#40;credentials/cached-credentials p&#41;&#93;
    &#40;credentials/fetch creds&#41;
    &#40;Thread/sleep 2500&#41;
    &#40;let &#91;refreshed @cnt&#93;
      &#40;credentials/stop creds&#41;
      &#40;Thread/sleep 1000&#41;
      &#40;is &#40;= 3 refreshed&#41; &quot;The credentials have been refreshed.&quot;&#41;
      &#40;is &#40;= refreshed @cnt&#41; &quot;We stopped the auto-refreshing process.&quot;&#41;&#41;&#41;&#41;
</code></pre><p>OK, looking good! Let's give it a <code>C-c C-k</code>:</p><pre class="language-text"><code class="lang-text language-text">clojure.lang.ExceptionInfo: Could not find namespace: clojure.tools.logging.test.
{:type :sci/error, :line 5, :column 3, :message &quot;Could not find namespace: clojure.tools.logging.test.&quot;, :sci.impl/callstack #object&#91;clojure.lang.Volatile 0x13387b21 {:status :ready, :val &#40;{:line 5, :column 3, :file &quot;/home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/credentials&#95;test.clj&quot;, :ns #object&#91;sci.lang.Namespace 0x65a7dc8f &quot;net.jmglov.awno.credentials-test&quot;&#93;}&#41;}&#93;, :file &quot;/home/jmglov/Documents/code/clojure/awno-api/test/src/net/jmglov/awno/credentials&#95;test.clj&quot;}
 at sci.impl.utils$rethrow&#95;with&#95;location&#95;of&#95;node.invokeStatic &#40;utils.cljc:129&#41;
    sci.impl.analyzer$return&#95;ns&#95;op$reify&#95;&#95;4355.eval &#40;analyzer.cljc:1189&#41;
    sci.impl.analyzer$return&#95;do$reify&#95;&#95;3968.eval &#40;analyzer.cljc:130&#41;
    sci.impl.interpreter$eval&#95;form.invokeStatic &#40;interpreter.cljc:40&#41;
    sci.core$eval&#95;form.invokeStatic &#40;core.cljc:329&#41;
    &#91;...&#93;
</code></pre><p>Oh no! It looks like whatever version of <code>clojure.tools.logging</code> that is included in babashka doesn't have <code>clojure.tools.logging.test</code>! 😭</p><p>For now, let's just comment out the stuff that needs <code>clojure.tools.logging.test</code> and get on with our lives:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns net.jmglov.awno.credentials-test
  &#40;:require &#91;clojure.test :as t :refer &#91;deftest testing use-fixtures is&#93;&#93;
            &#91;clojure.java.io :as io&#93;
            #&#95;&#91;clojure.tools.logging.test :refer &#91;with-log logged?&#93;&#93;
            &#91;net.jmglov.awno.credentials :as credentials&#93;
            &#91;net.jmglov.awno.util :as u&#93;
            &#91;net.jmglov.awno.test.utils :as tu&#93;
            &#91;net.jmglov.awno.ec2-metadata-utils :as ec2-metadata-utils&#93;
            &#91;net.jmglov.awno.ec2-metadata-utils-test :as ec2-metadata-utils-test&#93;&#41;
  &#40;:import &#40;java.time Instant&#41;&#41;&#41;

;; &#91;...&#93;

#&#95;&#40;deftest valid-credentials-test
  &#40;with-log
    &#40;credentials/valid-credentials nil &quot;x provider&quot;&#41;
    &#40;is &#40;logged? 'net.jmglov.awno.credentials :debug &#40;str &quot;Unable to fetch credentials from x provider.&quot;&#41;&#41;&#41;&#41;
  &#40;with-log
    &#40;credentials/valid-credentials {:aws/access-key-id     &quot;id&quot;
                                    :aws/secret-access-key &quot;secret&quot;}
                                   &quot;x provider&quot;&#41;
    &#40;is &#40;logged? 'net.jmglov.awno.credentials :debug &#40;str &quot;Fetched credentials from x provider.&quot;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>After doing that, the namespace evaluates just fine, so let's try running the tests:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">
&#40;comment

  &#40;t/run-tests&#41;
  ;; =&gt; java.lang.RuntimeException: Could not find net.jmglov.awno&#95;http.edn on classpath. user /home/jmglov/Documents/code/clojure/awno-api/src/net/jmglov/awno/http.clj:76:9

  &#41;
</code></pre><p>Oh sweet mother of mercy what now?</p><h2 id="http%3A_horrible_time_to_port">HTTP: Horrible Time To Port</h2><p>Since our error mentioned HTTP by name, let's look up HTTP in <code>porting-decisions.markdown</code>:</p><blockquote><p> The aws-api library defines a protocol  (<code>cognitect.aws.http/HttpClient</code>) to provide an interface between the  aws-api data transformation logic and the specific HTTP client  implementation. The aws-api includes an implementation for the  <code>com.cognitect/http-client</code> library. Interfaces are great: we can  provide our own implementations of the <code>cognitect.aws.http/HttpClient</code>  interface based on the various HTTP clients included in babashka. </p></blockquote><p>Alrighty, looks like we're gonna need to implement an HTTP client. Let's have a look at how this is done in awyeah-api by peeking in <a href='https://github.com/grzm/awyeah-api/blob/main/src/com/grzm/awyeah/http.clj'>src/com/grzm/awyeah/http.clj</a>. It seems by and large the same as aws-api's <a href='https://github.com/cognitect-labs/aws-api/blob/main/src/cognitect/aws/http.clj'>src/cognitect/aws/http.clj</a>, with exception of some class loader stuff, which is also mentioned in <code>porting-decisions.markdown</code>:</p><blockquote><p> There are a few other compatiblity issues, such as the use of  <code>java.lang.ClassLoader::getResources</code> in  <code>cognitect.aws.http/configured-client</code>, and replacing <code>&#94;int x</code> hinting  with explicit <code>&#40;int x&#41;</code> casts. </p></blockquote><p>Whilst <code>java.lang.ClassLoader::getResources</code> isn't mentioned directly, there are some class loader shenanigans happening here:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- configured-client
  &quot;If a single net.jmglov.awno&#95;http.edn is found on the classpath,
  returns the symbol bound to :constructor-var.

  Throws if 0 or &gt; 1 net.jmglov.awno&#95;http.edn files are found.
  &quot;
  &#91;&#93;
  &#40;let &#91;cl   &#40;.. Thread currentThread getContextClassLoader&#41;
        cfgs &#40;enumeration-seq &#40;.getResources cl &quot;net.jmglov.awno&#95;http.edn&quot;&#41;&#41;&#93;
    &#40;case &#40;count cfgs&#41;
      0 &#40;throw &#40;RuntimeException. &quot;Could not find net.jmglov.awno&#95;http.edn on classpath.&quot;&#41;&#41;
      1 &#40;-&gt; cfgs first read-config :constructor-var&#41;

      &#40;throw &#40;ex-info &quot;Found too many http-client cfgs. Pick one.&quot; {:config cfgs}&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Let's just follow grzm's lead and rip it out:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- configured-client
  &quot;If a single com&#95;grzm&#95;awyeah&#95;http.edn is found on the classpath,
  returns the symbol bound to :constructor-var.

  Throws if 0 or &gt; 1 com&#95;grzm&#95;awyeah&#95;http.edn files are found.
  &quot;
  &#91;&#93;
  &#40;try
    &#40;-&gt; &#40;io/resource &quot;net&#95;jmglov&#95;awno&#95;http.edn&quot;&#41; read-config :constructor-var&#41;
    &#40;catch Throwable &#95;
      &#40;throw &#40;RuntimeException. &quot;Could not find com&#95;grzm&#95;awyeah&#95;http.edn on classpath.&quot;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>This <code>dynaload</code> stuff also looks a bit unnecessary, so let's flush it as well by turning this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn resolve-http-client
  &#91;http-client-or-sym&#93;
  &#40;let &#91;c &#40;or &#40;when &#40;symbol? http-client-or-sym&#41;
                &#40;let &#91;ctor @&#40;dynaload/load-var http-client-or-sym&#41;&#93;
                  &#40;ctor&#41;&#41;&#41;
              http-client-or-sym
              &#40;let &#91;ctor @&#40;dynaload/load-var &#40;configured-client&#41;&#41;&#93;
                &#40;ctor&#41;&#41;&#41;&#93;
    &#40;when-not &#40;client? c&#41;
      &#40;throw &#40;ex-info &quot;not an http client&quot; {:provided http-client-or-sym
                                            :resolved c}&#41;&#41;&#41;
    c&#41;&#41;
</code></pre><p>Into that:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn resolve-http-client
  &#91;http-client-or-sym&#93;
  &#40;let &#91;c &#40;or &#40;when &#40;symbol? http-client-or-sym&#41;
                &#40;let &#91;ctor &#40;requiring-resolve http-client-or-sym&#41;&#93;
                  &#40;ctor&#41;&#41;&#41;
              http-client-or-sym
              &#40;let &#91;ctor &#40;requiring-resolve &#40;configured-client&#41;&#41;&#93;
                &#40;ctor&#41;&#41;&#41;&#93;
    &#40;when-not &#40;client? c&#41;
      &#40;throw &#40;ex-info &quot;not an http client&quot; {:provided http-client-or-sym
                                            :resolved c}&#41;&#41;&#41;
    c&#41;&#41;
</code></pre><p>This now means that we're gonna need <code>clojure.java.io</code>, so let's pop up to the <code>ns</code> form and require it. And whilst we're at it, we can get rid of this suspicious <code>net.jmglov.awno.dynaload</code> require, and kill the associated source file with fire! That leaves us with an <code>ns</code> form like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns &#94;:skip-wiki net.jmglov.awno.http
  &quot;Impl, don't call directly.&quot;
  &#40;:require &#91;clojure.edn :as edn&#93;
            &#91;clojure.core.async :as a&#93;
            &#91;clojure.java.io :as io&#93;&#41;&#41;
</code></pre><p>We're also going to need the <code>net&#95;jmglov&#95;awno&#95;http.edn</code> file mentioned above. We can just modify the original <code>cognitect&#95;aws&#95;http.edn</code>, so this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:constructor-var net.jmglov.awno.http.cognitect/create}
</code></pre><p>Becomes that:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:constructor-var net.jmglov.awno.http.awno/create}
</code></pre><p>Once this is done, we just need to rename the file to <code>net&#95;jmglov&#95;awno&#95;http.edn</code> and Robert should be one of our parents' brother. Oh yeah, and since we changed some stuff in resources, a <code>C-c M-r</code> to invoke <code>cider-restart</code> is sadly required. 😢</p><p>Continuing on our mission to civilise HTTP, let's turn our roving eye to the <code>net.jmglov.awno.http.awno</code> namespace, which is sadly not there, because our original <code>projectile-replace</code> of <code>cognitect.aws</code> with <code>net.jmglov.awno</code> left us with a file called <code>src/net/jmglov/awno/http/cognitect.clj</code> with a namespace of <code>net.jmglov.awno.http.cognitect</code>. We can fix the name easily enough, but looking at awyeah-api's <code>src/com/grzm/awyeah/http/awyeah.clj</code>, we see that he's replaced <code>cognitect.http-client</code> with a custom <code>com.grzm.awyeah.http-client</code>. Let's follow the same pattern:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns &#94;:skip-wiki net.jmglov.awno.http.awno
  &#40;:require &#91;net.jmglov.awno.http :as aws&#93;
            &#91;net.jmglov.awno.http-client :as impl&#93;&#41;&#41;

&#40;set! &#42;warn-on-reflection&#42; true&#41;

&#40;defn create
  &#91;&#93;
  &#40;let &#91;c &#40;impl/create nil&#41;&#93;
    &#40;reify aws/HttpClient
      &#40;-submit &#91;&#95; request channel&#93;
        &#40;impl/submit c request channel&#41;&#41;
      &#40;-stop &#91;&#95;&#93;
        &#40;impl/stop c&#41;&#41;&#41;&#41;&#41;
</code></pre><p>We'll also need to rename the file from <code>cognitect.clj</code> to <code>awno.clj</code>. Having done this, it's time to write <code>net.jmglov.awno.http-client</code>.</p><h2 id="what%27s_in_a_client%2C_anyway%3F">What's in a client, anyway?</h2><p>Let's start by straight up copying awyeah-api's <a href='https://github.com/grzm/awyeah-api/blob/main/src/com/grzm/awyeah/http_client.clj'>com/grzm/awyeah/http_client.clj</a> and replacing all the occurrences of <code>com.grzm.awyeah</code> with <code>net.jmglov.awno</code> to jmglovify it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns net.jmglov.awno.http-client
  &#40;:require
   &#91;clojure.core.async :refer &#91;put!&#93; :as a&#93;
   &#91;clojure.spec.alpha :as s&#93;
   &#91;net.jmglov.awno.http-client.client :as client&#93;
   &#91;net.jmglov.awno.http-client.specs&#93;&#41;
  &#40;:import
   &#40;clojure.lang ExceptionInfo&#41;
   &#40;java.net URI&#41;
   &#40;java.net.http HttpClient
                  HttpClient$Redirect
                  HttpHeaders
                  HttpRequest
                  HttpRequest$Builder
                  HttpRequest$BodyPublishers
                  HttpResponse
                  HttpResponse$BodyHandlers&#41;
   &#40;java.nio ByteBuffer&#41;
   &#40;java.time Duration&#41;
   &#40;java.util.function Function&#41;&#41;&#41;

&#40;set! &#42;warn-on-reflection&#42; true&#41;

&#40;defn submit
  &quot;Submit an http request, channel will be filled with response. Returns ch.

  Request map:

  :server-name        string
  :server-port         integer
  :uri                string
  :query-string       string, optional
  :request-method     :get/:post/:put/:head
  :scheme             :http or :https
  :headers            map from downcased string to string
  :body               ByteBuffer, optional
  :net.jmglov.awno.http-client/timeout-msec   opt, total request send/receive timeout
  :net.jmglov.awno.http-client/meta           opt, data to be added to the response map

  content-type must be specified in the headers map
  content-length is derived from the ByteBuffer passed to body

  Response map:

  :status              integer HTTP status code
  :body                ByteBuffer, optional
  :header              map from downcased string to string
  :net.jmglov.awno.http-client/meta           opt, data from the request

  On error, response map is per cognitect.anomalies&quot;
  &#40;&#91;client request&#93;
   &#40;submit client request &#40;a/chan 1&#41;&#41;&#41;
  &#40;&#91;client request ch&#93;
   &#40;s/assert ::submit-request request&#41;
   &#40;client/submit client request ch&#41;&#41;&#41;

&#40;def method-string
  {:get &quot;GET&quot;
   :post &quot;POST&quot;
   :put &quot;PUT&quot;
   :head &quot;HEAD&quot;
   :delete &quot;DELETE&quot;
   :patch &quot;PATCH&quot;}&#41;

&#40;defn byte-buffer-&gt;byte-array
  &#91;&#94;ByteBuffer bbuf&#93;
  &#40;.rewind bbuf&#41;
  &#40;let &#91;arr &#40;byte-array &#40;.remaining bbuf&#41;&#41;&#93;
    &#40;.get bbuf arr&#41;
    arr&#41;&#41;

&#40;defn flatten-headers &#91;headers&#93;
  &#40;-&gt;&gt; headers
       &#40;mapcat &#40;fn &#91;&#91;nom val&#93;&#93;
                 &#40;if &#40;coll? val&#41;
                   &#40;map &#40;fn &#91;v&#93; &#91;&#40;name nom&#41; v&#93;&#41; val&#41;
                   &#91;&#91;&#40;name nom&#41; val&#93;&#93;&#41;&#41;&#41;&#41;&#41;

;; &quot;host&quot; is a restricted header.
;; The host header is part of the AWS signed headers signature,
;; so it's included in the list of headers for request processing,
;; but we let the java.net.http HttpRequest assign the host header
;; from the URI rather than setting it directly.
&#40;def restricted-headers #{&quot;host&quot;}&#41;

&#40;defn add-headers
  &#91;&#94;HttpRequest$Builder builder headers&#93;
  &#40;doseq &#91;&#91;nom val&#93; &#40;-&gt;&gt; &#40;flatten-headers headers&#41;
                         &#40;remove &#40;fn &#91;&#91;nom &#95;&#93;&#93; &#40;restricted-headers nom&#41;&#41;&#41;&#41;&#93;
    &#40;.header builder nom val&#41;&#41;
  builder&#41;

&#40;defn map-&gt;http-request
  &#91;{:keys &#91;scheme server-name server-port uri query-string
           request-method headers body&#93;
    :or {scheme &quot;https&quot;}
    :as m}&#93;
  &#40;let &#91;uri &#40;URI. &#40;str &#40;name scheme&#41;
                       &quot;://&quot;
                       server-name
                       &#40;some-&gt;&gt; server-port &#40;str &quot;:&quot;&#41;&#41;
                       uri
                       &#40;some-&gt;&gt; query-string &#40;str &quot;?&quot;&#41;&#41;&#41;&#41;
        method &#40;method-string request-method&#41;
        bp &#40;if body
             &#40;HttpRequest$BodyPublishers/ofByteArray &#40;byte-buffer-&gt;byte-array body&#41;&#41;
             &#40;HttpRequest$BodyPublishers/noBody&#41;&#41;
        builder &#40;-&gt; &#40;HttpRequest/newBuilder uri&#41;
                    &#40;.method &#94;String method bp&#41;&#41;&#93;
    &#40;when &#40;seq headers&#41;
      &#40;add-headers builder headers&#41;&#41;
    &#40;when &#40;::timeout-msec m&#41;
      &#40;.timeout builder &#40;Duration/ofMillis &#40;::timeout-msec m&#41;&#41;&#41;&#41;
    &#40;.build builder&#41;&#41;&#41;

&#40;defn error-&gt;anomaly &#91;&#94;Throwable t&#93;
  {:cognitect.anomalies/category :cognitect.anomalies/fault
   :cognitect.anomalies/message &#40;.getMessage t&#41;
   ::throwable t}&#41;

&#40;defn header-map &#91;&#94;HttpHeaders headers&#93;
  &#40;-&gt;&gt; headers
       &#40;.map&#41;
       &#40;map &#40;fn &#91;&#91;k v&#93;&#93; &#91;k &#40;if &#40;&lt; 1 &#40;count v&#41;&#41;
                             &#40;into &#91;&#93; v&#41;
                             &#40;first v&#41;&#41;&#93;&#41;&#41;
       &#40;into {}&#41;&#41;&#41;

&#40;defn response-body?
  &#91;&#94;HttpRequest http-request&#93;
  &#40;&#40;complement #{&quot;HEAD&quot;}&#41; &#40;.method http-request&#41;&#41;&#41;

&#40;defn response-map
  &#91;&#94;HttpRequest http-request &#94;HttpResponse http-response&#93;
  &#40;let &#91;body &#40;when &#40;response-body? http-request&#41;
               &#40;.body http-response&#41;&#41;&#93;
    &#40;cond-&gt; {:status &#40;.statusCode http-response&#41;
             :headers &#40;header-map &#40;.headers http-response&#41;&#41;}
      body &#40;assoc :body &#40;ByteBuffer/wrap body&#41;&#41;&#41;&#41;&#41;

&#40;defrecord Client
    &#91;&#94;HttpClient http-client pending-ops pending-ops-limit&#93;
  client/Client
  &#40;-submit &#91;&#95; request ch&#93;
    &#40;if &#40;&lt; pending-ops-limit &#40;swap! pending-ops inc&#41;&#41;
      &#40;do
        &#40;put! ch &#40;merge {:cognitect.anomalies/category :cognitect.anomalies/busy
                         :cognitect.anomalies/message &#40;str &quot;Ops limit reached: &quot; pending-ops-limit&#41;
                         :pending-ops-limit pending-ops-limit}
                        &#40;select-keys request &#91;::meta&#93;&#41;&#41;&#41;
        &#40;swap! pending-ops dec&#41;&#41;
      &#40;try
        &#40;let &#91;http-request &#40;map-&gt;http-request request&#41;&#93;
          &#40;-&gt; &#40;.sendAsync http-client http-request &#40;HttpResponse$BodyHandlers/ofByteArray&#41;&#41;
              &#40;.thenApply
                &#40;reify Function
                  &#40;apply &#91;&#95; http-response&#93;
                    &#40;put! ch &#40;merge &#40;response-map http-request http-response&#41;
                                    &#40;select-keys request &#91;::meta&#93;&#41;&#41;&#41;&#41;&#41;&#41;
              &#40;.exceptionally
                &#40;reify Function
                  &#40;apply &#91;&#95; e&#93;
                    &#40;let &#91;cause &#40;.getCause &#94;Exception e&#41;
                          t &#40;if &#40;instance? ExceptionInfo cause&#41; cause e&#41;&#93;
                      &#40;put! ch &#40;merge &#40;error-&gt;anomaly t&#41; &#40;select-keys request &#91;::meta&#93;&#41;&#41;&#41;&#41;&#41;&#41;&#41;&#41;
          &#40;swap! pending-ops dec&#41;&#41;
        &#40;catch Throwable t
          &#40;put! ch &#40;merge &#40;error-&gt;anomaly t&#41; &#40;select-keys request &#91;::meta&#93;&#41;&#41;&#41;
          &#40;swap! pending-ops dec&#41;&#41;&#41;&#41;
    ch&#41;&#41;

&#40;defn create
  &#91;{:keys &#91;connect-timeout-msecs
           pending-ops-limit&#93;
    :or {connect-timeout-msecs 5000
         pending-ops-limit 64}
    :as &#95;config}&#93;
  &#40;let &#91;http-client &#40;.build &#40;-&gt; &#40;HttpClient/newBuilder&#41;
                                &#40;.connectTimeout &#40;Duration/ofMillis connect-timeout-msecs&#41;&#41;
                                &#40;.followRedirects HttpClient$Redirect/NORMAL&#41;&#41;&#41;&#93;
    &#40;-&gt;Client http-client &#40;atom 0&#41; pending-ops-limit&#41;&#41;&#41;

&#40;defn stop
  &quot;no-op. Implemented for compatibility&quot;
  &#91;&#94;Client &#95;client&#93;&#41;
</code></pre><p>Now we need to create those <code>net.jmglov.awno.http-client.client</code> and <code>net.jmglov.awno.http-client.specs</code> namespaces, so we can make copies of <a href='https://github.com/grzm/awyeah-api/blob/main/src/com/grzm/awyeah/http_client/client.clj'>com/grzm/awyeah/http_client/client.clj</a> and <a href='https://github.com/grzm/awyeah-api/blob/main/src/com/grzm/awyeah/http_client/specs.clj'>com/grzm/awyeah/http_client/specs.clj</a> and replace all the occurrences of <code>com.grzm.awyeah</code> with <code>net.jmglov.awno</code>, giving us this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns net.jmglov.awno.http-client.client&#41;

&#40;defprotocol Client
  &#40;-submit &#91;&#95; request ch&#93;&#41;&#41;

&#40;defn submit &#91;client request ch&#93;
  &#40;-submit client request ch&#41;&#41;
</code></pre><p>And this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns net.jmglov.awno.http-client.specs
  &#40;:require
   &#91;clojure.spec.alpha :as s&#93;&#41;
  &#40;:import
   &#40;java.nio ByteBuffer&#41;&#41;&#41;

&#40;defn- keyword-or-non-empty-string? &#91;x&#93;
  &#40;or &#40;keyword? x&#41;
      &#40;and &#40;string? x&#41; &#40;not-empty x&#41;&#41;&#41;&#41;

&#40;s/def :net.jmglov.awno.http-client/server-name string?&#41;
&#40;s/def :net.jmglov.awno.http-client/server-port int?&#41;
&#40;s/def :net.jmglov.awno.http-client/uri string?&#41;
&#40;s/def :net.jmglov.awno.http-client/request-method keyword?&#41;
&#40;s/def :net.jmglov.awno.http-client/scheme keyword-or-non-empty-string?&#41;
&#40;s/def :net.jmglov.awno.http-client/timeout-msec int?&#41;
&#40;s/def :net.jmglov.awno.http-client/meta map?&#41;
&#40;s/def :net.jmglov.awno.http-client/body &#40;s/nilable #&#40;instance? ByteBuffer %&#41;&#41;&#41;
&#40;s/def :net.jmglov.awno.http-client/query-string string?&#41;
&#40;s/def :net.jmglov.awno.http-client/headers map?&#41;

&#40;s/def :net.jmglov.awno.http-client/submit-request
  &#40;s/keys :req-un &#91;:net.jmglov.awno.http-client/server-name
                   :net.jmglov.awno.http-client/server-port
                   :net.jmglov.awno.http-client/uri
                   :net.jmglov.awno.http-client/request-method
                   :net.jmglov.awno.http-client/scheme&#93;
          :opt &#91;:net.jmglov.awno.http-client/timeout-msec
                :net.jmglov.awno.http-client/meta&#93;
          :opt-un &#91;:net.jmglov.awno.http-client/body
                   :net.jmglov.awno.http-client/query-string
                   :net.jmglov.awno.http-client/headers&#93;&#41;&#41;

&#40;s/def :net.jmglov.awno.http-client/status int?&#41;

&#40;s/def :net.jmglov.awno.http-client/submit-http-response
  &#40;s/keys :req-un &#91;:net.jmglov.awno.http-client/status&#93;
          :opt &#91;:net.jmglov.awno.http-client/meta&#93;
          :opt-un &#91;:net.jmglov.awno.http-client/body
                   :net.jmglov.awno.http-client/headers&#93;&#41;&#41;

&#40;s/def :net.jmglov.awno.http-client/error keyword?&#41;
&#40;s/def :net.jmglov.awno.http-client/throwable #&#40;instance? Throwable %&#41;&#41;

&#40;s/def :net.jmglov.awno.http-client/submit-error-response
  &#40;s/keys :req &#91;:net.jmglov.awno.http-client/error&#93;
          :opt &#91;:net.jmglov.awno.http-client/throwable
                :net.jmglov.awno.http-client/meta&#93;&#41;&#41;

&#40;s/def :net.jmglov.awno.http-client/submit-response
  &#40;s/or :http-response :net.jmglov.awno.http-client/submit-http-response
        :error-response :net.jmglov.awno.http-client/submit-error-response&#41;&#41;
</code></pre><h2 id="taking_stock_of_the_situation">Taking stock of the situation</h2><p>Having done everything that's obvious from <code>porting-decisions.markdown</code>, we might as well just see if it works. But before we do that, how about a...</p><p><img src="assets/2023-11-11-cliffhanger.jpg" alt="Sylvester Stallone hangs from a cliff" title="borkdude at work" /></p><p>See you next week for the thrilling conclusion of the trainwreck that is me attempting to replace an oil filter by completely disassembling the car and making my own oil filter out of paper towels and a coathanger I found lying around. What could possibly go wrong?</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2023-01-04-blambda-analyses-sites.html</id>
    <link href="https://jmglov.net/blog/2023-01-04-blambda-analyses-sites.html"/>
    <title>Blambda analyses sites</title>
    <updated>2023-01-04T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I'm giving a talk on <a href='https://github.com/jmglov/blambda'>Blambda</a> later this month at a local meetup group or two, and since I'm an experienced speaker, the talk is completely prepared, so now I have three weeks to rehearse it... in an alternate reality where I'm not a huge procrastinator, that is.</p><p>In this reality, I haven't even written an outline for the talk, despite convincing myself that I was going to do that over the holiday break. I was able to convince myself that before I write the outline, I should just hack on Blambda a little more to make sure it actually works. 😅</p><p>A while back, I read a post on Cyprien Pannier's blog called <a href='https://www.loop-code-recur.io/simple-site-analytics-with-serverless-clojure/'>Serverless site
analytics with Clojure nbb and
AWS</a>. It was a fantastic post, except for one thing: it was based on the hated <a href='https://github.com/babashka/nbb'>nbb</a>, surely borkdude's greatest sin!</p><p>I decided I could make the world a better place by doing the same thing, but using Babashka instead of the unholy nbb. Now that Blambda is all grown up and has a <a href='https://github.com/jmglov/blambda/releases/tag/v0.1.0'>v0.1.0 release</a>, it was the work of but a moment (for a definition of "moment" that spans several days) to implement a site analyser!</p><p><img src="assets/2023-01-04-preview.png" alt="Movie poster for Analyze This" title="Analyse this!" width=800px /></p><p>If you're the impatient sort, you can just take a look at <a href='https://github.com/jmglov/blambda/tree/v0.1.0/examples/site-analyser'>examples/site-analyser</a> in the Blambda repo, but if you wanna see how the sausage is made, buckle up, 'cause Kansas is going bye-bye!</p><h2 id="getting_started">Getting started</h2><p>Any great Babashka project starts with an empty directory, so let's make one!</p><pre class="language-text"><code class="lang-text language-text">$ mkdir site-analyser
$ cd site-analyser
</code></pre><p>We'll now add a <code>bb.edn</code> so we can start playing with Blambda:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {net.jmglov/blambda
        {:git/url &quot;https://github.com/jmglov/blambda.git&quot;
         :git/tag &quot;v0.1.0&quot;
         :git/sha &quot;b80ac1d&quot;}}
 :tasks
 {:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;
  :init &#40;do
          &#40;def config {}&#41;&#41;

  blambda {:doc &quot;Controls Blambda runtime and layers&quot;
           :task &#40;blambda/dispatch config&#41;}}}
</code></pre><p>This is enough to get us up and running with Blambda! Let's ask for help to see how to get going:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda help
Usage: bb blambda &lt;subcommand&gt; &lt;options&gt;

All subcommands support the options:
                                                                                                   --work-dir   &lt;dir&gt; .work  Working directory
  --target-dir &lt;dir&gt; target Build output directory

Subcommands:

build-runtime-layer: Builds Blambda custom runtime layer
  --bb-arch            &lt;arch&gt;    amd64   Architecture to target &#40;use amd64 if you don't care&#41;
  --runtime-layer-name &lt;name&gt;    blambda Name of custom runtime layer in AWS
  --bb-version         &lt;version&gt; 1.0.168 Babashka version

...
</code></pre><p>Might as well try to build the custom runtime layer, which is what allows lambdas to be written in Babashka in the first place:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-runtime-layer

Building custom runtime layer: /tmp/site-analyser/target/blambda.zip
Downloading https://github.com/babashka/babashka/releases/download/v1.0.168/babashka-1.0.168-linux-amd64-static.tar.gz
Decompressing .work/babashka-1.0.168-linux-amd64-static.tar.gz to .work
Adding file: bootstrap
Adding file: bootstrap.clj
Compressing custom runtime layer: /tmp/site-analyser/target/blambda.zip
  adding: bb &#40;deflated 70%&#41;
  adding: bootstrap &#40;deflated 53%&#41;
  adding: bootstrap.clj &#40;deflated 62%&#41;

$ ls -sh target/
total 21M
21M blambda.zip
</code></pre><p>Cool, looks legit!</p><h2 id="blasting_off_with_graviton2">Blasting off with Graviton2</h2><p>AWS being AWS, of course they had to go and design their own CPU. 🙄</p><p>It's called <a href='https://aws.amazon.com/ec2/graviton/'>Graviton</a>, and it's based on the ARM architecture. A little over a year ago, it became possible to <a href='https://aws.amazon.com/blogs/aws/aws-lambda-functions-powered-by-aws-graviton2-processor-run-your-functions-on-arm-and-get-up-to-34-better-price-performance/'>run
lambda functions on
Graviton</a>, which AWS claims delivers "up to 19 percent better performance at 20 percent lower cost". That's party I definitely wanna go to, and luckily for me, it just so happens that Babashka runs on ARM! 🎉 I love borkdude so much that I can almost forgive him for the dark alchemy that wrought nbb! Almost.</p><p>We can instruct Blambda to use the ARM version of Babashka by adding a key to the <code>config</code> map in <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps { ... }
 :tasks
 {:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;
  :init &#40;do
          &#40;def config {:bb-arch &quot;arm64&quot;}&#41;&#41;
  ...
</code></pre><p>Let's rebuild the runtime layer and see what's up:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-runtime-layer

Building custom runtime layer: /tmp/site-analyser/target/blambda.zip
Downloading https://github.com/babashka/babashka/releases/download/v1.0.168/babashka-1.0.168-linux-aarch64-static.tar.gz
Decompressing .work/babashka-1.0.168-linux-aarch64-static.tar.gz to .work
Adding file: bootstrap
Adding file: bootstrap.clj
Compressing custom runtime layer: /tmp/site-analyser/target/blambda.zip
updating: bb &#40;deflated 73%&#41;
updating: bootstrap &#40;deflated 53%&#41;
updating: bootstrap.clj &#40;deflated 62%&#41;
</code></pre><p>That <code>babashka-1.0.168-linux-aarch64-static.tar.gz</code> looks promising!</p><h2 id="say_hello_to_my_little_friend">Say hello to my little friend</h2><p>OK, so we have a custom runtime. That's awesome and all, but without a lambda function, a runtime is a bit passé, don't you think? Let's remedy this with the simplest of lambdas, the infamous Hello World. Except let's make it say "Hello Blambda" instead to make it more amazing!</p><p>All we need to accomplish this is a simple handler. Let's create a <code>src/handler.clj</code> and drop the following into it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns handler&#41;

&#40;defn handler &#91;{:keys &#91;name&#93; :or {name &quot;Blambda&quot;} :as event} context&#93;
  &#40;prn {:msg &quot;Invoked with event&quot;,
        :data {:event event}}&#41;
  {:greeting &#40;str &quot;Hello &quot; name &quot;!&quot;&#41;}&#41;
</code></pre><p>Now we'll need to tell Blambda what our lambda function should be called, where to find the sources, and what handler function to use. Back in <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps { ... }
 :tasks
 {:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;
  :init &#40;do
          &#40;def config {:bb-arch &quot;arm64&quot;
                       :lambda-name &quot;site-analyser&quot;
                       :lambda-handler &quot;handler/handler&quot;
                       :source-files &#91;&quot;handler.clj&quot;&#93;}&#41;&#41;
  ...
</code></pre><p>Now that we've done this, we can build the lambda:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-lambda

Building lambda artifact: /tmp/site-analyser/target/site-analyser.zip
Adding file: handler.clj
Compressing lambda: /tmp/site-analyser/target/site-analyser.zip
  adding: handler.clj &#40;deflated 25%&#41;

$ ls -sh target/
total 22M
 22M blambda.zip  4.0K site-analyser.zip
</code></pre><p>Amazing! I love the fact that the entire lambda artifact is only 4 KB!</p><h2 id="changing_the_world_with_terraform">Changing the world with Terraform</h2><p>Of course, this is still academic until we deploy the function to the world, so let's stop messing about and do it! For the rest of this post, I am going to assume that one of the following applies to you:</p><ol><li>You have a 1.x version of <a href='https://www.terraform.io/'>Terraform</a> installed,   or</li><li>You have Nix installed (or are running on NixOS) and have run `nix-shell -p   terraform` in your site-analyser directory</li></ol><p>Since one of those two options is true, let's generate ourselves some Terraform config!</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda terraform write-config
Writing lambda layer config: /tmp/site-analyser/target/blambda.tf
Writing lambda layer vars: /tmp/site-analyser/target/blambda.auto.tfvars
Writing lambda layers module: /tmp/site-analyser/target/modules/lambda&#95;layer.tf

$ find target/
target/
target/site-analyser.zip
target/blambda.zip
target/modules
target/modules/lambda&#95;layer.tf
target/blambda.auto.tfvars
target/blambda.tf
</code></pre><p>Note the <code>blambda.tf</code>, <code>blambda.auto.tfvars</code>, and <code>modules/lambda&#95;layer.tf</code> files there. If you're a Terraform aficionado, feel free to take a look at these; I'll just give you the highlights here.</p><p><code>blambda.tf</code> defines the following resources:</p><ul><li><code>aws&#95;lambda&#95;function.lambda</code> - the lambda function itself</li><li><code>aws&#95;cloudwatch&#95;log&#95;group.lambda</code> - a CloudWatch log group for the lambda  function to log to</li><li><code>aws&#95;iam&#95;role.lambda</code> - the IAM role that the lambda function will assume</li><li><code>aws&#95;iam&#95;policy.lambda</code> - an IAM policy describing what the lambda function is  allowed to do (in this case, just write logs to its own log group)</li><li><code>aws&#95;iam&#95;role&#95;policy&#95;attachment.lambda</code> - a virtual Terraform resource that  represents the attachment of the policy to the role</li><li><code>module.runtime.aws&#95;lambda&#95;layer&#95;version.layer</code> - the custom runtime layer</li></ul><p><code>blambda.auto.tfvars</code> sets various Terraform variables. The details are too boring to relate, but you are welcome to look at the file if your curiosity overwhelms you. 😉</p><p><code>modules/lambda&#95;layer.tf</code> defines a Terraform module that creates a lambda layer. The reason it's in a module and not just inline in the <code>blambda.tf</code> will become apparent later.</p><h2 id="just_deploy_the_thing_already%21">Just deploy the thing already!</h2><p>OK, now that I've gone into what is almost certainly too much detail on stuff that you almost certainly don't care about, let's just deploy the function!</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda terraform apply
Initializing modules...
- runtime in modules

Initializing the backend...

Initializing provider plugins...
- Finding latest version of hashicorp/aws...
- Installing hashicorp/aws v4.48.0...
- Installed hashicorp/aws v4.48.0 &#40;signed by HashiCorp&#41;

&#91;...&#93;

Terraform has been successfully initialized!

&#91;...&#93;

Terraform will perform the following actions:

  # aws&#95;cloudwatch&#95;log&#95;group.lambda will be created
  + resource &quot;aws&#95;cloudwatch&#95;log&#95;group&quot; &quot;lambda&quot; {

  # aws&#95;iam&#95;policy.lambda will be created
  + resource &quot;aws&#95;iam&#95;policy&quot; &quot;lambda&quot; {

  # aws&#95;iam&#95;role.lambda will be created
  + resource &quot;aws&#95;iam&#95;role&quot; &quot;lambda&quot; {
                                                                                                   # aws&#95;iam&#95;role&#95;policy&#95;attachment.lambda will be created
  + resource &quot;aws&#95;iam&#95;role&#95;policy&#95;attachment&quot; &quot;lambda&quot; {

  # aws&#95;lambda&#95;function.lambda will be created
  + resource &quot;aws&#95;lambda&#95;function&quot; &quot;lambda&quot; {

  # module.runtime.aws&#95;lambda&#95;layer&#95;version.layer will be created
  + resource &quot;aws&#95;lambda&#95;layer&#95;version&quot; &quot;layer&quot; {

&#91;...&#93;

Plan: 6 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value:
</code></pre><p>Let's be brave and type "yes" and then give the Enter key a resounding smackaroo! We should see something along the lines of this:</p><pre class="language-text"><code class="lang-text language-text">aws&#95;cloudwatch&#95;log&#95;group.lambda: Creating...
aws&#95;iam&#95;role.lambda: Creating...
module.runtime.aws&#95;lambda&#95;layer&#95;version.layer: Creating...
aws&#95;cloudwatch&#95;log&#95;group.lambda: Creation complete after 1s &#91;id=/aws/lambda/site-analyser&#93;
aws&#95;iam&#95;policy.lambda: Creating...
aws&#95;iam&#95;policy.lambda: Creation complete after 1s &#91;id=arn:aws:iam::123456789100:policy/site-analyser&#93;
aws&#95;iam&#95;role.lambda: Creation complete after 2s &#91;id=site-analyser&#93;
aws&#95;iam&#95;role&#95;policy&#95;attachment.lambda: Creating...
aws&#95;iam&#95;role&#95;policy&#95;attachment.lambda: Creation complete after 1s &#91;id=site-analyser-20230103173233475200000001&#93;
module.runtime.aws&#95;lambda&#95;layer&#95;version.layer: Still creating... &#91;10s elapsed&#93;
module.runtime.aws&#95;lambda&#95;layer&#95;version.layer: Still creating... &#91;20s elapsed&#93;
module.runtime.aws&#95;lambda&#95;layer&#95;version.layer: Still creating... &#91;30s elapsed&#93;
module.runtime.aws&#95;lambda&#95;layer&#95;version.layer: Creation complete after 31s &#91;id=arn:aws:lambda:eu-west-1:123456789100:layer:blambda:30&#93;
aws&#95;lambda&#95;function.lambda: Creating...
aws&#95;lambda&#95;function.lambda: Still creating... &#91;10s elapsed&#93;
aws&#95;lambda&#95;function.lambda: Creation complete after 11s &#91;id=site-analyser&#93;

Apply complete! Resources: 6 added, 0 changed, 0 destroyed.
</code></pre><p>OK, let's try invoking the function:</p><pre class="language-text"><code class="lang-text language-text">$ aws lambda invoke --function-name site-analyser /tmp/response.json
{
    &quot;StatusCode&quot;: 200,
    &quot;ExecutedVersion&quot;: &quot;$LATEST&quot;
}

$ cat /tmp/response.json
{&quot;greeting&quot;:&quot;Hello Blambda!&quot;}
</code></pre><p>According to the handler, we can also pass a name in:</p><pre class="language-text"><code class="lang-text language-text">$ aws lambda invoke --function-name site-analyser --payload '{&quot;name&quot;: &quot;Dear Reader&quot;}' /tmp/response.json
{
    &quot;StatusCode&quot;: 200,
    &quot;ExecutedVersion&quot;: &quot;$LATEST&quot;
}

$ cat /tmp/response.json 
{&quot;greeting&quot;:&quot;Hello Dear Reader!&quot;}
</code></pre><p>Looks like we're live in the cloud!</p><h2 id="http_ftw%21">HTTP FTW!</h2><p>It's pretty annoying to have to use the AWS CLI to invoke our function, what with all of the writing the response to a file and all that jazz. Luckily, <a href='https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html'>Lambda function
URLs</a> offer us a way out of this never-ending agony. All we need to do is add one simple Terraform resource.</p><p>Let's create a <code>tf/main.tf</code> and define our function URL:</p><pre class="language-terraform"><code class="lang-terraform language-terraform">resource &quot;aws&#95;lambda&#95;function&#95;url&quot; &quot;lambda&quot; {
  function&#95;name = aws&#95;lambda&#95;function.lambda.function&#95;name
  authorization&#95;type = &quot;NONE&quot;
}

output &quot;function&#95;url&quot; {
  value = aws&#95;lambda&#95;function&#95;url.lambda.function&#95;url
}
</code></pre><p>When using a function URL, the event passed to the lambda function looks a little different. Referring to the <a href='https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html#urls-payloads'>Request and response
payloads</a> page in the AWS Lambda developer guide, we hone in on the important bits:</p><p><strong>Request</strong></p><pre class="language-json"><code class="lang-json language-json">{
  &quot;queryStringParameters&quot;: {
    &quot;parameter1&quot;: &quot;value1,value2&quot;,
    &quot;parameter2&quot;: &quot;value&quot;
  },
  &quot;requestContext&quot;: {
    &quot;http&quot;: {
      &quot;method&quot;: &quot;POST&quot;,
      &quot;path&quot;: &quot;/my/path&quot;,
    }
  }
}
</code></pre><p><strong>Response</strong></p><pre class="language-json"><code class="lang-json language-json">{
  &quot;statusCode&quot;: 201,
  &quot;headers&quot;: {
    &quot;Content-Type&quot;: &quot;application/json&quot;,
    &quot;My-Custom-Header&quot;: &quot;Custom Value&quot;
  },
  &quot;body&quot;: &quot;{ \&quot;message\&quot;: \&quot;Hello, world!\&quot; }&quot;
}
</code></pre><p>Let's update our handler to log the method and path and grab the optional <code>name</code> from the query params. Our new <code>src/handler.clj</code> now looks like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns handler
  &#40;:require &#91;cheshire.core :as json&#93;&#41;&#41;

&#40;defn log &#91;msg data&#93;
  &#40;prn &#40;assoc data :msg msg&#41;&#41;&#41;

&#40;defn handler &#91;{:keys &#91;queryStringParameters requestContext&#93; :as event} &#95;context&#93;
  &#40;log &quot;Invoked with event&quot; {:event event}&#41;
  &#40;let &#91;{:keys &#91;method path&#93;} &#40;:http requestContext&#41;
        {:keys &#91;name&#93; :or {name &quot;Blambda&quot;}} queryStringParameters&#93;
    &#40;log &#40;format &quot;Request: %s %s&quot; method path&#41;
         {:method method, :path path, :name name}&#41;
    {:statusCode 200
     :headers {&quot;Content-Type&quot; &quot;application/json&quot;}
     :body &#40;json/generate-string {:greeting &#40;str &quot;Hello &quot; name &quot;!&quot;&#41;}&#41;}&#41;&#41;
</code></pre><p>The final step before we can deploy this gem is letting Blambda know that we want to include some extra Terraform config. For this, we set the <code>:extra-tf-config</code> key in <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps { ... }
 :tasks
 {:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;
  :init &#40;do
          &#40;def config {:bb-arch &quot;arm64&quot;
                       :lambda-name &quot;site-analyser&quot;
                       :lambda-handler &quot;handler/handler&quot;
                       :source-files &#91;&quot;handler.clj&quot;&#93;
                       :extra-tf-config &#91;&quot;tf/main.tf&quot;&#93;}&#41;&#41;
  ...
</code></pre><p>Now that all of this is done, let's rebuild our lambda, regenerate our Terraform config, and deploy away!</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-lambda

Building lambda artifact: /tmp/site-analyser/target/site-analyser.zip
Adding file: handler.clj
Compressing lambda: /tmp/site-analyser/target/site-analyser.zip
updating: handler.clj &#40;deflated 43%&#41;

$ bb blambda terraform write-config
Copying Terraform config tf/main.tf
Writing lambda layer config: /tmp/site-analyser/target/blambda.tf
Writing lambda layer vars: /tmp/site-analyser/target/blambda.auto.tfvars
Writing lambda layers module: /tmp/site-analyser/target/modules/lambda&#95;layer.tf

$ bb blambda terraform apply
                                                                                                 
Terraform will perform the following actions:

  # aws&#95;lambda&#95;function.lambda will be updated in-place
  &#126; resource &quot;aws&#95;lambda&#95;function&quot; &quot;lambda&quot; {

  # aws&#95;lambda&#95;function&#95;url.lambda will be created
  + resource &quot;aws&#95;lambda&#95;function&#95;url&quot; &quot;lambda&quot; {

Plan: 1 to add, 1 to change, 0 to destroy.

Changes to Outputs:
  + function&#95;url = &#40;known after apply&#41;

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws&#95;lambda&#95;function.lambda: Modifying... &#91;id=site-analyser&#93;
aws&#95;lambda&#95;function.lambda: Modifications complete after 7s &#91;id=site-analyser&#93;
aws&#95;lambda&#95;function&#95;url.lambda: Creating...
aws&#95;lambda&#95;function&#95;url.lambda: Creation complete after 1s &#91;id=site-analyser&#93;

Apply complete! Resources: 1 added, 1 changed, 0 destroyed.

Outputs:

function&#95;url = &quot;https://kuceaiz55k7soeki4u5oy4w6uy0ntbky.lambda-url.eu-west-1.on.aws/&quot;
</code></pre><p>Looks great! The <code>function&#95;url</code> is the base URL we'll use to make HTTP requests to our lambda. We can try it out with curl:</p><pre class="language-text"><code class="lang-text language-text">$ export BASE&#95;URL=https://kuceaiz55k7soeki4u5oy4w6uy0ntbky.lambda-url.eu-west-1.on.aws/

$ curl $BASE&#95;URL
{&quot;greeting&quot;:&quot;Hello Blambda!&quot;}

$ curl $BASE&#95;URL?name='Dear%20Reader'
{&quot;greeting&quot;:&quot;Hello Dear Reader!&quot;}
</code></pre><h2 id="logging_isn%27t_just_for_lumberjacks">Logging isn't just for lumberjacks</h2><p>Before we move on, let's take a quick look at our logs. Lambda functions automatically log anything printed to standard output to a log stream in a log group named <code>/aws/lambda/{function&#95;name}</code> (as long as they have permission to do so, which Blambda takes care of for us in the default IAM policy). Let's see what log streams we have in our <code>/aws/lambda/site-analyser</code> group:</p><pre class="language-text"><code class="lang-text language-text">$ aws logs describe-log-streams --log-group /aws/lambda/site-analyser \
    | jq '.logStreams | .&#91;&#93;.log&#91;18/1807&#93;e'
&quot;2023/01/03/&#91;$LATEST&#93;f8a5d5be9e0c4d34bcf6c8bb55e9c577&quot;
&quot;2023/01/04/&#91;$LATEST&#93;98a0ab46e2994cdda668124ccae610fc&quot;
</code></pre><p>We have two streams since we've tested our lambda twice (requests around the same time are batched into a single log stream). Let's pick the most recent one and see what it says:</p><pre class="language-text"><code class="lang-text language-text">$ aws logs get-log-events \
  --log-group /aws/lambda/site-analyser \
  --log-stream '2023/01/04/&#91;$LATEST&#93;98a0ab46e2994cdda668124ccae610fc' \
  | jq -r '.events|.&#91;&#93;.message'
Starting Babashka:
/opt/bb -cp /var/task /opt/bootstrap.clj
Loading babashka lambda handler: handler/handler

Starting babashka lambda event loop

START RequestId: 09a33775-0151-4d83-9a9b-b21b8add2b3a Version: $LATEST

{:event {:version &quot;2.0&quot;, :routeKey &quot;$default&quot;, :rawPath &quot;/&quot;, ...}

{:method &quot;GET&quot;, :path &quot;/&quot;, :name &quot;Blambda&quot;, :msg &quot;Request: GET /&quot;}

END RequestId: 09a33775-0151-4d83-9a9b-b21b8add2b3a

REPORT RequestId: 09a33775-0151-4d83-9a9b-b21b8add2b3a  Duration: 56.87 ms      Billed Duration: 507 ms Memory Size: 512 MB     Max Memory Used: 125 MB Init Duration: 450.02 ms

START RequestId: 0fca5efe-2644-4ebd-80ce-ebb390ffcaf4 Version: $LATEST

{:event {:version &quot;2.0&quot;, :routeKey &quot;$default&quot;, :rawPath &quot;/&quot;, ...}

{:method &quot;GET&quot;, :path &quot;/&quot;, :name &quot;Dear Reader&quot;, :msg &quot;Request: GET /&quot;}

END RequestId: 0fca5efe-2644-4ebd-80ce-ebb390ffcaf4

REPORT RequestId: 0fca5efe-2644-4ebd-80ce-ebb390ffcaf4  Duration: 2.23 ms       Billed Duration: 3 ms   Memory Size: 512 MB     Max Memory Used: 125 MB
</code></pre><p>There are a few things of interest here. First of all, we can see the Blambda custom runtime starting up:</p><pre class="language-text"><code class="lang-text language-text">Starting Babashka:
/opt/bb -cp /var/task /opt/bootstrap.clj
Loading babashka lambda handler: handler/handler

Starting babashka lambda event loop
</code></pre><p>This only happens on a so-called "cold start", which is when there is no lambda instance available to serve an invocation request. We always have a cold start on the first invocation of a lambda after it's deployed (i.e. on every code change), and then the lambda will stay warm for about 15 minutes after each invocation.</p><p>Next in our logs, we see the first test request we made:</p><pre class="language-text"><code class="lang-text language-text">START RequestId: 09a33775-0151-4d83-9a9b-b21b8add2b3a Version: $LATEST

{:event {:version &quot;2.0&quot;, :routeKey &quot;$default&quot;, :rawPath &quot;/&quot;, ...}

{:method &quot;GET&quot;, :path &quot;/&quot;, :name &quot;Blambda&quot;, :msg &quot;Request: GET /&quot;}

END RequestId: 09a33775-0151-4d83-9a9b-b21b8add2b3a

REPORT RequestId: 09a33775-0151-4d83-9a9b-b21b8add2b3a  Duration: 56.87 ms      Billed Duration: 507 ms Memory Size: 512 MB     Max Memory Used: 125 MB Init Duration: 450.02 ms
</code></pre><p>We can see the full event that is passed to the lambda by the function URL in the first EDN log line (which we should probably switch to JSON for compatibility will common log aggregation tools), then the log statement we added for the method, path, and name parameter. Finally, we get a report on the lambda invocation. We can see that it took 450 ms to initialise the runtime (seems a bit long; maybe increasing the memory size of our function would help), then 56.87 ms for the function invocation itself.</p><p>Let's compare that to the second invocation, the one where we added <code>name</code> to the query parameters:</p><pre class="language-text"><code class="lang-text language-text">START RequestId: 0fca5efe-2644-4ebd-80ce-ebb390ffcaf4 Version: $LATEST

{:event {:version &quot;2.0&quot;, :routeKey &quot;$default&quot;, :rawPath &quot;/&quot;, ...}

{:method &quot;GET&quot;, :path &quot;/&quot;, :name &quot;Dear Reader&quot;, :msg &quot;Request: GET /&quot;}

END RequestId: 0fca5efe-2644-4ebd-80ce-ebb390ffcaf4

REPORT RequestId: 0fca5efe-2644-4ebd-80ce-ebb390ffcaf4  Duration: 2.23 ms       Billed Duration: 3 ms   Memory Size: 512 MB     Max Memory Used: 125 MB
</code></pre><p>Note that we don't even have an init duration in this invocation, since the lambda was warm. Note also that the request duration was 2.23 ms!</p><p>Just for fun, let's make a few more requests and look at the durations.</p><pre class="language-text"><code class="lang-text language-text">$ for i in $&#40;seq 0 9&#41;; do curl $BASE&#95;URL?name=&quot;request%20$i&quot;; echo; done
{&quot;greeting&quot;:&quot;Hello request 0!&quot;}
{&quot;greeting&quot;:&quot;Hello request 1!&quot;}
{&quot;greeting&quot;:&quot;Hello request 2!&quot;}
{&quot;greeting&quot;:&quot;Hello request 3!&quot;}
{&quot;greeting&quot;:&quot;Hello request 4!&quot;}
{&quot;greeting&quot;:&quot;Hello request 5!&quot;}
{&quot;greeting&quot;:&quot;Hello request 6!&quot;}
{&quot;greeting&quot;:&quot;Hello request 7!&quot;}
{&quot;greeting&quot;:&quot;Hello request 8!&quot;}
{&quot;greeting&quot;:&quot;Hello request 9!&quot;}

$ aws logs describe-log-streams \
  --log-group /aws/lambda/site-analyser \
  | jq '.logStreams | .&#91;&#93;.logStreamName'
&quot;2023/01/03/&#91;$LATEST&#93;f8a5d5be9e0c4d34bcf6c8bb55e9c577&quot;
&quot;2023/01/04/&#91;$LATEST&#93;6532afd4465240dcb3f105abe2bcc250&quot;
&quot;2023/01/04/&#91;$LATEST&#93;98a0ab46e2994cdda668124ccae610fc&quot;
</code></pre><p>Hrm, <code>2023/01/04/&#91;$LATEST&#93;98a0ab46e2994cdda668124ccae610fc</code> was the stream we looked at last time, so let's assume that <code>2023/01/04/&#91;$LATEST&#93;6532afd4465240dcb3f105abe2bcc250</code> has our latest requests:</p><pre class="language-text"><code class="lang-text language-text">$ aws logs get-log-events \
  --log-group /aws/lambda/site-analyser \
  --log-stream '2023/01/04/&#91;$LATEST&#93;&#91;7/1936&#93;465240dcb3f105abe2bcc250' \
  | jq -r '.events | .&#91;&#93;.message' \
  | grep '&#94;REPORT'
REPORT RequestId: 4ee5993e-6d21-45cd-9b05-b31ea34d993f  Duration: 54.32 ms      Billed Duration: 505 ms Memory Size: 512 MB     Max Memory Used: 125 MB Init Duration: 450.45 ms
REPORT RequestId: 490b82c7-bca1-4427-8d07-ece41444ce2c  Duration: 1.81 ms       Billed Duration: 2 ms   Memory Size: 512 MB     Max Memory Used: 125 MB
REPORT RequestId: ca243a59-75b9-4192-aa91-76569765956a  Duration: 3.94 ms       Billed Duration: 4 ms   Memory Size: 512 MB     Max Memory Used: 126 MB
REPORT RequestId: 9d0981f8-3c48-45a5-bf8b-c37ed57e0f95  Duration: 1.77 ms       Billed Duration: 2 ms   Memory Size: 512 MB     Max Memory Used: 126 MB
REPORT RequestId: 2d5cca3f-752d-4407-99cd-bbb89ca74983  Duration: 1.73 ms       Billed Duration: 2 ms   Memory Size: 512 MB     Max Memory Used: 126 MB
REPORT RequestId: 674912af-b9e0-4308-b303-e5891a459ad1  Duration: 1.65 ms       Billed Duration: 2 ms   Memory Size: 512 MB     Max Memory Used: 126 MB
REPORT RequestId: d8efbec2-de6e-491d-b4c6-ce58d02225f1  Duration: 1.67 ms       Billed Duration: 2 ms   Memory Size: 512 MB     Max Memory Used: 126 MB
REPORT RequestId: c2a9246d-e3c4-40fa-9eb9-82fc200e6425  Duration: 1.64 ms       Billed Duration: 2 ms   Memory Size: 512 MB     Max Memory Used: 127 MB
REPORT RequestId: cbc2f1cd-23cf-4b26-87d4-0272c097956c  Duration: 1.72 ms       Billed Duration: 2 ms   Memory Size: 512 MB     Max Memory Used: 127 MB
REPORT RequestId: bae2e73b-4b21-4427-b0bd-359301722086  Duration: 1.73 ms       Billed Duration: 2 ms   Memory Size: 512 MB     Max Memory Used: 127 MB
</code></pre><p>Again, we see a cold start (because it apparently took me more than 15 minutes to write the part of the post since my first two test requests), then 9 more requests with durations mostly under 2 ms—dunno why there's a 3.94 ms outlier, but this is hardly a scientific benchmark. 😉</p><h2 id="getting_down_to_brass_tracks">Getting down to brass tracks</h2><p>OK, we've now got a lambda that can listen to HTTP requests. To turn it into the site analyser that was promised at the beginning of this blog post, we'll define a simple HTTP API:</p><ul><li><code>POST /track?url={url}</code> - increment the number of views for the specified URL</li><li><code>GET /dashboard</code> - display a simple HTML dashboard showing the number of  views of each URL for the past 7 days</li></ul><p>In order to implement the <code>/track</code> endpoint, we'll need somewhere to store the counters, and what better place than DynamoDB? We'll create a simple table with the date as the partition key and the url as the range key, which we can add to our <code>tf/main.tf</code> like so:</p><pre class="language-terraform"><code class="lang-terraform language-terraform">resource &quot;aws&#95;dynamodb&#95;table&quot; &quot;site&#95;analyser&quot; {
  name = &quot;site-analyser&quot;
  billing&#95;mode = &quot;PAY&#95;PER&#95;REQUEST&quot;
  hash&#95;key = &quot;date&quot;
  range&#95;key = &quot;url&quot;

  attribute {
    name = &quot;date&quot;
    type = &quot;S&quot;
  }

  attribute {
    name = &quot;url&quot;
    type = &quot;S&quot;
  }
}
</code></pre><p>We'll also need to give the lambda permissions to update and query this table, which means we'll need to define a custom IAM policy. 😭</p><p>Oh well, let's bite the bullet and add it to <code>tf/main.tf</code>:</p><pre class="language-terraform"><code class="lang-terraform language-terraform">resource &quot;aws&#95;iam&#95;role&quot; &quot;lambda&quot; {
  name = &quot;site-analyser-lambda&quot;

  assume&#95;role&#95;policy = jsonencode&#40;{
    Version = &quot;2012-10-17&quot;
    Statement = &#91;
      {
        Action = &quot;sts:AssumeRole&quot;
        Effect = &quot;Allow&quot;
        Principal = {
          Service = &quot;lambda.amazonaws.com&quot;
        }
      }
    &#93;
  }&#41;
}

resource &quot;aws&#95;iam&#95;policy&quot; &quot;lambda&quot; {
  name = &quot;site-analyser-lambda&quot;

  policy = jsonencode&#40;{
    Version = &quot;2012-10-17&quot;
    Statement = &#91;
      {
        Effect = &quot;Allow&quot;
        Action = &#91;
          &quot;logs:CreateLogStream&quot;,
          &quot;logs:PutLogEvents&quot;
        &#93;
        Resource = &quot;${aws&#95;cloudwatch&#95;log&#95;group.lambda.arn}:&#42;&quot;
      },
      {
        Effect = &quot;Allow&quot;
        Action = &#91;
          &quot;dynamodb:Query&quot;,
          &quot;dynamodb:UpdateItem&quot;,
        &#93;
        Resource = aws&#95;dynamodb&#95;table.site&#95;analyser.arn
      }
    &#93;
  }&#41;
}

resource &quot;aws&#95;iam&#95;role&#95;policy&#95;attachment&quot; &quot;lambda&quot; {
  role = aws&#95;iam&#95;role.lambda.name
  policy&#95;arn = aws&#95;iam&#95;policy.lambda.arn
}
</code></pre><p>Since Blambda won't be automatically generating a policy for us, we'll need to add a statement to the policy giving the lambda permission to write to CloudWatch Logs:</p><pre class="language-terraform"><code class="lang-terraform language-terraform">    Statement = &#91;
      {
        Effect = &quot;Allow&quot;
        Action = &#91;
          &quot;logs:CreateLogStream&quot;,
          &quot;logs:PutLogEvents&quot;
        &#93;
        Resource = &quot;${aws&#95;cloudwatch&#95;log&#95;group.lambda.arn}:&#42;&quot;
      },
      ...
</code></pre><p>and another one giving the lambda permissions to use the DynamoDB table:</p><pre class="language-terraform"><code class="lang-terraform language-terraform">    Statement = &#91;
      ...
      {
        Effect = &quot;Allow&quot;
        Action = &#91;
          &quot;dynamodb:Query&quot;,
          &quot;dynamodb:UpdateItem&quot;,
        &#93;
        Resource = aws&#95;dynamodb&#95;table.site&#95;analyser.arn
      }
    &#93;
</code></pre><p>Finally, we need to instruct Blambda that we'll be providing our own IAM role by adding the <code>:lambda-iam-role</code> key to <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps { ... }
 :tasks
 {:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;
  :init &#40;do
          &#40;def config {:bb-arch &quot;arm64&quot;
                       :lambda-name &quot;site-analyser&quot;
                       :lambda-handler &quot;handler/handler&quot;
                       :lambda-iam-role &quot;${aws&#95;iam&#95;role.lambda.arn}&quot;
                       :source-files &#91;&quot;handler.clj&quot;&#93;
                       :extra-tf-config &#91;&quot;tf/main.tf&quot;&#93;}&#41;&#41;
  ...
</code></pre><p>Before we implement the tracker, let's make sure that the new Terraform stuff we did all works:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda terraform write-config
Copying Terraform config tf/main.tf
Writing lambda layer config: /tmp/site-analyser/target/blambda.tf
Writing lambda layer vars: /tmp/site-analyser/target/blambda.auto.tfvars
Writing lambda layers module: /tmp/site-analyser/target/modules/lambda&#95;layer.tf

$ bb blambda terraform apply
&#91;...&#93;
Terraform will perform the following actions:

  # aws&#95;dynamodb&#95;table.site&#95;analyser will be created
  + resource &quot;aws&#95;dynamodb&#95;table&quot; &quot;site&#95;analyser&quot; {

  # aws&#95;iam&#95;policy.lambda must be replaced
-/+ resource &quot;aws&#95;iam&#95;policy&quot; &quot;lambda&quot; {

  # aws&#95;iam&#95;role.lambda must be replaced
-/+ resource &quot;aws&#95;iam&#95;role&quot; &quot;lambda&quot; {

  # aws&#95;iam&#95;role&#95;policy&#95;attachment.lambda must be replaced
-/+ resource &quot;aws&#95;iam&#95;role&#95;policy&#95;attachment&quot; &quot;lambda&quot; {

  # aws&#95;lambda&#95;function.lambda will be updated in-place
  &#126; resource &quot;aws&#95;lambda&#95;function&quot; &quot;lambda&quot; {

Plan: 4 to add, 1 to change, 3 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws&#95;iam&#95;role.lambda: Creating...
aws&#95;dynamodb&#95;table.site&#95;analyser: Creating...
aws&#95;iam&#95;role.lambda: Creation complete after 2s &#91;id=site-analyser-lambda&#93;
aws&#95;lambda&#95;function.lambda: Modifying... &#91;id=site-analyser&#93;
aws&#95;dynamodb&#95;table.site&#95;analyser: Creation complete after 7s &#91;id=site-analyser&#93;
aws&#95;iam&#95;policy.lambda: Creating...
aws&#95;iam&#95;policy.lambda: Creation complete after 1s &#91;id=arn:aws:iam::289341159200:policy/site-analyser-lambda&#93;
aws&#95;iam&#95;role&#95;policy&#95;attachment.lambda: Creating...
aws&#95;iam&#95;role&#95;policy&#95;attachment.lambda: Creation complete after 0s &#91;id=site-analyser-lambda-20230104115714236700000001&#93;
aws&#95;lambda&#95;function.lambda: Still modifying... &#91;id=site-analyser, 10s elapsed&#93;
aws&#95;lambda&#95;function.lambda: Still modifying... &#91;id=site-analyser, 20s elapsed&#93;
aws&#95;lambda&#95;function.lambda: Modifications complete after 22s &#91;id=site-analyser&#93;

Apply complete! Resources: 4 added, 1 changed, 0 destroyed.

Outputs:

function&#95;url = &quot;https://kuceaiz55k7soeki4u5oy4w6uy0ntbky.lambda-url.eu-west-1.on.aws/&quot;
</code></pre><p>Lookin' good!</p><h2 id="getting_down_to_brass_tracks_for_real_this_time">Getting down to brass tracks for real this time</h2><p>OK, now that we have a DynamoDB table and permissions to update it, let's implement the <code>/track</code> endpoint. The first thing we'll need to do is add <a href='https://github.com/grzm/awyeah-api'>awyeah-api</a> (a library which makes Cognitect's <a href='https://github.com/cognitect-labs/aws-api'>aws-api</a> work with Babashka) to talk to DynamoDB. We'll create a <code>src/bb.edn</code> and add the following:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;.&quot;&#93;
 :deps {com.cognitect.aws/endpoints {:mvn/version &quot;1.1.12.373&quot;}
        com.cognitect.aws/dynamodb {:mvn/version &quot;825.2.1262.0&quot;}
        com.grzm/awyeah-api {:git/url &quot;https://github.com/grzm/awyeah-api&quot;
                             :git/sha &quot;0fa7dd51f801dba615e317651efda8c597465af6&quot;}
        org.babashka/spec.alpha {:git/url &quot;https://github.com/babashka/spec.alpha&quot;
                                 :git/sha &quot;433b0778e2c32f4bb5d0b48e5a33520bee28b906&quot;}}}
</code></pre><p>To let Blambda know that it should build a lambda layer for the dependencies, we need to add a <code>:deps-layer-name</code> key to the config in our top-level <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps { ... }
 :tasks
 {:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;
  :init &#40;do
          &#40;def config {:bb-arch &quot;arm64&quot;
                       :deps-layer-name &quot;site-analyser-deps&quot;
                       :lambda-name &quot;site-analyser&quot;
                       :lambda-handler &quot;handler/handler&quot;
                       :lambda-iam-role &quot;${aws&#95;iam&#95;role.lambda.arn}&quot;
                       :source-files &#91;&quot;handler.clj&quot;&#93;
                       :extra-tf-config &#91;&quot;tf/main.tf&quot;&#93;}&#41;&#41;
  ...
</code></pre><p>Blambda will automatically look in <code>src/bb.edn</code> to find the dependencies to include in the layer. Let's test this out by building the deps layer:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-deps-layer

Building dependencies layer: /tmp/site-analyser/target/site-analyser-deps.zip
Classpath before transforming: src:/tmp/site-analyser/.work/m2-repo/com/cognitect/aws/dynamodb/825.2.1262.0/...

Classpath after transforming: src:/opt/m2-repo/com/cognitect/aws/dynamodb/825.2.1262.0/dynamodb-825.2.1262.0.jar:...

Compressing dependencies layer: /tmp/site-analyser/target/site-analyser-deps.zip
  adding: gitlibs/ &#40;stored 0%&#41;
  adding: gitlibs/&#95;repos/ &#40;stored 0%&#41;
&#91;...&#93;
  adding: m2-repo/com/cognitect/aws/dynamodb/825.2.1262.0/dynamodb-825.2.1262.0.pom.sha1 &#40;deflated 3%&#41;
  adding: deps-classpath &#40;deflated 67%&#41;
</code></pre><p>And since we have a new layer, we'll need to generate new Terraform config:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda terraform write-config
Copying Terraform config tf/main.tf
Writing lambda layer config: /tmp/site-analyser/target/blambda.tf
Writing lambda layer vars: /tmp/site-analyser/target/blambda.auto.tfvars
Writing lambda layers module: /tmp/site-analyser/target/modules/lambda&#95;layer.tf
</code></pre><p>Let's now whip up a <code>src/page&#95;views.clj</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns page-views
  &#40;:require &#91;com.grzm.awyeah.client.api :as aws&#93;&#41;&#41;

&#40;defn log &#91;msg data&#93;
  &#40;prn &#40;assoc data :msg msg&#41;&#41;&#41;

&#40;defmacro -&gt;map &#91;&amp; ks&#93;
  &#40;assert &#40;every? symbol? ks&#41;&#41;
  &#40;zipmap &#40;map keyword ks&#41;
          ks&#41;&#41;

&#40;defn -&gt;int &#91;s&#93;
  &#40;Integer/parseUnsignedInt s&#41;&#41;

&#40;defn client &#91;{:keys &#91;aws-region&#93; :as config}&#93;
  &#40;assoc config :dynamodb &#40;aws/client {:api :dynamodb, :region aws-region}&#41;&#41;&#41;

&#40;defn validate-response &#91;res&#93;
  &#40;when &#40;:cognitect.anomalies/category res&#41;
    &#40;let &#91;data &#40;merge &#40;select-keys res &#91;:cognitect.anomalies/category&#93;&#41;
                      {:err-msg &#40;:Message res&#41;
                       :err-type &#40;:&#95;&#95;type res&#41;}&#41;&#93;
      &#40;log &quot;DynamoDB request failed&quot; data&#41;
      &#40;throw &#40;ex-info &quot;DynamoDB request failed&quot; data&#41;&#41;&#41;&#41;
  res&#41;

&#40;defn increment! &#91;{:keys &#91;dynamodb views-table&#93; :as client} date url&#93;
  &#40;let &#91;req {:TableName views-table
             :Key {:date {:S date}
                   :url {:S url}}
             :UpdateExpression &quot;ADD #views :increment&quot;
             :ExpressionAttributeNames {&quot;#views&quot; &quot;views&quot;}
             :ExpressionAttributeValues {&quot;:increment&quot; {:N &quot;1&quot;}}
             :ReturnValues &quot;ALL&#95;NEW&quot;}
        &#95; &#40;log &quot;Incrementing page view counter&quot;
               &#40;-&gt;map date url req&#41;&#41;
        res &#40;-&gt; &#40;aws/invoke dynamodb {:op :UpdateItem
                                      :request req}&#41;
                validate-response&#41;
        new-counter &#40;-&gt; res
                        &#40;get-in &#91;:Attributes :views :N&#93;&#41;
                        -&gt;int&#41;
        ret &#40;-&gt;map date url new-counter&#41;&#93;
    &#40;log &quot;Page view counter incremented&quot;
         ret&#41;
    ret&#41;&#41;
</code></pre><h2 id="repl-driven_development%2C_naturally">REPL-driven development, naturally</h2><p>That's a bunch of code to have written without knowing it works, so let's act like real Clojure developers and fire up a REPL:</p><pre class="language-text"><code class="lang-text language-text">$ cd src/

$ bb nrepl-server 0
Started nREPL server at 127.0.0.1:42733
For more info visit: https://book.babashka.org/#&#95;nrepl
</code></pre><p>Once this is done, we can connect from our text editor (Emacs, I hope) and test things out in a <a href='https://betweentwoparens.com/blog/rich-comment-blocks/#rich-comment'>Rich
comment</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def c &#40;client {:aws-region &quot;eu-west-1&quot;, :views-table &quot;site-analyser&quot;}&#41;&#41;

  &#40;increment! c &quot;2023-01-04&quot; &quot;https://example.com/test.html&quot;&#41;
  ;; =&gt; {:date &quot;2023-01-04&quot;, :url &quot;https://example.com/test.html&quot;, :new-counter 1}

  &#40;increment! c &quot;2023-01-04&quot; &quot;https://example.com/test.html&quot;&#41;
  ;; =&gt; {:date &quot;2023-01-04&quot;, :url &quot;https://example.com/test.html&quot;, :new-counter 2}

  &#41;
</code></pre><p>Lookin' good! 😀</p><p>Let's populate the table with a bunch of data (this will take a little while):</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;doseq &#91;date &#40;map &#40;partial format &quot;2022-12-%02d&quot;&#41; &#40;range 1 32&#41;&#41;
          url &#40;map &#40;partial format &quot;https://example.com/page-%02d&quot;&#41; &#40;range 1 11&#41;&#41;&#93;
    &#40;dotimes &#91;&#95; &#40;rand-int 5&#41;&#93;
      &#40;increment! c date url&#41;&#41;&#41;
  ;; nil

  &#41;
</code></pre><p>Just for fun, we can take a quick peek at what the data looks like in DynamoDB:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;let &#91;{:keys &#91;dynamodb views-table&#93;} c&#93;
    &#40;aws/invoke dynamodb {:op :Scan
                          :request {:TableName views-table
                                    :Limit 5}}&#41;&#41;
  ;; =&gt; {:Items
  ;;     &#91;{:views {:N &quot;7&quot;},
  ;;       :date {:S &quot;2022-12-12&quot;},
  ;;       :url {:S &quot;https://example.com/page-01&quot;}}
  ;;      {:views {:N &quot;16&quot;},
  ;;       :date {:S &quot;2022-12-12&quot;},
  ;;       :url {:S &quot;https://example.com/page-02&quot;}}
  ;;      {:views {:N &quot;14&quot;},
  ;;       :date {:S &quot;2022-12-12&quot;},
  ;;       :url {:S &quot;https://example.com/page-03&quot;}}
  ;;      {:views {:N &quot;8&quot;},
  ;;       :date {:S &quot;2022-12-12&quot;},
  ;;       :url {:S &quot;https://example.com/page-05&quot;}}
  ;;      {:views {:N &quot;6&quot;},
  ;;       :date {:S &quot;2022-12-12&quot;},
  ;;       :url {:S &quot;https://example.com/page-06&quot;}}&#93;,
  ;;     :Count 5,
  ;;     :ScannedCount 5,
  ;;     :LastEvaluatedKey
  ;;     {:url {:S &quot;https://example.com/page-06&quot;}, :date {:S &quot;2022-12-12&quot;}}}

  &#41;
</code></pre><h2 id="you_can%27t_handle_the_track%21">You can't handle the track!</h2><p>Now that we have the DynamoDB machinery in place, let's connect it to our handler. But first, let's do a quick bit of refactoring so that we don't have to duplicate the <code>log</code> function in both our <code>handler</code> and <code>page-views</code> namespaces. We'll create a <code>src/util.clj</code> and add the following:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns util
  &#40;:import &#40;java.net URLDecoder&#41;
           &#40;java.nio.charset StandardCharsets&#41;&#41;&#41;

&#40;defmacro -&gt;map &#91;&amp; ks&#93;
  &#40;assert &#40;every? symbol? ks&#41;&#41;
  &#40;zipmap &#40;map keyword ks&#41;
          ks&#41;&#41;

&#40;defn -&gt;int &#91;s&#93;
  &#40;Integer/parseUnsignedInt s&#41;&#41;

&#40;defn log
  &#40;&#91;msg&#93;
   &#40;log msg {}&#41;&#41;
  &#40;&#91;msg data&#93;
   &#40;prn &#40;assoc data :msg msg&#41;&#41;&#41;&#41;
</code></pre><p>We need to update <code>page&#95;views.clj</code> to use this namespace:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns page-views
  &#40;:require &#91;com.grzm.awyeah.client.api :as aws&#93;
            &#91;util :refer &#91;-&gt;map&#93;&#93;&#41;&#41;

&#40;defn client &#91;{:keys &#91;aws-region&#93; :as config}&#93;
  &#40;assoc config :dynamodb &#40;aws/client {:api :dynamodb, :region aws-region}&#41;&#41;&#41;

&#40;defn validate-response &#91;res&#93;
  &#40;when &#40;:cognitect.anomalies/category res&#41;
    &#40;let &#91;data &#40;merge &#40;select-keys res &#91;:cognitect.anomalies/category&#93;&#41;
                      {:err-msg &#40;:Message res&#41;
                       :err-type &#40;:&#95;&#95;type res&#41;}&#41;&#93;
      &#40;util/log &quot;DynamoDB request failed&quot; data&#41;
      &#40;throw &#40;ex-info &quot;DynamoDB request failed&quot; data&#41;&#41;&#41;&#41;
  res&#41;

&#40;defn increment! &#91;{:keys &#91;dynamodb views-table&#93; :as client} date url&#93;
  &#40;let &#91;req {:TableName views-table
             :Key {:date {:S date}
                   :url {:S url}}
             :UpdateExpression &quot;ADD #views :increment&quot;
             :ExpressionAttributeNames {&quot;#views&quot; &quot;views&quot;}
             :ExpressionAttributeValues {&quot;:increment&quot; {:N &quot;1&quot;}}
             :ReturnValues &quot;ALL&#95;NEW&quot;}
        &#95; &#40;util/log &quot;Incrementing page view counter&quot;
                    &#40;-&gt;map date url req&#41;&#41;
        res &#40;-&gt; &#40;aws/invoke dynamodb {:op :UpdateItem
                                      :request req}&#41;
                validate-response&#41;
        new-counter &#40;-&gt; res
                        &#40;get-in &#91;:Attributes :views :N&#93;&#41;
                        util/-&gt;int&#41;
        ret &#40;-&gt;map date url new-counter&#41;&#93;
    &#40;util/log &quot;Page view counter incremented&quot;
              ret&#41;
    ret&#41;&#41;
</code></pre><p>Now we can turn our eye to <code>handler.clj</code>. First, we pull in the <code>util</code> namespace and use its <code>log</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns handler
  &#40;:require &#91;cheshire.core :as json&#93;
            &#91;util :refer &#91;-&gt;map&#93;&#93;&#41;&#41;

&#40;defn handler &#91;{:keys &#91;queryStringParameters requestContext&#93; :as event} &#95;context&#93;
  &#40;util/log &quot;Invoked with event&quot; {:event event}&#41;
  &#40;let &#91;{:keys &#91;method path&#93;} &#40;:http requestContext&#41;
        {:keys &#91;name&#93; :or {name &quot;Blambda&quot;}} queryStringParameters&#93;
    &#40;util/log &#40;format &quot;Request: %s %s&quot; method path&#41;
              {:method method, :path path, :name name}&#41;
    {:statusCode 200
     :headers {&quot;Content-Type&quot; &quot;application/json&quot;}
     :body &#40;json/generate-string {:greeting &#40;str &quot;Hello &quot; name &quot;!&quot;&#41;}&#41;}&#41;&#41;
</code></pre><p>Now, let's remove the hello world stuff and add a <code>/track</code> endpoint. We expect our clients to make an HTTP <code>POST</code> request to the <code>/track</code> path, so we can use a simple pattern match in the handler for this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn handler &#91;{:keys &#91;queryStringParameters requestContext&#93; :as event} &#95;context&#93;
  &#40;util/log &quot;Invoked with event&quot; {:event event}&#41;
  &#40;let &#91;{:keys &#91;method path&#93;} &#40;:http requestContext&#41;&#93;
    &#40;util/log &#40;format &quot;Request: %s %s&quot; method path&#41;
              {:method method, :path path, :name name}&#41;
    &#40;case &#91;method path&#93;
      &#91;&quot;POST&quot; &quot;/track&quot;&#93;
      &#40;let &#91;{:keys &#91;url&#93;} queryStringParameters&#93;
        &#40;if url
          &#40;do
            &#40;util/log &quot;Should be tracking a page view here&quot; &#40;-&gt;map url&#41;&#41;
            {:statusCode 204}&#41;
          {:statusCode 400
           :headers {&quot;Content-Type&quot; &quot;application/json&quot;}
           :body &#40;json/generate-string {:msg &quot;Missing required param: url&quot;}&#41;}&#41;&#41;

      {:statusCode 404
       :headers {&quot;Content-Type&quot; &quot;application/json&quot;}
       :body &#40;json/generate-string {:msg &#40;format &quot;Resource not found: %s&quot; path&#41;}&#41;}&#41;&#41;&#41;
</code></pre><p>We can test this out in the REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;handler {:requestContext {:http {:method &quot;POST&quot; :path &quot;/nope&quot;}}} nil&#41;
  ;; =&gt; {:statusCode 404,
  ;;     :headers {&quot;Content-Type&quot; &quot;application/json&quot;},
  ;;     :body &quot;{\&quot;msg\&quot;:\&quot;Resource not found: /nope\&quot;}&quot;}

  &#40;handler {:requestContext {:http {:method &quot;GET&quot; :path &quot;/track&quot;}}} nil&#41;
  ;; =&gt; {:statusCode 404,
  ;;     :headers {&quot;Content-Type&quot; &quot;application/json&quot;},
  ;;     :body &quot;{\&quot;msg\&quot;:\&quot;Resource not found: /track\&quot;}&quot;}

  &#40;handler {:requestContext {:http {:method &quot;POST&quot; :path &quot;/track&quot;}}} nil&#41;
  ;; =&gt; {:statusCode 400,
  ;;     :headers {&quot;Content-Type&quot; &quot;application/json&quot;},
  ;;     :body &quot;{\&quot;msg\&quot;:\&quot;Missing required param: url\&quot;}&quot;}

  &#40;handler {:requestContext {:http {:method &quot;POST&quot; :path &quot;/track&quot;}}
            :queryStringParameters {:url &quot;https://example.com/test.html&quot;}} nil&#41;
  ;; =&gt; {:statusCode 204}

  &#41;
</code></pre><p>Now we need to connect this to <code>page-views/increment!</code>, which in addition to the URL, also requires a date. Before figuring out how to provide that, let's extract the tracking code into a separate function so we don't keep adding stuff to the <code>handler</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn track-visit! &#91;{:keys &#91;queryStringParameters&#93; :as event}&#93;
  &#40;let &#91;{:keys &#91;url&#93;} queryStringParameters&#93;
    &#40;if url
      &#40;do
        &#40;util/log &quot;Should be tracking a page view here&quot; &#40;-&gt;map url&#41;&#41;
        {:statusCode 204}&#41;
      {:statusCode 400
       :headers {&quot;Content-Type&quot; &quot;application/json&quot;}
       :body &#40;json/generate-string {:msg &quot;Missing required param: url&quot;}&#41;}&#41;&#41;&#41;
</code></pre><p>Now we can simplify the <code>case</code> statement:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;case &#91;method path&#93;
      &#91;&quot;POST&quot; &quot;/track&quot;&#93; &#40;track-visit! event&#41;

      {:statusCode 404
       :headers {&quot;Content-Type&quot; &quot;application/json&quot;}
       :body &#40;json/generate-string {:msg &#40;format &quot;Resource not found: %s&quot; path&#41;}&#41;}&#41;
</code></pre><p>Babashka provides the <code>java.time</code> classes, so we can get the current date using <code>java.time.LocalDate</code>. Let's import it into our namespace then use it in our shiny new <code>track-visit!</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns handler
  &#40;:require &#91;cheshire.core :as json&#93;
            &#91;util :refer &#91;-&gt;map&#93;&#93;&#41;
  &#40;:import &#40;java.time LocalDate&#41;&#41;&#41;

&#40;defn track-visit! &#91;{:keys &#91;queryStringParameters&#93; :as event}&#93;
  &#40;let &#91;{:keys &#91;url&#93;} queryStringParameters&#93;
    &#40;if url
      &#40;let &#91;date &#40;str &#40;LocalDate/now&#41;&#41;&#93;
        &#40;util/log &quot;Should be tracking a page view here&quot; &#40;-&gt;map date url&#41;&#41;
        {:statusCode 204}&#41;
      &#40;do
        &#40;util/log &quot;Missing required query param&quot; {:param &quot;url&quot;}&#41;
        {:statusCode 400
         :body &quot;Missing required query param: url&quot;}&#41;&#41;&#41;&#41;
</code></pre><p>Let's test this out in the REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;handler {:requestContext {:http {:method &quot;POST&quot; :path &quot;/track&quot;}}
            :queryStringParameters {:url &quot;https://example.com/test.html&quot;}} nil&#41;
  ;; =&gt; {:statusCode 201}

  &#41;
</code></pre><p>You should see something like this printed to your REPL's output buffer:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:event {:requestContext {:http {:method &quot;POST&quot;, :path &quot;/track&quot;}}, :queryStringParameters {:url &quot;https://example.com/test.html&quot;}}, :msg &quot;Invoked with event&quot;}
{:method &quot;POST&quot;, :path &quot;/track&quot;, :msg &quot;Request: POST /track&quot;}
{:url &quot;https://example.com/test.html&quot;, :msg &quot;Should be tracking a page view here&quot;}
</code></pre><h2 id="wiring_it_all_up">Wiring it all up</h2><p>Finally, we need to connect the handler to <code>page-views/increment!</code>. <code>increment!</code> expects us to pass it a page views client, which contains a DynamoDB client, which will establish an HTTP connection when first used, which will take a couple of milliseconds. We would like this HTTP connection to be shared across invocations of our lambda so that we only need to establish it on a cold start (or whenever the DynamoDB API feels like the connection has been idle too long and decides to close it), so we'll use the trick of defining it outside our handler function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns handler
  &#40;:require &#91;cheshire.core :as json&#93;
            &#91;page-views&#93;
            &#91;util :refer &#91;-&gt;map&#93;&#93;&#41;
  &#40;:import &#40;java.time LocalDate&#41;&#41;&#41;

&#40;def client &#40;page-views/client {:aws-region &quot;eu-west-1&quot;
                                :views-table &quot;site-analyser&quot;}&#41;&#41;
</code></pre><p>Now we have everything we need! We'll replace our placeholder log line:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">        &#40;util/log &quot;Should be tracking a page view here&quot; &#40;-&gt;map url&#41;&#41;
</code></pre><p>with an actual call to <code>page-views/increment!</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">        &#40;page-views/increment! client date url&#41;
</code></pre><p>Let's test this one more time in the REPL before deploying it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;handler {:requestContext {:http {:method &quot;POST&quot; :path &quot;/track&quot;}}
            :queryStringParameters {:url &quot;https://example.com/test.html&quot;}} nil&#41;
  ;; =&gt; {:statusCode 201}

  &#41;
</code></pre><p>This time, we should see an actual DynamoDB query logged:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:event {:requestContext {:http {:method &quot;POST&quot;, :path &quot;/track&quot;}}, :queryStringParameters {:url &quot;https://example.com/test.html&quot;}}, :msg &quot;Invoked with event&quot;}
{:method &quot;POST&quot;, :path &quot;/track&quot;, :msg &quot;Request: POST /track&quot;}
{:date &quot;2023-01-04&quot;, :url &quot;https://example.com/test.html&quot;, :req {:TableName &quot;site-analyser&quot;, :Key {:date {:S &quot;2023-01-04&quot;}, :url {:S &quot;https://example.com/test.html&quot;}}, :UpdateExpression &quot;ADD #views :increment&quot;, :ExpressionAttributeNames {&quot;#views&quot; &quot;views&quot;}, :ExpressionAttributeValues {&quot;:increment&quot; {:N &quot;1&quot;}}, :ReturnValues &quot;ALL&#95;NEW&quot;}, :msg &quot;Incrementing page view counter&quot;}
{:date &quot;2023-01-04&quot;, :url &quot;https://example.com/test.html&quot;, :new-counter 3, :msg &quot;Page view counter incremented&quot;}
</code></pre><h2 id="deploying_the_real_deal">Deploying the real deal</h2><p>Let's recap what we've done:</p><ol><li>Added awyeah-api as a dependency</li><li>Add two new namespaces: <code>page-views</code> and <code>util</code></li><li>Updated our handler to actually use DynamoDB</li></ol><p>Since we added more source files, we need to add them to the <code>:source-files</code> list in the top-level <code>bb-edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps { ... }
 :tasks
 {:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;
  :init &#40;do
          &#40;def config {:bb-arch &quot;arm64&quot;
                       :deps-layer-name &quot;site-analyser-deps&quot;
                       :lambda-name &quot;site-analyser&quot;
                       :lambda-handler &quot;handler/handler&quot;
                       :lambda-iam-role &quot;${aws&#95;iam&#95;role.lambda.arn}&quot;
                       :source-files &#91;&quot;handler.clj&quot; &quot;page&#95;views.clj&quot; &quot;util.clj&quot;&#93;
                       :extra-tf-config &#91;&quot;tf/main.tf&quot;&#93;}&#41;&#41;
  ...
</code></pre><p>Once this is done, we can rebuild our lambda and reploy:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-lambda

Building lambda artifact: /tmp/site-analyser/target/site-analyser.zip
Adding file: handler.clj
Adding file: page&#95;views.clj
Adding file: util.clj
Compressing lambda: /tmp/site-analyser/target/site-analyser.zip
updating: handler.clj &#40;deflated 66%&#41;
  adding: page&#95;views.clj &#40;deflated 59%&#41;
  adding: util.clj &#40;deflated 35%&#41;

&#91;nix-shell:/tmp/site-analyser&#93;$ bb blambda terraform apply

Terraform will perform the following actions:

  # aws&#95;lambda&#95;function.lambda will be updated in-place
  &#126; resource &quot;aws&#95;lambda&#95;function&quot; &quot;lambda&quot; {

  # module.deps.aws&#95;lambda&#95;layer&#95;version.layer will be created
  + resource &quot;aws&#95;lambda&#95;layer&#95;version&quot; &quot;layer&quot; {

Plan: 1 to add, 1 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

module.deps.aws&#95;lambda&#95;layer&#95;version.layer: Creating...
module.deps.aws&#95;lambda&#95;layer&#95;version.layer: Still creating... &#91;10s elapsed&#93;
module.deps.aws&#95;lambda&#95;layer&#95;version.layer: Creation complete after 10s &#91;id=arn:aws:lambda:eu-west-1:289341159200:layer:site-analyser-de
ps:1&#93;
aws&#95;lambda&#95;function.lambda: Modifying... &#91;id=site-analyser&#93;
aws&#95;lambda&#95;function.lambda: Still modifying... &#91;id=site-analyser, 10s elapsed&#93;
aws&#95;lambda&#95;function.lambda: Modifications complete after 11s &#91;id=site-analyser&#93;

Apply complete! Resources: 1 added, 1 changed, 0 destroyed.

Outputs:

function&#95;url = &quot;https://kuceaiz55k7soeki4u5oy4w6uy0ntbky.lambda-url.eu-west-1.on.aws/&quot;
</code></pre><p>Now that this is deployed, we can test it by tracking a view:</p><pre class="language-text"><code class="lang-text language-text"> curl -v -X POST $BASE&#95;URL/track?url=https%3A%2F%2Fexample.com%2Ftest.html
&#42;   Trying 54.220.150.207:443...
&#42; Connected to kuceaiz55k7soeki4u5oy4w6uy0ntbky.lambda-url.eu-west-1.on.aws &#40;54.220.150.207&#41; port 443 &#40;#0&#41;
&#91;...&#93;
&lt; HTTP/1.1 204 No Content
&lt; Date: Wed, 04 Jan 2023 16:01:24 GMT
&lt; Content-Type: application/json
&lt; Connection: keep-alive
&lt; x-amzn-RequestId: 2d5c6a9d-d3a4-4abb-9a57-c956ca3030f3
&lt; X-Amzn-Trace-Id: root=1-63b5a2d4-58cb681c06672bb410efe80f;sampled=0
&lt;
&#42; Connection #0 to host kuceaiz55k7soeki4u5oy4w6uy0ntbky.lambda-url.eu-west-1.on.aws left intact
</code></pre><p>Looks like it worked!</p><h2 id="configuration_station">Configuration station</h2><p>Before we forge on, let's deal with the annoying hardcoding of our client config:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def client &#40;page-views/client {:aws-region &quot;eu-west-1&quot;
                                :views-table &quot;site-analyser&quot;}&#41;&#41;
</code></pre><p>The normal way of configuring lamdbas is to set environment variables, so let's do that:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-env
  &#40;&#91;k&#93;
   &#40;or &#40;System/getenv k&#41;
       &#40;let &#91;msg &#40;format &quot;Missing env var: %s&quot; k&#41;&#93;
         &#40;throw &#40;ex-info msg {:msg msg, :env-var k}&#41;&#41;&#41;&#41;&#41;
  &#40;&#91;k default&#93;
   &#40;or &#40;System/getenv k&#41; default&#41;&#41;&#41;

&#40;def config {:aws-region &#40;get-env &quot;AWS&#95;REGION&quot; &quot;eu-west-1&quot;&#41;
             :views-table &#40;get-env &quot;VIEWS&#95;TABLE&quot;&#41;}&#41;

&#40;def client &#40;page-views/client config&#41;&#41;
</code></pre><p>Now if we set the <code>VIEWS&#95;TABLE</code> environment variable in our lambda config (<code>AWS&#95;REGION</code> is set by the lambda runtime itself), we're good to go. We can tell Blambda to do this for us by adding a <code>:lambda-env-vars</code> to our top-level <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps { ... }
 :tasks
 {:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;
  :init &#40;do
          &#40;def config {:bb-arch &quot;arm64&quot;
                       :deps-layer-name &quot;site-analyser-deps&quot;
                       :lambda-name &quot;site-analyser&quot;
                       :lambda-handler &quot;handler/handler&quot;
                       :lambda-iam-role &quot;${aws&#95;iam&#95;role.lambda.arn}&quot;
                       :lambda-env-vars &#91;&quot;VIEWS&#95;TABLE=${aws&#95;dynamodb&#95;table.site&#95;analyser.name}&quot;&#93;
                       :source-files &#91;&quot;handler.clj&quot; &quot;page&#95;views.clj&quot; &quot;util.clj&quot;&#93;
                       :extra-tf-config &#91;&quot;tf/main.tf&quot;&#93;}&#41;&#41;
  ...
</code></pre><p>We'll set the name using the Terraform resource that we defined (<code>aws&#95;dynamodb&#95;table.site&#95;analyser</code>), so that if we decide to change the table name, we'll only need to change it in <code>tf/main.tf</code>. The odd format for <code>:lambda-env-vars</code> is to support specifying it from the command line, so just hold your nose and move on.</p><p>Let's rebuild our lambda, regenerate our Terraform config, and redeploy:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-lambda                                                                        &#91;54/1822&#93;

Building lambda artifact: /tmp/site-analyser/target/site-analyser.zip
Adding file: handler.clj
Adding file: page&#95;views.clj
Adding file: util.clj
Compressing lambda: /tmp/site-analyser/target/site-analyser.zip
updating: handler.clj &#40;deflated 64%&#41;
updating: page&#95;views.clj &#40;deflated 59%&#41;
updating: util.clj &#40;deflated 35%&#41;

$ bb blambda terraform write-config
Copying Terraform config tf/main.tf
Writing lambda layer config: /tmp/site-analyser/target/blambda.tf
Writing lambda layer vars: /tmp/site-analyser/target/blambda.auto.tfvars
Writing lambda layers module: /tmp/site-analyser/target/modules/lambda&#95;layer.tf

$ bb blambda terraform apply

Terraform will perform the following actions:

  # aws&#95;lambda&#95;function.lambda will be updated in-place
  &#126; resource &quot;aws&#95;lambda&#95;function&quot; &quot;lambda&quot; {
        # &#40;19 unchanged attributes hidden&#41;

      + environment {
          + variables = {
              + &quot;VIEWS&#95;TABLE&quot; = &quot;site-analyser&quot;
            }
        }
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws&#95;lambda&#95;function.lambda: Modifying... &#91;id=site-analyser&#93;
aws&#95;lambda&#95;function.lambda: Still modifying... &#91;id=site-analyser, 10s elapsed&#93;
aws&#95;lambda&#95;function.lambda: Modifications complete after 15s &#91;id=site-analyser&#93;

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

Outputs:

function&#95;url = &quot;https://kuceaiz55k7soeki4u5oy4w6uy0ntbky.lambda-url.eu-west-1.on.aws/&quot;
</code></pre><h2 id="a_dashing_dashboard">A dashing dashboard</h2><p>The final piece of the puzzle is displaying the site analytics. We said that <code>GET /dashboard</code> should serve up a nice HTML page, so let's add a route for this to our handler:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">    &#40;case &#91;method path&#93;
      &#91;&quot;GET&quot; &quot;/dashboard&quot;&#93; &#40;serve-dashboard event&#41;
      &#91;&quot;POST&quot; &quot;/track&quot;&#93; &#40;track-visit! event&#41;

      {:statusCode 404
       :headers {&quot;Content-Type&quot; &quot;application/json&quot;}
       :body &#40;json/generate-string {:msg &#40;format &quot;Resource not found: %s&quot; path&#41;}&#41;}&#41;
</code></pre><p>Before we write the <code>serve-dashboard</code> function, let's think about how this should work. Babashka ships with <a href='https://github.com/yogthos/Selmer'>Selmer</a>, a nice template system, so let's add a <code>src/index.html</code> template, copying liberally from Cyprien Pannier's blog post:</p><pre class="language-html"><code class="lang-html language-html">&lt;!doctype html&gt;
&lt;html lang=&quot;en&quot;&gt;
  &lt;head&gt;
    &lt;meta charset=&quot;utf-8&quot;&gt;
    &lt;meta name=&quot;description&quot; content=&quot;&quot;&gt;
    &lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;

    &lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css&quot;&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css&quot; integrity=&quot;sha512-1ycn6IcaQQ40/MKBW2W4Rhis/DbILU74C1vSrLJxCq57o941Ym01SwNsOMqvEBFlcgUa6xLiPY/NS5R+E6ztJQ==&quot; crossorigin=&quot;anonymous&quot; referrerpolicy=&quot;no-referrer&quot; /&gt;

    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/vega@5.21.0&quot;&gt;&lt;/script&gt;
    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/vega-lite@5.2.0&quot;&gt;&lt;/script&gt;
    &lt;script src=&quot;https://cdn.jsdelivr.net/npm/vega-embed@6.20.2&quot;&gt;&lt;/script&gt;

    &lt;title&gt;Site Analytics - Powered by Blambda!&lt;/title&gt;
  &lt;/head&gt;
  &lt;body&gt;
    &lt;section class=&quot;hero is-link block&quot;&gt;
      &lt;div class=&quot;hero-body has-text-centered&quot;&gt;
        &lt;p class=&quot;title&quot; style=&quot;vertical-align:baseline&quot;&gt;
          &lt;span class=&quot;icon&quot;&gt;
            &lt;i class=&quot;fas fa-chart-pie&quot;&gt;&lt;/i&gt;
          &lt;/span&gt;
          &amp;nbsp;Site Analytics - Powered by Blambda!
        &lt;/p&gt;
        &lt;p class=&quot;subtitle&quot;&gt;{{date-label}}&lt;/p&gt;
      &lt;/div&gt;
    &lt;/section&gt;
    &lt;div class=&quot;container is-max-desktop&quot;&gt;
      &lt;div class=&quot;box&quot;&gt;
        &lt;nav class=&quot;level is-mobile&quot;&gt;
          &lt;div class=&quot;level-item has-text-centered&quot;&gt;
            &lt;div&gt;
              &lt;p class=&quot;heading&quot;&gt;Total views&lt;/p&gt;
              &lt;p class=&quot;title&quot;&gt;{{total-views}}&lt;/p&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/nav&gt;
        &lt;div&gt;
          &lt;div id=&quot;{{chart-id}}&quot; style=&quot;width:100%; height:300px&quot;&gt;&lt;/div&gt;
          &lt;script type=&quot;text/javascript&quot;&gt;
           vegaEmbed &#40;'#{{chart-id}}', JSON.parse&#40;{{chart-spec|json|safe}}&#41;&#41;;
          &lt;/script&gt;
        &lt;/div&gt;
      &lt;/div&gt;
      &lt;div class=&quot;box&quot;&gt;
        &lt;h1 class=&quot;title is-3&quot;&gt;Top URLs&lt;/h1&gt;
        &lt;table class=&quot;table is-fullwidth is-hoverable is-striped&quot;&gt;
          &lt;thead&gt;
            &lt;tr&gt;
              &lt;th&gt;Rank&lt;/th&gt;
              &lt;th&gt;URL&lt;/th&gt;
              &lt;th&gt;Views&lt;/th&gt;
            &lt;/tr&gt;
          &lt;/thead&gt;
          &lt;tbody&gt;
            {% for i in top-urls %}
            &lt;tr&gt;
              &lt;td style=&quot;width: 20px&quot;&gt;{{i.rank}}&lt;/td&gt;
              &lt;td&gt;&lt;a href=&quot;{{i.url}}&quot;&gt;{{i.url}}&lt;/a&gt;&lt;/td&gt;
              &lt;td style=&quot;width: 20px&quot;&gt;{{i.views}}&lt;/td&gt;
            &lt;/tr&gt;
            {% endfor %}
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre><p>Just consider this stuff an incantation if you don't feel like reading it. 😅</p><p>Once we have the template in place, we can write the <code>serve-dashboard</code> that renders it. First, we need to require Selmer in <code>src/handler.clj</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns handler
  &#40;:require &#91;cheshire.core :as json&#93;
            &#91;selmer.parser :as selmer&#93;
            &#91;page-views&#93;
            &#91;util :refer &#91;-&gt;map&#93;&#93;&#41;
  &#40;:import &#40;java.time LocalDate&#41;&#41;&#41;
</code></pre><p>Now, we can just load and render the template in <code>serve-dashboard</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn serve-dashboard &#91;&#95;event&#93;
  &#40;util/log &quot;Rendering dashboard&quot;&#41;
  {:statusCode 200
   :headers {&quot;Content-Type&quot; &quot;text/html&quot;}
   :body &#40;selmer/render &#40;slurp &quot;index.html&quot;&#41; {}&#41;}&#41;
</code></pre><p>Since we've added <code>index.html</code> as a source file, we need to add it to the <code>:source-files</code> list in the top-level <code>bb-edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps { ... }
 :tasks
 {:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;
  :init &#40;do
          &#40;def config {:bb-arch &quot;arm64&quot;
                       :deps-layer-name &quot;site-analyser-deps&quot;
                       :lambda-name &quot;site-analyser&quot;
                       :lambda-handler &quot;handler/handler&quot;
                       :lambda-iam-role &quot;${aws&#95;iam&#95;role.lambda.arn}&quot;
                       :source-files &#91;;; Clojure sources
                                      &quot;handler.clj&quot;
                                      &quot;page&#95;views.clj&quot;
                                      &quot;util.clj&quot;

                                      ;; HTML templates
                                      &quot;index.html&quot;
                                      &#93;
                       :extra-tf-config &#91;&quot;tf/main.tf&quot;&#93;}&#41;&#41;
  ...
</code></pre><p>Let's rebuild and redeploy:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-lambda

Building lambda artifact: /tmp/site-analyser/target/site-analyser.zip
Adding file: handler.clj
Adding file: page&#95;views.clj
Adding file: util.clj
Adding file: index.html
Compressing lambda: /tmp/site-analyser/target/site-analyser.zip
updating: handler.clj &#40;deflated 66%&#41;
updating: page&#95;views.clj &#40;deflated 59%&#41;
updating: util.clj &#40;deflated 35%&#41;
  adding: index.html &#40;deflated 59%&#41;

&#91;nix-shell:/tmp/site-analyser&#93;$ bb blambda terraform apply

Terraform will perform the following actions:

  # aws&#95;lambda&#95;function.lambda will be updated in-place
  &#126; resource &quot;aws&#95;lambda&#95;function&quot; &quot;lambda&quot; {
Plan: 0 to add, 1 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws&#95;lambda&#95;function.lambda: Modifying... &#91;id=site-analyser&#93;
aws&#95;lambda&#95;function.lambda: Modifications complete after 7s &#91;id=site-analyser&#93;

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

Outputs:

function&#95;url = &quot;https://kuceaiz55k7soeki4u5oy4w6uy0ntbky.lambda-url.eu-west-1.on.aws/&quot;
</code></pre><p>Now we can visit https://kuceaiz55k7soeki4u5oy4w6uy0ntbky.lambda-url.eu-west-1.on.aws/dashboard in a web browser and see something like this:</p><p><img src="assets/2023-01-04-dashboard-empty.png" alt="Site analytics dashboard showing no data" title="Boring dashboard" width=800px /></p><p>This is definitely a bit on the boring side, so let's write some code to query DynamoDB and supply the data to make the dashboard dashing!</p><h2 id="paging_doctor_aws_again">Paging Doctor AWS again</h2><p>Whenever we fetch data from an AWS API, we need to handle pagination. Luckily, I have already <a href='2022-10-02-page-2.html'>blogged about this extensively</a>, so we can just copy and paste from my S3 paging example to accomplish the same with DynamoDB. Let's start by adding the handy <code>lazy-concat</code> helper function to <code>src/util.clj</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn lazy-concat &#91;colls&#93;
  &#40;lazy-seq
   &#40;when-first &#91;c colls&#93;
     &#40;lazy-cat c &#40;lazy-concat &#40;rest colls&#41;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Now, we can add some code to <code>src/page&#95;views.clj</code> to query DynamoDB in a page-friendly way:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-query-page &#91;{:keys &#91;dynamodb views-table&#93; :as client}
                      date
                      {:keys &#91;page-num LastEvaluatedKey&#93; :as prev}&#93;
  &#40;when prev
    &#40;util/log &quot;Got page&quot; prev&#41;&#41;
  &#40;when &#40;or &#40;nil? prev&#41;
            LastEvaluatedKey&#41;
    &#40;let &#91;page-num &#40;inc &#40;or page-num 0&#41;&#41;
          req &#40;merge
               {:TableName views-table
                :KeyConditionExpression &quot;#date = :date&quot;
                :ExpressionAttributeNames {&quot;#date&quot; &quot;date&quot;}
                :ExpressionAttributeValues {&quot;:date&quot; {:S date}}}
               &#40;when LastEvaluatedKey
                 {:ExclusiveStartKey LastEvaluatedKey}&#41;&#41;
          &#95; &#40;util/log &quot;Querying page views&quot; &#40;-&gt;map date page-num req&#41;&#41;
          res &#40;-&gt; &#40;aws/invoke dynamodb {:op :Query
                                        :request req}&#41;
                  validate-response&#41;
          &#95; &#40;util/log &quot;Got response&quot; &#40;-&gt;map res&#41;&#41;&#93;
      &#40;assoc res :page-num page-num&#41;&#41;&#41;&#41;

&#40;defn get-views &#91;client date&#93;
  &#40;-&gt;&gt; &#40;iteration &#40;partial get-query-page client date&#41;
                  :vf :Items&#41;
       util/lazy-concat
       &#40;map &#40;fn &#91;{:keys &#91;views date url&#93;}&#93;
              {:date &#40;:S date&#41;
               :url &#40;:S url&#41;
               :views &#40;util/-&gt;int &#40;:N views&#41;&#41;}&#41;&#41;&#41;&#41;
</code></pre><p>We might as well test this out in our REPL whilst we're here:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def c &#40;client {:aws-region &quot;eu-west-1&quot;, :views-table &quot;site-analyser&quot;}&#41;&#41;

  &#40;get-views c &quot;2022-12-25&quot;&#41;
  ;; =&gt; &#40;{:date &quot;2022-12-25&quot;, :url &quot;https://example.com/page-01&quot;, :views 5}
  ;;     {:date &quot;2022-12-25&quot;, :url &quot;https://example.com/page-03&quot;, :views 8}
  ;;     {:date &quot;2022-12-25&quot;, :url &quot;https://example.com/page-04&quot;, :views 15}
  ;;     {:date &quot;2022-12-25&quot;, :url &quot;https://example.com/page-05&quot;, :views 3}
  ;;     {:date &quot;2022-12-25&quot;, :url &quot;https://example.com/page-06&quot;, :views 12}
  ;;     {:date &quot;2022-12-25&quot;, :url &quot;https://example.com/page-07&quot;, :views 11}
  ;;     {:date &quot;2022-12-25&quot;, :url &quot;https://example.com/page-08&quot;, :views 15}
  ;;     {:date &quot;2022-12-25&quot;, :url &quot;https://example.com/page-09&quot;, :views 8}&#41;

  &#41;
</code></pre><p>Looks good!</p><p>Now let's plug this into <code>src/handler.clj</code>! The Vega library we're using to render our data will attach to a <code>&lt;div&gt;</code> in our HTML page, to which we'll give a random ID. In order to facilitate this, let's import <code>java.util.UUID</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns handler
  &#40;:require &#91;cheshire.core :as json&#93;
            &#91;selmer.parser :as selmer&#93;
            &#91;page-views&#93;
            &#91;util :refer &#91;-&gt;map&#93;&#93;&#41;
  &#40;:import &#40;java.time LocalDate&#41;
           &#40;java.util UUID&#41;&#41;&#41;
</code></pre><p>And whilst we're at it, let's add a little more config to control how many days of data and how many top URLs to show:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def config {:aws-region &#40;get-env &quot;AWS&#95;REGION&quot; &quot;eu-west-1&quot;&#41;
             :views-table &#40;get-env &quot;VIEWS&#95;TABLE&quot;&#41;
             :num-days &#40;util/-&gt;int &#40;get-env &quot;NUM&#95;DAYS&quot; &quot;7&quot;&#41;&#41;
             :num-top-urls &#40;util/-&gt;int &#40;get-env &quot;NUM&#95;TOP&#95;URLS&quot; &quot;10&quot;&#41;&#41;}&#41;
</code></pre><p>Now we're ready to write the <code>serve-dashboard</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn serve-dashboard &#91;{:keys &#91;queryStringParameters&#93; :as event}&#93;
  &#40;let &#91;date &#40;:date queryStringParameters&#41;
        dates &#40;if date
                &#91;date&#93;
                &#40;-&gt;&gt; &#40;range &#40;:num-days config&#41;&#41;
                     &#40;map #&#40;str &#40;.minusDays &#40;LocalDate/now&#41; %&#41;&#41;&#41;&#41;&#41;
        date-label &#40;or date &#40;format &quot;last %d days&quot; &#40;:num-days config&#41;&#41;&#41;
        all-views &#40;mapcat #&#40;page-views/get-views client %&#41; dates&#41;
        total-views &#40;reduce + &#40;map :views all-views&#41;&#41;
        top-urls &#40;-&gt;&gt; all-views
                      &#40;group-by :url&#41;
                      &#40;map &#40;fn &#91;&#91;url views&#93;&#93;
                             &#91;url &#40;reduce + &#40;map :views views&#41;&#41;&#93;&#41;&#41;
                      &#40;sort-by second&#41;
                      reverse
                      &#40;take &#40;:num-top-urls config&#41;&#41;
                      &#40;map-indexed &#40;fn &#91;i &#91;url views&#93;&#93;
                                     &#40;assoc &#40;-&gt;map url views&#41; :rank &#40;inc i&#41;&#41;&#41;&#41;&#41;
        chart-id &#40;str &quot;div-&quot; &#40;UUID/randomUUID&#41;&#41;
        chart-data &#40;-&gt;&gt; all-views
                        &#40;group-by :date&#41;
                        &#40;map &#40;fn &#91;&#91;date rows&#93;&#93;
                               {:date date
                                :views &#40;reduce + &#40;map :views rows&#41;&#41;}&#41;&#41;
                        &#40;sort-by :date&#41;&#41;
        chart-spec &#40;json/generate-string
                    {:$schema &quot;https://vega.github.io/schema/vega-lite/v5.json&quot;
                     :data {:values chart-data}
                     :mark {:type &quot;bar&quot;}
                     :width &quot;container&quot;
                     :height 300
                     :encoding {:x {:field &quot;date&quot;
                                    :type &quot;nominal&quot;
                                    :axis {:labelAngle -45}}
                                :y {:field &quot;views&quot;
                                    :type &quot;quantitative&quot;}}}&#41;
        tmpl-vars &#40;-&gt;map date-label
                         total-views
                         top-urls
                         chart-id
                         chart-spec&#41;&#93;
    &#40;util/log &quot;Rendering dashboard&quot; tmpl-vars&#41;
    {:statusCode 200
     :headers {&quot;Content-Type&quot; &quot;text/html&quot;}
     :body &#40;selmer/render &#40;slurp &quot;index.html&quot;&#41;
                          tmpl-vars&#41;}&#41;&#41;
</code></pre><p>A quick build and deploy and now we'll be able to see some exciting data!</p><pre class="language-test"><code class="lang-test language-test">$ bb blambda build-lambda

Building lambda artifact: /tmp/site-analyser/target/site-analyser.zip
Adding file: handler.clj
Adding file: page&#95;views.clj
Adding file: util.clj
Adding file: index.html
Compressing lambda: /tmp/site-analyser/target/site-analyser.zip
updating: handler.clj &#40;deflated 66%&#41;
updating: page&#95;views.clj &#40;deflated 65%&#41;
updating: util.clj &#40;deflated 42%&#41;
updating: index.html &#40;deflated 59%&#41;

&#91;nix-shell:/tmp/site-analyser&#93;$ bb blambda terraform apply
Terraform will perform the following actions:

  # aws&#95;lambda&#95;function.lambda will be updated in-place
  &#126; resource &quot;aws&#95;lambda&#95;function&quot; &quot;lambda&quot; {

Plan: 0 to add, 1 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws&#95;lambda&#95;function.lambda: Modifying... &#91;id=site-analyser&#93;
aws&#95;lambda&#95;function.lambda: Still modifying... &#91;id=site-analyser, 10s elapsed&#93;
aws&#95;lambda&#95;function.lambda: Modifications complete after 11s &#91;id=site-analyser&#93;

Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

Outputs:

function&#95;url = &quot;https://kuceaiz55k7soeki4u5oy4w6uy0ntbky.lambda-url.eu-west-1.on.aws/&quot;
</code></pre><p>Visiting https://kuceaiz55k7soeki4u5oy4w6uy0ntbky.lambda-url.eu-west-1.on.aws/dashboard again tells a tale of joy!</p><p><img src="assets/2023-01-04-dashboard-full.png" alt="Site analytics dashboard lots of data" title="Dashing dashboard" width=800px /></p><p>With this, let's declare our site analysed and all agree that Babashka is way better than nbb. Hurrah! 🎉</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-12-11-overengineering-improv.html</id>
    <link href="https://jmglov.net/blog/2022-12-11-overengineering-improv.html"/>
    <title>Over-Engineering Improv</title>
    <updated>2022-12-11T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I was walking Rover this morning through the snow, listening to a podcast on the dangers of economism, when I realised that my mind had drifted away from political theory and was stuck on a work-related problem. I've learned over the years that it's completely futile to fight that and try to turn my attention back to my podcast or book or whatever I was trying to do, so I leaned into it and tried to see if I could just solve the problem so it would leave me in peace and I could go back to enjoying my walk.</p><p>I've been a professional software engineer for about 20 years now, and these intrusions have historically been oriented around why my multi-threaded C++ IVR system is deadlocking or how to design DynamoDB tables for my serverless API or whatever, but in the past couple of years, these problems have been more likely oriented around organisational psychology, single-piece flow, or how to build a good promotion case for that great engineer on my team. Yes, it's true: I'm an engineering manager. And yes, I know, I swore that I would never be interested in becoming a manager, but I also swore that I would never do a lot of things that I have subsequently done and enjoyed, so it's no surprise that I was wrong about this as well.</p><p>But anyway, back to the problem at hand. I'm managing a newly formed team, and we're having a team kickoff session first thing in January. This is three days of doing things like building a social contract, defining our team purpose, meeting stakeholders, and deciding on ways of working, and I needed to find a good way to start the session. One of the classic openers is breakfast and an icebreaker, and I decided that if it ain't broke, don't fix it, so I should go ahead and do that. So far, no problem.</p><p>The challenge then became what kind of icebreaker to use. I needed something that would fit the following criteria:</p><ol><li>Works remotely. A lot of icebreakers have a physical component to them that   make them less or no fun over a video call.</li><li>Doesn't force people to disclose personal information that would make them   uncomfortable. Seemingly innocuous games like everyone bringing a childhood   photo and then the group trying to guess who's who can be traumatic for   people with gender dysphoria, and games featuring questions like "what's the   thing you most appreciate about your parents?" can be traumatic for survivors   of child abuse. On the less extreme end of the spectrum, some people just   don't feel comfortable sharing details about their personal life.</li><li>Fits in about half an hour.</li><li>Isn't one we've done recently.</li></ol><p>One of my favourites is <a href='https://icebreakerideas.com/two-truths-and-a-lie/'>Two Truths and a
Lie</a>, in which you come up with three short statements about yourself, two of which are true and one of which is a lie, and the group tries to figure out which one is the lie. This works well remotely; doesn't force people to get more personal than they want to, since the statements can be about anything (for example: I've brought down a global e-commerce website and cost my employer about $10 million in lost sales); and fits comfortably in half an hour for groups up to 7-8 people.</p><p>However, we used this icebreaker last time we had a team offsite three months ago, and some of the people from my previous team will also be on the new team. Worse yet, my so-called friend D.S. (you know who you are!) used this one in an org-wide offsite just last week. The nerve!</p><p>So TTaaL is right out. On Friday, I was browsing through some icebreaker ideas, and all of them failed to satisfy one or more criteria. This apparently had been eating at my subconscious, and decided to surface right when I was trying to concentrate on something else.</p><p>I started thinking about fun work-related things that I've done over the years, and one particular exercise popped into my mind: <a href='https://engineering.klarna.com/architecture-golf-60fb51a6e787'>Architecture
Golf</a>. Architecture Golf is a group learning exercise created by my former colleagues Túlio Ornelas and Kenneth Gibson, and works like this:</p><ol><li>The team gathers around a whiteboard (physical or digital).</li><li>Someone states a process, practice, or system you want to learn about as a   team.</li><li>One team member is randomly selected to go first.</li><li>That person draws the first stage of the process on the whiteboard and then   passes the pen to the next person, who draws the next stage. If the person   with the pen doesn't know what the next stage is, they simply take a guess.   Not only is this OK, it's expected! Whenever something is drawn that doesn't   match how the thing actually works, a discussion happens around why the thing   works like it does, and should it in fact work differently?</li><li>Rinse and repeat until the diagram is complete.</li></ol><p>According to Túlio:</p><blockquote><p> We call this exercise Architecture golf because it helped us explain the crazy  architecture that we sometimes have. Each team member takes one swing at a  time which pushes them closer to the solution, just like a team version of  golf. </p></blockquote><p>I encourage you to read the <a href='https://engineering.klarna.com/architecture-golf-60fb51a6e787'>full
article</a> and try this game out with your team sometime!</p><p>As cool as this is, it didn't quite fit my needs because 30 minutes is a bit too short for completing a session, and my team will own two systems, so I didn't want to single one and potentially send the message that it's somehow more important than the other system. However, the kernel of the idea is exactly what I was looking for: collaborative drawing, one person at a time.</p><p>So if we can't draw a real system, how about a fake one? My brain suddenly did that cool thing where it made a connection to some other thing, and I had the answer! I remembered being very amused by <a href='https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpriseEdition'>FizzBuzzEnterpriseEdition</a> a few years back, and thought it would be fun to collaboratively design an incredibly complicated distributed system to solve an incredibly trivial problem, such as FizzBuzz itself, reversing a string, sorting a list, etc.</p><p>My first thought was to call the game "Enterprise Improv", but one thing bothered me a little bit about it. A few years back, I read a really great blog post by Aurynn Shaw title <a href='https://blog.aurynn.com/2015/12/16-contempt-culture'>Contempt
Culture</a>. It starts like this:</p><blockquote><p> So when I started programming in 2001, it was du jour in the communities I  participated in to be highly critical of other languages. Other languages  sucked, the people using them were losers or stupid, if they would just use a  real language, such as the one we used, everything would just be better. </p><p> Right? </p></blockquote><p>The point Aurynn makes in the post (which you really should read; it's fantastic!) is that making fun of other languages or tools can result in people who use those languages or tools feeling ridiculed or less than.</p><p>Even though FizzBuzzEnterpriseEdition is clearly tongue in cheek and intended to be all in good fun, if you're a Java developer working at a large enterprise, I could certainly understand if you feel like you're being made fun of.</p><p>So I tried to think of something fairly universal to software development, something that all of us do from time to time. And lo! it came to me in a flash.</p><p>Over-engineering.</p><p>Which one of us has never added a layer or two of indirection because "we might need to switch to a different database" or implemented a plugin system because "then our users can write their own modules" or tried to use everything from the <a href='https://www.goodreads.com/book/show/85009.Design_Patterns'>Design Patterns</a> book in the same class because "those patterns are important for writing quality software"?</p><p>So I decided the name of the game would be "Over-Engineering Improv". And here's how you play:</p><h2 id="the_rules">The rules</h2><ol><li>Gather around a whiteboard (physical or digital).</li><li>Choose a trivial problem   (<a href='https://leetcode.com/problems/fizz-buzz/'>FizzBuzz</a>, <a href='https://leetcode.com/problems/reverse-string/description/'>reverse
   string</a>, <a href='https://leetcode.com/problems/sort-list/'>sort
   numbers</a>, etc) with a known   solution.</li><li>Write the solution to the problem down and post it next to the board (or   anywhere it can easily be referred to).</li><li>Randomly choose a first player.</li><li>The first player puts the first part of the complicated system that will   solve the problem on the board, explains what the part does (the first part   will almost certainly produce the data that will be operated on), then passes   the marker to the next player.</li><li>The next player says "<a href='https://en.wikipedia.org/wiki/Yes,_and...'>yes
   and...</a>" to the diagram by adding   the next part of the system, connecting it to what's already on the board,   and explaining what is happening. The object here is to make the system more   complicated than what's currently on the board. Having done this, they pass   the marker to the next player.</li><li>Repeat step 6 until there is about 5 minutes left in the game. Now the object   becomes completing the solution within the remaining time. Continuing drawing   and passing the marker.</li><li>When about a minute is left, declare the final turn. The player taking the   final turn should complete the system to solve the problem.</li></ol><p>If the problem is solved in the final turn, it's a win. If the problem isn't solved, it's still a win because hopefully everyone had loads of fun, and will be primed to beat the game next time!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-10-02-page-2.html</id>
    <link href="https://jmglov.net/blog/2022-10-02-page-2.html"/>
    <title>Page 2</title>
    <updated>2022-10-02T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><a href='2022-09-22-aws-paging.html'>Last time out</a>, I was desperately trying to understand why my beautifully crafted page-aware lazy loading S3 list objects function was fetching more pages than it actually needed to fulfil my requirements (doesn't sound very lazy to me!), but to no avail. If you cast your mind back, I had set my page size to 50, and was taking 105 objects:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;list-objects logs-client prefix&#41;
       &#40;take 105&#41;&#41;
  ;; =&gt; &#40;&quot;logs/E1HJS54CQLFQU4.2022-09-15-00.0125de6e.gz&quot;
  ;;     &quot;logs/E1HJS54CQLFQU4.2022-09-15-00.3b36a099.gz&quot;
  ;;     ...
  ;;     &quot;logs/E1HJS54CQLFQU4.2022-09-15-10.ae86e512.gz&quot;
  ;;     &quot;logs/E1HJS54CQLFQU4.2022-09-15-10.b4a720f9.gz&quot;&#41;

&#41;
</code></pre><p>But sadly seeing the following in my REPL buffer:</p><pre class="language-text"><code class="lang-text language-text">Fetching page 1
Fetching page 2
Fetching page 3
Fetching page 4
</code></pre><p>I know that some lazy sequences in Clojure realise in chunks, but those chunks are usually realised 32 at a time in my experience. It is actually absurdly hard to find any documentation that explains exactly how chunking works, but one can gather hints here and there from the dusty corners of the web's ancient past:</p><ul><li><a href='http://www.tianxiangxiong.com/2016/11/05/chunking-and-laziness-in-clojure.html'>Laziness and chunking in
  Clojure</a> -  2016</li><li><a href='https://www.markhneedham.com/blog/2014/04/06/clojure-not-so-lazy-sequences-a-k-a-chunking-behaviour/'>Clojure: Not so lazy sequences a.k.a chunking
  behaviour</a> -  2014</li><li><a href='http://blog.fogus.me/2010/01/22/de-chunkifying-sequences-in-clojure/'>De-chunkifying Sequences in
  Clojure</a> -  2010</li></ul><p>They all mention the number 32 (holiest of all powers of 2, clearly), and the first one even suggests looking at the implementation of <a href='https://github.com/clojure/clojure/blob/clojure-1.10.1/src/clj/clojure/core.clj#L2727'>clojure.core/map</a> and seeing that <code>map</code> calls the magic <code>chunk-first</code>, which "takes 32 elements for performance reasons". Spelunking deeper into the source for the <a href='https://github.com/clojure/clojure/blob/clojure-1.10.1/src/clj/clojure/core.clj#L701'>definition
of
<code>chunk-first</code></a> leads one to the following lines:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn &#94;:static  &#94;clojure.lang.IChunk chunk-first &#94;clojure.lang.IChunk &#91;&#94;clojure.lang.IChunkedSeq s&#93;
  &#40;.chunkedFirst s&#41;&#41;
</code></pre><p>Which leads to Clojure's <a href='https://github.com/clojure/clojure/tree/clojure-1.10.1/src/jvm/clojure/lang'>Java
implementation</a>, which leads to me reading a couple of the classes that implement the <code>IChunk</code> interface, looking for some mention of the chunk size, and running away in tears.</p><p>The funny thing about all of this is that I know that one is <a href='https://stuartsierra.com/2015/08/25/clojure-donts-lazy-effects'>not supposed to
use functions with side effects when processing lazy
sequences</a>. In fact, it says exactly that in the docstring for <a href='https://clojuredocs.org/clojure.core/iterate'>clojure.core/iterate</a>:</p><pre class="language-text"><code class="lang-text language-text">&#40;iterate f x&#41;

Returns a lazy sequence of x, &#40;f x&#41;, &#40;f &#40;f x&#41;&#41; etc. f must be free of
side-effects.
</code></pre><p>But I figured that it would "probably be fine for this use case." 😂</p><p>Having received my well-deserved comeuppance—albeit not completely understanding the form said comeuppance is taking—it's time to figure out how to lazily page without chunking. As luck would have it, right after I published my previous post, I opened up <a href='http://planet.clojure.in/'>Planet Clojure</a> in my RSS reader and saw a post by Abhinav Omprakash on "<a href='https://www.abhinavomprakash.com/posts/clojure-iteration/'>Clojure's Iteration function
</a>". According to the post, Clojure has a function called <code>iteration</code>, and:<blockquote><p> One of the most common use cases for iteration is making paginated api calls.  </p></blockquote>OK, this looks interesting. Why in the world didn't I know about this? Well, Abhinav's post links to a post on the JUXT blog called "<a href='https://www.juxt.pro/blog/new-clojure-iteration'>The new Clojure
iteration function</a>" (written by the irrepressible Renzo Borgatti!) wherein it is revealed that <code>iteration</code> is new in Clojure 1.11. In the post's introduction, Renzo mentions:</p><blockquote><p> the problem of dealing with batched API calls, those requiring the consumer a  "token" from the previous invocation to be able to proceed to the next. This  behaviour is very popular in API interfaces such as AWS S3, where the API  needs to protect against the case of a client requesting the content of a  bucket with millions of objects in it. </p></blockquote><p>He goes on to make a bold claim:</p><blockquote><p> In the past, Clojure developers dealing with paginated APIs have been solving  the same problem over and over. The problem is to create some layer that hides  away the need of knowing about the presence of pagination and provides the  seqable or reducible abstraction we are all familiar with. It is then up to  the user of such abstractions to decide if they want to eagerly load many  objects or consume them lazily, without any need to know how many requests are  necessary or how the pagination mechanism works. </p></blockquote><p>OK, I buy this, having solved this problem in many sub-optimal ways over the years. So <code>iteration</code> really sounds like what I want here. Let's see if I can modify my code based on the <code>iterate</code> function to use <code>iteration</code> instead. Here's what I ended up with last time:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-s3-page &#91;{:keys &#91;s3-client s3-bucket s3-page-size&#93;}
                   prefix prev&#93;
  &#40;let &#91;{token :NextContinuationToken
         truncated? :IsTruncated
         page-num :page-num} prev
        page-num &#40;if page-num &#40;inc page-num&#41; 1&#41;
        done? &#40;false? truncated?&#41;
        res &#40;when-not done?
              &#40;println &quot;Fetching page&quot; page-num&#41;
              &#40;-&gt; &#40;aws/invoke s3-client
                              {:op :ListObjectsV2
                               :request &#40;mk-s3-req s3-bucket prefix s3-page-size token&#41;}&#41;
                  &#40;assoc :page-num page-num&#41;&#41;&#41;&#93;
    res&#41;&#41;

&#40;defn s3-page-iterator &#91;logs-client prefix&#93;
  &#40;partial get-s3-page logs-client prefix&#41;&#41;

&#40;defn list-objects &#91;logs-client prefix&#93;
  &#40;-&gt;&gt; &#40;iterate &#40;s3-page-iterator logs-client prefix&#41; nil&#41;
       &#40;drop 1&#41;
       &#40;take-while :Contents&#41;
       &#40;mapcat &#40;comp &#40;partial map :Key&#41; :Contents&#41;&#41;&#41;&#41;
</code></pre><p>The JUXT post helpfully walks through <a href='https://www.juxt.pro/blog/new-clojure-iteration#_example_iterating_paginated_objects_using_the_new_function'>an example of listing objects in an S3
bucket</a>, which is exactly what I'm doing, but unhelpfully bases the example on <a href='https://github.com/mcohen01/amazonica'>Amazonica</a> (an excellent Clojure wrapper around the AWS Java SDK that I used for years until some cool kids from Nubank told me that all the cool kids were now using Cognitect's <a href='https://github.com/cognitect-labs/aws-api'>aws-api</a>, and I wanted to be cool like them, so I decided to use it for my next thing, which turned out to be a great decision since my next thing was <a href='https://github.com/jmglov/blambda'>Blambda</a>, which runs on <a href='https://github.com/babashka/babashka'>Babashka</a>, which can't use the AWS Java SDK anyway).</p><p>Where was I? Oh yeah, the JUXT blog. So it breaks down the arguments to <code>iteration</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;iteration step &amp; {:keys &#91;somef vf kf initk&#93;
                   :or {vf identity
                        kf identity
                        somef some?
                        initk nil}}&#41;
</code></pre><ul><li><code>step</code> is a function of the next marker token. This function should contain  the logic for making a request to the S3 API (or other relevant paginated API)  passing the given token.</li><li><code>somef</code> is a function that applied to the return of <code>&#40;step token&#41;</code> returns  <code>true</code> or <code>false</code> based on the fact that the response contains results or not,  respectively.</li><li><code>vf</code> is a function that applied to the return of <code>&#40;step token&#41;</code> returns the  items from the current response page.</li><li><code>kf</code> is a function that applied to the return of <code>&#40;step token&#41;</code> returns the  next marker token if one is available.</li><li><code>initk</code> is an initial value for the marker.</li></ul><p>Looking at this, my <code>get-s3-page</code> function sounds a lot like <code>step</code>, in that it contains the logic for making a request to S3. However, <code>step</code> is a function taking one argument, and <code>get-s3-page</code> takes three, so clearly it can't be used it as is. But the same was actually true for my previous attempt at paging that used <code>iterate</code>, and in fact I wrote a function to take care of this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn s3-page-iterator &#91;logs-client prefix&#93;
  &#40;partial get-s3-page logs-client prefix&#41;&#41;
</code></pre><p><code>s3-page-iterator</code> closes over the client and the prefix and returns a function that takes only one argument: <code>prev</code>, which is the previous page of results from S3. So that's <code>step</code> sorted!</p><p>In order to figure out what functions I need for <code>somef</code>, <code>vf</code>, and <code>kf</code> (gotta love the terse names of variables in <code>clojure.core</code>!), I need to look at what <code>get-s3-page</code> returns, since all three of those functions operate on the return value of <code>&#40;step token&#41;</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;get-s3-page logs-client &quot;logs/A1BCD23EFGHIJ4.2022-09-26-&quot; nil&#41;
       keys&#41;
  ;; =&gt; &#40;:Prefix
  ;;     :NextContinuationToken
  ;;     :Contents
  ;;     :MaxKeys
  ;;     :IsTruncated
  ;;     :Name
  ;;     :KeyCount
  ;;     :page-num&#41;

&#41;
</code></pre><p>I'll tackle <code>vf</code> and <code>kf</code> first, since they are pretty straightforward. <code>vf</code> needs to return the items from the current response page. Those items live in the map returned by <code>get-s3-page</code> under the <code>:Contents</code> key, and since keywords are functions that when called with a map, look themselves up in the map, I can use the <code>:Contents</code> keyword as my <code>vf</code>! 🎉</p><p><code>kf</code> returns the next token, which I have in the response as <code>:NextContinuationToken</code>, so it sounds like I should use that for <code>kf</code>. The only problem is that the second invocation of my <code>step</code> function will look like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;step &#40;:NextContinuationToken response&#41;&#41;
</code></pre><p>and <code>get-s3-page</code> expects <code>prev</code> to be the response itself, from which it knows how to extract the token all by itself. So I want to just pass the response to my function as-is, and luckily, Clojure has a function for that: <code>identity</code>, which returns its argument unchanged.</p><p>Now it's time to look at <code>somef</code>, a function that returns <code>true</code> if the response contains results and <code>false</code> otherwise. In my case, <code>get-s3-page</code> makes a request to the S3 API and returns the response <strong>unless</strong> the previous response wasn't truncated, in which case it returns <code>nil</code>. So what I want for <code>somef</code> is a function that returns true for any non-<code>nil</code> value, which is exactly what <a href='https://clojuredocs.org/clojure.core/some_q'>clojure.core/some?</a> does (not to be confused with <a href='https://clojuredocs.org/clojure.core/some'>clojure.core/some</a>).</p><p>Now that <code>somef</code>, <code>vf</code>, and <code>kf</code> are sorted, I'll turn my roving eye to <code>initk</code>, which is the initial value for the token passed to my <code>step</code> function. Just like in my previous attempt, I can use <code>nil</code> as the initial argument.</p><p>So putting this all together, my new <code>list-objects</code> function would look like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn list-objects &#91;logs-client prefix&#93;
  &#40;-&gt;&gt; &#40;iteration &#40;s3-page-iterator logs-client prefix&#41;
                  :vf :Contents
                  :kf identity
                  :somef some?
                  :initk nil&#41;
       &#40;mapcat &#40;partial map :Key&#41;&#41;&#41;&#41;
</code></pre><p>Looks good, lemme test it out!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;list-objects logs-client prefix&#41;
       &#40;take 5&#41;&#41;
  ;; =&gt; &#40;&quot;logs/A1BCD23EFGHIJ4.2022-09-25-00.0187bda9.gz&quot;
  ;;     &quot;logs/A1BCD23EFGHIJ4.2022-09-25-00.0e46ca54.gz&quot;
  ;;     &quot;logs/A1BCD23EFGHIJ4.2022-09-25-00.348fa655.gz&quot;
  ;;     &quot;logs/A1BCD23EFGHIJ4.2022-09-25-00.4345d6ea.gz&quot;
  ;;     &quot;logs/A1BCD23EFGHIJ4.2022-09-25-00.63005d64.gz&quot;&#41;

&#41;
</code></pre><p>Nice! Except for one thing. My REPL buffer reveals that I actually haven't fixed the problem I set out to fix:</p><pre class="language-text"><code class="lang-text language-text">Fetching page 1
Fetching page 2
Fetching page 3
Fetching page 4
</code></pre><p>Looks like I should have read a little further in the JUXT blog article, because Renzo explains exactly what's happening here:</p><blockquote><p> The results of calling [<code>get-s3-page</code>] are batched items as a collection of  collections. In general, we need to collapse the batches into a single  sequence and process them one by one [...] </p><p> Surprisingly, accessing the [first 5 items from] the first page produces  additional network calls for pages well ahead of what we currently need. This  is an effect of using [<code>mapcat</code>, which always evaluates the first 4
> arguments]! </p><p> The reader should understand that this is not a problem of iteration itself,  but more about the need to concatenate the results back for processing  maintaining laziness in place. </p></blockquote><p>Renzo being Renzo, of course he has a solution to this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn lazy-concat &#91;colls&#93;
  &#40;lazy-seq
   &#40;when-first &#91;c colls&#93;
     &#40;lazy-cat c &#40;lazy-concat &#40;rest colls&#41;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>I can fold this into my <code>list-objects</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn list-objects &#91;logs-client prefix&#93;
  &#40;-&gt;&gt; &#40;iteration &#40;s3-page-iterator logs-client prefix&#41;
                  :vf :Contents
                  :kf identity
                  :somef some?
                  :initk nil&#41;
       lazy-concat
       &#40;map :Key&#41;&#41;&#41;
</code></pre><p>Since <code>lazy-concat</code> is sewing the lists returned by <code>iteration</code> together, I don't need the chunktacular <code>mapcat</code> anymore; I can just use regular old <code>map</code>. Let's see if this works:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;list-objects logs-client prefix&#41;
       &#40;take 5&#41;&#41;
  ;; =&gt; &#40;&quot;logs/A1BCD23EFGHIJ4.2022-09-25-00.0187bda9.gz&quot;
  ;;     &quot;logs/A1BCD23EFGHIJ4.2022-09-25-00.0e46ca54.gz&quot;
  ;;     &quot;logs/A1BCD23EFGHIJ4.2022-09-25-00.348fa655.gz&quot;
  ;;     &quot;logs/A1BCD23EFGHIJ4.2022-09-25-00.4345d6ea.gz&quot;
  ;;     &quot;logs/A1BCD23EFGHIJ4.2022-09-25-00.63005d64.gz&quot;&#41;

&#41;
</code></pre><p>And the REPL buffer?</p><pre class="language-text"><code class="lang-text language-text">Fetching page 1
</code></pre><p>Amazing!</p><p>There's one last thing that's bugging me, though. If I look back at the docs for <code>iteration</code>, I see that it has some default arguments:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;iteration step &amp; {:keys &#91;somef vf kf initk&#93;
                   :or {vf identity
                        kf identity
                        somef some?
                        initk nil}}&#41;
</code></pre><p>So <code>vf</code> and <code>kf</code> default to <code>identity</code>, <code>somef</code> defaults to <code>some?</code>, and <code>initk</code> defaults to <code>nil</code>. Taking a look at how I call <code>iteration</code>, things look quite familiar:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;iteration &#40;s3-page-iterator logs-client prefix&#41;
           :vf :Contents
           :kf identity
           :somef some?
           :initk nil&#41;
</code></pre><p>My <code>kf</code>, <code>somef</code>, and <code>initk</code> all match the defaults! Looks like the Clojure core team kinda knows what they're doing. 😉</p><p>With this knowledge under my belt, I can simplify <code>list-objects</code> even further:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn list-objects &#91;logs-client prefix&#93;
  &#40;-&gt;&gt; &#40;iteration &#40;s3-page-iterator logs-client prefix&#41;
                  :vf :Contents&#41;
       lazy-concat
       &#40;map :Key&#41;&#41;&#41;
</code></pre><p>The cool thing about all of this is that I could use the exact same <code>get-s3-page</code> function I had before, as well as the same <code>s3-page-iterator</code> function, and only needed to change <code>list-objects</code> and sprinkle in the magic <code>lazy-concat</code> function from Renzo's box o' fun!</p><p>Before you try this at home, be sure to read the JUXT blog post carefully enough not to miss this sentence, which probably should have been bold and inside the dearly departed HTML <code>&lt;blink&gt;</code> tag:</p><blockquote><p> You need to remember to avoid using sequence with transducers for processing  items even after the initial concatenation, because as soon as you do,  chunking will hunt you down. </p></blockquote>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-09-22-aws-paging.html</id>
    <link href="https://jmglov.net/blog/2022-09-22-aws-paging.html"/>
    <title>Paging Doctor AWS</title>
    <updated>2022-09-22T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I've been using AWS for quite a while now, and one thing that is as inevitable as death and taxes is dealing with paging. All of the APIs that return multiple items support pagination in some way, and anytime you write code that needs to act on all items, you need to handle fetching pages. My quest to <a href='2022-09-02-dogfooding-blambda-logs.html'>parse my own
access logs</a>, is no exception, since my logs are stored in S3 and my API needs to list all of the logs for a specific date, of which there could be thousands. The way I've typically handled paging in Clojure with with a <code>loop</code> / <code>recur</code> similar to this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-items &#91;{:keys &#91;s3-client s3-bucket s3-page-size&#93; :as logs-client}
                 prefix&#93;
  &#40;loop &#91;items &#91;&#93;
         token nil&#93;
    &#40;let &#91;&#95; &#40;println &quot;Fetching page&quot;&#41;
          res &#40;aws/invoke s3-client
                          {:op :ListObjectsV2
                           :request &#40;mk-s3-req s3-bucket prefix s3-page-size token&#41;}&#41;
          items &#40;concat items &#40;:Contents res&#41;&#41;&#93;
      &#40;if &#40;:IsTruncated res&#41;
        &#40;recur items &#40;:NextContinuationToken res&#41;&#41;
        items&#41;&#41;&#41;&#41;
</code></pre><p><code>logs-client</code> is a data structure containing the stuff I need to talk to S3:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:aws-region &quot;eu-west-1&quot;,
 :s3-client ; Cognitect aws-api client here
 :s3-bucket &quot;logs.jmglov.net&quot;,
 :s3-prefix &quot;logs/&quot;,
 :s3-page-size 25}
</code></pre><p>And <code>prefix</code> is the start of S3 keys for CloudFront access logs for a specific date, for example: <code>logs/A1BCD23EFGHIJ4.2022-09-15-</code></p><p>Finally, <code>mk-s3-req</code> is a function that constructs a <code>ListObjectsV2</code> request, adding the continuation token if it's non-nil:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn mk-s3-req &#91;s3-bucket prefix s3-page-size continuation-token&#93;
  &#40;merge {:Bucket s3-bucket
          :Prefix prefix}
         &#40;when s3-page-size
           {:MaxKeys s3-page-size}&#41;
         &#40;when continuation-token
           {:ContinuationToken continuation-token}&#41;&#41;&#41;
</code></pre><p>The <code>loop</code> / <code>recur</code> approach has the benefit of being fairly simple, but the drawback of <a href='https://stuartsierra.com/2015/04/26/clojure-donts-concat'>being a lazily-ticking time
bomb</a>, as Stuart Sierra explains. It also has the drawback of fetching all of the items, regardless of how many I need. For example, if I need the first two items for some reason:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;take 2 &#40;get-items&#41;&#41;
</code></pre><p>I'll see something like this in my REPL buffer:</p><pre class="language-text"><code class="lang-text language-text">Fetching page
Fetching page
Fetching page
Fetching page
Fetching page
Fetching page
Fetching page
Fetching page
Fetching page
</code></pre><p>Now imagine instead of nine pages of items, I have hundreds. Yikes!</p><p>In practice, this hasn't been a problem for me, as I've seldom had enough pages for this to really matter, but it does feel yucky, to use a technical term. Surely we can do better, right?</p><p>Right. In fact, Clojure has an entire abstraction built around the concept of taking just what you need: the <a href='https://clojure.org/reference/sequences'>sequence
abstraction</a>. A sequence (often called a "seq" in the Clojure literature) is a logical list that is usually lazily realised, and many of the sequence library functions in <code>clojure.core</code> are lazy, such as <code>map</code>, <code>filter</code>, <code>take</code>, <code>drop</code>, etc. If we can find a way to generate a lazy sequence, the code above that takes the first two items in the sequence should only fetch the first page.</p><p>And where there's a will, Clojure has a way: the <a href='https://clojuredocs.org/clojure.core/iterate'>iterate</a> function. According to the docs, it returns a lazy sequence generated by a function that is repeatedly called with the previous value returned by the function. This is easier to see than it is to explain:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;iterate inc 5&#41;
       &#40;take 5&#41;&#41;
  ;; =&gt; &#40;5 6 7 8 9&#41;

&#41;
</code></pre><p>If we unroll this in our heads, this is what's going on:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;cons
   5
   &#40;cons
    &#40;inc 5&#41;
    &#40;cons
     &#40;inc &#40;inc 5&#41;&#41;
     &#40;cons
      &#40;inc &#40;inc &#40;inc 5&#41;&#41;&#41;
      &#40;cons
       &#40;inc &#40;inc &#40;inc &#40;inc 5&#41;&#41;&#41;&#41;
       nil&#41;&#41;&#41;&#41;&#41;
  ;; =&gt; &#40;5 6 7 8 9&#41;

&#41;
</code></pre><p>The cool thing about <code>iterate</code> is that our generator function <code>f</code> can do whatever it wants, as long as it takes one argument. So what if we write an <code>f</code> that fetches a page of results from S3? We have something similar to that in the body of the <code>loop</code> we wrote above, so let's adapt that a bit:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-s3-page &#91;{:keys &#91;s3-client s3-bucket s3-page-size&#93;}
                   prefix
                   token&#93;
  &#40;let &#91;&#95; &#40;println &quot;Fetching page&quot;&#41;
        res &#40;aws/invoke s3-client
                        {:op :ListObjectsV2
                         :request &#40;mk-s3-req s3-bucket prefix s3-page-size token&#41;}&#41;&#93;
    :???&#41;&#41;
</code></pre><p>Then we can do stuff like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn s3-page-iterator &#91;logs-client prefix&#93;
  &#40;partial get-s3-page logs-client prefix&#41;&#41;

&#40;-&gt;&gt; &#40;iterate &#40;s3-page-iterator logs-client prefix&#41; nil&#41;
     &#40;take 2&#41;&#41;
</code></pre><p>The <code>s3-page-iterator</code> function transforms our <code>get-s3-page</code> function, which takes three parameters, into a function that takes one parameter so we can use it with <code>iterate</code>. It does this through the magic of <code>partial</code>, which takes a function and some arguments, and returns a new function with those arguments applied. For example, we can create a partial function from <code>+</code>, which takes two (or more) arguments, that adds 1 to its argument:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def plus1 &#40;partial + 1&#41;&#41;

  &#40;plus1 2&#41;
  ;; =&gt; 3

&#41;
</code></pre><p>There's an open question here, though. What in the world should <code>get-s3-page</code> return? Since it's currently called with a continuation token, and the definition of <code>iterate</code> is that it calls the function with the return value of its previous invocation, we have no choice but to return the continuation token from the result of the <code>ListObjectsV2</code> call:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;:NextContinuationToken res&#41;
</code></pre><p>The only problem with this is that now we're throwing away the actual items, so what we'll get is something like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;iterate &#40;s3-page-iterator logs-client prefix&#41; nil&#41;
       &#40;take 2&#41;&#41;
  ;; =&gt; &#40;nil
  ;;     &quot;1wfyyUjO9xuARj07VdnGBHFPmwEwTyDn7VTywaAH6L417g/fWqfeVJRrCV+nFPFHwLVJ3+CWT6BXTdzyKIPsmZm4U0VpZRffW&quot;&#41;

&#41;
</code></pre><p>That's not all that interesting, to be honest. The one piece of good news is that our REPL buffer looks like this:</p><pre class="language-text"><code class="lang-text language-text">Fetching page
</code></pre><p>We have at least succeeded in being lazy! 🏆</p><p>As things stand, our function takes the token as its argument, meaning have what we need in order to do the pagination, but not what we need in order to build up the sequence of items to return. If we think about where the token comes from, it's actually the same place as the items: the response of the <code>ListObjectsV2</code> call. So what if we return the API response from the function, meaning that the function would then be called with that response on the next iteration? That could look something like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-s3-page &#91;{:keys &#91;s3-client s3-bucket s3-page-size&#93;}
                   prefix prev&#93;
  &#40;let &#91;{token :NextContinuationToken} prev
        &#95; &#40;println &quot;Fetching page&quot;&#41;
        res &#40;aws/invoke s3-client
                        {:op :ListObjectsV2
                         :request &#40;mk-s3-req s3-bucket prefix s3-page-size token&#41;}&#41;&#93;
    res&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; &#40;iterate &#40;s3-page-iterator logs-client prefix&#41; nil&#41;
       &#40;take 2&#41;&#41;
  ;; =&gt; &#40;nil
  ;;     {:Prefix &quot;logs/A1BCD23EFGHIJ4.2022-09-15-&quot;,
  ;;      :NextContinuationToken
  ;;      &quot;1s4mtjEeNyCwY1wu7URgZz/kHpfSH3HhaG26VfcL8sgSNXZF/iYdAjSNQQpTiLF+TRoIB/tD93dC/QmqcmFYo+ZgxX0oZg7+v&quot;,
  ;;      :Contents
  ;;      &#91;{:Key &quot;logs/A1BCD23EFGHIJ4.2022-09-15-00.0125de6e.gz&quot;,
  ;;        :LastModified #inst &quot;2022-09-15T00:39:56.000-00:00&quot;,
  ;;        :ETag &quot;\&quot;0fc8a817d4f9b742b6ae83292a181385\&quot;&quot;,
  ;;        :Size 1496,
  ;;        :StorageClass &quot;STANDARD&quot;}
  ;;       ...
  ;;       {:Key &quot;logs/A1BCD23EFGHIJ4.2022-09-15-02.95762404.gz&quot;,
  ;;        :LastModified #inst &quot;2022-09-15T02:49:56.000-00:00&quot;,
  ;;        :ETag &quot;\&quot;617e1235db32c79c37f5776abc5ff3ec\&quot;&quot;,
  ;;        :Size 800,
  ;;        :StorageClass &quot;STANDARD&quot;}&#93;,
  ;;      :MaxKeys 25,
  ;;      :IsTruncated true,
  ;;      :Name &quot;logs.jmglov.net&quot;,
  ;;      :KeyCount 25}&#41;

&#41;
</code></pre><p>This is definitely moving in the right direction, but there are a couple of issues. First things first, the first thing is <code>nil</code>, since that's the initial argument we gave to <code>iterate</code>. Second, the second thing is not a list of items, but rather a data structure containing a list of items under the <code>:Contents</code> key. We can address both of these issues by wrapping <code>get-s3-page</code> in a new function that handles turning a list of pages into a list of items:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn list-objects &#91;logs-client prefix&#93;
  &#40;-&gt;&gt; &#40;iterate &#40;s3-page-iterator logs-client prefix&#41; nil&#41;
       &#40;drop 1&#41;
       &#40;mapcat &#40;comp &#40;partial map :Key&#41; :Contents&#41;&#41;&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; &#40;list-objects logs-client prefix&#41;
       &#40;take 2&#41;&#41;
  ;; ...
  ;; ...
  ;; OMG what is going on here??? My REPL hasn't printed the result yet,
  ;; and I've been waiting several minutes! 😱

&#41;
</code></pre><p>Something is deeply wrong here, clearly. Looking at our REPL buffer, we find a clue:</p><pre class="language-text"><code class="lang-text language-text">Fetching page
Fetching page
Fetching page
Fetching page
Fetching page
Fetching page
Fetching page
Fetching page
Fetching page
Fetching page
...
</code></pre><p>This looks like an infinite loop, and given that we're looping on the continuation token, that is probably the issue. Let's have a look at the AWS documentation for <a href='https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html'>ListObjectsV2</a> and see what we can learn about the continuation token. In the response:</p><blockquote><p> <strong>NextContinuationToken</strong> </p><p> NextContinuationToken is sent when IsTruncated is true, which means there are  more keys in the bucket that can be listed. The next list requests to Amazon  S3 can be continued with this NextContinuationToken. </p><p> Type: string </p></blockquote><p>Aha! What is this <code>IsTruncated</code> of which they speak?</p><blockquote><p> <strong>IsTruncated</strong> </p><p> Set to false if all of the results were returned. Set to true if more keys are  available to return. </p><p> Type: Boolean </p></blockquote><p>Let's think about what our <code>get-s3-page</code> function is doing when called by <code>iterate</code>. Assuming we have five pages of objects with the prefix <code>logs/A1BCD23EFGHIJ4.2022-09-15-</code>, here's what our requests will look like:</p><pre class="language-text"><code class="lang-text language-text"> -&gt; NextContinuationToken nil
&lt;- IsTruncated true, NextContinuationToken &quot;t1&quot;
 -&gt; NextContinuationToken &quot;t1&quot;
&lt;- IsTruncated true, NextContinuationToken &quot;t2&quot;
 -&gt; NextContinuationToken &quot;t2&quot;
&lt;- IsTruncated true, NextContinuationToken &quot;t3&quot;
 -&gt; NextContinuationToken &quot;t3&quot;
&lt;- IsTruncated true, NextContinuationToken &quot;t4&quot;
 -&gt; NextContinuationToken &quot;t4&quot;
&lt;- IsTruncated false
 -&gt; NextContinuationToken nil
&lt;- IsTruncated true, NextContinuationToken &quot;t1&quot;
...
</code></pre><p>Right, so we'll just cycle through the pages until the heat death of the universe (assuming the universe continues expanding forever and thus atoms get too far apart to bang against each other and generate thermodynamic energy, an assumption which is frankly outside the scope of this blog post which is supposed to be about making S3 requests and not theoretical astrophysics). This is clearly A Bad Thing™ (cycling through pages, I mean, not the heat death of the universe, though that would also be A Bad Thing™), so let's see if we can't reason our way out of this with a little <a href='https://youtu.be/f84n5oFoZBc'>hammock
time</a> (this is actually an excuse for me to go walk Rover, since the poor fellow has been waiting 20 minutes for me to get to a good stopping point).</p><p><img src="assets/2022-09-22-rover.png" alt="A dog walks in a grassy field" title="Thanks, Dad!" width=800px /></p><p>OK, speaking of stopping points, it seems like what we need in this function is a stopping point. We know that when <code>IsTruncated</code> is false, there is no next page, so we should stop right there. Let's see how we can do that in code:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-s3-page &#91;{:keys &#91;s3-client s3-bucket s3-page-size&#93;}
                   prefix prev&#93;
  &#40;let &#91;{token :NextContinuationToken
         truncated? :IsTruncated
         page-num :page-num} prev
        page-num &#40;if page-num &#40;inc page-num&#41; 1&#41;
        done? &#40;false? truncated?&#41;
        res &#40;when-not done?
              &#40;println &quot;Fetching page&quot; page-num&#41;
              &#40;-&gt; &#40;aws/invoke s3-client
                              {:op :ListObjectsV2
                               :request &#40;mk-s3-req s3-bucket prefix s3-page-size token&#41;}&#41;
                  &#40;assoc :page-num page-num&#41;&#41;&#41;&#93;
    res&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; &#40;iterate &#40;partial get-s3-page
                         &#40;assoc logs-client :s3-page-size 100&#41; prefix&#41;
                nil&#41;
       &#40;take 6&#41;&#41;
  ;; =&gt; &#40;nil
  ;;     {:Prefix &quot;logs/E1HJS54CQLFQU4.2022-09-15-&quot;,
  ;;      :page-num 1,
  ;;      :Contents &#91;...&#93;,
  ;;      :NextContinuationToken &quot;1kkaMk4RnoZHxnSRa5TBnVMb9NECfmmq...&quot;,
  ;;      :MaxKeys 100,
  ;;      :IsTruncated true,
  ;;      :Name &quot;logs.jmglov.net&quot;,
  ;;      :KeyCount 100}
  ;;     {:Prefix &quot;logs/E1HJS54CQLFQU4.2022-09-15-&quot;,
  ;;      :page-num 2,
  ;;      :Contents &#91;...&#93;,
  ;;      :NextContinuationToken
  ;;      &quot;1Jz5W+GkccdkP6Jc7tDsdqqrHFlgThRSt2ZhgHh1uYPA1gIzR4aer2l...&quot;,
  ;;      :ContinuationToken
  ;;      &quot;1kkaMk4RnoZHxnSRa5TBnVMb9NECfmmqFfDLlcxdn6GdCiMc8ZzNQRj...&quot;,
  ;;      :MaxKeys 100,
  ;;      :IsTruncated true,
  ;;      :Name &quot;logs.jmglov.net&quot;,
  ;;      :KeyCount 100}
  ;;     {:Prefix &quot;logs/E1HJS54CQLFQU4.2022-09-15-&quot;,
  ;;      :page-num 3,
  ;;      :Contents &#91;...&#93;,
  ;;      :ContinuationToken
  ;;      &quot;1Jz5W+GkccdkP6Jc7tDsdqqrHFlgThRSt2ZhgHh1uYPA1gIzR4aer2l...&quot;,
  ;;      :MaxKeys 100,
  ;;      :IsTruncated false,
  ;;      :Name &quot;logs.jmglov.net&quot;,
  ;;      :KeyCount 36}
  ;;     nil
  ;;     {:Prefix &quot;logs/E1HJS54CQLFQU4.2022-09-15-&quot;,
  ;;      :page-num 1,
  ;;      :Contents &#91;...&#93;,
  ;;      :NextContinuationToken
  ;;      &quot;1EPegB1wcwtgRoGRiJpM4YYjiTvLHYYVjKr+ghX30LhowHoHMAepdcR...&quot;,
  ;;      :MaxKeys 100,
  ;;      :IsTruncated true,
  ;;      :Name &quot;logs.jmglov.net&quot;,
  ;;      :KeyCount 100}&#41;
       
&#41;
</code></pre><p>This is much better! Now we can see that we fetch page 1, which contains 100 items (<code>:KeyCount</code>); page 2, which also contains 100 items; page 3, which is the last page and contains only 36 items; then since <code>:IsTruncated</code> is false the next time <code>iterate</code> calls <code>get-s3-page</code>, we don't fetch the page and just return <code>nil</code>; then we start over again with page 1, and would continue cycling forever if it hadn't been for the <code>&#40;take 6&#41;</code>.</p><p>This experiment has accomplished something very important, however. Now we have a marker for when we've reached the last page and should therefore break the cycle! We can update our <code>list-objects</code> wrapper function to look for this marker:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn list-objects &#91;logs-client prefix&#93;
  &#40;-&gt;&gt; &#40;iterate &#40;s3-page-iterator logs-client prefix&#41; nil&#41;
       &#40;drop 1&#41;
       &#40;take-while :Contents&#41;
       &#40;mapcat &#40;comp &#40;partial map :Key&#41; :Contents&#41;&#41;&#41;&#41;

&#40;comment

  &#40;-&gt;&gt; &#40;list-objects logs-client prefix&#41;
       &#40;take 5&#41;&#41;
  ;; =&gt; &#40;&quot;logs/E1HJS54CQLFQU4.2022-09-15-00.0125de6e.gz&quot;
  ;;     &quot;logs/E1HJS54CQLFQU4.2022-09-15-00.3b36a099.gz&quot;
  ;;     &quot;logs/E1HJS54CQLFQU4.2022-09-15-00.54775acb.gz&quot;
  ;;     &quot;logs/E1HJS54CQLFQU4.2022-09-15-00.6c612378.gz&quot;
  ;;     &quot;logs/E1HJS54CQLFQU4.2022-09-15-00.73072440.gz&quot;&#41;

&#41;
</code></pre><p>Victory!</p><p>We can test out our laziness by taking 105 items, which should require fetching the first two pages of results but nothing more:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;list-objects logs-client prefix&#41;
       &#40;take 105&#41;&#41;
  ;; =&gt; &#40;&quot;logs/E1HJS54CQLFQU4.2022-09-15-00.0125de6e.gz&quot;
  ;;     &quot;logs/E1HJS54CQLFQU4.2022-09-15-00.3b36a099.gz&quot;
  ;;     ...
  ;;     &quot;logs/E1HJS54CQLFQU4.2022-09-15-10.ae86e512.gz&quot;
  ;;     &quot;logs/E1HJS54CQLFQU4.2022-09-15-10.b4a720f9.gz&quot;&#41;

&#41;
</code></pre><p>Strangely, our REPL buffer reports that we fetched three pages:</p><pre class="language-text"><code class="lang-text language-text">Fetching page 1
Fetching page 2
Fetching page 3
</code></pre><p>In fact, we see the same thing even when we took just 5 items. What in the world is going on here?</p><p>Well, it turns out that Clojure realises lazy sequences in chunks; in other words, it optimistically calls the function producing the lazy sequence a few times to ensure that you have enough items in the sequence. In our case, our sequence seems to be realised in chunks of 3?</p><p>I have to admit that my knowledge of Clojure internals doesn't go this deep, and I hope someone reading this can explain things to me <a href='https://twitter.com/jmglov'>on
Twitter</a>.</p><p>In any case, at least we've accomplished our original goal of not fetching all the things when we only need some of the things. So that's something. 🤷</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-09-10-retrospecting-blog.html</id>
    <link href="https://jmglov.net/blog/2022-09-10-retrospecting-blog.html"/>
    <title>Retrospecting the blog</title>
    <updated>2022-09-10T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Back in the beginning of June this year, I left my previous job after six years. Since I wouldn't start my new job until September 5th, I had a nice long summer vacation ahead of me! In fact, my vacation even started before my son's (who's in school) and my wife's (who's a teacher), so I would have some time on my hands. One of the things I decided to do with this abundance of time was to start blogging every day.</p><p><img src="assets/2022-09-10-it-begins.png" alt="A tweet reading: I'm starting a nice long summer vacation today, and I'm going to try a blogging challenge wherein I write and publish something everyday, even if it's a tiny thing. I'm sure there will be some Clojure stuff, some Arsenal stuff, and likely some NixOS stuff as well." title="And so it begins!" class=border /></p><p>Ironically, despite posting this in the morning of June 13th, it wasn't until noon on June 15th that I posted my first blog entry over on <a href='https://medium.com/@jmglov/summertime-and-the-writing-aint-easy-d62c7fa8fdd'>Medium</a>. In my defence, I did spend the 13th and 14th feverishly working on chapter 1 of "<a href='https://7amkickoff.com/index.php/2022/06/16/story-of-a-mediocre-fan/'>Story of a mediocre
fan</a>" for <a href='https://7amkickoff.com/'>my friend Tim's blog</a>, but I have to admit that the optics aren't great. 😉</p><p>The reason for deciding to blog every day, as I intended to explain in <a href='https://jmglov.net/blog/2022-06-15-summertime.html'>my first
post</a> but got sidetracked (no surprise to people who know me) and ended up talking about tennis, was to improve my writing. As a kid, I read voraciously, and loved books so much I decided to start writing them. My first efforts were picture books, which my mom showed me how to fold and bind with a stapler, but then I moved on to a mystery, inspired by the Hardy Boys series. As this was a serious novel (according to my 7 year old perspective), I needed a more serious writing tool than a pencil. We didn't have a computer yet (that would come the following year, and is a story that I really should write about, if I haven't already (oh yeah, <a href='2022-07-22-best-part-of-waking-up.html'>I kinda did
already</a>, though I feel the story would support a lengthier treatment (haha, I just realised that I put a parenthetical inside a parenthetical 😂 (what am I, a Lisp programmer?)))), but we did have a typewriter. This was a real typewriter too, not one of those fancy <a href='https://en.wikipedia.org/wiki/IBM_Selectric_typewriter'>IBM
Selectric</a> ones. When you wanted a carriage return, you needed to reach up there and pull a lever (at least you were rewarded with an incredibly satisfying <strong>ding!</strong>).</p><p>I don't remember much about that book other than there was a scary cave chase scene. I'm not sure if I ever finished it, or even what my definition of finishing it would have been back then. I do remember that I started writing a Tolkien-inspired book about elves and such at some point, but I'm sure I didn't get very far with that. I wrote a few chapters of a spy thriller starring characters called The Sniper and The Assassin. I did a bunch of writing in high school, as you do, and then did even more in university, and then pretty much stopped. I did write stuff at work, of course, but it's mostly emails and technical documents, which is a very different sort of writing indeed.</p><p>I'm not one of those people who just knows that I have a great novel in me, and by golly I'll write it one of these days, but I am someone who enjoys writing and misses not doing much of it. So when a summer with no work unfurled before me, I decided I was going to seize the day and get to writing: one blog post a day for the rest of the summer.</p><p>In the 88 days since I made that decision, I've written 57 posts (including this one). If I saw my goal as writing one post a day, every single day, I would have to conclude that I failed to reach that goal. However, the real goal was to get better at writing through frequent practice, and the act of writing one post a day was simply the process by which I planned to accomplish this. As with any process, the point is not to follow it to the letter, the point is that the process is supposed to enable you to make progress towards whatever your goal is.</p><p>Speaking of process, a vital part of any good process is time to reflect, which is formalised in many software methodologies as a periodic meeting called a retrospective. In the retrospective, the team discusses how things have been going and what could be improved. This concept is useful even in individual work, and in fact in individual work, we often don't allocate sufficient time for reflection, which can greatly harm our ability to learn from our mistakes and our successes.</p><p>This post is a retrospective of my summer of blogging.</p><p>What went well?</p><ul><li>57 posts in 88 days is a post on two out of every three days, on average.</li><li>People were actually reading my posts! 😲 In fact, I had three posts with more  than 10,000 views (more on that in a moment).</li><li>I had a good time writing most of the posts.</li><li>I feel like I am a better writer than when I started.</li></ul><p>What could be improved?</p><ul><li>If writing every day is vital, I need to get better at coming up with a topic  when nothing comes immediately to mind</li><li>Too many words, Mr. Mozart. I could do a better job of getting to the point  and removing all of the stuff that isn't crucial to getting across the message  or telling the story.</li></ul><p>What did I learn?</p><ul><li>Writing is really time consuming! Even though I didn't do much editing (for  most posts, I read them through once just to ensure that I didn't have any  grammatical errors or parts that didn't make much sense, but didn't really  work on the structure of the post), even the shortest posts took about 30  minutes, and some of them took four or five hours! The "Story of a mediocre  fan" series that I posted over on 7amkickoff (chapters  <a href='https://7amkickoff.com/index.php/2022/06/16/story-of-a-mediocre-fan/'>1</a>,  <a href='https://7amkickoff.com/index.php/2022/06/23/story-of-a-mediocre-fan-chapter-2/'>2</a>,  <a href='https://7amkickoff.com/index.php/2022/06/30/story-of-a-mediocre-fan-chapter-3/'>3</a>,  and  <a href='https://7amkickoff.com/index.php/2022/07/09/story-of-a-mediocre-fan-chapter-4/'>4</a>)  took about 10 hours <strong>per chapter</strong>.</li><li>Writing is a habit, and if you want to write every day, a routine is really  important. In the spirit of <a href='2022-08-27-most-important-first.html'>doing the most important thing
  first</a>, a good routine for me would be  setting aside an hour before breakfast, every single day. In fact, that's  what I've done the past few days to write this post. (The reason this short  post has taken multiple days to write is because I needed to do some data  analysis to get the statistics that I'm about to share.)</li></ul><p>I'll wrap up this retrospective with some statistics. Here are the 10 most popular posts from the summer:</p><ul><li><a href='2022-08-11-dogfooding-blambda-cli-ier.html'>Dogfooding Blambda 4: CLI, CLIier,
  CLIiest</a>: 58965 views</li><li><a href='2022-08-26-doing-software-wrong.html'>We're doing software wrong</a>: 22343  views</li><li><a href='2022-09-02-dogfooding-blambda-logs.html'>Dogfooding Blambda 5: To parse—perchance to
  dream</a>: 13302 views</li><li><a href='2022-08-09-dogfooding-blambda-2.html'>Dogfooding Blambda : I heard you liked
  layers</a>: 9626 views</li><li><a href='2022-08-17-hacking-blog-sharing.html'>Hacking the blog: social sharing</a>:  8318 views</li><li><a href='2022-08-10-dogfooding-blambda-cli.html'>Dogfooding Blambda 3: CLIify this!</a>:  7015 views</li><li><a href='2022-07-04-dogfooding-blambda-1.html'>Dogfooding Blambda! : revenge of the pod
  people</a>: 5591 views</li><li><a href='2022-08-25-scientific-music.html'>Scientific Music</a>: 5158 views</li><li><a href='2022-07-14-hacking-blog-repl.html'>Hacking the blog: REPLing to victory</a>:  4062 views</li><li><a href='2022-07-03-blambda.html'>Blambda!</a>: 2224 views</li><li><a href='2022-07-06-hacking-blog-categories.html'>Hacking the blog: categories</a>: 1487  views</li><li><a href='2022-07-05-hacking-blog-favicon.html'>Hacking the blog: favicon</a>: 1477 views</li><li><a href='2022-08-27-most-important-first.html'>Do the most important thing first</a>:  1381 views</li><li><a href='2022-07-15-hacking-blog-actually-caching.html'>Hacking the blog: actually
  caching</a>: 1352 views</li></ul><p>I suspect the "Story of a mediocre fan" series got even more views, since they were posted on a very widely read Arsenal blog, but I don't want to bother Tim to get those stats, since the point of all of this wasn't page views. 😉</p><p>Now that this experiment is over, I've concluded that I like blogging, and it's something that I want to continue. I won't set a regular schedule for posting, but rather set aside time for writing (and hacking on <a href='https://github.com/borkdude/quickblog'>quickblog</a> and <a href='https://github.com/jmglov/blambda'>Blambda</a> and the stuff that I often write about) and post when I have something interesting (to me) to post. I also want to try writing some more focused pieces, which I'll edit and get feedback from people and so on. We'll see how it goes! 🙂 </p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-09-02-dogfooding-blambda-logs.html</id>
    <link href="https://jmglov.net/blog/2022-09-02-dogfooding-blambda-logs.html"/>
    <title>Dogfooding Blambda 5: To parse—perchance to dream</title>
    <updated>2022-09-01T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>In the last instalment of <a href='tags/blambda.html'>Dogfooding Blambda</a>, we dove into <a href='2022-08-11-dogfooding-blambda-cli-ier.html'>the details of how the new command line interface
works</a>. This is all well and good, but it doesn't directly help us along in our lofty goal of parsing access logs for my blog. Let's stop yak shaving and get back into it!</p><h2 id="talking_to_s3">Talking to S3</h2><p>The first order of business is figuring out how to grab the access logs from S3. As previously noted, I decided to use <a href='https://github.com/grzm/awyeah-api'>awyeah-api</a>, so let's add it to our <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;.&quot;&#93;
 :deps {com.cognitect.aws/endpoints {:mvn/version &quot;1.1.12.206&quot;}
        com.cognitect.aws/s3 {:mvn/version &quot;822.2.1109.0&quot;}
        com.grzm/awyeah-api {:git/url &quot;https://github.com/grzm/awyeah-api&quot;
                             :git/sha &quot;0fa7dd51f801dba615e317651efda8c597465af6&quot;}
        org.babashka/spec.alpha {:git/url &quot;https://github.com/babashka/spec.alpha&quot;
                                 :git/sha &quot;433b0778e2c32f4bb5d0b48e5a33520bee28b906&quot;}}}
</code></pre><p>After doing this, we need to build and deploy a new deps layer with Blambda:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-deps-layer --deps-path src/bb.edn
&#91;...&#93;
Compressing custom runtime layer: target/deps.zip

$ bb blambda deploy-deps-layer --deps-layer-name s3-log-parser-deps
Publishing layer version for layer s3-log-parser-deps
Published layer arn:aws:lambda:eu-west-1:123456789100:layer:s3-log-parser-deps:2
</code></pre><p>If you'll cast your mind back a few weeks to last time we were messing around with a lamdbda handler, you may recall that we started with something like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns s3-log-parser&#41;

&#40;defn handler &#91;event context&#93;
  &#40;prn {:msg &quot;Invoked with event&quot;,
        :data {:event event}}&#41;
  {&quot;statusCode&quot; 200
   &quot;body&quot; &quot;Hello, Blambda!&quot;}&#41;
</code></pre><p>Again, no judgement—we've gotta start somewhere—but this doesn't have much to do with S3. Let's remedy that!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns s3-log-parser
  &#40;:require &#91;com.grzm.awyeah.client.api :as aws&#93;&#41;&#41;

&#40;comment

  ;; Don't print this!
  &#40;def s3 &#40;aws/client {:api :s3, :region &quot;eu-west-1&quot;}&#41;&#41;
  ;; =&gt; #'s3-log-parser/s3

  ;; Would be so useful but there are too many ops for S3 😭
  &#40;aws/ops s3&#41;

  ;; Prints instead of returning an object; check your REPL buffer
  &#40;aws/doc s3 :ListObjectsV2&#41;
  
  &#41;
</code></pre><p>After requiring the awyeah-api client in our namespace, we'll open up a <a href='https://betweentwoparens.com/blog/rich-comment-blocks/#rich-comment'>Rich
comment</a> and start evaluating forms right in our source buffer! This of course assumes that you've started a <a href='https://github.com/babashka/babashka'>Babashka</a> REPL by doing something along the lines of</p><pre class="language-text"><code class="lang-text language-text">bb nrepl-server
</code></pre><p>and you're using something along the lines of <a href='https://docs.cider.mx/cider/index.html'>CIDER</a> and you've told your editor (Emacs, I hope) to connect to the Babashka REPL. The next step is to evaluate the buffer (<strong>C-c C-k</strong> in CIDER, which runs <code>cider-load-buffer</code>) so that the namespace is loaded.</p><p>Once you've done that stuff, putting your cursor at the end of a form and hitting <strong>C-c C-v f c e</strong> (<code>cider-pprint-eval-last-sexp-to-comment</code>)—or the equivalent in your editor—should result in the form being evaluated and the result being printed back to your code buffer. That's what the</p><pre class="language-clojure"><code class="lang-clojure language-clojure">;; =&gt; #'s3-log-parser/s3
</code></pre><p>stuff is in my code blocks. Whenever you see <code>;; =&gt; </code>, you'll know that is the result of evaluating a form. It's a really nice way to do REPL-driven development; you can do little experiments right next to the bit of code you're working on, and it's easy to copy and paste bits of those experiments into functions and so on.</p><p>The first thing we're doing inside the comment is creating an S3 client using the <a href='https://cognitect-labs.github.io/aws-api/cognitect.aws.client.api-api.html#cognitect.aws.client.api/client'>aws/client</a> function. The reason I have that little note not to print the result is that <code>aws/client</code> returns this big data structure describing all of the stuff the client can do, which in the case of S3 takes about 2000 lines to print, which takes CIDER a few seconds to do and really gunks up my code buffer. So instead of <strong>C-c C-v f c e</strong>, I do <strong>C-c C-e</strong> (<code>cider-eval-last-sexp</code>), which evaluates the form and prints the result in a temporary popup thingy instead of as a comment back into my code buffer.</p><p>The <a href='https://cognitect-labs.github.io/aws-api/cognitect.aws.client.api-api.html#cognitect.aws.client.api/ops'>aws/ops</a> function is also really useful for clients that are a bit smaller than S3's. It prints out all of the operations a client can perform, along with the parameters and return values of the operation. Again, it is too much to print here. 😭</p><p>The <a href='https://cognitect-labs.github.io/aws-api/cognitect.aws.client.api-api.html#cognitect.aws.client.api/doc'>aws/doc</a> function can help us out, though. It takes a client and an operation and prints out a nice documentation string. If I move my cursor to the end of the form and do a nice little <strong>C-c C-e</strong>, I get the following printed into my REPL buffer:</p><pre class="language-text"><code class="lang-text language-text">-------------------------
ListObjectsV2

&lt;p&gt;Returns some or all &#40;up to 1,000&#41; of the objects in a bucket with each request.
You can use the request parameters as selection criteria to return a subset of
the objects in a bucket. A &lt;code&gt;200 OK&lt;/code&gt; response can contain valid or
invalid XML. Make sure to design your application to parse the contents of the
&#91;...&#93;
&lt;/p&gt; &lt;/li&gt; &lt;/ul&gt;

-------------------------
Request

{:Prefix string,
 :StartAfter string,
 :Bucket string,
 :EncodingType &#91;:one-of &#91;&quot;url&quot;&#93;&#93;,
 :Delimiter string,
 :FetchOwner boolean,
 :RequestPayer &#91;:one-of &#91;&quot;requester&quot;&#93;&#93;,
 :ContinuationToken string,
 :MaxKeys integer,
 :ExpectedBucketOwner string}

Required

&#91;:Bucket&#93;

-------------------------
Response

{:Prefix string,
 :StartAfter string,
 :EncodingType &#91;:one-of &#91;&quot;url&quot;&#93;&#93;,
 :Delimiter string,
 :NextContinuationToken string,
 :CommonPrefixes &#91;:seq-of {:Prefix string}&#93;,
 :ContinuationToken string,
 :Contents
 &#91;:seq-of
  {:Key string,
   :LastModified timestamp,
   :ETag string,
   :ChecksumAlgorithm
   &#91;:seq-of &#91;:one-of &#91;&quot;CRC32&quot; &quot;CRC32C&quot; &quot;SHA1&quot; &quot;SHA256&quot;&#93;&#93;&#93;,
   :Size integer,
   :StorageClass
   &#91;:one-of
    &#91;&quot;STANDARD&quot;
     &quot;REDUCED&#95;REDUNDANCY&quot;
     &quot;GLACIER&quot;
     &quot;STANDARD&#95;IA&quot;
     &quot;ONEZONE&#95;IA&quot;
     &quot;INTELLIGENT&#95;TIERING&quot;
     &quot;DEEP&#95;ARCHIVE&quot;
     &quot;OUTPOSTS&quot;
     &quot;GLACIER&#95;IR&quot;&#93;&#93;,
   :Owner {:DisplayName string, :ID string}}&#93;,
 :MaxKeys integer,
 :IsTruncated boolean,
 :Name string,
 :KeyCount integer}
</code></pre><p>OK, that's pretty cool. Most of the time, however, I find it easier to just use the <a href='https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html'>AWS API
documentation</a>.</p><p>Anyway, we've now figured out that we want to use the <code>ListObjectsV2</code> operation, and we know from the docs that it needs a bucket and can take a key prefix to return only objects in a certain folder hierarchy, as it were. We can also add a maximum number of objects to return for testing purposes.</p><p>At this point, we have all the information we need to write a function to list S3 objects with a certain prefix, so we could go ahead and slap it in our code. However, I don't ever trust myself to have read the docs correctly, so I always just try things out in my REPL first to see what happens:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;aws/invoke s3 {:op :ListObjectsV2
                  :request {:Bucket &quot;logs.jmglov.net&quot;
                            :Prefix &quot;logs/2022-06-26&quot;
                            :MaxKeys 2}}&#41;
  ;; =&gt; {:Prefix &quot;logs/2022-06-26&quot;,
  ;;     :NextContinuationToken
  ;;     &quot;1ClCd7jr58LZ6u1Ts2UAEknPY2/jktCdH8LS3lGtB7oJskG+l1K4+97f+KCSZ6AhI69gyHv9phN8L8nKnbkqTxc/NT/GWdz9A&quot;,
  ;;     :Contents
  ;;     &#91;{:Key &quot;logs/2022-06-26-00-14-34-7526D4F869A33078&quot;,
  ;;       :LastModified #inst &quot;2022-06-26T00:14:35.000-00:00&quot;,
  ;;       :ETag &quot;\&quot;815876abf870fa05421183daa60a0513\&quot;&quot;,
  ;;       :Size 366,
  ;;       :StorageClass &quot;STANDARD&quot;}
  ;;      {:Key &quot;logs/2022-06-26-00-14-36-F1E7815BC3A92960&quot;,
  ;;       :LastModified #inst &quot;2022-06-26T00:14:37.000-00:00&quot;,
  ;;       :ETag &quot;\&quot;91b2ceef95e28d72384ff55b77453a71\&quot;&quot;,
  ;;       :Size 383,
  ;;       :StorageClass &quot;STANDARD&quot;}&#93;,
  ;;     :MaxKeys 2,
  ;;     :IsTruncated true,
  ;;     :Name &quot;logs.jmglov.net&quot;,
  ;;     :KeyCount 2}
  
  &#41;
</code></pre><p>Nice! I got the request right on the first try, and now we know what the response looks like. The power of REPL-driven development is evident here, in that I'm playing with real data, not copying and pasting from an example response or something like that. Documentation sometimes lies, but real services don't.</p><p>OK, so now we know how to use <code>ListObjectsV2</code>, so let's turn this stuff into a proper function that returns all of the log objects for a specific date. Let's also remove the hard-coded stuff and put it in a <code>config</code> map:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def config {:region &quot;eu-west-1&quot;
             :s3-bucket &quot;logs.jmglov.net&quot;
             :s3-prefix &quot;logs/&quot;}&#41;

&#40;defn list-logs &#91;date&#93;
  &#40;let &#91;{:keys &#91;region s3-bucket s3-prefix&#93;} config
        request {:Bucket s3-bucket
                 :Prefix &#40;format &quot;%s%s&quot; s3-prefix date&#41;}
        response &#40;aws/invoke s3 {:op :ListObjectsV2
                                 :request request}&#41;&#93;
    &#40;prn {:msg &quot;Listing log files&quot;, :data request}&#41;
    &#40;prn {:msg &quot;Response&quot;, :data response}&#41;
    &#40;-&gt;&gt; response
         :Contents
         &#40;map :Key&#41;&#41;&#41;&#41;
</code></pre><p>Having written a shiny new function, we'll want to prove to ourselves that it works by testing it out. We could write a unit test (OK, it's not really a unit test, but you get what I mean), but that would require setting up test machinery which we currently don't have, which sounds quite distracting. How's about we embrace the REPL and just test stuff out in a comment block right below the function we've written?</p><p>The first step is loading the new code into our environment, which we can do by evaluating the buffer again (<strong>C-c C-k</strong> or your editor's equivalent). Having done that, we write a function call and do the eval and print dance (**C-c C-v f c e** or your editor's equivalent):</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;list-logs &quot;2022-06-28&quot;&#41;
  ;; =&gt; &#40;&quot;logs/2022-06-28-00-12-36-5E32B8C5369C818E&quot;
  ;;     &quot;logs/2022-06-28-00-15-28-989878A1E585B6F5&quot;&#41;

  &#41;
</code></pre><p>Awesome, the function works as designed! 🎉</p><h2 id="talking_back">Talking back</h2><p>Now that we know how to list objects, let's make the lambda do the business! That requires us to do the following:</p><ol><li>Modify our <code>config</code> map to load configuration from the environment</li><li>Print a nice little log statement that lets us know that the Lambda is cold   starting (any code that is outside the handler function will only run when a   new lambda instance is started; after that, the namespace is already loaded,   so the runtime will just call the handler function for subsequent requests)</li><li>Create an S3 client (again, outside the handler function so the S3 client   will persist across requests; this is a common pattern for expensive   operations like creating clients or populating caches or whatever)</li><li>Making our handler function call <code>list-logs</code>, then JSON-encode the result   into the lambda's response body </li></ol><p>Here's what that looks like:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns s3-log-parser
  &#40;:require &#91;com.grzm.awyeah.client.api :as aws&#93;
            &#91;cheshire.core :as json&#93;&#41;&#41;

&#40;def config
  {:region &#40;System/getenv &quot;AWS&#95;REGION&quot;&#41;
   :s3-bucket &#40;System/getenv &quot;S3&#95;BUCKET&quot;&#41;
   :s3-prefix &#40;System/getenv &quot;S3&#95;PREFIX&quot;&#41;}&#41;

&#40;prn {:msg &quot;Lambda starting&quot;, :data {:config config}}&#41;

&#40;def s3 &#40;aws/client {:api :s3, :region &#40;:region config&#41;}&#41;&#41;

&#40;defn list-logs &#91;date&#93;
  &#40;let &#91;{:keys &#91;region s3-bucket s3-prefix&#93;} config
        request {:Bucket s3-bucket
                 :Prefix &#40;format &quot;%s%s&quot; s3-prefix date&#41;}
        response &#40;aws/invoke s3 {:op :ListObjectsV2
                                 :request request}&#41;&#93;
    &#40;prn {:msg &quot;Listing log files&quot;, :data request}&#41;
    &#40;prn {:msg &quot;Response&quot;, :data response}&#41;
    &#40;-&gt;&gt; response
         :Contents
         &#40;map :Key&#41;&#41;&#41;&#41;

&#40;defn handler &#91;event &#95;context&#93;
  &#40;prn {:msg &quot;Invoked with event&quot;, :data {:event event}}&#41;
  &#40;let &#91;logs &#40;list-logs &quot;2022-06-26&quot;&#41;&#93;
    {&quot;statusCode&quot; 200
     &quot;body&quot; &#40;json/encode {:logs logs}&#41;}&#41;&#41;
</code></pre><p>Again, let's prove this works before actually deploying a lambda. We'll evaluate the buffer to pick up the new handler, but now we might have a slight issue:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  config
  ;; =&gt; {:region nil, :s3-bucket nil, :s3-prefix nil}

  &#41;
</code></pre><p>The <code>config</code> map is being initialised from environment variables that we haven't set. We could set the environment variables and then restart our REPL, but that doesn't feel like the Clojure way. Why don't we just re-define <code>config</code> and the S3 client in our comment block instead?</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def config {:region &quot;eu-west-1&quot;
               :s3-bucket &quot;logs.jmglov.net&quot;
               :s3-prefix &quot;logs/&quot;}&#41;
  ;; =&gt; #&lt;Var@6c7f283b:
  ;;      {:region &quot;eu-west-1&quot;, :s3-bucket &quot;logs.jmglov.net&quot;, :s3-prefix &quot;logs/&quot;}&gt;

  ;; Don't print this! Use C-c C-e instead.
  &#40;def s3 &#40;aws/client {:api :s3, :region &#40;:region config&#41;}&#41;&#41;

  &#41;
</code></pre><p>Now we can actually test the handler function in our REPL:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;handler {} {}&#41;
  ;; =&gt; {&quot;statusCode&quot; 200,
  ;;     &quot;body&quot;
  ;;     &quot;{\&quot;logs\&quot;:&#91;\&quot;logs/2022-06-26-00-14-34-7526D4F869A33078\&quot;,\&quot;logs/2022-06-26-00-14-36-F1E7815BC3A92960\&quot;&#93;}&quot;}

  &#41;
</code></pre><p>Looks like it works! Let's try it out as an actual lambda!</p><p>If we open the <a href='https://eu-west-1.console.aws.amazon.com/lambda/#/functions'>AWS Lambda
console</a> and click on our s3-log-parser function, we now need to do three things:</p><ol><li>Update the configuration to use the new version of the s3-log-parser-deps   layer that we previously deployed</li><li>Set the environment variables that our code is expecting</li><li>Update the code itself</li></ol><p>For the first step, let's take a look at the <strong>Function overview</strong> section in the console:</p><p><img src="assets/2022-09-02-overview.png" alt="Screenshot of the Function overview section in the Lambda
console" title="Click on Layers" width=800px class=border /></p><p>If we click on the "Layers" icon and then <strong>Edit</strong>, we can see that our function is currently using two layers: the blambda runtime and the deps layer:</p><p><img src="assets/2022-09-02-layers.png" alt="Screenshot of the Edit layers page in the Lambda console" title="Change deps layer version and save" width=800px class=border /></p><p>We need to change the version of the <code>s3-log-parser-deps</code> to 2 and then click <strong>Save</strong>.</p><p>Now that our deps are updated, let's set the environment variables. If we click on the <strong>Configuration</strong> tab just under the function overview, we see <strong>Environment variables</strong> on the left hand side. Clicking that reveals that we have no environment variables currently set:</p><p><img src="assets/2022-09-02-env.png" alt="Screenshot of the Configuration tab in the Lambda
console" title="Click Edit" width=800px class=border /></p><p>Let's click the <strong>Edit</strong> button and add the <code>S3&#95;BUCKET</code> and <code>S3&#95;PREFIX</code> environment variables (<code>AWS&#95;REGION</code> is set by the Lambda instance itself, so we don't need to set it):</p><p><img src="assets/2022-09-02-env-filled.png" alt="Screenshot of the Edit environment variables page in the Lambda
console" title="Click Save" width=800px class=border /></p><p>And now it's time for the code itself. Let's click on the <strong>Code</strong> tab, then just copy and paste the contents of our editor's code buffer over top of the code in <code>s3&#95;log&#95;parser.clj</code>:</p><p><img src="assets/2022-09-02-code1.png" alt="Screenshot of the Code source tab in the Lambda
console" title="Click Deploy" width=800px class=border /></p><p>The really cool thing about Rich comments is that we don't need to remove them from our source code, since they're not evaluated when loading the namespace!</p><p>Now we click <strong>Deploy</strong> to get the new code out there, and there's only one thing standing between us and glory: configuring a test event. In order to do this, we need to click the dropdown on the <strong>Test</strong> button, then select **Create new event<strong>. We can fill in something like "test" for the </strong>Event name**, leave the <strong>Event sharing settings</strong> to the default of "Private", and then click on the <strong>Template</strong> dropdown and select "API Gateway AWS Proxy" (since we intend this lambda to be invoked using a <a href='https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html'>function
URL</a>, which uses the same input payload as AWS Gateway):</p><p><img src="assets/2022-09-02-test-event.png" alt="Screenshot of the Configure test event window in the Lambda
console" title="Click Save" width=800px class=border /></p><p>If we scroll down and click <strong>Save</strong>, we're in business! Now we can click the <strong>Test</strong> button and revel in our success:</p><p><img src="assets/2022-09-02-test1.png" alt="Screenshot of the Execution results tab in the Lambda
console" title="Victory!" width=800px class=border /></p><h2 id="accepting_input">Accepting input</h2><p>As amazing as all this is, it is a little limiting that our lambda only lists logs for 2022-06-26. How about we accept the date as a query string parameter instead? Looking at the <a href='https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html'>AWS Lambda proxy integration
payload</a>, we can see that the query parameters are exposed in a <code>queryStringParameters</code> object in the event. Let's grab that and use it as the <code>date</code> parameter to <code>list-logs</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn handler &#91;event &#95;context&#93;
  &#40;prn {:msg &quot;Invoked with event&quot;, :data {:event event}}&#41;
  &#40;let &#91;date &#40;get-in event &#91;&quot;queryStringParameters&quot; &quot;date&quot;&#93;&#41;
        logs &#40;list-logs date&#41;&#93;
    {&quot;statusCode&quot; 200
     &quot;body&quot; &#40;json/encode {:logs logs}&#41;}&#41;&#41;

&#40;comment

  &#40;def event {&quot;queryStringParameters&quot; {&quot;date&quot; &quot;2022-09-02&quot;}}&#41;
  ;; =&gt; #&lt;Var@4262c861: {&quot;queryStringParameters&quot; {&quot;date&quot; &quot;2022-09-02&quot;}}&gt;

  &#40;handler event {}&#41;
  ;; =&gt; {&quot;statusCode&quot; 200,
  ;;     &quot;body&quot;
  ;;     &quot;{\&quot;logs\&quot;:&#91;\&quot;logs/2022-09-02-00-12-42-F789B0CC4E3D8814\&quot;,\&quot;logs/2022-09-02-00-13-14-534A4D7BB857B5D3\&quot;&#93;}&quot;}

  &#41;
</code></pre><p>This whole <code>&#40;prn {:msg ...}&#41;</code> thing is getting a bit tiring, so let's add a <code>log</code> function instead. We might as well also add the timestamp to the log message whilst we're at it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns s3-log-parser
  &#40;:require &#91;cheshire.core :as json&#93;
            &#91;com.grzm.awyeah.client.api :as aws&#93;&#41;
  &#40;:import &#40;java.time Instant&#41;&#41;&#41;

&#40;defn log &#91;msg data&#93;
  &#40;prn {:msg msg
        :data data
        :timestamp &#40;str &#40;Instant/now&#41;&#41;}&#41;&#41;
</code></pre><p>OK, this is moving in the right direction, but what if someone sends in the date in the wrong format?</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;handler {&quot;queryStringParameters&quot; {&quot;date&quot; &quot;2/9/2022&quot;}} {}&#41;
  ;; =&gt; {&quot;statusCode&quot; 200, &quot;body&quot; &quot;{\&quot;logs\&quot;:&#91;&#93;}&quot;}

  &#41;
</code></pre><p>I mean, this is technically correct, but it would be much more friendly to actually let the caller know that they did something wrong. Let's parse the date to be sure it's valid before handing it off to <code>list-logs</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns s3-log-parser
  &#40;:require &#91;cheshire.core :as json&#93;
            &#91;com.grzm.awyeah.client.api :as aws&#93;&#41;
  &#40;:import &#40;java.time Instant
                      LocalDate&#41;&#41;&#41;

&#40;defn get-date &#91;date-str&#93;
  &#40;try
    &#40;LocalDate/parse date-str&#41;
    &#40;catch Exception &#95;
      &#40;let &#91;msg &#40;format &quot;Invalid date: %s&quot; date-str&#41;
            data {:date date-str}&#93;
        &#40;log msg data&#41;
        &#40;throw &#40;ex-info msg data&#41;&#41;&#41;&#41;&#41;&#41;

&#40;comment

  &#40;get-date &quot;2/9/2022&quot;&#41;
  ;; =&gt; : Invalid date: 2/9/2022 s3-log-parser 

  &#41;
</code></pre><p>Cool. There's another annoyance here, though: we're logging the error and then throwing an exception with the exact same data. Let's create a function to do this for us, and then we can be more concise in <code>get-date</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn error &#91;msg data&#93;
  &#40;log msg data&#41;
  &#40;throw &#40;ex-info msg data&#41;&#41;&#41;

&#40;defn get-date &#91;date-str&#93;
  &#40;try
    &#40;LocalDate/parse date-str&#41;
    &#40;catch Exception &#95;
      &#40;error &#40;format &quot;Invalid date: %s&quot; date-str&#41; {:date date-str}&#41;&#41;&#41;&#41;

&#40;comment

  &#40;get-date &quot;2022-06-28&quot;&#41;
  ;; =&gt; #object&#91;java.time.LocalDate 0x502a28d3 &quot;2022-06-28&quot;&#93;

  &#40;get-date &quot;2/9/2022&quot;&#41;
  ;; =&gt; : Invalid date: nope s3-log-parser 

  &#41;
</code></pre><p>If we think a little about our API, it seems probable that someone could call it without providing a date parameter, in which case it seems reasonable to get today's logs. Let's update <code>get-date</code> accordingly:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-date &#91;date-str&#93;
  &#40;if date-str
    &#40;try
      &#40;LocalDate/parse date-str&#41;
      &#40;catch Exception &#95;
        &#40;error &#40;format &quot;Invalid date: %s&quot; date-str&#41; {:date date-str}&#41;&#41;&#41;
    &#40;LocalDate/now&#41;&#41;&#41;

&#40;comment

  &#40;get-date nil&#41;
  ;; =&gt; #object&#91;java.time.LocalDate 0x1b2b5155 &quot;2022-09-02&quot;&#93;

  &#41;
</code></pre><p>Now that <code>get-date</code> can throw an exception, we should handle that by returning a 400:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn handler &#91;event &#95;context&#93;
  &#40;log &quot;Invoked with event&quot; {:event event}&#41;
  &#40;try
    &#40;let &#91;date &#40;get-date &#40;get-in event &#91;&quot;queryStringParameters&quot; &quot;date&quot;&#93;&#41;&#41;
          logs &#40;list-logs date&#41;&#93;
      {&quot;statusCode&quot; 200
       &quot;body&quot; &#40;json/encode {:logs logs}&#41;}&#41;
     &#40;catch Exception e
       &#40;log &#40;ex-message e&#41; &#40;ex-data e&#41;&#41;
       {&quot;statusCode&quot; 400
        &quot;body&quot; &#40;ex-message e&#41;}&#41;&#41;&#41;&#41;

&#40;comment

  &#40;handler {&quot;queryStringParameters&quot; {&quot;date&quot; &quot;2022-06-28&quot;}} {}&#41;
  ;; =&gt; {&quot;statusCode&quot; 200,
  ;;     &quot;body&quot;
  ;;     &quot;{\&quot;logs\&quot;:&#91;\&quot;logs/2022-06-28-00-12-36-5E32B8C5369C818E\&quot;,\&quot;logs/2022-06-28-00-15-28-989878A1E585B6F5\&quot;&#93;}&quot;}

  &#40;handler {} {}&#41;
  ;; =&gt; {&quot;statusCode&quot; 200,
  ;;     &quot;body&quot;
  ;;     &quot;{\&quot;logs\&quot;:&#91;\&quot;logs/2022-09-01-00-12-48-36034402F760D842\&quot;,\&quot;logs/2022-09-01-00-13-47-48D19FF5B34710F9\&quot;&#93;}&quot;}

  &#41;
</code></pre><h2 id="gettin%27_loggy_wit%27_it">Gettin' loggy wit' it</h2><p>Alright, we now have a lambda that can list logs. However, that's not really what we set out to do; we actually wanted to parse the logs and return the data contained therein. Of course, a necessary precursor to parsing the logs is actually retrieving them from S3. Looking at the <a href='https://docs.aws.amazon.com/AmazonS3/latest/API/API_Operations.html'>S3 API
documentation</a>, the <a href='https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html'>GetObject</a> operation looks promising. Let's try it out!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;aws/invoke s3 {:op :GetObject
                  :request {:Bucket &#40;:s3-bucket config&#41;
                            :Key &quot;logs/2022-06-28-00-15-28-989878A1E585B6F5&quot;}}&#41;
  ;; =&gt; {:LastModified #inst &quot;2022-06-28T00:15:29.000-00:00&quot;,
  ;;     :ETag &quot;\&quot;59877f6538514fe39b6874d9220c10a6\&quot;&quot;,
  ;;     :Metadata {},
  ;;     :ServerSideEncryption &quot;AES256&quot;,
  ;;     :ContentLength 379,
  ;;     :ContentType &quot;text/plain&quot;,
  ;;     :AcceptRanges &quot;bytes&quot;,
  ;;     :Body
  ;;     #object&#91;java.io.BufferedInputStream 0x61636398 &quot;java.io.BufferedInputStream@61636398&quot;&#93;}

  &#41;
</code></pre><p>OK, so <code>GetObject</code> returns the contents of the object as a <code>BufferedInputStream</code>. In order to do something reasonable with that, we need to wrap it in some sort of reader, which we can do with <a href='https://clojuredocs.org/clojure.java.io/reader'>clojure.java.io/reader</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns s3-log-parser
  &#40;:require &#91;clojure.java.io :as io&#93;
            &#91;cheshire.core :as json&#93;
            &#91;com.grzm.awyeah.client.api :as aws&#93;&#41;
  &#40;:import &#40;java.time Instant
                      LocalDate&#41;&#41;&#41;

&#40;comment
  &#40;-&gt;&gt; &#40;aws/invoke s3 {:op :GetObject
                       :request {:Bucket &#40;:s3-bucket config&#41;
                                 :Key &quot;logs/2022-06-28-00-15-28-989878A1E585B6F5&quot;}}&#41;
       :Body
       io/reader&#41;
  ;; =&gt; #object&#91;java.io.BufferedReader 0x7aac737b &quot;java.io.BufferedReader@7aac737b&quot;&#93;

  &#40;-&gt;&gt; &#40;aws/invoke s3 {:op :GetObject
                       :request {:Bucket &#40;:s3-bucket config&#41;
                                 :Key &quot;logs/2022-06-28-00-15-28-989878A1E585B6F5&quot;}}&#41;
       :Body
       io/reader
       slurp&#41;
  ;; =&gt; &quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;27/Jun/2022:23:44:08 +0000&#93; 64.252.68.192 - DC5849267MERJ0J5 WEBSITE.GET.OBJECT blog/atom.xml \&quot;GET /blog/atom.xml HTTP/1.1\&quot; 304 - - 87380 25 - \&quot;-\&quot; \&quot;Amazon CloudFront\&quot; - xm6O1t0x9H8Opu44YrFM/PxX/SUFizd4pS1/FJ/oJqdywpkzcmqx/N7Kv2eMF1Ij+32rM17h/HQ= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -\n&quot;

  &#41;
</code></pre><p>Reading the contents into a string with <a href='https://clojuredocs.org/clojure.core/slurp'>slurp</a> is cool, but it would be nicer if we can get a sequence of lines. There's a helpful function in clojure.core called <a href='https://clojuredocs.org/clojure.core/line-seq'>line-seq</a>, which does just that. Even better, it can also consume from a reader!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;aws/invoke s3 {:op :GetObject
                       :request {:Bucket &#40;:s3-bucket config&#41;
                                 :Key &quot;logs/2022-06-28-00-15-28-989878A1E585B6F5&quot;}}&#41;
       :Body
       io/reader
       line-seq&#41;
  ;; =&gt; &#40;&quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;27/Jun/2022:23:44:08 +0000&#93; 64.252.68.192 - DC5849267MERJ0J5 WEBSITE.GET.OBJECT blog/atom.xml \&quot;GET /blog/atom.xml HTTP/1.1\&quot; 304 - - 87380 25 - \&quot;-\&quot; \&quot;Amazon CloudFront\&quot; - xm6O1t0x9H8Opu44YrFM/PxX/SUFizd4pS1/FJ/oJqdywpkzcmqx/N7Kv2eMF1Ij+32rM17h/HQ= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -&quot;&#41;

  &#41;
</code></pre><p>Now that we've learned how to grab lines from a log, let's put it in a function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-log-lines &#91;s3-key&#93;
  &#40;-&gt;&gt; &#40;aws/invoke s3 {:op :GetObject
                       :request {:Bucket &#40;:s3-bucket config&#41;
                                 :Key s3-key}}&#41;
       :Body
       io/reader
       line-seq&#41;&#41;

&#40;comment

  &#40;get-log-lines &quot;logs/2022-06-28-00-15-28-989878A1E585B6F5&quot;&#41;
  ;; =&gt; &#40;&quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;27/Jun/2022:23:44:08 +0000&#93; 64.252.68.192 - DC5849267MERJ0J5 WEBSITE.GET.OBJECT blog/atom.xml \&quot;GET /blog/atom.xml HTTP/1.1\&quot; 304 - - 87380 25 - \&quot;-\&quot; \&quot;Amazon CloudFront\&quot; - xm6O1t0x9H8Opu44YrFM/PxX/SUFizd4pS1/FJ/oJqdywpkzcmqx/N7Kv2eMF1Ij+32rM17h/HQ= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -&quot;&#41;

  &#41;
</code></pre><p>Now we can glue together what we've done so far to get all the log lines for a specific date!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &#40;list-logs &quot;2022-06-28&quot;&#41;
       &#40;mapcat get-log-lines&#41;&#41;
  ;; =&gt; &#40;&quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;27/Jun/2022:23:30:23 +0000&#93; 64.252.88.38 - DA5918VPJFK9A654 WEBSITE.GET.OBJECT blog/2022-06-21-todo-list.html \&quot;GET /blog/2022-06-21-todo-list.html HTTP/1.1\&quot; 304 - - 5447 32 - \&quot;-\&quot; \&quot;Amazon CloudFront\&quot; - ukDRnMomesiUakfwW1nSFaoxoQQ/Nn14Dv+4helmPEcIkaIxEFfPojLviLC9vdbiER5zyB/ZOxI= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -&quot;
  ;;     &quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;27/Jun/2022:23:44:08 +0000&#93; 64.252.68.192 - DC5849267MERJ0J5 WEBSITE.GET.OBJECT blog/atom.xml \&quot;GET /blog/atom.xml HTTP/1.1\&quot; 304 - - 87380 25 - \&quot;-\&quot; \&quot;Amazon CloudFront\&quot; - xm6O1t0x9H8Opu44YrFM/PxX/SUFizd4pS1/FJ/oJqdywpkzcmqx/N7Kv2eMF1Ij+32rM17h/HQ= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -&quot;&#41;

  &#41;
</code></pre><p>Now we're getting somewhere!</p><h2 id="errors_and_taxes">Errors and taxes</h2><p>Of course, we haven't taken into account the fact that calls to S3 can fail, and of course anything that <strong>can</strong> fail <strong>will</strong> eventually fail. Let's see what happens if we feed <code>GetObject</code> a key that doesn't exist:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;aws/invoke s3 {:op :GetObject
                  :request {:Bucket &#40;:s3-bucket config&#41;
                            :Key &quot;NOPE!&quot;}}&#41;
  ;; =&gt; {:Error
  ;;     {:HostIdAttrs {},
  ;;      :KeyAttrs {},
  ;;      :Message &quot;The specified key does not exist.&quot;,
  ;;      :Key &quot;NOPE!&quot;,
  ;;      :CodeAttrs {},
  ;;      :RequestIdAttrs {},
  ;;      :HostId
  ;;      &quot;Qfz+CzbvxySHBLdd1vIfX8rd8dpkgl1fnlxLTWSHGdtt77jqh/n9EJNzHiqKhZeqqMWP+ZUMRBQ=&quot;,
  ;;      :MessageAttrs {},
  ;;      :RequestId &quot;0TESGW38EP6NQ6XK&quot;,
  ;;      :Code &quot;NoSuchKey&quot;},
  ;;     :ErrorAttrs {},
  ;;     :cognitect.anomalies/category :cognitect.anomalies/not-found}

  &#41;
</code></pre><p>OK, this is straightforward enough to handle. We'll just check for an <code>:Error</code> key in the response and call our <code>error</code> function to log and throw:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-log-lines &#91;s3-key&#93;
  &#40;let &#91;resp &#40;aws/invoke s3 {:op :GetObject
                             :request {:Bucket &#40;:s3-bucket config&#41;
                                       :Key s3-key}}&#41;&#93;
    &#40;if &#40;:Error resp&#41;
      &#40;error &#40;-&gt; resp :Error :Message&#41; &#40;:Error resp&#41;&#41;
      &#40;-&gt;&gt; resp
           :Body
           io/reader
           line-seq&#41;&#41;&#41;&#41;

&#40;comment

  &#40;get-log-lines &quot;NOPE!&quot;&#41;
  ;; =&gt; : The specified key does not exist. s3-log-parser

  &#41;
</code></pre><p>It is a little distracting to have this if / else conditional in the middle of our otherwise pristine <code>get-log-lines</code> function, however. Let's pull error handling out into a separate function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn handle-error &#91;{err :Error :as resp}&#93;
  &#40;if err
    &#40;error &#40;:Message err&#41; err&#41;
    resp&#41;&#41;

&#40;comment

  &#40;handle-error
   {:Error
    {:HostIdAttrs {},
     :KeyAttrs {},
     :Message &quot;The specified key does not exist.&quot;,
     :Key &quot;NOPE!&quot;,
     :CodeAttrs {},
     :RequestIdAttrs {},
     :HostId
     &quot;Qfz+CzbvxySHBLdd1vIfX8rd8dpkgl1fnlxLTWSHGdtt77jqh/n9EJNzHiqKhZeqqMWP+ZUMRBQ=&quot;,
     :MessageAttrs {},
     :RequestId &quot;0TESGW38EP6NQ6XK&quot;,
     :Code &quot;NoSuchKey&quot;},
    :ErrorAttrs {},
    :cognitect.anomalies/category :cognitect.anomalies/not-found}&#41;
  ;; =&gt; : The specified key does not exist. s3-log-parser

  &#40;handle-error {:foo 1}&#41;
  ;; =&gt; {:foo 1}

  &#41;
</code></pre><p>Now we can keep <code>get-log-lines</code> focused on the happy path:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn get-log-lines &#91;s3-key&#93;
  &#40;-&gt;&gt; &#40;aws/invoke s3 {:op :GetObject
                       :request {:Bucket &#40;:s3-bucket config&#41;
                                 :Key s3-key}}&#41;
       handle-error
       :Body
       io/reader
       line-seq&#41;&#41;
</code></pre><h2 id="picking_up_the_pieces">Picking up the pieces</h2><p>We now have what we need to read logs, so let's update the handler to do just that:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn handler &#91;event &#95;context&#93;
  &#40;log &quot;Invoked with event&quot; {:event event}&#41;
  &#40;try
    &#40;let &#91;date &#40;get-date &#40;get-in event &#91;&quot;queryStringParameters&quot; &quot;date&quot;&#93;&#41;&#41;
          logs &#40;list-logs date&#41;
          log-lines &#40;mapcat get-log-lines logs&#41;&#93;
      {&quot;statusCode&quot; 200
       &quot;body&quot; &#40;json/encode {:logs logs
                            :lines log-lines}&#41;}&#41;
     &#40;catch Exception e
       &#40;log &#40;ex-message e&#41; &#40;ex-data e&#41;&#41;
       {&quot;statusCode&quot; 400
        &quot;body&quot; &#40;ex-message e&#41;}&#41;&#41;&#41;&#41;

&#40;comment

  &#40;handler {} {}&#41;
  ;; =&gt; {&quot;statusCode&quot; 200,
  ;;     &quot;body&quot;
  ;;     &quot;{\&quot;logs\&quot;:&#91;\&quot;logs/2022-09-01-00-12-48-36034402F760D842\&quot;,\&quot;logs/2022-09-01-00-13-47-48D19FF5B34710F9\&quot;&#93;,\&quot;lines\&quot;:&#91;\&quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;31/Aug/2022:23:40:32 +0000&#93; 64.252.89.133 - BC0KZGTDKR1QP07G WEBSITE.HEAD.OBJECT blog/2022-08-26-doing-software-wrong.html \\\&quot;HEAD /blog/2022-08-26-doing-software-wrong.html HTTP/1.1\\\&quot; 304 - - 7536 34 - \\\&quot;-\\\&quot; \\\&quot;Amazon CloudFront\\\&quot; - ApxkRhuJl/C73ZiR70xT6Stn2b1RkIcDPMnapeH5kWWQ+mT41qXfNeLaqpMc3j+5WCnrqoJH8N0= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -\&quot;,\&quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;31/Aug/2022:23:14:14 +0000&#93; 64.252.73.234 - M987YV3YB7DHBWA3 WEBSITE.GET.OBJECT index.html \\\&quot;GET / HTTP/1.1\\\&quot; 304 - - 3459 18 - \\\&quot;-\\\&quot; \\\&quot;Amazon CloudFront\\\&quot; - 14MYY/WyblbL1WgULsK86Cwwtn+tHCOgs+Y98xIkC/EIwkqMeN/SWpBsF6x2gC1Tir7DYRc/+Zk= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -\&quot;&#93;}&quot;}


  &#41;
</code></pre><p>It's neat to be able to test the handler in the REPL, but it is a little annoying to have to always invoke it like <code>&#40;handler {} {}&#41;</code> if we just want an empty event. Let's use Clojure's multi-arity functions to define a <code>handler/0</code> and a <code>handler/1</code> (the way you reference functions of different arities in Erlang is simply the best; read <code>handler/0</code> as "a function <code>handler</code> taking 0 arguments", and <code>handler/1</code> as "a function <code>handler</code> taking 1 argument").</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn handler
  &#40;&#91;&#93;
   &#40;handler {} {}&#41;&#41;
  &#40;&#91;event&#93;
   &#40;handler event {}&#41;&#41;
  &#40;&#91;event &#95;context&#93;
   &#40;log &quot;Invoked with event&quot; {:event event}&#41;
   &#40;try
     &#40;let &#91;date &#40;get-date &#40;get-in event &#91;&quot;queryStringParameters&quot; &quot;date&quot;&#93;&#41;&#41;
           logs &#40;list-logs date&#41;
           log-lines &#40;mapcat get-log-lines logs&#41;&#93;
       {&quot;statusCode&quot; 200
        &quot;body&quot; &#40;json/encode {:logs logs
                             :lines log-lines}&#41;}&#41;
     &#40;catch Exception e
       &#40;log &#40;ex-message e&#41; &#40;ex-data e&#41;&#41;
       {&quot;statusCode&quot; 400
        &quot;body&quot; &#40;ex-message e&#41;}&#41;&#41;&#41;&#41;

&#40;comment

  &#40;handler&#41;
  ;; =&gt; {&quot;statusCode&quot; 200,
  ;;     &quot;body&quot;
  ;;     &quot;{\&quot;logs\&quot;:&#91;\&quot;logs/2022-09-01-00-12-48-36034402F760D842\&quot;,\&quot;logs/2022-09-01-00-13-47-48D19FF5B34710F9\&quot;&#93;,\&quot;lines\&quot;:&#91;\&quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;31/Aug/2022:23:40:32 +0000&#93; 64.252.89.133 - BC0KZGTDKR1QP07G WEBSITE.HEAD.OBJECT blog/2022-08-26-doing-software-wrong.html \\\&quot;HEAD /blog/2022-08-26-doing-software-wrong.html HTTP/1.1\\\&quot; 304 - - 7536 34 - \\\&quot;-\\\&quot; \\\&quot;Amazon CloudFront\\\&quot; - ApxkRhuJl/C73ZiR70xT6Stn2b1RkIcDPMnapeH5kWWQ+mT41qXfNeLaqpMc3j+5WCnrqoJH8N0= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -\&quot;,\&quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;31/Aug/2022:23:14:14 +0000&#93; 64.252.73.234 - M987YV3YB7DHBWA3 WEBSITE.GET.OBJECT index.html \\\&quot;GET / HTTP/1.1\\\&quot; 304 - - 3459 18 - \\\&quot;-\\\&quot; \\\&quot;Amazon CloudFront\\\&quot; - 14MYY/WyblbL1WgULsK86Cwwtn+tHCOgs+Y98xIkC/EIwkqMeN/SWpBsF6x2gC1Tir7DYRc/+Zk= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -\&quot;&#93;}&quot;}

  &#41;
</code></pre><p>OK, we now have something worth pasting into the Lambda console and testing out!</p><p><img src="assets/2022-09-02-test2.png" alt="Screenshot of the AWS Lambda console showing a successful test
result" title="The sweet smell of victory!" width=800px class=border /></p><h2 id="what_does_it_all_mean%3F">What does it all mean?</h2><p>There's one final thing to do: actually parse the log lines. If we take a look at the <a href='https://docs.aws.amazon.com/AmazonS3/latest/userguide/LogFormat.html'>Amazon S3 server access log
format</a> documentation, we can see how we need to parse this thing. And for parsing strings, the obvious place to turn is a regular expression!</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;re-seq #&quot;&#94;&#40;\S+&#41; &#40;\S+&#41; \&#91;&#40;&#91;&#94;&#93;&#93;+&#41;\&#93; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; \&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; \&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot; \&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41;.&#42;$&quot;
          &quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;27/Jun/2022:23:30:23 +0000&#93; 64.252.88.38 - DA5918VPJFK9A654 WEBSITE.GET.OBJECT blog/2022-06-21-todo-list.html \&quot;GET /blog/2022-06-21-todo-list.html HTTP/1.1\&quot; 304 - - 5447 32 - \&quot;-\&quot; \&quot;Amazon CloudFront\&quot; - ukDRnMomesiUakfwW1nSFaoxoQQ/Nn14Dv+4helmPEcIkaIxEFfPojLviLC9vdbiER5zyB/ZOxI= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -&quot;&#41;
  ;; =&gt; &#40;&#91;&quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;27/Jun/2022:23:30:23 +0000&#93; 64.252.88.38 - DA5918VPJFK9A654 WEBSITE.GET.OBJECT blog/2022-06-21-todo-list.html \&quot;GET /blog/2022-06-21-todo-list.html HTTP/1.1\&quot; 304 - - 5447 32 - \&quot;-\&quot; \&quot;Amazon CloudFront\&quot; - ukDRnMomesiUakfwW1nSFaoxoQQ/Nn14Dv+4helmPEcIkaIxEFfPojLviLC9vdbiER5zyB/ZOxI= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -&quot;
  ;;      &quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188&quot;
  ;;      &quot;jmglov.net&quot;
  ;;      &quot;27/Jun/2022:23:30:23 +0000&quot;
  ;;      &quot;64.252.88.38&quot;
  ;;      &quot;-&quot;
  ;;      &quot;DA5918VPJFK9A654&quot;
  ;;      &quot;WEBSITE.GET.OBJECT&quot;
  ;;      &quot;blog/2022-06-21-todo-list.html&quot;
  ;;      &quot;GET /blog/2022-06-21-todo-list.html HTTP/1.1&quot;
  ;;      &quot;304&quot;
  ;;      &quot;-&quot;
  ;;      &quot;-&quot;
  ;;      &quot;5447&quot;
  ;;      &quot;32&quot;
  ;;      &quot;-&quot;
  ;;      &quot;-&quot;
  ;;      &quot;Amazon CloudFront&quot;
  ;;      &quot;-&quot;
  ;;      &quot;ukDRnMomesiUakfwW1nSFaoxoQQ/Nn14Dv+4helmPEcIkaIxEFfPojLviLC9vdbiER5zyB/ZOxI=&quot;
  ;;      &quot;-&quot;
  ;;      &quot;-&quot;
  ;;      &quot;-&quot;
  ;;      &quot;jmglov.net.s3-website-eu-west-1.amazonaws.com&quot;
  ;;      &quot;-&quot;
  ;;      &quot;-&quot;&#93;&#41;

  &#41;
</code></pre><p>Amazing! But also not amazing. Regular expressions get a bad name for being cryptic and brittle, so let's see if we can use the power of Clojure to make this one accessible and resilient!</p><p>We can define a variable that holds both the name of the field and the pattern used to match it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns s3-log-parser
  &#40;:require &#91;clojure.java.io :as io&#93;
            &#91;clojure.string :as str&#93;
            &#91;cheshire.core :as json&#93;
            &#91;com.grzm.awyeah.client.api :as aws&#93;&#41;
  &#40;:import &#40;java.time Instant
                      LocalDate&#41;&#41;&#41;

&#40;comment

  &#40;def log-fields
    &#40;-&gt;&gt; &#91;:bucket-owner &quot;&#40;\\S+&#41;&quot;
          :bucket &quot;&#40;\\S+&#41;&quot;
          :time &quot;\\&#91;&#40;&#91;&#94;&#93;&#93;+&#41;\\&#93;&quot;
          :remote-ip &quot;&#40;\\S+&#41;&quot;
          :requester &quot;&#40;\\S+&#41;&quot;
          :request-id &quot;&#40;\\S+&#41;&quot;
          :operation &quot;&#40;\\S+&#41;&quot;
          :key &quot;&#40;\\S+&#41;&quot;
          :request-uri &quot;\&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot;&quot;
          :http-status &quot;&#40;\\S+&#41;&quot;
          :error-code &quot;&#40;\\S+&#41;&quot;
          :bytes-sent &quot;&#40;\\S+&#41;&quot;
          :object-size &quot;&#40;\\S+&#41;&quot;
          :total-time &quot;&#40;\\S+&#41;&quot;
          :turn-around-time &quot;&#40;\\S+&#41;&quot;
          :referer &quot;\&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot;&quot;
          :user-agent &quot;\&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot;&quot;
          :version-id &quot;&#40;\\S+&#41;&quot;
          :host-id &quot;&#40;\\S+&#41;&quot;
          :signature-version &quot;&#40;\\S+&#41;&quot;
          :cipher-suite &quot;&#40;\\S+&#41;&quot;
          :authentication-type &quot;&#40;\\S+&#41;&quot;
          :host-header &quot;&#40;\\S+&#41;&quot;
          :tls-version &quot;&#40;\\S+&#41;&quot;
          :access-point-arn &quot;&#40;\\S+&#41;&quot;&#93;
         &#40;partition-all 2&#41;&#41;&#41;
  ;; =&gt; #&lt;Var@4b3cc742:
  ;;      &#40;&#40;:bucket-owner &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:bucket &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:time &quot;\\&#91;&#40;&#91;&#94;&#93;&#93;+&#41;\\&#93;&quot;&#41;
  ;;       &#40;:remote-ip &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:requester &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:request-id &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:operation &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:key &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:request-uri &quot;\&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot;&quot;&#41;
  ;;       &#40;:http-status &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:error-code &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:bytes-sent &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:object-size &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:total-time &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:turn-around-time &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:referer &quot;\&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot;&quot;&#41;
  ;;       &#40;:user-agent &quot;\&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot;&quot;&#41;
  ;;       &#40;:version-id &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:host-id &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:signature-version &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:cipher-suite &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:authentication-type &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:host-header &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:tls-version &quot;&#40;\\S+&#41;&quot;&#41;
  ;;       &#40;:access-point-arn &quot;&#40;\\S+&#41;&quot;&#41;&#41;&gt;

  &#41;
</code></pre><p>Having done this, let's build the regex from the individual patterns:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def log-regex &#40;-&gt;&gt; log-fields
                      &#40;map second&#41;
                      &#40;str/join &quot; &quot;&#41;
                      &#40;format &quot;&#94;%s&#40;.&#42;&#41;$&quot;&#41;
                      re-pattern&#41;&#41;
  ;; =&gt; #&lt;Var@487539b9:
  ;;      #&quot;&#94;&#40;\S+&#41; &#40;\S+&#41; \&#91;&#40;&#91;&#94;&#93;&#93;+&#41;\&#93; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; \&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; \&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot; \&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41; &#40;\S+&#41;&#40;.&#42;&#41;$&quot;&gt;

  &#40;re-seq log-regex
          &quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;27/Jun/2022:23:30:23 +0000&#93; 64.252.88.38 - DA5918VPJFK9A654 WEBSITE.GET.OBJECT blog/2022-06-21-todo-list.html \&quot;GET /blog/2022-06-21-todo-list.html HTTP/1.1\&quot; 304 - - 5447 32 - \&quot;-\&quot; \&quot;Amazon CloudFront\&quot; - ukDRnMomesiUakfwW1nSFaoxoQQ/Nn14Dv+4helmPEcIkaIxEFfPojLviLC9vdbiER5zyB/ZOxI= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -&quot;&#41;
  ;; =&gt; &#40;&#91;&quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;27/Jun/2022:23:30:23 +0000&#93; 64.252.88.38 - DA5918VPJFK9A654 WEBSITE.GET.OBJECT blog/2022-06-21-todo-list.html \&quot;GET /blog/2022-06-21-todo-list.html HTTP/1.1\&quot; 304 - - 5447 32 - \&quot;-\&quot; \&quot;Amazon CloudFront\&quot; - ukDRnMomesiUakfwW1nSFaoxoQQ/Nn14Dv+4helmPEcIkaIxEFfPojLviLC9vdbiER5zyB/ZOxI= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -&quot;
  ;;      &quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188&quot;
  ;;      &quot;jmglov.net&quot;
  ;;      &quot;27/Jun/2022:23:30:23 +0000&quot;
  ;;      &quot;64.252.88.38&quot;
  ;;      &quot;-&quot;
  ;;      &quot;DA5918VPJFK9A654&quot;
  ;;      &quot;WEBSITE.GET.OBJECT&quot;
  ;;      &quot;blog/2022-06-21-todo-list.html&quot;
  ;;      &quot;GET /blog/2022-06-21-todo-list.html HTTP/1.1&quot;
  ;;      &quot;304&quot;
  ;;      &quot;-&quot;
  ;;      &quot;-&quot;
  ;;      &quot;5447&quot;
  ;;      &quot;32&quot;
  ;;      &quot;-&quot;
  ;;      &quot;-&quot;
  ;;      &quot;Amazon CloudFront&quot;
  ;;      &quot;-&quot;
  ;;      &quot;ukDRnMomesiUakfwW1nSFaoxoQQ/Nn14Dv+4helmPEcIkaIxEFfPojLviLC9vdbiER5zyB/ZOxI=&quot;
  ;;      &quot;-&quot;
  ;;      &quot;-&quot;
  ;;      &quot;-&quot;
  ;;      &quot;jmglov.net.s3-website-eu-west-1.amazonaws.com&quot;
  ;;      &quot;-&quot;
  ;;      &quot;-&quot;
  ;;      &quot;&quot;&#93;&#41;

  &#41;
</code></pre><p>Now we can grab the names of the fields and build a map by zipping them together with the matched groups from the regex:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;def log-keys &#40;map first log-fields&#41;&#41;
  ;; =&gt; #&lt;Var@abab7a:
  ;;      &#40;:bucket-owner
  ;;       :bucket
  ;;       :time
  ;;       :remote-ip
  ;;       :requester
  ;;       :request-id
  ;;       :operation
  ;;       :key
  ;;       :request-uri
  ;;       :http-status
  ;;       :error-code
  ;;       :bytes-sent
  ;;       :object-size
  ;;       :total-time
  ;;       :turn-around-time
  ;;       :referer
  ;;       :user-agent
  ;;       :version-id
  ;;       :host-id
  ;;       :signature-version
  ;;       :cipher-suite
  ;;       :authentication-type
  ;;       :host-header
  ;;       :tls-version
  ;;       :access-point-arn&#41;&gt;

  &#40;-&gt;&gt; &quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;27/Jun/2022:23:30:23 +0000&#93; 64.252.88.38 - DA5918VPJFK9A654 WEBSITE.GET.OBJECT blog/2022-06-21-todo-list.html \&quot;GET /blog/2022-06-21-todo-list.html HTTP/1.1\&quot; 304 - - 5447 32 - \&quot;-\&quot; \&quot;Amazon CloudFront\&quot; - ukDRnMomesiUakfwW1nSFaoxoQQ/Nn14Dv+4helmPEcIkaIxEFfPojLviLC9vdbiER5zyB/ZOxI= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -&quot;
       &#40;re-seq log-regex&#41;
       first
       &#40;drop 1&#41;
       &#40;zipmap log-keys&#41;&#41;
  ;; =&gt; {:tls-version &quot;-&quot;,
  ;;     :request-uri &quot;GET /blog/2022-06-21-todo-list.html HTTP/1.1&quot;,
  ;;     :access-point-arn &quot;-&quot;,
  ;;     :request-id &quot;DA5918VPJFK9A654&quot;,
  ;;     :referer &quot;-&quot;,
  ;;     :user-agent &quot;Amazon CloudFront&quot;,
  ;;     :remote-ip &quot;64.252.88.38&quot;,
  ;;     :key &quot;blog/2022-06-21-todo-list.html&quot;,
  ;;     :host-header &quot;jmglov.net.s3-website-eu-west-1.amazonaws.com&quot;,
  ;;     :version-id &quot;-&quot;,
  ;;     :time &quot;27/Jun/2022:23:30:23 +0000&quot;,
  ;;     :operation &quot;WEBSITE.GET.OBJECT&quot;,
  ;;     :object-size &quot;5447&quot;,
  ;;     :authentication-type &quot;-&quot;,
  ;;     :error-code &quot;-&quot;,
  ;;     :bytes-sent &quot;-&quot;,
  ;;     :requester &quot;-&quot;,
  ;;     :http-status &quot;304&quot;,
  ;;     :turn-around-time &quot;-&quot;,
  ;;     :signature-version &quot;-&quot;,
  ;;     :total-time &quot;32&quot;,
  ;;     :host-id
  ;;     &quot;ukDRnMomesiUakfwW1nSFaoxoQQ/Nn14Dv+4helmPEcIkaIxEFfPojLviLC9vdbiER5zyB/ZOxI=&quot;,
  ;;     :cipher-suite &quot;-&quot;,
  ;;     :bucket &quot;jmglov.net&quot;,
  ;;     :bucket-owner
  ;;     &quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188&quot;}

  &#41;
</code></pre><p>There is one slight annoyance here, which is that we have all of these fields with values of "-". According to the docs, a "-" means there's no value for that field, so let's replace this with <code>nil</code>, which is Clojure's way of saying "no value here":</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#40;-&gt;&gt; &quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188 jmglov.net &#91;27/Jun/2022:23:30:23 +0000&#93; 64.252.88.38 - DA5918VPJFK9A654 WEBSITE.GET.OBJECT blog/2022-06-21-todo-list.html \&quot;GET /blog/2022-06-21-todo-list.html HTTP/1.1\&quot; 304 - - 5447 32 - \&quot;-\&quot; \&quot;Amazon CloudFront\&quot; - ukDRnMomesiUakfwW1nSFaoxoQQ/Nn14Dv+4helmPEcIkaIxEFfPojLviLC9vdbiER5zyB/ZOxI= - - - jmglov.net.s3-website-eu-west-1.amazonaws.com - -&quot;
       &#40;re-seq log-regex&#41;
       first
       &#40;drop 1&#41;
       &#40;map #&#40;if &#40;= &quot;-&quot; %&#41; nil %&#41;&#41;
       &#40;zipmap log-keys&#41;&#41;
  ;; =&gt; {:tls-version nil,
  ;;     :request-uri &quot;GET /blog/2022-06-21-todo-list.html HTTP/1.1&quot;,
  ;;     :access-point-arn nil,
  ;;     :request-id &quot;DA5918VPJFK9A654&quot;,
  ;;     :referer nil,
  ;;     :user-agent &quot;Amazon CloudFront&quot;,
  ;;     :remote-ip &quot;64.252.88.38&quot;,
  ;;     :key &quot;blog/2022-06-21-todo-list.html&quot;,
  ;;     :host-header &quot;jmglov.net.s3-website-eu-west-1.amazonaws.com&quot;,
  ;;     :version-id nil,
  ;;     :time &quot;27/Jun/2022:23:30:23 +0000&quot;,
  ;;     :operation &quot;WEBSITE.GET.OBJECT&quot;,
  ;;     :object-size &quot;5447&quot;,
  ;;     :authentication-type nil,
  ;;     :error-code nil,
  ;;     :bytes-sent nil,
  ;;     :requester nil,
  ;;     :http-status &quot;304&quot;,
  ;;     :turn-around-time nil,
  ;;     :signature-version nil,
  ;;     :total-time &quot;32&quot;,
  ;;     :host-id
  ;;     &quot;ukDRnMomesiUakfwW1nSFaoxoQQ/Nn14Dv+4helmPEcIkaIxEFfPojLviLC9vdbiER5zyB/ZOxI=&quot;,
  ;;     :cipher-suite nil,
  ;;     :bucket &quot;jmglov.net&quot;,
  ;;     :bucket-owner
  ;;     &quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188&quot;}

  &#41;
</code></pre><p>Much nicer!</p><h2 id="cleaning_up">Cleaning up</h2><p>OK, now that we've written all of this parsing code, we need to put it somewhere, and our name namespace doesn't feel right. Let's create a new file to hold all of the parsing logic, <code>parser.clj</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns parser
  &#40;:require &#91;clojure.string :as str&#93;&#41;&#41;

&#40;def log-fields
  &#40;-&gt;&gt; &#91;:bucket-owner &quot;&#40;\\S+&#41;&quot;
        :bucket &quot;&#40;\\S+&#41;&quot;
        :time &quot;\\&#91;&#40;&#91;&#94;&#93;&#93;+&#41;\\&#93;&quot;
        :remote-ip &quot;&#40;\\S+&#41;&quot;
        :requester &quot;&#40;\\S+&#41;&quot;
        :request-id &quot;&#40;\\S+&#41;&quot;
        :operation &quot;&#40;\\S+&#41;&quot;
        :key &quot;&#40;\\S+&#41;&quot;
        :request-uri &quot;\&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot;&quot;
        :http-status &quot;&#40;\\S+&#41;&quot;
        :error-code &quot;&#40;\\S+&#41;&quot;
        :bytes-sent &quot;&#40;\\S+&#41;&quot;
        :object-size &quot;&#40;\\S+&#41;&quot;
        :total-time &quot;&#40;\\S+&#41;&quot;
        :turn-around-time &quot;&#40;\\S+&#41;&quot;
        :referer &quot;\&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot;&quot;
        :user-agent &quot;\&quot;&#40;&#91;&#94;\&quot;&#93;+&#41;\&quot;&quot;
        :version-id &quot;&#40;\\S+&#41;&quot;
        :host-id &quot;&#40;\\S+&#41;&quot;
        :signature-version &quot;&#40;\\S+&#41;&quot;
        :cipher-suite &quot;&#40;\\S+&#41;&quot;
        :authentication-type &quot;&#40;\\S+&#41;&quot;
        :host-header &quot;&#40;\\S+&#41;&quot;
        :tls-version &quot;&#40;\\S+&#41;&quot;
        :access-point-arn &quot;&#40;\\S+&#41;&quot;&#93;
       &#40;partition-all 2&#41;&#41;&#41;

&#40;def log-keys &#40;map first log-fields&#41;&#41;
&#40;def log-regex &#40;-&gt;&gt; log-fields
                    &#40;map second&#41;
                    &#40;str/join &quot; &quot;&#41;
                    &#40;format &quot;&#94;%s&#40;.&#42;&#41;$&quot;&#41;
                    re-pattern&#41;&#41;

&#40;defn parse-line &#91;log-line&#93;
  &#40;-&gt;&gt; log-line
       &#40;re-seq log-regex&#41;
       first
       &#40;drop 1&#41;
       &#40;map #&#40;if &#40;= &quot;-&quot; %&#41; nil %&#41;&#41;
       &#40;zipmap log-keys&#41;&#41;&#41;
</code></pre><p>Now we can use this stuff in the handler, back in <code>s3&#95;log&#95;parser.clj</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns s3-log-parser
  &#40;:require &#91;clojure.java.io :as io&#93;
            &#91;cheshire.core :as json&#93;
            &#91;com.grzm.awyeah.client.api :as aws&#93;
            &#91;parser&#93;&#41;
  &#40;:import &#40;java.time Instant
                      LocalDate&#41;&#41;&#41;

&#40;defn handler
  &#40;&#91;&#93;
   &#40;handler {} {}&#41;&#41;
  &#40;&#91;event&#93;
   &#40;handler event {}&#41;&#41;
  &#40;&#91;event &#95;context&#93;
   &#40;log &quot;Invoked with event&quot; {:event event}&#41;
   &#40;try
     &#40;let &#91;date &#40;get-date &#40;get-in event &#91;&quot;queryStringParameters&quot; &quot;date&quot;&#93;&#41;&#41;
           logs &#40;list-logs date&#41;
           log-entries &#40;-&gt;&gt; logs
                            &#40;mapcat get-log-lines&#41;
                            &#40;map parser/parse-line&#41;&#41;
           body {:date &#40;str date&#41;, :logs logs, :entries log-entries}&#93;
       &#40;log &quot;Successfully parsed logs&quot; body&#41;
       {&quot;statusCode&quot; 200
        &quot;body&quot; &#40;json/encode body&#41;}&#41;
     &#40;catch Exception e
       &#40;log &#40;ex-message e&#41; &#40;ex-data e&#41;&#41;
       {&quot;statusCode&quot; 400
        &quot;body&quot; &#40;ex-message e&#41;}&#41;&#41;&#41;&#41;

&#40;comment

  &#40;handler {}&#41;
  ;; =&gt; {&quot;statusCode&quot; 200,
  ;;     &quot;body&quot;
  ;;     &quot;{\&quot;date\&quot;:\&quot;2022-09-02\&quot;,\&quot;logs\&quot;:&#91;\&quot;logs/2022-09-02-00-12-48-36034402F760D842\&quot;,\&quot;logs/2022-09-02-00-13-47-48D19FF5B34710F9\&quot;&#93;,\&quot;entries\&quot;:&#91;{\&quot;request-uri\&quot;:\&quot;HEAD /blog/2022-08-26-doing-software-wrong.html HTTP/1.1\&quot;,\&quot;request-id\&quot;:\&quot;BC0KZGTDKR1QP07G\&quot;,\&quot;user-agent\&quot;:\&quot;Amazon CloudFront\&quot;,\&quot;remote-ip\&quot;:\&quot;64.252.89.133\&quot;,\&quot;key\&quot;:\&quot;blog/2022-08-26-doing-software-wrong.html\&quot;,\&quot;host-header\&quot;:\&quot;jmglov.net.s3-website-eu-west-1.amazonaws.com\&quot;,\&quot;time\&quot;:\&quot;31/Aug/2022:23:40:32 +0000\&quot;,\&quot;operation\&quot;:\&quot;WEBSITE.HEAD.OBJECT\&quot;,\&quot;object-size\&quot;:\&quot;7536\&quot;,\&quot;http-status\&quot;:\&quot;304\&quot;,\&quot;total-time\&quot;:\&quot;34\&quot;,\&quot;host-id\&quot;:\&quot;ApxkRhuJl/C73ZiR70xT6Stn2b1RkIcDPMnapeH5kWWQ+mT41qXfNeLaqpMc3j+5WCnrqoJH8N0=\&quot;,\&quot;bucket\&quot;:\&quot;jmglov.net\&quot;,\&quot;bucket-owner\&quot;:\&quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188\&quot;},{\&quot;request-uri\&quot;:\&quot;GET / HTTP/1.1\&quot;,\&quot;request-id\&quot;:\&quot;M987YV3YB7DHBWA3\&quot;,\&quot;user-agent\&quot;:\&quot;Amazon CloudFront\&quot;,\&quot;remote-ip\&quot;:\&quot;64.252.73.234\&quot;,\&quot;key\&quot;:\&quot;index.html\&quot;,\&quot;host-header\&quot;:\&quot;jmglov.net.s3-website-eu-west-1.amazonaws.com\&quot;,\&quot;time\&quot;:\&quot;31/Aug/2022:23:14:14 +0000\&quot;,\&quot;operation\&quot;:\&quot;WEBSITE.GET.OBJECT\&quot;,\&quot;object-size\&quot;:\&quot;3459\&quot;,\&quot;http-status\&quot;:\&quot;304\&quot;,\&quot;total-time\&quot;:\&quot;18\&quot;,\&quot;host-id\&quot;:\&quot;14MYY/WyblbL1WgULsK86Cwwtn+tHCOgs+Y98xIkC/EIwkqMeN/SWpBsF6x2gC1Tir7DYRc/+Zk=\&quot;,\&quot;bucket\&quot;:\&quot;jmglov.net\&quot;,\&quot;bucket-owner\&quot;:\&quot;022d83ad6361dec3c93757e75c1c3a7982532ffdbf3bf87976490873591e2188\&quot;}&#93;}&quot;}

  &#41;
</code></pre><p>Now that everything looks good, we can go back to the Lambda console and try it out. We'll need to add our <code>parser.clj</code> file to the code, which we can do by using the <strong>File > New File</strong> menu option in the <strong>Code source</strong> section of the console, pasting in the contents of <code>parser.clj</code>, and then doing **File > Save As...** and entering "parser.clj" as the filename. We now need to paste the contents of <code>s3&#95;log&#95;parser.clj</code> into that file in the console, then press the <strong>Deploy</strong> button to put the new code out into the world.</p><p>Finally, we can take a deep breath, position the mouse cursor over the <strong>Test</strong> button, close our eyes, and click... victory!</p><p><img src="assets/2022-09-02-parsed.png" alt="Screenshot of the AWS Lambda console showing a test result with parsed
lines" title="Now what to do with this stuff?" width=800px /></p><p>Of course, this is a really clunky and error prone way to deploy code. In the next instalment of Dogfooding Blambda, we'll build a production grade deployment framework.</p><p>But for now, let's rest on our laurels so I can go eat some lunch. 😉</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-31-keep-on-running.html</id>
    <link href="https://jmglov.net/blog/2022-08-31-keep-on-running.html"/>
    <title>Keep on running</title>
    <updated>2022-08-31T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Yesterday's <a href='2022-08-27-most-important-first.html'>most important thing</a> was going to my local Bauhaus (a home improvement / building supply store) to buy some clamps so that I could repair two pairs of sunglasses that I broke playing football at the beach with my son (for some reason, I can't resist a header, even when I know that each header has about a 5% chance of hitting the top of my sunglasses' frame, and each such hit has about a 20% chance of cracking the plastic). Since Monday's football practice was cancelled due to insufficient numbers (turns out that over-35 dudes are sometimes a bit lazy), I felt that I needed some exercise, so I decided to run there and back instead of taking my bike.</p><p>It turns out that it's about six kilometres to the store, at least the way I went. I knew ahead of time that it was a decent distance, and I had committed to doing a nice long run, so that was just fine by me. I hate retracing my steps when I run, so I took a different route back, which turned out to be shorter. I realised I was only one kilometre from my house after running only about four kilometres, so I decided that rather than running straight home and accepting an 11 km run—I had my heart set on 12+ km—I'd hang a left through the forest and tack on a bit more distance.</p><p>I use Runkeeper to tell me my distance and pace every 500 metres, and I must have missed the 10.5 and 11 km notices (lost in a podcast I was listening to about the <a href='https://en.wikipedia.org/wiki/French_Constitution_of_1793'>French Constitution of
1793</a>, no doubt), so I was a bit annoyed that I hadn't added as much distance as I thought by detouring through the woods, so instead of heading straight onto my house, I took another left turn down a long hill (knowing full well that meant I would have to run back up the same long hill further on in the run 😬). A little over halfway down the hill, Runkeeper informed me that I had now run 11.5 km, which was a pleasant surprise for me.</p><p>Feeling satisfied, I finally turned for home, which I knew to be about 2 km away. When I arrived home and took a look at my final stats, this is what I saw:</p><p><img src="assets/2022-08-31-running.png" alt="A screenshot of Runkeeper showing 13.52 km distance, 1:20:24 time, 5:57
min/km, 1119 calories, 2nd fastest run of 11-17 km, and -0.03 from best time,
with a temperature of 17° C, 62% humidity, and a 20 km/h wind from the
north" title="Not too shabby!" width="800px" class="border"] /></p><p>I was quite happy with this, until I saw that it was only my second longest run, and only my second fastest in this distance range. Noooooooo!!! This is the curse of metrics: you're feeling one way, then the cold hard facts slap you right in the face. 😉</p><p>For those of you who are runners, 13.5 km isn't that long, and 5:57 min/km is definitely not that fast, but it is an achievement for me nonetheless, and I'll explain why once I return from the dentist's appointment that I completely forgot I had until 20 minutes ago, when my calendar thankfully reminded me. 😅</p><p><img src="assets/2022-08-31-time.gif" alt="A clock with other clocks swirling around it" title="Time keeps on slipping into the future" width=800px] /></p><p>OK, I'm back, and with no cavities! 🎉</p><p>So, I was about to tell you why my not so long not so fast run was so pleasing to me. As I've most likely previously mentioned, I play football (soccer) with my neighbourhood club's over-35 men's team, which they euphemistically call the "veteran's team." About five years ago, I was at training, as I am every Monday night unless it's snowing too hard to see the ball. I call it training, but we actually just play 7- or 9-a-side matches against either other. In any case, we were playing a training match, and there was one of those 50/50 balls that a guy on the opposing team and I were both running towards. I was pretty sure I could win it, so I slid in, but at the last minute, wasn't sure I was going to get it, so I pulled out of the slide tackle a bit so I wouldn't catch the other guy.</p><p>As any good football pundit will tell you, pulling out of a tackle you've previously committed to is when you get hurt, and that imaginary pundit was right on this occasion. I kinda got the ball and kinda got the guy, and was rewarded by the guy falling onto my right knee, which bent a bit sideways. I knew it wasn't great when it happened, and knew that it was actually kinda bad when I stood up and put some weight onto it. I did the responsible thing and stopped playing... outfield. I switched to goalkeeper for the rest of the training session, and then limped over to my car to go home. At least I hadn't taken my bike to training on that particular day!</p><p>By the next morning, my knee was a little swollen, and putting weight on it wasn't really a thing I could do, so I did the responsible thing and grabbed a bag of frozen peas out of the freezer and attached it to my knee with one of those elastic bandage things. I figured I'd keep an eye on it and go to the doctor if it was still hurting at the end of the week (this was Tuesday morning). Tragically, this was years before working from home was a normal thing to do for most people, so I had to take a vacation day from work (we have 30 over here in Sweden, so I don't use sick days unless I'm out for a few days, since you get paid less for those).</p><p>As the week went on, the swelling went down, and the pain lessened to the point that I could limp to the bus and then limp to the train and then sit with my right leg straight out and hope the bandage on it communicated to people that I had a legitimate medical reason for having my leg stuck out a bit into the aisle and therefore would not choose to accidentally on purpose trip over my leg to punish me for being an inconsiderate prick.</p><p>By Monday of the next week, I could bend my knee a little bit, so I decided that I was healing and didn't need any medical intervention. I did actually do the responsible thing this time and skipped football training, since I could barely walk and definitely couldn't run or kick a ball with my right foot, or even plant my right foot to kick a ball with my left because my right knee didn't like it when I asked it to hold up the 70+ kilograms of the rest of my body all by itself.</p><p>I ended up staying away from training for nine months or so whilst my knee healed. For the first four months or so, I didn't even go out with my son for a kickabout, which is something that we did at least once a weekend normally. When I resumed kicking the ball with him, I only used my left foot, which was at least good practice, since I'm right footed normally.</p><p>When I did start going back to training, I noticed that things would be fine for the first hour, then my knee would start hurting and I'd need to go in goal for the last half hour. I also started running again, and found that I could do three kilometres at a decent pace before my knee started hurting. Strangely enough, I started to realise that it wasn't always my right knee hurting; now it was occasionally my left. Amateur doctor that I am, I decided that nine months of favouring my left had put too much strain on the ligaments around that knee. I asked my friend Caroline, who is a doctor and also one hell of a distance runner, and she said that it was a plausible explanation, but that I really should see a specialist.</p><p>I knew she was right, so I did the responsible thing and booked an appointment with a specialist... a few months later. In fact, I didn't do it until my masseuse, who is also a physical therapist, told me that she was going to yell at me every time I came in for a massage until I went to see the knee doctor. Unwilling to incur her wrath, I finally made the appointment and went to see the doctor.</p><p>The doctor turned out to be this no-nonsense German woman, pleasant and friendly, but not the sort of person whose advice you'd dare ignore. She had me sit down on the examination table, took one look at my knees, and said, "OK, I know what the problem is." She hadn't as much as examined the knees with her hands yet. "I'm going to take an ultrasound to be sure," she continued. She put some of that cold gel on my knees and moved the wand around, then motioned me over to her computer screen as soon as the images came up.</p><p>"You see this bit here?" she asked. "That's your kneecap. Now this white stuff here is your <a href='https://en.wikipedia.org/wiki/Vastus_lateralis_muscle'>vastus
lateralis</a> muscle. See this gap between the muscle and the kneecap? That's not supposed to be there."</p><p>Apparently, when I hit my growth spurt sometime in my mid teens, my legs grew slightly too fast, and the vastus lateralis muscle, which is supposed to attach to the femur right above the kneecap and keep the kneecap from moving side to side when you run and walk, attached a bit further up, leaving this gap that allowed my kneecap to move laterally. At that age, the cartilage around the kneecap was strong enough to compensate, and since I kept quite active as an adult, with lots of running and sports and stuff, the cartilage stayed strong. But when I hurt my knee playing football and stopped running for nine months, the cartilage weakened and couldn't keep the kneecap in place, thus putting too much strain on my ligaments, which would start aching so that I would stop hurting them.</p><p>I was really impressed that the doctor could tell all of this just from looking at my thigh, and I told her as much. She laughed and said, "I assume you're pretty good at your job, right? Well, I'm pretty good at mine." Mic drop.</p><p>Anyway, she told me that it was nothing to worry about, and I just needed to go to a physical therapist and rehabilitate my knees so I'd be able to run without pain. She referred me to someone near my office and sent me on my way.</p><p>The physical therapist gave me a programme of exercises to do that would strengthen my cartilage (I didn't even know you could do that!) and recommended that I start going to the gym three times a week to do them, something that I had successfully avoided in my life to that point. I sucked it up and got a gym membership and became one of those people who go to the gym all the time, but at least I didn't become one of those people who actually enjoy it and can't stop telling everyone else how they go to the gym all the time.</p><p>I did this for about a year, and finally got to the point where I could make it through 90 minutes of football training and 5 km of running without pain, and then the pandemic hit. 🤦🏼</p><p>So I let my gym membership expire and haven't been back there since, but I have been running a few times a week, slowly building up distance. Last summer, I came up with the idea of running to the beach, which is a little over 4 km away from my house, taking a swim, and then running back. I found that I could do this without much pain (and my physical therapist had assured me that running through a little pain wouldn't do any harm), and really enjoyed being able to cool off in the middle of the run. As summer turned into autumn and people disappeared from the beach, I also found that I loved swimming in an almost deserted lake. In fact, when the weather was cold and grey and a little rainy, it would just be me and the geese at the beach.</p><p>I kept up this routine until the beginning of November, when the water temperature dropped to about 10° C and I just couldn't anymore. 😅</p><p>Sometime in the winter, my friend Pippa convinced me that the human body was actually capable of regulating its internal temperature when it was cold outside, and we'd just gotten all civilised and soft and forgotten how to do it, so I started slightly under-dressing to train my body to handle the cold. She also introduced me to a friend of hers who went in the water every single weekend, all year round. I couldn't quite imagine walking into water so cold that you had to break the ice just to get in, but towards the end of March, Pippa and Simon invited us for a cookout at the beach near their house, and said that we'd be going on a 3 km run finishing at the lake, and that the last person to run into the water was the loser and would have to wear a rubber chicken on their head for the rest of the day.</p><p>I ended up finishing last, but I did run into that water, and oh my was it ever cold! According to Pippa, it was 6° C, in fact. My sense of pride at having gone into water that cold more than offset the shame of wearing the rubber chicken, and I decided to run down to the beach near my house the next weekend and go in. Which I've been doing ever since, every weekend, rain or shine. In fact, rain is even better, since that keeps the people away and lets me enjoy the beauty of nature.</p><p>I like running, but I do find it boring to always run the same route, so I started looking for different routes to the beach. It turns out that there are many, so I started varying my route and adding a bit of distance on. I found one route that was 10 km, which was the longest I'd run since my knee injury. Last weekend, I decided to do a little more than that, so instead of taking the path straight up into the woods from the beach, I took the path that went along the shoreline, and ran about a kilometre down it before turning into the woods and towards my house. By the time I got home, I'd done 12 km!</p><p>So yesterday, I was determined to beat that. And I did it! Not only did I run 1.5 km farther, I also ran it faster—5:57 min/km as opposed to 6:09 min/km last weekend!</p><p>So despite my run being short and slow by the standards of most serious runners my age, I'm pretty pleased with myself! 🙂</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-29-whats-most-important.html</id>
    <link href="https://jmglov.net/blog/2022-08-29-whats-most-important.html"/>
    <title>What's the most important thing?</title>
    <updated>2022-08-29T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>In the spirit of <a href='2022-08-27-most-important-first.html'>doing the most important thing
first</a>, I started my "workday" (I'm on vacation for one more week, but I have non-recreational things that I want to do, which I define as "work" relative to the other stuff I'm doing) by creating a Github repo for this writing project I'm working on with Ray. I've typically used Google Docs for this sort of thing in the past, so I'm interested to see how a git-based model will work.</p><p>What I'd like to talk about today, however, is how I decided that creating a repo was the most important thing I wanted to do today. Let's start by stipulating a few things:</p><ul><li>There are some things I want to do and some things I have to do</li><li>I have a certain number of hours in any given day that I can dedicate to doing  these things</li><li>It is not possible to do all of these things within the allotted time in any  given day</li></ul><p>Given these constraints, how do I decide how to prioritise these things?</p><p><img src="assets/2022-08-29-bulletin-board.png" alt="A woman standing in front of a bulletin board covered in notes" title="Photo by Brandon Lopez on Unsplash" width=800px] /></p><p>Remember, we're not talking here about how to do the things or even in which order to do the things (though I've established my position on the order: do the most important thing first), but just to decide the relative priority of these things.</p><p>Most of the time, we tend to take a rather haphazard approach to prioritising things, at least in an individual context. Software teams usually have a somewhat more formal method, though I would argue that these formal methods are often just a haphazard approach in disguise. 😉 In this post, I'm explicitly focusing solely on prioritising tasks that have no external dependencies (meaning that you can't be blocked by waiting for something outside of your control to happen before you can make progress on the task) and where you have total freedom to decide on the priority.</p><p>Let's look at a few different frameworks for approaching this, using the following list of tasks as an example:</p><ul><li>Create a Github repo for the aforementioned writing project</li><li>Find out when and where to vote early in the upcoming Swedish elections</li><li>Finish my command-line interface work on  <a href='https://github.com/borkdude/quickblog'>quickblog</a></li><li>Write this blog post</li><li>Clean up the kitchen</li><li>Vacuum the apartment</li><li>Evaluate signing up for the hellofresh, which is one of those services that  delivers you a week's worth of ingredients and recipes for suppers so that you  can try new things and are freed from the tyranny of choice, at least  temporarily and for this one limited thing</li><li>Change the crystals in my humidor</li></ul><h2 id="the_eisenhower_matrix">The Eisenhower Matrix</h2><p>One of the easiest to understand frameworks is the so-called <a href='https://todoist.com/productivity-methods/eisenhower-matrix'>Eisenhower
Matrix</a> (also known as the Urgent-Important Matrix). The idea is that any given task can be either important or not, and either urgent or not, so you can draw a matrix with four quadrants like so:</p><p><img src="assets/2022-08-29-matrix1.png" alt="A matrix with four quadrants: important and urgent in the top left; important but not urgent in the top right; not important but urgent in the bottom right; not important and not urgent in the bottom right" title="The Matrix"] /></p><p>The Eisenhower Matrix is designed to combat the "<a href='https://hub.jhu.edu/2018/05/31/meeting-deadlines-time-management-behaviors/'>mere urgency
effect</a>", which, according to the paper which named the phenomenon (written by Meng Zhu, an associate professor of marketing at the Johns Hopkins business school):</p><blockquote><p> can lead people to work on an unimportant chore instead of a more essential  one. They do so, not because they have a logical reason—such as judging the  task easier to complete, wanting an immediate reward, or planning to get the  chore done before moving on to the more important job—but simply because they  feel they must beat an illusionary urgency (even when the task's duration is  shorter than the deadline provided). </p></blockquote><p>In order to use this method, the first thing I do is classify each of my tasks as important or not and urgent or not:</p><ul><li>Github repo: important because I really want to work on this writing project,  and urgent because I can't get Ray's input until I've set up the repo.</li><li>Voting info: important because I want to vote early, but not urgent because if  I don't vote early, I can just go to the polling place on election day itself,  which is 11 September.</li><li>quickblog CLI: important because I want to add some new features to my blog  and I can't do that until I finish up this work, but not urgent because I'm  not blocking anyone but myself.</li><li>Blog post: important because I want to keep up the habit of writing and urgent  because I want to finish this today.</li><li>Clean kitchen: not important to me because it's not really that bad and I can  just close my eyes when I walk in there so I'm not bothered by a few dirty  dishes in the sink, but urgent because if I don't do it before this afternoon,  the dishwasher won't be done in time for supper.</li><li>Vacuum: not important to me because it's not really that bad and I can just  close my eyes when I walk around the house so I'm not bothered by a few dust  bunnies here and there, but urgent because if I don't do it before 9 PM, I'll  have to wait until tomorrow since we're kindly asked not to vacuum or do  laundry between 9 PM and 7 AM so as not to potentially disturb people whilst  they're trying to sleep.</li><li>hellofresh: not important because we are capable of figuring out what to eat  for supper without it being decided for us, and not urgent because this coupon  that I have for a discounted trial doesn't have an expiration date.</li><li>Humidor: not important because the cigars in the humidor aren't nice enough  that I'll notice if they're slightly drier than they should be, and not urgent  because the humidity is still 66% and the recommended range is 65-75%.</li></ul><p>Putting that into the matrix, it looks like this:</p><p><img src="assets/2022-08-29-matrix2.png" alt="Matrix filled in. Important and urgent: Github repo, blog post; important but not urgent: voting info, quickblog CLI; not important but urgent: clean kitchen, vacuum; not important and not urgent: hellofresh, humidor" title="The Matrix Reloaded"] /></p><p>And finally, I use the matrix to decide what to do with each task. According to the framework, I should do the tasks that are in the urgent and important quadrant, schedule the tasks that are in the important but not urgent quadrant, delegate the tasks that are in the urgent but not important quadrant, and ignore the tasks that are neither important nor urgent (these quadrants are usually labelled do, schedule, delegate, and delete, but I'm not using any fancy software to manage these tasks, so deletion doesn't really make sense, and the word "ignore" just gives me a feeling of superiority over these damned tasks which are not the boss of me!).</p><p><img src="assets/2022-08-29-matrix3.png" alt="Matrix labeled "do" in the top left; "schedule" in the top right; "delegate"
in the bottom left; and "ignore" in the bottom right" title="The Matrix Revolutions"] /></p><p>Based on this, I decided to create the Github repo (already done!) and write this blog post (in progress as I type this but done by the time you read it) before I turned my attention to any of the other stuff. I arbitrarily decided that I'd create the repo first because I figured it would take at most five minutes to do and fill me with a sense of accomplishment which would power my blogging.</p><p>According to the framework, I should schedule the tasks that are important but not urgent, so I'll decide to do them "later" (either after I finish the urgent and important stuff or tomorrow if I run out of time today). I should delegate the urgent but not important tasks, which in my case means asking my son to do them. Actually, emptying the dishwasher is already one of the chores that he's supposed to do in exchange for his monthly allowance, so if he does that, I can reload the dishwasher in five or 10 minutes. Score! I could try and make a deal with him for the vacuuming, but I'm not sure if he'll have time, so I might end up having to do that one myself.</p><p>Finally, the tasks that are not urgent and not important can be ignored. I know that the humidor task will eventually become urgent when the humidity drops below 65%, but that probably won't be for a few weeks, and I'll notice the needle entering the grey at some point, so I can rest comfortably. As for the looking into hellofresh, meh.</p><p>So the Eisenhower Matrix has resulted in a reasonable plan for the day. Let's take a look at one other framework before I run out of fuel and need to go eat lunch.</p><h2 id="action_priority_matrix">Action Priority Matrix</h2><p>This is another 2x2 matrix, but instead of the quadrants being defined by importance and urgency, they're defined by impact and effort:</p><p><img src="assets/2022-08-29-matrix4.png" alt="A matrix with four quadrants: high impact and low effort in the top left; high
impact but high effort in the top right; low impact but low effort in the bottom
right; low impact but high effort in the bottom right" title="A little more action's what I need"] /></p><p>Let's do the impact / effort analysis on my task list:</p><ul><li>Github repo: high impact because it lets Ray and I get started on our project,  and low effort because I just have to click a few buttons.</li><li>Voting info: low impact because I already have a way to vote, but low effort  because if I open the envelope containing my röstkort ("voting card" in  Swedish), it probably tells me exactly what to do. Of course, it tells me that  in Swedish, which is not necessarily low effort for me to read, but I probably  can get away with just scanning it for an address and date range, so yeah, low  effort.</li><li>quickblog CLI: low impact because it's basically just making quickblog a  little more ergonomic to the handful of people that use it, and high effort  relative to the other tasks on the list.</li><li>Blog post: high impact because it reinforces a habit that I'm trying to build  and helps me improve as a writer through practice, and high effort relative to  the other tasks on the list.</li><li>Clean kitchen: low impact because the stupid kitchen will just get dirty  again, but low effort because unloading and reloading the dishwasher will take  me max 20 minutes and doesn't require any thinking.</li><li>Vacuum: low impact because the stupid house will just get dirty again, and  high effort relative to the other tasks on the list.</li><li>hellofresh: low impact because we already have a way to produce and  subsequently consume suppers, but low effort because I just have to scan a QR  code and then read some stuff on a website to decide.</li><li>Humidor: low impact because my cigars are already in the recommended humidity  range, but low effort because it will take me around 10 minutes to find the  jar of silicon crystals, take the dry ones out of my humidor, and replace them  with wet ones.</li></ul><p>Putting that into the matrix, it looks like this:</p><p><img src="assets/2022-08-29-matrix5.png" alt="Matrix filled in. high impact, low effort: Github repo; high impact, high
effort: blog post; low impact, low effort: voting info, clean kitchen,
hellofresh, humidor; low impact, high effort: quickblog, vacuum" title="Will action lead to satisfaction?"] /></p><p>According to the framework, I should focus on high impact, low effort tasks ("quick wins") as much as I can; consider when / whether to do the high impact, high effort tasks ("major projects"); drop or delegate low impact, low effort tasks ("fill-ins") or work on them if and when you have time; and try to avoid low impact, high effort tasks ("thankless tasks"). If I took this approach, my day would look like this:</p><ul><li>Create the Github repo</li><li>Consider writing the blog post (assume I considered it and decided to do it,  since you're now reading it)</li><li>Drop or delegate cleaning the kitchen (delegate to my son FTW!), looking for  the info on early voting, looking into hellofresh, and changing the crystals  in my humidor.</li><li>Try to avoid working on quickblog and vacuuming</li></ul><h2 id="comparison">Comparison</h2><p>So my Eisenhower plan looks like this:</p><ol><li>Github repo</li><li>Blog post</li><li>Delegate cleaning the kitchen to my son</li><li>Suck it up and vacuum</li><li>Voting info</li><li>Work on quickblog</li><li>Ignore hellofresh and the humidor</li></ol><p>And my Action Priority Matrix (APM) plan looks like this:</p><ol><li>Github repo</li><li>Blog post</li><li>Delegate cleaning the kitchen to my son</li><li>Look into voting info and hellofresh and change my humidor crystals if I have   time</li><li>Try to avoid working on quickblog and vacuuming</li></ol><p>These plans both point to creating the repo first and then writing the blog post, but then deviate a bit from there. Here's what action I should take on each task according to the two frameworks looks like:</p><ul><li>Github repo - Eisenhower: do; APM: do.</li><li>Voting info - Eisenhower: schedule; APM: drop or do if there's time.</li><li>quickblog - Eisenhower: schedule; APM: try to avoid.</li><li>Blog post - Eisenhower: do; APM: consider doing.</li><li>Clean up the kitchen - Eisenhower: delegate; APM: delegate.</li><li>Vacuum the apartment - Eisenhower: delegate (or do, if I can't delegate); APM:  try to avoid.</li><li>hellofresh - Eisenhower: ignore; APM: drop or do if there's time.</li><li>Humidor - Eisenhower: ignore; APM: drop or do if there's time.</li></ul><p>These frameworks yield slightly different results, and in fact they are intended for slightly different contexts. According to the <a href='https://monday.com/blog/project-management/action-priority-matrix-vs-eisenhower-matri/'>mondayblog</a>, the Eisenhower matrix is most useful for daily tasks (yay!), decision-making, and activities with deadlines and the Action Priority Matrix is most useful for business operations, product features, and large initiatives (oops!).</p><h2 id="summary">Summary</h2><p>The Eisenhower matrix yielded what I consider a pretty reasonable plan for the day, and one which I intend to implement. In fact, I've already done the first task, and almost finished the second. I'm going to eat lunch now, after which I'll suck it up and vacuum, and then if there's time left, I'll open the voting envelope and then possibly try to finish up the quickblog work. I'll happily ignore the other two tasks on the list, whatever they were. 😉</p><p>I was almost certainly misusing the Action Priority Matrix, but it's interesting that it pointed to the same two tasks to do first, and I learned about a new approach to prioritising that I hadn't heard of before. I actually found it when I was searching for another framework that my previous boss told me about, but which I only apparently bookmarked in my work account or Slack or something, and since I don't work there anymore, I can't access that, and can't really remember enough about the framework to guess at what it's called (it was something like writing down the stuff you have to do, the stuff you want to do, and ???).</p><p>Anyway, off to my peanut butter and jelly sandwich now, ktnxbye.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-27-most-important-first.html</id>
    <link href="https://jmglov.net/blog/2022-08-27-most-important-first.html"/>
    <title>Do the most important thing first</title>
    <updated>2022-08-27T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Like many people, I have many things I want to do, and many fewer hours available in which to do the things than would be required to do all the things. Hence, some prioritising is necessary. I'll handwave over how to do the prioritising today, because that would be a longer post that would require research and citations and—surprise surprise!—I don't have that kind of time today. Or rather I should say that I am not willing to allocate that amount of time to that today. Time can't be made, and we have as much as there is in a day, so it's all about deciding how to use that time.</p><p>Assuming that one has prioritised what one wants to do, the best advice that I've ever seen on how to actually get it done is this: do the most important thing first. Not the quickest thing, not the most satisfying thing; the most important thing. This means that whatever else happens in your day, you are guaranteed to make at least some progress on the thing you've decided is most important.</p><p>As I am an expert in not following my own advice, I did not do the most important thing first today, which is why you're getting this dashed off (or 📱'd 📥, if you wanna be mean about it, Ray) post instead of something more insightful or thought-provoking.</p><p>And now I'm off for a quick run before I go over to a friend's place to play some Dominion!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-26-doing-software-wrong.html</id>
    <link href="https://jmglov.net/blog/2022-08-26-doing-software-wrong.html"/>
    <title>We're doing software wrong</title>
    <updated>2022-08-26T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I have a friend named Ray, and Ray has a dog named Eula (yes, that <a href='https://en.wikipedia.org/wiki/End-user_license_agreement'>EULA</a>, and yes, Ray enjoys making bad jokes). As some of you may know, I also have a dog, whose name is Rover:</p><p><img src="assets/2022-08-26-rover-fox.jpg" alt="A dog sleeps on a bed, hugging a stuffed toy fox" title="To sleep perchance to dream" width=800px] /></p><p>People with dogs must walk these dogs several times a day, and since, as previously established, Ray and I are people with dogs, we walk them several times a day. Sometimes, these walks are coordinated (either by chance or by design) to allow us to chat together whilst we walk. Since Ray lives in Belgium and I live in Stockholm, we must rely on the magic of technology to enable these conversations.</p><p>Actually, I was just thinking about that the other day. I spent a year studying abroad in Japan in the year 2000 BS (Before Skype), and whilst <a href='https://en.wikipedia.org/wiki/Voice_over_IP'>VOIP</a> did exist, you either needed expensive hardware or a Linux box with complicated software that only the geekiest of Linux geeks could make work. My parents had neither the hardware nor the Linux expertise, so when I wanted to talk to them, I had to use this legacy thing called a "tele-phone". It's like a phone, but without the screen and the Twitter and all of that. And international calls were bloody expensive! I bought these phone cards for ¥5000&ndash;about $50&ndash;which had about 15 minutes of credit on them. And now it's just free to call anyone with an Internet connection and an app. 🤯</p><p>In any case, Ray and I walk and talk once a week or so, and the conversations go all over the place. There's a lot of Clojure (Ray's also a Clojure dev), a lot of politics, and some existential rambling. It was on a walk a couple of weeks back that we came to the shocking conclusion that we're doing software all wrong. We've been talking it through a bunch since then, and are trying to figure out how to explain this to people who are not us. When we do figure it out, expect a series of blog posts on the subject. 😀</p><p>It's really cool to have an opinionated friend that I can talk to who isn't afraid to disagree with me or point to flaws in my reasoning. Disagreements in good faith are one of the best ways to develop ideas, discard ideas, or figure out how to explain ideas. Of course, we don't disagree about everything; in fact, it's more common that one of us says something that the other one has thought a little about, then we riff on it and explore it.</p><p>The other requirement for this to work is that both you and the other person have to be genuinely interested in what the other person is saying, and listen actively. It's so common for a discussion to be more like waiting for your turn to talk instead of actually listening. There's this pithy little saying something like "it's the responsibility of the communicator to be understood," and of course I get the idea behind it, but like so many pithy little sayings, it leaves out the context and in so doing, absolves the listener from their responsibility in the conversation.</p><p>That's a topic for another day, actually. We have loads of pithy little sayings in software like "don't repeat yourself" and "if you can't measure it, you can't manage it," and I think they do tremendous harm when misapplied, which is most of the time.</p><p>That's it for today. Just remember: a stitch in time saves nine!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-25-scientific-music.html</id>
    <link href="https://jmglov.net/blog/2022-08-25-scientific-music.html"/>
    <title>Scientific Music</title>
    <updated>2022-08-25T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Before I get into what I'm actually planning to talk about in this post, allow me to make a variety of excuses for my long silence here:</p><ol><li>I've been busy</li><li>I've been lazy</li><li>I haven't been able to think of interesting stuff to write about</li><li>Etc.</li></ol><p>And allow me to invalidate these excuses so that you don't have to:</p><ol><li>Many people are busy and yet manage to do things that they want to do. Hmm,   maybe this is something I should write about in the coming days.</li><li>"Laziness" is in the eye of the beholder, and is quite often one of the holy   words of the Cult of Productivity rather than an actual real thing that is   really real. Hmm, maybe this is something I should write about in the coming   days.</li><li>When has "interesting" ever been a prerequisite for my writing? I am very   fond of the sound of my own voice, so I should just hold forth on any topic   that enters my mind, secure in the knowledge that I have one guaranteed   reader: me. Also, it seems like I have a few ideas on what to write about   (see points 1 and 2 above).</li><li>This is the only compelling excuse I see. Touché!</li></ol><p>OK, with all of that out of the way, I'll get onto the main story:</p><p>I have a friend from work (and the Clojure community) named Suvrat, and in addition to being a good programmer, he's also a good musician. A couple of months ago, he told me that he'd been a guest musician on this album called <a href='https://open.spotify.com/album/5lNYtd8IocZE2AbRsmM0s6'>KG
Westman & Zaعfaran</a>. I gave it a listen, and it is really cool. It's a mix of Indian and Egyptian music, using traditional instruments but modern composition, and the result is this atmospheric, haunting sound that I just can't get enough of (in fact, just talking about it made me want to listen to it, so it's now playing in the background as I write this). I really recommend checking this out. Go on, I'll wait.</p><p>Pretty awesome, right? I told you it would be. 😉</p><p>So anyway, back to Suvrat. He messaged me a few days ago and told me that he was playing with KG Westman at this cool café / cider (and maybe beer too?) brewery / farm called <a href='https://rosenhill.nu/index.php/info-in-english/'>Rosenhill</a> in nearby Ekerö at their annual Indian evening on August 24th. I told him that I would be there, and as a well-known man of my word (actually, I'm a well-known liar, so that bit about me being a well-known man of my word was a lie&ndash;as far as you know, anyway), my wife and I went out there last night.</p><p>We had some delicious Indian food (fried tofu with fresh vegetables from the farm and some sort of delicious sauce) with Suvrat and a couple of the musicians, who were kind enough to explain what we were about to hear: <a href='https://en.wikipedia.org/wiki/Indian_classical_music'>Indian
classical music</a>. Indian classical music is a rule-based improvisational form built on a melodic framework called a <a href='https://en.wikipedia.org/wiki/Raga'>raga</a> and a time measuring system called a <a href='https://en.wikipedia.org/wiki/Tala_(music'>tala</a>), and is played by a small ensemble of musicians with melodic and rhythmic instruments.</p><p><img src="assets/2022-08-25-musicians.jpg" alt="Four musicians sit on the floor in traditional Indian dress, performing music" title="Classically scientific" width=800px] /></p><p>In our case, the ensemble was two <a href='https://en.wikipedia.org/wiki/Sitar'>sitars</a> (a stringed instrument with between 18 and 21 strings that somewhat resembles a lute), a <a href='https://en.wikipedia.org/wiki/Santoor'>santoor</a> (an instrument which has 138 strings and is played on your lap using two small wooden mallets), and Suvrat himself on <a href='https://en.wikipedia.org/wiki/Tabla'>tabla</a> (a pair of hand drums).</p><p>According to Suvrat, this form of music isn't called "classical music" in India, but rather "Shastriya Sangeet" ("scientific music" in English)&ndash;in fact, the rules originate from a text written around 2200 years ago called the "<a href='https://en.wikipedia.org/wiki/Natya_Shastra'>Nāṭya
Śāstra</a>", which translates into English as "The Science of Performing Arts"&ndash;because the rules are based on math, and the players have to occasionally make mathematical computations in their heads as they play. According to Suvrat:</p><blockquote><p> When someone ends one musical sentence, they usually end it with something  called a <a href='https://en.wikipedia.org/wiki/Tihai'>Tihai</a> (pronounced: Ti-haa-ee),  and while creating these tihais, you need to compute a few things. And things  become more complicated when people "nest" these tihais, tihai in a tihai and  so on. </p></blockquote><p>Suvrat described a day of study with his teacher in India as playing for 3-4 hours, then having lunch with the teacher, who would explain little mathematical tricks during lunch that they could use, and then another 3-4 hours of playing music.</p><p>The music starts with the melodic instruments introducing the basic theme, which will repeat throughout the performance, and also introducing themselves to each other (since the music is rule-based, anyone can play together without having previously rehearsed together or even met each other). Suvrat described this as a "conversation in the language of music," which at first sounded very abstract, but absolutely made sense when I heard it. Through most of this opening section, which lasted about 15-20 minutes, one musician played at a time for a period of between just a few seconds and tens of seconds, like they were making a point, and then another player would start seamlessly as the first one finished their thought. At times, two players "spoke" at the same time, trading ideas back and forth quickly. And sometimes, they literally laughed at each other, which Suvrat explained to me afterwards was because they were making some silly musical jokes.</p><p>After the opening part, the percussion joined in, slowly at first, and then getting up to speed. At this point, all of the musicians were playing at the same time. For me, a first time listener, the music seemed to build organically without me noticing, and then at some point, I felt like I was enveloped in this rich tapestry of sound, which was beautiful as a whole, but was also comprised of many individual glimmering threads that you could follow and appreciate in their own right. I've never had the sensation of being inside a piece of music before, almost as if I was somehow participating in what was going on.</p><p>It was a really amazing experience. I can't really describe how the music works&mdash;the Wikipedia articles I've been linking have more details&ndash;or what it was like to listen to, so I really encourage you to go to a live performance at some point if you can.</p><p>Or if you can't, listen to this amazing talk from EuroClojure by Srihari Sriraman called <a href='https://youtu.be/ZvSSeuzN_b4'>Making machines that make music</a>, in which he uses Clojure to teach a computer how to compose Indian classical music.</p><p>Or do both!</p><p>PS: Suvrat, if you're reading this, please correct any errors I've made and I'll update the post. 🙂</p><p>PPS: Suvrat did in fact read this and suggest some enhancements! And my friend Sajal also read it and dropped me a couple of recommendations for other stuff I might like, based on this post. 💜</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-17-hacking-blog-sharing.html</id>
    <link href="https://jmglov.net/blog/2022-08-17-hacking-blog-sharing.html"/>
    <title>Hacking the blog: social sharing</title>
    <updated>2022-08-17T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>It appears that it has been one month and one day since <a href='2022-07-15-hacking-blog-actually-caching.html'>I last hacked the
blog</a>. Hard to believe! It's easier to believe that it's been <del>five</del> six days (I started this post yesterday but didn't finish it until today 😬) since <a href='2022-08-11-dogfooding-blambda-cli-ier.html'>I last
blogged</a>. I went camping over the weekend, and still haven't finished putting my gear away! 😅</p><p><img src="assets/2022-08-17-desk.png" alt="A desk with a computer and a lot of camping gear spread all over it" title="I'll put this away any day now." width=800px] /></p><p>As much fun as I've been having with the actual blogging, I must say I've been having less fun sharing blog posts on Twitter, since when I do, the only thing I see is a boring old URL.</p><p><img src="assets/2022-08-17-boring.png" alt="A tweet with a link to one of my blog posts which is just a URL" title="Oh wow, so ex... zzz"] /></p><p>By contrast, when I share an excellent post about an excellent Arsenal performance by an excellent blogger, I see an excellent preview thingy with a picture and a title and a summary and I'm now super engaged and want to click!</p><p><img src="assets/2022-08-17-7amkickoff.png" alt="A tweet with a link to a 7amkickoff blog post with a nice image and a summary" title="Engagement reaching new heights!"] /></p><p>I want nice things too!</p><p>But luckily, since I'm the owner / operator of my blog, I can just make nice things for myself and then have those nice things (but not eat them, because apparently you can't have a thing and eat it too, because then you won't have it anymore).</p><p>The first order of business is figuring out what to search for. I tried "website thumbnail image" and found a great article by Michelle Mannering: "<a href='https://dev.to/mishmanners/how-to-add-a-social-media-share-card-to-any-website-ha8'>How to add a
social media share card to any
website</a>". OK, so let's call this thingy a "share card" from now on.</p><p>According to Michelle, these are the tags that control the share card:</p><pre class="language-html"><code class="lang-html language-html">    &lt;!-- Primary Meta Tags --&gt; &lt;!-- this is the default metadata which all websites can draw on --&gt; 
    &lt;title&gt;YOUR&#95;WEBSITE&lt;/title&gt;
    &lt;meta name=&quot;title&quot; content=&quot;YOUR&#95;HEADING&quot;&gt;
    &lt;meta name=&quot;description&quot; content=&quot;YOUR&#95;SUMMARY&quot;&gt;

    &lt;!-- Open Graph / Facebook --&gt; &lt;!-- this is what Facebook and other social websites will draw on --&gt;
    &lt;meta property=&quot;og:type&quot; content=&quot;website&quot;&gt;
    &lt;meta property=&quot;og:url&quot; content=&quot;YOUR&#95;URL&quot;&gt;
    &lt;meta property=&quot;og:title&quot; content=&quot;YOUR&#95;HEADING&quot;&gt;
    &lt;meta property=&quot;og:description&quot; content=&quot;YOUR&#95;SUMMARY&quot;&gt;
    &lt;meta property=&quot;og:image&quot; content=&quot;YOUR&#95;IMAGE&#95;URL&quot;&gt;

    &lt;!-- Twitter --&gt; &lt;!-- You can have different summary for Twitter! --&gt;
    &lt;meta name=&quot;twitter:card&quot; content=&quot;summary&#95;large&#95;image&quot;&gt;
    &lt;meta name=&quot;twitter:url&quot; content=&quot;YOUR&#95;URL&quot;&gt;
    &lt;meta name=&quot;twitter:title&quot; content=&quot;YOUR&#95;HEADING&quot;&gt;
    &lt;meta name=&quot;twitter:description&quot; content=&quot;YOUR&#95;SUMMARY&quot;&gt;
    &lt;meta name=&quot;twitter:image&quot; content=&quot;YOUR&#95;IMAGE&#95;URL&quot;&gt;
</code></pre><p>(The article actually says <code>&lt;meta property=&quot;twitter:...&quot;&gt;</code>, but according to Twitter's <a href='https://developer.twitter.com/en/docs/twitter-for-websites/cards/guides/getting-started'>Cards
documentation</a>, it should be <code>&lt;meta name=&quot;twitter:...&quot;&gt;</code>, so I'll use that instead.)</p><p>If I slap these tags into the <code>&lt;head&gt;</code> of my document, I should win!</p><p>But what to put in the content of these tags? Let's take them one by one, using the 7amkickoff sharing card as a reference:</p><ul><li><code>YOUR&#95;HEADING</code>: "Summer days". This looks like the page title.</li><li><code>YOUR&#95;URL</code>: ???. I guess this is the page URL.</li><li><code>YOUR&#95;SUMMARY</code>: "Summer days are meant to be spent doing something quiet in  the early morning, followed by...". OK, this is a preview of the post's  content.</li><li><code>YOUR&#95;IMAGE&#95;URL</code>: logo with the "7". The thumbnail image (called a "featured  image" by Wordpress and Medium, IIRC).</li></ul><p>Now we can go page by page, filling these in as we go.</p><ol><li>Index page:<ul><li><code>YOUR&#95;HEADING</code>: page title     (<a href='https://github.com/borkdude/quickblog'>quickblog</a>'s <code>:blog-title</code> key)</li><li><code>YOUR&#95;URL</code>: page URL (quickblog: <code>:blog-root</code> + "index.html")</li><li><code>YOUR&#95;SUMMARY</code>: let's use a description of the blog here (quickblog:     <code>:blog-description</code>)</li><li><code>YOUR&#95;IMAGE&#95;URL</code>: we can put a blog logo here (let's add a new     <code>:blog-image</code> key to quickblog)</li></ul></li><li>Archive page:<ul><li><code>YOUR&#95;HEADING</code>: page title (quickblog: <code>:blog-title</code> + " - Archive")</li><li><code>YOUR&#95;URL</code>: page URL (quickblog: <code>:blog-root</code> + "archive.html")</li><li><code>YOUR&#95;SUMMARY</code>: (quickblog: "Archive - " + <code>:blog-description</code>)</li><li><code>YOUR&#95;IMAGE&#95;URL</code>: (quickblog: <code>:blog-image</code>)</li></ul></li><li>Tags page (i.e. the page listing all of the tags):<ul><li><code>YOUR&#95;HEADING</code>: page title (quickblog: <code>:blog-title</code> + " - Tags")</li><li><code>YOUR&#95;URL</code>: page URL (quickblog: <code>:blog-root</code> + "tags/index.html")</li><li><code>YOUR&#95;SUMMARY</code>: (quickblog: "Tags - " + <code>:blog-description</code>)</li><li><code>YOUR&#95;IMAGE&#95;URL</code>: (quickblog: <code>:blog-image</code>)</li></ul></li><li>Tag pages (i.e. pages for individual tags with links to the posts with that   tag):<ul><li><code>YOUR&#95;HEADING</code>: page title (quickblog: <code>:blog-title</code> + " - Tag - " + tag     name)</li><li><code>YOUR&#95;URL</code>: page URL (quickblog: <code>:blog-root</code> + "tags/{{tag}}.html")</li><li><code>YOUR&#95;SUMMARY</code>: (quickblog: "Posts tagged '{{tag}}' - " +     <code>:blog-description</code>)</li><li><code>YOUR&#95;IMAGE&#95;URL</code>: (quickblog: <code>:blog-image</code>)</li></ul></li><li>Posts:<ul><li><code>YOUR&#95;HEADING</code>: page title, which is the value of the post's <code>title</code>     metadata (specified in Markdown as <code>Title: Something or other</code>, as detailed     in the <a href='2022-07-14-hacking-blog-repl.html'>very first Hacking the blog
     post</a>)</li><li><code>YOUR&#95;URL</code>: page URL (quickblog: <code>:blog-root</code> + "{{file}}.html"; assuming     the post's Markdown file is called <code>something.md</code>, <code>file</code> will be     "something")</li><li><code>YOUR&#95;SUMMARY</code>: let's add a new piece of metadata to the Markdown file     called <code>Description:</code> (I know the article I referenced is calling it     <code>YOUR&#95;SUMMARY</code>, but I figure it's less surprising for this to match the     name of the meta tags where we'll put it)</li><li><code>YOUR&#95;IMAGE&#95;URL</code>: let's add an <code>Image:</code> metadata for this</li></ul></li></ol><p>Having figured out what to put in the meta tags, let's actually implement this! The nice thing about my blog being powered by quickblog is that all of the changes happen there (and are thus available to all quickblog users). Let's start by cloning quickblog. I'll open a terminal, change to the parent directory of my blog, and then run:</p><pre class="language-text"><code class="lang-text language-text">$ git clone git@github.com:borkdude/quickblog.git
</code></pre><p>Now I have a <code>quickblog</code> directory as a sibling of the <code>jmglov.net</code> directory that contains my blog. In order for my blog to pick up the local changes I'm about to make to quickblog, I need to change my dependency from using quickblog from Github to use the local copy instead.</p><p>My <code>bb.edn</code> currently looks like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {io.github.borkdude/quickblog
        #&#95;&quot;You use the newest SHA here:&quot;
        {:git/sha &quot;1c26f244003e590863ae6bba0b25b2ba6a258ac9&quot;}}
 ;; ...
 }
</code></pre><p>I'll change it to this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {io.github.borkdude/quickblog {:local/root &quot;../quickblog&quot;}
        #&#95;&quot;You use the newest SHA here:&quot;
        #&#95;{:git/sha &quot;1c26f244003e590863ae6bba0b25b2ba6a258ac9&quot;}}
 ;; ...
 }
</code></pre><p>I left the <code>{:git/sha &quot;1c26f244003e590863ae6bba0b25b2ba6a258ac9&quot;}</code> bit there for reference, but commented it out with the <code>#&#95;</code> <a href='https://clojure.org/reference/reader#_dispatch'>reader
macro</a>, which causes Clojure's reader to ignore the next form. You can think of it as more or less the `/* ... */` style comment in languages like Java and C.</p><p>Now, any changes I make to my local quickblog directory will be reflected in my blog when I run <code>bb render</code>.</p><p>Now that we're all set up, let's take a look at the quickblog source code and figure out how we're going to do this. The place to start is the page template, <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/resources/quickblog/templates/base.html'><code>base.html</code></a>. If we open it up and take a look at the <code>&lt;head&gt;</code> section, here's what we see:</p><pre class="language-html"><code class="lang-html language-html">  &lt;head&gt;
    &lt;title&gt;{{title}}&lt;/title&gt;
    &lt;meta charset=&quot;utf-8&quot;/&gt;
    &lt;link type=&quot;application/atom+xml&quot; rel=&quot;alternate&quot; href=&quot;{{relative-path | safe}}atom.xml&quot; title=&quot;{{title}}&quot;&gt;
    &lt;link rel=&quot;stylesheet&quot; href=&quot;{{relative-path | safe}}style.css&quot;&gt;
    &lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.28.0/prism.min.js&quot;&gt;&lt;/script&gt;
    &lt;script src=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.28.0/components/prism-clojure.min.js&quot;&gt;&lt;/script&gt;
    {{watch | safe }}
    &lt;link rel=&quot;stylesheet&quot; href=&quot;https://cdnjs.cloudflare.com/ajax/libs/prism/1.28.0/themes/prism.min.css&quot;&gt;

{% if favicon-tags %}{{favicon-tags | safe}}{% endif %}
  &lt;/head&gt;
</code></pre><p>The <code>{% ... %}</code> and <code>{{...}}</code> stuff are <a href='https://github.com/yogthos/Selmer'>Selmer</a> tags and variables. The `{% if ... %}<code> tag includes the stuff before the </code>{% endif %}` if the condition is true, and the <code>{{foo}}</code> is substituted with the value of the <code>foo</code> template variable, or the empty string if the <code>foo</code> template variable is undefined or <code>nil</code>.</p><p>Let's add our social sharing tags below the <code>&lt;% if favicon-tags %&gt;</code> line (which you may remember from the "<a href='2022-07-05-hacking-blog-favicon.html'>Hacking the blog:
favicon</a>" post). Since all pages have a title, we can include those tags unconditionally:</p><pre class="language-html"><code class="lang-html language-html">    &lt;!-- Social sharing &#40;Facebook, Twitter, LinkedIn, etc.&#41; --&gt;
    &lt;meta name=&quot;title&quot; content=&quot;{{title}}&quot;&gt;
    &lt;meta name=&quot;twitter:title&quot; content=&quot;{{title}}&quot;&gt;
    &lt;meta property=&quot;og:title&quot; content=&quot;{{title}}&quot;&gt;
</code></pre><p>We can also throw in <code>og:type</code>, since that should always be "website" for our purposes:</p><pre class="language-html"><code class="lang-html language-html">    &lt;meta property=&quot;og:type&quot; content=&quot;website&quot;&gt;
</code></pre><p>Since the template is already using <code>{{title}}</code>, we feel confident that the quickblog rendering code is providing it. Let's move on now to the description (<code>YOUR&#95;SUMMARY</code>, I know, it's confusing; sorry). Let's add the tags to the template:</p><pre class="language-html"><code class="lang-html language-html">{% if sharing.description %}
    &lt;meta name=&quot;description&quot; content=&quot;{{sharing.description}}&quot;&gt;
    &lt;meta name=&quot;twitter:description&quot; content=&quot;{{sharing.description}}&quot;&gt;
    &lt;meta property=&quot;og:description&quot; content=&quot;{{sharing.description}}&quot;&gt;
{% endif %}
</code></pre><p>This is something new. When we include a <code>.</code> in a template variable, what we're saying is the bit before the dot is a map which contains a field named the bit after the dot. In this case, we expect a template variable called <code>sharing</code> to be provided like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">:sharing {:description &quot;something&quot;}
</code></pre><p>We'll wrap this whole thing in an <code>{% if %} ... {% endif %}</code> so that nothing will be added to the template if the <code>sharing.description</code> variable is undefined.</p><p>Let's have faith that future us will find a way to provide the <code>sharing.description</code> variable somehow and forge on with our template. Next up is the URL:</p><pre class="language-html"><code class="lang-html language-html">{% if sharing.url %}
    &lt;meta name=&quot;twitter:url&quot; content=&quot;{{sharing.url}}&quot;&gt;
    &lt;meta property=&quot;og:url&quot; content=&quot;{{sharing.url}}&quot;&gt;
{% endif %}
</code></pre><p>Again, we'll have faith in our future selves, competent programmers that we are! The final piece of the puzzle is the image. We'll follow the same pattern, but with one small tweak:</p><pre class="language-html"><code class="lang-html language-html">{% if sharing.image %}
    &lt;meta name=&quot;twitter:image&quot; content=&quot;{{sharing.image}}&quot;&gt;
    &lt;meta name=&quot;twitter:card&quot; content=&quot;summary&#95;large&#95;image&quot;&gt;
    &lt;meta property=&quot;og:image&quot; content=&quot;{{sharing.image}}&quot;&gt;
    &lt;meta property=&quot;og:image:alt&quot; content=&quot;{{sharing.image-alt}}&quot;&gt;
{% else %}
    &lt;meta name=&quot;twitter:card&quot; content=&quot;summary&quot;&gt;
{% endif %}
</code></pre><p>The <code>og:image:alt</code> property is one that I tracked down in the <a href='https://ogp.me/'>Open Graph
protocol documentation</a>, and it provides alt text for the image, which is extremely important for making pages accessible to people using screen readers. I highly recommend reading resources like "<a href='https://accessibility.huit.harvard.edu/describe-content-images'>Write good Alt Text
to describe
images</a>" to learn more.</p><p>The <code>twitter:card</code> property has multiple options, according to Twitter's cards documentation:</p><ul><li><code>summary</code></li><li><code>summary&#95;large&#95;image</code></li><li><code>app</code></li><li><code>player</code></li></ul><p>It does not specify what these mean. I guess Twitter needs to keep the mystery alive! What we'll do for now is use <code>summary&#95;large&#95;image</code> when we have an image, and regular old "summary" when we don't.</p><p>According to this page, Twitter has another couple of meta tags we can set:</p><ul><li><code>twitter:site</code> - @username for the website used in the card footer</li><li><code>twitter:creator</code> - @username for the content creator / author</li></ul><p>We might as well do that, since quickblog has a <code>:twitter-handle</code> option.</p><pre class="language-html"><code class="lang-html language-html">{% if sharing.author %}
    &lt;meta name=&quot;twitter:creator&quot; content=&quot;{{sharing.author-twitter-handle}}&quot;&gt;
{% endif %}
{% if sharing.twitter-handle %}
    &lt;meta name=&quot;twitter:site&quot; content=&quot;{{sharing.twitter-handle}}&quot;&gt;
{% endif %}
</code></pre><p>The reason for defining them separately is that <code>:twitter-handle</code> is the owner of the blog, but the author of an individual post might be different, and we'll allow that to be specified with the <code>Twitter-Handle:</code> metadata tag in the post.</p><p>OK, now we have everything taken care of in the template itself. Let's turn our roving eye to the rendering code, starting with the index.</p><p>If we open up <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/src/quickblog/api.clj'><code>src/quickblog/api.clj</code></a>, we'll find a <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/src/quickblog/api.clj#L157'><code>spit-index</code></a> function at line 157. It does some figuring out of which posts to include in the index, then makes a call to <code>lib/write-page!</code>. This is where the template variables are defined:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:title blog-title
 :body body}
</code></pre><p>Looking back at our template, we want to add the following keys and values:</p><ul><li><code>description</code></li><li><code>url</code></li><li><code>image</code></li><li><code>author-twitter-handle</code></li><li><code>twitter-handle</code></li></ul><p>All of the information we need is contained in the <code>opts</code> that are passed to the function. Let's add the keys we need to the <a href='https://clojure.org/guides/destructuring#_associative_destructuring'>destructuring
form</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- spit-index
  &#91;{:keys &#91;blog-title blog-description blog-image blog-image-alt
           blog-root twitter-handle
           posts cached-posts deleted-posts modified-posts num-index-posts
           out-dir&#93;
    :as opts}&#93;
</code></pre><p>Now we can fill in the map of template variables:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;lib/write-page! opts out-file
                 &#40;base-html opts&#41;
                 {:title blog-title
                  :body body
                  :sharing {:description blog-description
                            :author twitter-handle
                            :twitter-handle twitter-handle
                            :image &#40;format &quot;%s/%s&quot; blog-root blog-image&#41;
                            :image-alt blog-image-alt
                            :url &#40;format &quot;%s/index.html&quot; blog-root&#41;}}&#41;
</code></pre><p>In this case, both the author and site Twitter handles are the same, since this is the index page of the entire blog.</p><p>There's only one thing here that is slightly worrisome: does the value of the <code>:blog-root</code> option end in a <code>/</code> or not? quickblog's documentation is silent on the matter, so we'd better handle both cases just to be safe. Let's add a function to <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/src/quickblog/internal.clj'><code>internal.clj</code></a> to take care of this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn blog-link &#91;{:keys &#91;blog-root&#93; :as opts} relative-url&#93;
  &#40;when relative-url
    &#40;format &quot;%s%s%s&quot;
            blog-root
            &#40;if &#40;str/ends-with? blog-root &quot;/&quot;&#41; &quot;&quot; &quot;/&quot;&#41;
            relative-url&#41;&#41;&#41;
</code></pre><p>And now we can use this in <code>spit-index</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- spit-index
  &#91;{:keys &#91;blog-title blog-description blog-image blog-image-alt twitter-handle
           posts cached-posts deleted-posts modified-posts num-index-posts
           out-dir&#93;
    :as opts}&#93;
  ;; ...
        &#40;lib/write-page! opts out-file
                         &#40;base-html opts&#41;
                         {:title blog-title
                          :body body
                          :sharing {:description blog-description
                                    :author twitter-handle
                                    :twitter-handle twitter-handle
                                    :image &#40;lib/blog-link opts blog-image&#41;
                                    :image-alt blog-image-alt
                                    :url &#40;lib/blog-link opts &quot;index.html&quot;&#41;}}&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Note that we no longer need the <code>blog-root</code> key in our destructuring form, so we've removed it to be neat and tidy.</p><p>Now onto the archive page. We see that there's a <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/src/quickblog/api.clj#L181'><code>spit-archive</code></a> function on line 181, so we'll do some very similar modifications there:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- spit-archive &#91;{:keys &#91;blog-title blog-description
                             blog-image blog-image-alt twitter-handle
                             modified-metadata posts out-dir&#93; :as opts}&#93;
  ;; ...
        &#40;lib/write-page! opts out-file
                         &#40;base-html opts&#41;
                         {:skip-archive true
                          :title title
                          :body &#40;hiccup/html &#40;lib/post-links &quot;Archive&quot; posts&#41;&#41;
                          :sharing {:description &#40;format &quot;Archive - %s&quot;
                                                         blog-description&#41;
                                    :author twitter-handle
                                    :twitter-handle twitter-handle
                                    :image &#40;lib/blog-link opts blog-image&#41;
                                    :image-alt blog-image-alt
                                    :url &#40;lib/blog-link opts &quot;archive.html&quot;&#41;}}&#41;&#41;&#41;&#41;&#41;
</code></pre><p>The tags page is now up, but there's no conveniently named <code>spit-tags</code> function, so we'll have to figure out how this is generated. If we just search <code>api.clj</code> for <code>tags</code>, we get a promising hit on <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/src/quickblog/api.clj#L120'>line
120</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- gen-tags &#91;{:keys &#91;blog-title modified-tags posts
                         out-dir tags-dir&#93;
                  :as opts}&#93;
  ;; ...
      &#40;lib/write-page! opts tags-file template
                       {:skip-archive true
                        :title &#40;str blog-title &quot; - Tags&quot;&#41;
                        :relative-path &quot;../&quot;
                        :body &#40;hiccup/html &#40;lib/tag-links &quot;Tags&quot; posts-by-tag&#41;&#41;}&#41;
      ;; ...
</code></pre><p>Ah, our old friend <code>lib/write-page!</code>. Let's rinse and repeat here:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn- gen-tags &#91;{:keys &#91;blog-title blog-description
                         blog-image blog-image-alt twitter-handle
                         modified-tags posts out-dir tags-dir&#93;
                  :as opts}&#93;
  ;; ...
      &#40;lib/write-page! opts tags-file template
                       {:skip-archive true
                        :title &#40;str blog-title &quot; - Tags&quot;&#41;
                        :relative-path &quot;../&quot;
                        :body &#40;hiccup/html &#40;lib/tag-links &quot;Tags&quot; posts-by-tag&#41;&#41;
                        :sharing {:description &#40;format &quot;Tags - %s&quot;
                                                       blog-description&#41;
                                  :author twitter-handle
                                  :twitter-handle twitter-handle
                                  :image &#40;lib/blog-link opts blog-image&#41;
                                  :image-alt blog-image-alt
                                  :url &#40;lib/blog-link opts &quot;tags/index.html&quot;&#41;}}&#41;
      ;; ...
</code></pre><p><code>gen-tags</code> looks like it also handles the individual tag pages:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;doseq &#91;tag-and-posts posts-by-tag&#93;
  &#40;lib/write-tag! opts tags-out-dir template tag-and-posts&#41;&#41;
</code></pre><p>Let's drill into the <code>lib/write-tag!</code> function, defined on <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/src/quickblog/internal.clj#L383'>line
383</a> of <code>internal.clj</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn write-tag! &#91;{:keys &#91;blog-title modified-tags&#93; :as opts}
                  tags-out-dir
                  template
                  &#91;tag posts&#93;&#93;
  &#40;let &#91;tag-filename &#40;fs/file tags-out-dir &#40;tag-file tag&#41;&#41;&#93;
    &#40;when &#40;or &#40;modified-tags tag&#41; &#40;not &#40;fs/exists? tag-filename&#41;&#41;&#41;
      &#40;write-page! opts tag-filename template
                   {:skip-archive true
                    :title &#40;str blog-title &quot; - Tag - &quot; tag&#41;
                    :relative-path &quot;../&quot;
                    :body &#40;hiccup/html &#40;post-links &#40;str &quot;Tag - &quot; tag&#41; posts
                                                   {:relative-path &quot;../&quot;}&#41;&#41;}&#41;&#41;&#41;&#41;
</code></pre><p>Nice! There's a call to <code>write-page!</code>, so we know exactly what we need to do:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn write-tag! &#91;{:keys &#91;blog-title blog-description
                          blog-image blog-image-alt twitter-handle
                          modified-tags&#93; :as opts}
                  tags-out-dir
                  template
                  &#91;tag posts&#93;&#93;
  ;; ...
      &#40;write-page! opts tag-filename template
                   {:skip-archive true
                    :title &#40;str blog-title &quot; - Tag - &quot; tag&#41;
                    :relative-path &quot;../&quot;
                    :body &#40;hiccup/html &#40;post-links &#40;str &quot;Tag - &quot; tag&#41; posts
                                                   {:relative-path &quot;../&quot;}&#41;&#41;
                    :sharing {:description &#40;format &quot;Posts tagged \&quot;%s\&quot; - %s&quot;
                                                   tag blog-description&#41;
                              :author twitter-handle
                              :twitter-handle twitter-handle
                              :image &#40;blog-link opts blog-image&#41;
                              :image-alt blog-image-alt
                              :url &#40;blog-link opts &quot;tags/index.html&quot;&#41;}}&#41;&#41;
</code></pre><p>There's only one thing left to do: the post pages. Let's see if we can figure out how they're rendered.</p><p>Back in <code>api.clj</code>, there's a <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/src/quickblog/api.clj#L89'><code>gen-posts</code></a> function at line 89. It's a bit long and scary looking, but there is a call to a <code>lib/write-post!</code> function at <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/src/quickblog/api.clj#L102'>line
102</a>, so it looks like we can probably get away with leaving <code>gen-posts</code> as is and making our changes in <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/src/quickblog/internal.clj#L353'><code>lib/write-post!</code></a>. Let's have a look:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn write-post! &#91;{:keys &#91;discuss-fallback
                           cache-dir
                           out-dir
                           force-render
                           page-template
                           post-template
                           posts-dir&#93;
                    :as opts}
                   {:keys &#91;file title date discuss tags html&#93;
                    :or {discuss discuss-fallback}}&#93;
  &#40;let &#91;out-file &#40;fs/file out-dir &#40;html-file file&#41;&#41;
        markdown-file &#40;fs/file posts-dir file&#41;
        cached-file &#40;fs/file cache-dir &#40;cache-file file&#41;&#41;
        body &#40;selmer/render post-template {:body @html
                                           :title title
                                           :date date
                                           :discuss discuss
                                           :tags tags}&#41;
        rendered-html &#40;render-page opts page-template
                                   {:title title
                                    :body body}&#41;&#93;
    &#40;println &quot;Writing post:&quot; &#40;str out-file&#41;&#41;
    &#40;spit out-file rendered-html&#41;&#41;&#41;
</code></pre><p>There are a few things to note here:</p><ol><li>There are two <code>:keys</code> destructurings happening here. The first is our old   friend <code>opts</code>, but the second has no name. The names of the keys look   familiar, though. <code>Title:</code>, <code>Date:</code>, and <code>Tags:</code> are the pieces of metadata   automatically added to new posts when we run the <code>bb new</code> command, so let's   assume that this second set of keys is the metadata defined in the post   itself, plus some extra metadata that quickblog attaches.</li><li>There's a call to <code>selmer/render</code> here, which appears to be rendering the   body of the post. Since the <code>&lt;meta&gt;</code> tags we're adding go in the <code>&lt;head&gt;</code>   section of the page, we can safely ignore this part.</li><li>There's no call to <code>write-page!</code>, but <code>render-page</code> looks pretty similar.   Let's add our template variables there.</li></ol><p>First, we'll add <code>twitter-handle</code> to the <code>opts</code> destructuring, give the second argument a name, <code>post-metadata</code>, and add the <code>description</code>, <code>image</code>, and <code>image-alt</code> keys to it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn write-post! &#91;{:keys &#91;blog-root
                           twitter-handle
                           discuss-fallback
                           cache-dir
                           out-dir
                           force-render
                           page-template
                           post-template
                           posts-dir&#93;
                    :as opts}
                   {:keys &#91;file title date discuss tags html
                           description image image-alt&#93;
                    :or {discuss discuss-fallback}
                    :as post-metadata}&#93;
  ;; ...
</code></pre><p>Now, let's figure out what the values of the template variables should be. <code>description</code> and <code>image-alt</code> are straightforward; it's what the post author added as the <code>Description:</code> and <code>Image-Alt:</code> metadata in the post, so we can use it as is.</p><p><code>url</code> is only a bit more complicated. We can use the <code>blog-link</code> function as usual, and the <code>relative-url</code> argument should be the name of the HTML file corresponding to this post. We can see on <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/src/quickblog/internal.clj#L363'>line
363</a> that the output file uses a function called <code>html-file</code>, which transforms the post's <code>foo.md</code> file into <code>foo.html</code>. Just what we needed!</p><p><code>twitter-handle</code>, which is the Twitter handle of the blog owner, can be used straight up. For <code>author</code>, let's look first for a <code>twitter-handle</code> key in the post metadata, and then fall back to the blog's <code>twitter-handle</code> otherwise:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">author &#40;-&gt; &#40;:twitter-handle post-metadata&#41; &#40;or twitter-handle&#41;&#41;
</code></pre><p>Finally, we want the post's author to be able to add <code>Image:</code> metadata to the post, which they should be able to specify either as an absolute URL or a relative URL. We can handle that here:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">image &#40;when image &#40;if &#40;re-matches #&quot;&#94;https?://.+&quot; image&#41;
                    image
                    &#40;blog-link opts image&#41;&#41;&#41;
</code></pre><p>Now we can just feed these keys to the <code>render-page</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">rendered-html &#40;render-page opts page-template
                           {:title title
                            :body body
                            :sharing &#40;-&gt;map description
                                            author
                                            twitter-handle
                                            image
                                            image-alt
                                            url&#41;}&#41;
</code></pre><p>Let's take a brief detour to look at this <code>-&gt;map</code> bit. It's a macro that lets us define a map with keys named the same as the variables holding the values. Or in other words, these two things are equivalent:</p><pre><code>&#40;-&gt;map description author twitter-handle image image-alt url&#41;

{:description description
 :author author
 :twitter-handle twitter-handle
 :image image
 :image-alt image-alt
 :url url}
</code></pre><p>In case you're interested, the macro is defined at <a href='https://github.com/borkdude/quickblog/blob/1c26f244003e590863ae6bba0b25b2ba6a258ac9/src/quickblog/internal.clj#L27'>line
27</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defmacro -&gt;map &#91;&amp; ks&#93;
  &#40;assert &#40;every? symbol? ks&#41;&#41;
  &#40;zipmap &#40;map keyword ks&#41;
          ks&#41;&#41;
</code></pre><p>If you're interested but don't understand what's going on here, I can highly recommend "<a href='https://pragprog.com/titles/cjclojure/mastering-clojure-macros/'>Mastering Clojure
Macros</a>", by Colin Jones, or <a href='https://www.braveclojure.com/writing-macros/'>Chapter 8</a> of "<a href='https://www.braveclojure.com/'>Clojure for the Brave and True</a>", by Daniel Higgenbotham. You can read "Clojure for the Brave and True" for free online, but if you can afford to show Daniel some monetary appreciation, you can order the print version using his affiliate link: http://amzn.to/1H7MqmT.</p><p>OK, we actually have everything we need to make this work! Let's generate a new post and test it out:</p><pre class="language-text"><code class="lang-text language-text">$ bb new --file test.md --title &quot;Test post&quot;
</code></pre><p>If we open up <code>posts/test.md</code>, we can add some metadata tags:</p><pre class="language-text"><code class="lang-text language-text">Title: Test post
Date: 2022-08-17
Tags: clojure
Twitter-Handle: jmglov
Description: This is an amazing blog post which tests the equally amazing social sharing functionality that we just added to quickblog!
Image: https://jmglov.net/test/2022-08-16-sharing-preview.png
Image-Alt: A leather-bound notebook lies open on a writing desk

Write a blog post here!
</code></pre><p>Now let's try things out! If we run:</p><pre class="language-text"><code class="lang-text language-text">$ bb watch
</code></pre><p>we can browse to our blog at http://localhost:1888/. We should see the index page, and if we click on the <a href='http://localhost:1888/test.html'>Test post</a> link, we can view the source of the page, look at the <code>&lt;head&gt;</code> section, and see:</p><pre class="language-html"><code class="lang-html language-html">&lt;head&gt;
  &lt;!-- some boring stuff here --&gt;

  &lt;!-- Social sharing &#40;Facebook, Twitter, LinkedIn, etc.&#41; --&gt;
  &lt;meta name=&quot;title&quot; content=&quot;Test post&quot;&gt;
  &lt;meta name=&quot;twitter:title&quot; content=&quot;Test post&quot;&gt;
  &lt;meta property=&quot;og:title&quot; content=&quot;Test post&quot;&gt;
  &lt;meta property=&quot;og:type&quot; content=&quot;website&quot;&gt;

  &lt;meta name=&quot;description&quot; content=&quot;This is an amazing blog post which tests the equally amazing social sharing functionality that we just added to quickblog!&quot;&gt;
  &lt;meta name=&quot;twitter:description&quot; content=&quot;This is an amazing blog post which tests the equally amazing social sharing functionality that we just added to quickblog!&quot;&gt;
  &lt;meta property=&quot;og:description&quot; content=&quot;This is an amazing blog post which tests the equally amazing social sharing functionality that we just added to quickblog!&quot;&gt;

  &lt;meta name=&quot;twitter:image&quot; content=&quot;https://jmglov.net/test/2022-08-16-sharing-preview.png&quot;&gt;
  &lt;meta name=&quot;twitter:card&quot; content=&quot;summary&#95;large&#95;image&quot;&gt;
  &lt;meta property=&quot;og:image&quot; content=&quot;https://jmglov.net/test/2022-08-16-sharing-preview.png&quot;&gt;
  &lt;meta property=&quot;og:image:alt&quot; content=&quot;A leather-bound notebook lies open on a writing desk&quot;&gt;

  &lt;meta name=&quot;twitter:creator&quot; content=&quot;jmglov&quot;&gt;
  &lt;meta name=&quot;twitter:site&quot; content=&quot;quickblog&quot;&gt;
&lt;/head&gt;
</code></pre><p>Awesome! But how can we know what this will look like when shared on social media sites? Well, I've done us all the great service of uploading this page to my website, so we can use <a href='https://metatags.io/'>metatags.io</a> to test it. If we pop in https://jmglov.net/test/social-post.html to the text box at the top of the site, we should see something like this:</p><p><img src="assets/2022-08-17-metatags.png" alt="The metatags.io site showing a preview of the social sharing card for our test page" title="Spectacularity!" width=800px] /></p><p>The spectacularity of this accomplishment cannot be overstated, my friends! 🏆</p><p>In case you're a quickblog user and you want to benefit from this stuff without having to do a bunch of typing, fear not! The latest version of quickblog already includes this functionality. 🙂</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-11-dogfooding-blambda-cli-ier.html</id>
    <link href="https://jmglov.net/blog/2022-08-11-dogfooding-blambda-cli-ier.html"/>
    <title>Dogfooding Blambda 4: CLI, CLIier, CLIiest</title>
    <updated>2022-08-11T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>In <a href='2022-08-10-dogfooding-blambda-cli.html'>yesterday's installment of Dogfooding
Blambda</a>, I fully intended to show y'all <a href='https://github.com/jmglov/blambda'>Blambda</a>'s command line interface, then walk you through how I implemented it, using the amazing <a href='https://github.com/babashka/cli'>babashka.cli</a> library. However, me being me, I kinda meandered all over the place, and by the time I had finished the "show" portion of Show and Tell, my dog let me know, kindly but firmly as only a dog can, that the "tell" portion would need to wait, because he couldn't anymore.</p><p>But hey, today is a new day, and I have heaps of time, so let's dig in!</p><p>After the <a href='2022-08-09-dogfooding-blambda-2.html'>less than wonderful experience I
had</a> using Blambda in my <a href='https://github.com/jmglov/s3-log-parser'>s3-log-parser</a> lambda, I decided that Blambda should be a library that exposed its functionality via an API, similar to how <a href='https://github.com/borkdude/quickblog'>quickblog</a> works. Thinking about what the API should look like, I decided on the following functions:</p><ul><li><code>build-runtime-layer</code>: builds the Blambda custom runtime as a Lambda layer</li><li><code>deploy-runtime-layer</code>: deploys the custom runtime layer</li><li><code>build-deps-layer</code>: builds a layer for lambda dependencies</li><li><code>deploy-deps-layer</code>: deploys the dependencies layer</li><li><code>clean</code>: sweeps up around the place to give you that peaceful happy feeling  that you so crave</li></ul><p>I had the code for building and deploying the custom runtime layer in Blamba's <a href='https://github.com/jmglov/blambda/blob/c253bf7d2b0bbbe3e53ad276b1c15c53b98d2088/bb.edn'><code>bb.edn</code></a>, as well as the code for building the deps layer, but in my infinite wisdom, the code for actually deploying the deps layer was in s3-log-parser, in the wonderfully named <a href='https://github.com/jmglov/s3-log-parser/blob/dcbb6546cc5e0d61a1374feeff78c0f58aa2d3c3/task_helper.clj'><code>task&#95;helper.clj</code></a> file.</p><p>It was straightforward enough to move the code for building and deploying into a <a href='https://github.com/jmglov/blambda/blob/2453e15cf75c03b2b02de5ca89c76081bba40251/src/blambda/api.clj'><code>blambda.api</code></a> namespace, and I had at least had the foresight to write a primitive argument parser in Blambda's own <a href='https://github.com/jmglov/blambda/blob/c253bf7d2b0bbbe3e53ad276b1c15c53b98d2088/task_helper.clj#L41'><code>task&#95;helper.clj</code></a> that turned something like `bb build-runtime-layer &ndash;bb-arch arm64 &ndash;bb-version 0.9.161` into:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:bb-arch &quot;arm64&quot;
 :bb-version &quot;0.9.161&quot;}
</code></pre><p>Since this is also how babashka.cli works, the migration was fairly straightforward. I took my old <code>task-helper/defaults</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:aws-region {:doc &quot;AWS region&quot;
              :default &#40;or &#40;System/getenv &quot;AWS&#95;DEFAULT&#95;REGION&quot;&#41; &quot;eu-west-1&quot;&#41;}
:bb-version {:doc &quot;Babashka version&quot;
             :default &quot;0.9.161&quot;}
:bb-arch {:doc &quot;Architecture to target&quot;
          :default &quot;amd64&quot;
          :values &#91;&quot;amd64&quot; &quot;arm64&quot;&#93;}
:deps-path {:doc &quot;Path to bb.edn or deps.edn containing lambda deps&quot;}
:layer-name {:doc &quot;Name of custom runtime layer in AWS&quot;
             :default &quot;blambda&quot;}
:target-dir {:doc &quot;Build output directory&quot;
             :default &quot;target&quot;}
:work-dir {:doc &quot;Working directory&quot;
           :default &quot;.work&quot;}}
</code></pre><p>and turned them into a babashka.cli <a href='https://github.com/babashka/cli#spec'>spec</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:aws-region {:desc &quot;AWS region&quot;
              :ref &quot;&lt;region&gt;&quot;
              :default &#40;or &#40;System/getenv &quot;AWS&#95;DEFAULT&#95;REGION&quot;&#41; &quot;eu-west-1&quot;&#41;}
 :bb-version {:desc &quot;Babashka version&quot;
              :ref &quot;&lt;version&gt;&quot;
              :default &quot;0.9.161&quot;}
 :bb-arch {:desc &quot;Architecture to target&quot;
           :ref &quot;&lt;arch&gt;&quot;
           :default &quot;amd64&quot;
           :values &#91;&quot;amd64&quot; &quot;arm64&quot;&#93;}
 :deps-path {:desc &quot;Path to bb.edn or deps.edn containing lambda dependencies&quot;
             :ref &quot;&lt;path&gt;&quot;}
 :deps-layer-name {:desc &quot;Name of dependencies layer in AWS&quot;
                   :ref &quot;&lt;name&gt;&quot;}
 :runtime-layer-name {:desc &quot;Name of custom runtime layer in AWS&quot;
                      :ref &quot;&lt;name&gt;&quot;
                      :default &quot;blambda&quot;}
 :target-dir {:desc &quot;Build output directory&quot;
              :ref &quot;&lt;dir&gt;&quot;
              :default &quot;target&quot;}
 :work-dir {:desc &quot;Working directory&quot;
            :ref &quot;&lt;dir&gt;&quot;
            :default &quot;.work&quot;}}
</code></pre><p>With this, I could create a nice CLI for Blambda:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns blambda.cli
  &#40;:require &#91;babashka.cli :as cli&#93;&#41;&#41;

&#40;def spec
  {
  ;; ...
  }&#41;

&#40;defn parse-opts &#91;&amp; &#95;&#93;
  &#40;let &#91;opts &#40;cli/parse-opts &#42;command-line-args&#42; {:spec spec}&#41;&#93;
    &#40;if &#40;:help opts&#41;
      &#40;do
        &#40;println &#40;cli/format-opts {:spec spec}&#41;&#41;
        &#40;System/exit 0&#41;&#41;
      opts&#41;&#41;&#41;
</code></pre><p>This is all it takes to get a lovely usage message:</p><pre class="language-text"><code class="lang-text language-text">$ bb -m blambda.cli/parse-opts --help
  --aws-region         &lt;region&gt;  eu-west-1 AWS region
  --bb-version         &lt;version&gt; 0.9.161   Babashka version
  --bb-arch            &lt;arch&gt;    amd64     Architecture to target
  --deps-path          &lt;path&gt;              Path to bb.edn or deps.edn containing lambda dependencies
  --deps-layer-name    &lt;name&gt;              Name of dependencies layer in AWS
  --runtime-layer-name &lt;name&gt;    blambda   Name of custom runtime layer in AWS
  --target-dir         &lt;dir&gt;     target    Build output directory
  --work-dir           &lt;dir&gt;     .work     Working directory
</code></pre><p>So that's Blambda as a library. Now I can use that in s3-log-parser to manage all of the moving parts in one place. In order to do this, all I need to do is add tasks to s3-log-parser's <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;.&quot;&#93;
 :deps {net.jmglov/blambda
        #&#95;&quot;You use the newest SHA here:&quot;
        {:git/url &quot;https://github.com/jmglov/blambda.git&quot;
         :git/sha &quot;e379410bb6b20bb9cf34acd42cfc65e5f4fd6845&quot;}}
 :tasks
 {:requires &#40;&#91;blambda.api :as blambda&#93;&#41;

  build-runtime-layer {:doc &quot;Builds Blambda custom runtime layer&quot;
                       :task &#40;blambda/build-runtime-layer&#41;}

  deploy-runtime-layer {:doc &quot;Deploys Blambda custom runtime layer&quot;
                        :task &#40;blambda/deploy-runtime-layer&#41;}

  build-deps-layer {:doc &quot;Builds dependencies layer&quot;
                    :task &#40;blambda/build-deps-layer&#41;}

  deploy-deps-layer {:doc &quot;Deploys dependencies layer&quot;
                     :task &#40;blambda/deploy-deps-layer&#41;}

  clean {:doc &quot;Deletes target and work directories&quot;
         :task &#40;blambda/clean&#41;}}}
</code></pre><p>Now I can do things like build my dependencies layer:</p><pre class="language-text"><code class="lang-text language-text">$ bb build-deps-layer --deps-path src/bb.edn 
Classpath before transforming: src:&#126;/s3-log-parser/.work/m2-repo/com/cognitect/aws/endpoints/1.1.12.206/endpoints-1.1.12.206.jar:...

Classpath after transforming: src:/opt/m2-repo/com/cognitect/aws/endpoints/1.1.12.206/endpoints-1.1.12.206.jar:...

Compressing custom runtime layer: &#126;/s3-log-parser/target/deps.zip
</code></pre><p>It's a little annoying that I have to add one task per library function, and even more annoying that I have to repeat information that's already there in <code>blambda.api</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn build-runtime-layer
  &quot;Builds Blambda custom runtime layer&quot;
  ;; ...
  &#41;

&#40;defn deploy-runtime-layer
  &quot;Deploys Blambda custom runtime layer&quot;
  ;; ...
  &#41;

&#40;defn build-deps-layer
  &quot;Builds dependencies layer&quot;
  ;; ...
  &#41;

&#40;defn deploy-deps-layer
  &quot;Deploys dependencies layer&quot;
  ;; ...
  &#41;

&#40;defn clean
  &quot;Deletes target and work directories&quot;
  ;; ...
  &#41;
</code></pre><p>What I'd like to do is add a single task that delegates all of this stuff to the Blambda CLI. But how to find this holiest of all holy grails?</p><p>Of course the mighty <a href='https://github.com/borkdude'>borkdude</a> has already thought of this, and babashka.cli has support for <a href='https://github.com/babashka/cli#subcommands'>subcommands</a>. If I expose each Blambda API function as a subcommand, I can interact with Blambda from s3-log-parser as I showed you yesterday:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-runtime-layer --bb-arch arm64
Downloading https://github.com/babashka/babashka/releases/download/v0.9.161/babashka-0.9.161-linux-aarch64-static.tar.gz
Decompressing .work/babashka-0.9.161-linux-aarch64-static.tar.gz to .work
Adding file bootstrap
Adding file bootstrap.clj
Compressing custom runtime layer: &#126;/my-lambda/target/bb.zip

$ bb blambda deploy-runtime-layer --bb-arch arm64
Publishing layer version for layer blambda
Published layer arn:aws:lambda:eu-west-1:289341159200:layer:blambda:1
</code></pre><p>And all this by adding a single line to my <code>bb.edn</code> (actually two lines, since I need to require <code>blambda.cli</code>, or rather three lines, since I want to nicely format the map, but you know what I mean):</p><pre class="language-clojure"><code class="lang-clojure language-clojure">:tasks
{:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;

 blambda {:doc &quot;Controls Blambda runtime and layers&quot;
          :task &#40;blambda/dispatch&#41;}}
</code></pre><p>Wow, now that's some amazing UX! 🏆</p><p>But how does this subcommand stuff work? babashka.cli's documentation gives this example:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns example
  &#40;:require &#91;babashka.cli :as cli&#93;&#41;&#41;

&#40;defn copy &#91;m&#93;
  &#40;assoc m :fn :copy&#41;&#41;

&#40;defn delete &#91;m&#93;
  &#40;assoc m :fn :delete&#41;&#41;

&#40;defn help &#91;m&#93;
  &#40;assoc m :fn :help&#41;&#41;

&#40;def table
  &#91;{:cmds &#91;&quot;copy&quot;&#93;   :fn copy   :args-&gt;opts &#91;:file&#93;}
   {:cmds &#91;&quot;delete&quot;&#93; :fn delete :args-&gt;opts &#91;:file&#93;}
   {:cmds &#91;&#93;         :fn help}&#93;&#41;

&#40;defn -main &#91;&amp; args&#93;
  &#40;cli/dispatch table args {:coerce {:depth :long}}&#41;&#41;
</code></pre><p>So it looks like I need to build a table that looks something like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def table
  &#91;{:cmds &#91;&quot;build-runtime-layer&quot;&#93;  :fn api/build-runtime-layer}
   {:cmds &#91;&quot;deploy-runtime-layer&quot;&#93; :fn api/deploy-runtime-layer}
   {:cmds &#91;&quot;build-deps-layer&quot;&#93;     :fn api/build-deps-layer}
   {:cmds &#91;&quot;deploy-deps-layer&quot;&#93;    :fn api/deploy-deps-layer}
   {:cmds &#91;&quot;clean&quot;&#93;                :fn api/build-runtime-layer}
   {:cmds &#91;&#93;                       :fn print-help}&#93;&#41;
</code></pre><p>And according to the docs, I can include other <code>babashka.cli/parse-arg</code> options in a table entry, so I'll add <code>:spec spec</code> to each.</p><p>Now I can run <code>bb blambda --help</code> from s3-log-parser to get a nice usage message:</p><pre class="language-text"><code class="lang-text language-text">  --aws-region         &lt;region&gt;  eu-west-1 AWS region
  --bb-version         &lt;version&gt; 0.9.161   Babashka version
  --bb-arch            &lt;arch&gt;    amd64     Architecture to target
  --deps-path          &lt;path&gt;              Path to bb.edn or deps.edn containing lambda dependencies
  --deps-layer-name    &lt;name&gt;              Name of dependencies layer in AWS
  --runtime-layer-name &lt;name&gt;    blambda   Name of custom runtime layer in AWS
  --target-dir         &lt;dir&gt;     target    Build output directory
  --work-dir           &lt;dir&gt;     .work     Working directory
</code></pre><p>Well, nice-ish. If I run this, I have no idea what subcommands <code>bb blambda</code> has, what they do, and which of these options apply to each command. Let's see if we can fix this.</p><p>Let's start by enriching our <code>print-help</code> function a bit and modifying our table to use it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn print-help &#91;cmds&#93;
  &#40;println
   &#40;format
    &quot;Usage: bb blambda &lt;subcommand&gt; &lt;options&gt;

Subcommands:

%s&quot;
    &#40;-&gt;&gt; cmds
         &#40;map &#40;comp first :cmds&#41;&#41;
         &#40;str/join &quot;\n\n&quot;&#41;&#41;&#41;&#41;&#41;

&#40;def table
  &#40;let &#91;cmds
        &#91;{:cmds &#91;&quot;build-runtime-layer&quot;&#93;  :fn api/build-runtime-layer}
         {:cmds &#91;&quot;deploy-runtime-layer&quot;&#93; :fn api/deploy-runtime-layer}
         {:cmds &#91;&quot;build-deps-layer&quot;&#93;     :fn api/build-deps-layer}
         {:cmds &#91;&quot;deploy-deps-layer&quot;&#93;    :fn api/deploy-deps-layer}
         {:cmds &#91;&quot;clean&quot;&#93;                :fn api/build-runtime-layer}&#93;&#93;
    &#40;conj cmds
          {:cmds &#91;&#93; :fn &#40;fn &#91;&#95;&#93; &#40;print-help cmds&#41;&#41;}&#41;&#41;&#41;
</code></pre><p>Now running <code>bb blambda --help</code> (or <code>bb blambda help</code> or <code>bb blambda</code>, for that matter) produces something a bit nicer:</p><pre class="language-text"><code class="lang-text language-text">Usage: bb blambda &lt;subcommand&gt; &lt;options&gt;

Subcommands:

build-runtime-layer

deploy-runtime-layer

build-deps-layer

deploy-deps-layer

clean
</code></pre><p>Let's add a description to each subcommand and then update <code>print-help</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def table
  &#40;let &#91;cmds
        &#91;{:cmds &#91;&quot;build-runtime-layer&quot;&#93;
          :desc &quot;Builds Blambda custom runtime layer&quot;
          :fn api/build-runtime-layer}
         {:cmds &#91;&quot;deploy-runtime-layer&quot;&#93;
          :desc &quot;Deploys Blambda custom runtime layer&quot;
          :fn api/deploy-runtime-layer}
         ;; ...
        &#93;&#93;
    &#40;conj cmds
          {:cmds &#91;&#93; :fn &#40;fn &#91;&#95;&#93; &#40;print-help cmds&#41;&#41;}&#41;&#41;&#41;

&#40;defn print-help' &#91;cmds&#93;
  &#40;println
   &#40;format
    &quot;Usage: bb blambda &lt;subcommand&gt; &lt;options&gt;

Subcommands:

%s&quot;
    &#40;-&gt;&gt; cmds
         &#40;map &#40;fn &#91;{:keys &#91;cmds desc&#93;}&#93;
                &#40;format &quot;%s: %s&quot; &#40;first cmds&#41; desc&#41;&#41;&#41;
         &#40;str/join &quot;\n\n&quot;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Now we're getting somewhere!</p><pre class="language-text"><code class="lang-text language-text">Usage: bb blambda &lt;subcommand&gt; &lt;options&gt;

Subcommands:

build-runtime-layer: Builds Blambda custom runtime layer

deploy-runtime-layer: Deploys Blambda custom runtime layer

build-deps-layer: Builds dependencies layer from bb.edn or deps.edn

deploy-deps-layer: Deploys dependencies layer

clean: Removes work and target folders
</code></pre><p>Now it's time to bring back our options. If we look at the options we have, <code>--target-dir</code> and <code>--work-dir</code> apply to every command, <code>--aws-region</code> applies to the two deployment commands, and the rest of the options (<code>--bb-arch</code>, <code>--bb-version</code>, <code>--deps-path</code>, <code>--runtime-layer-name</code>, and <code>--deps-layer-name</code>) apply only to specific commands. Let's see how we can express this in our command table:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def table
  &#40;let &#91;cmds
        &#91;{:cmds &#91;&quot;build-runtime-layer&quot;&#93;
          :desc &quot;Builds Blambda custom runtime layer&quot;
          :fn api/build-runtime-layer
          :opts #{:bb-version :bb-arch}}
         {:cmds &#91;&quot;build-deps-layer&quot;&#93;
          :desc &quot;Builds dependencies layer from bb.edn or deps.edn&quot;
          :fn api/build-deps-layer
          :opts #{:deps-path}}
         {:cmds &#91;&quot;deploy-runtime-layer&quot;&#93;
          :desc &quot;Deploys Blambda custom runtime layer&quot;
          :fn api/deploy-runtime-layer
          :opts #{:aws-region :bb-arch :runtime-layer-name}}
         {:cmds &#91;&quot;deploy-deps-layer&quot;&#93;
          :desc &quot;Deploys dependencies layer&quot;
          :fn api/deploy-deps-layer
          :opts #{:aws-region :bb-arch :deps-layer-name}}
         {:cmds &#91;&quot;clean&quot;&#93;
          :desc &quot;Removes work and target folders&quot;
          :fn api/clean}&#93;&#93;
    &#40;conj cmds
          {:cmds &#91;&#93;, :fn &#40;fn &#91;m&#93; &#40;print-help cmds&#41;&#41;}&#41;&#41;&#41;
</code></pre><p>This makes sense, but now we need a way to turn a set of opts into a spec. Whilst we're at it, let's add the global options (<code>--target-dir</code> and <code>--work-dir</code>) to every spec:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def specs
  {:aws-region
   {:desc &quot;AWS region&quot;
    :ref &quot;&lt;region&gt;&quot;
    :default &#40;or &#40;System/getenv &quot;AWS&#95;DEFAULT&#95;REGION&quot;&#41; &quot;eu-west-1&quot;&#41;}
   ;; ...
  }&#41;

&#40;def global-opts #{:target-dir :work-dir}&#41;

&#40;defn mk-spec &#91;default-opts opts&#93;
  &#40;select-keys specs &#40;set/union global-opts opts&#41;&#41;&#41;
</code></pre><p>Now we can plug <code>mk-spec</code> into our table:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def table
  &#40;let &#91;cmds
        &#91;{:cmds &#91;&quot;build-runtime-layer&quot;&#93;
          :desc &quot;Builds Blambda custom runtime layer&quot;
          :fn api/build-runtime-layer
          :spec &#40;mk-spec #{:bb-version :bb-arch}&#41;}
         {:cmds &#91;&quot;build-deps-layer&quot;&#93;
          :desc &quot;Builds dependencies layer from bb.edn or deps.edn&quot;
          :fn api/build-deps-layer
          :spec &#40;mk-spec #{:deps-path}&#41;}
         {:cmds &#91;&quot;deploy-runtime-layer&quot;&#93;
          :desc &quot;Deploys Blambda custom runtime layer&quot;
          :fn api/deploy-runtime-layer
          :spec &#40;mk-spec #{:aws-region :bb-arch :runtime-layer-name}&#41;}
         {:cmds &#91;&quot;deploy-deps-layer&quot;&#93;
          :desc &quot;Deploys dependencies layer&quot;
          :fn api/deploy-deps-layer
          :spec &#40;mk-spec #{:aws-region :deps-layer-name}&#41;}
         {:cmds &#91;&quot;clean&quot;&#93;
          :desc &quot;Removes work and target folders&quot;
          :fn api/clean}&#93;&#93;
    &#40;conj cmds
          {:cmds &#91;&#93;, :fn &#40;fn &#91;m&#93; &#40;print-help cmds&#41;&#41;}&#41;&#41;&#41;
</code></pre><p>And since we have the spec for each subcommand, we can use that in our help message:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn -&gt;subcommand-help &#91;{:keys &#91;cmd desc spec&#93;}&#93;
  &#40;format &quot;%s: %s\n%s&quot; cmd desc
          &#40;cli/format-opts {:spec spec}&#41;&#41;&#41;

&#40;defn print-help &#91;cmds&#93;
  &#40;println
   &#40;format
    &quot;Usage: bb blambda &lt;subcommand&gt; &lt;options&gt;

All subcommands support the options:

%s

Subcommands:

%s&quot;
    &#40;cli/format-opts {:spec &#40;select-keys specs global-opts&#41;}&#41;
    &#40;-&gt;&gt; cmds
         &#40;map -&gt;subcommand-help&#41;
         &#40;str/join &quot;\n\n&quot;&#41;&#41;&#41;&#41;
  &#40;System/exit 0&#41;&#41;
</code></pre><p>Running <code>bb blambda</code> now is very satisfying:</p><pre class="language-text"><code class="lang-text language-text">Usage: bb blambda &lt;subcommand&gt; &lt;options&gt;

All subcommands support the options:

  --work-dir   &lt;dir&gt; .work  Working directory
  --target-dir &lt;dir&gt; target Build output directory

Subcommands:

build-runtime-layer: Builds Blambda custom runtime layer
  --bb-arch    &lt;arch&gt;    amd64   Architecture to target &#40;use amd64 if you don't care&#41;
  --work-dir   &lt;dir&gt;     .work   Working directory
  --target-dir &lt;dir&gt;     target  Build output directory
  --bb-version &lt;version&gt; 0.9.161 Babashka version

build-deps-layer: Builds dependencies layer from bb.edn or deps.edn
  --work-dir   &lt;dir&gt;  .work  Working directory
  --deps-path  &lt;path&gt;        Path to bb.edn or deps.edn containing lambda deps
  --target-dir &lt;dir&gt;  target Build output directory

deploy-runtime-layer: Deploys Blambda custom runtime layer
  --bb-arch            &lt;arch&gt;   amd64     Architecture to target &#40;use amd64 if you don't care&#41;
  --runtime-layer-name &lt;name&gt;   blambda   Name of custom runtime layer in AWS
  --work-dir           &lt;dir&gt;    .work     Working directory
  --aws-region         &lt;region&gt; eu-west-1 AWS region
  --target-dir         &lt;dir&gt;    target    Build output directory

deploy-deps-layer: Deploys dependencies layer
  --work-dir        &lt;dir&gt;    .work     Working directory
  --aws-region      &lt;region&gt; eu-west-1 AWS region
  --target-dir      &lt;dir&gt;    target    Build output directory
  --deps-layer-name &lt;name&gt;             Name of dependencies layer in AWS

clean: Removes work and target folders
</code></pre><p>There's one tiny annoyance, though. The usage message says that all subcommands support <code>--work-dir</code> and <code>--target-dir</code>, but then those options are repeated for every subcommand, which is a bit unnecessary and distracting. What we need to do is suppress the global options in <code>-&gt;subcommand-help</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn -&gt;subcommand-help &#91;{:keys &#91;cmd desc spec&#93;}&#93;
  &#40;let &#91;spec &#40;apply dissoc spec global-opts&#41;&#93;
    &#40;format &quot;%s: %s\n%s&quot; cmd desc
            &#40;cli/format-opts {:spec spec}&#41;&#41;&#41;&#41;
</code></pre><p><code>dissoc</code> is normally used to remove one key from a map:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;dissoc {:a 1, :b 2, :c 3} :a&#41;  ;; =&gt; {:b 2, :c 3}
</code></pre><p>but you can also give it more keys:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;dissoc {:a 1, :b 2, :c 3} :a :b&#41;  ;; =&gt; {:c 3}
</code></pre><p>We have a set, <code>global-opts</code>, which is seqable, so we can use <code>apply</code> to splat it onto the end of the list of arguments to <code>dissoc</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;let &#91;spec &#40;apply dissoc spec global-opts&#41;&#93;
  ;; Now spec has all of the opts except the global ones
  &#41;
</code></pre><p>Let's see what we've accomplished:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda
Usage: bb blambda &lt;subcommand&gt; &lt;options&gt;

All subcommands support the options:

  --work-dir   &lt;dir&gt; .work  Working directory
  --target-dir &lt;dir&gt; target Build output directory

Subcommands:

build-runtime-layer: Builds Blambda custom runtime layer
  --bb-arch    &lt;arch&gt;    amd64   Architecture to target &#40;use amd64 if you don't care&#41;
  --bb-version &lt;version&gt; 0.9.161 Babashka version

build-deps-layer: Builds dependencies layer from bb.edn or deps.edn
  --deps-path &lt;path&gt; Path to bb.edn or deps.edn containing lambda deps

deploy-runtime-layer: Deploys Blambda custom runtime layer
  --bb-arch            &lt;arch&gt;   amd64     Architecture to target &#40;use amd64 if you don't care&#41;
  --runtime-layer-name &lt;name&gt;   blambda   Name of custom runtime layer in AWS
  --aws-region         &lt;region&gt; eu-west-1 AWS region

deploy-deps-layer: Deploys dependencies layer
  --aws-region      &lt;region&gt; eu-west-1 AWS region
  --deps-layer-name &lt;name&gt;             Name of dependencies layer in AWS

clean: Removes work and target folders
</code></pre><p>Excellent!</p><p>We're still missing one thing that I showed off yesterday, though: the ability for a client to override Blambda's defaults. In the case of s3-log-parser, I want to make sure I'm building Blambda for the ARM64 architecture, and set my deps path and deps layer name so that I don't have to remember to type the args every time.</p><p>Let's start out by wishing the feature into existence in s3-log-parser's <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">:tasks
{:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;

 blambda {:doc &quot;Controls Blambda runtime and layers&quot;
          :task &#40;blambda/dispatch
                 {:bb-arch &quot;arm64&quot;
                  :deps-path &quot;src/bb.edn&quot;
                  :deps-layer-name &quot;s3-log-parser-deps&quot;}&#41;}}
</code></pre><p>So we want the client to be able to pass defaults to <code>blambda.cli/dispatch</code>. Let's make it happen:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn dispatch
  &#40;&#91;&#93;
   &#40;dispatch {}&#41;&#41;
  &#40;&#91;default-opts &amp; args&#93;
   &#40;cli/dispatch &#40;mk-table default-opts&#41;
                 &#40;or args
                     &#40;seq &#42;command-line-args&#42;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>Because we're good Clojurists, we maintain backwards compatibility by keeping the 0-arity version of <code>dispatch</code>, and just have it send an empty <code>default-opts</code> map into the new 1-arity version.</p><p>We're not going to be able to keep our static version of <code>table</code> anymore either, so we'll wrap it in a function called <code>mk-table</code> that incorporates our defaults:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn mk-table &#91;default-opts&#93;
  &#40;let &#91;cmds
        &#91;{:cmds &#91;&quot;build-runtime-layer&quot;&#93;
          :desc &quot;Builds Blambda custom runtime layer&quot;
          :fn api/build-runtime-layer
          :spec &#40;mk-spec default-opts #{:bb-version :bb-arch}&#41;}
         ;; ...
         &#93;&#93;
    &#40;conj cmds
          {:cmds &#91;&#93;, :fn &#40;fn &#91;m&#93; &#40;print-help default-opts cmds&#41;&#41;}&#41;&#41;&#41;
</code></pre><p>We need to pass the <code>default-opts</code> along to <code>mk-spec</code> and <code>print-help</code> as well:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn mk-spec &#91;default-opts opts&#93;
  &#40;-&gt;&gt; &#40;select-keys specs &#40;set/union global-opts opts&#41;&#41;
       &#40;apply-defaults default-opts&#41;&#41;&#41;

&#40;defn -&gt;subcommand-help &#91;default-opts {:keys &#91;cmd desc spec&#93;}&#93;
  &#40;let &#91;spec &#40;apply dissoc spec global-opts&#41;&#93;
    &#40;format &quot;%s: %s\n%s&quot; cmd desc
            &#40;cli/format-opts {:spec
                              &#40;apply-defaults default-opts spec&#41;}&#41;&#41;&#41;&#41;

&#40;defn print-help &#91;default-opts cmds&#93;
  &#40;println
   &#40;format
    &quot;Usage: bb blambda &lt;subcommand&gt; &lt;options&gt; ...&quot;
    &#40;cli/format-opts {:spec &#40;select-keys specs global-opts&#41;}&#41;
    &#40;-&gt;&gt; cmds
         &#40;map &#40;partial -&gt;subcommand-help default-opts&#41;&#41;
         &#40;str/join &quot;\n\n&quot;&#41;&#41;&#41;&#41;
  &#40;System/exit 0&#41;&#41;
</code></pre><p>And finally, let's look at this mysterious new <code>apply-defaults</code> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn apply-defaults &#91;default-opts spec&#93;
  &#40;-&gt;&gt; spec
       &#40;map &#40;fn &#91;&#91;k v&#93;&#93;
              &#40;if-let &#91;default-val &#40;default-opts k&#41;&#93;
                &#91;k &#40;assoc v :default default-val&#41;&#93;
                &#91;k v&#93;&#41;&#41;&#41;
       &#40;into {}&#41;&#41;&#41;
</code></pre><p>If we run <code>bb blambda help</code> now, we can see the effects of our changes:</p><pre class="language-text"><code class="lang-text language-text">Usage: bb blambda &lt;subcommand&gt; &lt;options&gt;
&#91;...&#93;
Subcommands:

build-runtime-layer: Builds Blambda custom runtime layer
  --bb-arch    &lt;arch&gt;    arm64   Architecture to target &#40;use amd64 if you don't care&#41;
  --bb-version &lt;version&gt; 0.9.161 Babashka version

build-deps-layer: Builds dependencies layer from bb.edn or deps.edn
  --deps-path &lt;path&gt; src/bb.edn Path to bb.edn or deps.edn containing lambda deps

deploy-deps-layer: Deploys dependencies layer
  --aws-region      &lt;region&gt; eu-west-1          AWS region
  --deps-layer-name &lt;name&gt;   s3-log-parser-deps Name of dependencies layer in AWS
</code></pre><p>Note that <code>--bb-arch</code> now defaults to <code>arm64</code>, and <code>--deps-path</code> and <code>--deps-layer-name</code> now have default values, which they didn't before! 🎉</p><p>I realise I'm quite a few words into this post now, but I do want to add one more tiny feature. With a subcommand setup, you expect `bb blambda build-runtime-layer &ndash;help<code> to give you help on the </code>build-runtime-layer` subcommand, but at the moment, our code just ignores the <code>--help</code> flag and calls the <code>api/build-runtime-layer</code> function, which is definitely not what we want.</p><p>In order to support this, let's wrap the <code>api/build-runtime-layer</code> in a function that checks for <code>--help</code> and does the right thing:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn mk-table &#91;default-opts&#93;
  &#40;let &#91;cmds
        &#91;{:cmds &#91;&quot;build-runtime-layer&quot;&#93;
          :desc &quot;Builds Blambda custom runtime layer&quot;
          :fn &#40;fn &#91;opts&#93;
                &#40;when &#40;:help opts&#41;
                  &#40;print-command-help cmd spec&#41;
                  &#40;System/exit 0&#41;&#41;
                &#40;api/build-runtime-layer&#41;&#41;
          :spec &#40;mk-spec default-opts #{:bb-version :bb-arch}&#41;}
         ;; ...
         &#93;&#93;
    &#40;conj cmds
          {:cmds &#91;&#93;, :fn &#40;fn &#91;m&#93; &#40;print-help default-opts cmds&#41;&#41;}&#41;&#41;&#41;
</code></pre><p>We can define <code>print-command-help</code> as follows:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn print-command-help &#91;cmd spec&#93;
  &#40;println
   &#40;format &quot;Usage: bb blambda %s &lt;options&gt;\n\nOptions:\n%s&quot;
           cmd &#40;cli/format-opts {:spec spec}&#41;&#41;&#41;&#41;
</code></pre><p>Now things work as expected:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-runtime-layer --help
Usage: bb blambda build-runtime-layer &lt;options&gt;

Options:
  --bb-arch    &lt;arch&gt;    arm64   Architecture to target &#40;use amd64 if you don't care&#41;
  --work-dir   &lt;dir&gt;     .work   Working directory
  --target-dir &lt;dir&gt;     target  Build output directory
  --bb-version &lt;version&gt; 0.9.161 Babashka version
</code></pre><p>Of course, now we're in the somewhat unpleasant situation of having to copy and paste our wrapper for all of the other subcommands, so let's create a function to do this for us:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn mk-cmd &#91;default-opts {:keys &#91;cmd spec&#93; :as cmd-opts}&#93;
  &#40;merge
   cmd-opts
   {:cmds &#91;cmd&#93;
    :fn &#40;fn &#91;{:keys &#91;opts&#93;}&#93;
          &#40;when &#40;:help opts&#41;
            &#40;print-command-help cmd spec&#41;
            &#40;System/exit 0&#41;&#41;
          &#40;&#40;:fn cmd-opts&#41; opts&#41;&#41;}&#41;&#41;
</code></pre><p>Now we can use this function in <code>mk-table</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn mk-table &#91;default-opts&#93;
  &#40;let &#91;cmds
        &#91;{:cmd &quot;build-runtime-layer&quot;
          :desc &quot;Builds Blambda custom runtime layer&quot;
          :fn api/build-runtime-layer
          :spec &#40;mk-spec default-opts #{:bb-version :bb-arch}&#41;}
         ;; ...
         &#93;&#93;
    &#40;conj &#40;mapv &#40;partial mk-cmd default-opts&#41; cmds&#41;
          {:cmds &#91;&#93;, :fn &#40;fn &#91;m&#93; &#40;print-help default-opts cmds&#41;&#41;}&#41;&#41;&#41;
</code></pre><p>So <code>--help</code> now works for all subcommands. And since we now have <code>mk-cmd</code> wrapping our subcommand function for us, let's also ensure that all options are set (we have no optional options here):</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn mk-cmd &#91;default-opts {:keys &#91;cmd spec&#93; :as cmd-opts}&#93;
  &#40;merge
   cmd-opts
   {:cmds &#91;cmd&#93;
    :fn &#40;fn &#91;{:keys &#91;opts&#93;}&#93;
          &#40;let &#91;missing-args &#40;-&gt;&gt; &#40;set &#40;keys opts&#41;&#41;
                                  &#40;set/difference &#40;set &#40;keys spec&#41;&#41;&#41;
                                  &#40;map #&#40;format &quot;--%s&quot; &#40;name %&#41;&#41;&#41;
                                  &#40;str/join &quot;, &quot;&#41;&#41;&#93;
            &#40;when &#40;:help opts&#41;
              &#40;print-command-help cmd spec&#41;
              &#40;System/exit 0&#41;&#41;
            &#40;when-not &#40;empty? missing-args&#41;
              &#40;error {:cmd cmd, :spec spec}
                     &#40;format &quot;Missing required arguments: %s&quot; missing-args&#41;&#41;&#41;
            &#40;&#40;:fn cmd-opts&#41; opts&#41;&#41;&#41;}&#41;&#41;
</code></pre><p>The <code>error</code> function just formats a nice error message and exits:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn error &#91;{:keys &#91;cmd spec&#93;} msg&#93;
  &#40;println &#40;format &quot;%s\n&quot; msg&#41;&#41;
  &#40;print-command-help cmd spec&#41;
  &#40;System/exit 1&#41;&#41;
</code></pre><p>We can test this by commenting out the <code>:deps-path</code> key-value pair in s3-log-parser's <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">:tasks
{:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;

 blambda {:doc &quot;Controls Blambda runtime and layers&quot;
          :task &#40;blambda/dispatch
                 {:bb-arch &quot;arm64&quot;
;;                  :deps-path &quot;src/bb.edn&quot;
                  :deps-layer-name &quot;s3-log-parser-deps&quot;}&#41;}}
</code></pre><p>and then not mentioning <code>--deps-path</code> on the command line:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-deps-layer
Missing required arguments: --deps-path

Usage: bb blambda build-deps-layer &lt;options&gt;

Options:
  --work-dir   &lt;dir&gt;  .work  Working directory
  --deps-path  &lt;path&gt;        Path to bb.edn or deps.edn containing lambda deps
  --target-dir &lt;dir&gt;  target Build output directory
</code></pre><p>With that, we have achieved a great victory and can now move onto another activity (in my case, sleeping).</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-10-dogfooding-blambda-cli.html</id>
    <link href="https://jmglov.net/blog/2022-08-10-dogfooding-blambda-cli.html"/>
    <title>Dogfooding Blambda 3: CLIify this!</title>
    <updated>2022-08-10T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>When <a href='2022-08-09-dogfooding-blambda-2.html'>last we left</a> the epic tale of me trying to use a thing that I made myself and then being outraged at how poorly made the thing was, I had just gotten dependencies stuffed in a lambda layer and proved that it worked by deploying a lambda that listed some files from an S3 bucket. Hurrah!</p><p>Of course, I ended that post by complaining about the incredibly bad UX of the thing I had built, and a vague promise to make it less incredibly bad (never let good be the enemy of less bad!). Since last we spoke, I did just that. Check it out!</p><p>So I create a directory called <code>my-lambda</code>, then add a <code>bb.edn</code> like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {net.jmglov/blambda
        #&#95;&quot;You use the newest SHA here:&quot;
        {:git/url &quot;https://github.com/jmglov/blambda.git&quot;
         :git/sha &quot;2453e15cf75c03b2b02de5ca89c76081bba40251&quot;}}
 :tasks
 {:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;
  blambda {:doc &quot;Controls Blambda runtime and layers&quot;
           :task &#40;blambda/dispatch&#41;}}}
</code></pre><p>This is enough to let me use Blambda:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda
Usage: bb blambda &lt;subcommand&gt; &lt;options&gt;

All subcommands support the options:

  --target-dir &lt;dir&gt; target Build output directory
  --work-dir   &lt;dir&gt; .work  Working directory

Subcommands:

build-runtime-layer: Builds Blambda custom runtime layer
  --bb-version &lt;version&gt; 0.9.161 Babashka version
  --bb-arch    &lt;arch&gt;            Architecture to target &#40;use amd64 if you don't care&#41;

build-deps-layer: Builds dependencies layer from bb.edn or deps.edn
  --deps-path &lt;path&gt; Path to bb.edn or deps.edn containing lambda deps

deploy-runtime-layer: Deploys Blambda custom runtime layer
  --aws-region         &lt;region&gt; eu-west-1 AWS region
  --runtime-layer-name &lt;name&gt;   blambda   Name of custom runtime layer in AWS
  --bb-arch            &lt;arch&gt;   amd64     Architecture to target

deploy-deps-layer: Deploys dependencies layer
  --aws-region      &lt;region&gt; eu-west-1 AWS region
  --deps-layer-name &lt;name&gt;             Name of dependencies layer in AWS

clean: Removes work and target folders
</code></pre><p>I can create a Blambda runtime:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-runtime-layer --help
Usage: bb blambda build-runtime-layer &lt;options&gt;

Options:
  --target-dir &lt;dir&gt;     target  Build output directory
  --work-dir   &lt;dir&gt;     .work   Working directory
  --bb-version &lt;version&gt; 0.9.161 Babashka version
  --bb-arch    &lt;arch&gt;            Architecture to target

$ bb blambda build-runtime-layer --bb-arch arm64
Downloading https://github.com/babashka/babashka/releases/download/v0.9.161/babashka-0.9.161-linux-aarch64-static.tar.gz
Decompressing .work/babashka-0.9.161-linux-aarch64-static.tar.gz to .work
Adding file bootstrap
Adding file bootstrap.clj
Compressing custom runtime layer: &#126;/my-lambda/target/bb.zip
</code></pre><p>And deploy it:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda deploy-runtime-layer --bb-arch arm64
Publishing layer version for layer blambda
Published layer arn:aws:lambda:eu-west-1:289341159200:layer:blambda:1
</code></pre><p>Now, let's say I want my lambda to do S3 stuff using <a href='https://github.com/grzm/awyeah-api'>awyeah-api</a>. I'll create a <code>src</code> directory and drop a <code>bb.edn</code> in there:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;.&quot;&#93;
 :deps {com.cognitect.aws/endpoints {:mvn/version &quot;1.1.12.206&quot;}
        com.cognitect.aws/s3 {:mvn/version &quot;822.2.1109.0&quot;}
        com.grzm/awyeah-api {:git/url &quot;https://github.com/grzm/awyeah-api&quot;
                             :git/sha &quot;0fa7dd51f801dba615e317651efda8c597465af6&quot;}
        org.babashka/spec.alpha {:git/url &quot;https://github.com/babashka/spec.alpha&quot;
                                 :git/sha &quot;433b0778e2c32f4bb5d0b48e5a33520bee28b906&quot;}}}
</code></pre><p>I can now use Blambda to create a lambda layer containing all my dependencies:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-deps-layer
Missing required arguments: --deps-path

Usage: bb blambda build-deps-layer &lt;options&gt;

Options:
  --target-dir &lt;dir&gt;  target Build output directory
  --work-dir   &lt;dir&gt;  .work  Working directory
  --deps-path  &lt;path&gt;        Path to bb.edn or deps.edn containing lambda deps
</code></pre><p>Oops! Looks like I forgot the <code>--deps-path</code> argument. Let's try that again:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-deps-layer --deps-path src/bb.edn 
Cloning: https://github.com/grzm/awyeah-api
Downloading: com/cognitect/aws/endpoints/1.1.12.206/endpoints-1.1.12.206.pom from central
Downloading: org/clojure/clojure/1.11.1/clojure-1.11.1.pom from central
Downloading: com/cognitect/aws/s3/822.2.1109.0/s3-822.2.1109.0.pom from central
Checking out: https://github.com/grzm/awyeah-api at 0fa7dd51f801dba615e317651efda8c597465af6
Cloning: https://github.com/babashka/spec.alpha
Checking out: https://github.com/babashka/spec.alpha at 433b0778e2c32f4bb5d0b48e5a33520bee28b906
Downloading: org/clojure/spec.alpha/0.3.218/spec.alpha-0.3.218.pom from central
Downloading: org/clojure/core.specs.alpha/0.2.62/core.specs.alpha-0.2.62.pom from central
Downloading: org/clojure/pom.contrib/1.1.0/pom.contrib-1.1.0.pom from central
Downloading: com/cognitect/aws/endpoints/1.1.12.206/endpoints-1.1.12.206.jar from central
Downloading: com/cognitect/aws/s3/822.2.1109.0/s3-822.2.1109.0.jar from central
Downloading: org/clojure/spec.alpha/0.3.218/spec.alpha-0.3.218.jar from central
Downloading: org/clojure/clojure/1.11.1/clojure-1.11.1.jar from central
Downloading: org/clojure/core.specs.alpha/0.2.62/core.specs.alpha-0.2.62.jar from central
Classpath before transforming: src:&#126;/my-lambda/.work/m2-repo/com/cognitect/aws/endpoints/1.1.12.206/endpoints-1.1.12.206.jar:...

Classpath after transforming: src:/opt/m2-repo/com/cognitect/aws/endpoints/1.1.12.206/endpoints-1.1.12.206.jar:...

Compressing custom runtime layer: &#126;/my-lambda/target/deps.zip
</code></pre><p>What that wall of text (sorry about that!) is saying is that Blambda is downloading all of the dependencies my lambda has declared in <code>bb.edn</code> and zipping them up into a layer, which I can deploy like this:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda deploy-deps-layer --deps-layer-name my-lambda-deps
Publishing layer version for layer my-lambda-deps
Published layer arn:aws:lambda:eu-west-1:289341159200:layer:my-lambda-deps:1
</code></pre><p>It is a little annoying to have to remember those arguments every time, so let's see what we can do about that. If I go back to my top-level <code>bb.edn</code>, I can specify some defaults for my lambda:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:deps {net.jmglov/blambda
        #&#95;&quot;You use the newest SHA here:&quot;
        {:git/url &quot;https://github.com/jmglov/blambda.git&quot;
         :git/sha &quot;2453e15cf75c03b2b02de5ca89c76081bba40251&quot;}}
 :tasks
 {:requires &#40;&#91;blambda.cli :as blambda&#93;&#41;
  :init &#40;def opts {:deps-path &quot;src/bb.edn&quot;
                   :deps-layer-name &quot;my-lambda-deps&quot;
                   :bb-arch &quot;arm64&quot;}&#41;
  blambda {:doc &quot;Controls Blambda runtime and layers&quot;
           :task &#40;blambda/dispatch opts&#41;}}}
</code></pre><p>By adding the <code>opts</code> there, I've saved myself the trouble of typing <code>--bb-arch</code>, <code>--deps-path</code>, and <code>--deps-layer-name</code>:</p><pre class="language-text"><code class="lang-text language-text">$ bb blambda build-deps-layer
Classpath before transforming: src:&#126;/my-lambda/.work/m2-repo/com/cognitect/aws/endpoints/1.1.12.206/endpoints-1.1.12.206.jar:...

Classpath after transforming: src:/opt/m2-repo/com/cognitect/aws/endpoints/1.1.12.206/endpoints-1.1.12.206.jar:...

Compressing custom runtime layer: &#126;/my-lambda/target/deps.zip
</code></pre><p>I can now create a lambda in the AWS console that uses Blambda and my deps layer.</p><p><img src="assets/2022-08-10-create-lambda.png" alt="AWS Lambda console showing create function dialog" title="Click the button!" width=800px] /></p><p>Now I need to add the custom runtime layer:</p><p><img src="assets/2022-08-10-add-runtime.png" alt="AWS Lambda console showing add layer dialog with Blambda layer selected" title="Click the button!" width=800px] /></p><p>And the deps layer:</p><p><img src="assets/2022-08-10-add-deps.png" alt="AWS Lambda console showing add layer dialog with deps layer selected" title="Click the button!" width=800px] /></p><p>Looks nice! Now how about some Clojure code that does something exciting?</p><p>We can delete the files that AWS has put there and replace them with a <code>my&#95;lambda.clj</code> that looks like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns my-lambda
  &#40;:require &#91;cheshire.core :as json&#93;&#41;&#41;

&#40;defn handler &#91;event &#95;context&#93;
  &#40;let &#91;body {:msg &quot;Lambda handler invoked&quot;
              :data {:event event}}&#93;
    &#40;prn body&#41;
    {:status 200
     :body &#40;json/generate-string body&#41;}&#41;&#41;
</code></pre><p>The Cheshire JSON library is included free of charge by <a href='https://github.com/babashka/babashka'>Babashka</a>, so we can play with JSON despite not mentioning it in our <code>src/bb.edn</code>. Amazing!</p><p>We also need to change <strong>Runtime settings > Handler</strong> to <code>my-lambda/handler</code>, then click the <strong>Deploy</strong> button to get our lambda out there in the world!</p><p><img src="assets/2022-08-10-code.png" alt="AWS Lambda console showing the code described above" title="Such succinctness!" width=800px] /></p><p>After deploying, we excitedly click the <strong>Test</strong> button, only to be told that we need to configure a test event! 😭 No matter, we'll just go with the hello-world template and hope for the best.</p><p><img src="assets/2022-08-10-test-event.png" alt="AWS Lambda console showing a simple JSON test event" title="There's nothing like JSON to delight a crowd!" width=800px] /></p><p>Now that we have a test event configured, let's click <strong>Test</strong> again... and celebrate a job well done! 🎉</p><p><img src="assets/2022-08-10-victory.png" alt="AWS Lambda console showing a successful lambda execution" title="Victory!" width=800px] /></p><p>I had actually intended to explain the CLI stuff in this post, but this is already a bit long and my dog is starting to give me meaningful looks, so I'd better end this thrilling instalment of <a href='tags/blambda.html'>Dogfooding Blambda</a> here and take him out for a walk.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-09-dogfooding-blambda-2.html</id>
    <link href="https://jmglov.net/blog/2022-08-09-dogfooding-blambda-2.html"/>
    <title>Dogfooding Blambda : I heard you liked layers</title>
    <updated>2022-08-09T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>After a brief detour to port my blog to <a href='https://github.com/borkdude/quickblog'>quickblog</a> and enjoy some sun in Málaga, I'm back to eating yummy dogfood by trying to use <a href='https://github.com/jmglov/blambda'>Blambda</a> to parse my HTTP access logs: <a href='https://github.com/jmglov/s3-log-parser'>jmglov/s3-log-parser</a>.</p><p>I didn't realise it until I started writing this post, but it has actually been a month since I last used Blambda; I'm sure you all remember the wacky hijinks that ensued when <a href='2022-07-04-dogfooding-blambda-1.html'>I tried to make Blambda work with Babashka
pods</a>. Well, more wacky hijinks ensued when I came back to the project and realised that the released version of the <a href='https://github.com/babashka/pod-babashka-aws'>babasha-aws pod</a> was compiled for AMD64, and my lambda is of course ARM64 because I want to be cool. Instead of doing what a reasonable person would do and switch to an AMD64 lambda, I decided instead to use <a href='https://github.com/grzm/awyeah-api'>awyeah-api</a>, which is a port of Cognitect's <a href='https://github.com/cognitect-labs/aws-api'>aws-api</a> to Babashka. You know, because it's cool to not have to rely on an external binary and bbencode and all of that stuff. 😉</p><p>Of course, Blambda didn't actually support dependencies outside of pods, so the first step to switching to awyeah-api was implementing regular deps in Blambda. How hard could it be, right?</p><p>Just like I did for pods, I wanted to keep all deps in a layer so that it would be fast to deploy new versions of the lambda itself, and deps typically don't change very often. This should be pretty straightforward: just get Babashka to download my deps and zip them up into a layer.</p><p>I created a <code>bb.edn</code> with all of the awyeah-api stuff:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;.&quot;&#93;
 :deps {com.cognitect.aws/endpoints {:mvn/version &quot;1.1.12.206&quot;}
        com.cognitect.aws/s3 {:mvn/version &quot;822.2.1109.0&quot;}
        com.grzm/awyeah-api {:git/url &quot;https://github.com/grzm/awyeah-api&quot;
                             :git/sha &quot;0fa7dd51f801dba615e317651efda8c597465af6&quot;}
        org.babashka/spec.alpha {:git/url &quot;https://github.com/babashka/spec.alpha&quot;
                                 :git/sha &quot;433b0778e2c32f4bb5d0b48e5a33520bee28b906&quot;}}}
</code></pre><p>Now, running <code>bb tasks</code> in that directory downloaded all of the dependencies into my local Maven cache, <code>&#126;/.m2/repository</code>, so I just need to grab them and stuff them into a zip file. Except there seem to be about a million Java libraries in that directory, and I definitely don't want more than I actually need, since I want to keep my layer zipfile as small as possible. So what I would like to do is create a new directory and tell Babashka to use that as the Maven cache, so I know that everything downloaded there belongs to my lambda. But how to tell Babashka where I want my deps?</p><p>Searching the web wasn't much help, mainly because I couldn't find the right magic phrase, so I turned to Clojurians Slack for help. <a href='https://github.com/borkdude'>borkdude</a> himself stopped by and told me that Babashka uses the <a href='https://clojure.org/reference/deps_and_cli'>Clojure CLI</a> to resolve dependencies, and then Alex Miller (who must have a Slack alert for any mention of <code>-Sdeps</code>) told me about the <a href='https://clojure.org/reference/deps_and_cli#_dependencies'><code>:mvn/local-repo</code>
key</a>, which does exactly what I want. Excellent!</p><p>And then I ran into the next issue: in addition to Maven dependencies, I also have Git dependencies, and these are handled differently. Going back to the <a href='https://clojure.org/reference/deps_and_cli#_configuration_and_debugging'>Deps and CLI
Reference</a>, I found that one can set the <code>GITLIBS</code> environment variable to tell the CLI where to cache Git deps.</p><p>So now I knew how to get all of my deps right where I wanted them, so it was time to automate things with Babashka. I created a new <code>build-deps</code> task in Blamba's <code>bb.edn</code>. The first step was to consume the lambda's <code>bb.edn</code> and add the <code>:mvn/local-repo</code> key to it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">build-deps {:docs &quot;Builds a layer for dependencies&quot;
            :requires &#40;&#91;clojure.edn :as edn&#93;&#41;
            :task &#40;let &#91;{:keys &#91;deps-path work-dir&#93;} &#40;th/parse-args&#41;&#93;
                    &#40;when-not deps-path
                      &#40;th/error &quot;Mising required argument: --deps-path&quot;&#41;&#41;
                    &#40;fs/create-dirs work-dir&#41;
                    &#40;let &#91;m2-dir &quot;m2-repo&quot;
                          deps &#40;-&gt;&gt; deps-path slurp edn/read-string :deps&#41;&#93;
                      &#40;spit &#40;fs/file work-dir &quot;deps.edn&quot;&#41;
                            {:deps deps
                             :mvn/local-repo &#40;str m2-dir&#41;}&#41;&#41;&#41;}
</code></pre><p>This will create a <code>.work/deps.edn</code> file that contains all of the lambda's dependency, plus the magic <code>:mvn/local-repo</code> key to tell <code>tools.deps</code> to use <code>.work/m2-repo</code> for my Maven deps.</p><p>Now I need to run <code>clojure</code> somehow to download the deps into the right place. Chatting to borkdude revealed that there's a <code>babashka.deps/clojure</code> function that invokes the Clojure CLI. Awesome, so I can use that! I just need to not forget to set the <code>GITLIBS</code> env var to <code>.work/gitlibs</code> to make sure the Git deps end up in the right place.</p><pre class="language-clojure"><code class="lang-clojure language-clojure">build-deps {:docs &quot;Builds a layer for dependencies&quot;
            :requires &#40;&#91;clojure.edn :as edn&#93;
                       &#91;babashka.deps :refer &#91;clojure&#93;&#93;&#41;
            :task &#40;let &#91;{:keys &#91;deps-path work-dir&#93;} &#40;th/parse-args&#41;&#93;
                    ;; ...
                    &#40;let &#91;gitlibs-dir &quot;gitlibs&quot;
                          m2-dir &quot;m2-repo&quot;
                          deps &#40;-&gt;&gt; deps-path slurp edn/read-string :deps&#41;&#93;
                      ;; ...
                      &#40;clojure &#91;&quot;-Spath&quot;&#93;
                               {:dir work-dir
                                :env &#40;assoc &#40;into {} &#40;System/getenv&#41;&#41;
                                            &quot;GITLIBS&quot; &#40;str gitlibs-dir&#41;&#41;}&#41;&#41;&#41;}
</code></pre><p>And now that I have all the deps, I just need to zip them up:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">build-deps {:docs &quot;Builds a layer for dependencies&quot;
            :requires &#40;&#91;clojure.java.shell :refer &#91;sh&#93;&#93;
                       &#91;clojure.edn :as edn&#93;
                       &#91;babashka.deps :refer &#91;clojure&#93;&#93;&#41;
            :task &#40;let &#91;{:keys &#91;deps-path target-dir work-dir&#93;} &#40;th/parse-args&#41;
                        deps-zipfile &#40;th/deps-zipfile target-dir&#41;&#93;
                    ;; ...
                    &#40;fs/create-dirs target-dir work-dir&#41;
                    &#40;let &#91;gitlibs-dir &quot;gitlibs&quot;
                          m2-dir &quot;m2-repo&quot;
                          deps &#40;-&gt;&gt; deps-path slurp edn/read-string :deps&#41;&#93;
                      ;; ...
                      &#40;println &quot;Compressing custom runtime layer:&quot; deps-zipfile&#41;
                      &#40;let &#91;{:keys &#91;exit err&#93;}
                            &#40;sh &quot;zip&quot; &quot;-r&quot; deps-zipfile
                                &#40;fs/file-name gitlibs-dir&#41;
                                &#40;fs/file-name m2-dir&#41;
                                :dir work-dir&#41;&#93;
                        &#40;when &#40;not= 0 exit&#41;
                          &#40;println &quot;Error:&quot; err&#41;&#41;&#41;&#41;&#41;}
</code></pre><p>This gives me a <code>target/deps.zip</code> that I can use as a layer. Lambda layers unzip to <code>/opt</code>, so I'll need to update the Blambda runtime to add <code>GITLIBS=/opt/gitlibs</code> to my Babashka invocation and update my lambda's <code>bb.edn</code> to include <code>:mvn/local-repo &quot;/opt/m2-repo&quot;</code>, and then of course add the deps layer to my lambda function.</p><p>Once all this was done, I took a deep breath and pressed the <strong>Test</strong> button in the AWS Lambda console. And of course got an error. 😭</p><pre class="language-text"><code class="lang-text language-text">Exception in thread &quot;main&quot; java.lang.Exception: Couldn't find 'java'.
Please set JAVA&#95;HOME.
  at borkdude.deps$&#95;main.invokeStatic&#40;deps.clj:436&#41;
  at borkdude.deps$&#95;main.doInvoke&#40;deps.clj:425&#41;
  at clojure.lang.RestFn.applyTo&#40;RestFn.java:137&#41;
  at clojure.core$apply.invokeStatic&#40;core.clj:667&#41;
  at babashka.impl.deps$add&#95;deps$fn&#95;&#95;26630$fn&#95;&#95;26631.invoke&#40;deps.clj:92&#41;
  at babashka.impl.deps$add&#95;deps$fn&#95;&#95;26630.invoke&#40;deps.clj:92&#41;
  at babashka.impl.deps$add&#95;deps.invokeStatic&#40;deps.clj:92&#41;
  at babashka.main$exec.invokeStatic&#40;main.clj:789&#41;
  at babashka.main$main.invokeStatic&#40;main.clj:1052&#41;
  at babashka.main$main.doInvoke&#40;main.clj:1027&#41;
  at clojure.lang.RestFn.applyTo&#40;RestFn.java:137&#41;
  at clojure.core$apply.invokeStatic&#40;core.clj:667&#41;
  at babashka.main$&#95;main.invokeStatic&#40;main.clj:1085&#41;
  at babashka.main$&#95;main.doInvoke&#40;main.clj:1077&#41;
  at clojure.lang.RestFn.applyTo&#40;RestFn.java:137&#41;
  at babashka.main.main&#40;Unknown Source&#41;
</code></pre><p>Yikes! I guess this makes sense, though. Babashka is using the Clojure CLI to resolve dependencies, and the Clojure CLI needs a JVM. Unfortunately, this won't work for me, because the lambda container doesn't have Java installed.</p><p>At this point, having learned a lot about what's going on under the hood in Babashka, I decided to cheat and see how <a href='https://github.com/FieryCod/holy-lambda'>Holy
Lambda</a> solved this problem for the Babashka backend. Here's what <a href='https://github.com/FieryCod/holy-lambda/blob/master/modules/holy-lambda-babashka-layer/bootstrap'>HL's bootstrap
script</a> looks like:</p><pre class="language-bash"><code class="lang-bash language-bash">#!/bin/sh

set -e

export BABASHKA&#95;DISABLE&#95;SIGNAL&#95;HANDLERS=&quot;true&quot;

export XDG&#95;CACHE&#95;HOME=/opt
export XDG&#95;CONFIG&#95;HOME=/opt
export XDG&#95;DATA&#95;HOME=/opt
export HOME=/var/task
export GITLIBS=/opt
export CLOJURE&#95;TOOLS&#95;DIR=/opt
export CLJ&#95;CACHE=/opt
export CLJ&#95;CONFIG=/opt

export BABASHKA&#95;CLASSPATH=&quot;/opt/.m2:var/task/src:/var/task/.m2:/var/task:/var/task/src/clj:/var/task/src/cljc:src/cljc:src/clj:/var/task/resources&quot;
export BABASHKA&#95;PRELOADS='&#40;load-file &quot;/opt/hacks.clj&quot;&#41;'

if &#91; -z &quot;$HL&#95;ENTRYPOINT&quot; &#93;; then
  echo &quot;Environment variable \&quot;HL&#95;ENTRYPOINT\&quot; is not set. See https://fierycod.github.io/holy-lambda/#/babashka-backend-tutorial&quot;
fi;

/opt/bb -Duser.home=/var/task -m &quot;$HL&#95;ENTRYPOINT&quot;
</code></pre><p>Aha! HL sets the Babashka classpath explicitly so that Babashka won't need to do any resolution, and furthermore, that exciting looking <a href='https://github.com/FieryCod/holy-lambda/blob/master/modules/holy-lambda-babashka-layer/hacks.clj'><code>hacks.clj</code></a> actually disables dependency resolution altogether:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;require '&#91;babashka.deps&#93;&#41;

&#40;alter-var-root
 #'babashka.deps/add-deps
 &#40;fn &#91;f&#93;
   &#40;fn &#91;m&#93;
     &#40;println &quot;&#91;holy-lambda&#93; Dependencies should not be added via add-deps. Move your dependencies to a layer!&quot;&#41;
     &#40;System/exit 1&#41;&#41;&#41;&#41;
</code></pre><p>Sneaky but awesome! I'll have to copy this later, but for now, let me see if I can just make things work.</p><p>I'll take inspiration from Holy Lambda and set the classpath explicitly, so the first thing to do is figure out what the classpath should be. Luckily, I know how to calculate a classpath: <code>clj -Spath</code>. In fact, my <code>build-deps</code> code is already using for its side effect of downloading dependencies, so all I have to do is capture its output. I already noticed that <code>babashka.deps/clojure</code> is printing the classpath to standard output, so if I wrap this in a <a href='https://clojuredocs.org/clojure.core/with-out-str'><code>with-out-str</code></a>, I'm all good:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;let &#91;classpath
      &#40;with-out-str
        &#40;clojure &#91;&quot;-Spath&quot;&#93;
                 {:dir work-dir
                  :env &#40;assoc &#40;into {} &#40;System/getenv&#41;&#41;
                              &quot;GITLIBS&quot; &#40;str gitlibs-dir&#41;&#41;}&#41;&#41;&#93;
  &#40;println &quot;Classpath:&quot; classpath&#41;
  ;; ...
&#41;
</code></pre><p>Running <code>bb build-deps --deps-path ../s3-log-parser/src/bb.edn</code>, I see:</p><pre class="language-text"><code class="lang-text language-text">Classpath: src:&#126;/.work/m2-repo/com/cognitect/aws/endpoints/1.1.12.206/endpoints-1.1.12.206.jar:...
</code></pre><p>and so on. Cool! Now I have a classpath. The only problem with this is that it is an absolute path to my <code>.work</code> directory, and my dependencies are going to end up in <code>/opt</code> when I add my deps layer to my lambda. So I'll need to rewrite the classpath to say <code>/opt</code> everywhere it currently says <code>&#126;/.work</code>. No worries, Clojure can do that:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;let &#91;deps-base-dir &#40;str &#40;fs/path &#40;fs/cwd&#41; work-dir&#41;&#41;
      classpath
      &#40;with-out-str
        &#40;clojure &#91;&quot;-Spath&quot;&#93;
                 {:dir work-dir
                  :env &#40;assoc &#40;into {} &#40;System/getenv&#41;&#41;
                              &quot;GITLIBS&quot; &#40;str gitlibs-dir&#41;&#41;}&#41;&#41;
      deps-classpath &#40;str/replace classpath deps-base-dir &quot;/opt&quot;&#41;&#93;
  &#40;println &quot;Classpath before transforming:&quot; classpath&#41;
  &#40;println &quot;Classpath after transforming:&quot; deps-classpath&#41;
  ;; ...
&#41;
</code></pre><p>Running this gives me what I'm looking for:</p><pre class="language-text"><code class="lang-text language-text">Classpath before transforming: src:&#126;/.work/m2-repo/com/cognitect/aws/endpoints/1.1.12.206/endpoints-1.1.12.206.jar:...
Classpath after transforming: src:/opt/m2-repo/com/cognitect/aws/endpoints/1.1.12.206/endpoints-1.1.12.206.jar:...
</code></pre><p>Victory! 🎉</p><p>The next step is to pass that classpath along to Blambda so that it can set it when invoking <code>bb</code> in the custom runtime. I decided to write the classpath to a file that can be included in the deps layer, which Blambda will read when initialising the runtime:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;let &#91;classpath-file &#40;fs/file work-dir &quot;deps-classpath&quot;&#41;
      deps-base-dir &#40;str &#40;fs/path &#40;fs/cwd&#41; work-dir&#41;&#41;
      classpath
      &#40;with-out-str
        &#40;clojure &#91;&quot;-Spath&quot;&#93;
                 {:dir work-dir
                  :env &#40;assoc &#40;into {} &#40;System/getenv&#41;&#41;
                              &quot;GITLIBS&quot; &#40;str gitlibs-dir&#41;&#41;}&#41;&#41;
      deps-classpath &#40;str/replace classpath deps-base-dir &quot;/opt&quot;&#41;&#93;
  &#40;println &quot;Classpath before transforming:&quot; classpath&#41;
  &#40;println &quot;Classpath after transforming:&quot; deps-classpath&#41;
  &#40;spit classpath-file deps-classpath&#41;

  &#40;println &quot;Compressing custom runtime layer:&quot; deps-zipfile&#41;
  &#40;let &#91;{:keys &#91;exit err&#93;}
        &#40;sh &quot;zip&quot; &quot;-r&quot; deps-zipfile
            &#40;fs/file-name gitlibs-dir&#41;
            &#40;fs/file-name m2-dir&#41;
            &#40;fs/file-name classpath-file&#41;
            :dir work-dir&#41;&#93;
    &#40;when &#40;not= 0 exit&#41;
      &#40;println &quot;Error:&quot; err&#41;&#41;&#41;&#41;
</code></pre><p>When the deps layer is unzipped on the lambda instance, I'll have a file named <code>/opts/deps-classpath</code> containing the transformed classpath. Now all I have to do is update Blambda's <code>bootstrap</code> script to use the file if it's there:</p><pre class="language-bash"><code class="lang-bash language-bash">#!/bin/sh

set -e

LAYERS&#95;DIR=/opt
DEPS&#95;CLASSPATH&#95;FILE=&quot;$LAYERS&#95;DIR/deps-classpath&quot;

CLASSPATH=$LAMBDA&#95;TASK&#95;ROOT
if &#91; -e $DEPS&#95;CLASSPATH&#95;FILE &#93;; then
  CLASSPATH=&quot;$CLASSPATH:`cat $DEPS&#95;CLASSPATH&#95;FILE`&quot;
fi

export BABASHKA&#95;DISABLE&#95;SIGNAL&#95;HANDLERS=&quot;true&quot;
export BABASHKA&#95;PODS&#95;DIR=$LAYERS&#95;DIR/.babashka/pods
export GITLIBS=$LAYERS&#95;DIR/gitlibs

echo &quot;Starting Babashka:&quot;
echo &quot;$LAYERS&#95;DIR/bb -cp $CLASSPATH $LAYERS&#95;DIR/bootstrap.clj&quot;

$LAYERS&#95;DIR/bb -cp $CLASSPATH $LAYERS&#95;DIR/bootstrap.clj
</code></pre><p>Now the classpath will always start with <code>$LAMBDA&#95;TASK&#95;ROOT</code>, which is the directory where the lambda zipfile is extracted (<code>/var/task</code>), and then if there exists an <code>/opt/deps-classpath</code> file, its contents will be appended to the classpath.</p><p>Invoking <code>bb</code> with the <code>-cp</code> flag overrides the default classpath and stops Babashka from building the classpath from <code>bb.edn</code>. This also means that there's no need to include a <code>bb.edn</code> in the lambda archive, which saves precious bytes! 😉</p><p>After running <code>bb deploy</code> to deploy the new version of the Blambda custom runtime and creating a new version of my deps layer with <code>target/deps.zip</code>, I held my breath and clicked the <strong>Test</strong> button once again:</p><p><img src="assets/2022-08-09-blambda.png" alt="The lambda console showing a successful test result" title="Such logs!" width=800px] /></p><p>Victory! 🎉</p><p>Of course, there's a lot that is pretty yucky about this. The yuckiest bit is probably that I have to build and deploy Blambda from the Blambda repo, build the deps layer from the Blambda repo, but deploy the deps layer and build and deploy the lambda archive itself from the s3-log-parser repo. Gross. Will fix.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-05-here-we-go-again.html</id>
    <link href="https://jmglov.net/blog/2022-08-05-here-we-go-again.html"/>
    <title>Here we go again!</title>
    <updated>2022-08-05T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>It's the beginning of August and Arsenal's Premier League campaign is starting again tonight with a match against Crystal Palace. This is definitely the most excited I've been about a new season in quite a few years!</p><p><img src="assets/2022-08-05-gabi-gabi-gabi.jpg" alt="Gabriel Magalhães, Gabriel Martinelli, and Gabriel Jesus standing together in
white Arsenal training tops" title="Gabi Gabi Gabi!" width=800px /></p><p>The start of the first Emery season was interesting because for the first time in 20+ years, no one had any idea what to expect, and the start of the second Emery season was exciting because we'd just signed some Ivorian winger from the French league for £72 million, so surely he'd be awesome, right? What was his name again? The start of the first full Arteta season was exciting because we'd just won the Arsène Wenger Memorial Cup for the 14th time and surely we'd kick on from there, right?</p><p>In contrast, the start to last season was really low key for me. My expectations were that we'd finish somewhere around 6th, and a good target for the season would be qualifying for the Europa League. We'd signed Ødegaard permanently, and that was awesome, but I didn't expect he'd single-handedly turn us into top four challengers. We signed some young English defender named Ben White that I'd never even heard of, and £50 million was a lot of bloody money and last time we spent that on a player it was Pépé and we all remember how that went, right? And we had a tough start to the season! The opener against Brentford was an easy three points, sure, but then we faced Chelsea and City, and if we came out of those matches with even a point, it would be a miracle.</p><p>And then the season actually started and it was worse than even I expected. Taking 0 points from the first 9 and scoring no goals in the process was... bad. Very bad. I was very angry at <a href='https://7amkickoff.com/'>Tim from 7amkickoff</a> for being right as usual about Arteta not being a very good manager, but then things got less bad with a couple of 1-0 to the Arsenal wins and then holy shit we destroyed Spurs in the NLD and went on an unbeaten run that eventually lasted 10 games, but then we lost three in a row again and then we did that thing where we realise the season is halfway over and start getting results, and there were signs that Arteta was learning from his mistakes and then we were somehow in the hunt for the Champion's League and then we lost three in a row again and shit we were gonna finish out of the CL places and below Spurs again but then we won four in a row WTF we're gonna do it but then we lost two and finished below Spurs but at least destroyed Everton on the last day of the season to leave a smile on the home fans' faces.</p><p>Wow, last season was an absolute rollercoaster, wasn't it? And in checking my memory against the results from last season, I realised that we only drew three games all season. That's a bit astonishing to me. I don't think it means anything important, but just seems really unusual. At the end of the season, I was disappointed that we couldn't quite get over the last hurdle and qualify for the Champions League again, and of course sad that another year would go by without a <a href='https://www.chiark.greenend.org.uk/~mikepitt/totteringham.html'>St. Totteringham's Day
celebration</a>, but on the other hand, we had finished higher than I expected before the season started, and we were back in Europe and it wasn't the Conference League, so honestly, it was a decent season for me. 6/10, in the final analysis.</p><p>This summer, we've brought in a centre forward who can hustle and bustle and press and pass and most importantly, score! We've brought in really solid cover / competition for Kieran Tierney at left back in Zinchenko, we've finally found the new Viera that we've been looking for all these years, and we have William Saliba back from a great season in France where he won Young Player of the Year, made Team of the Year, and caught Mbappe from behind to take the ball cleanly and prevent a sure goal. In addition to being a really exciting young defender with great long passing, Saliba gives us the ability to move Ben White to right back when Tomiyasu is out (as he is right now).</p><p>I've only watched the highlights from the pre-season matches, as most of them were in the exceedingly early AM for me and then the one against Sevilla that I could have watched was ironically not available for streaming in Spain, where I was at the time, but wow oh wow am I ever excited! An Arsenal team successfully implementing a high press? An Arsenal team that can counter attack? An Arsenal team with a centre forward that is in the box and whacking the ball in the net? Yes please!</p><p>So I'm really pumped for tonight!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-03-reviewing-interviewing.html</id>
    <link href="https://jmglov.net/blog/2022-08-03-reviewing-interviewing.html"/>
    <title>Reviewing tech interviewing</title>
    <updated>2022-08-03T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Earlier this summer, I decided to switch jobs after six years at my previous company. I had joined as an engineer, but a few months into the pandemic, I had one of those "OMG what am I doing with my life?" moments that I guess quite a few people did, and asked myself why I was still at that company after almost five years (as it was in the spring of 2020). It wasn't the industry; it wasn't something I was passionate about. It wasn't the tech; even though we were using stuff I enjoyed a lot, so are many other companies, and some of them are even using Clojure, which I wasn't at the time. What it was was the people I worked with. Over my time there, I met a lot of great engineers and a lot of wonderful human beings, and there was a lot of overlap between those two groups. So given this was the case, why in the world wasn't I in a job where I could spend 100% of my time on the people?</p><p>And that is why I decided to change careers and become an Engineering Manager back in the spring of last year. And believe me, it is a career change, <strong>not</strong> a promotion. The two most common reasons engineers go into management are because they do see it as a promotion, or maybe even more frequently, because there's no one else to do it. I have personally tried management for that latter reason twice, and absolutely hated it. And that's no surprise really; I mean, falling on a grenade is not usually fun.</p><p>But this time around, I got into it for the right reasons: to be able to focus on helping people grow and succeed in their careers, and to be able to have a bigger impact on the culture in the Engineering organisation. I have to admit going in that I was a little worried that I'd miss coding, and would have to spend my weekends on personal projects to feed the addiction, but I was delighted to find that I didn't miss it. I made a deliberate choice not to be a hands on manager, because the team I was directly managing had a very senior engineer who was very capable of leading the day to day work, so I focused on people development and improving our process.</p><p>I also got heavily into hiring, as building a great team requires making sure that the people you're bringing in are the right people to take the team forward. I had been involved in hiring from almost the beginning of my time at the company, but had taken a break for a year when I moved into a lead architect role so that I could be sure I had enough time to do a good job in that role. Getting back into interviewing after my self-imposed hiatus was great. I had forgotten how much I enjoyed meeting candidates and doing my best to give them a platform to demonstrate their abilities and personality.</p><p>When I decided to leave my job at the beginning of June and started interviewing at other companies, it was a strange feeling to be back on that side of the table after so many years. I had the privilege of not being in a hurry, however, and I found that I actually enjoyed most of the processes. I wasn't desperate to find a new job right away, so I didn't feel any pressure. I just tried to be myself and be honest.</p><p>I wanted to share a couple of things that I liked and didn't like about the processes that I was in, and also share a few questions that I asked to try and determine whether the company would be a place I would enjoy working.</p><h2 id="stuff_i_liked">Stuff I liked</h2><p>First of all, I have to say that most of the processes were great. The HR people got back to me in good time after each interview, often with useful feedback. Unlike a lot of interviews for engineering positions, the Engineering Manager interviews tended to be behavioural interviews based on my actual experience; for example, questions like "Tell me about a time when you had to work with an under-performing report" or "Tell me about a time when you had to give difficult feedback to a superior." These sort of questions have no right or wrong answer, but in the hands of a skilled interviewer, can spark deep conversations where you gain a lot of insight into a person's management style. I had a lot of experience conducting these interviews (I think they're great for any candidate, not just managers, and we used them for engineering roles at my previous company as well), and it was fun being on the other end of them.</p><p>The coolest interview I had was a two part interview in which I talked to a couple of people in the team I would be managing and got a feel for their personal development needs as well as challenges that the team faced, and then was debriefed by the person who I would be reporting to. It was a really novel interview, and I think did a great job of putting me in a very realistic setting and letting me demonstrate skills that I would actually be using day to day. So much better than coding on a whiteboard or answering trivia questions about some technology or programming languages! (By the way, if your company still does those type of interview, please stop it immediately; they don't tell you anything useful and most candidate hate them.)</p><h2 id="stuff_i_didn%27t_like">Stuff I didn't like</h2><p>A couple of companies unfortunately had logic and/or personality tests. Those are really horrible, and I've never seen any data showing a strong correlation between performance on those tests and actual job performance, despite actively looking for it. My previous job used a logic test, and I spend the entire five years I was involved in hiring there trying in vain to get rid of it or get evidence that it was useful in any way. Those tests can put people with ADHD or dyslexia or other neurodiverse conditions at a huge disadvantage, and even companies who offer exemptions in those cases put the candidate in a position where they have to disclose something about themselves that should have no bearing on job performance.</p><p>One company had a battery of tests that took over an hour to do, including a logic test where you had to identify patterns, a numerical reasoning test, a verbal reasoning test, and two separate personality tests. The logic and numerical and verbal reasoning tests all had timed questions, which was extremely stressful. I'm not great at doing calculations quickly, and the logic test was either very hard or I'm just not good at that sort of thing. Neither of these things has ever negatively affected my job performance, and I really don't know what they expected to learn from the results of these tests. I didn't do well on those two tests, and felt really horrible after taking them. My poor performance didn't disqualify me, as the company chose to continue the process with me, so as far as I could tell, they basically put me through an extremely unpleasant situation for absolutely no reason. I felt bad for hours after taking those tests.</p><p>Another company had a logic test, and I just told them that I wasn't going to take it, so if that was a requirement for their process, I wasn't interested in continuing. It was and I didn't.</p><p>If you have a logic test or similar, just know that you are losing candidates. I have two friends who worked at my previous company that wouldn't even post job openings because they were so sick of the abuse they would get because we had a logic test in our process. Our recruiters have also gotten some really nasty messages from candidates.</p><h2 id="stuff_i_asked">Stuff I asked</h2><p>Since I had the luxury of choice, it was really important to me to do the best I could to ensure that the company would be a good fit for me. Here are some of the questions I asked:</p><ul><li><strong>How does your company make money? Are you making money?</strong> In the current  climate, investors are starting to actually care about revenues and profits  (which I think is a good thing, as long as the thinking isn't too short term)  and aren't willing to continue to just shovel into a company that isn't making  money and doesn't have a clear path to profitability. I didn't want to work  for a company that was heading for layoffs.</li><li><strong>Who are your investors?</strong> Most tech companies are venture capital funded to  some extent or other, and knowing which VC firms have leverage is important.  Sequoia Capital, for example, is a major investor in a lot of the companies  that have just laid off 10% of their workforce, and <a href='https://www.cnbc.com/2022/05/26/sequoia-coaches-start-ups-to-cut-costs-or-face-a-death-spiral-.html'>they are serious about
  firms cutting
  costs</a>.</li><li><strong>How diverse is senior leadership?</strong> I've worked for plenty of companies  where all of the CXOs are white men, and those companies tend to have  Engineering departments that are mostly white men as well. Diversity and  inclusion are really important to me, and I really believe that a company is  only as diverse as its leaders. This question is also interesting in that it  shows how the person answering it thinks about diversity. Do they view  diversity as percentage of women, or do they think about other marginalised  groups? Do they mention inclusion at all, or does it seem that they view  diversity as <a href='https://medium.com/tech-diversity-files/if-you-think-women-in-tech-is-just-a-pipeline-problem-you-haven-t-been-paying-attention-cb7a2073b996'>a pipeline
  problem</a>?</li><li><strong>How many hours per week do you work on average?</strong> Over my career, I have  found that there's an inverse correlation between the quantity of work  expected and the quality of that work. I know that I personally am not capable  of doing good work for more than about 40 hours a week. Sure, I can put in  some extra hours if there's an emergency, but working much more than 40 for  more than a couple of weeks in a row rapidly puts me in the red zone where I'm  making poor decisions and a lot of mistakes. I know this varies from person to  person, and some people actually like to to work longer hours, so I ask all of  the interviewers this question. If everyone gives me an answer that is north  of 40, I know that's not the place for me.</li></ul><p>I hope this is useful for someone out there. Or maybe everything I've said here is extremely obvious, in which case tech interviewing is a lot better than I thought! 😉</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-08-02-back-in-business.html</id>
    <link href="https://jmglov.net/blog/2022-08-02-back-in-business.html"/>
    <title>Back in business</title>
    <updated>2022-08-02T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I'm back in Stockholm after 12 days in Málaga, and as usual, it's both good and bad to be back. Good because coming home after some time away feels just like, well, coming home. And bad because you're not on vacation anymore and real life with real responsibilities reminds you how much you need a vacation! 😅</p><p>Our flight landed around 19:30 last night, and the hour it took to collect our luggage and drive to Simon and Pippa's house to collect Rover felt like an absolute eternity. It was so good to see this smiling face again!</p><p><img src="assets/2022-08-02-rover.jpg" alt="A yellow dog sitting in a chair" title="How could you leave me?" width=800px /></p><p>Simon and Pippa were nice enough to invite us to supper, so we didn't have to go through that just back from the airport refrigerator scavenger hunt that is the norm. We got home about 23:00, and I had just about enough time to shower before falling into bed and sleeping for 10 hours.</p><p>And then this morning, reality set in with a vengeance! We had no food, of course, so Rover and I set out for the store. Halfway there, I remembered that I had a package to pick up&ndash;many supermarkets in Sweden have a post counter, so rather than having your packages delivered to your home, you usually go and pick them&ndash;and of course the package was being delivered to the other store within 10 minutes walk of my house, but in the exact opposite direction from that in which I was walking. "No matter," I thought to myself, "I'll just pick it up later." But of course y'all know the one about how to make the universe laugh, right? As soon as we got to the store, a mail truck pulled up and began unloading boxes.</p><p>Rover did not like this one bit. I don't know how the bad blood between dogs and post carriers started, but in Rover's mind, he's the only thing that's prevented the post carrier from brutally murdering us these past five years, and he wasn't about to let this person unload those boxes without giving them a piece of his mind. And I wasn't about to leave Rover barking his head off outside the store whilst I dashed in for the essentials, so I admitted defeat, turned on my heel, and started trudging towards the other store (the one that had my package).</p><p>It was a cool and clear morning (14°C after 12 days of around 30°C felt wonderful), so I didn't mind the extra bit of walking, and I had a nice surprise at the store when my package turned out to be my refurbished phone. The battery on my six year old iPhone SE was down to about an hour on a charge, so I finally admitted defeat and replaced it with a used iPhone 12 Mini, which arrived whilst we were in Spain. I had gotten a notification on the PostNord app (of course there's an app for that), but had no idea what the package was, so it was a bit like Christmas morning as I opened the box and found a new (to me) phone.</p><p>After Rover and I got home, I had my first cup of proper Swedish coffee, and it was amazing. Spain is wonderful in many ways, but it's not really the place to go for good brew coffee, so that first sip of home coffee was about 100 times better than usual.</p><p>After coffee and a little breakfast, things went downhill fast. It was time to clean the washing machine, which sounds a bit ironic, since it is a machine that exists solely to clean stuff. But apparently the stuff it cleans off clothes has to go somewhere, and sometimes that somewhere is into the pump filter. Which I guess is better than into the pump itself, hence the filter, but cleaning that filter isn't exactly fun. I had to turn off the power to the machine, turn off the water to the machine, pull the machine out away from the wall so I could get to the filter, drain the drum into a bucket (it was amazing how much water came out of a purportedly empty machine when I took the plug out), then finally clean the filter itself, which was thoroughly disgusting. I guess that's what I get for not having cleaned it since we got the washing machine two years ago; apparently you're supposed to clean it 3-4 times a year. 😬</p><p>After cleaning the filter, I vacuumed and mopped behind the machine, because it was gross back there and I couldn't in good conscience push the machine back where it went knowing what would be under it, then I decided that hey, in for a penny, in for a pound, so why not go ahead and clean the bathtub? Ugh.</p><p>I think I need a vacation.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-31-for-a-few-dollars-more.html</id>
    <link href="https://jmglov.net/blog/2022-07-31-for-a-few-dollars-more.html"/>
    <title>For a few dollars more</title>
    <updated>2022-07-31T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>This is our last day in Málaga, and we took a walk in the foothills of Sierra de Mijas, the mountain overlooking the little towns along the Costa del Sol south of Málaga. The vistas were really stunning, with a very Wild West feel to them.</p><p><img src="assets/2022-07-31-benalmadena.jpg" alt="An arid landscape with a small village nestled in a valley with a mountain in the background" title="Now we start" width=800px /></p><p>This is for a very good reason, it turns out. We met my friend Pablo in Málaga the other day for dinner, and he told us that quite a few of the classic Sergio Leone "spaghetti westerns" were actually shot in Andalucía, not in the southwest US and Mexico where they're set. Andalucía also served as Dorne and the Red Keep in Game of Thrones, and Naboo Palace in Star Wars Episode II.</p><p>It's been a lovely holiday, with the right mix of relaxation and <a href='2022-07-23-comedy-of-errors.html'>adventure</a>, plus a healthy dose of <a href='https://github.com/borkdude/quickblog'>Clojure
programming</a>. It will be nice to return to Sweden tomorrow for a reunion with Rover, who has been staying with my friend Simon and his family. As you can see, he's not exactly had it rough there:</p><p><img src="assets/2022-07-31-rover.jpg" alt="A yellow dog sleeps curled up in a beanbag chair" title="Sleepytime" width=800px /></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-29-cant-stand-the-heat.html</id>
    <link href="https://jmglov.net/blog/2022-07-29-cant-stand-the-heat.html"/>
    <title>If you can't stand the heat, get off the planet</title>
    <updated>2022-07-29T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>In today's "news", the recent heat waves around the world are exacerbated by human-caused climate change. This is exceedingly obvious to anyone who is not trying to wilfully suspend their belief in basic facts, but where this gets more interesting is that a team of scientists from around the world, under the auspices of the World Weather Attribution project, <a href='https://www.worldweatherattribution.org/without-human-caused-climate-change-temperatures-of-40c-in-the-uk-would-have-been-extremely-unlikely/'>looked at the UK
heatwave</a> from the past week and were able to use sophisticated climate models to talk about precisely how much more likely such a heatwave is in our warming world and also how much hotter the heatwave was. Here are some of the highlights:</p><ul><li>The likelihood of observing such an event in a 1.2°C cooler world is extremely  low, and statistically impossible in two out of the three analysed stations.</li><li>The observational analysis shows that heatwave would have been about 4°C  cooler in preindustrial times.</li><li>According to their models, the same event would be about 2°C less hot in a  1.2°C cooler world, which is a much smaller change in intensity than observed,  meaning that their models are too conservative.</li><li>Combining observational data with their models, they found that human-caused  climate change made the heatwave at least 10 times more likely.</li></ul><p>This is interesting for a few reasons. First, it illustrates that climate models are becoming sophisticated enough to look at specific events, not just long term trends. Second, it shows that our climate models are likely painting too rosy a picture of the future, meaning it's likely to be worse than is currently being projected. Third, this particular modelling was based on today's climate, which is only 1.2°C hotter than the preindustrial climate, and we're on course for <a href='https://www.nature.com/articles/d41586-020-01125-x'>over 2°C of warming by the end of the
century</a>, possibly more.</p><p>The cherry on the top is that during the heatwave, <a href='https://www.bbc.com/news/uk-62323048'>meteorologists faced
unprecedented levels of abuse on social
media</a>, because of course they did.</p><blockquote><p> [Meteorologists] faced "public ridicule, accusations of lying or suggestions  of being blackmailed". Tweets aimed at BBC Weather and its presenters featured  personal insults and messages such as "it's just summer" - many described  advice on how to stay cool as pandering to the "woke-brigade" or for  "snowflakes". Other tweets accused the Met Office and the BBC of spreading  "alarmism" and "hysteria", telling both to "stop scaremongering". </p></blockquote><p>I wish I could say that this is incredible, but it's sadly all too credible. And of course all of the powerful corporations and politicians that are responsible for climate denialism being mainstream will never be held accountable.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-28-deserving-poor.html</id>
    <link href="https://jmglov.net/blog/2022-07-28-deserving-poor.html"/>
    <title>The deserving poor</title>
    <updated>2022-07-28T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I was reading a story this morning in The Intercept about problems being faced by renters entitled "<a href='https://theintercept.com/2022/07/19/federal-rental-assistance-arkansas-nebraska/'>Renters in Arkansas and Nebraska Face Eviction After
Governors Refuse Federal Rental
Assistance</a>", and wow did it ever push all of my buttons all at once. The gist of the story is that the US federal government has provided <a href='https://home.treasury.gov/policy-issues/coronavirus/assistance-for-state-local-and-tribal-governments/emergency-rental-assistance-program'>emergency rental assistance
money</a> (first $25 billion in December 2020, then an additional $21.55 billion in May 2021), but instead of handling this at the federal level, the money goes to state-level agencies to distribute. Or not, if the governors of those states choose not to accept the money for bullshit ideological reasons, as is the case here. The two states in question are Nebraska and Arkansas, and here are the two governors explaining their decisions; first, Nebraska governor Pete Ricketts in a press release called <a href='https://governor.nebraska.gov/press/%E2%80%9Cemergency%E2%80%9D-name-only-second-round-federal-emergency-rental-assistance-program'>"Emergency" in Name
Only</a>:</p><blockquote><p> At a certain point, we must acknowledge that the storm has passed and get back  to the Nebraska Way. We must guard against becoming a welfare state where  people are incentivized not to work and encouraged to rely on government  handouts well after an emergency is over. That is why Nebraska is not seeking  the second round of the Emergency Rental Assistance Program (ERAP). We cannot  justify asking for federal relief when we don’t have an emergency. </p></blockquote><p>Arkansas governor Asa Hutchinson was <a href='https://www.arkansasonline.com/news/2022/apr/23/hutchinson-to-decline-additional-federal-rent/'>singing from the same Reagan-era hymn
sheet</a>:</p><blockquote><p> Our economy has returned. There are jobs aplenty out there, and we have  existing programs in place for rental assistance that were pre-pandemic. We  are back working to the same extent pre-pandemic, and we have the same  opportunity moving up the economic ladder, so we need to move back to the same  rental assistance we had before. </p></blockquote><p>They are both basically saying the same thing: everyone has equal opportunities, so if you're poor it's because you're lazy, and lazy people don't deserve a roof over their heads.</p><p>The Intercept story offers a few examples of these lazy people who deserve what's happening to them:</p><blockquote><p> Samantha Schilling and her fiancé fell behind by about $1,900 on rent for  their home in North Little Rock, Arkansas. Schilling herself can’t work due to  a serious back injury. Then, in March, her fiancé’s hours at a Dollar General  store got cut back from a full-time schedule to two days a week. </p></blockquote><p>This lazy person with the back injury and her equally lazy fiancé whose hours were reduced at his job were evicted, having to leave behind many of their belongings because they had no place to take them. Just desserts!</p><blockquote><p> Adrian Toston, a resident of Jacksonville, a suburb of Little Rock, just spent  months out of work when orders for the home goods she was delivering dried up.  Losing her income meant losing her car, and she had to file for Chapter 13  bankruptcy. [...] She tried desperately to find another job and even got an  offer at Amazon, only to find out that when the company ran a background  check, a long-ago arrest for misdemeanor unauthorized use of a vehicle was  incorrectly categorized on her record as property theft. [...] It wasn’t until  another employer ran a background check and told her about the issue that she  was able to file the paperwork and get it fixed. </p></blockquote><p>This lazy person who was lazily desperately searching for a job only to be denied it due to an error on her record applied for rental assistance, "but despite calling every week for months to find out what was happening, she was declined again for supposedly failing to respond to an email she says she never got."</p><p>But even if the above lazy people found work, they might well still be shit out of luck, since <a href='https://edition.cnn.com/2021/07/15/homes/rent-affordability-minimum-wage/index.html'>minimum wage workers can't afford rent anywhere in
America</a>:</p><blockquote><p> There is no state, county or city in the country where a full-time,  minimum-wage worker working 40 hours a week can afford a two-bedroom rental,  <a href='https://nlihc.org/sites/default/files/oor/2021/Out-of-Reach_2021.pdf'>a
> report</a>  from the National Low Income Housing Coalition showed. A full-time  minimum-wage worker can afford a one-bedroom rental in only 7% of all US  counties — 218 counties out of more than 3,000 nationwide. </p></blockquote><p>Real estate prices are being driven upwards primarily because investors are seeing rentals as a good way to make money. Even well-intended government rent relief programmes basically just shift public money to private investors without addressing the root of the problem. If this money was put into providing housing at affordable rates, people who need help would be receiving it without ensuring that investors are maintaining high profits, but <a href='2022-07-27-thoughtcrime.html'>just as with
energy</a>, such an idea is anathema to the current order, where profit must always take precedence over preventing preventable human suffering.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-27-thoughtcrime.html</id>
    <link href="https://jmglov.net/blog/2022-07-27-thoughtcrime.html"/>
    <title>Thoughtcrime</title>
    <updated>2022-07-27T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>We're staying in a one-room apartment in Spain for vacation, so we've been switching on the news in the morning whilst eating breakfast. The news has been absolutely chock full of wildfires and droughts and heat warnings and this morning, torrential rains in St. Louis that caused flash floods. So yeah, signs of the Apocalypse that is climate change, met with the usual indifference in the corridors of power. Boring.</p><p>Something out of the ordinary that caught my eye was that <a href='https://www.nea.org.uk/who-we-are/about-nea/'>according to UK
charity National Energy Action</a> rampant inflation, coupled with surging energy prices, could mean that 33% of UK households could be in fuel poverty this winter:</p><blockquote><p> From October National Energy Action predicts 8.2 million UK households could  be in fuel poverty – that’s one in three. It comes after Cornwall Insight  predicted the average annual energy bill could reach £3,250 – that’s £270 a  month. Currently, around 6.5 million UK households are in the grip of fuel  poverty, unable to afford to heat their homes to the temperature needed to  keep warm and healthy. </p></blockquote><p>The solutions being talked about in the news are the usual ones: capping energy prices (a good start) and/or subsidies to the poorest households to help them afford to heat their homes.</p><p>Of course it's completely beyond the pale to suggest an actual solution to the problem: return control of energy generation and distribution and provide energy at cost. Or even below cost, subsidised by taxes. Or even for free, as a basic human right.</p><p>Reagan and Thatcher and Clinton and Milton Friedman and company all assured us that deregulating the energy market and privatising public power would lead to lower energy prices through the power of competition, but <a href='https://ceepr.mit.edu/deregulation-market-power-and-prices-evidence-from-the-electricity-sector/'>according to the MIT
Center for Energy and Environmental Policy
Research</a>, this has not been the case:</p><blockquote><p> electric deregulation in the U.S. has resulted in increased prices from market  power, and that this effect has dominated cost efficiencies. Though there was  early awareness of the potential for market power in deregulated markets, the  fact that the effects of market power could considerably exceed the savings  from increased cost efficiency is surprising. Our findings point to the  importance of careful market design and market monitoring in electricity  markets to guarantee that consumers benefit from the cost savings that  resulted from deregulation. </p></blockquote><p>In addition to leading to lower prices, moving energy back into public hands would mean that the process of moving to renewable energy generation would not be able to be held hostage by a small number of executives, board members, and powerful shareholders (often large hedge funds like BlackRock), concerned only with the profitability of such a move.</p><p>When it comes to ensuring that power can afford to heat their homes in the winter, the approach of giving people money to give to private companies who are often recording record profits as prices rise is a much less efficient way to use taxes than providing power as a public good, but of course it has the happy property of perpetuating the current state of affairs, wherein a small handful of incredibly wealthy people get wealthier whilst the rest of us slide into food and energy insecurity.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-24-winds-aint-changin.html</id>
    <link href="https://jmglov.net/blog/2022-07-24-winds-aint-changin.html"/>
    <title>The winds, they ain't a changin'</title>
    <updated>2022-07-24T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I've been reading a biography of a recent US political figure. Let's see if you can guess who it is from these short excerpts from the book.</p><blockquote><p> The reporters made another misjudgement, brought on their own sense of  self-importance. [...] The reason the reporters assumed X meant it [...] was  that he had insulted and attacked the press as he said it. No one could do  that and survive in American politics, in the opinion of the press. In  charging that the press was biased, untruthful, and vindictive, X had burned  his bridges behind him. </p></blockquote><blockquote><p> X had just made the press an issue. X knew that the national press did not  speak for or to millions of Republicans. He understood and enunciated a point  of view: that the press was liberal, Democratic, do-gooder, pro big government  and big labour, for high taxes, yet craven in the face of the [enemy's]  menace, and always out to give the shaft to Republicans. </p></blockquote><blockquote><p> X pointed out another advantage of the last press conference. "It served a  purpose," he said. "The press had a guilt complex about their inaccuracy."  (this was an assertion, not a fact, and every reporter [...] would have denied  it)... </p></blockquote><blockquote><p> ...although what he said delighted most Republicans, it made the Democrats  hate him even more, which pointed to his more general problem, that he was the  most hated and feared man in America. No one could rouse the Democrats for an  all-out effort quite the way X could; thus [...] the fervour and dedication of  the anti-X volunteers in the Y campaign. This was a consequence of X's  campaign style, in which he nearly always exaggerated... </p></blockquote><blockquote><p> X wanted to be powerful. To get power, he had to remain a public figure,  speaking out on the issues of the day. </p></blockquote><blockquote><p> He used a favourite X technique&ndash;to deny that he was saying what he was  saying&ndash;and got some revenge in the process. </p></blockquote><blockquote><p> X was pleased with his effort. He told his backers that "it served the purpose  of setting forth some constructive alternatives to present Administration  policy." Of course the opposite was true&ndash;the alternatives he had offered were  jingoistic and irresponsible and inconsistent. [...] But there was one good  thing about being in opposition: it freed X to slash and denounce without  having to assume any responsibility for his words. </p></blockquote><blockquote><p> The way the press had fawned on [his predecessor] had made X furious and  jealous; [...] the things [his predecessor] had gotten away with had made X  resentful. </p></blockquote><p>It's pretty easy to guess, right? Reply to me on <a href='https://twitter.com/jmglov/status/1551201401014976514'>this
thread</a> if you know who it is. I'll update this post tomorrow with a link to the correct answer.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-23-comedy-of-errors.html</id>
    <link href="https://jmglov.net/blog/2022-07-23-comedy-of-errors.html"/>
    <title>A comedy of errors</title>
    <updated>2022-07-23T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>As I mentioned a couple of days ago, we're currently on vacation in Spain. We got here on Wednesday evening, but due to some miscommunication, we had to switch accommodations on Thursday. Today, I was supposed to meet the owner of the apartment where we were originally staying to return the keys. We had agreed to meet at 11:30 at the apartment, which would take just under 30 minutes to get to from the place we're staying now. I left at 10:45 to walk to the bus stop, and was feeling good about myself as I arrived at the stop with 10 minutes to spare before the bus was scheduled to arrive. I wasn't sure how to get a ticket, so I popped into a tobacco shop that was right by the bus stop to ask. In my "extremely limited Spanish" (a euphemism for "knowing 25 words of Spanish and saying them in semi-random order with lots of hand gestures"), I managed to establish that I could buy a ticket on the bus.</p><p>I found a shady spot next to the bus stop to wait, and saw a QR code that you could scan to get updated information on when the bus would arrive. I scanned it and saw that the bus would be there in six minutes (<em>seis</em> and <em>minutos</em> are two of the 25 words I know in Spanish), so I settled down to wait, deeply satisfied with my careful planning and linguistic expertise.</p><p><em>Seis minutos</em> came and went with no sign of the bus. I started to get concerned that I had done something wrong, so I pulled my phone out of my pocket and opened Google Maps for directions. Sure enough, it indicated a bus stop 200 metres down the road. But wait a second, it wanted me to take bus 110, which also stopped where I was waiting, and it had definitely told me this stop before. "Oh well," I thought to myself, "Larry and Sergey are never wrong, right?" and set off down the road in the indicated direction. As I was waiting for the pedestrian light to go green so I could cross a side road, I happened to look over my shoulder and see the 110 bus approaching the bus stop I had just left. I turned on my heel in the hot Spanish sun (it's "only" 30 degrees today, but that's well above what I'm used to in Stockholm) and ran for it, waving my hand in what I hoped was the universal "please wait for me so I can get on your bus" gesture.</p><p>The bus driver was kind enough to wait for me to run up, panting and sweat streaming off my face, and get on. I produced my phone to pay for the ticket, but it turned out that the touchless thingy was only for bus passes, and you couldn't pay with a card. No matter, I had anticipated this situation by withdrawing some "cash" (a bit of gaudy paper that people use to pay for stuff, I'm told) from an "ATM" (a machine that charges you real money in exchange for these bits of paper), so I withdrew a 20 euro bill from my pocket and held it aloft triumphantly. The bus driver looked at me with a raised eyebrow, then waved a finger in the universal "fuck off with that 20 euro bill" gesture. Apparently he could only make change for a tenner.</p><p>Defeated, I slunk off the bus and started walking in the right direction, thinking I'd pass a little shop where I could buy a bottle of water or something and obtain one of these highly sought after 10 euro bills. I had made it about 100 metres down the road when something dawned on me: I was meeting a person to return the keys to an apartment, and I wasn't currently in possession of these keys. They were, in fact, safely ensconced in my backpack, which was safely ensconced in our new place, which was a 15 minute walk from where I currently was. Oh yeah, and my wife had the keys to that place, and she was at the pool with my son. I performed an honest to goodness facepalm, turned on my heel for the second time, and started walking back to the hotel.</p><p>I pulled my phone out to call my wife and admit to my failings, and I saw that I had a message from the person I was supposed to meet. "I ran into some traffic," the message said, "and I won't be there until 12:00. Sorry for the inconvenience!" I honestly-to-goodnessly lol'd, and let out a huge sigh of relief. "No worries," I texted back, "I'll see you there." I gave my wife a call to let her know the deal, then walked back up to the apartment. And by "up", I really mean it. One of the reasons that the Costa del Sol is so beautiful is that it basically goes lovely blue sea, steep-ass hills, dramatic mountains. There's not a whole lot of flat land on offer.</p><p>So I hiked a kilometre or so up one of these steep-ass hills to our hotel, met my wife at the pool to get the key to our room, recovered the apartment keys from my backpack, dropped the hotel keys back off with my wife, and cruised a kilometre or so down the steep-ass hill back to the bus stop. Google Maps reported that the next bus was in 15 minutes. I checked my watch, which showed the time as 11:40. "Ugh," I thought, "I won't make it there by 12:00, since the bus takes 10 minutes and then it's another 10 up another steep-ass hill (see? toldya they're not exactly in short supply around here) to the apartment."</p><p>I pulled out my phone to text the person, and saw that they had preempted me. "Still in traffic, will be there around 12:15". "No worries," I texted back, "I missed the bus,"&ndash;dropping an itsy bitsy modification of the truth to cover for my complete incompetence vis a vis forgetting the keys&ndash;"so I'm running late myself. See you soon!"</p><p>At this point, I should reveal that the person I was corresponding with didn't speak English, so all of these texts were happening in Google Translate-assisted Spanish, so I just had to trust Larry and Sergey that I was saying what I hoped I was saying.</p><p>Just for shits and giggles, I scanned the QR code at the bus stop to check the updated arrival time for the bus. It turned out that the bus was running really late, and the previously scheduled bus hadn't arrived yet, but was due imminently. Finally! A bit of luck breaking my way for once!</p><p>The bus rolled up in a minute or so and opened its doors to admit a few angry passengers who had been waiting for awhile in the hot sun and one delighted passenger who though he'd have to wait awhile in the hot sun. The driver was able to make change for my 10 euro bill, and I sat down in the sweet sweet air conditioned comfort of the bus and started watching the display to make sure I didn't miss my stop. I got off in the right place and walked rapidly up yet another steep-ass hill to meet the person. After handing over the keys, I decided I'd just walk back to our hotel, since it was only 45 minutes or so away on foot, and the whole journey (save walking down one steep-ass hill and up another) was along the seaside.</p><p>I'm happy to report that my walk home was without further incident. It was hot, but the beautiful views made it all worth my while.</p><p><img src="assets/2022-07-23-benalmedena.jpg" alt="A rocky promontory extends into the blue Mediterranean Sea" title="Looks inviting!" width=800px /></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-22-best-part-of-waking-up.html</id>
    <link href="https://jmglov.net/blog/2022-07-22-best-part-of-waking-up.html"/>
    <title>The best part of waking up</title>
    <updated>2022-07-22T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>In the autumn of 1996, I was in my last year of high school, and the time had come to figure out what to do with my life. Luckily, I had already done so: I was going to be a computer programmer! My dad had gotten an Apple IIc when I was 8, and that machine immediately became my favourite hobby. The person he had bought it from included a dot-matix printer, one of those cool green monochrome monitors, two caddies full of 5.25 inch floppy disks loaded with boring business software like Lotus 123 (my dad was an accountant), and most importantly, a huge stack of hobbyist magazines (<a href='https://en.wikipedia.org/wiki/Byte_(magazine'>Byte</a>) and <a href='https://archive.org/details/aplus-1985-01'>A+</a> are the two that I remember). Those magazines always had a few program listings in the <a href='https://en.wikipedia.org/wiki/BASIC'>BASIC</a> programming language, usually for simple games like <a href='https://en.wikipedia.org/wiki/Lunar_Lander_(video_game_genre'>Lunar
Lander</a>). If you typed them in correctly, you could play the game!</p><p>Of course, I never typed them in correctly the first time, since I was just copying word for word without understanding what the words meant. I literally learned to program by mistake. Or more accurately, by mistake after mistake. I got better at finding where I'd made a mistake, and I started seeing the patterns in the programs, and slowly but surely figured out what the arcane words were telling the computer to do. I still remember one program that I was entering where I was sure that the magazine had made a mistake, because if I typed in what was printed, this FOR loop would end one iteration sooner than it should. I decided to type in what I thought it should be, and sure enough, the program worked. A few days later when I was reading the next month's issue of the same magazine, I saw a letter to the editor pointing out the mistake I had noticed!</p><p>So yeah, by the time I was 17, I had been programming in some capacity or other for nearly 10 years, and I knew that I wanted to keep doing, and get paid for it to boot. There was only one problem: you needed a college degree to get a job as a programmer (or so I thought, at least), and I had approximately $0 of the $12,000 per year required to get a college degree at my school of choice, The College of William and Mary in Virginia (which is its official name, but hereafter I'll be referring to it as William & Mary or just W&M). I had a sweet job at Burger King running the drive-through, but that pretty much only generated enough money to buy petrol for my 1980 Camaro and the occasional movie, so I knew that if I wanted to go to college, I would need to get a real job.</p><p>My only problem was that I was still in high school, and most jobs that paid anything decent expected you to work 9-5, and I had classes during that time. I decided that the whole university thing was going to have to wait a year. So whilst my friends stressed over college applications, I stressed over trying to find some place that would hire me after I finished high school. My friend Ian had started a company that custom-built computers for people (back in those days, buying the components and building a computer yourself could save you a few hundred dollars, so there was a market for people with the know-how to build and sell computers and make a $100 profit), and I occasionally helped out when he had a lot of orders, but he didn't have enough work to be able to hire me for anything approaching full time.</p><p>By the beginning of May 1997, graduation was fast approaching and I still hadn't found anything. I was starting to resign myself to just picking up more hours at Burger King and seeing if maybe Walmart was hiring when I got a lucky break. The recreational softball season had just started, and my church had a team that I played on. One of the other guys on the team was an actual programmer at a company over in Harrisonburg, and he knew that the company was hiring a PC technician and said he'd get me an application form and put in a good word for me if I was interested. I told him I very much was, and he brought an application form for me to church the following Sunday. I filled it in and then drove the 30 minutes up Interstate 81 to Harrisonburg to drop it off at their office. It turned out that the boss wasn't busy, and when he heard that the guy Doug had recommended had just handed in an application form, he invited me into his office for an interview. I don't remember much about the interview itself, but I do remember him telling me that I could start the Monday following my high school graduation, which was in about two weeks.</p><p>I was elated! The job paid $15 an hour, which was a substantial improvement on the $4.45 an hour I made over at Burger King (minimum wage in Virginia back then was $4.25 an hour, but I had gotten a 20 cent raise at Burger King after working there for a year), so I'd finally be able to start saving money for college. The only problem was that the job was in Harrisonburg, and I lived in Staunton, which was about 30 miles away. My Camaro got about 15 miles to the gallon, so I'd be using 4 gallons of gas a day for my commute. Even though petrol was a lot cheaper (I remember it occasionally dipping below $1 a gallon&ndash;a gallon is about 4 litres&ndash;when there was a price war between neighbouring petrol stations), having to spend about $20 a week just on the commute didn't sound great to me.</p><p>Luckily, I had an idea. My grandmother lived in Harrisonburg in a two bedroom apartment, and she really loved having the grandchildren visit her. One day at work, I called and invited her out to dinner after work, my treat. Over chicken-fried steak at Shoney's, I asked her if I could move in with her for a year before I went to college the next fall. She thought it was a wonderful idea, so that weekend, I stuffed everything I owned into my car and moved to Harrisonburg. And not a moment too soon, because a week or two later, when I went out into the parking lot after work to go home and tried to start my car, it refused to start. Luckily, it was only about a 20 minute walk back to my grandmother's place.</p><p>I tried to figure out what was wrong with the car (for the millionth time; this car was always on the fritz for one reason or another), but to no avail. After a few weeks of it sitting in the company parking lot, my boss asked me what the deal was. I told him that it wasn't starting, and he told me that I'd need to call a tow truck, because he didn't want some broken-down car in the lot. He gave me until the end of the week, and I was starting to despair by Thursday when I got another lucky break.</p><p>I was hanging out by the front desk, flirting with the cute receptionist, when a car pulled into the parking lot. And not just any car, but a 1980 Camaro, same as mine. When the owner of the car walked in, I told him that I liked his car and I had one just like it. I mentioned that mine was currently broken, and he asked me if I would consider selling it, because his son was turning 16 soon and he wanted to surprise him with a car on his birthday, and his son really liked Camaros. He offered me $500 for it, and given that it a) didn't work and b) only cost me $500 when I bought it from my dad a couple of years before, I agreed to sell it on the spot. After picking up the PC he'd had us fix, he went to his bank, got $500 dollars cash, and promised to have the car out of the parking lot before the next day so I wouldnt' get in trouble with my boss.</p><p>My grandfather lived in Harrisonburg as well, and when I told him I'd moved, he invited me over to have supper with him on Wednesdays. He'd just gotten a new computer, and had a long list of things that he wanted it to do but that he just couldn't figure out. So I'd go over there after work, eat supper, and then after that, he and I would sit down in front of his computer for half and hour or so and I'd show him how to do various things. He took notes, and took a lot of pride in the fact that he never had to ask me the same question twice. Until he misplaced his notebook, that is. 😅</p><p>He also talked to me about my plan to save up for university, and pointed quite rightly to a slight flaw: though I would be making something like $23,000 after taxes for a year of work, William & Mary cost $12,000 a year, and I wouldn't be able to work full time whilst attending, so I'd start running short on funds after two years. He suggested investing some of the money in mutual funds, and explained to me how they worked. He'd been investing for years, and knew a lot about it. He offered to match every dollar that I invested, provided I would use the money to pay my tuition. It was through his great generousity that I was able to afford to go to university, and in fact he made this same offer to all 12 of his grandchildren.</p><p>Now that it looked like I'd be able to pay for school, the only thing that remained was to convince them to take me. The application period opened in late August of 1997 for the fall 1998 school year, and if I got my application in by the beginning of November, it would be considered for early decision. That way, if I was rejected, I would be still have time to apply to other schools before the regular application deadline in February. I got my high school transcripts together, wrote a few essays, and got a letter of recommendation from my favourite teacher (Mrs. Leigh, who taught English my junior and senior years), then put everything in an envelope, sent it off to Williamsburg, and crossed my fingers for a good result.</p><p>Harrisonburg was booming back in 1998, with the local college, James Madison University, expanding like wild. Like in any college town, a bunch of bars and restaurants popped up to support the growing student body, and one of those places was a vaguely Greek restaurant called Dave's Taverna. I never met Dave, but I guess he was a big jazz fan, because the restaurant had a jazz night every Thursday night with a live band. My good friend Adam was going to JMU and thus living in town, and he liked jazz as well (both of us got into jazz by way of being huge ska fans), so the two of us always met up there on Thursday nights for dinner and the sweet sweet sounds of saxophone (and bass and trumpet and piano and guitar and sometimes trombone as well, but none of those sound as sweet as a sax!).</p><p>The other thing Dave's offered in addition to jazz was free refills on their diner-style coffee (for the non-USians amongst you, that's the thin nearly tasteless stuff that gave US coffee a bad name with European tourists for years, back before we had coffee shows where you could get a proper cup). Adam and I were always competing with each other, and on one particular Thursday night in early December, we decided to see how many cups of coffee we could drink. Adam bowed out after 12 cups, but I decided to keep going to set a record that would never be beaten. After 15 cups, my hand was shaking so much that I was having to hold the cup with two hands to keep from spilling it on myself, and I had to go pee roughly every 10 minutes, but that did not dissaude me! After 17 cups, my heart was racing and I started seeing just a little bit double, but that did not dissaude me! When the waiter came around with the pot, I waved him over and pointed to my cup. He rolled all four of his eyes, sighed, and filled me up.</p><p>That 18th cup was a killer. In addition to the double vision, high heart rate, and pressure on the bladder, I started experiencing shortness of breath. I finished the cup somehow, but when the waiter came back around and started toward my cup with the pot, I hurriedly covered it with my hand to indicate my declaration of defeat. At least, that's what I indended to do. What I actually did is smack the cup off the table into Adam's lap, wiping that smirk off his face and replacing it with the wide-eyed look a man gets when a hot drink is suddenly spilled in his lap. Luckily for him, the cup contained at most a few drops of coffee, and I'm sure those drops weren't hot anymore.</p><p>I don't remember how I got home that night, but I hope Adam drove me, because even though there isn't any law forbidding Driving While Caffeinated, I was about 10 cups over what should be the legal limit. Oddly enough, I didn't have any trouble falling asleep, and I slept all the way until my alarm when off at 7:30 the next morning. I had breakfast with Grandmother and walked to work, but I started feeling bad almost as soon as I got to the office, and by 10:00, I knew I would have to call it a day. I told my boss that I was sick (but didn't go into the shameful details of why) and walked back home, feeling worse and worse with every step. I got home a little before 10:30, staggered into the apartment, and collapsed on the couch.</p><p>That's where Grandmother found me when she got back from her job at the hospital (she used to volunteer as a "candy striper", which is basically someone who goes around the hospital visiting with patients and families and generally trying to make it a cheerier place) a little after 3:00 in the afternoon. She had a few envelopes in her hand, and one was a big manilla one addressed to me. After being reassured that I wasn't dying, she handed me the big envelope, and my pulse quickened (but not from caffeine this time) when I saw the return address: The College of William & Mark, Williamsburg, Virginia.</p><p>I ripped the envelope open. Enclosed inside were a few sheets of paper and a glossy magazine of some sort. I pulled out the first sheet of paper, and read as far as "Dear Joshua, we are happy to inform you..." before launching myself off the sofa with a squeal of delight that startled my poor grandmother. "What in the world is going on with you?" she asked. "Well," I said, "I had 18 cups of coffee last night and I just read that I've been accepted to William & Mary!"</p><p>I wouldn't recommend drinking that much coffee in one sitting, but I would recommend going to William & Mary if you have the chance.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-21-so-close.html</id>
    <link href="https://jmglov.net/blog/2022-07-21-so-close.html"/>
    <title>So close!</title>
    <updated>2022-07-21T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>The family and I are sitting on the sofa in Malaga, watching the Austria-Germany quarterfinal match in the Women's EUR0 2022. I'm of course cheering for Austria, since they have two Arsenal players (goalkeeper <a href='https://en.wikipedia.org/wiki/Manuela_Zinsberger'>Manu
Zinsberger</a> and right back <a href='https://en.wikipedia.org/wiki/Laura_Wienroither'>Laura Wienrother</a>), and also since they are very much the underdogs. It's halftime, and Germany are leading 1-0 due to a ball recovery in midfield and a lightning quick attack, culminating in a drive into the box, pinpoint drag back, fantastic dummy, and laser-guided finish. So that sucks (even thought it was impressive to watch, I have to admit).</p><p>However, the best moment in the match so far was when Austria won a corner which Verena Hanshaw took. She found one of her teammates on the edge of the six yard box, who looped a header over the German defenders, leaving the keeper looking on in despair... but then it crashed against the outside of the post instead of nestling into the side of the net. When they showed the replay, we got a look at her name: Georgieva. I turned to my wife, "she has to be Bulgarian, right?" My wife nodded, "Could well be," she said. The match was far too gripping to look away from until halftime, but then I looked her up and saw that she has both Austrian and Bulgarian flags in her Insta profile! (In case you're confused by any of this, my wife is Bulgarian.)</p><p>As I write this now, the match is over and Germany have won (of course they have) and my heart is filled with sadness, but also gladness, because we had a lovely day on the beach and in the pool and just hanging out together. My mission to enjoy the vacation is on track!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-20-mile-high-club.html</id>
    <link href="https://jmglov.net/blog/2022-07-20-mile-high-club.html"/>
    <title>Mile high club</title>
    <updated>2022-07-20T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Today's post comes to you from high above Europe. The family and I are on our way to Spain for a couple of weeks, headed to the beaches of Malaga. This will be our first beach trip in just over two years, and I have to admit to having forgotten how all of this flying stuff works. I couldn't find the airport parking this morning, couldn't remember how to put a luggage tag on, and definitely forgot that a certain backpack that I have sets the scanner off every single time. I suspect it's because I'm always carrying enough cables with me to open my own electronics kiosk.</p><p>Despite my preponderance of cables, I'm looking forward to unplugging a bit during this vacation. There's three of us on the trip, but we only brought one computer, and the apartment where we're staying doesn't have wifi. I have two paper books with me ("<a href='https://www.goodreads.com/book/show/29580.Second_Foundation'>Second
Foundation</a>" and a Nixon biography that my friend Simon pushed on me&ndash;don't worry, neither Simon, myself, or the author of the book is a fan of Tricky Dick), an ebook reader stuffed full of digital goodies (I'm planning to read "<a href='https://www.goodreads.com/book/show/15824358-the-first-90-days'>The First 90
Days</a>" in preparation for starting a new job in the fall, and <a href='https://www.goodreads.com/author/show/415271.Kristen_R_Ghodsee'>Kristen
Ghodsee</a> has two new books out that I've been wanting to read), and a Remarkable e-paper notepad that I'm writing this here post on.</p><p>Um, reading back over that last paragraph, I realise that unplugging might be a bit of a relative thing. In any case, I'm going on vacation, and I'm determined to actually do some vacating!</p><p>In between my reading and beaching and watching the final stages of the women's Euros, I will endeavor to continue my daily writing, a task I actually failed in yesterday. You see, what happened was, I had just poured myself a lovely cup of coffee and sat down at my computer, ready to regale y'all with the story of my university application, when my wife popped in, phone in hand. It turns out that we needed some extra travel document that we didn't have, and the only place to obtain it was the city centre. By the time we got back from that unscheduled errand, it was time to pack and clean up the house (some friends are going to water the plants when we're gone, and we don't want them to encounter the horrors of an unwiped kitchen counter!) and then go over to some friends for dinner and then I was sooooo tired when we got back and also the dog ate my keyboard, so you see I just couldn't write my blog on time.</p><p>If that sounded like a bunch of excuses to you, you're spot on. I had a myriad of opportunities to do some writing earlier in the day before all of that stuff happened. I suspect the secret of those people who do write every day is having a routine of some sort, which I currently do not have. Let's see if I can get in the habit of sitting down with my morning coffee and writing before I check my email and get distracted by that one fix I have to make to some code or bill I have to pay or person I have to correct on Twitter.</p><p>Another thing I hope to accomplish over the next two weeks is to enjoy myself. Like many people, I have a tendency to focus on the minor annoyances in life rather than the good stuff that comprises the vast majority of my life. As I noted a few weeks back, making a concerted effort not to be annoyed by my fellow commuters ("commuting" is this thing we used to do when we worked in these buildings called "offices") made that facet of my life much more pleasurable, so I know that I do have a choice in the matter and that making the right choice will make me happier. So easy to do, right?</p><p>Wish me luck!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-18-football-or-dogs.html</id>
    <link href="https://jmglov.net/blog/2022-07-18-football-or-dogs.html"/>
    <title>Football or dogs?</title>
    <updated>2022-07-18T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I had planned to write a post today about how I found out I'd been accepted to university a few years ago (OK, I'm being generous by calling it "a few", but whatever), but things kept popping up and before I knew it, it was time for me to drive my son's friend to the airport so he could fly back to England. And then when I got back from the airport, it was time to take Rover for a walk, and then it was suppertime, and then time to watch a couple of episodes of a show with my wife, then time to watch the France-Iceland match, and now it's 23:42 and I still haven't written anything today.</p><p>During the less exciting parts of the match, I tried to think of something to write about. I wrote about football yesterday, so maybe something else? On the other hand, watching football and writing code is most of what I've been doing for the past two weeks, and I've written plenty about code as well.</p><p>As I was thinking about this, my dog, who was stretched out next to me on the couch, fast asleep, must have decided that he wasn't taking up quite enough room and stretched his hind legs all the way back and squashed me into the arm of the sofa. As I was moving his legs back, I was suddenly struck by a thought: what are dogs thinking about? As the first person in the history of the world to ponder this important issue, I'm sure I'll go on to become famous in philosophical circles or veterinary circles or veterinary philosophy circles.</p><p>Sometimes it's obvious what dogs are thinking. "OMG gimme gimme", when there's anything in your hand that they suspect is edible or playable with; "you want me to do what now?" when you're telling them to go lie down instead of sitting so close to the dinner table that their nose is actually making contact with your plate; "it's not happening, Dad," when you're telling them to sit and they don't want to; "I wonder how many other dogs have walked by this exact leaf in the last 72 hours?" when they're intently sniffing the same leaf for five minutes instead of walking or, you know, answering the call of nature.</p><p>But other times, it is a complete mystery what is going on in their heads. My dog likes to sleep, and spends most of his time doing that. He has various comfortable places around the house, and he'll be dozing in one of them, then all of a sudden get up, walk a couple of metres to another spot, plop down, and go back to sleep. Why? What made him decide, all of a sudden, that his previous spot just wasn't cutting it anymore and he needed to make a change in his life? What criteria did he use to select the next spot? Was it worth getting up and moving, or does he ever have regrets about his choices?</p><p>We may have to just accept that some things are unknowable.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-17-more-like-it.html</id>
    <link href="https://jmglov.net/blog/2022-07-17-more-like-it.html"/>
    <title>Now that's more like it!</title>
    <updated>2022-07-17T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I watched the <a href='https://www.uefa.com/womenseuro/match/2032223--sweden-vs-portugal/'>Sweden-Portugal
match</a> at the Women's Euro 2022, and that is definitely what I've been waiting for from Sweden! We started the tournament against the Netherlands, who are definitely a good team, but they were there for the taking and we couldn't do it. We looked very rusty against Switzerland in our second match, but managed a goal early in the second half, then conceded immediately afterwards and had to wait until the 79th minute to score the winner. It was not fun to watch, and the Swedish media were rightly critical of the performance. This match, on the other hand, <strong>was</strong> fun to watch, and I don't think the papers will be finding too much fault with the performance in the morning.</p><p>We started very strongly, playing the first ten minutes or so pretty much in Portugal's box. Being an Arsenal fan, I had that feeling like "we need to make this pressure count or we're gonna regret it!" Well, we didn't make that pressure count and we very nearly did regret it, as Portugal fought their way back into the game and probably should have scored in the 17th minute. That seemed to be the warning we needed, and Asllani took the game by the scruff of the neck, striking terror into the Portuguese defenders with her quick feet, great passing, and willingness to engage in the Dark Arts when necessary. At 21 minutes, she made a great pass to set up Nathalie Björn for a shot which was deflected out for a corner. The delivery was good from the ensuing corner, and the Portuguese goalkeeper came and punched it but didn't get much distance, and when the ball dropped for Filippa Angeldahl, she blasted it in.</p><p>We continued turning the screw with some great pressing and varied attacking play, winning a few more corners and looking dangerous, and then on 42 minutes, Stina Blackstenius scored the goal of the tournament! Asllani received a pass with her back to goal, but instead of controlling the back, she laid on a delicious backheel to send Blackstenius through 1:1 against the keeper, and Stina made no mistake with the finish. Unfortunately, she did make a mistake with the timing of her run, and the goal was rightly ruled out for offside. 😭</p><p>I was composing an angry tweet about how life is so unfair because the sheer artistry of the pass from Asllani deserved a goal, when I got distracted by some more great play by Asllani, this time a tricky dribble down to the baseline on the right that drew a foul from the Portuguese defender. Asllani took the resulting free kick low, pulling it back to Angeldahl as she made a run into the box and knocked it in to make it 2-0 right before halftime. I decided to abort the tweet, as it no longer felt that important to be upset about a goal we didn't score.</p><p>There had been a fair few stoppages for injury, so 7 minutes were added on at the end of the first half, and we got another goal off another set piece in the 7th of those minutes! It was more than I had hoped for, but certainly no less than our performance up to that point warranted.</p><p>The second half was more of the same: Sweden pressing and winning the ball back time and again, and unlike our errant and fairly insipid passing against the Netherlands and Switzerland, this time we were moving the ball with purpose and not wasting it. Stina Blackstenius started looking like her old self, making some good runs and getting a few shots off, but she definitely looked like she needed a goal to shake the cobwebs completely off after returning from a minor injury that kept her out of the starting lineup for our first group game.</p><p>At 53 minutes, we were awarded a penalty (rather harshly, I have to admit) for a handball in the box by Portugal. Asllani stepped up to take it, and put it so far into the bottom right corner that the keeper couldn't get a hand to even though she guessed the right way.</p><p>A few minutes later, Stina B finally got the goal she needed, with a beautiful looping header over the keeper off the inside of the far post, but of course it was ruled out for offside. They showed the frame that VAR used to make the ruling, and sure enough, Stina's shoulder is at least two millimetres ahead of the last defender's foot. Great use of technology there! 🙄</p><p>Sweden took their foot off the pedal a bit after about 75 minutes, and Portugal had a chance or two which they couldn't take, but then Blackstenius finally got her goal one minute into added time, blasting it into the top corner from just inside the box. I swear she looked over at the line judge to see if the flag was up. 😂</p><p>That was a lot of fun, and I think fans and the players themselves needed a reminder of why we are highly rated. I think there's still room for improvement, which is encouraging, because England and Germany look like the teams to beat at this tournament, and if we want to have a chance of beating either of them, we're going to need to do all of the good stuff we did today, but better.</p><p>Now we're through to the quarterfinals as group winners, meaning we meet the runners up from Group D, which will be one of Iceland, Belgium, or Italy. I'm hoping it will be Iceland, who are definitely one of the underdogs in the tournament. They're playing France tomorrow (😱), so I would expect they'll need a lot of luck to go through, but stranger things have happened.</p><p><img src="assets/2022-07-17-sweden.jpg" alt="Swedish players celebrate after the game" title="Bra spelat!" width=800px /></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-16-portrait-of-the-coder.html</id>
    <link href="https://jmglov.net/blog/2022-07-16-portrait-of-the-coder.html"/>
    <title>A portrait of the coder as a young(-ish) man</title>
    <updated>2022-07-16T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>It's gonna be another short one today (#📞-it-📥), as I have 55 minutes to write this post, take a shower, and walk to the bus stop so I can head into town to meet my friend Jordan for a visit to <a href='https://www.fotografiska.com/sto'>Fotografiska</a>, a cool museum of photography.</p><p>I've been <a href='tags/blog.html'>hacking quite a bit on my blog</a> over the last few days, but it's been behind the scenes stuff that speeds things up for me when I publish a new post, so it's probably not very exciting unless you're a Clojure programmer, in which case it's absolutely gripping stuff. Maybe. Probably not. But perhaps.</p><p>Clojure impressario <a href='https://github.com/borkdude'>borkdude</a> (borkiest of all dudes, let me assure you) got sick and tired of less borky dudes like myself forking <a href='https://github.com/borkdude/blog'>his blog</a>, so he wrapped it up as a library and dubbed it <a href='https://github.com/borkdude/quickblog'>quickblog</a>!</p><p><a href='https://twitter.com/borkdude/status/1547912740156583936'><img src="assets/2022-07-16-quickblog.png" alt="Tweet with demo of blog rendering. Tweet reads: Announcing quickblog, a
light-weight static blog engine for Clojure and Babashka!" title= "Like a blog, but quicker!" /></a></p><p>Since I've done various things to the blog since forking it, I've been hard at work folding the useful stuff back into quickblog so that it will render this blog and I can flush my hacky hacks down the toilet of history where they belong. 😉</p><p>It's been loads of fun working with borkdude on this. I haven't really collaborated on code with someone else since my friend Joanna and I wrote version 2 of the components system for the Core Banking Platform at work a year and a half or so ago, and it's something that I didn't know I was missing until I started doing it again.</p><p>I like Clojure the programming language, but I don't think I would have stuck with it for 10 years or however long it's been (I bought the first edition of "[Programming
Clojure]"(https://pragprog.com/titles/shcloj3/programming-clojure-third-edition/) when it was published back in 2008, but I didn't start using Clojure for anything more than playing around in the REPL&ndash;mainly translating exercises from "<a href='https://mitpress.mit.edu/books/little-schemer-fourth-edition'>The Little
Schemer</a>" to Clojure, truth be told&ndash;until around 2012, when I write a terribly awful system test framework for an adserver at work) if it wasn't for Clojure the community, especially the amazing European Clojurists who I used to run into at various conferences around the continent before The End Times. Speaking of which, <a href='https://clojuredays.org/'>Dutch
Clojure Days</a> is back this year at the end of October, so I hope to see some of you in Amsterdam! 😀</p><p>Anyway, I'll let y'all know when quickblog is successfully rendering this blog, and until then, I'll let y'all go.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-15-hacking-blog-actually-caching.html</id>
    <link href="https://jmglov.net/blog/2022-07-15-hacking-blog-actually-caching.html"/>
    <title>Hacking the blog: actually caching</title>
    <updated>2022-07-15T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>When last we left our intrepid blogger (me), he (I) had just <a href='2022-07-14-hacking-blog-repl.html'>admitted that
there was a bug</a> in his (my) caching code. 😢</p><p>But never fear! He (I) grabbed some <a href='https://melreams.com/2017/05/rich-hickey-hammock-driven-development/'>hammock
time</a>, then grabbed his (my) trusty REPL, then set about decomplecting the caching code to make it Rich compliant. This post will detail the outcome of that decomplecting.</p><p>OK, I'm getting tired of switching from third to first person, so let me settle on one from here on out: second person! No, that would be horribly confusing to you (me), so perhaps first person would be the right person.</p><p>To explain the caching strategy I landed on, let me recap the three categories of staleness that I identified:</p><ol><li>Files that only depend on themselves: assets and stylesheet</li><li>Files that depend on themselves, the templates, and the rendering system:   post pages</li><li>Files that depend on posts, templates, and the rendering system: archive   page, index page, tag pages, and RSS feeds</li></ol><p>Let's start at the top of <a href='https://github.com/jmglov/jmglov.net/blob/6cc42e6927b1c0c2bd8621a01a774d5185608fa1/render_blog.clj'><code>render&#95;blog.clj</code></a> and walk through how we handle each of these.</p><h2 id="assets_and_stylesheet">Assets and stylesheet</h2><p>This has not changed from my <a href='2022-07-11-hacking-blog-caching.html'>original caching
strategy</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;lib/copy-tree-modified &#40;fs/file blog-dir &quot;assets&quot;&#41;
                        asset-dir
                        &#40;.getParent out-dir&#41;&#41;

&#40;let &#91;style-src &#40;fs/file templates-dir &quot;style.css&quot;&#41;
      style-target &#40;fs/file out-dir &quot;style.css&quot;&#41;&#93;
  &#40;lib/copy-modified style-src style-target&#41;&#41;
</code></pre><h2 id="posts">Posts</h2><p>Now that we don't use <code>posts.edn</code> anymore, posts are loaded like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def posts &#40;-&gt;&gt; &#40;lib/load-posts posts-dir default-metadata&#41;
                &#40;lib/add-modified-metadata posts-dir out-dir&#41;&#41;&#41;
</code></pre><p><a href='https://github.com/jmglov/jmglov.net/blob/6cc42e6927b1c0c2bd8621a01a774d5185608fa1/lib.clj#L114'><code>lib/load-posts</code></a> is similar to how it worked <a href='https://github.com/jmglov/jmglov.net/blob/54f030bbb04e4f07f9e6fb512bdf99ae28753fd7/lib.clj#L114'>back in the <code>posts.edn</code>
days</a>, except instead of returning a list of post metadata, it returns a list of maps containing both the metadata and the actual rendered markdown for each post. Let's have a look:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn load-posts
  &quot;Returns all posts from `post-dir` in descending date order&quot;
  &#91;posts-dir default-metadata&#93;
  &#40;-&gt;&gt; &#40;fs/glob posts-dir &quot;&#42;.md&quot;&#41;
       &#40;map #&#40;load-post &#40;.toFile %&#41; default-metadata&#41;&#41;
       &#40;remove
        &#40;fn &#91;{:keys &#91;metadata&#93;}&#93;
          &#40;when-let &#91;missing-keys
                     &#40;seq &#40;set/difference required-metadata
                                          &#40;set &#40;keys metadata&#41;&#41;&#41;&#41;&#93;
            &#40;println &quot;Skipping&quot; &#40;:file metadata&#41;
                     &quot;due to missing required metadata:&quot;
                     &#40;str/join &quot;, &quot; &#40;map name missing-keys&#41;&#41;&#41;
            :skipping&#41;&#41;&#41;
       &#40;sort-by &#40;comp :date :metadata&#41; &#40;comp - compare&#41;&#41;&#41;&#41;
</code></pre><p>So instead of reading <code>posts.edn</code>, we now do the following:</p><ul><li>Call <a href='https://babashka.org/fs/codox/babashka.fs.html#var-glob'><code>fs/glob</code></a> tolist all Markdown files in the posts directory</li><li>Map over these with <code>load-post</code>, which we'll look at in a minute (note that<code>fs/glob</code> returns a list of <code>sun.nio.fs.UnixPath</code> objects, and <code>load-file</code> wantsa <code>java.io.File</code>, so we need to call <code>.toFile</code> here)</li><li>Remove the posts that don't have the required metadata keys (<code>:date</code> and  <code>:title</code>), since we won't be able to render them</li><li>Sort by date, reversing the order so that we get the most recent posts first</li></ul><p>Now let's look into <a href='https://github.com/jmglov/jmglov.net/blob/6cc42e6927b1c0c2bd8621a01a774d5185608fa1/lib.clj#L102'><code>lib/load-post</code></a> to see what this new post data structure looks like:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn load-post
  &#91;file default-metadata&#93;
  {:html &#40;delay &#40;markdown-&gt;html file&#41;&#41;
   :metadata &#40;do
               &#40;println &quot;Reading metadata for file:&quot; &#40;str file&#41;&#41;
               &#40;-&gt; &#40;slurp file&#41;
                   md/md-to-meta
                   &#40;transform-metadata default-metadata&#41;
                   &#40;assoc :file &#40;.getName file&#41;&#41;&#41;&#41;}&#41;
</code></pre><p>The first thing that stands out here is that we're returning a map with keys <code>:html</code> and <code>:metadata</code>, rather than returning the metadata map directly like <code>posts.edn</code> did. Let's look first at the value of the <code>:metadata</code> key:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;do
  &#40;println &quot;Reading metadata for file:&quot; &#40;str file&#41;&#41;
  &#40;-&gt; &#40;slurp file&#41;
      md/md-to-meta
      &#40;transform-metadata default-metadata&#41;
      &#40;assoc :file &#40;.getName file&#41;&#41;&#41;&#41;
</code></pre><p>We're reading in the file with <a href='https://clojuredocs.org/clojure.core/slurp'><code>slurp</code></a>, then feeding it to markdown-clj's <a href='https://github.com/yogthos/markdown-clj#metadata'><code>md-to-meta</code></a> function. If you recall from <a href='2022-07-14-hacking-blog-repl.html'>Hacking the blog: REPLing to
victory</a>, we are now <a href='https://github.com/fletcher/MultiMarkdown/wiki/MultiMarkdown-Syntax-Guide#metadata'>adding
metadata</a> to our Markdown files by starting the files like this:</p><pre class="language-markdown"><code class="lang-markdown language-markdown">Title: Hacking the blog: REPLing to victory
Date: 2022-07-14
Tags: clojure,blog,babashka

One of the &#91;things I learned on Tuesday&#93;&#40;2022-07-12-stuff-i-learned.html&#41; was...
</code></pre><p>The <code>md-to-meta</code> function just reads in and parses the metadata, without rendering the Markdown itself. The reason why we're decomplecting parsing metadata from rendering Markdown here is that parsing metadata is ⚡fast⚡, whereas rendering Markdown is (relatively) 🐢slow🐢. Also cuz Rich Hickey sez so, of course. 😉</p><p>After reading in the metadata, we transform it with <a href='https://github.com/jmglov/jmglov.net/blob/6cc42e6927b1c0c2bd8621a01a774d5185608fa1/lib.clj#L63'><code>transform-metadata</code></a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def metadata-transformers
  {:default first
   :tags #&#40;-&gt; % first &#40;str/split #&quot;,\s&#42;&quot;&#41; set&#41;}&#41;

&#40;defn transform-metadata
  &#91;metadata default-metadata&#93;
  &#40;-&gt;&gt; metadata
       &#40;map &#40;fn &#91;&#91;k v&#93;&#93;
              &#40;let &#91;transformer &#40;or &#40;metadata-transformers k&#41;
                                    &#40;metadata-transformers :default&#41;&#41;&#93;
                &#91;k &#40;transformer v&#41;&#93;&#41;&#41;&#41;
       &#40;into {}&#41;
       &#40;merge default-metadata&#41;&#41;&#41;
</code></pre><p><code>md-to-meta</code> returns a list of values for each key, since MultiMarkdown allows including a key multiple times, so you can say something like:</p><pre class="language-markdown"><code class="lang-markdown language-markdown">Author: Some Awesome Person
Author: Some Equally Awesome Person
</code></pre><p>That's why our default metadata transformer is <a href='https://clojuredocs.org/clojure.core/first'><code>first</code></a>, so it turns a list of one value into just the value.</p><p>Tags are slightly more complicated, since the way I have chosen to represent them is a comma-delimited list. That's why the transformer first uses <code>first</code> to get the value, then uses <a href='https://clojuredocs.org/clojure.string/split'><code>clojure.string/split</code></a> to turn the comma-delimited string into a list of tags, then uses <a href='https://clojuredocs.org/clojure.core/set'><code>set</code></a> to turn that list into a set, since the order of tags doesn't matter.</p><p>Finally, <code>transform-metadata</code> turns the list of pairs returned by <a href='https://clojuredocs.org/clojure.core/map'><code>map</code></a> back into a hashmap, then merges it with <code>default-metadata</code>, which I haven't talked about before, but is passed in from <code>render&#95;blog.clj</code> and looks like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def default-metadata
  {:author &quot;Josh Glover&quot;
   :copyright &quot;cc/by-nc/4.0&quot;}&#41;
</code></pre><p>The point of this is so that I don't have to include the author and copyright at the top of every file.</p><p>The last thing that <code>load-post</code> needs to do to the metadata is to add the post's filename, which is useful for all sorts of reasons that we'll see later on.</p><p>OK, that covers building the metadata for a post, so now let's turn our roving eye to the content:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn load-post
  &#91;file default-metadata&#93;
  {:html &#40;delay &#40;markdown-&gt;html file&#41;&#41;
   :metadata &#40;do :stuff&#41;}&#41;
</code></pre><p>This <a href='https://clojuredocs.org/clojure.core/delay'><code>delay</code></a> looks kind of interesting, but let's ignore it for now and look at <a href='https://github.com/jmglov/jmglov.net/blob/6cc42e6927b1c0c2bd8621a01a774d5185608fa1/lib.clj#L93'><code>markdown-&gt;html</code></a> first:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn markdown-&gt;html &#91;file&#93;
  &#40;let &#91;markdown &#40;slurp file&#41;&#93;
    &#40;println &quot;Processing markdown for file:&quot; &#40;str file&#41;&#41;
    &#40;-&gt; markdown
        pre-process-markdown
        &#40;md/md-to-html-string-with-meta :reference-links? true&#41;
        :html
        post-process-markdown&#41;&#41;&#41;
</code></pre><p>After we slurp in the file, we feed it to the <a href='https://github.com/jmglov/jmglov.net/blob/6cc42e6927b1c0c2bd8621a01a774d5185608fa1/lib.clj#L75'><code>pre-process-markdown</code></a> function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn pre-process-markdown &#91;markdown&#93;
  &#40;-&gt; markdown
      h/highlight-clojure
      ;; make links without markup clickable
      &#40;str/replace #&quot;http&#91;A-Za-z0-9/:.=#?&#95;-&#93;+&#40;&#91;\s&#93;&#41;&quot;
                   &#40;fn &#91;&#91;match ws&#93;&#93;
                     &#40;format &quot;&#91;%s&#93;&#40;%s&#41;%s&quot;
                             &#40;str/trim match&#41;
                             &#40;str/trim match&#41;
                             ws&#41;&#41;&#41;
      ;; allow links with markup over multiple lines
      &#40;str/replace #&quot;\&#91;&#91;&#94;\&#93;&#93;+\n&quot;
                   &#40;fn &#91;match&#93;
                     &#40;str/replace match &quot;\n&quot; &quot;
&quot;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>This is some super-duper <a href='https://github.com/borkdude'>borkdude</a> magic which I copied with pride that adds syntax highlighting to Clojure code blocks like the one above, and also makes links a bit nicer.</p><p>After enriching the Markdown, we render it to HTML by using markdown-clj's <code>md-to-html-string-with-meta</code> function, which returns a map with keys <code>:metadata</code> and <code>:html</code>. Since we handle the metadata separately, all we care about is the value of the <code>:html</code> key. The final thing we need to do is to send it on to <a href='https://github.com/jmglov/jmglov.net/blob/6cc42e6927b1c0c2bd8621a01a774d5185608fa1/lib.clj#L90'><code>post-process-markdown</code></a> to finish the job that <code>pre-process-markdown</code> started:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn post-process-markdown &#91;html&#93;
  &#40;str/replace html &quot;
&quot; &quot;\n&quot;&#41;&#41;
</code></pre><p>OK, back to that mysterious <code>delay</code> (as an aside, my wife's name is Delyana, so whenever I try to type "delay", my fingers produce "delya" instead, kind of like when I try to type my friend Linus's name and always type "Linux" instead). What <code>delay</code> does is:</p><blockquote><p> Takes a body of expressions and yields a <code>Delay</code> object that will invoke the  body only the first time it is forced (with <code>force</code> or <code>deref</code>/<code>@</code>), and will  cache the result and return it on all subsequent <code>force</code> calls. </p></blockquote><p>The reason we want to delay evaluation of <code>markdown-&gt;html</code> is that rendering Markdown is two orders of magnitude more expensive than simply parsing the metadata:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;let &#91;file &#40;fs/file &quot;blog&quot; &quot;posts&quot; &quot;2022-07-15-hacking-blog-actually-caching.md&quot;&#41;&#93;
  &#40;time
   &#40;do
     &#40;println &quot;Processing metadata for file&quot; &#40;str file&#41;&#41;
     &#40;-&gt; &#40;slurp file&#41;
         md/md-to-meta
         &#40;transform-metadata {}&#41;
         &#40;assoc :file &#40;.getName file&#41;&#41;&#41;&#41;&#41;

  &#40;time &#40;markdown-&gt;html file&#41;&#41;&#41;

;; Processing metadata for file blog/posts/2022-07-15-hacking-blog-actually-caching.md
;; &quot;Elapsed time: 1.210838 msecs&quot;
;; Processing markdown for file: blog/posts/2022-07-15-hacking-blog-actually-caching.md
;; &quot;Elapsed time: 841.44889 msecs&quot;
</code></pre><p>If the post hasn't changed since last it was rendered, we won't need to render it (this is a tiny lie, but more on that later), so delaying evaluation lets us return right away but allow access to the rendered HTML when needed, as we shall see.</p><p>Whew, that was a lot! The reason that we wanted to look at how posts are loaded is so that we can understand how they can be cached. If we look back at <code>render.clj</code>, we'll see that there's a second step in loading the posts:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def posts &#40;-&gt;&gt; &#40;lib/load-posts posts-dir default-metadata&#41;
                &#40;lib/add-modified-metadata posts-dir out-dir&#41;&#41;&#41;
</code></pre><p>Let's look at what <a href='https://github.com/jmglov/jmglov.net/blob/6cc42e6927b1c0c2bd8621a01a774d5185608fa1/lib.clj#L132'><code>lib/add-modified-metadata</code></a> is doing:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn add-modified-metadata
  &quot;Adds :modified? to each post showing if it is new or modified more recently than `out-dir`&quot;
  &#91;posts-dir out-dir posts&#93;
  &#40;let &#91;post-files &#40;map #&#40;fs/file posts-dir &#40;get-in % &#91;:metadata :file&#93;&#41;&#41; posts&#41;
        html-file-exists? #&#40;-&gt;&gt; &#40;get-in % &#91;:metadata :file&#93;&#41;
                                lib/html-file
                                &#40;fs/file out-dir&#41;
                                fs/exists?&#41;
        new-posts &#40;-&gt;&gt; &#40;remove html-file-exists? posts&#41;
                       &#40;map &#40;comp :file :metadata&#41;&#41;
                       set&#41;
        modified-posts &#40;-&gt;&gt; post-files
                            &#40;fs/modified-since out-dir&#41;
                            &#40;map #&#40;str &#40;.getFileName %&#41;&#41;&#41;
                            set&#41;
        new-or-modified-posts &#40;set/union new-posts modified-posts&#41;&#93;
    &#40;map #&#40;assoc-in %
                    &#91;:metadata :modified?&#93;
                    &#40;contains? new-or-modified-posts
                               &#40;get-in % &#91;:metadata :file&#93;&#41;&#41;&#41;
         posts&#41;&#41;&#41;
</code></pre><p>OK, there is a lot going on here at first glance, but it isn't really as complicated as it might look. Let's walk through it step by step:</p><ol><li>Get the filenames of each post by grabbing the <code>:file</code> key from its metadata</li><li>Define a function <code>html-file-exists?</code> that constructs the filename of the   HTML file that will be written once the post is rendered and processed by the   <a href='https://github.com/yogthos/Selmer'>Selmer</a> templating system, then checks if   that file exists</li><li>Create a set of new posts by removing the posts for which <code>html-file-exists?</code>   is true</li><li>Create a set of modified posts by using <code>fs/modified-since</code> to grab the posts   that have been modified more recently than the output directory, then map   them over <code>#&#40;str &#40;.getFilename %&#41;&#41;</code> to turn the path into a regular old   filename</li><li>Create a set of new or modified posts by taking the union of the two sets</li><li>Map over the posts with a function that checks whether each post's filename   exists in the set of new or modified files and saves the result in the post's   metadata under the <code>:modified</code> key</li></ol><p>So running this on a list of posts would yield something like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; &#40;lib/load-posts posts-dir default-metadata&#41;
     &#40;take 2&#41;
     &#40;lib/add-modified-metadata posts-dir out-dir&#41;&#41;
;; =&gt; &#40;{:html #&lt;Delay@78c390f1: :not-delivered&gt;,
;;      :metadata
;;      {:author &quot;Josh Glover&quot;,
;;       :copyright &quot;cc/by-nc/4.0&quot;,
;;       :tags #{&quot;clojure&quot; &quot;blog&quot; &quot;babashka&quot;},
;;       :preview &quot;true&quot;,
;;       :title &quot;Hacking the blog: actually caching&quot;,
;;       :date &quot;2022-07-15&quot;,
;;       :file &quot;2022-07-15-hacking-blog-actually-caching.md&quot;,
;;       :modified? true}}
;;     {:html #&lt;Delay@7393dd51: :not-delivered&gt;,
;;      :metadata
;;      {:author &quot;Josh Glover&quot;,
;;       :copyright &quot;cc/by-nc/4.0&quot;,
;;       :tags #{&quot;clojure&quot; &quot;blog&quot; &quot;babashka&quot;},
;;       :title &quot;Hacking the blog: REPLing to victory&quot;,
;;       :date &quot;2022-07-14&quot;,
;;       :file &quot;2022-07-14-hacking-blog-repl.md&quot;,
;;       :modified? false}}&#41;
</code></pre><p>Nice! Now that we have a list of posts with metadata telling us whether they have been modified, let's look at how the posts are rendered:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def bodies &#40;atom {}&#41;&#41;  ; re-used when generating atom.xml

&#40;doseq &#91;post posts&#93;
  &#40;lib/write-post! {:page-template page-template
                    :bodies bodies
                    :discuss-fallback discuss-fallback
                    :out-dir out-dir
                    :post-template post-template
                    :posts-dir posts-dir
                    :rendering-system-files rendering-system-files}
                   post&#41;&#41;
</code></pre><p>The important section of <a href='https://github.com/jmglov/jmglov.net/blob/6cc42e6927b1c0c2bd8621a01a774d5185608fa1/lib.clj#L199'><code>lib/write-post!</code></a> looks like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;if &#40;or modified?
        &#40;rendering-modified? rendering-system-files out-file&#41;&#41;
  &#40;let &#91;body &#40;selmer/render post-template {:body @html
                                           :title title
                                           :date date
                                           :discuss discuss
                                           :tags tags}&#41;
        rendered-html &#40;render-page config page-template
                                   {:title title
                                    :body body}&#41;&#93;
    &#40;println &quot;Writing post:&quot; &#40;str out-file&#41;&#41;
    &#40;spit out-file rendered-html&#41;
    &#40;let &#91;legacy-dir &#40;fs/file out-dir
                              &#40;str/replace date &quot;-&quot; &quot;/&quot;&#41;
                              &#40;str/replace file &quot;.md&quot; &quot;&quot;&#41;&#41;&#93;&#41;&#41;
  &#40;println file &quot;not modified; using cached version&quot;&#41;&#41;
</code></pre><p>If the post file itself has been modified or the rendering system has been modified, we call <code>selmer/render</code> with the <code>:body</code> template variable set to the result of <strong>dereferencing</strong> the post's <code>:html</code> key. Dereferencing forces the delayed evaluation to happen and gives us back the result, which in this case is calling <code>markdown-&gt;html</code> on the file contents of the post.</p><p>If the post hasn't been modified, there's no need to re-render the template, so we'll just notify the user of this fact, leave it alone, and move on with our life.</p><p>Alright, that covers posts, so now we can move onto...</p><h2 id="archive_file">Archive file</h2><p>This one is quite a bit more straightforward: we only need to re-render the archive page if any post has changed or the rendering system has been modified more recently than the archive file was last rendered. Here's the code:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;let &#91;archive-file &#40;fs/file out-dir &quot;archive.html&quot;&#41;
      rendering-modified? &#40;lib/rendering-modified? rendering-system-files
                                                   archive-file&#41;&#93;
  &#40;if &#40;or rendering-modified? &#40;lib/some-post-modified posts&#41;&#41;
    &#40;do
      &#40;println &quot;Writing archive page&quot; &#40;str archive-file&#41;&#41;
      &#40;spit archive-file
            &#40;selmer/render page-template
                           {:skip-archive true
                            :title &#40;str blog-title &quot; - Archive&quot;&#41;
                            :body &#40;hiccup/html &#40;lib/post-links {} &quot;Archive&quot; posts&#41;&#41;}&#41;&#41;&#41;
    &#40;println &quot;No posts modified; skipping archive file&quot;&#41;&#41;&#41;
</code></pre><p>The cool thing to note here is that the archive page doesn't use the contents of posts at all, only their metadata, so we never deference <code>:html</code>, thus never force a re-render of posts. This covers the case where we've changed the name, date, or tags of a post, since those are the only things that appear in the archive page.</p><h2 id="tags">Tags</h2><p>For tags, we generate a page for each tag linking to all of the posts with that tag, and an index file linking to each tag:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def posts-by-tag &#40;lib/posts-by-tag posts&#41;&#41;
&#40;def tags-dir &#40;fs/create-dirs &#40;fs/file out-dir &quot;tags&quot;&#41;&#41;&#41;

&#40;let &#91;tags-file &#40;fs/file tags-dir &quot;index.html&quot;&#41;
      rendering-modified? &#40;lib/rendering-modified? rendering-system-files
                                                   tags-dir&#41;&#93;
  &#40;if &#40;or rendering-modified? &#40;lib/some-post-modified posts&#41;&#41;
    &#40;do
      &#40;println &quot;Writing tags page&quot; &#40;str tags-file&#41;&#41;
      &#40;spit tags-file
            &#40;selmer/render page-template
                           {:skip-archive true
                            :title &#40;str blog-title &quot; - Tags&quot;&#41;
                            :relative-path &quot;../&quot;
                            :body &#40;hiccup/html &#40;lib/tag-links &quot;Tags&quot; posts-by-tag&#41;&#41;}&#41;&#41;
      &#40;doseq &#91;tag-and-posts posts-by-tag&#93;
        &#40;lib/write-tag! {:page-template page-template
                         :blog-title blog-title
                         :tags-dir tags-dir}
                        tag-and-posts&#41;&#41;&#41;
    &#40;println &quot;No posts modified; skipping tag files&quot;&#41;&#41;&#41;
</code></pre><p>These pages only need to be written if any posts have changed or if the rendering system has been modified more recently than the <code>tags/</code> subdirectory of the output directory. Just like with the archive page, the tags pages only use the posts' metadata, so re-rendering these pages won't force the posts themselves to be re-rendered.</p><h2 id="index_page">Index page</h2><p>The top-level <code>index.html</code> page contains the <em>n</em> most recent posts (where *n = 3<em> for my blog), so it needs to be re-rendered if any of the </em>n* most recent posts have changed or the rendering system has been modified more recently than the index page was last written. The index page <strong>does</strong> use the rendered HTML from each post, but as <code>delay</code> caches its result, posts that have been modified will already have been written by <code>lib/write-post!</code>, so at least we won't double-render a post. 🎉</p><h2 id="rss_feeds">RSS feeds</h2><p>We write two RSS feeds for the blog, one specific to <a href='http://planet.clojure.in/'>Planet
Clojure</a> which contains only posts tagged with "clojure" or "clojurescript", and one containing all posts.</p><p>These feeds are just like the archive, tag, and index pages in that they only need to be regenerated when any post has changed or when the rendering system has been modified more recently than each RSS feed file:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;let &#91;feed-file &#40;fs/file out-dir &quot;atom.xml&quot;&#41;
      clojure-feed-file &#40;fs/file out-dir &quot;planetclojure.xml&quot;&#41;
      clojure-posts &#40;filter
                     &#40;fn &#91;{:keys &#91;metadata&#93;}&#93;
                       &#40;some &#40;:tags metadata&#41; &#91;&quot;clojure&quot; &quot;clojurescript&quot;&#93;&#41;&#41;
                     posts&#41;&#93;
  &#40;if &#40;or &#40;lib/rendering-modified? rendering-system-files clojure-feed-file&#41;
          &#40;lib/some-post-modified clojure-posts&#41;&#41;
    &#40;do
      &#40;println &quot;Writing Clojure feed&quot; &#40;str clojure-feed-file&#41;&#41;
      &#40;spit clojure-feed-file
            &#40;atom-feed clojure-posts&#41;&#41;&#41;
    &#40;println &quot;No Clojure posts modified; skipping Clojure feed&quot;&#41;&#41;
  &#40;if &#40;or &#40;lib/rendering-modified? rendering-system-files feed-file&#41;
          &#40;lib/some-post-modified posts&#41;&#41;
    &#40;do
      &#40;println &quot;Writing feed&quot; &#40;str feed-file&#41;&#41;
      &#40;spit feed-file
            &#40;atom-feed posts&#41;&#41;&#41;
    &#40;println &quot;No posts modified; skipping main feed&quot;&#41;&#41;&#41;
</code></pre><p>Note that the Clojure feed is only re-rendered when one of the Clojure posts changes, so me writing a post about the <a href='2022-07-10-hands-off-womens-football.html'>Women's EURO
2022</a> won't cause the Clojure feed to be regenerated.</p><p>And that's all there is to it! Let's see what happens if we render our blog from scratch:</p><pre><code>&#91;jmglov@laurana:&#126;/Documents/code/jmglov.net&#93;$ bb clean
&#91;jmglov@laurana:&#126;/Documents/code/jmglov.net&#93;$ time bb render-blog
Reading metadata for file: blog/posts/2022-07-06-hacking-blog-categories.md
Reading metadata for file: blog/posts/2022-06-17-creating-a-blog-with-clojure.md
Reading metadata for file: blog/posts/2022-07-12-stuff-i-learned.md
&#91;...&#93;
Writing public/blog/assets/2022-06-29-rover-cake.jpg
Writing public/blog/assets/2022-07-10-reverse-sexism.png
Writing public/blog/assets/2022-07-10-all-for-it.png
&#91;...&#93;
Writing public/blog/style.css
Processing markdown for file: blog/posts/2022-07-15-hacking-blog-actually-caching.md
Writing post: public/blog/2022-07-15-hacking-blog-actually-caching.html
Processing markdown for file: blog/posts/2022-07-14-hacking-blog-repl.md
Writing post: public/blog/2022-07-14-hacking-blog-repl.html
Processing markdown for file: blog/posts/2022-07-13-omg-what-have-i-done.md
Writing post: public/blog/2022-07-13-omg-what-have-i-done.html
&#91;...&#93;
Writing archive page public/blog/archive.html
Writing tags page public/blog/tags/index.html
Writing tag page: public/blog/tags/football.html
Writing tag page: public/blog/tags/euro2022.html
&#91;...&#93;
Writing index page public/blog/index.html
Writing Clojure feed public/blog/planetclojure.xml
Writing feed public/blog/atom.xml

real	0m9.766s
user	0m8.987s
sys	0m0.116s
</code></pre><p>Ten whole seconds! 😭 But let's try rendering it again without making any changes:</p><pre><code>&#91;jmglov@laurana:&#126;/Documents/code/jmglov.net&#93;$ time bb render-blog
Reading metadata for file: blog/posts/2022-07-06-hacking-blog-categories.md
Reading metadata for file: blog/posts/2022-06-17-creating-a-blog-with-clojure.md
Reading metadata for file: blog/posts/2022-07-12-stuff-i-learned.md
&#91;...&#93;
2022-07-15-hacking-blog-actually-caching.md not modified; using cached version
2022-07-14-hacking-blog-repl.md not modified; using cached version
2022-07-13-omg-what-have-i-done.md not modified; using cached version
&#91;...&#93;
No posts modified; skipping archive file
No posts modified; skipping tag files
None of the 3 most recent posts modified; skipping index page
No Clojure posts modified; skipping Clojure feed
No posts modified; skipping main feed

real	0m0.211s
user	0m0.153s
sys	0m0.058s
</code></pre><p>Even an impatient person like me is willing to wait 200 milliseconds for my blog to render. 😉</p><p>Let's try re-running it again with changes to just one post (the one I'm typing right now):</p><pre><code>&#91;jmglov@laurana:&#126;/Documents/code/jmglov.net&#93;$ time bb render-blog
Reading metadata for file: blog/posts/2022-07-06-hacking-blog-categories.md
Reading metadata for file: blog/posts/2022-06-17-creating-a-blog-with-clojure.md
Reading metadata for file: blog/posts/2022-07-12-stuff-i-learned.md
&#91;...&#93;
Processing markdown for file: blog/posts/2022-07-15-hacking-blog-actually-caching.md
Writing post: public/blog/2022-07-15-hacking-blog-actually-caching.html
2022-07-14-hacking-blog-repl.md not modified; using cached version
2022-07-13-omg-what-have-i-done.md not modified; using cached version
2022-07-12-stuff-i-learned.md not modified; using cached version
&#91;...&#93;
Writing archive page public/blog/archive.html
Writing tags page public/blog/tags/index.html
Writing tag page: public/blog/tags/football.html
Writing tag page: public/blog/tags/euro2022.html
&#91;...&#93;
Writing index page public/blog/index.html
Processing markdown for file: blog/posts/2022-07-14-hacking-blog-repl.md
Processing markdown for file: blog/posts/2022-07-13-omg-what-have-i-done.md
Writing Clojure feed public/blog/planetclojure.xml
Processing markdown for file: blog/posts/2022-07-11-hacking-blog-caching.md
Processing markdown for file: blog/posts/2022-07-06-hacking-blog-categories.md
Processing markdown for file: blog/posts/2022-07-05-hacking-blog-favicon.md
&#91;...&#93;
Writing feed public/blog/atom.xml
Processing markdown for file: blog/posts/2022-07-12-stuff-i-learned.md
Processing markdown for file: blog/posts/2022-07-10-hands-off-womens-football.md
Processing markdown for file: blog/posts/2022-07-09-story-of-a-mediocre-fan-4.md
&#91;...&#93;
Processing markdown for file: blog/posts/2022-06-15-summertime.md

real	0m9.701s
user	0m8.912s
sys	0m0.123s
</code></pre><p>Well 💩! Why is it taking nearly 10 seconds to render one post? I mean, things start out so well:</p><pre><code>&#91;jmglov@laurana:&#126;/Documents/code/jmglov.net&#93;$ time bb render-blog
Reading metadata for file: blog/posts/2022-07-06-hacking-blog-categories.md
Reading metadata for file: blog/posts/2022-06-17-creating-a-blog-with-clojure.md
Reading metadata for file: blog/posts/2022-07-12-stuff-i-learned.md
&#91;...&#93;
Processing markdown for file: blog/posts/2022-07-15-hacking-blog-actually-caching.md
Writing post: public/blog/2022-07-15-hacking-blog-actually-caching.html
2022-07-14-hacking-blog-repl.md not modified; using cached version
2022-07-13-omg-what-have-i-done.md not modified; using cached version
2022-07-12-stuff-i-learned.md not modified; using cached version
</code></pre><p>but then devolve into sadness:</p><pre><code>Writing index page public/blog/index.html
Processing markdown for file: blog/posts/2022-07-14-hacking-blog-repl.md
Processing markdown for file: blog/posts/2022-07-13-omg-what-have-i-done.md
Writing Clojure feed public/blog/planetclojure.xml
Processing markdown for file: blog/posts/2022-07-11-hacking-blog-caching.md
Processing markdown for file: blog/posts/2022-07-06-hacking-blog-categories.md
Processing markdown for file: blog/posts/2022-07-05-hacking-blog-favicon.md
&#91;...&#93;
Writing feed public/blog/atom.xml
Processing markdown for file: blog/posts/2022-07-12-stuff-i-learned.md
Processing markdown for file: blog/posts/2022-07-10-hands-off-womens-football.md
Processing markdown for file: blog/posts/2022-07-09-story-of-a-mediocre-fan-4.md
&#91;...&#93;
Processing markdown for file: blog/posts/2022-06-15-summertime.md
</code></pre><p>Oh yeah, like I said, the index page needs the rendered HTML for the three most recent posts, which were <code>2022-07-15-hacking-blog-actually-caching.md</code>, <code>2022-07-14-hacking-blog-repl.md</code>, and <code>2022-07-13-omg-what-have-i-done</code>. Since <code>2022-07-15-hacking-blog-actually-caching.md</code> was actually modified, it was rendered by <code>lib/write-post!</code>, whereas the other two files weren't modified and thus weren't rendered until the index page tried to use them.</p><p>And then the Clojure feed needs all of the Clojure posts, which causes those to render, and then the main feed needs all of the posts, which causes them to render, meaning we've now rendered all of our posts. 😭</p><p>But never fear! Since we succeeded in simplifying things, we have all of the pieces we need to fix this problem as well. However, I've been writing and you've been reading for quite some time now, so let's leave that for another day (and maybe another post, who knows?).</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-14-hacking-blog-repl.html</id>
    <link href="https://jmglov.net/blog/2022-07-14-hacking-blog-repl.html"/>
    <title>Hacking the blog: REPLing to victory</title>
    <updated>2022-07-14T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>One of the <a href='2022-07-12-stuff-i-learned.html'>things I learned on Tuesday</a> was that I had a bug in the caching code on my blog (of course there's a bug in caching code; that is one of the few things you can count on in life). As any Clojure programmer would, I meditated upon the words of Rich Hickey and became enlightened. I must decomplect!</p><p>Let's look at what files are produced by my blog and what could cause them to become stale.</p><ul><li>Static assets (for example, images): the file itself is modified</li><li>Stylesheet: the file itself is modified</li><li>Posts: one of the following is modified:<ul><li>The entry in     <a href='https://github.com/jmglov/jmglov.net/blob/575a12cf2a87a4fd2a46dc131ed3a51f864ba57f/blog/posts.edn'><code>posts.edn</code></a>,    corresponding to a given post, since that means that some post metadata such    as title, date, tags, or filename may have changed</li><li>The Markdown file for a given post</li><li>A template file</li><li>Any Clojure file that is responsible for rendering:<ul><li><a href='https://github.com/jmglov/jmglov.net/blob/main/render_blog.clj'><code>render&#95;blog.clj</code></a></li><li><a href='https://github.com/jmglov/jmglov.net/blob/main/lib.clj'><code>lib.clj</code></a></li><li>The version of <a href='https://github.com/weavejester/hiccup'>Hiccup</a>,      <a href='https://github.com/yogthos/markdown-clj'>markdown-clj</a>, or      <a href='https://github.com/yogthos/Selmer'>Selmer</a>. I had not thought of this      before. Since this is a bit tricky, let's just use      <a href='https://github.com/jmglov/jmglov.net/blob/main/deps.edn'><code>deps.edn</code></a> as a      proxy for this, since all of these dependencies are specified there.</li></ul></li></ul></li><li>Archive page:<ul><li>A template file</li><li>The Markdown file for any post, since the title or filename may have changed</li><li>Any Clojure file that is responsible for rendering</li></ul></li><li>Tags index and tag-specific pages:<ul><li>A template file</li><li>The Markdown file for any post, since the title, filename, or tags may have    changed</li><li>Any Clojure file that is responsible for rendering</li></ul></li><li>Archive page:<ul><li>A template file</li><li>The Markdown file for any post, since the title or filename may have changed</li><li>Any Clojure file that is responsible for rendering</li></ul></li></ul><p>This simplifies matters quite a bit, because we only have three categories here:</p><ol><li>Files that only depend on themselves</li><li>Files that depend on themselves, the templates, and the rendering system</li><li>Files that depend on posts, templates, and the rendering system</li></ol><p>Category 1 is already simple (in the Hickian sense of the word), and in fact already works with my initial approach. Category 2 only applies to posts, which in fact category 3 depends on, so let's focus on getting category 2 working before we turn our attention to category 3.</p><p>Category 2 is definitely complex, since there are four separate things that should trigger a re-render for a given post:</p><ol><li>Its entry in <code>posts.edn</code></li><li>Its Markdown file</li><li>Any template (to be on the safe side)</li><li>The rendering system</li></ol><p>One obvious irritation is that the post's metadata and content come from different files. It would be simpler if the metadata was contained in the same file as the post, so that all we have to do to determine if the post needs to be re-rendered is to check if the Markdown file has been modified.</p><p>Luckily, there is a solution for this! <a href='https://github.com/fletcher/MultiMarkdown/wiki/MultiMarkdown-Syntax-Guide'>MultiMarkdown</a>—which is the flavour of Markdown implemented by markdown-clj—has an affordance for including <a href='https://github.com/fletcher/MultiMarkdown/wiki/MultiMarkdown-Syntax-Guide#metadata'>metadata</a> in a Markdown file. If you start your Markdown file like this:</p><pre><code>Title: Hacking the blog: REPLing to victory
Date: 2022-07-14
Tags: clojure,blog,babashka

Content starts here.
</code></pre><p>you are defining <code>Title</code>, <code>Date</code>, and <code>Tags</code> metadata.</p><p>markdown-clj supports this through the gloriously named <a href='https://github.com/yogthos/markdown-clj#metadata'><code>md-to-html-string-with-meta</code></a> function. Whereas the normal <code>md-to-html-string</code> function returns an HTML string, <code>md-to-html-string-with-meta</code> returns a map with keys <code>:metadata</code> and <code>:html</code>. I'll go into more details on this in a future post, but for now, I want to focus on the problem of moving the metadata from <code>posts.edn</code> to each Markdown file.</p><p>There is the obvious approach of doing it manually, which I would have chosen if I had just a few posts, but since I have 31 now, that would both take more time than I care to spend and also be rife with opportunities for manual errors.</p><p>So automation it is! Since <a href='https://github.com/babashka/babashka'>Babashka</a> was initially developed to allow us to write shell scripts in Clojure instead of Bash (or some other icky language like Python), it's an obvious choice for automating this. And since I'm using Clojure, I can skip the whole trial and error thing by using a technique we call REPL-driven development.</p><p>REPL-driven development basically boils down to constantly evaluating code in the context of our running program to try things out, rather than writing some code, running the program, watching it fail, scratching our heads, adding some debug print statements, re-running the program, watching it fail, reading the debug output, realising that we need a debug print in a place we didn't think of, adding it, re-running the program, reading the debug output, scratching our heads, changing the code, re-running the program, watching it fail in a different way...</p><p>If that last paragraph was tedious to read, you can imagine how tedious it is to actually do things this way! Or more likely, you don't have to imagine, because you've done things this way many many times.</p><p>So let me walk you through the actual REPL session I used to transform my files.</p><p>Since I'm using Emacs and <a href='https://docs.cider.mx/cider/index.html'>CIDER</a>, I'll be REPLing right in my editor, which is the superpower of the Clojure ecosystem. Many languages have a primitive REPL where you can execute code, but most don't integrate with your editor in any meaningful way (Elixir is an absolutely delightful exception to this rule 💜).</p><p>In order to enable this goodness, I need to start a Babashka REPL:</p><pre><code>bb nrepl-server 1667
</code></pre><p>Now, I open <code>lib.clj</code> in Emacs and run the <code>cider-connect</code> function, entering <code>localhost</code> and <code>1667</code> when prompted for host and port. CIDER will then open a REPL buffer and print something like this:</p><pre><code>;; Connected to nREPL server - nrepl://localhost:1667
;; CIDER 1.3.0 &#40;Ukraine&#41;, babashka.nrepl 0.0.6-SNAPSHOT
;; Babashka 0.8.156
;;     Docs: &#40;doc function-name&#41;
;;           &#40;find-doc part-of-name&#41;
;;   Source: &#40;source function-name&#41;
;;  Javadoc: &#40;javadoc java-object-or-class&#41;
;;     Exit: &lt;C-c C-q&gt;
;;  Results: Stored in vars &#42;1, &#42;2, &#42;3, an exception in &#42;e;
WARNING: Can't determine Clojure version.  The refactor-nrepl middleware requires clojure 1.8.0 &#40;or newer&#41;WARNING: clj-refactor and refactor-nrepl are out of sync.
Their versions are 3.5.2 and n/a, respectively.
You can mute this warning by changing cljr-suppress-middleware-warnings.
user&gt; 
</code></pre><p>Now I can evaluate Clojure forms in the REPL!</p><pre><code>user&gt; &#40;+ 1 2&#41;
;; =&gt; 3
</code></pre><p>This is cool, but no cooler than the REPLs that I called "primitive" a minute ago. To move from merely cool to totally awesome, I'll switch back to my <code>lib.clj</code> file and hit <code>C-c C-k</code> (that's Emacs-speak for Control + c followed by Control + k), which runs the <code>cider-load-buffer</code> Emacs function, which evaluates the entire file in your running REPL process. Now I can write code in the file and evaluate it straight away!</p><p>I'll start by writing a so-called <a href='https://betweentwoparens.com/blog/rich-comment-blocks/#rich-comment'>Rich
comment</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment

  &#41;
</code></pre><p>The <code>comment</code> macro is a nice way to comment out some code in a way that allows for <a href='https://practical.li/spacemacs/structural-editing/'>structural editing</a>, but it has a hidden superpower when combined with a REPL: you can evaluate code inside the comment block, safe in the knowledge that it won't be evaluated when the file is loaded in a real program (or when you press <code>C-c C-k</code> to re-evaluate the entire file).</p><p>So now I'm ready to rapidly iterate. Let me just remember what I'm trying to do again... oh yeah, move the metadata in the <code>posts.edn</code> file to the file for each post.</p><p>I can start by loading <code>posts.edn</code> and seeing what it looks like. I have a function called <code>load-posts</code> that loads the <code>posts.edn</code> file, so let's call it and see what it returns. What I can do is write some code inside my comment block:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment
  &#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
       first&#41;

  &#41;
</code></pre><p>and then put my cursor at the end of the line ending with <code>first&#41;</code> and press `C-c C-v f c e`, which runs the Emacs function <code>cider-pprint-eval-last-sexp-to-comment</code>, which does this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;comment
  &#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
       first&#41;
  ;; =&gt; {:title &quot;Some stuff I learned today&quot;,
  ;;     :file &quot;2022-07-12-stuff-i-learned.md&quot;,
  ;;     :tags #{&quot;waffle&quot;},
  ;;     :date &quot;2022-07-12&quot;}

  &#41;
</code></pre><p>What I've done here is evaluated code in a file in my editor and had the result written right back to the file. No need to change windows, no need to copy and paste, no need to move my eyes or engage my brain; it's all just muscle memory!</p><p>OK, so now I know what a post metadata entry looks like. In order to write that metadata to the top of a file, I'm going to need to read in the file. Let me try that out in my comment block and evaluate it:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
     &#40;map &#40;fn &#91;{:keys &#91;file&#93; :as post}&#93;
            &#40;let &#91;contents &#40;-&gt;&gt; file
                                &#40;fs/file &quot;blog&quot; &quot;posts&quot;&#41;
                                slurp&#41;&#93;
              &#40;assoc post :contents contents&#41;&#41;&#41;&#41;
     first&#41;
;; =&gt; {:title &quot;Some stuff I learned today&quot;,
;;     :file &quot;2022-07-12-stuff-i-learned.md&quot;,
;;     :tags #{&quot;waffle&quot;},
;;     :date &quot;2022-07-12&quot;,
;;     :contents
;;     &quot;Title: Some stuff I learned today\nTags: waffle\nDate: 2022-07-12\n\nToday...&quot;}
</code></pre><p>Cool, that worked! Now I need to figure out which metadata I want to write to the top of the file. From my example above, I want the title, the date, and the tags, so let me grab them and put them into the post data structure under a <code>:metadata</code> key:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt; &#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
         &#40;map &#40;fn &#91;{:keys &#91;file&#93; :as post}&#93;
                &#40;let &#91;contents &#40;-&gt;&gt; file
                                    &#40;fs/file &quot;blog&quot; &quot;posts&quot;&#41;
                                    slurp&#41;
                      metadata &#40;dissoc post :file&#41;&#93;
                  &#40;assoc post
                         :contents contents
                         :metadata metadata&#41;&#41;&#41;&#41;
         first
         :metadata&#41;&#41;
;; =&gt; {:title &quot;Some stuff I learned today&quot;, :tags #{&quot;waffle&quot;}, :date &quot;2022-07-12&quot;}
</code></pre><p>Looking good! Now, according to the MultiMarkdown spec, metadata keys should look like <code>Title: Some title here</code> instead of <code>:title &quot;Some title here&quot;</code>. I'll try transforming the metadata to this format:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt; &#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
         &#40;map &#40;fn &#91;{:keys &#91;file&#93; :as post}&#93;
                &#40;let &#91;contents &#40;-&gt;&gt; file
                                    &#40;fs/file &quot;blog&quot; &quot;posts&quot;&#41;
                                    slurp&#41;
                      metadata &#40;dissoc post :file&#41;&#93;
                  &#40;assoc post
                         :contents contents
                         :metadata metadata&#41;&#41;&#41;&#41;
         first
         :metadata
         &#40;map &#40;fn &#91;&#91;k v&#93;&#93; &#40;format &quot;%s: %s&quot; &#40;str/capitalize &#40;name k&#41;&#41; v&#41;&#41;&#41;&#41;&#41;
;; =&gt; &#40;&quot;Title: Some stuff I learned today&quot; &quot;Tags: #{\&quot;waffle\&quot;}&quot; &quot;Date: 2022-07-12&quot;&#41;
</code></pre><p>Looks pretty good except for that <code>&quot;Tags: #{\&quot;waffle\&quot;}&quot;</code> bit, which is the result of Clojure stringifying the set of tags that were in <code>posts.edn</code>. I decide that if I encounter a metadata value that is a list or set like <code>#{&quot;thing1&quot; &quot;thing2&quot; &quot;thing3&quot;}</code>, I'll transform it into a comma-delimited string like <code>&quot;thing1,thing2,thing3&quot;</code>. Of course, I can never remember which function in Clojure to use for seeing if a thing is a list or a set, so I'll try a few things in the REPL until I find the right one:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;sequential? #{}&#41;
;; =&gt; false

&#40;coll? #{}&#41;
;; =&gt; true
</code></pre><p>Oh right, it's <code>coll?</code> that I'm after. Armed with this knowledge, I can try my metadata transformation once more:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt; &#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
         &#40;map &#40;fn &#91;{:keys &#91;file&#93; :as post}&#93;
                &#40;let &#91;contents &#40;-&gt;&gt; file
                                    &#40;fs/file &quot;blog&quot; &quot;posts&quot;&#41;
                                    slurp&#41;
                      metadata &#40;dissoc post :file&#41;&#93;
                  &#40;assoc post
                         :contents contents
                         :metadata metadata&#41;&#41;&#41;&#41;
         first
         :metadata
         &#40;map &#40;fn &#91;&#91;k v&#93;&#93;
                &#40;let &#91;v &#40;if &#40;coll? v&#41; &#40;str/join &quot;,&quot; v&#41; v&#41;&#93;
                  &#40;format &quot;%s: %s&quot; &#40;str/capitalize &#40;name k&#41;&#41; v&#41;&#41;&#41;&#41;&#41;&#41;
;; =&gt; &#40;&quot;Title: Some stuff I learned today&quot; &quot;Tags: waffle&quot; &quot;Date: 2022-07-12&quot;&#41;
</code></pre><p>Looks better than before, but I'd really like to see what it does to a post with more than one tag. Let me see if I have any such posts:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
     &#40;map #&#40;count &#40;:tags %&#41;&#41;&#41;&#41;
;; =&gt; &#40;1 3 2 2 3 2 3 3 6 5 1 1 2 1 1 1 5 1 2 2 1 3 3 2 1 4 2 1&#41;
</code></pre><p>Sure do! Now I'll grab the first such post:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
     &#40;some #&#40;and &#40;&gt; &#40;count &#40;:tags %&#41;&#41; 1&#41; %&#41;&#41;&#41;
;; =&gt; {:title &quot;Hacking the blog: caching&quot;,
;;     :file &quot;2022-07-11-hacking-blog-caching.md&quot;,
;;     :tags #{&quot;clojure&quot; &quot;blog&quot; &quot;babashka&quot;},
;;     :date &quot;2022-07-11&quot;}
</code></pre><p>This <code>&#40;some #&#40;and &#40;&gt; &#40;count &#40;:tags %&#41;&#41; 1&#41; %&#41;&#41;</code> is a trick to get back the first item in a collection that matches a predicate. The <a href='https://clojuredocs.org/clojure.core/some'><code>some</code>
function</a> is somewhat odd in that it:</p><blockquote><p> Returns the first logical true value of (pred x) for any x in coll, else nil. </p></blockquote><p>If I just use the predicate, I get this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
     &#40;some #&#40;&gt; &#40;count &#40;:tags %&#41;&#41; 1&#41;&#41;&#41;
;; =&gt; true
</code></pre><p>This is not very helpful, since it just tells me what I already know, that I have a post with more than one tag. Using <code>and</code> gives me a way to return the thing that I found, since I know that the thing I found (a post, in this case) is logically true in Clojure since it is not <code>nil</code> or <code>false</code>.</p><p>OK, now that I have a post with more than one tag, let me try my transformation logic on it and make sure it works:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
     &#40;map &#40;fn &#91;{:keys &#91;file&#93; :as post}&#93;
            &#40;let &#91;contents &#40;-&gt;&gt; file
                                &#40;fs/file &quot;blog&quot; &quot;posts&quot;&#41;
                                slurp&#41;
                  metadata &#40;dissoc post :file&#41;&#93;
              &#40;assoc post
                     :contents contents
                     :metadata metadata&#41;&#41;&#41;&#41;
     &#40;some #&#40;and &#40;&gt; &#40;count &#40;:tags %&#41;&#41; 1&#41; %&#41;&#41;
     :metadata
     &#40;map &#40;fn &#91;&#91;k v&#93;&#93;
            &#40;let &#91;v &#40;if &#40;coll? v&#41; &#40;str/join &quot;,&quot; v&#41; v&#41;&#93;
              &#40;format &quot;%s: %s&quot; &#40;str/capitalize &#40;name k&#41;&#41; v&#41;&#41;&#41;&#41;&#41;
;; =&gt; &#40;&quot;Title: Hacking the blog: caching&quot;
;;     &quot;Tags: clojure,blog,babashka&quot;
;;     &quot;Date: 2022-07-11&quot;&#41;
</code></pre><p>Nice stuff! The next wrinkle is that some of my posts already contain metadata (because I started adding it to my last couple of posts in preparation for this switch), so I shouldn't overwrite it if it's already there. Let's see how I can detect such posts:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
     &#40;map &#40;fn &#91;{:keys &#91;file&#93; :as post}&#93;
            &#40;let &#91;contents &#40;-&gt;&gt; file
                                &#40;fs/file &quot;blog&quot; &quot;posts&quot;&#41;
                                slurp&#41;
                  metadata &#40;dissoc post :file&#41;&#93;
              &#40;assoc post
                     :contents contents
                     :metadata metadata&#41;&#41;&#41;&#41;
     &#40;some #&#40;and &#40;not &#40;re-find #&quot;&#94;&#91;A-z&#93;+: &quot; &#40;:contents %&#41;&#41;&#41; %&#41;&#41;&#41;
;; =&gt; {:title &quot;Hacking the blog: caching&quot;,
;;     :file &quot;2022-07-11-hacking-blog-caching.md&quot;,
;;     :tags #{&quot;clojure&quot; &quot;blog&quot; &quot;babashka&quot;},
;;     :date &quot;2022-07-11&quot;,
;;     :contents
;;     &quot;Well, it had to come to this, didn't it? At some point in the life...&quot;,
;;     :metadata
;;     {:title &quot;Hacking the blog: caching&quot;,
;;      :tags #{&quot;clojure&quot; &quot;blog&quot; &quot;babashka&quot;},
;;      :date &quot;2022-07-11&quot;}}
</code></pre><p>OK, I know how to find posts that already have metadata, so now I'm ready to prepend metadata to the file content only if it's not already there. I'll go ahead and do it, and then pick a random post and have a look at it to make sure it looks good:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
     &#40;map &#40;fn &#91;{:keys &#91;file&#93; :as post}&#93;
            &#40;let &#91;contents &#40;-&gt;&gt; file
                                &#40;fs/file &quot;blog&quot; &quot;posts&quot;&#41;
                                slurp&#41;
                  metadata &#40;dissoc post :file&#41;
                  metadata-str
                  &#40;-&gt;&gt; metadata
                       &#40;map &#40;fn &#91;&#91;k v&#93;&#93;
                              &#40;let &#91;v &#40;if &#40;coll? v&#41; &#40;str/join &quot;,&quot; v&#41; v&#41;&#93;
                                &#40;format &quot;%s: %s&quot; &#40;str/capitalize &#40;name k&#41;&#41; v&#41;&#41;&#41;&#41;
                       &#40;str/join &quot;\n&quot;&#41;&#41;
                  contents &#40;if &#40;re-find #&quot;&#94;&#91;A-z&#93;+: &quot; contents&#41;
                             contents
                             &#40;format &quot;%s\n\n%s&quot; metadata-str contents&#41;&#41;&#93;
              &#40;assoc post
                     :contents contents
                     :metadata metadata&#41;&#41;&#41;&#41;
     shuffle
     first&#41;
;; =&gt; {:title &quot;Story of a mediocre fan&quot;,
;;     :file &quot;2022-06-16-story-of-a-mediocre-fan.md&quot;,
;;     :tags #{&quot;arsenal&quot; &quot;stories&quot;},
;;     :date &quot;2022-06-16&quot;,
;;     :contents
;;     &quot;Title: Story of a mediocre fan\nTags: arsenal,stories\nDate: 2022-06-16\n\nThe winter...&quot;,
;;     :metadata
;;     {:title &quot;Story of a mediocre fan&quot;,
;;      :tags #{&quot;arsenal&quot; &quot;stories&quot;},
;;      :date &quot;2022-06-16&quot;}}
</code></pre><p>Sure enough, <code>:contents</code> begins with my metadata! Out of a surfeit of caution, I'll make sure that no posts would remain that don't have metadata at the beginning of their content:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
     &#40;map &#40;fn &#91;{:keys &#91;file&#93; :as post}&#93;
            &#40;let &#91;contents &#40;-&gt;&gt; file
                                &#40;fs/file &quot;blog&quot; &quot;posts&quot;&#41;
                                slurp&#41;
                  metadata &#40;dissoc post :file&#41;
                  metadata-str
                  &#40;-&gt;&gt; metadata
                       &#40;map &#40;fn &#91;&#91;k v&#93;&#93;
                              &#40;let &#91;v &#40;if &#40;coll? v&#41; &#40;str/join &quot;,&quot; v&#41; v&#41;&#93;
                                &#40;format &quot;%s: %s&quot; &#40;str/capitalize &#40;name k&#41;&#41; v&#41;&#41;&#41;&#41;
                       &#40;str/join &quot;\n&quot;&#41;&#41;
                  contents &#40;if &#40;re-find #&quot;&#94;&#91;A-z&#93;+: &quot; contents&#41;
                             contents
                             &#40;format &quot;%s\n\n%s&quot; metadata-str contents&#41;&#41;&#93;
              &#40;assoc post
                     :contents contents
                     :metadata metadata&#41;&#41;&#41;&#41;
     &#40;some #&#40;and &#40;not &#40;re-find #&quot;&#94;&#91;A-z&#93;+: &quot; &#40;:contents %&#41;&#41;&#41; %&#41;&#41;&#41;
;; =&gt; nil
</code></pre><p>All the pieces are now in place. The only thing left to do is actually write the updated file contents back to the file:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
     &#40;map &#40;fn &#91;{:keys &#91;file&#93; :as post}&#93;
            &#40;let &#91;contents &#40;-&gt;&gt; file
                                &#40;fs/file &quot;blog&quot; &quot;posts&quot;&#41;
                                slurp&#41;
                  metadata &#40;dissoc post :file&#41;
                  metadata-str
                  &#40;-&gt;&gt; metadata
                       &#40;map &#40;fn &#91;&#91;k v&#93;&#93;
                              &#40;let &#91;v &#40;if &#40;coll? v&#41; &#40;str/join &quot;,&quot; v&#41; v&#41;&#93;
                                &#40;format &quot;%s: %s&quot; &#40;str/capitalize &#40;name k&#41;&#41; v&#41;&#41;&#41;&#41;
                       &#40;str/join &quot;\n&quot;&#41;&#41;
                  contents &#40;if &#40;re-find #&quot;&#94;&#91;A-z&#93;+: &quot; contents&#41;
                             contents
                             &#40;format &quot;%s\n\n%s&quot; metadata-str contents&#41;&#41;&#93;
              &#40;assoc post
                     :contents contents
                     :metadata metadata&#41;&#41;&#41;&#41;
     &#40;map &#40;fn &#91;{:keys &#91;file contents&#93;}&#93;
            &#40;spit &#40;fs/file &quot;blog&quot; &quot;posts&quot; file&#41; contents&#41;&#41;&#41;
     doall&#41;
;; =&gt; &#40;nil
;;     nil
;;     ...
;;     nil&#41;
</code></pre><p>OK, something happened, but it's a little hard to tell exactly what, given that the <a href='https://clojuredocs.org/clojure.core/spit'><code>spit</code></a> function returns <code>nil</code>. By the way, the reason that I added the <code>doall</code> to the end of my pipeline is that CIDER will abbreviate the result of evaluating the expression in order to avoid possibly writing millions of lines of text to your file if you're processing a lot of data, and <a href='https://clojuredocs.org/clojure.core/map'><code>map</code></a> is a lazy function, meaning that it will only execute the mapping function when the result needs to be used. In my case, it will be used when the REPL tries to print it out, but if CIDER truncates the result of my evaluation such that not all results will be printed, some of my files won't be processed. That's where <a href='https://clojuredocs.org/clojure.core/doall'><code>doall</code></a> comes in: it walks the lazy sequence returned by <code>map</code> and forces evaluation of each value in the sequence.</p><p>This is why you shouldn't have side effects in mapping functions in real production code. You should use something like <a href='https://clojuredocs.org/clojure.core/doseq'><code>doseq</code></a> instead, which exists specifically to execute expressions with side effects. But hey, I'm experimenting here, so I'll take the convenience of being able to shove <code>map</code> in a pipeline and have my effects on the side, thank you very much! If I wanted to be told what to do by my programming language, I'd be writing Haskell. 😜</p><p>OK, now that I've updated my files (in theory, anyway), let me check a few to make sure they actually start with metadata like they should:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;-&gt;&gt; &#40;load-posts &#40;fs/file &quot;blog&quot; &quot;posts.edn&quot;&#41;&#41;
     &#40;map &#40;fn &#91;{:keys &#91;file&#93; :as post}&#93;
            &#40;let &#91;contents &#40;-&gt;&gt; file
                                &#40;fs/file &quot;blog&quot; &quot;posts&quot;&#41;
                                slurp&#41;&#93;
              &#40;subs contents 0 80&#41;&#41;&#41;&#41;
     &#40;take 3&#41;&#41;
;; =&gt; &#40;&quot;Title: Some stuff I learned today\nTags: waffle\nDate: 2022-07-12\n\nToday was a hig&quot;
;;     &quot;Title: Hacking the blog: caching\nTags: clojure,blog,babashka\nDate: 2022-07-11\n\nW&quot;
;;     &quot;Title: Hands off women's football\nTags: football,euro2022\nDate: 2022-07-10\n\nThe &quot;&#41;
</code></pre><p>Yes they do, and now I can declare victory, thanks to my trusty REPL and my wonderful CIDER! 🏆</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-13-omg-what-have-i-done.html</id>
    <link href="https://jmglov.net/blog/2022-07-13-omg-what-have-i-done.html"/>
    <title>Oh my goodness now what have I done?</title>
    <updated>2022-07-13T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I had this great post for y'all today where I fixed the caching issue on my blog and then walked through how I transformed the files to a new format, all in the Clojure REPL.</p><p>Except.</p><p>The programmers amongst you will already be laughing knowingly as I report that in the process of fixing the caching bug, I created 42 new bugs, all of which I needed to fix before I could publish this post. I'm happy to report that I have now fixed 41 of those 42 bugs (that I know about), thus allowing you to read my enthralling commentary on what a bad programmer I am.</p><p>Of course, fixing all of those bugs ate up all of the time I had for actually writing the post about how I transformed those files, and I'm due to bike over to Simon's in 10 minutes for an evening of bike repair, drinking, and playing Guitar Hero (I've already stuffed the Xbox 360 and guitars and stuff in my backpack). So my epic REPLing will have to wait until tomorrow, but I'm sure it will be so epic that you'll forget all about the fact that I ☎'d it 📥 today.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-12-stuff-i-learned.html</id>
    <link href="https://jmglov.net/blog/2022-07-12-stuff-i-learned.html"/>
    <title>Some stuff I learned today</title>
    <updated>2022-07-12T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Today was a highly educational day in which I learned many things. Here is a non-exhaustive list in roughly chronological order:</p><ol><li>Getting woken up by the sun pouring through your window is sometimes a good   thing, like when you have a 09:00 meeting that you've forgotten to set an   alarm for.</li><li>Having a dog that you have to walk twice a day is a wonderful thing in the   summer in Stockholm. It keeps you from spending all day in front of the   computer. This morning's walk was especially sunny and nice, warm but not   hot. There's a nice field near my house where I can take Rover off the leash   if no one is around, and no one was around this morning. A creek runs through   the middle of the field, and they just mowed along the banks last week, which   apparently uncovered all sorts of interesting smells that Rover needed to   spend a good long time investigating. I was strolling along, waiting for him   to finish sniffing one spot before moving to the next, listening to a   podcast, and appreciating the moment whilst I was in it. Life isn't always   great, but sometimes it is, even if only for a few minutes.</li><li>There was of course a bug in <a href='2022-07-11-hacking-blog-caching.html'>my caching
   code</a>, which occurred to me at some   point along my walk. Several solutions also occurred to me, but they were all   making the solution more complex and not simpler. I sat down at my computer   when I got back from the walk, fired up my REPL, and tried to trigger the bug   I though of. Indeed my darkest fears were realised as I added a new tag to an   existing post and watched it not re-render. I had another idea that was   simpler than my existing code, so I started messing around in the REPL until   I was able to pull the problem apart a bit and see a way through. I'm not   finished implementing the new idea yet, but when I am, I'll be sure to blog   about it.</li><li>I'm not going to make much progress on my reading (trying to finish Asimov's   "<a href='https://www.goodreads.com/book/show/29580.Second_Foundation'>Second
   Foundation</a>") if   I don't stop playing Civilization V! I always forget just how long that game   takes to play. 😅</li><li>I'm really terrible at bowling. Like really really terrible. Which would be   fine except I occasionally do something not terrible, which puts this   unreasonable idea in my head that I should not be terrible, thus making my   inevitable return to terribility (it's a word now!) a crushing   disappointment.</li><li>England may have completely ruined the <a href='https://www.uefa.com/womenseuro/'>Women's Euro
   2022</a> for me. I watched Spain play Germany   tonight, and what should have been an enjoyable watch as two good but very   different teams went head to head was rendered unwatchable because there   weren't eight goals and both teams made actual mistakes. To be fair, as an   Arsenal fan, there was something too familiar about the way Spain dominated   possession then just passed it around the penalty box without offering any   real threat. Arsène Wenger called it "sterile domination", and oh my goodness   have I had enough of it for one lifetime.</li><li>I really need to start writing my blog in the morning, when I am not super   tired.</li></ol>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-11-hacking-blog-caching.html</id>
    <link href="https://jmglov.net/blog/2022-07-11-hacking-blog-caching.html"/>
    <title>Hacking the blog: caching</title>
    <updated>2022-07-11T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Well, it had to come to this, didn't it? At some point in the life of every programmer, you have to take a deep sigh, realise that you have a problem where caching is the least bad solution, and get to it. That point in my life was today, when I finally got tired of waiting 30 seconds for my blog to publish, and knew that the reason it was taking so long is that my rendering process was repeating work that had already been done every time I ran it, and that resulted in rewriting files that made my publishing process think they had changes, and upload them to S3. The horror!</p><p>Let me quickly sketch out how publishing my blog works. I use <a href='https://docs.aws.amazon.com/AmazonS3/latest/userguide/WebsiteHosting.html'>S3 static website
hosting</a>, which means that publishing my website is nothing more than uploading files to my <code>jmglov.net</code> S3 bucket. I'm using <a href='https://github.com/babashka/babashka'>Babashka</a> to manage all this, so I have a task like this in my <a href='https://github.com/jmglov/jmglov.net/blob/main/bb.edn'><code>bb.edn</code></a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:tasks
 {publish-blog {:doc &quot;Publish blog&quot;
                :depends &#91;render-blog&#93;
                :task &#40;shell &quot;aws s3 sync --delete public/blog/ s3://jmglov.net/blog/&quot;&#41;}}}
</code></pre><p>The <code>aws sync</code> uses some <code>rsync</code>-like logic to only upload files that have changed. This is good for me, as I have a bunch of images, and I don't want to upload them over and over again, as it takes time, and eventually AWS will charge me for the bandwidth.</p><p>Let's take a look at <a href='https://github.com/jmglov/jmglov.net/blob/main/render_blog.clj'>how the blog is
rendered</a>:</p><ol><li>Copy all of the images from <code>assets/</code> to <code>public/blog/assets/</code></li><li>Copy the <code>style.css</code> file to <code>public/blog/</code></li><li>Read in all of the posts from <code>posts.edn</code></li><li>For each post, read the markdown source file, render it to HTML, insert it as   the body into the <a href='https://github.com/jmglov/jmglov.net/blob/main/blog/templates/base.html'>page
   template</a>   template with <a href='https://github.com/yogthos/Selmer'>Selmer</a>, and write the   resulting HTML file to <code>public/blog/</code></li><li>Create an <code>archive.html</code> page with links to all the posts</li><li>Create a <code>tags/index.html</code> page with links to all of the tags</li><li>For each tag, create a page with links to all the posts with that tag</li><li>Create a top-level <code>index.html</code> page with the last three posts</li><li>Create an <code>atom.xml</code> RSS feed with all of the posts</li><li>Create a <code>planetclojure.xml</code> RSS feed with posts tagged "clojure" or    "clojurescript"</li></ol><p>Each one of these steps is creating a file in <code>public/blog</code> that will be uploaded to S3 if the local file is newer than the file on S3. Without any caching, all of these files will be created every time I render the blog, which means they will always be uploaded, and this was what I was running into.</p><p>Here's how the asset files <a href='https://github.com/jmglov/jmglov.net/blob/a9f05b17f2459257e48c6807622df5fbc8951d5f/render_blog.clj#L32'>used to be
handled</a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns render-blog
  &#40;:require
   &#91;babashka.fs :as fs&#93;
   &#91;lib&#93;&#41;&#41;

&#40;def blog-dir &#40;fs/file &quot;blog&quot;&#41;&#41;
&#40;def out-dir &#40;fs/file &quot;public&quot; &quot;blog&quot;&#41;&#41;
&#40;def asset-dir &#40;fs/create-dirs &#40;fs/file out-dir &quot;assets&quot;&#41;&#41;&#41;

&#40;fs/copy-tree &#40;fs/file blog-dir &quot;assets&quot;&#41; asset-dir
              {:replace-existing true}&#41;
</code></pre><p><code>fs/copy-tree</code> is basically the same thing as <code>cp -r</code>: it copies all of the files from <code>blog/assets</code> to <code>public/blog/assets</code>. The problem is that changes the modification timestamp on the file, thus making <code>s3 sync</code> think it's a newer file and upload it. What I would like to do instead is only copy the new and modified asset files to <code>public/blog/assets</code>.</p><p>In order to do this, I wrote a new function, <a href='https://github.com/jmglov/jmglov.net/blob/fb1d1d28c9ef1289309cb539bd85e1ddb9400916/lib.clj#L23'><code>copy-tree-modified</code></a>, and used it like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;lib/copy-tree-modified &#40;fs/file blog-dir &quot;assets&quot;&#41;
                        asset-dir
                        &#40;.getParent out-dir&#41;&#41;
</code></pre><p>Here's what the function looks like:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn copy-tree-modified &#91;src-dir target-dir out-dir&#93;
  &#40;let &#91;modified-paths &#40;fs/modified-since &#40;fs/file target-dir&#41;
                                          &#40;fs/file src-dir&#41;&#41;
        new-paths &#40;-&gt;&gt; &#40;fs/glob src-dir &quot;&#42;&#42;&quot;&#41;
                       &#40;remove #&#40;fs/exists? &#40;fs/file out-dir %&#41;&#41;&#41;&#41;&#93;
    &#40;doseq &#91;path &#40;concat modified-paths new-paths&#41;
            :let &#91;target-path &#40;fs/file out-dir path&#41;&#93;&#93;
      &#40;fs/create-dirs &#40;.getParent target-path&#41;&#41;
      &#40;println &quot;Writing&quot; &#40;str target-path&#41;&#41;
      &#40;fs/copy &#40;fs/file path&#41; target-path&#41;&#41;&#41;&#41;
</code></pre><p>I'll walk you through what's going on here:</p><ol><li><a href='https://babashka.org/fs/codox/babashka.fs.html#var-modified-since'><code>fs/modified-since</code></a>   returns a list of the files in <code>src-dir</code> (which in the case of my assets, is   <code>blog/assets</code>) which have been modified since the time <code>target-dir</code>   (<code>public/blog/assets</code>) was last modifed.</li><li>Since this will not pick up files that have been added to <code>src-dir</code> after   <code>target-dir</code> was last modified, I do an   <a href='https://babashka.org/fs/codox/babashka.fs.html#var-glob'><code>fs/glob</code></a> to get a   list of all of the files in <code>src-dir</code>, then remove the ones that already   exist in <code>target-dir</code>.</li><li>I concatenate the modified files and the new files and then <code>doseq</code> over   them, creating subdirectories as needed, and then copy them into <code>out-dir</code>   (<code>public/blog</code>).</li></ol><p>This handles recursively copying directories, but how about single files? We can take a look at how the <code>style.css</code> used to be handled:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;let &#91;style-src &#40;fs/file templates-dir &quot;style.css&quot;&#41;
      style-target &#40;fs/file out-dir &quot;style.css&quot;&#41;&#93;
  &#40;fs/copy style-src style-target&#41;&#41;
</code></pre><p>Now it's subtly changed to use a new <code>copy-modified</code> library function:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;let &#91;style-src &#40;fs/file templates-dir &quot;style.css&quot;&#41;
      style-target &#40;fs/file out-dir &quot;style.css&quot;&#41;&#93;
  &#40;lib/copy-modified style-src style-target&#41;&#41;
</code></pre><p>The <a href='https://github.com/jmglov/jmglov.net/blob/fb1d1d28c9ef1289309cb539bd85e1ddb9400916/lib.clj#L14'><code>copy-modified</code></a> function looks like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn stale? &#91;src target&#93;
  &#40;seq &#40;fs/modified-since target src&#41;&#41;&#41;

&#40;defn copy-modified &#91;src target&#93;
  &#40;when &#40;stale? src target&#41;
    &#40;println &quot;Writing&quot; &#40;str target&#41;&#41;
    &#40;fs/create-dirs &#40;.getParent &#40;fs/file target&#41;&#41;&#41;
    &#40;fs/copy src target&#41;&#41;&#41;
</code></pre><p>We're using <code>fs/modified-since</code> in a slightly different way here. When both the <code>target</code> and the <code>src</code> are files, <code>fs/modified-since</code> will notice when <code>src</code> exists but <code>target</code> doesn't (meaning that <code>src</code> has been added since last time we rendered). Wrapping it in a <code>seq</code> will make it return <code>nil</code> when the list of modified files is empty, which we use as a truthy value.</p><p>The final piece of the puzzle is how to handle things like posts and archives and tags, which should only be written when there is a new post, an updated post, or something has changed with the rendering code or templates. I'll illustrate this by showing how the archive page is handled:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def posts-file &quot;posts.edn&quot;&#41;
&#40;def rendering-system-files &#91;&quot;render&#95;blog.clj&quot; templates-dir&#93;&#41;

&#40;let &#91;archive-file &#40;fs/file out-dir &quot;archive.html&quot;&#41;
      new-posts? &#40;lib/stale? posts-file archive-file&#41;
      rendering-modified? &#40;lib/rendering-modified? rendering-system-files
                                                   archive-file&#41;&#93;
  &#40;when &#40;or rendering-modified? new-posts?&#41;
    &#40;println &quot;Writing archive page&quot; &#40;str archive-file&#41;&#41;
    &#40;spit archive-file
          &#40;selmer/render base-html
                         {:skip-archive true
                          :title &#40;str blog-title &quot; - Archive&quot;&#41;
                          :body &#40;hiccup/html &#40;lib/post-links {} &quot;Archive&quot; posts&#41;&#41;}&#41;&#41;&#41;&#41;
</code></pre><p>To determine if there are new posts, we check if the archive file is stale with respect to the posts file, meaning that the posts file has changed since we last wrote the archive file.</p><p>To determine if any of the rendering code has changed, we use <a href='https://github.com/jmglov/jmglov.net/blob/fb1d1d28c9ef1289309cb539bd85e1ddb9400916/lib.clj#L11'><code>lib/rendering-modified?</code></a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn rendering-modified? &#91;rendering-system-files target-file&#93;
  &#40;seq &#40;fs/modified-since target-file rendering-system-files&#41;&#41;&#41;
</code></pre><p>What we're asking here is if <code>render&#95;blog.clj</code> or any of the template files have changed since we last wrote the archive file. If so, we want to re-render the archive file.</p><p>If you're interested in seeing this in action, take a look at <a href='https://github.com/jmglov/jmglov.net/blob/main/render_blog.clj'><code>render&#95;blog.clj</code></a> and <a href='https://github.com/jmglov/jmglov.net/blob/main/lib.clj'><code>lib.clj</code></a>. Just note that things are not very polished, and there are likely to be bugs. 😬</p><p><strong>Update:</strong> shortly after writing this code (<a href='2022-07-12-stuff-i-learned.md'>the next
morning</a> on my dog walk, in fact), I realised that my caching was horribly broken. To see how I fixed it, check out <a href='2022-07-15-hacking-blog-actually-caching.html'>Hacking
the blog: actually caching</a>.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-10-hands-off-womens-football.html</id>
    <link href="https://jmglov.net/blog/2022-07-10-hands-off-womens-football.html"/>
    <title>Hands off women's football</title>
    <updated>2022-07-10T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>The <a href='https://www.uefa.com/womenseuro/'>Women's Euro 2022</a> has served up some great action so far. On opening night, an incredibly organised and hard-working Austria held the mighty England to a single goal. We've seen Norway, Spain, and Germany each score four goals in their first match, with Spain playing some of the most technical football we've seen at the tournament so far, but also pressing incredibly effectively and making some really committed tackles. Yesterday saw the most evenly matched contests so far, with Switzerland going 2-0 up against Portugal in the first four minutes of the match, then Portugal marching back in the second half to finish 2-2, and Sweden drawing the Netherlands 1-1 in a match with lots of chances and lots of really good defending.</p><p>The fans have been incredible! The England-Austria opener was held at Old Trafford, and all 68,000 seats were filled, setting a new attendance record for the Women's Euros (the all time attendance record for women's football was set earlier this year, when 91,648 fans filled the Camp Nou as Barcelona beat Wolfsburg 5-1 in the Women's Champions League semi-final). The Netherlands-Sweden match last night was held in a much smaller stadium, but the stadium was packed with Dutch fans in orange and Swedish fans in yellow, and the atmosphere was fantastic. Twitter has been full of fans excitedly discussing the matches and supporting their teams on the <a href='https://twitter.com/hashtag/EURO2022'>#EURO2022</a> and <a href='https://twitter.com/hashtag/WEURO2022'>#WEURO2022</a> hashtags.</p><p><img src="assets/2022-07-10-devonshire-green-orange.png" alt="Twitter post showing Dutch fans gathering in Devonshire Green, wearing orange shirts" title="Orange is the new green" /></p><p>And of course, Twitter being Twitter, there are also the inevitable men that have to post about how ridiculous women's football is and how it's nowhere near as good as the men's game, and make sexist jokes and generally make an ass of themselves.</p><p><img src="assets/2022-07-10-sexist-joke.png" alt="Screenshot of tweet: Is kick off always delayed waiting for the ladies to get ready? Just another 5 mins!" title="Gross" /></p><p><img src="assets/2022-07-10-all-for-it.png" alt="Screenshot of tweet: I'm all for women's football. However, it's a bloody tough watch. I'm not even referring to the obvious fact the women's game is lacking in quality in comparison to the men's. The commentary & atmosphere just seems fake & forced. It's like a celebrity charity match." title="Don't worry, he's all for it" /></p><p><img src="assets/2022-07-10-reverse-sexism.png" alt="Screenshot of tweet: It's seems strange & unequal when we all celebrate a women officiating at a men's soccer match, yet not a single male refereeing at @uefa
 European championships in the UK. Where is the equality, it's a two way
 street." title="The plague reverse sexism" /></p><p>Hopefully it's obvious to everyone reading this that there is absolutely no need for this kind of bullshit. If you don't like women's football for whatever reason, you are very welcome to not watch it. But why do you want to go on the internet and shit on the people who are enjoying it? That's a rhetorical question, of course; I know the answer (hint hint: rhymes with "misogyny").</p><p>If you like women's football already, you're definitely already watching this tournament. If you like football but haven't seen any top-level women's football, this tournament is a great introduction to some of the world's best players, and has been delivering the goods so far. If you don't like women's football, there's plenty of other stuff on TV, so you can just watch that and spend zero time thinking about how unfair it is that women are playing football and people are enjoying watching.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-09-story-of-a-mediocre-fan-4.html</id>
    <link href="https://jmglov.net/blog/2022-07-09-story-of-a-mediocre-fan-4.html"/>
    <title>Story of a mediocre fan: chapter 4</title>
    <updated>2022-07-09T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Today's main story is that the final chapter of "<a href='https://7amkickoff.com/index.php/2022/07/09/story-of-a-mediocre-fan-chapter-4/'>Story of a mediocre
fan</a>" has been published over on 7amkickoff! Four weeks, 18,383 words, countless lessons about writing learned. I'm so grateful to Tim from 7amkickoff for giving me the opportunity to write something that lots of Arsenal fans will see! 💜</p><p>The other news of the day is that I finally got Civilization V working on my laptop! You can read the coda to <a href='2022-06-20-installing-steam-on-nixos.html'>Installing Steam on NixOS in 50 simple
steps</a> if you want the details, or you can just bask in this glorious screenshot with me:</p><p><img src="assets/2022-07-09-civ-v.png" alt="Screenshot of the Civ V opening screen, playing as Darius I of Persia" title="Civilise this!" width=800px /></p><p>Lovely!</p><p>I also had some <a href='https://en.wikipedia.org/wiki/Tripe_soup#Middle_East_and_Southeastern_Europe'>шкембе
чорба</a> for lunch, which is a Bulgarian tripe soup that probably comes from Turkey (where it's called işkembe çorbası, according to <a href='https://bg.wikipedia.org/wiki/%D0%A8%D0%BA%D0%B5%D0%BC%D0%B1%D0%B5_%D1%87%D0%BE%D1%80%D0%B1%D0%B0'>Bulgarian
Wikipedia</a>) via the Ottoman Empire. Spicy and delicious!</p><p>Tonight is the first big match of the Women's Euro 2022 for me tonight, as Sweden and the Netherlands play, pitting Arsenal striker <a href='https://en.wikipedia.org/wiki/Stina_Blackstenius'>Stina
Blackstenius</a> against Arsenal striker converted to number 10 <a href='https://en.wikipedia.org/wiki/Vivianne_Miedema'>Vivianne Miedema</a> and former Arsenal midfielder <a href='https://en.wikipedia.org/wiki/Dani%C3%ABlle_van_de_Donk'>Daniëlle van de
Donk</a>. Since each team has one current Arsenal player, by my rules, I should cheer for the Netherlands, since they also have a former Arsenal player, but rules be damned, I'm cheering for Sweden, because I live here and consider international sports the one acceptable place for nationalism.</p><p>Heja Sverige, and hej då allihop!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-08-quick-check-in.html</id>
    <link href="https://jmglov.net/blog/2022-07-08-quick-check-in.html"/>
    <title>A quick check-in</title>
    <updated>2022-07-08T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I started the day off with a nice game of tennis with my friend Joanna, then sat down at my desk and reworked the final chapter of "<a href='2022-06-16-story-of-a-mediocre-fan.html'>Story of a mediocre
fan</a>", which should go out tomorrow. I'm not entirely happy with my execution, but I learned a lot in writing it, and uncovered a lot of lovely memories that were buried somewhere in the drafty attic that is my brain.</p><p>I also watched the Spain-Finland match in the Women's Euro 2022, which was a really good game, thanks to massive underdogs Finland scoring in the second minute and then refusing to quit against a hugely talented Spain side. I'm watching the end of the first half of Germany-Denmark right now. I want to write a longer post about women's football, because as much as I'm enjoying this tournament, I'm not enjoying some of the nonsense on Twitter. For some reason, a certain sort of man has to either make stupid sexist jokes or talk about how his son's under 7 team could beat any of these teams, instead of just not watching the football if that's not his thing.</p><p>But that's a subject for another day, when I'm not tired and cranky. For now, I'm going to post this, then go find myself a beer and watch the second half.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-07-story-of-a-mediocre-writer.html</id>
    <link href="https://jmglov.net/blog/2022-07-07-story-of-a-mediocre-writer.html"/>
    <title>Story of a mediocre writer</title>
    <updated>2022-07-07T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>The final chapter of "<a href='2022-06-16-story-of-a-mediocre-fan.html'>Story of a mediocre
fan</a>" was supposed to go out today, but I ran into an age old issue discovered by Blaise Pascal in 1657, when he famously apologised in a letter that:</p><blockquote><p> I have made this longer than usual because I have not had time to make it shorter. </p></blockquote><p>I did some solid procrastination earlier this week, writing about a paragraph of the story on Monday, then about four more on Tuesday, and then got serious yesterday and wrote for a few hours, and then got really serious today and wrote for a few more hours and holy shit I have 5200 words and I don't have any time to make it shorter!</p><p>So I reached out to my friend Tim for advice, phrasing it something like "I can either make this simpler or I can keep going like this for another few thousand words and then split it somewhere into chapters 4 and 5 and halp!" This is one of those cases where I pretty much knew the answer when I asked the question. Simplicity is a virtue, and what I needed to do was find the essence of the story I was trying to tell, and then use only the words required to tell it.</p><p>Tim actually made a parallel between writing prose and writing software: feature creep. You know, the program has to do this one thing, but wouldn't it be cool if it also did this and that, and this module isn't as elegant as it could be, and let me just refactor this real quick, and all of a sudden you're left with a day or two before the thing must ship or else and now you've got to rush it into production and then you spend the next two years complaining about legacy and how you never have time to fix tech debt.</p><p>All because you could have made it simple but didn't.</p><p>So yeah, what I realised with Tim's help is that I'm trying to tell the story of how I became an Arsenal fan, and part one of that is how I fell in love with football / soccer, and why. A huge part of that story is people: playing Nintendo World Cup with Ryan, and playing actual football with those Mexican dudes in Harrisonburg and then Ayna and the Japanese guys at uni. So "why football?" has been answered. "Why Arsenal?" is the question I'm working on answering now. And the answer is again people. The <em>futsal</em> club in Japan that turned out to be an Arsenal supporters club, the people who wrote the great Arsenal blogs and recorded the great Arsenal podcasts, the fans I met when I finally had the chance to see Arsenal live, my son!</p><p>I know how to write that story. It just requires tossing out 4000 of the 5000 words I wrote and then writing about 2000 different ones. No sweat, right? 😅</p><p>What I'm learning here is that writing a long form piece requires structure and planning. I mean, I know that intellectually, since I wrote plenty of long form stuff back at William & Mary (I have a minor in English literature that I picked up somehow when I was trying to do a Computer Science degree). Structure and planning isn't always fun, and certainly is a great excuse to put off writing. When I set myself the challenge to write and publish something every day, I gave myself permission to just write without thinking, to remove the potential stumbling block of worrying if what I wrote would be any good. Sometimes it will, sometimes it won't, and that's OK, because this is just my silly blog and I'm writing it for the express purpose of getting better at writing.</p><p>However, "Story of a mediocre fan" is not supposed to be just written off the cuff. It's supposed to be decent enough to be read by the thousands of Arsenal fans who read 7amkickoff, and in order to make sure that's the case, I need to approach it differently.</p><p>So now I have a plan, and now I'm going to take the dog out for a walk so that my brain can start putting the structure in place so that I can wake up in the morning, lose to Joanna at tennis, and then come back to this desk and put my hands back on this keyboard and write the story I'm supposed to be writing.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-06-hacking-blog-categories.html</id>
    <link href="https://jmglov.net/blog/2022-07-06-hacking-blog-categories.html"/>
    <title>Hacking the blog: categories</title>
    <updated>2022-07-06T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Today's post is coming pretty late in the day due to me spending a few hours working on the final chapter of "<a href='2022-06-16-story-of-a-mediocre-fan.html'>Story of a mediocre
fan</a>" (not quite finished, but I'll wrap it up in the morning), playing some Civ V (I'm running the Mongol empire these days), actually doing the thing I'm about to write about, then watching the last two episodes of season 2 of <a href='https://www.imdb.com/title/tt8806524/'>Star Trek:
Picard</a>, and then watching the first half of the opening match of the <a href='https://www.uefa.com/womenseuro/'>Women's Euro 2022
tournament</a>. England (boo!) are leading Austria 1-0, but at least the goalscorer was Arsenal's own Beth Mead, so I can't be too mad. My approach to the tournament is to cheer for Sweden and then whatever team has the most Arsenal players (with ties being broken by which team has the most former Arsenal players). In this particular match, England have two current players (Leah Williamson and Beth Mead), and Austria do as well (Manu Zinsberger and Laura Wienroither), but then Austria also have a former Arsenal player (Viki Schnaderbeck), so I'm for them in this match.</p><p>But I'm not writing about football today (or at least, doing my best not to), I'm writing about the latest hacking I've done on the blog.</p><p>Though you can't currently tell this, each blog post has one or more categories associated with it. This post, for example, is categorised as "clojure" and "blog". These categories are currently only used to determine which posts should be included in the <a href='http://planet.clojure.in/'>Planet Clojure</a> feed, but I thought it would be cool to be able to browse all posts from a given category, so I hacked it together this afternoon.</p><p>This blog already has an <a href='archive.html'>archive page</a> which lists all of the posts, so my idea was to create a similar page for each category, called something like <a href='category/aws.html'><code>category/aws.html</code></a>.</p><p>The first step was to build a data structure which contains all of the posts for each category. The post metadata lives in <a href='https://github.com/jmglov/jmglov.net/blob/575a12cf2a87a4fd2a46dc131ed3a51f864ba57f/blog/posts.edn'><code>posts.edn</code></a>, which looks like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:title &quot;Blambda!&quot;
 :file &quot;2022-07-03-blambda.md&quot;
 :categories #{&quot;aws&quot; &quot;s3&quot; &quot;lambda&quot; &quot;clojure&quot;}
 :date &quot;2022-07-03&quot;}
{:title &quot;Dogfooding Blambda! : revenge of the pod people&quot;
 :file &quot;2022-07-04-dogfooding-blambda-1.md&quot;
 :categories #{&quot;aws&quot; &quot;s3&quot; &quot;lambda&quot; &quot;clojure&quot; &quot;blambda&quot;}
 :date &quot;2022-07-04&quot;}
{:title &quot;Hacking the blog: favicon&quot;
 :file &quot;2022-07-05-hacking-blog-favicon.md&quot;
 :categories #{&quot;clojure&quot; &quot;blog&quot;}
 :date &quot;2022-07-05&quot;}
{:title &quot;Hacking the blog: categories&quot;
 :file &quot;2022-07-06-hacking-blog-categories.md&quot;
 :categories #{&quot;clojure&quot; &quot;blog&quot;}
 :date &quot;2022-07-06&quot;}
</code></pre><p>The post metadata is loaded in <a href='https://github.com/jmglov/jmglov.net/blob/main/blog/render.clj'><code>render.clj</code></a> like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def posts &#40;sort-by :date &#40;comp - compare&#41;
                    &#40;edn/read-string &#40;format &quot;&#91;%s&#93;&quot;
                                             &#40;slurp &quot;posts.edn&quot;&#41;&#41;&#41;&#41;&#41;
</code></pre><p>This gives me a list of posts, with each post having one or more categories. What I need for my category pages, however, is a map like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{&quot;aws&quot; &#91;{:title &quot;Blambda!&quot;
         :file &quot;2022-07-03-blambda.md&quot;
         :categories #{&quot;aws&quot; &quot;s3&quot; &quot;lambda&quot; &quot;clojure&quot;}
         :date &quot;2022-07-03&quot;}
        {:title &quot;Dogfooding Blambda! : revenge of the pod people&quot;
         :file &quot;2022-07-04-dogfooding-blambda-1.md&quot;
         :categories #{&quot;aws&quot; &quot;s3&quot; &quot;lambda&quot; &quot;clojure&quot; &quot;blambda&quot;}
         :date &quot;2022-07-04&quot;}&#93;
 &quot;clojure&quot; &#91;{:title &quot;Blambda!&quot;
             :file &quot;2022-07-03-blambda.md&quot;
             :categories #{&quot;aws&quot; &quot;s3&quot; &quot;lambda&quot; &quot;clojure&quot;}
             :date &quot;2022-07-03&quot;}
            {:title &quot;Dogfooding Blambda! : revenge of the pod people&quot;
             :file &quot;2022-07-04-dogfooding-blambda-1.md&quot;
             :categories #{&quot;aws&quot; &quot;s3&quot; &quot;lambda&quot; &quot;clojure&quot; &quot;blambda&quot;}
             :date &quot;2022-07-04&quot;}
            {:title &quot;Hacking the blog: favicon&quot;
             :file &quot;2022-07-05-hacking-blog-favicon.md&quot;
             :categories #{&quot;clojure&quot; &quot;blog&quot;}
             :date &quot;2022-07-05&quot;}
            {:title &quot;Hacking the blog: categories&quot;
             :file &quot;2022-07-06-hacking-blog-categories.md&quot;
             :categories #{&quot;clojure&quot; &quot;blog&quot;}
             :date &quot;2022-07-06&quot;}&#93;}
</code></pre><p>Here's how we can achieve that:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def posts-by-category
  &#40;-&gt;&gt; posts
       &#40;sort-by :date&#41;
       &#40;mapcat &#40;fn &#91;{:keys &#91;categories&#93; :as post}&#93;
                 &#40;map &#40;fn &#91;category&#93; &#91;category post&#93;&#41; categories&#41;&#41;&#41;
       &#40;reduce &#40;fn &#91;acc &#91;category post&#93;&#93;
                 &#40;update acc category #&#40;conj % post&#41;&#41;&#41;
               {}&#41;&#41;&#41;
</code></pre><p>The <code>mapcat</code> step takes each post, which looks like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:title &quot;Hacking the blog: categories&quot;
 :file &quot;2022-07-06-hacking-blog-categories.md&quot;
 :categories #{&quot;clojure&quot; &quot;blog&quot;}
 :date &quot;2022-07-06&quot;}
</code></pre><p>and maps over the <code>:categories</code> list (that <code>{:keys &#91;categories&#93;}</code> bit is <a href='https://clojure.org/guides/destructuring#_associative_destructuring'>key
destructuring</a>, if you haven't seen it before), turning each category into a tuple of <code>&#91;category
post&#93;</code>. For this specific post, this would yield:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#91;&#91;&quot;clojure&quot; {:title &quot;Hacking the blog: categories&quot;
             :file &quot;2022-07-06-hacking-blog-categories.md&quot;
             :categories #{&quot;clojure&quot; &quot;blog&quot;}
             :date &quot;2022-07-06&quot;}&#93;
 &#91;&quot;blog&quot; {:title &quot;Hacking the blog: categories&quot;
          :file &quot;2022-07-06-hacking-blog-categories.md&quot;
          :categories #{&quot;clojure&quot; &quot;blog&quot;}
          :date &quot;2022-07-06&quot;}&#93;&#93;
</code></pre><p>Each post is turned inside out like this, yielding a list of lists of tuples, or at least before the "cat" part of <code>mapcat</code> goes to work. The difference between <code>map</code> and <code>mapcat</code> is that <code>mapcat</code> flattens the resulting list (according to <a href='https://clojuredocs.org/clojure.core/mapcat'>the docs</a>, it "returns the result of applying concat to the result of applying <code>map</code> to <code>f</code> and <code>colls</code>", but I like my explanation better), so instead of a list of lists of tuples, I get a list of tuples.</p><p>I reduce that list with this function, initialising <code>acc</code> to an empty map:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;fn &#91;acc &#91;category post&#93;&#93;
  &#40;update acc category #&#40;conj % post&#41;&#41;&#41;
</code></pre><p>For each entry in the list, the key in <code>acc</code> corresponding to the category is updated by adding the current post to the end of the list of posts with that category.</p><p>Now that I have the data structure I need, let's see how the archive page is currently built:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">;;;; Generate archive page

&#40;defn post-links &#91;&#93;
  &#91;:div {:style &quot;width: 600px;&quot;}
   &#91;:h1 &quot;Archive&quot;&#93;
   &#91;:ul.index
    &#40;for &#91;{:keys &#91;file title date preview&#93;} posts
          :when &#40;not preview&#41;&#93;
      &#91;:li &#91;:span
            &#91;:a {:href &#40;str/replace file &quot;.md&quot; &quot;.html&quot;&#41;}
             title&#93;
            &quot; - &quot;
            date&#93;&#93;&#41;&#93;&#93;&#41;

&#40;spit &#40;fs/file out-dir &quot;archive.html&quot;&#41;
      &#40;selmer/render base-html
                     {:skip-archive true
                      :title &#40;str blog-title &quot; - Archive&quot;&#41;
                      :body &#40;hiccup/html &#40;post-links&#41;&#41;}&#41;&#41;
</code></pre><p>I can do something very similar to build my category pages:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn category-links &#91;category posts&#93;
  &#91;:div {:style &quot;width: 600px;&quot;}
   &#91;:h1 &#40;str &quot;Category - &quot; category&#41;&#93;
   &#91;:ul.index
    &#40;for &#91;{:keys &#91;file title date preview&#93;} posts
          :when &#40;not preview&#41;&#93;
      &#91;:li &#91;:span
            &#91;:a {:href &#40;str &quot;../&quot; &#40;str/replace file &quot;.md&quot; &quot;.html&quot;&#41;&#41;}
             title&#93;
            &quot; - &quot;
            date&#93;&#93;&#41;&#93;&#93;&#41;

&#40;def categories-dir &#40;fs/create-dirs &#40;fs/file out-dir &quot;category&quot;&#41;&#41;&#41;

&#40;doseq &#91;&#91;category posts&#93; posts-by-category
        :let &#91;category-slug &#40;str/replace category #&quot;&#91;&#94;A-z0-9&#93;&quot; &quot;-&quot;&#41;&#93;&#93;
  &#40;spit &#40;fs/file categories-dir &#40;str category-slug &quot;.html&quot;&#41;&#41;
        &#40;selmer/render base-html
                       {:skip-archive true
                        :title &#40;str blog-title &quot; - Category - &quot; category&#41;
                        :relative-path &quot;../&quot;
                        :body &#40;hiccup/html &#40;category-links category posts&#41;&#41;}&#41;&#41;&#41;
</code></pre><p>Since I decided to put my category pages under the <code>category/</code> path, I need to adjust all of the links to go up one level. This required adding the <code>:relative-path &quot;../&quot;</code> to the list of variables passed to <a href='https://github.com/yogthos/Selmer'>Selmer</a> when rendering <a href='https://github.com/jmglov/jmglov.net/blob/main/blog/templates/base.html'><code>templates/base.html</code></a> and updating the template with stuff like:</p><pre class="language-html"><code class="lang-html language-html">&lt;link rel=&quot;stylesheet&quot; href=&quot;{{relative-path | safe}}style.css&quot;&gt;
</code></pre><p>Once I did that, all that was left to do was <code>bb publish</code>, and now you can enjoy all of the posts about this blog here: https://jmglov.net/blog/category/blog.html</p><p>You can see all of the changes required to implement this here: https://github.com/jmglov/jmglov.net/commit/6ea911d2b4c0418d01e74e4aceb2686a3f1b86a3</p><p>This is obviously a work in progress feature. It would be nice to do the following:</p><ul><li>Display the categories each post is tagged with</li><li>Rename "category" to "tag", since that more properly describes the concept</li><li>Link the tags on the post to each tag's archive page</li><li>Add a page somewhere listing all of the tags and linking to the archive pages  for each</li></ul><p>We'll see what I get around to that stuff. 😉</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-05-hacking-blog-favicon.html</id>
    <link href="https://jmglov.net/blog/2022-07-05-hacking-blog-favicon.html"/>
    <title>Hacking the blog: favicon</title>
    <updated>2022-07-05T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>The fun thing about having a blog which is built with a <a href='https://github.com/jmglov/jmglov.net'>static site
generator</a> is that you get to <del>waste</del> spend time customising it. In today's instalment of "Hacking the blog", we'll see how to add a "<a href='https://en.wikipedia.org/wiki/Favicon'>favicon</a>", which is that little icon thingy on your tab title.</p><p>My first order of business was to figure out what I wanted to use for my favicon. I decided that I really love this drawing that my friend Sebastian did on a whiteboard way back in 2016, so why not use it?</p><p><img src="assets/josh-right-on-transparent.png" alt="A cartoon drawing of me wearing a t-shirt that says 'I love Virginia'" title="Virginia is for hustlers" /></p><h2 id="building_the_favicon">Building the favicon</h2><p>The first step (after I remembered what a "favicon" was actually called) was making a clean version of this image that would scale down nicely to the various sizes used by browsers. Courtesy of <a href='https://stackoverflow.com/a/19590415/58994'>a really thorough
answer</a> to a question on Stack Overflow, I found a really cool site called <a href='https://realfavicongenerator.net/'>RealFaviconGenerator</a>, which would take an image (recommended to be at least 260x206 pixels) and spit out a zipfile containing a bunch of files:</p><ul><li>android-chrome-192x192.png</li><li>android-chrome-256x256.png</li><li>apple-touch-icon.png</li><li>browserconfig.xml</li><li>favicon-16x16.png</li><li>favicon-32x32.png</li><li>favicon.ico</li><li>mstile-150x150.png</li><li>safari-pinned-tab.svg</li><li>site.webmanifest</li></ul><p>These files, if placed at the root of your website and combined with a chunk of HTML in your <code>&lt;head&gt;</code> section, would do the right thing for All the Browsers and All the Smartphones.</p><p>So I opened up my image in <a href='https://gimp.org/'>the GIMP</a>, cropped it so only my head was visible, and removed the speech bubble, resulting in the following:</p><p><img src="assets/josh-right-on-260x260.png" alt="A cartoon drawing of my head" title="Virginia is for favicons" /></p><p>I fed this image into RealFaviconGenerator, which gave me back the zipfile described above and the following HTML fragment:</p><pre class="language-html"><code class="lang-html language-html">&lt;link rel=&quot;apple-touch-icon&quot; sizes=&quot;180x180&quot; href=&quot;/apple-touch-icon.png&quot;&gt;
&lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;32x32&quot; href=&quot;/favicon-32x32.png&quot;&gt;
&lt;link rel=&quot;icon&quot; type=&quot;image/png&quot; sizes=&quot;16x16&quot; href=&quot;/favicon-16x16.png&quot;&gt;
&lt;link rel=&quot;manifest&quot; href=&quot;/site.webmanifest&quot;&gt;
&lt;link rel=&quot;mask-icon&quot; href=&quot;/safari-pinned-tab.svg&quot; color=&quot;#5bbad5&quot;&gt;
&lt;meta name=&quot;msapplication-TileColor&quot; content=&quot;#da532c&quot;&gt;
&lt;meta name=&quot;theme-color&quot; content=&quot;#ffffff&quot;&gt;
</code></pre><h2 id="hacking_the_blog">Hacking the blog</h2><p>The next order of business was to get the favicon onto my site. If you remember from the <a href='2022-06-19-actually-blogging-with-clojure.html'>Actually blogging with
Clojure</a> post, the way the blog works is this:</p><ol><li>There's a <a href='https://github.com/babashka/babashka'>Babashka</a>   <a href='https://github.com/jmglov/jmglov.net/blob/main/blog/bb.edn'><code>bb.edn</code></a> which   defines a <code>render</code> task that looks like this:<pre class="language-clojure"><code class="lang-clojure language-clojure">render {:doc &quot;Render blog&quot;
        :task &#40;load-file &quot;render.clj&quot;&#41;}
   </code></pre></li><li><a href='https://github.com/jmglov/jmglov.net/blob/main/blog/render.clj'><code>render.clj</code></a>   does stuff like converting Markdown to HTML, then uses the   <a href='https://github.com/yogthos/Selmer'>Selmer</a> templating system to shove blog   posts into the   <a href='https://github.com/jmglov/jmglov.net/blob/main/blog/templates/base.html'><code>templates/base.html</code></a>   template</li><li><code>render.clj</code> then copies the resulting HTML files to a <code>public/</code> directory</li><li>There's another task called <code>publish</code> in <code>bb.edn</code> that uses the AWS CLI to   sync everything in the <code>public/</code> to the S3 bucket that contains my blog:<pre class="language-clojure"><code class="lang-clojure language-clojure">publish {:doc &quot;Publish to jmglov.net&quot;
         :depends &#91;render&#93;
         :task &#40;shell &quot;aws s3 sync --delete public/ s3://jmglov.net/blog/&quot;&#41;}
   </code></pre></li></ol><p>So getting the favicon injected into every page was as simple as blasting the HTML fragment into <code>templates/base.html</code>.</p><p>Almost.</p><p>You see, I also need to put the content being referenced in all of those <code>&lt;link&gt;</code> tags up on the website. My website itself uses the exact same machinery as the blog, meaning I need to add the HTML fragment to the top-level <a href='https://github.com/jmglov/jmglov.net/blob/main/templates/base.html'><code>templates/base.html</code></a>, and in order to get the favicon stuff onto the website, I need to hack up the top-level <a href='https://github.com/jmglov/jmglov.net/blob/main/render.clj'><code>render.clj</code></a>.</p><p>Looking at the way the existing <code>render.clj</code> (which I stole with pride from <a href='https://github.com/borkdude/blog'>borkdude’s blog</a>) handles images and CSS is most instructive:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;def out-dir &quot;public&quot;&#41;

;;;; Sync images and CSS

&#40;def asset-dir &#40;fs/create-dirs &#40;fs/file out-dir &quot;assets&quot;&#41;&#41;&#41;

&#40;fs/copy-tree &quot;assets&quot; asset-dir {:replace-existing true}&#41;

&#40;spit &#40;fs/file out-dir &quot;style.css&quot;&#41;
      &#40;slurp &quot;templates/style.css&quot;&#41;&#41;
</code></pre><p>Since the <code>deploy</code> task will sync everything from the <code>public/</code> directory to my website, I just need to put the contents of the favicon zipfile in <code>public/</code> and I win!</p><p>I unzipped the favicon zipfile into a top-level <a href='https://github.com/jmglov/jmglov.net/tree/main/favicon'><code>favicon/</code></a> directory, then added the following to <code>render.clj</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">;;;; Sync favicon

&#40;def favicon-dir &#40;fs/create-dirs &#40;fs/file out-dir&#41;&#41;&#41;

&#40;fs/copy-tree &quot;favicon&quot; favicon-dir {:replace-existing true}&#41;
</code></pre><p>And that's all it took to display my little cartoon face on your browser's tab! 🏆</p><p>If you're interested, you can check out <a href='https://github.com/jmglov/jmglov.net/commit/e9196e65568e5ba211c87f855e06d83a8fb20619'>this
commit</a> to see everything I did, all wrapped up into one cute little package.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-04-dogfooding-blambda-1.html</id>
    <link href="https://jmglov.net/blog/2022-07-04-dogfooding-blambda-1.html"/>
    <title>Dogfooding Blambda! : revenge of the pod people</title>
    <updated>2022-07-04T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Today I finally tried to use <a href='https://github.com/jmglov/blambda'>Blambda!</a> for something real: a log parser for my HTTP access logs that S3 and Cloudfront write to <a href='2022-06-24-s3-https.html'>my logs bucket</a>. You can follow along with my fun on Github: <a href='https://github.com/jmglov/s3-log-parser'>jmglov/s3-log-parser</a>.</p><p>Here's what I'm trying to do:</p><ol><li>Create a lambda function that downloads access logs from S3 for a certain date   range, parses them, and then returns some useful information</li><li>Save that useful information to a database</li><li>Write another function that provides some cool analytics on traffic going to   my blog and use a <a href='https://aws.amazon.com/blogs/aws/announcing-aws-lambda-function-urls-built-in-https-endpoints-for-single-function-microservices/'>Lambda Function
   URL</a>   to serve it up over HTTPS</li></ol><p>I'm sure this will change quite drastically as I go, but it seems like a fun problem that will find the rough edges with Blambda!</p><p>Here's how I proceeded.</p><h2 id="creating_a_lambda_handler">Creating a lambda handler</h2><p>In my project, I created a simple handler in <a href='https://github.com/jmglov/s3-log-parser/blob/main/src/s3_log_parser.clj'><code>src/s3&#95;log&#95;parser.clj</code></a>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns s3-log-parser&#41;

&#40;defn handler &#91;event context&#93;
  &#40;prn {:msg &quot;Invoked with event&quot;,
        :data {:event event}}&#41;
  {}&#41;
</code></pre><p>This will just log the event it was invoked with and then return an empty map (or JSON object, if you must).</p><p>Since this lambda will eventually interact with S3, I decided to bite the bullet and include the <a href='https://github.com/babashka/pod-babashka-aws'>babashka-aws</a> pod. I created a <a href='https://github.com/jmglov/s3-log-parser/blob/main/src/bb.edn'><code>src/bb.edn</code></a> like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">{:paths &#91;&quot;.&quot;&#93;
 :pods {org.babashka/aws {:version &quot;0.1.2&quot;}}}
</code></pre><p>This <code>bb.edn</code> will be picked up by <a href='https://github.com/babashka/babashka'>Babashka</a> when it is executing my lambda, since Blambda! runs <code>bb</code> from the directory where my lambda archive is unpacked (<code>$LAMBDA&#95;TASK&#95;ROOT</code>, for those of you familiar with building custom runtimes).</p><h2 id="packaging_my_lambda">Packaging my lambda</h2><p>I created a top-level <a href='https://github.com/jmglov/s3-log-parser/blob/main/bb.edn'><code>bb.edn</code></a>, which is used for defining Babashka tasks to build and deploy my function (not to be confused with <code>src/bb.edn</code>, which will be used at lambda runtime). The build task looks like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">build {:doc &quot;Builds lambda artifact&quot;
       :requires &#40;&#91;clojure.java.shell :refer &#91;sh&#93;&#93;&#41;
       :task &#40;let &#91;{:keys &#91;target-dir work-dir&#93;} &#40;th/parse-args&#41;
                   work-dir &#40;str work-dir &quot;/lambda&quot;&#41;
                   src-dir &quot;src&quot;
                   lambda-zipfile &#40;th/target-file target-dir &quot;function.zip&quot;&#41;&#93;
               &#40;doseq &#91;dir &#91;target-dir work-dir&#93;&#93;
                 &#40;fs/create-dirs dir&#41;&#41;

               &#40;doseq &#91;f &#91;&quot;bb.edn&quot; &quot;s3&#95;log&#95;parser.clj&quot;&#93;&#93;
                 &#40;println &quot;Adding file&quot; f&#41;
                 &#40;fs/delete-if-exists &#40;format &quot;%s/%s&quot; work-dir f&#41;&#41;
                 &#40;fs/copy &#40;format &quot;%s/%s&quot; src-dir f&#41; work-dir&#41;&#41;

               &#40;println &quot;Compressing lambda archive:&quot; lambda-zipfile&#41;
               &#40;let &#91;{:keys &#91;exit err&#93;}
                     &#40;sh &quot;zip&quot; &quot;-r&quot; lambda-zipfile &quot;.&quot;
                         :dir work-dir&#41;&#93;
                 &#40;when &#40;not= 0 exit&#41;
                   &#40;println &quot;Error:&quot; err&#41;&#41;&#41;&#41;}
</code></pre><p>You can read the source for the <code>th</code> namespace in <a href='https://github.com/jmglov/s3-log-parser/blob/main/task_helper.clj'><code>task&#95;helper.clj</code></a> if you like, but basically, what the <code>build</code> task is doing is:</p><ol><li>Creating work and target directories</li><li>Copying the <code>bb.edn</code> and <code>s3&#95;log&#95;parser.clj</code> files from the <code>src</code> directory   to the work directory</li><li>Zipping all the files in the work directory into <code>target/function.zip</code></li></ol><h2 id="deploying_my_lambda">Deploying my lambda</h2><p>My <code>deploy</code> task is pretty straightforward:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">deploy {:doc &quot;Deploys lambda using babashka-aws.&quot;
        :depends &#91;build&#93;
        :requires &#40;&#91;pod.babashka.aws :as aws&#93;&#41;
        :task &#40;let &#91;{:keys &#91;target-dir&#93; :as args}
                    &#40;th/parse-args&#41;

                    lambda-zipfile &#40;th/target-file target-dir &quot;function.zip&quot;&#41;
                    zipfile &#40;fs/read-all-bytes lambda-zipfile&#41;&#93;
                &#40;th/create-or-update-lambda &#40;assoc args :zipfile zipfile&#41;&#41;&#41;}
</code></pre><p>It reads in <code>target/function.zip</code> and passes it along to <code>task-helper/create-or-update-lambda</code>, which is a little more interesting:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn create-or-update-lambda &#91;{:keys &#91;aws-region bb-arch
                                       lambda-handler lambda-name lambda-role
                                       runtime-layer zipfile&#93;
                                :as args}&#93;
  &#40;let &#91;lambda &#40;aws/client {:api :lambda
                            :region aws-region}&#41;
        &#95; &#40;println &quot;Checking to see if lambda exists:&quot; lambda-name&#41;
        lambda-exists? &#40;-&gt; &#40;aws/invoke lambda {:op :GetFunction
                                               :request {:FunctionName lambda-name}}&#41;
                           &#40;contains? :Configuration&#41;&#41;&#93;
    &#40;if lambda-exists?
      &#40;update-lambda lambda args&#41;
      &#40;create-lambda lambda args&#41;&#41;&#41;&#41;
</code></pre><p>If no lambda with the name we've specified exists, we call <code>create-lambda</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn create-lambda &#91;lambda
                     {:keys &#91;aws-region bb-arch
                             lambda-handler lambda-name lambda-role
                             runtime-layer zipfile&#93;}&#93;
  &#40;let &#91;lambda-arch &#40;if &#40;= &quot;amd64&quot; bb-arch&#41; &quot;x86&#95;64&quot; &quot;arm64&quot;&#41;
        runtime &#40;if &#40;= &quot;amd64&quot; bb-arch&#41; &quot;provided&quot; &quot;provided.al2&quot;&#41;
        sts &#40;aws/client {:api :sts
                         :region aws-region}&#41;
        account-id &#40;-&gt; &#40;aws/invoke sts {:op :GetCallerIdentity}&#41; :Account&#41;
        layer-arns &#40;-&gt;&gt; &#91;runtime-layer&#93;
                        &#40;map #&#40;format &quot;arn:aws:lambda:%s:%s:layer:%s&quot;
                                      aws-region account-id
                                      &#40;latest-layer-version lambda %&#41;&#41;&#41;&#41;
        role-arn &#40;format &quot;arn:aws:iam::%s:role/%s&quot;
                         account-id lambda-role&#41;
        req {:FunctionName lambda-name
             :Runtime runtime
             :Role role-arn
             :Code {:ZipFile zipfile}
             :Handler lambda-handler
             :Layers layer-arns
             :Architectures &#91;lambda-arch&#93;}
        &#95; &#40;println &quot;Creating lambda:&quot; &#40;pr-str req&#41;&#41;
        res &#40;aws/invoke lambda {:op :CreateFunction
                                :request req}&#41;&#93;
    &#40;when &#40;contains? res :cognitect.anomalies/category&#41;
      &#40;println &quot;Error:&quot; &#40;pr-str res&#41;&#41;&#41;&#41;&#41;
</code></pre><p>If you're interested in this <code>aws/client</code> and <code>aws/invoke</code> stuff, this is the <a href='https://github.com/cognitect-labs/aws-api'>aws-api</a> library provided by the babashka-aws pod.</p><p><code>latest-layer-version</code> is a simple function that checks if our layer name includes a version (like <code>blambda:5</code>), or if not, uses Lambda's ListLayerVersions API to select the latest version:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn latest-layer-version &#91;lambda layer-name&#93;
  &#40;if &#40;string/includes? layer-name &quot;:&quot;&#41;
    layer-name
    &#40;let &#91;latest-version &#40;-&gt;&gt; &#40;aws/invoke lambda {:op :ListLayerVersions
                                                  :request {:LayerName layer-name}}&#41;
                              :LayerVersions
                              &#40;sort-by :Version&#41;
                              last
                              :Version&#41;&#93;
      &#40;format &quot;%s:%s&quot; layer-name latest-version&#41;&#41;&#41;&#41;
</code></pre><p>I can now deploy this by running <code>bb deploy</code> (I've pre-baked the IAM role required for this lambda, but it's basically the same as the one from the <a href='https://github.com/jmglov/blambda#using-blambda'>Blambda! example</a>).</p><h2 id="roadblock_the_first">Roadblock the first</h2><p>The problem is, when I invoke this lambda using a test event in the console, I get an error:</p><pre><code>Test Event Name
test

Response
{
  &quot;errorMessage&quot;: &quot;RequestId: 8292cbcf-2862-4189-97c2-757bc58a4ed8 Error: Runtime exited with error: exit status 1&quot;,
  &quot;errorType&quot;: &quot;Runtime.ExitError&quot;
}

Function Logs
ashka.pods.impl.resolver$download.invokeStatic&#40;resolver.clj:105&#41;
at babashka.pods.impl.resolver$pod&#95;manifest.invokeStatic&#40;resolver.clj:123&#41;
at babashka.pods.impl.resolver$resolve.invokeStatic&#40;resolver.clj:175&#41;
at babashka.pods.impl$resolve&#95;pod.invokeStatic&#40;impl.clj:327&#41;
&#91;...&#93;
Exception in thread &quot;main&quot; java.io.FileNotFoundException: /home/sbx&#95;user1051/.babashka/pods/repository/org.babashka/aws/0.1.2/manifest.edn &#40;No such file or directory&#41;
at com.oracle.svm.jni.JNIJavaCallWrappers.jniInvoke&#95;VA&#95;LIST&#95;FileNotFoundException&#95;constructor&#95;970c509c6abfd3f98898b9a7521945418b90b270&#40;JNIJavaCallWrappers.java:0&#41;
&#91;...&#93;
END RequestId: 8292cbcf-2862-4189-97c2-757bc58a4ed8
REPORT RequestId: 8292cbcf-2862-4189-97c2-757bc58a4ed8	Duration: 594.19 ms	Billed Duration: 595 ms	Memory Size: 128 MB	Max Memory Used: 21 MB	
RequestId: 8292cbcf-2862-4189-97c2-757bc58a4ed8 Error: Runtime exited with error: exit status 1
Runtime.ExitError

Request ID
8292cbcf-2862-4189-97c2-757bc58a4ed8
</code></pre><p>Oops! Babashka is trying to load the babashka-aws pod that I specified in my <code>src/bb.edn</code>, but I haven't provided that pod. I could use <code>babashka.pods/load-pod</code> at runtime to grab the pod, but that would mean that my lambda would have a slow cold start. A better idea is to pub the pod on the lambda instance's filesystem, but how can we do that?</p><p>The hint is in this line:</p><pre><code>Exception in thread &quot;main&quot; java.io.FileNotFoundException: /home/sbx&#95;user1051/.babashka/pods/repository/org.babashka/aws/0.1.2/manifest.edn &#40;No such file or directory&#41;
</code></pre><p>If I can create a <code>.babashka</code> directory in the lambda instance's home directory, Babashka should find any pods I put there. Of course, Lambda doesn't let you do that, but it does let you put stuff in <code>/opt</code>, by using a layer. Searching on Clojurians Slack yielded <a href='https://github.com/borkdude'>borkdude</a> mentioning an environment variable, <code>$BABASHKA&#95;PODS&#95;DIR</code>, which Babashka will use for the <a href='https://github.com/babashka/pods'>pods repository</a>.</p><p>Now I have all the pieces I need. The first step is...</p><h2 id="packaging_pods_into_a_layer">Packaging pods into a layer</h2><p>I added a <code>build-pods</code> task to my top-level <code>bb.edn</code>:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">build-pods {:doc &quot;Builds pods layer&quot;
            :requires &#40;&#91;clojure.java.shell :refer &#91;sh&#93;&#93;&#41;
            :task &#40;let &#91;{:keys &#91;target-dir work-dir&#93;} &#40;th/parse-args&#41;
                        work-dir &#40;str work-dir &quot;/pods&quot;&#41;
                        pods-dir &#40;str &#40;fs/home&#41; &quot;/.babashka/pods&quot;&#41;
                        pods-zipfile &#40;th/target-file target-dir &quot;pods.zip&quot;&#41;&#93;
                    &#40;doseq &#91;dir &#91;target-dir work-dir&#93;&#93;
                      &#40;fs/create-dirs dir&#41;&#41;

                    &#40;doseq &#91;pod &#91;&quot;org.babashka/aws/0.1.2&quot;&#93;
                            :let &#91;dst &#40;format &quot;%s/.babashka/pods/repository/%s&quot; work-dir pod&#41;&#93;&#93;
                      &#40;when-not &#40;fs/exists? dst&#41;
                        &#40;println &quot;Adding pod&quot; pod&#41;
                        &#40;fs/copy-tree &#40;format &quot;%s/repository/%s&quot; pods-dir pod&#41; dst&#41;&#41;&#41;

                    &#40;println &quot;Compressing pods layer&quot; pods-zipfile
                             &quot;from dir:&quot; work-dir&#41;
                    &#40;let &#91;{:keys &#91;exit err&#93;}
                          &#40;sh &quot;zip&quot; &quot;-r&quot; pods-zipfile &quot;.&quot;
                              :dir work-dir&#41;&#93;
                      &#40;when &#40;not= 0 exit&#41;
                        &#40;println &quot;Error:&quot; err&#41;&#41;&#41;&#41;}
</code></pre><p>What it's doing is copying the <code>&#126;/.babashka/pods/repository/org.babashka/aws/0.1.2</code> directory into the work dir, then adding it to <code>target/pods.zip</code>.</p><p>Deploying the layer looks like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">deploy-pods {:doc &quot;Deploys pods layer using babashka-aws.&quot;
             :depends &#91;build-pods&#93;
             :requires &#40;&#91;pod.babashka.aws :as aws&#93;&#41;
             :task &#40;let &#91;{:keys &#91;pods-layer aws-region target-dir&#93;} &#40;th/parse-args&#41;
                         pods-zipfile &#40;th/target-file target-dir &quot;pods.zip&quot;&#41;
                         client &#40;aws/client {:api :lambda
                                             :region aws-region}&#41;
                         zipfile &#40;fs/read-all-bytes pods-zipfile&#41;
                         &#95; &#40;println &quot;Publishing layer version for layer&quot; pods-layer&#41;
                         res &#40;aws/invoke client {:op :PublishLayerVersion
                                                 :request {:LayerName pods-layer
                                                           :Content {:ZipFile zipfile}}}&#41;&#93;
                     &#40;if &#40;:cognitect.anomalies/category res&#41;
                       &#40;prn &quot;Error:&quot; res&#41;
                       &#40;println &quot;Published layer&quot; &#40;:LayerVersionArn res&#41;&#41;&#41;&#41;}
</code></pre><h2 id="adding_the_pods_layer_to_my_lambda">Adding the pods layer to my lambda</h2><p>Since I'm already using the Blambda! layer in my lambda, adding a new layer only requires making minor changes to <code>task-helper/create-lambda</code>:</p><ul><li>Add <code>pods-layer</code> to the function arguments<pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;defn create-lambda &#91;lambda
                     {:keys &#91;aws-region bb-arch
                             lambda-handler lambda-name lambda-role
                             pods-layer runtime-layer zipfile&#93;}&#93;
  ;; ...
&#41;
  </code></pre></li><li>Add <code>pods-layer</code> to <code>layer-arns</code><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;let &#91;layer-arns &#40;-&gt;&gt; &#91;runtime-layer&#93;
                      &#40;map #&#40;format &quot;arn:aws:lambda:%s:%s:layer:%s&quot;
                                    aws-region account-id
                                    &#40;latest-layer-version lambda %&#41;&#41;&#41;&#41;
      ;; ...
      &#93;
  ;; ...
&#41;
</code></pre></li></ul><h2 id="making_blambda%21_find_my_pods">Making Blambda! find my pods</h2><p>The only problem left is having Blambda! set <code>BABASHKA&#95;PODS&#95;DIR</code> when starting <code>bb</code>. This is a simple matter of updating the <a href='https://github.com/jmglov/blambda/blob/main/bootstrap'><code>bootstrap</code></a> script in Blambda! itself:</p><pre class="language-sh"><code class="lang-sh language-sh">#!/bin/sh

export BABASHKA&#95;PODS&#95;DIR=/opt/.babashka/pods

cd $LAMBDA&#95;TASK&#95;ROOT
/opt/bb -cp $LAMBDA&#95;TASK&#95;ROOT /opt/bootstrap.clj
</code></pre><p>Now when I test my lambda, I get a much more satisfying result:</p><pre><code>Test Event Name
test

Response
{}

Function Logs
START RequestId: e0d573c2-5afa-4567-af74-592f12efa094 Version: $LATEST
Loading babashka lambda handler:  s3-log-parser/handler
Starting babashka lambda event loop
{:msg &quot;Invoked with event&quot;, :data {:event {:key1 &quot;value1&quot;, :key2 &quot;value2&quot;, :key3 &quot;value3&quot;}}}
END RequestId: e0d573c2-5afa-4567-af74-592f12efa094
REPORT RequestId: e0d573c2-5afa-4567-af74-592f12efa094	Duration: 109.60 ms	Billed Duration: 559 ms	Memory Size: 128 MB	Max Memory Used: 117 MB	Init Duration: 448.61 ms

Request ID
e0d573c2-5afa-4567-af74-592f12efa094
</code></pre><h2 id="where_to_next%3F">Where to next?</h2><p>There is so much wrong and gross here:</p><ul><li>I'm hardcoding the pods that my function needs, rather than resolving them  from the function's <code>bb.edn</code></li><li>I'm assuming that the pod will be in the repo instead of loading it first</li><li>This deployment stuff is kinda neat since it doesn't require external tools,  but also kinda sucks because it doesn't provide all the goodness that external  tools like <a href='https://www.terraform.io/'>Terraform</a> do</li><li>This function doesn't actually do anything, so I don't know if the pod is  actually working  </li></ul><p>Stay tuned for more thrilling adventures as I eat my own dogfood!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-03-blambda.html</id>
    <link href="https://jmglov.net/blog/2022-07-03-blambda.html"/>
    <title>Blambda!</title>
    <updated>2022-07-03T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>A couple of weeks ago, I made a <a href='2022-06-21-todo-list.html'>todo list</a> for the summer. One of the items on there was to create an AWS Lambda custom runtime for <a href='https://github.com/babashka/babashka'>Babashka</a>. I actually accomplished that a few days later, and today I want to walk through the what, the why, and the how of that project.</p><p>Let's start by answering the question of what a custom runtime is. For anyone not familiar with <a href='https://aws.amazon.com/lambda/'>AWS Lambda</a>, it is basically a way to run a function in the AWS cloud without having to worry about how or where the function is actually executed. For me, cloud functions are the next natural step along the path of computing without caring about machines. First came servers that you had to host yourself, then came VMs that you could run on servers you hosted yourself, then came EC2, which gave you a VM hosted by somebody else, then came containers, then came managed container environments like Kubernetes, then came function as a service, which allowed you to provide a zip file that just ran somewhere. You can nitpick the order if you want, but this is more or less accurate. I'm also not claiming that serverless is right for all workloads, but when it is, it's pretty great.</p><p>So having explained what Lambda is, I'll crack on with explaining custom runtimes. Lambda comes out of the box with runtimes that support a great variety of programming languages: Python, Golang, .Net, Ruby, JavaScript, and Java. Those last two are of interest to Clojure programmers, since they allow us to write ClojureScript functions and execute them on the NodeJS runtime, or Clojure proper on the Java runtime (you could technically also execute Clojure programs on the .Net runtime using <a href='https://clojure.org/about/clojureclr'>Clojure CLR</a>, but I doubt many people are doing that). This is great, unless you need predictably low-ish latency, because the first time you invoke a lambda function, AWS need to spin up an execution environment, then execute your function. This is called a "cold start", and for the JVM, it can take a few thousand milliseconds, and that's <strong>before</strong> starting the Clojure runtime, which can take a few thousand more.</p><p>Clojure programmers have long known about this JVM startup delay, of course, which is why we tend not to write command line utilities in Clojure, since it is quite annoying for your utility to take 2-3 seconds just to tell you that you've misspelled one of the options (was it <code>--dry-run</code> or <code>--dryrun</code>?). And of course we've had ways around that for awhile as well, mostly based on ClojureScript (<a href='https://github.com/anmonteiro/lumo'>Lumo</a> is one example). So one could write lambdas in ClojureScript and run them on the NodeJS runtime and not have to wait around for the JVM to start up (the NodeJS runtime has a cold start of a few hundred milliseconds instead of a few <strong>thousand</strong> for the JVM), though there are a few drawbacks with that as well:</p><ol><li>In order for the NodeJS runtime to execute ClojureScript, it needs to be   transpiled to JavaScript, which means you can't edit it in the lambda   console, which makes troubleshooting those annoying problems that only seem   to happen when you deploy the thing harder, since you have to add your   <code>println</code> statements locally and then compile and then upload and then try   again and then realise you need another <code>println</code> somewhere else... ugh! To   be fair, JVM Clojure has the same issue.</li><li>You have to use NodeJS. Yuck.</li></ol><p>Luckily, AWS has provided the ability to specify a <a href='https://docs.aws.amazon.com/lambda/latest/dg/runtimes-custom.html'>custom
runtime</a>, which can be written in any language and just needs to be executable on a Linux system and implement a simple local invocation API. This means that you can now write lambda functions in any language!</p><p>Getting back to Clojure, the wonderful <a href='https://github.com/borkdude'>borkdude</a> realised that if one could compile a Clojure program using <a href='https://www.graalvm.org/'>GraalVM</a>, it would start up fast, thus enabling command line programs in Clojure that didn't make you want to pull your hair out. "But why stop there?" borkdude presumably thought to himself. "Writing shell scripts in Bash kinda sucks, so what about writing them in Clojure instead? All I'd have to do is write a program that can interpret Clojure and compile it with GraalVM and then it could execute Clojure scripts or Clojure code passed on the command line and then my life would be complete." And this magical program, my friends, is called Babashka.</p><p>Since Babashka starts fast and can evaluate Clojure source code, we can build a custom runtime for Lambda that uses Babashka to execute our lambda functions, thus gaining the ability to edit source code in the lambda console <strong>and</strong> use Clojure instead of ClojureScript, both of which are very important to me.</p><p>This was the motivation behind building <a href='https://github.com/jmglov/blambda'>Blambda!</a>, which is a custom runtime that can be deployed as a <a href='https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-concepts.html#gettingstarted-concepts-layer'>Lambda
layer</a>. Let's talk about how it works.</p><p>A custom runtime requires only one thing: an executable named <code>bootstrap</code> in the root level of your lambda function's archive. When your function is invoked for the first time, the Lambda runtime executes the <code>bootstrap</code> function, which is then expected to call Lambda's <a href='https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-next'>next invocation
API</a>, which returns the request body of whatever called your lambda function, which the runtime customarily hands off to the actual code implementing your lambda function and then feeds the return value of that to the Lambda <a href='https://docs.aws.amazon.com/lambda/latest/dg/runtimes-api.html#runtimes-api-response'>invocation
response
API</a>, and then waits for the next request and does the same thing all over again.</p><p>In the case of Blambda!, the custom runtime consists of three parts:</p><ul><li>The statically linked Babashka executable itself</li><li>A Clojure program,  <a href='https://github.com/jmglov/blambda/blob/f1ab0ecd79e7b9d1ff733927baf68a9551e42d6b/bootstrap.clj'><code>bootstrap.clj</code></a>,  that implements the request handling loop described above. I borrowed this  from an existing Babashka runtime,  <a href='https://github.com/tatut/bb-lambda'>bb-lambda</a>, which I decided not to use  because it uses Docker, which makes me almost as sad as NodeJS. ;)</li><li>A  <a href='https://github.com/jmglov/blambda/blob/f1ab0ecd79e7b9d1ff733927baf68a9551e42d6b/bootstrap'><code>bootstrap</code></a>  shell script which uses Babashka to evaluate the above Clojure program</li></ul><p>Packaging this as a layer is as simple as downloading Babashka, then zipping it into an archive with the other two files, which you can see in the <code>build</code> task of Blambda!'s <a href='https://github.com/jmglov/blambda/blob/39519beeae0296508517f292df6de8f5df563dd7/bb.edn#L17'><code>bb.edn</code></a>.</p><p>To use Blambda!, you can build and deploy the custom runtime layer by cloning the repo and running:</p><pre><code>bb deploy
</code></pre><p>You then create a lambda function that uses the "provided" runtime, includes the "blambda" layer that was created by the <code>bb deploy</code> command, and sets the handler to whatever function in your namespace that will handle function invocations. For example, if you have a namespace like this:</p><pre class="language-clojure"><code class="lang-clojure language-clojure">&#40;ns hello&#41;

&#40;defn hello &#91;{:keys &#91;name&#93; :or {name &quot;Blambda&quot;} :as event} context&#93;
  &#40;prn {:msg &quot;Invoked with event&quot;,
        :data {:event event}}&#41;
  {:greeting &#40;str &quot;Hello &quot; name &quot;!&quot;&#41;}&#41;
</code></pre><p>you can create a lambda function like this:</p><p><img src="assets/2022-07-03-blambda-create-function.png" alt="AWS Lambda console create function page showing the setting of runtime to
'provide your own bootstrap on Amazon Linux 2'" /></p><p><img src="assets/2022-07-03-blambda-add-layer.png" alt="AWS Lambda console add layer page showing selecting a custom layer named 'blambda'" /></p><p><img src="assets/2022-07-03-blambda-set-handler.png" alt="AWS Lambda console edit runtime settings page showing setting the handler to 'hello/hello'" /></p><p>and then test it with an event like this:</p><pre class="language-json"><code class="lang-json language-json">{
  &quot;name&quot;: &quot;jmglov&quot;
}
</code></pre><p>The Lambda console will display something like this:</p><pre><code>Test Event Name
hello

Response
{
  &quot;greeting&quot;: &quot;Hello jmglov!&quot;
}

Function Logs
START RequestId: 4288f5e7-f4c9-41b2-a26f-b5d688c146ec Version: $LATEST
Loading babashka lambda handler:  hello/hello
Starting babashka lambda event loop
{:msg &quot;Invoked with event&quot;, :data {:event {:name &quot;jmglov&quot;}}}
END RequestId: 4288f5e7-f4c9-41b2-a26f-b5d688c146ec
REPORT RequestId: 4288f5e7-f4c9-41b2-a26f-b5d688c146ec	Duration: 240.41 ms	Billed Duration: 669 ms	Memory Size: 128 MB	Max Memory Used: 97 MB	Init Duration: 427.73 ms

Request ID
4288f5e7-f4c9-41b2-a26f-b5d688c146ec
</code></pre><p>Stay tuned for future posts on Blambda! when I try to actually use it for something real. ;)</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-02-two-simple-things.html</id>
    <link href="https://jmglov.net/blog/2022-07-02-two-simple-things.html"/>
    <title>Two simple things</title>
    <updated>2022-07-02T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>A couple of years back, I made two really minor changes that improved my quality of life in a major way.</p><p>For most of my life, I've had trouble falling asleep. Once I do fall asleep, I usually sleep well, but for whatever reason, my brain has trouble shutting itself down at night so that I can drift off to sleep. This seemed to be getting worse as I got older, until I stumbled onto two things.</p><p>The first came when we were in the US to visit my family. We had gone to the beach with them for a week in Delaware, then driven down to my aunt's house in Vienna (Virginia, not Austria) to spend the night and catch our flight the following afternoon. My best friend from childhood, Adam, just so happened to be off that day, and drove up from Richmond so he could hang out with us for a few hours before we had to go to the airport.</p><p>Adam is a social worker who specialises in addiction, and he was talking about how many of his clients who manage to get clean tend to smoke and drink a lot of coffee. One particular client always grabbed a cup of coffee on the way into see Adam, and then ducked out to refill his cup halfway through the appointment. His appointments were usually in mid-afternoon, and one day, he was telling Adam about how poor his sleep quality was. Adam suggested that he lay off coffee after lunch, but the client insisted that coffee didn't have any effect on him because he was so used to it, and he could fall asleep easily no matter how many cups he had, it was just that he always woke up in the middle of the night and tossed and turned. Adam didn't press the point with the client, but he told me that caffeine affects us all, despite how much tolerance we think we have.</p><p>At this point, I drank about five or six cups of coffee a day: two cups of black drip coffee with my breakfast at home, two lattes before lunch at work (we had machines there that made a decent latte, and one of them even had oat milk!), and another one or two after lunch. When we got back to Sweden, I kept thinking about what Adam had said, and I decided to stop drinking coffee after lunch. The improvement was almost instantaneous; I got to sleep much faster and actually found that I was less tired in the morning.</p><p>It wasn't a panacea, of course. I still had those nights where my brain wouldn't stop coming up with interesting ideas when I was trying to fall asleep. Luckily, my second discovery wasn't far off, and this one was born of pure serendipity.</p><p>Everyone in my family has iPhones, and we all have chargers on our bedside tables, and I have an additional one in my office room (also known as the guest bedroom). One day, my son's charger stopped working, and it turned out to be because the cable had just worn out. Since I had a charger in the office, I gave him the cable from my bedside table and just started plugging my phone in to charge in the office room instead.</p><p>This made a big difference. I always try to read in bed before falling asleep, but when my phone was there, I'd often notice that I had a notification on Twitter, or my friend Justin would send me a link to some dodgy Arsenal transfer rumour on WhatsApp, or I'd wonder if there was a new <a href='https://xkcd.com/'>xkcd</a> or whatever, so I'd put my bookmark in "just for a second" and grab my phone to check things out. When I moved my phone in the other room, even if I was curious about xkcd or wanted to check my schedule for tomorrow or whatever, it was such a pain to get out of my comfy bed and walk to the other room that I just said to myself, "meh, I'll check in the morning," and went back to my book.</p><p>It turns out that there are two properties of smartphones that aren't great for sleep. The first one is blue light, which <a href='https://www.health.harvard.edu/staying-healthy/blue-light-has-a-dark-side'>suppresses the body's secretion of
melatonin</a>, which is a hormone that is normally produced at night and tells our body that it's sleepytime. The second is social media, which seems to have <a href='https://www.sciencedirect.com/science/article/pii/S0747563220302399'>some
correlation with decreased quality of
sleep</a>.</p><p>So without knowing I was doing it, I removed a major sleep impedient from my life just because an iPhone charger cable stopped charging. Thanks, Apple!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-07-01-warsaw-pact.html</id>
    <link href="https://jmglov.net/blog/2022-07-01-warsaw-pact.html"/>
    <title>The Warsaw Pact</title>
    <updated>2022-07-01T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I should be driving back from the airport right now, having retrieved my wife and son after their trip to Bulgaria. What I am doing instead is writing a blog entry, which of course I would have done earlier if they weren't currently chilling in Warsaw, thanks to a two hour flight delay. At least it's not Heathrow or JFK or one of those horrible places, though.</p><p>So yeah, the dog and I have been here by ourselves for the last 10 days, and whilst we've entertained ourselves well enough, it will be nice to have the rest of the family back. I expect that Rover will lose the plot entirely when Lyani and Kai walk through the door.</p><p>I had this idea today when I was riding my bike and I dinged the bell so that two people walking side by side would switch to single file so I could pass them without hitting them or getting hit in the face by a bush, but then of course they maintained present course and speed so I had to risk the bush and luckily I was wearing my helmet so I could just duck my head and pray there weren't any solid branches in there. My idea was to write a piece about all the things that annoy me, but do it in a really passive aggressive way and make a joke that it was an assignment for my Swedish citizenship exam, because passive aggression is even more Swedish than IKEA, but then I ended up realising that there really aren't that many things that annoy me enough to write a humorous piece about.</p><p>There are things that make me angry, injustices of all sort and when Arsenal lose a game of football, but for the most part, I don't get annoyed when people don't behave like I think they should. This is the result of a conscious choice, though. You see, a few years back (six or so, if my memory serves), I was commuting to work every day, and was always getting annoyed by things like people standing on the wrong side of the escalator or not waiting for people to get off the train before barging in or people stopping suddenly in the middle of the sidewalk or whatever. I took my wife to see La Boheme at the opera house downtown, and after the opera, we were catching the train home. We needed to walk down a long escalator, and a train was coming, so we wanted to hurry up and catch it. Two young women were standing side by side on the escalator, blocking our path, and I said "Move!" to them in quite an angry tone of voice, and my wife gave me a look.</p><p>We missed that train and had to wait five whole minutes for the next one, during which time my wife told me that it really bothered her when I acted like that. "What do you mean?" I said, "They were clearly in the wrong and because of that we missed our train." "So what?" she said, "Now you're annoyed, I'm annoyed, and do you think those two learned their lesson and will never be mildly inconsiderate again in their lives?" I recognised this as one of those questions that don't require an answer, so I shut my mouth and thought a little about what she'd said. The more I thought about it, the more I realised that she had a good point. Me getting angry at random commuters had no effect on the world other than making me feel worse.</p><p>So I tried to break the habit starting the next morning on my way to work. When someone didn't move their bag off the seat so someone else could sit down, instead of letting the righteous anger course through my veins, I just ignored it. Or tried to, at least. When someone pushed onto the train as I was trying to get off, I just squeezed past them and went on with my day.</p><p>It didn't happen overnight, and it took a lot of intentional effort, but I was able to train myself to stop expecting everyone to behave exactly as I thought they should behave at all times. Because let's face it, there are about a million reasons why someone could commit one of these minor commuting sins. Maybe they're in a hurry for a really valid reason (late for a job interview, for example). Maybe they're distracted and don't notice they're standing in the middle of the bike path. Maybe they're really tired because they just got off a double shift at some shitty job. Or maybe they're an inconsiderate prick, but I honestly think that is the least likely of the many reasons people do inconsiderate things.</p><p>Plus, who the fuck am I to judge what the right thing for other people to do is? I can certainly judge what the right thing for <strong>me</strong> to do is, and then I can feel better about myself by doing that thing, so maybe that's what I should concentrate on doing, rather than being the arbiter of decency for everyone moving around the city of Stockholm.</p><p>And you know what? It worked. Not getting all worked up about what other people are doing has actually made me happier.</p><p>So sorry, no fun article about things I hate. Instead, I'm going to take a nice cold shower and get in a few turns of Civ V before it's time to drive to the airport and pick up the family.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-30-story-of-a-mediocre-fan-3.html</id>
    <link href="https://jmglov.net/blog/2022-06-30-story-of-a-mediocre-fan-3.html"/>
    <title>Story of a mediocre fan: chapter 3</title>
    <updated>2022-06-30T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>It's Thursday in Stockholm, and I have some good news and some bad news for you, which I shall interleave in the following several bullet points:</p><ul><li>Good news: as <a href='2022-06-28-hotter-than-hellsinki.html'>the damned Norwegians
  promised</a>, yesterday was cloudy and  cooler than at the beginning of the week, peaking at around 24 degrees in the  early evening.</li><li>Bad news: the peak temperature yesterday coincided with Simon and I tucking  into some really spicy curry and then starting an epic Guitar Hero session, so  it felt like about a gazillion degrees in my apartment.</li><li>Badder news: it's back to being sunny and hot now, and worse yet, when I look  back at my screenshot of <a href='assets/2022-06-28-stockholm-helsinki.png'>the Norwegians'
  forecast</a>, I actually see that they  predicted this, so now I have to apologise for calling them liars. Sorry, you  damned smug Norwegians, sitting atop your pile of filthy oil-generated lucre!</li><li>Good news: I wrote the 3000-4000 words required for <a href='https://7amkickoff.com/index.php/2022/06/30/story-of-a-mediocre-fan-chapter-3/'>chapter 3 of "Story of a
  mediocre
  fan"</a>.</li><li>Bad news: I wrote nearly 5000 words in chapter 3, which is a bit longer than  spec. Tim graciously told me that since he's verbose, he has no right to ask  me not to be. I think he does actually have the right, since it's his blog,  but I didn't want to argue with him on this point. ;)</li><li>Good news: my friend, former coworker, and best Product person in the world  Sajal is in town, and we're going out with our former boss Rafa to celebrate  Sajal's new job!</li><li>Bad news: the bus that I need to catch to be on time to meet the boys down on  Södermalm is in less than 40 minutes, and I haven't gotten in the shower yet  because I'm sitting here writing this!</li><li>Badder news: my mean friend Ray (not to be confused with my mean friend  Sen&ndash;maybe I need to get nicer friends!) will probably claim that this entry  is "phoning it in", the jackass! I don't see him blogging daily, only  complaining about the poor quality of my daily blog (which, to be honest, he  kinda has a point about, but I'm following <a href='2022-06-15-summertime.html'>my friend Juan's
  advice</a> and being brave enough to be awful until  I'm not).</li><li>Good news: I'm done phoning it in now, so you can stop reading this drivel and  head on over to <a href='https://7amkickoff.com/'>7amkickoff</a> to read my actual daily  writing!</li></ul>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-29-rovers-birthday.html</id>
    <link href="https://jmglov.net/blog/2022-06-29-rovers-birthday.html"/>
    <title>Rover's birthday</title>
    <updated>2022-06-29T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>Yesterday was a very special day for the best boy in the whole wide world: my dog Rover turned 9! We celebrated with the following lovely "cake", which I made by frosting half of a hamburger bun with liver pâté (the cheap supermarket kind, don't worry) and spelling out his age in doggy treats, since I don't think he knows how to blow out a candle and is afraid of fire, as any sensible dog should be.</p><p><img src="assets/2022-06-29-rover-cake.jpg" alt="A yellow dog looks at a cake made of liver pâté with a number 9 made of doggy
treats on top" title="What a good boy!" /></p><p>He also received a brand new to us gently used tennis ball that Joanna and I found the other day when retrieving a ball that I somehow managed to smack onto the overhead walkway at the tennis court where we were playing Monday morning.</p><p><img src="assets/2022-06-29-rover-ball.jpg" alt="A panting yellow dog sits next to a tennis ball" /></p><p>As a (mostly) Labrador Retriever, Rover's love of tennis balls is the stuff of Shakespeare sonnets.</p><p><em>Shall I compare thee to a summer’s day?</em><br /> <em>Thou art more greenish-yellow and more squishy.</em><br /> <em>Rough winds do make it hard to catch you,</em><br /> <em>And summer’s leash hath all too short a length.</em><br /> <em>Sometime too hot the eye of heaven shines,</em><br /> <em>Which makes me pant after several goes;</em><br /> <em>And every throw from fair sometime declines,</em><br /> <em>By chance, or Josh's dodgy aim, untrimmed;</em><br /> <em>But thy eternal bounce shall not fade,</em><br /> <em>Nor lose possession of that fuzz thou ow’st,</em><br /> <em>Nor shall death brag thou wand'rest in his shade,</em><br /> <em>When split thou seams become'st.</em><br /> <em>So long as dogs can chase, or eyes can see,</em><br /> <em>So long lives this, and this gives life to thee.</em></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-28-hotter-than-hellsinki.html</id>
    <link href="https://jmglov.net/blog/2022-06-28-hotter-than-hellsinki.html"/>
    <title>Hotter than hell... sinki</title>
    <updated>2022-06-28T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>It's Tuesday, and I have a litany of complaints. First and most importantly, it's hotter than hell... sinki here in Stockholm! Look, I'll show you:</p><p><img src="assets/2022-06-28-stockholm-helsinki.png" alt="Screenshot of weather forecast for Stockholm and
Helsinki" title="Lies!" /></p><p>Damn the <a href='https://www.yr.no/en'>lying Norwegians</a> and their lies! At least we're going to get some relief tomorrow, whilst our neighbours to the northeast will suffer, so that's something. All jokes aside, the Norwegian Metrological Institute does provide an excellent weather service, so feel free to enjoy their oil wealth-fuelled excellence. ;)</p><p>The hell / Helsinki thing, whilst it may appear at first glance to be a dad joke, was actually invented by my friend Adam, who once got in trouble for telling his sister Ana (who is an awesome person that I exchanged Star Wars novels and "Pride and Prejudice"-related quips with back in the day) to go to to Helsinki, with a delay of about 30 minutes between the "hell" and the "sinki" bits, according to Adam.</p><p>So where was I? Oh yes, a litany of complaints. I've covered the heat, so let me move onto the fact that I need to write 3000-4000 words of the third and final chapter of "<a href='2022-06-16-story-of-a-mediocre-fan.html'>Story of a mediocre fan</a>" by Thursday morning, and I currently have 0 words done. I texted my friend Simon for advice, thinking that since he's a professional journalist and all, he'd know how to get me unstuck. His answer:</p><p><img src="assets/2022-06-28-simon-writing-text.png" alt="Screenshot of iMessage chat. Simon says: Ha. Easy. It's like walking. Anyone
can do it. It all depends on what 'writing' means." title="Thanks, Simon" /></p><p>What a jerk! Why are we friends again? (I hope he's not reading this, because I want to borrow his kayak to paddle around Lake Mälaren tomorrow morning before it gets to hot to think.)</p><p>So anyway, I need to start writing about Arsenal, something that normally fills my heart with joy, but for some reason I'm looking for any distraction that I can find (did the dishes, started some laundry, watered the plants, took out the recycling, walked the very good boy, wrote a much longer blog post than intended).</p><p>Lemme just check Clojurians Slack to see if Ray sent me anything...</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-27-tennis-anyone.html</id>
    <link href="https://jmglov.net/blog/2022-06-27-tennis-anyone.html"/>
    <title>Tennis, anyone?</title>
    <updated>2022-06-27T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I'll make today's post (relatively) short and (hopefully) sweet, as I've spent a big chunk of the day working on <a href='https://github.com/jmglov/blambda'>Blambda!</a> and I need to actually start writing the third and final chapter of "<a href='2022-06-16-story-of-a-mediocre-fan.html'>Story of a
mediocre fan</a>" so that it's ready to post by Thursday.</p><p>I played tennis for the first time in at least 15 years this morning with my friend Joanna. It felt good to be back on the court despite having a bruised rib and accidentally picking up my gym shoes instead of the brand new tennis shoes I got for my birthday (the soles had completely dried out on my old shoes, which isn't all that surprising since they are 20 years old and haven't been worn in at least 15 years) and it being a gazillion degrees in Stockholm today (30 C). It was also obvious that I need to find a wall and hit against it quite a lot until I remember what a good forehand feels like. Oh yeah, and I need to relearn how to serve, apparently, since the only serves I got in today came off the frame of the racquet. Oh yeah, and I also need to get one of those goofy glasses strap thingies so I can play with my glasses on, otherwise I can't see the ball until it's too late, so I'm always a little behind the ball.</p><p>All of those nitpicks aside, I had a brilliant time. I hadn't quite forgotten how much I love the game, but it was a little like meeting a childhood friend for the first time in ages&ndash;you know intellectually that you'll have a great time handing out with them, but actually hanging out in person is even more amazing than you thought it would be.</p><p>We played on an indoor court at a tennis club, which was nice, but expensive. An hour cost 190 SEK (more or less 20 dollars or euros), and that's <strong>half price</strong> because it's summer and Stockholm is deserted between <a href='2022-06-25-midsommar.html'>midsommar</a> and August. Normal price is close to 400 an hour! I'm really annoyed at how expensive it is to play in Stockholm, since there are basically no public courts, so you always have to rent a court or be a member somewhere. Growing up in the US, many public parks have tennis courts which are free to play on and are first come, first served. If that had not been the case, I never would have ended up playing tennis for my entire teenage years, because I certainly couldn't afford court fees at a private club.</p><p>My friend May did discover a rare and wonderful public court near her house on a walk one day, and I had planned to take my bike over there to check it out today, but it is 34 bloody degrees out there, so that will have to wait for another day.</p><p>Speaking of 34 degrees outside, I now need to take Rover out for his evening walk. Luckily there's a forest right by my house, so at least we can be in the shade, but the poor fellow absolutely hates the heat, and just trudges along morosely looking like Eeyore.</p><p><a href='https://en.wikipedia.org/w/index.php?curid=64930688'><img src="assets/eeyore.png" alt="Eeyore the donkey, as rendered by Disney, looks sadly at the
camera" title= "By Disney - https://winniethepooh.disney.com/eeyore, Fair use" /></a></p><p>All right folx, hope you have a good one and you're keeping cooler than Rover and I are!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-26-loose-ends.html</id>
    <link href="https://jmglov.net/blog/2022-06-26-loose-ends.html"/>
    <title>Tying off loose ends</title>
    <updated>2022-06-26T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I have been working my way through the <a href='2022-06-21-todo-list.html'>old todo
list</a> in the five days since I wrote it, and I wanted to give you an update.</p><p>As I predicted, all of the things that should have definitely happened did in fact definitely happen:</p><ul><li>Fika with Esther. Beers were drunk and shade was sat it. Conversation was had,  and verily it was good.</li><li>Stop by the store on the way home from said fika. I got ingredients for  harissa and guacamole, though my plans for making and consuming these  delicacies did encounter a slight setback, as you will see.</li><li>Walk my dog. Regular as clockwork, that dude gets his walks, though it's been  bloody hot in Stockholm recently (today is 30 degrees!) and thus the poor  fella doesn't look like he's enjoying them all that much.</li><li>Play Guitar Hero with Simon. This happened, and it happened twice. On the day  that I wrote the todo list, the original idea was to drink beer and eat  burritos and guacamole and such prior to fucking out, but through an accident  of fate, Simon needed to return home for dinner after the shredfest, and thus  drove his car over instead of biking. This made beers an impossibility, and I  decided that I didn't want to go to the trouble of making food that I wouldn't  eat until much later, so only Guitar Hero was played. However, I'm happy to  admit that my plans for Wednesday fell through because my friend Johan very  rudely got the sniffles or something and thus couldn't make his leaving  drinks, so Simon and I reprised our roles as the greatest middle aged rockers  to have ever rocked clicky plastic guitars to an adoring crowd of my dog, and  this time we did eat the black bean and rice burritos and loads of guacamole  and drink some margaritas that I accidentally made double strong.</li></ul><p>Some of the things that were likely to have happened have already happened, and they shall be detailed below!</p><p>The thing that was unlikely to have happened did not in fact happen, but I don't feel bad about that because I told you it was unlikely to happen anyway so in a way, it's awesome that it didn't happen because otherwise I would have been wrong in my prediction that it was unlikely to happen. Of course, the summer isn't over yet, so it could still be unlikely to happen yet, Mr. Frodo.</p><h2 id="https_for_the_blog">HTTPS for the blog</h2><p>I did finally get HTTPS working for my blog, as you can see from the following screenshot that looks something like what you would see in the address bar of your very own browser should you choose to look up there at it:</p><p><img src="assets/2022-06-26-address-bar.png" alt="Browser address bar showing a shield and a
lock" /></p><p>Of course, the URL itself will be a bit different, but you know what I mean.</p><p>So here's what I needed to do (after following my <a href='2022-06-24-s3-https.html'>50 simple
steps</a>, of course):</p><ol><li>Use the <a href='https://us-east-1.console.aws.amazon.com/acm/home?region=us-east-1#/welcome'>AWS Certificate Manager
   console</a>   (in the us-east-1 region; this is extremely important!) to create a   certificate containing domain names <code>jmglov.net</code> and <code>www.jmglov.net</code>, using   DNS validation since I host my own domain using Route 53.</li><li>Open the hosted zone for <code>jmglov.net</code> in the <a href='https://us-east-1.console.aws.amazon.com/route53/v2/hostedzones#'>Route 53
   console</a>   and manually add the CNAME records displayed on the ACM certificate (for some   reason, the <strong>Create records in Route 53</strong> button in ACM didn't work for me,   but whatevs).</li><li>Use the <a href='https://us-east-1.console.aws.amazon.com/cloudfront/v3/home?region=eu-west-1#/'>CloudFront
   console</a>   to create a distribuion, setting the origin to   <code>jmglov.net.s3-website-eu-west-1.amazonaws.com</code>, adding <code>jmglov.net</code> and   <code>www.jmglov.net</code> as alternate domain names, choosing the ACM certificate   that I just created as the custom SSL certificate, and setting HTTP to   redirect to HTTPS.</li><li>Wait for the CloudFront distribution to deploy. You can test this by making   sure the S3 hosted site loads when you browse to the distribution domain name   that CloudFront creates for you (in my case,   https://d3bulohh9org4y.cloudfront.net).</li><li>Once the website loads using the distribution, use the Route 53 console to   update the A records for <code>jmglov.net</code> and <code>www.jmglov.net</code> to alias my   CloudFront distribution instead of my S3 bucket.</li></ol><p>Et voilà!</p><p>Oh, and my website logs did of course start showing up in the S3 logs bucket I created, after some delay probably caused by buffering. I also configured my CloudFront distribution to log to the same place, and those logs are also showing up there.</p><h2 id="blambda%21">Blambda!</h2><p>I also created an AWS Lambda custom runtime for Babashka, which I have called <a href='https://github.com/jmglov/blambda'>Blambda!</a>, because I couldn't think of a good pun involving BBs and lambdas or something like that.</p><p>This was also quite easy to do, thanks to a <a href='https://github.com/tatut/bb-lambda'>bb-lambda</a> project that I found on Github that already took care of the heavy lifting of interacting with the Lambda runtime API to process function invocations.</p><p>I decided to create my own custom runtime instead of just straight up using bb-lambda because it uses <a href='https://www.docker.com/'>Docker</a>, which I hate and fear (I don't even know what a container is, much less why you should use it to implement a serverless function). Thanks to the borktacular <a href='https://github.com/borkdude'>borkdude</a> (<a href='https://ko-fi.com/borkdude'>toss him a few euros on
Ko-fi</a> if you can) and the fact that he builds a static executable for <a href='https://github.com/babashka/babashka/releases/tag/v0.8.156'>each borkin' release of
Babashka</a>, creating a lambda layer for a custom runtime is really easy!</p><h2 id="more_stuff_to_do">More stuff to do</h2><p>Things that are likely to happen:</p><ul><li>Open up my Guitar Hero controller, attempt to fix it, don't succeed, but also  don't make things any worse.</li><li>Play some Civ V. I had <a href='2022-06-25-midsommar.html'>planned to do this
  yesterday</a>, but ran out of time because I ended up  going over to Simon and Pippa's for pizza (red peppers and aubergine /  eggplant, thanks for asking!). It is imperative that I do this today, because  those Koreans and Ottomans aren't going to defeat themselves. (Unless of  course they turn on each other, which would be totally awesome!)</li><li>Add some cool stuff to Blambda! to make it easier to deploy functions and the  like.</li><li>Dig into  <a href='https://open.spotify.com/episode/4TPwgRZTOsHPGXuVwJQyHd'>REPL-acement</a> with  Ray.</li><li>Learn what's so awesome about <a href='https://nixos.wiki/wiki/Flakes'>Nix flakes</a>.</li></ul><p>Things that may happen:</p><ul><li>Open up my Guitar Hero controller, attempt to fix it, and succeed!</li><li>Open up my Guitar Hero controller, attempt to fix it, and bollocks it all up  and then cry tears of great sadness and hit <a href='https://www.blocket.se/'>Blocket</a>  (kinda like a Swedish version of eBay or online garage sale thingy) to try to  find a new to me used one.</li></ul><p>Things that are unlikely to happen but really should:</p><ul><li>Learn Swedish.</li></ul>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-25-midsommar.html</id>
    <link href="https://jmglov.net/blog/2022-06-25-midsommar.html"/>
    <title>Midsommar</title>
    <updated>2022-06-25T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p><a href='https://en.wikipedia.org/w/index.php?curid=60104242'><img src="assets/midsommar.png" alt="Midsommar film poster" title= "By A24, Fair use, Wikipedia" /></a></p><p>Midsommar in Sweden means four things:</p><ol><li>Buggering off to your country house</li><li><a href='https://en.wikipedia.org/wiki/Midsummer#Sweden'>Making crowns of flowers and dancing around a
   maypole</a></li><li>Singing drinking songs and getting absolutely hammered on   <a href='https://en.wikipedia.org/wiki/Akvavit'>aquavit</a></li><li>Taking off from work until mid-August</li></ol><p>I did none of those things yesterday, iconoclast that I am. Instead, I slept off a slight hangover that I incurred the night before whilst hanging out with the Duskstalkers (one of my old teams at work) and celebrating Kim's new job (Staff Engineer, well done Kim-tacular!) and impending birthday. After rolling out of bed around 9:00, I took Rover out for his morning walk and then had a light breakfast whilst losing a few games of <a href='https://dominion.games/'>Dominion
online</a>, then did some light housework, and rewarded myself by plopping down in front of my son's computer and playing about 4 hours of Civ V!</p><p>At some point, my mate Simon (of Guitar Hero fame) texted to invite me over to his place for dinner, so Rover and I buggered off over there (not a country house, sadly) to enjoy not making crowns of flowers and dancing around a maypole but instead making a barbeque with Simon, and getting absolutely hammered on prosecco (not aquavit) whilst listening to Spotify. So we had a lovely evening, despite failing at midsommar-hood. I am taking off from work until September, though, so I guess I won't have my Swedish passport revoked for nonconformity.</p><p>Simon and his partner Pippa are tremendous fun and really smart people who have informed opinions on pretty much any topic, so it's always fun to have a few drinks with them and solve the world's problems. The only downside is that we don't necessarily remember all the solutions the next day, and I guess most of the solutions depend on people consulting us before doing things, which tragically I've been unable to convince people to do, even though obviously my superior judgement would have helped us as a species avoid the various sticky situations we now find ourselves in.</p><p>Since there was no designated driver on hand, Rover and I spent the night, then had a nice late breakfast and a dip in Lake Mälaren before coming home. And now here I sit, sharing my non-traditional yet lovely midsommar evening with all of you.</p><p>Now it's time to either finally <a href='2022-06-24-s3-https.html'>get HTTPS working on my
blog</a> or maybe just play some Civ V (I'm playing as Thailand, and having just taught the Mongols and the Germans some good manners, I'm swallowing hard as the Koreans and Ottomans have turned on me and unleashed a huge expeditionary force).</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-24-s3-https.html</id>
    <link href="https://jmglov.net/blog/2022-06-24-s3-https.html"/>
    <title>Using HTTPS with S3 static website hosting in 50 simple steps</title>
    <updated>2022-06-24T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I run this site on AWS S3, using static website hosting. In terms of simplicity, it's hard to beat: just toss your stuff in an S3 bucket, make sure the Content-type metadata is correct, and off you go. However, it doesn't support HTTPS by default. My site doesn't have anything on it that really <strong>requires</strong> end-to-end encryption, but a professional programmer who has been deep into AWS for the past 10 years or so having an HTTP-only site in 2022 is tacky and embarrassing. So today we shall remedy this, in 50 simple steps!</p><ol><li>Apply to <a href='https://www.wm.edu/'>The College of William and Mary</a> in Virginia.</li><li>Drink 18 cups of coffee one night at a jazz club.</li><li>Try to go to work the next day but then have to come home because you feel   like shit warmed over.</li><li>Curl up on the couch and try to sleep.</li><li>Get woken up when the mail carrier slides a fat envelope through the mail   slot in your apartment door.</li><li>See that the envelope is from The College of William and Mary in Virginia.</li><li>Years later, write a blog post on how to enable HTTPS for your S3 website in   which you mention The College of William and Mary in Virginia a lot, and   realise that you really should explain to your readers that whilst The   College of William and Mary in Virginia currently writes its name as "William   & Mary", its proper name is in fact The College of William and Mary in   Virginia, unless they've changed it at some point since you went there. It's   kinda like Ohio State: if you didn't go there, you call it Ohio State, but   actual Buckeyes will invariably remind you that it is really called The Ohio   State University.</li><li>Rip open that envelope with trembling hands (coffee hangover or excitement:   you decide).</li><li>Read the world "congratulations" and, overcome by joy, pass out on the couch.</li><li>Get assigned an email address by W&M (haha! another way to write it!) which    is the first letter of your first name, the first letter of your middle name    (or "x" if you don't have a middle name, if you recall correctly), and the    first four letters of your surname (if your surname is fewer than four    letters, you honestly can't remember what W&M would make of that).</li><li>Decide that email address is pretty dope.</li><li>Enroll in Computer Science 101 and find out that your Unix username is the    same as your email address, just without the "@wm.edu" bit on the end.</li><li>Some years later, register a domain with your dope-ass Unix username.</li><li>Create a primitive website and serve it off Apache on this old computer that    you keep under your desk in Columbus, Ohio whilst your wife gets a master's    degree in Japanese language and pedagogy.</li><li>Have intermittent fights with Apache because it can be a real PITA to    configure sometimes.</li><li>Get an SSL cert from something similar to <a href='https://letsencrypt.org/'>Let's
    Encrypt</a> that you forget the name of, but then    remember that you must still have an account there because you're a trained    assurer, so look it up in your encrypted password file that you started    sometime back in the very late 90s.</li><li>Discover that it is called <a href='http://www.cacert.org/'>CAcert</a>, that it still    exists, and that it is still not included in the trusted certificate    authorities that ship with Firefox.</li><li>Enable <code>mod&#95;ssl</code> on Apache and rejoice in the "s" that you now get to add    before the ":" when you type "http://" to visit your website!</li><li>Ask your friend Adrian to host your domain and website and mailserver for    you because you're moving to Japan because your wife is super smart and got    a scholarship to this intensive Japanese language study programme and so you    need to ditch your tower computers and buy a laptop instead.</li><li>Forget about your website for many many years.</li><li>See that AWS has added a static website hosting feature to S3.</li><li>Point your domain at it.</li><li>Realise at some point that <code>https://</code> doesn't work no more.</li><li>Cry bitter tears but then get over it.</li><li>Transfer your domain to <a href='https://aws.amazon.com/route53/'>AWS Route 53</a> at    some point.</li><li>Remember this whole HTTPS thing again and become embarrassed enough to do    something about it.</li><li>Try to get it working through some ACM witchcraft, but then get quite    frustrated for some reason and ragequit.</li><li>Don't think about it for many years.</li><li>Go through the process of <a href='http://jmglov.net/blog/2022-06-17-creating-a-blog-with-clojure.html'>Creating a blog with Clojure in 50 simple
    steps</a>.</li><li>Proudly post a link to your blog.</li><li>Get super embarrassed when your friend Thomas DMs you on Twitter to tell you    to sort your shit out vis-à-vis HTTPS because c'mon, person!</li><li>Wait for your friend Plínio to offer to share his technique with you.</li><li>Mix up some accidentally double-strength margaritas for yourself and your    friend Simon and then play some Guitar Hero all night.</li><li>Start watching "Star Trek: Generations" after Simon leaves for home.</li><li>Send a drunk text to your mean but cool friend Sen to tell her that you're    watching "Generations" and she can suck it.</li><li>Send a drunk WhatsApp voice message to your friends Micheleangelo and Tane    telling them how great they are.</li><li>Wake up at 09:30 the next morning with a slight headache and some serious    cotton mouth.</li><li>Take the poor patient doggy out for a walk.</li><li>Tell your friend Ray about your tequila measurement issue.</li><li>Make a pot of coffee and an enormous greasy breakfast.</li><li>Sit down at your computer to write.</li><li>Realise that you could probably get HTTPS working for your website.</li><li>Write 43 steps for how to do it before you actually get around to so much as    opening the link that Thomas sent you because Plínio is just a tease and    hasn't yet shared the good stuff with you. C'mon, Plínio, puff puff pass    already, brah!</li><li>Pop over to the <a href='https://us-east-1.console.aws.amazon.com/acm/home?region=us-east-1#/welcome'>ACM
    console</a>    to register a cert.</li><li>Realise that you might be getting ahead of yourself and open the <a href='https://docs.aws.amazon.com/AmazonS3/latest/userguide/website-hosting-custom-domain-walkthrough.html'>Configuring
    a static website using a custom domain registered with Route
    53</a>    page that Thomas sent you first so you don't take any missteps.</li><li>Create a bucket to hold your access logs, because that seems like a good    idea.</li><li>Enable server access logging in your root domain bucket.</li><li>Refresh a page on your website and then excitedly check your logs bucket and    get disappointed when you don't see anything there. Shrug your shoulders and    assume that there's some buffering happening, so something will probably    show up there sooner or later.</li><li>Read a little note on the AWS page:<blockquote><p> <strong>Note</strong> </p><p> Amazon S3 does not support HTTPS access to the website. If you want to use HTTPS, you can use Amazon CloudFront to serve a static website hosted on Amazon S3. </p><p> For more information, see <a href='https://aws.amazon.com/premiumsupport/knowledge-center/cloudfront-serve-static-website/'>How do I use CloudFront to serve a static website
hosted on Amazon
S3?</a> and <a href='https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-https-viewers-to-cloudfront.html'>Requiring HTTPS for communication between viewers and
CloudFront</a>. </li><li>Start to say "well, duh", but then remember that "duh" is an ableist word,    so say "uh, yeah" instead because it is one of the helpful alternatives     provided by the super awesome <a href='https://twitter.com/autistichoya'>Lydia X. Z.
    Brown</a> on the super awesome <a href='https://www.autistichoya.com/p/ableist-words-and-terms-to-avoid.html'>autistichoya
    blog</a>.     Start to move onto the next step but then realise that you're already on     step 50 and thus you now have a conundrum: do you <ul><li>Just add another step, even though you've already titled this piece "Using      HTTPS with S3 static website hosting in 50 simple steps" and the previous       <a href='http://jmglov.net/blog/2022-06-17-creating-a-blog-with-clojure.html'>two</a>       <a href='http://jmglov.net/blog/2022-06-20-installing-steam-on-nixos.html'>posts</a>       in this format have been exactly 50 steps each, and that's kinda the point       of a format: sticking to it? </li><li>Cheat by using a bullet list within step 50?</li><li>Realise that it's late in the day and you really need to take the dog out      before you walk over to the vet to get her to sign one place on your dog's       doggy passport that she forgot to yesterday but was nice enough to call       you about 30 minutes ago and ask you to check because she wasn't sure she       had signed everywhere and also your friend Tim has posted chapter 2 of       "<a href='https://7amkickoff.com/index.php/2022/06/23/story-of-a-mediocre-fan-chapter-2/'>Story of a mediocre
      fan</a>"       over on 7amkickoff, so you don't actually have to post this piece today       anyway, so you can actually stop writing and finish this stuff up       tomorrow? </li></ul></li></ol></p></blockquote><p>Which to choose, which to choose?</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-23-story-of-a-mediocre-fan-2.html</id>
    <link href="https://jmglov.net/blog/2022-06-23-story-of-a-mediocre-fan-2.html"/>
    <title>Story of a mediocre fan: chapter 2</title>
    <updated>2022-06-23T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I finished up chapter 2 of "Story of a mediocre fan" yesterday, but wasn't 100% sure that Tim was planning to post it today, so I figured that I'd better write a piece for today just in case, because it would be terrible to break my streak on a technicality. I took a look at <a href='http://jmglov.net/blog/2022-06-21-todo-list.html'>the old todo
list</a>, and saw that I intended to enable HTTPS for my blog, so I might as well write about that, in my now legendary "50 simple steps" format (I wanted to link to the "50 simple steps" entries in my archive, but I dunno if the static site generator I'm using supports that, and I'm short on time for reasons that shall become evident tomorrow, so I won't <a href='https://twitter.com/vanweringh/status/434264706988011520/photo/1'>UtSL</a> at the moment&ndash;damn, another entry for the todo list!), but then when I reached step 50, I saw that Tim had just tweeted out the link to chapter 2 on his blog, so I abandoned all hope ye who here enter on the HTTPS stuff and wrote this little post instead so that I can link to the piece that counts as my writing for the day but was actually written over the past week or so (shh, don't tell anyone!).</p><p>The link, by the way, is in the first sentence, so click it and stop reading this silliness before the end of this sentence which I will end soon but I want to give you a little time to click the link before I reach the end of the sentence which is ending in 5... 4... 3... 2... 1... NOW!!!</p><p>You can read "Story of a mediocre fan" over on 7amkickoff:</p><ul><li><a href='https://7amkickoff.com/index.php/2022/06/16/story-of-a-mediocre-fan/'>Chapter
  1</a></li><li><a href='https://7amkickoff.com/index.php/2022/06/23/story-of-a-mediocre-fan-chapter-2/'>Chapter
  2</a></li><li>Chapter 3 - coming on Thursday, 30 June</li></ul>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-22-about-to-rock.html</id>
    <link href="https://jmglov.net/blog/2022-06-22-about-to-rock.html"/>
    <title>Those about to rock</title>
    <updated>2022-06-22T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I'm getting toward the end of another eventful day. I woke up around 8:00, which is quite an achievment for me, since the sun is currently rising here in Sweden at 3:30 AM and my bedroom window faces almost directly northeast. I usually wake up around 4:00 AM and then struggle to go back to sleep for another hour and a half, but this morning, I didn't wake until 5:30, and got <strong>two</strong> and a half more hours!</p><p>Rested and ready for the day, I made a pot of coffee and then plopped down on the sofa to play some <a href='https://dominion.games/'>Dominion</a> online. I followed that up with a healthy breakfast, then took the dog out for a long walk. Upon my return, I sat down at the computer and finished chapter 2 of "<a href='https://7amkickoff.com/index.php/2022/06/16/story-of-a-mediocre-fan/'>Story of a mediocre
fan</a>", which should hopefully be going up on 7amkickoff tomorrow. Then it was time to take Rover to the veterinarian to get his doggy passport. Betcha didn't know they had those, did you? Rover was a very good boy even though he had to get his rabies vaccine booster, so he was rewarded quite generously with treats. I'd actually love if someone gave me a bunch of yummy snacks after I had to have a blood test or something else scary like that. I'll file that in my startup ideas pile: treats for brave humans. But digital somehow. Maybe some sort of non-fungible treat?</p><p>What's in store for tonight, though, puts all of this to shame. My friend Simon is coming over for some Guitar Hero. Our epic rock session last night just whetted our appetites for face-melting solos, so we're back at it again tonight. I didn't have time to make guacamole yesterday, so I'm doing it today, along with those black bean burritos that I was planning yesterday. Add a pitcher of margaritas and two plastic guitars and you have yourself a party!</p><p>So for those about to rock, <a href='https://youtu.be/8fPf6L0XNvM'>we salute you</a>! Now I'm off to the store to buy some limes and stuff.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-21-todo-list.html</id>
    <link href="https://jmglov.net/blog/2022-06-21-todo-list.html"/>
    <title>The old todo list</title>
    <updated>2022-06-21T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I'm getting a late start with the old blogging today, so I'll make do with a todo list for the summer.</p><p>Things that will definitely happen:</p><ul><li>Take a walk over to a nearby cafe with my friend Esther to have some  <a href='https://en.wikipedia.org/wiki/Coffee_culture#Sweden'>fika</a>. This is  definitely going to happen, as Esther is Dutch and therefore has made an entry  in her diary and entries in Dutch diaries are non-negotiable. Also, this event  will take place in 15 minutes.</li><li>Stop by the store on the way back from fika and buy a few ingredients for  making guacamole and <a href='https://en.wikipedia.org/wiki/Harissa'>harissa</a>. No, I  will not be eating both of this things together. Unless I decide to put  harissa on my black bean burrito that I'm going to also put guacamole on,  which actually sounds kinda good now that I say it out loud. I'll let you know  how this crime against food turns out.</li><li>Walk my dog. This will definitely happen at least twice a day, because  otherwise Rover gets very sad because he still hasn't learned to use the big  boy potty. Also he needs exercise.</li><li>Play some <a href='https://en.wikipedia.org/wiki/Guitar_Hero:_Warriors_of_Rock'>Guitar Hero: Warriors of
  Rock</a> with my  friend Simon. This will involve drinking beer and eating some chips with  guacamole, as long as I don't eat all of the guacamole on my burrito like a  greedy bastard. Headbanging, throwing horns, and "the man stance" are also  likely to feature.  </li></ul><p>Things that are likely to happen:</p><ul><li>Create an AWS Lambda custom runtime for  <a href='https://github.com/babashka/babashka'>Babashka</a>. I want to do this so I can  get blazing fast startup times for my lambda functions AND be able to edit the  source in the lambda console AND not be subjected to NodeJS in any way, shape,  or form. <a href='https://clojurescript.org/'>ClojureScript</a> is a wonderful wonderful  thing, but to <a href='https://news.ycombinator.com/item?id=4013118'>mangle the words</a>  of the great Rich Hickey, "Clojure rocks, Node reeks". Ray and I are in  violent disagreement about this, but Ray is wrong and can therefore suck it.</li><li>Enable HTTPS for my blog, because http://jmglov.net/blog/ is just embarassing.  I use S3 static website hosting, and I know there's a way to use CloudFront  plus an ACM (no, not <a href='https://www.acm.org/'>that ACM</a>, <a href='https://aws.amazon.com/certificate-manager/'>this
  one</a>) cert to do this, but I'm  pretty sure it will require a full day, mystic chanting, and the sacrifice of  a monitor or two (when I throw it across the room out of frustration).</li><li>Dig into  <a href='https://open.spotify.com/episode/4TPwgRZTOsHPGXuVwJQyHd'>REPL-acement</a> with  Ray.</li><li>Learn what's so awesome about <a href='https://nixos.wiki/wiki/Flakes'>Nix flakes</a>.</li></ul><p>Things that are unlikely to happen but really should:</p><ul><li>Learn Swedish.</li></ul><p>OK, I need to run because otherwise I'm gonna be late for fika, and that just will not do.</p><p><strong>Update:</strong> <a href='2022-06-26-loose-ends.md'>I actually did some of these things</a>!</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-20-installing-steam-on-nixos.html</id>
    <link href="https://jmglov.net/blog/2022-06-20-installing-steam-on-nixos.html"/>
    <title>Installing Steam on NixOS in 50 simple steps</title>
    <updated>2022-06-20T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I just dropped my partner and my son off at the airport, so the dog and I are on our own for the next 10 days. This will give me plenty of time to do some reading (finishing up "<a href='https://www.goodreads.com/book/show/29581.Foundation_and_Empire'>Foundation and
Empire</a>" then starting on a Nixon biography that my friend Simon loaded me), some writing (part 2 of "<a href='https://7amkickoff.com/index.php/2022/06/16/story-of-a-mediocre-fan/'>Story of a mediocre
fan</a>" as well as on this here blog), and some exercising (tennis, running, swimming&ndash;that is as soon as my bloody rib stops hurting). Ah, improving the mind and body!</p><p>So naturally, as I'm eating breakfast, I get the bright idea to install Steam so I can play some <a href='https://store.steampowered.com/app/8930/Sid_Meiers_Civilization_V/'>Civ
V</a>. And since I'm on NixOS, this is gonna be easy, right?</p><p>Right?</p><p>At least I had the discipline to start my blog entry <strong>before</strong> trying to install Steam.</p><p>But here we go, installing Steam on NixOS in 50 simple steps!</p><ol><li>See if there's already a package for Steam:<pre><code>   &#91;jmglov@laurana:&#126;&#93;$ nix repl '&lt;nixpkgs&gt;'
   Welcome to Nix 2.8.1. Type :? for help.
   
   Loading '&lt;nixpkgs&gt;'...
   Added 16468 variables.

   nix-repl&gt; steam
   error: Package ‘steam’ in /nix/var/nix/profiles/per-user/root/channels/nixos/pkgs/games/steam/steam.nix:43 has an unfree license &#40;‘unfreeRedistributable’&#41;, refusing to evaluate.
   </code></pre>   Hell yeah! Don't worry about that unfree stuff; you've already got that   sorted in your <code>home.nix</code>:<pre class="language-nix"><code class="lang-nix language-nix">   nixpkgs.config.allowUnfree = true;
   </code></pre></li><li>Before naively adding <code>steam</code> to <code>home.packages</code>, do a little searching and   find an <a href='https://nixos.wiki/wiki/Steam'>entry in the NixOS Wiki for Steam</a>!</li><li>Naively copy and paste the incantation from that page into <code>home.nix</code>:<pre class="language-nix"><code class="lang-nix language-nix">   programs.steam = {
     enable = true;
     remotePlay.openFirewall = true; # Open ports in the firewall for Steam Remote Play
     dedicatedServer.openFirewall = true; # Open ports in the firewall for Source Dedicated Server
   };
   </code></pre></li><li>Try it out!<pre><code>   &#91;jmglov@laurana:/etc/nixos/jmglov&#93;$ sudo nixos-rebuild switch
   building Nix...
   building the system configuration...
   error: The option `home-manager.users.jmglov.programs.steam' does not exist. Definition values:
          - In `/etc/nixos/configuration.nix':
              {
                dedicatedServer = {
                  openFirewall = true;
                };
                enable = true;
              ...
   &#40;use '--show-trace' to show detailed location information&#41;
   </code></pre>   Oh you poor sweet child, thinking things would be that simple.</li><li>Search for "nix home-manager steam" and find a nice blog entry: "<a href='https://linuxhint.com/how-to-instal-steam-on-nixos/'>How to
   Install Steam on
   NixOS?</a>". Despite this   blog entry not containing the word "home-manager" (wtf, Google? also, why   have you not configured Firefox to use DuckDuckGo?)...</li><li>Get distracted by this aside and configure Firefox to use   <a href='https://duckduckgo.com/'>DuckDuckGo</a> as your primary search engine to keep   them Google cookies from leaving crumbs all over the internet.</li><li>Remember that oh yeah, you were reading some blog entry on installing Steam   on Nix.</li><li>Despite this blog entry not containing the word "home-manager", it does make   it obvious that <code>programs.steam</code> is a NixOS module, not a <a href='https://nixos.wiki/wiki/Home_Manager'>Home
   Manager</a> one. Grumble about having to   install Steam system-wide, but then give in and move the Steam incantation to   your <code>/etc/nixos/configuration.nix</code>, then rebuild NixOS:<pre><code>   &#91;jmglov@laurana:/etc/nixos/jmglov&#93;$ sudo nixos-rebuild switch
   building Nix...
   building the system configuration...
   these 47 derivations will be built:
     /nix/store/0656idzgvxd6lsj5awhrami3wm66y1jk-ldconfig.drv
     /nix/store/i99alh7g9xqb0dys5w8fklld15y2jjf9-steam-wrapper.sh.drv
     /nix/store/0r8vhys80ipdspjch9l71ngddk62gws8-steam-init.drv
     /nix/store/kik59lly3njfwdqni1vnhvgk1kmnsava-steam&#95;1.0.0.74.tar.gz.drv
     /nix/store/d9kgzf4khzvlzr1isbx49665b8xm2kma-steam-original-1.0.0.74.drv
     # ...
   </code></pre></li><li>Wait for awhile for Nix to download some paths from the cache and then   compile some C (don't worry about all those gcc warnings; real C programmers   know what they're doing, damnit!), but then finally read these sweet sweet   words:<pre><code>   building '/nix/store/6mzdfasrx16l5zbdks6qpn98fbgyvw15-nixos-system-laurana-22.05.751.8b66e3f2ebc.drv'...
   updating GRUB 2 menu...
   stopping the following units: accounts-daemon.service, systemd-modules-load.service, systemd-udevd-control.socket, systemd-udevd-kernel.socket, systemd-udevd.service
   NOT restarting the following changed units: systemd-fsck@dev-disk-by\x2duuid-1495\x2d2D9B.service
   activating the configuration...
   setting up /etc...
   reloading user units for jmglov...
   setting up tmpfiles
   reloading the following units: dbus.service, firewall.service
   restarting the following units: polkit.service
   starting the following units: accounts-daemon.service, systemd-modules-load.service, systemd-udevd-control.socket, systemd-udevd-kernel.socket
   </code></pre></li><li>Revel in the fact that you have installed some stuff, then grit your teeth    and try to login to Steam:<pre><code>    &#91;jmglov@laurana:/etc/nixos/jmglov&#93;$ steamcmd
    steamcmd: command not found
    </code></pre></li><li>Oh ffs! Apparently you forgot to install steamcmd. OK, no matter, you can    just add that to your <code>home.nix</code> (might as well install <code>steam-tui</code>, which    you assume is some sort of UI?):<pre class="language-nix"><code class="lang-nix language-nix">    home.packages = with pkgs; &#91;
      # ...
      steam-tui
      steamcmd
      # ...
    &#93;;
    </code></pre></li><li>Run <code>sudo nixos-rebuild switch</code> to apply the changes, then quickly leave the    room so you don't have to watch gcc warnings scroll by.</li><li>Start a load of laundry.</li><li>Feel like a responsible steward of the household!</li><li>Remember to give the dog his arthritis medication and feel even prouder of    yourself.</li><li>Become briefly overwhelmed by melancholy at the thought of your dog's    mortality.</li><li>Remember that you were in the process of installing Steam on NixOS and    liveblogging the whole thing like a boss!</li><li>Come back to your computer and see that not only has stuff been installed,    but there were also no gcc warnings, so you could have just sat here and    missed out on that whole emotional roller coaster.</li><li>Try <code>steamcmd</code> again:<pre><code>    &#91;jmglov@laurana:/etc/nixos/jmglov&#93;$ steamcmd 
    bwrap: Can't chdir to /etc/nixos/jmglov: No such file or directory
    </code></pre>    WTF? Who told you to change to that directory, <code>steamcmd</code>?</li><li>Have a look at the <code>steamcmd</code> script to see what in the ever-loving frack is    going on around here:<pre class="language-bash"><code class="lang-bash language-bash">    &#91;jmglov@laurana:/etc/nixos/jmglov&#93;$ cat $&#40;which steamcmd&#41;
    #!/nix/store/40iwnlr30ykqm5ynm0bbk6bsjjc750ad-bash-5.1-p16/bin/bash -e
    
    # Always run steamcmd in the user's Steam root.
    STEAMROOT=&#126;/.local/share/Steam
    
    # Add coreutils to PATH for mkdir, ln and cp used below
    PATH=$PATH${PATH:+:}/nix/store/2zxip96ccjx0nw24kfpjq3wl7kcx6035-coreutils-9.0/bin
    
    # Create a facsimile Steam root if it doesn't exist.
    if &#91; ! -e &quot;$STEAMROOT&quot; &#93;; then
      mkdir -p &quot;$STEAMROOT&quot;/{appcache,config,logs,Steamapps/common}
      mkdir -p &#126;/.steam
      ln -sf &quot;$STEAMROOT&quot; &#126;/.steam/root
      ln -sf &quot;$STEAMROOT&quot; &#126;/.steam/steam
    fi
    
    # Copy the system steamcmd install to the Steam root. If we don't do
    # this, steamcmd assumes the path to `steamcmd` is the Steam root.
    # Note that symlinks don't work here.
    if &#91; ! -e &quot;$STEAMROOT/steamcmd.sh&quot; &#93;; then
      mkdir -p &quot;$STEAMROOT/linux32&quot;
      # steamcmd.sh will replace these on first use
      cp /nix/store/4qbs47jafxn30v18kx6z8584s44g7djk-steamcmd-20180104/share/steamcmd/steamcmd.sh &quot;$STEAMROOT/.&quot;
      cp /nix/store/4qbs47jafxn30v18kx6z8584s44g7djk-steamcmd-20180104/share/steamcmd/linux32/&#42; &quot;$STEAMROOT/linux32/.&quot;
    fi
    
    /nix/store/vp2njgh5r7j7vs8691bp87apii754mna-steam-run/bin/steam-run &quot;$STEAMROOT/steamcmd.sh&quot; &quot;$@&quot;
</code></pre></li><li>Check out <code>$STEAMROOT</code> to see what's up:<pre><code>    &#91;jmglov@laurana:/etc/nixos/jmglov&#93;$ find &#126;/.local/share/Steam/
    /home/jmglov/.local/share/Steam/
    /home/jmglov/.local/share/Steam/steamcmd.sh
    /home/jmglov/.local/share/Steam/logs
    /home/jmglov/.local/share/Steam/appcache
    /home/jmglov/.local/share/Steam/linux32
    /home/jmglov/.local/share/Steam/linux32/libstdc++.so.6
    /home/jmglov/.local/share/Steam/linux32/crashhandler.so
    /home/jmglov/.local/share/Steam/linux32/steamerrorreporter
    /home/jmglov/.local/share/Steam/linux32/steamcmd
    /home/jmglov/.local/share/Steam/config
    /home/jmglov/.local/share/Steam/Steamapps
    /home/jmglov/.local/share/Steam/Steamapps/common
    </code></pre></li><li>Scratch your cheek in that way people do that signifies deep puzzlement.</li><li>Have that "aha!" moment as you realise that obviously something weird is    happening in that <code>steamcmd.sh</code> script and then check it out:<pre class="language-bash"><code class="lang-bash language-bash">    &#91;jmglov@laurana:/etc/nixos/jmglov&#93;$ cat /home/jmglov/.local/share/Steam/steamcmd.sh
    #!/nix/store/40iwnlr30ykqm5ynm0bbk6bsjjc750ad-bash-5.1-p16/bin/bash
    
    STEAMROOT=&quot;$&#40;cd &quot;${0%/&#42;}&quot; &amp;&amp; echo $PWD&#41;&quot;
    STEAMCMD=`basename &quot;$0&quot; .sh`
    
    UNAME=`uname`
    if &#91; &quot;$UNAME&quot; == &quot;Linux&quot; &#93;; then
      STEAMEXE=&quot;${STEAMROOT}/linux32/${STEAMCMD}&quot;
      PLATFORM=&quot;linux32&quot;
      export LD&#95;LIBRARY&#95;PATH=&quot;$STEAMROOT/$PLATFORM:$LD&#95;LIBRARY&#95;PATH&quot;
    else # if &#91; &quot;$UNAME&quot; == &quot;Darwin&quot; &#93;; then
      STEAMEXE=&quot;${STEAMROOT}/${STEAMCMD}&quot;
      if &#91; ! -x ${STEAMEXE} &#93;; then
        STEAMEXE=&quot;${STEAMROOT}/Steam.AppBundle/Steam/Contents/MacOS/${STEAMCMD}&quot;
      fi
      export DYLD&#95;LIBRARY&#95;PATH=&quot;$STEAMROOT:$DYLD&#95;LIBRARY&#95;PATH&quot;
      export DYLD&#95;FRAMEWORK&#95;PATH=&quot;$STEAMROOT:$DYLD&#95;FRAMEWORK&#95;PATH&quot;
    fi
    
    ulimit -n 2048
    
    MAGIC&#95;RESTART&#95;EXITCODE=42
    
    if &#91; &quot;$DEBUGGER&quot; == &quot;gdb&quot; &#93; || &#91; &quot;$DEBUGGER&quot; == &quot;cgdb&quot; &#93;; then
      ARGSFILE=$&#40;mktemp $USER.steam.gdb.XXXX&#41;
    
      # Set the LD&#95;PRELOAD varname in the debugger, and unset the global version.
      if &#91; &quot;$LD&#95;PRELOAD&quot; &#93;; then
        echo set env LD&#95;PRELOAD=$LD&#95;PRELOAD &gt;&gt; &quot;$ARGSFILE&quot;
        echo show env LD&#95;PRELOAD &gt;&gt; &quot;$ARGSFILE&quot;
        unset LD&#95;PRELOAD
      fi
    
      $DEBUGGER -x &quot;$ARGSFILE&quot; &quot;$STEAMEXE&quot; &quot;$@&quot;
      rm &quot;$ARGSFILE&quot;
    else
      $DEBUGGER &quot;$STEAMEXE&quot; &quot;$@&quot;
    fi
    
    STATUS=$?
    
    if &#91; $STATUS -eq $MAGIC&#95;RESTART&#95;EXITCODE &#93;; then
        exec &quot;$0&quot; &quot;$@&quot;
    fi
    exit $STATUS
    </code></pre></li><li>Spend some time scratching your head, because that cheek thing apparently    didn't cut it.</li><li>Have a look at <code>/run/current-system/sw/bin/steam-run</code>, then scratch your    head some more.</li><li>Realise that <code>/etc/nixos/jmglov</code> is where you keep your Home Manager config,    then guess that since you're installing Steam as an os-wide package, maybe    you should do the same with <code>steamcmd</code> and <code>steam-tui</code>.</li><li>Realise that blergh! the channel being used by Home Manager is probably not    the same one being used by NixOS.</li><li>Confirm this:<pre><code>    &#91;jmglov@laurana:/etc/nixos/jmglov&#93;$ nix-channel --list
    home-manager https://github.com/nix-community/home-manager/archive/release-21.05.tar.gz
    nixos https://nixos.org/channels/nixos-21.11
    unstable https://nixos.org/channels/nixpkgs-unstable
    </code></pre></li><li>Swear in whatever way you see fit.</li><li>Promise yourself to upgrade Home Manager to 21.11 someday (but not this    day).</li><li>Move the bloody packages to bloody <code>/etc/nixos/configuration.nix</code> and `sudo    nixos-rebuild switch` again.</li><li>Hold your breath and try <code>steamcmd</code> again:<pre><code>    &#91;jmglov@laurana:/etc/nixos/jmglov&#93;$ steamcmd 
    bwrap: Can't chdir to /etc/nixos/jmglov: No such file or directory
    </code></pre></li><li>Swear at greater length than you previously did.</li><li>Realise something about your prompt and slowly turn red with embarrassment.</li><li>Type a command so your readers can see why you're embarrassed if they didn't    already catch it somewhere around step 19:<pre><code>    &#91;jmglov@laurana:/etc/nixos/jmglov&#93;$ pwd
    /etc/nixos/jmglov
    </code></pre></li><li>Change to your actual home directory obv.</li><li>Run <code>steamcmd</code> and watch as stuff actually happens!<pre><code>    &#91;jmglov@laurana:&#126;&#93;$ steamcmd 
    Redirecting stderr to '/home/jmglov/.local/share/Steam/logs/stderr.txt'
    ILocalize::AddFile&#40;&#41; failed to load file &quot;public/steambootstrapper&#95;english.txt&quot;.
    &#91;  0%&#93; Checking for available update...
    &#91;----&#93; Downloading update &#40;0 of 54,952 KB&#41;...
    &#91;  0%&#93; Downloading update &#40;642 of 54,952 KB&#41;...
    &#91;  1%&#93; Downloading update &#40;1,326 of 54,952 KB&#41;...
    &#91;  2%&#93; Downloading update &#40;2,236 of 54,952 KB&#41;...
    ...
    &#91;----&#93; Installing update...
    &#91;----&#93; Cleaning up...
    &#91;----&#93; Update complete, launching Steamcmd...
    Redirecting stderr to '/home/jmglov/.local/share/Steam/logs/stderr.txt'
    &#91;  0%&#93; Checking for available updates...
    &#91;----&#93; Verifying installation...
    Steam Console Client &#40;c&#41; Valve Corporation - version 1654574676
    -- type 'quit' to exit --
    Loading Steam API...OK
    
    Steam&gt;
    </code></pre></li><li>Swear again, but this time in celebration of the great victory you just    achieved!</li><li>Login to Steam, as soon as you remember your username and password:<pre><code>    Steam&gt;login jmglov &quot;This is not actually a password; nice try!&quot;
    Logging in user 'jmglov' to Steam Public...
    This computer has not been authenticated for your account using Steam Guard.
    Please check your email for the message from Steam, and enter the Steam Guard
     code from that message.
    You can also enter this code at any time using 'set&#95;steam&#95;guard&#95;code'
     at the console.
    Steam Guard code:H4X0R                              
    OK
    Waiting for client config...OK
    Waiting for user info...OK
    </code></pre></li><li>Get distracted by an email from your new boss whilst you're looking for your    Steam Guard code. Reply, because that's your boss writing!</li><li>According to the <a href='https://nixos.wiki/wiki/Steam'>NixOS Wiki article</a>,    <code>steam-tui</code> "should start just fine" after logging into Steam using    <code>steamcmd</code>, so give it a whirl!</li><li>Realise that <code>steam-tui</code> obviously stands for Steam Terminal User Interface,    and get a little sad that you're not looking at the awesome Steam UI that    they have on standard Linux.</li><li>Press on and login.</li><li>OMG check out your games menu! They're all there (plus some ones that your    son apparently bought on your account; hrm, gonna have to talk to that dude    when he gets back from Bulgaria).</li><li>Realise that <code>steam-tui</code> won't let you select text on your terminal to paste    here, so you'll need to take a screenshot.</li><li>Research a screenshot utility, discover <a href='https://xfce.org/'>Xfce</a> comes with    one, and add some stuff to your <a href='https://i3wm.org/'>i3</a> config to bind the    appropriate keys:<pre><code>    # You gotta have screenshots!
    bindsym Print exec xfce4-screenshooter -f
    bindsym Shift+Print exec xfce4-screenshooter -r
    bindsym Control+Print exec xfce4-screenshooter -w
    </code></pre></li><li>Take a screenshot and put it in the appropriate place to show up on your    blog as soon as you fix the Markdown rendering of images.</li><li>Leave a placeholder here to display your games menu (and ask yourself if it    was really worth it): <img src="img/steam-tui.png" alt="List of Steam games" /></li><li>Try to download Sid Meier's Civilization V and tug on your chin in    puzzlement as nothing seems to happen. Oh wait! Now the state has changed    from "uninstalled" to "Update Required".</li><li>Realise it's nearly lunchtime and ragequit <code>steam-tui</code> and this blog post!</li></ol><h2 id="update_from_2022-07-09">Update from 2022-07-09</h2><p>It turns out that I was missing something incredibly simple. Thanks to an article entitled "<a href='https://linuxhint.com/how-to-instal-steam-on-nixos/'>How to Install Steam on
NixOS?</a>", I realised that the Steam module that I installed by adding <code>programs.steam.enable = true;</code> to my <code>/etc/nixos/configuration.nix</code> installed the <code>steam</code> executable, which is the official Steam client for Linux. Starting that (from my home directory, as I discovered in steps 19-36) updated the client to the latest version, then I could browse to my library and install Civ V. Hurrah!</p><p><img src="assets/2022-07-09-steam-library.png" alt="Screenshot of my Steam library, with Civ V finally downloading" title="Let's get it on!" width=800px /></p><p>There was of course one last hurdle before I could actually play. When I clicked the play button, nothing seemed to happen, until I looked at the terminal window where I had run <code>steam</code> and saw some helpful errors:</p><pre><code>/bin/sh\0-c\0/home/jmglov/.local/share/Steam/ubuntu12&#95;32/reaper SteamLaunch AppId=8930 -- '/home/jmglov/.local/share/Steam/steamapps/common/Sid Meier'\''s Civilization V/./Civ5XP'\0
Game process added : AppID 8930 &quot;/home/jmglov/.local/share/Steam/ubuntu12&#95;32/reaper SteamLaunch AppId=8930 -- '/home/jmglov/.local/share/Steam/steamapps/common/Sid Meier'\''s Civilization V/./Civ5XP'&quot;, ProcID 600547, IP 0.0.0.0:0
SpawnProcessInternal: chdir /home/jmglov/.local/share/Steam/steamapps/common/Sid Meier's Civilization V failed, errno 2
</code></pre><p>It turned out that there was both a <code>&#126;/.local/share/Steam/steamapps</code> and a <code>&#126;/.local/share/Steam/Steamapps</code> directory, and of course the Steam client was looking in little-s <code>steamapps</code>, and Civ V was installed in big-S <code>Steamapps</code>. 🤦🏼</p><p>Ah well, easy enough to fix:</p><pre><code>cd &#126;/.local/share/Steam/steamapps/common
ln -s ../../Steamapps/common/Sid\ Meier\'s\ Civilization\ V
</code></pre><p>And Robert is indeed your mother's brother.</p><p><img src="assets/2022-07-09-civ-v.png" alt="Screenshot of the Civ V opening screen, playing as Darius I of Persia" title="Civilise this!" width=800px /></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-19-actually-blogging-with-clojure.html</id>
    <link href="https://jmglov.net/blog/2022-06-19-actually-blogging-with-clojure.html"/>
    <title>Actually blogging with Clojure</title>
    <updated>2022-06-19T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>I admit that my previous post on <a href='2022-06-17-creating-a-blog-with-clojure.html'>how to create a blog with
Clojure</a> may have been slightly tongue in cheek. This one will be less so, since I've actually gotten stuff working. ;)</p><p>Assuming that you have <a href='https://github.com/babashka/babashka'>Babashka</a> installed (which should be really easy unless you sail into the perfect storm of NixOS + broken zlib), cloning <a href='https://github.com/borkdude/blog'>borkdude’s blog
repo</a> from Github should give you quite an easy start.</p><p>Here's all I needed to do after getting my Babashka issues sorted to make it my own:</p><ol><li>Clone the repo: <code>git clone git@github.com:borkdude/blog.git</code></li><li>Update <code>bb.edn</code> to change the <code>publish</code> task to do an <code>aws s3 sync</code> to the S3   bucket my website is hosted from instead of rsync-ing to borkdude's webserver.</li><li>Run <code>rm posts/&#42;.md</code> to get rid of all of borkdude's content.</li><li>Add a new <code>posts/2022-06-17-creating-a-blog-with-clojure.md</code> file and copy and   paste the content from my Medium blog post into it.</li><li>Update <code>posts.edn</code> to remove the metadata for borkdude's content and replace   it with my single post:<pre class="language-clojure"><code class="lang-clojure language-clojure">   {:title &quot;Creating a blog with Clojure in 50 simple steps&quot;
    :file &quot;2022-06-17-creating-a-blog-with-clojure.md&quot;
    :categories #{&quot;clojure&quot;}
    :date &quot;2022-06-17&quot;}
   </code></pre></li><li>Update <code>render.clj</code> and change <code>blog-root</code> and <code>atom-feed</code> to reflect that   this is my blog and not borkdude's.</li><li>Update <code>templates/base.html</code> to comment out the Github discussions link to   borkdude's blog (I should put that back in once I make my website repo public)   and replace his Twitter username with mine.</li><li>Open a pull request to <a href='https://github.com/ghoseb/planet.clojure'>Planet
   Clojure</a> to pick up   http://jmglov.net/blog/atom.xml instead of my Medium feed.</li><li>Run <code>bb publish</code>.</li><li>Check out http://jmglov.net/blog/ and rejoice!</li></ol><p>So it seems like having your kickass Clojure blog and eating it too only takes 10 easy steps, not 50.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-18-aberrant-poetry.html</id>
    <link href="https://jmglov.net/blog/2022-06-18-aberrant-poetry.html"/>
    <title>Aberrant poetry</title>
    <updated>2022-06-18T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>So far this summer, most of the stuff I’ve written has turned out to be stories from my childhood, even when that was perhaps not my intention when I started typing. <a href='2022-06-17-creating-a-blog-with-clojure.html'>Yesterday’s post</a> broke the mold a bit, of course, but today I want to smash it!</p><p>Let me start off by telling a story from my childhood (d’oh!) to set things up. In 7th grade, I started middle school. In Staunton, where I was living at the time, we had three elementary schools, but just one middle school. So you’d be cruising along from kindergarten through 6th grade, hanging with the friends from your extended neighbourhood that you’d known for years — with an occasional The New Kid thrown in from time to time (I got to be The New Kid midway through 5th grade) — when all of a sudden you were dumped into 7th grade at Shelburne Middle School with all these people from the other two elementary schools that you probably didn’t know unless you played Little League baseball or something (in Little League, when you signed up, they just dumped you in a random team, so you wouldn’t necessarily be with your best friends).</p><p>One of these randos who was dumped into Shelburne along with me was this dude named Adam Steele. Unlike my best friend Ryan, who was into classical music, video games, and touch football (the US kind of football, not soccer football); or my best friend Ian, who was into shoes, video games, and BB guns; Adam was into Dungeons & Dragons and books and girls (he was a good-looking popular kid). Like many best friends, Adam and I were enemies before we were friends (I had another best friend like this, but that’s a really sad story that I’m honestly not sure I’m up to telling).</p><p>You see, in our social studies class, we were learning about the US legal system (but not the part about it where it disproportionately convicts Black and brown folks, ‘cuz that would be a little too real for 7th grade, apparently), and to do this, the teacher set up a mock trial where Goldilocks was being charged with breaking and entering. Adam was the prosecutor, I was Baby Bear (star witness for the prosecution, doncha know), and Goldilocks was this girl named Laura that I had a huuuuuuge crush on my entire middle school and high school career (but of course didn’t do anything about, because I didn’t know how to indicate to her that I thought she was the best and I really liked her and I’d love to get some ice cream or watch a movie or go roller skating or whatever together — wow, it sounds so easy in retrospect).</p><p>When Adam called me as a witness, I followed along with his questions according to the outline he’d prepared and coached me on, giving all the right answers. Things were looking pretty grim for Goldilocks, until her defence attorney stepped up for the cross examination and plot twist! I folded under questioning and revealed that Goldilocks was a friend of mine and I had invited her over and forgotten to tell my parents and the whole thing was just a terrible misunderstanding. As you can imagine, a jury of Goldilocks’s peers found her not guilty, she went free, and Adam absolutely <strong>hated</strong> me for the next several months.</p><p>I don’t remember what it was that diminished his hatred to the point where we started talking to each other civilly at school, but we eventually became friends and started hanging out, and he introduced me to D&D and fantasy novels (up to that point, I was unaware that fantasy was a genre, and thought that “The Hobbit” and the “Lord of the Rings” trilogy were the only books like that, so I would read “The Hobbit”, then all three “Lord of the Rings” books, then when I finished “Return of the King”, I’d start over with “The Hobbit” again). Adam was my first Dungeon Master (no, not like that, you pervs), and spun amazing tales of adventure — which often ended in the gruesome death of my character — that got the blood pumping and the mind soaring. He was, in short, an incredible storyteller and one of the most creative people I’d met so far in my young life.</p><p>The two of us were in the same English class, and we would try and outdo each other with our writing assignments: his sardonic wit and gift with words against my homespun charm and odd humour. Honestly, it was one of those contests where we were both winners, because we sharpened our craft and entertained our classmates. Some people in the classroom would actually pump their fist when the teacher called one of us up to the front to read our latest, and I swear to god that we even got applause a time or two.</p><p>And then we came to the poetry unit. Adam hated poetry. I don’t remember why, but he always rolled his eyes at every poem the teacher read out loud, and claimed it was writing in the same way that modern art was art: not at all. My mom was an English teacher, however, and she had instilled a love of poetry in me years ago, so I was really enjoying the unit, and in our very first poetry writing assigment, I wrote the first in a long series of poems in a new form I had invented: Spastic Poetry.</p><p>When I was 12 years old in the United States, I didn’t know the word “spastic” was used in the UK as a hurtful pejorative term for people with mental disabilities. In the US, we used it more like “absurdly off the wall” or something like that. In any case, I now know that the word is extremely ableist, so I have now renamed my form Aberrant Poetry, because I think that captures the spirit of the original.</p><p>So here, my dear readers, is an sample of Aberrant Poetry, the first I’ve attempted in at least 25 years. Forgive me if I’m a bit rusty.</p><h2 id="aberrant_toads">Aberrant Toads</h2><p><em>Of snails and frogs and aberrant toads</em><br /> <em>When I drive, I use the roads</em><br /> <em>Otherwise, my axel would break</em><br /> <em>And I’d have to replace it with a rake</em><br /> <em>And that would cause my car to stall</em><br /> <em>And likely run into a wall</em><br /> <em>Or perhaps into a field</em><br /> <em>Where an inventory of the creatures therein would yield</em><br /> <em>Snails and frogs and aberrent toads</em><br /> <em>And sorcerers and mages of many modes</em><br /> <em>Necromancers and wizards with funky robes</em><br /> <em>Summoning up demons with names like Wobbes</em><br /> <em>Who’d consume my car with hellish breath</em><br /> <em>Like they were high on crystal meth</em><br /> <em>Sold to them by Walter White</em><br /> <em>Or some other aberrant knight</em><br /> <em>Riding a horse of midnight black</em><br /> <em>Stopping by 7–11 for a snack</em><br /> <em>Perhaps a hot dog or even nachos</em><br /> <em>Like Randy Savage of the men of machos</em><br /> <em>Leaping down from the ring’s top rope</em><br /> <em>To land a supplex on some poor mope</em><br /> <em>But back to fields of verdant green</em><br /> <em>Wherein my car with cracked windscreen</em><br /> <em>Lay upside down, with spinning wheels</em><br /> <em>While my nasty bruise it heals</em><br /> <em>Upon the car, amongst other things</em><br /> <em>A little bird forlornly sings</em><br /> <em>Of snails and frogs and aberrant toads</em></p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-17-creating-a-blog-with-clojure.html</id>
    <link href="https://jmglov.net/blog/2022-06-17-creating-a-blog-with-clojure.html"/>
    <title>Creating a blog with Clojure in 50 simple steps</title>
    <updated>2022-06-17T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<ol><li>Install <a href='https://nixos.org/'>NixOS</a> on your laptop because it’s super cool   but also because you hate yourself just a little.</li><li>Come up with the idea of writing something every day this summer and   publishing it on “your blog”.</li><li>Try and remember if you have a blog somewhere.</li><li>Try and remember what your password to that blog might have been. Or did you   use your Google account to sign up?</li><li>Get lucky when you sign in with Google and you’re dropped into your profile   where you have a single blog entry from several years ago and of course a few   drafts because why not?</li><li>Start a new blog entry.</li><li>Try to figure out what in the world you’re going to write about.</li><li>Go on a walk with your dog and talk to your friend Ray on the phone (well,   Signal audio call, because who uses their phone for actual phoning?) about   your nascent blogging career.</li><li>Mention that you’re planning to do your blogging on your own site, using some   static site generator or other. Wisely realise that this will be way more   work that you think. Predict serious rabbit-holing to your friend.</li><li>Remember that the inimitable <a href='https://www.michielborkent.nl/'>borkdude</a>    wrote a <a href='https://blog.michielborkent.nl/migrating-octopress-to-babashka.html'>blog
    entry</a>    about how he’s generating his blog as a static site with    <a href='https://github.com/babashka/babashka'>Babashka</a>.</li><li>Clone <a href='https://github.com/borkdude/blog'>borkdude’s blog</a> from Github    because of course the blog itself is open source because borkdude is    awesome!</li><li>Add <code>pkgs.babashka</code> to your    <a href='https://github.com/jmglov/nixos-config/blob/main/home.nix'><code>home.nix</code></a> and    run <code>sudo nixos-rebuild switch</code> to install it.</li><li>Run <code>bb render</code> and get excited as Babashka downloads the    <a href='https://github.com/clj-kondo/clj-kondo'>clj-kondo</a> pod.</li><li>Cry tears of sadness as Babashka errors out with:<pre><code>    java.util.zip.ZipException: invalid entry CRC &#40;expected 0x7463ff01 but got
    0x6bbe279e&#41;
    </code></pre></li><li>Realise that you’re about a million versions behind the most current    Babashka.</li><li>Check    <a href='https://github.com/NixOS/nixpkgs/blob/a153c90eec38d1a5694c3a8793f5732608b94e8f/pkgs/development/interpreters/clojure/babashka.nix'><code>babashka.nix</code></a>    on the main branch of <a href='https://github.com/NixOS/nixpkgs'>nixpkgs</a> to see    what the latest available version is.</li><li>Get excited when you see that someone has just upgraded to the latest    version of Babashka last week.</li><li>Update your home.nix to pull Babashka (and just Babashka) from nixpkgs atthe commit where it was updated because you can do that in Nix and you knew thatNix rules previously so that’s why you’re running NixOS like a boss! (Also,because you hate yourself just a little.)</li><li>Run <code>bb render</code> again, triumphant in the knowledge that it’s going to work    now that it’s on the latest version, because the latest version of any given    piece of software has fixed all of the bugs.</li><li>Get a permission denied error when Babashka tries to execute clj-kondo.</li><li><code>ls -l &#126;/.babashka/pods/repository/borkdude/clj-kondo/2021.10.19/clj-kondo</code></li><li>See that in fact the execute bit is not set.</li><li>Surmise that this is because the clj-kondo pod failed to install due to the    <code>ZipException</code>.</li><li><code>rm -rf &#126;/.babashka/pods/repository/ &amp;&amp; bb render</code></li><li>Get the same bloody ZipException.</li><li>Try to remember your password to <a href='https://clojurians.slack.com/'>Clojurians
    Slack</a>.</li><li>Install the desktop Slack client by adding pkgs.slack to your home.nix.</li><li>Revel in the glory of Nix.</li><li>Get a magic signin link from the Slack client to your email.</li><li>See that your friend Ray DM’d you back in 2019 to ask if you were going to    <a href='https://clojutre.org/'>ClojuTre</a> and you totally ghosted him because you    apparently hadn’t logged into Clojurians Slack since the spring of 2018.</li><li>See if there’s a #babashka channel on Clojurians Slack and breathe a sigh of    relief when of course there is!</li><li>Post about your problem in #babashka.</li><li>Wait 42 seconds for borkdude himself (creator of Babashka and about azillion other awesome open source thingies, mostly Clojure-related) to answeryour question.</li><li>Provide borkdude with the version of zlib you’re using to validate his    theory.</li><li>Watch as borkdude pings in some badass Nix expert to help and wait 42 more    seconds before said badass arrives.</li><li>Provide the badass with a 14 line <code>bb.nix</code> that reproduces the problem on    any Nix installation because that’s how Nix works.</li><li>Whilst the badass works on a proper fix (applying the Arch Linux patch where    they fixed the zlib issue to the zlib derivation that the graalvm derivation    depends on, obviously), attempt to create a Nix overlay that pins zlib to    1.2.11, the last version that works with GraalVM.</li><li>Ask yourself WTF is happening when you keep getting infinite recursion when    evaluating your new Babashka derivation that uses the overlay that pins    zlib.</li><li>Find out that apparently you <a href='https://github.com/NixOS/nixpkgs/issues/61682'>can’t override zlib in an
    overlay</a> because    <code>pkgs.stdlib</code> for Linux includes zlib.</li><li>Despair when borkdude points you to a Nix flake that you could probably hack    up to work around the problem because you haven’t had the time to wrap your    head around <a href='https://nixos.wiki/wiki/Flakes'>Nix flakes</a>.</li><li>Walk your dog and talk to Ray on Signal and describe the rabbit hole and    laugh when he says that your words from the previous chat were prophetic    when you said that building your blog in Clojure would result in a rabbit    hole.</li><li>Be proud of yourself for writing your daily blog entry <strong>before</strong> messing    around with this stuff.</li><li>Make some oatmeal for yourself and your son.</li><li>Start to head back to your desk to keep hacking but then realise that the    sun is shining so go outside and play football with your son instead.</li><li>[Optional step] Go out to supper with some good friends from the union and    drink your troubles away.</li><li>Watch some Star Trek: The Next Generation even though your friend Sen hates    on it all the time because she thinks TOS is better even though she’s wrong    and is super mean about it when you try to convince her she’s wrong but    that’s OK because she just got a dope NCC-1701 tattoo so all is forgiven and    she’s actually the best anyway because she’s chair of the local union and a    total badass.</li><li>Fall asleep on the couch for half an hour but then do the responsible thing    and brush your teeth and take the dog out for a wee before getting in bed.</li><li>Sleep pretty damned well even though your rib is aching like wild because    you broke it 7 years ago and then broke it again 6 years ago (and when you    say you broke it, what you actually mean is that some wanker broke it for    you during football training, and then a really nice guy from work who was    going in fairly for a 50–50 challenge caught you in the same rib with his    shoulder a year later and broke it for you again) and then some 20-something    wanker from Brommapojkarna (they really need to change their name, the    sexist bastards) performed a full-on hockey-style body check on you during a    7-a-side football match last week and hit the damned rib but luckily it    didn’t break this time but just bruised pretty badly.</li><li>Wake up and see that the Nix badass has opened a PR to nixpkgs that fixes    your issue.</li><li>Responsibly write a blog entry on how to write a blog in Clojure in 50 easy    steps before you try out the fix from the badass’s branch.</li></ol><p>See, toldya it was easy!</p><p><img src="img/clojure-logo.png" alt="The Clojure logo" /></p><p>PS: I did write a blog entry and publish it yesterday, but it was a guest piece for my friend Tim’s blog: <a href='https://7amkickoff.com/index.php/2022/06/16/story-of-a-mediocre-fan/'>“Story of a mediocre
fan”</a>. I guess next time I do that, I should at least link it here so I have a record of having written something for posterity.</p>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-16-story-of-a-mediocre-fan.html</id>
    <link href="https://jmglov.net/blog/2022-06-16-story-of-a-mediocre-fan.html"/>
    <title>Story of a mediocre fan</title>
    <updated>2022-06-16T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>The winter before I moved to Sweden (2009-2010), Arsenal launched Arsenal Player, and gave Red members access to watch all matches online the next day. This included not only Premier League matches, but also League Cup, FA Cup, and Champion's League (remember that?) matches. This was the trigger for me to sign up, and I've remained a Red member since. This also was the beginning of my deep dive into Arsenal fandom. I subscribed to all of the Arsenal blogs I could find, one of which was called <a href='https://7amkickoff.com/'>7amkickoff</a>, because it was written by this Arsenal fan from the west coast US who often had to get up at absurd hours to watch Arsenal.</p><p>Like many US sports fans, Tim is really into stats. Unlike many US sports fans, he actually knows what he's talking about, and he's a damned good writer. Over the years, we've become friends on Twitter, and a few years back, he graciously offered to post some of my work if I even got around to writing about Arsenal. Well, I finally got around to it, and Tim posted the first part of a story I wrote about how I became and Arsenal fan.</p><p>You can read "Story of a mediocre fan" over on 7amkickoff:</p><ul><li><a href='https://7amkickoff.com/index.php/2022/06/16/story-of-a-mediocre-fan/'>Chapter
  1</a></li><li><a href='https://7amkickoff.com/index.php/2022/06/23/story-of-a-mediocre-fan-chapter-2/'>Chapter
  2</a></li><li><a href='https://7amkickoff.com/index.php/2022/06/30/story-of-a-mediocre-fan-chapter-3/'>Chapter
  3</a></li><li><a href='https://7amkickoff.com/index.php/2022/07/09/story-of-a-mediocre-fan-chapter-4/'>Chapter
  4</a></li></ul>]]></content>
  </entry>
  <entry>
    <id>https://jmglov.net/blog/2022-06-15-summertime.html</id>
    <link href="https://jmglov.net/blog/2022-06-15-summertime.html"/>
    <title>Summertime (and the writing ain’t easy)</title>
    <updated>2022-06-15T23:59:59+00:00</updated>
    <content type="html"><![CDATA[<p>As far back as I can remember, I always wanted to be a writer. To me, being a writer was better than being President of the United States. Even before I first wandered into the library for my first Hardy Boys paperback, I knew I wanted to be a part of the writers’ club. It was there that I knew that I belonged. To me, it meant being somebody in a neighborhood that was full of nobodies. Writers weren’t like anybody else. I mean, they did whatever they wanted. They double-parked in front of a cafe and nobody ever gave them a ticket. In the summer when they banged on their typewriters all night, nobody ever called the cops.</p><p>OK, only the first sentence of that opening is actually true, but it’s really good writing, right? Tragically it’s not my really good writing, as the astute cinephiles amongst you will have already realised. Still, a key part to being a writer is being able to recognise good writing, even if it’s not yours. And this I can do; much like Salieri, the desire burns in my heart as I observe geniuses at work, but I am not imbued with the talent.</p><p>Except that’s all a load of malarky, right? I have a friend who is an amazing artist, and as I was looking at some of his work one day, I told him that when I was a kid, I loved to draw (who doesn’t?), but that I wasn’t any good. He looked at me and told me that I was wrong. What I wasn’t, according to him, was not good; what I was was impatient. He was clearly from the Auguste Gusteau school: “Anyone can draw,” he said, “it just takes dedication and the willingness to be awful until you’re not.”</p><p>Without me knowing the terminology at the time, Juan was explaining the growth mindset to me. There is such a thing as innate talent, sure, but anyone can develop their skills over time through intentional practice. And it’s this intentional practice thing that’s been hard for me over the years. I have enough innate athletic ability to be slightly above average at most sports I’ve played over the years, but I’ve never had the drive to get really good. For the most part, I’ve trained by myself and gotten the basics down, but then got discouraged when I joined a team and saw that I wasn’t one of the best players. “They’re just better than me,” I thought, and didn’t realise that for the most part, they were better than me because they’d worked harder, put in the hours.</p><p>The one exception to this, sports-wise, was tennis. I absolutely loved tennis as a child. I remember watching Ivan Lendl slug it out with Boris Becker and John McEnroe and Jimmy Connors, the epic Martina Navratilova vs. Chris Everet rivalry, Björn Borg in the twilight of his career but still a threat. The reception wasn’t great on our old TV in the mid 80s, but it got better on clear nights, and the CBS affiliate over in Roanoke always broadcast the later stages of the Grand Slam tournaments, and I was glued to the TV, together with my dad (a diehard fan of any sport, even one he’d never heard of before like hurling), as commentators discussed in hushed voices the finer points of Lendl’s “inside out” forehand or whether Björn Borg or Arthur Ashe was the best men’s tennis player of all time.</p><p>Speaking of Arthur Ashe, my mom found a copy of “Arthur Ashe’s Tennis Clinic” at the county library and checked it out for me. I read that book cover to cover, even though I didn’t so much as have a tennis racquet, a fact that would be remedied in due course.</p><p>I was in Boy Scouts back then, and there was a magazine called “Boy’s Life” that you got as part of your membership. The magazine had a section where you could sell different things to raise money for the Boy Scouts, and one of the things was microwave popcorn, which had pretty much just come out at that point. A company called Act II developed a shelf stable butter flavoured popcorn, and even people way out in the sticks where I lived were starting to buy microwaves, and they loved them some buttered popcorn. Every box of popcorn (they had big boxes with 20 packages and small boxes with 5 packages) gave you a certain number of points that you could then redeem for various prizes. One of the prizes was a youth-sized tennis racquet, and I absolutely had to have it.</p><p>I walked to all the neighbours’ houses, peddling my goods — no mean feat when the closest neighbour was half a mile away. I remember the elderly couple on the next farm over buying two boxes of 20, even though they didn’t have a microwave. What they did with the popcorn is beyond me, but bless their hearts for contributing to my tennis career. My dad took the order sheet to his day job at the telephone cooperative and managed to sell a few of the smaller boxes to linemen and even a big box to a Bell Atlantic salesperson (this was a couple of years after the US government broke up the AT&T monopoly) who came all the way over from the Richmond office to try to sell them a new phone switch. As Dad tells it, he strung the poor guy along for awhile and mentioned that his son was trying to raise money for the Boy Scouts in order to get a tennis racquet, so the salesman asked to buy a box to try and close the deal, then Dad handed him the popcorn and told him that he was just the accountant and that the guy would have to stop back by next week when the president of the coop would be in.</p><p>Dad also had a seasonal job as a tax accountant, and he magically managed to sell a box to each of his clients. I didn’t suspect it at the time, being 7 or so years old, but looking back on it, I’m pretty sure Dad bought those boxes himself and just gave them away to his clients as a thank you present. Thanks, Dad! I love you, big guy.</p><p>In any case, after a couple of months, I had sold enough popcorn to get the points I needed for the tennis racquet. I filled out the form from Boy’s Life and waited eagerly for my racquet to arrive, which must have taken quite some time, because I remember it being really hot when I finally got it, and since tax season ended in mid-April, I must have sent the form away by the end of April at the latest. When the racquet arrived, I realised that I had a problem: I didn’t have any tennis balls, and the general store down the road definitely didn’t carry that sort of thing (you could buy a 50 pound bag of feed along with your eggs and milk, though).</p><p>Luckily for me, my grandfather was due to visit from Harrisonburg in a week, so I wrote him a letter — long distance phone calls were mighty expensive back in those days — asking him to bring me “a tennis ball” when he came. My grandfather, ever the practical joker, found a tennis ball by the side of the road that had probably once belonged to a dog but had since been run over by a few cars from the looks of it. He wrapped it up in some Christmas paper with a ribbon and everything. When he presented this to me, I tore it open eagerly and then did my best not to let my face fall when I saw this falling apart, completely flat ball. He in turn did his best to keep a straight face when he handed me his car keys and asked me to go get his glasses case, which he reckoned had slipped out of his pocket when he was driving, and had probably fallen under the driver’s seat.</p><p>I guess you already saw this coming, but when I reached under the driver’s seat to feel around for his glasses case, my hand closed instead on a can of tennis balls! I ran back into the house, completely forgetting about the glasses case (which had magically appeared in Grandpop’s shirt pocket in the intervening moments) and excitedly showed him what I had found. He of course acted like he had no idea how the can had gotten there, and insisted that it must have been there when he bought the car. Even at that age — I guess I was 8, since my birthday was in May and I figure this must have happened in July or August — I was mighty suspicious of this story, but I didn’t care in that moment that my grandfather was probably the biggest liar in the history of liars; I just wanted to play some tennis.</p><p>Back in the 80s, tennis balls came in an honest-to-goodness steel can, which you had to open with a can opener. I ran to the kitchen, pulled the can opener out of the drawer, cracked that bad boy open, and was out the door with my tennis racquet and that can of balls so fast that the resulting sonic boom probably shattered a window or two in neighbouring farmhouses.</p><p>We had a woodshed over by the gravel driveway, which had a nice wall around the side that was unbroken by doors or windows and had a more or less flat section of driveway running past it. I pulled a ball out of the can, took a deep breath, squared up for a forehand just like the photos in the Arthur Ashe book showed, and hit the ball against the wall of the shed. I still remember the thwack as the ball hit the strings, the thunk as it hit the wall, the skitter of gravel as it bounced in some random direction, and the whoosh of air through my strings as I tried in vain to return the ball.</p><p>I was in heaven. Playing tennis with an actual ball was better than I could have ever imagined. I kept at it until suppertime, sometimes getting a lucky bounce off the gravel driveway that carried the ball more or less back to me, sometimes not. After supper, I ran back outside for more, playing until the dusk got so dusky that I couldn’t really make out the bright green tennis balls anymore. The next morning, I woke up at sunrise and was back at it again. And so on the next morning and the next morning and the rest of the summer. I learned how to serve, how to volley, how to keep score. I pretended that I was facing off against Ivan Lendl in the final of the US Open, Jimmy Connors at Wimbleton, playing mixed doubles with Martina on the clay of Roland-Garros. I reveled in the glory of victory and the agony of narrow defeat. Tennis was life.</p><p>I kept playing tennis until the snow covered my makeshift court, and then resumed in the spring. I took my racquet with me when we drove to Harrisonburg to visit my mom and dad’s parents. Grandmother lived right by a park which had not one, but four tennis courts, and a real backboard that you could practise your strokes on. When we moved from our farm to the bustling metropolis of Staunton (population 18,000) in 1989, I was sad to leave the farm and my friends and Chip, my beloved Border Collie, but overjoyed at the prospect of living a 10 minute bike ride from the tennis courts at one of the city’s three public parks!</p><p>I met a kid named Ian in 5th grade who would become one of my three best friends in the whole world. He lived right by the park — in fact right across the street from the tennis courts — so whenever I went to his house, we’d play tennis. He had cable television as well, so we watched all the major tournaments, falling in love with this young dude named Andre Agassi as he shocked the tennis establishment by having a heavy metal hairdo and wearing shirts with colourful designs and also by being really really good. Ian and I couldn’t grow our hair like that, but we could save up our allowance to buy $25 Nike tennis shirts (that was a lot of money back then, kids) with neon colours splashed across them and Agassi’s signature on the tag, and so we did.</p><p>Ian’s love for the game turned out to be relatively short-lived, but mine continued undiminished. It turned out that my mom had played on her high school tennis team, so my dad got her a racquet for her birthday, and she proceeded to absolutely humiliate me on the court. She was as good as the backboard at returning my shots. She never hit the ball hard, but never made a single mistake, and could place the ball wherever she wanted. Her one weakness was serves; both serving and returning. Her serve had no pace on it, and she tended to stay on the baseline after serving, so I learned that if I chopped the ball with lots of backspin just barely over the net, the ball would die long before she got to it. And if I got my Arthur Ashe’s Tennis Clinic backhand grip serve just right, hit with plenty of power and oozing with topspin, placed right into the corner of the service box, she couldn’t get her racquet to it.</p><p>After a few months, my mom couldn’t give me much of a challenge anymore, but she had one extremely valuable lesson left to teach me: patience and how to win and lose with grace. She had the best mental game of any tennis player I ever faced; she never seemed to get frustrated when she hit a bad shot or I blasted a serve past her, and she never moped when she lost or gloated when she won. Most tennis players, even professionals, tend to get in their own head and effectively beat themselves from time to time. I never saw my mom do this. Most tennis players, even (or especially?) professionals, tend to get angry when they mess up from time to time. I never saw my mom do this. And most players, even professionals, tend to celebrate an opponent’s mistake a little too much from time to time, and even occasionally let a smirk cross their face. Not my mom. She was an absolute paragon of good sportspersonship.</p><p>Unfortunately I never learned this lesson well enough. I joined the high school tennis team as a freshman, and played all four years, eventually rising to #1 on the team in my senior year. Unlike my mom, I would lose to myself rather than my opponent plenty of times, occasionally throw my racquet or smack the court when I messed up, and sometimes do a little dance when I was crushing an opponent. I would have been a much better player if I could have been more like my mom.</p><p>I also would have been a much better player if I had had the drive to practise more, to work harder. I had enough drive to go to training and practise on my own, and I had enough belief in my ability to get better, but I didn’t have the belief that I could be the best, so I didn’t work hard enough, thus the prophecy that I would go on to be just a good high school tennis player instead of a great one was fulfilled.</p><p>So what does this have to do with writing? Well, I started out by intending to talk about how I wanted to be a writer as a kid, and then mentioned the growth mindset so I could explain why I want to challenge myself to write and publish something every day this summer, but then got well and truly sidetracked by a series of memories from my child- and young adulthood. But the point is to practise writing, and I have written many hundred words (why doesn’t Medium give you a running word count?), so despite this story not really having a point, I will declare victory!</p><p>That’s all for today. Let’s see what my brain spits out tomorrow!</p>]]></content>
  </entry>
</feed>
