support note

This commit is contained in:
Your Name 2024-06-13 20:44:15 -04:00 committed by Henri Dickson
parent e3fe4b1b4d
commit b543ad01ee
31 changed files with 752 additions and 92 deletions

View file

@ -499,7 +499,7 @@ class Item(PolymorphicModel):
return self.title
@classmethod
def get_by_url(cls, url_or_b62: str) -> "Self | None":
def get_by_url(cls, url_or_b62: str, resolve_merge=False) -> "Self | None":
b62 = url_or_b62.strip().split("/")[-1]
if len(b62) not in [21, 22]:
r = re.search(r"[A-Za-z0-9]{21,22}", url_or_b62)
@ -507,6 +507,14 @@ class Item(PolymorphicModel):
b62 = r[0]
try:
item = cls.objects.get(uid=uuid.UUID(int=b62_decode(b62)))
if resolve_merge:
resolve_cnt = 5
while item.merged_to_item and resolve_cnt > 0:
item = item.merged_to_item
resolve_cnt -= 1
if resolve_cnt == 0:
logger.error(f"resolve merge loop for {item}")
item = None
except Exception:
item = None
return item

View file

@ -60,6 +60,40 @@
</p>
{% endfor %}
</section>
<section>
<h5>
{% trans "my notes" %}
{% if mark.shelf %}
<small>
<span class="action inline">
<a href="#"
hx-get="{% url 'journal:note' item.uuid %}"
hx-target="body"
hx-swap="beforeend">
<i class="fa-regular fa-square-plus"></i>
</a>
</span>
</small>
{% endif %}
</h5>
{% for note in mark.notes %}
<span class="action">
<span>
<a href="#"
hx-get="{% url 'journal:note' note.item.uuid note.uuid %}"
hx-target="body"
hx-swap="beforeend"><i class="fa-regular fa-pen-to-square"></i></a>
</span>
{% if note.latest_post %}
{% include "action_like_post.html" with post=note.latest_post %}
{% include "action_boost_post.html" with post=note.latest_post %}
{% include "action_open_post.html" with post=note.latest_post %}
{% endif %}
</span>
<h6>{{ note.title|default:'' }}</h6>
<p>{{ note.content|linebreaks }}</p>
{% endfor %}
</section>
<section>
<h5>
{% trans "my review" %}

View file

@ -13,7 +13,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta property="og:title"
content="{{ site_name }}{% trans item.category.label %} - {{ item.display_title }}">
content="{% trans item.category.label %} - {{ item.display_title }}">
<meta property="og:type" content="{{ item.category }}">
<meta property="og:url" content="{{ item.absolute_url }}">
{% if item.has_cover %}<meta property="og:image" content="{{ item.cover_image_url }}">{% endif %}

View file

@ -8,4 +8,34 @@ from catalog.performance.tests import *
from catalog.podcast.tests import *
from catalog.tv.tests import *
# imported tests with same name might be ignored silently
class CatalogCase(TestCase):
databases = "__all__"
def setUp(self):
self.hyperion_hardcover = Edition.objects.create(title="Hyperion")
self.hyperion_hardcover.pages = 481
self.hyperion_hardcover.isbn = "9780385249492"
self.hyperion_hardcover.save()
self.hyperion_print = Edition.objects.create(title="Hyperion")
self.hyperion_print.pages = 500
self.hyperion_print.isbn = "9780553283686"
self.hyperion_print.save()
self.hyperion_ebook = Edition(title="Hyperion")
self.hyperion_ebook.asin = "B0043M6780"
self.hyperion_ebook.save()
self.andymion_print = Edition.objects.create(title="Andymion", pages=42)
# serie = Serie(title="Hyperion Cantos")
self.hyperion = Work(title="Hyperion")
self.hyperion.save()
def test_merge(self):
self.hyperion_hardcover.merge_to(self.hyperion_print)
self.assertEqual(self.hyperion_hardcover.merged_to_item, self.hyperion_print)
def test_merge_resolve(self):
self.hyperion_hardcover.merge_to(self.hyperion_print)
self.hyperion_print.merge_to(self.hyperion_ebook)
resloved = Item.get_by_url(self.hyperion_hardcover.url, True)
self.assertEqual(resloved, self.hyperion_ebook)

View file

@ -0,0 +1,64 @@
# Generated by Django 4.2.13 on 2024-06-13 13:10
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("users", "0001_initial_0_10"),
("catalog", "0001_initial_0_10"),
("journal", "0001_initial_0_10"),
]
operations = [
migrations.CreateModel(
name="Note",
fields=[
(
"piece_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="journal.piece",
),
),
("visibility", models.PositiveSmallIntegerField(default=0)),
(
"created_time",
models.DateTimeField(default=django.utils.timezone.now),
),
("edited_time", models.DateTimeField(auto_now=True)),
("metadata", models.JSONField(default=dict)),
(
"remote_id",
models.CharField(default=None, max_length=200, null=True),
),
("title", models.TextField(blank=True, default=None, null=True)),
("content", models.TextField()),
("sensitive", models.BooleanField(default=False)),
("attachements", models.JSONField(default=list)),
(
"item",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT, to="catalog.item"
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="users.apidentity",
),
),
],
options={
"abstract": False,
},
bases=("journal.piece",),
),
]

View file

