mirror of
https://git.sr.ht/~tsileo/microblog.pub
synced 2024-12-22 13:14:28 +00:00
Question/poll support
This commit is contained in:
parent
4c0e81224f
commit
be7648c9ed
7 changed files with 361 additions and 41 deletions
|
@ -1,3 +1,4 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
|
@ -73,6 +74,12 @@ def ensure_it_is_me(f):
|
|||
return wrapper
|
||||
|
||||
|
||||
def _answer_key(choice: str) -> str:
|
||||
h = hashlib.new("sha1")
|
||||
h.update(choice.encode())
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
class Box(Enum):
|
||||
INBOX = "inbox"
|
||||
OUTBOX = "outbox"
|
||||
|
@ -106,13 +113,17 @@ class MicroblogPubBackend(Backend):
|
|||
|
||||
def save(self, box: Box, activity: ap.BaseActivity) -> None:
|
||||
"""Custom helper for saving an activity to the DB."""
|
||||
is_public = True
|
||||
if activity.has_type(ap.ActivityType.CREATE) and not activity.is_public():
|
||||
is_public = False
|
||||
|
||||
DB.activities.insert_one(
|
||||
{
|
||||
"box": box.value,
|
||||
"activity": activity.to_dict(),
|
||||
"type": _to_list(activity.type),
|
||||
"remote_id": activity.id,
|
||||
"meta": {"undo": False, "deleted": False},
|
||||
"meta": {"undo": False, "deleted": False, "public": is_public},
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -453,6 +464,46 @@ class MicroblogPubBackend(Backend):
|
|||
{"$inc": {"meta.count_reply": -1, "meta.count_direct_reply": -1}},
|
||||
)
|
||||
|
||||
def _process_question_reply(self, create: ap.Create, question: ap.Question) -> None:
|
||||
choice = create.get_object().name
|
||||
|
||||
# Ensure it's a valid choice
|
||||
if choice not in [
|
||||
c["name"] for c in question._data.get("oneOf", question.anyOf)
|
||||
]:
|
||||
logger.info("invalid choice")
|
||||
return
|
||||
|
||||
# Check for duplicate votes
|
||||
if DB.activities.find_one(
|
||||
{
|
||||
"activity.object.actor": create.get_actor().id,
|
||||
"meta.answer_to": question.id,
|
||||
}
|
||||
):
|
||||
logger.info("duplicate response")
|
||||
return
|
||||
|
||||
# Update the DB
|
||||
answer_key = _answer_key(choice)
|
||||
|
||||
DB.activities.update_one(
|
||||
{"activity.object.id": question.id},
|
||||
{
|
||||
"$inc": {
|
||||
"meta.question_replies": 1,
|
||||
f"meta.question_answers.{answer_key}": 1,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
DB.activities.update_one(
|
||||
{"remote_id": create.id},
|
||||
{"$set": {"meta.answer_to": question.id, "meta.stream": False}},
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
@ensure_it_is_me
|
||||
def _handle_replies(self, as_actor: ap.Person, create: ap.Create) -> None:
|
||||
"""Go up to the root reply, store unknown replies in the `threads` DB and set the "meta.thread_root_parent"
|
||||
|
@ -465,6 +516,26 @@ class MicroblogPubBackend(Backend):
|
|||
root_reply = in_reply_to
|
||||
reply = ap.fetch_remote_activity(root_reply)
|
||||
|
||||
# Ensure the this is a local reply, of a question, with a direct "to" addressing
|
||||
if (
|
||||
reply.id.startswith(BASE_URL)
|
||||
and reply.has_type(ap.ActivityType.QUESTION.value)
|
||||
and _to_list(create.get_object().to)[0].startswith(BASE_URL)
|
||||
and not create.is_public()
|
||||
):
|
||||
return self._process_question_reply(create, reply)
|
||||
elif (
|
||||
create.id.startswith(BASE_URL)
|
||||
and reply.has_type(ap.ActivityType.QUESTION.value)
|
||||
and not create.is_public()
|
||||
):
|
||||
# Keep track of our own votes
|
||||
DB.activities.update_one(
|
||||
{"activity.object.id": reply.id, "box": "inbox"},
|
||||
{"$set": {"meta.voted_for": create.get_object().name}},
|
||||
)
|
||||
return None
|
||||
|
||||
creply = DB.activities.find_one_and_update(
|
||||
{"activity.object.id": in_reply_to},
|
||||
{"$inc": {"meta.count_reply": 1, "meta.count_direct_reply": 1}},
|
||||
|
|
161
app.py
161
app.py
|
@ -42,6 +42,7 @@ from little_boxes import activitypub as ap
|
|||
from little_boxes.activitypub import ActivityType
|
||||
from little_boxes.activitypub import _to_list
|
||||
from little_boxes.activitypub import clean_activity
|
||||
from little_boxes.activitypub import format_datetime
|
||||
from little_boxes.activitypub import get_backend
|
||||
from little_boxes.content_helper import parse_markdown
|
||||
from little_boxes.linked_data_sig import generate_signature
|
||||
|
@ -65,6 +66,7 @@ import config
|
|||
|
||||
from activitypub import Box
|
||||
from activitypub import embed_collection
|
||||
from activitypub import _answer_key
|
||||
from config import USER_AGENT
|
||||
from config import ADMIN_API_KEY
|
||||
from config import BASE_URL
|
||||
|
@ -129,16 +131,21 @@ def verify_pass(pwd):
|
|||
def inject_config():
|
||||
q = {
|
||||
"type": "Create",
|
||||
"activity.object.type": "Note",
|
||||
"activity.object.inReplyTo": None,
|
||||
"meta.deleted": False,
|
||||
"meta.public": True,
|
||||
}
|
||||
notes_count = DB.activities.find(
|
||||
{"box": Box.OUTBOX.value, "$or": [q, {"type": "Announce", "meta.undo": False}]}
|
||||
).count()
|
||||
q = {"type": "Create", "activity.object.type": "Note", "meta.deleted": False}
|
||||
with_replies_count = DB.activities.find(
|
||||
{"box": Box.OUTBOX.value, "$or": [q, {"type": "Announce", "meta.undo": False}]}
|
||||
{
|
||||
"box": Box.OUTBOX.value,
|
||||
"type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
|
||||
"meta.undo": False,
|
||||
"meta.deleted": False,
|
||||
"meta.public": True,
|
||||
}
|
||||
).count()
|
||||
liked_count = DB.activities.count(
|
||||
{
|
||||
|
@ -169,6 +176,7 @@ def inject_config():
|
|||
liked_count=liked_count,
|
||||
with_replies_count=with_replies_count,
|
||||
me=ME,
|
||||
base_url=config.BASE_URL,
|
||||
)
|
||||
|
||||
|
||||
|
@ -233,6 +241,16 @@ def _get_file_url(url, size, kind):
|
|||
return url
|
||||
|
||||
|
||||
@app.template_filter()
|
||||
def gtone(n):
|
||||
return n > 1
|
||||
|
||||
|
||||
@app.template_filter()
|
||||
def gtnow(dtstr):
|
||||
return format_datetime(datetime.now().astimezone()) > dtstr
|
||||
|
||||
|
||||
@app.template_filter()
|
||||
def remove_mongo_id(dat):
|
||||
if isinstance(dat, list):
|
||||
|
@ -805,6 +823,9 @@ def index():
|
|||
DB.activities, q, limit=25 - len(pinned)
|
||||
)
|
||||
|
||||
# FIXME(tsileo): add it on permakink too
|
||||
[_add_answers_to_questions(item) for item in outbox_data]
|
||||
|
||||
resp = render_template(
|
||||
"index.html",
|
||||
outbox_data=outbox_data,
|
||||
|
@ -823,6 +844,7 @@ def with_replies():
|
|||
"box": Box.OUTBOX.value,
|
||||
"type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
|
||||
"meta.deleted": False,
|
||||
"meta.public": True,
|
||||
"meta.undo": False,
|
||||
}
|
||||
outbox_data, older_than, newer_than = paginated_query(DB.activities, q)
|
||||
|
@ -914,6 +936,10 @@ def note_by_id(note_id):
|
|||
abort(404)
|
||||
if data["meta"].get("deleted", False):
|
||||
abort(410)
|
||||
|
||||
# If it's a Question, add the answers from meta
|
||||
_add_answers_to_questions(data)
|
||||
|
||||
thread = _build_thread(data)
|
||||
app.logger.info(f"thread={thread!r}")
|
||||
|
||||
|
@ -1084,9 +1110,31 @@ def remove_context(activity: Dict[str, Any]) -> Dict[str, Any]:
|
|||
return activity
|
||||
|
||||
|
||||
def _add_answers_to_questions(raw_doc: Dict[str, Any]) -> None:
|
||||
activity = raw_doc["activity"]
|
||||
if (
|
||||
"object" in activity
|
||||
and _to_list(activity["object"]["type"])[0] == ActivityType.QUESTION.value
|
||||
):
|
||||
for choice in activity["object"].get("oneOf", activity["object"].get("anyOf")):
|
||||
choice["replies"] = {
|
||||
"type": ActivityType.COLLECTION.value,
|
||||
"totalItems": raw_doc["meta"]
|
||||
.get("question_answers", {})
|
||||
.get(_answer_key(choice["name"]), 0),
|
||||
}
|
||||
now = datetime.now().astimezone()
|
||||
if format_datetime(now) > activity["object"]["endTime"]:
|
||||
activity["object"]["closed"] = activity["object"]["endTime"]
|
||||
|
||||
|
||||
def activity_from_doc(raw_doc: Dict[str, Any], embed: bool = False) -> Dict[str, Any]:
|
||||
raw_doc = add_extra_collection(raw_doc)
|
||||
activity = clean_activity(raw_doc["activity"])
|
||||
|
||||
# Handle Questions
|
||||
# TODO(tsileo): what about object embedded by ID/URL?
|
||||
_add_answers_to_questions(raw_doc)
|
||||
if embed:
|
||||
return remove_context(activity)
|
||||
return activity
|
||||
|
@ -1101,6 +1149,8 @@ def outbox():
|
|||
q = {
|
||||
"box": Box.OUTBOX.value,
|
||||
"meta.deleted": False,
|
||||
"meta.undo": False,
|
||||
"meta.public": True,
|
||||
"type": {"$in": [ActivityType.CREATE.value, ActivityType.ANNOUNCE.value]},
|
||||
}
|
||||
return jsonify(
|
||||
|
@ -1150,6 +1200,7 @@ def outbox_activity(item_id):
|
|||
)
|
||||
if not data:
|
||||
abort(404)
|
||||
|
||||
obj = activity_from_doc(data)
|
||||
if data["meta"].get("deleted", False):
|
||||
obj = ap.parse_activity(data["activity"])
|
||||
|
@ -1487,19 +1538,7 @@ def _user_api_arg(key: str, **kwargs):
|
|||
def _user_api_get_note(from_outbox: bool = False):
|
||||
oid = _user_api_arg("id")
|
||||
app.logger.info(f"fetching {oid}")
|
||||
try:
|
||||
note = ap.parse_activity(
|
||||
get_backend().fetch_iri(oid), expected=ActivityType.NOTE
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
note = ap.parse_activity(
|
||||
get_backend().fetch_iri(oid), expected=ActivityType.VIDEO
|
||||
)
|
||||
except Exception:
|
||||
raise ActivityNotFoundError(
|
||||
"Expected Note or Video ActivityType, but got something else"
|
||||
)
|
||||
note = ap.parse_activity(get_backend().fetch_iri(oid))
|
||||
if from_outbox and not note.id.startswith(ID):
|
||||
raise NotFromOutboxError(
|
||||
f"cannot load {note.id}, id must be owned by the server"
|
||||
|
@ -1542,6 +1581,30 @@ def api_boost():
|
|||
return _user_api_response(activity=announce_id)
|
||||
|
||||
|
||||
@app.route("/api/vote", methods=["POST"])
|
||||
@api_required
|
||||
def api_vote():
|
||||
oid = _user_api_arg("id")
|
||||
app.logger.info(f"fetching {oid}")
|
||||
note = ap.parse_activity(get_backend().fetch_iri(oid))
|
||||
choice = _user_api_arg("choice")
|
||||
|
||||
raw_note = dict(
|
||||
attributedTo=MY_PERSON.id,
|
||||
cc=[],
|
||||
to=note.get_actor().id,
|
||||
name=choice,
|
||||
tag=[],
|
||||
inReplyTo=note.id,
|
||||
)
|
||||
|
||||
note = ap.Note(**raw_note)
|
||||
create = note.build_create()
|
||||
create_id = post_to_outbox(create)
|
||||
|
||||
return _user_api_response(activity=create_id)
|
||||
|
||||
|
||||
@app.route("/api/like", methods=["POST"])
|
||||
@api_required
|
||||
def api_like():
|
||||
|
@ -1780,6 +1843,57 @@ def api_new_note():
|
|||
return _user_api_response(activity=create_id)
|
||||
|
||||
|
||||
@app.route("/api/new_question", methods=["POST"])
|
||||
@api_required
|
||||
def api_new_question():
|
||||
source = _user_api_arg("content")
|
||||
if not source:
|
||||
raise ValueError("missing content")
|
||||
|
||||
content, tags = parse_markdown(source)
|
||||
cc = [ID + "/followers"]
|
||||
|
||||
for tag in tags:
|
||||
if tag["type"] == "Mention":
|
||||
cc.append(tag["href"])
|
||||
|
||||
answers = []
|
||||
for i in range(4):
|
||||
a = _user_api_arg(f"answer{i}", default=None)
|
||||
if not a:
|
||||
break
|
||||
answers.append({"type": ActivityType.NOTE.value, "name": a})
|
||||
|
||||
choices = {
|
||||
"endTime": ap.format_datetime(
|
||||
datetime.now().astimezone()
|
||||
+ timedelta(minutes=int(_user_api_arg("open_for")))
|
||||
)
|
||||
}
|
||||
of = _user_api_arg("of")
|
||||
if of == "anyOf":
|
||||
choices["anyOf"] = answers
|
||||
else:
|
||||
choices["oneOf"] = answers
|
||||
|
||||
raw_question = dict(
|
||||
attributedTo=MY_PERSON.id,
|
||||
cc=list(set(cc)),
|
||||
to=[ap.AS_PUBLIC],
|
||||
content=content,
|
||||
tag=tags,
|
||||
source={"mediaType": "text/markdown", "content": source},
|
||||
inReplyTo=None,
|
||||
**choices,
|
||||
)
|
||||
|
||||
question = ap.Question(**raw_question)
|
||||
create = question.build_create()
|
||||
create_id = post_to_outbox(create)
|
||||
|
||||
return _user_api_response(activity=create_id)
|
||||
|
||||
|
||||
@app.route("/api/stream")
|
||||
@api_required
|
||||
def api_stream():
|
||||
|
@ -2583,6 +2697,7 @@ def task_process_new_activity():
|
|||
if not note.inReplyTo or note.inReplyTo.startswith(ID):
|
||||
tag_stream = True
|
||||
|
||||
# FIXME(tsileo): check for direct addressing in the to, cc, bcc... fields
|
||||
if (note.inReplyTo and note.inReplyTo.startswith(ID)) or note.has_mention(
|
||||
ID
|
||||
):
|
||||
|
@ -2734,8 +2849,6 @@ def task_cleanup():
|
|||
task = p.parse(request)
|
||||
app.logger.info(f"task={task!r}")
|
||||
p.push({}, "/task/cleanup_part_1")
|
||||
p.push({}, "/task/cleanup_part_2")
|
||||
p.push({}, "/task/cleanup_part_3")
|
||||
return ""
|
||||
|
||||
|
||||
|
@ -2847,6 +2960,17 @@ def task_cleanup_part_1():
|
|||
},
|
||||
{"$set": {"meta.keep": False}},
|
||||
)
|
||||
|
||||
DB.activities.update_many(
|
||||
{
|
||||
"box": Box.OUTBOX.value,
|
||||
"type": ActivityType.CREATE.value,
|
||||
"meta.public": {"$exists": False},
|
||||
},
|
||||
{"$set": {"meta.public": True}},
|
||||
)
|
||||
|
||||
p.push({}, "/task/cleanup_part_2")
|
||||
return "OK"
|
||||
|
||||
|
||||
|
@ -2870,6 +2994,7 @@ def task_cleanup_part_2():
|
|||
MEDIA_CACHE.fs.delete(grid_item._id)
|
||||
DB.activities.delete_one({"_id": data["_id"]})
|
||||
|
||||
p.push({}, "/task/cleanup_part_3")
|
||||
return "OK"
|
||||
|
||||
|
||||
|
|
13
config.py
13
config.py
|
@ -144,6 +144,19 @@ def create_indexes():
|
|||
]
|
||||
)
|
||||
|
||||
DB.activities.create_index([("box", pymongo.ASCENDING)])
|
||||
|
||||
# Outbox query
|
||||
DB.activities.create_index(
|
||||
[
|
||||
("box", pymongo.ASCENDING),
|
||||
("type", pymongo.ASCENDING),
|
||||
("meta.undo", pymongo.ASCENDING),
|
||||
("meta.deleted", pymongo.ASCENDING),
|
||||
("meta.public", pymongo.ASCENDING),
|
||||
]
|
||||
)
|
||||
|
||||
DB.activities.create_index(
|
||||
[
|
||||
("type", pymongo.ASCENDING),
|
||||
|
|
|
@ -329,3 +329,25 @@ input[type=submit] {
|
|||
.note-video {
|
||||
margin: 30px 0 10px 0;
|
||||
}
|
||||
li.answer {
|
||||
height:30px;
|
||||
margin-bottom:10px;
|
||||
position:relative;
|
||||
}
|
||||
.answer .answer-bar {
|
||||
position:absolute;
|
||||
height:30px;
|
||||
border-radius:2px;
|
||||
}
|
||||
.answer .answer-text {
|
||||
position:relative;
|
||||
top:6px;
|
||||
padding-left:10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.answer .answer-text > span {
|
||||
width:70px;
|
||||
display:inline-block;
|
||||
}
|
||||
|
|
|
@ -12,9 +12,13 @@
|
|||
<h3 style="padding-bottom: 30px">Replying to {{ content }}</h3>
|
||||
{{ utils.display_thread(thread) }}
|
||||
{% else %}
|
||||
<h3 style="padding-bottom:20px;">New note</h3>
|
||||
{% if request.args.get("question") == "1" %}
|
||||
<h3 style="padding-bottom:20px;">New question <small><a href="/admin/new">make it a note?</a></small></h3>
|
||||
{% else %}
|
||||
<h3 style="padding-bottom:20px;">New note <small><a href="/admin/new?question=1">make it a question?</a></small></h3>
|
||||
{% endif %}
|
||||
<form action="/api/new_note" method="POST" enctype="multipart/form-data">
|
||||
{% endif %}
|
||||
<form action="/api/new_{% if request.args.get("question") == "1" %}question{%else%}note{%endif%}" method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="redirect" value="/">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if reply %}<input type="hidden" name="reply" value="{{reply}}">{% endif %}
|
||||
|
@ -27,7 +31,32 @@
|
|||
|
||||
<textarea name="content" rows="10" cols="50" autofocus="autofocus" designMode="on">{{ content }}</textarea>
|
||||
<input type="file" name="file">
|
||||
|
||||
{% if request.args.get("question") == "1" %}
|
||||
<div style="margin-top:20px;">
|
||||
<p>Open for: <select name="open_for">
|
||||
<option value="30">30 minutes</option>
|
||||
<option value="60">1 hour</option>
|
||||
<option value="360">6 hour</option>
|
||||
<option value="1440" selected>1 day</option>
|
||||
<option value="4320">3 days</option>
|
||||
<option value="10080">7 days</option>
|
||||
</select></p>
|
||||
|
||||
<input type="hidden" name="of" value="oneOf" />
|
||||
<!--
|
||||
<p><select name="of">
|
||||
<option value="oneOf">Single choice</option>
|
||||
<option value="anyOf">Multiple choices</option>
|
||||
</select></p>-->
|
||||
|
||||
{% for i in range(4) %}
|
||||
<p><input type="text" name="answer{{i}}" placeholder="Answer #{{i+1}}"></p>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<input type="submit" value="post">
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -15,8 +15,6 @@
|
|||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
|
||||
|
||||
{% macro display_note(obj, perma=False, ui=False, likes=[], shares=[], meta={}, no_color=False) -%}
|
||||
{% if meta.actor %}
|
||||
{% set actor = meta.actor %}
|
||||
|
@ -24,6 +22,20 @@
|
|||
{% set actor = obj.attributedTo | get_actor %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% if session.logged_in %}
|
||||
{% set perma_id = obj.id | permalink_id %}
|
||||
|
||||
{% if request.args.get('older_than') %}
|
||||
{% set redir = request.path + "?older_than=" + request.args.get('older_than') + "#activity-" + perma_id %}
|
||||
{% elif request.args.get('newer_than') %}
|
||||
{% set redir = request.path + "?newer_than=" + request.args.get('newer_than') + "#activity-" + perma_id %}
|
||||
{% else %}
|
||||
{% set redir = request.path + "#activity-" + perma_id %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
<div class="note-box">
|
||||
<div class="note h-entry" id="activity-{{ obj.id | permalink_id }}">
|
||||
|
||||
|
@ -58,11 +70,67 @@
|
|||
{{ obj.name }} <a href="{{ obj | url_or_id | get_url }}">{{ obj | url_or_id | get_url }}</a>
|
||||
{% elif obj | has_type('Question') %}
|
||||
{{ obj.content | clean | safe }}
|
||||
<ul>
|
||||
|
||||
{% if obj.id | is_from_outbox %}
|
||||
<ul style="list-style:none;padding:0;">
|
||||
{% set total_votes = [0] %}
|
||||
{% for oneOf in obj.oneOf %}
|
||||
<li>{{ oneOf.name }} ({{ oneOf.replies.totalItems }})</li>
|
||||
{% if oneOf.replies %}
|
||||
{% if total_votes.append(total_votes.pop() + oneOf.replies.totalItems) %}{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% for oneOf in obj.oneOf %}
|
||||
{% set pct = 0 %}
|
||||
{% if total_votes[0] > 0 and oneOf.replies %}
|
||||
{% set pct = oneOf.replies.totalItems * 100.0 / total_votes[0] %}
|
||||
{% endif %}
|
||||
<li class="answer">
|
||||
<span class="answer-bar color-menu-background" style="width:{{pct}}%;"></span>
|
||||
<span class="answer-text">
|
||||
<span>{{ '%0.0f'| format(pct) }}%</span>
|
||||
{{ oneOf.name }}
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<p><small>
|
||||
{% if obj.closed %}
|
||||
Ended {{ obj.endTime | format_timeago }} with <strong>{{ total_votes[0] }}</strong> vote{% if total_votes[0] | gtone %}s{% endif %}.
|
||||
{% else %}
|
||||
Ends {{ obj.endTime | format_timeago }} (<strong>{{ total_votes[0] }}</strong> vote{% if total_votes[0] | gtone %}s{% endif %} as of now).
|
||||
{% endif %}
|
||||
</small></p>
|
||||
{% else %}
|
||||
|
||||
<ul style="list-style:none;padding:0;">
|
||||
{% for oneOf in obj.oneOf %}
|
||||
<li class="answer">
|
||||
<span class="answer-text">
|
||||
|
||||
{% if not meta.voted_for and not obj.endTime | gtnow %}
|
||||
<span><form action="/api/vote" class="action-form" method="POST">
|
||||
<input type="hidden" name="redirect" value="{{ redir }}">
|
||||
<input type="hidden" name="id" value="{{ obj.id }}">
|
||||
<input type="hidden" name="choice" value="{{ oneOf.name }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="bar-item">vote</button>
|
||||
</form></span>
|
||||
{% else %}
|
||||
<span>???</span>
|
||||
{% endif %}
|
||||
{{ oneOf.name }} {% if oneOf.name == meta.voted_for %}(your vote){% endif %}
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
<p><small>{% if obj.endTime | gtnow %}This question ended {{ obj.endTime | format_timeago }}.</small></p>
|
||||
{% else %}This question ends {{ obj.endTime | format_timeago }}{% endif %}
|
||||
</small></p>
|
||||
</ul>
|
||||
|
||||
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
{{ obj.content | clean | safe }}
|
||||
{% endif %}
|
||||
|
@ -120,17 +188,6 @@
|
|||
<a class ="bar-item" href="{{ obj | url_or_id | get_url }}">permalink</a>
|
||||
{% endif %}
|
||||
|
||||
{% if session.logged_in %}
|
||||
{% set perma_id = obj.id | permalink_id %}
|
||||
|
||||
{% if request.args.get('older_than') %}
|
||||
{% set redir = request.path + "?older_than=" + request.args.get('older_than') + "#activity-" + perma_id %}
|
||||
{% elif request.args.get('newer_than') %}
|
||||
{% set redir = request.path + "?newer_than=" + request.args.get('newer_than') + "#activity-" + perma_id %}
|
||||
{% else %}
|
||||
{% set redir = request.path + "#activity-" + perma_id %}
|
||||
{% endif %}
|
||||
|
||||
{% set aid = obj.id | quote_plus %}
|
||||
{% endif %}
|
||||
|
||||
|
|
|
@ -20,12 +20,15 @@ def links_from_note(note):
|
|||
tags_href.add(h)
|
||||
|
||||
links = set()
|
||||
if "content" in note:
|
||||
soup = BeautifulSoup(note["content"])
|
||||
for link in soup.find_all("a"):
|
||||
h = link.get("href")
|
||||
if h.startswith("http") and h not in tags_href and is_url_valid(h):
|
||||
links.add(h)
|
||||
|
||||
# FIXME(tsileo): support summary and name fields
|
||||
|
||||
return links
|
||||
|
||||
|
||||
|
|
Loading…
Reference in a new issue