Addtitional Mailing List stats in release report (#1712)

This commit is contained in:
daveoconnor
2025-03-26 18:08:20 -07:00
committed by GitHub
parent ca56fc8e44
commit da6ee3524e
17 changed files with 545 additions and 33 deletions

View File

@@ -320,3 +320,19 @@ For this to work `SLACK_BOT_API` must be set in the `.env` file.
| Options | Format | Description |
|----------------------|--------|--------------------------------------------------------------|
| `--user_id` | int | If passed, the user with this ID will receive email notifications when this task is started and finished, or if the task raises and exception. |
## `import_ml_counts`
**Purpose**: Import mailing list counts from the mailman archives.
```bash
./manage.py import_ml_counts
```
**Options**
| Options | Format | Description |
|----------------|--------|----------------------------------------------------------------------------------------------------------------------|
| `--start_date` | date | If passed, retrieves data from the start date supplied, d-m-y, default 20-11-1998 (the start of the data in mailman) |
| `--end_date` | date | If passed, If passed, retrieves data until the start date supplied, d-m-y, default today |

View File

@@ -313,9 +313,9 @@ https://docs.allauth.org/en/latest/socialaccount/providers/google.html
1. `TF_VAR_google_cloud_email` (the email address of your Google Cloud account)
2. `TF_VAR_google_organization_domain` (usually the domain of your Google Cloud account, e.g. "boost.org" if you will be using an @boost.org email address)
3. `TF_VAR_google_cloud_project_name` (optional, default: localboostdev) - needs to change if destroyed and a setup is needed within 30 days
2. Run `make development-tofu-init` to initialize tofu.
3. Run `make development-tofu-plan` to confirm the planned changes.
4. Run `make development-tofu-apply` to apply the changes.
2. Run `just development-tofu-init` to initialize tofu.
3. Run `just development-tofu-plan` to confirm the planned changes.
4. Run `just development-tofu-apply` to apply the changes.
5. Go to https://console.developers.google.com/
1. Search for the newly created project, named "Boost Development" (ID: localboostdev by default).
2. Type "credentials" in the search input at the top of the page.
@@ -352,6 +352,7 @@ In your env:
#### Set Up Pycharm
You can set up your IDE with a new "Python Debug Server" configuration as:
<img src="images/pycharm_debugger_settings.png" alt="PyCharm Debugger Settings" width="400">
#### Common Usage

View File

@@ -38,6 +38,7 @@ The `boost_setup` command will run all of the processes listed here:
# Get the most recent beta release, and delete old beta releases
./manage.py import_beta_release --delete-versions
./manage.py import_ml_counts
```
Read more aboout these [management commands](./commands.md).

View File

@@ -1,5 +1,17 @@
# Release Reports
## Prerequisites
1. You should upload updated subscriber data.
1. Ask Sam for a copy of the "subscribe" data.
2. In the Django admin interface go to "Subscription datas" under "MAILING_LIST".
3. At the top of the page click on the "IMPORT 'SUBSCRIBE' DATA" button.
2. To update the mailing list counts, if you haven't already run the "DO IT ALL" button:
1. Go to "Versions" under "VERSIONS" in the admin interface
2. At the top of the page click on the "DO IT ALL" button.
## Report Creation
1. Go to /admin
2. Go to the "Libraries" section
3. In the top menu click on "GET RELEASE REPORT".

View File

@@ -10,7 +10,11 @@ from django.db.models import F, Q, Count, OuterRef, Sum, When, Value, Case
from django.forms import Form, ModelChoiceField, ModelForm, BooleanField
from core.models import RenderedContent
from reports.generation import generate_wordcloud
from reports.generation import (
generate_wordcloud,
get_mailing_list_post_stats,
get_new_subscribers_stats,
)
from slack.models import Channel, SlackActivityBucket, SlackUser
from versions.models import Version
from .models import (
@@ -772,6 +776,12 @@ class CreateReportForm(CreateReportFullForm):
Channel.objects.filter(name__istartswith="boost").order_by("name"), 10
)
committee_members = version.financial_committee_members.all()
mailinglist_post_stats = get_mailing_list_post_stats(
prior_version.release_date, version.release_date
)
new_subscribers_stats = get_new_subscribers_stats(
prior_version.release_date, version.release_date
)
library_index_library_data = []
for library in self._get_libraries_by_quality():
library_index_library_data.append(
@@ -804,6 +814,8 @@ class CreateReportForm(CreateReportFullForm):
"mailinglist_total": total_mailinglist_count or 0,
"mailinglist_contributor_release_count": mailinglist_contributor_release_count, # noqa: E501
"mailinglist_contributor_new_count": mailinglist_contributor_new_count,
"mailinglist_post_stats": mailinglist_post_stats,
"mailinglist_new_subscribers_stats": new_subscribers_stats,
"commit_contributors_release_count": commit_contributors_release_count,
"commit_contributors_new_count": commit_contributors_new_count,
"global_contributors_new_count": len(

View File

@@ -82,6 +82,7 @@ class ReleaseTasksManager:
ReleaseTask("Updating github issues", ["update_issues"]),
ReleaseTask("Updating slack activity buckets", ["fetch_slack_activity"]),
ReleaseTask("Updating website statistics", self.update_website_statistics),
ReleaseTask("Importing mailing list counts", ["import_ml_counts"]),
ReleaseTask("Generating report", self.generate_report),
]

View File

@@ -1,11 +1,21 @@
import csv
import logging
import re
from datetime import datetime
from io import TextIOWrapper
from django import forms
from django.shortcuts import redirect, render
from django.urls import path
from django.http import HttpResponseRedirect
from django.contrib import admin, messages
from django.conf import settings
from mailing_list.models import EmailData
from mailing_list.models import EmailData, SubscriptionData
from mailing_list.tasks import sync_mailinglist_stats
logger = logging.getLogger(__name__)
@admin.register(EmailData)
class EmailDataAdmin(admin.ModelAdmin):
@@ -43,3 +53,62 @@ class EmailDataAdmin(admin.ModelAdmin):
def has_add_permission(self, request):
return False
class SubscribesCSVForm(forms.Form):
csv_file = forms.FileField()
@admin.register(SubscriptionData)
class SubscriptionDataAdmin(admin.ModelAdmin):
list_display = ["subscription_dt", "email"]
search_fields = ["email"]
change_list_template = "admin/mailinglist_change_list.html"
email_regex = re.compile("([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})")
def get_urls(self):
return [
path("import-csv", self.import_csv, name="import_csv")
] + super().get_urls()
def parse_rows(self, reader):
for row in reader:
date_str = " ".join(row[0:4])
try:
dt = datetime.strptime(date_str, "%b %d %H:%M:%S %Y")
except ValueError:
logger.error(f"Error parsing date {date_str} from {row=}")
dt = None
# re-merge, the email address isn't always in a consistent position
email_matches = re.search(self.email_regex, " ".join(row[6:]))
email = email_matches.group(0) if email_matches else None
entry_type = row[6]
# only save confirmed subscriber entries, it's all we need for now
if entry_type != "new":
continue
if not email:
logger.error(
f"Invalid email {row=} {email_matches=} {' '.join(row[6:])=}"
)
continue
yield SubscriptionData(
email=email,
entry_type=entry_type,
list=row[5].rstrip(":-1"),
subscription_dt=dt,
)
def import_csv(self, request):
if request.method == "POST":
csv_file = request.FILES["csv_file"]
rows = TextIOWrapper(csv_file, encoding="ISO-8859-1", newline="")
reader = csv.reader(rows, delimiter=" ")
SubscriptionData.objects.bulk_create(
self.parse_rows(reader), batch_size=500, ignore_conflicts=True
)
self.message_user(request, "Subscribe CSV file imported.")
return redirect("..")
payload = {"form": SubscribesCSVForm()}
return render(request, "admin/mailinglist_subscribe_csv_form.html", payload)

41
mailing_list/constants.py Normal file
View File

@@ -0,0 +1,41 @@
# we only want boost devel for now, leaving the others in case that changes.
ML_STATS_URLS = [
"https://lists.boost.org/Archives/boost/{:04}/{:02}/author.php",
# "https://lists.boost.org/boost-users/{:04}/{:02}/author.php",
# "https://lists.boost.org/boost-announce/{:04}/{:02}/author.php",
]
ARG_DATE_REGEX = r"^([0-9]+)(?:$|(?:-|/)([0-9]+)(?:$|(?:-|/)([0-9]+)$))"
AUTHOR_PATTERN_REGEX = r"<li><strong>(.*)</strong>"
DATE_PATTERN_REGEX = r".*<em>\((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\)</em>"
# used to map latin-1 characters to their utf-8 equivalents in the mailing list
# page html parser
LATIN_1_EQUIVS = {
8364: 128,
8218: 130,
402: 131,
8222: 132,
8230: 133,
8224: 134,
8225: 135,
710: 136,
8240: 137,
352: 138,
8249: 139,
338: 140,
381: 142,
8216: 145,
8217: 146,
8220: 147,
8221: 148,
8226: 149,
8211: 150,
8212: 151,
732: 152,
8482: 153,
353: 154,
8250: 155,
339: 156,
382: 158,
376: 159,
}

View File

@@ -0,0 +1,133 @@
# Copyright 2024 Dave O'Connor
# Derived from code by Joaquin M Lopez Munoz.
# Distributed under the Boost Software License, Version 1.0.
# (See accompanying file LICENSE_1_0.txt or copy at
# http://www.boost.org/LICENSE_1_0.txt)
import djclick as click
import logging
import re
import warnings
from datetime import timedelta, datetime
import html
from dateutil.relativedelta import relativedelta
from unidecode import unidecode
import requests
from mailing_list.constants import (
ML_STATS_URLS,
LATIN_1_EQUIVS,
ARG_DATE_REGEX,
AUTHOR_PATTERN_REGEX,
DATE_PATTERN_REGEX,
)
from mailing_list.models import PostingData
logger = logging.getLogger(__name__)
arg_date_pattern = re.compile(ARG_DATE_REGEX)
author_pattern = re.compile(AUTHOR_PATTERN_REGEX)
date_pattern = re.compile(DATE_PATTERN_REGEX)
def decode_broken_html(str):
def latin_1_ord(char):
n = ord(char)
return LATIN_1_EQUIVS.get(n, n)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
return unidecode(
bytearray(map(latin_1_ord, html.unescape(str))).decode("utf-8", "ignore")
)
def parse_start_datetime(date_str):
m = arg_date_pattern.match(date_str)
if not m:
raise ValueError("wrong date format")
logger.info(f"{m=} {m.group(1)=} {m.group(2)=} {m.group(3)=}")
return datetime(
int(m.group(3)) if m.group(3) else 1,
int(m.group(2)) if m.group(2) else 1,
int(m.group(1)),
0,
0,
0,
)
def parse_end_datetime(date_str):
m = arg_date_pattern.match(date_str)
if not m:
raise ValueError("wrong date format")
logger.info(f"{m=} {m.group(1)=} {m.group(2)=} {m.group(3)=}")
if m.group(2):
if m.group(3):
return datetime(
int(m.group(3)), int(m.group(2)), int(m.group(1)), 23, 59, 59
)
else:
return (
datetime(int(m.group(1)), int(m.group(2)), 1) + timedelta(days=31),
23,
59,
59,
).replace(day=1) - timedelta(days=1)
return datetime(int(m.group(1)), 12, 31, 23, 59, 59)
def retrieve_authors_from_ml(url, start_date, end_date):
posts = []
logger.info(f"Retrieving data from {url=}.")
r = requests.get(url)
if r.status_code == 404:
return posts
author = None
for line in r.text.splitlines():
author_match = author_pattern.match(line)
if author_match:
# needs multiple passes to work
author = decode_broken_html(author_match.group(1))
else:
date_pattern_match = date_pattern.match(line)
if author and date_pattern_match:
post_date = datetime.strptime(
date_pattern_match.group(1), "%Y-%m-%d %H:%M:%S"
)
if start_date <= post_date and post_date <= end_date:
posts.append(PostingData(name=author, post_time=post_date))
return posts
def retrieve_authors(start_date, end_date):
logger.info(f"retrieve_authors from {start_date=} to {end_date=}")
start_month = datetime(start_date.year, start_date.month, 1)
end_month = datetime(end_date.year, end_date.month, 1)
authors = []
while start_month <= end_month:
for ml in ML_STATS_URLS:
authors += retrieve_authors_from_ml(
ml.format(start_month.year, start_month.month), start_date, end_date
)
start_month = start_month + relativedelta(months=+1)
PostingData.objects.filter(
post_time__gte=start_date, post_time__lte=end_date
).delete()
PostingData.objects.bulk_create(authors)
@click.command()
@click.option("--start_date", is_flag=False, help="Start Date", default=None)
@click.option("--end_date", is_flag=False, help="End Date", default=None)
def command(start_date, end_date):
logger.info(f"Starting import_ml_counts {start_date=} {end_date=}")
start_date = (
parse_start_datetime(start_date) if start_date else datetime(1998, 11, 11)
)
logger.info(f"{start_date=}")
end_date = parse_end_datetime(end_date) if end_date else datetime.now()
logger.info(f"{end_date=}")
retrieve_authors(start_date, end_date)

View File

@@ -0,0 +1,52 @@
# Generated by Django 4.2.16 on 2025-03-20 18:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("mailing_list", "0004_initial"),
]
operations = [
migrations.CreateModel(
name="PostingData",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
("post_time", models.DateTimeField()),
("created", models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name="SubscriptionData",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("subscription_dt", models.DateTimeField()),
("email", models.EmailField(max_length=255)),
("entry_type", models.CharField(max_length=24)),
("list", models.CharField(max_length=24)),
("created", models.DateTimeField(auto_now_add=True)),
],
options={
"unique_together": {("subscription_dt", "email", "list")},
},
),
]

View File

@@ -35,3 +35,25 @@ class EmailData(models.Model):
def __str__(self):
return self.author.name
class PostingData(models.Model):
name = models.CharField(max_length=255)
post_time = models.DateTimeField()
created = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} {self.post_time}"
class SubscriptionData(models.Model):
subscription_dt = models.DateTimeField()
email = models.EmailField(max_length=255)
entry_type = models.CharField(max_length=24)
list = models.CharField(max_length=24)
created = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["subscription_dt", "email", "list"]

View File

@@ -1,17 +1,24 @@
import base64
import io
import logging
import random
from datetime import datetime
import psycopg2
from django.conf import settings
from django.db.models import Count
from django.db.models.functions import ExtractWeek, ExtractIsoYear
from matplotlib import pyplot as plt
from wordcloud import WordCloud, STOPWORDS
from core.models import SiteSettings
from libraries.models import WordcloudMergeWord # TODO: move model to this app
from mailing_list.models import PostingData, SubscriptionData
from reports.constants import WORDCLOUD_FONT
from versions.models import Version
logger = logging.getLogger(__name__)
def generate_wordcloud(version: Version) -> tuple[str | None, list]:
"""Generates a wordcloud png and returns it as a base64 string and word frequencies.
@@ -25,7 +32,7 @@ def generate_wordcloud(version: Version) -> tuple[str | None, list]:
width=1400,
height=700,
stopwords=STOPWORDS | SiteSettings.load().wordcloud_ignore_set,
font_path=settings.STATIC_ROOT / "font" / WORDCLOUD_FONT,
font_path=f"{settings.STATIC_ROOT}/font/{WORDCLOUD_FONT}",
)
word_frequencies = {}
for content in get_mail_content(version):
@@ -110,3 +117,40 @@ def get_mail_content(version: Version):
)
for [content] in cursor:
yield content
def get_mailing_list_post_stats(start_date: datetime, end_date: datetime):
logger.info(f"from {start_date} to {end_date}")
data = (
PostingData.objects.filter(post_time__gt=start_date, post_time__lte=end_date)
.annotate(week=ExtractWeek("post_time"), iso_year=ExtractIsoYear("post_time"))
.values("week")
.annotate(count=Count("id"))
.order_by("iso_year", "week")
)
return [{"y": s.get("count"), "x": s.get("week")} for s in data]
def get_new_subscribers_stats(start_date: datetime, end_date: datetime):
data = (
SubscriptionData.objects.filter(
subscription_dt__gte=start_date,
subscription_dt__lte=end_date,
list="boost",
)
.annotate(
week=ExtractWeek("subscription_dt"),
iso_year=ExtractIsoYear("subscription_dt"),
)
.values("week", "list")
.annotate(count=Count("id"))
.order_by("iso_year", "week")
)
formatted_data = [{"x": s.get("week"), "y": s.get("count")} for s in data]
referenced_weeks = [x.get("week") for x in data]
# account for weeks that no data is retrieved
for w in range(start_date.isocalendar().week, end_date.isocalendar().week + 1):
if w not in referenced_weeks:
formatted_data.append({"x": w, "y": 0})
return formatted_data

View File

@@ -31,6 +31,7 @@ wheel
cryptography
boto3
jsoncomment
unidecode
wordcloud
# Logging

View File

@@ -371,6 +371,8 @@ tzdata==2024.2
# via
# celery
# kombu
unidecode==1.3.8
# via -r ./requirements.in
urllib3==1.26.20
# via
# botocore

View File

@@ -1,11 +1,13 @@
{% extends "admin/change_list.html" %}
{% extends 'admin/change_list.html' %}
{% load i18n admin_urls %}
{% block object-tools %}
<ul class="object-tools">
{% block object-tools-items %}
{{ block.super }}
<li><a href="{% url 'admin:sync_mailinglist_stats' %}" class="addlink">{% trans "Sync Mailing List Stats" %}</a></li>
{% endblock %}
</ul>
<ul class="object-tools">
{% block object-tools-items %}
{{ block.super }}
<li><a href="{% url 'admin:sync_mailinglist_stats' %}" class="addlink">{% trans "Sync Mailing List Stats" %}</a></li>
<li><a href="{% url 'admin:import_csv' %}" class="addlink">{% trans "Import 'Subscribe' Data" %}</a></li>
{% endblock %}
</ul>
{% endblock %}

View File

@@ -0,0 +1,15 @@
{% extends "admin/base_site.html" %}
{% load static %}
{% block content %}
{{ block.super }}
<div class='container mx-auto'>
<h1>Upload Subscribe File</h1>
<div>
<form action="" method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<input name="submit" value="Submit" class="default" type="submit" />
</form>
</div>
</div>
{% endblock content %}

View File

@@ -30,6 +30,14 @@ body {
.committee_members img {
filter: grayscale(1);
}
#top-committed-libraries-chart .apexcharts-xaxis-label:nth-child(odd) {
transform: translateY(-8px);
}
#top-committed-libraries-chart .apexcharts-xaxis-label:nth-child(even) {
transform: translateY(8px);
}
</style>
{% endblock css %}
{% block content %}
@@ -219,7 +227,7 @@ body {
<div class="flex gap-x-8 w-full">
<div class="px-4">
<div class="mx-auto mb-6 mt-5">Top Contributors</div>
<div class="mx-auto mb-6 mt-[4.75rem]">Top Contributors</div>
<div class="m-auto grid grid-cols-1 gap-2">
{% for item in mailinglist_counts %}
<div class="flex flex-row gap-y-2 w-40 items-center">
@@ -249,21 +257,22 @@ body {
poster{{ mailinglist_contributor_release_count|pluralize }}
in this version. (<span class="font-bold">{{ mailinglist_contributor_new_count }}</span> New)
</div>
<div class="text-center my-2">Weekly mailing list posts from {{prior_version.release_date}} to {{version.release_date}} on the Boost Developers mailing list.</div>
<div id="release_post_stats"></div>
</div>
</div>
</div>
{% if wordcloud_base64 %}
<div class="pdf-page flex {{ bg_color }}" style="background-image: url('{% static 'img/release_report/bg6.png' %}');">
<div class="flex flex-col mx-auto">
<h2 class="mx-auto mb-10">Mailing List Word Cloud</h2>
<div class="flex mx-auto">
<img src="data:image/png;base64,{{ wordcloud_base64 }}" alt="Mailing List Word Cloud" class="w-full">
</div>
</div>
<div class="pdf-page flex {{ bg_color }}" style="background-image: url('{% static 'img/release_report/bg6.png' %}');">
<div class="flex flex-col h-full mx-auto w-full">
<h2 class="mx-auto mb-10">Mailing List New Subscribers</h2>
<div class="text-center my-2">Mailing list new subscribers from from {{prior_version.release_date}} to {{version.release_date}} on the Boost Developers mailing list.</div>
<div id="subscriptions_stats"></div>
</div>
{% endif %}
</div>
<div class="pdf-page flex {{ bg_color }}" style="background-image: url('{% static 'img/release_report/bg6.png' %}');">
<div class="flex flex-col h-full mx-auto w-full">
<h2 class="mx-auto mb-10">Mailing List Top 200 Most Frequently Used Words</h2>
@@ -276,6 +285,16 @@ body {
</div>
</div>
</div>
{% if wordcloud_base64 %}
<div class="pdf-page flex {{ bg_color }}" style="background-image: url('{% static 'img/release_report/bg6.png' %}');">
<div class="flex flex-col mx-auto">
<h2 class="mx-auto mb-10">Mailing List Word Cloud</h2>
<div class="flex mx-auto">
<img src="data:image/png;base64,{{ wordcloud_base64 }}" alt="Mailing List Word Cloud" class="w-full">
</div>
</div>
</div>
{% endif %}
{% if slack %}
{% for slack_channel_group in slack_channels %}
@@ -441,11 +460,7 @@ body {
{% endwith %}
</div>
<script>
var options = {
series: [{
name: 'Commits',
data: [{% for library in top_libraries_for_version|slice:":5" %}{{library.commit_count}}, {% endfor %}]
}],
const generalGraphOptions = {
chart: {
height: 400,
type: 'bar',
@@ -454,6 +469,9 @@ body {
toolbar: {
show: false,
},
zoom: {
enabled: false
},
},
plotOptions: {
bar: {
@@ -472,7 +490,6 @@ body {
}
},
xaxis: {
categories: [{% for library in top_libraries_for_version|slice:":5" %} "{{ library.name }}", {% endfor %}],
position: 'bottom',
axisBorder: {
show: false
@@ -496,10 +513,81 @@ body {
}
},
};
const chart = new ApexCharts(document.querySelector("#top-committed-libraries-chart"), options);
chart.render();
</script>
<script>
var committedLibrariesOptions = {
...generalGraphOptions,
series: [{
name: 'Commits',
data: [{% for library in top_libraries_for_version|slice:":5" %}{{library.commit_count}}, {% endfor %}]
}],
xaxis: {
type: "category",
categories: [{% for library in top_libraries_for_version|slice:":5" %} "{{ library.name }}", {% endfor %}],
labels: {
rotate: 0,
hideOverlappingLabels: false
}
},
};
const libraryCommitsChart = new ApexCharts(document.querySelector("#top-committed-libraries-chart"), committedLibrariesOptions);
libraryCommitsChart.render();
var mailingListPostsOptions = {
...generalGraphOptions,
series: [{
name: 'Posts',
data: {{mailinglist_post_stats|safe}}
}],
axisTicks: {
show: true,
},
xaxis: {
type: "numeric",
title: {
text: "Calendar Week"
},
labels: {
formatter: (value) => { return `${value}`; },
}
},
yaxis: {
title: {
text: "Posts"
}
},
};
const mailingListPostsChart = new ApexCharts(document.querySelector("#release_post_stats"), mailingListPostsOptions);
mailingListPostsChart.render();
const newEntries = {{mailinglist_new_subscribers_stats|safe}};
var subscriptionsOptions = {
...generalGraphOptions,
series: [{
name: 'New Subscribers',
data: newEntries
}],
axisTicks: {
show: true,
},
xaxis: {
type: "numeric",
title: {
text: "Calendar Week"
},
labels: {
formatter: (value) => { return `${value}`; }
}
},
yaxis: {
title: {
text: "New Subscribers"
}
},
};
const subscriptionsChart = new ApexCharts(document.querySelector("#subscriptions_stats"), subscriptionsOptions);
subscriptionsChart.render();
// Use fitText to resize text to fit its container.
// Starts at MAX_FONT_SIZE and tries smaller sizes until it fits or hits MIN_FONT_SIZE.
const fitText = async () => {