* eoy wrapped
This commit is contained in:
Henri Dickson 2023-12-22 23:59:48 -05:00 committed by GitHub
parent 7c6a0a5e3e
commit f3520b21df
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 645 additions and 5 deletions

View file

@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType
from .book.models import Edition, EditionInSchema, EditionSchema, Series, Work
from .collection.models import Collection as CatalogCollection
from .common.models import (
AvailableItemCategory,
ExternalResource,
IdType,
Item,

File diff suppressed because one or more lines are too long

View file

@ -310,6 +310,7 @@
_privateMethods.init(configs);
// return false;
}
alert('x');
(function () {
new inputTags({
container: document.getElementsByClassName("tag-input")[0],

View file

@ -42,7 +42,14 @@
<div class="sortable">
{% if request.user.is_authenticated %}
<section class="entity-sort shelf" id="calendar_grid">
<h5>书影音日历</h5>
<h5>
书影音日历
{% if year %}
<small>
<a href="{% url 'journal:wrapped' year %}">{{ year }} 年度统计</a>
</small>
{% endif %}
</h5>
<div class="calendar_view cards">
<p style="text-align: center;">
<i class="fa-solid fa-compact-disc fa-spin loading"></i>

View file

@ -0,0 +1,139 @@
{% load static %}
{% load i18n %}
{% load l10n %}
{% load humanize %}
{% load admin_url %}
{% load mastodon %}
{% load oauth_token %}
{% load truncate %}
{% load thumb %}
{% load collection %}
{% load user_actions %}
<!DOCTYPE html>
<html lang="zh" class="classic-page">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ site_name }} - {{ identity.display_name }} - {{ year }} 年度统计</title>
{% include "common_libs.html" %}
{% comment %} <script src="https://unpkg.com/rough-viz@2.0.5"></script> {% endcomment %}
<script src="{% static 'js/roughviz.umd.js' %}"></script>
{% comment %} <script src="{% static 'js/saveSvgAsPng.js' %}"></script> {% endcomment %}
<script src="https://cdn.jsdelivr.net/npm/save-svg-as-png@1.4.17/lib/saveSvgAsPng.min.js"></script>
<style>
.yAxisviz0, .rough-yAxisviz0 {
display: none;
}
.xAxisviz0 {
{% comment %} opacity: 0.3; {% endcomment %}
}
</style>
</head>
<body>
{% include "_header.html" %}
<main>
<div class="grid__main">
<span class="action">
<span>
<a onclick="restyle()" title="换样子"><i class="fa-solid fa-shuffle"></i></a>
</span>
<span>
<a hx-get="{% url 'journal:wrapped_share' year %}"
hx-target="body"
hx-swap="beforeend"
title="转发到时间轴"><i class="fa-solid fa-share-from-square"></i></a>
</span>
<span>
<a onclick="saveSvgAsPng($('#viz0').children('svg')[0], 'wrapped.png');"
title="下载图片"><i class="fa-solid fa-download"></i></a>
</span>
</span>
<h5>{{ year }} 年度统计</h5>
<div id="viz0" style="max-width: 100%; aspect-ratio: 1 / 1;"></div>
{{ by_cat|json_script:"cat-data" }}
{{ monthly|json_script:"mon-data" }}
{{ data|json_script:"data" }}
<script>
var cats = JSON.parse(document.getElementById('cat-data').textContent);
var data = JSON.parse(document.getElementById('data').textContent);
var opts = {
title: "{{ identity.user.mastodon_acct | default:identity.full_handle }} - {{ year }}",
element: '#viz0',
font: 1,
data: data,
labels: "Month",
highlight: "#666",
stackColorMapping: {
'📚': '#B4D2A5',
'🎬': '#7CBDFE',
'📺': '#FED37C',
'💿': '#FEA36D',
'🎮': '#C5A290',
'🎙️': '#9D6AB0',
'🎭': '#FE7C7C',
'x': '#FDDB23',
},
roughness: 1,
fillStyle: 'solid',
margin: { top: 80, left: 20, right: 20, bottom: 80 },
stroke: 1,
padding: 0.2,
color: 'red',
strokeWidth: 1,
axisStrokeWidth: 1,
innerStrokeWidth: 1,
fillWeight: 1,
axisRoughness: 1,
yLabel:"",
};
var viz0 = new roughViz.StackedBar(opts);
viz0.setTitle = function(title) {
$('#viz0').children('svg').css("background-color", "#fbfcfc").css("border-radius", "1rem");
viz0.svg.append("text")
.attr("x", viz0.width / 2)
.attr("y", 0 - viz0.margin.top / 2)
.attr("class", "title")
.attr("text-anchor", "middle")
.style( "font-size", 20)
.style( "font-weight", "bold" )
.style("font-family", viz0.fontFamily)
.style("color", "#000")
.text(title);
viz0.svg.append("text")
.attr("x", viz0.width / 2)
.attr("y", viz0.height + viz0.margin.top -30)
.attr("class", "title")
.attr("text-anchor", "middle")
.style( "font-size", 20 )
.style("font-family", viz0.fontFamily)
.style("color", "#000")
.text(cats);
viz0.svg.append("text")
.attr("x", viz0.width)
.attr("y", viz0.height + viz0.margin.top -10)
.attr("class", "title")
.attr("text-anchor", "end")
.style( "font-size", 10 )
.style("font-family", viz0.fontFamily)
.style("color", "#666")
.text("{{ identity.profile_uri }}");
};
if ($('#viz0')[0].clientHeight < 250) {
$('#viz0')[0].style.height = $('#viz0')[0].clientWidth + 'px';
}
viz0.boundRedraw()
function restyle() {
opts.roughness = Math.random()*4;
opts.fillStyle = ["hachure", "solid", "zigzag", "cross-hatch", "dashed", "zigzag-line"][Math.floor(Math.random() * 6)];
viz0.redraw(opts);
}
</script>
</div>
{% include "_sidebar.html" with show_profile=1 %}
</main>
{% include "_footer.html" %}
</body>
</html>

View file

@ -0,0 +1,80 @@
{% load static %}
{% load i18n %}
{% load l10n %}
{% load humanize %}
{% load admin_url %}
{% load mastodon %}
{% load oauth_token %}
{% load truncate %}
{% load highlight %}
{% load thumb %}
{% load duration %}
<dialog open
_="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>分享年度统计图</strong>
</header>
<div>
<form method="post" action="{% url 'journal:wrapped_share' year %}">
{% csrf_token %}
<input type="hidden" name="img" id="img" value="">
<div style="display: flex;">
<div style="width:6em; margin:1em;">
<img id="preview" alt="preview" style="width:6em;" />
</div>
<div style="width:100%;">
<textarea type="text"
name="comment"
placeholder="分享附言"
style="width:100%;
height:100%">分享 #我的{{year}}书影音</textarea>
</div>
</div>
<div style="margin:0.5em">
<fieldset>
可见性:
<input type="radio"
name="visibility"
value="0"
required
id="id_visibility_0"
checked />
<label for="id_visibility_0">公开</label>
<input type="radio"
name="visibility"
value="1"
required
id="id_visibility_1">
<label for="id_visibility_1">仅关注者</label>
<input type="radio"
name="visibility"
value="2"
required=""
id="id_visibility_2">
<label for="id_visibility_2">仅自己</label>
</fieldset>
</div>
<div>
<input type="submit" value="{% trans '分享' %}">
</div>
</form>
</div>
</article>
<script type="text/javascript">
function share(uri){
const pfx = 'data:image/png;base64,';
$('#preview').attr('src', uri);
if (uri.startsWith(pfx)) {
$('#img').val(uri.substring(pfx.length));
} else {
alert('分享失败');
}
}
svgAsPngUri($('#viz0').children('svg')[0]).then(share);
</script>
</dialog>

View file

@ -154,4 +154,6 @@ urlpatterns = [
name="user_calendar_data",
),
path("users/<str:id>/feed/reviews/", ReviewFeed(), name="review_feed"),
path("wrapped/<int:year>/", WrappedView.as_view(), name="wrapped"),
path("wrapped/<int:year>/share", WrappedShareView.as_view(), name="wrapped_share"),
]

View file

@ -20,3 +20,4 @@ from .post import piece_replies, post_like, post_replies, post_reply, post_unlik
from .profile import profile, user_calendar_data
from .review import ReviewFeed, review_edit, review_retrieve, user_review_list
from .tag import user_tag_edit, user_tag_list, user_tag_member_list
from .wrapped import WrappedShareView, WrappedView

View file

@ -1,3 +1,5 @@
import datetime
from django.contrib.auth.decorators import login_required
from django.core.exceptions import BadRequest, ObjectDoesNotExist, PermissionDenied
from django.http import Http404, HttpResponse, HttpResponseRedirect
@ -71,8 +73,16 @@ def profile(request: AuthedHttpRequest, user_name):
q_piece_visible_to_user(request.user)
)
top_tags = target.tag_manager.public_tags[:10]
year = None
else:
top_tags = target.tag_manager.all_tags[:10]
today = datetime.date.today()
if today.month > 11:
year = today.year
elif today.month < 2:
year = today.year - 1
else:
year = None
return render(
request,
"profile.html",
@ -89,6 +99,7 @@ def profile(request: AuthedHttpRequest, user_name):
],
"liked_collections_count": liked_collections.count(),
"layout": target.preference.profile_layout,
"year": year,
},
)

