diff options
-rw-r--r-- | README.md | 6 | ||||
-rw-r--r-- | activities/admin.py | 23 | ||||
-rw-r--r-- | activities/models/__init__.py | 6 | ||||
-rw-r--r-- | activities/models/fan_out.py | 35 | ||||
-rw-r--r-- | activities/models/post.py | 50 | ||||
-rw-r--r-- | activities/models/post_interaction.py | 112 | ||||
-rw-r--r-- | activities/models/timeline_event.py | 17 | ||||
-rw-r--r-- | activities/views/posts.py | 102 | ||||
-rw-r--r-- | activities/views/timelines.py | 8 | ||||
-rw-r--r-- | core/signatures.py | 5 | ||||
-rw-r--r-- | requirements.txt | 1 | ||||
-rw-r--r-- | static/css/style.css | 17 | ||||
-rwxr-xr-x | static/js/htmx.min.js | 1 | ||||
-rw-r--r-- | stator/graph.py | 4 | ||||
-rw-r--r-- | stator/models.py | 14 | ||||
-rw-r--r-- | takahe/settings/base.py | 2 | ||||
-rw-r--r-- | takahe/urls.py | 14 | ||||
-rw-r--r-- | templates/activities/_boost.html | 35 | ||||
-rw-r--r-- | templates/activities/_like.html | 9 | ||||
-rw-r--r-- | templates/activities/_post.html | 7 | ||||
-rw-r--r-- | templates/activities/post.html | 17 | ||||
-rw-r--r-- | templates/base.html | 3 | ||||
-rw-r--r-- | users/admin.py | 5 | ||||
-rw-r--r-- | users/models/identity.py | 33 | ||||
-rw-r--r-- | users/models/inbox_message.py | 8 | ||||
-rw-r--r-- | users/views/activitypub.py | 27 |
26 files changed, 460 insertions, 101 deletions
@@ -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("<body><template>"+e+"</template></body>",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("<table>"+e+"</table>",1);case"col":return l("<table><colgroup>"+e+"</colgroup></table>",2);case"tr":return l("<table><tbody>"+e+"</tbody></table>",2);case"td":case"th":return l("<table><tbody><tr>"+e+"</tr></tbody></table>",3);case"script":return l("<div>"+e+"</div>",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<e.length;r++){t.push(e[r])}}return t}function K(e,t){if(e){for(var r=0;r<e.length;r++){t(e[r])}}}function b(e){var t=e.getBoundingClientRect();var r=t.top;var n=t.bottom;return r<window.innerHeight&&n>=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<r.length;n++){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_PRECEDING){return i}}};var M=function(e,t){var r=J().querySelectorAll(t);for(var n=r.length-1;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;r<t.length;r++){if(e===t[r]){return true}}return false}function U(t,r){K(t.attributes,function(e){if(!r.hasAttribute(e.name)&&B(e.name)){t.removeAttribute(e.name)}});K(r.attributes,function(e){if(B(e.name)){t.setAttribute(e.name,e.value)}})}function V(e,t){var r=gr(t);for(var n=0;n<r.length;n++){var i=r[n];try{if(i.isInlineSwap(e)){return true}}catch(e){St(e)}}return e==="outerHTML"}function _(e,i,a){var t="#"+i.id;var o="outerHTML";if(e==="true"){}else if(e.indexOf(":")>0){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;e<i.length;e++){var a=i[e].split(":",2);var o=a[0];if(o.indexOf("#")===0){o=o.substring(1)}var s=a[1]||"true";var l=t.querySelector("#"+o);if(l){_(s,l,r)}}}K(O(t,"[hx-swap-oob], [data-hx-swap-oob]"),function(e){var t=G(e,"hx-swap-oob");if(t!=null){_(t,e,r)}})}function se(e){K(O(e,"[hx-preserve], [data-hx-preserve]"),function(e){var t=G(e,"id");var r=J().getElementById(t);if(r!=null){e.parentNode.replaceChild(r,e)}})}function le(n,e,i){K(e.querySelectorAll("[id]"),function(e){if(e.id&&e.id.length>0){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<e.length){t=(t<<5)-t+e.charCodeAt(r++)|0}return t}function de(e){var t=0;for(var r=0;r<e.attributes.length;r++){var n=e.attributes[r];if(n.value){t=he(n.name,t);t=he( |