# HG changeset patch # User Goffi # Date 1757169046 -7200 # Node ID c03297bb8d19d07250fd81908b6aa6813ed798f4 # Parent b30878c8b633216daaf961e60ca115fa6d3a71e3 server, browser (forums): Redesign of the forum feature: Forum has been fully redesigned, it now uses pubsub relationships and pubsub extended discovery to handle the hierarchy. Categories lead to topics (which are the name of the items of a blog-like nodes). Topics have comment nodes which are used to show the topic messages. Subscription state is retrieve when a thread is shown, and can be changed easily with as ingle button click. Quill editor is used, and is extended with Quill-Mention to easily send a mention to an XMPP entity. Attachments and tags are handled with buttons added to Quill editor. Search in a thread is using Pubsub MAM, is the same way as for blogs. rel 463 diff -r b30878c8b633 -r c03297bb8d19 libervia/web/pages/chat/_browser/__init__.py --- a/libervia/web/pages/chat/_browser/__init__.py Sat Sep 06 12:12:42 2025 +0200 +++ b/libervia/web/pages/chat/_browser/__init__.py Sat Sep 06 16:30:46 2025 +0200 @@ -287,7 +287,9 @@ # attachments self.file_uploader = FileUploader( - "", "chat/attachment_preview.html", on_delete_cb=self.on_attachment_delete + "", + "components/attachment_preview.html", + on_delete_cb=self.on_attachment_delete ) self.attachments_elt = document["attachments"] self.message_input = document["message_input_area"] diff -r b30878c8b633 -r c03297bb8d19 libervia/web/pages/forums/list/page_meta.py --- a/libervia/web/pages/forums/list/page_meta.py Sat Sep 06 12:12:42 2025 +0200 +++ b/libervia/web/pages/forums/list/page_meta.py Sat Sep 06 16:30:46 2025 +0200 @@ -1,9 +1,10 @@ #!/usr/bin/env python3 +from twisted.words.protocols.jabber import jid from libervia.web.server.constants import Const as C from libervia.backend.core.log import getLogger from libervia.backend.core.i18n import _ -from libervia.backend.tools.common import uri as xmpp_uri +from libervia.backend.tools.common import data_format, uri as xmpp_uri log = getLogger(__name__) import json @@ -12,16 +13,15 @@ name = "forums" access = C.PAGES_ACCESS_PUBLIC -template = "forum/overview.html" +template = "forum/forum.html" def parse_url(self, request): self.get_path_args( request, - ["service", "node", "forum_key"], + ["service", "node"], service="@jid", node="@", - forum_key="", ) @@ -31,36 +31,88 @@ pass -def get_links(self, forums): - for forum in forums: - try: - uri = forum["uri"] - except KeyError: - pass +def process_disco_data(page, service: jid.JID, ps_ext_disco_data: list) -> list: + """Convert pubsub disco data data into template-friendly structure""" + + def process_node(node_data, depth=0): + name = node_data["name"] + is_main_category = name.startswith("urn:xmpp:forum:category:") + metadata = node_data.get("metadata", {}) + title = metadata.get("title", name) + description = metadata.get("description", "") + + forum = { + "title": title, + "forum_type": "main_category" if is_main_category else "sub_category", + "description": description, + "node": name, + "last_activity": metadata.get("last_item_publication_date"), + "depth": depth, + "http_url": page.get_page_by_name("forum_topics").get_url( + service.full(), name + ) + } + if "nb_items" in metadata: + forum["nb_items"] = metadata["nb_items"] + + if is_main_category: + forum_children = forum["children"] = [] + for child in node_data.get("children", []): + processed = process_node(child, depth + 1) + if processed: + forum_children.append(processed) + + return forum + + forums = [] + for node_data in ps_ext_disco_data: + # We skip the root node (urn:xmpp:forum:0). + if node_data.get("name") == "urn:xmpp:forum:0": + for child in node_data.get("children", []): + processed = process_node(child, 0) + if processed: + forums.append(processed) else: - uri = xmpp_uri.parse_xmpp_uri(uri) - service = uri["path"] - node = uri["node"] - forum["http_url"] = self.get_page_by_name("forum_topics").get_url(service, node) - if "sub-forums" in forum: - get_links(self, forum["sub-forums"]) + processed = process_node(node_data, 0) + if processed: + forums.append(processed) + + return forums async def prepare_render(self, request): data = self.get_r_data(request) template_data = request.template_data - service, node, key = data["service"], data["node"], data["forum_key"] + service, node = data["service"], data["node"] profile = self.get_profile(request) or C.SERVICE_PROFILE try: - forums_raw = await self.host.bridge_call( - "forums_get", service.full() if service else "", node, key, profile + ps_disco_data_s = await self.host.bridge_call( + "ps_disco_get", + service.full() if service else "", + node, + data_format.serialise( + { + "depth": 3, + } + ), + profile, ) except Exception as e: log.warning(_("Can't retrieve forums: {msg}").format(msg=e)) - forums = [] + ps_disco_data = [] else: - forums = json.loads(forums_raw) - get_links(self, forums) + ps_disco_data = json.loads(ps_disco_data_s) + + template_data["forums"] = process_disco_data(self, service, ps_disco_data) - template_data["forums"] = forums +async def on_data_post(self, request): + jid_str = self.get_posted_data(request, "jid") + try: + jid_ = jid.JID(jid_str) + except RuntimeError: + self.page_error(request, C.HTTP_BAD_REQUEST) + return + url = self.get_url(jid_.full(), "@") + self.http_redirect(request, url) + diff -r b30878c8b633 -r c03297bb8d19 libervia/web/pages/forums/topics/page_meta.py --- a/libervia/web/pages/forums/topics/page_meta.py Sat Sep 06 12:12:42 2025 +0200 +++ b/libervia/web/pages/forums/topics/page_meta.py Sat Sep 06 16:30:46 2025 +0200 @@ -21,11 +21,7 @@ def add_breadcrumb(self, request, breadcrumbs): - data = self.get_r_data(request) - breadcrumbs.append({ - "label": label, - "url": self.get_url(data["service"].full(), data["node"]) - }) + return None async def prepare_render(self, request): @@ -36,31 +32,35 @@ template_data = request.template_data page_max = data.get("page_max", 20) extra = self.get_pubsub_extra(request, page_max=page_max) - topics, metadata = await self.host.bridge_call( - "forum_topics_get", - service.full(), - node, - extra, - profile + if not self.use_cache(request): + extra[C.KEY_USE_CACHE] = False + blog_data = data_format.deserialise( + await self.host.bridge_call( + 'mb_get', + service.userhost(), + node, + C.NO_LIMIT, + [], + data_format.serialise(extra), + profile + ) ) - metadata = data_format.deserialise(metadata) - self.set_pagination(request, metadata) - identities = self.host.get_session_data( - request, session_iface.IWebSession - ).identities - for topic in topics: - parsed_uri = xmpp_uri.parse_xmpp_uri(topic["uri"]) - author = topic["author"] - topic["http_uri"] = self.get_page_by_name("forum_view").get_url( - parsed_uri["path"], parsed_uri["node"] + for item in blog_data["items"]: + try: + # We check that there is a comment node for this topic. + item["comments"][0]["service"] + item["comments"][0]["node"] + except (KeyError, IndexError): + log.warning(f"Can't get comments node for item: {item}") + continue + else: + item["http_url"] = self.get_page_by_name("forum_view").get_url( + service.full(), node, item["id"] ) - if author not in identities: - id_raw = await self.host.bridge_call( - "identity_get", author, [], True, profile - ) - identities[topic["author"]] = data_format.deserialise(id_raw) + self.set_pagination(request, blog_data) + await self.fill_missing_identities(request, [i['author_jid'] for i in blog_data['items']]) - template_data["topics"] = topics + template_data['blog_items'] = blog_data template_data["url_topic_new"] = self.get_sub_page_url(request, "forum_topic_new") diff -r b30878c8b633 -r c03297bb8d19 libervia/web/pages/forums/view/_browser/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/web/pages/forums/view/_browser/__init__.py Sat Sep 06 16:30:46 2025 +0200 @@ -0,0 +1,338 @@ +from bridge import AsyncBridge as Bridge +from browser import aio, console as log, document, window +from cache import cache, identities +import dialog +import editor +import json +from file_uploader import FileUploader +from js_modules.quill import Quill + +# We need to import `quill_mention` after `quill` to make it install the extensions. +from js_modules import quill_mention +from template import Template + + +log.warning = log.warn +bridge = Bridge() +service = window.service +node = window.node + + +class LiberviaForumView: + + def __init__(self) -> None: + self.message_editor = Quill.new( + "#message_editor", + { + "theme": "snow", + "modules": { + "toolbar": [ + [{"header": [1, 2, 3, False]}], + ["bold", "italic", "underline", "strike", "code", "link"], + [ + "blockquote", + "code-block", + {"list": "ordered"}, + {"list": "bullet"}, + ], + ["clean"], + ], + "mention": { + "allowedChars": window.RegExp(r"^[\p{L}\p{N}\-_\+%. ]*$", "u"), + "mentionDenotationChars": ["@"], + "source": lambda search_term, render_list, mention_char: aio.run( + self.get_mentions(search_term, render_list, mention_char) + ), + }, + }, + }, + ) + self._add_custom_buttons() + + self.subscribe_btn_tpl = Template("forum/subscribe_button.html") + + # Tags + self.tag_editor = editor.TagsEditor("#tags") + + # Attachments + self.file_uploader = FileUploader( + "", + "components/attachment_preview.html", + on_delete_cb=self.on_attachment_delete, + ) + self.attachments_elt = document["forum-attachments"] + + # Search + + self.search_field = document["search_thread"] + self.search_field.bind("keydown", self.on_search_field_keydown) + document["search_thread_button"].bind("click", self.on_search) + + + document["send-message-button"].bind("click", self._send_message) + document["subscribe-button"].bind("click", self._toggle_subscription) + document["forum-file-input"].bind("change", self.on_file_selected) + + editor_container = document.select_one("#message_editor") + attachments_area = document.select_one("#attachments-area") + + editor_container.bind("drop", self.on_file_drop) + editor_container.bind("dragover", self.on_drag_over) + attachments_area.bind("drop", self.on_file_drop) + attachments_area.bind("dragover", self.on_drag_over) + + def _identity_to_mention_item( + self, entity_jid: str, entity_data: dict | None = None + ) -> dict: + """Convert entity data to an item usable for mentions. + + @param entity_jid: JID of the matching entity. + @param entity_data: Data of the matching entity. + If None, retrieve data from ``identities`` cache. + @return: data usable for mentions. + """ + if entity_data is None: + entity_data = identities.get(entity_jid) or {} + + try: + name = entity_data["nicknames"][0] + except (KeyError, IndexError): + name = str(entity_data) + return {"id": str(entity_jid), "value": name} + + async def get_mentions(self, search_term, render_list, mention_char) -> None: + """Retrieve mentions based on search term and mention character. + + This method is called by editor when a mention is requested. + @param search_term: The term to search for in the mentions. + @param render_list: Function to render the list of mentions. + @param mention_char: The character indicating the type of mention ("@" or "#") + """ + if not search_term: + render_list( + [ + self._identity_to_mention_item(entity_jid, entity_data) + for entity_jid, entity_data in identities.items() + ] + ) + else: + render_list( + [ + self._identity_to_mention_item(entity_jid) + for entity_jid in cache.matching_identities(search_term) + ] + ) + + def _add_custom_buttons(self) -> None: + + extra_buttons_span = document.createElement("span") + extra_buttons_span.className = "extra-buttons" + + # Tag button + tag_btn = document.createElement("button") + tag_btn.className = "extra-btn-tag" + tag_btn.innerHTML = '' + tag_btn.title = "Add Tag" + tag_btn.onclick = self.on_add_tag + extra_buttons_span.appendChild(tag_btn) + + # Attachment button + attachment_btn = document.createElement("button") + attachment_btn.className = "extra-btn-attachment" + attachment_btn.innerHTML = '' + attachment_btn.title = "Add Attachment" + attachment_btn.onclick = self.on_add_attachment + extra_buttons_span.appendChild(attachment_btn) + + toolbar = self.message_editor.getModule("toolbar") + toolbar.container.appendChild(extra_buttons_span) + + def _clean_html(self, html: str) -> str: + """Remove unwanted elements added by Quill.""" + # FIXME: this is a Q&D method to clean the elements added in particular by + # Quill-Mention. This would probably be done in a cleaner way by rewriting the + # Mention Blot, and setting the new one with `blotName`. + + temp_container = document.createElement("div") + temp_container.innerHTML = html + + spans_to_remove = [] + all_spans = temp_container.querySelectorAll("span") + + for span in all_spans: + # Check for unwanted elements. + classes = span.className.split() if span.className else [] + # Those classes are added by Quill-Mention + if any( + cls in ["mention", "ql-mention-denotation-char", "ql-mention-value"] + for cls in classes + ): + spans_to_remove.append(span) + # Quill-Mention also adds a element. + elif span.hasAttribute("contenteditable"): + spans_to_remove.append(span) + + # Remove the spans by replacing them with their content + for span in spans_to_remove: + if span.parentNode: + fragment = document.createDocumentFragment() + while span.firstChild: + fragment.appendChild(span.firstChild) + span.parentNode.replaceChild(fragment, span) + + return temp_container.innerHTML + + def _send_message(self, evt) -> None: + evt.preventDefault() + aio.run(self.send_message()) + + async def send_message(self) -> None: + mb_data = { + "service": service, + "node": node, + "content_xhtml": self._clean_html(self.message_editor.getSemanticHTML()), + "tags": list(self.tag_editor.current_tags) + } + + # Attachments. + attachments = [] + for attachment_elt in self.attachments_elt.children: + file_data = json.loads(attachment_elt.getAttribute("data-file")) + attachments.append({ + "sources": [{"url": file_data["url"]}], + "media_type": file_data["mime_type"], + "desc": file_data["name"] + + }) + if attachments: + mb_data["attachments"] = attachments + + # Mentions. + mentions = [] + delta = dict(self.message_editor.getContents()) + ops = delta.get("ops", []) + for op in ops: + window.op = op + op = dict(op) + try: + mention_data = op["insert"]["mention"] + except (TypeError, KeyError): + continue + else: + mentions.append(mention_data["id"]) + if "mentions": + mb_data["mentions"] = mentions + + log.debug(f"Sending message:\n{mb_data}") + try: + await bridge.mb_send(json.dumps(mb_data)) + except Exception as e: + dialog.notification.show(f"Can't send message: {e}.", "error") + else: + dialog.notification.show("Your message has been sent correctly.") + + def _toggle_subscription(self, evt) -> None: + aio.run(self.toggle_subscription()) + + async def toggle_subscription(self) -> None: + """Un/subscribe from/to the node. + + The subscription button will be disabled the time to send the request, the + regenerated according to the new status (or an error will be shown). + """ + button = document["subscribe-button"] + button.disabled = True + action = None + try: + if window.subscribed: + action = "unsubscribe" + await bridge.ps_unsubscribe(service, node) + else: + action = "subscribe" + await bridge.ps_subscribe(service, node, "") + except Exception as e: + dialog.notification.show(f"Can't {action}: {e}.", "error") + button.disabled = False + return + + window.subscribed = not window.subscribed + new_button = self.subscribe_btn_tpl.get_elt({"subscribed": window.subscribed}) + new_button.bind("click", self._toggle_subscription) + button.replaceWith(new_button) + + def on_search_field_keydown(self, evt) -> None: + if evt.key == "Enter": + self.on_search(evt) + + def on_search(self, evt) -> None: + """Reload the page with search query. + + The search params are added to current URL and the page is reloaded to show + filtered results. + """ + evt.preventDefault() + evt.stopPropagation() + search_value = self.search_field.value.strip() + if not search_value: + return + current_url = window.location.href + url = window.URL.new(current_url) + url.searchParams.set('search', search_value) + window.location.href = url.href + + + def on_add_tag(self, evt) -> None: + """Handle action on click on editor's "tag" button. + + It will open the tag element so user can add tags. + @param evt: Event data. + """ + document["tags_container"].classList.remove("is-hidden") + + def on_add_attachment(self, evt) -> None: + """Handle action on click on editor's "attachment" button. + + It will open the file dialog so user can select one or more files, then upload + them with FileUploader and update the attachments section. + @param evt: Event data. + """ + document["forum-file-input"].click() + + def on_file_selected(self, evt): + """Handle file selection for forum attachments""" + files = evt.currentTarget.files + self.file_uploader.upload_files(files, self.attachments_elt) + self.update_attachments_visibility() + + def on_attachment_delete(self, evt): + """Handle deletion of attachment""" + evt.stopPropagation() + target = evt.currentTarget + item_elt = target.closest(".attachment-preview") + if item_elt: + item_elt.remove() + self.update_attachments_visibility() + + def update_attachments_visibility(self): + """Show/hide attachments based on whether there are attachments""" + if len(self.attachments_elt.children): + self.attachments_elt.parentElement.style.display = "flex" + else: + self.attachments_elt.parentElement.style.display = "none" + + def on_file_drop(self, evt) -> None: + print("on_file_drop") + evt.preventDefault() + files = evt.dataTransfer.files + if files: + print(f"Dropped {len(files)} files") + self.file_uploader.upload_files(files, self.attachments_elt) + self.update_attachments_visibility() + + def on_drag_over(self, evt) -> None: + print("on_drag_over") + evt.dataTransfer.dropEffect = "move" + evt.preventDefault() + + +LiberviaForumView() diff -r b30878c8b633 -r c03297bb8d19 libervia/web/pages/forums/view/page_meta.py --- a/libervia/web/pages/forums/view/page_meta.py Sat Sep 06 12:12:42 2025 +0200 +++ b/libervia/web/pages/forums/view/page_meta.py Sat Sep 06 16:30:46 2025 +0200 @@ -10,22 +10,104 @@ name = "forum_view" label = D_("View") -access = C.PAGES_ACCESS_PUBLIC -template = "forum/view.html" +access = C.PAGES_ACCESS_PROFILE +template = "forum/view_messages.html" def parse_url(self, request): - self.get_path_args(request, ["service", "node"], 2, service="jid") + self.get_path_args(request, ["service", "node", "item"], 3, service="jid") + + +def add_breadcrumb(self, request, breadcrumbs): + return None async def prepare_render(self, request): + profile = self.get_profile(request) or C.SERVICE_PROFILE data = self.get_r_data(request) - data["show_comments"] = False - blog_page = self.get_page_by_name("blog_view") - request.args[b"before"] = [b""] - request.args[b"reverse"] = [b"1"] - await blog_page.prepare_render(self, request) - request.template_data["login_url"] = self.get_page_redirect_url(request) + parent_service, parent_node, parent_item_id = ( + data["service"], + data["node"], + data["item"], + ) + parent_item_data = data_format.deserialise( + await self.host.bridge_call( + "mb_get", + parent_service.userhost(), + parent_node, + C.NO_LIMIT, + [parent_item_id], + data_format.serialise({}), + profile, + ) + ) + parent_item = parent_item_data["items"][0] + try: + service_s = parent_item["comments"][0]["service"] + node = parent_item["comments"][0]["node"] + except (KeyError, IndexError) as e: + log.warning(f"No comment node found {e}:\n{parent_item=}") + self.page_error(request, C.HTTP_SERVICE_UNAVAILABLE) + return + + page_max = data.get("page_max", 20) + extra = self.get_pubsub_extra(request, page_max=page_max) + self.handle_search(request, extra) + if not self.use_cache(request): + extra[C.KEY_USE_CACHE] = False + blog_data = data_format.deserialise( + await self.host.bridge_call( + "mb_get", + service_s, + node, + C.NO_LIMIT, + [], + data_format.serialise(extra), + profile, + ) + ) + for parent_item in blog_data["items"]: + try: + comments_service = parent_item["comments"][0]["service"] + comments_node = parent_item["comments"][0]["node"] + except (KeyError, IndexError): + log.warning(f"Can't get comments node for item: {parent_item}") + continue + else: + parent_item["http_url"] = self.get_page_by_name("forum_view").get_url( + comments_service, comments_node + ) + self.set_pagination(request, blog_data) + await self.fill_missing_identities( + request, [i["author_jid"] for i in blog_data["items"]] + ) + + # We check if we are subscribed to the service. + try: + subscriptions = data_format.deserialise( + await self.host.bridge_call( + "ps_subscriptions_get", + service_s, + node, + profile, + ) + ) + except Exception as e: + log.warning("Can't retrieve subscriptions, assuming that we are not subscribed.") + subscribed = False + else: + subscribed = any(s.get("state") == "subscribed" for s in subscriptions) + + request.template_data.update( + { + "topic_title": parent_item["title"], + "service": service_s, + "node": node, + "blog_items": blog_data, + "subscribed": subscribed, + } + ) + self.expose_to_scripts(request, node=node, service=service_s, subscribed=subscribed) async def on_data_post(self, request): @@ -41,7 +123,8 @@ mb_data = {"service": service, "node": node, "content_rich": body} try: await self.host.bridge_call( - "mb_send", data_format.serialise(mb_data), profile) + "mb_send", data_format.serialise(mb_data), profile + ) except Exception as e: if "forbidden" in str(e): self.page_error(request, 401) diff -r b30878c8b633 -r c03297bb8d19 libervia/web/server/pages.py --- a/libervia/web/server/pages.py Sat Sep 06 12:12:42 2025 +0200 +++ b/libervia/web/server/pages.py Sat Sep 06 16:30:46 2025 +0200 @@ -26,9 +26,7 @@ import time import traceback from typing import List, Optional, Union -import urllib.error import urllib.parse -import urllib.request from twisted.internet import defer from twisted.python import failure diff -r b30878c8b633 -r c03297bb8d19 libervia/web/server/restricted_bridge.py --- a/libervia/web/server/restricted_bridge.py Sat Sep 06 12:12:42 2025 +0200 +++ b/libervia/web/server/restricted_bridge.py Sat Sep 06 16:30:46 2025 +0200 @@ -220,6 +220,14 @@ "message_send", to_jid_s, message, subject, mess_type, extra_s, profile ) + async def ps_invite( + self, invitee_jid_s, service_s, node, item_id, name, extra_s, profile + ): + self.no_service_profile(profile) + return await self.host.bridge_call( + "ps_invite", invitee_jid_s, service_s, node, item_id, name, extra_s, profile + ) + async def ps_node_delete(self, service_s, node, profile): self.no_service_profile(profile) return await self.host.bridge_call( @@ -235,11 +243,49 @@ return await self.host.bridge_call( "ps_item_retract", service_s, node, item_id, notify, profile) + async def ps_attachments_get( + self, service_s: str, node: str, item: str, senders_s: list[str], extra_s: str, + profile: str + ) -> None: + return await self.host.bridge_call( + "ps_attachments_get", service_s, node, item, senders_s, extra_s, profile + ) + + async def ps_attachments_set( + self, attachments_s: str, profile: str + ) -> None: + self.no_service_profile(profile) + return await self.host.bridge_call( + "ps_attachments_set", attachments_s, profile + ) + + async def ps_subscribe( + self, service_s, node, options_s, profile: str + ) -> str: + self.no_service_profile(profile) + return await self.host.bridge_call( + "ps_subscribe", service_s, node, options_s, profile + ) + + async def ps_unsubscribe( + self, service_s, node, profile: str + ) -> str: + self.no_service_profile(profile) + return await self.host.bridge_call( + "ps_unsubscribe", service_s, node, profile + ) + async def mb_preview(self, data, profile): return await self.host.bridge_call( "mb_preview", data, profile ) + async def mb_send(self, data_s, profile): + self.no_service_profile(profile) + return await self.host.bridge_call( + "mb_send", data_s, profile + ) + async def list_set(self, service_s, node, values, schema, item_id, extra, profile): self.no_service_profile(profile) return await self.host.bridge_call( @@ -315,14 +361,6 @@ "muc_join", room_jid_s, nick, options, profile ) - async def ps_invite( - self, invitee_jid_s, service_s, node, item_id, name, extra_s, profile - ): - self.no_service_profile(profile) - return await self.host.bridge_call( - "ps_invite", invitee_jid_s, service_s, node, item_id, name, extra_s, profile - ) - async def fis_invite( self, invitee_jid_s, service_s, repos_type, namespace, path, name, extra_s, profile @@ -374,22 +412,6 @@ "jid_search", search_term, options_s, profile ) - async def ps_attachments_get( - self, service_s: str, node: str, item: str, senders_s: list[str], extra_s: str, - profile: str - ) -> None: - return await self.host.bridge_call( - "ps_attachments_get", service_s, node, item, senders_s, extra_s, profile - ) - - async def ps_attachments_set( - self, attachments_s: str, profile: str - ) -> None: - self.no_service_profile(profile) - return await self.host.bridge_call( - "ps_attachments_set", attachments_s, profile - ) - async def remote_control_start( self, peer_jid_s: str, extra_s: str, profile: str ) -> None: