import logging import os import re import shutil import threading import zipfile import io import html import requests from meshchatx.src.backend.markdown_renderer import MarkdownRenderer class DocsManager: def __init__(self, config, public_dir, project_root=None, storage_dir=None): self.config = config self.public_dir = public_dir self.project_root = project_root self.storage_dir = storage_dir # Determine docs directories # If storage_dir is provided, we prefer using it for documentation storage # to avoid Read-only file system errors in environments like AppImages. if self.storage_dir: self.docs_dir = os.path.join(self.storage_dir, "reticulum-docs") self.meshchatx_docs_dir = os.path.join(self.storage_dir, "meshchatx-docs") else: self.docs_dir = os.path.join(self.public_dir, "reticulum-docs") self.meshchatx_docs_dir = os.path.join(self.public_dir, "meshchatx-docs") self.download_status = "idle" self.download_progress = 0 self.last_error = None # Ensure docs directories exist try: if not os.path.exists(self.docs_dir): os.makedirs(self.docs_dir) if not os.path.exists(self.meshchatx_docs_dir): os.makedirs(self.meshchatx_docs_dir) except OSError as e: # If we still fail (e.g. storage_dir was not provided and public_dir is read-only) # we log it but don't crash the whole app. Emergency mode can still run. logging.error(f"Failed to create documentation directories: {e}") self.last_error = str(e) # Initial population of MeshChatX docs if os.path.exists(self.meshchatx_docs_dir) and os.access( self.meshchatx_docs_dir, os.W_OK ): self.populate_meshchatx_docs() def populate_meshchatx_docs(self): """Populates meshchatx-docs from the project's docs folder.""" # Try to find docs folder in several places search_paths = [] if self.project_root: search_paths.append(os.path.join(self.project_root, "docs")) # 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 this_dir = os.path.dirname(os.path.abspath(__file__)) search_paths.append( os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs")) ) src_docs = None for path in search_paths: if os.path.exists(path) and os.path.isdir(path): src_docs = path break if not src_docs: logging.warning("MeshChatX docs source directory not found.") return try: for file in os.listdir(src_docs): if file.endswith(".md") or file.endswith(".txt"): src_path = os.path.join(src_docs, file) dest_path = os.path.join(self.meshchatx_docs_dir, file) # Only copy if source and destination are different if ( os.path.abspath(src_path) != os.path.abspath(dest_path) and os.access(self.meshchatx_docs_dir, os.W_OK) ): shutil.copy2(src_path, dest_path) # Also pre-render to HTML for easy sharing/viewing try: with open(src_path, "r", encoding="utf-8") as f: content = f.read() html_content = MarkdownRenderer.render(content) # Basic HTML wrapper for standalone viewing full_html = f"""
{html.escape(content)}",
"type": "text",
}
def export_docs(self):
"""Creates a zip of all docs and returns the bytes."""
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
# Add reticulum docs
for root, _, files in os.walk(self.docs_dir):
for file in files:
file_path = os.path.join(root, file)
rel_path = os.path.join(
"reticulum-docs", os.path.relpath(file_path, self.docs_dir)
)
zip_file.write(file_path, rel_path)
# Add meshchatx docs
for root, _, files in os.walk(self.meshchatx_docs_dir):
for file in files:
file_path = os.path.join(root, file)
rel_path = os.path.join(
"meshchatx-docs",
os.path.relpath(file_path, self.meshchatx_docs_dir),
)
zip_file.write(file_path, rel_path)
buffer.seek(0)
return buffer.getvalue()
def search(self, query, lang="en"):
if not query:
return []
results = []
query = query.lower()
# 1. Search MeshChatX Docs first
if os.path.exists(self.meshchatx_docs_dir):
for file in os.listdir(self.meshchatx_docs_dir):
if file.endswith((".md", ".txt")):
file_path = os.path.join(self.meshchatx_docs_dir, file)
try:
with open(
file_path, "r", encoding="utf-8", errors="ignore"
) as f:
content = f.read()
if query in content.lower():
# Simple snippet
idx = content.lower().find(query)
start = max(0, idx - 80)
end = min(len(content), idx + len(query) + 120)
snippet = content[start:end]
if start > 0:
snippet = "..." + snippet
if end < len(content):
snippet = snippet + "..."
results.append(
{
"title": file,
"path": f"/meshchatx-docs/{file}",
"snippet": snippet,
"source": "MeshChatX",
}
)
except Exception as e:
logging.error(f"Error searching MeshChatX doc {file}: {e}")
# 2. Search Reticulum Docs
if self.has_docs():
# Known language suffixes in Reticulum docs
known_langs = ["de", "es", "jp", "nl", "pl", "pt-br", "tr", "uk", "zh-cn"]
# Determine files to search
target_files = []
try:
for root, _, files in os.walk(self.docs_dir):
for file in files:
if file.endswith(".html"):
# Basic filtering for language if possible
if lang != "en":
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
has_lang_suffix = False
for lang_code in known_langs:
if f"_{lang_code}.html" in file:
has_lang_suffix = True
break
if not has_lang_suffix:
target_files.append(os.path.join(root, file))
# If we found nothing for a specific language, fall back to English ONLY
if not target_files and lang != "en":
for root, _, files in os.walk(self.docs_dir):
for file in files:
if file.endswith(".html"):
has_lang_suffix = False
for lang_code in known_langs:
if f"_{lang_code}.html" in file:
has_lang_suffix = True
break
if not has_lang_suffix:
target_files.append(os.path.join(root, file))
for file_path in target_files:
try:
with open(file_path, encoding="utf-8", errors="ignore") as f:
content = f.read()
# Very basic HTML tag removal for searching
text_content = re.sub(r"<[^>]+>", " ", content)
text_content = " ".join(text_content.split())
if query in text_content.lower():
# Find title
title_match = re.search(
r"