<?xml version="1.0" encoding="utf-8"?>
<feed xml:lang="en-us" xmlns="http://www.w3.org/2005/Atom"><title>Simon Willison's Weblog: airtable</title><link href="http://simonwillison.net/" rel="alternate"/><link href="http://simonwillison.net/tags/airtable.atom" rel="self"/><id>http://simonwillison.net/</id><updated>2024-01-11T04:40:09+00:00</updated><author><name>Simon Willison</name></author><entry><title>Budgeting with ChatGPT</title><link href="https://simonwillison.net/2024/Jan/11/budgeting-with-chatgpt/#atom-tag" rel="alternate"/><published>2024-01-11T04:40:09+00:00</published><updated>2024-01-11T04:40:09+00:00</updated><id>https://simonwillison.net/2024/Jan/11/budgeting-with-chatgpt/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://www.joncallahan.com/blog/ai-txns/"&gt;Budgeting with ChatGPT&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
Jon Callahan describes an ingenious system he set up to categorize his credit card transactions using GPT 3.5. He has his bank email him details of any transaction over $0, then has an email filter to forward those to Postmark, which sends them via a JSON webhook to a custom Deno Deploy app which cleans the transaction up with a GPT 3.5 prompt (including guessing the merchant) and submits the results to a base in Airtable.

    &lt;p&gt;&lt;small&gt;&lt;/small&gt;Via &lt;a href="https://twitter.com/jondcallahan/status/1745294880937582689"&gt;@jondcallahan&lt;/a&gt;&lt;/small&gt;&lt;/p&gt;


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/email"&gt;email&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/ai"&gt;ai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/airtable"&gt;airtable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/openai"&gt;openai&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/llms"&gt;llms&lt;/a&gt;&lt;/p&gt;



</summary><category term="email"/><category term="ai"/><category term="airtable"/><category term="openai"/><category term="llms"/></entry><entry><title>Porting VaccinateCA to Django</title><link href="https://simonwillison.net/2021/Apr/12/porting-vaccinateca-to-django/#atom-tag" rel="alternate"/><published>2021-04-12T05:18:48+00:00</published><updated>2021-04-12T05:18:48+00:00</updated><id>https://simonwillison.net/2021/Apr/12/porting-vaccinateca-to-django/#atom-tag</id><summary type="html">
    &lt;p&gt;As I mentioned &lt;a href="https://simonwillison.net/2021/Feb/28/vaccinateca/"&gt;back in February&lt;/a&gt;, I've been working with the &lt;a href="https://www.vaccinateca.com/"&gt;VaccinateCA&lt;/a&gt; project to try to bring the pandemic to an end a little earlier by helping gather as accurate a model as possible of where the Covid vaccine is available in California and how people can get it.&lt;/p&gt;
&lt;p&gt;The key activity at VaccinateCA is calling places to check on their availability and eligibility criteria. Up until last night this was powered by a heavily customized Airtable instance, accompanied by a custom JavaScript app for the callers that communicated with the Airtable API via some Netlify functions.&lt;/p&gt;
&lt;p&gt;Today, the flow is powered by a new custom Django backend, running on top of PostgreSQL.&lt;/p&gt;
&lt;h4&gt;The thing you should never do&lt;/h4&gt;
&lt;blockquote class="twitter-tweet"&gt;&lt;p lang="en" dir="ltr"&gt;Here&amp;#39;s one that took me fifteen years to learn: &amp;quot;let&amp;#39;s build a new thing and replace this&amp;quot; is hideously dangerous: 90% of the time you won&amp;#39;t fully replace the old thing, and now you have two problems!&lt;/p&gt;- Simon Willison (@simonw) &lt;a href="https://twitter.com/simonw/status/1145114228170190848?ref_src=twsrc%5Etfw"&gt;June 29, 2019&lt;/a&gt;&lt;/blockquote&gt;
&lt;p&gt;Replacing an existing system with a from-scratch rewrite is risky. Replacing a system that is built on something as flexible as Airtable that is evolving on a daily basis is positively terrifying!&lt;/p&gt;
&lt;p&gt;Airtable served us extremely well, but unfortunately there are hard limits to the number of rows Airtable can handle and we've already bounced up against them and had to archive some of our data. To keep scaling the organization we needed to migrate away.&lt;/p&gt;
&lt;p&gt;We needed to build a matching relational database with a comprehensive, permission-controlled interface for editing it, plus APIs to drive our website and application. And we needed to do it using the most &lt;a href="http://boringtechnology.club/"&gt;boring technology&lt;/a&gt; possible, so we could focus on solving problems directly rather than researching anything new.&lt;/p&gt;
&lt;p&gt;It will never cease to surprise me that Django has attained boring technology status! VaccineCA sits firmly in Django's sweet-spot. So we used that to build our replacement.&lt;/p&gt;
&lt;p&gt;The new Django-based system is called VIAL, for "Vaccine Information Archive and Library" - a neat &lt;a href="https://twitter.com/obra"&gt;Jesse Vincent&lt;/a&gt; bacronym.&lt;/p&gt;
&lt;p&gt;We switched things over to VIAL last night, but we still have activity in Airtable as well. I expect we'll keep using Airtable for the lifetime of the organization - there are plenty of ad-hoc data projects for which it's a perfect fit.&lt;/p&gt;
&lt;p&gt;The most important thing here is to have a trusted single point of truth for any piece of information. I'm not quite ready to declare victory on that point just yet, but hopefully once things settle down over the next few days.&lt;/p&gt;

&lt;p&gt;&lt;img src="https://static.simonwillison.net/static/2021/vial-index.png" style="max-width: 100%" alt="Screenshot of the Django admin VIAL index page" /&gt;&lt;/p&gt;

