<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Projects on Drew Herron</title><link>https://www.drewherron.com/projects/</link><description>Recent content in Projects on Drew Herron</description><generator>Hugo</generator><language>en-us</language><managingEditor>dherron@mailbox.org (Drew Herron)</managingEditor><webMaster>dherron@mailbox.org (Drew Herron)</webMaster><lastBuildDate>Thu, 20 Aug 2026 16:05:28 -0700</lastBuildDate><atom:link href="https://www.drewherron.com/projects/index.xml" rel="self" type="application/rss+xml"/><item><title>toponymia</title><link>https://www.drewherron.com/projects/toponymia/</link><pubDate>Fri, 24 Jul 2026 23:58:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/toponymia/</guid><description>&lt;p&gt;Toponymia is a map-based wiki, with articles explaining the etymology
of (potentially) every place name on the map. You can click any label
on the map to view an article, or create it if one doesn&amp;rsquo;t
exist. Places with an article show up with an orange label. The
etymologies are modeled per name rather than per article, since a
place can have multiple names (endonym plus exonyms) each with its own
etymology and source language(s). It&amp;rsquo;s a full wiki with user
creation/login, discussion sections, revision history, etc.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Toponymia is a map-based wiki, with articles explaining the etymology
of (potentially) every place name on the map. You can click any label
on the map to view an article, or create it if one doesn&rsquo;t
exist. Places with an article show up with an orange label. The
etymologies are modeled per name rather than per article, since a
place can have multiple names (endonym plus exonyms) each with its own
etymology and source language(s). It&rsquo;s a full wiki with user
creation/login, discussion sections, revision history, etc.</p>
<p>It is not entirely finished, but the site is live (and unadvertised).
You can view the current state at <a href="https://www.toponymia.org">www.toponymia.org</a>. I&rsquo;ll update this
post along with the site.</p>
<h2 id="history">History</h2>
<p>In 2019, I took a three-month full-stack programming bootcamp. Eight
hours a day of instruction and programming. The final month or so was
largely devoted to a capstone project, which we would present in front
of the school. This program was my capstone. It was fully functional
at the end, but not too pretty and kind of clunky.</p>
<p>About four years later, I was back in school taking a course called
&ldquo;Code Review&rdquo; - essentially software engineering with extra emphasis
on style and convention to make clean, organized, and legible code. We
had to pick some large, existing codebase and throughout the course
incrementally refactor it into TypeScript. So I took my old bootcamp
capstone and used that. I always thought it was a good idea for a
website, and wanted to keep working on it. But all this work didn&rsquo;t
really change any of the functionality of the program, it just taught
me Typescript (along with better programming).</p>
<p>I think I bought the domain name back in 2019, and I held on to it
in the hopes that I&rsquo;d eventually get around to finishing the site. I
finally got around to it in 2026 thanks to Anthropic: they ran a
special where they opened up Fable to Pro users for a few weeks. I
wanted to test out Fable, and I thought refreshing Toponymia would be
a great project for it. So I just used Claude Code to help make all
necessary edits for a more polished and usable site.</p>
<p>The overall design really didn&rsquo;t change much, but it looks and works
much better than before. You can have a look at the <a href="https://github.com/drewherron/toponymia/blob/master/README.md">README</a> if you
want, but here&rsquo;s a quick technical overview:</p>
<h2 id="details">Details</h2>
<p>It&rsquo;s built on a Django 6 + Django REST Framework backend with
PostgreSQL/PostGIS for geospatial storage, and a React + TypeScript +
Vite frontend rendering the map with MapLibre GL JS. There are no API
keys or paid services anywhere: map tiles come from OpenStreetMap via
OpenFreeMap, the Overpass API resolves clicked map features to
real-world OSM elements, Wikidata IDs anchor stable identities, and
Photon handles geocoding search. In production Django serves the built
React app directly out of <code>web/dist/</code>, adding server-rendered SEO
metadata, a sitemap, and robots.txt; in development the two run
separately, with Vite proxying API calls to Django.</p>
<p>My original program used pins on the map, but now it uses the labels
themselves (a huge improvement IMO). Every rendered label on the
basemap is clickable, and a server-side lookup resolves that click to
a stable entity, then serves the matching article. Articles aren&rsquo;t
tied to coordinates; they&rsquo;re anchored to a Wikidata ID when one
exists, falling back to an OpenStreetMap element or a
name-plus-location match.</p>
<p>On top of that sits a pretty conventional wiki stack: accounts via
django-allauth, full revision history with diffs and revert, threaded
talk pages, and moderation for admins (report queues, soft-delete,
per-article protection levels, report review, account banning).</p>
<p>What turned out to be of the main lessons in developing this: creating
a site where people can log in and publish their own content is a
completely different category of project. I feel like half of the
overall work revolved around this. Things like email notifications,
account management, moderation dashboards, licensing/copyright
considerations, etc., had to be thoroughly planned out and tested from
every possible angle. I don&rsquo;t feel so bad serving myself up a bug, but
I really don&rsquo;t want to disappoint or confuse a user who&rsquo;s contributing
to my site. And I need to prevent misbehaving users from doing any
damage.</p>
<p>I also wrote a bot to crawl the map for article-less labels, search
for etymologies on sources with a friendly license (e.g., Wikipedia
and Wiktionary) and automatically write articles. For legal reasons I
need to manually review every post, so it&rsquo;s not fully automated but it
does help a lot. That&rsquo;s a project for another post.</p>
<p>The source code for Toponymia can be found <a href="https://github.com/drewherron/toponymia/">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>keykammer</title><link>https://www.drewherron.com/projects/keykammer/</link><pubDate>Tue, 04 Aug 2026 23:13:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/keykammer/</guid><description>&lt;p&gt;Keykammer is a peer-to-peer encrypted chat program built around one
idea: any arbitrary file is simultaneously the room ID and the
encryption key. Two people holding the same file - a photo, a PDF, a
binary, anything - can open the same private chatroom from across the
internet. Without the file there&amp;rsquo;s no way to decrypt the messages, no
way to join the room, and no way to know the room exists at all.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Keykammer is a peer-to-peer encrypted chat program built around one
idea: any arbitrary file is simultaneously the room ID and the
encryption key. Two people holding the same file - a photo, a PDF, a
binary, anything - can open the same private chatroom from across the
internet. Without the file there&rsquo;s no way to decrypt the messages, no
way to join the room, and no way to know the room exists at all.</p>
<p>The word <em>Kammer</em> is German for &ldquo;chamber&rdquo;, as in a secret room. I&rsquo;m
not entirely sold on the name, but I can&rsquo;t think of anything
better. This is one of the more complex programs I&rsquo;ve ever made, so
there&rsquo;s still a good amount of testing and tweaking to do. I created a
website at <a href="https://www.keykammer.com">www.keykammer.com</a>. If that website is up, then the program
is in a pretty good state.</p>
<p>The next step (after it&rsquo;s fully functional) is turning this into a
phone app. A simple messaging/chat GUI where you open/join a room by
selecting a file. I imagine it won&rsquo;t be too hard after the CLI/backend
side is completely done.</p>
<h2 id="details">Details</h2>
<p>Here&rsquo;s how it works:</p>
<p>Say Alice runs the program with a picture of her dog as the keyfile:
<code>keykammer fluffy.jpg</code>. The file is hashed to a room ID, and that same
file derives the key that encrypts every message. Her client asks a
discovery server whether that room exists yet; it doesn&rsquo;t, so the room
is listed - meaning the server now holds exactly one fact, that this
room ID is at this address. Bob runs the program with the same file,
derives the same room ID, and the discovery server hands back Alice&rsquo;s
address so his client can connect <em>directly</em> to her. Once the room
hits capacity (two users by default) the listing is deleted, so if
anyone else goes looking for the room there&rsquo;s no evidence it existed.
The discovery server is optional anyway - you can bypass it with a
direct address, or run your own.</p>
<p>It&rsquo;s written in Go, and the same binary is the chat server, the
client, and the discovery server depending on the flags. Peers talk
over gRPC; rooms live only in RAM, only while users are connected, and
nothing is ever written to disk. Messages are encrypted before they
leave the sender, so the discovery server only ever sees room IDs and
addresses. There&rsquo;s a terminal UI and UPnP port forwarding to get
through home routers.</p>
<p>Room IDs are SHA-256; the encryption key comes from HKDF-SHA256 over
the file contents, and messages use AES-256-GCM. HKDF rather than
another hash because keyfile entropy varies so wildly - a JPEG and a
short text file are very different inputs - and HKDF is built to
produce uniformly random key material from uneven input. GCM because
it authenticates as well as encrypts, so tampering makes decryption
fail outright and there&rsquo;s no separate MAC to get wrong. It&rsquo;s all Go
standard library plus <code>x/crypto/hkdf</code>.</p>
<p>The obvious limitation is that there&rsquo;s no forward secrecy - the key
comes from the file, so it&rsquo;s the same every session, and anyone who
gets the file can read recorded past traffic. The file <em>is</em> the
identity, which is the whole design, but it means keyfile hygiene is
essentially the entire threat model.</p>
<p>The code and prebuilt binaries are <a href="https://github.com/drewherron/keykammer">here</a> on GitHub. Again, it mostly
works, but it isn&rsquo;t finished - I&rsquo;m not really used to all this
networking&hellip; work.</p>
]]></content:encoded></item><item><title>genmark</title><link>https://www.drewherron.com/projects/genmark/</link><pubDate>Wed, 29 Jul 2026 13:08:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/genmark/</guid><description>&lt;p&gt;&lt;a href="https://github.com/drewherron/genmark"&gt;Genmark&lt;/a&gt; is a plain-text genealogy domain-specific language (DSL) that
compiles to GEDCOM 5.5.1. The compilation is important because GEDCOM
is so common in genealogical work, but Genmark on its own forms an
extremely useful plain text file format (&lt;code&gt;.gmd&lt;/code&gt;) for recording and
organizing genealogical data. There&amp;rsquo;s good a chance this is the
project I&amp;rsquo;m most proud of. It feels like more of an achievement to
invent a new file format, and I&amp;rsquo;m actually using it for all of my own
genealogical research. As far as I know, it could be the &lt;em&gt;only&lt;/em&gt; plain
text format specifically for writing/reading genealogical data
directly.&lt;/p&gt;</description><content:encoded><![CDATA[<p><a href="https://github.com/drewherron/genmark">Genmark</a> is a plain-text genealogy domain-specific language (DSL) that
compiles to GEDCOM 5.5.1. The compilation is important because GEDCOM
is so common in genealogical work, but Genmark on its own forms an
extremely useful plain text file format (<code>.gmd</code>) for recording and
organizing genealogical data. There&rsquo;s good a chance this is the
project I&rsquo;m most proud of. It feels like more of an achievement to
invent a new file format, and I&rsquo;m actually using it for all of my own
genealogical research. As far as I know, it could be the <em>only</em> plain
text format specifically for writing/reading genealogical data
directly.</p>
<p>GEDCOM 5.5.1 is the standard interchange format for genealogy
software, but it&rsquo;s a machine format, not something anyone would want
to write by hand. Here&rsquo;s an example from the Genmark README:</p>
<pre tabindex="0"><code class="language-nil" data-lang="nil">0 @I_mary_smith@ INDI
1 NAME Mary Ellen /Smith/
1 SEX F
1 BIRT
2 DATE MAR 1895
2 PLAC Portland, Oregon
1 DEAT
2 DATE 28 FEB 1978
2 PLAC Portland, Oregon
1 FAMS @F1@
</code></pre><p>That same data represented in Genmark is:</p>
<pre tabindex="0"><code class="language-nil" data-lang="nil">Mary Ellen Smith [mary_smith]
  sex: F
  b: 1895-03 @ Portland, Oregon
  d: 1978-02-28 @ Portland, Oregon
</code></pre><p>So Genmark exists basically to put a readable layer on top of that:
you write a person as one block of plain text, and the compiler
produces valid GEDCOM. And really, if you don&rsquo;t need to use GEDCOM,
Genmark is a great format by itself. The idea is that the <code>.gmd</code> file
<strong>is</strong> the actual research archive - not an import/export intermediate -
so it can be read, diffed, grepped, and edited directly. The repo
includes configuration files for Emacs, Vim, and VS Code to make
editing there even more convenient.</p>
<p>The Genmark program is written in Go and ships as a single static
binary via <code>go install</code> or prebuilt cross-platform releases (Linux,
macOS Intel/ARM, Windows). The compilation process follows the
following pipeline: a lexer tokenizes the source, a parser builds an
AST, and a compiler walks it to emit GEDCOM, with a first pass to
register every record&rsquo;s ID before a second pass resolves references -
so forward references (citing a person before their record appears)
will work fine.</p>
<p>I didn&rsquo;t include absolutely everything available in GEDCOM, but I
tried to cover most of what people would need for real genealogical
research. Life events - birth, death, christening, burial,
immigration, naturalization, residence, census, occupation, military
service, education, arbitrary custom events - all take a common
description/date/place shape:</p>
<pre tabindex="0"><code class="language-nil" data-lang="nil">occ: Factory Foreman (1921..1945) @ Portland, Oregon
</code></pre><p>There&rsquo;s a wide range of options for how you might want to organize
your genealogical data, largely based on how much data you&rsquo;re storing
on how many people. You could keep it all in one file if you want to,
but Genmark compiles across multiple files and even whole directory
trees, resolving every <code>[id]</code> reference globally. You can organize the
data however makes sense: one file per person, one per branch of the
tree, one per surname or region, etc. A <code>combine</code> subcommand
concatenates multiple <code>.gmd</code> files back into one, preserving comments
and formatting, for when you want a single reviewable document without
producing GEDCOM.</p>
<p>Sharing a tree usually means redacting any living people, so <code>compile --privatize</code> strips anyone not demonstrably (or assumed, by
configuration) dead. Media files can be attached to a person via a
<code>media:</code> field and can live in per-person or per-branch directories
alongside the <code>.gmd</code> files; <code>--copy-media</code> gathers everything actually
referenced in the compiled output into a single bundle beside the
GEDCOM (skipping media orphaned by privatization) so the tree is
portable without manually collecting/reorganizing files.</p>
<p>Beyond compiling, there&rsquo;s a small set of analysis subcommands aimed at
research itself rather than output: <code>check</code> validates a set of files
for undefined references and conflicting facts without producing a
GEDCOM, and shows every <code>maybe:</code> as a running list of things to
verify; <code>places</code> tallies where events happened, with a <code>--by-region</code>
mode that rolls up to state or country and an <code>--at YEAR</code> mode that
infers each living person&rsquo;s approximate location at a given point in
time from their most recent dated event; and <code>names</code> tallies given,
middle, and surnames across the dataset, useful for spotting naming
patterns or picking a name that runs in the family.</p>
<p>You can see the code and download the binary <a href="https://github.com/drewherron/genmark">here</a> on GitHub. Again,
I&rsquo;m using this for my own work without really needing the GEDCOM
compilation at all (so far), so the most important details are in the
documentation/specs, not the code (although the program&rsquo;s other
subcommands are also useful). For a very thorough description of the
syntax see <a href="https://github.com/drewherron/genmark/blob/master/SYNTAX.md">SYNTAX.md</a>, and for more examples you can look at
<a href="https://github.com/drewherron/genmark/blob/master/EXAMPLES.gmd">EXAMPLES.gmd</a>.</p>
]]></content:encoded></item><item><title>mulnera</title><link>https://www.drewherron.com/projects/mulnera/</link><pubDate>Fri, 31 Jul 2026 14:16:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/mulnera/</guid><description>&lt;p&gt;&lt;a href="https://github.com/drewherron/mulnera"&gt;Mulnera&lt;/a&gt; takes a URL and tells you who and what is mentioned on that
page. The general design is a FastAPI application with both a web UI
and a documented REST API, IP-based rate limiting, and a Docker
Compose setup that brings Redis up alongside it. I mainly came up with
this project as a way to learn named-entity recognition (NER), since
I&amp;rsquo;d done so much in NLP but never actually touched NER. It was also
good practice in using local/spaCy models.&lt;/p&gt;</description><content:encoded><![CDATA[<p><a href="https://github.com/drewherron/mulnera">Mulnera</a> takes a URL and tells you who and what is mentioned on that
page. The general design is a FastAPI application with both a web UI
and a documented REST API, IP-based rate limiting, and a Docker
Compose setup that brings Redis up alongside it. I mainly came up with
this project as a way to learn named-entity recognition (NER), since
I&rsquo;d done so much in NLP but never actually touched NER. It was also
good practice in using local/spaCy models.</p>
<p>The name comes from <b>MUL</b>tilingual <b>NER</b> <b>A</b>PI.</p>
<h2 id="how-it-works">How it works</h2>
<p>You supply a list of URLs to check. The program scrapes the content,
works out what language it&rsquo;s in, pulls out the named entities and any
security-relevant indicators, flags anything on a watchlist, and
optionally translates the results into English. It was built with
OSINT work in mind, but there&rsquo;s nothing security-specific about the
core of it. It could be used to monitor for any keywords using any
list of URLs.</p>
<p>Content language is detected automatically, then mapped to a spaCy
model by language code. English and German are configured out of the
box (just for testing, really), and anything else falls back to
spaCy&rsquo;s multilingual model, which is less accurate but means
unsupported languages still get processed rather than rejected. Adding
a language is just a few lines of YAML and a model download. You can
also register a transformer-based model as its own option if you&rsquo;d
rather have the accuracy than the speed, which is roughly a
twenty-fold tradeoff.</p>
<p>Alongside the NER, it pattern-matches IOCs - IPv4 and IPv6 addresses,
URLs, domains, email addresses, MD5/SHA-1/SHA-256 hashes, and CVE
identifiers - and checks the text against configurable watchlists,
which ship with the usual threat actors and malware families but are
just a YAML map of category to terms. One thing worth knowing: the
watchlists are matched against the original text, before any
translation, so terms need to be written the way they&rsquo;d actually
appear in the source language.</p>
<p>Translation runs through the Google Translate API and is entirely
optional; without credentials everything else still works. Results are
cached in Redis, mostly to keep from paying to translate the same
entity repeatedly. I guess we could put a translation step on both
sides of the pipeline, so your keywords/IOCs could stay in English and
be run against foreign sites. Maybe someday.</p>
<p>You can find the code <a href="https://github.com/drewherron/mulnera">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>mcp-greenthumb</title><link>https://www.drewherron.com/projects/mcp-greenthumb/</link><pubDate>Tue, 04 Aug 2026 22:58:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/mcp-greenthumb/</guid><description>&lt;p&gt;An MCP server that gives any MCP-compatible AI client persistent
memory of my garden: what&amp;rsquo;s planted, where it is, and what&amp;rsquo;s happened
to it. I was asking LLMs for gardening advice and kept having to
retype the same paragraph of context every time. Now the context is
just there, and I can ask a question and get an answer that accounts
for my actual beds, plants, and zone.&lt;/p&gt;
&lt;p&gt;Once again, I&amp;rsquo;ve built in a default workflow that incorporates Emacs'
Org mode file format. While I&amp;rsquo;m out in the garden can hit the &lt;a href="https://github.com/orgzly"&gt;Orgzly&lt;/a&gt;
widget on my phone to quickly save a note. That syncs to my laptop
with &lt;a href="https://github.com/syncthing"&gt;Syncthing&lt;/a&gt;, and when I have the chance I separate out
(&lt;code&gt;org-refile&lt;/code&gt;) my garden notes into their own file. later I can ask
the LLM to import my notes, and it calls &lt;code&gt;sync_notes&lt;/code&gt; to parse the Org
file and interpret the entries.&lt;/p&gt;</description><content:encoded><![CDATA[<p>An MCP server that gives any MCP-compatible AI client persistent
memory of my garden: what&rsquo;s planted, where it is, and what&rsquo;s happened
to it. I was asking LLMs for gardening advice and kept having to
retype the same paragraph of context every time. Now the context is
just there, and I can ask a question and get an answer that accounts
for my actual beds, plants, and zone.</p>
<p>Once again, I&rsquo;ve built in a default workflow that incorporates Emacs'
Org mode file format. While I&rsquo;m out in the garden can hit the <a href="https://github.com/orgzly">Orgzly</a>
widget on my phone to quickly save a note. That syncs to my laptop
with <a href="https://github.com/syncthing">Syncthing</a>, and when I have the chance I separate out
(<code>org-refile</code>) my garden notes into their own file. later I can ask
the LLM to import my notes, and it calls <code>sync_notes</code> to parse the Org
file and interpret the entries.</p>
<p>Underneath it&rsquo;s a SQLite database of plants, locations, and activity
history. The client does all the natural language work; the server
only does structured storage and retrieval. It exposes tools for the
writes (registering a plant, recording a planting, logging activities,
updating a location) plus a read-only SQL escape hatch, and a handful
of resources that give the AI ambient context without it having to
ask.</p>
<p>An important point in the schema: a <em>plant</em> is registered once, and
each instance/group of it growing somewhere is a separate
<em>planting</em>. So peppers started in a seed tray and then split between a
raised bed and some pots are three plantings of one plant, tracked
independently. When a planting is finished, or a bed gets torn out,
it&rsquo;s marked inactive rather than deleted, so the history stays intact.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-org" data-lang="org"><span class="line"><span class="cl"><span class="gh">*</span><span class="gs"> Transplanted tomatoes outdoors</span>
</span></span><span class="line"><span class="cl"><span class="c">:PROPERTIES:
</span></span></span><span class="line"><span class="cl"><span class="cs">:CREATED:  [2026-04-21 Tue 15:32]
</span></span></span><span class="line"><span class="cl"><span class="c">:END:</span>
</span></span><span class="line"><span class="cl">Put them in pots 3, 4, and 5.
</span></span></code></pre></div><p>From that the model can check which of those pots already exist,
create the missing ones, add a planting for each, and log the
transplant. Re-importing the same file is safe; entries are
deduplicated by timestamp and content.</p>
<p>Nothing about the design (aside from the prompts and database schema)
is really specific to gardening. It&rsquo;s a structured, persistent memory
an LLM can read and write, and it wouldn&rsquo;t take much to point it at
something else entirely. Though, if that&rsquo;s all you want, I&rsquo;m sure
other programs do it better.</p>
<p>The code is <a href="https://github.com/drewherron/mcp-greenthumb">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>smakdown</title><link>https://www.drewherron.com/projects/smakdown/</link><pubDate>Fri, 24 Jul 2026 23:58:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/smakdown/</guid><description>&lt;p&gt;Smakdown is a Firefox extension that turns unstructured recipe web
pages into clean, structured notes. It strips a page down to its
meaningful content, sends that to an LLM for structuring, formats the
result as either Org-mode or Markdown, and writes to a file through a
Python native-messaging helper. You can supply a file path and the
recipe will be appended to that file, or supply a directory and then
each recipe will get its own file within that directory.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Smakdown is a Firefox extension that turns unstructured recipe web
pages into clean, structured notes. It strips a page down to its
meaningful content, sends that to an LLM for structuring, formats the
result as either Org-mode or Markdown, and writes to a file through a
Python native-messaging helper. You can supply a file path and the
recipe will be appended to that file, or supply a directory and then
each recipe will get its own file within that directory.</p>
<p>The extension and source code are available <a href="https://github.com/drewherron/smakdown">here</a>.</p>
<p>&ldquo;<a href="https://en.wiktionary.org/wiki/smak">Smak</a>&rdquo; means <em>taste</em> in Norwegian, Swedish, Polish, and Middle
English. I considered &ldquo;Geschmackdown&rdquo; or &ldquo;Schmackdown&rdquo; as the German
option, but&hellip; it just sounds worse to me.</p>
<p>I don&rsquo;t know if this is a common sentiment, but I&rsquo;m usually annoyed by
modern food blogs. It&rsquo;s like they&rsquo;re intentionally designed to be
inefficient, to keep you on the site longer. You need to browse
through the history of the food, and the history of the author&rsquo;s
relationship to the food, before getting to the recipe. I know it
doesn&rsquo;t bother everyone - my wife likes the extra context and doesn&rsquo;t
really understand why it bothers me. I considered - actually, I&rsquo;m
still considering - designing a food blog at the extreme end of this
context spectrum, where the reader is likely to never even find the
recipe&hellip;</p>
<p>In any case, I built Smakdown partially to get around this &ldquo;feature&rdquo;
of modern recipe websites. But that&rsquo;s really only half of the
motivation. Even if you don&rsquo;t mind all the reading, if you&rsquo;re someone
who prefers to work in plain text (as I do) and likes to keep
organized markdown or Org-mode notes (as I do), then I&rsquo;m sure you can
see the benefit in using this.</p>
<p>The &ldquo;native-messaging helper&rdquo; I mentioned earlier is a real weak point
of this program&rsquo;s design, but it seemed like the most reliable way to
write to arbitrary files on the user&rsquo;s system. This means you can&rsquo;t
just download the extension from the extension store, you need to
download/install the <code>.xpi</code> file directly, and then run the Python
install script.</p>
]]></content:encoded></item><item><title>orgmarks</title><link>https://www.drewherron.com/projects/orgmarks/</link><pubDate>Fri, 31 Jul 2026 14:25:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/orgmarks/</guid><description>&lt;p&gt;&lt;a href="https://github.com/drewherron/mulnera"&gt;Orgmarks&lt;/a&gt; converts browser bookmarks to Org-mode and back. Firefox and
Chrome both export to the Netscape Bookmark HTML format, which is fine
for machines and less fun to work with by hand; Orgmarks turns that
into an Org file, where folders are headlines and each bookmark is a
headline with a link under it.&lt;/p&gt;
&lt;p&gt;HTML is the standard export format for bookmarks, but I like to keep
things in an editable plain text format. I picked Org because I use
Emacs, but it&amp;rsquo;s better than HTML no matter which editor you
use (but way better in Emacs). Bookmarks look like this in the file:&lt;/p&gt;</description><content:encoded><![CDATA[<p><a href="https://github.com/drewherron/mulnera">Orgmarks</a> converts browser bookmarks to Org-mode and back. Firefox and
Chrome both export to the Netscape Bookmark HTML format, which is fine
for machines and less fun to work with by hand; Orgmarks turns that
into an Org file, where folders are headlines and each bookmark is a
headline with a link under it.</p>
<p>HTML is the standard export format for bookmarks, but I like to keep
things in an editable plain text format. I picked Org because I use
Emacs, but it&rsquo;s better than HTML no matter which editor you
use (but way better in Emacs). Bookmarks look like this in the file:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-org" data-lang="org"><span class="line"><span class="cl"><span class="gh">*</span><span class="gs"> Bookmarks Toolbar</span>
</span></span><span class="line"><span class="cl"><span class="gu">**</span> Wikipedia                                <span class="ge"> :reference:</span>
</span></span><span class="line"><span class="cl">[[<span class="na">https://wikipedia.org/</span>]]
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="gu">**</span> Development
</span></span><span class="line"><span class="cl"><span class="gu">***</span> GitHub
</span></span><span class="line"><span class="cl">[[<span class="na">https://github.com</span>]]
</span></span><span class="line"><span class="cl">Code hosting platform
</span></span></code></pre></div><p>Tags come across as org tags, Firefox&rsquo;s keyword shortcuts become
properties, and any text under a link is kept as a description. Nested
folders of any depth survive the round trip. Timestamps don&rsquo;t - I
decided <code>ADD_DATE</code> stamps on every entry were more clutter than
they&rsquo;re worth, and I doubt anyone is nostalgic about when exactly they
bookmarked something.</p>
<p>Once bookmarks are plain text, the usual benefits follow: they diff,
they can go in git, and you can edit them the way you edit anything
else in Emacs. The specific thing I wanted was <code>org-refile</code> - dumping
a messy browser export into one file and then filing bookmarks one at
a time into a clean one, without leaving the editor. The README
includes the Elisp bit of <code>init.el</code> I use for that, which restricts
refile targets to folders within the bookmarks file.</p>
<p>It&rsquo;ll also merge multiple inputs into a single Org file, combining
folders with matching names, and <code>--deduplicate</code> drops repeated URLs
(keeping whichever came first, so listing your organized file ahead of
a fresh browser export does the sensible thing). Your browser won&rsquo;t do
that for you.</p>
<p>This turned out to be one of my programs that I actually use
regulary. It&rsquo;s a Go CLI, with binaries for Linux, macOS, and
Windows. The code is <a href="https://github.com/drewherron/orgmarks">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>dmenu-radio</title><link>https://www.drewherron.com/projects/dmenu-radio/</link><pubDate>Fri, 31 Jul 2026 14:51:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/dmenu-radio/</guid><description>&lt;p&gt;A dmenu launcher for &lt;a href="https://github.com/ebruck/radiotray-ng"&gt;radiotray-ng&lt;/a&gt;: hit a key, pick an internet radio
station from a dmenu list, and it starts playing. No tray icon, no
window. In dwm a keybinding plus dmenu is the entire interface, and a
system tray is the one part of radiotray-ng I didn&amp;rsquo;t want.&lt;/p&gt;
&lt;p&gt;Stations show up as &lt;code&gt;Group / Name&lt;/code&gt; - &lt;code&gt;Jazz / KMHD&lt;/code&gt; - and selecting one
calls radiotray-ng&amp;rsquo;s &lt;code&gt;play_station&lt;/code&gt; over D-Bus. If radiotray-ng isn&amp;rsquo;t
running yet the script starts it in the background and waits for it to
appear on the session bus, so the keybinding works from a cold start
rather than failing the first time you press it. There&amp;rsquo;s also a
&lt;code&gt;--standalone&lt;/code&gt; mode that drops radiotray-ng entirely and plays through
&lt;code&gt;mpv --no-video&lt;/code&gt;, driven over mpv&amp;rsquo;s JSON IPC socket; if an mpv instance
is already listening there, the new station is sent to it instead of
spawning a second player.&lt;/p&gt;</description><content:encoded><![CDATA[<p>A dmenu launcher for <a href="https://github.com/ebruck/radiotray-ng">radiotray-ng</a>: hit a key, pick an internet radio
station from a dmenu list, and it starts playing. No tray icon, no
window. In dwm a keybinding plus dmenu is the entire interface, and a
system tray is the one part of radiotray-ng I didn&rsquo;t want.</p>
<p>Stations show up as <code>Group / Name</code> - <code>Jazz / KMHD</code> - and selecting one
calls radiotray-ng&rsquo;s <code>play_station</code> over D-Bus. If radiotray-ng isn&rsquo;t
running yet the script starts it in the background and waits for it to
appear on the session bus, so the keybinding works from a cold start
rather than failing the first time you press it. There&rsquo;s also a
<code>--standalone</code> mode that drops radiotray-ng entirely and plays through
<code>mpv --no-video</code>, driven over mpv&rsquo;s JSON IPC socket; if an mpv instance
is already listening there, the new station is sent to it instead of
spawning a second player.</p>
<p>Station data comes from radiotray-ng&rsquo;s own bookmarks file, but an
optional <code>bookmarks.org</code> takes priority, which is much nicer to
edit. Those play through <code>play_url</code>, so they don&rsquo;t have to exist in
radiotray-ng&rsquo;s bookmarks at all.</p>
<p>The remaining flags are conveniences for the dwm setup: <code>--toggle</code> for
a single-key play/stop binding, and <code>--status</code>, which prints the
current <code>Artist - Title</code> (or the station name, if the stream sends no
metadata) and nothing when stopped, so it drops straight into slstatus
as a <code>run_command</code>. It&rsquo;s one Python script, standard library only.</p>
<p>The code is <a href="https://github.com/drewherron/dmenu-radio">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>sitella</title><link>https://www.drewherron.com/projects/sitella/</link><pubDate>Tue, 04 Aug 2026 22:53:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/sitella/</guid><description>&lt;p&gt;From &lt;a href="https://en.wiktionary.org/wiki/sitella#Latin"&gt;Wiktionary&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;sitella&lt;/strong&gt; f (&lt;em&gt;genitive&lt;/em&gt; &lt;strong&gt;sitellae&lt;/strong&gt;); first declension&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;an urn for holding ballots&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;a href="https://github.com/drewherron/sitella"&gt;Sitella&lt;/a&gt; is a prototype of an end-to-end verifiable voting system - one
where anybody can check that the announced result is the correct count
of the ballots actually cast, without anybody being able to learn how
a given person voted. It&amp;rsquo;s a single Rust binary that plays every role:
election server, voter client, organizer tooling, and public auditor.&lt;/p&gt;</description><content:encoded><![CDATA[<p>From <a href="https://en.wiktionary.org/wiki/sitella#Latin">Wiktionary</a>:</p>
<blockquote>
<p><strong>sitella</strong> f (<em>genitive</em> <strong>sitellae</strong>); first declension</p>
<ol>
<li>an urn for holding ballots</li>
</ol>
</blockquote>
<p><a href="https://github.com/drewherron/sitella">Sitella</a> is a prototype of an end-to-end verifiable voting system - one
where anybody can check that the announced result is the correct count
of the ballots actually cast, without anybody being able to learn how
a given person voted. It&rsquo;s a single Rust binary that plays every role:
election server, voter client, organizer tooling, and public auditor.</p>
<p>I&rsquo;ve heard people saying that the relatively recent development of
blockchain technology could bu used to solve issues of election
security. I started this project out as an exploration of that idea,
but ended up dropping the blockchain aspect entirely. All of the
specific requirements I had for the final result were satisfied by
cryptography alone.</p>
<p>An election runs in five steps:</p>
<ol>
<li>Voters register once, and the important detail is that the secret
is generated on the voter&rsquo;s own device - the organizer only ever
receives a public <em>commitment</em> to it, and must never generate the
secret itself.</li>
<li>At the registration deadline the roll of commitments is frozen and
published as a plain text file, with its Merkle root pinned in the
first record of the transcript.</li>
<li>To vote, the device encrypts the choice, proves the ciphertext is
well-formed (exactly one candidate), proves in zero knowledge that
it belongs to <em>some</em> holder of a secret on that frozen roll, and
derives a nullifier that spends that identity&rsquo;s single vote. No
name and no commitment travel with the ballot. The server re-checks
everything, appends it to a hash-chained public transcript, and
hands back a signed receipt pointing at the ballot&rsquo;s position.</li>
<li>After close, the encrypted ballots are summed while still
encrypted, and only the <em>totals</em> get decrypted - by the trustees
together, each proving its decryption share is honest, so no
individual ballot is ever decrypted and no complete decryption key
ever exists.</li>
<li>Then anyone can run <code>sitella verify</code> against the transcript and
re-check all of it offline: the hash chain, every membership and
ballot proof, every nullifier, the voting window, the sums, the
decryption proofs. A voter can additionally point it at their
receipt and see their own ballot sitting in the count.</li>
</ol>
<p>But if you wanted to verify that your ballot was actually cast for a
particular candidate/proposal/etc., that is not possible. Before
looking into this whole &ldquo;voting&rdquo; topic, I knew we&rsquo;d need to handle
attacks from bad actors, but hadn&rsquo;t really considered legitimate
voters misbehaving. You don&rsquo;t <em>want</em> to give someone the ability to
confirm what they voted for - they could show that verification to
someone who is either paying them for the vote, or who is coercing
them to vote for something against their will.</p>
<p>This system could (theoretically) scale from a national election down
to an office lunch poll. Although, it is very much a prototype and not
something you&rsquo;d want to run a real election on (yet). I still have a
few improvements planned, and I&rsquo;ll keep this post updated along with
the code.</p>
<p>Get the program <a href="https://github.com/drewherron/sitella">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>random-image-server</title><link>https://www.drewherron.com/projects/random-image-server/</link><pubDate>Fri, 31 Jul 2026 14:29:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/random-image-server/</guid><description>&lt;p&gt;This project is just an Nginx server that shows one random image from
a directory, picking a different one on every page load. Point it at a
folder, visit the page, get an image.&lt;/p&gt;
&lt;p&gt;I haven&amp;rsquo;t actually deployed this anywhere yet. I was thinking it would
be funny to collect a massive amount of individual instances of one
meme, and then run a site that serves them up randomly, one at a time.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This project is just an Nginx server that shows one random image from
a directory, picking a different one on every page load. Point it at a
folder, visit the page, get an image.</p>
<p>I haven&rsquo;t actually deployed this anywhere yet. I was thinking it would
be funny to collect a massive amount of individual instances of one
meme, and then run a site that serves them up randomly, one at a time.</p>
<p>The whole thing is a Lua block inside the Nginx config. On each
request it scans the image directory with LuaFS, picks a random file
from what it finds, and emits a small HTML page displaying it centered
on a black background, with a cache-busting query parameter so the
browser doesn&rsquo;t just hand back the previous image. There&rsquo;s no
application behind Nginx, and no index or database of the images -
adding a picture to the directory is the entire deployment step.</p>
<p>Using LuaFS instead of shelling out to <code>ls</code> is the main reason it
stays reasonable to expose: nothing user-controlled ever reaches a
shell. Beyond that it runs as the unprivileged <code>nobody</code> user, rate
limits by IP (30 requests a minute by default), sanitizes paths so
nothing outside the image directory can be reached, only serves a
whitelist of image extensions, and sets the usual security headers.</p>
<p>It deploys either with Docker Compose or with an install script that
handles the Nginx and Lua packages directly on Debian- or Fedora-based
systems. Dimensions, port, rate limit, and hostname all come from
<code>.env</code>. There&rsquo;s a small attribution tooltip in the corner, which you
could remove or point at something else.</p>
<p>The code is <a href="https://github.com/drewherron/random-image-server">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>fontezuma</title><link>https://www.drewherron.com/projects/fontezuma/</link><pubDate>Sun, 26 Jul 2026 00:24:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/fontezuma/</guid><description>&lt;p&gt;This was the final project for a computer vision course in my master&amp;rsquo;s
program.&lt;/p&gt;
&lt;p&gt;If you have an image that includes text, you can feed it into this
program and it tells you which font that text most likely is. More
accurately, it tells you which of the fonts in the training set are
most similar to the image text.&lt;/p&gt;
&lt;p&gt;It works in three stages. First it builds its own training data: it
walks a directory of &lt;code&gt;.ttf&lt;/code&gt; files and renders every letter and digit
of every font to a 200x200 grayscale image, so each font becomes a
folder of labelled glyphs. Then it trains a CNN (TensorFlow/Keras) on
those images, with one class per font. Finally, given a real image, it
uses OpenCV to pull the individual characters out - CLAHE to even out
the contrast, Otsu thresholding to get a clean black-and-white image,
contour detection to find the character boundaries - classifies each
character separately, and aggregates those per-character scores into a
single prediction for the image as a whole.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This was the final project for a computer vision course in my master&rsquo;s
program.</p>
<p>If you have an image that includes text, you can feed it into this
program and it tells you which font that text most likely is. More
accurately, it tells you which of the fonts in the training set are
most similar to the image text.</p>
<p>It works in three stages. First it builds its own training data: it
walks a directory of <code>.ttf</code> files and renders every letter and digit
of every font to a 200x200 grayscale image, so each font becomes a
folder of labelled glyphs. Then it trains a CNN (TensorFlow/Keras) on
those images, with one class per font. Finally, given a real image, it
uses OpenCV to pull the individual characters out - CLAHE to even out
the contrast, Otsu thresholding to get a clean black-and-white image,
contour detection to find the character boundaries - classifies each
character separately, and aggregates those per-character scores into a
single prediction for the image as a whole.</p>
<p>See it <a href="https://github.com/drewherron/fontezuma">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>tonkija</title><link>https://www.drewherron.com/projects/tonkija/</link><pubDate>Thu, 20 Aug 2026 16:05:28 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/tonkija/</guid><description>&lt;p&gt;Tonkija was the final project for a course in my master&amp;rsquo;s program called
&amp;ldquo;Generative Security Applications&amp;rdquo;. The content of the course was
split between two subjects:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;How can we incorporate LLMs into security applications?&lt;/li&gt;
&lt;li&gt;How can we break the security of LLMs?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This program addresses that first question. It&amp;rsquo;s a Chrome
extension for automated security analysis of web pages, servers, and
code. It checks SSL/TLS certificates, DNS records, and HTTP security
headers, evaluates Content-Security-Policy, queries the VirusTotal
API, and integrates multiple LLM providers (OpenAI, Anthropic,
Google). The backend is Python/Flask.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Tonkija was the final project for a course in my master&rsquo;s program called
&ldquo;Generative Security Applications&rdquo;. The content of the course was
split between two subjects:</p>
<ol>
<li>How can we incorporate LLMs into security applications?</li>
<li>How can we break the security of LLMs?</li>
</ol>
<p>This program addresses that first question. It&rsquo;s a Chrome
extension for automated security analysis of web pages, servers, and
code. It checks SSL/TLS certificates, DNS records, and HTTP security
headers, evaluates Content-Security-Policy, queries the VirusTotal
API, and integrates multiple LLM providers (OpenAI, Anthropic,
Google). The backend is Python/Flask.</p>
<p>This project was my first time creating a browser extension. It&rsquo;s
also, to be honest, probably the last program I wrote without the help
of AI. I took this class during the final term of my master&rsquo;s;
everything after this has been a personal project. This was the final
project for the course, and my son was born during that finals week of
the final term of my master&rsquo;s. I even made a commit from the delivery
room.</p>
<p>See it <a href="https://github.com/drewherron/tonkija">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>llm-librarian</title><link>https://www.drewherron.com/projects/llm-librarian/</link><pubDate>Fri, 31 Jul 2026 14:49:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/llm-librarian/</guid><description>&lt;p&gt;A command-line tool that files a pile of ebooks (PDF, EPUB, MOBI,
AZW3) into an existing directory tree, using a local model through
&lt;a href="https://ollama.com"&gt;Ollama&lt;/a&gt;. No API costs, and nothing gets copied anywhere without your
approval.&lt;/p&gt;
&lt;p&gt;I built this because I tried letting Claude Code organize a few
ebooks, and it destroyed my entire system. I didn&amp;rsquo;t lose any data, but
I had to reinstall Linux. Somewhere in the process I&amp;rsquo;d given Claude
permission to `mv` files into the parent directory, and after letting
it run a while I realized it had backed out a few times too many and
moved my entire `home/` directory to some other directory in root. At
that point it&amp;rsquo;s not really safe to simply move things back. But at
least all my data was still there, I just saved what I needed and
reinstalled.&lt;/p&gt;</description><content:encoded><![CDATA[<p>A command-line tool that files a pile of ebooks (PDF, EPUB, MOBI,
AZW3) into an existing directory tree, using a local model through
<a href="https://ollama.com">Ollama</a>. No API costs, and nothing gets copied anywhere without your
approval.</p>
<p>I built this because I tried letting Claude Code organize a few
ebooks, and it destroyed my entire system. I didn&rsquo;t lose any data, but
I had to reinstall Linux. Somewhere in the process I&rsquo;d given Claude
permission to `mv` files into the parent directory, and after letting
it run a while I realized it had backed out a few times too many and
moved my entire `home/` directory to some other directory in root. At
that point it&rsquo;s not really safe to simply move things back. But at
least all my data was still there, I just saved what I needed and
reinstalled.</p>
<p>So then, the design is deliberately not an agent wandering around your
filesystem. It&rsquo;s a fixed pipeline, and most of the work went into
giving the model as little room as possible to be creative. First it
gathers metadata - from the filename, from whatever is embedded in the
file, and from an <a href="https://openlibrary.org">Open Library</a> lookup, which usually supplies subjects
and a Dewey number - so the model rarely has to fall back on its own
knowledge of a book. Then it classifies, in two small multiple-choice
steps: pick a top-level section, then pick a leaf directory inside
it. The options are enums built from your actual directory tree and
enforced by the structured output grammar, so the model can&rsquo;t invent a
category or a path. Dewey numbers map to a section hint in plain code,
not by asking the model. And since my ebooks directory is constructed
using my <a href="/projects/drewey-decimal/">drewy-decimal</a> script, DDC numbers are actually very helpful
in this organization.</p>
<p>Every decision is sent to a JSON manifest with a confidence score, and
nothing touches the library until you&rsquo;ve been through them.  The
confidence number is really just the model&rsquo;s own self-report,
uncalibrated. So it&rsquo;s a more of a key for review, not a guarantee. You
approve, reject, or edit each proposal (or bulk-approve above a
confidence threshold) and only then does <code>execute</code> copy the approved
books in, renamed <code>Title - Author (Year).ext</code>.</p>
<p>It copies, never moves; the tree is treated as read-only, so
directories are never created. If a book fits nowhere, that&rsquo;s a
<code>needs_review</code> outcome and it&rsquo;s your call whether to add a directory
and run it again. Re-runs are idempotent and <code>classify</code> is resumable,
so you can interrupt a long run without losing work.</p>
<p>And the runs are&hellip; long. Speed is entirely about prompt prefill - the
model reading a long list of candidate directories - which on a CPU
means minutes per book. A GPU is worth 10-50x here and needs no
configuration, so <code>classify</code> prints the model placement at startup to
tell you whether you&rsquo;re actually getting one. If your machine is
CPU-only you can point <code>--ollama-url</code> at Ollama running on a rented
GPU box over an SSH tunnel; only metadata and directory names go over
the wire, so the ebooks themselves never leave your machine.</p>
<p>The code is <a href="https://github.com/drewherron/llm-librarian">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>linux-init</title><link>https://www.drewherron.com/projects/linux-init/</link><pubDate>Fri, 31 Jul 2026 14:33:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/linux-init/</guid><description>&lt;p&gt;This is a shell script that automates my Linux setups after an
installation.&lt;/p&gt;
&lt;p&gt;The script has saved me a lot of time through multiple
Linux installs, but I&amp;rsquo;m not sure how useful it would be for anyone
else. Years of customization are included in this, and you&amp;rsquo;d really
only want to use it to end up with my exact OS setup. I don&amp;rsquo;t expect
there are many people using anything remotely near my setup, although
I guess someone out there may want to try it out. Surely the
combination of just dwm and Colemak-DH puts me in an extreme minority.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This is a shell script that automates my Linux setups after an
installation.</p>
<p>The script has saved me a lot of time through multiple
Linux installs, but I&rsquo;m not sure how useful it would be for anyone
else. Years of customization are included in this, and you&rsquo;d really
only want to use it to end up with my exact OS setup. I don&rsquo;t expect
there are many people using anything remotely near my setup, although
I guess someone out there may want to try it out. Surely the
combination of just dwm and Colemak-DH puts me in an extreme minority.</p>
<p><strong>However</strong>, you could start with my script as a base and edit it to
create your own Linux setup script. The overall design of the script
would make a good starting point for any Linux user.</p>
<p>The workflow is: install Fedora from the netinstall ISO with &lsquo;Basic
Desktop&rsquo; and nothing else selected, clone this repo into the new home
directory, drop <code>.fonts/</code> there alongside the script, and <code>.ssh</code> and
<code>.gnupg</code> into <code>secrets/</code>, then run it and answer the prompts. That&rsquo;s
the whole thing. I can go from a bare install to a fully configured
machine without touching anything else.</p>
<p>Here&rsquo;s an overview of what it does:</p>
<ol>
<li>Copies SSH and GPG keys in from <code>secrets/</code></li>
<li>Creates my standard home directory structure</li>
<li>Updates the system with dnf</li>
<li>Installs and configures LightDM, and puts a custom background on the
LUKS unlock screen at boot</li>
<li>Installs the development packages I want and removes the ones I
don&rsquo;t</li>
<li>Clones and builds my own versions of dwm, st, and dmenu, and sets up
an <code>xinitrc</code> for the dwm session</li>
<li>Stows my dotfiles with GNU Stow</li>
<li>Sets zsh as the default shell</li>
<li>Sets up my KMonad configuration</li>
<li>Clones whatever other repos I&rsquo;ve listed into their target locations</li>
</ol>
<p>Every step except the package update asks for confirmation first, so
you can pick what you actually want at runtime rather than editing the
script beforehand.</p>
<p>The KMonad piece is probably where this gets least portable. On top of
Colemak-DH, it puts modifiers on the bottom row as hold-keys: in
qwerty terms, hold <code>z</code> for ctrl, <code>x</code> for alt, <code>c</code> for super, <code>v</code> for a
numpad under the right hand, with the mirror image on <code>/</code>, <code>.</code>, <code>,</code>,
and <code>m</code>; hold space for vim-style direction keys; caps lock is
backspace. Mod + grave switches layout, but <code>colemak_dh_ortho</code> is my
default and dwm is configured around it. Which is roughly the point at
which I realized how few people are going to want to run this
unmodified. I&rsquo;ll make a separate blog post about my keyboard setup.</p>
<p>The script is <a href="https://github.com/drewherron/linux-init">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>drewey-decimal</title><link>https://www.drewherron.com/projects/drewey-decimal/</link><pubDate>Fri, 31 Jul 2026 14:37:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/drewey-decimal/</guid><description>&lt;p&gt;A directory structure for organizing an ebook library, loosely based
on the Dewey Decimal Classification, plus a shell script that creates
the whole thing in one go. This isn&amp;rsquo;t really much of a project, it&amp;rsquo;s
just how I changed the DDC to better fit my own library (especially
technology/programming/CS books). It seems kind of silly to even make
it public on GitHub, but I can imagine someone wanting to edit it for
their own use.&lt;/p&gt;</description><content:encoded><![CDATA[<p>A directory structure for organizing an ebook library, loosely based
on the Dewey Decimal Classification, plus a shell script that creates
the whole thing in one go. This isn&rsquo;t really much of a project, it&rsquo;s
just how I changed the DDC to better fit my own library (especially
technology/programming/CS books). It seems kind of silly to even make
it public on GitHub, but I can imagine someone wanting to edit it for
their own use.</p>
<p>I couldn&rsquo;t decide how to organize my ebooks, and eventually decided to
just use DDC. This would help me become more familiar with the system,
and also I can just look up a book&rsquo;s official DDC number when I can&rsquo;t
decide where it belongs in my library.</p>
<p>It isn&rsquo;t strict DDC, though. The 000s, which in real DDC are a mix of
&ldquo;general works&rdquo; and computing, have been completely redesigned for
modern computing topics. Everything from 100 up follows the standard
divisions: philosophy, religion, social sciences, language, science,
arts, literature by original language, history by region.</p>
<p>The other deliberate departure is that the numbers only go two levels
deep. Below that, directories are named, not numbered:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Library/000_Information_Technology/001_Foundations/Algorithms_Data_Structures
</span></span><span class="line"><span class="cl">Library/000_Information_Technology/006_Security/Cryptography
</span></span><span class="line"><span class="cl">Library/900_History/940_Europe/Scandinavia
</span></span></code></pre></div><p>That keeps the top of the tree principled and the bottom of it
flexible - I can add, rename, or delete leaf directories freely
without worrying about whether the numbering still makes sense.</p>
<p>The whole thing is one bash script that creates the tree, and it&rsquo;s
meant to be edited rather than run as-is. It reflects the books I have
and expect to have; you&rsquo;d want to delete the branches you&rsquo;ll never
use and add your own.</p>
<p>It also pairs with <a href="/projects/llm-librarian/">llm-librarian</a> which uses a tree like this one as
the fixed set of destinations it&rsquo;s allowed to file books into.</p>
<p>The script is <a href="https://github.com/drewherron/drewey-decimal">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>review-sentiment</title><link>https://www.drewherron.com/projects/review-sentiment/</link><pubDate>Tue, 04 Aug 2026 23:17:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/review-sentiment/</guid><description>&lt;p&gt;This was a group project for a machine learning course at Portland
State. The task was predicting the star rating (1 to 5) of an Amazon
review from its text, and we split it by method - a Naive Bayes
classifier, a multilayer perceptron, and BERT - so we could compare
them on the same data. I was in charge of BERT.&lt;/p&gt;
&lt;p&gt;That mostly meant fine-tuning &lt;code&gt;bert-base-uncased&lt;/code&gt; for five-class
sequence classification: tokenizing reviews to a fixed length,
attaching a classification head, and training with a low learning
rate. It was a lot of practice with the mechanics of adapting a
pretrained model to a specific task, as opposed to training something
from scratch.&lt;/p&gt;</description><content:encoded><![CDATA[<p>This was a group project for a machine learning course at Portland
State. The task was predicting the star rating (1 to 5) of an Amazon
review from its text, and we split it by method - a Naive Bayes
classifier, a multilayer perceptron, and BERT - so we could compare
them on the same data. I was in charge of BERT.</p>
<p>That mostly meant fine-tuning <code>bert-base-uncased</code> for five-class
sequence classification: tokenizing reviews to a fixed length,
attaching a classification head, and training with a low learning
rate. It was a lot of practice with the mechanics of adapting a
pretrained model to a specific task, as opposed to training something
from scratch.</p>
<p>I also added a second metric alongside plain accuracy: predictions
within one star of the true rating. Star ratings are ordinal, and
treating them as five unrelated classes makes a 4-for-5 mistake look
as bad as a 1-for-5 mistake, which doesn&rsquo;t reflect how wrong the model
actually is.</p>
<p>Again this was just a school project, but if you&rsquo;re interested the code
is <a href="https://github.com/drewherron/review-sentiment">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>word-ladder</title><link>https://www.drewherron.com/projects/word-ladder/</link><pubDate>Tue, 04 Aug 2026 23:23:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/word-ladder/</guid><description>&lt;p&gt;Another university group project, this time for a course in artificial
intelligence. The program&amp;rsquo;s purpose is solving word ladder puzzles -
getting from one word to another by changing a single letter at a
time, with every intermediate step a real word (&lt;code&gt;cold&lt;/code&gt;, &lt;code&gt;cord&lt;/code&gt;,
&lt;code&gt;word&lt;/code&gt;, &lt;code&gt;ward&lt;/code&gt;, &lt;code&gt;warm&lt;/code&gt;). The real point of the program was to explore
two very different search strategies against the same problem and
compare them.&lt;/p&gt;
&lt;p&gt;The A* version is the more straightforward strategy. Each node is a
word, its neighbors are every word in the dictionary one letter away,
and the heuristic is Hamming distance to the target: how many letters
still differ, which never overestimates the steps remaining, since a
step can only fix one letter at a time. Filtering the dictionary down
to words of the right length first keeps the branching manageable.&lt;/p&gt;</description><content:encoded><![CDATA[<p>Another university group project, this time for a course in artificial
intelligence. The program&rsquo;s purpose is solving word ladder puzzles -
getting from one word to another by changing a single letter at a
time, with every intermediate step a real word (<code>cold</code>, <code>cord</code>,
<code>word</code>, <code>ward</code>, <code>warm</code>). The real point of the program was to explore
two very different search strategies against the same problem and
compare them.</p>
<p>The A* version is the more straightforward strategy. Each node is a
word, its neighbors are every word in the dictionary one letter away,
and the heuristic is Hamming distance to the target: how many letters
still differ, which never overestimates the steps remaining, since a
step can only fix one letter at a time. Filtering the dictionary down
to words of the right length first keeps the branching manageable.</p>
<p>The genetic algorithm approach is more interesting (I think). A
chromosome is an entire candidate ladder, start word to end word, with
random junk in between; fitness scores each adjacent pair by how close
the two words are, rewarding single-letter steps heavily, penalizing
repeats, and docking points for length so ladders don&rsquo;t sprawl. Then
the usual operators, plus a few specific to the shape of this problem:
growth and shrink mutations that insert or remove a word mid-ladder,
seeding the initial population with words one letter off the
endpoints, and targeted mutation of whichever link in the chain is the
weakest. Most of those are toggles, which is what the comparison
scripts exist to test.</p>
<p>The code is <a href="https://github.com/drewherron/word_ladder">here</a> on GitHub.</p>
]]></content:encoded></item><item><title>metric-trainer</title><link>https://www.drewherron.com/projects/metric-trainer/</link><pubDate>Fri, 31 Jul 2026 14:34:00 -0700</pubDate><author>dherron@mailbox.org (Drew Herron)</author><guid>https://www.drewherron.com/projects/metric-trainer/</guid><description>&lt;p&gt;A terminal drill program for getting a feel for the metric system if
you grew up with imperial (or the reverse, I suppose). It asks you to
convert a value, you type an answer, and it tells you whether you were
close enough - within 1% by default, or 5% in easy mode, which also
sticks to rounder numbers. There&amp;rsquo;s a &lt;code&gt;--whole&lt;/code&gt; flag for whole numbers
only.&lt;/p&gt;
&lt;p&gt;Questions come from four categories - distance, weight, temperature,
volume - in both directions, and you can practice any combination of
them by typing the category letters together. Stats persist between
sessions so you can see which category you&amp;rsquo;re worst at. It&amp;rsquo;s a small C
program with no dependencies.&lt;/p&gt;</description><content:encoded><![CDATA[<p>A terminal drill program for getting a feel for the metric system if
you grew up with imperial (or the reverse, I suppose). It asks you to
convert a value, you type an answer, and it tells you whether you were
close enough - within 1% by default, or 5% in easy mode, which also
sticks to rounder numbers. There&rsquo;s a <code>--whole</code> flag for whole numbers
only.</p>
<p>Questions come from four categories - distance, weight, temperature,
volume - in both directions, and you can practice any combination of
them by typing the category letters together. Stats persist between
sessions so you can see which category you&rsquo;re worst at. It&rsquo;s a small C
program with no dependencies.</p>
<p>The code is <a href="https://github.com/drewherron/metric-trainer">here</a> on GitHub.</p>
]]></content:encoded></item></channel></rss>