dp-zp-agent/scripts/common.py
2026-07-28 23:56:03 +02:00

385 lines
7.0 KiB
Python

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,
}