#!/usr/bin/env python3 """Renderuje karty HTML konceptów bundle'a do katalogu pages/. Wejściem dla człowieka jest `pages/start.html` (landing decyzyjny), a nie graf: graf to załącznik pokazujący strukturę powiązań. Karty pokazują treść — ceny, dostępność, plusy/minusy, listy do zrobienia. Węzeł w grafie linkuje do karty przez pole `resource` (patrz gen_viz.py). Poza kartą na koncept skrypt generuje trzy strony zbiorcze: pages/index.html spis wszystkich kart, pogrupowany katalogami pages/porownanie.html jedna tabela WSZYSTKICH aktualnych ofert (nie regionów), sortowalna klikiem, złożona z samych frontmatterów; na górze podsumowanie per region z `wyjazd.md`, na dole sekcja „Archiwum” z kartami `priorytet: ARCHIWALNA` (riscercz na poprzedni termin) pages/start.html landing: trzy kafle (porównanie / analiza / graf) plus linki do kart czterech regionów `start.html` jest jedyną stroną z linkami root-absolutnymi (`/viz.html`, `/pages/...`), bo ten sam plik jest serwowany pod dwiema ścieżkami: jako `/index.html` (korzeń serwisu) i jako `/pages/start.html`. Ścieżki względne działałyby tylko pod jedną z nich. Wymaga to serwowania bundle'a z korzenia domeny — lokalnie `python3 -m http.server` w katalogu bundle'a, na PIHA korzeń wolumenu (komenda deployu na końcu wypisu skryptu). Renderer markdown: używa `python-markdown`, jeśli jest zainstalowany (rozszerzenia tables + fenced_code + sane_lists); w przeciwnym razie własnego renderera na samej bibliotece standardowej — obsługuje nagłówki, akapity, tabele GFM, listy (w tym `- [ ]`), cytaty, bloki kodu, linie poziome oraz inline: **bold**, *kursywa*, `kod`, [linki](...). Który tor zadziałał, skrypt wypisuje przy uruchomieniu. Pliki .md NIE są modyfikowane — to czysty generator, tak samo jak gen_viz.py. Pliki zarezerwowane (index.md, log.md) nie są konceptami (§3.1) i nie dostają kart; linki do nich prowadzą do pages/index.html. Uruchomienie: python3 gen_pages.py """ from __future__ import annotations import html import io import posixpath import re import shutil import sys import zipfile from dataclasses import dataclass from pathlib import Path import yaml BUNDLE_ROOT = Path(__file__).resolve().parent PAGES_DIRNAME = "pages" PAGES_DIR = BUNDLE_ROOT / PAGES_DIRNAME BUNDLE_NAME = "Ski 2027 — wyjazd 6–13.02.2027" # Termin wyjazdu w jednym miejscu: leci do nagłówków stron i do raportu # braków. Zmiana terminu = zmiana tych dwóch stałych i regeneracja. TERM_LABEL = "6–13.02.2027" TERM_SHORT = "6–13.02" # nagłówek kolumny w tabeli porównawczej TERM_NOTE = "tydzień Fasching, szczyt sezonu" RESERVED = {"index.md", "log.md"} # Pobieralna paczka źródeł dla AI (patrz sekcja niżej): same .md bundle'a # plus wygenerowany AI-README.md, bez stron, zdjęć i generatorów. ZIP_NAME = "ski-2027-kb.zip" ZIP_ROOT = "ski-2027-kb" # katalog wewnątrz archiwum, żeby się nie rozsypało AI_README = "AI-README.md" # Stały czas modyfikacji wpisów: archiwum ma być bajt w bajt identyczne, # dopóki nie zmieni się treść — inaczej każda regeneracja robiłaby gitowi # diff z niczego. 1980-01-01 to minimum formatu ZIP. ZIP_TIMESTAMP = (1980, 1, 1, 0, 0, 0) # Publiczny adres wystawki — jedyne miejsce, w którym siedzi domena. Używa go # WYŁĄCZNIE pages/linki.html (spis do kopiowania i wysyłania grupie); wszystkie # pozostałe strony linkują względnie albo root-absolutnie, żeby działały tak # samo lokalnie (python3 -m http.server) i po deployu. Zmiana domeny = zmiana # tej jednej stałej i regeneracja. BASE_URL = "https://narty27.kapala.org" try: # renderer preferowany, jeśli dostępny w systemie import markdown as _markdown_lib except ModuleNotFoundError: _markdown_lib = None RENDERER = "python-markdown" if _markdown_lib else "fallback (biblioteka standardowa)" # --- model ------------------------------------------------------------ @dataclass class Concept: id: str # np. "oferty/glemm-lodge" path: Path frontmatter: dict body: str @property def page(self) -> str: """Ścieżka karty względem katalogu pages/.""" return f"{self.id}.html" @property def title(self) -> str: return str(self.frontmatter.get("title") or self.id) def load_concepts() -> list[Concept]: concepts: list[Concept] = [] for path in sorted(BUNDLE_ROOT.rglob("*.md")): if ".git" in path.parts or path.name in RESERVED: continue text = path.read_text(encoding="utf-8") frontmatter: dict = {} body = text if text.startswith("---\n"): end = text.find("\n---", 3) if end != -1: frontmatter = yaml.safe_load(text[4:end]) or {} body = text[end + 4 :] concepts.append( Concept( id=path.relative_to(BUNDLE_ROOT).with_suffix("").as_posix(), path=path, frontmatter=frontmatter, body=body.lstrip("\n"), ) ) return concepts # --- linki ------------------------------------------------------------ _LINK_TARGET_RE = re.compile(r"\]\(([^)\s]+\.md)((?:#[^)\s]*)?)\)") def rewrite_links(body: str, source: Concept, pages: dict[str, str]) -> str: """Linki do plików .md → względne ścieżki do kart. External bez zmian.""" here = posixpath.dirname(source.page) def repl(match: re.Match) -> str: target, fragment = match.group(1), match.group(2) if "://" in target: return match.group(0) if target.startswith("/"): rel = target[1:] else: rel = posixpath.normpath(posixpath.join(posixpath.dirname(source.id), target)) concept_id = rel[:-3] if rel.endswith(".md") else rel if concept_id in pages: dest = pages[concept_id] elif posixpath.basename(rel) in RESERVED: dest = "index.html" # index.md / log.md nie mają kart else: return match.group(0) return "](" + posixpath.relpath(dest, here or ".") + fragment + ")" return _LINK_TARGET_RE.sub(repl, body) # --- renderer markdown: fallback na stdlib ----------------------------- _CODE_RE = re.compile(r"`([^`]+)`") _LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)\s]+)\)") _BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.S) _ITALIC_STAR_RE = re.compile(r"(? str: codes: list[str] = [] def stash(match: re.Match) -> str: codes.append(match.group(1)) return f"\x00c{len(codes) - 1}\x00" text = _CODE_RE.sub(stash, text) text = html.escape(text, quote=False) def link(match: re.Match) -> str: label, href = match.group(1), match.group(2) attrs = "" if "://" in href or href.startswith("mailto:"): attrs = ' target="_blank" rel="noopener" class="ext"' return f'{label}' text = _LINK_RE.sub(link, text) text = _BOLD_RE.sub(r"\1", text) text = _ITALIC_STAR_RE.sub(r"\1", text) text = _ITALIC_UNDER_RE.sub(r"\1", text) for i, code in enumerate(codes): text = text.replace(f"\x00c{i}\x00", f"{html.escape(code, quote=False)}") return text def _alignments(sep: str) -> list[str]: out = [] for cell in _cells(sep): left, right = cell.startswith(":"), cell.endswith(":") out.append("center" if left and right else "right" if right else "left") return out def _cells(row: str) -> list[str]: return [c.strip() for c in row.strip().strip("|").split("|")] def _starts_block(line: str) -> bool: s = line.strip() return ( not s or bool(_HEADING_RE.match(s)) or bool(_HR_RE.match(s)) or s.startswith(("```", ">")) or bool(_LIST_RE.match(line)) ) def render_blocks(text: str) -> str: lines = text.split("\n") out: list[str] = [] i = 0 while i < len(lines): line = lines[i] stripped = line.strip() if not stripped: i += 1 continue if stripped.startswith("```"): i += 1 code: list[str] = [] while i < len(lines) and not lines[i].strip().startswith("```"): code.append(lines[i]) i += 1 i += 1 out.append(f"
{html.escape(chr(10).join(code), quote=False)}
") continue heading = _HEADING_RE.match(stripped) if heading: level = len(heading.group(1)) out.append(f"{render_inline(heading.group(2))}") i += 1 continue if _HR_RE.match(stripped): out.append("
") i += 1 continue if "|" in stripped and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]): header = _cells(stripped) align = _alignments(lines[i + 1]) i += 2 rows = [] while i < len(lines) and "|" in lines[i] and lines[i].strip(): rows.append(_cells(lines[i])) i += 1 head = "".join( f'' f"{render_inline(c)}" for n, c in enumerate(header) ) body = "".join( "" + "".join( f'' f"{render_inline(c)}" for n, c in enumerate(row) ) + "" for row in rows ) out.append( f'
{head}' f"{body}
" ) continue if stripped.startswith(">"): quoted = [] while i < len(lines) and lines[i].strip().startswith(">"): quoted.append(lines[i].strip()[1:].lstrip()) i += 1 out.append(f"
{render_blocks(chr(10).join(quoted))}
") continue item = _LIST_RE.match(line) if item: ordered = item.group(1)[0].isdigit() items: list[str] = [] while i < len(lines): current = lines[i] match = _LIST_RE.match(current) if match: items.append(match.group(2)) i += 1 elif current.startswith((" ", "\t")) and current.strip() and items: items[-1] += " " + current.strip() i += 1 else: break rendered = [] for raw in items: task = _TASK_RE.match(raw) if task: checked = " checked" if task.group(1).lower() == "x" else "" rendered.append( f'
  • ' f"{render_inline(task.group(2))}
  • " ) else: rendered.append(f"
  • {render_inline(raw)}
  • ") tag = "ol" if ordered else "ul" out.append(f"<{tag}>{''.join(rendered)}") continue paragraph = [stripped] i += 1 while i < len(lines) and not _starts_block(lines[i]): if "|" in lines[i] and i + 1 < len(lines) and _TABLE_SEP_RE.match(lines[i + 1]): break paragraph.append(lines[i].strip()) i += 1 out.append(f"

    {render_inline(' '.join(paragraph))}

    ") return "\n".join(out) def md_to_html(text: str) -> str: if _markdown_lib: return _markdown_lib.markdown( text, extensions=["tables", "fenced_code", "sane_lists"] ) return render_blocks(text) # --- szablon ---------------------------------------------------------- CSS = """ :root { color-scheme: light dark; --fg:#0f172a; --muted:#64748b; --bg:#ffffff; --line:#e2e8f0; --accent:#0d9488; --chip:#f1f5f9; --quote:#f8fafc; } @media (prefers-color-scheme: dark) { :root { --fg:#e2e8f0; --muted:#94a3b8; --bg:#0f172a; --line:#334155; --accent:#2dd4bf; --chip:#1e293b; --quote:#1e293b; } } * { box-sizing: border-box; } body { margin: 0 auto; padding: 1.25rem 1rem 4rem; max-width: 46rem; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 17px; line-height: 1.55; color: var(--fg); background: var(--bg); -webkit-text-size-adjust: 100%; } nav { font-size: .85rem; margin-bottom: 1.25rem; } nav a { color: var(--muted); } h1 { font-size: 1.55rem; line-height: 1.25; margin: 0 0 .35rem; } h2 { font-size: 1.2rem; margin: 2rem 0 .5rem; padding-top: .5rem; border-top: 1px solid var(--line); } h3 { font-size: 1.02rem; margin: 1.4rem 0 .4rem; } p, ul, ol { margin: .6rem 0; } ul, ol { padding-left: 1.3rem; } li { margin: .25rem 0; } li.task { list-style: none; margin-left: -1.3rem; } li.task input { margin-right: .45rem; } a { color: var(--accent); } a.ext::after { content: " ↗"; font-size: .8em; color: var(--muted); } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .88em; background: var(--chip); padding: .1em .35em; border-radius: 4px; } pre { background: var(--chip); padding: .75rem; border-radius: 6px; overflow-x: auto; } pre code { background: none; padding: 0; } blockquote { margin: .9rem 0; padding: .6rem .9rem; background: var(--quote); border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; } blockquote p { margin: .2rem 0; } hr { border: 0; border-top: 1px solid var(--line); margin: 1.5rem 0; } .table-wrap { overflow-x: auto; margin: .8rem 0; -webkit-overflow-scrolling: touch; } table { border-collapse: collapse; width: 100%; font-size: .92rem; } th, td { border: 1px solid var(--line); padding: .4rem .55rem; vertical-align: top; } th { background: var(--chip); font-weight: 600; } .chip { display: inline-block; background: var(--chip); color: var(--muted); font-size: .72rem; text-transform: uppercase; letter-spacing: .04em; padding: .15rem .5rem; border-radius: 999px; } .lead { color: var(--muted); margin: .35rem 0 1.25rem; } .meta { margin: 0 0 1.5rem; } .meta summary { cursor: pointer; font-size: .85rem; color: var(--muted); padding: .3rem 0; } .meta table { font-size: .85rem; } .meta th { width: 38%; text-align: left; font-weight: 500; word-break: break-word; } .cards { list-style: none; padding: 0; margin: 0; } .cards li { border: 1px solid var(--line); border-radius: 8px; padding: .8rem .9rem; margin: .6rem 0; } .cards a { font-weight: 600; text-decoration: none; } .cards p { margin: .3rem 0 0; font-size: .9rem; color: var(--muted); } footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line); font-size: .78rem; color: var(--muted); } /* landing: kafle i lista regionów */ body.wide { max-width: 72rem; } .tiles, .regions { list-style: none; padding: 0; } .tiles { margin: 1.25rem 0 2rem; display: grid; gap: .75rem; } .regions { margin: .6rem 0 0; display: grid; gap: .5rem; } @media (min-width: 34rem) { .tiles { grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); } .regions { grid-template-columns: repeat(2, 1fr); } } .tiles a, .regions a { display: block; border: 1px solid var(--line); border-radius: 10px; padding: .9rem 1rem; text-decoration: none; color: var(--fg); } .tiles a { font-size: 1.05rem; font-weight: 600; } .tiles a:hover, .tiles a:focus, .regions a:hover, .regions a:focus { border-color: var(--accent); } .tiles small, .regions small { display: block; margin-top: .3rem; font-weight: 400; font-size: .85rem; color: var(--muted); } .regions a { font-weight: 600; } /* podsumowanie per region na karcie porównania */ .summary { list-style: none; padding: 0; margin: .8rem 0 2rem; display: grid; gap: .6rem; } @media (min-width: 48rem) { .summary { grid-template-columns: repeat(2, 1fr); } } .summary li { border: 1px solid var(--line); border-left: 3px solid var(--accent); border-radius: 8px; padding: .65rem .85rem; } .summary .budget { font-size: .95rem; } .summary p { margin: .35rem 0 0; font-size: .88rem; color: var(--muted); } /* tabela porównawcza: sortowanie klikiem + kolor statusu ceny */ table.sortable th { cursor: pointer; user-select: none; white-space: nowrap; } table.sortable th::after { content: " ↕"; font-size: .8em; opacity: .35; } table.sortable th[data-dir="asc"]::after { content: " ↑"; opacity: 1; } table.sortable th[data-dir="desc"]::after { content: " ↓"; opacity: 1; } table.sortable td { font-size: .88rem; padding: .35rem .5rem; } table.sortable td[data-num] { white-space: nowrap; } table.sortable .name { font-weight: 600; } tr.verified td { background: rgba(13, 148, 136, .14); } tr.estimate td { background: rgba(100, 116, 139, .10); color: var(--muted); } tr.estimate td.name a, tr.verified td.name a { color: var(--accent); } /* zweryfikowany BRAK miejsc bije kolor statusu ceny — reguła niżej wygrywa */ tr.unavailable td { background: rgba(220, 38, 38, .16); } tr.unavailable td.name a { text-decoration: line-through; } td.avail { white-space: nowrap; font-weight: 600; } .archive { margin-top: 2.5rem; } .archive h2 { margin-bottom: .2rem; } .archive .lead { font-size: .88rem; margin-top: .2rem; } .legend { font-size: .82rem; color: var(--muted); margin: .5rem 0 0; } .legend .box { display: inline-block; width: .8em; height: .8em; border-radius: 3px; border: 1px solid var(--line); vertical-align: -.05em; margin-right: .25em; } .legend .box.v { background: rgba(13, 148, 136, .35); } .legend .box.e { background: rgba(100, 116, 139, .25); } .legend .box.u { background: rgba(220, 38, 38, .35); } /* miniatury w tabeli */ td.thumb { padding: .25rem; width: 128px; } td.thumb img { width: 120px; height: 80px; object-fit: cover; border-radius: 6px; display: block; } td.thumb .placeholder { display: flex; width: 120px; height: 80px; border-radius: 6px; align-items: center; justify-content: center; background: var(--chip); color: var(--muted); font-weight: 700; letter-spacing: .05em; text-decoration: none; } /* galeria na karcie oferty */ .gallery { margin: 0 0 1.25rem; } .gallery-main img { width: 100%; max-height: 24rem; object-fit: cover; border-radius: 10px; display: block; background: var(--chip); } .gallery-thumbs { display: flex; gap: .4rem; margin-top: .4rem; overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: .2rem; } .gallery-thumbs button { flex: 0 0 auto; padding: 0; border: 2px solid transparent; border-radius: 6px; background: none; cursor: pointer; } .gallery-thumbs button[aria-current] { border-color: var(--accent); } .gallery-thumbs img { width: 5.5rem; height: 3.6rem; object-fit: cover; border-radius: 4px; display: block; } /* spis linków do kopiowania */ .copybar { display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; margin: 1rem 0 1.5rem; } .copybar button { font: inherit; font-size: .9rem; padding: .45rem .9rem; border: 1px solid var(--accent); border-radius: 8px; background: var(--accent); color: #f8fafc; cursor: pointer; } .copybar button + button { background: none; color: var(--accent); } .copybar button:hover { filter: brightness(1.08); } .copy-status { font-size: .85rem; color: var(--muted); } .links { list-style: none; padding: 0; margin: .5rem 0 0; } .links li { padding: .55rem 0; border-bottom: 1px solid var(--line); } .links li:last-child { border-bottom: 0; } .links a { font-weight: 600; text-decoration: none; } .links code.url { display: block; margin: .2rem 0; font-size: .82rem; background: none; padding: 0; color: var(--muted); word-break: break-all; user-select: all; } .links p { margin: 0; font-size: .85rem; color: var(--muted); } /* kafel regionu ze zdjęciem w tle */ .regions a.hero { background-size: cover; background-position: center; border-color: transparent; color: #f8fafc; min-height: 6.5rem; display: flex; flex-direction: column; justify-content: flex-end; } .regions a.hero small { color: #e2e8f0; } """ SORT_JS = """ document.querySelectorAll('table.sortable').forEach(function (table) { var head = table.tHead.rows[0]; Array.prototype.forEach.call(head.cells, function (th, i) { th.tabIndex = 0; th.setAttribute('role', 'button'); th.addEventListener('click', function () { sortTable(table, i, th); }); th.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); sortTable(table, i, th); } }); }); }); function sortTable(table, index, th) { var dir = th.dataset.dir === 'asc' ? 'desc' : 'asc'; Array.prototype.forEach.call(table.tHead.rows[0].cells, function (h) { delete h.dataset.dir; h.setAttribute('aria-sort', 'none'); }); th.dataset.dir = dir; th.setAttribute('aria-sort', dir === 'asc' ? 'ascending' : 'descending'); var body = table.tBodies[0]; var rows = Array.prototype.slice.call(body.rows); var mul = dir === 'asc' ? 1 : -1; rows.sort(function (a, b) { var x = a.cells[index], y = b.cells[index]; var nx = x.dataset.num, ny = y.dataset.num; if (nx !== undefined && ny !== undefined) return (parseFloat(nx) - parseFloat(ny)) * mul; // Wiersze bez wartości liczbowej zawsze na końcu — w obie strony. if (nx !== undefined) return -1; if (ny !== undefined) return 1; var kx = x.dataset.key !== undefined ? x.dataset.key : x.textContent; var ky = y.dataset.key !== undefined ? y.dataset.key : y.textContent; if (!kx) return 1; if (!ky) return -1; return kx.localeCompare(ky, 'pl') * mul; }); rows.forEach(function (row) { body.appendChild(row); }); } """ def _page_shell( title: str, depth: int, content: str, *, body_class: str = "", nav: str | None = None, script: str = "", ) -> str: up = "../" * depth if nav is None: nav = ( f'← Start · ' f'Wszystkie karty · ' f'Porównanie ofert · ' f'Linki · ' f'Graf' ) css_class = f' class="{body_class}"' if body_class else "" script_tag = f"\n" if script else "" return f""" {html.escape(title)} — {html.escape(BUNDLE_NAME)} {content} {script_tag} """ def _meta_table(frontmatter: dict) -> str: rows = [] for key, value in frontmatter.items(): if isinstance(value, list): shown = ", ".join(_link_value(str(v)) for v in value) else: shown = _link_value(str(value)) rows.append( f"{html.escape(str(key))}{shown}" ) return ( '
    Metadane (frontmatter)' f'
    {"".join(rows)}
    ' "
    " ) _URLISH_RE = re.compile( r"^(https?://\S+|(?:[a-z0-9][a-z0-9-]*\.)+(?:at|com|pl|de|eu|net|org|io|info))$" ) def _link_value(value: str) -> str: """Wartość frontmattera; adresy robi klikalnymi, resztę tylko escape'uje.""" escaped = html.escape(value, quote=False) if _URLISH_RE.match(value.strip()): href = value.strip() if "://" not in href: href = "https://" + href return f'{escaped}' if value.strip().startswith("mailto:") or "@" in value and " " not in value.strip(): return f'{escaped}' return escaped def render_page(concept: Concept, pages: dict[str, str]) -> str: body = rewrite_links(concept.body, concept, pages) rendered = md_to_html(body) # Pierwszy nagłówek H1 body dubluje tytuł karty — awansujemy go na

    # strony zamiast dokładać drugi. Sam `title` i tak jest w tabelce metadanych. match = re.match(r"\s*

    (.*?)

    ", rendered, re.S) if match: headline = match.group(1) rendered = rendered[match.end() :] else: headline = html.escape(concept.title) frontmatter = concept.frontmatter chip = html.escape(str(frontmatter.get("type") or "—")) description = str(frontmatter.get("description") or "") lead = f'

    {html.escape(description)}

    ' 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{gallery}\n" f"{_meta_table(frontmatter)}\n{rendered}" ) 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) def group_heading(dirname: str) -> str: """Nagłówek grupy = pierwszy nagłówek `index.md` katalogu (§8). Indexy podkatalogów same nazywają swoją zawartość, więc karta zbiorcza nie musi mieć zaszytej listy katalogów — dorzucenie nowego regionu do bundle'a wystarczy, żeby pojawił się tu z własną nazwą. """ index = BUNDLE_ROOT / dirname / "index.md" if index.is_file(): match = _INDEX_HEADING_RE.search(index.read_text(encoding="utf-8")) if match: return match.group(1).strip() return dirname def render_index(concepts: list[Concept]) -> str: """Karta zbiorcza: koncepty z korzenia, potem grupa na katalog najwyższego poziomu. Każdy koncept bundle'a ląduje w dokładnie jednej grupie.""" by_dir: dict[str, list[Concept]] = {} for concept in concepts: if "/" in concept.id: by_dir.setdefault(concept.id.split("/", 1)[0], []).append(concept) groups: list[tuple[str, list[Concept]]] = [ ("Wyjazd i wiedza ogólna", [c for c in concepts if "/" not in c.id]) ] for dirname in sorted(by_dir): # region.md przed oferty/* — płycej w drzewie znaczy ogólniej. items = sorted(by_dir[dirname], key=lambda c: (c.id.count("/"), c.id)) groups.append((group_heading(dirname), items)) sections = [] for heading, items in groups: if not items: continue cards = [] for concept in items: description = str(concept.frontmatter.get("description") or "") status = concept.frontmatter.get("status_ceny") badge = ( f'{html.escape(str(status).split("—")[0].strip())} ' if status else "" ) cards.append( f'
  • ' f"{html.escape(concept.title)} " f'{badge}{html.escape(str(concept.frontmatter.get("type") or "—"))}' f"

    {html.escape(description)}

  • " ) sections.append(f"

    {heading}

    \n") content = ( f"

    {html.escape(BUNDLE_NAME)}

    \n" '

    Karty konceptów bundle\'a — pełna treść każdego dokumentu ' "(ceny, dostępność, plusy/minusy). Struktura powiązań: " 'graf.

    \n' + "\n".join(sections) ) 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'' return ( '" ) # --- 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), (f"Dostępność {TERM_SHORT}", "_dostepnosc", 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") # --- dostępność terminu (pole `dostepnosc_6_13_02_2027`) --------------- AVAILABILITY_FIELD = "dostepnosc_6_13_02_2027" # (emoji z legendy, skrót do komórki, klucz sortowania). Kolejność kluczy # układa kolumnę decyzyjnie: potwierdzone wolne → do sprawdzenia → brak. AVAILABILITY_MARKS = ( ("✅", "✅ DOSTĘPNE", "1"), ("🔍", "🔍 do sprawdzenia", "2"), ("❌", "❌ BRAK (zweryf.)", "3"), ) def availability(concept: Concept) -> str: return str(concept.frontmatter.get(AVAILABILITY_FIELD) or "").strip() def availability_mark(value: str) -> tuple[str, str, str] | None: for mark in AVAILABILITY_MARKS: if value.startswith(mark[0]): return mark return None def _availability_cell(value: str) -> str: """Komórka dostępności: emoji + jedno słowo, pełna treść w `title`. Wartości z frontmattera są zdaniami („🔍 BRAK DANYCH — silnik online pokaże od ręki”), a tabela ma się skanować wzrokiem — więc w komórce zostaje sam werdykt, a uzasadnienie czeka pod kursorem. """ if not value: return '—' mark = availability_mark(value) if not mark: return _cell(value, False) _, short, key = mark return ( f'{html.escape(short)}' ) def is_archived(concept: Concept) -> bool: """Karty z riserczu na poprzedni termin — poza tabelą i statusami.""" return str(concept.frontmatter.get("priorytet") or "").upper().startswith( "ARCHIWALNA" ) 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 _offer_rows(offers: list[Concept], by_dir: dict[str, Region]) -> str: """Wiersze tabeli porównawczej dla podanych ofert.""" rows = [] for concept in offers: frontmatter = concept.frontmatter status = str(frontmatter.get("status_ceny") or "") upper = status.upper() classes = [ "verified" if "ZWERYFIKOWANE" in upper else "estimate" if "SZACUNEK" in upper else "" ] # Zweryfikowany brak miejsc to najmocniejszy komunikat w wierszu — # ważniejszy niż to, czy cena jest pewna. if availability(concept).startswith("❌"): classes.append("unavailable") 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'' f'{html.escape(name)}' ) elif field == "_region": page = html.escape(f"{region.concept_id}.html", quote=True) if region else "" label = html.escape(region_label) shown = f'{label}' if region else label cells.append( f'{shown}' ) elif field == "_dostepnosc": cells.append(_availability_cell(availability(concept))) elif field == "www": url = str(frontmatter.get("www") or "").strip() if url: host = re.sub(r"^https?://(www\.)?", "", url).split("/")[0] cells.append( f'' f'{html.escape(host)}' ) else: cells.append('—') 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) ) css_row = " ".join(c for c in classes if c) klass = f' class="{css_row}"' if css_row else "" rows.append(f"{''.join(cells)}") return "".join(rows) def _comparison_table(offers: list[Concept], by_dir: dict[str, Region]) -> str: head = 'Zdjęcie' + "".join( f'{html.escape(title)}' for title, _, _ in COLUMNS ) return ( '
    ' f"{head}{_offer_rows(offers, by_dir)}" "
    " ) def _by_price(concept: Concept) -> tuple[float, str]: # Domyślna kolejność: najtańsza rodzina 2+2 na górze — pierwsze pytanie, # które grupa zadaje. Resztę porządków daje sortowanie klikiem. return ( sort_number(str(concept.frontmatter.get("koszt_rodzina_2p2") or "")) or 1e9, concept.title, ) def render_comparison(concepts: list[Concept], regions: list[Region]) -> str: """Jedna tabela wszystkich aktualnych ofert bundle'a, prosto z frontmatterów. Karty archiwalne (riscercz na poprzedni termin) idą do osobnej tabeli na dole strony — nie mieszają się do porównania ani do statusów dostępności, ale nie znikają z bazy. """ by_dir = {r.dirname: r for r in regions} offers = sorted((c for c in concepts if is_offer(c)), key=_by_price) active = [c for c in offers if not is_archived(c)] archived = [c for c in offers if is_archived(c)] table = _comparison_table(active, by_dir) counts: dict[str, int] = {} for concept in active: mark = availability_mark(availability(concept)) counts[mark[1] if mark else "—"] = counts.get(mark[1] if mark else "—", 0) + 1 tally = " · ".join(f"{label}: {n}" for label, n in sorted(counts.items())) archive = "" if archived: archive = ( '
    \n' "

    Archiwum — poza paczką riserczową 08.2026

    \n" f'

    Karty z riserczu na poprzedni termin ' f"(16–23.01.2027). Ceny i dostępność nie były weryfikowane na " f"{html.escape(TERM_LABEL)} — nie wchodzą do porównania wyżej ani " "do statusów dostępności. Zostają, bo tropy są nadal dobre.

    \n" f"{_comparison_table(archived, by_dir)}\n" "
    " ) 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'
  • ' f"{html.escape(region.label)}
    {budget}{conclusion}
  • " ) summary = ( "

    Regiony w skrócie

    \n" '
      ' + "".join(items) + "
    " ) content = ( "

    Porównanie ofert

    \n" f'

    Wszystkie {len(active)} rozważanych noclegów na termin ' f"{html.escape(TERM_LABEL)} ({html.escape(TERM_NOTE)}) " "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 ' '  zweryfikowany brak miejsc
    ' f"Dostępność {html.escape(TERM_SHORT)}: ✅ DOSTĘPNE (zweryfikowane) · " "🔍 do sprawdzenia (obiekt nie publikuje kalendarza) · " "❌ BRAK (zweryfikowane). " f"Bilans: {html.escape(tally)}. Najedź na komórkę, żeby zobaczyć " "pełną notatkę.

    \n" f"{archive}" ) 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: dostępność na " f"{TERM_SHORT}, 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."), ("/pages/linki.html", "Wszystkie linki", "Pełne adresy wszystkich stron wystawki, z przyciskiem „kopiuj” — " "do wrzucenia grupie."), (f"/{PAGES_DIRNAME}/{ZIP_NAME}", "Pobierz bazę (.zip) — wrzuć do ChatGPT/Claude'a i pytaj", "Wszystkie źródła wiedzy w markdownie, bez stron i zdjęć — model " "przeczyta całość i odpowie na pytania o wyjazd."), ] def render_start(concepts: list[Concept], regions: list[Region]) -> str: tiles = "".join( f'
  • {html.escape(title)}' f"{html.escape(description)}
  • " for href, title, description in TILES ) by_id = {c.id: c for c in concepts} cards = [] for region in regions: concept = by_id.get(region.concept_id) description = region.budget or ( str(concept.frontmatter.get("description") or "") if concept else "" ) style = "" hero = region_hero(concepts, region) if hero: style = ( ' class="hero" style="background-image:linear-gradient(' "rgba(15,23,42,.62),rgba(15,23,42,.72)),url('" f"/pages/{html.escape(hero, quote=True)}')\"" ) label = html.escape(region.label) budget = ( f"Budżet grupy: {html.escape(region.budget)}" if region.budget else f"{html.escape(description)}" ) cards.append( f'
  • {label}{budget}
  • " ) content = ( f"

    {html.escape(BUNDLE_NAME)}

    \n" f'

    {html.escape(TERM_LABEL)} ' f"({html.escape(TERM_NOTE)}), 4 rodziny / 14 osób, wyjazd z Warszawy. " "Od czego zacząć:

    \n" f'
      {tiles}
    \n' "

    Regiony

    \n" '

    Karta regionu = skipassy, dojazd, terminy, koszty ' "życia i budżet całkowity.

    \n" f'
      {"".join(cards)}
    ' ) nav = ( 'Wszystkie karty · ' 'Porównanie ofert · ' 'Linki · ' 'Graf' ) return _page_shell("Start", 0, content, nav=nav) def region_hero(concepts: list[Concept], region: Region) -> str: """Pierwsze zdjęcie najlepszej oferty regionu (priorytet PLAN A).""" offers = [ c for c in concepts if is_offer(c) and posixpath.dirname(posixpath.dirname(c.id)) == region.dirname ] def rank(concept: Concept) -> tuple[int, str]: priority = str(concept.frontmatter.get("priorytet") or "").upper() for n, marker in enumerate(("PLAN A", "PLAN B", "PLAN C")): if priority.startswith(marker): return (n, concept.id) return (9, concept.id) for concept in sorted(offers, key=rank): images = offer_image_paths(concept) if images: return images[0] return "" # --- spis linków do wysłania ------------------------------------------ @dataclass class Link: url: str label: str description: str = "" def site_url(path: str) -> str: """Ścieżka w bundle'u → pełny adres publiczny.""" return f"{BASE_URL}/{path.lstrip('/')}" def _describe(concept: Concept | None, limit: int = 110) -> str: """Opis konceptu przycięty do jednej linijki — bez skracania po separatorze, bo w opisach nawias i myślnik niosą treść (inaczej niż w polach tabeli).""" if not concept: return "" text = str(concept.frontmatter.get("description") or "") if len(text) > limit: text = text[: limit - 1].rsplit(" ", 1)[0] + "…" return text def link_sections( concepts: list[Concept], regions: list[Region] ) -> list[tuple[str, list[Link]]]: """Spis wszystkich stron wystawki — z tych samych danych co reszta stron. Nic tu nie jest wypisane ręcznie: regiony biorą się z `wyjazd.md`, oferty z katalogów i frontmatterów, a „Pozostałe” to po prostu koncepty, których nie złapała żadna wcześniejsza sekcja. Nowa oferta czy piąty wariant pojawiają się na liście przy najbliższej regeneracji, same z siebie. """ by_id = {c.id: c for c in concepts} used: set[str] = set() entry = [ Link(site_url(""), "Start", "Landing decyzyjny — od tego zaczyna grupa."), Link( site_url("pages/porownanie.html"), "Porównanie ofert", "Wszystkie noclegi w jednej sortowalnej tabeli, ze zdjęciami.", ), Link( site_url("pages/wyjazd.html"), "Analiza wariantów", _describe(by_id.get("wyjazd")) or "Cztery regiony obok siebie.", ), Link( site_url("pages/index.html"), "Spis wszystkich kart", "Każdy dokument bazy wiedzy w jednym miejscu.", ), Link(site_url("viz.html"), "Graf powiązań", "Struktura bazy — załącznik."), Link(site_url("pages/linki.html"), "Ten spis linków", "Strona, którą czytasz."), Link( site_url(f"{PAGES_DIRNAME}/{ZIP_NAME}"), "Pobierz bazę (.zip) — wrzuć do ChatGPT/Claude'a i pytaj", "Wszystkie źródła wiedzy w markdownie, bez stron i zdjęć — model " "przeczyta całość i odpowie na pytania o wyjazd.", ), ] used.add("wyjazd") sections: list[tuple[str, list[Link]]] = [("Wejście", entry)] region_links = [] for region in regions: concept = by_id.get(region.concept_id) if not concept: continue used.add(region.concept_id) budget = f"Budżet grupy {region.budget}. " if region.budget else "" region_links.append( Link( site_url(f"{PAGES_DIRNAME}/{concept.page}"), region.label, budget + _describe(concept, 90), ) ) if region_links: sections.append(("Regiony", region_links)) for region in regions: offers = [ c for c in concepts if is_offer(c) and posixpath.dirname(posixpath.dirname(c.id)) == region.dirname ] if not offers: continue links = [] for concept in sorted(offers, key=_priority_rank): used.add(concept.id) priority = _shorten(str(concept.frontmatter.get("priorytet") or "")) place = str(concept.frontmatter.get("miejscowosc") or "") links.append( Link( site_url(f"{PAGES_DIRNAME}/{concept.page}"), str(concept.frontmatter.get("nazwa") or concept.title), " · ".join(part for part in (priority, place) if part), ) ) sections.append((f"Oferty — {region.label}", links)) rest = [ Link( site_url(f"{PAGES_DIRNAME}/{c.page}"), c.title, _describe(c), ) for c in concepts if c.id not in used ] rest.append( Link( site_url(f"{PAGES_DIRNAME}/start.html"), "Start pod własną ścieżką", "Ten sam landing co adres główny — przydaje się w zakładkach.", ) ) sections.append(("Pozostałe", rest)) return sections def _priority_rank(concept: Concept) -> tuple[int, str]: priority = str(concept.frontmatter.get("priorytet") or "").upper() for n, marker in enumerate(("PLAN A", "PLAN B", "PLAN C")): if priority.startswith(marker): return (n, concept.title) return (9, concept.title) LINKS_JS = """ function collectAll() { var lines = []; document.querySelectorAll('section[data-section]').forEach(function (section) { lines.push(section.dataset.section + ':'); section.querySelectorAll('.url').forEach(function (code) { lines.push(code.textContent.trim()); }); lines.push(''); }); return lines.join('\\n').trim() + '\\n'; } function say(message) { var status = document.querySelector('.copy-status'); status.textContent = message; window.setTimeout(function () { if (status.textContent === message) status.textContent = ''; }, 4000); } // navigator.clipboard istnieje tylko w bezpiecznym kontekście (https albo // localhost) — po http z adresu IP trzeba starą drogą przez zaznaczenie. function copyLegacy(text) { var area = document.createElement('textarea'); area.value = text; area.setAttribute('readonly', ''); area.style.position = 'fixed'; area.style.top = '-1000px'; document.body.appendChild(area); area.select(); var copied = false; try { copied = document.execCommand('copy'); } catch (error) { copied = false; } document.body.removeChild(area); return copied; } function copyText(text, what) { var ok = function () { say('Skopiowane: ' + what); }; var no = function () { say('Nie udało się skopiować — zaznacz adres ręcznie.'); }; if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(ok, function () { if (copyLegacy(text)) { ok(); } else { no(); } }); } else if (copyLegacy(text)) { ok(); } else { no(); } } document.getElementById('copy-all').addEventListener('click', function () { copyText(collectAll(), 'cała lista'); }); document.getElementById('copy-start').addEventListener('click', function () { copyText(this.dataset.url, 'link startowy'); }); """ def render_links(concepts: list[Concept], regions: list[Region]) -> str: sections = link_sections(concepts, regions) blocks = [] total = 0 for title, links in sections: if not links: continue items = [] for link in links: total += 1 escaped = html.escape(link.url, quote=True) description = ( f"

    {html.escape(link.description)}

    " if link.description else "" ) items.append( f'
  • {html.escape(link.label)}' f'{html.escape(link.url)}{description}
  • ' ) blocks.append( f'
    ' f"

    {html.escape(title)}

    " f'
    ' ) content = ( "

    Linki do wysłania

    \n" f'

    Wszystkie {total} stron wystawki jako pełne adresy — ' "do skopiowania i wrzucenia grupie. Lista powstaje z tych samych danych " "co reszta stron, więc nowa oferta pojawi się tu sama.

    \n" '
    ' '' f'" '' "
    \n" + "\n".join(blocks) ) return _page_shell("Linki", 0, content, script=LINKS_JS) # --- pobieralna paczka źródeł dla AI ---------------------------------- def bundle_sources() -> list[Path]: """Pliki .md bundle'a — z zachowaniem struktury katalogów.""" return sorted( path for path in BUNDLE_ROOT.rglob("*.md") if ".git" not in path.parts and PAGES_DIRNAME not in path.relative_to(BUNDLE_ROOT).parts ) def bundle_date(concepts: list[Concept]) -> str: """Data treści = najnowsze `ostatnia_aktualizacja` w bundle'u. Świadomie NIE bierzemy zegara: paczka ma być identyczna, dopóki nie zmieni się wiedza. Data z zegara robiłaby nową wersję pliku każdego dnia, także wtedy, gdy nic nie doszło. """ dates = [ str(c.frontmatter.get("ostatnia_aktualizacja") or "").strip() for c in concepts ] return max((d for d in dates if d), default="—") def render_ai_readme(concepts: list[Concept]) -> str: """AI-README.md do paczki — skrót index.md dla modelu po drugiej stronie.""" offers = [c for c in concepts if is_offer(c)] active = [c for c in offers if not is_archived(c)] regions = sorted( c.id.split("/")[0] for c in concepts if c.id.endswith("/region") ) date = bundle_date(concepts) return f"""# Ski 2027 — baza wiedzy wyjazdu ({TERM_LABEL}) ## PL To baza wiedzy wyjazdu narciarskiego czterech rodzin (14 osób: 8 dorosłych, 4 nastolatków, 2 dzieci) z Warszawy w Alpy w terminie **{TERM_LABEL}** ({TERM_NOTE}). Format to OKF: markdown z frontmatterem YAML, gdzie jeden plik = jeden koncept, `index.md` w każdym katalogu jest listingiem, a `log.md` historią zmian; porównywane są {len(regions)} regiony ({", ".join(regions)}) w schemacie `/region.md` + `/oferty/*.md`, razem {len(active)} aktualnych kart noclegów. Odpowiadaj na pytania o ten wyjazd **wyłącznie na podstawie tych plików** i podawaj, z którego pliku pochodzi odpowiedź. O pewności danych mówią dwa pola frontmattera: `status_ceny` (ZWERYFIKOWANE = odczytane z cennika obiektu, SZACUNEK = nasza estymacja, do potwierdzenia) oraz `{AVAILABILITY_FIELD}` ze statusem ✅ DOSTĘPNE (zweryfikowane) / ❌ BRAK (zweryfikowane) / 🔍 BRAK DANYCH (do weryfikacji). Nie przedstawiaj szacunków jako cen potwierdzonych i nie zgaduj dostępności tam, gdzie stoi 🔍. Karty z `priorytet: ARCHIWALNA` pochodzą z riserczu na poprzedni termin (16–23.01.2027) — traktuj je jako historyczne, nie jako aktualną ofertę. **Data treści (najnowsza aktualizacja w bazie): {date}.** ## EN This is the knowledge base of a ski trip for four families (14 people: 8 adults, 4 teenagers, 2 children) driving from Warsaw to the Alps on **{TERM_LABEL}** (carnival week, peak season). The format is OKF: markdown with YAML frontmatter, one file = one concept, `index.md` in each directory is a listing and `log.md` is the change history; {len(regions)} regions ({", ".join(regions)}) are compared using the same schema `/region.md` + `/oferty/*.md`, with {len(active)} current accommodation cards in total. Answer questions about this trip **only from these files** and say which file an answer comes from. Two frontmatter fields carry the confidence of the data: `status_ceny` (ZWERYFIKOWANE = read from the property's own price list, SZACUNEK = our estimate, unconfirmed) and `{AVAILABILITY_FIELD}` (✅ AVAILABLE, verified / ❌ NOT AVAILABLE, verified / 🔍 NO DATA, to be checked). Never present an estimate as a confirmed price and never guess availability where the value is 🔍. Cards marked `priorytet: ARCHIWALNA` come from research for the previous date (16–23.01.2027) — treat them as historical. **Content date (latest update in the base): {date}.** """ def write_bundle_zip(concepts: list[Concept]) -> Path: """Buduje pages/ski-2027-kb.zip — deterministycznie, bajt w bajt.""" entries: list[tuple[str, str]] = [ (f"{ZIP_ROOT}/{AI_README}", render_ai_readme(concepts)) ] for path in bundle_sources(): rel = path.relative_to(BUNDLE_ROOT).as_posix() entries.append((f"{ZIP_ROOT}/{rel}", path.read_text(encoding="utf-8"))) buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive: for name, text in sorted(entries): info = zipfile.ZipInfo(name, date_time=ZIP_TIMESTAMP) info.compress_type = zipfile.ZIP_DEFLATED info.external_attr = 0o644 << 16 archive.writestr(info, text.encode("utf-8")) target = PAGES_DIR / ZIP_NAME target.write_bytes(buffer.getvalue()) return target # --- raport braków ---------------------------------------------------- def missing_data(concepts: list[Concept]) -> list[tuple[str, list[str]]]: """Czego brakuje w ofertach — bez zgadywania, tylko z tego, co jest.""" report = [] for concept in concepts: if not is_offer(concept) or is_archived(concept): continue gaps = [] status = str(concept.frontmatter.get("status_ceny") or "") if not status: gaps.append("brak pola `status_ceny`") elif "SZACUNEK" in status.upper(): gaps.append(f"cena niepotwierdzona ({status})") # Dostępność czytamy z pola frontmattera, nie z nagłówka w tekście — # werdykt ✅/❌/🔍 jest danymi, a nie tym, czy ktoś napisał sekcję. value = availability(concept) if not value: gaps.append(f"brak pola `{AVAILABILITY_FIELD}`") elif value.startswith("🔍"): gaps.append(f"dostępność {TERM_LABEL} do weryfikacji ({_shorten(value)})") open_todos = len(re.findall(r"^\s*[-*]\s+\[ \]", concept.body, re.M)) if open_todos: gaps.append(f"otwartych pozycji „Do zrobienia”: {open_todos}") if gaps: report.append((concept.id, gaps)) return report # --- main ------------------------------------------------------------- DEPLOY_COMMAND = """ssh piha 'rm -rf /tmp/narty27-deploy && mkdir -p /tmp/narty27-deploy' \\ && scp -r viz.html pages piha:/tmp/narty27-deploy/ \\ && ssh piha 'docker run --rm -v narty27_narty27_content:/content \\ -v /tmp/narty27-deploy:/src:ro alpine sh -c \\ "rm -rf /content/pages && cp -r /src/viz.html /src/pages /content/ \\ && cp /src/pages/start.html /content/index.html && ls -la /content"' \\ && ssh piha 'rm -rf /tmp/narty27-deploy'""" def main() -> int: concepts = load_concepts() pages = {c.id: c.page for c in concepts} regions = parse_regions() # Katalog img/ jest wejściem, nie wyjściem tego skryptu (wypełnia go # gen_images.py) — czyścimy wszystko poza nim. PAGES_DIR.mkdir(parents=True, exist_ok=True) for entry in PAGES_DIR.iterdir(): if entry.name == IMG_DIRNAME: continue shutil.rmtree(entry) if entry.is_dir() else entry.unlink() written = [] for concept in concepts: out = PAGES_DIR / concept.page out.parent.mkdir(parents=True, exist_ok=True) out.write_text(render_page(concept, pages), encoding="utf-8") written.append(out) for name, markup in ( ("index.html", render_index(concepts)), ("porownanie.html", render_comparison(concepts, regions)), ("linki.html", render_links(concepts, regions)), ("start.html", render_start(concepts, regions)), ): path = PAGES_DIR / name path.write_text(markup, encoding="utf-8") written.append(path) written.append(write_bundle_zip(concepts)) print(f"Renderer markdown: {RENDERER}") print(f"Zapisano {len(written)} plików w {PAGES_DIR}:") for path in written: size = path.stat().st_size / 1024 print(f" {path.relative_to(BUNDLE_ROOT)} ({size:.1f} KiB)") print() offers = [c for c in concepts if is_offer(c)] with_photos = [c for c in offers if offer_image_paths(c)] print( f"Regiony z wyjazd.md: {len(regions)} " f"({', '.join(r.label for r in regions) or '—'})" ) active = [c for c in offers if not is_archived(c)] print( f"Oferty w tabeli porównawczej: {len(active)} " f"(+{len(offers) - len(active)} archiwalnych w osobnej sekcji), " f"ze zdjęciami: {len(with_photos)} (zdjęcia dokłada gen_images.py)" ) marks = [availability_mark(availability(c)) for c in active] print( f"Dostępność {TERM_LABEL}: " + " · ".join( f"{label}: {sum(1 for m in marks if m and m[1] == label)}" for _, label, _ in AVAILABILITY_MARKS ) + f" · bez pola: {sum(1 for m in marks if m is None)}" ) gaps = missing_data(concepts) print() if gaps: print(f"Oferty z brakami w danych ({len(gaps)}) — do uzupełnienia ręcznie:") for concept_id, items in gaps: print(f" ! {concept_id}") for item in items: print(f" – {item}") else: print("Braków w danych ofert nie wykryto.") print() print("Deploy na PIHA (korzeń serwisu = start.html, graf pod /viz.html):") print(DEPLOY_COMMAND) return 0 if __name__ == "__main__": raise SystemExit(main())