├── config ├── __init__.py ├── wsgi.py ├── hosts.py ├── urls_2003.py ├── nginx.conf.erb └── urls.py ├── .env ├── feedstats ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0002_longer_user_agent_field.py │ ├── 0003_subscribercount_unique_subscriber_count.py │ └── 0001_initial.py ├── apps.py ├── admin.py ├── models.py ├── utils.py └── tests.py ├── monthly ├── __init__.py ├── migrations │ ├── __init__.py │ └── 0001_initial.py ├── apps.py ├── admin.py ├── urls.py ├── models.py ├── views.py └── tests.py ├── redirects ├── __init__.py ├── migrations │ ├── __init__.py │ ├── 0003_alter_redirect_unique_together_and_more.py │ ├── 0002_auto_20171001_2242.py │ └── 0001_initial.py ├── tests.py ├── views.py ├── apps.py ├── admin.py ├── models.py └── middleware.py ├── .python-version ├── blog ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ ├── reindex_all.py │ │ ├── validate_entries_xml.py │ │ ├── import_blog_json.py │ │ ├── import_quora.py │ │ └── import_blog_xml.py ├── migrations │ ├── __init__.py │ ├── 0015_enable_pg_trgm.py │ ├── 0020_tag_description.py │ ├── 0017_alter_series_options.py │ ├── 0003_auto_20170926_0641.py │ ├── 0025_entry_live_timezone.py │ ├── 0026_quotation_context.py │ ├── 0014_entry_custom_template.py │ ├── 0019_blogmark_use_markdown.py │ ├── 0028_note_title.py │ ├── 0029_blogmark_title.py │ ├── 0011_entry_extra_head_html.py │ ├── 0022_alter_blogmark_use_markdown.py │ ├── 0018_alter_blogmark_link_url_alter_blogmark_via_url.py │ ├── 0008_entry_tweet_html.py │ ├── 0009_import_ref.py │ ├── 0021_previous_tag_name.py │ ├── 0007_metadata_default.py │ ├── 0023_blogmark_is_draft_entry_is_draft_quotation_is_draft.py │ ├── 0012_card_image.py │ ├── 0004_metadata_json.py │ ├── 0005_search_document.py │ ├── 0006_gin_indexes.py │ ├── 0024_liveupdate.py │ ├── 0013_fix_simon_incutio_links.py │ ├── 0016_series.py │ ├── 0030_tagmerge.py │ ├── 0027_note.py │ ├── 0010_auto_20180918_1646.py │ ├── 0002_auto_20150713_0551.py │ └── 0001_initial.py ├── templatetags │ ├── __init__.py │ ├── blog_tags.py │ ├── tag_cloud.py │ └── blog_calendar.py ├── __init__.py ├── apps.py ├── context_processors.py ├── factories.py ├── middleware.py ├── signals.py ├── views_2003.py ├── tag_views.py └── feeds.py ├── templates ├── includes │ ├── tag_cloud.html │ ├── entry_footer.html │ ├── calendar.html │ └── blog_mixed_list.html ├── entry_updates.html ├── feeds │ ├── note.html │ ├── quotation.html │ ├── everything.html │ └── blogmark.html ├── _draft_warning.html ├── _tags.html ├── projects │ └── index.html ├── admin │ ├── base_site.html │ ├── blog │ │ └── entry │ │ │ └── change_form.html │ └── index.html ├── monthly_detail.html ├── top_tags.html ├── 404.html ├── tools.html ├── series_index.html ├── smallhead.html ├── monthly.html ├── archive_day.html ├── homepage.html ├── _sponsor_promo.html ├── wide.html ├── quotation.html ├── django_sql_dashboard │ └── base.html ├── _pagination.html ├── note.html ├── write.html ├── archive_month.html ├── archive_series.html ├── blogmark.html ├── tags.html ├── bighead.html ├── archive_tag.html ├── entry.html ├── item_base.html ├── archive_year.html ├── search.html ├── about.html └── base.html ├── .gitignore ├── favicon.ico ├── Procfile ├── static ├── favicon.ico ├── css │ └── img │ │ ├── arrow.png │ │ ├── set_case.png │ │ ├── stripe-492.gif │ │ ├── questionmark.png │ │ ├── comment-bubble.png │ │ ├── feed-icon-14x14.png │ │ ├── feed-icon-28x28.png │ │ ├── purple-gradient.png │ │ ├── comment-top-grey.gif │ │ ├── comment-bottom-grey.gif │ │ ├── comment-top-orange.gif │ │ ├── comment-top-purple.gif │ │ ├── comment-bottom-orange.gif │ │ └── comment-bottom-purple.gif ├── lite-yt-embed.css └── image-gallery.js ├── runserver.sh ├── .github ├── dependabot.yml └── workflows │ ├── cog.yml │ ├── ci.yml │ └── combine-prs.yaml ├── AGENTS.md ├── manage.py ├── .claude └── settings.json ├── requirements.txt ├── README.md ├── claude-run-tests.sh ├── CLAUDE.md └── old-import-xml └── django_content_type.xml /config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | WEB_CONCURRENCY=2 -------------------------------------------------------------------------------- /feedstats/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /monthly/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /redirects/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /blog/management/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /blog/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /blog/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /feedstats/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /monthly/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /redirects/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /blog/management/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /blog/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = "blog.apps.BlogConfig" 2 | -------------------------------------------------------------------------------- /templates/includes/tag_cloud.html: -------------------------------------------------------------------------------- 1 | {% for tag in tags %}{{ tag }} {% endfor %} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | *.pyc 3 | staticfiles 4 | old-import-xml/blog_comment.xml 5 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/favicon.ico -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | release: python manage.py migrate --noinput 2 | web: gunicorn config.wsgi --log-file - -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /static/css/img/arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/arrow.png -------------------------------------------------------------------------------- /static/css/img/set_case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/set_case.png -------------------------------------------------------------------------------- /static/css/img/stripe-492.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/stripe-492.gif -------------------------------------------------------------------------------- /static/css/img/questionmark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/questionmark.png -------------------------------------------------------------------------------- /redirects/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | from django.test import TestCase 5 | 6 | # Create your tests here. 7 | -------------------------------------------------------------------------------- /static/css/img/comment-bubble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/comment-bubble.png -------------------------------------------------------------------------------- /static/css/img/feed-icon-14x14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/feed-icon-14x14.png -------------------------------------------------------------------------------- /static/css/img/feed-icon-28x28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/feed-icon-28x28.png -------------------------------------------------------------------------------- /static/css/img/purple-gradient.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/purple-gradient.png -------------------------------------------------------------------------------- /feedstats/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class FeedstatsConfig(AppConfig): 5 | name = "feedstats" 6 | -------------------------------------------------------------------------------- /redirects/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | from django.shortcuts import render 5 | 6 | # Create your views here. 7 | -------------------------------------------------------------------------------- /static/css/img/comment-top-grey.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/comment-top-grey.gif -------------------------------------------------------------------------------- /static/css/img/comment-bottom-grey.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/comment-bottom-grey.gif -------------------------------------------------------------------------------- /static/css/img/comment-top-orange.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/comment-top-orange.gif -------------------------------------------------------------------------------- /static/css/img/comment-top-purple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/comment-top-purple.gif -------------------------------------------------------------------------------- /static/css/img/comment-bottom-orange.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/comment-bottom-orange.gif -------------------------------------------------------------------------------- /static/css/img/comment-bottom-purple.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simonw/simonwillisonblog/HEAD/static/css/img/comment-bottom-purple.gif -------------------------------------------------------------------------------- /runserver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | DJANGO_DEBUG=1 \ 3 | uv run --with-requirements requirements.txt \ 4 | python ./manage.py runserver 0.0.0.0:8033 5 | -------------------------------------------------------------------------------- /redirects/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | from django.apps import AppConfig 5 | 6 | 7 | class RedirectsConfig(AppConfig): 8 | name = "redirects" 9 | -------------------------------------------------------------------------------- /monthly/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MonthlyConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "monthly" 7 | -------------------------------------------------------------------------------- /redirects/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Redirect 3 | 4 | admin.site.register( 5 | Redirect, list_display=("domain", "path", "target"), list_filter=("domain",) 6 | ) 7 | -------------------------------------------------------------------------------- /config/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 4 | 5 | from django.core.wsgi import get_wsgi_application 6 | 7 | 8 | application = get_wsgi_application() 9 | -------------------------------------------------------------------------------- /templates/entry_updates.html: -------------------------------------------------------------------------------- 1 | {% for update in updates %} 2 |
3 |

