from __future__ import annotations import html import json import os import re from pathlib import Path from typing import Any import frontmatter PROJECT_ROOT = Path(__file__).resolve().parents[2] ZPWIKI_ROOT = Path( os.getenv( "ZPWIKI_ROOT", str(PROJECT_ROOT.parent / "zpwiki"), ) ).resolve() PAGES_ROOT = ZPWIKI_ROOT / "pages" DATA_DIR = PROJECT_ROOT / "data" DOCUMENTS_FILE = DATA_DIR / "documents.json" CHUNKS_FILE = DATA_DIR / "chunks.json" DB_FILE = DATA_DIR / "zp_index.sqlite" HEADING_RE = re.compile(r"^[ \t]{0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$") FENCE_RE = re.compile(r"^[ \t]{0,3}(```+|~~~+)") MARKDOWN_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]*\)") MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]*\)") HTML_TAG_RE = re.compile(r"<[^>]+>") WHITESPACE_RE = re.compile(r"\s+") TRUE_VALUES = { "1", "true", "yes", "on", "ano", "áno", } FALSE_VALUES = { "0", "false", "no", "off", "nie", } def json_safe(value: Any) -> Any: """Prevedie metadata do formátu vhodného pre JSON.""" if value is None or isinstance( value, (str, int, float, bool), ): return value if isinstance(value, list): return [json_safe(item) for item in value] if isinstance(value, dict): return {str(key): json_safe(item) for key, item in value.items()} return str(value) def normalize_list(value: Any) -> list[str]: """Zjednotí hodnotu na čistý zoznam bez duplicít.""" if value is None: return [] if isinstance(value, list): raw_items = [str(item).strip() for item in value] elif isinstance(value, str): raw_items = [item.strip() for item in value.split(",")] else: raw_items = [str(value).strip()] items: list[str] = [] seen: set[str] = set() for item in raw_items: if item and item not in seen: items.append(item) seen.add(item) return items def normalize_optional_bool( value: Any, ) -> bool | None: """Normalizuje bežné YAML reprezentácie true/false.""" if value is None: return None if isinstance(value, bool): return value if isinstance(value, int) and value in {0, 1}: return bool(value) if isinstance(value, str): normalized = value.strip().casefold() if normalized in TRUE_VALUES: return True if normalized in FALSE_VALUES: return False return None def read_json(path: Path) -> Any: if not path.exists(): raise FileNotFoundError(f"Súbor neexistuje: {path}") with path.open( "r", encoding="utf-8", ) as file: return json.load(file) def write_json( path: Path, data: Any, ) -> None: path.parent.mkdir( parents=True, exist_ok=True, ) with path.open( "w", encoding="utf-8", ) as file: json.dump( data, file, ensure_ascii=False, indent=2, ) def clean_heading_text( value: str, ) -> str: """Odstráni základné Markdown značky z nadpisu.""" value = html.unescape(value) value = MARKDOWN_IMAGE_RE.sub( r"\1", value, ) value = MARKDOWN_LINK_RE.sub( r"\1", value, ) value = HTML_TAG_RE.sub( "", value, ) value = value.replace("`", "") value = value.replace("*", "") value = value.replace("_", " ") value = value.replace("~", "") return WHITESPACE_RE.sub( " ", value, ).strip() def first_markdown_heading( content: str, ) -> str | None: """ Nájde prvý Markdown nadpis mimo fenced code blockov. """ active_fence: str | None = None for line in content.splitlines(): fence_match = FENCE_RE.match(line) if fence_match: marker = fence_match.group(1)[0] if active_fence == marker: active_fence = None elif active_fence is None: active_fence = marker continue if active_fence is not None: continue heading_match = HEADING_RE.match(line) if not heading_match: continue heading = clean_heading_text(heading_match.group(1)) if heading: return heading return None def parent_directory_title( file_path: Path, ) -> str | None: """Vytvorí názov z rodičovského priečinka.""" directory_name = file_path.parent.name.strip() if not directory_name: return None readable = re.sub( r"[_-]+", " ", directory_name, ) readable = WHITESPACE_RE.sub( " ", readable, ).strip() return readable.title() or None def resolve_page_title( file_path: Path, metadata: dict[str, Any], content: str, ) -> str: """ Určí názov dokumentu v tomto poradí: 1. YAML title, 2. prvý Markdown nadpis, 3. rodičovský priečinok, 4. cesta k súboru. """ metadata_title = metadata.get("title") if metadata_title is not None: title = str(metadata_title).strip() if title: return title heading_title = first_markdown_heading(content) if heading_title: return heading_title directory_title = parent_directory_title(file_path) if directory_title: return directory_title try: relative_path = file_path.relative_to(ZPWIKI_ROOT) except ValueError: relative_path = file_path return str(relative_path) def load_zpwiki_page( file_path: Path, *, wiki_root: Path | None = None, ) -> dict[str, Any]: post = frontmatter.load(file_path) metadata = {key: json_safe(value) for key, value in post.metadata.items()} raw_taxonomy = metadata.get("taxonomy") taxonomy = raw_taxonomy if isinstance(raw_taxonomy, dict) else {} categories = normalize_list(metadata.get("category") or taxonomy.get("category")) tags = normalize_list( metadata.get("tag") or metadata.get("tags") or taxonomy.get("tag") or taxonomy.get("tags") ) content = post.content.strip() title = resolve_page_title( file_path, metadata, content, ) return { "path": str(file_path.relative_to(wiki_root or ZPWIKI_ROOT)), "title": title, "categories": categories, "tags": tags, "published": normalize_optional_bool(metadata.get("published")), "author": (metadata.get("author") or taxonomy.get("author")), "taxonomy": taxonomy, "metadata": metadata, "content": content, }