137
journal/views/wrapped.py Normal file
View file

@ -0,0 +1,137 @@
import base64
import calendar
from typing import Any
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, F
from django.db.models.functions import ExtractMonth
from django.http import HttpRequest, HttpResponseRedirect
from django.http.response import HttpResponse
from django.views.generic.base import TemplateView
from catalog.models import (
AvailableItemCategory,
ItemCategory,
PodcastEpisode,
item_content_types,
)
from journal.models import Comment, ShelfType
from mastodon.api import boost_toot_later, get_toot_visibility, post_toot_later
from takahe.utils import Takahe
from users.models import User
_type_emoji = {
"movie": "🎬",
"music": "💿",
"album": "💿",
"game": "🎮",
"tv": "📺",
"tvshow": "📺",
"tvseason": "📺",
"book": "📚",
"edition": "📚",
"podcast": "🎙️",
"performance": "🎭",
"performanceproduction": "🎭",
}
def _type_to_emoji():
cts = item_content_types()
return {v: _type_emoji.get(k.__name__.lower(), k.__name__) for k, v in cts.items()}
class WrappedView(LoginRequiredMixin, TemplateView):
template_name = "wrapped.html"
def get_context_data(self, **kwargs):
user: User = self.request.user # type: ignore
target = user.identity
year = kwargs.get("year")
context = super().get_context_data(**kwargs)
context["identity"] = target
cnt = {}
cats = []
_item_types = _type_to_emoji()
for cat in AvailableItemCategory:
queryset = target.shelf_manager.get_latest_members(
ShelfType.COMPLETE, ItemCategory(cat)
).filter(created_time__year=year)
cnt[cat] = queryset.count()
if cat.value == "podcast":
pc = (
Comment.objects.filter(
owner=target,
item__polymorphic_ctype_id=item_content_types()[PodcastEpisode],
)
.values("item__podcastepisode__program_id")
.distinct()
.count()
)
cnt[cat] += pc
if cnt[cat] > 0:
cats.append(f"{_type_emoji[cat.value]}x{cnt[cat]}")
context["by_cat"] = " ".join(cats)
all = list(
target.shelf_manager.get_latest_members(ShelfType.COMPLETE)
.filter(created_time__year=year)
.annotate(month=ExtractMonth("created_time"))
.annotate(cat=F("item__polymorphic_ctype_id"))
.values("month", "cat")
.annotate(total=Count("month"))
.order_by("month")
.values_list("month", "cat", "total")
)
data = [{"Month": calendar.month_abbr[m]} for m in range(1, 13)]
for m, ct, cnt in all:
data[m - 1][_item_types[ct]] = data[m - 1].get(_item_types[ct], 0) + cnt
podcast_by_month = list(
Comment.objects.filter(
owner=target,
item__polymorphic_ctype_id=item_content_types()[PodcastEpisode],
)
.filter(created_time__year=year)
.annotate(month=ExtractMonth("created_time"))
.values("item__podcastepisode__program_id", "month")
.distinct()
.annotate(total=Count("month", distinct=True))
.values_list("month", "total")
)
for m, cnt in podcast_by_month:
data[m - 1]["🎙️"] = data[m - 1].get("🎙️", 0) + cnt
context["data"] = data
return context
class WrappedShareView(LoginRequiredMixin, TemplateView):
template_name = "wrapped_share.html"
def post(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
img = base64.b64decode(request.POST.get("img", ""))
comment = request.POST.get("comment", "")
visibility = int(request.POST.get("visibility", 0))
user: User = request.user # type: ignore
identity = user.identity # type: ignore
media = Takahe.upload_image(
identity.pk, "year.png", img, "image/png", "NeoDB Yearly Summary"
)
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
if classic_repost:
post_toot_later(
user,
comment,
get_toot_visibility(visibility, user),
img=img,
img_name="year.png",
img_type="image/png",
)
elif post:
boost_toot_later(user, post.url)
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))