{{ update.created_str }} {{ update.content|safe }}

4 |
5 | {% endfor %} 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "13:00" 8 | groups: 9 | python-packages: 10 | patterns: 11 | - "*" 12 | -------------------------------------------------------------------------------- /feedstats/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import SubscriberCount 3 | 4 | admin.site.register( 5 | SubscriberCount, 6 | list_display=("path", "user_agent", "count", "created"), 7 | list_filter=("path", "created"), 8 | ) 9 | -------------------------------------------------------------------------------- /config/hosts.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django_hosts import patterns, host 3 | 4 | host_patterns = patterns( 5 | "", 6 | host(r"www", settings.ROOT_URLCONF, name="www"), 7 | host(r"2003", "config.urls_2003", name="urls_2003"), 8 | ) 9 | -------------------------------------------------------------------------------- /blog/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.db.models import signals 3 | 4 | 5 | class BlogConfig(AppConfig): 6 | name = "blog" 7 | 8 | def ready(self): 9 | # import signal handlers 10 | from blog import signals 11 | -------------------------------------------------------------------------------- /templates/feeds/note.html: -------------------------------------------------------------------------------- 1 | {{ obj.body_rendered }} 2 | {% if obj.tags.count %} 3 |

Tags: {% for tag in obj.tags.all %}{{ tag.tag }}{% if not forloop.last %}, {% endif %}{% endfor %}

