From 20e63023bb0d3c7e4cb36b91b73e79f51889cc90 Mon Sep 17 00:00:00 2001
From: Andrew Godwin
Date: Tue, 15 Nov 2022 18:30:30 -0700
Subject: Get outbound likes/boosts and their undos working
---
README.md | 6 +-
activities/admin.py | 23 ++++++-
activities/models/__init__.py | 6 +-
activities/models/fan_out.py | 35 +++++++++++
activities/models/post.py | 50 ++++++++++-----
activities/models/post_interaction.py | 112 +++++++++++++++++++++++++++++++---
activities/models/timeline_event.py | 17 ++++++
activities/views/posts.py | 102 +++++++++++++++++++++++++++++++
activities/views/timelines.py | 8 ++-
core/signatures.py | 5 --
requirements.txt | 1 +
static/css/style.css | 17 ++++++
static/js/htmx.min.js | 1 +
stator/graph.py | 4 ++
stator/models.py | 14 +++--
takahe/settings/base.py | 2 +
takahe/urls.py | 14 +++--
templates/activities/_boost.html | 35 +++--------
templates/activities/_like.html | 9 +++
templates/activities/_post.html | 7 +++
templates/activities/post.html | 17 ++++++
templates/base.html | 3 +-
users/admin.py | 5 ++
users/models/identity.py | 33 +++++++++-
users/models/inbox_message.py | 8 +++
users/views/activitypub.py | 27 +-------
26 files changed, 460 insertions(+), 101 deletions(-)
create mode 100644 activities/views/posts.py
create mode 100755 static/js/htmx.min.js
create mode 100644 templates/activities/_like.html
create mode 100644 templates/activities/post.html
diff --git a/README.md b/README.md
index e7846e3..435aa54 100644
--- a/README.md
+++ b/README.md
@@ -39,14 +39,14 @@ the less sure I am about it.
- [ ] Set post visibility
- [x] Receive posts
- [ ] Handle received post visibility
-- [ ] Receive post deletions
+- [x] Receive post deletions
- [x] Set content warnings on posts
- [ ] Show content warnings on posts
- [ ] Attach images to posts
- [ ] Receive images on posts
-- [ ] Create boosts
+- [x] Create boosts
- [x] Receive boosts
-- [ ] Create likes
+- [x] Create likes
- [x] Receive likes
- [x] Create follows
- [ ] Undo follows
diff --git a/activities/admin.py b/activities/admin.py
index 947a596..a025230 100644
--- a/activities/admin.py
+++ b/activities/admin.py
@@ -6,25 +6,42 @@ from activities.models import FanOut, Post, PostInteraction, TimelineEvent
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ["id", "state", "author", "created"]
- raw_id_fields = ["to", "mentions"]
+ raw_id_fields = ["to", "mentions", "author"]
actions = ["force_fetch"]
+ readonly_fields = ["created", "updated", "object_json"]
@admin.action(description="Force Fetch")
def force_fetch(self, request, queryset):
for instance in queryset:
instance.debug_fetch()
+ @admin.display(description="ActivityPub JSON")
+ def object_json(self, instance):
+ return instance.to_ap()
+
@admin.register(TimelineEvent)
class TimelineEventAdmin(admin.ModelAdmin):
list_display = ["id", "identity", "created", "type"]
- raw_id_fields = ["identity", "subject_post", "subject_identity"]
+ raw_id_fields = [
+ "identity",
+ "subject_post",
+ "subject_identity",
+ "subject_post_interaction",
+ ]
@admin.register(FanOut)
class FanOutAdmin(admin.ModelAdmin):
list_display = ["id", "state", "state_attempted", "type", "identity"]
- raw_id_fields = ["identity", "subject_post"]
+ raw_id_fields = ["identity", "subject_post", "subject_post_interaction"]
+ readonly_fields = ["created", "updated"]
+ actions = ["force_execution"]
+
+ @admin.action(description="Force Execution")
+ def force_execution(self, request, queryset):
+ for instance in queryset:
+ instance.transition_perform("new")
@admin.register(PostInteraction)
diff --git a/activities/models/__init__.py b/activities/models/__init__.py
index a0680ad..48ba879 100644
--- a/activities/models/__init__.py
+++ b/activities/models/__init__.py
@@ -1,4 +1,4 @@
-from .fan_out import FanOut # noqa
-from .post import Post # noqa
-from .post_interaction import PostInteraction # noqa
+from .fan_out import FanOut, FanOutStates # noqa
+from .post import Post, PostStates # noqa
+from .post_interaction import PostInteraction, PostInteractionStates # noqa
from .timeline_event import TimelineEvent # noqa
diff --git a/activities/models/fan_out.py b/activities/models/fan_out.py
index dbe86c0..771be19 100644
--- a/activities/models/fan_out.py
+++ b/activities/models/fan_out.py
@@ -38,6 +38,40 @@ class FanOutStates(StateGraph):
key_id=post.author.public_key_id,
)
return cls.sent
+ # Handle boosts/likes
+ elif fan_out.type == FanOut.Types.interaction:
+ interaction = await fan_out.subject_post_interaction.afetch_full()
+ if fan_out.identity.local:
+ # Make a timeline event directly
+ await sync_to_async(TimelineEvent.add_post_interaction)(
+ identity=fan_out.identity,
+ interaction=interaction,
+ )
+ else:
+ # Send it to the remote inbox
+ await HttpSignature.signed_request(
+ uri=fan_out.identity.inbox_uri,
+ body=canonicalise(interaction.to_ap()),
+ private_key=interaction.identity.private_key,
+ key_id=interaction.identity.public_key_id,
+ )
+ # Handle undoing boosts/likes
+ elif fan_out.type == FanOut.Types.undo_interaction:
+ interaction = await fan_out.subject_post_interaction.afetch_full()
+ if fan_out.identity.local:
+ # Delete any local timeline events
+ await sync_to_async(TimelineEvent.delete_post_interaction)(
+ identity=fan_out.identity,
+ interaction=interaction,
+ )
+ else:
+ # Send an undo to the remote inbox
+ await HttpSignature.signed_request(
+ uri=fan_out.identity.inbox_uri,
+ body=canonicalise(interaction.to_undo_ap()),
+ private_key=interaction.identity.private_key,
+ key_id=interaction.identity.public_key_id,
+ )
else:
raise ValueError(f"Cannot fan out with type {fan_out.type}")
@@ -50,6 +84,7 @@ class FanOut(StatorModel):
class Types(models.TextChoices):
post = "post"
interaction = "interaction"
+ undo_interaction = "undo_interaction"
state = StateField(FanOutStates)
diff --git a/activities/models/post.py b/activities/models/post.py
index 74b335b..22e6412 100644
--- a/activities/models/post.py
+++ b/activities/models/post.py
@@ -2,7 +2,7 @@ from typing import Dict, Optional
import httpx
import urlman
-from django.db import models
+from django.db import models, transaction
from django.utils import timezone
from activities.models.fan_out import FanOut
@@ -99,7 +99,12 @@ class Post(StatorModel):
class urls(urlman.Urls):
view = "{self.author.urls.view}posts/{self.id}/"
- object_uri = "{self.author.urls.actor}posts/{self.id}/"
+ view_nice = "{self.author.urls.view_nice}posts/{self.id}/"
+ object_uri = "{self.author.actor_uri}posts/{self.id}/"
+ action_like = "{view}like/"
+ action_unlike = "{view}unlike/"
+ action_boost = "{view}boost/"
+ action_unboost = "{view}unboost/"
def get_scheme(self, url):
return "https"
@@ -130,16 +135,17 @@ class Post(StatorModel):
def create_local(
cls, author: Identity, content: str, summary: Optional[str] = None
) -> "Post":
- post = cls.objects.create(
- author=author,
- content=content,
- summary=summary or None,
- sensitive=bool(summary),
- local=True,
- )
- post.object_uri = post.author.actor_uri + f"posts/{post.id}/"
- post.url = post.object_uri
- post.save()
+ with transaction.atomic():
+ post = cls.objects.create(
+ author=author,
+ content=content,
+ summary=summary or None,
+ sensitive=bool(summary),
+ local=True,
+ )
+ post.object_uri = post.urls.object_uri
+ post.url = post.urls.view_nice
+ post.save()
return post
### ActivityPub (outbound) ###
@@ -179,7 +185,7 @@ class Post(StatorModel):
"content": self.safe_content,
"to": "as:Public",
"as:sensitive": self.sensitive,
- "url": self.urls.view.full(), # type: ignore
+ "url": self.urls.view_nice if self.local else self.url,
}
if self.summary:
value["summary"] = self.summary
@@ -257,7 +263,7 @@ class Post(StatorModel):
create=True,
update=True,
)
- raise ValueError(f"Cannot find Post with URI {object_uri}")
+ raise cls.DoesNotExist(f"Cannot find Post with URI {object_uri}")
@classmethod
def handle_create_ap(cls, data):
@@ -275,6 +281,22 @@ class Post(StatorModel):
# Force it into fanned_out as it's not ours
post.transition_perform(PostStates.fanned_out)
+ @classmethod
+ def handle_delete_ap(cls, data):
+ """
+ Handles an incoming create request
+ """
+ # Find our post by ID if we have one
+ try:
+ post = cls.by_object_uri(data["object"]["id"])
+ except cls.DoesNotExist:
+ # It's already been deleted
+ return
+ # Ensure the actor on the request authored the post
+ if not post.author.actor_uri == data["actor"]:
+ raise ValueError("Actor on delete does not match object")
+ post.delete()
+
def debug_fetch(self):
"""
Fetches the Post from its original URL again and updates us with it
diff --git a/activities/models/post_interaction.py b/activities/models/post_interaction.py
index 151ab45..ea95cdf 100644
--- a/activities/models/post_interaction.py
+++ b/activities/models/post_interaction.py
@@ -14,9 +14,13 @@ from users.models.identity import Identity
class PostInteractionStates(StateGraph):
new = State(try_interval=300)
- fanned_out = State()
+ fanned_out = State(externally_progressed=True)
+ undone = State(try_interval=300)
+ undone_fanned_out = State()
new.transitions_to(fanned_out)
+ fanned_out.transitions_to(undone)
+ undone.transitions_to(undone_fanned_out)
@classmethod
async def handle_new(cls, instance: "PostInteraction"):
@@ -31,26 +35,74 @@ class PostInteractionStates(StateGraph):
):
if follow.source.local or follow.target.local:
await FanOut.objects.acreate(
- identity_id=follow.source_id,
type=FanOut.Types.interaction,
- subject_post=interaction,
+ identity_id=follow.source_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
)
# Like: send a copy to the original post author only
elif interaction.type == interaction.Types.like:
await FanOut.objects.acreate(
- identity_id=interaction.post.author_id,
type=FanOut.Types.interaction,
- subject_post=interaction,
+ identity_id=interaction.post.author_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
)
else:
raise ValueError("Cannot fan out unknown type")
- # And one for themselves if they're local
- if interaction.identity.local:
+ # And one for themselves if they're local and it's a boost
+ if (
+ interaction.type == PostInteraction.Types.boost
+ and interaction.identity.local
+ ):
await FanOut.objects.acreate(
identity_id=interaction.identity_id,
type=FanOut.Types.interaction,
- subject_post=interaction,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
+ )
+ return cls.fanned_out
+
+ @classmethod
+ async def handle_undone(cls, instance: "PostInteraction"):
+ """
+ Creates all needed fan-out objects to undo a PostInteraction.
+ """
+ interaction = await instance.afetch_full()
+ # Undo Boost: send a copy to all people who follow this user
+ if interaction.type == interaction.Types.boost:
+ async for follow in interaction.identity.inbound_follows.select_related(
+ "source", "target"
+ ):
+ if follow.source.local or follow.target.local:
+ await FanOut.objects.acreate(
+ type=FanOut.Types.undo_interaction,
+ identity_id=follow.source_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
+ )
+ # Undo Like: send a copy to the original post author only
+ elif interaction.type == interaction.Types.like:
+ await FanOut.objects.acreate(
+ type=FanOut.Types.undo_interaction,
+ identity_id=interaction.post.author_id,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
+ )
+ else:
+ raise ValueError("Cannot fan out unknown type")
+ # And one for themselves if they're local and it's a boost
+ if (
+ interaction.type == PostInteraction.Types.boost
+ and interaction.identity.local
+ ):
+ await FanOut.objects.acreate(
+ identity_id=interaction.identity_id,
+ type=FanOut.Types.undo_interaction,
+ subject_post=interaction.post,
+ subject_post_interaction=interaction,
)
+ return cls.undone_fanned_out
class PostInteraction(StatorModel):
@@ -95,6 +147,35 @@ class PostInteraction(StatorModel):
class Meta:
index_together = [["type", "identity", "post"]]
+ ### Display helpers ###
+
+ @classmethod
+ def get_post_interactions(cls, posts, identity):
+ """
+ Returns a dict of {interaction_type: set(post_ids)} for all the posts
+ and the given identity, for use in templates.
+ """
+ # Bulk-fetch any interactions
+ ids_with_interaction_type = cls.objects.filter(
+ identity=identity,
+ post_id__in=[post.pk for post in posts],
+ type__in=[cls.Types.like, cls.Types.boost],
+ state__in=[PostInteractionStates.new, PostInteractionStates.fanned_out],
+ ).values_list("post_id", "type")
+ # Make it into the return dict
+ result = {}
+ for post_id, interaction_type in ids_with_interaction_type:
+ result.setdefault(interaction_type, set()).add(post_id)
+ return result
+
+ @classmethod
+ def get_event_interactions(cls, events, identity):
+ """
+ Returns a dict of {interaction_type: set(post_ids)} for all the posts
+ within the events and the given identity, for use in templates.
+ """
+ return cls.get_post_interactions([e.subject_post for e in events], identity)
+
### Async helpers ###
async def afetch_full(self):
@@ -111,6 +192,9 @@ class PostInteraction(StatorModel):
"""
Returns the AP JSON for this object
"""
+ # Create an object URI if we don't have one
+ if self.object_uri is None:
+ self.object_uri = self.identity.actor_uri + f"#{self.type}/{self.id}"
if self.type == self.Types.boost:
value = {
"type": "Announce",
@@ -132,6 +216,18 @@ class PostInteraction(StatorModel):
raise ValueError("Cannot turn into AP")
return value
+ def to_undo_ap(self) -> Dict:
+ """
+ Returns the AP JSON to undo this object
+ """
+ object = self.to_ap()
+ return {
+ "id": object["id"] + "/undo",
+ "type": "Undo",
+ "actor": self.identity.actor_uri,
+ "object": object,
+ }
+
### ActivityPub (inbound) ###
@classmethod
diff --git a/activities/models/timeline_event.py b/activities/models/timeline_event.py
index 6dba32c..29dec19 100644
--- a/activities/models/timeline_event.py
+++ b/activities/models/timeline_event.py
@@ -114,3 +114,20 @@ class TimelineEvent(models.Model):
subject_identity_id=interaction.identity_id,
subject_post_interaction=interaction,
)[0]
+
+ @classmethod
+ def delete_post_interaction(cls, identity, interaction):
+ if interaction.type == interaction.Types.like:
+ cls.objects.filter(
+ identity=identity,
+ type=cls.Types.liked,
+ subject_post_id=interaction.post_id,
+ subject_identity_id=interaction.identity_id,
+ ).delete()
+ elif interaction.type == interaction.Types.boost:
+ cls.objects.filter(
+ identity=identity,
+ type__in=[cls.Types.boosted, cls.Types.boost],
+ subject_post_id=interaction.post_id,
+ subject_identity_id=interaction.identity_id,
+ ).delete()
diff --git a/activities/views/posts.py b/activities/views/posts.py
new file mode 100644
index 0000000..ece7cf3
--- /dev/null
+++ b/activities/views/posts.py
@@ -0,0 +1,102 @@
+from django.shortcuts import get_object_or_404, redirect, render
+from django.utils.decorators import method_decorator
+from django.views.generic import TemplateView, View
+
+from activities.models import PostInteraction, PostInteractionStates
+from users.decorators import identity_required
+from users.shortcuts import by_handle_or_404
+
+
+class Post(TemplateView):
+
+ template_name = "activities/post.html"
+
+ def get_context_data(self, handle, post_id):
+ identity = by_handle_or_404(self.request, handle, local=False)
+ post = get_object_or_404(identity.posts, pk=post_id)
+ return {
+ "identity": identity,
+ "post": post,
+ "interactions": PostInteraction.get_post_interactions(
+ [post],
+ self.request.identity,
+ ),
+ }
+
+
+@method_decorator(identity_required, name="dispatch")
+class Like(View):
+ """
+ Adds/removes a like from the current identity to the post
+ """
+
+ undo = False
+
+ def post(self, request, handle, post_id):
+ identity = by_handle_or_404(self.request, handle, local=False)
+ post = get_object_or_404(identity.posts, pk=post_id)
+ if self.undo:
+ # Undo any likes on the post
+ for interaction in PostInteraction.objects.filter(
+ type=PostInteraction.Types.like,
+ identity=request.identity,
+ post=post,
+ ):
+ interaction.transition_perform(PostInteractionStates.undone)
+ else:
+ # Make a like on this post if we didn't already
+ PostInteraction.objects.get_or_create(
+ type=PostInteraction.Types.like,
+ identity=request.identity,
+ post=post,
+ )
+ # Return either a redirect or a HTMX snippet
+ if request.htmx:
+ return render(
+ request,
+ "activities/_like.html",
+ {
+ "post": post,
+ "interactions": {"like": set() if self.undo else {post.pk}},
+ },
+ )
+ return redirect(post.urls.view)
+
+
+@method_decorator(identity_required, name="dispatch")
+class Boost(View):
+ """
+ Adds/removes a boost from the current identity to the post
+ """
+
+ undo = False
+
+ def post(self, request, handle, post_id):
+ identity = by_handle_or_404(self.request, handle, local=False)
+ post = get_object_or_404(identity.posts, pk=post_id)
+ if self.undo:
+ # Undo any boosts on the post
+ for interaction in PostInteraction.objects.filter(
+ type=PostInteraction.Types.boost,
+ identity=request.identity,
+ post=post,
+ ):
+ interaction.transition_perform(PostInteractionStates.undone)
+ else:
+ # Make a boost on this post if we didn't already
+ PostInteraction.objects.get_or_create(
+ type=PostInteraction.Types.boost,
+ identity=request.identity,
+ post=post,
+ )
+ # Return either a redirect or a HTMX snippet
+ if request.htmx:
+ return render(
+ request,
+ "activities/_boost.html",
+ {
+ "post": post,
+ "interactions": {"boost": set() if self.undo else {post.pk}},
+ },
+ )
+ return redirect(post.urls.view)
diff --git a/activities/views/timelines.py b/activities/views/timelines.py
index 9be988d..c59c3b6 100644
--- a/activities/views/timelines.py
+++ b/activities/views/timelines.py
@@ -4,7 +4,7 @@ from django.template.defaultfilters import linebreaks_filter
from django.utils.decorators import method_decorator
from django.views.generic import FormView, TemplateView
-from activities.models import Post, TimelineEvent
+from activities.models import Post, PostInteraction, TimelineEvent
from users.decorators import identity_required
@@ -33,7 +33,7 @@ class Home(FormView):
def get_context_data(self):
context = super().get_context_data()
- context["events"] = (
+ context["events"] = list(
TimelineEvent.objects.filter(
identity=self.request.identity,
type__in=[TimelineEvent.Types.post, TimelineEvent.Types.boost],
@@ -41,7 +41,9 @@ class Home(FormView):
.select_related("subject_post", "subject_post__author")
.order_by("-created")[:100]
)
-
+ context["interactions"] = PostInteraction.get_event_interactions(
+ context["events"], self.request.identity
+ )
context["current_page"] = "home"
return context
diff --git a/core/signatures.py b/core/signatures.py
index 0959333..8b52c1a 100644
--- a/core/signatures.py
+++ b/core/signatures.py
@@ -115,15 +115,11 @@ class HttpSignature:
if "HTTP_DIGEST" in request.META:
expected_digest = HttpSignature.calculate_digest(request.body)
if request.META["HTTP_DIGEST"] != expected_digest:
- print("Wrong digest")
raise VerificationFormatError("Digest is incorrect")
# Verify date header
if "HTTP_DATE" in request.META and not skip_date:
header_date = parse_http_date(request.META["HTTP_DATE"])
if abs(timezone.now().timestamp() - header_date) > 60:
- print(
- f"Date mismatch - they sent {header_date}, now is {timezone.now().timestamp()}"
- )
raise VerificationFormatError("Date is too far away")
# Get the signature details
if "HTTP_SIGNATURE" not in request.META:
@@ -186,7 +182,6 @@ class HttpSignature:
)
del headers["(request-target)"]
async with httpx.AsyncClient() as client:
- print(f"Calling {method} {uri}")
response = await client.request(
method,
uri,
diff --git a/requirements.txt b/requirements.txt
index 1c09acd..ce82854 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,3 +10,4 @@ gunicorn~=20.1.0
psycopg2~=2.9.5
bleach~=5.0.1
pydantic~=1.10.2
+django-htmx~=1.13.0
diff --git a/static/css/style.css b/static/css/style.css
index 59590ef..9eaec2f 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -528,6 +528,23 @@ h1.identity small {
margin: 12px 0 4px 0;
}
+.post .actions {
+ padding-left: 64px;
+}
+
+.post .actions a {
+ cursor: pointer;
+ color: var(--color-text-dull);
+}
+
+.post .actions a:hover {
+ color: var(--color-text-main);
+}
+
+.post .actions a.active {
+ color: var(--color-highlight);
+}
+
.boost-banner {
padding: 0 0 3px 5px;
}
diff --git a/static/js/htmx.min.js b/static/js/htmx.min.js
new file mode 100755
index 0000000..293df11
--- /dev/null
+++ b/static/js/htmx.min.js
@@ -0,0 +1 @@
+(function(e,t){if(typeof define==="function"&&define.amd){define([],t)}else{e.htmx=e.htmx||t()}})(typeof self!=="undefined"?self:this,function(){return function(){"use strict";var W={onLoad:t,process:mt,on:X,off:F,trigger:Q,ajax:or,find:R,findAll:O,closest:N,values:function(e,t){var r=jt(e,t||"post");return r.values},remove:q,addClass:L,removeClass:T,toggleClass:H,takeClass:A,defineExtension:dr,removeExtension:vr,logAll:C,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,inlineScriptNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",disableSelector:"[hx-disable], [data-hx-disable]",useTemplateFragments:false,scrollBehavior:"smooth",defaultFocusScroll:false},parseInterval:v,_:e,createEventSource:function(e){return new EventSource(e,{withCredentials:true})},createWebSocket:function(e){return new WebSocket(e,[])},version:"1.8.4"};var r={addTriggerHandler:ft,bodyContains:te,canAccessLocalStorage:E,filterValues:zt,hasAttribute:o,getAttributeValue:G,getClosestMatch:h,getExpressionVars:rr,getHeaders:_t,getInputValues:jt,getInternalData:Z,getSwapSpecification:Gt,getTriggerSpecs:Xe,getTarget:oe,makeFragment:g,mergeObjects:re,makeSettleInfo:Zt,oobSwap:_,selectAndSwap:Oe,settleImmediately:At,shouldCancel:Ve,triggerEvent:Q,triggerErrorEvent:Y,withExtensions:wt};var n=["get","post","put","delete","patch"];var i=n.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function v(e){if(e==undefined){return undefined}if(e.slice(-2)=="ms"){return parseFloat(e.slice(0,-2))||undefined}if(e.slice(-1)=="s"){return parseFloat(e.slice(0,-1))*1e3||undefined}if(e.slice(-1)=="m"){return parseFloat(e.slice(0,-1))*1e3*60||undefined}return parseFloat(e)||undefined}function f(e,t){return e.getAttribute&&e.getAttribute(t)}function o(e,t){return e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function G(e,t){return f(e,t)||f(e,"data-"+t)}function u(e){return e.parentElement}function J(){return document}function h(e,t){while(e&&!t(e)){e=u(e)}return e?e:null}function a(e,t,r){var n=G(t,r);var i=G(t,"hx-disinherit");if(e!==t&&i&&(i==="*"||i.split(" ").indexOf(r)>=0)){return"unset"}else{return n}}function $(t,r){var n=null;h(t,function(e){return n=a(t,e,r)});if(n!=="unset"){return n}}function d(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function s(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function l(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=J().createDocumentFragment()}return i}function g(e){if(W.config.useTemplateFragments){var t=l("
"+e+"",0);return t.querySelector("template").content}else{var r=s(e);switch(r){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return l("",1);case"col":return l("",2);case"tr":return l("",2);case"td":case"th":return l("",3);case"script":return l(""+e+"
",1);default:return l(e,0)}}}function ee(e){if(e){e()}}function p(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function m(e){return p(e,"Function")}function x(e){return p(e,"Object")}function Z(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function y(e){var t=[];if(e){for(var r=0;r=0}function te(e){if(e.getRootNode&&e.getRootNode()instanceof ShadowRoot){return J().body.contains(e.getRootNode().host)}else{return J().body.contains(e)}}function w(e){return e.trim().split(/\s+/)}function re(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function S(e){try{return JSON.parse(e)}catch(e){St(e);return null}}function E(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function e(e){return Qt(J().body,function(){return eval(e)})}function t(t){var e=W.on("htmx:load",function(e){t(e.detail.elt)});return e}function C(){W.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function R(e,t){if(t){return e.querySelector(t)}else{return R(J(),e)}}function O(e,t){if(t){return e.querySelectorAll(t)}else{return O(J(),e)}}function q(e,t){e=D(e);if(t){setTimeout(function(){q(e)},t)}else{e.parentElement.removeChild(e)}}function L(e,t,r){e=D(e);if(r){setTimeout(function(){L(e,t)},r)}else{e.classList&&e.classList.add(t)}}function T(e,t,r){e=D(e);if(r){setTimeout(function(){T(e,t)},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function H(e,t){e=D(e);e.classList.toggle(t)}function A(e,t){e=D(e);K(e.parentElement.children,function(e){T(e,t)});L(e,t)}function N(e,t){e=D(e);if(e.closest){return e.closest(t)}else{do{if(e==null||d(e,t)){return e}}while(e=e&&u(e))}}function I(e,t){if(t.indexOf("closest ")===0){return[N(e,t.substr(8))]}else if(t.indexOf("find ")===0){return[R(e,t.substr(5))]}else if(t.indexOf("next ")===0){return[k(e,t.substr(5))]}else if(t.indexOf("previous ")===0){return[M(e,t.substr(9))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else{return J().querySelectorAll(t)}}var k=function(e,t){var r=J().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function ne(e,t){if(t){return I(e,t)[0]}else{return I(J().body,e)[0]}}function D(e){if(p(e,"String")){return R(e)}else{return e}}function P(e,t,r){if(m(t)){return{target:J().body,event:e,listener:t}}else{return{target:D(e),event:t,listener:r}}}function X(t,r,n){pr(function(){var e=P(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=m(r);return e?r:n}function F(t,r,n){pr(function(){var e=P(t,r,n);e.target.removeEventListener(e.event,e.listener)});return m(r)?r:n}var ie=J().createElement("output");function j(e,t){var r=$(e,t);if(r){if(r==="this"){return[ae(e,t)]}else{var n=I(e,r);if(n.length===0){St('The selector "'+r+'" on '+t+" returned no matches!");return[ie]}else{return n}}}}function ae(e,t){return h(e,function(e){return G(e,t)!=null})}function oe(e){var t=$(e,"hx-target");if(t){if(t==="this"){return ae(e,"hx-target")}else{return ne(e,t)}}else{var r=Z(e);if(r.boosted){return J().body}else{return e}}}function B(e){var t=W.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=J().querySelectorAll(t);if(r){K(r,function(e){var t;var r=i.cloneNode(true);t=J().createDocumentFragment();t.appendChild(r);if(!V(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!Q(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){Ce(o,e,e,t,a)}K(a.elts,function(e){Q(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);Y(J().body,"htmx:oobErrorNoTarget",{content:i})}return e}function z(e,t,r){var n=$(e,"hx-select-oob");if(n){var i=n.split(",");for(let e=0;e0){var t=n.querySelector(e.tagName+"[id='"+e.id+"']");if(t&&t!==n){var r=e.cloneNode();U(e,t);i.tasks.push(function(){U(e,r)})}}})}function ue(e){return function(){T(e,W.config.addedClass);mt(e);ht(e);fe(e);Q(e,"htmx:load")}}function fe(e){var t="[autofocus]";var r=d(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function ce(e,t,r,n){le(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;L(i,W.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(ue(i))}}}function he(e,t){var r=0;while(r-1){var t=e.replace(/