\d{2})/$", views.newsletter_detail, name="detail"
11 | ),
12 | ]
13 |
--------------------------------------------------------------------------------
/blog/migrations/0015_enable_pg_trgm.py:
--------------------------------------------------------------------------------
1 | from django.contrib.postgres.operations import TrigramExtension
2 | from django.db import migrations
3 |
4 |
5 | class Migration(migrations.Migration):
6 | dependencies = [
7 | ("blog", "0014_entry_custom_template"),
8 | ]
9 |
10 | operations = [TrigramExtension()]
11 |
--------------------------------------------------------------------------------
/.claude/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "hooks": {
3 | "SessionStart": [
4 | {
5 | "type": "command",
6 | "command": "[ \"$CLAUDE_CODE_REMOTE\" = \"true\" ] && uv python install 3.12 && uv venv --python 3.12 .venv312 && . .venv312/bin/activate && uv pip install -q -r requirements.txt || true"
7 | }
8 | ]
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/templates/includes/entry_footer.html:
--------------------------------------------------------------------------------
1 | {{ entry.created|date:"f A"|lower }} {% if showdate %} / {{ entry.created|date:"jS F Y" }} {% endif %}{% if entry.tags.count %} / {% for tag in entry.tags.all %}{{ tag.get_link }}{% if not forloop.last %}, {% endif %}{% endfor %}{% endif %}
--------------------------------------------------------------------------------
/blog/migrations/0020_tag_description.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations, models
2 |
3 |
4 | class Migration(migrations.Migration):
5 |
6 | dependencies = [
7 | ("blog", "0019_blogmark_use_markdown"),
8 | ]
9 |
10 | operations = [
11 | migrations.AddField(
12 | model_name="tag",
13 | name="description",
14 | field=models.TextField(blank=True),
15 | ),
16 | ]
17 |
--------------------------------------------------------------------------------
/templates/projects/index.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}
2 | {% block title %}Simon Willison’s Weblog: Projects{% endblock %}
3 | {% block primary %}
4 |
5 | Projects
6 |
7 | {% for project in projects %}
8 | {{ project }}
9 | {{ project.short_description }}
10 | {{ project.display_date_range }}
11 | {% endfor %}
12 |
13 | {% endblock %}
14 |
--------------------------------------------------------------------------------
/blog/migrations/0017_alter_series_options.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.3 on 2021-08-07 22:34
2 |
3 | from django.db import migrations
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("blog", "0016_series"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterModelOptions(
13 | name="series",
14 | options={"verbose_name_plural": "Series"},
15 | ),
16 | ]
17 |
--------------------------------------------------------------------------------
/templates/admin/base_site.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/base.html" %}
2 |
3 | {% block extrastyle %}
4 | {{ block.super }}
5 |
17 | {% endblock %}
18 |
--------------------------------------------------------------------------------
/templates/admin/blog/entry/change_form.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/change_form.html" %}
2 | {% block content %}
3 | {{ block.super }}
4 |
5 | <lite-youtube>
6 |
7 | <p><lite-youtube videoid="-jiBLQyUi38" js-api="js-api"
8 | title="Apple's Knowledge Navigator concept video (1987)"
9 | playlabel="Play: Apple's Knowledge Navigator concept video (1987)"
10 | > </lite-youtube></p>
11 |
12 |
13 | {% endblock %}
14 |
--------------------------------------------------------------------------------
/templates/monthly_detail.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}
2 |
3 | {% block title %}{{ newsletter.subject }}{% endblock %}
4 |
5 | {% block primary %}
6 | Back to monthly newsletter archive
7 | {{ newsletter.subject }}
8 | Sent: {{ newsletter.sent_at|date:"F j, Y" }}
9 |
10 | {{ newsletter.body_html }}
11 |
12 | {% endblock %}
13 |
14 | {% block secondary %}{% endblock %}
15 |
--------------------------------------------------------------------------------
/blog/migrations/0003_auto_20170926_0641.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.11.5 on 2017-09-26 06:41
3 |
4 |
5 | from django.db import migrations
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ("blog", "0002_auto_20150713_0551"),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterModelOptions(
15 | name="comment",
16 | options={"get_latest_by": "created", "ordering": ("-created",)},
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/blog/migrations/0025_entry_live_timezone.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.1 on 2024-10-01 17:02
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("blog", "0024_liveupdate"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="entry",
15 | name="live_timezone",
16 | field=models.CharField(blank=True, max_length=100, null=True),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/blog/migrations/0026_quotation_context.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.1 on 2024-10-11 12:51
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("blog", "0025_entry_live_timezone"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="quotation",
15 | name="context",
16 | field=models.CharField(blank=True, max_length=255, null=True),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/blog/migrations/0014_entry_custom_template.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.1.2 on 2020-11-14 02:31
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("blog", "0013_fix_simon_incutio_links"),
9 | ]
10 |
11 | operations = [
12 | migrations.AddField(
13 | model_name="entry",
14 | name="custom_template",
15 | field=models.CharField(blank=True, max_length=100, null=True),
16 | ),
17 | ]
18 |
--------------------------------------------------------------------------------
/blog/migrations/0019_blogmark_use_markdown.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.0.2 on 2024-04-25 03:52
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("blog", "0018_alter_blogmark_link_url_alter_blogmark_via_url"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="blogmark",
15 | name="use_markdown",
16 | field=models.BooleanField(default=False),
17 | ),
18 | ]
19 |
--------------------------------------------------------------------------------
/templates/feeds/quotation.html:
--------------------------------------------------------------------------------
1 | {{ obj.body }}
2 | — {% if obj.source_url %}{{ obj.source }} {% else %}{{ obj.source }}{% endif %}{% if obj.context %}, {{ obj.context_rendered }}{% endif %}
3 | {% if obj.tags.count %}
4 | Tags: {% for tag in obj.tags.all %}{{ tag.tag }} {% if not forloop.last %}, {% endif %}{% endfor %}
5 | {% endif %}
6 |
--------------------------------------------------------------------------------
/templates/top_tags.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}
2 | {% block title %}Top tags{% endblock %}
3 | {% block primary %}
4 | Top tags
5 | {% for info in tags_info %}
6 |
7 |
8 | {% for entry in info.recent_entries %}
9 | {{ entry.title }}
10 | {% empty %}
11 | No entries yet
12 | {% endfor %}
13 |
14 | {% endfor %}
15 | {% endblock %}
16 |
--------------------------------------------------------------------------------
/templates/404.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}{% load humanize %}
2 |
3 | {% block title %}404: Page not found{% endblock %}
4 |
5 | {% block primary %}
6 | 404: Page not found
7 |
8 | Try running a search instead:
9 |
10 |
14 |
15 | {% endblock %}
16 |
--------------------------------------------------------------------------------
/templates/feeds/everything.html:
--------------------------------------------------------------------------------
1 | {% if obj.is_entry %}
2 | {{ obj.body|safe }}
3 | {% if obj.tags.count %}
4 | Tags: {% for tag in obj.tags.all %}{{ tag.tag }} {% if not forloop.last %}, {% endif %}{% endfor %}
5 | {% endif %}
6 | {% elif obj.is_blogmark %}
7 | {% include "feeds/blogmark.html" %}
8 | {% elif obj.is_quotation %}
9 | {% include "feeds/quotation.html" %}
10 | {% elif obj.is_note %}
11 | {% include "feeds/note.html" %}
12 | {% endif %}
13 |
--------------------------------------------------------------------------------
/feedstats/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | class SubscriberCount(models.Model):
5 | path = models.CharField(max_length=128)
6 | count = models.IntegerField()
7 | created = models.DateTimeField(auto_now_add=True)
8 | user_agent = models.CharField(max_length=256, db_index=True)
9 |
10 | class Meta:
11 | constraints = [
12 | models.UniqueConstraint(
13 | fields=["path", "user_agent", "count", "created"],
14 | name="unique_subscriber_count",
15 | )
16 | ]
17 |
--------------------------------------------------------------------------------
/blog/migrations/0028_note_title.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.1 on 2025-04-23 02:30
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("blog", "0027_note"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="note",
15 | name="title",
16 | field=models.CharField(
17 | blank=True, default="", help_text="Optional page title", max_length=255
18 | ),
19 | ),
20 | ]
21 |
--------------------------------------------------------------------------------
/blog/migrations/0029_blogmark_title.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.4 on 2025-09-03 14:08
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("blog", "0028_note_title"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="blogmark",
15 | name="title",
16 | field=models.CharField(
17 | blank=True, default="", help_text="Optional page title", max_length=255
18 | ),
19 | ),
20 | ]
21 |
--------------------------------------------------------------------------------
/blog/management/commands/reindex_all.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from blog.models import Entry, Blogmark, Quotation
3 |
4 |
5 | class Command(BaseCommand):
6 | help = "Re-indexes all entries, blogmarks, quotations"
7 |
8 | def handle(self, *args, **kwargs):
9 | for klass in (Entry, Blogmark, Quotation):
10 | i = 0
11 | for obj in klass.objects.prefetch_related("tags").all():
12 | obj.save()
13 | i += 1
14 | if i % 100 == 0:
15 | print(klass, i)
16 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | arrow==1.4.0
2 | beautifulsoup4==4.14.3
3 | django-http-debug==0.2
4 | cloudflare==4.3.1
5 | dateutils==0.6.12
6 | db-to-sqlite==1.5
7 | djp==0.3.1
8 | django-plugin-django-header==0.1.1
9 | dj-database-url==3.0.1
10 | django-proxy==1.3.0
11 | django-sql-dashboard==1.2
12 | django-test-plus==2.3.0
13 | Django==6.0
14 | factory-boy==3.3.3
15 | gunicorn==23.0.0
16 | html5lib==1.1
17 | psycopg2-binary==2.9.11
18 | pytest==9.0.2
19 | raven>=3
20 | requests==2.32.5
21 | SQLAlchemy>=1.3.0
22 | whitenoise==6.11.0
23 | Markdown==3.10
24 | django-hosts==7.0.0
25 | pyspellchecker==0.8.4
--------------------------------------------------------------------------------
/feedstats/migrations/0002_longer_user_agent_field.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.11.5 on 2017-10-20 18:52
3 | from __future__ import unicode_literals
4 |
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ("feedstats", "0001_initial"),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterField(
15 | model_name="subscribercount",
16 | name="user_agent",
17 | field=models.CharField(db_index=True, max_length=256),
18 | ),
19 | ]
20 |
--------------------------------------------------------------------------------
/monthly/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from django.utils.safestring import mark_safe
3 | from markdown import markdown
4 |
5 |
6 | class Newsletter(models.Model):
7 | subject = models.CharField(max_length=255)
8 | body = models.TextField()
9 | sent_at = models.DateTimeField()
10 |
11 | class Meta:
12 | ordering = ["-sent_at"]
13 | get_latest_by = "sent_at"
14 |
15 | def __str__(self):
16 | return f"{self.subject} ({self.sent_at:%Y-%m-%d})"
17 |
18 | @property
19 | def body_html(self):
20 | return mark_safe(markdown(self.body))
21 |
--------------------------------------------------------------------------------
/redirects/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 |
4 | class Redirect(models.Model):
5 | domain = models.CharField(max_length=128, blank=True)
6 | path = models.CharField(max_length=128, blank=True)
7 | target = models.CharField(max_length=256, blank=True)
8 | created = models.DateTimeField(auto_now_add=True)
9 |
10 | class Meta:
11 | constraints = [
12 | models.UniqueConstraint(fields=["domain", "path"], name="unique_redirect")
13 | ]
14 |
15 | def __unicode__(self):
16 | return "%s/%s => %s" % (self.domain, self.path, self.target)
17 |
--------------------------------------------------------------------------------
/feedstats/migrations/0003_subscribercount_unique_subscriber_count.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1 on 2024-08-13 22:50
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("feedstats", "0002_longer_user_agent_field"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddConstraint(
14 | model_name="subscribercount",
15 | constraint=models.UniqueConstraint(
16 | fields=("path", "user_agent", "count", "created"),
17 | name="unique_subscriber_count",
18 | ),
19 | ),
20 | ]
21 |
--------------------------------------------------------------------------------
/templates/feeds/blogmark.html:
--------------------------------------------------------------------------------
1 | {% load entry_tags %}
2 | {{ obj.link_title }}
3 | {% if obj.commentary %}{% if not obj.use_markdown %}{{ obj.commentary|typography|linebreaks|strip_wrapping_p }}{% else %}{{ obj.body|strip_wrapping_p }}{% endif %}{% endif %}
4 | {% if obj.via_title %}
5 | Via {{ obj.via_title }}
6 | {% endif %}
7 | {% if obj.tags.count %}
8 | Tags: {% for tag in obj.tags.all %}{{ tag.tag }} {% if not forloop.last %}, {% endif %}{% endfor %}
9 | {% endif %}
10 |
--------------------------------------------------------------------------------
/templates/tools.html:
--------------------------------------------------------------------------------
1 | {% extends "item_base.html" %}
2 |
3 | {% block title %}Tools{% endblock %}
4 |
5 | {% block item_content %}
6 | Tools
7 |
8 | Currently deployed: {{ deployed_hash }} (diff to master )
9 |
10 | {% if msg %}{{ msg }}
{% endif %}
11 |
12 |
16 |
17 | {% endblock %}
18 |
--------------------------------------------------------------------------------
/blog/migrations/0011_entry_extra_head_html.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.0b1 on 2018-09-18 16:48
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("blog", "0010_auto_20180918_1646"),
9 | ]
10 |
11 | operations = [
12 | migrations.AddField(
13 | model_name="entry",
14 | name="extra_head_html",
15 | field=models.TextField(
16 | blank=True,
17 | help_text="Extra HTML to be included in the <head> for this entry",
18 | null=True,
19 | ),
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/templates/series_index.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}
2 | {% block title %}Simon Willison’s Weblog: Series of posts{% endblock %}
3 | {% block primary %}
4 |
5 | Series of posts
6 |
7 | {% for series in all_series %}
8 |
9 | {{ series.summary }}
10 |
11 | {% for entry in series.entries_ordest_first %}
12 | {{ entry }} - {{ entry.created }}
13 | {% endfor %}
14 |
15 | {% endfor %}
16 |
17 | {% endblock %}
18 |
--------------------------------------------------------------------------------
/templates/smallhead.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block body_class %} class="smallhead"{% endblock %}
4 | {% block body %}
5 |
11 |
12 |
13 |
14 | {% block primary %}{% endblock %}
15 |
16 |
17 |
18 | {% block secondary %}{% endblock %}
19 |
20 |
21 |
22 | {% block thirdsection %}{% endblock %}
23 |
24 | {% endblock %}
25 |
--------------------------------------------------------------------------------
/blog/management/commands/validate_entries_xml.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from xml.etree import ElementTree
3 | from blog.models import Entry
4 |
5 |
6 | class Command(BaseCommand):
7 | help = "Spits out list of entries with invalid XML"
8 |
9 | def handle(self, *args, **kwargs):
10 | for entry in Entry.objects.all():
11 | try:
12 | ElementTree.fromstring("%s " % entry.body.encode("utf8"))
13 | except Exception as e:
14 | print(e)
15 | print(entry.title)
16 | print("https://simonwillison.com/admin/blog/entry/%d/" % entry.pk)
17 | print()
18 |
--------------------------------------------------------------------------------
/blog/migrations/0022_alter_blogmark_use_markdown.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1 on 2024-08-13 22:44
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("blog", "0021_previous_tag_name"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterField(
14 | model_name="blogmark",
15 | name="use_markdown",
16 | field=models.BooleanField(
17 | default=False,
18 | help_text='Images can use the img element - set width="..." for a specific width and use class="blogmark-image" to center and add a drop shadow.',
19 | ),
20 | ),
21 | ]
22 |
--------------------------------------------------------------------------------
/redirects/migrations/0003_alter_redirect_unique_together_and_more.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1 on 2024-08-13 22:51
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("redirects", "0002_auto_20171001_2242"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterUniqueTogether(
14 | name="redirect",
15 | unique_together=set(),
16 | ),
17 | migrations.AddConstraint(
18 | model_name="redirect",
19 | constraint=models.UniqueConstraint(
20 | fields=("domain", "path"), name="unique_redirect"
21 | ),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/blog/migrations/0018_alter_blogmark_link_url_alter_blogmark_via_url.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.1.7 on 2023-05-22 19:57
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("blog", "0017_alter_series_options"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterField(
13 | model_name="blogmark",
14 | name="link_url",
15 | field=models.URLField(max_length=512),
16 | ),
17 | migrations.AlterField(
18 | model_name="blogmark",
19 | name="via_url",
20 | field=models.URLField(blank=True, max_length=512, null=True),
21 | ),
22 | ]
23 |
--------------------------------------------------------------------------------
/blog/migrations/0008_entry_tweet_html.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.11.5 on 2017-10-08 20:35
3 |
4 |
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ("blog", "0007_metadata_default"),
11 | ]
12 |
13 | operations = [
14 | migrations.AddField(
15 | model_name="entry",
16 | name="tweet_html",
17 | field=models.TextField(
18 | blank=True,
19 | help_text=b"Paste in the embed tweet HTML, minus the script tag,\n to display a tweet in the sidebar next to this entry.",
20 | null=True,
21 | ),
22 | ),
23 | ]
24 |
--------------------------------------------------------------------------------
/templates/includes/calendar.html:
--------------------------------------------------------------------------------
1 |
2 | {% spaceless %}
3 |
4 | M T W T F S S
5 |
6 | {% for week in weeks %}
7 |
8 | {% for day in week %}
9 | {% if not day.display %} {% else %}
10 | {% if day.populated %}{% if day.entries %}{% endif %}{{ day.day|date:"j" }} {% if day.entries %} {% endif %}
11 | {% else %}{{ day.day|date:"j" }}{% endif %}
12 | {% endif %}
13 | {% endfor %}
14 |
15 | {% endfor %}
16 |
17 | {% endspaceless %}
18 |
--------------------------------------------------------------------------------
/config/urls_2003.py:
--------------------------------------------------------------------------------
1 | from django.urls import re_path, include
2 | from django.http import (
3 | HttpResponse,
4 | )
5 | from django.conf import settings
6 | from blog import views_2003 as blog_views
7 |
8 |
9 | DISALLOW_ALL = """
10 | User-agent: *
11 | Disallow: /
12 | """.strip()
13 |
14 |
15 | def robots_txt(request):
16 | return HttpResponse(DISALLOW_ALL, content_type="text/plain")
17 |
18 |
19 | urlpatterns = [
20 | re_path(r"^$", blog_views.index),
21 | re_path(r"^robots\.txt$", robots_txt),
22 | ]
23 |
24 |
25 | if settings.DEBUG:
26 | try:
27 | import debug_toolbar
28 |
29 | urlpatterns = [
30 | re_path(r"^__debug__/", include(debug_toolbar.urls))
31 | ] + urlpatterns
32 | except ImportError:
33 | pass
34 |
--------------------------------------------------------------------------------
/templates/monthly.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}
2 |
3 | {% block title %}Monthly newsletter archive{% endblock %}
4 |
5 | {% block primary %}
6 | Monthly newsletter archive
7 |
8 | Welcome to the archive of my monthly newsletter. Each edition offers a
9 | curated summary of the most important updates from my work and research.
10 |
11 | {% if newsletters %}
12 |
21 | {% else %}
22 | No newsletters have been published yet.
23 | {% endif %}
24 | {% endblock %}
25 |
26 | {% block secondary %}{% endblock %}
27 |
--------------------------------------------------------------------------------
/blog/migrations/0009_import_ref.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations, models
2 |
3 |
4 | class Migration(migrations.Migration):
5 | dependencies = [
6 | ("blog", "0008_entry_tweet_html"),
7 | ]
8 |
9 | operations = [
10 | migrations.AddField(
11 | model_name="blogmark",
12 | name="import_ref",
13 | field=models.TextField(max_length=64, null=True, unique=True),
14 | ),
15 | migrations.AddField(
16 | model_name="entry",
17 | name="import_ref",
18 | field=models.TextField(max_length=64, null=True, unique=True),
19 | ),
20 | migrations.AddField(
21 | model_name="quotation",
22 | name="import_ref",
23 | field=models.TextField(max_length=64, null=True, unique=True),
24 | ),
25 | ]
26 |
--------------------------------------------------------------------------------
/blog/migrations/0021_previous_tag_name.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations, models
2 |
3 |
4 | class Migration(migrations.Migration):
5 |
6 | dependencies = [
7 | ("blog", "0020_tag_description"),
8 | ]
9 |
10 | operations = [
11 | migrations.CreateModel(
12 | name="PreviousTagName",
13 | fields=[
14 | (
15 | "id",
16 | models.AutoField(
17 | auto_created=True,
18 | primary_key=True,
19 | serialize=False,
20 | verbose_name="ID",
21 | ),
22 | ),
23 | ("previous_name", models.SlugField()),
24 | ("tag", models.ForeignKey(on_delete=models.CASCADE, to="blog.Tag")),
25 | ],
26 | ),
27 | ]
28 |
--------------------------------------------------------------------------------
/blog/context_processors.py:
--------------------------------------------------------------------------------
1 | from blog.models import Entry, Blogmark, Quotation, Note
2 | from django.conf import settings
3 | from django.core.cache import cache
4 |
5 |
6 | def all(request):
7 | return {
8 | "years_with_content": years_with_content(),
9 | }
10 |
11 |
12 | def years_with_content():
13 | cache_key = "years-with-content-3"
14 | years = cache.get(cache_key)
15 | if not years:
16 | years = list(
17 | set(
18 | list(Entry.objects.datetimes("created", "year"))
19 | + list(Blogmark.objects.datetimes("created", "year"))
20 | + list(Quotation.objects.datetimes("created", "year"))
21 | + list(Note.objects.datetimes("created", "year"))
22 | )
23 | )
24 | years.sort()
25 | cache.set(cache_key, years, 60 * 60)
26 | return years
27 |
--------------------------------------------------------------------------------
/monthly/views.py:
--------------------------------------------------------------------------------
1 | from django.http import Http404
2 | from django.shortcuts import render
3 |
4 | from .models import Newsletter
5 |
6 |
7 | def monthly_index(request):
8 | newsletters = Newsletter.objects.all()
9 | context = {
10 | "newsletters": newsletters,
11 | }
12 | return render(request, "monthly.html", context)
13 |
14 |
15 | def newsletter_detail(request, year, month):
16 | year = int(year)
17 | month = int(month)
18 | newsletters = Newsletter.objects.filter(
19 | sent_at__year=year, sent_at__month=month
20 | ).order_by("-sent_at")
21 | newsletter = newsletters.first()
22 | if newsletter is None:
23 | raise Http404("Newsletter not found")
24 |
25 | return render(
26 | request,
27 | "monthly_detail.html",
28 | {
29 | "newsletter": newsletter,
30 | },
31 | )
32 |
--------------------------------------------------------------------------------
/blog/migrations/0007_metadata_default.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.11.5 on 2017-10-08 20:17
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("blog", "0006_gin_indexes"),
9 | ]
10 |
11 | operations = [
12 | migrations.AlterField(
13 | model_name="blogmark",
14 | name="metadata",
15 | field=models.JSONField(blank=True, default={}),
16 | ),
17 | migrations.AlterField(
18 | model_name="entry",
19 | name="metadata",
20 | field=models.JSONField(blank=True, default={}),
21 | ),
22 | migrations.AlterField(
23 | model_name="quotation",
24 | name="metadata",
25 | field=models.JSONField(blank=True, default={}),
26 | ),
27 | ]
28 |
--------------------------------------------------------------------------------
/blog/migrations/0023_blogmark_is_draft_entry_is_draft_quotation_is_draft.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1 on 2024-09-18 05:47
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | dependencies = [
9 | ("blog", "0022_alter_blogmark_use_markdown"),
10 | ]
11 |
12 | operations = [
13 | migrations.AddField(
14 | model_name="blogmark",
15 | name="is_draft",
16 | field=models.BooleanField(default=False),
17 | ),
18 | migrations.AddField(
19 | model_name="entry",
20 | name="is_draft",
21 | field=models.BooleanField(default=False),
22 | ),
23 | migrations.AddField(
24 | model_name="quotation",
25 | name="is_draft",
26 | field=models.BooleanField(default=False),
27 | ),
28 | ]
29 |
--------------------------------------------------------------------------------
/blog/migrations/0012_card_image.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.1.2 on 2019-03-07 05:28
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("blog", "0011_entry_extra_head_html"),
9 | ]
10 |
11 | operations = [
12 | migrations.AddField(
13 | model_name="blogmark",
14 | name="card_image",
15 | field=models.CharField(blank=True, max_length=128, null=True),
16 | ),
17 | migrations.AddField(
18 | model_name="entry",
19 | name="card_image",
20 | field=models.CharField(blank=True, max_length=128, null=True),
21 | ),
22 | migrations.AddField(
23 | model_name="quotation",
24 | name="card_image",
25 | field=models.CharField(blank=True, max_length=128, null=True),
26 | ),
27 | ]
28 |
--------------------------------------------------------------------------------
/blog/factories.py:
--------------------------------------------------------------------------------
1 | import factory
2 | import factory.django
3 | import factory.fuzzy
4 | from datetime import timezone
5 |
6 |
7 | class BaseFactory(factory.django.DjangoModelFactory):
8 | slug = factory.Sequence(lambda n: "slug%d" % n)
9 | created = factory.Faker("past_datetime", tzinfo=timezone.utc)
10 |
11 |
12 | class EntryFactory(BaseFactory):
13 | class Meta:
14 | model = "blog.Entry"
15 |
16 | title = factory.Faker("sentence")
17 |
18 |
19 | class BlogmarkFactory(BaseFactory):
20 | class Meta:
21 | model = "blog.Blogmark"
22 |
23 | link_url = factory.Faker("uri")
24 | link_title = factory.Faker("sentence")
25 | commentary = factory.Faker("sentence")
26 |
27 |
28 | class QuotationFactory(BaseFactory):
29 | class Meta:
30 | model = "blog.Quotation"
31 |
32 |
33 | class NoteFactory(BaseFactory):
34 | class Meta:
35 | model = "blog.Note"
36 |
--------------------------------------------------------------------------------
/blog/migrations/0004_metadata_json.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.11.5 on 2017-09-29 06:03
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("blog", "0003_auto_20170926_0641"),
9 | ]
10 |
11 | operations = [
12 | migrations.AddField(
13 | model_name="blogmark",
14 | name="metadata",
15 | field=models.JSONField(default={}),
16 | preserve_default=False,
17 | ),
18 | migrations.AddField(
19 | model_name="entry",
20 | name="metadata",
21 | field=models.JSONField(default={}),
22 | preserve_default=False,
23 | ),
24 | migrations.AddField(
25 | model_name="quotation",
26 | name="metadata",
27 | field=models.JSONField(default={}),
28 | preserve_default=False,
29 | ),
30 | ]
31 |
--------------------------------------------------------------------------------
/templates/admin/index.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/index.html" %}
2 |
3 | {% block content %}
4 |
5 | {% include "admin/app_list.html" with app_list=app_list show_changelinks=True %}
6 |
7 |
Tools
8 |
9 |
10 | Bulk Tag Tool - Add tags to multiple items at once
11 |
12 |
13 | Merge Tags - Merge multiple tags into one
14 |
15 |
16 | SQL Dashboard - Run SQL queries against the database
17 |
18 |
19 |
20 |
21 | {% endblock %}
22 |
--------------------------------------------------------------------------------
/blog/migrations/0005_search_document.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.11.5 on 2017-09-30 20:45
3 |
4 |
5 | import django.contrib.postgres.search
6 | from django.db import migrations
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [
11 | ("blog", "0004_metadata_json"),
12 | ]
13 |
14 | operations = [
15 | migrations.AddField(
16 | model_name="blogmark",
17 | name="search_document",
18 | field=django.contrib.postgres.search.SearchVectorField(null=True),
19 | ),
20 | migrations.AddField(
21 | model_name="entry",
22 | name="search_document",
23 | field=django.contrib.postgres.search.SearchVectorField(null=True),
24 | ),
25 | migrations.AddField(
26 | model_name="quotation",
27 | name="search_document",
28 | field=django.contrib.postgres.search.SearchVectorField(null=True),
29 | ),
30 | ]
31 |
--------------------------------------------------------------------------------
/templates/archive_day.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}
2 |
3 | {% block title %}Archive for {{ date|date:"l, jS F Y" }}{% endblock %}
4 |
5 | {% block primary %}
6 | {{ date|date:"l, jS F Y"}}
7 | {% load blog_tags %}
8 | {% blog_mixed_list items %}
9 |
10 | {% endblock %}
11 |
12 | {% block secondary %}
13 |
22 | {% endblock %}
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # simonwillisonblog
2 |
3 | [](https://github.com/simonw/simonwillisonblog/actions)
4 |
5 | The code that runs my weblog, https://simonwillison.net/
6 |
7 | ## Search Engine
8 |
9 | This blog includes a built-in search engine. Here's how it works:
10 |
11 | 1. The search functionality is implemented in the `search` function in `blog/search.py`.
12 | 2. It uses a combination of full-text search and tag-based filtering.
13 | 3. The search index is built and updated automatically when new content is added to the blog.
14 | 4. Users can search for content using keywords, which are matched against the full text of blog entries and blogmarks.
15 | 5. The search results are ranked based on relevance and can be further filtered by tags.
16 | 6. The search interface is integrated into the blog's user interface, allowing for a seamless user experience.
17 |
18 | For more details on the implementation, refer to the `search` function in `blog/search.py`.
19 |
--------------------------------------------------------------------------------
/templates/homepage.html:
--------------------------------------------------------------------------------
1 | {% extends "bighead.html" %}{% load entry_tags %}
2 |
3 | {% block title %}Simon Willison’s Weblog{% endblock %}
4 |
5 | {% block body_class %} class="homepage"{% endblock %}
6 |
7 | {% block extrahead %}
8 |
9 |
10 |
11 |
12 | {% for entry in entries %}{{ entry.extra_head_html|default:""|safe }}{% endfor %}
13 | {% endblock %}
14 |
15 | {% block primary %}
16 | Recent
17 |
18 | {% load blog_tags %}
19 | {% blog_mixed_list_with_dates items day_headers=1 %}
20 | {% endblock %}
21 |
22 | {% block secondary %}
23 | Highlights
24 |
25 | {% for entry in entries %}
26 | {{ entry }} - {{ entry.created.date }}
27 | {% endfor %}
28 |
29 | {% include "_sponsor_promo.html" %}
30 | {% endblock %}
31 |
--------------------------------------------------------------------------------
/monthly/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.4 on 2025-09-30 20:49
2 |
3 | from django.db import migrations, models
4 |
5 |
6 | class Migration(migrations.Migration):
7 |
8 | initial = True
9 |
10 | dependencies = []
11 |
12 | operations = [
13 | migrations.CreateModel(
14 | name="Newsletter",
15 | fields=[
16 | (
17 | "id",
18 | models.BigAutoField(
19 | auto_created=True,
20 | primary_key=True,
21 | serialize=False,
22 | verbose_name="ID",
23 | ),
24 | ),
25 | ("subject", models.CharField(max_length=255)),
26 | ("body", models.TextField()),
27 | ("sent_at", models.DateTimeField()),
28 | ],
29 | options={
30 | "ordering": ["-sent_at"],
31 | "get_latest_by": "sent_at",
32 | },
33 | ),
34 | ]
35 |
--------------------------------------------------------------------------------
/redirects/migrations/0002_auto_20171001_2242.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.11.5 on 2017-10-01 22:42
3 |
4 |
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | dependencies = [
10 | ("redirects", "0001_initial"),
11 | ]
12 |
13 | operations = [
14 | migrations.AlterField(
15 | model_name="redirect",
16 | name="domain",
17 | field=models.CharField(blank=True, default="", max_length=128),
18 | preserve_default=False,
19 | ),
20 | migrations.AlterField(
21 | model_name="redirect",
22 | name="path",
23 | field=models.CharField(blank=True, default="", max_length=128),
24 | preserve_default=False,
25 | ),
26 | migrations.AlterField(
27 | model_name="redirect",
28 | name="target",
29 | field=models.CharField(blank=True, default="", max_length=256),
30 | preserve_default=False,
31 | ),
32 | ]
33 |
--------------------------------------------------------------------------------
/templates/_sponsor_promo.html:
--------------------------------------------------------------------------------
1 |
9 |
14 | Monthly briefing
15 |
16 |
21 | Sponsor me for $10/month and get a curated email digest of the month's most important LLM developments.
22 |
23 |
28 | Pay me to send you less!
29 |
30 |
40 | Sponsor & subscribe
41 |
42 |
--------------------------------------------------------------------------------
/blog/migrations/0006_gin_indexes.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.11.5 on 2017-10-01 01:48
3 |
4 |
5 | import django.contrib.postgres.indexes
6 | from django.db import migrations
7 |
8 |
9 | class Migration(migrations.Migration):
10 | dependencies = [
11 | ("blog", "0005_search_document"),
12 | ]
13 |
14 | operations = [
15 | migrations.AddIndex(
16 | model_name="blogmark",
17 | index=django.contrib.postgres.indexes.GinIndex(
18 | fields=["search_document"], name="blog_blogma_search__45eeb9_gin"
19 | ),
20 | ),
21 | migrations.AddIndex(
22 | model_name="quotation",
23 | index=django.contrib.postgres.indexes.GinIndex(
24 | fields=["search_document"], name="blog_quotat_search__aa2d47_gin"
25 | ),
26 | ),
27 | migrations.AddIndex(
28 | model_name="entry",
29 | index=django.contrib.postgres.indexes.GinIndex(
30 | fields=["search_document"], name="blog_entry_search__d62c3b_gin"
31 | ),
32 | ),
33 | ]
34 |
--------------------------------------------------------------------------------
/feedstats/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations, models
2 |
3 |
4 | class Migration(migrations.Migration):
5 | initial = True
6 |
7 | dependencies = []
8 |
9 | operations = [
10 | migrations.CreateModel(
11 | name="SubscriberCount",
12 | fields=[
13 | (
14 | "id",
15 | models.AutoField(
16 | auto_created=True,
17 | primary_key=True,
18 | serialize=False,
19 | verbose_name="ID",
20 | ),
21 | ),
22 | ("path", models.CharField(max_length=128)),
23 | ("count", models.IntegerField()),
24 | ("created", models.DateTimeField(auto_now_add=True)),
25 | ("user_agent", models.CharField(db_index=True, max_length=128)),
26 | ],
27 | ),
28 | # migrations.AlterIndexTogether(
29 | # name="subscribercount",
30 | # index_together=set([("path", "user_agent", "count", "created")]),
31 | # ),
32 | ]
33 |
--------------------------------------------------------------------------------
/.github/workflows/cog.yml:
--------------------------------------------------------------------------------
1 | name: Cog
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | permissions:
10 | contents: write
11 |
12 | jobs:
13 | cog:
14 | runs-on: ubuntu-latest
15 | steps:
16 | - name: Checkout repository
17 | uses: actions/checkout@v6
18 | with:
19 | ref: ${{ github.head_ref }}
20 |
21 | - name: Set up Python
22 | uses: actions/setup-python@v6
23 | with:
24 | python-version: '3.13'
25 | cache: 'pip'
26 | cache-dependency-path: '.github/workflows/cog.yml'
27 | - name: Install cogapp
28 | run: pip install cogapp
29 | - name: Run cog and commit changes
30 | run: |
31 | cog -r static/css/all.css
32 | git config --local user.email "github-actions[bot]@users.noreply.github.com"
33 | git config --local user.name "github-actions[bot]"
34 | git add static/css/all.css
35 | if git diff --staged --quiet; then
36 | echo "No changes to commit"
37 | else
38 | git commit -m "Update generated CSS with cog [skip ci]"
39 | git push
40 | fi
41 |
--------------------------------------------------------------------------------
/templates/wide.html:
--------------------------------------------------------------------------------
1 | {% extends "item_base.html" %}
2 | {% load entry_tags %}
3 | {% block title %}{{ entry.title|typography }}{% endblock %}
4 |
5 | {% block body_class %} class="entry-wide"{% endblock %}
6 |
7 | {% block extrahead %}
8 | {{ block.super }}
9 | {{ entry.extra_head_html|default:""|safe }}
10 | {% endblock %}
11 |
12 | {% block card_title %}{{ entry.title|typography }}{% endblock %}
13 | {% block card_description %}{{ entry.body|xhtml|remove_context_paragraph|typography|xhtml2html|striptags|truncatewords:30|force_escape }}{% endblock %}
14 |
15 | {% block item_content %}
16 | {{ entry.title|typography }}
17 |
18 | {{ entry.body|xhtml|resize_images_to_fit_width:"450"|typography|xhtml2html }}
19 |
20 |
26 |
27 | {% endblock %}
28 |
--------------------------------------------------------------------------------
/redirects/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # Generated by Django 1.11.5 on 2017-10-01 20:58
3 |
4 |
5 | from django.db import migrations, models
6 |
7 |
8 | class Migration(migrations.Migration):
9 | initial = True
10 |
11 | dependencies = []
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name="Redirect",
16 | fields=[
17 | (
18 | "id",
19 | models.AutoField(
20 | auto_created=True,
21 | primary_key=True,
22 | serialize=False,
23 | verbose_name="ID",
24 | ),
25 | ),
26 | ("domain", models.CharField(blank=True, max_length=128, null=True)),
27 | ("path", models.CharField(blank=True, max_length=128, null=True)),
28 | ("target", models.CharField(blank=True, max_length=256, null=True)),
29 | ("created", models.DateTimeField(auto_now_add=True)),
30 | ],
31 | ),
32 | # migrations.AlterUniqueTogether(
33 | # name="redirect",
34 | # unique_together=set([("domain", "path")]),
35 | # ),
36 | ]
37 |
--------------------------------------------------------------------------------
/redirects/middleware.py:
--------------------------------------------------------------------------------
1 | from .models import Redirect
2 | from django.http import HttpResponsePermanentRedirect
3 |
4 |
5 | def redirect_middleware(get_response):
6 | def middleware(request):
7 | path = request.path.lstrip("/")
8 | redirects = list(
9 | Redirect.objects.filter(
10 | domain=request.get_host(),
11 | # We redirect on either a path match or a '*'
12 | # record existing for this domain
13 | path__in=(path, "*"),
14 | )
15 | )
16 | # A non-star redirect always takes precedence
17 | non_star = [r for r in redirects if r.path != "*"]
18 | if non_star:
19 | return HttpResponsePermanentRedirect(non_star[0].target)
20 | # If there's a star redirect, build path and redirect to that
21 | star = [r for r in redirects if r.path == "*"]
22 | if star:
23 | new_url = star[0].target + path
24 | if request.META["QUERY_STRING"]:
25 | new_url += "?" + request.META["QUERY_STRING"]
26 | return HttpResponsePermanentRedirect(new_url)
27 | # Default: no redirects, just get on with it:
28 | return get_response(request)
29 |
30 | return middleware
31 |
--------------------------------------------------------------------------------
/blog/migrations/0024_liveupdate.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.1 on 2024-10-01 16:57
2 |
3 | import django.db.models.deletion
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ("blog", "0023_blogmark_is_draft_entry_is_draft_quotation_is_draft"),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name="LiveUpdate",
16 | fields=[
17 | (
18 | "id",
19 | models.AutoField(
20 | auto_created=True,
21 | primary_key=True,
22 | serialize=False,
23 | verbose_name="ID",
24 | ),
25 | ),
26 | ("created", models.DateTimeField(auto_now_add=True)),
27 | ("content", models.TextField()),
28 | (
29 | "entry",
30 | models.ForeignKey(
31 | on_delete=django.db.models.deletion.CASCADE,
32 | related_name="updates",
33 | to="blog.entry",
34 | ),
35 | ),
36 | ],
37 | ),
38 | ]
39 |
--------------------------------------------------------------------------------
/templates/quotation.html:
--------------------------------------------------------------------------------
1 | {% extends "item_base.html" %}
2 |
3 | {% block title %}A quote from {{ quotation.source }}{% endblock %}
4 |
5 | {% block card_title %}A quote from {{ quotation.source }}{% endblock %}
6 | {% block card_description %}{{ quotation.body_strip_tags|truncatewords:30|force_escape }}{% endblock %}
7 |
8 | {% load entry_tags %}
9 | {% block item_content %}
10 |
11 | {% include "_draft_warning.html" %}
12 |
{{ quotation.body }}
13 |
— {% if quotation.source_url %}{{ quotation.source }} {% else %}{{ quotation.source }}{% endif %}{% if quotation.context %}, {{ quotation.context_rendered }} {% endif %}
14 |
15 |
16 |
17 |
18 | {% endblock %}
19 |
20 | {% block secondary %}
21 |
22 | {% include "_tags.html" with obj=quotation %}
23 |
24 | {% endblock %}
25 |
--------------------------------------------------------------------------------
/feedstats/utils.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 | from .models import SubscriberCount
3 | import datetime
4 | import re
5 |
6 | subscribers_re = re.compile(r"(\d+) subscribers")
7 |
8 |
9 | def count_subscribers(view_fn):
10 | @wraps(view_fn)
11 | def inner_fn(request, *args, **kwargs):
12 | user_agent = request.META.get("HTTP_USER_AGENT", "")
13 | match = subscribers_re.search(user_agent)
14 | if match:
15 | count = int(match.group(1))
16 | today = datetime.date.today()
17 | simplified_user_agent = subscribers_re.sub("X subscribers", user_agent)
18 | # Do we have this one yet?
19 | if not SubscriberCount.objects.filter(
20 | path=request.path,
21 | count=count,
22 | user_agent=simplified_user_agent,
23 | created__year=today.year,
24 | created__month=today.month,
25 | created__day=today.day,
26 | ).exists():
27 | SubscriberCount.objects.create(
28 | path=request.path,
29 | count=count,
30 | user_agent=simplified_user_agent,
31 | )
32 | return view_fn(request, *args, **kwargs)
33 |
34 | return inner_fn
35 |
--------------------------------------------------------------------------------
/templates/django_sql_dashboard/base.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {% block title %}{% endblock %}
7 |
45 |
46 |
47 |
48 | Simon Willison’s Weblog
49 |
50 |
51 | {% block content %}{% endblock %}
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/blog/middleware.py:
--------------------------------------------------------------------------------
1 | from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse
2 | from django.shortcuts import redirect
3 |
4 |
5 | class AmpersandRedirectMiddleware:
6 | def __init__(self, get_response):
7 | self.get_response = get_response
8 |
9 | def __call__(self, request):
10 | full_path = request.get_full_path()
11 | if "&" in full_path or "&%3B" in full_path:
12 | parsed_url = urlparse(full_path)
13 | query_params = parse_qsl(parsed_url.query)
14 |
15 | # Replace & with & in the query parameters
16 | corrected_query = [(k.replace("amp;", ""), v) for k, v in query_params]
17 |
18 | # Rebuild the URL with corrected query parameters
19 | corrected_url = urlunparse(
20 | (
21 | parsed_url.scheme,
22 | parsed_url.netloc,
23 | parsed_url.path,
24 | parsed_url.params,
25 | urlencode(corrected_query),
26 | parsed_url.fragment,
27 | )
28 | )
29 |
30 | # Redirect the user to the corrected URL
31 | return redirect(corrected_url)
32 |
33 | response = self.get_response(request)
34 | return response
35 |
--------------------------------------------------------------------------------
/templates/_pagination.html:
--------------------------------------------------------------------------------
1 | {% if page.paginator.num_pages > 1 %}{% load blog_tags %}{% load humanize %}
2 |
22 | {% else %}
23 | {% if page_total %}
24 |
27 | {% endif %}
28 | {% endif %}
29 |
--------------------------------------------------------------------------------
/templates/note.html:
--------------------------------------------------------------------------------
1 | {% extends "item_base.html" %}
2 |
3 | {% block title %}{% if note.title %}{{ note.title }}{% else %}Note on {{ note.created|date:"jS F Y" }}{% endif %}{% endblock %}
4 |
5 | {% block extrahead %}
6 | {{ block.super }}
7 |
8 | {% endblock %}
9 |
10 | {% block card_title %}{% if note.title %}{{ note.title }}{% else %}Note on {{ note.created|date:"jS F Y" }}{% endif %}{% endblock %}
11 | {% block card_description %}{{ note.body_rendered|striptags|truncatewords:30|force_escape }}{% endblock %}
12 |
13 | {% load entry_tags %}
14 | {% block item_content %}
15 |
16 | {% include "_draft_warning.html" %}
17 | {{ note.body_rendered }}
18 |
19 |
20 |
21 |
22 | {% endblock %}
23 |
24 | {% block secondary %}
25 |
32 | {% endblock %}
33 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | services:
9 | postgres:
10 | image: postgres
11 | env:
12 | POSTGRES_USER: postgres
13 | POSTGRES_DB: test_db
14 | POSTGRES_HOST_AUTH_METHOD: trust
15 | ports:
16 | - 5432:5432
17 | options: >-
18 | --health-cmd pg_isready
19 | --health-interval 10s
20 | --health-timeout 5s
21 | --health-retries 5
22 | steps:
23 | - name: Check out code
24 | uses: actions/checkout@v4
25 | - name: Set up Python
26 | uses: actions/setup-python@v5
27 | with:
28 | python-version: "3.13"
29 | cache: pip
30 | - name: Install dependencies
31 | run: |
32 | pip install -r requirements.txt
33 | - name: Set up database
34 | run: |
35 | sudo apt-get install -y postgresql-client
36 | export DATABASE_URL=postgres://postgres:@localhost/test_db
37 | python manage.py migrate --noinput
38 | python manage.py collectstatic --noinput
39 | - name: Run tests
40 | run: |
41 | export DATABASE_URL=postgres://postgres:@localhost/test_db
42 | python manage.py test -v3
43 |
--------------------------------------------------------------------------------
/templates/write.html:
--------------------------------------------------------------------------------
1 | {% extends "item_base.html" %}
2 | {% block extrahead %}
3 |
4 |
19 |
20 | {% endblock %}
21 | {% load entry_tags %}
22 | {% block title %}{{ entry.title|typography }}{% endblock %}
23 |
24 | {% block item_content %}
25 | ...
26 |
27 |
28 |
29 |
30 |
31 | {% endblock %}
32 |
33 | {% block secondary %}
34 |
50 | {% endblock %}
51 |
--------------------------------------------------------------------------------
/claude-run-tests.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | set -e
3 |
4 | # Script to run tests for the simonwillisonblog project
5 | # Can be run multiple times without breaking
6 |
7 | # Change to project directory
8 | cd "$(dirname "$0")"
9 |
10 | # Create virtual environment if it doesn't exist
11 | if [ ! -d ".venv" ]; then
12 | echo "Creating virtual environment..."
13 | uv venv --python 3.12 .venv
14 | fi
15 |
16 | # Activate virtual environment
17 | source .venv/bin/activate
18 |
19 | # Install dependencies (uv pip install is idempotent)
20 | echo "Installing dependencies..."
21 | uv pip install -r requirements.txt --quiet
22 |
23 | # Start PostgreSQL if not running
24 | if ! pg_isready -q 2>/dev/null; then
25 | echo "Starting PostgreSQL..."
26 | sudo service postgresql start
27 | sleep 2
28 | fi
29 |
30 | # Set up postgres user password if needed (idempotent)
31 | sudo -u postgres psql -c "ALTER USER postgres PASSWORD 'postgres';" 2>/dev/null || true
32 |
33 | # Create test database if it doesn't exist (idempotent)
34 | sudo -u postgres createdb test_db 2>/dev/null || true
35 |
36 | # Set database URL
37 | export DATABASE_URL=postgres://postgres:postgres@localhost/test_db
38 |
39 | # Run migrations (idempotent)
40 | echo "Running migrations..."
41 | python manage.py migrate --noinput
42 |
43 | # Run tests
44 | echo "Running tests..."
45 | python manage.py test "$@"
46 |
--------------------------------------------------------------------------------
/templates/archive_month.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}{% load humanize %}
2 |
3 | {% block title %}Archive for {{ date|date:"F Y" }}{% endblock %}
4 |
5 | {% block primary %}
6 |
7 | {{ date|date:"F Y"}}
8 |
9 | {% if total > 2 %}
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 | {{ total|intcomma }} post{{ total|pluralize }}:
18 | {% for t in type_counts %}
19 | {{ t.count }} {% if t.count == 1 %}{{ t.singular }}{% else %}{{ t.plural }}{% endif %} {% if not forloop.last %}, {% endif %}
20 | {% endfor %}
21 |
22 | {% endif %}
23 |
24 | {% load blog_tags %}
25 |
26 | {% blog_mixed_list_with_dates items day_headers=1 day_links=1 %}
27 |
28 | {% include "_pagination.html" %}
29 |
30 | {% endblock %}
31 |
32 | {% block secondary %}
33 |
38 | {% endblock %}
39 |
--------------------------------------------------------------------------------
/blog/signals.py:
--------------------------------------------------------------------------------
1 | from django.dispatch import receiver
2 | from django.db.models.signals import post_save, m2m_changed
3 | from django.db.models import Value, TextField
4 | from django.contrib.postgres.search import SearchVector
5 | from django.db import transaction
6 | from blog.models import BaseModel, Tag
7 | import operator
8 | from functools import reduce
9 |
10 |
11 | @receiver(post_save)
12 | def on_save(sender, **kwargs):
13 | if not issubclass(sender, BaseModel):
14 | return
15 | transaction.on_commit(make_updater(kwargs["instance"]))
16 |
17 |
18 | @receiver(m2m_changed)
19 | def on_m2m_changed(sender, **kwargs):
20 | instance = kwargs["instance"]
21 | model = kwargs["model"]
22 | if model is Tag:
23 | transaction.on_commit(make_updater(instance))
24 | elif isinstance(instance, Tag):
25 | for obj in model.objects.filter(pk__in=kwargs["pk_set"]):
26 | transaction.on_commit(make_updater(obj))
27 |
28 |
29 | def make_updater(instance):
30 | components = instance.index_components()
31 | pk = instance.pk
32 |
33 | def on_commit():
34 | search_vectors = []
35 | for weight, text in list(components.items()):
36 | search_vectors.append(
37 | SearchVector(Value(text, output_field=TextField()), weight=weight)
38 | )
39 | instance.__class__.objects.filter(pk=pk).update(
40 | search_document=reduce(operator.add, search_vectors)
41 | )
42 |
43 | return on_commit
44 |
--------------------------------------------------------------------------------
/feedstats/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 | from .models import SubscriberCount
3 | import datetime
4 |
5 |
6 | class FeedstatsTests(TestCase):
7 | def test_feedstats_records_subscriber_numbers(self):
8 | self.assertEqual(0, SubscriberCount.objects.count())
9 | # If no \d+ subscribers, we don't record anything
10 | self.client.get("/atom/everything/", HTTP_USER_AGENT="Blah")
11 | self.assertEqual(0, SubscriberCount.objects.count())
12 | self.client.get("/atom/everything/", HTTP_USER_AGENT="Blah (10 subscribers)")
13 | self.assertEqual(1, SubscriberCount.objects.count())
14 | row = SubscriberCount.objects.all()[0]
15 | self.assertEqual("/atom/everything/", row.path)
16 | self.assertEqual(10, row.count)
17 | self.assertEqual(datetime.date.today(), row.created.date())
18 | self.assertEqual("Blah (X subscribers)", row.user_agent)
19 | # If we hit again with the same number, no new record is recorded
20 | self.client.get("/atom/everything/", HTTP_USER_AGENT="Blah (10 subscribers)")
21 | self.assertEqual(1, SubscriberCount.objects.count())
22 | # If we hit again with a different number, we record a new row
23 | self.client.get("/atom/everything/", HTTP_USER_AGENT="Blah (11 subscribers)")
24 | self.assertEqual(2, SubscriberCount.objects.count())
25 | row = SubscriberCount.objects.all()[1]
26 | self.assertEqual(11, row.count)
27 | self.assertEqual("Blah (X subscribers)", row.user_agent)
28 |
--------------------------------------------------------------------------------
/templates/archive_series.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}
2 |
3 | {% block title %}Simon Willison: {{ series }}{% endblock %}
4 |
5 | {% block primary %}
6 | Series : {{ series }}
7 |
8 | {{ series.summary_rendered }}
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | Atom feed
29 |
30 | {% load blog_tags %}
31 |
32 | {% blog_mixed_list_with_dates items %}
33 |
34 | {% endblock %}
35 |
--------------------------------------------------------------------------------
/templates/blogmark.html:
--------------------------------------------------------------------------------
1 | {% extends "item_base.html" %}
2 |
3 | {% block title %}{{ blogmark.title|default:blogmark.link_title }}{% endblock %}
4 | {% load entry_tags %}
5 |
6 | {% block extrahead %}
7 | {{ block.super }}
8 |
9 | {% endblock %}
10 |
11 | {% block card_title %}{{ blogmark.title|default:blogmark.link_title|typography }}{% endblock %}
12 | {% block card_description %}{{ blogmark.body|striptags|truncatewords:30|force_escape }}{% endblock %}
13 |
14 | {% block item_content %}
15 | {% include "_draft_warning.html" %}
16 | {{ blogmark.link_title }} {% if blogmark.via_url %} (via ){% endif %}{% if not blogmark.via_url and not blogmark.link_title|ends_with_punctuation %}.{% endif %} {% if not blogmark.use_markdown %}{{ blogmark.commentary|typography|linebreaks|strip_wrapping_p }}{% else %}{{ blogmark.body|strip_wrapping_p }}{% endif %}
17 |
18 |
19 |
20 | {% endblock %}
21 |
22 | {% block secondary %}
23 |
31 | {% endblock %}
32 |
--------------------------------------------------------------------------------
/blog/migrations/0013_fix_simon_incutio_links.py:
--------------------------------------------------------------------------------
1 | from django.db import migrations
2 | from django.utils.dates import MONTHS_3
3 | import re
4 |
5 | MONTHS_3_REV = {
6 | "jan": 1,
7 | "feb": 2,
8 | "mar": 3,
9 | "apr": 4,
10 | "may": 5,
11 | "jun": 6,
12 | "jul": 7,
13 | "aug": 8,
14 | "sep": 9,
15 | "oct": 10,
16 | "nov": 11,
17 | "dec": 12,
18 | }
19 | MONTHS_3_REV_REV = {value: key for key, value in list(MONTHS_3_REV.items())}
20 |
21 | url_re = re.compile(
22 | r'"http://simon\.incutio\.com/archive/(\d{4})/(\d{2})/(\d{2})/(.*?)"'
23 | )
24 |
25 |
26 | def fix_url(m):
27 | yyyy, mm, dd, slug = m.groups()
28 | month = MONTHS_3_REV_REV[int(mm)].title()
29 | return '"/{}/{}/{}/{}/"'.format(yyyy, month, dd, slug.replace("#", ""))
30 |
31 |
32 | def fix_simon_incutio_links(apps, schema_editor):
33 | Entry = apps.get_model("blog", "Entry")
34 | actually_fix_them(Entry)
35 |
36 |
37 | def actually_fix_them(Entry):
38 | for entry in Entry.objects.filter(body__icontains="simon.incutio"):
39 | new_body = url_re.sub(fix_url, entry.body)
40 | if new_body != entry.body:
41 | Entry.objects.filter(pk=entry.pk).update(body=new_body)
42 | path = "/%d/%s/%d/%s/" % (
43 | entry.created.year,
44 | MONTHS_3[entry.created.month].title(),
45 | entry.created.day,
46 | entry.slug,
47 | )
48 | print("Updated https://simonwillison.net{}".format(path))
49 |
50 |
51 | class Migration(migrations.Migration):
52 | dependencies = [
53 | ("blog", "0012_card_image"),
54 | ]
55 |
56 | operations = [
57 | migrations.RunPython(fix_simon_incutio_links),
58 | ]
59 |
--------------------------------------------------------------------------------
/config/nginx.conf.erb:
--------------------------------------------------------------------------------
1 | daemon off;
2 | #Heroku dynos have at least 4 cores.
3 | worker_processes <%= ENV['NGINX_WORKERS'] || 4 %>;
4 |
5 | events {
6 | use epoll;
7 | accept_mutex on;
8 | worker_connections 1024;
9 | }
10 |
11 | http {
12 | gzip on;
13 | gzip_comp_level 2;
14 | gzip_min_length 512;
15 |
16 | server_tokens off;
17 |
18 | log_format custom 'measure#nginx.service=$request_time request="$request" '
19 | 'status_code=$status request_id=$http_x_request_id '
20 | 'remote_addr="$remote_addr" forwarded_for="$http_x_forwarded_for" '
21 | 'forwarded_proto="$http_x_forwarded_proto" via="$http_via" '
22 | 'body_bytes_sent=$body_bytes_sent referer="$http_referer" '
23 | 'user_agent="$http_user_agent" '
24 | 'request_time="$request_time" '
25 | 'upstream_response_time="$upstream_response_time" '
26 | 'upstream_connect_time="$upstream_connect_time" '
27 | 'upstream_header_time="$upstream_header_time";';
28 | access_log logs/nginx/access.log custom;
29 | error_log logs/nginx/error.log;
30 |
31 | include mime.types;
32 | default_type application/octet-stream;
33 | sendfile on;
34 |
35 | #Must read the body in 5 seconds.
36 | client_body_timeout 5;
37 |
38 | upstream app_server {
39 | server unix:/tmp/nginx.socket fail_timeout=0;
40 | }
41 |
42 | server {
43 | listen <%= ENV["PORT"] %>;
44 | server_name _;
45 | keepalive_timeout 5;
46 |
47 | location / {
48 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
49 | proxy_set_header Host $http_host;
50 | proxy_redirect off;
51 | proxy_pass http://app_server;
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/monthly/tests.py:
--------------------------------------------------------------------------------
1 | from datetime import datetime
2 |
3 | from django.test import TestCase
4 | from django.urls import reverse
5 | from django.utils import timezone
6 |
7 | from .models import Newsletter
8 |
9 |
10 | class MonthlyViewsTests(TestCase):
11 | def setUp(self):
12 | self.newsletter_old = Newsletter.objects.create(
13 | subject="Older edition",
14 | body="Old body",
15 | sent_at=timezone.make_aware(datetime(2024, 1, 31, 12, 0, 0)),
16 | )
17 | self.newsletter_new = Newsletter.objects.create(
18 | subject="Newer edition",
19 | body="New **body**",
20 | sent_at=timezone.make_aware(datetime(2024, 2, 29, 12, 0, 0)),
21 | )
22 |
23 | def test_monthly_index_lists_newsletters(self):
24 | response = self.client.get(reverse("monthly:index"))
25 | self.assertEqual(response.status_code, 200)
26 | newsletters = list(response.context["newsletters"])
27 | self.assertEqual(newsletters, [self.newsletter_new, self.newsletter_old])
28 | self.assertContains(response, "Newer edition")
29 | self.assertContains(response, "Older edition")
30 | self.assertContains(
31 | response,
32 | reverse("monthly:detail", kwargs={"year": 2024, "month": "02"}),
33 | )
34 |
35 | def test_newsletter_detail_renders_content(self):
36 | response = self.client.get(
37 | reverse("monthly:detail", kwargs={"year": 2024, "month": "02"})
38 | )
39 | self.assertEqual(response.status_code, 200)
40 | self.assertEqual(response.context["newsletter"], self.newsletter_new)
41 | self.assertContains(response, "Newer edition")
42 | self.assertContains(response, "body ", html=True)
43 |
44 | def test_newsletter_detail_missing(self):
45 | response = self.client.get(
46 | reverse("monthly:detail", kwargs={"year": 2023, "month": "12"})
47 | )
48 | self.assertEqual(response.status_code, 404)
49 |
--------------------------------------------------------------------------------
/blog/migrations/0016_series.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from django.db import migrations, models
3 | import django.db.models.deletion
4 |
5 |
6 | class Migration(migrations.Migration):
7 | dependencies = [
8 | ("blog", "0015_enable_pg_trgm"),
9 | ]
10 |
11 | operations = [
12 | migrations.CreateModel(
13 | name="Series",
14 | fields=[
15 | (
16 | "id",
17 | models.AutoField(
18 | auto_created=True,
19 | primary_key=True,
20 | serialize=False,
21 | verbose_name="ID",
22 | ),
23 | ),
24 | ("created", models.DateTimeField(default=datetime.datetime.utcnow)),
25 | ("slug", models.SlugField(max_length=64, unique=True)),
26 | ("title", models.CharField(max_length=255)),
27 | ("summary", models.TextField()),
28 | ],
29 | ),
30 | migrations.AddField(
31 | model_name="blogmark",
32 | name="series",
33 | field=models.ForeignKey(
34 | blank=True,
35 | null=True,
36 | on_delete=django.db.models.deletion.PROTECT,
37 | to="blog.series",
38 | ),
39 | ),
40 | migrations.AddField(
41 | model_name="entry",
42 | name="series",
43 | field=models.ForeignKey(
44 | blank=True,
45 | null=True,
46 | on_delete=django.db.models.deletion.PROTECT,
47 | to="blog.series",
48 | ),
49 | ),
50 | migrations.AddField(
51 | model_name="quotation",
52 | name="series",
53 | field=models.ForeignKey(
54 | blank=True,
55 | null=True,
56 | on_delete=django.db.models.deletion.PROTECT,
57 | to="blog.series",
58 | ),
59 | ),
60 | ]
61 |
--------------------------------------------------------------------------------
/blog/migrations/0030_tagmerge.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 6.0 on 2025-12-09 00:00
2 |
3 | from django.db import migrations, models
4 | import django.db.models.deletion
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | dependencies = [
10 | ("blog", "0029_blogmark_title"),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name="TagMerge",
16 | fields=[
17 | (
18 | "id",
19 | models.AutoField(
20 | auto_created=True,
21 | primary_key=True,
22 | serialize=False,
23 | verbose_name="ID",
24 | ),
25 | ),
26 | ("created", models.DateTimeField(auto_now_add=True)),
27 | (
28 | "source_tag_name",
29 | models.SlugField(help_text="The tag that was merged (and deleted)"),
30 | ),
31 | (
32 | "destination_tag_name",
33 | models.SlugField(help_text="Name of destination tag at time of merge"),
34 | ),
35 | (
36 | "details",
37 | models.JSONField(
38 | default=dict,
39 | help_text="JSON with affected primary keys for each content type",
40 | ),
41 | ),
42 | (
43 | "destination_tag",
44 | models.ForeignKey(
45 | help_text="The tag that items were merged into",
46 | null=True,
47 | on_delete=django.db.models.deletion.SET_NULL,
48 | to="blog.tag",
49 | ),
50 | ),
51 | ],
52 | options={
53 | "verbose_name": "Tag Merge",
54 | "verbose_name_plural": "Tag Merges",
55 | "ordering": ("-created",),
56 | },
57 | ),
58 | ]
59 |
--------------------------------------------------------------------------------
/CLAUDE.md:
--------------------------------------------------------------------------------
1 | # Simon Willison's Blog
2 |
3 | Django 6.0 web application for https://simonwillison.net/
4 |
5 | ## Running Tests
6 |
7 | This project uses Django's built-in test framework with PostgreSQL.
8 |
9 | ### Prerequisites
10 |
11 | 1. Python 3.12+ (Django 6.0 requirement)
12 | 2. PostgreSQL database running
13 | 3. Dependencies installed: `pip install -r requirements.txt`
14 |
15 | ### Setting up Python 3.12 with uv
16 |
17 | If your system doesn't have Python 3.12+, use `uv` to install it:
18 |
19 | ```bash
20 | # Install Python 3.12
21 | uv python install 3.12
22 |
23 | # Create a virtual environment with Python 3.12
24 | uv venv --python 3.12 .venv312
25 |
26 | # Activate and install dependencies
27 | source .venv312/bin/activate
28 | uv pip install -r requirements.txt
29 | ```
30 |
31 | ### Database Setup
32 |
33 | Set the `DATABASE_URL` environment variable:
34 |
35 | ```bash
36 | export DATABASE_URL=postgres://postgres:@localhost/test_db
37 | ```
38 |
39 | Run migrations before testing:
40 |
41 | ```bash
42 | python manage.py migrate --noinput
43 | ```
44 |
45 | ### Running Tests
46 |
47 | Run all tests:
48 |
49 | ```bash
50 | python manage.py test
51 | ```
52 |
53 | Run tests with verbose output:
54 |
55 | ```bash
56 | python manage.py test -v3
57 | ```
58 |
59 | Run tests for a specific app:
60 |
61 | ```bash
62 | python manage.py test blog
63 | python manage.py test feedstats
64 | python manage.py test monthly
65 | ```
66 |
67 | Run a specific test class or method:
68 |
69 | ```bash
70 | python manage.py test blog.tests.BlogTests
71 | python manage.py test blog.tests.BlogTests.test_homepage
72 | ```
73 |
74 | ## Project Structure
75 |
76 | - `blog/` - Main blog app (entries, blogmarks, quotations, notes, tags)
77 | - `monthly/` - Newsletter functionality
78 | - `feedstats/` - Feed subscriber statistics
79 | - `redirects/` - URL redirect handling
80 | - `config/` - Django settings and URL configuration
81 | - `templates/` - HTML templates
82 | - `static/` - Static assets
83 |
84 | ## Test Data
85 |
86 | Tests use `factory-boy` for generating test data. Factories are defined in `blog/factories.py`.
87 |
--------------------------------------------------------------------------------
/blog/views_2003.py:
--------------------------------------------------------------------------------
1 | from django.shortcuts import render
2 | from django.db.models import CharField, Value
3 | import datetime
4 | from .models import (
5 | Blogmark,
6 | Entry,
7 | Quotation,
8 | )
9 |
10 |
11 | def index(request):
12 | # Get back items across all item types - I went back to UK on 30 September 2004
13 | cutoff_kwargs = {}
14 | if request.GET.get("backdate"):
15 | cutoff_kwargs["created__lte"] = datetime.datetime(2004, 9, 29, 0, 0, 0)
16 | recent = list(
17 | Entry.objects.filter(**cutoff_kwargs)
18 | .annotate(content_type=Value("entry", output_field=CharField()))
19 | .values("content_type", "id", "created")
20 | .order_by()
21 | .union(
22 | Blogmark.objects.filter(**cutoff_kwargs)
23 | .annotate(content_type=Value("blogmark", output_field=CharField()))
24 | .values("content_type", "id", "created")
25 | .order_by()
26 | )
27 | .union(
28 | Quotation.objects.filter(**cutoff_kwargs)
29 | .annotate(content_type=Value("quotation", output_field=CharField()))
30 | .values("content_type", "id", "created")
31 | .order_by()
32 | )
33 | .order_by("-created")[:30]
34 | )
35 |
36 | # Now load the entries, blogmarks, quotations
37 | items = []
38 | to_load = {}
39 | for item in recent:
40 | to_load.setdefault(item["content_type"], []).append(item["id"])
41 | for content_type, model in (
42 | ("entry", Entry),
43 | ("blogmark", Blogmark),
44 | ("quotation", Quotation),
45 | ):
46 | if content_type not in to_load:
47 | continue
48 | items.extend(
49 | [
50 | {"type": content_type, "obj": obj}
51 | for obj in model.objects.in_bulk(to_load[content_type]).values()
52 | ]
53 | )
54 |
55 | items.sort(key=lambda x: x["obj"].created, reverse=True)
56 |
57 | response = render(
58 | request,
59 | "homepage_2003.html",
60 | {
61 | "items": items,
62 | },
63 | )
64 | response["Cache-Control"] = "s-maxage=200"
65 | return response
66 |
--------------------------------------------------------------------------------
/templates/tags.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}
2 | {% block title %}Tags{% endblock %}
3 | {% block primary %}
4 |
5 |
6 |
7 |
8 |
9 |
10 | {% load tag_cloud %}
11 | {% tag_cloud %}
12 |
13 |
52 |
53 | {% endblock %}
54 |
55 | {% block secondary %}
56 |
66 | {% endblock %}
67 |
68 |
--------------------------------------------------------------------------------
/templates/bighead.html:
--------------------------------------------------------------------------------
1 | {% extends "base.html" %}
2 |
3 | {% block body %}
4 |
5 |
6 |
7 |
Simon Willison’s Weblog
8 |
9 |
15 |
16 |
17 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | {% block primary %}{% endblock %}
31 |
32 |
33 |
34 | {% block secondary %}{% endblock %}
35 |
36 |
37 | {% endblock %}
38 |
--------------------------------------------------------------------------------
/blog/migrations/0027_note.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 5.1.1 on 2025-03-26 05:34
2 |
3 | import datetime
4 | import django.contrib.postgres.indexes
5 | import django.contrib.postgres.search
6 | import django.db.models.deletion
7 | from django.db import migrations, models
8 |
9 |
10 | class Migration(migrations.Migration):
11 |
12 | dependencies = [
13 | ("blog", "0026_quotation_context"),
14 | ]
15 |
16 | operations = [
17 | migrations.CreateModel(
18 | name="Note",
19 | fields=[
20 | (
21 | "id",
22 | models.AutoField(
23 | auto_created=True,
24 | primary_key=True,
25 | serialize=False,
26 | verbose_name="ID",
27 | ),
28 | ),
29 | ("created", models.DateTimeField(default=datetime.datetime.utcnow)),
30 | ("slug", models.SlugField(max_length=64)),
31 | ("metadata", models.JSONField(blank=True, default=dict)),
32 | (
33 | "search_document",
34 | django.contrib.postgres.search.SearchVectorField(null=True),
35 | ),
36 | ("import_ref", models.TextField(max_length=64, null=True, unique=True)),
37 | ("card_image", models.CharField(blank=True, max_length=128, null=True)),
38 | ("is_draft", models.BooleanField(default=False)),
39 | ("body", models.TextField()),
40 | (
41 | "series",
42 | models.ForeignKey(
43 | blank=True,
44 | null=True,
45 | on_delete=django.db.models.deletion.PROTECT,
46 | to="blog.series",
47 | ),
48 | ),
49 | ("tags", models.ManyToManyField(blank=True, to="blog.tag")),
50 | ],
51 | options={
52 | "verbose_name_plural": "Notes",
53 | "ordering": ("-created",),
54 | "abstract": False,
55 | "indexes": [
56 | django.contrib.postgres.indexes.GinIndex(
57 | fields=["search_document"], name="blog_note_search__10f4f2_gin"
58 | )
59 | ],
60 | },
61 | ),
62 | ]
63 |
--------------------------------------------------------------------------------
/blog/migrations/0010_auto_20180918_1646.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 2.0b1 on 2018-09-18 16:46
2 |
3 | import datetime
4 | from django.db import migrations, models
5 |
6 |
7 | class Migration(migrations.Migration):
8 | dependencies = [
9 | ("blog", "0009_import_ref"),
10 | ]
11 |
12 | operations = [
13 | migrations.AlterModelOptions(
14 | name="entry",
15 | options={"ordering": ("-created",), "verbose_name_plural": "Entries"},
16 | ),
17 | migrations.AlterField(
18 | model_name="blogmark",
19 | name="created",
20 | field=models.DateTimeField(default=datetime.datetime.utcnow),
21 | ),
22 | migrations.AlterField(
23 | model_name="blogmark",
24 | name="metadata",
25 | field=models.JSONField(blank=True, default=dict),
26 | ),
27 | migrations.AlterField(
28 | model_name="comment",
29 | name="spam_status",
30 | field=models.CharField(
31 | choices=[
32 | ("normal", "Not suspected"),
33 | ("approved", "Approved"),
34 | ("suspected", "Suspected"),
35 | ("spam", "SPAM"),
36 | ],
37 | max_length=16,
38 | ),
39 | ),
40 | migrations.AlterField(
41 | model_name="entry",
42 | name="created",
43 | field=models.DateTimeField(default=datetime.datetime.utcnow),
44 | ),
45 | migrations.AlterField(
46 | model_name="entry",
47 | name="metadata",
48 | field=models.JSONField(blank=True, default=dict),
49 | ),
50 | migrations.AlterField(
51 | model_name="entry",
52 | name="tweet_html",
53 | field=models.TextField(
54 | blank=True,
55 | help_text="Paste in the embed tweet HTML, minus the script tag,\n to display a tweet in the sidebar next to this entry.",
56 | null=True,
57 | ),
58 | ),
59 | migrations.AlterField(
60 | model_name="quotation",
61 | name="created",
62 | field=models.DateTimeField(default=datetime.datetime.utcnow),
63 | ),
64 | migrations.AlterField(
65 | model_name="quotation",
66 | name="metadata",
67 | field=models.JSONField(blank=True, default=dict),
68 | ),
69 | ]
70 |
--------------------------------------------------------------------------------
/blog/migrations/0002_auto_20150713_0551.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | from django.db import models, migrations
5 |
6 |
7 | class Migration(migrations.Migration):
8 | dependencies = [
9 | ("contenttypes", "0002_remove_content_type_name"),
10 | ("blog", "0001_initial"),
11 | ]
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name="Comment",
16 | fields=[
17 | (
18 | "id",
19 | models.AutoField(
20 | verbose_name="ID",
21 | serialize=False,
22 | auto_created=True,
23 | primary_key=True,
24 | ),
25 | ),
26 | ("object_id", models.PositiveIntegerField(db_index=True)),
27 | ("body", models.TextField()),
28 | ("created", models.DateTimeField()),
29 | ("name", models.CharField(max_length=50)),
30 | ("url", models.URLField(max_length=255, null=True, blank=True)),
31 | ("email", models.CharField(max_length=50, null=True, blank=True)),
32 | ("openid", models.CharField(max_length=255, null=True, blank=True)),
33 | ("ip", models.GenericIPAddressField()),
34 | (
35 | "spam_status",
36 | models.CharField(
37 | max_length=16,
38 | choices=[
39 | (b"normal", b"Not suspected"),
40 | (b"approved", b"Approved"),
41 | (b"suspected", b"Suspected"),
42 | (b"spam", b"SPAM"),
43 | ],
44 | ),
45 | ),
46 | ("visible_on_site", models.BooleanField(default=True, db_index=True)),
47 | ("spam_reason", models.TextField()),
48 | (
49 | "content_type",
50 | models.ForeignKey(
51 | to="contenttypes.ContentType", on_delete=models.CASCADE
52 | ),
53 | ),
54 | ],
55 | options={
56 | "ordering": ["-created"],
57 | "get_latest_by": "created",
58 | },
59 | ),
60 | migrations.AlterModelOptions(
61 | name="blogmark",
62 | options={"ordering": ("-created",)},
63 | ),
64 | migrations.AlterModelOptions(
65 | name="entry",
66 | options={"ordering": ("-created",)},
67 | ),
68 | migrations.AlterModelOptions(
69 | name="quotation",
70 | options={"ordering": ("-created",)},
71 | ),
72 | ]
73 |
--------------------------------------------------------------------------------
/blog/templatetags/blog_tags.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from django.utils.safestring import mark_safe
3 | from markdown import markdown
4 |
5 | register = template.Library()
6 |
7 |
8 | @register.inclusion_tag("includes/blog_mixed_list.html", takes_context=True)
9 | def blog_mixed_list(context, items):
10 | context.update({"items": items, "showdate": False})
11 | return context
12 |
13 |
14 | @register.inclusion_tag("includes/blog_mixed_list.html", takes_context=True)
15 | def blog_mixed_list_with_dates(
16 | context, items, year_headers=False, day_headers=False, day_links=False
17 | ):
18 | context.update(
19 | {
20 | "items": items,
21 | "showdate": not day_headers,
22 | "year_headers": year_headers,
23 | "day_headers": day_headers,
24 | "day_links": day_links,
25 | }
26 | )
27 | return context
28 |
29 |
30 | @register.inclusion_tag("includes/comments_list.html", takes_context=True)
31 | def comments_list(context, comments):
32 | context.update(
33 | {
34 | "comments": comments,
35 | "show_headers": False,
36 | }
37 | )
38 | return context
39 |
40 |
41 | @register.inclusion_tag("includes/comments_list.html", takes_context=True)
42 | def comments_list_with_headers(context, comments):
43 | context.update(
44 | {
45 | "comments": comments,
46 | "show_headers": True,
47 | }
48 | )
49 | return context
50 |
51 |
52 | @register.simple_tag(takes_context=True)
53 | def page_href(context, page):
54 | query_dict = context["request"].GET.copy()
55 | if page == 1 and "page" in query_dict:
56 | del query_dict["page"]
57 | query_dict["page"] = str(page)
58 | return "?" + query_dict.urlencode()
59 |
60 |
61 | @register.simple_tag(takes_context=True)
62 | def add_qsarg(context, name, value):
63 | query_dict = context["request"].GET.copy()
64 | if value not in query_dict.getlist(name):
65 | query_dict.appendlist(name, value)
66 | # And always remove ?page= - see
67 | # https://github.com/simonw/simonwillisonblog/issues/239
68 | if "page" in query_dict:
69 | query_dict.pop("page")
70 | return "?" + query_dict.urlencode()
71 |
72 |
73 | @register.simple_tag(takes_context=True)
74 | def remove_qsarg(context, name, value):
75 | query_dict = context["request"].GET.copy()
76 | query_dict.setlist(name, [v for v in query_dict.getlist(name) if v != value])
77 | return "?" + query_dict.urlencode()
78 |
79 |
80 | @register.simple_tag(takes_context=True)
81 | def replace_qsarg(context, name, value):
82 | query_dict = context["request"].GET.copy()
83 | query_dict[name] = value
84 | return "?" + query_dict.urlencode()
85 |
86 |
87 | @register.filter
88 | def markdownify(text):
89 | """
90 | Convert Markdown text to HTML.
91 | """
92 | return mark_safe(markdown(text))
93 |
--------------------------------------------------------------------------------
/blog/tag_views.py:
--------------------------------------------------------------------------------
1 | from .models import Tag
2 | from django.db.models import (
3 | Case,
4 | When,
5 | Value,
6 | IntegerField,
7 | F,
8 | Q,
9 | Subquery,
10 | OuterRef,
11 | Count,
12 | )
13 | from django.db.models.functions import Length
14 | from django.http import JsonResponse, HttpResponse
15 | import json
16 |
17 |
18 | def tags_autocomplete(request):
19 | query = request.GET.get("q", "")
20 | # Remove whitespace
21 | query = "".join(query.split())
22 | if query:
23 | entry_count = (
24 | Tag.objects.filter(id=OuterRef("pk"))
25 | .annotate(
26 | count=Count("entry", filter=Q(entry__is_draft=False), distinct=True)
27 | )
28 | .values("count")
29 | )
30 |
31 | # Subquery for counting blogmarks
32 | blogmark_count = (
33 | Tag.objects.filter(id=OuterRef("pk"))
34 | .annotate(
35 | count=Count(
36 | "blogmark", filter=Q(blogmark__is_draft=False), distinct=True
37 | )
38 | )
39 | .values("count")
40 | )
41 |
42 | # Subquery for counting quotations
43 | quotation_count = (
44 | Tag.objects.filter(id=OuterRef("pk"))
45 | .annotate(
46 | count=Count(
47 | "quotation", filter=Q(quotation__is_draft=False), distinct=True
48 | )
49 | )
50 | .values("count")
51 | )
52 | note_count = (
53 | Tag.objects.filter(id=OuterRef("pk"))
54 | .annotate(
55 | count=Count(
56 | "note", filter=Q(note__is_draft=False), distinct=True
57 | ) # <-- Use 'note' model name
58 | )
59 | .values("count")
60 | )
61 |
62 | tags = (
63 | Tag.objects.filter(tag__icontains=query)
64 | .annotate(
65 | total_entry=Subquery(entry_count),
66 | total_blogmark=Subquery(blogmark_count),
67 | total_quotation=Subquery(quotation_count),
68 | total_note=Subquery(note_count),
69 | is_exact_match=Case(
70 | When(tag__iexact=query, then=Value(1)),
71 | default=Value(0),
72 | output_field=IntegerField(),
73 | ),
74 | )
75 | .annotate(
76 | count=F("total_entry")
77 | + F("total_blogmark")
78 | + F("total_quotation")
79 | + F("total_note")
80 | )
81 | .order_by("-is_exact_match", "-count", Length("tag"))[:5]
82 | )
83 | else:
84 | tags = Tag.objects.none()
85 |
86 | if request.GET.get("debug"):
87 | return HttpResponse(
88 | ""
89 | + json.dumps(list(tags.values()), indent=4)
90 | + " "
91 | + str(tags.query)
92 | + ""
93 | )
94 |
95 | return JsonResponse({"tags": list(tags.values())})
96 |
--------------------------------------------------------------------------------
/static/lite-yt-embed.css:
--------------------------------------------------------------------------------
1 | lite-youtube {
2 | background-color: #000;
3 | position: relative;
4 | display: block;
5 | contain: content;
6 | background-position: center center;
7 | background-size: cover;
8 | cursor: pointer;
9 | max-width: 720px;
10 | }
11 |
12 | /* gradient */
13 | lite-youtube::before {
14 | content: attr(data-title);
15 | display: block;
16 | position: absolute;
17 | top: 0;
18 | /* Pixel-perfect port of YT's gradient PNG, using https://github.com/bluesmoon/pngtocss plus optimizations */
19 | background-image: linear-gradient(180deg, rgb(0 0 0 / 67%) 0%, rgb(0 0 0 / 54%) 14%, rgb(0 0 0 / 15%) 54%, rgb(0 0 0 / 5%) 72%, rgb(0 0 0 / 0%) 94%);
20 | height: 99px;
21 | width: 100%;
22 | font-family: "YouTube Noto",Roboto,Arial,Helvetica,sans-serif;
23 | color: hsl(0deg 0% 93.33%);
24 | text-shadow: 0 0 2px rgba(0,0,0,.5);
25 | font-size: 18px;
26 | padding: 25px 20px;
27 | overflow: hidden;
28 | white-space: nowrap;
29 | text-overflow: ellipsis;
30 | box-sizing: border-box;
31 | }
32 |
33 | lite-youtube:hover::before {
34 | color: white;
35 | }
36 |
37 | /* responsive iframe with a 16:9 aspect ratio
38 | thanks https://css-tricks.com/responsive-iframes/
39 | */
40 | lite-youtube::after {
41 | content: "";
42 | display: block;
43 | padding-bottom: calc(100% / (16 / 9));
44 | }
45 | lite-youtube > iframe {
46 | width: 100%;
47 | height: 100%;
48 | position: absolute;
49 | top: 0;
50 | left: 0;
51 | border: 0;
52 | }
53 |
54 | /* play button */
55 | lite-youtube > .lty-playbtn {
56 | display: block;
57 | /* Make the button element cover the whole area for a large hover/click target… */
58 | width: 100%;
59 | height: 100%;
60 | /* …but visually it's still the same size */
61 | background: no-repeat center/68px 48px;
62 | /* YT's actual play button svg */
63 | background-image: url('data:image/svg+xml;utf8, ');
64 | position: absolute;
65 | cursor: pointer;
66 | z-index: 1;
67 | filter: grayscale(100%);
68 | transition: filter .1s cubic-bezier(0, 0, 0.2, 1);
69 | border: 0;
70 | }
71 |
72 | lite-youtube:hover > .lty-playbtn,
73 | lite-youtube .lty-playbtn:focus {
74 | filter: none;
75 | }
76 |
77 | /* Post-click styles */
78 | lite-youtube.lyt-activated {
79 | cursor: unset;
80 | }
81 | lite-youtube.lyt-activated::before,
82 | lite-youtube.lyt-activated > .lty-playbtn {
83 | opacity: 0;
84 | pointer-events: none;
85 | }
86 |
87 | .lyt-visually-hidden {
88 | clip: rect(0 0 0 0);
89 | clip-path: inset(50%);
90 | height: 1px;
91 | overflow: hidden;
92 | position: absolute;
93 | white-space: nowrap;
94 | width: 1px;
95 | }
96 |
--------------------------------------------------------------------------------
/templates/archive_tag.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}{% load humanize %}
2 |
3 | {% block title %}Simon Willison on {{ tags|join:" and " }}{% endblock %}
4 |
5 | {% block extrahead %}
6 |
7 |
8 |
9 |
10 | {% endblock %}
11 |
12 | {% block primary %}
13 | {% if tags|length == 1 %}
14 |
17 | Atom feed for {{ tags.0 }}
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | {% endif %}
38 | {{ total|intcomma }} post{{ total|pluralize }} tagged “{{ tags|join:"” and “" }}”
39 |
40 | {% if tag.description %}
41 | {{ tag.description_rendered }}
42 | {% endif %}
43 |
44 | {% if total > 10 %}
45 |
46 |
47 |
48 | {% for tag in tags %}
49 |
50 | {% endfor %}
51 |
52 | {% endif %}
53 |
54 | {% load blog_tags %}
55 |
56 | {% blog_mixed_list_with_dates items year_headers=1 %}
57 |
58 | {% include "_pagination.html" %}
59 |
60 | {% endblock %}
61 |
62 | {% block secondary %}
63 |
75 | {% endblock %}
76 |
--------------------------------------------------------------------------------
/blog/management/commands/import_blog_json.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from datetime import timezone
3 | from blog.models import (
4 | Entry,
5 | Blogmark,
6 | Tag,
7 | Quotation,
8 | )
9 | import requests
10 | from dateutil import parser
11 | import json
12 |
13 |
14 | class Command(BaseCommand):
15 | help = """
16 | ./manage.py import_blog_json URL-or-path-to-JSON
17 | """
18 |
19 | def add_arguments(self, parser):
20 | parser.add_argument(
21 | "url_or_path_to_json",
22 | type=str,
23 | help="URL or path to JSON to import",
24 | )
25 | parser.add_argument(
26 | "--tag_with",
27 | action="store",
28 | dest="tag_with",
29 | default=False,
30 | help="Tag to apply to all imported items",
31 | )
32 |
33 | def handle(self, *args, **kwargs):
34 | url_or_path_to_json = kwargs["url_or_path_to_json"]
35 | tag_with = kwargs["tag_with"]
36 | tag_with_tag = None
37 | if tag_with:
38 | tag_with_tag = Tag.objects.get_or_create(tag=tag_with)[0]
39 |
40 | is_url = url_or_path_to_json.startswith(
41 | "http://"
42 | ) or url_or_path_to_json.startswith("https://")
43 |
44 | if is_url:
45 | items = requests.get(url_or_path_to_json).json()
46 | else:
47 | items = json.load(open(url_or_path_to_json))
48 |
49 | for item in items:
50 | created = parser.parse(item["datetime"]).replace(tzinfo=timezone.utc)
51 | was_created = False
52 | slug = item["slug"][:64].strip("-")
53 | if item["type"] == "entry":
54 | klass = Entry
55 | kwargs = dict(
56 | body=item["body"],
57 | title=item["title"],
58 | created=created,
59 | slug=slug,
60 | metadata=item,
61 | )
62 | elif item["type"] == "quotation":
63 | klass = Quotation
64 | kwargs = dict(
65 | quotation=item["quotation"],
66 | source=item["source"],
67 | source_url=item["source_url"],
68 | created=created,
69 | slug=slug,
70 | metadata=item,
71 | )
72 | elif item["type"] == "blogmark":
73 | klass = Blogmark
74 | kwargs = dict(
75 | slug=slug,
76 | link_url=item["link_url"],
77 | link_title=item["link_title"],
78 | via_url=item.get("via_url") or "",
79 | via_title=item.get("via_title") or "",
80 | commentary=item["commentary"] or "",
81 | created=created,
82 | metadata=item,
83 | )
84 | else:
85 | assert False, "type should be known, %s" % item["type"]
86 | if item.get("import_ref"):
87 | obj, was_created = klass.objects.update_or_create(
88 | import_ref=item["import_ref"], defaults=kwargs
89 | )
90 | else:
91 | obj = klass.objects.create(**kwargs)
92 | tags = [Tag.objects.get_or_create(tag=tag)[0] for tag in item["tags"]]
93 | if tag_with_tag:
94 | tags.append(tag_with_tag)
95 | obj.tags.set(tags)
96 | print(was_created, obj, obj.get_absolute_url())
97 |
--------------------------------------------------------------------------------
/templates/entry.html:
--------------------------------------------------------------------------------
1 | {% extends "item_base.html" %}
2 | {% load entry_tags %}
3 | {% block title %}{{ entry.title|typography }}{% endblock %}
4 |
5 | {% block extrahead %}
6 | {{ block.super }}
7 | {{ entry.extra_head_html|default:""|safe }}
8 |
9 | {% endblock %}
10 |
11 | {% block card_title %}{{ entry.title|typography }}{% endblock %}
12 | {% block card_description %}{{ entry.body|xhtml|remove_context_paragraph|typography|xhtml2html|striptags|truncatewords:30|force_escape }}{% endblock %}
13 |
14 | {% block item_content %}
15 |
16 |
{{ entry.title|typography }}
17 |
{{ entry.created|date:"jS F Y" }}
18 |
19 | {% include "_draft_warning.html" %}
20 |
21 | {{ entry.body|xhtml|resize_images_to_fit_width:"450"|typography|xhtml2html }}
22 |
23 | {% if updates %}
24 |
25 | {% include "entry_updates.html" %}
26 |
27 | {% endif %}
28 |
29 |
30 |
31 | {% endblock %}
32 |
33 | {% block recent_articles_header %}More recent articles{% endblock %}
34 |
35 | {% block secondary %}
36 |
63 |
64 | {% if entry.tweet_html %}{{ entry.tweet_html|safe }}{% endif %}
65 | {% endblock %}
66 |
67 | {% block footer %}
68 | {% if previously_hosted %}Previously hosted at {{ previously_hosted }}
{% endif %}
69 | {{ block.super }}
70 | {% if entry.tweet_html %}{% endif %}
71 | {% endblock %}
72 |
--------------------------------------------------------------------------------
/blog/templatetags/tag_cloud.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from django.utils.safestring import mark_safe
3 |
4 | register = template.Library()
5 |
6 | from blog.models import Tag
7 |
8 | # Classes for different levels
9 | CLASSES = (
10 | "--skip--", # We don't show the least popular tags
11 | "not-popular-at-all",
12 | "not-very-popular",
13 | "somewhat-popular",
14 | "somewhat-more-popular",
15 | "popular",
16 | "more-than-just-popular",
17 | "very-popular",
18 | "ultra-popular",
19 | )
20 |
21 |
22 | def make_css_rules(
23 | min_size=0.7, max_size=2.0, units="em", selector_prefix=".tag-cloud ."
24 | ):
25 | num_classes = len(CLASSES)
26 | diff_each_time = (max_size - min_size) / (num_classes - 1)
27 | for i, klass in enumerate(CLASSES):
28 | print(
29 | "%s%s { font-size: %.2f%s; }"
30 | % (selector_prefix, klass, min_size + (i * diff_each_time), units)
31 | )
32 |
33 |
34 | import math
35 |
36 |
37 | def log(f):
38 | try:
39 | return math.log(f)
40 | except OverflowError:
41 | return 0
42 |
43 |
44 | @register.inclusion_tag("includes/tag_cloud.html")
45 | def tag_cloud_for_tags(tags):
46 | """
47 | Renders a tag cloud of tags. Input should be a non-de-duped list of tag
48 | strings.
49 | """
50 | return _tag_cloud_helper(tags)
51 |
52 |
53 | def _tag_cloud_helper(tags):
54 | # Count them all up
55 | tag_counts = {}
56 | for tag in tags:
57 | try:
58 | tag_counts[tag] += 1
59 | except KeyError:
60 | tag_counts[tag] = 1
61 | min_count = min(tag_counts.values())
62 | max_count = max(tag_counts.values())
63 | tags = list(tag_counts.keys())
64 | tags.sort()
65 | html_tags = []
66 | intervals = 10.0
67 | log_max = log(max_count)
68 | log_min = log(min_count)
69 | diff = log_max - log_min
70 | if diff < 0.01:
71 | # Avoid divide-by-zero problems
72 | diff = 0.01
73 | for tag in tags:
74 | score = tag_counts[tag]
75 | index = int((len(CLASSES) - 1) * (log(score) - log_min) / diff)
76 | if CLASSES[index] == "--skip--":
77 | continue
78 | html_tags.append(
79 | mark_safe(
80 | '%s %s '
81 | % (tag, score, (score != 1 and "s" or ""), CLASSES[index], tag, score)
82 | )
83 | )
84 | return {"tags": html_tags}
85 |
86 |
87 | @register.inclusion_tag("includes/tag_cloud.html")
88 | def tag_cloud():
89 | # We do this with raw SQL for efficiency
90 | from django.db import connection
91 |
92 | # Get tags for entries, blogmarks, quotations
93 | cursor = connection.cursor()
94 | cursor.execute(
95 | "select tag from blog_entry_tags, blog_tag where blog_entry_tags.tag_id = blog_tag.id"
96 | )
97 | entry_tags = [row[0] for row in cursor.fetchall()]
98 | cursor.execute(
99 | "select tag from blog_blogmark_tags, blog_tag where blog_blogmark_tags.tag_id = blog_tag.id"
100 | )
101 | blogmark_tags = [row[0] for row in cursor.fetchall()]
102 | cursor.execute(
103 | "select tag from blog_quotation_tags, blog_tag where blog_quotation_tags.tag_id = blog_tag.id"
104 | )
105 | quotation_tags = [row[0] for row in cursor.fetchall()]
106 | cursor.execute(
107 | "select tag from blog_note_tags, blog_tag where blog_note_tags.tag_id = blog_tag.id"
108 | )
109 | note_tags = [row[0] for row in cursor.fetchall()]
110 | cursor.close()
111 | # Add them together
112 | tags = entry_tags + blogmark_tags + quotation_tags + note_tags
113 | return _tag_cloud_helper(tags)
114 |
--------------------------------------------------------------------------------
/templates/includes/blog_mixed_list.html:
--------------------------------------------------------------------------------
1 | {% load entry_tags %}{% load humanize %}
2 | {% for item in items %}
3 | {% if year_headers %}{% ifchanged item.obj.created.year %}{{ item.obj.created.year }} {% endifchanged %}{% endif %}
4 | {% if day_headers %}{% ifchanged item.obj.created.date %}{% if day_links %}{{ item.obj.created.date }} {% else %}{{ item.obj.created.date }}{% endif %} {% endifchanged %}{% endif %}
5 | {% if item.type == "photoset" %}
6 |
7 |
8 |
9 | {% if item.obj.description %}
{{ item.obj.description }}
{% endif %}
10 |
{{ item.obj.primary.created|date:"f A"|lower }} / {{ item.obj.photos.count }} photo{{ item.obj.photos.count|pluralize }}{% if item.obj.has_map %} / map {% endif %}
11 |
12 |
13 | {% endif %}
14 | {% if item.type == "entry" %}
15 |
16 |
17 | {% if item.obj.card_image %}
18 |
19 |
20 |
21 | {% endif %}
22 |
23 | {{ item.obj.body|split_cutoff|xhtml|remove_context_paragraph|first_paragraph|typography|xhtml2html }}
24 | {% if item.obj.multi_paragraph %}[... {{ item.obj.body|wordcount|intcomma }} word{{ item.obj.body|wordcount|pluralize }} ] {% endif %}
25 |
26 |
29 |
30 | {% endif %}
31 | {% with item.obj.tags.all as all_tags %}
32 | {% partialdef date-and-tags %}
33 |
34 | #
35 | {% if showdate %}{{ item.obj.created|date:"jS F Y" }} ,
36 | {% endif %}{{ item.obj.created|date:"f A"|lower }}
37 | {% if all_tags %} / {% for tag in all_tags %}{{ tag.get_link }}{% if not forloop.last %}, {% endif %}{% endfor %}{% endif %}
38 |
39 | {% endpartialdef date-and-tags %}
40 | {% if item.type == "blogmark" %}
41 |
42 |
{{ item.obj.link_title|typography }} {% if item.obj.via_url %}
43 | (via ){% endif %}{% if not item.obj.via_url and not item.obj.link_title|ends_with_punctuation %}.{% endif %}
44 | {% if not item.obj.use_markdown %}{{ item.obj.commentary|typography|linebreaks|strip_wrapping_p }}{% else %}{{ item.obj.body|strip_wrapping_p }}{% endif %}
45 | {% partial date-and-tags %}
46 |
47 | {% endif %}
48 | {% if item.type == "quotation" %}
49 |
50 |
{{ item.obj.body }}
51 |
— {% if item.obj.source_url %}{{ item.obj.source }} {% else %}{{ item.obj.source }}{% endif %}{% if item.obj.context %}, {{ item.obj.context_rendered }}{% endif %}
52 | {% partial date-and-tags %}
53 |
54 | {% endif %}
55 | {% if item.type == "note" %}
56 |
57 | {{ item.obj.body_rendered }}
58 | {% partial date-and-tags %}
59 |
60 | {% endif %}
61 | {% endwith %}
62 | {% endfor %}
63 |
--------------------------------------------------------------------------------
/templates/item_base.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}
2 |
3 | {% block extrahead %}
4 | {% if item.card_image %}
5 | {% else %} {% endif %}
6 |
7 |
8 | {% if item.card_image %}
9 | {% endif %}
10 |
11 |
12 |
13 | {% if item.is_draft %}
14 |
15 | {% endif %}
16 | {% endblock %}
17 |
18 | {% block primary %}
19 |
20 | {% if show_comment_warning %}
21 |
22 | {% endif %}
23 | {% block item_content %}
24 | {% endblock %}
25 |
26 | {% if recent_articles %}
27 |
28 |
{% block recent_articles_header %}Recent articles{% endblock %}
29 |
30 | {% for article in recent_articles %}{% if article != item %}
31 | {{ article }} - {{ article.created|date:"jS F Y" }}
32 | {% endif %}{% endfor %}
33 |
34 |
35 | {% endif %}
36 | {% endblock %}
37 |
38 | {% block thirdsection %}
39 |
40 | {% if comments %}
41 | {{ comments|length }} comment{{ comments|length|pluralize }}
42 |
43 |
87 | {% endif %}
88 | {% endblock %}
89 |
--------------------------------------------------------------------------------
/templates/archive_year.html:
--------------------------------------------------------------------------------
1 | {% extends "smallhead.html" %}
2 |
3 | {% block title %}Archive for {{ year }}{% endblock %}
4 |
5 | {% block primary %}
6 | Archive for {{ year }}
7 |
8 |
9 |
10 | {% for month in months %}
11 | {{ month.date|date:"F" }} - {% for count in month.counts_not_0 %}{{ count.1 }} {% if count.0 == "entry" %}{{ count.1|pluralize:"entry,entries" }}{% else %}{{ count.0 }}{{ count.1|pluralize }}{% endif %}{% if not forloop.last %}, {% endif %}{% endfor %}
12 | {% if month.entries %}
13 |
14 | {% for entry in month.entries %}
15 | {{ entry.created|date:"jS" }}: {{ entry }}
16 | {% endfor %}
17 |
18 | {% endif %}
19 |
20 | {% endfor %}
21 |
22 |
23 | {% endblock %}
24 |
25 | {% block secondary %}
26 |
29 | {% endblock %}
30 |
31 | {% block footer %}
32 | {{ block.super }}
33 |
36 |
39 |
105 | {% endblock %}
106 |
--------------------------------------------------------------------------------
/static/image-gallery.js:
--------------------------------------------------------------------------------
1 |
2 | class ImageGallery extends HTMLElement {
3 | constructor() {
4 | super();
5 | this.attachShadow({ mode: 'open' });
6 | }
7 |
8 | static get observedAttributes() {
9 | return ['width'];
10 | }
11 |
12 | attributeChangedCallback() {
13 | this.render();
14 | }
15 |
16 | connectedCallback() {
17 | this.render();
18 | }
19 |
20 | handleImageClick(e) {
21 | const img = e.target.closest('img');
22 | if (!img) return;
23 |
24 | const dialog = this.shadowRoot.querySelector('dialog');
25 | const modalImg = dialog.querySelector('.modal-img');
26 |
27 | modalImg.src = img.dataset.fullsize;
28 | modalImg.alt = img.alt;
29 |
30 | dialog.showModal();
31 | }
32 |
33 | render() {
34 | const cols = this.getAttribute('width') || 3;
35 |
36 | this.shadowRoot.innerHTML = `
37 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | ×
116 |
117 |
118 |
119 |
120 | `;
121 |
122 | this.setupImages();
123 |
124 | const dialog = this.shadowRoot.querySelector('dialog');
125 | dialog.addEventListener('click', (e) => {
126 | if (e.target === dialog) dialog.close();
127 | });
128 | }
129 |
130 | setupImages() {
131 | const slot = this.shadowRoot.querySelector('#gallery-slot');
132 | const images = slot.assignedElements();
133 |
134 | images.forEach(img => {
135 | if (img.tagName === 'IMG') {
136 | if (!img.dataset.fullsize) {
137 | img.dataset.fullsize = img.src;
138 | }
139 | if (img.dataset.thumb) {
140 | img.src = img.dataset.thumb;
141 | }
142 | img.onclick = (e) => this.handleImageClick(e);
143 | }
144 | });
145 | }
146 | }
147 |
148 | customElements.define('image-gallery', ImageGallery);
--------------------------------------------------------------------------------
/blog/management/commands/import_quora.py:
--------------------------------------------------------------------------------
1 | from django.core.management.base import BaseCommand
2 | from datetime import timezone
3 | from django.db import transaction
4 | from blog.models import (
5 | Entry,
6 | Tag,
7 | )
8 | from BeautifulSoup import BeautifulSoup as Soup
9 | import requests
10 | from django.utils.html import escape
11 | from django.utils.text import slugify
12 | from dateutil import parser
13 | import datetime
14 | import random
15 |
16 |
17 | class Command(BaseCommand):
18 | help = "./manage.py import_quora http://URL-to-JSON.json http://URL-to-topic-CSV"
19 |
20 | def add_arguments(self, parser):
21 | parser.add_argument("json_url", type=str)
22 | parser.add_argument("topic_csv_url", type=str)
23 |
24 | def handle(self, *args, **kwargs):
25 | data_url = kwargs["json_url"]
26 | topic_csv_url = kwargs["topic_csv_url"]
27 | lines = requests.get(topic_csv_url).content.split("\n")
28 | quora_to_tag = {
29 | line.split("\t")[0]: line.split("\t")[-1].strip()
30 | for line in lines
31 | if line.strip()
32 | }
33 | posts = requests.get(data_url).json()
34 | with transaction.atomic():
35 | quora = Tag.objects.get_or_create(tag="quora")[0]
36 | for post in posts:
37 | question = post["originalQuestion"] or post["question"]
38 | url = "https://www.quora.com" + (
39 | post["originalQuestionUrl"] or post["href"]
40 | )
41 | if question.endswith("Remove Banner"):
42 | question = question.replace("Remove Banner", "")
43 | answer = clean_answer(post["answer"])
44 | date = datetime.datetime.combine(
45 | parser.parse(post["meta"].replace("Added ", "")).date(),
46 | datetime.time(random.randint(9, 18), random.randint(0, 59)),
47 | ).replace(tzinfo=timezone.utc)
48 | truncated_question = question
49 | if len(truncated_question) > 250:
50 | truncated_question = truncated_question[:250] + "..."
51 | body = 'My answer to %s on Quora
' % (
52 | url,
53 | escape(question),
54 | )
55 | body += "\n\n" + answer
56 | body = body.replace(" ", " ")
57 | slug = slugify(" ".join(truncated_question.split()[:4]))
58 | with transaction.atomic():
59 | entry = Entry.objects.create(
60 | slug=slug,
61 | created=date,
62 | title=truncated_question,
63 | body=body,
64 | metadata=post,
65 | )
66 | entry.tags.add(quora)
67 | for topic in post["topics"]:
68 | tag = quora_to_tag.get(topic)
69 | if tag:
70 | entry.tags.add(Tag.objects.get_or_create(tag=tag)[0])
71 | print(entry)
72 |
73 |
74 | def clean_answer(html):
75 | soup = Soup(html)
76 | # Ditch class attributes
77 | for tag in ("p", "span", "a", "code", "div"):
78 | for el in soup.findAll(tag):
79 | del el["class"]
80 | # On links, kill the rel and target and onclick and tooltip
81 | for el in soup.findAll("a"):
82 | del el["rel"]
83 | del el["target"]
84 | del el["onclick"]
85 | del el["data-qt-tooltip"]
86 |
87 | for el in soup.findAll("canvas"):
88 | el.extract()
89 |
90 | for img in soup.findAll("img"):
91 | del img["class"]
92 | del img["data-src"]
93 | src = img["master_src"]
94 | del img["master_src"]
95 | w = img["master_w"]
96 | del img["master_w"]
97 | h = img["master_h"]
98 | del img["master_h"]
99 | img["src"] = src
100 | img["width"] = w
101 | img["height"] = h
102 | img["style"] = "max-width: 100%"
103 |
104 | # Cleanup YouTube videos
105 | for div in soup.findAll("div", {"data-video-provider": "youtube"}):
106 | iframe = Soup(div["data-embed"]).find("iframe")
107 | src = "https:%s" % iframe["src"].split("?")[0]
108 | div.replaceWith(
109 | Soup(
110 | """
111 |
114 | """
115 | % src
116 | )
117 | )
118 |
119 | html = str(soup)
120 | html = html.replace(' with paragraphs
122 | chunks = html.split(" ")
123 | new_chunks = []
124 | for chunk in chunks:
125 | if not chunk.startswith("<"):
126 | chunk = "%s
" % chunk
127 | new_chunks.append(chunk)
128 | return "\n\n".join(new_chunks)
129 |
--------------------------------------------------------------------------------
/blog/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 |
3 |
4 | from django.db import models, migrations
5 |
6 |
7 | class Migration(migrations.Migration):
8 | dependencies = []
9 |
10 | operations = [
11 | migrations.CreateModel(
12 | name="Blogmark",
13 | fields=[
14 | (
15 | "id",
16 | models.AutoField(
17 | verbose_name="ID",
18 | serialize=False,
19 | auto_created=True,
20 | primary_key=True,
21 | ),
22 | ),
23 | ("slug", models.SlugField(max_length=64)),
24 | ("link_url", models.URLField()),
25 | ("link_title", models.CharField(max_length=255)),
26 | ("via_url", models.URLField(null=True, blank=True)),
27 | ("via_title", models.CharField(max_length=255, null=True, blank=True)),
28 | ("commentary", models.TextField()),
29 | ("created", models.DateTimeField()),
30 | ],
31 | ),
32 | migrations.CreateModel(
33 | name="Entry",
34 | fields=[
35 | (
36 | "id",
37 | models.AutoField(
38 | verbose_name="ID",
39 | serialize=False,
40 | auto_created=True,
41 | primary_key=True,
42 | ),
43 | ),
44 | ("title", models.CharField(max_length=255)),
45 | ("slug", models.SlugField(max_length=64)),
46 | ("body", models.TextField()),
47 | ("created", models.DateTimeField()),
48 | ],
49 | ),
50 | migrations.CreateModel(
51 | name="Photo",
52 | fields=[
53 | (
54 | "id",
55 | models.AutoField(
56 | verbose_name="ID",
57 | serialize=False,
58 | auto_created=True,
59 | primary_key=True,
60 | ),
61 | ),
62 | ("flickr_id", models.CharField(max_length=32)),
63 | ("server", models.CharField(max_length=8)),
64 | ("secret", models.CharField(max_length=32)),
65 | ("title", models.CharField(max_length=255, null=True, blank=True)),
66 | ("longitude", models.CharField(max_length=32, null=True, blank=True)),
67 | ("latitude", models.CharField(max_length=32, null=True, blank=True)),
68 | ("created", models.DateTimeField()),
69 | ],
70 | ),
71 | migrations.CreateModel(
72 | name="Photoset",
73 | fields=[
74 | (
75 | "id",
76 | models.AutoField(
77 | verbose_name="ID",
78 | serialize=False,
79 | auto_created=True,
80 | primary_key=True,
81 | ),
82 | ),
83 | ("flickr_id", models.CharField(max_length=32)),
84 | ("title", models.CharField(max_length=255, null=True, blank=True)),
85 | ("description", models.TextField()),
86 | (
87 | "photos",
88 | models.ManyToManyField(related_name="in_photoset", to="blog.Photo"),
89 | ),
90 | (
91 | "primary",
92 | models.ForeignKey(to="blog.Photo", on_delete=models.CASCADE),
93 | ),
94 | ],
95 | ),
96 | migrations.CreateModel(
97 | name="Quotation",
98 | fields=[
99 | (
100 | "id",
101 | models.AutoField(
102 | verbose_name="ID",
103 | serialize=False,
104 | auto_created=True,
105 | primary_key=True,
106 | ),
107 | ),
108 | ("slug", models.SlugField(max_length=64)),
109 | ("quotation", models.TextField()),
110 | ("source", models.CharField(max_length=255)),
111 | ("source_url", models.URLField(null=True, blank=True)),
112 | ("created", models.DateTimeField()),
113 | ],
114 | ),
115 | migrations.CreateModel(
116 | name="Tag",
117 | fields=[
118 | (
119 | "id",
120 | models.AutoField(
121 | verbose_name="ID",
122 | serialize=False,
123 | auto_created=True,
124 | primary_key=True,
125 | ),
126 | ),
127 | ("tag", models.SlugField(unique=True)),
128 | ],
129 | ),
130 | migrations.AddField(
131 | model_name="quotation",
132 | name="tags",
133 | field=models.ManyToManyField(to="blog.Tag", blank=True),
134 | ),
135 | migrations.AddField(
136 | model_name="entry",
137 | name="tags",
138 | field=models.ManyToManyField(to="blog.Tag", blank=True),
139 | ),
140 | migrations.AddField(
141 | model_name="blogmark",
142 | name="tags",
143 | field=models.ManyToManyField(to="blog.Tag", blank=True),
144 | ),
145 | ]
146 |
--------------------------------------------------------------------------------
/templates/search.html:
--------------------------------------------------------------------------------
1 | {% extends "item_base.html" %}{% load humanize %}
2 |
3 | {% block title %}{{ title }}{% endblock %}
4 |
5 | {% block rel_canonical %}{% endblock %}
6 |
7 | {% block item_content %}
8 | {% load blog_tags %}
9 | {{ title }}
10 |
11 |
12 |
13 |
14 | {% if selected %}
15 | {% for pair in selected.items %}
16 | {% if pair.0 == 'tags' %}
17 | {% for tag in pair.1 %}
18 |
19 | {% endfor %}
20 | {% else %}
21 |
22 | {% endif %}
23 | {% endfor %}
24 | {% endif %}
25 |
26 |
27 |
28 | {% if selected %}
29 | Filters:
30 | {% if selected.type %}
31 | Type: {{ selected.type }} ×
32 | {% endif %}
33 | {% if selected.year %}
34 | Year: {{ selected.year }} ×
35 | {% endif %}
36 | {% if selected.month %}
37 | Month: {{ selected.month_name }} ×
38 | {% endif %}
39 | {% for tag in selected.tags %}
40 | {{ tag }} ×
41 | {% endfor %}
42 | {% endif %}
43 | {% if results %}
44 | Sorted by {{ sort }} {% if q %} · {% endif %}
45 | {% if sort == "date" and q %}relevance {% endif %}
46 | {% if sort == "relevance" %}date {% endif %}
47 | {% endif %}
48 |
49 |
50 |
51 |
52 | {% if total %}
53 | {% if selected or q %}
54 | {% include "_pagination.html" with page_total=total %}
55 | {% blog_mixed_list_with_dates results %}
56 | {% include "_pagination.html" %}
57 | {% endif %}
58 | {% else %}
59 | {% if selected or q %}
60 | No results found
61 | {% if suggestion and num_corrected_results %}
62 | Suggestion: {{ suggestion }} ({{ num_corrected_results }} result{{ num_corrected_results|pluralize }})
63 | {% endif %}
64 | {% endif %}
65 | {% endif %}
66 |
67 | {% endblock %}
68 |
69 | {% block secondary %}
70 |
104 |
152 | {% endblock %}
153 |
--------------------------------------------------------------------------------
/blog/feeds.py:
--------------------------------------------------------------------------------
1 | from django.contrib.syndication.views import Feed
2 | from django.utils.dateformat import format as date_format
3 | from django.utils.feedgenerator import Atom1Feed
4 | from django.http import HttpResponse
5 | from blog.models import Entry, Blogmark, Quotation, Note
6 |
7 |
8 | class Base(Feed):
9 | feed_type = Atom1Feed
10 | link = "/"
11 | author_name = "Simon Willison"
12 |
13 | def __call__(self, request, *args, **kwargs):
14 | response = super(Base, self).__call__(request, *args, **kwargs)
15 | # Open CORS headers
16 | response["Access-Control-Allow-Origin"] = "*"
17 | response["Access-Control-Allow-Methods"] = "GET, OPTIONS"
18 | response["Access-Control-Max-Age"] = "1000"
19 | # Tell CloudFlare to cache my feeds
20 | cache_minutes = 10
21 | response["Cache-Control"] = "s-maxage=%d" % (cache_minutes * 60)
22 | return response
23 |
24 | def item_link(self, item):
25 | return (
26 | "https://simonwillison.net"
27 | + item.get_absolute_url()
28 | + "#atom-%s" % self.ga_source
29 | )
30 |
31 | def item_categories(self, item):
32 | return [t.tag for t in item.tags.all()]
33 |
34 | def item_pubdate(self, item):
35 | return item.created
36 |
37 | def item_updateddate(self, item):
38 | return item.created
39 |
40 | def get_feed(self, obj, request):
41 | feedgen = super().get_feed(obj, request)
42 | feedgen.content_type = "application/xml; charset=utf-8"
43 | return feedgen
44 |
45 |
46 | class Entries(Base):
47 | title = "Simon Willison's Weblog: Entries"
48 | ga_source = "entries"
49 |
50 | def items(self):
51 | return (
52 | Entry.objects.filter(is_draft=False)
53 | .prefetch_related("tags")
54 | .order_by("-created")[:15]
55 | )
56 |
57 | def item_title(self, item):
58 | return item.title
59 |
60 | def item_description(self, item):
61 | note = (
62 | "You are only seeing the long-form articles from my blog. "
63 | 'Subscribe to /atom/everything/ '
64 | 'to get all of my posts, or take a look at my other subscription options .
'
65 | )
66 | return item.body + note
67 |
68 |
69 | class Blogmarks(Base):
70 | title = "Simon Willison's Weblog: Blogmarks"
71 | description_template = "feeds/blogmark.html"
72 | ga_source = "blogmarks"
73 |
74 | def items(self):
75 | return (
76 | Blogmark.objects.filter(is_draft=False)
77 | .prefetch_related("tags")
78 | .order_by("-created")[:15]
79 | )
80 |
81 | def item_title(self, item):
82 | return item.title or item.link_title
83 |
84 |
85 | class Everything(Base):
86 | title = "Simon Willison's Weblog"
87 | description_template = "feeds/everything.html"
88 | ga_source = "everything"
89 |
90 | def items(self):
91 | # Pretty dumb implementation: pull top 30 of entries/blogmarks/quotations
92 | # then sort them together and return most recent 30 combined
93 | last_30_entries = list(
94 | Entry.objects.filter(is_draft=False)
95 | .prefetch_related("tags")
96 | .order_by("-created")[:30]
97 | )
98 | last_30_blogmarks = list(
99 | Blogmark.objects.filter(is_draft=False)
100 | .prefetch_related("tags")
101 | .order_by("-created")[:30]
102 | )
103 | last_30_quotations = list(
104 | Quotation.objects.filter(is_draft=False)
105 | .prefetch_related("tags")
106 | .order_by("-created")[:30]
107 | )
108 | last_30_notes = list(
109 | Note.objects.filter(is_draft=False)
110 | .prefetch_related("tags")
111 | .order_by("-created")[:30]
112 | )
113 | combined = (
114 | last_30_blogmarks + last_30_entries + last_30_quotations + last_30_notes
115 | )
116 | combined.sort(key=lambda e: e.created, reverse=True)
117 | return combined[:30]
118 |
119 | def item_title(self, item):
120 | if isinstance(item, Entry):
121 | return item.title
122 | elif isinstance(item, Blogmark):
123 | return item.title or item.link_title
124 | elif isinstance(item, Quotation):
125 | return "Quoting %s" % item.source
126 | elif isinstance(item, Note):
127 | if item.title:
128 | return item.title
129 | else:
130 | return "Note on {}".format(date_format(item.created, "jS F Y"))
131 | else:
132 | return "Unknown item type"
133 |
134 |
135 | class SeriesFeed(Everything):
136 | ga_source = "series"
137 |
138 | def __init__(self, series):
139 | self.title = "Simon Willison's Weblog: {}".format(series.title)
140 | self.series = series
141 |
142 | def items(self):
143 | return list(self.series.entry_set.all().order_by("-created"))
144 |
145 |
146 | class EverythingTagged(Everything):
147 | ga_source = "tag"
148 |
149 | def __init__(self, title, items):
150 | self.title = "Simon Willison's Weblog: {}".format(title)
151 | self._items = items
152 |
153 | def items(self):
154 | return self._items
155 |
156 |
157 | def sitemap(request):
158 | xml = [
159 | ''
160 | ''
161 | ]
162 | for klass in (Entry, Blogmark, Quotation, Note):
163 | for obj in klass.objects.exclude(is_draft=True).only("slug", "created"):
164 | xml.append(
165 | "https://simonwillison.net%s "
166 | % obj.get_absolute_url()
167 | )
168 | xml.append(" ")
169 | return HttpResponse("\n".join(xml), content_type="application/xml")
170 |
--------------------------------------------------------------------------------
/templates/about.html:
--------------------------------------------------------------------------------
1 | {% extends "item_base.html" %}
2 |
3 | {% block title %}About Simon Willison{% endblock %}
4 |
5 | {% block rel_canonical %}{% endblock %}
6 |
7 | {% block item_content %}{% load blog_tags %}
8 |
9 |
About me
10 |
11 |
Here's my most recent conference bio:
12 |
13 |
14 | Simon Willison is the creator of Datasette , an open source tool for exploring and publishing data. He currently works full-time building open source tools for data journalism, built around Datasette and SQLite.
15 |
16 | Prior to becoming an independent open source developer, Simon was an engineering director at Eventbrite. Simon joined Eventbrite through their acquisition of Lanyrd, a Y Combinator funded company he co-founded in 2010.
17 |
18 | He is a co-creator of the Django Web Framework, and has been blogging about web development and programming since 2002 at simonwillison.net
19 |
20 |
21 |
Subscribe
22 |
23 |
You can subscribe to my blog via email newsletter, using Atom feeds or by following me on Mastodon or Bluesky or Twitter.
24 |
25 |
Free weekly-ish newsletter
26 |
27 |
I send out a newsletter version of this blog once every week or so. You can subscribe to that here:
28 |
29 |
30 |
31 |
Paid monthly newsletter
32 |
33 |
I write a lot of stuff. If you like you can pay me to send you less . At the end of every month I send out a much shorter newsletter to anyone who sponsors me for $10 or more on GitHub . It's intended to be a ten minute read that catches you up on the most important developments from the past month in LLMs and my other projects and research.
34 |
35 |
Mastodon and Bluesky and Twitter
36 |
37 |
42 |
43 |
Atom feeds
44 |
45 |
The main feed for my site combines my blog entries, my blogmarks and my collected quotations:
46 |
47 |
https://simonwillison.net/atom/everything/
48 |
49 |
If you just want my longer form blog entries you can subscribe to this feed instead:
50 |
51 |
https://simonwillison.net/atom/entries/
52 |
53 |
Or this feed for just my links (excluding quotes):
54 |
55 |
https://simonwillison.net/atom/links/
56 |
57 |
Every tag on my blog has its own feed. You can subscribe to those by adding .atom to the URL to the tag page.
58 |
59 |
For example, to subscribe to just my content about Datasette, use the following:
60 |
61 |
https://simonwillison.net/tags/datasette.atom
62 |
63 |
Disclosures
64 |
65 | {% filter markdownify %}
66 | I do not receive any compensation for writing about specific topics on this blog - no sponsored content! I plan to continue this policy. If I ever change this I will disclose that both here and in the post itself.
67 |
68 | Part of work on [Datasette Cloud](https://www.datasette.cloud/) is sponsored by [Fly.io](https://fly.io/). I have a generous set of [GitHub Sponsors](https://github.com/sponsors/simonw) who support my work on Datasette and other open source projects.
69 |
70 | I am currently a member of the board of directors [for the Python Software Foundation](https://www.python.org/psf/board/).
71 |
72 | I am a [GitHub Star](https://stars.github.com/profiles/simonw/) (an unpaid position). GitHub paid me as a participant of their [GitHub Accelerator](https://github.blog/news-insights/company-news/github-accelerator-our-first-cohort-and-whats-next/) program in 2023.
73 |
74 | Mozilla supported my work as part of [their MIECO program](https://web.archive.org/web/20240917063820/https://future.mozilla.org/mieco2023/) (Internet Ecosystem program) in 2023-2024.
75 |
76 | I have not accepted payments from LLM vendors, but I am frequently invited to preview new LLM products and features from organizations that include OpenAI, Anthropic, Gemini and Mistral, often under NDA or subject to an embargo. This often also includes free API credits and invitations to events.
77 |
78 | *One exception: OpenAI paid my for my time when I attended a GPT-5 preview at their office which was [used in a video](https://simonwillison.net/2025/Aug/7/previewing-gpt-5/). They did not ask for any editorial insight or control over what I wrote after that event, aside from keeping to their embargo.*
79 |
80 | I run ads on my blog using [EthicalAds](https://www.ethicalads.io/). I also earn money from Twitter's [Creator Revenue Sharing](https://help.x.com/en/using-x/creator-revenue-sharing) program, and try not to let that incentivize me to post engagement bait!
81 |
82 | I provide ad-hoc consulting and training services to a number of different companies and organizations. If any of those represent a conflict of interest with my writing here I will disclose that in the relevant post.
83 |
84 | {% endfilter %}
85 |
86 |
About this site
87 |
88 |
This site is a custom web application built using Django . The source code can be found on GitHub at simonw/simonwillisonblog .
89 |
The site is hosted on Heroku and stores content in PostgreSQL, which is backed up to JSON files in simonw/simonwillisonblog-backup . These are then deployed to a Datasette instance running at datasette.simonwillison.net .
90 |
91 | {% endblock %}
92 |
--------------------------------------------------------------------------------
/old-import-xml/django_content_type.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | 1
33 | message
34 | auth
35 | message
36 |
37 |
38 |
39 | 2
40 | group
41 | auth
42 | group
43 |
44 |
45 |
46 | 3
47 | user
48 | auth
49 | user
50 |
51 |
52 |
53 | 4
54 | permission
55 | auth
56 | permission
57 |
58 |
59 |
60 | 5
61 | content type
62 | contenttypes
63 | contenttype
64 |
65 |
66 |
67 | 6
68 | session
69 | sessions
70 | session
71 |
72 |
73 |
74 | 7
75 | site
76 | sites
77 | site
78 |
79 |
80 |
81 | 8
82 | log entry
83 | admin
84 | logentry
85 |
86 |
87 |
88 | 9
89 | topic
90 | speaking
91 | topic
92 |
93 |
94 |
95 | 10
96 | material
97 | speaking
98 | material
99 |
100 |
101 |
102 | 11
103 | event
104 | speaking
105 | event
106 |
107 |
108 |
109 | 12
110 | talk
111 | speaking
112 | talk
113 |
114 |
115 |
116 | 13
117 | comment
118 | blog
119 | comment
120 |
121 |
122 |
123 | 14
124 | previous revision
125 | blog
126 | previousrevision
127 |
128 |
129 |
130 | 15
131 | photo
132 | blog
133 | photo
134 |
135 |
136 |
137 | 16
138 | blacklisted domain
139 | blog
140 | blacklisteddomain
141 |
142 |
143 |
144 | 17
145 | blogmark
146 | blog
147 | blogmark
148 |
149 |
150 |
151 | 18
152 | tag
153 | blog
154 | tag
155 |
156 |
157 |
158 | 19
159 | entry
160 | blog
161 | entry
162 |
163 |
164 |
165 | 20
166 | photoset
167 | blog
168 | photoset
169 |
170 |
171 |
172 | 21
173 | quotation
174 | blog
175 | quotation
176 |
177 |
178 |
179 | 22
180 | nonce
181 | openidconsumer
182 | nonce
183 |
184 |
185 |
186 | 23
187 | association
188 | openidconsumer
189 | association
190 |
191 |
192 |
193 | 24
194 | watch
195 | blog
196 | watch
197 |
198 |
199 |
200 | 25
201 | whitelist
202 | blog
203 | whitelist
204 |
205 |
206 |
207 | 26
208 | url
209 | shorter
210 | url
211 |
212 |
213 |
214 |
215 |
216 |
217 |
--------------------------------------------------------------------------------
/blog/templatetags/blog_calendar.py:
--------------------------------------------------------------------------------
1 | from django import template
2 |
3 | register = template.Library()
4 |
5 | from blog.models import Entry, Photo, Quotation, Blogmark, Photoset, Note
6 | import datetime, copy
7 |
8 | # This code used to use the following:
9 | # import calendar
10 | # calendar.Calendar().itermonthdates(date.year, date.month)
11 | # But... that functionality of the calendar module is only available in
12 | # 2.5, and relies on the with statement. D'oh!
13 |
14 | FIRSTWEEKDAY = 0 # Monday
15 |
16 |
17 | def itermonthdates(year, month):
18 | "Modelled after 2.5's calendar...itermonthdates"
19 | date = datetime.date(year, month, 1)
20 | # Go back to the beginning of the week
21 | days = (date.weekday() - FIRSTWEEKDAY) % 7
22 | date -= datetime.timedelta(days=days)
23 | oneday = datetime.timedelta(days=1)
24 | while True:
25 | yield date
26 | date += oneday
27 | if date.month != month and date.weekday() == FIRSTWEEKDAY:
28 | break
29 |
30 |
31 | def get_next_month(date):
32 | "I can't believe this isn't in the standard library!"
33 | if date.month == 12:
34 | return datetime.date(date.year + 1, 1, 1)
35 | else:
36 | return datetime.date(date.year, date.month + 1, 1)
37 |
38 |
39 | def get_previous_month(date):
40 | if date.month == 1:
41 | return datetime.date(date.year - 1, 12, 1)
42 | else:
43 | return datetime.date(date.year, date.month - 1, 1)
44 |
45 |
46 | @register.inclusion_tag("includes/calendar.html")
47 | def render_calendar(date):
48 | return calendar_context(date)
49 |
50 |
51 | @register.inclusion_tag("includes/calendar.html")
52 | def render_calendar_month_only(date):
53 | ctxt = calendar_context(date)
54 | ctxt["month_only"] = True
55 | return ctxt
56 |
57 |
58 | MODELS_TO_CHECK = ( # Name, model, score
59 | ("links", Blogmark, 2, "created"),
60 | ("entries", Entry, 4, "created"),
61 | ("quotes", Quotation, 2, "created"),
62 | ("notes", Note, 2, "created"),
63 | ("photos", Photo, 1, "created"),
64 | ("photosets", Photoset, 2, "primary__created"),
65 | )
66 |
67 |
68 | def make_empty_day_dict(date):
69 | d = dict([(key, []) for key, _1, _2, _3 in MODELS_TO_CHECK])
70 | d.update({"day": date, "populated": False, "display": True})
71 | return d
72 |
73 |
74 | def attribute_lookup(obj, attr_string):
75 | "Attr string is something like 'primary__created"
76 | lookups = attr_string.split("__")
77 | for lookup in lookups:
78 | obj = getattr(obj, lookup)
79 | return obj
80 |
81 |
82 | def calendar_context(date):
83 | "Renders a summary calendar for the given month"
84 | day_things = dict(
85 | [(d, make_empty_day_dict(d)) for d in itermonthdates(date.year, date.month)]
86 | )
87 | # Flag all days NOT in year/month as display: False
88 | for day in list(day_things.keys()):
89 | if day.month != date.month:
90 | day_things[day]["display"] = False
91 | for name, model, score, created_lookup in MODELS_TO_CHECK:
92 | lookup_args = {
93 | created_lookup + "__month": date.month,
94 | created_lookup + "__year": date.year,
95 | }
96 | if model in (Blogmark, Entry, Quotation, Note):
97 | lookup_args["is_draft"] = False
98 | for item in model.objects.filter(**lookup_args):
99 | day = day_things[attribute_lookup(item, created_lookup).date()]
100 | day[name].append(item)
101 | day["populated"] = True
102 | # Now that we've gathered the data we can render the calendar
103 | days = list(day_things.values())
104 | days.sort(key=lambda x: x["day"])
105 | # But first, swoop through and add a description to every day
106 | for day in days:
107 | day["score"] = score_for_day(day)
108 | if day["populated"]:
109 | day["description"] = description_for_day(day)
110 | if day["day"] == date:
111 | day["is_this_day"] = True
112 | # Now swoop through again, applying a colour to every day based on score
113 | cg = ColourGradient(WHITE, PURPLE)
114 | max_score = max([d["score"] for d in days] + [0.001])
115 | for day in days:
116 | day["colour"] = cg.pick_css(float(day["score"]) / max_score)
117 | weeks = []
118 | while days:
119 | weeks.append(days[0:7])
120 | del days[0:7]
121 | # Find next and previous months
122 | # WARNING: This makes an assumption that I posted at least one thing every
123 | # month since I started.
124 | first_month = Entry.objects.all().order_by("created")[0].created.date()
125 | if get_next_month(first_month) <= date:
126 | previous_month = get_previous_month(date)
127 | else:
128 | previous_month = None
129 | if date < datetime.date.today().replace(day=1):
130 | next_month = get_next_month(date)
131 | else:
132 | next_month = None
133 | return {
134 | "next_month": next_month,
135 | "previous_month": previous_month,
136 | "date": date,
137 | "weeks": weeks,
138 | }
139 |
140 |
141 | PURPLE = (163, 143, 183)
142 | WHITE = (255, 255, 255)
143 |
144 |
145 | class ColourGradient(object):
146 | def __init__(self, min_col, max_col):
147 | self.min_col = min_col
148 | self.max_col = max_col
149 |
150 | def pick(self, f):
151 | f = float(f)
152 | assert 0.0 <= f <= 1.0, "argument must be between 0 and 1, inclusive"
153 |
154 | def calc(pair):
155 | return (pair[0] - pair[1]) * f + pair[1]
156 |
157 | return tuple(map(calc, list(zip(self.max_col, self.min_col))))
158 |
159 | def pick_css(self, f):
160 | "Returns e.g. rgb(0, 0, 0)"
161 | return "rgb(%s)" % ", ".join(map(str, list(map(int, self.pick(f)))))
162 |
163 |
164 | def description_for_day(day):
165 | bits = []
166 | for key in day:
167 | if isinstance(day[key], list) and len(day[key]) > 0:
168 | count = len(day[key])
169 | if count == 1:
170 | name = day[key][0]._meta.verbose_name
171 | else:
172 | name = day[key][0]._meta.verbose_name_plural
173 | bits.append("%d %s" % (count, name))
174 | return ", ".join(bits)
175 |
176 |
177 | def score_for_day(day):
178 | "1 point/photo, 2 points for blogmark/quote/photoset, 4 points for entry"
179 | score = 0
180 | for name, model, points, created_lookup in MODELS_TO_CHECK:
181 | score += points * len(day[name])
182 | return score
183 |
--------------------------------------------------------------------------------
/templates/base.html:
--------------------------------------------------------------------------------
1 | {% load static %}
2 |
3 |
4 |
5 |
6 | {% block rel_canonical %} {% endblock %}
7 | {% block title %}{% endblock %}
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | {% block extrahead %}{% endblock %}
16 |
24 |
25 |
26 | {% block body %}{% endblock %}
27 |
28 | {% block footer %}
29 |
30 |
31 | Colophon
32 | © {% for year in years_with_content %}
33 | {{ year|date:"Y" }} {% endfor %}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | {% endblock %}
47 |
67 |
88 |
104 |
154 |
155 |
156 |
--------------------------------------------------------------------------------
/blog/management/commands/import_blog_xml.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.core.management.base import BaseCommand
3 | from django.contrib.contenttypes.models import ContentType
4 | from blog.models import (
5 | Entry,
6 | Blogmark,
7 | Tag,
8 | Quotation,
9 | Comment,
10 | )
11 | from xml.etree import ElementTree as ET
12 | import os
13 | import sys
14 |
15 |
16 | def iter_rows(filepath):
17 | # Iterate over rows in a SequelPro XML dump
18 | et = ET.parse(open(filepath))
19 | for row in et.findall("database/table_data/row"):
20 | d = {}
21 | for field in row.findall("field"):
22 | d[field.attrib["name"]] = field.text
23 | yield d
24 |
25 |
26 | class Command(BaseCommand):
27 | help = """
28 | ./manage.py import_blog_xml
29 | """
30 |
31 | def add_arguments(self, parser):
32 | parser.add_argument(
33 | "--xmldir",
34 | action="store",
35 | dest="xmldir",
36 | default=os.path.join(settings.BASE_DIR, "old-import-xml"),
37 | help="Directory where the XML files live",
38 | )
39 |
40 | def handle(self, *args, **kwargs):
41 | xmldir = kwargs["xmldir"]
42 | import_tags(xmldir)
43 | import_entries(xmldir)
44 | import_blogmarks(xmldir)
45 | import_quotations(xmldir)
46 | import_comments(xmldir)
47 |
48 |
49 | def import_tags(xmldir):
50 | # First create tags
51 | for row in iter_rows(os.path.join(xmldir, "blog_tag.xml")):
52 | Tag.objects.get_or_create(tag=row["tag"])
53 |
54 |
55 | def import_entries(xmldir):
56 | # Now do entries
57 | for row in iter_rows(os.path.join(xmldir, "blog_entry.xml")):
58 | entry, created = Entry.objects.get_or_create(
59 | id=row["id"],
60 | defaults=dict(
61 | body=row["body"],
62 | created=row["created"],
63 | title=row["title"],
64 | slug=row["slug"],
65 | ),
66 | )
67 | print(entry, created)
68 |
69 | # Now associate entries with tags
70 | for row in iter_rows(os.path.join(xmldir, "blog_entry_tags.xml")):
71 | entry_id = row["entry_id"]
72 | tag = row["tag_id"] # actually a tag
73 | entry = Entry.objects.get(pk=entry_id)
74 | entry.tags.add(Tag.objects.get(tag=tag))
75 |
76 |
77 | def import_blogmarks(xmldir):
78 | # Next do blogmarks
79 | for row in iter_rows(os.path.join(xmldir, "blog_blogmark.xml")):
80 | blogmark, created = Blogmark.objects.get_or_create(
81 | id=row["id"],
82 | defaults=dict(
83 | slug=row["slug"],
84 | link_url=row["link_url"],
85 | link_title=row["link_title"],
86 | via_url=row["via_url"],
87 | via_title=row["via_title"],
88 | commentary=row["commentary"] or "",
89 | created=row["created"],
90 | ),
91 | )
92 | for row in iter_rows(os.path.join(xmldir, "blog_blogmark_tags.xml")):
93 | blogmark_id = row["blogmark_id"]
94 | tag = row["tag_id"] # actually a tag
95 | entry = Blogmark.objects.get(pk=blogmark_id)
96 | entry.tags.add(Tag.objects.get(tag=tag))
97 |
98 |
99 | def import_quotations(xmldir):
100 | # and now quotations
101 | for row in iter_rows(os.path.join(xmldir, "blog_quotation.xml")):
102 | quotation, created = Quotation.objects.get_or_create(
103 | id=row["id"],
104 | defaults=dict(
105 | slug=row["slug"],
106 | quotation=row["quotation"],
107 | source=row["source"],
108 | source_url=row["source_url"],
109 | created=row["created"],
110 | ),
111 | )
112 | for row in iter_rows(os.path.join(xmldir, "blog_quotation_tags.xml")):
113 | quotation_id = row["quotation_id"]
114 | tag = row["tag_id"] # actually a tag
115 | entry = Quotation.objects.get(pk=quotation_id)
116 | entry.tags.add(Tag.objects.get(tag=tag))
117 |
118 |
119 | def import_comments(xmldir):
120 | # Finally... comments!
121 | # First we need to know what the old content_type IDs
122 | # should map to
123 | content_types_by_id = {}
124 | for row in iter_rows(os.path.join(xmldir, "django_content_type.xml")):
125 | content_types_by_id[row["id"]] = row
126 |
127 | content_type_models_by_name = {}
128 | for ct in ContentType.objects.filter(app_label="blog"):
129 | content_type_models_by_name[ct.model] = ct
130 |
131 | i = 0
132 |
133 | for row in iter_rows(os.path.join(xmldir, "blog_comment.xml")):
134 | #
135 | #
136 | # 31819
137 | # 19
138 | # 1503
139 | # http://videos.pass.as/index.html
140 | # http://www.full-length-movies.biz/index.html
141 | # http://download-movies.fw.nu/index.html
142 | # http://movies.isthebe.st/index.html
143 | # 2005-10-11 21:29:24
144 | # xxx
145 | # http://movies.isthebe.st/index.html
146 | # -ana@ma-.com
147 | #
148 | # 80.82.59.156
149 | # spam
150 | # 0
151 | #
152 | #
153 | Comment.objects.get_or_create(
154 | id=row["id"],
155 | defaults=dict(
156 | content_type=content_type_models_by_name[
157 | content_types_by_id[row["content_type_id"]]["model"]
158 | ],
159 | object_id=row["object_id"],
160 | body=row["body"],
161 | created=row["created"] + "Z",
162 | name=row["name"] or "",
163 | url=row["url"],
164 | email=row["email"],
165 | openid=row["openid"],
166 | ip=(row["ip"] or "0.0.0.0")
167 | .replace("xx.xx.xx.xx", "0.0.0.0")
168 | .replace("xxx.xxx.xxx.xxx", "0.0.0.0")
169 | .replace("unknown", "0.0.0.0"),
170 | spam_status=row["spam_status"],
171 | visible_on_site=row["visible_on_site"],
172 | spam_reason=row["spam_reason"] or "",
173 | ),
174 | )
175 | i += 1
176 | if i % 100 == 0:
177 | print(i)
178 | sys.stdout.flush()
179 |
--------------------------------------------------------------------------------
/config/urls.py:
--------------------------------------------------------------------------------
1 | from django.urls import path, re_path, include
2 | from django.contrib import admin
3 | from django.http import (
4 | HttpResponseRedirect,
5 | HttpResponsePermanentRedirect,
6 | HttpResponse,
7 | )
8 | from django.views.decorators.cache import never_cache
9 | from django.conf import settings
10 | import django_sql_dashboard
11 | import djp
12 | from blog import views as blog_views
13 | from blog import search as search_views
14 | from blog import tag_views
15 | from blog import feeds
16 | from feedstats.utils import count_subscribers
17 | import os
18 | import importlib.metadata
19 | import json
20 | from proxy.views import proxy_view
21 |
22 |
23 | handler404 = "blog.views.custom_404"
24 |
25 |
26 | def wellknown_webfinger(request):
27 | remote_url = (
28 | "https://fedi.simonwillison.net/.well-known/webfinger?"
29 | + request.META["QUERY_STRING"]
30 | )
31 | return proxy_view(request, remote_url)
32 |
33 |
34 | def wellknown_hostmeta(request):
35 | remote_url = (
36 | "https://fedi.simonwillison.net/.well-known/host-meta?"
37 | + request.META["QUERY_STRING"]
38 | )
39 | return proxy_view(request, remote_url)
40 |
41 |
42 | def wellknown_nodeinfo(request):
43 | remote_url = "https://fedi.simonwillison.net/.well-known/nodeinfo"
44 | return proxy_view(request, remote_url)
45 |
46 |
47 | def username_redirect(request):
48 | return HttpResponseRedirect("https://fedi.simonwillison.net/@simon")
49 |
50 |
51 | def newsletter_redirect(request):
52 | return HttpResponseRedirect("https://simonw.substack.com/")
53 |
54 |
55 | def projects_redirect(request):
56 | return HttpResponseRedirect(
57 | "https://github.com/simonw/simonw/blob/main/releases.md"
58 | )
59 |
60 |
61 | FAVICON = open(os.path.join(settings.BASE_DIR, "static/favicon.ico"), "rb").read()
62 |
63 |
64 | def static_redirect(request):
65 | return HttpResponsePermanentRedirect(
66 | "http://static.simonwillison.net%s" % request.get_full_path()
67 | )
68 |
69 |
70 | def tag_redirect(request, tag):
71 | return HttpResponsePermanentRedirect("/tags/{}/".format(tag))
72 |
73 |
74 | STAGING_ROBOTS_TXT = """
75 | User-agent: Twitterbot
76 | Disallow:
77 |
78 | User-agent: *
79 | Disallow: /
80 | """
81 |
82 | PRODUCTION_ROBOTS_TXT = """
83 | User-agent: ChatGPT-User
84 | Disallow:
85 |
86 | User-agent: *
87 | Disallow: /admin/
88 | Disallow: /search/
89 |
90 | Sitemap: https://simonwillison.net/sitemap.xml
91 | """
92 |
93 |
94 | def robots_txt(request):
95 | if settings.STAGING:
96 | txt = STAGING_ROBOTS_TXT
97 | else:
98 | txt = PRODUCTION_ROBOTS_TXT
99 | return HttpResponse(txt, content_type="text/plain")
100 |
101 |
102 | def favicon_ico(request):
103 | return HttpResponse(FAVICON, content_type="image/x-icon")
104 |
105 |
106 | @never_cache
107 | def versions(request):
108 | installed_packages = [
109 | (dist.metadata["Name"], dist.version)
110 | for dist in sorted(
111 | importlib.metadata.distributions(), key=lambda d: d.metadata["Name"].lower()
112 | )
113 | ]
114 | return HttpResponse(
115 | json.dumps(installed_packages, indent=4), content_type="text/plain"
116 | )
117 |
118 |
119 | urlpatterns = [
120 | path("monthly/", include("monthly.urls")),
121 | re_path(r"^card/(.*$)$", blog_views.screenshot_card),
122 | re_path(r"^$", blog_views.index),
123 | re_path(r"^(\d{4})/$", blog_views.archive_year),
124 | re_path(r"^(\d{4})/(\w{3})/$", blog_views.archive_month),
125 | re_path(r"^(\d{4})/(\w{3})/(\d{1,2})/$", blog_views.archive_day),
126 | re_path(r"^(\d{4})/(\w{3})/(\d{1,2})/([\-\w]+)/$", blog_views.archive_item),
127 | re_path(r"^updates/(\d+)/$", blog_views.entry_updates),
128 | re_path(r"^updates/(\d+)\.json$", blog_views.entry_updates_json),
129 | # Redirects for entries, blogmarks, quotations by ID
130 | re_path(r"^e/(\d+)/?$", blog_views.redirect_entry),
131 | re_path(r"^b/(\d+)/?$", blog_views.redirect_blogmark),
132 | re_path(r"^q/(\d+)/?$", blog_views.redirect_quotation),
133 | re_path(r"^n/(\d+)/?$", blog_views.redirect_note),
134 | # Ancient URL pattern still getting hits
135 | re_path(r"^/?archive/(\d{4})/(\d{2})/(\d{2})/$", blog_views.archive_day_redirect),
136 | re_path(
137 | r"^/?archive/(\d{4})/(\d{2})/(\d{2})/([\-\w]+)/?$",
138 | blog_views.archive_item_redirect,
139 | ),
140 | # Fediverse
141 | path(".well-known/webfinger", wellknown_webfinger),
142 | path(".well-known/host-meta", wellknown_hostmeta),
143 | path(".well-known/nodeinfo", wellknown_nodeinfo),
144 | path("@simon", username_redirect),
145 | re_path(r"^newsletter/?$", newsletter_redirect),
146 | re_path(r"^projects/?$", projects_redirect),
147 | re_path(r"^versions/$", versions),
148 | re_path(r"^robots\.txt$", robots_txt),
149 | re_path(r"^favicon\.ico$", favicon_ico),
150 | re_path(r"^search/$", search_views.search),
151 | re_path(r"^about/$", blog_views.about),
152 | path("top-tags/", blog_views.top_tags),
153 | re_path(r"^tags/$", blog_views.tag_index),
154 | re_path(r"^tags/(.*?)/$", blog_views.archive_tag),
155 | re_path(r"^tags/(.*?).atom$", blog_views.archive_tag_atom),
156 | re_path(r"^tag/([a-zA-Z0-9_-]+)/$", tag_redirect),
157 | re_path(r"^series/$", blog_views.series_index),
158 | re_path(r"^series/(.*?)/$", blog_views.archive_series),
159 | re_path(r"^series/(.*?).atom$", blog_views.archive_series_atom),
160 | re_path(r"^atom/entries/$", count_subscribers(feeds.Entries().__call__)),
161 | re_path(r"^atom/links/$", count_subscribers(feeds.Blogmarks().__call__)),
162 | re_path(r"^atom/everything/$", count_subscribers(feeds.Everything().__call__)),
163 | re_path(r"^sitemap\.xml$", feeds.sitemap),
164 | path("tools/", blog_views.tools),
165 | path("tools/extract-title/", blog_views.tools_extract_title),
166 | re_path(r"^tools/search-tags/$", search_views.tools_search_tags),
167 | re_path(r"^write/$", blog_views.write),
168 | # (r'^about/$', blog_views.about),
169 | path("admin/bulk-tag/", blog_views.bulk_tag, name="bulk_tag"),
170 | path("admin/merge-tags/", blog_views.merge_tags, name="merge_tags"),
171 | path("api/add-tag/", blog_views.api_add_tag, name="api_add_tag"),
172 | re_path(r"^admin/", admin.site.urls),
173 | re_path(r"^static/", static_redirect),
174 | path("dashboard/", include(django_sql_dashboard.urls)),
175 | path("user-from-cookies/", blog_views.user_from_cookies),
176 | path("tags-autocomplete/", tag_views.tags_autocomplete),
177 | ] + djp.urlpatterns()
178 | if settings.DEBUG:
179 | try:
180 | import debug_toolbar
181 |
182 | urlpatterns = [
183 | re_path(r"^__debug__/", include(debug_toolbar.urls))
184 | ] + urlpatterns
185 | except ImportError:
186 | pass
187 |
--------------------------------------------------------------------------------
/.github/workflows/combine-prs.yaml:
--------------------------------------------------------------------------------
1 | name: 'Combine PRs'
2 |
3 | # Controls when the action will run - in this case triggered manually
4 | on:
5 | workflow_dispatch:
6 | inputs:
7 | branchPrefix:
8 | description: 'Branch prefix to find combinable PRs based on'
9 | required: true
10 | default: 'dependabot'
11 | mustBeGreen:
12 | description: 'Only combine PRs that are green (status is success). Set to false if repo does not run checks'
13 | type: boolean
14 | required: true
15 | default: true
16 | combineBranchName:
17 | description: 'Name of the branch to combine PRs into'
18 | required: true
19 | default: 'combine-prs-branch'
20 | ignoreLabel:
21 | description: 'Exclude PRs with this label'
22 | required: true
23 | default: 'nocombine'
24 |
25 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel
26 | jobs:
27 | # This workflow contains a single job called "combine-prs"
28 | combine-prs:
29 | # The type of runner that the job will run on
30 | runs-on: ubuntu-latest
31 | permissions: write-all
32 |
33 | # Steps represent a sequence of tasks that will be executed as part of the job
34 | steps:
35 | - uses: actions/github-script@v6
36 | id: create-combined-pr
37 | name: Create Combined PR
38 | with:
39 | github-token: ${{secrets.GITHUB_TOKEN}}
40 | script: |
41 | const pulls = await github.paginate('GET /repos/:owner/:repo/pulls', {
42 | owner: context.repo.owner,
43 | repo: context.repo.repo
44 | });
45 | let branchesAndPRStrings = [];
46 | let baseBranch = null;
47 | let baseBranchSHA = null;
48 | for (const pull of pulls) {
49 | const branch = pull['head']['ref'];
50 | console.log('Pull for branch: ' + branch);
51 | if (branch.startsWith('${{ github.event.inputs.branchPrefix }}')) {
52 | console.log('Branch matched prefix: ' + branch);
53 | let statusOK = true;
54 | if(${{ github.event.inputs.mustBeGreen }}) {
55 | console.log('Checking green status: ' + branch);
56 | const stateQuery = `query($owner: String!, $repo: String!, $pull_number: Int!) {
57 | repository(owner: $owner, name: $repo) {
58 | pullRequest(number:$pull_number) {
59 | commits(last: 1) {
60 | nodes {
61 | commit {
62 | statusCheckRollup {
63 | state
64 | }
65 | }
66 | }
67 | }
68 | }
69 | }
70 | }`
71 | const vars = {
72 | owner: context.repo.owner,
73 | repo: context.repo.repo,
74 | pull_number: pull['number']
75 | };
76 | const result = await github.graphql(stateQuery, vars);
77 | const [{ commit }] = result.repository.pullRequest.commits.nodes;
78 | const state = commit.statusCheckRollup.state
79 | console.log('Validating status: ' + state);
80 | if(state != 'SUCCESS') {
81 | console.log('Discarding ' + branch + ' with status ' + state);
82 | statusOK = false;
83 | }
84 | }
85 | console.log('Checking labels: ' + branch);
86 | const labels = pull['labels'];
87 | for(const label of labels) {
88 | const labelName = label['name'];
89 | console.log('Checking label: ' + labelName);
90 | if(labelName == '${{ github.event.inputs.ignoreLabel }}') {
91 | console.log('Discarding ' + branch + ' with label ' + labelName);
92 | statusOK = false;
93 | }
94 | }
95 | if (statusOK) {
96 | console.log('Adding branch to array: ' + branch);
97 | const prString = '#' + pull['number'] + ' ' + pull['title'];
98 | branchesAndPRStrings.push({ branch, prString });
99 | baseBranch = pull['base']['ref'];
100 | baseBranchSHA = pull['base']['sha'];
101 | }
102 | }
103 | }
104 | if (branchesAndPRStrings.length == 0) {
105 | core.setFailed('No PRs/branches matched criteria');
106 | return;
107 | }
108 | try {
109 | await github.rest.git.createRef({
110 | owner: context.repo.owner,
111 | repo: context.repo.repo,
112 | ref: 'refs/heads/' + '${{ github.event.inputs.combineBranchName }}',
113 | sha: baseBranchSHA
114 | });
115 | } catch (error) {
116 | console.log(error);
117 | core.setFailed('Failed to create combined branch - maybe a branch by that name already exists?');
118 | return;
119 | }
120 |
121 | let combinedPRs = [];
122 | let mergeFailedPRs = [];
123 | for(const { branch, prString } of branchesAndPRStrings) {
124 | try {
125 | await github.rest.repos.merge({
126 | owner: context.repo.owner,
127 | repo: context.repo.repo,
128 | base: '${{ github.event.inputs.combineBranchName }}',
129 | head: branch,
130 | });
131 | console.log('Merged branch ' + branch);
132 | combinedPRs.push(prString);
133 | } catch (error) {
134 | console.log('Failed to merge branch ' + branch);
135 | mergeFailedPRs.push(prString);
136 | }
137 | }
138 |
139 | console.log('Creating combined PR');
140 | const combinedPRsString = combinedPRs.join('\n');
141 | let body = '✅ This PR was created by the Combine PRs action by combining the following PRs:\n' + combinedPRsString;
142 | if(mergeFailedPRs.length > 0) {
143 | const mergeFailedPRsString = mergeFailedPRs.join('\n');
144 | body += '\n\n⚠️ The following PRs were left out due to merge conflicts:\n' + mergeFailedPRsString
145 | }
146 | await github.rest.pulls.create({
147 | owner: context.repo.owner,
148 | repo: context.repo.repo,
149 | title: 'Combined PR',
150 | head: '${{ github.event.inputs.combineBranchName }}',
151 | base: baseBranch,
152 | body: body
153 | });
154 |
--------------------------------------------------------------------------------