#!/usr/bin/env python3 """ Upload bilingual training documentation to GOD CRM Documents module. Reads two markdown files (English and Russian) and uploads them as a single bilingual document into the CRM Knowledge Base (widget 126). Usage: python3 scripts/upload-training-docs.py --en docs/training_en.md --ru docs/training_ru.md python3 scripts/upload-training-docs.py --en docs/training_en.md --ru docs/training_ru.md --name "User Guide" python3 scripts/upload-training-docs.py --en docs/training_en.md # English only python3 scripts/upload-training-docs.py --list # List existing documents Environment variables: CRM_URL - CRM base URL (default: http://localhost:5001) CRM_JWT_SECRET - JWT secret for token generation (reads from .env if not set) CRM_USER_ID - User ID for auth (default: 2) Configuration: PROJECT_ID = 146 (Knowledge Base project) REGISTRY_TABLE_ID = 2362 (Documents registry) FOLDER_PATH = knowledge-base/documents/ """ import argparse import json import os import re import sys import urllib.request import urllib.error import hashlib import hmac import base64 import time import struct from pathlib import Path from difflib import SequenceMatcher # ============================================================================= # Configuration # ============================================================================= CRM_URL = os.environ.get("CRM_URL", "http://localhost:5001") PROJECT_ID = 146 REGISTRY_TABLE_ID = 2365 FOLDER_PATH = "knowledge-base/documents/" # JWT configuration JWT_SECRET = os.environ.get("CRM_JWT_SECRET", "") CRM_USER_ID = int(os.environ.get("CRM_USER_ID", "2")) CRM_USER_EMAIL = os.environ.get("CRM_USER_EMAIL", "gera69lvl@gmail.com") # ============================================================================= # JWT Token Generation (minimal, no external deps) # ============================================================================= def _b64url_encode(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") def generate_jwt(payload: dict, secret: str) -> str: """Generate a HS256 JWT token without external dependencies.""" header = {"alg": "HS256", "typ": "JWT"} header_b64 = _b64url_encode(json.dumps(header, separators=(",", ":")).encode()) payload_b64 = _b64url_encode(json.dumps(payload, separators=(",", ":")).encode()) signing_input = f"{header_b64}.{payload_b64}" signature = hmac.new( secret.encode(), signing_input.encode(), hashlib.sha256 ).digest() sig_b64 = _b64url_encode(signature) return f"{header_b64}.{payload_b64}.{sig_b64}" def get_jwt_token() -> str: """Get JWT token for CRM API authentication.""" global JWT_SECRET if not JWT_SECRET: # Try to read from .env env_path = Path(__file__).parent.parent / ".env" if env_path.exists(): for line in env_path.read_text().splitlines(): if line.startswith("JWT_SECRET="): JWT_SECRET = line.split("=", 1)[1].strip() break if not JWT_SECRET: print("ERROR: JWT_SECRET not found. Set CRM_JWT_SECRET env var or add to .env") sys.exit(1) now = int(time.time()) payload = { "id": CRM_USER_ID, "email": CRM_USER_EMAIL, "role": "admin", "iat": now, "exp": now + 86400, # 24 hours } return generate_jwt(payload, JWT_SECRET) # ============================================================================= # CRM API Client # ============================================================================= class CrmApi: def __init__(self, base_url: str, token: str): self.base_url = base_url.rstrip("/") self.token = token def _request(self, method: str, path: str, data: dict = None) -> dict: url = f"{self.base_url}{path}" body = json.dumps(data).encode() if data else None req = urllib.request.Request( url, data=body, method=method, headers={ "Content-Type": "application/json", "Cookie": f"access_token={self.token}", }, ) try: with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode()) except urllib.error.HTTPError as e: error_body = e.read().decode() if e.fp else "" print(f" HTTP {e.code}: {error_body[:500]}") raise except urllib.error.URLError as e: print(f" Connection error: {e.reason}") raise def get(self, path: str) -> dict: return self._request("GET", path) def post(self, path: str, data: dict) -> dict: return self._request("POST", path, data) @staticmethod def _unwrap(result: dict) -> dict: """Unwrap CRM API response: {success, data} -> data.""" if result.get("success") and "data" in result: return result["data"] return result def create_document(self, name: str, slug: str = None, description: str = "", icon: str = "๐Ÿ“„", category: str = None) -> dict: """Create a new document in the Knowledge Base registry.""" payload = { "name": name, "description": description, "icon": icon, "folder_path": FOLDER_PATH, } if slug: payload["slug"] = slug if category: payload["category"] = category print(f" Creating document: {name}") result = self._unwrap(self.post(f"/api/v3/projects/{PROJECT_ID}/documents", payload)) print(f" Created: document_id={result.get('document_id')}, table_id={result.get('table_id')}") return result def import_sections(self, document_id: int, sections: list) -> dict: """Import bilingual sections into a document via import-v4.""" print(f" Importing {len(sections)} sections into document {document_id}...") result = self._unwrap(self.post( f"/api/v3/documents/{document_id}/import-v4", { "registry_table_id": REGISTRY_TABLE_ID, "sections": sections, }, )) print(f" Imported {result.get('count', 0)} sections") return result def list_documents(self) -> list: """List existing documents in the Knowledge Base.""" result = self._unwrap(self.get( f"/api/v3/tables/{REGISTRY_TABLE_ID}/rows?limit=100" )) if isinstance(result, list): return result return result.get("rows", result.get("data", [])) # ============================================================================= # Markdown Parser # ============================================================================= def parse_markdown(text: str) -> list: """ Parse markdown text into sections. Returns list of dicts with keys: level: 'h1', 'h2', 'h3', 'text', 'code', 'divider' title: heading text (for h1/h2/h3) content: body text code_lang: language for code blocks """ sections = [] lines = text.split("\n") i = 0 current_text_lines = [] def flush_text(): nonlocal current_text_lines if current_text_lines: content = "\n".join(current_text_lines).strip() if content: sections.append({ "level": "text", "content": content, }) current_text_lines = [] while i < len(lines): line = lines[i] # Headings h_match = re.match(r"^(#{1,3})\s+(.+)$", line) if h_match: flush_text() level = f"h{len(h_match.group(1))}" title = h_match.group(2).strip() sections.append({ "level": level, "title": title, "content": title, }) i += 1 continue # Code blocks code_match = re.match(r"^```(\w*)$", line) if code_match: flush_text() lang = code_match.group(1) or "text" code_lines = [] i += 1 while i < len(lines) and not lines[i].startswith("```"): code_lines.append(lines[i]) i += 1 i += 1 # skip closing ``` code_content = "\n".join(code_lines) if code_content.strip(): sections.append({ "level": "text", "content": f"```{lang}\n{code_content}\n```", "type": "code", }) continue # Horizontal rules / dividers if re.match(r"^[-*_]{3,}\s*$", line): flush_text() sections.append({"level": "divider", "content": "---"}) i += 1 continue # Regular text current_text_lines.append(line) i += 1 flush_text() return sections def _split_into_chapters(sections: list, level: str = "h1") -> list: """ Split a flat list of sections into chapters at the given heading level. Returns list of dicts: {"heading": section_or_None, "body": [sections...]} """ chapters = [] current = {"heading": None, "body": []} for sec in sections: if sec["level"] == level: # Save previous chapter if it has content if current["heading"] or current["body"]: chapters.append(current) current = {"heading": sec, "body": []} else: current["body"].append(sec) if current["heading"] or current["body"]: chapters.append(current) return chapters def _collect_text_content(sections: list) -> str: """Collect all text/divider content from sections into a single string.""" parts = [] for sec in sections: if sec["level"] in ("text", "divider"): parts.append(sec.get("content", "")) return "\n\n".join(parts) def _collect_subheading_groups(body: list, sub_level: str) -> list: """ Split body sections into groups by sub-heading level. Returns: [{"heading": sec_or_None, "body": [text_sections...]}] """ groups = [] current = {"heading": None, "body": []} for sec in body: if sec["level"] == sub_level: if current["heading"] or current["body"]: groups.append(current) current = {"heading": sec, "body": []} elif sec["level"] in ("text", "divider"): current["body"].append(sec) else: # Lower-level heading (e.g., h3 when splitting by h2) โ€” treat as text current["body"].append(sec) if current["heading"] or current["body"]: groups.append(current) return groups def _align_chapters(en_chapters: list, ru_chapters: list) -> list: """ Build a list of (en_chapter_or_None, ru_chapter_or_None) pairs. Uses exact title matching as priority, then positional fallback. This handles cases where one language has extra chapters (e.g. RU has an extra "AI ะะณะตะฝั‚ั‹" chapter before "AI Agents API"). """ # Build mapping by exact title match ru_title_map = {} for idx, ch in enumerate(ru_chapters): if ch["heading"]: title = ch["heading"].get("title", "").strip() if title: ru_title_map[title] = idx pairs = [] used_ru = set() en_idx = 0 ru_idx = 0 for en_ch in en_chapters: en_title = en_ch["heading"].get("title", "").strip() if en_ch["heading"] else "" # Try exact title match first if en_title and en_title in ru_title_map: matched_ru_idx = ru_title_map[en_title] # Add any unmatched RU chapters before this match as RU-only while ru_idx < matched_ru_idx: if ru_idx not in used_ru: pairs.append((None, ru_chapters[ru_idx])) used_ru.add(ru_idx) ru_idx += 1 pairs.append((en_ch, ru_chapters[matched_ru_idx])) used_ru.add(matched_ru_idx) ru_idx = matched_ru_idx + 1 else: # Positional fallback: match with next unused RU chapter while ru_idx < len(ru_chapters) and ru_idx in used_ru: ru_idx += 1 if ru_idx < len(ru_chapters): pairs.append((en_ch, ru_chapters[ru_idx])) used_ru.add(ru_idx) ru_idx += 1 else: pairs.append((en_ch, None)) # Add remaining unmatched RU chapters for idx in range(len(ru_chapters)): if idx not in used_ru: pairs.append((None, ru_chapters[idx])) return pairs def merge_bilingual_sections(en_sections: list, ru_sections: list) -> list: """ Merge English and Russian sections into bilingual sections. Strategy (heading-based tree alignment): 1. Split both into H1 chapters 2. Match chapters using exact title matching + positional fallback 3. Within each chapter, split by H2 sections and match by index 4. Within each H2, split by H3 and match by index 5. Between matched headings, merge ALL text blocks into single content_en + content_ru 6. Unmatched sections get single-language content This handles structural differences where one language has more detail (extra paragraphs, sub-headings) within the same chapter. """ merged = [] en_chapters = _split_into_chapters(en_sections, "h1") ru_chapters = _split_into_chapters(ru_sections, "h1") chapter_pairs = _align_chapters(en_chapters, ru_chapters) for en_ch, ru_ch in chapter_pairs: # --- Merge H1 heading --- if en_ch and en_ch["heading"] and ru_ch and ru_ch["heading"]: merged.append({ "level": "h1", "title": en_ch["heading"].get("title", ""), "content_en": en_ch["heading"].get("title", ""), "content_ru": ru_ch["heading"].get("title", ""), }) elif en_ch and en_ch["heading"]: merged.append({ "level": "h1", "title": en_ch["heading"].get("title", ""), "content_en": en_ch["heading"].get("title", ""), "content_ru": "", }) elif ru_ch and ru_ch["heading"]: merged.append({ "level": "h1", "title": ru_ch["heading"].get("title", ""), "content_en": "", "content_ru": ru_ch["heading"].get("title", ""), }) en_body = (en_ch["body"] if en_ch else []) ru_body = (ru_ch["body"] if ru_ch else []) # --- Split bodies by H2 --- en_h2_groups = _collect_subheading_groups(en_body, "h2") ru_h2_groups = _collect_subheading_groups(ru_body, "h2") max_h2 = max(len(en_h2_groups), len(ru_h2_groups)) if (en_h2_groups or ru_h2_groups) else 0 for h2_idx in range(max_h2): en_g = en_h2_groups[h2_idx] if h2_idx < len(en_h2_groups) else None ru_g = ru_h2_groups[h2_idx] if h2_idx < len(ru_h2_groups) else None # --- Merge H2 heading --- if en_g and en_g["heading"] and ru_g and ru_g["heading"]: merged.append({ "level": "h2", "title": en_g["heading"].get("title", ""), "content_en": en_g["heading"].get("title", ""), "content_ru": ru_g["heading"].get("title", ""), }) elif en_g and en_g["heading"]: merged.append({ "level": "h2", "title": en_g["heading"].get("title", ""), "content_en": en_g["heading"].get("title", ""), "content_ru": "", }) elif ru_g and ru_g["heading"]: merged.append({ "level": "h2", "title": ru_g["heading"].get("title", ""), "content_en": "", "content_ru": ru_g["heading"].get("title", ""), }) en_sub_body = (en_g["body"] if en_g else []) ru_sub_body = (ru_g["body"] if ru_g else []) # --- Split by H3 within this H2 group --- en_h3_groups = _collect_subheading_groups(en_sub_body, "h3") ru_h3_groups = _collect_subheading_groups(ru_sub_body, "h3") max_h3 = max(len(en_h3_groups), len(ru_h3_groups)) if (en_h3_groups or ru_h3_groups) else 0 for h3_idx in range(max_h3): en_h3 = en_h3_groups[h3_idx] if h3_idx < len(en_h3_groups) else None ru_h3 = ru_h3_groups[h3_idx] if h3_idx < len(ru_h3_groups) else None # --- Merge H3 heading --- if en_h3 and en_h3["heading"] and ru_h3 and ru_h3["heading"]: merged.append({ "level": "h3", "title": en_h3["heading"].get("title", ""), "content_en": en_h3["heading"].get("title", ""), "content_ru": ru_h3["heading"].get("title", ""), }) elif en_h3 and en_h3["heading"]: merged.append({ "level": "h3", "title": en_h3["heading"].get("title", ""), "content_en": en_h3["heading"].get("title", ""), "content_ru": "", }) elif ru_h3 and ru_h3["heading"]: merged.append({ "level": "h3", "title": ru_h3["heading"].get("title", ""), "content_en": "", "content_ru": ru_h3["heading"].get("title", ""), }) # --- Merge text content under H3 --- en_text = _collect_text_content(en_h3["body"] if en_h3 else []) ru_text = _collect_text_content(ru_h3["body"] if ru_h3 else []) if en_text or ru_text: merged.append({ "level": "text", "content_en": en_text, "content_ru": ru_text, }) return merged def validate_bilingual_structure(en_sections: list, ru_sections: list) -> dict: """ Validate that EN and RU documents have matching structure. Returns a report dict with: - valid: bool - warnings: [str] - errors: [str] - heading counts per level - matched/unmatched headings """ report = { "valid": True, "warnings": [], "errors": [], "en_headings": {"h1": 0, "h2": 0, "h3": 0}, "ru_headings": {"h1": 0, "h2": 0, "h3": 0}, "en_total": len(en_sections), "ru_total": len(ru_sections), "matched": [], "en_only": [], "ru_only": [], } # Count headings en_h = [s for s in en_sections if s["level"] in ("h1", "h2", "h3")] ru_h = [s for s in ru_sections if s["level"] in ("h1", "h2", "h3")] for h in en_h: report["en_headings"][h["level"]] += 1 for h in ru_h: report["ru_headings"][h["level"]] += 1 # Check heading counts match for level in ("h1", "h2", "h3"): en_count = report["en_headings"][level] ru_count = report["ru_headings"][level] if en_count != ru_count: diff = abs(en_count - ru_count) msg = f"{level.upper()}: EN={en_count}, RU={ru_count} (diff: {diff})" if level == "h1" and diff > 0: report["errors"].append(msg) report["valid"] = False elif diff > 5: report["errors"].append(msg) report["valid"] = False else: report["warnings"].append(msg) # Match headings at each level using fuzzy matching for level in ("h1", "h2", "h3"): en_at_level = [h for h in en_h if h["level"] == level] ru_at_level = [h for h in ru_h if h["level"] == level] used_ru = set() for eh in en_at_level: en_title = eh.get("title", eh.get("content", "")).lower() best_score = 0.0 best_idx = -1 best_ru_title = "" for j, rh in enumerate(ru_at_level): if j in used_ru: continue ru_title = rh.get("title", rh.get("content", "")).lower() # Check exact match or technical term overlap if en_title == ru_title: score = 1.0 else: score = SequenceMatcher(None, en_title, ru_title).ratio() # Boost for shared technical terms tech = re.findall(r'[A-Z]{2,}|api|crm|jwt|webhook', f"{en_title} {ru_title}", re.IGNORECASE) en_tech = set(t.lower() for t in re.findall(r'[A-Z]{2,}|api|crm|jwt|webhook', en_title, re.IGNORECASE)) ru_tech = set(t.lower() for t in re.findall(r'[A-Z]{2,}|api|crm|jwt|webhook', ru_title, re.IGNORECASE)) if en_tech and ru_tech and en_tech == ru_tech: score = max(score, 0.85) if score > best_score: best_score = score best_idx = j best_ru_title = ru_title if best_idx >= 0 and best_score >= 0.3: report["matched"].append((eh.get("title", ""), ru_at_level[best_idx].get("title", ""), round(best_score, 2))) used_ru.add(best_idx) else: report["en_only"].append(eh.get("title", eh.get("content", ""))) for j, rh in enumerate(ru_at_level): if j not in used_ru: report["ru_only"].append(rh.get("title", rh.get("content", ""))) # Total section count warning total_diff = abs(len(en_sections) - len(ru_sections)) if total_diff > 20: report["warnings"].append(f"Section count differs: EN={len(en_sections)}, RU={len(ru_sections)} (diff: {total_diff})") return report def print_validation(report: dict) -> bool: """Print validation report and return whether valid.""" valid = report["valid"] print(f"\n{'โ”€'*50}") print(f"๐Ÿ“‹ Structure Validation: {'โœ… VALID' if valid else 'โŒ INVALID'}") print(f"{'โ”€'*50}") print(f" {'Level':<6} {'EN':>5} {'RU':>5} {'Match':>7}") for level in ("h1", "h2", "h3"): en_n = report["en_headings"][level] ru_n = report["ru_headings"][level] ok = "โœ…" if en_n == ru_n else "โŒ" if (level == "h1" and en_n != ru_n) else "โš ๏ธ" print(f" {level.upper():<6} {en_n:>5} {ru_n:>5} {ok:>7}") print(f" Total: EN={report['en_total']}, RU={report['ru_total']}") if report["matched"]: print(f"\n Matched headings ({len(report['matched'])}):") for en_t, ru_t, score in report["matched"][:10]: print(f" {score:.0%} โ”‚ {en_t[:30]:<30} โ†” {ru_t[:30]}") if report["en_only"]: print(f"\n EN-only ({len(report['en_only'])}):") for t in report["en_only"][:5]: print(f" ๐Ÿ‡ฌ๐Ÿ‡ง {t}") if report["ru_only"]: print(f"\n RU-only ({len(report['ru_only'])}):") for t in report["ru_only"][:5]: print(f" ๐Ÿ‡ท๐Ÿ‡บ {t}") if report["errors"]: print(f"\n โŒ Errors:") for e in report["errors"]: print(f" โ€ข {e}") if report["warnings"]: print(f"\n โš ๏ธ Warnings:") for w in report["warnings"]: print(f" โ€ข {w}") return valid def prepare_api_sections(merged_sections: list) -> list: """Convert merged sections to API import-v4 format.""" api_sections = [] order = 10 for sec in merged_sections: entry = { "level": sec["level"], "order": order, } if sec["level"] in ("h1", "h2", "h3"): entry["title"] = sec.get("title", "") if sec.get("content_en"): entry["content_en"] = sec["content_en"] if sec.get("content_ru"): entry["content_ru"] = sec["content_ru"] # For single-language fallback if "content_en" not in entry and "content_ru" not in entry: entry["content_en"] = sec.get("content", "") api_sections.append(entry) order += 10 return api_sections # ============================================================================= # Main # ============================================================================= def cmd_upload(args): """Upload markdown files as a bilingual document.""" # Read files en_text = "" ru_text = "" if args.en: en_path = Path(args.en) if not en_path.exists(): print(f"ERROR: English file not found: {args.en}") sys.exit(1) en_text = en_path.read_text(encoding="utf-8") print(f"Read EN: {en_path} ({len(en_text)} chars)") if args.ru: ru_path = Path(args.ru) if not ru_path.exists(): print(f"ERROR: Russian file not found: {args.ru}") sys.exit(1) ru_text = ru_path.read_text(encoding="utf-8") print(f"Read RU: {ru_path} ({len(ru_text)} chars)") if not en_text and not ru_text: print("ERROR: At least one language file is required (--en or --ru)") sys.exit(1) # Parse markdown en_sections = parse_markdown(en_text) if en_text else [] ru_sections = parse_markdown(ru_text) if ru_text else [] print(f"Parsed: {len(en_sections)} EN sections, {len(ru_sections)} RU sections") # Validate structure before merge if en_sections and ru_sections and not getattr(args, 'skip_validation', False): report = validate_bilingual_structure(en_sections, ru_sections) is_valid = print_validation(report) if getattr(args, 'validate', False): print("\n๐Ÿ” VALIDATE MODE โ€” no upload performed") sys.exit(0 if is_valid else 1) if not is_valid: print("\nโŒ Structure validation failed!") print(" Fix the EN/RU files to have matching structure, or use --skip-validation to force import") sys.exit(1) elif getattr(args, 'validate', False): print("โŒ Need both --en and --ru for validation") sys.exit(1) # Merge bilingual if en_sections and ru_sections: merged = merge_bilingual_sections(en_sections, ru_sections) print(f"Merged: {len(merged)} bilingual sections") elif en_sections: merged = [ { "level": s["level"], "title": s.get("title", ""), "content_en": s.get("content", s.get("title", "")), } for s in en_sections ] else: merged = [ { "level": s["level"], "title": s.get("title", ""), "content_ru": s.get("content", s.get("title", "")), } for s in ru_sections ] # Prepare API sections api_sections = prepare_api_sections(merged) # Connect to CRM token = get_jwt_token() api = CrmApi(CRM_URL, token) # Determine document name doc_name = args.name if not doc_name: # Use first H1 from EN, or filename for s in (en_sections or ru_sections): if s["level"] == "h1": doc_name = s.get("title", "") break if not doc_name: doc_name = Path(args.en or args.ru).stem.replace("_", " ").replace("-", " ").title() doc_slug = args.slug doc_icon = args.icon or "๐Ÿ“–" doc_category = args.category # Create document print(f"\nCreating document: '{doc_name}'") doc_result = api.create_document( name=doc_name, slug=doc_slug, description=f"Training documentation ({('EN+RU' if en_text and ru_text else 'EN' if en_text else 'RU')})", icon=doc_icon, category=doc_category, ) document_id = doc_result["document_id"] table_id = doc_result["table_id"] # Import sections print(f"\nImporting sections into document {document_id} (table {table_id})...") # Split into batches of 50 to avoid timeout batch_size = 50 total_imported = 0 for i in range(0, len(api_sections), batch_size): batch = api_sections[i : i + batch_size] # Re-number orders within batch for j, sec in enumerate(batch): sec["order"] = (i + j) * 10 + 10 result = api.import_sections(document_id, batch) total_imported += result.get("count", 0) print(f"\n{'='*60}") print(f"SUCCESS: Document uploaded!") print(f" Name: {doc_name}") print(f" Document ID: {document_id}") print(f" Table ID: {table_id}") print(f" Sections: {total_imported}") print(f" Languages: {'EN + RU' if en_text and ru_text else 'EN' if en_text else 'RU'}") print(f" URL: {CRM_URL}/widgets/126") print(f"{'='*60}") def cmd_list(args): """List existing documents in Knowledge Base.""" token = get_jwt_token() api = CrmApi(CRM_URL, token) docs = api.list_documents() if not docs: print("No documents found in Knowledge Base") return print(f"\nKnowledge Base Documents ({len(docs)} total):") print(f"{'ID':<6} {'Name':<40} {'Status':<10} {'Slug':<30}") print("-" * 90) for doc in docs: data = doc.get("data", doc) if isinstance(doc, dict) else {} if isinstance(data, str): data = json.loads(data) doc_id = doc.get("id", "?") name = data.get("name", "?") status = data.get("status", "?") slug = data.get("slug", "?") print(f"{doc_id:<6} {name:<40} {status:<10} {slug:<30}") def _update_crm_url(url): global CRM_URL CRM_URL = url def main(): parser = argparse.ArgumentParser( description="Upload bilingual training docs to GOD CRM Knowledge Base", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Upload both languages python3 %(prog)s --en training_en.md --ru training_ru.md --name "User Guide" # Upload English only python3 %(prog)s --en training_en.md --name "Quick Start" # Upload with custom settings python3 %(prog)s --en guide_en.md --ru guide_ru.md --name "API Guide" --icon "๐Ÿ”Œ" --category "api" # List existing documents python3 %(prog)s --list """, ) parser.add_argument("--en", help="Path to English markdown file") parser.add_argument("--ru", help="Path to Russian markdown file") parser.add_argument("--name", help="Document name (auto-detected from first H1 if not set)") parser.add_argument("--slug", help="Document slug (auto-generated from name if not set)") parser.add_argument("--icon", default="๐Ÿ“–", help="Document icon emoji (default: ๐Ÿ“–)") parser.add_argument("--category", help="Document category") parser.add_argument("--list", action="store_true", help="List existing documents") parser.add_argument("--dry-run", action="store_true", help="Parse files but don't upload") parser.add_argument("--validate", action="store_true", help="Validate EN/RU structure only (no upload)") parser.add_argument("--skip-validation", action="store_true", help="Skip structure validation before import") parser.add_argument("--url", default=CRM_URL, help=f"CRM URL (default: {CRM_URL})") args = parser.parse_args() if args.url and args.url != CRM_URL: _update_crm_url(args.url) if args.list: cmd_list(args) return if not args.en and not args.ru: parser.print_help() print("\nERROR: Specify at least --en or --ru file") sys.exit(1) if args.dry_run: # Just parse and show structure for lang, path in [("EN", args.en), ("RU", args.ru)]: if path: text = Path(path).read_text(encoding="utf-8") sections = parse_markdown(text) print(f"\n{lang} sections ({len(sections)}):") for i, s in enumerate(sections): level = s["level"] preview = s.get("title", s.get("content", ""))[:80] print(f" [{i:3d}] {level:6s} {preview}") return cmd_upload(args) if __name__ == "__main__": main()