import html import io import logging import os import re import shutil import threading import zipfile 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 self.storage_dir: self.docs_base_dir = os.path.join(self.storage_dir, "reticulum-docs") self.meshchatx_docs_dir = os.path.join(self.storage_dir, "meshchatx-docs") else: self.docs_base_dir = os.path.join(self.public_dir, "reticulum-docs") self.meshchatx_docs_dir = os.path.join(self.public_dir, "meshchatx-docs") # The actual docs are served from this directory # We will use a 'current' subdirectory for the active version self.docs_dir = os.path.join(self.docs_base_dir, "current") self.versions_dir = os.path.join(self.docs_base_dir, "versions") self.download_status = "idle" self.download_progress = 0 self.last_error = None # Ensure docs directories exist try: for d in [ self.docs_base_dir, self.versions_dir, self.docs_dir, self.meshchatx_docs_dir, ]: if not os.path.exists(d): os.makedirs(d) # If 'current' doesn't exist but we have versions, pick the latest one if not os.path.exists(self.docs_dir) or not os.listdir(self.docs_dir): self._update_current_link() except OSError as e: logging.exception(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 _update_current_link(self, version=None): """Updates the 'current' directory to point to the specified version or the latest one.""" if not os.path.exists(self.versions_dir): return versions = self.get_available_versions() if not versions: return target_version = version if not target_version: # Pick latest version (alphabetically) target_version = versions[-1] version_path = os.path.join(self.versions_dir, target_version) if not os.path.exists(version_path): return # On some systems symlinks might fail or be restricted, so we use a directory copy or move # but for now let's try to just use the path directly if possible. # However, meshchat.py uses self.docs_dir for the static route. # To make it simple and robust across platforms, we'll clear 'current' and copy the version if os.path.exists(self.docs_dir): if os.path.islink(self.docs_dir): os.unlink(self.docs_dir) else: shutil.rmtree(self.docs_dir) try: # Try symlink first as it's efficient # We use a relative path for the symlink target to make the storage directory portable # version_path is relative to CWD, so we need it relative to the parent of self.docs_dir rel_target = os.path.relpath(version_path, os.path.dirname(self.docs_dir)) os.symlink(rel_target, self.docs_dir) except (OSError, AttributeError): # Fallback to copy shutil.copytree(version_path, self.docs_dir) def get_available_versions(self): if not os.path.exists(self.versions_dir): return [] versions = [ d for d in os.listdir(self.versions_dir) if os.path.isdir(os.path.join(self.versions_dir, d)) ] return sorted(versions) def get_current_version(self): if not os.path.exists(self.docs_dir): return None if os.path.islink(self.docs_dir): return os.path.basename(os.readlink(self.docs_dir)) # If it's a copy, we might need a metadata file to know which version it is version_file = os.path.join(self.docs_dir, ".version") if os.path.exists(version_file): try: with open(version_file) as f: return f.read().strip() except OSError: pass return "unknown" def switch_version(self, version): if version in self.get_available_versions(): self._update_current_link(version) return True return False def delete_version(self, version): """Deletes a specific version of documentation.""" if version not in self.get_available_versions(): return False version_path = os.path.join(self.versions_dir, version) if not os.path.exists(version_path): return False try: # If the deleted version is the current one, unlink 'current' first current_version = self.get_current_version() if current_version == version: if os.path.exists(self.docs_dir): if os.path.islink(self.docs_dir): os.unlink(self.docs_dir) else: shutil.rmtree(self.docs_dir) shutil.rmtree(version_path) # If we just deleted the current version, try to pick another one as current if current_version == version: self._update_current_link() return True except Exception as e: logging.exception(f"Failed to delete docs version {version}: {e}") return False def clear_reticulum_docs(self): """Clears all Reticulum documentation and versions.""" try: if os.path.exists(self.docs_base_dir): # We don't want to delete the base dir itself, just its contents # except possibly some metadata if we added any. # Actually, deleting everything inside reticulum-docs is fine. for item in os.listdir(self.docs_base_dir): item_path = os.path.join(self.docs_base_dir, item) if os.path.islink(item_path): os.unlink(item_path) elif os.path.isdir(item_path): shutil.rmtree(item_path) else: os.remove(item_path) # Re-create required subdirectories for d in [self.versions_dir, self.docs_dir]: if not os.path.exists(d): os.makedirs(d) self.config.docs_downloaded.set(False) return True except Exception as e: logging.exception(f"Failed to clear Reticulum docs: {e}") return False 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 (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")), ) 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, 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,
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.exception(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:
# English: no language suffix; other langs use _