' if description else ""
+ images = offer_images(concept) if is_offer(concept) else []
+ gallery = render_gallery(images, str(frontmatter.get("nazwa") or concept.title))
+
content = (
f'{chip}\n'
- f"
{headline}
\n{lead}\n"
+ f"
{headline}
\n{lead}\n{gallery}\n"
f"{_meta_table(frontmatter)}\n{rendered}"
)
- return _page_shell(concept.title, concept.id.count("/"), content)
+ return _page_shell(
+ concept.title,
+ concept.id.count("/"),
+ content,
+ script=GALLERY_JS if len(images) > 1 else "",
+ )
_INDEX_HEADING_RE = re.compile(r"^#\s+(.+)$", re.M)
@@ -505,6 +658,467 @@ def render_index(concepts: list[Concept]) -> str:
return _page_shell("Karty", 0, content)
+# --- zdjęcia obiektów (pages/img//, patrz gen_images.py) -----------
+
+IMG_DIRNAME = "img"
+IMG_DIR = PAGES_DIR / IMG_DIRNAME
+IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp")
+
+
+def offer_image_paths(concept: Concept) -> list[str]:
+ """Zdjęcia oferty jako ścieżki względne wobec katalogu `pages/`.
+
+ Mapowanie id → zdjęcia jest konwencją katalogową (pages/img//NN.jpg),
+ nie polem frontmattera — pliki .md zostają nietknięte.
+ """
+ offer_id = str(concept.frontmatter.get("id") or "").strip()
+ if not offer_id:
+ return []
+ directory = IMG_DIR / offer_id
+ if not directory.is_dir():
+ return []
+ files = sorted(p for p in directory.iterdir() if p.suffix.lower() in IMG_EXTS)
+ return [f"{IMG_DIRNAME}/{offer_id}/{p.name}" for p in files]
+
+
+def offer_images(concept: Concept) -> list[str]:
+ """To samo, ale względem katalogu karty konceptu."""
+ up = "../" * concept.id.count("/")
+ return [up + path for path in offer_image_paths(concept)]
+
+
+def initials(name: str) -> str:
+ """Inicjały obiektu — placeholder dla ofert bez zdjęć."""
+ words = [w for w in re.split(r"[\s/–—-]+", name) if w and w[0].isalnum()]
+ return "".join(w[0].upper() for w in words[:2]) or "?"
+
+
+GALLERY_JS = """
+document.querySelectorAll('.gallery').forEach(function (gallery) {
+ var big = gallery.querySelector('.gallery-main img');
+ gallery.querySelectorAll('.gallery-thumbs button').forEach(function (button) {
+ button.addEventListener('click', function () {
+ var img = button.querySelector('img');
+ big.src = img.dataset.full || img.src;
+ gallery.querySelectorAll('.gallery-thumbs button').forEach(function (b) {
+ b.removeAttribute('aria-current');
+ });
+ button.setAttribute('aria-current', 'true');
+ });
+ });
+});
+"""
+
+
+def render_gallery(images: list[str], alt: str) -> str:
+ if not images:
+ return ""
+ escaped_alt = html.escape(alt, quote=True)
+ first = html.escape(images[0], quote=True)
+ thumbs = ""
+ if len(images) > 1:
+ buttons = "".join(
+ f''
+ for n, src in enumerate(images)
+ )
+ thumbs = f'
{buttons}
'
+ return (
+ ''
+ f'
'
+ f"{thumbs}"
+ )
+
+
+# --- podsumowanie per region (z wyjazd.md) ----------------------------
+
+TRIP_FILE = "wyjazd.md"
+BUDGET_ROW = "budżet grupy razem"
+FALLBACK_ROW = "charakter"
+
+_TABLE_LINK_RE = re.compile(r"\[([^\]]+)\]\(/([^)\s]+\.md)\)")
+_EMPHASIS_RE = re.compile(r"\*\*|\*|`")
+
+
+@dataclass
+class Region:
+ concept_id: str # np. "ischgl/region"
+ dirname: str # np. "ischgl" ("" dla Saalbach z korzenia)
+ label: str # np. "Ischgl/Paznaun"
+ budget: str = ""
+ conclusion: str = ""
+
+
+def _plain(cell: str) -> str:
+ """Komórka tabeli markdown → czysty tekst (linki na same etykiety)."""
+ text = _LINK_RE.sub(r"\1", cell)
+ return _EMPHASIS_RE.sub("", text).strip()
+
+
+def parse_regions() -> list[Region]:
+ """Regiony + budżet całkowity + jeden wniosek — z tabeli w `wyjazd.md`.
+
+ Nic nie jest tu zaszyte na sztywno: kolumny bierzemy z nagłówka tabeli
+ porównawczej (te, które linkują do `*/region.md`), budżet z wiersza
+ „Budżet grupy razem”, a wniosek z pierwszej pozycji „Wniosków
+ przekrojowych”, która dany region wymienia. Dorzucenie piątego wariantu
+ do `wyjazd.md` wystarczy, żeby pojawił się na stronach.
+ """
+ path = BUNDLE_ROOT / TRIP_FILE
+ if not path.is_file():
+ return []
+ text = path.read_text(encoding="utf-8")
+
+ regions: list[Region] = []
+ rows: dict[str, list[str]] = {}
+ lines = text.split("\n")
+ for n, line in enumerate(lines):
+ if not line.strip().startswith("|"):
+ continue
+ header = _cells(line)
+ targets = [_TABLE_LINK_RE.search(c) for c in header[1:]]
+ if sum(1 for t in targets if t and t.group(2).endswith("region.md")) < 2:
+ continue
+ if n + 1 >= len(lines) or not _TABLE_SEP_RE.match(lines[n + 1]):
+ continue
+ for match in targets:
+ if not match or not match.group(2).endswith("region.md"):
+ continue
+ concept_id = match.group(2)[: -len(".md")]
+ regions.append(
+ Region(
+ concept_id=concept_id,
+ dirname=posixpath.dirname(concept_id),
+ label=_plain(match.group(1)),
+ )
+ )
+ for row in lines[n + 2 :]:
+ if not row.strip().startswith("|"):
+ break
+ cells = _cells(row)
+ rows[_plain(cells[0]).lower()] = [_plain(c) for c in cells[1:]]
+ break
+
+ conclusions = parse_conclusions(text)
+ for index, region in enumerate(regions):
+ for key in (BUDGET_ROW, FALLBACK_ROW):
+ values = rows.get(key) or []
+ if index < len(values) and values[index]:
+ if key == BUDGET_ROW:
+ region.budget = values[index]
+ elif not region.conclusion:
+ region.conclusion = values[index]
+ keyword = region.dirname or region.label.split()[0]
+ for raw in conclusions:
+ if keyword.lower() in raw.lower():
+ region.conclusion = _plain(raw.split(". ")[0]).rstrip(".") + "."
+ break
+ return regions
+
+
+_ORDERED_ITEM_RE = re.compile(r"^\s*\d+[.)]\s+(.*)$")
+
+
+def parse_conclusions(text: str) -> list[str]:
+ """Pozycje listy numerowanej z sekcji „Wnioski przekrojowe”."""
+ section = re.split(r"^#{2,4}\s+Wnioski przekrojowe\s*$", text, flags=re.M)
+ if len(section) < 2:
+ return []
+ items: list[str] = []
+ for line in section[1].split("\n"):
+ if line.startswith("#"):
+ break
+ match = _ORDERED_ITEM_RE.match(line)
+ if match:
+ items.append(match.group(1).strip())
+ elif line.startswith((" ", "\t")) and line.strip() and items:
+ items[-1] += " " + line.strip()
+ return items
+
+
+# --- karta porównania ofert -------------------------------------------
+
+# (nagłówek, pole frontmattera / klucz specjalny, czy sortować liczbowo)
+COLUMNS: list[tuple[str, str, bool]] = [
+ ("Oferta", "_nazwa", False),
+ ("Priorytet", "priorytet", False),
+ ("Region", "_region", False),
+ ("Miejscowość", "miejscowosc", False),
+ ("€ / apart. / tydzień", "cena_za_apartament_tydzien", True),
+ ("Rodzina 2+2", "koszt_rodzina_2p2", True),
+ ("Rodzina 2+1", "koszt_rodzina_2p1", True),
+ ("Status ceny", "status_ceny", False),
+ ("Od wyciągu", "odleglosc_od_wyciagu", False),
+ ("WWW", "www", False),
+]
+
+_DIGIT_GAP_RE = re.compile(r"(?<=\d)[\s ](?=\d)")
+_NUMBER_RE = re.compile(r"\d+(?:[.,]\d+)?")
+
+
+def sort_number(value: str) -> float | None:
+ """Pierwsza liczba w tekście, ze spacją jako separatorem tysięcy."""
+ match = _NUMBER_RE.search(_DIGIT_GAP_RE.sub("", value))
+ return float(match.group(0).replace(",", ".")) if match else None
+
+
+def is_offer(concept: Concept) -> bool:
+ return str(concept.frontmatter.get("type", "")).startswith("oferta")
+
+
+def _shorten(value: str) -> str:
+ """Sam rdzeń wartości; doprecyzowanie w nawiasie/po myślniku idzie do title.
+
+ „SZACUNEK — silnik online na alpenparks.at” w komórce rozpycha tabelę na
+ trzy wiersze, a i tak koduje ją kolor wiersza. Pełna treść zostaje pod
+ kursorem, nic nie ginie.
+ """
+ for separator in (" — ", " (", " - ", " dla "):
+ head = value.split(separator)[0].strip()
+ if head and head != value:
+ return head
+ return value
+
+
+# Kolumny, w których doprecyzowanie JEST treścią — skracanie zabrałoby sens.
+NO_SHORTEN = {"odleglosc_od_wyciagu"}
+
+
+def _cell(value: str, numeric: bool, suffix: str = "", short: bool = True) -> str:
+ attrs = ""
+ if not value:
+ return '
—
'
+ if numeric:
+ number = sort_number(value)
+ if number is not None:
+ attrs += f' data-num="{number:g}"'
+ else:
+ attrs += f' data-key="{html.escape(value, quote=True)}"'
+ shown = _shorten(value) if short else value
+ if shown != value:
+ attrs += f' title="{html.escape(value, quote=True)}"'
+ return f"
{html.escape(shown + suffix)}
"
+
+
+def render_comparison(concepts: list[Concept], regions: list[Region]) -> str:
+ """Jedna tabela wszystkich ofert bundle'a, prosto z frontmatterów."""
+ by_dir = {r.dirname: r for r in regions}
+ offers = [c for c in concepts if is_offer(c)]
+ # Domyślna kolejność: najtańsza rodzina 2+2 na górze — pierwsze pytanie,
+ # które grupa zadaje. Resztę porządków daje sortowanie klikiem.
+ offers.sort(
+ key=lambda c: (
+ sort_number(str(c.frontmatter.get("koszt_rodzina_2p2") or "")) or 1e9,
+ c.title,
+ )
+ )
+
+ rows = []
+ for concept in offers:
+ frontmatter = concept.frontmatter
+ status = str(frontmatter.get("status_ceny") or "")
+ upper = status.upper()
+ css_row = (
+ "verified" if "ZWERYFIKOWANE" in upper
+ else "estimate" if "SZACUNEK" in upper
+ else ""
+ )
+ dirname = posixpath.dirname(posixpath.dirname(concept.id))
+ region = by_dir.get(dirname)
+ region_label = region.label if region else (dirname or "—")
+ href = html.escape(posixpath.relpath(concept.page, "."), quote=True)
+
+ images = offer_image_paths(concept) # porownanie.html leży w pages/
+ name = str(frontmatter.get("nazwa") or concept.title)
+ if images:
+ thumb = (
+ f''
+ )
+ else:
+ thumb = (
+ f''
+ f"{html.escape(initials(name))}"
+ )
+
+ cells = [f'
{thumb}
']
+ for _, field, numeric in COLUMNS:
+ if field == "_nazwa":
+ cells.append(
+ f'
')
+ else:
+ value = str(frontmatter.get(field) or "")
+ suffix = ""
+ if not value and field == "cena_za_apartament_tydzien":
+ # Dom dla całej grupy wyceniany jest w całości — pokazujemy
+ # to zamiast pustej komórki, ale z etykietą „za co”.
+ value = str(frontmatter.get("cena_za_calosc_tydzien") or "")
+ suffix = " (cały dom)" if value else ""
+ cells.append(
+ _cell(value, numeric, suffix, short=field not in NO_SHORTEN)
+ )
+ klass = f' class="{css_row}"' if css_row else ""
+ rows.append(f"
{''.join(cells)}
")
+
+ head = '
Zdjęcie
' + "".join(
+ f'
{html.escape(title)}
'
+ for title, _, _ in COLUMNS
+ )
+ table = (
+ '
'
+ f"{head}
{''.join(rows)}
"
+ )
+
+ summary = ""
+ if regions:
+ items = []
+ for region in regions:
+ budget = (
+ f'Budżet grupy: '
+ f"{html.escape(region.budget)}"
+ if region.budget
+ else ""
+ )
+ conclusion = (
+ f"
{render_inline(region.conclusion)}
" if region.conclusion else ""
+ )
+ items.append(
+ f'
Wszystkie {len(offers)} rozważanych noclegów w jednej '
+ "tabeli — dane prosto z frontmatterów kart. Kliknij nagłówek, żeby "
+ "posortować; domyślnie od najtańszej rodziny 2+2.
\n"
+ f"{summary}\n"
+ "
Wszystkie oferty
\n"
+ f"{table}\n"
+ '
cena zweryfikowana '
+ ' szacunek do potwierdzenia
'
+ )
+ return _page_shell(
+ "Porównanie ofert", 0, content, body_class="wide", script=SORT_JS
+ )
+
+
+# --- landing decyzyjny ------------------------------------------------
+
+# start.html jest serwowany i jako /index.html, i jako /pages/start.html —
+# tylko ścieżki root-absolutne działają pod obiema (patrz docstring modułu).
+TILES = [
+ ("/pages/porownanie.html", "Porównanie ofert",
+ "Wszystkie noclegi w jednej sortowalnej tabeli: ceny, status ceny, "
+ "priorytet, odległość od wyciągu."),
+ ("/pages/wyjazd.html", "Analiza wariantów",
+ "Cztery regiony obok siebie: skipassy, dojazd, budżet całkowity grupy "
+ "i wnioski przekrojowe."),
+ ("/viz.html", "Graf powiązań",
+ "Struktura bazy wiedzy — koncepty, linki między nimi i adresy "
+ "zewnętrzne. Załącznik, nie punkt wejścia."),
+]
+
+
+def render_start(concepts: list[Concept], regions: list[Region]) -> str:
+ tiles = "".join(
+ f'
Mail (szablon EN): 4 apartamenty, 16–23.01.2027, konfiguracja 4+4+3+3, cena grupowa.
Alternatywy w Kappl, jeśli brak miejsc: Apartments Kappl (apartments.kappl.at — skibus 100 m, narciarnia z suszarką), Ferienwohnen Mattle (centrum, blisko trasy i skibusa), Apart Avenzio (3 min od gondoli Kappl).