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[1] 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, ) -> 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(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, }