4 | {% endif %} 5 | -------------------------------------------------------------------------------- /templates/_draft_warning.html: -------------------------------------------------------------------------------- 1 | {% if is_draft %} 2 |
3 | Draft: This is a draft post. Please do not share this URL with anyone else. 4 |
5 | {% endif %} -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Repo Guidelines 2 | 3 | ## Running tests 4 | 5 | 1. Set the `DATABASE_URL` environment variable to `postgres://testuser:testpass@localhost/simonwillisonblog`. 6 | 2. Run tests from the repository root with: 7 | 8 | ```bash 9 | python manage.py test -v3 10 | ``` 11 | -------------------------------------------------------------------------------- /templates/_tags.html: -------------------------------------------------------------------------------- 1 | {% if obj.tags.count %} 2 | {% for tag in obj.tags.all %} 3 | 7 | {% endfor %} 8 | {% endif %} -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /monthly/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Newsletter 4 | 5 | 6 | @admin.register(Newsletter) 7 | class NewsletterAdmin(admin.ModelAdmin): 8 | list_display = ("subject", "sent_at") 9 | ordering = ("-sent_at",) 10 | fields = ("subject", "body", "sent_at") 11 | -------------------------------------------------------------------------------- /monthly/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, re_path 2 | 3 | from . import views 4 | 5 | app_name = "monthly" 6 | 7 | urlpatterns = [ 8 | path("", views.monthly_index, name="index"), 9 | re_path( 10 | r"^(?P\d{4})-(?P\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 | 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 |

{{ info.tag.tag }} ({{ info.total }})

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 |
11 | 12 | 13 |
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 |
13 | 14 | {% csrf_token %} 15 |
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 |

{{ series.title }}

9 |

{{ series.summary }}

10 |
    11 | {% for entry in series.entries_ordest_first %} 12 |
  1. {{ entry }} - {{ entry.created }}
  2. 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 |
6 | 10 |
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 | 5 | 6 | {% for week in weeks %} 7 | 8 | {% for day in week %} 9 | {% if not day.display %}{% else %} 10 | 12 | {% endif %} 13 | {% endfor %} 14 | 15 | {% endfor %} 16 |
MTWTFSS
 {% if day.populated %}{% if day.entries %}{% endif %}{{ day.day|date:"j" }}{% if day.entries %}{% endif %} 11 | {% else %}{{ day.day|date:"j" }}{% endif %}
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 |
14 |

{{ date|date:"Y" }} » {{ date|date:"F" }}

15 | {% load blog_calendar %} 16 | {% render_calendar date %} 17 | {% if photo %} 18 |
{% for p in photo %}{{ p.title }}{% endfor %}

19 | {% if more_photos %}... {{ more_photos }} photo{{ more_photos|pluralize }}{% endif %} 20 | {% endif %} 21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # simonwillisonblog 2 | 3 | [![GitHub Actions](https://github.com/simonw/simonwillisonblog/actions/workflows/ci.yml/badge.svg)](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 | 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 |
26 | {% include "_tags.html" with obj=note %} 27 | 28 |
29 | {% include "_sponsor_promo.html" %} 30 | 31 |
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 |
35 | 36 |
37 |

38 | 39 | 40 |

41 |

42 | 43 | 44 |

45 | 46 |
47 | 48 | 49 |
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 |
34 |

{{ date|date:"Y" }} » {{ date|date:"F" }}