@ -1,6 +1,7 @@
from .collection import Collection, CollectionMember, FeaturedCollection
from .comment import Comment
from .common import (
Content,
Piece,
PieceInteraction,
PiecePost,
@ -14,6 +15,7 @@ from .common import (
from .like import Like
from .mark import Mark
from .mixins import UserOwnedObjectMixin
from .note import Note
from .rating import Rating
from .renderers import render_md
from .review import Review
@ -43,6 +45,7 @@ __all__ = [
"q_piece_visible_to_user",
"Like",
"Mark",
"Note",
"Rating",
"render_md",
"Review",

View file

@ -9,6 +9,7 @@ from catalog.collection.models import Collection as CatalogCollection
from catalog.common import jsondata
from catalog.common.utils import DEFAULT_ITEM_COVER, piece_cover_path
from catalog.models import Item
from takahe.utils import Takahe
from users.models import APIdentity
from .common import Piece
@ -113,11 +114,8 @@ class Collection(List):
Takahe.post_collection(self)
def delete(self, *args, **kwargs):
existing_post = self.latest_post
if existing_post:
from takahe.utils import Takahe
Takahe.delete_posts([existing_post.pk])
if self.local:
Takahe.delete_posts(self.all_post_ids)
return super().delete(*args, **kwargs)
@property

View file

@ -5,6 +5,7 @@ from django.db import models
from django.utils import timezone
from catalog.models import Item
from takahe.utils import Takahe
from users.models import APIdentity
from .common import Content
@ -33,7 +34,7 @@ class Comment(Content):
return d
@classmethod
def update_by_ap_object(cls, owner, item, obj, post_id, visibility):
def update_by_ap_object(cls, owner, item, obj, post):
p = cls.objects.filter(owner=owner, item=item).first()
if p and p.edited_time >= datetime.fromisoformat(obj["updated"]):
return p # incoming ap object is older than what we have, no update needed
@ -45,14 +46,14 @@ class Comment(Content):
"text": content,
"local": False,
"remote_id": obj["id"],
"visibility": visibility,
"visibility": Takahe.visibility_t2n(post.visibility),
"created_time": datetime.fromisoformat(obj["published"]),
"edited_time": datetime.fromisoformat(obj["updated"]),
}
if obj.get("relatedWithItemPosition"):
d["metadata"] = {"position": obj["relatedWithItemPosition"]}
p, _ = cls.objects.update_or_create(owner=owner, item=item, defaults=d)
p.link_post_id(post_id)
p.link_post_id(post.id)
return p
@property

View file

@ -1,18 +1,23 @@
import re
import uuid
from abc import abstractmethod
from datetime import datetime
from functools import cached_property
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any, Self
# from deepmerge import always_merger
from django.conf import settings
from django.core.signing import b62_decode, b62_encode
from django.db import connection, models
from django.db.models import Avg, CharField, Count, Q
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from loguru import logger
from polymorphic.models import PolymorphicModel
from catalog.common.models import AvailableItemCategory, Item, ItemCategory
from catalog.models import item_categories, item_content_types
from mastodon.api import boost_toot_later, delete_toot, delete_toot_later, post_toot2
from takahe.utils import Takahe
from users.models import APIdentity, User
@ -123,6 +128,28 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
"takahe.Post", related_name="pieces", through="PiecePost"
)
def get_mastodon_repost_url(self):
return (self.metadata or {}).get("shared_link")
def set_mastodon_repost_url(self, url: str | None):
metadata = self.metadata or {}
if metadata.get("shared_link", None) == url:
return
if not url:
metadata.pop("shared_link", None)
else:
metadata["shared_link"] = url
self.metadata = metadata
self.save(update_fields=["metadata"])
def delete(self, *args, **kwargs):
if self.local:
Takahe.delete_posts(self.all_post_ids)
toot_url = self.get_mastodon_repost_url()
if toot_url:
delete_toot_later(self.owner.user, toot_url)
return super().delete(*args, **kwargs)
@property
def uuid(self):
return b62_encode(self.uid.int)
@ -139,10 +166,6 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
def api_url(self):
return f"/api/{self.url}" if self.url_path else None
@property
def shared_link(self):
return Takahe.get_post_url(self.latest_post.pk) if self.latest_post else None
@property
def like_count(self):
return (
@ -187,16 +210,13 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
pp = PiecePost.objects.filter(post_id=post_id).first()
return pp.piece if pp else None
@classmethod
def update_by_ap_object(cls, owner, item, obj, post_id, visibility):
raise NotImplementedError("subclass must implement this")
@property
def ap_object(self):
raise NotImplementedError("subclass must implement this")
def link_post_id(self, post_id: int):
PiecePost.objects.get_or_create(piece=self, post_id=post_id)
try:
del self.latest_post_id
del self.latest_post
except AttributeError:
pass
def clear_post_ids(self):
PiecePost.objects.filter(piece=self).delete()
@ -219,6 +239,160 @@ class Piece(PolymorphicModel, UserOwnedObjectMixin):
)
return post_ids
@property
def ap_object(self):
raise NotImplementedError("subclass must implement this")
@classmethod
@abstractmethod
def params_from_ap_object(cls, post, obj, piece):
return {}
@abstractmethod
def to_post_params(self):
return {}
@abstractmethod
def to_mastodon_params(self):
return {}
@classmethod
def update_by_ap_object(cls, owner: APIdentity, item: Item, obj, post: Post):
"""
Create or update a content piece with related AP message
"""
p = cls.get_by_post_id(post.id)
if p and p.owner.pk != post.author_id:
logger.warning(f"Owner mismatch: {p.owner.pk} != {post.author_id}")
return
local = post.local
visibility = Takahe.visibility_t2n(post.visibility)
d = cls.params_from_ap_object(post, obj, p)
if p:
# update existing piece
edited = post.edited if local else datetime.fromisoformat(obj["updated"])
if p.edited_time >= edited:
# incoming ap object is older than what we have, no update needed
return p
d["edited_time"] = edited
for k, v in d.items():
setattr(p, k, v)
p.save(update_fields=d.keys())
else:
# no previously linked piece, create a new one and link to post
d.update(
{
"item": item,
"owner": owner,
"local": post.local,
"visibility": visibility,
"remote_id": None if local else obj["id"],
}
)
if local:
d["created_time"] = post.published
d["edited_time"] = post.edited or post.published
else:
d["created_time"] = datetime.fromisoformat(obj["published"])
d["edited_time"] = datetime.fromisoformat(obj["updated"])
p = cls.objects.create(**d)
p.link_post_id(post.id)
if local:
# a local piece is reconstructred from a post, update post and fanout
if not post.type_data:
post.type_data = {}
# always_merger.merge(
# post.type_data,
# {
# "object": {
# "tag": [item.ap_object_ref],
# "relatedWith": [p.ap_object],
# }
# },
# )
post.type_data = {
"object": {
"tag": [item.ap_object_ref],
"relatedWith": [p.ap_object],
}
}
post.save(update_fields=["type_data"])
Takahe.update_state(post, "edited")
return p
def sync_to_mastodon(self, delete_existing=False):
user = self.owner.user
if not user.mastodon_site:
return
if user.preference.mastodon_repost_mode == 1:
if delete_existing:
self.delete_mastodon_repost()
return self.repost_to_mastodon()
elif self.latest_post:
return boost_toot_later(user, self.latest_post.url)
else:
logger.warning("No post found for piece")
return False, 404
def delete_mastodon_repost(self):
toot_url = self.get_mastodon_repost_url()
if toot_url:
self.set_mastodon_repost_url(None)
delete_toot(self.owner.user, toot_url)
def repost_to_mastodon(self):
user = self.owner.user
d = {
"user": user,
"visibility": self.visibility,
"update_toot_url": self.get_mastodon_repost_url(),
}
d.update(self.to_mastodon_params())
response = post_toot2(**d)
if response is not None and response.status_code in [200, 201]:
j = response.json()
if "url" in j:
metadata = {"shared_link": j["url"]}
if self.metadata != metadata:
self.metadata = metadata
self.save(update_fields=["metadata"])
return True, 200
else:
logger.warning(response)
return False, response.status_code if response is not None else -1
def sync_to_timeline(self, delete_existing=False):
user = self.owner.user
v = Takahe.visibility_n2t(self.visibility, user.preference.post_public_mode)
existing_post = self.latest_post
if existing_post and existing_post.state in ["deleted", "deleted_fanned_out"]:
existing_post = None
elif existing_post and delete_existing:
Takahe.delete_posts([existing_post.pk])
existing_post = None
params = {
"author_pk": self.owner.pk,
"visibility": v,
"post_pk": existing_post.pk if existing_post else None,
"post_time": self.created_time, # type:ignore subclass must have this
"edit_time": self.edited_time, # type:ignore subclass must have this
"data": {
"object": {
"tag": (
[self.item.ap_object_ref] # type:ignore
if hasattr(self, "item")
else []
),
"relatedWith": [self.ap_object],
}
},
}
params.update(self.to_post_params())
post = Takahe.post(**params)
if post and post != existing_post:
self.link_post_id(post.pk)
return post
class PiecePost(models.Model):
post_id: int

View file

@ -26,6 +26,7 @@ from takahe.utils import Takahe
from users.models import APIdentity
from .comment import Comment
from .note import Note
from .rating import Rating
from .review import Review
from .shelf import Shelf, ShelfLogEntry, ShelfManager, ShelfMember, ShelfType
@ -105,6 +106,14 @@ class Mark:
else None
)
@cached_property
def notes(self):
return Note.objects.filter(owner=self.owner, item=self.item)
# post_ids = PiecePost.objects.filter(
# piece__note__owner_id=self.owner.pk, piece__note__item_id=self.item.pk
# ).values_list("post_id", flat=True)
# return Takahe.get_posts(list(post_ids))
@property
def created_time(self) -> datetime | None:
return self.shelfmember.created_time if self.shelfmember else None