&lt;h4&gt;Data synchronization patterns&lt;/h4&gt;
&lt;p&gt;The first challenge, before even writing any code, was how to get stuff out of Airtable. I built a tool for this a while ago called &lt;a href="https://datasette.io/tools/airtable-export"&gt;airtable-export&lt;/a&gt;, and it turned out the VaccinateCA team were using it already before I joined!&lt;/p&gt;
&lt;p&gt;&lt;code&gt;airtable-export&lt;/code&gt; was already running several times an hour, backing up the data in JSON format to a GitHub repository (a form of &lt;a href="https://simonwillison.net/2020/Oct/9/git-scraping/"&gt;Git scraping&lt;/a&gt;). This gave us a detailed history of changes to the Airtable data, which occasionally proved extremely useful for answering questions about when a specific record was changed or deleted.&lt;/p&gt;
&lt;p&gt;Having the data in a GitHub repository was also useful because it gave us somewhere to pull data from that wasn't governed by Airtable's rate limits.&lt;/p&gt;
&lt;p&gt;I iterated through a number of different approaches for writing importers for the data.&lt;/p&gt;
&lt;p&gt;Each Airtable table ended up as a single JSON file in our GitHub repository, containing an array of objects - those files got pretty big, topping out at about 80MB.&lt;/p&gt;
&lt;p&gt;I started out with Django management commands, which could be passed a file or a URL. A neat thing about using GitHub for this is that you can use the "raw data" link to obtain a URL with a short-lived token, which grants access to that file. So I could create a short-term URL and paste it directly to my import tool.&lt;/p&gt;
&lt;p&gt;I don't have a good pattern for running Django management commands on Google Cloud Run, so I started moving to API-based import scripts instead.&lt;/p&gt;
&lt;p&gt;The pattern that ended up working best was to provide a &lt;code&gt;/api/importRecords&lt;/code&gt; API endpoint which accepts a JSON array of items.&lt;/p&gt;
&lt;p&gt;The API expects the input to have a unique primary key in each record - &lt;code&gt;airtable_id&lt;/code&gt; in our case. It then uses Django's &lt;a href="https://docs.djangoproject.com/en/3.2/ref/models/querysets/#update-or-create"&gt;update_or_create()&lt;/a&gt; ORM method to create new records if they were missing, and update existing records otherwise.&lt;/p&gt;
&lt;p&gt;One remaining challenge: posting 80MB of JSON to an API in one go would likely run into resource limits. I needed a way to break that input up into smaller batches.&lt;/p&gt;
&lt;p&gt;I ended up building a new tool for this called &lt;a href="https://github.com/simonw/json-post"&gt;json-post&lt;/a&gt;. It has an extremely specific use-case: it's for when you want to POST a big JSON array to an API endpoint but you want to first break it up into batches!&lt;/p&gt;
&lt;p&gt;Here's how to break up the JSON in &lt;code&gt;Reports.json&lt;/code&gt; into 50 item arrays and send them to that API as separate POSTs:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;json-post Reports.json \                              
   "https://example.com/api/importReports" \
   --batch-size 50
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here are some more complex options. Here we need to pass an &lt;code&gt;Authorization: Bearer XXXtokenXXX&lt;/code&gt; API key header, run the array in reverse, record our progress (the JSON responses from the API as newline-delimited JSON) to a log file, set a longer HTTP read timeout and filter for just specific items:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;% json-post Reports.json \                              
   "https://example.com/api/importReports" \
  -h Authorization 'Bearer XXXtokenXXX' \
  --batch-size 50 \
  --reverse \
  --log /tmp/progress.txt \
  --http-read-timeout 20 \
  --filter 'item.get("is_soft_deleted")'
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--filter&lt;/code&gt; option proved particularly useful. As we kicked the tires on VIAL we would spot new bugs - things like the import script failing to correctly record the &lt;code&gt;is_soft_deleted&lt;/code&gt; field we were using in Airtable. Being able to filter that input file with a command-line flag meant we could easily re-run the import just for a subset of reports that were affected by a particular bug.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;--filter&lt;/code&gt; takes a Python expression that gets compiled into a function and passed &lt;code&gt;item&lt;/code&gt; as the current item in the list. I borrowed the pattern from &lt;a href="https://datasette.io/tools/sqlite-transform#user-content-lambda-for-executing-your-own-code"&gt;my sqlite-transform tool&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="value-of-api-logs"&gt;The value of API logs&lt;/h4&gt;
&lt;p&gt;VaccineCA's JavaScript caller application used to send data to Airtable via a Netlify function, which allowed additional authentication to be added built using  Auth0.&lt;/p&gt;
&lt;p&gt;Back in February, the team had the bright idea to log the API traffic to that function to a separate base in Airtable - including full request and response bodies.&lt;/p&gt;
&lt;p&gt;This proved invaluable for debugging. It also meant that when I started building VIAL's alternative implementation of the "submit a call report" API I could replay historic API traffic that had been recorded in that table, giving me a powerful way to exercise the new API with real-world traffic.&lt;/p&gt;
&lt;p&gt;This meant that when we turned on VIAL we could switch our existing JavaScript SPA over to talking to it using a fully tested clone of the existing Airtable-backed API.&lt;/p&gt;
&lt;p&gt;VIAL implements this logging pattern again, this time using Django and PostgreSQL.&lt;/p&gt;
&lt;p&gt;Given that the writable APIs will recieve in the low thousands of requests a day, keeping them in a database table works great. The table has grown to 90MB so far. I'm hoping that the pandemic will be over before we have to worry about logging capacity!&lt;/p&gt;
&lt;p&gt;We're using PostgreSQL &lt;code&gt;jsonb&lt;/code&gt; columns to store the incoming and returned JSON, via Django's &lt;a href="https://docs.djangoproject.com/en/3.2/ref/models/fields/#jsonfield"&gt;JSONField&lt;/a&gt;. This means we can do in-depth API analysis using PostgreSQL's JSON SQL functions! Being able to examine returned JSON error messages or aggregate across incoming request bodies helped enormously when debugging problems with the API import scripts.&lt;/p&gt;
&lt;h4&gt;Storing the original JSON&lt;/h4&gt;
&lt;p&gt;Today, almost all of the data stored in VIAL originated in Airtable. One trick that has really helped build the system is that each of the tables that might contain imported data has both an &lt;code&gt;airtable_id&lt;/code&gt; nullable column and an &lt;code&gt;import_json&lt;/code&gt; JSON field.&lt;/p&gt;
&lt;p&gt;Any time we import a record from Airtable, we record both the ID and the full, original Airtable JSON that we used for the import.&lt;/p&gt;
&lt;p&gt;This is another powerful tool for debugging: we can view the original Airtable JSON directly in the Django admin interface for a record, and confirm that it matches the ORM fields that we set from that.&lt;/p&gt;
&lt;p&gt;I came up with a simple pattern for &lt;a href="https://til.simonwillison.net/django/pretty-print-json-admin"&gt;Pretty-printing all read-only JSON in the Django admin&lt;/a&gt; that helps with this too.&lt;/p&gt;
&lt;h4&gt;Staying as flexible as possible&lt;/h4&gt;
&lt;p&gt;The thing that worried me most about replacing Airtable with Django was Airtable's incredible flexibility. In the organization's short life it has already solved &lt;em&gt;so many&lt;/em&gt; problems by adding new columns in Airtable, or building new views.&lt;/p&gt;
&lt;p&gt;Is it possible to switch to custom software without losing that huge cultural advantage?&lt;/p&gt;
&lt;p&gt;This is the same reason it's so hard for custom software to compete with spreadsheets.&lt;/p&gt;
&lt;p&gt;We've only just made the switch, so we won't know for a while how well we've done at handling this. I have a few mechanisms in place that I'm hoping will help.&lt;/p&gt;
&lt;p&gt;The first is &lt;a href="https://github.com/simonw/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;. I wrote about this project in previous weeknotes &lt;a href="https://simonwillison.net/2021/Mar/14/weeknotes/"&gt;here&lt;/a&gt; and &lt;a href="https://simonwillison.net/2021/Mar/21/django-sql-dashboard-widgets/"&gt;here&lt;/a&gt; - the goal is to bring some of the ideas from &lt;a href="https://datasette.io/"&gt;Datasette&lt;/a&gt; into the Django/PostgreSQL world, by providing a read-only mechanism for constructing SQL queries, bookmarking and saving the results and outputting simple SQL-driven visualizations.&lt;/p&gt;
&lt;p&gt;We have a lot of SQL knowledge at VaccinateCA, so my hope is that people with SQL will be able to solve their own problems, and people who don't know SQL yet will have no trouble finding someone who can help them.&lt;/p&gt;
&lt;p&gt;In the &lt;a href="http://boringtechnology.club/#17"&gt;boring technology&lt;/a&gt; model of things, &lt;code&gt;django-sql-dashboard&lt;/code&gt; counts as the main innovation token I'm spending for this project. I'm optimistic that it will pay off.&lt;/p&gt;
&lt;p&gt;I'm also leaning heavily on Django's migration system, with the aim of making database migrations common and boring, rather than their usual default of being rare and exciting. We're up to 77 migrations already, in a codebase that is just over two months old!&lt;/p&gt;
&lt;p&gt;I think a culture that evolves the database schema quickly and with as little drama as possible is crucial to maintaining the agility that this kind of organization needs.&lt;/p&gt;
&lt;p&gt;Aside from the Django Admin providing the editing interface, everything that comes into and goes out of VIAL happens through APIs. These are fully documented: I want people to be able to build against the APIs independently, especially for things like data import.&lt;/p&gt;
&lt;p&gt;After seeing significant success with PostgreSQL JSON already, I'm considering using it to add even more API-driven flexbility to VIAL in the future. Allowing our client developers to start collecting a new piece of data from our volunteers in an existing JSON field, then migrating that into a separate column once it has proven its value, is very tempting indeed.&lt;/p&gt;
&lt;h4&gt;Open source tools we are using&lt;/h4&gt;
&lt;p&gt;An incomplete list of open source packages we are using for VIAL so far:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pydantic-docs.helpmanual.io/"&gt;pydantic&lt;/a&gt; - as a validation layer for some of the API endpoints&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/python-social-auth/social-app-django"&gt;social-auth-app-django&lt;/a&gt; - to integrate with &lt;a href="https://auth0.com/"&gt;Auth0&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/adamchainz/django-cors-headers"&gt;django-cors-headers&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/mpdavis/python-jose"&gt;python-jose&lt;/a&gt; - for JWTs, which were already in use by our Airtable caller app&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/etianen/django-reversion"&gt;django-reversion&lt;/a&gt; and &lt;a href="https://github.com/jedie/django-reversion-compare/"&gt;django-reversion-compare&lt;/a&gt; to provide a diffable, revertable history of some of our core models&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/django-admin-tools/django-admin-tools"&gt;django-admin-tools&lt;/a&gt; - which adds a handy customizable menu to the admin, good for linking to additional custom tools&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/3YOURMIND/django-migration-linter"&gt;django-migration-linter&lt;/a&gt; - to help avoid accidentally shipping migrations that could cause downtime during a deploy&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pytest-django.readthedocs.io/en/latest/"&gt;pytest-django&lt;/a&gt;, &lt;a href="https://github.com/adamchainz/time-machine"&gt;time-machine&lt;/a&gt; and &lt;a href="https://colin-b.github.io/pytest_httpx/"&gt;pytest-httpx&lt;/a&gt; for our unit tests&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.sentry.io/platforms/python/"&gt;sentry-sdk&lt;/a&gt;, &lt;a href="https://docs.honeycomb.io/getting-data-in/python/beeline/"&gt;honeycomb-beeline&lt;/a&gt; and  &lt;a href="https://github.com/prometheus/client_python"&gt;prometheus-client&lt;/a&gt; for error logging and observability&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Want to help out?&lt;/h4&gt;
&lt;p&gt;VaccinateCA &lt;a href="https://twitter.com/patio11/status/1379587878624190466"&gt;is hiring&lt;/a&gt;! It's an interesting gig, because the ultimate goal is to end the pandemic and put this non-profit permanently out of business. So if you want to help end things faster, get in touch.&lt;/p&gt;

