We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
Create collapsible "Private messages" section in left sidebar
At present, private messages are collapsed in the left sidebar, unless the user is in a private message narrow. This has a few down sides:

1. Getting to a PM conversation generally requires multiple clicks.
2. It's not immediately clear who send you a new private message, which is important for determining whether one needs to read it right away.
3. It can be hard for new users to figure out how to view and send private messages.

In order to address this, we should try making a private messages section in the left sidebar that is open by default. Specifically:

1. Make a Private messages section just above STREAMS in the left sidebar that is open by default.
2. In the new PMs section, use the same algorithm we use for stream topics to decide how many conversations to show.
3. Make the PMs section collapsible, similar to the collapsible sections in #20072. The open/collapsed state should be sticky as the user navigates around Zulip, closes and reopens the window, logs out and in, etc.

Note that this will likely require experimentation for us to get it right. To avoid misdirected effort, please post screenshots in the #design stream on chat.zulip.org for feedback. Also, if (3) can't be implemented quickly, we can test the experience in chat.zulip.org without waiting for it to be completed.

[Prior discussion on CZO](https://chat.zulip.org/#narrow/stream/101-design/topic/private.20messages.20UI/near/1159032).

See also #11108.

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `tools/lib/capitalization.py`
Content:
```
1 import re
2 from typing import List, Match, Tuple
3 
4 from bs4 import BeautifulSoup
5 
6 # The phrases in this list will be ignored. The longest phrase is
7 # tried first; this removes the chance of smaller phrases changing
8 # the text before longer phrases are tried.
9 # The errors shown by `tools/check-capitalization` can be added to
10 # this list without any modification.
11 IGNORED_PHRASES = [
12     # Proper nouns and acronyms
13     r"API",
14     r"APNS",
15     r"Botserver",
16     r"Cookie Bot",
17     r"DevAuthBackend",
18     r"GCM",
19     r"GitHub",
20     r"Gravatar",
21     r"Help Center",
22     r"HTTP",
23     r"ID",
24     r"IDs",
25     r"IP",
26     r"JSON",
27     r"Kerberos",
28     r"LDAP",
29     r"Markdown",
30     r"OTP",
31     r"Pivotal",
32     r"PM",
33     r"PMs",
34     r"Slack",
35     r"Google",
36     r"Terms of Service",
37     r"Tuesday",
38     r"URL",
39     r"UUID",
40     r"Webathena",
41     r"WordPress",
42     r"Zephyr",
43     r"Zoom",
44     r"Zulip",
45     r"Zulip Server",
46     r"Zulip Account Security",
47     r"Zulip Security",
48     r"Zulip Cloud Standard",
49     r"BigBlueButton",
50     # Code things
51     r"\.zuliprc",
52     # BeautifulSoup will remove <z-user> which is horribly confusing,
53     # so we need more of the sentence.
54     r"<z-user></z-user> will have the same role",
55     # Things using "I"
56     r"I understand",
57     r"I'm",
58     r"I've",
59     # Specific short words
60     r"beta",
61     r"and",
62     r"bot",
63     r"e\.g\.",
64     r"enabled",
65     r"signups",
66     # Placeholders
67     r"keyword",
68     r"streamname",
69     r"user@example\.com",
70     # Fragments of larger strings
71     (r"your subscriptions on your Streams page"),
72     r"Add global time<br />Everyone sees global times in their own time zone\.",
73     r"user",
74     r"an unknown operating system",
75     r"Go to Settings",
76     # SPECIAL CASES
77     # Because topics usually are lower-case, this would look weird if it were capitalized
78     r"more topics",
79     # Used alone in a parenthetical where capitalized looks worse.
80     r"^deprecated$",
81     # Capital 'i' looks weird in reminders popover
82     r"in 1 hour",
83     r"in 20 minutes",
84     r"in 3 hours",
85     # these are used as topics
86     r"^new streams$",
87     r"^stream events$",
88     # These are used as example short names (e.g. an uncapitalized context):
89     r"^marketing$",
90     r"^cookie$",
91     # Used to refer custom time limits
92     r"\bN\b",
93     # Capital c feels obtrusive in clear status option
94     r"clear",
95     r"group private messages with \{recipient\}",
96     r"private messages with \{recipient\}",
97     r"private messages with yourself",
98     r"GIF",
99     # Emoji name placeholder
100     r"leafy green vegetable",
101     # Subdomain placeholder
102     r"your-organization-url",
103     # Used in invite modal
104     r"or",
105     # Used in GIPHY popover.
106     r"GIFs",
107     r"GIPHY",
108     # Used in our case studies
109     r"Technical University of Munich",
110     r"University of California San Diego",
111     # Used in stream creation form
112     r"email hidden",
113     # Use in compose box.
114     r"to send",
115     r"to add a new line",
116     # Used in showing Notification Bot read receipts message
117     "Notification Bot",
118     # Used in presence_enabled setting label
119     r"invisible mode off",
120     # Typeahead suggestions for "Pronouns" custom field type.
121     r"he/him",
122     r"she/her",
123     r"they/them",
124 ]
125 
126 # Sort regexes in descending order of their lengths. As a result, the
127 # longer phrases will be ignored first.
128 IGNORED_PHRASES.sort(key=lambda regex: len(regex), reverse=True)
129 
130 # Compile regexes to improve performance. This also extracts the
131 # text using BeautifulSoup and then removes extra whitespaces from
132 # it. This step enables us to add HTML in our regexes directly.
133 COMPILED_IGNORED_PHRASES = [
134     re.compile(" ".join(BeautifulSoup(regex, "lxml").text.split())) for regex in IGNORED_PHRASES
135 ]
136 
137 SPLIT_BOUNDARY = "?.!"  # Used to split string into sentences.
138 SPLIT_BOUNDARY_REGEX = re.compile(rf"[{SPLIT_BOUNDARY}]")
139 
140 # Regexes which check capitalization in sentences.
141 DISALLOWED = [
142     r"^[a-z](?!\})",  # Checks if the sentence starts with a lower case character.
143     r"^[A-Z][a-z]+[\sa-z0-9]+[A-Z]",  # Checks if an upper case character exists
144     # after a lower case character when the first character is in upper case.
145 ]
146 DISALLOWED_REGEX = re.compile(r"|".join(DISALLOWED))
147 
148 BANNED_WORDS = {
149     "realm": "The term realm should not appear in user-facing strings. Use organization instead.",
150 }
151 
152 
153 def get_safe_phrase(phrase: str) -> str:
154     """
155     Safe phrase is in lower case and doesn't contain characters which can
156     conflict with split boundaries. All conflicting characters are replaced
157     with low dash (_).
158     """
159     phrase = SPLIT_BOUNDARY_REGEX.sub("_", phrase)
160     return phrase.lower()
161 
162 
163 def replace_with_safe_phrase(matchobj: Match[str]) -> str:
164     """
165     The idea is to convert IGNORED_PHRASES into safe phrases, see
166     `get_safe_phrase()` function. The only exception is when the
167     IGNORED_PHRASE is at the start of the text or after a split
168     boundary; in this case, we change the first letter of the phrase
169     to upper case.
170     """
171     ignored_phrase = matchobj.group(0)
172     safe_string = get_safe_phrase(ignored_phrase)
173 
174     start_index = matchobj.start()
175     complete_string = matchobj.string
176 
177     is_string_start = start_index == 0
178     # We expect that there will be one space between split boundary
179     # and the next word.
180     punctuation = complete_string[max(start_index - 2, 0)]
181     is_after_split_boundary = punctuation in SPLIT_BOUNDARY
182     if is_string_start or is_after_split_boundary:
183         return safe_string.capitalize()
184 
185     return safe_string
186 
187 
188 def get_safe_text(text: str) -> str:
189     """
190     This returns text which is rendered by BeautifulSoup and is in the
191     form that can be split easily and has all IGNORED_PHRASES processed.
192     """
193     soup = BeautifulSoup(text, "lxml")
194     text = " ".join(soup.text.split())  # Remove extra whitespaces.
195     for phrase_regex in COMPILED_IGNORED_PHRASES:
196         text = phrase_regex.sub(replace_with_safe_phrase, text)
197 
198     return text
199 
200 
201 def is_capitalized(safe_text: str) -> bool:
202     sentences = SPLIT_BOUNDARY_REGEX.split(safe_text)
203     return not any(DISALLOWED_REGEX.search(sentence.strip()) for sentence in sentences)
204 
205 
206 def check_banned_words(text: str) -> List[str]:
207     lower_cased_text = text.lower()
208     errors = []
209     for word, reason in BANNED_WORDS.items():
210         if word in lower_cased_text:
211             # Hack: Should move this into BANNED_WORDS framework; for
212             # now, just hand-code the skips:
213             if "realm_name" in lower_cased_text:
214                 continue
215             kwargs = dict(word=word, text=text, reason=reason)
216             msg = "{word} found in '{text}'. {reason}".format(**kwargs)
217             errors.append(msg)
218 
219     return errors
220 
221 
222 def check_capitalization(strings: List[str]) -> Tuple[List[str], List[str], List[str]]:
223     errors = []
224     ignored = []
225     banned_word_errors = []
226     for text in strings:
227         text = " ".join(text.split())  # Remove extra whitespaces.
228         safe_text = get_safe_text(text)
229         has_ignored_phrase = text != safe_text
230         capitalized = is_capitalized(safe_text)
231         if not capitalized:
232             errors.append(text)
233         elif has_ignored_phrase:
234             ignored.append(text)
235 
236         banned_word_errors.extend(check_banned_words(text))
237 
238     return sorted(errors), sorted(ignored), sorted(banned_word_errors)
239
```
--- 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/tools/lib/capitalization.py b/tools/lib/capitalization.py
--- a/tools/lib/capitalization.py
+++ b/tools/lib/capitalization.py
@@ -78,6 +78,9 @@
     r"more topics",
     # Used alone in a parenthetical where capitalized looks worse.
     r"^deprecated$",
+    # We want the similar text in the Private Messages section to have the same capitalization.
+    r"more conversations",
+    r"back to streams",
     # Capital 'i' looks weird in reminders popover
     r"in 1 hour",
     r"in 20 minutes",

{"golden_diff": "diff --git a/tools/lib/capitalization.py b/tools/lib/capitalization.py\n--- a/tools/lib/capitalization.py\n+++ b/tools/lib/capitalization.py\n@@ -78,6 +78,9 @@\n     r\"more topics\",\n     # Used alone in a parenthetical where capitalized looks worse.\n     r\"^deprecated$\",\n+    # We want the similar text in the Private Messages section to have the same capitalization.\n+    r\"more conversations\",\n+    r\"back to streams\",\n     # Capital 'i' looks weird in reminders popover\n     r\"in 1 hour\",\n     r\"in 20 minutes\",\n", "issue": "Create collapsible \"Private messages\" section in left sidebar\nAt present, private messages are collapsed in the left sidebar, unless the user is in a private message narrow. This has a few down sides:\r\n\r\n1. Getting to a PM conversation generally requires multiple clicks.\r\n2. It's not immediately clear who send you a new private message, which is important for determining whether one needs to read it right away.\r\n3. It can be hard for new users to figure out how to view and send private messages.\r\n\r\nIn order to address this, we should try making a private messages section in the left sidebar that is open by default. Specifically:\r\n\r\n1. Make a Private messages section just above STREAMS in the left sidebar that is open by default.\r\n2. In the new PMs section, use the same algorithm we use for stream topics to decide how many conversations to show.\r\n3. Make the PMs section collapsible, similar to the collapsible sections in #20072. The open/collapsed state should be sticky as the user navigates around Zulip, closes and reopens the window, logs out and in, etc.\r\n\r\nNote that this will likely require experimentation for us to get it right. To avoid misdirected effort, please post screenshots in the #design stream on chat.zulip.org for feedback. Also, if (3) can't be implemented quickly, we can test the experience in chat.zulip.org without waiting for it to be completed.\r\n\r\n[Prior discussion on CZO](https://chat.zulip.org/#narrow/stream/101-design/topic/private.20messages.20UI/near/1159032).\r\n\r\nSee also #11108.\n", "before_files": [{"content": "import re\nfrom typing import List, Match, Tuple\n\nfrom bs4 import BeautifulSoup\n\n# The phrases in this list will be ignored. The longest phrase is\n# tried first; this removes the chance of smaller phrases changing\n# the text before longer phrases are tried.\n# The errors shown by `tools/check-capitalization` can be added to\n# this list without any modification.\nIGNORED_PHRASES = [\n    # Proper nouns and acronyms\n    r\"API\",\n    r\"APNS\",\n    r\"Botserver\",\n    r\"Cookie Bot\",\n    r\"DevAuthBackend\",\n    r\"GCM\",\n    r\"GitHub\",\n    r\"Gravatar\",\n    r\"Help Center\",\n    r\"HTTP\",\n    r\"ID\",\n    r\"IDs\",\n    r\"IP\",\n    r\"JSON\",\n    r\"Kerberos\",\n    r\"LDAP\",\n    r\"Markdown\",\n    r\"OTP\",\n    r\"Pivotal\",\n    r\"PM\",\n    r\"PMs\",\n    r\"Slack\",\n    r\"Google\",\n    r\"Terms of Service\",\n    r\"Tuesday\",\n    r\"URL\",\n    r\"UUID\",\n    r\"Webathena\",\n    r\"WordPress\",\n    r\"Zephyr\",\n    r\"Zoom\",\n    r\"Zulip\",\n    r\"Zulip Server\",\n    r\"Zulip Account Security\",\n    r\"Zulip Security\",\n    r\"Zulip Cloud Standard\",\n    r\"BigBlueButton\",\n    # Code things\n    r\"\\.zuliprc\",\n    # BeautifulSoup will remove <z-user> which is horribly confusing,\n    # so we need more of the sentence.\n    r\"<z-user></z-user> will have the same role\",\n    # Things using \"I\"\n    r\"I understand\",\n    r\"I'm\",\n    r\"I've\",\n    # Specific short words\n    r\"beta\",\n    r\"and\",\n    r\"bot\",\n    r\"e\\.g\\.\",\n    r\"enabled\",\n    r\"signups\",\n    # Placeholders\n    r\"keyword\",\n    r\"streamname\",\n    r\"user@example\\.com\",\n    # Fragments of larger strings\n    (r\"your subscriptions on your Streams page\"),\n    r\"Add global time<br />Everyone sees global times in their own time zone\\.\",\n    r\"user\",\n    r\"an unknown operating system\",\n    r\"Go to Settings\",\n    # SPECIAL CASES\n    # Because topics usually are lower-case, this would look weird if it were capitalized\n    r\"more topics\",\n    # Used alone in a parenthetical where capitalized looks worse.\n    r\"^deprecated$\",\n    # Capital 'i' looks weird in reminders popover\n    r\"in 1 hour\",\n    r\"in 20 minutes\",\n    r\"in 3 hours\",\n    # these are used as topics\n    r\"^new streams$\",\n    r\"^stream events$\",\n    # These are used as example short names (e.g. an uncapitalized context):\n    r\"^marketing$\",\n    r\"^cookie$\",\n    # Used to refer custom time limits\n    r\"\\bN\\b\",\n    # Capital c feels obtrusive in clear status option\n    r\"clear\",\n    r\"group private messages with \\{recipient\\}\",\n    r\"private messages with \\{recipient\\}\",\n    r\"private messages with yourself\",\n    r\"GIF\",\n    # Emoji name placeholder\n    r\"leafy green vegetable\",\n    # Subdomain placeholder\n    r\"your-organization-url\",\n    # Used in invite modal\n    r\"or\",\n    # Used in GIPHY popover.\n    r\"GIFs\",\n    r\"GIPHY\",\n    # Used in our case studies\n    r\"Technical University of Munich\",\n    r\"University of California San Diego\",\n    # Used in stream creation form\n    r\"email hidden\",\n    # Use in compose box.\n    r\"to send\",\n    r\"to add a new line\",\n    # Used in showing Notification Bot read receipts message\n    \"Notification Bot\",\n    # Used in presence_enabled setting label\n    r\"invisible mode off\",\n    # Typeahead suggestions for \"Pronouns\" custom field type.\n    r\"he/him\",\n    r\"she/her\",\n    r\"they/them\",\n]\n\n# Sort regexes in descending order of their lengths. As a result, the\n# longer phrases will be ignored first.\nIGNORED_PHRASES.sort(key=lambda regex: len(regex), reverse=True)\n\n# Compile regexes to improve performance. This also extracts the\n# text using BeautifulSoup and then removes extra whitespaces from\n# it. This step enables us to add HTML in our regexes directly.\nCOMPILED_IGNORED_PHRASES = [\n    re.compile(\" \".join(BeautifulSoup(regex, \"lxml\").text.split())) for regex in IGNORED_PHRASES\n]\n\nSPLIT_BOUNDARY = \"?.!\"  # Used to split string into sentences.\nSPLIT_BOUNDARY_REGEX = re.compile(rf\"[{SPLIT_BOUNDARY}]\")\n\n# Regexes which check capitalization in sentences.\nDISALLOWED = [\n    r\"^[a-z](?!\\})\",  # Checks if the sentence starts with a lower case character.\n    r\"^[A-Z][a-z]+[\\sa-z0-9]+[A-Z]\",  # Checks if an upper case character exists\n    # after a lower case character when the first character is in upper case.\n]\nDISALLOWED_REGEX = re.compile(r\"|\".join(DISALLOWED))\n\nBANNED_WORDS = {\n    \"realm\": \"The term realm should not appear in user-facing strings. Use organization instead.\",\n}\n\n\ndef get_safe_phrase(phrase: str) -> str:\n    \"\"\"\n    Safe phrase is in lower case and doesn't contain characters which can\n    conflict with split boundaries. All conflicting characters are replaced\n    with low dash (_).\n    \"\"\"\n    phrase = SPLIT_BOUNDARY_REGEX.sub(\"_\", phrase)\n    return phrase.lower()\n\n\ndef replace_with_safe_phrase(matchobj: Match[str]) -> str:\n    \"\"\"\n    The idea is to convert IGNORED_PHRASES into safe phrases, see\n    `get_safe_phrase()` function. The only exception is when the\n    IGNORED_PHRASE is at the start of the text or after a split\n    boundary; in this case, we change the first letter of the phrase\n    to upper case.\n    \"\"\"\n    ignored_phrase = matchobj.group(0)\n    safe_string = get_safe_phrase(ignored_phrase)\n\n    start_index = matchobj.start()\n    complete_string = matchobj.string\n\n    is_string_start = start_index == 0\n    # We expect that there will be one space between split boundary\n    # and the next word.\n    punctuation = complete_string[max(start_index - 2, 0)]\n    is_after_split_boundary = punctuation in SPLIT_BOUNDARY\n    if is_string_start or is_after_split_boundary:\n        return safe_string.capitalize()\n\n    return safe_string\n\n\ndef get_safe_text(text: str) -> str:\n    \"\"\"\n    This returns text which is rendered by BeautifulSoup and is in the\n    form that can be split easily and has all IGNORED_PHRASES processed.\n    \"\"\"\n    soup = BeautifulSoup(text, \"lxml\")\n    text = \" \".join(soup.text.split())  # Remove extra whitespaces.\n    for phrase_regex in COMPILED_IGNORED_PHRASES:\n        text = phrase_regex.sub(replace_with_safe_phrase, text)\n\n    return text\n\n\ndef is_capitalized(safe_text: str) -> bool:\n    sentences = SPLIT_BOUNDARY_REGEX.split(safe_text)\n    return not any(DISALLOWED_REGEX.search(sentence.strip()) for sentence in sentences)\n\n\ndef check_banned_words(text: str) -> List[str]:\n    lower_cased_text = text.lower()\n    errors = []\n    for word, reason in BANNED_WORDS.items():\n        if word in lower_cased_text:\n            # Hack: Should move this into BANNED_WORDS framework; for\n            # now, just hand-code the skips:\n            if \"realm_name\" in lower_cased_text:\n                continue\n            kwargs = dict(word=word, text=text, reason=reason)\n            msg = \"{word} found in '{text}'. {reason}\".format(**kwargs)\n            errors.append(msg)\n\n    return errors\n\n\ndef check_capitalization(strings: List[str]) -> Tuple[List[str], List[str], List[str]]:\n    errors = []\n    ignored = []\n    banned_word_errors = []\n    for text in strings:\n        text = \" \".join(text.split())  # Remove extra whitespaces.\n        safe_text = get_safe_text(text)\n        has_ignored_phrase = text != safe_text\n        capitalized = is_capitalized(safe_text)\n        if not capitalized:\n            errors.append(text)\n        elif has_ignored_phrase:\n            ignored.append(text)\n\n        banned_word_errors.extend(check_banned_words(text))\n\n    return sorted(errors), sorted(ignored), sorted(banned_word_errors)\n", "path": "tools/lib/capitalization.py"}], "after_files": [{"content": "import re\nfrom typing import List, Match, Tuple\n\nfrom bs4 import BeautifulSoup\n\n# The phrases in this list will be ignored. The longest phrase is\n# tried first; this removes the chance of smaller phrases changing\n# the text before longer phrases are tried.\n# The errors shown by `tools/check-capitalization` can be added to\n# this list without any modification.\nIGNORED_PHRASES = [\n    # Proper nouns and acronyms\n    r\"API\",\n    r\"APNS\",\n    r\"Botserver\",\n    r\"Cookie Bot\",\n    r\"DevAuthBackend\",\n    r\"GCM\",\n    r\"GitHub\",\n    r\"Gravatar\",\n    r\"Help Center\",\n    r\"HTTP\",\n    r\"ID\",\n    r\"IDs\",\n    r\"IP\",\n    r\"JSON\",\n    r\"Kerberos\",\n    r\"LDAP\",\n    r\"Markdown\",\n    r\"OTP\",\n    r\"Pivotal\",\n    r\"PM\",\n    r\"PMs\",\n    r\"Slack\",\n    r\"Google\",\n    r\"Terms of Service\",\n    r\"Tuesday\",\n    r\"URL\",\n    r\"UUID\",\n    r\"Webathena\",\n    r\"WordPress\",\n    r\"Zephyr\",\n    r\"Zoom\",\n    r\"Zulip\",\n    r\"Zulip Server\",\n    r\"Zulip Account Security\",\n    r\"Zulip Security\",\n    r\"Zulip Cloud Standard\",\n    r\"BigBlueButton\",\n    # Code things\n    r\"\\.zuliprc\",\n    # BeautifulSoup will remove <z-user> which is horribly confusing,\n    # so we need more of the sentence.\n    r\"<z-user></z-user> will have the same role\",\n    # Things using \"I\"\n    r\"I understand\",\n    r\"I'm\",\n    r\"I've\",\n    # Specific short words\n    r\"beta\",\n    r\"and\",\n    r\"bot\",\n    r\"e\\.g\\.\",\n    r\"enabled\",\n    r\"signups\",\n    # Placeholders\n    r\"keyword\",\n    r\"streamname\",\n    r\"user@example\\.com\",\n    # Fragments of larger strings\n    (r\"your subscriptions on your Streams page\"),\n    r\"Add global time<br />Everyone sees global times in their own time zone\\.\",\n    r\"user\",\n    r\"an unknown operating system\",\n    r\"Go to Settings\",\n    # SPECIAL CASES\n    # Because topics usually are lower-case, this would look weird if it were capitalized\n    r\"more topics\",\n    # Used alone in a parenthetical where capitalized looks worse.\n    r\"^deprecated$\",\n    # We want the similar text in the Private Messages section to have the same capitalization.\n    r\"more conversations\",\n    r\"back to streams\",\n    # Capital 'i' looks weird in reminders popover\n    r\"in 1 hour\",\n    r\"in 20 minutes\",\n    r\"in 3 hours\",\n    # these are used as topics\n    r\"^new streams$\",\n    r\"^stream events$\",\n    # These are used as example short names (e.g. an uncapitalized context):\n    r\"^marketing$\",\n    r\"^cookie$\",\n    # Used to refer custom time limits\n    r\"\\bN\\b\",\n    # Capital c feels obtrusive in clear status option\n    r\"clear\",\n    r\"group private messages with \\{recipient\\}\",\n    r\"private messages with \\{recipient\\}\",\n    r\"private messages with yourself\",\n    r\"GIF\",\n    # Emoji name placeholder\n    r\"leafy green vegetable\",\n    # Subdomain placeholder\n    r\"your-organization-url\",\n    # Used in invite modal\n    r\"or\",\n    # Used in GIPHY popover.\n    r\"GIFs\",\n    r\"GIPHY\",\n    # Used in our case studies\n    r\"Technical University of Munich\",\n    r\"University of California San Diego\",\n    # Used in stream creation form\n    r\"email hidden\",\n    # Use in compose box.\n    r\"to send\",\n    r\"to add a new line\",\n    # Used in showing Notification Bot read receipts message\n    \"Notification Bot\",\n    # Used in presence_enabled setting label\n    r\"invisible mode off\",\n    # Typeahead suggestions for \"Pronouns\" custom field type.\n    r\"he/him\",\n    r\"she/her\",\n    r\"they/them\",\n]\n\n# Sort regexes in descending order of their lengths. As a result, the\n# longer phrases will be ignored first.\nIGNORED_PHRASES.sort(key=lambda regex: len(regex), reverse=True)\n\n# Compile regexes to improve performance. This also extracts the\n# text using BeautifulSoup and then removes extra whitespaces from\n# it. This step enables us to add HTML in our regexes directly.\nCOMPILED_IGNORED_PHRASES = [\n    re.compile(\" \".join(BeautifulSoup(regex, \"lxml\").text.split())) for regex in IGNORED_PHRASES\n]\n\nSPLIT_BOUNDARY = \"?.!\"  # Used to split string into sentences.\nSPLIT_BOUNDARY_REGEX = re.compile(rf\"[{SPLIT_BOUNDARY}]\")\n\n# Regexes which check capitalization in sentences.\nDISALLOWED = [\n    r\"^[a-z](?!\\})\",  # Checks if the sentence starts with a lower case character.\n    r\"^[A-Z][a-z]+[\\sa-z0-9]+[A-Z]\",  # Checks if an upper case character exists\n    # after a lower case character when the first character is in upper case.\n]\nDISALLOWED_REGEX = re.compile(r\"|\".join(DISALLOWED))\n\nBANNED_WORDS = {\n    \"realm\": \"The term realm should not appear in user-facing strings. Use organization instead.\",\n}\n\n\ndef get_safe_phrase(phrase: str) -> str:\n    \"\"\"\n    Safe phrase is in lower case and doesn't contain characters which can\n    conflict with split boundaries. All conflicting characters are replaced\n    with low dash (_).\n    \"\"\"\n    phrase = SPLIT_BOUNDARY_REGEX.sub(\"_\", phrase)\n    return phrase.lower()\n\n\ndef replace_with_safe_phrase(matchobj: Match[str]) -> str:\n    \"\"\"\n    The idea is to convert IGNORED_PHRASES into safe phrases, see\n    `get_safe_phrase()` function. The only exception is when the\n    IGNORED_PHRASE is at the start of the text or after a split\n    boundary; in this case, we change the first letter of the phrase\n    to upper case.\n    \"\"\"\n    ignored_phrase = matchobj.group(0)\n    safe_string = get_safe_phrase(ignored_phrase)\n\n    start_index = matchobj.start()\n    complete_string = matchobj.string\n\n    is_string_start = start_index == 0\n    # We expect that there will be one space between split boundary\n    # and the next word.\n    punctuation = complete_string[max(start_index - 2, 0)]\n    is_after_split_boundary = punctuation in SPLIT_BOUNDARY\n    if is_string_start or is_after_split_boundary:\n        return safe_string.capitalize()\n\n    return safe_string\n\n\ndef get_safe_text(text: str) -> str:\n    \"\"\"\n    This returns text which is rendered by BeautifulSoup and is in the\n    form that can be split easily and has all IGNORED_PHRASES processed.\n    \"\"\"\n    soup = BeautifulSoup(text, \"lxml\")\n    text = \" \".join(soup.text.split())  # Remove extra whitespaces.\n    for phrase_regex in COMPILED_IGNORED_PHRASES:\n        text = phrase_regex.sub(replace_with_safe_phrase, text)\n\n    return text\n\n\ndef is_capitalized(safe_text: str) -> bool:\n    sentences = SPLIT_BOUNDARY_REGEX.split(safe_text)\n    return not any(DISALLOWED_REGEX.search(sentence.strip()) for sentence in sentences)\n\n\ndef check_banned_words(text: str) -> List[str]:\n    lower_cased_text = text.lower()\n    errors = []\n    for word, reason in BANNED_WORDS.items():\n        if word in lower_cased_text:\n            # Hack: Should move this into BANNED_WORDS framework; for\n            # now, just hand-code the skips:\n            if \"realm_name\" in lower_cased_text:\n                continue\n            kwargs = dict(word=word, text=text, reason=reason)\n            msg = \"{word} found in '{text}'. {reason}\".format(**kwargs)\n            errors.append(msg)\n\n    return errors\n\n\ndef check_capitalization(strings: List[str]) -> Tuple[List[str], List[str], List[str]]:\n    errors = []\n    ignored = []\n    banned_word_errors = []\n    for text in strings:\n        text = \" \".join(text.split())  # Remove extra whitespaces.\n        safe_text = get_safe_text(text)\n        has_ignored_phrase = text != safe_text\n        capitalized = is_capitalized(safe_text)\n        if not capitalized:\n            errors.append(text)\n        elif has_ignored_phrase:\n            ignored.append(text)\n\n        banned_word_errors.extend(check_banned_words(text))\n\n    return sorted(errors), sorted(ignored), sorted(banned_word_errors)\n", "path": "tools/lib/capitalization.py"}]}