View file

@ -3,6 +3,7 @@ import html
import random
import re
import string
import time
from urllib.parse import quote
import django_rq
@ -129,6 +130,33 @@ def boost_toot_later(user, post_url):
)
def post_toot_later(
user,
content,
visibility,
local_only=False,
update_id=None,
spoiler_text=None,
img=None,
img_name=None,
img_type=None,
):
if user and user.mastodon_token and user.mastodon_site and content:
django_rq.get_queue("fetch").enqueue(
post_toot,
user.mastodon_site,
content,
visibility,
user.mastodon_token,
local_only,
update_id,
spoiler_text,
img,
img_name,
img_type,
)
def post_toot(
site,
content,
@ -137,18 +165,47 @@ def post_toot(
local_only=False,
update_id=None,
spoiler_text=None,
img=None,
img_name=None,
img_type=None,
):
headers = {
"User-Agent": USER_AGENT,
"Authorization": f"Bearer {token}",
"Idempotency-Key": random_string_generator(16),
}
media_id = None
if img and img_name and img_type:
try:
media_id = (
requests.post(
"https://" + get_api_domain(site) + "/api/v1/media",
headers=headers,
data={},
files={"file": (img_name, img, img_type)},
)
.json()
.get("id")
)
ready = False
while ready is False:
time.sleep(3)
j = requests.get(
"https://" + get_api_domain(site) + "/api/v1/media/" + media_id,
headers=headers,
).json()
ready = j.get("url") is not None
except Exception as e:
logger.warning(f"Error uploading image {e}")
headers["Idempotency-Key"] = random_string_generator(16)
response = None
url = "https://" + get_api_domain(site) + API_PUBLISH_TOOT
payload = {
"status": content,
"visibility": visibility,
}
if media_id:
payload["media_ids[]"] = [media_id]
if spoiler_text:
payload["spoiler_text"] = spoiler_text
if local_only:
@ -163,7 +220,8 @@ def post_toot(
response.status_code = 200
if response is not None and response.status_code != 200:
logger.warning(f"Error {url} {response.status_code}")
except Exception:
except Exception as e:
logger.warning(f"Error posting {e}")
response = None
return response

View file

@ -1,3 +1,4 @@
blurhash-python
cachetools
dateparser
discord.py

View file

@ -813,4 +813,77 @@ class Migration(migrations.Migration):
],
},
),
migrations.CreateModel(
name="PostAttachment",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("state", models.CharField(default="new", max_length=100)),
("state_changed", models.DateTimeField(auto_now_add=True)),
("mimetype", models.CharField(max_length=200)),
(
"file",
models.FileField(
blank=True,
null=True,
storage=takahe.models.upload_store,
upload_to=functools.partial(
takahe.models.upload_namer, *("attachments",), **{}
),
),
),
(
"thumbnail",
models.ImageField(
blank=True,
null=True,
storage=takahe.models.upload_store,
upload_to=functools.partial(
takahe.models.upload_namer,
*("attachment_thumbnails",),
**{}
),
),
),
("remote_url", models.CharField(blank=True, max_length=500, null=True)),
("name", models.TextField(blank=True, null=True)),
("width", models.IntegerField(blank=True, null=True)),
("height", models.IntegerField(blank=True, null=True)),
("focal_x", models.FloatField(blank=True, null=True)),
("focal_y", models.FloatField(blank=True, null=True)),
("blurhash", models.TextField(blank=True, null=True)),
("created", models.DateTimeField(auto_now_add=True)),
("updated", models.DateTimeField(auto_now=True)),
(
"author",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="attachments",
to="takahe.identity",
),
),
(
"post",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="attachments",
to="takahe.post",
),
),
],
options={
"db_table": "activities_postattachment",
},
),
]