87
journal/models/note.py Normal file
View file

@ -0,0 +1,87 @@
from functools import cached_property
from typing import override
from django.db import models
from loguru import logger
from mastodon.api import delete_toot_later
from takahe.utils import Takahe
from .common import Content
from .renderers import render_text
from .shelf import ShelfMember
class Note(Content):
title = models.TextField(blank=True, null=True, default=None)
content = models.TextField(blank=False, null=False)
sensitive = models.BooleanField(default=False, null=False)
attachements = models.JSONField(default=list)
@property
def html(self):
return render_text(self.content)
@property
def ap_object(self):
d = {
"id": self.absolute_url,
"type": "Note",
"title": self.title,
"content": self.content,
"sensitive": self.sensitive,
"published": self.created_time.isoformat(),
"updated": self.edited_time.isoformat(),
"attributedTo": self.owner.actor_uri,
"withRegardTo": self.item.absolute_url,
"href": self.absolute_url,
}
return d
@override
@classmethod
def params_from_ap_object(cls, post, obj, piece):
return {
"title": obj.get("title", post.summary),
"content": obj.get("content", "").strip(),
"sensitive": obj.get("sensitive", post.sensitive),
# "attachements": obj.get("attachements", []),
}
@override
@classmethod
def update_by_ap_object(cls, owner, item, obj, post):
p = super().update_by_ap_object(owner, item, obj, post)
if (
p
and p.local
and owner.user.preference.mastodon_default_repost
and owner.user.mastodon_username
):
p.sync_to_mastodon()
return p
@cached_property
def shelfmember(self) -> ShelfMember | None:
return ShelfMember.objects.filter(item=self.item, owner=self.owner).first()
def to_mastodon_params(self):
return {
"spoiler_text": self.title,
"content": self.content,
"sensitive": self.sensitive,
# "attachements": self.attachements,
"reply_to_toot_url": (
self.shelfmember.get_mastodon_repost_url() if self.shelfmember else None
),
}
def to_post_params(self):
return {
"summary": self.title,
"content": self.content,
"sensitive": self.sensitive,
"reply_to_pk": (
self.shelfmember.latest_post_id if self.shelfmember else None
),
}

