385 lines
7.0 KiB
Python
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,
|
|
}
|