View file

@ -848,6 +848,7 @@ class Post(models.Model):
"""
interactions: "models.QuerySet[PostInteraction]"
attachments: "models.QuerySet[PostAttachment]"
class Visibilities(models.IntegerChoices):
public = 0
@ -1079,8 +1080,8 @@ class Post(models.Model):
days=settings.FANOUT_LIMIT_DAYS
):
post.state = "fanned_out" # add post quietly if it's old
# if attachments:# FIXME
# post.attachments.set(attachments)
if attachments:
post.attachments.set(attachments)
# if question: # FIXME
# post.type = question["type"]
# post.type_data = PostTypeData(__root__=question).__root__
@ -1121,7 +1122,8 @@ class Post(models.Model):
self.edited = timezone.now()
self.mentions.set(self.mentions_from_content(content, self.author))
self.emojis.set(Emoji.emojis_from_content(content, None))
# self.attachments.set(attachments or []) # fixme
if attachments is not None:
self.attachments.set(attachments or []) # type: ignore
if type_data:
self.type_data = type_data
self.save()
@ -1251,6 +1253,65 @@ class FanOut(models.Model):
updated = models.DateTimeField(auto_now=True)
class PostAttachment(models.Model):
"""
An attachment to a Post. Could be an image, a video, etc.
"""
post = models.ForeignKey(
"takahe.post",
on_delete=models.CASCADE,
related_name="attachments",
blank=True,
null=True,
)
author = models.ForeignKey(
"takahe.Identity",
on_delete=models.CASCADE,
related_name="attachments",
blank=True,
null=True,
)
# state = StateField(graph=PostAttachmentStates)
state = models.CharField(max_length=100, default="new")
state_changed = models.DateTimeField(auto_now_add=True)
mimetype = models.CharField(max_length=200)
# Files may not be populated if it's remote and not cached on our side yet
file = models.FileField(
upload_to=partial(upload_namer, "attachments"),
null=True,
blank=True,
storage=upload_store,
)
thumbnail = models.ImageField(
upload_to=partial(upload_namer, "attachment_thumbnails"),
null=True,
blank=True,
storage=upload_store,
)
remote_url = models.CharField(max_length=500, null=True, blank=True)
# This is the description for images, at least
name = models.TextField(null=True, blank=True)
width = models.IntegerField(null=True, blank=True)
height = models.IntegerField(null=True, blank=True)
focal_x = models.FloatField(null=True, blank=True)
focal_y = models.FloatField(null=True, blank=True)
blurhash = models.TextField(null=True, blank=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
class Meta:
# managed = False
db_table = "activities_postattachment"
class EmojiQuerySet(models.QuerySet):
def usable(self, domain: Domain | None = None):
"""

