diff --git a/catalog/common/models.py b/catalog/common/models.py
index e55383dd..95c81a73 100644
--- a/catalog/common/models.py
+++ b/catalog/common/models.py
@@ -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
diff --git a/catalog/templates/_item_user_pieces.html b/catalog/templates/_item_user_pieces.html
index ea9f96a3..e961789d 100644
--- a/catalog/templates/_item_user_pieces.html
+++ b/catalog/templates/_item_user_pieces.html
@@ -60,6 +60,40 @@
{% endfor %}
+
+
+ {% trans "my notes" %}
+ {% if mark.shelf %}
+
+
+
+
+
+
+
+ {% endif %}
+
+ {% for note in mark.notes %}
+
+
+
+
+ {% 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 %}
+
+
{{ note.title|default:'' }}
+
{{ note.content|linebreaks }}
+ {% endfor %}
+
{% trans "my review" %}
diff --git a/catalog/templates/item_base.html b/catalog/templates/item_base.html
index cd4dcd3c..b54fe218 100644
--- a/catalog/templates/item_base.html
+++ b/catalog/templates/item_base.html
@@ -13,7 +13,7 @@
+ content="{% trans item.category.label %} - {{ item.display_title }}">
{% if item.has_cover %}{% endif %}
diff --git a/catalog/tests.py b/catalog/tests.py
index 1d386a44..6935f405 100644
--- a/catalog/tests.py
+++ b/catalog/tests.py
@@ -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)
diff --git a/journal/migrations/0002_note.py b/journal/migrations/0002_note.py
new file mode 100644
index 00000000..ef33ee41
--- /dev/null
+++ b/journal/migrations/0002_note.py
@@ -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",),
+ ),
+ ]
diff --git a/journal/models/__init__.py b/journal/models/__init__.py
index fa71eb3a..e4d0de3b 100644
--- a/journal/models/__init__.py
+++ b/journal/models/__init__.py
@@ -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",
diff --git a/journal/models/collection.py b/journal/models/collection.py
index b7155530..7383b777 100644
--- a/journal/models/collection.py
+++ b/journal/models/collection.py
@@ -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
diff --git a/journal/models/comment.py b/journal/models/comment.py
index 68d5b256..b3ed0940 100644
--- a/journal/models/comment.py
+++ b/journal/models/comment.py
@@ -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
diff --git a/journal/models/common.py b/journal/models/common.py
index 1c6921e1..5f7404ab 100644
--- a/journal/models/common.py
+++ b/journal/models/common.py
@@ -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
diff --git a/journal/models/mark.py b/journal/models/mark.py
index b70bd915..9b7cb88d 100644
--- a/journal/models/mark.py
+++ b/journal/models/mark.py
@@ -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
diff --git a/journal/models/note.py b/journal/models/note.py
new file mode 100644
index 00000000..671e9059
--- /dev/null
+++ b/journal/models/note.py
@@ -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
+ ),
+ }
diff --git a/journal/models/rating.py b/journal/models/rating.py
index 263b7d55..70803d85 100644
--- a/journal/models/rating.py
+++ b/journal/models/rating.py
@@ -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
diff --git a/journal/models/review.py b/journal/models/review.py
index 22468002..8120a8bb 100644
--- a/journal/models/review.py
+++ b/journal/models/review.py
@@ -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
diff --git a/journal/models/shelf.py b/journal/models/shelf.py
index 7959dc7d..2a496e7b 100644
--- a/journal/models/shelf.py
+++ b/journal/models/shelf.py
@@ -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
diff --git a/journal/templates/action_open_post.html b/journal/templates/action_open_post.html
index 2439d552..f1681e76 100644
--- a/journal/templates/action_open_post.html
+++ b/journal/templates/action_open_post.html
@@ -3,18 +3,17 @@
+ onclick="navigator.share({url:'{{ post.object_uri|escapejs }}'});event.preventDefault();">
{% if post.visibility == 1 %}
-
+
{% elif post.visibility == 2 %}
-
+
{% elif post.visibility == 3 %}
-
+
{% elif post.visibility == 4 %}
-
+
{% else %}
-
+
{% endif %}
diff --git a/journal/templates/collection.html b/journal/templates/collection.html
index 71022317..a634e1ad 100644
--- a/journal/templates/collection.html
+++ b/journal/templates/collection.html
@@ -139,13 +139,9 @@
{% endif %}
-
-
-
+ {% if collection.latest_post %}
+ {% include "action_open_post.html" with post=collection.latest_post %}
+ {% endif %}
{% trans "Created date" %}: {{ collection.created_time|date }}
diff --git a/journal/templates/note.html b/journal/templates/note.html
new file mode 100644
index 00000000..88085cd0
--- /dev/null
+++ b/journal/templates/note.html
@@ -0,0 +1,71 @@
+{% load static %}
+{% load i18n %}
+{% load l10n %}
+{% load humanize %}
+{% load mastodon %}
+{% load thumb %}
+
diff --git a/journal/templates/review.html b/journal/templates/review.html
index 5760b0d1..c5f29f23 100644
--- a/journal/templates/review.html
+++ b/journal/templates/review.html
@@ -86,13 +86,9 @@
{% endif %}
-
-
-
+ {% if review.latest_post %}
+ {% include "action_open_post.html" with post=review.latest_post %}
+ {% endif %}
{{ review.created_time|date }}
diff --git a/journal/urls.py b/journal/urls.py
index a3c712bc..2fd9db3a 100644
--- a/journal/urls.py
+++ b/journal/urls.py
@@ -21,6 +21,8 @@ urlpatterns = [
path("wish/", wish, name="wish"),
path("mark/", mark, name="mark"),
path("comment/", comment, name="comment"),
+ path("note/", note, name="note"),
+ path("note//", note, name="note"),
path("piece//replies", piece_replies, name="piece_replies"),
path("post//replies", post_replies, name="post_replies"),
path("post//reply", post_reply, name="post_reply"),
diff --git a/journal/views/__init__.py b/journal/views/__init__.py
index f7782e82..4c6b754b 100644
--- a/journal/views/__init__.py
+++ b/journal/views/__init__.py
@@ -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,
diff --git a/journal/views/mark.py b/journal/views/mark.py
index 582ea967..7ca829f6 100644
--- a/journal/views/mark.py
+++ b/journal/views/mark.py
@@ -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
diff --git a/journal/views/wrapped.py b/journal/views/wrapped.py
index 5cae4160..391014ba 100644
--- a/journal/views/wrapped.py
+++ b/journal/views/wrapped.py
@@ -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
diff --git a/mastodon/api.py b/mastodon/api.py
index 9e56ebae..4fdede0a 100644
--- a/mastodon/api.py
+++ b/mastodon/api.py
@@ -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:
diff --git a/neodb-takahe b/neodb-takahe
index 01cacb7d..03a4f54a 160000
--- a/neodb-takahe
+++ b/neodb-takahe
@@ -1 +1 @@
-Subproject commit 01cacb7dce19f7fadcda7e629b78b2752d74eece
+Subproject commit 03a4f54a8f9bd3d06dd9f466d40d6f5853699923
diff --git a/pyproject.toml b/pyproject.toml
index 662eb12f..d73740c7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
]
diff --git a/requirements-dev.lock b/requirements-dev.lock
index 22cc7892..a8d1ff4f 100644
--- a/requirements-dev.lock
+++ b/requirements-dev.lock
@@ -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
diff --git a/requirements.lock b/requirements.lock
index ef0e3cd5..e4178c6f 100644
--- a/requirements.lock
+++ b/requirements.lock
@@ -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
diff --git a/takahe/ap_handlers.py b/takahe/ap_handlers.py
index 0b401a47..aae46630 100644
--- a/takahe/ap_handlers.py
+++ b/takahe/ap_handlers.py
@@ -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):
diff --git a/takahe/migrations/0001_initial.py b/takahe/migrations/0001_initial.py
index 05d80b28..cb8f1c88 100644
--- a/takahe/migrations/0001_initial.py
+++ b/takahe/migrations/0001_initial.py
@@ -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",
diff --git a/takahe/models.py b/takahe/models.py
index ee4889ad..fbe4254a 100644
--- a/takahe/models.py
+++ b/takahe/models.py
@@ -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()
diff --git a/takahe/utils.py b/takahe/utils.py
index fdffaa47..c2e63d00 100644
--- a/takahe/utils.py
+++ b/takahe/utils.py
@@ -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