View file

@ -7,6 +7,7 @@ from django.db.models import Avg, Count, Q
from django.utils.translation import gettext_lazy as _
from catalog.models import Item
from takahe.utils import Takahe
from users.models import APIdentity
from .common import Content
@ -39,7 +40,7 @@ class Rating(Content):
}
@classmethod
def update_by_ap_object(cls, owner, item, obj, post_id, visibility):
def update_by_ap_object(cls, owner, item, obj, post):
p = cls.objects.filter(owner=owner, item=item).first()
if p and p.edited_time >= datetime.fromisoformat(obj["updated"]):
return p # incoming ap object is older than what we have, no update needed
@ -63,12 +64,12 @@ class Rating(Content):
"grade": value,
"local": False,
"remote_id": obj["id"],
"visibility": visibility,
"visibility": Takahe.visibility_t2n(post.visibility),
"created_time": datetime.fromisoformat(obj["published"]),
"edited_time": datetime.fromisoformat(obj["updated"]),
}
p = cls.objects.update_or_create(owner=owner, item=item, defaults=d)[0]
p.link_post_id(post_id)
p.link_post_id(post.id)
return p
@staticmethod

View file

@ -9,6 +9,7 @@ from markdownx.models import MarkdownxField
from catalog.models import Item
from mastodon.api import boost_toot_later, share_review
from takahe.utils import Takahe
from users.models import APIdentity
from .common import Content
@ -52,7 +53,7 @@ class Review(Content):
}
@classmethod
def update_by_ap_object(cls, owner, item, obj, post_id, visibility):
def update_by_ap_object(cls, owner, item, obj, post):
p = cls.objects.filter(owner=owner, item=item).first()
if p and p.edited_time >= datetime.fromisoformat(obj["updated"]):
return p # incoming ap object is older than what we have, no update needed
@ -66,12 +67,12 @@ class Review(Content):
"body": content,
"local": False,
"remote_id": obj["id"],
"visibility": visibility,
"visibility": Takahe.visibility_t2n(post.visibility),
"created_time": datetime.fromisoformat(obj["published"]),
"edited_time": datetime.fromisoformat(obj["updated"]),
}
p, _ = cls.objects.update_or_create(owner=owner, item=item, defaults=d)
p.link_post_id(post_id)
p.link_post_id(post.id)
return p
@cached_property

View file

@ -10,6 +10,7 @@ from django.utils.translation import pgettext_lazy as __
from loguru import logger
from catalog.models import Item, ItemCategory
from takahe.utils import Takahe
from users.models import APIdentity
from .common import q_item_in_category
@ -335,9 +336,7 @@ class ShelfMember(ListMember):
}
@classmethod
def update_by_ap_object(
cls, owner: APIdentity, item: Item, obj: dict, post_id: int, visibility: int
):
def update_by_ap_object(cls, owner: APIdentity, item: Item, obj: dict, post):
p = cls.objects.filter(owner=owner, item=item).first()
if p and p.edited_time >= datetime.fromisoformat(obj["updated"]):
return p # incoming ap object is older than what we have, no update needed
@ -349,12 +348,12 @@ class ShelfMember(ListMember):
"parent": shelf,
"position": 0,
"local": False,
"visibility": visibility,
"visibility": Takahe.visibility_t2n(post.visibility),
"created_time": datetime.fromisoformat(obj["published"]),
"edited_time": datetime.fromisoformat(obj["updated"]),
}
p, _ = cls.objects.update_or_create(owner=owner, item=item, defaults=d)
p.link_post_id(post_id)
p.link_post_id(post.id)
return p
@cached_property

View file