&lt;blockquote class="twitter-tweet"&gt;&lt;p lang="en" dir="ltr"&gt;VaccinateCA is hiring a handful of engineers to help scale our data ingestion and display by more than an order of magnitude.&lt;br /&gt;&lt;br /&gt;If you&amp;#39;d like to register interest:&lt;a href="https://t.co/BSvi40sW1M"&gt;https://t.co/BSvi40sW1M&lt;/a&gt;&lt;br /&gt;&lt;br /&gt;Generalists welcome. Three subprojects; Python backend/pedestrian front-end JS.&lt;/p&gt;- Patrick McKenzie (@patio11) &lt;a href="https://twitter.com/patio11/status/1379587878624190466?ref_src=twsrc%5Etfw"&gt;April 7, 2021&lt;/a&gt;&lt;/blockquote&gt;
&lt;h4&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/main/vscode/language-specific-indentation-settings.md"&gt;Language-specific indentation settings in VS Code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/main/django/efficient-bulk-deletions-in-django.md"&gt;Efficient bulk deletions in Django&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/simonw/til/blob/main/postgresql/unnest-csv.md"&gt;Using unnest() to use a comma-separated string as the input to an IN query&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;Releases this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/json-post"&gt;json-post&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/json-post/releases/tag/0.2"&gt;0.2&lt;/a&gt; - (&lt;a href="https://github.com/simonw/json-post/releases"&gt;3 total releases&lt;/a&gt;) - 2021-04-11
&lt;br /&gt;Tool for posting JSON to an API, broken into pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/airtable-export"&gt;airtable-export&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.7.1"&gt;0.7.1&lt;/a&gt; - (&lt;a href="https://github.com/simonw/airtable-export/releases"&gt;10 total releases&lt;/a&gt;) - 2021-04-09
&lt;br /&gt;Export Airtable data to YAML, JSON or SQLite files on disk&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/simonw/django-sql-dashboard"&gt;django-sql-dashboard&lt;/a&gt;&lt;/strong&gt;: &lt;a href="https://github.com/simonw/django-sql-dashboard/releases/tag/0.6a0"&gt;0.6a0&lt;/a&gt; - (&lt;a href="https://github.com/simonw/django-sql-dashboard/releases"&gt;13 total releases&lt;/a&gt;) - 2021-04-09
&lt;br /&gt;Django app for building dashboards using raw SQL queries&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/migrations"&gt;migrations&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/postgresql"&gt;postgresql&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/airtable"&gt;airtable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/boring-technology"&gt;boring-technology&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="django"/><category term="django-admin"/><category term="migrations"/><category term="postgresql"/><category term="weeknotes"/><category term="airtable"/><category term="vaccinate-ca"/><category term="boring-technology"/></entry><entry><title>The Airtable formulas at the heart of everything</title><link href="https://simonwillison.net/2021/Mar/23/vaccinateca-2021-03-23/#atom-tag" rel="alternate"/><published>2021-03-23T17:00:00+00:00</published><updated>2021-03-23T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Mar/23/vaccinateca-2021-03-23/#atom-tag</id><summary type="html">
    &lt;p class="context"&gt;&lt;em&gt;Originally posted to my internal blog at VaccinateCA&lt;/em&gt;&lt;/p&gt;&lt;p&gt;While working on &lt;a href="https://github.com/CAVaccineInventory/vial/issues/53"&gt;building a Counties.json API endpoint&lt;/a&gt; for VIAL I realized I wasn't entirely sure how the "Total reports" and "Yeses" numbers in this piece of JSON were calculated:&lt;/p&gt;