35 | {% load blog_calendar %} 36 | {% render_calendar_month_only date %} 37 |
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 |
24 | 25 | {% include "_tags.html" with obj=blogmark %} 26 | 27 |
28 | {% include "_sponsor_promo.html" %} 29 | 30 |
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 |
57 | You can view the intersection of up to three tags by navigating to /tags/tag1+tag2/. 58 | 59 |

Archive by year

60 |

61 | {% for year in years_with_content reversed %} 62 | {{ year|date:"Y" }}{% if not forloop.last %} / {% endif %} 63 | {% endfor %} 64 |

65 |
66 | {% endblock %} 67 | 68 | -------------------------------------------------------------------------------- /templates/bighead.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block body %} 4 |
5 | 6 |
7 |

Simon Willison’s Weblog

8 | 16 |
17 |
18 | {% if current_tags %}

On {% for tag in current_tags %}{{ tag }} {{ tag.total_count }} {% endfor %}...

{% else %}Tags...{% endif %} 19 |
20 | 21 | 22 |
23 |
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 |
64 | {% if only_one_tag and tag.get_related_tags %} 65 |

Related

66 | {% for related_tag in tag.get_related_tags %} 67 | 71 | {% endfor %} 72 |

73 | {% endif %} 74 |
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 |
37 |

This is {{ entry.title|typography }} by Simon Willison, posted on {{ entry.created|date:"jS F Y" }}.

38 | {% if entry.series %}{% with entry.series_info as series_info %} 39 |
40 |

Part of series {{ entry.series.title }}

41 |
    42 | {% for other in series_info.entries %} 43 | {% if other.pk == entry.pk %} 44 |
  1. {{ other }} - {{ other.created }}
  2. 45 | {% else %} 46 |
  3. {{ other }} - {{ other.created }}
  4. 47 | {% endif %} 48 | {% endfor %} 49 | {% if series_info.has_next %}
  5. … more
  6. {% endif %} 50 |
51 |
52 | {% endwith %}{% endif %} 53 | {% include "_tags.html" with obj=entry %} 54 | {% with entry.next_by_created as next_entry %}{% if next_entry %} 55 |

Next: {{ next_entry.title }}

56 | {% endif %}{% endwith %} 57 | {% with entry.previous_by_created as previous_entry %}{% if previous_entry %} 58 |

Previous: {{ previous_entry.title }}

59 | {% endif %}{% endwith %} 60 |
61 | {% include "_sponsor_promo.html" %} 62 |
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 | {{ item.obj.primary.title }} 8 |

{{ item.obj.title }}, a photoset

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 |

{{ item.obj.title|typography }}

17 | {% if item.obj.card_image %} 18 |
19 | Visit {{ item.obj.title }} 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 |

Your comment is being held in a moderation queue. (clear)

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 |
44 | 45 |
46 | {% if comments_open %} 47 |
48 |

49 |

50 | {% if not openid %}

51 |

52 |

Sign in with OpenID

53 | {% else %} 54 |

{{ openid }}

55 | {% endif %} 56 |

57 |

59 | 60 | 61 | 62 |

63 |

64 | Auto-HTML: Line breaks are preserved; URLs will be converted in to links. 65 |

66 |

67 | Manual XHTML: Enter your own, valid XHTML. Allowed tags are 69 | a, p, blockquote, ul, 70 | ol, li, dl, dt, 71 | dd, em, strong, dfn, 72 | code, q, samp, kbd, 73 | var, cite, abbr, acronym, 74 | sub, sup, br, pre 75 |

76 |

77 | 78 | 79 |

80 |
81 | {% else %} 82 | Comments are closed. 83 | {% endif %} 84 |
85 | 86 |
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 |
27 |

{% for year in years_with_content %}{{ year|date:"Y" }}{% if not forloop.last %} / {% endif %}{% endfor %}

28 |
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 | 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 |
71 | {% if type_counts %} 72 |

Types

73 |
    74 | {% for t in type_counts %} 75 |
  • {{ t.type }} {{ t.n|intcomma }}
  • 76 | {% endfor %} 77 |
78 | {% endif %} 79 | {% if year_counts %} 80 |

Years

81 | 86 | {% endif %} 87 | {% if month_counts %} 88 |

Months

89 | 94 | {% endif %} 95 | {% if tag_counts %} 96 |

Tags

97 |
    98 | {% for t in tag_counts %} 99 |
  • {{ t.tag }} {{ t.n|intcomma }}
  • 100 | {% endfor %} 101 |
102 | {% endif %} 103 |
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 | 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 | --------------------------------------------------------------------------------