@ -3,18 +3,17 @@
<a target="_blank"
rel="noopener"
href="{{ post.object_uri }}"
onclick="navigator.share({url:'{{ post.object_uri|escapejs }}'});event.preventDefault();"
title="{% trans "link for fediverse" %}">
onclick="navigator.share({url:'{{ post.object_uri|escapejs }}'});event.preventDefault();">
{% if post.visibility == 1 %}
<i class="fa-solid fa-lock-open"></i>
<i class="fa-solid fa-lock-open" title="{% trans "Public" %}"></i>
{% elif post.visibility == 2 %}
<i class="fa-solid fa-lock"></i>
<i class="fa-solid fa-lock" title="{% trans "Followers Only" %}"></i>
{% elif post.visibility == 3 %}
<i class="fa-solid fa-at"></i>
<i class="fa-solid fa-at" title="{% trans "Mentioned Only" %}"></i>
{% elif post.visibility == 4 %}
<i class="fa-solid fa-igloo"></i>
<i class="fa-solid fa-igloo" title="{% trans "Public" %}"></i>
{% else %}
<i class="fa-solid fa-globe"></i>
<i class="fa-solid fa-globe" title="{% trans "Public" %}"></i>
{% endif %}
</a>
</span>

View file

@ -139,13 +139,9 @@
</span>
{% endif %}
<span class="action inline">
<span>
<a target="_blank"
rel="noopener"
href="{{ collection.shared_link }}"
onclick="navigator.share({url:'{{ collection.shared_link|escapejs }}'});event.preventDefault();"
title="link for fediverse"><i class="fa-solid {% if collection.visibility > 0 %} fa-lock {% else %} fa-globe {% endif %}"></i></a>
</span>
{% if collection.latest_post %}
{% include "action_open_post.html" with post=collection.latest_post %}
{% endif %}
<span><a>{% trans "Created date" %}: {{ collection.created_time|date }}</a></span>
</span>
</section>

View file

@ -0,0 +1,71 @@
{% load static %}
{% load i18n %}
{% load l10n %}
{% load humanize %}
{% load mastodon %}
{% load thumb %}
<dialog open
class="mark-editor"
_="on close_dialog add .closing then wait for animationend then remove me">
<article>
<header>
<link to="#"
aria-label="Close"
class="close"
_="on click trigger close_dialog" />
<strong>{% trans 'Note' %} - {{ item.display_title }}</strong>
</header>
<div>
<form action="{% url 'journal:note' item.uuid %}" method="post">
{% csrf_token %}
<input type="hidden" name="uuid" value="{{ note.uuid|default:'' }}">
<textarea name="content" cols="40" rows="10" placeholder="" id="id_content">{{ note.content|default:'' }}</textarea>
<div class="grid">
<div>
<fieldset>
<input type="radio"
name="visibility"
value="0"
required=""
id="id_visibility_0"
{% if note.visibility == 0 or not note %}checked{% endif %}>
<label for="id_visibility_0">{% trans "Public" %}</label>
<input type="radio"
name="visibility"
value="1"
required=""
id="id_visibility_1"
{% if note.visibility == 1 %}checked{% endif %}>
<label for="id_visibility_1">{% trans "Followers Only" %}</label>
<input type="radio"
name="visibility"
value="2"
required=""
id="id_visibility_2"
{% if note.visibility == 2 %}checked{% endif %}>
<label for="id_visibility_2">{% trans "Mentioned Only" %}</label>
</fieldset>
</div>
<div>
<fieldset>
{% if request.user.mastodon_acct %}
<label for="id_share_to_mastodon">
<input role="switch"
type="checkbox"
name="share_to_mastodon"
id="id_share_to_mastodon"
value="1"
{% if request.user.preference.mastodon_default_repost %}checked{% endif %}>
{% trans "Repost to timeline" %}
</label>
{% endif %}
</fieldset>
</div>
</div>
<div>
<input type="submit" class="button float-right" value="{% trans "Save" %}">
</div>
</form>
</div>
</article>
</dialog>

View file

@ -86,13 +86,9 @@
</span>
{% endif %}
<span class="action inline">
<span>
<a target="_blank"
rel="noopener"
href="{{ review.shared_link }}"
onclick="navigator.share({url:'{{ review.shared_link |escapejs }}'});event.preventDefault();"
title="{% trans "link for fediverse" %}"><i class="fa-solid {% if review.visibility > 0 %} fa-lock {% else %} fa-globe {% endif %}"></i></a>
</span>
{% if review.latest_post %}
{% include "action_open_post.html" with post=review.latest_post %}
{% endif %}
<span><a>{{ review.created_time|date }}</a></span>
</span>
</footer>

View file