&lt;div class="highlight highlight-source-json"&gt;&lt;pre&gt;    {
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;County&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Glenn County&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;County vaccination reservations URL&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://www.countyofglenn.net/news/press-release-public-health-public-information-public-notice/20210301/covid-19-vaccine-interest&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Facebook Page&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://www.facebook.com/GlennCountyHHSA/&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Notes&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;(Updated: March 22) **Eligibility**: 65+, A...&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Total reports&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;11&lt;/span&gt;,
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Twitter Page&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://twitter.com/glenncountyoes&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Vaccine info URL&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;https://www.countyofglenn.net/dept/health-human-services/public-health/covid-19/covid-19-vaccine-information&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;,
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;Yeses&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-c1"&gt;3&lt;/span&gt;,
      &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;id&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;: &lt;span class="pl-s"&gt;&lt;span class="pl-pds"&gt;"&lt;/span&gt;rec0QOd7EXzSuZZvN&lt;span class="pl-pds"&gt;"&lt;/span&gt;&lt;/span&gt;
    }&lt;/pre&gt;&lt;/div&gt;
&lt;p&gt;This lead down the fascinating rabbit-hole that is our Airtable formulas. Numbers like this are calculated using a combination of Airtable Rollups and Airtable Formulas, which invisibly form the heart of our entire organization.&lt;/p&gt;
&lt;p&gt;Of particular interest: the "Latest report yes?" column on Locations, currently defined like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;IF(
  OR(
    SEARCH("Vaccinating essential workers", ARRAYJOIN({Availability Info})) != "",
    SEARCH("Scheduling second dose only", ARRAYJOIN({Availability Info})) != "",
    SEARCH("Yes", ARRAYJOIN({Availability Info})) != ""
  ),
  1, 0
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Here are screenshots I gathered as I followed them back to figure out how they worked:&lt;/p&gt;
&lt;p&gt;County "Yeses":&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Counties_-_Airtable" src="https://user-images.githubusercontent.com/9599/112202953-37777980-8bcf-11eb-8092-743ca95e59a5.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;County total reports:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Counties_-_Airtable" src="https://user-images.githubusercontent.com/9599/112203011-465e2c00-8bcf-11eb-9cde-ee6486058f7a.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;County percentage yes:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Counties_-_Airtable" src="https://user-images.githubusercontent.com/9599/112203064-583fcf00-8bcf-11eb-9474-3737d44daa3b.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;These rollups use formulas on the Locations table:&lt;/p&gt;
&lt;p&gt;Latest report:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Locations_-_Airtable" src="https://user-images.githubusercontent.com/9599/112203180-7c031500-8bcf-11eb-916e-e669140fdbff.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;Number of reports:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Locations_-_Airtable" src="https://user-images.githubusercontent.com/9599/112203232-8ae9c780-8bcf-11eb-86fd-dfeff102e47e.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;Latest report yes?&lt;/p&gt;
&lt;p&gt;&lt;img alt="Staging__CA_COVID_vaccines_copy__Locations_-_Airtable" src="https://user-images.githubusercontent.com/9599/112203362-b371c180-8bcf-11eb-8c16-0ac73ba97165.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;And a bonus one: here's &lt;code&gt;is_callable_now&lt;/code&gt; which I need to better understand how our call targetting works:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;AND(
  OR(
    {Do not call until} = BLANK(),
    {Do not call until} &amp;lt; NOW()
  ),
  OR(
    {Next available to app flow} = BLANK(),
    {Next available to app flow} &amp;lt; NOW()
  ),
  NOT({is_soft_deleted}),
  NOT({do_not_call}),
  {Phone number}
)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img alt="CA_COVID_vaccines__Locations_-_Airtable" src="https://user-images.githubusercontent.com/9599/112205115-b40b5780-8bd1-11eb-9e1f-c833cc8c73e9.png" style="max-width:100%;"/&gt;&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/airtable"&gt;airtable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="airtable"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>Importing data from Airtable into Django, plus a search engine for all our code</title><link href="https://simonwillison.net/2021/Feb/24/vaccinateca-2021-02-24/#atom-tag" rel="alternate"/><published>2021-02-24T17:00:00+00:00</published><updated>2021-02-24T17:00:00+00:00</updated><id>https://simonwillison.net/2021/Feb/24/vaccinateca-2021-02-24/#atom-tag</id><summary type="html">
    &lt;p class="context"&gt;&lt;em&gt;Originally posted to my internal blog at VaccinateCA&lt;/em&gt;&lt;/p&gt;&lt;p&gt;I made &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/commits/12514c713686e640036289c0411a59af5df9d4ed"&gt;a bunch of progress&lt;/a&gt; on the Django backend prototype-that-soon-won’t-be-a-prototype today.&lt;/p&gt;
