mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-08-05 07:09:22 +00:00
Improve propagation sync and improve markdown rendering
CI / build-frontend (push) Failing after 18s
CI / lint (push) Failing after 1m45s
CI / test-backend (push) Successful in 42s
CI / test-lang (push) Failing after 19s
Build and Publish Docker Image / build (push) Failing after 2m43s
Build and Publish Docker Image / build-dev (push) Failing after 49s
Build Test / Build and Test (push) Failing after 7m0s
Tests / test (push) Failing after 18s
Security Scans / scan (push) Failing after 48s
CI / build-frontend (push) Failing after 18s
CI / lint (push) Failing after 1m45s
CI / test-backend (push) Successful in 42s
CI / test-lang (push) Failing after 19s
Build and Publish Docker Image / build (push) Failing after 2m43s
Build and Publish Docker Image / build-dev (push) Failing after 49s
Build Test / Build and Test (push) Failing after 7m0s
Tests / test (push) Failing after 18s
Security Scans / scan (push) Failing after 48s
- Added methods to collect and manage propagation sync metrics in ReticulumMeshChat, improving message tracking and delivery confirmation. - Updated frontend components to display detailed sync status notifications, including stored messages and delivery confirmations. - Implemented URL safety checks in the markdown renderer to prevent XSS vulnerabilities by sanitizing links and image sources. - Refactored various comments and code for clarity and maintainability across multiple files.
This commit is contained in:
@@ -301,8 +301,7 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// find path to python/cxfreeze reticulum meshchatx executable
|
||||
// Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat
|
||||
// find path to python/cxfreeze executable (setup.py builds ReticulumMeshChatX)
|
||||
const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX";
|
||||
|
||||
// get app path (handles both development and packaged app)
|
||||
@@ -362,7 +361,7 @@ app.whenReady().then(async () => {
|
||||
const requiredArguments = [
|
||||
"--headless", // reticulum meshchatx usually launches default web browser, we don't want this when using electron
|
||||
"--port",
|
||||
"9337", // FIXME: let system pick a random unused port?
|
||||
"9337",
|
||||
// '--test-exception-message', 'Test Exception Message', // uncomment to test the crash dialog
|
||||
];
|
||||
|
||||
|
||||
+2
-3
@@ -458,8 +458,7 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// find path to python/cxfreeze reticulum meshchatx executable
|
||||
// Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat
|
||||
// find path to python/cxfreeze executable (setup.py builds ReticulumMeshChatX)
|
||||
const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX";
|
||||
|
||||
// get app path (handles both development and packaged app)
|
||||
@@ -524,7 +523,7 @@ app.whenReady().then(async () => {
|
||||
const requiredArguments = [
|
||||
"--headless", // reticulum meshchatx usually launches default web browser, we don't want this when using electron
|
||||
"--port",
|
||||
"9337", // FIXME: let system pick a random unused port?
|
||||
"9337",
|
||||
// '--test-exception-message', 'Test Exception Message', // uncomment to test the crash dialog
|
||||
];
|
||||
|
||||
|
||||
+99
-16
@@ -279,6 +279,7 @@ class ReticulumMeshChat:
|
||||
# Multi-identity support
|
||||
self.contexts: dict[str, IdentityContext] = {}
|
||||
self.current_context: IdentityContext | None = None
|
||||
self._propagation_sync_metrics: dict[str, dict] = {}
|
||||
|
||||
self.setup_identity(identity)
|
||||
self.web_audio_bridge = WebAudioBridge(None, None)
|
||||
@@ -819,7 +820,6 @@ class ReticulumMeshChat:
|
||||
self._identity_session_id += 1
|
||||
|
||||
# Teardown current identity state and managers
|
||||
# This also calls cleanup_rns_state_for_identity
|
||||
self.teardown_identity()
|
||||
|
||||
# Give loops a moment to finish
|
||||
@@ -1684,6 +1684,94 @@ class ReticulumMeshChat:
|
||||
return
|
||||
ctx.message_router.cancel_propagation_node_requests()
|
||||
|
||||
def _get_propagation_sync_metrics(self, context=None):
|
||||
ctx = context or self.current_context
|
||||
if not ctx:
|
||||
return None
|
||||
|
||||
key = ctx.identity_hash
|
||||
if key not in self._propagation_sync_metrics:
|
||||
self._propagation_sync_metrics[key] = {
|
||||
"started_at": None,
|
||||
"baseline_total_messages": 0,
|
||||
"baseline_delivered_messages": 0,
|
||||
"messages_stored": 0,
|
||||
"delivery_confirmations": 0,
|
||||
"messages_hidden": 0,
|
||||
}
|
||||
|
||||
return self._propagation_sync_metrics[key]
|
||||
|
||||
def _begin_propagation_sync_metrics(self, context=None):
|
||||
ctx = context or self.current_context
|
||||
if not ctx or not ctx.database:
|
||||
return
|
||||
|
||||
metrics = self._get_propagation_sync_metrics(context=ctx)
|
||||
if metrics is None:
|
||||
return
|
||||
|
||||
metrics["started_at"] = datetime.now(UTC).isoformat()
|
||||
metrics["baseline_total_messages"] = ctx.database.messages.count_lxmf_messages()
|
||||
metrics["baseline_delivered_messages"] = (
|
||||
ctx.database.messages.count_lxmf_messages_by_state("delivered")
|
||||
)
|
||||
metrics["messages_stored"] = 0
|
||||
metrics["delivery_confirmations"] = 0
|
||||
metrics["messages_hidden"] = 0
|
||||
|
||||
def _collect_propagation_sync_metrics(self, context=None):
|
||||
ctx = context or self.current_context
|
||||
if not ctx or not ctx.database:
|
||||
return {
|
||||
"messages_stored": 0,
|
||||
"delivery_confirmations": 0,
|
||||
"messages_hidden": 0,
|
||||
}
|
||||
|
||||
metrics = self._get_propagation_sync_metrics(context=ctx)
|
||||
if metrics is None:
|
||||
return {
|
||||
"messages_stored": 0,
|
||||
"delivery_confirmations": 0,
|
||||
"messages_hidden": 0,
|
||||
}
|
||||
if metrics["started_at"] is None:
|
||||
return {
|
||||
"messages_stored": 0,
|
||||
"delivery_confirmations": 0,
|
||||
"messages_hidden": 0,
|
||||
}
|
||||
|
||||
messages_received = ctx.message_router.propagation_transfer_last_result or 0
|
||||
current_total_messages = ctx.database.messages.count_lxmf_messages()
|
||||
current_delivered_messages = ctx.database.messages.count_lxmf_messages_by_state(
|
||||
"delivered",
|
||||
)
|
||||
|
||||
messages_stored = max(
|
||||
current_total_messages - metrics["baseline_total_messages"],
|
||||
0,
|
||||
)
|
||||
delivery_confirmations = max(
|
||||
current_delivered_messages - metrics["baseline_delivered_messages"],
|
||||
0,
|
||||
)
|
||||
messages_hidden = max(
|
||||
messages_received - messages_stored - delivery_confirmations,
|
||||
0,
|
||||
)
|
||||
|
||||
metrics["messages_stored"] = messages_stored
|
||||
metrics["delivery_confirmations"] = delivery_confirmations
|
||||
metrics["messages_hidden"] = messages_hidden
|
||||
|
||||
return {
|
||||
"messages_stored": messages_stored,
|
||||
"delivery_confirmations": delivery_confirmations,
|
||||
"messages_hidden": messages_hidden,
|
||||
}
|
||||
|
||||
# stops and removes the active propagation node
|
||||
def remove_active_propagation_node(self, context=None):
|
||||
ctx = context or self.current_context
|
||||
@@ -2125,12 +2213,7 @@ class ReticulumMeshChat:
|
||||
sys.exit(code)
|
||||
|
||||
def get_routes(self):
|
||||
# This is a bit of a hack to get the routes without running the full server
|
||||
# It's mainly for testing purposes
|
||||
routes = web.RouteTableDef()
|
||||
|
||||
# We need to mock some things that are usually setup in run()
|
||||
# but for just getting the route handlers, we can skip most of it
|
||||
self._define_routes(routes)
|
||||
return routes
|
||||
|
||||
@@ -2378,7 +2461,6 @@ class ReticulumMeshChat:
|
||||
)
|
||||
|
||||
result = self.database.restore_database(path)
|
||||
# Note: This might require an app relaunch to be fully effective
|
||||
return web.json_response(
|
||||
{"status": "success", "result": result, "requires_relaunch": True},
|
||||
)
|
||||
@@ -3263,7 +3345,6 @@ class ReticulumMeshChat:
|
||||
InterfaceEditor.update_value(interface_details, data, "callsign")
|
||||
InterfaceEditor.update_value(interface_details, data, "ssid")
|
||||
|
||||
# FIXME: move to own sections
|
||||
# RNode Airtime limits and station ID
|
||||
InterfaceEditor.update_value(interface_details, data, "callsign")
|
||||
InterfaceEditor.update_value(interface_details, data, "id_interval")
|
||||
@@ -6055,6 +6136,7 @@ class ReticulumMeshChat:
|
||||
|
||||
@routes.get("/api/v1/lxmf/propagation-node/status")
|
||||
async def propagation_node_status(request):
|
||||
sync_metrics = self._collect_propagation_sync_metrics()
|
||||
return web.json_response(
|
||||
{
|
||||
"propagation_node_status": {
|
||||
@@ -6064,6 +6146,11 @@ class ReticulumMeshChat:
|
||||
"progress": self.message_router.propagation_transfer_progress
|
||||
* 100, # convert to percentage
|
||||
"messages_received": self.message_router.propagation_transfer_last_result,
|
||||
"messages_stored": sync_metrics["messages_stored"],
|
||||
"delivery_confirmations": sync_metrics[
|
||||
"delivery_confirmations"
|
||||
],
|
||||
"messages_hidden": sync_metrics["messages_hidden"],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -8893,6 +8980,8 @@ class ReticulumMeshChat:
|
||||
if not ctx:
|
||||
return
|
||||
|
||||
self._begin_propagation_sync_metrics(context=ctx)
|
||||
|
||||
# update last synced at timestamp
|
||||
ctx.config.lxmf_preferred_propagation_node_last_synced_at.set(int(time.time()))
|
||||
|
||||
@@ -10721,7 +10810,6 @@ class ReticulumMeshChat:
|
||||
|
||||
# if incoming icon matches our own, skip storing and clear any mistaken stored copy
|
||||
# for now, but this will need to be updated later if two users do have the same icon
|
||||
# FIXME
|
||||
if (
|
||||
local_icon_name
|
||||
and local_icon_fg
|
||||
@@ -10917,7 +11005,7 @@ class ReticulumMeshChat:
|
||||
self.send_failed_message_via_propagation_node(lxmf_message, context=context)
|
||||
|
||||
# update state
|
||||
self.on_lxmf_sending_state_updated(lxmf_message)
|
||||
self.on_lxmf_sending_state_updated(lxmf_message, context=context)
|
||||
|
||||
# sends a previously failed message via a propagation node
|
||||
def send_failed_message_via_propagation_node(
|
||||
@@ -11317,8 +11405,6 @@ class ReticulumMeshChat:
|
||||
|
||||
# updates lxmf message in database and broadcasts to websocket until it's delivered, or it fails
|
||||
async def handle_lxmf_message_progress(self, lxmf_message, context=None):
|
||||
# FIXME: there's no register_progress_callback on the lxmf message, so manually send progress until delivered, propagated or failed
|
||||
# we also can't use on_lxmf_sending_state_updated method to do this, because of async/await issues...
|
||||
ctx = context or self.current_context
|
||||
if not ctx:
|
||||
return
|
||||
@@ -11741,9 +11827,7 @@ class ReticulumMeshChat:
|
||||
|
||||
return None
|
||||
|
||||
# get name to show for an lxmf conversation
|
||||
# currently, this will use the app data from the most recent announce
|
||||
# TODO: we should fetch this from our contacts database, when it gets implemented, and if not found, fallback to app data
|
||||
# get name to show for an lxmf conversation (from most recent announce app data)
|
||||
def get_lxmf_conversation_name(
|
||||
self,
|
||||
destination_hash,
|
||||
@@ -11792,7 +11876,6 @@ def env_bool(env_name, default=False):
|
||||
|
||||
|
||||
# class to manage config stored in database
|
||||
# FIXME: we should probably set this as an instance variable of ReticulumMeshChat so it has a proper home, and pass it in to the constructor?
|
||||
def main():
|
||||
# apply asyncio 3.13 patch if needed
|
||||
AsyncUtils.apply_asyncio_313_patch()
|
||||
|
||||
@@ -16,9 +16,7 @@ class AsyncUtils:
|
||||
if sys.version_info >= (3, 13):
|
||||
import asyncio.base_events
|
||||
|
||||
# We need to patch the loop's sendfile to raise NotImplementedError for SSL transports.
|
||||
# This will force aiohttp to use its own fallback which works correctly.
|
||||
|
||||
# Patch sendfile so aiohttp uses its SSL fallback on 3.13.
|
||||
original_sendfile = asyncio.base_events.BaseEventLoop.sendfile
|
||||
|
||||
async def patched_sendfile(
|
||||
|
||||
@@ -86,6 +86,17 @@ class MessageDAO:
|
||||
def get_all_lxmf_messages(self):
|
||||
return self.provider.fetchall("SELECT * FROM lxmf_messages")
|
||||
|
||||
def count_lxmf_messages(self):
|
||||
row = self.provider.fetchone("SELECT COUNT(*) AS count FROM lxmf_messages")
|
||||
return row["count"] if row and row["count"] is not None else 0
|
||||
|
||||
def count_lxmf_messages_by_state(self, state):
|
||||
row = self.provider.fetchone(
|
||||
"SELECT COUNT(*) AS count FROM lxmf_messages WHERE state = ?",
|
||||
(state,),
|
||||
)
|
||||
return row["count"] if row and row["count"] is not None else 0
|
||||
|
||||
def get_conversation_messages(self, destination_hash, limit=100, offset=0):
|
||||
return self.provider.fetchall(
|
||||
"SELECT * FROM lxmf_messages WHERE peer_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
|
||||
@@ -157,8 +157,6 @@ class DatabaseSchema:
|
||||
|
||||
# Other essential tables that were present from version 1
|
||||
# Peewee automatically creates tables if they don't exist.
|
||||
# Here we define the full schema for all tables as they should be now.
|
||||
|
||||
tables = {
|
||||
"announces": """
|
||||
CREATE TABLE IF NOT EXISTS announces (
|
||||
|
||||
@@ -201,9 +201,7 @@ class DocsManager:
|
||||
# Also try in the public directory
|
||||
search_paths.append(os.path.join(self.public_dir, "meshchatx-docs"))
|
||||
|
||||
# Also try relative to this file
|
||||
# This file is in meshchatx/src/backend/docs_manager.py
|
||||
# Project root is 3 levels up
|
||||
# Also try relative to this file (project root 3 levels up)
|
||||
this_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
search_paths.append(
|
||||
os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs")),
|
||||
@@ -407,8 +405,7 @@ class DocsManager:
|
||||
if f"_{lang}.html" in file:
|
||||
target_files.append(os.path.join(root, file))
|
||||
else:
|
||||
# For English, we want files that DON'T have a language suffix
|
||||
# This is a bit heuristic
|
||||
# English: no language suffix; other langs use _<lang>.html
|
||||
has_lang_suffix = False
|
||||
for lang_code in known_langs:
|
||||
if f"_{lang_code}.html" in file:
|
||||
@@ -634,7 +631,6 @@ class DocsManager:
|
||||
else:
|
||||
shutil.copy2(s, d)
|
||||
else:
|
||||
# Just extract everything directly to version_dir, but remove root folder if exists
|
||||
zip_ref.extractall(temp_extract)
|
||||
src_path = os.path.join(temp_extract, root_folder)
|
||||
if os.path.exists(src_path) and os.path.isdir(src_path):
|
||||
|
||||
@@ -8,7 +8,6 @@ from websockets.sync.connection import Connection
|
||||
|
||||
|
||||
class WebsocketClientInterface(Interface):
|
||||
# TODO: required?
|
||||
DEFAULT_IFAC_SIZE = 16
|
||||
|
||||
RECONNECT_DELAY_SECONDS = 5
|
||||
|
||||
@@ -11,7 +11,6 @@ from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
|
||||
|
||||
|
||||
class WebsocketServerInterface(Interface):
|
||||
# TODO: required?
|
||||
DEFAULT_IFAC_SIZE = 16
|
||||
|
||||
RESTART_DELAY_SECONDS = 5
|
||||
@@ -63,7 +62,6 @@ class WebsocketServerInterface(Interface):
|
||||
def clients(self):
|
||||
return len(self.spawned_interfaces)
|
||||
|
||||
# TODO docs
|
||||
def received_announce(self, from_spawned=False):
|
||||
if from_spawned:
|
||||
self.ia_freq_deque.append(time.time())
|
||||
@@ -105,14 +103,10 @@ class WebsocketServerInterface(Interface):
|
||||
spawned_interface.parent_interface = self
|
||||
spawned_interface.online = True
|
||||
|
||||
# TODO implement?
|
||||
spawned_interface.announce_rate_target = None
|
||||
spawned_interface.announce_rate_grace = None
|
||||
spawned_interface.announce_rate_penalty = None
|
||||
|
||||
# TODO ifac?
|
||||
# TODO announce rates?
|
||||
|
||||
# activate child interface
|
||||
RNS.log(
|
||||
f"Spawned new WebsocketClientInterface: {spawned_interface}",
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import html
|
||||
import re
|
||||
|
||||
_SAFE_LINK_PREFIXES = ("https://", "http://", "/", "#", "mailto:")
|
||||
_UNSAFE_PROTOCOLS = ("javascript:", "data:", "vbscript:", "file:")
|
||||
|
||||
|
||||
def _safe_href(url):
|
||||
if not url or not isinstance(url, str):
|
||||
return "#"
|
||||
u = url.strip().lower()
|
||||
if any(u.startswith(p) for p in _UNSAFE_PROTOCOLS):
|
||||
return "#"
|
||||
if any(u.startswith(p) for p in _SAFE_LINK_PREFIXES):
|
||||
return url
|
||||
if ":" in u.split("/")[0]:
|
||||
return "#"
|
||||
return url
|
||||
|
||||
|
||||
class MarkdownRenderer:
|
||||
"""A simple Markdown to HTML renderer."""
|
||||
@@ -101,17 +117,29 @@ class MarkdownRenderer:
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# Links
|
||||
# Links (href sanitized to prevent javascript:/data: XSS)
|
||||
def link_repl(match):
|
||||
label, url = match.group(1), match.group(2)
|
||||
safe_url = _safe_href(url)
|
||||
return f'<a href="{html.escape(safe_url)}" class="text-blue-600 dark:text-blue-400 hover:underline" target="_blank" rel="noopener noreferrer">{label}</a>'
|
||||
|
||||
text = re.sub(
|
||||
r"\[([^\]]+)\]\(([^)]+)\)",
|
||||
r'<a href="\2" class="text-blue-600 dark:text-blue-400 hover:underline" target="_blank">\1</a>',
|
||||
link_repl,
|
||||
text,
|
||||
)
|
||||
|
||||
# Images
|
||||
# Images (src sanitized)
|
||||
def img_repl(match):
|
||||
alt, src = match.group(1), match.group(2)
|
||||
safe_src = _safe_href(src)
|
||||
if safe_src == "#":
|
||||
return html.escape(match.group(0))
|
||||
return f'<div class="my-6"><img src="{html.escape(safe_src)}" alt="{alt}" class="max-w-full h-auto rounded-xl shadow-lg border border-gray-100 dark:border-zinc-800"></div>'
|
||||
|
||||
text = re.sub(
|
||||
r"!\[([^\]]*)\]\(([^)]+)\)",
|
||||
r'<div class="my-6"><img src="\2" alt="\1" class="max-w-full h-auto rounded-xl shadow-lg border border-gray-100 dark:border-zinc-800"></div>',
|
||||
img_repl,
|
||||
text,
|
||||
)
|
||||
|
||||
|
||||
@@ -79,10 +79,6 @@ class TelephoneManager:
|
||||
|
||||
@property
|
||||
def is_recording(self):
|
||||
# Check if voicemail manager or this manager is recording
|
||||
# This is a bit of a hack since we don't have a direct link to voicemail_manager here
|
||||
# but we can check if our own recording is active if we had it.
|
||||
# For now, we'll just return False and let meshchat.py handle the combined status.
|
||||
return False
|
||||
|
||||
def init_telephone(self):
|
||||
@@ -138,8 +134,6 @@ class TelephoneManager:
|
||||
|
||||
def on_telephone_ringing(self, caller_identity: RNS.Identity):
|
||||
if self.initiation_status:
|
||||
# This is an outgoing call where the remote side is now ringing.
|
||||
# We update the initiation status to "Ringing..." for the UI.
|
||||
self._update_initiation_status("Ringing...")
|
||||
return
|
||||
|
||||
|
||||
@@ -1094,8 +1094,14 @@ export default {
|
||||
// show result
|
||||
const status = this.propagationNodeStatus?.state;
|
||||
const messagesReceived = this.propagationNodeStatus?.messages_received ?? 0;
|
||||
const messagesStored = this.propagationNodeStatus?.messages_stored ?? 0;
|
||||
const deliveryConfirmations =
|
||||
this.propagationNodeStatus?.delivery_confirmations ?? 0;
|
||||
const messagesHidden = this.propagationNodeStatus?.messages_hidden ?? 0;
|
||||
if (status === "complete" || status === "idle") {
|
||||
ToastUtils.success(this.$t("app.sync_complete", { count: messagesReceived }));
|
||||
const base = this.$t("app.sync_complete", { count: messagesReceived });
|
||||
const details = `${messagesStored} stored, ${deliveryConfirmations} confirmations, ${messagesHidden} hidden`;
|
||||
ToastUtils.success(`${base} (${details})`);
|
||||
} else {
|
||||
ToastUtils.error(this.$t("app.sync_error", { status: status }));
|
||||
}
|
||||
|
||||
@@ -3223,7 +3223,6 @@ export default {
|
||||
}
|
||||
},
|
||||
async playVoicemail(voicemail) {
|
||||
// This is now handled by AudioWaveformPlayer, but we keep it for backward compatibility or direct calls
|
||||
if (this.playingVoicemailId === voicemail.id) {
|
||||
if (this.audioPlayer) {
|
||||
this.audioPlayer.pause();
|
||||
|
||||
@@ -8,8 +8,7 @@ export default class LinkUtils {
|
||||
|
||||
// Hash is 32 hex chars. Path is optional (NomadNet only).
|
||||
const hashPattern = "[a-fA-F0-9]{32}";
|
||||
// This matches optional prefixes (nomadnet://, nomadnet@, lxmf://, lxmf@)
|
||||
// followed by a 32-char hex hash, and an optional path starting with :
|
||||
// Optional prefix (nomadnet://, nomadnet@, lxmf://, lxmf@), then hash, optional path.
|
||||
const reticulumRegex = new RegExp(
|
||||
`(nomadnet://|nomadnet@|lxmf://|lxmf@)?(${hashPattern})(?::(/[\\w\\d./?%&=-]*))?`,
|
||||
"g"
|
||||
|
||||
@@ -7,11 +7,13 @@ export default class MarkdownRenderer {
|
||||
* Ported and simplified from meshchatx/src/backend/markdown_renderer.py
|
||||
*/
|
||||
static render(text) {
|
||||
if (!text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof text !== "string") {
|
||||
text = String(text);
|
||||
}
|
||||
|
||||
// Escape HTML entities first to prevent XSS
|
||||
text = Utils.escapeHtml(text);
|
||||
|
||||
// Fenced code blocks - process these FIRST and replace with placeholders
|
||||
@@ -81,9 +83,12 @@ export default class MarkdownRenderer {
|
||||
* Strips markdown from text for previews.
|
||||
*/
|
||||
static strip(text) {
|
||||
if (!text) {
|
||||
if (text == null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof text !== "string") {
|
||||
text = String(text);
|
||||
}
|
||||
|
||||
// Strip fenced code blocks
|
||||
text = text.replace(/```(\w+)?\n([\s\S]*?)\n```/g, "[Code Block]");
|
||||
|
||||
@@ -115,9 +115,10 @@ class MicronParser {
|
||||
}
|
||||
|
||||
convertMicronToHtml(markup) {
|
||||
if (markup == null) return "";
|
||||
if (typeof markup !== "string") markup = String(markup);
|
||||
let html = "";
|
||||
|
||||
// parse header tags for page-level color defaults
|
||||
const headerColors = this.parseHeaderTags(markup);
|
||||
|
||||
const plainStyle = this.SELECTED_STYLES?.plain || { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG };
|
||||
|
||||
@@ -197,7 +197,8 @@ class Utils {
|
||||
}
|
||||
|
||||
static escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
if (text == null) return "";
|
||||
text = String(text);
|
||||
const map = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
|
||||
Reference in New Issue
Block a user