@ -21,6 +21,8 @@ urlpatterns = [
path("wish/<str:item_uuid>", wish, name="wish"),
path("mark/<str:item_uuid>", mark, name="mark"),
path("comment/<str:item_uuid>", comment, name="comment"),
path("note/<str:item_uuid>", note, name="note"),
path("note/<str:item_uuid>/<str:note_uuid>", note, name="note"),
path("piece/<str:piece_uuid>/replies", piece_replies, name="piece_replies"),
path("post/<int:post_id>/replies", post_replies, name="post_replies"),
path("post/<int:post_id>/reply", post_reply, name="post_reply"),

View file

@ -16,7 +16,7 @@ from .collection import (
user_liked_collection_list,
)
from .common import piece_delete
from .mark import comment, mark, mark_log, user_mark_list, wish
from .mark import comment, mark, mark_log, note, user_mark_list, wish
from .post import (
piece_replies,
post_boost,

View file

@ -16,7 +16,7 @@ from common.utils import AuthedHttpRequest, get_uuid_or_404
from mastodon.api import boost_toot_later, share_comment
from takahe.utils import Takahe
from ..models import Comment, Mark, ShelfManager, ShelfType, TagManager
from ..models import Comment, Mark, Note, ShelfManager, ShelfType, TagManager
from .common import render_list, render_relogin, target_identity_required
PAGE_SIZE = 10
@ -192,6 +192,53 @@ def comment(request: AuthedHttpRequest, item_uuid):
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
@login_required
@require_http_methods(["GET", "POST"])
def note(request: AuthedHttpRequest, item_uuid: str, note_uuid: str = ""):
item = get_object_or_404(Item, uid=get_uuid_or_404(item_uuid))
note_uuid = request.POST.get("uuid", note_uuid)
note = None
content = request.POST.get("content")
if note_uuid:
note = get_object_or_404(
Note, owner=request.user.identity, item=item, uid=get_uuid_or_404(note_uuid)
)
if request.method == "GET":
return render(
request,
"note.html",
{
"item": item,
"note": note,
},
)
else:
if request.POST.get("delete", default=False) or not content:
if not note:
raise Http404(_("Content not found"))
note.delete()
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
share_to_mastodon = bool(request.POST.get("share_to_mastodon", default=False))
visibility = int(request.POST.get("visibility", default=0))
delete_existing_post = False
if note:
delete_existing_post = visibility != note.visibility
note.content = content
note.visibility = visibility
note.save()
else:
note = Note.objects.create(
owner=request.user.identity,
item=item,
content=content,
visibility=visibility,
)
note.sync_to_timeline(delete_existing=delete_existing_post)
if share_to_mastodon:
note.sync_to_mastodon(delete_existing=delete_existing_post)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
def user_mark_list(request: AuthedHttpRequest, user_name, shelf_type, item_category):
return render_list(
request, user_name, "mark", shelf_type=shelf_type, item_category=item_category

View file

@ -123,9 +123,9 @@ class WrappedShareView(LoginRequiredMixin, TemplateView):
)
post = Takahe.post(
identity.pk,
"",
comment,
Takahe.visibility_n2t(visibility, user.preference.post_public_mode),
"",
attachments=[media],
)
classic_repost = user.preference.mastodon_repost_mode == 1

View file

@ -227,6 +227,83 @@ def post_toot(
return response
def delete_toot(user, toot_url):
headers = {
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {user.mastodon_token}",
"Idempotency-Key": random_string_generator(16),
}
toot_id = get_status_id_by_url(toot_url)
url = (
"https://"
+ get_api_domain(user.mastodon_site)
+ API_PUBLISH_TOOT
+ "/"
+ toot_id
)
try:
response = requests.delete(url, headers=headers)
if response.status_code != 200:
logger.warning(f"Error DELETE {url} {response.status_code}")
except Exception as e:
logger.warning(f"Error deleting {e}")
def delete_toot_later(user, toot_url):
if user and user.mastodon_token and user.mastodon_site and toot_url:
django_rq.get_queue("fetch").enqueue(delete_toot, user, toot_url)
def post_toot2(
user,
content,
visibility,
update_toot_url: str | None = None,
reply_to_toot_url: str | None = None,
sensitive: bool = False,
spoiler_text: str | None = None,
):
headers = {
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {user.mastodon_token}",
"Idempotency-Key": random_string_generator(16),
}
response = None
url = "https://" + get_api_domain(user.mastodon_site) + API_PUBLISH_TOOT
payload = {
"status": content,
"visibility": get_toot_visibility(visibility, user),
}
update_id = get_status_id_by_url(update_toot_url)
reply_to_id = get_status_id_by_url(reply_to_toot_url)
if reply_to_id:
payload["in_reply_to_id"] = reply_to_id
# if media_id:
# payload["media_ids[]"] = [media_id]
if spoiler_text:
payload["spoiler_text"] = spoiler_text
if sensitive:
payload["sensitive"] = True
try:
if update_id:
response = put(url + "/" + update_id, headers=headers, data=payload)
if not update_id or (response is not None and response.status_code != 200):
headers["Idempotency-Key"] = random_string_generator(16)
response = post(url, headers=headers, data=payload)
if response is not None and response.status_code != 200:
headers["Idempotency-Key"] = random_string_generator(16)
payload["in_reply_to_id"] = None
response = post(url, headers=headers, data=payload)
if response is not None and response.status_code == 201:
response.status_code = 200
if response is not None and response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
except Exception as e:
logger.warning(f"Error posting {e}")
response = None
return response
def _get_redirect_uris(allow_multiple=True) -> str:
u = settings.SITE_INFO["site_url"] + "/account/login/oauth"
if not allow_multiple:

@ -1 +1 @@
Subproject commit 01cacb7dce19f7fadcda7e629b78b2752d74eece
Subproject commit 03a4f54a8f9bd3d06dd9f466d40d6f5853699923

View file

@ -54,6 +54,7 @@ dependencies = [
"typesense",
"urlman",
"validators",
"deepmerge>=1.1.1",
]
[tool.rye]
@ -66,7 +67,7 @@ dev-dependencies = [
"djlint~=1.34.1",
"isort~=5.13.2",
"lxml-stubs",
"pyright==1.1.366",
"pyright>=1.1.367",
"ruff",
"mkdocs-material>=9.5.25",
]

View file

@ -56,6 +56,7 @@ cryptography==42.0.8
cssbeautifier==1.15.1
# via djlint
dateparser==1.2.0
deepmerge==1.1.1
discord-py==2.3.2
distlib==0.3.8
# via virtualenv
@ -219,7 +220,7 @@ pygments==2.18.0
# via mkdocs-material
pymdown-extensions==10.8.1
# via mkdocs-material
pyright==1.1.366
pyright==1.1.367
python-dateutil==2.9.0.post0
# via dateparser
# via django-auditlog

View file

@ -42,6 +42,7 @@ click==8.1.7
cryptography==42.0.8
# via jwcrypto
dateparser==1.2.0
deepmerge==1.1.1
discord-py==2.3.2
django==4.2.13
# via django-anymail

View file

@ -1,9 +1,20 @@
from time import sleep
from typing import Any
from django.conf import settings
from loguru import logger
from catalog.common import *
from journal.models import Comment, Piece, PieceInteraction, Rating, Review, ShelfMember
from journal.models import (
Comment,
Content,
Note,
Piece,
PieceInteraction,
Rating,
Review,
ShelfMember,
)
from users.models.apidentity import APIdentity
from .models import Follow, Identity, Post, TimelineEvent
@ -28,10 +39,11 @@ _supported_ap_journal_types = {
"Rating": Rating,
"Comment": Comment,
"Review": Review,
"Note": Note,
}
def _parse_items(objects):
def _parse_items(objects) -> list[dict[str, Any]]:
logger.debug(f"Parsing item links from {objects}")
if not objects:
return []
@ -40,7 +52,7 @@ def _parse_items(objects):
return items
def _parse_piece_objects(objects):
def _parse_piece_objects(objects) -> list[dict[str, Any]]:
logger.debug(f"Parsing pieces from {objects}")
if not objects:
return []
@ -54,10 +66,15 @@ def _parse_piece_objects(objects):
return pieces
def _get_or_create_item(item_obj):
def _get_or_create_item(item_obj) -> Item | None:
logger.debug(f"Fetching item by ap from {item_obj}")
typ = item_obj["type"]
url = item_obj["href"]
if url.startswith(settings.SITE_INFO["site_url"]):
item = Item.get_by_url(url, True)
if not item:
logger.warning(f"Item not found for {url}")
return item
if typ in ["TVEpisode", "PodcastEpisode"]:
# TODO support episode item
# match and fetch parent item first
@ -74,25 +91,23 @@ def _get_or_create_item(item_obj):
return item
def _get_visibility(post_visibility):
match post_visibility:
case 2:
return 1
case 3:
return 2
case _:
return 0
def post_created(pk, post_data):
return post_fetched(pk, post_data)
def post_fetched(pk, obj):
def post_edited(pk, post_data):
return post_fetched(pk, post_data)
def post_fetched(pk, post_data):
post = Post.objects.get(pk=pk)
owner = Takahe.get_or_create_remote_apidentity(post.author)
if not post.type_data:
if not post.type_data and not post_data:
logger.warning(f"Post {post} has no type_data")
return
ap_object = post.type_data.get("object", {})
items = _parse_items(ap_object.get("tag"))
pieces = _parse_piece_objects(ap_object.get("relatedWith"))
ap_objects = post_data or post.type_data.get("object", {})
items = _parse_items(ap_objects.get("tag"))
pieces = _parse_piece_objects(ap_objects.get("relatedWith"))
logger.info(f"Post {post} has items {items} and pieces {pieces}")
if len(items) == 0:
logger.warning(f"Post {post} has no remote items")
@ -109,17 +124,20 @@ def post_fetched(pk, obj):
if not cls:
logger.warning(f'Unknown link type {p["type"]}')
continue
cls.update_by_ap_object(owner, item, p, pk, _get_visibility(post.visibility))
cls.update_by_ap_object(owner, item, p, post)
def post_deleted(pk, obj):
for piece in Piece.objects.filter(posts__id=pk, local=False):
def post_deleted(pk, post_data):
for piece in Piece.objects.filter(posts__id=pk):
if piece.local and piece.__class__ != Note:
# no delete other than Note, for backward compatibility, should reconsider later
return
# delete piece if the deleted post is the most recent one for the piece
if piece.latest_post_id == pk:
logger.debug(f"Deleting remote piece {piece}")
logger.debug(f"Deleting piece {piece}")
piece.delete()
else:
logger.debug(f"Matched remote piece {piece} has newer posts, not deleting")
logger.debug(f"Matched piece {piece} has newer posts, not deleting")
def post_interacted(interaction_pk, interaction, post_pk, identity_pk):

View file

@ -310,6 +310,11 @@ class Migration(migrations.Migration):
),
("state", models.CharField(default="new", max_length=100)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("state_next_attempt", models.DateTimeField(blank=True, null=True)),
(
"state_locked_until",
models.DateTimeField(blank=True, db_index=True, null=True),
),
("local", models.BooleanField()),
(
"object_uri",

View file

@ -900,6 +900,7 @@ class Post(models.Model):
"""
if TYPE_CHECKING:
author_id: int
interactions: "models.QuerySet[PostInteraction]"
attachments: "models.QuerySet[PostAttachment]"
@ -933,6 +934,8 @@ class Post(models.Model):
# state = StateField(PostStates)
state = models.CharField(max_length=100, default="new")
state_changed = models.DateTimeField(auto_now_add=True)
state_next_attempt = models.DateTimeField(blank=True, null=True)
state_locked_until = models.DateTimeField(null=True, blank=True, db_index=True)
# If it is our post or not
local = models.BooleanField()
@ -1090,6 +1093,7 @@ class Post(models.Model):
attachments: list | None = None,
type_data: dict | None = None,
published: datetime.datetime | None = None,
edited: datetime.datetime | None = None,
) -> "Post":
with transaction.atomic():
# Find mentions in this post
@ -1118,6 +1122,8 @@ class Post(models.Model):
"hashtags": hashtags,
"in_reply_to": reply_to.object_uri if reply_to else None,
}
if edited:
post_obj["edited"] = edited
if published:
_delta = timezone.now() - published
if _delta > datetime.timedelta(0):
@ -1161,6 +1167,7 @@ class Post(models.Model):
attachment_attributes: list | None = None,
type_data: dict | None = None,
published: datetime.datetime | None = None,
edited: datetime.datetime | None = None,
):
with transaction.atomic():
# Strip all HTML and apply linebreaks filter
@ -1173,7 +1180,7 @@ class Post(models.Model):
self.summary = summary or None
self.sensitive = bool(summary) if sensitive is None else sensitive
self.visibility = visibility
self.edited = timezone.now()
self.edited = edited or timezone.now()
self.mentions.set(self.mentions_from_content(content, self.author))
self.emojis.set(Emoji.emojis_from_content(content, None))
if attachments is not None:
@ -1232,7 +1239,9 @@ class Post(models.Model):
type=PostInteraction.Types.boost,
state__in=["new", "fanned_out"],
).count(),
"replies": Post.objects.filter(in_reply_to=self.object_uri).count(),
"replies": Post.objects.filter(in_reply_to=self.object_uri)
.exclude(state__in=["deleted", "deleted_fanned_out"])
.count(),
}
if save:
self.save()

View file

@ -442,14 +442,15 @@ class Takahe:
@staticmethod
def post(
author_pk: int,
pre_conetent: str,
content: str,
visibility: Visibilities,
pre_conetent: str = "",
summary: str | None = None,
sensitive: bool = False,
data: dict | None = None,
post_pk: int | None = None,
post_time: datetime.datetime | None = None,
edit_time: datetime.datetime | None = None,
reply_to_pk: int | None = None,
attachments: list | None = None,
) -> Post | None:
@ -475,6 +476,7 @@ class Takahe:
visibility=visibility,
type_data=data,
published=post_time,
edited=edit_time,
attachments=attachments,
)
else:
@ -487,6 +489,7 @@ class Takahe:
visibility=visibility,
type_data=data,
published=post_time,
edited=edit_time,
reply_to=reply_to_post,
attachments=attachments,
)
@ -509,9 +512,24 @@ class Takahe:
post = Post.objects.filter(pk=post_pk).first() if post_pk else None
return post.object_uri if post else None
@staticmethod
def update_post(post_pk, **kwargs):
Post.objects.filter(pk=post_pk).update(**kwargs)
@staticmethod
def delete_posts(post_pks):
parent_posts = list(
Post.objects.filter(
object_uri__in=Post.objects.filter(
pk__in=post_pks, in_reply_to__isnull=False
)
.distinct("in_reply_to")
.values_list("in_reply_to", flat=True)
)
)
Post.objects.filter(pk__in=post_pks).update(state="deleted")
for post in parent_posts:
post.calculate_stats()
# TimelineEvent.objects.filter(subject_post__in=[post.pk]).delete()
PostInteraction.objects.filter(post__in=post_pks).update(state="undone")
@ -538,6 +556,16 @@ class Takahe:
else:
return Takahe.Visibilities.public
@staticmethod
def visibility_t2n(visibility: int) -> int:
match visibility:
case 2:
return 1
case 3:
return 2
case _:
return 0
@staticmethod
def post_collection(collection: "Collection"):
existing_post = collection.latest_post
@ -573,9 +601,9 @@ class Takahe:
}
post = Takahe.post(
collection.owner.pk,
pre_conetent,
content,
visibility,
pre_conetent,
None,
False,
data,
@ -621,9 +649,9 @@ class Takahe:
existing_post = None if share_as_new_post else comment.latest_post
post = Takahe.post(
comment.owner.pk,
pre_conetent,
content,
v,
pre_conetent,
spoiler,
spoiler is not None,
data,
@ -668,9 +696,9 @@ class Takahe:
existing_post = None if share_as_new_post else review.latest_post
post = Takahe.post( # TODO post as Article?
review.owner.pk,
pre_conetent,
content,
v,
pre_conetent,
None,
False,
data,
@ -712,9 +740,9 @@ class Takahe:
)
post = Takahe.post(
mark.owner.pk,
pre_conetent,
content + append_content,
v,
pre_conetent,
spoiler,
spoiler is not None,
data,
@ -768,7 +796,7 @@ class Takahe:
def reply_post(
post_pk: int, identity_pk: int, content: str, visibility: Visibilities
):
return Takahe.post(identity_pk, "", content, visibility, reply_to_pk=post_pk)
return Takahe.post(identity_pk, content, visibility, reply_to_pk=post_pk)
@staticmethod
def boost_post(post_pk: int, identity_pk: int):
@ -859,7 +887,7 @@ class Takahe:
return FediverseHtmlParser(linebreaks_filter(txt)).html
@staticmethod
def update_state(obj, state):
def update_state(obj: Post | Relay, state: str):
obj.state = state
obj.state_changed = timezone.now()
obj.state_next_attempt = None