View file

@ -1,7 +1,11 @@
import io
from typing import TYPE_CHECKING
# import blurhash
from django.conf import settings
from django.core.cache import cache
from django.core.files.images import ImageFile
from PIL import Image
from .models import *
@ -364,6 +368,37 @@ class Takahe:
Block.objects.filter(state="new").update(state="sent")
Block.objects.exclude(state="sent").delete()
@staticmethod
def upload_image(
author_pk: int,
filename: str,
content: bytes,
mimetype: str,
description: str = "",
) -> PostAttachment:
if len(content) > 1024 * 1024 * 5:
raise ValueError("Image too large")
main_file = ImageFile(io.BytesIO(content), name=filename)
resized_image = Image.open(io.BytesIO(content))
resized_image.thumbnail((400, 225), resample=Image.Resampling.BILINEAR)
new_image_bytes = io.BytesIO()
resized_image.save(new_image_bytes, format="webp", save_all=True)
thumbnail_file = ImageFile(new_image_bytes, name="image.webp")
# hash = blurhash.encode(resized_image, 4, 4)
attachment = PostAttachment.objects.create(
mimetype=mimetype,
width=main_file.width,
height=main_file.height,
name=description or None,
state="fetched",
author_id=author_pk,
file=main_file,
thumbnail=thumbnail_file,
# blurhash=hash,
)
attachment.save()
return attachment
@staticmethod
def post(
author_pk: int,
@ -376,6 +411,7 @@ class Takahe:
post_pk: int | None = None,
post_time: datetime.datetime | None = None,
reply_to_pk: int | None = None,
attachments: list | None = None,
) -> Post | None:
identity = Identity.objects.get(pk=author_pk)
post = (
@ -399,6 +435,7 @@ class Takahe:
visibility=visibility,
type_data=data,
published=post_time,
attachments=attachments,
)
else:
post = Post.create_local(
@ -411,6 +448,7 @@ class Takahe:
type_data=data,
published=post_time,
reply_to=reply_to_post,
attachments=attachments,
)
return post
@ -646,6 +684,10 @@ class Takahe:
if not post:
logger.warning(f"Cannot find post {post_pk}")
return
identity = Identity.objects.filter(pk=identity_pk).first()
if not identity:
logger.warning(f"Cannot find identity {identity_pk}")
return
interaction = PostInteraction.objects.get_or_create(
type=type,
identity_id=identity_pk,