We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Hashtags are catching parts of urls
Links with anchors (https://www.example.com/test#three) are getting parsed as containing hashtags

(This was reported to me via email)

--- 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         for (mention_text, mention_user) in find_mentions(
100             request.user, content
101         ).items():
102             # add them to status mentions fk
103             status.mention_users.add(mention_user)
104 
105             # turn the mention into a link
106             content = re.sub(
107                 rf"{mention_text}\b(?!@)",
108                 rf'<a href="{mention_user.remote_id}">{mention_text}</a>',
109                 content,
110             )
111         # add reply parent to mentions
112         if status.reply_parent:
113             status.mention_users.add(status.reply_parent.user)
114 
115         # inspect the text for hashtags
116         for (mention_text, mention_hashtag) in find_or_create_hashtags(content).items():
117             # add them to status mentions fk
118             status.mention_hashtags.add(mention_hashtag)
119 
120             # turn the mention into a link
121             content = re.sub(
122                 rf"{mention_text}\b(?!@)",
123                 rf'<a href="{mention_hashtag.remote_id}" data-mention="hashtag">'
124                 + rf"{mention_text}</a>",
125                 content,
126             )
127 
128         # deduplicate mentions
129         status.mention_users.set(set(status.mention_users.all()))
130 
131         # don't apply formatting to generated notes
132         if not isinstance(status, models.GeneratedNote) and content:
133             status.content = to_markdown(content)
134         # do apply formatting to quotes
135         if hasattr(status, "quote"):
136             status.quote = to_markdown(status.quote)
137 
138         status.ready = True
139         status.save(created=created)
140 
141         # update a readthrough, if needed
142         if bool(request.POST.get("id")):
143             try:
144                 edit_readthrough(request)
145             except Http404:
146                 pass
147 
148         if is_api_request(request):
149             return HttpResponse()
150         return redirect_to_referer(request)
151 
152 
153 @method_decorator(login_required, name="dispatch")
154 class DeleteStatus(View):
155     """tombstone that bad boy"""
156 
157     def post(self, request, status_id):
158         """delete and tombstone a status"""
159         status = get_object_or_404(models.Status, id=status_id)
160 
161         # don't let people delete other people's statuses
162         status.raise_not_deletable(request.user)
163 
164         # perform deletion
165         status.delete()
166         return redirect("/")
167 
168 
169 @login_required
170 @require_POST
171 def update_progress(request, book_id):  # pylint: disable=unused-argument
172     """Either it's just a progress update, or it's a comment with a progress update"""
173     if request.POST.get("post-status"):
174         return CreateStatus.as_view()(request, "comment")
175     return edit_readthrough(request)
176 
177 
178 @login_required
179 @require_POST
180 def edit_readthrough(request):
181     """can't use the form because the dates are too finnicky"""
182     # TODO: remove this, it duplicates the code in the ReadThrough view
183     readthrough = get_object_or_404(models.ReadThrough, id=request.POST.get("id"))
184 
185     readthrough.start_date = load_date_in_user_tz_as_utc(
186         request.POST.get("start_date"), request.user
187     )
188     readthrough.finish_date = load_date_in_user_tz_as_utc(
189         request.POST.get("finish_date"), request.user
190     )
191 
192     progress = request.POST.get("progress")
193     try:
194         progress = int(progress)
195         readthrough.progress = progress
196     except (ValueError, TypeError):
197         pass
198 
199     progress_mode = request.POST.get("progress_mode")
200     try:
201         progress_mode = models.ProgressMode(progress_mode)
202         readthrough.progress_mode = progress_mode
203     except ValueError:
204         pass
205 
206     readthrough.save()
207 
208     # record the progress update individually
209     # use default now for date field
210     readthrough.create_update()
211 
212     if is_api_request(request):
213         return HttpResponse()
214     return redirect_to_referer(request)
215 
216 
217 def find_mentions(user, content):
218     """detect @mentions in raw status content"""
219     if not content:
220         return {}
221     # The regex has nested match groups, so the 0th entry has the full (outer) match
222     # And beacuse the strict username starts with @, the username is 1st char onward
223     usernames = [m[0][1:] for m in re.findall(regex.STRICT_USERNAME, content)]
224 
225     known_users = (
226         models.User.viewer_aware_objects(user)
227         .filter(Q(username__in=usernames) | Q(localname__in=usernames))
228         .distinct()
229     )
230     # Prepare a lookup based on both username and localname
231     username_dict = {
232         **{f"@{u.username}": u for u in known_users},
233         **{f"@{u.localname}": u for u in known_users.filter(local=True)},
234     }
235 
236     # Users not captured here could be blocked or not yet loaded on the server
237     not_found = set(usernames) - set(username_dict.keys())
238     for username in not_found:
239         mention_user = handle_remote_webfinger(username, unknown_only=True)
240         if not mention_user:
241             # this user is blocked or can't be found
242             continue
243         username_dict[f"@{mention_user.username}"] = mention_user
244         username_dict[f"@{mention_user.localname}"] = mention_user
245     return username_dict
246 
247 
248 def find_or_create_hashtags(content):
249     """detect #hashtags in raw status content
250 
251     it stores hashtags case-sensitive, but ensures that an existing
252     hashtag with different case are found and re-used. for example,
253     an existing #BookWyrm hashtag will be found and used even if the
254     status content is using #bookwyrm.
255     """
256     if not content:
257         return {}
258 
259     found_hashtags = {t.lower(): t for t in re.findall(regex.HASHTAG, content)}
260     if len(found_hashtags) == 0:
261         return {}
262 
263     known_hashtags = {
264         t.name.lower(): t
265         for t in models.Hashtag.objects.filter(
266             Q(name__in=found_hashtags.keys())
267         ).distinct()
268     }
269 
270     not_found = found_hashtags.keys() - known_hashtags.keys()
271     for lower_name in not_found:
272         tag_name = found_hashtags[lower_name]
273         mention_hashtag = models.Hashtag(name=tag_name)
274         mention_hashtag.save()
275         known_hashtags[lower_name] = mention_hashtag
276 
277     return {found_hashtags[k]: v for k, v in known_hashtags.items()}
278 
279 
280 def format_links(content):
281     """detect and format links"""
282     validator = URLValidator()
283     formatted_content = ""
284     split_content = re.split(r"(\s+)", content)
285 
286     for potential_link in split_content:
287         if not potential_link:
288             continue
289         wrapped = _wrapped(potential_link)
290         if wrapped:
291             wrapper_close = potential_link[-1]
292             formatted_content += potential_link[0]
293             potential_link = potential_link[1:-1]
294 
295         try:
296             # raises an error on anything that's not a valid link
297             validator(potential_link)
298 
299             # use everything but the scheme in the presentation of the link
300             url = urlparse(potential_link)
301             link = url.netloc + url.path + url.params
302             if url.query != "":
303                 link += "?" + url.query
304             if url.fragment != "":
305                 link += "#" + url.fragment
306 
307             formatted_content += f'<a href="{potential_link}">{link}</a>'
308         except (ValidationError, UnicodeError):
309             formatted_content += potential_link
310 
311         if wrapped:
312             formatted_content += wrapper_close
313 
314     return formatted_content
315 
316 
317 def _wrapped(text):
318     """check if a line of text is wrapped"""
319     wrappers = [("(", ")"), ("[", "]"), ("{", "}")]
320     for wrapper in wrappers:
321         if text[0] == wrapper[0] and text[-1] == wrapper[-1]:
322             return True
323     return False
324 
325 
326 def to_markdown(content):
327     """catch links and convert to markdown"""
328     content = format_links(content)
329     content = markdown(content)
330     # sanitize resulting html
331     return sanitizer.clean(content)
332
```
--- 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
@@ -96,34 +96,22 @@
 
         # inspect the text for user tags
         content = status.content
-        for (mention_text, mention_user) in find_mentions(
-            request.user, content
-        ).items():
+        mentions = find_mentions(request.user, content)
+        for (_, mention_user) in mentions.items():
             # add them to status mentions fk
             status.mention_users.add(mention_user)
+        content = format_mentions(content, mentions)
 
-            # turn the mention into a link
-            content = re.sub(
-                rf"{mention_text}\b(?!@)",
-                rf'<a href="{mention_user.remote_id}">{mention_text}</a>',
-                content,
-            )
         # add reply parent to mentions
         if status.reply_parent:
             status.mention_users.add(status.reply_parent.user)
 
         # inspect the text for hashtags
-        for (mention_text, mention_hashtag) in find_or_create_hashtags(content).items():
+        hashtags = find_or_create_hashtags(content)
+        for (_, mention_hashtag) in hashtags.items():
             # add them to status mentions fk
             status.mention_hashtags.add(mention_hashtag)
-
-            # turn the mention into a link
-            content = re.sub(
-                rf"{mention_text}\b(?!@)",
-                rf'<a href="{mention_hashtag.remote_id}" data-mention="hashtag">'
-                + rf"{mention_text}</a>",
-                content,
-            )
+        content = format_hashtags(content, hashtags)
 
         # deduplicate mentions
         status.mention_users.set(set(status.mention_users.all()))
@@ -150,6 +138,31 @@
         return redirect_to_referer(request)
 
 
+def format_mentions(content, mentions):
+    """Detect @mentions and make them links"""
+    for (mention_text, mention_user) in mentions.items():
+        # turn the mention into a link
+        content = re.sub(
+            rf"(?<!/)\B{mention_text}\b(?!@)",
+            rf'<a href="{mention_user.remote_id}">{mention_text}</a>',
+            content,
+        )
+    return content
+
+
+def format_hashtags(content, hashtags):
+    """Detect #hashtags and make them links"""
+    for (mention_text, mention_hashtag) in hashtags.items():
+        # turn the mention into a link
+        content = re.sub(
+            rf"(?<!/)\B{mention_text}\b(?!@)",
+            rf'<a href="{mention_hashtag.remote_id}" data-mention="hashtag">'
+            + rf"{mention_text}</a>",
+            content,
+        )
+    return content
+
+
 @method_decorator(login_required, name="dispatch")
 class DeleteStatus(View):
     """tombstone that bad boy"""

{"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@@ -96,34 +96,22 @@\n \n         # inspect the text for user tags\n         content = status.content\n-        for (mention_text, mention_user) in find_mentions(\n-            request.user, content\n-        ).items():\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-            # turn the mention into a link\n-            content = re.sub(\n-                rf\"{mention_text}\\b(?!@)\",\n-                rf'<a href=\"{mention_user.remote_id}\">{mention_text}</a>',\n-                content,\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-        for (mention_text, mention_hashtag) in find_or_create_hashtags(content).items():\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-\n-            # turn the mention into a link\n-            content = re.sub(\n-                rf\"{mention_text}\\b(?!@)\",\n-                rf'<a href=\"{mention_hashtag.remote_id}\" data-mention=\"hashtag\">'\n-                + rf\"{mention_text}</a>\",\n-                content,\n-            )\n+        content = format_hashtags(content, hashtags)\n \n         # deduplicate mentions\n         status.mention_users.set(set(status.mention_users.all()))\n@@ -150,6 +138,31 @@\n         return redirect_to_referer(request)\n \n \n+def 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+\n+def 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\")\n class DeleteStatus(View):\n     \"\"\"tombstone that bad boy\"\"\"\n", "issue": "Hashtags are catching parts of urls\nLinks with anchors (https://www.example.com/test#three) are getting parsed as containing hashtags\r\n\r\n(This was reported to me via email)\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        for (mention_text, mention_user) in find_mentions(\n            request.user, content\n        ).items():\n            # add them to status mentions fk\n            status.mention_users.add(mention_user)\n\n            # turn the mention into a link\n            content = re.sub(\n                rf\"{mention_text}\\b(?!@)\",\n                rf'<a href=\"{mention_user.remote_id}\">{mention_text}</a>',\n                content,\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        for (mention_text, mention_hashtag) in find_or_create_hashtags(content).items():\n            # add them to status mentions fk\n            status.mention_hashtags.add(mention_hashtag)\n\n            # turn the mention into a link\n            content = re.sub(\n                rf\"{mention_text}\\b(?!@)\",\n                rf'<a href=\"{mention_hashtag.remote_id}\" data-mention=\"hashtag\">'\n                + rf\"{mention_text}</a>\",\n                content,\n            )\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\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 beacuse 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 beacuse 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"}]}