We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Malformed Links in Profiles and Posts
**Describe the bug**
When you write a URL at the end of a sentence in your profile or in a post, the trailing dot (.) is appended to the generated HTML anchor.

**To Reproduce**
Steps to reproduce the behavior:
1. Go to your profile
2. Click on 'edit profile'
3. Insert "https://github.com/bookwyrm-social/bookwyrm/issues." (with the dot at the end)
4. Click on "save"
5. Follow the Link

**Example**
https://bookwyrm.social/user/taarion

**Expected behavior**
The Link shoud be without the dot

**Instance**
bookwyrm.social i.E.


---

**Desktop (please complete the following information):**
    - OS: Windows 11
    - Browser Vivaldi
    - Version 6.1



--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `bookwyrm/views/status.py`
Content:
```
1 """ what are we here for if not for posting """
2 import re
3 import logging
4 from urllib.parse import urlparse
5 
6 from django.contrib.auth.decorators import login_required
7 from django.core.validators import URLValidator
8 from django.core.exceptions import ValidationError
9 from django.db.models import Q
10 from django.http import HttpResponse, HttpResponseBadRequest, Http404
11 from django.shortcuts import get_object_or_404, redirect
12 from django.template.response import TemplateResponse
13 from django.utils import timezone
14 from django.utils.decorators import method_decorator
15 from django.views import View
16 from django.views.decorators.http import require_POST
17 
18 from markdown import markdown
19 from bookwyrm import forms, models
20 from bookwyrm.utils import regex, sanitizer
21 from .helpers import handle_remote_webfinger, is_api_request
22 from .helpers import load_date_in_user_tz_as_utc, redirect_to_referer
23 
24 logger = logging.getLogger(__name__)
25 
26 
27 # pylint: disable= no-self-use
28 @method_decorator(login_required, name="dispatch")
29 class EditStatus(View):
30     """the view for *posting*"""
31 
32     def get(self, request, status_id):  # pylint: disable=unused-argument
33         """load the edit panel"""
34         status = get_object_or_404(
35             models.Status.objects.select_subclasses(), id=status_id
36         )
37 
38         status_type = "reply" if status.reply_parent else status.status_type.lower()
39         data = {
40             "type": status_type,
41             "book": getattr(status, "book", None),
42             "draft": status,
43         }
44         return TemplateResponse(request, "compose.html", data)
45 
46 
47 # pylint: disable= no-self-use
48 @method_decorator(login_required, name="dispatch")
49 class CreateStatus(View):
50     """the view for *posting*"""
51 
52     def get(self, request, status_type):  # pylint: disable=unused-argument
53         """compose view (...not used?)"""
54         book = get_object_or_404(models.Edition, id=request.GET.get("book"))
55         data = {"book": book}
56         return TemplateResponse(request, "compose.html", data)
57 
58     # pylint: disable=too-many-branches
59     def post(self, request, status_type, existing_status_id=None):
60         """create status of whatever type"""
61         created = not existing_status_id
62         existing_status = None
63         if existing_status_id:
64             existing_status = get_object_or_404(
65                 models.Status.objects.select_subclasses(), id=existing_status_id
66             )
67             existing_status.edited_date = timezone.now()
68 
69         status_type = status_type[0].upper() + status_type[1:]
70 
71         try:
72             form = getattr(forms, f"{status_type}Form")(
73                 request.POST, instance=existing_status
74             )
75         except AttributeError as err:
76             logger.exception(err)
77             return HttpResponseBadRequest()
78 
79         if not form.is_valid():
80             if is_api_request(request):
81                 logger.exception(form.errors)
82                 return HttpResponseBadRequest()
83             return redirect_to_referer(request)
84 
85         status = form.save(request, commit=False)
86         status.ready = False
87         # save the plain, unformatted version of the status for future editing
88         status.raw_content = status.content
89         if hasattr(status, "quote"):
90             status.raw_quote = status.quote
91 
92         status.sensitive = status.content_warning not in [None, ""]
93         # the status has to be saved now before we can add many to many fields
94         # like mentions
95         status.save(broadcast=False)
96 
97         # inspect the text for user tags
98         content = status.content
99         mentions = find_mentions(request.user, content)
100         for (_, mention_user) in mentions.items():
101             # add them to status mentions fk
102             status.mention_users.add(mention_user)
103         content = format_mentions(content, mentions)
104 
105         # add reply parent to mentions
106         if status.reply_parent:
107             status.mention_users.add(status.reply_parent.user)
108 
109         # inspect the text for hashtags
110         hashtags = find_or_create_hashtags(content)
111         for (_, mention_hashtag) in hashtags.items():
112             # add them to status mentions fk
113             status.mention_hashtags.add(mention_hashtag)
114         content = format_hashtags(content, hashtags)
115 
116         # deduplicate mentions
117         status.mention_users.set(set(status.mention_users.all()))
118 
119         # don't apply formatting to generated notes
120         if not isinstance(status, models.GeneratedNote) and content:
121             status.content = to_markdown(content)
122         # do apply formatting to quotes
123         if hasattr(status, "quote"):
124             status.quote = to_markdown(status.quote)
125 
126         status.ready = True
127         status.save(created=created)
128 
129         # update a readthrough, if needed
130         if bool(request.POST.get("id")):
131             try:
132                 edit_readthrough(request)
133             except Http404:
134                 pass
135 
136         if is_api_request(request):
137             return HttpResponse()
138         return redirect_to_referer(request)
139 
140 
141 def format_mentions(content, mentions):
142     """Detect @mentions and make them links"""
143     for (mention_text, mention_user) in mentions.items():
144         # turn the mention into a link
145         content = re.sub(
146             rf"(?<!/)\B{mention_text}\b(?!@)",
147             rf'<a href="{mention_user.remote_id}">{mention_text}</a>',
148             content,
149         )
150     return content
151 
152 
153 def format_hashtags(content, hashtags):
154     """Detect #hashtags and make them links"""
155     for (mention_text, mention_hashtag) in hashtags.items():
156         # turn the mention into a link
157         content = re.sub(
158             rf"(?<!/)\B{mention_text}\b(?!@)",
159             rf'<a href="{mention_hashtag.remote_id}" data-mention="hashtag">'
160             + rf"{mention_text}</a>",
161             content,
162         )
163     return content
164 
165 
166 @method_decorator(login_required, name="dispatch")
167 class DeleteStatus(View):
168     """tombstone that bad boy"""
169 
170     def post(self, request, status_id):
171         """delete and tombstone a status"""
172         status = get_object_or_404(models.Status, id=status_id)
173 
174         # don't let people delete other people's statuses
175         status.raise_not_deletable(request.user)
176 
177         # perform deletion
178         status.delete()
179         return redirect("/")
180 
181 
182 @login_required
183 @require_POST
184 def update_progress(request, book_id):  # pylint: disable=unused-argument
185     """Either it's just a progress update, or it's a comment with a progress update"""
186     if request.POST.get("post-status"):
187         return CreateStatus.as_view()(request, "comment")
188     return edit_readthrough(request)
189 
190 
191 @login_required
192 @require_POST
193 def edit_readthrough(request):
194     """can't use the form because the dates are too finnicky"""
195     # TODO: remove this, it duplicates the code in the ReadThrough view
196     readthrough = get_object_or_404(models.ReadThrough, id=request.POST.get("id"))
197 
198     readthrough.start_date = load_date_in_user_tz_as_utc(
199         request.POST.get("start_date"), request.user
200     )
201     readthrough.finish_date = load_date_in_user_tz_as_utc(
202         request.POST.get("finish_date"), request.user
203     )
204 
205     progress = request.POST.get("progress")
206     try:
207         progress = int(progress)
208         readthrough.progress = progress
209     except (ValueError, TypeError):
210         pass
211 
212     progress_mode = request.POST.get("progress_mode")
213     try:
214         progress_mode = models.ProgressMode(progress_mode)
215         readthrough.progress_mode = progress_mode
216     except ValueError:
217         pass
218 
219     readthrough.save()
220 
221     # record the progress update individually
222     # use default now for date field
223     readthrough.create_update()
224 
225     if is_api_request(request):
226         return HttpResponse()
227     return redirect_to_referer(request)
228 
229 
230 def find_mentions(user, content):
231     """detect @mentions in raw status content"""
232     if not content:
233         return {}
234     # The regex has nested match groups, so the 0th entry has the full (outer) match
235     # And because the strict username starts with @, the username is 1st char onward
236     usernames = [m[0][1:] for m in re.findall(regex.STRICT_USERNAME, content)]
237 
238     known_users = (
239         models.User.viewer_aware_objects(user)
240         .filter(Q(username__in=usernames) | Q(localname__in=usernames))
241         .distinct()
242     )
243     # Prepare a lookup based on both username and localname
244     username_dict = {
245         **{f"@{u.username}": u for u in known_users},
246         **{f"@{u.localname}": u for u in known_users.filter(local=True)},
247     }
248 
249     # Users not captured here could be blocked or not yet loaded on the server
250     not_found = set(usernames) - set(username_dict.keys())
251     for username in not_found:
252         mention_user = handle_remote_webfinger(username, unknown_only=True)
253         if not mention_user:
254             # this user is blocked or can't be found
255             continue
256         username_dict[f"@{mention_user.username}"] = mention_user
257         username_dict[f"@{mention_user.localname}"] = mention_user
258     return username_dict
259 
260 
261 def find_or_create_hashtags(content):
262     """detect #hashtags in raw status content
263 
264     it stores hashtags case-sensitive, but ensures that an existing
265     hashtag with different case are found and re-used. for example,
266     an existing #BookWyrm hashtag will be found and used even if the
267     status content is using #bookwyrm.
268     """
269     if not content:
270         return {}
271 
272     found_hashtags = {t.lower(): t for t in re.findall(regex.HASHTAG, content)}
273     if len(found_hashtags) == 0:
274         return {}
275 
276     known_hashtags = {
277         t.name.lower(): t
278         for t in models.Hashtag.objects.filter(
279             Q(name__in=found_hashtags.keys())
280         ).distinct()
281     }
282 
283     not_found = found_hashtags.keys() - known_hashtags.keys()
284     for lower_name in not_found:
285         tag_name = found_hashtags[lower_name]
286         mention_hashtag = models.Hashtag(name=tag_name)
287         mention_hashtag.save()
288         known_hashtags[lower_name] = mention_hashtag
289 
290     return {found_hashtags[k]: v for k, v in known_hashtags.items()}
291 
292 
293 def format_links(content):
294     """detect and format links"""
295     validator = URLValidator()
296     formatted_content = ""
297     split_content = re.split(r"(\s+)", content)
298 
299     for potential_link in split_content:
300         if not potential_link:
301             continue
302         wrapped = _wrapped(potential_link)
303         if wrapped:
304             wrapper_close = potential_link[-1]
305             formatted_content += potential_link[0]
306             potential_link = potential_link[1:-1]
307 
308         try:
309             # raises an error on anything that's not a valid link
310             validator(potential_link)
311 
312             # use everything but the scheme in the presentation of the link
313             url = urlparse(potential_link)
314             link = url.netloc + url.path + url.params
315             if url.query != "":
316                 link += "?" + url.query
317             if url.fragment != "":
318                 link += "#" + url.fragment
319 
320             formatted_content += f'<a href="{potential_link}">{link}</a>'
321         except (ValidationError, UnicodeError):
322             formatted_content += potential_link
323 
324         if wrapped:
325             formatted_content += wrapper_close
326 
327     return formatted_content
328 
329 
330 def _wrapped(text):
331     """check if a line of text is wrapped"""
332     wrappers = [("(", ")"), ("[", "]"), ("{", "}")]
333     for wrapper in wrappers:
334         if text[0] == wrapper[0] and text[-1] == wrapper[-1]:
335             return True
336     return False
337 
338 
339 def to_markdown(content):
340     """catch links and convert to markdown"""
341     content = format_links(content)
342     content = markdown(content)
343     # sanitize resulting html
344     return sanitizer.clean(content)
345
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/bookwyrm/views/status.py b/bookwyrm/views/status.py
--- a/bookwyrm/views/status.py
+++ b/bookwyrm/views/status.py
@@ -305,6 +305,11 @@
             formatted_content += potential_link[0]
             potential_link = potential_link[1:-1]
 
+        ends_with_punctuation = _ends_with_punctuation(potential_link)
+        if ends_with_punctuation:
+            punctuation_glyph = potential_link[-1]
+            potential_link = potential_link[0:-1]
+
         try:
             # raises an error on anything that's not a valid link
             validator(potential_link)
@@ -324,6 +329,9 @@
         if wrapped:
             formatted_content += wrapper_close
 
+        if ends_with_punctuation:
+            formatted_content += punctuation_glyph
+
     return formatted_content
 
 
@@ -336,6 +344,15 @@
     return False
 
 
+def _ends_with_punctuation(text):
+    """check if a line of text ends with a punctuation glyph"""
+    glyphs = [".", ",", ";", ":", "!", "?", "”", "’", '"', "»"]
+    for glyph in glyphs:
+        if text[-1] == glyph:
+            return True
+    return False
+
+
 def to_markdown(content):
     """catch links and convert to markdown"""
     content = format_links(content)

{"golden_diff": "diff --git a/bookwyrm/views/status.py b/bookwyrm/views/status.py\n--- a/bookwyrm/views/status.py\n+++ b/bookwyrm/views/status.py\n@@ -305,6 +305,11 @@\n             formatted_content += potential_link[0]\n             potential_link = potential_link[1:-1]\n \n+        ends_with_punctuation = _ends_with_punctuation(potential_link)\n+        if ends_with_punctuation:\n+            punctuation_glyph = potential_link[-1]\n+            potential_link = potential_link[0:-1]\n+\n         try:\n             # raises an error on anything that's not a valid link\n             validator(potential_link)\n@@ -324,6 +329,9 @@\n         if wrapped:\n             formatted_content += wrapper_close\n \n+        if ends_with_punctuation:\n+            formatted_content += punctuation_glyph\n+\n     return formatted_content\n \n \n@@ -336,6 +344,15 @@\n     return False\n \n \n+def _ends_with_punctuation(text):\n+    \"\"\"check if a line of text ends with a punctuation glyph\"\"\"\n+    glyphs = [\".\", \",\", \";\", \":\", \"!\", \"?\", \"\u201d\", \"\u2019\", '\"', \"\u00bb\"]\n+    for glyph in glyphs:\n+        if text[-1] == glyph:\n+            return True\n+    return False\n+\n+\n def to_markdown(content):\n     \"\"\"catch links and convert to markdown\"\"\"\n     content = format_links(content)\n", "issue": "Malformed Links in Profiles and Posts\n**Describe the bug**\r\nWhen you write a URL at the end of a sentence in your profile or in a post, the trailing dot (.) is appended to the generated HTML anchor.\r\n\r\n**To Reproduce**\r\nSteps to reproduce the behavior:\r\n1. Go to your profile\r\n2. Click on 'edit profile'\r\n3. Insert \"https://github.com/bookwyrm-social/bookwyrm/issues.\" (with the dot at the end)\r\n4. Click on \"save\"\r\n5. Follow the Link\r\n\r\n**Example**\r\nhttps://bookwyrm.social/user/taarion\r\n\r\n**Expected behavior**\r\nThe Link shoud be without the dot\r\n\r\n**Instance**\r\nbookwyrm.social i.E.\r\n\r\n\r\n---\r\n\r\n**Desktop (please complete the following information):**\r\n    - OS: Windows 11\r\n    - Browser Vivaldi\r\n    - Version 6.1\r\n\r\n\n", "before_files": [{"content": "\"\"\" what are we here for if not for posting \"\"\"\nimport re\nimport logging\nfrom urllib.parse import urlparse\n\nfrom django.contrib.auth.decorators import login_required\nfrom django.core.validators import URLValidator\nfrom django.core.exceptions import ValidationError\nfrom django.db.models import Q\nfrom django.http import HttpResponse, HttpResponseBadRequest, Http404\nfrom django.shortcuts import get_object_or_404, redirect\nfrom django.template.response import TemplateResponse\nfrom django.utils import timezone\nfrom django.utils.decorators import method_decorator\nfrom django.views import View\nfrom django.views.decorators.http import require_POST\n\nfrom markdown import markdown\nfrom bookwyrm import forms, models\nfrom bookwyrm.utils import regex, sanitizer\nfrom .helpers import handle_remote_webfinger, is_api_request\nfrom .helpers import load_date_in_user_tz_as_utc, redirect_to_referer\n\nlogger = logging.getLogger(__name__)\n\n\n# pylint: disable= no-self-use\n@method_decorator(login_required, name=\"dispatch\")\nclass EditStatus(View):\n    \"\"\"the view for *posting*\"\"\"\n\n    def get(self, request, status_id):  # pylint: disable=unused-argument\n        \"\"\"load the edit panel\"\"\"\n        status = get_object_or_404(\n            models.Status.objects.select_subclasses(), id=status_id\n        )\n\n        status_type = \"reply\" if status.reply_parent else status.status_type.lower()\n        data = {\n            \"type\": status_type,\n            \"book\": getattr(status, \"book\", None),\n            \"draft\": status,\n        }\n        return TemplateResponse(request, \"compose.html\", data)\n\n\n# pylint: disable= no-self-use\n@method_decorator(login_required, name=\"dispatch\")\nclass CreateStatus(View):\n    \"\"\"the view for *posting*\"\"\"\n\n    def get(self, request, status_type):  # pylint: disable=unused-argument\n        \"\"\"compose view (...not used?)\"\"\"\n        book = get_object_or_404(models.Edition, id=request.GET.get(\"book\"))\n        data = {\"book\": book}\n        return TemplateResponse(request, \"compose.html\", data)\n\n    # pylint: disable=too-many-branches\n    def post(self, request, status_type, existing_status_id=None):\n        \"\"\"create status of whatever type\"\"\"\n        created = not existing_status_id\n        existing_status = None\n        if existing_status_id:\n            existing_status = get_object_or_404(\n                models.Status.objects.select_subclasses(), id=existing_status_id\n            )\n            existing_status.edited_date = timezone.now()\n\n        status_type = status_type[0].upper() + status_type[1:]\n\n        try:\n            form = getattr(forms, f\"{status_type}Form\")(\n                request.POST, instance=existing_status\n            )\n        except AttributeError as err:\n            logger.exception(err)\n            return HttpResponseBadRequest()\n\n        if not form.is_valid():\n            if is_api_request(request):\n                logger.exception(form.errors)\n                return HttpResponseBadRequest()\n            return redirect_to_referer(request)\n\n        status = form.save(request, commit=False)\n        status.ready = False\n        # save the plain, unformatted version of the status for future editing\n        status.raw_content = status.content\n        if hasattr(status, \"quote\"):\n            status.raw_quote = status.quote\n\n        status.sensitive = status.content_warning not in [None, \"\"]\n        # the status has to be saved now before we can add many to many fields\n        # like mentions\n        status.save(broadcast=False)\n\n        # inspect the text for user tags\n        content = status.content\n        mentions = find_mentions(request.user, content)\n        for (_, mention_user) in mentions.items():\n            # add them to status mentions fk\n            status.mention_users.add(mention_user)\n        content = format_mentions(content, mentions)\n\n        # add reply parent to mentions\n        if status.reply_parent:\n            status.mention_users.add(status.reply_parent.user)\n\n        # inspect the text for hashtags\n        hashtags = find_or_create_hashtags(content)\n        for (_, mention_hashtag) in hashtags.items():\n            # add them to status mentions fk\n            status.mention_hashtags.add(mention_hashtag)\n        content = format_hashtags(content, hashtags)\n\n        # deduplicate mentions\n        status.mention_users.set(set(status.mention_users.all()))\n\n        # don't apply formatting to generated notes\n        if not isinstance(status, models.GeneratedNote) and content:\n            status.content = to_markdown(content)\n        # do apply formatting to quotes\n        if hasattr(status, \"quote\"):\n            status.quote = to_markdown(status.quote)\n\n        status.ready = True\n        status.save(created=created)\n\n        # update a readthrough, if needed\n        if bool(request.POST.get(\"id\")):\n            try:\n                edit_readthrough(request)\n            except Http404:\n                pass\n\n        if is_api_request(request):\n            return HttpResponse()\n        return redirect_to_referer(request)\n\n\ndef format_mentions(content, mentions):\n    \"\"\"Detect @mentions and make them links\"\"\"\n    for (mention_text, mention_user) in mentions.items():\n        # turn the mention into a link\n        content = re.sub(\n            rf\"(?<!/)\\B{mention_text}\\b(?!@)\",\n            rf'<a href=\"{mention_user.remote_id}\">{mention_text}</a>',\n            content,\n        )\n    return content\n\n\ndef format_hashtags(content, hashtags):\n    \"\"\"Detect #hashtags and make them links\"\"\"\n    for (mention_text, mention_hashtag) in hashtags.items():\n        # turn the mention into a link\n        content = re.sub(\n            rf\"(?<!/)\\B{mention_text}\\b(?!@)\",\n            rf'<a href=\"{mention_hashtag.remote_id}\" data-mention=\"hashtag\">'\n            + rf\"{mention_text}</a>\",\n            content,\n        )\n    return content\n\n\n@method_decorator(login_required, name=\"dispatch\")\nclass DeleteStatus(View):\n    \"\"\"tombstone that bad boy\"\"\"\n\n    def post(self, request, status_id):\n        \"\"\"delete and tombstone a status\"\"\"\n        status = get_object_or_404(models.Status, id=status_id)\n\n        # don't let people delete other people's statuses\n        status.raise_not_deletable(request.user)\n\n        # perform deletion\n        status.delete()\n        return redirect(\"/\")\n\n\n@login_required\n@require_POST\ndef update_progress(request, book_id):  # pylint: disable=unused-argument\n    \"\"\"Either it's just a progress update, or it's a comment with a progress update\"\"\"\n    if request.POST.get(\"post-status\"):\n        return CreateStatus.as_view()(request, \"comment\")\n    return edit_readthrough(request)\n\n\n@login_required\n@require_POST\ndef edit_readthrough(request):\n    \"\"\"can't use the form because the dates are too finnicky\"\"\"\n    # TODO: remove this, it duplicates the code in the ReadThrough view\n    readthrough = get_object_or_404(models.ReadThrough, id=request.POST.get(\"id\"))\n\n    readthrough.start_date = load_date_in_user_tz_as_utc(\n        request.POST.get(\"start_date\"), request.user\n    )\n    readthrough.finish_date = load_date_in_user_tz_as_utc(\n        request.POST.get(\"finish_date\"), request.user\n    )\n\n    progress = request.POST.get(\"progress\")\n    try:\n        progress = int(progress)\n        readthrough.progress = progress\n    except (ValueError, TypeError):\n        pass\n\n    progress_mode = request.POST.get(\"progress_mode\")\n    try:\n        progress_mode = models.ProgressMode(progress_mode)\n        readthrough.progress_mode = progress_mode\n    except ValueError:\n        pass\n\n    readthrough.save()\n\n    # record the progress update individually\n    # use default now for date field\n    readthrough.create_update()\n\n    if is_api_request(request):\n        return HttpResponse()\n    return redirect_to_referer(request)\n\n\ndef find_mentions(user, content):\n    \"\"\"detect @mentions in raw status content\"\"\"\n    if not content:\n        return {}\n    # The regex has nested match groups, so the 0th entry has the full (outer) match\n    # And because the strict username starts with @, the username is 1st char onward\n    usernames = [m[0][1:] for m in re.findall(regex.STRICT_USERNAME, content)]\n\n    known_users = (\n        models.User.viewer_aware_objects(user)\n        .filter(Q(username__in=usernames) | Q(localname__in=usernames))\n        .distinct()\n    )\n    # Prepare a lookup based on both username and localname\n    username_dict = {\n        **{f\"@{u.username}\": u for u in known_users},\n        **{f\"@{u.localname}\": u for u in known_users.filter(local=True)},\n    }\n\n    # Users not captured here could be blocked or not yet loaded on the server\n    not_found = set(usernames) - set(username_dict.keys())\n    for username in not_found:\n        mention_user = handle_remote_webfinger(username, unknown_only=True)\n        if not mention_user:\n            # this user is blocked or can't be found\n            continue\n        username_dict[f\"@{mention_user.username}\"] = mention_user\n        username_dict[f\"@{mention_user.localname}\"] = mention_user\n    return username_dict\n\n\ndef find_or_create_hashtags(content):\n    \"\"\"detect #hashtags in raw status content\n\n    it stores hashtags case-sensitive, but ensures that an existing\n    hashtag with different case are found and re-used. for example,\n    an existing #BookWyrm hashtag will be found and used even if the\n    status content is using #bookwyrm.\n    \"\"\"\n    if not content:\n        return {}\n\n    found_hashtags = {t.lower(): t for t in re.findall(regex.HASHTAG, content)}\n    if len(found_hashtags) == 0:\n        return {}\n\n    known_hashtags = {\n        t.name.lower(): t\n        for t in models.Hashtag.objects.filter(\n            Q(name__in=found_hashtags.keys())\n        ).distinct()\n    }\n\n    not_found = found_hashtags.keys() - known_hashtags.keys()\n    for lower_name in not_found:\n        tag_name = found_hashtags[lower_name]\n        mention_hashtag = models.Hashtag(name=tag_name)\n        mention_hashtag.save()\n        known_hashtags[lower_name] = mention_hashtag\n\n    return {found_hashtags[k]: v for k, v in known_hashtags.items()}\n\n\ndef format_links(content):\n    \"\"\"detect and format links\"\"\"\n    validator = URLValidator()\n    formatted_content = \"\"\n    split_content = re.split(r\"(\\s+)\", content)\n\n    for potential_link in split_content:\n        if not potential_link:\n            continue\n        wrapped = _wrapped(potential_link)\n        if wrapped:\n            wrapper_close = potential_link[-1]\n            formatted_content += potential_link[0]\n            potential_link = potential_link[1:-1]\n\n        try:\n            # raises an error on anything that's not a valid link\n            validator(potential_link)\n\n            # use everything but the scheme in the presentation of the link\n            url = urlparse(potential_link)\n            link = url.netloc + url.path + url.params\n            if url.query != \"\":\n                link += \"?\" + url.query\n            if url.fragment != \"\":\n                link += \"#\" + url.fragment\n\n            formatted_content += f'<a href=\"{potential_link}\">{link}</a>'\n        except (ValidationError, UnicodeError):\n            formatted_content += potential_link\n\n        if wrapped:\n            formatted_content += wrapper_close\n\n    return formatted_content\n\n\ndef _wrapped(text):\n    \"\"\"check if a line of text is wrapped\"\"\"\n    wrappers = [(\"(\", \")\"), (\"[\", \"]\"), (\"{\", \"}\")]\n    for wrapper in wrappers:\n        if text[0] == wrapper[0] and text[-1] == wrapper[-1]:\n            return True\n    return False\n\n\ndef to_markdown(content):\n    \"\"\"catch links and convert to markdown\"\"\"\n    content = format_links(content)\n    content = markdown(content)\n    # sanitize resulting html\n    return sanitizer.clean(content)\n", "path": "bookwyrm/views/status.py"}], "after_files": [{"content": "\"\"\" what are we here for if not for posting \"\"\"\nimport re\nimport logging\nfrom urllib.parse import urlparse\n\nfrom django.contrib.auth.decorators import login_required\nfrom django.core.validators import URLValidator\nfrom django.core.exceptions import ValidationError\nfrom django.db.models import Q\nfrom django.http import HttpResponse, HttpResponseBadRequest, Http404\nfrom django.shortcuts import get_object_or_404, redirect\nfrom django.template.response import TemplateResponse\nfrom django.utils import timezone\nfrom django.utils.decorators import method_decorator\nfrom django.views import View\nfrom django.views.decorators.http import require_POST\n\nfrom markdown import markdown\nfrom bookwyrm import forms, models\nfrom bookwyrm.utils import regex, sanitizer\nfrom .helpers import handle_remote_webfinger, is_api_request\nfrom .helpers import load_date_in_user_tz_as_utc, redirect_to_referer\n\nlogger = logging.getLogger(__name__)\n\n\n# pylint: disable= no-self-use\n@method_decorator(login_required, name=\"dispatch\")\nclass EditStatus(View):\n    \"\"\"the view for *posting*\"\"\"\n\n    def get(self, request, status_id):  # pylint: disable=unused-argument\n        \"\"\"load the edit panel\"\"\"\n        status = get_object_or_404(\n            models.Status.objects.select_subclasses(), id=status_id\n        )\n\n        status_type = \"reply\" if status.reply_parent else status.status_type.lower()\n        data = {\n            \"type\": status_type,\n            \"book\": getattr(status, \"book\", None),\n            \"draft\": status,\n        }\n        return TemplateResponse(request, \"compose.html\", data)\n\n\n# pylint: disable= no-self-use\n@method_decorator(login_required, name=\"dispatch\")\nclass CreateStatus(View):\n    \"\"\"the view for *posting*\"\"\"\n\n    def get(self, request, status_type):  # pylint: disable=unused-argument\n        \"\"\"compose view (...not used?)\"\"\"\n        book = get_object_or_404(models.Edition, id=request.GET.get(\"book\"))\n        data = {\"book\": book}\n        return TemplateResponse(request, \"compose.html\", data)\n\n    # pylint: disable=too-many-branches\n    def post(self, request, status_type, existing_status_id=None):\n        \"\"\"create status of whatever type\"\"\"\n        created = not existing_status_id\n        existing_status = None\n        if existing_status_id:\n            existing_status = get_object_or_404(\n                models.Status.objects.select_subclasses(), id=existing_status_id\n            )\n            existing_status.edited_date = timezone.now()\n\n        status_type = status_type[0].upper() + status_type[1:]\n\n        try:\n            form = getattr(forms, f\"{status_type}Form\")(\n                request.POST, instance=existing_status\n            )\n        except AttributeError as err:\n            logger.exception(err)\n            return HttpResponseBadRequest()\n\n        if not form.is_valid():\n            if is_api_request(request):\n                logger.exception(form.errors)\n                return HttpResponseBadRequest()\n            return redirect_to_referer(request)\n\n        status = form.save(request, commit=False)\n        status.ready = False\n        # save the plain, unformatted version of the status for future editing\n        status.raw_content = status.content\n        if hasattr(status, \"quote\"):\n            status.raw_quote = status.quote\n\n        status.sensitive = status.content_warning not in [None, \"\"]\n        # the status has to be saved now before we can add many to many fields\n        # like mentions\n        status.save(broadcast=False)\n\n        # inspect the text for user tags\n        content = status.content\n        mentions = find_mentions(request.user, content)\n        for (_, mention_user) in mentions.items():\n            # add them to status mentions fk\n            status.mention_users.add(mention_user)\n        content = format_mentions(content, mentions)\n\n        # add reply parent to mentions\n        if status.reply_parent:\n            status.mention_users.add(status.reply_parent.user)\n\n        # inspect the text for hashtags\n        hashtags = find_or_create_hashtags(content)\n        for (_, mention_hashtag) in hashtags.items():\n            # add them to status mentions fk\n            status.mention_hashtags.add(mention_hashtag)\n        content = format_hashtags(content, hashtags)\n\n        # deduplicate mentions\n        status.mention_users.set(set(status.mention_users.all()))\n\n        # don't apply formatting to generated notes\n        if not isinstance(status, models.GeneratedNote) and content:\n            status.content = to_markdown(content)\n        # do apply formatting to quotes\n        if hasattr(status, \"quote\"):\n            status.quote = to_markdown(status.quote)\n\n        status.ready = True\n        status.save(created=created)\n\n        # update a readthrough, if needed\n        if bool(request.POST.get(\"id\")):\n            try:\n                edit_readthrough(request)\n            except Http404:\n                pass\n\n        if is_api_request(request):\n            return HttpResponse()\n        return redirect_to_referer(request)\n\n\ndef format_mentions(content, mentions):\n    \"\"\"Detect @mentions and make them links\"\"\"\n    for (mention_text, mention_user) in mentions.items():\n        # turn the mention into a link\n        content = re.sub(\n            rf\"(?<!/)\\B{mention_text}\\b(?!@)\",\n            rf'<a href=\"{mention_user.remote_id}\">{mention_text}</a>',\n            content,\n        )\n    return content\n\n\ndef format_hashtags(content, hashtags):\n    \"\"\"Detect #hashtags and make them links\"\"\"\n    for (mention_text, mention_hashtag) in hashtags.items():\n        # turn the mention into a link\n        content = re.sub(\n            rf\"(?<!/)\\B{mention_text}\\b(?!@)\",\n            rf'<a href=\"{mention_hashtag.remote_id}\" data-mention=\"hashtag\">'\n            + rf\"{mention_text}</a>\",\n            content,\n        )\n    return content\n\n\n@method_decorator(login_required, name=\"dispatch\")\nclass DeleteStatus(View):\n    \"\"\"tombstone that bad boy\"\"\"\n\n    def post(self, request, status_id):\n        \"\"\"delete and tombstone a status\"\"\"\n        status = get_object_or_404(models.Status, id=status_id)\n\n        # don't let people delete other people's statuses\n        status.raise_not_deletable(request.user)\n\n        # perform deletion\n        status.delete()\n        return redirect(\"/\")\n\n\n@login_required\n@require_POST\ndef update_progress(request, book_id):  # pylint: disable=unused-argument\n    \"\"\"Either it's just a progress update, or it's a comment with a progress update\"\"\"\n    if request.POST.get(\"post-status\"):\n        return CreateStatus.as_view()(request, \"comment\")\n    return edit_readthrough(request)\n\n\n@login_required\n@require_POST\ndef edit_readthrough(request):\n    \"\"\"can't use the form because the dates are too finnicky\"\"\"\n    # TODO: remove this, it duplicates the code in the ReadThrough view\n    readthrough = get_object_or_404(models.ReadThrough, id=request.POST.get(\"id\"))\n\n    readthrough.start_date = load_date_in_user_tz_as_utc(\n        request.POST.get(\"start_date\"), request.user\n    )\n    readthrough.finish_date = load_date_in_user_tz_as_utc(\n        request.POST.get(\"finish_date\"), request.user\n    )\n\n    progress = request.POST.get(\"progress\")\n    try:\n        progress = int(progress)\n        readthrough.progress = progress\n    except (ValueError, TypeError):\n        pass\n\n    progress_mode = request.POST.get(\"progress_mode\")\n    try:\n        progress_mode = models.ProgressMode(progress_mode)\n        readthrough.progress_mode = progress_mode\n    except ValueError:\n        pass\n\n    readthrough.save()\n\n    # record the progress update individually\n    # use default now for date field\n    readthrough.create_update()\n\n    if is_api_request(request):\n        return HttpResponse()\n    return redirect_to_referer(request)\n\n\ndef find_mentions(user, content):\n    \"\"\"detect @mentions in raw status content\"\"\"\n    if not content:\n        return {}\n    # The regex has nested match groups, so the 0th entry has the full (outer) match\n    # And because the strict username starts with @, the username is 1st char onward\n    usernames = [m[0][1:] for m in re.findall(regex.STRICT_USERNAME, content)]\n\n    known_users = (\n        models.User.viewer_aware_objects(user)\n        .filter(Q(username__in=usernames) | Q(localname__in=usernames))\n        .distinct()\n    )\n    # Prepare a lookup based on both username and localname\n    username_dict = {\n        **{f\"@{u.username}\": u for u in known_users},\n        **{f\"@{u.localname}\": u for u in known_users.filter(local=True)},\n    }\n\n    # Users not captured here could be blocked or not yet loaded on the server\n    not_found = set(usernames) - set(username_dict.keys())\n    for username in not_found:\n        mention_user = handle_remote_webfinger(username, unknown_only=True)\n        if not mention_user:\n            # this user is blocked or can't be found\n            continue\n        username_dict[f\"@{mention_user.username}\"] = mention_user\n        username_dict[f\"@{mention_user.localname}\"] = mention_user\n    return username_dict\n\n\ndef find_or_create_hashtags(content):\n    \"\"\"detect #hashtags in raw status content\n\n    it stores hashtags case-sensitive, but ensures that an existing\n    hashtag with different case are found and re-used. for example,\n    an existing #BookWyrm hashtag will be found and used even if the\n    status content is using #bookwyrm.\n    \"\"\"\n    if not content:\n        return {}\n\n    found_hashtags = {t.lower(): t for t in re.findall(regex.HASHTAG, content)}\n    if len(found_hashtags) == 0:\n        return {}\n\n    known_hashtags = {\n        t.name.lower(): t\n        for t in models.Hashtag.objects.filter(\n            Q(name__in=found_hashtags.keys())\n        ).distinct()\n    }\n\n    not_found = found_hashtags.keys() - known_hashtags.keys()\n    for lower_name in not_found:\n        tag_name = found_hashtags[lower_name]\n        mention_hashtag = models.Hashtag(name=tag_name)\n        mention_hashtag.save()\n        known_hashtags[lower_name] = mention_hashtag\n\n    return {found_hashtags[k]: v for k, v in known_hashtags.items()}\n\n\ndef format_links(content):\n    \"\"\"detect and format links\"\"\"\n    validator = URLValidator()\n    formatted_content = \"\"\n    split_content = re.split(r\"(\\s+)\", content)\n\n    for potential_link in split_content:\n        if not potential_link:\n            continue\n        wrapped = _wrapped(potential_link)\n        if wrapped:\n            wrapper_close = potential_link[-1]\n            formatted_content += potential_link[0]\n            potential_link = potential_link[1:-1]\n\n        ends_with_punctuation = _ends_with_punctuation(potential_link)\n        if ends_with_punctuation:\n            punctuation_glyph = potential_link[-1]\n            potential_link = potential_link[0:-1]\n\n        try:\n            # raises an error on anything that's not a valid link\n            validator(potential_link)\n\n            # use everything but the scheme in the presentation of the link\n            url = urlparse(potential_link)\n            link = url.netloc + url.path + url.params\n            if url.query != \"\":\n                link += \"?\" + url.query\n            if url.fragment != \"\":\n                link += \"#\" + url.fragment\n\n            formatted_content += f'<a href=\"{potential_link}\">{link}</a>'\n        except (ValidationError, UnicodeError):\n            formatted_content += potential_link\n\n        if wrapped:\n            formatted_content += wrapper_close\n\n        if ends_with_punctuation:\n            formatted_content += punctuation_glyph\n\n    return formatted_content\n\n\ndef _wrapped(text):\n    \"\"\"check if a line of text is wrapped\"\"\"\n    wrappers = [(\"(\", \")\"), (\"[\", \"]\"), (\"{\", \"}\")]\n    for wrapper in wrappers:\n        if text[0] == wrapper[0] and text[-1] == wrapper[-1]:\n            return True\n    return False\n\n\ndef _ends_with_punctuation(text):\n    \"\"\"check if a line of text ends with a punctuation glyph\"\"\"\n    glyphs = [\".\", \",\", \";\", \":\", \"!\", \"?\", \"\u201d\", \"\u2019\", '\"', \"\u00bb\"]\n    for glyph in glyphs:\n        if text[-1] == glyph:\n            return True\n    return False\n\n\ndef to_markdown(content):\n    \"\"\"catch links and convert to markdown\"\"\"\n    content = format_links(content)\n    content = markdown(content)\n    # sanitize resulting html\n    return sanitizer.clean(content)\n", "path": "bookwyrm/views/status.py"}]}