&lt;h4&gt;
Importing data from Airtable&lt;/h4&gt;
&lt;p&gt;My goal for the day was to get some real data into the prototype, imported from Airtable. I’ve now done that with the two most important tables: Locations (aka places that people can go to get the vaccine) and CallReports, created by volunteers making phone calls.&lt;/p&gt;
&lt;p&gt;I achieved this by writing two new Django management commands:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;./manage.py import_airtable_locations&lt;/code&gt; (&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/12514c713686e640036289c0411a59af5df9d4ed/vaccinate/core/import_utils.py#L21-L71"&gt;code&lt;/a&gt;, &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/12514c713686e640036289c0411a59af5df9d4ed/vaccinate/core/test_airtable_import.py#L52-L74"&gt;test&lt;/a&gt;) populates the Django Locations model by pulling data from the Locations table in Airtable.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;./manage.py import_airtable_reports&lt;/code&gt; (&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/12514c713686e640036289c0411a59af5df9d4ed/vaccinate/core/import_utils.py#L74-L117"&gt;code&lt;/a&gt;, &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/12514c713686e640036289c0411a59af5df9d4ed/vaccinate/core/test_airtable_import.py#L77-L128"&gt;test&lt;/a&gt;) populates the Django CallReports model using data from the Reports table in Airtable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both of these importers follow &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/blob/12514c713686e640036289c0411a59af5df9d4ed/vaccinate/core/management/commands/import_airtable_locations.py#L6-L28"&gt;the same pattern&lt;/a&gt;: you can point them directly at a Locations.json file you’ve already downloaded:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;./manage.py import_airtable_locations \
  --json-file=Locations.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Or you can use a GitHub personal access token to have them load the data directly from our airtable-data-backup  repository, provided you have access to that.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;./manage.py import_airtable_locations \
  --github-token=xxxx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I built the GitHub token mechanism to make it easy to run this command on a server, without having to mess around uplooading JSON files first. Since the prototype is running on Heroku I can pull a fresh import into it directly by running the following command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;% heroku run bash -a vaccinateca-preview
Running bash on ⬢ vaccinateca-preview... up, run.3026 (Hobby)
~ $ vaccinate/manage.py import_airtable_locations --github-token xxx
Skipping rec0xZ5EaKnnynfDa [name=CVS Pharmacy® &amp;amp; Drug Store at 25272 Marguerite Pkwy, Mission Viejo, CA 92692], reason=No latitude
Skipping rec7nHXCuSYRR61V0 [name=None], reason=No name
Skipping rec8Xk6kn4SvAKeEm [name=None], reason=No name
Skipping recCYZZRJCRlXykun [name=None], reason=No name
Skipping recJt0iQbqmglF0XL [name=Dignity Health (Woodland)], reason=No county
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The command outputs a line for each item it fails to import. This turns out to be an extremely useful way to spot invalid data - locations with no name, latitude or county for example.&lt;/p&gt;
&lt;p&gt;The script successfully imported 7038 locations, skipping just 17.&lt;/p&gt;
&lt;p&gt;My call report importer needs a lot more work. It imported 21,029 call records, but  skipped 962. More details &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/9#issuecomment-785529714"&gt;in the GitHub issue thread&lt;/a&gt;.&lt;/p&gt;
&lt;h4&gt;
Django Admin customization&lt;/h4&gt;
&lt;p&gt;I also made some tweaks to the Django admin. Here’s a screenshot of the call records list as it stands now.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Banners_and_Alerts_and_Select_call_report_to_change___Django_site_admin" src="https://user-images.githubusercontent.com/9599/109108464-7b6a8200-76e8-11eb-8ea3-b0d102cb7a03.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;p&gt;And here's the locations table:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Banners_and_Alerts_and_Select_location_to_change___Django_site_admin" src="https://user-images.githubusercontent.com/9599/109108511-9210d900-76e8-11eb-9324-6305edf858e3.png" style="max-width:100%;"/&gt;&lt;/p&gt;
&lt;h4&gt;
SSO with Auth0&lt;/h4&gt;
&lt;p&gt;This isn't fully working yet, but it's nearly there. I've integrated Auth0 SSO with the Django app, with the goal being that any staff member can use Auth0 to sign in to the Django Admin panel and view and modify the data there.&lt;/p&gt;
&lt;p&gt;You can try it out on the &lt;a href="https://vaccinateca-preview.herokuapp.com/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/&lt;/a&gt; page - once it's fully working, you'll be able to sign in and then visit &lt;a href="https://vaccinateca-preview.herokuapp.com/admin/" rel="nofollow"&gt;https://vaccinateca-preview.herokuapp.com/admin/&lt;/a&gt; to interact with the data.&lt;/p&gt;
&lt;p&gt;Here's &lt;a href="https://github.com/CAVaccineInventory/django.vaccinate/issues/8"&gt;the ongoing issue thread&lt;/a&gt;. I ended up creating a brand new role in Auth0 called "Vaccinate CA Staff" - I've figured out how to access those roles from the Django app when a user signs in, so I can then use membership of that role to control access to the admin panel.&lt;/p&gt;
&lt;h4&gt;
Even more ambitious goals&lt;/h4&gt;
&lt;p&gt;Since the Django backend work is going at a very healthy pace, I'm extending my ambitions for it a bit.&lt;/p&gt;
&lt;p&gt;The goal of the app is to replace Airtable as the point of truth for the data collected by our calling volunteers AND as the data source behind the public-facing &lt;a href="https://www.vaccinateca.com/" rel="nofollow"&gt;https://www.vaccinateca.com/&lt;/a&gt; site.&lt;/p&gt;
&lt;p&gt;Once I've made some improvements to the schema informed by the data importing project, I think the next step will be to spin up just enough of an API endpoint that the calling app can start writing to Django in parallel to writing to Airtable.&lt;/p&gt;
&lt;p&gt;This can be done inside the &lt;a href="https://github.com/CAVaccineInventory/help.vaccinate/blob/ba54e04a386d5ba10763331ef2f0c90946443d2f/netlify/functions/submitReport/index.js#L74"&gt;Netlify function&lt;/a&gt; used by the app. I'd like that function to continue writing to Airtable but also to write to the Django/PostgreSQL stack, wrapped in an error handler so failures there are ignored.&lt;/p&gt;
&lt;p&gt;Then we can run the Django app as a silent partner to Airtable for a few days and compare the results gathered by the two, to gain confidence before switching over from one to the other.&lt;/p&gt;
&lt;p&gt;Getting that running will be my goal for next week. If all goes well we may find we can make the full switch to the Django backend within a couple of weeks.&lt;/p&gt;
&lt;h4&gt;
Regular expression code search across all of our repos&lt;/h4&gt;
&lt;p&gt;I love code search. I particularly like being able to search code with regular expressions, and then share links to those searches with other engineers.&lt;/p&gt;
&lt;p&gt;The best code search tool I’ve ever used is &lt;a href="https://github.com/BurntSushi/ripgrep"&gt;ripgrep&lt;/a&gt;. A few months ago &lt;a href="https://simonwillison.net/2020/Nov/28/datasette-ripgrep/" rel="nofollow"&gt;I built a simple web wrapper&lt;/a&gt; around ripgrep called &lt;a href="https://datasette.io/plugins/datasette-ripgrep" rel="nofollow"&gt;datasette-ripgrep&lt;/a&gt;. This evening I deployed a copy of it against the source code from nine of our code repos:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/airtable-sql-science"&gt;CAVaccineInventory/airtable-sql-science&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/gists"&gt;CAVaccineInventory/gists&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/airtable-export"&gt;CAVaccineInventory/airtable-export&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/data-engineering"&gt;CAVaccineInventory/data-engineering&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/help.vaccinate"&gt;CAVaccineInventory/help.vaccinate&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/airtableApps"&gt;CAVaccineInventory/airtableApps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/django.vaccinate"&gt;CAVaccineInventory/django.vaccinate&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/site"&gt;CAVaccineInventory/site&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/CAVaccineInventory/vaccinebot"&gt;CAVaccineInventory/vaccinebot&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can access the search engine here (nicer URL coming soon):&lt;/p&gt;
&lt;p&gt;&lt;a href="https://vaccinateca-ripgrep-j7hipcg4aq-uc.a.run.app/" rel="nofollow"&gt;https://vaccinateca-ripgrep-j7hipcg4aq-uc.a.run.app/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You'll need to sign in with GitHub - I'm protecting the site using &lt;a href="https://datasette.io/plugins/datasette-auth-github" rel="nofollow"&gt;datasette-auth-github&lt;/a&gt; configured to only allow in members of the &lt;code&gt;CAVaccineInventor&lt;/code&gt; GitHub organization.&lt;/p&gt;
&lt;p&gt;Once you've signed in, try this example search:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://vaccinateca-ripgrep-j7hipcg4aq-uc.a.run.app/-/ripgrep?pattern=fetch%5C%28%7CfetchJsonFromEndpoint&amp;amp;glob=*.js" rel="nofollow"&gt;https://vaccinateca-ripgrep-j7hipcg4aq-uc.a.run.app/-/ripgrep?pattern=fetch%5C%28%7CfetchJsonFromEndpoint&amp;amp;glob=*.js&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;That runs a search for &lt;code&gt;fetch\(|fetchJsonFromEndpoint&lt;/code&gt; across all of our &lt;code&gt;*.js&lt;/code&gt; files, which shows us everywhere we are making an HTTP reuest using either &lt;code&gt;fetch()&lt;/code&gt; or our own &lt;code&gt;fetchJsonFromEndpoint()&lt;/code&gt; function.&lt;/p&gt;
&lt;p&gt;The repo for the search engine &lt;a href="https://github.com/CAVaccineInventory/vaccinateca-ripgrep"&gt;is here&lt;/a&gt;.&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/django"&gt;django&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/django-admin"&gt;django-admin&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/airtable"&gt;airtable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca"&gt;vaccinate-ca&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/vaccinate-ca-blog"&gt;vaccinate-ca-blog&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="django"/><category term="django-admin"/><category term="airtable"/><category term="vaccinate-ca"/><category term="vaccinate-ca-blog"/></entry><entry><title>Weeknotes: airtable-export, generating screenshots in GitHub Actions, Dogsheep!</title><link href="https://simonwillison.net/2020/Sep/3/weeknotes-airtable-screenshots-dogsheep/#atom-tag" rel="alternate"/><published>2020-09-03T23:28:29+00:00</published><updated>2020-09-03T23:28:29+00:00</updated><id>https://simonwillison.net/2020/Sep/3/weeknotes-airtable-screenshots-dogsheep/#atom-tag</id><summary type="html">
    &lt;p&gt;This week I figured out how to populate Datasette from Airtable, wrote code to generate social media preview card page screenshots using Puppeteer, and made a big breakthrough with my Dogsheep project.&lt;/p&gt;
&lt;h4 id="weeknotes-2020-09-03-airtable-export"&gt;airtable-export&lt;/h4&gt;
&lt;p&gt;I wrote about &lt;a href="https://www.rockybeaches.com/"&gt;Rocky Beaches&lt;/a&gt; in my weeknotes &lt;a href="https://simonwillison.net/2020/Aug/21/weeknotes-rocky-beaches/"&gt;two weeks ago&lt;/a&gt;. It's a new website built by Natalie Downe that showcases great places to go rockpooling (tidepooling in American English), mixing in tide data from NOAA and species sighting data from iNaturalist.&lt;/p&gt;
&lt;p&gt;Rocky Beaches is powered by Datasette, using a GitHub Actions workflow that builds the site's underlying SQLite database using API calls and YAML data stored in the GitHub repository.&lt;/p&gt;
&lt;p&gt;Natalie wanted to use Airtable to maintain the structured data for the site, rather than hand-editing a YAML file. So I built &lt;a href="https://github.com/simonw/airtable-export"&gt;airtable-export&lt;/a&gt;, a command-line script for sucking down all of the data from an Airtable instance and writing it to disk as YAML or JSON.&lt;/p&gt;
&lt;p&gt;You run it like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;airtable-export out/ mybaseid table1 table2 --key=key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This will create a folder called &lt;code&gt;out/&lt;/code&gt; with a &lt;code&gt;.yml&lt;/code&gt; file for each of the tables.&lt;/p&gt;
&lt;p&gt;Sadly the Airtable API doesn't yet provide a mechanism to list all of the tables in a database (a &lt;a href="https://community.airtable.com/t/list-tables-given-api-key-and-baseid/1173"&gt;long-running feature request&lt;/a&gt;) so you have to list the tables yourself.&lt;/p&gt;
&lt;p&gt;We're now &lt;a href="https://github.com/natbat/rockybeaches/blob/32a010292e7c1ba47db1a86523a61c666d977074/.github/workflows/deploy.yml#L31-L44"&gt;running that command&lt;/a&gt; as part of the Rocky Beaches build script, and committing the latest version of the YAML file back to the GitHub repo (thus gaining a &lt;a href="https://github.com/natbat/rockybeaches/commits/main/airtable"&gt;full change history&lt;/a&gt; for that data).&lt;/p&gt;
&lt;h4 id="weeknotes-2020-09-03-social-media-cards-tils"&gt;Social media cards for my TILs&lt;/h4&gt;
&lt;p&gt;I really like social media cards - &lt;code&gt;og:image&lt;/code&gt; HTML meta attributes for Facebook and &lt;code&gt;twitter:image&lt;/code&gt; for Twitter. I wanted them for articles on my &lt;a href="https://til.simonwillison.net/"&gt;TIL website&lt;/a&gt; since I often share those via Twitter.&lt;/p&gt;
&lt;p&gt;One catch: my TILs aren't very image heavy. So I decided to generate screenshots of the pages and use those as the 2x1 social media card images.&lt;/p&gt;
&lt;p&gt;The best way I know of programatically generating screenshots is to use &lt;a href="https://developers.google.com/web/tools/puppeteer"&gt;Puppeteer&lt;/a&gt;, a Node.js library for automating a headless instance of the Chrome browser that is maintained by the Chrome DevTools team.&lt;/p&gt;
&lt;p&gt;My first attempt was to run Puppeteer in an AWS Lambda function on &lt;a href="https://vercel.com/"&gt;Vercel&lt;/a&gt;. I remembered seeing an example of how to do this in the Vercel documentation a few years ago. The example isn't there any more, but I found the &lt;a href="https://github.com/vercel/now-examples/pull/207"&gt;original pull request&lt;/a&gt; that introduced it.&lt;/p&gt;
&lt;p&gt;Since the example was MIT licensed I created my own fork at &lt;a href="https://github.com/simonw/puppeteer-screenshot"&gt;simonw/puppeteer-screenshot&lt;/a&gt; and updated it to work with the latest Chrome.&lt;/p&gt;
&lt;p&gt;It's pretty resource intensive, so I also added a secret &lt;code&gt;?key=&lt;/code&gt; mechanism so only my own automation code could call my instance running on Vercel.&lt;/p&gt;
&lt;p&gt;I needed to store the generated screenshots somewhere. They're pretty small - on the order of 60KB each - so I decided to store them in my SQLite database itself and use my &lt;a href="https://github.com/simonw/datasette-media"&gt;datasette-media&lt;/a&gt; plugin (see &lt;a href="https://simonwillison.net/2020/Jul/30/fun-binary-data-and-sqlite/"&gt;Fun with binary data and SQLite&lt;/a&gt;) to serve them up.&lt;/p&gt;
&lt;p&gt;This worked! Until it didn't... I ran into a showstopper bug when I realized that the screenshot process relies on the page being live on the site... but when a new article is added it's not live when the build process works, so the generated screenshot &lt;a href="https://github.com/simonw/til/issues/23"&gt;is of the 404 page&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;So I reworked it to generate the screenshots inside the GitHub Action as part of the build script, using &lt;a href="https://github.com/JarvusInnovations/puppeteer-cli"&gt;puppeteer-cli&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;My &lt;a href="https://github.com/simonw/til/blob/3fca996228ad54ee433b25840fcd3682e9f7bbfd/generate_screenshots.py"&gt;generate_screenshots.py&lt;/a&gt; script handles this, by first shelling out to &lt;code&gt;datasette --get&lt;/code&gt; to render the HTML for the page, then running &lt;code&gt;puppeteer&lt;/code&gt; to generate the screenshot. Relevant code:&lt;/p&gt;
&lt;pre&gt;&lt;span class="pl-k"&gt;def&lt;/span&gt; &lt;span class="pl-en"&gt;png_for_path&lt;/span&gt;(&lt;span class="pl-s1"&gt;path&lt;/span&gt;):
    &lt;span class="pl-c"&gt;# Path is e.g. /til/til/python_debug-click-with-pdb.md&lt;/span&gt;
    &lt;span class="pl-s1"&gt;page_html&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-en"&gt;str&lt;/span&gt;(&lt;span class="pl-v"&gt;TMP_PATH&lt;/span&gt; &lt;span class="pl-c1"&gt;/&lt;/span&gt; &lt;span class="pl-s"&gt;"generate-screenshots-page.html"&lt;/span&gt;)
    &lt;span class="pl-c"&gt;# Use datasette to generate HTML&lt;/span&gt;
    &lt;span class="pl-s1"&gt;proc&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;subprocess&lt;/span&gt;.&lt;span class="pl-en"&gt;run&lt;/span&gt;([&lt;span class="pl-s"&gt;"datasette"&lt;/span&gt;, &lt;span class="pl-s"&gt;"."&lt;/span&gt;, &lt;span class="pl-s"&gt;"--get"&lt;/span&gt;, &lt;span class="pl-s1"&gt;path&lt;/span&gt;], &lt;span class="pl-s1"&gt;capture_output&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;)
    &lt;span class="pl-en"&gt;open&lt;/span&gt;(&lt;span class="pl-s1"&gt;page_html&lt;/span&gt;, &lt;span class="pl-s"&gt;"wb"&lt;/span&gt;).&lt;span class="pl-en"&gt;write&lt;/span&gt;(&lt;span class="pl-s1"&gt;proc&lt;/span&gt;.&lt;span class="pl-s1"&gt;stdout&lt;/span&gt;)
    &lt;span class="pl-c"&gt;# Now use puppeteer screenshot to generate a PNG&lt;/span&gt;
    &lt;span class="pl-s1"&gt;proc2&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;subprocess&lt;/span&gt;.&lt;span class="pl-en"&gt;run&lt;/span&gt;(
        [
            &lt;span class="pl-s"&gt;"puppeteer"&lt;/span&gt;,
            &lt;span class="pl-s"&gt;"screenshot"&lt;/span&gt;,
            &lt;span class="pl-s1"&gt;page_html&lt;/span&gt;,
            &lt;span class="pl-s"&gt;"--viewport"&lt;/span&gt;,
            &lt;span class="pl-s"&gt;"800x400"&lt;/span&gt;,
            &lt;span class="pl-s"&gt;"--full-page=false"&lt;/span&gt;,
        ],
        &lt;span class="pl-s1"&gt;capture_output&lt;/span&gt;&lt;span class="pl-c1"&gt;=&lt;/span&gt;&lt;span class="pl-c1"&gt;True&lt;/span&gt;,
    )
    &lt;span class="pl-s1"&gt;png_bytes&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;proc2&lt;/span&gt;.&lt;span class="pl-s1"&gt;stdout&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-s1"&gt;png_bytes&lt;/span&gt;&lt;/pre&gt;
&lt;p&gt;This worked great! Except for one thing... the site is hosted on Vercel, and Vercel has a 5MB &lt;a href="https://vercel.com/docs/platform/limits#serverless-function-payload-size-limit"&gt;response size limit&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Every time my GitHub build script runs it downloads the previous SQLite database file, so it can avoid regenerating screenshots and HTML for pages that haven't changed.&lt;/p&gt;
&lt;p&gt;The addition of the binary screenshots drove the size of the SQLite database over 5MB, so the part of my script that retrieved the previous database &lt;a href="https://github.com/simonw/til/issues/25"&gt;no longer worked&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I needed a reliable way to store that 5MB (and probably eventually 10-50MB) database file in between runs of my action.&lt;/p&gt;
&lt;p&gt;The best place to put this would be an S3 bucket, but I find the process of setting up IAM permissions for access to a new bucket so infuriating that I couldn't bring myself to do it.&lt;/p&gt;
&lt;p&gt;So... I created a new dedicated GitHub repository, &lt;a href="https://github.com/simonw/til-db"&gt;simonw/til-db&lt;/a&gt;, and updated my action to store the binary file in that repo - using &lt;a href="https://github.com/simonw/til/blob/1e29c3fe5e90c29b0e71d87dba805484ceb4393c/.github/workflows/build.yml#L80-L86"&gt;a force push&lt;/a&gt; so the repo doesn't need to maintain unnecessary version history of the binary asset.&lt;/p&gt;
&lt;p&gt;This is an abomination of a hack, and it made me cackle a lot. I &lt;a href="https://twitter.com/simonw/status/1301029346614718465"&gt;tweeted about it&lt;/a&gt; and got the suggestion to try &lt;a href="https://git-lfs.github.com/"&gt;Git LFS&lt;/a&gt; instead, which would definitely be a more appropriate way to solve this problem.&lt;/p&gt;
&lt;h4 id="weeknotes-2020-09-03-rendering-markdown"&gt;Rendering Markdown&lt;/h4&gt;
&lt;p&gt;I write my blog entries in Markdown and transform them into HTML before I post them on my blog. Some day I'll teach my blog to render Markdown itself, but so far I've got by through copying and pasting into Markdown tools.&lt;/p&gt;
&lt;p&gt;My favourite Markdown flavour is GitHub's, which adds a bunch of useful capabilities - most notably the ability to apply syntax highlighting. GitHub &lt;a href="https://docs.github.com/en/rest/reference/markdown"&gt;expose an API&lt;/a&gt; that applies their Markdown formatter and returns the resulting HTML.&lt;/p&gt;
&lt;p&gt;I built myself &lt;a href="https://til.simonwillison.net/tools/render-markdown"&gt;a quick and scrappy tool&lt;/a&gt; in JavaScript that sends Markdown through their API and then applies a few DOM manipulations to clean up what comes back. It was a nice opportunity to write some modern vanilla JavaScript using &lt;code&gt;fetch()&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight highlight-source-js"&gt;&lt;pre&gt;&lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;function&lt;/span&gt; &lt;span class="pl-en"&gt;render&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;markdown&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;return&lt;/span&gt; &lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-en"&gt;fetch&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'https://api.github.com/markdown'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
        &lt;span class="pl-c1"&gt;method&lt;/span&gt;: &lt;span class="pl-s"&gt;'POST'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
        &lt;span class="pl-c1"&gt;headers&lt;/span&gt;: &lt;span class="pl-kos"&gt;{&lt;/span&gt;
            &lt;span class="pl-s"&gt;'Content-Type'&lt;/span&gt;: &lt;span class="pl-s"&gt;'application/json'&lt;/span&gt;
        &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt;
        &lt;span class="pl-c1"&gt;body&lt;/span&gt;: &lt;span class="pl-c1"&gt;JSON&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;stringify&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;{&lt;/span&gt;&lt;span class="pl-s"&gt;'mode'&lt;/span&gt;: &lt;span class="pl-s"&gt;'markdown'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-s"&gt;'text'&lt;/span&gt;: &lt;span class="pl-s1"&gt;markdown&lt;/span&gt;&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;
    &lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;text&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;

&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;button&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementsByTagName&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'button'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;[&lt;/span&gt;&lt;span class="pl-c1"&gt;0&lt;/span&gt;&lt;span class="pl-kos"&gt;]&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;output&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'output'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;preview&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-smi"&gt;document&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;getElementById&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'preview'&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;

&lt;span class="pl-s1"&gt;button&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-en"&gt;addEventListener&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s"&gt;'click'&lt;/span&gt;&lt;span class="pl-kos"&gt;,&lt;/span&gt; &lt;span class="pl-k"&gt;async&lt;/span&gt; &lt;span class="pl-k"&gt;function&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt; &lt;span class="pl-kos"&gt;{&lt;/span&gt;
    &lt;span class="pl-k"&gt;const&lt;/span&gt; &lt;span class="pl-s1"&gt;rendered&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-k"&gt;await&lt;/span&gt; &lt;span class="pl-en"&gt;render&lt;/span&gt;&lt;span class="pl-kos"&gt;(&lt;/span&gt;&lt;span class="pl-s1"&gt;input&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;value&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;output&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;value&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;rendered&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
    &lt;span class="pl-s1"&gt;preview&lt;/span&gt;&lt;span class="pl-kos"&gt;.&lt;/span&gt;&lt;span class="pl-c1"&gt;innerHTML&lt;/span&gt; &lt;span class="pl-c1"&gt;=&lt;/span&gt; &lt;span class="pl-s1"&gt;rendered&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;
&lt;span class="pl-kos"&gt;}&lt;/span&gt;&lt;span class="pl-kos"&gt;)&lt;/span&gt;&lt;span class="pl-kos"&gt;;&lt;/span&gt;&lt;/pre&gt;&lt;/div&gt;
&lt;h4 id="weeknotes-2020-09-03-dogsheep-beta"&gt;Dogsheep Beta&lt;/h4&gt;
&lt;p&gt;My most exciting project this week was getting out the first working version of &lt;a href="https://github.com/dogsheep/beta"&gt;Dogsheep Beta&lt;/a&gt; - the search engine that ties together results from my &lt;a href="https://dogsheep.github.io/"&gt;Dogsheep&lt;/a&gt; family of tools for personal analytics.&lt;/p&gt;
&lt;p&gt;I'm giving a talk about this tonight at PyCon Australia: &lt;a href="https://2020.pycon.org.au/program/73uk8x/"&gt;Build your own data warehouse for personal analytics with SQLite and Datasette&lt;/a&gt;. I'll be writing up detailed notes in the next few days, so watch this space.&lt;/p&gt;
&lt;h4 id="weeknotes-2020-09-03-til-this-week"&gt;TIL this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/jq_reformatting-airtable-json.md"&gt;Converting Airtable JSON for use with sqlite-utils using jq&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/javascript_minifying-uglify-npx.md"&gt;Minifying JavaScript with npx uglify-js&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/pytest_subprocess-server.md"&gt;Start a server in a subprocess during a pytest session&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/bash_loop-over-csv.md"&gt;Looping over comma-separated values in Bash&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/cloudrun_gcloud-run-services-list.md"&gt;Using the gcloud run services list command&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://til.simonwillison.net/til/til/python_debug-click-with-pdb.md"&gt;Debugging a Click application using pdb&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="weeknotes-2020-09-03-releases-this-week"&gt;Releases this week&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.4.1"&gt;dogsheep-beta 0.4.1&lt;/a&gt; - 2020-09-03&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.4"&gt;dogsheep-beta 0.4&lt;/a&gt; - 2020-09-03&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.4a1"&gt;dogsheep-beta 0.4a1&lt;/a&gt; - 2020-09-03&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.4a0"&gt;dogsheep-beta 0.4a0&lt;/a&gt; - 2020-09-03&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.3"&gt;dogsheep-beta 0.3&lt;/a&gt; - 2020-09-02&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.2"&gt;dogsheep-beta 0.2&lt;/a&gt; - 2020-09-01&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.1"&gt;dogsheep-beta 0.1&lt;/a&gt; - 2020-09-01&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.1a2"&gt;dogsheep-beta 0.1a2&lt;/a&gt; - 2020-09-01&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/dogsheep/dogsheep-beta/releases/tag/0.1a"&gt;dogsheep-beta 0.1a&lt;/a&gt; - 2020-09-01&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.4"&gt;airtable-export 0.4&lt;/a&gt; - 2020-08-30&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/datasette-yaml/releases/tag/0.1a"&gt;datasette-yaml 0.1a&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.3.1"&gt;airtable-export 0.3.1&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.3"&gt;airtable-export 0.3&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.2"&gt;airtable-export 0.2&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.1.1"&gt;airtable-export 0.1.1&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/airtable-export/releases/tag/0.1"&gt;airtable-export 0.1&lt;/a&gt; - 2020-08-29&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/datasette/releases/tag/0.49a0"&gt;datasette 0.49a0&lt;/a&gt; - 2020-08-28&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/simonw/sqlite-utils/releases/tag/2.16.1"&gt;sqlite-utils 2.16.1&lt;/a&gt; - 2020-08-28&lt;/li&gt;
&lt;/ul&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/markdown"&gt;markdown&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/dogsheep"&gt;dogsheep&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/weeknotes"&gt;weeknotes&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/github-actions"&gt;github-actions&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/airtable"&gt;airtable&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/puppeteer"&gt;puppeteer&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="projects"/><category term="yaml"/><category term="markdown"/><category term="dogsheep"/><category term="weeknotes"/><category term="github-actions"/><category term="airtable"/><category term="puppeteer"/></entry><entry><title>airtable-export</title><link href="https://simonwillison.net/2020/Aug/29/airtable-export/#atom-tag" rel="alternate"/><published>2020-08-29T21:48:37+00:00</published><updated>2020-08-29T21:48:37+00:00</updated><id>https://simonwillison.net/2020/Aug/29/airtable-export/#atom-tag</id><summary type="html">
    
&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/simonw/airtable-export"&gt;airtable-export&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;
I wrote a command-line utility for exporting data from Airtable and dumping it to disk as YAML, JSON or newline delimited JSON files. This means you can backup an Airtable database from a GitHub Action and get a commit history of changes made to your data.


    &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/json"&gt;json&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/projects"&gt;projects&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/yaml"&gt;yaml&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/airtable"&gt;airtable&lt;/a&gt;&lt;/p&gt;



</summary><category term="json"/><category term="projects"/><category term="yaml"/><category term="airtable"/></entry><entry><title>Off the shelf question database/management system for repeated surveys?</title><link href="https://simonwillison.net/2016/Dec/7/off-the-shelf-question-databasemanagement/#atom-tag" rel="alternate"/><published>2016-12-07T07:16:00+00:00</published><updated>2016-12-07T07:16:00+00:00</updated><id>https://simonwillison.net/2016/Dec/7/off-the-shelf-question-databasemanagement/#atom-tag</id><summary type="html">
    &lt;p&gt;&lt;em&gt;My answer to &lt;a href="http://ask.metafilter.com/303489/Off-the-shelf-question-database-management-system-for-repeated-surveys#4395053"&gt;Off the shelf question database/management system for repeated surveys?&lt;/a&gt; on Ask MetaFilter&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I've been using Airtable for some personal projects recently and I could not be more impressed with it. It makes building a relatively sophisticated database trivial, the collaboration features are outstanding (live updates, full history tracking on everything) and it's fully cross platform - I've designed new databases on my iPhone!&lt;/p&gt;
    
        &lt;p&gt;Tags: &lt;a href="https://simonwillison.net/tags/ask-metafilter"&gt;ask-metafilter&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/surveys"&gt;surveys&lt;/a&gt;, &lt;a href="https://simonwillison.net/tags/airtable"&gt;airtable&lt;/a&gt;&lt;/p&gt;
    

</summary><category term="ask-metafilter"/><category term="surveys"/><category term="airtable"/></entry></feed>